TimePasses/Plugin.cs

224 lines
6.9 KiB
C#

using System.Runtime.InteropServices;
using System.Text;
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 TimePasses.Util;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace TimePasses;
public sealed class Plugin : IDalamudPlugin {
[PluginService]
internal static IPluginLog Log { get; set; }
[PluginService]
internal IClientState ClientState { get; init; }
[PluginService]
private IGameInteropProvider GameInterop { get; init; }
private HttpClient Client { get; } = new();
private DataFile Data { get; set; }
private Dictionary<(string, bool), nint> ReplacementPointers { get; } = [];
private SemaphoreSlim Mutex { get; } = new(1, 1);
private static IDeserializer Deserializer { get; } = new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.WithTypeConverter(new WhenLevelConverter())
.WithTypeConverter(ReplacementTextConverter.Instance)
.WithNodeDeserializer(new WhenNodeDeserialiser())
.IgnoreUnmatchedProperties()
.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<GetBalloonRowDelegate>? GetBalloonRowHook { get; init; }
public Plugin() {
this.GameInterop!.InitializeFromAttributes(this);
this.GetBalloonRowHook?.Enable();
Task.Run(async () => {
string yaml;
#if DEBUG
yaml = await Plugin.LoadEmbeddedReplacements();
#else
try {
using var resp = await this.Client.GetAsync("https://git.anna.lgbt/anna/TimePasses/raw/branch/main/replacements.yaml");
yaml = await resp.Content.ReadAsStringAsync();
} catch (Exception ex) {
Plugin.Log.Warning(ex, "could not download replacements");
yaml = await Plugin.LoadEmbeddedReplacements();
}
#endif
await this.Mutex.WaitAsync();
try {
this.Data = Plugin.Deserializer.Deserialize<DataFile>(yaml);
this.ResetReplacementPointers();
} finally {
this.Mutex.Release();
}
});
}
public void Dispose() {
this.GetBalloonRowHook?.Dispose();
this.Client.Dispose();
this.Mutex.Dispose();
this.ResetReplacementPointers();
}
private static async Task<string> LoadEmbeddedReplacements() {
using var stream = typeof(Plugin).Assembly.GetManifestResourceStream("TimePasses.replacements.yaml");
using var reader = new StreamReader(stream!);
return await reader.ReadToEndAsync();
}
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) {
var idx = Random.Shared.Next(when.Text.Count);
return this.GetOrCreateReplacementPointer(when.Text[idx], when.Slowly);
}
if (rep.Text != null) {
var idx = Random.Shared.Next(rep.Text.Count);
return this.GetOrCreateReplacementPointer(rep.Text[idx], rep.Slowly);
}
return null;
}
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 sb = new StringBuilder();
var seStringBuilder = new SeStringBuilder();
for (var i = 0; i < lines.Length; i++) {
if (i != 0) {
seStringBuilder.Add(NewLinePayload.Payload);
}
var line = lines[i]
.TrimEnd()
.Replace("<em>", Markers.EmphasisOn.ToString())
.Replace("</em>", Markers.EmphasisOff.ToString());
foreach (var ch in line) {
switch (ch) {
case Markers.EmphasisOn: {
Append();
seStringBuilder.AddItalicsOn();
break;
}
case Markers.EmphasisOff: {
Append();
seStringBuilder.AddItalicsOff();
break;
}
default: {
sb.Append(ch);
break;
}
}
}
Append();
continue;
void Append() {
if (sb.Length == 0) {
return;
}
seStringBuilder.AddText(sb.ToString());
sb.Clear();
}
}
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;
}
}
}
private void ResetReplacementPointers() {
foreach (var (_, ptr) in this.ReplacementPointers) {
Marshal.FreeHGlobal(ptr);
}
this.ReplacementPointers.Clear();
}
}
[Serializable]
internal class DataFile {
public Definitions Definitions { get; init; }
public Replacement[] Replacements { get; init; }
}
[Serializable]
public class Definitions {
public uint[] Quests { get; init; }
}
internal static class Markers {
internal const char EmphasisOn = '\uf000';
internal const char EmphasisOff = '\uf001';
}