Files
Daybreak/Daybreak.API/Services/CharacterSelectService.cs
2026-06-30 17:09:16 +02:00

487 lines
18 KiB
C#

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<CharacterSelectService> 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<CharacterSelectService> logger = logger.ThrowIfNull();
public Task<CharacterSelectInformation?> 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<CharacterSelectEntry>((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<bool> 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<bool> 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<WrappedPointer<CharSelectorChar>> Chars;
}
[StructLayout(LayoutKind.Explicit, Pack = 1)]
private readonly struct CharSelectorChar
{
[FieldOffset(0x0020)]
public readonly Array20Char Name;
}
private async Task<bool> 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<CharSelectorContext>(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<bool> 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<string?> 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<bool> 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<CharSelectorContext>(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;
}
}