fix: make plugin work on stock Dalamud

Use some horrible, cursed AppDomain shit to load dependencies that break on normal Dalamud in their own environment, then do classification there instead.
This commit is contained in:
Anna 2020-12-23 03:52:19 -05:00
parent 76462ff628
commit 22ebb14e40
18 changed files with 220 additions and 182 deletions

View File

@ -1,35 +0,0 @@
using System;
using System.IO;
using System.Threading.Channels;
using Microsoft.ML;
namespace NoSoliciting.Classifier {
public class Classifier : IDisposable {
private string ConfigPath { get; }
private MLContext Context { get; }
private ITransformer Model { get; }
private DataViewSchema Schema { get; }
private PredictionEngine<MessageData, MessagePrediction> PredictionEngine { get; }
public Classifier(string configPath) {
this.ConfigPath = configPath;
this.Context = new MLContext();
this.Model = this.Context.Model.Load(Path.Combine(this.ConfigPath, "model.zip"), out var schema);
this.Schema = schema;
this.PredictionEngine = this.Context.Model.CreatePredictionEngine<MessageData, MessagePrediction>(this.Model, this.Schema);
}
public string Classify(ushort channel, string message) {
var data = new MessageData(channel, message);
var pred = this.PredictionEngine.Predict(data);
return pred.Category;
}
public void Dispose() {
this.PredictionEngine.Dispose();
}
}
}

View File

@ -1,24 +0,0 @@
using Microsoft.ML.Data;
namespace NoSoliciting.Classifier {
public class MessageData {
public string? Category { get; }
public uint Channel { get; }
public string Message { get; }
public MessageData(uint channel, string message) {
this.Channel = channel;
this.Message = message;
}
}
public class MessagePrediction {
[ColumnName("PredictedLabel")]
public string Category { get; set; } = null!;
[ColumnName("Score")]
public float[] Probabilities { get; set; } = null!;
}
}

View File

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<runtime>
<assemblyBinding>
<dependentAssembly>
<assemblyIdentity name="System.Numerics.Vectors"
culture="neutral"
publicKeyToken="b03f5f7f11d50a3a" />
<bindingRedirect oldVersion="4.1.3.0"
newVersion="4.1.4.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>

View File

@ -0,0 +1,31 @@
using System;
using System.IO;
using Microsoft.ML;
using NoSoliciting.Interface;
namespace NoSoliciting.CursedWorkaround {
[Serializable]
public class CursedWorkaround : MarshalByRefObject, IClassifier, IDisposable {
private MLContext Context { get; set; } = null!;
private ITransformer Model { get; set; } = null!;
private DataViewSchema Schema { get; set; } = null!;
private PredictionEngine<MessageData, MessagePrediction> PredictionEngine { get; set; } = null!;
public void Initialise(byte[] data) {
this.Context = new MLContext();
using var stream = new MemoryStream(data);
var model = this.Context.Model.Load(stream, out var schema);
this.Model = model;
this.Schema = schema;
this.PredictionEngine = this.Context.Model.CreatePredictionEngine<MessageData, MessagePrediction>(this.Model, this.Schema);
}
public string Classify(ushort channel, string message) {
return this.PredictionEngine.Predict(new MessageData(channel, message)).Category;
}
public void Dispose() {
this.PredictionEngine.Dispose();
}
}
}

View File

@ -3,14 +3,7 @@
<Costura>
<ExcludeAssemblies>
Costura
NoSoliciting.Interface
</ExcludeAssemblies>
<Unmanaged64Assemblies>
CpuMathNative
LdaNative
</Unmanaged64Assemblies>
<PreloadOrder>
CpuMathNative
LdaNative
</PreloadOrder>
</Costura>
</Weavers>

View File

@ -0,0 +1,63 @@
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.ML.Data;
namespace NoSoliciting.CursedWorkaround {
public class MessageData {
private static readonly Regex WardRegex = new Regex(@"w.{0,2}\d", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex PlotRegex = new Regex(@"p.{0,2}\d", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly string[] PlotWords = {
"plot",
"apartment",
"apt",
};
private static readonly Regex NumbersRegex = new Regex(@"\d{1,2}.{0,2}\d{1,2}", RegexOptions.Compiled);
private static readonly string[] TradeWords = {
"B>",
"S>",
"buy",
"sell",
};
public string? Category { get; }
public uint Channel { get; }
public string Message { get; }
public bool PartyFinder => this.Channel == 0;
public bool Shout => this.Channel == 11 || this.Channel == 30;
public bool ContainsWard => this.Message.ContainsIgnoreCase("ward") || WardRegex.IsMatch(this.Message);
public bool ContainsPlot => PlotWords.Any(word => this.Message.ContainsIgnoreCase(word)) || PlotRegex.IsMatch(this.Message);
public bool ContainsHousingNumbers => NumbersRegex.IsMatch(this.Message);
public bool ContainsTradeWords => TradeWords.Any(word => this.Message.ContainsIgnoreCase(word));
public MessageData(uint channel, string message) {
this.Channel = channel;
this.Message = message;
}
}
public class MessagePrediction {
[ColumnName("PredictedLabel")]
public string Category { get; set; } = null!;
[ColumnName("Score")]
public float[] Probabilities { get; set; } = null!;
}
public static class RmtExtensions {
public static bool ContainsIgnoreCase(this string haystack, string needle) {
return CultureInfo.InvariantCulture.CompareInfo.IndexOf(haystack, needle, CompareOptions.IgnoreCase) >= 0;
}
}
}

View File

@ -2,16 +2,9 @@
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>8</LangVersion>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<PlatformTarget>x64</PlatformTarget>
<Nullable>enable</Nullable>
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup>
<ItemGroup>
@ -25,4 +18,13 @@
<PackageReference Include="Microsoft.ML" Version="1.5.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\NoSoliciting.Interface\NoSoliciting.Interface.csproj" />
</ItemGroup>
<ItemGroup>
<None Remove="costura64\CpuMathNative.dll" />
<EmbeddedResource Include="costura64\CpuMathNative.dll" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,7 @@
namespace NoSoliciting.Interface {
public interface IClassifier {
void Initialise(byte[] data);
string Classify(ushort channel, string message);
}
}

View File

@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
</PropertyGroup>
</Project>

View File

@ -9,7 +9,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NoSoliciting.Tests", "NoSoliciting.Tests\NoSoliciting.Tests.csproj", "{1962D91F-543A-4214-88FD-788BB7ACECE3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NoSoliciting.Classifier", "NoSoliciting.Classifier\NoSoliciting.Classifier.csproj", "{F5F1671E-8DC4-47E4-AD3E-55DA5BDF1B93}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NoSoliciting.CursedWorkaround", "NoSoliciting.CursedWorkaround\NoSoliciting.CursedWorkaround.csproj", "{F3238422-A9D8-4E71-9365-9EFFEC85CB59}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NoSoliciting.Interface", "NoSoliciting.Interface\NoSoliciting.Interface.csproj", "{E88E57AB-EFB8-4F2F-93DB-F63123638C44}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -25,10 +27,14 @@ Global
{1962D91F-543A-4214-88FD-788BB7ACECE3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1962D91F-543A-4214-88FD-788BB7ACECE3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1962D91F-543A-4214-88FD-788BB7ACECE3}.Release|Any CPU.Build.0 = Release|Any CPU
{F5F1671E-8DC4-47E4-AD3E-55DA5BDF1B93}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F5F1671E-8DC4-47E4-AD3E-55DA5BDF1B93}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F5F1671E-8DC4-47E4-AD3E-55DA5BDF1B93}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F5F1671E-8DC4-47E4-AD3E-55DA5BDF1B93}.Release|Any CPU.Build.0 = Release|Any CPU
{F3238422-A9D8-4E71-9365-9EFFEC85CB59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F3238422-A9D8-4E71-9365-9EFFEC85CB59}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F3238422-A9D8-4E71-9365-9EFFEC85CB59}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F3238422-A9D8-4E71-9365-9EFFEC85CB59}.Release|Any CPU.Build.0 = Release|Any CPU
{E88E57AB-EFB8-4F2F-93DB-F63123638C44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E88E57AB-EFB8-4F2F-93DB-F63123638C44}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E88E57AB-EFB8-4F2F-93DB-F63123638C44}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E88E57AB-EFB8-4F2F-93DB-F63123638C44}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@ -30,10 +30,11 @@ namespace NoSoliciting {
public Message(uint defsVersion, ChatType type, string sender, string content, string? reason) : this(
defsVersion,
type,
new SeString(new Payload[] { new TextPayload(sender) }),
new SeString(new Payload[] { new TextPayload(content) }),
new SeString(new Payload[] {new TextPayload(sender)}),
new SeString(new Payload[] {new TextPayload(content)}),
reason
) { }
) {
}
[Serializable]
[JsonObject(NamingStrategyType = typeof(SnakeCaseNamingStrategy))]
@ -41,7 +42,9 @@ namespace NoSoliciting {
public Guid Id { get; set; }
public uint DefinitionsVersion { get; set; }
public DateTime Timestamp { get; set; }
public ushort Type { get; set; }
// note: cannot use byte[] because Newtonsoft thinks it's a good idea to always base64 byte[]
// and I don't want to write a custom converter to overwrite their stupiditiy
public List<byte> Sender { get; set; }
@ -54,7 +57,7 @@ namespace NoSoliciting {
Id = this.Id,
DefinitionsVersion = this.DefinitionsVersion,
Timestamp = this.Timestamp,
Type = (ushort)this.ChatType,
Type = (ushort) this.ChatType,
Sender = this.Sender.Encode().ToList(),
Content = this.Content.Encode().ToList(),
Reason = this.FilterReason,
@ -154,12 +157,17 @@ namespace NoSoliciting {
public static class ChatTypeExt {
private const ushort Clear7 = ~(~0 << 7);
public static byte LogKind(this ChatType type) => type switch {
ChatType.TellIncoming => (byte) ChatType.TellOutgoing,
_ => (byte) type,
};
public static ChatType FromCode(ushort code) {
return (ChatType)(code & Clear7);
return (ChatType) (code & Clear7);
}
public static ChatType FromDalamud(XivChatType type) {
return FromCode((ushort)type);
return FromCode((ushort) type);
}
public static bool IsBattle(this ChatType type) {

View File

@ -1,12 +1,10 @@
using System;
using System.IO;
using System.Net;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using Dalamud.Plugin;
using Microsoft.ML;
using NoSoliciting.Properties;
using NoSoliciting.Interface;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
@ -20,29 +18,37 @@ namespace NoSoliciting.Ml {
private const string Url = "http://localhost:8000/manifest.yaml";
public uint Version { get; }
private MLContext Context { get; }
private ITransformer Model { get; }
private DataViewSchema Schema { get; }
private PredictionEngine<MessageData, MessagePrediction> PredictionEngine { get; }
private IClassifier Classifier { get; }
private MlFilter(uint version, MLContext context, ITransformer model, DataViewSchema schema) {
private MlFilter(uint version, IClassifier classifier) {
this.Version = version;
this.Context = context;
this.Model = model;
this.Schema = schema;
this.PredictionEngine = this.Context.Model.CreatePredictionEngine<MessageData, MessagePrediction>(this.Model, this.Schema);
this.Classifier = classifier;
}
// private MLContext Context { get; }
// private ITransformer Model { get; }
// private DataViewSchema Schema { get; }
// private PredictionEngine<MessageData, MessagePrediction> PredictionEngine { get; }
//
// private MlFilter(uint version, MLContext context, ITransformer model, DataViewSchema schema) {
// this.Version = version;
// this.Context = context;
// this.Model = model;
// this.Schema = schema;
// this.PredictionEngine = this.Context.Model.CreatePredictionEngine<MessageData, MessagePrediction>(this.Model, this.Schema);
// }
public MessageCategory ClassifyMessage(ushort channel, string message) {
var data = new MessageData(channel, message);
var pred = this.PredictionEngine.Predict(data);
var category = MessageCategoryExt.FromString(pred.Category);
// var data = new MessageData(channel, message);
// var pred = this.PredictionEngine.Predict(data);
var rawCategory = this.Classifier.Classify(channel, message);
var category = MessageCategoryExt.FromString(rawCategory);
if (category != null) {
return (MessageCategory) category;
}
PluginLog.LogWarning($"Unknown message category: {pred.Category}");
PluginLog.LogWarning($"Unknown message category: {rawCategory}");
return MessageCategory.Normal;
}
@ -72,11 +78,18 @@ namespace NoSoliciting.Ml {
UpdateCachedFile(plugin, ModelName, data);
UpdateCachedFile(plugin, ManifestName, Encoding.UTF8.GetBytes(manifest.Item2));
var context = new MLContext();
using var stream = new MemoryStream(data);
var model = context.Model.Load(stream, out var schema);
// var context = new MLContext();
// using var stream = new MemoryStream(data);
// var model = context.Model.Load(stream, out var schema);
return new MlFilter(manifest.Item1.Version, context, model, schema);
// return new MlFilter(manifest.Item1.Version, context, model, schema);
plugin.Classifier.Initialise(data);
return new MlFilter(
manifest.Item1.Version,
plugin.Classifier
);
}
private static async Task<byte[]?> DownloadModel(Uri url) {
@ -150,7 +163,7 @@ namespace NoSoliciting.Ml {
}
public void Dispose() {
this.PredictionEngine.Dispose();
// this.PredictionEngine.Dispose();
}
}
}

View File

@ -4,57 +4,6 @@ using System.Text.RegularExpressions;
using Microsoft.ML.Data;
namespace NoSoliciting.Ml {
public class MessageData {
private static readonly Regex WardRegex = new Regex(@"w.{0,2}\d", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex PlotRegex = new Regex(@"p.{0,2}\d", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly string[] PlotWords = {
"plot",
"apartment",
"apt",
};
private static readonly Regex NumbersRegex = new Regex(@"\d{1,2}.{0,2}\d{1,2}", RegexOptions.Compiled);
private static readonly string[] TradeWords = {
"B>",
"S>",
"buy",
"sell",
};
public string? Category { get; }
public uint Channel { get; }
public string Message { get; }
public bool PartyFinder => this.Channel == 0;
public bool Shout => this.Channel == 11 || this.Channel == 30;
public bool ContainsWard => this.Message.ContainsIgnoreCase("ward") || WardRegex.IsMatch(this.Message);
public bool ContainsPlot => PlotWords.Any(word => this.Message.ContainsIgnoreCase(word)) || PlotRegex.IsMatch(this.Message);
public bool ContainsHousingNumbers => NumbersRegex.IsMatch(this.Message);
public bool ContainsTradeWords => TradeWords.Any(word => this.Message.ContainsIgnoreCase(word));
public MessageData(uint channel, string message) {
this.Channel = channel;
this.Message = message;
}
}
public class MessagePrediction {
[ColumnName("PredictedLabel")]
public string Category { get; set; } = null!;
[ColumnName("Score")]
public float[] Probabilities { get; set; } = null!;
}
public enum MessageCategory {
Trade,
FreeCompany,
@ -80,13 +29,13 @@ namespace NoSoliciting.Ml {
};
public static string Name(this MessageCategory category) => category switch {
MessageCategory.Trade => "Trades",
MessageCategory.Trade => "Trade ads",
MessageCategory.FreeCompany => "Free Company ads",
MessageCategory.Normal => "Normal messages",
MessageCategory.Phishing => "Phishing messages",
MessageCategory.RmtContent => "RMT (content)",
MessageCategory.RmtGil => "RMT (gil)",
MessageCategory.Roleplaying => "Roleplaying",
MessageCategory.Roleplaying => "Roleplaying ads",
MessageCategory.Static => "Static recruitment",
_ => throw new ArgumentException("Invalid category", nameof(category)),
};

View File

@ -36,7 +36,6 @@
</Reference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.ML" Version="1.5.4" />
<PackageReference Include="YamlDotNet" Version="9.1.0" />
<PackageReference Include="ILRepack.Lib.MSBuild.Task" Version="2.0.18.2" />
</ItemGroup>
@ -53,4 +52,12 @@
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<None Update="NoSoliciting.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\NoSoliciting.CursedWorkaround\NoSoliciting.CursedWorkaround.csproj" />
</ItemGroup>
</Project>

View File

@ -1,7 +1,7 @@
{
"Author": "ascclemens",
"Name": "NoSoliciting",
"Description": "Hides RMT in chat and the party finder. /prmt",
"Description": "Customisable chat and Party Finder filtering. In addition to letting you filter anything from chat and PF, it comes with built-in filters for the following:\n\n- RMT (both gil and content)\n- FC ads\n- RP ads\n- Phishing messages\n- Static recruitment\n- Trade ads\n- Any PF with an item level over the max\n\nNow with experimental machine learning!",
"InternalName": "NoSoliciting",
"AssemblyVersion": "1.5.0",
"RepoUrl": "https://git.sr.ht/~jkcclemens/NoSoliciting",

View File

@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Threading.Tasks;
using NoSoliciting.Interface;
using NoSoliciting.Ml;
namespace NoSoliciting {
@ -33,9 +34,20 @@ namespace NoSoliciting {
// ReSharper disable once AutoPropertyCanBeMadeGetOnly.Local
public string AssemblyLocation { get; private set; } = Assembly.GetExecutingAssembly().Location;
private const string LibraryName = "NoSoliciting.CursedWorkaround";
private AppDomain InnerDomain { get; set; } = null!;
public IClassifier Classifier { get; private set; } = null!;
public void Initialize(DalamudPluginInterface pluginInterface) {
// NOTE: THE SECRET IS TO DOWNGRADE System.Numerics.Vectors THAT'S INCLUDED WITH DALAMUD
// CRY
// FIXME: eventually this cursed workaround for old System.Numerics.Vectors should be destroyed
this.InnerDomain = AppDomain.CreateDomain(LibraryName, AppDomain.CurrentDomain.Evidence, new AppDomainSetup {
ApplicationName = LibraryName,
ConfigurationFile = $"{LibraryName}.dll.config",
ApplicationBase = Path.GetDirectoryName(this.AssemblyLocation),
});
this.Classifier = (IClassifier) this.InnerDomain.CreateInstanceAndUnwrap(LibraryName, $"{LibraryName}.CursedWorkaround");
string path = Environment.GetEnvironmentVariable("PATH")!;
string newPath = Path.GetDirectoryName(this.AssemblyLocation)!;
@ -78,7 +90,11 @@ namespace NoSoliciting {
}
Task.Run(async () => { this.MlFilter = await MlFilter.Load(this); })
.ContinueWith(_ => PluginLog.Log("Machine learning model loaded"));
.ContinueWith(e => {
if (!e.IsFaulted) {
PluginLog.Log("Machine learning model loaded");
}
});
}
internal void UpdateDefinitions() {
@ -125,6 +141,9 @@ namespace NoSoliciting {
this.Interface.UiBuilder.OnBuildUi -= this.Ui.Draw;
this.Interface.UiBuilder.OnOpenConfigUi -= this.Ui.OpenSettings;
this.Interface.CommandManager.RemoveHandler("/prmt");
// AppDomain.CurrentDomain.AssemblyResolve -= this.ResolveAssembly;
AppDomain.Unload(this.InnerDomain);
}
this._disposedValue = true;

View File

@ -11,6 +11,7 @@ using System.Net;
using System.Numerics;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Lumina.Excel.GeneratedSheets;
using NoSoliciting.Ml;
namespace NoSoliciting {
@ -275,7 +276,13 @@ namespace NoSoliciting {
var types = this.Plugin.Config.MlFilters[category];
void DrawTypes(ChatType type) {
var name = type == ChatType.None ? "Party Finder" : type.ToString();
string name;
if (type == ChatType.None) {
name = "Party Finder";
} else {
var lf = this.Plugin.Interface.Data.GetExcelSheet<LogFilter>().FirstOrDefault(lf => lf.LogKind == type.LogKind());
name = lf?.Name?.ToString() ?? type.ToString();
}
var check = types.Contains(type);
if (!ImGui.Checkbox(name, ref check)) {