fee2459e73
F12.2 step 5b — service cluster (~42 sites in 16 files):
MessageManager, GameFunctions/{Chat, GameFunctions, KeybindManager},
EmoteCache, PayloadHandler, AutoTellTabsService, FontManager, Commands,
Util/{WrapperUtil, AutoTranslate, MemoryUtil}, Message, Themes/ThemeRegistry,
Ipc/ExtraChat, Configuration.
The proxy interface gained Dalamud's params-overload signature
(messageTemplate + params object[]) to cover Configuration.cs:86 which
relies on Serilog-style placeholders.
Verified: zero remaining Plugin.Log.X(...) call-sites in HellionChat/,
build green, build-suite 690/690.
419 lines
13 KiB
C#
419 lines
13 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Numerics;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Dalamud.Bindings.ImGui;
|
|
using Dalamud.Interface.Textures;
|
|
using Dalamud.Interface.Textures.TextureWraps;
|
|
using Dalamud.Utility;
|
|
using SixLabors.ImageSharp;
|
|
using SixLabors.ImageSharp.PixelFormats;
|
|
|
|
namespace HellionChat;
|
|
|
|
public static class EmoteCache
|
|
{
|
|
private static readonly string[] NotWorking =
|
|
[
|
|
":tf:",
|
|
"(ditto)",
|
|
"c!",
|
|
"h!",
|
|
"l!",
|
|
"M&Mjc",
|
|
"LUL3D",
|
|
"p!",
|
|
"POLICE2",
|
|
"r!",
|
|
"Pussy",
|
|
"s!",
|
|
"v!",
|
|
"w!",
|
|
"x0r6ztGiggle",
|
|
"z!",
|
|
"xar2EDM",
|
|
"iron95Pls",
|
|
"Clap2",
|
|
"AlienPls3",
|
|
"Life",
|
|
"peepoPogClimbingTreeHard4House",
|
|
"monkaGIGAftRobertDowneyJr",
|
|
"DogLookingSussyAndCold",
|
|
"DICKS",
|
|
];
|
|
|
|
private static readonly HttpClient Client = new();
|
|
|
|
private const string BetterTTV = "https://api.betterttv.net/3";
|
|
private const string GlobalEmotes = $"{BetterTTV}/cached/emotes/global";
|
|
private const string Top100Emotes = "{0}/emotes/shared/top?before={1}&limit=100";
|
|
private const string EmotePath = "https://cdn.betterttv.net/emote/{0}/3x";
|
|
|
|
[Serializable]
|
|
private struct Top100()
|
|
{
|
|
[JsonPropertyName("emote")]
|
|
public Emote Emote { get; set; }
|
|
|
|
[JsonPropertyName("id")]
|
|
public required string Id { get; set; }
|
|
}
|
|
|
|
[Serializable]
|
|
public struct Emote()
|
|
{
|
|
[JsonPropertyName("id")]
|
|
public required string Id { get; set; }
|
|
|
|
[JsonPropertyName("code")]
|
|
public required string Code { get; set; }
|
|
|
|
[JsonPropertyName("imageType")]
|
|
public required string ImageType { get; set; }
|
|
}
|
|
|
|
public enum LoadingState
|
|
{
|
|
Unloaded,
|
|
Loading,
|
|
Done,
|
|
}
|
|
|
|
// All fields below are uninitialised while State != Done.
|
|
public static LoadingState State = LoadingState.Unloaded;
|
|
|
|
private static readonly Dictionary<string, Emote> Cache = new();
|
|
private static readonly Dictionary<string, EmoteBase> EmoteImages = new();
|
|
|
|
public static string[] SortedCodeArray = [];
|
|
|
|
// Cancelled on Dispose to stop in-flight downloads; replaced on re-enable.
|
|
private static CancellationTokenSource Cts = new();
|
|
internal static CancellationToken Token => Cts.Token;
|
|
|
|
// Tracks in-flight loads so Dispose can drain them before teardown.
|
|
private static readonly ConcurrentBag<Task> PendingLoads = new();
|
|
|
|
internal static void TrackLoad(Task loadTask, string emoteCode)
|
|
{
|
|
PendingLoads.Add(
|
|
loadTask.ContinueWith(
|
|
t =>
|
|
{
|
|
if (t.IsFaulted)
|
|
Plugin.LogProxy.Error(
|
|
t.Exception!,
|
|
$"EmoteCache load failed for {emoteCode}"
|
|
);
|
|
},
|
|
TaskScheduler.Default
|
|
)
|
|
);
|
|
}
|
|
|
|
public static async Task LoadData()
|
|
{
|
|
if (State is not LoadingState.Unloaded)
|
|
return;
|
|
|
|
// Reset CTS if Dispose was called and the plugin is being re-enabled.
|
|
if (Cts.IsCancellationRequested)
|
|
Cts = new CancellationTokenSource();
|
|
|
|
State = LoadingState.Loading;
|
|
var ct = Cts.Token;
|
|
try
|
|
{
|
|
var global = await Client.GetAsync(GlobalEmotes, ct);
|
|
var globalList = await global.Content.ReadAsStringAsync(ct);
|
|
|
|
foreach (var emote in JsonSerializer.Deserialize<Emote[]>(globalList)!)
|
|
if (!string.IsNullOrEmpty(emote.Code) && !NotWorking.Contains(emote.Code))
|
|
Cache.TryAdd(emote.Code, emote);
|
|
|
|
var lastId = string.Empty;
|
|
for (var i = 0; i < 15; i++)
|
|
{
|
|
var top = await Client.GetAsync(Top100Emotes.Format(BetterTTV, lastId), ct);
|
|
var topList = await top.Content.ReadAsStringAsync(ct);
|
|
|
|
var jsonList = JsonSerializer.Deserialize<List<Top100>>(topList)!;
|
|
// BetterTTV occasionally returns entries with a null Code;
|
|
// skip them so a single bad row doesn't break the whole cache.
|
|
foreach (var emote in jsonList)
|
|
if (
|
|
!string.IsNullOrEmpty(emote.Emote.Code)
|
|
&& !NotWorking.Contains(emote.Emote.Code)
|
|
)
|
|
Cache.TryAdd(emote.Emote.Code, emote.Emote);
|
|
|
|
lastId = jsonList.Last().Id;
|
|
}
|
|
|
|
SortedCodeArray = Cache.Keys.Order().ToArray();
|
|
State = LoadingState.Done;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Plugin disposed mid-load; State stays on Loading so re-enable can retry.
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Reset to Unloaded so a later trigger can retry without a plugin reload.
|
|
State = LoadingState.Unloaded;
|
|
Plugin.LogProxy.Error(ex, "BetterTTV cache wasn't initialized");
|
|
}
|
|
}
|
|
|
|
public static void Dispose()
|
|
{
|
|
Cts.Cancel();
|
|
|
|
// 5s upper bound; anything still running gets abandoned.
|
|
try
|
|
{
|
|
Task.WaitAll(PendingLoads.ToArray(), TimeSpan.FromSeconds(5));
|
|
}
|
|
catch (AggregateException)
|
|
{
|
|
// Faults already logged in TrackLoad.
|
|
}
|
|
|
|
while (PendingLoads.TryTake(out _)) { }
|
|
|
|
foreach (var emote in EmoteImages.Values)
|
|
emote.InnerDispose();
|
|
}
|
|
|
|
internal static bool Exists(string code)
|
|
{
|
|
return State is LoadingState.Done && SortedCodeArray.Contains(code);
|
|
}
|
|
|
|
internal static EmoteBase? GetEmote(string code)
|
|
{
|
|
if (State is not LoadingState.Done)
|
|
return null;
|
|
|
|
if (!Cache.TryGetValue(code, out var emoteDetail))
|
|
return null;
|
|
|
|
if (EmoteImages.TryGetValue(emoteDetail.Id, out var emote))
|
|
return emote;
|
|
|
|
try
|
|
{
|
|
if (emoteDetail.ImageType == "gif")
|
|
{
|
|
var animatedEmote = new ImGuiGif().Prepare(emoteDetail);
|
|
EmoteImages.Add(emoteDetail.Id, animatedEmote);
|
|
return animatedEmote;
|
|
}
|
|
|
|
var staticEmote = new ImGuiEmote().Prepare(emoteDetail);
|
|
EmoteImages.Add(emoteDetail.Id, staticEmote);
|
|
|
|
return staticEmote;
|
|
}
|
|
catch
|
|
{
|
|
Plugin.LogProxy.Error("Failed to convert");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public abstract class EmoteBase
|
|
{
|
|
public bool Failed;
|
|
public bool IsLoaded;
|
|
|
|
public byte[] RawData = [];
|
|
|
|
protected IDalamudTextureWrap? Texture;
|
|
|
|
public virtual void Draw(Vector2 size)
|
|
{
|
|
ImGui.Image(Texture!.Handle, size);
|
|
}
|
|
|
|
internal async Task<byte[]> LoadAsync(Emote emote, CancellationToken ct)
|
|
{
|
|
// Path-traversal guard: resolve and verify the candidate path stays
|
|
// inside the cache directory before reading or writing.
|
|
var dir = Path.GetFullPath(
|
|
Path.Join(Plugin.Interface.ConfigDirectory.FullName, "EmoteCacheV1")
|
|
);
|
|
Directory.CreateDirectory(dir);
|
|
|
|
var dirPrefix = dir.EndsWith(Path.DirectorySeparatorChar)
|
|
? dir
|
|
: dir + Path.DirectorySeparatorChar;
|
|
var filePath = Path.GetFullPath(Path.Join(dir, $"{emote.Id}.{emote.ImageType}"));
|
|
if (!filePath.StartsWith(dirPrefix, StringComparison.Ordinal))
|
|
throw new InvalidOperationException(
|
|
$"Emote path escapes cache directory: id={emote.Id}, type={emote.ImageType}"
|
|
);
|
|
|
|
if (File.Exists(filePath))
|
|
{
|
|
RawData = await File.ReadAllBytesAsync(filePath, ct);
|
|
}
|
|
else
|
|
{
|
|
var content = await Client.GetAsync(EmotePath.Format(emote.Id), ct);
|
|
RawData = await content.Content.ReadAsByteArrayAsync(ct);
|
|
|
|
await using var stream = new FileStream(
|
|
filePath,
|
|
FileMode.Create,
|
|
FileAccess.Write,
|
|
FileShare.Read
|
|
);
|
|
await stream.WriteAsync(RawData, ct);
|
|
}
|
|
|
|
return RawData;
|
|
}
|
|
|
|
public abstract void InnerDispose();
|
|
}
|
|
|
|
public sealed class ImGuiEmote : EmoteBase
|
|
{
|
|
public ImGuiEmote Prepare(Emote emote)
|
|
{
|
|
var ct = EmoteCache.Token;
|
|
// Task.Run keeps the sync prefix off the ImGui render thread.
|
|
EmoteCache.TrackLoad(Task.Run(() => LoadAsyncTracked(emote, ct), ct), emote.Code);
|
|
return this;
|
|
}
|
|
|
|
private async Task LoadAsyncTracked(Emote emote, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var image = await LoadAsync(emote, ct);
|
|
if (image.Length <= 0)
|
|
return;
|
|
|
|
ct.ThrowIfCancellationRequested();
|
|
Texture = await Plugin.TextureProvider.CreateFromImageAsync(
|
|
image,
|
|
cancellationToken: ct
|
|
);
|
|
IsLoaded = true;
|
|
}
|
|
catch (OperationCanceledException) { }
|
|
catch (Exception ex)
|
|
{
|
|
Failed = true;
|
|
Plugin.LogProxy.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}");
|
|
}
|
|
}
|
|
|
|
public override void InnerDispose()
|
|
{
|
|
Texture?.Dispose();
|
|
}
|
|
}
|
|
|
|
public sealed class ImGuiGif : EmoteBase
|
|
{
|
|
private List<(IDalamudTextureWrap Texture, float Delay)> Frames = [];
|
|
private float FrameTimer;
|
|
private int CurrentFrame;
|
|
private ulong GlobalFrameCount;
|
|
|
|
public override void Draw(Vector2 size)
|
|
{
|
|
if (Frames.Count == 0)
|
|
return;
|
|
|
|
if (CurrentFrame >= Frames.Count)
|
|
{
|
|
CurrentFrame = 0;
|
|
FrameTimer = -1f;
|
|
}
|
|
|
|
var frame = Frames[CurrentFrame];
|
|
if (FrameTimer <= 0.0f)
|
|
FrameTimer = frame.Delay;
|
|
|
|
ImGui.Image(frame.Texture.Handle, size);
|
|
|
|
if (GlobalFrameCount == Plugin.Interface.UiBuilder.FrameCount)
|
|
return;
|
|
|
|
GlobalFrameCount = Plugin.Interface.UiBuilder.FrameCount;
|
|
|
|
FrameTimer -= ImGui.GetIO().DeltaTime;
|
|
if (FrameTimer <= 0f)
|
|
CurrentFrame++;
|
|
}
|
|
|
|
public override void InnerDispose()
|
|
{
|
|
Frames.ForEach(f => f.Texture.Dispose());
|
|
Frames.Clear();
|
|
}
|
|
|
|
public ImGuiGif Prepare(Emote emote)
|
|
{
|
|
var ct = EmoteCache.Token;
|
|
EmoteCache.TrackLoad(Task.Run(() => LoadAsyncTracked(emote, ct), ct), emote.Code);
|
|
return this;
|
|
}
|
|
|
|
private async Task LoadAsyncTracked(Emote emote, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var image = await LoadAsync(emote, ct);
|
|
if (image.Length <= 0)
|
|
return;
|
|
|
|
using var ms = new MemoryStream(image);
|
|
using var img = Image.Load<Rgba32>(ms);
|
|
if (img.Frames.Count == 0)
|
|
return;
|
|
|
|
var frames = new List<(IDalamudTextureWrap Tex, float Delay)>();
|
|
foreach (var frame in img.Frames)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
var delay = frame.Metadata.GetGifMetadata().FrameDelay / 100f;
|
|
|
|
// Match browser behaviour: anything under 20ms rounds up to 100ms.
|
|
if (delay < 0.02f)
|
|
delay = 0.1f;
|
|
|
|
var buffer = new byte[4 * frame.Width * frame.Height];
|
|
frame.CopyPixelDataTo(buffer);
|
|
var tex = await Plugin.TextureProvider.CreateFromRawAsync(
|
|
RawImageSpecification.Rgba32(frame.Width, frame.Height),
|
|
buffer,
|
|
cancellationToken: ct
|
|
);
|
|
frames.Add((tex, delay));
|
|
}
|
|
|
|
Frames = frames;
|
|
IsLoaded = true;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Plugin disposed mid-load; release any partial frames.
|
|
foreach (var f in Frames)
|
|
f.Texture.Dispose();
|
|
Frames = [];
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Failed = true;
|
|
Plugin.LogProxy.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}");
|
|
}
|
|
}
|
|
}
|
|
}
|