using System.Collections.Generic; using System.IO; using System.Linq; 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; /// /// This class represents a parsed SeString. /// public class SeString { /// /// Initializes a new instance of the class. /// Creates a new SeString from an ordered list of payloads. /// public SeString() { this.Payloads = new List(); } /// /// Initializes a new instance of the class. /// Creates a new SeString from an ordered list of payloads. /// /// The Payload objects to make up this string. [JsonConstructor] public SeString(List payloads) { this.Payloads = payloads; } /// /// Initializes a new instance of the class. /// Creates a new SeString from an ordered list of payloads. /// /// The Payload objects to make up this string. public SeString(params Payload[] payloads) { this.Payloads = new List(payloads); } /// /// Gets a list of Payloads necessary to display the arrow link marker icon in chat /// with the appropriate glow and coloring. /// /// A list of all the payloads required to insert the link marker. public static IEnumerable TextArrowPayloads { get { var clientState = Service.Get(); var markerSpace = clientState.ClientLanguage switch { ClientLanguage.German => " ", ClientLanguage.French => " ", _ => string.Empty, }; return new List { new UIForegroundPayload(500), new UIGlowPayload(501), new TextPayload($"{(char)SeIconChar.LinkMarker}{markerSpace}"), UIGlowPayload.UIGlowOff, UIForegroundPayload.UIForegroundOff, }; } } /// /// Gets an empty SeString. /// public static SeString Empty => new(); /// /// Gets the ordered list of payloads included in this SeString. /// public List Payloads { get; } /// /// Gets all of the raw text from a message as a single joined string. /// /// /// All the raw text from the contained payloads, joined into a single string. /// public string TextValue { get { return this.Payloads .Where(p => p is ITextProvider) .Cast() .Aggregate(new StringBuilder(), (sb, tp) => sb.Append(tp.Text), sb => sb.ToString()); } } /// /// Implicitly convert a string into a SeString containing a . /// /// string to convert. /// Equivalent SeString. public static implicit operator SeString(string str) => new(new TextPayload(str)); /// /// Implicitly convert a string into a SeString containing a . /// /// string to convert. /// Equivalent SeString. public static explicit operator SeString(Lumina.Text.SeString str) => str.ToDalamudString(); /// /// Parse a binary game message into an SeString. /// /// Pointer to the string's data in memory. /// Length of the string's data in memory. /// An SeString containing parsed Payload objects for each payload in the data. public static unsafe SeString Parse(byte* ptr, int len) { if (ptr == null) return Empty; var payloads = new List(); using (var stream = new UnmanagedMemoryStream(ptr, len)) using (var reader = new BinaryReader(stream)) { while (stream.Position < len) { payloads.Add(Payload.Decode(reader)); } } return new SeString(payloads); } /// /// Parse a binary game message into an SeString. /// /// Binary message payload data in SE's internal format. /// An SeString containing parsed Payload objects for each payload in the data. public static unsafe SeString Parse(ReadOnlySpan data) { fixed (byte* ptr = data) { var len = data.IndexOf((byte)0); return Parse(ptr, len == -1 ? data.Length : len); } } /// /// Parse a binary game message into an SeString. /// /// Binary message payload data in SE's internal format. /// An SeString containing parsed Payload objects for each payload in the data. public static SeString Parse(byte[] bytes) => Parse(new ReadOnlySpan(bytes)); /// /// Parse a binary game message into an SeString. /// /// Pointer to the string's data in memory. Needs to be null-terminated. /// An SeString containing parsed Payload objects for each payload in the data. public static unsafe SeString Parse(byte* ptr) => Parse(MemoryMarshal.CreateReadOnlySpanFromNullTerminated(ptr)); /// /// Creates an SeString representing an entire Payload chain that can be used to link an item in the chat log. /// /// The id of the item to link. /// Whether to link the high-quality variant of the item. /// An optional name override to display, instead of the actual item name. /// An SeString containing all the payloads necessary to display an item link in the chat log. public static SeString CreateItemLink(uint itemId, bool isHq, string? displayNameOverride = null) => CreateItemLink(itemId, isHq ? ItemKind.Hq : ItemKind.Normal, displayNameOverride); /// /// Creates an SeString representing an entire Payload chain that can be used to link an item in the chat log. /// /// The id of the item to link. /// The kind of item to link. /// An optional name override to display, instead of the actual item name. /// An SeString containing all the payloads necessary to display an item link in the chat log. public static SeString CreateItemLink(uint itemId, ItemKind kind = ItemKind.Normal, string? displayNameOverride = null) { var clientState = Service.Get(); var seStringEvaluator = Service.Get(); var rawId = ItemUtil.GetRawId(itemId, kind); var displayName = displayNameOverride ?? ItemUtil.GetItemName(rawId); if (displayName.IsEmpty) throw new Exception("Invalid item ID specified, could not determine item name."); var copyName = ItemUtil.GetItemName(rawId, false).ExtractText(); var textColor = ItemUtil.GetItemRarityColorType(rawId); var textEdgeColor = textColor + 1u; 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); return SeString.Parse(seStringEvaluator.EvaluateFromAddon(371, [itemLink], clientState.ClientLanguage)); } /// /// Creates an SeString representing an entire Payload chain that can be used to link an item in the chat log. /// /// The Lumina Item to link. /// Whether to link the high-quality variant of the item. /// An optional name override to display, instead of the actual item name. /// An SeString containing all the payloads necessary to display an item link in the chat log. public static SeString CreateItemLink(Item item, bool isHq, string? displayNameOverride = null) { return CreateItemLink(item.RowId, isHq, displayNameOverride ?? item.Name.ExtractText()); } /// /// Creates an SeString representing an entire Payload chain that can be used to link a map position in the chat log. /// /// The id of the TerritoryType for this map link. /// The id of the Map for this map link. /// The raw x-coordinate for this link. /// The raw y-coordinate for this link.. /// An SeString containing all of the payloads necessary to display a map link in the chat log. public static SeString CreateMapLink(uint territoryId, uint mapId, int rawX, int rawY) => CreateMapLinkWithInstance(territoryId, mapId, null, rawX, rawY); /// /// Creates an SeString representing an entire Payload chain that can be used to link a map position in the chat log. /// /// The id of the TerritoryType for this map link. /// The id of the Map for this map link. /// An optional area instance number to be included in this link. /// The raw x-coordinate for this link. /// The raw y-coordinate for this link.. /// An SeString containing all of the payloads necessary to display a map link in the chat log. public static SeString CreateMapLinkWithInstance(uint territoryId, uint mapId, int? instance, int rawX, int rawY) { var mapPayload = new MapLinkPayload(territoryId, mapId, rawX, rawY); var nameString = GetMapLinkNameString(mapPayload.PlaceName, instance, mapPayload.CoordinateString); var payloads = new List(new Payload[] { mapPayload, // arrow goes here new TextPayload(nameString), RawPayload.LinkTerminator, }); payloads.InsertRange(1, TextArrowPayloads); return new SeString(payloads); } /// /// Creates an SeString representing an entire Payload chain that can be used to link a map position in the chat log. /// /// The id of the TerritoryType for this map link. /// The id of the Map for this map link. /// The human-readable x-coordinate for this link. /// The human-readable y-coordinate for this link. /// An optional offset to account for rounding and truncation errors; it is best to leave this untouched in most cases. /// An SeString containing all of the payloads necessary to display a map link in the chat log. 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. /// /// The id of the TerritoryType for this map link. /// The id of the Map for this map link. /// An optional area instance number to be included in this link. /// The human-readable x-coordinate for this link. /// The human-readable y-coordinate for this link. /// An optional offset to account for rounding and truncation errors; it is best to leave this untouched in most cases. /// An SeString containing all of the payloads necessary to display a map link in the chat log. public static SeString CreateMapLinkWithInstance(uint territoryId, uint mapId, int? instance, float xCoord, float yCoord, float fudgeFactor = 0.05f) { var mapPayload = new MapLinkPayload(territoryId, mapId, xCoord, yCoord, fudgeFactor); var nameString = GetMapLinkNameString(mapPayload.PlaceName, instance, mapPayload.CoordinateString); var payloads = new List(new Payload[] { mapPayload, // arrow goes here new TextPayload(nameString), RawPayload.LinkTerminator, }); payloads.InsertRange(1, TextArrowPayloads); return new SeString(payloads); } /// /// 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. /// /// The name of the location for this link. This should be exactly the name as seen in a displayed map link in-game for the same zone. /// The human-readable x-coordinate for this link. /// The human-readable y-coordinate for this link. /// An optional offset to account for rounding and truncation errors; it is best to leave this untouched in most cases. /// 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. /// /// The name of the location for this link. This should be exactly the name as seen in a displayed map link in-game for the same zone. /// An optional area instance number to be included in this link. /// The human-readable x-coordinate for this link. /// The human-readable y-coordinate for this link. /// An optional offset to account for rounding and truncation errors; it is best to leave this untouched in most cases. /// An SeString containing all of the payloads necessary to display a map link in the chat log. public static SeString? CreateMapLinkWithInstance(string placeName, int? instance, float xCoord, float yCoord, float fudgeFactor = 0.05f) { var data = Service.Get(); var mapSheet = data.GetExcelSheet(); var matches = data.GetExcelSheet() .Where(row => row.Name.ExtractText().Equals(placeName, StringComparison.InvariantCultureIgnoreCase)); foreach (var place in matches) { var map = mapSheet.Cast().FirstOrDefault(row => row!.Value.PlaceName.RowId == place.RowId); if (map.HasValue && map.Value.TerritoryType.RowId != 0) { return CreateMapLinkWithInstance(map.Value.TerritoryType.RowId, map.Value.RowId, instance, xCoord, yCoord, fudgeFactor); } } // TODO: empty? throw? return null; } /// /// Creates an SeString representing an entire payload chain that can be used to link party finder listings in the chat log. /// /// The listing ID of the party finder entry. /// The name of the recruiter. /// Whether the listing is limited to the current world or not. /// An SeString containing all the payloads necessary to display a party finder link in the chat log. public static SeString CreatePartyFinderLink(uint listingId, string recruiterName, bool isCrossWorld = false) { var payloads = new List() { new PartyFinderPayload(listingId, isCrossWorld ? PartyFinderPayload.PartyFinderLinkType.NotSpecified : PartyFinderPayload.PartyFinderLinkType.LimitedToHomeWorld), // -> new TextPayload($"Looking for Party ({recruiterName})" + (isCrossWorld ? " " : string.Empty)), }; payloads.InsertRange(1, TextArrowPayloads); if (isCrossWorld) payloads.Add(new IconPayload(BitmapFontIcon.CrossWorld)); payloads.Add(RawPayload.LinkTerminator); return new SeString(payloads); } /// /// Creates an SeString representing an entire payload chain that can be used to link the party finder search conditions. /// /// The text that should be displayed for the link. /// An SeString containing all the payloads necessary to display a link to the party finder search conditions. public static SeString CreatePartyFinderSearchConditionsLink(string message) { var payloads = new List() { new PartyFinderPayload(), // -> new TextPayload(message), }; payloads.InsertRange(1, TextArrowPayloads); payloads.Add(RawPayload.LinkTerminator); return new SeString(payloads); } /// /// Creates a SeString from a json. (For testing - not recommended for production use.) /// /// A serialized SeString produced by ToJson() . /// A SeString initialized with values from the json. public static SeString? FromJson(string json) { var s = JsonConvert.DeserializeObject(json, new JsonSerializerSettings { PreserveReferencesHandling = PreserveReferencesHandling.Objects, TypeNameHandling = TypeNameHandling.Auto, ConstructorHandling = ConstructorHandling.AllowNonPublicDefaultConstructor, }); return s; } /// /// Serializes the SeString to json. /// /// An json representation of this object. public string ToJson() { return JsonConvert.SerializeObject(this, Formatting.Indented, new JsonSerializerSettings() { PreserveReferencesHandling = PreserveReferencesHandling.Objects, ReferenceLoopHandling = ReferenceLoopHandling.Ignore, TypeNameHandling = TypeNameHandling.Auto, }); } /// /// Appends the contents of one SeString to this one. /// /// The SeString to append to this one. /// This object. public SeString Append(SeString other) { this.Payloads.AddRange(other.Payloads); return this; } /// /// Appends a list of payloads to this SeString. /// /// The Payloads to append. /// This object. public SeString Append(IEnumerable payloads) { this.Payloads.AddRange(payloads); return this; } /// /// Appends a single payload to this SeString. /// /// The payload to append. /// This object. public SeString Append(Payload payload) { this.Payloads.Add(payload); return this; } /// /// Encodes the Payloads in this SeString into a binary representation /// suitable for use by in-game handlers, such as the chat log. /// /// The binary encoded payload data. public byte[] Encode() { var messageBytes = new List(); foreach (var p in this.Payloads) { messageBytes.AddRange(p.Encode()); } return messageBytes.ToArray(); } /// /// Encodes the Payloads in this SeString into a binary representation /// suitable for use by in-game handlers, such as the chat log. /// Includes a null terminator at the end of the string. /// /// The binary encoded payload data. public byte[] EncodeWithNullTerminator() { var messageBytes = new List(); foreach (var p in this.Payloads) { messageBytes.AddRange(p.Encode()); } // Add Null Terminator messageBytes.Add(0); return messageBytes.ToArray(); } /// /// Get the text value of this SeString. /// /// The TextValue property. public override string ToString() { return this.TextValue; } private static string GetMapLinkNameString(string placeName, int? instance, string coordinateString) { var instanceString = string.Empty; if (instance is > 0 and < 10) { instanceString = (SeIconChar.Instance1 + instance.Value - 1).ToIconString(); } return $"{placeName}{instanceString} {coordinateString}"; } }