Files
HellionChat/HellionChat/Util/AutoTranslate.cs
T
JonKazama-Hellion 6051e49307 chore(profiling): instrument plugin-load hot paths (v1.4.9 R3)
Bump AutoTranslate-warmup and FilterAllTabs log-level from Debug to
Information so the xllog tail surfaces them without a Debug filter.
Wrap MessageStore.Connect and MessageStore.Migrate in Stopwatches so
the SQLite open and migration-chain costs are visible too.

Sub-Task 3.4 Befund on v1.4.8-baseline (4 reloads, medians):
- MessageStore.Connect: 50.5 ms
- MessageStore.Migrate:    2 ms
- MessageManager.FilterAllTabs: 68.5 ms
- AutoTranslate warmup:  108 ms
- UiBuilder HITCH:       108.9 ms

Outcome D — none of the three dominates the 200 ms threshold. The
ChatTwo "300 ms" comment for AutoTranslate is falsified at ~108 ms;
SQLite is not the bottleneck (52.5 ms total); FilterAllTabs runs on
the worker thread and only competes for CPU slots. The HITCH is left
unexplained by these probes, which keeps Hypothesis c (multi-window
WindowSystem.Draw initial pass) as the main R2 suspect to be
validated by the R1 lazy-window refactor.

Logs stay in as belt-and-suspenders for future plugin-load
regressions.
2026-05-14 23:33:56 +02:00

409 lines
14 KiB
C#

using System.Diagnostics;
using System.Globalization;
using System.Text;
using Dalamud.Game;
using Dalamud.Utility;
using Lumina.Excel;
using Lumina.Text.Payloads;
using Lumina.Text.ReadOnly;
using Pidgin;
using static Pidgin.Parser;
using static Pidgin.Parser<char>;
namespace HellionChat.Util;
internal static class AutoTranslate
{
private static readonly Dictionary<ClientLanguage, List<AutoTranslateEntry>> Entries = new();
private static readonly HashSet<(uint, uint)> ValidEntries = [];
// Serialises all reads/writes against Entries and ValidEntries.
// PreloadCache fills both from a worker thread while the main thread
// reads via Matching/ReplaceWithPayload/StartsWithCommand.
private static readonly object EntriesLock = new();
private static Parser<char, (string name, Maybe<IEnumerable<ISelectorPart>> selector)> Parser()
{
var sheetName = Any.AtLeastOnceUntil(Lookahead(Char('[').IgnoreResult().Or(End)))
.Select(string.Concat)
.Labelled("sheetName");
var numPair = Map(
ISelectorPart (first, second) =>
new IndexRange(
uint.Parse(string.Concat(first)),
uint.Parse(string.Concat(second))
),
Digit.AtLeastOnce().Before(Char('-')),
Digit.AtLeastOnce()
)
.Labelled("numPair");
var singleRow = Digit
.AtLeastOnce()
.Select(string.Concat)
.Select(ISelectorPart (num) => new SingleRow(uint.Parse(num)));
var column = String("col-")
.Then(Digit.AtLeastOnce())
.Select(string.Concat)
.Select(ISelectorPart (num) => new ColumnSpecifier(uint.Parse(num)));
var noun = String("noun").Select(ISelectorPart (_) => new NounMarker());
var selectorItems = OneOf(Try(numPair), singleRow, column, noun)
.Separated(Char(','))
.Labelled("selectorItems");
var selector = selectorItems.Between(Char('['), Char(']')).Labelled("selector");
return Map((name, sel) => (name, sel), sheetName, selector.Optional());
}
// Warms the auto-translate cache on a background thread so the first
// message send doesn't hitch the main thread. IsBackground keeps plugin
// unload non-blocking even if the warmup is still in flight.
internal static void PreloadCache()
{
var thread = new Thread(() =>
{
var sw = Stopwatch.StartNew();
AllEntries();
// v1.4.9 R3 profiling: Information so the xllog tail surfaces this
// without a Debug filter. Belt-and-suspenders for future plugin-load
// regressions; remains in place after Sub-Task 3.4 Befund.
Plugin.LogProxy.Information(
$"Warming up auto-translate took {sw.ElapsedMilliseconds}ms"
);
})
{
IsBackground = true,
Name = "HellionChat-AutoTranslate-Warmup",
};
thread.Start();
}
private static List<AutoTranslateEntry> AllEntries()
{
lock (EntriesLock)
{
if (Entries.TryGetValue(Plugin.DataManager.Language, out var entries))
return entries;
return BuildEntriesLocked();
}
}
private static List<AutoTranslateEntry> BuildEntriesLocked()
{
var shouldAdd = ValidEntries.Count == 0;
var parser = Parser();
var list = new List<AutoTranslateEntry>();
foreach (var row in Sheets.CompletionSheet)
{
var lookup = string.Concat(
row.LookupTable.Select(p =>
p.Type == ReadOnlySePayloadType.Text ? Encoding.UTF8.GetString(p.Body.Span)
: p.MacroCode == MacroCode.Num
&& p.TryGetExpression(out var num)
&& num.TryGetInt(out var val)
? val.ToString(CultureInfo.InvariantCulture)
: ",,,unexpected macro code,,,"
)
);
try
{
if (lookup is not ("" or "@"))
{
// SE added whitespace to newer entries; strip it before parsing.
lookup = lookup.Replace(" ", "");
var (sheetName, selector) = parser.ParseOrThrow(lookup);
var sheet = Plugin.DataManager.Excel.GetSheet<RawRow>(name: sheetName);
var columns = new List<int>();
var rows = new List<Range>();
if (selector.HasValue)
{
columns.Clear();
rows.Clear();
foreach (var part in selector.Value)
{
switch (part)
{
case IndexRange range:
{
var start = (int)range.Start;
var end = (int)(range.End + 1);
rows.Add(start..end);
break;
}
case SingleRow single:
{
var idx = (int)single.Row;
rows.Add(idx..(idx + 1));
break;
}
case ColumnSpecifier col:
columns.Add((int)col.Column);
break;
}
}
}
if (columns.Count == 0)
columns.Add(0);
if (rows.Count == 0)
// Can't use index-from-end here because we iterate over integers,
// not an array directly. `0..^0` would silently skip the sheet.
rows.Add(..Index.FromStart((int)sheet.GetRowAt(sheet.Count - 1).RowId + 1));
foreach (var range in rows)
{
// Integer iteration -- can't use index-from-end (see above).
for (var i = range.Start.Value; i < range.End.Value; i++)
{
if (!sheet.TryGetRow((uint)i, out var rowParser))
continue;
foreach (var col in columns)
{
var rawName = rowParser.ReadStringColumn(col);
if (!rawName.IsEmpty)
{
list.Add(
new AutoTranslateEntry(
row.Group,
(uint)i,
rawName.ToString(),
string.Empty
)
);
if (shouldAdd)
ValidEntries.Add((row.Group, (uint)i));
}
}
}
}
}
else if (lookup is not "@")
{
if (row.Text.IsEmpty)
continue;
list.Add(
new AutoTranslateEntry(
row.Group,
row.RowId,
row.Text.ToString(),
row.GroupTitle.ToString()
)
);
if (shouldAdd)
ValidEntries.Add((row.Group, row.RowId));
}
}
catch (Exception ex)
{
Plugin.LogProxy.Error(ex, $"failed to translate: {lookup}");
}
}
Entries[Plugin.DataManager.Language] = list;
return list;
}
internal static List<AutoTranslateEntry> Matching(string prefix, bool sort)
{
var wholeMatches = new List<AutoTranslateEntry>();
var prefixMatches = new List<AutoTranslateEntry>();
var otherMatches = new List<AutoTranslateEntry>();
foreach (var entry in AllEntries())
{
if (entry.Text.Equals(prefix, StringComparison.OrdinalIgnoreCase))
{
wholeMatches.Add(entry);
}
else if (entry.Text.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
prefixMatches.Add(entry);
}
else if (entry.Text.Contains(prefix, StringComparison.OrdinalIgnoreCase))
{
otherMatches.Add(entry);
}
else if (entry.Title.Length > 0)
{
if (entry.Title.Equals(prefix, StringComparison.OrdinalIgnoreCase))
wholeMatches.Add(entry);
else if (entry.Title.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
prefixMatches.Add(entry);
else if (entry.Title.Contains(prefix, StringComparison.OrdinalIgnoreCase))
otherMatches.Add(entry);
}
}
if (sort)
{
return wholeMatches
.OrderBy(entry => entry.Text, StringComparer.OrdinalIgnoreCase)
.Concat(
prefixMatches.OrderBy(entry => entry.Text, StringComparer.OrdinalIgnoreCase)
)
.Concat(otherMatches.OrderBy(entry => entry.Text, StringComparer.OrdinalIgnoreCase))
.ToList();
}
return wholeMatches.Concat(prefixMatches).Concat(otherMatches).ToList();
}
internal static void ReplaceWithPayload(ref byte[] bytes)
{
var search = "<at:"u8.ToArray();
if (bytes.Length <= search.Length)
return;
bool needBuild;
lock (EntriesLock)
needBuild = ValidEntries.Count == 0;
if (needBuild)
AllEntries();
var start = -1;
for (var i = 0; i < bytes.Length; i++)
{
if (start != -1)
{
if (bytes[i] != '>')
continue;
var tag = Encoding.UTF8.GetString(bytes[start..(i + 1)]);
var parts = tag[4..^1].Split(',', 2);
if (
parts.Length == 2
&& uint.TryParse(parts[0], out var group)
&& uint.TryParse(parts[1], out var key)
)
{
bool isValid;
lock (EntriesLock)
isValid = ValidEntries.Contains((group, key));
var payload = isValid ? CreateFixedTranslation(group, key) : [];
var oldBytes = bytes.ToArray();
var lengthDiff = payload.Length - (i - start);
bytes = new byte[oldBytes.Length + lengthDiff];
Array.Copy(oldBytes, bytes, start);
Array.Copy(payload, 0, bytes, start, payload.Length);
Array.Copy(
oldBytes,
i + 1,
bytes,
start + payload.Length,
oldBytes.Length - (i + 1)
);
i += lengthDiff;
}
start = -1;
}
// Span comparison avoids the msvcrt.dll P/Invoke which is fragile
// under Wine and caused an extra managed-to-unmanaged copy per check.
if (
i + search.Length < bytes.Length
&& bytes.AsSpan(i, search.Length).SequenceEqual(search)
)
start = i;
}
}
public static bool StartsWithCommand(ref byte[] bytes)
{
var search = "<at:"u8;
if (bytes.Length <= search.Length)
return false;
bool needBuild;
lock (EntriesLock)
needBuild = ValidEntries.Count == 0;
if (needBuild)
AllEntries();
for (var i = 0; i < search.Length; i++)
{
if (bytes[i] != search[i])
return false;
}
for (var i = 0; i < bytes.Length; i++)
{
if (bytes[i] != '>')
continue;
var tag = Encoding.UTF8.GetString(bytes[..(i + 1)]);
var parts = tag[4..^1].Split(',', 2);
if (
parts.Length == 2
&& uint.TryParse(parts[0], out var group)
&& uint.TryParse(parts[1], out var key)
)
{
bool isValid;
lock (EntriesLock)
isValid = ValidEntries.Contains((group, key));
if (!isValid)
return false;
var evaluated = Plugin
.Evaluator.Evaluate(new ReadOnlySeString(CreateFixedTranslation(group, key)))
.ToString();
if (!evaluated.StartsWith('/'))
return false;
bytes = Encoding.UTF8.GetBytes(evaluated);
return true;
}
}
return false;
}
private static byte[] CreateFixedTranslation(uint group, uint key)
{
using var rssb = new RentedSeStringBuilder();
return rssb
.Builder.BeginMacro(MacroCode.Fixed)
.AppendUIntExpression(group - 1)
.AppendUIntExpression(key)
.EndMacro()
.ToArray();
}
}
internal interface ISelectorPart { }
internal class SingleRow(uint row) : ISelectorPart
{
public uint Row { get; } = row;
}
internal class IndexRange(uint start, uint end) : ISelectorPart
{
public uint Start { get; } = start;
public uint End { get; } = end;
}
internal class NounMarker : ISelectorPart { }
internal class ColumnSpecifier(uint column) : ISelectorPart
{
public uint Column { get; } = column;
}
internal class AutoTranslateEntry(uint group, uint row, string str, string title)
{
internal uint Group { get; } = group;
internal uint Row { get; } = row;
internal string Text { get; } = str;
internal string Title { get; } = title;
}