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 <KazWolfe@users.noreply.github.com>
This commit is contained in:
Haselnussbomber 2025-03-24 17:00:27 +01:00 committed by GitHub
parent 7cac19ce81
commit fdbfdbb2cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 5831 additions and 196 deletions

View file

@ -0,0 +1,89 @@
namespace Dalamud.Game;
/// <summary>
/// Enum describing possible action kinds.
/// </summary>
public enum ActionKind
{
/// <summary>
/// A Trait.
/// </summary>
Trait = 0,
/// <summary>
/// An Action.
/// </summary>
Action = 1,
/// <summary>
/// A usable Item.
/// </summary>
Item = 2, // does not work?
/// <summary>
/// A usable EventItem.
/// </summary>
EventItem = 3, // does not work?
/// <summary>
/// An EventAction.
/// </summary>
EventAction = 4,
/// <summary>
/// A GeneralAction.
/// </summary>
GeneralAction = 5,
/// <summary>
/// A BuddyAction.
/// </summary>
BuddyAction = 6,
/// <summary>
/// A MainCommand.
/// </summary>
MainCommand = 7,
/// <summary>
/// A Companion.
/// </summary>
Companion = 8, // unresolved?!
/// <summary>
/// A CraftAction.
/// </summary>
CraftAction = 9,
/// <summary>
/// An Action (again).
/// </summary>
Action2 = 10, // what's the difference?
/// <summary>
/// A PetAction.
/// </summary>
PetAction = 11,
/// <summary>
/// A CompanyAction.
/// </summary>
CompanyAction = 12,
/// <summary>
/// A Mount.
/// </summary>
Mount = 13,
// 14-18 unused
/// <summary>
/// A BgcArmyAction.
/// </summary>
BgcArmyAction = 19,
/// <summary>
/// An Ornament.
/// </summary>
Ornament = 20,
}

View file

@ -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;

View file

@ -0,0 +1,30 @@
using Lumina.Text;
namespace Dalamud.Game.Text.Evaluator.Internal;
/// <summary>
/// Wraps payloads in an open and close icon, for example the Auto Translation open/close brackets.
/// </summary>
internal readonly struct SeStringBuilderIconWrap : IDisposable
{
private readonly SeStringBuilder builder;
private readonly uint iconClose;
/// <summary>
/// Initializes a new instance of the <see cref="SeStringBuilderIconWrap"/> struct.<br/>
/// Appends an icon macro with <paramref name="iconOpen"/> on creation, and an icon macro with
/// <paramref name="iconClose"/> on disposal.
/// </summary>
/// <param name="builder">The builder to use.</param>
/// <param name="iconOpen">The open icon id.</param>
/// <param name="iconClose">The close icon id.</param>
public SeStringBuilderIconWrap(SeStringBuilder builder, uint iconOpen, uint iconClose)
{
this.builder = builder;
this.iconClose = iconClose;
this.builder.AppendIcon(iconOpen);
}
/// <inheritdoc/>
public void Dispose() => this.builder.AppendIcon(this.iconClose);
}

View file

@ -0,0 +1,83 @@
using System.Globalization;
using Dalamud.Utility;
using Lumina.Text;
using Lumina.Text.ReadOnly;
namespace Dalamud.Game.Text.Evaluator.Internal;
/// <summary>
/// A context wrapper used in <see cref="SeStringEvaluator"/>.
/// </summary>
internal readonly ref struct SeStringContext
{
/// <summary>
/// The <see cref="SeStringBuilder"/> to append text and macros to.
/// </summary>
internal readonly SeStringBuilder Builder;
/// <summary>
/// A list of local parameters.
/// </summary>
internal readonly Span<SeStringParameter> LocalParameters;
/// <summary>
/// The target language, used for sheet lookups.
/// </summary>
internal readonly ClientLanguage Language;
/// <summary>
/// Initializes a new instance of the <see cref="SeStringContext"/> struct.
/// </summary>
/// <param name="builder">The <see cref="SeStringBuilder"/> to append text and macros to.</param>
/// <param name="localParameters">A list of local parameters.</param>
/// <param name="language">The target language, used for sheet lookups.</param>
internal SeStringContext(SeStringBuilder builder, Span<SeStringParameter> localParameters, ClientLanguage language)
{
this.Builder = builder;
this.LocalParameters = localParameters;
this.Language = language;
}
/// <summary>
/// Gets the <see cref="System.Globalization.CultureInfo"/> of the current target <see cref="Language"/>.
/// </summary>
internal CultureInfo CultureInfo => Localization.GetCultureInfoFromLangCode(this.Language.ToCode());
/// <summary>
/// Tries to get a number from the local parameters at the specified index.
/// </summary>
/// <param name="index">The index in the <see cref="LocalParameters"/> list.</param>
/// <param name="value">The local parameter number.</param>
/// <returns><c>true</c> if the local parameters list contained a parameter at given index, <c>false</c> otherwise.</returns>
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;
}
/// <summary>
/// Tries to get a string from the local parameters at the specified index.
/// </summary>
/// <param name="index">The index in the <see cref="LocalParameters"/> list.</param>
/// <param name="value">The local parameter string.</param>
/// <returns><c>true</c> if the local parameters list contained a parameter at given index, <c>false</c> otherwise.</returns>
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;
}
}

View file

@ -0,0 +1,49 @@
namespace Dalamud.Game.Text.Evaluator.Internal;
/// <summary>
/// An enum providing additional information about the sheet redirect.
/// </summary>
[Flags]
internal enum SheetRedirectFlags
{
/// <summary>
/// No flags.
/// </summary>
None = 0,
/// <summary>
/// Resolved to a sheet related with items.
/// </summary>
Item = 1,
/// <summary>
/// Resolved to the EventItem sheet.
/// </summary>
EventItem = 2,
/// <summary>
/// Resolved to a high quality item.
/// </summary>
/// <remarks>
/// Append Addon#9.
/// </remarks>
HighQuality = 4,
/// <summary>
/// Resolved to a collectible item.
/// </summary>
/// <remarks>
/// Append Addon#150.
/// </remarks>
Collectible = 8,
/// <summary>
/// Resolved to a sheet related with actions.
/// </summary>
Action = 16,
/// <summary>
/// Resolved to the Action sheet.
/// </summary>
ActionSheet = 32,
}

View file

@ -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;
/// <summary>
/// A service to resolve sheet redirects in expressions.
/// </summary>
[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<DataManager>.Get();
[ServiceManager.ServiceConstructor]
private SheetRedirectResolver()
{
}
/// <summary>
/// Resolves the sheet redirect, if any is present.
/// </summary>
/// <param name="sheetName">The sheet name.</param>
/// <param name="rowId">The row id.</param>
/// <param name="colIndex">The column index. Use <c>ushort.MaxValue</c> as default.</param>
/// <returns>Flags giving additional information about the redirect.</returns>
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<LSheets.EventItem>().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<LSheets.Treasure>().TryGetRow(rowId, out var treasureRow) &&
treasureRow.Unknown0.IsEmpty)
rowId = 0; // defaulting to "Treasure Coffer"
break;
case 3: // Aetheryte
rowId = this.dataManager.GetExcelSheet<LSheets.Aetheryte>()
.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<LSheets.Treasure>().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<LSheets.WeatherReportReplace>().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<LSheets.InstanceContent>().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<LSheets.PartyContent>().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<LSheets.PublicContent>().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<LSheets.AkatsukiNote>().TryGetRow(rowId, out var row))
rowId = (uint)row[0].Unknown2;
break;
}
}
return flags;
}
}

File diff suppressed because it is too large Load diff

View file

@ -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;
/// <summary>
/// A wrapper for a local parameter, holding either a number or a string.
/// </summary>
public readonly struct SeStringParameter
{
private readonly uint num;
private readonly ReadOnlySeString str;
/// <summary>
/// Initializes a new instance of the <see cref="SeStringParameter"/> struct for a number parameter.
/// </summary>
/// <param name="value">The number value.</param>
public SeStringParameter(uint value)
{
this.num = value;
}
/// <summary>
/// Initializes a new instance of the <see cref="SeStringParameter"/> struct for a string parameter.
/// </summary>
/// <param name="value">The string value.</param>
public SeStringParameter(ReadOnlySeString value)
{
this.str = value;
this.IsString = true;
}
/// <summary>
/// Initializes a new instance of the <see cref="SeStringParameter"/> struct for a string parameter.
/// </summary>
/// <param name="value">The string value.</param>
public SeStringParameter(string value)
{
this.str = new ReadOnlySeString(value);
this.IsString = true;
}
/// <summary>
/// Gets a value indicating whether the backing type of this parameter is a string.
/// </summary>
public bool IsString { get; }
/// <summary>
/// Gets a numeric value.
/// </summary>
public uint UIntValue =>
!this.IsString
? this.num
: uint.TryParse(this.str.ExtractText(), out var value) ? value : 0;
/// <summary>
/// Gets a string value.
/// </summary>
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);
}

View file

@ -0,0 +1,17 @@
namespace Dalamud.Game.Text.Noun.Enums;
/// <summary>
/// Article types for <see cref="ClientLanguage.English"/>.
/// </summary>
public enum EnglishArticleType
{
/// <summary>
/// Indefinite article (a, an).
/// </summary>
Indefinite = 1,
/// <summary>
/// Definite article (the).
/// </summary>
Definite = 2,
}

View file

@ -0,0 +1,32 @@
namespace Dalamud.Game.Text.Noun.Enums;
/// <summary>
/// Article types for <see cref="ClientLanguage.French"/>.
/// </summary>
public enum FrenchArticleType
{
/// <summary>
/// Indefinite article (une, des).
/// </summary>
Indefinite = 1,
/// <summary>
/// Definite article (le, la, les).
/// </summary>
Definite = 2,
/// <summary>
/// Possessive article (mon, mes).
/// </summary>
PossessiveFirstPerson = 3,
/// <summary>
/// Possessive article (ton, tes).
/// </summary>
PossessiveSecondPerson = 4,
/// <summary>
/// Possessive article (son, ses).
/// </summary>
PossessiveThirdPerson = 5,
}

View file

@ -0,0 +1,37 @@
namespace Dalamud.Game.Text.Noun.Enums;
/// <summary>
/// Article types for <see cref="ClientLanguage.German"/>.
/// </summary>
public enum GermanArticleType
{
/// <summary>
/// Unbestimmter Artikel (ein, eine, etc.).
/// </summary>
Indefinite = 1,
/// <summary>
/// Bestimmter Artikel (der, die, das, etc.).
/// </summary>
Definite = 2,
/// <summary>
/// Possessivartikel "dein" (dein, deine, etc.).
/// </summary>
Possessive = 3,
/// <summary>
/// Negativartikel "kein" (kein, keine, etc.).
/// </summary>
Negative = 4,
/// <summary>
/// Nullartikel.
/// </summary>
ZeroArticle = 5,
/// <summary>
/// Demonstrativpronomen "dieser" (dieser, diese, etc.).
/// </summary>
Demonstrative = 6,
}

View file

@ -0,0 +1,17 @@
namespace Dalamud.Game.Text.Noun.Enums;
/// <summary>
/// Article types for <see cref="ClientLanguage.Japanese"/>.
/// </summary>
public enum JapaneseArticleType
{
/// <summary>
/// Near listener (それら).
/// </summary>
NearListener = 1,
/// <summary>
/// Distant from both speaker and listener (あれら).
/// </summary>
Distant = 2,
}

View file

@ -0,0 +1,73 @@
using Dalamud.Game.Text.Noun.Enums;
using Lumina.Text.ReadOnly;
using LSheets = Lumina.Excel.Sheets;
namespace Dalamud.Game.Text.Noun;
/// <summary>
/// Parameters for noun processing.
/// </summary>
internal record struct NounParams()
{
/// <summary>
/// The language of the sheet to be processed.
/// </summary>
public required ClientLanguage Language;
/// <summary>
/// The name of the sheet containing the row to process.
/// </summary>
public required string SheetName = string.Empty;
/// <summary>
/// The row id within the sheet to process.
/// </summary>
public required uint RowId;
/// <summary>
/// The quantity of the entity (default is 1). Used to determine grammatical number (e.g., singular or plural).
/// </summary>
public int Quantity = 1;
/// <summary>
/// The article type.
/// </summary>
/// <remarks>
/// Depending on the <see cref="Language"/>, this has different meanings.<br/>
/// See <see cref="JapaneseArticleType"/>, <see cref="GermanArticleType"/>, <see cref="FrenchArticleType"/>, <see cref="EnglishArticleType"/>.
/// </remarks>
public int ArticleType = 1;
/// <summary>
/// The grammatical case (e.g., Nominative, Genitive, Dative, Accusative) used for German texts (default is 0).
/// </summary>
public int GrammaticalCase = 0;
/// <summary>
/// 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 "//").
/// </summary>
public ReadOnlySeString LinkMarker = default;
/// <summary>
/// An indicator that this noun will be processed from an Action sheet. Only used for German texts.
/// </summary>
public bool IsActionSheet;
/// <summary>
/// Gets the column offset.
/// </summary>
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,
};
}

View file

@ -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] = ?
*/
/// <summary>
/// Provides functionality to process texts from sheets containing grammatical placeholders.
/// </summary>
[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<DataManager>.Get();
[ServiceManager.ServiceDependency]
private readonly DalamudConfiguration dalamudConfiguration = Service<DalamudConfiguration>.Get();
private readonly ConcurrentDictionary<NounParams, ReadOnlySeString> cache = [];
[ServiceManager.ServiceConstructor]
private NounProcessor()
{
}
/// <summary>
/// Processes a specific row from a sheet and generates a formatted string based on grammatical and language-specific rules.
/// </summary>
/// <param name="nounParams">Parameters for processing.</param>
/// <returns>A ReadOnlySeString representing the processed text.</returns>
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;
}
/// <summary>
/// Resolves noun placeholders in Japanese text.
/// </summary>
/// <param name="nounParams">Parameters for processing.</param>
/// <returns>A ReadOnlySeString representing the processed text.</returns>
/// <remarks>
/// This is a C# implementation of Component::Text::Localize::NounJa.Resolve.
/// </remarks>
private ReadOnlySeString ResolveNounJa(NounParams nounParams)
{
var sheet = this.dataManager.Excel.GetSheet<RawRow>(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<RawRow>(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;
}
/// <summary>
/// Resolves noun placeholders in English text.
/// </summary>
/// <param name="nounParams">Parameters for processing.</param>
/// <returns>A ReadOnlySeString representing the processed text.</returns>
/// <remarks>
/// This is a C# implementation of Component::Text::Localize::NounEn.Resolve.
/// </remarks>
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<RawRow>(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<RawRow>(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;
}
/// <summary>
/// Resolves noun placeholders in German text.
/// </summary>
/// <param name="nounParams">Parameters for processing.</param>
/// <returns>A ReadOnlySeString representing the processed text.</returns>
/// <remarks>
/// This is a C# implementation of Component::Text::Localize::NounDe.Resolve.
/// </remarks>
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<RawRow>(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<RawRow>(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;
}
/// <summary>
/// Resolves noun placeholders in French text.
/// </summary>
/// <param name="nounParams">Parameters for processing.</param>
/// <returns>A ReadOnlySeString representing the processed text.</returns>
/// <remarks>
/// This is a C# implementation of Component::Text::Localize::NounFr.Resolve.
/// </remarks>
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<RawRow>(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<RawRow>(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;
}
}

View file

@ -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
/// <summary>
/// Kinds of items that can be fetched from this payload.
/// </summary>
[Api12ToDo("Move this out of ItemPayload. It's used in other classes too.")]
public enum ItemKind : uint
{
/// <summary>
@ -121,7 +124,7 @@ public class ItemPayload : Payload
/// Gets the actual item ID of this payload.
/// </summary>
[JsonIgnore]
public uint ItemId => GetAdjustedId(this.rawItemId).ItemId;
public uint ItemId => ItemUtil.GetBaseId(this.rawItemId).ItemId;
/// <summary>
/// Gets the raw, unadjusted item ID of this payload.
@ -161,7 +164,7 @@ public class ItemPayload : Payload
/// <returns>The created item payload.</returns>
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),
};
}
}

View file

@ -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;
/// <summary>
@ -187,57 +192,32 @@ public class SeString
/// <returns>An SeString containing all the payloads necessary to display an item link in the chat log.</returns>
public static SeString CreateItemLink(uint itemId, ItemPayload.ItemKind kind = ItemPayload.ItemKind.Normal, string? displayNameOverride = null)
{
var data = Service<DataManager>.Get();
var clientState = Service<ClientState.ClientState>.Get();
var seStringEvaluator = Service<SeStringEvaluator>.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<Item>()?.GetRowOrDefault(itemId);
displayName = item?.Name.ExtractText();
rarity = item?.Rarity ?? 1;
break;
case ItemPayload.ItemKind.EventItem:
displayName = data.GetExcelSheet<EventItem>()?.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));
}
/// <summary>
@ -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);
/// <summary>
/// Creates an SeString representing an entire Payload chain that can be used to link a map position in the chat log.
/// </summary>
@ -340,7 +320,7 @@ public class SeString
/// <returns>An SeString containing all of the payloads necessary to display a map link in the chat log.</returns>
public static SeString? CreateMapLink(string placeName, float xCoord, float yCoord, float fudgeFactor = 0.05f) =>
CreateMapLinkWithInstance(placeName, null, xCoord, yCoord, fudgeFactor);
/// <summary>
/// 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}";
}
}