71aefd30eb
Two long-standing upstream bugs surfaced in our distribution as red error rows on every plugin load. Neither is caused by the fork's own code, but both happen under our plugin name in the log so they had to go before the plugin gets handed to anyone. EmoteCache.LoadData fed BetterTTV's API responses straight into Dictionary.TryAdd. The Top100 endpoint occasionally returns rows with a null Code field, which tripped ArgumentNullException and killed the entire emote load. Filter null/empty codes out explicitly before insertion so a single bad row no longer breaks the cache for everyone else. FontManager.BuildFonts called fontId.AddToBuildToolkit without guarding for the case where the configured SystemFontId points at a font that isn't installed (e.g. "Crimson Text" on a Linux box, or a font the user uninstalled after picking it). The atlas build then crashed and dumped a Dalamud DelegateFontHandle error every single load. Wrap the call in AddFontWithFallback: if the font isn't found, log a warning, fall back to the bundled NotoSansCjkRegular asset and continue. Applied to both the regular and italic font handles.
310 lines
9.4 KiB
C#
310 lines
9.4 KiB
C#
using System.Numerics;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Dalamud.Interface.Textures;
|
|
using Dalamud.Interface.Textures.TextureWraps;
|
|
using Dalamud.Utility;
|
|
using Dalamud.Bindings.ImGui;
|
|
using SixLabors.ImageSharp;
|
|
using SixLabors.ImageSharp.PixelFormats;
|
|
|
|
namespace ChatTwo;
|
|
|
|
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 = default;
|
|
|
|
[JsonPropertyName("id")]
|
|
public string Id = string.Empty;
|
|
}
|
|
|
|
[Serializable]
|
|
public struct Emote()
|
|
{
|
|
[JsonPropertyName("id")]
|
|
public string Id = string.Empty;
|
|
|
|
[JsonPropertyName("code")]
|
|
public string Code = string.Empty;
|
|
|
|
[JsonPropertyName("imageType")]
|
|
public string ImageType = string.Empty;
|
|
}
|
|
|
|
public enum LoadingState
|
|
{
|
|
Unloaded,
|
|
Loading,
|
|
Done
|
|
}
|
|
|
|
// All of this data is uninitalized while State is not `LoadingState.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 = [];
|
|
|
|
public static async void LoadData()
|
|
{
|
|
if (State is not LoadingState.Unloaded)
|
|
return;
|
|
|
|
State = LoadingState.Loading;
|
|
try
|
|
{
|
|
var global = await Client.GetAsync(GlobalEmotes);
|
|
var globalList = await global.Content.ReadAsStringAsync();
|
|
|
|
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));
|
|
var topList = await top.Content.ReadAsStringAsync();
|
|
|
|
var jsonList = JsonSerializer.Deserialize<List<Top100>>(topList)!;
|
|
// BetterTTV occasionally returns entries with a null Code; the
|
|
// upstream code passed those straight into Dictionary.TryAdd
|
|
// and tripped ArgumentNullException, killing the whole emote
|
|
// load. Skip them defensively so a single bad row no longer
|
|
// breaks the cache for everyone else.
|
|
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 (Exception ex)
|
|
{
|
|
Plugin.Log.Error(ex, "BetterTTV cache wasn't initialized");
|
|
}
|
|
}
|
|
|
|
public static void Dispose()
|
|
{
|
|
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.Log.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)
|
|
{
|
|
var dir = Path.Join(Plugin.Interface.ConfigDirectory.FullName, "EmoteCacheV1");
|
|
Directory.CreateDirectory(dir);
|
|
|
|
var filePath = Path.Join(dir, $"{emote.Id}.{emote.ImageType}");
|
|
if (File.Exists(filePath))
|
|
{
|
|
RawData = await File.ReadAllBytesAsync(filePath);
|
|
}
|
|
else
|
|
{
|
|
var content = await new HttpClient().GetAsync(EmotePath.Format(emote.Id));
|
|
RawData = await content.Content.ReadAsByteArrayAsync();
|
|
|
|
await using var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read);
|
|
stream.Write(RawData, 0, RawData.Length);
|
|
}
|
|
|
|
return RawData;
|
|
}
|
|
|
|
public abstract void InnerDispose();
|
|
}
|
|
|
|
public sealed class ImGuiEmote : EmoteBase
|
|
{
|
|
public ImGuiEmote Prepare(Emote emote)
|
|
{
|
|
Task.Run(() => Load(emote));
|
|
return this;
|
|
}
|
|
|
|
private async void Load(Emote emote)
|
|
{
|
|
try
|
|
{
|
|
var image = await LoadAsync(emote);
|
|
if (image.Length <= 0)
|
|
return;
|
|
|
|
Texture = await Plugin.TextureProvider.CreateFromImageAsync(image);
|
|
IsLoaded = true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Failed = true;
|
|
Plugin.Log.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)
|
|
{
|
|
Task.Run(() => Load(emote));
|
|
return this;
|
|
}
|
|
|
|
private async void Load(Emote emote)
|
|
{
|
|
try
|
|
{
|
|
var image = await LoadAsync(emote);
|
|
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)
|
|
{
|
|
var delay = frame.Metadata.GetGifMetadata().FrameDelay / 100f;
|
|
|
|
// Follows the same pattern as browsers, anything under 0.02s delay will be rounded up to 0.1s
|
|
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);
|
|
frames.Add((tex, delay));
|
|
}
|
|
|
|
Frames = frames;
|
|
IsLoaded = true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Failed = true;
|
|
Plugin.Log.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}");
|
|
}
|
|
}
|
|
}
|
|
} |