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 / will be overwritten later + 0xBA, 0,0,0,0, // mov edx, / will be overwritten later + 0xB9, 0,0,0,0 // mov ecx, / will be overwritten later + ]; + + const string maskAll = "xxxxxxxxxxxxxxx"; // 15 × 'x' + + var msgBytes = Encoding.ASCII.GetBytes(assertionMessage + '\0'); + var msgMask = new string('x', msgBytes.Length); + + var fileOrigBytes = Encoding.ASCII.GetBytes(assertionFile + '\0'); + var fileOrigMask = new string('x', fileOrigBytes.Length); + + var (rdataBase, rdataHdr) = this.dataSection; + var rdataStart = rdataBase + rdataHdr.VirtualAddress; + var rdataEnd = rdataStart + rdataHdr.VirtualSize; + + nuint msgSearchPos = rdataStart; + while (true) + { + var msgPtr = FindInRange(msgBytes, msgMask, 0, msgSearchPos, rdataEnd); + if (msgPtr is 0) + { + break; // no more messages → we are done + } + + msgSearchPos = msgPtr + 1; // next search starts just after the hit + + // write ECX operand (little-endian) into the pattern + BitConverter.TryWriteBytes(pattern.Slice(11, 4), (uint)msgPtr); + + var fileSearchPos = rdataStart; + while (true) + { + var filePtr = FindInRange(fileOrigBytes, fileOrigMask, 0, fileSearchPos, rdataEnd); + + // fallbacks: lower-case and CamelCase, exactly like the C++ + if (filePtr == 0) + { + var lower = assertionFile.ToLowerInvariant(); + var lowerBytes = Encoding.ASCII.GetBytes(lower + '\0'); + filePtr = FindInRange(lowerBytes, new string('x', lowerBytes.Length), 0, fileSearchPos, rdataEnd); + } + + if (filePtr == 0) + { + var camel = ToCamelCase(assertionFile); + var camelBytes = Encoding.ASCII.GetBytes(camel + '\0'); + filePtr = FindInRange(camelBytes, new string('x', camelBytes.Length), 0, fileSearchPos, rdataEnd); + } + + if (filePtr == 0) + { + break; // try next message + } + + fileSearchPos = filePtr + 1; + + // If what we found is “…/file.hpp” (no drive letter) fix it + if (Marshal.ReadByte((nint)(filePtr + 1)) != (byte)':') + { + // look ≤128 bytes backward for ':' and back-up one char + nuint colon = FindInRange([(byte)':'], "x", -1, filePtr, filePtr - 128); + if (colon == 0) + { + continue; // failed → try next file hit + } + + filePtr = colon; + } + + // write EDX operand + BitConverter.TryWriteBytes(pattern.Slice(6, 4), (uint)filePtr); + + nuint hit = 0; + if (lineNumber != 0 && (lineNumber & 0xff) == lineNumber) + { + // PUSH imm8 variant – start matching at pattern[3] + pattern[3] = 0x6A; // opcode + pattern[4] = (byte)lineNumber; + hit = FindAddressInternal( + this.textSection, + pattern[3..].ToArray(), + maskAll[3..], + offset); + } + + if (hit == 0 && lineNumber != 0) + { + // PUSH imm32 variant – whole pattern + pattern[0] = 0x68; + BitConverter.TryWriteBytes(pattern.Slice(1, 4), lineNumber); + hit = FindAddressInternal( + this.textSection, + pattern.ToArray(), + maskAll, + offset); + } + + if (hit == 0 && lineNumber == 0) + { + // No line number – match only from MOV EDX onward + hit = FindAddressInternal( + this.textSection, + pattern[5..].ToArray(), + maskAll[5..], + offset); + } + + if (hit != 0) + { + return hit; // found exactly what we wanted + } + } + } + + return 0; // nothing matched + } + + public nuint FindAndResolveAddress(byte[] pattern, string mask, int offset = 0) + { + var scopedLogger = this.logger.CreateScopedLogger(); + var address = FindAddressInternal(this.textSection, pattern, mask, offset); + if (address is 0) + { + scopedLogger.LogError("Failed to find address"); + return 0; + } + + var ptr = (nuint)Marshal.ReadIntPtr((nint)address); + return ptr; + } + + public nuint FindAddress(byte[] pattern, string mask, int offset = 0) + { + var address = FindAddressInternal(this.textSection, pattern, mask, offset); + return address; + } + + public nuint ToFunctionStart(nuint callInstructionAddress, uint scanRange = 0x500) + { + if (callInstructionAddress == 0) + { + return 0; + } + + // pattern: 55 8B EC – mask: "xxx" + ReadOnlySpan prologue = [0x55, 0x8B, 0xEC]; + const string mask = "xxx"; + + var start = callInstructionAddress; // begin at the CALL itself + var end = callInstructionAddress - scanRange; // scan backwards + + return FindInRange(prologue, mask, 0, start, end); + } + + private static bool IsValidPtr(nuint address, (nuint BaseAddress, ImageSectionHeader Section) section) + { + return address > 0 && + (ulong)address > section.Section.ImageBaseAddress && + (ulong)address < (section.Section.ImageBaseAddress + section.Section.VirtualSize); + } + + private static unsafe nuint FindInRange( + ReadOnlySpan pattern, + string mask, + int offset, + nuint start, + nuint end) + { + bool forward = start < end; + int patLength = pattern.Length; + + if (forward) + { + end -= (uint)patLength; // forward scan ⇒ clamp tail + } + + var cur = start; + while (forward ? cur <= end : cur >= end) + { + if (Marshal.ReadByte((nint)cur) == pattern[0]) + { + var matched = true; + for (int i = 1; i < patLength && matched; ++i) + { + if (mask[i] == 'x' && + Marshal.ReadByte((nint)(cur + (uint)i)) != pattern[i]) + { + matched = false; + } + } + + if (matched) + { + return (nuint)((long)cur + offset); + } + } + + cur = forward ? cur + 1 : cur - 1; + if (!forward && cur == 0) // prevent unsigned wrap-around + { + break; + } + } + + return 0; + } + + private static string ToCamelCase(string text) + => CultureInfo.InvariantCulture.TextInfo.ToTitleCase(text.ToLowerInvariant()); + + private static nuint FindAddressInternal((nuint BaseAddress, ImageSectionHeader Section) section, byte[] pattern, string mask, int offset = 0) + { + var textStart = section.BaseAddress + section.Section.VirtualAddress; + var textEnd = textStart + section.Section.VirtualSize - (uint)pattern.Length; + + for (var cur = textStart; cur <= textEnd; ++cur) + { + if (Marshal.ReadByte((nint)cur) != pattern[0]) + { + continue; + } + + var match = true; + for (var i = 1; i < pattern.Length && match; ++i) + { + if (mask[i] is 'x' && Marshal.ReadByte((nint)(cur + (uint)i)) != pattern[i]) + { + match = false; + } + } + + if (match) + { + return (nuint)((nint)cur + offset); + } + } + + return 0; + } + + private static (nuint, ImageSectionHeader) GetSectionHeader(string headerName) + { + var m = Process.GetCurrentProcess().MainModule ?? throw new InvalidOperationException("Failed to initialize memory scanner. Failed to find main module"); + var baseAddr = (nuint)m.BaseAddress; + var pe = new PeFile(m.FileName); + var textHdr = pe.ImageSectionHeaders?.FirstOrDefault(h => h.Name == headerName); + return textHdr is null + ? throw new InvalidOperationException($"Failed to initialize memory scanner. Failed to find {headerName} section") + : ((nuint, ImageSectionHeader))(baseAddr, textHdr); + } +} diff --git a/Daybreak.API/Services/Interop/PartyContextService.cs b/Daybreak.API/Services/Interop/PartyContextService.cs new file mode 100644 index 00000000..76993762 --- /dev/null +++ b/Daybreak.API/Services/Interop/PartyContextService.cs @@ -0,0 +1,99 @@ +using Daybreak.API.Interop; +using Daybreak.API.Models; +using System.Core.Extensions; +using System.Extensions; + +namespace Daybreak.API.Services.Interop; + +public sealed class PartyContextService + : IAddressHealthService +{ + private const string SearchButtonFile = "\\Code\\Gw\\Ui\\Game\\Party\\PtSearch.cpp"; + private const string SearchButtonAssertion = "m_activeList == LIST_HEROES"; + private const string WindowButtonFile = "\\Code\\Gw\\Ui\\Game\\Party\\PtButtons.cpp"; + private const string WindowButtonAssertion = "m_selection.agentId"; + + // Fastcall funcs do not work in .NET for x86 right now. https://github.com/dotnet/runtime/issues/113851 + // This fastcall equivalent is + private readonly GWFastCall searchButtonCallback; + // This fastcall equivalent is + private readonly GWFastCall windowButtonCallback; + + private readonly MemoryScanningService memoryScanningService; + private readonly ILogger logger; + + public PartyContextService( + MemoryScanningService memoryScanningService, + ILogger logger) + { + this.memoryScanningService = memoryScanningService.ThrowIfNull(); + this.logger = logger.ThrowIfNull(); + this.searchButtonCallback = new(new GWAddressCache(() => this.memoryScanningService.ToFunctionStart(this.memoryScanningService.FindAssertion(SearchButtonFile, SearchButtonAssertion, 0, 0)))); + this.windowButtonCallback = new(new GWAddressCache(() => this.memoryScanningService.ToFunctionStart(this.memoryScanningService.FindAssertion(WindowButtonFile, WindowButtonAssertion, 0, 0)))); + } + + public List GetAddressStates() + { + unsafe + { + return + [ + new AddressState + { + Address = this.searchButtonCallback.Cache.GetAddress() ?? 0, + Name = nameof(this.searchButtonCallback) + }, + new AddressState + { + Address = this.windowButtonCallback.Cache.GetAddress() ?? 0, + Name = nameof(this.windowButtonCallback) + } + ]; + } + } + + public unsafe bool CallSearchButtonCallback(void* ctx, uint edx, uint* wparam) + { + this.searchButtonCallback.Invoke((nint)ctx, edx, (nint)wparam); + return true; + } + + public unsafe bool CallWindowButtonCallback(void* ctx, uint edx, uint* wparam) + { + this.windowButtonCallback.Invoke((nint)ctx, edx, (nint)wparam); + return true; + } + + public unsafe bool AddHero(uint heroId) + { + var wparam = stackalloc uint[4]; + wparam[2] = 0x6; + wparam[1] = 0x1; + + var ctx = stackalloc uint[13]; + ctx[0xb] = 1; + ctx[9] = heroId; + return this.CallSearchButtonCallback(ctx, 2, wparam); + } + + public unsafe bool KickHero(uint heroId) + { + var wparam = stackalloc uint[4]; + wparam[2] = 0x6; + wparam[1] = 0x6; + + var ctx = stackalloc uint[13]; + ctx[0xb] = 1; + ctx[9] = heroId; + return this.CallSearchButtonCallback(ctx, 0, wparam); + } + + public bool KickAllHeroes() => this.KickHero(0x26); + + public unsafe bool LeaveParty() + { + var ctx = stackalloc uint[14]; + ctx[0xd] = 1; + return this.CallWindowButtonCallback(ctx, 0, (uint*)0); + } +} diff --git a/Daybreak.API/Services/Interop/PlatformContextService.cs b/Daybreak.API/Services/Interop/PlatformContextService.cs new file mode 100644 index 00000000..3b65f67c --- /dev/null +++ b/Daybreak.API/Services/Interop/PlatformContextService.cs @@ -0,0 +1,64 @@ +using Daybreak.API.Interop; +using Daybreak.API.Models; +using System.Core.Extensions; +using System.Extensions.Core; + +namespace Daybreak.API.Services.Interop; + +public sealed class PlatformContextService + : IAddressHealthService +{ + private const string WindowHandleAddressMask = "xxxxx????xxx"; + private const int WindowHandleAddressOffset = -0xC; + private static readonly byte[] WindowHandleAddressPattern = [0x83, 0xC4, 0x04, 0x83, 0x3D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x75, 0x31]; + + private readonly MemoryScanningService memoryScanningService; + private readonly GWAddressCache windowHandleAddress; + private readonly ILogger logger; + + public PlatformContextService( + MemoryScanningService memoryScanningService, + ILogger logger) + { + this.logger = logger.ThrowIfNull(); + this.memoryScanningService = memoryScanningService.ThrowIfNull(); + this.windowHandleAddress = new GWAddressCache(this.GetWindowHandleAddress); + } + + public List GetAddressStates() + { + return [ + new AddressState + { + Address = this.windowHandleAddress.GetAddress() ?? 0, + Name = nameof(this.windowHandleAddress), + }, + ]; + } + + public unsafe uint? GetWindowHandle() + { + var windowHandlePtr = this.windowHandleAddress.GetAddress(); + if (windowHandlePtr is null) + { + this.logger.LogError("Failed to get window handle"); + return null; + } + + return *(uint*)windowHandlePtr; + } + + private nuint GetWindowHandleAddress() + { + var scopedLogger = this.logger.CreateScopedLogger(); + var baseAddress = this.memoryScanningService.FindAndResolveAddress(WindowHandleAddressPattern, WindowHandleAddressMask, WindowHandleAddressOffset); + if (baseAddress is 0) + { + scopedLogger.LogError("Failed to find window handle address"); + return 0U; + } + + scopedLogger.LogInformation("Window handle address: 0x{address:X8}", baseAddress); + return baseAddress; + } +} diff --git a/Daybreak.API/Services/Interop/SkillbarContextService.cs b/Daybreak.API/Services/Interop/SkillbarContextService.cs new file mode 100644 index 00000000..af07c1e1 --- /dev/null +++ b/Daybreak.API/Services/Interop/SkillbarContextService.cs @@ -0,0 +1,62 @@ +using Daybreak.API.Interop; +using Daybreak.API.Interop.GuildWars; +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 SkillbarContextService + : IAddressHealthService +{ + private const string LoadSkillTemplateFile = "TemplatesHelpers.cpp"; + private const string LoadSkillTemplateAssertion = "targetPrimaryProf == templateData.profPrimary"; + + [SuppressUnmanagedCodeSecurity] + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private unsafe delegate void LoadSkillTemplate(uint agentId, SkillTemplate* template); + + private readonly MemoryScanningService memoryScanningService; + private readonly GWDelegateCache loadSkillTemplateFunc; + private readonly ILogger logger; + + public SkillbarContextService( + MemoryScanningService memoryScanningService, + ILogger logger) + { + this.logger = logger.ThrowIfNull(); + this.memoryScanningService = memoryScanningService.ThrowIfNull(); + unsafe + { + this.loadSkillTemplateFunc = new GWDelegateCache( + new GWAddressCache(() => this.memoryScanningService.ToFunctionStart( + this.memoryScanningService.FindAssertion(LoadSkillTemplateFile, LoadSkillTemplateAssertion, 0, 0), 0x200))); + } + } + + public List GetAddressStates() + { + return + [ + new() + { + Address = this.loadSkillTemplateFunc.Cache.GetAddress() ?? 0U, + Name = nameof(this.loadSkillTemplateFunc) + } + ]; + } + + public unsafe void LoadBuild(uint agentId, SkillTemplate* template) + { + var scopedLogger = this.logger.CreateScopedLogger(); + if (this.loadSkillTemplateFunc.GetDelegate() is not LoadSkillTemplate func) + { + scopedLogger.LogError("Load skill template delegate is not initialized"); + return; + } + + func(agentId, template); + } +} diff --git a/Daybreak.API/Services/Interop/UIContextService.cs b/Daybreak.API/Services/Interop/UIContextService.cs new file mode 100644 index 00000000..ab6713b2 --- /dev/null +++ b/Daybreak.API/Services/Interop/UIContextService.cs @@ -0,0 +1,385 @@ +using Daybreak.API.Interop; +using Daybreak.API.Interop.GuildWars; +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 UIContextService + : IAddressHealthService, IHostedService, IHookHealthService +{ + private const string GetChildFrameIdFile = "CtlView.cpp"; + private const string GetChildFrameIdAssertion = "pageId"; + private const int GetChildFrameIdOffset = 0x19; + private const string SetFrameFlagFile = "FrApi.cpp"; + private const string SetFrameFlagAssertion = "frameId"; + private const int SetFrameVisibleOffset = 0x4f9; + private const int SetFrameDisabledOffset = 0x4e3; + private static readonly byte[] SendFrameUiMessageByIdSeq = [0x83, 0xFB, 0x47, 0x73, 0x14]; + private const string SendFrameUiMessageByIdMask = "xxxxx"; + private const int SendFrameUiMessageByIdOffset = -0x34; + private static readonly byte[] SendUIMessageSeq = [0xB9, 0x00, 0x00, 0x00, 0x00, 0xE8, 0x00, 0x00, 0x00, 0x00, 0x5D, 0xC3, 0x89, 0x45, 0x08]; + private const string SendUIMessageMask = "x????x????xxxxx"; + private const string CreateHashFromStringMask = "xxxxxxx"; + private const int CreateHashFromStringOffset = 0x7; + private static readonly byte[] CreateHashFromStringSeq = [0x85, 0xC0, 0x74, 0x0D, 0x6A, 0xFF, 0x50]; + private const string FrameArrayFile = "\\Code\\Engine\\Frame\\FrMsg.cpp"; + private const string FrameArrayAssertion = "frame"; + private static readonly byte[] GetRootFrameSeq = [0x05, 0xE0, 0xFE, 0xFF, 0xFF, 0xC3]; + private const string GetRootFrameMask = "xxxxxx"; + private const int GetRootFrameOffset = -0x3C; + + [SuppressUnmanagedCodeSecurity] + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private unsafe delegate uint CreateHashFromStringFunc(char* value, int seed); + + [SuppressUnmanagedCodeSecurity] + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void SendUIMessageFunc(UIMessage uiMessage, nuint wParam, nuint lParam); + + [SuppressUnmanagedCodeSecurity] + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void SetFrameFlagFunc(uint frameId, uint flag); + + [SuppressUnmanagedCodeSecurity] + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate uint GetChildFrameIdFunc(uint parentFrameId, uint childOffset); + + [SuppressUnmanagedCodeSecurity] + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private unsafe delegate Frame* GetRootFrameFunc(); + + private readonly SemaphoreSlim semaphoreSlim = new(1); + private readonly MemoryScanningService memoryScanningService; + private readonly GWDelegateCache createHashFromString; + private readonly GWDelegateCache getChildFrameId; + private readonly GWAddressCache frameArrayAddressCache; + private readonly GWHook sendUiMessageHook; + private readonly GWDelegateCache getRootFrame; + // This fastcall equivalent is *, void*, UIMessage, void*, void*> + private readonly GWFastCall sendFrameUIMessage; + private readonly ILogger logger; + + private CancellationTokenSource? cts = default; + + public UIContextService( + MemoryScanningService memoryScanningService, + ILogger logger) + { + this.logger = logger.ThrowIfNull(); + this.memoryScanningService = memoryScanningService.ThrowIfNull(); + this.createHashFromString = new GWDelegateCache(new GWAddressCache(() => this.memoryScanningService.FunctionFromNearCall( + this.memoryScanningService.FindAddress(CreateHashFromStringSeq, CreateHashFromStringMask, CreateHashFromStringOffset)))); + this.frameArrayAddressCache = new GWAddressCache(() => this.memoryScanningService.FindAssertion(FrameArrayFile, FrameArrayAssertion, 0, -0x14)); + this.sendUiMessageHook = new GWHook( + new GWAddressCache(() => this.memoryScanningService.ToFunctionStart( + this.memoryScanningService.FindAddress(SendUIMessageSeq, SendUIMessageMask))), + this.OnSendUIMessage); + this.sendFrameUIMessage = new( + new GWAddressCache(() => this.memoryScanningService.FunctionFromNearCall( + this.memoryScanningService.FindAddress(SendFrameUiMessageByIdSeq, SendFrameUiMessageByIdMask, SendFrameUiMessageByIdOffset) + 0x67))); + this.getChildFrameId = new GWDelegateCache( + new GWAddressCache(() => this.memoryScanningService.FunctionFromNearCall( + this.memoryScanningService.FindAssertion(GetChildFrameIdFile, GetChildFrameIdAssertion, 0, GetChildFrameIdOffset)))); + this.getRootFrame = new GWDelegateCache( + new GWAddressCache(() => this.memoryScanningService.FindAddress(GetRootFrameSeq, GetRootFrameMask, GetRootFrameOffset))); + } + + public List GetAddressStates() + { + return + [ + new() + { + Name = nameof(this.createHashFromString), + Address = this.createHashFromString.Cache.GetAddress() ?? 0U + }, + new() + { + Name = nameof(this.frameArrayAddressCache), + Address = this.frameArrayAddressCache.GetAddress() ?? 0U + }, + new() + { + Name = nameof(this.getChildFrameId), + Address = this.getChildFrameId.Cache.GetAddress() ?? 0U + }, + new() + { + Name = nameof(this.sendFrameUIMessage), + Address = this.sendFrameUIMessage.Cache.GetAddress() ?? 0U + }, + new() + { + Name = nameof(this.getRootFrame), + Address = this.getRootFrame.Cache.GetAddress() ?? 0U + } + ]; + } + + public List GetHookStates() + { + return + [ + new HookState + { + Hooked = this.sendUiMessageHook.Hooked, + Name = nameof(this.sendUiMessageHook), + TargetAddress = this.sendUiMessageHook.TargetAddress, + ContinueAddress = this.sendUiMessageHook.ContinueAddress, + DetourAddress = this.sendUiMessageHook.DetourAddress + } + ]; + } + + 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.sendUiMessageHook.Dispose(); + return Task.CompletedTask; + } + + public unsafe WrappedPointer GetChildFrame(WrappedPointer parent, uint childOffset) + { + var scopedLogger = this.logger.CreateScopedLogger(); + if (parent.IsNull) + { + scopedLogger.LogError("Parent frame is null"); + return null; + } + + var getChildFrameId = this.getChildFrameId.GetDelegate(); + if (getChildFrameId is null) + { + scopedLogger.LogError("Failed to get GetChildFrameId delegate"); + return null; + } + + var childFrameId = getChildFrameId(parent.Pointer->FrameId, childOffset); + if (childFrameId == 0) + { + scopedLogger.LogError("No child frame found for parent frame {parent}", parent.Pointer->FrameId); + return null; + } + + return this.GetFrameById(childFrameId); + } + + public WrappedPointer GetButtonActionFrame() + { + return this.GetChildFrame(this.GetFrameByLabel("Game"), 6); + } + + public unsafe bool SendFrameUIMessage(WrappedPointer frame, UIMessage messageId, void* arg1, void* arg2 = null) + { + var scopedLogger = this.logger.CreateScopedLogger(); + if (frame.IsNull) + { + scopedLogger.LogError("Frame is null"); + return false; + } + + this.sendFrameUIMessage.Invoke((nint)(&frame.Pointer->FrameCallbacks), 0, messageId, (nint)arg1, (nint)arg2); + return true; + } + + public unsafe bool SetFrameDisabled(WrappedPointer frame, bool disabled) + { + var scopedLogger = this.logger.CreateScopedLogger(); + if (frame.IsNull) + { + scopedLogger.LogError("Frame is null"); + return false; + } + + SetFrameDisabledInternal(frame, disabled); + return true; + } + + public unsafe bool SetFrameVisible(WrappedPointer frame, bool visible) + { + var scopedLogger = this.logger.CreateScopedLogger(); + if (frame.IsNull) + { + scopedLogger.LogError("Frame is null"); + return false; + } + + SetFrameVisibleInternal(frame, visible); + return true; + } + + public unsafe bool KeyDown(UIAction action, WrappedPointer frame) + { + if (frame.IsNull) + { + frame = this.GetButtonActionFrame(); + } + + this.semaphoreSlim.Wait(); + var packet = new UIPackets.KeyAction((uint)action); + this.SendFrameUIMessage(frame, UIMessage.KeyDown, &packet); + this.semaphoreSlim.Release(); + return true; + } + + public unsafe bool KeyUp(UIAction action, WrappedPointer frame) + { + if (frame.IsNull) + { + frame = this.GetButtonActionFrame(); + } + + this.semaphoreSlim.Wait(); + var packet = new UIPackets.KeyAction((uint)action); + this.SendFrameUIMessage(frame, UIMessage.KeyUp, &packet); + this.semaphoreSlim.Release(); + return true; + } + + public unsafe WrappedPointer>> GetFrameArray() + { + if (this.frameArrayAddressCache.GetAddress() is not nuint address) + { + return null; + } + + return *(GuildWarsArray>**)address; + } + + public unsafe WrappedPointer GetFrameByLabel(string label) + { + var scopedLogger = this.logger.CreateScopedLogger(); + var frameArray = this.GetFrameArray(); + if (frameArray.IsNull) + { + scopedLogger.LogError("Frame array is null"); + return null; + } + + var hash = this.CreateHashFromString(label, -1); + if (hash is 0) + { + scopedLogger.LogError("Failed to get hash for label {label}", label); + return null; + } + + for(var i = 0; i < frameArray.Pointer->Size; i++) + { + var frame = frameArray.Pointer->Buffer[i]; + if (frame.Pointer is null || + (int)frame.Pointer == -1) + { + continue; + } + + if (frame.Pointer->Relation.FrameHashId == hash) + { + return frame; + } + } + + scopedLogger.LogWarning("Frame with label {label} not found", label); + return null; + } + + public unsafe WrappedPointer GetFrameById(uint frameId) + { + var scopedLogger = this.logger.CreateScopedLogger(); + var frameArray = this.GetFrameArray(); + if (frameArray.IsNull || + frameArray.Pointer->Size <= frameId) + { + scopedLogger.LogError("Frame array is null or frameId {frameId} is out of bounds", frameId); + return null; + } + + var frame = frameArray.Pointer->Skip((int)frameId).FirstOrDefault(); + if (frame.IsNull) + { + scopedLogger.LogWarning("Frame with id {frameId} not found", frameId); + return null; + } + + return frame; + } + + public unsafe uint CreateHashFromString(string value, int seed) + { + var scopedLogger = this.logger.CreateScopedLogger(); + if (this.createHashFromString.GetDelegate() is not CreateHashFromStringFunc del) + { + scopedLogger.LogError("Failed to get CreateHashFromString delegate"); + return 0; + } + + fixed (char* valuePtr = value) + { + return del(valuePtr, seed); + } + } + + public unsafe void SendMessage(UIMessage message, nuint wParam, nuint lParam) + { + this.semaphoreSlim.Wait(); + this.sendUiMessageHook.Continue(message, wParam, lParam); + this.semaphoreSlim.Release(); + } + + private async ValueTask InitializeHooks(CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(); + scopedLogger.LogInformation("Initializing hooks"); + while (!this.sendUiMessageHook.EnsureInitialized()) + { + scopedLogger.LogDebug("Waiting for hook to initialize: {hook}", nameof(this.sendUiMessageHook)); + await Task.Delay(1000, cancellationToken); + } + + scopedLogger.LogInformation("Hooks initialized"); + } + + private void OnSendUIMessage(UIMessage uiMessage, nuint wParam, nuint lParam) + { + this.sendUiMessageHook.Continue(uiMessage, wParam, lParam); + } + + private static unsafe void SetFrameVisibleInternal(WrappedPointer frame, bool visible) + { + uint* pState = &frame.Pointer->Field91; // direct field address + if (visible) + { + *pState &= ~0x200u; // clear “hidden” + } + else + { + *pState |= 0x200u; // set “hidden” + } + + } + + private static unsafe void SetFrameDisabledInternal(WrappedPointer frame, bool disabled) + { + uint* pState = &frame.Pointer->Field91; // same word (offset 0x184) + + if (disabled) + { + *pState |= 0x10u; // set “disabled” + } + else + { + *pState &= ~0x10u; // clear + } + } +} diff --git a/Daybreak.API/Services/MainPlayerService.cs b/Daybreak.API/Services/MainPlayerService.cs new file mode 100644 index 00000000..a2558305 --- /dev/null +++ b/Daybreak.API/Services/MainPlayerService.cs @@ -0,0 +1,590 @@ +using Daybreak.API.Extensions; +using Daybreak.API.Interop.GuildWars; +using Daybreak.API.Models; +using Daybreak.API.Services.Interop; +using Daybreak.Shared.Models; +using Daybreak.Shared.Models.Api; +using Daybreak.Shared.Models.Builds; +using Daybreak.Shared.Services.BuildTemplates; +using MemoryPack; +using System.Buffers; +using System.Collections.Concurrent; +using System.Core.Extensions; +using System.Extensions; +using System.Extensions.Core; +using System.Runtime.CompilerServices; +using ZLinq; +using InstanceType = Daybreak.API.Interop.GuildWars.InstanceType; + +namespace Daybreak.API.Services; + +public sealed class MainPlayerService : IDisposable +{ + private readonly InstanceContextService instanceContextService; + private readonly IBuildTemplateManager buildTemplateManager; + private readonly SkillbarContextService skillbarContextService; + private readonly AgentContextService agentContextService; + private readonly CallbackRegistration callbackRegistration; + private readonly GameContextService gameContextService; + private readonly GameThreadService gameThreadService; + private readonly ILogger logger; + + private readonly ConcurrentQueue> pendingRequests = new(); + private readonly ConcurrentDictionary consumers = new(); + private readonly ArrayBufferWriter bufferWriter = new(); + + private TimeSpan minUpdateFrequency = TimeSpan.MaxValue; + private MainPlayerState? mainPlayerState; + private DateTimeOffset lastUpdateTime = DateTimeOffset.MinValue; + private DateTimeOffset lastFrequencyUpdate = DateTimeOffset.MinValue; + + public MainPlayerService( + InstanceContextService instanceContextService, + IBuildTemplateManager buildTemplateManager, + SkillbarContextService skillbarContextService, + GameContextService gameContextService, + AgentContextService agentContextService, + GameThreadService gameThreadService, + ILogger logger) + { + this.instanceContextService = instanceContextService.ThrowIfNull(); + this.buildTemplateManager = buildTemplateManager.ThrowIfNull(); + this.skillbarContextService = skillbarContextService.ThrowIfNull(); + this.gameThreadService = gameThreadService.ThrowIfNull(); + this.agentContextService = agentContextService.ThrowIfNull(); + this.gameContextService = gameContextService.ThrowIfNull(); + this.logger = logger.ThrowIfNull(); + this.callbackRegistration = this.gameThreadService.RegisterCallback(this.OnGameThreadProc); + } + + public void Dispose() + { + this.callbackRegistration?.Dispose(); + } + + public Task SetCurrentBuild(string buildCode, CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(); + if (!this.buildTemplateManager.TryDecodeTemplate(buildCode, out var build) || + build is not SingleBuildEntry singleBuild) + { + scopedLogger.LogError("Failed to decode build template from code {buildCode}", buildCode); + return Task.FromResult(false); + } + + return this.gameThreadService.QueueOnGameThread(() => + { + unsafe + { + if (this.instanceContextService.GetInstanceType() is InstanceType.Loading or InstanceType.Explorable) + { + scopedLogger.LogError("Not in outpost"); + return default; + } + + var gameContext = this.gameContextService.GetGameContext(); + if (gameContext.IsNull || + gameContext.Pointer->WorldContext is null) + { + scopedLogger.LogError("Failed to get game context"); + return false; + } + + var playerAgentId = this.agentContextService.GetPlayerAgentId(); + if (playerAgentId is 0x0) + { + scopedLogger.LogError("Failed to get player agent id"); + return false; + } + + var playerAgent = this.GetAgentContext(playerAgentId); + if (playerAgent is null || + playerAgent->Type is not AgentType.Living || + playerAgent->AgentId != playerAgentId) + { + scopedLogger.LogError("Player agent {playerAgentId} not found in agent array", playerAgentId); + return false; + } + + var livingAgent = (AgentLivingContext*)playerAgent; + if (livingAgent->Level != gameContext.Pointer->WorldContext->Level) + { + scopedLogger.LogError("Player agent not found. Player level mismatch: {level} != {gameLevel}", livingAgent->Level, gameContext.Pointer->WorldContext->Level); + return false; + } + + var agentProfession = gameContext.Pointer->WorldContext->Professions.AsValueEnumerable().FirstOrDefault(p => p.AgentId == playerAgentId); + if (agentProfession.AgentId != playerAgentId) + { + scopedLogger.LogError("Failed to find agent profession for player agent id {agentId}", playerAgentId); + return false; + } + + var validationRequest = new BuildTemplateValidationRequest( + (uint)singleBuild.Primary.Id, + (uint)singleBuild.Secondary.Id, + singleBuild.Skills.AsValueEnumerable().Select(s => (uint)s.Id).ToArray(), + livingAgent->Primary, + agentProfession.UnlockedProfessionsFlags, + [.. gameContext.Pointer->WorldContext->UnlockedCharacterSkills]); + + if (!this.buildTemplateManager.CanTemplateApply(validationRequest)) + { + scopedLogger.LogError("Build template validation failed for player agent id {agentId}", playerAgentId); + return false; + } + + var attributeIds = new Array12Uint(); + var attributeValues = new Array12Uint(); + var skills = new Array8Uint(); + for(var i = 0; i < singleBuild.Attributes.Count && i < 12; i++) + { + if (singleBuild.Attributes[i].Attribute is null) + { + continue; + } + + attributeIds[i] = (uint)singleBuild.Attributes[i].Attribute!.Id; + attributeValues[i] = (uint)singleBuild.Attributes[i].Points; + } + + for(var i =0; i < singleBuild.Skills.Count && i < 8; i++) + { + skills[i] = (uint)singleBuild.Skills[i].Id; + } + + var skillTemplate = new SkillTemplate( + (uint)singleBuild.Primary.Id, + (uint)singleBuild.Secondary.Id, + (uint)Math.Min(singleBuild.Attributes.Count, 12), + attributeIds, + attributeValues, + skills); + + this.skillbarContextService.LoadBuild(playerAgentId, &skillTemplate); + return true; + } + }, cancellationToken); + } + + public Task GetCurrentBuild(CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(); + return this.gameThreadService.QueueOnGameThread(() => + { + unsafe + { + if (this.instanceContextService.GetInstanceType() is InstanceType.Loading) + { + scopedLogger.LogError("Not loaded"); + return default; + } + + var gameContext = this.gameContextService.GetGameContext(); + if (gameContext.IsNull) + { + scopedLogger.LogError("Failed to get game context"); + return default; + } + + var playerAgentId = this.agentContextService.GetPlayerAgentId(); + if (playerAgentId is 0x0) + { + scopedLogger.LogError("Failed to get player agent id"); + return default; + } + + var skillbarContext = gameContext.Pointer->WorldContext->Skillbars.AsValueEnumerable().FirstOrDefault(s => s.AgentId == playerAgentId); + if (skillbarContext.AgentId != playerAgentId) + { + scopedLogger.LogError("Failed to find skillbar context for player agent id {agentId}", playerAgentId); + return default; + } + + var agentAttributes = gameContext.Pointer->WorldContext->Attributes.AsValueEnumerable().FirstOrDefault(a => a.AgentId == playerAgentId); + if (agentAttributes.AgentId != playerAgentId) + { + scopedLogger.LogError("Failed to find agent attributes for player agent id {agentId}", playerAgentId); + return default; + } + + var professionContext = gameContext.Pointer->WorldContext->Professions.AsValueEnumerable().FirstOrDefault(p => p.AgentId == playerAgentId); + if (professionContext.AgentId != playerAgentId) + { + scopedLogger.LogError("Failed to find agent profession for player agent id {agentId}", playerAgentId); + return default; + } + + return new BuildEntry( + (int)professionContext.CurrentPrimary, + (int)professionContext.CurrentSecondary, + agentAttributes.Attributes.GetAttributeEntryList(), + [skillbarContext.Skill0.Id, skillbarContext.Skill1.Id, skillbarContext.Skill2.Id, skillbarContext.Skill3.Id, + skillbarContext.Skill4.Id, skillbarContext.Skill5.Id, skillbarContext.Skill6.Id, skillbarContext.Skill7.Id]); + } + }, cancellationToken); + } + + public Task GetQuestLog(CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(); + return this.gameThreadService.QueueOnGameThread(() => + { + unsafe + { + if (this.instanceContextService.GetInstanceType() is InstanceType.Loading) + { + scopedLogger.LogError("Not loaded"); + return default; + } + + var gameContext = this.gameContextService.GetGameContext(); + if (gameContext.IsNull || gameContext.Pointer->WorldContext is null) + { + scopedLogger.LogError("Game context is not initialized"); + return default; + } + + return new QuestLogInformation( + gameContext.Pointer->WorldContext->ActiveQuestId, + gameContext.Pointer->WorldContext->QuestLog.AsValueEnumerable().Select(q => new QuestInformation(q.QuestId, q.MapFrom, q.MapTo)).ToList()); + } + }, cancellationToken); + } + + public Task GetMainPlayerInformation(CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(); + return this.gameThreadService.QueueOnGameThread(() => + { + unsafe + { + if (this.instanceContextService.GetInstanceType() is InstanceType.Loading) + { + scopedLogger.LogError("Not loaded"); + return default; + } + + var gameContext = this.gameContextService.GetGameContext(); + if (gameContext.IsNull || gameContext.Pointer->CharContext is null || + gameContext.Pointer->WorldContext is null) + { + scopedLogger.LogError("Game context is not initialized"); + return default; + } + + var playerNameSpan = gameContext.Pointer->CharContext->PlayerName.AsSpan(); + var playerName = new string(playerNameSpan[..playerNameSpan.IndexOf('\0')]); + var emailSpan = gameContext.Pointer->CharContext->PlayerEmail.AsSpan(); + var email = new string(emailSpan[..emailSpan.IndexOf('\0')]); + var accountName = new string(gameContext.Pointer->WorldContext->AccountInfo->AccountName); + return new MainPlayerInformation( + gameContext.Pointer->CharContext->PlayerUuid.ToString(), + email, + playerName, + accountName, + gameContext.Pointer->WorldContext->AccountInfo->Wins, + gameContext.Pointer->WorldContext->AccountInfo->Losses, + gameContext.Pointer->WorldContext->AccountInfo->Rating, + gameContext.Pointer->WorldContext->AccountInfo->QualifierPoints, + gameContext.Pointer->WorldContext->AccountInfo->Rank, + gameContext.Pointer->WorldContext->AccountInfo->TournamentRewardPoints); + } + }, cancellationToken); + } + + public Task GetMainPlayerState(CancellationToken cancellationToken) + { + var tcs = new TaskCompletionSource(); + if (cancellationToken.CanBeCanceled) + { + cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken)); + } + + this.pendingRequests.Enqueue(tcs); + return tcs.Task; + } + + public Task GetMainPlayerInstance(CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(); + return this.gameThreadService.QueueOnGameThread(() => + { + unsafe + { + var instanceType = this.instanceContextService.GetInstanceType(); + if (instanceType is InstanceType.Loading) + { + return new InstanceInfo(0, 0, 0, 0, Shared.Models.Api.InstanceType.Loading, DistrictRegionInfo.Unknown, LanguageInfo.Unknown, CampaignInfo.Unknown, ContinentInfo.Unknown, RegionInfo.Unknown, DifficultyInfo.Unknown); + } + + var gameContext = this.gameContextService.GetGameContext(); + var instanceInfoContext = this.instanceContextService.GetInstanceInfoContext(); + var serverRegion = this.instanceContextService.GetServerRegion(); + if (gameContext.IsNull || + gameContext.Pointer->CharContext is null || + gameContext.Pointer->WorldContext is null || + gameContext.Pointer->PartyContext is null || + instanceInfoContext.IsNull) + { + scopedLogger.LogError("Game context is not initialized"); + return new InstanceInfo(0, 0, 0, 0, Shared.Models.Api.InstanceType.Loading, DistrictRegionInfo.Unknown, LanguageInfo.Unknown, CampaignInfo.Unknown, ContinentInfo.Unknown, RegionInfo.Unknown, DifficultyInfo.Unknown); + } + + var charContext = *gameContext.Pointer->CharContext; + var worldContext = *gameContext.Pointer->WorldContext; + var partyContext = *gameContext.Pointer->PartyContext; + var instanceInfo = *instanceInfoContext.Pointer; + var mapId = charContext.MapId; + var language = charContext.Language; + var districtNumber = charContext.DistrictNumber; + var foesKilled = worldContext.FoesKilled; + var foesToKill = worldContext.FoesToKill; + + return new InstanceInfo( + MapId: charContext.MapId, + DistrictNumber: charContext.DistrictNumber, + FoesKilled: worldContext.FoesKilled, + FoesToKill: worldContext.FoesToKill, + Type: (Shared.Models.Api.InstanceType)instanceType, + DistrictRegion: (DistrictRegionInfo)serverRegion, + Language: (LanguageInfo)charContext.Language, + Campaign: (CampaignInfo)instanceInfo.CurrentMapInfo->Campaign, + Continent: (ContinentInfo)instanceInfo.CurrentMapInfo->Continent, + Region: (RegionInfo)instanceInfo.CurrentMapInfo->Region, + Difficulty: partyContext.Flags.HasFlag(PartyFlags.HardMode) ? DifficultyInfo.Hard : DifficultyInfo.Normal); + } + }, cancellationToken); + } + + public Task GetTitleInfo(CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(); + return this.gameThreadService.QueueOnGameThread(() => + { + unsafe + { + if (this.instanceContextService.GetInstanceType() is InstanceType.Loading) + { + scopedLogger.LogError("Not loaded"); + return default; + } + + var gameContext = this.gameContextService.GetGameContext(); + if (gameContext.IsNull || gameContext.Pointer->WorldContext is null) + { + scopedLogger.LogError("Game context is not initialized"); + return default; + } + + if (!gameContext.TryGetPlayerId(out var playerId)) + { + scopedLogger.LogError("Failed to get player id"); + return default; + } + + var player = gameContext.Pointer->WorldContext->Players.AsValueEnumerable().FirstOrDefault(p => p.AgentId == playerId); + if (player.AgentId != playerId) + { + scopedLogger.LogError("Failed to find player with id {playerId}", playerId); + return default; + } + + var currentTier = player.ActiveTitleTier; + TitleContext? currentTitle = default; + int? id = -1; + for(var i = 0; i < gameContext.Pointer->WorldContext->Titles.Size; i++) + { + var title = gameContext.Pointer->WorldContext->Titles.Skip(i).FirstOrDefault(); + if (title.CurrentTitleTierIndex == currentTier) + { + currentTitle = title; + id = i; + break; + } + } + + if (!currentTitle.HasValue) + { + scopedLogger.LogError("Failed to find current title with tier {currentTier}", currentTier); + return default; + } + + var titleTier = gameContext.Pointer->WorldContext->TitleTiers.Skip((int)currentTitle.Value.CurrentTitleTierIndex).FirstOrDefault(); + return new TitleInfo + ( + Id: (uint)id, + IsPercentage: currentTitle.Value.Props.HasFlag(TitleProps.PercentageBased), + PointsForCurrentRank: currentTitle.Value.PointsNeededForCurrentRank, + PointsForNextRank: titleTier.TierNumber == currentTitle.Value.MaxTitleRank ? + currentTitle.Value.CurrentPoints : + currentTitle.Value.PointsNeededForNextRank, + CurrentPoints: currentTitle.Value.CurrentPoints, + TierNumber: titleTier.TierNumber, + MaxTierNumber: currentTitle.Value.MaxTitleTierIndex + ); + } + }, cancellationToken); + } + + public CallbackRegistration RegisterMainStateConsumer(TimeSpan frequency, Action> onUpdate) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(frequency, TimeSpan.Zero); + + var id = Guid.NewGuid(); + var now = DateTimeOffset.UtcNow; + var entry = new ByteConsumerEntry(id, frequency, onUpdate); + + this.consumers[id] = entry; + this.RecalculateMinFrequency(); + + return new CallbackRegistration(id, () => + { + this.consumers.TryRemove(id, out _); + this.RecalculateMinFrequency(); + }); + } + + private unsafe void OnGameThreadProc() + { + var scopedLogger = this.logger.CreateScopedLogger(); + var now = DateTimeOffset.UtcNow; + + /* + * Periodically adjust the frequency of updates based on connected clients. + * Sometimes clients crash and disconnect in a way that the server wasn't able + * to trigger a frequency recalculation, so this periodic recalculation should + * take care of inconsistent state. + */ + if (now - this.lastFrequencyUpdate > TimeSpan.FromSeconds(10)) + { + this.RecalculateMinFrequency(); + } + + /* + * Only rebuild the state when we do not have one yet or the shortest required frequency has elapsed. + * If we have any pending request (excluding consumers), we should also rebuild the state + * to return the latest data to the request + */ + if (this.mainPlayerState is not null && + this.minUpdateFrequency is TimeSpan minFreq && + now - this.lastUpdateTime < minFreq && + this.pendingRequests.IsEmpty) + { + return; + } + + if (this.instanceContextService.GetInstanceType() is InstanceType.Loading) + { + scopedLogger.LogError("Not loaded"); + return; + } + + var gameContext = this.gameContextService.GetGameContext(); + var agentArray = this.agentContextService.GetAgentArray(); + var playerAgentId = this.agentContextService.GetPlayerAgentId(); + if (gameContext.IsNull || + gameContext.Pointer->WorldContext is null || + gameContext.Pointer->CharContext is null || + gameContext.Pointer->WorldContext->MapAgents.Buffer is null || + agentArray.IsNull || + agentArray.Pointer->Buffer is null || + playerAgentId is 0x0) + { + scopedLogger.LogDebug("Game data is not yet initialized"); + return; + } + + var playerMapAgent = gameContext.Pointer->WorldContext->MapAgents.AsValueEnumerable().Skip((int)playerAgentId).FirstOrDefault(); + var playerAgent = this.GetAgentContext(playerAgentId); + if (playerAgent is null || + playerAgent->Type is not AgentType.Living || + playerAgent->AgentId != playerAgentId) + { + scopedLogger.LogError("Player agent {playerAgentId} not found in agent array", playerAgentId); + return; + } + + var livingAgent = (AgentLivingContext*)playerAgent; + if (livingAgent->Level != gameContext.Pointer->WorldContext->Level) + { + scopedLogger.LogError("Player agent not found. Player level mismatch: {level} != {gameLevel}", livingAgent->Level, gameContext.Pointer->WorldContext->Level); + return; + } + + this.mainPlayerState = new MainPlayerState( + gameContext.Pointer->WorldContext->Experience, + gameContext.Pointer->WorldContext->Level, + + gameContext.Pointer->WorldContext->CurrentLuxon, + gameContext.Pointer->WorldContext->CurrentKurzick, + gameContext.Pointer->WorldContext->CurrentImperial, + gameContext.Pointer->WorldContext->CurrentBalthazar, + gameContext.Pointer->WorldContext->MaxLuxon, + gameContext.Pointer->WorldContext->MaxKurzick, + gameContext.Pointer->WorldContext->MaxImperial, + gameContext.Pointer->WorldContext->MaxBalthazar, + gameContext.Pointer->WorldContext->TotalLuxon, + gameContext.Pointer->WorldContext->TotalKurzick, + gameContext.Pointer->WorldContext->TotalImperial, + gameContext.Pointer->WorldContext->TotalBalthazar, + + // Energy and health are percentages of Max + livingAgent->Health * livingAgent->MaxHealth, + livingAgent->MaxHealth, + livingAgent->Energy * livingAgent->MaxEnergy, + livingAgent->MaxEnergy, + + livingAgent->Primary, + livingAgent->Secondary, + + playerAgent->Pos.X, + playerAgent->Pos.Y + ); + + this.bufferWriter.ResetWrittenCount(); + MemoryPackSerializer.Serialize(this.bufferWriter, this.mainPlayerState); + this.lastUpdateTime = now; + + while (this.pendingRequests.TryDequeue(out var tcs)) + { + tcs.TrySetResult(this.mainPlayerState); + } + + foreach (var kvp in this.consumers) + { + var entry = kvp.Value; + entry.TryConsume(now, this.bufferWriter.WrittenSpan); + } + } + + private void RecalculateMinFrequency() + { + var scopedLogger = this.logger.CreateScopedLogger(); + var noConsumers = this.consumers.IsEmpty; + if (noConsumers) + { + this.minUpdateFrequency = TimeSpan.MaxValue; + scopedLogger.LogDebug("No consumers registered, disabling updates"); + this.lastFrequencyUpdate = DateTimeOffset.UtcNow; + return; + } + + var minValue = this.consumers.Values.Min(c => c.Frequency); + scopedLogger.LogDebug("Adjusted update frequency to {frequency}", minValue); + this.lastFrequencyUpdate = DateTimeOffset.UtcNow; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private unsafe AgentContext* GetAgentContext(uint agentId) + { + var agentArray = this.agentContextService.GetAgentArray(); + if (agentArray.IsNull || agentArray.Pointer->Buffer is null || + agentArray.Pointer->Size <= agentId) + { + return null; + } + + return agentArray.Pointer->Buffer[agentId].Pointer; + } +} diff --git a/Daybreak.API/Services/PartyService.cs b/Daybreak.API/Services/PartyService.cs new file mode 100644 index 00000000..d504f331 --- /dev/null +++ b/Daybreak.API/Services/PartyService.cs @@ -0,0 +1,506 @@ +using Daybreak.API.Extensions; +using Daybreak.API.Interop.GuildWars; +using Daybreak.API.Models; +using Daybreak.API.Services.Interop; +using Daybreak.Shared.Models; +using Daybreak.Shared.Models.Api; +using Daybreak.Shared.Models.Guildwars; +using Daybreak.Shared.Services.BuildTemplates; +using System.Core.Extensions; +using System.Extensions; +using System.Extensions.Core; +using System.Windows.Navigation; +using ZLinq; +using InstanceType = Daybreak.API.Interop.GuildWars.InstanceType; + +namespace Daybreak.API.Services; + +public sealed class PartyService( + IBuildTemplateManager buildTemplateManager, + UIService uiService, + UIContextService uIContextService, + SkillbarContextService skillbarContextService, + InstanceContextService instanceContextService, + PartyContextService partyContextService, + GameThreadService gameThreadService, + GameContextService gameContextService, + ILogger logger) +{ + private static readonly TimeSpan HeroSpawnDelay = TimeSpan.FromSeconds(1); + + private readonly IBuildTemplateManager buildTemplateManager = buildTemplateManager.ThrowIfNull(); + private readonly UIService uiService = uiService.ThrowIfNull(); + private readonly UIContextService uIContextService = uIContextService.ThrowIfNull(); + private readonly SkillbarContextService skillbarContextService = skillbarContextService.ThrowIfNull(); + private readonly InstanceContextService instanceContextService = instanceContextService.ThrowIfNull(); + private readonly PartyContextService partyContextService = partyContextService.ThrowIfNull(); + private readonly GameThreadService gameThreadService = gameThreadService.ThrowIfNull(); + private readonly GameContextService gameContextService = gameContextService.ThrowIfNull(); + private readonly ILogger logger = logger.ThrowIfNull(); + + public async Task SetPartyLoadout(PartyLoadout partyLoadout, CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(); + + if (!await this.IsInValidOutpost(cancellationToken)) + { + scopedLogger.LogError("Could not set party loadout. Not in a valid outpost"); + return false; + } + + if (!await this.LeaveParty(cancellationToken)) + { + scopedLogger.LogError("Could not set party loadout. Could not leave party"); + return false; + } + + if (!await this.gameThreadService.QueueOnGameThread(() => this.SpawnHeroes(partyLoadout), cancellationToken)) + { + scopedLogger.LogError("Could not set party loadout. Could not spawn heroes"); + return false; + } + + await Task.Delay(HeroSpawnDelay, cancellationToken); + + if (!await this.gameThreadService.QueueOnGameThread(() => this.ApplyBuilds(partyLoadout), cancellationToken)) + { + scopedLogger.LogError("Could not set party loadout. Could not apply builds"); + return false; + } + + var heroBehaviorSetup = await this.gameThreadService.QueueOnGameThread(() => this.GetHeroBehaviorSetup(partyLoadout), cancellationToken); + foreach(var heroBehaviorEntry in heroBehaviorSetup ?? []) + { + if (!await this.SetHeroBehavior(heroBehaviorEntry.AgentId, heroBehaviorEntry.Behavior, cancellationToken)) + { + scopedLogger.LogWarning("Could not set hero behavior for agent {agentId} to {behavior}", heroBehaviorEntry.AgentId, heroBehaviorEntry.Behavior); + } + } + + return true; + } + + public Task GetPartyLoadout(CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(); + return this.gameThreadService.QueueOnGameThread(() => + { + unsafe + { + if (this.instanceContextService.GetInstanceType() is InstanceType.Loading or InstanceType.Explorable) + { + scopedLogger.LogError("Not in outpost"); + return default; + } + + if (!this.gameContextService.GetGameContext().TryGetBuildContext(out var skillbars, out var attributes, out var professions, out _)) + { + scopedLogger.LogError("Failed to get build context"); + return default; + } + + if (!this.gameContextService.GetGameContext().TryGetHeroFlags(out var heroFlags)) + { + scopedLogger.LogError("Failed to get hero flags"); + return default; + } + + if (!this.gameContextService.GetGameContext().TryGetPlayerId(out var playerId)) + { + scopedLogger.LogError("Failed to get player id"); + return default; + } + + if (!this.gameContextService.GetGameContext().TryGetPlayerParty(out _, out _, out var heroes, out _)) + { + scopedLogger.LogError("Failed to get player party"); + return default; + } + + var buildTuples = professions.Value.AsValueEnumerable() + .Select(p => (p.AgentId, p, attributes.Value.FirstOrDefault(a => a.AgentId == p.AgentId), skillbars.Value.FirstOrDefault(s => s.AgentId == p.AgentId), heroFlags.Value.FirstOrDefault(f => f.AgentId == p.AgentId))) + .Select(t => (t.AgentId, GetBuildEntryById(t.p, t.Item4, t.Item3), t.Item5.Behavior)) + .Where(t => t.Item2 is not null) + .OfType<(uint AgentId, BuildEntry BuildEntry, Behavior Behavior)>() + .ToList(); + + return new PartyLoadout( + [.. buildTuples.Select(t => + { + if (t.AgentId == playerId) + { + return new PartyLoadoutEntry(0, HeroBehavior.Undefined, t.BuildEntry); + } + else if (heroes.Value.FirstOrDefault(h => h.AgentId == t.AgentId) is HeroPartyMember hero && + hero.AgentId == t.AgentId) + { + return new PartyLoadoutEntry((int)hero.HeroId, (HeroBehavior)t.Behavior, t.BuildEntry); + } + + return default; + }) + .OfType()]); + } + }, cancellationToken); + } + + public async Task LeaveParty(CancellationToken cancellationToken) + { + var partySize = await this.GetPartySize(cancellationToken); + if (partySize is 1 or 0) + { + return true; + } + + return await this.gameThreadService.QueueOnGameThread(this.partyContextService.LeaveParty, cancellationToken); + } + + public async Task GetPartySize(CancellationToken cancellationToken) + { + return await this.gameThreadService.QueueOnGameThread(() => + { + unsafe + { + if (!this.gameContextService.GetGameContext().TryGetPlayerParty(out _, out var players, out var heroes, out var henchmen)) + { + this.logger.LogError("Failed to get player party"); + return 0; + } + + return players.Value.Size + heroes.Value.Size + heroes.Value.Size; + } + }, cancellationToken); + } + + public async Task SetHeroBehavior(uint heroAgentId, HeroBehavior behavior, CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(); + + if (!await this.IsInValidOutpost(cancellationToken)) + { + scopedLogger.LogError("Could not set hero behavior. Not in a valid outpost"); + return false; + } + + var heroCommanderFrameLabel = await this.HeroCommanderFrameLabel(heroAgentId, cancellationToken); + if (heroCommanderFrameLabel is null) + { + scopedLogger.LogError("Could not set hero behavior. Could not find hero commander frame label for agent {heroAgentId}", heroAgentId); + return false; + } + + var maybeFrame = await this.uiService.GetManagedFrame(heroCommanderFrameLabel, cancellationToken); + if (!maybeFrame.HasValue) + { + scopedLogger.LogError("Could not set hero behavior. Could not get managed frame for label {heroCommanderFrameLabel}", heroCommanderFrameLabel); + return false; + } + + using var frame = maybeFrame.Value; + var result = await this.gameThreadService.QueueOnGameThread(() => + { + unsafe + { + var btn = this.uIContextService.GetChildFrame(frame.Frame, (uint)behavior); + var packet = new UIPackets.MouseClick(UIPackets.MouseButtons.Left, 0, 0); + if (btn.IsNull) + { + scopedLogger.LogError("Could not set hero behavior. Could not find button for behavior {behavior} in frame {frameLabel}", behavior, heroCommanderFrameLabel); + return false; + } + + return this.uIContextService.SendFrameUIMessage(btn, UIMessage.MouseClick, &packet); + } + }, cancellationToken); + + if (!result) + { + scopedLogger.LogError("Failed to send UI message to set hero behavior {behavior} for agent {heroAgentId}", behavior, heroAgentId); + } + + scopedLogger.LogInformation("Set hero behavior {behavior} for agent {agentId}", behavior, heroAgentId); + return result; + } + + private async Task IsInValidOutpost(CancellationToken cancellationToken) + { + return await this.gameThreadService.QueueOnGameThread(() => + { + return this.instanceContextService.GetInstanceType() is InstanceType.Outpost; + }, cancellationToken); + } + + private unsafe bool SpawnHeroes(PartyLoadout partyLoadout) + { + var scopedLogger = this.logger.CreateScopedLogger(); + scopedLogger.LogDebug("Spawning {heroCount} heroes for party loadout", partyLoadout.Entries.AsValueEnumerable().Count(c => c.HeroId != 0)); + foreach (var entry in partyLoadout.Entries) + { + if (entry.HeroId != 0 && + Hero.TryParse(entry.HeroId, out var hero)) + { + scopedLogger.LogInformation("Adding hero [{heroId}] [{heroName}] with behavior {behavior}", entry.HeroId, hero.Name, entry.HeroBehavior); + this.partyContextService.AddHero((uint)entry.HeroId); + } + else + { + scopedLogger.LogWarning("Invalid hero entry in party loadout: {heroId}", entry.HeroId); + continue; + } + } + + return true; + } + + private unsafe bool ApplyBuilds(PartyLoadout partyLoadout) + { + var scopedLogger = this.logger.CreateScopedLogger(); + scopedLogger.LogDebug("Applying builds"); + + if (!this.gameContextService.GetGameContext().TryGetPlayerParty(out _, out var players, out var heroes, out var henchmen)) + { + scopedLogger.LogError("Failed to get player party"); + return false; + } + + if (!this.gameContextService.GetGameContext().TryGetBuildContext(out _, out _, out var professions, out var unlockedSkills)) + { + scopedLogger.LogError("Failed to get build context"); + return false; + } + + if (!this.gameContextService.GetGameContext().TryGetPlayerId(out var playerId)) + { + scopedLogger.LogError("Failed to get player id"); + return false; + } + + if (!this.gameContextService.GetGameContext().TryGetAccountContext(out var accountContext) || + accountContext.IsNull) + { + scopedLogger.LogError("Failed to get account context"); + return false; + } + + var playerProfessionContext = professions.Value.AsValueEnumerable().FirstOrDefault(p => p.AgentId == playerId); + if (playerProfessionContext.AgentId != playerId) + { + scopedLogger.LogError("Failed to get player profession context for player id {playerId}", playerId); + return false; + } + + var parsedEntries = partyLoadout.Entries.AsValueEnumerable() + .Select(entry => + { + if (entry.HeroId is 0) + { + return (entry, playerId); + } + else + { + var hero = heroes.Value.AsValueEnumerable().FirstOrDefault(h => h.HeroId == entry.HeroId); + return (entry, hero.AgentId); + } + }) + .Select(t => (t.entry, t.playerId, professions.Value.AsValueEnumerable().FirstOrDefault(p => p.AgentId == t.playerId))) + .Select(t => + { + var validSkills = t.playerId == playerId + ? unlockedSkills + : accountContext.Pointer->UnlockedAccountSkills; + + var unlockedProfessionsFlags = t.playerId == playerId + ? playerProfessionContext.UnlockedProfessionsFlags + : uint.MaxValue; + + if (!this.buildTemplateManager.CanTemplateApply( + new BuildTemplateValidationRequest( + (uint)t.entry.Build.Primary, + (uint)t.entry.Build.Secondary, + [.. t.entry.Build.Skills], + (uint)t.Item3.CurrentPrimary, + unlockedProfessionsFlags, + [.. validSkills]))) + { + return (t.entry, t.playerId, t.Item3, false); + } + + return (t.entry, t.playerId, t.Item3, true); + }) + .Where(t => + { + if (!t.Item4) + { + if (t.entry.HeroId is 0) + { + scopedLogger.LogError("Cannot apply build for player"); + } + else + { + scopedLogger.LogError("Cannot apply build for hero {heroId}", t.entry.HeroId); + } + } + + return t.Item4; + }); + + foreach (var (entry, maybeAgentId, _, _) in parsedEntries) + { + if (maybeAgentId is not uint agentId) + { + continue; + } + + var attributeIds = new Array12Uint(); + var attributeValues = new Array12Uint(); + for (var i = 0; i < entry.Build.Attributes.Count && i < 12; i++) + { + attributeIds[i] = (uint)entry.Build.Attributes[i].Id; + attributeValues[i] = (uint)entry.Build.Attributes[i].BasePoints; + } + + var skills = new Array8Uint(); + for (var i = 0; i < entry.Build.Skills.Count && i < 8; i++) + { + skills[i] = entry.Build.Skills[i]; + } + + var skillTemplate = new SkillTemplate( + (uint)entry.Build.Primary, + (uint)entry.Build.Secondary, + (uint)entry.Build.Attributes.Count, + attributeIds, + attributeValues, + skills); + + scopedLogger.LogInformation("Applying build for agent {agentId} with primary {primary} and secondary {secondary}", agentId, entry.Build.Primary, entry.Build.Secondary); + this.skillbarContextService.LoadBuild(agentId, &skillTemplate); + } + + return true; + } + + private unsafe List<(uint AgentId, HeroBehavior Behavior)>? GetHeroBehaviorSetup(PartyLoadout partyLoadout) + { + var scopedLogger = this.logger.CreateScopedLogger(); + scopedLogger.LogDebug("Getting hero behavior setup"); + + if (!this.gameContextService.GetGameContext().TryGetPlayerParty(out _, out _, out var heroes, out _) || + !heroes.HasValue) + { + scopedLogger.LogError("Failed to get player party"); + return default; + } + + return heroes.Value.AsValueEnumerable() + .Select(h => + { + if (h.HeroId is 0) + { + return default; + } + + var entry = partyLoadout.Entries.AsValueEnumerable().FirstOrDefault(e => (uint)e.HeroId == h.HeroId); + if (entry is null) + { + scopedLogger.LogWarning("No entry found for hero {heroId}", h.HeroId); + return default; + } + + return (h.AgentId, entry.HeroBehavior); + }) + .Where(t => t.AgentId is not 0) + .ToList(); + } + + private async Task HeroCommanderFrameLabel(uint heroAgentId, CancellationToken cancellationToken) + { + var heroOffset = await this.GetHeroNumber(heroAgentId, cancellationToken); + if (heroOffset > 6) + { + return default; + } + + return heroOffset switch + { + 0 => "AgentCommander0", + 1 => "AgentCommander1", + 2 => "AgentCommander2", + 3 => "AgentCommander3", + 4 => "AgentCommander4", + 5 => "AgentCommander5", + 6 => "AgentCommander6", + _ => default + }; + } + + private async Task GetHeroNumber(uint agentId, CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(); + var heroNumber = await this.gameThreadService.QueueOnGameThread(() => + { + unsafe + { + var gameContext = this.gameContextService.GetGameContext(); + if (gameContext.IsNull) + { + scopedLogger.LogError("Game context is null"); + return -1; + } + + var partyContext = gameContext.Pointer->PartyContext; + if (partyContext is null) + { + scopedLogger.LogError("Party context is null"); + return -1; + } + + var charContext = gameContext.Pointer->CharContext; + if (charContext is null) + { + scopedLogger.LogError("Char context is null"); + return -1; + } + + var playerParty = partyContext->PlayerParty; + var playerId = charContext->PlayerNumber; + var offset = 0; + foreach(var hero in playerParty->Heroes) + { + if (hero.OwnerPlayerId == playerId) + { + if (hero.AgentId == agentId) + { + return offset; + } + + offset++; + } + } + + return -1; + } + }, cancellationToken); + + return heroNumber is -1 ? default : (uint)heroNumber; + } + + private static BuildEntry? GetBuildEntryById(ProfessionsContext professionContext, SkillbarContext skillbar, PartyAttribute attributes) + { + return new BuildEntry( + Primary: (int)professionContext.CurrentPrimary, + Secondary: (int)professionContext.CurrentSecondary, + Attributes: attributes.Attributes.GetAttributeEntryList(), + Skills: + [ + skillbar.Skill0.Id, + skillbar.Skill1.Id, + skillbar.Skill2.Id, + skillbar.Skill3.Id, + skillbar.Skill4.Id, + skillbar.Skill5.Id, + skillbar.Skill6.Id, + skillbar.Skill7.Id + ]); + } +} diff --git a/Daybreak.API/Services/UIService.cs b/Daybreak.API/Services/UIService.cs new file mode 100644 index 00000000..951212f3 --- /dev/null +++ b/Daybreak.API/Services/UIService.cs @@ -0,0 +1,103 @@ +using Daybreak.API.Interop; +using Daybreak.API.Interop.GuildWars; +using Daybreak.API.Services.Interop; +using System.Extensions.Core; + +namespace Daybreak.API.Services; + +public sealed class UIService( + GameThreadService gameThreadService, + UIContextService uIContextService, + ILogger logger) +{ + private readonly GameThreadService gameThreadService = gameThreadService; + private readonly UIContextService uIContextService = uIContextService; + private readonly ILogger logger = logger; + + public async Task Keypress(UIAction action, WrappedPointer frame, CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(); + var keyDownResult = await this.gameThreadService.QueueOnGameThread(() => + { + if (!this.uIContextService.KeyDown(action, frame)) + { + scopedLogger.LogError("Failed to send keydown action {action}", action); + return false; + } + + return true; + }, cancellationToken); + + if (!keyDownResult) + { + return false; + } + + var keyUpResult = await this.gameThreadService.QueueOnGameThread(() => + { + if (!this.uIContextService.KeyUp(action, frame)) + { + scopedLogger.LogError("Failed to send keyup action {action}", action); + return false; + } + + return true; + }, cancellationToken); + + if (!keyUpResult) + { + return false; + } + + return true; + } + + public async Task GetManagedFrame(string label, CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(); + if (string.IsNullOrEmpty(label)) + { + scopedLogger.LogError("Frame label is null or empty"); + return null; + } + + var frame = await this.gameThreadService.QueueOnGameThread(() => this.uIContextService.GetFrameByLabel(label), cancellationToken); + if (!frame.IsNull) + { + // Frame was already open, we do not want to auto-close it + scopedLogger.LogInformation("Frame with label {label} is already open", label); + return new ManagedFrame(frame, () => { }); + } + + var uiAction = GetUIActionFromFrameLabel(label); + await this.Keypress(uiAction, null, cancellationToken); + frame = await this.gameThreadService.QueueOnGameThread(() => this.uIContextService.GetFrameByLabel(label), cancellationToken); + await this.gameThreadService.QueueOnGameThread(() => this.uIContextService.SetFrameVisible(frame, true), cancellationToken); + return new ManagedFrame(frame, async () => + { + await this.gameThreadService.QueueOnGameThread(async () => + { + if (!this.uIContextService.GetFrameByLabel(label).IsNull) + { + await this.Keypress(uiAction, null, CancellationToken.None); + } + + }, CancellationToken.None); + }); + } + + private static UIAction GetUIActionFromFrameLabel(string frameLabel) + { + return frameLabel switch + { + "AgentCommander0" => UIAction.OpenHeroCommander1, + "AgentCommander1" => UIAction.OpenHeroCommander2, + "AgentCommander2" => UIAction.OpenHeroCommander3, + "AgentCommander3" => UIAction.OpenHeroCommander4, + "AgentCommander4" => UIAction.OpenHeroCommander5, + "AgentCommander5" => UIAction.OpenHeroCommander6, + "AgentCommander6" => UIAction.OpenHeroCommander7, + _ => UIAction.None + }; + } +} diff --git a/Daybreak.API/Services/WebApplicationBuilderExtensions.cs b/Daybreak.API/Services/WebApplicationBuilderExtensions.cs new file mode 100644 index 00000000..236720ad --- /dev/null +++ b/Daybreak.API/Services/WebApplicationBuilderExtensions.cs @@ -0,0 +1,85 @@ +using Daybreak.API.Services.Interop; +using Daybreak.Shared.Services.BuildTemplates; +using Daybreak.Shared.Services.MDns; +using System.Diagnostics.CodeAnalysis; + +namespace Daybreak.API.Services; + +public static class WebApplicationBuilderExtensions +{ + public static WebApplicationBuilder WithDaybreakServices(this WebApplicationBuilder builder) + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.WithHookHostedService(); + builder.WithHookHostedService(); + builder.WithAddressService(); + builder.WithAddressService(); + builder.WithAddressService(); + builder.WithAddressService(); + builder.WithAddressService(); + builder.WithAddressService(); + builder.WithHookAddressHostedService(); + return builder; + } + + private static WebApplicationBuilder WithHookService<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>(this WebApplicationBuilder builder) + where T : class, IHookHealthService + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + return builder; + } + + private static WebApplicationBuilder WithAddressService<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>(this WebApplicationBuilder builder) + where T : class, IAddressHealthService + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + return builder; + } + + private static WebApplicationBuilder WithHookHostedService<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>(this WebApplicationBuilder builder) + where T : class, IHookHealthService, IHostedService + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + return builder; + } + + private static WebApplicationBuilder WithAddressHostedService<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>(this WebApplicationBuilder builder) + where T : class, IAddressHealthService, IHostedService + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + return builder; + } + + private static WebApplicationBuilder WithHookAddressService<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>(this WebApplicationBuilder builder) + where T : class, IHookHealthService, IAddressHealthService + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + return builder; + } + + private static WebApplicationBuilder WithHookAddressHostedService<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>(this WebApplicationBuilder builder) + where T : class, IHookHealthService, IAddressHealthService, IHostedService + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + return builder; + } +} diff --git a/Daybreak.API/Swagger/WebApplicationBuilderExtensions.cs b/Daybreak.API/Swagger/WebApplicationBuilderExtensions.cs new file mode 100644 index 00000000..bc6e3f1e --- /dev/null +++ b/Daybreak.API/Swagger/WebApplicationBuilderExtensions.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Http.Json; +using Microsoft.AspNetCore.Routing.Constraints; +using Microsoft.Extensions.Options; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Text.Json; + +namespace Daybreak.API.Swagger; + +public static class WebApplicationBuilderExtensions +{ + public static WebApplicationBuilder WithSwagger(this WebApplicationBuilder builder) + { + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(); + builder.Services.AddTransient(sp => + { + var opts = sp.GetRequiredService>().Value?.SerializerOptions + ?? new JsonSerializerOptions(JsonSerializerDefaults.Web); + + return new JsonSerializerDataContractResolver(opts); + }); + builder.Services.Configure(static options => + { + options.SetParameterPolicy("regex"); + }); + builder.Services.AddOpenApi(); + + return builder; + } +} diff --git a/Daybreak.API/Swagger/WebApplicationExtensions.cs b/Daybreak.API/Swagger/WebApplicationExtensions.cs new file mode 100644 index 00000000..2c1906f4 --- /dev/null +++ b/Daybreak.API/Swagger/WebApplicationExtensions.cs @@ -0,0 +1,11 @@ +namespace Daybreak.API.Swagger; + +public static class WebApplicationExtensions +{ + public static WebApplication UseSwaggerWithUI(this WebApplication app) + { + app.UseSwagger(); + app.UseSwaggerUI(); + return app; + } +} diff --git a/Daybreak.API/WebSockets/UpdateWebSocketRoute.cs b/Daybreak.API/WebSockets/UpdateWebSocketRoute.cs new file mode 100644 index 00000000..0655024f --- /dev/null +++ b/Daybreak.API/WebSockets/UpdateWebSocketRoute.cs @@ -0,0 +1,46 @@ +using Net.Sdk.Web.Websockets; +using System.Buffers; +using System.Net.WebSockets; + +namespace Daybreak.API.WebSockets; + +public abstract class UpdateWebSocketRoute + : WebSocketRouteBase +{ + private readonly byte[] sharedBuffer; + private readonly ArraySegment packetBuffer; + private int sendingOperations = 0; + + protected abstract int BufferSize { get; } + + public UpdateWebSocketRoute() + { + this.sharedBuffer = ArrayPool.Shared.Rent(this.BufferSize); + this.packetBuffer = new ArraySegment(this.sharedBuffer); + } + + public override Task SocketClosed() + { + ArrayPool.Shared.Return(this.sharedBuffer); + return base.SocketClosed(); + } + + public void SendUpdate(ReadOnlySpan state) + { + if (Interlocked.CompareExchange(ref this.sendingOperations, 1, 0) != 0) + { + return; + } + + if (this.WebSocket is null) + { + this.sendingOperations = 0; + return; + } + + state.CopyTo(this.packetBuffer); + var packet = this.packetBuffer.Slice(0, state.Length); + this.WebSocket?.SendAsync(packet, WebSocketMessageType.Binary, true, this.Context?.RequestAborted ?? CancellationToken.None) + .ContinueWith(_ => this.sendingOperations = 0); + } +} diff --git a/Daybreak.API/WebSockets/WebApplicationBuilderExtensions.cs b/Daybreak.API/WebSockets/WebApplicationBuilderExtensions.cs new file mode 100644 index 00000000..4de00905 --- /dev/null +++ b/Daybreak.API/WebSockets/WebApplicationBuilderExtensions.cs @@ -0,0 +1,13 @@ +using Daybreak.API.Controllers.WebSocket.MainPlayer; +using Net.Sdk.Web; + +namespace Daybreak.API.WebSockets; + +public static class WebApplicationBuilderExtensions +{ + public static WebApplicationBuilder WithWebSocketRoutes(this WebApplicationBuilder builder) + { + builder.WithWebSocketRoute(); + return builder; + } +} diff --git a/Daybreak.API/WebSockets/WebApplicationExtensions.cs b/Daybreak.API/WebSockets/WebApplicationExtensions.cs new file mode 100644 index 00000000..302d4435 --- /dev/null +++ b/Daybreak.API/WebSockets/WebApplicationExtensions.cs @@ -0,0 +1,41 @@ +using Daybreak.API.Controllers.WebSocket.MainPlayer; +using Daybreak.Shared.Models.Api; +using Net.Sdk.Web.Websockets; +using Net.Sdk.Web.Websockets.Extensions; +using System.Diagnostics.CodeAnalysis; +using System.Extensions; + +namespace Daybreak.API.WebSockets; + +public static class WebApplicationExtensions +{ + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code")] + [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.")] + public static WebApplication UseWebSocketRoutes(this WebApplication app) + { + app.RegisterWebSocketRoute( + path: "/api/v1/ws/main-player/state", + name: nameof(MainPlayerStateRoute), + summary: $"{nameof(MainPlayerState)} WebSocket", + description: $"Subscribe to {nameof(MainPlayerState)} websocket. On each game thread proc, the server will send a serialized {nameof(MainPlayerState)} payload", + tag: "Main Player"); + + return app; + } + + private static WebApplication RegisterWebSocketRoute(this WebApplication app, string path, string name, string summary, string description, string tag) + where T : WebSocketRouteBase + { + app.UseWebSocketRoute(path) + .Cast() + .WithName(name) + .WithSummary(summary) + .WithDescription(description) + .Produces(StatusCodes.Status101SwitchingProtocols) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status400BadRequest) + .WithTags(tag); + return app; + } +} diff --git a/Daybreak.API/rd.xml b/Daybreak.API/rd.xml new file mode 100644 index 00000000..f8c35bbf --- /dev/null +++ b/Daybreak.API/rd.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/Daybreak.Shared/Converters/NullToUnsetValueConverter.cs b/Daybreak.Shared/Converters/NullToUnsetValueConverter.cs new file mode 100644 index 00000000..33361542 --- /dev/null +++ b/Daybreak.Shared/Converters/NullToUnsetValueConverter.cs @@ -0,0 +1,12 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using System.Windows; + +namespace Daybreak.Shared.Converters; +public sealed class NullToUnsetValueConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => value ?? DependencyProperty.UnsetValue; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => Binding.DoNothing; +} diff --git a/Daybreak.Shared/Daybreak.Shared.csproj b/Daybreak.Shared/Daybreak.Shared.csproj index 5a9ee277..7fb6cdff 100644 --- a/Daybreak.Shared/Daybreak.Shared.csproj +++ b/Daybreak.Shared/Daybreak.Shared.csproj @@ -9,18 +9,22 @@ + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + - - + - + diff --git a/Daybreak.Shared/Models/Api/AttributeEntry.cs b/Daybreak.Shared/Models/Api/AttributeEntry.cs new file mode 100644 index 00000000..2c9c7f0f --- /dev/null +++ b/Daybreak.Shared/Models/Api/AttributeEntry.cs @@ -0,0 +1,4 @@ +namespace Daybreak.Shared.Models.Api; +public sealed record AttributeEntry(uint Id, uint BasePoints, uint TotalPoints) +{ +} diff --git a/Daybreak.Shared/Models/Api/BuildEntry.cs b/Daybreak.Shared/Models/Api/BuildEntry.cs new file mode 100644 index 00000000..64b11e1d --- /dev/null +++ b/Daybreak.Shared/Models/Api/BuildEntry.cs @@ -0,0 +1,6 @@ +using System.Collections.Generic; + +namespace Daybreak.Shared.Models.Api; +public sealed record BuildEntry(int Primary, int Secondary, List Attributes, List Skills) +{ +} diff --git a/Daybreak.Shared/Models/Api/CampaignInfo.cs b/Daybreak.Shared/Models/Api/CampaignInfo.cs new file mode 100644 index 00000000..cf2a066f --- /dev/null +++ b/Daybreak.Shared/Models/Api/CampaignInfo.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Daybreak.Shared.Models.Api; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CampaignInfo +{ + Core, + Prophecies, + Factions, + Nightfall, + EyeOfTheNorth, + BonusMissionPack, + Unknown +} diff --git a/Daybreak.Shared/Models/Api/CharacterSelectEntry.cs b/Daybreak.Shared/Models/Api/CharacterSelectEntry.cs new file mode 100644 index 00000000..f8a0199a --- /dev/null +++ b/Daybreak.Shared/Models/Api/CharacterSelectEntry.cs @@ -0,0 +1,4 @@ +namespace Daybreak.Shared.Models.Api; +public sealed record CharacterSelectEntry(string Uuid, string Name, uint Primary, uint Secondary, uint Campaign, uint Map, uint Level, bool Pvp) +{ +} diff --git a/Daybreak.Shared/Models/Api/CharacterSelectInformation.cs b/Daybreak.Shared/Models/Api/CharacterSelectInformation.cs new file mode 100644 index 00000000..7f71a282 --- /dev/null +++ b/Daybreak.Shared/Models/Api/CharacterSelectInformation.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; + +namespace Daybreak.Shared.Models.Api; + +public sealed record CharacterSelectInformation(CharacterSelectEntry? CurrentCharacter, List CharacterNames) +{ +} diff --git a/Daybreak.Shared/Models/Api/ContinentInfo.cs b/Daybreak.Shared/Models/Api/ContinentInfo.cs new file mode 100644 index 00000000..118643d0 --- /dev/null +++ b/Daybreak.Shared/Models/Api/ContinentInfo.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Daybreak.Shared.Models.Api; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ContinentInfo +{ + Kryta, + DevContinent, + Cantha, + BattleIsles, + Elona, + RealmOfTorment, + Unknown +} diff --git a/Daybreak.Shared/Models/Api/DifficultyInfo.cs b/Daybreak.Shared/Models/Api/DifficultyInfo.cs new file mode 100644 index 00000000..15b34dc8 --- /dev/null +++ b/Daybreak.Shared/Models/Api/DifficultyInfo.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace Daybreak.Shared.Models.Api; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum DifficultyInfo +{ + Normal, + Hard, + Unknown +} diff --git a/Daybreak.Shared/Models/Api/DistrictRegionInfo.cs b/Daybreak.Shared/Models/Api/DistrictRegionInfo.cs new file mode 100644 index 00000000..29d2f0ba --- /dev/null +++ b/Daybreak.Shared/Models/Api/DistrictRegionInfo.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Daybreak.Shared.Models.Api; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum DistrictRegionInfo +{ + International = -2, + America = 0, + Korea, + Europe, + China, + Japan, + Unknown = 0xff +} diff --git a/Daybreak.Shared/Models/Api/HeroBehavior.cs b/Daybreak.Shared/Models/Api/HeroBehavior.cs new file mode 100644 index 00000000..5c182585 --- /dev/null +++ b/Daybreak.Shared/Models/Api/HeroBehavior.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Daybreak.Shared.Models.Api; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum HeroBehavior +{ + Fight, + Guard, + AvoidCombat, + Undefined +} diff --git a/Daybreak.Shared/Models/Api/InstanceInfo.cs b/Daybreak.Shared/Models/Api/InstanceInfo.cs new file mode 100644 index 00000000..f9e7e708 --- /dev/null +++ b/Daybreak.Shared/Models/Api/InstanceInfo.cs @@ -0,0 +1,4 @@ +namespace Daybreak.Shared.Models.Api; +public sealed record InstanceInfo(uint MapId, uint DistrictNumber, uint FoesKilled, uint FoesToKill, InstanceType Type, DistrictRegionInfo DistrictRegion, LanguageInfo Language, CampaignInfo Campaign, ContinentInfo Continent, RegionInfo Region, DifficultyInfo Difficulty) +{ +} diff --git a/Daybreak.Shared/Models/Api/InstanceType.cs b/Daybreak.Shared/Models/Api/InstanceType.cs new file mode 100644 index 00000000..53a4f941 --- /dev/null +++ b/Daybreak.Shared/Models/Api/InstanceType.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Daybreak.Shared.Models.Api; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum InstanceType +{ + Outpost, + Explorable, + Loading, + Undefined +} diff --git a/Daybreak.Shared/Models/Api/LanguageInfo.cs b/Daybreak.Shared/Models/Api/LanguageInfo.cs new file mode 100644 index 00000000..bb939540 --- /dev/null +++ b/Daybreak.Shared/Models/Api/LanguageInfo.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace Daybreak.Shared.Models.Api; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum LanguageInfo +{ + English, + Korean, + French, + German, + Italian, + Spanish, + TraditionalChinese, + Japanese = 8, + Polish, + Russian, + BorkBorkBork = 17, + Unknown = 0xff +} diff --git a/Daybreak.Shared/Models/Api/MainPlayerInformation.cs b/Daybreak.Shared/Models/Api/MainPlayerInformation.cs new file mode 100644 index 00000000..31a701d0 --- /dev/null +++ b/Daybreak.Shared/Models/Api/MainPlayerInformation.cs @@ -0,0 +1,15 @@ +namespace Daybreak.Shared.Models.Api; + +public sealed record MainPlayerInformation( + string Uuid, + string Email, + string CharacterName, + string AccountName, + uint Wins, + uint Losses, + uint Rating, + uint QualifierPoints, + uint Rank, + uint TournamentRewardPoints) +{ +} diff --git a/Daybreak.Shared/Models/Api/MainPlayerState.cs b/Daybreak.Shared/Models/Api/MainPlayerState.cs new file mode 100644 index 00000000..613342f4 --- /dev/null +++ b/Daybreak.Shared/Models/Api/MainPlayerState.cs @@ -0,0 +1,34 @@ +using MemoryPack; + +namespace Daybreak.Shared.Models.Api; + +/// +/// State of the main player data in the game. +/// This will be serialized to a when being sent over the WebSocket. +/// +[MemoryPackable] +public partial record MainPlayerState( + uint CurrentExperience, + uint Level, + uint CurrentLuxon, + uint CurrentKurzick, + uint CurrentImperial, + uint CurrentBalthazar, + uint MaxLuxon, + uint MaxKurzick, + uint MaxImperial, + uint MaxBalthazar, + uint TotalLuxon, + uint TotalKurzick, + uint TotalImperial, + uint TotalBalthazar, + float CurrentHp, + uint MaxHp, + float CurrentEnergy, + uint MaxEnergy, + uint PrimaryProfession, + uint SecondaryProfession, + float PosX, + float PosY) +{ +} diff --git a/Daybreak.Shared/Models/Api/PartyLoadout.cs b/Daybreak.Shared/Models/Api/PartyLoadout.cs new file mode 100644 index 00000000..0783526d --- /dev/null +++ b/Daybreak.Shared/Models/Api/PartyLoadout.cs @@ -0,0 +1,6 @@ +using System.Collections.Generic; + +namespace Daybreak.Shared.Models.Api; +public sealed record PartyLoadout(List Entries) +{ +} diff --git a/Daybreak.Shared/Models/Api/PartyLoadoutEntry.cs b/Daybreak.Shared/Models/Api/PartyLoadoutEntry.cs new file mode 100644 index 00000000..5e29bbe4 --- /dev/null +++ b/Daybreak.Shared/Models/Api/PartyLoadoutEntry.cs @@ -0,0 +1,4 @@ +namespace Daybreak.Shared.Models.Api; +public sealed record PartyLoadoutEntry(int HeroId, HeroBehavior HeroBehavior, BuildEntry Build) +{ +} diff --git a/Daybreak.Shared/Models/Api/QuestInformation.cs b/Daybreak.Shared/Models/Api/QuestInformation.cs new file mode 100644 index 00000000..39022dbc --- /dev/null +++ b/Daybreak.Shared/Models/Api/QuestInformation.cs @@ -0,0 +1,5 @@ +namespace Daybreak.Shared.Models.Api; + +public sealed record QuestInformation(uint QuestId, uint MapFrom, uint MapTo) +{ +} diff --git a/Daybreak.Shared/Models/Api/QuestLogInformation.cs b/Daybreak.Shared/Models/Api/QuestLogInformation.cs new file mode 100644 index 00000000..63337370 --- /dev/null +++ b/Daybreak.Shared/Models/Api/QuestLogInformation.cs @@ -0,0 +1,6 @@ +using System.Collections.Generic; + +namespace Daybreak.Shared.Models.Api; +public sealed record QuestLogInformation(uint CurrentQuestId, IReadOnlyList Quests) +{ +} diff --git a/Daybreak.Shared/Models/Api/RegionInfo.cs b/Daybreak.Shared/Models/Api/RegionInfo.cs new file mode 100644 index 00000000..93f885aa --- /dev/null +++ b/Daybreak.Shared/Models/Api/RegionInfo.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; + +namespace Daybreak.Shared.Models.Api; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum RegionInfo +{ + Kryta, + Maguuma, + Ascalon, + NorthernShiverpeaks, + HeroesAscent, + CrystalDesert, + FissureOfWoe, + Presearing, + Kaineng, + Kurzick, + Luxon, + ShingJea, + Kourna, + Vaabi, + Desolation, + Istan, + DomainOfAnguish, + TarnishedCoast, + DepthsOfTyria, + FarShiverpeaks, + CharrHomelands, + BattleIslands, + TheBattleOfJahai, + TheFlightNorth, + TheTenguAccords, + TheRiseOfTheWhiteMantle, + Swat, + DevRegion, + Unknown +} diff --git a/Daybreak.Shared/Models/Api/TitleInfo.cs b/Daybreak.Shared/Models/Api/TitleInfo.cs new file mode 100644 index 00000000..49610f43 --- /dev/null +++ b/Daybreak.Shared/Models/Api/TitleInfo.cs @@ -0,0 +1,4 @@ +namespace Daybreak.Shared.Models.Api; +public sealed record TitleInfo(uint Id, bool IsPercentage, uint CurrentPoints, uint PointsForCurrentRank, uint PointsForNextRank, uint TierNumber, uint MaxTierNumber) +{ +} diff --git a/Daybreak.Shared/Models/BuildTemplateValidationRequest.cs b/Daybreak.Shared/Models/BuildTemplateValidationRequest.cs new file mode 100644 index 00000000..c092797d --- /dev/null +++ b/Daybreak.Shared/Models/BuildTemplateValidationRequest.cs @@ -0,0 +1,16 @@ +namespace Daybreak.Shared.Models; +public readonly struct BuildTemplateValidationRequest( + uint buildPrimary, + uint buildSecondary, + uint[] buildSkills, + uint currentPrimary, + uint unlockedCharacterProfessions, + uint[] unlockedSkills) +{ + public readonly uint BuildPrimary = buildPrimary; + public readonly uint BuildSecondary = buildSecondary; + public readonly uint[] BuildSkills = buildSkills ?? []; + public readonly uint CurrentPrimary = currentPrimary; + public readonly uint UnlockedCharacterProfessions = unlockedCharacterProfessions; + public readonly uint[] UnlockedSkills = unlockedSkills ?? []; +} diff --git a/Daybreak.Shared/Models/DaybreakApiContext.cs b/Daybreak.Shared/Models/DaybreakApiContext.cs new file mode 100644 index 00000000..283be026 --- /dev/null +++ b/Daybreak.Shared/Models/DaybreakApiContext.cs @@ -0,0 +1,9 @@ +using System; +using System.Diagnostics; + +namespace Daybreak.Shared.Models; +public readonly struct DaybreakAPIContext(Uri apiUri, Process process) +{ + public readonly Uri ApiUri = apiUri; + public readonly Process Process = process; +} diff --git a/Daybreak.Shared/Models/DnsRegistrationToken.cs b/Daybreak.Shared/Models/DnsRegistrationToken.cs new file mode 100644 index 00000000..fc53cca2 --- /dev/null +++ b/Daybreak.Shared/Models/DnsRegistrationToken.cs @@ -0,0 +1,19 @@ +using MeaMod.DNS.Multicast; +using System; + +namespace Daybreak.Shared.Models; +public readonly struct DnsRegistrationToken : IDisposable +{ + private readonly ServiceDiscovery serviceDiscovery = new(); + + internal DnsRegistrationToken( + ServiceProfile serviceProfile) + { + this.serviceDiscovery.Advertise(serviceProfile); + } + + public void Dispose() + { + this.serviceDiscovery.Dispose(); + } +} diff --git a/Daybreak.Shared/Models/FocusView/CharacterComponentContext.cs b/Daybreak.Shared/Models/FocusView/CharacterComponentContext.cs new file mode 100644 index 00000000..d5729a23 --- /dev/null +++ b/Daybreak.Shared/Models/FocusView/CharacterComponentContext.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Daybreak.Shared.Models.FocusView; +public sealed class CharacterComponentContext +{ + public required uint CurrentExperience { get; init; } + public required CharacterSelectComponentEntry CurrentCharacter { get; init; } + public required List Characters { get; init; } +} diff --git a/Daybreak.Shared/Models/FocusView/CharacterSelectComponentEntry.cs b/Daybreak.Shared/Models/FocusView/CharacterSelectComponentEntry.cs new file mode 100644 index 00000000..70e2aac0 --- /dev/null +++ b/Daybreak.Shared/Models/FocusView/CharacterSelectComponentEntry.cs @@ -0,0 +1,25 @@ +using System; + +namespace Daybreak.Shared.Models.FocusView; +public sealed class CharacterSelectComponentEntry : IEquatable +{ + public required string DisplayName { get; init; } + public required string CharacterName { get; init; } + + public bool Equals(CharacterSelectComponentEntry? other) + { + return other is not null && + this.DisplayName == other.DisplayName && + this.CharacterName == other.CharacterName; + } + + public override bool Equals(object? obj) + { + return this.Equals(obj as CharacterSelectComponentEntry); + } + + public override int GetHashCode() + { + return HashCode.Combine(this.DisplayName, this.CharacterName); + } +} diff --git a/Daybreak.Shared/Models/FocusView/CurrentMapComponentContext.cs b/Daybreak.Shared/Models/FocusView/CurrentMapComponentContext.cs new file mode 100644 index 00000000..79aa7b93 --- /dev/null +++ b/Daybreak.Shared/Models/FocusView/CurrentMapComponentContext.cs @@ -0,0 +1,7 @@ +using Daybreak.Shared.Models.Guildwars; + +namespace Daybreak.Shared.Models.FocusView; +public sealed class CurrentMapComponentContext +{ + public required Map? CurrentMap { get; init; } +} diff --git a/Daybreak.Shared/Models/FocusView/FocusViewContext.cs b/Daybreak.Shared/Models/FocusView/FocusViewContext.cs new file mode 100644 index 00000000..f0cf94aa --- /dev/null +++ b/Daybreak.Shared/Models/FocusView/FocusViewContext.cs @@ -0,0 +1,10 @@ +using Daybreak.Shared.Models.LaunchConfigurations; +using Daybreak.Shared.Services.Api; + +namespace Daybreak.Shared.Models.FocusView; + +public sealed class FocusViewContext +{ + public required ScopedApiContext ApiContext { get; init; } + public required GuildWarsApplicationLaunchContext LaunchContext { get; init; } +} diff --git a/Daybreak.Shared/Models/FocusView/PlayerContextMenuContext.cs b/Daybreak.Shared/Models/FocusView/PlayerContextMenuContext.cs deleted file mode 100644 index eea06d74..00000000 --- a/Daybreak.Shared/Models/FocusView/PlayerContextMenuContext.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Daybreak.Shared.Models.Guildwars; -using Daybreak.Shared.Models.LaunchConfigurations; - -namespace Daybreak.Shared.Models.FocusView; -/// -/// XD Name -/// -internal sealed class PlayerContextMenuContext -{ - public GuildWarsApplicationLaunchContext? GuildWarsApplicationLaunchContext { get; set; } - public PlayerInformation? Player { get; set; } -} diff --git a/Daybreak.Shared/Models/FocusView/PlayerResourcesComponentContext.cs b/Daybreak.Shared/Models/FocusView/PlayerResourcesComponentContext.cs new file mode 100644 index 00000000..d09e7330 --- /dev/null +++ b/Daybreak.Shared/Models/FocusView/PlayerResourcesComponentContext.cs @@ -0,0 +1,18 @@ +namespace Daybreak.Shared.Models.FocusView; +public sealed class PlayerResourcesComponentContext +{ + public required uint CurrentKurzick { get; init; } + public required uint CurrentLuxon { get; init; } + public required uint CurrentBalthazar { get; init; } + public required uint CurrentImperial { get; init; } + + public required uint MaxKurzick { get; init; } + public required uint MaxLuxon { get; init; } + public required uint MaxBalthazar { get; init; } + public required uint MaxImperial { get; init; } + + public required uint TotalKurzick { get; init; } + public required uint TotalLuxon { get; init; } + public required uint TotalBalthazar { get; init; } + public required uint TotalImperial { get; init; } +} diff --git a/Daybreak.Shared/Models/FocusView/QuestLogComponentContext.cs b/Daybreak.Shared/Models/FocusView/QuestLogComponentContext.cs new file mode 100644 index 00000000..3d098223 --- /dev/null +++ b/Daybreak.Shared/Models/FocusView/QuestLogComponentContext.cs @@ -0,0 +1,9 @@ +using Daybreak.Shared.Models.Guildwars; +using System.Collections.Generic; + +namespace Daybreak.Shared.Models.FocusView; +public sealed class QuestLogComponentContext +{ + public required QuestMetadata? CurrentQuest { get; init; } + public required List Quests { get; init; } +} diff --git a/Daybreak.Shared/Models/FocusView/TitleInformationComponentContext.cs b/Daybreak.Shared/Models/FocusView/TitleInformationComponentContext.cs new file mode 100644 index 00000000..073c0a9e --- /dev/null +++ b/Daybreak.Shared/Models/FocusView/TitleInformationComponentContext.cs @@ -0,0 +1,13 @@ +using Daybreak.Shared.Models.Guildwars; + +namespace Daybreak.Shared.Models.FocusView; +public sealed class TitleInformationComponentContext +{ + public required Title Title { get; init; } + public required bool IsPercentage { get; init; } + public required uint CurrentPoints { get; init; } + public required uint PointsForCurrentRank { get; init; } + public required uint PointsForNextRank { get; init; } + public required uint TierNumber { get; init; } + public required uint MaxTierNumber { get; init; } +} diff --git a/Daybreak.Shared/Models/FocusView/VanquishComponentContext.cs b/Daybreak.Shared/Models/FocusView/VanquishComponentContext.cs new file mode 100644 index 00000000..0f242fac --- /dev/null +++ b/Daybreak.Shared/Models/FocusView/VanquishComponentContext.cs @@ -0,0 +1,8 @@ +namespace Daybreak.Shared.Models.FocusView; +public sealed class VanquishComponentContext +{ + public required uint FoesToKill { get; init; } + public required uint FoesKilled { get; init; } + public required bool HardMode { get; init; } + public required bool Vanquishing { get; init; } +} diff --git a/Daybreak.Shared/Models/Guildwars/Attribute.cs b/Daybreak.Shared/Models/Guildwars/Attribute.cs index 4653d89f..6694301c 100644 --- a/Daybreak.Shared/Models/Guildwars/Attribute.cs +++ b/Daybreak.Shared/Models/Guildwars/Attribute.cs @@ -51,8 +51,8 @@ public sealed class Attribute public static readonly Attribute WindPrayers = new() { Name = "Wind Prayers", Id = 42, Profession = Profession.Dervish }; public static readonly Attribute EarthPrayers = new() { Name = "Earth Prayers", Id = 43, Profession = Profession.Dervish }; public static readonly Attribute Mysticism = new() { Name = "Mysticism", Id = 44, Profession = Profession.Dervish }; - public static readonly IEnumerable Attributes = new List - { + public static readonly IEnumerable Attributes = + [ FastCasting, IllusionMagic, DominationMagic, @@ -96,7 +96,7 @@ public sealed class Attribute WindPrayers, EarthMagic, Mysticism - }; + ]; public static bool TryParse(int id, out Attribute attribute) { diff --git a/Daybreak.Shared/Models/Guildwars/Hero.cs b/Daybreak.Shared/Models/Guildwars/Hero.cs index e219d2c3..269de8d2 100644 --- a/Daybreak.Shared/Models/Guildwars/Hero.cs +++ b/Daybreak.Shared/Models/Guildwars/Hero.cs @@ -5,7 +5,7 @@ using System.Linq; namespace Daybreak.Shared.Models.Guildwars; public sealed class Hero : IWikiEntity { - public static readonly Hero None = new() { Id = 0, Profession = Profession.None }; + public static readonly Hero None = new() { Id = 0, Profession = Profession.None, Name = string.Empty, WikiUrl = string.Empty }; public static readonly Hero Norgu = new() { Id = 1, Name = "Norgu", WikiUrl = "https://wiki.guildwars.com/wiki/Norgu", Profession = Profession.Mesmer }; public static readonly Hero Goren = new() { Id = 2, Name = "Goren", WikiUrl = "https://wiki.guildwars.com/wiki/Goren", Profession = Profession.Warrior }; public static readonly Hero Tahlkora = new() { Id = 3, Name = "Tahlkora", WikiUrl = "https://wiki.guildwars.com/wiki/Tahlkora", Profession = Profession.Monk }; @@ -33,14 +33,14 @@ public sealed class Hero : IWikiEntity public static readonly Hero Xandra = new() { Id = 25, Name = "Xandra", WikiUrl = "https://wiki.guildwars.com/wiki/Xandra", Profession = Profession.Ritualist }; public static readonly Hero Vekk = new() { Id = 26, Name = "Vekk", WikiUrl = "https://wiki.guildwars.com/wiki/Vekk", Profession = Profession.Elementalist }; public static readonly Hero OgdenStonehealer = new() { Id = 27, Name = "Ogden Stonehealer", WikiUrl = "https://wiki.guildwars.com/wiki/Ogden_Stonehealer", Profession = Profession.Monk }; - public static readonly Hero Merc1 = new() { Id = 28, Name = "Mercenary Hero 1", Profession = Profession.None }; - public static readonly Hero Merc2 = new() { Id = 29, Name = "Mercenary Hero 2", Profession = Profession.None }; - public static readonly Hero Merc3 = new() { Id = 30, Name = "Mercenary Hero 3", Profession = Profession.None }; - public static readonly Hero Merc4 = new() { Id = 31, Name = "Mercenary Hero 4", Profession = Profession.None }; - public static readonly Hero Merc5 = new() { Id = 32, Name = "Mercenary Hero 5", Profession = Profession.None }; - public static readonly Hero Merc6 = new() { Id = 33, Name = "Mercenary Hero 6", Profession = Profession.None }; - public static readonly Hero Merc7 = new() { Id = 34, Name = "Mercenary Hero 7", Profession = Profession.None }; - public static readonly Hero Merc8 = new() { Id = 35, Name = "Mercenary Hero 8", Profession = Profession.None }; + public static readonly Hero Merc1 = new() { Id = 28, Name = "Mercenary Hero 1", WikiUrl = string.Empty, Profession = Profession.None }; + public static readonly Hero Merc2 = new() { Id = 29, Name = "Mercenary Hero 2", WikiUrl = string.Empty, Profession = Profession.None }; + public static readonly Hero Merc3 = new() { Id = 30, Name = "Mercenary Hero 3", WikiUrl = string.Empty, Profession = Profession.None }; + public static readonly Hero Merc4 = new() { Id = 31, Name = "Mercenary Hero 4", WikiUrl = string.Empty, Profession = Profession.None }; + public static readonly Hero Merc5 = new() { Id = 32, Name = "Mercenary Hero 5", WikiUrl = string.Empty, Profession = Profession.None }; + public static readonly Hero Merc6 = new() { Id = 33, Name = "Mercenary Hero 6", WikiUrl = string.Empty, Profession = Profession.None }; + public static readonly Hero Merc7 = new() { Id = 34, Name = "Mercenary Hero 7", WikiUrl = string.Empty, Profession = Profession.None }; + public static readonly Hero Merc8 = new() { Id = 35, Name = "Mercenary Hero 8", WikiUrl = string.Empty, Profession = Profession.None }; public static readonly Hero Miku = new() { Id = 36, Name = "Miku", WikiUrl = "https://wiki.guildwars.com/wiki/Miku", Profession = Profession.Assassin }; public static readonly Hero ZeiRi = new() { Id = 37, Name = "Zei Ri", WikiUrl = "https://wiki.guildwars.com/wiki/Zei_Ri", Profession = Profession.Ritualist }; @@ -128,8 +128,8 @@ public sealed class Hero : IWikiEntity { } - public int Id { get; init; } - public string? Name { get; init; } - public string? WikiUrl { get; init; } + public required int Id { get; init; } + public required string Name { get; init; } + public required string WikiUrl { get; init; } public required Profession Profession { get; init; } } diff --git a/Daybreak.Shared/Models/Guildwars/LoginData.cs b/Daybreak.Shared/Models/Guildwars/LoginData.cs deleted file mode 100644 index baa2614a..00000000 --- a/Daybreak.Shared/Models/Guildwars/LoginData.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Daybreak.Shared.Models.Guildwars; - -public sealed class LoginData -{ - public string Email { get; init; } = string.Empty; - public string PlayerName { get; init; } = string.Empty; -} diff --git a/Daybreak.Shared/Models/Guildwars/MainPlayerData.cs b/Daybreak.Shared/Models/Guildwars/MainPlayerData.cs deleted file mode 100644 index 192bb959..00000000 --- a/Daybreak.Shared/Models/Guildwars/MainPlayerData.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Daybreak.Shared.Models.Guildwars; - -public sealed class MainPlayerData -{ - public MainPlayerInformation? PlayerInformation { get; init; } -} diff --git a/Daybreak.Shared/Models/Guildwars/MainPlayerInformation.cs b/Daybreak.Shared/Models/Guildwars/MainPlayerInformation.cs deleted file mode 100644 index 1f586a8a..00000000 --- a/Daybreak.Shared/Models/Guildwars/MainPlayerInformation.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Collections.Generic; - -namespace Daybreak.Shared.Models.Guildwars; - -public sealed class MainPlayerInformation -{ - public string? Name { get; init; } - public uint Timer { get; init; } - public TitleInformation? TitleInformation { get; init; } - public int Id { get; init; } - public int Level { get; init; } - public Position? Position { get; set; } - public Profession? PrimaryProfession { get; init; } - public Profession? SecondaryProfession { get; init; } - public List? UnlockedProfession { get; init; } - public Build? CurrentBuild { get; init; } - public float CurrentHealth { get; set; } - public float MaxHealth { get; init; } - public float CurrentEnergy { get; set; } - public float MaxEnergy { get; init; } - public float HealthRegen { get; init; } - public float EnergyRegen { get; init; } - public float RotationAngle { get; set; } - public bool HardModeUnlocked { get; init; } - public uint Experience { get; init; } - public uint Morale { get; init; } - public Quest? Quest { get; init; } - public List? QuestLog { get; init; } -} diff --git a/Daybreak.Shared/Models/Guildwars/PartyCompositionMetadataEntry.cs b/Daybreak.Shared/Models/Guildwars/PartyCompositionMetadataEntry.cs index 69428591..9d29d479 100644 --- a/Daybreak.Shared/Models/Guildwars/PartyCompositionMetadataEntry.cs +++ b/Daybreak.Shared/Models/Guildwars/PartyCompositionMetadataEntry.cs @@ -1,4 +1,5 @@ -using Newtonsoft.Json; +using Daybreak.Shared.Models.Api; +using Newtonsoft.Json; using Newtonsoft.Json.Converters; namespace Daybreak.Shared.Models.Guildwars; @@ -11,6 +12,9 @@ public sealed class PartyCompositionMetadataEntry [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public int? HeroId { get; init; } + + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public HeroBehavior? Behavior { get; init; } } [JsonConverter(typeof(StringEnumConverter))] diff --git a/Daybreak.Shared/Models/Guildwars/PlayerInformation.cs b/Daybreak.Shared/Models/Guildwars/PlayerInformation.cs deleted file mode 100644 index 51188373..00000000 --- a/Daybreak.Shared/Models/Guildwars/PlayerInformation.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Collections.Generic; - -namespace Daybreak.Shared.Models.Guildwars; - -public sealed class PlayerInformation -{ - public int Id { get; init; } - public uint Timer { get; init; } - public int Level { get; init; } - public Profession? PrimaryProfession { get; init; } - public Profession? SecondaryProfession { get; init; } - public List? UnlockedProfession { get; init; } - public Build? CurrentBuild { get; init; } - public Npc? NpcDefinition { get; init; } - public float CurrentHealth { get; set; } - public float MaxHealth { get; init; } - public float CurrentEnergy { get; set; } - public float MaxEnergy { get; init; } - public float HealthRegen { get; init; } - public float EnergyRegen { get; init; } - public float RotationAngle { get; set; } - - // These two currently don't work with the memory scanner - public uint ModelType { get; init; } - public Position? Position { get; set; } -} diff --git a/Daybreak.Shared/Models/Guildwars/Position.cs b/Daybreak.Shared/Models/Guildwars/Position.cs deleted file mode 100644 index 785a65df..00000000 --- a/Daybreak.Shared/Models/Guildwars/Position.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; - -namespace Daybreak.Shared.Models.Guildwars; - -public readonly struct Position : IEquatable -{ - public float X { get; init; } - public float Y { get; init; } - - public bool Equals(Position other) - { - return this.X == other.X && this.Y == other.Y; - } - - public override bool Equals(object? obj) - { - return obj is Position position && this.Equals(position); - } - - public override int GetHashCode() - { - return HashCode.Combine(this.X, this.Y); - } - - public static bool operator ==(Position left, Position right) - { - return left.Equals(right); - } - - public static bool operator !=(Position left, Position right) - { - return !(left == right); - } -} diff --git a/Daybreak.Shared/Models/Guildwars/QuestMetadata.cs b/Daybreak.Shared/Models/Guildwars/QuestMetadata.cs index 1e6154bd..e4880e5f 100644 --- a/Daybreak.Shared/Models/Guildwars/QuestMetadata.cs +++ b/Daybreak.Shared/Models/Guildwars/QuestMetadata.cs @@ -3,7 +3,6 @@ public sealed class QuestMetadata { public Quest? Quest { get; init; } - public Position? Position { get; init; } public Map? From { get; init; } public Map? To { get; init; } public float RotationAngle { get; } = 0f; diff --git a/Daybreak.Shared/Models/Guildwars/SessionData.cs b/Daybreak.Shared/Models/Guildwars/SessionData.cs deleted file mode 100644 index a931811b..00000000 --- a/Daybreak.Shared/Models/Guildwars/SessionData.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Daybreak.Shared.Models.Guildwars; - -public sealed class SessionData -{ - public SessionInformation? Session { get; init; } -} diff --git a/Daybreak.Shared/Models/Guildwars/SessionInformation.cs b/Daybreak.Shared/Models/Guildwars/SessionInformation.cs deleted file mode 100644 index f0b7d797..00000000 --- a/Daybreak.Shared/Models/Guildwars/SessionInformation.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Daybreak.Shared.Models.Guildwars; - -public sealed class SessionInformation -{ - public uint FoesKilled { get; init; } - public uint FoesToKill { get; init; } - public Map? CurrentMap { get; init; } - public uint InstanceTimer { get; init; } - public InstanceType InstanceType { get; init; } -} diff --git a/Daybreak.Shared/Models/Guildwars/Skill.cs b/Daybreak.Shared/Models/Guildwars/Skill.cs index 18d7fd3f..0ab98b48 100644 --- a/Daybreak.Shared/Models/Guildwars/Skill.cs +++ b/Daybreak.Shared/Models/Guildwars/Skill.cs @@ -913,7 +913,7 @@ public sealed class Skill public static readonly Skill ScorpionWire = new() { Id = 815, Name = "Scorpion Wire", Profession = Profession.Assassin }; public static readonly Skill SiphonStrength = new() { Id = 827, Name = "Siphon Strength", Profession = Profession.Assassin }; public static readonly Skill DancingDaggers = new() { Id = 858, Name = "Dancing Daggers", Profession = Profession.Assassin }; - public static readonly Skill SignetofShadows = new() { Id = 876, Name = "Signet of Shadows" }; + public static readonly Skill SignetofShadows = new() { Id = 876, Name = "Signet of Shadows", Profession = Profession.Assassin }; public static readonly Skill ShamefulFear = new() { Id = 927, Name = "Shameful Fear", Profession = Profession.Assassin }; public static readonly Skill SiphonSpeed = new() { Id = 951, Name = "Siphon Speed", Profession = Profession.Assassin }; public static readonly Skill MantisTouch = new() { Id = 974, Name = "Mantis Touch", Profession = Profession.Assassin }; @@ -3022,9 +3022,9 @@ public sealed class Skill return skill; } - public Profession? Profession { get; private set; } - public string? Name { get; private set; } - public int? Id { get; private set; } + public required Profession Profession { get; init; } + public required string Name { get; init; } + public required int Id { get; init; } public string? AlternativeName { get; private set; } public override string ToString() => this.Name ?? this.AlternativeName ?? nameof(Skill); private Skill() diff --git a/Daybreak.Shared/Models/Guildwars/SkillMetadata.cs b/Daybreak.Shared/Models/Guildwars/SkillMetadata.cs deleted file mode 100644 index 5b2fd0dc..00000000 --- a/Daybreak.Shared/Models/Guildwars/SkillMetadata.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Daybreak.Shared.Models.Guildwars; - -public sealed class SkillMetadata -{ - public Skill? Skill { get; init; } - public uint Adrenaline1 { get; init; } - public uint Adrenaline2 { get; init; } - public uint Recharge { get; init; } - public uint Id { get; init; } - public uint Event { get; init; } -} diff --git a/Daybreak.Shared/Models/Guildwars/TeamBuildData.cs b/Daybreak.Shared/Models/Guildwars/TeamBuildData.cs deleted file mode 100644 index dffb3b70..00000000 --- a/Daybreak.Shared/Models/Guildwars/TeamBuildData.cs +++ /dev/null @@ -1,7 +0,0 @@ -using System.Collections.Generic; - -namespace Daybreak.Shared.Models.Guildwars; -public sealed class TeamBuildData -{ - public required List TeamBuildPlayers { get; init; } -} diff --git a/Daybreak.Shared/Models/Guildwars/TeamBuildPlayerData.cs b/Daybreak.Shared/Models/Guildwars/TeamBuildPlayerData.cs deleted file mode 100644 index 933590c0..00000000 --- a/Daybreak.Shared/Models/Guildwars/TeamBuildPlayerData.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace Daybreak.Shared.Models.Guildwars; -public abstract class TeamBuildPlayerData -{ - public required Build Build { get; init; } -} - -public sealed class TeamBuildMainPlayerData : TeamBuildPlayerData -{ -} - -public sealed class TeamBuildHeroData : TeamBuildPlayerData -{ - public required Hero Hero { get; init; } -} - -public sealed class TeamBuildPartyMemberData : TeamBuildPlayerData -{ -} - -public sealed class TeamBuildHenchmanData : TeamBuildPlayerData -{ -} diff --git a/Daybreak.Shared/Models/Guildwars/TitleInformation.cs b/Daybreak.Shared/Models/Guildwars/TitleInformation.cs deleted file mode 100644 index 936f6388..00000000 --- a/Daybreak.Shared/Models/Guildwars/TitleInformation.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Daybreak.Shared.Models.Guildwars; - -public sealed class TitleInformation -{ - public bool IsValid => this.CurrentPoints != 0 && this.PointsForCurrentRank != 0 && this.PointsForNextRank != 0 && this.TierNumber != 0 && this.MaxTierNumber != 0; - - public bool? IsPercentage { get; init; } - - public uint? CurrentPoints { get; init; } - - public uint? PointsForCurrentRank { get; init; } - - public uint? PointsForNextRank { get; init; } - - public uint? TierNumber { get; init; } - - public uint? MaxTierNumber { get; init; } - - public Title? Title { get; init; } -} diff --git a/Daybreak.Shared/Models/Guildwars/UserData.cs b/Daybreak.Shared/Models/Guildwars/UserData.cs deleted file mode 100644 index aa01c3cc..00000000 --- a/Daybreak.Shared/Models/Guildwars/UserData.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Daybreak.Shared.Models.Guildwars; -public sealed class UserData -{ - public UserInformation? User { get; init; } -} diff --git a/Daybreak.Shared/Models/Guildwars/UserInformation.cs b/Daybreak.Shared/Models/Guildwars/UserInformation.cs deleted file mode 100644 index c431eac0..00000000 --- a/Daybreak.Shared/Models/Guildwars/UserInformation.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Daybreak.Shared.Models.Guildwars; - -public sealed class UserInformation -{ - public string? Email { get; init; } - public uint CurrentKurzickPoints { get; init; } - public uint TotalKurzickPoints { get; init; } - public uint CurrentLuxonPoints { get; init; } - public uint TotalLuxonPoints { get; init; } - public uint CurrentImperialPoints { get; init; } - public uint TotalImperialPoints { get; init; } - public uint CurrentBalthazarPoints { get; init; } - public uint TotalBalthazarPoints { get; init; } - public uint CurrentSkillPoints { get; init; } - public uint TotalSkillPoints { get; init; } - public uint MaxKurzickPoints { get; init; } - public uint MaxLuxonPoints { get; init; } - public uint MaxImperialPoints { get; init; } - public uint MaxBalthazarPoints { get; init; } -} diff --git a/Daybreak.Shared/Models/Guildwars/WorldData.cs b/Daybreak.Shared/Models/Guildwars/WorldData.cs deleted file mode 100644 index b17dbfd6..00000000 --- a/Daybreak.Shared/Models/Guildwars/WorldData.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Daybreak.Shared.Models.Guildwars; - -public sealed class WorldData -{ - public Campaign? Campaign { get; init; } - public Continent? Continent { get; init; } - public Region? Region { get; init; } - public Map? Map { get; init; } -} diff --git a/Daybreak.Shared/Models/Guildwars/WorldPlayerInformation.cs b/Daybreak.Shared/Models/Guildwars/WorldPlayerInformation.cs deleted file mode 100644 index e7d2e75b..00000000 --- a/Daybreak.Shared/Models/Guildwars/WorldPlayerInformation.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Collections.Generic; - -namespace Daybreak.Shared.Models.Guildwars; - -public sealed class WorldPlayerInformation -{ - public string? Name { get; init; } - public uint Timer { get; init; } - public TitleInformation? TitleInformation { get; init; } - public int Id { get; init; } - public int Level { get; init; } - public Position? Position { get; set; } - public Profession? PrimaryProfession { get; init; } - public Profession? SecondaryProfession { get; init; } - public List? UnlockedProfession { get; init; } - public Build? CurrentBuild { get; init; } - public float CurrentHealth { get; set; } - public float MaxHealth { get; init; } - public float CurrentEnergy { get; set; } - public float MaxEnergy { get; init; } - public float HealthRegen { get; init; } - public float EnergyRegen { get; init; } - public float RotationAngle { get; set; } -} diff --git a/Daybreak.Shared/Models/Interop/GameContext.cs b/Daybreak.Shared/Models/Interop/GameContext.cs index 28e08a07..5c4314df 100644 --- a/Daybreak.Shared/Models/Interop/GameContext.cs +++ b/Daybreak.Shared/Models/Interop/GameContext.cs @@ -5,101 +5,101 @@ namespace Daybreak.Shared.Models.Interop; [StructLayout(LayoutKind.Explicit)] public readonly struct GameContext { - public const uint BaseOffset = 0x007C; + private const int BaseOffset = 0x007C; - [FieldOffset(0x0000)] + [FieldOffset(BaseOffset + 0x0000)] public readonly GuildwarsArray MapEntities; - [FieldOffset(0x0030)] + [FieldOffset(BaseOffset + 0x0030)] public readonly GuildwarsArray PartyAttributes; - [FieldOffset(0x04AC)] + [FieldOffset(BaseOffset + 0x04AC)] public readonly uint QuestId; - [FieldOffset(0x04B0)] + [FieldOffset(BaseOffset + 0x04B0)] public readonly GuildwarsArray QuestLog; - [FieldOffset(0x0604)] + [FieldOffset(BaseOffset + 0x0604)] public readonly GuildwarsPointer PlayerControlledChar; - [FieldOffset(0x0608)] + [FieldOffset(BaseOffset + 0x0608)] public readonly uint HardModeUnlocked; - [FieldOffset(0x0640)] + [FieldOffset(BaseOffset + 0x0640)] public readonly GuildwarsArray Professions; - [FieldOffset(0x0674)] + [FieldOffset(BaseOffset + 0x0674)] public readonly GuildwarsArray Skillbars; - [FieldOffset(0x06C4)] + [FieldOffset(BaseOffset + 0x06C4)] public readonly uint Experience; - [FieldOffset(0x06CC)] + [FieldOffset(BaseOffset + 0x06CC)] public readonly uint CurrentKurzick; - [FieldOffset(0x06D4)] + [FieldOffset(BaseOffset + 0x06D4)] public readonly uint TotalKurzick; - [FieldOffset(0x06DC)] + [FieldOffset(BaseOffset + 0x06DC)] public readonly uint CurrentLuxon; - [FieldOffset(0x06E4)] + [FieldOffset(BaseOffset + 0x06E4)] public readonly uint TotalLuxon; - [FieldOffset(0x06EC)] + [FieldOffset(BaseOffset + 0x06EC)] public readonly uint CurrentImperial; - [FieldOffset(0x06F4)] + [FieldOffset(BaseOffset + 0x06F4)] public readonly uint TotalImperial; - [FieldOffset(0x070C)] + [FieldOffset(BaseOffset + 0x070C)] public readonly uint Level; - [FieldOffset(0x0714)] + [FieldOffset(BaseOffset + 0x0714)] public readonly uint Morale; - [FieldOffset(0x071C)] + [FieldOffset(BaseOffset + 0x071C)] public readonly uint CurrentBalthazar; - [FieldOffset(0x0724)] + [FieldOffset(BaseOffset + 0x0724)] public readonly uint TotalBalthazar; - [FieldOffset(0x072C)] + [FieldOffset(BaseOffset + 0x072C)] public readonly uint CurrentSkillPoints; - [FieldOffset(0x0734)] + [FieldOffset(BaseOffset + 0x0734)] public readonly uint TotalSkillPoints; - [FieldOffset(0x073C)] + [FieldOffset(BaseOffset + 0x073C)] public readonly uint MaxKurzick; - [FieldOffset(0x0740)] + [FieldOffset(BaseOffset + 0x0740)] public readonly uint MaxLuxon; - [FieldOffset(0x0744)] + [FieldOffset(BaseOffset + 0x0744)] public readonly uint MaxBalthazar; - [FieldOffset(0x0748)] + [FieldOffset(BaseOffset + 0x0748)] public readonly uint MaxImperial; - [FieldOffset(0x0770)] + [FieldOffset(BaseOffset + 0x0770)] public readonly GuildwarsArray MissionMapIcons; - [FieldOffset(0x0780)] + [FieldOffset(BaseOffset + 0x0780)] public readonly GuildwarsArray Npcs; - [FieldOffset(0x790)] + [FieldOffset(BaseOffset + 0x790)] public readonly GuildwarsArray Players; - [FieldOffset(0x7A0)] + [FieldOffset(BaseOffset + 0x7A0)] public readonly GuildwarsArray Titles; - [FieldOffset(0x7B0)] + [FieldOffset(BaseOffset + 0x7B0)] public readonly GuildwarsArray TitlesTiers; - [FieldOffset(0x07D0)] + [FieldOffset(BaseOffset + 0x07D0)] public readonly uint FoesKilled; - [FieldOffset(0x07D4)] + [FieldOffset(BaseOffset + 0x07D4)] public readonly uint FoesToKill; } diff --git a/Daybreak.Shared/Models/LaunchConfigurations/GuildWarsApplicationLaunchContext.cs b/Daybreak.Shared/Models/LaunchConfigurations/GuildWarsApplicationLaunchContext.cs index 52fb6f4d..6af1b65f 100644 --- a/Daybreak.Shared/Models/LaunchConfigurations/GuildWarsApplicationLaunchContext.cs +++ b/Daybreak.Shared/Models/LaunchConfigurations/GuildWarsApplicationLaunchContext.cs @@ -5,9 +5,9 @@ namespace Daybreak.Shared.Models.LaunchConfigurations; public sealed record GuildWarsApplicationLaunchContext : IEquatable { - public LaunchConfigurationWithCredentials LaunchConfiguration { get; init; } = default!; - public Process GuildWarsProcess { get; init; } = default!; - public uint ProcessId { get; init; } = default!; + public required LaunchConfigurationWithCredentials LaunchConfiguration { get; init; } + public required Process GuildWarsProcess { get; init; } + public required uint ProcessId { get; init; } public GuildWarsApplicationLaunchContext() { diff --git a/Daybreak.Shared/Models/MainPlayerResourceContext.cs b/Daybreak.Shared/Models/MainPlayerResourceContext.cs deleted file mode 100644 index 12433ab9..00000000 --- a/Daybreak.Shared/Models/MainPlayerResourceContext.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Daybreak.Shared.Models.Guildwars; - -namespace Daybreak.Shared.Models; -public sealed class MainPlayerResourceContext -{ - public UserData? User { get; init; } - public SessionData? Session { get; init; } - public MainPlayerData? Player { get; init; } -} diff --git a/Daybreak.Shared/Services/Api/IAttachedApiAccessor.cs b/Daybreak.Shared/Services/Api/IAttachedApiAccessor.cs new file mode 100644 index 00000000..174d94da --- /dev/null +++ b/Daybreak.Shared/Services/Api/IAttachedApiAccessor.cs @@ -0,0 +1,8 @@ +using Daybreak.Shared.Models.LaunchConfigurations; + +namespace Daybreak.Shared.Services.Api; +public interface IAttachedApiAccessor +{ + GuildWarsApplicationLaunchContext? LaunchContext { get; } + ScopedApiContext? ApiContext { get; } +} diff --git a/Daybreak.Shared/Services/Api/IDaybreakApiService.cs b/Daybreak.Shared/Services/Api/IDaybreakApiService.cs index 29ea801c..6fadd5a9 100644 --- a/Daybreak.Shared/Services/Api/IDaybreakApiService.cs +++ b/Daybreak.Shared/Services/Api/IDaybreakApiService.cs @@ -1,6 +1,12 @@ -using Daybreak.Shared.Services.Mods; +using Daybreak.Shared.Models.LaunchConfigurations; +using Daybreak.Shared.Services.Mods; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; namespace Daybreak.Shared.Services.Api; public interface IDaybreakApiService : IModService { + Task AttachDaybreakApiContext(GuildWarsApplicationLaunchContext launchContext, CancellationToken cancellationToken); + Task GetDaybreakApiContext(Process guildWarsProcess, CancellationToken cancellationToken); } diff --git a/Daybreak.Shared/Services/Api/ScopedApiContext.cs b/Daybreak.Shared/Services/Api/ScopedApiContext.cs new file mode 100644 index 00000000..e3e66699 --- /dev/null +++ b/Daybreak.Shared/Services/Api/ScopedApiContext.cs @@ -0,0 +1,170 @@ +using Daybreak.Shared.Models; +using Daybreak.Shared.Models.Api; +using Microsoft.Extensions.Logging; +using System; +using System.Extensions.Core; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Daybreak.Shared.Services.Api; +public sealed class ScopedApiContext( + ILogger logger, + IHttpClient httpClient, + DaybreakAPIContext context) +{ + private const string IdentifierPlaceholder = "[IDENTIFIER]"; + private const string CodePlaceholder = "[CODE]"; + + private const string GetHealthPath = "/api/v1/rest/health"; + private const string GetCharacterSelectPath = "/api/v1/rest/character-select"; + private const string PostCharacterSelectPath = $"/api/v1/rest/character-select/{IdentifierPlaceholder}"; + private const string GetMainPlayerStatePath = "/api/v1/rest/main-player/state"; + private const string GetMainPlayerQuestLogPath = "/api/v1/rest/main-player/quest-log"; + private const string GetMainPlayerInfoPath = "/api/v1/rest/main-player/info"; + private const string GetMainPlayerInstanceInfoPath = "/api/v1/rest/main-player/instance-info"; + private const string GetMainPlayerBuildPath = "/api/v1/rest/main-player/build"; + private const string PostMainPlayerBuildPath = $"/api/v1/rest/main-player/build?code={CodePlaceholder}"; + private const string PartyLoadoutPath = "/api/v1/rest/party/loadout"; + private const string GetTitleInfoPath = "/api/v1/rest/main-player/title"; + + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5); + + private readonly ILogger logger = logger; + private readonly DaybreakAPIContext context = context; + private readonly IHttpClient httpClient = httpClient; + + public async Task IsAvailable(CancellationToken cancellationToken) + { + return await this.Get(GetHealthPath, response => + { + if (response.IsSuccessStatusCode) + { + return Task.FromResult(true); + } + + return Task.FromResult(false); + }, cancellationToken); + } + + public Task GetCharacters(CancellationToken cancellationToken) => this.GetPayload(GetCharacterSelectPath, cancellationToken); + + public async Task SwitchCharacter(string characterName, CancellationToken cancellationToken) + { + var path = PostCharacterSelectPath.Replace(IdentifierPlaceholder, characterName); + var result = await this.Post(path, request => new StringContent(string.Empty), cancellationToken); + if (!result) + { + this.logger.LogError("Failed to switch character to {characterName}", characterName); + return false; + } + + return true; + } + + public Task GetMainPlayerState(CancellationToken cancellationToken) => this.GetPayload(GetMainPlayerStatePath, cancellationToken); + + public Task GetMainPlayerQuestLog(CancellationToken cancellationToken) => this.GetPayload(GetMainPlayerQuestLogPath, cancellationToken); + + public Task GetMainPlayerInfo(CancellationToken cancellationToken) => this.GetPayload(GetMainPlayerInfoPath, cancellationToken); + + public Task GetMainPlayerInstanceInfo(CancellationToken cancellationToken) => this.GetPayload(GetMainPlayerInstanceInfoPath, cancellationToken); + + public Task GetMainPlayerBuild(CancellationToken cancellationToken) => this.GetPayload(GetMainPlayerBuildPath, cancellationToken); + + public async Task PostMainPlayerBuild(string code, CancellationToken cancellationToken) + { + var path = PostMainPlayerBuildPath.Replace(CodePlaceholder, code); + using var emptyContent = new StringContent(string.Empty); + return await this.Post(path, request => emptyContent, cancellationToken); + } + + public Task GetPartyLoadout(CancellationToken cancellationToken) => this.GetPayload(PartyLoadoutPath, cancellationToken); + + public Task GetTitleInfo(CancellationToken cancellationToken) => this.GetPayload(GetTitleInfoPath, cancellationToken); + + public async Task PostPartyLoadout(PartyLoadout partyLoadout, CancellationToken cancellationToken) => await this.PostPayload(PartyLoadoutPath, partyLoadout, cancellationToken); + + private async Task PostPayload(string path, T payload, CancellationToken cancellationToken) + { + using var content = JsonContent.Create(payload); + var result = await this.Post(path, (request) => + { + request.Headers.Add("Accept", "application/json"); + return content; + }, cancellationToken); + + return result; + } + + private async Task Post(string path, Func contentBuilder, CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(path); + using var timeoutCts = new CancellationTokenSource(RequestTimeout); + using var compositeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + if (!Uri.TryCreate(this.context.ApiUri, path, out var uri)) + { + scopedLogger.LogError("Failed to create URI from {baseUri}/{path}", this.context.ApiUri, path); + return false; + } + + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, uri); + request.Content = contentBuilder(request); + using var response = await this.httpClient.SendAsync(request, compositeCts.Token); + if (response.IsSuccessStatusCode) + { + return true; + } + + scopedLogger.LogError("Failed to post data to {path}: {statusCode} {reasonPhrase}", path, response.StatusCode, response.ReasonPhrase ?? string.Empty); + return false; + } + catch (Exception ex) + { + scopedLogger.LogError(ex, "Failed to execute api request"); + return false; + } + } + + private async Task GetPayload(string path, CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(path); + return await this.Get(path, async (response) => + { + if (!response.IsSuccessStatusCode) + { + scopedLogger.LogError("Failed to fetch data from {path}: {statusCode} {reasonPhrase}", path, response.StatusCode, response.ReasonPhrase ?? string.Empty); + return default; + } + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken); + return payload; + }, cancellationToken); + } + + private async Task Get(string path, Func> responseBuilder, CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(path); + using var timeoutCts = new CancellationTokenSource(RequestTimeout); + using var compositeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + if (!Uri.TryCreate(this.context.ApiUri, path, out var uri)) + { + scopedLogger.LogError("Failed to create URI from {baseUri}/{path}", this.context.ApiUri, path); + return default; + } + + try + { + using var response = await this.httpClient.GetAsync(uri, compositeCts.Token); + return await responseBuilder(response); + } + catch (Exception ex) + { + scopedLogger.LogError(ex, "Failed to execute api request"); + return default; + } + } +} diff --git a/Daybreak.Shared/Services/ApplicationLauncher/IApplicationLauncher.cs b/Daybreak.Shared/Services/ApplicationLauncher/IApplicationLauncher.cs index 823e1886..558ac967 100644 --- a/Daybreak.Shared/Services/ApplicationLauncher/IApplicationLauncher.cs +++ b/Daybreak.Shared/Services/ApplicationLauncher/IApplicationLauncher.cs @@ -1,6 +1,7 @@ using Daybreak.Shared.Models.LaunchConfigurations; using System.Collections.Generic; using System.Diagnostics; +using System.Threading; using System.Threading.Tasks; namespace Daybreak.Shared.Services.ApplicationLauncher; @@ -11,7 +12,7 @@ public interface IApplicationLauncher IEnumerable GetGuildwarsProcesses(params LaunchConfigurationWithCredentials[] launchConfigurationWithCredentials); IEnumerable GetGuildwarsProcesses(); void KillGuildWarsProcess(GuildWarsApplicationLaunchContext guildWarsApplicationLaunchContext); - Task LaunchGuildwars(LaunchConfigurationWithCredentials launchConfigurationWithCredentials); + Task LaunchGuildwars(LaunchConfigurationWithCredentials launchConfigurationWithCredentials, CancellationToken cancellationToken); void RestartDaybreak(); void RestartDaybreakAsAdmin(); void RestartDaybreakAsNormalUser(); diff --git a/Daybreak/Services/BuildTemplates/BuildTemplateManager.cs b/Daybreak.Shared/Services/BuildTemplates/BuildTemplateManager.cs similarity index 79% rename from Daybreak/Services/BuildTemplates/BuildTemplateManager.cs rename to Daybreak.Shared/Services/BuildTemplates/BuildTemplateManager.cs index af69d356..81097480 100644 --- a/Daybreak/Services/BuildTemplates/BuildTemplateManager.cs +++ b/Daybreak.Shared/Services/BuildTemplates/BuildTemplateManager.cs @@ -1,24 +1,25 @@ -using Daybreak.Services.BuildTemplates.Models; +using Daybreak.Shared.Models; +using Daybreak.Shared.Models.Api; using Daybreak.Shared.Models.Builds; using Daybreak.Shared.Models.Guildwars; -using Daybreak.Shared.Services.BuildTemplates; +using Daybreak.Shared.Services.BuildTemplates.Models; using Daybreak.Shared.Utils; using Microsoft.Extensions.Logging; -using NAudio.MediaFoundation; using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Extensions; +using System.Extensions.Core; using System.IO; using System.Linq; using System.Text; -using System.Threading; using System.Threading.Tasks; +using AttributeEntry = Daybreak.Shared.Models.Builds.AttributeEntry; using Convert = System.Convert; -namespace Daybreak.Services.BuildTemplates; +namespace Daybreak.Shared.Services.BuildTemplates; -internal sealed class BuildTemplateManager( +public sealed class BuildTemplateManager( ILogger logger) : IBuildTemplateManager { private const string DecodingLookupTable = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; @@ -51,9 +52,8 @@ internal sealed class BuildTemplateManager( PreviousName = string.Empty, Attributes = emptyBuild.Attributes, Skills = emptyBuild.Skills, + CreationTime = DateTimeOffset.UtcNow }; - - entry.CreationTime = DateTimeOffset.UtcNow; return entry; } @@ -65,10 +65,9 @@ internal sealed class BuildTemplateManager( Name = name, PreviousName = string.Empty, Attributes = emptyBuild.Attributes, - Skills = emptyBuild.Skills + Skills = emptyBuild.Skills, + CreationTime = DateTimeOffset.UtcNow }; - - entry.CreationTime = DateTimeOffset.UtcNow; return entry; } @@ -79,10 +78,9 @@ internal sealed class BuildTemplateManager( { Name = name, PreviousName = string.Empty, - Builds = [this.CreateSingleBuild()] + Builds = [this.CreateSingleBuild()], + CreationTime = DateTimeOffset.UtcNow }; - - entry.CreationTime = DateTimeOffset.UtcNow; return entry; } @@ -92,23 +90,22 @@ internal sealed class BuildTemplateManager( { Name = name, PreviousName = string.Empty, - Builds = [this.CreateSingleBuild(name)] + Builds = [this.CreateSingleBuild(name)], + CreationTime = DateTimeOffset.UtcNow }; - - entry.CreationTime = DateTimeOffset.UtcNow; return entry; } - public TeamBuildEntry CreateTeamBuild(TeamBuildData teamBuildData) + public TeamBuildEntry CreateTeamBuild(PartyLoadout partyLoadout) { var entry = this.CreateTeamBuild(); - return this.PopulateTeamBuild(entry, teamBuildData); + return this.PopulateTeamBuild(entry, partyLoadout); } - public TeamBuildEntry CreateTeamBuild(TeamBuildData teamBuildData, string name) + public TeamBuildEntry CreateTeamBuild(PartyLoadout partyLoadout, string name) { - var entry = this.CreateTeamBuild(); - return this.PopulateTeamBuild(entry, teamBuildData); + var entry = this.CreateTeamBuild(name); + return this.PopulateTeamBuild(entry, partyLoadout); } public SingleBuildEntry ConvertToSingleBuildEntry(TeamBuildEntry teamBuildEntry) @@ -142,6 +139,49 @@ internal sealed class BuildTemplateManager( return teamBuildEntry; } + public bool CanTemplateApply(BuildTemplateValidationRequest request) + { + var scopedLogger = this.logger.CreateScopedLogger(); + bool IsProfessionUnlocked(int professionId) => (request.UnlockedCharacterProfessions & (1 << professionId)) != 0; + bool IsSkillUnlocked(int skillId, uint[] unlockedSkills) + { + var realIndex = skillId / 32; + if (realIndex >= unlockedSkills.Length) + { + return false; + } + + var shift = skillId % 32; + var flag = 1U << shift; + return (unlockedSkills[realIndex] & flag) != 0; + } + + if (request.BuildPrimary != request.CurrentPrimary) + { + scopedLogger.LogError("Primary profession does not match current primary profession"); + return false; + } + + if (request.BuildSecondary is not 0 && + !IsProfessionUnlocked((int)request.BuildSecondary)) + { + scopedLogger.LogError("Secondary profession is not unlocked"); + return false; + } + + foreach(var skill in request.BuildSkills) + { + if (skill is not 0 && + !IsSkillUnlocked((int)skill, request.UnlockedSkills)) + { + scopedLogger.LogError("Skill {skillId} is not unlocked", skill); + return false; + } + } + + return true; + } + public void SaveBuild(IBuildEntry buildEntry) { var encodedBuild = new StringBuilder(); @@ -256,7 +296,7 @@ internal sealed class BuildTemplateManager( { Name = randomName, PreviousName = randomName, - Builds = builds.Select(b => new SingleBuildEntry + Builds = [.. builds.Select(b => new SingleBuildEntry { Name = randomName, PreviousName = randomName, @@ -264,7 +304,7 @@ internal sealed class BuildTemplateManager( Secondary = b.Secondary, Attributes = b.Attributes, Skills = b.Skills - }).ToList() + })] }), onFailure: exception => throw exception); } @@ -347,41 +387,63 @@ internal sealed class BuildTemplateManager( throw new InvalidOperationException($"Unknown build entry of type {build.GetType().Name}"); } - private TeamBuildEntry PopulateTeamBuild(TeamBuildEntry teamBuildEntry, TeamBuildData teamBuildData) + private TeamBuildEntry PopulateTeamBuild(TeamBuildEntry teamBuildEntry, PartyLoadout partyLoadout) { + var scopedLogger = this.logger.CreateScopedLogger(); teamBuildEntry.Builds.Clear(); var partyCompositionMetadata = new List(); var index = 0; - foreach (var teamBuildPlayer in teamBuildData?.TeamBuildPlayers ?? []) + foreach (var entry in partyLoadout.Entries) { - if (teamBuildPlayer is null) + var build = entry.Build; + var buildEntry = this.CreateSingleBuild(); + if (!Profession.TryParse(build.Primary, out var primary)) { - continue; + scopedLogger.LogError("Failed to parse primary profession with id {professionId}", build.Primary); + throw new InvalidOperationException($"Failed to parse primary profession with id {build.Primary}"); } - var build = teamBuildPlayer.Build; - var buildEntry = this.CreateSingleBuild(); - buildEntry.Primary = build.Primary; - buildEntry.Secondary = build.Secondary; - buildEntry.Attributes = build.Attributes; - buildEntry.Skills = build.Skills; + if (!Profession.TryParse(build.Secondary, out var secondary)) + { + scopedLogger.LogError("Failed to parse secondary profession with id {professionId}", build.Secondary); + throw new InvalidOperationException($"Failed to parse secondary profession with id {build.Secondary}"); + } + + buildEntry.Primary = primary; + buildEntry.Secondary = secondary; + buildEntry.Attributes = [.. build.Attributes.Select(a => + { + if (!Daybreak.Shared.Models.Guildwars.Attribute.TryParse((int)a.Id, out var attr)) + { + scopedLogger.LogError("Failed to parse attribute with id {attributeId}", a.Id); + throw new InvalidOperationException($"Failed to parse attribute with id {a.Id}"); + } + + return new Daybreak.Shared.Models.Builds.AttributeEntry + { + Attribute = attr, + Points = (int)a.BasePoints + }; + })]; + buildEntry.Skills = [.. build.Skills.Select(s => + { + if (!Skill.TryParse((int)s, out var skill)) + { + scopedLogger.LogError("Failed to parse skill with id {skillId}", s); + throw new InvalidOperationException($"Failed to parse skill with id {s}"); + } + + return skill; + })]; + teamBuildEntry.Builds.Add(buildEntry); - if (teamBuildPlayer is TeamBuildHeroData heroBuildData) + partyCompositionMetadata.Add(new PartyCompositionMetadataEntry { - partyCompositionMetadata.Add(new PartyCompositionMetadataEntry { Type = PartyCompositionMemberType.Hero, HeroId = heroBuildData.Hero.Id, Index = index }); - } - else if (teamBuildPlayer is TeamBuildMainPlayerData) - { - partyCompositionMetadata.Add(new PartyCompositionMetadataEntry { Type = PartyCompositionMemberType.MainPlayer, Index = index }); - } - else if (teamBuildPlayer is TeamBuildPartyMemberData) - { - partyCompositionMetadata.Add(new PartyCompositionMetadataEntry { Type = PartyCompositionMemberType.Player, Index = index }); - } - else if (teamBuildPlayer is TeamBuildHenchmanData) - { - partyCompositionMetadata.Add(new PartyCompositionMetadataEntry { Type = PartyCompositionMemberType.Henchman, Index = index }); - } + Type = entry.HeroId is 0 ? PartyCompositionMemberType.MainPlayer : PartyCompositionMemberType.Hero, + Index = index, + Behavior = entry.HeroId is 0 ? default : (HeroBehavior)entry.HeroBehavior, + HeroId = entry.HeroId is 0 ? default : entry.HeroId + }); index++; } @@ -529,6 +591,7 @@ internal sealed class BuildTemplateManager( build.Skills.Add(skill); } + this.logger.LogInformation("Successfully parsed build template"); return build; } @@ -543,9 +606,9 @@ internal sealed class BuildTemplateManager( PrimaryProfessionId = build.Primary.Id, SecondaryProfessionId = build.Secondary.Id, AttributeCount = build.Attributes.Where(attrEntry => attrEntry.Points > 0).Count(), - AttributesIds = build.Attributes.Where(attrEntry => attrEntry.Points > 0).OrderBy(attrEntry => attrEntry.Attribute!.Id).Select(attrEntry => attrEntry.Attribute!.Id).ToList(), - AttributePoints = build.Attributes.Where(attrEntry => attrEntry.Points > 0).OrderBy(attrEntry => attrEntry.Attribute!.Id).Select(attrEntry => attrEntry.Points).ToList(), - SkillIds = build.Skills.Select(skill => skill.Id!.Value).ToList(), + AttributesIds = [.. build.Attributes.Where(attrEntry => attrEntry.Points > 0).OrderBy(attrEntry => attrEntry.Attribute!.Id).Select(attrEntry => attrEntry.Attribute!.Id)], + AttributePoints = [.. build.Attributes.Where(attrEntry => attrEntry.Points > 0).OrderBy(attrEntry => attrEntry.Attribute!.Id).Select(attrEntry => attrEntry.Points)], + SkillIds = [.. build.Skills.Select(skill => skill.Id)], TailPresent = true }; @@ -555,12 +618,12 @@ internal sealed class BuildTemplateManager( var encodedBase64 = new List(); while (index < encodedBinary.Length) { - var subset = new string(encodedBinary.Skip(index).Take(6).ToArray()); + var subset = new string([.. encodedBinary.Skip(index).Take(6)]); encodedBase64.Add(FromBitString(subset)); index += 6; } - var template = new string(encodedBase64.Select(b => DecodingLookupTable[b]).ToArray()); + var template = new string([.. encodedBase64.Select(b => DecodingLookupTable[b])]); return template; } @@ -587,9 +650,9 @@ internal sealed class BuildTemplateManager( var buildMetadata = new BuildMetadata { - Base64Decoded = template.Select(c => DecodingLookupTable.IndexOf(c)).ToList(), + Base64Decoded = [.. template.Select(c => DecodingLookupTable.IndexOf(c))], }; - buildMetadata.BinaryDecoded = buildMetadata.Base64Decoded.Select(ToBitString).ToList(); + buildMetadata.BinaryDecoded = [.. buildMetadata.Base64Decoded.Select(ToBitString)]; var stream = new DecodeCharStream([.. buildMetadata.BinaryDecoded]); buildMetadata.Header = stream.Read(4); diff --git a/Daybreak.Shared/Services/BuildTemplates/IBuildTemplateManager.cs b/Daybreak.Shared/Services/BuildTemplates/IBuildTemplateManager.cs index f48eb4c0..1e6ab4f4 100644 --- a/Daybreak.Shared/Services/BuildTemplates/IBuildTemplateManager.cs +++ b/Daybreak.Shared/Services/BuildTemplates/IBuildTemplateManager.cs @@ -1,5 +1,6 @@ -using Daybreak.Shared.Models.Builds; -using Daybreak.Shared.Models.Guildwars; +using Daybreak.Shared.Models; +using Daybreak.Shared.Models.Api; +using Daybreak.Shared.Models.Builds; using System; using System.Collections.Generic; using System.Extensions; @@ -12,12 +13,13 @@ public interface IBuildTemplateManager SingleBuildEntry ConvertToSingleBuildEntry(TeamBuildEntry teamBuildEntry); TeamBuildEntry ConvertToTeamBuildEntry(SingleBuildEntry singleBuildEntry); bool IsTemplate(string template); + bool CanTemplateApply(BuildTemplateValidationRequest request); SingleBuildEntry CreateSingleBuild(); SingleBuildEntry CreateSingleBuild(string name); TeamBuildEntry CreateTeamBuild(); TeamBuildEntry CreateTeamBuild(string name); - TeamBuildEntry CreateTeamBuild(TeamBuildData teamBuildData); - TeamBuildEntry CreateTeamBuild(TeamBuildData teamBuildData, string name); + TeamBuildEntry CreateTeamBuild(PartyLoadout partyLoadout); + TeamBuildEntry CreateTeamBuild(PartyLoadout partyLoadout, string name); void ClearBuilds(); void SaveBuild(IBuildEntry buildEntry); void RemoveBuild(IBuildEntry buildEntry); diff --git a/Daybreak/Services/BuildTemplates/Models/DecodeCharStream.cs b/Daybreak.Shared/Services/BuildTemplates/Models/DecodeCharStream.cs similarity index 92% rename from Daybreak/Services/BuildTemplates/Models/DecodeCharStream.cs rename to Daybreak.Shared/Services/BuildTemplates/Models/DecodeCharStream.cs index 6b0ac330..b2c4b62c 100644 --- a/Daybreak/Services/BuildTemplates/Models/DecodeCharStream.cs +++ b/Daybreak.Shared/Services/BuildTemplates/Models/DecodeCharStream.cs @@ -1,6 +1,6 @@ using System; -namespace Daybreak.Services.BuildTemplates.Models; +namespace Daybreak.Shared.Services.BuildTemplates.Models; internal sealed class DecodeCharStream(string[] encodedValues) { diff --git a/Daybreak/Services/BuildTemplates/Models/EncodeCharStream.cs b/Daybreak.Shared/Services/BuildTemplates/Models/EncodeCharStream.cs similarity index 83% rename from Daybreak/Services/BuildTemplates/Models/EncodeCharStream.cs rename to Daybreak.Shared/Services/BuildTemplates/Models/EncodeCharStream.cs index 0800db4b..6304b33c 100644 --- a/Daybreak/Services/BuildTemplates/Models/EncodeCharStream.cs +++ b/Daybreak.Shared/Services/BuildTemplates/Models/EncodeCharStream.cs @@ -1,10 +1,10 @@ using System.Text; -namespace Daybreak.Services.BuildTemplates.Models; +namespace Daybreak.Shared.Services.BuildTemplates.Models; internal sealed class EncodeCharStream { - private readonly StringBuilder innerStringBuilder = new StringBuilder(); + private readonly StringBuilder innerStringBuilder = new(); public void Write(int value, int count) { diff --git a/Daybreak.Shared/Services/Logging/IDebugLogsWriter.cs b/Daybreak.Shared/Services/Logging/IConsoleLogsWriter.cs similarity index 60% rename from Daybreak.Shared/Services/Logging/IDebugLogsWriter.cs rename to Daybreak.Shared/Services/Logging/IConsoleLogsWriter.cs index 19c26d7a..b83cb47d 100644 --- a/Daybreak.Shared/Services/Logging/IDebugLogsWriter.cs +++ b/Daybreak.Shared/Services/Logging/IConsoleLogsWriter.cs @@ -2,6 +2,6 @@ namespace Daybreak.Shared.Services.Logging; -public interface IDebugLogsWriter : ILogsWriter +public interface IConsoleLogsWriter : ILogsWriter { } diff --git a/Daybreak.Shared/Services/MDns/IMDnsService.cs b/Daybreak.Shared/Services/MDns/IMDnsService.cs new file mode 100644 index 00000000..cdf6c0fc --- /dev/null +++ b/Daybreak.Shared/Services/MDns/IMDnsService.cs @@ -0,0 +1,12 @@ +using Daybreak.Shared.Models; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Daybreak.Shared.Services.MDns; +public interface IMDnsService +{ + Task?> FindLocalService(string service, string protocol = "_http._tcp", CancellationToken cancellationToken = default); + DnsRegistrationToken RegisterDomain(string service, ushort port, string subType, string protocol = "_http._tcp"); +} diff --git a/Daybreak.Shared/Services/MDns/MDnsService.cs b/Daybreak.Shared/Services/MDns/MDnsService.cs new file mode 100644 index 00000000..1e128be3 --- /dev/null +++ b/Daybreak.Shared/Services/MDns/MDnsService.cs @@ -0,0 +1,88 @@ +using Daybreak.Shared.Models; +using MeaMod.DNS.Model; +using MeaMod.DNS.Multicast; +using System.Collections.Generic; +using System; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +namespace Daybreak.Shared.Services.MDns; + +public sealed class MDnsService + : IMDnsService +{ + public DnsRegistrationToken RegisterDomain(string service, ushort port, string subType, string protocol = "_http._tcp") + { + var profile = new ServiceProfile(service, protocol, port); + profile.AddProperty("path", "/"); + profile.AddProperty("name", service); + profile.Subtypes.Add(subType); + return new DnsRegistrationToken(profile); + } + + public async Task?> FindLocalService(string service, string protocol = "_http._tcp", CancellationToken cancellationToken = default) + { + var message = new Message(); + message.Questions.Add(new Question { Name = $"{service}.{protocol}.local", Type = DnsType.ANY }); + using var mdns = new MulticastService(); + mdns.Start(); + + var resp = await mdns.ResolveAsync(message, cancellationToken); + + var srv = resp.Answers.OfType().FirstOrDefault(); + if (srv is null) + { + return default; + } + + var port = srv.Port; + var host = srv.Target; + var scheme = ParseSchemeFromServiceType(srv); + var ipv4 = resp.AdditionalRecords + .OfType() + .Where(a => a.Name == host) + .Select(a => a.Address) + .Cast(); + + var ipv6 = resp.AdditionalRecords + .OfType() + .Where(a => a.Name == host) + .Select(a => a.Address) + .Cast(); + + var uris = new List(); + foreach (var ip in ipv4.Concat(ipv6)) + { + var builder = new UriBuilder + { + Scheme = scheme, + Host = ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 + ? $"[{ip}]" + : ip.ToString(), + Port = port + }; + + uris.Add(builder.Uri); + } + + return uris; + } + + private static string ParseSchemeFromServiceType(SRVRecord srvRecord) + { + // split "instance._http._tcp.local." → ["instance", "_http", "_tcp", "local"] + var labels = srvRecord.Target.Labels; + if (labels.Count < 2) return "http"; + + return labels[^3] switch + { + "_https" => "https", + "_ws" => "ws", + "_wss" => "wss", + "_ftp" => "ftp", + _ => "http" + }; + } +} diff --git a/Daybreak.Shared/Services/Scanner/IGuildwarsMemoryCache.cs b/Daybreak.Shared/Services/Scanner/IGuildwarsMemoryCache.cs deleted file mode 100644 index 06510fa9..00000000 --- a/Daybreak.Shared/Services/Scanner/IGuildwarsMemoryCache.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Daybreak.Shared.Models.Guildwars; -using Daybreak.Shared.Models.LaunchConfigurations; -using System.Threading; -using System.Threading.Tasks; - -namespace Daybreak.Shared.Services.Scanner; - -public interface IGuildwarsMemoryCache -{ - Task EnsureInitialized(GuildWarsApplicationLaunchContext context, CancellationToken cancellationToken); - Task ReadLoginData(CancellationToken cancellationToken); - Task ReadWorldData(CancellationToken cancellationToken); - Task ReadSessionData(CancellationToken cancellationToken); - Task ReadUserData(CancellationToken cancellationToken); - Task ReadMainPlayerData(CancellationToken cancellationToken); - Task ReadTeamBuildData(CancellationToken cancellationToken); -} diff --git a/Daybreak.Shared/Services/Scanner/IGuildwarsMemoryReader.cs b/Daybreak.Shared/Services/Scanner/IGuildwarsMemoryReader.cs deleted file mode 100644 index 3f9a8e74..00000000 --- a/Daybreak.Shared/Services/Scanner/IGuildwarsMemoryReader.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Daybreak.Shared.Models.Guildwars; -using System.Threading; -using System.Threading.Tasks; - -namespace Daybreak.Shared.Services.Scanner; - -public interface IGuildwarsMemoryReader -{ - Task IsInitialized(uint processId, CancellationToken cancellationToken); - Task EnsureInitialized(uint processId, CancellationToken cancellationToken); - Task ReadLoginData(CancellationToken cancellationToken); - Task ReadWorldData(CancellationToken cancellationToken); - Task ReadUserData(CancellationToken cancellationToken); - Task ReadSessionData(CancellationToken cancellationToken); - Task ReadMainPlayerData(CancellationToken cancellationToken); - Task ReadTeamBuildData(CancellationToken cancellationToken); - void Stop(); -} diff --git a/Daybreak.Shared/Services/Scanner/IMemoryScanner.cs b/Daybreak.Shared/Services/Scanner/IMemoryScanner.cs deleted file mode 100644 index 4689deac..00000000 --- a/Daybreak.Shared/Services/Scanner/IMemoryScanner.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Daybreak.Shared.Models.Interop; -using System.Diagnostics; - -namespace Daybreak.Shared.Services.Scanner; - -public interface IMemoryScanner -{ - public uint ModuleStartAddress { get; } - public byte[]? Memory { get; } - public uint Size { get; } - public bool Scanning { get; } - public Process? Process { get; } - - void BeginScanner(Process process); - void EndScanner(); - T Read(GuildwarsPointer pointer, uint offset = 0); - T Read(uint address); - T[] ReadArray(uint address, uint size); - T[] ReadArray(GuildwarsArray guildwarsArray); - T[] ReadArray(GuildwarsPointerArray guildwarsPointerArray); - byte[]? ReadBytes(uint address, uint size); - string ReadWString(uint address, uint maxsize); - T ReadPtrChain(uint Base, uint finalPointerOffset = 0, params uint[] offsets); - uint ScanForAssertion(string? assertionFile, string? assertionMessage); - uint ScanForPtr(byte[] pattern, string? mask = default, bool readptr = false); -} diff --git a/Daybreak.Shared/Utils/NativeMethods.cs b/Daybreak.Shared/Utils/NativeMethods.cs index 1d7b166a..7438cdd2 100644 --- a/Daybreak.Shared/Utils/NativeMethods.cs +++ b/Daybreak.Shared/Utils/NativeMethods.cs @@ -7,6 +7,10 @@ namespace Daybreak.Shared.Utils; public static 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 static uint WM_KEYDOWN = 0x0100; public static uint SWP_SHOWWINDOW = 0x0040; public static nint HWND_TOPMOST = new(-1); @@ -364,6 +368,20 @@ public static class NativeMethods public const int WM_SYSCOMMAND = 0x112; + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool AllocConsole(); + [DllImport("kernel32.dll", SetLastError = true)] + public static extern nint GetConsoleWindow(); + [DllImport("kernel32.dll", SetLastError = true)] + public static extern nint GetStdHandle(int nStdHandle); + [DllImport("kernel32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool GetConsoleMode(nint hConsoleHandle, out uint lpMode); + [DllImport("kernel32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool SetConsoleMode(nint hConsoleHandle, uint dwMode); + [DllImport("Dbghelp.dll", SetLastError = true)] public static extern bool MiniDumpWriteDump(nint hProcess, int processId, SafeHandle hFile, MinidumpType dumpType, nint expParam, nint userStreamParam, nint callbackParam); [DllImport("kernel32.dll", SetLastError = true)] diff --git a/Daybreak.sln b/Daybreak.sln index 2d102f0d..ae3f5753 100644 --- a/Daybreak.sln +++ b/Daybreak.sln @@ -101,8 +101,8 @@ Global {38546C66-D14D-47B3-9F13-3CC439C271E6}.Debug|Any CPU.Build.0 = Debug|Any CPU {38546C66-D14D-47B3-9F13-3CC439C271E6}.Debug|x64.ActiveCfg = Debug|Any CPU {38546C66-D14D-47B3-9F13-3CC439C271E6}.Debug|x64.Build.0 = Debug|Any CPU - {38546C66-D14D-47B3-9F13-3CC439C271E6}.Debug|x86.ActiveCfg = Debug|Any CPU - {38546C66-D14D-47B3-9F13-3CC439C271E6}.Debug|x86.Build.0 = Debug|Any CPU + {38546C66-D14D-47B3-9F13-3CC439C271E6}.Debug|x86.ActiveCfg = Debug|x86 + {38546C66-D14D-47B3-9F13-3CC439C271E6}.Debug|x86.Build.0 = Debug|x86 {38546C66-D14D-47B3-9F13-3CC439C271E6}.Release|Any CPU.ActiveCfg = Release|Any CPU {38546C66-D14D-47B3-9F13-3CC439C271E6}.Release|Any CPU.Build.0 = Release|Any CPU {38546C66-D14D-47B3-9F13-3CC439C271E6}.Release|x64.ActiveCfg = Release|Any CPU diff --git a/Daybreak/Configuration/Options/MemoryReaderOptions.cs b/Daybreak/Configuration/Options/MemoryReaderOptions.cs deleted file mode 100644 index 4fc35ec6..00000000 --- a/Daybreak/Configuration/Options/MemoryReaderOptions.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Daybreak.Attributes; -using Newtonsoft.Json; - -namespace Daybreak.Configuration.Options; - -[OptionsName(Name = "Memory Reader")] -internal sealed class MemoryReaderOptions -{ - [JsonProperty(nameof(MemoryReaderFrequency))] - [OptionRange(MinValue = 0, MaxValue = 1000)] - [OptionName(Name = "Memory Reader Frequency", Description = "Measured in ms. Sets how often should the launcher polls information from the game. Actual frequency is capped by the memory reading speed")] - public double MemoryReaderFrequency { get; set; } = 0; - - [JsonProperty(nameof(AllowWhispers))] - [OptionName(Name = "Allow Whispers", Description = "If enabled, Daybreak will be allowed to send whispers to the player. Mainly used in notifications")] - public bool AllowWhispers { get; set; } = true; -} diff --git a/Daybreak/Configuration/ProjectConfiguration.cs b/Daybreak/Configuration/ProjectConfiguration.cs index e7b72c62..c52dd351 100644 --- a/Daybreak/Configuration/ProjectConfiguration.cs +++ b/Daybreak/Configuration/ProjectConfiguration.cs @@ -23,7 +23,6 @@ using Microsoft.Extensions.DependencyInjection; using Daybreak.Services.Navigation; using Daybreak.Services.Onboarding; using Daybreak.Services.Menu; -using Daybreak.Services.Scanner; using Daybreak.Services.Experience; using Daybreak.Services.Metrics; using Daybreak.Services.Monitoring; @@ -88,7 +87,6 @@ using Daybreak.Shared.Services.Screens; using Daybreak.Shared.Services.Logging; using Daybreak.Shared.Services.Notifications; using Daybreak.Shared.Services.TradeChat; -using Daybreak.Shared.Services.Scanner; using Daybreak.Shared.Services.Themes; using Daybreak.Shared.Services.Shortcuts; using Daybreak.Shared.Services.Guildwars; @@ -132,6 +130,7 @@ using Daybreak.Shared.Models; using Daybreak.Shared.Services.DXVK; using Daybreak.Services.DXVK; using Daybreak.Views.Onboarding.DXVK; +using Daybreak.Shared.Services.MDns; namespace Daybreak.Configuration; @@ -163,7 +162,7 @@ public class ProjectConfiguration : PluginConfigurationBase services.AddScoped(sp => new NotificationsDbContext(sp.GetRequiredService())); services.AddScoped(sp => new TradeMessagesDbContext(sp.GetRequiredService())); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => { @@ -173,7 +172,7 @@ public class ProjectConfiguration : PluginConfigurationBase }); services.AddSingleton(sp => new CompositeLogsWriter( sp.GetService()!, - sp.GetService()!, + sp.GetService()!, sp.GetService()!)); services.AddScoped((sp) => new ScopeMetadata(new CorrelationVector())); @@ -210,16 +209,15 @@ public class ProjectConfiguration : PluginConfigurationBase services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton, WindowEventsHook>(); + services.AddSingleton(); + services.AddSingleton(); services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService().Cast()); services.AddScoped(); @@ -369,7 +367,6 @@ public class ProjectConfiguration : PluginConfigurationBase optionsProducer.RegisterOptions(); optionsProducer.RegisterOptions(); - optionsProducer.RegisterOptions(); optionsProducer.RegisterOptions(); optionsProducer.RegisterOptions(); @@ -412,6 +409,7 @@ public class ProjectConfiguration : PluginConfigurationBase public override void RegisterMods(IModsManager modsManager) { modsManager.RegisterMod(); + modsManager.RegisterMod(); modsManager.RegisterMod(); modsManager.RegisterMod(); modsManager.RegisterMod(); @@ -419,7 +417,6 @@ public class ProjectConfiguration : PluginConfigurationBase modsManager.RegisterMod(); modsManager.RegisterMod(); modsManager.RegisterMod(singleton: true); - modsManager.RegisterMod(); } public override void RegisterBrowserExtensions(IBrowserExtensionsProducer browserExtensionsProducer) @@ -536,6 +533,10 @@ public class ProjectConfiguration : PluginConfigurationBase .RegisterHttpClient() .WithMessageHandler(this.SetupLoggingAndMetrics) .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) + .Build() + .RegisterHttpClient() + .WithMessageHandler(this.SetupLoggingAndMetrics) + .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) .Build(); } } diff --git a/Daybreak/Controls/DropDownButton.cs b/Daybreak/Controls/DropDownButton.cs new file mode 100644 index 00000000..a8832254 --- /dev/null +++ b/Daybreak/Controls/DropDownButton.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Extensions; +using System.Windows.Media; +using Daybreak.Controls.Buttons; + +namespace Daybreak.Controls; + +/// +/// A drop‑down button that shows the current selection and lets the user pick another item. +/// +[TemplatePart(Name = PartMainButton, Type = typeof(HighlightButton))] +[TemplatePart(Name = PartArrowButton, Type = typeof(HighlightButton))] +public partial class DropDownButton : Control +{ + private const string PartMainButton = "PART_MainButton"; + private const string PartArrowButton = "PART_ArrowButton"; + private const string PartDropDown = "PART_DropDown"; + + [GenerateDependencyProperty] + private DataTemplate itemTemplate = default!; + + [GenerateDependencyProperty] + private object selectedItem = default!; + + [GenerateDependencyProperty] + private IEnumerable items = default!; + + [GenerateDependencyProperty(InitialValue = true)] + private bool clickEnabled = true; + + [GenerateDependencyProperty] + private Brush dropDownBackground = default!; + + [GenerateDependencyProperty] + private Brush disableBrush = default!; + + public event EventHandler? Clicked; + + public event EventHandler? SelectionChanged; + + private HighlightButton? mainButton; + private HighlightButton? arrowButton; + + static DropDownButton() + { + // connect to default style in Themes/Generic.xaml + DefaultStyleKeyProperty.OverrideMetadata( + typeof(DropDownButton), + new FrameworkPropertyMetadata(typeof(DropDownButton))); + } + + public DropDownButton() + { + // ignore right‑clicks so the default context‑menu logic does not interfere + this.PreviewMouseRightButtonDown += (_, e) => e.Handled = true; + this.PreviewMouseRightButtonUp += (_, e) => e.Handled = true; + } + + public override void OnApplyTemplate() + { + base.OnApplyTemplate(); + if (this.mainButton is not null) + { + this.mainButton.Clicked -= this.MainButton_Clicked; + } + + if (this.arrowButton is not null) + { + this.arrowButton.Clicked -= this.ArrowButton_Clicked; + } + + if (this.ContextMenu is not null) + { + this.ContextMenu.Opened -= this.ContextMenu_Opened; + } + + this.mainButton = this.GetTemplateChild(PartMainButton) as HighlightButton; + this.arrowButton = this.GetTemplateChild(PartArrowButton) as HighlightButton; + + if (this.mainButton is not null) + { + this.mainButton.Clicked += this.MainButton_Clicked; + } + + if (this.arrowButton is not null) + { + this.arrowButton.Clicked += this.ArrowButton_Clicked; + } + + if (this.ContextMenu is not null) + { + this.ContextMenu.Opened += this.ContextMenu_Opened; + } + } + + private void MainButton_Clicked(object? sender, object e) + { + this.Clicked?.Invoke(this, this.SelectedItem); + } + + private void ArrowButton_Clicked(object? sender, object e) + { + if (this.ContextMenu is not null) + { + this.ContextMenu.PlacementTarget = this; + this.ContextMenu.IsOpen = true; + } + } + + // When the context‑menu opens, locate the DropDownButtonContextMenu inside it and hook its CLR event. + private void ContextMenu_Opened(object? sender, RoutedEventArgs e) + { + if (sender is not ContextMenu menu) + return; + + if (menu.Template.FindName(PartDropDown, menu) is DropDownButtonContextMenu dropDown) + { + dropDown.ItemClicked -= this.DropDownButtonContextMenu_ItemClicked; // avoid duplicates + dropDown.ItemClicked += this.DropDownButtonContextMenu_ItemClicked; + } + } + + private void DropDownButtonContextMenu_ItemClicked(object? _, object e) + { + this.SelectedItem = e; + if (this.ContextMenu is not null) + { + this.ContextMenu.IsOpen = false; + } + + this.SelectionChanged?.Invoke(this, e); + } +} diff --git a/Daybreak/Controls/DropDownButton.xaml b/Daybreak/Controls/DropDownButton.xaml deleted file mode 100644 index c154f852..00000000 --- a/Daybreak/Controls/DropDownButton.xaml +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Daybreak/Controls/DropDownButton.xaml.cs b/Daybreak/Controls/DropDownButton.xaml.cs deleted file mode 100644 index 2e05c058..00000000 --- a/Daybreak/Controls/DropDownButton.xaml.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using System.Collections; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Extensions; -using System.Windows.Input; - -namespace Daybreak.Controls; -/// -/// Interaction logic for DropdownButton.xaml -/// -public partial class DropDownButton : UserControl -{ - [GenerateDependencyProperty] - private DataTemplate itemTemplate = default!; - - [GenerateDependencyProperty] - private object selectedItem = default!; - - [GenerateDependencyProperty] - private IEnumerable items = default!; - - [GenerateDependencyProperty(InitialValue = true)] - private bool clickEnabled = true; - - public event EventHandler Clicked = default!; - public event EventHandler SelectionChanged = default!; - - public DropDownButton() - { - this.InitializeComponent(); - } - - private void MainButton_Clicked(object sender, EventArgs e) - { - this.Clicked?.Invoke(this, this.SelectedItem); - } - - private void ArrowButton_Clicked(object sender, EventArgs e) - { - var contextMenu = this.ContextMenu; - contextMenu.PlacementTarget = this; - contextMenu.IsOpen = true; - } - - private void DropDownButtonContextMenu_ItemClicked(object _, object e) - { - this.SelectedItem = e; - this.ContextMenu.IsOpen = false; - this.SelectionChanged?.Invoke(this, e); - } - - private void IgnoreRightMouseButton(object sender, MouseButtonEventArgs e) - { - if (e.ChangedButton is MouseButton.Right) - { - e.Handled = true; - } - } -} diff --git a/Daybreak/Controls/DropDownButtonContextMenu.xaml b/Daybreak/Controls/DropDownButtonContextMenu.xaml index ff82cbac..cf09b457 100644 --- a/Daybreak/Controls/DropDownButtonContextMenu.xaml +++ b/Daybreak/Controls/DropDownButtonContextMenu.xaml @@ -18,8 +18,8 @@ Tag="{Binding Mode=OneWay}" Clicked="HighlightButton_Clicked"> - + diff --git a/Daybreak/Controls/FocusViewComponents/CharacterComponent.xaml b/Daybreak/Controls/FocusViewComponents/CharacterComponent.xaml new file mode 100644 index 00000000..e34ddc0a --- /dev/null +++ b/Daybreak/Controls/FocusViewComponents/CharacterComponent.xaml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + diff --git a/Daybreak/Controls/FocusViewComponents/CharacterComponent.xaml.cs b/Daybreak/Controls/FocusViewComponents/CharacterComponent.xaml.cs new file mode 100644 index 00000000..14103ed2 --- /dev/null +++ b/Daybreak/Controls/FocusViewComponents/CharacterComponent.xaml.cs @@ -0,0 +1,154 @@ +using Daybreak.Configuration.Options; +using Daybreak.Shared; +using Daybreak.Shared.Models.FocusView; +using Daybreak.Shared.Services.Experience; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.ObjectModel; +using System.Configuration; +using System.Core.Extensions; +using System.Linq; +using System.Windows.Controls; +using System.Windows.Extensions; +using System.Windows.Input; + +namespace Daybreak.Controls.FocusViewComponents; + +public partial class CharacterComponent : UserControl +{ + private readonly IExperienceCalculator experienceCalculator; + private readonly ILiveUpdateableOptions liveOptions; + + public event EventHandler? NavigateToClicked; + public event EventHandler? SwitchCharacterClicked; + + public ObservableCollection Characters { get; } = []; + + [GenerateDependencyProperty] + private CharacterSelectComponentEntry currentCharacter = default!; + + [GenerateDependencyProperty] + private double currentExperienceInLevel; + [GenerateDependencyProperty] + private double nextLevelExperienceThreshold; + [GenerateDependencyProperty] + private string experienceBarText = string.Empty; + + private uint experienceCache = 0; + + public CharacterComponent() + :this(Global.GlobalServiceProvider.GetRequiredService(), + Global.GlobalServiceProvider.GetRequiredService>()) + { + } + + public CharacterComponent( + IExperienceCalculator experienceCalculator, + ILiveUpdateableOptions liveOptions) + { + this.experienceCalculator = experienceCalculator.ThrowIfNull(); + this.liveOptions = liveOptions.ThrowIfNull(); + this.InitializeComponent(); + } + + private void UserControl_DataContextChanged(object _, System.Windows.DependencyPropertyChangedEventArgs __) + { + if (this.DataContext is not CharacterComponentContext context) + { + return; + } + + if (this.CurrentCharacter != context.CurrentCharacter) + { + this.CurrentCharacter = context.CurrentCharacter; + } + + var charsToAdd = context.Characters + .Where(c => !this.Characters.Contains(c)) + .ToList(); + var charsToRemove = this.Characters + .Where(c => !context.Characters.Contains(c)) + .ToList(); + + foreach (var character in charsToAdd) + { + this.Characters.Add(character); + } + + foreach (var character in charsToRemove) + { + this.Characters.Remove(character); + } + + if (this.experienceCache != context.CurrentExperience) + { + this.experienceCache = context.CurrentExperience; + this.CurrentExperienceInLevel = this.experienceCalculator.GetExperienceForCurrentLevel(context.CurrentExperience); + this.NextLevelExperienceThreshold = this.experienceCalculator.GetNextExperienceThreshold(context.CurrentExperience); + this.UpdateExperienceText(); + } + } + + private void DropDownButton_SelectionChanged(object _, object newEntry) + { + if (newEntry is not CharacterSelectComponentEntry newCharacter) + { + return; + } + + this.SwitchCharacterClicked?.Invoke(this, newCharacter); + } + + private void ExperienceBar_MouseLeftButtonDown(object _, MouseButtonEventArgs e) + { + switch (this.liveOptions.Value.ExperienceDisplay) + { + case Configuration.FocusView.ExperienceDisplay.CurrentLevelCurrentAndCurrentLevelMax: + this.liveOptions.Value.ExperienceDisplay = Configuration.FocusView.ExperienceDisplay.TotalCurretAndTotalMax; + break; + case Configuration.FocusView.ExperienceDisplay.TotalCurretAndTotalMax: + this.liveOptions.Value.ExperienceDisplay = Configuration.FocusView.ExperienceDisplay.RemainingUntilNextLevel; + break; + case Configuration.FocusView.ExperienceDisplay.RemainingUntilNextLevel: + this.liveOptions.Value.ExperienceDisplay = Configuration.FocusView.ExperienceDisplay.Percentage; + break; + case Configuration.FocusView.ExperienceDisplay.Percentage: + this.liveOptions.Value.ExperienceDisplay = Configuration.FocusView.ExperienceDisplay.CurrentLevelCurrentAndCurrentLevelMax; + break; + } + + this.liveOptions.UpdateOption(); + this.UpdateExperienceText(); + } + + private void UpdateExperienceText() + { + if (this.DataContext is not CharacterComponentContext context) + { + return; + } + + switch (this.liveOptions.Value.ExperienceDisplay) + { + case Configuration.FocusView.ExperienceDisplay.CurrentLevelCurrentAndCurrentLevelMax: + var currentExperienceInLevel = this.experienceCalculator.GetExperienceForCurrentLevel(context.CurrentExperience); + var nextLevelExperienceThreshold = this.experienceCalculator.GetNextExperienceThreshold(context.CurrentExperience); + this.ExperienceBarText = $"{(int)currentExperienceInLevel} / {(int)nextLevelExperienceThreshold} XP"; + break; + case Configuration.FocusView.ExperienceDisplay.TotalCurretAndTotalMax: + var currentTotalExperience = context.CurrentExperience; + var requiredTotalExperience = this.experienceCalculator.GetTotalExperienceForNextLevel(currentTotalExperience); + this.ExperienceBarText = $"{(int)currentTotalExperience} / {(int)requiredTotalExperience} XP"; + break; + case Configuration.FocusView.ExperienceDisplay.RemainingUntilNextLevel: + var remainingExperience = this.experienceCalculator.GetRemainingExperienceForNextLevel(context.CurrentExperience); + this.ExperienceBarText = $"Remaining {(int)remainingExperience} XP"; + break; + case Configuration.FocusView.ExperienceDisplay.Percentage: + var currentExperienceInLevel2 = this.experienceCalculator.GetExperienceForCurrentLevel(context.CurrentExperience); + var nextLevelExperienceThreshold2 = this.experienceCalculator.GetNextExperienceThreshold(context.CurrentExperience); + this.ExperienceBarText = $"{(int)((double)currentExperienceInLevel2 / (double)nextLevelExperienceThreshold2 * 100)}% XP"; + break; + } + } +} diff --git a/Daybreak/Controls/FocusViewComponents/CurrentMapComponent.xaml b/Daybreak/Controls/FocusViewComponents/CurrentMapComponent.xaml index 5bc44e05..cec47109 100644 --- a/Daybreak/Controls/FocusViewComponents/CurrentMapComponent.xaml +++ b/Daybreak/Controls/FocusViewComponents/CurrentMapComponent.xaml @@ -10,24 +10,29 @@ BorderThickness="1" mc:Ignorable="d" d:DesignHeight="450" d:DesignWidth="800"> - + + + + + - + Margin="10, 0, 10, 0" + VerticalAlignment="Center" + HorizontalAlignment="Left" + Text="Current Map" /> - + diff --git a/Daybreak/Controls/FocusViewComponents/CurrentQuestComponent.xaml b/Daybreak/Controls/FocusViewComponents/CurrentQuestComponent.xaml deleted file mode 100644 index 52a8d61d..00000000 --- a/Daybreak/Controls/FocusViewComponents/CurrentQuestComponent.xaml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - diff --git a/Daybreak/Controls/FocusViewComponents/CurrentQuestComponent.xaml.cs b/Daybreak/Controls/FocusViewComponents/CurrentQuestComponent.xaml.cs deleted file mode 100644 index f1fe20d4..00000000 --- a/Daybreak/Controls/FocusViewComponents/CurrentQuestComponent.xaml.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Daybreak.Shared.Models.Guildwars; -using System; -using System.Windows.Controls; - -namespace Daybreak.Controls.FocusViewComponents; -/// -/// Interaction logic for CurrentQuestComponent.xaml -/// -public partial class CurrentQuestComponent : UserControl -{ - public event EventHandler? NavigateToClicked; - - public CurrentQuestComponent() - { - this.InitializeComponent(); - } - - private void CurrentQuest_MouseLeftButtonDown(object _, System.Windows.Input.MouseButtonEventArgs e) - { - if (this.DataContext is not Quest quest) - { - return; - } - - if (quest.WikiUrl is not string url) - { - return; - } - - this.NavigateToClicked?.Invoke(this, url); - } -} diff --git a/Daybreak/Controls/FocusViewComponents/MainPlayerInformationComponent.xaml b/Daybreak/Controls/FocusViewComponents/MainPlayerInformationComponent.xaml deleted file mode 100644 index 4a907392..00000000 --- a/Daybreak/Controls/FocusViewComponents/MainPlayerInformationComponent.xaml +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/Daybreak/Controls/FocusViewComponents/MainPlayerInformationComponent.xaml.cs b/Daybreak/Controls/FocusViewComponents/MainPlayerInformationComponent.xaml.cs deleted file mode 100644 index 240aaa90..00000000 --- a/Daybreak/Controls/FocusViewComponents/MainPlayerInformationComponent.xaml.cs +++ /dev/null @@ -1,119 +0,0 @@ -using Daybreak.Launch; -using Daybreak.Shared; -using Daybreak.Shared.Models.Guildwars; -using Daybreak.Shared.Services.BuildTemplates; -using Daybreak.Shared.Services.Navigation; -using Daybreak.Shared.Services.Scanner; -using Daybreak.Views; -using Microsoft.Extensions.DependencyInjection; -using System; -using System.Core.Extensions; -using System.Threading; -using System.Windows.Controls; - -namespace Daybreak.Controls.FocusViewComponents; -/// -/// Interaction logic for MainPlayerInformationComponent.xaml -/// -public partial class MainPlayerInformationComponent : UserControl -{ - private readonly IGuildwarsMemoryCache guildwarsMemoryCache; - private readonly IBuildTemplateManager buildTemplateManager; - private readonly IViewManager viewManager; - - public event EventHandler? NavigateToClicked; - - public MainPlayerInformationComponent() - : this( - Global.GlobalServiceProvider.GetRequiredService(), - Global.GlobalServiceProvider.GetRequiredService(), - Global.GlobalServiceProvider.GetRequiredService()) - { - } - - public MainPlayerInformationComponent( - IGuildwarsMemoryCache guildwarsMemoryCache, - IBuildTemplateManager buildTemplateManager, - IViewManager viewManager) - { - this.guildwarsMemoryCache = guildwarsMemoryCache.ThrowIfNull(); - this.buildTemplateManager = buildTemplateManager.ThrowIfNull(); - this.viewManager = viewManager.ThrowIfNull(); - this.InitializeComponent(); - } - - private void MetaBuilds_MouseLeftButtonDown(object _, System.Windows.Input.MouseButtonEventArgs e) - { - if (this.DataContext is not MainPlayerInformation mainPlayer) - { - return; - } - - if (mainPlayer.PrimaryProfession is not null && - mainPlayer.PrimaryProfession != Profession.None && - mainPlayer.PrimaryProfession.BuildsUrl is string url) - { - this.NavigateToClicked?.Invoke(this, url); - } - } - - private void PrimaryProfession_MouseLeftButtonDown(object _, System.Windows.Input.MouseButtonEventArgs e) - { - if (this.DataContext is not MainPlayerInformation mainPlayer) - { - return; - } - - if (mainPlayer.PrimaryProfession is not null && - mainPlayer.PrimaryProfession != Profession.None && - mainPlayer.PrimaryProfession.WikiUrl is string url) - { - this.NavigateToClicked?.Invoke(this, url); - } - } - - private void SecondaryProfession_MouseLeftButtonDown(object _, System.Windows.Input.MouseButtonEventArgs e) - { - if (this.DataContext is not MainPlayerInformation mainPlayer) - { - return; - } - - if (mainPlayer.SecondaryProfession is not null && - mainPlayer.SecondaryProfession != Profession.None && - mainPlayer.SecondaryProfession.WikiUrl is string url) - { - this.NavigateToClicked?.Invoke(this, url); - } - } - - private void EditBuild_MouseLeftButtonDown(object _, System.Windows.Input.MouseButtonEventArgs e) - { - if (this.DataContext is not MainPlayerInformation mainPlayer) - { - return; - } - - if (mainPlayer.CurrentBuild is Build build) - { - var buildEntry = this.buildTemplateManager.CreateSingleBuild(); - buildEntry.Primary = build.Primary; - buildEntry.Secondary = build.Secondary; - buildEntry.Attributes = build.Attributes; - buildEntry.Skills = build.Skills; - this.viewManager.ShowView(buildEntry); - } - } - - private async void EditTeamBuild_MouseLeftButtonDown(object _, System.Windows.Input.MouseButtonEventArgs e) - { - var teamBuildData = await this.guildwarsMemoryCache.ReadTeamBuildData(CancellationToken.None); - if (teamBuildData is null) - { - return; - } - - var teamBuild = this.buildTemplateManager.CreateTeamBuild(teamBuildData); - this.viewManager.ShowView(teamBuild); - } -} diff --git a/Daybreak/Controls/FocusViewComponents/PlayerResourcesComponent.xaml b/Daybreak/Controls/FocusViewComponents/PlayerResourcesComponent.xaml index 8992517a..7ff9c8e3 100644 --- a/Daybreak/Controls/FocusViewComponents/PlayerResourcesComponent.xaml +++ b/Daybreak/Controls/FocusViewComponents/PlayerResourcesComponent.xaml @@ -6,297 +6,86 @@ xmlns:local="clr-namespace:Daybreak.Controls.FocusViewComponents" xmlns:converters="clr-namespace:Daybreak.Shared.Converters;assembly=Daybreak.Shared" xmlns:controls="clr-namespace:Daybreak.Controls" + Background="{DynamicResource Daybreak.Brushes.Background}" + BorderBrush="{DynamicResource MahApps.Brushes.ThemeForeground}" + BorderThickness="1" x:Name="_this" mc:Ignorable="d" d:DesignHeight="450" d:DesignWidth="800"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Daybreak/Controls/FocusViewComponents/PlayerResourcesComponent.xaml.cs b/Daybreak/Controls/FocusViewComponents/PlayerResourcesComponent.xaml.cs index 8af8fb4c..fc5320d8 100644 --- a/Daybreak/Controls/FocusViewComponents/PlayerResourcesComponent.xaml.cs +++ b/Daybreak/Controls/FocusViewComponents/PlayerResourcesComponent.xaml.cs @@ -1,8 +1,6 @@ using Daybreak.Configuration.Options; -using Daybreak.Launch; using Daybreak.Shared; -using Daybreak.Shared.Models; -using Daybreak.Shared.Models.Guildwars; +using Daybreak.Shared.Models.FocusView; using Daybreak.Shared.Services.Experience; using Microsoft.Extensions.DependencyInjection; using System.Configuration; @@ -21,12 +19,6 @@ public partial class PlayerResourcesComponent : UserControl private readonly IExperienceCalculator experienceCalculator; private readonly ILiveUpdateableOptions liveOptions; - [GenerateDependencyProperty] - private double currentExperienceInLevel; - [GenerateDependencyProperty] - private double nextLevelExperienceThreshold; - [GenerateDependencyProperty] - private string experienceBarText = string.Empty; [GenerateDependencyProperty] private string luxonBarText = string.Empty; [GenerateDependencyProperty] @@ -36,27 +28,6 @@ public partial class PlayerResourcesComponent : UserControl [GenerateDependencyProperty] private string balthazarBarText = string.Empty; - [GenerateDependencyProperty] - private int pointsInCurrentRank; - [GenerateDependencyProperty] - private int pointsForNextRank; - [GenerateDependencyProperty] - private bool titleActive; - [GenerateDependencyProperty] - private string titleText = string.Empty; - - [GenerateDependencyProperty] - private string healthBarText = string.Empty; - [GenerateDependencyProperty] - private string energyBarText = string.Empty; - - [GenerateDependencyProperty] - private bool vanquishing; - [GenerateDependencyProperty] - private int totalFoes; - [GenerateDependencyProperty] - private string vanquishingText = string.Empty; - public PlayerResourcesComponent() : this( Global.GlobalServiceProvider.GetRequiredService(), @@ -82,27 +53,7 @@ public partial class PlayerResourcesComponent : UserControl } } - private void ExperienceBar_MouseLeftButtonDown(object _, MouseButtonEventArgs e) - { - switch (this.liveOptions.Value.ExperienceDisplay) - { - case Configuration.FocusView.ExperienceDisplay.CurrentLevelCurrentAndCurrentLevelMax: - this.liveOptions.Value.ExperienceDisplay = Configuration.FocusView.ExperienceDisplay.TotalCurretAndTotalMax; - break; - case Configuration.FocusView.ExperienceDisplay.TotalCurretAndTotalMax: - this.liveOptions.Value.ExperienceDisplay = Configuration.FocusView.ExperienceDisplay.RemainingUntilNextLevel; - break; - case Configuration.FocusView.ExperienceDisplay.RemainingUntilNextLevel: - this.liveOptions.Value.ExperienceDisplay = Configuration.FocusView.ExperienceDisplay.Percentage; - break; - case Configuration.FocusView.ExperienceDisplay.Percentage: - this.liveOptions.Value.ExperienceDisplay = Configuration.FocusView.ExperienceDisplay.CurrentLevelCurrentAndCurrentLevelMax; - break; - } - - this.liveOptions.UpdateOption(); - this.UpdateExperienceText(); - } + private void LuxonBar_MouseLeftButtonDown(object _, MouseButtonEventArgs e) { @@ -180,145 +131,22 @@ public partial class PlayerResourcesComponent : UserControl this.UpdateBalthazarText(); } - private void VanquishingBar_MouseLeftButtonDown(object _, MouseButtonEventArgs e) - { - switch (this.liveOptions.Value.VanquishingDisplay) - { - case Configuration.FocusView.PointsDisplay.CurrentAndMax: - this.liveOptions.Value.VanquishingDisplay = Configuration.FocusView.PointsDisplay.Remaining; - break; - case Configuration.FocusView.PointsDisplay.Remaining: - this.liveOptions.Value.VanquishingDisplay = Configuration.FocusView.PointsDisplay.Percentage; - break; - case Configuration.FocusView.PointsDisplay.Percentage: - this.liveOptions.Value.VanquishingDisplay = Configuration.FocusView.PointsDisplay.CurrentAndMax; - break; - } - - this.liveOptions.UpdateOption(); - this.UpdateVanquishingText(); - } - - private void HealthBar_MouseLeftButtonDown(object _, MouseButtonEventArgs e) - { - switch (this.liveOptions.Value.HealthDisplay) - { - case Configuration.FocusView.PointsDisplay.CurrentAndMax: - this.liveOptions.Value.HealthDisplay = Configuration.FocusView.PointsDisplay.Remaining; - break; - case Configuration.FocusView.PointsDisplay.Remaining: - this.liveOptions.Value.HealthDisplay = Configuration.FocusView.PointsDisplay.Percentage; - break; - case Configuration.FocusView.PointsDisplay.Percentage: - this.liveOptions.Value.HealthDisplay = Configuration.FocusView.PointsDisplay.CurrentAndMax; - break; - } - - this.liveOptions.UpdateOption(); - this.UpdateHealthText(); - } - - private void EnergyBar_MouseLeftButtonDown(object _, MouseButtonEventArgs e) - { - switch (this.liveOptions.Value.EnergyDisplay) - { - case Configuration.FocusView.PointsDisplay.CurrentAndMax: - this.liveOptions.Value.EnergyDisplay = Configuration.FocusView.PointsDisplay.Remaining; - break; - case Configuration.FocusView.PointsDisplay.Remaining: - this.liveOptions.Value.EnergyDisplay = Configuration.FocusView.PointsDisplay.Percentage; - break; - case Configuration.FocusView.PointsDisplay.Percentage: - this.liveOptions.Value.EnergyDisplay = Configuration.FocusView.PointsDisplay.CurrentAndMax; - break; - } - - this.liveOptions.UpdateOption(); - this.UpdateEnergyText(); - } - private void UpdateGameData() { - if (this.DataContext is not MainPlayerResourceContext mainPlayerResourceContext || - mainPlayerResourceContext.User?.User is null || - mainPlayerResourceContext.Session?.Session is null || - mainPlayerResourceContext.Player?.PlayerInformation is null) + if (this.DataContext is not PlayerResourcesComponentContext) { return; } - this.CurrentExperienceInLevel = this.experienceCalculator.GetExperienceForCurrentLevel(mainPlayerResourceContext.Player.PlayerInformation.Experience); - this.NextLevelExperienceThreshold = this.experienceCalculator.GetNextExperienceThreshold(mainPlayerResourceContext.Player.PlayerInformation.Experience); - this.TotalFoes = (int)(mainPlayerResourceContext.Session.Session.FoesKilled + mainPlayerResourceContext.Session.Session.FoesToKill); - this.Vanquishing = mainPlayerResourceContext.Session.Session.FoesToKill + mainPlayerResourceContext.Session.Session.FoesKilled > 0U; - this.TitleActive = mainPlayerResourceContext.Player.PlayerInformation.TitleInformation is not null && mainPlayerResourceContext.Player.PlayerInformation.TitleInformation.IsValid; - - if (mainPlayerResourceContext.Player.PlayerInformation.TitleInformation is TitleInformation titleInformation && titleInformation.IsValid) - { - if (titleInformation.MaxTierNumber == titleInformation.TierNumber) - { - this.PointsInCurrentRank = (int)titleInformation.CurrentPoints!; - this.PointsForNextRank = (int)titleInformation.CurrentPoints!; - } - else if (titleInformation.IsPercentage is false) - { - this.PointsInCurrentRank = (int)((uint)titleInformation.CurrentPoints! - (uint)titleInformation.PointsForCurrentRank!); - this.PointsForNextRank = (int)((uint)titleInformation.PointsForNextRank! - (uint)titleInformation.PointsForCurrentRank!); - } - else - { - this.PointsInCurrentRank = (int)(uint)titleInformation.CurrentPoints!; - this.PointsForNextRank = (int)(uint)titleInformation.PointsForNextRank!; - } - } - - this.UpdateExperienceText(); this.UpdateLuxonText(); this.UpdateKurzickText(); this.UpdateImperialText(); this.UpdateBalthazarText(); - this.UpdateVanquishingText(); - this.UpdateHealthText(); - this.UpdateEnergyText(); - this.UpdateTitleText(); - } - - private void UpdateExperienceText() - { - if (this.DataContext is not MainPlayerResourceContext mainPlayerResourceContext || - mainPlayerResourceContext.Player?.PlayerInformation is null) - { - return; - } - - switch (this.liveOptions.Value.ExperienceDisplay) - { - case Configuration.FocusView.ExperienceDisplay.CurrentLevelCurrentAndCurrentLevelMax: - var currentExperienceInLevel = this.experienceCalculator.GetExperienceForCurrentLevel(mainPlayerResourceContext.Player.PlayerInformation.Experience); - var nextLevelExperienceThreshold = this.experienceCalculator.GetNextExperienceThreshold(mainPlayerResourceContext.Player.PlayerInformation.Experience); - this.ExperienceBarText = $"{(int)currentExperienceInLevel} / {(int)nextLevelExperienceThreshold} XP"; - break; - case Configuration.FocusView.ExperienceDisplay.TotalCurretAndTotalMax: - var currentTotalExperience = mainPlayerResourceContext.Player.PlayerInformation.Experience; - var requiredTotalExperience = this.experienceCalculator.GetTotalExperienceForNextLevel(currentTotalExperience); - this.ExperienceBarText = $"{(int)currentTotalExperience} / {(int)requiredTotalExperience} XP"; - break; - case Configuration.FocusView.ExperienceDisplay.RemainingUntilNextLevel: - var remainingExperience = this.experienceCalculator.GetRemainingExperienceForNextLevel(mainPlayerResourceContext.Player.PlayerInformation.Experience); - this.ExperienceBarText = $"Remaining {(int)remainingExperience} XP"; - break; - case Configuration.FocusView.ExperienceDisplay.Percentage: - var currentExperienceInLevel2 = this.experienceCalculator.GetExperienceForCurrentLevel(mainPlayerResourceContext.Player.PlayerInformation.Experience); - var nextLevelExperienceThreshold2 = this.experienceCalculator.GetNextExperienceThreshold(mainPlayerResourceContext.Player.PlayerInformation.Experience); - this.ExperienceBarText = $"{(int)((double)currentExperienceInLevel2 / (double)nextLevelExperienceThreshold2 * 100)}% XP"; - break; - } } private void UpdateLuxonText() { - if (this.DataContext is not MainPlayerResourceContext mainPlayerResourceContext || - mainPlayerResourceContext.User?.User is null) + if (this.DataContext is not PlayerResourcesComponentContext mainPlayerResourceContext) { return; } @@ -326,21 +154,20 @@ public partial class PlayerResourcesComponent : UserControl switch (this.liveOptions.Value.LuxonPointsDisplay) { case Configuration.FocusView.PointsDisplay.CurrentAndMax: - this.LuxonBarText = $"{mainPlayerResourceContext.User.User.CurrentLuxonPoints} / {mainPlayerResourceContext.User.User.MaxLuxonPoints} Luxon Points"; + this.LuxonBarText = $"{mainPlayerResourceContext.CurrentLuxon} / {mainPlayerResourceContext.MaxLuxon} Luxon Points"; break; case Configuration.FocusView.PointsDisplay.Remaining: - this.LuxonBarText = $"Remaining {mainPlayerResourceContext.User.User.MaxLuxonPoints - mainPlayerResourceContext.User.User.CurrentLuxonPoints} Luxon Points"; + this.LuxonBarText = $"Remaining {mainPlayerResourceContext.MaxLuxon - mainPlayerResourceContext.CurrentLuxon} Luxon Points"; break; case Configuration.FocusView.PointsDisplay.Percentage: - this.LuxonBarText = $"{(int)((double)mainPlayerResourceContext.User.User.CurrentLuxonPoints / (double)mainPlayerResourceContext.User.User.MaxLuxonPoints * 100)}% Luxon Points"; + this.LuxonBarText = $"{(int)((double)mainPlayerResourceContext.CurrentLuxon / (double)mainPlayerResourceContext.MaxLuxon * 100)}% Luxon Points"; break; } } private void UpdateKurzickText() { - if (this.DataContext is not MainPlayerResourceContext mainPlayerResourceContext || - mainPlayerResourceContext.User?.User is null) + if (this.DataContext is not PlayerResourcesComponentContext mainPlayerResourceContext) { return; } @@ -348,21 +175,20 @@ public partial class PlayerResourcesComponent : UserControl switch (this.liveOptions.Value.KurzickPointsDisplay) { case Configuration.FocusView.PointsDisplay.CurrentAndMax: - this.KurzickBarText = $"{mainPlayerResourceContext.User.User.CurrentKurzickPoints} / {mainPlayerResourceContext.User.User.MaxKurzickPoints} Kurzick Points"; + this.KurzickBarText = $"{mainPlayerResourceContext.CurrentKurzick} / {mainPlayerResourceContext.MaxKurzick} Kurzick Points"; break; case Configuration.FocusView.PointsDisplay.Remaining: - this.KurzickBarText = $"Remaining {mainPlayerResourceContext.User.User!.MaxKurzickPoints - mainPlayerResourceContext.User.User.CurrentKurzickPoints} Kurzick Points"; + this.KurzickBarText = $"Remaining {mainPlayerResourceContext.MaxKurzick - mainPlayerResourceContext.CurrentKurzick} Kurzick Points"; break; case Configuration.FocusView.PointsDisplay.Percentage: - this.KurzickBarText = $"{(int)((double)mainPlayerResourceContext.User.User.CurrentKurzickPoints / (double)mainPlayerResourceContext.User.User.MaxKurzickPoints * 100)}% Kurzick Points"; + this.KurzickBarText = $"{(int)((double)mainPlayerResourceContext.CurrentKurzick / (double)mainPlayerResourceContext.MaxKurzick * 100)}% Kurzick Points"; break; } } private void UpdateImperialText() { - if (this.DataContext is not MainPlayerResourceContext mainPlayerResourceContext || - mainPlayerResourceContext.User?.User is null) + if (this.DataContext is not PlayerResourcesComponentContext mainPlayerResourceContext) { return; } @@ -370,21 +196,20 @@ public partial class PlayerResourcesComponent : UserControl switch (this.liveOptions.Value.ImperialPointsDisplay) { case Configuration.FocusView.PointsDisplay.CurrentAndMax: - this.ImperialBarText = $"{mainPlayerResourceContext.User.User!.CurrentImperialPoints} / {mainPlayerResourceContext.User.User.MaxImperialPoints} Imperial Points"; + this.ImperialBarText = $"{mainPlayerResourceContext.CurrentImperial} / {mainPlayerResourceContext.MaxImperial} Imperial Points"; break; case Configuration.FocusView.PointsDisplay.Remaining: - this.ImperialBarText = $"Remaining {mainPlayerResourceContext.User.User!.MaxImperialPoints - mainPlayerResourceContext.User.User.CurrentImperialPoints} Imperial Points"; + this.ImperialBarText = $"Remaining {mainPlayerResourceContext.MaxImperial - mainPlayerResourceContext.CurrentImperial} Imperial Points"; break; case Configuration.FocusView.PointsDisplay.Percentage: - this.ImperialBarText = $"{(int)((double)mainPlayerResourceContext.User.User!.CurrentImperialPoints / (double)mainPlayerResourceContext.User.User.MaxImperialPoints * 100)}% Imperial Points"; + this.ImperialBarText = $"{(int)((double)mainPlayerResourceContext.CurrentImperial / (double)mainPlayerResourceContext.MaxImperial * 100)}% Imperial Points"; break; } } private void UpdateBalthazarText() { - if (this.DataContext is not MainPlayerResourceContext mainPlayerResourceContext || - mainPlayerResourceContext.User?.User is null) + if (this.DataContext is not PlayerResourcesComponentContext mainPlayerResourceContext) { return; } @@ -392,98 +217,14 @@ public partial class PlayerResourcesComponent : UserControl switch (this.liveOptions.Value.BalthazarPointsDisplay) { case Configuration.FocusView.PointsDisplay.CurrentAndMax: - this.BalthazarBarText = $"{mainPlayerResourceContext.User.User.CurrentBalthazarPoints} / {mainPlayerResourceContext.User.User.MaxBalthazarPoints} Balthazar Points"; + this.BalthazarBarText = $"{mainPlayerResourceContext.CurrentBalthazar} / {mainPlayerResourceContext.MaxBalthazar} Balthazar Points"; break; case Configuration.FocusView.PointsDisplay.Remaining: - this.BalthazarBarText = $"Remaining {mainPlayerResourceContext.User.User.MaxBalthazarPoints - mainPlayerResourceContext.User.User.CurrentBalthazarPoints} Balthazar Points"; + this.BalthazarBarText = $"Remaining {mainPlayerResourceContext.MaxBalthazar - mainPlayerResourceContext.CurrentBalthazar} Balthazar Points"; break; case Configuration.FocusView.PointsDisplay.Percentage: - this.BalthazarBarText = $"{(int)((double)mainPlayerResourceContext.User.User.CurrentBalthazarPoints / (double)mainPlayerResourceContext.User.User.MaxBalthazarPoints * 100)}% Balthazar Points"; + this.BalthazarBarText = $"{(int)((double)mainPlayerResourceContext.CurrentBalthazar / (double)mainPlayerResourceContext.MaxBalthazar * 100)}% Balthazar Points"; break; } } - - private void UpdateVanquishingText() - { - if (this.DataContext is not MainPlayerResourceContext mainPlayerResourceContext || - mainPlayerResourceContext.Session?.Session is null) - { - return; - } - - switch (this.liveOptions.Value.VanquishingDisplay) - { - case Configuration.FocusView.PointsDisplay.CurrentAndMax: - this.VanquishingText = $"{mainPlayerResourceContext.Session.Session.FoesKilled} / {(int)this.TotalFoes} Foes Killed"; - break; - case Configuration.FocusView.PointsDisplay.Remaining: - this.VanquishingText = $"Remaining {mainPlayerResourceContext.Session.Session.FoesToKill} Foes"; - break; - case Configuration.FocusView.PointsDisplay.Percentage: - this.VanquishingText = $"{(int)((double)mainPlayerResourceContext.Session.Session.FoesKilled / (double)this.TotalFoes * 100)}% Foes Killed"; - break; - } - } - - private void UpdateHealthText() - { - if (this.DataContext is not MainPlayerResourceContext mainPlayerResourceContext || - mainPlayerResourceContext.Player?.PlayerInformation is null) - { - return; - } - - switch (this.liveOptions.Value.HealthDisplay) - { - case Configuration.FocusView.PointsDisplay.CurrentAndMax: - this.HealthBarText = $"{(int)mainPlayerResourceContext.Player.PlayerInformation.CurrentHealth} / {(int)mainPlayerResourceContext.Player.PlayerInformation.MaxHealth} Health"; - break; - case Configuration.FocusView.PointsDisplay.Remaining: - this.HealthBarText = $"Remaining {(int)mainPlayerResourceContext.Player.PlayerInformation.CurrentHealth} Health"; - break; - case Configuration.FocusView.PointsDisplay.Percentage: - this.HealthBarText = $"{(int)(mainPlayerResourceContext.Player.PlayerInformation.CurrentHealth / mainPlayerResourceContext.Player.PlayerInformation.MaxHealth * 100)}% Health"; - break; - } - } - - private void UpdateEnergyText() - { - if (this.DataContext is not MainPlayerResourceContext mainPlayerResourceContext || - mainPlayerResourceContext.Player?.PlayerInformation is null) - { - return; - } - - switch (this.liveOptions.Value.EnergyDisplay) - { - case Configuration.FocusView.PointsDisplay.CurrentAndMax: - this.EnergyBarText = $"{(int)mainPlayerResourceContext.Player.PlayerInformation.CurrentEnergy} / {(int)mainPlayerResourceContext.Player.PlayerInformation.MaxEnergy} Energy"; - break; - case Configuration.FocusView.PointsDisplay.Remaining: - this.EnergyBarText = $"Remaining {(int)mainPlayerResourceContext.Player.PlayerInformation.CurrentEnergy} Energy"; - break; - case Configuration.FocusView.PointsDisplay.Percentage: - this.EnergyBarText = $"{(int)(mainPlayerResourceContext.Player.PlayerInformation.CurrentEnergy / mainPlayerResourceContext.Player.PlayerInformation.MaxEnergy * 100)}% Energy"; - break; - } - } - - private void UpdateTitleText() - { - if (this.DataContext is not MainPlayerResourceContext mainPlayerResourceContext || - mainPlayerResourceContext.Player?.PlayerInformation is null) - { - return; - } - - if (mainPlayerResourceContext.Player.PlayerInformation.TitleInformation?.IsPercentage is true) - { - this.TitleText = $"{(double?)mainPlayerResourceContext.Player.PlayerInformation.TitleInformation?.CurrentPoints / 10d}% Rank Progress"; - } - else - { - this.TitleText = $"{mainPlayerResourceContext.Player.PlayerInformation.TitleInformation?.CurrentPoints}/{mainPlayerResourceContext.Player.PlayerInformation.TitleInformation?.PointsForNextRank} Rank Progress"; - } - } } diff --git a/Daybreak/Controls/FocusViewComponents/QuestLogComponent.xaml b/Daybreak/Controls/FocusViewComponents/QuestLogComponent.xaml index ac27df28..67633c32 100644 --- a/Daybreak/Controls/FocusViewComponents/QuestLogComponent.xaml +++ b/Daybreak/Controls/FocusViewComponents/QuestLogComponent.xaml @@ -5,6 +5,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="clr-namespace:Daybreak.Controls.FocusViewComponents" xmlns:templates="clr-namespace:Daybreak.Controls.Templates" + xmlns:buttons="clr-namespace:Daybreak.Controls.Buttons" x:Name="_this" Background="{DynamicResource Daybreak.Brushes.Background}" BorderBrush="{DynamicResource MahApps.Brushes.ThemeForeground}" @@ -13,6 +14,8 @@ d:DesignHeight="450" d:DesignWidth="800"> + + @@ -20,18 +23,37 @@ Foreground="{DynamicResource MahApps.Brushes.ThemeForeground}" Grid.Row="0" FontSize="18" - Padding="10" + Padding="10, 0, 0, 0" + Text="Active Quest" /> + + + + Grid.Row="3"> diff --git a/Daybreak/Controls/FocusViewComponents/QuestLogComponent.xaml.cs b/Daybreak/Controls/FocusViewComponents/QuestLogComponent.xaml.cs index 1c04ebdd..07ffd535 100644 --- a/Daybreak/Controls/FocusViewComponents/QuestLogComponent.xaml.cs +++ b/Daybreak/Controls/FocusViewComponents/QuestLogComponent.xaml.cs @@ -1,4 +1,5 @@ -using Daybreak.Shared.Models.Guildwars; +using Daybreak.Shared.Models.FocusView; +using Daybreak.Shared.Models.Guildwars; using System; using System.Windows.Controls; @@ -22,7 +23,7 @@ public partial class QuestLogComponent : UserControl return; } - this.NavigateToClicked?.Invoke(this, e.WikiUrl!); + this.NavigateToClicked?.Invoke(this, e.WikiUrl ?? string.Empty); } private void QuestLogTemplate_QuestClicked(object _, Quest e) @@ -32,6 +33,17 @@ public partial class QuestLogComponent : UserControl return; } - this.NavigateToClicked?.Invoke(this, e.WikiUrl!); + this.NavigateToClicked?.Invoke(this, e.WikiUrl ?? string.Empty); + } + + private void ActiveQuest_Clicked(object sender, EventArgs e) + { + if (this.DataContext is not QuestLogComponentContext context || + context.CurrentQuest?.Quest is null) + { + return; + } + + this.NavigateToClicked?.Invoke(this, context.CurrentQuest.Quest.WikiUrl ?? string.Empty); } } diff --git a/Daybreak/Controls/FocusViewComponents/TitleInformationComponent.xaml b/Daybreak/Controls/FocusViewComponents/TitleInformationComponent.xaml index 78c20ccd..1c8b0313 100644 --- a/Daybreak/Controls/FocusViewComponents/TitleInformationComponent.xaml +++ b/Daybreak/Controls/FocusViewComponents/TitleInformationComponent.xaml @@ -5,6 +5,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="clr-namespace:Daybreak.Controls.FocusViewComponents" xmlns:buttons="clr-namespace:Daybreak.Controls.Buttons" + xmlns:controls="clr-namespace:Daybreak.Controls" DataContextChanged="UserControl_DataContextChanged" x:Name="_this" Background="{DynamicResource Daybreak.Brushes.Background}" @@ -21,7 +22,7 @@ @@ -41,15 +42,16 @@ - + diff --git a/Daybreak/Controls/FocusViewComponents/TitleInformationComponent.xaml.cs b/Daybreak/Controls/FocusViewComponents/TitleInformationComponent.xaml.cs index 3e9a0a61..f9397ec0 100644 --- a/Daybreak/Controls/FocusViewComponents/TitleInformationComponent.xaml.cs +++ b/Daybreak/Controls/FocusViewComponents/TitleInformationComponent.xaml.cs @@ -1,4 +1,5 @@ -using Daybreak.Shared.Models.Guildwars; +using Daybreak.Shared.Models.FocusView; +using Daybreak.Shared.Models.Guildwars; using System; using System.Windows; using System.Windows.Controls; @@ -14,7 +15,13 @@ public partial class TitleInformationComponent : UserControl public event EventHandler? NavigateToClicked; [GenerateDependencyProperty] - private string titleRankName = string.Empty; + private int pointsInCurrentRank; + [GenerateDependencyProperty] + private int pointsForNextRank; + [GenerateDependencyProperty] + private bool titleActive; + [GenerateDependencyProperty] + private string titleText = string.Empty; public TitleInformationComponent() { @@ -23,8 +30,8 @@ public partial class TitleInformationComponent : UserControl private void Title_MouseLeftButtonDown(object _, MouseButtonEventArgs __) { - if (this.DataContext is not TitleInformation titleInformation || - titleInformation.Title is not Title title || + if (this.DataContext is not TitleInformationComponentContext context || + context.Title is not Title title || title.WikiUrl is not string url) { return; @@ -35,20 +42,51 @@ public partial class TitleInformationComponent : UserControl private void UserControl_DataContextChanged(object _, DependencyPropertyChangedEventArgs __) { - if (this.DataContext is not TitleInformation titleInformation) + if (this.DataContext is not TitleInformationComponentContext context) { return; } - if (titleInformation.Title is not null && - titleInformation.Title.Tiers!.Count > titleInformation.TierNumber - 1) + this.TitleActive = context.Title is not null; + + if (context.Title is not null) { - var rankIndex = (int)titleInformation.TierNumber! - 1; - this.TitleRankName = $"{titleInformation.Title.Tiers![rankIndex]} ({titleInformation.TierNumber}/{titleInformation.MaxTierNumber})"; + if (context.MaxTierNumber == context.TierNumber || + context.PointsForCurrentRank == context.PointsForNextRank) + { + this.PointsInCurrentRank = (int)context.CurrentPoints; + this.PointsForNextRank = (int)context.CurrentPoints; + } + else if (context.IsPercentage is false) + { + this.PointsInCurrentRank = (int)((uint)context.CurrentPoints - (uint)context.PointsForCurrentRank); + this.PointsForNextRank = (int)((uint)context.PointsForNextRank - (uint)context.PointsForCurrentRank); + } + else + { + + this.PointsInCurrentRank = (int)(uint)context.CurrentPoints; + this.PointsForNextRank = (int)(uint)context.PointsForNextRank; + } + } + + this.UpdateTitleText(); + } + + private void UpdateTitleText() + { + if (this.DataContext is not TitleInformationComponentContext context) + { + return; + } + + if (context.IsPercentage is true) + { + this.TitleText = $"{(double?)context.CurrentPoints / 10d}% Rank Progress"; } else { - this.TitleRankName = string.Empty; + this.TitleText = $"{context.CurrentPoints}/{context.PointsForNextRank} Rank Progress"; } } } diff --git a/Daybreak/Controls/FocusViewComponents/VanquishComponent.xaml b/Daybreak/Controls/FocusViewComponents/VanquishComponent.xaml new file mode 100644 index 00000000..8918a192 --- /dev/null +++ b/Daybreak/Controls/FocusViewComponents/VanquishComponent.xaml @@ -0,0 +1,42 @@ + + + + + + + + + diff --git a/Daybreak/Controls/FocusViewComponents/VanquishComponent.xaml.cs b/Daybreak/Controls/FocusViewComponents/VanquishComponent.xaml.cs new file mode 100644 index 00000000..6c79f315 --- /dev/null +++ b/Daybreak/Controls/FocusViewComponents/VanquishComponent.xaml.cs @@ -0,0 +1,86 @@ +using Daybreak.Configuration.Options; +using Daybreak.Shared; +using Daybreak.Shared.Models.FocusView; +using Microsoft.Extensions.DependencyInjection; +using System.Configuration; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Extensions; +using System.Windows.Input; + +namespace Daybreak.Controls.FocusViewComponents; +/// +/// Interaction logic for VanquishComponent.xaml +/// +public partial class VanquishComponent : UserControl +{ + private readonly ILiveUpdateableOptions liveOptions; + + [GenerateDependencyProperty] + private int totalFoes; + [GenerateDependencyProperty] + private string vanquishingText = string.Empty; + + public VanquishComponent() + : this(Global.GlobalServiceProvider.GetRequiredService>()) + { + } + + public VanquishComponent( + ILiveUpdateableOptions liveOptions) + { + this.liveOptions = liveOptions; + this.InitializeComponent(); + } + + private void UserControl_DataContextChanged(object _, DependencyPropertyChangedEventArgs __) + { + if (this.DataContext is not VanquishComponentContext context) + { + return; + } + + this.TotalFoes = (int)(context.FoesKilled + context.FoesToKill); + this.UpdateVanquishingText(); + } + + private void VanquishingBar_MouseLeftButtonDown(object _, MouseButtonEventArgs e) + { + switch (this.liveOptions.Value.VanquishingDisplay) + { + case Configuration.FocusView.PointsDisplay.CurrentAndMax: + this.liveOptions.Value.VanquishingDisplay = Configuration.FocusView.PointsDisplay.Remaining; + break; + case Configuration.FocusView.PointsDisplay.Remaining: + this.liveOptions.Value.VanquishingDisplay = Configuration.FocusView.PointsDisplay.Percentage; + break; + case Configuration.FocusView.PointsDisplay.Percentage: + this.liveOptions.Value.VanquishingDisplay = Configuration.FocusView.PointsDisplay.CurrentAndMax; + break; + } + + this.liveOptions.UpdateOption(); + this.UpdateVanquishingText(); + } + + private void UpdateVanquishingText() + { + if (this.DataContext is not VanquishComponentContext context) + { + return; + } + + switch (this.liveOptions.Value.VanquishingDisplay) + { + case Configuration.FocusView.PointsDisplay.CurrentAndMax: + this.VanquishingText = $"{context.FoesKilled} / {(int)this.TotalFoes} Foes Killed"; + break; + case Configuration.FocusView.PointsDisplay.Remaining: + this.VanquishingText = $"Remaining {context.FoesToKill} Foes"; + break; + case Configuration.FocusView.PointsDisplay.Percentage: + this.VanquishingText = $"{(int)((double)context.FoesKilled / (double)this.TotalFoes * 100)}% Foes Killed"; + break; + } + } +} diff --git a/Daybreak/Controls/SpacedStackPanel.cs b/Daybreak/Controls/SpacedStackPanel.cs new file mode 100644 index 00000000..c23eb876 --- /dev/null +++ b/Daybreak/Controls/SpacedStackPanel.cs @@ -0,0 +1,72 @@ +using System.Windows; +using System.Windows.Controls; + +namespace Daybreak.Controls; +public sealed class SpacedStackPanel : StackPanel +{ + public static readonly DependencyProperty SpacingProperty = + DependencyProperty.Register( + nameof(Spacing), + typeof(double), + typeof(SpacedStackPanel), + new FrameworkPropertyMetadata(0d, + FrameworkPropertyMetadataOptions.AffectsArrange | + FrameworkPropertyMetadataOptions.AffectsMeasure, + (_, __) => { /* invalidates layout automatically */ })); + + public double Spacing + { + get => (double)this.GetValue(SpacingProperty); + set => this.SetValue(SpacingProperty, value); + } + + protected override Size MeasureOverride(Size constraint) + { + this.ApplySpacing(); + return base.MeasureOverride(constraint); + } + + protected override Size ArrangeOverride(Size arrangeSize) + { + this.ApplySpacing(); + return base.ArrangeOverride(arrangeSize); + } + + protected override void OnVisualChildrenChanged( + DependencyObject visualAdded, DependencyObject visualRemoved) + { + base.OnVisualChildrenChanged(visualAdded, visualRemoved); + this.ApplySpacing(); + } + + private void ApplySpacing() + { + bool firstVisible = true; + + foreach (UIElement child in this.InternalChildren) + { + if (child is not FrameworkElement fe) + { + continue; + } + + if (child.Visibility == Visibility.Visible && + fe.ActualWidth > 0 && + fe.ActualHeight > 0) + { + // first visible element ⇒ no leading gap + fe.Margin = firstVisible + ? default + : this.Orientation == Orientation.Horizontal + ? new Thickness(this.Spacing, 0, 0, 0) // gap to the left + : new Thickness(0, this.Spacing, 0, 0); // gap on top + + firstVisible = false; + } + else + { + fe.Margin = default; + } + } + } +} diff --git a/Daybreak/Daybreak.csproj b/Daybreak/Daybreak.csproj index f19b101d..ed9f1deb 100644 --- a/Daybreak/Daybreak.csproj +++ b/Daybreak/Daybreak.csproj @@ -15,9 +15,11 @@ true cfb2a489-db80-448d-a969-80270f314c46 True + win-x86 + @@ -96,9 +98,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + - - true - win-x86 + $(ProjectDir)bin\$(Platform)\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\ + $(PublishDir) + $(BaseBuildDir) + + + + + + + + + + + + + + + + + + + + + + diff --git a/Daybreak/Launch/Launcher.cs b/Daybreak/Launch/Launcher.cs index 226bf84d..14a6975b 100644 --- a/Daybreak/Launch/Launcher.cs +++ b/Daybreak/Launch/Launcher.cs @@ -15,17 +15,16 @@ using Daybreak.Shared.Services.Startup; using Daybreak.Shared.Services.Themes; using Daybreak.Shared.Services.Updater.PostUpdate; using Daybreak.Shared.Services.Window; -using Daybreak.Views; +using Daybreak.Shared.Utils; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Slim; using Slim.Integration.ServiceCollection; using System; using System.Core.Extensions; -using System.Drawing.Printing; +using System.Diagnostics; using System.Runtime.CompilerServices; using System.Text; -using System.Threading; using System.Threading.Tasks; using System.Windows; using System.Windows.Extensions; @@ -34,7 +33,10 @@ using System.Windows.Media; //The following lines are needed to expose internal objects to the test project [assembly: InternalsVisibleTo("Daybreak.Tests")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] - +[assembly: ThemeInfo( + ResourceDictionaryLocation.None, + ResourceDictionaryLocation.SourceAssembly) +] namespace Daybreak.Launch; public sealed class Launcher : ExtendedApplication @@ -55,6 +57,10 @@ public sealed class Launcher : ExtendedApplication [STAThread] public static int Main(string[] args) { +#if DEBUG + AllocateAnsiConsole(); +#endif + Instance = new Launcher(args); RegisterExtraEncodingProviders(); RegisterMahAppsStyle(); @@ -105,6 +111,13 @@ public sealed class Launcher : ExtendedApplication this.ServiceProvider.GetRequiredService().ShowSplashScreen(); _ = this.ServiceProvider.GetRequiredService().GetCurrentTheme(); + /* + * Hook into WPF traces and output them to logs + */ + + PresentationTraceSources.DataBindingSource.Switch.Level = SourceLevels.Warning | SourceLevels.Error; + PresentationTraceSources.DataBindingSource.Listeners.Add(new BindingErrorTraceListener(this.ServiceProvider.GetRequiredService>())); + await this.InitializeApplicationServices(startupStatus, optionsProducer); } @@ -247,4 +260,34 @@ public sealed class Launcher : ExtendedApplication { Instance.Resources.MergedDictionaries[2].Add("Daybreak.Brushes.Background", new SolidColorBrush(Colors.Transparent)); } + + private static void AllocateAnsiConsole() + { + NativeMethods.AllocConsole(); + var handle = NativeMethods.GetStdHandle(NativeMethods.STD_OUTPUT_HANDLE); + if (!NativeMethods.GetConsoleMode(handle, out var mode)) + { + Console.WriteLine("Failed to get console mode"); + } + + if (!NativeMethods.SetConsoleMode(handle, mode | NativeMethods.ENABLE_VIRTUAL_TERMINAL_PROCESSING | NativeMethods.ENABLE_PROCESSED_OUTPUT)) + { + Console.WriteLine("Failed to enable virtual terminal processing"); + } + } + + internal sealed class BindingErrorTraceListener(ILogger logger) : TraceListener + { + private readonly ILogger logger = logger; + + public override void Write(string? message) + { + this.logger.LogWarning("WPF Binding error: {bindingMessage}", message); + } + + public override void WriteLine(string? message) + { + this.logger.LogWarning("WPF Binding error: {bindingMessage}", message); + } + } } diff --git a/Daybreak/Services/Api/AttachedApiAccessor.cs b/Daybreak/Services/Api/AttachedApiAccessor.cs new file mode 100644 index 00000000..ce4e85e1 --- /dev/null +++ b/Daybreak/Services/Api/AttachedApiAccessor.cs @@ -0,0 +1,9 @@ +using Daybreak.Shared.Models.LaunchConfigurations; +using Daybreak.Shared.Services.Api; + +namespace Daybreak.Services.Api; +public sealed class AttachedApiAccessor : IAttachedApiAccessor +{ + public GuildWarsApplicationLaunchContext? LaunchContext { get; internal set; } + public ScopedApiContext? ApiContext { get; internal set; } +} diff --git a/Daybreak/Services/Api/DaybreakApiService.cs b/Daybreak/Services/Api/DaybreakApiService.cs index 76f64a4b..8397830d 100644 --- a/Daybreak/Services/Api/DaybreakApiService.cs +++ b/Daybreak/Services/Api/DaybreakApiService.cs @@ -1,45 +1,128 @@ -using Daybreak.Shared.Models.Mods; +using Daybreak.Configuration.Options; +using Daybreak.Shared.Models; +using Daybreak.Shared.Models.LaunchConfigurations; +using Daybreak.Shared.Models.Mods; using Daybreak.Shared.Services.Api; using Daybreak.Shared.Services.Injection; +using Daybreak.Shared.Services.MDns; using Daybreak.Shared.Services.Notifications; using Daybreak.Shared.Utils; using Microsoft.Extensions.Logging; +using System; using System.Collections.Generic; +using System.Configuration; using System.Core.Extensions; +using System.Diagnostics; using System.Extensions.Core; using System.IO; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; namespace Daybreak.Services.Api; public sealed class DaybreakApiService( + IAttachedApiAccessor attachedApiAccessor, + IMDnsService mdnsService, IStubInjector stubInjector, INotificationService notificationService, - ILogger logger) + IHttpClient scopedApiClient, + ILiveUpdateableOptions liveUpdateableOptions, + ILogger logger, + ILogger scopedApiLogger) : IDaybreakApiService { private const string DaybreakApiName = "Daybreak.API.dll"; + private const string ProcessIdPlaceholder = "{PID}"; + private const string DaybreakApiServiceName = $"daybreak-api-{ProcessIdPlaceholder}"; + private const int MDNSRetryCount = 3; + private static readonly TimeSpan MDNSTimeout = TimeSpan.FromSeconds(5); + + private readonly IAttachedApiAccessor attachedApiAccessor = attachedApiAccessor.ThrowIfNull(); + private readonly IMDnsService mdnsService = mdnsService.ThrowIfNull(); private readonly IStubInjector stubInjector = stubInjector.ThrowIfNull(); private readonly INotificationService notificationService = notificationService.ThrowIfNull(); + private readonly IHttpClient scopedApiClient = scopedApiClient.ThrowIfNull(); + private readonly ILiveUpdateableOptions liveUpdateableOptions = liveUpdateableOptions.ThrowIfNull(); private readonly ILogger logger = logger.ThrowIfNull(); + private readonly ILogger scopedApiLogger = scopedApiLogger.ThrowIfNull(); public string Name { get; } = "Daybreak API"; - public bool IsEnabled { get; set; } = false; + + public bool IsEnabled + { + get => this.liveUpdateableOptions.Value.Enabled; + set + { + this.liveUpdateableOptions.Value.Enabled = value; + this.liveUpdateableOptions.UpdateOption(); + } + } + public bool IsInstalled { get; } = true; public IEnumerable GetCustomArguments() => []; - public Task OnGuildWarsCreated(GuildWarsCreatedContext guildWarsCreatedContext, CancellationToken cancellationToken) => Task.CompletedTask; + public async Task AttachDaybreakApiContext(GuildWarsApplicationLaunchContext launchContext, CancellationToken cancellationToken) + { + var apiContext = await this.GetDaybreakApiContext(launchContext.GuildWarsProcess, cancellationToken); + if (this.attachedApiAccessor is AttachedApiAccessor accessor) + { + accessor.LaunchContext = launchContext; + accessor.ApiContext = apiContext; + } - public Task OnGuildWarsStarted(GuildWarsStartedContext guildWarsStartedContext, CancellationToken cancellationToken) => - Task.Factory.StartNew(() => this.InjectWithStub(guildWarsStartedContext), cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current); + return apiContext; + } + + public async Task GetDaybreakApiContext(Process guildWarsProcess, CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(); + if (guildWarsProcess.HasExited) + { + scopedLogger.LogWarning("Guild Wars process has exited"); + return default; + } + + var serviceName = DaybreakApiServiceName.Replace(ProcessIdPlaceholder, guildWarsProcess.Id.ToString()); + IReadOnlyList? serviceUris = default; + for (var i = 0; i < MDNSRetryCount; i++) + { + using var cts = new CancellationTokenSource(MDNSTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cts.Token); + scopedLogger.LogInformation("{attempt}/{maxAttempts} Searching for Daybreak API service with name {serviceName}", i, MDNSRetryCount, serviceName); + try + { + serviceUris = await this.mdnsService.FindLocalService(serviceName, cancellationToken: linkedCts.Token); + break; + } + catch(Exception e) + { + scopedLogger.LogError(e, "{attempt}/{maxAttempts} Failed to find Daybreak API service with name {serviceName}", i, MDNSRetryCount, serviceName); + } + } + + var serviceUri = serviceUris?.Count > 0 ? serviceUris[0] : default; + if (serviceUri is null) + { + scopedLogger.LogWarning("Failed to find Daybreak API service by name {serviceName}", serviceName); + return default; + } + + var apiContext = new DaybreakAPIContext(serviceUri, guildWarsProcess); + return new ScopedApiContext(this.scopedApiLogger, this.scopedApiClient, apiContext); + } + + public Task OnGuildWarsCreated(GuildWarsCreatedContext guildWarsCreatedContext, CancellationToken cancellationToken) => + Task.Factory.StartNew(() => this.InjectWithStub(guildWarsCreatedContext), cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current); + + public Task OnGuildWarsStarted(GuildWarsStartedContext guildWarsStartedContext, CancellationToken cancellationToken) => Task.CompletedTask; public Task OnGuildWarsStarting(GuildWarsStartingContext guildWarsStartingContext, CancellationToken cancellationToken) => Task.CompletedTask; public Task OnGuildWarsStartingDisabled(GuildWarsStartingDisabledContext guildWarsStartingDisabledContext, CancellationToken cancellationToken) => Task.CompletedTask; - private void InjectWithStub(GuildWarsStartedContext context) + private void InjectWithStub(GuildWarsCreatedContext context) { var scopedLogger = this.logger.CreateScopedLogger(); var dllName = @@ -67,7 +150,7 @@ public sealed class DaybreakApiService( return; } - if (port < 0) + if (port <= 0) { this.notificationService.NotifyError( "Daybreak API Failure", diff --git a/Daybreak/Services/ApplicationLauncher/ApplicationLauncher.cs b/Daybreak/Services/ApplicationLauncher/ApplicationLauncher.cs index a0943fe5..9f128da6 100644 --- a/Daybreak/Services/ApplicationLauncher/ApplicationLauncher.cs +++ b/Daybreak/Services/ApplicationLauncher/ApplicationLauncher.cs @@ -6,7 +6,6 @@ using Daybreak.Shared.Models.Mods; using Daybreak.Shared.Services.ApplicationLauncher; using Daybreak.Shared.Services.ExecutableManagement; using Daybreak.Shared.Services.Mods; -using Daybreak.Shared.Services.Mutex; using Daybreak.Shared.Services.Notifications; using Daybreak.Shared.Services.Privilege; using Daybreak.Shared.Utils; @@ -36,13 +35,11 @@ internal sealed class ApplicationLauncher( IGuildWarsExecutableManager guildWarsExecutableManager, INotificationService notificationService, ILiveOptions launcherOptions, - IMutexHandler mutexHandler, IModsManager modsManager, IPrivilegeManager privilegeManager, ILogger logger) : IApplicationLauncher { private const string ProcessName = "gw"; - private const string ArenaNetMutex = "AN-Mute"; private const double LaunchMemoryThreshold = 200000000; private static readonly TimeSpan LaunchTimeout = TimeSpan.FromMinutes(1); @@ -50,41 +47,17 @@ internal sealed class ApplicationLauncher( private readonly IGuildWarsExecutableManager guildWarsExecutableManager = guildWarsExecutableManager.ThrowIfNull(); private readonly INotificationService notificationService = notificationService.ThrowIfNull(); private readonly ILiveOptions launcherOptions = launcherOptions.ThrowIfNull(); - private readonly IMutexHandler mutexHandler = mutexHandler.ThrowIfNull(); private readonly IModsManager modsManager = modsManager.ThrowIfNull(); private readonly ILogger logger = logger.ThrowIfNull(); private readonly IPrivilegeManager privilegeManager = privilegeManager.ThrowIfNull(); - public async Task LaunchGuildwars(LaunchConfigurationWithCredentials launchConfigurationWithCredentials) + public async Task LaunchGuildwars(LaunchConfigurationWithCredentials launchConfigurationWithCredentials, CancellationToken cancellationToken) { launchConfigurationWithCredentials.ThrowIfNull(); launchConfigurationWithCredentials.Credentials!.ThrowIfNull(); - var configuration = this.launcherOptions.Value; - if (configuration.MultiLaunchSupport is true) - { - if (this.privilegeManager.AdminPrivileges is false) - { - this.privilegeManager.RequestAdminPrivileges("You need administrator rights in order to start using multi-launch"); - return null; - } - - if (launchConfigurationWithCredentials.ExecutablePath is not null && - !this.ClearGwLocks(launchConfigurationWithCredentials.ExecutablePath)) - { - this.logger.LogError("Failed to clear GW locks. Canceling GuildWars launch"); - return null; - } - } - else if (this.GetGuildwarsProcesses().Any()) - { - this.notificationService.NotifyError( - title: "Can not launch Guild Wars", - description: "Multi-launch is disabled. Can not launch another instance of Guild Wars while the current one is running"); - return null; - } - using var timeout = new CancellationTokenSource(LaunchTimeout); - var gwProcess = await this.LaunchGuildwarsProcess(launchConfigurationWithCredentials, timeout.Token); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token); + var gwProcess = await this.LaunchGuildwarsProcess(launchConfigurationWithCredentials, cancellation.Token); if (gwProcess is null) { return default; @@ -349,18 +322,21 @@ internal sealed class ApplicationLauncher( while (sw.Elapsed.TotalSeconds < LaunchTimeout.TotalSeconds) { await Task.Delay(500, cancellationToken); - var gwProcess = Process.GetProcessesByName("gw").FirstOrDefault(); - if (gwProcess is null) + if (process.HasExited) + { + this.logger.LogError($"Guild Wars process exited before the main window was shown. Process ID: {process.Id}"); + this.notificationService.NotifyError( + title: "Guild Wars process exited", + description: "Guild Wars process exited before the main window was shown. Please check logs for details"); + return default; + } + + if (process.MainWindowHandle == IntPtr.Zero) { continue; } - if (gwProcess!.MainWindowHandle == IntPtr.Zero) - { - continue; - } - - var windows = GetRootWindowsOfProcess(gwProcess.Id) + var windows = GetRootWindowsOfProcess(process.Id) .Select(root => (root, GetChildWindows(root))) .SelectMany(tuple => { @@ -379,28 +355,12 @@ internal sealed class ApplicationLauncher( continue; } - var virtualMemory = gwProcess.VirtualMemorySize64; + var virtualMemory = process.VirtualMemorySize64; if (virtualMemory < LaunchMemoryThreshold) { continue; } - var windowInfo = new NativeMethods.WindowInfo(true); - NativeMethods.GetWindowInfo(gwProcess.MainWindowHandle, ref windowInfo); - if (windowInfo.rcWindow.Width == 0 || windowInfo.rcWindow.Height == 0) - { - continue; - } - - /* - * GW loads more than 90 modules when it starts properly. If there are less than - * 90 modules, GW probably has failed to start or has not started yet - */ - if (gwProcess.Modules.Count < 90) - { - continue; - } - /* * Run the actions one by one, to avoid injection issues */ @@ -430,7 +390,7 @@ internal sealed class ApplicationLauncher( } } - return gwProcess; + return process; } throw new InvalidOperationException("Unable to launch Guild Wars process. Timed out waiting for the main window to launch"); @@ -486,53 +446,6 @@ internal sealed class ApplicationLauncher( } } - private bool ClearGwLocks(string path) - { - if (!this.SetRegistryGuildwarsPath(path)) - { - this.logger.LogError("Failed to set registry entries. Failing to start GuildWars"); - return false; - } - - foreach (var process in Process.GetProcessesByName(ProcessName)) - { - this.mutexHandler.CloseMutex(process, ArenaNetMutex); - } - - return true; - } - - private bool SetRegistryGuildwarsPath(string path) - { - try - { - var regSrc = Microsoft.Win32.Registry.GetValue("HKEY_LOCAL_MACHINE\\SOFTWARE\\ArenaNet\\Guild Wars", "Src", null); - if (regSrc != null && (string)regSrc != Path.GetFullPath(path)) - { - Microsoft.Win32.Registry.SetValue("HKEY_LOCAL_MACHINE\\SOFTWARE\\ArenaNet\\Guild Wars", "Src", Path.GetFullPath(path)); - Microsoft.Win32.Registry.SetValue("HKEY_LOCAL_MACHINE\\SOFTWARE\\ArenaNet\\Guild Wars", "Path", Path.GetFullPath(path)); - } - - regSrc = Microsoft.Win32.Registry.GetValue("HKEY_LOCAL_MACHINE\\SOFTWARE\\Wow6432Node\\ArenaNet\\Guild Wars", "Src", null); - if (regSrc == null || (string)regSrc == Path.GetFullPath(path)) - { - return true; - } - - Microsoft.Win32.Registry.SetValue("HKEY_LOCAL_MACHINE\\SOFTWARE\\Wow6432Node\\ArenaNet\\Guild Wars", "Src", - Path.GetFullPath(path)); - Microsoft.Win32.Registry.SetValue("HKEY_LOCAL_MACHINE\\SOFTWARE\\Wow6432Node\\ArenaNet\\Guild Wars", "Path", - Path.GetFullPath(path)); - - return true; - } - catch (UnauthorizedAccessException e) - { - this.logger.LogError(e, "Failed to patch registry"); - return false; - } - } - private static IEnumerable GetGuildwarsProcessesInternal(params LaunchConfigurationWithCredentials[] launchConfigurationWithCredentials) { return Process.GetProcessesByName(ProcessName) diff --git a/Daybreak/Services/ExceptionHandling/ExceptionHandler.cs b/Daybreak/Services/ExceptionHandling/ExceptionHandler.cs index 62c153fe..a3658838 100644 --- a/Daybreak/Services/ExceptionHandling/ExceptionHandler.cs +++ b/Daybreak/Services/ExceptionHandling/ExceptionHandler.cs @@ -80,6 +80,13 @@ internal sealed class ExceptionHandler : IExceptionHandler this.logger.LogError(e, "Encountered operation canceled exception"); return true; } + else if (aggregateException.InnerExceptions.FirstOrDefault() is ArgumentException argumentException && + aggregateException.Message.Contains("A Task's exception(s) were not observed") && + argumentException.Message?.Contains("Process with an Id of") is true) + { + this.logger.LogError(e, "Encountered argument exception. Guild Wars process terminated unexpectedly"); + return true; + } } else if (e.Message.Contains("Invalid window handle.") && e.StackTrace?.Contains("CoreWebView2Environment.CreateCoreWebView2ControllerAsync") is true) { @@ -90,8 +97,14 @@ internal sealed class ExceptionHandler : IExceptionHandler this.logger.LogError(e, "Failed to initialize browser"); return true; } + else if (e is ArgumentException argumentException && + argumentException.Message.Contains("Process with an Id of")) + { + this.logger.LogError(e, "Encountered argument exception. Guild Wars process terminated unexpectedly"); + return true; + } - this.logger.LogError(e, $"Unhandled exception caught {e.GetType()}"); + this.logger.LogError(e, $"Unhandled exception caught {e.GetType()}"); this.notificationService.NotifyError(e.GetType().Name, e.ToString()); return true; } diff --git a/Daybreak/Services/Logging/ConsoleLogsWriter.cs b/Daybreak/Services/Logging/ConsoleLogsWriter.cs new file mode 100644 index 00000000..89dfc752 --- /dev/null +++ b/Daybreak/Services/Logging/ConsoleLogsWriter.cs @@ -0,0 +1,27 @@ +using Daybreak.Shared.Services.Logging; +using Microsoft.Extensions.Logging; +using System; +using System.Logging; + +namespace Daybreak.Services.Logging; + +internal sealed class ConsoleLogsWriter : IConsoleLogsWriter +{ + public void WriteLog(Log log) + { + var colorCode = GetAnsiColorForLogLevel(log.LogLevel); + Console.WriteLine( + $"[{log.LogTime}]\t[\u001b[{colorCode}m{log.LogLevel}\u001b[0m]\t[{log.Category}]\n{log.Message}"); + } + + private static string GetAnsiColorForLogLevel(LogLevel level) => level switch + { + LogLevel.Trace => "90", // Bright Black + LogLevel.Debug => "36", // Cyan + LogLevel.Information => "32", // Green + LogLevel.Warning => "33", // Yellow + LogLevel.Error => "31", // Red + LogLevel.Critical => "30;41", // Black on Red + _ => "37" // White + }; +} diff --git a/Daybreak/Services/Logging/DebugLogsWriter.cs b/Daybreak/Services/Logging/DebugLogsWriter.cs deleted file mode 100644 index 4024026f..00000000 --- a/Daybreak/Services/Logging/DebugLogsWriter.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Daybreak.Shared.Services.Logging; -using System.Diagnostics; -using System.Logging; - -namespace Daybreak.Services.Logging; - -internal sealed class DebugLogsWriter : IDebugLogsWriter -{ - public void WriteLog(Log log) - { - Debug.WriteLine($"[{log.LogTime}]\t[{log.LogLevel}]\t[{log.Category}]\n{log.Message}"); - } -} diff --git a/Daybreak/Services/ReShade/ReShadeService.cs b/Daybreak/Services/ReShade/ReShadeService.cs index 32755c94..413a9347 100644 --- a/Daybreak/Services/ReShade/ReShadeService.cs +++ b/Daybreak/Services/ReShade/ReShadeService.cs @@ -10,7 +10,6 @@ using Daybreak.Shared.Services.Downloads; using Daybreak.Shared.Services.Injection; using Daybreak.Shared.Services.Notifications; using Daybreak.Shared.Services.ReShade; -using Daybreak.Shared.Services.Scanner; using Daybreak.Shared.Utils; using HtmlAgilityPack; using IniParser.Parser; @@ -55,7 +54,6 @@ internal sealed class ReShadeService : IReShadeService, IApplicationLifetimeServ private static readonly string[] FxExtensions = [".fx",]; private static readonly string[] FxHeaderExtensions = [".fxh"]; - private readonly IGuildwarsMemoryCache guildwarsMemoryCache; private readonly INotificationService notificationService; private readonly IProcessInjector processInjector; private readonly ILiveUpdateableOptions liveUpdateableOptions; @@ -88,7 +86,6 @@ internal sealed class ReShadeService : IReShadeService, IApplicationLifetimeServ File.Exists(ConfigIniPath); public ReShadeService( - IGuildwarsMemoryCache guildwarsMemoryCache, INotificationService notificationService, IProcessInjector processInjector, ILiveUpdateableOptions liveUpdateableOptions, @@ -96,7 +93,6 @@ internal sealed class ReShadeService : IReShadeService, IApplicationLifetimeServ IDownloadService downloadService, ILogger logger) { - this.guildwarsMemoryCache = guildwarsMemoryCache.ThrowIfNull(); this.notificationService = notificationService.ThrowIfNull(); this.processInjector = processInjector.ThrowIfNull(); this.liveUpdateableOptions = liveUpdateableOptions.ThrowIfNull(); diff --git a/Daybreak/Services/Scanner/GuildwarsMemoryCache.cs b/Daybreak/Services/Scanner/GuildwarsMemoryCache.cs deleted file mode 100644 index 8d6f1875..00000000 --- a/Daybreak/Services/Scanner/GuildwarsMemoryCache.cs +++ /dev/null @@ -1,84 +0,0 @@ -using Daybreak.Configuration.Options; -using Daybreak.Services.Scanner.Models; -using Daybreak.Shared.Models.Guildwars; -using Daybreak.Shared.Models.LaunchConfigurations; -using Daybreak.Shared.Services.Scanner; -using System; -using System.Configuration; -using System.Core.Extensions; -using System.Threading; -using System.Threading.Tasks; - -namespace Daybreak.Services.Scanner; - -internal sealed class GuildwarsMemoryCache : IGuildwarsMemoryCache -{ - private readonly IGuildwarsMemoryReader guildwarsMemoryReader; - private readonly ILiveOptions liveOptions; - private readonly CachedData loginDataCache = new(); - private readonly CachedData worldDataCache = new(); - private readonly CachedData sessionDataCache = new(); - private readonly CachedData userDataCache = new(); - private readonly CachedData mainPlayerDataCache = new(); - private readonly CachedData teamBuildDataCache = new(); - - public GuildwarsMemoryCache( - IGuildwarsMemoryReader guildwarsMemoryReader, - ILiveOptions liveOptions) - { - this.guildwarsMemoryReader = guildwarsMemoryReader.ThrowIfNull(); - this.liveOptions = liveOptions.ThrowIfNull(); - } - - public async Task EnsureInitialized(GuildWarsApplicationLaunchContext context, CancellationToken cancellationToken) - { - await this.guildwarsMemoryReader.EnsureInitialized(context.ThrowIfNull().ProcessId, cancellationToken); - } - - public Task ReadLoginData(CancellationToken cancellationToken) - { - return this.ReadDataInternal(this.loginDataCache, this.guildwarsMemoryReader.ReadLoginData, cancellationToken); - } - - public Task ReadWorldData(CancellationToken cancellationToken) - { - return this.ReadDataInternal(this.worldDataCache, this.guildwarsMemoryReader.ReadWorldData, cancellationToken); - } - - public Task ReadSessionData(CancellationToken cancellationToken) - { - return this.ReadDataInternal(this.sessionDataCache, this.guildwarsMemoryReader.ReadSessionData, cancellationToken); - } - - public Task ReadUserData(CancellationToken cancellationToken) - { - return this.ReadDataInternal(this.userDataCache, this.guildwarsMemoryReader.ReadUserData, cancellationToken); - } - - public Task ReadMainPlayerData(CancellationToken cancellationToken) - { - return this.ReadDataInternal(this.mainPlayerDataCache, this.guildwarsMemoryReader.ReadMainPlayerData, cancellationToken); - } - - public Task ReadTeamBuildData(CancellationToken cancellationToken) - { - return this.ReadDataInternal(this.teamBuildDataCache, this.guildwarsMemoryReader.ReadTeamBuildData, cancellationToken); - } - - private async Task ReadDataInternal(CachedData cachedData, Func> task, CancellationToken cancellationToken) - { - if (DateTime.Now - cachedData.SetTime <= TimeSpan.FromMilliseconds(this.liveOptions.Value.MemoryReaderFrequency)) - { - return cachedData.Data; - } - - var data = await task(cancellationToken); - if (data is null) - { - return data; - } - - cachedData.SetData(data); - return cachedData.Data; - } -} diff --git a/Daybreak/Services/Scanner/GuildwarsMemoryReader.cs b/Daybreak/Services/Scanner/GuildwarsMemoryReader.cs deleted file mode 100644 index fb44b399..00000000 --- a/Daybreak/Services/Scanner/GuildwarsMemoryReader.cs +++ /dev/null @@ -1,767 +0,0 @@ -using Daybreak.Shared.Models.Builds; -using Daybreak.Shared.Models.Guildwars; -using Daybreak.Shared.Models.Interop; -using Daybreak.Shared.Models.Metrics; -using Daybreak.Shared.Services.Metrics; -using Daybreak.Shared.Services.Scanner; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Core.Extensions; -using System.Diagnostics; -using System.Diagnostics.Metrics; -using System.Extensions; -using System.Extensions.Core; -using System.Linq; -using System.Logging; -using System.Threading; -using System.Threading.Tasks; - -namespace Daybreak.Services.Scanner; - -public sealed class GuildwarsMemoryReader( - IMemoryScanner memoryScanner, - IMetricsService metricsService, - ILogger logger) : IGuildwarsMemoryReader -{ - private const int RetryInitializationCount = 5; - private const string LatencyMeterName = "Memory Reader Latency"; - private const string LatencyMeterUnitsName = "Milliseconds"; - private const string LatencyMeterDescription = "Amount of milliseconds elapsed while reading memory. P95 aggregation"; - - private readonly IMemoryScanner memoryScanner = memoryScanner.ThrowIfNull(); - private readonly Histogram latencyMeter = metricsService.ThrowIfNull().CreateHistogram(LatencyMeterName, LatencyMeterUnitsName, LatencyMeterDescription, AggregationTypes.P95); - private readonly ILogger logger = logger.ThrowIfNull(); - - private uint globalContextPointer; - private uint instanceInfoPointer; - private uint entityArrayPointer; - - public async Task EnsureInitialized(uint processId, CancellationToken cancellationToken) - { - var scoppedLogger = this.logger.CreateScopedLogger(); - var currentGuildwarsProcess = Process.GetProcessById((int)processId); - if (currentGuildwarsProcess is null) - { - scoppedLogger.LogWarning($"Process is null. {nameof(GuildwarsMemoryReader)} will not start"); - return; - } - - try - { - if (cancellationToken.IsCancellationRequested) - { - throw new TaskCanceledException(); - } - - await this.InitializeSafe(currentGuildwarsProcess, scoppedLogger); - } - catch (Exception e) - { - scoppedLogger.LogError(e, "Encountered exception during initialization"); - } - } - - public void Stop() - { - var scoppedLogger = this.logger.CreateScopedLogger(); - scoppedLogger.LogInformation($"Stopping {nameof(GuildwarsMemoryReader)}"); - this.memoryScanner?.EndScanner(); - } - - public Task ReadLoginData(CancellationToken cancellationToken) - { - if (this.memoryScanner.Scanning is false) - { - return Task.FromResult(default); - } - - return Task.Run(() => this.SafeReadGameMemory(this.ReadLoginDataInternal), cancellationToken); - } - - public Task ReadWorldData(CancellationToken cancellationToken) - { - if (this.memoryScanner.Scanning is false) - { - return Task.FromResult(default); - } - - return Task.Run(() => this.SafeReadGameMemory(this.ReadWorldDataInternal), cancellationToken); - } - - public Task IsInitialized(uint processId, CancellationToken cancellationToken) - { - return Task.FromResult(this.memoryScanner.Scanning && processId == this.memoryScanner.Process?.Id); - } - - public Task ReadUserData(CancellationToken cancellationToken) - { - if (this.memoryScanner.Scanning is false) - { - return Task.FromResult(default); - } - - return Task.Run(() => this.SafeReadGameMemory(this.ReadUserDataInternal), cancellationToken); - } - - public Task ReadSessionData(CancellationToken cancellationToken) - { - if (this.memoryScanner.Scanning is false) - { - return Task.FromResult(default); - } - - return Task.Run(() => this.SafeReadGameMemory(this.ReadSessionDataInternal), cancellationToken); - } - - public Task ReadMainPlayerData(CancellationToken cancellationToken) - { - if (this.memoryScanner.Scanning is false) - { - return Task.FromResult(default); - } - - return Task.Run(() => this.SafeReadGameMemory(this.ReadMainPlayerDataInternal), cancellationToken); - } - - public Task ReadTeamBuildData(CancellationToken cancellationToken) - { - if (this.memoryScanner.Scanning is false) - { - return Task.FromResult(default); - } - - return Task.Run(() => this.SafeReadGameMemory(this.ReadTeamBuildDataInternal), cancellationToken); - } - - private async Task InitializeSafe(Process process, ScopedLogger scopedLogger) - { - if (this.memoryScanner.Process is null || - this.memoryScanner.Process.HasExited || - this.memoryScanner.Process.MainModule?.FileName != process?.MainModule?.FileName) - { - if (this.memoryScanner.Scanning) - { - scopedLogger.LogInformation("Scanner is already scanning a different process. Restart scanner and target the new process"); - this.globalContextPointer = 0x0; - this.instanceInfoPointer = 0x0; - this.entityArrayPointer = 0x0; - this.memoryScanner.EndScanner(); - } - - scopedLogger.LogInformation($"Initializing {nameof(GuildwarsMemoryReader)}"); - await this.ResilientBeginScanner(scopedLogger, process!); - } - - if (this.memoryScanner.Scanning is false && - process?.HasExited is false) - { - scopedLogger.LogInformation($"Initializing {nameof(GuildwarsMemoryReader)}"); - await this.ResilientBeginScanner(scopedLogger, process!); - } - } - - private async Task ResilientBeginScanner(ScopedLogger scopedLogger, Process process) - { - for (var i = 0; i < RetryInitializationCount; i++) - { - if (this.memoryScanner.Scanning && process.Id == this.memoryScanner.Process?.Id) - { - scopedLogger.LogInformation("Scanner already initialized"); - return; - } - - if (this.memoryScanner.Scanning) - { - this.memoryScanner.EndScanner(); - this.globalContextPointer = 0x0; - this.instanceInfoPointer = 0x0; - this.entityArrayPointer = 0x0; - } - - try - { - scopedLogger.LogInformation("Initializing scanner"); - this.memoryScanner.BeginScanner(process!); - break; - } - catch (Exception e) - { - scopedLogger.LogError(e, "Error during initialization"); - await Task.Delay(1000); - } - } - } - - private T? SafeReadGameMemory(Func readingFunction) - { - try - { - var stopWatch = Stopwatch.StartNew(); - var ret = readingFunction(); - stopWatch.Stop(); - this.latencyMeter.Record(stopWatch.Elapsed.TotalMilliseconds); - - return ret; - } - catch (Exception e) - { - this.logger.LogError(e, "Exception encountered when reading game memory"); - return default; - } - } - - private UserData? ReadUserDataInternal() - { - var maybeGlobalContext = this.GetGlobalContext(); - if (!maybeGlobalContext.HasValue) - { - return default; - } - - var globalContext = maybeGlobalContext.Value; - var gameContext = this.memoryScanner.Read(globalContext.GameContext, GameContext.BaseOffset); - var userContext = this.memoryScanner.Read(globalContext.UserContext, UserContext.BaseOffset); - return new UserData - { - User = new UserInformation - { - Email = userContext.PlayerEmailFirstChar + userContext.PlayerEmailSecondChar + userContext.PlayerEmailRemaining, - CurrentBalthazarPoints = gameContext.CurrentBalthazar, - CurrentImperialPoints = gameContext.CurrentImperial, - CurrentKurzickPoints = gameContext.CurrentKurzick, - CurrentLuxonPoints = gameContext.CurrentLuxon, - CurrentSkillPoints = gameContext.CurrentSkillPoints, - MaxBalthazarPoints = gameContext.MaxBalthazar, - MaxImperialPoints = gameContext.MaxImperial, - MaxKurzickPoints = gameContext.MaxKurzick, - MaxLuxonPoints = gameContext.MaxLuxon, - TotalBalthazarPoints = gameContext.TotalBalthazar, - TotalImperialPoints = gameContext.TotalImperial, - TotalKurzickPoints = gameContext.TotalKurzick, - TotalLuxonPoints = gameContext.TotalLuxon, - TotalSkillPoints = gameContext.TotalSkillPoints - } - }; - } - - private LoginData? ReadLoginDataInternal() - { - var maybeGlobalContext = this.GetGlobalContext(); - if (!maybeGlobalContext.HasValue) - { - return default; - } - - var globalContext = maybeGlobalContext.Value; - var userContext = this.memoryScanner.Read(globalContext.UserContext, UserContext.BaseOffset); - return new LoginData - { - Email = userContext.PlayerEmailFirstChar + (userContext.PlayerEmailSecondChar + userContext.PlayerEmailRemaining), - PlayerName = userContext.PlayerName - }; - } - - private WorldData? ReadWorldDataInternal() - { - var maybeGlobalContext = this.GetGlobalContext(); - if (!maybeGlobalContext.HasValue) - { - return default; - } - - var globalContext = maybeGlobalContext.Value; - var userContext = this.memoryScanner.Read(globalContext.UserContext, UserContext.BaseOffset); - var instanceInfo = this.memoryScanner.ReadPtrChain(this.GetInstanceInfoPointer(), 0x0, 0x0); - var areaInfo = this.memoryScanner.Read(instanceInfo.AreaInfo); - - _ = Map.TryParse((int)userContext.MapId, out var map); - _ = Region.TryParse((int)areaInfo.RegionId, out var region); - _ = Continent.TryParse((int)areaInfo.ContinentId, out var continent); - _ = Campaign.TryParse((int)areaInfo.CampaignId, out var campaign); - - return new WorldData - { - Campaign = campaign, - Continent = continent, - Region = region, - Map = map - }; - } - - private SessionData? ReadSessionDataInternal() - { - var maybeGlobalContext = this.GetGlobalContext(); - if (!maybeGlobalContext.HasValue) - { - return default; - } - - var maybeInstanceInfoContext = this.GetInstanceInfoContext(); - if (!maybeInstanceInfoContext.HasValue) - { - return default; - } - - var globalContext = maybeGlobalContext.Value; - var instanceInfoContext = maybeInstanceInfoContext.Value; - var gameContext = this.memoryScanner.Read(globalContext.GameContext, GameContext.BaseOffset); - var userContext = this.memoryScanner.Read(globalContext.UserContext, UserContext.BaseOffset); - var instanceContext = this.memoryScanner.Read(globalContext.InstanceContext, InstanceContext.BaseOffset); - _ = Map.TryParse((int)userContext.MapId, out var currentMap); - return new SessionData - { - Session = new SessionInformation - { - CurrentMap = currentMap, - FoesKilled = gameContext.FoesKilled, - FoesToKill = gameContext.FoesToKill, - InstanceTimer = instanceContext.Timer, - InstanceType = instanceInfoContext.InstanceType switch - { - Shared.Models.Interop.InstanceType.Explorable => Shared.Models.Guildwars.InstanceType.Explorable, - Shared.Models.Interop.InstanceType.Loading => Shared.Models.Guildwars.InstanceType.Loading, - Shared.Models.Interop.InstanceType.Outpost => Shared.Models.Guildwars.InstanceType.Outpost, - _ => Shared.Models.Guildwars.InstanceType.Undefined - } - } - }; - } - - private MainPlayerData? ReadMainPlayerDataInternal() - { - var maybeGlobalContext = this.GetGlobalContext(); - if (!maybeGlobalContext.HasValue) - { - return default; - } - - var maybeEntityArray = this.GetEntityArray(); - if (!maybeEntityArray.HasValue) - { - return default; - } - - var globalContext = maybeGlobalContext.Value; - var entityArray = maybeEntityArray.Value; - var gameContext = this.memoryScanner.Read(globalContext.GameContext, GameContext.BaseOffset); - var instanceContext = this.memoryScanner.Read(globalContext.InstanceContext, InstanceContext.BaseOffset); - var userContext = this.memoryScanner.Read(globalContext.UserContext, UserContext.BaseOffset); - var playerControlledCharContext = this.memoryScanner.Read(gameContext.PlayerControlledChar, PlayerControlledCharContext.BaseOffset); - var playerContexts = this.memoryScanner.ReadArray(gameContext.Players); - var mapEntityContexts = this.memoryScanner.ReadArray(gameContext.MapEntities); - var professionContexts = this.memoryScanner.ReadArray(gameContext.Professions); - var questContexts = this.memoryScanner.ReadArray(gameContext.QuestLog); - var skillBarContexts = this.memoryScanner.ReadArray(gameContext.Skillbars); - var partyAttributesContexts = this.memoryScanner.ReadArray(gameContext.PartyAttributes); - var titleContexts = this.memoryScanner.ReadArray(gameContext.Titles); - var titleTiersContexts = this.memoryScanner.ReadArray(gameContext.TitlesTiers); - var entityContextPtrs = this.memoryScanner.ReadArray(entityArray.Buffer, entityArray.Size); - var entityContexts = entityContextPtrs.Select(this.memoryScanner.Read).ToArray(); - - var playerId = playerControlledCharContext.AgentId; - var mapEntity = mapEntityContexts.Skip((int)playerId).FirstOrDefault(); - var playerContext = playerContexts.FirstOrDefault(p => p.AgentId == playerId); - var professionContext = professionContexts.FirstOrDefault(p => p.AgentId == playerId); - var skillbarContext = skillBarContexts.FirstOrDefault(p => p.AgentId == playerId); - var partyAttributesContext = partyAttributesContexts.FirstOrDefault(p => p.AgentId == playerId); - var entityContext = entityContexts.FirstOrDefault(p => p.AgentId == playerId); - var mainPlayerInformation = this.GetMainPlayerInformation( - gameContext, - instanceContext, - playerContext, - mapEntity, - professionContext, - questContexts, - skillbarContext, - partyAttributesContext, - titleContexts, - titleTiersContexts, - entityContext); - return new MainPlayerData - { - PlayerInformation = mainPlayerInformation - }; - } - - private TeamBuildData? ReadTeamBuildDataInternal() - { - var maybeGlobalContext = this.GetGlobalContext(); - if (!maybeGlobalContext.HasValue) - { - return default; - } - - var maybeEntityArray = this.GetEntityArray(); - if (!maybeEntityArray.HasValue) - { - return default; - } - - var globalContext = maybeGlobalContext.Value; - var entityArray = maybeEntityArray.Value; - var gameContext = this.memoryScanner.Read(globalContext.GameContext, GameContext.BaseOffset); - var instanceContext = this.memoryScanner.Read(globalContext.InstanceContext, InstanceContext.BaseOffset); - var userContext = this.memoryScanner.Read(globalContext.UserContext, UserContext.BaseOffset); - var playerControlledCharContext = this.memoryScanner.Read(gameContext.PlayerControlledChar, PlayerControlledCharContext.BaseOffset); - var partyContext = this.memoryScanner.Read(globalContext.PartyContext); - var playerPartyContext = this.memoryScanner.Read(partyContext.PlayerParty); - var partyPlayers = this.memoryScanner.ReadArray(playerPartyContext.Players); - var partyHeroes = this.memoryScanner.ReadArray(playerPartyContext.Heroes); - var partyHench = this.memoryScanner.ReadArray(playerPartyContext.Henchmen); - - var playerContexts = this.memoryScanner.ReadArray(gameContext.Players); - var mapEntityContexts = this.memoryScanner.ReadArray(gameContext.MapEntities); - var professionContexts = this.memoryScanner.ReadArray(gameContext.Professions); - var questContexts = this.memoryScanner.ReadArray(gameContext.QuestLog); - var skillBarContexts = this.memoryScanner.ReadArray(gameContext.Skillbars); - var partyAttributesContexts = this.memoryScanner.ReadArray(gameContext.PartyAttributes); - var titleContexts = this.memoryScanner.ReadArray(gameContext.Titles); - var titleTiersContexts = this.memoryScanner.ReadArray(gameContext.TitlesTiers); - var entityContextPtrs = this.memoryScanner.ReadArray(entityArray.Buffer, entityArray.Size); - var entityContexts = entityContextPtrs.Select(this.memoryScanner.Read).ToArray(); - - var entityAggregateWithBuilds = professionContexts - .Select(p => (p.AgentId, p, partyAttributesContexts.FirstOrDefault(pa => pa.AgentId == p.AgentId), skillBarContexts.FirstOrDefault(s => s.AgentId == p.AgentId))) - .Select(t => (t.AgentId, GetEntityBuild(t.p, t.Item3, t.Item4))) - .Where(t => t.Item2 is not null) - .OfType<(uint AgentId, Build Build)>(); - - - return new TeamBuildData - { - TeamBuildPlayers = [.. entityAggregateWithBuilds.Select<(uint AgentId, Build Build), TeamBuildPlayerData>(t => - { - var agentId = t.AgentId; - var build = t.Build; - if (agentId == playerControlledCharContext.AgentId) - { - return new TeamBuildMainPlayerData { Build = build }; - } - - if (partyHeroes.FirstOrDefault(h => h.AgentId == agentId) is HeroPartyMember heroPartyMember && - heroPartyMember.HeroId != 0 && - Hero.TryParse((int)heroPartyMember.HeroId, out var hero)) - { - - return new TeamBuildHeroData { Build = build, Hero = hero }; - } - - if (partyHench.FirstOrDefault(h => h.AgentId == agentId) is HenchmanPartyMember henchmanPartyMember && - henchmanPartyMember.AgentId != 0) - { - return new TeamBuildHenchmanData { Build = build }; - } - - return new TeamBuildPartyMemberData { Build = build }; - }).OrderBy(t => t is TeamBuildMainPlayerData ? 0 : 1)] - }; - } - - private GlobalContext? GetGlobalContext() - { - var basePtr = this.GetGlobalContextPointer(); - var baseContextAddress = this.memoryScanner.Read(basePtr); - var gameContextPtr = this.memoryScanner.Read(baseContextAddress + (6 * 4)); - var globalContext = this.memoryScanner.Read(gameContextPtr); - return globalContext; - } - - private InstanceInfoContext? GetInstanceInfoContext() - { - var ptr = this.GetInstanceInfoPointer(); - var instanceInfoPtr = this.memoryScanner.Read(ptr); - var instanceInfo = this.memoryScanner.Read(instanceInfoPtr); - return instanceInfo; - } - - private GenericGuildwarsArray? GetEntityArray() - { - var ptr = this.GetEntityArrayPointer(); - var entityArrayPtr = this.memoryScanner.Read(ptr); - var entityArray = this.memoryScanner.Read(entityArrayPtr); - return entityArray; - } - - private uint GetGlobalContextPointer() - { - if (this.globalContextPointer == 0) - { - var ptr = this.memoryScanner.ScanForPtr([0x50, 0x6A, 0x0F, 0x6A, 0x00, 0xFF, 0x35], "xxxxxxx") + 0x7; - this.globalContextPointer = this.memoryScanner.Read(ptr); - } - - return this.globalContextPointer; - } - - private uint GetInstanceInfoPointer() - { - if (this.instanceInfoPointer == 0) - { - this.instanceInfoPointer = this.memoryScanner.ScanForPtr([0x6A, 0x2C, 0x50, 0xE8, 0x00, 0x00, 0x00, 0x00, 0x83, 0xC4, 0x08, 0xC7], "xxxx????xxxx") + 0xD; - } - - return this.instanceInfoPointer; - } - - private uint GetEntityArrayPointer() - { - if (this.entityArrayPointer == 0) - { - this.entityArrayPointer = this.memoryScanner.ScanForPtr([0x8B, 0x0C, 0x90, 0x85, 0xC9, 0x74, 0x19], "xxxxxxx") - 0x4; - } - - return this.entityArrayPointer; - } - - private MainPlayerInformation GetMainPlayerInformation( - GameContext gameContext, - InstanceContext instanceContext, - PlayerContext playerContext, - MapEntityContext mapEntity, - ProfessionsContext professionContext, - QuestContext[] quests, - SkillbarContext skillbarContext, - PartyAttributesContext partyAttributesContext, - TitleContext[] titles, - TitleTierContext[] titleTiers, - EntityContext entityContext) - { - var playerInformation = this.GetWorldPlayerInformation(playerContext, instanceContext, mapEntity, professionContext, skillbarContext, partyAttributesContext, titles, titleTiers, entityContext); - _ = Quest.TryParse((int)gameContext.QuestId, out var quest); - var questLog = quests - .Select(q => - { - _ = Quest.TryParse((int)q.QuestId, out var parsedQuest); - _ = Map.TryParse((int)q.MapFrom, out var mapFrom); - _ = Map.TryParse((int)q.MapTo, out var mapTo); - return new QuestMetadata - { - Quest = parsedQuest, - From = mapFrom, - To = mapTo, - Position = float.IsFinite(q.Marker.X) && float.IsFinite(q.Marker.Y) ? - new Position { X = q.Marker.X, Y = q.Marker.Y } : - default - }; - }) - .Where(q => q.Quest is not null) - .ToList(); - - return new MainPlayerInformation - { - Id = playerInformation.Id, - Timer = playerInformation.Timer, - Position = playerInformation.Position, - PrimaryProfession = playerInformation.PrimaryProfession, - SecondaryProfession = playerInformation.SecondaryProfession, - UnlockedProfession = playerInformation.UnlockedProfession, - HardModeUnlocked = gameContext.HardModeUnlocked == 1, - CurrentEnergy = playerInformation.CurrentEnergy, - CurrentHealth = playerInformation.CurrentHealth, - MaxEnergy = playerInformation.MaxEnergy, - MaxHealth = playerInformation.MaxHealth, - EnergyRegen = playerInformation.EnergyRegen, - HealthRegen = playerInformation.HealthRegen, - Quest = quest, - QuestLog = questLog, - Name = playerInformation.Name, - Experience = gameContext.Experience, - Level = (int)gameContext.Level, - Morale = gameContext.Morale, - CurrentBuild = playerInformation.CurrentBuild, - TitleInformation = playerInformation.TitleInformation - }; - } - - private WorldPlayerInformation GetWorldPlayerInformation( - PlayerContext playerContext, - InstanceContext instanceContext, - MapEntityContext mapEntity, - ProfessionsContext profession, - SkillbarContext skillbar, - PartyAttributesContext partyAttributes, - TitleContext[] titles, - TitleTierContext[] titleTiers, - EntityContext entity) - { - var name = this.memoryScanner.Read(playerContext.NamePointer); - var playerInformation = GetPlayerInformation(playerContext.AgentId, instanceContext, mapEntity, profession, skillbar, partyAttributes, entity); - var maybeCurrentTitleContext = (TitleContext?)null; - var maybeCurrentTitle = (Title?)null; - for (var i = 0; i < titles.Length; i++) - { - var title = titles[i]; - if (title.CurrentTitleTierIndex == playerContext.ActiveTitleTier) - { - maybeCurrentTitleContext = title; - _ = Title.TryParse(i, out maybeCurrentTitle); - break; - } - } - - var maybeTitleTier = (TitleTierContext?)null; - if (maybeCurrentTitleContext is TitleContext currentTitle) - { - maybeTitleTier = titleTiers[currentTitle.CurrentTitleTierIndex]; - } - - var titleInformation = (TitleInformation?)null; - if (maybeCurrentTitleContext is not null && maybeTitleTier is not null) - { - titleInformation = new TitleInformation - { - CurrentPoints = maybeCurrentTitleContext?.CurrentPoints, - IsPercentage = maybeCurrentTitleContext?.IsPercentage, - PointsForCurrentRank = maybeCurrentTitleContext?.PointsNeededForCurrentRank, - PointsForNextRank = maybeTitleTier?.TierNumber == maybeCurrentTitleContext?.MaxTitleRank ? - maybeCurrentTitleContext?.CurrentPoints : - maybeCurrentTitleContext?.PointsNeededForNextRank, - TierNumber = maybeTitleTier?.TierNumber, - MaxTierNumber = maybeCurrentTitleContext?.MaxTitleRank, - Title = maybeCurrentTitle - }; - } - - return new WorldPlayerInformation - { - Id = playerInformation.Id, - Timer = playerInformation.Timer, - Level = playerInformation.Level, - Position = playerInformation.Position, - PrimaryProfession = playerInformation.PrimaryProfession, - SecondaryProfession = playerInformation.SecondaryProfession, - UnlockedProfession = playerInformation.UnlockedProfession, - CurrentEnergy = playerInformation.CurrentEnergy, - CurrentHealth = playerInformation.CurrentHealth, - MaxEnergy = playerInformation.MaxEnergy, - MaxHealth = playerInformation.MaxHealth, - EnergyRegen = playerInformation.EnergyRegen, - HealthRegen = playerInformation.HealthRegen, - Name = name, - CurrentBuild = playerInformation.CurrentBuild, - TitleInformation = titleInformation, - }; - } - - private static PlayerInformation GetPlayerInformation( - int playerId, - InstanceContext instanceContext, - MapEntityContext mapEntity, - ProfessionsContext profession, - SkillbarContext skillbar, - PartyAttributesContext partyAttributes, - EntityContext entity) - { - var build = GetEntityBuild(profession, partyAttributes, skillbar); - var unlockedProfessions = Profession.Professions - .Where(p => profession.ProfessionUnlocked(p.Id)) - .Append(build?.Primary) - .OfType() - .Where(p => p != Profession.None) - .OrderBy(p => p.Id) - .ToList(); - - - (var currentHp, var currentEnergy) = ApplyEnergyAndHealthRegen(instanceContext, mapEntity); - if (!Npc.TryParse(entity.EntityModelType, out var npc)) - { - npc = Npc.Unknown; - } - - return new PlayerInformation - { - Id = playerId, - Timer = entity.Timer, - Level = entity.Level, - NpcDefinition = npc, - ModelType = entity.EntityModelType, - PrimaryProfession = build?.Primary ?? Profession.None, - SecondaryProfession = build?.Secondary ?? Profession.None, - UnlockedProfession = unlockedProfessions, - CurrentHealth = currentHp, - CurrentEnergy = currentEnergy, - MaxHealth = mapEntity.MaxHealth, - MaxEnergy = mapEntity.MaxEnergy, - HealthRegen = mapEntity.HealthRegen, - EnergyRegen = mapEntity.EnergyRegen, - CurrentBuild = build, - Position = new Position - { - X = entity.Position.X, - Y = entity.Position.Y - } - }; - } - - private static Build? GetEntityBuild( - ProfessionsContext professionsContext, - PartyAttributesContext partyAttributesContext, - SkillbarContext skillbarContext) - { - _ = Profession.TryParse((int)professionsContext.CurrentPrimary, out var primaryProfession); - _ = Profession.TryParse((int)professionsContext.CurrentSecondary, out var secondaryProfession); - - Build? build = default; - if (primaryProfession is not null && - secondaryProfession is not null) - { - var attributes = (primaryProfession.PrimaryAttribute is null ? - [] : - new List { primaryProfession.PrimaryAttribute! }) - .Concat(primaryProfession.Attributes) - .Concat(secondaryProfession.Attributes) - .Select(a => new AttributeEntry { Attribute = a }) - .ToList(); - foreach(var entry in attributes) - { - var attributesContext = partyAttributesContext.Attributes.FirstOrDefault(p => p.Id == entry.Attribute?.Id); - if (attributesContext.IncrementPoints == 0 && attributesContext.DecrementPoints == 0) - { - continue; - } - - entry.Points = (int)attributesContext.BaseLevel; - } - - var skillContexts = new SkillContext[] - { - skillbarContext.Skill0, - skillbarContext.Skill1, - skillbarContext.Skill2, - skillbarContext.Skill3, - skillbarContext.Skill4, - skillbarContext.Skill5, - skillbarContext.Skill6, - skillbarContext.Skill7, - }; - - build = new Build - { - Primary = primaryProfession, - Secondary = secondaryProfession, - Attributes = attributes, - Skills = [.. skillContexts.Select(s => - Skill.TryParse((int)s.Id, out var parsedSkill) ? - parsedSkill : - Skill.NoSkill)] - }; - } - - return build; - } - - private static (float CurrentHp, float CurrentEnergy) ApplyEnergyAndHealthRegen(InstanceContext instanceContext, MapEntityContext entityContext) - { - var lastKnownHp = entityContext.CurrentHealth; - var lastKnownEnergy = entityContext.CurrentEnergy; - var hpRegen = entityContext.HealthRegen; - var energyRegen = entityContext.EnergyRegen; - var millisSinceLastKnownInformation = instanceContext.Timer - (uint)entityContext.SkillTimestamp; - var currentHp = lastKnownHp + (hpRegen * ((float)millisSinceLastKnownInformation / 1000)); - var currentEnergy = lastKnownEnergy + (energyRegen * ((float)millisSinceLastKnownInformation / 1000)); - return ( - currentHp > entityContext.MaxHealth ? entityContext.MaxHealth : currentHp, - currentEnergy > entityContext.MaxEnergy ? entityContext.MaxEnergy : currentEnergy); - } -} diff --git a/Daybreak/Services/Scanner/MemoryScanner.cs b/Daybreak/Services/Scanner/MemoryScanner.cs deleted file mode 100644 index e98a017e..00000000 --- a/Daybreak/Services/Scanner/MemoryScanner.cs +++ /dev/null @@ -1,380 +0,0 @@ -using Daybreak.Shared.Models.Interop; -using Daybreak.Shared.Services.Scanner; -using Daybreak.Shared.Utils; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Core.Extensions; -using System.Diagnostics; -using System.Linq; -using System.Runtime.InteropServices; -using System.Text; -using System.Threading; - -namespace Daybreak.Services.Scanner; - -internal sealed class MemoryScanner( - ILogger logger) : IMemoryScanner -{ - private const int LockTimeout = 1000; - - private const double MaximumReadSize = 10e8; - private const double MaximumArraySize = 10e5; - - private readonly SemaphoreSlim semaphoreSlim = new(1); - private readonly ILogger logger = logger.ThrowIfNull(); - - public uint ModuleStartAddress { get; private set; } - public byte[]? Memory { get; private set; } - public uint Size { get; private set; } - public bool Scanning - { - get; - private set - { - if (!this.semaphoreSlim.Wait(LockTimeout)) - { - return; - } - - field = value; - this.semaphoreSlim.Release(); - } - } - public Process? Process { get; private set; } - - public void BeginScanner(Process process) - { - _ = process.ThrowIfNull(); - - - if (this.Scanning && process.Id == this.Process?.Id) - { - throw new InvalidOperationException("Scanner is already running"); - } - - if (!this.semaphoreSlim.Wait(LockTimeout)) - { - throw new InvalidOperationException("Unable to start scanner. Scanner is locked"); - } - - this.Process = process; - (var startAddress, var size) = this.GetModuleInfo(process); - if (startAddress == 0 && - size == 0) - { - this.semaphoreSlim.Release(); - return; - } - - this.ModuleStartAddress = startAddress; - this.Size = size; - this.Memory = this.ReadBytesNonLocking(this.ModuleStartAddress, this.Size); - - this.semaphoreSlim.Release(); - this.Scanning = true; - } - - public void EndScanner() - { - if (!this.Scanning) - { - return; - } - - if (!this.semaphoreSlim.Wait(LockTimeout)) - { - throw new InvalidOperationException("Unable to stop scanner. Scanner is locked"); - } - - this.Memory = default; - this.Size = default; - this.ModuleStartAddress = default; - this.semaphoreSlim.Release(); - this.Scanning = false; - } - - public T Read(GuildwarsPointer pointer, uint offset = 0) - { - if (pointer is GuildwarsPointer) - { - return (T)(this.ReadWString(pointer.Address, 256) as object); - } - - return this.Read(pointer.Address + offset); - } - - public T Read(uint address) - { - this.ValidateReadScanner(); - var size = Marshal.SizeOf(typeof(T)); - var buffer = Marshal.AllocHGlobal(size); - - NativeMethods.ReadProcessMemory(this.Process!.Handle, - address, - buffer, - (uint)size, - out _ - ); - - var ret = (T)Marshal.PtrToStructure(buffer, typeof(T))!; - Marshal.FreeHGlobal(buffer); - - return ret; - } - - public T[] ReadArray(uint address, uint size) - { - this.ValidateReadScanner(); - if (size > MaximumArraySize) - { - throw new InvalidOperationException($"Expected size to read is too large. Array size {size}"); - } - - var itemSize = Marshal.SizeOf(typeof(T)); - var readSize = (int)size * itemSize; - if (readSize > MaximumReadSize) - { - throw new InvalidOperationException($"Expected size to read is too large. Size {readSize}"); - } - - var buffer = Marshal.AllocHGlobal((int)readSize); - - NativeMethods.ReadProcessMemory(this.Process!.Handle, - address, - buffer, - (uint)readSize, - out _ - ); - - var retArray = new T[size]; - var arrayPointer = buffer; - for (var i = 0; i < size; i++) - { - retArray[i] = (T)Marshal.PtrToStructure(arrayPointer, typeof(T))!; - arrayPointer += itemSize; - } - - Marshal.FreeHGlobal(buffer); - return retArray; - } - - public T[] ReadArray(GuildwarsArray guildwarsArray) - { - return this.ReadArray(guildwarsArray.Buffer.Address, guildwarsArray.Size); - } - - public T[] ReadArray(GuildwarsPointerArray guildwarsPointerArray) - { - var ptrs = this.ReadArray(guildwarsPointerArray.Buffer.Address, guildwarsPointerArray.Size); - var retList = new List(); - foreach (var ptr in ptrs) - { - retList.Add(this.Read(ptr)); - } - - return [.. retList]; - } - - public byte[]? ReadBytes(uint address, uint size) - { - this.ValidateReadScanner(); - return this.ReadBytesNonLocking(address, size); - } - - public string ReadWString(uint address, uint maxsize) - { - this.ValidateReadScanner(); - var rawbytes = this.ReadBytes(address, maxsize); - if (rawbytes == null) - { - return ""; - } - - var ret = Encoding.Unicode.GetString(rawbytes); - if (ret.Contains('\0')) - { - ret = ret[..ret.IndexOf('\0')]; - } - - return ret; - } - - public T ReadPtrChain(uint Base, uint finalPointerOffset = 0, params uint[] offsets) - { - this.ValidateReadScanner(); - foreach (var offset in offsets) - { - Base = this.Read(Base + offset); - } - - return this.Read(Base + finalPointerOffset); - } - - public uint ScanForPtr(byte[] pattern, string? mask = default, bool readptr = false) - { - this.ValidateReadScanner(); - if (pattern?.Length == 0) - { - throw new ArgumentException("Pattern cannot be empty"); - } - - for (var scan = 0U; scan < this.Size; ++scan) - { - if (this.Memory![scan] != pattern![0]) - { - continue; - } - - var matched = true; - for (var patternIndex = 0; patternIndex < pattern.Length; ++patternIndex) - { - if (mask is not null && - mask.Length > patternIndex && - mask[patternIndex] == '?') - { - continue; - } - - var memoryValue = this.Memory[scan + patternIndex]; - var signatureValue = pattern[patternIndex]; - if (memoryValue != signatureValue) - { - matched = false; - break; - } - } - - if (matched) - { - if (readptr) - { - return BitConverter.ToUInt32(this.Memory, (int)scan); - } - - return this.ModuleStartAddress + scan; - } - } - - return 0; - } - - public uint ScanForAssertion(string? assertionFile, string? assertionMessage) - { - this.ValidateReadScanner(); - var mask = new StringBuilder(64); - for (var i = 0; i < 64; i++) - { - mask.Append('\0'); - } - - var assertionBytes = new byte[] { 0xBA, 0x0, 0x0, 0x0, 0x0, 0xB9, 0x0, 0x0, 0x0, 0x0 }; - var assertionMask = "x????x????"; - if (assertionMessage is not null) - { - var assertionMessageBytes = Encoding.ASCII.GetBytes(assertionMessage); - for (var i = 0; i < assertionMessage.Length; i++) - { - mask[i] = 'x'; - } - - mask[assertionMessage.Length] = 'x'; - mask[assertionMessage.Length + 1] = '\0'; - var rdataPtr = this.ScanForPtr(assertionMessageBytes, mask.ToString(), false); - if (rdataPtr == 0) - { - return 0; - } - - assertionBytes[6] = (byte)rdataPtr; - assertionBytes[7] = (byte)(rdataPtr >> 8); - assertionBytes[8] = (byte)(rdataPtr >> 16); - assertionBytes[9] = (byte)(rdataPtr >> 24); - } - - if (assertionFile is not null) - { - var assertionFileBytes = Encoding.ASCII.GetBytes(assertionFile); - for (var i = 0; i < assertionFile.Length; i++) - { - mask[i] = 'x'; - } - - mask[assertionFile.Length] = 'x'; - mask[assertionFile.Length + 1] = '\0'; - var rdataPtr = this.ScanForPtr(assertionFileBytes, mask.ToString(), false); - - assertionBytes[1] = (byte)rdataPtr; - assertionBytes[2] = (byte)(rdataPtr >> 8); - assertionBytes[3] = (byte)(rdataPtr >> 16); - assertionBytes[4] = (byte)(rdataPtr >> 24); - } - - return this.ScanForPtr(assertionBytes, assertionMask, false); - } - - private byte[]? ReadBytesNonLocking(uint address, uint size) - { - if (size > MaximumReadSize) - { - throw new InvalidOperationException($"Expected size to read is too large. Size {size}"); - } - - var buffer = Marshal.AllocHGlobal((int)size); - - NativeMethods.ReadProcessMemory(this.Process!.Handle, - address, - buffer, - size, - out _ - ); - - var ret = new byte[size]; - Marshal.Copy(buffer, ret, 0, (int)size); - Marshal.FreeHGlobal(buffer); - - return ret; - } - - private void ValidateReadScanner() - { - if (!this.Scanning) - { - throw new InvalidOperationException("Scanner is not running"); - } - - if (this.Memory is null) - { - throw new InvalidOperationException("Scanner is running but memory is not initialized"); - } - - if (this.Process is null || - this.Process?.HasExited is true) - { - throw new InvalidOperationException("Process has exited"); - } - } - - private (uint StartAddress, uint Size) GetModuleInfo(Process process) - { - try - { - var name = process.ProcessName; - var modules = process.Modules; - foreach (var module in modules.OfType()) - { - if (module.ModuleName != null && - module.ModuleName.StartsWith(name, StringComparison.OrdinalIgnoreCase)) - { - return ((uint)module.BaseAddress.ToInt32(), (uint)module.ModuleMemorySize); - } - } - } - catch (Exception e) - { - this.logger.LogError(e, "Failed to get module info"); - } - - return (0, 0); - } -} diff --git a/Daybreak/Services/Scanner/Models/AttributePayload.cs b/Daybreak/Services/Scanner/Models/AttributePayload.cs deleted file mode 100644 index a60f519a..00000000 --- a/Daybreak/Services/Scanner/Models/AttributePayload.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Daybreak.Services.Scanner.Models; - -internal class AttributePayload -{ - public uint Id { get; set; } - public uint ActualLevel { get; set; } - public uint BaseLevel { get; set; } - public uint IncrementPoints { get; set; } - public uint DecrementPoints { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/BagContentPayload.cs b/Daybreak/Services/Scanner/Models/BagContentPayload.cs deleted file mode 100644 index d0f50cd4..00000000 --- a/Daybreak/Services/Scanner/Models/BagContentPayload.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Collections.Generic; - -namespace Daybreak.Services.Scanner.Models; -internal sealed class BagContentPayload -{ - public uint Id { get; set; } - public uint Slot { get; set; } - public uint Count { get; set; } - public List? Modifiers { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/BagPayload.cs b/Daybreak/Services/Scanner/Models/BagPayload.cs deleted file mode 100644 index 618601e9..00000000 --- a/Daybreak/Services/Scanner/Models/BagPayload.cs +++ /dev/null @@ -1,7 +0,0 @@ -using System.Collections.Generic; - -namespace Daybreak.Services.Scanner.Models; -internal class BagPayload -{ - public List? Items { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/BuildPayload.cs b/Daybreak/Services/Scanner/Models/BuildPayload.cs deleted file mode 100644 index 4c3cac35..00000000 --- a/Daybreak/Services/Scanner/Models/BuildPayload.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Collections.Generic; - -namespace Daybreak.Services.Scanner.Models; - -internal class BuildPayload -{ - public List? Attributes { get; set; } - public uint Primary { get; set; } - public uint Secondary { get; set; } - public List? Skills { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/CachedData.cs b/Daybreak/Services/Scanner/Models/CachedData.cs deleted file mode 100644 index de26a958..00000000 --- a/Daybreak/Services/Scanner/Models/CachedData.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -namespace Daybreak.Services.Scanner.Models; - -public sealed class CachedData -{ - public T? Data { get; private set; } - public DateTime SetTime { get; private set; } = DateTime.MinValue; - - public void SetData(T data) - { - this.Data = data; - this.SetTime = DateTime.Now; - } -} diff --git a/Daybreak/Services/Scanner/Models/CameraPayload.cs b/Daybreak/Services/Scanner/Models/CameraPayload.cs deleted file mode 100644 index 24b9907d..00000000 --- a/Daybreak/Services/Scanner/Models/CameraPayload.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Daybreak.Services.Scanner.Models; -internal sealed class CameraPayload -{ - public float Yaw { get; set; } - public float Pitch { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/GameDataPayload.cs b/Daybreak/Services/Scanner/Models/GameDataPayload.cs deleted file mode 100644 index d41e3ef6..00000000 --- a/Daybreak/Services/Scanner/Models/GameDataPayload.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Collections.Generic; - -namespace Daybreak.Services.Scanner.Models; -internal sealed class GameDataPayload -{ - public List? LivingEntities { get; set; } - public MainPlayerPayload? MainPlayer { get; set; } - public List? MapIcons { get; set; } - public List? Party { get; set; } - public List? WorldPlayers { get; set; } - public uint TargetId { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/GameStatePayload.cs b/Daybreak/Services/Scanner/Models/GameStatePayload.cs deleted file mode 100644 index 45a5c6fb..00000000 --- a/Daybreak/Services/Scanner/Models/GameStatePayload.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Collections.Generic; - -namespace Daybreak.Services.Scanner.Models; -internal sealed class GameStatePayload -{ - public CameraPayload? Camera { get; set; } - public List? States { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/InventoryPayload.cs b/Daybreak/Services/Scanner/Models/InventoryPayload.cs deleted file mode 100644 index 53aa5cd7..00000000 --- a/Daybreak/Services/Scanner/Models/InventoryPayload.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; - -namespace Daybreak.Services.Scanner.Models; - -internal class InventoryPayload -{ - public uint GoldInStorage { get; set; } - public uint GoldOnCharacter { get; set; } - public BagPayload? Backpack { get; set; } - public BagPayload? BeltPouch { get; set; } - public BagPayload? EquipmentPack { get; set; } - public BagPayload? MaterialStorage { get; set; } - public BagPayload? UnclaimedItems { get; set; } - public BagPayload? EquippedItems { get; set; } - public List? Bags { get; set; } - public List? StoragePanes { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/LivingEntityPayload.cs b/Daybreak/Services/Scanner/Models/LivingEntityPayload.cs deleted file mode 100644 index 008e826d..00000000 --- a/Daybreak/Services/Scanner/Models/LivingEntityPayload.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Daybreak.Services.Scanner.Models; - -internal class LivingEntityPayload -{ - public uint Id { get; set; } - public uint EntityState { get; set; } - public uint EntityAllegiance { get; set; } - public uint Level { get; set; } - public uint NpcDefinition { get; set; } - public float PosX { get; set; } - public float PosY { get; set; } - public uint PrimaryProfessionId { get; set; } - public uint SecondaryProfessionId { get; set; } - public uint Timer { get; set; } - public float Health { get; set; } - public float Energy { get; set; } - public float RotationAngle { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/LoginPayload.cs b/Daybreak/Services/Scanner/Models/LoginPayload.cs deleted file mode 100644 index e80486ce..00000000 --- a/Daybreak/Services/Scanner/Models/LoginPayload.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Daybreak.Services.Scanner.Models; -internal sealed class LoginPayload -{ - public string? Email { get; set; } - public string? PlayerName { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/MainPlayerPayload.cs b/Daybreak/Services/Scanner/Models/MainPlayerPayload.cs deleted file mode 100644 index ff748950..00000000 --- a/Daybreak/Services/Scanner/Models/MainPlayerPayload.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; - -namespace Daybreak.Services.Scanner.Models; - -internal class MainPlayerPayload : WorldPlayerPayload -{ - public uint CurrentEnergy { get; set; } - public uint CurrentHp { get; set; } - public uint CurrentQuest { get; set; } - public uint Experience { get; set; } - public bool HardModeUnlocked { get; set; } - public uint MaxHp { get; set; } - public uint MaxEnergy { get; set; } - public uint Morale { get; set; } - public List? QuestLog { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/MapIconPayload.cs b/Daybreak/Services/Scanner/Models/MapIconPayload.cs deleted file mode 100644 index 0dee713b..00000000 --- a/Daybreak/Services/Scanner/Models/MapIconPayload.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Daybreak.Services.Scanner.Models; -internal class MapIconPayload -{ - public uint Affiliation { get; set; } - public uint Id { get; set; } - public float PosX { get; set; } - public float PosY { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/MapPayload.cs b/Daybreak/Services/Scanner/Models/MapPayload.cs deleted file mode 100644 index a67eae43..00000000 --- a/Daybreak/Services/Scanner/Models/MapPayload.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Daybreak.Services.Scanner.Models; - -internal sealed class MapPayload -{ - public bool IsLoaded { get; set; } - public uint Id { get; set; } - public uint InstanceType { get; set; } - public uint Timer { get; set; } - public uint Campaign { get; set; } - public uint Continent { get; set; } - public uint Region { get; set; } - public uint Type { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/NamePayload.cs b/Daybreak/Services/Scanner/Models/NamePayload.cs deleted file mode 100644 index 95a762cf..00000000 --- a/Daybreak/Services/Scanner/Models/NamePayload.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Daybreak.Services.Scanner.Models; -internal sealed class NamePayload -{ - public int Id { get; set; } - public string? Name { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/PartyPlayerPayload.cs b/Daybreak/Services/Scanner/Models/PartyPlayerPayload.cs deleted file mode 100644 index ff9d19e9..00000000 --- a/Daybreak/Services/Scanner/Models/PartyPlayerPayload.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Collections.Generic; - -namespace Daybreak.Services.Scanner.Models; - -internal class PartyPlayerPayload : LivingEntityPayload -{ - public BuildPayload? Build { get; set; } - public List? UnlockedProfession { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/PathingMetadataPayload.cs b/Daybreak/Services/Scanner/Models/PathingMetadataPayload.cs deleted file mode 100644 index a8669155..00000000 --- a/Daybreak/Services/Scanner/Models/PathingMetadataPayload.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Daybreak.Services.Scanner.Models; -internal sealed class PathingMetadataPayload -{ - public uint TrapezoidCount { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/PathingPayload.cs b/Daybreak/Services/Scanner/Models/PathingPayload.cs deleted file mode 100644 index e1bff55e..00000000 --- a/Daybreak/Services/Scanner/Models/PathingPayload.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Collections.Generic; - -namespace Daybreak.Services.Scanner.Models; -internal sealed class PathingPayload -{ - public List? Trapezoids { get; set; } - public List>? AdjacencyList { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/PathingTrapezoidPayload.cs b/Daybreak/Services/Scanner/Models/PathingTrapezoidPayload.cs deleted file mode 100644 index ee939961..00000000 --- a/Daybreak/Services/Scanner/Models/PathingTrapezoidPayload.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Daybreak.Services.Scanner.Models; -internal sealed class PathingTrapezoidPayload -{ - public uint Id { get; set; } - public uint PathingMapId { get; set; } - public float XTL { get; set; } - public float XTR { get; set; } - public float XBL { get; set; } - public float XBR { get; set; } - public float YT { get; set; } - public float YB { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/PreGamePayload.cs b/Daybreak/Services/Scanner/Models/PreGamePayload.cs deleted file mode 100644 index 0c424d38..00000000 --- a/Daybreak/Services/Scanner/Models/PreGamePayload.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Collections.Generic; - -namespace Daybreak.Services.Scanner.Models; -internal sealed class PreGamePayload -{ - public int ChosenCharacterIndex { get; set; } - public List? Characters { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/QuestMetadataPayload.cs b/Daybreak/Services/Scanner/Models/QuestMetadataPayload.cs deleted file mode 100644 index 52dbe95d..00000000 --- a/Daybreak/Services/Scanner/Models/QuestMetadataPayload.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Daybreak.Services.Scanner.Models; - -internal class QuestMetadataPayload -{ - public uint FromId { get; set; } - public uint Id { get; set; } - public float? PosX { get; set; } - public float? PosY { get; set; } - public uint ToId { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/SessionPayload.cs b/Daybreak/Services/Scanner/Models/SessionPayload.cs deleted file mode 100644 index f4ff0164..00000000 --- a/Daybreak/Services/Scanner/Models/SessionPayload.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Daybreak.Services.Scanner.Models; -internal sealed class SessionPayload -{ - public uint FoesKilled { get; set; } - public uint FoesToKill { get; set; } - public uint MapId { get; set; } - public uint InstanceTimer { get; set; } - public uint InstanceType { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/StatePayload.cs b/Daybreak/Services/Scanner/Models/StatePayload.cs deleted file mode 100644 index 949b6e9a..00000000 --- a/Daybreak/Services/Scanner/Models/StatePayload.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Daybreak.Services.Scanner.Models; -internal sealed class StatePayload -{ - public uint Id { get; set; } - public float PosX { get; set; } - public float PosY { get; set; } - public uint State { get; set; } - public float Health { get; set; } - public float Energy { get; set; } - public float RotationAngle { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/TitleInfoPayload.cs b/Daybreak/Services/Scanner/Models/TitleInfoPayload.cs deleted file mode 100644 index 8bc3a13e..00000000 --- a/Daybreak/Services/Scanner/Models/TitleInfoPayload.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Daybreak.Services.Scanner.Models; -public sealed class TitleInfoPayload -{ - public uint CurrentPoints { get; set; } - public uint PointsNeededNextRank { get; set; } - public uint TitleId { get; set; } - public uint TitleTierId { get; set; } - public uint CurrentTier { get; set; } - public string? TitleName { get; set; } - public bool IsPercentageBased { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/TitlePayload.cs b/Daybreak/Services/Scanner/Models/TitlePayload.cs deleted file mode 100644 index 9d11194d..00000000 --- a/Daybreak/Services/Scanner/Models/TitlePayload.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Daybreak.Services.Scanner.Models; - -internal class TitlePayload -{ - public uint CurrentPoints { get; set; } - public uint Id { get; set; } - public bool IsPercentage { get; set; } - public uint MaxTierNumber { get; set; } - public uint PointsForCurrentRank { get; set; } - public uint PointsForNextRank { get; set; } - public uint TierNumber { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/UserPayload.cs b/Daybreak/Services/Scanner/Models/UserPayload.cs deleted file mode 100644 index adfad661..00000000 --- a/Daybreak/Services/Scanner/Models/UserPayload.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Daybreak.Services.Scanner.Models; - -internal sealed class UserPayload -{ - public string? Email { get; set; } - public uint CurrentKurzickPoints { get; set; } - public uint CurrentLuxonPoints { get; set; } - public uint CurrentImperialPoints { get; set; } - public uint CurrentBalthazarPoints { get; set; } - public uint CurrentSkillPoints { get; set; } - public uint TotalKurzickPoints { get; set; } - public uint TotalLuxonPoints { get; set; } - public uint TotalImperialPoints { get; set; } - public uint TotalBalthazarPoints { get; set; } - public uint TotalSkillPoints { get; set; } - public uint MaxKurzickPoints { get; set; } - public uint MaxLuxonPoints { get; set; } - public uint MaxImperialPoints { get; set; } - public uint MaxBalthazarPoints { get; set; } -} diff --git a/Daybreak/Services/Scanner/Models/WorldPlayerPayload.cs b/Daybreak/Services/Scanner/Models/WorldPlayerPayload.cs deleted file mode 100644 index 14f0378e..00000000 --- a/Daybreak/Services/Scanner/Models/WorldPlayerPayload.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Daybreak.Services.Scanner.Models; - -internal class WorldPlayerPayload : PartyPlayerPayload -{ - public string? Name { get; set; } - public TitlePayload? Title { get; set; } -} diff --git a/Daybreak/Services/Screens/GuildwarsScreenPlacer.cs b/Daybreak/Services/Screens/GuildwarsScreenPlacer.cs index c427019d..d8a2939f 100644 --- a/Daybreak/Services/Screens/GuildwarsScreenPlacer.cs +++ b/Daybreak/Services/Screens/GuildwarsScreenPlacer.cs @@ -1,6 +1,5 @@ using Daybreak.Configuration.Options; using Daybreak.Shared.Models.Mods; -using Daybreak.Shared.Services.Scanner; using Daybreak.Shared.Services.Screens; using Microsoft.Extensions.Logging; using System; @@ -17,18 +16,15 @@ internal sealed class GuildwarsScreenPlacer : IGuildwarsScreenPlacer { private static readonly TimeSpan Delay = TimeSpan.FromSeconds(5); - private readonly IGuildwarsMemoryCache guildwarsMemoryCache; private readonly ILiveUpdateableOptions liveOptions; private readonly IScreenManager screenManager; private readonly ILogger logger; public GuildwarsScreenPlacer( - IGuildwarsMemoryCache guildwarsMemoryCache, ILiveUpdateableOptions liveOptions, IScreenManager screenManager, ILogger logger) { - this.guildwarsMemoryCache = guildwarsMemoryCache.ThrowIfNull(); this.liveOptions = liveOptions.ThrowIfNull(); this.screenManager = screenManager.ThrowIfNull(); this.logger = logger.ThrowIfNull(); diff --git a/Daybreak/Services/Screenshots/OnlinePictureClient.cs b/Daybreak/Services/Screenshots/OnlinePictureClient.cs index c0105f34..55ef272b 100644 --- a/Daybreak/Services/Screenshots/OnlinePictureClient.cs +++ b/Daybreak/Services/Screenshots/OnlinePictureClient.cs @@ -1,8 +1,8 @@ using Daybreak.Configuration.Options; using Daybreak.Services.Screenshots.Models; using Daybreak.Shared.Models.Guildwars; +using Daybreak.Shared.Services.Api; using Daybreak.Shared.Services.Images; -using Daybreak.Shared.Services.Scanner; using Daybreak.Shared.Services.Screenshots; using Daybreak.Shared.Utils; using Microsoft.Extensions.Logging; @@ -29,20 +29,20 @@ internal sealed class OnlinePictureClient : IOnlinePictureClient private static readonly string CacheFolder = PathUtils.GetAbsolutePathFromRoot(CacheFolderSubPath); private readonly IImageCache imageCache; - private readonly IGuildwarsMemoryCache guildwarsMemoryCache; + private readonly IAttachedApiAccessor attachedApiAccessor; private readonly IHttpClient httpClient; private readonly ILiveOptions themeOptions; private readonly ILogger logger; public OnlinePictureClient( IImageCache imageCache, - IGuildwarsMemoryCache guildwarsMemoryCache, + IAttachedApiAccessor attachedApiAccessor, ILogger logger, ILiveOptions themeOptions, IHttpClient httpClient) { this.imageCache = imageCache.ThrowIfNull(); - this.guildwarsMemoryCache = guildwarsMemoryCache.ThrowIfNull(); + this.attachedApiAccessor = attachedApiAccessor.ThrowIfNull(); this.logger = logger.ThrowIfNull(); this.themeOptions = themeOptions.ThrowIfNull(); this.httpClient = httpClient.ThrowIfNull(); @@ -93,25 +93,23 @@ internal sealed class OnlinePictureClient : IOnlinePictureClient var validEntries = Location.Locations .SelectMany(l => l.Entries) .Where(e => Models.Event.Wintersday.ValidLocations!.Any(map => e.Map == map)); - if (localized) + if (localized && this.attachedApiAccessor.ApiContext is ScopedApiContext apiContext) { - WorldData? worldInfo = default; - try + var instanceInfo = await apiContext.GetMainPlayerInstanceInfo(CancellationToken.None); + if (instanceInfo is not null) { - worldInfo = await this.guildwarsMemoryCache.ReadWorldData(CancellationToken.None); - } - catch (Exception ex) when (ex is TimeoutException or TaskCanceledException or HttpRequestException) - { - this.logger.LogInformation("Could not retrieve world data. Returning random screenshot"); - } - - if (worldInfo?.Map is Map map) - { - var localizedEntries = validEntries.Where(e => e.Map == map).ToList(); - if (localizedEntries.Count > 0) + if (Map.TryParse((int)instanceInfo.MapId, out var map)) { - var selectedEntry = localizedEntries[Random.Shared.Next(0, localizedEntries.Count)]; - return GetScreenshotName(selectedEntry, selectedEntry.StartIndex ?? 0 + Random.Shared.Next(0, selectedEntry.Count ?? 0)); + var localizedEntries = validEntries.Where(e => e.Map == map).ToList(); + if (localizedEntries.Count > 0) + { + var selectedEntry = localizedEntries[Random.Shared.Next(0, localizedEntries.Count)]; + return GetScreenshotName(selectedEntry, selectedEntry.StartIndex ?? 0 + Random.Shared.Next(0, selectedEntry.Count ?? 0)); + } + } + else + { + this.logger.LogError("Could not parse map ID {mapId}", instanceInfo.MapId); } } } @@ -123,27 +121,30 @@ internal sealed class OnlinePictureClient : IOnlinePictureClient private async Task<(string Uri, string CreditText)> GetLocalizedImageUri() { - WorldData? worldInfo = default; - try + if (this.attachedApiAccessor.ApiContext is not ScopedApiContext apiContext) { - worldInfo = await this.guildwarsMemoryCache.ReadWorldData(CancellationToken.None); - if (worldInfo is null) - { - return GetRandomScreenShot(); - } + return GetRandomScreenShot(); } - catch (Exception ex) when (ex is TimeoutException or TaskCanceledException or HttpRequestException) + + var instanceInfo = await apiContext.GetMainPlayerInstanceInfo(CancellationToken.None); + if (instanceInfo is null) { - this.logger.LogInformation("Could not retrieve world data. Returning random screenshot"); + return GetRandomScreenShot(); + } + + if (!Region.TryParse((int)instanceInfo.Region, out var region) || + !Map.TryParse((int)instanceInfo.MapId, out var map)) + { + this.logger.LogError("Could not parse region {regionId} or map ID {mapId}", instanceInfo.Region, instanceInfo.MapId); return GetRandomScreenShot(); } var validLocations = Location.Locations - .Where(l => l.Region == worldInfo!.Region) + .Where(l => l.Region == region) .ToList(); var validCategories = validLocations .SelectMany(l => l.Entries) - .Where(c => c.Map == worldInfo!.Map) + .Where(c => c.Map == map) .ToList(); if (validCategories.None()) { diff --git a/Daybreak/Themes/Generic.xaml b/Daybreak/Themes/Generic.xaml new file mode 100644 index 00000000..a90fc3cf --- /dev/null +++ b/Daybreak/Themes/Generic.xaml @@ -0,0 +1,94 @@ + + + + + + + diff --git a/Daybreak/Views/FocusView.xaml b/Daybreak/Views/FocusView.xaml index c1b9cc8a..a11a6e9c 100644 --- a/Daybreak/Views/FocusView.xaml +++ b/Daybreak/Views/FocusView.xaml @@ -30,68 +30,40 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + @@ -103,14 +75,11 @@ - - - + Grid.Row="0"> mainWindowEventsHook; private readonly INotificationService notificationService; private readonly IBuildTemplateManager buildTemplateManager; private readonly IApplicationLauncher applicationLauncher; - private readonly IGuildwarsMemoryCache guildwarsMemoryCache; - private readonly IGuildwarsMemoryReader guildwarsMemoryReader; private readonly IExperienceCalculator experienceCalculator; private readonly IViewManager viewManager; private readonly IScreenManager screenManager; @@ -61,15 +55,30 @@ public partial class FocusView : UserControl [GenerateDependencyProperty] private bool mainPlayerDataValid; - [GenerateDependencyProperty] - private MainPlayerResourceContext mainPlayerResourceContext = new(); - [GenerateDependencyProperty] private string browserAddress = string.Empty; [GenerateDependencyProperty] private bool pauseDataFetching; + [GenerateDependencyProperty] + private CharacterComponentContext characterSelectComponentContext = default!; + + [GenerateDependencyProperty] + private CurrentMapComponentContext currentMapComponentContext = default!; + + [GenerateDependencyProperty] + private TitleInformationComponentContext titleInformationComponentContext = default!; + + [GenerateDependencyProperty] + private QuestLogComponentContext questLogComponentContext = default!; + + [GenerateDependencyProperty] + private PlayerResourcesComponentContext playerResourcesComponentContext = default!; + + [GenerateDependencyProperty] + private VanquishComponentContext vanquishComponentContext = default!; + private bool browserMaximized = false; private CancellationTokenSource? cancellationTokenSource; @@ -78,8 +87,6 @@ public partial class FocusView : UserControl INotificationService notificationService, IBuildTemplateManager buildTemplateManager, IApplicationLauncher applicationLauncher, - IGuildwarsMemoryCache guildwarsMemoryCache, - IGuildwarsMemoryReader guildwarsMemoryReader, IExperienceCalculator experienceCalculator, IViewManager viewManager, IScreenManager screenManager, @@ -91,8 +98,6 @@ public partial class FocusView : UserControl this.notificationService = notificationService.ThrowIfNull(); this.buildTemplateManager = buildTemplateManager.ThrowIfNull(); this.applicationLauncher = applicationLauncher.ThrowIfNull(); - this.guildwarsMemoryCache = guildwarsMemoryCache.ThrowIfNull(); - this.guildwarsMemoryReader = guildwarsMemoryReader.ThrowIfNull(); this.experienceCalculator = experienceCalculator.ThrowIfNull(); this.viewManager = viewManager.ThrowIfNull(); this.screenManager = screenManager.ThrowIfNull(); @@ -134,13 +139,13 @@ public partial class FocusView : UserControl continue; } - if (this.DataContext is not GuildWarsApplicationLaunchContext context) + if (this.DataContext is not FocusViewContext context) { await Task.Delay(MainPlayerDataFrequency, cancellationToken); continue; } - if (context.GuildWarsProcess?.HasExited is not false) + if (context.LaunchContext.GuildWarsProcess?.HasExited is not false) { this.logger.LogInformation($"Executable is not running. Returning to {nameof(LauncherView)}"); this.viewManager.ShowView(); @@ -148,36 +153,67 @@ public partial class FocusView : UserControl return; } - var readUserDataTask = this.guildwarsMemoryCache.ReadUserData(cancellationToken); - var readSessionDataTask = this.guildwarsMemoryCache.ReadSessionData(cancellationToken); - var readMainPlayerDataTask = this.guildwarsMemoryCache.ReadMainPlayerData(cancellationToken); + var isAvailableTask = context.ApiContext.IsAvailable(cancellationToken); + var mainPlayerInfoTask = context.ApiContext.GetMainPlayerInfo(cancellationToken); + var mainPlayerStateTask = context.ApiContext.GetMainPlayerState(cancellationToken); + var instanceInfoTask = context.ApiContext.GetMainPlayerInstanceInfo(cancellationToken); + var characterSelectTask = context.ApiContext.GetCharacters(cancellationToken); + var titleInfoTask = context.ApiContext.GetTitleInfo(cancellationToken); + var questLogTask = context.ApiContext.GetMainPlayerQuestLog(cancellationToken); await Task.WhenAll( - readUserDataTask, - readSessionDataTask, - readMainPlayerDataTask, + isAvailableTask, + mainPlayerInfoTask, + mainPlayerStateTask, + instanceInfoTask, + characterSelectTask, + titleInfoTask, + questLogTask, Task.Delay(MainPlayerDataFrequency, cancellationToken)).ConfigureAwait(true); - var userData = await readUserDataTask; - var sessionData = await readSessionDataTask; - var mainPlayerData = await readMainPlayerDataTask; - if (userData?.User is null || - sessionData?.Session is null || - mainPlayerData?.PlayerInformation is null || - sessionData.Session.InstanceType is InstanceType.Loading or InstanceType.Undefined || - sessionData.Session.InstanceTimer == 0) + var isAvailable = await isAvailableTask; + var mainPlayerInfo = await mainPlayerInfoTask; + var mainPlayerState = await mainPlayerStateTask; + var instanceInfo = await instanceInfoTask; + var characters = await characterSelectTask; + var titleInfo = await titleInfoTask; + var questLog = await questLogTask; + if (isAvailable is not true) + { + retries++; + if (retries >= MaxRetries) + { + scopedLogger.LogError("Could not ensure connection is initialized. Returning to launcher view"); + this.notificationService.NotifyError( + title: "GuildWars unresponsive", + description: "Could not connect to Guild Wars instance. Returning to Launcher view"); + this.viewManager.ShowView(); + } + else + { + scopedLogger.LogError("Could not ensure connection is initialized. Backing off before retrying"); + await Task.Delay(UninitializedBackoff, cancellationToken); + } + } + + if (instanceInfo is null || + instanceInfo.Type is Shared.Models.Api.InstanceType.Loading or Shared.Models.Api.InstanceType.Undefined || + mainPlayerInfo is null || + mainPlayerState is null || + characters is null || + questLog is null) { this.MainPlayerDataValid = false; continue; } - this.MainPlayerResourceContext = new MainPlayerResourceContext - { - Player = mainPlayerData, - User = userData, - Session = sessionData - }; + this.SetCurrentMapComponentContext(instanceInfo); + this.SetTitleInformationComponentContext(titleInfo); + this.SetCharacterSelectComponentContext(mainPlayerState, characters); + this.SetQuestLogComponentContext(questLog); + this.SetPlayerResourcesComponentContext(mainPlayerState); + this.SetVanquishComponentContext(instanceInfo); - this.MainPlayerDataValid = true; + this.MainPlayerDataValid = !this.PauseDataFetching; this.Browser.Visibility = Visibility.Visible; retries = 0; } @@ -186,36 +222,6 @@ public partial class FocusView : UserControl scopedLogger.LogError(ex, "Encountered invalid operation exception. Cancelling periodic main player reading"); return; } - catch (Exception ex) when (ex is TimeoutException or OperationCanceledException) - { - if (this.DataContext is not GuildWarsApplicationLaunchContext context) - { - continue; - } - - scopedLogger.LogError(ex, "Encountered timeout. Verifying connection"); - try - { - await this.guildwarsMemoryCache.EnsureInitialized(context, cancellationToken); - } - catch (InvalidOperationException innerEx) - { - retries++; - if (retries >= MaxRetries) - { - scopedLogger.LogError(innerEx, "Could not ensure connection is initialized. Returning to launcher view"); - this.notificationService.NotifyError( - title: "GuildWars unresponsive", - description: "Could not connect to Guild Wars instance. Returning to Launcher view"); - this.viewManager.ShowView(); - } - else - { - scopedLogger.LogError(innerEx, "Could not ensure connection is initialized. Backing off before retrying"); - await Task.Delay(UninitializedBackoff, cancellationToken); - } - } - } catch (Exception ex) { scopedLogger.LogError(ex, "Encountered non-terminating exception. Silently continuing"); @@ -223,9 +229,9 @@ public partial class FocusView : UserControl } } - private async void FocusView_Loaded(object _, RoutedEventArgs e) + private void FocusView_Loaded(object _, RoutedEventArgs e) { - if (this.DataContext is not GuildWarsApplicationLaunchContext context) + if (this.DataContext is not FocusViewContext context) { return; } @@ -236,7 +242,6 @@ public partial class FocusView : UserControl this.cancellationTokenSource?.Dispose(); this.cancellationTokenSource = new CancellationTokenSource(); var cancellationToken = this.cancellationTokenSource.Token; - await this.guildwarsMemoryCache.EnsureInitialized(context, cancellationToken); this.PeriodicallyReadMainPlayerContextData(cancellationToken); } @@ -265,6 +270,28 @@ public partial class FocusView : UserControl } } + private async void CharacterSelectComponent_SwitchCharacterClicked(object _, CharacterSelectComponentEntry? e) + { + if (this.DataContext is not FocusViewContext context || + context.ApiContext is not ScopedApiContext apiContext || + e.CharacterName.IsNullOrWhiteSpace()) + { + return; + } + + await this.Dispatcher.InvokeAsync(() => + { + this.PauseDataFetching = true; + this.MainPlayerDataValid = false; + }); + await apiContext.SwitchCharacter(e.CharacterName, this.cancellationTokenSource?.Token ?? CancellationToken.None); + await this.Dispatcher.InvokeAsync(() => + { + this.PauseDataFetching = false; + this.MainPlayerDataValid = true; + }); + } + private void Browser_BuildDecoded(object _, DownloadedBuild e) { if (e is null || @@ -340,4 +367,137 @@ public partial class FocusView : UserControl { this.PauseDataFetching = false; } + + private void SetVanquishComponentContext(InstanceInfo instanceInfo) + { + this.VanquishComponentContext = new VanquishComponentContext + { + FoesKilled = instanceInfo.FoesKilled, + FoesToKill = instanceInfo.FoesToKill, + HardMode = instanceInfo.Difficulty is DifficultyInfo.Hard, + Vanquishing = (instanceInfo.FoesToKill + instanceInfo.FoesKilled > 0U) && instanceInfo.Difficulty is DifficultyInfo.Hard + }; + } + + private void SetCurrentMapComponentContext(InstanceInfo instanceInfo) + { + _ = Map.TryParse((int)instanceInfo.MapId, out var map); + this.CurrentMapComponentContext = new CurrentMapComponentContext { CurrentMap = map }; + } + + private void SetCharacterSelectComponentContext(MainPlayerState state, CharacterSelectInformation characters) + { + var characterNames = characters.CharacterNames + .Select(c => + { + _ = Profession.TryParse((int)c.Primary, out var primary); + _ = Profession.TryParse((int)c.Secondary, out var secondary); + var professionText = secondary == Profession.None ? + primary.Alias is null ? string.Empty : $"{primary.Alias} " + : $"{primary.Alias}/{secondary.Alias} "; + var name = $"{professionText}{c.Name}"; + return (c, name); + }) + .ToList(); + + + + var mainCharacter = characterNames.FirstOrDefault(c => c.c.Name == characters.CurrentCharacter?.Name); + var restCharacters = characterNames.Where(c => c.c.Name != mainCharacter.c.Name); + this.CharacterSelectComponentContext = new CharacterComponentContext + { + CurrentExperience = state.CurrentExperience, + Characters = [.. restCharacters.Select(c => new CharacterSelectComponentEntry { CharacterName = c.c.Name, DisplayName = c.name })], + CurrentCharacter = new CharacterSelectComponentEntry { CharacterName = mainCharacter.c.Name, DisplayName = mainCharacter.name } + }; + } + + private void SetQuestLogComponentContext(QuestLogInformation questLog) + { + var currentQuestEntry = questLog.Quests.FirstOrDefault(q => q.QuestId == questLog.CurrentQuestId); + QuestMetadata? currentQuestMeta = default; + if (currentQuestEntry is not null) + { + _ = Quest.TryParse((int)questLog.CurrentQuestId, out var currentQuest); + _ = Map.TryParse((int)currentQuestEntry.MapFrom, out var mapFrom); + _ = Map.TryParse((int)currentQuestEntry.MapTo, out var mapTo); + currentQuestMeta = new QuestMetadata + { + From = mapFrom, + To = mapTo, + Quest = currentQuest + }; + } + + this.QuestLogComponentContext = new QuestLogComponentContext + { + CurrentQuest = currentQuestMeta, + Quests = [.. questLog.Quests + .Where(q => q.QuestId != questLog.CurrentQuestId) + .Select(quest => + { + if (!Quest.TryParse((int)quest.QuestId, out var parsedQuest) || + !Map.TryParse((int)quest.MapFrom, out var mapFrom) || + !Map.TryParse((int)quest.MapTo, out var mapTo)) + { + return default; + } + + return new QuestMetadata { From = mapFrom, To = mapTo, Quest = parsedQuest }; + }) + .OfType()] + }; + } + + private void SetTitleInformationComponentContext(TitleInfo? titleInfo) + { + if (titleInfo is not null && + Title.TryParse((int)titleInfo.Id, out var title)) + { + this.TitleInformationComponentContext = new TitleInformationComponentContext + { + Title = title, + CurrentPoints = titleInfo.CurrentPoints, + IsPercentage = titleInfo.IsPercentage, + MaxTierNumber = titleInfo.MaxTierNumber, + TierNumber = titleInfo.TierNumber, + PointsForCurrentRank = titleInfo.PointsForCurrentRank, + PointsForNextRank = titleInfo.PointsForNextRank + }; + } + else + { + this.TitleInformationComponentContext = new TitleInformationComponentContext + { + Title = Title.None, + CurrentPoints = 0, + IsPercentage = false, + MaxTierNumber = 0, + TierNumber = 0, + PointsForCurrentRank = 0, + PointsForNextRank = 0 + }; + } + } + + private void SetPlayerResourcesComponentContext(MainPlayerState state) + { + this.PlayerResourcesComponentContext = new PlayerResourcesComponentContext + { + CurrentBalthazar = state.CurrentBalthazar, + CurrentImperial = state.CurrentImperial, + CurrentLuxon = state.CurrentLuxon, + CurrentKurzick = state.CurrentKurzick, + + MaxBalthazar = state.MaxBalthazar, + MaxImperial = state.MaxImperial, + MaxLuxon = state.MaxLuxon, + MaxKurzick = state.MaxKurzick, + + TotalBalthazar = state.TotalBalthazar, + TotalImperial = state.TotalImperial, + TotalLuxon = state.TotalLuxon, + TotalKurzick = state.TotalKurzick, + }; + } } diff --git a/Daybreak/Views/LauncherView.xaml b/Daybreak/Views/LauncherView.xaml index 609d8128..e2db1d9d 100644 --- a/Daybreak/Views/LauncherView.xaml +++ b/Daybreak/Views/LauncherView.xaml @@ -26,6 +26,7 @@ Items="{Binding ElementName=_this, Path=LaunchConfigurations, Mode=OneWay}" SelectedItem="{Binding ElementName=_this, Path=LatestConfiguration, Mode=TwoWay}" ClickEnabled="{Binding ElementName=_this, Path=CanLaunch, Mode=OneWay}" + DisableBrush="Black" SelectionChanged="DropDownButton_SelectionChanged" Clicked="DropDownButton_Clicked"> diff --git a/Daybreak/Views/LauncherView.xaml.cs b/Daybreak/Views/LauncherView.xaml.cs index ebad91e3..032294a7 100644 --- a/Daybreak/Views/LauncherView.xaml.cs +++ b/Daybreak/Views/LauncherView.xaml.cs @@ -1,14 +1,16 @@ using Daybreak.Configuration.Options; using Daybreak.Shared.Models; +using Daybreak.Shared.Models.FocusView; using Daybreak.Shared.Models.LaunchConfigurations; using Daybreak.Shared.Models.Onboarding; +using Daybreak.Shared.Services.Api; using Daybreak.Shared.Services.ApplicationLauncher; using Daybreak.Shared.Services.InternetChecker; using Daybreak.Shared.Services.LaunchConfigurations; using Daybreak.Shared.Services.Menu; using Daybreak.Shared.Services.Navigation; +using Daybreak.Shared.Services.Notifications; using Daybreak.Shared.Services.Onboarding; -using Daybreak.Shared.Services.Scanner; using Daybreak.Shared.Services.Screens; using System; using System.Collections.ObjectModel; @@ -21,7 +23,6 @@ using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Extensions; -using System.Windows.Threading; namespace Daybreak.Views; @@ -32,7 +33,8 @@ namespace Daybreak.Views; [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0052:Remove unread private members", Justification = "Used by source generators")] public partial class LauncherView : UserControl { - private readonly IGuildwarsMemoryReader guildwarsMemoryReader; + private readonly INotificationService notificationService; + private readonly IDaybreakApiService daybreakApiService; private readonly IMenuService menuService; private readonly ILaunchConfigurationService launchConfigurationService; private readonly IConnectivityStatus connectivityStatus; @@ -54,7 +56,8 @@ public partial class LauncherView : UserControl public ObservableCollection LaunchConfigurations { get; } = []; public LauncherView( - IGuildwarsMemoryReader guildwarsMemoryReader, + INotificationService notificationService, + IDaybreakApiService daybreakApiService, IMenuService menuService, ILaunchConfigurationService launchConfigurationService, IConnectivityStatus connectivityStatus, @@ -64,7 +67,8 @@ public partial class LauncherView : UserControl IScreenManager screenManager, ILiveOptions focusViewOptions) { - this.guildwarsMemoryReader = guildwarsMemoryReader.ThrowIfNull(); + this.notificationService = notificationService.ThrowIfNull(); + this.daybreakApiService = daybreakApiService.ThrowIfNull(); this.menuService = menuService.ThrowIfNull(); this.launchConfigurationService = launchConfigurationService.ThrowIfNull(); this.connectivityStatus = connectivityStatus.ThrowIfNull(); @@ -168,7 +172,7 @@ public partial class LauncherView : UserControl } else { - var launchingTask = await new TaskFactory().StartNew(this.LaunchGuildWars, TaskCreationOptions.LongRunning); + var launchingTask = await new TaskFactory().StartNew(() => this.LaunchGuildWars(this.cancellationTokenSource?.Token ?? CancellationToken.None), TaskCreationOptions.LongRunning); try { await launchingTask; @@ -216,42 +220,88 @@ public partial class LauncherView : UserControl this.applicationLauncher.KillGuildWarsProcess(context); } - private async Task LaunchGuildWars() + private async Task LaunchGuildWars(CancellationToken cancellationToken) { var latestConfig = await this.Dispatcher.InvokeAsync(() => this.LatestConfiguration); - if (this.applicationLauncher.GetGuildwarsProcess(latestConfig.Configuration!) is GuildWarsApplicationLaunchContext context) + if (latestConfig.Configuration is null) { - // Detected already running guildwars process - await this.Dispatcher.InvokeAsync(() => this.CanLaunch = false); - if (this.focusViewOptions.Value.Enabled) - { - this.menuService.CloseMenu(); - this.viewManager.ShowView(context); - } - - this.launchConfigurationService.SetLastLaunchConfigurationWithCredentials(latestConfig.Configuration!); return; } - try + if (this.applicationLauncher.GetGuildwarsProcess(latestConfig.Configuration) is GuildWarsApplicationLaunchContext context) { - var launchedContext = await this.applicationLauncher.LaunchGuildwars(latestConfig.Configuration!); - if (launchedContext is null) + try { + // Detected already running guildwars process await this.Dispatcher.InvokeAsync(() => this.CanLaunch = false); + if (this.focusViewOptions.Value.Enabled) + { + var notificationToken = this.notificationService.NotifyInformation( + title: "Attaching to Guild Wars process...", + description: "Attempting to attach to Guild Wars process"); + var apiContext = await this.daybreakApiService.AttachDaybreakApiContext(context, cancellationToken); + notificationToken.Cancel(); + + if (apiContext is null) + { + this.notificationService.NotifyError( + title: "Could not attach to Guild Wars", + description: "Could not find the Api context to attach to Guild Wars. Check the logs for more details"); + await this.Dispatcher.InvokeAsync(() => this.CanLaunch = true); + } + else + { + this.viewManager.ShowView(new FocusViewContext { ApiContext = apiContext, LaunchContext = context }); + this.menuService.CloseMenu(); + } + } + + this.launchConfigurationService.SetLastLaunchConfigurationWithCredentials(latestConfig.Configuration); return; } - - this.launchConfigurationService.SetLastLaunchConfigurationWithCredentials(latestConfig.Configuration!); - if (this.focusViewOptions.Value.Enabled) + catch(Exception) { - await this.Dispatcher.InvokeAsync(() => this.CanLaunch = false); - this.menuService.CloseMenu(); - this.viewManager.ShowView(launchedContext); + await this.Dispatcher.InvokeAsync(() => this.CanLaunch = true); } } - catch (Exception) + else { + try + { + var launchedContext = await this.applicationLauncher.LaunchGuildwars(latestConfig.Configuration, cancellationToken); + await this.Dispatcher.InvokeAsync(() => this.CanLaunch = false); + if (launchedContext is null) + { + return; + } + + this.launchConfigurationService.SetLastLaunchConfigurationWithCredentials(latestConfig.Configuration); + if (this.focusViewOptions.Value.Enabled) + { + var notificationToken = this.notificationService.NotifyInformation( + title: "Attaching to Guild Wars process...", + description: "Attempting to attach to Guild Wars process"); + var apiContext = await this.daybreakApiService.AttachDaybreakApiContext(launchedContext, cancellationToken); + notificationToken.Cancel(); + + if (apiContext is null) + { + this.notificationService.NotifyError( + title: "Could not attach to Guild Wars", + description: "Could not find the Api context to attach to Guild Wars. Check the logs for more details"); + await this.Dispatcher.InvokeAsync(() => this.CanLaunch = true); + } + else + { + this.viewManager.ShowView(new FocusViewContext { ApiContext = apiContext, LaunchContext = launchedContext }); + this.menuService.CloseMenu(); + } + } + } + catch (Exception) + { + await this.Dispatcher.InvokeAsync(() => this.CanLaunch = true); + } } } @@ -279,19 +329,27 @@ public partial class LauncherView : UserControl launcherViewContext.GameRunning = true; } - launcherViewContext.GameRunning = true; - launcherViewContext.CanLaunch = false; - launcherViewContext.CanAttach = false; - launcherViewContext.CanKill = false; // If FocusView is disabled, don't initialize memory scanner, instead just allow the user to kill the game if (!this.focusViewOptions.Value.Enabled || !isSelected) { + launcherViewContext.GameRunning = true; + launcherViewContext.CanLaunch = false; + launcherViewContext.CanAttach = false; + launcherViewContext.CanKill = false; return; } - await this.guildwarsMemoryReader.EnsureInitialized(context.ProcessId, cancellationToken); - if (!await this.guildwarsMemoryReader.IsInitialized(context.ProcessId, cancellationToken)) + if (await this.daybreakApiService.GetDaybreakApiContext(context.GuildWarsProcess, cancellationToken) is not ScopedApiContext apiContext) + { + launcherViewContext.GameRunning = false; + launcherViewContext.CanLaunch = false; + launcherViewContext.CanAttach = false; + launcherViewContext.CanKill = true; + return; + } + + if (!await apiContext.IsAvailable(cancellationToken)) { launcherViewContext.CanKill = true; launcherViewContext.GameRunning = true; @@ -300,8 +358,8 @@ public partial class LauncherView : UserControl return; } - var loginInfo = await this.guildwarsMemoryReader.ReadLoginData(cancellationToken); - if (loginInfo?.Email != context.LaunchConfiguration.Credentials?.Username) + var mainPlayerInfo = await apiContext.GetMainPlayerInfo(cancellationToken); + if (mainPlayerInfo?.Email != context.LaunchConfiguration.Credentials?.Username) { launcherViewContext.GameRunning = false; launcherViewContext.CanAttach = false;