Compare commits

..
Author SHA1 Message Date
Alexandru Macocian 310dbd59b8 Move to fully managed frame 2026-03-06 15:43:20 +01:00
Alexandru Macocian eae44fb576 Adjust layout pass 2026-03-06 15:43:20 +01:00
Alexandru Macocian 8d847159f3 Try to manually place frame 2026-03-06 15:43:20 +01:00
Alexandru Macocian 82514d1707 Fix kMeasureContent 2026-03-06 15:43:20 +01:00
Alexandru Macocian 84ee5a9faf GWCA 2026-03-06 15:43:19 +01:00
Alexandru Macocian 2634c32b22 GWCA 2026-03-06 15:43:19 +01:00
Alexandru Macocian 538e56d0a9 Fix 2026-03-06 15:43:19 +01:00
Alexandru Macocian 59b1acf9df fixes to the labels 2026-03-06 15:43:19 +01:00
Alexandru Macocian 9c3f644869 diag info 2026-03-06 15:43:19 +01:00
Alexandru Macocian 0240acb37a Compute layout independently 2026-03-06 15:43:19 +01:00
Alexandru Macocian 02c24e6e86 GWCA fixes 2026-03-06 15:43:19 +01:00
Alexandru Macocian 53da02d440 GWCA fixes 2026-03-06 15:43:19 +01:00
Alexandru Macocian 06c27da672 Fix for UI instability 2026-03-06 15:43:19 +01:00
Alexandru Macocian 54bf57e6c1 Reentrancy guard 2026-03-06 15:43:18 +01:00
Alexandru Macocian 733d6fc9f6 Updates to GWCA 2026-03-06 15:43:18 +01:00
amacocianandAlexandru Macocian 46943e940b First working iteration 2026-03-06 15:43:18 +01:00
amacocianandAlexandru Macocian 13985d703f Adjut populate skill data 2026-03-06 15:43:18 +01:00
amacocianandAlexandru Macocian e01aa28e01 Fix templates summary callback 2026-03-06 15:43:18 +01:00
amacocianandAlexandru Macocian f5eb67fd1c Update GWCA 2026-03-06 15:43:18 +01:00
amacocianandAlexandru Macocian d380949b45 Remove debug logging 2026-03-06 15:43:11 +01:00
amacocianandAlexandru Macocian b23811604d Fix hooking 2026-03-06 15:43:11 +01:00
amacocianandAlexandru Macocian 7a665491f1 Adjustments 2026-03-06 15:43:11 +01:00
amacocianandAlexandru Macocian a966906d6e Reinit load skill template hook 2026-03-06 15:43:11 +01:00
amacocianandAlexandru Macocian 5b554707bf Implement hooking in PartyService 2026-03-06 15:43:11 +01:00
amacocianandAlexandru Macocian aeb2c9cc99 Switch to base roslyn server 2026-03-06 15:42:56 +01:00
amacocianandAlexandru Macocian c1aaf2d1e7 Fix party loading by template 2026-03-06 15:42:55 +01:00
amacocianandAlexandru Macocian 98fa8ffa3f Rebase onto release 2026-03-06 15:42:55 +01:00
19c7b6a852 Fix template loading from API (#1495)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-03-06 15:41:25 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0905bf05cb 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-02 11:58:38 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
132d032546 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-02 11:58:17 +01:00
Brendan McCoyandGitHub 78646fd1b5 Add dye tint support to inventory item model (#1490)
Switched to using GWCA's GetBagByIndex for safer bag pointer access.
2026-02-28 17:17:24 +01:00
github-actions[bot] d8f92b4b91 Bump version to 0.9.10.5 2026-02-26 22:21:39 +00:00
274 changed files with 10278 additions and 22670 deletions
-71
View File
@@ -1,71 +0,0 @@
# 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
@@ -1,28 +0,0 @@
# 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
@@ -1,245 +0,0 @@
# 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
@@ -1,46 +0,0 @@
#!/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
@@ -1,66 +0,0 @@
# 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,14 +13,10 @@ 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
+9 -66
View File
@@ -9,11 +9,6 @@ on:
required: false
type: boolean
default: false
signBinaries:
description: "Whether to code-sign Windows binaries"
required: false
type: boolean
default: false
jobs:
# ───────────────────────────────────────────────────────
@@ -21,7 +16,7 @@ jobs:
# (Injector + API) that both platforms need.
# ───────────────────────────────────────────────────────
build-x86:
runs-on: windows-2025
runs-on: windows-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
@@ -29,13 +24,10 @@ 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@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive
@@ -48,7 +40,7 @@ jobs:
echo "version=$version" >> $env:GITHUB_OUTPUT
- name: Install .NET
uses: actions/setup-dotnet@v6
uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.x"
@@ -56,30 +48,6 @@ 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:
@@ -94,24 +62,21 @@ jobs:
# ───────────────────────────────────────────────────────
build-windows:
needs: build-x86
runs-on: windows-2025
runs-on: windows-latest
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@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive
- name: Install .NET
uses: actions/setup-dotnet@v6
uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.x"
@@ -133,28 +98,6 @@ 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:
@@ -171,13 +114,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive
- name: Install .NET
uses: actions/setup-dotnet@v6
uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.x"
@@ -285,7 +228,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive
-4
View File
@@ -15,9 +15,6 @@ on:
- "Daybreak.Shared/**"
- "Daybreak.Windows/**"
- "Daybreak.Linux/**"
- ".github/scripts/**"
- ".github/workflows/cd-template.yml"
- ".github/workflows/cd.yaml"
workflow_dispatch:
jobs:
@@ -25,5 +22,4 @@ 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@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive
- name: Install .NET
uses: actions/setup-dotnet@v6
uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.x"
@@ -49,13 +49,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive
- name: Install .NET
uses: actions/setup-dotnet@v6
uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.x"
+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
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@v6
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.x'
@@ -18,7 +18,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
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.8
uses: vers-one/dotnet-project-version-updater@v1.7
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@v9
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
+2 -2
View File
@@ -27,12 +27,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install .NET Core
uses: actions/setup-dotnet@v6
uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.x"
+2 -2
View File
@@ -181,8 +181,8 @@ DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once / local publish directories
[Pp]ublish/
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
-108
View File
@@ -1,108 +0,0 @@
-- 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
@@ -0,0 +1,8 @@
{
"recommendations": [
"ms-dotnettools.csharp"
],
"unwantedRecommendations": [
"ms-dotnettools.csdevkit"
]
}
+6 -30
View File
@@ -3,43 +3,19 @@
"configurations": [
{
"name": "Debug Daybreak.Linux",
"type": "dotnet",
"type": "coreclr",
"request": "launch",
"projectPath": "${workspaceFolder}/Daybreak.Linux/Daybreak.Linux.csproj",
"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",
"console": "integratedTerminal",
"justMyCode": true,
"stopAtEntry": false,
"suppressJITOptimizations": false,
"symbolOptions": {
"searchMicrosoftSymbolServer": false,
"searchNuGetOrgSymbolServer": false
},
"logging": {
"engineLogging": false,
"moduleLoad": false,
"exceptions": false,
"browserStdOut": false
}
"stopAtEntry": false
},
{
"name": "Debug Daybreak.Windows",
"type": "dotnet",
"request": "launch",
"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
}
"projectPath": "${workspaceFolder}/Daybreak.Windows/Daybreak.Windows.csproj"
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"dotnet.defaultSolution": "Daybreak.slnx",
"omnisharp.projectLoadTimeout": 120,
"dotnet.preferCSharpExtension": true,
"csharp.semanticHighlighting.enabled": true,
"search.followSymlinks": false,
"search.exclude": {
+36
View File
@@ -24,6 +24,42 @@
"/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
@@ -1,113 +0,0 @@
# 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,2 +1,10 @@
{
"Logging": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Warning"
}
}
}
}
@@ -1,10 +0,0 @@
{
"Logging": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Warning"
}
}
}
}
@@ -1,2 +1,10 @@
{
"Logging": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Warning"
}
}
}
}
@@ -1,5 +1,4 @@
using Daybreak.API.Services;
using Daybreak.Shared.Models.Api;
using Microsoft.AspNetCore.Mvc;
using Net.Sdk.Web;
@@ -14,8 +13,8 @@ public sealed class PartyController(PartyService partyService)
[GenerateGet("loadout")]
[EndpointName("GetPartyLoadout")]
[EndpointSummary("Get the current party loadout")]
[EndpointDescription("Get the current party loadout. Returns a json serialized PartyLoadout object")]
[ProducesResponseType<PartyLoadout>(StatusCodes.Status200OK)]
[EndpointDescription("Get the current party loadout. Returns the encoded party loadout template code")]
[ProducesResponseType<string>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public async Task<IResult> GetPartyLoadout(
CancellationToken cancellationToken)
@@ -27,12 +26,12 @@ public sealed class PartyController(PartyService partyService)
[GeneratePost("loadout")]
[EndpointName("SetPartyLoadout")]
[EndpointSummary("Set the current party loadout")]
[EndpointDescription("Set the current party loadout")]
[EndpointDescription("Set the current party loadout from an encoded template code")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public async Task<IResult> SetPartyLoadout([FromBody] PartyLoadout partyLoadout, CancellationToken cancellationToken)
public async Task<IResult> SetPartyLoadout([FromQuery(Name = "code")] string? code, CancellationToken cancellationToken)
{
var result = await this.partyService.SetPartyLoadout(partyLoadout, cancellationToken);
var result = await this.partyService.SetPartyLoadout(code ?? string.Empty, cancellationToken);
return result ? Results.Ok() : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
}
+11 -2
View File
@@ -6,7 +6,6 @@
<Nullable>enable</Nullable>
<OutputType>Library</OutputType>
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-x86</RuntimeIdentifier>
<StripSymbols>true</StripSymbols>
<InteropExports>true</InteropExports>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
@@ -27,7 +26,14 @@
<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>
@@ -82,7 +88,10 @@
<PackageReference Include="Serilog.Settings.Configuration" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="Swashbuckle.AspNetCore.Swagger" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" />
<PackageReference Include="SystemExtensions.NetStandard.Generators" PrivateAssets="all" />
<PackageReference Include="ZLinq" />
</ItemGroup>
+80 -79
View File
@@ -3,7 +3,6 @@ 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;
@@ -20,15 +19,24 @@ using Net.Sdk.Web;
namespace Daybreak.API;
public class EntryPoint
public static 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();
private static readonly object GwcaResolverLock = new();
private static IntPtr gwcaHandle;
private static bool gwcaResolverRegistered;
// 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;
[UnmanagedCallersOnly(EntryPoint = "ThreadInit"), STAThread]
[RequiresUnreferencedCode("The handler uses a static method that gets referenced, so there's no unreferenced code to worry about")]
@@ -39,20 +47,27 @@ public class EntryPoint
{
Environment.SetEnvironmentVariable("ASPNETCORE_HOSTINGSTARTUPASSEMBLIES", null, EnvironmentVariableTarget.Process);
Environment.SetEnvironmentVariable("ASPNETCORE_PREVENTHOSTINGSTARTUP", "true", EnvironmentVariableTarget.Process);
#if DEBUG
ConsoleExtensions.AllocateAnsiConsole();
#endif
// 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();
}
var port = FindAvailablePort(StartPort);
var app = CreateApplication(port);
var runTask = Task.Run(() => StartServer(app), CancellationTokenSource.Token);
var scopedLogger = app.Services.GetRequiredService<ILogger<EntryPoint>>().CreateScopedLogger();
var scopedLogger = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger(nameof(EntryPoint));
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("HealthCheck status: {status} in {duration}. Report: {report}", status.Status, status.TotalDuration, string.Join("\n", status.Entries.Select(e => $"{e.Key}: {e.Value.Status}")));
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}")));
if (status.Status is HealthStatus.Healthy)
{
healthy = true;
@@ -66,12 +81,12 @@ public class EntryPoint
if (healthy)
{
scopedLogger.LogInformation("Daybreak API is healthy on port {port}. Initialization succeeded in {duration}", port, sw.Elapsed);
scopedLogger.LogInformation("{methodName}: Daybreak API is healthy on port {port}. Initialization succeeded in {duration}", nameof(ThreadInit), port, sw.Elapsed);
return port;
}
else
{
scopedLogger.LogError("Daybreak API failed to initialize in {duration}", sw.Elapsed);
scopedLogger.LogError("{methodName}: Daybreak API failed to initialize in {duration}", nameof(ThreadInit), sw.Elapsed);
CancellationTokenSource.Cancel();
return -1;
}
@@ -92,6 +107,8 @@ public class EntryPoint
.WithRoutes()
.WithHealthChecks();
builder.Services.AddOpenApi();
// Add CORS policy - allow localhost and trusted community sites
builder.Services.AddCors(options =>
{
@@ -136,22 +153,54 @@ public class EntryPoint
private static async Task StartServer(WebApplication app)
{
var scopedLogger = app.Services.GetRequiredService<ILogger<EntryPoint>>().CreateScopedLogger();
var scopedLogger = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger(nameof(EntryPoint));
try
{
scopedLogger.LogDebug("Initializing GWCA");
scopedLogger.LogDebug("{methodName}: Initializing GWCA", nameof(StartServer));
PreloadNativeDependencies(scopedLogger);
SetupGWCALogging(app);
scopedLogger.LogDebug("{methodName}: GWCA log handler registered", nameof(StartServer));
var result = GWCA.GW.Initialize();
scopedLogger.LogDebug($"GWCA.GW.Initialize() returned: {result}");
scopedLogger.LogDebug("{methodName}: GWCA.GW.Initialize() returned: {result}", nameof(StartServer), result);
await app.RunAsync();
}
catch (Exception ex)
{
scopedLogger.LogCritical(ex, $"Encountered fatal error while starting API server {ex}");
scopedLogger.LogCritical(ex, "{methodName}: Encountered fatal error while starting API server {exception}", nameof(StartServer), 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;
@@ -185,74 +234,26 @@ public class EntryPoint
return true;
}
private static void PreloadNativeDependencies(ScopedLogger<EntryPoint> logger)
private static void PreloadNativeDependencies(ILogger 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?.Equals("gwca.dll", StringComparison.OrdinalIgnoreCase) is true)
if (module.ModuleName?.Contains("Daybreak.API", StringComparison.OrdinalIgnoreCase) is true)
{
var handle = NativeLibrary.Load(module.FileName);
RegisterGwcaResolver(handle, module.FileName, logger);
logger.LogDebug($"Reused already-loaded gwca.dll from {module.FileName}");
return;
}
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);
}
if (daybreakApiDir is null &&
module.ModuleName?.Contains("Daybreak.API", StringComparison.OrdinalIgnoreCase) is true)
{
daybreakApiDir = Path.GetDirectoryName(module.FileName);
break;
}
}
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;
}
}
+10 -27
View File
@@ -1,7 +1,6 @@
using Daybreak.API.Models;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Daybreak.API.Services.Interop;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using ZLinq;
namespace Daybreak.API.Health;
@@ -12,37 +11,21 @@ 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 async (HealthCheckService healthCheckService, CancellationToken cancellationToken) =>
app.MapGet("/api/v1/rest/health", static (IEnumerable<IInteropHealthService> services) =>
{
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)
})
};
var entries = services
.SelectMany(s => s.GetAddressHealth())
.ToList();
var response = new HealthCheckResponse(Environment.ProcessId, entries);
return Results.Json(response);
})
.WithName("Health")
.WithSummary("Service health")
.WithDescription($"Current health status of the service. Returns a serialized object of type {nameof(HealthCheckResponse)}")
.WithTags("Service")
.WithSummary("Interop hook health")
.WithDescription("Returns the status of all low-level game hooks")
.WithTags("Health")
.Produces<HealthCheckResponse>(StatusCodes.Status200OK)
.WithOpenApi();
return app;
}
}
+7 -3
View File
@@ -1,4 +1,5 @@
using System.Runtime.InteropServices;
using Daybreak.API.Interop.GuildWars;
namespace Daybreak.API.Interop;
@@ -18,17 +19,20 @@ 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
@@ -0,0 +1,26 @@
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;
}
}
+482 -1952
View File
File diff suppressed because it is too large Load Diff
+312
View File
@@ -0,0 +1,312 @@
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
@@ -0,0 +1,243 @@
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);
}
}
@@ -0,0 +1,20 @@
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,44 +13,17 @@ 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.Count);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)index, this.Size);
return this.Buffer[index];
}
}
public Enumerator GetEnumerator() => new(this.Buffer, this.Count);
public Enumerator GetEnumerator() => new(this.Buffer, this.Size);
IEnumerator<T> IEnumerable<T>.GetEnumerator() => this.GetEnumerator();
@@ -1,23 +1,11 @@
namespace Daybreak.API.Interop.GuildWars;
public readonly unsafe struct WrappedPointer<T>(T* pointer)
public unsafe readonly struct WrappedPointer<T>(T* pointer)
where T : unmanaged
{
public readonly T* Pointer = pointer;
public bool IsNull => this.Pointer is null;
/// <summary>
/// True when the pointer is non-null and plausibly dereferenceable.
/// </summary>
/// <remarks>
/// Guild Wars structures are 4-byte aligned on x86, so a misaligned address is never a
/// real structure. When a GWCA pattern scan fails to resolve, it can hand back an
/// address inside Gw.exe's code section (for example 0x0048CA72); reading through that
/// yields nonsense field values and eventually an access violation. NativeAOT cannot
/// throw <c>AccessViolationException</c>, so it fail-fasts and terminates Guild Wars.
/// </remarks>
public bool IsValid => this.Pointer is not null && ((nuint)this.Pointer & 0x3) == 0;
public readonly bool IsNull => this.Pointer is null;
public static implicit operator WrappedPointer<T>(T* pointer) => new(pointer);
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,3 @@
namespace Daybreak.API.Models;
public sealed record AddressHealth(string Name, string Address, bool Hooked);
+4 -2
View File
@@ -2,9 +2,11 @@
namespace Daybreak.API.Models;
public sealed class CallbackRegistration(Guid uid, Action onDispose) : IDisposable
public sealed class CallbackRegistration<T>(Guid uid, T callback, Action onDispose) : IDisposable
where T : Delegate
{
private readonly Guid uid = uid;
public Guid Uid { get; } = uid;
public T Callback { get; } = callback.ThrowIfNull();
private readonly Action onDispose = onDispose.ThrowIfNull();
public void Dispose()
@@ -1,14 +0,0 @@
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; }
}
+1 -11
View File
@@ -1,13 +1,3 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System.Text.Json.Serialization;
namespace Daybreak.API.Models;
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; }
}
public sealed record HealthCheckResponse(int ProcessId, IEnumerable<AddressHealth> AddressHealth);
@@ -15,8 +15,9 @@ 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))]
@@ -25,8 +26,6 @@ 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))]
+36 -83
View File
@@ -1,6 +1,7 @@
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;
@@ -17,6 +18,7 @@ public sealed class CharacterSelectService(
UIContextService uiContextService,
GameThreadService gameThreadService,
GameContextService gameContextService,
PreferencesService preferencesService,
ILogger<CharacterSelectService> logger)
{
private readonly InstanceContextService instanceContextService = instanceContextService.ThrowIfNull();
@@ -24,6 +26,7 @@ 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)
@@ -35,28 +38,21 @@ public sealed class CharacterSelectService(
{
var gameContext = this.gameContextService.GetGameContext();
if (gameContext.IsNull ||
gameContext.Pointer->World is null ||
gameContext.Pointer->Character is null)
gameContext.Pointer->World is null)
{
scopedLogger.LogError("Game context is not initialized");
return default;
}
var availableCharsContext = this.gameContextService.GetAvailableChars();
if (!availableCharsContext.IsValid ||
!availableCharsContext.Pointer->IsValid)
if (availableCharsContext.IsNull)
{
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);
scopedLogger.LogError("Available characters context is not initialized");
return default;
}
var currentUuid = (*(Uuid*)gameContext.Pointer->Character->PlayerUuid).ToString();
var availableChars = new List<CharacterSelectEntry>((int)availableCharsContext.Pointer->Count);
var availableChars = new List<CharacterSelectEntry>((int)availableCharsContext.Pointer->Size);
foreach (var charContext in *availableCharsContext.Pointer)
{
var name = new string(charContext.Name);
@@ -169,12 +165,6 @@ 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)
{
@@ -183,8 +173,7 @@ public sealed class CharacterSelectService(
}
var ctx = this.uiContextService.GetFrameContext<CharSelectorContext>(selectorFrame);
if (ctx.IsNull ||
(nuint)ctx.Pointer <= 0xFFFF)
if (ctx.IsNull)
{
scopedLogger.LogError("Character selector context not found");
return false;
@@ -203,9 +192,7 @@ public sealed class CharacterSelectService(
// Find target character index
uint targetIdx = 0xFFFF;
// 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;
var charCount = ctx.Pointer->Chars.Size;
for (uint i = 0; i < charCount; i++)
{
@@ -281,10 +268,7 @@ public sealed class CharacterSelectService(
}
}
// 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).
// Navigate to target character by selecting previous/next until we reach it
while (targetIdx < selectedIdx)
{
if (selectedIdx == 0)
@@ -292,24 +276,13 @@ public sealed class CharacterSelectService(
break;
}
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))
if (!SelectChar(selectedIdx - 1))
{
scopedLogger.LogError("Failed to select previous character");
return false;
}
selectedIdx = prevIdx;
selectedIdx--;
}
while (targetIdx > selectedIdx)
@@ -319,24 +292,13 @@ public sealed class CharacterSelectService(
break;
}
var nextIdx = selectedIdx + 1;
while (nextIdx < charCount && ctx.Pointer->Chars.Buffer[nextIdx].IsNull)
{
nextIdx++;
}
if (nextIdx >= charCount)
{
break;
}
if (!SelectChar(nextIdx))
if (!SelectChar(selectedIdx + 1))
{
scopedLogger.LogError("Failed to select next character");
return false;
}
selectedIdx = nextIdx;
selectedIdx++;
}
var chosen = selectedIdx == targetIdx;
@@ -379,7 +341,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;
@@ -391,13 +353,25 @@ public sealed class CharacterSelectService(
return false;
}
// 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);
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);
}
}
private async Task<string?> GetCharNameByUuid(string uuid, CancellationToken cancellationToken)
@@ -415,8 +389,7 @@ public sealed class CharacterSelectService(
}
var availableCharsContext = this.gameContextService.GetAvailableChars();
if (!availableCharsContext.IsValid ||
!availableCharsContext.Pointer->IsValid)
if (availableCharsContext.IsNull)
{
scopedLogger.LogError("Available characters context is not initialized");
return default;
@@ -460,27 +433,7 @@ public sealed class CharacterSelectService(
private unsafe bool IsCharSelectReady()
{
// 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;
return this.GetCharSelectorFrame() is not null;
}
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)
public Task QueueOnGameThread(Action action, CancellationToken cancellationToken, bool forceEnqueue = false)
{
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, false);
GWCA.GW.GameThread.Enqueue(funcPtr, forceEnqueue);
return taskCompletionSource.Task;
}
public Task<T> QueueOnGameThread<T>(Func<T> action, CancellationToken cancellationToken)
public Task<T> QueueOnGameThread<T>(Func<T> action, CancellationToken cancellationToken, bool forceEnqueue = false)
{
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, false);
GWCA.GW.GameThread.Enqueue(funcPtr, forceEnqueue);
return taskCompletionSource.Task;
}
@@ -0,0 +1,8 @@
using Daybreak.API.Models;
namespace Daybreak.API.Services.Interop;
public interface IInteropHealthService
{
IEnumerable<AddressHealth> GetAddressHealth();
}
@@ -224,6 +224,35 @@ 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,9 +1,286 @@
using Daybreak.API.Interop;
using System.Extensions;
using System.Extensions.Core;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
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
public sealed class SkillbarContextService : IHostedService, IInteropHealthService
{
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,6 +4,7 @@ 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;
@@ -41,7 +42,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, ushort Value)>)>();
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)>)>();
for (var i = 0; i < 23; i++)
{
// Use GWCA helper to fetch bag pointers by index — safer than reading an inline array directly.
@@ -51,7 +52,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, ushort Value)>();
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)>();
itemTuples.Add((bag->BagType, retBag));
if (bag->ItemsCount is 0)
{
@@ -75,7 +76,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, item->Value));
retBag.Add((item->ModelId, completeNameEncoded, singleItemName, nameEncoded, (item->Interaction & 0x08000000) != 0, item->Quantity, modifiers, (ItemType)item->Type, item->Interaction, item->ModelFileId, item->Dye.DyeTint));
}
}
@@ -90,16 +91,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, ushort Value)> 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)> 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, ushort Value)>();
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)>();
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, item.Value));
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));
}
decodedItemTuples.Add((tuple.Type, decodedItems));
@@ -125,8 +126,7 @@ public sealed class InventoryService(
ItemType: item.ItemType.ToString(),
Interaction: item.Interaction,
ModelFileId: item.ModelFileId,
DyeTint: item.DyeTint,
Value: item.Value))]))]);
DyeTint: item.DyeTint))]))]);
}
private static string ToBase64(string encoded)
+409 -132
View File
@@ -5,6 +5,7 @@ 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;
@@ -16,35 +17,75 @@ using InstanceType = Daybreak.API.Interop.GuildWars.InstanceType;
namespace Daybreak.API.Services;
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)
public sealed class PartyService : IHostedService
{
private static readonly TimeSpan HeroSpawnDelay = TimeSpan.FromSeconds(1);
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 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;
public async Task<bool> SetPartyLoadout(PartyLoadout partyLoadout, CancellationToken cancellationToken)
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)
{
var scopedLogger = this.logger.CreateScopedLogger();
if (!await this.IsInValidOutpost(cancellationToken))
{
scopedLogger.LogError("Could not set party loadout. Not in a valid outpost");
@@ -52,6 +93,14 @@ public sealed class PartyService(
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");
@@ -59,7 +108,7 @@ public sealed class PartyService(
return false;
}
if (!await this.gameThreadService.QueueOnGameThread(() => this.SpawnHeroes(partyLoadout), cancellationToken))
if (!await this.gameThreadService.QueueOnGameThread(() => this.SpawnHeroes(teamBuild), 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);
@@ -68,14 +117,14 @@ public sealed class PartyService(
await Task.Delay(HeroSpawnDelay, cancellationToken);
if (!await this.gameThreadService.QueueOnGameThread(() => this.ApplyBuilds(partyLoadout), cancellationToken))
if (!await this.gameThreadService.QueueOnGameThread(() => this.ApplyBuilds(teamBuild), 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(partyLoadout), cancellationToken);
var heroBehaviorSetup = await this.gameThreadService.QueueOnGameThread(() => this.GetHeroBehaviorSetup(teamBuild), cancellationToken);
foreach (var (AgentId, Behavior) in heroBehaviorSetup ?? [])
{
if (!await this.SetHeroBehavior(AgentId, Behavior, cancellationToken))
@@ -89,10 +138,10 @@ public sealed class PartyService(
return true;
}
public Task<PartyLoadout?> GetPartyLoadout(CancellationToken cancellationToken)
public async Task<string?> GetPartyLoadout(CancellationToken cancellationToken)
{
var scopedLogger = this.logger.CreateScopedLogger();
return this.gameThreadService.QueueOnGameThread(() =>
var teamBuild = await this.gameThreadService.QueueOnGameThread(() =>
{
unsafe
{
@@ -133,25 +182,47 @@ public sealed class PartyService(
.OfType<(uint AgentId, BuildEntry BuildEntry, API.Interop.GWCA.GW.HeroBehavior Behavior)>()
.ToList();
return new PartyLoadout(
[.. buildTuples.Select(t =>
{
if (t.AgentId == playerId)
{
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)
{
return new PartyLoadoutEntry((int)hero.HeroId, (Shared.Models.Api.HeroBehavior)t.Behavior, t.BuildEntry);
}
var teamBuildEntry = this.buildTemplateManager.CreateTeamBuild();
teamBuildEntry.Builds.Clear();
var partyComposition = new List<PartyCompositionMetadataEntry>();
return default;
})
.Where(t => t is not null)
.OfType<PartyLoadoutEntry>()]);
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)
{
partyComposition.Add(new PartyCompositionMetadataEntry
{
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
{
Type = PartyCompositionMemberType.Hero,
Index = teamBuildEntry.Builds.Count - 1,
HeroId = (int)hero.HeroId,
Behavior = (Shared.Models.Api.HeroBehavior)t.Behavior
});
}
}
teamBuildEntry.PartyComposition = partyComposition;
return teamBuildEntry;
}
}, cancellationToken);
if (teamBuild is null)
{
return default;
}
return this.buildTemplateManager.EncodeTemplate(teamBuild);
}
public async Task<bool> KickAllHeroes(CancellationToken cancellationToken)
@@ -232,6 +303,210 @@ public sealed class PartyService(
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(() =>
@@ -240,21 +515,25 @@ public sealed class PartyService(
}, cancellationToken);
}
private unsafe bool SpawnHeroes(PartyLoadout partyLoadout)
private bool SpawnHeroes(TeamBuildEntry partyLoadout)
{
var scopedLogger = this.logger.CreateScopedLogger();
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))
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))
{
if (entry.HeroId != 0 &&
Hero.TryParse(entry.HeroId, out var hero))
if (entry.HeroId is not null &&
entry.HeroId != 0 &&
Hero.TryParse(entry.HeroId.Value, out var hero))
{
scopedLogger.LogInformation("Adding hero [{heroId}] [{heroName}] with behavior {behavior}", entry.HeroId, hero.Name, entry.HeroBehavior);
scopedLogger.LogInformation("Adding hero [{heroId}] [{heroName}] with behavior {behavior}", entry.HeroId, hero.Name, entry.Behavior ?? Shared.Models.Api.HeroBehavior.Guard);
this.partyContextService.AddHero((uint)entry.HeroId);
}
else
{
scopedLogger.LogWarning("Invalid hero entry in party loadout: {heroId}", entry.HeroId);
scopedLogger.LogWarning("Invalid hero entry in party loadout: {heroId}", entry.HeroId ?? -1);
continue;
}
}
@@ -262,7 +541,7 @@ public sealed class PartyService(
return true;
}
private unsafe bool ApplyBuilds(PartyLoadout partyLoadout)
private unsafe bool ApplyBuilds(TeamBuildEntry partyLoadout)
{
var scopedLogger = this.logger.CreateScopedLogger();
scopedLogger.LogDebug("Applying builds");
@@ -299,103 +578,71 @@ public sealed class PartyService(
return false;
}
var parsedEntries = partyLoadout.Entries.AsValueEnumerable()
.Select(entry =>
{
if (entry.HeroId is 0)
{
return (entry, playerId);
}
else
{
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)
for (var buildIndex = 0; buildIndex < partyLoadout.Builds.Count; buildIndex++)
{
if (maybeAgentId is not uint agentId)
var build = partyLoadout.Builds[buildIndex];
var composition = partyLoadout.PartyComposition?.FirstOrDefault(c => c.Index == buildIndex);
if (composition is null)
{
continue;
}
var attributeIds = new AttributeArray12();
var attributeValues = new Array12Uint();
for (var i = 0; i < entry.Build.Attributes.Count && i < 12; i++)
uint agentId;
if (composition.HeroId is 0 or null)
{
attributeIds[i] = (GWCA.GW.Constants.Attribute)entry.Build.Attributes[i].Id;
attributeValues[i] = (uint)entry.Build.Attributes[i].BasePoints;
agentId = playerId.Value;
}
else
{
var hero = heroes.Value.AsValueEnumerable().FirstOrDefault(h => (int)h.HeroId == composition.HeroId.GetValueOrDefault());
agentId = hero.AgentId;
}
var skills = new SkillIDArray8();
for (var i = 0; i < entry.Build.Skills.Count && i < 8; i++)
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])))
{
skills[i] = (GWCA.GW.Constants.SkillID)entry.Build.Skills[i];
if (composition.HeroId is 0 or null)
{
scopedLogger.LogError("Cannot apply build for player");
}
else
{
scopedLogger.LogError("Cannot apply build for hero {heroId}", composition.HeroId);
}
continue;
}
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);
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);
this.skillbarContextService.LoadBuild(agentId, &skillTemplate);
}
return true;
}
private unsafe List<(uint AgentId, Shared.Models.Api.HeroBehavior Behavior)>? GetHeroBehaviorSetup(PartyLoadout partyLoadout)
private unsafe List<(uint AgentId, Shared.Models.Api.HeroBehavior Behavior)>? GetHeroBehaviorSetup(TeamBuildEntry teamBuild)
{
var scopedLogger = this.logger.CreateScopedLogger();
scopedLogger.LogDebug("Getting hero behavior setup");
if (!this.gameContextService.GetGameContext().TryGetPlayerParty(out _, out _, out var heroes, out _) ||
!heroes.HasValue)
{
@@ -403,6 +650,12 @@ public sealed class PartyService(
return default;
}
if (teamBuild.PartyComposition is null)
{
scopedLogger.LogWarning("No party composition metadata in team build");
return default;
}
return heroes.Value.AsValueEnumerable()
.Select(h =>
{
@@ -411,14 +664,14 @@ public sealed class PartyService(
return default;
}
var entry = partyLoadout.Entries.AsValueEnumerable().FirstOrDefault(e => e.HeroId == (int)h.HeroId);
var entry = teamBuild.PartyComposition.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.HeroBehavior);
return (h.AgentId, entry.Behavior ?? Shared.Models.Api.HeroBehavior.Guard);
})
.Where(t => t.AgentId is not 0)
.ToList();
@@ -469,7 +722,7 @@ public sealed class PartyService(
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)
{
@@ -507,4 +760,28 @@ public sealed class PartyService(
(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,4 +1,5 @@
using Daybreak.API.Services.Interop;
using System.Diagnostics.CodeAnalysis;
using Daybreak.API.Services.Interop;
using Daybreak.Shared.Services.BuildTemplates;
using Daybreak.Shared.Services.BuildTemplates.Parsers;
@@ -16,12 +17,10 @@ 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>();
@@ -29,6 +28,18 @@ 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,4 +1,8 @@
using Microsoft.AspNetCore.Routing.Constraints;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.Routing.Constraints;
using Microsoft.Extensions.Options;
using Swashbuckle.AspNetCore.SwaggerGen;
using System.Text.Json;
namespace Daybreak.API.Swagger;
@@ -7,6 +11,14 @@ 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,13 +1,11 @@
using Scalar.AspNetCore;
namespace Daybreak.API.Swagger;
namespace Daybreak.API.Swagger;
public static class WebApplicationExtensions
{
public static WebApplication UseSwaggerWithUI(this WebApplication app)
{
app.MapOpenApi();
app.MapScalarApiReference();
app.UseSwagger();
app.UseSwaggerUI();
return app;
}
}
@@ -1,40 +0,0 @@
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,33 +39,4 @@ 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,7 +9,6 @@ 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;
@@ -49,12 +48,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;
@@ -83,6 +82,7 @@ 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,13 +128,7 @@ public class ProjectConfiguration : PluginConfigurationBase
var resourceBuilder = ResourceBuilder
.CreateDefault()
.AddService("Daybreak", serviceVersion: CurrentVersion.ToString());
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"),
};
var attributes = new List<KeyValuePair<string, object>>();
#if DEBUG
attributes.Add(new KeyValuePair<string, object>("deployment.environment", "debug"));
#else
@@ -231,7 +225,6 @@ 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>();
@@ -255,13 +248,14 @@ 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.AddSingleton<IGuildWarsExecutableManager, GuildWarsExecutableManager>();
services.AddHostedSingleton<IGuildWarsExecutableManager, GuildWarsExecutableManager>();
services.AddHostedSingleton<IEventNotifierService, EventNotifierService>();
services.AddHostedSingleton<IApiScanningService, ApiScanningService>();
services.AddHostedSingleton<GameScreenshotsTheme>();
@@ -349,7 +343,6 @@ public class ProjectConfiguration : PluginConfigurationBase
optionsProducer.RegisterOptions<SynchronizationOptions>();
optionsProducer.RegisterOptions<FocusViewOptions>();
optionsProducer.RegisterOptions<DaybreakApiOptions>();
optionsProducer.RegisterOptions<DownloadOptions>();
optionsProducer.RegisterOptions<ToolboxOptions>();
optionsProducer.RegisterOptions<UModOptions>();
@@ -390,11 +383,8 @@ public class ProjectConfiguration : PluginConfigurationBase
public override void RegisterMods(IModsProducer modsManager)
{
modsManager.RegisterMod<IGuildWarsVersionChecker, GuildWarsVersionChecker>();
// 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<IToolboxService, ToolboxService>();
modsManager.RegisterMod<IUModService, UModService>();
modsManager.RegisterMod<IDirectSongService, DirectSongService>(singleton: true);
}
@@ -575,6 +565,10 @@ 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)
+2 -3
View File
@@ -39,15 +39,14 @@
</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" />
@@ -66,7 +65,7 @@
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.Debug" />
<PackageReference Include="SharpCompress" />
<PackageReference Include="StbImageSharp" />
<PackageReference Include="SixLabors.ImageSharp" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" />
</ItemGroup>
@@ -4,14 +4,5 @@ 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, port);
var systemPid = this.pidProvider.ResolveSystemPid(reportedPid.Value, GuildWarsExecutable);
var uri = new Uri($"http://localhost:{port}");
scopedLogger.LogDebug(
@@ -30,7 +30,6 @@ internal sealed class ApplicationLauncher(
IGuildWarsProcessFinder guildWarsProcessFinder,
IDaybreakRestartingService daybreakRestartingService,
IPrivilegeManager privilegeManager,
ISteamService steamService,
ILogger<ApplicationLauncher> logger
) : IApplicationLauncher
{
@@ -56,7 +55,6 @@ internal sealed class ApplicationLauncher(
private readonly IPrivilegeManager privilegeManager =
privilegeManager.ThrowIfNull();
private readonly ISteamService steamService = steamService.ThrowIfNull();
public async Task<GuildWarsApplicationLaunchContext?> LaunchGuildwars(
LaunchConfigurationWithCredentials launchConfigurationWithCredentials,
@@ -65,27 +63,6 @@ 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,
@@ -130,9 +107,8 @@ internal sealed class ApplicationLauncher(
)
{
var scopedLogger = this.logger.CreateScopedLogger();
var isSteamLogin = launchConfigurationWithCredentials.Credentials?.IsSteamLogin is true;
var email = isSteamLogin ? null : launchConfigurationWithCredentials.Credentials?.Username;
var password = isSteamLogin ? null : launchConfigurationWithCredentials.Credentials?.Password;
var email = launchConfigurationWithCredentials.Credentials?.Username;
var password = launchConfigurationWithCredentials.Credentials?.Password;
var executable = launchConfigurationWithCredentials.ExecutablePath;
if (executable is not null && File.Exists(executable) is false)
{
@@ -253,7 +229,7 @@ internal sealed class ApplicationLauncher(
};
var steamAppIdFilePath = Path.Combine(workingDirectory, SteamAppIdFile);
if (launchConfigurationWithCredentials.SteamSupport || isSteamLogin)
if (launchConfigurationWithCredentials.SteamSupport)
{
await File.WriteAllTextAsync(steamAppIdFilePath, SteamAppId, cancellationToken);
scopedLogger.LogDebug(
@@ -560,7 +536,18 @@ internal sealed class ApplicationLauncher(
var scopedLogger = this.logger.CreateScopedLogger();
try
{
this.guildWarsProcessFinder.KillProcess(guildWarsApplicationLaunchContext);
var process = guildWarsApplicationLaunchContext.GuildWarsProcess;
if (
process.MainModule?.FileName is not null
&& process.MainModule.FileName.Contains(
"Gw.exe",
StringComparison.OrdinalIgnoreCase
)
)
{
process.Kill(true);
return;
}
}
catch (Exception e)
when (e.Message.Contains(
@@ -26,12 +26,6 @@ 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;
@@ -62,7 +56,6 @@ 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,7 +433,6 @@ internal sealed class DXVKService(
{
Directory.Delete(x32Target, recursive: true);
}
if (Directory.Exists(x64Target))
{
Directory.Delete(x64Target, recursive: true);
@@ -1,25 +1,20 @@
using Daybreak.Configuration.Options;
using Daybreak.Shared.Models.Async;
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";
@@ -28,79 +23,19 @@ 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);
// 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);
using var response = await this.httpClient.GetAsync(downloadUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
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;
}
@@ -109,39 +44,30 @@ 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 chunkTimeout = TimeSpan.FromSeconds(currentOptions.ChunkTimeout);
var buffer = new byte[BufferSize];
// Scoped so the handle is released before a failed download is deleted.
using (var fileStream = File.Open(destinationPath, FileMode.Create, FileAccess.Write))
var startTime = DateTime.Now;
while (downloadStream.CanRead && (length = await downloadStream.ReadAsync(buffer, cancellationToken)) > 0)
{
while (downloadStream.CanRead)
downloaded += length;
downloadedPerTimeframe += length;
await fileStream.WriteAsync(buffer.AsMemory(0, length), cancellationToken);
if ((DateTime.Now - tickTime).TotalMilliseconds > StatusUpdateInterval)
{
// 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;
}
tickTime = DateTime.Now;
var downloadedInSecond = downloadedPerTimeframe * 1000d / StatusUpdateInterval;
this.averageDownloadSpeed.Record(downloadedInSecond);
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));
}
// var avgSpeed = downloaded / (tickTime - startTime).TotalSeconds;
// var remainingSize = downloadSize - downloaded;
// var secondsRemaining = remainingSize / avgSpeed;
downloadedPerTimeframe = 0d;
progress.Report(ProgressDownload(downloaded / downloadSize));
}
}
@@ -149,24 +75,14 @@ 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,7 +1,6 @@
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;
@@ -33,7 +32,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: Global.NotificationShortExpiration, metaData: e.Title);
this.notificationService.NotifyInformation<NavigateToCalendarViewHandler>(e.Title!, $"{GetRemainingTime(e)}\n{e.Description!}", expirationTime: DateTime.Now + TimeSpan.FromSeconds(15), metaData: e.Title);
}
}
@@ -4,6 +4,7 @@ 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;
@@ -41,7 +42,7 @@ internal sealed class ExceptionHandler(
var exceptionDate = DateTime.UtcNow;
if (this.logger is null)
{
this.WriteCrashFiles(e, exceptionDate);
WriteCrashFiles(e, exceptionDate);
return false;
}
@@ -53,7 +54,7 @@ internal sealed class ExceptionHandler(
case HandleResult.Handled:
return true;
case HandleResult.Fatal:
this.WriteCrashFiles(e, exceptionDate);
WriteCrashFiles(e, exceptionDate);
return false;
case HandleResult.Unhandled:
default:
@@ -1,26 +1,36 @@
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
ILogger<GuildWarsExecutableManager> logger) : IGuildWarsExecutableManager, IHostedService
{
internal const int MaxExecutables = 10;
private readonly static TimeSpan ExecutableVerificationLatency = TimeSpan.FromSeconds(5);
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();
@@ -33,6 +43,7 @@ internal sealed class GuildWarsExecutableManager(
public void AddExecutable(string executablePath)
{
var fullPath = Path.GetFullPath(executablePath);
ExecutablesSemaphore.Wait();
var options = this.liveUpdateableOptions.CurrentValue;
@@ -42,8 +53,6 @@ internal sealed class GuildWarsExecutableManager(
list.Insert(0, executablePath);
}
this.TrimInvalidExecutables(list);
options.ExecutablePaths = list;
this.optionsProvider.SaveOption(options);
ExecutablesSemaphore.Release();
@@ -71,26 +80,38 @@ internal sealed class GuildWarsExecutableManager(
return IsValidExecutableInternal(executablePath);
}
/// <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)
private async Task VerifyExecutables(CancellationToken cancellationToken)
{
var scopedLogger = this.logger.CreateScopedLogger();
for (var i = executables.Count - 1; i >= 0 && executables.Count > MaxExecutables; i--)
var scopedLogger = this.logger.CreateScopedLogger(nameof(this.VerifyExecutables), string.Empty);
while (!cancellationToken.IsCancellationRequested)
{
if (IsValidExecutableInternal(executables[i]))
await ExecutablesSemaphore.WaitAsync(cancellationToken);
var executables = this.liveUpdateableOptions.CurrentValue.ExecutablePaths;
var deletedExecutable = false;
for (var i = 0; i < executables.Count; i++)
{
continue;
var executable = executables[i];
if (IsValidExecutableInternal(executable))
{
continue;
}
scopedLogger.LogWarning($"Detected deleted executable at {executable}");
deletedExecutable = true;
executables.Remove(executable);
i--;
}
scopedLogger.LogInformation("Evicting stale executable while over capacity: {executable}", executables[i]);
executables.RemoveAt(i);
if (deletedExecutable)
{
var options = this.liveUpdateableOptions.CurrentValue;
options.ExecutablePaths = executables;
this.optionsProvider.SaveOption(options);
}
ExecutablesSemaphore.Release();
await Task.Delay(ExecutableVerificationLatency, cancellationToken);
}
}
@@ -1,54 +0,0 @@
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: Global.NotificationShortExpiration);
expirationTime: DateTime.Now + TimeSpan.FromSeconds(15));
}
public Task OnGuildWarsCreated(GuildWarsCreatedContext guildWarsCreatedContext, CancellationToken cancellationToken) => Task.CompletedTask;
@@ -310,19 +310,15 @@ 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. 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];
// 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];
private readonly PeFile peFile;
private readonly ImageSectionHeader textSection;
@@ -38,39 +38,13 @@ internal sealed class GuildWarsExecutableParser
return Task.Run(() =>
{
var patternRva = this.FindWithWildcards(FileIdFunctionPattern);
var fileIdFunctionRva = this.FindGetFileIdCall(patternRva);
var callRva = patternRva + 0x32;
var fileIdFunctionRva = this.FollowCall(callRva);
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,6 +94,24 @@ 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,12 +1,13 @@
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 : RedactingLogEventSink, IDisposable
public sealed class InMemorySink : ILogEventSink, IDisposable
{
private const int MaxLogEvents = 50000;
@@ -29,7 +30,7 @@ public sealed class InMemorySink : RedactingLogEventSink, IDisposable
this.logEvents.Clear();
}
protected override void EmitRedacted(LogEvent logEvent)
public void Emit(LogEvent logEvent)
{
lock (this.snapShotLock)
{
@@ -1,155 +0,0 @@
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();
}
@@ -1,27 +0,0 @@
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.UtcNow + TimeSpan.FromSeconds(5)),
ExpirationTime = expirationTime ?? (DateTime.Now + TimeSpan.FromSeconds(5)),
Dismissible = dismissible,
Level = logLevel,
};
@@ -10,7 +10,6 @@ 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;
@@ -19,6 +18,7 @@ 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,7 +34,6 @@ 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";
@@ -69,7 +68,6 @@ 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";
@@ -189,11 +187,6 @@ 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);
@@ -205,16 +198,11 @@ 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, true);
Directory.Delete(destination);
}
return Task.CompletedTask;
@@ -222,7 +210,7 @@ internal sealed class ReShadeService(
public void OpenReShadeFolder()
{
this.shellExecutor.OpenPath(ReShadePath);
Process.Start("explorer.exe", ReShadePath);
}
public async Task<IEnumerable<ShaderPackage>> GetStockPackages(CancellationToken cancellationToken)
@@ -1,10 +1,13 @@
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 StbImageSharp;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using static Daybreak.Shared.Models.Themes.Theme;
using Color = System.Drawing.Color;
@@ -62,9 +65,9 @@ public sealed class ScreenshotService(
try
{
using var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
var image = ImageResult.FromStream(fileStream, ColorComponents.RedGreenBlueAlpha);
using var image = Image.Load<Rgba32>(fileStream);
var (dominantColor, isDark) = GetDominantColor(image.Data, image.Width, image.Height);
var (dominantColor, isDark) = GetDominantColor(image);
var closestAccent = GetClosestAccentColor(dominantColor);
var entry = new ScreenshotEntry(path, closestAccent, isDark ? LightDarkMode.Dark : LightDarkMode.Light);
EntryCache[path] = entry;
@@ -78,25 +81,88 @@ public sealed class ScreenshotService(
}
}
private static (Color Color, bool IsDark) GetDominantColor(ReadOnlySpan<byte> rgba, int width, int height)
private static (Color Color, bool IsDark) GetDominantColor(Image<Rgba32> image)
{
long totalR = 0, totalG = 0, totalB = 0;
int pixelCount = 0;
int stride = width * 4; // RGBA, 4 bytes per pixel
// Sample every SampleQuality-th pixel of every SampleQuality-th row for performance.
for (int y = 0; y < height; y += SampleQuality)
image.ProcessPixelRows(accessor =>
{
int rowStart = y * stride;
for (int x = 0; x < width; x += SampleQuality)
for (int y = 0; y < accessor.Height; y += SampleQuality)
{
int i = rowStart + (x * 4);
totalR += rgba[i];
totalG += rgba[i + 1];
totalB += rgba[i + 2];
pixelCount++;
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++;
}
}
}
});
if (pixelCount == 0)
{
@@ -20,7 +20,9 @@ public sealed class VerifyNativeComponentsAction(
private const string ApiFolder = "Api";
private const string InstallerFolder = "Installer";
private static readonly string[] InjectorFiles = ["Daybreak.Injector.exe"];
private static readonly string[] InjectorFiles = OperatingSystem.IsWindows()
? ["Daybreak.Injector.exe", "FASM.DLL"]
: ["Daybreak.Injector.exe", "FASM.DLL"];
private static readonly string[] ApiFiles = ["Daybreak.API.dll"];
private static readonly string[] InstallerFiles = OperatingSystem.IsWindows()
? ["Daybreak.Installer.exe"]
@@ -44,8 +44,7 @@ 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);
var extractionOptions = new ExtractionOptions { ExtractFullPath = true, Overwrite = true };
using var reader = ReaderFactory.OpenReader(stream, new ReaderOptions { ExtractFullPath = true, Overwrite = true });
var processedEntries = 0;
@@ -69,7 +68,7 @@ internal sealed class TarGzExtractor(
progressTracker(0.5, entryKey);
// Extract the entry
reader.WriteEntryToDirectory(destinationDirectory, extractionOptions);
reader.WriteEntryToDirectory(destinationDirectory);
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 = SanitizeUri(SecretManager.GetSecret(SecretKeys.ApmUri));
private static readonly string ApmEndpoint = SecretManager.GetSecret(SecretKeys.ApmUri);
private static readonly string ApmServiceAccount = SecretManager.GetSecret(SecretKeys.ApmServiceAccount);
private static readonly string ApmServiceKey = SecretManager.GetSecret(SecretKeys.ApmServiceKey);
@@ -159,10 +159,7 @@ internal sealed class TelemetryHost : IDisposable, IHostedService
return;
}
// 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 apmUri = new Uri(ApmEndpoint);
var creds = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{ApmServiceAccount}:{ApmServiceKey}"));
this.meter = Sdk.CreateMeterProviderBuilder()
@@ -175,13 +172,11 @@ internal sealed class TelemetryHost : IDisposable, IHostedService
.AddMeter("OpenTelemetry.Instrumentation.SqlClient")
.AddOtlpExporter((exporterOptions, readerOptions) =>
{
exporterOptions.Endpoint = new Uri(apmUri, "opentelemetry/v1/metrics");
exporterOptions.Endpoint = new Uri(apmUri, "v1/metrics");
exporterOptions.Headers = $"Authorization=Basic {creds}";
exporterOptions.Protocol = OpenTelemetry.Exporter.OtlpExportProtocol.HttpProtobuf;
readerOptions.PeriodicExportingMetricReaderOptions.ExportIntervalMilliseconds = 10_000;
// VictoriaMetrics works best with cumulative temporality; delta breaks
// standard rate()/increase() queries used by the Grafana dashboards.
readerOptions.TemporalityPreference = MetricReaderTemporalityPreference.Cumulative;
readerOptions.TemporalityPreference = MetricReaderTemporalityPreference.Delta;
})
.Build();
@@ -194,8 +189,8 @@ internal sealed class TelemetryHost : IDisposable, IHostedService
logOpts.SetResourceBuilder(this.resourceBuilder);
logOpts.AddOtlpExporter(exp =>
{
exp.Endpoint = new Uri(apmUri, "insert/opentelemetry/v1/logs");
exp.Headers = $"Authorization=Basic {creds}";
exp.Endpoint = new Uri(apmUri, "v1/logs");
exp.Headers = $"Authorization=Basic {creds}";
exp.Protocol = OpenTelemetry.Exporter.OtlpExportProtocol.HttpProtobuf;
});
@@ -206,20 +201,6 @@ 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 Daybreak.Services.Logging;
using Serilog.Core;
using Serilog.Events;
namespace Daybreak.Services.Telemetry;
public sealed class TelemetryLogSink : RedactingLogEventSink
public sealed class TelemetryLogSink : ILogEventSink
{
public readonly static TelemetryLogSink Instance = new();
@@ -13,7 +13,7 @@ public sealed class TelemetryLogSink : RedactingLogEventSink
{
}
protected override void EmitRedacted(LogEvent logEvent)
public void Emit(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) => this.LaunchToolbox(guildWarsCreatedContext.ApplicationLauncherContext.Process, cancellationToken);
public Task OnGuildWarsCreated(GuildWarsCreatedContext guildWarsCreatedContext, CancellationToken cancellationToken) => Task.CompletedTask;
public Task OnGuildWarsStarted(GuildWarsStartedContext guildWarsStartedContext, CancellationToken cancellationToken) => Task.CompletedTask;
public Task OnGuildWarsStarted(GuildWarsStartedContext guildWarsStartedContext, CancellationToken cancellationToken) => this.LaunchToolbox(guildWarsStartedContext.ApplicationLauncherContext.Process, cancellationToken);
public IEnumerable<string> GetCustomArguments() => [];
@@ -366,10 +366,6 @@ 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,7 +1,6 @@
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;
@@ -198,7 +197,7 @@ internal sealed class TradeAlertingService : ITradeAlertingService, IHostedServi
this.notificationService.NotifyInformation(
title: "Quote alert!",
description: description,
expirationTime: Global.NotificationShortExpiration);
expirationTime: DateTime.Now + TimeSpan.FromSeconds(15));
}
await Task.Delay(TimeSpan.FromSeconds(this.options.CurrentValue.QuoteAlertsInterval), cancellationToken);
@@ -259,7 +258,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.UtcNow + TimeSpan.FromDays(1));
expirationTime: DateTime.Now + TimeSpan.FromDays(1));
}
private static bool CheckMatch(string toCheck, string toMatch, bool isRegex)
+299
View File
@@ -0,0 +1,299 @@
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();
}
+71 -81
View File
@@ -8,90 +8,80 @@
<div class="option-view-title">
<h3>Accounts</h3>
</div>
@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="Delete account" />
</FluentTextField>
</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="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 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>
}
<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>
+9 -5
View File
@@ -8,7 +8,6 @@ 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;
@@ -18,6 +17,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
using Photino.Blazor;
using System.Core.Extensions;
using System.Diagnostics;
using System.Extensions;
using TrailBlazr.Services;
@@ -39,7 +39,6 @@ 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);
@@ -90,7 +89,6 @@ public sealed class AppViewModel
INotificationProducer notificationProducer,
INotificationService notificationService,
IWindowManipulationService windowManipulationService,
IShellExecutor shellExecutor,
ILogger<App> logger)
{
this.keyboardHookService = keyboardHookService.ThrowIfNull();
@@ -106,7 +104,6 @@ public sealed class AppViewModel
this.NotificationProducer = notificationProducer.ThrowIfNull();
this.notificationService = notificationService.ThrowIfNull();
this.windowManipulationService = windowManipulationService.ThrowIfNull();
this.shellExecutor = shellExecutor.ThrowIfNull();
this.logger = logger.ThrowIfNull();
@@ -218,7 +215,14 @@ public sealed class AppViewModel
public void OpenIssues()
{
this.shellExecutor.OpenUrl(IssueUrl);
try
{
Process.Start(new ProcessStartInfo { FileName = IssueUrl, UseShellExecute = true });
}
catch (Exception ex)
{
this.logger.LogError(ex, "Encountered exception while opening issues page");
}
}
public void OpenSynchronizationView()
@@ -232,7 +232,7 @@ public abstract class BuildTemplateViewModelBase<TViewModel, TView>(
protected void FilterSkillsByProfessionsAndString(string? searchFilter = default)
{
var filteredSkills = Skill.AllSkills.Where(skill =>
var filteredSkills = Skill.Skills.Where(skill =>
{
if (skill.PvP)
{
@@ -1,6 +1,4 @@
@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="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" />
@@ -25,67 +23,67 @@
<div class="skill-concise-description">@this.Context.Skill.ConciseDescription</div>
</div>
<div class="right-side">
@if (this.Context.Skill.Energy.HasValue)
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Energy))
{
<div class="skill-resource">
<div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Energy />
</div>
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Energy)</div>
<div class="skill-resource-text">@this.Context.Skill.Energy</div>
</div>
}
@if (this.Context.Skill.Activation.HasValue)
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Activation))
{
<div class="skill-resource">
<div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Activation />
</div>
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Activation)</div>
<div class="skill-resource-text">@this.Context.Skill.Activation</div>
</div>
}
@if (this.Context.Skill.Recharge.HasValue)
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Recharge))
{
<div class="skill-resource">
<div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Recharge />
</div>
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Recharge)</div>
<div class="skill-resource-text">@this.Context.Skill.Recharge</div>
</div>
}
@if (this.Context.Skill.Overcast.HasValue)
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Overcast))
{
<div class="skill-resource">
<div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Overcast />
</div>
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Overcast)</div>
<div class="skill-resource-text">@this.Context.Skill.Overcast</div>
</div>
}
@if (this.Context.Skill.Adrenaline.HasValue)
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Adrenaline))
{
<div class="skill-resource">
<div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Adrenaline />
</div>
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Adrenaline)</div>
<div class="skill-resource-text">@this.Context.Skill.Adrenaline</div>
</div>
}
@if (this.Context.Skill.Sacrifice.HasValue)
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Sacrifice))
{
<div class="skill-resource">
<div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Sacrifice />
</div>
<div class="skill-resource-text">@this.SkillNumberFormatter.FormatPercent(this.Context.Skill.Sacrifice)</div>
<div class="skill-resource-text">@this.Context.Skill.Sacrifice</div>
</div>
}
@if (this.Context.Skill.Upkeep.HasValue)
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Upkeep))
{
<div class="skill-resource">
<div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Upkeep />
</div>
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Upkeep)</div>
<div class="skill-resource-text">@this.Context.Skill.Upkeep</div>
</div>
}
</div>
@@ -1,28 +0,0 @@
<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; }
}
@@ -1,14 +0,0 @@
.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;
}
@@ -1,37 +0,0 @@
<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();
}
}
}
@@ -1,38 +0,0 @@
.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,23 +1,6 @@
<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
{
@if (this.Context is not null)
{
<div class="container backdrop-panel">
<div class="title-track-container">
<div class="title-label">
Title Track
@@ -35,8 +18,8 @@
</div>
</div>
</div>
}
</div>
</div>
}
@code {
[Parameter]
@@ -86,7 +69,7 @@
private string GetTitleDescription()
{
if (this.Context?.Title is null)
if (this.Context is null)
{
return string.Empty;
}
@@ -54,15 +54,3 @@
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,34 +1,21 @@
<div class="container backdrop-panel">
@if (this.Context is null)
{
<SpinnerWidget IsLoading="true" />
}
else
{
@if (this.Context is not null &&
this.Context.HardMode &&
this.Context.Vanquishing)
{
<div class="container backdrop-panel">
<div class="title">
Vanquishing
</div>
@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 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>
}
else
{
<div class="vanquish-bar-container">
<div class="vanquish-idle">
Not vanquishing
</div>
</div>
}
}
</div>
</div>
</div>
}
@code {
[Parameter]
@@ -50,11 +37,6 @@
}
var totalFoes = this.Context.FoesToKill + this.Context.FoesKilled;
if (totalFoes is 0)
{
return "width: 0%;";
}
return $"width: {this.Context.FoesKilled * 100d / totalFoes}%;";
}
@@ -40,12 +40,3 @@
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,9 +25,6 @@
{
<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>
@@ -75,7 +72,7 @@
try
{
await this.notificationLock.WaitAsync(cancellationToken);
this.notifications.Add(new NotificationWrapper { Notification = notification, StartTime = DateTime.UtcNow, ExpirationTime = notification.ExpirationTime });
this.notifications.Add(new NotificationWrapper { Notification = notification, ExpirationTime = notification.ExpirationTime });
}
catch (OperationCanceledException)
{
@@ -170,9 +167,7 @@
private void ExtendNotification(NotificationWrapper notification)
{
notification.StartTime = DateTime.UtcNow;
notification.ExpirationTime = DateTime.UtcNow + NotificationExtension;
notification.LifetimeGeneration++;
notification.ExpirationTime = DateTime.Now + NotificationExtension;
}
private async Task OpenNofitication(NotificationWrapper notification)
@@ -99,25 +99,21 @@
/* Notification level styling */
.notification-item.level-information {
--notification-accent: var(--accent-fill-rest);
border-left: 4px solid var(--notification-accent);
border-left: 4px solid var(--accent-fill-rest);
}
.notification-item.level-warning {
--notification-accent: #ff9500;
border-left: 4px solid var(--notification-accent);
border-left: 4px solid #ff9500;
}
.notification-item.level-error,
.notification-item.level-critical {
--notification-accent: #d13438;
border-left: 4px solid var(--notification-accent);
border-left: 4px solid #d13438;
}
.notification-item.level-debug,
.notification-item.level-trace {
--notification-accent: var(--neutral-stroke-accessible);
border-left: 4px solid var(--notification-accent);
border-left: 4px solid var(--neutral-stroke-accessible);
}
.notification-item[class*="level-"]:hover {
@@ -125,32 +121,6 @@
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%);
@@ -209,9 +179,4 @@
animation: none;
opacity: 0;
}
.notification-progress {
animation: none;
transform: scaleX(1);
}
}
@@ -6,7 +6,6 @@ using System.Core.Extensions;
using TrailBlazr.ViewModels;
namespace Daybreak.Views;
public sealed class EventCalendarViewModel(IEventService eventService)
: ViewModelBase<EventCalendarViewModel, EventCalendarView>
{
@@ -93,7 +92,7 @@ public sealed class EventCalendarViewModel(IEventService eventService)
{
dates.Add(new DateTime(today.Year, today.Month, day));
}
return dates;
}
@@ -108,7 +107,6 @@ public sealed class EventCalendarViewModel(IEventService eventService)
events.Add(eventItem);
}
}
return events;
}
}
+58 -69
View File
@@ -8,76 +8,65 @@
<div class="option-view-title">
<h3>Executables</h3>
</div>
@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>
</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>
}
<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>
</div>
</div>
}
</div>
</div>
</div>
@@ -37,7 +37,6 @@ public class ExecutablesViewModel(
{
this.Executables.Remove(executable);
this.guildWarsExecutableManager.RemoveExecutable(executable.Path);
this.RefreshView();
}
public async Task CreateExecutable()
+42 -210
View File
@@ -1,82 +1,50 @@
@using Daybreak.Shared.Models.FocusView
@using Daybreak.Views.Components.FocusView
@using Microsoft.JSInterop
@using Daybreak.Views.Components.FocusView
@page "/focus"
@inherits ViewBase<FocusView, FocusViewModel>
<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 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>
<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 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>
</div>
</div>
@@ -86,140 +54,4 @@
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