Files
HellionChat/ChatTwo/Util/AutoTranslate.cs
T
Dean Sheather fdae6b1cd2 fix: add Races, Clans and Companion Actions to auto-translate
The Race, Tribe and BuddyAction sheets were skipped as the default
behavior was to include rows from `0..^0` if no range was specified in
the Completion sheet. That range would usually include everything, but
since the ranges were iterated over as integers there is no "last" index
so `^0` equals zero.

Also preloads the AutoTranslate cache on plugin load in a new thread to
avoid a hitch when sending the first message. This behavior is disabled
in DEBUG builds to make profiling easier.
2024-04-29 18:55:24 +10:00

366 lines
12 KiB
C#

using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using Dalamud;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Plugin.Services;
using Dalamud.Utility;
using Lumina.Excel;
using Lumina.Excel.GeneratedSheets;
using Pidgin;
using static Pidgin.Parser;
using static Pidgin.Parser<char>;
using TextPayload = Lumina.Text.Payloads.TextPayload;
namespace ChatTwo.Util;
internal static class AutoTranslate
{
private static readonly Dictionary<ClientLanguage, List<AutoTranslateEntry>> Entries = new();
private static readonly HashSet<(uint, uint)> ValidEntries = 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(
(first, second) => (ISelectorPart) 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(num => (ISelectorPart) new SingleRow(uint.Parse(num)));
var column = String("col-")
.Then(Digit.AtLeastOnce())
.Select(string.Concat)
.Select(num => (ISelectorPart) 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, selector) => (name, selector),
sheetName,
selector.Optional()
);
}
private static string TextValue(this Lumina.Text.SeString str)
{
var payloads = str.Payloads
.Select(p => {
if (p is TextPayload text) {
return p.Data[0] == 0x03
? text.RawString[1..]
: text.RawString;
}
if (p.Data.Length <= 1) {
return "";
}
if (p.Data[1] == 0x1F) {
return "-";
}
if (p.Data.Length > 2 && p.Data[1] == 0x20) {
var value = p.Data.Length > 4
? p.Data[3] - 1
: p.Data[2];
return ((char) (48 + value)).ToString();
}
return "";
});
return string.Join("", payloads);
}
/// <summary>
/// Preloads auto-translate entries into the cache for the current game
/// language. Without this, the first message will take a long time to send
/// (which causes a hitch in the main thread).
///
/// This spawns a new thread.
/// </summary>
internal static void PreloadCache(IDataManager data)
{
new Thread(() =>
{
var sw = Stopwatch.StartNew();
AllEntries(data);
Plugin.Log.Debug($"Warming up auto-translate took {sw.ElapsedMilliseconds}ms");
}).Start();
}
private static List<AutoTranslateEntry> AllEntries(IDataManager data)
{
if (Entries.TryGetValue(data.Language, out var entries))
return entries;
var shouldAdd = ValidEntries.Count == 0;
var parser = Parser();
var list = new List<AutoTranslateEntry>();
foreach (var row in data.GetExcelSheet<Completion>()!)
{
var lookup = row.LookupTable.TextValue();
if (lookup is not ("" or "@"))
{
var (sheetName, selector) = parser.ParseOrThrow(lookup);
var sheetType = typeof(Completion)
.Assembly
.GetType($"Lumina.Excel.GeneratedSheets.{sheetName}")!;
var getSheet = data
.GetType()
.GetMethod("GetExcelSheet", Type.EmptyTypes)!
.MakeGenericMethod(sheetType);
var sheet = (ExcelSheetImpl) getSheet.Invoke(data, null)!;
var rowParsers = sheet.GetRowParsers().ToArray();
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);
var validRows = rowParsers.Select(p => p.RowId).ToArray();
if (rows.Count == 0)
// We can't use an "index from end" (like `^0`) here because
// we're iterating over integers, not an array directly.
// Previously, we were setting `0..^0` which caused these
// sheets to be completely skipped due to this bug.
// See below.
rows.Add(..Index.FromStart((int)validRows.Max()));
foreach (var range in rows)
{
// We iterate over the range by numerical values here, so
// we can't use an "index from end" otherwise nothing will
// happen.
// See above.
for (var i = range.Start.Value; i < range.End.Value; i++)
{
if (!validRows.Contains((uint) i))
continue;
foreach (var col in columns)
{
var rowParser = rowParsers.FirstOrDefault(p => p.RowId == i);
if (rowParser == null)
continue;
var rawName = rowParser.ReadColumn<Lumina.Text.SeString>(col)!;
var name = rawName.ToDalamudString();
var text = name.TextValue;
if (text.Length > 0)
{
list.Add(new AutoTranslateEntry(
row.Group,
(uint) i,
text,
name
));
if (shouldAdd)
ValidEntries.Add((row.Group, (uint) i));
}
}
}
}
}
else if (lookup is not "@")
{
var text = row.Text.ToDalamudString();
list.Add(new AutoTranslateEntry(
row.Group,
row.RowId,
text.TextValue,
text
));
if (shouldAdd)
ValidEntries.Add((row.Group, row.RowId));
}
}
Entries[data.Language] = list;
return list;
}
internal static List<AutoTranslateEntry> Matching(IDataManager data, string prefix, bool sort)
{
var wholeMatches = new List<AutoTranslateEntry>();
var prefixMatches = new List<AutoTranslateEntry>();
var otherMatches = new List<AutoTranslateEntry>();
foreach (var entry in AllEntries(data))
{
if (entry.String.Equals(prefix, StringComparison.OrdinalIgnoreCase))
wholeMatches.Add(entry);
else if (entry.String.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
prefixMatches.Add(entry);
else if (entry.String.Contains(prefix, StringComparison.OrdinalIgnoreCase))
otherMatches.Add(entry);
}
if (sort)
{
return wholeMatches.OrderBy(entry => entry.String, StringComparer.OrdinalIgnoreCase)
.Concat(prefixMatches.OrderBy(entry => entry.String, StringComparer.OrdinalIgnoreCase))
.Concat(otherMatches.OrderBy(entry => entry.String, StringComparer.OrdinalIgnoreCase))
.ToList();
}
return wholeMatches
.Concat(prefixMatches)
.Concat(otherMatches)
.ToList();
}
[DllImport("msvcrt.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern int memcmp(byte[] b1, byte[] b2, UIntPtr count);
internal static void ReplaceWithPayload(IDataManager data, ref byte[] bytes)
{
var search = "<at:"u8.ToArray();
if (bytes.Length <= search.Length)
return;
// populate the list of valid entries
if (ValidEntries.Count == 0)
AllEntries(data);
var start = -1;
for (var i = 0; i < bytes.Length; i++)
{
if (start != -1) {
if (bytes[i] == '>')
{
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))
{
var payload = ValidEntries.Contains((group, key))
? new AutoTranslatePayload(group, key).Encode()
: Array.Empty<byte>();
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;
}
else
{
continue;
}
}
if (i + search.Length < bytes.Length && memcmp(bytes[i..], search, (UIntPtr) search.Length) == 0)
start = i;
}
}
}
internal interface ISelectorPart { }
internal class SingleRow : ISelectorPart
{
public uint Row { get; }
public SingleRow(uint row)
{
Row = row;
}
}
internal class IndexRange : ISelectorPart
{
public uint Start { get; }
public uint End { get; }
public IndexRange(uint start, uint end)
{
Start = start;
End = end;
}
}
internal class NounMarker : ISelectorPart { }
internal class ColumnSpecifier : ISelectorPart
{
public uint Column { get; }
public ColumnSpecifier(uint column)
{
Column = column;
}
}
internal class AutoTranslateEntry
{
internal uint Group { get; }
internal uint Row { get; }
internal string String { get; }
internal SeString SeString { get; }
public AutoTranslateEntry(uint group, uint row, string str, SeString seStr)
{
Group = group;
Row = row;
String = str;
SeString = seStr;
}
}