Compare commits

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 13:54:14 +02:00
amacocian 5bf0af61ba Fix commandline arguments with space (#1593) 2026-07-28 13:54:14 +02:00
amacocian 03ed31e85b Fix telemetry (Closes #1591) (#1592) 2026-07-28 13:54:14 +02:00
amacocian b21a4482bd Redact sensitive logs (#1590) 2026-07-28 13:54:14 +02:00
github-actions[bot]andamacocian 2ad8ae79ce Bump version to 0.9.10.15 2026-07-28 13:54:14 +02:00
amacocianandAlexandru Macocian a4be747dd5 Fix more notifications expiration times (#1587)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-07-18 07:00:00 +02:00
amacocianandAlexandru Macocian a6cd5d6f37 Update dependencies (#1585)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-07-18 07:00:00 +02:00
amacocianandAlexandru Macocian 0e80fb7d86 Update GWCA (#1584)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-07-18 07:00:00 +02:00
github-actions[bot]andamacocian 0793f181bb Bump version to 0.9.10.14 2026-07-18 07:00:00 +02:00
amacocian a9ee0424f7 FocusView browser maximize (Closes #1577) (#1578) 2026-07-03 18:45:45 +02:00
amacocian 49f45b6b76 Fix notifications disappearing too soon (Closes #1573) (#1576) 2026-07-03 18:45:45 +02:00
github-actions[bot]andamacocian 4216a03438 Bump version to 0.9.10.13 2026-07-03 18:45:45 +02:00
amacocianandAlexandru Macocian a9a8346a5b Fix character select (Closes #1561) (#1571)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-30 17:09:16 +02:00
amacocianandAlexandru Macocian 70a95b5e08 Executable list eviction limit only on invalid exes (#1570)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-30 17:09:16 +02:00
amacocianandAlexandru Macocian 1e8c2e44e2 Fix Kill command on Linux (Closes #1564) (#1569)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-30 17:09:16 +02:00
amacocianandAlexandru Macocian d553aadead Update skills after patch update (Closes #1565) (#1568)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-30 17:09:16 +02:00
amacocianandAlexandru Macocian fabb98f74e Improve exe matching on Linux (#1567)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-30 17:09:16 +02:00
amacocianandAlexandru Macocian 0a0ec5cefc Fix multibox support on Linux (#1566)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-30 17:09:16 +02:00
amacocianandAlexandru Macocian 0d31587855 Revert GWCA static linking (Closes #1562) (#1563)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-30 17:09:16 +02:00
github-actions[bot]andamacocian f4a08e36dd Bump version to 0.9.10.12 2026-06-30 17:09:16 +02:00
170 changed files with 9327 additions and 6029 deletions
+71
View File
@@ -0,0 +1,71 @@
# Copilot instructions for Daybreak
Daybreak is a cross-platform Guild Wars launcher built on .NET 10 and
Photino.Blazor. Read the
[Architecture Overview](../README.md#architecture-overview) for the project
layout and [CONTRIBUTING.md](../CONTRIBUTING.md) for the branching and release
process.
## Workflow
- Branch off the current `release/[version]` branch, never off `master`.
- One feature or fix per branch and per PR.
- Never push to `master` and never edit the version in `Directory.Build.props`.
Both are owned by the release workflows.
- Title PRs `Short description (Closes #123)` so the linked issue gets labelled.
## Build and test
CI does not run on PRs targeting a release branch, so validate locally. Run the
smallest command that covers the change:
```bash
dotnet build Daybreak.Linux/Daybreak.Linux.csproj # or Daybreak.Windows
dotnet test Daybreak.Tests/Daybreak.Tests.csproj --filter "FullyQualifiedName~YourTests"
```
Keep the build warning-free.
## Where code goes
| Change | Project |
| -------------------------------------------------- | ------------------------------------ |
| Models, utilities, interfaces shared by everything | `Daybreak.Shared` |
| Blazor views and services | `Daybreak.Core` |
| Platform-specific implementations | `Daybreak.Windows`, `Daybreak.Linux` |
`Daybreak.Tests` only references `Daybreak.Core`. When adding platform code, put
the logic worth testing in `Daybreak.Shared` and leave the platform project as a
thin caller.
Add NuGet packages through `Directory.Packages.props`.
## C# conventions
`.editorconfig` is the source of truth. The projects are set up to treat
warnings as errors.
## Verify before claiming
Prefer evidence over reasoning about behaviour, especially for the Wine and
injection paths. Wine is scriptable, so reproduce the actual failure and confirm
the fix against it rather than inferring what a Win32 call does.
## Writing style
Applies to docs, comments, PR descriptions and answers.
- Be short. Cut filler, recap and process narration.
- No AI-isms and no inflated language.
- Do not paste code into documentation. Link to the file so there is only one
copy of it.
- Explain the reason for a change, not a summary of the diff.
## Markdown
- No inline HTML. Use `![alt][ref]` with a reference definition instead of an
`img` tag.
- Start a mermaid fence with the diagram type, for example `flowchart LR`. YAML
frontmatter inside the fence is rejected by some renderers.
- Long URLs belong in reference-style link definitions at the bottom of the
file.
+3 -3
View File
@@ -48,7 +48,7 @@ jobs:
echo "version=$version" >> $env:GITHUB_OUTPUT
- name: Install .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
@@ -111,7 +111,7 @@ jobs:
submodules: recursive
- name: Install .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
@@ -177,7 +177,7 @@ jobs:
submodules: recursive
- name: Install .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
submodules: recursive
- name: Install .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
@@ -55,7 +55,7 @@ jobs:
submodules: recursive
- name: Install .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
# If this step fails, then you should remove it and run the build manually (see below)
- name: Install .NET Core
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.x'
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
fetch-depth: 0
- name: Install .NET Core
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
-3
View File
@@ -88,9 +88,6 @@ StyleCopReport.xml
*.pgc
*.pgd
*.rsp
# Generated by the GWCA binding generator and consumed by the NativeAOT linker
# (see Daybreak.API.csproj); tracked like Interop/GWCA.cs so it is always present.
!Daybreak.API/Interop/GWCA.alternatenames.rsp
*.sbr
*.tlb
*.tli
+113
View File
@@ -0,0 +1,113 @@
# Contributing to Daybreak
## Getting started
Install the prerequisites listed under
[Build Requirements](README.md#build-requirements), then clone with submodules:
```bash
git clone --recurse-submodules https://github.com/AlexMacocian/Daybreak.git
```
Read the [Architecture Overview](README.md#architecture-overview) before making
changes so you put code in the right project.
## Branching model
There is always exactly one active release branch, named `release/[version]`
(for example `release/0.9.10.20`). It is the integration branch: all work
targets it, and `master` only receives finished releases.
```mermaid
flowchart LR
subgraph cycle["Release cycle"]
direction LR
R["release/0.9.10.20<br/><i>active release branch</i>"]
F["feature branch<br/><i>one per change</i>"]
R -->|fork| F
F -->|"PR, squash merge"| R
end
R -->|"PR, rebase merge"| M["master"]
M --> CD["CD pipeline<br/>builds and publishes the release"]
CD -->|"job bumps the revision"| N["release/0.9.10.21<br/><i>next active release branch</i>"]
```
### Feature work
1. Branch off the current release branch.
2. Implement one feature or fix. Keep the branch scoped to a single change.
3. Open a PR against the release branch.
4. Merge with **squash**, so each feature lands as one commit.
### Releasing
1. Open a PR from the release branch onto `master`.
2. Merge with **rebase**, keeping full history. Each feature stays a single
commit on `master`.
3. The [CD pipeline](.github/workflows/cd.yaml) runs on the push to `master`,
builds the Windows and Linux bundles and publishes a GitHub release.
4. On success,
[create-revision-release-branch.yml](.github/workflows/create-revision-release-branch.yml)
bumps the revision and creates the next `release/[version]` branch
automatically. That becomes the new active release branch.
Do not push directly to `master` or create release branches by hand.
## Versions
The version lives in a single place,
[`Directory.Build.props`](Directory.Build.props), and follows
`major.minor.build.revision`. It is bumped by the release branch creation job,
not manually.
Revision bumps happen automatically after every release. For the other
components, run the matching workflow manually before starting the next cycle:
| Workflow | Bumps |
| ------------------------------------------------------------------------------------------ | --------------------- |
| [create-major-release-branch.yml](.github/workflows/create-major-release-branch.yml) | `^.0.0.0` |
| [create-minor-release-branch.yml](.github/workflows/create-minor-release-branch.yml) | `*.^.0.0` |
| [create-build-release-branch.yml](.github/workflows/create-build-release-branch.yml) | `*.*.^.0` |
| [create-revision-release-branch.yml](.github/workflows/create-revision-release-branch.yml) | `*.*.*.^` (automatic) |
All four delegate to
[create-release-branch-template.yml](.github/workflows/create-release-branch-template.yml).
## Pull requests
Title format follows the existing history: a short description, plus the issue
reference when it closes one.
```text
Setup stable computer name in Wine prefix (Closes #1609)
```
[label-merged-issues.yaml](.github/workflows/label-merged-issues.yaml) parses
`close/fix/resolve #N` from the title and body of PRs merged into a release
branch and labels the linked issues, so use those keywords.
## Checks
[CI](.github/workflows/ci.yaml) and
[version_check.yaml](.github/workflows/version_check.yaml) only trigger on PRs
targeting `master`, so a feature PR onto a release branch is not covered by
them. Run the equivalent locally before opening one:
```bash
dotnet build Daybreak.Linux/Daybreak.Linux.csproj # or Daybreak.Windows on Windows
dotnet test Daybreak.Tests/Daybreak.Tests.csproj
```
The version check fails a PR to `master` if the version in
`Directory.Build.props` is not ahead of the latest tag. This is why the bump
must come from the release branch job.
## Code style
Formatting rules are in [`.editorconfig`](.editorconfig) and are enforced by the
compiler and analyzers; keep the build warning-free.
Tests live in [`Daybreak.Tests`](Daybreak.Tests), use MSTest with
FluentAssertions and NSubstitute, and only reference `Daybreak.Core`. If you
want platform code covered by tests, put the testable part in
[`Daybreak.Shared`](Daybreak.Shared).
+4 -32
View File
@@ -60,44 +60,16 @@
</ItemGroup>
<ItemGroup>
<!-- gwca.dll is a build-time codegen input only (PE exports + headers feed the binding generator); it is not copied or linked. -->
<None Include="..\Dependencies\GWCA\gwca.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>gwca.dll</Link>
</None>
<AdditionalFiles Include="..\Dependencies\GWCA\gwca.dll" />
</ItemGroup>
<ItemGroup>
<!-- Statically link GWCA into the NativeAOT image instead of P/Invoking gwca.dll at runtime. -->
<DirectPInvoke Include="gwca.dll" />
<NativeLibrary Include="..\Dependencies\GWCA\gwca_static.lib" />
<NativeLibrary Include="..\Dependencies\GWCA\minhook.lib" />
<NativeLibrary Include="..\Dependencies\GWCA\d3d9.lib" />
<NativeLibrary Include="..\Dependencies\GWCA\d3dx9.lib" />
</ItemGroup>
<ItemGroup>
<!-- GWCA pulls in system imports beyond NativeAOT's default SDK lib set:
shell32 (CommandLineToArgvW, ShellExecuteW) and dbghelp (ImageNtHeader). -->
<SdkNativeLibrary Include="shell32.lib" />
<SdkNativeLibrary Include="dbghelp.lib" />
</ItemGroup>
<ItemGroup>
<RdXmlFile Include="rd.xml" />
</ItemGroup>
<!--
NativeAOT win-x86 statically links GWCA via DirectPInvoke. NativeAOT decorates
cdecl P/Invoke targets with a leading underscore (_?Foo@GW@@...), but MSVC C++
exports have none (?Foo@GW@@...). The GWCA binding generator emits a matching
set of /ALTERNATENAME directives (Interop/GWCA.alternatenames.rsp) during
CoreCompile; feed them to lld-link before it runs.
-->
<Target Name="_AddGwcaAlternateNames"
BeforeTargets="LinkNative"
Condition="Exists('$(MSBuildProjectDirectory)/Interop/GWCA.alternatenames.rsp')">
<ReadLinesFromFile File="$(MSBuildProjectDirectory)/Interop/GWCA.alternatenames.rsp">
<Output TaskParameter="Lines" ItemName="LinkerArg" />
</ReadLinesFromFile>
</Target>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
+36 -2
View File
@@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Extensions.Core;
using System.Logging;
using System.Net.NetworkInformation;
using System.Reflection;
using System.Runtime.InteropServices;
using Daybreak.API.Configuration;
using Daybreak.API.Extensions;
@@ -22,8 +23,12 @@ namespace Daybreak.API;
public class EntryPoint
{
private const int StartPort = 5080;
private const string GwcaDllName = "gwca.dll";
private static readonly TimeSpan InitializationTimeout = TimeSpan.FromSeconds(5);
private static readonly CancellationTokenSource CancellationTokenSource = new();
private static readonly object GwcaResolverLock = new();
private static IntPtr gwcaHandle;
private static bool gwcaResolverRegistered;
[UnmanagedCallersOnly(EntryPoint = "ThreadInit"), STAThread]
[RequiresUnreferencedCode("The handler uses a static method that gets referenced, so there's no unreferenced code to worry about")]
@@ -192,7 +197,8 @@ public class EntryPoint
{
if (module.ModuleName?.Equals("gwca.dll", StringComparison.OrdinalIgnoreCase) is true)
{
NativeLibrary.Load(module.FileName);
var handle = NativeLibrary.Load(module.FileName);
RegisterGwcaResolver(handle, module.FileName, logger);
logger.LogDebug($"Reused already-loaded gwca.dll from {module.FileName}");
return;
}
@@ -213,7 +219,8 @@ public class EntryPoint
var gwcaPath = Path.Combine(daybreakApiDir, "gwca.dll");
if (File.Exists(gwcaPath))
{
NativeLibrary.Load(gwcaPath);
var handle = NativeLibrary.Load(gwcaPath);
RegisterGwcaResolver(handle, gwcaPath, logger);
logger.LogDebug($"Preloaded gwca.dll from {gwcaPath}");
}
else
@@ -221,4 +228,31 @@ public class EntryPoint
logger.LogError($"gwca.dll not found at {gwcaPath}");
}
}
private static void RegisterGwcaResolver(IntPtr handle, string path, ScopedLogger<EntryPoint> logger)
{
lock (GwcaResolverLock)
{
gwcaHandle = handle;
if (gwcaResolverRegistered)
{
logger.LogDebug($"Updated gwca.dll resolver handle for {path}");
return;
}
NativeLibrary.SetDllImportResolver(typeof(GWCA).Assembly, ResolveNativeLibrary);
gwcaResolverRegistered = true;
logger.LogDebug($"Registered gwca.dll import resolver for {path}");
}
}
private static IntPtr ResolveNativeLibrary(string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
{
if (string.Equals(libraryName, GwcaDllName, StringComparison.OrdinalIgnoreCase))
{
return gwcaHandle;
}
return IntPtr.Zero;
}
}
@@ -1,501 +0,0 @@
/ALTERNATENAME:_?DisableHooks@GW@@YAXXZ=?DisableHooks@GW@@YAXXZ
/ALTERNATENAME:_?EnableHooks@GW@@YAXXZ=?EnableHooks@GW@@YAXXZ
/ALTERNATENAME:_?FatalAssert@GW@@YAXPBD0I0@Z=?FatalAssert@GW@@YAXPBD0I0@Z
/ALTERNATENAME:_?GetAccountContext@GW@@YAPAUAccountContext@1@XZ=?GetAccountContext@GW@@YAPAUAccountContext@1@XZ
/ALTERNATENAME:_?GetAgentContext@GW@@YAPAUAgentContext@1@XZ=?GetAgentContext@GW@@YAPAUAgentContext@1@XZ
/ALTERNATENAME:_?GetAvailableChars@GW@@YAPAV?$Array@UCharacterInformation@GW@@@1@XZ=?GetAvailableChars@GW@@YAPAV?$Array@UCharacterInformation@GW@@@1@XZ
/ALTERNATENAME:_?GetCharContext@GW@@YAPAUCharContext@1@XZ=?GetCharContext@GW@@YAPAUCharContext@1@XZ
/ALTERNATENAME:_?GetDistance@GW@@YAMABUVec2f@1@0@Z=?GetDistance@GW@@YAMABUVec2f@1@0@Z
/ALTERNATENAME:_?GetDistance@GW@@YAMUVec3f@1@0@Z=?GetDistance@GW@@YAMUVec3f@1@0@Z
/ALTERNATENAME:_?GetGameContext@GW@@YAPAUGameContext@1@XZ=?GetGameContext@GW@@YAPAUGameContext@1@XZ
/ALTERNATENAME:_?GetGuildContext@GW@@YAPAUGuildContext@1@XZ=?GetGuildContext@GW@@YAPAUGuildContext@1@XZ
/ALTERNATENAME:_?GetItemContext@GW@@YAPAUItemContext@1@XZ=?GetItemContext@GW@@YAPAUItemContext@1@XZ
/ALTERNATENAME:_?GetMapContext@GW@@YAPAUMapContext@1@XZ=?GetMapContext@GW@@YAPAUMapContext@1@XZ
/ALTERNATENAME:_?GetNorm@GW@@YAMUVec2f@1@@Z=?GetNorm@GW@@YAMUVec2f@1@@Z
/ALTERNATENAME:_?GetNorm@GW@@YAMUVec3f@1@@Z=?GetNorm@GW@@YAMUVec3f@1@@Z
/ALTERNATENAME:_?GetPreGameContext@GW@@YAPAUPreGameContext@1@XZ=?GetPreGameContext@GW@@YAPAUPreGameContext@1@XZ
/ALTERNATENAME:_?GetWorldContext@GW@@YAPAUWorldContext@1@XZ=?GetWorldContext@GW@@YAPAUWorldContext@1@XZ
/ALTERNATENAME:_?Hash@GW@@YAIPBXI@Z=?Hash@GW@@YAIPBXI@Z
/ALTERNATENAME:_?Hash16@GW@@YAIG@Z=?Hash16@GW@@YAIG@Z
/ALTERNATENAME:_?Hash32@GW@@YAII@Z=?Hash32@GW@@YAII@Z
/ALTERNATENAME:_?Hash8@GW@@YAIE@Z=?Hash8@GW@@YAIE@Z
/ALTERNATENAME:_?HashWString@GW@@YAIPB_WI@Z=?HashWString@GW@@YAIPB_WI@Z
/ALTERNATENAME:_?Initialize@GW@@YA_NXZ=?Initialize@GW@@YA_NXZ
/ALTERNATENAME:_?RegisterLogHandler@GW@@YAXP6AXPAXW4LogLevel@1@PBD2I2@Z0@Z=?RegisterLogHandler@GW@@YAXP6AXPAXW4LogLevel@1@PBD2I2@Z0@Z
/ALTERNATENAME:_?RegisterPanicHandler@GW@@YAXP6AXPAXPBD1I1@Z0@Z=?RegisterPanicHandler@GW@@YAXP6AXPAXPBD1I1@Z0@Z
/ALTERNATENAME:_?Rotate@GW@@YA?AUVec2f@1@U21@M@Z=?Rotate@GW@@YA?AUVec2f@1@U21@M@Z
/ALTERNATENAME:_?Terminate@GW@@YAXXZ=?Terminate@GW@@YAXXZ
/ALTERNATENAME:_?ChangeTarget@Agents@GW@@YA_NI@Z=?ChangeTarget@Agents@GW@@YA_NI@Z
/ALTERNATENAME:_?ChangeTarget@Agents@GW@@YA_NPBUAgent@2@@Z=?ChangeTarget@Agents@GW@@YA_NPBUAgent@2@@Z
/ALTERNATENAME:_?CountAllegianceInRange@Agents@GW@@YAIW4Allegiance@Constants@2@M@Z=?CountAllegianceInRange@Agents@GW@@YAIW4Allegiance@Constants@2@M@Z
/ALTERNATENAME:_?GetAgentArray@Agents@GW@@YAPAV?$Array@PAUAgent@GW@@@2@XZ=?GetAgentArray@Agents@GW@@YAPAV?$Array@PAUAgent@GW@@@2@XZ
/ALTERNATENAME:_?GetAgentByID@Agents@GW@@YAPAUAgent@2@I@Z=?GetAgentByID@Agents@GW@@YAPAUAgent@2@I@Z
/ALTERNATENAME:_?GetAgentCharData@Agents@GW@@YAPAUAgentCharData@2@I@Z=?GetAgentCharData@Agents@GW@@YAPAUAgentCharData@2@I@Z
/ALTERNATENAME:_?GetAgentEncName@Agents@GW@@YAPA_WI@Z=?GetAgentEncName@Agents@GW@@YAPA_WI@Z
/ALTERNATENAME:_?GetAgentEncName@Agents@GW@@YAPA_WPBUAgent@2@@Z=?GetAgentEncName@Agents@GW@@YAPA_WPBUAgent@2@@Z
/ALTERNATENAME:_?GetAgentIdByLoginNumber@Agents@GW@@YAII@Z=?GetAgentIdByLoginNumber@Agents@GW@@YAII@Z
/ALTERNATENAME:_?GetAgentMatchesFlags@Agents@GW@@YA_NPBUAgent@2@W4AgentTargetFlags@2@@Z=?GetAgentMatchesFlags@Agents@GW@@YA_NPBUAgent@2@W4AgentTargetFlags@2@@Z
/ALTERNATENAME:_?GetAgentPrimary@Agents@GW@@YA?AW4ProfessionByte@Constants@2@I@Z=?GetAgentPrimary@Agents@GW@@YA?AW4ProfessionByte@Constants@2@I@Z
/ALTERNATENAME:_?GetAgentSecondary@Agents@GW@@YA?AW4ProfessionByte@Constants@2@I@Z=?GetAgentSecondary@Agents@GW@@YA?AW4ProfessionByte@Constants@2@I@Z
/ALTERNATENAME:_?GetAmountOfPlayersInInstance@Agents@GW@@YAIXZ=?GetAmountOfPlayersInInstance@Agents@GW@@YAIXZ
/ALTERNATENAME:_?GetControlledCharacter@Agents@GW@@YAPAUAgentLiving@2@XZ=?GetControlledCharacter@Agents@GW@@YAPAUAgentLiving@2@XZ
/ALTERNATENAME:_?GetControlledCharacterId@Agents@GW@@YAIXZ=?GetControlledCharacterId@Agents@GW@@YAIXZ
/ALTERNATENAME:_?GetEvaluatedTargetId@Agents@GW@@YAIXZ=?GetEvaluatedTargetId@Agents@GW@@YAIXZ
/ALTERNATENAME:_?GetHeroAgentID@Agents@GW@@YAII@Z=?GetHeroAgentID@Agents@GW@@YAII@Z
/ALTERNATENAME:_?GetMapAgentArray@Agents@GW@@YAPAV?$Array@UMapAgent@GW@@@2@XZ=?GetMapAgentArray@Agents@GW@@YAPAV?$Array@UMapAgent@GW@@@2@XZ
/ALTERNATENAME:_?GetMapAgentByID@Agents@GW@@YAPAUMapAgent@2@I@Z=?GetMapAgentByID@Agents@GW@@YAPAUMapAgent@2@I@Z
/ALTERNATENAME:_?GetNPCArray@Agents@GW@@YAPAV?$Array@UNPC@GW@@@2@XZ=?GetNPCArray@Agents@GW@@YAPAV?$Array@UNPC@GW@@@2@XZ
/ALTERNATENAME:_?GetNPCByID@Agents@GW@@YAPAUNPC@2@I@Z=?GetNPCByID@Agents@GW@@YAPAUNPC@2@I@Z
/ALTERNATENAME:_?GetObservingId@Agents@GW@@YAIXZ=?GetObservingId@Agents@GW@@YAIXZ
/ALTERNATENAME:_?GetPlayerArray@Agents@GW@@YAPAV?$Array@UPlayer@GW@@@2@XZ=?GetPlayerArray@Agents@GW@@YAPAV?$Array@UPlayer@GW@@@2@XZ
/ALTERNATENAME:_?GetPlayerByID@Agents@GW@@YAPAUAgent@2@I@Z=?GetPlayerByID@Agents@GW@@YAPAUAgent@2@I@Z
/ALTERNATENAME:_?GetPlayerNameByLoginNumber@Agents@GW@@YAPA_WI@Z=?GetPlayerNameByLoginNumber@Agents@GW@@YAPA_WI@Z
/ALTERNATENAME:_?GetTargetAsAgentLiving@Agents@GW@@YAPAUAgentLiving@2@XZ=?GetTargetAsAgentLiving@Agents@GW@@YAPAUAgentLiving@2@XZ
/ALTERNATENAME:_?GetTargetId@Agents@GW@@YAIXZ=?GetTargetId@Agents@GW@@YAIXZ
/ALTERNATENAME:_?InteractAgent@Agents@GW@@YA_NPBUAgent@2@_N@Z=?InteractAgent@Agents@GW@@YA_NPBUAgent@2@_N@Z
/ALTERNATENAME:_?IsObserving@Agents@GW@@YA_NXZ=?IsObserving@Agents@GW@@YA_NXZ
/ALTERNATENAME:_?Move@Agents@GW@@YA_NMMI@Z=?Move@Agents@GW@@YA_NMMI@Z
/ALTERNATENAME:_?Move@Agents@GW@@YA_NUGamePos@2@@Z=?Move@Agents@GW@@YA_NUGamePos@2@@Z
/ALTERNATENAME:_?SendDialog@Agents@GW@@YA_NI@Z=?SendDialog@Agents@GW@@YA_NI@Z
/ALTERNATENAME:_?Click@ButtonFrame@GW@@QAE_NXZ=?Click@ButtonFrame@GW@@QAE_NXZ
/ALTERNATENAME:_?DoubleClick@ButtonFrame@GW@@QAE_NXZ=?DoubleClick@ButtonFrame@GW@@QAE_NXZ
/ALTERNATENAME:_?GetLabel@ButtonFrame@GW@@QAE_NPAPB_W@Z=?GetLabel@ButtonFrame@GW@@QAE_NPAPB_W@Z
/ALTERNATENAME:_?MouseAction@ButtonFrame@GW@@QAE_NW4ActionState@UIPacket@UI@2@@Z=?MouseAction@ButtonFrame@GW@@QAE_NW4ActionState@UIPacket@UI@2@@Z
/ALTERNATENAME:_?SetLabel@ButtonFrame@GW@@QAE_NPB_W@Z=?SetLabel@ButtonFrame@GW@@QAE_NPB_W@Z
/ALTERNATENAME:_?ComputeCamPos@CameraMgr@GW@@YA?AUVec3f@2@M@Z=?ComputeCamPos@CameraMgr@GW@@YA?AUVec3f@2@M@Z
/ALTERNATENAME:_?GetCamera@CameraMgr@GW@@YAPAUCamera@2@XZ=?GetCamera@CameraMgr@GW@@YAPAUCamera@2@XZ
/ALTERNATENAME:_?GetCameraUnlock@CameraMgr@GW@@YA_NXZ=?GetCameraUnlock@CameraMgr@GW@@YA_NXZ
/ALTERNATENAME:_?GetFieldOfView@CameraMgr@GW@@YAMXZ=?GetFieldOfView@CameraMgr@GW@@YAMXZ
/ALTERNATENAME:_?GetYaw@CameraMgr@GW@@YAMXZ=?GetYaw@CameraMgr@GW@@YAMXZ
/ALTERNATENAME:_?SetFieldOfView@CameraMgr@GW@@YA_NM@Z=?SetFieldOfView@CameraMgr@GW@@YA_NM@Z
/ALTERNATENAME:_?SetFog@CameraMgr@GW@@YA_N_N@Z=?SetFog@CameraMgr@GW@@YA_N_N@Z
/ALTERNATENAME:_?SetMaxDist@CameraMgr@GW@@YA_NM@Z=?SetMaxDist@CameraMgr@GW@@YA_NM@Z
/ALTERNATENAME:_?UnlockCam@CameraMgr@GW@@YA_N_N@Z=?UnlockCam@CameraMgr@GW@@YA_N_N@Z
/ALTERNATENAME:_?UpdateCameraPos@CameraMgr@GW@@YA_NXZ=?UpdateCameraPos@CameraMgr@GW@@YA_NXZ
/ALTERNATENAME:_?AddToChatLog@Chat@GW@@YA_NPA_WI@Z=?AddToChatLog@Chat@GW@@YA_NPA_WI@Z
/ALTERNATENAME:_?CreateCommand@Chat@GW@@YAXPAUHookEntry@2@PB_WP6AXPAUHookStatus@2@1HPBQA_W@Z@Z=?CreateCommand@Chat@GW@@YAXPAUHookEntry@2@PB_WP6AXPAUHookStatus@2@1HPBQA_W@Z@Z
/ALTERNATENAME:_?DeleteCommand@Chat@GW@@YAXPAUHookEntry@2@PB_W@Z=?DeleteCommand@Chat@GW@@YAXPAUHookEntry@2@PB_W@Z
/ALTERNATENAME:_?GetChannel@Chat@GW@@YA?AW4Channel@12@D@Z=?GetChannel@Chat@GW@@YA?AW4Channel@12@D@Z
/ALTERNATENAME:_?GetChannel@Chat@GW@@YA?AW4Channel@12@_W@Z=?GetChannel@Chat@GW@@YA?AW4Channel@12@_W@Z
/ALTERNATENAME:_?GetChannelColors@Chat@GW@@YAXW4Channel@12@PAI1@Z=?GetChannelColors@Chat@GW@@YAXW4Channel@12@PAI1@Z
/ALTERNATENAME:_?GetChatLog@Chat@GW@@YAPAUChatBuffer@12@XZ=?GetChatLog@Chat@GW@@YAPAUChatBuffer@12@XZ
/ALTERNATENAME:_?GetCurrentChatChannel@Chat@GW@@YA?AW4Channel@12@XZ=?GetCurrentChatChannel@Chat@GW@@YA?AW4Channel@12@XZ
/ALTERNATENAME:_?GetDefaultColors@Chat@GW@@YAXW4Channel@12@PAI1@Z=?GetDefaultColors@Chat@GW@@YAXW4Channel@12@PAI1@Z
/ALTERNATENAME:_?GetIsTyping@Chat@GW@@YA_NXZ=?GetIsTyping@Chat@GW@@YA_NXZ
/ALTERNATENAME:_?SendChat@Chat@GW@@YA_NDPBD@Z=?SendChat@Chat@GW@@YA_NDPBD@Z
/ALTERNATENAME:_?SendChat@Chat@GW@@YA_NDPB_W@Z=?SendChat@Chat@GW@@YA_NDPB_W@Z
/ALTERNATENAME:_?SendChat@Chat@GW@@YA_NPBD@Z=?SendChat@Chat@GW@@YA_NPBD@Z
/ALTERNATENAME:_?SendChat@Chat@GW@@YA_NPB_W0@Z=?SendChat@Chat@GW@@YA_NPB_W0@Z
/ALTERNATENAME:_?SendChat@Chat@GW@@YA_NPB_W@Z=?SendChat@Chat@GW@@YA_NPB_W@Z
/ALTERNATENAME:_?SetMessageColor@Chat@GW@@YAIW4Channel@12@I@Z=?SetMessageColor@Chat@GW@@YAIW4Channel@12@I@Z
/ALTERNATENAME:_?SetSenderColor@Chat@GW@@YAIW4Channel@12@I@Z=?SetSenderColor@Chat@GW@@YAIW4Channel@12@I@Z
/ALTERNATENAME:_?SetTimestampsColor@Chat@GW@@YAXI@Z=?SetTimestampsColor@Chat@GW@@YAXI@Z
/ALTERNATENAME:_?SetTimestampsFormat@Chat@GW@@YAX_N0@Z=?SetTimestampsFormat@Chat@GW@@YAX_N0@Z
/ALTERNATENAME:_?ToggleTimestamps@Chat@GW@@YAX_N@Z=?ToggleTimestamps@Chat@GW@@YAX_N@Z
/ALTERNATENAME:_?WriteChat@Chat@GW@@YAXW4Channel@12@PB_W1_N@Z=?WriteChat@Chat@GW@@YAXW4Channel@12@PB_W1_N@Z
/ALTERNATENAME:_?WriteChatEnc@Chat@GW@@YAXW4Channel@12@PB_W1_N@Z=?WriteChatEnc@Chat@GW@@YAXW4Channel@12@PB_W1_N@Z
/ALTERNATENAME:_?GetValue@CheckboxFrame@GW@@UAEIXZ=?GetValue@CheckboxFrame@GW@@UAEIXZ
/ALTERNATENAME:_?IsChecked@CheckboxFrame@GW@@QAE_NXZ=?IsChecked@CheckboxFrame@GW@@QAE_NXZ
/ALTERNATENAME:_?SetChecked@CheckboxFrame@GW@@QAE_N_N@Z=?SetChecked@CheckboxFrame@GW@@QAE_N_N@Z
/ALTERNATENAME:_?SetValue@CheckboxFrame@GW@@UAE_NI@Z=?SetValue@CheckboxFrame@GW@@UAE_NI@Z
/ALTERNATENAME:_?AddOption@DropdownFrame@GW@@QAE_NPB_WI@Z=?AddOption@DropdownFrame@GW@@QAE_NPB_WI@Z
/ALTERNATENAME:_?GetCount@DropdownFrame@GW@@QAE_NPAI@Z=?GetCount@DropdownFrame@GW@@QAE_NPAI@Z
/ALTERNATENAME:_?GetOptionIndex@DropdownFrame@GW@@QAE_NIPAI@Z=?GetOptionIndex@DropdownFrame@GW@@QAE_NIPAI@Z
/ALTERNATENAME:_?GetOptionValue@DropdownFrame@GW@@QAE_NIPAI@Z=?GetOptionValue@DropdownFrame@GW@@QAE_NIPAI@Z
/ALTERNATENAME:_?GetSelectedIndex@DropdownFrame@GW@@QAE_NPAI@Z=?GetSelectedIndex@DropdownFrame@GW@@QAE_NPAI@Z
/ALTERNATENAME:_?GetValue@DropdownFrame@GW@@UAEIXZ=?GetValue@DropdownFrame@GW@@UAEIXZ
/ALTERNATENAME:_?HasValueMapping@DropdownFrame@GW@@QAE_NXZ=?HasValueMapping@DropdownFrame@GW@@QAE_NXZ
/ALTERNATENAME:_?SelectIndex@DropdownFrame@GW@@QAE_NI@Z=?SelectIndex@DropdownFrame@GW@@QAE_NI@Z
/ALTERNATENAME:_?SelectOption@DropdownFrame@GW@@QAE_NI@Z=?SelectOption@DropdownFrame@GW@@QAE_NI@Z
/ALTERNATENAME:_?SetValue@DropdownFrame@GW@@UAE_NI@Z=?SetValue@DropdownFrame@GW@@UAE_NI@Z
/ALTERNATENAME:_?GetValue@EditableTextFrame@GW@@QAEPB_WXZ=?GetValue@EditableTextFrame@GW@@QAEPB_WXZ
/ALTERNATENAME:_?IsReadOnly@EditableTextFrame@GW@@QAE_NXZ=?IsReadOnly@EditableTextFrame@GW@@QAE_NXZ
/ALTERNATENAME:_?SetMaxLength@EditableTextFrame@GW@@QAE_NI@Z=?SetMaxLength@EditableTextFrame@GW@@QAE_NI@Z
/ALTERNATENAME:_?SetReadOnly@EditableTextFrame@GW@@QAE_N_N@Z=?SetReadOnly@EditableTextFrame@GW@@QAE_N_N@Z
/ALTERNATENAME:_?SetValue@EditableTextFrame@GW@@QAE_NPB_W@Z=?SetValue@EditableTextFrame@GW@@QAE_NPB_W@Z
/ALTERNATENAME:_?GetTimeElapsed@Effect@GW@@QBEKXZ=?GetTimeElapsed@Effect@GW@@QBEKXZ
/ALTERNATENAME:_?GetTimeRemaining@Effect@GW@@QBEKXZ=?GetTimeRemaining@Effect@GW@@QBEKXZ
/ALTERNATENAME:_?DropBuff@Effects@GW@@YA_NI@Z=?DropBuff@Effects@GW@@YA_NI@Z
/ALTERNATENAME:_?GetAgentBuffs@Effects@GW@@YAPAV?$Array@UBuff@GW@@@2@I@Z=?GetAgentBuffs@Effects@GW@@YAPAV?$Array@UBuff@GW@@@2@I@Z
/ALTERNATENAME:_?GetAgentEffects@Effects@GW@@YAPAV?$Array@UEffect@GW@@@2@I@Z=?GetAgentEffects@Effects@GW@@YAPAV?$Array@UEffect@GW@@@2@I@Z
/ALTERNATENAME:_?GetAgentEffectsArray@Effects@GW@@YAPAUAgentEffects@2@I@Z=?GetAgentEffectsArray@Effects@GW@@YAPAUAgentEffects@2@I@Z
/ALTERNATENAME:_?GetAlcoholLevel@Effects@GW@@YAIXZ=?GetAlcoholLevel@Effects@GW@@YAIXZ
/ALTERNATENAME:_?GetDrunkAf@Effects@GW@@YAXMI@Z=?GetDrunkAf@Effects@GW@@YAXMI@Z
/ALTERNATENAME:_?GetPartyEffectsArray@Effects@GW@@YAPAV?$Array@UAgentEffects@GW@@@2@XZ=?GetPartyEffectsArray@Effects@GW@@YAPAV?$Array@UAgentEffects@GW@@@2@XZ
/ALTERNATENAME:_?GetPlayerBuffBySkillId@Effects@GW@@YAPAUBuff@2@W4SkillID@Constants@2@@Z=?GetPlayerBuffBySkillId@Effects@GW@@YAPAUBuff@2@W4SkillID@Constants@2@@Z
/ALTERNATENAME:_?GetPlayerBuffs@Effects@GW@@YAPAV?$Array@UBuff@GW@@@2@XZ=?GetPlayerBuffs@Effects@GW@@YAPAV?$Array@UBuff@GW@@@2@XZ
/ALTERNATENAME:_?GetPlayerEffectBySkillId@Effects@GW@@YAPAUEffect@2@W4SkillID@Constants@2@@Z=?GetPlayerEffectBySkillId@Effects@GW@@YAPAUEffect@2@W4SkillID@Constants@2@@Z
/ALTERNATENAME:_?GetPlayerEffects@Effects@GW@@YAPAV?$Array@UEffect@GW@@@2@XZ=?GetPlayerEffects@Effects@GW@@YAPAV?$Array@UEffect@GW@@@2@XZ
/ALTERNATENAME:_?GetPlayerEffectsArray@Effects@GW@@YAPAUAgentEffects@2@XZ=?GetPlayerEffectsArray@Effects@GW@@YAPAUAgentEffects@2@XZ
/ALTERNATENAME:_?RemoveEventCallback@EventMgr@GW@@YAXPAUHookEntry@2@W4EventID@12@@Z=?RemoveEventCallback@EventMgr@GW@@YAXPAUHookEntry@2@W4EventID@12@@Z
/ALTERNATENAME:_?AddFriend@FriendListMgr@GW@@YA_NPB_W0@Z=?AddFriend@FriendListMgr@GW@@YA_NPB_W0@Z
/ALTERNATENAME:_?AddIgnore@FriendListMgr@GW@@YA_NPB_W0@Z=?AddIgnore@FriendListMgr@GW@@YA_NPB_W0@Z
/ALTERNATENAME:_?ChangeFriendType@FriendListMgr@GW@@YA_NPAUFriend@2@W4FriendType@2@@Z=?ChangeFriendType@FriendListMgr@GW@@YA_NPAUFriend@2@W4FriendType@2@@Z
/ALTERNATENAME:_?GetFriend@FriendListMgr@GW@@YAPAUFriend@2@I@Z=?GetFriend@FriendListMgr@GW@@YAPAUFriend@2@I@Z
/ALTERNATENAME:_?GetFriend@FriendListMgr@GW@@YAPAUFriend@2@PBE@Z=?GetFriend@FriendListMgr@GW@@YAPAUFriend@2@PBE@Z
/ALTERNATENAME:_?GetFriend@FriendListMgr@GW@@YAPAUFriend@2@PB_W0W4FriendType@2@@Z=?GetFriend@FriendListMgr@GW@@YAPAUFriend@2@PB_W0W4FriendType@2@@Z
/ALTERNATENAME:_?GetMyStatus@FriendListMgr@GW@@YA?AW4FriendStatus@2@XZ=?GetMyStatus@FriendListMgr@GW@@YA?AW4FriendStatus@2@XZ
/ALTERNATENAME:_?GetNumberOfFriends@FriendListMgr@GW@@YAIW4FriendType@2@@Z=?GetNumberOfFriends@FriendListMgr@GW@@YAIW4FriendType@2@@Z
/ALTERNATENAME:_?GetNumberOfIgnores@FriendListMgr@GW@@YAIXZ=?GetNumberOfIgnores@FriendListMgr@GW@@YAIXZ
/ALTERNATENAME:_?GetNumberOfPartners@FriendListMgr@GW@@YAIXZ=?GetNumberOfPartners@FriendListMgr@GW@@YAIXZ
/ALTERNATENAME:_?GetNumberOfTraders@FriendListMgr@GW@@YAIXZ=?GetNumberOfTraders@FriendListMgr@GW@@YAIXZ
/ALTERNATENAME:_?RemoveFriend@FriendListMgr@GW@@YA_NPAUFriend@2@@Z=?RemoveFriend@FriendListMgr@GW@@YA_NPAUFriend@2@@Z
/ALTERNATENAME:_?RemoveFriendStatusCallback@FriendListMgr@GW@@YAXPAUHookEntry@2@@Z=?RemoveFriendStatusCallback@FriendListMgr@GW@@YAXPAUHookEntry@2@@Z
/ALTERNATENAME:_?SetFriendListStatus@FriendListMgr@GW@@YA_NW4FriendStatus@2@@Z=?SetFriendListStatus@FriendListMgr@GW@@YA_NW4FriendStatus@2@@Z
/ALTERNATENAME:_?ClearCalls@GameThread@GW@@YAXXZ=?ClearCalls@GameThread@GW@@YAXXZ
/ALTERNATENAME:_?EnableHooks@GameThread@GW@@YAXXZ=?EnableHooks@GameThread@GW@@YAXXZ
/ALTERNATENAME:_Enqueue=Enqueue
/ALTERNATENAME:_?IsInGameThread@GameThread@GW@@YA_NXZ=?IsInGameThread@GameThread@GW@@YA_NXZ
/ALTERNATENAME:_RegisterGameThreadCallback=RegisterGameThreadCallback
/ALTERNATENAME:_?RemoveGameThreadCallback@GameThread@GW@@YAXPAUHookEntry@2@@Z=?RemoveGameThreadCallback@GameThread@GW@@YAXPAUHookEntry@2@@Z
/ALTERNATENAME:_?GetCurrentGH@GuildMgr@GW@@YAPAUGuild@2@XZ=?GetCurrentGH@GuildMgr@GW@@YAPAUGuild@2@XZ
/ALTERNATENAME:_?GetGuildArray@GuildMgr@GW@@YAPAV?$Array@PAUGuild@GW@@@2@XZ=?GetGuildArray@GuildMgr@GW@@YAPAV?$Array@PAUGuild@GW@@@2@XZ
/ALTERNATENAME:_?GetGuildInfo@GuildMgr@GW@@YAPAUGuild@2@I@Z=?GetGuildInfo@GuildMgr@GW@@YAPAUGuild@2@I@Z
/ALTERNATENAME:_?GetPlayerGuild@GuildMgr@GW@@YAPAUGuild@2@XZ=?GetPlayerGuild@GuildMgr@GW@@YAPAUGuild@2@XZ
/ALTERNATENAME:_?GetPlayerGuildAnnouncement@GuildMgr@GW@@YAPA_WXZ=?GetPlayerGuildAnnouncement@GuildMgr@GW@@YAPA_WXZ
/ALTERNATENAME:_?GetPlayerGuildAnnouncer@GuildMgr@GW@@YAPA_WXZ=?GetPlayerGuildAnnouncer@GuildMgr@GW@@YAPA_WXZ
/ALTERNATENAME:_?GetPlayerGuildIndex@GuildMgr@GW@@YAIXZ=?GetPlayerGuildIndex@GuildMgr@GW@@YAIXZ
/ALTERNATENAME:_?LeaveGH@GuildMgr@GW@@YA_NXZ=?LeaveGH@GuildMgr@GW@@YA_NXZ
/ALTERNATENAME:_?TravelGH@GuildMgr@GW@@YA_NUGHKey@2@@Z=?TravelGH@GuildMgr@GW@@YA_NUGHKey@2@@Z
/ALTERNATENAME:_?TravelGH@GuildMgr@GW@@YA_NXZ=?TravelGH@GuildMgr@GW@@YA_NXZ
/ALTERNATENAME:_?CreateHook@Hook@GW@@YAHPAPAXPAX0@Z=?CreateHook@Hook@GW@@YAHPAPAXPAX0@Z
/ALTERNATENAME:_?Deinitialize@Hook@GW@@YAXXZ=?Deinitialize@Hook@GW@@YAXXZ
/ALTERNATENAME:_?DisableHooks@Hook@GW@@YAXPAX@Z=?DisableHooks@Hook@GW@@YAXPAX@Z
/ALTERNATENAME:_?EnableHooks@Hook@GW@@YAXPAX@Z=?EnableHooks@Hook@GW@@YAXPAX@Z
/ALTERNATENAME:_?EnterHook@Hook@GW@@YAXXZ=?EnterHook@Hook@GW@@YAXXZ
/ALTERNATENAME:_?GetInHookCount@Hook@GW@@YAHXZ=?GetInHookCount@Hook@GW@@YAHXZ
/ALTERNATENAME:_?Initialize@Hook@GW@@YAXXZ=?Initialize@Hook@GW@@YAXXZ
/ALTERNATENAME:_?LeaveHook@Hook@GW@@YAXXZ=?LeaveHook@Hook@GW@@YAXXZ
/ALTERNATENAME:_?RemoveHook@Hook@GW@@YAXPAX@Z=?RemoveHook@Hook@GW@@YAXPAX@Z
/ALTERNATENAME:_?GetIsMaterial@Item@GW@@QBE_NXZ=?GetIsMaterial@Item@GW@@QBE_NXZ
/ALTERNATENAME:_?GetIsZcoin@Item@GW@@QBE_NXZ=?GetIsZcoin@Item@GW@@QBE_NXZ
/ALTERNATENAME:_?GetSelectedValue@ItemListFrame@GW@@QAE_NPAI@Z=?GetSelectedValue@ItemListFrame@GW@@QAE_NPAI@Z
/ALTERNATENAME:_?GetSortHandler@ItemListFrame@GW@@QAEP6AHII@ZXZ=?GetSortHandler@ItemListFrame@GW@@QAEP6AHII@ZXZ
/ALTERNATENAME:_?SetSortHandler@ItemListFrame@GW@@QAE_NP6AHII@Z@Z=?SetSortHandler@ItemListFrame@GW@@QAE_NP6AHII@Z@Z
/ALTERNATENAME:_?CanAccessXunlaiChest@Items@GW@@YA_NXZ=?CanAccessXunlaiChest@Items@GW@@YA_NXZ
/ALTERNATENAME:_?CanInteractWithItem@Items@GW@@YA_NPBUItem@2@@Z=?CanInteractWithItem@Items@GW@@YA_NPBUItem@2@@Z
/ALTERNATENAME:_?CountItemByModelId@Items@GW@@YAIIHH@Z=?CountItemByModelId@Items@GW@@YAIIHH@Z
/ALTERNATENAME:_?DepositGold@Items@GW@@YAII@Z=?DepositGold@Items@GW@@YAII@Z
/ALTERNATENAME:_?DestroyItem@Items@GW@@YA_NI@Z=?DestroyItem@Items@GW@@YA_NI@Z
/ALTERNATENAME:_?DropGold@Items@GW@@YA_NI@Z=?DropGold@Items@GW@@YA_NI@Z
/ALTERNATENAME:_?DropItem@Items@GW@@YA_NPBUItem@2@I@Z=?DropItem@Items@GW@@YA_NPBUItem@2@I@Z
/ALTERNATENAME:_?EquipItem@Items@GW@@YA_NPBUItem@2@I@Z=?EquipItem@Items@GW@@YA_NPBUItem@2@I@Z
/ALTERNATENAME:_?GetBag@Items@GW@@YAPAUBag@2@W43Constants@2@@Z=?GetBag@Items@GW@@YAPAUBag@2@W43Constants@2@@Z
/ALTERNATENAME:_?GetBagArray@Items@GW@@YAPAPAUBag@2@XZ=?GetBagArray@Items@GW@@YAPAPAUBag@2@XZ
/ALTERNATENAME:_?GetBagByIndex@Items@GW@@YAPAUBag@2@I@Z=?GetBagByIndex@Items@GW@@YAPAUBag@2@I@Z
/ALTERNATENAME:_?GetCompositeModelInfo@Items@GW@@YAPBUCompositeModelInfo@2@I@Z=?GetCompositeModelInfo@Items@GW@@YAPBUCompositeModelInfo@2@I@Z
/ALTERNATENAME:_?GetEquipmentVisibility@Items@GW@@YA?AW4EquipmentStatus@2@W4EquipmentType@2@@Z=?GetEquipmentVisibility@Items@GW@@YA?AW4EquipmentStatus@2@W4EquipmentType@2@@Z
/ALTERNATENAME:_?GetGoldAmountInStorage@Items@GW@@YAIXZ=?GetGoldAmountInStorage@Items@GW@@YAIXZ
/ALTERNATENAME:_?GetGoldAmountOnCharacter@Items@GW@@YAIXZ=?GetGoldAmountOnCharacter@Items@GW@@YAIXZ
/ALTERNATENAME:_?GetHeroInventory@Items@GW@@YAPAUInventory@2@W4HeroID@Constants@2@@Z=?GetHeroInventory@Items@GW@@YAPAUInventory@2@W4HeroID@Constants@2@@Z
/ALTERNATENAME:_?GetHoveredItem@Items@GW@@YAPAUItem@2@XZ=?GetHoveredItem@Items@GW@@YAPAUItem@2@XZ
/ALTERNATENAME:_?GetInventory@Items@GW@@YAPAUInventory@2@XZ=?GetInventory@Items@GW@@YAPAUInventory@2@XZ
/ALTERNATENAME:_?GetIsStorageOpen@Items@GW@@YA_NXZ=?GetIsStorageOpen@Items@GW@@YA_NXZ
/ALTERNATENAME:_?GetItemArray@Items@GW@@YAPAV?$Array@PAUItem@GW@@@2@XZ=?GetItemArray@Items@GW@@YAPAV?$Array@PAUItem@GW@@@2@XZ
/ALTERNATENAME:_?GetItemById@Items@GW@@YAPAUItem@2@I@Z=?GetItemById@Items@GW@@YAPAUItem@2@I@Z
/ALTERNATENAME:_?GetItemByModelId@Items@GW@@YAPAUItem@2@IHH@Z=?GetItemByModelId@Items@GW@@YAPAUItem@2@IHH@Z
/ALTERNATENAME:_?GetItemByModelIdAndModifiers@Items@GW@@YAPAUItem@2@IPBUItemModifier@2@IHH@Z=?GetItemByModelIdAndModifiers@Items@GW@@YAPAUItem@2@IPBUItemModifier@2@IHH@Z
/ALTERNATENAME:_?GetItemBySlot@Items@GW@@YAPAUItem@2@PBUBag@2@I@Z=?GetItemBySlot@Items@GW@@YAPAUItem@2@PBUBag@2@I@Z
/ALTERNATENAME:_?GetItemFormula@Items@GW@@YAPBUItemFormula@2@PBUItem@2@@Z=?GetItemFormula@Items@GW@@YAPBUItemFormula@2@PBUItem@2@@Z
/ALTERNATENAME:_?GetMaterialSlot@Items@GW@@YA?AW4MaterialSlot@Constants@2@PBUItem@2@@Z=?GetMaterialSlot@Items@GW@@YA?AW4MaterialSlot@Constants@2@PBUItem@2@@Z
/ALTERNATENAME:_?GetMaterialStorageStackSize@Items@GW@@YAIXZ=?GetMaterialStorageStackSize@Items@GW@@YAIXZ
/ALTERNATENAME:_?GetPvPItemInfo@Items@GW@@YAPBUPvPItemInfo@2@I@Z=?GetPvPItemInfo@Items@GW@@YAPBUPvPItemInfo@2@I@Z
/ALTERNATENAME:_?GetPvPItemUpgrade@Items@GW@@YAPBUPvPItemUpgradeInfo@2@I@Z=?GetPvPItemUpgrade@Items@GW@@YAPBUPvPItemUpgradeInfo@2@I@Z
/ALTERNATENAME:_?GetPvPItemUpgradeEncodedDescription@Items@GW@@YA_NIPAPA_W@Z=?GetPvPItemUpgradeEncodedDescription@Items@GW@@YA_NIPAPA_W@Z
/ALTERNATENAME:_?GetPvPItemUpgradeEncodedName@Items@GW@@YA_NIPAPA_W@Z=?GetPvPItemUpgradeEncodedName@Items@GW@@YA_NIPAPA_W@Z
/ALTERNATENAME:_?GetSalvageSessionInfo@Items@GW@@YAPAUSalvageSessionInfo@2@XZ=?GetSalvageSessionInfo@Items@GW@@YAPAUSalvageSessionInfo@2@XZ
/ALTERNATENAME:_?GetStoragePage@Items@GW@@YA?AW4StoragePane@Constants@2@XZ=?GetStoragePage@Items@GW@@YA?AW4StoragePane@Constants@2@XZ
/ALTERNATENAME:_?IdentifyItem@Items@GW@@YA_NII@Z=?IdentifyItem@Items@GW@@YA_NII@Z
/ALTERNATENAME:_?MoveItem@Items@GW@@YA_NPBUItem@2@0I@Z=?MoveItem@Items@GW@@YA_NPBUItem@2@0I@Z
/ALTERNATENAME:_?MoveItem@Items@GW@@YA_NPBUItem@2@PBUBag@2@II@Z=?MoveItem@Items@GW@@YA_NPBUItem@2@PBUBag@2@II@Z
/ALTERNATENAME:_?MoveItem@Items@GW@@YA_NPBUItem@2@W4Bag@Constants@2@II@Z=?MoveItem@Items@GW@@YA_NPBUItem@2@W4Bag@Constants@2@II@Z
/ALTERNATENAME:_?OpenXunlaiWindow@Items@GW@@YA_N_N0@Z=?OpenXunlaiWindow@Items@GW@@YA_N_N0@Z
/ALTERNATENAME:_?PickUpItem@Items@GW@@YA_NPBUItem@2@I@Z=?PickUpItem@Items@GW@@YA_NPBUItem@2@I@Z
/ALTERNATENAME:_?PingWeaponSet@Items@GW@@YA_NIII@Z=?PingWeaponSet@Items@GW@@YA_NIII@Z
/ALTERNATENAME:_?RemoveItemClickCallback@Items@GW@@YAXPAUHookEntry@2@@Z=?RemoveItemClickCallback@Items@GW@@YAXPAUHookEntry@2@@Z
/ALTERNATENAME:_?SalvageMaterials@Items@GW@@YA_NXZ=?SalvageMaterials@Items@GW@@YA_NXZ
/ALTERNATENAME:_?SalvageSessionCancel@Items@GW@@YA_NXZ=?SalvageSessionCancel@Items@GW@@YA_NXZ
/ALTERNATENAME:_?SalvageStart@Items@GW@@YA_NII@Z=?SalvageStart@Items@GW@@YA_NII@Z
/ALTERNATENAME:_?SetEquipmentVisibility@Items@GW@@YA_NW4EquipmentType@2@W4EquipmentStatus@2@@Z=?SetEquipmentVisibility@Items@GW@@YA_NW4EquipmentType@2@W4EquipmentStatus@2@@Z
/ALTERNATENAME:_?UseItem@Items@GW@@YA_NPBUItem@2@@Z=?UseItem@Items@GW@@YA_NPBUItem@2@@Z
/ALTERNATENAME:_?UseItemByModelId@Items@GW@@YA_NIHH@Z=?UseItemByModelId@Items@GW@@YA_NIHH@Z
/ALTERNATENAME:_?WithdrawGold@Items@GW@@YAII@Z=?WithdrawGold@Items@GW@@YAII@Z
/ALTERNATENAME:_?CancelEnterChallenge@Map@GW@@YA_NXZ=?CancelEnterChallenge@Map@GW@@YA_NXZ
/ALTERNATENAME:_?CreateMapContext@Map@GW@@YAPAUMapContext@2@I@Z=?CreateMapContext@Map@GW@@YAPAUMapContext@2@I@Z
/ALTERNATENAME:_?DestroyMapContext@Map@GW@@YA_NPAUMapContext@2@@Z=?DestroyMapContext@Map@GW@@YA_NPAUMapContext@2@@Z
/ALTERNATENAME:_?EnterChallenge@Map@GW@@YA_NXZ=?EnterChallenge@Map@GW@@YA_NXZ
/ALTERNATENAME:_?GetDistrict@Map@GW@@YAHXZ=?GetDistrict@Map@GW@@YAHXZ
/ALTERNATENAME:_?GetFoesKilled@Map@GW@@YAIXZ=?GetFoesKilled@Map@GW@@YAIXZ
/ALTERNATENAME:_?GetFoesToKill@Map@GW@@YAIXZ=?GetFoesToKill@Map@GW@@YAIXZ
/ALTERNATENAME:_?GetInstanceTime@Map@GW@@YAIXZ=?GetInstanceTime@Map@GW@@YAIXZ
/ALTERNATENAME:_?GetInstanceType@Map@GW@@YA?AW4InstanceType@Constants@2@XZ=?GetInstanceType@Map@GW@@YA?AW4InstanceType@Constants@2@XZ
/ALTERNATENAME:_?GetIsInCinematic@Map@GW@@YA_NXZ=?GetIsInCinematic@Map@GW@@YA_NXZ
/ALTERNATENAME:_?GetIsMapLoaded@Map@GW@@YA_NXZ=?GetIsMapLoaded@Map@GW@@YA_NXZ
/ALTERNATENAME:_?GetIsMapUnlocked@Map@GW@@YA_NW4MapID@Constants@2@@Z=?GetIsMapUnlocked@Map@GW@@YA_NW4MapID@Constants@2@@Z
/ALTERNATENAME:_?GetIsObserving@Map@GW@@YA_NXZ=?GetIsObserving@Map@GW@@YA_NXZ
/ALTERNATENAME:_?GetLanguage@Map@GW@@YA?AW4Language@Constants@2@XZ=?GetLanguage@Map@GW@@YA?AW4Language@Constants@2@XZ
/ALTERNATENAME:_?GetMapID@Map@GW@@YA?AW4MapID@Constants@2@XZ=?GetMapID@Map@GW@@YA?AW4MapID@Constants@2@XZ
/ALTERNATENAME:_?GetMapInfo@Map@GW@@YAPAUAreaInfo@2@W4MapID@Constants@2@@Z=?GetMapInfo@Map@GW@@YAPAUAreaInfo@2@W4MapID@Constants@2@@Z
/ALTERNATENAME:_?GetMissionMapContext@Map@GW@@YAPAUMissionMapContext@2@XZ=?GetMissionMapContext@Map@GW@@YAPAUMissionMapContext@2@XZ
/ALTERNATENAME:_?GetMissionMapIconArray@Map@GW@@YAPAV?$Array@UMissionMapIcon@GW@@@2@XZ=?GetMissionMapIconArray@Map@GW@@YAPAV?$Array@UMissionMapIcon@GW@@@2@XZ
/ALTERNATENAME:_?GetRegion@Map@GW@@YA?AW4ServerRegion@Constants@2@XZ=?GetRegion@Map@GW@@YA?AW4ServerRegion@Constants@2@XZ
/ALTERNATENAME:_?GetWorldMapContext@Map@GW@@YAPAUWorldMapContext@2@XZ=?GetWorldMapContext@Map@GW@@YAPAUWorldMapContext@2@XZ
/ALTERNATENAME:_?LanguageFromDistrict@Map@GW@@YA?AW4Language@Constants@2@W4District@42@@Z=?LanguageFromDistrict@Map@GW@@YA?AW4Language@Constants@2@W4District@42@@Z
/ALTERNATENAME:_?QueryAltitude@Map@GW@@YAMPBUGamePos@2@MPAUMapContext@2@@Z=?QueryAltitude@Map@GW@@YAMPBUGamePos@2@MPAUMapContext@2@@Z
/ALTERNATENAME:_?RegionFromDistrict@Map@GW@@YA?AW4ServerRegion@Constants@2@W4District@42@@Z=?RegionFromDistrict@Map@GW@@YA?AW4ServerRegion@Constants@2@W4District@42@@Z
/ALTERNATENAME:_?SkipCinematic@Map@GW@@YA_NXZ=?SkipCinematic@Map@GW@@YA_NXZ
/ALTERNATENAME:_?Travel@Map@GW@@YA_NW4MapID@Constants@2@W4District@42@H@Z=?Travel@Map@GW@@YA_NW4MapID@Constants@2@W4District@42@H@Z
/ALTERNATENAME:_?Travel@Map@GW@@YA_NW4MapID@Constants@2@W4ServerRegion@42@HW4Language@42@@Z=?Travel@Map@GW@@YA_NW4MapID@Constants@2@W4ServerRegion@42@HW4Language@42@@Z
/ALTERNATENAME:_?GetGWVersion@MemoryMgr@GW@@YAIXZ=?GetGWVersion@MemoryMgr@GW@@YAIXZ
/ALTERNATENAME:_?GetGWWindowHandle@MemoryMgr@GW@@YAPAUHWND__@@XZ=?GetGWWindowHandle@MemoryMgr@GW@@YAPAUHWND__@@XZ
/ALTERNATENAME:_?GetPersonalDir@MemoryMgr@GW@@YA_NIPA_W@Z=?GetPersonalDir@MemoryMgr@GW@@YA_NIPA_W@Z
/ALTERNATENAME:_?GetSkillTimer@MemoryMgr@GW@@YAKXZ=?GetSkillTimer@MemoryMgr@GW@@YAKXZ
/ALTERNATENAME:_?MemAlloc@MemoryMgr@GW@@YAPAXI@Z=?MemAlloc@MemoryMgr@GW@@YAPAXI@Z
/ALTERNATENAME:_?MemFree@MemoryMgr@GW@@YAXPAX@Z=?MemFree@MemoryMgr@GW@@YAXPAX@Z
/ALTERNATENAME:_?MemRealloc@MemoryMgr@GW@@YAPAXPAXI@Z=?MemRealloc@MemoryMgr@GW@@YAPAXPAXI@Z
/ALTERNATENAME:_?DisableHooks@MemoryPatcher@GW@@SAXXZ=?DisableHooks@MemoryPatcher@GW@@SAXXZ
/ALTERNATENAME:_?EnableHooks@MemoryPatcher@GW@@SAXXZ=?EnableHooks@MemoryPatcher@GW@@SAXXZ
/ALTERNATENAME:_?Reset@MemoryPatcher@GW@@QAEXXZ=?Reset@MemoryPatcher@GW@@QAEXXZ
/ALTERNATENAME:_?SetPatch@MemoryPatcher@GW@@QAEXIPBDI@Z=?SetPatch@MemoryPatcher@GW@@QAEXIPBDI@Z
/ALTERNATENAME:_?SetRedirect@MemoryPatcher@GW@@QAE_NIPAX@Z=?SetRedirect@MemoryPatcher@GW@@QAE_NIPAX@Z
/ALTERNATENAME:_?TogglePatch@MemoryPatcher@GW@@QAE_N_N@Z=?TogglePatch@MemoryPatcher@GW@@QAE_N_N@Z
/ALTERNATENAME:_?GetMerchantItems@Merchant@GW@@YAIW4TransactionType@12@IPAI@Z=?GetMerchantItems@Merchant@GW@@YAIW4TransactionType@12@IPAI@Z
/ALTERNATENAME:_?RequestQuote@Merchant@GW@@YA_NW4TransactionType@12@I@Z=?RequestQuote@Merchant@GW@@YA_NW4TransactionType@12@I@Z
/ALTERNATENAME:_?TransactItems@Merchant@GW@@YA_NXZ=?TransactItems@Merchant@GW@@YA_NXZ
/ALTERNATENAME:_?GetDecodedLabel@MultiLineTextLabelFrame@GW@@QAEPB_WXZ=?GetDecodedLabel@MultiLineTextLabelFrame@GW@@QAEPB_WXZ
/ALTERNATENAME:_?GetEncodedLabel@MultiLineTextLabelFrame@GW@@QAEPB_WXZ=?GetEncodedLabel@MultiLineTextLabelFrame@GW@@QAEPB_WXZ
/ALTERNATENAME:_?SetLabel@MultiLineTextLabelFrame@GW@@QAE_NPB_W@Z=?SetLabel@MultiLineTextLabelFrame@GW@@QAE_NPB_W@Z
/ALTERNATENAME:_?AddHenchman@PartyMgr@GW@@YA_NI@Z=?AddHenchman@PartyMgr@GW@@YA_NI@Z
/ALTERNATENAME:_?AddHero@PartyMgr@GW@@YA_NW4HeroID@Constants@2@@Z=?AddHero@PartyMgr@GW@@YA_NW4HeroID@Constants@2@@Z
/ALTERNATENAME:_?FlagAll@PartyMgr@GW@@YA_NUGamePos@2@@Z=?FlagAll@PartyMgr@GW@@YA_NUGamePos@2@@Z
/ALTERNATENAME:_?FlagHero@PartyMgr@GW@@YA_NIUGamePos@2@@Z=?FlagHero@PartyMgr@GW@@YA_NIUGamePos@2@@Z
/ALTERNATENAME:_?GetAgentAttributes@PartyMgr@GW@@YAPAUAttribute@2@I@Z=?GetAgentAttributes@PartyMgr@GW@@YAPAUAttribute@2@I@Z
/ALTERNATENAME:_?GetHeroAgentID@PartyMgr@GW@@YAII@Z=?GetHeroAgentID@PartyMgr@GW@@YAII@Z
/ALTERNATENAME:_?GetHeroConstData@PartyMgr@GW@@YAPAUHeroConstData@2@W4HeroID@Constants@2@@Z=?GetHeroConstData@PartyMgr@GW@@YAPAUHeroConstData@2@W4HeroID@Constants@2@@Z
/ALTERNATENAME:_?GetHeroInfo@PartyMgr@GW@@YAPAUHeroInfo@2@W4HeroID@Constants@2@@Z=?GetHeroInfo@PartyMgr@GW@@YAPAUHeroInfo@2@W4HeroID@Constants@2@@Z
/ALTERNATENAME:_?GetIsHardModeUnlocked@PartyMgr@GW@@YA_NXZ=?GetIsHardModeUnlocked@PartyMgr@GW@@YA_NXZ
/ALTERNATENAME:_?GetIsLeader@PartyMgr@GW@@YA_NXZ=?GetIsLeader@PartyMgr@GW@@YA_NXZ
/ALTERNATENAME:_?GetIsPartyDefeated@PartyMgr@GW@@YA_NXZ=?GetIsPartyDefeated@PartyMgr@GW@@YA_NXZ
/ALTERNATENAME:_?GetIsPartyInHardMode@PartyMgr@GW@@YA_NXZ=?GetIsPartyInHardMode@PartyMgr@GW@@YA_NXZ
/ALTERNATENAME:_?GetIsPartyLoaded@PartyMgr@GW@@YA_NXZ=?GetIsPartyLoaded@PartyMgr@GW@@YA_NXZ
/ALTERNATENAME:_?GetIsPartyTicked@PartyMgr@GW@@YA_NXZ=?GetIsPartyTicked@PartyMgr@GW@@YA_NXZ
/ALTERNATENAME:_?GetIsPlayerTicked@PartyMgr@GW@@YA_NI@Z=?GetIsPlayerTicked@PartyMgr@GW@@YA_NI@Z
/ALTERNATENAME:_?GetPartyHenchmanCount@PartyMgr@GW@@YAIXZ=?GetPartyHenchmanCount@PartyMgr@GW@@YAIXZ
/ALTERNATENAME:_?GetPartyHeroCount@PartyMgr@GW@@YAIXZ=?GetPartyHeroCount@PartyMgr@GW@@YAIXZ
/ALTERNATENAME:_?GetPartyInfo@PartyMgr@GW@@YAPAUPartyInfo@2@I@Z=?GetPartyInfo@PartyMgr@GW@@YAPAUPartyInfo@2@I@Z
/ALTERNATENAME:_?GetPartyPlayerCount@PartyMgr@GW@@YAIXZ=?GetPartyPlayerCount@PartyMgr@GW@@YAIXZ
/ALTERNATENAME:_?GetPartySearch@PartyMgr@GW@@YAPAUPartySearch@2@I@Z=?GetPartySearch@PartyMgr@GW@@YAPAUPartySearch@2@I@Z
/ALTERNATENAME:_?GetPartySize@PartyMgr@GW@@YAIXZ=?GetPartySize@PartyMgr@GW@@YAIXZ
/ALTERNATENAME:_?GetPetInfo@PartyMgr@GW@@YAPAUPetInfo@2@I@Z=?GetPetInfo@PartyMgr@GW@@YAPAUPetInfo@2@I@Z
/ALTERNATENAME:_?InvitePlayer@PartyMgr@GW@@YA_NI@Z=?InvitePlayer@PartyMgr@GW@@YA_NI@Z
/ALTERNATENAME:_?InvitePlayer@PartyMgr@GW@@YA_NPB_W@Z=?InvitePlayer@PartyMgr@GW@@YA_NPB_W@Z
/ALTERNATENAME:_?KickAllHeroes@PartyMgr@GW@@YA_NXZ=?KickAllHeroes@PartyMgr@GW@@YA_NXZ
/ALTERNATENAME:_?KickHenchman@PartyMgr@GW@@YA_NI@Z=?KickHenchman@PartyMgr@GW@@YA_NI@Z
/ALTERNATENAME:_?KickHero@PartyMgr@GW@@YA_NW4HeroID@Constants@2@@Z=?KickHero@PartyMgr@GW@@YA_NW4HeroID@Constants@2@@Z
/ALTERNATENAME:_?KickPlayer@PartyMgr@GW@@YA_NI@Z=?KickPlayer@PartyMgr@GW@@YA_NI@Z
/ALTERNATENAME:_?LeaveParty@PartyMgr@GW@@YA_NXZ=?LeaveParty@PartyMgr@GW@@YA_NXZ
/ALTERNATENAME:_?RespondToPartyRequest@PartyMgr@GW@@YA_NI_N@Z=?RespondToPartyRequest@PartyMgr@GW@@YA_NI_N@Z
/ALTERNATENAME:_?ReturnToOutpost@PartyMgr@GW@@YA_NXZ=?ReturnToOutpost@PartyMgr@GW@@YA_NXZ
/ALTERNATENAME:_?SearchParty@PartyMgr@GW@@YA_NIPB_W@Z=?SearchParty@PartyMgr@GW@@YA_NIPB_W@Z
/ALTERNATENAME:_?SearchPartyCancel@PartyMgr@GW@@YA_NXZ=?SearchPartyCancel@PartyMgr@GW@@YA_NXZ
/ALTERNATENAME:_?SearchPartyReply@PartyMgr@GW@@YA_NI_N@Z=?SearchPartyReply@PartyMgr@GW@@YA_NI_N@Z
/ALTERNATENAME:_?SetHardMode@PartyMgr@GW@@YA_N_N@Z=?SetHardMode@PartyMgr@GW@@YA_N_N@Z
/ALTERNATENAME:_?SetHeroBehavior@PartyMgr@GW@@YA_NIW4HeroBehavior@2@@Z=?SetHeroBehavior@PartyMgr@GW@@YA_NIW4HeroBehavior@2@@Z
/ALTERNATENAME:_?SetHeroSkillDisabled@PartyMgr@GW@@YA_NII_N@Z=?SetHeroSkillDisabled@PartyMgr@GW@@YA_NII_N@Z
/ALTERNATENAME:_?SetHeroTarget@PartyMgr@GW@@YA_NII@Z=?SetHeroTarget@PartyMgr@GW@@YA_NII@Z
/ALTERNATENAME:_?SetPetBehavior@PartyMgr@GW@@YA_NIW4HeroBehavior@2@@Z=?SetPetBehavior@PartyMgr@GW@@YA_NIW4HeroBehavior@2@@Z
/ALTERNATENAME:_?SetTickToggle@PartyMgr@GW@@YAX_N@Z=?SetTickToggle@PartyMgr@GW@@YAX_N@Z
/ALTERNATENAME:_?Tick@PartyMgr@GW@@YA_N_N@Z=?Tick@PartyMgr@GW@@YA_N_N@Z
/ALTERNATENAME:_?UnflagAll@PartyMgr@GW@@YA_NXZ=?UnflagAll@PartyMgr@GW@@YA_NXZ
/ALTERNATENAME:_?UnflagHero@PartyMgr@GW@@YA_NI@Z=?UnflagHero@PartyMgr@GW@@YA_NI@Z
/ALTERNATENAME:_?ChangeSecondProfession@PlayerMgr@GW@@YA_NW4Profession@Constants@2@I@Z=?ChangeSecondProfession@PlayerMgr@GW@@YA_NW4Profession@Constants@2@I@Z
/ALTERNATENAME:_?GetActiveTitle@PlayerMgr@GW@@YAPAUTitle@2@XZ=?GetActiveTitle@PlayerMgr@GW@@YAPAUTitle@2@XZ
/ALTERNATENAME:_?GetActiveTitleId@PlayerMgr@GW@@YA?AW4TitleID@Constants@2@XZ=?GetActiveTitleId@PlayerMgr@GW@@YA?AW4TitleID@Constants@2@XZ
/ALTERNATENAME:_?GetAmountOfPlayersInInstance@PlayerMgr@GW@@YAIXZ=?GetAmountOfPlayersInInstance@PlayerMgr@GW@@YAIXZ
/ALTERNATENAME:_?GetPlayerAgentId@PlayerMgr@GW@@YAII@Z=?GetPlayerAgentId@PlayerMgr@GW@@YAII@Z
/ALTERNATENAME:_?GetPlayerArray@PlayerMgr@GW@@YAPAV?$Array@UPlayer@GW@@@2@XZ=?GetPlayerArray@PlayerMgr@GW@@YAPAV?$Array@UPlayer@GW@@@2@XZ
/ALTERNATENAME:_?GetPlayerByID@PlayerMgr@GW@@YAPAUPlayer@2@I@Z=?GetPlayerByID@PlayerMgr@GW@@YAPAUPlayer@2@I@Z
/ALTERNATENAME:_?GetPlayerByName@PlayerMgr@GW@@YAPAUPlayer@2@PB_W@Z=?GetPlayerByName@PlayerMgr@GW@@YAPAUPlayer@2@PB_W@Z
/ALTERNATENAME:_?GetPlayerName@PlayerMgr@GW@@YAPA_WI@Z=?GetPlayerName@PlayerMgr@GW@@YAPA_WI@Z
/ALTERNATENAME:_?GetPlayerNumber@PlayerMgr@GW@@YAIXZ=?GetPlayerNumber@PlayerMgr@GW@@YAIXZ
/ALTERNATENAME:_?GetTitleData@PlayerMgr@GW@@YAPAUTitleClientData@2@W4TitleID@Constants@2@@Z=?GetTitleData@PlayerMgr@GW@@YAPAUTitleClientData@2@W4TitleID@Constants@2@@Z
/ALTERNATENAME:_?GetTitleTrack@PlayerMgr@GW@@YAPAUTitle@2@W4TitleID@Constants@2@@Z=?GetTitleTrack@PlayerMgr@GW@@YAPAUTitle@2@W4TitleID@Constants@2@@Z
/ALTERNATENAME:_?RemoveActiveTitle@PlayerMgr@GW@@YA_NXZ=?RemoveActiveTitle@PlayerMgr@GW@@YA_NXZ
/ALTERNATENAME:_?SetActiveTitle@PlayerMgr@GW@@YA_NW4TitleID@Constants@2@@Z=?SetActiveTitle@PlayerMgr@GW@@YA_NW4TitleID@Constants@2@@Z
/ALTERNATENAME:_?SetPlayerName@PlayerMgr@GW@@YAPA_WIPB_W@Z=?SetPlayerName@PlayerMgr@GW@@YAPA_WIPB_W@Z
/ALTERNATENAME:_?GetValue@ProgressBar@GW@@UAEIXZ=?GetValue@ProgressBar@GW@@UAEIXZ
/ALTERNATENAME:_?SetColorId@ProgressBar@GW@@QAE_NI@Z=?SetColorId@ProgressBar@GW@@QAE_NI@Z
/ALTERNATENAME:_?SetMax@ProgressBar@GW@@QAE_NI@Z=?SetMax@ProgressBar@GW@@QAE_NI@Z
/ALTERNATENAME:_?SetStyle@ProgressBar@GW@@QAE_NW4ProgressBarStyle@12@@Z=?SetStyle@ProgressBar@GW@@QAE_NW4ProgressBarStyle@12@@Z
/ALTERNATENAME:_?SetValue@ProgressBar@GW@@UAE_NI@Z=?SetValue@ProgressBar@GW@@UAE_NI@Z
/ALTERNATENAME:_?AbandonQuest@QuestMgr@GW@@YA_NPAUQuest@2@@Z=?AbandonQuest@QuestMgr@GW@@YA_NPAUQuest@2@@Z
/ALTERNATENAME:_?AbandonQuestId@QuestMgr@GW@@YA_NW4QuestID@Constants@2@@Z=?AbandonQuestId@QuestMgr@GW@@YA_NW4QuestID@Constants@2@@Z
/ALTERNATENAME:_?GetActiveQuest@QuestMgr@GW@@YAPAUQuest@2@XZ=?GetActiveQuest@QuestMgr@GW@@YAPAUQuest@2@XZ
/ALTERNATENAME:_?GetActiveQuestId@QuestMgr@GW@@YA?AW4QuestID@Constants@2@XZ=?GetActiveQuestId@QuestMgr@GW@@YA?AW4QuestID@Constants@2@XZ
/ALTERNATENAME:_?GetQuest@QuestMgr@GW@@YAPAUQuest@2@W4QuestID@Constants@2@@Z=?GetQuest@QuestMgr@GW@@YAPAUQuest@2@W4QuestID@Constants@2@@Z
/ALTERNATENAME:_?GetQuestEntryGroupName@QuestMgr@GW@@YA_NW4QuestID@Constants@2@PA_WI@Z=?GetQuestEntryGroupName@QuestMgr@GW@@YA_NW4QuestID@Constants@2@PA_WI@Z
/ALTERNATENAME:_?GetQuestLog@QuestMgr@GW@@YAPAV?$Array@UQuest@GW@@@2@XZ=?GetQuestLog@QuestMgr@GW@@YAPAV?$Array@UQuest@GW@@@2@XZ
/ALTERNATENAME:_?RequestQuestInfo@QuestMgr@GW@@YA_NPBUQuest@2@_N@Z=?RequestQuestInfo@QuestMgr@GW@@YA_NPBUQuest@2@_N@Z
/ALTERNATENAME:_?RequestQuestInfoId@QuestMgr@GW@@YA_NW4QuestID@Constants@2@_N@Z=?RequestQuestInfoId@QuestMgr@GW@@YA_NW4QuestID@Constants@2@_N@Z
/ALTERNATENAME:_?SetActiveQuest@QuestMgr@GW@@YA_NPAUQuest@2@@Z=?SetActiveQuest@QuestMgr@GW@@YA_NPAUQuest@2@@Z
/ALTERNATENAME:_?SetActiveQuestId@QuestMgr@GW@@YA_NW4QuestID@Constants@2@@Z=?SetActiveQuestId@QuestMgr@GW@@YA_NW4QuestID@Constants@2@@Z
/ALTERNATENAME:_?EnableHooks@Render@GW@@YAXXZ=?EnableHooks@Render@GW@@YAXXZ
/ALTERNATENAME:_?GetFieldOfView@Render@GW@@YAMXZ=?GetFieldOfView@Render@GW@@YAMXZ
/ALTERNATENAME:_?GetFrameLimit@Render@GW@@YAIXZ=?GetFrameLimit@Render@GW@@YAIXZ
/ALTERNATENAME:_?GetGraphicsRendererValue@Render@GW@@YAIW4Metric@12@I@Z=?GetGraphicsRendererValue@Render@GW@@YAIW4Metric@12@I@Z
/ALTERNATENAME:_?GetIsFullscreen@Render@GW@@YAHXZ=?GetIsFullscreen@Render@GW@@YAHXZ
/ALTERNATENAME:_?GetIsInRenderLoop@Render@GW@@YA_NXZ=?GetIsInRenderLoop@Render@GW@@YA_NXZ
/ALTERNATENAME:_?GetRenderCallback@Render@GW@@YAP6AXPAUIDirect3DDevice9@@@ZXZ=?GetRenderCallback@Render@GW@@YAP6AXPAUIDirect3DDevice9@@@ZXZ
/ALTERNATENAME:_?GetTransform@Render@GW@@YAPAUMat4x3f@12@W4Transform@12@@Z=?GetTransform@Render@GW@@YAPAUMat4x3f@12@W4Transform@12@@Z
/ALTERNATENAME:_?GetViewportHeight@Render@GW@@YAIXZ=?GetViewportHeight@Render@GW@@YAIXZ
/ALTERNATENAME:_?GetViewportWidth@Render@GW@@YAIXZ=?GetViewportWidth@Render@GW@@YAIXZ
/ALTERNATENAME:_?GetWindowHandle@Render@GW@@YAPAUHWND__@@XZ=?GetWindowHandle@Render@GW@@YAPAUHWND__@@XZ
/ALTERNATENAME:_?SetFog@Render@GW@@YA_N_N@Z=?SetFog@Render@GW@@YA_N_N@Z
/ALTERNATENAME:_?SetFrameLimit@Render@GW@@YA_NI@Z=?SetFrameLimit@Render@GW@@YA_NI@Z
/ALTERNATENAME:_?SetGraphicsRendererValue@Render@GW@@YA_NW4Metric@12@II@Z=?SetGraphicsRendererValue@Render@GW@@YA_NW4Metric@12@II@Z
/ALTERNATENAME:_?SetRenderCallback@Render@GW@@YAXP6AXPAUIDirect3DDevice9@@@Z@Z=?SetRenderCallback@Render@GW@@YAXP6AXPAUIDirect3DDevice9@@@Z@Z
/ALTERNATENAME:_?SetResetCallback@Render@GW@@YAXP6AXPAUIDirect3DDevice9@@@Z@Z=?SetResetCallback@Render@GW@@YAXP6AXPAUIDirect3DDevice9@@@Z@Z
/ALTERNATENAME:_?Find@Scanner@GW@@YAIPBD0HW4ScannerSection@2@@Z=?Find@Scanner@GW@@YAIPBD0HW4ScannerSection@2@@Z
/ALTERNATENAME:_?FindAssertion@Scanner@GW@@YAIPBD0IH@Z=?FindAssertion@Scanner@GW@@YAIPBD0IH@Z
/ALTERNATENAME:_?FindInRange@Scanner@GW@@YAIPBD0HKK@Z=?FindInRange@Scanner@GW@@YAIPBD0HKK@Z
/ALTERNATENAME:_?FindNthUseOfString@Scanner@GW@@YAIPBDIHW4ScannerSection@2@@Z=?FindNthUseOfString@Scanner@GW@@YAIPBDIHW4ScannerSection@2@@Z
/ALTERNATENAME:_?FindNthUseOfString@Scanner@GW@@YAIPB_WIHW4ScannerSection@2@@Z=?FindNthUseOfString@Scanner@GW@@YAIPB_WIHW4ScannerSection@2@@Z
/ALTERNATENAME:_?FindUseOfString@Scanner@GW@@YAIPBDHW4ScannerSection@2@@Z=?FindUseOfString@Scanner@GW@@YAIPBDHW4ScannerSection@2@@Z
/ALTERNATENAME:_?FindUseOfString@Scanner@GW@@YAIPB_WHW4ScannerSection@2@@Z=?FindUseOfString@Scanner@GW@@YAIPB_WHW4ScannerSection@2@@Z
/ALTERNATENAME:_?FunctionFromNearCall@Scanner@GW@@YAII_N@Z=?FunctionFromNearCall@Scanner@GW@@YAII_N@Z
/ALTERNATENAME:_?GetGameTlsIndex@Scanner@GW@@YAKXZ=?GetGameTlsIndex@Scanner@GW@@YAKXZ
/ALTERNATENAME:_?GetSectionAddressRange@Scanner@GW@@YAXW4ScannerSection@2@PAI1@Z=?GetSectionAddressRange@Scanner@GW@@YAXW4ScannerSection@2@PAI1@Z
/ALTERNATENAME:_?Initialize@Scanner@GW@@YAXPBD@Z=?Initialize@Scanner@GW@@YAXPBD@Z
/ALTERNATENAME:_?IsValidPtr@Scanner@GW@@YA_NIW4ScannerSection@2@@Z=?IsValidPtr@Scanner@GW@@YA_NIW4ScannerSection@2@@Z
/ALTERNATENAME:_?ToFunctionStart@Scanner@GW@@YAIII@Z=?ToFunctionStart@Scanner@GW@@YAIII@Z
/ALTERNATENAME:_?AddItem@ScrollableFrame@GW@@QAE_NIIP6AXPAUInteractionMessage@UI@2@PAX1@Z@Z=?AddItem@ScrollableFrame@GW@@QAE_NIIP6AXPAUInteractionMessage@UI@2@PAX1@Z@Z
/ALTERNATENAME:_?ClearItems@ScrollableFrame@GW@@QAE_NXZ=?ClearItems@ScrollableFrame@GW@@QAE_NXZ
/ALTERNATENAME:_?GetCount@ScrollableFrame@GW@@QAE_NPAI@Z=?GetCount@ScrollableFrame@GW@@QAE_NPAI@Z
/ALTERNATENAME:_?GetItemFrameId@ScrollableFrame@GW@@QAEII@Z=?GetItemFrameId@ScrollableFrame@GW@@QAEII@Z
/ALTERNATENAME:_?GetItems@ScrollableFrame@GW@@QAEIPAII@Z=?GetItems@ScrollableFrame@GW@@QAEIPAII@Z
/ALTERNATENAME:_?GetSelectedValue@ScrollableFrame@GW@@QAE_NPAI@Z=?GetSelectedValue@ScrollableFrame@GW@@QAE_NPAI@Z
/ALTERNATENAME:_?GetSortHandler@ScrollableFrame@GW@@QAEP6AHII@ZXZ=?GetSortHandler@ScrollableFrame@GW@@QAEP6AHII@ZXZ
/ALTERNATENAME:_?RemoveItem@ScrollableFrame@GW@@QAE_NI@Z=?RemoveItem@ScrollableFrame@GW@@QAE_NI@Z
/ALTERNATENAME:_?SetSortHandler@ScrollableFrame@GW@@QAE_NP6AHII@Z@Z=?SetSortHandler@ScrollableFrame@GW@@QAE_NP6AHII@Z@Z
/ALTERNATENAME:_?GetSkillById@Skillbar@GW@@QAEPAUSkillbarSkill@2@W4SkillID@Constants@2@PAI@Z=?GetSkillById@Skillbar@GW@@QAEPAUSkillbarSkill@2@W4SkillID@Constants@2@PAI@Z
/ALTERNATENAME:_?ChangeSecondProfession@SkillbarMgr@GW@@YA_NIW4Profession@Constants@2@@Z=?ChangeSecondProfession@SkillbarMgr@GW@@YA_NIW4Profession@Constants@2@@Z
/ALTERNATENAME:_?DecodeSkillTemplate@SkillbarMgr@GW@@YA_NAAUSkillTemplate@12@PBD@Z=?DecodeSkillTemplate@SkillbarMgr@GW@@YA_NAAUSkillTemplate@12@PBD@Z
/ALTERNATENAME:_?EncodeSkillTemplate@SkillbarMgr@GW@@YA_NABUSkillTemplate@12@PADI@Z=?EncodeSkillTemplate@SkillbarMgr@GW@@YA_NABUSkillTemplate@12@PADI@Z
/ALTERNATENAME:_?GetAttributeConstantData@SkillbarMgr@GW@@YAPAUAttributeInfo@2@W4Attribute@Constants@2@@Z=?GetAttributeConstantData@SkillbarMgr@GW@@YAPAUAttributeInfo@2@W4Attribute@Constants@2@@Z
/ALTERNATENAME:_?GetHeroSkillbar@SkillbarMgr@GW@@YAPAUSkillbar@2@I@Z=?GetHeroSkillbar@SkillbarMgr@GW@@YAPAUSkillbar@2@I@Z
/ALTERNATENAME:_?GetHoveredSkill@SkillbarMgr@GW@@YAPAUSkill@2@XZ=?GetHoveredSkill@SkillbarMgr@GW@@YAPAUSkill@2@XZ
/ALTERNATENAME:_?GetIsSkillLearnt@SkillbarMgr@GW@@YA_NW4SkillID@Constants@2@@Z=?GetIsSkillLearnt@SkillbarMgr@GW@@YA_NW4SkillID@Constants@2@@Z
/ALTERNATENAME:_?GetIsSkillUnlocked@SkillbarMgr@GW@@YA_NW4SkillID@Constants@2@@Z=?GetIsSkillUnlocked@SkillbarMgr@GW@@YA_NW4SkillID@Constants@2@@Z
/ALTERNATENAME:_?GetPlayerSkillbar@SkillbarMgr@GW@@YAPAUSkillbar@2@XZ=?GetPlayerSkillbar@SkillbarMgr@GW@@YAPAUSkillbar@2@XZ
/ALTERNATENAME:_?GetSkillConstantData@SkillbarMgr@GW@@YAPAUSkill@2@W4SkillID@Constants@2@@Z=?GetSkillConstantData@SkillbarMgr@GW@@YAPAUSkill@2@W4SkillID@Constants@2@@Z
/ALTERNATENAME:_?GetSkillSlot@SkillbarMgr@GW@@YAHW4SkillID@Constants@2@@Z=?GetSkillSlot@SkillbarMgr@GW@@YAHW4SkillID@Constants@2@@Z
/ALTERNATENAME:_?GetSkillTemplate@SkillbarMgr@GW@@YA_NAAUSkillTemplate@12@@Z=?GetSkillTemplate@SkillbarMgr@GW@@YA_NAAUSkillTemplate@12@@Z
/ALTERNATENAME:_?GetSkillTemplate@SkillbarMgr@GW@@YA_NIAAUSkillTemplate@12@@Z=?GetSkillTemplate@SkillbarMgr@GW@@YA_NIAAUSkillTemplate@12@@Z
/ALTERNATENAME:_?GetSkillbar@SkillbarMgr@GW@@YAPAUSkillbar@2@I@Z=?GetSkillbar@SkillbarMgr@GW@@YAPAUSkillbar@2@I@Z
/ALTERNATENAME:_?GetSkillbarArray@SkillbarMgr@GW@@YAPAV?$Array@USkillbar@GW@@@2@XZ=?GetSkillbarArray@SkillbarMgr@GW@@YAPAV?$Array@USkillbar@GW@@@2@XZ
/ALTERNATENAME:_?LoadSkillTemplate@SkillbarMgr@GW@@YA_NAAUSkillTemplate@12@@Z=?LoadSkillTemplate@SkillbarMgr@GW@@YA_NAAUSkillTemplate@12@@Z
/ALTERNATENAME:_?LoadSkillTemplate@SkillbarMgr@GW@@YA_NIAAUSkillTemplate@12@@Z=?LoadSkillTemplate@SkillbarMgr@GW@@YA_NIAAUSkillTemplate@12@@Z
/ALTERNATENAME:_?LoadSkillTemplate@SkillbarMgr@GW@@YA_NIPBD@Z=?LoadSkillTemplate@SkillbarMgr@GW@@YA_NIPBD@Z
/ALTERNATENAME:_?LoadSkillTemplate@SkillbarMgr@GW@@YA_NPBD@Z=?LoadSkillTemplate@SkillbarMgr@GW@@YA_NPBD@Z
/ALTERNATENAME:_?UseSkill@SkillbarMgr@GW@@YA_NII@Z=?UseSkill@SkillbarMgr@GW@@YA_NII@Z
/ALTERNATENAME:_?UseSkillByID@SkillbarMgr@GW@@YA_NII@Z=?UseSkillByID@SkillbarMgr@GW@@YA_NII@Z
/ALTERNATENAME:_?GetRecharge@SkillbarSkill@GW@@QBEIXZ=?GetRecharge@SkillbarSkill@GW@@QBEIXZ
/ALTERNATENAME:_?GetValue@SliderFrame@GW@@QAE_NPAI@Z=?GetValue@SliderFrame@GW@@QAE_NPAI@Z
/ALTERNATENAME:_?GetValue@SliderFrame@GW@@UAEIXZ=?GetValue@SliderFrame@GW@@UAEIXZ
/ALTERNATENAME:_?SetValue@SliderFrame@GW@@UAE_NI@Z=?SetValue@SliderFrame@GW@@UAE_NI@Z
/ALTERNATENAME:_?EmulatePacket@StoC@GW@@YA_NPAUPacketBase@1Packet@2@@Z=?EmulatePacket@StoC@GW@@YA_NPAUPacketBase@1Packet@2@@Z
/ALTERNATENAME:_?RemoveCallback@StoC@GW@@YAIIPAUHookEntry@2@@Z=?RemoveCallback@StoC@GW@@YAIIPAUHookEntry@2@@Z
/ALTERNATENAME:_?RemoveCallbacks@StoC@GW@@YAIPAUHookEntry@2@@Z=?RemoveCallbacks@StoC@GW@@YAIPAUHookEntry@2@@Z
/ALTERNATENAME:_?RemovePostCallback@StoC@GW@@YAXIPAUHookEntry@2@@Z=?RemovePostCallback@StoC@GW@@YAXIPAUHookEntry@2@@Z
/ALTERNATENAME:_?AddTab@TabsFrame@GW@@QAEPAUFrame@UI@2@PB_WIIP6AXPAUInteractionMessage@42@PAX2@Z2@Z=?AddTab@TabsFrame@GW@@QAEPAUFrame@UI@2@PB_WIIP6AXPAUInteractionMessage@42@PAX2@Z2@Z
/ALTERNATENAME:_?ChooseTab@TabsFrame@GW@@QAE_NI@Z=?ChooseTab@TabsFrame@GW@@QAE_NI@Z
/ALTERNATENAME:_?ChooseTab@TabsFrame@GW@@QAE_NPAUFrame@UI@2@@Z=?ChooseTab@TabsFrame@GW@@QAE_NPAUFrame@UI@2@@Z
/ALTERNATENAME:_?DisableTab@TabsFrame@GW@@QAE_NI@Z=?DisableTab@TabsFrame@GW@@QAE_NI@Z
/ALTERNATENAME:_?EnableTab@TabsFrame@GW@@QAE_NI@Z=?EnableTab@TabsFrame@GW@@QAE_NI@Z
/ALTERNATENAME:_?GetCurrentTab@TabsFrame@GW@@QAEPAUFrame@UI@2@XZ=?GetCurrentTab@TabsFrame@GW@@QAEPAUFrame@UI@2@XZ
/ALTERNATENAME:_?GetCurrentTabIndex@TabsFrame@GW@@QAE_NPAI@Z=?GetCurrentTabIndex@TabsFrame@GW@@QAE_NPAI@Z
/ALTERNATENAME:_?GetIsTabEnabled@TabsFrame@GW@@QAE_NIPAI@Z=?GetIsTabEnabled@TabsFrame@GW@@QAE_NIPAI@Z
/ALTERNATENAME:_?GetTabByLabel@TabsFrame@GW@@QAEPAUFrame@UI@2@PB_W@Z=?GetTabByLabel@TabsFrame@GW@@QAEPAUFrame@UI@2@PB_W@Z
/ALTERNATENAME:_?GetTabFrameId@TabsFrame@GW@@QAE_NIPAI@Z=?GetTabFrameId@TabsFrame@GW@@QAE_NIPAI@Z
/ALTERNATENAME:_?RemoveTab@TabsFrame@GW@@QAE_NI@Z=?RemoveTab@TabsFrame@GW@@QAE_NI@Z
/ALTERNATENAME:_?GetDecodedLabel@TextLabelFrame@GW@@QAEPB_WXZ=?GetDecodedLabel@TextLabelFrame@GW@@QAEPB_WXZ
/ALTERNATENAME:_?GetEncodedLabel@TextLabelFrame@GW@@QAEPB_WXZ=?GetEncodedLabel@TextLabelFrame@GW@@QAEPB_WXZ
/ALTERNATENAME:_?SetFont@TextLabelFrame@GW@@QAE_NI@Z=?SetFont@TextLabelFrame@GW@@QAE_NI@Z
/ALTERNATENAME:_?SetLabel@TextLabelFrame@GW@@QAE_NPB_W@Z=?SetLabel@TextLabelFrame@GW@@QAE_NPB_W@Z
/ALTERNATENAME:_?AcceptTrade@Trade@GW@@YA_NXZ=?AcceptTrade@Trade@GW@@YA_NXZ
/ALTERNATENAME:_?CancelTrade@Trade@GW@@YA_NXZ=?CancelTrade@Trade@GW@@YA_NXZ
/ALTERNATENAME:_?ChangeOffer@Trade@GW@@YA_NXZ=?ChangeOffer@Trade@GW@@YA_NXZ
/ALTERNATENAME:_?IsItemOffered@Trade@GW@@YAPAUTradeItem@2@I@Z=?IsItemOffered@Trade@GW@@YAPAUTradeItem@2@I@Z
/ALTERNATENAME:_?OfferItem@Trade@GW@@YA_NII@Z=?OfferItem@Trade@GW@@YA_NII@Z
/ALTERNATENAME:_?OpenTradeWindow@Trade@GW@@YA_NI@Z=?OpenTradeWindow@Trade@GW@@YA_NI@Z
/ALTERNATENAME:_?RemoveItem@Trade@GW@@YA_NI@Z=?RemoveItem@Trade@GW@@YA_NI@Z
/ALTERNATENAME:_?SubmitOffer@Trade@GW@@YA_NI@Z=?SubmitOffer@Trade@GW@@YA_NI@Z
/ALTERNATENAME:_?AddFrameUIInteractionCallback@UI@GW@@YA_NPAUFrame@12@P6AXPAUInteractionMessage@12@PAX2@Z2@Z=?AddFrameUIInteractionCallback@UI@GW@@YA_NPAUFrame@12@P6AXPAUInteractionMessage@12@PAX2@Z2@Z
/ALTERNATENAME:_?AsyncDecodeStr@UI@GW@@YAXPB_WP6AXPAX0@Z1W4Language@Constants@2@@Z=?AsyncDecodeStr@UI@GW@@YAXPB_WP6AXPAX0@Z1W4Language@Constants@2@@Z
/ALTERNATENAME:_?AsyncDecodeStr@UI@GW@@YAXPB_WPA_WI@Z=?AsyncDecodeStr@UI@GW@@YAXPB_WPA_WI@Z
/ALTERNATENAME:_?ButtonClick@UI@GW@@YA_NPAUFrame@12@@Z=?ButtonClick@UI@GW@@YA_NPAUFrame@12@@Z
/ALTERNATENAME:_?CreateUIComponent@UI@GW@@YAIIIIP6AXPAUInteractionMessage@12@PAX1@Z1PB_W@Z=?CreateUIComponent@UI@GW@@YAIIIIP6AXPAUInteractionMessage@12@PAX1@Z1PB_W@Z
/ALTERNATENAME:_?Default_UICallback@UI@GW@@YA_NPAUInteractionMessage@12@PAX1@Z=?Default_UICallback@UI@GW@@YA_NPAUInteractionMessage@12@PAX1@Z
/ALTERNATENAME:_?DestroyUIComponent@UI@GW@@YA_NPAUFrame@12@@Z=?DestroyUIComponent@UI@GW@@YA_NPAUFrame@12@@Z
/ALTERNATENAME:_?DrawOnCompass@UI@GW@@YA_NIIPAUCompassPoint@12@@Z=?DrawOnCompass@UI@GW@@YA_NIIPAUCompassPoint@12@@Z
/ALTERNATENAME:_?EncStrToUInt32@UI@GW@@YAIPB_W@Z=?EncStrToUInt32@UI@GW@@YAIPB_W@Z
/ALTERNATENAME:_?GetChildFrame@UI@GW@@YAPAUFrame@12@PAU312@I@Z=?GetChildFrame@UI@GW@@YAPAUFrame@12@PAU312@I@Z
/ALTERNATENAME:_?GetCommandLinePref@UI@GW@@YA_NPB_WPAI@Z=?GetCommandLinePref@UI@GW@@YA_NPB_WPAI@Z
/ALTERNATENAME:_?GetCommandLinePref@UI@GW@@YA_NPB_WPAPA_W@Z=?GetCommandLinePref@UI@GW@@YA_NPB_WPAPA_W@Z
/ALTERNATENAME:_?GetCurrentTooltip@UI@GW@@YAPAUTooltipInfo@12@XZ=?GetCurrentTooltip@UI@GW@@YAPAUTooltipInfo@12@XZ
/ALTERNATENAME:_?GetFrameById@UI@GW@@YAPAUFrame@12@I@Z=?GetFrameById@UI@GW@@YAPAUFrame@12@I@Z
/ALTERNATENAME:_?GetFrameByLabel@UI@GW@@YAPAUFrame@12@PB_W@Z=?GetFrameByLabel@UI@GW@@YAPAUFrame@12@PB_W@Z
/ALTERNATENAME:_?GetFrameContext@UI@GW@@YAPAXPAUFrame@12@@Z=?GetFrameContext@UI@GW@@YAPAXPAUFrame@12@@Z
/ALTERNATENAME:_?GetIsShiftScreenShot@UI@GW@@YA_NXZ=?GetIsShiftScreenShot@UI@GW@@YA_NXZ
/ALTERNATENAME:_?GetIsUIDrawn@UI@GW@@YA_NXZ=?GetIsUIDrawn@UI@GW@@YA_NXZ
/ALTERNATENAME:_?GetIsWorldMapShowing@UI@GW@@YA_NXZ=?GetIsWorldMapShowing@UI@GW@@YA_NXZ
/ALTERNATENAME:_?GetParentFrame@UI@GW@@YAPAUFrame@12@PAU312@@Z=?GetParentFrame@UI@GW@@YAPAUFrame@12@PAU312@@Z
/ALTERNATENAME:_?GetPreference@UI@GW@@YAIW4EnumPreference@12@@Z=?GetPreference@UI@GW@@YAIW4EnumPreference@12@@Z
/ALTERNATENAME:_?GetPreference@UI@GW@@YAIW4NumberPreference@12@@Z=?GetPreference@UI@GW@@YAIW4NumberPreference@12@@Z
/ALTERNATENAME:_?GetPreference@UI@GW@@YAPA_WW4StringPreference@12@@Z=?GetPreference@UI@GW@@YAPA_WW4StringPreference@12@@Z
/ALTERNATENAME:_?GetPreference@UI@GW@@YA_NW4FlagPreference@12@@Z=?GetPreference@UI@GW@@YA_NW4FlagPreference@12@@Z
/ALTERNATENAME:_?GetPreferenceOptions@UI@GW@@YAIW4EnumPreference@12@PAPAI@Z=?GetPreferenceOptions@UI@GW@@YAIW4EnumPreference@12@PAPAI@Z
/ALTERNATENAME:_?GetRootFrame@UI@GW@@YAPAUFrame@12@XZ=?GetRootFrame@UI@GW@@YAPAUFrame@12@XZ
/ALTERNATENAME:_?GetSettings@UI@GW@@YAPAV?$Array@E@2@XZ=?GetSettings@UI@GW@@YAPAV?$Array@E@2@XZ
/ALTERNATENAME:_?GetTextLanguage@UI@GW@@YA?AW4Language@Constants@2@XZ=?GetTextLanguage@UI@GW@@YA?AW4Language@Constants@2@XZ
/ALTERNATENAME:_?GetWindowPosition@UI@GW@@YAPAUWindowPosition@12@W4WindowID@12@@Z=?GetWindowPosition@UI@GW@@YAPAUWindowPosition@12@W4WindowID@12@@Z
/ALTERNATENAME:_?IsInControllerCursorMode@UI@GW@@YA_NXZ=?IsInControllerCursorMode@UI@GW@@YA_NXZ
/ALTERNATENAME:_?IsInControllerMode@UI@GW@@YA_NXZ=?IsInControllerMode@UI@GW@@YA_NXZ
/ALTERNATENAME:_?IsInMobileMode@UI@GW@@YA_NXZ=?IsInMobileMode@UI@GW@@YA_NXZ
/ALTERNATENAME:_?IsValidEncStr@UI@GW@@YA_NPB_W@Z=?IsValidEncStr@UI@GW@@YA_NPB_W@Z
/ALTERNATENAME:_?Keydown@UI@GW@@YA_NW4ControlAction@12@PAUFrame@12@@Z=?Keydown@UI@GW@@YA_NW4ControlAction@12@PAUFrame@12@@Z
/ALTERNATENAME:_?Keypress@UI@GW@@YA_NW4ControlAction@12@PAUFrame@12@@Z=?Keypress@UI@GW@@YA_NW4ControlAction@12@PAUFrame@12@@Z
/ALTERNATENAME:_?Keyup@UI@GW@@YA_NW4ControlAction@12@PAUFrame@12@@Z=?Keyup@UI@GW@@YA_NW4ControlAction@12@PAUFrame@12@@Z
/ALTERNATENAME:_?RemoveCreateUIComponentCallback@UI@GW@@YAXPAUHookEntry@2@@Z=?RemoveCreateUIComponentCallback@UI@GW@@YAXPAUHookEntry@2@@Z
/ALTERNATENAME:_?RemoveFrameUIMessageCallback@UI@GW@@YAXPAUHookEntry@2@@Z=?RemoveFrameUIMessageCallback@UI@GW@@YAXPAUHookEntry@2@@Z
/ALTERNATENAME:_?RemoveKeydownCallback@UI@GW@@YAXPAUHookEntry@2@@Z=?RemoveKeydownCallback@UI@GW@@YAXPAUHookEntry@2@@Z
/ALTERNATENAME:_?RemoveKeyupCallback@UI@GW@@YAXPAUHookEntry@2@@Z=?RemoveKeyupCallback@UI@GW@@YAXPAUHookEntry@2@@Z
/ALTERNATENAME:_?RemoveUIMessageCallback@UI@GW@@YAXPAUHookEntry@2@W4UIMessage@12@@Z=?RemoveUIMessageCallback@UI@GW@@YAXPAUHookEntry@2@W4UIMessage@12@@Z
/ALTERNATENAME:_?SelectDropdownOption@UI@GW@@YA_NPAUFrame@12@I@Z=?SelectDropdownOption@UI@GW@@YA_NPAUFrame@12@I@Z
/ALTERNATENAME:_?SendFrameUIMessage@UI@GW@@YA_NPAUFrame@12@W4UIMessage@12@PAX2@Z=?SendFrameUIMessage@UI@GW@@YA_NPAUFrame@12@W4UIMessage@12@PAX2@Z
/ALTERNATENAME:_?SendUIMessage@UI@GW@@YA_NW4UIMessage@12@PAX1@Z=?SendUIMessage@UI@GW@@YA_NW4UIMessage@12@PAX1@Z
/ALTERNATENAME:_?SetCommandLinePref@UI@GW@@YA_NPB_WI@Z=?SetCommandLinePref@UI@GW@@YA_NPB_WI@Z
/ALTERNATENAME:_?SetCommandLinePref@UI@GW@@YA_NPB_WPA_W@Z=?SetCommandLinePref@UI@GW@@YA_NPB_WPA_W@Z
/ALTERNATENAME:_?SetFrameDisabled@UI@GW@@YA_NPAUFrame@12@_N@Z=?SetFrameDisabled@UI@GW@@YA_NPAUFrame@12@_N@Z
/ALTERNATENAME:_?SetFrameMargins@UI@GW@@YA_NPAUFrame@12@IQAM1I@Z=?SetFrameMargins@UI@GW@@YA_NPAUFrame@12@IQAM1I@Z
/ALTERNATENAME:_?SetFrameTitle@UI@GW@@YA_NPAUFrame@12@PB_W@Z=?SetFrameTitle@UI@GW@@YA_NPAUFrame@12@PB_W@Z
/ALTERNATENAME:_?SetFrameVisible@UI@GW@@YA_NPAUFrame@12@_N@Z=?SetFrameVisible@UI@GW@@YA_NPAUFrame@12@_N@Z
/ALTERNATENAME:_?SetOpenLinks@UI@GW@@YAX_N@Z=?SetOpenLinks@UI@GW@@YAX_N@Z
/ALTERNATENAME:_?SetPreference@UI@GW@@YA_NW4EnumPreference@12@I@Z=?SetPreference@UI@GW@@YA_NW4EnumPreference@12@I@Z
/ALTERNATENAME:_?SetPreference@UI@GW@@YA_NW4FlagPreference@12@_N@Z=?SetPreference@UI@GW@@YA_NW4FlagPreference@12@_N@Z
/ALTERNATENAME:_?SetPreference@UI@GW@@YA_NW4NumberPreference@12@I@Z=?SetPreference@UI@GW@@YA_NW4NumberPreference@12@I@Z
/ALTERNATENAME:_?SetPreference@UI@GW@@YA_NW4StringPreference@12@PA_W@Z=?SetPreference@UI@GW@@YA_NW4StringPreference@12@PA_W@Z
/ALTERNATENAME:_?SetWindowPosition@UI@GW@@YA_NW4WindowID@12@PAUWindowPosition@12@@Z=?SetWindowPosition@UI@GW@@YA_NW4WindowID@12@PAUWindowPosition@12@@Z
/ALTERNATENAME:_?SetWindowVisible@UI@GW@@YA_NW4WindowID@12@_N@Z=?SetWindowVisible@UI@GW@@YA_NW4WindowID@12@_N@Z
/ALTERNATENAME:_?TriggerFrameRedraw@UI@GW@@YA_NPAUFrame@12@@Z=?TriggerFrameRedraw@UI@GW@@YA_NPAUFrame@12@@Z
/ALTERNATENAME:_?UInt32ToEncStr@UI@GW@@YA_NIPA_WI@Z=?UInt32ToEncStr@UI@GW@@YA_NIPA_WI@Z
/ALTERNATENAME:_?GetBottomRightOnScreen@FramePosition@UI@GW@@QBE?AUVec2f@3@PBUFrame@23@@Z=?GetBottomRightOnScreen@FramePosition@UI@GW@@QBE?AUVec2f@3@PBUFrame@23@@Z
/ALTERNATENAME:_?GetContentBottomRight@FramePosition@UI@GW@@QBE?AUVec2f@3@PBUFrame@23@@Z=?GetContentBottomRight@FramePosition@UI@GW@@QBE?AUVec2f@3@PBUFrame@23@@Z
/ALTERNATENAME:_?GetContentTopLeft@FramePosition@UI@GW@@QBE?AUVec2f@3@PBUFrame@23@@Z=?GetContentTopLeft@FramePosition@UI@GW@@QBE?AUVec2f@3@PBUFrame@23@@Z
/ALTERNATENAME:_?GetSizeOnScreen@FramePosition@UI@GW@@QBE?AUVec2f@3@PBUFrame@23@@Z=?GetSizeOnScreen@FramePosition@UI@GW@@QBE?AUVec2f@3@PBUFrame@23@@Z
/ALTERNATENAME:_?GetTopLeftOnScreen@FramePosition@UI@GW@@QBE?AUVec2f@3@PBUFrame@23@@Z=?GetTopLeftOnScreen@FramePosition@UI@GW@@QBE?AUVec2f@3@PBUFrame@23@@Z
/ALTERNATENAME:_?GetViewportScale@FramePosition@UI@GW@@QBE?AUVec2f@3@PBUFrame@23@@Z=?GetViewportScale@FramePosition@UI@GW@@QBE?AUVec2f@3@PBUFrame@23@@Z
/ALTERNATENAME:_?GetFrame@FrameRelation@UI@GW@@QAEPAUFrame@23@XZ=?GetFrame@FrameRelation@UI@GW@@QAEPAUFrame@23@XZ
/ALTERNATENAME:_?GetParent@FrameRelation@UI@GW@@QBEPAUFrame@23@XZ=?GetParent@FrameRelation@UI@GW@@QBEPAUFrame@23@XZ
/ALTERNATENAME:_?xAxis@WindowPosition@UI@GW@@QBE?AUVec2f@3@M_N@Z=?xAxis@WindowPosition@UI@GW@@QBE?AUVec2f@3@M_N@Z
/ALTERNATENAME:_?yAxis@WindowPosition@UI@GW@@QBE?AUVec2f@3@M_N@Z=?yAxis@WindowPosition@UI@GW@@QBE?AUVec2f@3@M_N@Z
File diff suppressed because it is too large Load Diff
@@ -13,17 +13,44 @@ public readonly unsafe struct GuildWarsArray<T> : IEnumerable<T>
public readonly uint Size;
public readonly uint Param;
/// <summary>
/// Upper bound on a believable element count. Guild Wars' largest arrays hold at most a
/// few thousand entries, so anything beyond this is uninitialised or misresolved memory
/// rather than a real array.
/// </summary>
private const uint MaxPlausibleCapacity = 0x10000;
/// <summary>
/// Mirrors <c>GW::BaseArray::valid()</c> - the buffer must be null or aligned and the size
/// must fit within the capacity - plus a bound on the capacity itself. Guild Wars leaves
/// these fields transiently inconsistent while it (re)allocates an array, and a
/// misresolved GWCA scan can point this struct at arbitrary bytes that still satisfy
/// <c>size &lt;= capacity</c>.
/// </summary>
public bool IsValid =>
(this.Buffer is null || ((nuint)this.Buffer & 0x3) == 0) &&
this.Size <= this.Capacity &&
this.Capacity <= MaxPlausibleCapacity;
/// <summary>
/// Number of elements that can safely be read. Zero whenever the array is not backed by
/// a buffer, matching <c>GW::BaseArray::get()</c>, which returns null instead of
/// dereferencing. Reading past this is an access violation, and because NativeAOT cannot
/// throw <c>AccessViolationException</c> it fail-fasts and takes Guild Wars down with it.
/// </summary>
public uint Count => this.Buffer is not null && this.IsValid ? this.Size : 0;
public T this[int index]
{
get
{
ArgumentOutOfRangeException.ThrowIfNegative(index);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)index, this.Size);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)index, this.Count);
return this.Buffer[index];
}
}
public Enumerator GetEnumerator() => new(this.Buffer, this.Size);
public Enumerator GetEnumerator() => new(this.Buffer, this.Count);
IEnumerator<T> IEnumerable<T>.GetEnumerator() => this.GetEnumerator();
@@ -7,6 +7,18 @@ public readonly unsafe struct WrappedPointer<T>(T* pointer)
public bool IsNull => this.Pointer is null;
/// <summary>
/// True when the pointer is non-null and plausibly dereferenceable.
/// </summary>
/// <remarks>
/// Guild Wars structures are 4-byte aligned on x86, so a misaligned address is never a
/// real structure. When a GWCA pattern scan fails to resolve, it can hand back an
/// address inside Gw.exe's code section (for example 0x0048CA72); reading through that
/// yields nonsense field values and eventually an access violation. NativeAOT cannot
/// throw <c>AccessViolationException</c>, so it fail-fasts and terminates Guild Wars.
/// </remarks>
public bool IsValid => this.Pointer is not null && ((nuint)this.Pointer & 0x3) == 0;
public static implicit operator WrappedPointer<T>(T* pointer) => new(pointer);
public static implicit operator T*(WrappedPointer<T> wrappedPointer) => wrappedPointer.Pointer;
+82 -34
View File
@@ -17,7 +17,6 @@ public sealed class CharacterSelectService(
UIContextService uiContextService,
GameThreadService gameThreadService,
GameContextService gameContextService,
PreferencesService preferencesService,
ILogger<CharacterSelectService> logger)
{
private readonly InstanceContextService instanceContextService = instanceContextService.ThrowIfNull();
@@ -25,7 +24,6 @@ public sealed class CharacterSelectService(
private readonly UIContextService uiContextService = uiContextService.ThrowIfNull();
private readonly GameThreadService gameThreadService = gameThreadService.ThrowIfNull();
private readonly GameContextService gameContextService = gameContextService.ThrowIfNull();
private readonly PreferencesService preferencesService = preferencesService.ThrowIfNull();
private readonly ILogger<CharacterSelectService> logger = logger.ThrowIfNull();
public Task<CharacterSelectInformation?> GetCharacterSelectInformation(CancellationToken cancellationToken)
@@ -37,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);
@@ -164,6 +169,12 @@ public sealed class CharacterSelectService(
{
unsafe
{
if (!this.IsCharSelectReady())
{
scopedLogger.LogError("Character select is not ready");
return false;
}
var selectorFrame = this.GetCharSelectorFrame();
if (selectorFrame is null)
{
@@ -172,7 +183,8 @@ public sealed class CharacterSelectService(
}
var ctx = this.uiContextService.GetFrameContext<CharSelectorContext>(selectorFrame);
if (ctx.IsNull)
if (ctx.IsNull ||
(nuint)ctx.Pointer <= 0xFFFF)
{
scopedLogger.LogError("Character selector context not found");
return false;
@@ -191,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++)
{
@@ -267,7 +281,10 @@ public sealed class CharacterSelectService(
}
}
// Navigate to target character by selecting previous/next until we reach it
// Navigate to target character by selecting previous/next until we reach it.
// Empty roster slots are represented by null entries in the character array, so
// step over them instead of bailing out (otherwise navigation gets stuck at the
// slot right before a gap and never reaches characters beyond it).
while (targetIdx < selectedIdx)
{
if (selectedIdx == 0)
@@ -275,13 +292,24 @@ public sealed class CharacterSelectService(
break;
}
if (!SelectChar(selectedIdx - 1))
var prevIdx = selectedIdx - 1;
while (prevIdx > 0 && ctx.Pointer->Chars.Buffer[prevIdx].IsNull)
{
prevIdx--;
}
if (ctx.Pointer->Chars.Buffer[prevIdx].IsNull)
{
break;
}
if (!SelectChar(prevIdx))
{
scopedLogger.LogError("Failed to select previous character");
return false;
}
selectedIdx--;
selectedIdx = prevIdx;
}
while (targetIdx > selectedIdx)
@@ -291,13 +319,24 @@ public sealed class CharacterSelectService(
break;
}
if (!SelectChar(selectedIdx + 1))
var nextIdx = selectedIdx + 1;
while (nextIdx < charCount && ctx.Pointer->Chars.Buffer[nextIdx].IsNull)
{
nextIdx++;
}
if (nextIdx >= charCount)
{
break;
}
if (!SelectChar(nextIdx))
{
scopedLogger.LogError("Failed to select next character");
return false;
}
selectedIdx++;
selectedIdx = nextIdx;
}
var chosen = selectedIdx == targetIdx;
@@ -352,25 +391,13 @@ public sealed class CharacterSelectService(
return false;
}
var previousOrderPreference = await this.gameThreadService.QueueOnGameThread(() => this.preferencesService.GetEnumPreference(EnumPreference.CharSortOrder), cancellationToken);
if (previousOrderPreference is null)
{
scopedLogger.LogError("Failed to get previous character sort order preference");
return false;
}
scopedLogger.LogInformation("Previous character sort order preference: {order}. Setting order to alphabetize", (CharSortOrder)previousOrderPreference);
await this.gameThreadService.QueueOnGameThread(() => this.preferencesService.SetEnumPreference(EnumPreference.CharSortOrder, (uint)CharSortOrder.Alphabetize), cancellationToken);
try
{
return await this.SelectCharacterToPlay(characterName, play: true, cancellationToken);
}
finally
{
// TODO: Delay to ensure character select has fully processed the selection before reverting sort order. Should be done after loading into the map.
await Task.Delay(1000, cancellationToken);
await this.gameThreadService.QueueOnGameThread(() => this.preferencesService.SetEnumPreference(EnumPreference.CharSortOrder, previousOrderPreference.Value), cancellationToken);
}
// NOTE: Mirrors GWToolbox's GW::LoginMgr::SelectCharacterToPlay, which navigates the
// character carousel purely by stepping prev/next over the selector context's character
// array. It deliberately does NOT touch the in-game CharSortOrder preference: the
// navigation relies on the selector context order matching the visible carousel order,
// and forcing a re-sort here desyncs the two (the click-by-name steps then land on the
// wrong index, so navigation only appears to work in one direction).
return await this.SelectCharacterToPlay(characterName, play: true, cancellationToken);
}
private async Task<string?> GetCharNameByUuid(string uuid, CancellationToken cancellationToken)
@@ -388,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;
@@ -432,7 +460,27 @@ public sealed class CharacterSelectService(
private unsafe bool IsCharSelectReady()
{
return this.GetCharSelectorFrame() is not null;
// GW UI state must report char select (2). kCheckUIState fills the value passed via lParam.
uint uiState = 10;
this.uiContextService.SendMessage(UIMessage.kCheckUIState, 0, (nuint)(&uiState));
var selectorFrame = this.GetCharSelectorFrame();
if (uiState != 2 ||
selectorFrame is null ||
!selectorFrame->IsVisible)
{
return false;
}
// The frame context must be fully populated (a real pointer) and belong to this frame.
var ctx = this.uiContextService.GetFrameContext<CharSelectorContext>(selectorFrame);
if (ctx.IsNull ||
(nuint)ctx.Pointer <= 0xFFFF)
{
return false;
}
return ctx.Pointer->FrameId == selectorFrame->FrameId;
}
private unsafe Frame* GetCharSelectorFrame()
@@ -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
@@ -255,7 +261,7 @@ public class ProjectConfiguration : PluginConfigurationBase
services.AddHostedSingleton<IThemeManager, BlazorThemeInteropService>();
services.AddHostedSingleton<IConnectivityStatus, ConnectivityStatus>();
services.AddHostedSingleton<ITradeAlertingService, TradeAlertingService>();
services.AddHostedSingleton<IGuildWarsExecutableManager, GuildWarsExecutableManager>();
services.AddSingleton<IGuildWarsExecutableManager, GuildWarsExecutableManager>();
services.AddHostedSingleton<IEventNotifierService, EventNotifierService>();
services.AddHostedSingleton<IApiScanningService, ApiScanningService>();
services.AddHostedSingleton<GameScreenshotsTheme>();
@@ -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);
}
@@ -131,7 +131,7 @@ public sealed class ApiScanningService(
}
// Convert the reported PID (Wine PID on Linux) to system PID
var systemPid = this.pidProvider.ResolveSystemPid(reportedPid.Value, GuildWarsExecutable);
var systemPid = this.pidProvider.ResolveSystemPid(reportedPid.Value, GuildWarsExecutable, port);
var uri = new Uri($"http://localhost:{port}");
scopedLogger.LogDebug(
@@ -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(
@@ -536,18 +560,7 @@ internal sealed class ApplicationLauncher(
var scopedLogger = this.logger.CreateScopedLogger();
try
{
var process = guildWarsApplicationLaunchContext.GuildWarsProcess;
if (
process.MainModule?.FileName is not null
&& process.MainModule.FileName.Contains(
"Gw.exe",
StringComparison.OrdinalIgnoreCase
)
)
{
process.Kill(true);
return;
}
this.guildWarsProcessFinder.KillProcess(guildWarsApplicationLaunchContext);
}
catch (Exception e)
when (e.Message.Contains(
@@ -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);
}
}
@@ -1,36 +1,26 @@
using Daybreak.Configuration.Options;
using Daybreak.Shared.Services.ExecutableManagement;
using Daybreak.Shared.Services.Options;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Core.Extensions;
using System.Extensions;
using System.Extensions.Core;
namespace Daybreak.Services.ExecutableManagement;
internal sealed class GuildWarsExecutableManager(
IOptionsProvider optionsProvider,
IOptionsMonitor<GuildwarsExecutableOptions> liveUpdateableOptions,
ILogger<GuildWarsExecutableManager> logger) : IGuildWarsExecutableManager, IHostedService
ILogger<GuildWarsExecutableManager> logger) : IGuildWarsExecutableManager
{
private readonly static TimeSpan ExecutableVerificationLatency = TimeSpan.FromSeconds(5);
internal const int MaxExecutables = 10;
private readonly static SemaphoreSlim ExecutablesSemaphore = new(1, 1);
private readonly IOptionsProvider optionsProvider = optionsProvider.ThrowIfNull();
private readonly IOptionsMonitor<GuildwarsExecutableOptions> liveUpdateableOptions = liveUpdateableOptions.ThrowIfNull();
private readonly ILogger<GuildWarsExecutableManager> logger = logger.ThrowIfNull();
async Task IHostedService.StartAsync(CancellationToken cancellationToken)
{
await this.VerifyExecutables(cancellationToken);
}
Task IHostedService.StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public IEnumerable<string> GetExecutableList()
{
ExecutablesSemaphore.Wait();
@@ -43,7 +33,6 @@ internal sealed class GuildWarsExecutableManager(
public void AddExecutable(string executablePath)
{
var fullPath = Path.GetFullPath(executablePath);
ExecutablesSemaphore.Wait();
var options = this.liveUpdateableOptions.CurrentValue;
@@ -53,6 +42,8 @@ internal sealed class GuildWarsExecutableManager(
list.Insert(0, executablePath);
}
this.TrimInvalidExecutables(list);
options.ExecutablePaths = list;
this.optionsProvider.SaveOption(options);
ExecutablesSemaphore.Release();
@@ -80,38 +71,26 @@ internal sealed class GuildWarsExecutableManager(
return IsValidExecutableInternal(executablePath);
}
private async Task VerifyExecutables(CancellationToken cancellationToken)
/// <summary>
/// Soft-caps the stored executable list at <see cref="MaxExecutables"/>. Only entries that no
/// longer point to an existing file are evicted, oldest first, and only while the list is over
/// capacity. A valid executable is never evicted automatically, even if that keeps the list
/// above the cap. This bounds growth from stale entries without losing executables that live on
/// a temporarily unavailable volume (e.g. an unmounted removable or network drive), which would
/// otherwise be indistinguishable from a deleted file by path alone.
/// </summary>
private void TrimInvalidExecutables(List<string> executables)
{
var scopedLogger = this.logger.CreateScopedLogger(nameof(this.VerifyExecutables), string.Empty);
while (!cancellationToken.IsCancellationRequested)
var scopedLogger = this.logger.CreateScopedLogger();
for (var i = executables.Count - 1; i >= 0 && executables.Count > MaxExecutables; i--)
{
await ExecutablesSemaphore.WaitAsync(cancellationToken);
var executables = this.liveUpdateableOptions.CurrentValue.ExecutablePaths;
var deletedExecutable = false;
for (var i = 0; i < executables.Count; i++)
if (IsValidExecutableInternal(executables[i]))
{
var executable = executables[i];
if (IsValidExecutableInternal(executable))
{
continue;
}
scopedLogger.LogWarning($"Detected deleted executable at {executable}");
deletedExecutable = true;
executables.Remove(executable);
i--;
continue;
}
if (deletedExecutable)
{
var options = this.liveUpdateableOptions.CurrentValue;
options.ExecutablePaths = executables;
this.optionsProvider.SaveOption(options);
}
ExecutablesSemaphore.Release();
await Task.Delay(ExecutableVerificationLatency, cancellationToken);
scopedLogger.LogInformation("Evicting stale executable while over capacity: {executable}", executables[i]);
executables.RemoveAt(i);
}
}
@@ -125,7 +125,7 @@ internal sealed class GuildWarsVersionChecker(
this.notificationService.NotifyError<GuildWarsBatchUpdateNotificationHandler>(
title: "Guild Wars needs an update",
description: $"Click here to update the executable located at {guildWarsStartingContext.ApplicationLauncherContext.ExecutablePath}",
expirationTime: DateTime.Now + TimeSpan.FromSeconds(15));
expirationTime: Global.NotificationShortExpiration);
}
public Task OnGuildWarsCreated(GuildWarsCreatedContext guildWarsCreatedContext, CancellationToken cancellationToken) => Task.CompletedTask;
@@ -1,13 +1,12 @@
using Daybreak.Launch;
using Daybreak.Models;
using Serilog.Core;
using Serilog.Events;
using Serilog.Formatting.Display;
using System.Collections.Immutable;
namespace Daybreak.Services.Logging;
public sealed class InMemorySink : ILogEventSink, IDisposable
public sealed class InMemorySink : RedactingLogEventSink, IDisposable
{
private const int MaxLogEvents = 50000;
@@ -30,7 +29,7 @@ public sealed class InMemorySink : ILogEventSink, IDisposable
this.logEvents.Clear();
}
public void Emit(LogEvent logEvent)
protected override void EmitRedacted(LogEvent logEvent)
{
lock (this.snapShotLock)
{
@@ -0,0 +1,155 @@
using Serilog.Events;
using Serilog.Parsing;
using System.Buffers;
using System.Text.RegularExpressions;
namespace Daybreak.Services.Logging;
/// <summary>
/// Rewrites <see cref="LogEvent"/> instances so that sensitive user information (usernames,
/// e-mail addresses and passwords) is masked before it reaches a sink.
/// <para>
/// It relies on two complementary strategies:
/// <list type="bullet">
/// <item>Regex matching against the command-line style tokens (<c>-email &lt;value&gt;</c>,
/// <c>-password &lt;value&gt;</c>) that appear in rendered messages and property values.</item>
/// <item>Property-name matching, which masks any structured property named after a credential
/// field (Username, Password, Email) regardless of its value.</item>
/// </list>
/// </para>
/// </summary>
public static partial class LogEventRedactor
{
public const string RedactedValue = "[REDACTED]";
// Structured property names whose value must always be masked.
private static readonly HashSet<string> SensitivePropertyNames = new(StringComparer.OrdinalIgnoreCase)
{
"Username",
"Password",
"Email",
};
// Cheap, vectorized pre-filter: the (comparatively expensive) look-behind regex patterns are
// skipped unless one of these credential flags is present in the text. SearchValues performs a
// single multi-pattern scan (Teddy/Aho-Corasick internally) that outperforms both individual
// Contains calls and a hand-rolled automaton, and scales as the marker set grows. Every secret
// pattern below must be gated by one of these markers.
private static readonly SearchValues<string> SecretMarkers = SearchValues.Create(
["-email", "-password"],
StringComparison.OrdinalIgnoreCase);
// Patterns whose single match is the secret value to mask. The credential flag itself is kept
// in a look-behind so only the value is replaced.
private static readonly Regex[] SecretPatterns =
[
EmailArgumentRegex(),
PasswordArgumentRegex(),
];
/// <summary>
/// Returns a copy of <paramref name="logEvent"/> with sensitive information masked. The original
/// event is returned unchanged when nothing needs to be redacted.
/// </summary>
public static LogEvent Redact(LogEvent logEvent)
{
ArgumentNullException.ThrowIfNull(logEvent);
var redactedTemplate = RedactTemplate(logEvent.MessageTemplate);
var redactedProperties = logEvent.Properties
.Select(p => new LogEventProperty(p.Key, RedactPropertyValue(p.Key, p.Value)))
.ToList();
return new LogEvent(
logEvent.Timestamp,
logEvent.Level,
logEvent.Exception,
redactedTemplate,
redactedProperties);
}
/// <summary>
/// Masks the credential values embedded in an arbitrary string using the configured regex patterns.
/// </summary>
public static string RedactText(string? text)
{
if (string.IsNullOrEmpty(text))
{
return text ?? string.Empty;
}
// Fast path: avoid running the look-behind regexes over the overwhelming majority of log
// text that cannot contain a credential flag. This keeps redaction global (fail-safe) while
// costing only a vectorized substring scan for events that carry no secrets.
if (!MightContainSecret(text))
{
return text;
}
foreach (var pattern in SecretPatterns)
{
text = pattern.Replace(text, RedactedValue);
}
return text;
}
private static bool MightContainSecret(string text)
{
return text.AsSpan().ContainsAny(SecretMarkers);
}
private static MessageTemplate RedactTemplate(MessageTemplate template)
{
var tokens = template.Tokens
.Select(token => token is TextToken textToken
? new TextToken(RedactText(textToken.Text))
: token)
.ToList();
return new MessageTemplate(tokens);
}
private static LogEventPropertyValue RedactPropertyValue(string propertyName, LogEventPropertyValue value)
{
if (SensitivePropertyNames.Contains(propertyName))
{
return new ScalarValue(RedactedValue);
}
return RedactPropertyValue(value);
}
private static LogEventPropertyValue RedactPropertyValue(LogEventPropertyValue value)
{
switch (value)
{
case ScalarValue { Value: string stringValue }:
return new ScalarValue(RedactText(stringValue));
case SequenceValue sequence:
return new SequenceValue(sequence.Elements.Select(RedactPropertyValue));
case StructureValue structure:
var structureProperties = structure.Properties
.Select(p => new LogEventProperty(p.Name, RedactPropertyValue(p.Name, p.Value)));
return new StructureValue(structureProperties, structure.TypeTag);
case DictionaryValue dictionary:
var elements = dictionary.Elements
.Select(kvp => new KeyValuePair<ScalarValue, LogEventPropertyValue>(
kvp.Key,
RedactPropertyValue(kvp.Value)));
return new DictionaryValue(elements);
default:
return value;
}
}
[GeneratedRegex(@"(?<=-email[=\s])(""[^""]*""|'[^']*'|\S+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex EmailArgumentRegex();
[GeneratedRegex(@"(?<=-password[=\s])(""[^""]*""|'[^']*'|\S+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex PasswordArgumentRegex();
}
@@ -0,0 +1,27 @@
using Serilog.Core;
using Serilog.Events;
namespace Daybreak.Services.Logging;
/// <summary>
/// Base class for sinks that must never receive sensitive user information. Every emitted
/// <see cref="LogEvent"/> is passed through <see cref="LogEventRedactor"/> before being handed to
/// the concrete sink via <see cref="EmitRedacted"/>.
/// <para>
/// This lets us keep the Console sink (only visible to the local user) unredacted while masking
/// sensitive user information for sinks that persist or forward logs off the machine, such as the
/// in-memory sink used for exporting logs and the telemetry sink used to forward logs to the server.
/// </para>
/// </summary>
public abstract class RedactingLogEventSink : ILogEventSink
{
public void Emit(LogEvent logEvent)
{
this.EmitRedacted(LogEventRedactor.Redact(logEvent));
}
/// <summary>
/// Handles the log event after sensitive information has been masked.
/// </summary>
protected abstract void EmitRedacted(LogEvent logEvent);
}
@@ -153,7 +153,7 @@ internal sealed class NotificationService(
Title = title,
Description = description,
Metadata = metaData ?? string.Empty,
ExpirationTime = expirationTime ?? (DateTime.Now + TimeSpan.FromSeconds(5)),
ExpirationTime = expirationTime ?? (DateTime.UtcNow + TimeSpan.FromSeconds(5)),
Dismissible = dismissible,
Level = logLevel,
};
@@ -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);
}
@@ -133,9 +133,9 @@ internal sealed class ToolboxService(
public Task OnGuildWarsStartingDisabled(GuildWarsStartingDisabledContext guildWarsStartingDisabledContext, CancellationToken cancellationToken) => Task.CompletedTask;
public Task OnGuildWarsCreated(GuildWarsCreatedContext guildWarsCreatedContext, CancellationToken cancellationToken) => Task.CompletedTask;
public Task OnGuildWarsCreated(GuildWarsCreatedContext guildWarsCreatedContext, CancellationToken cancellationToken) => this.LaunchToolbox(guildWarsCreatedContext.ApplicationLauncherContext.Process, cancellationToken);
public Task OnGuildWarsStarted(GuildWarsStartedContext guildWarsStartedContext, CancellationToken cancellationToken) => this.LaunchToolbox(guildWarsStartedContext.ApplicationLauncherContext.Process, cancellationToken);
public Task OnGuildWarsStarted(GuildWarsStartedContext guildWarsStartedContext, CancellationToken cancellationToken) => Task.CompletedTask;
public IEnumerable<string> GetCustomArguments() => [];
@@ -1,6 +1,7 @@
using Daybreak.Configuration.Options;
using Daybreak.Services.TradeChat.Models;
using Daybreak.Services.TradeChat.Notifications;
using Daybreak.Shared;
using Daybreak.Shared.Configuration.Options;
using Daybreak.Shared.Converters;
using Daybreak.Shared.Models.Trade;
@@ -197,7 +198,7 @@ internal sealed class TradeAlertingService : ITradeAlertingService, IHostedServi
this.notificationService.NotifyInformation(
title: "Quote alert!",
description: description,
expirationTime: DateTime.Now + TimeSpan.FromSeconds(15));
expirationTime: Global.NotificationShortExpiration);
}
await Task.Delay(TimeSpan.FromSeconds(this.options.CurrentValue.QuoteAlertsInterval), cancellationToken);
@@ -258,7 +259,7 @@ internal sealed class TradeAlertingService : ITradeAlertingService, IHostedServi
title: $"{source} Trader Alert",
description: $"{alert.Name} has matched on a trader message. Sender: {traderMessageDTO.Sender}. Message: {traderMessageDTO.Message}",
metaData: JsonSerializer.Serialize(traderMessage),
expirationTime: DateTime.Now + TimeSpan.FromDays(1));
expirationTime: DateTime.UtcNow + TimeSpan.FromDays(1));
}
private static bool CheckMatch(string toCheck, string toMatch, bool isRegex)
+80 -70
View File
@@ -8,80 +8,90 @@
<div class="option-view-title">
<h3>Accounts</h3>
</div>
<div class="credentials-list">
@foreach (var credential in this.ViewModel.LoginCredentials)
{
<div class="credential-row">
<div class="identifier-row">
<div class="identifier-label">
<FluentLabel>Identifier</FluentLabel>
</div>
<div class="identifier-field">
<FluentTextField Value="@credential.LoginCredentials.Identifier"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
ReadOnly="true"
Placeholder="Identifier">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => this.ViewModel.RemoveCredential(credential))"
slot="end"
Color="Color.Neutral"
Title="Delete account" />
</FluentTextField>
</div>
</div>
<div class="username-row">
<div class="username-label">
<FluentLabel>Username</FluentLabel>
</div>
<div class="username-field">
<FluentTextField Value="@credential.LoginCredentials.Username"
ValueChanged="@(e => this.ViewModel.UsernameChanged(credential, e))"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Immediate="true"
ImmediateDelay="500"
InputMode="InputMode.Text"
TextFieldType="TextFieldType.Email"
Placeholder="Username" />
</div>
</div>
<div class="password-row">
<div class="password-label">
<FluentLabel>Password</FluentLabel>
</div>
<div class="password-field">
<FluentTextField Value="@credential.LoginCredentials.Password"
ValueChanged="@(e => this.ViewModel.PasswordChanged(credential, e))"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Immediate="true"
ImmediateDelay="500"
InputMode="InputMode.Text"
TextFieldType="@(credential.PasswordVisible ? TextFieldType.Text : TextFieldType.Password)"
Placeholder="Password">
@if (credential.PasswordVisible)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Eye())"
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
@if (this.ViewModel.LoginCredentials.Count is 0)
{
<EmptyStateWidget Message="No accounts configured"
ActionText="Add account"
Icon="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Person())"
OnAction="@this.ViewModel.CreateCredential" />
}
else
{
<div class="credentials-list">
@foreach (var credential in this.ViewModel.LoginCredentials)
{
<div class="credential-row">
<div class="identifier-row">
<div class="identifier-label">
<FluentLabel>Identifier</FluentLabel>
</div>
<div class="identifier-field">
<FluentTextField Value="@credential.LoginCredentials.Identifier"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
ReadOnly="true"
Placeholder="Identifier">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => this.ViewModel.RemoveCredential(credential))"
slot="end"
Color="Color.Neutral"
Title="Hide password" />
}
else
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.EyeOff())"
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
slot="end"
Color="Color.Neutral"
Title="Show password" />
}
</FluentTextField>
Title="Delete account" />
</FluentTextField>
</div>
</div>
<div class="username-row">
<div class="username-label">
<FluentLabel>Username</FluentLabel>
</div>
<div class="username-field">
<FluentTextField Value="@credential.LoginCredentials.Username"
ValueChanged="@(e => this.ViewModel.UsernameChanged(credential, e))"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Immediate="true"
ImmediateDelay="500"
InputMode="InputMode.Text"
TextFieldType="TextFieldType.Email"
Placeholder="Username" />
</div>
</div>
<div class="password-row">
<div class="password-label">
<FluentLabel>Password</FluentLabel>
</div>
<div class="password-field">
<FluentTextField Value="@credential.LoginCredentials.Password"
ValueChanged="@(e => this.ViewModel.PasswordChanged(credential, e))"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Immediate="true"
ImmediateDelay="500"
InputMode="InputMode.Text"
TextFieldType="@(credential.PasswordVisible ? TextFieldType.Text : TextFieldType.Password)"
Placeholder="Password">
@if (credential.PasswordVisible)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Eye())"
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
slot="end"
Color="Color.Neutral"
Title="Hide password" />
}
else
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.EyeOff())"
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
slot="end"
Color="Color.Neutral"
Title="Show password" />
}
</FluentTextField>
</div>
</div>
</div>
</div>
}
</div>
}
</div>
}
</div>
</div>
+5 -9
View File
@@ -8,6 +8,7 @@ using Daybreak.Shared.Services.Menu;
using Daybreak.Shared.Services.Notifications;
using Daybreak.Shared.Services.Options;
using Daybreak.Shared.Services.Privilege;
using Daybreak.Shared.Services.Shell;
using Daybreak.Shared.Services.Themes;
using Daybreak.Shared.Services.Updater;
using Daybreak.Shared.Services.Window;
@@ -17,7 +18,6 @@ using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
using Photino.Blazor;
using System.Core.Extensions;
using System.Diagnostics;
using System.Extensions;
using TrailBlazr.Services;
@@ -39,6 +39,7 @@ public sealed class AppViewModel
private readonly JSConsoleInterop jsConsoleInterop;
private readonly INotificationService notificationService;
private readonly IWindowManipulationService windowManipulationService;
private readonly IShellExecutor shellExecutor;
private readonly ILogger<App> logger;
private SemaphoreSlim themeChangeSemaphore = new(1, 1);
@@ -89,6 +90,7 @@ public sealed class AppViewModel
INotificationProducer notificationProducer,
INotificationService notificationService,
IWindowManipulationService windowManipulationService,
IShellExecutor shellExecutor,
ILogger<App> logger)
{
this.keyboardHookService = keyboardHookService.ThrowIfNull();
@@ -104,6 +106,7 @@ public sealed class AppViewModel
this.NotificationProducer = notificationProducer.ThrowIfNull();
this.notificationService = notificationService.ThrowIfNull();
this.windowManipulationService = windowManipulationService.ThrowIfNull();
this.shellExecutor = shellExecutor.ThrowIfNull();
this.logger = logger.ThrowIfNull();
@@ -215,14 +218,7 @@ public sealed class AppViewModel
public void OpenIssues()
{
try
{
Process.Start(new ProcessStartInfo { FileName = IssueUrl, UseShellExecute = true });
}
catch (Exception ex)
{
this.logger.LogError(ex, "Encountered exception while opening issues page");
}
this.shellExecutor.OpenUrl(IssueUrl);
}
public void OpenSynchronizationView()
@@ -0,0 +1,28 @@
<div class="empty-state">
<FluentIcon Value="@this.Icon" Color="Color.Neutral" />
<div class="empty-message">@this.Message</div>
<FluentButton Appearance="Appearance.Accent"
Disabled="@this.Disabled"
IconStart="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Add())"
Title="@this.ActionText"
OnClick="@this.OnAction">
@this.ActionText
</FluentButton>
</div>
@code {
[Parameter]
public string Message { get; set; } = string.Empty;
[Parameter]
public string ActionText { get; set; } = "Add";
[Parameter]
public Icon Icon { get; set; } = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Search();
[Parameter]
public bool Disabled { get; set; }
[Parameter]
public EventCallback OnAction { get; set; }
}
@@ -0,0 +1,14 @@
.empty-state {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
color: var(--neutral-foreground-hint);
}
.empty-message {
font-size: var(--font-size-large);
text-align: center;
}
@@ -0,0 +1,37 @@
<div class="browser-container @(this.IsMaximized ? "maximized" : string.Empty)">
<button class="browser-toggle-button"
title="@(this.IsMaximized ? "Restore" : "Expand to fullscreen")"
@onclick="this.ToggleMaximized"
@onclick:stopPropagation="true">
<FluentIcon Value="@(this.IsMaximized ?
(Microsoft.FluentUI.AspNetCore.Components.Icon)new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowMinimize() :
new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowMaximize())" />
</button>
<iframe sandbox="allow-scripts allow-same-origin allow-forms"
class="browser-frame"
src="@this.Source" />
</div>
@code {
[Parameter]
public string? Source { get; init; }
[Parameter]
public Action? OnMaximized { get; init; }
[Parameter]
public Action? OnRestore { get; init; }
private bool IsMaximized { get; set; }
private void ToggleMaximized()
{
this.IsMaximized = !this.IsMaximized;
if (this.IsMaximized)
{
this.OnMaximized?.Invoke();
}
else
{
this.OnRestore?.Invoke();
}
}
}
@@ -0,0 +1,38 @@
.browser-container {
position: relative;
display: flex;
flex: 1;
min-height: 0;
height: 100%;
width: 100%;
}
.browser-frame {
flex: 1;
height: 100%;
width: 100%;
margin: -2px;
border: none;
}
.browser-toggle-button {
position: absolute;
top: 4px;
right: 4px;
z-index: 1002;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
border-radius: 50%;
cursor: pointer;
background: rgba(0, 0, 0, 0.45);
color: #fff;
}
.browser-toggle-button:hover {
background: var(--accent-fill-rest);
}
@@ -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.ExpirationTime = DateTime.Now + NotificationExtension;
notification.StartTime = DateTime.UtcNow;
notification.ExpirationTime = DateTime.UtcNow + NotificationExtension;
notification.LifetimeGeneration++;
}
private async Task OpenNofitication(NotificationWrapper notification)
@@ -99,21 +99,25 @@
/* Notification level styling */
.notification-item.level-information {
border-left: 4px solid var(--accent-fill-rest);
--notification-accent: var(--accent-fill-rest);
border-left: 4px solid var(--notification-accent);
}
.notification-item.level-warning {
border-left: 4px solid #ff9500;
--notification-accent: #ff9500;
border-left: 4px solid var(--notification-accent);
}
.notification-item.level-error,
.notification-item.level-critical {
border-left: 4px solid #d13438;
--notification-accent: #d13438;
border-left: 4px solid var(--notification-accent);
}
.notification-item.level-debug,
.notification-item.level-trace {
border-left: 4px solid var(--neutral-stroke-accessible);
--notification-accent: var(--neutral-stroke-accessible);
border-left: 4px solid var(--notification-accent);
}
.notification-item[class*="level-"]:hover {
@@ -121,6 +125,32 @@
cursor: pointer;
}
/* Expiration progress line animating from start time to expiration */
.notification-progress {
position: absolute;
left: 0;
bottom: 0;
height: 3px;
width: 100%;
background: var(--notification-accent, var(--accent-fill-rest));
transform: scaleX(0);
transform-origin: left center;
animation-name: notificationProgress;
animation-timing-function: linear;
animation-fill-mode: forwards;
animation-iteration-count: 1;
pointer-events: none;
}
@keyframes notificationProgress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes slideInFromRight {
from {
transform: translateX(100%);
@@ -179,4 +209,9 @@
animation: none;
opacity: 0;
}
.notification-progress {
animation: none;
transform: scaleX(1);
}
}
@@ -6,6 +6,7 @@ using System.Core.Extensions;
using TrailBlazr.ViewModels;
namespace Daybreak.Views;
public sealed class EventCalendarViewModel(IEventService eventService)
: ViewModelBase<EventCalendarViewModel, EventCalendarView>
{
@@ -92,7 +93,7 @@ public sealed class EventCalendarViewModel(IEventService eventService)
{
dates.Add(new DateTime(today.Year, today.Month, day));
}
return dates;
}
@@ -107,6 +108,7 @@ public sealed class EventCalendarViewModel(IEventService eventService)
events.Add(eventItem);
}
}
return events;
}
}
+68 -57
View File
@@ -8,65 +8,76 @@
<div class="option-view-title">
<h3>Executables</h3>
</div>
<div class="executables-list">
@foreach (var executable in this.ViewModel.Executables)
{
<div class="executable-row">
<div class="executable-label">
<FluentLabel>Path</FluentLabel>
</div>
<div class="executable-field">
<FluentTextField Value="@executable.Path"
ReadOnly="true"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Placeholder="Path">
<div class="executable-field-controls"
slot="end">
@if (executable.Validating)
{
<div class="loading-circle">
<SpinnerWidget IsLoading="true" />
@if (executable.UpdateProgress is string progress)
{
<div class="update-progress-text">@progress</div>
}
</div>
}
else if (executable.Valid)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircle())"
Color="Color.Neutral"
Title="Executable is valid" />
}
else if (executable.NeedsUpdate)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDownload())"
@onclick="@(() => this.ViewModel.UpdateExecutable(executable))"
Color="Color.Neutral"
Title="Update executable" />
}
else
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircleWarning())"
Color="Color.Neutral"
Title="Executable is invalid" />
}
@if (this.ViewModel.Executables.Count is 0)
{
<EmptyStateWidget Message="No executables configured"
ActionText="Add executable"
Icon="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Apps())"
Disabled="@(!this.ViewModel.AddButtonEnabled)"
OnAction="@this.ViewModel.CreateExecutable" />
}
else
{
<div class="executables-list">
@foreach (var executable in this.ViewModel.Executables)
{
<div class="executable-row">
<div class="executable-label">
<FluentLabel>Path</FluentLabel>
</div>
<div class="executable-field">
<FluentTextField Value="@executable.Path"
ReadOnly="true"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Placeholder="Path">
<div class="executable-field-controls"
slot="end">
@if (executable.Validating)
{
<div class="loading-circle">
<SpinnerWidget IsLoading="true" />
@if (executable.UpdateProgress is string progress)
{
<div class="update-progress-text">@progress</div>
}
</div>
}
else if (executable.Valid)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircle())"
Color="Color.Neutral"
Title="Executable is valid" />
}
else if (executable.NeedsUpdate)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDownload())"
@onclick="@(() => this.ViewModel.UpdateExecutable(executable))"
Color="Color.Neutral"
Title="Update executable" />
}
else
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircleWarning())"
Color="Color.Neutral"
Title="Executable is invalid" />
}
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.FolderOpen())"
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.ModifyPath(executable); })"
Color="Color.Neutral"
Title="Modify path" />
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.RemoveExecutable(executable); })"
Color="Color.Neutral"
Title="Remove executable" />
</div>
</FluentTextField>
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.FolderOpen())"
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.ModifyPath(executable); })"
Color="Color.Neutral"
Title="Modify path" />
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.RemoveExecutable(executable); })"
Color="Color.Neutral"
Title="Remove executable" />
</div>
</FluentTextField>
</div>
</div>
</div>
}
</div>
}
</div>
}
</div>
</div>
@@ -37,6 +37,7 @@ public class ExecutablesViewModel(
{
this.Executables.Remove(executable);
this.guildWarsExecutableManager.RemoveExecutable(executable.Path);
this.RefreshView();
}
public async Task CreateExecutable()
+18 -5
View File
@@ -45,11 +45,11 @@
</div>
<div class="focus-content">
<div class="grid-stack" @ref="this.gridElement"
<div class="grid-stack @(this.maximizedComponent is not null ? "has-maximized" : string.Empty)" @ref="this.gridElement"
style="--focus-cols: @this.ViewModel.GridColumns; --focus-rows: @this.ViewModel.GridRows;">
@foreach (var tile in this.ViewModel.VisibleTiles)
{
<div class="grid-stack-item"
<div class="grid-stack-item @(this.maximizedComponent == tile.Component ? "maximized" : string.Empty)"
@key="tile.Component"
data-component="@tile.Component"
gs-x="@(tile.Column - 1)"
@@ -93,6 +93,7 @@
private ElementReference gridElement;
private int lastSyncedVersion = -1;
private bool isAddDropdownOpen;
private FocusViewComponent? maximizedComponent;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
@@ -138,14 +139,26 @@
private void EnterEditLayout()
{
this.isAddDropdownOpen = false;
this.maximizedComponent = null;
this.ViewModel.ToggleEditLayout();
}
private void ToggleAddDropdown() => this.isAddDropdownOpen = !this.isAddDropdownOpen;
private void SetMaximized(FocusViewComponent? component)
{
this.maximizedComponent = component;
this.StateHasChanged();
}
private async Task ToggleComponentAsync(FocusViewComponent component)
{
await this.SyncFromGridAsync();
if (this.maximizedComponent == component)
{
this.maximizedComponent = null;
}
this.ViewModel.ToggleTileVisibility(component);
}
@@ -181,9 +194,9 @@
OnBuildClicked="@this.ViewModel.OnBuildClicked"
OnLoadPlayerSingleBuildClicked="@this.ViewModel.OnPlayerSingleBuildClicked"
OnLoadPlayerTeamBuildClicked="@this.ViewModel.OnPlayerTeamBuildClicked" />,
FocusViewComponent.Browser => @<iframe sandbox="allow-scripts allow-same-origin allow-forms"
style="height: 100%; width: 100%; margin: -2px;"
src="@this.ViewModel.BrowserSource" />,
FocusViewComponent.Browser => @<BrowserComponent Source="@this.ViewModel.BrowserSource"
OnMaximized="@(() => this.SetMaximized(FocusViewComponent.Browser))"
OnRestore="@(() => this.SetMaximized(null))" />,
_ => @<text></text>
};
+16
View File
@@ -130,6 +130,22 @@
z-index: 1001;
}
/* A maximized tile (e.g. Browser) expands to cover the whole grid area, hiding its siblings.
GridStack positions tiles with inline transform/width/height, so !important is required to
override it here. */
.grid-stack-item.maximized {
position: absolute !important;
inset: 0 !important;
transform: none !important;
width: 100% !important;
height: 100% !important;
z-index: 1500;
}
.grid-stack.has-maximized > .grid-stack-item:not(.maximized) {
display: none;
}
/* Per-tile remove button (top-right) to hide a component while editing. */
.tile-remove-button {
position: absolute;
+151 -141
View File
@@ -9,151 +9,161 @@
<div class="option-view-title">
<h3>Launch Configurations</h3>
</div>
<div class="launch-configs-list">
@for (var i = 0; i < this.ViewModel.LaunchConfigurations.Count; i++)
{
var config = this.ViewModel.LaunchConfigurations[i];
var index = i;
<div class="launch-configs-row" id="launch-config-@config.Identifier">
<div class="launch-config-reorder">
<div class="launch-config-reorder-label">
<FluentLabel>Reorder</FluentLabel>
@if (this.ViewModel.LaunchConfigurations.Count is 0)
{
<EmptyStateWidget Message="No launch configurations yet"
ActionText="Add launch configuration"
Icon="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Rocket())"
OnAction="@this.ViewModel.CreateNewLaunchConfiguration" />
}
else
{
<div class="launch-configs-list">
@for (var i = 0; i < this.ViewModel.LaunchConfigurations.Count; i++)
{
var config = this.ViewModel.LaunchConfigurations[i];
var index = i;
<div class="launch-configs-row" id="launch-config-@config.Identifier">
<div class="launch-config-reorder">
<div class="launch-config-reorder-label">
<FluentLabel>Reorder</FluentLabel>
</div>
<div class="launch-config-reorder-buttons">
@if (index > 0)
{
<FluentButton Appearance="Appearance.Stealth"
IconOnly="true"
Title="Move up"
OnClick="@(() => this.ViewModel.MoveConfigUp(config))">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowUp())" />
</FluentButton>
}
@if (index < this.ViewModel.LaunchConfigurations.Count - 1)
{
<FluentButton Appearance="Appearance.Stealth"
IconOnly="true"
Title="Move down"
OnClick="@(() => this.ViewModel.MoveConfigDown(config))">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDown())" />
</FluentButton>
}
</div>
</div>
<div class="launch-config-reorder-buttons">
@if (index > 0)
{
<FluentButton Appearance="Appearance.Stealth"
IconOnly="true"
Title="Move up"
OnClick="@(() => this.ViewModel.MoveConfigUp(config))">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowUp())" />
<div class="launch-config-identifier">
<div class="launch-config-identifier-label">
<FluentLabel>Identifier</FluentLabel>
</div>
<div class="launch-config-identifier-field">
<FluentTextField Value="@config.Identifier" ReadOnly="true" Spellcheck="false"
Placeholder="Launch config identifier">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => this.ViewModel.DeleteLaunchConfiguration(config))" slot="end" Color="Color.Neutral"
Title="Delete launch configuration" />
</FluentTextField>
</div>
</div>
<div class="launch-config-name">
<div class="launch-config-name-label">
<FluentLabel>Name</FluentLabel>
</div>
<div class="launch-config-name-field">
<FluentTextField Value="@config.Name"
ValueChanged="@((newValue) => this.ViewModel.CustomNameChanged(config, newValue))" Spellcheck="false"
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
Placeholder="Custom name" />
</div>
</div>
<div class="launch-config-color">
<div class="launch-config-color-label">
<FluentLabel>Accent Color</FluentLabel>
</div>
<div class="launch-config-color-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.AvailableColors"
SelectedOption="@(config.Color ?? string.Empty)"
SelectedOptionChanged="@((newValue) => this.ViewModel.ColorChanged(config, newValue))">
<OptionTemplate>
<div class="color-option">
@if (!string.IsNullOrWhiteSpace(context))
{
<div class="color-swatch" style="background-color: @(Daybreak.Shared.Models.ColorPalette.AccentColor.Accents.FirstOrDefault(a => a.Name.ToString() == context)?.Hex ?? "transparent")"></div>
}
<span>@this.ViewModel.GetColorDisplayName(context)</span>
</div>
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-executable">
<div class="launch-config-executable-label">
<FluentLabel>Executable</FluentLabel>
</div>
<div class="launch-config-executable-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.Executables" SelectedOption="@config.ExecutablePath"
SelectedOptionChanged="@((newValue) => this.ViewModel.ExecutableChanged(config, newValue))">
<OptionTemplate>
<div title="@(context)">
@(string.IsNullOrWhiteSpace(context) ? "Any Executable" : context)
</div>
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-credentials">
<div class="launch-config-credentials-label">
<FluentLabel>Credentials</FluentLabel>
</div>
<div class="launch-config-credentials-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.Credentials.Select(c => c.Identifier ?? string.Empty)"
SelectedOption="@(config.Credentials?.Identifier ?? string.Empty)"
SelectedOptionChanged="@((newValue) => this.ViewModel.CredentialsChanged(config, newValue))">
<OptionTemplate>
@(this.ViewModel.Credentials.FirstOrDefault(c => c.Identifier == context)?.Username ?? "Unknown")
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-args">
<div class="launch-config-args-label">
<FluentLabel>Custom Arguments</FluentLabel>
</div>
<div class="launch-config-args-field">
<FluentTextField Value="@config.Arguments"
ValueChanged="@((newValue) => this.ViewModel.CustomArgsChanged(config, newValue))" Spellcheck="false"
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
Placeholder="Launch arguments" />
</div>
</div>
<div class="launch-config-steam">
<div class="launch-config-steam-label">
<FluentLabel>Steam support</FluentLabel>
</div>
<div class="launch-config-steam-field">
<FluentCheckbox Value="@config.SteamSupport"
ValueChanged="@((newValue) => this.ViewModel.SteamSupportChanged(config, newValue))" />
</div>
</div>
<div class="launch-config-custom-mods">
<div class="launch-config-custom-mods-label">
<FluentLabel>Custom mod loadout</FluentLabel>
</div>
<div class="launch-config-custom-mods-field">
<FluentCheckbox Value="@config.CustomModLoadoutEnabled"
ValueChanged="@((newValue) => this.ViewModel.CustomModLoadoutChanged(config, newValue))" />
<FluentButton Appearance="Appearance.Neutral"
@onclick="@(() => this.ViewModel.ManageCustomMods(config))"
Disabled="@(!config.CustomModLoadoutEnabled)"
Title="Manage custom mods for this launch configuration">
Manage Mods
</FluentButton>
}
@if (index < this.ViewModel.LaunchConfigurations.Count - 1)
{
<FluentButton Appearance="Appearance.Stealth"
IconOnly="true"
Title="Move down"
OnClick="@(() => this.ViewModel.MoveConfigDown(config))">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDown())" />
</FluentButton>
}
</div>
</div>
</div>
<div class="launch-config-identifier">
<div class="launch-config-identifier-label">
<FluentLabel>Identifier</FluentLabel>
</div>
<div class="launch-config-identifier-field">
<FluentTextField Value="@config.Identifier" ReadOnly="true" Spellcheck="false"
Placeholder="Launch config identifier">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => this.ViewModel.DeleteLaunchConfiguration(config))" slot="end" Color="Color.Neutral"
Title="Delete launch configuration" />
</FluentTextField>
</div>
</div>
<div class="launch-config-name">
<div class="launch-config-name-label">
<FluentLabel>Name</FluentLabel>
</div>
<div class="launch-config-name-field">
<FluentTextField Value="@config.Name"
ValueChanged="@((newValue) => this.ViewModel.CustomNameChanged(config, newValue))" Spellcheck="false"
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
Placeholder="Custom name" />
</div>
</div>
<div class="launch-config-color">
<div class="launch-config-color-label">
<FluentLabel>Accent Color</FluentLabel>
</div>
<div class="launch-config-color-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.AvailableColors"
SelectedOption="@(config.Color ?? string.Empty)"
SelectedOptionChanged="@((newValue) => this.ViewModel.ColorChanged(config, newValue))">
<OptionTemplate>
<div class="color-option">
@if (!string.IsNullOrWhiteSpace(context))
{
<div class="color-swatch" style="background-color: @(Daybreak.Shared.Models.ColorPalette.AccentColor.Accents.FirstOrDefault(a => a.Name.ToString() == context)?.Hex ?? "transparent")"></div>
}
<span>@this.ViewModel.GetColorDisplayName(context)</span>
</div>
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-executable">
<div class="launch-config-executable-label">
<FluentLabel>Executable</FluentLabel>
</div>
<div class="launch-config-executable-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.Executables" SelectedOption="@config.ExecutablePath"
SelectedOptionChanged="@((newValue) => this.ViewModel.ExecutableChanged(config, newValue))">
<OptionTemplate>
<div title="@(context)">
@(string.IsNullOrWhiteSpace(context) ? "Any Executable" : context)
</div>
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-credentials">
<div class="launch-config-credentials-label">
<FluentLabel>Credentials</FluentLabel>
</div>
<div class="launch-config-credentials-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.Credentials.Select(c => c.Identifier ?? string.Empty)"
SelectedOption="@(config.Credentials?.Identifier ?? string.Empty)"
SelectedOptionChanged="@((newValue) => this.ViewModel.CredentialsChanged(config, newValue))">
<OptionTemplate>
@(this.ViewModel.Credentials.FirstOrDefault(c => c.Identifier == context)?.Username ?? "Unknown")
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-args">
<div class="launch-config-args-label">
<FluentLabel>Custom Arguments</FluentLabel>
</div>
<div class="launch-config-args-field">
<FluentTextField Value="@config.Arguments"
ValueChanged="@((newValue) => this.ViewModel.CustomArgsChanged(config, newValue))" Spellcheck="false"
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
Placeholder="Launch arguments" />
</div>
</div>
<div class="launch-config-steam">
<div class="launch-config-steam-label">
<FluentLabel>Steam support</FluentLabel>
</div>
<div class="launch-config-steam-field">
<FluentCheckbox Value="@config.SteamSupport"
ValueChanged="@((newValue) => this.ViewModel.SteamSupportChanged(config, newValue))" />
</div>
</div>
<div class="launch-config-custom-mods">
<div class="launch-config-custom-mods-label">
<FluentLabel>Custom mod loadout</FluentLabel>
</div>
<div class="launch-config-custom-mods-field">
<FluentCheckbox Value="@config.CustomModLoadoutEnabled"
ValueChanged="@((newValue) => this.ViewModel.CustomModLoadoutChanged(config, newValue))" />
<FluentButton Appearance="Appearance.Neutral"
@onclick="@(() => this.ViewModel.ManageCustomMods(config))"
Disabled="@(!config.CustomModLoadoutEnabled)"
Title="Manage custom mods for this launch configuration">
Manage Mods
</FluentButton>
</div>
</div>
</div>
}
</div>
}
</div>
}
</div>
</div>
@@ -30,6 +30,7 @@ public sealed class LaunchConfigurationsViewModel(
public override ValueTask ParametersSet(LaunchConfigurationsView view, CancellationToken cancellationToken)
{
this.Credentials.ClearAnd().AddRange(this.credentialManager.GetCredentialList());
this.Credentials.Add(LoginCredentials.SteamLogin);
this.LaunchConfigurations.ClearAnd().AddRange(this.launchConfigurationService.GetLaunchConfigurations());
this.Executables.ClearAnd().AddRange(this.guildWarsExecutableManager.GetExecutableList()).Add(string.Empty);
return base.ParametersSet(view, cancellationToken);
+4 -1
View File
@@ -507,9 +507,12 @@ public sealed class LaunchViewModel(
cancellationToken
);
// If the API is available but it does not belong to the desired user, return null
// If the API is available but it does not belong to the desired user, return null.
// Steam login configurations use a virtual credential whose username is not the account
// email, so the ownership check is skipped for them.
if (
maybeApiContext is not null
&& launcherViewContext.Configuration.Credentials.IsSteamLogin is false
&& await maybeApiContext.GetLoginInfo(cancellationToken) is LoginInfo loginInfo
&& loginInfo.Email != launcherViewContext.Configuration.Credentials.Username
)
+5 -3
View File
@@ -1,8 +1,8 @@
using Daybreak.Shared.Models.Plugins;
using Daybreak.Shared.Services.ApplicationLauncher;
using Daybreak.Shared.Services.Plugins;
using Daybreak.Shared.Services.Shell;
using Photino.NET;
using System.Diagnostics;
using System.Extensions;
using TrailBlazr.Services;
using TrailBlazr.ViewModels;
@@ -12,13 +12,15 @@ public sealed class PluginsViewModel(
PhotinoWindow window,
IApplicationLauncher applicationLauncher,
IViewManager viewManager,
IPluginsService pluginsService)
IPluginsService pluginsService,
IShellExecutor shellExecutor)
: ViewModelBase<PluginsViewModel, PluginsView>
{
private readonly PhotinoWindow window = window;
private readonly IApplicationLauncher applicationLauncher = applicationLauncher;
private readonly IViewManager viewManager = viewManager;
private readonly IPluginsService pluginsService = pluginsService;
private readonly IShellExecutor shellExecutor = shellExecutor;
public List<AvailablePlugin> AvailablePlugins { get; init; } = [];
public bool CanSave { get; private set; } = false;
@@ -71,7 +73,7 @@ public sealed class PluginsViewModel(
public void NavigateToPlugin(AvailablePlugin plugin)
{
Process.Start("explorer.exe", plugin.Path);
this.shellExecutor.OpenPath(plugin.Path);
}
private void UpdatePlugins()
@@ -1,15 +1,23 @@
<div class="stretch-container backdrop-panel">
<div class="content">
<Virtualize Items="@this.ViewModel.Versions"
Context="version">
<div class="version-row">
<div class="version-row-center" @onclick="@(() => this.ViewModel.OpenVersionPage(version))">
<span>@version.ToString()</span>
Context="version"
ItemSize="56">
<div class="version-item" tabindex="0"
@onclick="@(() => this.ViewModel.OpenVersionPage(version))">
<div class="version-info">
<div class="version-icon">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size20.Tag())"
Color="Color.Neutral" />
</div>
<span class="version-name">@version.ToString()</span>
</div>
<div class="version-row-right">
<div class="version-actions"
@onclick="@(() => this.ViewModel.DownloadVersion(version))"
@onclick:stopPropagation="true">
<FluentButton Appearance="Appearance.Stealth"
IconOnly="true"
OnClick="@(() => this.ViewModel.DownloadVersion(version))">
Title="Download version">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDownload())" />
</FluentButton>
</div>
@@ -1,5 +1,5 @@
using Daybreak.Shared.Services.Updater;
using System.Diagnostics;
using Daybreak.Shared.Services.Shell;
using Daybreak.Shared.Services.Updater;
using System.Extensions;
using TrailBlazr.Services;
using TrailBlazr.ViewModels;
@@ -7,13 +7,15 @@ using TrailBlazr.ViewModels;
namespace Daybreak.Views;
public sealed class VersionManagementViewModel(
IViewManager viewManager,
IApplicationUpdater applicationUpdater)
IApplicationUpdater applicationUpdater,
IShellExecutor shellExecutor)
: ViewModelBase<VersionManagementViewModel, VersionManagementView>
{
private const string VersionPlaceholder = "{VERSION}";
private const string ReleaseURL = $"https://github.com/AlexMacocian/Daybreak/releases/tag/v{VersionPlaceholder}";
private readonly IViewManager viewManager = viewManager;
private readonly IApplicationUpdater applicationUpdater = applicationUpdater;
private readonly IShellExecutor shellExecutor = shellExecutor;
public List<Version> Versions { get; init; } = [];
public Version? CurrentVersion { get; set; }
@@ -31,6 +33,6 @@ public sealed class VersionManagementViewModel(
public void OpenVersionPage(Version version)
{
Process.Start("explorer.exe", ReleaseURL.Replace(VersionPlaceholder, version.ToString()));
this.shellExecutor.OpenUrl(ReleaseURL.Replace(VersionPlaceholder, version.ToString()));
}
}
@@ -13,15 +13,55 @@
margin: 50px 0px 0px 0px;
}
.version-row {
.version-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 12px;
border-bottom: 1px solid var(--neutral-stroke-divider-rest);
cursor: pointer;
transition: background-color 0.2s ease;
box-sizing: border-box;
border-radius: 6px;
height: 56px;
}
.version-row-center {
cursor: pointer;
text-align: center;
align-content: center;
.version-item:hover {
background-color: var(--accent-fill-hover);
}
.version-item:focus {
outline: 2px solid var(--accent-fill-focus);
outline-offset: -2px;
}
.version-info {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
min-width: 0;
}
.version-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.version-name {
font-weight: 600;
font-size: var(--font-size-medium);
color: var(--neutral-foreground-rest);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.version-actions {
flex-shrink: 0;
}
+33 -12
View File
@@ -178,6 +178,7 @@ internal static class CppHeaderParser
{
inMultiLineConstexpr = false;
}
continue;
}
@@ -189,6 +190,7 @@ internal static class CppHeaderParser
{
inSkipBlock = false;
}
continue;
}
@@ -211,6 +213,7 @@ internal static class CppHeaderParser
{
ParseEnumMembersFromLine(trimmed.Substring(0, closeBraceIdx), currentEnum);
}
inEnum = false;
var node = namespaceStack.Peek();
node.Enums.Add(currentEnum);
@@ -231,11 +234,11 @@ internal static class CppHeaderParser
int closeBraces = CountChar(lineWithoutComments, '}');
int prevDepth = structBraceDepth;
structBraceDepth += openBraces - closeBraces;
// Debug: Track brace changes for AgentLiving
if (currentStruct.Name == "AgentLiving" && (openBraces > 0 || closeBraces > 0))
{
DebugLines.Add($"[BRACE] line {i+1}: {currentStruct.Name} depth {prevDepth}->{structBraceDepth} (open={openBraces}, close={closeBraces})");
DebugLines.Add($"[BRACE] line {i + 1}: {currentStruct.Name} depth {prevDepth}->{structBraceDepth} (open={openBraces}, close={closeBraces})");
}
// If we hit the closing brace of the struct
@@ -252,8 +255,8 @@ internal static class CppHeaderParser
pathParts.Add(n.Name);
pathParts.Reverse();
currentStruct.DebugNamespacePath = string.Join(".", pathParts);
DebugLines.Add($"[STRUCT-END] line {i+1}: {currentStruct.Name} with {currentStruct.Fields.Count} fields");
DebugLines.Add($"[STRUCT-END] line {i + 1}: {currentStruct.Name} with {currentStruct.Fields.Count} fields");
node.Structs.Add(currentStruct);
currentStruct = null;
inStruct = false;
@@ -278,6 +281,7 @@ internal static class CppHeaderParser
if (field is not null)
currentStruct.Fields.Add(field);
}
continue;
}
@@ -295,7 +299,7 @@ internal static class CppHeaderParser
var trailingCommentIdx = trimmed.IndexOf("//");
if (trailingCommentIdx > 0)
trimmedWithoutTrailingComment = trimmed.Substring(0, trailingCommentIdx).TrimEnd();
if (trimmedWithoutTrailingComment.Contains("(") && (trimmedWithoutTrailingComment.Contains(")") || trimmedWithoutTrailingComment.Contains("{")))
continue;
@@ -339,6 +343,7 @@ internal static class CppHeaderParser
};
currentStruct.Fields.Add(field);
}
continue;
}
@@ -354,6 +359,7 @@ internal static class CppHeaderParser
var child = current.GetOrCreateChild(part);
namespaceStack.Push(child);
}
continue;
}
@@ -366,13 +372,14 @@ internal static class CppHeaderParser
freeBraceDepth--;
continue;
}
if (namespaceStack.Count > 1)
{
// Debug: Track where namespace was popped
var poppedNode = namespaceStack.Pop();
poppedNode.DebugPopLine = i + 1;
}
continue;
}
@@ -390,6 +397,7 @@ internal static class CppHeaderParser
}
}
}
continue;
}
@@ -436,6 +444,7 @@ internal static class CppHeaderParser
inSkipBlock = true;
skipBlockBraceCount = braces;
}
continue;
}
@@ -448,6 +457,7 @@ internal static class CppHeaderParser
inSkipBlock = true;
skipBlockBraceCount = braces;
}
continue;
}
@@ -455,17 +465,18 @@ internal static class CppHeaderParser
if (OutOfLineMethodRegex.IsMatch(trimmed))
{
int braces = CountChar(trimmed, '{') - CountChar(trimmed, '}');
DebugLines.Add($"[OUT-OF-LINE] line {i+1}: {trimmed.Substring(0, Math.Min(50, trimmed.Length))}... braces={braces}");
DebugLines.Add($"[OUT-OF-LINE] line {i + 1}: {trimmed.Substring(0, Math.Min(50, trimmed.Length))}... braces={braces}");
if (braces > 0)
{
inSkipBlock = true;
skipBlockBraceCount = braces;
}
continue;
}
else if (trimmed.Contains("::") && trimmed.Contains("(") && trimmed.Contains("{"))
{
DebugLines.Add($"[UNMATCHED-METHOD] line {i+1}: {trimmed.Substring(0, Math.Min(60, trimmed.Length))}");
DebugLines.Add($"[UNMATCHED-METHOD] line {i + 1}: {trimmed.Substring(0, Math.Min(60, trimmed.Length))}");
}
// ── Track function declarations with body on separate line ─
@@ -492,6 +503,7 @@ internal static class CppHeaderParser
inMultiLineConstexpr = true;
multiLineBraceCount = braces;
}
continue;
}
@@ -501,7 +513,7 @@ internal static class CppHeaderParser
{
var structName = structMatch.Groups[1].Value;
var baseType = structMatch.Groups[2].Success ? structMatch.Groups[2].Value : null;
DebugLines.Add($"[STRUCT-START] line {i+1}: {structName} (inSkip={inSkipBlock}, freeBrace={freeBraceDepth})");
DebugLines.Add($"[STRUCT-START] line {i + 1}: {structName} (inSkip={inSkipBlock}, freeBrace={freeBraceDepth})");
// Skip some special cases
if (structName.StartsWith("__") || structName == "Packet")
@@ -512,6 +524,7 @@ internal static class CppHeaderParser
inSkipBlock = true;
skipBlockBraceCount = braces;
}
continue;
}
@@ -530,7 +543,7 @@ internal static class CppHeaderParser
if (structNobraceMatch.Success)
{
var structName = structNobraceMatch.Groups[1].Value;
// Skip forward declarations (just struct Name;)
if (trimmed.EndsWith(";"))
continue;
@@ -559,8 +572,10 @@ internal static class CppHeaderParser
i = j;
break;
}
break; // unexpected content
}
continue;
}
@@ -593,9 +608,11 @@ internal static class CppHeaderParser
{
ParseEnumMembersFromLine(rest, enumDef);
}
currentEnum = enumDef;
inEnum = true;
}
continue;
}
@@ -637,14 +654,18 @@ internal static class CppHeaderParser
{
ParseEnumMembersFromLine(rest, enumDef);
}
currentEnum = enumDef;
inEnum = true;
}
i = j;
break;
}
break; // unexpected content
}
continue;
}
@@ -794,7 +815,7 @@ internal static class CppHeaderParser
int slashSlash = line.IndexOf("//");
if (slashSlash >= 0)
line = line.Substring(0, slashSlash);
// Remove /* */ style comments (simple - doesn't handle nested)
int blockStart = line.IndexOf("/*");
while (blockStart >= 0)
@@ -806,7 +827,7 @@ internal static class CppHeaderParser
line = line.Substring(0, blockStart);
blockStart = line.IndexOf("/*");
}
return line;
}
+77 -104
View File
@@ -238,7 +238,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Emit
var sb = new StringBuilder(65536);
EmitHeader(sb, totalExports, skippedExports);
// Add diagnostic comment about parsed structs
sb.AppendLine(" // ═══════════════════════════════════════════════════");
sb.AppendLine(" // Parsed structs diagnostic:");
@@ -246,6 +246,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
{
sb.AppendLine($" // {diag}");
}
sb.AppendLine(" // ═══════════════════════════════════════════════════");
sb.AppendLine();
@@ -270,31 +271,33 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
{
EmitConstantsNode(sb, grandchild, typeMap, namespaceClassNames, 4);
}
continue;
}
EmitConstantsNode(sb, child, typeMap, namespaceClassNames, 4);
}
sb.AppendLine("}"); // Close GWCA class
sb.AppendLine("}"); // Close Daybreak.API.Interop namespace
// Emit structs and enums into GuildWars namespace for consumer code compatibility
// Consumer code uses `using Daybreak.API.Interop.GuildWars;` to access types
sb.AppendLine();
sb.AppendLine("namespace Daybreak.API.Interop.GuildWars");
sb.AppendLine("{");
var emittedNames = new HashSet<string>(StringComparer.Ordinal);
// Collect inline array types needed by struct fields (for non-blittable array fields)
var inlineArrayTypes = new HashSet<(string csType, int size)>();
CollectInlineArrayTypes(headerRoot, typeMap, inlineArrayTypes);
// Emit manually-defined helper structs first (TLink, etc.)
EmitManualHelperStructs(sb, 4, emittedNames);
// Emit inline array types for non-blittable arrays (e.g., enum arrays)
EmitInlineArrayTypes(sb, 4, inlineArrayTypes, emittedNames);
// Emit enums first - they're often referenced by structs (e.g. Attribute enum vs Attribute struct)
EmitEnumsToGuildWarsNamespace(sb, headerRoot, 4, emittedNames);
EmitStructsToGuildWarsNamespace(sb, headerRoot, typeMap, 4, emittedNames, inlineArrayTypes);
@@ -308,45 +311,6 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
var outputPath = Path.Combine(repoRoot!, "Daybreak.API", "Interop", "GWCA.cs");
#pragma warning disable RS1035 // File IO required to emit source that LibraryImport generator can process
File.WriteAllText(outputPath, sb.ToString());
// Emit linker /ALTERNATENAME directives for the NativeAOT (DirectPInvoke,
// win-x86) static-link path. NativeAOT decorates cdecl P/Invoke targets
// with a leading underscore ('_?Foo@GW@@...'), but MSVC emits C++ symbols
// without it ('?Foo@GW@@...'). DirectPInvoke does no fuzzy matching, so we
// bridge each referenced symbol to its real name. Harmless on the DLL path
// (the file is only consumed by the native linker via Daybreak.API.csproj).
EmitAlternateNamesFile(sb.ToString(),
Path.Combine(repoRoot!, "Daybreak.API", "Interop", "GWCA.alternatenames.rsp"));
#pragma warning restore RS1035
}
/// <summary>
/// Scans the generated bindings for active (non-commented) LibraryImport
/// entry points and writes one "/ALTERNATENAME:_&lt;entry&gt;=&lt;entry&gt;"
/// directive per unique symbol, bridging NativeAOT's x86 cdecl underscore
/// decoration to GWCA's undecorated MSVC C++ export names.
/// </summary>
private static void EmitAlternateNamesFile(string generatedSource, string outputPath)
{
const string marker = "[LibraryImport(DllName, EntryPoint = \"";
var seen = new HashSet<string>(StringComparer.Ordinal);
var alt = new StringBuilder();
foreach (var rawLine in generatedSource.Split('\n'))
{
var line = rawLine.Trim();
// Skip commented-out bindings ("// [LibraryImport...]").
if (!line.StartsWith(marker, StringComparison.Ordinal))
continue;
var start = marker.Length;
var end = line.IndexOf('"', start);
if (end <= start)
continue;
var entry = line.Substring(start, end - start);
if (seen.Add(entry))
alt.Append("/ALTERNATENAME:_").Append(entry).Append('=').Append(entry).Append('\n');
}
#pragma warning disable RS1035 // File IO required to emit linker directives the native linker consumes
File.WriteAllText(outputPath, alt.ToString());
#pragma warning restore RS1035
}
@@ -509,11 +473,8 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Build LibraryImport attribute and calling convention. GWCA's free
// functions are __cdecl (the 'YA' in the mangled name) and members are
// __thiscall ('QAE'/'AAE'). The convention must be explicit: it is both
// an ABI requirement (x86 stack cleanup differs) and, for DirectPInvoke
// static linking, it drives the symbol decoration the linker resolves.
// Without it the default Winapi/__stdcall mangles every free function as
// '_<name>@<bytes>', which matches neither the cdecl ABI nor the lib.
// __thiscall ('QAE'/'AAE'). The convention must be explicit on x86
// because stack cleanup differs from the Winapi/__stdcall default.
var conventionAttr = info.IsMember
? $"[UnmanagedCallConv(CallConvs = [typeof(CallConvThiscall)])]"
: $"[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]";
@@ -593,6 +554,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
else
sb.AppendLine($"{innerPad} {SanitizeIdentifier(member.Name)},{comment}");
}
sb.AppendLine($"{innerPad}}}");
}
else
@@ -708,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))
{
@@ -735,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))
@@ -757,7 +719,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
typeMap[simpleStructKey] = fqCsType;
}
}
// Structs go into GuildWars namespace (flat), not nested GWCA classes
typeMap[mapKey] = fqCsType;
}
@@ -787,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);
@@ -814,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);
@@ -830,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}");
@@ -857,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>
@@ -949,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))
@@ -957,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),
@@ -971,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)
{
@@ -1001,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)
@@ -1045,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))
{
@@ -1062,7 +1026,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
}
}
}
foreach (var child in node.Children.Values)
{
CollectInlineArrayTypes(child, typeMap, inlineArrayTypes);
@@ -1076,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>");
@@ -1109,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;
}
@@ -1134,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)
{
@@ -1166,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)
{
@@ -1183,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("*"))
@@ -1212,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)
{
@@ -1301,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>";
}
@@ -1328,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")
@@ -1343,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("::"))
{
@@ -1399,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",
@@ -1462,6 +1433,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
result.Append(part.Substring(1));
}
}
return result.ToString();
}
@@ -1691,7 +1663,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
_ => "nint",
};
}
/// <summary>
/// Resolves a struct name to its C# type, handling name collisions with enums.
/// </summary>
@@ -1706,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.
@@ -1722,7 +1694,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Fall back to direct lookup result (could be an enum) or just the name
return direct ?? name;
}
/// <summary>
/// Resolves a pointer type used as a template argument (e.g., Agent* in Array&lt;Agent*&gt;).
/// In unsafe C#, pointer types can be used as generic type arguments for unmanaged structs.
@@ -1731,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
{
@@ -1749,7 +1721,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
_ => "nint", // unmapped pointer -> nint
};
}
/// <summary>
/// Maps C/C++ primitive type names to C# primitive types.
/// </summary>
@@ -1932,6 +1904,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
child = new ConstantsNode(childName);
this.Children[childName] = child;
}
return child;
}
}
@@ -1985,4 +1958,4 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
public string SourceType { get; set; } = ""; // e.g., "Array"
public string? TemplateArg { get; set; } // e.g., "Buff"
}
}
}
+1
View File
@@ -461,6 +461,7 @@ internal sealed class MsvcDemangler
{
pos++; // skip Z - this terminates the function pointer, not the outer param list
}
break;
}
+4
View File
@@ -25,6 +25,10 @@ internal static partial class NativeMethods
[LibraryImport("kernel32.dll")]
public static partial nint OpenProcess(ProcessAccessFlags dwDesiredAccess, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, uint dwProcessID);
[LibraryImport("kernel32.dll", EntryPoint = "QueryFullProcessImageNameW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool QueryFullProcessImageName(nint hProcess, uint dwFlags, [Out] char[] lpExeName, ref uint lpdwSize);
[LibraryImport("kernel32.dll", EntryPoint = "GetModuleHandleW", StringMarshalling = StringMarshalling.Utf16)]
public static partial nint GetModuleHandle(string lpModuleName);
+1 -1
View File
@@ -17,7 +17,7 @@ public sealed class ProcessLauncher
{
var sw = Stopwatch.StartNew();
Process? process;
processId = LaunchClient(path, string.Join(" ", args), elevated, out threadId);
processId = LaunchClient(path, args, elevated, out threadId);
if (processId is 0)
{
Console.WriteLine("Failed to launch GuildWars process.");
+78
View File
@@ -0,0 +1,78 @@
using Daybreak.Shared.Models;
using System.Diagnostics;
namespace Daybreak.Injector;
/// <summary>
/// Resolves a Wine-internal process id from a Guild Wars executable path.
///
/// On Linux multiple Guild Wars instances (each in its own install directory) run under
/// the same Wine prefix. Matching by executable name alone cannot tell them apart, so we
/// enumerate the running processes and compare each one's full image path — which the
/// caller already knows from the Linux side — to find the unique owning Wine process.
///
/// This runs inside Wine, where <see cref="Process.Id"/> is the Wine pid, so the matched
/// id is exactly what the stub/winapi injectors expect.
/// </summary>
public static class ProcessResolver
{
private const string GuildWarsProcessName = "Gw";
public static InjectorResponses.ResolveResult Resolve(string executablePath, out int processId)
{
processId = 0;
var target = NormalizePath(executablePath);
foreach (var process in Process.GetProcessesByName(GuildWarsProcessName))
{
try
{
if (TryGetImagePath(process.Id) is { } imagePath &&
string.Equals(NormalizePath(imagePath), target, StringComparison.Ordinal))
{
processId = process.Id;
return InjectorResponses.ResolveResult.Success;
}
}
finally
{
process.Dispose();
}
}
return InjectorResponses.ResolveResult.ProcessNotFound;
}
private static string? TryGetImagePath(int processId)
{
var handle = NativeMethods.OpenProcess(
NativeMethods.ProcessAccessFlags.QueryLimitedInformation, false, (uint)processId);
if (handle is 0)
{
return null;
}
try
{
var buffer = new char[1024];
var size = (uint)buffer.Length;
if (!NativeMethods.QueryFullProcessImageName(handle, 0, buffer, ref size) || size is 0)
{
return null;
}
return new string(buffer, 0, (int)size);
}
finally
{
NativeMethods.CloseHandle(handle);
}
}
/// <summary>
/// Normalizes a Windows/Wine path for comparison: unifies separators, trims a trailing
/// null/whitespace, and lower-cases (Windows paths are case-insensitive).
/// </summary>
private static string NormalizePath(string path) =>
path.Trim().TrimEnd('\0').Replace('/', '\\').ToLowerInvariant();
}
+83 -1
View File
@@ -12,14 +12,60 @@ static void PrintUsage()
Console.WriteLine("- stub");
Console.WriteLine("- launch");
Console.WriteLine("- resume");
Console.WriteLine("- resolve");
Console.WriteLine("Examples:");
Console.WriteLine("1) Daybreak.Injector winapi 1234 C:\\path\\to\\dll.dll");
Console.WriteLine("2) Daybreak.Injector stub 1234 entryPoint C:\\path\\to\\dll.dll");
Console.WriteLine("3) Daybreak.Injector launch true C:\\path\\to\\dll.dll arg1 arg2 arg3");
Console.WriteLine("4) Daybreak.Injector resume 1234");
Console.WriteLine("5) Daybreak.Injector resolve \"Z:\\path\\to\\Gw.exe\"");
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,
@@ -135,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))
{
@@ -173,6 +219,24 @@ static bool TryParseThreadResumeArgs(
return true;
}
static bool TryParseResolveArgs(
string[] args,
[NotNullWhen(true)] out string? executablePath,
out InjectorResponses.ResolveResult exitCode)
{
executablePath = default;
if (args.Length < 2 || string.IsNullOrWhiteSpace(args[1]))
{
PrintUsage();
exitCode = InjectorResponses.ResolveResult.InvalidArgs;
return false;
}
executablePath = args[1];
exitCode = InjectorResponses.ResolveResult.Success;
return true;
}
if (args.Length < 1)
{
PrintUsage();
@@ -244,6 +308,24 @@ switch (mode)
Console.WriteLine($"ExitCode: {result}");
return result;
}
case "resolve":
{
if (!TryParseResolveArgs(args, out var executablePath, out var parseResult))
{
Console.WriteLine($"ExitCode: {(int)parseResult}");
return (int)parseResult;
}
Console.WriteLine($"Resolving Wine PID for {executablePath}");
var result = ProcessResolver.Resolve(executablePath, out var processId);
if (result is InjectorResponses.ResolveResult.Success)
{
Console.WriteLine($"ProcessId: {processId}");
}
Console.WriteLine($"ExitCode: {(int)result}");
return (int)result;
}
default:
PrintUsage();
Console.WriteLine($"ExitCode: {(int)InjectorResponses.GenericResults.InvalidMode}");
@@ -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>();
+173 -4
View File
@@ -1,22 +1,191 @@
using Daybreak.Linux.Services.Wine;
using Daybreak.Shared.Services.Api;
using System.Globalization;
namespace Daybreak.Linux.Services.Api;
/// <summary>
/// Linux implementation of <see cref="IPidProvider"/>.
/// On Linux, the reported PID is a Wine-internal PID that must be
/// converted to the actual Linux system PID via <see cref="IWinePidMapper"/>.
/// On Linux, the reported PID is a Wine-internal PID. The most reliable way to map an
/// API instance to its Linux process is via the TCP port it listens on: we resolve the
/// owning Linux PID from the listening socket. This disambiguates multiple concurrent
/// Guild Wars instances, which a Wine-PID-by-name lookup cannot do.
/// </summary>
public sealed class PidProvider(IWinePidMapper winePidMapper) : IPidProvider
{
private readonly IWinePidMapper winePidMapper = winePidMapper;
/// <inheritdoc />
public int ResolveSystemPid(int reportedPid, string executableName)
public int ResolveSystemPid(int reportedPid, string executableName, int? port = null)
{
// On Linux, the reported PID is a Wine PID. Convert to Linux system PID.
if (port is not null && TryGetProcessIdForTcpListener(port.Value, executableName) is { } listenerPid)
{
return listenerPid;
}
// Fallback: the reported PID is a Wine PID. Convert to Linux system PID by name.
var linuxPid = this.winePidMapper.WinePidToLinuxPid(reportedPid, executableName);
return linuxPid ?? reportedPid;
}
/// <summary>
/// Resolves the Linux PID that owns the listening socket on <paramref name="port"/>.
/// Under Wine the listening socket fd is shared between the game process and the
/// shared <c>wineserver</c> process, so candidate processes are filtered by
/// <paramref name="executableName"/> to select the game process and exclude wineserver.
/// </summary>
private static int? TryGetProcessIdForTcpListener(int port, string executableName)
{
try
{
var socketInodes = GetTcpListenerSocketInodes("/proc/net/tcp", port)
.Concat(GetTcpListenerSocketInodes("/proc/net/tcp6", port))
.ToHashSet(StringComparer.Ordinal);
if (socketInodes.Count == 0)
{
return null;
}
foreach (var procDir in Directory.EnumerateDirectories("/proc"))
{
if (!int.TryParse(Path.GetFileName(procDir), out var pid))
{
continue;
}
// Only consider processes whose command line references the target
// executable. This excludes wineserver (which shares the socket fd)
// and limits the fd scan to the handful of Guild Wars processes.
if (!ProcessReferencesExecutable(procDir, executableName))
{
continue;
}
if (ProcessOwnsSocket(procDir, socketInodes))
{
return pid;
}
}
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
return null;
}
private static bool ProcessReferencesExecutable(string procDir, string executableName)
{
try
{
var cmdline = File.ReadAllText(Path.Combine(procDir, "cmdline"));
return cmdline.Contains(executableName, StringComparison.OrdinalIgnoreCase);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
}
private static bool ProcessOwnsSocket(string procDir, HashSet<string> socketInodes)
{
var fdDir = Path.Combine(procDir, "fd");
if (!Directory.Exists(fdDir))
{
return false;
}
try
{
foreach (var fd in Directory.EnumerateFiles(fdDir))
{
string? linkTarget;
try
{
linkTarget = new FileInfo(fd).LinkTarget;
}
catch (IOException)
{
continue;
}
catch (UnauthorizedAccessException)
{
continue;
}
if (linkTarget is not null &&
TryParseSocketInode(linkTarget, out var inode) &&
socketInodes.Contains(inode))
{
return true;
}
}
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
return false;
}
private static IEnumerable<string> GetTcpListenerSocketInodes(string procNetPath, int port)
{
if (!File.Exists(procNetPath))
{
yield break;
}
foreach (var line in File.ReadLines(procNetPath).Skip(1))
{
var columns = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (columns.Length < 10 ||
!IsListeningSocket(columns[3]) ||
!TryParsePort(columns[1], out var socketPort) ||
socketPort != port)
{
continue;
}
yield return columns[9];
}
}
private static bool TryParsePort(string localAddress, out int port)
{
port = default;
var separatorIndex = localAddress.LastIndexOf(':');
if (separatorIndex < 0 ||
separatorIndex == localAddress.Length - 1 ||
!int.TryParse(localAddress[(separatorIndex + 1)..], NumberStyles.HexNumber, null, out port))
{
return false;
}
return true;
}
private static bool IsListeningSocket(string state) => state.Equals("0A", StringComparison.OrdinalIgnoreCase);
private static bool TryParseSocketInode(string linkTarget, out string inode)
{
inode = string.Empty;
const string socketPrefix = "socket:[";
if (!linkTarget.StartsWith(socketPrefix, StringComparison.Ordinal) || !linkTarget.EndsWith(']'))
{
return false;
}
inode = linkTarget[socketPrefix.Length..^1];
return inode.Length > 0;
}
}
@@ -52,6 +52,19 @@ public sealed class GuildWarsProcessFinder(
return this.FindProcesses(configuration).FirstOrDefault();
}
/// <summary>
/// Terminates the Wine-hosted Guild Wars process tree.
/// The process was already validated as a Gw.exe Wine process during discovery,
/// so we kill it directly. On Linux the process exposed by the context is the
/// Wine loader hosting Gw.exe; its <see cref="Process.MainModule"/> therefore
/// points at the Wine loader rather than Gw.exe, which is why platform-agnostic
/// name checks cannot be used here.
/// </summary>
public void KillProcess(GuildWarsApplicationLaunchContext guildWarsApplicationLaunchContext)
{
guildWarsApplicationLaunchContext.GuildWarsProcess.Kill(entireProcessTree: true);
}
public IEnumerable<GuildWarsApplicationLaunchContext?> FindProcesses(
params LaunchConfigurationWithCredentials[] configurations
)
@@ -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);
}
}
@@ -18,6 +18,7 @@ public class DaybreakInjector(
) : IDaybreakInjector
{
private const string InjectorRelativePath = "Injector/Daybreak.Injector.exe";
private const string GuildWarsExecutableName = "Gw.exe";
private readonly ILogger<DaybreakInjector> logger = logger;
private readonly IWinePrefixManager winePrefixManager = winePrefixManager;
@@ -57,8 +58,9 @@ public class DaybreakInjector(
return InjectorResponses.InjectResult.InvalidInjector;
}
// Convert Linux PID to Wine PID for the injector
var winePid = this.winePidMapper.LinuxPidToWinePid(processId);
// Resolve the Wine PID by matching the process's full image path (handles
// multiple concurrent Guild Wars instances).
var winePid = await this.ResolveWinePid(processId, cancellationToken);
if (winePid is null)
{
scopedLogger.LogError("No Wine PID mapping found for Linux PID {ProcessId}", processId);
@@ -99,8 +101,9 @@ public class DaybreakInjector(
return InjectorResponses.InjectResult.InvalidInjector;
}
// Convert Linux PID to Wine PID for the injector
var winePid = this.winePidMapper.LinuxPidToWinePid(processId);
// Resolve the Wine PID by matching the process's full image path (handles
// multiple concurrent Guild Wars instances).
var winePid = await this.ResolveWinePid(processId, cancellationToken);
if (winePid is null)
{
scopedLogger.LogError("No Wine PID mapping found for Linux PID {ProcessId}", processId);
@@ -193,8 +196,9 @@ public class DaybreakInjector(
// Convert Wine PID to Linux PID so the rest of Daybreak can use Process.GetProcessById()
if (processId > 0)
{
var executableName = Path.GetFileName(executablePath);
var linuxPid = this.winePidMapper.WinePidToLinuxPid(processId, executableName);
// Match on the full executable path so concurrent Guild Wars instances
// (different install directories) are not confused for one another.
var linuxPid = this.winePidMapper.WinePidToLinuxPid(processId, executablePath);
if (linuxPid is not null)
{
scopedLogger.LogInformation(
@@ -289,4 +293,88 @@ public class DaybreakInjector(
return defaultExitCode;
}
/// <summary>
/// Resolves the Wine PID for a Linux Guild Wars process by asking the injector (which runs
/// inside Wine) to match on the process's full image path. This disambiguates multiple
/// concurrent Guild Wars instances, which a name-only lookup cannot.
/// </summary>
private async Task<int?> ResolveWinePid(int linuxPid, CancellationToken cancellationToken)
{
var scopedLogger = this.logger.CreateScopedLogger();
var wineExecutablePath = TryGetWineExecutablePath(linuxPid);
if (wineExecutablePath is null)
{
scopedLogger.LogError("Could not read Wine executable path for Linux PID {ProcessId}", linuxPid);
return null;
}
var (output, _, _) = await this.LaunchInjector(
["resolve", $"\"{wineExecutablePath}\""],
cancellationToken,
completionChecker: (line, _) => line.StartsWith("ExitCode: ")
);
var winePid = ParseLabeledIntFromOutput(output, "ProcessId: ");
if (winePid is null)
{
scopedLogger.LogWarning(
"Injector could not resolve Wine PID for {ExecutablePath} (Linux PID {ProcessId})",
wineExecutablePath,
linuxPid
);
return null;
}
scopedLogger.LogDebug(
"Resolved Linux PID {ProcessId} -> Wine PID {WinePid} via {ExecutablePath}",
linuxPid,
winePid.Value,
wineExecutablePath
);
return winePid;
}
/// <summary>
/// Reads the Wine-form executable path (e.g. "Z:\home\...\Gw.exe") from the process's
/// command line, used to uniquely identify it among concurrent Guild Wars instances.
/// </summary>
private static string? TryGetWineExecutablePath(int linuxPid)
{
try
{
var cmdline = File.ReadAllText($"/proc/{linuxPid}/cmdline");
var segments = cmdline.Split('\0', StringSplitOptions.RemoveEmptyEntries);
return segments.FirstOrDefault(s =>
s.EndsWith(GuildWarsExecutableName, StringComparison.OrdinalIgnoreCase));
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
}
private static int? ParseLabeledIntFromOutput(string? output, string label)
{
if (output is null)
{
return null;
}
foreach (var line in output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries))
{
if (line.StartsWith(label) &&
int.TryParse(line[label.Length..], out var value))
{
return value;
}
}
return null;
}
}
@@ -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);
}
+11 -16
View File
@@ -1,27 +1,22 @@
namespace Daybreak.Linux.Services.Wine;
/// <summary>
/// Stateless translator between Wine-internal PIDs and Linux system PIDs.
/// Uses /proc scanning and winedbg to resolve mappings on demand.
/// Translates Wine-internal PIDs to Linux system PIDs by scanning /proc.
/// The reverse direction (Linux → Wine PID) is handled by the injector, which runs
/// inside Wine and can disambiguate concurrent instances by full image path.
/// </summary>
public interface IWinePidMapper
{
/// <summary>
/// Converts a Wine-internal PID to a Linux system PID.
/// Scans /proc for a process whose cmdline contains the executable name
/// and whose environment references our Wine prefix.
/// Converts a Wine-internal PID to a Linux system PID by scanning /proc.
/// When <paramref name="executable"/> is a full path it is matched against each
/// candidate's full executable path, which uniquely identifies the process even
/// when multiple Guild Wars instances (different install directories) run at once.
/// When only a file name is supplied, the first matching process is returned.
/// </summary>
/// <param name="winePid">The Wine-internal process ID (unused for lookup, kept for logging).</param>
/// <param name="executableName">The executable name (e.g. "Gw.exe") to search for in /proc.</param>
/// <param name="executable">The executable to match: a full Linux path (preferred, for
/// unambiguous matching) or a bare file name (e.g. "Gw.exe") as a best-effort fallback.</param>
/// <returns>The Linux PID, or null if not found.</returns>
int? WinePidToLinuxPid(int winePid, string executableName);
/// <summary>
/// Converts a Linux system PID back to a Wine-internal PID.
/// Reads the process's cmdline to determine the executable, then queries
/// winedbg to find the corresponding Wine PID.
/// </summary>
/// <param name="linuxPid">The Linux system PID.</param>
/// <returns>The Wine PID, or null if not found.</returns>
int? LinuxPidToWinePid(int linuxPid);
int? WinePidToLinuxPid(int winePid, string executable);
}
@@ -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.
}
}
}
+43 -104
View File
@@ -1,24 +1,25 @@
using System.Diagnostics;
using System.Globalization;
using Microsoft.Extensions.Logging;
namespace Daybreak.Linux.Services.Wine;
/// <summary>
/// Stateless translator between Wine-internal PIDs and Linux system PIDs.
/// Uses /proc scanning for Wine→Linux and winedbg for Linux→Wine.
/// Translates Wine-internal PIDs to Linux system PIDs by scanning /proc and matching the
/// full executable path. The reverse direction (Linux → Wine PID) lives in the injector,
/// which runs inside Wine and can disambiguate concurrent instances by image path.
/// </summary>
public sealed class WinePidMapper(
IWinePrefixManager winePrefixManager,
ILogger<WinePidMapper> logger
) : IWinePidMapper
{
private readonly IWinePrefixManager winePrefixManager = winePrefixManager;
private readonly ILogger<WinePidMapper> logger = logger;
/// <inheritdoc />
public int? WinePidToLinuxPid(int winePid, string executableName)
public int? WinePidToLinuxPid(int winePid, string executable)
{
var targetFileName = Path.GetFileName(executable);
var matchByFullPath = executable.Contains('/') || executable.Contains('\\');
var targetFullPath = matchByFullPath ? TryGetFullPath(executable) : null;
try
{
foreach (var procDir in Directory.EnumerateDirectories("/proc"))
@@ -38,14 +39,35 @@ public sealed class WinePidMapper(
}
var cmdline = File.ReadAllText(cmdlinePath);
if (!cmdline.Contains(executableName, StringComparison.OrdinalIgnoreCase))
if (cmdline.Length == 0)
{
continue;
}
// cmdline is null-separated; find the segment pointing at the executable.
var segments = cmdline.Split('\0', StringSplitOptions.RemoveEmptyEntries);
var exeSegment = segments.FirstOrDefault(s =>
s.EndsWith(targetFileName, StringComparison.OrdinalIgnoreCase));
if (exeSegment is null)
{
continue;
}
// When a full path was supplied, require an exact path match so that
// concurrent Guild Wars instances in different directories are not confused.
if (matchByFullPath)
{
var candidateFullPath = TryGetFullPath(WinePathToLinuxPath(exeSegment));
if (candidateFullPath is null ||
!string.Equals(candidateFullPath, targetFullPath, StringComparison.OrdinalIgnoreCase))
{
continue;
}
}
this.logger.LogDebug(
"Resolved Wine PID {WinePid} -> Linux PID {LinuxPid} for {ExeName}",
winePid, pid, executableName
"Resolved Wine PID {WinePid} -> Linux PID {LinuxPid} for {Executable}",
winePid, pid, executable
);
return pid;
@@ -56,119 +78,36 @@ public sealed class WinePidMapper(
}
catch (Exception ex)
{
this.logger.LogError(ex, "Error scanning /proc for Wine process {ExeName}", executableName);
this.logger.LogError(ex, "Error scanning /proc for Wine process {Executable}", executable);
}
this.logger.LogWarning("Could not find Linux PID for Wine PID {WinePid} ({ExeName})", winePid, executableName);
this.logger.LogWarning("Could not find Linux PID for Wine PID {WinePid} ({Executable})", winePid, executable);
return null;
}
/// <inheritdoc />
public int? LinuxPidToWinePid(int linuxPid)
private static string? TryGetFullPath(string path)
{
// Step 1: Read the executable name from /proc/<pid>/cmdline
string? executableName;
try
{
var cmdline = File.ReadAllText($"/proc/{linuxPid}/cmdline");
// cmdline is null-separated; find the segment containing .exe
var segments = cmdline.Split('\0', StringSplitOptions.RemoveEmptyEntries);
var exeSegment = segments.FirstOrDefault(s => s.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
?? segments.FirstOrDefault(s => s.Contains(".exe", StringComparison.OrdinalIgnoreCase));
if (exeSegment is null)
{
this.logger.LogWarning("Could not determine executable name for Linux PID {LinuxPid}", linuxPid);
return null;
}
// Extract just the filename (e.g. "Gw.exe" from "Z:\mnt\...\Gw.exe")
executableName = exeSegment.Split('\\').Last().Split('/').Last();
return Path.GetFullPath(path);
}
catch (Exception ex)
catch (Exception)
{
this.logger.LogWarning(ex, "Could not read cmdline for Linux PID {LinuxPid}", linuxPid);
return null;
}
// Step 2: Query winedbg for the Wine PID of that executable
return this.QueryWineDbgForPid(executableName);
}
/// <summary>
/// Runs <c>winedbg --command "info proc"</c> in our prefix and parses the output
/// to find the Wine PID for the given executable name.
/// Output format: " 0000021c 1 'Gw.exe'"
/// Converts a Wine Z: drive path back to a Linux path.
/// "Z:\home\user\Guild Wars\Gw.exe" -> "/home/user/Guild Wars/Gw.exe".
/// </summary>
private int? QueryWineDbgForPid(string executableName)
private static string WinePathToLinuxPath(string winePath)
{
var prefixPath = this.winePrefixManager.GetWinePrefixPath();
try
if (winePath.StartsWith("Z:", StringComparison.OrdinalIgnoreCase))
{
var startInfo = new ProcessStartInfo
{
FileName = "winedbg",
Arguments = "--command \"info proc\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
startInfo.Environment["WINEPREFIX"] = prefixPath;
using var process = new Process { StartInfo = startInfo };
process.Start();
var output = process.StandardOutput.ReadToEnd();
process.WaitForExit(TimeSpan.FromSeconds(5));
// Parse lines like:
// 0000021c 1 'Gw.exe'
// 00000038 12 \_ 'services.exe'
foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = line.Trim();
// Skip header line
if (trimmed.StartsWith("pid", StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Check if this line contains our executable
if (!trimmed.Contains(executableName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Strip tree prefixes like "\_ " or "= "
var cleaned = trimmed.TrimStart('=', ' ');
if (cleaned.StartsWith("\\_"))
{
cleaned = cleaned[2..].TrimStart();
}
// First token is the hex PID
var hexPid = cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
if (hexPid is not null && int.TryParse(hexPid, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var winePid))
{
this.logger.LogDebug(
"Resolved Linux PID -> Wine PID {WinePid} (0x{WinePidHex}) for {ExeName}",
winePid, hexPid, executableName
);
return winePid;
}
}
}
catch (Exception ex)
{
this.logger.LogError(ex, "Failed to query winedbg for {ExeName}", executableName);
winePath = winePath[2..];
}
this.logger.LogWarning("Could not find Wine PID for {ExeName} via winedbg", executableName);
return null;
return winePath.Replace('\\', '/');
}
}
@@ -19,6 +19,11 @@ public sealed class WinePrefixManager(
{
private const string WinePrefixFolder = "WinePrefix";
private const string WineExecutable = "wine";
private const string WineNetworkRegistryKey = @"HKCU\Software\Wine\Network";
private const string ComputerNameRegistryKey =
@"HKLM\System\CurrentControlSet\Control\ComputerName\ComputerName";
private const string ActiveComputerNameRegistryKey =
@"HKLM\System\CurrentControlSet\Control\ComputerName\ActiveComputerName";
private static readonly ProgressUpdate ProgressStarting = new(0, "Starting Wine prefix setup");
private static readonly ProgressUpdate ProgressCheckingWine = new(
@@ -117,7 +122,10 @@ public sealed class WinePrefixManager(
public IEnumerable<string> GetCustomArguments() => [];
/// <inheritdoc />
public Task OnGuildWarsStarting(GuildWarsStartingContext guildWarsStartingContext, CancellationToken cancellationToken)
public async Task OnGuildWarsStarting(
GuildWarsStartingContext guildWarsStartingContext,
CancellationToken cancellationToken
)
{
if (!this.IsAvailable())
{
@@ -127,7 +135,7 @@ public sealed class WinePrefixManager(
title: "Wine not installed",
description: "Wine is required to launch Guild Wars on Linux. Please install Wine and restart Daybreak.",
expirationTime: DateTime.UtcNow + TimeSpan.FromSeconds(15));
return Task.CompletedTask;
return;
}
if (!this.IsInitialized())
@@ -138,10 +146,23 @@ public sealed class WinePrefixManager(
title: "Wine prefix not initialized",
description: "The Wine prefix needs to be set up before launching Guild Wars. Click here to initialize it.",
expirationTime: DateTime.UtcNow + TimeSpan.FromSeconds(15));
return Task.CompletedTask;
return;
}
return Task.CompletedTask;
if (await this.ConfigureComputerName(cancellationToken))
{
return;
}
this.logger.LogError(
"Failed to configure the Linux host name as the Wine computer name. Blocking Guild Wars startup"
);
guildWarsStartingContext.CancelStartup = true;
this.notificationService.NotifyError(
title: "Wine prefix configuration failed",
description: "Daybreak could not configure the Linux host name as the Wine computer name. Check the logs for details.",
expirationTime: DateTime.UtcNow + TimeSpan.FromSeconds(15)
);
}
/// <inheritdoc />
@@ -243,6 +264,59 @@ public sealed class WinePrefixManager(
return this.winePrefixPath;
}
public async Task<bool> ConfigureComputerName(CancellationToken cancellationToken)
{
if (!this.IsInitialized())
{
this.logger.LogWarning(
"Cannot configure computer name before the Wine prefix is initialized"
);
return false;
}
var computerName = ComputerNameUtils.SanitizeComputerName(Environment.MachineName);
var useDnsComputerNameConfigured = await this.AddRegistryValue(
WineNetworkRegistryKey,
"UseDnsComputerName",
"N",
"REG_SZ",
cancellationToken
);
var computerNameConfigured = await this.AddRegistryValue(
ComputerNameRegistryKey,
"ComputerName",
computerName,
"REG_SZ",
cancellationToken
);
var activeComputerNameConfigured = await this.AddRegistryValue(
ActiveComputerNameRegistryKey,
"ComputerName",
computerName,
"REG_SZ",
cancellationToken
);
if (
!useDnsComputerNameConfigured
|| !computerNameConfigured
|| !activeComputerNameConfigured
)
{
this.logger.LogError(
"Failed to configure Wine computer name {ComputerName} from the Linux host name",
computerName
);
return false;
}
this.logger.LogInformation(
"Configured Wine computer name {ComputerName} from the Linux host name",
computerName
);
return true;
}
public IProgressAsyncOperation<bool> Install(CancellationToken cancellationToken)
{
return ProgressAsyncOperation.Create(
@@ -280,6 +354,13 @@ public sealed class WinePrefixManager(
"Wine prefix already initialized at {PrefixPath}",
this.winePrefixPath
);
if (!await this.ConfigureComputerName(cancellationToken))
{
progress.Report(ProgressFailed);
return false;
}
progress.Report(ProgressAlreadyInitialized);
return true;
}
@@ -333,6 +414,13 @@ public sealed class WinePrefixManager(
this.logger.LogWarning("Failed to set d3d9 DLL override, but continuing...");
}
progress.Report(new ProgressUpdate(0.9, "Configuring computer name"));
if (!await this.ConfigureComputerName(cancellationToken))
{
progress.Report(ProgressFailed);
return false;
}
this.logger.LogInformation("Wine prefix initialized successfully");
progress.Report(ProgressFinished);
return true;
@@ -390,6 +478,15 @@ public sealed class WinePrefixManager(
startInfo.Environment["WINEPREFIX"] = this.winePrefixPath;
// Guild Wars outlives this call, so its crash backtrace must go somewhere
// durable rather than into the stderr pipe we stop reading below.
if (WineDebugLog.IsEnabled)
{
WineDebugLog.WriteSessionHeader($"{wineExePath} {wineArgs}");
WineDebugLog.Apply(startInfo);
this.logger.LogDebug("Wine diagnostics are being appended to {WineDebugLog}", WineDebugLog.LogPath);
}
try
{
using var process = new Process { StartInfo = startInfo };
+2 -2
View File
@@ -7,8 +7,8 @@ namespace Daybreak.Shared.Models.Guildwars;
/// Definition of a Guild Wars skill. The static skill collections (per-campaign
/// lists, <see cref="AllSkills"/>, individual <c>public static readonly Skill X</c>
/// fields) are generated by <c>Tools/SkillUpdater</c> into <c>Skill.g.cs</c> from
/// the official wiki. Re-run the tool with
/// <c>dotnet run --project Tools/SkillUpdater</c> to refresh.
/// the GWToolbox API, with names and icons from the official wiki. Re-run the
/// tool with <c>dotnet run --project Tools/SkillUpdater</c> to refresh.
/// </summary>
public sealed partial class Skill : IIconUrlEntity
{
File diff suppressed because it is too large Load Diff
@@ -52,4 +52,12 @@ public static class InjectorResponses
InvalidInjector = -3,
InvalidThreadHandle = -400,
}
public enum ResolveResult
{
Success = 0,
InvalidArgs = -1,
InvalidInjector = -3,
ProcessNotFound = -501,
}
}
@@ -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 &&
@@ -11,7 +11,7 @@ public class Notification : ICancellableNotification
public string Description { get; init; } = string.Empty;
public string Metadata { get; init; } = string.Empty;
public DateTime ExpirationTime { get; init; }
public DateTime CreationTime { get; init; } = DateTime.Now;
public DateTime CreationTime { get; init; } = DateTime.UtcNow;
public bool Dismissible { get; init; }
public virtual Type? HandlingType { get; init; }
+2 -1
View File
@@ -12,6 +12,7 @@ public interface IPidProvider
/// </summary>
/// <param name="reportedPid">The process ID reported by the API (may be a Wine PID on Linux).</param>
/// <param name="executableName">The executable name to search for (e.g., "Gw.exe").</param>
/// <param name="port">The local TCP port owned by the process, when available.</param>
/// <returns>The system process ID, or the original PID if conversion fails or is not needed.</returns>
int ResolveSystemPid(int reportedPid, string executableName);
int ResolveSystemPid(int reportedPid, string executableName, int? port = null);
}
@@ -131,6 +131,16 @@ public sealed class ScopedApiContext(
scopedLogger.LogError("Failed to post data to {path}: {statusCode} {reasonPhrase}", path, response.StatusCode, response.ReasonPhrase ?? string.Empty);
return false;
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
scopedLogger.LogDebug("Request to {path} timed out after {timeout}", path, timeout);
return false;
}
catch (OperationCanceledException)
{
scopedLogger.LogDebug("Request to {path} was canceled", path);
return false;
}
catch (Exception ex)
{
scopedLogger.LogError(ex, "Failed to execute api request");
@@ -170,6 +180,16 @@ public sealed class ScopedApiContext(
using var response = await this.httpClient.GetAsync(uri, compositeCts.Token);
return await responseBuilder(response);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
scopedLogger.LogDebug("Request to {path} timed out after {timeout}", path, timeout);
return default;
}
catch (OperationCanceledException)
{
scopedLogger.LogDebug("Request to {path} was canceled", path);
return default;
}
catch (Exception ex)
{
scopedLogger.LogError(ex, "Failed to execute api request");
@@ -30,4 +30,12 @@ public interface IGuildWarsProcessFinder
IEnumerable<GuildWarsApplicationLaunchContext?> FindProcesses(
params LaunchConfigurationWithCredentials[] configurations
);
/// <summary>
/// Terminates the Guild Wars process associated with the given launch context.
/// Implementations are responsible for handling platform-specific process layouts
/// (e.g. Wine-hosted processes on Linux). May throw, allowing the caller to handle
/// platform-specific failures such as insufficient privileges.
/// </summary>
void KillProcess(GuildWarsApplicationLaunchContext guildWarsApplicationLaunchContext);
}
@@ -0,0 +1,19 @@
namespace Daybreak.Shared.Services.ApplicationLauncher;
/// <summary>
/// Platform-specific service for interacting with the Steam client.
/// Used to determine whether Steam is available before launching Guild Wars with Steam login.
/// </summary>
public interface ISteamService
{
/// <summary>
/// Returns true if Steam login is supported on the current platform.
/// Steam login is currently only supported on Windows.
/// </summary>
bool IsSteamLoginSupported { get; }
/// <summary>
/// Returns true if the Steam client is currently running.
/// </summary>
bool IsSteamRunning();
}
@@ -0,0 +1,21 @@
namespace Daybreak.Shared.Services.Shell;
/// <summary>
/// Opens URLs and file-system paths using the operating system's default handler.
/// Provides a cross-platform abstraction over platform-specific shell invocations
/// (e.g. <c>explorer.exe</c> on Windows, <c>xdg-open</c> on Linux).
/// </summary>
public interface IShellExecutor
{
/// <summary>
/// Opens the given URL using the default web browser.
/// </summary>
/// <param name="url">The absolute URL to open.</param>
void OpenUrl(string url);
/// <summary>
/// Opens the given file or folder using the default file manager or associated program.
/// </summary>
/// <param name="path">The file-system path to open.</param>
void OpenPath(string path);
}
+109
View File
@@ -0,0 +1,109 @@
namespace Daybreak.Shared.Utils;
/// <summary>
/// Helpers for working with Windows style command line strings.
/// </summary>
public static class CommandLineUtils
{
/// <summary>
/// Splits a Windows style command line into the argument vector it represents.
/// </summary>
/// <remarks>
/// This mirrors the parser .NET applies to <see cref="System.Diagnostics.ProcessStartInfo.Arguments"/>
/// when starting a process on Unix. Anything that needs to hand those arguments to another launcher
/// must produce the same vector, otherwise the process observes a different command line depending on
/// how it was started.
/// </remarks>
/// <param name="commandLine">The command line to split. May be null or empty.</param>
/// <returns>The parsed arguments, with quoting and backslash escapes resolved.</returns>
public static List<string> SplitCommandLine(string? commandLine)
{
var results = new List<string>();
if (string.IsNullOrEmpty(commandLine))
{
return results;
}
var i = 0;
while (i < commandLine.Length)
{
while (i < commandLine.Length && (commandLine[i] is ' ' or '\t'))
{
i++;
}
if (i == commandLine.Length)
{
break;
}
results.Add(ReadArgument(commandLine, ref i));
}
return results;
}
private static string ReadArgument(string commandLine, ref int i)
{
var argument = new System.Text.StringBuilder();
var inQuotes = false;
while (i < commandLine.Length)
{
var backslashCount = 0;
while (i < commandLine.Length && commandLine[i] is '\\')
{
i++;
backslashCount++;
}
if (backslashCount > 0)
{
if (i >= commandLine.Length || commandLine[i] is not '"')
{
argument.Append('\\', backslashCount);
}
else
{
// Every pair of backslashes produces one literal backslash. A remaining
// backslash escapes the quote that follows it.
argument.Append('\\', backslashCount / 2);
if (backslashCount % 2 != 0)
{
argument.Append('"');
i++;
}
}
continue;
}
var c = commandLine[i];
if (c is '"')
{
if (inQuotes && i + 1 < commandLine.Length && commandLine[i + 1] is '"')
{
argument.Append('"');
i++;
}
else
{
inQuotes = !inQuotes;
}
i++;
continue;
}
if (!inQuotes && c is ' ' or '\t')
{
break;
}
argument.Append(c);
i++;
}
return argument.ToString();
}
}
@@ -0,0 +1,66 @@
using System.Text;
namespace Daybreak.Shared.Utils;
/// <summary>
/// Helpers for producing Windows compatible computer names.
/// </summary>
public static class ComputerNameUtils
{
/// <summary>
/// Maximum length of a Windows NetBIOS computer name, matching the Win32 MAX_COMPUTERNAME_LENGTH constant.
/// </summary>
public const int MaxComputerNameLength = 15;
/// <summary>
/// Name used when no valid computer name can be derived from the host.
/// </summary>
public const string FallbackComputerName = "DAYBREAK";
/// <summary>
/// Converts an arbitrary host name into a name that Win32 GetComputerName can return.
/// </summary>
/// <remarks>
/// GetComputerNameW is documented to fill a buffer of MAX_COMPUTERNAME_LENGTH + 1 characters, so callers
/// size their buffers accordingly. When the configured name is longer, the call fails with
/// ERROR_BUFFER_OVERFLOW and leaves the caller's buffer untouched, which applications such as GWToolbox
/// observe as an empty computer name. Wine's own wineboot upper-cases and truncates the Linux host name
/// for the same reason, so this method reproduces that behaviour.
/// </remarks>
/// <param name="name">The host name to sanitize.</param>
/// <returns>An upper-case, at most <see cref="MaxComputerNameLength"/> characters long, non-empty computer name.</returns>
public static string SanitizeComputerName(string? name)
{
if (name is null)
{
return FallbackComputerName;
}
var builder = new StringBuilder(MaxComputerNameLength);
foreach (var c in name)
{
if (builder.Length >= MaxComputerNameLength)
{
break;
}
if (char.IsAsciiLetterOrDigit(c))
{
builder.Append(char.ToUpperInvariant(c));
}
else if (c is '-' or '_')
{
builder.Append(c);
}
}
// A computer name may not start or end with a separator.
while (builder.Length > 0 && builder[^1] is '-' or '_')
{
builder.Length--;
}
var sanitized = builder.ToString().TrimStart('-', '_');
return sanitized.Length is 0 ? FallbackComputerName : sanitized;
}
}
@@ -128,4 +128,25 @@ public sealed class CredentialManagerTests
a.Username.Should().BeEmpty();
a.Password.Should().BeEmpty();
}
[TestMethod]
public void TryGetCredentialsByIdentifier_SteamLoginSentinel_ReturnsVirtualCredential()
{
var ok = this.manager.TryGetCredentialsByIdentifier(LoginCredentials.SteamLoginIdentifier, out var found);
ok.Should().BeTrue();
found.Should().BeSameAs(LoginCredentials.SteamLogin);
found!.IsSteamLogin.Should().BeTrue();
}
[TestMethod]
public void StoreCredentials_DoesNotPersistSteamLoginSentinel()
{
var real = new LoginCredentials { Identifier = "id-1", Username = "alice", Password = "pw" };
this.manager.StoreCredentials([LoginCredentials.SteamLogin, real]);
this.options.ProtectedLoginCredentials.Should().ContainSingle()
.Which.Identifier.Should().Be("id-1");
}
}
@@ -0,0 +1,214 @@
using Daybreak.Configuration.Options;
using Daybreak.Services.ExecutableManagement;
using Daybreak.Shared.Services.Options;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
namespace Daybreak.Tests.Services.ExecutableManagement;
[TestClass]
public sealed class GuildWarsExecutableManagerTests
{
private readonly IOptionsMonitor<GuildwarsExecutableOptions> liveOptions = Substitute.For<IOptionsMonitor<GuildwarsExecutableOptions>>();
private readonly IOptionsProvider optionsProvider = Substitute.For<IOptionsProvider>();
private readonly GuildwarsExecutableOptions options = new();
private readonly GuildWarsExecutableManager manager;
private string tempDir = string.Empty;
public GuildWarsExecutableManagerTests()
{
this.liveOptions.CurrentValue.Returns(this.options);
this.manager = new GuildWarsExecutableManager(
this.optionsProvider, this.liveOptions, NullLogger<GuildWarsExecutableManager>.Instance);
}
[TestInitialize]
public void Setup()
{
this.tempDir = Path.Combine(Path.GetTempPath(), "db-exe-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(this.tempDir);
}
[TestCleanup]
public void Cleanup()
{
try
{
if (Directory.Exists(this.tempDir))
{
Directory.Delete(this.tempDir, recursive: true);
}
}
catch
{
// Best effort cleanup.
}
}
private string CreateValidExecutable(string name)
{
var path = Path.Combine(this.tempDir, name);
File.WriteAllText(path, "stub");
return path;
}
// A path under a directory that does not exist - mirrors a file on an unmounted/removed
// volume or a genuinely deleted executable (indistinguishable by path alone).
private static string MissingExecutable(string name)
=> Path.Combine(Path.GetTempPath(), "db-missing-" + Guid.NewGuid().ToString("N"), name);
[TestMethod]
public void AddExecutable_InsertsNewPathAtFront()
{
var existing = this.CreateValidExecutable("Gw1.exe");
var added = this.CreateValidExecutable("Gw2.exe");
this.options.ExecutablePaths.Add(existing);
this.manager.AddExecutable(added);
this.options.ExecutablePaths.Should().Equal(added, existing);
this.optionsProvider.Received().SaveOption(this.options);
}
[TestMethod]
public void AddExecutable_DoesNotInsertDuplicate()
{
var exe = this.CreateValidExecutable("Gw.exe");
this.options.ExecutablePaths.Add(exe);
this.manager.AddExecutable(exe);
this.options.ExecutablePaths.Should().ContainSingle().Which.Should().Be(exe);
}
[TestMethod]
public void AddExecutable_UnderCapacity_DoesNotEvictInvalidEntries()
{
// An invalid entry (e.g. an unmounted drive) must survive while under the cap.
var unavailable = MissingExecutable("Gw.exe");
this.options.ExecutablePaths.Add(unavailable);
var added = this.CreateValidExecutable("Gw2.exe");
this.manager.AddExecutable(added);
this.options.ExecutablePaths.Should().Contain(unavailable);
this.options.ExecutablePaths.Should().Contain(added);
}
[TestMethod]
public void AddExecutable_OverCapacity_EvictsInvalidAndKeepsValid()
{
// (cap - 1) valid + 1 invalid == cap stored entries, then add one more valid -> over cap by 1.
var valids = new List<string>();
for (var i = 0; i < GuildWarsExecutableManager.MaxExecutables - 1; i++)
{
valids.Add(this.CreateValidExecutable($"valid{i}.exe"));
}
var stale = MissingExecutable("stale.exe");
this.options.ExecutablePaths.AddRange(valids);
this.options.ExecutablePaths.Add(stale);
var added = this.CreateValidExecutable("new.exe");
this.manager.AddExecutable(added);
this.options.ExecutablePaths.Should().HaveCount(GuildWarsExecutableManager.MaxExecutables);
this.options.ExecutablePaths.Should().NotContain(stale);
this.options.ExecutablePaths.Should().Contain(added);
this.options.ExecutablePaths.Should().Contain(valids);
}
[TestMethod]
public void AddExecutable_OverCapacityAllValid_DoesNotEvictValidEntries()
{
var valids = new List<string>();
for (var i = 0; i < GuildWarsExecutableManager.MaxExecutables; i++)
{
valids.Add(this.CreateValidExecutable($"valid{i}.exe"));
}
this.options.ExecutablePaths.AddRange(valids);
var added = this.CreateValidExecutable("new.exe");
this.manager.AddExecutable(added);
// Valid executables are never evicted, even when that keeps the list above the cap.
this.options.ExecutablePaths.Should().HaveCount(GuildWarsExecutableManager.MaxExecutables + 1);
this.options.ExecutablePaths.Should().Contain(added);
this.options.ExecutablePaths.Should().Contain(valids);
}
[TestMethod]
public void AddExecutable_OverCapacity_EvictsOldestInvalidFirstAndOnlyAsNeeded()
{
// (cap - 2) valid + 2 invalid == cap, then add one more -> over by 1 -> evict only the oldest invalid.
var valids = new List<string>();
for (var i = 0; i < GuildWarsExecutableManager.MaxExecutables - 2; i++)
{
valids.Add(this.CreateValidExecutable($"valid{i}.exe"));
}
var newerStale = MissingExecutable("newer-stale.exe");
var olderStale = MissingExecutable("older-stale.exe");
this.options.ExecutablePaths.AddRange(valids);
this.options.ExecutablePaths.Add(newerStale);
this.options.ExecutablePaths.Add(olderStale); // appended last -> oldest (highest index)
var added = this.CreateValidExecutable("new.exe");
this.manager.AddExecutable(added);
this.options.ExecutablePaths.Should().HaveCount(GuildWarsExecutableManager.MaxExecutables);
this.options.ExecutablePaths.Should().NotContain(olderStale);
this.options.ExecutablePaths.Should().Contain(newerStale);
this.options.ExecutablePaths.Should().Contain(added);
}
[TestMethod]
public void GetExecutableList_ReturnsOnlyValidExecutables()
{
var valid = this.CreateValidExecutable("Gw.exe");
var missing = MissingExecutable("Gw.exe");
this.options.ExecutablePaths.Add(valid);
this.options.ExecutablePaths.Add(missing);
var list = this.manager.GetExecutableList().ToList();
list.Should().ContainSingle().Which.Should().Be(valid);
}
[TestMethod]
public void GetExecutableList_DoesNotRemoveInvalidEntriesFromStorage()
{
// Listing must be non-destructive: a temporarily unavailable executable stays persisted.
var missing = MissingExecutable("Gw.exe");
this.options.ExecutablePaths.Add(missing);
this.manager.GetExecutableList();
this.options.ExecutablePaths.Should().Contain(missing);
this.optionsProvider.DidNotReceive().SaveOption(Arg.Any<GuildwarsExecutableOptions>());
}
[TestMethod]
public void IsValidExecutable_TrueForExistingFile_FalseForMissingFile()
{
var valid = this.CreateValidExecutable("Gw.exe");
this.manager.IsValidExecutable(valid).Should().BeTrue();
this.manager.IsValidExecutable(MissingExecutable("Gw.exe")).Should().BeFalse();
}
[TestMethod]
public void RemoveExecutable_RemovesEntryAndSaves()
{
var exe = this.CreateValidExecutable("Gw.exe");
this.options.ExecutablePaths.Add(exe);
this.manager.RemoveExecutable(exe);
this.options.ExecutablePaths.Should().NotContain(exe);
this.optionsProvider.Received().SaveOption(this.options);
}
}
@@ -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,135 @@
using System.Diagnostics;
using Daybreak.Configuration.Options;
using Daybreak.Services.Toolbox;
using Daybreak.Services.Toolbox.Utilities;
using Daybreak.Shared.Exceptions;
using Daybreak.Shared.Models;
using Daybreak.Shared.Models.Mods;
using Daybreak.Shared.Services.BuildTemplates;
using Daybreak.Shared.Services.Injection;
using Daybreak.Shared.Services.Notifications;
using Daybreak.Shared.Services.Options;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
namespace Daybreak.Tests.Services.Toolbox;
/// <summary>
/// Guards the mod lifecycle phase in which GWToolbox is injected. Toolbox must inject during
/// <see cref="ToolboxService.OnGuildWarsCreated"/> (before Daybreak.API) and must NOT inject
/// during <see cref="ToolboxService.OnGuildWarsStarted"/>. This ordering is what lets Toolbox
/// load its bundled gwca.dll first so Daybreak.API reuses the same module.
/// </summary>
[TestClass]
public sealed class ToolboxServiceTests
{
private readonly IOptionsProvider optionsProvider = Substitute.For<IOptionsProvider>();
private readonly IBuildTemplateManager buildTemplateManager = Substitute.For<IBuildTemplateManager>();
private readonly INotificationService notificationService = Substitute.For<INotificationService>();
private readonly IProcessInjector processInjector = Substitute.For<IProcessInjector>();
private readonly IToolboxClient toolboxClient = Substitute.For<IToolboxClient>();
private readonly IOptionsMonitor<ToolboxOptions> toolboxOptions = Substitute.For<IOptionsMonitor<ToolboxOptions>>();
private readonly ToolboxOptions options = new();
private readonly Process process = new();
private readonly ToolboxService service;
public ToolboxServiceTests()
{
this.toolboxOptions.CurrentValue.Returns(this.options);
this.service = new ToolboxService(
this.optionsProvider,
this.buildTemplateManager,
this.notificationService,
this.processInjector,
this.toolboxClient,
this.toolboxOptions,
Substitute.For<ILogger<ToolboxService>>());
}
[TestCleanup]
public void Cleanup() => this.process.Dispose();
private GuildWarsCreatedContext CreatedContext() => new()
{
ApplicationLauncherContext = this.LauncherContext(),
};
private GuildWarsStartedContext StartedContext() => new()
{
ApplicationLauncherContext = this.LauncherContext(),
};
private ApplicationLauncherContext LauncherContext() => new()
{
ExecutablePath = "Gw.exe",
Process = this.process,
ProcessId = 1234,
};
[TestMethod]
public async Task OnGuildWarsStarted_WhenEnabled_DoesNotInjectToolbox()
{
this.options.Enabled = true;
var reachedLaunch = false;
this.processInjector
.Inject(Arg.Any<Process>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(_ => { reachedLaunch = true; return Task.FromResult(true); });
// OnGuildWarsStarted must be a pure no-op now that injection moved to OnGuildWarsCreated.
await this.service.OnGuildWarsStarted(this.StartedContext(), CancellationToken.None);
reachedLaunch.Should().BeFalse("Toolbox injection moved out of OnGuildWarsStarted");
await this.processInjector
.DidNotReceive()
.Inject(Arg.Any<Process>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[TestMethod]
public async Task OnGuildWarsStarted_WhenDisabled_DoesNotInjectToolbox()
{
this.options.Enabled = false;
await this.service.OnGuildWarsStarted(this.StartedContext(), CancellationToken.None);
await this.processInjector
.DidNotReceive()
.Inject(Arg.Any<Process>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[TestMethod]
public async Task OnGuildWarsCreated_WhenDisabled_DoesNotInjectToolbox()
{
this.options.Enabled = false;
await this.service.OnGuildWarsCreated(this.CreatedContext(), CancellationToken.None);
await this.processInjector
.DidNotReceive()
.Inject(Arg.Any<Process>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[TestMethod]
public async Task OnGuildWarsCreated_WhenEnabled_DrivesToolboxLaunch()
{
this.options.Enabled = true;
var reachedLaunch = false;
this.processInjector
.Inject(Arg.Any<Process>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(_ => { reachedLaunch = true; return Task.FromResult(true); });
try
{
await this.service.OnGuildWarsCreated(this.CreatedContext(), CancellationToken.None);
}
catch (ExecutableNotFoundException)
{
// Reached LaunchToolbox, but the GWToolbox dll is not installed in this environment.
// This still proves OnGuildWarsCreated routes into the toolbox launch path.
reachedLaunch = true;
}
reachedLaunch.Should().BeTrue("OnGuildWarsCreated must inject Toolbox before Daybreak.API");
}
}
@@ -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>();
+1 -1
View File
@@ -9,7 +9,7 @@ namespace Daybreak.Windows.Services.Api;
public sealed class PidProvider : IPidProvider
{
/// <inheritdoc />
public int ResolveSystemPid(int reportedPid, string executableName)
public int ResolveSystemPid(int reportedPid, string executableName, int? port = null)
{
// On Windows, the reported PID is the actual system PID
return reportedPid;
@@ -28,6 +28,24 @@ public sealed class GuildWarsProcessFinder : IGuildWarsProcessFinder
return this.FindProcesses(configuration).FirstOrDefault();
}
/// <summary>
/// Terminates the Guild Wars process tree. Guards against killing an unrelated
/// process by verifying the main module is Gw.exe. Reading the main module of an
/// elevated process throws a <see cref="Win32Exception"/>, which is propagated so
/// the caller can fall back to a direct kill or request elevation.
/// </summary>
public void KillProcess(GuildWarsApplicationLaunchContext guildWarsApplicationLaunchContext)
{
var process = guildWarsApplicationLaunchContext.GuildWarsProcess;
if (
process.MainModule?.FileName is not null
&& process.MainModule.FileName.Contains("Gw.exe", StringComparison.OrdinalIgnoreCase)
)
{
process.Kill(true);
}
}
public IEnumerable<GuildWarsApplicationLaunchContext?> FindProcesses(
params LaunchConfigurationWithCredentials[] configurations
)
@@ -0,0 +1,19 @@
using System.Diagnostics;
using Daybreak.Shared.Services.ApplicationLauncher;
namespace Daybreak.Windows.Services.ApplicationLauncher;
/// <summary>
/// Windows-specific Steam service. Detects the running Steam client by process name.
/// </summary>
public sealed class SteamService : ISteamService
{
private const string SteamProcessName = "steam";
public bool IsSteamLoginSupported => true;
public bool IsSteamRunning()
{
return Process.GetProcessesByName(SteamProcessName).Length > 0;
}
}
@@ -0,0 +1,51 @@
using Daybreak.Shared.Services.Shell;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
namespace Daybreak.Windows.Services.Shell;
/// <summary>
/// Windows implementation of <see cref="IShellExecutor"/>.
/// URLs are opened via shell execution (default browser); paths are opened via <c>explorer.exe</c>.
/// </summary>
internal sealed class ShellExecutor(
ILogger<ShellExecutor> logger) : IShellExecutor
{
private const string ExplorerExecutable = "explorer.exe";
private readonly ILogger<ShellExecutor> logger = logger;
public void OpenUrl(string url)
{
if (string.IsNullOrWhiteSpace(url))
{
return;
}
try
{
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
}
catch (Exception ex)
{
this.logger.LogError(ex, "Failed to open url {url}", url);
}
}
public void OpenPath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return;
}
try
{
Process.Start(ExplorerExecutable, path);
}
catch (Exception ex)
{
this.logger.LogError(ex, "Failed to open path {path}", path);
}
}
}
+23 -20
View File
@@ -279,6 +279,7 @@ namespace GW {
constexpr int Winnowing = 2926;
constexpr int EoE = 2927;
constexpr int Symbiosis = 2930;
constexpr int FrozenSoil = 2933;
constexpr int QZ = 2937;
@@ -454,6 +455,8 @@ namespace GW {
constexpr int LockedChest = 8192; // this is actually ->ExtraType
constexpr int VarnyTheZealot = 8258;
namespace Minipet {
constexpr int MiniatureConfessorDorian = 8344;
constexpr int MiniaturePrincessSalma = 8349;
@@ -467,26 +470,26 @@ namespace GW {
} // namespace Minipet
namespace SummoningStone {
constexpr int ImperialCripplingSlash = 9043;
constexpr int ImperialTripleChop = 9044;
constexpr int ImperialBarrage = 9045;
constexpr int ImperialQuiveringBlade = 9046;
constexpr int TenguHundredBlades = 9047;
constexpr int TenguBroadHeadArrow = 9048;
constexpr int TenguPalmStrike = 9049;
constexpr int TenguLifeSheath = 9050;
constexpr int TenguAngchuElementalist = 9051;
constexpr int TenguFeveredDreams = 9052;
constexpr int TenguSpitefulSpirit = 9053;
constexpr int TenguPreservation = 9054;
constexpr int TenguPrimalRage = 9055;
constexpr int TenguGlassArrows = 9056;
constexpr int TenguWayOftheAssassin = 9057;
constexpr int TenguPeaceandHarmony = 9058;
constexpr int TenguSandstorm = 9059;
constexpr int TenguPanic = 9060;
constexpr int TenguAuraOftheLich = 9061;
constexpr int TenguDefiantWasXinrae = 9062;
constexpr int ImperialCripplingSlash = 9268;
constexpr int ImperialTripleChop = 9269;
constexpr int ImperialBarrage = 9270;
constexpr int ImperialQuiveringBlade = 9271;
constexpr int TenguHundredBlades = 9272;
constexpr int TenguBroadHeadArrow = 9273;
constexpr int TenguPalmStrike = 9274;
constexpr int TenguLifeSheath = 9275;
constexpr int TenguAngchuElementalist = 9276;
constexpr int TenguFeveredDreams = 9277;
constexpr int TenguSpitefulSpirit = 9278;
constexpr int TenguPreservation = 9279;
constexpr int TenguPrimalRage = 9280;
constexpr int TenguGlassArrows = 9281;
constexpr int TenguWayOftheAssassin = 9282;
constexpr int TenguPeaceandHarmony = 9283;
constexpr int TenguSandstorm = 9284;
constexpr int TenguPanic = 9285;
constexpr int TenguAuraOftheLich = 9286;
constexpr int TenguDefiantWasXinrae = 9287;
} // namespace SummoningStone
} // namespace ModelID
} // namespace Constants

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