From fdbfdbb2cdc348e1c7bb114287cc93c16cb34967 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Mon, 24 Mar 2025 17:00:27 +0100 Subject: [PATCH] Add SeStringEvaluator service (#2188) * Add SeStringEvaluator service * Move DrawCopyableText into WidgetUtil * Use Icon2RemapTable in SeStringRenderer * Beautify some code * Make sure to use the correct language * Add SeString Creator widget * Fix getting local parameters * Update expressionNames * misc changes * Use InvariantCulture in TryResolveSheet * Add SeStringEvaluatorAgingStep * Fix item id comparisons * Add SheetRedirectResolverAgingStep * Add NounProcessorAgingStep * Update SeString.CreateItemLink This also adds the internal ItemUtil class. * Fix name of SeStringCreator widget * Add Global Parameters tab to SeStringCreatorWidget * Load widgets on demand * Update SeStringCreatorWidget * Resizable SeStringCreatorWidget panels * Update GamepadStateAgingStep * Experimental status was removed in #2144 * Update SheetRedirectResolver, rewrite Noun params * Fixes for 4 am code * Remove incorrect column offset I have no idea how that happened. * Draw names of linked things --------- Co-authored-by: Soreepeong <3614868+Soreepeong@users.noreply.github.com> Co-authored-by: KazWolfe --- Dalamud/Game/ActionKind.cs | 89 + Dalamud/Game/Gui/GameGui.cs | 2 +- .../Internal/SeStringBuilderIconWrap.cs | 30 + .../Evaluator/Internal/SeStringContext.cs | 83 + .../Evaluator/Internal/SheetRedirectFlags.cs | 49 + .../Internal/SheetRedirectResolver.cs | 232 ++ .../Game/Text/Evaluator/SeStringEvaluator.cs | 1995 +++++++++++++++++ .../Game/Text/Evaluator/SeStringParameter.cs | 79 + .../Text/Noun/Enums/EnglishArticleType.cs | 17 + .../Game/Text/Noun/Enums/FrenchArticleType.cs | 32 + .../Game/Text/Noun/Enums/GermanArticleType.cs | 37 + .../Text/Noun/Enums/JapaneseArticleType.cs | 17 + Dalamud/Game/Text/Noun/NounParams.cs | 73 + Dalamud/Game/Text/Noun/NounProcessor.cs | 461 ++++ .../SeStringHandling/Payloads/ItemPayload.cs | 20 +- .../Game/Text/SeStringHandling/SeString.cs | 80 +- .../Internal/SeStringRenderer.cs | 74 +- .../Internal/Windows/Data/DataWindow.cs | 12 +- .../Internal/Windows/Data/WidgetUtil.cs | 34 + .../Data/Widgets/AtkArrayDataBrowserWidget.cs | 42 +- .../Windows/Data/Widgets/FateTableWidget.cs | 28 +- .../Data/Widgets/NounProcessorWidget.cs | 207 ++ .../Data/Widgets/SeStringCreatorWidget.cs | 1276 +++++++++++ .../AgingSteps/GamepadStateAgingStep.cs | 40 +- .../AgingSteps/NounProcessorAgingStep.cs | 259 +++ .../AgingSteps/SeStringEvaluatorAgingStep.cs | 92 + .../SheetRedirectResolverAgingStep.cs | 130 ++ .../Windows/SelfTest/SelfTestWindow.cs | 3 + Dalamud/Interface/Utility/ImGuiHelpers.cs | 4 - Dalamud/Plugin/Services/ISeStringEvaluator.cs | 79 + Dalamud/Utility/ActionKindExtensions.cs | 26 + Dalamud/Utility/ClientLanguageExtensions.cs | 36 + Dalamud/Utility/ItemUtil.cs | 159 ++ Dalamud/Utility/ObjectKindExtensions.cs | 33 + Dalamud/Utility/SeStringExtensions.cs | 152 ++ Dalamud/Utility/StringExtensions.cs | 45 + 36 files changed, 5831 insertions(+), 196 deletions(-) create mode 100644 Dalamud/Game/ActionKind.cs create mode 100644 Dalamud/Game/Text/Evaluator/Internal/SeStringBuilderIconWrap.cs create mode 100644 Dalamud/Game/Text/Evaluator/Internal/SeStringContext.cs create mode 100644 Dalamud/Game/Text/Evaluator/Internal/SheetRedirectFlags.cs create mode 100644 Dalamud/Game/Text/Evaluator/Internal/SheetRedirectResolver.cs create mode 100644 Dalamud/Game/Text/Evaluator/SeStringEvaluator.cs create mode 100644 Dalamud/Game/Text/Evaluator/SeStringParameter.cs create mode 100644 Dalamud/Game/Text/Noun/Enums/EnglishArticleType.cs create mode 100644 Dalamud/Game/Text/Noun/Enums/FrenchArticleType.cs create mode 100644 Dalamud/Game/Text/Noun/Enums/GermanArticleType.cs create mode 100644 Dalamud/Game/Text/Noun/Enums/JapaneseArticleType.cs create mode 100644 Dalamud/Game/Text/Noun/NounParams.cs create mode 100644 Dalamud/Game/Text/Noun/NounProcessor.cs create mode 100644 Dalamud/Interface/Internal/Windows/Data/WidgetUtil.cs create mode 100644 Dalamud/Interface/Internal/Windows/Data/Widgets/NounProcessorWidget.cs create mode 100644 Dalamud/Interface/Internal/Windows/Data/Widgets/SeStringCreatorWidget.cs create mode 100644 Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/NounProcessorAgingStep.cs create mode 100644 Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/SeStringEvaluatorAgingStep.cs create mode 100644 Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/SheetRedirectResolverAgingStep.cs create mode 100644 Dalamud/Plugin/Services/ISeStringEvaluator.cs create mode 100644 Dalamud/Utility/ActionKindExtensions.cs create mode 100644 Dalamud/Utility/ItemUtil.cs create mode 100644 Dalamud/Utility/ObjectKindExtensions.cs diff --git a/Dalamud/Game/ActionKind.cs b/Dalamud/Game/ActionKind.cs new file mode 100644 index 000000000..9a574f9a8 --- /dev/null +++ b/Dalamud/Game/ActionKind.cs @@ -0,0 +1,89 @@ +namespace Dalamud.Game; + +/// +/// Enum describing possible action kinds. +/// +public enum ActionKind +{ + /// + /// A Trait. + /// + Trait = 0, + + /// + /// An Action. + /// + Action = 1, + + /// + /// A usable Item. + /// + Item = 2, // does not work? + + /// + /// A usable EventItem. + /// + EventItem = 3, // does not work? + + /// + /// An EventAction. + /// + EventAction = 4, + + /// + /// A GeneralAction. + /// + GeneralAction = 5, + + /// + /// A BuddyAction. + /// + BuddyAction = 6, + + /// + /// A MainCommand. + /// + MainCommand = 7, + + /// + /// A Companion. + /// + Companion = 8, // unresolved?! + + /// + /// A CraftAction. + /// + CraftAction = 9, + + /// + /// An Action (again). + /// + Action2 = 10, // what's the difference? + + /// + /// A PetAction. + /// + PetAction = 11, + + /// + /// A CompanyAction. + /// + CompanyAction = 12, + + /// + /// A Mount. + /// + Mount = 13, + + // 14-18 unused + + /// + /// A BgcArmyAction. + /// + BgcArmyAction = 19, + + /// + /// An Ornament. + /// + Ornament = 20, +} diff --git a/Dalamud/Game/Gui/GameGui.cs b/Dalamud/Game/Gui/GameGui.cs index 7cd6d7360..1041464a7 100644 --- a/Dalamud/Game/Gui/GameGui.cs +++ b/Dalamud/Game/Gui/GameGui.cs @@ -323,7 +323,7 @@ internal sealed unsafe class GameGui : IInternalDisposableService, IGameGui return ret; } - private void HandleActionHoverDetour(AgentActionDetail* hoverState, ActionKind actionKind, uint actionId, int a4, byte a5) + private void HandleActionHoverDetour(AgentActionDetail* hoverState, FFXIVClientStructs.FFXIV.Client.UI.Agent.ActionKind actionKind, uint actionId, int a4, byte a5) { this.handleActionHoverHook.Original(hoverState, actionKind, actionId, a4, a5); this.HoveredAction.ActionKind = (HoverActionKind)actionKind; diff --git a/Dalamud/Game/Text/Evaluator/Internal/SeStringBuilderIconWrap.cs b/Dalamud/Game/Text/Evaluator/Internal/SeStringBuilderIconWrap.cs new file mode 100644 index 000000000..65567d240 --- /dev/null +++ b/Dalamud/Game/Text/Evaluator/Internal/SeStringBuilderIconWrap.cs @@ -0,0 +1,30 @@ +using Lumina.Text; + +namespace Dalamud.Game.Text.Evaluator.Internal; + +/// +/// Wraps payloads in an open and close icon, for example the Auto Translation open/close brackets. +/// +internal readonly struct SeStringBuilderIconWrap : IDisposable +{ + private readonly SeStringBuilder builder; + private readonly uint iconClose; + + /// + /// Initializes a new instance of the struct.
+ /// Appends an icon macro with on creation, and an icon macro with + /// on disposal. + ///
+ /// The builder to use. + /// The open icon id. + /// The close icon id. + public SeStringBuilderIconWrap(SeStringBuilder builder, uint iconOpen, uint iconClose) + { + this.builder = builder; + this.iconClose = iconClose; + this.builder.AppendIcon(iconOpen); + } + + /// + public void Dispose() => this.builder.AppendIcon(this.iconClose); +} diff --git a/Dalamud/Game/Text/Evaluator/Internal/SeStringContext.cs b/Dalamud/Game/Text/Evaluator/Internal/SeStringContext.cs new file mode 100644 index 000000000..a32702f6c --- /dev/null +++ b/Dalamud/Game/Text/Evaluator/Internal/SeStringContext.cs @@ -0,0 +1,83 @@ +using System.Globalization; + +using Dalamud.Utility; + +using Lumina.Text; +using Lumina.Text.ReadOnly; + +namespace Dalamud.Game.Text.Evaluator.Internal; + +/// +/// A context wrapper used in . +/// +internal readonly ref struct SeStringContext +{ + /// + /// The to append text and macros to. + /// + internal readonly SeStringBuilder Builder; + + /// + /// A list of local parameters. + /// + internal readonly Span LocalParameters; + + /// + /// The target language, used for sheet lookups. + /// + internal readonly ClientLanguage Language; + + /// + /// Initializes a new instance of the struct. + /// + /// The to append text and macros to. + /// A list of local parameters. + /// The target language, used for sheet lookups. + internal SeStringContext(SeStringBuilder builder, Span localParameters, ClientLanguage language) + { + this.Builder = builder; + this.LocalParameters = localParameters; + this.Language = language; + } + + /// + /// Gets the of the current target . + /// + internal CultureInfo CultureInfo => Localization.GetCultureInfoFromLangCode(this.Language.ToCode()); + + /// + /// Tries to get a number from the local parameters at the specified index. + /// + /// The index in the list. + /// The local parameter number. + /// true if the local parameters list contained a parameter at given index, false otherwise. + internal bool TryGetLNum(int index, out uint value) + { + if (index >= 0 && this.LocalParameters.Length > index) + { + value = this.LocalParameters[index].UIntValue; + return true; + } + + value = 0; + return false; + } + + /// + /// Tries to get a string from the local parameters at the specified index. + /// + /// The index in the list. + /// The local parameter string. + /// true if the local parameters list contained a parameter at given index, false otherwise. + internal bool TryGetLStr(int index, out ReadOnlySeString value) + { + if (index >= 0 && this.LocalParameters.Length > index) + { + value = this.LocalParameters[index].StringValue; + return true; + } + + value = default; + return false; + } +} diff --git a/Dalamud/Game/Text/Evaluator/Internal/SheetRedirectFlags.cs b/Dalamud/Game/Text/Evaluator/Internal/SheetRedirectFlags.cs new file mode 100644 index 000000000..1c1171873 --- /dev/null +++ b/Dalamud/Game/Text/Evaluator/Internal/SheetRedirectFlags.cs @@ -0,0 +1,49 @@ +namespace Dalamud.Game.Text.Evaluator.Internal; + +/// +/// An enum providing additional information about the sheet redirect. +/// +[Flags] +internal enum SheetRedirectFlags +{ + /// + /// No flags. + /// + None = 0, + + /// + /// Resolved to a sheet related with items. + /// + Item = 1, + + /// + /// Resolved to the EventItem sheet. + /// + EventItem = 2, + + /// + /// Resolved to a high quality item. + /// + /// + /// Append Addon#9. + /// + HighQuality = 4, + + /// + /// Resolved to a collectible item. + /// + /// + /// Append Addon#150. + /// + Collectible = 8, + + /// + /// Resolved to a sheet related with actions. + /// + Action = 16, + + /// + /// Resolved to the Action sheet. + /// + ActionSheet = 32, +} diff --git a/Dalamud/Game/Text/Evaluator/Internal/SheetRedirectResolver.cs b/Dalamud/Game/Text/Evaluator/Internal/SheetRedirectResolver.cs new file mode 100644 index 000000000..57a58c80d --- /dev/null +++ b/Dalamud/Game/Text/Evaluator/Internal/SheetRedirectResolver.cs @@ -0,0 +1,232 @@ +using Dalamud.Data; +using Dalamud.Utility; + +using Lumina.Extensions; + +using ItemKind = Dalamud.Game.Text.SeStringHandling.Payloads.ItemPayload.ItemKind; +using LSheets = Lumina.Excel.Sheets; + +namespace Dalamud.Game.Text.Evaluator.Internal; + +/// +/// A service to resolve sheet redirects in expressions. +/// +[ServiceManager.EarlyLoadedService] +internal class SheetRedirectResolver : IServiceType +{ + private static readonly (string SheetName, uint ColumnIndex, bool ReturnActionSheetFlag)[] ActStrSheets = + [ + (nameof(LSheets.Trait), 0, false), + (nameof(LSheets.Action), 0, true), + (nameof(LSheets.Item), 0, false), + (nameof(LSheets.EventItem), 0, false), + (nameof(LSheets.EventAction), 0, false), + (nameof(LSheets.GeneralAction), 0, false), + (nameof(LSheets.BuddyAction), 0, false), + (nameof(LSheets.MainCommand), 5, false), + (nameof(LSheets.Companion), 0, false), + (nameof(LSheets.CraftAction), 0, false), + (nameof(LSheets.Action), 0, true), + (nameof(LSheets.PetAction), 0, false), + (nameof(LSheets.CompanyAction), 0, false), + (nameof(LSheets.Mount), 0, false), + (string.Empty, 0, false), + (string.Empty, 0, false), + (string.Empty, 0, false), + (string.Empty, 0, false), + (string.Empty, 0, false), + (nameof(LSheets.BgcArmyAction), 1, false), + (nameof(LSheets.Ornament), 8, false), + ]; + + private static readonly string[] ObjStrSheetNames = + [ + nameof(LSheets.BNpcName), + nameof(LSheets.ENpcResident), + nameof(LSheets.Treasure), + nameof(LSheets.Aetheryte), + nameof(LSheets.GatheringPointName), + nameof(LSheets.EObjName), + nameof(LSheets.Mount), + nameof(LSheets.Companion), + string.Empty, + string.Empty, + nameof(LSheets.Item), + ]; + + [ServiceManager.ServiceDependency] + private readonly DataManager dataManager = Service.Get(); + + [ServiceManager.ServiceConstructor] + private SheetRedirectResolver() + { + } + + /// + /// Resolves the sheet redirect, if any is present. + /// + /// The sheet name. + /// The row id. + /// The column index. Use ushort.MaxValue as default. + /// Flags giving additional information about the redirect. + internal SheetRedirectFlags Resolve(ref string sheetName, ref uint rowId, ref uint colIndex) + { + var flags = SheetRedirectFlags.None; + + switch (sheetName) + { + case nameof(LSheets.Item) or "ItemHQ" or "ItemMP": + { + flags |= SheetRedirectFlags.Item; + + var (itemId, kind) = ItemUtil.GetBaseId(rowId); + + if (kind == ItemKind.Hq || sheetName == "ItemHQ") + { + flags |= SheetRedirectFlags.HighQuality; + } + else if (kind == ItemKind.Collectible || sheetName == "ItemMP") // MP for Masterpiece?! + { + flags |= SheetRedirectFlags.Collectible; + } + + if (kind == ItemKind.EventItem && + rowId - 2_000_000 <= this.dataManager.GetExcelSheet().Count) + { + flags |= SheetRedirectFlags.EventItem; + sheetName = nameof(LSheets.EventItem); + } + else + { + sheetName = nameof(LSheets.Item); + rowId = itemId; + } + + if (colIndex is >= 4 and <= 7) + return SheetRedirectFlags.None; + + break; + } + + case "ActStr": + { + var returnActionSheetFlag = false; + (var index, rowId) = uint.DivRem(rowId, 1000000); + if (index < ActStrSheets.Length) + (sheetName, colIndex, returnActionSheetFlag) = ActStrSheets[index]; + + if (sheetName != nameof(LSheets.Companion) && colIndex != 13) + flags |= SheetRedirectFlags.Action; + + if (returnActionSheetFlag) + flags |= SheetRedirectFlags.ActionSheet; + + break; + } + + case "ObjStr": + { + (var index, rowId) = uint.DivRem(rowId, 1000000); + if (index < ObjStrSheetNames.Length) + sheetName = ObjStrSheetNames[index]; + + colIndex = 0; + + switch (index) + { + case 0: // BNpcName + if (rowId >= 100000) + rowId += 900000; + break; + + case 1: // ENpcResident + rowId += 1000000; + break; + + case 2: // Treasure + if (this.dataManager.GetExcelSheet().TryGetRow(rowId, out var treasureRow) && + treasureRow.Unknown0.IsEmpty) + rowId = 0; // defaulting to "Treasure Coffer" + break; + + case 3: // Aetheryte + rowId = this.dataManager.GetExcelSheet() + .TryGetRow(rowId, out var aetheryteRow) && aetheryteRow.IsAetheryte + ? 0u // "Aetheryte" + : 1; // "Aethernet Shard" + break; + + case 5: // EObjName + rowId += 2000000; + break; + } + + break; + } + + case nameof(LSheets.EObj) when colIndex is <= 7 or ushort.MaxValue: + sheetName = nameof(LSheets.EObjName); + break; + + case nameof(LSheets.Treasure) + when this.dataManager.GetExcelSheet().TryGetRow(rowId, out var treasureRow) && + treasureRow.Unknown0.IsEmpty: + rowId = 0; // defaulting to "Treasure Coffer" + break; + + case "WeatherPlaceName": + { + sheetName = nameof(LSheets.PlaceName); + + var placeNameSubId = rowId; + if (this.dataManager.GetExcelSheet().TryGetFirst( + r => r.PlaceNameSub.RowId == placeNameSubId, + out var row)) + rowId = row.PlaceNameParent.RowId; + break; + } + + case nameof(LSheets.InstanceContent) when colIndex == 3: + { + sheetName = nameof(LSheets.ContentFinderCondition); + colIndex = 43; + + if (this.dataManager.GetExcelSheet().TryGetRow(rowId, out var row)) + rowId = row.Order; + break; + } + + case nameof(LSheets.PartyContent) when colIndex == 2: + { + sheetName = nameof(LSheets.ContentFinderCondition); + colIndex = 43; + + if (this.dataManager.GetExcelSheet().TryGetRow(rowId, out var row)) + rowId = row.ContentFinderCondition.RowId; + break; + } + + case nameof(LSheets.PublicContent) when colIndex == 3: + { + sheetName = nameof(LSheets.ContentFinderCondition); + colIndex = 43; + + if (this.dataManager.GetExcelSheet().TryGetRow(rowId, out var row)) + rowId = row.ContentFinderCondition.RowId; + break; + } + + case nameof(LSheets.AkatsukiNote): + { + sheetName = nameof(LSheets.AkatsukiNoteString); + colIndex = 0; + + if (this.dataManager.Excel.GetSubrowSheet().TryGetRow(rowId, out var row)) + rowId = (uint)row[0].Unknown2; + break; + } + } + + return flags; + } +} diff --git a/Dalamud/Game/Text/Evaluator/SeStringEvaluator.cs b/Dalamud/Game/Text/Evaluator/SeStringEvaluator.cs new file mode 100644 index 000000000..723dbcb41 --- /dev/null +++ b/Dalamud/Game/Text/Evaluator/SeStringEvaluator.cs @@ -0,0 +1,1995 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; + +using Dalamud.Configuration.Internal; +using Dalamud.Data; +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Game.Config; +using Dalamud.Game.Text.Evaluator.Internal; +using Dalamud.Game.Text.Noun; +using Dalamud.Game.Text.Noun.Enums; +using Dalamud.Logging.Internal; +using Dalamud.Plugin.Services; +using Dalamud.Utility; + +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Client.Game.UI; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Client.UI.Info; +using FFXIVClientStructs.FFXIV.Client.UI.Misc; +using FFXIVClientStructs.FFXIV.Component.Text; + +using Lumina.Data.Structs.Excel; +using Lumina.Excel; +using Lumina.Excel.Sheets; +using Lumina.Extensions; +using Lumina.Text; +using Lumina.Text.Expressions; +using Lumina.Text.Payloads; +using Lumina.Text.ReadOnly; + +using AddonSheet = Lumina.Excel.Sheets.Addon; + +namespace Dalamud.Game.Text.Evaluator; + +#pragma warning disable SeStringEvaluator + +/// +/// Evaluator for SeStrings. +/// +[ServiceManager.EarlyLoadedService] +internal class SeStringEvaluator : IServiceType, ISeStringEvaluator +{ + private static readonly ModuleLog Log = new("SeStringEvaluator"); + + [ServiceManager.ServiceDependency] + private readonly DataManager dataManager = Service.Get(); + + [ServiceManager.ServiceDependency] + private readonly GameConfig gameConfig = Service.Get(); + + [ServiceManager.ServiceDependency] + private readonly DalamudConfiguration dalamudConfiguration = Service.Get(); + + [ServiceManager.ServiceDependency] + private readonly NounProcessor nounProcessor = Service.Get(); + + [ServiceManager.ServiceDependency] + private readonly SheetRedirectResolver sheetRedirectResolver = Service.Get(); + + private readonly ConcurrentDictionary, string> actStrCache = []; + private readonly ConcurrentDictionary, string> objStrCache = []; + + [ServiceManager.ServiceConstructor] + private SeStringEvaluator() + { + } + + /// + public ReadOnlySeString Evaluate( + ReadOnlySeString str, + Span localParameters = default, + ClientLanguage? language = null) + { + return this.Evaluate(str.AsSpan(), localParameters, language); + } + + /// + public ReadOnlySeString Evaluate( + ReadOnlySeStringSpan str, + Span localParameters = default, + ClientLanguage? language = null) + { + if (str.IsTextOnly()) + return new(str); + + var lang = language ?? this.dalamudConfiguration.EffectiveLanguage.ToClientLanguage(); + + // TODO: remove culture info toggling after supporting CultureInfo for SeStringBuilder.Append, + // and then remove try...finally block (discard builder from the pool on exception) + var previousCulture = CultureInfo.CurrentCulture; + var builder = SeStringBuilder.SharedPool.Get(); + try + { + CultureInfo.CurrentCulture = Localization.GetCultureInfoFromLangCode(lang.ToCode()); + return this.EvaluateAndAppendTo(builder, str, localParameters, lang).ToReadOnlySeString(); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + SeStringBuilder.SharedPool.Return(builder); + } + } + + /// + public ReadOnlySeString EvaluateFromAddon( + uint addonId, + Span localParameters = default, + ClientLanguage? language = null) + { + var lang = language ?? this.dalamudConfiguration.EffectiveLanguage.ToClientLanguage(); + + if (!this.dataManager.GetExcelSheet(lang).TryGetRow(addonId, out var addonRow)) + return default; + + return this.Evaluate(addonRow.Text.AsSpan(), localParameters, lang); + } + + /// + public ReadOnlySeString EvaluateFromLobby( + uint lobbyId, + Span localParameters = default, + ClientLanguage? language = null) + { + var lang = language ?? this.dalamudConfiguration.EffectiveLanguage.ToClientLanguage(); + + if (!this.dataManager.GetExcelSheet(lang).TryGetRow(lobbyId, out var lobbyRow)) + return default; + + return this.Evaluate(lobbyRow.Text.AsSpan(), localParameters, lang); + } + + /// + public ReadOnlySeString EvaluateFromLogMessage( + uint logMessageId, + Span localParameters = default, + ClientLanguage? language = null) + { + var lang = language ?? this.dalamudConfiguration.EffectiveLanguage.ToClientLanguage(); + + if (!this.dataManager.GetExcelSheet(lang).TryGetRow(logMessageId, out var logMessageRow)) + return default; + + return this.Evaluate(logMessageRow.Text.AsSpan(), localParameters, lang); + } + + /// + public string EvaluateActStr(ActionKind actionKind, uint id, ClientLanguage? language = null) => + this.actStrCache.GetOrAdd( + new(actionKind, id, language ?? this.dalamudConfiguration.EffectiveLanguage.ToClientLanguage()), + static (key, t) => t.EvaluateFromAddon(2026, [key.Kind.GetActStrId(key.Id)], key.Language) + .ExtractText() + .StripSoftHyphen(), + this); + + /// + public string EvaluateObjStr(ObjectKind objectKind, uint id, ClientLanguage? language = null) => + this.objStrCache.GetOrAdd( + new(objectKind, id, language ?? this.dalamudConfiguration.EffectiveLanguage.ToClientLanguage()), + static (key, t) => t.EvaluateFromAddon(2025, [key.Kind.GetObjStrId(key.Id)], key.Language) + .ExtractText() + .StripSoftHyphen(), + this); + + // TODO: move this to MapUtil? + private static uint ConvertRawToMapPos(Lumina.Excel.Sheets.Map map, short offset, float value) + { + var scale = map.SizeFactor / 100.0f; + return (uint)(10 - (int)(((((value + offset) * scale) + 1024f) * -0.2f) / scale)); + } + + private static uint ConvertRawToMapPosX(Lumina.Excel.Sheets.Map map, float x) + => ConvertRawToMapPos(map, map.OffsetX, x); + + private static uint ConvertRawToMapPosY(Lumina.Excel.Sheets.Map map, float y) + => ConvertRawToMapPos(map, map.OffsetY, y); + + private SeStringBuilder EvaluateAndAppendTo( + SeStringBuilder builder, + ReadOnlySeStringSpan str, + Span localParameters, + ClientLanguage language) + { + var context = new SeStringContext(builder, localParameters, language); + + foreach (var payload in str) + { + if (!this.ResolvePayload(in context, payload)) + { + context.Builder.Append(payload); + } + } + + return builder; + } + + private bool ResolvePayload(in SeStringContext context, ReadOnlySePayloadSpan payload) + { + if (payload.Type != ReadOnlySePayloadType.Macro) + return false; + + // if (context.HandlePayload(payload, in context)) + // return true; + + switch (payload.MacroCode) + { + case MacroCode.SetResetTime: + return this.TryResolveSetResetTime(in context, payload); + + case MacroCode.SetTime: + return this.TryResolveSetTime(in context, payload); + + case MacroCode.If: + return this.TryResolveIf(in context, payload); + + case MacroCode.Switch: + return this.TryResolveSwitch(in context, payload); + + case MacroCode.PcName: + return this.TryResolvePcName(in context, payload); + + case MacroCode.IfPcGender: + return this.TryResolveIfPcGender(in context, payload); + + case MacroCode.IfPcName: + return this.TryResolveIfPcName(in context, payload); + + // case MacroCode.Josa: + // case MacroCode.Josaro: + + case MacroCode.IfSelf: + return this.TryResolveIfSelf(in context, payload); + + // case MacroCode.NewLine: // pass through + // case MacroCode.Wait: // pass through + // case MacroCode.Icon: // pass through + + case MacroCode.Color: + return this.TryResolveColor(in context, payload); + + case MacroCode.EdgeColor: + return this.TryResolveEdgeColor(in context, payload); + + case MacroCode.ShadowColor: + return this.TryResolveShadowColor(in context, payload); + + // case MacroCode.SoftHyphen: // pass through + // case MacroCode.Key: + // case MacroCode.Scale: + + case MacroCode.Bold: + return this.TryResolveBold(in context, payload); + + case MacroCode.Italic: + return this.TryResolveItalic(in context, payload); + + // case MacroCode.Edge: + // case MacroCode.Shadow: + // case MacroCode.NonBreakingSpace: // pass through + // case MacroCode.Icon2: // pass through + // case MacroCode.Hyphen: // pass through + + case MacroCode.Num: + return this.TryResolveNum(in context, payload); + + case MacroCode.Hex: + return this.TryResolveHex(in context, payload); + + case MacroCode.Kilo: + return this.TryResolveKilo(in context, payload); + + // case MacroCode.Byte: + + case MacroCode.Sec: + return this.TryResolveSec(in context, payload); + + // case MacroCode.Time: + + case MacroCode.Float: + return this.TryResolveFloat(in context, payload); + + // case MacroCode.Link: // pass through + + case MacroCode.Sheet: + return this.TryResolveSheet(in context, payload); + + case MacroCode.String: + return this.TryResolveString(in context, payload); + + case MacroCode.Caps: + return this.TryResolveCaps(in context, payload); + + case MacroCode.Head: + return this.TryResolveHead(in context, payload); + + case MacroCode.Split: + return this.TryResolveSplit(in context, payload); + + case MacroCode.HeadAll: + return this.TryResolveHeadAll(in context, payload); + + case MacroCode.Fixed: + return this.TryResolveFixed(in context, payload); + + case MacroCode.Lower: + return this.TryResolveLower(in context, payload); + + case MacroCode.JaNoun: + return this.TryResolveNoun(ClientLanguage.Japanese, in context, payload); + + case MacroCode.EnNoun: + return this.TryResolveNoun(ClientLanguage.English, in context, payload); + + case MacroCode.DeNoun: + return this.TryResolveNoun(ClientLanguage.German, in context, payload); + + case MacroCode.FrNoun: + return this.TryResolveNoun(ClientLanguage.French, in context, payload); + + // case MacroCode.ChNoun: + + case MacroCode.LowerHead: + return this.TryResolveLowerHead(in context, payload); + + case MacroCode.ColorType: + return this.TryResolveColorType(in context, payload); + + case MacroCode.EdgeColorType: + return this.TryResolveEdgeColorType(in context, payload); + + // case MacroCode.Ruby: + + case MacroCode.Digit: + return this.TryResolveDigit(in context, payload); + + case MacroCode.Ordinal: + return this.TryResolveOrdinal(in context, payload); + + // case MacroCode.Sound: // pass through + + case MacroCode.LevelPos: + return this.TryResolveLevelPos(in context, payload); + + default: + return false; + } + } + + private unsafe bool TryResolveSetResetTime(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + DateTime date; + + if (payload.TryGetExpression(out var eHour, out var eWeekday) + && this.TryResolveInt(in context, eHour, out var eHourVal) + && this.TryResolveInt(in context, eWeekday, out var eWeekdayVal)) + { + var t = DateTime.UtcNow.AddDays(((eWeekdayVal - (int)DateTime.UtcNow.DayOfWeek) + 7) % 7); + date = new DateTime(t.Year, t.Month, t.Day, eHourVal, 0, 0, DateTimeKind.Utc).ToLocalTime(); + } + else if (payload.TryGetExpression(out eHour) + && this.TryResolveInt(in context, eHour, out eHourVal)) + { + var t = DateTime.UtcNow; + date = new DateTime(t.Year, t.Month, t.Day, eHourVal, 0, 0, DateTimeKind.Utc).ToLocalTime(); + } + else + { + return false; + } + + MacroDecoder.GetMacroTime()->SetTime(date); + + return true; + } + + private unsafe bool TryResolveSetTime(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eTime) || !this.TryResolveUInt(in context, eTime, out var eTimeVal)) + return false; + + var date = DateTimeOffset.FromUnixTimeSeconds(eTimeVal).LocalDateTime; + MacroDecoder.GetMacroTime()->SetTime(date); + + return true; + } + + private bool TryResolveIf(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + return + payload.TryGetExpression(out var eCond, out var eTrue, out var eFalse) + && this.ResolveStringExpression( + context, + this.TryResolveBool(in context, eCond, out var eCondVal) && eCondVal + ? eTrue + : eFalse); + } + + private bool TryResolveSwitch(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + var cond = -1; + foreach (var e in payload) + { + switch (cond) + { + case -1: + cond = this.TryResolveUInt(in context, e, out var eVal) ? (int)eVal : 0; + break; + case > 1: + cond--; + break; + default: + return this.ResolveStringExpression(in context, e); + } + } + + return false; + } + + private unsafe bool TryResolvePcName(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eEntityId)) + return false; + + if (!this.TryResolveUInt(in context, eEntityId, out var entityId)) + return false; + + // TODO: handle LogNameType + + NameCache.CharacterInfo characterInfo = default; + if (NameCache.Instance()->TryGetCharacterInfoByEntityId(entityId, &characterInfo)) + { + context.Builder.Append((ReadOnlySeStringSpan)characterInfo.Name.AsSpan()); + + if (characterInfo.HomeWorldId != AgentLobby.Instance()->LobbyData.HomeWorldId && + WorldHelper.Instance()->AllWorlds.TryGetValue((ushort)characterInfo.HomeWorldId, out var world, false)) + { + context.Builder.AppendIcon(88); + + if (this.gameConfig.UiConfig.TryGetUInt("LogCrossWorldName", out var logCrossWorldName) && + logCrossWorldName == 1) + context.Builder.Append((ReadOnlySeStringSpan)world.Name); + } + + return true; + } + + // TODO: lookup via InstanceContentCrystallineConflictDirector + // TODO: lookup via MJIManager + + return false; + } + + private unsafe bool TryResolveIfPcGender(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eEntityId, out var eMale, out var eFemale)) + return false; + + if (!this.TryResolveUInt(in context, eEntityId, out var entityId)) + return false; + + NameCache.CharacterInfo characterInfo = default; + if (NameCache.Instance()->TryGetCharacterInfoByEntityId(entityId, &characterInfo)) + return this.ResolveStringExpression(in context, characterInfo.Sex == 0 ? eMale : eFemale); + + // TODO: lookup via InstanceContentCrystallineConflictDirector + + return false; + } + + private unsafe bool TryResolveIfPcName(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eEntityId, out var eName, out var eTrue, out var eFalse)) + return false; + + if (!this.TryResolveUInt(in context, eEntityId, out var entityId) || !eName.TryGetString(out var name)) + return false; + + name = this.Evaluate(name, context.LocalParameters, context.Language).AsSpan(); + + NameCache.CharacterInfo characterInfo = default; + return NameCache.Instance()->TryGetCharacterInfoByEntityId(entityId, &characterInfo) && + this.ResolveStringExpression( + context, + name.Equals(characterInfo.Name.AsSpan()) + ? eTrue + : eFalse); + } + + private unsafe bool TryResolveIfSelf(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eEntityId, out var eTrue, out var eFalse)) + return false; + + if (!this.TryResolveUInt(in context, eEntityId, out var entityId)) + return false; + + // the game uses LocalPlayer here, but using PlayerState seems more safe. + return this.ResolveStringExpression(in context, PlayerState.Instance()->EntityId == entityId ? eTrue : eFalse); + } + + private bool TryResolveColor(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eColor)) + return false; + + if (eColor.TryGetPlaceholderExpression(out var ph) && ph == (int)ExpressionType.StackColor) + context.Builder.PopColor(); + else if (this.TryResolveUInt(in context, eColor, out var eColorVal)) + context.Builder.PushColorBgra(eColorVal); + + return true; + } + + private bool TryResolveEdgeColor(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eColor)) + return false; + + if (eColor.TryGetPlaceholderExpression(out var ph) && ph == (int)ExpressionType.StackColor) + context.Builder.PopEdgeColor(); + else if (this.TryResolveUInt(in context, eColor, out var eColorVal)) + context.Builder.PushEdgeColorBgra(eColorVal); + + return true; + } + + private bool TryResolveShadowColor(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eColor)) + return false; + + if (eColor.TryGetPlaceholderExpression(out var ph) && ph == (int)ExpressionType.StackColor) + context.Builder.PopShadowColor(); + else if (this.TryResolveUInt(in context, eColor, out var eColorVal)) + context.Builder.PushShadowColorBgra(eColorVal); + + return true; + } + + private bool TryResolveBold(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eEnable) || + !this.TryResolveBool(in context, eEnable, out var eEnableVal)) + return false; + + context.Builder.AppendSetBold(eEnableVal); + + return true; + } + + private bool TryResolveItalic(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eEnable) || + !this.TryResolveBool(in context, eEnable, out var eEnableVal)) + return false; + + context.Builder.AppendSetItalic(eEnableVal); + + return true; + } + + private bool TryResolveNum(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eInt) || !this.TryResolveInt(in context, eInt, out var eIntVal)) + { + context.Builder.Append('0'); + return true; + } + + context.Builder.Append(eIntVal.ToString()); + + return true; + } + + private bool TryResolveHex(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eUInt) || !this.TryResolveUInt(in context, eUInt, out var eUIntVal)) + { + // TODO: throw? + // ERROR: mismatch parameter type ('' is not numeric) + return false; + } + + context.Builder.Append("0x{0:X08}".Format(eUIntVal)); + + return true; + } + + private bool TryResolveKilo(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eInt, out var eSep) || + !this.TryResolveInt(in context, eInt, out var eIntVal)) + { + context.Builder.Append('0'); + return true; + } + + if (eIntVal == int.MinValue) + { + // -2147483648 + context.Builder.Append("-2"u8); + this.ResolveStringExpression(in context, eSep); + context.Builder.Append("147"u8); + this.ResolveStringExpression(in context, eSep); + context.Builder.Append("483"u8); + this.ResolveStringExpression(in context, eSep); + context.Builder.Append("648"u8); + return true; + } + + if (eIntVal < 0) + { + context.Builder.Append('-'); + eIntVal = -eIntVal; + } + + if (eIntVal == 0) + { + context.Builder.Append('0'); + return true; + } + + var anyDigitPrinted = false; + for (var i = 1_000_000_000; i > 0; i /= 10) + { + var digit = (eIntVal / i) % 10; + switch (anyDigitPrinted) + { + case false when digit == 0: + continue; + case true when i % 3 == 0: + this.ResolveStringExpression(in context, eSep); + break; + } + + anyDigitPrinted = true; + context.Builder.Append((char)('0' + digit)); + } + + return true; + } + + private bool TryResolveSec(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eInt) || !this.TryResolveUInt(in context, eInt, out var eIntVal)) + { + // TODO: throw? + // ERROR: mismatch parameter type ('' is not numeric) + return false; + } + + context.Builder.Append("{0:00}".Format(eIntVal)); + return true; + } + + private bool TryResolveFloat(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eValue, out var eRadix, out var eSeparator) + || !this.TryResolveInt(in context, eValue, out var eValueVal) + || !this.TryResolveInt(in context, eRadix, out var eRadixVal)) + { + return false; + } + + var (integerPart, fractionalPart) = int.DivRem(eValueVal, eRadixVal); + if (fractionalPart < 0) + { + integerPart--; + fractionalPart += eRadixVal; + } + + context.Builder.Append(integerPart.ToString()); + this.ResolveStringExpression(in context, eSeparator); + + // brain fried code + Span fractionalDigits = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + var pos = fractionalDigits.Length - 1; + for (var r = eRadixVal; r > 1; r /= 10) + { + fractionalDigits[pos--] = (byte)('0' + (fractionalPart % 10)); + fractionalPart /= 10; + } + + context.Builder.Append(fractionalDigits[(pos + 1)..]); + + return true; + } + + private bool TryResolveSheet(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + var enu = payload.GetEnumerator(); + + if (!enu.MoveNext() || !enu.Current.TryGetString(out var eSheetNameStr)) + return false; + + if (!enu.MoveNext() || !this.TryResolveUInt(in context, enu.Current, out var eRowIdValue)) + return false; + + if (!enu.MoveNext() || !this.TryResolveUInt(in context, enu.Current, out var eColIndexValue)) + return false; + + var eColParamValue = 0u; + if (enu.MoveNext()) + this.TryResolveUInt(in context, enu.Current, out eColParamValue); + + var resolvedSheetName = this.Evaluate(eSheetNameStr, context.LocalParameters, context.Language).ExtractText(); + + this.sheetRedirectResolver.Resolve(ref resolvedSheetName, ref eRowIdValue, ref eColIndexValue); + + if (string.IsNullOrEmpty(resolvedSheetName)) + return false; + + if (!this.dataManager.Excel.SheetNames.Contains(resolvedSheetName)) + return false; + + if (!this.dataManager.GetExcelSheet(context.Language, resolvedSheetName) + .TryGetRow(eRowIdValue, out var row)) + return false; + + if (eColIndexValue >= row.Columns.Count) + return false; + + var column = row.Columns[(int)eColIndexValue]; + switch (column.Type) + { + case ExcelColumnDataType.String: + context.Builder.Append(this.Evaluate(row.ReadString(column.Offset), [eColParamValue], context.Language)); + return true; + case ExcelColumnDataType.Bool: + context.Builder.Append((row.ReadBool(column.Offset) ? 1u : 0).ToString("D", CultureInfo.InvariantCulture)); + return true; + case ExcelColumnDataType.Int8: + context.Builder.Append(row.ReadInt8(column.Offset).ToString("D", CultureInfo.InvariantCulture)); + return true; + case ExcelColumnDataType.UInt8: + context.Builder.Append(row.ReadUInt8(column.Offset).ToString("D", CultureInfo.InvariantCulture)); + return true; + case ExcelColumnDataType.Int16: + context.Builder.Append(row.ReadInt16(column.Offset).ToString("D", CultureInfo.InvariantCulture)); + return true; + case ExcelColumnDataType.UInt16: + context.Builder.Append(row.ReadUInt16(column.Offset).ToString("D", CultureInfo.InvariantCulture)); + return true; + case ExcelColumnDataType.Int32: + context.Builder.Append(row.ReadInt32(column.Offset).ToString("D", CultureInfo.InvariantCulture)); + return true; + case ExcelColumnDataType.UInt32: + context.Builder.Append(row.ReadUInt32(column.Offset).ToString("D", CultureInfo.InvariantCulture)); + return true; + case ExcelColumnDataType.Float32: + context.Builder.Append(row.ReadFloat32(column.Offset).ToString("D", CultureInfo.InvariantCulture)); + return true; + case ExcelColumnDataType.Int64: + context.Builder.Append(row.ReadInt64(column.Offset).ToString("D", CultureInfo.InvariantCulture)); + return true; + case ExcelColumnDataType.UInt64: + context.Builder.Append(row.ReadUInt64(column.Offset).ToString("D", CultureInfo.InvariantCulture)); + return true; + case ExcelColumnDataType.PackedBool0: + context.Builder.Append((row.ReadPackedBool(column.Offset, 0) ? 1u : 0).ToString("D", CultureInfo.InvariantCulture)); + return true; + case ExcelColumnDataType.PackedBool1: + context.Builder.Append((row.ReadPackedBool(column.Offset, 1) ? 1u : 0).ToString("D", CultureInfo.InvariantCulture)); + return true; + case ExcelColumnDataType.PackedBool2: + context.Builder.Append((row.ReadPackedBool(column.Offset, 2) ? 1u : 0).ToString("D", CultureInfo.InvariantCulture)); + return true; + case ExcelColumnDataType.PackedBool3: + context.Builder.Append((row.ReadPackedBool(column.Offset, 3) ? 1u : 0).ToString("D", CultureInfo.InvariantCulture)); + return true; + case ExcelColumnDataType.PackedBool4: + context.Builder.Append((row.ReadPackedBool(column.Offset, 4) ? 1u : 0).ToString("D", CultureInfo.InvariantCulture)); + return true; + case ExcelColumnDataType.PackedBool5: + context.Builder.Append((row.ReadPackedBool(column.Offset, 5) ? 1u : 0).ToString("D", CultureInfo.InvariantCulture)); + return true; + case ExcelColumnDataType.PackedBool6: + context.Builder.Append((row.ReadPackedBool(column.Offset, 6) ? 1u : 0).ToString("D", CultureInfo.InvariantCulture)); + return true; + case ExcelColumnDataType.PackedBool7: + context.Builder.Append((row.ReadPackedBool(column.Offset, 7) ? 1u : 0).ToString("D", CultureInfo.InvariantCulture)); + return true; + default: + return false; + } + } + + private bool TryResolveString(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + return payload.TryGetExpression(out var eStr) && this.ResolveStringExpression(in context, eStr); + } + + private bool TryResolveCaps(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eStr)) + return false; + + var builder = SeStringBuilder.SharedPool.Get(); + + try + { + var headContext = new SeStringContext(builder, context.LocalParameters, context.Language); + + if (!this.ResolveStringExpression(headContext, eStr)) + return false; + + var str = builder.ToReadOnlySeString(); + var pIdx = 0; + + foreach (var p in str) + { + pIdx++; + + if (p.Type == ReadOnlySePayloadType.Invalid) + continue; + + if (pIdx == 1 && p.Type == ReadOnlySePayloadType.Text) + { + context.Builder.Append(Encoding.UTF8.GetString(p.Body.ToArray()).ToUpper(context.CultureInfo)); + continue; + } + + context.Builder.Append(p); + } + + return true; + } + finally + { + SeStringBuilder.SharedPool.Return(builder); + } + } + + private bool TryResolveHead(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eStr)) + return false; + + var builder = SeStringBuilder.SharedPool.Get(); + + try + { + var headContext = new SeStringContext(builder, context.LocalParameters, context.Language); + + if (!this.ResolveStringExpression(headContext, eStr)) + return false; + + var str = builder.ToReadOnlySeString(); + var pIdx = 0; + + foreach (var p in str) + { + pIdx++; + + if (p.Type == ReadOnlySePayloadType.Invalid) + continue; + + if (pIdx == 1 && p.Type == ReadOnlySePayloadType.Text) + { + context.Builder.Append(Encoding.UTF8.GetString(p.Body.Span).FirstCharToUpper(context.CultureInfo)); + continue; + } + + context.Builder.Append(p); + } + + return true; + } + finally + { + SeStringBuilder.SharedPool.Return(builder); + } + } + + private bool TryResolveSplit(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eText, out var eSeparator, out var eIndex)) + return false; + + if (!eSeparator.TryGetString(out var eSeparatorVal) || !eIndex.TryGetUInt(out var eIndexVal) || eIndexVal <= 0) + return false; + + var builder = SeStringBuilder.SharedPool.Get(); + + try + { + var headContext = new SeStringContext(builder, context.LocalParameters, context.Language); + + if (!this.ResolveStringExpression(headContext, eText)) + return false; + + var separator = eSeparatorVal.ExtractText(); + if (separator.Length < 1) + return false; + + var splitted = builder.ToReadOnlySeString().ExtractText().Split(separator[0]); + if (eIndexVal <= splitted.Length) + { + context.Builder.Append(splitted[eIndexVal - 1]); + return true; + } + + return false; + } + finally + { + SeStringBuilder.SharedPool.Return(builder); + } + } + + private bool TryResolveHeadAll(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eStr)) + return false; + + var builder = SeStringBuilder.SharedPool.Get(); + + try + { + var headContext = new SeStringContext(builder, context.LocalParameters, context.Language); + + if (!this.ResolveStringExpression(headContext, eStr)) + return false; + + var str = builder.ToReadOnlySeString(); + + foreach (var p in str) + { + if (p.Type == ReadOnlySePayloadType.Invalid) + continue; + + if (p.Type == ReadOnlySePayloadType.Text) + { + context.Builder.Append( + context.CultureInfo.TextInfo.ToTitleCase(Encoding.UTF8.GetString(p.Body.Span))); + + continue; + } + + context.Builder.Append(p); + } + + return true; + } + finally + { + SeStringBuilder.SharedPool.Return(builder); + } + } + + private bool TryResolveFixed(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + // This is handled by the second function in Client::UI::Misc::PronounModule_ProcessString + + var enu = payload.GetEnumerator(); + + if (!enu.MoveNext() || !this.TryResolveInt(in context, enu.Current, out var e0Val)) + return false; + + if (!enu.MoveNext() || !this.TryResolveInt(in context, enu.Current, out var e1Val)) + return false; + + return e0Val switch + { + 100 or 200 => e1Val switch + { + 1 => this.TryResolveFixedPlayerLink(in context, ref enu), + 2 => this.TryResolveFixedClassJobLevel(in context, ref enu), + 3 => this.TryResolveFixedMapLink(in context, ref enu), + 4 => this.TryResolveFixedItemLink(in context, ref enu), + 5 => this.TryResolveFixedChatSoundEffect(in context, ref enu), + 6 => this.TryResolveFixedObjStr(in context, ref enu), + 7 => this.TryResolveFixedString(in context, ref enu), + 8 => this.TryResolveFixedTimeRemaining(in context, ref enu), + // Reads a uint and saves it to PronounModule+0x3AC + // TODO: handle this? looks like it's for the mentor/beginner icon of the player link in novice network + // see "FF 50 50 8B B0" + 9 => true, + 10 => this.TryResolveFixedStatusLink(in context, ref enu), + 11 => this.TryResolveFixedPartyFinderLink(in context, ref enu), + 12 => this.TryResolveFixedQuestLink(in context, ref enu), + _ => false, + }, + _ => this.TryResolveFixedAutoTranslation(in context, payload, e0Val, e1Val), + }; + } + + private unsafe bool TryResolveFixedPlayerLink(in SeStringContext context, ref ReadOnlySePayloadSpan.Enumerator enu) + { + if (!enu.MoveNext() || !this.TryResolveUInt(in context, enu.Current, out var worldId)) + return false; + + if (!enu.MoveNext() || !enu.Current.TryGetString(out var playerName)) + return false; + + if (UIGlobals.IsValidPlayerCharacterName(playerName.ExtractText())) + { + var flags = 0u; + if (InfoModule.Instance()->IsInCrossWorldDuty()) + flags |= 0x10; + + context.Builder.PushLink(LinkMacroPayloadType.Character, flags, worldId, 0u, playerName); + context.Builder.Append(playerName); + context.Builder.PopLink(); + } + else + { + context.Builder.Append(playerName); + } + + if (worldId == AgentLobby.Instance()->LobbyData.HomeWorldId) + return true; + + if (!this.dataManager.GetExcelSheet(context.Language).TryGetRow(worldId, out var worldRow)) + return false; + + context.Builder.AppendIcon(88); + context.Builder.Append(worldRow.Name); + + return true; + } + + private bool TryResolveFixedClassJobLevel(in SeStringContext context, ref ReadOnlySePayloadSpan.Enumerator enu) + { + if (!enu.MoveNext() || !this.TryResolveInt(in context, enu.Current, out var classJobId) || classJobId <= 0) + return false; + + if (!enu.MoveNext() || !this.TryResolveInt(in context, enu.Current, out var level)) + return false; + + if (!this.dataManager.GetExcelSheet(context.Language) + .TryGetRow((uint)classJobId, out var classJobRow)) + return false; + + context.Builder.Append(classJobRow.Name); + + if (level != 0) + context.Builder.Append(context.CultureInfo, $"({level:D})"); + + return true; + } + + private bool TryResolveFixedMapLink(in SeStringContext context, ref ReadOnlySePayloadSpan.Enumerator enu) + { + if (!enu.MoveNext() || !this.TryResolveUInt(in context, enu.Current, out var territoryTypeId)) + return false; + + if (!enu.MoveNext() || !this.TryResolveUInt(in context, enu.Current, out var packedIds)) + return false; + + if (!enu.MoveNext() || !this.TryResolveInt(in context, enu.Current, out var rawX)) + return false; + + if (!enu.MoveNext() || !this.TryResolveInt(in context, enu.Current, out var rawY)) + return false; + + if (!enu.MoveNext() || !this.TryResolveInt(in context, enu.Current, out var rawZ)) + return false; + + if (!enu.MoveNext() || !this.TryResolveUInt(in context, enu.Current, out var placeNameIdInt)) + return false; + + var instance = packedIds >> 0x10; + var mapId = packedIds & 0xFF; + + if (this.dataManager.GetExcelSheet(context.Language) + .TryGetRow(territoryTypeId, out var territoryTypeRow)) + { + if (!this.dataManager.GetExcelSheet(context.Language) + .TryGetRow( + placeNameIdInt == 0 ? territoryTypeRow.PlaceName.RowId : placeNameIdInt, + out var placeNameRow)) + return false; + + if (!this.dataManager.GetExcelSheet().TryGetRow(mapId, out var mapRow)) + return false; + + var sb = SeStringBuilder.SharedPool.Get(); + + sb.Append(placeNameRow.Name); + if (instance is > 0 and <= 9) + sb.Append((char)((char)0xE0B0 + (char)instance)); + + var placeNameWithInstance = sb.ToReadOnlySeString(); + SeStringBuilder.SharedPool.Return(sb); + + var mapPosX = ConvertRawToMapPosX(mapRow, rawX / 1000f); + var mapPosY = ConvertRawToMapPosY(mapRow, rawY / 1000f); + + var linkText = rawZ == -30000 + ? this.EvaluateFromAddon( + 1635, + [placeNameWithInstance, mapPosX, mapPosY], + context.Language) + : this.EvaluateFromAddon( + 1636, + [placeNameWithInstance, mapPosX, mapPosY, rawZ / (rawZ >= 0 ? 10 : -10), rawZ], + context.Language); + + context.Builder.PushLinkMapPosition(territoryTypeId, mapId, rawX, rawY); + context.Builder.Append(this.EvaluateFromAddon(371, [linkText], context.Language)); + context.Builder.PopLink(); + + return true; + } + + var rowId = mapId switch + { + 0 => 875u, // "(No location set for map link)" + 1 => 874u, // "(Map link unavailable in this area)" + 2 => 13743u, // "(Unable to set map link)" + _ => 0u, + }; + if (rowId == 0u) + return false; + if (this.dataManager.GetExcelSheet(context.Language).TryGetRow(rowId, out var addonRow)) + context.Builder.Append(addonRow.Text); + return true; + } + + private bool TryResolveFixedItemLink(in SeStringContext context, ref ReadOnlySePayloadSpan.Enumerator enu) + { + if (!enu.MoveNext() || !this.TryResolveUInt(in context, enu.Current, out var itemId)) + return false; + + if (!enu.MoveNext() || !this.TryResolveUInt(in context, enu.Current, out var rarity)) + return false; + + if (!enu.MoveNext() || !this.TryResolveInt(in context, enu.Current, out var unk2)) + return false; + + if (!enu.MoveNext() || !this.TryResolveInt(in context, enu.Current, out var unk3)) + return false; + + if (!enu.MoveNext() || !enu.Current.TryGetString(out var itemName)) // TODO: unescape?? + return false; + + // rarity color start + context.Builder.Append(this.EvaluateFromAddon(6, [rarity], context.Language)); + + var v2 = (ushort)((unk2 & 0xFF) + (unk3 << 0x10)); // TODO: find out what this does + + context.Builder.PushLink(LinkMacroPayloadType.Item, itemId, rarity, v2); + + // arrow and item name + context.Builder.Append(this.EvaluateFromAddon(371, [itemName], context.Language)); + + context.Builder.PopLink(); + context.Builder.PopColor(); + + return true; + } + + private bool TryResolveFixedChatSoundEffect(in SeStringContext context, ref ReadOnlySePayloadSpan.Enumerator enu) + { + if (!enu.MoveNext() || !this.TryResolveUInt(in context, enu.Current, out var soundEffectId)) + return false; + + context.Builder.Append($""); + + // the game would play it here + + return true; + } + + private bool TryResolveFixedObjStr(in SeStringContext context, ref ReadOnlySePayloadSpan.Enumerator enu) + { + if (!enu.MoveNext() || !this.TryResolveUInt(in context, enu.Current, out var objStrId)) + return false; + + context.Builder.Append(this.EvaluateFromAddon(2025, [objStrId], context.Language)); + + return true; + } + + private bool TryResolveFixedString(in SeStringContext context, ref ReadOnlySePayloadSpan.Enumerator enu) + { + if (!enu.MoveNext() || !enu.Current.TryGetString(out var text)) + return false; + + // formats it through vsprintf using "%s"?? + context.Builder.Append(text.ExtractText()); + + return true; + } + + private bool TryResolveFixedTimeRemaining(in SeStringContext context, ref ReadOnlySePayloadSpan.Enumerator enu) + { + if (!enu.MoveNext() || !this.TryResolveUInt(in context, enu.Current, out var seconds)) + return false; + + if (seconds != 0) + { + context.Builder.Append(this.EvaluateFromAddon(33, [seconds / 60, seconds % 60], context.Language)); + } + else + { + if (this.dataManager.GetExcelSheet(context.Language).TryGetRow(48, out var addonRow)) + context.Builder.Append(addonRow.Text); + } + + return true; + } + + private bool TryResolveFixedStatusLink(in SeStringContext context, ref ReadOnlySePayloadSpan.Enumerator enu) + { + if (!enu.MoveNext() || !this.TryResolveUInt(in context, enu.Current, out var statusId)) + return false; + + if (!enu.MoveNext() || !this.TryResolveBool(in context, enu.Current, out var hasOverride)) + return false; + + if (!this.dataManager.GetExcelSheet(context.Language) + .TryGetRow(statusId, out var statusRow)) + return false; + + ReadOnlySeStringSpan statusName; + ReadOnlySeStringSpan statusDescription; + + if (hasOverride) + { + if (!enu.MoveNext() || !enu.Current.TryGetString(out statusName)) + return false; + + if (!enu.MoveNext() || !enu.Current.TryGetString(out statusDescription)) + return false; + } + else + { + statusName = statusRow.Name.AsSpan(); + statusDescription = statusRow.Description.AsSpan(); + } + + var sb = SeStringBuilder.SharedPool.Get(); + + switch (statusRow.StatusCategory) + { + case 1: + sb.Append(this.EvaluateFromAddon(376, default, context.Language)); + break; + + case 2: + sb.Append(this.EvaluateFromAddon(377, default, context.Language)); + break; + } + + sb.Append(statusName); + + var linkText = sb.ToReadOnlySeString(); + SeStringBuilder.SharedPool.Return(sb); + + context.Builder + .BeginMacro(MacroCode.Link) + .AppendUIntExpression((uint)LinkMacroPayloadType.Status) + .AppendUIntExpression(statusId) + .AppendUIntExpression(0) + .AppendUIntExpression(0) + .AppendStringExpression(statusName) + .AppendStringExpression(statusDescription) + .EndMacro(); + + context.Builder.Append(this.EvaluateFromAddon(371, [linkText], context.Language)); + + context.Builder.PopLink(); + + return true; + } + + private bool TryResolveFixedPartyFinderLink(in SeStringContext context, ref ReadOnlySePayloadSpan.Enumerator enu) + { + if (!enu.MoveNext() || !this.TryResolveUInt(in context, enu.Current, out var listingId)) + return false; + + if (!enu.MoveNext() || !this.TryResolveUInt(in context, enu.Current, out var unk1)) + return false; + + if (!enu.MoveNext() || !this.TryResolveUInt(in context, enu.Current, out var worldId)) + return false; + + if (!enu.MoveNext() || !this.TryResolveInt( + context, + enu.Current, + out var crossWorldFlag)) // 0 = cross world, 1 = not cross world + return false; + + if (!enu.MoveNext() || !enu.Current.TryGetString(out var playerName)) + return false; + + context.Builder + .BeginMacro(MacroCode.Link) + .AppendUIntExpression((uint)LinkMacroPayloadType.PartyFinder) + .AppendUIntExpression(listingId) + .AppendUIntExpression(unk1) + .AppendUIntExpression((uint)(crossWorldFlag << 0x10) + worldId) + .EndMacro(); + + context.Builder.Append( + this.EvaluateFromAddon( + 371, + [this.EvaluateFromAddon(2265, [playerName, crossWorldFlag], context.Language)], + context.Language)); + + context.Builder.PopLink(); + + return true; + } + + private bool TryResolveFixedQuestLink(in SeStringContext context, ref ReadOnlySePayloadSpan.Enumerator enu) + { + if (!enu.MoveNext() || !this.TryResolveUInt(in context, enu.Current, out var questId)) + return false; + + if (!enu.MoveNext() || !enu.MoveNext() || !enu.MoveNext()) // unused + return false; + + if (!enu.MoveNext() || !enu.Current.TryGetString(out var questName)) + return false; + + /* TODO: hide incomplete, repeatable special event quest names + if (!QuestManager.IsQuestComplete(questId) && !QuestManager.Instance()->IsQuestAccepted(questId)) + { + var questRecompleteManager = QuestRecompleteManager.Instance(); + if (questRecompleteManager == null || !questRecompleteManager->"E8 ?? ?? ?? ?? 0F B6 57 FF"(questId)) { + if (_excelService.TryGetRow(5497, context.Language, out var addonRow)) + questName = addonRow.Text.AsSpan(); + } + } + */ + + context.Builder + .BeginMacro(MacroCode.Link) + .AppendUIntExpression((uint)LinkMacroPayloadType.Quest) + .AppendUIntExpression(questId) + .AppendUIntExpression(0) + .AppendUIntExpression(0) + .EndMacro(); + + context.Builder.Append(this.EvaluateFromAddon(371, [questName], context.Language)); + + context.Builder.PopLink(); + + return true; + } + + private bool TryResolveFixedAutoTranslation( + in SeStringContext context, in ReadOnlySePayloadSpan payload, int e0Val, int e1Val) + { + // Auto-Translation / Completion + var group = (uint)(e0Val + 1); + var rowId = (uint)e1Val; + + using var icons = new SeStringBuilderIconWrap(context.Builder, 54, 55); + + if (!this.dataManager.GetExcelSheet(context.Language).TryGetFirst( + row => row.Group == group && !row.LookupTable.IsEmpty, + out var groupRow)) + return false; + + var lookupTable = ( + groupRow.LookupTable.IsTextOnly() + ? groupRow.LookupTable + : this.Evaluate( + groupRow.LookupTable.AsSpan(), + context.LocalParameters, + context.Language)).ExtractText(); + + // Completion sheet + if (lookupTable.Equals("@")) + { + if (this.dataManager.GetExcelSheet(context.Language).TryGetRow(rowId, out var completionRow)) + { + context.Builder.Append(completionRow.Text); + } + + return true; + } + + // CategoryDataCache + if (lookupTable.Equals("#")) + { + // couldn't find any, so we don't handle them :p + context.Builder.Append(payload); + return false; + } + + // All other sheets + var rangesStart = lookupTable.IndexOf('['); + // Sheet without ranges + if (rangesStart == -1) + { + if (this.dataManager.GetExcelSheet(context.Language, lookupTable).TryGetRow(rowId, out var row)) + { + context.Builder.Append(row.ReadStringColumn(0)); + return true; + } + } + + var sheetName = lookupTable[..rangesStart]; + var ranges = lookupTable[(rangesStart + 1)..^1]; + if (ranges.Length == 0) + return true; + + var isNoun = false; + var col = 0; + + if (ranges.StartsWith("noun")) + { + isNoun = true; + } + else if (ranges.StartsWith("col")) + { + var colRangeEnd = ranges.IndexOf(','); + if (colRangeEnd == -1) + colRangeEnd = ranges.Length; + + col = int.Parse(ranges[4..colRangeEnd]); + } + else if (ranges.StartsWith("tail")) + { + // couldn't find any, so we don't handle them :p + context.Builder.Append(payload); + return false; + } + + if (isNoun && context.Language == ClientLanguage.German && sheetName == "Companion") + { + context.Builder.Append(this.nounProcessor.ProcessNoun(new NounParams() + { + Language = ClientLanguage.German, + SheetName = sheetName, + RowId = rowId, + Quantity = 1, + ArticleType = (int)GermanArticleType.ZeroArticle, + })); + } + else if (this.dataManager.GetExcelSheet(context.Language, sheetName).TryGetRow(rowId, out var row)) + { + context.Builder.Append(row.ReadStringColumn(col)); + } + + return true; + } + + private bool TryResolveLower(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eStr)) + return false; + + var builder = SeStringBuilder.SharedPool.Get(); + + try + { + var headContext = new SeStringContext(builder, context.LocalParameters, context.Language); + + if (!this.ResolveStringExpression(headContext, eStr)) + return false; + + var str = builder.ToReadOnlySeString(); + + foreach (var p in str) + { + if (p.Type == ReadOnlySePayloadType.Invalid) + continue; + + if (p.Type == ReadOnlySePayloadType.Text) + { + context.Builder.Append(Encoding.UTF8.GetString(p.Body.ToArray()).ToLower(context.CultureInfo)); + + continue; + } + + context.Builder.Append(p); + } + + return true; + } + finally + { + SeStringBuilder.SharedPool.Return(builder); + } + } + + private bool TryResolveNoun(ClientLanguage language, in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + var eAmountVal = 1; + var eCaseVal = 1; + + var enu = payload.GetEnumerator(); + + if (!enu.MoveNext() || !enu.Current.TryGetString(out var eSheetNameStr)) + return false; + + var sheetName = this.Evaluate(eSheetNameStr, context.LocalParameters, context.Language).ExtractText(); + + if (!enu.MoveNext() || !this.TryResolveInt(in context, enu.Current, out var eArticleTypeVal)) + return false; + + if (!enu.MoveNext() || !this.TryResolveUInt(in context, enu.Current, out var eRowIdVal)) + return false; + + uint colIndex = ushort.MaxValue; + var flags = this.sheetRedirectResolver.Resolve(ref sheetName, ref eRowIdVal, ref colIndex); + + if (string.IsNullOrEmpty(sheetName)) + return false; + + // optional arguments + if (enu.MoveNext()) + { + if (!this.TryResolveInt(in context, enu.Current, out eAmountVal)) + return false; + + if (enu.MoveNext()) + { + if (!this.TryResolveInt(in context, enu.Current, out eCaseVal)) + return false; + + // For Chinese texts? + /* + if (enu.MoveNext()) + { + var eUnkInt5 = enu.Current; + if (!TryResolveInt(context,eUnkInt5, out eUnkInt5Val)) + return false; + } + */ + } + } + + context.Builder.Append( + this.nounProcessor.ProcessNoun(new NounParams() + { + Language = language, + SheetName = sheetName, + RowId = eRowIdVal, + Quantity = eAmountVal, + ArticleType = eArticleTypeVal, + GrammaticalCase = eCaseVal - 1, + IsActionSheet = flags.HasFlag(SheetRedirectFlags.Action), + })); + + return true; + } + + private bool TryResolveLowerHead(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eStr)) + return false; + + var builder = SeStringBuilder.SharedPool.Get(); + + try + { + var headContext = new SeStringContext(builder, context.LocalParameters, context.Language); + + if (!this.ResolveStringExpression(headContext, eStr)) + return false; + + var str = builder.ToReadOnlySeString(); + var pIdx = 0; + + foreach (var p in str) + { + pIdx++; + + if (p.Type == ReadOnlySePayloadType.Invalid) + continue; + + if (pIdx == 1 && p.Type == ReadOnlySePayloadType.Text) + { + context.Builder.Append(Encoding.UTF8.GetString(p.Body.Span).FirstCharToLower(context.CultureInfo)); + continue; + } + + context.Builder.Append(p); + } + + return true; + } + finally + { + SeStringBuilder.SharedPool.Return(builder); + } + } + + private bool TryResolveColorType(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eColorType) || + !this.TryResolveUInt(in context, eColorType, out var eColorTypeVal)) + return false; + + if (eColorTypeVal == 0) + context.Builder.PopColor(); + else if (this.dataManager.GetExcelSheet().TryGetRow(eColorTypeVal, out var row)) + context.Builder.PushColorBgra((row.UIForeground >> 8) | (row.UIForeground << 24)); + + return true; + } + + private bool TryResolveEdgeColorType(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eColorType) || + !this.TryResolveUInt(in context, eColorType, out var eColorTypeVal)) + return false; + + if (eColorTypeVal == 0) + context.Builder.PopEdgeColor(); + else if (this.dataManager.GetExcelSheet().TryGetRow(eColorTypeVal, out var row)) + context.Builder.PushEdgeColorBgra((row.UIForeground >> 8) | (row.UIForeground << 24)); + + return true; + } + + private bool TryResolveDigit(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eValue, out var eTargetLength)) + return false; + + if (!this.TryResolveInt(in context, eValue, out var eValueVal)) + return false; + + if (!this.TryResolveInt(in context, eTargetLength, out var eTargetLengthVal)) + return false; + + context.Builder.Append(eValueVal.ToString(new string('0', eTargetLengthVal))); + + return true; + } + + private bool TryResolveOrdinal(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eValue) || !this.TryResolveUInt(in context, eValue, out var eValueVal)) + return false; + + // TODO: Culture support? + context.Builder.Append( + $"{eValueVal}{(eValueVal % 10) switch + { + _ when eValueVal is >= 10 and <= 19 => "th", + 1 => "st", + 2 => "nd", + 3 => "rd", + _ => "th", + }}"); + return true; + } + + private bool TryResolveLevelPos(in SeStringContext context, in ReadOnlySePayloadSpan payload) + { + if (!payload.TryGetExpression(out var eLevel) || !this.TryResolveUInt(in context, eLevel, out var eLevelVal)) + return false; + + if (!this.dataManager.GetExcelSheet(context.Language).TryGetRow(eLevelVal, out var level) || + !level.Map.IsValid) + return false; + + if (!this.dataManager.GetExcelSheet(context.Language).TryGetRow( + level.Map.Value.PlaceName.RowId, + out var placeName)) + return false; + + var mapPosX = ConvertRawToMapPosX(level.Map.Value, level.X); + var mapPosY = ConvertRawToMapPosY(level.Map.Value, level.Z); // Z is [sic] + + context.Builder.Append( + this.EvaluateFromAddon( + 1637, + [placeName.Name, mapPosX, mapPosY], + context.Language)); + + return true; + } + + private unsafe bool TryGetGNumDefault(uint parameterIndex, out uint value) + { + value = 0u; + + var rtm = RaptureTextModule.Instance(); + if (rtm is null) + return false; + + if (!ThreadSafety.IsMainThread) + { + Log.Error("Global parameters may only be used from the main thread."); + return false; + } + + ref var gp = ref rtm->TextModule.MacroDecoder.GlobalParameters; + if (parameterIndex >= gp.MySize) + return false; + + var p = rtm->TextModule.MacroDecoder.GlobalParameters[parameterIndex]; + switch (p.Type) + { + case TextParameterType.Integer: + value = (uint)p.IntValue; + return true; + + case TextParameterType.ReferencedUtf8String: + Log.Error("Requested a number; Utf8String global parameter at {parameterIndex}.", parameterIndex); + return false; + + case TextParameterType.String: + Log.Error("Requested a number; string global parameter at {parameterIndex}.", parameterIndex); + return false; + + case TextParameterType.Uninitialized: + Log.Error("Requested a number; uninitialized global parameter at {parameterIndex}.", parameterIndex); + return false; + + default: + return false; + } + } + + private unsafe bool TryProduceGStrDefault(SeStringBuilder builder, ClientLanguage language, uint parameterIndex) + { + var rtm = RaptureTextModule.Instance(); + if (rtm is null) + return false; + + ref var gp = ref rtm->TextModule.MacroDecoder.GlobalParameters; + if (parameterIndex >= gp.MySize) + return false; + + if (!ThreadSafety.IsMainThread) + { + Log.Error("Global parameters may only be used from the main thread."); + return false; + } + + var p = rtm->TextModule.MacroDecoder.GlobalParameters[parameterIndex]; + switch (p.Type) + { + case TextParameterType.Integer: + builder.Append($"{p.IntValue:D}"); + return true; + + case TextParameterType.ReferencedUtf8String: + this.EvaluateAndAppendTo( + builder, + p.ReferencedUtf8StringValue->Utf8String.AsSpan(), + null, + language); + return false; + + case TextParameterType.String: + this.EvaluateAndAppendTo(builder, new(p.StringValue), null, language); + return false; + + case TextParameterType.Uninitialized: + default: + return false; + } + } + + private unsafe bool TryResolveUInt( + in SeStringContext context, in ReadOnlySeExpressionSpan expression, out uint value) + { + if (expression.TryGetUInt(out value)) + return true; + + if (expression.TryGetPlaceholderExpression(out var exprType)) + { + // if (context.TryGetPlaceholderNum(exprType, out value)) + // return true; + + switch ((ExpressionType)exprType) + { + case ExpressionType.Millisecond: + value = (uint)DateTime.Now.Millisecond; + return true; + case ExpressionType.Second: + value = (uint)MacroDecoder.GetMacroTime()->tm_sec; + return true; + case ExpressionType.Minute: + value = (uint)MacroDecoder.GetMacroTime()->tm_min; + return true; + case ExpressionType.Hour: + value = (uint)MacroDecoder.GetMacroTime()->tm_hour; + return true; + case ExpressionType.Day: + value = (uint)MacroDecoder.GetMacroTime()->tm_mday; + return true; + case ExpressionType.Weekday: + value = (uint)MacroDecoder.GetMacroTime()->tm_wday; + return true; + case ExpressionType.Month: + value = (uint)MacroDecoder.GetMacroTime()->tm_mon + 1; + return true; + case ExpressionType.Year: + value = (uint)MacroDecoder.GetMacroTime()->tm_year + 1900; + return true; + default: + return false; + } + } + + if (expression.TryGetParameterExpression(out exprType, out var operand1)) + { + if (!this.TryResolveUInt(in context, operand1, out var paramIndex)) + return false; + if (paramIndex == 0) + return false; + paramIndex--; + return (ExpressionType)exprType switch + { + ExpressionType.LocalNumber => context.TryGetLNum((int)paramIndex, out value), // lnum + ExpressionType.GlobalNumber => this.TryGetGNumDefault(paramIndex, out value), // gnum + _ => false, // gstr, lstr + }; + } + + if (expression.TryGetBinaryExpression(out exprType, out operand1, out var operand2)) + { + switch ((ExpressionType)exprType) + { + case ExpressionType.GreaterThanOrEqualTo: + case ExpressionType.GreaterThan: + case ExpressionType.LessThanOrEqualTo: + case ExpressionType.LessThan: + if (!this.TryResolveInt(in context, operand1, out var value1) + || !this.TryResolveInt(in context, operand2, out var value2)) + { + return false; + } + + value = (ExpressionType)exprType switch + { + ExpressionType.GreaterThanOrEqualTo => value1 >= value2 ? 1u : 0u, + ExpressionType.GreaterThan => value1 > value2 ? 1u : 0u, + ExpressionType.LessThanOrEqualTo => value1 <= value2 ? 1u : 0u, + ExpressionType.LessThan => value1 < value2 ? 1u : 0u, + _ => 0u, + }; + return true; + + case ExpressionType.Equal: + case ExpressionType.NotEqual: + if (this.TryResolveInt(in context, operand1, out value1) && + this.TryResolveInt(in context, operand2, out value2)) + { + if ((ExpressionType)exprType == ExpressionType.Equal) + value = value1 == value2 ? 1u : 0u; + else + value = value1 == value2 ? 0u : 1u; + return true; + } + + if (operand1.TryGetString(out var strval1) && operand2.TryGetString(out var strval2)) + { + var resolvedStr1 = this.EvaluateAndAppendTo( + SeStringBuilder.SharedPool.Get(), + strval1, + context.LocalParameters, + context.Language); + var resolvedStr2 = this.EvaluateAndAppendTo( + SeStringBuilder.SharedPool.Get(), + strval2, + context.LocalParameters, + context.Language); + var equals = resolvedStr1.GetViewAsSpan().SequenceEqual(resolvedStr2.GetViewAsSpan()); + SeStringBuilder.SharedPool.Return(resolvedStr1); + SeStringBuilder.SharedPool.Return(resolvedStr2); + + if ((ExpressionType)exprType == ExpressionType.Equal) + value = equals ? 1u : 0u; + else + value = equals ? 0u : 1u; + return true; + } + + // compare int with string, string with int?? + + return true; + + default: + return false; + } + } + + if (expression.TryGetString(out var str)) + { + var evaluatedStr = this.Evaluate(str, context.LocalParameters, context.Language); + + foreach (var payload in evaluatedStr) + { + if (!payload.TryGetExpression(out var expr)) + return false; + + return this.TryResolveUInt(in context, expr, out value); + } + + return false; + } + + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool TryResolveInt(in SeStringContext context, in ReadOnlySeExpressionSpan expression, out int value) + { + if (this.TryResolveUInt(in context, expression, out var u32)) + { + value = (int)u32; + return true; + } + + value = 0; + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool TryResolveBool(in SeStringContext context, in ReadOnlySeExpressionSpan expression, out bool value) + { + if (this.TryResolveUInt(in context, expression, out var u32)) + { + value = u32 != 0; + return true; + } + + value = false; + return false; + } + + private bool ResolveStringExpression(in SeStringContext context, in ReadOnlySeExpressionSpan expression) + { + uint u32; + + if (expression.TryGetString(out var innerString)) + { + context.Builder.Append(this.Evaluate(innerString, context.LocalParameters, context.Language)); + return true; + } + + /* + if (expression.TryGetPlaceholderExpression(out var exprType)) + { + if (context.TryProducePlaceholder(context,exprType)) + return true; + } + */ + + if (expression.TryGetParameterExpression(out var exprType, out var operand1)) + { + if (!this.TryResolveUInt(in context, operand1, out var paramIndex)) + return false; + if (paramIndex == 0) + return false; + paramIndex--; + switch ((ExpressionType)exprType) + { + case ExpressionType.LocalNumber: // lnum + if (!context.TryGetLNum((int)paramIndex, out u32)) + return false; + + context.Builder.Append(unchecked((int)u32).ToString()); + return true; + + case ExpressionType.LocalString: // lstr + if (!context.TryGetLStr((int)paramIndex, out var str)) + return false; + + context.Builder.Append(str); + return true; + + case ExpressionType.GlobalNumber: // gnum + if (!this.TryGetGNumDefault(paramIndex, out u32)) + return false; + + context.Builder.Append(unchecked((int)u32).ToString()); + return true; + + case ExpressionType.GlobalString: // gstr + return this.TryProduceGStrDefault(context.Builder, context.Language, paramIndex); + + default: + return false; + } + } + + // Handles UInt and Binary expressions + if (!this.TryResolveUInt(in context, expression, out u32)) + return false; + + context.Builder.Append(((int)u32).ToString()); + return true; + } + + private readonly record struct StringCacheKey(TK Kind, uint Id, ClientLanguage Language) + where TK : struct, Enum; +} diff --git a/Dalamud/Game/Text/Evaluator/SeStringParameter.cs b/Dalamud/Game/Text/Evaluator/SeStringParameter.cs new file mode 100644 index 000000000..c1f238f56 --- /dev/null +++ b/Dalamud/Game/Text/Evaluator/SeStringParameter.cs @@ -0,0 +1,79 @@ +using System.Globalization; + +using Lumina.Text.ReadOnly; + +using DSeString = Dalamud.Game.Text.SeStringHandling.SeString; +using LSeString = Lumina.Text.SeString; + +namespace Dalamud.Game.Text.Evaluator; + +/// +/// A wrapper for a local parameter, holding either a number or a string. +/// +public readonly struct SeStringParameter +{ + private readonly uint num; + private readonly ReadOnlySeString str; + + /// + /// Initializes a new instance of the struct for a number parameter. + /// + /// The number value. + public SeStringParameter(uint value) + { + this.num = value; + } + + /// + /// Initializes a new instance of the struct for a string parameter. + /// + /// The string value. + public SeStringParameter(ReadOnlySeString value) + { + this.str = value; + this.IsString = true; + } + + /// + /// Initializes a new instance of the struct for a string parameter. + /// + /// The string value. + public SeStringParameter(string value) + { + this.str = new ReadOnlySeString(value); + this.IsString = true; + } + + /// + /// Gets a value indicating whether the backing type of this parameter is a string. + /// + public bool IsString { get; } + + /// + /// Gets a numeric value. + /// + public uint UIntValue => + !this.IsString + ? this.num + : uint.TryParse(this.str.ExtractText(), out var value) ? value : 0; + + /// + /// Gets a string value. + /// + public ReadOnlySeString StringValue => + this.IsString ? this.str : new(this.num.ToString("D", CultureInfo.InvariantCulture)); + + public static implicit operator SeStringParameter(int value) => new((uint)value); + + public static implicit operator SeStringParameter(uint value) => new(value); + + public static implicit operator SeStringParameter(ReadOnlySeString value) => new(value); + + public static implicit operator SeStringParameter(ReadOnlySeStringSpan value) => new(new ReadOnlySeString(value)); + + public static implicit operator SeStringParameter(LSeString value) => new(new ReadOnlySeString(value.RawData)); + + public static implicit operator SeStringParameter(DSeString value) => new(new ReadOnlySeString(value.Encode())); + + public static implicit operator SeStringParameter(string value) => new(value); +} diff --git a/Dalamud/Game/Text/Noun/Enums/EnglishArticleType.cs b/Dalamud/Game/Text/Noun/Enums/EnglishArticleType.cs new file mode 100644 index 000000000..9214bea0b --- /dev/null +++ b/Dalamud/Game/Text/Noun/Enums/EnglishArticleType.cs @@ -0,0 +1,17 @@ +namespace Dalamud.Game.Text.Noun.Enums; + +/// +/// Article types for . +/// +public enum EnglishArticleType +{ + /// + /// Indefinite article (a, an). + /// + Indefinite = 1, + + /// + /// Definite article (the). + /// + Definite = 2, +} diff --git a/Dalamud/Game/Text/Noun/Enums/FrenchArticleType.cs b/Dalamud/Game/Text/Noun/Enums/FrenchArticleType.cs new file mode 100644 index 000000000..3b6d6a63e --- /dev/null +++ b/Dalamud/Game/Text/Noun/Enums/FrenchArticleType.cs @@ -0,0 +1,32 @@ +namespace Dalamud.Game.Text.Noun.Enums; + +/// +/// Article types for . +/// +public enum FrenchArticleType +{ + /// + /// Indefinite article (une, des). + /// + Indefinite = 1, + + /// + /// Definite article (le, la, les). + /// + Definite = 2, + + /// + /// Possessive article (mon, mes). + /// + PossessiveFirstPerson = 3, + + /// + /// Possessive article (ton, tes). + /// + PossessiveSecondPerson = 4, + + /// + /// Possessive article (son, ses). + /// + PossessiveThirdPerson = 5, +} diff --git a/Dalamud/Game/Text/Noun/Enums/GermanArticleType.cs b/Dalamud/Game/Text/Noun/Enums/GermanArticleType.cs new file mode 100644 index 000000000..29124e172 --- /dev/null +++ b/Dalamud/Game/Text/Noun/Enums/GermanArticleType.cs @@ -0,0 +1,37 @@ +namespace Dalamud.Game.Text.Noun.Enums; + +/// +/// Article types for . +/// +public enum GermanArticleType +{ + /// + /// Unbestimmter Artikel (ein, eine, etc.). + /// + Indefinite = 1, + + /// + /// Bestimmter Artikel (der, die, das, etc.). + /// + Definite = 2, + + /// + /// Possessivartikel "dein" (dein, deine, etc.). + /// + Possessive = 3, + + /// + /// Negativartikel "kein" (kein, keine, etc.). + /// + Negative = 4, + + /// + /// Nullartikel. + /// + ZeroArticle = 5, + + /// + /// Demonstrativpronomen "dieser" (dieser, diese, etc.). + /// + Demonstrative = 6, +} diff --git a/Dalamud/Game/Text/Noun/Enums/JapaneseArticleType.cs b/Dalamud/Game/Text/Noun/Enums/JapaneseArticleType.cs new file mode 100644 index 000000000..14a29c4ff --- /dev/null +++ b/Dalamud/Game/Text/Noun/Enums/JapaneseArticleType.cs @@ -0,0 +1,17 @@ +namespace Dalamud.Game.Text.Noun.Enums; + +/// +/// Article types for . +/// +public enum JapaneseArticleType +{ + /// + /// Near listener (それら). + /// + NearListener = 1, + + /// + /// Distant from both speaker and listener (あれら). + /// + Distant = 2, +} diff --git a/Dalamud/Game/Text/Noun/NounParams.cs b/Dalamud/Game/Text/Noun/NounParams.cs new file mode 100644 index 000000000..3d5c424be --- /dev/null +++ b/Dalamud/Game/Text/Noun/NounParams.cs @@ -0,0 +1,73 @@ +using Dalamud.Game.Text.Noun.Enums; + +using Lumina.Text.ReadOnly; + +using LSheets = Lumina.Excel.Sheets; + +namespace Dalamud.Game.Text.Noun; + +/// +/// Parameters for noun processing. +/// +internal record struct NounParams() +{ + /// + /// The language of the sheet to be processed. + /// + public required ClientLanguage Language; + + /// + /// The name of the sheet containing the row to process. + /// + public required string SheetName = string.Empty; + + /// + /// The row id within the sheet to process. + /// + public required uint RowId; + + /// + /// The quantity of the entity (default is 1). Used to determine grammatical number (e.g., singular or plural). + /// + public int Quantity = 1; + + /// + /// The article type. + /// + /// + /// Depending on the , this has different meanings.
+ /// See , , , . + ///
+ public int ArticleType = 1; + + /// + /// The grammatical case (e.g., Nominative, Genitive, Dative, Accusative) used for German texts (default is 0). + /// + public int GrammaticalCase = 0; + + /// + /// An optional string that is placed in front of the text that should be linked, such as item names (default is an empty string; the game uses "//"). + /// + public ReadOnlySeString LinkMarker = default; + + /// + /// An indicator that this noun will be processed from an Action sheet. Only used for German texts. + /// + public bool IsActionSheet; + + /// + /// Gets the column offset. + /// + public readonly int ColumnOffset => this.SheetName switch + { + // See "E8 ?? ?? ?? ?? 44 8B 6B 08" + nameof(LSheets.BeastTribe) => 10, + nameof(LSheets.DeepDungeonItem) => 1, + nameof(LSheets.DeepDungeonEquipment) => 1, + nameof(LSheets.DeepDungeonMagicStone) => 1, + nameof(LSheets.DeepDungeonDemiclone) => 1, + nameof(LSheets.Glasses) => 4, + nameof(LSheets.GlassesStyle) => 15, + _ => 0, + }; +} diff --git a/Dalamud/Game/Text/Noun/NounProcessor.cs b/Dalamud/Game/Text/Noun/NounProcessor.cs new file mode 100644 index 000000000..18f8cd4a9 --- /dev/null +++ b/Dalamud/Game/Text/Noun/NounProcessor.cs @@ -0,0 +1,461 @@ +using System.Collections.Concurrent; + +using Dalamud.Configuration.Internal; +using Dalamud.Data; +using Dalamud.Game.Text.Noun.Enums; +using Dalamud.Logging.Internal; +using Dalamud.Utility; + +using Lumina.Excel; +using Lumina.Text.ReadOnly; + +using LSeStringBuilder = Lumina.Text.SeStringBuilder; +using LSheets = Lumina.Excel.Sheets; + +namespace Dalamud.Game.Text.Noun; + +/* +Attributive sheet: + Japanese: + Unknown0 = Singular Demonstrative + Unknown1 = Plural Demonstrative + English: + Unknown2 = Article before a singular noun beginning with a consonant sound + Unknown3 = Article before a generic noun beginning with a consonant sound + Unknown4 = N/A + Unknown5 = Article before a singular noun beginning with a vowel sound + Unknown6 = Article before a generic noun beginning with a vowel sound + Unknown7 = N/A + German: + Unknown8 = Nominative Masculine + Unknown9 = Nominative Feminine + Unknown10 = Nominative Neutral + Unknown11 = Nominative Plural + Unknown12 = Genitive Masculine + Unknown13 = Genitive Feminine + Unknown14 = Genitive Neutral + Unknown15 = Genitive Plural + Unknown16 = Dative Masculine + Unknown17 = Dative Feminine + Unknown18 = Dative Neutral + Unknown19 = Dative Plural + Unknown20 = Accusative Masculine + Unknown21 = Accusative Feminine + Unknown22 = Accusative Neutral + Unknown23 = Accusative Plural + French (unsure): + Unknown24 = Singular Article + Unknown25 = Singular Masculine Article + Unknown26 = Plural Masculine Article + Unknown27 = ? + Unknown28 = ? + Unknown29 = Singular Masculine/Feminine Article, before a noun beginning in a vowel or an h + Unknown30 = Plural Masculine/Feminine Article, before a noun beginning in a vowel or an h + Unknown31 = ? + Unknown32 = ? + Unknown33 = Singular Feminine Article + Unknown34 = Plural Feminine Article + Unknown35 = ? + Unknown36 = ? + Unknown37 = Singular Masculine/Feminine Article, before a noun beginning in a vowel or an h + Unknown38 = Plural Masculine/Feminine Article, before a noun beginning in a vowel or an h + Unknown39 = ? + Unknown40 = ? + +Placeholders: + [t] = article or grammatical gender (EN: the, DE: der, die, das) + [n] = amount (number) + [a] = declension + [p] = plural + [pa] = ? +*/ + +/// +/// Provides functionality to process texts from sheets containing grammatical placeholders. +/// +[ServiceManager.EarlyLoadedService] +internal class NounProcessor : IServiceType +{ + // column names from ExdSchema, most likely incorrect + private const int SingularColumnIdx = 0; + private const int AdjectiveColumnIdx = 1; + private const int PluralColumnIdx = 2; + private const int PossessivePronounColumnIdx = 3; + private const int StartsWithVowelColumnIdx = 4; + private const int Unknown5ColumnIdx = 5; // probably used in Chinese texts + private const int PronounColumnIdx = 6; + private const int ArticleColumnIdx = 7; + + private static readonly ModuleLog Log = new("NounProcessor"); + + [ServiceManager.ServiceDependency] + private readonly DataManager dataManager = Service.Get(); + + [ServiceManager.ServiceDependency] + private readonly DalamudConfiguration dalamudConfiguration = Service.Get(); + + private readonly ConcurrentDictionary cache = []; + + [ServiceManager.ServiceConstructor] + private NounProcessor() + { + } + + /// + /// Processes a specific row from a sheet and generates a formatted string based on grammatical and language-specific rules. + /// + /// Parameters for processing. + /// A ReadOnlySeString representing the processed text. + public ReadOnlySeString ProcessNoun(NounParams nounParams) + { + if (nounParams.GrammaticalCase < 0 || nounParams.GrammaticalCase > 5) + return default; + + if (this.cache.TryGetValue(nounParams, out var value)) + return value; + + var output = nounParams.Language switch + { + ClientLanguage.Japanese => this.ResolveNounJa(nounParams), + ClientLanguage.English => this.ResolveNounEn(nounParams), + ClientLanguage.German => this.ResolveNounDe(nounParams), + ClientLanguage.French => this.ResolveNounFr(nounParams), + _ => default, + }; + + this.cache.TryAdd(nounParams, output); + + return output; + } + + /// + /// Resolves noun placeholders in Japanese text. + /// + /// Parameters for processing. + /// A ReadOnlySeString representing the processed text. + /// + /// This is a C# implementation of Component::Text::Localize::NounJa.Resolve. + /// + private ReadOnlySeString ResolveNounJa(NounParams nounParams) + { + var sheet = this.dataManager.Excel.GetSheet(nounParams.Language.ToLumina(), nounParams.SheetName); + if (!sheet.TryGetRow(nounParams.RowId, out var row)) + { + Log.Warning("Sheet {SheetName} does not contain row #{RowId}", nounParams.SheetName, nounParams.RowId); + return default; + } + + var attributiveSheet = this.dataManager.Excel.GetSheet(nounParams.Language.ToLumina(), nameof(LSheets.Attributive)); + + var builder = LSeStringBuilder.SharedPool.Get(); + + // Ko-So-A-Do + var ksad = attributiveSheet.GetRow((uint)nounParams.ArticleType).ReadStringColumn(nounParams.Quantity > 1 ? 1 : 0); + if (!ksad.IsEmpty) + { + builder.Append(ksad); + + if (nounParams.Quantity > 1) + { + builder.ReplaceText("[n]"u8, ReadOnlySeString.FromText(nounParams.Quantity.ToString())); + } + } + + if (!nounParams.LinkMarker.IsEmpty) + builder.Append(nounParams.LinkMarker); + + var text = row.ReadStringColumn(nounParams.ColumnOffset); + if (!text.IsEmpty) + builder.Append(text); + + var ross = builder.ToReadOnlySeString(); + LSeStringBuilder.SharedPool.Return(builder); + return ross; + } + + /// + /// Resolves noun placeholders in English text. + /// + /// Parameters for processing. + /// A ReadOnlySeString representing the processed text. + /// + /// This is a C# implementation of Component::Text::Localize::NounEn.Resolve. + /// + private ReadOnlySeString ResolveNounEn(NounParams nounParams) + { + /* + a1->Offsets[0] = SingularColumnIdx + a1->Offsets[1] = PluralColumnIdx + a1->Offsets[2] = StartsWithVowelColumnIdx + a1->Offsets[3] = PossessivePronounColumnIdx + a1->Offsets[4] = ArticleColumnIdx + */ + + var sheet = this.dataManager.Excel.GetSheet(nounParams.Language.ToLumina(), nounParams.SheetName); + if (!sheet.TryGetRow(nounParams.RowId, out var row)) + { + Log.Warning("Sheet {SheetName} does not contain row #{RowId}", nounParams.SheetName, nounParams.RowId); + return default; + } + + var attributiveSheet = this.dataManager.Excel.GetSheet(nounParams.Language.ToLumina(), nameof(LSheets.Attributive)); + + var builder = LSeStringBuilder.SharedPool.Get(); + + var isProperNounColumn = nounParams.ColumnOffset + ArticleColumnIdx; + var isProperNoun = isProperNounColumn >= 0 ? row.ReadInt8Column(isProperNounColumn) : ~isProperNounColumn; + if (isProperNoun == 0) + { + var startsWithVowelColumn = nounParams.ColumnOffset + StartsWithVowelColumnIdx; + var startsWithVowel = startsWithVowelColumn >= 0 + ? row.ReadInt8Column(startsWithVowelColumn) + : ~startsWithVowelColumn; + + var articleColumn = startsWithVowel + (2 * (startsWithVowel + 1)); + var grammaticalNumberColumnOffset = nounParams.Quantity == 1 ? SingularColumnIdx : PluralColumnIdx; + var article = attributiveSheet.GetRow((uint)nounParams.ArticleType) + .ReadStringColumn(articleColumn + grammaticalNumberColumnOffset); + if (!article.IsEmpty) + builder.Append(article); + + if (!nounParams.LinkMarker.IsEmpty) + builder.Append(nounParams.LinkMarker); + } + + var text = row.ReadStringColumn(nounParams.ColumnOffset + (nounParams.Quantity == 1 ? SingularColumnIdx : PluralColumnIdx)); + if (!text.IsEmpty) + builder.Append(text); + + builder.ReplaceText("[n]"u8, ReadOnlySeString.FromText(nounParams.Quantity.ToString())); + + var ross = builder.ToReadOnlySeString(); + LSeStringBuilder.SharedPool.Return(builder); + return ross; + } + + /// + /// Resolves noun placeholders in German text. + /// + /// Parameters for processing. + /// A ReadOnlySeString representing the processed text. + /// + /// This is a C# implementation of Component::Text::Localize::NounDe.Resolve. + /// + private ReadOnlySeString ResolveNounDe(NounParams nounParams) + { + /* + a1->Offsets[0] = SingularColumnIdx + a1->Offsets[1] = PluralColumnIdx + a1->Offsets[2] = PronounColumnIdx + a1->Offsets[3] = AdjectiveColumnIdx + a1->Offsets[4] = PossessivePronounColumnIdx + a1->Offsets[5] = Unknown5ColumnIdx + a1->Offsets[6] = ArticleColumnIdx + */ + + var sheet = this.dataManager.Excel.GetSheet(nounParams.Language.ToLumina(), nounParams.SheetName); + if (!sheet.TryGetRow(nounParams.RowId, out var row)) + { + Log.Warning("Sheet {SheetName} does not contain row #{RowId}", nounParams.SheetName, nounParams.RowId); + return default; + } + + var attributiveSheet = this.dataManager.Excel.GetSheet(nounParams.Language.ToLumina(), nameof(LSheets.Attributive)); + + var builder = LSeStringBuilder.SharedPool.Get(); + ReadOnlySeString ross; + + if (nounParams.IsActionSheet) + { + builder.Append(row.ReadStringColumn(nounParams.GrammaticalCase)); + builder.ReplaceText("[n]"u8, ReadOnlySeString.FromText(nounParams.Quantity.ToString())); + + ross = builder.ToReadOnlySeString(); + LSeStringBuilder.SharedPool.Return(builder); + return ross; + } + + var genderIndexColumn = nounParams.ColumnOffset + PronounColumnIdx; + var genderIndex = genderIndexColumn >= 0 ? row.ReadInt8Column(genderIndexColumn) : ~genderIndexColumn; + + var articleIndexColumn = nounParams.ColumnOffset + ArticleColumnIdx; + var articleIndex = articleIndexColumn >= 0 ? row.ReadInt8Column(articleIndexColumn) : ~articleIndexColumn; + + var caseColumnOffset = (4 * nounParams.GrammaticalCase) + 8; + + var caseRowOffsetColumn = nounParams.ColumnOffset + (nounParams.Quantity == 1 ? AdjectiveColumnIdx : PossessivePronounColumnIdx); + var caseRowOffset = caseRowOffsetColumn >= 0 + ? row.ReadInt8Column(caseRowOffsetColumn) + : (sbyte)~caseRowOffsetColumn; + + if (nounParams.Quantity != 1) + genderIndex = 3; + + var hasT = false; + var text = row.ReadStringColumn(nounParams.ColumnOffset + (nounParams.Quantity == 1 ? SingularColumnIdx : PluralColumnIdx)); + if (!text.IsEmpty) + { + hasT = text.ContainsText("[t]"u8); + + if (articleIndex == 0 && !hasT) + { + var grammaticalGender = attributiveSheet.GetRow((uint)nounParams.ArticleType) + .ReadStringColumn(caseColumnOffset + genderIndex); // Genus + if (!grammaticalGender.IsEmpty) + builder.Append(grammaticalGender); + } + + if (!nounParams.LinkMarker.IsEmpty) + builder.Append(nounParams.LinkMarker); + + builder.Append(text); + + var plural = attributiveSheet.GetRow((uint)(caseRowOffset + 26)) + .ReadStringColumn(caseColumnOffset + genderIndex); + if (builder.ContainsText("[p]"u8)) + builder.ReplaceText("[p]"u8, plural); + else + builder.Append(plural); + + if (hasT) + { + var article = + attributiveSheet.GetRow(39).ReadStringColumn(caseColumnOffset + genderIndex); // Definiter Artikel + builder.ReplaceText("[t]"u8, article); + } + } + + var pa = attributiveSheet.GetRow(24).ReadStringColumn(caseColumnOffset + genderIndex); + builder.ReplaceText("[pa]"u8, pa); + + RawRow declensionRow; + + declensionRow = (GermanArticleType)nounParams.ArticleType switch + { + // Schwache Flexion eines Adjektivs?! + GermanArticleType.Possessive or GermanArticleType.Demonstrative => attributiveSheet.GetRow(25), + _ when hasT => attributiveSheet.GetRow(25), + + // Starke Deklination + GermanArticleType.ZeroArticle => attributiveSheet.GetRow(38), + + // Gemischte Deklination + GermanArticleType.Definite => attributiveSheet.GetRow(37), + + // Starke Flexion eines Artikels?! + GermanArticleType.Indefinite or GermanArticleType.Negative => attributiveSheet.GetRow(26), + _ => attributiveSheet.GetRow(26), + }; + + var declension = declensionRow.ReadStringColumn(caseColumnOffset + genderIndex); + builder.ReplaceText("[a]"u8, declension); + + builder.ReplaceText("[n]"u8, ReadOnlySeString.FromText(nounParams.Quantity.ToString())); + + ross = builder.ToReadOnlySeString(); + LSeStringBuilder.SharedPool.Return(builder); + return ross; + } + + /// + /// Resolves noun placeholders in French text. + /// + /// Parameters for processing. + /// A ReadOnlySeString representing the processed text. + /// + /// This is a C# implementation of Component::Text::Localize::NounFr.Resolve. + /// + private ReadOnlySeString ResolveNounFr(NounParams nounParams) + { + /* + a1->Offsets[0] = SingularColumnIdx + a1->Offsets[1] = PluralColumnIdx + a1->Offsets[2] = StartsWithVowelColumnIdx + a1->Offsets[3] = PronounColumnIdx + a1->Offsets[4] = Unknown5ColumnIdx + a1->Offsets[5] = ArticleColumnIdx + */ + + var sheet = this.dataManager.Excel.GetSheet(nounParams.Language.ToLumina(), nounParams.SheetName); + if (!sheet.TryGetRow(nounParams.RowId, out var row)) + { + Log.Warning("Sheet {SheetName} does not contain row #{RowId}", nounParams.SheetName, nounParams.RowId); + return default; + } + + var attributiveSheet = this.dataManager.Excel.GetSheet(nounParams.Language.ToLumina(), nameof(LSheets.Attributive)); + + var builder = LSeStringBuilder.SharedPool.Get(); + ReadOnlySeString ross; + + var startsWithVowelColumn = nounParams.ColumnOffset + StartsWithVowelColumnIdx; + var startsWithVowel = startsWithVowelColumn >= 0 + ? row.ReadInt8Column(startsWithVowelColumn) + : ~startsWithVowelColumn; + + var pronounColumn = nounParams.ColumnOffset + PronounColumnIdx; + var pronoun = pronounColumn >= 0 ? row.ReadInt8Column(pronounColumn) : ~pronounColumn; + + var articleColumn = nounParams.ColumnOffset + ArticleColumnIdx; + var article = articleColumn >= 0 ? row.ReadInt8Column(articleColumn) : ~articleColumn; + + var v20 = 4 * (startsWithVowel + 6 + (2 * pronoun)); + + if (article != 0) + { + var v21 = attributiveSheet.GetRow((uint)nounParams.ArticleType).ReadStringColumn(v20); + if (!v21.IsEmpty) + builder.Append(v21); + + if (!nounParams.LinkMarker.IsEmpty) + builder.Append(nounParams.LinkMarker); + + var text = row.ReadStringColumn(nounParams.ColumnOffset + (nounParams.Quantity <= 1 ? SingularColumnIdx : PluralColumnIdx)); + if (!text.IsEmpty) + builder.Append(text); + + if (nounParams.Quantity <= 1) + builder.ReplaceText("[n]"u8, ReadOnlySeString.FromText(nounParams.Quantity.ToString())); + + ross = builder.ToReadOnlySeString(); + LSeStringBuilder.SharedPool.Return(builder); + return ross; + } + + var v17 = row.ReadInt8Column(nounParams.ColumnOffset + Unknown5ColumnIdx); + if (v17 != 0 && (nounParams.Quantity > 1 || v17 == 2)) + { + var v29 = attributiveSheet.GetRow((uint)nounParams.ArticleType).ReadStringColumn(v20 + 2); + if (!v29.IsEmpty) + { + builder.Append(v29); + + if (!nounParams.LinkMarker.IsEmpty) + builder.Append(nounParams.LinkMarker); + + var text = row.ReadStringColumn(nounParams.ColumnOffset + PluralColumnIdx); + if (!text.IsEmpty) + builder.Append(text); + } + } + else + { + var v27 = attributiveSheet.GetRow((uint)nounParams.ArticleType).ReadStringColumn(v20 + (v17 != 0 ? 1 : 3)); + if (!v27.IsEmpty) + builder.Append(v27); + + if (!nounParams.LinkMarker.IsEmpty) + builder.Append(nounParams.LinkMarker); + + var text = row.ReadStringColumn(nounParams.ColumnOffset + SingularColumnIdx); + if (!text.IsEmpty) + builder.Append(text); + } + + builder.ReplaceText("[n]"u8, ReadOnlySeString.FromText(nounParams.Quantity.ToString())); + + ross = builder.ToReadOnlySeString(); + LSeStringBuilder.SharedPool.Return(builder); + return ross; + } +} diff --git a/Dalamud/Game/Text/SeStringHandling/Payloads/ItemPayload.cs b/Dalamud/Game/Text/SeStringHandling/Payloads/ItemPayload.cs index c31707ff2..d6fd897b8 100644 --- a/Dalamud/Game/Text/SeStringHandling/Payloads/ItemPayload.cs +++ b/Dalamud/Game/Text/SeStringHandling/Payloads/ItemPayload.cs @@ -4,6 +4,8 @@ using System.Linq; using System.Text; using Dalamud.Data; +using Dalamud.Utility; + using Lumina.Excel; using Lumina.Excel.Sheets; using Newtonsoft.Json; @@ -73,6 +75,7 @@ public class ItemPayload : Payload /// /// Kinds of items that can be fetched from this payload. /// + [Api12ToDo("Move this out of ItemPayload. It's used in other classes too.")] public enum ItemKind : uint { /// @@ -121,7 +124,7 @@ public class ItemPayload : Payload /// Gets the actual item ID of this payload. /// [JsonIgnore] - public uint ItemId => GetAdjustedId(this.rawItemId).ItemId; + public uint ItemId => ItemUtil.GetBaseId(this.rawItemId).ItemId; /// /// Gets the raw, unadjusted item ID of this payload. @@ -161,7 +164,7 @@ public class ItemPayload : Payload /// The created item payload. public static ItemPayload FromRaw(uint rawItemId, string? displayNameOverride = null) { - var (id, kind) = GetAdjustedId(rawItemId); + var (id, kind) = ItemUtil.GetBaseId(rawItemId); return new ItemPayload(id, kind, displayNameOverride); } @@ -230,7 +233,7 @@ public class ItemPayload : Payload protected override void DecodeImpl(BinaryReader reader, long endOfStream) { this.rawItemId = GetInteger(reader); - this.Kind = GetAdjustedId(this.rawItemId).Kind; + this.Kind = ItemUtil.GetBaseId(this.rawItemId).Kind; if (reader.BaseStream.Position + 3 < endOfStream) { @@ -255,15 +258,4 @@ public class ItemPayload : Payload this.displayName = Encoding.UTF8.GetString(itemNameBytes); } } - - private static (uint ItemId, ItemKind Kind) GetAdjustedId(uint rawItemId) - { - return rawItemId switch - { - > 500_000 and < 1_000_000 => (rawItemId - 500_000, ItemKind.Collectible), - > 1_000_000 and < 2_000_000 => (rawItemId - 1_000_000, ItemKind.Hq), - > 2_000_000 => (rawItemId, ItemKind.EventItem), // EventItem IDs are NOT adjusted - _ => (rawItemId, ItemKind.Normal), - }; - } } diff --git a/Dalamud/Game/Text/SeStringHandling/SeString.cs b/Dalamud/Game/Text/SeStringHandling/SeString.cs index 7f1955da5..b7618305a 100644 --- a/Dalamud/Game/Text/SeStringHandling/SeString.cs +++ b/Dalamud/Game/Text/SeStringHandling/SeString.cs @@ -5,11 +5,16 @@ using System.Runtime.InteropServices; using System.Text; using Dalamud.Data; +using Dalamud.Game.Text.Evaluator; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Utility; + using Lumina.Excel.Sheets; + using Newtonsoft.Json; +using LSeStringBuilder = Lumina.Text.SeStringBuilder; + namespace Dalamud.Game.Text.SeStringHandling; /// @@ -187,57 +192,32 @@ public class SeString /// An SeString containing all the payloads necessary to display an item link in the chat log. public static SeString CreateItemLink(uint itemId, ItemPayload.ItemKind kind = ItemPayload.ItemKind.Normal, string? displayNameOverride = null) { - var data = Service.Get(); + var clientState = Service.Get(); + var seStringEvaluator = Service.Get(); - var displayName = displayNameOverride; - var rarity = 1; // default: white - if (displayName == null) - { - switch (kind) - { - case ItemPayload.ItemKind.Normal: - case ItemPayload.ItemKind.Collectible: - case ItemPayload.ItemKind.Hq: - var item = data.GetExcelSheet()?.GetRowOrDefault(itemId); - displayName = item?.Name.ExtractText(); - rarity = item?.Rarity ?? 1; - break; - case ItemPayload.ItemKind.EventItem: - displayName = data.GetExcelSheet()?.GetRowOrDefault(itemId)?.Name.ExtractText(); - break; - default: - throw new ArgumentOutOfRangeException(nameof(kind), kind, null); - } - } + var rawId = ItemUtil.GetRawId(itemId, kind); - if (displayName == null) - { + var displayName = displayNameOverride ?? ItemUtil.GetItemName(rawId); + if (displayName.IsEmpty) throw new Exception("Invalid item ID specified, could not determine item name."); - } - if (kind == ItemPayload.ItemKind.Hq) - { - displayName += $" {(char)SeIconChar.HighQuality}"; - } - else if (kind == ItemPayload.ItemKind.Collectible) - { - displayName += $" {(char)SeIconChar.Collectible}"; - } + var copyName = ItemUtil.GetItemName(rawId, false).ExtractText(); + var textColor = ItemUtil.GetItemRarityColorType(rawId); + var textEdgeColor = textColor + 1u; - var textColor = (ushort)(549 + ((rarity - 1) * 2)); - var textGlowColor = (ushort)(textColor + 1); + var sb = LSeStringBuilder.SharedPool.Get(); + var itemLink = sb + .PushColorType(textColor) + .PushEdgeColorType(textEdgeColor) + .PushLinkItem(rawId, copyName) + .Append(displayName) + .PopLink() + .PopEdgeColorType() + .PopColorType() + .ToReadOnlySeString(); + LSeStringBuilder.SharedPool.Return(sb); - // Note: `SeStringBuilder.AddItemLink` uses this function, so don't call it here! - return new SeStringBuilder() - .AddUiForeground(textColor) - .AddUiGlow(textGlowColor) - .Add(new ItemPayload(itemId, kind)) - .Append(TextArrowPayloads) - .AddText(displayName) - .AddUiGlowOff() - .AddUiForegroundOff() - .Add(RawPayload.LinkTerminator) - .Build(); + return SeString.Parse(seStringEvaluator.EvaluateFromAddon(371, [itemLink], clientState.ClientLanguage)); } /// @@ -301,7 +281,7 @@ public class SeString public static SeString CreateMapLink( uint territoryId, uint mapId, float xCoord, float yCoord, float fudgeFactor = 0.05f) => CreateMapLinkWithInstance(territoryId, mapId, null, xCoord, yCoord, fudgeFactor); - + /// /// Creates an SeString representing an entire Payload chain that can be used to link a map position in the chat log. /// @@ -340,7 +320,7 @@ public class SeString /// An SeString containing all of the payloads necessary to display a map link in the chat log. public static SeString? CreateMapLink(string placeName, float xCoord, float yCoord, float fudgeFactor = 0.05f) => CreateMapLinkWithInstance(placeName, null, xCoord, yCoord, fudgeFactor); - + /// /// Creates an SeString representing an entire Payload chain that can be used to link a map position in the chat log, matching a specified zone name. /// Returns null if no corresponding PlaceName was found. @@ -511,7 +491,7 @@ public class SeString { messageBytes.AddRange(p.Encode()); } - + // Add Null Terminator messageBytes.Add(0); @@ -526,7 +506,7 @@ public class SeString { return this.TextValue; } - + private static string GetMapLinkNameString(string placeName, int? instance, string coordinateString) { var instanceString = string.Empty; @@ -534,7 +514,7 @@ public class SeString { instanceString = (SeIconChar.Instance1 + instance.Value - 1).ToIconString(); } - + return $"{placeName}{instanceString} {coordinateString}"; } } diff --git a/Dalamud/Interface/ImGuiSeStringRenderer/Internal/SeStringRenderer.cs b/Dalamud/Interface/ImGuiSeStringRenderer/Internal/SeStringRenderer.cs index 4937e4af0..9221c2dc5 100644 --- a/Dalamud/Interface/ImGuiSeStringRenderer/Internal/SeStringRenderer.cs +++ b/Dalamud/Interface/ImGuiSeStringRenderer/Internal/SeStringRenderer.cs @@ -7,7 +7,6 @@ using BitFaster.Caching.Lru; using Dalamud.Data; using Dalamud.Game; -using Dalamud.Game.Config; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Interface.ImGuiSeStringRenderer.Internal.TextProcessing; using Dalamud.Interface.Utility; @@ -44,9 +43,6 @@ internal unsafe class SeStringRenderer : IInternalDisposableService /// of this placeholder. On its own, usually displayed like [OBJ]. private const int ObjectReplacementCharacter = '\uFFFC'; - [ServiceManager.ServiceDependency] - private readonly GameConfig gameConfig = Service.Get(); - /// Cache of compiled SeStrings from . private readonly ConcurrentLru cache = new(1024); @@ -570,70 +566,16 @@ internal unsafe class SeStringRenderer : IInternalDisposableService // Apply gamepad key mapping to icons. case MacroCode.Icon2 when payload.TryGetExpression(out var icon) && icon.TryGetInt(out var iconId): - var configName = (BitmapFontIcon)iconId switch + ref var iconMapping = ref RaptureAtkModule.Instance()->AtkFontManager.Icon2RemapTable; + for (var i = 0; i < 30; i++) { - ControllerShoulderLeft => SystemConfigOption.PadButton_L1, - ControllerShoulderRight => SystemConfigOption.PadButton_R1, - ControllerTriggerLeft => SystemConfigOption.PadButton_L2, - ControllerTriggerRight => SystemConfigOption.PadButton_R2, - ControllerButton3 => SystemConfigOption.PadButton_Triangle, - ControllerButton1 => SystemConfigOption.PadButton_Cross, - ControllerButton0 => SystemConfigOption.PadButton_Circle, - ControllerButton2 => SystemConfigOption.PadButton_Square, - ControllerStart => SystemConfigOption.PadButton_Start, - ControllerBack => SystemConfigOption.PadButton_Select, - ControllerAnalogLeftStick => SystemConfigOption.PadButton_LS, - ControllerAnalogLeftStickIn => SystemConfigOption.PadButton_LS, - ControllerAnalogLeftStickUpDown => SystemConfigOption.PadButton_LS, - ControllerAnalogLeftStickLeftRight => SystemConfigOption.PadButton_LS, - ControllerAnalogRightStick => SystemConfigOption.PadButton_RS, - ControllerAnalogRightStickIn => SystemConfigOption.PadButton_RS, - ControllerAnalogRightStickUpDown => SystemConfigOption.PadButton_RS, - ControllerAnalogRightStickLeftRight => SystemConfigOption.PadButton_RS, - _ => (SystemConfigOption?)null, - }; - - if (configName is null || !this.gameConfig.TryGet(configName.Value, out PadButtonValue pb)) - return (BitmapFontIcon)iconId; - - return pb switch - { - PadButtonValue.Autorun_Support => ControllerShoulderLeft, - PadButtonValue.Hotbar_Set_Change => ControllerShoulderRight, - PadButtonValue.XHB_Left_Start => ControllerTriggerLeft, - PadButtonValue.XHB_Right_Start => ControllerTriggerRight, - PadButtonValue.Jump => ControllerButton3, - PadButtonValue.Accept => ControllerButton0, - PadButtonValue.Cancel => ControllerButton1, - PadButtonValue.Map_Sub => ControllerButton2, - PadButtonValue.MainCommand => ControllerStart, - PadButtonValue.HUD_Select => ControllerBack, - PadButtonValue.Move_Operation => (BitmapFontIcon)iconId switch + if (iconMapping[i].IconId == iconId) { - ControllerAnalogLeftStick => ControllerAnalogLeftStick, - ControllerAnalogLeftStickIn => ControllerAnalogLeftStickIn, - ControllerAnalogLeftStickUpDown => ControllerAnalogLeftStickUpDown, - ControllerAnalogLeftStickLeftRight => ControllerAnalogLeftStickLeftRight, - ControllerAnalogRightStick => ControllerAnalogLeftStick, - ControllerAnalogRightStickIn => ControllerAnalogLeftStickIn, - ControllerAnalogRightStickUpDown => ControllerAnalogLeftStickUpDown, - ControllerAnalogRightStickLeftRight => ControllerAnalogLeftStickLeftRight, - _ => (BitmapFontIcon)iconId, - }, - PadButtonValue.Camera_Operation => (BitmapFontIcon)iconId switch - { - ControllerAnalogLeftStick => ControllerAnalogRightStick, - ControllerAnalogLeftStickIn => ControllerAnalogRightStickIn, - ControllerAnalogLeftStickUpDown => ControllerAnalogRightStickUpDown, - ControllerAnalogLeftStickLeftRight => ControllerAnalogRightStickLeftRight, - ControllerAnalogRightStick => ControllerAnalogRightStick, - ControllerAnalogRightStickIn => ControllerAnalogRightStickIn, - ControllerAnalogRightStickUpDown => ControllerAnalogRightStickUpDown, - ControllerAnalogRightStickLeftRight => ControllerAnalogRightStickLeftRight, - _ => (BitmapFontIcon)iconId, - }, - _ => (BitmapFontIcon)iconId, - }; + return (BitmapFontIcon)iconMapping[i].RemappedIconId; + } + } + + return (BitmapFontIcon)iconId; } return None; diff --git a/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs b/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs index f3ec882fc..7326f6745 100644 --- a/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs +++ b/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs @@ -47,11 +47,13 @@ internal class DataWindow : Window, IDisposable new KeyStateWidget(), new MarketBoardWidget(), new NetworkMonitorWidget(), + new NounProcessorWidget(), new ObjectTableWidget(), new PartyListWidget(), new PluginIpcWidget(), new SeFontTestWidget(), new ServicesWidget(), + new SeStringCreatorWidget(), new SeStringRendererTestWidget(), new StartInfoWidget(), new TargetWidget(), @@ -68,6 +70,7 @@ internal class DataWindow : Window, IDisposable private bool isExcept; private bool selectionCollapsed; private IDataWindowWidget currentWidget; + private bool isLoaded; /// /// Initializes a new instance of the class. @@ -81,8 +84,6 @@ internal class DataWindow : Window, IDisposable this.RespectCloseHotkey = false; this.orderedModules = this.modules.OrderBy(module => module.DisplayName); this.currentWidget = this.orderedModules.First(); - - this.Load(); } /// @@ -91,6 +92,7 @@ internal class DataWindow : Window, IDisposable /// public override void OnOpen() { + this.Load(); } /// @@ -183,6 +185,7 @@ internal class DataWindow : Window, IDisposable if (ImGuiComponents.IconButton("forceReload", FontAwesomeIcon.Sync)) { + this.isLoaded = false; this.Load(); } @@ -236,6 +239,11 @@ internal class DataWindow : Window, IDisposable private void Load() { + if (this.isLoaded) + return; + + this.isLoaded = true; + foreach (var widget in this.modules) { widget.Load(); diff --git a/Dalamud/Interface/Internal/Windows/Data/WidgetUtil.cs b/Dalamud/Interface/Internal/Windows/Data/WidgetUtil.cs new file mode 100644 index 000000000..209970e24 --- /dev/null +++ b/Dalamud/Interface/Internal/Windows/Data/WidgetUtil.cs @@ -0,0 +1,34 @@ +using Dalamud.Interface.Utility; + +using ImGuiNET; + +namespace Dalamud.Interface.Internal.Windows.Data; + +/// +/// Common utilities used in Widgets. +/// +internal class WidgetUtil +{ + /// + /// Draws text that can be copied on click. + /// + /// The text shown and to be copied. + /// The text in the tooltip. + internal static void DrawCopyableText(string text, string tooltipText = "Copy") + { + ImGuiHelpers.SafeTextWrapped(text); + + if (ImGui.IsItemHovered()) + { + ImGui.SetMouseCursor(ImGuiMouseCursor.Hand); + ImGui.BeginTooltip(); + ImGui.TextUnformatted(tooltipText); + ImGui.EndTooltip(); + } + + if (ImGui.IsItemClicked()) + { + ImGui.SetClipboardText(text); + } + } +} diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/AtkArrayDataBrowserWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/AtkArrayDataBrowserWidget.cs index 791dc5310..c3499570c 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/AtkArrayDataBrowserWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/AtkArrayDataBrowserWidget.cs @@ -57,24 +57,6 @@ internal unsafe class AtkArrayDataBrowserWidget : IDataWindowWidget this.DrawExtendArrayTab(); } - private static void DrawCopyableText(string text, string tooltipText) - { - ImGuiHelpers.SafeTextWrapped(text); - - if (ImGui.IsItemHovered()) - { - ImGui.SetMouseCursor(ImGuiMouseCursor.Hand); - ImGui.BeginTooltip(); - ImGui.TextUnformatted(tooltipText); - ImGui.EndTooltip(); - } - - if (ImGui.IsItemClicked()) - { - ImGui.SetClipboardText(text); - } - } - private void DrawArrayList(Type? arrayType, int arrayCount, short* arrayKeys, AtkArrayData** arrays, ref int selectedIndex) { using var table = ImRaii.Table("ArkArrayTable", 3, ImGuiTableFlags.ScrollY | ImGuiTableFlags.Borders, new Vector2(300, -1)); @@ -162,7 +144,7 @@ internal unsafe class AtkArrayDataBrowserWidget : IDataWindowWidget ImGui.SameLine(); ImGui.TextUnformatted("Address: "); ImGui.SameLine(0, 0); - DrawCopyableText($"0x{(nint)array:X}", "Copy address"); + WidgetUtil.DrawCopyableText($"0x{(nint)array:X}", "Copy address"); if (array->SubscribedAddonsCount > 0) { @@ -238,22 +220,22 @@ internal unsafe class AtkArrayDataBrowserWidget : IDataWindowWidget var ptr = &array->IntArray[i]; ImGui.TableNextColumn(); // Address - DrawCopyableText($"0x{(nint)ptr:X}", "Copy entry address"); + WidgetUtil.DrawCopyableText($"0x{(nint)ptr:X}", "Copy entry address"); ImGui.TableNextColumn(); // Integer - DrawCopyableText((*ptr).ToString(), "Copy value"); + WidgetUtil.DrawCopyableText((*ptr).ToString(), "Copy value"); ImGui.TableNextColumn(); // Short - DrawCopyableText((*(short*)ptr).ToString(), "Copy as short"); + WidgetUtil.DrawCopyableText((*(short*)ptr).ToString(), "Copy as short"); ImGui.TableNextColumn(); // Byte - DrawCopyableText((*(byte*)ptr).ToString(), "Copy as byte"); + WidgetUtil.DrawCopyableText((*(byte*)ptr).ToString(), "Copy as byte"); ImGui.TableNextColumn(); // Float - DrawCopyableText((*(float*)ptr).ToString(), "Copy as float"); + WidgetUtil.DrawCopyableText((*(float*)ptr).ToString(), "Copy as float"); ImGui.TableNextColumn(); // Hex - DrawCopyableText($"0x{array->IntArray[i]:X2}", "Copy Hex"); + WidgetUtil.DrawCopyableText($"0x{array->IntArray[i]:X2}", "Copy Hex"); } } @@ -333,11 +315,11 @@ internal unsafe class AtkArrayDataBrowserWidget : IDataWindowWidget if (this.showTextAddress) { if (!isNull) - DrawCopyableText($"0x{(nint)array->StringArray[i]:X}", "Copy text address"); + WidgetUtil.DrawCopyableText($"0x{(nint)array->StringArray[i]:X}", "Copy text address"); } else { - DrawCopyableText($"0x{(nint)(&array->StringArray[i]):X}", "Copy entry address"); + WidgetUtil.DrawCopyableText($"0x{(nint)(&array->StringArray[i]):X}", "Copy entry address"); } ImGui.TableNextColumn(); // Managed @@ -351,7 +333,7 @@ internal unsafe class AtkArrayDataBrowserWidget : IDataWindowWidget { if (this.showMacroString) { - DrawCopyableText(new ReadOnlySeStringSpan(array->StringArray[i]).ToString(), "Copy text"); + WidgetUtil.DrawCopyableText(new ReadOnlySeStringSpan(array->StringArray[i]).ToString(), "Copy text"); } else { @@ -408,11 +390,11 @@ internal unsafe class AtkArrayDataBrowserWidget : IDataWindowWidget ImGui.TextUnformatted($"#{i}"); ImGui.TableNextColumn(); // Address - DrawCopyableText($"0x{(nint)(&array->DataArray[i]):X}", "Copy entry address"); + WidgetUtil.DrawCopyableText($"0x{(nint)(&array->DataArray[i]):X}", "Copy entry address"); ImGui.TableNextColumn(); // Pointer if (!isNull) - DrawCopyableText($"0x{(nint)array->DataArray[i]:X}", "Copy address"); + WidgetUtil.DrawCopyableText($"0x{(nint)array->DataArray[i]:X}", "Copy address"); } } } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/FateTableWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/FateTableWidget.cs index 1a43f2b2d..34b04dae0 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/FateTableWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/FateTableWidget.cs @@ -69,10 +69,10 @@ internal class FateTableWidget : IDataWindowWidget ImGui.TextUnformatted($"#{i}"); ImGui.TableNextColumn(); // Address - DrawCopyableText($"0x{fate.Address:X}", "Click to copy Address"); + WidgetUtil.DrawCopyableText($"0x{fate.Address:X}", "Click to copy Address"); ImGui.TableNextColumn(); // FateId - DrawCopyableText(fate.FateId.ToString(), "Click to copy FateId (RowId of Fate sheet)"); + WidgetUtil.DrawCopyableText(fate.FateId.ToString(), "Click to copy FateId (RowId of Fate sheet)"); ImGui.TableNextColumn(); // State ImGui.TextUnformatted(fate.State.ToString()); @@ -140,7 +140,7 @@ internal class FateTableWidget : IDataWindowWidget ImGui.TableNextColumn(); // Name - DrawCopyableText(fate.Name.ToString(), "Click to copy Name"); + WidgetUtil.DrawCopyableText(fate.Name.ToString(), "Click to copy Name"); ImGui.TableNextColumn(); // Progress ImGui.TextUnformatted($"{fate.Progress}%"); @@ -156,28 +156,10 @@ internal class FateTableWidget : IDataWindowWidget ImGui.TextUnformatted(fate.HasBonus.ToString()); ImGui.TableNextColumn(); // Position - DrawCopyableText(fate.Position.ToString(), "Click to copy Position"); + WidgetUtil.DrawCopyableText(fate.Position.ToString(), "Click to copy Position"); ImGui.TableNextColumn(); // Radius - DrawCopyableText(fate.Radius.ToString(), "Click to copy Radius"); - } - } - - private static void DrawCopyableText(string text, string tooltipText) - { - ImGuiHelpers.SafeTextWrapped(text); - - if (ImGui.IsItemHovered()) - { - ImGui.SetMouseCursor(ImGuiMouseCursor.Hand); - ImGui.BeginTooltip(); - ImGui.TextUnformatted(tooltipText); - ImGui.EndTooltip(); - } - - if (ImGui.IsItemClicked()) - { - ImGui.SetClipboardText(text); + WidgetUtil.DrawCopyableText(fate.Radius.ToString(), "Click to copy Radius"); } } } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/NounProcessorWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/NounProcessorWidget.cs new file mode 100644 index 000000000..bc0bd0ac9 --- /dev/null +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/NounProcessorWidget.cs @@ -0,0 +1,207 @@ +using System.Linq; +using System.Text; + +using Dalamud.Data; +using Dalamud.Game; +using Dalamud.Game.ClientState; +using Dalamud.Game.Text.Noun; +using Dalamud.Game.Text.Noun.Enums; +using Dalamud.Interface.Utility.Raii; + +using ImGuiNET; +using Lumina.Data; +using Lumina.Excel; +using Lumina.Excel.Sheets; + +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; + +/// +/// Widget for the NounProcessor service. +/// +internal class NounProcessorWidget : IDataWindowWidget +{ + /// A list of German grammatical cases. + internal static readonly string[] GermanCases = ["Nominative", "Genitive", "Dative", "Accusative"]; + + private static readonly Type[] NounSheets = [ + typeof(Aetheryte), + typeof(BNpcName), + typeof(BeastTribe), + typeof(DeepDungeonEquipment), + typeof(DeepDungeonItem), + typeof(DeepDungeonMagicStone), + typeof(DeepDungeonDemiclone), + typeof(ENpcResident), + typeof(EObjName), + typeof(EurekaAetherItem), + typeof(EventItem), + typeof(GCRankGridaniaFemaleText), + typeof(GCRankGridaniaMaleText), + typeof(GCRankLimsaFemaleText), + typeof(GCRankLimsaMaleText), + typeof(GCRankUldahFemaleText), + typeof(GCRankUldahMaleText), + typeof(GatheringPointName), + typeof(Glasses), + typeof(GlassesStyle), + typeof(HousingPreset), + typeof(Item), + typeof(MJIName), + typeof(Mount), + typeof(Ornament), + typeof(TripleTriadCard), + ]; + + private ClientLanguage[] languages = []; + private string[] languageNames = []; + + private int selectedSheetNameIndex = 0; + private int selectedLanguageIndex = 0; + private int rowId = 1; + private int amount = 1; + + /// + public string[]? CommandShortcuts { get; init; } = { "noun" }; + + /// + public string DisplayName { get; init; } = "Noun Processor"; + + /// + public bool Ready { get; set; } + + /// + public void Load() + { + this.languages = Enum.GetValues(); + this.languageNames = Enum.GetNames(); + this.selectedLanguageIndex = (int)Service.Get().ClientLanguage; + + this.Ready = true; + } + + /// + public void Draw() + { + var nounProcessor = Service.Get(); + var dataManager = Service.Get(); + var clientState = Service.Get(); + + var sheetType = NounSheets.ElementAt(this.selectedSheetNameIndex); + var language = this.languages[this.selectedLanguageIndex]; + + ImGui.SetNextItemWidth(300); + if (ImGui.Combo("###SelectedSheetName", ref this.selectedSheetNameIndex, NounSheets.Select(t => t.Name).ToArray(), NounSheets.Length)) + { + this.rowId = 1; + } + + ImGui.SameLine(); + + ImGui.SetNextItemWidth(120); + if (ImGui.Combo("###SelectedLanguage", ref this.selectedLanguageIndex, this.languageNames, this.languageNames.Length)) + { + language = this.languages[this.selectedLanguageIndex]; + this.rowId = 1; + } + + ImGui.SetNextItemWidth(120); + var sheet = dataManager.Excel.GetSheet(Language.English, sheetType.Name); + var minRowId = (int)sheet.FirstOrDefault().RowId; + var maxRowId = (int)sheet.LastOrDefault().RowId; + if (ImGui.InputInt("RowId###RowId", ref this.rowId, 1, 10, ImGuiInputTextFlags.AutoSelectAll)) + { + if (this.rowId < minRowId) + this.rowId = minRowId; + + if (this.rowId >= maxRowId) + this.rowId = maxRowId; + } + + ImGui.SameLine(); + ImGui.TextUnformatted($"(Range: {minRowId} - {maxRowId})"); + + ImGui.SetNextItemWidth(120); + if (ImGui.InputInt("Amount###Amount", ref this.amount, 1, 10, ImGuiInputTextFlags.AutoSelectAll)) + { + if (this.amount <= 0) + this.amount = 1; + } + + var articleTypeEnumType = language switch + { + ClientLanguage.Japanese => typeof(JapaneseArticleType), + ClientLanguage.German => typeof(GermanArticleType), + ClientLanguage.French => typeof(FrenchArticleType), + _ => typeof(EnglishArticleType), + }; + + var numCases = language == ClientLanguage.German ? 4 : 1; + +#if DEBUG + if (ImGui.Button("Copy as self-test entry")) + { + var sb = new StringBuilder(); + + foreach (var articleType in Enum.GetValues(articleTypeEnumType)) + { + for (var grammaticalCase = 0; grammaticalCase < numCases; grammaticalCase++) + { + var nounParams = new NounParams() + { + SheetName = sheetType.Name, + RowId = (uint)this.rowId, + Language = language, + Quantity = this.amount, + ArticleType = (int)articleType, + GrammaticalCase = grammaticalCase, + }; + var output = nounProcessor.ProcessNoun(nounParams).ExtractText().Replace("\"", "\\\""); + var caseParam = language == ClientLanguage.German ? $"(int)GermanCases.{GermanCases[grammaticalCase]}" : "1"; + sb.AppendLine($"new(nameof(LSheets.{sheetType.Name}), {this.rowId}, ClientLanguage.{language}, {this.amount}, (int){articleTypeEnumType.Name}.{Enum.GetName(articleTypeEnumType, articleType)}, {caseParam}, \"{output}\"),"); + } + } + + ImGui.SetClipboardText(sb.ToString()); + } +#endif + + using var table = ImRaii.Table("TextDecoderTable", 1 + numCases, ImGuiTableFlags.ScrollY | ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.NoSavedSettings); + if (!table) return; + + ImGui.TableSetupColumn("ArticleType", ImGuiTableColumnFlags.WidthFixed, 150); + for (var i = 0; i < numCases; i++) + ImGui.TableSetupColumn(language == ClientLanguage.German ? GermanCases[i] : "Text"); + ImGui.TableSetupScrollFreeze(6, 1); + ImGui.TableHeadersRow(); + + foreach (var articleType in Enum.GetValues(articleTypeEnumType)) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.TableHeader(articleType.ToString()); + + for (var currentCase = 0; currentCase < numCases; currentCase++) + { + ImGui.TableNextColumn(); + + try + { + var nounParams = new NounParams() + { + SheetName = sheetType.Name, + RowId = (uint)this.rowId, + Language = language, + Quantity = this.amount, + ArticleType = (int)articleType, + GrammaticalCase = currentCase, + }; + ImGui.TextUnformatted(nounProcessor.ProcessNoun(nounParams).ExtractText()); + } + catch (Exception ex) + { + ImGui.TextUnformatted(ex.ToString()); + } + } + } + } +} diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/SeStringCreatorWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/SeStringCreatorWidget.cs new file mode 100644 index 000000000..2a56cb6c7 --- /dev/null +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/SeStringCreatorWidget.cs @@ -0,0 +1,1276 @@ +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Text; + +using Dalamud.Configuration.Internal; +using Dalamud.Data; +using Dalamud.Game; +using Dalamud.Game.Text.Evaluator; +using Dalamud.Game.Text.Noun.Enums; +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Memory; +using Dalamud.Utility; + +using FFXIVClientStructs.FFXIV.Client.System.String; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Client.UI.Misc; +using FFXIVClientStructs.FFXIV.Component.Text; + +using ImGuiNET; + +using Lumina.Data; +using Lumina.Data.Files.Excel; +using Lumina.Data.Structs.Excel; +using Lumina.Excel; +using Lumina.Excel.Sheets; +using Lumina.Text.Expressions; +using Lumina.Text.Payloads; +using Lumina.Text.ReadOnly; + +using LSeStringBuilder = Lumina.Text.SeStringBuilder; + +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; + +/// +/// Widget to create SeStrings. +/// +internal class SeStringCreatorWidget : IDataWindowWidget +{ + private const LinkMacroPayloadType DalamudLinkType = (LinkMacroPayloadType)Payload.EmbeddedInfoType.DalamudLink - 1; + + private readonly Dictionary expressionNames = new() + { + { MacroCode.SetResetTime, ["Hour", "WeekDay"] }, + { MacroCode.SetTime, ["Time"] }, + { MacroCode.If, ["Condition", "StatementTrue", "StatementFalse"] }, + { MacroCode.Switch, ["Condition"] }, + { MacroCode.PcName, ["EntityId"] }, + { MacroCode.IfPcGender, ["EntityId", "CaseMale", "CaseFemale"] }, + { MacroCode.IfPcName, ["EntityId", "CaseTrue", "CaseFalse"] }, + // { MacroCode.Josa, [] }, + // { MacroCode.Josaro, [] }, + { MacroCode.IfSelf, ["EntityId", "CaseTrue", "CaseFalse"] }, + // { MacroCode.NewLine, [] }, + { MacroCode.Wait, ["Seconds"] }, + { MacroCode.Icon, ["IconId"] }, + { MacroCode.Color, ["Color"] }, + { MacroCode.EdgeColor, ["Color"] }, + { MacroCode.ShadowColor, ["Color"] }, + // { MacroCode.SoftHyphen, [] }, + // { MacroCode.Key, [] }, + // { MacroCode.Scale, [] }, + { MacroCode.Bold, ["Enabled"] }, + { MacroCode.Italic, ["Enabled"] }, + // { MacroCode.Edge, [] }, + // { MacroCode.Shadow, [] }, + // { MacroCode.NonBreakingSpace, [] }, + { MacroCode.Icon2, ["IconId"] }, + // { MacroCode.Hyphen, [] }, + { MacroCode.Num, ["Value"] }, + { MacroCode.Hex, ["Value"] }, + { MacroCode.Kilo, ["Value", "Separator"] }, + { MacroCode.Byte, ["Value"] }, + { MacroCode.Sec, ["Time"] }, + { MacroCode.Time, ["Value"] }, + { MacroCode.Float, ["Value", "Radix", "Separator"] }, + { MacroCode.Link, ["Type"] }, + { MacroCode.Sheet, ["SheetName", "RowId", "ColumnIndex", "ColumnParam"] }, + { MacroCode.String, ["String"] }, + { MacroCode.Caps, ["String"] }, + { MacroCode.Head, ["String"] }, + { MacroCode.Split, ["String", "Separator"] }, + { MacroCode.HeadAll, ["String"] }, + // { MacroCode.Fixed, [] }, + { MacroCode.Lower, ["String"] }, + { MacroCode.JaNoun, ["SheetName", "ArticleType", "RowId", "Amount", "Case", "UnkInt5"] }, + { MacroCode.EnNoun, ["SheetName", "ArticleType", "RowId", "Amount", "Case", "UnkInt5"] }, + { MacroCode.DeNoun, ["SheetName", "ArticleType", "RowId", "Amount", "Case", "UnkInt5"] }, + { MacroCode.FrNoun, ["SheetName", "ArticleType", "RowId", "Amount", "Case", "UnkInt5"] }, + { MacroCode.ChNoun, ["SheetName", "ArticleType", "RowId", "Amount", "Case", "UnkInt5"] }, + { MacroCode.LowerHead, ["String"] }, + { MacroCode.ColorType, ["ColorType"] }, + { MacroCode.EdgeColorType, ["ColorType"] }, + { MacroCode.Digit, ["Value", "TargetLength"] }, + { MacroCode.Ordinal, ["Value"] }, + { MacroCode.Sound, ["IsJingle", "SoundId"] }, + { MacroCode.LevelPos, ["LevelId"] }, + }; + + private readonly Dictionary linkExpressionNames = new() + { + { LinkMacroPayloadType.Character, ["Flags", "WorldId"] }, + { LinkMacroPayloadType.Item, ["ItemId", "Rarity"] }, + { LinkMacroPayloadType.MapPosition, ["TerritoryType/MapId", "RawX", "RawY"] }, + { LinkMacroPayloadType.Quest, ["QuestId"] }, + { LinkMacroPayloadType.Achievement, ["AchievementId"] }, + { LinkMacroPayloadType.HowTo, ["HowToId"] }, + // PartyFinderNotification + { LinkMacroPayloadType.Status, ["StatusId"] }, + { LinkMacroPayloadType.PartyFinder, ["ListingId", string.Empty, "WorldId"] }, + { LinkMacroPayloadType.AkatsukiNote, ["AkatsukiNoteId"] }, + { DalamudLinkType, ["CommandId", "Extra1", "Extra2", "ExtraString"] }, + }; + + private readonly Dictionary fixedExpressionNames = new() + { + { 1, ["Type0", "Type1", "WorldId"] }, + { 2, ["Type0", "Type1", "ClassJobId", "Level"] }, + { 3, ["Type0", "Type1", "TerritoryTypeId", "Instance & MapId", "RawX", "RawY", "RawZ", "PlaceNameIdOverride"] }, + { 4, ["Type0", "Type1", "ItemId", "Rarity", string.Empty, string.Empty, "Item Name"] }, + { 5, ["Type0", "Type1", "Sound Effect Id"] }, + { 6, ["Type0", "Type1", "ObjStrId"] }, + { 7, ["Type0", "Type1", "Text"] }, + { 8, ["Type0", "Type1", "Seconds"] }, + { 9, ["Type0", "Type1", string.Empty] }, + { 10, ["Type0", "Type1", "StatusId", "HasOverride", "NameOverride", "DescriptionOverride"] }, + { 11, ["Type0", "Type1", "ListingId", string.Empty, "WorldId", "CrossWorldFlag"] }, + { 12, ["Type0", "Type1", "QuestId", string.Empty, string.Empty, string.Empty, "QuestName"] }, + }; + + private readonly List entries = [ + new TextEntry(TextEntryType.String, "Welcome to "), + new TextEntry(TextEntryType.Macro, ""), + new TextEntry(TextEntryType.Macro, ""), + new TextEntry(TextEntryType.String, "Dalamud"), + new TextEntry(TextEntryType.Macro, ""), + new TextEntry(TextEntryType.Macro, ""), + new TextEntry(TextEntryType.Macro, " "), + ]; + + private SeStringParameter[]? localParameters = [Util.GetScmVersion()]; + private ReadOnlySeString input; + private ClientLanguage? language; + private int importSelectedSheetName; + private int importRowId; + private string[]? validImportSheetNames; + private float inputsWidth; + private float lastContentWidth; + + private enum TextEntryType + { + String, + Macro, + Fixed, + } + + /// + public string[]? CommandShortcuts { get; init; } = []; + + /// + public string DisplayName { get; init; } = "SeString Creator"; + + /// + public bool Ready { get; set; } + + /// + public void Load() + { + this.language = Service.Get().EffectiveLanguage.ToClientLanguage(); + this.UpdateInputString(false); + this.Ready = true; + } + + /// + public void Draw() + { + var contentWidth = ImGui.GetContentRegionAvail().X; + + // split panels in the middle by default + if (this.inputsWidth == 0) + { + this.inputsWidth = contentWidth / 2f; + } + + // resize panels relative to the window size + if (contentWidth != this.lastContentWidth) + { + var originalWidth = this.lastContentWidth != 0 ? this.lastContentWidth : contentWidth; + this.inputsWidth = this.inputsWidth / originalWidth * contentWidth; + this.lastContentWidth = contentWidth; + } + + using var tabBar = ImRaii.TabBar("SeStringCreatorWidgetTabBar"); + if (!tabBar) return; + + this.DrawCreatorTab(contentWidth); + this.DrawGlobalParametersTab(); + } + + private void DrawCreatorTab(float contentWidth) + { + using var tab = ImRaii.TabItem("Creator"); + if (!tab) return; + + this.DrawControls(); + ImGui.Spacing(); + this.DrawInputs(); + + this.localParameters ??= this.GetLocalParameters(this.input.AsSpan(), []); + + var evaluated = Service.Get().Evaluate( + this.input.AsSpan(), + this.localParameters, + this.language); + + ImGui.SameLine(0, 0); + + ImGui.Button("###InputPanelResizer", new Vector2(4, -1)); + if (ImGui.IsItemActive()) + { + this.inputsWidth += ImGui.GetIO().MouseDelta.X; + } + + if (ImGui.IsItemHovered()) + { + ImGui.SetMouseCursor(ImGuiMouseCursor.ResizeEW); + + if (ImGui.IsMouseDoubleClicked(ImGuiMouseButton.Left)) + { + this.inputsWidth = contentWidth / 2f; + } + } + + ImGui.SameLine(); + + using var child = ImRaii.Child("Preview", new Vector2(ImGui.GetContentRegionAvail().X, -1)); + if (!child) return; + + if (this.localParameters!.Length != 0) + { + ImGui.Spacing(); + this.DrawParameters(); + } + + this.DrawPreview(evaluated); + + ImGui.Spacing(); + this.DrawPayloads(evaluated); + } + + private unsafe void DrawGlobalParametersTab() + { + using var tab = ImRaii.TabItem("Global Parameters"); + if (!tab) return; + + using var table = ImRaii.Table("GlobalParametersTable", 5, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY | ImGuiTableFlags.NoSavedSettings); + if (!table) return; + + ImGui.TableSetupColumn("Id", ImGuiTableColumnFlags.WidthFixed, 40); + ImGui.TableSetupColumn("Type", ImGuiTableColumnFlags.WidthFixed, 100); + ImGui.TableSetupColumn("ValuePtr", ImGuiTableColumnFlags.WidthFixed, 120); + ImGui.TableSetupColumn("Value", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupScrollFreeze(5, 1); + ImGui.TableHeadersRow(); + + var deque = RaptureTextModule.Instance()->GlobalParameters; + for (var i = 0u; i < deque.MySize; i++) + { + var item = deque[i]; + + ImGui.TableNextRow(); + ImGui.TableNextColumn(); // Id + ImGui.TextUnformatted(i.ToString()); + + ImGui.TableNextColumn(); // Type + ImGui.TextUnformatted(item.Type.ToString()); + + ImGui.TableNextColumn(); // ValuePtr + WidgetUtil.DrawCopyableText($"0x{(nint)item.ValuePtr:X}"); + + ImGui.TableNextColumn(); // Value + switch (item.Type) + { + case TextParameterType.Integer: + WidgetUtil.DrawCopyableText($"0x{item.IntValue:X}"); + ImGui.SameLine(); + WidgetUtil.DrawCopyableText(item.IntValue.ToString()); + break; + + case TextParameterType.ReferencedUtf8String: + if (item.ReferencedUtf8StringValue != null) + WidgetUtil.DrawCopyableText(new ReadOnlySeStringSpan(item.ReferencedUtf8StringValue->Utf8String).ToString()); + else + ImGui.TextUnformatted("null"); + + break; + + case TextParameterType.String: + if (item.StringValue != null) + WidgetUtil.DrawCopyableText(MemoryHelper.ReadStringNullTerminated((nint)item.StringValue)); + else + ImGui.TextUnformatted("null"); + break; + } + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(i switch + { + 0 => "Player Name", + 1 => "Temp Player 1 Name", + 2 => "Temp Player 2 Name", + 3 => "Player Sex", + 4 => "Temp Player 1 Sex", + 5 => "Temp Player 2 Sex", + 6 => "Temp Player 1 Unk 1", + 7 => "Temp Player 2 Unk 1", + 10 => "Eorzea Time Hours", + 11 => "Eorzea Time Minutes", + 12 => "ColorSay", + 13 => "ColorShout", + 14 => "ColorTell", + 15 => "ColorParty", + 16 => "ColorAlliance", + 17 => "ColorLS1", + 18 => "ColorLS2", + 19 => "ColorLS3", + 20 => "ColorLS4", + 21 => "ColorLS5", + 22 => "ColorLS6", + 23 => "ColorLS7", + 24 => "ColorLS8", + 25 => "ColorFCompany", + 26 => "ColorPvPGroup", + 27 => "ColorPvPGroupAnnounce", + 28 => "ColorBeginner", + 29 => "ColorEmoteUser", + 30 => "ColorEmote", + 31 => "ColorYell", + 32 => "ColorFCAnnounce", + 33 => "ColorBeginnerAnnounce", + 34 => "ColorCWLS", + 35 => "ColorAttackSuccess", + 36 => "ColorAttackFailure", + 37 => "ColorAction", + 38 => "ColorItem", + 39 => "ColorCureGive", + 40 => "ColorBuffGive", + 41 => "ColorDebuffGive", + 42 => "ColorEcho", + 43 => "ColorSysMsg", + 51 => "Player Grand Company Rank (Maelstrom)", + 52 => "Player Grand Company Rank (Twin Adders)", + 53 => "Player Grand Company Rank (Immortal Flames)", + 54 => "Companion Name", + 55 => "Content Name", + 56 => "ColorSysBattle", + 57 => "ColorSysGathering", + 58 => "ColorSysErr", + 59 => "ColorNpcSay", + 60 => "ColorItemNotice", + 61 => "ColorGrowup", + 62 => "ColorLoot", + 63 => "ColorCraft", + 64 => "ColorGathering", + 65 => "Temp Player 1 Unk 2", + 66 => "Temp Player 2 Unk 2", + 67 => "Player ClassJobId", + 68 => "Player Level", + 70 => "Player Race", + 71 => "Player Synced Level", + 77 => "Client/Plattform?", + 78 => "Player BirthMonth", + 82 => "Datacenter Region", + 83 => "ColorCWLS2", + 84 => "ColorCWLS3", + 85 => "ColorCWLS4", + 86 => "ColorCWLS5", + 87 => "ColorCWLS6", + 88 => "ColorCWLS7", + 89 => "ColorCWLS8", + 91 => "Player Grand Company", + 92 => "TerritoryType Id", + 93 => "Is Soft Keyboard Enabled", + 94 => "LogSetRoleColor 1: LogColorRoleTank", + 95 => "LogSetRoleColor 2: LogColorRoleTank", + 96 => "LogSetRoleColor 1: LogColorRoleHealer", + 97 => "LogSetRoleColor 2: LogColorRoleHealer", + 98 => "LogSetRoleColor 1: LogColorRoleDPS", + 99 => "LogSetRoleColor 2: LogColorRoleDPS", + 100 => "LogSetRoleColor 1: LogColorOtherClass", + 101 => "LogSetRoleColor 2: LogColorOtherClass", + 102 => "Has Login Security Token", + _ => string.Empty, + }); + } + } + + private unsafe void DrawControls() + { + if (ImGui.Button("Add entry")) + { + this.entries.Add(new(TextEntryType.String, string.Empty)); + } + + ImGui.SameLine(); + + if (ImGui.Button("Add from Sheet")) + { + ImGui.OpenPopup("AddFromSheetPopup"); + } + + this.DrawAddFromSheetPopup(); + + ImGui.SameLine(); + + if (ImGui.Button("Print")) + { + var output = Utf8String.CreateEmpty(); + var temp = Utf8String.CreateEmpty(); + var temp2 = Utf8String.CreateEmpty(); + + foreach (var entry in this.entries) + { + switch (entry.Type) + { + case TextEntryType.String: + output->ConcatCStr(entry.Message); + break; + + case TextEntryType.Macro: + temp->Clear(); + RaptureTextModule.Instance()->MacroEncoder.EncodeString(temp, entry.Message); + output->Append(temp); + break; + + case TextEntryType.Fixed: + temp->SetString(entry.Message); + temp2->Clear(); + + RaptureTextModule.Instance()->TextModule.ProcessMacroCode(temp2, temp->StringPtr); + var out1 = PronounModule.Instance()->ProcessString(temp2, true); + var out2 = PronounModule.Instance()->ProcessString(out1, false); + + output->Append(out2); + break; + } + } + + RaptureLogModule.Instance()->PrintString(output->StringPtr); + temp2->Dtor(true); + temp->Dtor(true); + output->Dtor(true); + } + + ImGui.SameLine(); + + if (ImGui.Button("Print Evaluated")) + { + var sb = new LSeStringBuilder(); + + foreach (var entry in this.entries) + { + switch (entry.Type) + { + case TextEntryType.String: + sb.Append(entry.Message); + break; + + case TextEntryType.Macro: + case TextEntryType.Fixed: + sb.AppendMacroString(entry.Message); + break; + } + } + + RaptureLogModule.Instance()->PrintString(Service.Get().Evaluate(sb.ToReadOnlySeString())); + } + + if (this.entries.Count != 0) + { + ImGui.SameLine(); + + if (ImGui.Button("Copy MacroString")) + { + var sb = new LSeStringBuilder(); + + foreach (var entry in this.entries) + { + switch (entry.Type) + { + case TextEntryType.String: + sb.Append(entry.Message); + break; + + case TextEntryType.Macro: + case TextEntryType.Fixed: + sb.AppendMacroString(entry.Message); + break; + } + } + + ImGui.SetClipboardText(sb.ToReadOnlySeString().ToString()); + } + + ImGui.SameLine(); + + if (ImGui.Button("Clear entries")) + { + this.entries.Clear(); + this.UpdateInputString(); + } + } + + var raptureTextModule = RaptureTextModule.Instance(); + if (!raptureTextModule->MacroEncoder.EncoderError.IsEmpty) + { + ImGui.SameLine(); + ImGui.TextUnformatted(raptureTextModule->MacroEncoder.EncoderError.ToString()); // TODO: EncoderError doesn't clear + } + + ImGui.SameLine(); + ImGui.SetNextItemWidth(90 * ImGuiHelpers.GlobalScale); + using (var dropdown = ImRaii.Combo("##Language", this.language.ToString() ?? "Language...")) + { + if (dropdown) + { + var values = Enum.GetValues().OrderBy((ClientLanguage lang) => lang.ToString()); + foreach (var value in values) + { + if (ImGui.Selectable(Enum.GetName(value), value == this.language)) + { + this.language = value; + this.UpdateInputString(); + } + } + } + } + } + + private void DrawAddFromSheetPopup() + { + using var popup = ImRaii.Popup("AddFromSheetPopup"); + if (!popup) return; + + var dataManager = Service.Get(); + + this.validImportSheetNames ??= dataManager.Excel.SheetNames.Where(sheetName => + { + try + { + var headerFile = dataManager.GameData.GetFile($"exd/{sheetName}.exh"); + if (headerFile.Header.Variant != ExcelVariant.Default) + return false; + + var sheet = dataManager.Excel.GetSheet(Language.English, sheetName); + return sheet.Columns.Any(col => col.Type == ExcelColumnDataType.String); + } + catch + { + return false; + } + }).OrderBy(sheetName => sheetName, StringComparer.InvariantCulture).ToArray(); + + var sheetChanged = ImGui.Combo("Sheet Name", ref this.importSelectedSheetName, this.validImportSheetNames, this.validImportSheetNames.Length); + + try + { + var sheet = dataManager.Excel.GetSheet(this.language?.ToLumina() ?? Language.English, this.validImportSheetNames[this.importSelectedSheetName]); + var minRowId = (int)sheet.FirstOrDefault().RowId; + var maxRowId = (int)sheet.LastOrDefault().RowId; + + var rowIdChanged = ImGui.InputInt("RowId", ref this.importRowId, 1, 10); + + ImGui.SameLine(0, ImGui.GetStyle().ItemInnerSpacing.X); + ImGui.TextUnformatted($"(Range: {minRowId} - {maxRowId})"); + + if (sheetChanged || rowIdChanged) + { + if (sheetChanged || this.importRowId < minRowId) + this.importRowId = minRowId; + + if (this.importRowId > maxRowId) + this.importRowId = maxRowId; + } + + if (!sheet.TryGetRow((uint)this.importRowId, out var row)) + { + ImGui.TextColored(new Vector4(1, 0, 0, 1), "Row not found"); + return; + } + + ImGui.TextUnformatted("Select string to add:"); + + using var table = ImRaii.Table("StringSelectionTable", 2, ImGuiTableFlags.Borders | ImGuiTableFlags.NoSavedSettings); + if (!table) return; + + ImGui.TableSetupColumn("Column", ImGuiTableColumnFlags.WidthFixed, 50); + ImGui.TableSetupColumn("Value", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupScrollFreeze(0, 1); + ImGui.TableHeadersRow(); + + for (var i = 0; i < sheet.Columns.Count; i++) + { + var column = sheet.Columns[i]; + if (column.Type != ExcelColumnDataType.String) + continue; + + var value = row.ReadStringColumn(i); + if (value.IsEmpty) + continue; + + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(i.ToString()); + + ImGui.TableNextColumn(); + if (ImGui.Selectable($"{value.ToString().Truncate(100)}###Column{i}")) + { + foreach (var payload in value) + { + switch (payload.Type) + { + case ReadOnlySePayloadType.Text: + this.entries.Add(new(TextEntryType.String, Encoding.UTF8.GetString(payload.Body.Span))); + break; + + case ReadOnlySePayloadType.Macro: + this.entries.Add(new(TextEntryType.Macro, payload.ToString())); + break; + } + } + + this.UpdateInputString(); + ImGui.CloseCurrentPopup(); + } + } + } + catch (Exception e) + { + ImGui.TextUnformatted(e.Message); + return; + } + } + + private unsafe void DrawInputs() + { + using var child = ImRaii.Child("Inputs", new Vector2(this.inputsWidth, -1)); + if (!child) return; + + using var table = ImRaii.Table("StringMakerTable", 3, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY | ImGuiTableFlags.NoSavedSettings); + if (!table) return; + + ImGui.TableSetupColumn("Type", ImGuiTableColumnFlags.WidthFixed, 100); + ImGui.TableSetupColumn("Text", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.WidthFixed, 80); + ImGui.TableSetupScrollFreeze(3, 1); + ImGui.TableHeadersRow(); + + var arrowUpButtonSize = this.GetIconButtonSize(FontAwesomeIcon.ArrowUp); + var arrowDownButtonSize = this.GetIconButtonSize(FontAwesomeIcon.ArrowDown); + var trashButtonSize = this.GetIconButtonSize(FontAwesomeIcon.Trash); + var terminalButtonSize = this.GetIconButtonSize(FontAwesomeIcon.Terminal); + + var entryToRemove = -1; + var entryToMoveUp = -1; + var entryToMoveDown = -1; + var updateString = false; + + for (var i = 0; i < this.entries.Count; i++) + { + var key = $"##Entry{i}"; + var entry = this.entries[i]; + + ImGui.TableNextRow(); + + ImGui.TableNextColumn(); // Type + var type = (int)entry.Type; + ImGui.SetNextItemWidth(-1); + if (ImGui.Combo($"##Type{i}", ref type, ["String", "Macro", "Fixed"], 3)) + { + entry.Type = (TextEntryType)type; + updateString |= true; + } + + ImGui.TableNextColumn(); // Text + var message = entry.Message; + ImGui.SetNextItemWidth(-1); + if (ImGui.InputText($"##{i}_Message", ref message, 255)) + { + entry.Message = message; + updateString |= true; + } + + ImGui.TableNextColumn(); // Actions + + if (i > 0) + { + if (this.IconButton(key + "_Up", FontAwesomeIcon.ArrowUp, "Move up")) + { + entryToMoveUp = i; + } + } + else + { + ImGui.Dummy(arrowUpButtonSize); + } + + ImGui.SameLine(0, ImGui.GetStyle().ItemInnerSpacing.X); + + if (i < this.entries.Count - 1) + { + if (this.IconButton(key + "_Down", FontAwesomeIcon.ArrowDown, "Move down")) + { + entryToMoveDown = i; + } + } + else + { + ImGui.Dummy(arrowDownButtonSize); + } + + ImGui.SameLine(0, ImGui.GetStyle().ItemInnerSpacing.X); + + if (ImGui.IsKeyDown(ImGuiKey.LeftShift) || ImGui.IsKeyDown(ImGuiKey.RightShift)) + { + if (this.IconButton(key + "_Delete", FontAwesomeIcon.Trash, "Delete")) + { + entryToRemove = i; + } + } + else + { + this.IconButton( + key + "_Delete", + FontAwesomeIcon.Trash, + "Delete with shift", + disabled: true); + } + } + + table.Dispose(); + + if (entryToMoveUp != -1) + { + var removedItem = this.entries[entryToMoveUp]; + this.entries.RemoveAt(entryToMoveUp); + this.entries.Insert(entryToMoveUp - 1, removedItem); + updateString |= true; + } + + if (entryToMoveDown != -1) + { + var removedItem = this.entries[entryToMoveDown]; + this.entries.RemoveAt(entryToMoveDown); + this.entries.Insert(entryToMoveDown + 1, removedItem); + updateString |= true; + } + + if (entryToRemove != -1) + { + this.entries.RemoveAt(entryToRemove); + updateString |= true; + } + + if (updateString) + { + this.UpdateInputString(); + } + } + + private unsafe void UpdateInputString(bool resetLocalParameters = true) + { + var sb = new LSeStringBuilder(); + + foreach (var entry in this.entries) + { + switch (entry.Type) + { + case TextEntryType.String: + sb.Append(entry.Message); + break; + + case TextEntryType.Macro: + case TextEntryType.Fixed: + sb.AppendMacroString(entry.Message); + break; + } + } + + this.input = sb.ToReadOnlySeString(); + + if (resetLocalParameters) + this.localParameters = null; + } + + private void DrawPreview(ReadOnlySeString str) + { + using var nodeColor = ImRaii.PushColor(ImGuiCol.Text, 0xFF00FF00); + using var node = ImRaii.TreeNode("Preview", ImGuiTreeNodeFlags.DefaultOpen); + nodeColor.Pop(); + if (!node) return; + + ImGui.Dummy(new Vector2(0, ImGui.GetTextLineHeight())); + ImGui.SameLine(0, 0); + ImGuiHelpers.SeStringWrapped(str); + } + + private void DrawParameters() + { + using var nodeColor = ImRaii.PushColor(ImGuiCol.Text, 0xFF00FF00); + using var node = ImRaii.TreeNode("Parameters", ImGuiTreeNodeFlags.DefaultOpen); + nodeColor.Pop(); + if (!node) return; + + for (var i = 0; i < this.localParameters!.Length; i++) + { + if (this.localParameters[i].IsString) + { + var str = this.localParameters[i].StringValue.ExtractText(); + if (ImGui.InputText($"lstr({i + 1})", ref str, 255)) + { + this.localParameters[i] = new(str); + } + } + else + { + var num = (int)this.localParameters[i].UIntValue; + if (ImGui.InputInt($"lnum({i + 1})", ref num)) + { + this.localParameters[i] = new((uint)num); + } + } + } + } + + private void DrawPayloads(ReadOnlySeString evaluated) + { + using (var nodeColor = ImRaii.PushColor(ImGuiCol.Text, 0xFF00FF00)) + using (var node = ImRaii.TreeNode("Payloads", ImGuiTreeNodeFlags.DefaultOpen | ImGuiTreeNodeFlags.SpanAvailWidth)) + { + nodeColor.Pop(); + if (node) this.DrawSeString("payloads", this.input.AsSpan(), treeNodeFlags: ImGuiTreeNodeFlags.DefaultOpen | ImGuiTreeNodeFlags.SpanAvailWidth); + } + + if (this.input.Equals(evaluated)) + return; + + using (var nodeColor = ImRaii.PushColor(ImGuiCol.Text, 0xFF00FF00)) + using (var node = ImRaii.TreeNode("Payloads (Evaluated)", ImGuiTreeNodeFlags.DefaultOpen | ImGuiTreeNodeFlags.SpanAvailWidth)) + { + nodeColor.Pop(); + if (node) this.DrawSeString("payloads-evaluated", evaluated.AsSpan(), treeNodeFlags: ImGuiTreeNodeFlags.DefaultOpen | ImGuiTreeNodeFlags.SpanAvailWidth); + } + } + + private void DrawSeString(string id, ReadOnlySeStringSpan rosss, bool asTreeNode = false, bool renderSeString = false, int depth = 0, ImGuiTreeNodeFlags treeNodeFlags = ImGuiTreeNodeFlags.None) + { + using var seStringId = ImRaii.PushId(id); + + if (rosss.PayloadCount == 0) + { + ImGui.Dummy(Vector2.Zero); + return; + } + + using var node = asTreeNode ? this.SeStringTreeNode(id, rosss) : null; + if (asTreeNode && !node!) return; + + if (!asTreeNode && renderSeString) + { + ImGuiHelpers.SeStringWrapped(rosss, new() + { + ForceEdgeColor = true, + }); + } + + var payloadIdx = -1; + foreach (var payload in rosss) + { + payloadIdx++; + using var payloadId = ImRaii.PushId(payloadIdx); + + var preview = payload.Type.ToString(); + if (payload.Type == ReadOnlySePayloadType.Macro) + preview += $": {payload.MacroCode}"; + + using var nodeColor = ImRaii.PushColor(ImGuiCol.Text, 0xFF00FFFF); + using var payloadNode = ImRaii.TreeNode($"[{payloadIdx}] {preview}", ImGuiTreeNodeFlags.DefaultOpen | ImGuiTreeNodeFlags.SpanAvailWidth); + nodeColor.Pop(); + if (!payloadNode) continue; + + using var table = ImRaii.Table($"##Payload{payloadIdx}Table", 2); + if (!table) return; + + ImGui.TableSetupColumn("Label", ImGuiTableColumnFlags.WidthFixed, 120); + ImGui.TableSetupColumn("Tree", ImGuiTableColumnFlags.WidthStretch); + + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(payload.Type == ReadOnlySePayloadType.Text ? "Text" : "ToString()"); + ImGui.TableNextColumn(); + var text = payload.ToString(); + WidgetUtil.DrawCopyableText($"\"{text}\"", text); + + if (payload.Type != ReadOnlySePayloadType.Macro) + continue; + + if (payload.ExpressionCount > 0) + { + var exprIdx = 0; + uint? subType = null; + uint? fixedType = null; + + if (payload.MacroCode == MacroCode.Link && payload.TryGetExpression(out var linkExpr1) && linkExpr1.TryGetUInt(out var linkExpr1Val)) + { + subType = linkExpr1Val; + } + else if (payload.MacroCode == MacroCode.Fixed && payload.TryGetExpression(out var fixedTypeExpr, out var linkExpr2) && fixedTypeExpr.TryGetUInt(out var fixedTypeVal) && linkExpr2.TryGetUInt(out var linkExpr2Val)) + { + subType = linkExpr2Val; + fixedType = fixedTypeVal; + } + + foreach (var expr in payload) + { + using var exprId = ImRaii.PushId(exprIdx); + + this.DrawExpression(payload.MacroCode, subType, fixedType, exprIdx++, expr); + } + } + } + } + + private unsafe void DrawExpression(MacroCode macroCode, uint? subType, uint? fixedType, int exprIdx, ReadOnlySeExpressionSpan expr) + { + ImGui.TableNextRow(); + + ImGui.TableNextColumn(); + var expressionName = this.GetExpressionName(macroCode, subType, exprIdx, expr); + ImGui.TextUnformatted($"[{exprIdx}] " + (string.IsNullOrEmpty(expressionName) ? $"Expr {exprIdx}" : expressionName)); + + ImGui.TableNextColumn(); + + if (expr.Body.IsEmpty) + { + ImGui.TextUnformatted("(?)"); + return; + } + + if (expr.TryGetUInt(out var u32)) + { + if (macroCode is MacroCode.Icon or MacroCode.Icon2 && exprIdx == 0) + { + var iconId = u32; + + if (macroCode == MacroCode.Icon2) + { + var iconMapping = RaptureAtkModule.Instance()->AtkFontManager.Icon2RemapTable; + for (var i = 0; i < 30; i++) + { + if (iconMapping[i].IconId == iconId) + { + iconId = iconMapping[i].RemappedIconId; + break; + } + } + } + + var builder = LSeStringBuilder.SharedPool.Get(); + builder.AppendIcon(iconId); + ImGuiHelpers.SeStringWrapped(builder.ToArray()); + LSeStringBuilder.SharedPool.Return(builder); + + ImGui.SameLine(); + } + + WidgetUtil.DrawCopyableText(u32.ToString()); + ImGui.SameLine(); + WidgetUtil.DrawCopyableText($"0x{u32:X}"); + + if (macroCode == MacroCode.Link && exprIdx == 0) + { + var name = subType != null && (LinkMacroPayloadType)subType == DalamudLinkType + ? "Dalamud" + : Enum.GetName((LinkMacroPayloadType)u32); + + if (!string.IsNullOrEmpty(name)) + { + ImGui.SameLine(); + ImGui.TextUnformatted(name); + } + } + + if (macroCode is MacroCode.JaNoun or MacroCode.EnNoun or MacroCode.DeNoun or MacroCode.FrNoun && exprIdx == 1) + { + var language = macroCode switch + { + MacroCode.JaNoun => ClientLanguage.Japanese, + MacroCode.DeNoun => ClientLanguage.German, + MacroCode.FrNoun => ClientLanguage.French, + _ => ClientLanguage.English, + }; + var articleTypeEnumType = language switch + { + ClientLanguage.Japanese => typeof(JapaneseArticleType), + ClientLanguage.German => typeof(GermanArticleType), + ClientLanguage.French => typeof(FrenchArticleType), + _ => typeof(EnglishArticleType), + }; + ImGui.SameLine(); + ImGui.TextUnformatted(Enum.GetName(articleTypeEnumType, u32)); + } + + if (macroCode is MacroCode.DeNoun && exprIdx == 4 && u32 is >= 0 and <= 3) + { + ImGui.SameLine(); + ImGui.TextUnformatted(NounProcessorWidget.GermanCases[u32]); + } + + if (macroCode is MacroCode.Fixed && subType != null && fixedType != null && fixedType is 100 or 200 && subType == 5 && exprIdx == 2) + { + ImGui.SameLine(); + if (ImGui.SmallButton("Play")) + { + UIGlobals.PlayChatSoundEffect(u32 + 1); + } + } + + if (macroCode is MacroCode.Link && subType != null && exprIdx == 1) + { + var dataManager = Service.Get(); + + switch ((LinkMacroPayloadType)subType) + { + case LinkMacroPayloadType.Item when dataManager.GetExcelSheet(this.language).TryGetRow(u32, out var itemRow): + ImGui.SameLine(); + ImGui.TextUnformatted(itemRow.Name.ExtractText()); + break; + + case LinkMacroPayloadType.Quest when dataManager.GetExcelSheet(this.language).TryGetRow(u32, out var questRow): + ImGui.SameLine(); + ImGui.TextUnformatted(questRow.Name.ExtractText()); + break; + + case LinkMacroPayloadType.Achievement when dataManager.GetExcelSheet(this.language).TryGetRow(u32, out var achievementRow): + ImGui.SameLine(); + ImGui.TextUnformatted(achievementRow.Name.ExtractText()); + break; + + case LinkMacroPayloadType.HowTo when dataManager.GetExcelSheet(this.language).TryGetRow(u32, out var howToRow): + ImGui.SameLine(); + ImGui.TextUnformatted(howToRow.Name.ExtractText()); + break; + + case LinkMacroPayloadType.Status when dataManager.GetExcelSheet(this.language).TryGetRow(u32, out var statusRow): + ImGui.SameLine(); + ImGui.TextUnformatted(statusRow.Name.ExtractText()); + break; + + case LinkMacroPayloadType.AkatsukiNote when + dataManager.GetSubrowExcelSheet(this.language).TryGetRow(u32, out var akatsukiNoteRow) && + dataManager.GetExcelSheet(this.language).TryGetRow((uint)akatsukiNoteRow[0].Unknown2, out var akatsukiNoteStringRow): + ImGui.SameLine(); + ImGui.TextUnformatted(akatsukiNoteStringRow.Unknown0.ExtractText()); + break; + } + } + + return; + } + + if (expr.TryGetString(out var s)) + { + this.DrawSeString("Preview", s, treeNodeFlags: ImGuiTreeNodeFlags.DefaultOpen); + return; + } + + if (expr.TryGetPlaceholderExpression(out var exprType)) + { + if (((ExpressionType)exprType).GetNativeName() is { } nativeName) + { + ImGui.TextUnformatted(nativeName); + return; + } + + ImGui.TextUnformatted($"?x{exprType:X02}"); + return; + } + + if (expr.TryGetParameterExpression(out exprType, out var e1)) + { + if (((ExpressionType)exprType).GetNativeName() is { } nativeName) + { + ImGui.TextUnformatted($"{nativeName}({e1.ToString()})"); + return; + } + + throw new InvalidOperationException("All native names must be defined for unary expressions."); + } + + if (expr.TryGetBinaryExpression(out exprType, out e1, out var e2)) + { + if (((ExpressionType)exprType).GetNativeName() is { } nativeName) + { + ImGui.TextUnformatted($"{e1.ToString()} {nativeName} {e2.ToString()}"); + return; + } + + throw new InvalidOperationException("All native names must be defined for binary expressions."); + } + + var sb = new StringBuilder(); + sb.EnsureCapacity(1 + 3 * expr.Body.Length); + sb.Append($"({expr.Body[0]:X02}"); + for (var i = 1; i < expr.Body.Length; i++) + sb.Append($" {expr.Body[i]:X02}"); + sb.Append(')'); + ImGui.TextUnformatted(sb.ToString()); + } + + private string GetExpressionName(MacroCode macroCode, uint? subType, int idx, ReadOnlySeExpressionSpan expr) + { + if (this.expressionNames.TryGetValue(macroCode, out var names) && idx < names.Length) + return names[idx]; + + if (macroCode == MacroCode.Switch) + return $"Case {idx - 1}"; + + if (macroCode == MacroCode.Link && subType != null && this.linkExpressionNames.TryGetValue((LinkMacroPayloadType)subType, out var linkNames) && idx - 1 < linkNames.Length) + return linkNames[idx - 1]; + + if (macroCode == MacroCode.Fixed && subType != null && this.fixedExpressionNames.TryGetValue((uint)subType, out var fixedNames) && idx < fixedNames.Length) + return fixedNames[idx]; + + if (macroCode == MacroCode.Link && idx == 4) + return "Copy String"; + + return string.Empty; + } + + private SeStringParameter[] GetLocalParameters(ReadOnlySeStringSpan rosss, Dictionary? parameters) + { + parameters ??= []; + + void ProcessString(ReadOnlySeStringSpan rosss) + { + foreach (var payload in rosss) + { + foreach (var expression in payload) + { + ProcessExpression(expression); + } + } + } + + void ProcessExpression(ReadOnlySeExpressionSpan expression) + { + if (expression.TryGetString(out var exprString)) + { + ProcessString(exprString); + return; + } + + if (expression.TryGetBinaryExpression(out var expressionType, out var operand1, out var operand2)) + { + ProcessExpression(operand1); + ProcessExpression(operand2); + return; + } + + if (expression.TryGetParameterExpression(out expressionType, out var operand)) + { + if (!operand.TryGetUInt(out var index)) + return; + + if (parameters.ContainsKey(index)) + return; + + if (expressionType == (int)ExpressionType.LocalNumber) + { + parameters[index] = new SeStringParameter(0); + return; + } + else if (expressionType == (int)ExpressionType.LocalString) + { + parameters[index] = new SeStringParameter(string.Empty); + return; + } + } + } + + ProcessString(rosss); + + if (parameters.Count > 0) + { + var last = parameters.OrderBy(x => x.Key).Last(); + + if (parameters.Count != last.Key) + { + // fill missing local parameter slots, so we can go off the array index in SeStringContext + + for (var i = 1u; i <= last.Key; i++) + { + if (!parameters.ContainsKey(i)) + parameters[i] = new SeStringParameter(0); + } + } + } + + return parameters.OrderBy(x => x.Key).Select(x => x.Value).ToArray(); + } + + private ImRaii.IEndObject SeStringTreeNode(string id, ReadOnlySeStringSpan previewText, uint color = 0xFF00FFFF, ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags.None) + { + using var titleColor = ImRaii.PushColor(ImGuiCol.Text, color); + var node = ImRaii.TreeNode("##" + id, flags); + ImGui.SameLine(); + ImGuiHelpers.SeStringWrapped(previewText, new() + { + ForceEdgeColor = true, + WrapWidth = 9999, + }); + return node; + } + + private bool IconButton(string key, FontAwesomeIcon icon, string tooltip, Vector2 size = default, bool disabled = false, bool active = false) + { + using var iconFont = ImRaii.PushFont(UiBuilder.IconFont); + if (!key.StartsWith("##")) key = "##" + key; + + var disposables = new List(); + + if (disabled) + { + disposables.Add(ImRaii.PushColor(ImGuiCol.Text, ImGui.GetStyle().Colors[(int)ImGuiCol.TextDisabled])); + disposables.Add(ImRaii.PushColor(ImGuiCol.ButtonActive, ImGui.GetStyle().Colors[(int)ImGuiCol.Button])); + disposables.Add(ImRaii.PushColor(ImGuiCol.ButtonHovered, ImGui.GetStyle().Colors[(int)ImGuiCol.Button])); + } + else if (active) + { + disposables.Add(ImRaii.PushColor(ImGuiCol.Button, ImGui.GetStyle().Colors[(int)ImGuiCol.ButtonActive])); + } + + var pressed = ImGui.Button(icon.ToIconString() + key, size); + + foreach (var disposable in disposables) + disposable.Dispose(); + + iconFont?.Dispose(); + + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.TextUnformatted(tooltip); + ImGui.EndTooltip(); + } + + return pressed; + } + + private Vector2 GetIconButtonSize(FontAwesomeIcon icon) + { + using var iconFont = ImRaii.PushFont(UiBuilder.IconFont); + return ImGui.CalcTextSize(icon.ToIconString()) + ImGui.GetStyle().FramePadding * 2; + } + + private class TextEntry(TextEntryType type, string text) + { + public string Message { get; set; } = text; + + public TextEntryType Type { get; set; } = type; + } +} diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/GamepadStateAgingStep.cs b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/GamepadStateAgingStep.cs index ccee570c7..323d82bbc 100644 --- a/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/GamepadStateAgingStep.cs +++ b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/GamepadStateAgingStep.cs @@ -1,6 +1,11 @@ -using Dalamud.Game.ClientState.GamePad; +using System.Linq; -using ImGuiNET; +using Dalamud.Game.ClientState.GamePad; +using Dalamud.Interface.Utility; + +using Lumina.Text.Payloads; + +using LSeStringBuilder = Lumina.Text.SeStringBuilder; namespace Dalamud.Interface.Internal.Windows.SelfTest.AgingSteps; @@ -17,11 +22,34 @@ internal class GamepadStateAgingStep : IAgingStep { var gamepadState = Service.Get(); - ImGui.Text("Hold down North, East, L1"); + var buttons = new (GamepadButtons Button, uint IconId)[] + { + (GamepadButtons.North, 11), + (GamepadButtons.East, 8), + (GamepadButtons.L1, 12), + }; - if (gamepadState.Raw(GamepadButtons.North) == 1 - && gamepadState.Raw(GamepadButtons.East) == 1 - && gamepadState.Raw(GamepadButtons.L1) == 1) + var builder = LSeStringBuilder.SharedPool.Get(); + + builder.Append("Hold down "); + + for (var i = 0; i < buttons.Length; i++) + { + var (button, iconId) = buttons[i]; + + builder.BeginMacro(MacroCode.Icon).AppendUIntExpression(iconId).EndMacro(); + builder.PushColorRgba(gamepadState.Raw(button) == 1 ? 0x0000FF00u : 0x000000FF); + builder.Append(button.ToString()); + builder.PopColor(); + + builder.Append(i < buttons.Length - 1 ? ", " : "."); + } + + ImGuiHelpers.SeStringWrapped(builder.ToReadOnlySeString()); + + LSeStringBuilder.SharedPool.Return(builder); + + if (buttons.All(tuple => gamepadState.Raw(tuple.Button) == 1)) { return SelfTestStepResult.Pass; } diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/NounProcessorAgingStep.cs b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/NounProcessorAgingStep.cs new file mode 100644 index 000000000..4073616b2 --- /dev/null +++ b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/NounProcessorAgingStep.cs @@ -0,0 +1,259 @@ +using Dalamud.Game; +using Dalamud.Game.Text.Noun; +using Dalamud.Game.Text.Noun.Enums; + +using ImGuiNET; + +using LSheets = Lumina.Excel.Sheets; + +namespace Dalamud.Interface.Internal.Windows.SelfTest.AgingSteps; + +/// +/// Test setup for NounProcessor. +/// +internal class NounProcessorAgingStep : IAgingStep +{ + private NounTestEntry[] tests = + [ + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.Japanese, 1, (int)JapaneseArticleType.NearListener, 1, "その蜂蜜酒の運び人"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.Japanese, 1, (int)JapaneseArticleType.Distant, 1, "蜂蜜酒の運び人"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.Japanese, 2, (int)JapaneseArticleType.NearListener, 1, "それらの蜂蜜酒の運び人"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.Japanese, 2, (int)JapaneseArticleType.Distant, 1, "あれらの蜂蜜酒の運び人"), + + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.English, 1, (int)EnglishArticleType.Indefinite, 1, "a mead-porting Midlander"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.English, 1, (int)EnglishArticleType.Definite, 1, "the mead-porting Midlander"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.English, 2, (int)EnglishArticleType.Indefinite, 1, "mead-porting Midlanders"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.English, 2, (int)EnglishArticleType.Definite, 1, "mead-porting Midlanders"), + + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.Indefinite, (int)GermanCases.Nominative, "ein Met schleppender Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.Indefinite, (int)GermanCases.Genitive, "eines Met schleppenden Wiesländers"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.Indefinite, (int)GermanCases.Dative, "einem Met schleppenden Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.Indefinite, (int)GermanCases.Accusative, "einen Met schleppenden Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.Definite, (int)GermanCases.Nominative, "der Met schleppender Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.Definite, (int)GermanCases.Genitive, "des Met schleppenden Wiesländers"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.Definite, (int)GermanCases.Dative, "dem Met schleppenden Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.Definite, (int)GermanCases.Accusative, "den Met schleppenden Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.Possessive, (int)GermanCases.Nominative, "dein Met schleppende Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.Possessive, (int)GermanCases.Genitive, "deines Met schleppenden Wiesländers"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.Possessive, (int)GermanCases.Dative, "deinem Met schleppenden Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.Possessive, (int)GermanCases.Accusative, "deinen Met schleppenden Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.Negative, (int)GermanCases.Nominative, "kein Met schleppender Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.Negative, (int)GermanCases.Genitive, "keines Met schleppenden Wiesländers"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.Negative, (int)GermanCases.Dative, "keinem Met schleppenden Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.Negative, (int)GermanCases.Accusative, "keinen Met schleppenden Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.ZeroArticle, (int)GermanCases.Nominative, "Met schleppender Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.ZeroArticle, (int)GermanCases.Genitive, "Met schleppenden Wiesländers"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.ZeroArticle, (int)GermanCases.Dative, "Met schleppendem Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.ZeroArticle, (int)GermanCases.Accusative, "Met schleppenden Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.Demonstrative, (int)GermanCases.Nominative, "dieser Met schleppende Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.Demonstrative, (int)GermanCases.Genitive, "dieses Met schleppenden Wiesländers"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.Demonstrative, (int)GermanCases.Dative, "diesem Met schleppenden Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 1, (int)GermanArticleType.Demonstrative, (int)GermanCases.Accusative, "diesen Met schleppenden Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.Indefinite, (int)GermanCases.Nominative, "2 Met schleppenden Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.Indefinite, (int)GermanCases.Genitive, "2 Met schleppenden Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.Indefinite, (int)GermanCases.Dative, "2 Met schleppenden Wiesländern"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.Indefinite, (int)GermanCases.Accusative, "2 Met schleppenden Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.Definite, (int)GermanCases.Nominative, "die Met schleppende Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.Definite, (int)GermanCases.Genitive, "der Met schleppender Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.Definite, (int)GermanCases.Dative, "den Met schleppenden Wiesländern"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.Definite, (int)GermanCases.Accusative, "die Met schleppende Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.Possessive, (int)GermanCases.Nominative, "deine Met schleppenden Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.Possessive, (int)GermanCases.Genitive, "deiner Met schleppenden Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.Possessive, (int)GermanCases.Dative, "deinen Met schleppenden Wiesländern"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.Possessive, (int)GermanCases.Accusative, "deine Met schleppenden Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.Negative, (int)GermanCases.Nominative, "keine Met schleppenden Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.Negative, (int)GermanCases.Genitive, "keiner Met schleppenden Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.Negative, (int)GermanCases.Dative, "keinen Met schleppenden Wiesländern"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.Negative, (int)GermanCases.Accusative, "keine Met schleppenden Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.ZeroArticle, (int)GermanCases.Nominative, "Met schleppende Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.ZeroArticle, (int)GermanCases.Genitive, "Met schleppender Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.ZeroArticle, (int)GermanCases.Dative, "Met schleppenden Wiesländern"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.ZeroArticle, (int)GermanCases.Accusative, "Met schleppende Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.Demonstrative, (int)GermanCases.Nominative, "diese Met schleppenden Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.Demonstrative, (int)GermanCases.Genitive, "dieser Met schleppenden Wiesländer"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.Demonstrative, (int)GermanCases.Dative, "diesen Met schleppenden Wiesländern"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.German, 2, (int)GermanArticleType.Demonstrative, (int)GermanCases.Accusative, "diese Met schleppenden Wiesländer"), + + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.French, 1, (int)FrenchArticleType.Indefinite, 1, "un livreur d'hydromel"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.French, 1, (int)FrenchArticleType.Definite, 1, "le livreur d'hydromel"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.French, 1, (int)FrenchArticleType.PossessiveFirstPerson, 1, "mon livreur d'hydromel"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.French, 1, (int)FrenchArticleType.PossessiveSecondPerson, 1, "ton livreur d'hydromel"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.French, 1, (int)FrenchArticleType.PossessiveThirdPerson, 1, "son livreur d'hydromel"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.French, 2, (int)FrenchArticleType.Indefinite, 1, "des livreurs d'hydromel"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.French, 2, (int)FrenchArticleType.Definite, 1, "les livreurs d'hydromel"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.French, 2, (int)FrenchArticleType.PossessiveFirstPerson, 1, "mes livreurs d'hydromel"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.French, 2, (int)FrenchArticleType.PossessiveSecondPerson, 1, "tes livreurs d'hydromel"), + new(nameof(LSheets.BNpcName), 1330, ClientLanguage.French, 2, (int)FrenchArticleType.PossessiveThirdPerson, 1, "ses livreurs d'hydromel"), + + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.Japanese, 1, (int)JapaneseArticleType.NearListener, 1, "その酔いどれのネル"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.Japanese, 1, (int)JapaneseArticleType.Distant, 1, "酔いどれのネル"), + + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.English, 1, (int)EnglishArticleType.Indefinite, 1, "Nell Half-full"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.English, 1, (int)EnglishArticleType.Definite, 1, "Nell Half-full"), + + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.Indefinite, (int)GermanCases.Nominative, "Nell die Beschwipste"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.Indefinite, (int)GermanCases.Genitive, "Nell der Beschwipsten"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.Indefinite, (int)GermanCases.Dative, "Nell der Beschwipsten"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.Indefinite, (int)GermanCases.Accusative, "Nell die Beschwipste"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.Definite, (int)GermanCases.Nominative, "Nell die Beschwipste"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.Definite, (int)GermanCases.Genitive, "Nell der Beschwipsten"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.Definite, (int)GermanCases.Dative, "Nell der Beschwipsten"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.Definite, (int)GermanCases.Accusative, "Nell die Beschwipste"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.Possessive, (int)GermanCases.Nominative, "Nell die Beschwipste"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.Possessive, (int)GermanCases.Genitive, "Nell der Beschwipsten"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.Possessive, (int)GermanCases.Dative, "Nell der Beschwipsten"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.Possessive, (int)GermanCases.Accusative, "Nell die Beschwipste"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.Negative, (int)GermanCases.Nominative, "Nell die Beschwipste"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.Negative, (int)GermanCases.Genitive, "Nell der Beschwipsten"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.Negative, (int)GermanCases.Dative, "Nell der Beschwipsten"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.Negative, (int)GermanCases.Accusative, "Nell die Beschwipste"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.ZeroArticle, (int)GermanCases.Nominative, "Nell die Beschwipste"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.ZeroArticle, (int)GermanCases.Genitive, "Nell der Beschwipsten"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.ZeroArticle, (int)GermanCases.Dative, "Nell der Beschwipsten"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.ZeroArticle, (int)GermanCases.Accusative, "Nell die Beschwipste"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.Demonstrative, (int)GermanCases.Nominative, "Nell die Beschwipste"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.Demonstrative, (int)GermanCases.Genitive, "Nell der Beschwipsten"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.Demonstrative, (int)GermanCases.Dative, "Nell der Beschwipsten"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.German, 1, (int)GermanArticleType.Demonstrative, (int)GermanCases.Accusative, "Nell die Beschwipste"), + + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.French, 1, (int)FrenchArticleType.Indefinite, 1, "Nell la Boit-sans-soif"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.French, 1, (int)FrenchArticleType.Definite, 1, "Nell la Boit-sans-soif"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.French, 1, (int)FrenchArticleType.PossessiveFirstPerson, 1, "ma Nell la Boit-sans-soif"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.French, 1, (int)FrenchArticleType.PossessiveSecondPerson, 1, "ta Nell la Boit-sans-soif"), + new(nameof(LSheets.ENpcResident), 1031947, ClientLanguage.French, 1, (int)FrenchArticleType.PossessiveThirdPerson, 1, "sa Nell la Boit-sans-soif"), + + new(nameof(LSheets.Item), 44348, ClientLanguage.Japanese, 1, (int)JapaneseArticleType.NearListener, 1, "その希少トームストーン:幻想"), + new(nameof(LSheets.Item), 44348, ClientLanguage.Japanese, 1, (int)JapaneseArticleType.Distant, 1, "希少トームストーン:幻想"), + new(nameof(LSheets.Item), 44348, ClientLanguage.Japanese, 2, (int)JapaneseArticleType.NearListener, 1, "それらの希少トームストーン:幻想"), + new(nameof(LSheets.Item), 44348, ClientLanguage.Japanese, 2, (int)JapaneseArticleType.Distant, 1, "あれらの希少トームストーン:幻想"), + + new(nameof(LSheets.Item), 44348, ClientLanguage.English, 1, (int)EnglishArticleType.Indefinite, 1, "an irregular tomestone of phantasmagoria"), + new(nameof(LSheets.Item), 44348, ClientLanguage.English, 1, (int)EnglishArticleType.Definite, 1, "the irregular tomestone of phantasmagoria"), + new(nameof(LSheets.Item), 44348, ClientLanguage.English, 2, (int)EnglishArticleType.Indefinite, 1, "irregular tomestones of phantasmagoria"), + new(nameof(LSheets.Item), 44348, ClientLanguage.English, 2, (int)EnglishArticleType.Definite, 1, "irregular tomestones of phantasmagoria"), + + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.Indefinite, (int)GermanCases.Nominative, "ein ungewöhnlicher Allagischer Stein der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.Indefinite, (int)GermanCases.Genitive, "eines ungewöhnlichen Allagischen Steins der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.Indefinite, (int)GermanCases.Dative, "einem ungewöhnlichen Allagischen Stein der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.Indefinite, (int)GermanCases.Accusative, "einen ungewöhnlichen Allagischen Stein der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.Definite, (int)GermanCases.Nominative, "der ungewöhnlicher Allagischer Stein der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.Definite, (int)GermanCases.Genitive, "des ungewöhnlichen Allagischen Steins der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.Definite, (int)GermanCases.Dative, "dem ungewöhnlichen Allagischen Stein der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.Definite, (int)GermanCases.Accusative, "den ungewöhnlichen Allagischen Stein der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.Possessive, (int)GermanCases.Nominative, "dein ungewöhnliche Allagische Stein der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.Possessive, (int)GermanCases.Genitive, "deines ungewöhnlichen Allagischen Steins der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.Possessive, (int)GermanCases.Dative, "deinem ungewöhnlichen Allagischen Stein der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.Possessive, (int)GermanCases.Accusative, "deinen ungewöhnlichen Allagischen Stein der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.Negative, (int)GermanCases.Nominative, "kein ungewöhnlicher Allagischer Stein der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.Negative, (int)GermanCases.Genitive, "keines ungewöhnlichen Allagischen Steins der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.Negative, (int)GermanCases.Dative, "keinem ungewöhnlichen Allagischen Stein der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.Negative, (int)GermanCases.Accusative, "keinen ungewöhnlichen Allagischen Stein der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.ZeroArticle, (int)GermanCases.Nominative, "ungewöhnlicher Allagischer Stein der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.ZeroArticle, (int)GermanCases.Genitive, "ungewöhnlichen Allagischen Steins der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.ZeroArticle, (int)GermanCases.Dative, "ungewöhnlichem Allagischem Stein der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.ZeroArticle, (int)GermanCases.Accusative, "ungewöhnlichen Allagischen Stein der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.Demonstrative, (int)GermanCases.Nominative, "dieser ungewöhnliche Allagische Stein der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.Demonstrative, (int)GermanCases.Genitive, "dieses ungewöhnlichen Allagischen Steins der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.Demonstrative, (int)GermanCases.Dative, "diesem ungewöhnlichen Allagischen Stein der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 1, (int)GermanArticleType.Demonstrative, (int)GermanCases.Accusative, "diesen ungewöhnlichen Allagischen Stein der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.Indefinite, (int)GermanCases.Nominative, "2 ungewöhnlichen Allagischen Steine der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.Indefinite, (int)GermanCases.Genitive, "2 ungewöhnlichen Allagischen Steine der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.Indefinite, (int)GermanCases.Dative, "2 ungewöhnlichen Allagischen Steinen der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.Indefinite, (int)GermanCases.Accusative, "2 ungewöhnlichen Allagischen Steine der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.Definite, (int)GermanCases.Nominative, "die ungewöhnliche Allagische Steine der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.Definite, (int)GermanCases.Genitive, "der ungewöhnlicher Allagischer Steine der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.Definite, (int)GermanCases.Dative, "den ungewöhnlichen Allagischen Steinen der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.Definite, (int)GermanCases.Accusative, "die ungewöhnliche Allagische Steine der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.Possessive, (int)GermanCases.Nominative, "deine ungewöhnlichen Allagischen Steine der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.Possessive, (int)GermanCases.Genitive, "deiner ungewöhnlichen Allagischen Steine der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.Possessive, (int)GermanCases.Dative, "deinen ungewöhnlichen Allagischen Steinen der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.Possessive, (int)GermanCases.Accusative, "deine ungewöhnlichen Allagischen Steine der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.Negative, (int)GermanCases.Nominative, "keine ungewöhnlichen Allagischen Steine der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.Negative, (int)GermanCases.Genitive, "keiner ungewöhnlichen Allagischen Steine der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.Negative, (int)GermanCases.Dative, "keinen ungewöhnlichen Allagischen Steinen der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.Negative, (int)GermanCases.Accusative, "keine ungewöhnlichen Allagischen Steine der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.ZeroArticle, (int)GermanCases.Nominative, "ungewöhnliche Allagische Steine der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.ZeroArticle, (int)GermanCases.Genitive, "ungewöhnlicher Allagischer Steine der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.ZeroArticle, (int)GermanCases.Dative, "ungewöhnlichen Allagischen Steinen der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.ZeroArticle, (int)GermanCases.Accusative, "ungewöhnliche Allagische Steine der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.Demonstrative, (int)GermanCases.Nominative, "diese ungewöhnlichen Allagischen Steine der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.Demonstrative, (int)GermanCases.Genitive, "dieser ungewöhnlichen Allagischen Steine der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.Demonstrative, (int)GermanCases.Dative, "diesen ungewöhnlichen Allagischen Steinen der Phantasmagorie"), + new(nameof(LSheets.Item), 44348, ClientLanguage.German, 2, (int)GermanArticleType.Demonstrative, (int)GermanCases.Accusative, "diese ungewöhnlichen Allagischen Steine der Phantasmagorie"), + + new(nameof(LSheets.Item), 44348, ClientLanguage.French, 1, (int)FrenchArticleType.Indefinite, 1, "un mémoquartz inhabituel fantasmagorique"), + new(nameof(LSheets.Item), 44348, ClientLanguage.French, 1, (int)FrenchArticleType.Definite, 1, "le mémoquartz inhabituel fantasmagorique"), + new(nameof(LSheets.Item), 44348, ClientLanguage.French, 1, (int)FrenchArticleType.PossessiveFirstPerson, 1, "mon mémoquartz inhabituel fantasmagorique"), + new(nameof(LSheets.Item), 44348, ClientLanguage.French, 1, (int)FrenchArticleType.PossessiveSecondPerson, 1, "ton mémoquartz inhabituel fantasmagorique"), + new(nameof(LSheets.Item), 44348, ClientLanguage.French, 1, (int)FrenchArticleType.PossessiveThirdPerson, 1, "son mémoquartz inhabituel fantasmagorique"), + new(nameof(LSheets.Item), 44348, ClientLanguage.French, 2, (int)FrenchArticleType.Indefinite, 1, "des mémoquartz inhabituels fantasmagoriques"), + new(nameof(LSheets.Item), 44348, ClientLanguage.French, 2, (int)FrenchArticleType.Definite, 1, "les mémoquartz inhabituels fantasmagoriques"), + new(nameof(LSheets.Item), 44348, ClientLanguage.French, 2, (int)FrenchArticleType.PossessiveFirstPerson, 1, "mes mémoquartz inhabituels fantasmagoriques"), + new(nameof(LSheets.Item), 44348, ClientLanguage.French, 2, (int)FrenchArticleType.PossessiveSecondPerson, 1, "tes mémoquartz inhabituels fantasmagoriques"), + new(nameof(LSheets.Item), 44348, ClientLanguage.French, 2, (int)FrenchArticleType.PossessiveThirdPerson, 1, "ses mémoquartz inhabituels fantasmagoriques"), + + new(nameof(LSheets.Action), 45, ClientLanguage.German, 1, (int)FrenchArticleType.Indefinite, 1, "Blumenflüsterer IV"), + ]; + + private enum GermanCases + { + Nominative, + Genitive, + Dative, + Accusative, + } + + /// + public string Name => "Test NounProcessor"; + + /// + public unsafe SelfTestStepResult RunStep() + { + var nounProcessor = Service.Get(); + + for (var i = 0; i < this.tests.Length; i++) + { + var e = this.tests[i]; + + var nounParams = new NounParams() + { + SheetName = e.SheetName, + RowId = e.RowId, + Language = e.Language, + Quantity = e.Quantity, + ArticleType = e.ArticleType, + GrammaticalCase = e.GrammaticalCase, + }; + var output = nounProcessor.ProcessNoun(nounParams); + + if (e.ExpectedResult != output) + { + ImGui.TextUnformatted($"Mismatch detected (Test #{i}):"); + ImGui.TextUnformatted($"Got: {output}"); + ImGui.TextUnformatted($"Expected: {e.ExpectedResult}"); + + if (ImGui.Button("Continue")) + return SelfTestStepResult.Fail; + + return SelfTestStepResult.Waiting; + } + } + + return SelfTestStepResult.Pass; + } + + /// + public void CleanUp() + { + // ignored + } + + private record struct NounTestEntry( + string SheetName, + uint RowId, + ClientLanguage Language, + int Quantity, + int ArticleType, + int GrammaticalCase, + string ExpectedResult); +} diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/SeStringEvaluatorAgingStep.cs b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/SeStringEvaluatorAgingStep.cs new file mode 100644 index 000000000..3a0a7d546 --- /dev/null +++ b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/SeStringEvaluatorAgingStep.cs @@ -0,0 +1,92 @@ +using Dalamud.Game.ClientState; +using Dalamud.Game.Text.Evaluator; + +using ImGuiNET; + +using Lumina.Text.ReadOnly; + +namespace Dalamud.Interface.Internal.Windows.SelfTest.AgingSteps; + +/// +/// Test setup for SeStringEvaluator. +/// +internal class SeStringEvaluatorAgingStep : IAgingStep +{ + private int step = 0; + + /// + public string Name => "Test SeStringEvaluator"; + + /// + public SelfTestStepResult RunStep() + { + var seStringEvaluator = Service.Get(); + + switch (this.step) + { + case 0: + ImGui.TextUnformatted("Is this the current time, and is it ticking?"); + + // This checks that EvaluateFromAddon fetches the correct Addon row, + // that MacroDecoder.GetMacroTime()->SetTime() has been called + // and that local and global parameters have been read correctly. + + ImGui.TextUnformatted(seStringEvaluator.EvaluateFromAddon(31, [(uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds()]).ExtractText()); + + if (ImGui.Button("Yes")) + this.step++; + + ImGui.SameLine(); + + if (ImGui.Button("No")) + return SelfTestStepResult.Fail; + + break; + + case 1: + ImGui.TextUnformatted("Checking pcname macro using the local player name..."); + + // This makes sure that NameCache.Instance()->TryGetCharacterInfoByEntityId() has been called, + // that it returned the local players name by using its EntityId, + // and that it didn't include the world name by checking the HomeWorldId against AgentLobby.Instance()->LobbyData.HomeWorldId. + + var clientState = Service.Get(); + var localPlayer = clientState.LocalPlayer; + if (localPlayer is null) + { + ImGui.TextUnformatted("You need to be logged in for this step."); + + if (ImGui.Button("Skip")) + return SelfTestStepResult.NotRan; + + return SelfTestStepResult.Waiting; + } + + var evaluatedPlayerName = seStringEvaluator.Evaluate(ReadOnlySeString.FromMacroString(""), [localPlayer.EntityId]).ExtractText(); + var localPlayerName = localPlayer.Name.TextValue; + + if (evaluatedPlayerName != localPlayerName) + { + ImGui.TextUnformatted("The player name doesn't match:"); + ImGui.TextUnformatted($"Evaluated Player Name (got): {evaluatedPlayerName}"); + ImGui.TextUnformatted($"Local Player Name (expected): {localPlayerName}"); + + if (ImGui.Button("Continue")) + return SelfTestStepResult.Fail; + + return SelfTestStepResult.Waiting; + } + + return SelfTestStepResult.Pass; + } + + return SelfTestStepResult.Waiting; + } + + /// + public void CleanUp() + { + // ignored + this.step = 0; + } +} diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/SheetRedirectResolverAgingStep.cs b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/SheetRedirectResolverAgingStep.cs new file mode 100644 index 000000000..0c9dc763f --- /dev/null +++ b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/SheetRedirectResolverAgingStep.cs @@ -0,0 +1,130 @@ +using System.Runtime.InteropServices; + +using Dalamud.Game; +using Dalamud.Game.Text.Evaluator.Internal; + +using FFXIVClientStructs.FFXIV.Client.System.String; +using FFXIVClientStructs.FFXIV.Client.UI.Misc; + +using ImGuiNET; + +namespace Dalamud.Interface.Internal.Windows.SelfTest.AgingSteps; + +/// +/// Test setup for SheetRedirectResolver. +/// +internal class SheetRedirectResolverAgingStep : IAgingStep +{ + private RedirectEntry[] redirects = + [ + new("Item", 10, SheetRedirectFlags.Item), + new("ItemHQ", 10, SheetRedirectFlags.Item | SheetRedirectFlags.HighQuality), + new("ItemMP", 10, SheetRedirectFlags.Item | SheetRedirectFlags.Collectible), + new("Item", 35588, SheetRedirectFlags.Item), + new("Item", 1035588, SheetRedirectFlags.Item | SheetRedirectFlags.HighQuality), + new("Item", 2000217, SheetRedirectFlags.Item | SheetRedirectFlags.EventItem), + new("ActStr", 10, SheetRedirectFlags.Action), // Trait + new("ActStr", 1000010, SheetRedirectFlags.Action | SheetRedirectFlags.ActionSheet), // Action + new("ActStr", 2000010, SheetRedirectFlags.Action), // Item + new("ActStr", 3000010, SheetRedirectFlags.Action), // EventItem + new("ActStr", 4000010, SheetRedirectFlags.Action), // EventAction + new("ActStr", 5000010, SheetRedirectFlags.Action), // GeneralAction + new("ActStr", 6000010, SheetRedirectFlags.Action), // BuddyAction + new("ActStr", 7000010, SheetRedirectFlags.Action), // MainCommand + new("ActStr", 8000010, SheetRedirectFlags.Action), // Companion + new("ActStr", 9000010, SheetRedirectFlags.Action), // CraftAction + new("ActStr", 10000010, SheetRedirectFlags.Action | SheetRedirectFlags.ActionSheet), // Action + new("ActStr", 11000010, SheetRedirectFlags.Action), // PetAction + new("ActStr", 12000010, SheetRedirectFlags.Action), // CompanyAction + new("ActStr", 13000010, SheetRedirectFlags.Action), // Mount + // new("ActStr", 14000010, RedirectFlags.Action), + // new("ActStr", 15000010, RedirectFlags.Action), + // new("ActStr", 16000010, RedirectFlags.Action), + // new("ActStr", 17000010, RedirectFlags.Action), + // new("ActStr", 18000010, RedirectFlags.Action), + new("ActStr", 19000010, SheetRedirectFlags.Action), // BgcArmyAction + new("ActStr", 20000010, SheetRedirectFlags.Action), // Ornament + new("ObjStr", 10), // BNpcName + new("ObjStr", 1000010), // ENpcResident + new("ObjStr", 2000010), // Treasure + new("ObjStr", 3000010), // Aetheryte + new("ObjStr", 4000010), // GatheringPointName + new("ObjStr", 5000010), // EObjName + new("ObjStr", 6000010), // Mount + new("ObjStr", 7000010), // Companion + // new("ObjStr", 8000010), + // new("ObjStr", 9000010), + new("ObjStr", 10000010), // Item + new("EObj", 2003479), // EObj => EObjName + new("Treasure", 1473), // Treasure (without name, falls back to rowId 0) + new("Treasure", 1474), // Treasure (with name) + new("WeatherPlaceName", 0), + new("WeatherPlaceName", 28), + new("WeatherPlaceName", 40), + new("WeatherPlaceName", 52), + new("WeatherPlaceName", 2300), + ]; + + private unsafe delegate SheetRedirectFlags ResolveSheetRedirect(RaptureTextModule* thisPtr, Utf8String* sheetName, uint* rowId, uint* flags); + + /// + public string Name => "Test SheetRedirectResolver"; + + /// + public unsafe SelfTestStepResult RunStep() + { + // Client::UI::Misc::RaptureTextModule_ResolveSheetRedirect + if (!Service.Get().TryScanText("E8 ?? ?? ?? ?? 44 8B E8 A8 10", out var addr)) + return SelfTestStepResult.Fail; + + var sheetRedirectResolver = Service.Get(); + var resolveSheetRedirect = Marshal.GetDelegateForFunctionPointer(addr); + var utf8SheetName = Utf8String.CreateEmpty(); + + try + { + for (var i = 0; i < this.redirects.Length; i++) + { + var redirect = this.redirects[i]; + + utf8SheetName->SetString(redirect.SheetName); + + var rowId1 = redirect.RowId; + uint colIndex1 = ushort.MaxValue; + var flags1 = resolveSheetRedirect(RaptureTextModule.Instance(), utf8SheetName, &rowId1, &colIndex1); + + var sheetName2 = redirect.SheetName; + var rowId2 = redirect.RowId; + uint colIndex2 = ushort.MaxValue; + var flags2 = sheetRedirectResolver.Resolve(ref sheetName2, ref rowId2, ref colIndex2); + + if (utf8SheetName->ToString() != sheetName2 || rowId1 != rowId2 || colIndex1 != colIndex2 || flags1 != flags2) + { + ImGui.TextUnformatted($"Mismatch detected (Test #{i}):"); + ImGui.TextUnformatted($"Input: {redirect.SheetName}#{redirect.RowId}"); + ImGui.TextUnformatted($"Game: {utf8SheetName->ToString()}#{rowId1}-{colIndex1} ({flags1})"); + ImGui.TextUnformatted($"Evaluated: {sheetName2}#{rowId2}-{colIndex2} ({flags2})"); + + if (ImGui.Button("Continue")) + return SelfTestStepResult.Fail; + + return SelfTestStepResult.Waiting; + } + } + + return SelfTestStepResult.Pass; + } + finally + { + utf8SheetName->Dtor(true); + } + } + + /// + public void CleanUp() + { + // ignored + } + + private record struct RedirectEntry(string SheetName, uint RowId, SheetRedirectFlags Flags = SheetRedirectFlags.None); +} diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/SelfTestWindow.cs b/Dalamud/Interface/Internal/Windows/SelfTest/SelfTestWindow.cs index 3b3670228..1be6f31a3 100644 --- a/Dalamud/Interface/Internal/Windows/SelfTest/SelfTestWindow.cs +++ b/Dalamud/Interface/Internal/Windows/SelfTest/SelfTestWindow.cs @@ -50,6 +50,9 @@ internal class SelfTestWindow : Window new DutyStateAgingStep(), new GameConfigAgingStep(), new MarketBoardAgingStep(), + new SheetRedirectResolverAgingStep(), + new NounProcessorAgingStep(), + new SeStringEvaluatorAgingStep(), new LogoutEventAgingStep(), }; diff --git a/Dalamud/Interface/Utility/ImGuiHelpers.cs b/Dalamud/Interface/Utility/ImGuiHelpers.cs index 2093d9bcb..d9056fec4 100644 --- a/Dalamud/Interface/Utility/ImGuiHelpers.cs +++ b/Dalamud/Interface/Utility/ImGuiHelpers.cs @@ -210,8 +210,6 @@ public static class ImGuiHelpers /// ImGui ID, if link functionality is desired. /// Button flags to use on link interaction. /// Interaction result of the rendered text. - /// This function is experimental. Report any issues to GitHub issues or to Discord #dalamud-dev channel. - /// The function definition is stable; only in the next API version a function may be removed. public static SeStringDrawResult SeStringWrapped( ReadOnlySpan sss, scoped in SeStringDrawParams style = default, @@ -226,8 +224,6 @@ public static class ImGuiHelpers /// ImGui ID, if link functionality is desired. /// Button flags to use on link interaction. /// Interaction result of the rendered text. - /// This function is experimental. Report any issues to GitHub issues or to Discord #dalamud-dev channel. - /// The function definition is stable; only in the next API version a function may be removed. public static SeStringDrawResult CompileSeStringWrapped( string text, scoped in SeStringDrawParams style = default, diff --git a/Dalamud/Plugin/Services/ISeStringEvaluator.cs b/Dalamud/Plugin/Services/ISeStringEvaluator.cs new file mode 100644 index 000000000..2bd423b7c --- /dev/null +++ b/Dalamud/Plugin/Services/ISeStringEvaluator.cs @@ -0,0 +1,79 @@ +using System.Diagnostics.CodeAnalysis; + +using Dalamud.Game; +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Game.Text.Evaluator; + +using Lumina.Text.ReadOnly; + +namespace Dalamud.Plugin.Services; + +/// +/// Defines a service for retrieving localized text for various in-game entities. +/// +[Experimental("SeStringEvaluator")] +public interface ISeStringEvaluator +{ + /// + /// Evaluates macros in a . + /// + /// The string containing macros. + /// An optional list of local parameters. + /// An optional language override. + /// An evaluated . + ReadOnlySeString Evaluate(ReadOnlySeString str, Span localParameters = default, ClientLanguage? language = null); + + /// + /// Evaluates macros in a . + /// + /// The string containing macros. + /// An optional list of local parameters. + /// An optional language override. + /// An evaluated . + ReadOnlySeString Evaluate(ReadOnlySeStringSpan str, Span localParameters = default, ClientLanguage? language = null); + + /// + /// Evaluates macros in text from the Addon sheet. + /// + /// The row id of the Addon sheet. + /// An optional list of local parameters. + /// An optional language override. + /// An evaluated . + ReadOnlySeString EvaluateFromAddon(uint addonId, Span localParameters = default, ClientLanguage? language = null); + + /// + /// Evaluates macros in text from the Lobby sheet. + /// + /// The row id of the Lobby sheet. + /// An optional list of local parameters. + /// An optional language override. + /// An evaluated . + ReadOnlySeString EvaluateFromLobby(uint lobbyId, Span localParameters = default, ClientLanguage? language = null); + + /// + /// Evaluates macros in text from the LogMessage sheet. + /// + /// The row id of the LogMessage sheet. + /// An optional list of local parameters. + /// An optional language override. + /// An evaluated . + ReadOnlySeString EvaluateFromLogMessage(uint logMessageId, Span localParameters = default, ClientLanguage? language = null); + + /// + /// Evaluates ActStr from the given ActionKind and id. + /// + /// The ActionKind. + /// The action id. + /// An optional language override. + /// The name of the action. + string EvaluateActStr(ActionKind actionKind, uint id, ClientLanguage? language = null); + + /// + /// Evaluates ObjStr from the given ObjectKind and id. + /// + /// The ObjectKind. + /// The object id. + /// An optional language override. + /// The singular name of the object. + string EvaluateObjStr(ObjectKind objectKind, uint id, ClientLanguage? language = null); +} diff --git a/Dalamud/Utility/ActionKindExtensions.cs b/Dalamud/Utility/ActionKindExtensions.cs new file mode 100644 index 000000000..21026bc31 --- /dev/null +++ b/Dalamud/Utility/ActionKindExtensions.cs @@ -0,0 +1,26 @@ +using Dalamud.Game; + +namespace Dalamud.Utility; + +/// +/// Extension methods for the enum. +/// +public static class ActionKindExtensions +{ + /// + /// Converts the id of an ActionKind to the id used in the ActStr sheet redirect. + /// + /// The ActionKind this id is for. + /// The id. + /// An id that can be used in the ActStr sheet redirect. + public static uint GetActStrId(this ActionKind actionKind, uint id) + { + // See "83 F9 0D 76 0B" + var idx = (uint)actionKind; + + if (idx is <= 13 or 19 or 20) + return id + (1000000 * idx); + + return 0; + } +} diff --git a/Dalamud/Utility/ClientLanguageExtensions.cs b/Dalamud/Utility/ClientLanguageExtensions.cs index 69c39c9b8..47f0a2082 100644 --- a/Dalamud/Utility/ClientLanguageExtensions.cs +++ b/Dalamud/Utility/ClientLanguageExtensions.cs @@ -23,4 +23,40 @@ public static class ClientLanguageExtensions _ => throw new ArgumentOutOfRangeException(nameof(language)), }; } + + /// + /// Gets the language code from a ClientLanguage. + /// + /// The ClientLanguage to convert. + /// The language code (ja, en, de, fr). + /// An exception that is thrown when no valid ClientLanguage was given. + public static string ToCode(this ClientLanguage value) + { + return value switch + { + ClientLanguage.Japanese => "ja", + ClientLanguage.English => "en", + ClientLanguage.German => "de", + ClientLanguage.French => "fr", + _ => throw new ArgumentOutOfRangeException(nameof(value)), + }; + } + + /// + /// Gets the ClientLanguage from a language code. + /// + /// The language code to convert (ja, en, de, fr). + /// The ClientLanguage. + /// An exception that is thrown when no valid language code was given. + public static ClientLanguage ToClientLanguage(this string value) + { + return value switch + { + "ja" => ClientLanguage.Japanese, + "en" => ClientLanguage.English, + "de" => ClientLanguage.German, + "fr" => ClientLanguage.French, + _ => throw new ArgumentOutOfRangeException(nameof(value)), + }; + } } diff --git a/Dalamud/Utility/ItemUtil.cs b/Dalamud/Utility/ItemUtil.cs new file mode 100644 index 000000000..de1e5a721 --- /dev/null +++ b/Dalamud/Utility/ItemUtil.cs @@ -0,0 +1,159 @@ +using System.Runtime.CompilerServices; + +using Dalamud.Data; +using Dalamud.Game; +using Dalamud.Game.Text.SeStringHandling.Payloads; +using Dalamud.Game.Text; + +using Lumina.Excel.Sheets; +using Lumina.Text.ReadOnly; + +using static Dalamud.Game.Text.SeStringHandling.Payloads.ItemPayload; +using Lumina.Text; + +namespace Dalamud.Utility; + +/// +/// Utilities related to Items. +/// +internal static class ItemUtil +{ + private static int? eventItemRowCount; + + /// Converts raw item ID to item ID with its classification. + /// Raw item ID. + /// Item ID and its classification. + internal static (uint ItemId, ItemKind Kind) GetBaseId(uint rawItemId) + { + if (IsEventItem(rawItemId)) return (rawItemId, ItemKind.EventItem); // EventItem IDs are NOT adjusted + if (IsHighQuality(rawItemId)) return (rawItemId - 1_000_000, ItemKind.Hq); + if (IsCollectible(rawItemId)) return (rawItemId - 500_000, ItemKind.Collectible); + return (rawItemId, ItemKind.Normal); + } + + /// Converts item ID with its classification to raw item ID. + /// Item ID. + /// Item classification. + /// Raw Item ID. + internal static uint GetRawId(uint itemId, ItemKind kind) + { + return kind switch + { + ItemKind.Collectible when itemId < 500_000 => itemId + 500_000, + ItemKind.Hq when itemId < 1_000_000 => itemId + 1_000_000, + ItemKind.EventItem => itemId, // EventItem IDs are not adjusted + _ => itemId, + }; + } + + /// + /// Checks if the item id belongs to a normal item. + /// + /// The item id to check. + /// true when the item id belongs to a normal item. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool IsNormalItem(uint itemId) + { + return itemId < 500_000; + } + + /// + /// Checks if the item id belongs to a collectible item. + /// + /// The item id to check. + /// true when the item id belongs to a collectible item. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool IsCollectible(uint itemId) + { + return itemId is >= 500_000 and < 1_000_000; + } + + /// + /// Checks if the item id belongs to a high quality item. + /// + /// The item id to check. + /// true when the item id belongs to a high quality item. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool IsHighQuality(uint itemId) + { + return itemId is >= 1_000_000 and < 2_000_000; + } + + /// + /// Checks if the item id belongs to an event item. + /// + /// The item id to check. + /// true when the item id belongs to an event item. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool IsEventItem(uint itemId) + { + return itemId >= 2_000_000 && itemId - 2_000_000 < (eventItemRowCount ??= Service.Get().GetExcelSheet().Count); + } + + /// + /// Gets the name of an item. + /// + /// The raw item id. + /// Whether to include the High Quality or Collectible icon. + /// An optional client language override. + /// The item name. + internal static ReadOnlySeString GetItemName(uint itemId, bool includeIcon = true, ClientLanguage? language = null) + { + var dataManager = Service.Get(); + + if (IsEventItem(itemId)) + { + return dataManager + .GetExcelSheet(language) + .TryGetRow(itemId, out var eventItem) + ? eventItem.Name + : default; + } + + var (baseId, kind) = GetBaseId(itemId); + + if (!dataManager + .GetExcelSheet(language) + .TryGetRow(baseId, out var item)) + { + return default; + } + + if (!includeIcon || kind is not (ItemKind.Hq or ItemKind.Collectible)) + return item.Name; + + var builder = SeStringBuilder.SharedPool.Get(); + + builder.Append(item.Name); + + switch (kind) + { + case ItemPayload.ItemKind.Hq: + builder.Append($" {(char)SeIconChar.HighQuality}"); + break; + case ItemPayload.ItemKind.Collectible: + builder.Append($" {(char)SeIconChar.Collectible}"); + break; + } + + var itemName = builder.ToReadOnlySeString(); + SeStringBuilder.SharedPool.Return(builder); + return itemName; + } + + /// + /// Gets the color row id for an item name. + /// + /// The raw item Id. + /// Wheather this color is used as edge color. + /// The Color row id. + internal static uint GetItemRarityColorType(uint itemId, bool isEdgeColor = false) + { + var rarity = 1u; + + if (!IsEventItem(itemId) && Service.Get().GetExcelSheet().TryGetRow(GetBaseId(itemId).ItemId, out var item)) + rarity = item.Rarity; + + return (isEdgeColor ? 548u : 547u) + (rarity * 2u); + } +} diff --git a/Dalamud/Utility/ObjectKindExtensions.cs b/Dalamud/Utility/ObjectKindExtensions.cs new file mode 100644 index 000000000..5d42dc760 --- /dev/null +++ b/Dalamud/Utility/ObjectKindExtensions.cs @@ -0,0 +1,33 @@ +using Dalamud.Game.ClientState.Objects.Enums; + +namespace Dalamud.Utility; + +/// +/// Extension methods for the enum. +/// +public static class ObjectKindExtensions +{ + /// + /// Converts the id of an ObjectKind to the id used in the ObjStr sheet redirect. + /// + /// The ObjectKind this id is for. + /// The id. + /// An id that can be used in the ObjStr sheet redirect. + public static uint GetObjStrId(this ObjectKind objectKind, uint id) + { + // See "8D 41 FE 83 F8 0C 77 4D" + return objectKind switch + { + ObjectKind.BattleNpc => id < 1000000 ? id : id - 900000, + ObjectKind.EventNpc => id, + ObjectKind.Treasure or + ObjectKind.Aetheryte or + ObjectKind.GatheringPoint or + ObjectKind.Companion or + ObjectKind.Housing => id + (1000000 * (uint)objectKind) - 2000000, + ObjectKind.EventObj => id + (1000000 * (uint)objectKind) - 4000000, + ObjectKind.CardStand => id + 3000000, + _ => 0, + }; + } +} diff --git a/Dalamud/Utility/SeStringExtensions.cs b/Dalamud/Utility/SeStringExtensions.cs index f50f19d8e..b21b9b743 100644 --- a/Dalamud/Utility/SeStringExtensions.cs +++ b/Dalamud/Utility/SeStringExtensions.cs @@ -1,3 +1,5 @@ +using System.Linq; + using Lumina.Text.Parse; using Lumina.Text.ReadOnly; @@ -74,4 +76,154 @@ public static class SeStringExtensions /// character name to validate. /// indicator if character is name is valid. public static bool IsValidCharacterName(this DSeString value) => value.ToString().IsValidCharacterName(); + + /// + /// Determines whether the contains only text payloads. + /// + /// The to check. + /// true if the string contains only text payloads; otherwise, false. + public static bool IsTextOnly(this ReadOnlySeString ross) + { + return ross.AsSpan().IsTextOnly(); + } + + /// + /// Determines whether the contains only text payloads. + /// + /// The to check. + /// true if the span contains only text payloads; otherwise, false. + public static bool IsTextOnly(this ReadOnlySeStringSpan rosss) + { + foreach (var payload in rosss) + { + if (payload.Type != ReadOnlySePayloadType.Text) + return false; + } + + return true; + } + + /// + /// Determines whether the contains the specified text. + /// + /// The to search. + /// The text to find. + /// true if the text is found; otherwise, false. + public static bool ContainsText(this ReadOnlySeString ross, ReadOnlySpan needle) + { + return ross.AsSpan().ContainsText(needle); + } + + /// + /// Determines whether the contains the specified text. + /// + /// The to search. + /// The text to find. + /// true if the text is found; otherwise, false. + public static bool ContainsText(this ReadOnlySeStringSpan rosss, ReadOnlySpan needle) + { + foreach (var payload in rosss) + { + if (payload.Type != ReadOnlySePayloadType.Text) + continue; + + if (payload.Body.IndexOf(needle) != -1) + return true; + } + + return false; + } + + /// + /// Determines whether the contains the specified text. + /// + /// The builder to search. + /// The text to find. + /// true if the text is found; otherwise, false. + public static bool ContainsText(this LSeStringBuilder builder, ReadOnlySpan needle) + { + return builder.ToReadOnlySeString().ContainsText(needle); + } + + /// + /// Replaces occurrences of a specified text in a with another text. + /// + /// The original string. + /// The text to find. + /// The replacement text. + /// A new with the replacements made. + public static ReadOnlySeString ReplaceText( + this ReadOnlySeString ross, + ReadOnlySpan toFind, + ReadOnlySpan replacement) + { + if (ross.IsEmpty) + return ross; + + var sb = LSeStringBuilder.SharedPool.Get(); + + foreach (var payload in ross) + { + if (payload.Type == ReadOnlySePayloadType.Invalid) + continue; + + if (payload.Type != ReadOnlySePayloadType.Text) + { + sb.Append(payload); + continue; + } + + var index = payload.Body.Span.IndexOf(toFind); + if (index == -1) + { + sb.Append(payload); + continue; + } + + var lastIndex = 0; + while (index != -1) + { + sb.Append(payload.Body.Span[lastIndex..index]); + + if (!replacement.IsEmpty) + { + sb.Append(replacement); + } + + lastIndex = index + toFind.Length; + index = payload.Body.Span[lastIndex..].IndexOf(toFind); + + if (index != -1) + index += lastIndex; + } + + sb.Append(payload.Body.Span[lastIndex..]); + } + + var output = sb.ToReadOnlySeString(); + LSeStringBuilder.SharedPool.Return(sb); + return output; + } + + /// + /// Replaces occurrences of a specified text in an with another text. + /// + /// The builder to modify. + /// The text to find. + /// The replacement text. + public static void ReplaceText( + this LSeStringBuilder builder, + ReadOnlySpan toFind, + ReadOnlySpan replacement) + { + if (toFind.IsEmpty) + return; + + var str = builder.ToReadOnlySeString(); + if (str.IsEmpty) + return; + + var replaced = ReplaceText(new ReadOnlySeString(builder.GetViewAsMemory()), toFind, replacement); + builder.Clear().Append(replaced); + } } diff --git a/Dalamud/Utility/StringExtensions.cs b/Dalamud/Utility/StringExtensions.cs index 24aa48446..50973e338 100644 --- a/Dalamud/Utility/StringExtensions.cs +++ b/Dalamud/Utility/StringExtensions.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Globalization; using FFXIVClientStructs.FFXIV.Client.UI; @@ -43,4 +44,48 @@ public static class StringExtensions if (!UIGlobals.IsValidPlayerCharacterName(value)) return false; return includeLegacy || value.Length <= 21; } + + /// + /// Converts the first character of the string to uppercase while leaving the rest of the string unchanged. + /// + /// The input string. + /// + /// A new string with the first character converted to uppercase. + [return: NotNullIfNotNull("input")] + public static string? FirstCharToUpper(this string? input, CultureInfo? culture = null) => + string.IsNullOrWhiteSpace(input) + ? input + : $"{char.ToUpper(input[0], culture ?? CultureInfo.CurrentCulture)}{input.AsSpan(1)}"; + + /// + /// Converts the first character of the string to lowercase while leaving the rest of the string unchanged. + /// + /// The input string. + /// + /// A new string with the first character converted to lowercase. + [return: NotNullIfNotNull("input")] + public static string? FirstCharToLower(this string? input, CultureInfo? culture = null) => + string.IsNullOrWhiteSpace(input) + ? input + : $"{char.ToLower(input[0], culture ?? CultureInfo.CurrentCulture)}{input.AsSpan(1)}"; + + /// + /// Removes soft hyphen characters (U+00AD) from the input string. + /// + /// The input string to remove soft hyphen characters from. + /// A string with all soft hyphens removed. + public static string StripSoftHyphen(this string input) => input.Replace("\u00AD", string.Empty); + + /// + /// Truncates the given string to the specified maximum number of characters, + /// appending an ellipsis if truncation occurs. + /// + /// The string to truncate. + /// The maximum allowed length of the string. + /// The string to append if truncation occurs (defaults to "..."). + /// The truncated string, or the original string if no truncation is needed. + public static string? Truncate(this string input, int maxChars, string ellipses = "...") + { + return string.IsNullOrEmpty(input) || input.Length <= maxChars ? input : input[..maxChars] + ellipses; + } }