using System.Runtime.InteropServices; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Hooking; using Dalamud.IoC; using Dalamud.Plugin; using Dalamud.Plugin.Services; using Dalamud.Utility.Signatures; using TimePasses.Model; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; namespace TimePasses; public class Plugin : IDalamudPlugin { [PluginService] private IGameInteropProvider GameInterop { get; init; } [PluginService] internal static IPluginLog Log { get; set; } private HttpClient Client { get; } = new(); private DataFile Data { get; set; } private Dictionary<(string, bool), nint> ReplacementPointers { get; } = []; private SemaphoreSlim Mutex { get; } = new(1, 1); internal static IDeserializer Deserializer { get; } = new DeserializerBuilder() .WithNamingConvention(UnderscoredNamingConvention.Instance) .WithNodeDeserializer(new WhenNodeDeserialiser()) .Build(); private static class Signatures { internal const string GetBalloonRow = "E8 ?? ?? ?? ?? 48 85 C0 74 4D 48 89 5C 24"; } private delegate nint GetBalloonRowDelegate(uint rowId); [Signature(Signatures.GetBalloonRow, DetourName = nameof(GetBalloonRowDetour))] private Hook? GetBalloonRowHook { get; init; } public Plugin() { this.GameInterop!.InitializeFromAttributes(this); this.GetBalloonRowHook?.Enable(); Task.Run(async () => { #if DEBUG var stream = typeof(Plugin).Assembly.GetManifestResourceStream("TimePasses.replacements.yaml"); using var reader = new StreamReader(stream!); var yaml = await reader.ReadToEndAsync(); #else using var resp = await this.Client.GetAsync("https://git.anna.lgbt/anna/TimePasses/raw/branch/main/replacements.yaml"); var yaml = await resp.Content.ReadAsStringAsync(); #endif await this.Mutex.WaitAsync(); try { this.Data = Plugin.Deserializer.Deserialize(yaml); this.ReplacementPointers.Clear(); } finally { this.Mutex.Release(); } }); } public void Dispose() { this.GetBalloonRowHook?.Dispose(); this.Client.Dispose(); this.Mutex.Dispose(); foreach (var (_, ptr) in this.ReplacementPointers) { Marshal.FreeHGlobal(ptr); } this.ReplacementPointers.Clear(); } private nint GetBalloonRowDetour(uint rowId) { try { var ptr = this.GetBalloonRowDetourInner(rowId); if (ptr != null) { return ptr.Value; } } catch (Exception ex) { Plugin.Log.Error(ex, "Error in GetBalloonRowDetour"); } return this.GetBalloonRowHook!.Original(rowId); } private nint? GetBalloonRowDetourInner(uint rowId) { this.Mutex.Wait(); using var release = new OnDispose(() => this.Mutex.Release()); var rep = this.Data.Replacements.FirstOrDefault(rep => rep.Id == rowId); if (rep == null) { return null; } var when = rep.When.FirstOrDefault(when => when.IsValid(this)); if (when == null) { if (rep.Text != null) { return this.GetOrCreateReplacementPointer(rep.Text, rep.Slowly); } return null; } return this.GetOrCreateReplacementPointer(when.Text, when.Slowly); } private nint GetOrCreateReplacementPointer(string text, bool slowly) { if (this.ReplacementPointers.TryGetValue((text, slowly), out var cached)) { return cached; } var lines = text .ReplaceLineEndings("\n") .Split('\n'); var seStringBuilder = new SeStringBuilder(); for (var i = 0; i < lines.Length; i++) { if (i != 0) { seStringBuilder.Add(NewLinePayload.Payload); } seStringBuilder.AddText(lines[i].TrimEnd()); } var textBytes = seStringBuilder.Encode(); unsafe { var ptr = (uint*) Marshal.AllocHGlobal(8 + textBytes.Length + 1); try { *ptr = 8; *(ptr + 1) = slowly ? 1u : 0u; var bytes = (byte*) (ptr + 2); bytes[textBytes.Length] = 0; Marshal.Copy(textBytes, 0, (nint) bytes, textBytes.Length); this.ReplacementPointers![(text, slowly)] = (nint) ptr; return (nint) ptr; } catch { Marshal.FreeHGlobal((nint) ptr); throw; } } } } [Serializable] internal class DataFile { public Replacement[] Replacements { get; init; } }