Compare commits

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 13:54:14 +02:00
amacocian 5bf0af61ba Fix commandline arguments with space (#1593) 2026-07-28 13:54:14 +02:00
amacocian 03ed31e85b Fix telemetry (Closes #1591) (#1592) 2026-07-28 13:54:14 +02:00
amacocian b21a4482bd Redact sensitive logs (#1590) 2026-07-28 13:54:14 +02:00
github-actions[bot]andamacocian 2ad8ae79ce Bump version to 0.9.10.15 2026-07-28 13:54:14 +02:00
amacocianandAlexandru Macocian a4be747dd5 Fix more notifications expiration times (#1587)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-07-18 07:00:00 +02:00
amacocianandAlexandru Macocian a6cd5d6f37 Update dependencies (#1585)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-07-18 07:00:00 +02:00
amacocianandAlexandru Macocian 0e80fb7d86 Update GWCA (#1584)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-07-18 07:00:00 +02:00
github-actions[bot]andamacocian 0793f181bb Bump version to 0.9.10.14 2026-07-18 07:00:00 +02:00
amacocian a9ee0424f7 FocusView browser maximize (Closes #1577) (#1578) 2026-07-03 18:45:45 +02:00
amacocian 49f45b6b76 Fix notifications disappearing too soon (Closes #1573) (#1576) 2026-07-03 18:45:45 +02:00
github-actions[bot]andamacocian 4216a03438 Bump version to 0.9.10.13 2026-07-03 18:45:45 +02:00
amacocianandAlexandru Macocian a9a8346a5b Fix character select (Closes #1561) (#1571)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-30 17:09:16 +02:00
amacocianandAlexandru Macocian 70a95b5e08 Executable list eviction limit only on invalid exes (#1570)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-30 17:09:16 +02:00
amacocianandAlexandru Macocian 1e8c2e44e2 Fix Kill command on Linux (Closes #1564) (#1569)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-30 17:09:16 +02:00
amacocianandAlexandru Macocian d553aadead Update skills after patch update (Closes #1565) (#1568)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-30 17:09:16 +02:00
amacocianandAlexandru Macocian fabb98f74e Improve exe matching on Linux (#1567)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-30 17:09:16 +02:00
amacocianandAlexandru Macocian 0a0ec5cefc Fix multibox support on Linux (#1566)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-30 17:09:16 +02:00
amacocianandAlexandru Macocian 0d31587855 Revert GWCA static linking (Closes #1562) (#1563)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-30 17:09:16 +02:00
github-actions[bot]andamacocian f4a08e36dd Bump version to 0.9.10.12 2026-06-30 17:09:16 +02:00
amacocian c71dd6ed9c Update docs (#1559) 2026-06-29 22:36:44 +02:00
amacocianandAlexandru Macocian a61cff8dfd Focus View Grid layout and resizable components (#1558) (Closes #1552)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-29 22:36:44 +02:00
amacocianandAlexandru Macocian 9d7f145c25 Sign windows binaries (#1557)
* Sign windows binaries

* Fix temp test

* Remove temporary test

---------

Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-29 22:36:44 +02:00
amacocianandAlexandru Macocian dc340189d6 Improve x11 support in Linux with GDK X11 fallback and scale support (#1556) (Closes #1551)
* Fix GDK X11 support

* Better scaling support for Linux

---------

Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-29 22:36:44 +02:00
amacocianandAlexandru Macocian 5edb5ff780 Update dependencies + move to StbImageSharp (#1555)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-29 22:36:44 +02:00
amacocianandAlexandru Macocian 890bb26c01 Make Daybreak.API compile statically against GWCA (#1554)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-29 22:36:44 +02:00
amacocianandAlexandru Macocian 6c2574c722 Debug scripts improvements (#1553)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-29 22:36:44 +02:00
dependabot[bot]amacociandependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8760f743ab Bump Microsoft.Web.WebView2 from 1.0.3967.48 to 1.0.4022.49 (#1548)
---
updated-dependencies:
- dependency-name: Microsoft.Web.WebView2
  dependency-version: 1.0.4022.49
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-29 22:36:44 +02:00
dependabot[bot]andamacocian 97da183bd3 Bump actions/checkout from 6 to 7 (#1549) 2026-06-29 22:36:44 +02:00
github-actions[bot]andamacocian 65439f25dc Bump version to 0.9.10.11 2026-06-29 22:36:44 +02:00
amacocian a755319fb1 Add value to inventory service response (Closes #1545) (#1546) 2026-06-03 22:09:58 +02:00
amacocian 7e72cb3d3f Add more testing coverage (#1543) 2026-06-03 22:09:58 +02:00
amacocian ca154827b2 Setup linux api build and some tests (#1542) 2026-06-03 22:09:58 +02:00
amacocian 089a10a056 Reorganize skills (#1541)
* Reorganize skills

* Minor fixes
2026-06-03 22:09:58 +02:00
amacocian 65767d6ab5 Fix build paths for linux (Closes #1539) (#1540) 2026-06-03 22:09:58 +02:00
github-actions[bot]andamacocian 5828263717 Bump version to 0.9.10.10 2026-06-03 22:09:58 +02:00
amacocian 05f73cb306 GWCA fixes (#1538) 2026-05-13 14:37:06 +02:00
amacocian 0271e6b08c Fix gwca generator (#1537) 2026-05-13 14:37:06 +02:00
amacocian 640f28584e Implement logsview filter (#1535) 2026-05-13 14:37:06 +02:00
amacocian dd89e3316e Improve toolbox integration (Closes #1532) (#1534) 2026-05-13 14:37:06 +02:00
dependabot[bot]amacociandependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
10fb70787b Bump Microsoft.Web.WebView2 from 1.0.3912.50 to 1.0.3967.48 (#1533)
---
updated-dependencies:
- dependency-name: Microsoft.Web.WebView2
  dependency-version: 1.0.3967.48
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-13 14:37:06 +02:00
github-actions[bot]andamacocian 1393223826 Bump version to 0.9.10.9 2026-05-13 14:37:06 +02:00
amacocian b23907025d Update dependencies (#1530) 2026-04-29 15:48:06 +02:00
amacocian 09aed18d9d Fix executable update (Closes #1526) (#1529) 2026-04-29 15:48:06 +02:00
amacocian b7d58f5e4c Update GWCA (Closes #1527) (#1528) 2026-04-29 15:48:06 +02:00
github-actions[bot]andamacocian 9888bc741f Bump version to 0.9.10.8 2026-04-29 15:48:06 +02:00
amacocian c1b2ed7548 Revert Daybreak placeholder (#1522) 2026-04-20 14:01:37 +02:00
github-actions[bot]andamacocian c23a44a313 Bump version to 0.9.10.7 2026-04-20 14:01:37 +02:00
amacocian b217f183cb Replace Swagger UI with Scalar and OpenApi (Closes #1517) (#1520) 2026-04-20 12:03:58 +02:00
amacocian d24a0a49e8 Update GWCA (Closes #1515) (#1519) 2026-04-20 12:03:58 +02:00
amacocian cdd620a786 Update dependencies (#1518) 2026-04-20 12:03:58 +02:00
dependabot[bot]amacociandependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
abbf87184b Bump vers-one/dotnet-project-version-updater from 1.7 to 1.8 (#1508)
Bumps [vers-one/dotnet-project-version-updater](https://github.com/vers-one/dotnet-project-version-updater) from 1.7 to 1.8.
- [Release notes](https://github.com/vers-one/dotnet-project-version-updater/releases)
- [Commits](https://github.com/vers-one/dotnet-project-version-updater/compare/v1.7...v1.8)

---
updated-dependencies:
- dependency-name: vers-one/dotnet-project-version-updater
  dependency-version: '1.8'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 12:03:58 +02:00
dependabot[bot]amacociandependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
15fa4520aa Bump Microsoft.CodeAnalysis.Analyzers from 5.3.0-2.25625.1 to 5.3.0 (#1509)
---
updated-dependencies:
- dependency-name: Microsoft.CodeAnalysis.Analyzers
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 12:03:58 +02:00
dependabot[bot]amacociandependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
bc963f486a Bump actions/github-script from 8 to 9 (#1511)
Bumps [actions/github-script](https://github.com/actions/github-script) from 8 to 9.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v8...v9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 12:03:58 +02:00
dependabot[bot]amacociandependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
66386a6b85 Bump Microsoft.Web.WebView2 from 1.0.3800.47 to 1.0.3912.50 (#1512)
---
updated-dependencies:
- dependency-name: Microsoft.Web.WebView2
  dependency-version: 1.0.3912.50
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 12:03:58 +02:00
amacocian 8d34d5d774 Make character name whitespace (Closes #1507) (#1516) 2026-04-20 12:03:58 +02:00
amacocian d5c8e7ad03 Remove reloaded dependency (use native bytecode) (#1514) 2026-04-20 12:03:58 +02:00
github-actions[bot]andamacocian 8eba2b92dd Bump version to 0.9.10.6 2026-04-20 12:03:58 +02:00
amacocianandAlexandru Macocian d9bd9d5395 Update GWCA (#1505)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-03-13 15:13:57 +01:00
amacocianandAlexandru Macocian a59f88d396 Fix launch configuration reordering (Closes #1488) (#1504)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-03-13 15:13:57 +01:00
amacocianandAlexandru Macocian 3799108f7e Fix ReShade deleting files when disabled and not installed (Closes #1500) (#1503)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-03-13 15:13:57 +01:00
dependabot[bot]amacociandependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a7f0f31a94 Bump Microsoft.CodeAnalysis.Analyzers and Microsoft.CodeAnalysis.CSharp (#1501)
Bumps Microsoft.CodeAnalysis.Analyzers from 3.11.0 to 5.3.0-2.25625.1
Bumps Microsoft.CodeAnalysis.CSharp from 5.0.0 to 5.3.0

---
updated-dependencies:
- dependency-name: Microsoft.CodeAnalysis.Analyzers
  dependency-version: 5.3.0-2.25625.1
  dependency-type: direct:production
  update-type: version-update:semver-major
- dependency-name: Microsoft.CodeAnalysis.CSharp
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 15:13:57 +01:00
amacocian 6ffa774270 Update GWCA (Closes #1494 Closes #1483) (#1498) 2026-03-13 15:13:57 +01:00
amacocianandAlexandru Macocian 2124ae92b4 Fix template loading from API (#1495)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-03-13 15:13:57 +01:00
dependabot[bot]amacociandependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
00312a6367 Bump actions/download-artifact from 7 to 8 (#1492)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 15:13:57 +01:00
dependabot[bot]amacociandependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
df0d32bc30 Bump actions/upload-artifact from 6 to 7 (#1493)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 15:13:57 +01:00
Brendan McCoyandamacocian ecd19b0324 Add dye tint support to inventory item model (#1490)
Switched to using GWCA's GetBagByIndex for safer bag pointer access.
2026-03-13 15:13:57 +01:00
github-actions[bot]andamacocian aa2e9905c1 Bump version to 0.9.10.5 2026-03-13 15:13:57 +01:00
274 changed files with 22676 additions and 10284 deletions
+71
View File
@@ -0,0 +1,71 @@
# Copilot instructions for Daybreak
Daybreak is a cross-platform Guild Wars launcher built on .NET 10 and
Photino.Blazor. Read the
[Architecture Overview](../README.md#architecture-overview) for the project
layout and [CONTRIBUTING.md](../CONTRIBUTING.md) for the branching and release
process.
## Workflow
- Branch off the current `release/[version]` branch, never off `master`.
- One feature or fix per branch and per PR.
- Never push to `master` and never edit the version in `Directory.Build.props`.
Both are owned by the release workflows.
- Title PRs `Short description (Closes #123)` so the linked issue gets labelled.
## Build and test
CI does not run on PRs targeting a release branch, so validate locally. Run the
smallest command that covers the change:
```bash
dotnet build Daybreak.Linux/Daybreak.Linux.csproj # or Daybreak.Windows
dotnet test Daybreak.Tests/Daybreak.Tests.csproj --filter "FullyQualifiedName~YourTests"
```
Keep the build warning-free.
## Where code goes
| Change | Project |
| -------------------------------------------------- | ------------------------------------ |
| Models, utilities, interfaces shared by everything | `Daybreak.Shared` |
| Blazor views and services | `Daybreak.Core` |
| Platform-specific implementations | `Daybreak.Windows`, `Daybreak.Linux` |
`Daybreak.Tests` only references `Daybreak.Core`. When adding platform code, put
the logic worth testing in `Daybreak.Shared` and leave the platform project as a
thin caller.
Add NuGet packages through `Directory.Packages.props`.
## C# conventions
`.editorconfig` is the source of truth. The projects are set up to treat
warnings as errors.
## Verify before claiming
Prefer evidence over reasoning about behaviour, especially for the Wine and
injection paths. Wine is scriptable, so reproduce the actual failure and confirm
the fix against it rather than inferring what a Win32 call does.
## Writing style
Applies to docs, comments, PR descriptions and answers.
- Be short. Cut filler, recap and process narration.
- No AI-isms and no inflated language.
- Do not paste code into documentation. Link to the file so there is only one
copy of it.
- Explain the reason for a change, not a summary of the diff.
## Markdown
- No inline HTML. Use `![alt][ref]` with a reference definition instead of an
`img` tag.
- Start a mermaid fence with the diagram type, for example `flowchart LR`. YAML
frontmatter inside the fence is rejected by some renderers.
- Long URLs belong in reference-style link definitions at the bottom of the
file.
+28
View File
@@ -0,0 +1,28 @@
# Pre-configure SimplySign Desktop for unattended use:
# - auto-show the login dialog on launch (so the TOTP keystrokes have a target)
# - cache the smart-card PIN in the CSP, so signtool signs without a per-call
# PIN prompt, and forget it again when the session disconnects.
# Mirrors the settings used by the blinkdisk / docuscope CI signing setups.
$ErrorActionPreference = "Stop"
$RegistryPath = "HKCU:\Software\Certum\SimplySign"
$settings = [ordered]@{
ShowLoginDialogOnStart = 1
ShowLoginDialogOnAppRequest = 1
RememberLastUserName = 0
Autostart = 0
UnregisterCertificatesOnDisconnect = 0
RememberPINinCSP = 1
ForgetPINinCSPonDisconnect = 1
LangID = 9
}
Write-Host "=== Configuring SimplySign Desktop registry ==="
New-Item -Path $RegistryPath -Force | Out-Null
foreach ($name in $settings.Keys) {
Set-ItemProperty -Path $RegistryPath -Name $name -Value $settings[$name] -Type DWord
Write-Host " $name = $($settings[$name])"
}
Write-Host "Done."
+245
View File
@@ -0,0 +1,245 @@
# Authenticate SimplySign Desktop non-interactively.
#
# Certum's SimplySign cloud has no headless login: the certificate only reaches
# the Windows store after the GUI client authenticates. So we generate the
# current TOTP from the otpauth:// secret (CERTUM_OTP_URI) and paste the
# credentials into the login dialog. This needs an interactive desktop session,
# which GitHub-hosted Windows runners provide.
#
# Based on https://www.devas.life/how-to-automate-signing-your-windows-app-with-certum/
# and the refinements in blinkdisk's connect-simplysign.ps1.
param(
[string]$OtpUri = $env:CERTUM_OTP_URI,
[string]$UserId = $env:CERTUM_USERID,
[string]$ExePath = $env:CERTUM_EXE_PATH
)
if (-not $OtpUri) { Write-Host "ERROR: CERTUM_OTP_URI not provided"; exit 1 }
if (-not $UserId) { Write-Host "ERROR: CERTUM_USERID not provided"; exit 1 }
if (-not $ExePath) {
$ExePath = "C:\Program Files\Certum\SimplySign Desktop\SimplySignDesktop.exe"
}
Write-Host "=== SimplySign Desktop TOTP authentication ==="
if (-not (Test-Path $ExePath)) {
Write-Host "ERROR: SimplySign Desktop not found at $ExePath"
exit 1
}
# --- Parse the otpauth:// URI (works on PowerShell 5.1 and 7+) ---------------
$uri = [Uri]$OtpUri
try {
$q = [System.Web.HttpUtility]::ParseQueryString($uri.Query)
} catch {
$q = @{}
foreach ($part in $uri.Query.TrimStart('?') -split '&') {
$kv = $part -split '=', 2
if ($kv.Count -eq 2) { $q[$kv[0]] = [Uri]::UnescapeDataString($kv[1]) }
}
}
$Base32 = $q['secret']
$Digits = if ($q['digits']) { [int]$q['digits'] } else { 6 }
$Period = if ($q['period']) { [int]$q['period'] } else { 30 }
$Algorithm = if ($q['algorithm']) { $q['algorithm'].ToUpper() } else { 'SHA256' }
if (-not $Base32) { Write-Host "ERROR: otpauth URI has no 'secret'"; exit 1 }
if ($Algorithm -notin @('SHA1', 'SHA256', 'SHA512')) {
Write-Host "ERROR: unsupported TOTP algorithm: $Algorithm"
exit 1
}
# --- TOTP generator (RFC 6238), inline C# so there are no dependencies --------
Add-Type -Language CSharp @"
using System;
using System.Security.Cryptography;
public static class Totp
{
private const string B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
private static byte[] Base32Decode(string s)
{
s = s.TrimEnd('=').ToUpperInvariant();
byte[] bytes = new byte[s.Length * 5 / 8];
int bitBuffer = 0, bitsLeft = 0, idx = 0;
foreach (char c in s)
{
int val = B32.IndexOf(c);
if (val < 0) throw new ArgumentException("Invalid Base32 char: " + c);
bitBuffer = (bitBuffer << 5) | val;
bitsLeft += 5;
if (bitsLeft >= 8) { bytes[idx++] = (byte)(bitBuffer >> (bitsLeft - 8)); bitsLeft -= 8; }
}
return bytes;
}
private static HMAC Hmac(string algorithm, byte[] key)
{
switch (algorithm.ToUpper())
{
case "SHA1": return new HMACSHA1(key);
case "SHA256": return new HMACSHA256(key);
case "SHA512": return new HMACSHA512(key);
default: throw new ArgumentException("Unsupported algorithm: " + algorithm);
}
}
public static string Now(string secret, int digits, int period, string algorithm)
{
byte[] key = Base32Decode(secret);
long counter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / period;
byte[] cnt = BitConverter.GetBytes(counter);
if (BitConverter.IsLittleEndian) Array.Reverse(cnt);
byte[] hash;
using (var h = Hmac(algorithm, key)) { hash = h.ComputeHash(cnt); }
int offset = hash[hash.Length - 1] & 0x0F;
int binary =
((hash[offset] & 0x7F) << 24) |
((hash[offset + 1] & 0xFF) << 16) |
((hash[offset + 2] & 0xFF) << 8) |
(hash[offset + 3] & 0xFF);
int otp = binary % (int)Math.Pow(10, digits);
return otp.ToString(new string('0', digits));
}
}
"@
function Find-UiByName($el, $walker, $name) {
$c = $walker.GetFirstChild($el)
while ($c) {
if ($c.Current.Name -eq $name) { return $c }
$r = Find-UiByName $c $walker $name
if ($r) { return $r }
$c = $walker.GetNextSibling($c)
}
return $null
}
# An outdated SimplySign build pops a modal "New version found - download?" box
# over the login form, which swallows the credential keystrokes. Decline it by
# clicking "No" (Invoke / legacy default action / click the element's point).
function Dismiss-UpdatePrompt {
try { Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes -ErrorAction Stop } catch { return $false }
if (-not ('Win32Mouse' -as [type])) {
Add-Type @"
using System;
using System.Runtime.InteropServices;
public static class Win32Mouse {
[DllImport("user32.dll")] static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] static extern void mouse_event(uint f, uint x, uint y, uint d, int e);
public static void Click(int x, int y){ SetCursorPos(x,y); mouse_event(0x02,0,0,0,0); mouse_event(0x04,0,0,0,0); }
}
"@
}
$procIds = @((Get-Process -Name '*SimplySign*' -ErrorAction SilentlyContinue).Id)
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
$top = $walker.GetFirstChild([System.Windows.Automation.AutomationElement]::RootElement)
while ($top) {
if ($procIds -contains $top.Current.ProcessId) {
$no = Find-UiByName $top $walker "No"
if ($no) {
Write-Host "Update prompt detected; declining (clicking 'No')."
try { ($no.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern)).Invoke(); return $true } catch {}
try { ($no.GetCurrentPattern([System.Windows.Automation.LegacyIAccessiblePattern]::Pattern)).DoDefaultAction(); return $true } catch {}
try { $pt = $no.GetClickablePoint(); [Win32Mouse]::Click([int]$pt.X, [int]$pt.Y); return $true } catch { Write-Host "Click 'No' failed: $($_.Exception.Message)" }
}
}
$top = $walker.GetNextSibling($top)
}
return $false
}
# Start from a clean slate so a stale window/session can't swallow the keystrokes.
if ($existing = Get-Process -Name "SimplySignDesktop" -ErrorAction Ignore) {
Write-Host "Killing existing SimplySignDesktop process..."
$existing | Stop-Process -Force
Start-Sleep -Seconds 1
}
Write-Host "Launching SimplySign Desktop..."
$proc = Start-Process -FilePath $ExePath -PassThru
Start-Sleep -Seconds 3
$wshell = New-Object -ComObject WScript.Shell
Write-Host "Focusing the login window..."
$focused = $wshell.AppActivate($proc.Id)
if (-not $focused) { $focused = $wshell.AppActivate('SimplySign Desktop') }
for ($i = 0; (-not $focused) -and ($i -lt 10); $i++) {
Start-Sleep -Milliseconds 500
$focused = $wshell.AppActivate($proc.Id) -or $wshell.AppActivate('SimplySign Desktop')
}
if (-not $focused) {
Write-Host "ERROR: could not bring SimplySign Desktop to the foreground"
exit 1
}
# Decline the "new version available" modal if it's covering the login form.
if (Dismiss-UpdatePrompt) { Start-Sleep -Milliseconds 800 }
# Re-assert focus: dismissing the dialog can move the foreground window, and
# keystrokes sent to the wrong window are silently dropped.
$wshell.AppActivate($proc.Id) | Out-Null
$wshell.AppActivate('SimplySign Desktop') | Out-Null
Start-Sleep -Milliseconds 400
# Paste rather than type: SendKeys mangles characters like + ^ % ( ) and can
# drop characters; pasting delivers the exact string. Falls back to typing if
# the clipboard is unavailable.
function Set-Field([string]$text) {
try {
Set-Clipboard -Value $text -ErrorAction Stop
Start-Sleep -Milliseconds 150
$wshell.SendKeys("^v")
} catch {
Write-Host "Clipboard unavailable ($($_.Exception.Message)); typing instead."
$wshell.SendKeys($text)
}
Start-Sleep -Milliseconds 250
}
Write-Host "Injecting credentials..."
Set-Field $UserId
$wshell.SendKeys("{TAB}")
Start-Sleep -Milliseconds 200
# Generate the code right before sending it so it can't expire while we focus.
$otp = [Totp]::Now($Base32, $Digits, $Period, $Algorithm)
Set-Field $otp
Start-Sleep -Milliseconds 200
$wshell.SendKeys("{ENTER}")
# Don't leave the OTP / username sitting on the clipboard afterwards.
try { Set-Clipboard -Value " " -ErrorAction Stop } catch {}
Write-Host "Waiting for authentication to settle..."
Start-Sleep -Seconds 5
if (-not (Get-Process -Id $proc.Id -ErrorAction SilentlyContinue)) {
Write-Host "ERROR: SimplySign Desktop exited - authentication failed."
exit 1
}
# Confirm the login actually mounted the signing certificate into the store.
$thumb = if ($env:CERTUM_CERT_SHA1) { ($env:CERTUM_CERT_SHA1 -replace '\s', '').ToUpperInvariant() } else { $null }
Write-Host "Verifying the signing certificate reached the store..."
$deadline = (Get-Date).AddSeconds(60)
$found = $false
do {
$certs = Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My -ErrorAction SilentlyContinue
if ($thumb) { $found = [bool]($certs | Where-Object { $_.Thumbprint -eq $thumb }) }
else { $found = [bool]($certs | Where-Object { $_.Subject -like '*Certum*' -or $_.Issuer -like '*Certum*' }) }
if ($found) { break }
Start-Sleep -Seconds 3
} while ((Get-Date) -lt $deadline)
if ($found) {
Write-Host "=== Authentication complete: signing certificate is available. ==="
} else {
Write-Host "ERROR: signing certificate did not appear after authentication."
exit 1
}
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# Install Certum SimplySign Desktop on the (ephemeral) Windows runner.
# SimplySign Desktop is what mounts the cloud signing certificate as a virtual
# smart card into the Windows certificate store; signtool then selects it by
# thumbprint. The version + checksum are pinned for reproducibility.
set -euo pipefail
VERSION="9.3.4.72"
URL="https://files.certum.eu/software/SimplySignDesktop/Windows/${VERSION}/SimplySignDesktop-${VERSION}-64-bit-en.msi"
EXPECTED_SHA256="bd51ebbaaac20fc7d59ab7103b5ed532b7800586df4e31f6999d03a394f9c515"
INSTALLER="SimplySignDesktop.msi"
INSTALL_DIR="/c/Program Files/Certum/SimplySign Desktop"
echo "=== Installing Certum SimplySign Desktop ${VERSION} ==="
# Idempotent: a cached/pre-provisioned image may already have it.
if [ -d "$INSTALL_DIR" ]; then
echo "Already installed at: $INSTALL_DIR"
exit 0
fi
echo "Downloading installer..."
curl -L "$URL" -o "$INSTALLER" --fail --max-time 600
ACTUAL_SHA256=$(sha256sum "$INSTALLER" | awk '{print $1}')
if [ "$EXPECTED_SHA256" != "$ACTUAL_SHA256" ]; then
echo "ERROR: checksum mismatch"
echo " expected: $EXPECTED_SHA256"
echo " actual: $ACTUAL_SHA256"
exit 1
fi
echo "Checksum verified."
echo "Running msiexec (silent)..."
# Start-Process inherits this CWD, so the relative installer path resolves here.
powershell -Command "Start-Process msiexec.exe -ArgumentList '/i','${INSTALLER}','/quiet','/norestart','/l*v','install.log','ALLUSERS=1','REBOOT=ReallySuppress' -Wait -NoNewWindow"
if [ ! -d "$INSTALL_DIR" ]; then
echo "ERROR: installation directory not found after install"
echo "Last 20 lines of install.log:"
tail -20 install.log 2>/dev/null || echo "(no install.log)"
exit 1
fi
echo "SimplySign Desktop installed."
+66
View File
@@ -0,0 +1,66 @@
# Sign the given files with the Certum cloud certificate (selected by SHA1
# thumbprint from the store) and verify each signature. Assumes SimplySign
# Desktop has already authenticated (connect-simplysign.ps1).
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string[]]$Files,
[string]$CertSha1 = $env:CERTUM_CERT_SHA1,
[string]$TimestampUrl = "http://time.certum.pl"
)
$ErrorActionPreference = "Stop"
if (-not $CertSha1) { throw "CERTUM_CERT_SHA1 not provided." }
$thumb = ($CertSha1 -replace '\s', '').ToUpperInvariant()
# Resolve the newest signtool.exe from the installed Windows SDK(s).
$kitsBin = "C:\Program Files (x86)\Windows Kits\10\bin"
$signtool = Get-ChildItem -Path $kitsBin -Recurse -Filter signtool.exe -File -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -like '*\x64\*' } |
Sort-Object { [version]$_.Directory.Parent.Name } |
Select-Object -Last 1 -ExpandProperty FullName
if (-not $signtool) { throw "signtool.exe not found under $kitsBin" }
Write-Host "Using signtool: $signtool"
# The SimplySign virtual smart card registers in the store a moment after the
# desktop client authenticates; poll for our certificate before signing.
Write-Host "Waiting for certificate $thumb to appear in the store..."
$deadline = (Get-Date).AddSeconds(120)
$cert = $null
$elapsed = 0
do {
$cert = Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My -ErrorAction SilentlyContinue |
Where-Object { $_.Thumbprint -eq $thumb } | Select-Object -First 1
if ($cert) { break }
Start-Sleep -Seconds 3
$elapsed += 3
Write-Host " ...still waiting (${elapsed}s)"
} while ((Get-Date) -lt $deadline)
if (-not $cert) {
Write-Host "Certificate not found. CurrentUser\My + LocalMachine\My contents:"
Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My -ErrorAction SilentlyContinue |
Select-Object Thumbprint, Subject, HasPrivateKey | Format-Table -AutoSize | Out-String | Write-Host
Write-Host "SimplySign processes:"
Get-Process -Name '*SimplySign*' -ErrorAction SilentlyContinue |
Select-Object Name, Id, Responding, MainWindowTitle | Format-Table -AutoSize | Out-String | Write-Host
throw "Certificate $thumb not found in the store - SimplySign authentication likely failed (see dump above)."
}
Write-Host "Found signing certificate: $($cert.Subject)"
$failed = @()
foreach ($file in $Files) {
if (-not (Test-Path $file)) { throw "File to sign not found: $file" }
Write-Host "=== Signing $file ==="
& $signtool sign /sha1 $thumb /fd sha256 /td sha256 /tr $TimestampUrl /v $file
if ($LASTEXITCODE -ne 0) { $failed += $file; continue }
& $signtool verify /pa /v $file
if ($LASTEXITCODE -ne 0) { $failed += $file }
}
if ($failed.Count -gt 0) {
throw "Signing/verification failed for: $($failed -join ', ')"
}
Write-Host "All binaries signed and verified."
+4
View File
@@ -13,10 +13,14 @@ on:
- "Daybreak.Shared/**"
- "Daybreak.Windows/**"
- "Daybreak.Linux/**"
- ".github/scripts/**"
- ".github/workflows/cd-template.yml"
- ".github/workflows/cd-dry-run.yml"
jobs:
cd:
uses: ./.github/workflows/cd-template.yml
with:
publishRelease: false
signBinaries: false
secrets: inherit
+66 -9
View File
@@ -9,6 +9,11 @@ on:
required: false
type: boolean
default: false
signBinaries:
description: "Whether to code-sign Windows binaries"
required: false
type: boolean
default: false
jobs:
# ───────────────────────────────────────────────────────
@@ -16,7 +21,7 @@ jobs:
# (Injector + API) that both platforms need.
# ───────────────────────────────────────────────────────
build-x86:
runs-on: windows-latest
runs-on: windows-2025
outputs:
version: ${{ steps.get_version.outputs.version }}
@@ -24,10 +29,13 @@ jobs:
env:
Configuration: Release
Actions_Allow_Unsecure_Commands: true
CERTUM_OTP_URI: ${{ secrets.CERTUM_OTP_URI }}
CERTUM_USERID: ${{ secrets.CERTUM_USERID }}
CERTUM_CERT_SHA1: ${{ secrets.CERTUM_CERT_SHA1 }}
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: recursive
@@ -40,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"
@@ -48,6 +56,30 @@ jobs:
run: .\Scripts\PublishWindowsX86.ps1
shell: pwsh
# Certum SimplySign code signing. Binaries are signed only when explicitly
# requested and the Certum secrets are provisioned.
- name: Set up Certum SimplySign
if: inputs.signBinaries && env.CERTUM_OTP_URI != ''
shell: bash
run: |
chmod +x ./.github/scripts/install-simplysign.sh
./.github/scripts/install-simplysign.sh
powershell -ExecutionPolicy Bypass -File "./.github/scripts/configure-simplysign.ps1"
- name: Authenticate Certum SimplySign
if: inputs.signBinaries && env.CERTUM_OTP_URI != ''
shell: bash
run: powershell -ExecutionPolicy Bypass -File "./.github/scripts/connect-simplysign.ps1"
- name: Sign x86 binaries
if: inputs.signBinaries && env.CERTUM_OTP_URI != ''
run: |
./.github/scripts/sign-certum.ps1 -Files @(
"Publish/Injector/Daybreak.Injector.exe",
"Publish/Api/Daybreak.API.dll"
)
shell: pwsh
- name: Upload x86 artifacts
uses: actions/upload-artifact@v7
with:
@@ -62,21 +94,24 @@ jobs:
# ───────────────────────────────────────────────────────
build-windows:
needs: build-x86
runs-on: windows-latest
runs-on: windows-2025
env:
Configuration: Release
Actions_Allow_Unsecure_Commands: true
CERTUM_OTP_URI: ${{ secrets.CERTUM_OTP_URI }}
CERTUM_USERID: ${{ secrets.CERTUM_USERID }}
CERTUM_CERT_SHA1: ${{ secrets.CERTUM_CERT_SHA1 }}
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: recursive
- name: Install .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
@@ -98,6 +133,28 @@ jobs:
run: .\Scripts\PublishWindows.ps1
shell: pwsh
- name: Set up Certum SimplySign
if: inputs.signBinaries && env.CERTUM_OTP_URI != ''
shell: bash
run: |
chmod +x ./.github/scripts/install-simplysign.sh
./.github/scripts/install-simplysign.sh
powershell -ExecutionPolicy Bypass -File "./.github/scripts/configure-simplysign.ps1"
- name: Authenticate Certum SimplySign
if: inputs.signBinaries && env.CERTUM_OTP_URI != ''
shell: bash
run: powershell -ExecutionPolicy Bypass -File "./.github/scripts/connect-simplysign.ps1"
- name: Sign Windows build
if: inputs.signBinaries && env.CERTUM_OTP_URI != ''
run: |
./.github/scripts/sign-certum.ps1 -Files @(
"Publish/Daybreak.exe",
"Publish/Installer/Daybreak.Installer.exe"
)
shell: pwsh
- name: Upload Windows build
uses: actions/upload-artifact@v7
with:
@@ -114,13 +171,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: recursive
- name: Install .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
@@ -228,7 +285,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: recursive
+4
View File
@@ -15,6 +15,9 @@ on:
- "Daybreak.Shared/**"
- "Daybreak.Windows/**"
- "Daybreak.Linux/**"
- ".github/scripts/**"
- ".github/workflows/cd-template.yml"
- ".github/workflows/cd.yaml"
workflow_dispatch:
jobs:
@@ -22,4 +25,5 @@ jobs:
uses: ./.github/workflows/cd-template.yml
with:
publishRelease: true
signBinaries: true
secrets: inherit
+4 -4
View File
@@ -16,13 +16,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: recursive
- name: Install .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
@@ -49,13 +49,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: recursive
- name: Install .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
@@ -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'
@@ -18,7 +18,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_PAT }}
@@ -30,7 +30,7 @@ jobs:
- name: Bump version in Directory.Build.props
id: bump
uses: vers-one/dotnet-project-version-updater@v1.7
uses: vers-one/dotnet-project-version-updater@v1.8
with:
file: "Directory.Build.props"
version: ${{ inputs.versionBump }}
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
steps:
- name: Label linked issues as merged
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
+2 -2
View File
@@ -27,12 +27,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Install .NET Core
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
+2 -2
View File
@@ -181,8 +181,8 @@ DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Click-Once / local publish directories
[Pp]ublish/
# Publish Web Output
*.[Pp]ublish.xml
+108
View File
@@ -0,0 +1,108 @@
-- Per-project Neovim config for the Daybreak repo.
-- Auto-sourced when nvim is started inside the repo, provided the user has
-- `vim.o.exrc = true` in their config (or runs `:set exrc`). Neovim 0.9+
-- prompts to trust this file on first load.
--
-- Commands added:
-- :DaybreakStageX86 [Config] build x86 components via wine and stage
-- them into Daybreak.Linux/bin/<Config>/...
-- /{Injector,Api}/ (default Config: Debug)
-- :DaybreakRunLinux run Daybreak.Linux via `dotnet run` in a
-- terminal split (honors launchSettings.json,
-- which easy-dot-net's launcher does not)
--
-- Note: a Linux/wine attach-debugger workflow was attempted (winedbg --gdb
-- stub *and* native Linux gdb -p attach), but both freeze GW reliably because
-- of ptrace/wineserver interactions that wine doesn't tolerate. For now,
-- prefer logging-based debugging via the API's console + log file in the
-- game directory (Daybreak.API<YYYYMMDD>.log next to Gw.exe).
local repo = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':p:h')
local scripts = repo .. '/Scripts'
-- ───── Progress notification with spinner ──────────────────────────────────
-- Uses nvim-notify / snacks.nvim's `id`/`replace` semantics to update a
-- single notification in place. Falls back to plain notify if the backend
-- doesn't support replace (the spinner just stops updating).
local SPINNER = { '', '', '', '', '', '', '', '' }
local function start_progress(title, message)
-- stable id so snacks.nvim/nvim-notify replace the same notification
local id = 'daybreak-' .. title:gsub('%s+', '-') .. '-' .. tostring(vim.uv.hrtime())
local state = { title = title, msg = message, frame = 1, done = false, id = id }
local function render()
local icon = state.done and '' or (SPINNER[state.frame] .. ' ')
local opts = {
title = state.title,
id = state.id,
timeout = state.done and 4000 or false,
hide_from_history = not state.done,
icon = state.done and '' or nil,
}
if state.notif then opts.replace = state.notif end
state.notif = vim.notify(icon .. state.msg, vim.log.levels.INFO, opts)
end
render()
state.timer = vim.uv.new_timer()
state.timer:start(80, 80, vim.schedule_wrap(function()
if state.done then return end
state.frame = (state.frame % #SPINNER) + 1
render()
end))
return {
update = function(msg) state.msg = msg; render() end,
finish = function(ok, msg)
state.done = true
if state.timer then state.timer:stop(); state.timer:close(); state.timer = nil end
state.msg = msg or state.msg
local final_opts = {
title = state.title,
id = state.id,
timeout = 4000,
icon = ok and '' or '',
}
if state.notif then final_opts.replace = state.notif end
vim.notify((ok and '' or '') .. state.msg,
ok and vim.log.levels.INFO or vim.log.levels.ERROR, final_opts)
end,
}
end
-- ───── :DaybreakStageX86 [Config] ─────────────────────────────────────────
vim.api.nvim_create_user_command('DaybreakStageX86', function(opts)
local config = opts.args ~= '' and opts.args or 'Debug'
local progress = start_progress('Daybreak StageX86', 'Building x86 components (' .. config .. ')…')
vim.fn.jobstart({ scripts .. '/StageX86ForDebug.sh', config, '' }, {
cwd = repo,
on_exit = function(_, code)
vim.schedule(function()
if code == 0 then
progress.finish(true, 'Staged x86 (' .. config .. ')')
else
progress.finish(false, 'Stage failed (exit ' .. code ..
'). See /tmp/wine-injector-publish.log, /tmp/wine-api-publish.log')
end
end)
end,
stdout_buffered = false,
stderr_buffered = false,
})
end, {
nargs = '?',
complete = function() return { 'Debug', 'Release' } end,
desc = 'Build x86 components in wine and stage into Daybreak.Linux output',
})
-- ───── :DaybreakRunLinux ───────────────────────────────────────────────────
-- easy-dot-net launches the produced binary directly, bypassing launchSettings.json.
-- This runs Daybreak.Linux through `dotnet run`, which honors the environment
-- variables (GDK_BACKEND, WEBKIT_DISABLE_DMABUF_RENDERER, etc.) declared there.
-- Opens in the current window (no split) so it integrates with normal buffer
-- navigation; Ctrl-\ Ctrl-N to leave terminal mode, then your usual buffer
-- bindings work.
vim.api.nvim_create_user_command('DaybreakRunLinux', function()
vim.cmd('enew')
vim.fn.termopen({ 'dotnet', 'run', '--project', 'Daybreak.Linux/Daybreak.Linux.csproj', '-c', 'Debug' },
{ cwd = repo })
vim.cmd('startinsert')
end, { desc = 'Run Daybreak.Linux via dotnet run in the current window' })
-8
View File
@@ -1,8 +0,0 @@
{
"recommendations": [
"ms-dotnettools.csharp"
],
"unwantedRecommendations": [
"ms-dotnettools.csdevkit"
]
}
+30 -6
View File
@@ -3,19 +3,43 @@
"configurations": [
{
"name": "Debug Daybreak.Linux",
"type": "coreclr",
"type": "dotnet",
"request": "launch",
"program": "${workspaceFolder}/Daybreak.Linux/bin/x64/Debug/net10.0/linux-x64/Daybreak.dll",
"cwd": "${workspaceFolder}/Daybreak.Linux/bin/x64/Debug/net10.0/linux-x64",
"preLaunchTask": "build-linux",
"projectPath": "${workspaceFolder}/Daybreak.Linux/Daybreak.Linux.csproj",
"console": "integratedTerminal",
"stopAtEntry": false
"justMyCode": true,
"stopAtEntry": false,
"suppressJITOptimizations": false,
"symbolOptions": {
"searchMicrosoftSymbolServer": false,
"searchNuGetOrgSymbolServer": false
},
"logging": {
"engineLogging": false,
"moduleLoad": false,
"exceptions": false,
"browserStdOut": false
}
},
{
"name": "Debug Daybreak.Windows",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/Daybreak.Windows/Daybreak.Windows.csproj"
"projectPath": "${workspaceFolder}/Daybreak.Windows/Daybreak.Windows.csproj",
"console": "integratedTerminal",
"justMyCode": true,
"stopAtEntry": false,
"suppressJITOptimizations": false,
"symbolOptions": {
"searchMicrosoftSymbolServer": false,
"searchNuGetOrgSymbolServer": false
},
"logging": {
"engineLogging": false,
"moduleLoad": false,
"exceptions": false,
"browserStdOut": false
}
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"dotnet.defaultSolution": "Daybreak.slnx",
"dotnet.preferCSharpExtension": true,
"omnisharp.projectLoadTimeout": 120,
"csharp.semanticHighlighting.enabled": true,
"search.followSymlinks": false,
"search.exclude": {
-36
View File
@@ -24,42 +24,6 @@
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/Daybreak.slnx",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/Daybreak.slnx",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/Daybreak.slnx"
],
"problemMatcher": "$msCompile"
}
]
}
+113
View File
@@ -0,0 +1,113 @@
# Contributing to Daybreak
## Getting started
Install the prerequisites listed under
[Build Requirements](README.md#build-requirements), then clone with submodules:
```bash
git clone --recurse-submodules https://github.com/AlexMacocian/Daybreak.git
```
Read the [Architecture Overview](README.md#architecture-overview) before making
changes so you put code in the right project.
## Branching model
There is always exactly one active release branch, named `release/[version]`
(for example `release/0.9.10.20`). It is the integration branch: all work
targets it, and `master` only receives finished releases.
```mermaid
flowchart LR
subgraph cycle["Release cycle"]
direction LR
R["release/0.9.10.20<br/><i>active release branch</i>"]
F["feature branch<br/><i>one per change</i>"]
R -->|fork| F
F -->|"PR, squash merge"| R
end
R -->|"PR, rebase merge"| M["master"]
M --> CD["CD pipeline<br/>builds and publishes the release"]
CD -->|"job bumps the revision"| N["release/0.9.10.21<br/><i>next active release branch</i>"]
```
### Feature work
1. Branch off the current release branch.
2. Implement one feature or fix. Keep the branch scoped to a single change.
3. Open a PR against the release branch.
4. Merge with **squash**, so each feature lands as one commit.
### Releasing
1. Open a PR from the release branch onto `master`.
2. Merge with **rebase**, keeping full history. Each feature stays a single
commit on `master`.
3. The [CD pipeline](.github/workflows/cd.yaml) runs on the push to `master`,
builds the Windows and Linux bundles and publishes a GitHub release.
4. On success,
[create-revision-release-branch.yml](.github/workflows/create-revision-release-branch.yml)
bumps the revision and creates the next `release/[version]` branch
automatically. That becomes the new active release branch.
Do not push directly to `master` or create release branches by hand.
## Versions
The version lives in a single place,
[`Directory.Build.props`](Directory.Build.props), and follows
`major.minor.build.revision`. It is bumped by the release branch creation job,
not manually.
Revision bumps happen automatically after every release. For the other
components, run the matching workflow manually before starting the next cycle:
| Workflow | Bumps |
| ------------------------------------------------------------------------------------------ | --------------------- |
| [create-major-release-branch.yml](.github/workflows/create-major-release-branch.yml) | `^.0.0.0` |
| [create-minor-release-branch.yml](.github/workflows/create-minor-release-branch.yml) | `*.^.0.0` |
| [create-build-release-branch.yml](.github/workflows/create-build-release-branch.yml) | `*.*.^.0` |
| [create-revision-release-branch.yml](.github/workflows/create-revision-release-branch.yml) | `*.*.*.^` (automatic) |
All four delegate to
[create-release-branch-template.yml](.github/workflows/create-release-branch-template.yml).
## Pull requests
Title format follows the existing history: a short description, plus the issue
reference when it closes one.
```text
Setup stable computer name in Wine prefix (Closes #1609)
```
[label-merged-issues.yaml](.github/workflows/label-merged-issues.yaml) parses
`close/fix/resolve #N` from the title and body of PRs merged into a release
branch and labels the linked issues, so use those keywords.
## Checks
[CI](.github/workflows/ci.yaml) and
[version_check.yaml](.github/workflows/version_check.yaml) only trigger on PRs
targeting `master`, so a feature PR onto a release branch is not covered by
them. Run the equivalent locally before opening one:
```bash
dotnet build Daybreak.Linux/Daybreak.Linux.csproj # or Daybreak.Windows on Windows
dotnet test Daybreak.Tests/Daybreak.Tests.csproj
```
The version check fails a PR to `master` if the version in
`Directory.Build.props` is not ahead of the latest tag. This is why the bump
must come from the release branch job.
## Code style
Formatting rules are in [`.editorconfig`](.editorconfig) and are enforced by the
compiler and analyzers; keep the build warning-free.
Tests live in [`Daybreak.Tests`](Daybreak.Tests), use MSTest with
FluentAssertions and NSubstitute, and only reference `Daybreak.Core`. If you
want platform code covered by tests, put the testable part in
[`Daybreak.Shared`](Daybreak.Shared).
@@ -1,10 +1,2 @@
{
"Logging": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Warning"
}
}
}
}
@@ -0,0 +1,10 @@
{
"Logging": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Warning"
}
}
}
}
@@ -1,10 +1,2 @@
{
"Logging": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Warning"
}
}
}
}
@@ -1,4 +1,5 @@
using Daybreak.API.Services;
using Daybreak.Shared.Models.Api;
using Microsoft.AspNetCore.Mvc;
using Net.Sdk.Web;
@@ -13,8 +14,8 @@ public sealed class PartyController(PartyService partyService)
[GenerateGet("loadout")]
[EndpointName("GetPartyLoadout")]
[EndpointSummary("Get the current party loadout")]
[EndpointDescription("Get the current party loadout. Returns the encoded party loadout template code")]
[ProducesResponseType<string>(StatusCodes.Status200OK)]
[EndpointDescription("Get the current party loadout. Returns a json serialized PartyLoadout object")]
[ProducesResponseType<PartyLoadout>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public async Task<IResult> GetPartyLoadout(
CancellationToken cancellationToken)
@@ -26,12 +27,12 @@ public sealed class PartyController(PartyService partyService)
[GeneratePost("loadout")]
[EndpointName("SetPartyLoadout")]
[EndpointSummary("Set the current party loadout")]
[EndpointDescription("Set the current party loadout from an encoded template code")]
[EndpointDescription("Set the current party loadout")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public async Task<IResult> SetPartyLoadout([FromQuery(Name = "code")] string? code, CancellationToken cancellationToken)
public async Task<IResult> SetPartyLoadout([FromBody] PartyLoadout partyLoadout, CancellationToken cancellationToken)
{
var result = await this.partyService.SetPartyLoadout(code ?? string.Empty, cancellationToken);
var result = await this.partyService.SetPartyLoadout(partyLoadout, cancellationToken);
return result ? Results.Ok() : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
}
+2 -11
View File
@@ -6,6 +6,7 @@
<Nullable>enable</Nullable>
<OutputType>Library</OutputType>
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-x86</RuntimeIdentifier>
<StripSymbols>true</StripSymbols>
<InteropExports>true</InteropExports>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
@@ -26,14 +27,7 @@
<ResolveStaticWebAssetsInputsDependsOn>RemoveDuplicateStaticWebAssets;$(ResolveStaticWebAssetsInputsDependsOn)</ResolveStaticWebAssetsInputsDependsOn>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<!-- Faster ILC compilation for iterative development -->
<IlcOptimizationPreference>None</IlcOptimizationPreference>
<IlcTrimMetadata>false</IlcTrimMetadata>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<StripSymbols>true</StripSymbols>
<DebugType>none</DebugType>
<DebugSymbols>false</DebugSymbols>
</PropertyGroup>
@@ -88,10 +82,7 @@
<PackageReference Include="Serilog.Settings.Configuration" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="Swashbuckle.AspNetCore.Swagger" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" />
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="SystemExtensions.NetStandard.Generators" PrivateAssets="all" />
<PackageReference Include="ZLinq" />
</ItemGroup>
+79 -80
View File
@@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Extensions.Core;
using System.Logging;
using System.Net.NetworkInformation;
using System.Reflection;
using System.Runtime.InteropServices;
using Daybreak.API.Configuration;
using Daybreak.API.Extensions;
@@ -19,24 +20,15 @@ using Net.Sdk.Web;
namespace Daybreak.API;
public static class EntryPoint
public class EntryPoint
{
private const bool IsDebug =
#if DEBUG
true;
#else
false;
#endif
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();
// GWCA log handler - must be static to prevent GC
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void GWCALogHandler(nint context, int level, nint msg, nint file, uint line, nint function);
private static GWCALogHandler? gwcaLogHandlerDelegate;
private static ILogger? gwcaLogger;
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")]
@@ -47,27 +39,20 @@ public static class EntryPoint
{
Environment.SetEnvironmentVariable("ASPNETCORE_HOSTINGSTARTUPASSEMBLIES", null, EnvironmentVariableTarget.Process);
Environment.SetEnvironmentVariable("ASPNETCORE_PREVENTHOSTINGSTARTUP", "true", EnvironmentVariableTarget.Process);
// Enable console if GW was launched with --debug-api or if we're running a debug build
var args = Environment.GetCommandLineArgs();
if (IsDebug ||
args.Any(arg => arg.Equals("--debug-api", StringComparison.OrdinalIgnoreCase) ||
arg.Equals("-debug-api", StringComparison.OrdinalIgnoreCase)))
{
NativeMethods.AllocConsole();
}
#if DEBUG
ConsoleExtensions.AllocateAnsiConsole();
#endif
var port = FindAvailablePort(StartPort);
var app = CreateApplication(port);
var runTask = Task.Run(() => StartServer(app), CancellationTokenSource.Token);
var scopedLogger = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger(nameof(EntryPoint));
var scopedLogger = app.Services.GetRequiredService<ILogger<EntryPoint>>().CreateScopedLogger();
var healthCheck = app.Services.GetRequiredService<HealthCheckService>();
var sw = Stopwatch.StartNew();
var healthy = false;
while (sw.Elapsed < InitializationTimeout)
{
var status = Task.Run(() => healthCheck.CheckHealthAsync()).Result;
scopedLogger.LogWarning("{methodName}: HealthCheck status: {status} in {duration}. Report: {report}", nameof(ThreadInit), status.Status, status.TotalDuration, string.Join("\n", status.Entries.Select(e => $"{e.Key}: {e.Value.Status}")));
scopedLogger.LogWarning("HealthCheck status: {status} in {duration}. Report: {report}", status.Status, status.TotalDuration, string.Join("\n", status.Entries.Select(e => $"{e.Key}: {e.Value.Status}")));
if (status.Status is HealthStatus.Healthy)
{
healthy = true;
@@ -81,12 +66,12 @@ public static class EntryPoint
if (healthy)
{
scopedLogger.LogInformation("{methodName}: Daybreak API is healthy on port {port}. Initialization succeeded in {duration}", nameof(ThreadInit), port, sw.Elapsed);
scopedLogger.LogInformation("Daybreak API is healthy on port {port}. Initialization succeeded in {duration}", port, sw.Elapsed);
return port;
}
else
{
scopedLogger.LogError("{methodName}: Daybreak API failed to initialize in {duration}", nameof(ThreadInit), sw.Elapsed);
scopedLogger.LogError("Daybreak API failed to initialize in {duration}", sw.Elapsed);
CancellationTokenSource.Cancel();
return -1;
}
@@ -107,8 +92,6 @@ public static class EntryPoint
.WithRoutes()
.WithHealthChecks();
builder.Services.AddOpenApi();
// Add CORS policy - allow localhost and trusted community sites
builder.Services.AddCors(options =>
{
@@ -153,54 +136,22 @@ public static class EntryPoint
private static async Task StartServer(WebApplication app)
{
var scopedLogger = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger(nameof(EntryPoint));
var scopedLogger = app.Services.GetRequiredService<ILogger<EntryPoint>>().CreateScopedLogger();
try
{
scopedLogger.LogDebug("{methodName}: Initializing GWCA", nameof(StartServer));
scopedLogger.LogDebug("Initializing GWCA");
PreloadNativeDependencies(scopedLogger);
SetupGWCALogging(app);
scopedLogger.LogDebug("{methodName}: GWCA log handler registered", nameof(StartServer));
var result = GWCA.GW.Initialize();
scopedLogger.LogDebug("{methodName}: GWCA.GW.Initialize() returned: {result}", nameof(StartServer), result);
scopedLogger.LogDebug($"GWCA.GW.Initialize() returned: {result}");
await app.RunAsync();
}
catch (Exception ex)
{
scopedLogger.LogCritical(ex, "{methodName}: Encountered fatal error while starting API server {exception}", nameof(StartServer), ex);
scopedLogger.LogCritical(ex, $"Encountered fatal error while starting API server {ex}");
throw;
}
}
private static void SetupGWCALogging(WebApplication app)
{
gwcaLogger = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("GWCA");
gwcaLogHandlerDelegate = OnGWCALog;
var handlerPtr = Marshal.GetFunctionPointerForDelegate(gwcaLogHandlerDelegate);
GWCA.GW.RegisterLogHandler(handlerPtr, nint.Zero);
}
private static void OnGWCALog(nint context, int level, nint msg, nint file, uint line, nint function)
{
var msgStr = msg != nint.Zero ? Marshal.PtrToStringAnsi(msg) : "<null>";
var fileStr = file != nint.Zero ? Marshal.PtrToStringAnsi(file) : "<null>";
var funcStr = function != nint.Zero ? Marshal.PtrToStringAnsi(function) : "<null>";
var logLevel = level switch
{
0 => LogLevel.Trace, // LEVEL_TRACE
1 => LogLevel.Debug, // LEVEL_DEBUG
2 => LogLevel.Information, // LEVEL_INFO
3 => LogLevel.Warning, // LEVEL_WARN
4 => LogLevel.Error, // LEVEL_ERR
5 => LogLevel.Critical, // LEVEL_CRITICAL
_ => LogLevel.Debug
};
gwcaLogger?.Log(logLevel, "{gwcaSource}: {file}:{line} ({function}) {message}", nameof(GWCA), fileStr, line, funcStr, msgStr);
}
private static int FindAvailablePort(int startPort)
{
var port = startPort;
@@ -234,26 +185,74 @@ public static class EntryPoint
return true;
}
private static void PreloadNativeDependencies(ILogger logger)
private static void PreloadNativeDependencies(ScopedLogger<EntryPoint> logger)
{
// First, check if gwca.dll is already loaded in the process (e.g. by GWToolbox).
// If so, reuse it via a ref-count bump on the existing path rather than loading
// a second copy from the Daybreak.API directory. Loading a second instance of
// gwca.dll would result in duplicate native modules in the process, which can
// lead to inconsistent state across mods that depend on it.
string? daybreakApiDir = default;
foreach (ProcessModule module in Process.GetCurrentProcess().Modules)
{
if (module.ModuleName?.Contains("Daybreak.API", StringComparison.OrdinalIgnoreCase) is true)
if (module.ModuleName?.Equals("gwca.dll", StringComparison.OrdinalIgnoreCase) is true)
{
var moduleDir = Path.GetDirectoryName(module.FileName)!;
var gwcaPath = Path.Combine(moduleDir, "gwca.dll");
if (File.Exists(gwcaPath))
{
NativeLibrary.Load(gwcaPath);
logger.LogDebug("{methodName}: Preloaded gwca.dll from {path}", nameof(PreloadNativeDependencies), gwcaPath);
}
else
{
logger.LogError("{methodName}: gwca.dll not found at {path}", nameof(PreloadNativeDependencies), gwcaPath);
}
var handle = NativeLibrary.Load(module.FileName);
RegisterGwcaResolver(handle, module.FileName, logger);
logger.LogDebug($"Reused already-loaded gwca.dll from {module.FileName}");
return;
}
break;
if (daybreakApiDir is null &&
module.ModuleName?.Contains("Daybreak.API", StringComparison.OrdinalIgnoreCase) is true)
{
daybreakApiDir = Path.GetDirectoryName(module.FileName);
}
}
if (daybreakApiDir is null)
{
logger.LogError("Could not locate Daybreak.API module directory to preload gwca.dll");
return;
}
var gwcaPath = Path.Combine(daybreakApiDir, "gwca.dll");
if (File.Exists(gwcaPath))
{
var handle = NativeLibrary.Load(gwcaPath);
RegisterGwcaResolver(handle, gwcaPath, logger);
logger.LogDebug($"Preloaded gwca.dll from {gwcaPath}");
}
else
{
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;
}
}
+27 -10
View File
@@ -1,6 +1,7 @@
using Daybreak.API.Models;
using Daybreak.API.Services.Interop;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using ZLinq;
namespace Daybreak.API.Health;
@@ -11,21 +12,37 @@ public static class WebApplicationExtensions
[RequiresDynamicCode("The handler uses a static method, so there's no dynamic code to worry about")]
public static WebApplication UseHealthChecks(this WebApplication app)
{
app.MapGet("/api/v1/rest/health", static (IEnumerable<IInteropHealthService> services) =>
app.MapGet("/api/v1/rest/health", static async (HealthCheckService healthCheckService, CancellationToken cancellationToken) =>
{
var entries = services
.SelectMany(s => s.GetAddressHealth())
.ToList();
var response = new HealthCheckResponse(Environment.ProcessId, entries);
var report = await healthCheckService.CheckHealthAsync(cancellationToken);
var response = new HealthCheckResponse
{
ProcessId = Environment.ProcessId,
Status = report.Status,
TotalDuration = report.TotalDuration,
Entries = report.Entries.ToDictionary(
e => e.Key,
e => new HealthCheckEntryResponse
{
Status = e.Value.Status,
Description = e.Value.Description ?? string.Empty,
Tags = [.. e.Value.Tags],
Data = e.Value.Data
.Where(kvp => kvp.Value is JsonElement je)
.ToDictionary(kvp => kvp.Key,
kvp => (JsonElement)kvp.Value)
})
};
return Results.Json(response);
})
.WithName("Health")
.WithSummary("Interop hook health")
.WithDescription("Returns the status of all low-level game hooks")
.WithTags("Health")
.WithSummary("Service health")
.WithDescription($"Current health status of the service. Returns a serialized object of type {nameof(HealthCheckResponse)}")
.WithTags("Service")
.Produces<HealthCheckResponse>(StatusCodes.Status200OK)
.WithOpenApi();
return app;
}
}
+3 -7
View File
@@ -1,5 +1,4 @@
using System.Runtime.InteropServices;
using Daybreak.API.Interop.GuildWars;
namespace Daybreak.API.Interop;
@@ -19,20 +18,17 @@ public readonly struct Frame
[FieldOffset(0x0024)]
public readonly uint TemplateType;
[FieldOffset(0x00B8)]
[FieldOffset(0x00b8)]
public readonly uint ChildOffsetId;
[FieldOffset(0x00BC)]
[FieldOffset(0x00bc)]
public readonly uint FrameId;
[FieldOffset(0x00D8)]
public readonly FramePositionData Position;
[FieldOffset(0x018C)]
public readonly uint FrameState;
public bool IsCreated => (this.FrameState & 0x4) != 0;
public bool IsHidden => (this.FrameState & 0x200) != 0;
public bool IsVisible => !this.IsHidden;
public bool IsDisabled => (this.FrameState & 0x10) != 0;
public bool IsDIsabled => (this.FrameState & 0x10) != 0;
}
-26
View File
@@ -1,26 +0,0 @@
using System.Core.Extensions;
namespace Daybreak.API.Interop;
public sealed class GWAddressCache(Func<nuint> provider)
{
private readonly Func<nuint> provider = provider.ThrowIfNull();
private nuint? cachedAddress;
public nuint? GetAddress()
{
if (this.cachedAddress.HasValue)
{
return this.cachedAddress.Value;
}
var addr = this.provider();
if (addr is 0x0)
{
return null;
}
this.cachedAddress = addr;
return addr;
}
}
+1954 -484
View File
File diff suppressed because it is too large Load Diff
-312
View File
@@ -1,312 +0,0 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace Daybreak.API.Interop;
public abstract class GWFastCall(GWAddressCache target)
{
public readonly struct Void { }
public GWAddressCache Cache { get; } = target;
public abstract void Initialize();
/// <summary>
/// Builds a thunk for __fastcall with 1 parameter (ECX only).
/// </summary>
protected unsafe static nint BuildFastCallThunk1(nuint target)
{
if (target is 0)
{
return 0;
}
var code = stackalloc byte[10]
{
0x58, // pop eax (ret)
0x59, // pop ecx (arg1)
0x50, // push eax (ret)
0xB8, 0, 0, 0, 0, // mov eax, TARGET
0xFF, 0xE0 // jmp eax
}; // 10 bytes total
const int TARGET_OFFSET = 4;
Unsafe.WriteUnaligned(ref code[TARGET_OFFSET], (uint)target);
var mem = NativeMethods.VirtualAlloc(0, 10, NativeMethods.MEM_COMMIT | NativeMethods.MEM_RESERVE, NativeMethods.PAGE_EXECUTE_READWRITE);
if (mem is 0)
{
throw new Win32Exception(Marshal.GetLastWin32Error(), "VirtualAlloc");
}
Buffer.MemoryCopy(code, (void*)mem, 10, 10);
return mem;
}
/// <summary>
/// Builds a thunk for __fastcall with 2+ parameters (ECX and EDX).
/// </summary>
protected unsafe static nint BuildFastCallThunk2(nuint target)
{
if (target is 0)
{
return 0;
}
var code = stackalloc byte[11]
{
0x58, // pop eax (ret)
0x59, // pop ecx (ctx)
0x5A, // pop edx (edxVal)
0x50, // push eax (ret)
0xB8,0,0,0,0, // mov eax, TARGET
0xFF,0xE0 // jmp eax
}; // 11 bytes total
const int TARGET_OFFSET = 5;
Unsafe.WriteUnaligned(ref code[TARGET_OFFSET], (uint)target);
var mem = NativeMethods.VirtualAlloc(0, 11, NativeMethods.MEM_COMMIT | NativeMethods.MEM_RESERVE, NativeMethods.PAGE_EXECUTE_READWRITE);
if (mem is 0)
{
throw new Win32Exception(Marshal.GetLastWin32Error(), "VirtualAlloc");
}
Buffer.MemoryCopy(code, (void*)mem, 11, 11);
return mem;
}
}
public unsafe sealed class GWFastCall<T1, T2>(GWAddressCache target) : GWFastCall(target)
{
private delegate* unmanaged[Stdcall]<T1, T2> func = null;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public unsafe T2? Invoke(T1 t1)
{
if (this.func is null)
{
this.Initialize();
}
if (this.func is not null)
{
if (typeof(T2) == typeof(Void))
{
((delegate* unmanaged[Stdcall]<T1, void>)this.func)(t1);
return default;
}
return this.func(t1);
}
return default;
}
public override void Initialize()
{
if (this.Cache.GetAddress() is not nuint addr ||
addr is 0)
{
return;
}
var call = BuildFastCallThunk1(addr);
this.func = (delegate* unmanaged[Stdcall]<T1, T2>)call;
}
}
public unsafe sealed class GWFastCall<T1, T2, T3>(GWAddressCache target) : GWFastCall(target)
{
private delegate* unmanaged[Stdcall]<T1, T2, T3> func = null;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public unsafe T3? Invoke(T1 t1, T2 t2)
{
if (this.func is null)
{
this.Initialize();
}
if (this.func is not null)
{
if (typeof(T3) == typeof(Void))
{
((delegate* unmanaged[Stdcall]<T1, T2, void>)this.func)(t1, t2);
return default;
}
return this.func(t1, t2);
}
return default;
}
public override void Initialize()
{
if (this.Cache.GetAddress() is not nuint addr ||
addr is 0)
{
return;
}
var call = BuildFastCallThunk2(addr);
this.func = (delegate* unmanaged[Stdcall]<T1, T2, T3>)call;
}
}
public unsafe sealed class GWFastCall<T1, T2, T3, T4>(GWAddressCache target) : GWFastCall(target)
{
private delegate* unmanaged[Stdcall]<T1, T2, T3, T4> func = null;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public unsafe T4? Invoke(T1 t1, T2 t2, T3 t3)
{
if (this.func is null)
{
this.Initialize();
}
if (this.func is not null)
{
if (typeof(T4) == typeof(Void))
{
((delegate* unmanaged[Stdcall]<T1, T2, T3, void>)this.func)(t1, t2, t3);
return default;
}
return this.func(t1, t2, t3);
}
return default;
}
public override void Initialize()
{
if (this.Cache.GetAddress() is not nuint addr ||
addr is 0)
{
return;
}
var call = BuildFastCallThunk2(addr);
this.func = (delegate* unmanaged[Stdcall]<T1, T2, T3, T4>)call;
}
}
public unsafe sealed class GWFastCall<T1, T2, T3, T4, T5>(GWAddressCache target) : GWFastCall(target)
{
private delegate* unmanaged[Stdcall]<T1, T2, T3, T4, T5> func = null;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public unsafe T5? Invoke(T1 t1, T2 t2, T3 t3, T4 t4)
{
if (this.func is null)
{
this.Initialize();
}
if (this.func is not null)
{
if (typeof(T5) == typeof(Void))
{
((delegate* unmanaged[Stdcall]<T1, T2, T3, T4, void>)this.func)(t1, t2, t3, t4);
return default;
}
return this.func(t1, t2, t3, t4);
}
return default;
}
public override void Initialize()
{
if (this.Cache.GetAddress() is not nuint addr ||
addr is 0)
{
return;
}
var call = BuildFastCallThunk2(addr);
this.func = (delegate* unmanaged[Stdcall]<T1, T2, T3, T4, T5>)call;
}
}
public unsafe sealed class GWFastCall<T1, T2, T3, T4, T5, T6>(GWAddressCache target) : GWFastCall(target)
{
private delegate* unmanaged[Stdcall]<T1, T2, T3, T4, T5, T6> func = null;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public unsafe T6? Invoke(T1 t1, T2 t2, T3 t3, T4 t4, T5 t5)
{
if (this.func is null)
{
this.Initialize();
}
if (this.func is not null)
{
if (typeof(T6) == typeof(Void))
{
((delegate* unmanaged[Stdcall]<T1, T2, T3, T4, T5, void>)this.func)(t1, t2, t3, t4, t5);
return default;
}
return this.func(t1, t2, t3, t4, t5);
}
return default;
}
public override void Initialize()
{
if (this.Cache.GetAddress() is not nuint addr ||
addr is 0)
{
return;
}
var call = BuildFastCallThunk2(addr);
this.func = (delegate* unmanaged[Stdcall]<T1, T2, T3, T4, T5, T6>)call;
}
}
public unsafe sealed class GWFastCall<T1, T2, T3, T4, T5, T6, T7>(GWAddressCache target) : GWFastCall(target)
{
private delegate* unmanaged[Stdcall]<T1, T2, T3, T4, T5, T6, T7> func = null;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public unsafe T7? Invoke(T1 t1, T2 t2, T3 t3, T4 t4, T5 t5, T6 t6)
{
if (this.func is null)
{
this.Initialize();
}
if (this.func is not null)
{
if (typeof(T7) == typeof(Void))
{
((delegate* unmanaged[Stdcall]<T1, T2, T3, T4, T5, T6, void>)this.func)(t1, t2, t3, t4, t5, t6);
return default;
}
return this.func(t1, t2, t3, t4, t5, t6);
}
return default;
}
public override void Initialize()
{
if (this.Cache.GetAddress() is not nuint addr ||
addr is 0)
{
return;
}
var call = BuildFastCallThunk2(addr);
this.func = (delegate* unmanaged[Stdcall]<T1, T2, T3, T4, T5, T6, T7>)call;
}
}
-243
View File
@@ -1,243 +0,0 @@
using System.Runtime.InteropServices;
using MinHook;
namespace Daybreak.API.Interop;
/// <summary>
/// A thin convenience wrapper around MinHook that
/// resolves the target address lazily through <see cref="GWAddressCache"/>
/// unwraps an existing "CALL/JMP rel32" stub so we patch the real code
/// exposes <see cref="Original"/> so your detour can chain
/// </summary>
public sealed class GWHook<T>(
GWAddressCache funcAddress,
T detour,
bool bypassPreviousHooks = false)
where T : Delegate
{
private const uint PAGE_EXECUTE_READWRITE = 0x40;
private readonly bool bypassPreviousHooks = bypassPreviousHooks;
private readonly GWAddressCache addressCache = funcAddress ?? throw new ArgumentNullException(nameof(funcAddress));
private readonly T detour = detour ?? throw new ArgumentNullException(nameof(detour));
private readonly SemaphoreSlim semaphore = new(1, 1);
private T? cont; // trampoline returned by MinHook, or previous hook's detour
private HookEngine? engine;
private bool usedJmpPatch; // true if we patched an existing JMP instead of using MinHook
private int originalRel32; // saved rel32 for restoration when usedJmpPatch
/// <summary>Delegate that calls the next hook / real function.</summary>
public T Continue => this.cont ??
throw new InvalidOperationException("Hook not initialised call EnsureInitialized() first.");
public bool Hooked { get; private set; } = false;
public nuint TargetAddress { get; private set; }
public nuint ContinueAddress { get; private set; }
public nuint DetourAddress { get; private set; }
/// <summary>Installs the hook exactly once (thread-safe).</summary>
public bool EnsureInitialized()
{
if (this.Hooked)
{
return true;
}
this.semaphore.Wait();
try
{
if (this.Hooked)
{
return true;
}
var addr = this.addressCache.GetAddress();
if (addr.HasValue is false ||
addr.Value is 0)
{
return false;
}
var target = this.bypassPreviousHooks
? UnwrapNearBranch(addr.Value)
: addr.Value;
if (target is 0)
{
return false;
}
// If not bypassing previous hooks and target starts with JMP, use our JMP patching
// This handles the case where GWCA already hooked the function
if (!this.bypassPreviousHooks && StartsWithJmp(target))
{
if (this.TryPatchExistingJmp(target))
{
return true;
}
}
// Try MinHook for normal (unhooked) functions
if (this.TryMinHook(target))
{
return true;
}
// Fallback: try JMP patching even if we didn't detect JMP initially
// (in case StartsWithJmp was wrong or MinHook failed for other reasons)
if (this.TryPatchExistingJmp(target))
{
return true;
}
return false;
}
finally
{
this.semaphore.Release();
}
}
public void Dispose()
{
this.semaphore.Wait();
try
{
if (!this.Hooked)
{
return;
}
if (this.usedJmpPatch)
{
// Restore the original JMP target
RestoreJmpRel32(this.TargetAddress, this.originalRel32);
}
else if (this.engine is not null)
{
this.engine.DisableHooks();
this.engine.Dispose();
}
this.cont = null;
this.Hooked = false;
}
finally
{
this.semaphore.Release();
}
}
private bool TryMinHook(nuint target)
{
try
{
this.engine = new HookEngine();
this.cont = this.engine.CreateHook((nint)target, this.detour);
this.engine.EnableHooks();
this.TargetAddress = target;
this.ContinueAddress = (nuint)this.cont.Method.MethodHandle.GetFunctionPointer();
this.DetourAddress = (nuint)this.detour.Method.MethodHandle.GetFunctionPointer();
this.Hooked = true;
this.usedJmpPatch = false;
return true;
}
catch
{
this.engine?.Dispose();
this.engine = null;
return false;
}
}
/// <summary>
/// If the target starts with JMP rel32 (0xE9), another hook is already installed.
/// We patch that JMP to point to our detour, and call the previous hook's detour as Continue.
/// </summary>
private unsafe bool TryPatchExistingJmp(nuint target)
{
var p = (byte*)target;
if (*p != 0xE9)
{
return false;
}
// Read the existing rel32 offset (points to previous hook's detour)
var existingRel32 = *(int*)(p + 1);
var previousDetourAddr = (nuint)(p + 5 + existingRel32);
// Create a delegate to call the previous hook
this.cont = Marshal.GetDelegateForFunctionPointer<T>((nint)previousDetourAddr);
// Calculate new rel32 to point to our detour
var ourDetourPtr = Marshal.GetFunctionPointerForDelegate(this.detour);
var newRel32 = (int)((nint)ourDetourPtr - (nint)(p + 5));
// Patch the JMP
if (!PatchJmpRel32(target, newRel32))
{
this.cont = null;
return false;
}
this.originalRel32 = existingRel32;
this.TargetAddress = target;
this.ContinueAddress = previousDetourAddr;
this.DetourAddress = (nuint)ourDetourPtr;
this.Hooked = true;
this.usedJmpPatch = true;
return true;
}
private static unsafe bool PatchJmpRel32(nuint jmpAddr, int newRel32)
{
var rel32Ptr = (byte*)(jmpAddr + 1);
if (!NativeMethods.VirtualProtect(rel32Ptr, 4, PAGE_EXECUTE_READWRITE, out var oldProtect))
{
return false;
}
try
{
*(int*)rel32Ptr = newRel32;
return true;
}
finally
{
NativeMethods.VirtualProtect(rel32Ptr, 4, oldProtect, out _);
}
}
/// <summary>
/// Checks if the target address starts with a JMP rel32 instruction (0xE9).
/// </summary>
private static unsafe bool StartsWithJmp(nuint addr)
{
var p = (byte*)addr;
return *p == 0xE9;
}
private static unsafe void RestoreJmpRel32(nuint jmpAddr, int originalRel32)
{
var rel32Ptr = (byte*)(jmpAddr + 1);
if (NativeMethods.VirtualProtect(rel32Ptr, 4, PAGE_EXECUTE_READWRITE, out var oldProtect))
{
*(int*)rel32Ptr = originalRel32;
NativeMethods.VirtualProtect(rel32Ptr, 4, oldProtect, out _);
}
}
/// <summary>
/// Follows a <c>CALL rel32</c> (<c>E8</c>) **or** <c>JMP rel32</c> (<c>E9</c>)
/// trampoline that may have been planted by a previous hook.
/// </summary>
private static unsafe nuint UnwrapNearBranch(nuint addr)
{
var p = (byte*)addr;
if (*p != 0xE8 && *p != 0xE9)
return addr;
var rel = *(int*)(p + 1);
return (nuint)(p + 5 + rel);
}
}
@@ -1,20 +0,0 @@
using System.Runtime.InteropServices;
namespace Daybreak.API.Interop.GuildWars;
[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x1C)]
public readonly struct BitReader
{
[FieldOffset(0x0000)]
public readonly int HasError;
[FieldOffset(0x0004)]
public readonly int RackBitCount;
[FieldOffset(0x0008)]
public readonly long RackData;
[FieldOffset(0x0010)]
public readonly nint CurrentPtr;
[FieldOffset(0x0014)]
public readonly nint StartPtr;
[FieldOffset(0x0018)]
public readonly nint EndPtr;
}
@@ -13,17 +13,44 @@ public readonly unsafe struct GuildWarsArray<T> : IEnumerable<T>
public readonly uint Size;
public readonly uint Param;
/// <summary>
/// Upper bound on a believable element count. Guild Wars' largest arrays hold at most a
/// few thousand entries, so anything beyond this is uninitialised or misresolved memory
/// rather than a real array.
/// </summary>
private const uint MaxPlausibleCapacity = 0x10000;
/// <summary>
/// Mirrors <c>GW::BaseArray::valid()</c> - the buffer must be null or aligned and the size
/// must fit within the capacity - plus a bound on the capacity itself. Guild Wars leaves
/// these fields transiently inconsistent while it (re)allocates an array, and a
/// misresolved GWCA scan can point this struct at arbitrary bytes that still satisfy
/// <c>size &lt;= capacity</c>.
/// </summary>
public bool IsValid =>
(this.Buffer is null || ((nuint)this.Buffer & 0x3) == 0) &&
this.Size <= this.Capacity &&
this.Capacity <= MaxPlausibleCapacity;
/// <summary>
/// Number of elements that can safely be read. Zero whenever the array is not backed by
/// a buffer, matching <c>GW::BaseArray::get()</c>, which returns null instead of
/// dereferencing. Reading past this is an access violation, and because NativeAOT cannot
/// throw <c>AccessViolationException</c> it fail-fasts and takes Guild Wars down with it.
/// </summary>
public uint Count => this.Buffer is not null && this.IsValid ? this.Size : 0;
public T this[int index]
{
get
{
ArgumentOutOfRangeException.ThrowIfNegative(index);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)index, this.Size);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)index, this.Count);
return this.Buffer[index];
}
}
public Enumerator GetEnumerator() => new(this.Buffer, this.Size);
public Enumerator GetEnumerator() => new(this.Buffer, this.Count);
IEnumerator<T> IEnumerable<T>.GetEnumerator() => this.GetEnumerator();
@@ -1,11 +1,23 @@
namespace Daybreak.API.Interop.GuildWars;
public unsafe readonly struct WrappedPointer<T>(T* pointer)
public readonly unsafe struct WrappedPointer<T>(T* pointer)
where T : unmanaged
{
public readonly T* Pointer = pointer;
public readonly bool IsNull => this.Pointer is null;
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);
@@ -1,16 +0,0 @@
using System.Runtime.InteropServices;
namespace Daybreak.API.Interop;
[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x94)]
public struct TemplateSummaryFrameData
{
[FieldOffset(0x00)]
public uint FrameId;
[FieldOffset(0x04)]
public GuildWars.SkillTemplate SkillTemplate;
[FieldOffset(0x90)]
public uint AgentId;
}
-3
View File
@@ -1,3 +0,0 @@
namespace Daybreak.API.Models;
public sealed record AddressHealth(string Name, string Address, bool Hooked);
+2 -4
View File
@@ -2,11 +2,9 @@
namespace Daybreak.API.Models;
public sealed class CallbackRegistration<T>(Guid uid, T callback, Action onDispose) : IDisposable
where T : Delegate
public sealed class CallbackRegistration(Guid uid, Action onDispose) : IDisposable
{
public Guid Uid { get; } = uid;
public T Callback { get; } = callback.ThrowIfNull();
private readonly Guid uid = uid;
private readonly Action onDispose = onDispose.ThrowIfNull();
public void Dispose()
@@ -0,0 +1,14 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Daybreak.API.Models;
public sealed class HealthCheckEntryResponse
{
[JsonConverter(typeof(JsonStringEnumConverter<HealthStatus>))]
public required HealthStatus Status { get; set; }
public required string Description { get; set; }
public required Dictionary<string, JsonElement> Data { get; set; }
public required List<string> Tags { get; set; }
}
+11 -1
View File
@@ -1,3 +1,13 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System.Text.Json.Serialization;
namespace Daybreak.API.Models;
public sealed record HealthCheckResponse(int ProcessId, IEnumerable<AddressHealth> AddressHealth);
public sealed class HealthCheckResponse
{
public required int ProcessId { get; set; }
[JsonConverter(typeof(JsonStringEnumConverter<HealthStatus>))]
public required HealthStatus Status { get; set; }
public required TimeSpan TotalDuration { get; set; }
public required Dictionary<string, HealthCheckEntryResponse> Entries { get; set; }
}
@@ -15,9 +15,8 @@ namespace Daybreak.API.Serialization;
[JsonSerializable(typeof(Dictionary<string, string>))]
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(AddressHealth))]
[JsonSerializable(typeof(List<AddressHealth>))]
[JsonSerializable(typeof(HealthCheckResponse))]
[JsonSerializable(typeof(HealthCheckEntryResponse))]
[JsonSerializable(typeof(MainPlayerState))]
[JsonSerializable(typeof(QuestLogInformation))]
[JsonSerializable(typeof(QuestInformation))]
@@ -26,6 +25,8 @@ namespace Daybreak.API.Serialization;
[JsonSerializable(typeof(CharacterSelectEntry))]
[JsonSerializable(typeof(BuildEntry))]
[JsonSerializable(typeof(List<BuildEntry>))]
[JsonSerializable(typeof(PartyLoadoutEntry))]
[JsonSerializable(typeof(List<PartyLoadout>))]
[JsonSerializable(typeof(InstanceInfo))]
[JsonSerializable(typeof(TitleInfo))]
[JsonSerializable(typeof(LoginInfo))]
+83 -36
View File
@@ -1,7 +1,6 @@
using Daybreak.API.Extensions;
using Daybreak.API.Interop;
using Daybreak.API.Interop.GuildWars;
using Daybreak.API.Models;
using Daybreak.API.Services.Interop;
using Daybreak.Shared.Models.Api;
using System.Core.Extensions;
@@ -18,7 +17,6 @@ public sealed class CharacterSelectService(
UIContextService uiContextService,
GameThreadService gameThreadService,
GameContextService gameContextService,
PreferencesService preferencesService,
ILogger<CharacterSelectService> logger)
{
private readonly InstanceContextService instanceContextService = instanceContextService.ThrowIfNull();
@@ -26,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)
@@ -38,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);
@@ -165,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)
{
@@ -173,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;
@@ -192,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++)
{
@@ -268,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)
@@ -276,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)
@@ -292,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;
@@ -341,7 +379,7 @@ public sealed class CharacterSelectService(
await Task.Delay(100, cancellationToken);
}
catch(Exception e)
catch (Exception e)
{
scopedLogger.LogError(e, "Error while waiting for character select to be ready");
throw;
@@ -353,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)
@@ -389,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;
@@ -433,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()
@@ -15,7 +15,7 @@ public sealed class GameThreadService
// Prevent GC of delegates passed to native code
private readonly ConcurrentDictionary<GameThreadCallback, bool> prevent_GC_callbacks = [];
public Task QueueOnGameThread(Action action, CancellationToken cancellationToken, bool forceEnqueue = false)
public Task QueueOnGameThread(Action action, CancellationToken cancellationToken)
{
var taskCompletionSource = new TaskCompletionSource();
var item = new WorkItem(action, taskCompletionSource, cancellationToken);
@@ -47,12 +47,12 @@ public sealed class GameThreadService
this.prevent_GC_callbacks[callback] = true;
var funcPtr = Marshal.GetFunctionPointerForDelegate(callback);
GWCA.GW.GameThread.Enqueue(funcPtr, forceEnqueue);
GWCA.GW.GameThread.Enqueue(funcPtr, false);
return taskCompletionSource.Task;
}
public Task<T> QueueOnGameThread<T>(Func<T> action, CancellationToken cancellationToken, bool forceEnqueue = false)
public Task<T> QueueOnGameThread<T>(Func<T> action, CancellationToken cancellationToken)
{
var taskCompletionSource = new TaskCompletionSource<T>();
var item = new WorkItem<T>(action, taskCompletionSource, cancellationToken);
@@ -85,7 +85,7 @@ public sealed class GameThreadService
this.prevent_GC_callbacks[callback] = true;
var funcPtr = Marshal.GetFunctionPointerForDelegate(callback);
GWCA.GW.GameThread.Enqueue(funcPtr, forceEnqueue);
GWCA.GW.GameThread.Enqueue(funcPtr, false);
return taskCompletionSource.Task;
}
@@ -1,8 +0,0 @@
using Daybreak.API.Models;
namespace Daybreak.API.Services.Interop;
public interface IInteropHealthService
{
IEnumerable<AddressHealth> GetAddressHealth();
}
@@ -224,35 +224,6 @@ public sealed unsafe class MemoryScanningService(
return FindInRange(prologue, mask, 0, start, end);
}
/// <summary>
/// Finds the start of a function by scanning backwards from <paramref name="addressInFunction"/>
/// for <c>CC CC</c> padding bytes (INT 3) that MSVC emits between functions.
/// Requires at least 2 consecutive <c>CC</c> bytes to avoid false positives from
/// single <c>CC</c> bytes that appear in instruction encodings.
/// Unlike <see cref="ToFunctionStart"/>, this works even when a previous hook
/// (e.g. GWCA) has overwritten the prologue with a JMP.
/// </summary>
public nuint ToFunctionStartFromPadding(nuint addressInFunction, uint scanRange = 0x500)
{
if (addressInFunction is 0)
{
return 0;
}
var p = (byte*)addressInFunction;
var limit = p - scanRange;
for (var scan = p - 1; scan > limit; scan--)
{
// Need at least 2 consecutive CC bytes (real inter-function padding)
if (*scan == 0xCC && *(scan - 1) == 0xCC && *(scan + 1) != 0xCC)
{
return (nuint)(scan + 1);
}
}
return 0;
}
public nuint FindNthUseOfString(string str, uint nth, int offset = 0, bool useTextSection = true)
{
var scopedLogger = this.logger.CreateScopedLogger();
@@ -1,286 +1,9 @@
using System.Extensions;
using System.Extensions.Core;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Daybreak.API.Interop;
using Daybreak.API.Interop;
using Daybreak.API.Interop.GuildWars;
using Daybreak.API.Models;
using Daybreak.Shared.Models.Builds;
using Daybreak.Shared.Services.BuildTemplates;
namespace Daybreak.API.Services.Interop;
public sealed class SkillbarContextService : IHostedService, IInteropHealthService
public sealed class SkillbarContextService
{
private const string Base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
private static readonly byte[] DecodeTemplateHeaderSignature =
[
0x55, 0x8B, 0xEC, 0x56,
0x8B, 0x75, 0x0C, 0x8B,
0xCE, 0x6A, 0x04, 0xE8,
0x00, 0x00, 0x00, 0x00,
0x8B, 0xC8, 0x83, 0xE1,
0x0E, 0x80, 0xF9, 0x0E,
0x75, 0x00, 0xA9, 0xF1,
0xFF, 0xFF, 0xFF, 0x74
];
private const string DecodeTemplateHeaderMask = "xxxxxxxxxxxx????xxxxxxxxx?xxxxxx";
private const int DecodeTemplateHeaderOffset = 0x0;
private const string LoadSkillTemplateAssertionFile = "TemplatesHelpers.cpp";
private const string LoadSkillTemplateAssertionMessage = "targetPrimaryProf == templateData.profPrimary";
private static readonly byte[] PopulateSkillDataSignature =
[
0x55, 0x8B, 0xEC, 0x81,
0xEC, 0x50, 0x01, 0x00,
0x00, 0xA1, 0x00, 0x00,
0x00, 0x00, 0x33, 0xC5,
0x89, 0x45, 0xFC, 0x53,
0x56, 0x8B, 0x75, 0x08
];
private const string PopulateSkillDataMask = "xxxxxxxxxx????xxxxxxxxxx";
private const int PopulateSkillDataOffset = 0x0;
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private unsafe delegate byte DecodeTemplateHeaderDelegate(SkillTemplate* outTemplate, BitReader* bitReader);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private unsafe delegate void LoadSkillTemplateDelegate(int targetAgentId, SkillTemplate* templateData);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)]
private unsafe delegate void PopulateSkillDataDelegate(TemplateSummaryFrameData* thisPtr, SkillTemplate* skillTemplateData);
private readonly ChatService chatService;
private readonly MemoryScanningService memoryScanningService;
private readonly IBuildTemplateManager templateManager;
private readonly GWAddressCache decodeTemplateHeaderAddressCache;
private readonly GWHook<DecodeTemplateHeaderDelegate> decodeTemplateHeaderHook;
private readonly GWAddressCache loadSkillTemplateAddressCache;
private readonly GWHook<LoadSkillTemplateDelegate> loadSkillTemplateHook;
private readonly GWAddressCache populateSkillDataAddressCache;
private readonly GWHook<PopulateSkillDataDelegate> populateSkillDataHook;
private readonly ILogger<SkillbarContextService> logger;
private readonly List<CallbackRegistration<Func<string, WrappedPointer<SkillTemplate>, bool>>> decodeTemplateHeaderCallbacks = [];
private readonly List<CallbackRegistration<Func<SkillTemplate, bool>>> loadSkillTemplateCallbacks = [];
private readonly List<CallbackRegistration<Func<WrappedPointer<TemplateSummaryFrameData>, WrappedPointer<SkillTemplate>, bool>>> populateSkillDataCallbacks = [];
public unsafe SkillbarContextService(
ChatService chatService,
IBuildTemplateManager buildTemplateManager,
MemoryScanningService memoryScanningService,
ILogger<SkillbarContextService> logger)
{
this.chatService = chatService;
this.templateManager = buildTemplateManager;
this.memoryScanningService = memoryScanningService;
this.decodeTemplateHeaderAddressCache = new GWAddressCache(() =>
this.memoryScanningService.FindAddress(DecodeTemplateHeaderSignature, DecodeTemplateHeaderMask, DecodeTemplateHeaderOffset));
this.decodeTemplateHeaderHook = new GWHook<DecodeTemplateHeaderDelegate>(
this.decodeTemplateHeaderAddressCache,
this.DecodeTemplateHeaderDetour);
this.loadSkillTemplateAddressCache = new GWAddressCache(() =>
{
var assertion = this.memoryScanningService.FindAssertion(
LoadSkillTemplateAssertionFile,
LoadSkillTemplateAssertionMessage);
if (assertion is 0) return 0;
return this.memoryScanningService.ToFunctionStartFromPadding(assertion);
});
this.loadSkillTemplateHook = new GWHook<LoadSkillTemplateDelegate>(
this.loadSkillTemplateAddressCache,
this.LoadSkillTemplateDetour);
this.populateSkillDataAddressCache = new GWAddressCache(() =>
this.memoryScanningService.FindAddress(PopulateSkillDataSignature, PopulateSkillDataMask, PopulateSkillDataOffset));
this.populateSkillDataHook = new GWHook<PopulateSkillDataDelegate>(
this.populateSkillDataAddressCache,
this.PopulateSkillDataDetour);
this.logger = logger;
}
public unsafe void LoadBuild(uint agentId, SkillTemplate* template) => GWCA.GW.SkillbarMgr.LoadSkillTemplate(agentId, template);
public unsafe void CallOriginalPopulateSkillData(TemplateSummaryFrameData* frameData, SkillTemplate* skillTemplateData)
{
this.populateSkillDataHook.Continue(frameData, skillTemplateData);
}
public Task StartAsync(CancellationToken cancellationToken)
{
Task.Run(async () =>
{
while (!this.decodeTemplateHeaderHook.EnsureInitialized())
{
await Task.Delay(500, cancellationToken);
}
this.logger.LogInformation(
"DecodeTemplateHeader hook installed at 0x{target:X8}",
this.decodeTemplateHeaderHook.TargetAddress);
while (!this.loadSkillTemplateHook.EnsureInitialized())
{
await Task.Delay(500, cancellationToken);
}
this.logger.LogInformation(
"LoadSkillTemplate hook installed at 0x{target:X8}",
this.loadSkillTemplateHook.TargetAddress);
while (!this.populateSkillDataHook.EnsureInitialized())
{
await Task.Delay(500, cancellationToken);
}
this.logger.LogInformation(
"PopulateSkillData hook installed at 0x{target:X8}",
this.populateSkillDataHook.TargetAddress);
}, cancellationToken);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
this.decodeTemplateHeaderHook.Dispose();
this.loadSkillTemplateHook.Dispose();
this.populateSkillDataHook.Dispose();
return Task.CompletedTask;
}
public IEnumerable<AddressHealth> GetAddressHealth()
{
yield return new AddressHealth(
"DecodeTemplateHeader",
$"0x{this.decodeTemplateHeaderHook.TargetAddress:X8}",
this.decodeTemplateHeaderHook.Hooked);
yield return new AddressHealth(
"LoadSkillTemplate",
$"0x{this.loadSkillTemplateHook.TargetAddress:X8}",
this.loadSkillTemplateHook.Hooked);
yield return new AddressHealth(
"PopulateSkillData",
$"0x{this.populateSkillDataHook.TargetAddress:X8}",
this.populateSkillDataHook.Hooked);
}
public CallbackRegistration<Func<string, WrappedPointer<SkillTemplate>, bool>> RegisterDecodeTemplateHeaderHandler(Func<string, WrappedPointer<SkillTemplate>, bool> callback)
{
var uid = Guid.NewGuid();
var registration =
new CallbackRegistration<Func<string, WrappedPointer<SkillTemplate>, bool>>(uid, callback, () => this.decodeTemplateHeaderCallbacks.RemoveAll(r => r.Uid == uid));
this.decodeTemplateHeaderCallbacks.Add(registration);
return registration;
}
public CallbackRegistration<Func<SkillTemplate, bool>> RegisterLoadSkillTemplateHandler(Func<SkillTemplate, bool> callback)
{
var uid = Guid.NewGuid();
var registration =
new CallbackRegistration<Func<SkillTemplate, bool>>(uid, callback, () => this.loadSkillTemplateCallbacks.RemoveAll(r => r.Uid == uid));
this.loadSkillTemplateCallbacks.Add(registration);
return registration;
}
public CallbackRegistration<Func<WrappedPointer<TemplateSummaryFrameData>, WrappedPointer<SkillTemplate>, bool>> RegisterPopulateSkillDataHandler(Func<WrappedPointer<TemplateSummaryFrameData>, WrappedPointer<SkillTemplate>, bool> callback)
{
var uid = Guid.NewGuid();
var registration =
new CallbackRegistration<Func<WrappedPointer<TemplateSummaryFrameData>, WrappedPointer<SkillTemplate>, bool>>(uid, callback, () => this.populateSkillDataCallbacks.RemoveAll(r => r.Uid == uid));
this.populateSkillDataCallbacks.Add(registration);
return registration;
}
private unsafe byte DecodeTemplateHeaderDetour(SkillTemplate* outTemplate, BitReader* bitReader)
{
var scopedLogger = this.logger.CreateScopedLogger();
if (this.decodeTemplateHeaderCallbacks.Count > 0)
{
// Read the full raw buffer before the game consumes it
var rawBytes = ReadBitReaderBuffer(bitReader);
var templateString = ReencodeToBase64(rawBytes);
if (this.decodeTemplateHeaderCallbacks.Any(r => r.Callback(templateString, outTemplate)))
{
scopedLogger.LogDebug("DecodeTemplateHeader callback handled template: {template}", templateString);
return 1;
}
}
return this.decodeTemplateHeaderHook.Continue(outTemplate, bitReader);
}
private unsafe void LoadSkillTemplateDetour(int targetAgentId, SkillTemplate* templateData)
{
if (this.loadSkillTemplateCallbacks.Any(r => r.Callback(*templateData)))
{
return;
}
this.loadSkillTemplateHook.Continue(targetAgentId, templateData);
}
private unsafe void PopulateSkillDataDetour(TemplateSummaryFrameData* thisPtr, SkillTemplate* skillTemplateData)
{
if (this.populateSkillDataCallbacks.Any(r => r.Callback(thisPtr, skillTemplateData)))
{
return;
}
this.populateSkillDataHook.Continue(thisPtr, skillTemplateData);
}
/// <summary>
/// Copies the entire byte buffer backing a BitReader (from StartPtr to EndPtr) into a managed byte array.
/// </summary>
private static unsafe byte[] ReadBitReaderBuffer(BitReader* reader)
{
var start = reader->StartPtr;
var end = reader->EndPtr;
if (start == 0 || end == 0 || end <= start)
{
return [];
}
var length = (int)(end - start);
var buffer = new byte[length];
fixed (byte* dst = buffer)
{
Buffer.MemoryCopy((void*)start, dst, length, length);
}
return buffer;
}
/// <summary>
/// Re-encodes the raw BitReader buffer bytes back into the original base64 template string.
/// The buffer contains packed bits (8 per byte) written by Base64DecodeTemplate's BitWriter,
/// which wrote 6 bits per base64 character. We extract 6 bits at a time, LSB-first.
/// </summary>
private static string ReencodeToBase64(byte[] rawBytes)
{
var totalBits = rawBytes.Length * 8;
var charCount = totalBits / 6;
var chars = new char[charCount];
var bitPosition = 0;
for (var i = 0; i < charCount; i++)
{
var value = 0;
for (var b = 0; b < 6; b++)
{
var byteIndex = bitPosition / 8;
var bitIndex = bitPosition % 8;
if (byteIndex < rawBytes.Length && ((rawBytes[byteIndex] >> bitIndex) & 1) != 0)
{
value |= 1 << b;
}
bitPosition++;
}
chars[i] = value < Base64Alphabet.Length ? Base64Alphabet[value] : '?';
}
return new string(chars);
}
}
+8 -8
View File
@@ -4,7 +4,6 @@ using Daybreak.API.Services.Interop;
using Daybreak.Shared.Models.Api;
using Daybreak.Shared.Models.Guildwars;
using System.Extensions.Core;
using System.Logging;
using System.Runtime.CompilerServices;
using System.Text;
@@ -42,7 +41,7 @@ public sealed class InventoryService(
return default;
}
var itemTuples = new List<(GWCA.GW.Constants.BagType Type, List<(uint ModelId, string EncodedCompleteName, string EncodedSingleName, string EncodedName, bool Inscribable, int Quantity, uint[] Modifiers, ItemType ItemType, uint Interaction, uint ModelFileId, Byte DyeTint)>)>();
var itemTuples = new List<(GWCA.GW.Constants.BagType Type, List<(uint ModelId, string EncodedCompleteName, string EncodedSingleName, string EncodedName, bool Inscribable, int Quantity, uint[] Modifiers, ItemType ItemType, uint Interaction, uint ModelFileId, Byte DyeTint, ushort Value)>)>();
for (var i = 0; i < 23; i++)
{
// Use GWCA helper to fetch bag pointers by index — safer than reading an inline array directly.
@@ -52,7 +51,7 @@ public sealed class InventoryService(
continue;
}
var retBag = new List<(uint ModelId, string EncodedCompleteName, string EncodedSingleName, string EncodedName, bool Inscribable, int Quantity, uint[] Modifiers, ItemType ItemType, uint Interaction, uint ModelFileId, Byte DyeTint)>();
var retBag = new List<(uint ModelId, string EncodedCompleteName, string EncodedSingleName, string EncodedName, bool Inscribable, int Quantity, uint[] Modifiers, ItemType ItemType, uint Interaction, uint ModelFileId, Byte DyeTint, ushort Value)>();
itemTuples.Add((bag->BagType, retBag));
if (bag->ItemsCount is 0)
{
@@ -76,7 +75,7 @@ public sealed class InventoryService(
modifiers[j] = item->ModStruct[j].Mod;
}
retBag.Add((item->ModelId, completeNameEncoded, singleItemName, nameEncoded, (item->Interaction & 0x08000000) != 0, item->Quantity, modifiers, (ItemType)item->Type, item->Interaction, item->ModelFileId, item->Dye.DyeTint));
retBag.Add((item->ModelId, completeNameEncoded, singleItemName, nameEncoded, (item->Interaction & 0x08000000) != 0, item->Quantity, modifiers, (ItemType)item->Type, item->Interaction, item->ModelFileId, item->Dye.DyeTint, item->Value));
}
}
@@ -91,16 +90,16 @@ public sealed class InventoryService(
// Decode strings sequentially to avoid race conditions with the game's TextParser language field.
// Parallel decoding causes crashes because multiple operations race to set/restore the language.
var decodedItemTuples = new List<(GWCA.GW.Constants.BagType Type, List<(uint ModelId, string EncodedName, string? DecodedName, string EncodedSingleName, string? DecodedSingleName, string EncodedCompleteName, string? DecodedCompleteName, bool Inscribable, int Quantity, uint[] Modifiers, ItemType ItemType, uint Interaction, uint ModelFileId, byte DyeTint)> Items)>();
var decodedItemTuples = new List<(GWCA.GW.Constants.BagType Type, List<(uint ModelId, string EncodedName, string? DecodedName, string EncodedSingleName, string? DecodedSingleName, string EncodedCompleteName, string? DecodedCompleteName, bool Inscribable, int Quantity, uint[] Modifiers, ItemType ItemType, uint Interaction, uint ModelFileId, byte DyeTint, ushort Value)> Items)>();
foreach (var tuple in itemTuples)
{
var decodedItems = new List<(uint ModelId, string EncodedName, string? DecodedName, string EncodedSingleName, string? DecodedSingleName, string EncodedCompleteName, string? DecodedCompleteName, bool Inscribable, int Quantity, uint[] Modifiers, ItemType ItemType, uint Interaction, uint ModelFileId, byte DyeTint)>();
var decodedItems = new List<(uint ModelId, string EncodedName, string? DecodedName, string EncodedSingleName, string? DecodedSingleName, string EncodedCompleteName, string? DecodedCompleteName, bool Inscribable, int Quantity, uint[] Modifiers, ItemType ItemType, uint Interaction, uint ModelFileId, byte DyeTint, ushort Value)>();
foreach (var item in tuple.Item2)
{
var decodedName = await this.uIService.DecodeString(item.EncodedName, (GWCA.GW.Constants.Language)Language.English, cancellationToken);
var decodedCompleteName = await this.uIService.DecodeString(item.EncodedCompleteName, (GWCA.GW.Constants.Language)Language.English, cancellationToken);
var decodedSingleName = await this.uIService.DecodeString(item.EncodedSingleName, (GWCA.GW.Constants.Language)Language.English, cancellationToken);
decodedItems.Add((item.ModelId, item.EncodedName, decodedName, item.EncodedSingleName, decodedSingleName, item.EncodedCompleteName, decodedCompleteName, item.Inscribable, item.Quantity, item.Modifiers, item.ItemType, item.Interaction, item.ModelFileId, item.DyeTint));
decodedItems.Add((item.ModelId, item.EncodedName, decodedName, item.EncodedSingleName, decodedSingleName, item.EncodedCompleteName, decodedCompleteName, item.Inscribable, item.Quantity, item.Modifiers, item.ItemType, item.Interaction, item.ModelFileId, item.DyeTint, item.Value));
}
decodedItemTuples.Add((tuple.Type, decodedItems));
@@ -126,7 +125,8 @@ public sealed class InventoryService(
ItemType: item.ItemType.ToString(),
Interaction: item.Interaction,
ModelFileId: item.ModelFileId,
DyeTint: item.DyeTint))]))]);
DyeTint: item.DyeTint,
Value: item.Value))]))]);
}
private static string ToBase64(string encoded)
+130 -407
View File
@@ -5,7 +5,6 @@ using Daybreak.API.Models;
using Daybreak.API.Services.Interop;
using Daybreak.Shared.Models;
using Daybreak.Shared.Models.Api;
using Daybreak.Shared.Models.Builds;
using Daybreak.Shared.Models.Guildwars;
using Daybreak.Shared.Services.BuildTemplates;
using System.Core.Extensions;
@@ -17,75 +16,35 @@ using InstanceType = Daybreak.API.Interop.GuildWars.InstanceType;
namespace Daybreak.API.Services;
public sealed class PartyService : IHostedService
public sealed class PartyService(
ChatService chatService,
IBuildTemplateManager buildTemplateManager,
UIService uiService,
UIContextService uIContextService,
SkillbarContextService skillbarContextService,
InstanceContextService instanceContextService,
PartyContextService partyContextService,
GameThreadService gameThreadService,
GameContextService gameContextService,
ILogger<PartyService> logger)
{
private static readonly TimeSpan HeroSpawnDelay = TimeSpan.FromSeconds(1);
private readonly ChatService chatService;
private readonly IBuildTemplateManager buildTemplateManager;
private readonly UIService uiService;
private readonly UIContextService uIContextService;
private readonly SkillbarContextService skillbarContextService;
private readonly InstanceContextService instanceContextService;
private readonly PartyContextService partyContextService;
private readonly GameThreadService gameThreadService;
private readonly GameContextService gameContextService;
private readonly ILogger<PartyService> logger;
private readonly CallbackRegistration<Func<string, WrappedPointer<SkillTemplate>, bool>> decodeTemplateHeaderCallbackRegistration;
private readonly CallbackRegistration<Func<SkillTemplate, bool>> loadSkillTemplateCallbackRegistration;
private readonly CallbackRegistration<Func<WrappedPointer<TemplateSummaryFrameData>, WrappedPointer<SkillTemplate>, bool>> populateSkillDataCallbackRegistration;
private readonly ChatService chatService = chatService.ThrowIfNull();
private readonly IBuildTemplateManager buildTemplateManager = buildTemplateManager.ThrowIfNull();
private readonly UIService uiService = uiService.ThrowIfNull();
private readonly UIContextService uIContextService = uIContextService.ThrowIfNull();
private readonly SkillbarContextService skillbarContextService = skillbarContextService.ThrowIfNull();
private readonly InstanceContextService instanceContextService = instanceContextService.ThrowIfNull();
private readonly PartyContextService partyContextService = partyContextService.ThrowIfNull();
private readonly GameThreadService gameThreadService = gameThreadService.ThrowIfNull();
private readonly GameContextService gameContextService = gameContextService.ThrowIfNull();
private readonly ILogger<PartyService> logger = logger.ThrowIfNull();
private string? cachedTemplateCode;
private nint floatingPreviewFrame;
private bool isPopulatingExtraBuilds;
public PartyService(
ChatService chatService,
IBuildTemplateManager buildTemplateManager,
UIService uiService,
UIContextService uIContextService,
SkillbarContextService skillbarContextService,
InstanceContextService instanceContextService,
PartyContextService partyContextService,
GameThreadService gameThreadService,
GameContextService gameContextService,
ILogger<PartyService> logger)
{
this.chatService = chatService.ThrowIfNull();
this.buildTemplateManager = buildTemplateManager.ThrowIfNull();
this.uiService = uiService.ThrowIfNull();
this.uIContextService = uIContextService.ThrowIfNull();
this.skillbarContextService = skillbarContextService.ThrowIfNull();
this.instanceContextService = instanceContextService.ThrowIfNull();
this.partyContextService = partyContextService.ThrowIfNull();
this.gameThreadService = gameThreadService.ThrowIfNull();
this.gameContextService = gameContextService.ThrowIfNull();
this.logger = logger.ThrowIfNull();
this.decodeTemplateHeaderCallbackRegistration = this.skillbarContextService.RegisterDecodeTemplateHeaderHandler(this.DecodeTemplateHeader);
this.loadSkillTemplateCallbackRegistration = this.skillbarContextService.RegisterLoadSkillTemplateHandler(this.LoadSkillTemplate);
this.populateSkillDataCallbackRegistration = this.skillbarContextService.RegisterPopulateSkillDataHandler(this.PopulateSkillData);
}
public Task StartAsync(CancellationToken cancellationToken)
{
// Needed to be a hosted service so that we can register the callbacks in the constructor.
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
this.decodeTemplateHeaderCallbackRegistration.Dispose();
this.loadSkillTemplateCallbackRegistration.Dispose();
this.populateSkillDataCallbackRegistration.Dispose();
// Floating preview will be cleaned up by the game on shutdown.
this.floatingPreviewFrame = 0;
return Task.CompletedTask;
}
public async Task<bool> SetPartyLoadout(string partyLoadoutTemplate, CancellationToken cancellationToken)
public async Task<bool> SetPartyLoadout(PartyLoadout partyLoadout, CancellationToken cancellationToken)
{
var scopedLogger = this.logger.CreateScopedLogger();
if (!await this.IsInValidOutpost(cancellationToken))
{
scopedLogger.LogError("Could not set party loadout. Not in a valid outpost");
@@ -93,14 +52,6 @@ public sealed class PartyService : IHostedService
return false;
}
if (!this.buildTemplateManager.TryDecodeTemplate(partyLoadoutTemplate, out var build) ||
build is not TeamBuildEntry teamBuild)
{
scopedLogger.LogError("Could not set party loadout. Not a valid party loadout");
await this.chatService.AddMessageAsync("Cannot set party loadout. Not a valid party loadout.", "Daybreak.API", Channel.CHANNEL_MODERATOR, cancellationToken);
return false;
}
if (!await this.KickAllHeroes(cancellationToken))
{
scopedLogger.LogError("Could not set party loadout. Could not leave party");
@@ -108,7 +59,7 @@ public sealed class PartyService : IHostedService
return false;
}
if (!await this.gameThreadService.QueueOnGameThread(() => this.SpawnHeroes(teamBuild), cancellationToken))
if (!await this.gameThreadService.QueueOnGameThread(() => this.SpawnHeroes(partyLoadout), cancellationToken))
{
scopedLogger.LogError("Could not set party loadout. Could not spawn heroes");
await this.chatService.AddMessageAsync("Cannot set party loadout. Could not spawn heroes.", "Daybreak.API", Channel.CHANNEL_MODERATOR, cancellationToken);
@@ -117,14 +68,14 @@ public sealed class PartyService : IHostedService
await Task.Delay(HeroSpawnDelay, cancellationToken);
if (!await this.gameThreadService.QueueOnGameThread(() => this.ApplyBuilds(teamBuild), cancellationToken))
if (!await this.gameThreadService.QueueOnGameThread(() => this.ApplyBuilds(partyLoadout), cancellationToken))
{
scopedLogger.LogError("Could not set party loadout. Could not apply builds");
await this.chatService.AddMessageAsync("Cannot set party loadout. Could not apply builds.", "Daybreak.API", Channel.CHANNEL_MODERATOR, cancellationToken);
return false;
}
var heroBehaviorSetup = await this.gameThreadService.QueueOnGameThread(() => this.GetHeroBehaviorSetup(teamBuild), cancellationToken);
var heroBehaviorSetup = await this.gameThreadService.QueueOnGameThread(() => this.GetHeroBehaviorSetup(partyLoadout), cancellationToken);
foreach (var (AgentId, Behavior) in heroBehaviorSetup ?? [])
{
if (!await this.SetHeroBehavior(AgentId, Behavior, cancellationToken))
@@ -138,10 +89,10 @@ public sealed class PartyService : IHostedService
return true;
}
public async Task<string?> GetPartyLoadout(CancellationToken cancellationToken)
public Task<PartyLoadout?> GetPartyLoadout(CancellationToken cancellationToken)
{
var scopedLogger = this.logger.CreateScopedLogger();
var teamBuild = await this.gameThreadService.QueueOnGameThread(() =>
return this.gameThreadService.QueueOnGameThread(() =>
{
unsafe
{
@@ -182,47 +133,25 @@ public sealed class PartyService : IHostedService
.OfType<(uint AgentId, BuildEntry BuildEntry, API.Interop.GWCA.GW.HeroBehavior Behavior)>()
.ToList();
var teamBuildEntry = this.buildTemplateManager.CreateTeamBuild();
teamBuildEntry.Builds.Clear();
var partyComposition = new List<PartyCompositionMetadataEntry>();
foreach (var t in buildTuples.OrderBy(t => t.AgentId == playerId ? 0 : 1))
{
var singleBuild = this.buildTemplateManager.CreateSingleBuild(t.BuildEntry);
teamBuildEntry.Builds.Add(singleBuild);
if (t.AgentId == playerId)
return new PartyLoadout(
[.. buildTuples.Select(t =>
{
partyComposition.Add(new PartyCompositionMetadataEntry
if (t.AgentId == playerId)
{
Type = PartyCompositionMemberType.MainPlayer,
Index = teamBuildEntry.Builds.Count - 1
});
}
else if (heroes.Value.FirstOrDefault(h => h.AgentId == t.AgentId) is HeroPartyMember hero &&
hero.AgentId == t.AgentId)
{
partyComposition.Add(new PartyCompositionMetadataEntry
return new PartyLoadoutEntry(0, Shared.Models.Api.HeroBehavior.Undefined, t.BuildEntry);
}
else if (heroes.Value.FirstOrDefault(h => h.AgentId == t.AgentId) is HeroPartyMember hero &&
hero.AgentId == t.AgentId)
{
Type = PartyCompositionMemberType.Hero,
Index = teamBuildEntry.Builds.Count - 1,
HeroId = (int)hero.HeroId,
Behavior = (Shared.Models.Api.HeroBehavior)t.Behavior
});
}
}
return new PartyLoadoutEntry((int)hero.HeroId, (Shared.Models.Api.HeroBehavior)t.Behavior, t.BuildEntry);
}
teamBuildEntry.PartyComposition = partyComposition;
return teamBuildEntry;
return default;
})
.Where(t => t is not null)
.OfType<PartyLoadoutEntry>()]);
}
}, cancellationToken);
if (teamBuild is null)
{
return default;
}
return this.buildTemplateManager.EncodeTemplate(teamBuild);
}
public async Task<bool> KickAllHeroes(CancellationToken cancellationToken)
@@ -303,210 +232,6 @@ public sealed class PartyService : IHostedService
return result;
}
private unsafe bool DecodeTemplateHeader(string templateCode, WrappedPointer<SkillTemplate> skillTemplate)
{
var scopedLogger = this.logger.CreateScopedLogger();
this.cachedTemplateCode = default;
// Clean up any previous floating preview when a new template is being decoded
this.CleanupFloatingPreview();
if (!this.buildTemplateManager.TryDecodeTemplate(templateCode, out var build) ||
build is not TeamBuildEntry teamBuildEntry ||
teamBuildEntry.Builds.Count is 0)
{
return false;
}
this.cachedTemplateCode = templateCode;
PopulateSkillTemplate(teamBuildEntry.Builds[0], skillTemplate);
scopedLogger.LogDebug("Decoded skill template from template code: {templateCode}", templateCode);
return true;
}
private bool LoadSkillTemplate(SkillTemplate template)
{
var scopedLogger = this.logger.CreateScopedLogger();
if (this.cachedTemplateCode is null)
{
return false;
}
var pendingLoadout = this.cachedTemplateCode;
this.cachedTemplateCode = default;
scopedLogger.LogDebug("Loading skill template from cached template code: {templateCode}", pendingLoadout);
Task.Run(async () => await this.SetPartyLoadout(pendingLoadout, CancellationToken.None));
// Return false to allow the normal template loading to continue.
// Returning true would block the original LoadSkillTemplate call chain,
// leaving the game with uninitialized template data and causing
// TemplatesSkillsCanApply assertion failures.
return false;
}
/// <summary>
/// Intercepts TemplatesSummary_PopulateSkillData when a team build is cached.
/// Blocks the game's own rendering and creates our own floating frame
/// containing ALL builds (0 through N-1), positioned near the template dialog.
/// Returns true to suppress the game's PopulateSkillData entirely.
/// </summary>
private unsafe bool PopulateSkillData(WrappedPointer<TemplateSummaryFrameData> frameData, WrappedPointer<SkillTemplate> skillTemplateData)
{
// Reentrancy guard — PopulateSkillTemplatePreview internally calls the game's
// PopulateSkillData which re-enters this hook for each child frame we create.
if (this.isPopulatingExtraBuilds)
{
return false;
}
var scopedLogger = this.logger.CreateScopedLogger();
if (this.cachedTemplateCode is null)
{
this.CleanupFloatingPreview();
return false;
}
if (!this.buildTemplateManager.TryDecodeTemplate(this.cachedTemplateCode, out var build) ||
build is not TeamBuildEntry teamBuild ||
teamBuild.Builds.Count <= 1)
{
this.CleanupFloatingPreview();
return false;
}
// If we already have a floating preview for this template, suppress the game's draw
if (this.floatingPreviewFrame != 0)
{
return true;
}
scopedLogger.LogDebug(
"Creating floating preview for all {count} builds",
teamBuild.Builds.Count);
// Create a container frame for ALL builds (including build 0).
fixed (char* label = "DaybreakTeamPreview")
{
var floatingFrame = GWCA.GW.FrameMgr.CreateContainerFrame(
(ushort*)label, 50f, 2f, 4f);
if (floatingFrame is null)
{
scopedLogger.LogWarning("Failed to create floating preview frame");
return false;
}
this.floatingPreviewFrame = (nint)floatingFrame;
this.isPopulatingExtraBuilds = true;
try
{
// Create and populate a child frame for EVERY build in the team
for (var i = 0; i < teamBuild.Builds.Count; i++)
{
var template = new SkillTemplate();
PopulateSkillTemplate(teamBuild.Builds[i], &template);
if (!GWCA.GW.FrameMgr.PopulateSkillTemplatePreview(floatingFrame, &template, (uint)i))
{
scopedLogger.LogWarning("Failed to create and populate preview for build {index}", i);
continue;
}
}
}
finally
{
this.isPopulatingExtraBuilds = false;
}
// Compute desired height for all builds
var desiredH = GWCA.GW.FrameMgr.GetContainerDesiredHeight(floatingFrame);
scopedLogger.LogDebug("Container created with {count} builds, desiredHeight={h}",
teamBuild.Builds.Count, desiredH);
// Defer positioning until the game's layout pass has computed
// screen coordinates for the original summary frame. We use that
// frame's position as an anchor even though we're replacing its content.
var origFrameId = frameData.Pointer->FrameId;
var floatingFramePtr = floatingFrame;
var height = desiredH;
_ = this.gameThreadService.QueueOnGameThread(() =>
{
this.PositionFloatingPreview(origFrameId, floatingFramePtr, height, retriesRemaining: 10);
}, CancellationToken.None, forceEnqueue: true);
}
// Return true to suppress the game's own PopulateSkillData for build 0.
// We're drawing everything ourselves.
return true;
}
/// <summary>
/// Deferred positioning callback. Waits for the original summary frame
/// to have valid screen coordinates after the layout pass, then positions
/// the floating preview at the same location (replacing it visually).
/// </summary>
private unsafe void PositionFloatingPreview(uint origFrameId, Frame* floatingFrame, float desiredH, int retriesRemaining)
{
var scopedLogger = this.logger.CreateScopedLogger();
var origFrame = GWCA.GW.UI.GetFrameById(origFrameId);
if (origFrame is null)
{
scopedLogger.LogWarning("Original frame {fid} no longer valid", origFrameId);
return;
}
var origPos = (FramePositionData*)((byte*)origFrame + 0xD8);
var origHeight = origPos->ScreenTop - origPos->ScreenBottom;
var origWidth = origPos->ScreenRight - origPos->ScreenLeft;
if (origHeight <= 0f || origWidth <= 0f)
{
if (retriesRemaining <= 0)
{
scopedLogger.LogWarning("Original frame still has zero dimensions after all retries");
return;
}
_ = this.gameThreadService.QueueOnGameThread(() =>
{
this.PositionFloatingPreview(origFrameId, floatingFrame, desiredH, retriesRemaining - 1);
}, CancellationToken.None, forceEnqueue: true);
return;
}
// Position our container at the same location as the original summary frame,
// extending downward to fit all builds.
var fpos = (FramePositionData*)((byte*)floatingFrame + 0xD8);
fpos->Flags = 0;
fpos->Left = origPos->ScreenLeft;
fpos->Top = origPos->ScreenTop;
fpos->Right = origPos->ScreenRight;
fpos->Bottom = origPos->ScreenTop - desiredH;
fpos->ScreenLeft = origPos->ScreenLeft;
fpos->ScreenTop = origPos->ScreenTop;
fpos->ScreenRight = origPos->ScreenRight;
fpos->ScreenBottom = origPos->ScreenTop - desiredH;
GWCA.GW.UI.TriggerFrameRedraw(floatingFrame);
// Hide the original frame since we're replacing it
GWCA.GW.UI.SetFrameVisible(origFrame, false);
// Layout children within the now-sized container
GWCA.GW.FrameMgr.LayoutContainer(floatingFrame);
scopedLogger.LogDebug(
"Positioned at Screen=[L={sl}, B={sb}, R={sr}, T={st}], origFrame hidden",
fpos->ScreenLeft, fpos->ScreenBottom, fpos->ScreenRight, fpos->ScreenTop);
}
private unsafe void CleanupFloatingPreview()
{
if (this.floatingPreviewFrame != 0)
{
var frame = (Frame*)this.floatingPreviewFrame;
GWCA.GW.FrameMgr.DestroyFloatingFrame(frame);
this.floatingPreviewFrame = 0;
}
}
private async Task<bool> IsInValidOutpost(CancellationToken cancellationToken)
{
return await this.gameThreadService.QueueOnGameThread(() =>
@@ -515,25 +240,21 @@ public sealed class PartyService : IHostedService
}, cancellationToken);
}
private bool SpawnHeroes(TeamBuildEntry partyLoadout)
private unsafe bool SpawnHeroes(PartyLoadout partyLoadout)
{
var scopedLogger = this.logger.CreateScopedLogger();
scopedLogger.LogDebug("Spawning {heroCount} heroes for party loadout", partyLoadout.PartyComposition?.AsValueEnumerable().Count(c => c.HeroId != 0) ?? 0);
foreach ((var index, var entry) in partyLoadout.Builds.AsValueEnumerable().OrderBy(e => e.Primary.Id)
.Select((e, i) => (e, partyLoadout.PartyComposition?.First(e => e.Index == i)))
.OfType<(SingleBuildEntry, PartyCompositionMetadataEntry)>()
.Where(t => t.Item2.Type is PartyCompositionMemberType.Hero))
scopedLogger.LogDebug("Spawning {heroCount} heroes for party loadout", partyLoadout.Entries.AsValueEnumerable().Count(c => c.HeroId != 0));
foreach (var entry in partyLoadout.Entries.AsValueEnumerable().OrderBy(e => e.Build.Primary))
{
if (entry.HeroId is not null &&
entry.HeroId != 0 &&
Hero.TryParse(entry.HeroId.Value, out var hero))
if (entry.HeroId != 0 &&
Hero.TryParse(entry.HeroId, out var hero))
{
scopedLogger.LogInformation("Adding hero [{heroId}] [{heroName}] with behavior {behavior}", entry.HeroId, hero.Name, entry.Behavior ?? Shared.Models.Api.HeroBehavior.Guard);
scopedLogger.LogInformation("Adding hero [{heroId}] [{heroName}] with behavior {behavior}", entry.HeroId, hero.Name, entry.HeroBehavior);
this.partyContextService.AddHero((uint)entry.HeroId);
}
else
{
scopedLogger.LogWarning("Invalid hero entry in party loadout: {heroId}", entry.HeroId ?? -1);
scopedLogger.LogWarning("Invalid hero entry in party loadout: {heroId}", entry.HeroId);
continue;
}
}
@@ -541,7 +262,7 @@ public sealed class PartyService : IHostedService
return true;
}
private unsafe bool ApplyBuilds(TeamBuildEntry partyLoadout)
private unsafe bool ApplyBuilds(PartyLoadout partyLoadout)
{
var scopedLogger = this.logger.CreateScopedLogger();
scopedLogger.LogDebug("Applying builds");
@@ -578,71 +299,103 @@ public sealed class PartyService : IHostedService
return false;
}
for (var buildIndex = 0; buildIndex < partyLoadout.Builds.Count; buildIndex++)
{
var build = partyLoadout.Builds[buildIndex];
var composition = partyLoadout.PartyComposition?.FirstOrDefault(c => c.Index == buildIndex);
if (composition is null)
var parsedEntries = partyLoadout.Entries.AsValueEnumerable()
.Select(entry =>
{
continue;
}
uint agentId;
if (composition.HeroId is 0 or null)
{
agentId = playerId.Value;
}
else
{
var hero = heroes.Value.AsValueEnumerable().FirstOrDefault(h => (int)h.HeroId == composition.HeroId.GetValueOrDefault());
agentId = hero.AgentId;
}
var professionContext = professions.Value.AsValueEnumerable().FirstOrDefault(p => p.AgentId == agentId);
var validSkills = agentId == playerId
? unlockedSkills
: accountContext.Pointer->UnlockedAccountSkills;
var unlockedProfessionsFlags = agentId == playerId
? playerProfessionContext.UnlockedProfessions
: uint.MaxValue;
if (!this.buildTemplateManager.CanTemplateApply(
new BuildTemplateValidationRequest(
(uint)build.Primary.Id,
(uint)build.Secondary.Id,
[.. build.Skills.Select(s => (uint)s.Id)],
(uint)(int)professionContext.Primary,
unlockedProfessionsFlags,
[.. validSkills])))
{
if (composition.HeroId is 0 or null)
if (entry.HeroId is 0)
{
scopedLogger.LogError("Cannot apply build for player");
return (entry, playerId);
}
else
{
scopedLogger.LogError("Cannot apply build for hero {heroId}", composition.HeroId);
var hero = heroes.Value.AsValueEnumerable().FirstOrDefault(h => (int)h.HeroId == entry.HeroId);
return (entry, hero.AgentId);
}
})
.Select(t => (t.entry, t.playerId, professions.Value.AsValueEnumerable().FirstOrDefault(p => p.AgentId == t.playerId)))
.Select(t =>
{
var validSkills = t.playerId == playerId
? unlockedSkills
: accountContext.Pointer->UnlockedAccountSkills;
var unlockedProfessionsFlags = t.playerId == playerId
? playerProfessionContext.UnlockedProfessions
: uint.MaxValue;
if (!this.buildTemplateManager.CanTemplateApply(
new BuildTemplateValidationRequest(
(uint)t.entry.Build.Primary,
(uint)t.entry.Build.Secondary,
[.. t.entry.Build.Skills],
(uint)t.Item3.Primary,
unlockedProfessionsFlags,
[.. validSkills])))
{
return (t.entry, t.playerId, t.Item3, false);
}
return (t.entry, t.playerId, t.Item3, true);
})
.Where(t =>
{
if (!t.Item4)
{
if (t.entry.HeroId is 0)
{
scopedLogger.LogError("Cannot apply build for player");
}
else
{
scopedLogger.LogError("Cannot apply build for hero {heroId}", t.entry.HeroId);
}
}
return t.Item4;
});
foreach (var (entry, maybeAgentId, _, _) in parsedEntries)
{
if (maybeAgentId is not uint agentId)
{
continue;
}
var skillTemplate = new SkillTemplate();
PopulateSkillTemplate(build, &skillTemplate);
scopedLogger.LogInformation("Applying build for agent {agentId} with primary {primary} and secondary {secondary}", agentId, build.Primary, build.Secondary);
var attributeIds = new AttributeArray12();
var attributeValues = new Array12Uint();
for (var i = 0; i < entry.Build.Attributes.Count && i < 12; i++)
{
attributeIds[i] = (GWCA.GW.Constants.Attribute)entry.Build.Attributes[i].Id;
attributeValues[i] = (uint)entry.Build.Attributes[i].BasePoints;
}
var skills = new SkillIDArray8();
for (var i = 0; i < entry.Build.Skills.Count && i < 8; i++)
{
skills[i] = (GWCA.GW.Constants.SkillID)entry.Build.Skills[i];
}
var skillTemplate = new SkillTemplate
{
Primary = (GWCA.GW.Constants.Profession)entry.Build.Primary,
Secondary = (GWCA.GW.Constants.Profession)entry.Build.Secondary,
AttributesCount = (uint)entry.Build.Attributes.Count,
AttributeIds = attributeIds,
Skills = skills
};
Unsafe.CopyBlock(skillTemplate.AttributeValues, Unsafe.AsPointer(ref attributeValues), (uint)(12 * sizeof(uint)));
scopedLogger.LogInformation("Applying build for agent {agentId} with primary {primary} and secondary {secondary}", agentId, entry.Build.Primary, entry.Build.Secondary);
this.skillbarContextService.LoadBuild(agentId, &skillTemplate);
}
return true;
}
private unsafe List<(uint AgentId, Shared.Models.Api.HeroBehavior Behavior)>? GetHeroBehaviorSetup(TeamBuildEntry teamBuild)
private unsafe List<(uint AgentId, Shared.Models.Api.HeroBehavior Behavior)>? GetHeroBehaviorSetup(PartyLoadout partyLoadout)
{
var scopedLogger = this.logger.CreateScopedLogger();
scopedLogger.LogDebug("Getting hero behavior setup");
if (!this.gameContextService.GetGameContext().TryGetPlayerParty(out _, out _, out var heroes, out _) ||
!heroes.HasValue)
{
@@ -650,12 +403,6 @@ public sealed class PartyService : IHostedService
return default;
}
if (teamBuild.PartyComposition is null)
{
scopedLogger.LogWarning("No party composition metadata in team build");
return default;
}
return heroes.Value.AsValueEnumerable()
.Select(h =>
{
@@ -664,14 +411,14 @@ public sealed class PartyService : IHostedService
return default;
}
var entry = teamBuild.PartyComposition.AsValueEnumerable().FirstOrDefault(e => e.HeroId == (int)h.HeroId);
var entry = partyLoadout.Entries.AsValueEnumerable().FirstOrDefault(e => e.HeroId == (int)h.HeroId);
if (entry is null)
{
scopedLogger.LogWarning("No entry found for hero {heroId}", h.HeroId);
return default;
}
return (h.AgentId, entry.Behavior ?? Shared.Models.Api.HeroBehavior.Guard);
return (h.AgentId, entry.HeroBehavior);
})
.Where(t => t.AgentId is not 0)
.ToList();
@@ -722,7 +469,7 @@ public sealed class PartyService : IHostedService
var playerParty = GWCA.GW.PartyMgr.GetPartyInfo(0);
var playerId = charContext->PlayerNumber;
var offset = 0;
foreach (var hero in playerParty->Heroes)
foreach(var hero in playerParty->Heroes)
{
if (hero.OwnerPlayerId == playerId)
{
@@ -760,28 +507,4 @@ public sealed class PartyService : IHostedService
(uint)skillbar.Skills[7].SkillId
]);
}
private static unsafe void PopulateSkillTemplate(SingleBuildEntry build, SkillTemplate* skillTemplate)
{
var attributeIds = new AttributeArray12();
var attributeValues = new Array12Uint();
for (var i = 0; i < build.Attributes.Count && i < 12; i++)
{
attributeIds[i] = (GWCA.GW.Constants.Attribute)(build.Attributes[i].Attribute?.Id ?? 0);
attributeValues[i] = (uint)build.Attributes[i].Points;
}
var skills = new SkillIDArray8();
for (var i = 0; i < build.Skills.Count && i < 8; i++)
{
skills[i] = (GWCA.GW.Constants.SkillID)build.Skills[i].Id;
}
skillTemplate->Primary = (GWCA.GW.Constants.Profession)build.Primary.Id;
skillTemplate->Secondary = (GWCA.GW.Constants.Profession)build.Secondary.Id;
skillTemplate->AttributesCount = (uint)Math.Min(build.Attributes.Count, 12);
skillTemplate->AttributeIds = attributeIds;
skillTemplate->Skills = skills;
Unsafe.CopyBlock(skillTemplate->AttributeValues, Unsafe.AsPointer(ref attributeValues), (uint)(12 * sizeof(uint)));
}
}
@@ -1,5 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Daybreak.API.Services.Interop;
using Daybreak.API.Services.Interop;
using Daybreak.Shared.Services.BuildTemplates;
using Daybreak.Shared.Services.BuildTemplates.Parsers;
@@ -17,10 +16,12 @@ public static class WebApplicationBuilderExtensions
builder.Services.AddSingleton<ChatService>();
builder.Services.AddSingleton<MainPlayerService>();
builder.Services.AddSingleton<CharacterSelectService>();
builder.Services.AddSingleton<PartyService>();
builder.Services.AddSingleton<UIService>();
builder.Services.AddSingleton<LoginService>();
builder.Services.AddSingleton<InventoryService>();
builder.Services.AddSingleton<GameThreadService>();
builder.Services.AddSingleton<SkillbarContextService>();
builder.Services.AddSingleton<GameContextService>();
builder.Services.AddSingleton<InstanceContextService>();
builder.Services.AddSingleton<AgentContextService>();
@@ -28,18 +29,6 @@ public static class WebApplicationBuilderExtensions
builder.Services.AddSingleton<PartyContextService>();
builder.Services.AddSingleton<PreferencesService>();
builder.Services.AddSingleton<UIContextService>();
builder.Services.AddHostedSingleton<PartyService>();
builder.Services.AddHostedSingleton<SkillbarContextService>();
builder.Services.AddSingleton<IInteropHealthService>(sp => sp.GetRequiredService<SkillbarContextService>());
return builder;
}
public static IServiceCollection AddHostedSingleton<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TService>(this IServiceCollection services)
where TService : class, IHostedService
{
services.AddSingleton<TService>();
services.AddHostedService(sp => sp.GetRequiredService<TService>());
return services;
}
}
@@ -1,8 +1,4 @@
using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.Routing.Constraints;
using Microsoft.Extensions.Options;
using Swashbuckle.AspNetCore.SwaggerGen;
using System.Text.Json;
using Microsoft.AspNetCore.Routing.Constraints;
namespace Daybreak.API.Swagger;
@@ -11,14 +7,6 @@ public static class WebApplicationBuilderExtensions
public static WebApplicationBuilder WithSwagger(this WebApplicationBuilder builder)
{
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddTransient<ISerializerDataContractResolver>(sp =>
{
var opts = sp.GetRequiredService<IOptions<JsonOptions>>().Value?.SerializerOptions
?? new JsonSerializerOptions(JsonSerializerDefaults.Web);
return new JsonSerializerDataContractResolver(opts);
});
builder.Services.Configure<RouteOptions>(static options =>
{
options.SetParameterPolicy<RegexInlineRouteConstraint>("regex");
@@ -1,11 +1,13 @@
namespace Daybreak.API.Swagger;
using Scalar.AspNetCore;
namespace Daybreak.API.Swagger;
public static class WebApplicationExtensions
{
public static WebApplication UseSwaggerWithUI(this WebApplication app)
{
app.UseSwagger();
app.UseSwaggerUI();
app.MapOpenApi();
app.MapScalarApiReference();
return app;
}
}
@@ -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;
}
@@ -39,4 +39,33 @@ public sealed class FocusViewOptions
[JsonPropertyName(nameof(EnergyDisplay))]
[OptionName(Name = "Energy Display Mode", Description = "Sets how should the energy display show the information")]
public PointsDisplay EnergyDisplay { get; set; }
[JsonPropertyName(nameof(GridColumns))]
[OptionName(Name = "Layout Grid Columns", Description = "Number of columns in the focus view layout grid")]
[OptionIgnore]
public int GridColumns { get; set; } = DefaultGridColumns;
[JsonPropertyName(nameof(GridRows))]
[OptionName(Name = "Layout Grid Rows", Description = "Number of rows in the focus view layout grid")]
[OptionIgnore]
public int GridRows { get; set; } = DefaultGridRows;
[JsonPropertyName(nameof(Layout))]
[OptionName(Name = "Layout", Description = "Tile-based layout of the focus view components")]
public List<FocusViewTile> Layout { get; set; } = DefaultLayout();
public const int DefaultGridColumns = 20;
public const int DefaultGridRows = 16;
public static List<FocusViewTile> DefaultLayout() =>
[
new() { Component = FocusViewComponent.Character, Column = 1, Row = 1, ColumnSpan = 10, RowSpan = 3 },
new() { Component = FocusViewComponent.PlayerResources, Column = 1, Row = 4, ColumnSpan = 10, RowSpan = 3 },
new() { Component = FocusViewComponent.Vanquish, Column = 1, Row = 7, ColumnSpan = 10, RowSpan = 1 },
new() { Component = FocusViewComponent.Title, Column = 1, Row = 8, ColumnSpan = 10, RowSpan = 1 },
new() { Component = FocusViewComponent.CurrentMap, Column = 1, Row = 9, ColumnSpan = 10, RowSpan = 1 },
new() { Component = FocusViewComponent.QuestLog, Column = 1, Row = 10, ColumnSpan = 10, RowSpan = 7 },
new() { Component = FocusViewComponent.Build, Column = 11, Row = 1, ColumnSpan = 10, RowSpan = 5 },
new() { Component = FocusViewComponent.Browser, Column = 11, Row = 6, ColumnSpan = 10, RowSpan = 11 },
];
}
@@ -9,6 +9,7 @@ using Daybreak.Services.ApplicationArguments.ArgumentHandling;
using Daybreak.Services.ApplicationLauncher;
using Daybreak.Services.BuildTemplates;
using Daybreak.Services.Credentials;
using Daybreak.Services.Formatting;
using Daybreak.Services.DirectSong;
using Daybreak.Services.Downloads;
using Daybreak.Services.Events;
@@ -48,12 +49,12 @@ using Daybreak.Services.TradeChat.Models;
using Daybreak.Services.TradeChat.Notifications;
using Daybreak.Services.UMod;
using Daybreak.Services.Updater;
using Daybreak.Services.Wiki;
using Daybreak.Shared.Models.Plugins;
using Daybreak.Shared.Services.Api;
using Daybreak.Shared.Services.ApplicationArguments;
using Daybreak.Shared.Services.ApplicationLauncher;
using Daybreak.Shared.Services.BuildTemplates;
using Daybreak.Shared.Services.Formatting;
using Daybreak.Shared.Services.BuildTemplates.Parsers;
using Daybreak.Shared.Services.Credentials;
using Daybreak.Shared.Services.DirectSong;
@@ -82,7 +83,6 @@ using Daybreak.Shared.Services.Toolbox;
using Daybreak.Shared.Services.TradeChat;
using Daybreak.Shared.Services.UMod;
using Daybreak.Shared.Services.Updater;
using Daybreak.Shared.Services.Wiki;
using Daybreak.Services.Graph;
using Daybreak.Themes;
using Daybreak.Views;
@@ -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
@@ -225,6 +231,7 @@ public class ProjectConfiguration : PluginConfigurationBase
services.AddScoped<IOnboardingService, OnboardingService>();
services.AddScoped<IExperienceCalculator, ExperienceCalculator>();
services.AddScoped<IAttributePointCalculator, AttributePointCalculator>();
services.AddScoped<ISkillNumberFormatter, SkillNumberFormatter>();
services.AddScoped<IDownloadService, DownloadService>();
services.AddScoped<IGuildWarsInstaller, IntegratedGuildwarsInstaller>();
services.AddScoped<IExceptionHandler, ExceptionHandler>();
@@ -248,14 +255,13 @@ public class ProjectConfiguration : PluginConfigurationBase
services.AddScoped<ILaunchConfigurationService, LaunchConfigurationService>();
services.AddScoped<IEventService, EventService>();
services.AddScoped<IApplicationArgumentService, ApplicationArgumentService>();
services.AddScoped<IWikiService, WikiService>();
services.AddScoped<IScreenshotService, ScreenshotService>();
services.AddHostedSingleton<IApplicationUpdater, ApplicationUpdater>();
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>();
@@ -383,8 +390,11 @@ public class ProjectConfiguration : PluginConfigurationBase
public override void RegisterMods(IModsProducer modsManager)
{
modsManager.RegisterMod<IGuildWarsVersionChecker, GuildWarsVersionChecker>();
modsManager.RegisterMod<IDaybreakApiService, DaybreakApiService>();
// Toolbox must be registered before Daybreak API so that Toolbox injects first
// and loads its gwca.dll. Daybreak API will then reuse the already-loaded gwca.dll
// instead of loading a second copy, avoiding duplicate native module loads.
modsManager.RegisterMod<IToolboxService, ToolboxService>();
modsManager.RegisterMod<IDaybreakApiService, DaybreakApiService>();
modsManager.RegisterMod<IUModService, UModService>();
modsManager.RegisterMod<IDirectSongService, DirectSongService>(singleton: true);
}
@@ -565,10 +575,6 @@ public class ProjectConfiguration : PluginConfigurationBase
.WithMessageHandler(SetupLoggingAndMetrics<ScopedApiContext>)
.WithDefaultRequestHeadersSetup(SetupDaybreakUserAgent)
.Build()
.RegisterHttpClient<WikiService>()
.WithMessageHandler(SetupLoggingAndMetrics<WikiService>)
.WithDefaultRequestHeadersSetup(SetupDaybreakUserAgent)
.Build()
.RegisterHttpClient<ReShadeService>()
.WithMessageHandler(SetupLoggingAndMetrics<ReShadeService>)
.WithDefaultRequestHeadersSetup(SetupDaybreakUserAgent)
+3 -2
View File
@@ -39,14 +39,15 @@
</Target>
<ItemGroup>
<PackageReference Include="Elastic.OpenTelemetry" />
<PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components.Icons" />
<PackageReference Include="Microsoft.AspNetCore.Components" />
<PackageReference Include="Microsoft.Identity.Client" />
<PackageReference Include="OpenTelemetry.Exporter.Console" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="OpenTelemetry.Instrumentation.Process" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
<PackageReference Include="PeNet" />
<PackageReference Include="Plumsy" />
@@ -65,7 +66,7 @@
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.Debug" />
<PackageReference Include="SharpCompress" />
<PackageReference Include="SixLabors.ImageSharp" />
<PackageReference Include="StbImageSharp" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" />
</ItemGroup>
@@ -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);
}
}
@@ -4,7 +4,6 @@ using Daybreak.Shared.Services.ExceptionHandling;
using Daybreak.Shared.Services.Notifications;
using Microsoft.Extensions.Logging;
using System.Core.Extensions;
using System.Diagnostics;
using System.Extensions;
using System.Extensions.Core;
using System.Logging;
@@ -42,7 +41,7 @@ internal sealed class ExceptionHandler(
var exceptionDate = DateTime.UtcNow;
if (this.logger is null)
{
WriteCrashFiles(e, exceptionDate);
this.WriteCrashFiles(e, exceptionDate);
return false;
}
@@ -54,7 +53,7 @@ internal sealed class ExceptionHandler(
case HandleResult.Handled:
return true;
case HandleResult.Fatal:
WriteCrashFiles(e, exceptionDate);
this.WriteCrashFiles(e, exceptionDate);
return false;
case HandleResult.Unhandled:
default:
@@ -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);
}
}
@@ -0,0 +1,54 @@
using System.Globalization;
using Daybreak.Shared.Services.Formatting;
namespace Daybreak.Services.Formatting;
internal sealed class SkillNumberFormatter : ISkillNumberFormatter
{
public string Format(double? value)
{
if (value is not double d)
{
return string.Empty;
}
if (d < 0)
{
return "-" + this.Format(-d);
}
var whole = (long)Math.Floor(d);
var fractional = d - whole;
var fractionGlyph = TryGetFractionGlyph(fractional);
if (fractionGlyph is null)
{
return d == whole
? whole.ToString(CultureInfo.InvariantCulture)
: d.ToString("0.##", CultureInfo.InvariantCulture);
}
return whole == 0
? fractionGlyph
: whole.ToString(CultureInfo.InvariantCulture) + fractionGlyph;
}
public string FormatPercent(double? value)
{
if (value is not double d)
{
return string.Empty;
}
return Math.Round(d * 100).ToString("0", CultureInfo.InvariantCulture) + "%";
}
private static string? TryGetFractionGlyph(double fractional) => fractional switch
{
< 0.001 => null,
< 0.26 and > 0.24 => "¹⁄₄",
< 0.51 and > 0.49 => "¹⁄₂",
< 0.76 and > 0.74 => "³⁄₄",
_ => null,
};
}
@@ -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;
@@ -310,15 +310,19 @@ internal sealed class IntegratedGuildwarsInstaller(
{
var scopedLogger = this.logger.CreateScopedLogger(flowIdentifier: destinationPath);
var exePath = Path.Combine(destinationPath, ExeName);
scopedLogger.LogDebug("Fetching latest Guild Wars executable");
var latestGwPath = await this.FetchLatestGuildwarsInternal(progress, cancellationToken);
if (latestGwPath is null)
{
scopedLogger.LogError("Failed to fetch latest Guild Wars executable");
progress.Report(ProgressFailed);
return false;
}
scopedLogger.LogDebug("Copying latest Guild Wars executable to destination");
var filePath = Path.GetFullPath(exePath);
File.Copy(latestGwPath, filePath, true);
scopedLogger.LogDebug("Copied latest Guild Wars executable to destination");
progress.Report(ProgressStartingExecutable);
await Task.Delay(100, cancellationToken);
this.guildWarsExecutableManager.AddExecutable(filePath);
@@ -2,16 +2,16 @@
using PeNet.Header.Pe;
namespace Daybreak.Services.Guildwars.Utils;
/// <summary>
/// https://github.com/gwdevhub/gwlauncher/blob/master/GW%20Launcher/Guildwars/FileIdFinder.cs
/// </summary>
internal sealed class GuildWarsExecutableParser
{
// Used by older Guild Wars versions
private readonly static byte[] VersionPattern = [0x8B, 0xC8, 0x33, 0xDB, 0x39, 0x8D, 0xC0, 0xFD, 0xFF, 0xFF, 0x0F, 0x95, 0xC3];
// Used by newer Guild Wars versions
private readonly static byte?[] FileIdFunctionPattern = [0x55, 0x8B, 0xEC, 0x83, 0xEC, 0x08, 0xE8, null, null, null, null, 0x83, 0x3D, null, null, null, null, 0x00];
// Used by newer Guild Wars versions. Matches ProcessVersionUpdate prologue:
// push ebp; mov ebp,esp; sub esp,N; call <addr>; cmp dword ptr [global], 0
// Byte 5 (stack size) is wildcarded since it changes across builds.
private readonly static byte?[] FileIdFunctionPattern = [0x55, 0x8B, 0xEC, 0x83, 0xEC, null, 0xE8, null, null, null, null, 0x83, 0x3D, null, null, null, null, 0x00];
private readonly PeFile peFile;
private readonly ImageSectionHeader textSection;
@@ -38,13 +38,39 @@ internal sealed class GuildWarsExecutableParser
return Task.Run(() =>
{
var patternRva = this.FindWithWildcards(FileIdFunctionPattern);
var callRva = patternRva + 0x32;
var fileIdFunctionRva = this.FollowCall(callRva);
var fileIdFunctionRva = this.FindGetFileIdCall(patternRva);
var fileId = (int)this.Read(fileIdFunctionRva + 1);
return fileId;
}, cancellationToken);
}
// GetFileId is a tiny function: mov eax, <imm32>; ret (bytes: B8 xx xx xx xx C3).
// Scan E8 (call) instructions within the matched function and follow each one
// to find the call whose target matches that shape.
private uint FindGetFileIdCall(uint functionRva, int scanLength = 0x80)
{
for (int offset = 0; offset < scanLength; offset++)
{
var rva = functionRva + (uint)offset;
var posInFile = this.RvaToOffset(rva);
if (this.peFile.RawFile.ReadByte(posInFile) != 0xE8)
{
continue;
}
var targetRva = rva + (uint)BitConverter.ToInt32(this.peFile.RawFile.ToArray(), posInFile + 1) + 5;
var targetOffset = this.RvaToOffset(targetRva);
// Check for: B8 xx xx xx xx C3 (mov eax, imm32; ret)
if (this.peFile.RawFile.ReadByte(targetOffset) == 0xB8 &&
this.peFile.RawFile.ReadByte(targetOffset + 5) == 0xC3)
{
return targetRva;
}
}
throw new Exception("Couldn't find GetFileId call within the function");
}
private uint FindWithWildcards(byte?[] pattern, int offset = 0)
{
var buffer = this.peFile.RawFile.AsSpan((int)this.textSection.PointerToRawData, (int)this.textSection.SizeOfRawData);
@@ -94,24 +94,6 @@ internal sealed class LaunchConfigurationService(
return this.IsValidInternal(launchConfiguration);
}
public LaunchConfigurationWithCredentials? GetLastLaunchConfigurationWithCredentials()
{
return this.GetLaunchConfigurations().LastOrDefault();
}
public void SetLastLaunchConfigurationWithCredentials(LaunchConfigurationWithCredentials launchConfigurationWithCredentials)
{
var configs = this.liveUpdateableOptions.CurrentValue.LaunchConfigurations;
var maybeConfig = configs
.FirstOrDefault(l => l.CredentialsIdentifier == launchConfigurationWithCredentials.Credentials?.Identifier &&
l.Executable == launchConfigurationWithCredentials.ExecutablePath) ?? throw new InvalidOperationException("Provided launch configuration is not part of the known list of launch configurations");
configs.Remove(maybeConfig);
configs.Add(maybeConfig);
var options = this.liveUpdateableOptions.CurrentValue;
options.LaunchConfigurations = configs;
this.optionsProvider.SaveOption(options);
}
private bool SaveConfigurationInternal(LaunchConfigurationWithCredentials launchConfigurationWithCredentials)
{
var configs = this.liveUpdateableOptions.CurrentValue.LaunchConfigurations.ToList();
@@ -1,13 +1,12 @@
using Daybreak.Launch;
using Daybreak.Models;
using Serilog.Core;
using Serilog.Events;
using Serilog.Formatting.Display;
using System.Collections.Immutable;
namespace Daybreak.Services.Logging;
public sealed class InMemorySink : ILogEventSink, IDisposable
public sealed class InMemorySink : RedactingLogEventSink, IDisposable
{
private const int MaxLogEvents = 50000;
@@ -30,7 +29,7 @@ public sealed class InMemorySink : ILogEventSink, IDisposable
this.logEvents.Clear();
}
public void Emit(LogEvent logEvent)
protected override void EmitRedacted(LogEvent logEvent)
{
lock (this.snapShotLock)
{
@@ -0,0 +1,155 @@
using Serilog.Events;
using Serilog.Parsing;
using System.Buffers;
using System.Text.RegularExpressions;
namespace Daybreak.Services.Logging;
/// <summary>
/// Rewrites <see cref="LogEvent"/> instances so that sensitive user information (usernames,
/// e-mail addresses and passwords) is masked before it reaches a sink.
/// <para>
/// It relies on two complementary strategies:
/// <list type="bullet">
/// <item>Regex matching against the command-line style tokens (<c>-email &lt;value&gt;</c>,
/// <c>-password &lt;value&gt;</c>) that appear in rendered messages and property values.</item>
/// <item>Property-name matching, which masks any structured property named after a credential
/// field (Username, Password, Email) regardless of its value.</item>
/// </list>
/// </para>
/// </summary>
public static partial class LogEventRedactor
{
public const string RedactedValue = "[REDACTED]";
// Structured property names whose value must always be masked.
private static readonly HashSet<string> SensitivePropertyNames = new(StringComparer.OrdinalIgnoreCase)
{
"Username",
"Password",
"Email",
};
// Cheap, vectorized pre-filter: the (comparatively expensive) look-behind regex patterns are
// skipped unless one of these credential flags is present in the text. SearchValues performs a
// single multi-pattern scan (Teddy/Aho-Corasick internally) that outperforms both individual
// Contains calls and a hand-rolled automaton, and scales as the marker set grows. Every secret
// pattern below must be gated by one of these markers.
private static readonly SearchValues<string> SecretMarkers = SearchValues.Create(
["-email", "-password"],
StringComparison.OrdinalIgnoreCase);
// Patterns whose single match is the secret value to mask. The credential flag itself is kept
// in a look-behind so only the value is replaced.
private static readonly Regex[] SecretPatterns =
[
EmailArgumentRegex(),
PasswordArgumentRegex(),
];
/// <summary>
/// Returns a copy of <paramref name="logEvent"/> with sensitive information masked. The original
/// event is returned unchanged when nothing needs to be redacted.
/// </summary>
public static LogEvent Redact(LogEvent logEvent)
{
ArgumentNullException.ThrowIfNull(logEvent);
var redactedTemplate = RedactTemplate(logEvent.MessageTemplate);
var redactedProperties = logEvent.Properties
.Select(p => new LogEventProperty(p.Key, RedactPropertyValue(p.Key, p.Value)))
.ToList();
return new LogEvent(
logEvent.Timestamp,
logEvent.Level,
logEvent.Exception,
redactedTemplate,
redactedProperties);
}
/// <summary>
/// Masks the credential values embedded in an arbitrary string using the configured regex patterns.
/// </summary>
public static string RedactText(string? text)
{
if (string.IsNullOrEmpty(text))
{
return text ?? string.Empty;
}
// Fast path: avoid running the look-behind regexes over the overwhelming majority of log
// text that cannot contain a credential flag. This keeps redaction global (fail-safe) while
// costing only a vectorized substring scan for events that carry no secrets.
if (!MightContainSecret(text))
{
return text;
}
foreach (var pattern in SecretPatterns)
{
text = pattern.Replace(text, RedactedValue);
}
return text;
}
private static bool MightContainSecret(string text)
{
return text.AsSpan().ContainsAny(SecretMarkers);
}
private static MessageTemplate RedactTemplate(MessageTemplate template)
{
var tokens = template.Tokens
.Select(token => token is TextToken textToken
? new TextToken(RedactText(textToken.Text))
: token)
.ToList();
return new MessageTemplate(tokens);
}
private static LogEventPropertyValue RedactPropertyValue(string propertyName, LogEventPropertyValue value)
{
if (SensitivePropertyNames.Contains(propertyName))
{
return new ScalarValue(RedactedValue);
}
return RedactPropertyValue(value);
}
private static LogEventPropertyValue RedactPropertyValue(LogEventPropertyValue value)
{
switch (value)
{
case ScalarValue { Value: string stringValue }:
return new ScalarValue(RedactText(stringValue));
case SequenceValue sequence:
return new SequenceValue(sequence.Elements.Select(RedactPropertyValue));
case StructureValue structure:
var structureProperties = structure.Properties
.Select(p => new LogEventProperty(p.Name, RedactPropertyValue(p.Name, p.Value)));
return new StructureValue(structureProperties, structure.TypeTag);
case DictionaryValue dictionary:
var elements = dictionary.Elements
.Select(kvp => new KeyValuePair<ScalarValue, LogEventPropertyValue>(
kvp.Key,
RedactPropertyValue(kvp.Value)));
return new DictionaryValue(elements);
default:
return value;
}
}
[GeneratedRegex(@"(?<=-email[=\s])(""[^""]*""|'[^']*'|\S+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex EmailArgumentRegex();
[GeneratedRegex(@"(?<=-password[=\s])(""[^""]*""|'[^']*'|\S+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex PasswordArgumentRegex();
}
@@ -0,0 +1,27 @@
using Serilog.Core;
using Serilog.Events;
namespace Daybreak.Services.Logging;
/// <summary>
/// Base class for sinks that must never receive sensitive user information. Every emitted
/// <see cref="LogEvent"/> is passed through <see cref="LogEventRedactor"/> before being handed to
/// the concrete sink via <see cref="EmitRedacted"/>.
/// <para>
/// This lets us keep the Console sink (only visible to the local user) unredacted while masking
/// sensitive user information for sinks that persist or forward logs off the machine, such as the
/// in-memory sink used for exporting logs and the telemetry sink used to forward logs to the server.
/// </para>
/// </summary>
public abstract class RedactingLogEventSink : ILogEventSink
{
public void Emit(LogEvent logEvent)
{
this.EmitRedacted(LogEventRedactor.Redact(logEvent));
}
/// <summary>
/// Handles the log event after sensitive information has been masked.
/// </summary>
protected abstract void EmitRedacted(LogEvent logEvent);
}
@@ -153,7 +153,7 @@ internal sealed class NotificationService(
Title = title,
Description = description,
Metadata = metaData ?? string.Empty,
ExpirationTime = expirationTime ?? (DateTime.Now + TimeSpan.FromSeconds(5)),
ExpirationTime = expirationTime ?? (DateTime.UtcNow + TimeSpan.FromSeconds(5)),
Dismissible = dismissible,
Level = logLevel,
};
@@ -10,6 +10,7 @@ using Daybreak.Shared.Services.Injection;
using Daybreak.Shared.Services.Notifications;
using Daybreak.Shared.Services.Options;
using Daybreak.Shared.Services.ReShade;
using Daybreak.Shared.Services.Shell;
using Daybreak.Shared.Utils;
using Daybreak.Views.Mods;
using HtmlAgilityPack;
@@ -18,7 +19,6 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Core.Extensions;
using System.Data;
using System.Diagnostics;
using System.Extensions;
using System.Extensions.Core;
using System.IO.Compression;
@@ -34,6 +34,7 @@ internal sealed class ReShadeService(
IHttpClient<ReShadeService> httpClient,
IDownloadService downloadService,
IViewManager viewManager,
IShellExecutor shellExecutor,
ILogger<ReShadeService> logger) : IReShadeService
{
private const string PackagesIniUrl = "https://raw.githubusercontent.com/crosire/reshade-shaders/list/EffectPackages.ini";
@@ -68,6 +69,7 @@ internal sealed class ReShadeService(
private readonly IHttpClient<ReShadeService> httpClient = httpClient.ThrowIfNull();
private readonly IDownloadService downloadService = downloadService.ThrowIfNull();
private readonly IViewManager viewManager = viewManager.ThrowIfNull();
private readonly IShellExecutor shellExecutor = shellExecutor.ThrowIfNull();
private readonly ILogger<ReShadeService> logger = logger.ThrowIfNull();
public string Name => "ReShade";
@@ -187,6 +189,11 @@ internal sealed class ReShadeService(
public Task OnGuildWarsStarting(GuildWarsStartingContext guildWarsStartingContext, CancellationToken cancellationToken)
{
if (!this.IsInstalled)
{
return Task.CompletedTask;
}
var destinationDirectory = Path.GetFullPath(new FileInfo(guildWarsStartingContext.ApplicationLauncherContext.ExecutablePath).DirectoryName!);
EnsureFileExistsInGuildwarsDirectory(ReShadeLog, destinationDirectory);
EnsureFileExistsInGuildwarsDirectory(ReShadePreset, destinationDirectory);
@@ -198,11 +205,16 @@ internal sealed class ReShadeService(
public Task OnGuildWarsStartingDisabled(GuildWarsStartingDisabledContext guildWarsStartingDisabledContext, CancellationToken cancellationToken)
{
if (!this.IsInstalled)
{
return Task.CompletedTask;
}
var destinationDirectory = Path.GetFullPath(new FileInfo(guildWarsStartingDisabledContext.ApplicationLauncherContext.ExecutablePath).DirectoryName!);
var destination = Path.Combine(Path.GetFullPath(destinationDirectory), PresetsFolder);
if (Directory.Exists(destination))
{
Directory.Delete(destination);
Directory.Delete(destination, true);
}
return Task.CompletedTask;
@@ -210,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)
@@ -1,13 +1,10 @@
using System.Collections.Concurrent;
using System.Extensions.Core;
using System.Numerics;
using System.Runtime.InteropServices;
using Daybreak.Shared.Models;
using Daybreak.Shared.Models.ColorPalette;
using Daybreak.Shared.Services.Screenshots;
using Microsoft.Extensions.Logging;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using StbImageSharp;
using static Daybreak.Shared.Models.Themes.Theme;
using Color = System.Drawing.Color;
@@ -65,9 +62,9 @@ public sealed class ScreenshotService(
try
{
using var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var image = Image.Load<Rgba32>(fileStream);
var image = ImageResult.FromStream(fileStream, ColorComponents.RedGreenBlueAlpha);
var (dominantColor, isDark) = GetDominantColor(image);
var (dominantColor, isDark) = GetDominantColor(image.Data, image.Width, image.Height);
var closestAccent = GetClosestAccentColor(dominantColor);
var entry = new ScreenshotEntry(path, closestAccent, isDark ? LightDarkMode.Dark : LightDarkMode.Light);
EntryCache[path] = entry;
@@ -81,88 +78,25 @@ public sealed class ScreenshotService(
}
}
private static (Color Color, bool IsDark) GetDominantColor(Image<Rgba32> image)
private static (Color Color, bool IsDark) GetDominantColor(ReadOnlySpan<byte> rgba, int width, int height)
{
long totalR = 0, totalG = 0, totalB = 0;
int pixelCount = 0;
int stride = width * 4; // RGBA, 4 bytes per pixel
image.ProcessPixelRows(accessor =>
// Sample every SampleQuality-th pixel of every SampleQuality-th row for performance.
for (int y = 0; y < height; y += SampleQuality)
{
for (int y = 0; y < accessor.Height; y += SampleQuality)
int rowStart = y * stride;
for (int x = 0; x < width; x += SampleQuality)
{
var rowSpan = accessor.GetRowSpan(y);
var byteSpan = MemoryMarshal.AsBytes(rowSpan);
// Process using SIMD where possible
// Each Rgba32 pixel is 4 bytes: R, G, B, A
var vectorSize = Vector<byte>.Count;
var pixelsPerVector = vectorSize / 4; // 4 bytes per pixel
int x = 0;
// SIMD path: process multiple sampled pixels at once
if (Vector.IsHardwareAccelerated && rowSpan.Length >= pixelsPerVector * SampleQuality)
{
var sumR = Vector<long>.Zero;
var sumG = Vector<long>.Zero;
var sumB = Vector<long>.Zero;
int simdPixelCount = 0;
// Gather sampled pixels into arrays for vectorization
Span<int> rValues = stackalloc int[Vector<int>.Count];
Span<int> gValues = stackalloc int[Vector<int>.Count];
Span<int> bValues = stackalloc int[Vector<int>.Count];
// Collect sampled pixels for SIMD processing
while (x + (Vector<int>.Count * SampleQuality) <= rowSpan.Length)
{
for (int i = 0; i < Vector<int>.Count && x < rowSpan.Length; i++, x += SampleQuality)
{
var pixel = rowSpan[x];
rValues[i] = pixel.R;
gValues[i] = pixel.G;
bValues[i] = pixel.B;
simdPixelCount++;
}
var rVec = new Vector<int>(rValues);
var gVec = new Vector<int>(gValues);
var bVec = new Vector<int>(bValues);
// Widen to long and accumulate
Vector.Widen(rVec, out var rLow, out var rHigh);
Vector.Widen(gVec, out var gLow, out var gHigh);
Vector.Widen(bVec, out var bLow, out var bHigh);
sumR += rLow + rHigh;
sumG += gLow + gHigh;
sumB += bLow + bHigh;
}
// Sum up the vector lanes
for (int i = 0; i < Vector<long>.Count; i++)
{
totalR += sumR[i];
totalG += sumG[i];
totalB += sumB[i];
}
pixelCount += simdPixelCount;
}
// Scalar fallback for remaining pixels
for (; x < rowSpan.Length; x += SampleQuality)
{
var pixel = rowSpan[x];
totalR += pixel.R;
totalG += pixel.G;
totalB += pixel.B;
pixelCount++;
}
int i = rowStart + (x * 4);
totalR += rgba[i];
totalG += rgba[i + 1];
totalB += rgba[i + 2];
pixelCount++;
}
});
}
if (pixelCount == 0)
{
@@ -20,9 +20,7 @@ public sealed class VerifyNativeComponentsAction(
private const string ApiFolder = "Api";
private const string InstallerFolder = "Installer";
private static readonly string[] InjectorFiles = OperatingSystem.IsWindows()
? ["Daybreak.Injector.exe", "FASM.DLL"]
: ["Daybreak.Injector.exe", "FASM.DLL"];
private static readonly string[] InjectorFiles = ["Daybreak.Injector.exe"];
private static readonly string[] ApiFiles = ["Daybreak.API.dll"];
private static readonly string[] InstallerFiles = OperatingSystem.IsWindows()
? ["Daybreak.Installer.exe"]
@@ -44,7 +44,8 @@ internal sealed class TarGzExtractor(
// Use ReaderFactory which properly handles nested archives like .tar.gz
using var stream = File.OpenRead(sourceFile);
using var reader = ReaderFactory.OpenReader(stream, new ReaderOptions { ExtractFullPath = true, Overwrite = true });
using var reader = ReaderFactory.OpenReader(stream);
var extractionOptions = new ExtractionOptions { ExtractFullPath = true, Overwrite = true };
var processedEntries = 0;
@@ -68,7 +69,7 @@ internal sealed class TarGzExtractor(
progressTracker(0.5, entryKey);
// Extract the entry
reader.WriteEntryToDirectory(destinationDirectory);
reader.WriteEntryToDirectory(destinationDirectory, extractionOptions);
processedEntries++;
}
@@ -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() => [];
@@ -366,6 +366,10 @@ internal sealed class ToolboxService(
if (await this.processInjector.Inject(process, dll, cancellationToken))
{
scopedLogger.LogDebug("Injected toolbox dll");
// Give Toolbox a moment to load its bundled gwca.dll into the target process
// before any subsequent mod (e.g. Daybreak.API) tries to load its own copy.
// This ensures Daybreak.API's existing-module check sees gwca.dll and reuses it.
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
this.notificationService.NotifyInformation(
title: "GWToolbox started",
description: "GWToolbox has been injected");
@@ -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)
-299
View File
@@ -1,299 +0,0 @@
using Daybreak.Shared.Models.Guildwars;
using Daybreak.Shared.Services.Wiki;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.RegularExpressions;
using Attribute = Daybreak.Shared.Models.Guildwars.Attribute;
namespace Daybreak.Services.Wiki;
public sealed partial class WikiService(
IHttpClient<WikiService> httpClient,
ILogger<WikiService> logger)
: IWikiService
{
private const string WikiQueryTitlePlaceholder = "[TITLE]";
private const string WikiQueryUrl = $"https://wiki.guildwars.com/api.php?action=query&format=json&titles={WikiQueryTitlePlaceholder}&prop=revisions&rvprop=content&rvslots=main";
private static readonly ConcurrentDictionary<int, SkillDescription> DescriptionCache = [];
private readonly IHttpClient<WikiService> httpClient = httpClient;
private readonly ILogger<WikiService> logger = logger;
public async Task<SkillDescription?> GetSkillDescription(Skill skill, CancellationToken cancellationToken)
{
if (DescriptionCache.TryGetValue(skill.Id, out var cachedDescription))
{
return cachedDescription;
}
var wikiText = await this.GetWikiText(skill.Name, cancellationToken);
if (wikiText is null)
{
return default;
}
var description = ParseWikiText(wikiText);
if (description is null)
{
return default;
}
DescriptionCache[skill.Id] = description;
return description;
}
private async ValueTask<string?> GetWikiText(string name, CancellationToken cancellationToken)
{
while (true)
{
var urlEncodedName = UrlEncoder.Default.Encode(name);
var wikiUrl = WikiQueryUrl.Replace(WikiQueryTitlePlaceholder, urlEncodedName);
var response = await this.httpClient.GetAsync(wikiUrl, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return default;
}
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var wikiText = ExtractWikiTextFromJson(content);
if (wikiText is null)
{
return default;
}
var redirectTarget = ExtractRedirectTarget(wikiText);
if (redirectTarget is null)
{
return wikiText;
}
name = redirectTarget;
}
}
private static string? ExtractRedirectTarget(string wikiText)
{
if (string.IsNullOrWhiteSpace(wikiText))
{
return null;
}
var match = RedirectRegex().Match(wikiText.Trim());
return match.Success ? match.Groups[1].Value : null;
}
private static string? ExtractWikiTextFromJson(string jsonContent)
{
try
{
using var json = JsonDocument.Parse(jsonContent);
var root = json.RootElement;
if (!root.TryGetProperty("query", out var query) ||
!query.TryGetProperty("pages", out var pages))
{
return null;
}
// Get the first property (page) from pages object
using var pagesEnumerator = pages.EnumerateObject();
if (!pagesEnumerator.MoveNext())
{
return null;
}
var page = pagesEnumerator.Current.Value;
if (!page.TryGetProperty("revisions", out var revisions) ||
revisions.GetArrayLength() == 0)
{
return null;
}
var firstRevision = revisions[0];
if (firstRevision.TryGetProperty("slots", out var slots) &&
slots.TryGetProperty("main", out var main) &&
main.TryGetProperty("*", out var content))
{
return content.GetString();
}
return null;
}
catch (Exception)
{
return default;
}
}
private static SkillDescription? ParseWikiText(string wikiText)
{
var infoboxContent = ExtractSkillInfobox(CleanWikiText(wikiText));
if (infoboxContent is not null)
{
return new SkillDescription(
Id: ExtractField(infoboxContent, "id") is string idStr ? int.TryParse(idStr, out var id) ? id : -1 : -1,
Campaign: ExtractField(infoboxContent, "campaign") is string campaignStr ? Campaign.TryParse(campaignStr, out var campaign) ? campaign : Campaign.None : Campaign.None,
Name: ExtractField(infoboxContent, "name") ?? string.Empty,
Profession: ExtractField(infoboxContent, "profession") is string professionStr ? Profession.TryParse(professionStr, out var profession) ? profession : Profession.None : Profession.None,
Attribute: ExtractField(infoboxContent, "attribute") is string attrStr ? Attribute.TryParse(attrStr, out var attribute) ? attribute : Attribute.None : Attribute.None,
PveOnly: ExtractField(infoboxContent, "pve-only") is string pveOnlyStr && pveOnlyStr.StartsWith('y'), // Some fields contain yes, others y
Pvp: ExtractField(infoboxContent, "is-pvp") is string pvpStr && pvpStr.StartsWith('y'),
Elite: ExtractField(infoboxContent, "elite") is string eliteStr && eliteStr.StartsWith('y'), // Some fields contain yes, others y
Type: ExtractField(infoboxContent, "type") ?? string.Empty,
Energy: ExtractField(infoboxContent, "energy") ?? string.Empty,
Activation: ExtractField(infoboxContent, "activation") ?? string.Empty,
Recharge: ExtractField(infoboxContent, "recharge") ?? string.Empty,
Overcast: ExtractField(infoboxContent, "overcast") ?? string.Empty,
Adrenaline: ExtractField(infoboxContent, "adrenaline") ?? string.Empty,
Sacrifice: ExtractField(infoboxContent, "sacrifice") ?? string.Empty,
Upkeep: ExtractField(infoboxContent, "upkeep") ?? string.Empty,
Description: ExtractField(infoboxContent, "description") ?? string.Empty,
ConciseDescription: ExtractField(infoboxContent, "concise description") ?? string.Empty);
}
return default;
}
private static string CleanWikiText(string wikiText)
{
if (string.IsNullOrEmpty(wikiText))
{
return wikiText;
}
var cleaned = wikiText;
// Handle common fraction templates
cleaned = HalfFractionRegex().Replace(cleaned, "¹⁄₂");
cleaned = QuarterFractionRegex().Replace(cleaned, "¹⁄₄");
cleaned = ThreeQuarterFractionRegex().Replace(cleaned, "³⁄₄");
cleaned = ThreeOverTwoFractionRegex().Replace(cleaned, "³⁄₂");
// Handle decimal number templates
cleaned = DecimalRegex().Replace(cleaned, "$1");
// Handle {{gr|min|max}} templates (green range values)
cleaned = GreenRangeRegex().Replace(cleaned, "$1…$2");
cleaned = GreenRange2Regex().Replace(cleaned, "$1…$2");
cleaned = GrayRangeRegex().Replace(cleaned, "$1…$2");
// Handle simple links [[Link Text]]
cleaned = SimpleLinkRegex().Replace(cleaned, "$1");
// Handle {{gray|text}} templates
cleaned = GrayTextRegex().Replace(cleaned, "");
cleaned = GreyTextRegex().Replace(cleaned, "");
// Handle {{sic|text}} templates (remove the sic notation but keep the content)
cleaned = SicTextRegex().Replace(cleaned, "");
// Handle links with display text [[Link|Display Text]]
cleaned = LinkWithDisplayTextRegex().Replace(cleaned, "$2");
// Handle skill type formatting
cleaned = SkillTypeLinkRegex().Replace(cleaned, "$1");
// Remove any remaining wiki markup
cleaned = BoldTextRegex().Replace(cleaned, "$1"); // Bold
cleaned = ItalicTextRegex().Replace(cleaned, "$1"); // Italic
cleaned = HtmlTagRegex().Replace(cleaned, ""); // HTML tags
// Clean up extra whitespace
cleaned = WhitespaceRegex().Replace(cleaned, " ").Trim();
return cleaned;
}
private static string? ExtractSkillInfobox(string wikiText)
{
var startPattern = "{{Skill infobox";
var startIndex = wikiText.IndexOf(startPattern, StringComparison.OrdinalIgnoreCase);
if (startIndex == -1)
return null;
// Find the pipe after "{{Skill infobox"
var pipeIndex = wikiText.IndexOf('|', startIndex);
if (pipeIndex == -1)
return null;
// Count braces to find the matching closing }}
var braceCount = 2; // Start with 2 for the opening {{
var index = startIndex + startPattern.Length;
while (index < wikiText.Length && braceCount > 0)
{
if (wikiText[index] == '{' && index + 1 < wikiText.Length && wikiText[index + 1] == '{')
{
braceCount += 2;
index += 2;
}
else if (wikiText[index] == '}' && index + 1 < wikiText.Length && wikiText[index + 1] == '}')
{
braceCount -= 2;
index += 2;
}
else
{
index++;
}
}
if (braceCount == 0)
{
// Extract content between the pipe and the closing }}
return wikiText.Substring(pipeIndex + 1, index - pipeIndex - 3).Trim();
}
return null;
}
private static string? ExtractField(string infoboxContent, string fieldName)
{
var pattern = $@"(?:^|\|\s*){Regex.Escape(fieldName)}\s*=\s*(.*?)(?=\s*\||$)";
var match = Regex.Match(infoboxContent, pattern, RegexOptions.IgnoreCase | RegexOptions.Multiline);
return match.Success ? match.Groups[1].Value.Trim() : null;
}
[GeneratedRegex(@"^#REDIRECT\s*\[\[([^\]]+)\]\]", RegexOptions.IgnoreCase)]
private static partial Regex RedirectRegex();
[GeneratedRegex(@"\{\{1/2\}\}", RegexOptions.IgnoreCase)]
private static partial Regex HalfFractionRegex();
[GeneratedRegex(@"\{\{1/4\}\}", RegexOptions.IgnoreCase)]
private static partial Regex QuarterFractionRegex();
[GeneratedRegex(@"\{\{3/4\}\}", RegexOptions.IgnoreCase)]
private static partial Regex ThreeQuarterFractionRegex();
[GeneratedRegex(@"\{\{3/2\}\}", RegexOptions.IgnoreCase)]
private static partial Regex ThreeOverTwoFractionRegex();
[GeneratedRegex(@"\{\{(\d+(?:\.\d+)?)\}\}", RegexOptions.IgnoreCase)]
private static partial Regex DecimalRegex();
[GeneratedRegex(@"\{\{gr\|(\+?-?\d+)\|(\+?-?\d+)\|?-?\}\}", RegexOptions.IgnoreCase)]
private static partial Regex GreenRangeRegex();
[GeneratedRegex(@"\{\{gr2\|(\+?-?\d+)\|(\+?-?\d+)\|?-?\}\}", RegexOptions.IgnoreCase)]
private static partial Regex GreenRange2Regex();
[GeneratedRegex(@"\{\{gray\|(\+?-?\d+)\|(\+?-?\d+)\|?-?\}\}", RegexOptions.IgnoreCase)]
private static partial Regex GrayRangeRegex();
[GeneratedRegex(@"\{\{sic(?:\|([^}]*))?\}\}", RegexOptions.IgnoreCase)]
private static partial Regex SicTextRegex();
[GeneratedRegex(@"\[\[([^\]|]+)\]\]", RegexOptions.IgnoreCase)]
private static partial Regex SimpleLinkRegex();
[GeneratedRegex(@"\[\[([^|]+)\|([^\]]+)\]\]", RegexOptions.IgnoreCase)]
private static partial Regex LinkWithDisplayTextRegex();
[GeneratedRegex(@"\[\[([^|]+)\]\]", RegexOptions.IgnoreCase)]
private static partial Regex SkillTypeLinkRegex();
[GeneratedRegex(@"'''([^']+)'''", RegexOptions.IgnoreCase)]
private static partial Regex BoldTextRegex();
[GeneratedRegex(@"''([^']+)''", RegexOptions.IgnoreCase)]
private static partial Regex ItalicTextRegex();
[GeneratedRegex(@"<[^>]*>", RegexOptions.IgnoreCase)]
private static partial Regex HtmlTagRegex();
[GeneratedRegex(@"\s+", RegexOptions.IgnoreCase)]
private static partial Regex WhitespaceRegex();
[GeneratedRegex(@"\{\{gray\|([^}]+)\}\}", RegexOptions.IgnoreCase)]
private static partial Regex GrayTextRegex();
[GeneratedRegex(@"\{\{grey\|([^}]+)\}\}", RegexOptions.IgnoreCase)]
private static partial Regex GreyTextRegex();
}
+80 -70
View File
@@ -8,80 +8,90 @@
<div class="option-view-title">
<h3>Accounts</h3>
</div>
<div class="credentials-list">
@foreach (var credential in this.ViewModel.LoginCredentials)
{
<div class="credential-row">
<div class="identifier-row">
<div class="identifier-label">
<FluentLabel>Identifier</FluentLabel>
</div>
<div class="identifier-field">
<FluentTextField Value="@credential.LoginCredentials.Identifier"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
ReadOnly="true"
Placeholder="Identifier">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => this.ViewModel.RemoveCredential(credential))"
slot="end"
Color="Color.Neutral"
Title="Delete account" />
</FluentTextField>
</div>
</div>
<div class="username-row">
<div class="username-label">
<FluentLabel>Username</FluentLabel>
</div>
<div class="username-field">
<FluentTextField Value="@credential.LoginCredentials.Username"
ValueChanged="@(e => this.ViewModel.UsernameChanged(credential, e))"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Immediate="true"
ImmediateDelay="500"
InputMode="InputMode.Text"
TextFieldType="TextFieldType.Email"
Placeholder="Username" />
</div>
</div>
<div class="password-row">
<div class="password-label">
<FluentLabel>Password</FluentLabel>
</div>
<div class="password-field">
<FluentTextField Value="@credential.LoginCredentials.Password"
ValueChanged="@(e => this.ViewModel.PasswordChanged(credential, e))"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Immediate="true"
ImmediateDelay="500"
InputMode="InputMode.Text"
TextFieldType="@(credential.PasswordVisible ? TextFieldType.Text : TextFieldType.Password)"
Placeholder="Password">
@if (credential.PasswordVisible)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Eye())"
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
@if (this.ViewModel.LoginCredentials.Count is 0)
{
<EmptyStateWidget Message="No accounts configured"
ActionText="Add account"
Icon="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Person())"
OnAction="@this.ViewModel.CreateCredential" />
}
else
{
<div class="credentials-list">
@foreach (var credential in this.ViewModel.LoginCredentials)
{
<div class="credential-row">
<div class="identifier-row">
<div class="identifier-label">
<FluentLabel>Identifier</FluentLabel>
</div>
<div class="identifier-field">
<FluentTextField Value="@credential.LoginCredentials.Identifier"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
ReadOnly="true"
Placeholder="Identifier">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => this.ViewModel.RemoveCredential(credential))"
slot="end"
Color="Color.Neutral"
Title="Hide password" />
}
else
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.EyeOff())"
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
slot="end"
Color="Color.Neutral"
Title="Show password" />
}
</FluentTextField>
Title="Delete account" />
</FluentTextField>
</div>
</div>
<div class="username-row">
<div class="username-label">
<FluentLabel>Username</FluentLabel>
</div>
<div class="username-field">
<FluentTextField Value="@credential.LoginCredentials.Username"
ValueChanged="@(e => this.ViewModel.UsernameChanged(credential, e))"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Immediate="true"
ImmediateDelay="500"
InputMode="InputMode.Text"
TextFieldType="TextFieldType.Email"
Placeholder="Username" />
</div>
</div>
<div class="password-row">
<div class="password-label">
<FluentLabel>Password</FluentLabel>
</div>
<div class="password-field">
<FluentTextField Value="@credential.LoginCredentials.Password"
ValueChanged="@(e => this.ViewModel.PasswordChanged(credential, e))"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Immediate="true"
ImmediateDelay="500"
InputMode="InputMode.Text"
TextFieldType="@(credential.PasswordVisible ? TextFieldType.Text : TextFieldType.Password)"
Placeholder="Password">
@if (credential.PasswordVisible)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Eye())"
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
slot="end"
Color="Color.Neutral"
Title="Hide password" />
}
else
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.EyeOff())"
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
slot="end"
Color="Color.Neutral"
Title="Show password" />
}
</FluentTextField>
</div>
</div>
</div>
</div>
}
</div>
}
</div>
}
</div>
</div>
+5 -9
View File
@@ -8,6 +8,7 @@ using Daybreak.Shared.Services.Menu;
using Daybreak.Shared.Services.Notifications;
using Daybreak.Shared.Services.Options;
using Daybreak.Shared.Services.Privilege;
using Daybreak.Shared.Services.Shell;
using Daybreak.Shared.Services.Themes;
using Daybreak.Shared.Services.Updater;
using Daybreak.Shared.Services.Window;
@@ -17,7 +18,6 @@ using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
using Photino.Blazor;
using System.Core.Extensions;
using System.Diagnostics;
using System.Extensions;
using TrailBlazr.Services;
@@ -39,6 +39,7 @@ public sealed class AppViewModel
private readonly JSConsoleInterop jsConsoleInterop;
private readonly INotificationService notificationService;
private readonly IWindowManipulationService windowManipulationService;
private readonly IShellExecutor shellExecutor;
private readonly ILogger<App> logger;
private SemaphoreSlim themeChangeSemaphore = new(1, 1);
@@ -89,6 +90,7 @@ public sealed class AppViewModel
INotificationProducer notificationProducer,
INotificationService notificationService,
IWindowManipulationService windowManipulationService,
IShellExecutor shellExecutor,
ILogger<App> logger)
{
this.keyboardHookService = keyboardHookService.ThrowIfNull();
@@ -104,6 +106,7 @@ public sealed class AppViewModel
this.NotificationProducer = notificationProducer.ThrowIfNull();
this.notificationService = notificationService.ThrowIfNull();
this.windowManipulationService = windowManipulationService.ThrowIfNull();
this.shellExecutor = shellExecutor.ThrowIfNull();
this.logger = logger.ThrowIfNull();
@@ -215,14 +218,7 @@ public sealed class AppViewModel
public void OpenIssues()
{
try
{
Process.Start(new ProcessStartInfo { FileName = IssueUrl, UseShellExecute = true });
}
catch (Exception ex)
{
this.logger.LogError(ex, "Encountered exception while opening issues page");
}
this.shellExecutor.OpenUrl(IssueUrl);
}
public void OpenSynchronizationView()
@@ -232,7 +232,7 @@ public abstract class BuildTemplateViewModelBase<TViewModel, TView>(
protected void FilterSkillsByProfessionsAndString(string? searchFilter = default)
{
var filteredSkills = Skill.Skills.Where(skill =>
var filteredSkills = Skill.AllSkills.Where(skill =>
{
if (skill.PvP)
{
@@ -1,4 +1,6 @@
<div class="backdrop-panel container skill-snippet" style="--mouse-x: @(this.Context.MousePosition.PosX)px; --mouse-y: @(this.Context.MousePosition.PosY)px;">
@inject ISkillNumberFormatter SkillNumberFormatter
<div class="backdrop-panel container skill-snippet" style="--mouse-x: @(this.Context.MousePosition.PosX)px; --mouse-y: @(this.Context.MousePosition.PosY)px;">
<div class="left-side">
<div class="skill-icon">
<SkillIcon Skill="this.Context.Skill" />
@@ -23,67 +25,67 @@
<div class="skill-concise-description">@this.Context.Skill.ConciseDescription</div>
</div>
<div class="right-side">
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Energy))
@if (this.Context.Skill.Energy.HasValue)
{
<div class="skill-resource">
<div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Energy />
</div>
<div class="skill-resource-text">@this.Context.Skill.Energy</div>
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Energy)</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Activation))
@if (this.Context.Skill.Activation.HasValue)
{
<div class="skill-resource">
<div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Activation />
</div>
<div class="skill-resource-text">@this.Context.Skill.Activation</div>
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Activation)</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Recharge))
@if (this.Context.Skill.Recharge.HasValue)
{
<div class="skill-resource">
<div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Recharge />
</div>
<div class="skill-resource-text">@this.Context.Skill.Recharge</div>
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Recharge)</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Overcast))
@if (this.Context.Skill.Overcast.HasValue)
{
<div class="skill-resource">
<div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Overcast />
</div>
<div class="skill-resource-text">@this.Context.Skill.Overcast</div>
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Overcast)</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Adrenaline))
@if (this.Context.Skill.Adrenaline.HasValue)
{
<div class="skill-resource">
<div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Adrenaline />
</div>
<div class="skill-resource-text">@this.Context.Skill.Adrenaline</div>
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Adrenaline)</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Sacrifice))
@if (this.Context.Skill.Sacrifice.HasValue)
{
<div class="skill-resource">
<div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Sacrifice />
</div>
<div class="skill-resource-text">@this.Context.Skill.Sacrifice</div>
<div class="skill-resource-text">@this.SkillNumberFormatter.FormatPercent(this.Context.Skill.Sacrifice)</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Upkeep))
@if (this.Context.Skill.Upkeep.HasValue)
{
<div class="skill-resource">
<div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Upkeep />
</div>
<div class="skill-resource-text">@this.Context.Skill.Upkeep</div>
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Upkeep)</div>
</div>
}
</div>
@@ -0,0 +1,28 @@
<div class="empty-state">
<FluentIcon Value="@this.Icon" Color="Color.Neutral" />
<div class="empty-message">@this.Message</div>
<FluentButton Appearance="Appearance.Accent"
Disabled="@this.Disabled"
IconStart="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Add())"
Title="@this.ActionText"
OnClick="@this.OnAction">
@this.ActionText
</FluentButton>
</div>
@code {
[Parameter]
public string Message { get; set; } = string.Empty;
[Parameter]
public string ActionText { get; set; } = "Add";
[Parameter]
public Icon Icon { get; set; } = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Search();
[Parameter]
public bool Disabled { get; set; }
[Parameter]
public EventCallback OnAction { get; set; }
}
@@ -0,0 +1,14 @@
.empty-state {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
color: var(--neutral-foreground-hint);
}
.empty-message {
font-size: var(--font-size-large);
text-align: center;
}
@@ -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);
}
@@ -1,6 +1,23 @@
@if (this.Context is not null)
{
<div class="container backdrop-panel">
<div class="container backdrop-panel">
@if (this.Context is null)
{
<SpinnerWidget IsLoading="true" />
}
else if (this.Context.Title is null)
{
<div class="title-track-container">
<div class="title-label">
Title Track
</div>
</div>
<div class="title-bar-container">
<div class="title-idle">
No active title
</div>
</div>
}
else
{
<div class="title-track-container">
<div class="title-label">
Title Track
@@ -18,8 +35,8 @@
</div>
</div>
</div>
</div>
}
}
</div>
@code {
[Parameter]
@@ -69,7 +86,7 @@
private string GetTitleDescription()
{
if (this.Context is null)
if (this.Context?.Title is null)
{
return string.Empty;
}
@@ -54,3 +54,15 @@
color: var(--neutral-foreground-rest);
z-index: 1;
}
.title-idle {
width: 100%;
height: var(--font-size-x-large);
display: flex;
align-items: center;
justify-content: center;
background: var(--neutral-fill-rest);
border-radius: 0px 0px 8px 8px;
font-size: var(--font-size-medium);
color: var(--neutral-foreground-hint);
}
@@ -1,21 +1,34 @@
@if (this.Context is not null &&
this.Context.HardMode &&
this.Context.Vanquishing)
{
<div class="container backdrop-panel">
<div class="container backdrop-panel">
@if (this.Context is null)
{
<SpinnerWidget IsLoading="true" />
}
else
{
<div class="title">
Vanquishing
</div>
<div class="vanquish-bar-container">
<div class="vanquish-bar" @onclick="@(() => this.VanquishBarClicked())">
<div class="vanquish-bar-fill" style="@(this.GetVanquishBarWidth())"></div>
<div class="vanquish-bar-text">
@this.GetVanquishBarText()
@if (this.Context.HardMode && this.Context.Vanquishing)
{
<div class="vanquish-bar-container">
<div class="vanquish-bar" @onclick="@(() => this.VanquishBarClicked())">
<div class="vanquish-bar-fill" style="@(this.GetVanquishBarWidth())"></div>
<div class="vanquish-bar-text">
@this.GetVanquishBarText()
</div>
</div>
</div>
</div>
</div>
}
}
else
{
<div class="vanquish-bar-container">
<div class="vanquish-idle">
Not vanquishing
</div>
</div>
}
}
</div>
@code {
[Parameter]
@@ -37,6 +50,11 @@
}
var totalFoes = this.Context.FoesToKill + this.Context.FoesKilled;
if (totalFoes is 0)
{
return "width: 0%;";
}
return $"width: {this.Context.FoesKilled * 100d / totalFoes}%;";
}
@@ -40,3 +40,12 @@
color: var(--neutral-foreground-rest);
z-index: 1;
}
.vanquish-idle {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: var(--font-size-medium);
color: var(--neutral-foreground-hint);
}
@@ -25,6 +25,9 @@
{
<p class="notification-description">@notificationTuple.Notification.Description</p>
}
<div class="notification-progress"
@key="@($"{notificationTuple.Notification.Id}-{notificationTuple.LifetimeGeneration}")"
style="animation-duration: @(notificationTuple.LifetimeMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture))ms;"></div>
</div>
}
</div>
@@ -72,7 +75,7 @@
try
{
await this.notificationLock.WaitAsync(cancellationToken);
this.notifications.Add(new NotificationWrapper { Notification = notification, ExpirationTime = notification.ExpirationTime });
this.notifications.Add(new NotificationWrapper { Notification = notification, StartTime = DateTime.UtcNow, ExpirationTime = notification.ExpirationTime });
}
catch (OperationCanceledException)
{
@@ -167,7 +170,9 @@
private void ExtendNotification(NotificationWrapper notification)
{
notification.ExpirationTime = DateTime.Now + NotificationExtension;
notification.StartTime = DateTime.UtcNow;
notification.ExpirationTime = DateTime.UtcNow + NotificationExtension;
notification.LifetimeGeneration++;
}
private async Task OpenNofitication(NotificationWrapper notification)
@@ -99,21 +99,25 @@
/* Notification level styling */
.notification-item.level-information {
border-left: 4px solid var(--accent-fill-rest);
--notification-accent: var(--accent-fill-rest);
border-left: 4px solid var(--notification-accent);
}
.notification-item.level-warning {
border-left: 4px solid #ff9500;
--notification-accent: #ff9500;
border-left: 4px solid var(--notification-accent);
}
.notification-item.level-error,
.notification-item.level-critical {
border-left: 4px solid #d13438;
--notification-accent: #d13438;
border-left: 4px solid var(--notification-accent);
}
.notification-item.level-debug,
.notification-item.level-trace {
border-left: 4px solid var(--neutral-stroke-accessible);
--notification-accent: var(--neutral-stroke-accessible);
border-left: 4px solid var(--notification-accent);
}
.notification-item[class*="level-"]:hover {
@@ -121,6 +125,32 @@
cursor: pointer;
}
/* Expiration progress line animating from start time to expiration */
.notification-progress {
position: absolute;
left: 0;
bottom: 0;
height: 3px;
width: 100%;
background: var(--notification-accent, var(--accent-fill-rest));
transform: scaleX(0);
transform-origin: left center;
animation-name: notificationProgress;
animation-timing-function: linear;
animation-fill-mode: forwards;
animation-iteration-count: 1;
pointer-events: none;
}
@keyframes notificationProgress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes slideInFromRight {
from {
transform: translateX(100%);
@@ -179,4 +209,9 @@
animation: none;
opacity: 0;
}
.notification-progress {
animation: none;
transform: scaleX(1);
}
}
@@ -6,6 +6,7 @@ using System.Core.Extensions;
using TrailBlazr.ViewModels;
namespace Daybreak.Views;
public sealed class EventCalendarViewModel(IEventService eventService)
: ViewModelBase<EventCalendarViewModel, EventCalendarView>
{
@@ -92,7 +93,7 @@ public sealed class EventCalendarViewModel(IEventService eventService)
{
dates.Add(new DateTime(today.Year, today.Month, day));
}
return dates;
}
@@ -107,6 +108,7 @@ public sealed class EventCalendarViewModel(IEventService eventService)
events.Add(eventItem);
}
}
return events;
}
}
+68 -57
View File
@@ -8,65 +8,76 @@
<div class="option-view-title">
<h3>Executables</h3>
</div>
<div class="executables-list">
@foreach (var executable in this.ViewModel.Executables)
{
<div class="executable-row">
<div class="executable-label">
<FluentLabel>Path</FluentLabel>
</div>
<div class="executable-field">
<FluentTextField Value="@executable.Path"
ReadOnly="true"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Placeholder="Path">
<div class="executable-field-controls"
slot="end">
@if (executable.Validating)
{
<div class="loading-circle">
<SpinnerWidget IsLoading="true" />
@if (executable.UpdateProgress is string progress)
{
<div class="update-progress-text">@progress</div>
}
</div>
}
else if (executable.Valid)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircle())"
Color="Color.Neutral"
Title="Executable is valid" />
}
else if (executable.NeedsUpdate)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDownload())"
@onclick="@(() => this.ViewModel.UpdateExecutable(executable))"
Color="Color.Neutral"
Title="Update executable" />
}
else
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircleWarning())"
Color="Color.Neutral"
Title="Executable is invalid" />
}
@if (this.ViewModel.Executables.Count is 0)
{
<EmptyStateWidget Message="No executables configured"
ActionText="Add executable"
Icon="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Apps())"
Disabled="@(!this.ViewModel.AddButtonEnabled)"
OnAction="@this.ViewModel.CreateExecutable" />
}
else
{
<div class="executables-list">
@foreach (var executable in this.ViewModel.Executables)
{
<div class="executable-row">
<div class="executable-label">
<FluentLabel>Path</FluentLabel>
</div>
<div class="executable-field">
<FluentTextField Value="@executable.Path"
ReadOnly="true"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Placeholder="Path">
<div class="executable-field-controls"
slot="end">
@if (executable.Validating)
{
<div class="loading-circle">
<SpinnerWidget IsLoading="true" />
@if (executable.UpdateProgress is string progress)
{
<div class="update-progress-text">@progress</div>
}
</div>
}
else if (executable.Valid)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircle())"
Color="Color.Neutral"
Title="Executable is valid" />
}
else if (executable.NeedsUpdate)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDownload())"
@onclick="@(() => this.ViewModel.UpdateExecutable(executable))"
Color="Color.Neutral"
Title="Update executable" />
}
else
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircleWarning())"
Color="Color.Neutral"
Title="Executable is invalid" />
}
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.FolderOpen())"
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.ModifyPath(executable); })"
Color="Color.Neutral"
Title="Modify path" />
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.RemoveExecutable(executable); })"
Color="Color.Neutral"
Title="Remove executable" />
</div>
</FluentTextField>
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.FolderOpen())"
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.ModifyPath(executable); })"
Color="Color.Neutral"
Title="Modify path" />
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.RemoveExecutable(executable); })"
Color="Color.Neutral"
Title="Remove executable" />
</div>
</FluentTextField>
</div>
</div>
</div>
}
</div>
}
</div>
}
</div>
</div>
@@ -37,6 +37,7 @@ public class ExecutablesViewModel(
{
this.Executables.Remove(executable);
this.guildWarsExecutableManager.RemoveExecutable(executable.Path);
this.RefreshView();
}
public async Task CreateExecutable()
+210 -42
View File
@@ -1,50 +1,82 @@
@using Daybreak.Views.Components.FocusView
@using Daybreak.Shared.Models.FocusView
@using Daybreak.Views.Components.FocusView
@using Microsoft.JSInterop
@page "/focus"
@inherits ViewBase<FocusView, FocusViewModel>
<div class="focus-dashboard">
<div class="dashboard-half">
<div class="component character-component">
<CharacterComponent Context="@this.ViewModel.CharacterComponentContext"
OnCharacterChanged="@this.ViewModel.OnCharacterChanged"
OnExperienceBarClicked="@this.ViewModel.OnExperienceDisplayClicked" />
</div>
<div class="component resources-component">
<PlayerResourcesComponent Context="@this.ViewModel.PlayerResourcesComponentContext"
OnBalthazarBarClicked="@this.ViewModel.OnBalthazarBarClicked"
OnKurzickBarClicked="@this.ViewModel.OnKurzickBarClicked"
OnImperialBarClicked="@this.ViewModel.OnImperialBarClicked"
OnLuxonBarClicked="@this.ViewModel.OnLuxonBarClicked" />
</div>
<div class="component vanquish-component">
<VanquishComponent Context="@this.ViewModel.VanquishComponentContext"
OnVanquishBarClicked="@this.ViewModel.OnVanquishingDisplayClicked" />
</div>
<div class="component title-component">
<TitleInformationComponent Context="@this.ViewModel.TitleInformationComponentContext"
OnTitleClicked="@this.ViewModel.OnTitleClicked" />
</div>
<div class="component map-component">
<CurrentMapComponent Context="@this.ViewModel.CurrentMapComponentContext"
OnCurrentMapClicked="@this.ViewModel.OnCurrentMapClicked"/>
</div>
<div class="component quest-component">
<QuestLogComponent Context="@this.ViewModel.QuestLogComponentContext"
OnQuestClicked="@this.ViewModel.OnQuestClicked"/>
</div>
<div class="focus-dashboard @(this.ViewModel.IsEditingLayout ? "editing" : string.Empty)">
<div class="title-bar-additional-btns-left">
@if (this.ViewModel.IsEditingLayout)
{
<button class="title-bar-btn" @onclick="@this.SaveLayoutAsync" title="Save layout">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Save())" />
</button>
<button class="title-bar-btn" @onclick="@this.ViewModel.ResetLayout" title="Reset layout to defaults">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowUndo())" />
</button>
<div class="add-component-wrapper">
<button class="title-bar-btn"
title="Add component"
disabled="@(!this.ViewModel.HiddenComponents.Any())"
@onclick="@this.ToggleAddDropdown">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Add())" />
</button>
@if (this.isAddDropdownOpen && this.ViewModel.HiddenComponents.Any())
{
<div class="add-component-menu">
@foreach (var component in this.ViewModel.HiddenComponents)
{
<div class="add-component-item" @onclick="@(() => this.AddComponentAsync(component))">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Eye())" />
<span>@TileName(component)</span>
</div>
}
</div>
}
</div>
}
else
{
<button class="title-bar-btn" @onclick="@this.EnterEditLayout" title="Edit layout">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.SquareMultiple())" />
</button>
}
</div>
<div class="dashboard-half">
<div class="component build-component">
<BuildComponent Context="@this.ViewModel.BuildComponentContext"
OnBuildClicked="@this.ViewModel.OnBuildClicked"
OnLoadPlayerSingleBuildClicked="@this.ViewModel.OnPlayerSingleBuildClicked"
OnLoadPlayerTeamBuildClicked="@this.ViewModel.OnPlayerTeamBuildClicked" />
</div>
<div class="component browser-component">
<iframe sandbox="allow-scripts allow-same-origin allow-forms"
style="height: 100%; width: 100%; margin: -2px;"
src="@this.ViewModel.BrowserSource" />
<div class="focus-content">
<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 @(this.maximizedComponent == tile.Component ? "maximized" : string.Empty)"
@key="tile.Component"
data-component="@tile.Component"
gs-x="@(tile.Column - 1)"
gs-y="@(tile.Row - 1)"
gs-w="@tile.ColumnSpan"
gs-h="@tile.RowSpan">
<div class="grid-stack-item-content component component-@(tile.Component.ToString().ToLowerInvariant())">
@if (this.ViewModel.IsEditingLayout)
{
<div class="focus-tile-placeholder">
<span class="focus-tile-name">@TileName(tile.Component)</span>
<button class="tile-remove-button"
title="Hide @TileName(tile.Component)"
@onclick="@(() => this.ToggleComponentAsync(tile.Component))">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Dismiss())" />
</button>
</div>
}
else
{
<div class="focus-tile-body">
@this.RenderTile(tile.Component)
</div>
}
</div>
</div>
}
</div>
</div>
</div>
@@ -54,4 +86,140 @@
public required string ConfigurationId { get; init; }
[Parameter]
public required string ProcessId { get; init; }
[Inject]
public required IJSRuntime JSRuntime { get; init; }
private ElementReference gridElement;
private int lastSyncedVersion = -1;
private bool isAddDropdownOpen;
private FocusViewComponent? maximizedComponent;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (this.ViewModel.Tiles.Count == 0)
{
return;
}
if (this.ViewModel.LayoutVersion != this.lastSyncedVersion)
{
this.lastSyncedVersion = this.ViewModel.LayoutVersion;
this.ViewModel.MarkLayoutSynced();
await this.JSRuntime.InvokeVoidAsync("focusGrid.init", this.gridElement, new
{
columns = this.ViewModel.GridColumns,
rows = this.ViewModel.GridRows,
editing = this.ViewModel.IsEditingLayout
});
}
}
// Pulls the current geometry from Gridstack into the view model. Called only at meaningful
// moments (save / show / hide) so live drag/resize is never interrupted by a round-trip.
private async Task SyncFromGridAsync()
{
if (this.ViewModel.Tiles.Count == 0)
{
return;
}
var changes = await this.JSRuntime.InvokeAsync<FocusTileChange[]>("focusGrid.save");
this.ViewModel.ApplyTileChanges(changes);
}
private async Task SaveLayoutAsync()
{
await this.SyncFromGridAsync();
this.isAddDropdownOpen = false;
this.ViewModel.SaveLayout();
}
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);
}
private async Task AddComponentAsync(FocusViewComponent component)
{
await this.SyncFromGridAsync();
this.ViewModel.ToggleTileVisibility(component);
if (!this.ViewModel.HiddenComponents.Any())
{
this.isAddDropdownOpen = false;
}
}
private RenderFragment RenderTile(FocusViewComponent component) => component switch
{
FocusViewComponent.Character => @<CharacterComponent Context="@this.ViewModel.CharacterComponentContext"
OnCharacterChanged="@this.ViewModel.OnCharacterChanged"
OnExperienceBarClicked="@this.ViewModel.OnExperienceDisplayClicked" />,
FocusViewComponent.PlayerResources => @<PlayerResourcesComponent Context="@this.ViewModel.PlayerResourcesComponentContext"
OnBalthazarBarClicked="@this.ViewModel.OnBalthazarBarClicked"
OnKurzickBarClicked="@this.ViewModel.OnKurzickBarClicked"
OnImperialBarClicked="@this.ViewModel.OnImperialBarClicked"
OnLuxonBarClicked="@this.ViewModel.OnLuxonBarClicked" />,
FocusViewComponent.Vanquish => @<VanquishComponent Context="@this.ViewModel.VanquishComponentContext"
OnVanquishBarClicked="@this.ViewModel.OnVanquishingDisplayClicked" />,
FocusViewComponent.Title => @<TitleInformationComponent Context="@this.ViewModel.TitleInformationComponentContext"
OnTitleClicked="@this.ViewModel.OnTitleClicked" />,
FocusViewComponent.CurrentMap => @<CurrentMapComponent Context="@this.ViewModel.CurrentMapComponentContext"
OnCurrentMapClicked="@this.ViewModel.OnCurrentMapClicked" />,
FocusViewComponent.QuestLog => @<QuestLogComponent Context="@this.ViewModel.QuestLogComponentContext"
OnQuestClicked="@this.ViewModel.OnQuestClicked" />,
FocusViewComponent.Build => @<BuildComponent Context="@this.ViewModel.BuildComponentContext"
OnBuildClicked="@this.ViewModel.OnBuildClicked"
OnLoadPlayerSingleBuildClicked="@this.ViewModel.OnPlayerSingleBuildClicked"
OnLoadPlayerTeamBuildClicked="@this.ViewModel.OnPlayerTeamBuildClicked" />,
FocusViewComponent.Browser => @<BrowserComponent Source="@this.ViewModel.BrowserSource"
OnMaximized="@(() => this.SetMaximized(FocusViewComponent.Browser))"
OnRestore="@(() => this.SetMaximized(null))" />,
_ => @<text></text>
};
private static string TileName(FocusViewComponent component) => component switch
{
FocusViewComponent.Character => "Character",
FocusViewComponent.PlayerResources => "Resources",
FocusViewComponent.Vanquish => "Vanquish",
FocusViewComponent.Title => "Title",
FocusViewComponent.CurrentMap => "Current Map",
FocusViewComponent.QuestLog => "Quest Log",
FocusViewComponent.Build => "Builds",
FocusViewComponent.Browser => "Browser",
_ => component.ToString()
};
protected override void Dispose(bool disposing)
{
if (disposing)
{
_ = this.JSRuntime.InvokeVoidAsync("focusGrid.destroy");
}
base.Dispose(disposing);
}
}

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