| 项目 | 要求 |
|---|---|
| 运行时 | .NET 8.0(Runtime 或 SDK) |
| 编译目标 | net8.0 |
| 开发工具 | Visual Studio 2022+ / Rider / VS Code + C# Dev Kit |
| 项目类型 | Class Library(类库) |
| 程序集名称 | 必须为 Main(见第 2 节) |
dotnet new classlib -n MyPlugin cd MyPlugin
AssemblyName 必须设为 MainMain.dll。
如果 AssemblyName 不是 Main,编译产物名称不对,插件将无法被加载。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Library</OutputType>
<!-- ★ 必须为 Main,编译输出才是 Main.dll ★ -->
<AssemblyName>Main</AssemblyName>
<RootNamespace>MyPlugin</RootNamespace>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- 不生成独立 AssemblyInfo,避免与接口库冲突 -->
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
</PropertyGroup>
<ItemGroup>
<!-- 引用主程序提供的接口库(不要复制到输出目录)-->
<Reference Include="ConverterPlugins">
<HintPath>..\CYLDConverterDev\ConverterPlugins.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<!-- 如需 Newtonsoft.Json,同样设为不复制 -->
<!--
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.*">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
-->
</Project>
ConverterPlugins.dll 由主程序(CYLDConverterDev.exe)提供,位于 exe 同级目录。
开发时将该路径指向本地副本即可;正式发布时无需随插件一起打包。
public interface IConverterPlugin
{
/// <summary>插件唯一名称,不能为空</summary>
string PluginName { get; }
/// <summary>插件简介(显示在插件列表)</summary>
string PluginInfo { get; }
/// <summary>作者</summary>
string Author { get; }
/// <summary>版本号,如 "1.0.0"</summary>
string Version { get; }
/// <summary>详细描述(可多行)</summary>
string DetailedDescription { get; }
/// <summary>创建/更新时间,格式随意</summary>
string CreatedTime { get; }
/// <summary>支持的输入扩展名,必须含点,如 ".litematic"</summary>
string[] SupportedInputExtensions { get; }
/// <summary>输出文件扩展名,必须含点,如 ".txt"</summary>
string SupportedOutputExtension { get; }
/// <summary>执行转换</summary>
/// <param name="inputData">文件原始字节(byte[])</param>
/// <param name="outputPath">输出文件的完整路径</param>
/// <param name="pluginDirectory">本插件子目录的完整路径</param>
/// <param name="options">用户配置的选项字典,或进度回调(见第 6 节)</param>
/// <returns>true = 成功,false = 失败</returns>
bool Convert(object inputData, string outputPath,
string pluginDirectory, object? options);
}
| 属性 / 方法 | 是否必需 | 说明 |
|---|---|---|
PluginName | 必需 | 非空字符串,全局唯一 |
SupportedInputExtensions | 必需 | 非空数组,如 new[]{".txt"} |
SupportedOutputExtension | 必需 | 单个扩展名 |
Convert(...) | 必需 | 核心转换逻辑 |
PluginInfo / Author / Version | 可选 | 显示用,可返回空字符串 |
IPluginOptionsProvider | 可选 | 需要用户输入参数时实现 |
实现此接口后,主程序会在转换前逐条提示用户输入参数,并将结果以 Dictionary<string, object> 形式传入 Convert 的 options 参数。
public interface IPluginOptionsProvider
{
List<PluginOption>? GetOptions();
}
public class PluginOption
{
public string Key { get; set; } = ""; // 字典键名
public string DisplayName { get; set; } = ""; // 交互提示名
public string Description { get; set; } = ""; // 附加说明
public string DefaultValue { get; set; } = ""; // 默认值(字符串形式)
public string ValueType { get; set; } = "string";// int / float / bool / string
public bool IsRequired { get; set; } = false; // 是否必填
}
主程序支持的 ValueType 及对应 C# 类型:
| ValueType 字符串 | 字典值 C# 类型 | 接受的用户输入 |
|---|---|---|
string | string | 任意文本 |
int / integer | int | 整数 |
float / single | float | 小数 |
double | double | 小数 |
bool / boolean | bool | true/false/yes/no/1/0/on/off |
ConverterPlugins.dll — 插件接口库Newtonsoft.Json.dll — JSON 库(若主程序已含)<!-- 方式一:Reference 引用时设 Private=false --> <Reference Include="ConverterPlugins"> <HintPath>path\to\ConverterPlugins.dll</HintPath> <Private>false</Private> </Reference> <!-- 方式二:PackageReference 时排除 runtime 资产 --> <PackageReference Include="Newtonsoft.Json" Version="13.*"> <ExcludeAssets>runtime</ExcludeAssets> </PackageReference>
除上述共享库外,其他第三方依赖(如 SharpCompress.dll)应放入插件子目录,DevPluginLoadContext 会自动从插件目录解析。
插件可能需要外部资源文件(如方块映射表 snbt_convert.txt、ID 映射 id.json 等)。
主程序向插件传入的 pluginDirectory 是该插件的子目录路径,但资源文件可能被放在
Plugins/ 根目录下供多个插件共享。
FindResourceFile() 辅助方法,按以下顺序搜索:
pluginDirectory/filename)out 子目录(pluginDirectory/out/filename)Plugins/ 根目录)/// <summary>
/// 在插件目录及父目录中搜索资源文件。
/// 搜索顺序:pluginDirectory → pluginDirectory/out → pluginDirectory 的父目录
/// </summary>
private static string? FindResourceFile(string pluginDirectory, string fileName)
{
// 1. 插件子目录
string path = Path.Combine(pluginDirectory, fileName);
if (File.Exists(path)) return path;
// 2. 插件子目录的 out 子目录
path = Path.Combine(pluginDirectory, "out", fileName);
if (File.Exists(path)) return path;
// 3. Plugins 根目录(父目录)
string? parentDir = Path.GetDirectoryName(pluginDirectory);
if (parentDir != null)
{
path = Path.Combine(parentDir, fileName);
if (File.Exists(path)) return path;
}
return null;
}
// 使用示例:
// string? mappingFile = FindResourceFile(pluginDirectory, "id.json");
// if (mappingFile != null)
// LoadMapping(mappingFile);
// else
// Console.WriteLine("[WARN] id.json 未找到,将使用内置默认映射");
主程序会将进度回调 Action<int> 通过 options 参数传入(百分比 0–100)。
插件可选择支持此功能,以在控制台显示进度条(由主程序渲染)。
public bool Convert(object inputData, string outputPath,
string pluginDirectory, object? options)
{
// 尝试从 options 中取出进度报告回调
Action<int>? reportProgress = null;
if (options is Action<int> directCallback)
{
reportProgress = directCallback;
}
else if (options is Dictionary<string, object> dict
&& dict.TryGetValue("__progress__", out var cb)
&& cb is Action<int> dictCallback)
{
reportProgress = dictCallback;
}
// 在转换过程中调用:
reportProgress?.Invoke(0); // 开始
// ... 处理 25% ...
reportProgress?.Invoke(25);
// ... 处理 50% ...
reportProgress?.Invoke(50);
// ... 完成 ...
reportProgress?.Invoke(100);
return true;
}
options 中的回调即可,不影响正常运行。
以下示例展示一个将文本文件转换为带行号版本的插件,演示了所有关键模式:
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using ConverterPlugins;
namespace LineNumberPlugin
{
public class LineNumberConverter : IConverterPlugin, IPluginOptionsProvider
{
// ── 基本信息 ────────────────────────────────────────────────────────
public string PluginName => "行号添加器";
public string PluginInfo => "为文本文件每行添加行号";
public string Author => "开发者";
public string Version => "1.0.0";
public string CreatedTime => "2026-04-25";
public string DetailedDescription => "读取文本文件,在每行前加上行号,支持自定义分隔符和起始行号。";
public string[] SupportedInputExtensions => new[] { ".txt", ".log", ".csv" };
public string SupportedOutputExtension => ".txt";
// ── 插件选项 ────────────────────────────────────────────────────────
public List<PluginOption>? GetOptions() => new()
{
new() {
Key = "separator", DisplayName = "行号分隔符",
Description = "行号与内容之间的分隔符,如 \": \" 或 \". \"",
ValueType = "string", DefaultValue = ": ", IsRequired = false
},
new() {
Key = "startLine", DisplayName = "起始行号",
Description = "第一行的行号",
ValueType = "int", DefaultValue = "1", IsRequired = false
},
new() {
Key = "padWidth", DisplayName = "行号宽度",
Description = "行号左补零至此宽度(0=不补零)",
ValueType = "int", DefaultValue = "0", IsRequired = false
}
};
// ── 核心转换 ────────────────────────────────────────────────────────
public bool Convert(object inputData, string outputPath,
string pluginDirectory, object? options)
{
// 1. 取进度回调
Action<int>? progress = options is Action<int> cb ? cb : null;
try
{
// 2. 验证输入
if (inputData is not byte[] bytes)
{
Console.WriteLine("[ERROR] 输入数据类型错误,期望 byte[]");
return false;
}
progress?.Invoke(5);
// 3. 解析选项
var opts = options as Dictionary<string, object> ?? new();
string sep = opts.TryGetValue("separator", out var s) && s is string sv ? sv : ": ";
int start = opts.TryGetValue("startLine", out var st) && st is int si ? si : 1;
int pad = opts.TryGetValue("padWidth", out var pw) && pw is int pi ? pi : 0;
// 4. 读取并处理
string text = Encoding.UTF8.GetString(bytes);
var lines = text.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);
var sb = new StringBuilder(bytes.Length + lines.Length * 6);
progress?.Invoke(20);
for (int i = 0; i < lines.Length; i++)
{
string lineNum = pad > 0
? (start + i).ToString().PadLeft(pad, '0')
: (start + i).ToString();
sb.Append(lineNum).Append(sep).AppendLine(lines[i]);
if (i % 1000 == 0)
progress?.Invoke(20 + (int)(i * 70.0 / lines.Length));
}
progress?.Invoke(90);
// 5. 输出文件
File.WriteAllText(outputPath, sb.ToString(), Encoding.UTF8);
progress?.Invoke(100);
Console.WriteLine($"[INFO] 已处理 {lines.Length} 行 -> {outputPath}");
return true;
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] {PluginName} 转换异常: {ex.Message}");
return false;
}
}
}
}
# Release 编译(推荐) dotnet build -c Release # 输出:bin/Release/net8.0/Main.dll ← 因为 AssemblyName=Main # 将该目录下的 Main.dll(以及第三方依赖)复制到 Plugins/插件子目录/
CYLDConverterDev/ # 开发版主程序目录 ├── CYLDConverterDev.exe # 主程序 ├── ConverterPlugins.dll # 接口库(主程序提供,勿放入插件目录) ├── Newtonsoft.Json.dll # 若有(主程序提供,勿放入插件目录) ├── Logs/ # 自动生成的日志目录 │ ├── Plugins/ # 插件根目录 │ │ │ ├── snbt_convert.txt # 共享资源(可被多个插件通过 FindResourceFile 找到) │ ├── id.json # 共享资源 │ │ │ ├── MyPlugin/ # 插件子目录(名称任意) │ │ ├── Main.dll # ★ 必须命名为 Main.dll ★ │ │ └── SomeDep.dll # 第三方依赖(按需放置) │ │ │ ├── LitematicConverter/ │ │ └── Main.dll │ │ │ └── SchematicConverter/ │ └── Main.dll │ └── 其他运行时文件...
| 文件 | 是否必需 | 说明 |
|---|---|---|
Main.dll | 必需 | 插件主程序集,名称固定 |
*.dll(第三方) | 按需 | 第三方依赖,排除共享库 |
Main.dll.sig | ❌ 开发版不需要 | 正式版签名文件 |
ConverterPlugins.dll | ❌ 禁止放入 | 会导致类型冲突 |
Newtonsoft.Json.dll | ❌ 禁止放入 | 主程序已提供 |
| 资源文件 | 按需 | 可放插件子目录或 Plugins/ 根目录 |
CYLDConverterDev.exe,只需:
dotnet build)Main.dll 覆盖到对应插件目录# 编译并部署到开发版目录,配合菜单 [3] 热重载使用
$pluginName = "MyPlugin"
$devDir = "C:\path\to\CYLDConverterDev\Plugins\$pluginName"
dotnet build -c Release
if ($LASTEXITCODE -ne 0) { Write-Error "编译失败"; exit 1 }
New-Item -ItemType Directory -Force -Path $devDir | Out-Null
Copy-Item "bin\Release\net8.0\Main.dll" "$devDir\Main.dll" -Force
Write-Host "✓ 已部署到 $devDir" -ForegroundColor Green
Write-Host " → 在主程序菜单选择 [3] 重新加载插件"
开发版以 DEBUG 级别记录所有插件加载和转换过程,日志文件位于
Logs/CYLDConverterDev_YYYY-MM-DD.log。
| 错误现象 | 根本原因 | 解决方案 |
|---|---|---|
| 跳过: MyPlugin (缺少 Main.dll) | AssemblyName 不是 Main,输出文件名不对 | 在 .csproj 中添加 <AssemblyName>Main</AssemblyName> |
| 类型加载异常 / IConverterPlugin 不匹配 | 插件目录中存在 ConverterPlugins.dll | 删除插件目录中的 ConverterPlugins.dll,并在 csproj 设 Private=false |
| 未找到 IConverterPlugin 实现 | 插件类未实现接口,或命名空间冲突 | 检查 class MyConverter : IConverterPlugin 是否正确 |
| 重复插件名称,跳过 | 两个插件目录的 PluginName 属性返回相同值 |
修改其中一个插件的 PluginName |
| 依赖项加载失败 (FileNotFoundException) | 第三方 DLL 未放在插件目录 | 将依赖 DLL 复制到 Plugins/MyPlugin/ 目录 |
| 资源文件找不到 | 硬编码路径或未使用 FindResourceFile | 使用第 5 节的 FindResourceFile() 辅助方法 |
| 转换失败,Convert 返回 false | 插件内部逻辑异常 | 查看控制台输出和 Logs/ 日志;在 Convert 内 try/catch 并打印详细错误 |
Convert 方法开头打印 pluginDirectory 的值,确认路径正确Console.WriteLine($"[DEBUG] ...") 输出调试信息,开发版会直接显示Main.dll ✓dotnet build -c Release,确认 bin/Release/net8.0/Main.dll 存在
Main.dll 发送给管理员,获取 Main.dll.sig 签名文件
Main.dll、Main.dll.sig 及依赖 DLL 放入正式版 Plugins/<插件名>/
下面是一个生产就绪的最简插件模板,涵盖所有最佳实践:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Library</OutputType>
<AssemblyName>Main</AssemblyName> <!-- ★ 固定为 Main ★ -->
<RootNamespace>PluginTemplate</RootNamespace>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
</PropertyGroup>
<ItemGroup>
<Reference Include="ConverterPlugins">
<HintPath>..\CYLDConverterDev\ConverterPlugins.dll</HintPath>
<Private>false</Private> <!-- ★ 不复制到输出目录 ★ -->
</Reference>
</ItemGroup>
</Project>
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using ConverterPlugins;
namespace PluginTemplate
{
public class TemplateConverter : IConverterPlugin, IPluginOptionsProvider
{
// ── 1. 基本元数据 ────────────────────────────────────────────────────
public string PluginName => "我的插件";
public string PluginInfo => "简要说明(一行)";
public string Author => "作者名";
public string Version => "1.0.0";
public string CreatedTime => "2026-04-25";
public string DetailedDescription => "详细描述(可多行)";
public string[] SupportedInputExtensions => new[] { ".txt" };
public string SupportedOutputExtension => ".txt";
// ── 2. 插件参数(无需参数时可删除此方法,并去掉 IPluginOptionsProvider)──
public List<PluginOption>? GetOptions() => new()
{
new() {
Key = "myParam", DisplayName = "我的参数",
Description = "参数说明",
ValueType = "string", DefaultValue = "默认值", IsRequired = false
}
};
// ── 3. 核心转换 ──────────────────────────────────────────────────────
public bool Convert(object inputData, string outputPath,
string pluginDirectory, object? options)
{
// 3.1 取进度回调(可选)
Action<int>? progress = options is Action<int> cb ? cb : null;
try
{
// 3.2 验证输入
if (inputData is not byte[] bytes)
{
Console.WriteLine("[ERROR] 输入数据类型错误");
return false;
}
progress?.Invoke(10);
// 3.3 取选项
var opts = options as Dictionary<string, object> ?? new();
string myParam = opts.TryGetValue("myParam", out var v) && v is string s ? s : "默认值";
// 3.4 查找资源文件(若需要)
// string? resFile = FindResourceFile(pluginDirectory, "mapping.txt");
// 3.5 转换逻辑(在此处实现你的核心逻辑)
string input = Encoding.UTF8.GetString(bytes);
string output = $"参数: {myParam}\n\n{input}";
progress?.Invoke(90);
// 3.6 写出文件
File.WriteAllText(outputPath, output, Encoding.UTF8);
progress?.Invoke(100);
return true;
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] {PluginName}: {ex.Message}");
return false;
}
}
// ── 辅助:资源文件搜索 ────────────────────────────────────────────────
private static string? FindResourceFile(string pluginDirectory, string fileName)
{
string path = Path.Combine(pluginDirectory, fileName);
if (File.Exists(path)) return path;
path = Path.Combine(pluginDirectory, "out", fileName);
if (File.Exists(path)) return path;
string? parent = Path.GetDirectoryName(pluginDirectory);
if (parent != null)
{
path = Path.Combine(parent, fileName);
if (File.Exists(path)) return path;
}
return null;
}
}
}
Main.dll.sig)。