mirror of
https://github.com/gwdevhub/Daybreak.git
synced 2026-09-16 21:35:20 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
310dbd59b8 | ||
|
|
eae44fb576 | ||
|
|
8d847159f3 | ||
|
|
82514d1707 | ||
|
|
84ee5a9faf | ||
|
|
2634c32b22 | ||
|
|
538e56d0a9 | ||
|
|
59b1acf9df | ||
|
|
9c3f644869 | ||
|
|
0240acb37a | ||
|
|
02c24e6e86 | ||
|
|
53da02d440 | ||
|
|
06c27da672 | ||
|
|
54bf57e6c1 | ||
|
|
733d6fc9f6 | ||
|
|
46943e940b | ||
|
|
13985d703f | ||
|
|
e01aa28e01 | ||
|
|
f5eb67fd1c | ||
|
|
d380949b45 | ||
|
|
b23811604d | ||
|
|
7a665491f1 | ||
|
|
a966906d6e | ||
|
|
5b554707bf | ||
|
|
aeb2c9cc99 | ||
|
|
c1aaf2d1e7 | ||
|
|
98fa8ffa3f | ||
|
|
19c7b6a852 | ||
|
|
0905bf05cb | ||
|
|
132d032546 | ||
|
|
78646fd1b5 | ||
|
|
d8f92b4b91 |
@@ -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.
|
||||
@@ -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."
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-dotnettools.csharp"
|
||||
],
|
||||
"unwantedRecommendations": [
|
||||
"ms-dotnettools.csdevkit"
|
||||
]
|
||||
}
|
||||
Vendored
+6
-30
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"dotnet.defaultSolution": "Daybreak.slnx",
|
||||
"omnisharp.projectLoadTimeout": 120,
|
||||
"dotnet.preferCSharpExtension": true,
|
||||
"csharp.semanticHighlighting.enabled": true,
|
||||
"search.followSymlinks": false,
|
||||
"search.exclude": {
|
||||
|
||||
Vendored
+36
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 <= 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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Daybreak.API.Models;
|
||||
|
||||
public sealed record AddressHealth(string Name, string Address, bool Hooked);
|
||||
@@ -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,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))]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <value></c>,
|
||||
/// <c>-password <value></c>) that appear in rendered messages and property values.</item>
|
||||
/// <item>Property-name matching, which masks any structured property named after a credential
|
||||
/// field (Username, Password, Email) regardless of its value.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static partial class LogEventRedactor
|
||||
{
|
||||
public const string RedactedValue = "[REDACTED]";
|
||||
|
||||
// Structured property names whose value must always be masked.
|
||||
private static readonly HashSet<string> SensitivePropertyNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Username",
|
||||
"Password",
|
||||
"Email",
|
||||
};
|
||||
|
||||
// Cheap, vectorized pre-filter: the (comparatively expensive) look-behind regex patterns are
|
||||
// skipped unless one of these credential flags is present in the text. SearchValues performs a
|
||||
// single multi-pattern scan (Teddy/Aho-Corasick internally) that outperforms both individual
|
||||
// Contains calls and a hand-rolled automaton, and scales as the marker set grows. Every secret
|
||||
// pattern below must be gated by one of these markers.
|
||||
private static readonly SearchValues<string> SecretMarkers = SearchValues.Create(
|
||||
["-email", "-password"],
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Patterns whose single match is the secret value to mask. The credential flag itself is kept
|
||||
// in a look-behind so only the value is replaced.
|
||||
private static readonly Regex[] SecretPatterns =
|
||||
[
|
||||
EmailArgumentRegex(),
|
||||
PasswordArgumentRegex(),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Returns a copy of <paramref name="logEvent"/> with sensitive information masked. The original
|
||||
/// event is returned unchanged when nothing needs to be redacted.
|
||||
/// </summary>
|
||||
public static LogEvent Redact(LogEvent logEvent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logEvent);
|
||||
|
||||
var redactedTemplate = RedactTemplate(logEvent.MessageTemplate);
|
||||
var redactedProperties = logEvent.Properties
|
||||
.Select(p => new LogEventProperty(p.Key, RedactPropertyValue(p.Key, p.Value)))
|
||||
.ToList();
|
||||
|
||||
return new LogEvent(
|
||||
logEvent.Timestamp,
|
||||
logEvent.Level,
|
||||
logEvent.Exception,
|
||||
redactedTemplate,
|
||||
redactedProperties);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Masks the credential values embedded in an arbitrary string using the configured regex patterns.
|
||||
/// </summary>
|
||||
public static string RedactText(string? text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return text ?? string.Empty;
|
||||
}
|
||||
|
||||
// Fast path: avoid running the look-behind regexes over the overwhelming majority of log
|
||||
// text that cannot contain a credential flag. This keeps redaction global (fail-safe) while
|
||||
// costing only a vectorized substring scan for events that carry no secrets.
|
||||
if (!MightContainSecret(text))
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
foreach (var pattern in SecretPatterns)
|
||||
{
|
||||
text = pattern.Replace(text, RedactedValue);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
private static bool MightContainSecret(string text)
|
||||
{
|
||||
return text.AsSpan().ContainsAny(SecretMarkers);
|
||||
}
|
||||
|
||||
private static MessageTemplate RedactTemplate(MessageTemplate template)
|
||||
{
|
||||
var tokens = template.Tokens
|
||||
.Select(token => token is TextToken textToken
|
||||
? new TextToken(RedactText(textToken.Text))
|
||||
: token)
|
||||
.ToList();
|
||||
|
||||
return new MessageTemplate(tokens);
|
||||
}
|
||||
|
||||
private static LogEventPropertyValue RedactPropertyValue(string propertyName, LogEventPropertyValue value)
|
||||
{
|
||||
if (SensitivePropertyNames.Contains(propertyName))
|
||||
{
|
||||
return new ScalarValue(RedactedValue);
|
||||
}
|
||||
|
||||
return RedactPropertyValue(value);
|
||||
}
|
||||
|
||||
private static LogEventPropertyValue RedactPropertyValue(LogEventPropertyValue value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case ScalarValue { Value: string stringValue }:
|
||||
return new ScalarValue(RedactText(stringValue));
|
||||
|
||||
case SequenceValue sequence:
|
||||
return new SequenceValue(sequence.Elements.Select(RedactPropertyValue));
|
||||
|
||||
case StructureValue structure:
|
||||
var structureProperties = structure.Properties
|
||||
.Select(p => new LogEventProperty(p.Name, RedactPropertyValue(p.Name, p.Value)));
|
||||
return new StructureValue(structureProperties, structure.TypeTag);
|
||||
|
||||
case DictionaryValue dictionary:
|
||||
var elements = dictionary.Elements
|
||||
.Select(kvp => new KeyValuePair<ScalarValue, LogEventPropertyValue>(
|
||||
kvp.Key,
|
||||
RedactPropertyValue(kvp.Value)));
|
||||
return new DictionaryValue(elements);
|
||||
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"(?<=-email[=\s])(""[^""]*""|'[^']*'|\S+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex EmailArgumentRegex();
|
||||
|
||||
[GeneratedRegex(@"(?<=-password[=\s])(""[^""]*""|'[^']*'|\S+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex PasswordArgumentRegex();
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user