mirror of
https://github.com/gwdevhub/Daybreak.git
synced 2026-09-15 21:09:22 +00:00
Compare commits
42
Commits
v0.9.10.11
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe4720c5e1 | ||
|
|
b57d2ad5d3 | ||
|
|
5faeae64d3 | ||
|
|
fdcf238d68 | ||
|
|
d9f7d05672 | ||
|
|
c61ee2ecd7 | ||
|
|
82512f9461 | ||
|
|
2dc13b7184 | ||
|
|
da691e3310 | ||
|
|
5e020f51d9 | ||
|
|
1f63e59b8a | ||
|
|
dcb9c29505 | ||
|
|
db83e5a954 | ||
|
|
c7c5506ee0 | ||
|
|
2988b6b38d | ||
|
|
debef9a36f | ||
|
|
839bc57b48 | ||
|
|
175c16bd00 | ||
|
|
ad356f431e | ||
|
|
ebe368e61e | ||
|
|
d800a630b5 | ||
|
|
239f91a65f | ||
|
|
aff7c24b6a | ||
|
|
5bf0af61ba | ||
|
|
03ed31e85b | ||
|
|
b21a4482bd | ||
|
|
2ad8ae79ce | ||
|
|
a4be747dd5 | ||
|
|
a6cd5d6f37 | ||
|
|
0e80fb7d86 | ||
|
|
0793f181bb | ||
|
|
a9ee0424f7 | ||
|
|
49f45b6b76 | ||
|
|
4216a03438 | ||
|
|
a9a8346a5b | ||
|
|
70a95b5e08 | ||
|
|
1e8c2e44e2 | ||
|
|
d553aadead | ||
|
|
fabb98f74e | ||
|
|
0a0ec5cefc | ||
|
|
0d31587855 | ||
|
|
f4a08e36dd |
@@ -0,0 +1,71 @@
|
||||
# Copilot instructions for Daybreak
|
||||
|
||||
Daybreak is a cross-platform Guild Wars launcher built on .NET 10 and
|
||||
Photino.Blazor. Read the
|
||||
[Architecture Overview](../README.md#architecture-overview) for the project
|
||||
layout and [CONTRIBUTING.md](../CONTRIBUTING.md) for the branching and release
|
||||
process.
|
||||
|
||||
## Workflow
|
||||
|
||||
- Branch off the current `release/[version]` branch, never off `master`.
|
||||
- One feature or fix per branch and per PR.
|
||||
- Never push to `master` and never edit the version in `Directory.Build.props`.
|
||||
Both are owned by the release workflows.
|
||||
- Title PRs `Short description (Closes #123)` so the linked issue gets labelled.
|
||||
|
||||
## Build and test
|
||||
|
||||
CI does not run on PRs targeting a release branch, so validate locally. Run the
|
||||
smallest command that covers the change:
|
||||
|
||||
```bash
|
||||
dotnet build Daybreak.Linux/Daybreak.Linux.csproj # or Daybreak.Windows
|
||||
dotnet test Daybreak.Tests/Daybreak.Tests.csproj --filter "FullyQualifiedName~YourTests"
|
||||
```
|
||||
|
||||
Keep the build warning-free.
|
||||
|
||||
## Where code goes
|
||||
|
||||
| Change | Project |
|
||||
| -------------------------------------------------- | ------------------------------------ |
|
||||
| Models, utilities, interfaces shared by everything | `Daybreak.Shared` |
|
||||
| Blazor views and services | `Daybreak.Core` |
|
||||
| Platform-specific implementations | `Daybreak.Windows`, `Daybreak.Linux` |
|
||||
|
||||
`Daybreak.Tests` only references `Daybreak.Core`. When adding platform code, put
|
||||
the logic worth testing in `Daybreak.Shared` and leave the platform project as a
|
||||
thin caller.
|
||||
|
||||
Add NuGet packages through `Directory.Packages.props`.
|
||||
|
||||
## C# conventions
|
||||
|
||||
`.editorconfig` is the source of truth. The projects are set up to treat
|
||||
warnings as errors.
|
||||
|
||||
## Verify before claiming
|
||||
|
||||
Prefer evidence over reasoning about behaviour, especially for the Wine and
|
||||
injection paths. Wine is scriptable, so reproduce the actual failure and confirm
|
||||
the fix against it rather than inferring what a Win32 call does.
|
||||
|
||||
## Writing style
|
||||
|
||||
Applies to docs, comments, PR descriptions and answers.
|
||||
|
||||
- Be short. Cut filler, recap and process narration.
|
||||
- No AI-isms and no inflated language.
|
||||
- Do not paste code into documentation. Link to the file so there is only one
|
||||
copy of it.
|
||||
- Explain the reason for a change, not a summary of the diff.
|
||||
|
||||
## Markdown
|
||||
|
||||
- No inline HTML. Use `![alt][ref]` with a reference definition instead of an
|
||||
`img` tag.
|
||||
- Start a mermaid fence with the diagram type, for example `flowchart LR`. YAML
|
||||
frontmatter inside the fence is rejected by some renderers.
|
||||
- Long URLs belong in reference-style link definitions at the bottom of the
|
||||
file.
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
echo "version=$version" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "10.x"
|
||||
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "10.x"
|
||||
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "10.x"
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "10.x"
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "10.x"
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
|
||||
- name: Install .NET Core
|
||||
uses: actions/setup-dotnet@v5
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: '10.x'
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install .NET Core
|
||||
uses: actions/setup-dotnet@v5
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "10.x"
|
||||
|
||||
|
||||
@@ -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
@@ -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).
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
+659
-219
File diff suppressed because it is too large
Load Diff
@@ -13,17 +13,44 @@ public readonly unsafe struct GuildWarsArray<T> : IEnumerable<T>
|
||||
public readonly uint Size;
|
||||
public readonly uint Param;
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound on a believable element count. Guild Wars' largest arrays hold at most a
|
||||
/// few thousand entries, so anything beyond this is uninitialised or misresolved memory
|
||||
/// rather than a real array.
|
||||
/// </summary>
|
||||
private const uint MaxPlausibleCapacity = 0x10000;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors <c>GW::BaseArray::valid()</c> - the buffer must be null or aligned and the size
|
||||
/// must fit within the capacity - plus a bound on the capacity itself. Guild Wars leaves
|
||||
/// these fields transiently inconsistent while it (re)allocates an array, and a
|
||||
/// misresolved GWCA scan can point this struct at arbitrary bytes that still satisfy
|
||||
/// <c>size <= capacity</c>.
|
||||
/// </summary>
|
||||
public bool IsValid =>
|
||||
(this.Buffer is null || ((nuint)this.Buffer & 0x3) == 0) &&
|
||||
this.Size <= this.Capacity &&
|
||||
this.Capacity <= MaxPlausibleCapacity;
|
||||
|
||||
/// <summary>
|
||||
/// Number of elements that can safely be read. Zero whenever the array is not backed by
|
||||
/// a buffer, matching <c>GW::BaseArray::get()</c>, which returns null instead of
|
||||
/// dereferencing. Reading past this is an access violation, and because NativeAOT cannot
|
||||
/// throw <c>AccessViolationException</c> it fail-fasts and takes Guild Wars down with it.
|
||||
/// </summary>
|
||||
public uint Count => this.Buffer is not null && this.IsValid ? this.Size : 0;
|
||||
|
||||
public T this[int index]
|
||||
{
|
||||
get
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(index);
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)index, this.Size);
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)index, this.Count);
|
||||
return this.Buffer[index];
|
||||
}
|
||||
}
|
||||
|
||||
public Enumerator GetEnumerator() => new(this.Buffer, this.Size);
|
||||
public Enumerator GetEnumerator() => new(this.Buffer, this.Count);
|
||||
|
||||
IEnumerator<T> IEnumerable<T>.GetEnumerator() => this.GetEnumerator();
|
||||
|
||||
|
||||
@@ -7,6 +7,18 @@ public readonly unsafe struct WrappedPointer<T>(T* pointer)
|
||||
|
||||
public bool IsNull => this.Pointer is null;
|
||||
|
||||
/// <summary>
|
||||
/// True when the pointer is non-null and plausibly dereferenceable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Guild Wars structures are 4-byte aligned on x86, so a misaligned address is never a
|
||||
/// real structure. When a GWCA pattern scan fails to resolve, it can hand back an
|
||||
/// address inside Gw.exe's code section (for example 0x0048CA72); reading through that
|
||||
/// yields nonsense field values and eventually an access violation. NativeAOT cannot
|
||||
/// throw <c>AccessViolationException</c>, so it fail-fasts and terminates Guild Wars.
|
||||
/// </remarks>
|
||||
public bool IsValid => this.Pointer is not null && ((nuint)this.Pointer & 0x3) == 0;
|
||||
|
||||
public static implicit operator WrappedPointer<T>(T* pointer) => new(pointer);
|
||||
|
||||
public static implicit operator T*(WrappedPointer<T> wrappedPointer) => wrappedPointer.Pointer;
|
||||
|
||||
@@ -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 <value></c>,
|
||||
/// <c>-password <value></c>) that appear in rendered messages and property values.</item>
|
||||
/// <item>Property-name matching, which masks any structured property named after a credential
|
||||
/// field (Username, Password, Email) regardless of its value.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static partial class LogEventRedactor
|
||||
{
|
||||
public const string RedactedValue = "[REDACTED]";
|
||||
|
||||
// Structured property names whose value must always be masked.
|
||||
private static readonly HashSet<string> SensitivePropertyNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Username",
|
||||
"Password",
|
||||
"Email",
|
||||
};
|
||||
|
||||
// Cheap, vectorized pre-filter: the (comparatively expensive) look-behind regex patterns are
|
||||
// skipped unless one of these credential flags is present in the text. SearchValues performs a
|
||||
// single multi-pattern scan (Teddy/Aho-Corasick internally) that outperforms both individual
|
||||
// Contains calls and a hand-rolled automaton, and scales as the marker set grows. Every secret
|
||||
// pattern below must be gated by one of these markers.
|
||||
private static readonly SearchValues<string> SecretMarkers = SearchValues.Create(
|
||||
["-email", "-password"],
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Patterns whose single match is the secret value to mask. The credential flag itself is kept
|
||||
// in a look-behind so only the value is replaced.
|
||||
private static readonly Regex[] SecretPatterns =
|
||||
[
|
||||
EmailArgumentRegex(),
|
||||
PasswordArgumentRegex(),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Returns a copy of <paramref name="logEvent"/> with sensitive information masked. The original
|
||||
/// event is returned unchanged when nothing needs to be redacted.
|
||||
/// </summary>
|
||||
public static LogEvent Redact(LogEvent logEvent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logEvent);
|
||||
|
||||
var redactedTemplate = RedactTemplate(logEvent.MessageTemplate);
|
||||
var redactedProperties = logEvent.Properties
|
||||
.Select(p => new LogEventProperty(p.Key, RedactPropertyValue(p.Key, p.Value)))
|
||||
.ToList();
|
||||
|
||||
return new LogEvent(
|
||||
logEvent.Timestamp,
|
||||
logEvent.Level,
|
||||
logEvent.Exception,
|
||||
redactedTemplate,
|
||||
redactedProperties);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Masks the credential values embedded in an arbitrary string using the configured regex patterns.
|
||||
/// </summary>
|
||||
public static string RedactText(string? text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return text ?? string.Empty;
|
||||
}
|
||||
|
||||
// Fast path: avoid running the look-behind regexes over the overwhelming majority of log
|
||||
// text that cannot contain a credential flag. This keeps redaction global (fail-safe) while
|
||||
// costing only a vectorized substring scan for events that carry no secrets.
|
||||
if (!MightContainSecret(text))
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
foreach (var pattern in SecretPatterns)
|
||||
{
|
||||
text = pattern.Replace(text, RedactedValue);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
private static bool MightContainSecret(string text)
|
||||
{
|
||||
return text.AsSpan().ContainsAny(SecretMarkers);
|
||||
}
|
||||
|
||||
private static MessageTemplate RedactTemplate(MessageTemplate template)
|
||||
{
|
||||
var tokens = template.Tokens
|
||||
.Select(token => token is TextToken textToken
|
||||
? new TextToken(RedactText(textToken.Text))
|
||||
: token)
|
||||
.ToList();
|
||||
|
||||
return new MessageTemplate(tokens);
|
||||
}
|
||||
|
||||
private static LogEventPropertyValue RedactPropertyValue(string propertyName, LogEventPropertyValue value)
|
||||
{
|
||||
if (SensitivePropertyNames.Contains(propertyName))
|
||||
{
|
||||
return new ScalarValue(RedactedValue);
|
||||
}
|
||||
|
||||
return RedactPropertyValue(value);
|
||||
}
|
||||
|
||||
private static LogEventPropertyValue RedactPropertyValue(LogEventPropertyValue value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case ScalarValue { Value: string stringValue }:
|
||||
return new ScalarValue(RedactText(stringValue));
|
||||
|
||||
case SequenceValue sequence:
|
||||
return new SequenceValue(sequence.Elements.Select(RedactPropertyValue));
|
||||
|
||||
case StructureValue structure:
|
||||
var structureProperties = structure.Properties
|
||||
.Select(p => new LogEventProperty(p.Name, RedactPropertyValue(p.Name, p.Value)));
|
||||
return new StructureValue(structureProperties, structure.TypeTag);
|
||||
|
||||
case DictionaryValue dictionary:
|
||||
var elements = dictionary.Elements
|
||||
.Select(kvp => new KeyValuePair<ScalarValue, LogEventPropertyValue>(
|
||||
kvp.Key,
|
||||
RedactPropertyValue(kvp.Value)));
|
||||
return new DictionaryValue(elements);
|
||||
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"(?<=-email[=\s])(""[^""]*""|'[^']*'|\S+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex EmailArgumentRegex();
|
||||
|
||||
[GeneratedRegex(@"(?<=-password[=\s])(""[^""]*""|'[^']*'|\S+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex PasswordArgumentRegex();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace Daybreak.Services.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for sinks that must never receive sensitive user information. Every emitted
|
||||
/// <see cref="LogEvent"/> is passed through <see cref="LogEventRedactor"/> before being handed to
|
||||
/// the concrete sink via <see cref="EmitRedacted"/>.
|
||||
/// <para>
|
||||
/// This lets us keep the Console sink (only visible to the local user) unredacted while masking
|
||||
/// sensitive user information for sinks that persist or forward logs off the machine, such as the
|
||||
/// in-memory sink used for exporting logs and the telemetry sink used to forward logs to the server.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public abstract class RedactingLogEventSink : ILogEventSink
|
||||
{
|
||||
public void Emit(LogEvent logEvent)
|
||||
{
|
||||
this.EmitRedacted(LogEventRedactor.Redact(logEvent));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the log event after sensitive information has been masked.
|
||||
/// </summary>
|
||||
protected abstract void EmitRedacted(LogEvent logEvent);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -8,80 +8,90 @@
|
||||
<div class="option-view-title">
|
||||
<h3>Accounts</h3>
|
||||
</div>
|
||||
<div class="credentials-list">
|
||||
@foreach (var credential in this.ViewModel.LoginCredentials)
|
||||
{
|
||||
<div class="credential-row">
|
||||
<div class="identifier-row">
|
||||
<div class="identifier-label">
|
||||
<FluentLabel>Identifier</FluentLabel>
|
||||
</div>
|
||||
<div class="identifier-field">
|
||||
<FluentTextField Value="@credential.LoginCredentials.Identifier"
|
||||
Appearance="FluentInputAppearance.Outline"
|
||||
AutoComplete="off"
|
||||
ReadOnly="true"
|
||||
Placeholder="Identifier">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
|
||||
@onclick="@(() => this.ViewModel.RemoveCredential(credential))"
|
||||
slot="end"
|
||||
Color="Color.Neutral"
|
||||
Title="Delete account" />
|
||||
</FluentTextField>
|
||||
</div>
|
||||
</div>
|
||||
<div class="username-row">
|
||||
<div class="username-label">
|
||||
<FluentLabel>Username</FluentLabel>
|
||||
</div>
|
||||
<div class="username-field">
|
||||
<FluentTextField Value="@credential.LoginCredentials.Username"
|
||||
ValueChanged="@(e => this.ViewModel.UsernameChanged(credential, e))"
|
||||
Appearance="FluentInputAppearance.Outline"
|
||||
AutoComplete="off"
|
||||
Immediate="true"
|
||||
ImmediateDelay="500"
|
||||
InputMode="InputMode.Text"
|
||||
TextFieldType="TextFieldType.Email"
|
||||
Placeholder="Username" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="password-row">
|
||||
<div class="password-label">
|
||||
<FluentLabel>Password</FluentLabel>
|
||||
</div>
|
||||
<div class="password-field">
|
||||
<FluentTextField Value="@credential.LoginCredentials.Password"
|
||||
ValueChanged="@(e => this.ViewModel.PasswordChanged(credential, e))"
|
||||
Appearance="FluentInputAppearance.Outline"
|
||||
AutoComplete="off"
|
||||
Immediate="true"
|
||||
ImmediateDelay="500"
|
||||
InputMode="InputMode.Text"
|
||||
TextFieldType="@(credential.PasswordVisible ? TextFieldType.Text : TextFieldType.Password)"
|
||||
Placeholder="Password">
|
||||
@if (credential.PasswordVisible)
|
||||
{
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Eye())"
|
||||
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
|
||||
@if (this.ViewModel.LoginCredentials.Count is 0)
|
||||
{
|
||||
<EmptyStateWidget Message="No accounts configured"
|
||||
ActionText="Add account"
|
||||
Icon="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Person())"
|
||||
OnAction="@this.ViewModel.CreateCredential" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="credentials-list">
|
||||
@foreach (var credential in this.ViewModel.LoginCredentials)
|
||||
{
|
||||
<div class="credential-row">
|
||||
<div class="identifier-row">
|
||||
<div class="identifier-label">
|
||||
<FluentLabel>Identifier</FluentLabel>
|
||||
</div>
|
||||
<div class="identifier-field">
|
||||
<FluentTextField Value="@credential.LoginCredentials.Identifier"
|
||||
Appearance="FluentInputAppearance.Outline"
|
||||
AutoComplete="off"
|
||||
ReadOnly="true"
|
||||
Placeholder="Identifier">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
|
||||
@onclick="@(() => this.ViewModel.RemoveCredential(credential))"
|
||||
slot="end"
|
||||
Color="Color.Neutral"
|
||||
Title="Hide password" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.EyeOff())"
|
||||
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
|
||||
slot="end"
|
||||
Color="Color.Neutral"
|
||||
Title="Show password" />
|
||||
}
|
||||
</FluentTextField>
|
||||
Title="Delete account" />
|
||||
</FluentTextField>
|
||||
</div>
|
||||
</div>
|
||||
<div class="username-row">
|
||||
<div class="username-label">
|
||||
<FluentLabel>Username</FluentLabel>
|
||||
</div>
|
||||
<div class="username-field">
|
||||
<FluentTextField Value="@credential.LoginCredentials.Username"
|
||||
ValueChanged="@(e => this.ViewModel.UsernameChanged(credential, e))"
|
||||
Appearance="FluentInputAppearance.Outline"
|
||||
AutoComplete="off"
|
||||
Immediate="true"
|
||||
ImmediateDelay="500"
|
||||
InputMode="InputMode.Text"
|
||||
TextFieldType="TextFieldType.Email"
|
||||
Placeholder="Username" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="password-row">
|
||||
<div class="password-label">
|
||||
<FluentLabel>Password</FluentLabel>
|
||||
</div>
|
||||
<div class="password-field">
|
||||
<FluentTextField Value="@credential.LoginCredentials.Password"
|
||||
ValueChanged="@(e => this.ViewModel.PasswordChanged(credential, e))"
|
||||
Appearance="FluentInputAppearance.Outline"
|
||||
AutoComplete="off"
|
||||
Immediate="true"
|
||||
ImmediateDelay="500"
|
||||
InputMode="InputMode.Text"
|
||||
TextFieldType="@(credential.PasswordVisible ? TextFieldType.Text : TextFieldType.Password)"
|
||||
Placeholder="Password">
|
||||
@if (credential.PasswordVisible)
|
||||
{
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Eye())"
|
||||
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
|
||||
slot="end"
|
||||
Color="Color.Neutral"
|
||||
Title="Hide password" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.EyeOff())"
|
||||
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
|
||||
slot="end"
|
||||
Color="Color.Neutral"
|
||||
Title="Show password" />
|
||||
}
|
||||
</FluentTextField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ using Daybreak.Shared.Services.Menu;
|
||||
using Daybreak.Shared.Services.Notifications;
|
||||
using Daybreak.Shared.Services.Options;
|
||||
using Daybreak.Shared.Services.Privilege;
|
||||
using Daybreak.Shared.Services.Shell;
|
||||
using Daybreak.Shared.Services.Themes;
|
||||
using Daybreak.Shared.Services.Updater;
|
||||
using Daybreak.Shared.Services.Window;
|
||||
@@ -17,7 +18,6 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.JSInterop;
|
||||
using Photino.Blazor;
|
||||
using System.Core.Extensions;
|
||||
using System.Diagnostics;
|
||||
using System.Extensions;
|
||||
using TrailBlazr.Services;
|
||||
|
||||
@@ -39,6 +39,7 @@ public sealed class AppViewModel
|
||||
private readonly JSConsoleInterop jsConsoleInterop;
|
||||
private readonly INotificationService notificationService;
|
||||
private readonly IWindowManipulationService windowManipulationService;
|
||||
private readonly IShellExecutor shellExecutor;
|
||||
private readonly ILogger<App> logger;
|
||||
|
||||
private SemaphoreSlim themeChangeSemaphore = new(1, 1);
|
||||
@@ -89,6 +90,7 @@ public sealed class AppViewModel
|
||||
INotificationProducer notificationProducer,
|
||||
INotificationService notificationService,
|
||||
IWindowManipulationService windowManipulationService,
|
||||
IShellExecutor shellExecutor,
|
||||
ILogger<App> logger)
|
||||
{
|
||||
this.keyboardHookService = keyboardHookService.ThrowIfNull();
|
||||
@@ -104,6 +106,7 @@ public sealed class AppViewModel
|
||||
this.NotificationProducer = notificationProducer.ThrowIfNull();
|
||||
this.notificationService = notificationService.ThrowIfNull();
|
||||
this.windowManipulationService = windowManipulationService.ThrowIfNull();
|
||||
this.shellExecutor = shellExecutor.ThrowIfNull();
|
||||
this.logger = logger.ThrowIfNull();
|
||||
|
||||
|
||||
@@ -215,14 +218,7 @@ public sealed class AppViewModel
|
||||
|
||||
public void OpenIssues()
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo { FileName = IssueUrl, UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.logger.LogError(ex, "Encountered exception while opening issues page");
|
||||
}
|
||||
this.shellExecutor.OpenUrl(IssueUrl);
|
||||
}
|
||||
|
||||
public void OpenSynchronizationView()
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<div class="empty-state">
|
||||
<FluentIcon Value="@this.Icon" Color="Color.Neutral" />
|
||||
<div class="empty-message">@this.Message</div>
|
||||
<FluentButton Appearance="Appearance.Accent"
|
||||
Disabled="@this.Disabled"
|
||||
IconStart="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Add())"
|
||||
Title="@this.ActionText"
|
||||
OnClick="@this.OnAction">
|
||||
@this.ActionText
|
||||
</FluentButton>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string ActionText { get; set; } = "Add";
|
||||
|
||||
[Parameter]
|
||||
public Icon Icon { get; set; } = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Search();
|
||||
|
||||
[Parameter]
|
||||
public bool Disabled { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnAction { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
color: var(--neutral-foreground-hint);
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
font-size: var(--font-size-large);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,151 +9,161 @@
|
||||
<div class="option-view-title">
|
||||
<h3>Launch Configurations</h3>
|
||||
</div>
|
||||
<div class="launch-configs-list">
|
||||
@for (var i = 0; i < this.ViewModel.LaunchConfigurations.Count; i++)
|
||||
{
|
||||
var config = this.ViewModel.LaunchConfigurations[i];
|
||||
var index = i;
|
||||
<div class="launch-configs-row" id="launch-config-@config.Identifier">
|
||||
<div class="launch-config-reorder">
|
||||
<div class="launch-config-reorder-label">
|
||||
<FluentLabel>Reorder</FluentLabel>
|
||||
@if (this.ViewModel.LaunchConfigurations.Count is 0)
|
||||
{
|
||||
<EmptyStateWidget Message="No launch configurations yet"
|
||||
ActionText="Add launch configuration"
|
||||
Icon="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Rocket())"
|
||||
OnAction="@this.ViewModel.CreateNewLaunchConfiguration" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="launch-configs-list">
|
||||
@for (var i = 0; i < this.ViewModel.LaunchConfigurations.Count; i++)
|
||||
{
|
||||
var config = this.ViewModel.LaunchConfigurations[i];
|
||||
var index = i;
|
||||
<div class="launch-configs-row" id="launch-config-@config.Identifier">
|
||||
<div class="launch-config-reorder">
|
||||
<div class="launch-config-reorder-label">
|
||||
<FluentLabel>Reorder</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-reorder-buttons">
|
||||
@if (index > 0)
|
||||
{
|
||||
<FluentButton Appearance="Appearance.Stealth"
|
||||
IconOnly="true"
|
||||
Title="Move up"
|
||||
OnClick="@(() => this.ViewModel.MoveConfigUp(config))">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowUp())" />
|
||||
</FluentButton>
|
||||
}
|
||||
@if (index < this.ViewModel.LaunchConfigurations.Count - 1)
|
||||
{
|
||||
<FluentButton Appearance="Appearance.Stealth"
|
||||
IconOnly="true"
|
||||
Title="Move down"
|
||||
OnClick="@(() => this.ViewModel.MoveConfigDown(config))">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDown())" />
|
||||
</FluentButton>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-reorder-buttons">
|
||||
@if (index > 0)
|
||||
{
|
||||
<FluentButton Appearance="Appearance.Stealth"
|
||||
IconOnly="true"
|
||||
Title="Move up"
|
||||
OnClick="@(() => this.ViewModel.MoveConfigUp(config))">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowUp())" />
|
||||
<div class="launch-config-identifier">
|
||||
<div class="launch-config-identifier-label">
|
||||
<FluentLabel>Identifier</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-identifier-field">
|
||||
<FluentTextField Value="@config.Identifier" ReadOnly="true" Spellcheck="false"
|
||||
Placeholder="Launch config identifier">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
|
||||
@onclick="@(() => this.ViewModel.DeleteLaunchConfiguration(config))" slot="end" Color="Color.Neutral"
|
||||
Title="Delete launch configuration" />
|
||||
</FluentTextField>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-name">
|
||||
<div class="launch-config-name-label">
|
||||
<FluentLabel>Name</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-name-field">
|
||||
<FluentTextField Value="@config.Name"
|
||||
ValueChanged="@((newValue) => this.ViewModel.CustomNameChanged(config, newValue))" Spellcheck="false"
|
||||
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
|
||||
Placeholder="Custom name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-color">
|
||||
<div class="launch-config-color-label">
|
||||
<FluentLabel>Accent Color</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-color-field">
|
||||
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
|
||||
Items="@this.ViewModel.AvailableColors"
|
||||
SelectedOption="@(config.Color ?? string.Empty)"
|
||||
SelectedOptionChanged="@((newValue) => this.ViewModel.ColorChanged(config, newValue))">
|
||||
<OptionTemplate>
|
||||
<div class="color-option">
|
||||
@if (!string.IsNullOrWhiteSpace(context))
|
||||
{
|
||||
<div class="color-swatch" style="background-color: @(Daybreak.Shared.Models.ColorPalette.AccentColor.Accents.FirstOrDefault(a => a.Name.ToString() == context)?.Hex ?? "transparent")"></div>
|
||||
}
|
||||
<span>@this.ViewModel.GetColorDisplayName(context)</span>
|
||||
</div>
|
||||
</OptionTemplate>
|
||||
</FluentSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-executable">
|
||||
<div class="launch-config-executable-label">
|
||||
<FluentLabel>Executable</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-executable-field">
|
||||
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
|
||||
Items="@this.ViewModel.Executables" SelectedOption="@config.ExecutablePath"
|
||||
SelectedOptionChanged="@((newValue) => this.ViewModel.ExecutableChanged(config, newValue))">
|
||||
<OptionTemplate>
|
||||
<div title="@(context)">
|
||||
@(string.IsNullOrWhiteSpace(context) ? "Any Executable" : context)
|
||||
</div>
|
||||
</OptionTemplate>
|
||||
</FluentSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-credentials">
|
||||
<div class="launch-config-credentials-label">
|
||||
<FluentLabel>Credentials</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-credentials-field">
|
||||
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
|
||||
Items="@this.ViewModel.Credentials.Select(c => c.Identifier ?? string.Empty)"
|
||||
SelectedOption="@(config.Credentials?.Identifier ?? string.Empty)"
|
||||
SelectedOptionChanged="@((newValue) => this.ViewModel.CredentialsChanged(config, newValue))">
|
||||
<OptionTemplate>
|
||||
@(this.ViewModel.Credentials.FirstOrDefault(c => c.Identifier == context)?.Username ?? "Unknown")
|
||||
</OptionTemplate>
|
||||
</FluentSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-args">
|
||||
<div class="launch-config-args-label">
|
||||
<FluentLabel>Custom Arguments</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-args-field">
|
||||
<FluentTextField Value="@config.Arguments"
|
||||
ValueChanged="@((newValue) => this.ViewModel.CustomArgsChanged(config, newValue))" Spellcheck="false"
|
||||
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
|
||||
Placeholder="Launch arguments" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-steam">
|
||||
<div class="launch-config-steam-label">
|
||||
<FluentLabel>Steam support</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-steam-field">
|
||||
<FluentCheckbox Value="@config.SteamSupport"
|
||||
ValueChanged="@((newValue) => this.ViewModel.SteamSupportChanged(config, newValue))" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-custom-mods">
|
||||
<div class="launch-config-custom-mods-label">
|
||||
<FluentLabel>Custom mod loadout</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-custom-mods-field">
|
||||
<FluentCheckbox Value="@config.CustomModLoadoutEnabled"
|
||||
ValueChanged="@((newValue) => this.ViewModel.CustomModLoadoutChanged(config, newValue))" />
|
||||
<FluentButton Appearance="Appearance.Neutral"
|
||||
@onclick="@(() => this.ViewModel.ManageCustomMods(config))"
|
||||
Disabled="@(!config.CustomModLoadoutEnabled)"
|
||||
Title="Manage custom mods for this launch configuration">
|
||||
Manage Mods
|
||||
</FluentButton>
|
||||
}
|
||||
@if (index < this.ViewModel.LaunchConfigurations.Count - 1)
|
||||
{
|
||||
<FluentButton Appearance="Appearance.Stealth"
|
||||
IconOnly="true"
|
||||
Title="Move down"
|
||||
OnClick="@(() => this.ViewModel.MoveConfigDown(config))">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDown())" />
|
||||
</FluentButton>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-identifier">
|
||||
<div class="launch-config-identifier-label">
|
||||
<FluentLabel>Identifier</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-identifier-field">
|
||||
<FluentTextField Value="@config.Identifier" ReadOnly="true" Spellcheck="false"
|
||||
Placeholder="Launch config identifier">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
|
||||
@onclick="@(() => this.ViewModel.DeleteLaunchConfiguration(config))" slot="end" Color="Color.Neutral"
|
||||
Title="Delete launch configuration" />
|
||||
</FluentTextField>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-name">
|
||||
<div class="launch-config-name-label">
|
||||
<FluentLabel>Name</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-name-field">
|
||||
<FluentTextField Value="@config.Name"
|
||||
ValueChanged="@((newValue) => this.ViewModel.CustomNameChanged(config, newValue))" Spellcheck="false"
|
||||
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
|
||||
Placeholder="Custom name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-color">
|
||||
<div class="launch-config-color-label">
|
||||
<FluentLabel>Accent Color</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-color-field">
|
||||
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
|
||||
Items="@this.ViewModel.AvailableColors"
|
||||
SelectedOption="@(config.Color ?? string.Empty)"
|
||||
SelectedOptionChanged="@((newValue) => this.ViewModel.ColorChanged(config, newValue))">
|
||||
<OptionTemplate>
|
||||
<div class="color-option">
|
||||
@if (!string.IsNullOrWhiteSpace(context))
|
||||
{
|
||||
<div class="color-swatch" style="background-color: @(Daybreak.Shared.Models.ColorPalette.AccentColor.Accents.FirstOrDefault(a => a.Name.ToString() == context)?.Hex ?? "transparent")"></div>
|
||||
}
|
||||
<span>@this.ViewModel.GetColorDisplayName(context)</span>
|
||||
</div>
|
||||
</OptionTemplate>
|
||||
</FluentSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-executable">
|
||||
<div class="launch-config-executable-label">
|
||||
<FluentLabel>Executable</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-executable-field">
|
||||
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
|
||||
Items="@this.ViewModel.Executables" SelectedOption="@config.ExecutablePath"
|
||||
SelectedOptionChanged="@((newValue) => this.ViewModel.ExecutableChanged(config, newValue))">
|
||||
<OptionTemplate>
|
||||
<div title="@(context)">
|
||||
@(string.IsNullOrWhiteSpace(context) ? "Any Executable" : context)
|
||||
</div>
|
||||
</OptionTemplate>
|
||||
</FluentSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-credentials">
|
||||
<div class="launch-config-credentials-label">
|
||||
<FluentLabel>Credentials</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-credentials-field">
|
||||
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
|
||||
Items="@this.ViewModel.Credentials.Select(c => c.Identifier ?? string.Empty)"
|
||||
SelectedOption="@(config.Credentials?.Identifier ?? string.Empty)"
|
||||
SelectedOptionChanged="@((newValue) => this.ViewModel.CredentialsChanged(config, newValue))">
|
||||
<OptionTemplate>
|
||||
@(this.ViewModel.Credentials.FirstOrDefault(c => c.Identifier == context)?.Username ?? "Unknown")
|
||||
</OptionTemplate>
|
||||
</FluentSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-args">
|
||||
<div class="launch-config-args-label">
|
||||
<FluentLabel>Custom Arguments</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-args-field">
|
||||
<FluentTextField Value="@config.Arguments"
|
||||
ValueChanged="@((newValue) => this.ViewModel.CustomArgsChanged(config, newValue))" Spellcheck="false"
|
||||
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
|
||||
Placeholder="Launch arguments" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-steam">
|
||||
<div class="launch-config-steam-label">
|
||||
<FluentLabel>Steam support</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-steam-field">
|
||||
<FluentCheckbox Value="@config.SteamSupport"
|
||||
ValueChanged="@((newValue) => this.ViewModel.SteamSupportChanged(config, newValue))" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-custom-mods">
|
||||
<div class="launch-config-custom-mods-label">
|
||||
<FluentLabel>Custom mod loadout</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-custom-mods-field">
|
||||
<FluentCheckbox Value="@config.CustomModLoadoutEnabled"
|
||||
ValueChanged="@((newValue) => this.ViewModel.CustomModLoadoutChanged(config, newValue))" />
|
||||
<FluentButton Appearance="Appearance.Neutral"
|
||||
@onclick="@(() => this.ViewModel.ManageCustomMods(config))"
|
||||
Disabled="@(!config.CustomModLoadoutEnabled)"
|
||||
Title="Manage custom mods for this launch configuration">
|
||||
Manage Mods
|
||||
</FluentButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ public sealed class LaunchConfigurationsViewModel(
|
||||
public override ValueTask ParametersSet(LaunchConfigurationsView view, CancellationToken cancellationToken)
|
||||
{
|
||||
this.Credentials.ClearAnd().AddRange(this.credentialManager.GetCredentialList());
|
||||
this.Credentials.Add(LoginCredentials.SteamLogin);
|
||||
this.LaunchConfigurations.ClearAnd().AddRange(this.launchConfigurationService.GetLaunchConfigurations());
|
||||
this.Executables.ClearAnd().AddRange(this.guildWarsExecutableManager.GetExecutableList()).Add(string.Empty);
|
||||
return base.ParametersSet(view, cancellationToken);
|
||||
|
||||
@@ -507,9 +507,12 @@ public sealed class LaunchViewModel(
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
// If the API is available but it does not belong to the desired user, return null
|
||||
// If the API is available but it does not belong to the desired user, return null.
|
||||
// Steam login configurations use a virtual credential whose username is not the account
|
||||
// email, so the ownership check is skipped for them.
|
||||
if (
|
||||
maybeApiContext is not null
|
||||
&& launcherViewContext.Configuration.Credentials.IsSteamLogin is false
|
||||
&& await maybeApiContext.GetLoginInfo(cancellationToken) is LoginInfo loginInfo
|
||||
&& loginInfo.Email != launcherViewContext.Configuration.Credentials.Username
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using Daybreak.Shared.Models.Plugins;
|
||||
using Daybreak.Shared.Services.ApplicationLauncher;
|
||||
using Daybreak.Shared.Services.Plugins;
|
||||
using Daybreak.Shared.Services.Shell;
|
||||
using Photino.NET;
|
||||
using System.Diagnostics;
|
||||
using System.Extensions;
|
||||
using TrailBlazr.Services;
|
||||
using TrailBlazr.ViewModels;
|
||||
@@ -12,13 +12,15 @@ public sealed class PluginsViewModel(
|
||||
PhotinoWindow window,
|
||||
IApplicationLauncher applicationLauncher,
|
||||
IViewManager viewManager,
|
||||
IPluginsService pluginsService)
|
||||
IPluginsService pluginsService,
|
||||
IShellExecutor shellExecutor)
|
||||
: ViewModelBase<PluginsViewModel, PluginsView>
|
||||
{
|
||||
private readonly PhotinoWindow window = window;
|
||||
private readonly IApplicationLauncher applicationLauncher = applicationLauncher;
|
||||
private readonly IViewManager viewManager = viewManager;
|
||||
private readonly IPluginsService pluginsService = pluginsService;
|
||||
private readonly IShellExecutor shellExecutor = shellExecutor;
|
||||
|
||||
public List<AvailablePlugin> AvailablePlugins { get; init; } = [];
|
||||
public bool CanSave { get; private set; } = false;
|
||||
@@ -71,7 +73,7 @@ public sealed class PluginsViewModel(
|
||||
|
||||
public void NavigateToPlugin(AvailablePlugin plugin)
|
||||
{
|
||||
Process.Start("explorer.exe", plugin.Path);
|
||||
this.shellExecutor.OpenPath(plugin.Path);
|
||||
}
|
||||
|
||||
private void UpdatePlugins()
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
<div class="stretch-container backdrop-panel">
|
||||
<div class="content">
|
||||
<Virtualize Items="@this.ViewModel.Versions"
|
||||
Context="version">
|
||||
<div class="version-row">
|
||||
<div class="version-row-center" @onclick="@(() => this.ViewModel.OpenVersionPage(version))">
|
||||
<span>@version.ToString()</span>
|
||||
Context="version"
|
||||
ItemSize="56">
|
||||
<div class="version-item" tabindex="0"
|
||||
@onclick="@(() => this.ViewModel.OpenVersionPage(version))">
|
||||
<div class="version-info">
|
||||
<div class="version-icon">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size20.Tag())"
|
||||
Color="Color.Neutral" />
|
||||
</div>
|
||||
<span class="version-name">@version.ToString()</span>
|
||||
</div>
|
||||
<div class="version-row-right">
|
||||
<div class="version-actions"
|
||||
@onclick="@(() => this.ViewModel.DownloadVersion(version))"
|
||||
@onclick:stopPropagation="true">
|
||||
<FluentButton Appearance="Appearance.Stealth"
|
||||
IconOnly="true"
|
||||
OnClick="@(() => this.ViewModel.DownloadVersion(version))">
|
||||
Title="Download version">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDownload())" />
|
||||
</FluentButton>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Daybreak.Shared.Services.Updater;
|
||||
using System.Diagnostics;
|
||||
using Daybreak.Shared.Services.Shell;
|
||||
using Daybreak.Shared.Services.Updater;
|
||||
using System.Extensions;
|
||||
using TrailBlazr.Services;
|
||||
using TrailBlazr.ViewModels;
|
||||
@@ -7,13 +7,15 @@ using TrailBlazr.ViewModels;
|
||||
namespace Daybreak.Views;
|
||||
public sealed class VersionManagementViewModel(
|
||||
IViewManager viewManager,
|
||||
IApplicationUpdater applicationUpdater)
|
||||
IApplicationUpdater applicationUpdater,
|
||||
IShellExecutor shellExecutor)
|
||||
: ViewModelBase<VersionManagementViewModel, VersionManagementView>
|
||||
{
|
||||
private const string VersionPlaceholder = "{VERSION}";
|
||||
private const string ReleaseURL = $"https://github.com/AlexMacocian/Daybreak/releases/tag/v{VersionPlaceholder}";
|
||||
private readonly IViewManager viewManager = viewManager;
|
||||
private readonly IApplicationUpdater applicationUpdater = applicationUpdater;
|
||||
private readonly IShellExecutor shellExecutor = shellExecutor;
|
||||
|
||||
public List<Version> Versions { get; init; } = [];
|
||||
public Version? CurrentVersion { get; set; }
|
||||
@@ -31,6 +33,6 @@ public sealed class VersionManagementViewModel(
|
||||
|
||||
public void OpenVersionPage(Version version)
|
||||
{
|
||||
Process.Start("explorer.exe", ReleaseURL.Replace(VersionPlaceholder, version.ToString()));
|
||||
this.shellExecutor.OpenUrl(ReleaseURL.Replace(VersionPlaceholder, version.ToString()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,15 +13,55 @@
|
||||
margin: 50px 0px 0px 0px;
|
||||
}
|
||||
|
||||
.version-row {
|
||||
.version-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--neutral-stroke-divider-rest);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
border-radius: 6px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.version-row-center {
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
align-content: center;
|
||||
.version-item:hover {
|
||||
background-color: var(--accent-fill-hover);
|
||||
}
|
||||
|
||||
.version-item:focus {
|
||||
outline: 2px solid var(--accent-fill-focus);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.version-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.version-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.version-name {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-medium);
|
||||
color: var(--neutral-foreground-rest);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.version-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -178,6 +178,7 @@ internal static class CppHeaderParser
|
||||
{
|
||||
inMultiLineConstexpr = false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -189,6 +190,7 @@ internal static class CppHeaderParser
|
||||
{
|
||||
inSkipBlock = false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -211,6 +213,7 @@ internal static class CppHeaderParser
|
||||
{
|
||||
ParseEnumMembersFromLine(trimmed.Substring(0, closeBraceIdx), currentEnum);
|
||||
}
|
||||
|
||||
inEnum = false;
|
||||
var node = namespaceStack.Peek();
|
||||
node.Enums.Add(currentEnum);
|
||||
@@ -231,11 +234,11 @@ internal static class CppHeaderParser
|
||||
int closeBraces = CountChar(lineWithoutComments, '}');
|
||||
int prevDepth = structBraceDepth;
|
||||
structBraceDepth += openBraces - closeBraces;
|
||||
|
||||
|
||||
// Debug: Track brace changes for AgentLiving
|
||||
if (currentStruct.Name == "AgentLiving" && (openBraces > 0 || closeBraces > 0))
|
||||
{
|
||||
DebugLines.Add($"[BRACE] line {i+1}: {currentStruct.Name} depth {prevDepth}->{structBraceDepth} (open={openBraces}, close={closeBraces})");
|
||||
DebugLines.Add($"[BRACE] line {i + 1}: {currentStruct.Name} depth {prevDepth}->{structBraceDepth} (open={openBraces}, close={closeBraces})");
|
||||
}
|
||||
|
||||
// If we hit the closing brace of the struct
|
||||
@@ -252,8 +255,8 @@ internal static class CppHeaderParser
|
||||
pathParts.Add(n.Name);
|
||||
pathParts.Reverse();
|
||||
currentStruct.DebugNamespacePath = string.Join(".", pathParts);
|
||||
|
||||
DebugLines.Add($"[STRUCT-END] line {i+1}: {currentStruct.Name} with {currentStruct.Fields.Count} fields");
|
||||
|
||||
DebugLines.Add($"[STRUCT-END] line {i + 1}: {currentStruct.Name} with {currentStruct.Fields.Count} fields");
|
||||
node.Structs.Add(currentStruct);
|
||||
currentStruct = null;
|
||||
inStruct = false;
|
||||
@@ -278,6 +281,7 @@ internal static class CppHeaderParser
|
||||
if (field is not null)
|
||||
currentStruct.Fields.Add(field);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -295,7 +299,7 @@ internal static class CppHeaderParser
|
||||
var trailingCommentIdx = trimmed.IndexOf("//");
|
||||
if (trailingCommentIdx > 0)
|
||||
trimmedWithoutTrailingComment = trimmed.Substring(0, trailingCommentIdx).TrimEnd();
|
||||
|
||||
|
||||
if (trimmedWithoutTrailingComment.Contains("(") && (trimmedWithoutTrailingComment.Contains(")") || trimmedWithoutTrailingComment.Contains("{")))
|
||||
continue;
|
||||
|
||||
@@ -339,6 +343,7 @@ internal static class CppHeaderParser
|
||||
};
|
||||
currentStruct.Fields.Add(field);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -354,6 +359,7 @@ internal static class CppHeaderParser
|
||||
var child = current.GetOrCreateChild(part);
|
||||
namespaceStack.Push(child);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -366,13 +372,14 @@ internal static class CppHeaderParser
|
||||
freeBraceDepth--;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (namespaceStack.Count > 1)
|
||||
{
|
||||
// Debug: Track where namespace was popped
|
||||
var poppedNode = namespaceStack.Pop();
|
||||
poppedNode.DebugPopLine = i + 1;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -390,6 +397,7 @@ internal static class CppHeaderParser
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -436,6 +444,7 @@ internal static class CppHeaderParser
|
||||
inSkipBlock = true;
|
||||
skipBlockBraceCount = braces;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -448,6 +457,7 @@ internal static class CppHeaderParser
|
||||
inSkipBlock = true;
|
||||
skipBlockBraceCount = braces;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -455,17 +465,18 @@ internal static class CppHeaderParser
|
||||
if (OutOfLineMethodRegex.IsMatch(trimmed))
|
||||
{
|
||||
int braces = CountChar(trimmed, '{') - CountChar(trimmed, '}');
|
||||
DebugLines.Add($"[OUT-OF-LINE] line {i+1}: {trimmed.Substring(0, Math.Min(50, trimmed.Length))}... braces={braces}");
|
||||
DebugLines.Add($"[OUT-OF-LINE] line {i + 1}: {trimmed.Substring(0, Math.Min(50, trimmed.Length))}... braces={braces}");
|
||||
if (braces > 0)
|
||||
{
|
||||
inSkipBlock = true;
|
||||
skipBlockBraceCount = braces;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
else if (trimmed.Contains("::") && trimmed.Contains("(") && trimmed.Contains("{"))
|
||||
{
|
||||
DebugLines.Add($"[UNMATCHED-METHOD] line {i+1}: {trimmed.Substring(0, Math.Min(60, trimmed.Length))}");
|
||||
DebugLines.Add($"[UNMATCHED-METHOD] line {i + 1}: {trimmed.Substring(0, Math.Min(60, trimmed.Length))}");
|
||||
}
|
||||
|
||||
// ── Track function declarations with body on separate line ─
|
||||
@@ -492,6 +503,7 @@ internal static class CppHeaderParser
|
||||
inMultiLineConstexpr = true;
|
||||
multiLineBraceCount = braces;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -501,7 +513,7 @@ internal static class CppHeaderParser
|
||||
{
|
||||
var structName = structMatch.Groups[1].Value;
|
||||
var baseType = structMatch.Groups[2].Success ? structMatch.Groups[2].Value : null;
|
||||
DebugLines.Add($"[STRUCT-START] line {i+1}: {structName} (inSkip={inSkipBlock}, freeBrace={freeBraceDepth})");
|
||||
DebugLines.Add($"[STRUCT-START] line {i + 1}: {structName} (inSkip={inSkipBlock}, freeBrace={freeBraceDepth})");
|
||||
|
||||
// Skip some special cases
|
||||
if (structName.StartsWith("__") || structName == "Packet")
|
||||
@@ -512,6 +524,7 @@ internal static class CppHeaderParser
|
||||
inSkipBlock = true;
|
||||
skipBlockBraceCount = braces;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -530,7 +543,7 @@ internal static class CppHeaderParser
|
||||
if (structNobraceMatch.Success)
|
||||
{
|
||||
var structName = structNobraceMatch.Groups[1].Value;
|
||||
|
||||
|
||||
// Skip forward declarations (just struct Name;)
|
||||
if (trimmed.EndsWith(";"))
|
||||
continue;
|
||||
@@ -559,8 +572,10 @@ internal static class CppHeaderParser
|
||||
i = j;
|
||||
break;
|
||||
}
|
||||
|
||||
break; // unexpected content
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -593,9 +608,11 @@ internal static class CppHeaderParser
|
||||
{
|
||||
ParseEnumMembersFromLine(rest, enumDef);
|
||||
}
|
||||
|
||||
currentEnum = enumDef;
|
||||
inEnum = true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -637,14 +654,18 @@ internal static class CppHeaderParser
|
||||
{
|
||||
ParseEnumMembersFromLine(rest, enumDef);
|
||||
}
|
||||
|
||||
currentEnum = enumDef;
|
||||
inEnum = true;
|
||||
}
|
||||
|
||||
i = j;
|
||||
break;
|
||||
}
|
||||
|
||||
break; // unexpected content
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -794,7 +815,7 @@ internal static class CppHeaderParser
|
||||
int slashSlash = line.IndexOf("//");
|
||||
if (slashSlash >= 0)
|
||||
line = line.Substring(0, slashSlash);
|
||||
|
||||
|
||||
// Remove /* */ style comments (simple - doesn't handle nested)
|
||||
int blockStart = line.IndexOf("/*");
|
||||
while (blockStart >= 0)
|
||||
@@ -806,7 +827,7 @@ internal static class CppHeaderParser
|
||||
line = line.Substring(0, blockStart);
|
||||
blockStart = line.IndexOf("/*");
|
||||
}
|
||||
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
|
||||
@@ -238,7 +238,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
// Emit
|
||||
var sb = new StringBuilder(65536);
|
||||
EmitHeader(sb, totalExports, skippedExports);
|
||||
|
||||
|
||||
// Add diagnostic comment about parsed structs
|
||||
sb.AppendLine(" // ═══════════════════════════════════════════════════");
|
||||
sb.AppendLine(" // Parsed structs diagnostic:");
|
||||
@@ -246,6 +246,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
{
|
||||
sb.AppendLine($" // {diag}");
|
||||
}
|
||||
|
||||
sb.AppendLine(" // ═══════════════════════════════════════════════════");
|
||||
sb.AppendLine();
|
||||
|
||||
@@ -270,31 +271,33 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
{
|
||||
EmitConstantsNode(sb, grandchild, typeMap, namespaceClassNames, 4);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
EmitConstantsNode(sb, child, typeMap, namespaceClassNames, 4);
|
||||
}
|
||||
|
||||
sb.AppendLine("}"); // Close GWCA class
|
||||
sb.AppendLine("}"); // Close Daybreak.API.Interop namespace
|
||||
|
||||
|
||||
// Emit structs and enums into GuildWars namespace for consumer code compatibility
|
||||
// Consumer code uses `using Daybreak.API.Interop.GuildWars;` to access types
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("namespace Daybreak.API.Interop.GuildWars");
|
||||
sb.AppendLine("{");
|
||||
var emittedNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
|
||||
// Collect inline array types needed by struct fields (for non-blittable array fields)
|
||||
var inlineArrayTypes = new HashSet<(string csType, int size)>();
|
||||
CollectInlineArrayTypes(headerRoot, typeMap, inlineArrayTypes);
|
||||
|
||||
|
||||
// Emit manually-defined helper structs first (TLink, etc.)
|
||||
EmitManualHelperStructs(sb, 4, emittedNames);
|
||||
|
||||
|
||||
// Emit inline array types for non-blittable arrays (e.g., enum arrays)
|
||||
EmitInlineArrayTypes(sb, 4, inlineArrayTypes, emittedNames);
|
||||
|
||||
|
||||
// Emit enums first - they're often referenced by structs (e.g. Attribute enum vs Attribute struct)
|
||||
EmitEnumsToGuildWarsNamespace(sb, headerRoot, 4, emittedNames);
|
||||
EmitStructsToGuildWarsNamespace(sb, headerRoot, typeMap, 4, emittedNames, inlineArrayTypes);
|
||||
@@ -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:_<entry>=<entry>"
|
||||
/// 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<Agent*>).
|
||||
/// 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,6 +461,7 @@ internal sealed class MsvcDemangler
|
||||
{
|
||||
pos++; // skip Z - this terminates the function pointer, not the outer param list
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -7,8 +7,8 @@ namespace Daybreak.Shared.Models.Guildwars;
|
||||
/// Definition of a Guild Wars skill. The static skill collections (per-campaign
|
||||
/// lists, <see cref="AllSkills"/>, individual <c>public static readonly Skill X</c>
|
||||
/// fields) are generated by <c>Tools/SkillUpdater</c> into <c>Skill.g.cs</c> from
|
||||
/// the official wiki. Re-run the tool with
|
||||
/// <c>dotnet run --project Tools/SkillUpdater</c> to refresh.
|
||||
/// the GWToolbox API, with names and icons from the official wiki. Re-run the
|
||||
/// tool with <c>dotnet run --project Tools/SkillUpdater</c> to refresh.
|
||||
/// </summary>
|
||||
public sealed partial class Skill : IIconUrlEntity
|
||||
{
|
||||
|
||||
+2244
-2305
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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
Submodule Daybreak.wiki updated: 62452af26d...936d25cf80
+23
-20
@@ -279,6 +279,7 @@ namespace GW {
|
||||
|
||||
constexpr int Winnowing = 2926;
|
||||
constexpr int EoE = 2927;
|
||||
constexpr int Symbiosis = 2930;
|
||||
constexpr int FrozenSoil = 2933;
|
||||
constexpr int QZ = 2937;
|
||||
|
||||
@@ -454,6 +455,8 @@ namespace GW {
|
||||
|
||||
constexpr int LockedChest = 8192; // this is actually ->ExtraType
|
||||
|
||||
constexpr int VarnyTheZealot = 8258;
|
||||
|
||||
namespace Minipet {
|
||||
constexpr int MiniatureConfessorDorian = 8344;
|
||||
constexpr int MiniaturePrincessSalma = 8349;
|
||||
@@ -467,26 +470,26 @@ namespace GW {
|
||||
} // namespace Minipet
|
||||
|
||||
namespace SummoningStone {
|
||||
constexpr int ImperialCripplingSlash = 9043;
|
||||
constexpr int ImperialTripleChop = 9044;
|
||||
constexpr int ImperialBarrage = 9045;
|
||||
constexpr int ImperialQuiveringBlade = 9046;
|
||||
constexpr int TenguHundredBlades = 9047;
|
||||
constexpr int TenguBroadHeadArrow = 9048;
|
||||
constexpr int TenguPalmStrike = 9049;
|
||||
constexpr int TenguLifeSheath = 9050;
|
||||
constexpr int TenguAngchuElementalist = 9051;
|
||||
constexpr int TenguFeveredDreams = 9052;
|
||||
constexpr int TenguSpitefulSpirit = 9053;
|
||||
constexpr int TenguPreservation = 9054;
|
||||
constexpr int TenguPrimalRage = 9055;
|
||||
constexpr int TenguGlassArrows = 9056;
|
||||
constexpr int TenguWayOftheAssassin = 9057;
|
||||
constexpr int TenguPeaceandHarmony = 9058;
|
||||
constexpr int TenguSandstorm = 9059;
|
||||
constexpr int TenguPanic = 9060;
|
||||
constexpr int TenguAuraOftheLich = 9061;
|
||||
constexpr int TenguDefiantWasXinrae = 9062;
|
||||
constexpr int ImperialCripplingSlash = 9268;
|
||||
constexpr int ImperialTripleChop = 9269;
|
||||
constexpr int ImperialBarrage = 9270;
|
||||
constexpr int ImperialQuiveringBlade = 9271;
|
||||
constexpr int TenguHundredBlades = 9272;
|
||||
constexpr int TenguBroadHeadArrow = 9273;
|
||||
constexpr int TenguPalmStrike = 9274;
|
||||
constexpr int TenguLifeSheath = 9275;
|
||||
constexpr int TenguAngchuElementalist = 9276;
|
||||
constexpr int TenguFeveredDreams = 9277;
|
||||
constexpr int TenguSpitefulSpirit = 9278;
|
||||
constexpr int TenguPreservation = 9279;
|
||||
constexpr int TenguPrimalRage = 9280;
|
||||
constexpr int TenguGlassArrows = 9281;
|
||||
constexpr int TenguWayOftheAssassin = 9282;
|
||||
constexpr int TenguPeaceandHarmony = 9283;
|
||||
constexpr int TenguSandstorm = 9284;
|
||||
constexpr int TenguPanic = 9285;
|
||||
constexpr int TenguAuraOftheLich = 9286;
|
||||
constexpr int TenguDefiantWasXinrae = 9287;
|
||||
} // namespace SummoningStone
|
||||
} // namespace ModelID
|
||||
} // namespace Constants
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user