diff --git a/Dalamud/Dalamud.cs b/Dalamud/Dalamud.cs index 3d39a07ab..105657bc2 100644 --- a/Dalamud/Dalamud.cs +++ b/Dalamud/Dalamud.cs @@ -12,6 +12,7 @@ using Dalamud.Data; using Dalamud.DiscordBot; using Dalamud.Game; using Dalamud.Game.Chat; +using Dalamud.Game.Chat.SeStringHandling; using Dalamud.Game.ClientState; using Dalamud.Game.ClientState.Actors.Types; using Dalamud.Game.ClientState.Actors.Types.NonPlayer; @@ -116,6 +117,9 @@ namespace Dalamud { this.Data = new DataManager(this.StartInfo.Language); await this.Data.Initialize(this.baseDirectory); + // TODO: better way to do this? basically for lumina injection + SeString.Dalamud = this; + this.NetworkHandlers = new NetworkHandlers(this, this.Configuration.OptOutMbCollection); // Initialize managers. Basically handlers for the logic @@ -123,7 +127,6 @@ namespace Dalamud { SetupCommands(); this.ChatHandlers = new ChatHandlers(this); - // Discord Bot Manager this.BotManager = new DiscordBotManager(this, this.Configuration.DiscordFeatureConfig); this.BotManager.Start(); @@ -558,25 +561,14 @@ namespace Dalamud { private ItemSearchWindow itemSearchCommandWindow; private bool isImguiDrawItemSearchWindow; - private void OnItemLinkCommand(string command, string arguments) { + private void OnItemLinkCommand(string command, string arguments) + { this.itemSearchCommandWindow = new ItemSearchWindow(this.Data, new UiBuilder(this.InterfaceManager, "ItemSearcher"), false, arguments); - this.itemSearchCommandWindow.OnItemChosen += (sender, item) => { - var hexData = new byte[] { - 0x02, 0x13, 0x06, 0xFE, 0xFF, 0xF3, 0xF3, 0xF3, 0x03, 0x02, 0x27, 0x07, 0x03, 0xF2, 0x3A, 0x2F, - 0x02, 0x01, 0x03, 0x02, 0x13, 0x06, 0xFE, 0xFF, 0xFF, 0x7B, 0x1A, 0x03, 0xEE, 0x82, 0xBB, 0x02, - 0x13, 0x02, 0xEC, 0x03 - }; - - var endTag = new byte[] { - 0x02, 0x27, 0x07, 0xCF, 0x01, 0x01, 0x01, 0xFF, 0x01, 0x03, 0x02, 0x13, 0x02, 0xEC, 0x03 - }; - - BitConverter.GetBytes((short) item.RowId).Reverse().ToArray().CopyTo(hexData, 14); - - hexData = hexData.Concat(Encoding.UTF8.GetBytes(item.Name)).Concat(endTag).ToArray(); - - this.Framework.Gui.Chat.PrintChat(new XivChatEntry { - MessageBytes = hexData + this.itemSearchCommandWindow.OnItemChosen += (sender, item) => + { + this.Framework.Gui.Chat.PrintChat(new XivChatEntry + { + MessageBytes = SeStringUtils.CreateItemLink(item, false).Encode() }); }; this.isImguiDrawItemSearchWindow = true; diff --git a/Dalamud/Data/TransientSheet/Completion.cs b/Dalamud/Data/TransientSheet/Completion.cs new file mode 100644 index 000000000..e437971c3 --- /dev/null +++ b/Dalamud/Data/TransientSheet/Completion.cs @@ -0,0 +1,53 @@ +using Lumina.Excel; + +namespace Dalamud.Data.TransientSheet +{ + [Sheet( "Completion", columnHash: 0x2e6c55a3 )] + public class Completion : IExcelRow + { + // column defs from Mon, 02 Mar 2020 11:00:20 GMT + + + // col: 03 offset: 0000 + public string Text; + + // col: 04 offset: 0004 + public string GroupTitle; + + // col: 02 offset: 0008 + public string LookupTable; + + // col: 00 offset: 000c + public ushort Group; + + // col: 01 offset: 000e + public ushort Key; + + + public int RowId { get; set; } + public int SubRowId { get; set; } + + public void PopulateData( RowParser parser, Lumina.Lumina lumina ) + { + RowId = parser.Row; + SubRowId = parser.SubRow; + + // col: 3 offset: 0000 + Text = parser.ReadOffset< string >( 0x0 ); + + // col: 4 offset: 0004 + GroupTitle = parser.ReadOffset< string >( 0x4 ); + + // col: 2 offset: 0008 + LookupTable = parser.ReadOffset< string >( 0x8 ); + + // col: 0 offset: 000c + Group = parser.ReadOffset< ushort >( 0xc ); + + // col: 1 offset: 000e + Key = parser.ReadOffset< ushort >( 0xe ); + + + } + } +} diff --git a/Dalamud/Data/TransientSheet/PetMirage.cs b/Dalamud/Data/TransientSheet/PetMirage.cs new file mode 100644 index 000000000..42e39337d --- /dev/null +++ b/Dalamud/Data/TransientSheet/PetMirage.cs @@ -0,0 +1,401 @@ +using Lumina.Excel; + +namespace Dalamud.Data.TransientSheet +{ + [Sheet( "PetMirage", columnHash: 0x720608f1 )] + public class PetMirage : IExcelRow + { + // column defs from Sun, 26 Apr 2020 15:17:06 GMT + + + // col: 02 offset: 0000 + public string Name; + + // col: 03 offset: 0004 + public ushort unknown4; + + // col: 33 offset: 0006 + public ushort unknown6; + + // col: 48 offset: 0008 + public ushort unknown8; + + // col: 18 offset: 000a + public byte unknowna; + + // col: 04 offset: 000c + public ushort unknownc; + + // col: 34 offset: 000e + public ushort unknowne; + + // col: 49 offset: 0010 + public ushort unknown10; + + // col: 19 offset: 0012 + public byte unknown12; + + // col: 05 offset: 0014 + public ushort unknown14; + + // col: 35 offset: 0016 + public ushort unknown16; + + // col: 50 offset: 0018 + public ushort unknown18; + + // col: 20 offset: 001a + public byte unknown1a; + + // col: 06 offset: 001c + public ushort unknown1c; + + // col: 36 offset: 001e + public ushort unknown1e; + + // col: 51 offset: 0020 + public ushort unknown20; + + // col: 21 offset: 0022 + public byte unknown22; + + // col: 07 offset: 0024 + public ushort unknown24; + + // col: 37 offset: 0026 + public ushort unknown26; + + // col: 52 offset: 0028 + public ushort unknown28; + + // col: 22 offset: 002a + public byte unknown2a; + + // col: 08 offset: 002c + public ushort unknown2c; + + // col: 38 offset: 002e + public ushort unknown2e; + + // col: 53 offset: 0030 + public ushort unknown30; + + // col: 23 offset: 0032 + public byte unknown32; + + // col: 09 offset: 0034 + public ushort unknown34; + + // col: 39 offset: 0036 + public ushort unknown36; + + // col: 54 offset: 0038 + public ushort unknown38; + + // col: 24 offset: 003a + public byte unknown3a; + + // col: 10 offset: 003c + public ushort unknown3c; + + // col: 40 offset: 003e + public ushort unknown3e; + + // col: 55 offset: 0040 + public ushort unknown40; + + // col: 25 offset: 0042 + public byte unknown42; + + // col: 11 offset: 0044 + public ushort unknown44; + + // col: 41 offset: 0046 + public ushort unknown46; + + // col: 56 offset: 0048 + public ushort unknown48; + + // col: 26 offset: 004a + public byte unknown4a; + + // col: 12 offset: 004c + public ushort unknown4c; + + // col: 42 offset: 004e + public ushort unknown4e; + + // col: 57 offset: 0050 + public ushort unknown50; + + // col: 27 offset: 0052 + public byte unknown52; + + // col: 13 offset: 0054 + public ushort unknown54; + + // col: 43 offset: 0056 + public ushort unknown56; + + // col: 58 offset: 0058 + public ushort unknown58; + + // col: 28 offset: 005a + public byte unknown5a; + + // col: 14 offset: 005c + public ushort unknown5c; + + // col: 44 offset: 005e + public ushort unknown5e; + + // col: 59 offset: 0060 + public ushort unknown60; + + // col: 29 offset: 0062 + public byte unknown62; + + // col: 15 offset: 0064 + public ushort unknown64; + + // col: 45 offset: 0066 + public ushort unknown66; + + // col: 60 offset: 0068 + public ushort unknown68; + + // col: 30 offset: 006a + public byte unknown6a; + + // col: 16 offset: 006c + public ushort unknown6c; + + // col: 46 offset: 006e + public ushort unknown6e; + + // col: 61 offset: 0070 + public ushort unknown70; + + // col: 31 offset: 0072 + public byte unknown72; + + // col: 17 offset: 0074 + public ushort unknown74; + + // col: 47 offset: 0076 + public ushort unknown76; + + // col: 62 offset: 0078 + public ushort unknown78; + + // col: 32 offset: 007a + public byte unknown7a; + + // col: 00 offset: 007c + public float unknown7c; + + // col: 01 offset: 0080 + public ushort unknown80; + + + public int RowId { get; set; } + public int SubRowId { get; set; } + + public void PopulateData( RowParser parser, Lumina.Lumina lumina ) + { + RowId = parser.Row; + SubRowId = parser.SubRow; + + // col: 2 offset: 0000 + Name = parser.ReadOffset< string >( 0x0 ); + + // col: 3 offset: 0004 + unknown4 = parser.ReadOffset< ushort >( 0x4 ); + + // col: 33 offset: 0006 + unknown6 = parser.ReadOffset< ushort >( 0x6 ); + + // col: 48 offset: 0008 + unknown8 = parser.ReadOffset< ushort >( 0x8 ); + + // col: 18 offset: 000a + unknowna = parser.ReadOffset< byte >( 0xa ); + + // col: 4 offset: 000c + unknownc = parser.ReadOffset< ushort >( 0xc ); + + // col: 34 offset: 000e + unknowne = parser.ReadOffset< ushort >( 0xe ); + + // col: 49 offset: 0010 + unknown10 = parser.ReadOffset< ushort >( 0x10 ); + + // col: 19 offset: 0012 + unknown12 = parser.ReadOffset< byte >( 0x12 ); + + // col: 5 offset: 0014 + unknown14 = parser.ReadOffset< ushort >( 0x14 ); + + // col: 35 offset: 0016 + unknown16 = parser.ReadOffset< ushort >( 0x16 ); + + // col: 50 offset: 0018 + unknown18 = parser.ReadOffset< ushort >( 0x18 ); + + // col: 20 offset: 001a + unknown1a = parser.ReadOffset< byte >( 0x1a ); + + // col: 6 offset: 001c + unknown1c = parser.ReadOffset< ushort >( 0x1c ); + + // col: 36 offset: 001e + unknown1e = parser.ReadOffset< ushort >( 0x1e ); + + // col: 51 offset: 0020 + unknown20 = parser.ReadOffset< ushort >( 0x20 ); + + // col: 21 offset: 0022 + unknown22 = parser.ReadOffset< byte >( 0x22 ); + + // col: 7 offset: 0024 + unknown24 = parser.ReadOffset< ushort >( 0x24 ); + + // col: 37 offset: 0026 + unknown26 = parser.ReadOffset< ushort >( 0x26 ); + + // col: 52 offset: 0028 + unknown28 = parser.ReadOffset< ushort >( 0x28 ); + + // col: 22 offset: 002a + unknown2a = parser.ReadOffset< byte >( 0x2a ); + + // col: 8 offset: 002c + unknown2c = parser.ReadOffset< ushort >( 0x2c ); + + // col: 38 offset: 002e + unknown2e = parser.ReadOffset< ushort >( 0x2e ); + + // col: 53 offset: 0030 + unknown30 = parser.ReadOffset< ushort >( 0x30 ); + + // col: 23 offset: 0032 + unknown32 = parser.ReadOffset< byte >( 0x32 ); + + // col: 9 offset: 0034 + unknown34 = parser.ReadOffset< ushort >( 0x34 ); + + // col: 39 offset: 0036 + unknown36 = parser.ReadOffset< ushort >( 0x36 ); + + // col: 54 offset: 0038 + unknown38 = parser.ReadOffset< ushort >( 0x38 ); + + // col: 24 offset: 003a + unknown3a = parser.ReadOffset< byte >( 0x3a ); + + // col: 10 offset: 003c + unknown3c = parser.ReadOffset< ushort >( 0x3c ); + + // col: 40 offset: 003e + unknown3e = parser.ReadOffset< ushort >( 0x3e ); + + // col: 55 offset: 0040 + unknown40 = parser.ReadOffset< ushort >( 0x40 ); + + // col: 25 offset: 0042 + unknown42 = parser.ReadOffset< byte >( 0x42 ); + + // col: 11 offset: 0044 + unknown44 = parser.ReadOffset< ushort >( 0x44 ); + + // col: 41 offset: 0046 + unknown46 = parser.ReadOffset< ushort >( 0x46 ); + + // col: 56 offset: 0048 + unknown48 = parser.ReadOffset< ushort >( 0x48 ); + + // col: 26 offset: 004a + unknown4a = parser.ReadOffset< byte >( 0x4a ); + + // col: 12 offset: 004c + unknown4c = parser.ReadOffset< ushort >( 0x4c ); + + // col: 42 offset: 004e + unknown4e = parser.ReadOffset< ushort >( 0x4e ); + + // col: 57 offset: 0050 + unknown50 = parser.ReadOffset< ushort >( 0x50 ); + + // col: 27 offset: 0052 + unknown52 = parser.ReadOffset< byte >( 0x52 ); + + // col: 13 offset: 0054 + unknown54 = parser.ReadOffset< ushort >( 0x54 ); + + // col: 43 offset: 0056 + unknown56 = parser.ReadOffset< ushort >( 0x56 ); + + // col: 58 offset: 0058 + unknown58 = parser.ReadOffset< ushort >( 0x58 ); + + // col: 28 offset: 005a + unknown5a = parser.ReadOffset< byte >( 0x5a ); + + // col: 14 offset: 005c + unknown5c = parser.ReadOffset< ushort >( 0x5c ); + + // col: 44 offset: 005e + unknown5e = parser.ReadOffset< ushort >( 0x5e ); + + // col: 59 offset: 0060 + unknown60 = parser.ReadOffset< ushort >( 0x60 ); + + // col: 29 offset: 0062 + unknown62 = parser.ReadOffset< byte >( 0x62 ); + + // col: 15 offset: 0064 + unknown64 = parser.ReadOffset< ushort >( 0x64 ); + + // col: 45 offset: 0066 + unknown66 = parser.ReadOffset< ushort >( 0x66 ); + + // col: 60 offset: 0068 + unknown68 = parser.ReadOffset< ushort >( 0x68 ); + + // col: 30 offset: 006a + unknown6a = parser.ReadOffset< byte >( 0x6a ); + + // col: 16 offset: 006c + unknown6c = parser.ReadOffset< ushort >( 0x6c ); + + // col: 46 offset: 006e + unknown6e = parser.ReadOffset< ushort >( 0x6e ); + + // col: 61 offset: 0070 + unknown70 = parser.ReadOffset< ushort >( 0x70 ); + + // col: 31 offset: 0072 + unknown72 = parser.ReadOffset< byte >( 0x72 ); + + // col: 17 offset: 0074 + unknown74 = parser.ReadOffset< ushort >( 0x74 ); + + // col: 47 offset: 0076 + unknown76 = parser.ReadOffset< ushort >( 0x76 ); + + // col: 62 offset: 0078 + unknown78 = parser.ReadOffset< ushort >( 0x78 ); + + // col: 32 offset: 007a + unknown7a = parser.ReadOffset< byte >( 0x7a ); + + // col: 0 offset: 007c + unknown7c = parser.ReadOffset< float >( 0x7c ); + + // col: 1 offset: 0080 + unknown80 = parser.ReadOffset< ushort >( 0x80 ); + + + } + } +} diff --git a/Dalamud/DiscordBot/DiscordBotManager.cs b/Dalamud/DiscordBot/DiscordBotManager.cs index 6fec653ed..12c46d9ff 100644 --- a/Dalamud/DiscordBot/DiscordBotManager.cs +++ b/Dalamud/DiscordBot/DiscordBotManager.cs @@ -205,10 +205,8 @@ namespace Dalamud.DiscordBot { senderWorld = this.dalamud.ClientState.LocalPlayer.HomeWorld.GameData.Name; } else { - playerLink.Resolve(); - senderName = wasOutgoingTell ? this.dalamud.ClientState.LocalPlayer.Name : playerLink.PlayerName; - senderWorld = playerLink.ServerName; + senderWorld = playerLink.World.Name; } var rawMessage = SeString.Parse(message.RawData).TextValue; diff --git a/Dalamud/Game/Chat/SeStringHandling/ITextProvider.cs b/Dalamud/Game/Chat/SeStringHandling/ITextProvider.cs new file mode 100644 index 000000000..9604dbc04 --- /dev/null +++ b/Dalamud/Game/Chat/SeStringHandling/ITextProvider.cs @@ -0,0 +1,9 @@ +using System; + +namespace Dalamud.Game.Chat.SeStringHandling +{ + interface ITextProvider + { + string Text { get; } + } +} diff --git a/Dalamud/Game/Chat/SeStringHandling/Payload.cs b/Dalamud/Game/Chat/SeStringHandling/Payload.cs index 7b31a4565..e809244ec 100644 --- a/Dalamud/Game/Chat/SeStringHandling/Payload.cs +++ b/Dalamud/Game/Chat/SeStringHandling/Payload.cs @@ -2,15 +2,17 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using Dalamud.Data; using Dalamud.Game.Chat.SeStringHandling.Payloads; using Serilog; // TODOs: // - refactor integer handling now that we have multiple packed types -// - common construction/property design for subclasses -// - lumina DI -// - design for handling raw values vs resolved values, both for input and output -// - wrapper class(es) for handling of composite links in chat (item, map etc) and formatting operations +// Maybes: +// - convert parsing to custom structs for each payload? would make some code prettier and easier to work with +// but also wouldn't work out as well for things that are dynamically-sized +// - [SeString] some way to add surrounding formatting information as flags/data to text (or other?) payloads? +// eg, if a text payload is surrounded by italics payloads, strip them out and mark the text payload as italicized namespace Dalamud.Game.Chat.SeStringHandling { @@ -19,33 +21,107 @@ namespace Dalamud.Game.Chat.SeStringHandling /// public abstract class Payload { + /// + /// The type of this payload. + /// public abstract PayloadType Type { get; } - public abstract void Resolve(); + /// + /// Whether this payload has been modified since the last Encode(). + /// + public bool Dirty { get; protected set; } = true; - public abstract byte[] Encode(); + /// + /// Encodes the internal state of this payload into a byte[] suitable for sending to in-game + /// handlers such as the chat log. + /// + /// Encoded binary payload data suitable for use with in-game handlers. + protected abstract byte[] EncodeImpl(); - protected abstract void ProcessChunkImpl(BinaryReader reader, long endOfStream); + // TODO: endOfStream is somewhat legacy now that payload length is always handled correctly. + // This could be changed to just take a straight byte[], but that would complicate reading + // but we could probably at least remove the end param + /// + /// Decodes a byte stream from the game into a payload object. + /// + /// A BinaryReader containing at least all the data for this payload. + /// The location holding the end of the data for this payload. + protected abstract void DecodeImpl(BinaryReader reader, long endOfStream); - public static Payload Process(BinaryReader reader) + /// + /// The Lumina instance to use for any necessary data lookups. + /// + protected DataManager dataResolver; + + // private for now, since subclasses shouldn't interact with this + // To force-invalidate it, Dirty can be set to true + private byte[] encodedData; + + protected Payload() { + // this is not a good way to do this, but I don't want to have to include a dalamud + // reference on multiple methods in every payload class + // We could also just directly reference this static where we use it, but this at least + // allows for more easily changing how this is injected later, without affecting code + // that makes use of it + this.dataResolver = SeString.Dalamud.Data; + } + + /// + /// Encode this payload object into a byte[] useable in-game for things like the chat log. + /// + /// If true, ignores any cached value and forcibly reencodes the payload from its internal representation. + /// A byte[] suitable for use with in-game handlers such as the chat log. + public byte[] Encode(bool force = false) + { + if (Dirty || force) + { + this.encodedData = EncodeImpl(); + Dirty = false; + } + + return this.encodedData; + } + + /// + /// Decodes a binary representation of a payload into its corresponding nice object payload. + /// + /// A reader positioned at the start of the payload, and containing at least one entire payload. + /// The constructed Payload-derived object that was decoded from the binary data. + public static Payload Decode(BinaryReader reader) + { + var payloadStartPos = reader.BaseStream.Position; + Payload payload = null; var initialByte = reader.ReadByte(); reader.BaseStream.Position--; if (initialByte != START_BYTE) { - payload = ProcessText(reader); + payload = DecodeText(reader); } else { - payload = ProcessChunk(reader); + payload = DecodeChunk(reader); } + // for now, cache off the actual binary data for this payload, so we don't have to + // regenerate it if the payload isn't modified + // TODO: probably better ways to handle this + var payloadEndPos = reader.BaseStream.Position; + + reader.BaseStream.Position = payloadStartPos; + payload.encodedData = reader.ReadBytes((int)(payloadEndPos - payloadStartPos)); + payload.Dirty = false; + + // Log.Verbose($"got payload bytes {BitConverter.ToString(payload.encodedData).Replace("-", " ")}"); + + reader.BaseStream.Position = payloadEndPos; + return payload; } - private static Payload ProcessChunk(BinaryReader reader) + private static Payload DecodeChunk(BinaryReader reader) { Payload payload = null; @@ -55,8 +131,13 @@ namespace Dalamud.Game.Chat.SeStringHandling var packetStart = reader.BaseStream.Position; + // any unhandled payload types will be turned into a RawPayload with the exact same binary data switch (chunkType) { + case SeStringChunkType.EmphasisItalic: + payload = new EmphasisItalicPayload(); + break; + case SeStringChunkType.Interactable: { var subType = (EmbeddedInfoType)reader.ReadByte(); @@ -81,10 +162,13 @@ namespace Dalamud.Game.Chat.SeStringHandling case EmbeddedInfoType.LinkTerminator: // this has no custom handling and so needs to fallthrough to ensure it is captured default: - Log.Verbose("Unhandled EmbeddedInfoType: {0}", subType); + // but I'm also tired of this log + if (subType != EmbeddedInfoType.LinkTerminator) + { + Log.Verbose("Unhandled EmbeddedInfoType: {0}", subType); + } // rewind so we capture the Interactable byte in the raw data reader.BaseStream.Seek(-1, SeekOrigin.Current); - payload = new RawPayload((byte)chunkType); break; } } @@ -104,11 +188,11 @@ namespace Dalamud.Game.Chat.SeStringHandling default: Log.Verbose("Unhandled SeStringChunkType: {0}", chunkType); - payload = new RawPayload((byte)chunkType); break; } - payload?.ProcessChunkImpl(reader, reader.BaseStream.Position + chunkLen - 1); + payload ??= new RawPayload((byte)chunkType); + payload.DecodeImpl(reader, reader.BaseStream.Position + chunkLen - 1); // read through the rest of the packet var readBytes = (uint)(reader.BaseStream.Position - packetStart); @@ -117,10 +201,10 @@ namespace Dalamud.Game.Chat.SeStringHandling return payload; } - private static Payload ProcessText(BinaryReader reader) + private static Payload DecodeText(BinaryReader reader) { var payload = new TextPayload(); - payload.ProcessChunkImpl(reader, reader.BaseStream.Length); + payload.DecodeImpl(reader, reader.BaseStream.Length); return payload; } @@ -132,6 +216,7 @@ namespace Dalamud.Game.Chat.SeStringHandling protected enum SeStringChunkType { + EmphasisItalic = 0x1A, Interactable = 0x27, AutoTranslateKey = 0x2E, UIForeground = 0x48, @@ -148,6 +233,10 @@ namespace Dalamud.Game.Chat.SeStringHandling LinkTerminator = 0xCF // not clear but seems to always follow a link } + + // TODO - everything below needs to be completely refactored, now that we have run into + // a lot more cases than were originally handled. + protected enum IntegerType { // used as an internal marker; sometimes single bytes are bare with no marker at all @@ -158,8 +247,8 @@ namespace Dalamud.Game.Chat.SeStringHandling Int16 = 0xF2, Int16Packed = 0xF4, // seen in map links, seemingly 2 8-bit values packed into 2 bytes with only one marker Int24Special = 0xF6, // unsure how different form Int24 - used for hq items that add 1 million, also used for normal 24-bit values in map links - Int24Packed = 0xFC, // used in map links- sometimes short+byte, sometimes... not?? Int24 = 0xFA, + Int24Packed = 0xFC, // used in map links- sometimes short+byte, sometimes... not?? Int32 = 0xFE } diff --git a/Dalamud/Game/Chat/SeStringHandling/PayloadType.cs b/Dalamud/Game/Chat/SeStringHandling/PayloadType.cs index 92e8afa44..fe379ffd2 100644 --- a/Dalamud/Game/Chat/SeStringHandling/PayloadType.cs +++ b/Dalamud/Game/Chat/SeStringHandling/PayloadType.cs @@ -39,6 +39,10 @@ namespace Dalamud.Game.Chat.SeStringHandling /// AutoTranslateText, /// + /// An SeString payload representing italic emphasis formatting on text. + /// + EmphasisItalic, + /// /// An SeString payload representing any data we don't handle. /// Unknown diff --git a/Dalamud/Game/Chat/SeStringHandling/Payloads/AutoTranslatePayload.cs b/Dalamud/Game/Chat/SeStringHandling/Payloads/AutoTranslatePayload.cs index 87b25aa45..97e925b11 100644 --- a/Dalamud/Game/Chat/SeStringHandling/Payloads/AutoTranslatePayload.cs +++ b/Dalamud/Game/Chat/SeStringHandling/Payloads/AutoTranslatePayload.cs @@ -1,86 +1,75 @@ +using Dalamud.Data.TransientSheet; +using Lumina.Excel.GeneratedSheets; +using Serilog; using System; using System.Collections.Generic; using System.IO; - +using System.Linq; namespace Dalamud.Game.Chat.SeStringHandling.Payloads { - public class AutoTranslatePayload : Payload + /// + /// An SeString Payload containing an auto-translation/completion chat message. + /// + public class AutoTranslatePayload : Payload, ITextProvider { public override PayloadType Type => PayloadType.AutoTranslateText; - public uint Group { get; set; } - - public uint Key { get; set; } - - public string Text { get; set; } - - public override void Resolve() + private string text; + /// + /// The actual text displayed in-game for this payload. + /// + /// + /// Value is evaluated lazily and cached. + /// + public string Text { - // TODO: fixup once lumina DI is in - - //if (string.IsNullOrEmpty(Text)) - //{ - // var sheet = dalamud.Data.GetExcelSheet(); - - // Completion row = null; - // try - // { - // // try to get the row in the Completion table itself, because this is 'easiest' - // // The row may not exist at all (if the Key is for another table), or it could be the wrong row - // // (again, if it's meant for another table) - // row = sheet.GetRow(Key); - // } - // catch {} // don't care, row will be null - - // if (row?.Group == Group) - // { - // // if the row exists in this table and the group matches, this is actually the correct data - // Text = $"{{ {row.Text} }} "; - // } - // else - // { - // Log.Verbose("row mismatch"); - // try - // { - // // we need to get the linked table and do the lookup there instead - // // in this case, there will only be one entry for this group id - // row = sheet.GetRows().First(r => r.Group == Group); - // // many of the names contain valid id ranges after the table name, but we don't need those - // var actualTableName = row.LookupTable.Split('[')[0]; - - // var name = actualTableName switch - // { - // // TODO: rest of xref'd tables - // "Action" => dalamud.Data.GetExcelSheet().GetRow(Key).Name, - // "ClassJob" => dalamud.Data.GetExcelSheet().GetRow(Key).Name, - // "CraftAction" => dalamud.Data.GetExcelSheet().GetRow(Key).Name, - // "Mount" => dalamud.Data.GetExcelSheet().GetRow(Key).Singular, - // "PlaceName" => dalamud.Data.GetExcelSheet().GetRow(Key).Name, - // "Race" => dalamud.Data.GetExcelSheet().GetRow(Key).Masculine, - // _ => throw new Exception(actualTableName) - // }; - - // Text = $"{{ {name} }} "; - // } - // catch (Exception e) - // { - // Log.Error(e, $"AutoTranslatePayload - failed to resolve: {this}"); - // } - // } - //} + get + { + // wrap the text in the colored brackets that is uses in-game, since those + // are not actually part of any of the payloads + this.text ??= $"{(char)SeIconChar.AutoTranslateOpen} {Resolve()} {(char)SeIconChar.AutoTranslateClose}"; + return this.text; + } } - public override byte[] Encode() + private uint group; + private uint key; + + internal AutoTranslatePayload() { } + + /// + /// Creates a new auto-translate payload. + /// + /// The group id for this message. + /// The key/row id for this message. Which table this is in depends on the group id and details the Completion table. + /// + /// This table is somewhat complicated in structure, and so using this constructor may not be very nice. + /// There is probably little use to create one of these, however. + /// + public AutoTranslatePayload(uint group, uint key) { - var keyBytes = MakeInteger(Key); + this.group = group; + this.key = key; + } + + // TODO: friendlier ctor? not sure how to handle that given how weird the tables are + + public override string ToString() + { + return $"{Type} - Group: {group}, Key: {key}, Text: {Text}"; + } + + protected override byte[] EncodeImpl() + { + var keyBytes = MakeInteger(this.key); var chunkLen = keyBytes.Length + 2; var bytes = new List() { START_BYTE, (byte)SeStringChunkType.AutoTranslateKey, (byte)chunkLen, - (byte)Group + (byte)this.group }; bytes.AddRange(keyBytes); bytes.Add(END_BYTE); @@ -88,18 +77,80 @@ namespace Dalamud.Game.Chat.SeStringHandling.Payloads return bytes.ToArray(); } - public override string ToString() - { - return $"{Type} - Group: {Group}, Key: {Key}, Text: {Text}"; - } - - protected override void ProcessChunkImpl(BinaryReader reader, long endOfStream) + protected override void DecodeImpl(BinaryReader reader, long endOfStream) { // this seems to always be a bare byte, and not following normal integer encoding // the values in the table are all <70 so this is presumably ok - Group = reader.ReadByte(); + this.group = reader.ReadByte(); - Key = GetInteger(reader); + this.key = GetInteger(reader); + } + + private string Resolve() + { + string value = null; + + var sheet = this.dataResolver.GetExcelSheet(); + + Completion row = null; + try + { + // try to get the row in the Completion table itself, because this is 'easiest' + // The row may not exist at all (if the Key is for another table), or it could be the wrong row + // (again, if it's meant for another table) + row = sheet.GetRow((int)this.key); + } + catch { } // don't care, row will be null + + if (row?.Group == this.group) + { + // if the row exists in this table and the group matches, this is actually the correct data + value = row.Text; + } + else + { + try + { + // we need to get the linked table and do the lookup there instead + // in this case, there will only be one entry for this group id + row = sheet.GetRows().First(r => r.Group == this.group); + // many of the names contain valid id ranges after the table name, but we don't need those + var actualTableName = row.LookupTable.Split('[')[0]; + + var ikey = (int)this.key; + + var name = actualTableName switch + { + "Action" => this.dataResolver.GetExcelSheet().GetRow(ikey).Name, + "ActionComboRoute" => this.dataResolver.GetExcelSheet().GetRow(ikey).Name, + "BuddyAction" => this.dataResolver.GetExcelSheet().GetRow(ikey).Name, + "ClassJob" => this.dataResolver.GetExcelSheet().GetRow(ikey).Name, + "Companion" => this.dataResolver.GetExcelSheet().GetRow(ikey).Singular, + "CraftAction" => this.dataResolver.GetExcelSheet().GetRow(ikey).Name, + "GeneralAction" => this.dataResolver.GetExcelSheet().GetRow(ikey).Name, + "GuardianDeity" => this.dataResolver.GetExcelSheet().GetRow(ikey).Name, + "MainCommand" => this.dataResolver.GetExcelSheet().GetRow(ikey).Name, + "Mount" => this.dataResolver.GetExcelSheet().GetRow(ikey).Singular, + "Pet" => this.dataResolver.GetExcelSheet().GetRow(ikey).Name, + "PetAction" => this.dataResolver.GetExcelSheet().GetRow(ikey).Name, + "PetMirage" => this.dataResolver.GetExcelSheet().GetRow(ikey).Name, + "PlaceName" => this.dataResolver.GetExcelSheet().GetRow(ikey).Name, + "Race" => this.dataResolver.GetExcelSheet().GetRow(ikey).Masculine, + "TextCommand" => this.dataResolver.GetExcelSheet().GetRow(ikey).Command, + "Tribe" => this.dataResolver.GetExcelSheet().GetRow(ikey).Masculine, + "Weather" => this.dataResolver.GetExcelSheet().GetRow(ikey).Name, + _ => throw new Exception(actualTableName) + }; + + value = name; + } + catch (Exception e) + { + Log.Error(e, $"AutoTranslatePayload - failed to resolve: {this}"); + } + } + + return value; } } } diff --git a/Dalamud/Game/Chat/SeStringHandling/Payloads/EmphasisItalicPayload.cs b/Dalamud/Game/Chat/SeStringHandling/Payloads/EmphasisItalicPayload.cs new file mode 100644 index 000000000..341439edb --- /dev/null +++ b/Dalamud/Game/Chat/SeStringHandling/Payloads/EmphasisItalicPayload.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace Dalamud.Game.Chat.SeStringHandling.Payloads +{ + /// + /// An SeString Payload containing information about enabling or disabling italics formatting on following text. + /// + /// + /// As with other formatting payloads, this is only useful in a payload block, where it affects any subsequent + /// text payloads. + /// + public class EmphasisItalicPayload : Payload + { + /// + /// Payload representing enabling italics on following text. + /// + public static EmphasisItalicPayload ItalicsOn => new EmphasisItalicPayload(true); + /// + /// Payload representing disabling italics on following text. + /// + public static EmphasisItalicPayload ItalicsOff => new EmphasisItalicPayload(false); + + public override PayloadType Type => PayloadType.EmphasisItalic; + + /// + /// Whether this payload enables italics formatting for following text. + /// + public bool IsEnabled { get; private set; } + + internal EmphasisItalicPayload() { } + + /// + /// Creates an EmphasisItalicPayload. + /// + /// Whether italics formatting should be enabled or disabled for following text. + public EmphasisItalicPayload(bool enabled) + { + IsEnabled = enabled; + } + + public override string ToString() + { + return $"{Type} - Enabled: {IsEnabled}"; + } + + protected override byte[] EncodeImpl() + { + // realistically this will always be a single byte of value 1 or 2 + // but we'll treat it normally anyway + var enabledBytes = MakeInteger(IsEnabled ? (uint)1 : 0); + + var chunkLen = enabledBytes.Length + 1; + var bytes = new List() + { + START_BYTE, (byte)SeStringChunkType.EmphasisItalic, (byte)chunkLen + }; + bytes.AddRange(enabledBytes); + bytes.Add(END_BYTE); + + return bytes.ToArray(); + } + + protected override void DecodeImpl(BinaryReader reader, long endOfStream) + { + IsEnabled = (GetInteger(reader) == 1); + } + } +} diff --git a/Dalamud/Game/Chat/SeStringHandling/Payloads/ItemPayload.cs b/Dalamud/Game/Chat/SeStringHandling/Payloads/ItemPayload.cs index bc58d488e..87b3ab87d 100644 --- a/Dalamud/Game/Chat/SeStringHandling/Payloads/ItemPayload.cs +++ b/Dalamud/Game/Chat/SeStringHandling/Payloads/ItemPayload.cs @@ -1,48 +1,98 @@ +using Dalamud.Data.TransientSheet; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; -using System.Threading.Tasks; namespace Dalamud.Game.Chat.SeStringHandling.Payloads { + /// + /// An SeString Payload representing an interactable item link. + /// public class ItemPayload : Payload { public override PayloadType Type => PayloadType.Item; - public uint ItemId { get; private set; } - public string ItemName { get; private set; } = string.Empty; - public bool IsHQ { get; private set; } = false; - - public ItemPayload() { } - - public ItemPayload(uint itemId, bool isHQ) + private Item item; + /// + /// The underlying Lumina Item represented by this payload. + /// + /// + /// Value is evaluated lazily and cached. + /// + public Item Item { - ItemId = itemId; - IsHQ = isHQ; - } - - public override void Resolve() - { - if (string.IsNullOrEmpty(ItemName)) + get { - dynamic item = XivApi.GetItem((int)ItemId).GetAwaiter().GetResult(); - ItemName = item.Name; + this.item ??= this.dataResolver.GetExcelSheet().GetRow((int)this.itemId); + return this.item; } } - public override byte[] Encode() + // mainly to allow overriding the name (for things like owo) + // TODO: even though this is present in some item links, it may not really have a use at all + // For things like owo, changing the text payload is probably correct, whereas changing the + // actual embedded name might not work properly. + private string displayName = null; + /// + /// The displayed name for this item link. Note that incoming links only sometimes have names embedded, + /// often the name is only present in a following text payload. + /// + public string DisplayName { - var actualItemId = IsHQ ? ItemId + 1000000 : ItemId; + get + { + return this.displayName; + } + + set + { + this.displayName = value; + Dirty = true; + } + } + + /// + /// Whether or not this item link is for a high-quality version of the item. + /// + public bool IsHQ { get; private set; } = false; + + private uint itemId; + + internal ItemPayload() { } + + /// + /// Creates a payload representing an interactable item link for the specified item. + /// + /// The id of the item. + /// Whether or not the link should be for the high-quality variant of the item. + /// An optional name to include in the item link. Typically this should + /// be left as null, or set to the normal item name. Actual overrides are better done with the subsequent + /// TextPayload that is a part of a full item link in chat. + public ItemPayload(uint itemId, bool isHQ, string displayNameOverride = null) + { + this.itemId = itemId; + this.IsHQ = isHQ; + this.displayName = displayNameOverride; + } + + public override string ToString() + { + return $"{Type} - ItemId: {itemId}, IsHQ: {IsHQ}, Name: {this.displayName ?? Item.Name}"; + } + + protected override byte[] EncodeImpl() + { + var actualItemId = IsHQ ? this.itemId + 1000000 : this.itemId; var idBytes = MakeInteger(actualItemId); - bool hasName = !string.IsNullOrEmpty(ItemName); + bool hasName = !string.IsNullOrEmpty(this.displayName); var chunkLen = idBytes.Length + 4; if (hasName) { // 1 additional unknown byte compared to the nameless version, 1 byte for the name length, and then the name itself - chunkLen += (1 + 1 + ItemName.Length); + chunkLen += (1 + 1 + this.displayName.Length); if (IsHQ) { chunkLen += 4; // unicode representation of the HQ symbol is 3 bytes, preceded by a space @@ -61,7 +111,7 @@ namespace Dalamud.Game.Chat.SeStringHandling.Payloads // Links don't have to include the name, but if they do, it requires additional work if (hasName) { - var nameLen = ItemName.Length + 1; + var nameLen = this.displayName.Length + 1; if (IsHQ) { nameLen += 4; // space plus 3 bytes for HQ symbol @@ -72,7 +122,7 @@ namespace Dalamud.Game.Chat.SeStringHandling.Payloads 0xFF, // unk (byte)nameLen }); - bytes.AddRange(Encoding.UTF8.GetBytes(ItemName)); + bytes.AddRange(Encoding.UTF8.GetBytes(this.displayName)); if (IsHQ) { @@ -86,18 +136,13 @@ namespace Dalamud.Game.Chat.SeStringHandling.Payloads return bytes.ToArray(); } - public override string ToString() + protected override void DecodeImpl(BinaryReader reader, long endOfStream) { - return $"{Type} - ItemId: {ItemId}, ItemName: {ItemName}, IsHQ: {IsHQ}"; - } + this.itemId = GetInteger(reader); - protected override void ProcessChunkImpl(BinaryReader reader, long endOfStream) - { - ItemId = GetInteger(reader); - - if (ItemId > 1000000) + if (this.itemId > 1000000) { - ItemId -= 1000000; + this.itemId -= 1000000; IsHQ = true; } @@ -109,6 +154,11 @@ namespace Dalamud.Game.Chat.SeStringHandling.Payloads var itemNameLen = (int)GetInteger(reader); var itemNameBytes = reader.ReadBytes(itemNameLen); + // it probably isn't necessary to store this, as we now get the lumina Item + // on demand from the id, which will have the name + // For incoming links, the name "should?" always match + // but we'll store it for use in encode just in case it doesn't + // HQ items have the HQ symbol as part of the name, but since we already recorded // the HQ flag, we want just the bare name if (IsHQ) @@ -116,10 +166,28 @@ namespace Dalamud.Game.Chat.SeStringHandling.Payloads itemNameBytes = itemNameBytes.Take(itemNameLen - 4).ToArray(); } - ItemName = Encoding.UTF8.GetString(itemNameBytes); + this.displayName = Encoding.UTF8.GetString(itemNameBytes); } } + protected override byte[] MakeInteger(uint value, bool withMarker = true, bool incrementSmallInts = true) + { + // TODO: as part of refactor + + // linking an item id that is a multiple of 256 seemingly *requires* using byte*256 marker encoding + // or the link will not display correctly + // I am unsure if this applies to other data types as well, so keeping localized here, pending the + // refactor of all this integer handling mess + if (value % 256 == 0) + { + value /= 256; + // this is no longer a small int, but it was likely converted to that range + incrementSmallInts = false; + } + + return base.MakeInteger(value, withMarker, incrementSmallInts); + } + protected override byte GetMarkerForIntegerBytes(byte[] bytes) { // custom marker just for hq items? @@ -128,6 +196,12 @@ namespace Dalamud.Game.Chat.SeStringHandling.Payloads return (byte)IntegerType.Int24Special; } + // TODO: as in the above function + if (bytes.Length == 1 && (this.itemId % 256 == 0)) + { + return (byte)IntegerType.ByteTimes256; + } + return base.GetMarkerForIntegerBytes(bytes); } } diff --git a/Dalamud/Game/Chat/SeStringHandling/Payloads/MapLinkPayload.cs b/Dalamud/Game/Chat/SeStringHandling/Payloads/MapLinkPayload.cs index f422133d8..e73eb8393 100644 --- a/Dalamud/Game/Chat/SeStringHandling/Payloads/MapLinkPayload.cs +++ b/Dalamud/Game/Chat/SeStringHandling/Payloads/MapLinkPayload.cs @@ -5,32 +5,179 @@ using System.IO; namespace Dalamud.Game.Chat.SeStringHandling.Payloads { + /// + /// An SeString Payload representing an interactable map position link. + /// public class MapLinkPayload : Payload { public override PayloadType Type => PayloadType.MapLink; - // pre-Resolve() values - public uint TerritoryTypeId { get; set; } - public uint MapId { get; set; } - public uint RawX { get; set; } - public uint RawY { get; set; } + private Map map; + /// + /// The Map specified for this map link. + /// + /// + /// Value is evaluated lazily and cached. + /// + public Map Map + { + get + { + this.map ??= this.dataResolver.GetExcelSheet().GetRow((int)this.mapId); + return this.map; + } + } - // Resolved values - // It might make sense to have Territory be an external type, that has assorted relevant info - public string Territory { get; private set; } - public string Zone { get; private set; } - public float XCoord { get; private set; } - public float YCoord { get; private set; } + private TerritoryType territoryType; + /// + /// The TerritoryType specified for this map link. + /// + /// + /// Value is evaluated lazily and cached. + /// + public TerritoryType TerritoryType + { + get + { + this.territoryType ??= this.dataResolver.GetExcelSheet().GetRow((int)this.territoryTypeId); + return this.territoryType; + } + } + + /// + /// The internal x-coordinate for this map position. + /// + public int RawX { get; private set; } + + /// + /// The internal y-coordinate for this map position. + /// + public int RawY { get; private set; } + + // these could be cached, but this isn't really too egregious + /// + /// The readable x-coordinate position for this map link. This value is approximate and unrounded. + /// + public float XCoord + { + get + { + return ConvertRawPositionToMapCoordinate(RawX, Map.SizeFactor); + } + } + + /// + /// The readable y-coordinate position for this map link. This value is approximate and unrounded. + /// + public float YCoord + { + get + { + return ConvertRawPositionToMapCoordinate(RawY, Map.SizeFactor); + } + } + + /// + /// The printable map coordinates for this link. This value tries to match the in-game printable text as closely as possible + /// but is an approximation and may be slightly off for some positions. + /// + public string CoordinateString + { + get + { + // this truncates the values to one decimal without rounding, which is what the game does + // the fudge also just attempts to correct the truncated/displayed value for rounding/fp issues + // TODO: should this fudge factor be the same as in the ctor? currently not since that is customizable + const float fudge = 0.02f; + var x = Math.Truncate((XCoord+fudge) * 10.0f) / 10.0f; + var y = Math.Truncate((YCoord+fudge) * 10.0f) / 10.0f; + + // the formatting and spacing the game uses + return $"( {x.ToString("0.0")} , {y.ToString("0.0")} )"; + } + } + + private string placeNameRegion; + /// + /// The region name for this map link. This corresponds to the upper zone name found in the actual in-game map UI. eg, "La Noscea" + /// + public string PlaceNameRegion + { + get + { + this.placeNameRegion ??= this.dataResolver.GetExcelSheet().GetRow(TerritoryType.PlaceNameRegion).Name; + return this.placeNameRegion; + } + } + + private string placeName; + /// + /// The place name for this map link. This corresponds to the lower zone name found in the actual in-game map UI. eg, "Limsa Lominsa Upper Decks" + /// + public string PlaceName + { + get + { + this.placeName ??= this.dataResolver.GetExcelSheet().GetRow(TerritoryType.PlaceName).Name; + return this.placeName; + } + } + + /// + /// The data string for this map link, for use by internal game functions that take a string variant and not a binary payload. + /// + public string DataString => $"m:{TerritoryType.RowId},{Map.RowId},{RawX},{RawY}"; + + private uint territoryTypeId; + private uint mapId; // there is no Z; it's purely in the text payload where applicable - public override byte[] Encode() - { - // TODO: for now we just encode the raw/internal values - // eventually we should allow creation using 'nice' values that then encode properly + internal MapLinkPayload() { } - var packedTerritoryAndMapBytes = MakePackedInteger(TerritoryTypeId, MapId); - var xBytes = MakeInteger(RawX); - var yBytes = MakeInteger(RawY); + /// + /// Creates an interactable MapLinkPayload from a human-readable position. + /// + /// The id of the TerritoryType entry for this link. + /// The id of the Map entry for 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. + public MapLinkPayload(uint territoryTypeId, uint mapId, float niceXCoord, float niceYCoord, float fudgeFactor = 0.05f) + { + this.territoryTypeId = territoryTypeId; + this.mapId = mapId; + // this fudge is necessary basically to ensure we don't shift down a full tenth + // because essentially values are truncated instead of rounded, so 3.09999f will become + // 3.0f and not 3.1f + RawX = this.ConvertMapCoordinateToRawPosition(niceXCoord + fudgeFactor, Map.SizeFactor); + RawY = this.ConvertMapCoordinateToRawPosition(niceYCoord + fudgeFactor, Map.SizeFactor); + } + + /// + /// Creates an interactable MapLinkPayload from a raw position. + /// + /// The id of the TerritoryType entry for this link. + /// The id of the Map entry for this link. + /// The internal raw x-coordinate for this link. + /// The internal raw y-coordinate for this link. + public MapLinkPayload(uint territoryTypeId, uint mapId, int rawX, int rawY) + { + this.territoryTypeId = territoryTypeId; + this.mapId = mapId; + RawX = rawX; + RawY = rawY; + } + + public override string ToString() + { + return $"{Type} - TerritoryTypeId: {territoryTypeId}, MapId: {mapId}, RawX: {RawX}, RawY: {RawY}, display: {PlaceName} {CoordinateString}"; + } + + protected override byte[] EncodeImpl() + { + var packedTerritoryAndMapBytes = MakePackedInteger(this.territoryTypeId, this.mapId); + var xBytes = MakeInteger(unchecked((uint)RawX)); + var yBytes = MakeInteger(unchecked((uint)RawY)); var chunkLen = 4 + packedTerritoryAndMapBytes.Length + xBytes.Length + yBytes.Length; @@ -50,27 +197,7 @@ namespace Dalamud.Game.Chat.SeStringHandling.Payloads return bytes.ToArray(); } - public override void Resolve() - { - // TODO: add once lumina DI is figured out - //if (string.IsNullOrEmpty(Territory)) - //{ - // var terrRow = dataResolver.GetExcelSheet().GetRow((int)TerritoryTypeId); - // Territory = dataResolver.GetExcelSheet().GetRow(terrRow.PlaceName).Name; - // Zone = dataResolver.GetExcelSheet().GetRow(terrRow.PlaceNameZone).Name; - - // var mapSizeFactor = dataResolver.GetExcelSheet().GetRow((int)MapId).SizeFactor; - // XCoord = ConvertRawPositionToMapCoordinate(RawX, mapSizeFactor); - // YCoord = ConvertRawPositionToMapCoordinate(RawY, mapSizeFactor); - //} - } - - public override string ToString() - { - return $"{Type} - TerritoryTypeId: {TerritoryTypeId}, MapId: {MapId}, RawX: {RawX}, RawY: {RawY}"; - } - - protected override void ProcessChunkImpl(BinaryReader reader, long endOfStream) + protected override void DecodeImpl(BinaryReader reader, long endOfStream) { // for debugging for now var oldPos = reader.BaseStream.Position; @@ -79,9 +206,9 @@ namespace Dalamud.Game.Chat.SeStringHandling.Payloads try { - (TerritoryTypeId, MapId) = GetPackedIntegers(reader); - RawX = (uint)GetInteger(reader); - RawY = (uint)GetInteger(reader); + (this.territoryTypeId, this.mapId) = GetPackedIntegers(reader); + RawX = unchecked((int)GetInteger(reader)); + RawY = unchecked((int)GetInteger(reader)); // the Z coordinate is never in this chunk, just the text (if applicable) // seems to always be FF 01 @@ -98,23 +225,23 @@ namespace Dalamud.Game.Chat.SeStringHandling.Payloads #region ugliness // from https://github.com/xivapi/ffxiv-datamining/blob/master/docs/MapCoordinates.md // extra 1/1000 because that is how the network ints are done - private float ConvertRawPositionToMapCoordinate(uint pos, float scale) + private float ConvertRawPositionToMapCoordinate(int pos, float scale) { var c = scale / 100.0f; - var scaledPos = (int)pos * c / 1000.0f; + var scaledPos = pos * c / 1000.0f; return ((41.0f / c) * ((scaledPos + 1024.0f) / 2048.0f)) + 1.0f; } // Created as the inverse of ConvertRawPositionToMapCoordinate(), since no one seemed to have a version of that - private float ConvertMapCoordinateToRawPosition(float pos, float scale) + private int ConvertMapCoordinateToRawPosition(float pos, float scale) { var c = scale / 100.0f; var scaledPos = ((((pos - 1.0f) * c / 41.0f) * 2048.0f) - 1024.0f) / c; scaledPos *= 1000.0f; - return (int)Math.Round(scaledPos); + return (int)scaledPos; } #endregion diff --git a/Dalamud/Game/Chat/SeStringHandling/Payloads/PlayerPayload.cs b/Dalamud/Game/Chat/SeStringHandling/Payloads/PlayerPayload.cs index 16dd6ab45..d2c0cbfb8 100644 --- a/Dalamud/Game/Chat/SeStringHandling/Payloads/PlayerPayload.cs +++ b/Dalamud/Game/Chat/SeStringHandling/Payloads/PlayerPayload.cs @@ -1,56 +1,95 @@ +using Lumina.Excel.GeneratedSheets; using System; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Text; -using System.Threading.Tasks; namespace Dalamud.Game.Chat.SeStringHandling.Payloads { + /// + /// An SeString Payload representing a player link. + /// public class PlayerPayload : Payload { public override PayloadType Type => PayloadType.Player; - public string PlayerName { get; private set; } - public uint ServerId { get; private set; } - public string ServerName { get; private set; } = String.Empty; - - public PlayerPayload() { } - - public PlayerPayload(string playerName, uint serverId) + private string playerName; + /// + /// The player's displayed name. This does not contain the server name. + /// + public string PlayerName { - PlayerName = playerName; - ServerId = serverId; - } - - public override void Resolve() - { - if (string.IsNullOrEmpty(ServerName)) + get { return this.playerName; } + set { - dynamic server = XivApi.Get($"World/{ServerId}").GetAwaiter().GetResult(); - ServerName = server.Name; + this.playerName = value; + Dirty = true; } } - public override byte[] Encode() + private World world; + /// + /// The Lumina object representing the player's home server. + /// + /// + /// Value is evaluated lazily and cached. + /// + public World World { - var chunkLen = PlayerName.Length + 7; + get + { + this.world ??= this.dataResolver.GetExcelSheet().GetRow((int)this.serverId); + return this.world; + } + } + + /// + /// A text representation of this player link matching how it might appear in-game. + /// The world name will always be present. + /// + public string DisplayedName => $"{PlayerName}{(char)SeIconChar.CrossWorld}{World.Name}"; + + private uint serverId; + + internal PlayerPayload() { } + + /// + /// Create a PlayerPayload link for the specified player. + /// + /// The player's displayed name. + /// The player's home server id. + public PlayerPayload(string playerName, uint serverId) + { + this.playerName = playerName; + this.serverId = serverId; + } + + public override string ToString() + { + return $"{Type} - PlayerName: {PlayerName}, ServerId: {serverId}, ServerName: {World.Name}"; + } + + protected override byte[] EncodeImpl() + { + var chunkLen = this.playerName.Length + 7; var bytes = new List() { START_BYTE, (byte)SeStringChunkType.Interactable, (byte)chunkLen, (byte)EmbeddedInfoType.PlayerName, /* unk */ 0x01, - (byte)(ServerId+1), // I didn't want to deal with single-byte values in MakeInteger, so we have to do the +1 manually + (byte)(this.serverId+1), // I didn't want to deal with single-byte values in MakeInteger, so we have to do the +1 manually /* unk */0x01, /* unk */0xFF, // these sometimes vary but are frequently this - (byte)(PlayerName.Length+1) + (byte)(this.playerName.Length+1) }; - bytes.AddRange(Encoding.UTF8.GetBytes(PlayerName)); + bytes.AddRange(Encoding.UTF8.GetBytes(this.playerName)); bytes.Add(END_BYTE); + // TODO: should these really be here? additional payloads should come in separately already... + // encoded names are followed by the name in plain text again // use the payload parsing for consistency, as this is technically a new chunk - bytes.AddRange(new TextPayload(PlayerName).Encode()); + bytes.AddRange(new TextPayload(playerName).Encode()); // unsure about this entire packet, but it seems to always follow a name bytes.AddRange(new byte[] @@ -63,23 +102,18 @@ namespace Dalamud.Game.Chat.SeStringHandling.Payloads return bytes.ToArray(); } - public override string ToString() - { - return $"{Type} - PlayerName: {PlayerName}, ServerId: {ServerId}, ServerName: {ServerName}"; - } - - protected override void ProcessChunkImpl(BinaryReader reader, long endOfStream) + protected override void DecodeImpl(BinaryReader reader, long endOfStream) { // unk reader.ReadByte(); - ServerId = GetInteger(reader); + this.serverId = GetInteger(reader); // unk reader.ReadBytes(2); var nameLen = (int)GetInteger(reader); - PlayerName = Encoding.UTF8.GetString(reader.ReadBytes(nameLen)); + this.playerName = Encoding.UTF8.GetString(reader.ReadBytes(nameLen)); } } } diff --git a/Dalamud/Game/Chat/SeStringHandling/Payloads/RawPayload.cs b/Dalamud/Game/Chat/SeStringHandling/Payloads/RawPayload.cs index 77480291b..301bde07e 100644 --- a/Dalamud/Game/Chat/SeStringHandling/Payloads/RawPayload.cs +++ b/Dalamud/Game/Chat/SeStringHandling/Payloads/RawPayload.cs @@ -1,51 +1,84 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; namespace Dalamud.Game.Chat.SeStringHandling.Payloads { + /// + /// An SeString Payload representing unhandled raw payload data. + /// Mainly useful for constructing unhandled hardcoded payloads, or forwarding any unknown + /// payloads without modification. + /// public class RawPayload : Payload { + // this and others could be an actual static member somewhere and avoid construction costs, but that probably isn't a real concern + /// + /// A fixed Payload representing a common link-termination sequence, found in many payload chains. + /// + public static RawPayload LinkTerminator => new RawPayload(new byte[] { 0x02, 0x27, 0x07, 0xCF, 0x01, 0x01, 0x01, 0xFF, 0x01, 0x03 }); + public override PayloadType Type => PayloadType.Unknown; - public byte ChunkType { get; private set; } - public byte[] Data { get; private set; } - - public RawPayload(byte chunkType) + private byte[] data; + // this is a bit different from the underlying data + // We need to store just the chunk data for decode to behave nicely, but when reading data out + // it makes more sense to get the entire payload + /// + /// The entire payload byte sequence for this payload. + /// The returned data is a clone and modifications will not be persisted. + /// + public byte[] Data { - ChunkType = chunkType; + get + { + // for now don't allow modifying the contents + // because we don't really have a way to track Dirty + return (byte[])Encode().Clone(); + } } - public override void Resolve() + private byte chunkType; + + internal RawPayload(byte chunkType) { - // nothing to do + this.chunkType = chunkType; } - public override byte[] Encode() + public RawPayload(byte[] data) { - var chunkLen = Data.Length + 1; + // this payload is 'special' in that we require the entire chunk to be passed in + // and not just the data after the header + // This sets data to hold the chunk data fter the header, excluding the END_BYTE + this.chunkType = data[1]; + this.data = data.Skip(3).Take(data.Length-4).ToArray(); + } + + public override string ToString() + { + return $"{Type} - Data: {BitConverter.ToString(Data).Replace("-", " ")}"; + } + + protected override byte[] EncodeImpl() + { + var chunkLen = this.data.Length + 1; var bytes = new List() { START_BYTE, - ChunkType, + this.chunkType, (byte)chunkLen }; - bytes.AddRange(Data); + bytes.AddRange(this.data); bytes.Add(END_BYTE); return bytes.ToArray(); } - public override string ToString() + protected override void DecodeImpl(BinaryReader reader, long endOfStream) { - return $"{Type} - Chunk type: {ChunkType:X}, Data: {BitConverter.ToString(Data).Replace("-", " ")}"; - } - - protected override void ProcessChunkImpl(BinaryReader reader, long endOfStream) - { - Data = reader.ReadBytes((int)(endOfStream - reader.BaseStream.Position + 1)); + this.data = reader.ReadBytes((int)(endOfStream - reader.BaseStream.Position + 1)); } } } diff --git a/Dalamud/Game/Chat/SeStringHandling/Payloads/StatusPayload.cs b/Dalamud/Game/Chat/SeStringHandling/Payloads/StatusPayload.cs index e76d1590f..d5753e40c 100644 --- a/Dalamud/Game/Chat/SeStringHandling/Payloads/StatusPayload.cs +++ b/Dalamud/Game/Chat/SeStringHandling/Payloads/StatusPayload.cs @@ -1,40 +1,54 @@ +using Lumina.Excel.GeneratedSheets; using System; using System.Collections.Generic; using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Dalamud.Game.Chat.SeStringHandling.Payloads { + /// + /// An SeString Payload representing an interactable status link. + /// public class StatusPayload : Payload { public override PayloadType Type => PayloadType.Status; - public uint StatusId { get; private set; } - - public string StatusName { get; private set; } = string.Empty; - - public StatusPayload() { } - - public StatusPayload(uint statusId) + private Status status; + /// + /// The Lumina Status object represented by this payload. + /// + /// + /// Value is evaluated lazily and cached. + /// + public Status Status { - StatusId = statusId; - } - - public override void Resolve() - { - if (string.IsNullOrEmpty(StatusName)) + get { - dynamic status = XivApi.Get($"Status/{StatusId}").GetAwaiter().GetResult(); - //Console.WriteLine($"Resolved status {StatusId} to {status.Name}"); - StatusName = status.Name; + status ??= this.dataResolver.GetExcelSheet().GetRow((int)this.statusId); + return status; } } - public override byte[] Encode() + private uint statusId; + + internal StatusPayload() { } + + /// + /// Creates a new StatusPayload for the given status id. + /// + /// The id of the Status for this link. + public StatusPayload(uint statusId) { - var idBytes = MakeInteger(StatusId); + this.statusId = statusId; + } + + public override string ToString() + { + return $"{Type} - StatusId: {statusId}, Name: {Status.Name}"; + } + + protected override byte[] EncodeImpl() + { + var idBytes = MakeInteger(this.statusId); var chunkLen = idBytes.Length + 7; var bytes = new List() @@ -49,14 +63,9 @@ namespace Dalamud.Game.Chat.SeStringHandling.Payloads return bytes.ToArray(); } - public override string ToString() + protected override void DecodeImpl(BinaryReader reader, long endOfStream) { - return $"{Type} - StatusId: {StatusId}, StatusName: {StatusName}"; - } - - protected override void ProcessChunkImpl(BinaryReader reader, long endOfStream) - { - StatusId = GetInteger(reader); + this.statusId = GetInteger(reader); } } } diff --git a/Dalamud/Game/Chat/SeStringHandling/Payloads/TextPayload.cs b/Dalamud/Game/Chat/SeStringHandling/Payloads/TextPayload.cs index bd6703835..be2f5c13e 100644 --- a/Dalamud/Game/Chat/SeStringHandling/Payloads/TextPayload.cs +++ b/Dalamud/Game/Chat/SeStringHandling/Payloads/TextPayload.cs @@ -1,60 +1,64 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Text; -using System.Threading.Tasks; namespace Dalamud.Game.Chat.SeStringHandling.Payloads { - public class TextPayload : Payload + /// + /// An SeString Payload representing a plain text string. + /// + public class TextPayload : Payload, ITextProvider { public override PayloadType Type => PayloadType.RawText; - private string textConverted = null; - + // allow modifying the text of existing payloads on the fly + private string text; /// - /// The Text of this text payload as an UTF-8 converted string. - /// Don't rely on this for accurate representation of SE payload data, please check RawData instead. + /// The text contained in this payload. + /// This may contain SE's special unicode characters. /// - public string Text { - get { return this.textConverted ??= Encoding.UTF8.GetString(RawData); } - set { - this.textConverted = value; - RawData = Encoding.UTF8.GetBytes(value); + public string Text + { + get { return this.text; } + set + { + this.text = value; + Dirty = true; } } - /// - /// The raw unconverted data of this text payload. - /// - public byte[] RawData { get; set; } - - public TextPayload() { } - - public TextPayload(string text) - { - Text = text; - } - - public override void Resolve() - { - // nothing to do - } - - public override byte[] Encode() - { - return Encoding.UTF8.GetBytes(Text); - } - public override string ToString() { return $"{Type} - Text: {Text}"; } - protected override void ProcessChunkImpl(BinaryReader reader, long endOfStream) + internal TextPayload() { } + + /// + /// Creates a new TextPayload for the given text. + /// + /// The text to include for this payload. + public TextPayload(string text) { - var text = new List(); + this.text = text; + } + + protected override byte[] EncodeImpl() + { + // special case to allow for empty text payloads, so users don't have to check + // this may change or go away + if (string.IsNullOrEmpty(this.text)) + { + return new byte[] { }; + } + + return Encoding.UTF8.GetBytes(this.text); + } + + protected override void DecodeImpl(BinaryReader reader, long endOfStream) + { + var textBytes = new List(); while (reader.BaseStream.Position < endOfStream) { @@ -66,13 +70,13 @@ namespace Dalamud.Game.Chat.SeStringHandling.Payloads break; } - text.Add(nextByte); + textBytes.Add(nextByte); } - if (text.Count > 0) + if (textBytes.Count > 0) { // TODO: handling of the game's assorted special unicode characters - Text = Encoding.UTF8.GetString(text.ToArray()); + this.text = Encoding.UTF8.GetString(textBytes.ToArray()); } } } diff --git a/Dalamud/Game/Chat/SeStringHandling/Payloads/UIForegroundPayload.cs b/Dalamud/Game/Chat/SeStringHandling/Payloads/UIForegroundPayload.cs index a9afebfa8..9a8e5780c 100644 --- a/Dalamud/Game/Chat/SeStringHandling/Payloads/UIForegroundPayload.cs +++ b/Dalamud/Game/Chat/SeStringHandling/Payloads/UIForegroundPayload.cs @@ -1,22 +1,89 @@ +using Lumina.Excel.GeneratedSheets; using System; using System.Collections.Generic; using System.IO; namespace Dalamud.Game.Chat.SeStringHandling.Payloads { + /// + /// An SeString Payload representing a UI foreground color applied to following text payloads. + /// public class UIForegroundPayload : Payload { + /// + /// Payload representing disabling foreground color on following text. + /// + public static UIForegroundPayload UIForegroundOff => new UIForegroundPayload(0); + public override PayloadType Type => PayloadType.UIForeground; - public ushort RawColor { get; private set; } + /// + /// Whether or not this payload represents applying a foreground color, or disabling one. + /// + public bool IsEnabled => ColorKey != 0; - //public int Red { get; private set; } - //public int Green { get; private set; } - //public int Blue { get; private set; } - - public override byte[] Encode() + private UIColor color; + /// + /// A Lumina UIColor object representing this payload. The actual color data is at UIColor.UIForeground + /// + /// + /// Value is evaluated lazily and cached. + /// + public UIColor UIColor { - var colorBytes = MakeInteger(RawColor); + get + { + this.color ??= this.dataResolver.GetExcelSheet().GetRow(this.colorKey); + return this.color; + } + } + + /// + /// The color key used as a lookup in the UIColor table for this foreground color. + /// + public ushort ColorKey + { + get { return this.colorKey; } + set + { + this.colorKey = value; + this.color = null; + Dirty = true; + } + } + + /// + /// The Red/Green/Blue values for this foreground color, encoded as a typical hex color. + /// + public uint RGB + { + get + { + return (UIColor.UIForeground & 0xFFFFFF); + } + } + + private ushort colorKey; + + internal UIForegroundPayload() { } + + /// + /// Creates a new UIForegroundPayload for the given UIColor key. + /// + /// + public UIForegroundPayload(ushort colorKey) + { + this.colorKey = colorKey; + } + + public override string ToString() + { + return $"{Type} - UIColor: {colorKey} color: {(IsEnabled ? RGB : 0)}"; + } + + protected override byte[] EncodeImpl() + { + var colorBytes = MakeInteger(this.colorKey); var chunkLen = colorBytes.Length + 1; var bytes = new List(new byte[] @@ -30,19 +97,9 @@ namespace Dalamud.Game.Chat.SeStringHandling.Payloads return bytes.ToArray(); } - public override void Resolve() + protected override void DecodeImpl(BinaryReader reader, long endOfStream) { - // TODO: resolve color keys to hex colors via UIColor table - } - - public override string ToString() - { - return $"{Type} - RawColor: {RawColor}"; - } - - protected override void ProcessChunkImpl(BinaryReader reader, long endOfStream) - { - RawColor = (ushort)GetInteger(reader); + this.colorKey = (ushort)GetInteger(reader); } protected override byte GetMarkerForIntegerBytes(byte[] bytes) diff --git a/Dalamud/Game/Chat/SeStringHandling/Payloads/UIGlowPayload.cs b/Dalamud/Game/Chat/SeStringHandling/Payloads/UIGlowPayload.cs index 3bc98fc6e..7731b431a 100644 --- a/Dalamud/Game/Chat/SeStringHandling/Payloads/UIGlowPayload.cs +++ b/Dalamud/Game/Chat/SeStringHandling/Payloads/UIGlowPayload.cs @@ -1,22 +1,89 @@ +using Lumina.Excel.GeneratedSheets; using System; using System.Collections.Generic; using System.IO; namespace Dalamud.Game.Chat.SeStringHandling.Payloads { + /// + /// An SeString Payload representing a UI glow color applied to following text payloads. + /// public class UIGlowPayload : Payload { + /// + /// Payload representing disabling glow color on following text. + /// + public static UIGlowPayload UIGlowOff => new UIGlowPayload(0); + public override PayloadType Type => PayloadType.UIGlow; - public ushort RawColor { get; private set; } + /// + /// Whether or not this payload represents applying a glow color, or disabling one. + /// + public bool IsEnabled => ColorKey != 0; - //public int Red { get; private set; } - //public int Green { get; private set; } - //public int Blue { get; private set; } - - public override byte[] Encode() + private UIColor color; + /// + /// A Lumina UIColor object representing this payload. The actual color data is at UIColor.UIGlow + /// + /// + /// Value is evaluated lazily and cached. + /// + public UIColor UIColor { - var colorBytes = MakeInteger(RawColor); + get + { + this.color ??= this.dataResolver.GetExcelSheet().GetRow(this.colorKey); + return this.color; + } + } + + /// + /// The color key used as a lookup in the UIColor table for this glow color. + /// + public ushort ColorKey + { + get { return this.colorKey; } + set + { + this.colorKey = value; + this.color = null; + Dirty = true; + } + } + + /// + /// The Red/Green/Blue values for this glow color, encoded as a typical hex color. + /// + public uint RGB + { + get + { + return (UIColor.UIGlow & 0xFFFFFF); + } + } + + private ushort colorKey; + + internal UIGlowPayload() { } + + /// + /// Creates a new UIForegroundPayload for the given UIColor key. + /// + /// + public UIGlowPayload(ushort colorKey) + { + this.colorKey = colorKey; + } + + public override string ToString() + { + return $"{Type} - UIColor: {colorKey} color: {(IsEnabled ? RGB : 0)}"; + } + + protected override byte[] EncodeImpl() + { + var colorBytes = MakeInteger(this.colorKey); var chunkLen = colorBytes.Length + 1; var bytes = new List(new byte[] @@ -30,19 +97,9 @@ namespace Dalamud.Game.Chat.SeStringHandling.Payloads return bytes.ToArray(); } - public override void Resolve() + protected override void DecodeImpl(BinaryReader reader, long endOfStream) { - // TODO: resolve color keys to hex colors via UIColor table - } - - public override string ToString() - { - return $"{Type} - RawColor: {RawColor}"; - } - - protected override void ProcessChunkImpl(BinaryReader reader, long endOfStream) - { - RawColor = (ushort)GetInteger(reader); + this.colorKey = (ushort)GetInteger(reader); } protected override byte GetMarkerForIntegerBytes(byte[] bytes) diff --git a/Dalamud/Game/Chat/SeStringHandling/SeString.cs b/Dalamud/Game/Chat/SeStringHandling/SeString.cs index 87e7dee62..0ca95b3e5 100644 --- a/Dalamud/Game/Chat/SeStringHandling/SeString.cs +++ b/Dalamud/Game/Chat/SeStringHandling/SeString.cs @@ -3,9 +3,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; -using System.Threading.Tasks; -using Dalamud.Data; -using Dalamud.Game.Chat.SeStringHandling.Payloads; namespace Dalamud.Game.Chat.SeStringHandling { @@ -14,12 +11,13 @@ namespace Dalamud.Game.Chat.SeStringHandling /// public class SeString { - public List Payloads { get; } + // TODO: probably change how this is done/where it comes from + internal static Dalamud Dalamud { get; set; } - public SeString(List payloads) - { - Payloads = payloads; - } + /// + /// The ordered list of payloads included in this SeString. + /// + public List Payloads { get; } /// /// Helper function to get all raw text from a message as a single joined string @@ -29,35 +27,30 @@ namespace Dalamud.Game.Chat.SeStringHandling /// public string TextValue { - get { - var sb = new StringBuilder(); - foreach (var p in Payloads) - { - if (p.Type == PayloadType.RawText) - { - sb.Append(((TextPayload)p).Text); - } - } - - return sb.ToString(); + get + { + return Payloads + .Where(p => p is ITextProvider) + .Cast() + .Aggregate(new StringBuilder(), (sb, tp) => sb.Append(tp.Text), sb => sb.ToString()); } } /// - /// Parse an array of bytes to a SeString. + /// 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) { var payloads = new List(); - using (var stream = new MemoryStream(bytes)) { - using var reader = new BinaryReader(stream); - + using (var stream = new MemoryStream(bytes)) + using (var reader = new BinaryReader(stream)) + { while (stream.Position < bytes.Length) { - var payload = Payload.Process(reader); + var payload = Payload.Decode(reader); if (payload != null) payloads.Add(payload); } @@ -67,10 +60,61 @@ namespace Dalamud.Game.Chat.SeStringHandling } /// - /// Encode a parsed/created SeString to an array of bytes, to be used for injection. + /// Creates a new SeString from an ordered list of payloads. /// - /// - /// The bytes of the message. + /// The Payload objects to make up this string. + public SeString(List payloads) + { + Payloads = payloads; + } + + /// + /// Creates a new SeString from an ordered list of payloads. + /// + /// The Payload objects to make up this string. + public SeString(Payload[] payloads) + { + Payloads = new List(payloads); + } + + /// + /// Appends the contents of one SeString to this one. + /// + /// The SeString to append to this one. + /// This object. + public SeString Append(SeString other) + { + Payloads.AddRange(other.Payloads); + return this; + } + + /// + /// Appends a list of payloads to this SeString. + /// + /// The Payloads to append. + /// This object. + public SeString Append(List payloads) + { + Payloads.AddRange(payloads); + return this; + } + + /// + /// Appends a single payload to this SeString. + /// + /// The payload to append. + /// This object. + public SeString Append(Payload payload) + { + 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(); diff --git a/Dalamud/Game/Chat/SeStringHandling/SeStringUtils.cs b/Dalamud/Game/Chat/SeStringHandling/SeStringUtils.cs new file mode 100644 index 000000000..e7b4cc43f --- /dev/null +++ b/Dalamud/Game/Chat/SeStringHandling/SeStringUtils.cs @@ -0,0 +1,149 @@ +using Dalamud.Game.Chat.SeStringHandling.Payloads; +using Lumina.Excel.GeneratedSheets; +using System; +using System.Collections.Generic; +using System.Linq; + +using DalamudItem = Dalamud.Data.TransientSheet.Item; + +namespace Dalamud.Game.Chat.SeStringHandling +{ + /// + /// A utility class for working with common SeString variants. + /// + public static class SeStringUtils + { + /// + /// 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) + { + string displayName = displayNameOverride ?? SeString.Dalamud.Data.GetExcelSheet().GetRow((int)itemId).Name; + if (isHQ) + { + displayName += $" {(char)SeIconChar.HighQuality}"; + } + + // TODO: probably a cleaner way to build these than doing the bulk+insert + var payloads = new List(new Payload[] + { + new UIForegroundPayload(0x0225), + new UIGlowPayload(0x0226), + new ItemPayload(itemId, isHQ), + // arrow goes here + new TextPayload(displayName), + RawPayload.LinkTerminator + // sometimes there is another set of uiglow/foreground off payloads here + // might be necessary when including additional text after the item name + }); + payloads.InsertRange(3, TextArrowPayloads()); + + return new SeString(payloads); + } + + /// + /// 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(DalamudItem item, bool isHQ, string displayNameOverride = null) + { + return CreateItemLink((uint)item.RowId, isHQ, displayNameOverride ?? item.Name); + } + + public static SeString CreateMapLink(uint territoryId, uint mapId, int rawX, int rawY) + { + var mapPayload = new MapLinkPayload(territoryId, mapId, rawX, rawY); + var nameString = $"{mapPayload.PlaceName} {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) + { + var mapPayload = new MapLinkPayload(territoryId, mapId, xCoord, yCoord, fudgeFactor); + var nameString = $"{mapPayload.PlaceName} {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. + /// + /// 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) + { + var mapSheet = SeString.Dalamud.Data.GetExcelSheet(); + + var matches = SeString.Dalamud.Data.GetExcelSheet().GetRows() + .Where(row => row.Name.ToLowerInvariant() == placeName.ToLowerInvariant()) + .ToArray(); + + foreach (var place in matches) + { + var map = mapSheet.GetRows().FirstOrDefault(row => row.PlaceName == place.RowId); + if (map != null) + { + return CreateMapLink(map.TerritoryType, (uint)map.RowId, xCoord, yCoord); + } + } + + // TODO: empty? throw? + return null; + } + + /// + /// Creates 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 List TextArrowPayloads() + { + return new List(new Payload[] + { + new UIForegroundPayload(0x01F4), + new UIGlowPayload(0x01F5), + new TextPayload($"{(char)SeIconChar.LinkMarker}"), + UIGlowPayload.UIGlowOff, + UIForegroundPayload.UIForegroundOff + }); + } + } +} diff --git a/Dalamud/Game/Chat/XivChatType.cs b/Dalamud/Game/Chat/XivChatType.cs index 7e8eb5326..b0f04a93b 100644 --- a/Dalamud/Game/Chat/XivChatType.cs +++ b/Dalamud/Game/Chat/XivChatType.cs @@ -6,7 +6,7 @@ namespace Dalamud.Game.Chat /// /// The FFXIV chat types as seen in the LogKind ex table. /// - public enum XivChatType : ushort + public enum XivChatType : ushort // FIXME: this is a single byte { None = 0, Debug = 1, diff --git a/Dalamud/Game/ChatHandlers.cs b/Dalamud/Game/ChatHandlers.cs index 1aabc893a..8ac099093 100644 --- a/Dalamud/Game/ChatHandlers.cs +++ b/Dalamud/Game/ChatHandlers.cs @@ -188,14 +188,14 @@ namespace Dalamud.Game { break; } - Log.Debug($"Probable retainer sale: {message}, decoded item {itemLink.ItemId}, HQ {itemLink.IsHQ}"); + Log.Debug($"Probable retainer sale: {message}, decoded item {itemLink.Item.RowId}, HQ {itemLink.IsHQ}"); var valueInfo = matchInfo.Groups["value"]; // not sure if using a culture here would work correctly, so just strip symbols instead if (!valueInfo.Success || !int.TryParse(valueInfo.Value.Replace(",", "").Replace(".", ""), out var itemValue)) continue; - Task.Run(() => this.dalamud.BotManager.ProcessRetainerSale((int)itemLink.ItemId, itemValue, itemLink.IsHQ)); + Task.Run(() => this.dalamud.BotManager.ProcessRetainerSale((int)itemLink.Item.RowId, itemValue, itemLink.IsHQ)); break; } } @@ -231,8 +231,16 @@ namespace Dalamud.Game { } private static string MakeItalics(string text) { - return Encoding.UTF8.GetString(new byte[] {0x02, 0x1A, 0x02, 0x02, 0x03}) + text + - Encoding.UTF8.GetString(new byte[] {0x02, 0x1A, 0x02, 0x01, 0x03}); + // TODO: when the above code is switched to SeString, this can be a straight insertion of the + // italics payloads only, and be a lot cleaner + var italicString = new SeString(new List(new Payload[] + { + EmphasisItalicPayload.ItalicsOn, + new TextPayload(text), + EmphasisItalicPayload.ItalicsOff + })); + + return Encoding.UTF8.GetString(italicString.Encode()); } } } diff --git a/Dalamud/Game/Internal/Gui/ChatGui.cs b/Dalamud/Game/Internal/Gui/ChatGui.cs index d43bae8c8..787741e01 100644 --- a/Dalamud/Game/Internal/Gui/ChatGui.cs +++ b/Dalamud/Game/Internal/Gui/ChatGui.cs @@ -138,8 +138,8 @@ namespace Dalamud.Game.Internal.Gui { if (!FastByteArrayCompare(oldEdited, newEdited)) { Log.Verbose("SeString was edited, taking precedence over StdString edit."); message.RawData = newEdited; - } - //Log.Debug($"\nOLD: {BitConverter.ToString(originalMessageData)}\nNEW: {BitConverter.ToString(newEdited)}"); + Log.Debug($"\nOLD: {BitConverter.ToString(originalMessageData)}\nNEW: {BitConverter.ToString(newEdited)}"); + } var messagePtr = pMessage; OwnedStdString allocatedString = null; diff --git a/Dalamud/Game/Internal/Gui/GameGui.cs b/Dalamud/Game/Internal/Gui/GameGui.cs index 1145402f8..bd779dec7 100644 --- a/Dalamud/Game/Internal/Gui/GameGui.cs +++ b/Dalamud/Game/Internal/Gui/GameGui.cs @@ -172,8 +172,7 @@ namespace Dalamud.Game.Internal.Gui { openMapWithFlag = Address.GetVirtualFunction(uiMapObjectPtr, 0, 63); - var mapLinkString = - $"m:{mapLink.TerritoryTypeId},{mapLink.MapId},{unchecked((int)mapLink.RawX)},{unchecked((int)mapLink.RawY)}"; + var mapLinkString = mapLink.DataString; Log.Debug($"OpenMapWithMapLink: Opening Map Link: {mapLinkString}");