using Daybreak.API.Extensions; using Daybreak.API.Interop; using Daybreak.API.Interop.GuildWars; using Daybreak.API.Services.Interop; using Daybreak.Shared.Models.Api; using System.Core.Extensions; using System.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) { 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->World 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 = (*(Uuid*)gameContext.Pointer->Character->PlayerUuid).ToString(); var availableChars = new List((int)availableCharsContext.Pointer->Size); foreach (var charContext in *availableCharsContext.Pointer) { var name = new string(charContext.Name); availableChars.Add(new CharacterSelectEntry( (*(Uuid*)charContext.Uuid).ToString(), name, (uint)charContext.GetPrimaryProfession(), (uint)charContext.GetSecondaryProfession(), (uint)charContext.GetCampaign(), (uint)charContext.GetMapId(), (uint)charContext.GetLevel(), charContext.IsPvP())); } // TODO: Reforged sometimes returns Zero UUID even when in-game, need to investigate why if (currentUuid != Uuid.Zero.ToString()) { var currentCharacter = availableChars.FirstOrDefault(c => c.Uuid == currentUuid); return new CharacterSelectInformation(currentCharacter, availableChars); } else { var currentCharacter = availableChars.FirstOrDefault(); 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 selectResult = await this.WaitForCharSelectAndSelectCharacter(characterName, cancellationToken); if (!selectResult) { scopedLogger.LogError("Failed to select character {name}", characterName); 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 selectResult = await this.WaitForCharSelectAndSelectCharacter(desiredCharName, cancellationToken); if (!selectResult) { scopedLogger.LogError("Failed to select character {name} with uuid {uuid}", desiredCharName, uuid); return false; } return true; } [StructLayout(LayoutKind.Sequential, Pack = 1)] private unsafe struct CharSelectButtonParam { public char* Name; public uint Play; } [StructLayout(LayoutKind.Explicit, Pack = 1)] private readonly unsafe struct CharSelectorContext { [FieldOffset(0x0000)] public readonly uint Vtable; [FieldOffset(0x0004)] public readonly uint FrameId; [FieldOffset(0x0008)] public readonly GuildWarsArray> Chars; } [StructLayout(LayoutKind.Explicit, Pack = 1)] private readonly struct CharSelectorChar { [FieldOffset(0x0020)] public readonly Array20Char Name; } private async Task SelectCharacterToPlay(string characterName, bool play, CancellationToken cancellationToken) { var scopedLogger = this.logger.CreateScopedLogger(); return await this.gameThreadService.QueueOnGameThread(() => { unsafe { if (!this.IsCharSelectReady()) { scopedLogger.LogError("Character select is not ready"); return false; } var selectorFrame = this.GetCharSelectorFrame(); if (selectorFrame is null) { scopedLogger.LogError("Character selector frame not found"); return false; } var ctx = this.uiContextService.GetFrameContext(selectorFrame); if (ctx.IsNull || (nuint)ctx.Pointer <= 0xFFFF) { scopedLogger.LogError("Character selector context not found"); return false; } var panesFrame = this.uiContextService.GetChildFrame(selectorFrame, 0); if (panesFrame.IsNull) { scopedLogger.LogError("Character panes frame not found"); return false; } // Get current selected index uint selectedIdx = 0; this.uiContextService.SendFrameUIMessage(panesFrame, UIMessage.kFrameMessage_0x4a, null, &selectedIdx); // Find target character index uint targetIdx = 0xFFFF; var charCount = ctx.Pointer->Chars.Size; for (uint i = 0; i < charCount; i++) { var c = ctx.Pointer->Chars.Buffer[i]; if (c.IsNull) { continue; } var charNameSpan = c.Pointer->Name.AsSpan(); var nullIdx = charNameSpan.IndexOf('\0'); if (nullIdx <= 0) { continue; } var charName = new string(charNameSpan[..nullIdx]); if (!charName.Equals(characterName, StringComparison.OrdinalIgnoreCase)) { continue; } targetIdx = i; break; } if (targetIdx >= charCount) { scopedLogger.LogError("Character {name} not found in character list", characterName); return false; } var parentFrame = this.uiContextService.GetParentFrame(selectorFrame); if (parentFrame.IsNull) { scopedLogger.LogError("Parent frame not found"); return false; } // Helper function to select a character at a specific index bool SelectChar(uint idx) { var charToSelect = ctx.Pointer->Chars.Buffer[idx]; if (charToSelect.IsNull) { return false; } fixed (char* namePtr = charToSelect.Pointer->Name.AsSpan()) { var buttonParam = new CharSelectButtonParam { Name = namePtr, Play = 0 }; var action = new kMouseAction { FrameId = selectorFrame->FrameId, ChildOffsetId = selectorFrame->ChildOffsetId, CurrentState = GWCA.GW.UI.UIPacket.ActionState.MouseClick, Wparam = (nint)(&buttonParam) }; if (!this.uiContextService.SendFrameUIMessage(parentFrame, UIMessage.kMouseClick2, &action)) { return false; } uint newSelectedIdx = 0; this.uiContextService.SendFrameUIMessage(panesFrame, UIMessage.kFrameMessage_0x4a, null, &newSelectedIdx); return newSelectedIdx == idx; } } // Navigate to target character by selecting previous/next until we reach it. // Empty roster slots are represented by null entries in the character array, so // step over them instead of bailing out (otherwise navigation gets stuck at the // slot right before a gap and never reaches characters beyond it). while (targetIdx < selectedIdx) { if (selectedIdx == 0) { break; } var prevIdx = selectedIdx - 1; while (prevIdx > 0 && ctx.Pointer->Chars.Buffer[prevIdx].IsNull) { prevIdx--; } if (ctx.Pointer->Chars.Buffer[prevIdx].IsNull) { break; } if (!SelectChar(prevIdx)) { scopedLogger.LogError("Failed to select previous character"); return false; } selectedIdx = prevIdx; } while (targetIdx > selectedIdx) { if (selectedIdx >= charCount - 1) { break; } var nextIdx = selectedIdx + 1; while (nextIdx < charCount && ctx.Pointer->Chars.Buffer[nextIdx].IsNull) { nextIdx++; } if (nextIdx >= charCount) { break; } if (!SelectChar(nextIdx)) { scopedLogger.LogError("Failed to select next character"); return false; } selectedIdx = nextIdx; } var chosen = selectedIdx == targetIdx; if (!chosen) { scopedLogger.LogError("Failed to select character {name}", characterName); return false; } if (!play) { return true; } var playButton = this.uiContextService.GetFrameByLabel("Play"); if (playButton.IsNull) { scopedLogger.LogError("Play button not found"); return false; } return this.uiContextService.ButtonClick(playButton); } }, cancellationToken); } private async Task WaitForCharSelectAndSelectCharacter(string characterName, CancellationToken cancellationToken) { var scopedLogger = this.logger.CreateScopedLogger(); // Wait for character select to be ready while (!cancellationToken.IsCancellationRequested) { try { if (await this.gameThreadService.QueueOnGameThread(this.IsCharSelectReady, cancellationToken)) { break; } await Task.Delay(100, cancellationToken); } catch (Exception e) { scopedLogger.LogError(e, "Error while waiting for character select to be ready"); throw; } } if (cancellationToken.IsCancellationRequested) { return false; } // NOTE: Mirrors GWToolbox's GW::LoginMgr::SelectCharacterToPlay, which navigates the // character carousel purely by stepping prev/next over the selector context's character // array. It deliberately does NOT touch the in-game CharSortOrder preference: the // navigation relies on the selector context order matching the visible carousel order, // and forcing a re-sort here desyncs the two (the click-by-name steps then land on the // wrong index, so navigation only appears to work in one direction). return await this.SelectCharacterToPlay(characterName, play: true, cancellationToken); } 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 ((*(Uuid*)charContext.Uuid).ToString() != uuid) { continue; } var name = new string(charContext.Name); return name; } return default; } }, cancellationToken); } private async Task TriggerLogOut(CancellationToken cancellationToken) { await this.gameThreadService.QueueOnGameThread(() => { var logoutMessage = new kLogout { Unknown = 0, CharacterSelect = 1 }; unsafe { this.uiContextService.SendMessage(UIMessage.kLogout, (uint)&logoutMessage, 0); } }, cancellationToken); } private async Task ValidateState(CancellationToken cancellationToken) { return await this.gameThreadService.QueueOnGameThread(() => { return this.instanceContextService.GetInstanceType() is not API.Interop.GuildWars.InstanceType.Loading; }, cancellationToken); } private unsafe bool IsCharSelectReady() { // GW UI state must report char select (2). kCheckUIState fills the value passed via lParam. uint uiState = 10; this.uiContextService.SendMessage(UIMessage.kCheckUIState, 0, (nuint)(&uiState)); var selectorFrame = this.GetCharSelectorFrame(); if (uiState != 2 || selectorFrame is null || !selectorFrame->IsVisible) { return false; } // The frame context must be fully populated (a real pointer) and belong to this frame. var ctx = this.uiContextService.GetFrameContext(selectorFrame); if (ctx.IsNull || (nuint)ctx.Pointer <= 0xFFFF) { return false; } return ctx.Pointer->FrameId == selectorFrame->FrameId; } private unsafe Frame* GetCharSelectorFrame() { var selectorFrame = this.uiContextService.GetFrameByLabel("Selector"); if (selectorFrame.IsNull) { return null; } return selectorFrame.Pointer; } }