diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml
index 82a8962b..7009eafa 100644
--- a/.github/workflows/cd.yaml
+++ b/.github/workflows/cd.yaml
@@ -82,21 +82,6 @@ jobs:
env:
RuntimeIdentifier: win-${{ matrix.targetplatform }}
- - name: Create publish installer files
- run: dotnet publish .\Daybreak.Installer\Daybreak.Installer.csproj -c $env:Configuration -r $env:RuntimeIdentifier --property:SolutionDir=$env:GITHUB_WORKSPACE -p:PublishReadyToRun=true -p:PublishSingleFile=true --self-contained true -o .\Publish
- env:
- RuntimeIdentifier: win-${{ matrix.targetplatform }}
-
- - name: Create publish extractor files
- run: dotnet publish .\Daybreak.7ZipExtractor\Daybreak.7ZipExtractor.csproj -c $env:Configuration -r $env:RuntimeIdentifier --property:SolutionDir=$env:GITHUB_WORKSPACE -p:PublishReadyToRun=true -p:PublishSingleFile=false --self-contained true -o .\Publish
- env:
- RuntimeIdentifier: win-${{ matrix.targetplatform }}
-
- - name: Create publish API files
- run: dotnet publish .\Daybreak.API\Daybreak.API.csproj -c $env:Configuration -r $env:RuntimeIdentifier --property:SolutionDir=$env:GITHUB_WORKSPACE -p:PublishSingleFile=false --self-contained true -o .\Publish
- env:
- RuntimeIdentifier: win-${{ matrix.targetplatform }}
-
- name: Pack publish files
run: |
Write-Host $env
diff --git a/Daybreak.7ZipExtractor/Daybreak.7ZipExtractor.csproj b/Daybreak.7ZipExtractor/Daybreak.7ZipExtractor.csproj
index 79bb0af1..469c5cce 100644
--- a/Daybreak.7ZipExtractor/Daybreak.7ZipExtractor.csproj
+++ b/Daybreak.7ZipExtractor/Daybreak.7ZipExtractor.csproj
@@ -6,7 +6,7 @@
Daybreak._7ZipExtractor
enable
enable
- AnyCPU;x64
+ x64
diff --git a/Daybreak.API/Configuration/BuildInfo.cs b/Daybreak.API/Configuration/BuildInfo.cs
new file mode 100644
index 00000000..71792fde
--- /dev/null
+++ b/Daybreak.API/Configuration/BuildInfo.cs
@@ -0,0 +1,11 @@
+namespace Daybreak.API.Configuration;
+
+public static class BuildInfo
+{
+ public const string Configuration =
+#if DEBUG
+ "Debug";
+#else
+ "Release";
+#endif
+}
diff --git a/Daybreak.API/Configuration/WebApplicationBuilderExtensions.cs b/Daybreak.API/Configuration/WebApplicationBuilderExtensions.cs
new file mode 100644
index 00000000..eab55d74
--- /dev/null
+++ b/Daybreak.API/Configuration/WebApplicationBuilderExtensions.cs
@@ -0,0 +1,41 @@
+namespace Daybreak.API.Configuration;
+
+public static class WebApplicationBuilderExtensions
+{
+ public static WebApplicationBuilder WithConfiguration(this WebApplicationBuilder builder)
+ {
+ var config = BuildInfo.Configuration;
+ var appSettingsStream = GetManifestResourceStream($"Daybreak.API.Configuration.appsettings.json");
+ if (appSettingsStream is null)
+ {
+ throw new InvalidOperationException("Failed to load appsettings.json");
+ }
+
+ var appSettingsConfigStream = GetManifestResourceStream($"Daybreak.API.Configuration.appsettings.{config}.json");
+ if (appSettingsConfigStream is null)
+ {
+ throw new InvalidOperationException($"Failed to load appsettings.{config}.json");
+ }
+
+ builder.Configuration
+ .AddJsonStream(appSettingsStream)
+ .AddJsonStream(appSettingsConfigStream)
+ .AddEnvironmentVariables();
+
+ builder.WebHost.UseSetting(WebHostDefaults.PreventHostingStartupKey, "true");
+ return builder;
+ }
+
+ private static Stream? GetManifestResourceStream(string name)
+ {
+ var assembly = typeof(WebApplicationBuilderExtensions).Assembly;
+ var resourceName = assembly.GetManifestResourceNames()
+ .FirstOrDefault(n => n.EndsWith(name, StringComparison.OrdinalIgnoreCase));
+ if (resourceName is null)
+ {
+ return default;
+ }
+
+ return assembly.GetManifestResourceStream(resourceName);
+ }
+}
diff --git a/Daybreak.API/Configuration/appsettings.Debug.json b/Daybreak.API/Configuration/appsettings.Debug.json
new file mode 100644
index 00000000..8593c62d
--- /dev/null
+++ b/Daybreak.API/Configuration/appsettings.Debug.json
@@ -0,0 +1,2 @@
+{
+}
\ No newline at end of file
diff --git a/Daybreak.API/Configuration/appsettings.Release.Debug.json b/Daybreak.API/Configuration/appsettings.Release.Debug.json
new file mode 100644
index 00000000..5dc13ec4
--- /dev/null
+++ b/Daybreak.API/Configuration/appsettings.Release.Debug.json
@@ -0,0 +1,10 @@
+{
+ "Logging": {
+ "MinimumLevel": {
+ "Default": "Debug",
+ "Override": {
+ "Microsoft": "Warning"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Daybreak.API/Configuration/appsettings.Release.json b/Daybreak.API/Configuration/appsettings.Release.json
new file mode 100644
index 00000000..8593c62d
--- /dev/null
+++ b/Daybreak.API/Configuration/appsettings.Release.json
@@ -0,0 +1,2 @@
+{
+}
\ No newline at end of file
diff --git a/Daybreak.API/Configuration/appsettings.json b/Daybreak.API/Configuration/appsettings.json
new file mode 100644
index 00000000..4bcb7047
--- /dev/null
+++ b/Daybreak.API/Configuration/appsettings.json
@@ -0,0 +1,13 @@
+{
+ "Logging": {
+ "MinimumLevel": {
+ "Default": "Debug",
+ "Override": {
+ "Microsoft": "Debug"
+ }
+ }
+ },
+ "Correlation": {
+ "Header": "X-Correlation-Vector"
+ }
+}
\ No newline at end of file
diff --git a/Daybreak.API/Controllers/Rest/CharacterSelectController.cs b/Daybreak.API/Controllers/Rest/CharacterSelectController.cs
new file mode 100644
index 00000000..a6b800a9
--- /dev/null
+++ b/Daybreak.API/Controllers/Rest/CharacterSelectController.cs
@@ -0,0 +1,51 @@
+using Daybreak.API.Interop.GuildWars;
+using Daybreak.API.Services;
+using Daybreak.Shared.Models.Api;
+using Microsoft.AspNetCore.Mvc;
+using Net.Sdk.Web;
+using System.Core.Extensions;
+
+namespace Daybreak.API.Controllers.Rest;
+
+[GenerateController("api/v1/rest/character-select")]
+[Tags("Character Select")]
+public sealed class CharacterSelectController(
+ CharacterSelectService characterSelectService)
+{
+ private readonly CharacterSelectService characterSelectService = characterSelectService.ThrowIfNull();
+
+ [GenerateGet]
+ [EndpointName("GetCharacterSelectInformation")]
+ [EndpointSummary("Get Character Select Information")]
+ [EndpointDescription("Get the character select information for the current player. Returns a serialized CharacterSelectInformation object")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+ public async Task GetCharacterSelectInformation(
+ CancellationToken cancellationToken)
+ {
+ var characterSelectInfo = await this.characterSelectService.GetCharacterSelectInformation(cancellationToken);
+ return characterSelectInfo is null ?
+ Results.StatusCode(StatusCodes.Status503ServiceUnavailable) :
+ Results.Ok(characterSelectInfo);
+ }
+
+ [GeneratePost("{identifier}")]
+ [EndpointName("ChangeCharacter")]
+ [EndpointSummary("Change Character by identifier")]
+ [EndpointDescription("Logs out from the current character and tries to log in with the desired character. Identifier can be either uuid or character name")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+ public async Task ChangeCharacter(
+ [FromRoute] string identifier,
+ CancellationToken cancellationToken)
+ {
+ var isUuid = Uuid.TryParse(identifier, out _);
+ var result = isUuid
+ ? await this.characterSelectService.ChangeCharacterByUuid(identifier, cancellationToken)
+ : await this.characterSelectService.ChangeCharacterByName(identifier, cancellationToken);
+ return result ?
+ Results.Ok() :
+ Results.StatusCode(StatusCodes.Status500InternalServerError);
+ }
+}
diff --git a/Daybreak.API/Controllers/Rest/MainPlayerController.cs b/Daybreak.API/Controllers/Rest/MainPlayerController.cs
new file mode 100644
index 00000000..1e72d81c
--- /dev/null
+++ b/Daybreak.API/Controllers/Rest/MainPlayerController.cs
@@ -0,0 +1,102 @@
+using Daybreak.API.Services;
+using Daybreak.Shared.Models.Api;
+using Microsoft.AspNetCore.Mvc;
+using Net.Sdk.Web;
+using System.Core.Extensions;
+
+namespace Daybreak.API.Controllers.Rest;
+
+[GenerateController("api/v1/rest/main-player")]
+[Tags("Main Player")]
+public sealed class MainPlayerController(
+ MainPlayerService mainPlayerService,
+ ILogger logger)
+{
+ private readonly MainPlayerService mainPlayerService = mainPlayerService.ThrowIfNull();
+ private readonly ILogger logger = logger.ThrowIfNull();
+
+ [GenerateGet("state")]
+ [EndpointName("GetMainPlayerState")]
+ [EndpointSummary("Get the current MainPlayerState")]
+ [EndpointDescription("Get the current MainPlayerState. Returns a json serialized MainPlayerState object. You can use the websocket endpoint to subscribe to MainPlayerState updates on each game thread proc")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+ public async Task GetMainPlayerState(CancellationToken cancellationToken)
+ {
+ var mainPlayerState = await this.mainPlayerService.GetMainPlayerState(cancellationToken);
+ return mainPlayerState is not null ? Results.Ok(mainPlayerState) : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
+ }
+
+ [GenerateGet("quest-log")]
+ [EndpointName("GetQuestLog")]
+ [EndpointSummary("Get the current Quest Log")]
+ [EndpointDescription("Get the current Quest Log. Returns a json serialized QuestLogInformation object")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+ public async Task GetQuestLog(CancellationToken cancellationToken)
+ {
+ var questLog = await this.mainPlayerService.GetQuestLog(cancellationToken);
+ return questLog is not null ? Results.Ok(questLog) : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
+ }
+
+ [GenerateGet("info")]
+ [EndpointName("GetMainPlayerInformation")]
+ [EndpointSummary("Get the current MainPlayerInformation")]
+ [EndpointDescription("Get the current MainPlayerInformation. Returns a json serialized MainPlayerInformation object")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+ public async Task GetMainPlayerInformation(CancellationToken cancellationToken)
+ {
+ var mainPlayerInformation = await this.mainPlayerService.GetMainPlayerInformation(cancellationToken);
+ return mainPlayerInformation is not null ? Results.Ok(mainPlayerInformation) : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
+ }
+
+ [GenerateGet("build")]
+ [EndpointName("GetMainPlayerBuild")]
+ [EndpointSummary("Get the current build")]
+ [EndpointDescription("Get the current build. Returns a json serialized BuildEntry object")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+ public async Task GetMainPlayerBuild(CancellationToken cancellationToken)
+ {
+ var build = await this.mainPlayerService.GetCurrentBuild(cancellationToken);
+ return build is not null ? Results.Ok(build) : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
+ }
+
+ [GeneratePost("build")]
+ [EndpointName("SetMainPlayerBuild")]
+ [EndpointSummary("Set the current build")]
+ [EndpointDescription("Set the current build. Returns 200 is the operation has succeeded")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+ public async Task SetMainPlayerBuild([FromQuery(Name = "code")] string? code, CancellationToken cancellationToken)
+ {
+ var result = await this.mainPlayerService.SetCurrentBuild(code ?? string.Empty, cancellationToken);
+ return result ? Results.Ok() : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
+ }
+
+ [GenerateGet("instance-info")]
+ [EndpointName("GetMainPlayerInstanceInfo")]
+ [EndpointSummary("Get the current instance info")]
+ [EndpointDescription("Get the current instance info. Returns a json serialized MainPlayerInstanceInfo object")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+ public async Task GetMainPlayerInstanceInfo(CancellationToken cancellationToken)
+ {
+ var instanceInfo = await this.mainPlayerService.GetMainPlayerInstance(cancellationToken);
+ return instanceInfo is not null ? Results.Ok(instanceInfo) : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
+ }
+
+ [GenerateGet("title")]
+ [EndpointName("GetMainPlayerTitleInfo")]
+ [EndpointSummary("Get the current title info")]
+ [EndpointDescription("Get the current title info. Returns a json serialized TitleInfo object")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+ public async Task GetMainPlayerTitleInfo(CancellationToken cancellationToken)
+ {
+ var titleInfo = await this.mainPlayerService.GetTitleInfo(cancellationToken);
+ return titleInfo is not null ? Results.Ok(titleInfo) : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
+ }
+}
diff --git a/Daybreak.API/Controllers/Rest/PartyController.cs b/Daybreak.API/Controllers/Rest/PartyController.cs
new file mode 100644
index 00000000..312dd1ab
--- /dev/null
+++ b/Daybreak.API/Controllers/Rest/PartyController.cs
@@ -0,0 +1,38 @@
+using Daybreak.API.Services;
+using Daybreak.Shared.Models.Api;
+using Microsoft.AspNetCore.Mvc;
+using Net.Sdk.Web;
+
+namespace Daybreak.API.Controllers.Rest;
+
+[GenerateController("api/v1/rest/party")]
+[Tags("Party")]
+public sealed class PartyController(PartyService partyService)
+{
+ private readonly PartyService 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(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+ public async Task GetPartyLoadout(
+ CancellationToken cancellationToken)
+ {
+ var partyLoadout = await this.partyService.GetPartyLoadout(cancellationToken);
+ return partyLoadout is not null ? Results.Ok(partyLoadout) : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
+ }
+
+ [GeneratePost("loadout")]
+ [EndpointName("SetPartyLoadout")]
+ [EndpointSummary("Set the current party loadout")]
+ [EndpointDescription("Set the current party loadout")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+ public async Task SetPartyLoadout([FromBody] PartyLoadout partyLoadout, CancellationToken cancellationToken)
+ {
+ var result = await this.partyService.SetPartyLoadout(partyLoadout, cancellationToken);
+ return result ? Results.Ok() : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
+ }
+}
diff --git a/Daybreak.API/Controllers/TestController.cs b/Daybreak.API/Controllers/TestController.cs
deleted file mode 100644
index 6fcd835b..00000000
--- a/Daybreak.API/Controllers/TestController.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-using Net.Sdk.Web;
-
-namespace Daybreak.API.Controllers;
-
-[GenerateController("api/test")]
-public sealed class TestController
-{
- [GenerateGet("testing")]
- public IResult GetTest(CancellationToken token)
- {
- return Results.Text("Hello from injected ASP-NET Core!", "text/plain");
- }
-}
diff --git a/Daybreak.API/Controllers/WebSocket/MainPlayer/MainPlayerStateRoute.cs b/Daybreak.API/Controllers/WebSocket/MainPlayer/MainPlayerStateRoute.cs
new file mode 100644
index 00000000..ad6ddc00
--- /dev/null
+++ b/Daybreak.API/Controllers/WebSocket/MainPlayer/MainPlayerStateRoute.cs
@@ -0,0 +1,67 @@
+using Daybreak.API.Models;
+using Daybreak.API.Services;
+using Daybreak.API.WebSockets;
+using Daybreak.Shared.Models.Api;
+using System.Buffers;
+using System.Core.Extensions;
+using System.Extensions.Core;
+using System.Net.WebSockets;
+
+namespace Daybreak.API.Controllers.WebSocket.MainPlayer;
+
+public sealed class MainPlayerStateRoute(
+ MainPlayerService mainPlayerStateService,
+ ChatService chatService,
+ ILogger logger)
+ : UpdateWebSocketRoute
+{
+ private const int DefaultFrequency = 1000;
+
+ private readonly MainPlayerService mainPlayerStateService = mainPlayerStateService.ThrowIfNull();
+ private readonly ChatService chatService = chatService.ThrowIfNull();
+ private readonly ILogger logger = logger.ThrowIfNull();
+
+ private CallbackRegistration? mainPlayerStateRegistration;
+
+ protected override int BufferSize => 256;
+
+ public override Task ExecuteAsync(WebSocketMessageType type, ReadOnlySequence data, CancellationToken cancellationToken)
+ {
+ return Task.CompletedTask;
+ }
+
+ public override async Task SocketAccepted(CancellationToken cancellationToken)
+ {
+ var scopedLogger = this.logger.CreateScopedLogger();
+ if (!int.TryParse(this.Context?.Request.Query["f"].FirstOrDefault(), out var freq))
+ {
+ freq = DefaultFrequency;
+ }
+
+ var freqTimeSpan = TimeSpan.FromMilliseconds(freq);
+ scopedLogger.LogInformation("WebSocket accepted {id} with frequency {frequency}", this.Context?.Connection.Id ?? string.Empty, freqTimeSpan);
+
+ this.mainPlayerStateRegistration = this.mainPlayerStateService.RegisterMainStateConsumer(freqTimeSpan, this.SendUpdate);
+
+ await this.chatService.AddMessageAsync(
+ message: $"{nameof(MainPlayerState)} subscriber added: {this.Context?.Connection.RemoteIpAddress?.ToString()}:{this.Context?.Connection.RemotePort} with frequency {freq}ms",
+ sender: "Daybreak.API",
+ channel: Channel.Whisper,
+ cancellationToken: cancellationToken);
+ }
+
+ public override async Task SocketClosed()
+ {
+ var scopedLogger = this.logger.CreateScopedLogger();
+
+ this.mainPlayerStateRegistration?.Dispose();
+ this.mainPlayerStateRegistration = default;
+
+ scopedLogger.LogInformation("WebSocket closed {id}", this.Context?.Connection.Id ?? string.Empty);
+ await this.chatService.AddMessageAsync(
+ message: $"{nameof(MainPlayerState)} subscriber removed: {this.Context?.Connection.RemoteIpAddress?.ToString()}:{this.Context?.Connection.RemotePort}",
+ sender: "Daybreak.API",
+ channel: Channel.Whisper,
+ cancellationToken: CancellationToken.None);
+ }
+}
diff --git a/Daybreak.API/Converters/PointerValueConverter.cs b/Daybreak.API/Converters/PointerValueConverter.cs
new file mode 100644
index 00000000..65f4bd1a
--- /dev/null
+++ b/Daybreak.API/Converters/PointerValueConverter.cs
@@ -0,0 +1,19 @@
+using Daybreak.API.Interop;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Daybreak.API.Converters;
+
+public sealed class PointerValueConverter : JsonConverter
+{
+ public override PointerValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ var s = reader.GetString() ?? throw new JsonException("Expected string");
+ return PointerValue.Parse(s);
+ }
+
+ public override void Write(Utf8JsonWriter writer, PointerValue value, JsonSerializerOptions options)
+ {
+ writer.WriteStringValue(value.ToString());
+ }
+}
diff --git a/Daybreak.API/Daybreak.API.csproj b/Daybreak.API/Daybreak.API.csproj
index 76f899ad..0b09f2d4 100644
--- a/Daybreak.API/Daybreak.API.csproj
+++ b/Daybreak.API/Daybreak.API.csproj
@@ -9,33 +9,49 @@
true
true
true
+ true
true
true
-
-
+
OutOfProcess
false
false
true
+ x86
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
- $(MSBuildProjectDirectory)\..\Daybreak\bin\x86\$(Configuration)\$(TargetFramework)\
- true
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Daybreak.API/EntryPoint.cs b/Daybreak.API/EntryPoint.cs
index daa25d31..0482ef8b 100644
--- a/Daybreak.API/EntryPoint.cs
+++ b/Daybreak.API/EntryPoint.cs
@@ -1,47 +1,99 @@
-using System.Net.NetworkInformation;
+using System.Diagnostics;
+using System.Extensions.Core;
+using System.Net.NetworkInformation;
using System.Runtime.InteropServices;
+using Daybreak.API.Configuration;
using Daybreak.API.Extensions;
+using Daybreak.API.Health;
+using Daybreak.API.Hosting;
+using Daybreak.API.Logging;
using Daybreak.API.Serialization;
+using Daybreak.API.Services;
+using Daybreak.API.Swagger;
+using Daybreak.API.WebSockets;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
using Net.Sdk.Web;
namespace Daybreak.API;
-public static class EntryPoint
+public class EntryPoint
{
private const int StartPort = 5080;
+ private static readonly TimeSpan InitializationTimeout = TimeSpan.FromSeconds(5);
+ private static readonly CancellationTokenSource CancellationTokenSource = new();
[UnmanagedCallersOnly(EntryPoint = "ThreadInit"), STAThread]
public static int ThreadInit(IntPtr _, int __)
{
+ Environment.SetEnvironmentVariable("ASPNETCORE_HOSTINGSTARTUPASSEMBLIES", null, EnvironmentVariableTarget.Process);
+ Environment.SetEnvironmentVariable("ASPNETCORE_PREVENTHOSTINGSTARTUP", "true", EnvironmentVariableTarget.Process);
+#if DEBUG
ConsoleExtensions.AllocateAnsiConsole();
+#endif
var port = FindAvailablePort(StartPort);
- if (port <= 0)
+ var app = CreateApplication(port);
+ var runTask = Task.Run(() => StartServer(app), CancellationTokenSource.Token);
+ var scopedLogger = app.Services.GetRequiredService>().CreateScopedLogger();
+ var healthCheck = app.Services.GetRequiredService();
+ var sw = Stopwatch.StartNew();
+ var healthy = false;
+ while(sw.Elapsed < InitializationTimeout)
{
- Console.WriteLine($"No available port found starting from {StartPort}");
- return -1;
+ 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}")));
+ if (status.Status is HealthStatus.Healthy)
+ {
+ healthy = true;
+ break;
+ }
+ else
+ {
+ Thread.Sleep(100);
+ }
}
- Console.WriteLine($"Starting Daybreak API on port {port}");
- Task.Run(() => StartServer(port));
- return port;
+ if (healthy)
+ {
+ scopedLogger.LogInformation("Daybreak API is healthy on port {port}. Initialization succeeded in {duration}", port, sw.Elapsed);
+ return port;
+ }
+ else
+ {
+ scopedLogger.LogError("Daybreak API failed to initialize in {duration}", sw.Elapsed);
+ CancellationTokenSource.Cancel();
+ return -1;
+ }
}
- private static async Task StartServer(int port)
+ private static WebApplication CreateApplication(int port)
+ {
+ var app = WebApplication.CreateBuilder()
+ .WithConfiguration()
+ .WithHosting(port)
+ .WithSwagger()
+ .WithSerializationContext()
+ .WithLogging()
+ .WithDaybreakServices()
+ .WithWebSocketRoutes()
+ .WithRoutes()
+ .WithHealthChecks()
+ .Build();
+
+ app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30) });
+
+ return app
+ .UseHealthChecks()
+ .UseLogging()
+ .UseRoutes()
+ .UseWebSocketRoutes()
+ .UseSwaggerWithUI();
+
+ }
+
+ private static async Task StartServer(WebApplication app)
{
try
{
- var builder = WebApplication.CreateBuilder();
- builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
- builder.Services.ConfigureHttpJsonOptions(options =>
- {
- options.SerializerOptions.TypeInfoResolverChain.Insert(0, new ApiJsonSerializerContext());
- });
- builder.Logging.AddConsole();
- builder.WithRoutes();
-
- var app = builder.Build();
- app.UseRoutes();
-
await app.RunAsync();
}
catch (Exception ex)
diff --git a/Daybreak.API/Extensions/AttributeContextExtensions.cs b/Daybreak.API/Extensions/AttributeContextExtensions.cs
new file mode 100644
index 00000000..8956d131
--- /dev/null
+++ b/Daybreak.API/Extensions/AttributeContextExtensions.cs
@@ -0,0 +1,17 @@
+using Daybreak.API.Interop.GuildWars;
+using Daybreak.Shared.Models.Api;
+using ZLinq;
+
+namespace Daybreak.API.Extensions;
+
+public static class AttributeContextExtensions
+{
+ public static List GetAttributeEntryList(this AttributeContext[] attributes)
+ {
+ return attributes
+ .AsValueEnumerable()
+ .Take(45) // There are only 45 maximum attributes in game
+ .Where(a => a.LevelBase > 0 && a.Level >= a.LevelBase) // Select only attributes with points in them
+ .Select(a => new AttributeEntry(a.Id, a.LevelBase, a.Level)).ToList();
+ }
+}
diff --git a/Daybreak.API/Extensions/GameContextExtensions.cs b/Daybreak.API/Extensions/GameContextExtensions.cs
new file mode 100644
index 00000000..70c34cd0
--- /dev/null
+++ b/Daybreak.API/Extensions/GameContextExtensions.cs
@@ -0,0 +1,101 @@
+using Daybreak.API.Interop.GuildWars;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Daybreak.API.Extensions;
+
+public static unsafe class GameContextExtensions
+{
+ public static bool TryGetPlayerId(
+ this WrappedPointer gameContext,
+ [NotNullWhen(true)] out uint? playerId)
+ {
+ playerId = 0;
+ if (gameContext.IsNull ||
+ gameContext.Pointer->WorldContext is null ||
+ gameContext.Pointer->WorldContext->PlayerControlledChar is null)
+ {
+ return false;
+ }
+
+ playerId = gameContext.Pointer->WorldContext->PlayerControlledChar->AgentId;
+ return true;
+ }
+
+ public static bool TryGetBuildContext(
+ this WrappedPointer gameContext,
+ [NotNullWhen(true)] out GuildWarsArray? skillbars,
+ [NotNullWhen(true)] out GuildWarsArray? attributes,
+ [NotNullWhen(true)] out GuildWarsArray? professions,
+ [NotNullWhen(true)] out GuildWarsArray? unlockedSkills)
+ {
+ skillbars = default;
+ attributes = default;
+ professions = default;
+ unlockedSkills = default;
+ if (gameContext.IsNull ||
+ gameContext.Pointer->WorldContext is null)
+ {
+ return false;
+ }
+
+ skillbars = gameContext.Pointer->WorldContext->Skillbars;
+ attributes = gameContext.Pointer->WorldContext->Attributes;
+ professions = gameContext.Pointer->WorldContext->Professions;
+ unlockedSkills = gameContext.Pointer->WorldContext->UnlockedCharacterSkills;
+ return true;
+ }
+
+ public static bool TryGetPlayerParty(
+ this WrappedPointer gameContext,
+ [NotNullWhen(true)] out uint? partyId,
+ [NotNullWhen(true)] out GuildWarsArray? players,
+ [NotNullWhen(true)] out GuildWarsArray? heroes,
+ [NotNullWhen(true)] out GuildWarsArray? henchmen)
+ {
+ partyId = default;
+ players = default;
+ heroes = default;
+ henchmen = default;
+ if (gameContext.IsNull ||
+ gameContext.Pointer->PartyContext is null)
+ {
+ return false;
+ }
+
+ partyId = gameContext.Pointer->PartyContext->PlayerParty->PartyId;
+ players = gameContext.Pointer->PartyContext->PlayerParty->Players;
+ heroes = gameContext.Pointer->PartyContext->PlayerParty->Heroes;
+ henchmen = gameContext.Pointer->PartyContext->PlayerParty->Henchmen;
+ return true;
+ }
+
+ public static bool TryGetHeroFlags(
+ this WrappedPointer gameContext,
+ [NotNullWhen(true)]out GuildWarsArray? heroFlags)
+ {
+ heroFlags = default;
+ if (gameContext.IsNull ||
+ gameContext.Pointer->WorldContext is null)
+ {
+ return false;
+ }
+
+ heroFlags = gameContext.Pointer->WorldContext->HeroFlags;
+ return true;
+ }
+
+ public static bool TryGetAccountContext(
+ this WrappedPointer gameContext,
+ out WrappedPointer accountContext)
+ {
+ accountContext = default;
+ if (gameContext.IsNull ||
+ gameContext.Pointer->AccountContext is null)
+ {
+ return false;
+ }
+
+ accountContext = gameContext.Pointer->AccountContext;
+ return true;
+ }
+}
diff --git a/Daybreak.API/Health/AddressHealthCheck.cs b/Daybreak.API/Health/AddressHealthCheck.cs
new file mode 100644
index 00000000..ada6a685
--- /dev/null
+++ b/Daybreak.API/Health/AddressHealthCheck.cs
@@ -0,0 +1,31 @@
+using Daybreak.API.Serialization;
+using Daybreak.API.Services.Interop;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using System.Text.Json;
+using ZLinq;
+
+namespace Daybreak.API.Health;
+
+public sealed class AddressHealthCheck(IEnumerable addressHealthServices)
+ : IHealthCheck
+{
+ private readonly IEnumerable addressHealthServices = addressHealthServices;
+
+ public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
+ {
+ var states = this.addressHealthServices.AsValueEnumerable().SelectMany(addressService => addressService.GetAddressStates());
+ var healthy = !states.Any(s => s.Address == 0x0);
+ using var statesArray = states.ToArrayPool();
+ var serialized = JsonSerializer.SerializeToElement(statesArray.ArraySegment, ApiJsonSerializerContext.Default.ArraySegmentAddressState);
+ var meta = new Dictionary
+ {
+ ["Count"] = statesArray.Size,
+ ["Invalid"] = states.Count(s => s.Address == 0x0),
+ ["States"] = serialized,
+ };
+
+ return Task.FromResult(healthy
+ ? HealthCheckResult.Healthy("Addresses are initialized", meta)
+ : HealthCheckResult.Unhealthy("Addresses are not initialized", data: meta));
+ }
+}
diff --git a/Daybreak.API/Health/HooksHealthCheck.cs b/Daybreak.API/Health/HooksHealthCheck.cs
new file mode 100644
index 00000000..f5931950
--- /dev/null
+++ b/Daybreak.API/Health/HooksHealthCheck.cs
@@ -0,0 +1,31 @@
+using Daybreak.API.Serialization;
+using Daybreak.API.Services.Interop;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using System.Text.Json;
+using ZLinq;
+
+namespace Daybreak.API.Health;
+
+public sealed class HooksHealthCheck(IEnumerable hookHealthServices)
+ : IHealthCheck
+{
+ private readonly IEnumerable hookHealthServices = hookHealthServices;
+
+ public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
+ {
+ var states = this.hookHealthServices.AsValueEnumerable().SelectMany(hookService => hookService.GetHookStates());
+ var healthy = !states.Any(s => !s.Hooked);
+ var statesArray = states.ToArrayPool();
+ var serialized = JsonSerializer.SerializeToElement(statesArray.ArraySegment, ApiJsonSerializerContext.Default.ArraySegmentHookState);
+ var meta = new Dictionary
+ {
+ ["Count"] = statesArray.Size,
+ ["Unhooked"] = states.Count(s => !s.Hooked),
+ ["States"] = serialized,
+ };
+
+ return Task.FromResult(healthy
+ ? HealthCheckResult.Healthy("Hooks are initialized", meta)
+ : HealthCheckResult.Unhealthy("Hooks are not initialized", data: meta));
+ }
+}
diff --git a/Daybreak.API/Health/WebApplicationBuilderExtensions.cs b/Daybreak.API/Health/WebApplicationBuilderExtensions.cs
new file mode 100644
index 00000000..123e6469
--- /dev/null
+++ b/Daybreak.API/Health/WebApplicationBuilderExtensions.cs
@@ -0,0 +1,12 @@
+namespace Daybreak.API.Health;
+
+public static class WebApplicationBuilderExtensions
+{
+ public static WebApplicationBuilder WithHealthChecks(this WebApplicationBuilder builder)
+ {
+ builder.Services.AddHealthChecks()
+ .AddCheck(nameof(HooksHealthCheck))
+ .AddCheck(nameof(AddressHealthCheck));
+ return builder;
+ }
+}
diff --git a/Daybreak.API/Health/WebApplicationExtensions.cs b/Daybreak.API/Health/WebApplicationExtensions.cs
new file mode 100644
index 00000000..a58e169f
--- /dev/null
+++ b/Daybreak.API/Health/WebApplicationExtensions.cs
@@ -0,0 +1,47 @@
+using Daybreak.API.Models;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using ZLinq;
+
+namespace Daybreak.API.Health;
+
+public static class WebApplicationExtensions
+{
+ [RequiresUnreferencedCode("The handler uses a static method that gets referenced, so there's no unreferenced code to worry about")]
+ [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) =>
+ {
+ var report = await healthCheckService.CheckHealthAsync(cancellationToken);
+
+ var response = new HealthCheckResponse
+ {
+ Status = report.Status,
+ TotalDuration = report.TotalDuration,
+ Entries = report.Entries.ToDictionary(
+ e => e.Key,
+ e => new HealthCheckEntryResponse
+ {
+ Status = e.Value.Status,
+ Description = e.Value.Description ?? string.Empty,
+ Tags = [.. e.Value.Tags],
+ Data = e.Value.Data
+ .Where(kvp => kvp.Value is JsonElement je)
+ .ToDictionary(kvp => kvp.Key,
+ kvp => (JsonElement)kvp.Value)
+ })
+ };
+
+ return Results.Json(response);
+ })
+ .WithName("Health")
+ .WithSummary("Service health")
+ .WithDescription($"Current health status of the service. Returns a serialized object of type {nameof(HealthCheckResponse)}")
+ .WithTags("Service")
+ .Produces(StatusCodes.Status200OK)
+ .WithOpenApi();
+ return app;
+ }
+}
diff --git a/Daybreak.API/Hosting/WebApplicationBuilderExtensions.cs b/Daybreak.API/Hosting/WebApplicationBuilderExtensions.cs
new file mode 100644
index 00000000..ae540928
--- /dev/null
+++ b/Daybreak.API/Hosting/WebApplicationBuilderExtensions.cs
@@ -0,0 +1,11 @@
+namespace Daybreak.API.Hosting;
+
+public static class WebApplicationBuilderExtensions
+{
+ public static WebApplicationBuilder WithHosting(this WebApplicationBuilder builder, int port)
+ {
+ builder.WebHost.ConfigureKestrel(o => o.ListenAnyIP(port));
+
+ return builder;
+ }
+}
diff --git a/Daybreak.API/Interop/FixedArrays.cs b/Daybreak.API/Interop/FixedArrays.cs
new file mode 100644
index 00000000..246a224b
--- /dev/null
+++ b/Daybreak.API/Interop/FixedArrays.cs
@@ -0,0 +1,23 @@
+#pragma warning disable CS0436 // Type conflicts with imported type
+using System.Extensions;
+
+[assembly: GenerateFixedArray(Size = 4)]
+[assembly: GenerateFixedArray(Size = 0x14)]
+[assembly: GenerateFixedArray(Size = 0x40)]
+[assembly: GenerateFixedArray(Size = 0x18)]
+[assembly: GenerateFixedArray(Size = 256)]
+[assembly: GenerateFixedArray(Size = 32)]
+[assembly: GenerateFixedArray(Size = 5)]
+[assembly: GenerateFixedArray(Size = 8)]
+[assembly: GenerateFixedArray(Size = 56)]
+[assembly: GenerateFixedArray(Size = 17)]
+[assembly: GenerateFixedArray(Size = 12)]
+[assembly: GenerateFixedArray(Size = 13)]
+[assembly: GenerateFixedArray(Size = 8)]
+#pragma warning restore CS0436 // Type conflicts with imported type
+
+namespace Daybreak.API.Interop;
+
+public static class FixedArrays
+{
+}
diff --git a/Daybreak.API/Interop/GWAddressCache.cs b/Daybreak.API/Interop/GWAddressCache.cs
new file mode 100644
index 00000000..a49b69fc
--- /dev/null
+++ b/Daybreak.API/Interop/GWAddressCache.cs
@@ -0,0 +1,26 @@
+using System.Core.Extensions;
+
+namespace Daybreak.API.Interop;
+
+public sealed class GWAddressCache(Func provider)
+{
+ private readonly Func 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;
+ }
+}
diff --git a/Daybreak.API/Interop/GWDelegateCache.cs b/Daybreak.API/Interop/GWDelegateCache.cs
new file mode 100644
index 00000000..219caea5
--- /dev/null
+++ b/Daybreak.API/Interop/GWDelegateCache.cs
@@ -0,0 +1,19 @@
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop;
+
+public sealed class GWDelegateCache(GWAddressCache cache)
+ where TDelegate : Delegate
+{
+ public GWAddressCache Cache { get; } = cache;
+
+ public TDelegate? GetDelegate()
+ {
+ if (this.Cache.GetAddress() is not nuint address)
+ {
+ return default;
+ }
+
+ return Marshal.GetDelegateForFunctionPointer((nint)address);
+ }
+}
diff --git a/Daybreak.API/Interop/GWFastCall.cs b/Daybreak.API/Interop/GWFastCall.cs
new file mode 100644
index 00000000..bb688ade
--- /dev/null
+++ b/Daybreak.API/Interop/GWFastCall.cs
@@ -0,0 +1,240 @@
+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();
+
+ protected unsafe static nint BuildFastCallThunk(nuint target)
+ {
+ if (target is 0)
+ {
+ return 0;
+ }
+
+ var code = stackalloc byte[13]
+ {
+ 0x58, // pop eax (ret)
+ 0x59, // pop ecx (ctx)
+ 0x5A, // pop edx (edxVal)
+ 0x5B, // pop ebx (wparam)
+ 0x53, // push ebx (wparam)
+ 0x50, // push eax (ret)
+ 0xB8,0,0,0,0, // mov eax, TARGET
+ 0xFF,0xE0 // jmp eax
+ }; // 13 bytes total
+
+ const int TARGET_OFFSET = 7;
+ Unsafe.WriteUnaligned(ref code[TARGET_OFFSET], (uint)target);
+
+ var mem = NativeMethods.VirtualAlloc(0, 13, 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, 13, 13);
+ return mem;
+ }
+}
+
+public unsafe sealed class GWFastCall(GWAddressCache target) : GWFastCall(target)
+{
+ private delegate* unmanaged[Stdcall] 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])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 = BuildFastCallThunk(addr);
+ this.func = (delegate* unmanaged[Stdcall])call;
+ }
+}
+
+public unsafe sealed class GWFastCall(GWAddressCache target) : GWFastCall(target)
+{
+ private delegate* unmanaged[Stdcall] 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])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 = BuildFastCallThunk(addr);
+ this.func = (delegate* unmanaged[Stdcall])call;
+ }
+}
+
+public unsafe sealed class GWFastCall(GWAddressCache target) : GWFastCall(target)
+{
+ private delegate* unmanaged[Stdcall] 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])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 = BuildFastCallThunk(addr);
+ this.func = (delegate* unmanaged[Stdcall])call;
+ }
+}
+
+public unsafe sealed class GWFastCall(GWAddressCache target) : GWFastCall(target)
+{
+ private delegate* unmanaged[Stdcall] 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])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 = BuildFastCallThunk(addr);
+ this.func = (delegate* unmanaged[Stdcall])call;
+ }
+}
+
+public unsafe sealed class GWFastCall(GWAddressCache target) : GWFastCall(target)
+{
+ private delegate* unmanaged[Stdcall] 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])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 = BuildFastCallThunk(addr);
+ this.func = (delegate* unmanaged[Stdcall])call;
+ }
+}
diff --git a/Daybreak.API/Interop/GWHook.cs b/Daybreak.API/Interop/GWHook.cs
new file mode 100644
index 00000000..43012db9
--- /dev/null
+++ b/Daybreak.API/Interop/GWHook.cs
@@ -0,0 +1,122 @@
+using System.Runtime.InteropServices;
+using MinHook;
+
+namespace Daybreak.API.Interop;
+
+///
+/// A thin convenience wrapper around MinHook that
+/// resolves the target address lazily through
+/// unwraps an existing “CALL/JMP rel32” stub so we patch the real code
+/// exposes so your detour can chain
+///
+public sealed class GWHook(
+ GWAddressCache funcAddress,
+ T detour,
+ bool bypassPreviousHooks = false) : IHook
+ where T : Delegate
+{
+ 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
+ private HookEngine? engine;
+
+ /// Delegate that calls the next hook / real function.
+ 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; }
+
+ /// Installs the hook exactly once (thread-safe).
+ 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;
+ }
+
+ 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;
+ return true;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ finally
+ {
+ this.semaphore.Release();
+ }
+ }
+
+ public void Dispose()
+ {
+ this.semaphore.Wait();
+ try
+ {
+ if (!this.Hooked ||
+ this.engine is null)
+ {
+ return;
+ }
+
+ this.engine.DisableHooks();
+ this.engine.Dispose();
+
+ this.cont = null;
+ this.Hooked = false;
+ }
+ finally
+ {
+ this.semaphore.Release();
+ }
+ }
+
+ ///
+ /// Follows a CALL rel32 (E8) **or** JMP rel32 (E9)
+ /// trampoline that may have been planted by a previous hook.
+ ///
+ private static unsafe nuint UnwrapNearBranch(nuint addr)
+ {
+ byte op = Marshal.ReadByte((IntPtr)addr);
+ if (op != 0xE8 && op != 0xE9) // not CALL/JMP rel32
+ return addr;
+
+ int rel = Marshal.ReadInt32((IntPtr)(addr + 1));
+ long dst = (long)addr + 5 + rel; // 5-byte instruction
+ return (nuint)dst;
+ }
+}
diff --git a/Daybreak.API/Interop/GuildWars/AccountGameContext.cs b/Daybreak.API/Interop/GuildWars/AccountGameContext.cs
new file mode 100644
index 00000000..e9e49353
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/AccountGameContext.cs
@@ -0,0 +1,26 @@
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0xC)]
+public readonly struct AccountUnlockedCount
+{
+ [FieldOffset(0x0000)]
+ public readonly uint Id;
+}
+
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x138)]
+public readonly struct AccountGameContext
+{
+ [FieldOffset(0x0000)]
+ public readonly GuildWarsArray AccountUnlockedCounts;
+
+ [FieldOffset(0x00b4)]
+ public readonly GuildWarsArray UnlockedPvpHeroes;
+
+ [FieldOffset(0x0124)]
+ public readonly GuildWarsArray UnlockedAccountSkills;
+
+ [FieldOffset(0x0134)]
+ public readonly uint AccountFlags;
+}
diff --git a/Daybreak.API/Interop/GuildWars/AccountInfoContext.cs b/Daybreak.API/Interop/GuildWars/AccountInfoContext.cs
new file mode 100644
index 00000000..f5b2d4b1
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/AccountInfoContext.cs
@@ -0,0 +1,15 @@
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+[StructLayout(LayoutKind.Sequential, Pack = 1, Size = 0x1C)]
+public readonly unsafe struct AccountInfoContext
+{
+ public readonly char* AccountName;
+ public readonly uint Wins;
+ public readonly uint Losses;
+ public readonly uint Rating;
+ public readonly uint QualifierPoints;
+ public readonly uint Rank;
+ public readonly uint TournamentRewardPoints;
+}
diff --git a/Daybreak.API/Interop/GuildWars/Agent.cs b/Daybreak.API/Interop/GuildWars/Agent.cs
new file mode 100644
index 00000000..e47368ab
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/Agent.cs
@@ -0,0 +1,247 @@
+using System.Numerics;
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+[Flags]
+public enum AgentType : uint
+{
+ Living = 0xDB,
+ Gadget = 0x200,
+ Item = 0x400
+}
+
+[Flags]
+public enum AgentModelType : ushort
+{
+ NPC = 0x2000,
+ Player = 0x3000
+}
+
+[Flags]
+public enum LivingAgentState : uint
+{
+ None = 0,
+ InCombatStance = 0x000001,
+ HasQuest = 0x000002,
+ Dead = 0x000008,
+ Female = 0x000200,
+ HasBossGlow = 0x000400,
+ HidingCape = 0x001000,
+ CanBeViewedInPartyWindow = 0x020000,
+ SpawnedSpirit = 0x040000,
+ BeingObservedOrPlayer = 0x400000
+}
+
+public enum LivingAgentAllegiance : byte
+{
+ Ally_NonAttackable = 0x1,
+ Neutral = 0x2,
+ Enemy = 0x3,
+ Spirit_Pet = 0x4,
+ Minion = 0x5,
+ Npc_Minipet = 0x6
+};
+
+
+[Flags]
+public enum LivingAgentEffects : uint
+{
+ None = 0,
+ Bleeding = 0x0001,
+ Conditioned = 0x0002,
+ Crippled1 = 0x0008, // Part of the Crippled check
+ Crippled2 = 0x0002, // Part of the Crippled check
+ Crippled = 0x000A, // Combined flag for crippled (0x0008 | 0x0002)
+ Dead = 0x0010,
+ DeepWound = 0x0020,
+ Poisoned = 0x0040,
+ Enchanted = 0x0080,
+ DegenHexed = 0x0400,
+ Hexed = 0x0800,
+ WeaponSpelled = 0x8000
+}
+
+public enum LivingAgentModelState : ushort
+{
+ // Idle states
+ Idle1 = 64,
+ Idle2 = 68,
+ Idle3 = 100,
+
+ // Casting states
+ Casting1 = 65,
+ Casting2 = 581,
+
+ // Moving states
+ Moving1 = 12,
+ Moving2 = 76,
+ Moving3 = 204,
+
+ // Attacking states
+ Attacking1 = 96,
+ Attacking2 = 1088,
+ Attacking3 = 1120,
+
+ // Other states
+ KnockedDown = 1104
+}
+
+[StructLayout(LayoutKind.Sequential, Pack = 1)]
+public readonly struct AgentEffects
+{
+ public readonly uint AgentId;
+ public readonly GuildWarsArray Buffs;
+ public readonly GuildWarsArray Effects;
+}
+
+[StructLayout(LayoutKind.Explicit, Pack = 1)]
+public readonly struct AgentGameContext
+{
+ [FieldOffset(0x01AC)]
+ public readonly uint InstanceTimer;
+}
+
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 196)]
+public readonly struct AgentContext
+{
+ [FieldOffset(0x0014)]
+ public readonly uint AgentInstanceTimer;
+
+ [FieldOffset(0x002C)]
+ public readonly uint AgentId;
+
+ [FieldOffset(0x0074)]
+ public readonly GamePos Pos;
+
+ [FieldOffset(0x009C)]
+ public readonly AgentType Type;
+
+ [FieldOffset(0x00A0)]
+ public readonly Vector2 Velocity;
+}
+
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 212)]
+public readonly struct AgentItemContext
+{
+ [FieldOffset(0x00C4)]
+ public readonly uint OwnerId;
+
+ [FieldOffset(0x00C8)]
+ public readonly uint ItemId;
+
+ [FieldOffset(0x00D0)]
+ public readonly uint ExtraType;
+}
+
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 228)]
+public readonly struct AgentGadgetContext
+{
+ [FieldOffset(0x00CC)]
+ public readonly uint ExtraType;
+
+ [FieldOffset(0x00D0)]
+ public readonly uint GadgetId;
+}
+
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x1C0)]
+public readonly unsafe struct AgentLivingContext
+{
+ [FieldOffset(0x00C4)]
+ public readonly uint OwnerId;
+
+ [FieldOffset(0x00F4)]
+ public readonly ushort AgentType; // All non-player have an identifier. Two of the same mob have the same identifier;
+
+ [FieldOffset(0x00F6)]
+ public readonly AgentModelType AgentModelType;
+
+ [FieldOffset(0x0104)]
+ public readonly TagInfo* Tags;
+
+ [FieldOffset(0x010A)]
+ public readonly byte Primary;
+
+ [FieldOffset(0x010B)]
+ public readonly byte Secondary;
+
+ [FieldOffset(0x010C)]
+ public readonly byte Level;
+
+ [FieldOffset(0x010D)]
+ public readonly byte TeamId;
+
+ [FieldOffset(0x0114)]
+ public readonly float EnergyRegen;
+
+ [FieldOffset(0x011C)]
+ public readonly float Energy;
+
+ [FieldOffset(0x0120)]
+ public readonly uint MaxEnergy;
+
+ [FieldOffset(0x0128)]
+ public readonly float HealthRegen;
+
+ [FieldOffset(0x0130)]
+ public readonly float Health;
+
+ [FieldOffset(0x0134)]
+ public readonly uint MaxHealth;
+
+ [FieldOffset(0x0138)]
+ public readonly uint Effects;
+
+ [FieldOffset(0x0154)]
+ public readonly LivingAgentModelState ModelState;
+
+ [FieldOffset(0x0158)]
+ public readonly LivingAgentState State;
+
+ [FieldOffset(0x0180)]
+ public readonly uint LoginNumber;
+
+ [FieldOffset(0x01B1)]
+ public readonly LivingAgentAllegiance Allegiance;
+
+ public readonly bool IsAlive => (!this.State.HasFlag(LivingAgentState.Dead)) && this.Health > 0f;
+
+ public readonly bool IsPlayer => this.LoginNumber is not 0;
+
+ public readonly bool IsNpc => this.LoginNumber is 0;
+}
+
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 52)]
+public readonly struct MapAgentContext
+{
+ [FieldOffset(0x0000)]
+ public readonly float CurrentEnergy;
+
+ [FieldOffset(0x0004)]
+ public readonly float MaxEnergy;
+
+ [FieldOffset(0x0008)]
+ public readonly float EnergyRegen;
+
+ [FieldOffset(0x000C)]
+ public readonly uint SkillTimestamp;
+
+ [FieldOffset(0x0020)]
+ public readonly float CurrentHealth;
+
+ [FieldOffset(0x0024)]
+ public readonly float MaxHealth;
+
+ [FieldOffset(0x0028)]
+ public readonly float HealthRegen;
+
+ [FieldOffset(0x0030)]
+ public readonly LivingAgentEffects Effects;
+}
+
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x38)]
+public readonly unsafe struct AgentInfo
+{
+ [FieldOffset(0x0034)]
+ public readonly char* NameEncoded;
+}
diff --git a/Daybreak.API/Interop/GuildWars/Attribute.cs b/Daybreak.API/Interop/GuildWars/Attribute.cs
new file mode 100644
index 00000000..d817ea84
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/Attribute.cs
@@ -0,0 +1,93 @@
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+[StructLayout(LayoutKind.Sequential, Pack = 1)]
+public readonly struct AttributeContext
+{
+ public readonly uint Id;
+ public readonly uint LevelBase;
+ public readonly uint Level;
+ public readonly uint DecrementPoints;
+ public readonly uint IncrementPoints;
+}
+
+[StructLayout(LayoutKind.Sequential, Pack = 1)]
+public readonly struct AttributeInfo
+{
+ public readonly uint ProfessionId;
+ public readonly uint AttributeId;
+ public readonly uint NameId;
+ public readonly uint DescriptionId;
+ public readonly uint IsPve;
+}
+
+[StructLayout(LayoutKind.Sequential, Pack = 1)]
+public readonly struct PartyAttribute
+{
+ public readonly uint AgentId;
+ public readonly AttributeContext Attribute0;
+ public readonly AttributeContext Attribute1;
+ public readonly AttributeContext Attribute2;
+ public readonly AttributeContext Attribute3;
+ public readonly AttributeContext Attribute4;
+ public readonly AttributeContext Attribute5;
+ public readonly AttributeContext Attribute6;
+ public readonly AttributeContext Attribute7;
+ public readonly AttributeContext Attribute8;
+ public readonly AttributeContext Attribute9;
+ public readonly AttributeContext Attribute10;
+ public readonly AttributeContext Attribute11;
+ public readonly AttributeContext Attribute12;
+ public readonly AttributeContext Attribute13;
+ public readonly AttributeContext Attribute14;
+ public readonly AttributeContext Attribute15;
+ public readonly AttributeContext Attribute16;
+ public readonly AttributeContext Attribute17;
+ public readonly AttributeContext Attribute18;
+ public readonly AttributeContext Attribute19;
+ public readonly AttributeContext Attribute20;
+ public readonly AttributeContext Attribute21;
+ public readonly AttributeContext Attribute22;
+ public readonly AttributeContext Attribute23;
+ public readonly AttributeContext Attribute24;
+ public readonly AttributeContext Attribute25;
+ public readonly AttributeContext Attribute26;
+ public readonly AttributeContext Attribute27;
+ public readonly AttributeContext Attribute28;
+ public readonly AttributeContext Attribute29;
+ public readonly AttributeContext Attribute30;
+ public readonly AttributeContext Attribute31;
+ public readonly AttributeContext Attribute32;
+ public readonly AttributeContext Attribute33;
+ public readonly AttributeContext Attribute34;
+ public readonly AttributeContext Attribute35;
+ public readonly AttributeContext Attribute36;
+ public readonly AttributeContext Attribute37;
+ public readonly AttributeContext Attribute38;
+ public readonly AttributeContext Attribute39;
+ public readonly AttributeContext Attribute40;
+ public readonly AttributeContext Attribute41;
+ public readonly AttributeContext Attribute42;
+ public readonly AttributeContext Attribute43;
+ public readonly AttributeContext Attribute44;
+ public readonly AttributeContext Attribute45;
+ public readonly AttributeContext Attribute46;
+ public readonly AttributeContext Attribute47;
+ public readonly AttributeContext Attribute48;
+ public readonly AttributeContext Attribute49;
+ public readonly AttributeContext Attribute50;
+ public readonly AttributeContext Attribute51;
+ public readonly AttributeContext Attribute52;
+ public readonly AttributeContext Attribute53;
+
+ public readonly AttributeContext[] Attributes =>
+ [
+ this.Attribute0, this.Attribute1, this.Attribute2, this.Attribute3, this.Attribute4, this.Attribute5, this.Attribute6, this.Attribute7, this.Attribute8, this.Attribute9,
+ this.Attribute10, this.Attribute11, this.Attribute12, this.Attribute13, this.Attribute14, this.Attribute15, this.Attribute16, this.Attribute17, this.Attribute18, this.Attribute19,
+ this.Attribute20, this.Attribute21, this.Attribute22, this.Attribute23, this.Attribute24, this.Attribute25, this.Attribute26, this.Attribute27, this.Attribute28, this.Attribute29,
+ this.Attribute30, this.Attribute31, this.Attribute32, this.Attribute33, this.Attribute34, this.Attribute35, this.Attribute36, this.Attribute37, this.Attribute38, this.Attribute39,
+ this.Attribute40, this.Attribute41, this.Attribute42, this.Attribute43, this.Attribute44, this.Attribute45, this.Attribute46, this.Attribute47, this.Attribute48, this.Attribute49,
+ this.Attribute50, this.Attribute51, this.Attribute52, this.Attribute53
+ ];
+}
diff --git a/Daybreak.API/Interop/GuildWars/Behavior.cs b/Daybreak.API/Interop/GuildWars/Behavior.cs
new file mode 100644
index 00000000..b43bc4f8
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/Behavior.cs
@@ -0,0 +1,8 @@
+namespace Daybreak.API.Interop.GuildWars;
+
+public enum Behavior
+{
+ Fight,
+ Guard,
+ AvoidCombat
+}
diff --git a/Daybreak.API/Interop/GuildWars/CharContext.cs b/Daybreak.API/Interop/GuildWars/CharContext.cs
new file mode 100644
index 00000000..1f79efd4
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/CharContext.cs
@@ -0,0 +1,50 @@
+using System.Extensions;
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+[StructLayout(LayoutKind.Explicit, Pack = 1)]
+public readonly struct CharContext
+{
+ [FieldOffset(0x0064)]
+ public readonly Uuid PlayerUuid;
+
+ [FieldOffset(0x0074)]
+ public readonly Array20Char PlayerName;
+
+ [FieldOffset(0x0198)]
+ public readonly uint MapId;
+
+ [FieldOffset(0x019C)]
+ public readonly uint IsExporable;
+
+ [FieldOffset(0x01A0)]
+ public readonly Array24Byte Host;
+
+ [FieldOffset(0x01B8)]
+ public readonly uint PlayerId;
+
+ [FieldOffset(0x0220)]
+ public readonly uint DistrictNumber;
+
+ [FieldOffset(0x0224)]
+ public readonly Language Language;
+
+ [FieldOffset(0x0228)]
+ public readonly uint ObserveMapId;
+
+ [FieldOffset(0x022C)]
+ public readonly uint CurrentMapId;
+
+ [FieldOffset(0x0230)]
+ public readonly uint ObserveMapType;
+
+ [FieldOffset(0x0234)]
+ public readonly uint CurrentMapType;
+
+ [FieldOffset(0x02A4)]
+ public readonly uint PlayerNumber;
+
+ [FieldOffset(0x03B8)]
+ public readonly Array64Char PlayerEmail;
+}
diff --git a/Daybreak.API/Interop/GuildWars/CharInfoContext.cs b/Daybreak.API/Interop/GuildWars/CharInfoContext.cs
new file mode 100644
index 00000000..488c8d32
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/CharInfoContext.cs
@@ -0,0 +1,22 @@
+using System.Extensions;
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+[StructLayout(LayoutKind.Explicit, Pack = 1)]
+public readonly struct CharInfoContext
+{
+ [FieldOffset(0x0008)]
+ public readonly Uuid Uuid;
+ [FieldOffset(0x0018)]
+ public readonly Array20Char Name;
+ [FieldOffset(0x0040)]
+ public readonly Array17Uint Props;
+
+ public readonly uint MapId => (this.Props[0] >> 16) & 0xFFFF;
+ public readonly uint Primary => (this.Props[2] >> 20) & 0xF;
+ public readonly uint Secondary => (this.Props[7] >> 10) & 0xF;
+ public readonly uint Campaign => this.Props[7] & 0xF;
+ public readonly uint Level => (this.Props[7] >> 4) & 0x3F;
+ public readonly bool IsPvp => ((this.Props[7] >> 9) & 0x1) == 0x1;
+}
diff --git a/Daybreak.API/Interop/GuildWars/District.cs b/Daybreak.API/Interop/GuildWars/District.cs
new file mode 100644
index 00000000..dd89cf54
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/District.cs
@@ -0,0 +1,19 @@
+namespace Daybreak.API.Interop.GuildWars;
+
+public enum District
+{
+ Current,
+ International,
+ American,
+ EuropeEnglish,
+ EuropeFrench,
+ EuropeGerman,
+ EuropeItalian,
+ EuropeSpanish,
+ EuropePolish,
+ EuropeRussian,
+ AsiaKorean,
+ AsiaChinese,
+ AsiaJapanese,
+ Unknown = 0xff
+}
diff --git a/Daybreak.API/Interop/GuildWars/Frame.cs b/Daybreak.API/Interop/GuildWars/Frame.cs
new file mode 100644
index 00000000..52d7de2d
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/Frame.cs
@@ -0,0 +1,68 @@
+using Daybreak.API.Models;
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+// Very incomplete. GWCA contains a much better definition
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x1AC)]
+public readonly struct Frame
+{
+ [FieldOffset(0x0008)]
+ public readonly uint FrameLayout;
+
+ [FieldOffset(0x0018)]
+ public readonly uint VisibilityFlags;
+
+ [FieldOffset(0x0020)]
+ public readonly uint Type;
+
+ [FieldOffset(0x0024)]
+ public readonly uint TemplateType;
+
+ [FieldOffset(0x00A0)]
+ public readonly GuildWarsArray FrameCallbacks;
+
+ [FieldOffset(0x00b0)]
+ public readonly uint ChildOffsetId;
+
+ [FieldOffset(0x00b4)]
+ public readonly uint FrameId;
+
+ [FieldOffset(0x0120)]
+ public readonly FrameRelation Relation;
+
+ [FieldOffset(0x0184)]
+ public readonly uint Field91;
+
+ public bool IsCreated => (this.Field91 & 0x4) != 0;
+ public bool IsHidden => (this.Field91 & 0x200) != 0;
+ public bool IsVisible => !this.IsHidden;
+ public bool IsDIsabled => (this.Field91 & 0x10) != 0;
+}
+
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x1C)]
+public readonly unsafe struct FrameRelation
+{
+ [FieldOffset(0x0000)]
+ public readonly FrameRelation* Parent;
+
+ [FieldOffset(0x000C)]
+ public readonly uint FrameHashId;
+}
+
+[StructLayout(LayoutKind.Sequential, Pack =1)]
+public readonly unsafe struct InteractionMessage
+{
+ public readonly uint FrameId;
+ public readonly UIMessage MessageId;
+ public readonly void** WParam;
+}
+
+public unsafe delegate void UIInteractionCallback(InteractionMessage* message, void* wParam, void* lParam);
+
+[StructLayout(LayoutKind.Sequential, Pack = 1, Size = 0xC)]
+public unsafe readonly struct FrameInteractionCallback
+{
+ public readonly void* Callback; // UIInteractionCallback
+ public readonly void* UiCtl_Context;
+}
diff --git a/Daybreak.API/Interop/GuildWars/GameContext.cs b/Daybreak.API/Interop/GuildWars/GameContext.cs
new file mode 100644
index 00000000..aee40ed7
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/GameContext.cs
@@ -0,0 +1,28 @@
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+[StructLayout(LayoutKind.Explicit, Pack = 1)]
+public readonly unsafe struct GameContext
+{
+ [FieldOffset(0x0008)]
+ public readonly AgentGameContext* AgentGameContext;
+
+ [FieldOffset(0x0014)]
+ public readonly MapContext* MapContext;
+
+ [FieldOffset(0x0028)]
+ public readonly AccountGameContext* AccountContext;
+
+ [FieldOffset(0x002C)]
+ public readonly WorldContext* WorldContext;
+
+ [FieldOffset(0x003C)]
+ public readonly GuildContext* GuildContext;
+
+ [FieldOffset(0x0044)]
+ public readonly CharContext* CharContext;
+
+ [FieldOffset(0x004C)]
+ public readonly PartyContext* PartyContext;
+}
diff --git a/Daybreak.API/Interop/GuildWars/GamePos.cs b/Daybreak.API/Interop/GuildWars/GamePos.cs
new file mode 100644
index 00000000..1cebce24
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/GamePos.cs
@@ -0,0 +1,11 @@
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+[StructLayout(LayoutKind.Sequential, Pack = 1)]
+public readonly struct GamePos
+{
+ public readonly float X;
+ public readonly float Y;
+ public readonly uint Plane;
+}
diff --git a/Daybreak.API/Interop/GuildWars/Guild.cs b/Daybreak.API/Interop/GuildWars/Guild.cs
new file mode 100644
index 00000000..a50f2407
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/Guild.cs
@@ -0,0 +1,110 @@
+using System.Extensions;
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+[StructLayout(LayoutKind.Explicit, Pack = 1)]
+public readonly unsafe struct GuildContext
+{
+ [FieldOffset(0x0034)]
+ public readonly Array20Char PlayerName;
+
+ [FieldOffset(0x0060)]
+ public readonly uint PlayerBuildIndex;
+
+ [FieldOffset(0x0078)]
+ public readonly Array256Char Announcement;
+
+ [FieldOffset(0x0287)]
+ public readonly Array20Char AnnouncementAuthor;
+
+ [FieldOffset(0x02A0)]
+ public readonly uint PlayerGuildRank;
+
+ [FieldOffset(0x02A8)]
+ public readonly GuildWarsArray FactionOutpostGuilds;
+
+ [FieldOffset(0x02B8)]
+ public readonly uint KurzickTownCount;
+
+ [FieldOffset(0x02BC)]
+ public readonly uint LuxonTownCount;
+
+ [FieldOffset(0x02F8)]
+ public readonly GuildWarsArray> Guilds;
+
+ [FieldOffset(0x0358)]
+ public readonly GuildWarsArray> PlayerRoster;
+}
+
+[StructLayout(LayoutKind.Sequential, Pack = 1)]
+public readonly struct TownAlliance
+{
+ public readonly uint Rank;
+ public readonly uint Allegiance;
+ public readonly uint Faction;
+ public readonly Array32Char Name;
+ public readonly Array5Char Tag;
+ public readonly CapeDesign Cape;
+ public readonly uint MapId;
+}
+
+[StructLayout(LayoutKind.Sequential, Pack = 1)]
+public readonly struct CapeDesign
+{
+ public readonly uint CapeBgColor;
+ public readonly uint CapeDetailColor;
+ public readonly uint CapeEmblemColor;
+ public readonly uint CapeShape;
+ public readonly uint CapeDetail;
+ public readonly uint CapeEmblem;
+ public readonly uint CapeTrim;
+}
+
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x174)]
+public readonly unsafe struct GuildPlayer
+{
+ [FieldOffset(0x0004)]
+ public readonly char* NamePtr;
+ [FieldOffset(0x0008)]
+ public readonly Array20Char InvitedName;
+ [FieldOffset(0x0030)]
+ public readonly Array20Char CurrentName;
+ [FieldOffset(0x0050)]
+ public readonly Array20Char InviterName;
+ [FieldOffset(0x0080)]
+ public readonly uint InviteTime;
+ [FieldOffset(0x0084)]
+ public readonly Array20Char PromoterName;
+ [FieldOffset(0x00DC)]
+ public readonly uint Offline;
+ [FieldOffset(0x00E0)]
+ public readonly uint MemberType;
+ [FieldOffset(0x00E4)]
+ public readonly uint Status;
+}
+
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0xAC)]
+public readonly struct Guild
+{
+ [FieldOffset(0x0024)]
+ public readonly uint Index;
+ [FieldOffset(0x0028)]
+ public readonly uint Rank;
+ [FieldOffset(0x002C)]
+ public readonly uint Features;
+ [FieldOffset(0x0030)]
+ public readonly Array32Char Name;
+ [FieldOffset(0x0070)]
+ public readonly uint Rating;
+ [FieldOffset(0x0074)]
+ public readonly uint Faction;
+ [FieldOffset(0x0078)]
+ public readonly uint FactionPoints;
+ [FieldOffset(0x007C)]
+ public readonly uint QualifierPoints;
+ [FieldOffset(0x0080)]
+ public readonly Array8Char Tag;
+ [FieldOffset(0x0090)]
+ public readonly CapeDesign Cape;
+}
diff --git a/Daybreak.API/Interop/GuildWars/GuildWarsArray.cs b/Daybreak.API/Interop/GuildWars/GuildWarsArray.cs
new file mode 100644
index 00000000..2079f746
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/GuildWarsArray.cs
@@ -0,0 +1,52 @@
+using System.Collections;
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+[StructLayout(LayoutKind.Sequential, Pack = 1)]
+public readonly unsafe struct GuildWarsArray : IEnumerable
+ where T : unmanaged
+{
+ public readonly T* Buffer;
+ public readonly uint Capacity;
+ public readonly uint Size;
+ public readonly uint Param;
+
+ public Enumerator GetEnumerator() => new(this.Buffer, this.Size);
+
+ IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator();
+
+ public unsafe struct Enumerator : IEnumerator, IEnumerator
+ {
+ private readonly T* buffer;
+ private readonly uint size;
+ private int index;
+
+ internal Enumerator(T* buffer, uint size)
+ {
+ this.buffer = buffer;
+ this.size = size;
+ this.index = -1;
+ }
+
+ public bool MoveNext()
+ {
+ int next = this.index + 1;
+ if (next >= this.size)
+ {
+ return false;
+ }
+
+ this.index = next;
+ return true;
+ }
+
+ public T Current => this.buffer[this.index];
+ object IEnumerator.Current => this.Current;
+
+ public void Reset() => this.index = -1;
+ public void Dispose() { }
+ }
+}
diff --git a/Daybreak.API/Interop/GuildWars/Hero.cs b/Daybreak.API/Interop/GuildWars/Hero.cs
new file mode 100644
index 00000000..14efd45e
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/Hero.cs
@@ -0,0 +1,43 @@
+using System.Extensions;
+using System.Numerics;
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x24)]
+public readonly struct HeroFlag
+{
+ [FieldOffset(0x0000)]
+ public readonly uint HeroId;
+ [FieldOffset(0x0004)]
+ public readonly uint AgentId;
+ [FieldOffset(0x0008)]
+ public readonly uint Level;
+ [FieldOffset(0x000C)]
+ public readonly Behavior Behavior;
+ [FieldOffset(0x0010)]
+ public readonly Vector2 Flag;
+ [FieldOffset(0x0020)]
+ public readonly uint LockedTargetId;
+}
+
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x78)]
+public readonly struct HeroInfo
+{
+ [FieldOffset(0x0000)]
+ public readonly uint HeroId;
+ [FieldOffset(0x0004)]
+ public readonly uint AgentId;
+ [FieldOffset(0x0008)]
+ public readonly uint Level;
+ [FieldOffset(0x000C)]
+ public readonly Profession Primary;
+ [FieldOffset(0x0010)]
+ public readonly Profession Secondary;
+ [FieldOffset(0x0014)]
+ public readonly uint HeroFileId;
+ [FieldOffset(0x0018)]
+ public readonly uint ModelFileId;
+ [FieldOffset(0x0050)]
+ public readonly Array20Char Name;
+}
diff --git a/Daybreak.API/Interop/GuildWars/InstanceInfo.cs b/Daybreak.API/Interop/GuildWars/InstanceInfo.cs
new file mode 100644
index 00000000..fc87600f
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/InstanceInfo.cs
@@ -0,0 +1,29 @@
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+public enum InstanceType
+{
+ Outpost,
+ Explorable,
+ Loading
+};
+
+[StructLayout(LayoutKind.Explicit, Pack = 1)]
+public readonly unsafe struct InstanceInfoContext
+{
+ [FieldOffset(0x0004)]
+ public readonly InstanceType InstanceType;
+ [FieldOffset(0x0008)]
+ public readonly AreaInfo* CurrentMapInfo;
+}
+
+[StructLayout(LayoutKind.Sequential, Pack = 1)]
+public readonly struct AreaInfo
+{
+ public readonly uint Campaign;
+ public readonly uint Continent;
+ public readonly uint Region;
+ public readonly uint RegionType;
+ public readonly uint Flags;
+}
diff --git a/Daybreak.API/Interop/GuildWars/Language.cs b/Daybreak.API/Interop/GuildWars/Language.cs
new file mode 100644
index 00000000..d7a26738
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/Language.cs
@@ -0,0 +1,17 @@
+namespace Daybreak.API.Interop.GuildWars;
+
+public enum Language
+{
+ English,
+ Korean,
+ French,
+ German,
+ Italian,
+ Spanish,
+ TraditionalChinese,
+ Japanese = 8,
+ Polish,
+ Russian,
+ BorkBorkBork = 17,
+ Unknown = 0xff
+}
diff --git a/Daybreak.API/Interop/GuildWars/MapContext.cs b/Daybreak.API/Interop/GuildWars/MapContext.cs
new file mode 100644
index 00000000..6970fd32
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/MapContext.cs
@@ -0,0 +1,8 @@
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+[StructLayout(LayoutKind.Explicit, Pack = 1)]
+public readonly struct MapContext
+{
+}
diff --git a/Daybreak.API/Interop/GuildWars/Npc.cs b/Daybreak.API/Interop/GuildWars/Npc.cs
new file mode 100644
index 00000000..7de1cf6c
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/Npc.cs
@@ -0,0 +1,61 @@
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+[StructLayout(LayoutKind.Explicit)]
+public readonly unsafe struct NpcContext
+{
+ [FieldOffset(0x0000)]
+ public readonly uint ModelId;
+
+ [FieldOffset(0x000C)]
+ public readonly uint Sex;
+
+ [FieldOffset(0x0010)]
+ public readonly NpcFlags Flags;
+
+ [FieldOffset(0x0014)]
+ public readonly uint Primary;
+
+ [FieldOffset(0x001C)]
+ public readonly byte DefaultLevel;
+
+ [FieldOffset(0x0020)]
+ public readonly char* EncName;
+
+ [FieldOffset(0x0028)]
+ public readonly int FilesCount;
+
+ [FieldOffset(0x002C)]
+ public readonly int FilesCapacity;
+
+ [Flags]
+ public enum NpcFlags : uint
+ {
+ None = 0x0000,
+ Henchman = 0x0010,
+ Hero = 0x0020,
+ Spirit = 0x4000,
+ Minion = 0x0100,
+ Pet = 0x000D
+ }
+}
+
+[StructLayout(LayoutKind.Sequential, Pack = 1)]
+public readonly unsafe struct PetContext
+{
+ public readonly uint AgentId;
+ public readonly uint OwnerAgentId;
+ public readonly char* PetName;
+ public readonly uint ModelFileId1;
+ public readonly uint ModelFileId2;
+ public readonly Behavior Behavior;
+ public readonly uint LockedTargetId;
+}
+
+[StructLayout(LayoutKind.Sequential, Pack = 1)]
+public readonly struct ControlledMinion
+{
+ public readonly uint AgentId;
+ public readonly uint MinionCount;
+}
diff --git a/Daybreak.API/Interop/GuildWars/Party.cs b/Daybreak.API/Interop/GuildWars/Party.cs
new file mode 100644
index 00000000..62cc060e
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/Party.cs
@@ -0,0 +1,124 @@
+using System.Extensions;
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+[Flags]
+public enum PlayerPartyMemberState : uint
+{
+ None = 0,
+ Connected = 1,
+ Ticked = 2
+}
+
+public enum PartySearchType
+{
+ PartySearchType_Hunting = 0,
+ PartySearchType_Mission = 1,
+ PartySearchType_Quest = 2,
+ PartySearchType_Trade = 3,
+ PartySearchType_Guild = 4,
+};
+
+[Flags]
+public enum PartyFlags : uint
+{
+ None = 0,
+ HardMode = 0x10, // Bit 4
+ Defeated = 0x20, // Bit 5
+ PartyLeader = 0x80 // Bit 7 (0x7th position)
+}
+
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0xC)]
+public readonly unsafe struct PartyMoraleContext
+{
+ [FieldOffset(0x000C)]
+ public readonly PartyMemberMoraleInfo* MoraleInfo;
+}
+
+[StructLayout(LayoutKind.Explicit, Pack = 1)]
+public readonly struct PartyMemberMoraleInfo
+{
+ [FieldOffset(0x0000)]
+ public readonly uint AgentId;
+ [FieldOffset(0x0014)]
+ public readonly uint Morale;
+}
+
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x58)]
+public readonly unsafe struct PartyContext
+{
+ [FieldOffset(0x0014)]
+ public readonly PartyFlags Flags;
+ [FieldOffset(0x0040)]
+ public readonly GuildWarsArray> Parties;
+ [FieldOffset(0x0054)]
+ public readonly PartyInfo* PlayerParty;
+ [FieldOffset(0x00C0)]
+ public readonly GuildWarsArray> PartySearches;
+}
+
+[StructLayout(LayoutKind.Sequential, Pack = 1, Size = 0xC)]
+public readonly struct PlayerPartyMember
+{
+ public readonly uint LogingNumber;
+ public readonly uint CalledTargetId;
+ public readonly PlayerPartyMemberState State;
+}
+
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x18)]
+public readonly struct HeroPartyMember
+{
+ [FieldOffset(0x0000)]
+ public readonly uint AgentId;
+ [FieldOffset(0x0004)]
+ public readonly uint OwnerPlayerId;
+ [FieldOffset(0x0008)]
+ public readonly uint HeroId;
+ [FieldOffset(0x0014)]
+ public readonly uint Level;
+}
+
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x34)]
+public readonly struct HenchmanPartyMember
+{
+ [FieldOffset(0x0000)]
+ public readonly uint AgentId;
+ [FieldOffset(0x002C)]
+ public readonly Profession Profession;
+ [FieldOffset(0x0030)]
+ public readonly uint Level;
+}
+
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x84)]
+public readonly struct PartyInfo
+{
+ [FieldOffset(0x0000)]
+ public readonly uint PartyId;
+ [FieldOffset(0x0004)]
+ public readonly GuildWarsArray Players;
+ [FieldOffset(0x0014)]
+ public readonly GuildWarsArray Henchmen;
+ [FieldOffset(0x0024)]
+ public readonly GuildWarsArray Heroes;
+ [FieldOffset(0x0034)]
+ public readonly GuildWarsArray Others;
+}
+
+[StructLayout(LayoutKind.Sequential, Pack = 1)]
+public readonly struct PartySearch
+{
+ public readonly uint PartySearchId;
+ public readonly PartySearchType PartySearchType;
+ public readonly uint HardMode;
+ public readonly uint District;
+ public readonly uint Language;
+ public readonly uint PartySize;
+ public readonly uint HeroCount;
+ public readonly Array32Char Message;
+ public readonly Array20Char PartyLeader;
+ public readonly Profession Primary;
+ public readonly Profession Secondary;
+ public readonly uint Level;
+ public readonly uint Timestamp;
+}
diff --git a/Daybreak.API/Interop/GuildWars/PlayerContext.cs b/Daybreak.API/Interop/GuildWars/PlayerContext.cs
new file mode 100644
index 00000000..fc751766
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/PlayerContext.cs
@@ -0,0 +1,44 @@
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+[Flags]
+public enum PlayerContextFlags : uint
+{
+ None = 0,
+ PvP = 0x800
+}
+
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x4C)]
+public readonly unsafe struct PlayerContext
+{
+ [FieldOffset(0x0000)]
+ public readonly int AgentId;
+
+ [FieldOffset(0x0014)]
+ public readonly PlayerContextFlags Flags;
+
+ [FieldOffset(0x0018)]
+ public readonly uint PrimaryProfession;
+
+ [FieldOffset(0x001C)]
+ public readonly uint SecondaryProfession;
+
+ [FieldOffset(0x0024)]
+ public readonly char* NameEncoded;
+
+ [FieldOffset(0x0028)]
+ public readonly char* Name;
+
+ [FieldOffset(0x002C)]
+ public readonly uint PartyLeaderPlayerNumber;
+
+ [FieldOffset(0x0030)]
+ public readonly uint ActiveTitleTier;
+
+ [FieldOffset(0x0034)]
+ public readonly uint PlayerNumber;
+
+ [FieldOffset(0x0038)]
+ public readonly uint PartySize;
+}
diff --git a/Daybreak.API/Interop/GuildWars/PlayerControlledCharContext.cs b/Daybreak.API/Interop/GuildWars/PlayerControlledCharContext.cs
new file mode 100644
index 00000000..343d4c9b
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/PlayerControlledCharContext.cs
@@ -0,0 +1,10 @@
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+[StructLayout(LayoutKind.Explicit, Pack = 1)]
+public readonly struct PlayerControlledCharContext
+{
+ [FieldOffset(0x0014)]
+ public readonly uint AgentId;
+}
diff --git a/Daybreak.API/Interop/GuildWars/PreGameContext.cs b/Daybreak.API/Interop/GuildWars/PreGameContext.cs
new file mode 100644
index 00000000..d429a9c0
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/PreGameContext.cs
@@ -0,0 +1,30 @@
+using System.Extensions;
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+[StructLayout(LayoutKind.Explicit, Pack = 1)]
+public readonly struct PreGameContext
+{
+ [FieldOffset(0x0000)]
+ public readonly uint FrameId;
+
+ [FieldOffset(0x0124)]
+ public readonly uint ChosenCharacterIndex;
+
+ [FieldOffset(0x0140)]
+ public readonly uint Index1;
+
+ [FieldOffset(0x0144)]
+ public readonly uint Index2;
+
+ [FieldOffset(0x00148)]
+ public readonly GuildWarsArray LoginCharacters;
+}
+
+[StructLayout(LayoutKind.Explicit, Pack = 1)]
+public readonly struct LoginCharacterContext
+{
+ [FieldOffset(0x0004)]
+ public readonly Array20Char CharacterName;
+}
diff --git a/Daybreak.API/Interop/GuildWars/Profession.cs b/Daybreak.API/Interop/GuildWars/Profession.cs
new file mode 100644
index 00000000..1957d22a
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/Profession.cs
@@ -0,0 +1,47 @@
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+public enum Profession : uint
+{
+ None,
+ Warrior,
+ Ranger,
+ Monk,
+ Necromancer,
+ Mesmer,
+ Elementalist,
+ Assassin,
+ Ritualist,
+ Paragon,
+ Dervish
+}
+
+public enum ProfessionByte : byte
+{
+ None,
+ Warrior,
+ Ranger,
+ Monk,
+ Necromancer,
+ Mesmer,
+ Elementalist,
+ Assassin,
+ Ritualist,
+ Paragon,
+ Dervish
+}
+
+[StructLayout(LayoutKind.Sequential, Pack = 1, Size = 0x0014)]
+public readonly struct ProfessionsContext
+{
+ public readonly uint AgentId;
+ public readonly Profession CurrentPrimary;
+ public readonly Profession CurrentSecondary;
+ public readonly uint UnlockedProfessionsFlags;
+
+ public bool ProfessionUnlocked(int professionId)
+ {
+ return (this.UnlockedProfessionsFlags & (1U << professionId)) != 0;
+ }
+}
diff --git a/Daybreak.API/Interop/GuildWars/Quest.cs b/Daybreak.API/Interop/GuildWars/Quest.cs
new file mode 100644
index 00000000..045f4116
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/Quest.cs
@@ -0,0 +1,28 @@
+using System.Numerics;
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x0034)]
+public readonly struct QuestContext
+{
+ [FieldOffset(0x0000)]
+ public readonly uint QuestId;
+
+ [FieldOffset(0x0014)]
+ public readonly uint MapFrom;
+
+ [FieldOffset(0x0018)]
+ public readonly Vector3 Marker;
+
+ [FieldOffset(0x0028)]
+ public readonly uint MapTo;
+}
+
+[StructLayout(LayoutKind.Sequential, Pack = 1, Size = 0xC)]
+public readonly unsafe struct MissionObjectiveContext
+{
+ public readonly uint ObjectiveId;
+ public readonly char* EncodedString;
+ public readonly uint Type;
+}
diff --git a/Daybreak.API/Interop/GuildWars/ServerRegion.cs b/Daybreak.API/Interop/GuildWars/ServerRegion.cs
new file mode 100644
index 00000000..4149f3e8
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/ServerRegion.cs
@@ -0,0 +1,12 @@
+namespace Daybreak.API.Interop.GuildWars;
+
+public enum ServerRegion
+{
+ International = -2,
+ America = 0,
+ Korea,
+ Europe,
+ China,
+ Japan,
+ Unknown = 0xff
+}
diff --git a/Daybreak.API/Interop/GuildWars/Skill.cs b/Daybreak.API/Interop/GuildWars/Skill.cs
new file mode 100644
index 00000000..86946600
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/Skill.cs
@@ -0,0 +1,58 @@
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+[StructLayout(LayoutKind.Sequential, Pack = 1)]
+public readonly struct SkillContext
+{
+ public readonly uint Adrenaline1;
+ public readonly uint Adrenaline2;
+ public readonly uint Recharge;
+ public readonly uint Id;
+ public readonly uint Event;
+}
+
+[StructLayout(LayoutKind.Sequential, Pack = 1, Size = 0xBC)]
+public readonly struct SkillbarContext
+{
+ public readonly uint AgentId;
+ public readonly SkillContext Skill0;
+ public readonly SkillContext Skill1;
+ public readonly SkillContext Skill2;
+ public readonly SkillContext Skill3;
+ public readonly SkillContext Skill4;
+ public readonly SkillContext Skill5;
+ public readonly SkillContext Skill6;
+ public readonly SkillContext Skill7;
+ public readonly uint Disabled;
+ public readonly uint Casting;
+}
+
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x10)]
+public readonly struct Buff
+{
+ [FieldOffset(0x0000)]
+ public readonly uint SkillId;
+ [FieldOffset(0x0008)]
+ public readonly uint BuffId;
+ [FieldOffset(0x000C)]
+ public readonly uint TargetAgentId;
+}
+
+[StructLayout(LayoutKind.Sequential, Pack = 1, Size = 0x18)]
+public readonly struct Effect
+{
+ public readonly uint SkillId;
+ public readonly uint AttributeLevel;
+ public readonly uint EffectId;
+ public readonly uint AgentId;
+ public readonly float Duration;
+ public readonly uint Timestamp;
+}
+
+[StructLayout(LayoutKind.Sequential, Pack = 1)]
+public readonly struct DupeSkill
+{
+ public readonly uint SkillId;
+ public readonly uint Count;
+}
diff --git a/Daybreak.API/Interop/GuildWars/SkillTemplate.cs b/Daybreak.API/Interop/GuildWars/SkillTemplate.cs
new file mode 100644
index 00000000..3a64aeae
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/SkillTemplate.cs
@@ -0,0 +1,13 @@
+using System.Extensions;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+public readonly struct SkillTemplate(uint primary, uint secondary, uint attributeCount, Array12Uint attributeIds, Array12Uint attributeValues, Array8Uint skills)
+{
+ public readonly uint Primary = primary;
+ public readonly uint Secondary = secondary;
+ public readonly uint AttributesCount = attributeCount;
+ public readonly Array12Uint AttributeIds = attributeIds;
+ public readonly Array12Uint AttributeValues = attributeValues;
+ public readonly Array8Uint Skills = skills;
+}
diff --git a/Daybreak.API/Interop/GuildWars/TagInfo.cs b/Daybreak.API/Interop/GuildWars/TagInfo.cs
new file mode 100644
index 00000000..6e8b20f4
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/TagInfo.cs
@@ -0,0 +1,12 @@
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+[StructLayout(LayoutKind.Sequential, Pack = 1)]
+public readonly struct TagInfo
+{
+ public readonly ushort GuildId;
+ public readonly byte Primary;
+ public readonly byte Secondary;
+ public readonly ushort Level;
+}
diff --git a/Daybreak.API/Interop/GuildWars/Title.cs b/Daybreak.API/Interop/GuildWars/Title.cs
new file mode 100644
index 00000000..31707de1
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/Title.cs
@@ -0,0 +1,40 @@
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+[Flags]
+public enum TitleProps : uint
+{
+ None = 0,
+ PercentageBased = 1,
+ HasTiers = 2
+ // Note: The HasTiers check uses (props & 3) == 2, which means bit 1 is set but bit 0 is not
+}
+
+[StructLayout(LayoutKind.Sequential, Pack = 1, Size = 40)]
+public readonly struct TitleContext
+{
+ public readonly TitleProps Props;
+ public readonly uint CurrentPoints;
+ public readonly uint CurrentTitleTierIndex;
+ public readonly uint PointsNeededForCurrentRank;
+ public readonly uint NextTitleTierIndex;
+ public readonly uint PointsNeededForNextRank;
+ public readonly uint MaxTitleRank;
+ public readonly uint MaxTitleTierIndex;
+}
+
+[StructLayout(LayoutKind.Sequential, Pack = 1, Size = 0xC)]
+public readonly unsafe struct TitleTier
+{
+ public readonly TitleProps Props;
+ public readonly uint TierNumber;
+ public readonly char* TierNameEncoded;
+}
+
+[StructLayout(LayoutKind.Sequential, Pack = 1)]
+public readonly struct TitleClientData
+{
+ public readonly uint TitleId;
+ public readonly uint NameId;
+}
diff --git a/Daybreak.API/Interop/GuildWars/UIAction.cs b/Daybreak.API/Interop/GuildWars/UIAction.cs
new file mode 100644
index 00000000..9d38e5be
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/UIAction.cs
@@ -0,0 +1,183 @@
+namespace Daybreak.API.Interop.GuildWars;
+
+public enum UIAction : uint
+{
+ None = 0,
+ Screenshot = 0xAE,
+
+ // Panels
+ CloseAllPanels = 0x85,
+ ToggleInventoryWindow = 0x8B,
+ OpenScoreChart = 0xBD,
+ OpenTemplateManager = 0xD3,
+ OpenSaveEquipmentTemplate = 0xD4,
+ OpenSaveSkillTemplate = 0xD5,
+ OpenParty = 0xBF,
+ OpenGuild = 0xBA,
+ OpenFriends = 0xB9,
+ ToggleAllBags = 0xB8,
+ OpenMissionMap = 0xB6,
+ OpenBag2 = 0xB5,
+ OpenBag1 = 0xB4,
+ OpenBelt = 0xB3,
+ OpenBackpack = 0xB2,
+ OpenSkillsAndAttributes = 0x8F,
+ OpenQuestLog = 0x8E,
+ OpenWorldMap = 0x8C,
+ OpenOptions = 0x8D,
+ OpenHero = 0x8A,
+
+ // Weapon sets
+ CycleEquipment = 0x86,
+ ActivateWeaponSet1 = 0x81,
+ ActivateWeaponSet2,
+ ActivateWeaponSet3,
+ ActivateWeaponSet4,
+
+ DropItem = 0xCD, // drops bundle item >> flags, ashes, etc
+
+ // Chat
+ CharReply = 0xBE,
+ OpenChat = 0xA1,
+ OpenAlliance = 0x88,
+
+ ReverseCamera = 0x90,
+ StrafeLeft = 0x91,
+ StrafeRight = 0x92,
+ TurnLeft = 0xA2,
+ TurnRight = 0xA3,
+ MoveBackward = 0xAC,
+ MoveForward = 0xAD,
+ CancelAction = 0xAF,
+ Interact = 0x80,
+ ReverseDirection = 0xB1,
+ Autorun = 0xB7,
+ Follow = 0xCC,
+
+ // Targeting
+ TargetPartyMember1 = 0x96,
+ TargetPartyMember2,
+ TargetPartyMember3,
+ TargetPartyMember4,
+ TargetPartyMember5,
+ TargetPartyMember6,
+ TargetPartyMember7,
+ TargetPartyMember8,
+ TargetPartyMember9 = 0xC6,
+ TargetPartyMember10,
+ TargetPartyMember11,
+ TargetPartyMember12,
+
+ TargetNearestItem = 0xC3,
+ TargetNextItem = 0xC4,
+ TargetPreviousItem = 0xC5,
+ TargetPartyMemberNext = 0xCA,
+ TargetPartyMemberPrevious = 0xCB,
+ TargetAllyNearest = 0xBC,
+ ClearTarget = 0xE3,
+ TargetSelf = 0xA0, // also 0x96
+ TargetPriorityTarget = 0x9F,
+ TargetNearestEnemy = 0x93,
+ TargetNextEnemy = 0x95,
+ TargetPreviousEnemy = 0x9E,
+
+ ShowOthers = 0x89,
+ ShowTargets = 0x94,
+
+ CameraZoomIn = 0xCE,
+ CameraZoomOut = 0xCF,
+
+ // Party/Hero commands
+ ClearPartyCommands = 0xDB,
+ CommandParty = 0xD6,
+ CommandHero1,
+ CommandHero2,
+ CommandHero3,
+ CommandHero4 = 0x102,
+ CommandHero5,
+ CommandHero6,
+ CommandHero7,
+
+ OpenHero1PetCommander = 0xE0,
+ OpenHero2PetCommander,
+ OpenHero3PetCommander,
+ OpenHero4PetCommander = 0xFE,
+ OpenHero5PetCommander,
+ OpenHero6PetCommander,
+ OpenHero7PetCommander,
+ OpenHeroCommander1 = 0xDC,
+ OpenHeroCommander2,
+ OpenHeroCommander3,
+ OpenPetCommander,
+ OpenHeroCommander4 = 0x126,
+ OpenHeroCommander5,
+ OpenHeroCommander6,
+ OpenHeroCommander7,
+
+ Hero1Skill1 = 0xE5,
+ Hero1Skill2,
+ Hero1Skill3,
+ Hero1Skill4,
+ Hero1Skill5,
+ Hero1Skill6,
+ Hero1Skill7,
+ Hero1Skill8,
+ Hero2Skill1,
+ Hero2Skill2,
+ Hero2Skill3,
+ Hero2Skill4,
+ Hero2Skill5,
+ Hero2Skill6,
+ Hero2Skill7,
+ Hero2Skill8,
+ Hero3Skill1,
+ Hero3Skill2,
+ Hero3Skill3,
+ Hero3Skill4,
+ Hero3Skill5,
+ Hero3Skill6,
+ Hero3Skill7,
+ Hero3Skill8,
+ Hero4Skill1 = 0x106,
+ Hero4Skill2,
+ Hero4Skill3,
+ Hero4Skill4,
+ Hero4Skill5,
+ Hero4Skill6,
+ Hero4Skill7,
+ Hero4Skill8,
+ Hero5Skill1,
+ Hero5Skill2,
+ Hero5Skill3,
+ Hero5Skill4,
+ Hero5Skill5,
+ Hero5Skill6,
+ Hero5Skill7,
+ Hero5Skill8,
+ Hero6Skill1,
+ Hero6Skill2,
+ Hero6Skill3,
+ Hero6Skill4,
+ Hero6Skill5,
+ Hero6Skill6,
+ Hero6Skill7,
+ Hero6Skill8,
+ Hero7Skill1,
+ Hero7Skill2,
+ Hero7Skill3,
+ Hero7Skill4,
+ Hero7Skill5,
+ Hero7Skill6,
+ Hero7Skill7,
+ Hero7Skill8,
+
+ // Skills
+ UseSkill1 = 0xA4,
+ UseSkill2,
+ UseSkill3,
+ UseSkill4,
+ UseSkill5,
+ UseSkill6,
+ UseSkill7,
+ UseSkill8
+};
diff --git a/Daybreak.API/Interop/GuildWars/Uuid.cs b/Daybreak.API/Interop/GuildWars/Uuid.cs
new file mode 100644
index 00000000..ef1245e7
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/Uuid.cs
@@ -0,0 +1,108 @@
+using System.Globalization;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+public readonly struct Uuid(
+ uint timeLow,
+ ushort timeMid,
+ ushort timeHighAndVersion,
+ byte clockSqHi,
+ byte clockSqLow,
+ byte node0,
+ byte node1,
+ byte node2,
+ byte node3,
+ byte node4,
+ byte node5) : IEquatable
+{
+ public const uint NodeLength = 6;
+
+ public readonly uint TimeLow = timeLow;
+ public readonly ushort TimeMid = timeMid;
+ public readonly ushort TimeHighAndVersion = timeHighAndVersion;
+ public readonly byte ClockSqHiAndRes = clockSqHi;
+ public readonly byte ClockSqLow = clockSqLow;
+ public readonly byte Node0 = node0;
+ public readonly byte Node1 = node1;
+ public readonly byte Node2 = node2;
+ public readonly byte Node3 = node3;
+ public readonly byte Node4 = node4;
+ public readonly byte Node5 = node5;
+
+ public bool Equals(Uuid other)
+ {
+ return this.TimeLow == other.TimeLow &&
+ this.TimeMid == other.TimeMid &&
+ this.TimeHighAndVersion == other.TimeHighAndVersion &&
+ this.ClockSqHiAndRes == other.ClockSqHiAndRes &&
+ this.ClockSqLow == other.ClockSqLow &&
+ this.Node0 == other.Node0 &&
+ this.Node1 == other.Node1 &&
+ this.Node2 == other.Node2 &&
+ this.Node3 == other.Node3 &&
+ this.Node4 == other.Node4 &&
+ this.Node5 == other.Node5;
+ }
+
+ public override bool Equals(object? obj)
+ {
+ return obj is Uuid uuid && this.Equals(uuid);
+ }
+
+ public static bool operator ==(Uuid left, Uuid right)
+ {
+ return left.Equals(right);
+ }
+
+ public static bool operator !=(Uuid left, Uuid right)
+ {
+ return !(left == right);
+ }
+
+ public static bool TryParse(string uuidString, out Uuid uuid)
+ {
+ if (uuidString.Length != 36)
+ {
+ uuid = default;
+ return false;
+ }
+
+ var parts = uuidString.Split('-');
+ if (parts.Length != 5)
+ {
+ uuid = default;
+ return false;
+ }
+
+ if (!uint.TryParse(parts[0], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var timeLow) ||
+ !ushort.TryParse(parts[1], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var timeMid) ||
+ !ushort.TryParse(parts[2], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var timeHighAndVersion) ||
+ !byte.TryParse(parts[3].AsSpan(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var clockSqHi) ||
+ !byte.TryParse(parts[3].AsSpan(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var clockSqLow) ||
+ !byte.TryParse(parts[4].AsSpan(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var node0) ||
+ !byte.TryParse(parts[4].AsSpan(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var node1) ||
+ !byte.TryParse(parts[4].AsSpan(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var node2) ||
+ !byte.TryParse(parts[4].AsSpan(6, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var node3) ||
+ !byte.TryParse(parts[4].AsSpan(8, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var node4) ||
+ !byte.TryParse(parts[4].AsSpan(10, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var node5))
+ {
+ uuid = default;
+ return false;
+ }
+
+ uuid = new Uuid(timeLow, timeMid, timeHighAndVersion, clockSqHi, clockSqLow, node0, node1, node2, node3, node4, node5);
+ return true;
+ }
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(this.TimeLow, this.TimeMid, this.TimeHighAndVersion, this.ClockSqHiAndRes, this.ClockSqLow, [this.Node0, this.Node1, this.Node2, this.Node3, this.Node4, this.Node5]);
+ }
+
+ public override string ToString()
+ {
+ return $"{this.TimeLow:X8}-{this.TimeMid:X4}-{this.TimeHighAndVersion:X4}-{this.ClockSqHiAndRes:X2}{this.ClockSqLow:X2}-{this.Node0:X2}{this.Node1:X2}{this.Node2:X2}{this.Node3:X2}{this.Node4:X2}{this.Node5:X2}";
+ }
+
+ public readonly static Uuid Zero = new(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
+}
diff --git a/Daybreak.API/Interop/GuildWars/WorldContext.cs b/Daybreak.API/Interop/GuildWars/WorldContext.cs
new file mode 100644
index 00000000..f4dc1f50
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/WorldContext.cs
@@ -0,0 +1,173 @@
+using System.Numerics;
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop.GuildWars;
+
+[StructLayout(LayoutKind.Explicit, Pack = 1)]
+public readonly unsafe struct WorldContext
+{
+ [FieldOffset(0x0000)]
+ public readonly AccountInfoContext* AccountInfo;
+
+ [FieldOffset(0x0024)]
+ public readonly GuildWarsArray MerchItems;
+
+ [FieldOffset(0x0034)]
+ public readonly GuildWarsArray MerchItems2;
+
+ [FieldOffset(0x007C)]
+ public readonly GuildWarsArray MapAgents;
+
+ [FieldOffset(0x009C)]
+ public readonly Vector3 AllFlag;
+
+ [FieldOffset(0x00AC)]
+ public readonly GuildWarsArray Attributes;
+
+ [FieldOffset(0x0508)]
+ public readonly GuildWarsArray PartyEffects;
+
+ [FieldOffset(0x0528)]
+ public readonly uint ActiveQuestId;
+
+ [FieldOffset(0x052C)]
+ public readonly GuildWarsArray QuestLog;
+
+ [FieldOffset(0x0564)]
+ public readonly GuildWarsArray MissionObjectives;
+
+ [FieldOffset(0x0574)]
+ public readonly GuildWarsArray HenchmenAgentIds;
+
+ [FieldOffset(0x0584)]
+ public readonly GuildWarsArray HeroFlags;
+
+ [FieldOffset(0x0594)]
+ public readonly GuildWarsArray HeroInfos;
+
+ [FieldOffset(0x05A4)]
+ public readonly GuildWarsArray CartographedAreas;
+
+ [FieldOffset(0x05BC)]
+ public readonly GuildWarsArray ControlledMinions;
+
+ [FieldOffset(0x05CC)]
+ public readonly GuildWarsArray MissionsCompleted;
+
+ [FieldOffset(0x05DC)]
+ public readonly GuildWarsArray MissionsBonus;
+
+ [FieldOffset(0x05EC)]
+ public readonly GuildWarsArray MissionsCompletedHardMode;
+
+ [FieldOffset(0x05FC)]
+ public readonly GuildWarsArray MissionsBonusHardMode;
+
+ [FieldOffset(0x060C)]
+ public readonly GuildWarsArray UnlockedMap;
+
+ [FieldOffset(0x062C)]
+ public readonly GuildWarsArray PartyMorale;
+
+ [FieldOffset(0x067C)]
+ public readonly uint PlayerNumber;
+
+ [FieldOffset(0x0680)]
+ public readonly PlayerControlledCharContext* PlayerControlledChar;
+
+ [FieldOffset(0x0684)]
+ public readonly uint HardModeUnlocked;
+
+ [FieldOffset(0x06AC)]
+ public readonly GuildWarsArray Pets;
+
+ [FieldOffset(0x06BC)]
+ public readonly GuildWarsArray Professions;
+
+ [FieldOffset(0x06F0)]
+ public readonly GuildWarsArray Skillbars;
+
+ [FieldOffset(0x0700)]
+ public readonly GuildWarsArray LearnableCharacterSkills;
+
+ [FieldOffset(0x0710)]
+ public readonly GuildWarsArray UnlockedCharacterSkills;
+
+ [FieldOffset(0x0720)]
+ public readonly GuildWarsArray DupeSkills;
+
+ [FieldOffset(0x0740)]
+ public readonly uint Experience;
+
+ [FieldOffset(0x0748)]
+ public readonly uint CurrentKurzick;
+
+ [FieldOffset(0x0750)]
+ public readonly uint TotalKurzick;
+
+ [FieldOffset(0x0758)]
+ public readonly uint CurrentLuxon;
+
+ [FieldOffset(0x0760)]
+ public readonly uint TotalLuxon;
+
+ [FieldOffset(0x0768)]
+ public readonly uint CurrentImperial;
+
+ [FieldOffset(0x0770)]
+ public readonly uint TotalImperial;
+
+ [FieldOffset(0x0788)]
+ public readonly uint Level;
+
+ [FieldOffset(0x0790)]
+ public readonly uint Morale;
+
+ [FieldOffset(0x0798)]
+ public readonly uint CurrentBalthazar;
+
+ [FieldOffset(0x07A0)]
+ public readonly uint TotalBalthazar;
+
+ [FieldOffset(0x07A8)]
+ public readonly uint CurrentSkillPoints;
+
+ [FieldOffset(0x07B0)]
+ public readonly uint TotalSkillPoints;
+
+ [FieldOffset(0x07B8)]
+ public readonly uint MaxKurzick;
+
+ [FieldOffset(0x07BC)]
+ public readonly uint MaxLuxon;
+
+ [FieldOffset(0x07C0)]
+ public readonly uint MaxBalthazar;
+
+ [FieldOffset(0x07C4)]
+ public readonly uint MaxImperial;
+
+ [FieldOffset(0x07CC)]
+ public readonly GuildWarsArray AgentInfos;
+
+ [FieldOffset(0x07FC)]
+ public readonly GuildWarsArray Npcs;
+
+ [FieldOffset(0x080C)]
+ public readonly GuildWarsArray Players;
+
+ [FieldOffset(0x081C)]
+ public readonly GuildWarsArray Titles;
+
+ [FieldOffset(0x082C)]
+ public readonly GuildWarsArray TitleTiers;
+
+ [FieldOffset(0x083C)]
+ public readonly GuildWarsArray VanquishedAreas;
+
+ [FieldOffset(0x084C)]
+ public readonly uint FoesKilled;
+
+ [FieldOffset(0x0850)]
+ public readonly uint FoesToKill;
+}
diff --git a/Daybreak.API/Interop/GuildWars/WrappedPointer.cs b/Daybreak.API/Interop/GuildWars/WrappedPointer.cs
new file mode 100644
index 00000000..646d8f90
--- /dev/null
+++ b/Daybreak.API/Interop/GuildWars/WrappedPointer.cs
@@ -0,0 +1,37 @@
+namespace Daybreak.API.Interop.GuildWars;
+
+public readonly unsafe struct WrappedPointer(T* pointer)
+ where T : unmanaged
+{
+ public readonly T* Pointer = pointer;
+
+ public bool IsNull => this.Pointer is null;
+
+ public static implicit operator WrappedPointer(T* pointer) => new(pointer);
+
+ public static implicit operator T*(WrappedPointer wrappedPointer) => wrappedPointer.Pointer;
+
+ public static bool operator ==(T* left, WrappedPointer right) => left == right.Pointer;
+
+ public static bool operator !=(T* left, WrappedPointer right) => left != right.Pointer;
+
+ public static bool operator ==(WrappedPointer left, T* right) => left.Pointer == right;
+
+ public static bool operator !=(WrappedPointer left, T* right) => left.Pointer != right;
+
+ public static bool operator ==(WrappedPointer left, WrappedPointer right) => left.Pointer == right.Pointer;
+
+ public static bool operator !=(WrappedPointer left, WrappedPointer right) => left.Pointer != right.Pointer;
+
+ public override int GetHashCode() => this.Pointer is null ? 0 : this.Pointer->GetHashCode();
+
+ public override bool Equals(object? obj)
+ {
+ if (obj is WrappedPointer wrappedPointer)
+ {
+ return this.Pointer == wrappedPointer.Pointer;
+ }
+
+ return false;
+ }
+}
diff --git a/Daybreak.API/Interop/IHook.cs b/Daybreak.API/Interop/IHook.cs
new file mode 100644
index 00000000..acc053ad
--- /dev/null
+++ b/Daybreak.API/Interop/IHook.cs
@@ -0,0 +1,9 @@
+namespace Daybreak.API.Interop;
+
+public interface IHook
+ : IDisposable where T : Delegate
+{
+ public T Continue { get; }
+
+ public nuint ContinueAddress { get; }
+}
diff --git a/Daybreak.API/Interop/ManagedFrame.cs b/Daybreak.API/Interop/ManagedFrame.cs
new file mode 100644
index 00000000..cb60eeee
--- /dev/null
+++ b/Daybreak.API/Interop/ManagedFrame.cs
@@ -0,0 +1,19 @@
+using Daybreak.API.Interop.GuildWars;
+
+namespace Daybreak.API.Interop;
+
+public readonly struct ManagedFrame(
+ WrappedPointer frame,
+ Action closeFrame) : IDisposable
+{
+ private readonly Action closeFrame = closeFrame;
+
+ public static ManagedFrame Null => new(null, () => { });
+
+ public readonly WrappedPointer Frame = frame;
+
+ public void Dispose()
+ {
+ this.closeFrame();
+ }
+}
diff --git a/Daybreak.API/Interop/MemoryPackExtensions.cs b/Daybreak.API/Interop/MemoryPackExtensions.cs
new file mode 100644
index 00000000..7c191c89
--- /dev/null
+++ b/Daybreak.API/Interop/MemoryPackExtensions.cs
@@ -0,0 +1,31 @@
+using MemoryPack;
+using System.Buffers;
+
+namespace Daybreak.API.Interop;
+
+public static class MemoryPackExtensions
+{
+ /// Serialize into .
+ /// Number of bytes written.
+ public static int SerializeToSpan(T value, Span destination)
+ {
+ var dummy = DummyBufferWriter.Instance;
+
+ using var state = MemoryPackWriterOptionalStatePool.Rent(null);
+
+ var writer = new MemoryPackWriter(ref dummy, destination, state);
+
+ writer.WriteValue(value);
+ writer.Flush();
+
+ return writer.WrittenCount;
+ }
+
+ private sealed class DummyBufferWriter : IBufferWriter
+ {
+ public static readonly DummyBufferWriter Instance = new();
+ public void Advance(int count) { /* ignore – we never overflow destination */ }
+ public Memory GetMemory(int sizeHint = 0) => throw new NotSupportedException();
+ public Span GetSpan(int sizeHint = 0) => throw new NotSupportedException();
+ }
+}
diff --git a/Daybreak.API/Interop/PointerValue.cs b/Daybreak.API/Interop/PointerValue.cs
new file mode 100644
index 00000000..84153744
--- /dev/null
+++ b/Daybreak.API/Interop/PointerValue.cs
@@ -0,0 +1,78 @@
+using Daybreak.API.Converters;
+using System.Text.Json.Serialization;
+
+namespace Daybreak.API.Interop;
+
+[JsonConverter(typeof(PointerValueConverter))]
+public readonly struct PointerValue(nuint address) : IEquatable, IEquatable, IEquatable
+{
+ public readonly nuint Address = address;
+
+ public bool Equals(PointerValue other)
+ {
+ return this.Address.Equals(other.Address);
+ }
+
+ public bool Equals(nuint other)
+ {
+ return this.Address.Equals(other);
+ }
+
+ public bool Equals(nint other)
+ {
+ return this.Address.Equals((nuint)other);
+ }
+
+ public override string ToString()
+ {
+ return $"0x{this.Address:X8}";
+ }
+
+ public override bool Equals(object? obj) => obj is PointerValue value && this.Equals(value);
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(this.Address);
+ }
+
+ public static bool operator ==(PointerValue left, PointerValue right)
+ {
+ return left.Equals(right);
+ }
+
+ public static bool operator !=(PointerValue left, PointerValue right)
+ {
+ return !(left == right);
+ }
+
+ public static implicit operator PointerValue(nuint address)
+ {
+ return new PointerValue(address);
+ }
+
+ public static implicit operator PointerValue(nint address)
+ {
+ return new PointerValue((nuint)address);
+ }
+
+ public static implicit operator PointerValue(int address)
+ {
+ return new PointerValue((nuint)address);
+ }
+
+ public static implicit operator PointerValue(uint address)
+ {
+ return new PointerValue(address);
+ }
+
+ public static PointerValue Parse(string s)
+ {
+ if (s.StartsWith("0x", StringComparison.InvariantCultureIgnoreCase)
+ && nuint.TryParse(s.AsSpan(2), System.Globalization.NumberStyles.HexNumber, null, out var v))
+ {
+ return new PointerValue(v);
+ }
+
+ throw new FormatException($"Invalid pointer format: {s}");
+ }
+}
diff --git a/Daybreak.API/Interop/UnmanagedStruct.cs b/Daybreak.API/Interop/UnmanagedStruct.cs
new file mode 100644
index 00000000..9c2aa462
--- /dev/null
+++ b/Daybreak.API/Interop/UnmanagedStruct.cs
@@ -0,0 +1,22 @@
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Interop;
+
+public sealed class UnmanagedStruct : IDisposable
+ where T : struct
+{
+ public nint Address { get; private set; }
+
+ public UnmanagedStruct(T value)
+ {
+ this.Address = Marshal.AllocHGlobal(Marshal.SizeOf());
+ Marshal.StructureToPtr(value, this.Address, false);
+ }
+
+ public void Dispose()
+ {
+ Marshal.DestroyStructure(this.Address);
+ Marshal.FreeHGlobal(this.Address);
+ this.Address = 0x0;
+ }
+}
diff --git a/Daybreak.API/Logging/LoggingEnrichmentMiddleware.cs b/Daybreak.API/Logging/LoggingEnrichmentMiddleware.cs
new file mode 100644
index 00000000..69e79da1
--- /dev/null
+++ b/Daybreak.API/Logging/LoggingEnrichmentMiddleware.cs
@@ -0,0 +1,29 @@
+using Microsoft.CorrelationVector;
+using Microsoft.Extensions.Options;
+using Net.Sdk.Web;
+using Net.Sdk.Web.Options;
+using Serilog.Context;
+
+namespace Daybreak.API.Logging;
+
+public sealed class LoggingEnrichmentMiddleware(IOptions options)
+ : IMiddleware
+{
+ private readonly CorrelationVectorOptions options = options.Value;
+
+ public Task InvokeAsync(HttpContext context, RequestDelegate next)
+ {
+ if (!context.Request.Headers.TryGetValue(this.options.Header, out var value) ||
+ value.FirstOrDefault() is not string cvString ||
+ CorrelationVector.Parse(cvString) is not CorrelationVector cv)
+ {
+ cv = new CorrelationVector();
+ }
+
+ cv = CorrelationVector.Extend(cv.ToString());
+ context.SetCorrelationVector(cv);
+ LogContext.PushProperty("CorrelationVector", cv);
+ HeaderDictionaryExtensions.Append(context.Response.Headers, this.options.Header, context.GetCorrelationVector().ToString());
+ return next(context);
+ }
+}
diff --git a/Daybreak.API/Logging/WebApplicationBuilderExtensions.cs b/Daybreak.API/Logging/WebApplicationBuilderExtensions.cs
new file mode 100644
index 00000000..28944940
--- /dev/null
+++ b/Daybreak.API/Logging/WebApplicationBuilderExtensions.cs
@@ -0,0 +1,31 @@
+using Serilog.Settings.Configuration;
+using Serilog.Sinks.SystemConsole.Themes;
+using Serilog;
+using Net.Sdk.Web.Options;
+
+namespace Daybreak.API.Logging;
+
+public static class WebApplicationBuilderExtensions
+{
+ public static WebApplicationBuilder WithLogging(this WebApplicationBuilder builder)
+ {
+ builder.Logging.ClearProviders();
+ builder.Logging.AddSerilog(new LoggerConfiguration()
+ .ReadFrom.Configuration(builder.Configuration, new ConfigurationReaderOptions
+ {
+ SectionName = "Logging"
+ })
+ .Enrich.FromLogContext()
+ .WriteTo.Console(
+ outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss}] {Level:u4}: [{SourceContext}] [{CorrelationVector}] {NewLine}{Message:lj}{NewLine}{Exception}",
+ theme: AnsiConsoleTheme.Sixteen)
+ .WriteTo.File(
+ outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss}] {Level:u4}: [{SourceContext}] [{CorrelationVector}] {NewLine}{Message:lj}{NewLine}{Exception}",
+ path: "Daybreak.API.log")
+ .CreateLogger());
+ builder.Services.AddScoped();
+ builder.Services.AddOptions()
+ .Bind(builder.Configuration.GetSection("Correlation"));
+ return builder;
+ }
+}
diff --git a/Daybreak.API/Logging/WebApplicationExtensions.cs b/Daybreak.API/Logging/WebApplicationExtensions.cs
new file mode 100644
index 00000000..bbe4c677
--- /dev/null
+++ b/Daybreak.API/Logging/WebApplicationExtensions.cs
@@ -0,0 +1,10 @@
+namespace Daybreak.API.Logging;
+
+public static class WebApplicationExtensions
+{
+ public static WebApplication UseLogging(this WebApplication app)
+ {
+ app.UseMiddleware();
+ return app;
+ }
+}
diff --git a/Daybreak.API/Models/AddressState.cs b/Daybreak.API/Models/AddressState.cs
new file mode 100644
index 00000000..779ceab1
--- /dev/null
+++ b/Daybreak.API/Models/AddressState.cs
@@ -0,0 +1,9 @@
+using Daybreak.API.Interop;
+
+namespace Daybreak.API.Models;
+
+public sealed class AddressState
+{
+ public required string Name { get; init; }
+ public required PointerValue Address { get; init; }
+}
diff --git a/Daybreak.API/Models/AsyncRefreshCache.cs b/Daybreak.API/Models/AsyncRefreshCache.cs
new file mode 100644
index 00000000..1d4cf364
--- /dev/null
+++ b/Daybreak.API/Models/AsyncRefreshCache.cs
@@ -0,0 +1,38 @@
+using System.Core.Extensions;
+using System.Extensions;
+
+namespace Daybreak.API.Models;
+
+public sealed class AsyncRefreshCache(TimeSpan refreshRate, Func> refreshFunc)
+{
+ private readonly Func> refreshFunc = refreshFunc.ThrowIfNull();
+
+ private readonly SemaphoreSlim semaphoreSlim = new(1);
+
+ private T? cache;
+ private DateTime lastUpdateDateTime = DateTime.MinValue;
+ private TimeSpan refreshRate = refreshRate;
+
+ public async ValueTask GetAsync(CancellationToken cancellationToken)
+ {
+ if (this.cache is not null && DateTime.UtcNow - this.lastUpdateDateTime < this.refreshRate)
+ {
+ return this.cache;
+ }
+
+ using var ctx = await this.semaphoreSlim.Acquire(cancellationToken);
+ if (DateTime.UtcNow - this.lastUpdateDateTime > this.refreshRate ||
+ this.cache is null)
+ {
+ this.lastUpdateDateTime = DateTime.UtcNow;
+ this.cache = await this.refreshFunc(cancellationToken);
+ }
+
+ return this.cache;
+ }
+
+ public void UpdateRefreshRate(TimeSpan newRefreshRate)
+ {
+ this.refreshRate = newRefreshRate;
+ }
+}
diff --git a/Daybreak.API/Models/ByteConsumerEntry.cs b/Daybreak.API/Models/ByteConsumerEntry.cs
new file mode 100644
index 00000000..d2e55433
--- /dev/null
+++ b/Daybreak.API/Models/ByteConsumerEntry.cs
@@ -0,0 +1,23 @@
+using System.Core.Extensions;
+
+namespace Daybreak.API.Models;
+
+public sealed class ByteConsumerEntry(
+ Guid id, TimeSpan freq, Action> handler)
+{
+ private readonly Action> handler = handler.ThrowIfNull();
+
+ private DateTimeOffset lastConsume = DateTimeOffset.MinValue;
+
+ public Guid Id { get; } = id;
+ public TimeSpan Frequency { get; } = freq;
+
+ public void TryConsume(DateTimeOffset currentTime, ReadOnlySpan value)
+ {
+ if (currentTime - this.lastConsume >= this.Frequency)
+ {
+ this.handler(value);
+ this.lastConsume = currentTime;
+ }
+ }
+}
diff --git a/Daybreak.API/Models/CallbackRegistration.cs b/Daybreak.API/Models/CallbackRegistration.cs
new file mode 100644
index 00000000..b7d17c85
--- /dev/null
+++ b/Daybreak.API/Models/CallbackRegistration.cs
@@ -0,0 +1,14 @@
+using System.Core.Extensions;
+
+namespace Daybreak.API.Models;
+
+public sealed class CallbackRegistration(Guid uid, Action onDispose) : IDisposable
+{
+ private readonly Guid uid = uid;
+ private readonly Action onDispose = onDispose.ThrowIfNull();
+
+ public void Dispose()
+ {
+ this.onDispose();
+ }
+}
diff --git a/Daybreak.API/Models/Channel.cs b/Daybreak.API/Models/Channel.cs
new file mode 100644
index 00000000..945a27cf
--- /dev/null
+++ b/Daybreak.API/Models/Channel.cs
@@ -0,0 +1,24 @@
+namespace Daybreak.API.Models;
+
+public enum Channel : uint
+{
+ Alliance = 0,
+ Allies = 1, // coop with two groups for instance.
+ GWCA1 = 2,
+ All = 3,
+ GWCA2 = 4,
+ Moderator = 5,
+ Emote = 6,
+ Warning = 7, // shows in the middle of the screen and does not parse tags
+ GWCA3 = 8,
+ Guild = 9,
+ Global = 10,
+ Group = 11,
+ Trade = 12,
+ Advisory = 13,
+ Whisper = 14,
+ Count,
+
+ // non-standard channel, but useful.
+ Command
+};
diff --git a/Daybreak.API/Models/HealthCheckEntryResponse.cs b/Daybreak.API/Models/HealthCheckEntryResponse.cs
new file mode 100644
index 00000000..195d2452
--- /dev/null
+++ b/Daybreak.API/Models/HealthCheckEntryResponse.cs
@@ -0,0 +1,14 @@
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Daybreak.API.Models;
+
+public sealed class HealthCheckEntryResponse
+{
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ public required HealthStatus Status { get; set; }
+ public required string Description { get; set; }
+ public required Dictionary Data { get; set; }
+ public required List Tags { get; set; }
+}
diff --git a/Daybreak.API/Models/HealthCheckResponse.cs b/Daybreak.API/Models/HealthCheckResponse.cs
new file mode 100644
index 00000000..1e3fd4ef
--- /dev/null
+++ b/Daybreak.API/Models/HealthCheckResponse.cs
@@ -0,0 +1,12 @@
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using System.Text.Json.Serialization;
+
+namespace Daybreak.API.Models;
+
+public sealed class HealthCheckResponse
+{
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ public required HealthStatus Status { get; set; }
+ public required TimeSpan TotalDuration { get; set; }
+ public required Dictionary Entries { get; set; }
+}
diff --git a/Daybreak.API/Models/HookState.cs b/Daybreak.API/Models/HookState.cs
new file mode 100644
index 00000000..b286807c
--- /dev/null
+++ b/Daybreak.API/Models/HookState.cs
@@ -0,0 +1,12 @@
+using Daybreak.API.Interop;
+
+namespace Daybreak.API.Models;
+
+public sealed class HookState
+{
+ public required bool Hooked { get; init; }
+ public required string Name { get; init; }
+ public required PointerValue TargetAddress { get; init; }
+ public required PointerValue ContinueAddress { get; init; }
+ public required PointerValue DetourAddress { get; init; }
+}
diff --git a/Daybreak.API/Models/IWorkItem.cs b/Daybreak.API/Models/IWorkItem.cs
new file mode 100644
index 00000000..8ea88c54
--- /dev/null
+++ b/Daybreak.API/Models/IWorkItem.cs
@@ -0,0 +1,9 @@
+namespace Daybreak.API.Models;
+
+public interface IWorkItem
+{
+ CancellationToken CancellationToken { get; }
+ void Execute();
+ void Cancel();
+ void Exception(Exception ex);
+}
diff --git a/Daybreak.API/Models/UIMessage.cs b/Daybreak.API/Models/UIMessage.cs
new file mode 100644
index 00000000..018e2893
--- /dev/null
+++ b/Daybreak.API/Models/UIMessage.cs
@@ -0,0 +1,154 @@
+namespace Daybreak.API.Models;
+
+public enum UIMessage : uint
+{
+ None = 0x0,
+ Resize = 0x8,
+ InitFrame = 0x9,
+ DestroyFrame = 0xb,
+ KeyDown = 0x1e, // wparam = UIPacket::kKeyAction*
+ KeyUp = 0x20, // wparam = UIPacket::kKeyAction*
+ MouseClick = 0x22, // wparam = UIPacket::kMouseClick*
+ MouseClick2 = 0x2e, // wparam = UIPacket::kMouseAction*
+ MouseAction = 0x2f, // wparam = UIPacket::kMouseAction*
+ SetLayout = 0x33,
+ FrameMessage_0x47 = 0x47, // Multiple uses depending on frame
+ UpdateAgentEffects = 0x10000000 | 0x9,
+ RerenderAgentModel = 0x10000000 | 0x7, // wparam = uint32_t agent_id
+ AgentSpeechBubble = 0x10000000 | 0x17,
+ ShowAgentNameTag = 0x10000000 | 0x19, // wparam = AgentNameTagInfo*
+ HideAgentNameTag = 0x10000000 | 0x1A,
+ SetAgentNameTagAttribs = 0x10000000 | 0x1B, // wparam = AgentNameTagInfo*
+ ChangeTarget = 0x10000000 | 0x20, // wparam = UIPacket::kChangeTarget*
+ AgentStartCasting = 0x10000000 | 0x27, // wparam = UIPacket::kAgentStartCasting*
+ ShowMapEntryMessage = 0x10000000 | 0x29, // wparam = { wchar_t* title, wchar_t* subtitle }
+ SetCurrentPlayerData = 0x10000000 | 0x2A, // fired after setting the worldcontext player name
+ PostProcessingEffect = 0x10000000 | 0x34, // Triggered when drunk. wparam = UIPacket::kPostProcessingEffect
+ HeroAgentAdded = 0x10000000 | 0x38, // hero assigned to agent/inventory/ai mode
+ HeroDataAdded = 0x10000000 | 0x39, // hero info received from server (name, level etc)
+ ShowXunlaiChest = 0x10000000 | 0x40,
+ MinionCountUpdated = 0x10000000 | 0x46,
+ MoraleChange = 0x10000000 | 0x47, // wparam = {agent id, morale percent }
+ LoginStateChanged = 0x10000000 | 0x50, // wparam = {bool is_logged_in, bool unk }
+ EffectAdd = 0x10000000 | 0x55, // wparam = {agent_id, GW::Effect*}
+ EffectRenew = 0x10000000 | 0x56, // wparam = GW::Effect*
+ EffectRemove = 0x10000000 | 0x57, // wparam = effect id
+ SkillActivated = 0x10000000 | 0x5b, // wparam ={ uint32_t agent_id , uint32_t skill_id }
+ UpdateSkillbar = 0x10000000 | 0x5E, // wparam ={ uint32_t agent_id , ... }
+ UpdateSkillsAvailable = 0x10000000 | 0x5f, // Triggered on a skill unlock, profession change or map load
+ TitleProgressUpdated = 0x10000000 | 0x65, // wparam = title_id
+ ExperienceGained = 0x10000000 | 0x66, // wparam = experience amount
+ WriteToChatLog = 0x10000000 | 0x7E, // wparam = UIPacket::kWriteToChatLog*. Triggered by the game when it wants to add a new message to chat.
+ WriteToChatLogWithSender = 0x10000000 | 0x7f, // wparam = UIPacket::kWriteToChatLogWithSender*. Triggered by the game when it wants to add a new message to chat.
+ AllyOrGuildMessage = 0x10000000 | 0x80, // wparam = UIPacket::kAllyOrGuildMessage*
+ PlayerChatMessage = 0x10000000 | 0x81, // wparam = UIPacket::kPlayerChatMessage*
+ FloatingWindowMoved = 0x10000000 | 0x83, // wparam = frame_id
+ FriendUpdated = 0x10000000 | 0x89, // wparam = { GW::Friend*, ... }
+ MapLoaded = 0x10000000 | 0x8A,
+ OpenWhisper = 0x10000000 | 0x90, // wparam = wchar* name
+ Logout = 0x10000000 | 0x9b, // wparam = { bool unknown, bool character_select }
+ CompassDraw = 0x10000000 | 0x9c, // wparam = UIPacket::kCompassDraw*
+ OnScreenMessage = 0x10000000 | 0xA0, // wparam = wchar_** encoded_string
+ DialogBody = 0x10000000 | 0xA4, // wparam = DialogBodyInfo*
+ DialogButton = 0x10000000 | 0xA1, // wparam = DialogButtonInfo*
+ TargetNPCPartyMember = 0x10000000 | 0xB1, // wparam = { uint32_t unk, uint32_t agent_id }
+ TargetPlayerPartyMember = 0x10000000 | 0xB2, // wparam = { uint32_t unk, uint32_t player_number }
+ VendorWindow = 0x10000000 | 0xB3, // wparam = UIPacket::kVendorWindow
+ VendorItems = 0x10000000 | 0xB7, // wparam = UIPacket::kVendorItems
+ VendorTransComplete = 0x10000000 | 0xB9, // wparam = *TransactionType
+ VendorQuote = 0x10000000 | 0xBB, // wparam = UIPacket::kVendorQuote
+ StartMapLoad = 0x10000000 | 0xC0, // wparam = { uint32_t map_id, ...}
+ WorldMapUpdated = 0x10000000 | 0xC5, // Triggered when an area in the world map has been discovered/updated
+ GuildMemberUpdated = 0x10000000 | 0xD8, // wparam = { GuildPlayer::name_ptr }
+ ShowHint = 0x10000000 | 0xDF, // wparam = { uint32_t icon_type, wchar_t* message_enc }
+ WeaponSetSwapComplete = 0x10000000 | 0xE7, // wparam = UIPacket::kWeaponSwap*
+ WeaponSetSwapCancel = 0x10000000 | 0xE8,
+ WeaponSetUpdated = 0x10000000 | 0xE9,
+ UpdateGoldCharacter = 0x10000000 | 0xEA, // wparam = { uint32_t unk, uint32_t gold_character }
+ UpdateGoldStorage = 0x10000000 | 0xEB, // wparam = { uint32_t unk, uint32_t gold_storage }
+ InventorySlotUpdated = 0x10000000 | 0xEC, // undocumented. Triggered when an item is moved into a slot
+ EquipmentSlotUpdated = 0x10000000 | 0xED, // undocumented. Triggered when an item is moved into a slot
+ InventorySlotCleared = 0x10000000 | 0xEF, // undocumented. Triggered when an item has been removed from a slot
+ EquipmentSlotCleared = 0x10000000 | 0xF0, // undocumented. Triggered when an item has been removed from a slot
+ PvPWindowContent = 0x10000000 | 0xF8,
+ PreStartSalvage = 0x10000000 | 0x100, // { uint32_t item_id, uint32_t kit_id }
+ TradePlayerUpdated = 0x10000000 | 0x103, // wparam = GW::TraderPlayer*
+ ItemUpdated = 0x10000000 | 0x104, // wparam = UIPacket::kItemUpdated*
+ MapChange = 0x10000000 | 0x10F, // wparam = map id
+ CalledTargetChange = 0x10000000 | 0x113, // wparam = { player_number, target_id }
+ ErrorMessage = 0x10000000 | 0x117, // wparam = { int error_index, wchar_t* error_encoded_string }
+ PartyHardModeChanged = 0x10000000 | 0x118, // wparam = { int is_hard_mode }
+ PartyAddHenchman = 0x10000000 | 0x119,
+ PartyRemoveHenchman = 0x10000000 | 0x11a,
+ PartyAddHero = 0x10000000 | 0x11c,
+ PartyRemoveHero = 0x10000000 | 0x11d,
+ PartyAddPlayer = 0x10000000 | 0x122,
+ PartyRemovePlayer = 0x10000000 | 0x124,
+ DisableEnterMissionBtn = 0x10000000 | 0x128, // wparam = boolean (1 = disabled, 0 = enabled)
+ ShowCancelEnterMissionBtn = 0x10000000 | 0x12b,
+ PartyDefeated = 0x10000000 | 0x12d,
+ PartySearchInviteReceived = 0x10000000 | 0x135, // wparam = UIPacket::kPartySearchInviteReceived*
+ PartySearchInviteSent = 0x10000000 | 0x137,
+ PartyShowConfirmDialog = 0x10000000 | 0x138, // wparam = UIPacket::kPartyShowConfirmDialog
+ PreferenceEnumChanged = 0x10000000 | 0x13E, // wparam = UiPacket::kPreferenceEnumChanged
+ PreferenceFlagChanged = 0x10000000 | 0x13F, // wparam = UiPacket::kPreferenceFlagChanged
+ PreferenceValueChanged = 0x10000000 | 0x140, // wparam = UiPacket::kPreferenceValueChanged
+ UIPositionChanged = 0x10000000 | 0x141, // wparam = UIPacket::kUIPositionChanged
+ QuestAdded = 0x10000000 | 0x149, // wparam = { quest_id, ... }
+ QuestDetailsChanged = 0x10000000 | 0x14A, // wparam = { quest_id, ... }
+ ClientActiveQuestChanged = 0x10000000 | 0x14C, // wparam = { quest_id, ... }. Triggered when the game requests the current quest to change
+ ServerActiveQuestChanged = 0x10000000 | 0x14E, // wparam = UIPacket::kServerActiveQuestChanged*. Triggered when the server requests the current quest to change
+ UnknownQuestRelated = 0x10000000 | 0x14F,
+ DungeonComplete = 0x10000000 | 0x151, // undocumented
+ MissionComplete = 0x10000000 | 0x152, // undocumented
+ VanquishComplete = 0x10000000 | 0x154, // undocumented
+ ObjectiveAdd = 0x10000000 | 0x155, // wparam = UIPacket::kObjectiveAdd*
+ ObjectiveComplete = 0x10000000 | 0x156, // wparam = UIPacket::kObjectiveComplete*
+ ObjectiveUpdated = 0x10000000 | 0x157, // wparam = UIPacket::kObjectiveUpdated*
+ TradeSessionStart = 0x10000000 | 0x160, // wparam = { trade_state, player_number }
+ TradeSessionUpdated = 0x10000000 | 0x166, // no args
+ TriggerLogoutPrompt = 0x10000000 | 0x16C, // no args
+ ToggleOptionsWindow = 0x10000000 | 0x16D, // no args
+ CheckUIState = 0x10000000 | 0x170, // Undocumented
+ RedrawItem = 0x10000000 | 0x172, // wparam = uint32_t item_id
+ CloseSettings = 0x10000000 | 0x174, // Undocumented
+ ChangeSettingsTab = 0x10000000 | 0x175, // wparam = uint32_t is_interface_tab
+ GuildHall = 0x10000000 | 0x177, // wparam = gh key (uint32_t[4])
+ LeaveGuildHall = 0x10000000 | 0x179,
+ Travel = 0x10000000 | 0x17A,
+ OpenWikiUrl = 0x10000000 | 0x17B, // wparam = char* url
+ AppendMessageToChat = 0x10000000 | 0x189, // wparam = wchar_t* message
+ HideHeroPanel = 0x10000000 | 0x197, // wparam = hero_id
+ ShowHeroPanel = 0x10000000 | 0x198, // wparam = hero_id
+ GetInventoryAgentId = 0x10000000 | 0x19c, // wparam = 0, lparam = uint32_t* agent_id_out. Used to fetch which agent is selected
+ EquipItem = 0x10000000 | 0x19d, // wparam = { item_id, agent_id }
+ MoveItem = 0x10000000 | 0x19e, // wparam = { item_id, to_bag, to_slot, bool prompt }
+ InitiateTrade = 0x10000000 | 0x1A0,
+ InventoryAgentChanged = 0x10000000 | 0x1b0, // Triggered when inventory needs updating due to agent change; no args
+ OpenTemplate = 0x10000000 | 0x1B9, // wparam = GW::UI::ChatTemplate*
+
+ // GWCA Client to Server commands. Only added the ones that are used for hooks, everything else goes straight into GW
+
+ SendLoadSkillTemplate = 0x30000000 | 0x3, // wparam = SkillbarMgr::SkillTemplate*
+ SendPingWeaponSet = 0x30000000 | 0x4, // wparam = UIPacket::kSendPingWeaponSet*
+ SendMoveItem = 0x30000000 | 0x5, // wparam = UIPacket::kSendMoveItem*
+ SendMerchantRequestQuote = 0x30000000 | 0x6, // wparam = UIPacket::kSendMerchantRequestQuote*
+ SendMerchantTransactItem = 0x30000000 | 0x7, // wparam = UIPacket::kSendMerchantTransactItem*
+ SendUseItem = 0x30000000 | 0x8, // wparam = UIPacket::kSendUseItem*
+ SendSetActiveQuest = 0x30000000 | 0x9, // wparam = uint32_t quest_id
+ SendAbandonQuest = 0x30000000 | 0xA, // wparam = uint32_t quest_id
+ SendChangeTarget = 0x30000000 | 0xB, // wparam = UIPacket::kSendChangeTarget* // e.g. tell the gw client to focus on a different target
+ SendCallTarget = 0x30000000 | 0x13, // wparam = { uint32_t call_type, uint32_t agent_id } // also used to broadcast morale, death penalty, "I'm following X", etc
+ SendDialog = 0x30000000 | 0x16, // wparam = dialog_id // internal use
+
+
+ StartWhisper = 0x30000000 | 0x17, // wparam = UIPacket::kStartWhisper*
+ GetSenderColor = 0x30000000 | 0x18, // wparam = UIPacket::kGetColor* // Get chat sender color depending on channel, output object passed by reference
+ GetMessageColor = 0x30000000 | 0x19, // wparam = UIPacket::kGetColor* // Get chat message color depending on channel, output object passed by reference
+ SendChatMessage = 0x30000000 | 0x1B, // wparam = UIPacket::kSendChatMessage*
+ LogChatMessage = 0x30000000 | 0x1D, // wparam = UIPacket::kLogChatMessage*. Triggered when a message wants to be added to the persistent chat log.
+ RecvWhisper = 0x30000000 | 0x1E, // wparam = UIPacket::kRecvWhisper*
+ PrintChatMessage = 0x30000000 | 0x1F, // wparam = UIPacket::kPrintChatMessage*. Triggered when a message wants to be added to the in-game chat window.
+ SendWorldAction = 0x30000000 | 0x20, // wparam = UIPacket::kSendWorldAction*
+ SetRendererValue = 0x30000000 | 0x21 // wparam = UIPacket::kSetRendererValue
+};
diff --git a/Daybreak.API/Models/UIPackets.cs b/Daybreak.API/Models/UIPackets.cs
new file mode 100644
index 00000000..7f5ab228
--- /dev/null
+++ b/Daybreak.API/Models/UIPackets.cs
@@ -0,0 +1,46 @@
+using System.Runtime.InteropServices;
+
+namespace Daybreak.API.Models;
+
+public static class UIPackets
+{
+ public enum MouseButtons
+ {
+ Left = 0x0,
+ Middle = 0x1,
+ Right = 0x2
+ }
+
+ [StructLayout(LayoutKind.Sequential, Pack = 1)]
+ public readonly struct LogChatMessage(string message, Channel channel)
+ {
+ [MarshalAs(UnmanagedType.LPWStr)]
+ public readonly string Message = message;
+ public readonly Channel Channel = channel;
+ };
+
+ [StructLayout(LayoutKind.Sequential, Pack = 1)]
+ public readonly struct UIChatMessage(Channel channel, string message, Channel channel2)
+ {
+ public readonly Channel Channel = channel;
+ [MarshalAs(UnmanagedType.LPWStr)]
+ public readonly string Message = message;
+ public readonly Channel Channel2 = channel2;
+ }
+
+ [StructLayout(LayoutKind.Sequential, Pack = 1)]
+ public readonly struct KeyAction(uint key)
+ {
+ public readonly uint Key = key;
+ public readonly uint WParam = 0x4000;
+ public readonly uint LParam = 0x0006;
+ }
+
+ [StructLayout(LayoutKind.Sequential, Pack = 1)]
+ public readonly struct MouseClick(MouseButtons mouseButton, uint isDoubleClick, uint unknownTypeScreenPos)
+ {
+ public readonly MouseButtons MouseButton = mouseButton;
+ public readonly uint IsDoubleClick = isDoubleClick;
+ public readonly uint UnknownTypeScreenPos = unknownTypeScreenPos;
+ }
+}
diff --git a/Daybreak.API/Models/WorkItem.cs b/Daybreak.API/Models/WorkItem.cs
new file mode 100644
index 00000000..6a3580e8
--- /dev/null
+++ b/Daybreak.API/Models/WorkItem.cs
@@ -0,0 +1,80 @@
+namespace Daybreak.API.Models;
+
+public sealed class WorkItem(
+ Action action,
+ TaskCompletionSource tcs,
+ CancellationToken token) : IWorkItem
+{
+ private readonly Action action = action;
+ private readonly TaskCompletionSource tcs = tcs;
+
+ public CancellationToken CancellationToken { get; } = token;
+
+ public void Execute()
+ {
+ if (this.CancellationToken.IsCancellationRequested)
+ {
+ this.tcs.TrySetCanceled(this.CancellationToken);
+ return;
+ }
+
+ try
+ {
+ this.action();
+ this.tcs.TrySetResult();
+ }
+ catch (Exception ex)
+ {
+ this.tcs.TrySetException(ex);
+ }
+ }
+
+ public void Cancel()
+ {
+ this.tcs.TrySetCanceled(this.CancellationToken);
+ }
+
+ public void Exception(Exception ex)
+ {
+ this.tcs.TrySetException(ex);
+ }
+}
+
+public sealed class WorkItem(
+ Func func,
+ TaskCompletionSource tcs,
+ CancellationToken token) : IWorkItem
+{
+ private readonly Func func = func;
+ private readonly TaskCompletionSource tcs = tcs;
+
+ public CancellationToken CancellationToken { get; } = token;
+
+ public void Execute()
+ {
+ if (this.CancellationToken.IsCancellationRequested)
+ {
+ this.tcs.TrySetCanceled(this.CancellationToken);
+ return;
+ }
+
+ try
+ {
+ this.tcs.TrySetResult(this.func());
+ }
+ catch (Exception ex)
+ {
+ this.tcs.TrySetException(ex);
+ }
+ }
+
+ public void Cancel()
+ {
+ this.tcs.TrySetCanceled(this.CancellationToken);
+ }
+
+ public void Exception(Exception ex)
+ {
+ this.tcs.TrySetException(ex);
+ }
+}
diff --git a/Daybreak.API/NativeMethods.cs b/Daybreak.API/NativeMethods.cs
index b0e43a35..32f34738 100644
--- a/Daybreak.API/NativeMethods.cs
+++ b/Daybreak.API/NativeMethods.cs
@@ -2,12 +2,21 @@
namespace Daybreak.API;
-internal static partial class NativeMethods
+internal unsafe static partial class NativeMethods
{
public const int STD_OUTPUT_HANDLE = -11;
public const uint ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004;
public const uint ENABLE_PROCESSED_OUTPUT = 0x0001;
+ public const int WM_KEYDOWN = 0x0100;
+ public const int WM_KEYUP = 0x0101;
+ public const int WM_CHAR = 0x0102;
+ public const int VK_RIGHT = 0x27;
+
+ public const uint MEM_COMMIT = 0x1000;
+ public const uint MEM_RESERVE = 0x2000;
+ public const uint PAGE_EXECUTE_READWRITE = 0x40;
+
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool AllocConsole();
@@ -25,4 +34,18 @@ internal static partial class NativeMethods
[LibraryImport("kernel32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool SetConsoleMode(nint hConsoleHandle, uint dwMode);
+
+ [LibraryImport("user32.dll")]
+ public static partial nint SendMessageW(nint hWnd, int Msg, nint wParam, nint lParam);
+
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ public static partial nint VirtualAlloc(
+ nint lpAddress, nuint dwSize,
+ uint flAllocationType, uint flProtect);
+
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static partial bool VirtualProtect(
+ void* lpAddress, nuint dwSize,
+ uint flNewProtect, out uint lpflOldProtect);
}
diff --git a/Daybreak.API/Serialization/ApiJsonSerializerContext.cs b/Daybreak.API/Serialization/ApiJsonSerializerContext.cs
index 42809679..28f41d89 100644
--- a/Daybreak.API/Serialization/ApiJsonSerializerContext.cs
+++ b/Daybreak.API/Serialization/ApiJsonSerializerContext.cs
@@ -1,8 +1,39 @@
-using System.Text.Json.Serialization;
+using Daybreak.API.Models;
+using Daybreak.Shared.Models.Api;
+using System.Text.Json;
+using System.Text.Json.Serialization;
namespace Daybreak.API.Serialization;
[JsonSerializable(typeof(string))]
+[JsonSerializable(typeof(byte[]))]
+[JsonSerializable(typeof(int))]
+[JsonSerializable(typeof(uint))]
+[JsonSerializable(typeof(nuint))]
+[JsonSerializable(typeof(bool))]
+[JsonSerializable(typeof(Task))]
+[JsonSerializable(typeof(Task))]
+[JsonSerializable(typeof(Dictionary))]
+[JsonSerializable(typeof(List))]
+[JsonSerializable(typeof(JsonElement))]
+[JsonSerializable(typeof(HealthCheckResponse))]
+[JsonSerializable(typeof(HealthCheckEntryResponse))]
+[JsonSerializable(typeof(HookState))]
+[JsonSerializable(typeof(ArraySegment))]
+[JsonSerializable(typeof(AddressState))]
+[JsonSerializable(typeof(ArraySegment))]
+[JsonSerializable(typeof(MainPlayerState))]
+[JsonSerializable(typeof(QuestLogInformation))]
+[JsonSerializable(typeof(QuestInformation))]
+[JsonSerializable(typeof(MainPlayerInformation))]
+[JsonSerializable(typeof(CharacterSelectInformation))]
+[JsonSerializable(typeof(CharacterSelectEntry))]
+[JsonSerializable(typeof(BuildEntry))]
+[JsonSerializable(typeof(List))]
+[JsonSerializable(typeof(PartyLoadoutEntry))]
+[JsonSerializable(typeof(List))]
+[JsonSerializable(typeof(InstanceInfo))]
+[JsonSerializable(typeof(TitleInfo))]
public partial class ApiJsonSerializerContext : JsonSerializerContext
{
}
diff --git a/Daybreak.API/Serialization/WebApplicationBuilderExtensions.cs b/Daybreak.API/Serialization/WebApplicationBuilderExtensions.cs
new file mode 100644
index 00000000..74603e84
--- /dev/null
+++ b/Daybreak.API/Serialization/WebApplicationBuilderExtensions.cs
@@ -0,0 +1,14 @@
+namespace Daybreak.API.Serialization;
+
+public static class WebApplicationBuilderExtensions
+{
+ public static WebApplicationBuilder WithSerializationContext(this WebApplicationBuilder builder)
+ {
+ builder.Services.ConfigureHttpJsonOptions(options =>
+ {
+ options.SerializerOptions.TypeInfoResolverChain.Insert(0, new ApiJsonSerializerContext());
+ });
+
+ return builder;
+ }
+}
diff --git a/Daybreak.API/Services/ApiAdvertisingService.cs b/Daybreak.API/Services/ApiAdvertisingService.cs
new file mode 100644
index 00000000..43164d2d
--- /dev/null
+++ b/Daybreak.API/Services/ApiAdvertisingService.cs
@@ -0,0 +1,56 @@
+using Daybreak.Shared.Models;
+using Daybreak.Shared.Services.MDns;
+using Microsoft.AspNetCore.Hosting.Server;
+using Microsoft.AspNetCore.Hosting.Server.Features;
+using System.Extensions.Core;
+
+namespace Daybreak.API.Services;
+
+public sealed class ApiAdvertisingService(
+ IServer server,
+ IMDnsService mDnsService,
+ IHostApplicationLifetime lifetime,
+ ILogger logger) : IHostedService
+{
+ private const string ProcessIdPlaceholder = "{PID}";
+ private const string ServiceName = $"daybreak-api-{ProcessIdPlaceholder}";
+ private const string ServiceSubType = "daybreak-api";
+ private readonly IServerAddressesFeature serverAddressesFeature = server.Features.Get() ?? throw new InvalidOperationException("Server does not support addresses feature.");
+ private readonly IMDnsService mDnsService = mDnsService;
+ private readonly IHostApplicationLifetime lifetime = lifetime;
+ private readonly ILogger logger = logger;
+
+ private DnsRegistrationToken? dnsToken;
+
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ this.lifetime.ApplicationStarted.Register(this.RegisterDomain);
+ return Task.CompletedTask;
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken)
+ {
+ this.dnsToken?.Dispose();
+ return Task.CompletedTask;
+ }
+
+ private void RegisterDomain()
+ {
+ var scopedLogger = this.logger.CreateScopedLogger();
+ var addresses = this.serverAddressesFeature?.Addresses.ToArray() ?? [];
+ var port = addresses
+ .Select(a => new Uri(a).Port)
+ .Distinct()
+ .FirstOrDefault();
+
+ scopedLogger.LogInformation("Found port {port}", port);
+ if (port is 0)
+ {
+ throw new InvalidOperationException("Unable to start advertising service. No port found");
+ }
+
+ var dnsEntry = ServiceName.Replace(ProcessIdPlaceholder, Environment.ProcessId.ToString());
+ scopedLogger.LogInformation("Advertising service {service}:{port}", dnsEntry, port);
+ this.dnsToken = this.mDnsService.RegisterDomain(dnsEntry, (ushort)port, ServiceSubType);
+ }
+}
diff --git a/Daybreak.API/Services/CharacterSelectService.cs b/Daybreak.API/Services/CharacterSelectService.cs
new file mode 100644
index 00000000..9d81e19b
--- /dev/null
+++ b/Daybreak.API/Services/CharacterSelectService.cs
@@ -0,0 +1,313 @@
+using Daybreak.API.Services.Interop;
+using Daybreak.Shared.Models.Api;
+using System.Core.Extensions;
+using System.Extensions.Core;
+using System.Runtime.InteropServices;
+using ZLinq;
+
+namespace Daybreak.API.Services;
+
+public sealed class CharacterSelectService(
+ InstanceContextService instanceContextService,
+ PlatformContextService platformContextService,
+ UIContextService uiContextService,
+ GameThreadService gameThreadService,
+ GameContextService gameContextService,
+ ILogger logger)
+{
+ [StructLayout(LayoutKind.Sequential, Pack = 1)]
+ private readonly struct LogOutMessage(uint unknown, uint characterSelect)
+ {
+ public readonly uint Unknown = unknown;
+ public readonly uint CharacterSelect = characterSelect;
+ }
+
+ private readonly InstanceContextService instanceContextService = instanceContextService.ThrowIfNull();
+ private readonly PlatformContextService platformContextService = platformContextService.ThrowIfNull();
+ private readonly UIContextService uiContextService = uiContextService.ThrowIfNull();
+ private readonly GameThreadService gameThreadService = gameThreadService.ThrowIfNull();
+ private readonly GameContextService gameContextService = gameContextService.ThrowIfNull();
+ private readonly ILogger logger = logger.ThrowIfNull();
+
+ public Task GetCharacterSelectInformation(CancellationToken cancellationToken)
+ {
+ var scopedLogger = this.logger.CreateScopedLogger();
+ return this.gameThreadService.QueueOnGameThread(() =>
+ {
+ unsafe
+ {
+ var gameContext = this.gameContextService.GetGameContext();
+ if (gameContext.IsNull ||
+ gameContext.Pointer->WorldContext is null)
+ {
+ scopedLogger.LogError("Game context is not initialized");
+ return default;
+ }
+
+ var availableCharsContext = this.gameContextService.GetAvailableChars();
+ if (availableCharsContext.IsNull)
+ {
+ scopedLogger.LogError("Available characters context is not initialized");
+ return default;
+ }
+
+ var currentUuid = gameContext.Pointer->CharContext->PlayerUuid.ToString();
+ var availableChars = new List((int)availableCharsContext.Pointer->Size);
+ foreach(var charContext in *availableCharsContext.Pointer)
+ {
+ var nameSpan = charContext.Name.AsSpan();
+ var name = new string(nameSpan[..nameSpan.IndexOf('\0')]);
+ availableChars.Add(new CharacterSelectEntry(
+ charContext.Uuid.ToString(),
+ name,
+ charContext.Primary,
+ charContext.Secondary,
+ charContext.Campaign,
+ charContext.MapId,
+ charContext.Level,
+ charContext.IsPvp));
+ }
+
+ var currentCharacter = availableChars.FirstOrDefault(c => c.Uuid == currentUuid);
+ return new CharacterSelectInformation(currentCharacter, availableChars);
+ }
+ }, cancellationToken);
+ }
+
+ public async Task ChangeCharacterByName(string characterName, CancellationToken cancellationToken)
+ {
+ var scopedLogger = this.logger.CreateScopedLogger();
+ if (await this.ValidateState(cancellationToken) is false)
+ {
+ scopedLogger.LogError("Cannot change character while in loading state");
+ return false;
+ }
+
+ await this.TriggerLogOut(cancellationToken);
+
+ var indexResult = await this.WaitForLoginScreenAndGetCurrentAndDesiredIndex(characterName, cancellationToken);
+ if (indexResult is null)
+ {
+ scopedLogger.LogError("Failed to find index by name {name}", characterName);
+ return false;
+ }
+
+ var currentIndex = indexResult.Value.CurrentIndex;
+ var desiredIndex = indexResult.Value.DesiredIndex;
+ scopedLogger.LogInformation("Changing character to {name} with index {index}", characterName, desiredIndex);
+ var navigateResult = await this.NavigateToCharAndPlay(currentIndex, desiredIndex, cancellationToken);
+ if (!navigateResult)
+ {
+ scopedLogger.LogError("Failed to navigate to character {name} with index {index}", characterName, desiredIndex);
+ return false;
+ }
+
+ return true;
+ }
+
+ public async Task ChangeCharacterByUuid(string uuid, CancellationToken cancellationToken)
+ {
+ var scopedLogger = this.logger.CreateScopedLogger();
+ if (await this.ValidateState(cancellationToken) is false)
+ {
+ scopedLogger.LogError("Cannot change character while in loading state");
+ return false;
+ }
+
+ var desiredCharName = await this.GetCharNameByUuid(uuid, cancellationToken);
+ if (desiredCharName is null)
+ {
+ scopedLogger.LogError("Failed to find character with UUID: {uuid}", uuid);
+ return false;
+ }
+
+ await this.TriggerLogOut(cancellationToken);
+
+ var indexResult = await this.WaitForLoginScreenAndGetCurrentAndDesiredIndex(desiredCharName, cancellationToken);
+ if (indexResult is null)
+ {
+ scopedLogger.LogError("Resolved character by uuid {uuid} but failed to find index by name {name}", uuid, desiredCharName);
+ return false;
+ }
+
+ var currentIndex = indexResult.Value.CurrentIndex;
+ var desiredIndex = indexResult.Value.DesiredIndex;
+ scopedLogger.LogInformation("Changing character to {name} with index {index} and uuid {uuid}", desiredCharName, desiredIndex, uuid);
+ var navigateResult = await this.NavigateToCharAndPlay(currentIndex, desiredIndex, cancellationToken);
+ if (!navigateResult)
+ {
+ scopedLogger.LogError("Failed to navigate to character {name} with index {index} and uuid {uuid}", desiredCharName, desiredIndex, uuid);
+ return false;
+ }
+
+ return true;
+ }
+
+ private async Task ValidateState(CancellationToken cancellationToken)
+ {
+ return await this.gameThreadService.QueueOnGameThread(() =>
+ {
+ return this.instanceContextService.GetInstanceType() is not API.Interop.GuildWars.InstanceType.Loading;
+ }, cancellationToken);
+ }
+
+ private async Task NavigateToCharAndPlay(uint currentIndex, uint desiredIndex, CancellationToken cancellationToken)
+ {
+ var scopedLogger = this.logger.CreateScopedLogger();
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ await Task.Delay(100, cancellationToken);
+ var result = await this.gameThreadService.QueueOnGameThread(() =>
+ {
+ unsafe
+ {
+ var preGameContext = this.gameContextService.GetPreGameContext();
+ if (preGameContext.IsNull)
+ {
+ scopedLogger.LogError("Pre-game context is not initialized");
+ return false;
+ }
+
+ var hwnd = this.platformContextService.GetWindowHandle();
+ if (hwnd is null or 0)
+ {
+ scopedLogger.LogError("Failed to get window handle");
+ return false;
+ }
+
+ if (preGameContext.Pointer->Index1 == currentIndex)
+ {
+ return false; //Not moved yet
+ }
+
+ if (preGameContext.Pointer->Index1 == desiredIndex)
+ {
+ // We're on the desired character. Trigger play
+ NativeMethods.SendMessageW((nint)hwnd.Value, NativeMethods.WM_KEYDOWN, 0x50, 0x00190001);
+ NativeMethods.SendMessageW((nint)hwnd.Value, NativeMethods.WM_CHAR, 0x70, 0x00190001);
+ NativeMethods.SendMessageW((nint)hwnd.Value, NativeMethods.WM_KEYUP, 0x50, 0x00190001);
+ return true;
+ }
+
+ currentIndex = preGameContext.Pointer->Index1;
+ NativeMethods.SendMessageW((nint)hwnd.Value, NativeMethods.WM_KEYDOWN, NativeMethods.VK_RIGHT, 0x014D0001);
+ NativeMethods.SendMessageW((nint)hwnd.Value, NativeMethods.WM_KEYUP, NativeMethods.VK_RIGHT, 0x014D0001);
+ return false;
+ }
+ }, cancellationToken);
+
+ if (result)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private async Task GetCharNameByUuid(string uuid, CancellationToken cancellationToken)
+ {
+ var scopedLogger = this.logger.CreateScopedLogger();
+ return await this.gameThreadService.QueueOnGameThread(() =>
+ {
+ unsafe
+ {
+ var gameContext = this.gameContextService.GetGameContext();
+ if (gameContext.IsNull)
+ {
+ scopedLogger.LogError("Game context is not initialized");
+ return default;
+ }
+
+ var availableCharsContext = this.gameContextService.GetAvailableChars();
+ if (availableCharsContext.IsNull)
+ {
+ scopedLogger.LogError("Available characters context is not initialized");
+ return default;
+ }
+
+ foreach (var charContext in *availableCharsContext.Pointer)
+ {
+ if (charContext.Uuid.ToString() != uuid)
+ {
+ continue;
+ }
+
+ var nameSpan = charContext.Name.AsSpan();
+ var name = new string(nameSpan[..nameSpan.IndexOf('\0')]);
+ return name;
+ }
+
+ return default;
+ }
+ }, cancellationToken);
+ }
+
+ private async Task TriggerLogOut(CancellationToken cancellationToken)
+ {
+ await this.gameThreadService.QueueOnGameThread(() =>
+ {
+ var logoutMessage = new LogOutMessage(0, 0);
+ unsafe
+ {
+ this.uiContextService.SendMessage(Models.UIMessage.Logout, (uint)&logoutMessage, 0);
+ }
+ }, cancellationToken);
+ }
+
+ private async Task<(uint DesiredIndex, uint CurrentIndex)?> WaitForLoginScreenAndGetCurrentAndDesiredIndex(string desiredCharName, CancellationToken cancellationToken)
+ {
+ uint? desiredIndex = default;
+ uint? currentIndex = default;
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ await Task.Delay(100, cancellationToken);
+ var ready = await this.gameThreadService.QueueOnGameThread(() =>
+ {
+ unsafe
+ {
+ var preGameContext = this.gameContextService.GetPreGameContext();
+ if (preGameContext.IsNull)
+ {
+ return false;
+ }
+
+ var uiState = 10U;
+ this.uiContextService.SendMessage(Models.UIMessage.CheckUIState, 0, (uint)&uiState);
+ var loginScreen = uiState == 2;
+ if (!loginScreen)
+ {
+ return false;
+ }
+
+ for (var i = 0U; i < preGameContext.Pointer->LoginCharacters.Size; i++)
+ {
+ var loginCharacter = preGameContext.Pointer->LoginCharacters.Buffer[i];
+ var charNameSpan = loginCharacter.CharacterName.AsSpan();
+ var charName = new string(charNameSpan[..charNameSpan.IndexOf('\0')]);
+ if (charName == desiredCharName)
+ {
+ desiredIndex = i;
+ currentIndex = 0xffffffdd;
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }, cancellationToken);
+
+ if (ready)
+ {
+ break;
+ }
+ }
+
+ if (desiredIndex is not null && currentIndex is not null)
+ {
+ return (desiredIndex.Value, currentIndex.Value);
+ }
+
+ return default;
+ }
+}
diff --git a/Daybreak.API/Services/ChatService.cs b/Daybreak.API/Services/ChatService.cs
new file mode 100644
index 00000000..9e246d6d
--- /dev/null
+++ b/Daybreak.API/Services/ChatService.cs
@@ -0,0 +1,77 @@
+using Daybreak.API.Interop;
+using Daybreak.API.Interop.GuildWars;
+using Daybreak.API.Models;
+using Daybreak.API.Services.Interop;
+using System.Core.Extensions;
+using System.Extensions;
+
+namespace Daybreak.API.Services;
+
+public sealed class ChatService(
+ InstanceContextService instanceContextService,
+ GameThreadService gameThreadService,
+ UIContextService uiContextService)
+{
+ private readonly SemaphoreSlim semaphoreSlim = new(1);
+ private readonly InstanceContextService instanceContextService = instanceContextService.ThrowIfNull();
+ private readonly GameThreadService gameThreadService = gameThreadService.ThrowIfNull();
+ private readonly UIContextService uiContextService = uiContextService.ThrowIfNull();
+
+ ///
+ /// Adds a message to the chat. It does not send the message, it only shows it to the player.
+ ///
+ public async ValueTask AddMessageAsync(string message, string? sender, Channel channel, CancellationToken cancellationToken)
+ {
+ using var ctx = await this.semaphoreSlim.Acquire(cancellationToken);
+ await this.gameThreadService.QueueOnGameThread(() =>
+ {
+ if (this.instanceContextService.GetInstanceType() is InstanceType.Loading)
+ {
+ return;
+ }
+
+ var encodedMessage = string.Create(
+ 3 + message.Length,
+ message,
+ static (span, msg) =>
+ {
+ span[0] = '\u0108';
+ span[1] = '\u0107';
+ msg.AsSpan().CopyTo(span[2..]);
+ span[^1] = '\u0001';
+ });
+
+ var encodedSender = sender is not null
+ ? string.Create(
+ 3 + sender.Length,
+ sender,
+ static (span, msg) =>
+ {
+ span[0] = '\u0108';
+ span[1] = '\u0107';
+ msg.AsSpan().CopyTo(span[2..]);
+ span[^1] = '\u0001';
+ })
+ : default;
+
+ var encoded = encodedSender is null
+ ? encodedMessage
+ : string.Create(
+ encodedMessage.Length + encodedSender.Length + 6,
+ (encodedMessage, encodedSender),
+ static (span, state) =>
+ {
+ span[0] = '\u076b';
+ span[1] = '\u010a';
+ state.encodedSender.AsSpan().CopyTo(span[2..]);
+ span[2 + state.encodedSender.Length] = '\u0001';
+ span[3 + state.encodedSender.Length] = '\u010b';
+ state.encodedMessage.AsSpan().CopyTo(span[(4 + state.encodedSender.Length)..]);
+ span[^2] = '\u0001';
+ });
+
+ using var packet = new UnmanagedStruct(new UIPackets.UIChatMessage(channel, encoded, channel));
+ this.uiContextService.SendMessage(UIMessage.WriteToChatLog, (nuint)packet.Address, 0x0);
+ }, cancellationToken);
+ }
+}
diff --git a/Daybreak.API/Services/Interop/AgentContextService.cs b/Daybreak.API/Services/Interop/AgentContextService.cs
new file mode 100644
index 00000000..eb883b7f
--- /dev/null
+++ b/Daybreak.API/Services/Interop/AgentContextService.cs
@@ -0,0 +1,101 @@
+using Daybreak.API.Interop;
+using Daybreak.API.Interop.GuildWars;
+using Daybreak.API.Models;
+using System.Core.Extensions;
+using System.Extensions.Core;
+
+namespace Daybreak.API.Services.Interop;
+
+public unsafe sealed class AgentContextService : IAddressHealthService
+{
+ private const string AgentArrayMask = "xxxxxxx";
+ private const int AgentArrayOffset = -0x4;
+ private static readonly byte[] AgentArrayAddressPattern = [0x8b, 0x0c, 0x90, 0x85, 0xc9, 0x74, 0x19];
+
+ private const string PlayerAgentIdMask = "xx????xxxx";
+ private const int PlayerAgentIdOffset = -0xE;
+ private static readonly byte[] PlayerAgentIdAddressPattern = [0x5d, 0xe9, 0x00, 0x00, 0x00, 0x00, 0x55, 0x8b, 0xec, 0x53];
+
+ private readonly MemoryScanningService memoryScanningService;
+ private readonly GWAddressCache agentArrayAddress;
+ private readonly GWAddressCache playerAgentIdAddress;
+ private readonly ILogger logger;
+
+ public AgentContextService(
+ MemoryScanningService memoryScanningService,
+ ILogger logger)
+ {
+ this.logger = logger.ThrowIfNull();
+ this.memoryScanningService = memoryScanningService.ThrowIfNull();
+ this.agentArrayAddress = new GWAddressCache(this.GetAgentArrayAddress);
+ this.playerAgentIdAddress = new GWAddressCache(this.GetPreGameContextAddress);
+ }
+
+ public List GetAddressStates()
+ {
+ return [
+ new AddressState
+ {
+ Address = this.agentArrayAddress.GetAddress() ?? 0,
+ Name = nameof(this.agentArrayAddress),
+ },
+ new AddressState
+ {
+ Address = this.playerAgentIdAddress.GetAddress() ?? 0,
+ Name = nameof(this.playerAgentIdAddress),
+ },
+ ];
+ }
+
+ public WrappedPointer>> GetAgentArray()
+ {
+ var agentArrayAddress = this.agentArrayAddress.GetAddress();
+ if (agentArrayAddress is null or 0x0)
+ {
+ this.logger.LogError("Failed to get agent array address");
+ return null;
+ }
+
+ return (GuildWarsArray>*)agentArrayAddress;
+ }
+
+ public uint GetPlayerAgentId()
+ {
+ var playerAgentIdAddress = this.playerAgentIdAddress.GetAddress();
+ if (playerAgentIdAddress is null or 0x0)
+ {
+ this.logger.LogError("Failed to get player agent id address");
+ return 0;
+ }
+
+ return *(uint*)playerAgentIdAddress;
+ }
+
+ private nuint GetAgentArrayAddress()
+ {
+ var scopedLogger = this.logger.CreateScopedLogger();
+ var address = this.memoryScanningService.FindAndResolveAddress(AgentArrayAddressPattern, AgentArrayMask, AgentArrayOffset);
+ if (address is 0)
+ {
+ scopedLogger.LogError("Failed to find agent array address");
+ return 0U;
+ }
+
+ scopedLogger.LogInformation("Agent array address: 0x{address:X8}", address);
+ return address;
+ }
+
+ private nuint GetPreGameContextAddress()
+ {
+ var scopedLogger = this.logger.CreateScopedLogger();
+ var address = this.memoryScanningService.FindAndResolveAddress(PlayerAgentIdAddressPattern, PlayerAgentIdMask, PlayerAgentIdOffset);
+ if (address is 0)
+ {
+ scopedLogger.LogError("Failed to find player agent id address");
+ return 0U;
+ }
+
+ scopedLogger.LogInformation("Player agent id address: 0x{address:X8}", address);
+ return address;
+ }
+}
diff --git a/Daybreak.API/Services/Interop/ChatHandlingService.cs b/Daybreak.API/Services/Interop/ChatHandlingService.cs
new file mode 100644
index 00000000..4656d135
--- /dev/null
+++ b/Daybreak.API/Services/Interop/ChatHandlingService.cs
@@ -0,0 +1,85 @@
+using Daybreak.API.Interop;
+using Daybreak.API.Models;
+using System.Core.Extensions;
+using System.Extensions.Core;
+using System.Runtime.InteropServices;
+using System.Security;
+
+namespace Daybreak.API.Services.Interop;
+
+public sealed class ChatHandlingService
+ : IHostedService, IHookHealthService
+{
+ private const string AddToChatLogMask = "xxxxxx";
+ private static readonly byte[] AddToChatLogSeq = [0x40, 0x25, 0xFF, 0x01, 0x00, 0x00];
+
+ [SuppressUnmanagedCodeSecurity]
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private delegate bool AddToChatLog([MarshalAs(UnmanagedType.LPWStr)] string message, Channel channel);
+
+ private readonly MemoryScanningService memoryScanningService;
+ private readonly GWHook addToChatLogHook;
+ private readonly ILogger logger;
+
+ private CancellationTokenSource? cts = default;
+
+ public ChatHandlingService(
+ MemoryScanningService memoryScanningService,
+ ILogger logger)
+ {
+ this.logger = logger.ThrowIfNull();
+ this.memoryScanningService = memoryScanningService.ThrowIfNull();
+ this.addToChatLogHook = new GWHook(
+ new GWAddressCache(() => this.memoryScanningService.ToFunctionStart(
+ this.memoryScanningService.FindAddress(AddToChatLogSeq, AddToChatLogMask))),
+ this.OnAddToChatLog);
+ }
+
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ this.cts?.Dispose();
+ this.cts = new CancellationTokenSource();
+ return Task.Factory.StartNew(() => this.InitializeHooks(this.cts.Token), this.cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Current);
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken)
+ {
+ this.cts?.Cancel();
+ this.cts?.Dispose();
+ this.addToChatLogHook.Dispose();
+ return Task.CompletedTask;
+ }
+
+ public List GetHookStates()
+ {
+ return
+ [
+ new HookState
+ {
+ Hooked = this.addToChatLogHook.Hooked,
+ Name = nameof(this.addToChatLogHook),
+ TargetAddress = new PointerValue(this.addToChatLogHook.TargetAddress),
+ ContinueAddress = new PointerValue(this.addToChatLogHook.ContinueAddress),
+ DetourAddress = new PointerValue(this.addToChatLogHook.DetourAddress)
+ }
+ ];
+ }
+
+ private async ValueTask InitializeHooks(CancellationToken cancellationToken)
+ {
+ var scopedLogger = this.logger.CreateScopedLogger();
+ scopedLogger.LogInformation("Initializing hooks");
+ while (!this.addToChatLogHook.EnsureInitialized())
+ {
+ scopedLogger.LogDebug("Waiting for hook to initialize: {hook}", nameof(this.addToChatLogHook));
+ await Task.Delay(1000, cancellationToken);
+ }
+
+ scopedLogger.LogInformation("Hooks initialized");
+ }
+
+ private bool OnAddToChatLog(string message, Channel channel)
+ {
+ return this.addToChatLogHook.Continue(message, channel);
+ }
+}
diff --git a/Daybreak.API/Services/Interop/GameContextService.cs b/Daybreak.API/Services/Interop/GameContextService.cs
new file mode 100644
index 00000000..1609ec6c
--- /dev/null
+++ b/Daybreak.API/Services/Interop/GameContextService.cs
@@ -0,0 +1,181 @@
+using Daybreak.API.Interop;
+using Daybreak.API.Interop.GuildWars;
+using Daybreak.API.Models;
+using System.Core.Extensions;
+using System.Extensions.Core;
+
+namespace Daybreak.API.Services.Interop;
+
+public unsafe sealed class GameContextService : IAddressHealthService
+{
+ private const string BaseAddressMask = "xxxxxxx";
+ private const int BaseAddressOffset = +7;
+ private static readonly byte[] BaseAddressPattern = [0x50, 0x6A, 0x0F, 0x6A, 0x00, 0xFF, 0x35];
+
+ private const string PreGameContextFile = "UiPregame.cpp";
+ private const string PreGameContextAssertion = "!s_scene";
+ private const int PreGameContextOffset = 0x34;
+
+ private const string AvailableCharsMask = "xx????xxxxxxx";
+ private const int AvailableCharsOffset = 0x2;
+ private static readonly byte[] AvailableCharsPattern = [0x8B, 0x35, 0x00, 0x00, 0x00, 0x00, 0x57, 0x69, 0xF8, 0x84, 0x00, 0x00, 0x00];
+
+ private readonly MemoryScanningService memoryScanningService;
+ private readonly GWAddressCache baseAddress;
+ private readonly GWAddressCache preGameContextAddress;
+ private readonly GWAddressCache availableCharsAddress;
+ private readonly ILogger logger;
+
+ public GameContextService(
+ MemoryScanningService memoryScanningService,
+ ILogger logger)
+ {
+ this.logger = logger.ThrowIfNull();
+ this.memoryScanningService = memoryScanningService.ThrowIfNull();
+ this.baseAddress = new GWAddressCache(this.GetBaseAddress);
+ this.preGameContextAddress = new GWAddressCache(this.GetPreGameContextAddress);
+ this.availableCharsAddress = new GWAddressCache(this.GetAvailableCharactersAddress);
+ }
+
+ public List GetAddressStates()
+ {
+ return [
+ new AddressState
+ {
+ Address = this.baseAddress.GetAddress() ?? 0,
+ Name = nameof(this.baseAddress),
+ },
+ new AddressState
+ {
+ Address = this.preGameContextAddress.GetAddress() ?? 0,
+ Name = nameof(this.preGameContextAddress),
+ },
+ new AddressState
+ {
+ Address = this.availableCharsAddress.GetAddress() ?? 0,
+ Name = nameof(this.availableCharsAddress),
+ }
+ ];
+ }
+
+ public WrappedPointer GetGameContext()
+ {
+ var gameContextAddress = this.GetGameContextAddress();
+ if (gameContextAddress is 0x0)
+ {
+ this.logger.LogError("Failed to get game context address");
+ return null;
+ }
+
+ return (GameContext*)gameContextAddress;
+ }
+
+ public WrappedPointer GetPreGameContext()
+ {
+ var preGameContextAddress = this.preGameContextAddress.GetAddress();
+ if (preGameContextAddress is null or 0x0)
+ {
+ this.logger.LogError("Failed to get pre-game context address");
+ return null;
+ }
+
+ return *(PreGameContext**)preGameContextAddress;
+ }
+
+ public WrappedPointer> GetAvailableChars()
+ {
+ var availableCharsAddress = this.availableCharsAddress.GetAddress();
+ if (availableCharsAddress is null or 0x0)
+ {
+ this.logger.LogError("Failed to get available chars address");
+ return null;
+ }
+
+ return *(GuildWarsArray**)availableCharsAddress;
+ }
+
+ public bool IsMapLoaded()
+ {
+ var gameContext = this.GetGameContext();
+ if (gameContext.IsNull)
+ {
+ return false;
+ }
+
+ if (gameContext.Pointer->MapContext is null)
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ private nuint GetGameContextAddress()
+ {
+ var scopedLogger = this.logger.CreateScopedLogger();
+ if (this.baseAddress.GetAddress() is not nuint basePtr)
+ {
+ scopedLogger.LogError("Failed to get base address");
+ return default;
+ }
+
+ nuint** baseContext = *(nuint***)basePtr;
+ if (baseContext is null)
+ {
+ scopedLogger.LogError("Failed to get base context address");
+ return default;
+ }
+
+ var globalContextPtr = baseContext[0x6];
+ if (globalContextPtr is null)
+ {
+ scopedLogger.LogError("Failed to get game context address");
+ return default;
+ }
+
+ return (nuint)globalContextPtr;
+ }
+
+ private nuint GetBaseAddress()
+ {
+ var scopedLogger = this.logger.CreateScopedLogger();
+ var baseAddress = this.memoryScanningService.FindAndResolveAddress(BaseAddressPattern, BaseAddressMask, BaseAddressOffset);
+ if (baseAddress is 0)
+ {
+ scopedLogger.LogError("Failed to find base address");
+ return 0U;
+ }
+
+ scopedLogger.LogInformation("Base address: 0x{address:X8}", baseAddress);
+ return baseAddress;
+ }
+
+ private unsafe nuint GetPreGameContextAddress()
+ {
+ var scopedLogger = this.logger.CreateScopedLogger();
+ var preGameContextAddress = this.memoryScanningService.FindAssertion(PreGameContextFile, PreGameContextAssertion, 0, PreGameContextOffset);
+ if (preGameContextAddress is 0)
+ {
+ scopedLogger.LogError("Failed to find pre-game context address");
+ return 0U;
+ }
+
+ preGameContextAddress = *(nuint*)preGameContextAddress;
+ scopedLogger.LogInformation("Pre-game context address: 0x{address:X8}", preGameContextAddress);
+ return preGameContextAddress;
+ }
+
+ private unsafe nuint GetAvailableCharactersAddress()
+ {
+ var scopedLogger = this.logger.CreateScopedLogger();
+ var availableCharsAddress = this.memoryScanningService.FindAddress(AvailableCharsPattern, AvailableCharsMask, AvailableCharsOffset);
+ if (availableCharsAddress is 0)
+ {
+ scopedLogger.LogError("Failed to find available chars address");
+ return 0U;
+ }
+
+ scopedLogger.LogInformation("Available chars address: 0x{address:X8}", availableCharsAddress);
+ return availableCharsAddress;
+ }
+}
diff --git a/Daybreak.API/Services/Interop/GameThreadService.cs b/Daybreak.API/Services/Interop/GameThreadService.cs
new file mode 100644
index 00000000..6e23f238
--- /dev/null
+++ b/Daybreak.API/Services/Interop/GameThreadService.cs
@@ -0,0 +1,142 @@
+using Daybreak.API.Interop;
+using Daybreak.API.Models;
+using System.Collections.Concurrent;
+using System.Core.Extensions;
+using System.Extensions.Core;
+using System.Runtime.InteropServices;
+using System.Security;
+
+namespace Daybreak.API.Services.Interop;
+
+public sealed class GameThreadService
+ : IHostedService, IHookHealthService
+{
+ private const string LeaveGameThreadFile = "FrApi.cpp";
+ private const string LeaveGameThreadAssertion = "renderElapsed >= 0";
+
+ [SuppressUnmanagedCodeSecurity]
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private delegate void LeaveGameThread(nint ctx);
+
+ private readonly MemoryScanningService memoryScanningService;
+ private readonly GWHook leaveGameThreadHook;
+ private readonly ILogger logger;
+ private readonly ConcurrentQueue queuedItems = [];
+ private readonly ConcurrentDictionary registeredCallbacks = [];
+
+ private CancellationTokenSource? cts = default;
+
+ public GameThreadService(
+ MemoryScanningService memoryScanningService,
+ ILogger logger)
+ {
+ this.logger = logger.ThrowIfNull();
+ this.memoryScanningService = memoryScanningService.ThrowIfNull();
+ this.leaveGameThreadHook = new GWHook(
+ new GWAddressCache(() => this.memoryScanningService.ToFunctionStart(
+ this.memoryScanningService.FindAssertion(LeaveGameThreadFile, LeaveGameThreadAssertion, 0, 0), 0x300)),
+ this.OnLeaveGameThread);
+ }
+
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ this.cts?.Dispose();
+ this.cts = new CancellationTokenSource();
+ return Task.Factory.StartNew(() => this.InitializeHooks(this.cts.Token), this.cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Current);
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken)
+ {
+ this.cts?.Cancel();
+ this.cts?.Dispose();
+ this.leaveGameThreadHook.Dispose();
+ return Task.CompletedTask;
+ }
+
+ public Task QueueOnGameThread(Action action, CancellationToken cancellationToken)
+ {
+ var taskCompletionSource = new TaskCompletionSource();
+ this.queuedItems.Enqueue(new WorkItem(action, taskCompletionSource, cancellationToken));
+ return taskCompletionSource.Task;
+ }
+
+ public Task QueueOnGameThread(Func action, CancellationToken cancellationToken)
+ {
+ var taskCompletionSource = new TaskCompletionSource();
+ this.queuedItems.Enqueue(new WorkItem(action, taskCompletionSource, cancellationToken));
+ return taskCompletionSource.Task;
+ }
+
+ public CallbackRegistration RegisterCallback(Action action)
+ {
+ var uid = new Guid();
+ var registration = new CallbackRegistration(uid, () => this.registeredCallbacks.TryRemove(uid, out _));
+ this.registeredCallbacks[uid] = action;
+ return registration;
+ }
+
+ public List GetHookStates()
+ {
+ return
+ [
+ new HookState
+ {
+ Hooked = this.leaveGameThreadHook.Hooked,
+ Name = nameof(this.leaveGameThreadHook),
+ TargetAddress = new PointerValue(this.leaveGameThreadHook.TargetAddress),
+ ContinueAddress = new PointerValue(this.leaveGameThreadHook.ContinueAddress),
+ DetourAddress = new PointerValue(this.leaveGameThreadHook.DetourAddress)
+ }
+ ];
+ }
+
+ private async ValueTask InitializeHooks(CancellationToken cancellationToken)
+ {
+ var scopedLogger = this.logger.CreateScopedLogger();
+ scopedLogger.LogInformation("Initializing hooks");
+ while (!this.leaveGameThreadHook.EnsureInitialized())
+ {
+ scopedLogger.LogDebug("Waiting for hook to initialize: {hook}", nameof(this.leaveGameThreadHook));
+ await Task.Delay(1000, cancellationToken);
+ }
+
+ scopedLogger.LogInformation("Hooks initialized");
+ }
+
+ private void OnLeaveGameThread(nint ctx)
+ {
+ var scopedLogger = this.logger.CreateScopedLogger();
+ while(this.queuedItems.TryDequeue(out var item))
+ {
+ try
+ {
+ if (item.CancellationToken.IsCancellationRequested)
+ {
+ item.Cancel();
+ continue;
+ }
+
+ item.Execute();
+ }
+ catch(Exception ex)
+ {
+ scopedLogger.LogError(ex, "Error executing action on game thread");
+ item.Exception(ex);
+ }
+ }
+
+ foreach(var callback in this.registeredCallbacks)
+ {
+ try
+ {
+ callback.Value();
+ }
+ catch(Exception ex)
+ {
+ scopedLogger.LogError(ex, "Error executing registered callback {uid}", callback.Key);
+ }
+ }
+
+ this.leaveGameThreadHook.Continue(ctx);
+ }
+}
diff --git a/Daybreak.API/Services/Interop/IAddressHealthService.cs b/Daybreak.API/Services/Interop/IAddressHealthService.cs
new file mode 100644
index 00000000..538fa0cd
--- /dev/null
+++ b/Daybreak.API/Services/Interop/IAddressHealthService.cs
@@ -0,0 +1,8 @@
+using Daybreak.API.Models;
+
+namespace Daybreak.API.Services.Interop;
+
+public interface IAddressHealthService
+{
+ List GetAddressStates();
+}
diff --git a/Daybreak.API/Services/Interop/IHookHealthService.cs b/Daybreak.API/Services/Interop/IHookHealthService.cs
new file mode 100644
index 00000000..9953b609
--- /dev/null
+++ b/Daybreak.API/Services/Interop/IHookHealthService.cs
@@ -0,0 +1,8 @@
+using Daybreak.API.Models;
+
+namespace Daybreak.API.Services.Interop;
+
+public interface IHookHealthService
+{
+ List GetHookStates();
+}
diff --git a/Daybreak.API/Services/Interop/InstanceContextService.cs b/Daybreak.API/Services/Interop/InstanceContextService.cs
new file mode 100644
index 00000000..36fa2689
--- /dev/null
+++ b/Daybreak.API/Services/Interop/InstanceContextService.cs
@@ -0,0 +1,104 @@
+using Daybreak.API.Interop;
+using Daybreak.API.Interop.GuildWars;
+using Daybreak.API.Models;
+using System.Core.Extensions;
+
+namespace Daybreak.API.Services.Interop;
+
+public sealed unsafe class InstanceContextService : IAddressHealthService
+{
+ private const string InstanceInfoAddressMask = "xxxx????xxxx";
+ private const int InstanceInfoAddressOffset = +0xD;
+ private static readonly byte[] InstanceInfoAddressPattern = [0x6A, 0x2C, 0x50, 0xE8, 0x00, 0x00, 0x00, 0x00, 0x83, 0xC4, 0x08, 0xC7];
+ private const string AreaInfoAddressMask = "xxxxx";
+ private const int AreaInfoAddressOffset = +0x5;
+ private static readonly byte[] AreaInfoAddressPattern = [0x6B, 0xC6, 0x7C, 0x5E, 0x05];
+ private const string ServerRegionAddressMask = "xxxxxxx";
+ private const int ServerRegionAddressOffset = -0x4;
+ private static readonly byte[] ServerRegionAddressPattern = [0x6A, 0x54, 0x8D, 0x46, 0x24, 0x89, 0x08];
+
+ private readonly MemoryScanningService memoryScanningService;
+ private readonly GWAddressCache instanceInfoAddress;
+ private readonly GWAddressCache areaInfoAddress;
+ private readonly GWAddressCache serverRegionAddress;
+ private readonly ILogger logger;
+
+ public InstanceContextService(
+ MemoryScanningService memoryScanningService,
+ ILogger logger)
+ {
+ this.logger = logger.ThrowIfNull();
+ this.memoryScanningService = memoryScanningService.ThrowIfNull();
+ this.instanceInfoAddress = new GWAddressCache(() => this.memoryScanningService.FindAndResolveAddress(InstanceInfoAddressPattern, InstanceInfoAddressMask, InstanceInfoAddressOffset));
+ this.areaInfoAddress = new GWAddressCache(() => this.memoryScanningService.FindAndResolveAddress(AreaInfoAddressPattern, AreaInfoAddressMask, AreaInfoAddressOffset));
+ this.serverRegionAddress = new GWAddressCache(() => this.memoryScanningService.FindAndResolveAddress(ServerRegionAddressPattern, ServerRegionAddressMask, ServerRegionAddressOffset));
+ }
+
+ public List GetAddressStates()
+ {
+ return [
+ new AddressState
+ {
+ Address = this.instanceInfoAddress.GetAddress() ?? 0,
+ Name = nameof(this.instanceInfoAddress),
+ },
+ new AddressState
+ {
+ Address = this.areaInfoAddress.GetAddress() ?? 0,
+ Name = nameof(this.areaInfoAddress),
+ },
+ new AddressState
+ {
+ Address = this.serverRegionAddress.GetAddress() ?? 0,
+ Name = nameof(this.serverRegionAddress),
+ }
+ ];
+ }
+
+ public WrappedPointer GetInstanceInfoContext()
+ {
+ var instanceInfoAddress = this.instanceInfoAddress.GetAddress();
+ if (instanceInfoAddress is null)
+ {
+ this.logger.LogError("Failed to get instance info address");
+ return null;
+ }
+
+ return (InstanceInfoContext*)instanceInfoAddress;
+ }
+
+ public WrappedPointer GetAreaInfo()
+ {
+ var areaInfoAddress = this.areaInfoAddress.GetAddress();
+ if (areaInfoAddress is null)
+ {
+ this.logger.LogError("Failed to get area info address");
+ return null;
+ }
+
+ return (AreaInfo*)areaInfoAddress;
+ }
+
+ public ServerRegion GetServerRegion()
+ {
+ var serverRegionAddress = this.serverRegionAddress.GetAddress();
+ if (serverRegionAddress is null)
+ {
+ this.logger.LogError("Failed to get server region address");
+ return ServerRegion.Unknown;
+ }
+
+ return *(ServerRegion*)serverRegionAddress;
+ }
+
+ public InstanceType GetInstanceType()
+ {
+ var instanceInfo = this.GetInstanceInfoContext();
+ if (instanceInfo.IsNull)
+ {
+ return InstanceType.Loading;
+ }
+
+ return instanceInfo.Pointer->InstanceType;
+ }
+}
diff --git a/Daybreak.API/Services/Interop/MemoryScanningService.cs b/Daybreak.API/Services/Interop/MemoryScanningService.cs
new file mode 100644
index 00000000..15a37bd7
--- /dev/null
+++ b/Daybreak.API/Services/Interop/MemoryScanningService.cs
@@ -0,0 +1,335 @@
+using Daybreak.Shared.Models.Interop;
+using PeNet;
+using PeNet.Header.Pe;
+using System.Core.Extensions;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Extensions.Core;
+using System.Globalization;
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace Daybreak.API.Services.Interop;
+
+public sealed unsafe class MemoryScanningService
+{
+ private readonly ILogger logger;
+ private readonly (nuint BaseAddress, ImageSectionHeader Section) textSection = GetSectionHeader(".text");
+ private readonly (nuint BaseAddress, ImageSectionHeader Section) dataSection = GetSectionHeader(".rdata");
+
+ public MemoryScanningService(
+ ILogger logger)
+ {
+ this.logger = logger.ThrowIfNull();
+ }
+
+ public T? ReadPointer<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] T>(GuildwarsPointer ptr)
+ where T : struct
+ {
+ var scopedLogger = this.logger.CreateScopedLogger();
+ scopedLogger.LogInformation("Reading pointer: 0x{ptr:X8}", ptr.Address);
+ if (ptr.Address is 0)
+ {
+ return default;
+ }
+
+ return Marshal.PtrToStructure((nint)ptr.Address);
+ }
+
+ public nuint FunctionFromNearCall(nuint callInstructionAddress, bool checkValidPtr = true)
+ {
+ var scopedLogger = this.logger.CreateScopedLogger();
+ if (callInstructionAddress is 0)
+ {
+ scopedLogger.LogError("Invalid call instruction address: 0x{address:X8}", callInstructionAddress);
+ return 0;
+ }
+
+ var opcode = Marshal.ReadByte((nint)callInstructionAddress);
+ var functionAddress = opcode switch
+ {
+ 0xE8 => (nuint)((nint)callInstructionAddress + 5 + Marshal.ReadInt32((nint)(callInstructionAddress + 1))),
+ 0xE9 => (nuint)((nint)callInstructionAddress + 5 + Marshal.ReadInt32((nint)(callInstructionAddress + 1))),
+ 0xEB => (nuint)((nint)callInstructionAddress + 2 + (sbyte)Marshal.ReadByte((nint)(callInstructionAddress + 1))),
+ _ => (nuint)0
+ };
+
+ if (checkValidPtr && functionAddress is 0)
+ {
+ scopedLogger.LogError("Invalid function address: 0x{address:X8}", functionAddress);
+ return 0;
+ }
+
+ // Recursively resolve nested JMPs
+ var nestedCall = this.FunctionFromNearCall(functionAddress, checkValidPtr);
+ if (nestedCall != 0)
+ {
+ scopedLogger.LogInformation("Resolved nested call to: 0x{address:X8}", nestedCall);
+ return nestedCall;
+ }
+
+ scopedLogger.LogInformation("Resolved function address: 0x{address:X8}", functionAddress);
+ return functionAddress;
+ }
+
+ public nuint FindAssertion(
+ string assertionFile,
+ string assertionMessage,
+ uint lineNumber = 0,
+ int offset = 0)
+ {
+ Span pattern =
+ [
+ 0x68, 0,0,0,0, // push