chore: initialize standalone UPM package
Publish UPM package / publish (push) Failing after 1s

This commit is contained in:
2026-08-26 02:50:31 +08:00
commit 78ccae3e07
142 changed files with 6572 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
name: Publish UPM package
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
NODE_AUTH_TOKEN: ${{ secrets.SHRINKSDK_PACKAGE_TOKEN }}
steps:
- name: Fetch tagged revision
shell: bash
run: |
set -eu
ref="${{ gitea.sha }}"
test -n "$ref"
git init .
git remote add origin "https://git.crash.work/ShrinkSDK/ShrinkModFramework.git"
git fetch --depth=1 origin "$ref"
git checkout --detach FETCH_HEAD
- name: Validate immutable release version
shell: bash
run: |
set -eu
tag="$(git describe --exact-match --tags HEAD)"
version="$(node -p "require('./package.json').version")"
test "$tag" = "v$version"
npm pack --dry-run
- name: Publish to ShrinkSDK registry
shell: bash
run: |
set -eu
: "${NODE_AUTH_TOKEN:?SHRINKSDK_PACKAGE_TOKEN is required}"
npmrc="$HOME/.npmrc"
cleanup() { rm -f "$npmrc"; }
trap cleanup EXIT
printf '%s\n' \
'registry=https://git.crash.work/api/packages/ShrinkSDK/npm/' \
'//git.crash.work/api/packages/ShrinkSDK/npm/:_authToken=${NODE_AUTH_TOKEN}' > "$npmrc"
npm publish --registry=https://git.crash.work/api/packages/ShrinkSDK/npm/
+39
View File
@@ -0,0 +1,39 @@
name: Verify standalone Unity package
on:
workflow_dispatch:
jobs:
editmode:
runs-on: unity-2022.3.62f3
container:
image: docker.1panel.live/unityci/editor:ubuntu-2022.3.62f3-windows-mono-3
volumes:
- /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-license:/root/.local/share/unity3d/Unity
- /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-entitlements:/root/.config/unity3d/Unity/licenses
steps:
- name: Fetch selected revision
shell: bash
run: |
set -eu
ref="${{ gitea.sha }}"
git init .
git remote add origin "https://git.crash.work/ShrinkSDK/ShrinkModFramework.git"
git fetch --depth=1 origin "$ref"
git checkout --detach FETCH_HEAD
- name: Run package EditMode tests
shell: bash
run: |
set -eu
unity_bin="$(command -v unity-editor || command -v unity || command -v Unity || true)"
test -n "$unity_bin"
"$unity_bin" \
-batchmode \
-nographics \
-quit \
-projectPath "$PWD/Development~/UnityProject" \
-runTests \
-testPlatform EditMode \
-testResults "$PWD/TestResults/editmode.xml" \
-logFile "$PWD/TestResults/unity.log"
+10
View File
@@ -0,0 +1,10 @@
/Development~/UnityProject/[Ll]ibrary/
/Development~/UnityProject/[Tt]emp/
/Development~/UnityProject/[Oo]bj/
/Development~/UnityProject/[Ll]ogs/
/Development~/UnityProject/[Uu]ser[Ss]ettings/
/Development~/UnityProject/TestResults/
/Tools~/**/[Bb]in/
/Tools~/**/[Oo]bj/
*.user
*.DotSettings.user
+8
View File
@@ -0,0 +1,8 @@
.git/
.gitea/
Development~/
Tools~/
*.csproj
*.sln
*.user
*.DotSettings.user
+40
View File
@@ -0,0 +1,40 @@
# Changelog
本文件记录 `ShrinkModFramework` 在当前工作区中的包内变更。
## [0.2.0] - 2026-08-24
### Added
- 新增 `ShrinkModContextHost` / `ShrinkModComponentSource`,把模组生命周期、Registry、Network handler 与 Harmony lease 纳入可逆组件事务。
- 新增真实外部 DLL fixture 测试,覆盖失败替换恢复、损坏文件、依赖缺失、watcher 去抖与删除卸载。
- 注册表新增 owner namespace、显式优先级覆盖、同优先级冲突拒绝和覆盖候选诊断;owner 卸载时会恢复下一个覆盖或基础项。
- `ShrinkModContext` 新增 `RegisterContent` / `OverrideContent`,模组不再手工传入 owner;查询改为 `IReadOnlyShrinkModRegistry<T>`
### Changed
- `ShrinkModLoader` 默认使用 ContextHost;外部 DLL 以 SHA-256 作为 revision,同内容幂等。
- watcher 监听新增、修改、删除与重命名,并把 burst 文件事件折叠为一次主线程组合提交。
- 移除允许任意完整键的 `ShrinkModRegistry.Register(owner, key, value)`;基础内容必须通过 owner namespace 注册。
### Fixed
- 新模组 apply 失败时同时恢复旧组件组合和外部 DLL revision 缓存,避免失败程序集继续被当作当前版本。
- 删除 DLL 时同步清理程序集解析路径缓存;EditMode 创建 watcher driver 时不再调用 `DontDestroyOnLoad`
## [0.1.0] - 2026-04-07
### Added
- 建立模组声明与生命周期基础:`[ShrinkMod]``[ShrinkModDependency]``IShrinkMod``ShrinkModBase``ShrinkModLoader`、注册表与运行时设置。
- 默认启动入口改为 `[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]` 自动引导,不再强依赖场景内手挂 bootstrap。
- 支持扫描和增量装载外部 DLL 模组。
- 预留可选 Harmony 热补丁与模组网络同步抽象层。
- 新增 `ShrinkSDK/Mod/导出 Mod SDK 开发包`,可导出给外部模组开发者使用的 `GeneratedModSdk/`
- 新增 `ShrinkSDK/Mod/生成仓库内模组模板(推荐)`,可在当前 Unity 工程内快速生成模组模板。
- 新增 `ShrinkSDK/Mod/生成外部模组模板`,可生成独立 DLL 模组工程。
### Changed
- 运行时目录结构统一拆分为 `Bootstrap / Core / Metadata / Registry / Loading / Network / Integration`
- README 同步更新新的目录结构、模板入口、SDK 导出方式与推荐工作流。
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 47a420daff9e1b7468f48a442ad7290e
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+6
View File
@@ -0,0 +1,6 @@
[Ll]ibrary/
[Tt]emp/
[Oo]bj/
[Ll]ogs/
[Uu]ser[Ss]ettings/
TestResults/
@@ -0,0 +1,15 @@
{
"scopedRegistries": [
{
"name": "ShrinkSDK",
"url": "https://git.crash.work/api/packages/ShrinkSDK/npm/",
"scopes": [
"com.cneicy"
]
}
],
"dependencies": {
"com.unity.test-framework": "1.1.33",
"com.cneicy.shrink-mod-framework": "file:../../.."
}
}
@@ -0,0 +1,2 @@
m_EditorVersion: 2022.3.62f3
m_EditorVersionWithRevision: 2022.3.62f3 (96770f904ca7)
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2a4090dedcf4f964195877944df52d40
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a4c5850a094f141429521fdc9e82295b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2b6d34b770edc7b4bb76e93b13ffd2ab
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,3 @@
bin/
obj/
artifacts/
@@ -0,0 +1,102 @@
# __PROJECT_NAME__
这是由 `ShrinkModFramework` 生成器创建的外部 DLL 模组模板。
## 模板内容
- `src/__PROJECT_NAME__Mod.cs`
- 模组主入口,演示 `ShrinkModFramework + ShrinkEventBus + ShrinkCommand + ShrinkNetwork`
- `src/__PROJECT_NAME__Contracts.cs`
- 示例事件、RPC 请求与响应
- `__PROJECT_NAME__.csproj`
- 独立类库工程,默认引用当前 SDK 仓库里的运行时工程
- `build.ps1`
- 一键构建,可选复制 DLL 到你的模组目录
- `dev-env.ps1`
- 先设置 `MSBuildEnableWorkloadResolver=false`,适合有些 IDE 打开项目前先跑一遍
- `global.json`
- 锁定到生成模板时检测到的 `dotnet SDK` 版本
## 默认标识
- `ProjectName`: `__PROJECT_NAME__`
- `Namespace`: `__ROOT_NAMESPACE__`
- `ModId`: `__MOD_ID__`
- `DisplayName`: `__DISPLAY_NAME__`
生成后建议你先改这几处,再开始写正式业务。
## 构建
```powershell
dotnet build .\__PROJECT_NAME__.csproj -c Release /nr:false
```
或者直接运行:
```powershell
.\build.ps1
```
如果你的 IDE 打开项目时报类似:
```text
Microsoft.NET.SDK.WorkloadAutoImportPropsLocator
```
先在 PowerShell 里执行:
```powershell
.\dev-env.ps1
```
然后在**同一个 shell** 里启动 IDE,或者直接继续 `dotnet build`。
默认产物输出到:
```text
artifacts\Release\
```
## 投放到游戏
`ShrinkModFramework` 当前会扫描:
```text
Application.persistentDataPath/Mods
```
所以常见流程是:
1. 先构建出 `__PROJECT_NAME__.dll`
2. 把 DLL 复制到游戏实际使用的 `Mods` 目录
3. 启动游戏,让 `ShrinkModLoader` 自动发现
如果你已经知道目标 `Mods` 目录,也可以直接用:
```powershell
.\build.ps1 -ModsDir "D:\YourGameMods\Mods"
```
## 这个模板示范了什么
- `[ShrinkMod]` 模组声明
- `ShrinkModBase` 生命周期
- `[ShrinkEventSubscriber]` / `[ShrinkSubscribe]` 生成式实例接入 Mod Bus
- `ShrinkCommandSubscriber` 实例命令自动接入
- `ShrinkNetworkSubscriber` 实例网络处理自动接入
- `context.RegisterContent(...)` 使用 ModId namespace 注册内容
- `context.OverrideContent(...)` 以显式优先级覆盖已有内容
- `context.RegisterNetworkHandler(...)` 走模组网络抽象层
## 注意
- 示例里用到的 `opcode`、命令路径、`modId` 都只是模板默认值,正式模组请自己调整,避免冲突
- 当前模板默认依赖当前仓库根目录下的:
- `ShrinkModFramework.Runtime.csproj`
- `ShrinkEventBus.Runtime.csproj`
- `Assets/Modules/ShrinkEventBus/Tools~/DotNet/ShrinkEventBus.Generator/ShrinkEventBus.Generator.csproj`(作为 Roslyn analyzer
- `ShrinkCommand.Runtime.csproj`
- `ShrinkNetwork.Runtime.csproj`
- 如果你把模板工程移到别处,需要同步修正 `__PROJECT_NAME__.csproj` 里的 `ProjectReference`
- 某些 IDE / MSBuild 环境里,`MSBuildEnableWorkloadResolver=false` 写在项目文件内仍然太晚;这种情况要靠 `dev-env.ps1` 或系统环境变量在**打开项目之前**先设置
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: dfa94a3d7a41c6b4e81e7675db94e55f
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net471</TargetFramework>
<LangVersion>9.0</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<MSBuildEnableWorkloadResolver>false</MSBuildEnableWorkloadResolver>
<RootNamespace>__ROOT_NAMESPACE__</RootNamespace>
<AssemblyName>__PROJECT_NAME__</AssemblyName>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<EnableDefaultCompileItems>true</EnableDefaultCompileItems>
<OutputPath>artifacts\$(Configuration)\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="__SDK_ROOT_RELATIVE__\ShrinkModFramework.Runtime.csproj" />
<ProjectReference Include="__SDK_ROOT_RELATIVE__\ShrinkEventBus.Runtime.csproj" />
<ProjectReference Include="__SDK_ROOT_RELATIVE__\Assets\Modules\ShrinkEventBus\Tools~\DotNet\ShrinkEventBus.Generator\ShrinkEventBus.Generator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<ProjectReference Include="__SDK_ROOT_RELATIVE__\ShrinkCommand.Runtime.csproj" />
<ProjectReference Include="__SDK_ROOT_RELATIVE__\ShrinkNetwork.Runtime.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 770057fe3b5782f4ba28e2e36f087b96
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,32 @@
param(
[string]$Configuration = "Release",
[string]$ModsDir = ""
)
$ErrorActionPreference = "Stop"
$projectPath = Join-Path $PSScriptRoot "__PROJECT_NAME__.csproj"
dotnet build $projectPath -c $Configuration /nr:false
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$artifactDir = Join-Path $PSScriptRoot ("artifacts\" + $Configuration)
$dllPath = Join-Path $artifactDir "__PROJECT_NAME__.dll"
Write-Host "Build completed: $dllPath"
if ([string]::IsNullOrWhiteSpace($ModsDir)) {
return
}
New-Item -ItemType Directory -Force -Path $ModsDir | Out-Null
Copy-Item -LiteralPath $dllPath -Destination (Join-Path $ModsDir "__PROJECT_NAME__.dll") -Force
$pdbPath = Join-Path $artifactDir "__PROJECT_NAME__.pdb"
if (Test-Path -LiteralPath $pdbPath) {
Copy-Item -LiteralPath $pdbPath -Destination (Join-Path $ModsDir "__PROJECT_NAME__.pdb") -Force
}
Write-Host "Copied artifacts to: $ModsDir"
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 956930660ab00e74692b22857f3159b7
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
$env:MSBuildEnableWorkloadResolver = "false"
Write-Host "MSBuildEnableWorkloadResolver=false"
Write-Host "Current dotnet: $(dotnet --version)"
Write-Host ""
Write-Host "现在请在这个 PowerShell 里启动你的 IDE,或直接在这个 shell 里运行 dotnet build。"
Write-Host "示例:"
Write-Host " dotnet build .\\__PROJECT_NAME__.csproj -c Release /nr:false"
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 56c53370b8dbbab4ea70d4566bc1fed8
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "__DOTNET_SDK_VERSION__",
"rollForward": "latestPatch"
}
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 48ebb276437630c4495d82c4c8e4ae2e
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c8713158fd023ce48abfb7365184ac7c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,25 @@
#nullable enable
using ShrinkEventBus;
using ShrinkNetwork;
namespace __ROOT_NAMESPACE__
{
public sealed class __PROJECT_NAME__Event : IShrinkEvent
{
public string Message { get; set; } = string.Empty;
}
[ShrinkNetworkMessage(__REQUEST_OPCODE__, "__ROUTE_PREFIX__/ping")]
public sealed class __PROJECT_NAME__PingRequest : IShrinkNetworkRequest
{
public string Message { get; set; } = string.Empty;
}
[ShrinkNetworkMessage(__RESPONSE_OPCODE__, "__ROUTE_PREFIX__/ping_response")]
public sealed class __PROJECT_NAME__PingResponse : ShrinkRpcResponseBase
{
public string Message { get; set; } = string.Empty;
public int Counter { get; set; }
}
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: f4ff81758d9539947ba6f4df94c2db91
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,66 @@
#nullable enable
using ShrinkCommand;
using ShrinkEventBus;
using ShrinkModFramework;
using ShrinkNetwork;
namespace __ROOT_NAMESPACE__
{
[ShrinkMod("__MOD_ID__", "__DISPLAY_NAME__", "1.0.0")]
[ShrinkEventSubscriber(OwnerId = "__MOD_ID__", DefaultBus = "mod:__MOD_ID__")]
[ShrinkCommandSubscriber]
[ShrinkNetworkSubscriber]
public sealed partial class __PROJECT_NAME__Mod : ShrinkModBase
{
private readonly RuntimeState _state = new();
public override void OnConstruct(ShrinkModContext context)
{
context.Log("模组已构造。");
}
public override void OnRegisterContent(ShrinkModContext context)
{
context.RegisterContent("example.items", "example_item", "__DISPLAY_NAME__ Item");
}
public override void OnInitialize(ShrinkModContext context)
{
context.RegisterNetworkHandler<int>("sync.counter", (_, value) =>
{
_state.Counter = value;
context.Log("收到模组网络计数:" + value);
});
}
[ShrinkSubscribe]
private void OnExampleEvent(__PROJECT_NAME__Event evt)
{
_state.LastEventMessage = evt.Message ?? string.Empty;
_state.Counter++;
}
[ShrinkCommand("__COMMAND_ROOT__ ping", Description = "测试模组命令", Permission = "__MOD_ID__.command.ping")]
private string Ping()
{
return "pong:" + _state.Counter;
}
[ShrinkNetworkSubscribe(Permission = "__MOD_ID__.rpc.ping")]
private __PROJECT_NAME__PingResponse HandlePing(__PROJECT_NAME__PingRequest request)
{
return new __PROJECT_NAME__PingResponse
{
Message = "pong:" + (request.Message ?? string.Empty),
Counter = _state.Counter
};
}
private sealed class RuntimeState
{
public int Counter { get; set; }
public string LastEventMessage { get; set; } = string.Empty;
}
}
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: a2a0ac418f07ef74393b89e9612e3f5f
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8b337545ac56d6f46a969397d332e35c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,39 @@
# __PROJECT_NAME__
这是 `ShrinkModFramework` 生成的仓库内模组模板。
## 为什么推荐这个模板
这个模板直接生成到当前 Unity 项目的 `Assets` 目录下,复用:
- 当前 Unity 工程
- 当前 `ShrinkSDK.sln`
- 当前 asmdef 编译链
所以它比“独立外部 csproj 模板”更省事,也不会碰到额外的 IDE / workload SDK 解析问题。
## 生成位置
- 当前输出目录:`__ASSET_RELATIVE_OUTPUT__`
## 模板内容
- `Runtime/__ASMDEF_NAME__.asmdef`
- `Runtime/__PROJECT_NAME__Mod.cs`
- `Runtime/__PROJECT_NAME__Contracts.cs`
- `README.md`
## 建议流程
1. 生成模板
2. 在现有 Unity/Rider/VS 解决方案里直接修改代码
3. 等 Unity 正常编译通过
4. 后续如果需要,再单独补“导出 DLL 到 Mods 目录”的步骤
## 默认标识
- `ProjectName`: `__PROJECT_NAME__`
- `Namespace`: `__ROOT_NAMESPACE__`
- `Assembly`: `__ASMDEF_NAME__`
- `ModId`: `__MOD_ID__`
- `DisplayName`: `__DISPLAY_NAME__`
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 8aa46c7dafda5194b94ab88513c56b44
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f7a48cec05ee9c842ad9bc0b7e7ec425
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,18 @@
{
"name": "__ASMDEF_NAME__",
"rootNamespace": "__ROOT_NAMESPACE__",
"references": [
"ShrinkModFramework.Runtime",
"ShrinkEventBus.Runtime",
"ShrinkCommand.Runtime",
"ShrinkNetwork.Runtime"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 9358a269a6e4a9740a69798dba1c1655
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,25 @@
#nullable enable
using ShrinkEventBus;
using ShrinkNetwork;
namespace __ROOT_NAMESPACE__
{
public sealed class __PROJECT_NAME__Event : IShrinkEvent
{
public string Message { get; set; } = string.Empty;
}
[ShrinkNetworkMessage(32001, "__MOD_ID__/ping")]
public sealed class __PROJECT_NAME__PingRequest : IShrinkNetworkRequest
{
public string Message { get; set; } = string.Empty;
}
[ShrinkNetworkMessage(32002, "__MOD_ID__/ping_response")]
public sealed class __PROJECT_NAME__PingResponse : ShrinkRpcResponseBase
{
public string Message { get; set; } = string.Empty;
public int Counter { get; set; }
}
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 2dbfd0d836e24d84eabaff73259c7e90
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,66 @@
#nullable enable
using ShrinkCommand;
using ShrinkEventBus;
using ShrinkModFramework;
using ShrinkNetwork;
namespace __ROOT_NAMESPACE__
{
[ShrinkMod("__MOD_ID__", "__DISPLAY_NAME__", "1.0.0")]
[ShrinkEventSubscriber(OwnerId = "__MOD_ID__", DefaultBus = "mod:__MOD_ID__")]
[ShrinkCommandSubscriber]
[ShrinkNetworkSubscriber]
public sealed partial class __PROJECT_NAME__Mod : ShrinkModBase
{
private readonly RuntimeState _state = new();
public override void OnConstruct(ShrinkModContext context)
{
context.Log("模组已构造。");
}
public override void OnRegisterContent(ShrinkModContext context)
{
context.RegisterContent("example.items", "example_item", "__DISPLAY_NAME__ Item");
}
public override void OnInitialize(ShrinkModContext context)
{
context.RegisterNetworkHandler<int>("sync.counter", (_, value) =>
{
_state.Counter = value;
context.Log("收到模组网络计数:" + value);
});
}
[ShrinkSubscribe]
private void OnExampleEvent(__PROJECT_NAME__Event evt)
{
_state.LastEventMessage = evt.Message ?? string.Empty;
_state.Counter++;
}
[ShrinkCommand("__COMMAND_ROOT__ ping", Description = "测试模组命令", Permission = "__MOD_ID__.command.ping")]
private string Ping()
{
return "pong:" + _state.Counter;
}
[ShrinkNetworkSubscribe(Permission = "__MOD_ID__.rpc.ping")]
private __PROJECT_NAME__PingResponse HandlePing(__PROJECT_NAME__PingRequest request)
{
return new __PROJECT_NAME__PingResponse
{
Message = "pong:" + (request.Message ?? string.Empty),
Counter = _state.Counter
};
}
private sealed class RuntimeState
{
public int Counter { get; set; }
public string LastEventMessage { get; set; } = string.Empty;
}
}
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 5e1319b1cd7dc0d4b918d12152b358ab
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,248 @@
#if UNITY_EDITOR
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEngine;
public static class ShrinkExternalModTemplateGenerator
{
private const string GenerateMenuPath = "ShrinkSDK/Mod/生成外部模组模板";
private const string TemplateRelativePath = "Assets/Modules/ShrinkModFramework/Editor/Scaffolding/ExternalModProjectTemplate";
private const string DefaultGeneratedModsRelativePath = "GeneratedMods";
private const string TemplateStampFileName = ".shrink-mod-template.json";
private const string TemplateManifestFileName = ".shrink-mod-template-files.txt";
[MenuItem(GenerateMenuPath)]
public static void GenerateTemplate()
{
try
{
var context = BuildContext();
if (context == null)
return;
CopyTemplateProject(context.Value.TemplateRoot, context.Value.OutputRoot, context.Value.Replacements);
AssetDatabase.Refresh();
UnityEngine.Debug.Log($"[ShrinkModFramework] 已生成外部模组模板:{context.Value.OutputRoot}");
}
catch (Exception ex)
{
UnityEngine.Debug.LogError($"[ShrinkModFramework] 生成外部模组模板失败:{ex.Message}");
UnityEngine.Debug.LogException(ex);
}
}
private static (string ProjectRoot, string TemplateRoot, string OutputRoot, Dictionary<string, string> Replacements)? BuildContext()
{
var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
var templateRoot = Path.Combine(projectRoot, TemplateRelativePath.Replace('/', Path.DirectorySeparatorChar));
if (!Directory.Exists(templateRoot))
throw new DirectoryNotFoundException($"未找到模组模板目录:{templateRoot}");
var generatedModsRoot = Path.Combine(projectRoot, DefaultGeneratedModsRelativePath);
Directory.CreateDirectory(generatedModsRoot);
var outputRoot = EditorUtility.SaveFolderPanel(
"选择外部模组模板输出目录",
generatedModsRoot,
"SampleShrinkMod");
if (string.IsNullOrWhiteSpace(outputRoot))
return null;
outputRoot = Path.GetFullPath(outputRoot);
var directoryName = new DirectoryInfo(outputRoot).Name;
var projectName = SanitizeProjectName(directoryName);
var rootNamespace = projectName;
var displayName = projectName;
var modId = BuildDefaultModId(projectName);
var commandRoot = ToCommandToken(projectName);
var routePrefix = modId.Replace('.', '/');
var sdkRootRelativePath = NormalizePath(Path.GetRelativePath(outputRoot, projectRoot));
var replacements = new Dictionary<string, string>(StringComparer.Ordinal)
{
["__PROJECT_NAME__"] = projectName,
["__ROOT_NAMESPACE__"] = rootNamespace,
["__DISPLAY_NAME__"] = displayName,
["__MOD_ID__"] = modId,
["__COMMAND_ROOT__"] = commandRoot,
["__ROUTE_PREFIX__"] = routePrefix,
["__SDK_ROOT_RELATIVE__"] = sdkRootRelativePath,
["__DOTNET_SDK_VERSION__"] = GetPreferredSdkVersion(),
["__REQUEST_OPCODE__"] = "31001",
["__RESPONSE_OPCODE__"] = "31002"
};
return (projectRoot, templateRoot, outputRoot, replacements);
}
private static void CopyTemplateProject(string templateRoot, string outputRoot, IReadOnlyDictionary<string, string> replacements)
{
Directory.CreateDirectory(outputRoot);
var stampPath = Path.Combine(outputRoot, TemplateStampFileName);
if (Directory.EnumerateFileSystemEntries(outputRoot).Any() && !File.Exists(stampPath))
{
throw new InvalidOperationException(
$"目标目录已存在且不是 ShrinkModFramework 自动生成的模组模板,为避免覆盖人工修改,已中止:{outputRoot}");
}
var managedFiles = new List<string>();
foreach (var templateFile in Directory.EnumerateFiles(templateRoot, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(templateRoot, templateFile);
relativePath = ReplaceTokens(relativePath, replacements);
var outputRelativePath = relativePath.EndsWith(".txt", StringComparison.Ordinal)
? relativePath[..^".txt".Length]
: relativePath;
managedFiles.Add(NormalizePath(outputRelativePath));
var outputFile = Path.Combine(outputRoot, outputRelativePath);
var outputDir = Path.GetDirectoryName(outputFile);
if (!string.IsNullOrWhiteSpace(outputDir))
Directory.CreateDirectory(outputDir);
var content = File.ReadAllText(templateFile, Encoding.UTF8);
content = ReplaceTokens(content, replacements);
File.WriteAllText(outputFile, content, new UTF8Encoding(false));
}
var manifestPath = Path.Combine(outputRoot, TemplateManifestFileName);
if (File.Exists(manifestPath))
{
var previousManagedFiles = File.ReadAllLines(manifestPath, Encoding.UTF8)
.Where(line => !string.IsNullOrWhiteSpace(line))
.ToArray();
foreach (var previousManagedFile in previousManagedFiles)
{
if (managedFiles.Contains(previousManagedFile, StringComparer.Ordinal))
continue;
var staleFile = Path.Combine(outputRoot, previousManagedFile.Replace('/', Path.DirectorySeparatorChar));
if (File.Exists(staleFile))
File.Delete(staleFile);
}
}
File.WriteAllLines(manifestPath, managedFiles.OrderBy(item => item, StringComparer.Ordinal), new UTF8Encoding(false));
var stamp = new StringBuilder();
stamp.AppendLine("{");
stamp.AppendLine(@" ""generatedBy"": ""ShrinkExternalModTemplateGenerator"",");
stamp.AppendLine($@" ""generatedAt"": ""{DateTime.UtcNow:O}""");
stamp.AppendLine("}");
File.WriteAllText(stampPath, stamp.ToString(), new UTF8Encoding(false));
}
private static string ReplaceTokens(string input, IReadOnlyDictionary<string, string> replacements)
{
var result = input;
foreach (var pair in replacements)
result = result.Replace(pair.Key, pair.Value);
return result;
}
private static string SanitizeProjectName(string rawName)
{
if (string.IsNullOrWhiteSpace(rawName))
return "SampleShrinkMod";
var builder = new StringBuilder();
var capitalizeNext = true;
foreach (var ch in rawName)
{
if (char.IsLetterOrDigit(ch))
{
builder.Append(capitalizeNext ? char.ToUpperInvariant(ch) : ch);
capitalizeNext = false;
}
else
{
capitalizeNext = true;
}
}
if (builder.Length == 0)
builder.Append("SampleShrinkMod");
if (char.IsDigit(builder[0]))
builder.Insert(0, 'M');
return builder.ToString();
}
private static string BuildDefaultModId(string projectName)
{
var token = ToKebabCase(projectName);
return string.IsNullOrWhiteSpace(token) ? "example.sample-mod" : $"example.{token}";
}
private static string ToCommandToken(string projectName)
{
var token = ToKebabCase(projectName).Replace("-", "_");
return string.IsNullOrWhiteSpace(token) ? "sample_mod" : token;
}
private static string ToKebabCase(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
var builder = new StringBuilder();
for (var i = 0; i < value.Length; i++)
{
var ch = value[i];
if (!char.IsLetterOrDigit(ch))
{
if (builder.Length > 0 && builder[^1] != '-')
builder.Append('-');
continue;
}
if (char.IsUpper(ch) && i > 0 && builder.Length > 0 && builder[^1] != '-')
builder.Append('-');
builder.Append(char.ToLowerInvariant(ch));
}
return builder.ToString().Trim('-');
}
private static string NormalizePath(string path)
=> path.Replace('\\', '/');
private static string GetPreferredSdkVersion()
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "dotnet",
Arguments = "--version",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null)
return "9.0.312";
var output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit(3000);
return string.IsNullOrWhiteSpace(output) ? "9.0.312" : output;
}
catch
{
return "9.0.312";
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 92c78e493d52a144389ddc6073111b0e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,203 @@
#if UNITY_EDITOR
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEngine;
public static class ShrinkInRepoModTemplateGenerator
{
private const string GenerateMenuPath = "ShrinkSDK/Mod/生成仓库内模组模板(推荐)";
private const string TemplateRelativePath = "Assets/Modules/ShrinkModFramework/Editor/Scaffolding/InRepoModTemplate";
private const string DefaultOutputRootRelativePath = "Assets/GeneratedMods";
private const string TemplateStampFileName = ".shrink-inrepo-mod-template.json";
private const string TemplateManifestFileName = ".shrink-inrepo-mod-template-files.txt";
[MenuItem(GenerateMenuPath)]
public static void GenerateTemplate()
{
try
{
var context = BuildContext();
if (context == null)
return;
CopyTemplateProject(context.Value.TemplateRoot, context.Value.OutputRoot, context.Value.Replacements);
AssetDatabase.Refresh();
UnityEngine.Debug.Log($"[ShrinkModFramework] 已生成仓库内模组模板:{context.Value.OutputRoot}");
}
catch (Exception ex)
{
UnityEngine.Debug.LogError($"[ShrinkModFramework] 生成仓库内模组模板失败:{ex.Message}");
UnityEngine.Debug.LogException(ex);
}
}
private static (string TemplateRoot, string OutputRoot, Dictionary<string, string> Replacements)? BuildContext()
{
var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
var templateRoot = Path.Combine(projectRoot, TemplateRelativePath.Replace('/', Path.DirectorySeparatorChar));
if (!Directory.Exists(templateRoot))
throw new DirectoryNotFoundException($"未找到仓库内模组模板目录:{templateRoot}");
var defaultOutputRoot = Path.Combine(projectRoot, DefaultOutputRootRelativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(defaultOutputRoot);
var outputRoot = EditorUtility.SaveFolderPanel(
"选择仓库内模组模板输出目录",
defaultOutputRoot,
"SampleShrinkMod");
if (string.IsNullOrWhiteSpace(outputRoot))
return null;
outputRoot = Path.GetFullPath(outputRoot);
var assetsRoot = Path.GetFullPath(Application.dataPath);
if (!outputRoot.StartsWith(assetsRoot, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("仓库内模组模板必须生成到当前项目的 Assets 目录下。");
var directoryName = new DirectoryInfo(outputRoot).Name;
var projectName = SanitizeProjectName(directoryName);
var rootNamespace = projectName;
var displayName = projectName;
var modId = BuildDefaultModId(projectName);
var commandRoot = ToCommandToken(projectName);
var asmdefName = projectName + ".Runtime";
var assetRelativeOutput = "Assets" + outputRoot.Substring(assetsRoot.Length).Replace('\\', '/');
var replacements = new Dictionary<string, string>(StringComparer.Ordinal)
{
["__PROJECT_NAME__"] = projectName,
["__ROOT_NAMESPACE__"] = rootNamespace,
["__DISPLAY_NAME__"] = displayName,
["__MOD_ID__"] = modId,
["__COMMAND_ROOT__"] = commandRoot,
["__ASMDEF_NAME__"] = asmdefName,
["__ASSET_RELATIVE_OUTPUT__"] = assetRelativeOutput
};
return (templateRoot, outputRoot, replacements);
}
private static void CopyTemplateProject(string templateRoot, string outputRoot, IReadOnlyDictionary<string, string> replacements)
{
Directory.CreateDirectory(outputRoot);
var stampPath = Path.Combine(outputRoot, TemplateStampFileName);
if (Directory.EnumerateFileSystemEntries(outputRoot).Any() && !File.Exists(stampPath))
{
throw new InvalidOperationException(
$"目标目录已存在且不是 ShrinkModFramework 自动生成的仓库内模组模板,为避免覆盖人工修改,已中止:{outputRoot}");
}
var managedFiles = new List<string>();
foreach (var templateFile in Directory.EnumerateFiles(templateRoot, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(templateRoot, templateFile);
relativePath = ReplaceTokens(relativePath, replacements);
var outputRelativePath = relativePath.EndsWith(".txt", StringComparison.Ordinal)
? relativePath[..^".txt".Length]
: relativePath;
managedFiles.Add(NormalizePath(outputRelativePath));
var outputFile = Path.Combine(outputRoot, outputRelativePath);
var outputDir = Path.GetDirectoryName(outputFile);
if (!string.IsNullOrWhiteSpace(outputDir))
Directory.CreateDirectory(outputDir);
var content = File.ReadAllText(templateFile, Encoding.UTF8);
content = ReplaceTokens(content, replacements);
File.WriteAllText(outputFile, content, new UTF8Encoding(false));
}
var manifestPath = Path.Combine(outputRoot, TemplateManifestFileName);
File.WriteAllLines(manifestPath, managedFiles.OrderBy(item => item, StringComparer.Ordinal), new UTF8Encoding(false));
var stamp = new StringBuilder();
stamp.AppendLine("{");
stamp.AppendLine(@" ""generatedBy"": ""ShrinkInRepoModTemplateGenerator"",");
stamp.AppendLine($@" ""generatedAt"": ""{DateTime.UtcNow:O}""");
stamp.AppendLine("}");
File.WriteAllText(stampPath, stamp.ToString(), new UTF8Encoding(false));
}
private static string ReplaceTokens(string input, IReadOnlyDictionary<string, string> replacements)
{
var result = input;
foreach (var pair in replacements)
result = result.Replace(pair.Key, pair.Value);
return result;
}
private static string SanitizeProjectName(string rawName)
{
if (string.IsNullOrWhiteSpace(rawName))
return "SampleShrinkMod";
var builder = new StringBuilder();
var capitalizeNext = true;
foreach (var ch in rawName)
{
if (char.IsLetterOrDigit(ch))
{
builder.Append(capitalizeNext ? char.ToUpperInvariant(ch) : ch);
capitalizeNext = false;
}
else
{
capitalizeNext = true;
}
}
if (builder.Length == 0)
builder.Append("SampleShrinkMod");
if (char.IsDigit(builder[0]))
builder.Insert(0, 'M');
return builder.ToString();
}
private static string BuildDefaultModId(string projectName)
{
var token = ToKebabCase(projectName);
return string.IsNullOrWhiteSpace(token) ? "example.sample-mod" : $"example.{token}";
}
private static string ToCommandToken(string projectName)
{
var token = ToKebabCase(projectName).Replace("-", "_");
return string.IsNullOrWhiteSpace(token) ? "sample_mod" : token;
}
private static string ToKebabCase(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
var builder = new StringBuilder();
for (var i = 0; i < value.Length; i++)
{
var ch = value[i];
if (!char.IsLetterOrDigit(ch))
{
if (builder.Length > 0 && builder[^1] != '-')
builder.Append('-');
continue;
}
if (char.IsUpper(ch) && i > 0 && builder.Length > 0 && builder[^1] != '-')
builder.Append('-');
builder.Append(char.ToLowerInvariant(ch));
}
return builder.ToString().Trim('-');
}
private static string NormalizePath(string path)
=> path.Replace('\\', '/');
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b6cd6b51094b7e442bf0e5c09fcd42d3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+585
View File
@@ -0,0 +1,585 @@
#if UNITY_EDITOR
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEditor.PackageManager;
using UnityEngine;
public static class ShrinkModSdkExporter
{
private const string ExportMenuPath = "ShrinkSDK/Mod/导出 Mod SDK 开发包";
private const string DefaultOutputRootRelativePath = "GeneratedModSdk";
private const string StampFileName = ".shrink-mod-sdk.json";
private const string TemplateProjectName = "SampleShrinkMod";
private const string EventBusPackageName = "com.cneicy.shrink-eventbus";
private const string ModFrameworkPackageName = "com.cneicy.shrink-mod-framework";
private const string EventBusGeneratorProjectRelativePath = "Tools~/DotNet/ShrinkEventBus.Generator/ShrinkEventBus.Generator.csproj";
private const string BundledGeneratorRelativePath = "Editor/Scaffolding/Support/ShrinkEventBus.Generator.dll.bytes";
private static readonly string[] ExportAssemblyNames =
{
"ShrinkModFramework.Runtime",
"ShrinkEventBus.Runtime",
"ShrinkEventBus.Generator",
"ShrinkCommand.Runtime",
"ShrinkCommand.Integration.EventBus",
"ShrinkCommand.Integration.Network",
"ShrinkNetwork.Runtime",
"ShrinkNetwork.Integration.EventBus",
"ShrinkDataSaver.Runtime",
"ShrinkDataSaver.Integration.EventBus",
"Assembly-CSharp",
"UniTask",
"Newtonsoft.Json",
"Kcp-CSharp",
"System.Runtime.CompilerServices.Unsafe",
"UnityEngine",
"UnityEngine.CoreModule"
};
[MenuItem(ExportMenuPath)]
public static void ExportSdk()
{
try
{
var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
BuildSolution(projectRoot);
var generatorAssemblyPath = BuildOrResolveEventBusGenerator(projectRoot);
var defaultOutputRoot = Path.Combine(projectRoot, DefaultOutputRootRelativePath);
Directory.CreateDirectory(defaultOutputRoot);
var outputRoot = EditorUtility.SaveFolderPanel(
"选择 Mod SDK 导出目录",
defaultOutputRoot,
"ShrinkModSdk");
if (string.IsNullOrWhiteSpace(outputRoot))
return;
outputRoot = Path.GetFullPath(outputRoot);
PrepareOutputDirectory(outputRoot);
var libsRoot = Path.Combine(outputRoot, "Libs");
var templateRoot = Path.Combine(outputRoot, "Templates", "ExternalMod", TemplateProjectName);
Directory.CreateDirectory(libsRoot);
Directory.CreateDirectory(templateRoot);
var exportedFiles = ExportLibraries(projectRoot, generatorAssemblyPath, libsRoot);
WriteSdkReadme(outputRoot, exportedFiles);
WriteSdkManifest(outputRoot, exportedFiles);
WriteExternalTemplate(templateRoot);
WriteStamp(outputRoot);
UnityEngine.Debug.Log($"[ShrinkModFramework] 已导出 Mod SDK 开发包:{outputRoot}");
EditorUtility.RevealInFinder(outputRoot);
}
catch (Exception ex)
{
UnityEngine.Debug.LogError($"[ShrinkModFramework] 导出 Mod SDK 开发包失败:{ex.Message}");
UnityEngine.Debug.LogException(ex);
}
}
private static void BuildSolution(string projectRoot)
{
var solutionPath = Path.Combine(projectRoot, "ShrinkSDK.sln");
if (!File.Exists(solutionPath))
{
UnityEngine.Debug.Log("[ShrinkModFramework] 未检测到 Workspace 解决方案,直接使用当前 Unity 已编译程序集导出 Mod SDK。");
return;
}
var startInfo = new ProcessStartInfo
{
FileName = "dotnet",
Arguments = "build .\\ShrinkSDK.sln /m:1 /nr:false -p:BaseIntermediateOutputPath=Temp\\CliObj\\ModSdkExport\\",
WorkingDirectory = projectRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null)
throw new InvalidOperationException("无法启动 dotnet build。");
var stdout = process.StandardOutput.ReadToEnd();
var stderr = process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
"构建 ShrinkSDK.sln 失败。\n" +
stdout + "\n" +
stderr);
}
}
private static string BuildOrResolveEventBusGenerator(string projectRoot)
{
var generatorProjectPath = ResolveEventBusGeneratorProjectPath(projectRoot);
if (generatorProjectPath == null)
{
var bundledGeneratorPath = Path.Combine(ResolveModFrameworkPackagePath(projectRoot), BundledGeneratorRelativePath);
if (File.Exists(bundledGeneratorPath))
return bundledGeneratorPath;
throw new InvalidOperationException(
"未找到 ShrinkEventBus.Generator 源码或随包分发的分析器。请安装 com.cneicy.shrink-eventbus,或使用包含 ShrinkModFramework 支持资源的完整包版本。");
}
var startInfo = new ProcessStartInfo
{
FileName = "dotnet",
Arguments = "build \"" + generatorProjectPath + "\" -c Release /nr:false",
WorkingDirectory = Path.GetDirectoryName(generatorProjectPath),
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null)
throw new InvalidOperationException("无法启动 ShrinkEventBus.Generator 构建。");
var stdout = process.StandardOutput.ReadToEnd();
var stderr = process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0)
throw new InvalidOperationException("构建 ShrinkEventBus.Generator 失败。\n" + stdout + "\n" + stderr);
var generatorAssemblyPath = Path.Combine(
Path.GetDirectoryName(generatorProjectPath) ?? string.Empty,
"bin",
"Release",
"netstandard2.0",
"ShrinkEventBus.Generator.dll");
if (!File.Exists(generatorAssemblyPath))
throw new InvalidOperationException("ShrinkEventBus.Generator 构建完成但未找到输出 DLL" + generatorAssemblyPath);
return generatorAssemblyPath;
}
private static string? ResolveEventBusGeneratorProjectPath(string projectRoot)
{
var workspaceProjectPath = Path.Combine(
projectRoot,
"Assets",
"Modules",
"ShrinkEventBus",
EventBusGeneratorProjectRelativePath);
if (File.Exists(workspaceProjectPath))
return workspaceProjectPath;
var package = PackageInfo.GetAllRegisteredPackages()
.FirstOrDefault(candidate => string.Equals(candidate.name, EventBusPackageName, StringComparison.Ordinal));
if (package == null)
return null;
var packageProjectPath = Path.Combine(package.resolvedPath, EventBusGeneratorProjectRelativePath);
return File.Exists(packageProjectPath) ? packageProjectPath : null;
}
private static string ResolveModFrameworkPackagePath(string projectRoot)
{
var workspacePackagePath = Path.Combine(projectRoot, "Assets", "Modules", "ShrinkModFramework");
if (File.Exists(Path.Combine(workspacePackagePath, "package.json")))
return workspacePackagePath;
var package = PackageInfo.GetAllRegisteredPackages()
.FirstOrDefault(candidate => string.Equals(candidate.name, ModFrameworkPackageName, StringComparison.Ordinal));
if (package != null && Directory.Exists(package.resolvedPath))
return package.resolvedPath;
throw new InvalidOperationException("无法解析 ShrinkModFramework 包路径。");
}
private static void PrepareOutputDirectory(string outputRoot)
{
Directory.CreateDirectory(outputRoot);
var stampPath = Path.Combine(outputRoot, StampFileName);
if (Directory.EnumerateFileSystemEntries(outputRoot).Any() && !File.Exists(stampPath))
{
throw new InvalidOperationException(
$"目标目录已存在且不是 ShrinkModFramework 自动导出的 Mod SDK 目录,为避免覆盖人工内容,已中止:{outputRoot}");
}
foreach (var entry in Directory.EnumerateFileSystemEntries(outputRoot).ToArray())
{
if (Directory.Exists(entry))
Directory.Delete(entry, true);
else
File.Delete(entry);
}
}
private static List<string> ExportLibraries(string projectRoot, string generatorAssemblyPath, string libsRoot)
{
var candidateDirectories = new[]
{
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "Assembly-CSharp"),
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkModFramework.Runtime"),
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkEventBus.Runtime"),
Path.GetDirectoryName(generatorAssemblyPath) ?? string.Empty,
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkCommand.Runtime"),
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkCommand.Integration.EventBus"),
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkCommand.Integration.Network"),
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkNetwork.Runtime"),
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkNetwork.Integration.EventBus"),
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkDataSaver.Runtime"),
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkDataSaver.Integration.EventBus")
};
var copied = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var exported = new List<string>();
foreach (var assemblyName in ExportAssemblyNames)
{
foreach (var extension in new[] { ".dll", ".pdb", ".xml" })
{
var fileName = assemblyName + extension;
if (copied.Contains(fileName))
continue;
var source = candidateDirectories
.Select(directory => Path.Combine(directory, fileName))
.FirstOrDefault(File.Exists);
if (string.IsNullOrWhiteSpace(source))
continue;
var destination = Path.Combine(libsRoot, fileName);
File.Copy(source, destination, true);
copied.Add(fileName);
exported.Add(fileName.Replace('\\', '/'));
}
}
return exported.OrderBy(item => item, StringComparer.OrdinalIgnoreCase).ToList();
}
private static void WriteSdkReadme(string outputRoot, IReadOnlyList<string> exportedFiles)
{
var builder = new StringBuilder();
builder.AppendLine("# Shrink Mod SDK");
builder.AppendLine();
builder.AppendLine("这是给外部模组开发者使用的开发包。");
builder.AppendLine();
builder.AppendLine("## 内容");
builder.AppendLine();
builder.AppendLine("- `Libs/`");
builder.AppendLine(" - 已编译好的框架运行时 DLL、EventBus 代码生成器、当前游戏 DLL,以及模板需要的基础依赖");
builder.AppendLine("- `Templates/ExternalMod/SampleShrinkMod/`");
builder.AppendLine(" - 可直接复制一份开始写外部模组的模板工程");
builder.AppendLine();
builder.AppendLine("## 推荐用法");
builder.AppendLine();
builder.AppendLine("1. 复制 `Templates/ExternalMod/SampleShrinkMod/` 到你自己的工作目录");
builder.AppendLine("2. 修改项目名、命名空间、`modId`、命令路径、消息 opcode");
builder.AppendLine("3. 优先使用模板里的 `build.cmd` 构建");
builder.AppendLine("4. 把构建出来的 DLL 投放到游戏的 `Mods` 目录");
builder.AppendLine();
builder.AppendLine("## 为什么不用直接引用仓库 csproj");
builder.AppendLine();
builder.AppendLine("- 外部开发者通常拿不到完整仓库环境");
builder.AppendLine("- 独立打开 `Sdk-style csproj` 时,有些 IDE/MSBuild 环境会碰到 workload SDK 解析问题");
builder.AppendLine("- 这个开发包的目标就是让外部开发者只依赖 `Libs/` 就能开始写模组");
builder.AppendLine();
builder.AppendLine("## 当前游戏 API");
builder.AppendLine();
builder.AppendLine("- 当前先直接导出 `Assembly-CSharp.dll` 作为游戏侧 API");
builder.AppendLine("- 长期更推荐单独抽一个稳定的 `Game.ModAPI.dll`,避免把整个游戏主程序集暴露给外部模组");
builder.AppendLine();
builder.AppendLine("## 已导出的文件");
builder.AppendLine();
foreach (var exportedFile in exportedFiles)
builder.AppendLine("- `Libs/" + exportedFile + "`");
File.WriteAllText(Path.Combine(outputRoot, "README.md"), builder.ToString(), new UTF8Encoding(false));
}
private static void WriteSdkManifest(string outputRoot, IReadOnlyList<string> exportedFiles)
{
var unityVersion = File.Exists(Path.Combine(Path.GetFullPath(Path.Combine(Application.dataPath, "..")), "ProjectSettings", "ProjectVersion.txt"))
? File.ReadAllLines(Path.Combine(Path.GetFullPath(Path.Combine(Application.dataPath, "..")), "ProjectSettings", "ProjectVersion.txt"))
.FirstOrDefault(line => line.StartsWith("m_EditorVersion:", StringComparison.Ordinal))?
.Split(':').Last().Trim() ?? string.Empty
: string.Empty;
var builder = new StringBuilder();
builder.AppendLine("{");
builder.AppendLine($@" ""exportedAt"": ""{DateTime.UtcNow:O}"",");
builder.AppendLine($@" ""unityVersion"": ""{unityVersion}"",");
builder.AppendLine(@" ""assemblies"": [");
for (var i = 0; i < exportedFiles.Count; i++)
{
var suffix = i == exportedFiles.Count - 1 ? string.Empty : ",";
builder.AppendLine($@" ""{exportedFiles[i]}""{suffix}");
}
builder.AppendLine(" ]");
builder.AppendLine("}");
File.WriteAllText(Path.Combine(outputRoot, "manifest.json"), builder.ToString(), new UTF8Encoding(false));
}
private static void WriteExternalTemplate(string templateRoot)
{
Directory.CreateDirectory(Path.Combine(templateRoot, "src"));
File.WriteAllText(Path.Combine(templateRoot, "README.md"), BuildTemplateReadme(), new UTF8Encoding(false));
File.WriteAllText(Path.Combine(templateRoot, "global.json"), BuildTemplateGlobalJson(), new UTF8Encoding(false));
File.WriteAllText(Path.Combine(templateRoot, "build.cmd"), BuildTemplateBuildCmd(), new UTF8Encoding(false));
File.WriteAllText(Path.Combine(templateRoot, "dev-shell.cmd"), BuildTemplateDevShellCmd(), new UTF8Encoding(false));
File.WriteAllText(Path.Combine(templateRoot, TemplateProjectName + ".csproj"), BuildTemplateCsproj(), new UTF8Encoding(false));
File.WriteAllText(Path.Combine(templateRoot, "src", TemplateProjectName + "Contracts.cs"), BuildTemplateContractsCs(), new UTF8Encoding(false));
File.WriteAllText(Path.Combine(templateRoot, "src", TemplateProjectName + "Mod.cs"), BuildTemplateModCs(), new UTF8Encoding(false));
}
private static string BuildTemplateReadme()
{
return @"# SampleShrinkMod
这是给外部模组开发者的 DLL 模组模板。
## 目录
- `SampleShrinkMod.csproj`
- 引用 `..\..\..\Libs\` 下的 SDK DLL
- `build.cmd`
- 推荐的构建入口,会先设置 `MSBuildEnableWorkloadResolver=false`
- `dev-shell.cmd`
- 打开一个已经设置好环境变量的命令行
- `src/`
- 示例模组代码
EventBus handler 只使用 `[ShrinkEventSubscriber]` / `[ShrinkSubscribe]`。项目已把 `Libs/ShrinkEventBus.Generator.dll` 作为 Roslyn analyzer 引入,因此 Attach/Post 路径不依赖运行时反射。
## 推荐构建方式
双击或在命令行运行:
```cmd
build.cmd
```
## 如果你想开 IDE
先运行:
```cmd
dev-shell.cmd
```
然后在这个 shell 里再启动 IDE,或者继续执行 `build.cmd`。
## 注意
- 当前模板默认引用 `..\..\..\Libs\Assembly-CSharp.dll` 作为游戏 API
- 长期更推荐游戏方单独提供一个稳定的 `Game.ModAPI.dll`
";
}
private static string BuildTemplateGlobalJson()
{
return @"{
""sdk"": {
""version"": ""9.0.312"",
""rollForward"": ""latestPatch""
}
}";
}
private static string BuildTemplateBuildCmd()
{
return @"@echo off
setlocal
set MSBuildEnableWorkloadResolver=false
dotnet build .\SampleShrinkMod.csproj -c Release /nr:false
endlocal";
}
private static string BuildTemplateDevShellCmd()
{
return @"@echo off
set MSBuildEnableWorkloadResolver=false
echo MSBuildEnableWorkloadResolver=false
echo Current dotnet:
dotnet --version
cmd /k";
}
private static string BuildTemplateCsproj()
{
var references = new[]
{
"ShrinkModFramework.Runtime",
"ShrinkEventBus.Runtime",
"ShrinkCommand.Runtime",
"ShrinkCommand.Integration.EventBus",
"ShrinkCommand.Integration.Network",
"ShrinkNetwork.Runtime",
"ShrinkNetwork.Integration.EventBus",
"ShrinkDataSaver.Runtime",
"ShrinkDataSaver.Integration.EventBus",
"Assembly-CSharp",
"UniTask",
"Newtonsoft.Json",
"Kcp-CSharp",
"System.Runtime.CompilerServices.Unsafe",
"UnityEngine",
"UnityEngine.CoreModule"
};
var builder = new StringBuilder();
builder.AppendLine("<Project Sdk=\"Microsoft.NET.Sdk\">");
builder.AppendLine(" <PropertyGroup>");
builder.AppendLine(" <TargetFramework>net471</TargetFramework>");
builder.AppendLine(" <LangVersion>9.0</LangVersion>");
builder.AppendLine(" <Nullable>enable</Nullable>");
builder.AppendLine(" <ImplicitUsings>disable</ImplicitUsings>");
builder.AppendLine(" <MSBuildEnableWorkloadResolver>false</MSBuildEnableWorkloadResolver>");
builder.AppendLine(" <RootNamespace>SampleShrinkMod</RootNamespace>");
builder.AppendLine(" <AssemblyName>SampleShrinkMod</AssemblyName>");
builder.AppendLine(" <GenerateAssemblyInfo>false</GenerateAssemblyInfo>");
builder.AppendLine(" <EnableDefaultCompileItems>true</EnableDefaultCompileItems>");
builder.AppendLine(" <OutputPath>artifacts\\$(Configuration)\\</OutputPath>");
builder.AppendLine(" <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>");
builder.AppendLine(" </PropertyGroup>");
builder.AppendLine();
builder.AppendLine(" <ItemGroup>");
foreach (var reference in references)
{
builder.AppendLine($" <Reference Include=\"{reference}\">");
builder.AppendLine($" <HintPath>..\\..\\..\\Libs\\{reference}.dll</HintPath>");
builder.AppendLine(" <Private>true</Private>");
builder.AppendLine(" </Reference>");
}
builder.AppendLine(" </ItemGroup>");
builder.AppendLine();
builder.AppendLine(" <ItemGroup>");
builder.AppendLine(" <Analyzer Include=\"..\\..\\..\\Libs\\ShrinkEventBus.Generator.dll\" />");
builder.AppendLine(" </ItemGroup>");
builder.AppendLine("</Project>");
return builder.ToString();
}
private static string BuildTemplateContractsCs()
{
return @"#nullable enable
using ShrinkEventBus;
using ShrinkNetwork;
namespace SampleShrinkMod
{
public sealed class SampleShrinkModEvent : IShrinkEvent
{
public string Message { get; set; } = string.Empty;
}
[ShrinkNetworkMessage(41001, ""sample.shrink.mod/ping"")]
public sealed class SampleShrinkModPingRequest : IShrinkNetworkRequest
{
public string Message { get; set; } = string.Empty;
}
[ShrinkNetworkMessage(41002, ""sample.shrink.mod/ping_response"")]
public sealed class SampleShrinkModPingResponse : ShrinkRpcResponseBase
{
public string Message { get; set; } = string.Empty;
public int Counter { get; set; }
}
}";
}
private static string BuildTemplateModCs()
{
return @"#nullable enable
using ShrinkCommand;
using ShrinkEventBus;
using ShrinkModFramework;
using ShrinkNetwork;
namespace SampleShrinkMod
{
[ShrinkMod(""sample.shrink.mod"", ""Sample Shrink Mod"", ""1.0.0"")]
[ShrinkEventSubscriber(OwnerId = ""sample.shrink.mod"", DefaultBus = ""mod:sample.shrink.mod"")]
[ShrinkCommandSubscriber]
[ShrinkNetworkSubscriber]
public sealed partial class SampleShrinkModMod : ShrinkModBase
{
private readonly RuntimeState _state = new();
public override void OnConstruct(ShrinkModContext context)
{
context.Log(""模组已构造。"");
}
public override void OnRegisterContent(ShrinkModContext context)
{
context.RegisterContent(""example.items"", ""example_item"", ""Sample Shrink Mod Item"");
}
public override void OnInitialize(ShrinkModContext context)
{
context.RegisterNetworkHandler<int>(""sync.counter"", (_, value) =>
{
_state.Counter = value;
context.Log(""收到模组网络计数:"" + value);
});
}
[ShrinkSubscribe]
private void OnExampleEvent(SampleShrinkModEvent evt)
{
_state.LastEventMessage = evt.Message ?? string.Empty;
_state.Counter++;
}
[ShrinkCommand(""sample_shrink_mod ping"", Description = ""测试模组命令"", Permission = ""sample.shrink.mod.command.ping"")]
private string Ping()
{
return ""pong:"" + _state.Counter;
}
[ShrinkNetworkSubscribe(Permission = ""sample.shrink.mod.rpc.ping"")]
private SampleShrinkModPingResponse HandlePing(SampleShrinkModPingRequest request)
{
return new SampleShrinkModPingResponse
{
Message = ""pong:"" + (request.Message ?? string.Empty),
Counter = _state.Counter
};
}
private sealed class RuntimeState
{
public int Counter { get; set; }
public string LastEventMessage { get; set; } = string.Empty;
}
}
}";
}
private static void WriteStamp(string outputRoot)
{
var builder = new StringBuilder();
builder.AppendLine("{");
builder.AppendLine(@" ""generatedBy"": ""ShrinkModSdkExporter"",");
builder.AppendLine($@" ""generatedAt"": ""{DateTime.UtcNow:O}""");
builder.AppendLine("}");
File.WriteAllText(Path.Combine(outputRoot, StampFileName), builder.ToString(), new UTF8Encoding(false));
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ebad25910ea711d41b17c9dd2bbc6032
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+710
View File
@@ -0,0 +1,710 @@
# ShrinkModFramework
一个面向 Unity 的轻量模组框架,目标是给 `Shrink` 系列提供接近 Forge 的核心能力:
- 模组声明
- 模组发现
- 依赖解析
- 生命周期阶段
- 内容注册表
- 自动启动
- 外部 DLL 模组热加载
- Harmony 热补丁接入
- 网络同步通道
## 当前定位
这是一套 Unity 可落地的 Forge 风格核心框架,不是 Minecraft Forge 的逐项复刻。
当前重点是:
- 已编译进工程内的模组
- 外部 DLL 模组按内容 revision 的增量发现与替换
- `ShrinkContextHost` 驱动的模组组件生命周期与失败恢复
- Harmony 补丁自动应用
- 与具体联网库解耦的网络同步框架
当前不包含:
- 运行时卸载已加载程序集(程序集仍受 Unity/Mono AppDomain 限制驻留)
- IL2CPP Player 下的外部 DLL 动态加载
- 内置的资源包系统、命令系统、配方编辑器
- 内置的具体联网实现
## 自动启动
现在默认**不需要**把 `ShrinkModBootstrap` 挂到场景里。
框架会通过:
```csharp
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
```
自动读取 `ShrinkModFrameworkSettings` 并调用装载流程。
相关入口:
- `Runtime/Bootstrap/ShrinkModRuntimeBootstrap.cs`
- `Runtime/Loading/ShrinkModLoader.cs`
`ShrinkModBootstrap` 仍然保留,作为手动覆盖或调试入口,但不是必需品。
## 配置文件
创建 `ShrinkModFrameworkSettings` 资产后,框架会自动查找它。
关键配置包括:
- `autoLoadOnStartup`
- 是否在启动时自动装载模组
- `useContextHost`
- 默认开启:把模组四阶段放进 `ShrinkModContextHost`,替换失败恢复旧组件源;关闭后回退旧 Loader
- `verboseLogging`
- 是否输出详细日志
- `assemblyNamePrefixes`
- 只扫描指定前缀的程序集
- `enableExternalDllMods`
- 是否启用外部 DLL 模组
- `externalModsFolderName`
- 外部模组目录名,默认 `Mods`
- `watchExternalModsDirectory`
- 是否自动监听目录变化并协调外部 DLL revision 变化
- `externalModsReloadDelaySeconds`
- 文件变更后延迟多少秒再尝试热加载
- `externalAssemblyRevisionSoftLimit`
- 外部 DLL 常驻 revision 的软阈值;达到阈值时提示执行 Domain Reload 或重启进程,默认 `16`
- `enableHarmonyPatching`
- 是否启用 Harmony 自动补丁
- `enableNetworkSync`
- 是否启用网络同步框架
## 目录结构
当前 `Assets/Modules/ShrinkModFramework/Runtime/` 按职责拆成:
- `Bootstrap/`
- 启动入口、运行时驱动、设置资产
- `Core/`
- 模组实例生命周期核心类型
- `Metadata/`
- 模组特性、依赖、状态、版本信息
- `Registry/`
- 模组注册表与注册表管理器
- `Loading/`
- 模组发现、装载、外部 DLL 与 Harmony 接入
- `Network/`
- 模组网络抽象层
- `Integration/`
- 对现有 Shrink 模块的可选自动接入胶水
推荐阅读顺序:
1. `Bootstrap/`
2. `Core/` + `Metadata/`
3. `Loading/`
4. `Registry/` + `Network/` + `Integration/`
另外还有一层编辑器脚手架:
- `Editor/Scaffolding/`
- `ShrinkExternalModTemplateGenerator.cs`
- `ExternalModProjectTemplate/`
这层专门给模组开发者生成外部 DLL 模组模板,不参与运行时装载。
## 模组定义
### 基础模组
```csharp
using ShrinkModFramework;
[ShrinkMod("demo.core", "Demo Core", "1.0.0")]
public class DemoCoreMod : ShrinkModBase
{
public override void OnRegisterContent(ShrinkModContext context)
{
context.RegisterContent("items", "iron_hammer", "Iron Hammer");
}
public override void OnInitialize(ShrinkModContext context)
{
context.Log("初始化完成");
}
}
```
### 声明依赖
```csharp
using ShrinkModFramework;
[ShrinkMod("demo.magic", "Demo Magic", "1.0.0")]
[ShrinkModDependency("demo.core", "1.0.0")]
public class DemoMagicMod : ShrinkModBase
{
public override void OnInitialize(ShrinkModContext context)
{
context.Log("我会在 demo.core 之后初始化");
}
}
```
### 关闭模组自动补丁
```csharp
[ShrinkMod("demo.safe", "Demo Safe", "1.0.0", AutoApplyHarmonyPatches = false)]
public class DemoSafeMod : ShrinkModBase
{
}
```
## 生命周期
每个模组按依赖顺序进入以下阶段:
1. `OnConstruct`
2. `OnRegisterContent`
3. `OnInitialize`
4. `OnReady`
推荐职责:
- `OnConstruct`
- 初始化本模组运行时对象
- `OnRegisterContent`
- 向注册表注册物品、方块、能力、配方等
- `OnInitialize`
- 注册事件、接线系统、绑定网络频道
- `OnReady`
- 做依赖其他模组最终状态的收尾逻辑
## 外部 DLL 模组热加载
框架会扫描:
`Application.persistentDataPath/<externalModsFolderName>`
默认就是:
`Application.persistentDataPath/Mods`
ContextLoader 装载规则:
- 以 DLL 内容 SHA-256 作为 revision;同 revision 幂等,不触发重载
- 文件变更会加载新程序集并提交新的模组组件源;旧程序集仍驻留,但旧 fiber 的效应会先回滚
- 新模组任何阶段失败时,Host 会重新协调旧 source;旧注册表内容和已登记效应恢复
- 文件删除会移除当前 source 并卸载模组实例;不会宣称程序集已从 AppDomain 卸载
- 不支持 IL2CPP Player 动态程序集加载
- 支持同目录依赖程序集解析
你可以在运行时调用:
```csharp
ShrinkModLoader.LoadNewExternalMods();
```
默认会扫描当前 DLL revision,并把新增、替换、删除映射为一个完整期望组合;
变更事务失败时保留旧模组组合。设置 `useContextHost = false` 才回退为仅新增 DLL 的旧路径。
如果 `watchExternalModsDirectory = true`,框架还会监听新增、修改、删除、重命名事件,经过主线程 debouncer 后提交一次完整组合;同一 burst 内的中间坏文件不会覆盖当前有效 revision。
### 常驻 revision 诊断
Unity/Mono 不能从当前 AppDomain 单独卸载已载入程序集。框架保留当前 revision 的生效语义,同时通过以下接口暴露实际常驻情况:
```csharp
var snapshot = ShrinkModDiagnostics.CaptureExternalAssemblies(settings);
```
快照包含当前与历史 revision、来源路径、程序集名、SHA-256 revision、载入字节数、常驻数量和软阈值状态。失败 revision 可以成为已载入的历史程序集,但不会成为 current;达到软阈值只告警,不伪造卸载行为。
导入 `ShrinkContext.AppAdapter` 的 Editor 工具后,也可以从 `ShrinkSDK/Cordis/诊断与组合` 查看同一份常驻快照;该窗口通过可选反射读取,不会让 AppAdapter 对 ModFramework 建立硬依赖。
## Harmony 热补丁
如果运行环境里存在 `0Harmony`ContextHost 会把每个模组的补丁租约作为可逆效应管理:激活时调用
`PatchAll(modAssembly)`,卸载/替换时调用 `UnpatchSelf`(或兼容的 `UnpatchAll(id)`)。旧 Loader
路径仍按原逻辑只应用一次。
旧路径的调用形态是:
- `Harmony("shrink.mod.<modId>")`
- `PatchAll(modAssembly)`
这意味着:
- 模组可以在自己的程序集里直接写 Harmony Patch 类
- 框架负责发现模组后自动补丁,并在 ContextHost 路径登记逆操作
- 如果没有 Harmony,框架只会跳过,不会强依赖崩溃
注意:
- 当前是“可选桥接”,不是把 Harmony 打进框架里
- 需要你自己把 `0Harmony.dll` 放进项目环境或外部模组依赖中
## 注册表
### 注册内容
```csharp
var key = context.RegisterContent("items", "sword", "Sword");
// key == "demo.core:sword"
```
基础注册不接受任意完整键。框架始终使用当前 `ModId` 生成 `<owner>:<localKey>`,避免模组误写其它 owner 的 namespace。
### 覆盖已有内容
```csharp
context.OverrideContent(
"items",
"core:iron_sword",
priority: 100,
value: "Overridden Sword");
```
### 读取内容
```csharp
var itemRegistry = context.GetRegistry<string>("items");
if (itemRegistry.TryGet("demo.core:sword", out var itemName))
{
context.Log($"找到内容:{itemName}");
}
```
规则:
- 基础项的完整键固定为 `<ownerModId>:<localKey>`
- `context.GetRegistry<T>()` 只返回 `IReadOnlyShrinkModRegistry<T>`;写入只能经过 `RegisterContent` / `OverrideContent`
- 覆盖目标必须已经存在
- 优先级越高越先命中;同目标、同优先级直接报冲突
- 每条基础项和覆盖项都记录 owner;owner 卸载时自动撤回,并恢复下一个覆盖或基础值
- `Entries` 返回当前生效项,`BaseEntries` 返回未应用覆盖的基础项
- 注册表按类型和名称双重隔离
## 网络同步
框架内置的是**网络同步抽象层**,不是具体联网库。
核心接口:
- `IShrinkModNetworkTransport`
- `ShrinkModNetworkManager`
这意味着你可以把它接到:
- NGO
- Mirror
- FishNet
- 自己的 Socket/Relay 层
### 注册消息处理器
```csharp
context.RegisterNetworkHandler<int>("sync.hp", (messageContext, value) =>
{
Debug.Log($"收到 HP{value}");
});
```
### 发送消息
```csharp
context.SendToServer("sync.hp", 100);
context.SendToAllClients("sync.hp", 100);
context.SendToClient("sync.hp", 100, "client-1");
```
### 绑定传输层
```csharp
public sealed class MyTransport : IShrinkModNetworkTransport
{
public bool IsServer => true;
public bool IsClient => true;
public event Action<ShrinkModNetworkEnvelope> OnEnvelopeReceived;
public void Send(ShrinkModNetworkEnvelope envelope)
{
// 这里接你自己的网络库
}
}
ShrinkModNetworkManager.SetTransport(new MyTransport());
```
## 与现有模块协作
当前仓库里已经有这些可复用模块:
- `ShrinkEventBus`
- `ShrinkDataSaver`
- `ShrinkCommand`
- `ShrinkCommand.Integration.EventBus`
- `ShrinkCommand.Integration.Network`
- `ShrinkNetwork`
- `ShrinkNetwork.Integration.EventBus`
- `ShrinkDataSaver.Integration.EventBus`
`ShrinkModFramework` 当前的定位不是“重新包一层这些模块”,而是给模组提供统一生命周期,然后在合适阶段把模组实例接到这些模块现成的注册入口上。
### 自动接入了什么
从当前版本开始,模组经过:
1. `OnConstruct`
2. `OnRegisterContent`
之后,框架会自动尝试把**模组实例本身**接入这些现有模块:
- 如果模组类带 `[ShrinkEventSubscriber]` 且携带生成绑定,自动 `Attach``ShrinkBusKey.Mod(modId)`,并在模组生命周期结束时释放 binding
- 如果模组类带 `[ShrinkCommandSubscriber]`,自动调用 `ShrinkCommandRuntime.Default.RegisterCommands(modInstance)`
- 如果模组类带 `[ShrinkNetworkSubscriber]`,自动调用 `ShrinkNetworkRuntime.Default.RegisterHandlers(modInstance)`
- 如果项目里存在 `ShrinkNetwork.Integration.EventBus`,会额外调用 `ShrinkNetworkEventBusBridge.RefreshBindings()`,把新模组里声明的网络事件类型补进桥接层
这意味着:
- **项目内模组**和**运行时外部 DLL 模组**都可以把实例方法挂到 `EventBus / Command / Network`
- 模组作者不需要自己再写一遍宿主层胶水
- EventBus 是模组框架的正式依赖;Command、Network 及其桥接仍按安装情况接入
### 推荐写法
最稳的做法是把“和现有模块交互的入口”直接写在模组类实例上,而不是依赖外部 DLL 的静态自动扫描。
```csharp
using Cysharp.Threading.Tasks;
using ShrinkCommand;
using ShrinkDataSaver;
using ShrinkEventBus;
using ShrinkModFramework;
using ShrinkNetwork;
[ShrinkMod("demo.full", "Demo Full", "1.0.0")]
[ShrinkEventSubscriber(OwnerId = "demo.full", DefaultBus = "mod:demo.full")]
[ShrinkCommandSubscriber]
[ShrinkNetworkSubscriber]
public sealed partial class DemoFullMod : ShrinkModBase
{
private DemoSaveData _saveData = new();
public override void OnInitialize(ShrinkModContext context)
{
ShrinkSave.RegisterModule(
$"{context.ModInfo.ModId}.save",
() => _saveData,
data => _saveData = data ?? new DemoSaveData());
context.RegisterNetworkHandler<int>("sync.level", (messageContext, level) =>
{
_saveData.Level = level;
context.Log($"同步等级:{level}");
});
}
[ShrinkSubscribe]
private void OnSaveCompleted(ShrinkDataSaver.Integration.SaveCompletedEvent evt)
{
// 这里只是示意:模组类实例会被框架自动接到 EventBus
}
[ShrinkCommand("demo set-level <value>", Permission = "demo.admin")]
private string SetLevel(int value)
{
_saveData.Level = value;
return $"level={value}";
}
[ShrinkNetworkSubscribe]
private UniTask<DemoPingResponse> HandlePing(DemoPingRequest request)
{
return UniTask.FromResult(new DemoPingResponse
{
Message = "pong:" + request.Message
});
}
}
```
### 各模块怎么用
#### 1. `ShrinkEventBus`
适合:
- 模组内部系统解耦
- 监听 `ShrinkDataSaver.Integration.EventBus`
- 配合 `ShrinkNetwork.Integration.EventBus` 做事件即网络消息
推荐:
- 模组类本身带 `[ShrinkEventSubscriber]` 并声明 `DefaultBus = "mod:<modId>"`
- 实例方法上写 `[ShrinkSubscribe]`
- 类型声明为 `partial`,由 ILPostProcessor 或 Roslyn incremental generator 生成强类型 binding
-`ShrinkModFramework` 自动 Attach 到模组 Bus,并随模组生命周期释放
注意:
- Unity 项目内类型由 ILPostProcessor 生成 binding;外部 DLL 使用导出 SDK 中的 `ShrinkEventBus.Generator` 分析器
- 外部 DLL 必须携带生成合同;正式运行时不会反射扫描没有生成合同的旧程序集
- 模组框架只负责把已生成的实例 binding Attach 到对应 Mod Bus,不会枚举方法或调用 `MethodInfo.Invoke`
#### 2. `ShrinkDataSaver`
适合:
- 模组自己的配置和进度持久化
- 跨模组只读查询
推荐:
-`OnInitialize` 里调用 `ShrinkSave.RegisterModule(...)`
- 模块 key 用 `modId` 做前缀,例如 `demo.full.save`
- 读取别的模块数据时优先走 `ShrinkSave.QueryModule<T>(...)`
当前边界:
- `ShrinkModFramework` 不会替你自动注册存档模块,因为每个模组要保存什么只能由业务自己决定
- 当前也没有热卸载流程,所以运行时新增 DLL 后注册的存档模块默认跟随本轮进程直到退出
#### 3. `ShrinkCommand`
适合:
- 模组开放调试命令
- 服务端管理命令
- 配合网络桥接做远程命令
推荐:
- 模组类带 `[ShrinkCommandSubscriber]`
- 实例方法写 `[ShrinkCommand("path ...")]`
- 交给框架自动注册到 `ShrinkCommandRuntime.Default`
如果要继续往外接:
- 需要 EventBus 请求式命令时,用 `ShrinkCommand.Integration.EventBus`
- 需要远程命令 RPC 时,用 `ShrinkCommand.Integration.Network`
#### 4. `ShrinkNetwork`
适合:
- 模组自己的 RPC / 消息协议
- 模组内部状态同步
推荐分两层:
- 简单场景:继续用 `context.RegisterNetworkHandler(...)``context.SendToServer/Client/...`
- 需要完整 `ShrinkNetwork` 能力时:模组类带 `[ShrinkNetworkSubscriber]`,实例方法写 `[ShrinkNetworkSubscribe]`
当前自动接入的是:
- `ShrinkNetworkRuntime.Default.RegisterHandlers(modInstance)`
这意味着:
- 模组实例上的网络 handler 可以直接进默认服务
- handler 参数里的消息类型如果带 `[ShrinkNetworkMessage]`,注册时会一起补齐消息元数据
#### 5. `ShrinkNetwork.Integration.EventBus`
适合:
- 事件本身就是网络协议
- 本地 EventBus 与远端 EventBus 保持同一套事件语义
推荐:
- 事件类型同时满足 `IShrinkEvent + IShrinkNetworkMessage`
- 标记 `[ShrinkNetworkEvent] + [ShrinkNetworkMessage(...)]`
- 让模组监听或发布这些事件,而不是再写一层重复 DTO
当前补的胶水:
- 外部 DLL 模组装载后,框架会调用 `ShrinkNetworkEventBusBridge.RefreshBindings()`
这能解决:
- 新模组里的网络事件类型,原来桥接层启动时看不到
- 现在新增 DLL 后可把这些事件补进桥接层的入站注册表
### 项目内模组 vs 外部 DLL 模组
这两类不要混着理解:
- **项目内模组**
- 编译时就在 Unity 当前 AppDomain 里
- 由 Unity ILPostProcessor 生成 EventBus binding
- **运行时外部 DLL 模组**
-`ShrinkModFramework` 后续动态加载进来的
- 必须引用导出包里的 `ShrinkEventBus.Generator.dll` 生成同一份 binding 合同
所以当前推荐原则很明确:
- **对外部 DLL,优先写实例方法**
- 不要把可发现性建立在“宿主已经提前扫过一次全局静态特性”上
### 还没自动做的事
当前版本还**没有**替你自动做这些:
- 不会自动把模组数据注册进 `ShrinkDataSaver`
- 不会自动帮你给 `ShrinkModNetworkManager` 绑定 `ShrinkNetwork` 传输层
- 不会为外部 DLL 的静态 `EventBus / Command / Network` 特性类做全局重复扫描
原因很直接:
- `DataSaver` 需要业务决定保存什么
- `ShrinkModNetworkManager` 只是抽象层,具体要不要复用 `ShrinkNetwork`、Mirror、NGO 取决于宿主策略
- 对现有静态扫描入口做“再次全局扫描”容易产生重复注册
如果后面要继续往前推,最值得补的不是再加更多反射,而是做两块正式基础设施:
- `ShrinkModFramework.Integration.ShrinkNetwork`
-`IShrinkModNetworkTransport` 正式桥到 `ShrinkNetworkSession / ShrinkNetworkService`
- `ShrinkModFramework.Integration.DataSaver`
- 给模组提供规范化的 `modId` 前缀存档注册和可选卸载清理策略
## 生成模组模板
现在已经提供和 `ShrinkNetwork` 独立服务器生成器同风格的模组开发入口:
- 菜单:`ShrinkSDK/Mod/导出 Mod SDK 开发包`
- 菜单:`ShrinkSDK/Mod/生成仓库内模组模板(推荐)`
- 菜单:`ShrinkSDK/Mod/生成外部模组模板`
更推荐优先用“仓库内模组模板”:
- 直接生成到当前 Unity 项目的 `Assets/GeneratedMods/...`
- 复用当前 `ShrinkSDK.sln`
- 复用当前 Unity / asmdef 编译链
- 不需要单独打开外部 `csproj`
- 也就不会撞到独立工程那类 IDE / workload SDK 解析问题
适合:
- 你自己就在这个 SDK 仓库里开发模组
- 想最快开始写代码
- 想直接享受当前解决方案里的跳转、补全、编译和 Unity 刷新
“外部模组模板”更适合:
- 真正要把模组工程单独发给外部开发者
- 或者明确要独立于当前仓库维护一个 DLL 工程
如果目标是给**外部模组开发者**发 SDK,当前更推荐直接用:
- `ShrinkSDK/Mod/导出 Mod SDK 开发包`
它会导出:
- `Libs/`
- 已编译好的框架运行时 DLL
- 当前游戏 DLL
- 模板构建所需的基础依赖
- `Templates/ExternalMod/SampleShrinkMod/`
- 一个已经改成引用 `Libs/*.dll` 的外部模组模板
- 自带 `build.cmd``dev-shell.cmd`
- 不再直接引用你本地仓库 `csproj`
这套开发包比“只给一个模板工程”更适合外发,因为:
- 外部开发者不需要拿到完整仓库
- 不需要依赖你本地 `ShrinkSDK.sln`
- 运行时框架和游戏 API 会跟模板一起发出去
- 当前默认游戏 API 先用 `Assembly-CSharp.dll`
- 后续如果你单独抽出 `Game.ModAPI.dll`,只需要替换导出内容即可
生成器会:
- 让你选择一个输出目录
- 以目录名推导默认的 `ProjectName / Namespace / DisplayName / ModId`
- 生成一个可独立构建的外部 DLL 模组工程
默认生成内容包括:
- `README.md`
- `build.ps1`
- `.gitignore`
- `global.json`
- `__PROJECT_NAME__.csproj`
- `src/__PROJECT_NAME__Mod.cs`
- `src/__PROJECT_NAME__Contracts.cs`
模板工程默认引用当前仓库里的:
- `ShrinkModFramework.Runtime.csproj`
- `ShrinkEventBus.Runtime.csproj`
- `ShrinkCommand.Runtime.csproj`
- `ShrinkNetwork.Runtime.csproj`
所以它不是一个“纯空壳”,而是直接演示:
- `[ShrinkMod]`
- `ShrinkModBase` 生命周期
- `EventBus / Command / Network` 实例自动接入
- 一个最小 RPC 请求/响应示例
兼容性处理:
- 模板 `csproj` 默认关闭 `MSBuild workload resolver`
- 模板同时会写入 `global.json`,锁到当前生成时检测到的 `dotnet --version`
- 模板还会生成 `dev-env.ps1`,用于在启动 IDE 前先设置 `MSBuildEnableWorkloadResolver=false`
这样可以尽量避免某些 IDE / MSBuild 环境在打开项目时误触发
`Microsoft.NET.SDK.WorkloadAutoImportPropsLocator` 解析失败。
建议流程:
1. 你自己在当前仓库开发时,优先用 `ShrinkSDK/Mod/生成仓库内模组模板(推荐)`
2. 先改 `modId`、命名空间、命令路径、`opcode`
3. 在当前 Unity/IDE 里直接开发和编译
4. 如果要发给外部模组开发者,改用 `ShrinkSDK/Mod/导出 Mod SDK 开发包`
如果你已经知道目标 `Mods` 目录,也可以直接跑模板里的:
```powershell
.\build.ps1 -ModsDir "D:\YourGame\Mods"
```
## 设计约束
- 一个模组类必须有 `[ShrinkMod]`
- 一个模组类必须实现 `IShrinkMod`
- 一个模组类必须能被无参构造
- 模组 ID 必须全局唯一
- 必选依赖缺失或版本不满足时,装载直接失败
- 检测到循环依赖时,装载直接失败
- 外部 DLL 支持当前组合的添加、替换与删除;已载入的程序集 revision 仍常驻,回滚和卸载单位是模组 fiber 与其效应
## 关键文件
- `Runtime/Bootstrap/ShrinkModRuntimeBootstrap.cs`
- `Runtime/Loading/ShrinkModLoader.cs`
- `Runtime/Loading/ShrinkExternalModAssemblyLoader.cs`
- `Runtime/Loading/ShrinkHarmonyPatchService.cs`
- `Runtime/Registry/ShrinkModRegistry.cs`
- `Runtime/Network/ShrinkModNetworkManager.cs`
- `Runtime/Network/IShrinkModNetworkTransport.cs`
- `Runtime/Integration/ShrinkModOptionalRuntimeIntegration.cs`
## 结论
现在这套框架已经从“只能在工程内静态发现模组”的骨架,升级成了:
- 自动启动
- 可增量接入外部 DLL
- 可选 Harmony 补丁
- 可扩展的网络同步框架
如果继续往 Forge 靠,下一步最值得做的是:
- 模组配置系统
- 模组资源系统
- 模组命令系统
- 模组专属存档与同步策略
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 647c1debb678f9543bb6da0f94a51e90
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ddc14a844da2693418fe69ac4b2c0836
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+3
View File
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("ShrinkModFramework.Tests")]
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4db6b958232258a439bebcb315ee304d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: fb836b5fd9485e94cbefc8f1d743bc0f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+32
View File
@@ -0,0 +1,32 @@
using UnityEngine;
namespace ShrinkModFramework
{
[DefaultExecutionOrder(-2100)]
public sealed class ShrinkModBootstrap : MonoBehaviour
{
[Header("Override (留空则只使用组件上的选项)")]
[SerializeField] private ShrinkModFrameworkSettings settingsOverride;
[Header("Bootstrap")]
[SerializeField] private bool autoLoadOnAwake = true;
private static bool _bootstrapped;
private void Awake()
{
if (_bootstrapped)
{
Destroy(gameObject);
return;
}
_bootstrapped = true;
DontDestroyOnLoad(gameObject);
var shouldAutoLoad = settingsOverride ? settingsOverride.autoLoadOnStartup : autoLoadOnAwake;
if (shouldAutoLoad)
ShrinkModLoader.LoadAll(settingsOverride);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 23fd762abde4cfe48bf93f9ff86ffdd5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,69 @@
using UnityEngine;
namespace ShrinkModFramework
{
[CreateAssetMenu(fileName = "ShrinkModFrameworkSettings", menuName = "ShrinkModFramework/Settings")]
public class ShrinkModFrameworkSettings : ScriptableObject
{
private static ShrinkModFrameworkSettings _instance;
public static ShrinkModFrameworkSettings Instance
{
get
{
if (_instance) return _instance;
_instance = Resources.Load<ShrinkModFrameworkSettings>("ShrinkModFrameworkSettings");
#if UNITY_EDITOR
if (!_instance)
{
var guids = UnityEditor.AssetDatabase.FindAssets("t:ShrinkModFrameworkSettings");
if (guids.Length > 0)
{
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[0]);
_instance = UnityEditor.AssetDatabase.LoadAssetAtPath<ShrinkModFrameworkSettings>(path);
}
}
#endif
if (!_instance)
{
_instance = CreateInstance<ShrinkModFrameworkSettings>();
Debug.LogWarning(
"[ShrinkModFramework] 未找到 ShrinkModFrameworkSettings 配置文件,当前使用默认配置。");
}
return _instance;
}
internal set => _instance = value;
}
[Header("Bootstrap")]
public bool autoLoadOnStartup = true;
[Tooltip("使用 ShrinkContextHost 协调模组组件;关闭时回退到旧的只增不减 ShrinkModLoader。")]
public bool useContextHost = true;
[Header("Logging")]
public bool verboseLogging = true;
[Header("Discovery")]
public string[] assemblyNamePrefixes = new string[0];
[Header("External Mods")]
public bool enableExternalDllMods = true;
public bool autoCreateExternalModsDirectory = true;
public string externalModsFolderName = "Mods";
public bool watchExternalModsDirectory = true;
public float externalModsReloadDelaySeconds = 0.5f;
[Min(1)]
[Tooltip("Mono 下外部程序集 revision 会常驻;历史数量达到该软阈值后提示 Domain Reload/重启。")]
public int externalAssemblyRevisionSoftLimit = 16;
[Header("Harmony")]
public bool enableHarmonyPatching = true;
[Header("Networking")]
public bool enableNetworkSync = true;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8f9516fe9a338fb448308a9205c3d448
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,34 @@
using System;
namespace ShrinkModFramework
{
/// <summary>把文件系统的 burst 事件折叠为一次主线程 reload。</summary>
internal sealed class ShrinkModReloadDebouncer
{
private bool _pending;
private float _reloadAt;
public bool IsPending => _pending;
public void Schedule(float now, float delaySeconds)
{
_pending = true;
_reloadAt = now + Math.Max(0.05f, delaySeconds);
}
public bool TryConsume(float now)
{
if (!_pending || now < _reloadAt)
return false;
_pending = false;
return true;
}
public void Reset()
{
_pending = false;
_reloadAt = 0f;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ec113876ec861b64cb8391eed726108b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,34 @@
using UnityEngine;
namespace ShrinkModFramework
{
public static class ShrinkModRuntimeBootstrap
{
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStaticStateForPlayMode()
{
ShrinkModLoader.ResetForDomainReload();
ShrinkModNetworkManager.ResetForDomainReload();
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
private static void AutoLoad()
{
var settings = ShrinkModFrameworkSettings.Instance;
ShrinkModRuntimeDriver.EnsureCreated(settings);
if (settings && !settings.autoLoadOnStartup)
return;
try
{
ShrinkModLoader.LoadAll(settings);
}
catch (System.Exception ex)
{
Debug.LogException(ex);
Debug.LogError($"[ShrinkModFramework] 自动启动失败:{ex.Message}");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 329c6f80f39fd4840b987c16a6124b1c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+173
View File
@@ -0,0 +1,173 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using UnityEngine;
namespace ShrinkModFramework
{
[DefaultExecutionOrder(-2099)]
internal sealed class ShrinkModRuntimeDriver : MonoBehaviour
{
private static ShrinkModRuntimeDriver _instance;
private readonly ConcurrentQueue<Action> _mainThreadActions = new();
private readonly ShrinkModReloadDebouncer _externalReloadDebouncer = new();
private FileSystemWatcher _watcher;
private ShrinkModFrameworkSettings _settings;
public static void EnsureCreated(ShrinkModFrameworkSettings settings)
{
if (_instance)
{
_instance.ApplySettings(settings);
return;
}
var go = new GameObject("ShrinkModRuntimeDriver");
if (Application.isPlaying)
DontDestroyOnLoad(go);
_instance = go.AddComponent<ShrinkModRuntimeDriver>();
_instance.ApplySettings(settings);
}
public static void Enqueue(Action action)
{
if (action == null)
return;
if (_instance == null)
throw new InvalidOperationException("ShrinkModRuntimeDriver 尚未创建。");
_instance._mainThreadActions.Enqueue(action);
}
internal static ShrinkModRuntimeDriver InstanceForTesting => _instance;
internal bool HasWatcherForTesting => _watcher != null;
internal void PumpForTesting() => Update();
private void Update()
{
while (_mainThreadActions.TryDequeue(out var action))
{
try
{
action();
}
catch (Exception ex)
{
Debug.LogException(ex);
}
}
if (!_externalReloadDebouncer.TryConsume(Time.realtimeSinceStartup))
return;
try
{
ShrinkModLoader.LoadNewExternalMods(_settings);
}
catch (Exception ex)
{
Debug.LogException(ex);
Debug.LogError($"[ShrinkModFramework] 自动热加载外部模组失败:{ex.Message}");
}
}
private void OnDestroy()
{
DisposeWatcher();
if (_instance == this)
_instance = null;
}
private void ApplySettings(ShrinkModFrameworkSettings settings)
{
_settings = settings ?? ShrinkModFrameworkSettings.Instance;
if (_settings == null || !_settings.enableExternalDllMods || !_settings.watchExternalModsDirectory)
{
DisposeWatcher();
return;
}
TrySetupWatcher();
}
private void TrySetupWatcher()
{
#if ENABLE_IL2CPP && !UNITY_EDITOR
return;
#else
var directory = ShrinkExternalModAssemblyLoader.GetExternalModsDirectory(_settings);
if (_settings.autoCreateExternalModsDirectory)
Directory.CreateDirectory(directory);
if (!Directory.Exists(directory))
return;
if (_watcher != null && string.Equals(_watcher.Path, directory, StringComparison.OrdinalIgnoreCase))
return;
DisposeWatcher();
try
{
_watcher = new FileSystemWatcher(directory, "*.dll")
{
IncludeSubdirectories = true,
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime
};
_watcher.Created += OnExternalModsChanged;
_watcher.Changed += OnExternalModsChanged;
_watcher.Deleted += OnExternalModsChanged;
_watcher.Renamed += OnExternalModsRenamed;
_watcher.EnableRaisingEvents = true;
}
catch (Exception ex)
{
Debug.LogWarning($"[ShrinkModFramework] 外部模组目录监听启动失败:{ex.Message}");
DisposeWatcher();
}
#endif
}
private void OnExternalModsChanged(object sender, FileSystemEventArgs args)
{
ScheduleExternalReload(args.FullPath);
}
private void OnExternalModsRenamed(object sender, RenamedEventArgs args)
{
ScheduleExternalReload(args.OldFullPath);
ScheduleExternalReload(args.FullPath);
}
private void ScheduleExternalReload(string path)
{
if (string.IsNullOrWhiteSpace(path))
return;
_mainThreadActions.Enqueue(() =>
{
var delay = _settings ? Mathf.Max(0.05f, _settings.externalModsReloadDelaySeconds) : 0.5f;
_externalReloadDebouncer.Schedule(Time.realtimeSinceStartup, delay);
});
}
private void DisposeWatcher()
{
if (_watcher == null)
return;
_watcher.EnableRaisingEvents = false;
_watcher.Created -= OnExternalModsChanged;
_watcher.Changed -= OnExternalModsChanged;
_watcher.Deleted -= OnExternalModsChanged;
_watcher.Renamed -= OnExternalModsRenamed;
_watcher.Dispose();
_watcher = null;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9a850cdc47a8b5b41a7559c3d1fd9efc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6eddd3eb11f065f4d912ab35a69576f3
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+91
View File
@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cysharp.Threading.Tasks;
using ShrinkContext;
using ShrinkEventBus;
namespace ShrinkModFramework
{
/// <summary>把 IShrinkMod 四阶段生命周期映射为一个可逆 Cordis 组件。</summary>
internal sealed class ShrinkModComponent : IShrinkComponent
{
private readonly ShrinkModContextHost _host;
private readonly ShrinkModComponentSource _source;
private readonly string[] _inject;
private readonly string[] _provide;
public ShrinkModComponent(ShrinkModContextHost host, ShrinkModComponentSource source)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_source = source ?? throw new ArgumentNullException(nameof(source));
_inject = source.Info.Dependencies
.Where(dependency => !dependency.Optional)
.Select(dependency => ShrinkModContextHost.GetModKey(dependency.ModId))
.Distinct(StringComparer.Ordinal)
.ToArray();
_provide = new[] { ShrinkModContextHost.GetModKey(source.Info.ModId) };
}
public string Name => "shrink.mod/" + _source.Info.ModId;
public IReadOnlyList<string> Inject => _inject;
public IReadOnlyList<string> Provide => _provide;
public UniTask ApplyAsync(ShrinkCtx ctx, object config)
{
var instance = _source.Factory();
if (instance == null)
throw new InvalidOperationException($"Mod factory returned null: {_source.Info.ModId}");
var handle = new ShrinkModHandle(_source.Info, instance)
{
Generation = _host.NextGeneration()
};
ctx.Effect(
() => _host.RegisterHandle(handle),
() =>
{
_host.UnregisterHandle(handle);
handle.State = ShrinkModState.Unloaded;
});
IDisposable harmonyLease = null;
ctx.Effect(
() => harmonyLease = ShrinkHarmonyPatchService.AcquirePatchesIfNeeded(
handle.Info, _host.EnableHarmonyPatching, _host.VerboseLogging),
() => harmonyLease?.Dispose());
var modContext = new ShrinkModContext(handle.Info, _host.RegistryManager,
_host.Mods, _host.VerboseLogging, ctx);
instance.OnConstruct(modContext);
handle.State = ShrinkModState.Constructed;
// 先登记归属清理,OnRegisterContent 中途失败也能撤回已注册的部分内容。
ctx.Effect(() => { }, () => _host.RegistryManager.RemoveOwnedEntries(handle.Info.ModId));
instance.OnRegisterContent(modContext);
handle.State = ShrinkModState.ContentRegistered;
IDisposable eventBinding = null;
ctx.Effect(
() => eventBinding = ShrinkModOptionalRuntimeIntegration.TryAttachEventBusInstance(
instance.GetType(), instance, handle.Info.ModId, _host.VerboseLogging),
() =>
{
eventBinding?.Dispose();
EventBus.RemoveBus(ShrinkBusKey.Mod(handle.Info.ModId));
});
// 模组网络 handler 以 ModId 为归属键,可在失败或卸载时整体撤回。
ctx.Effect(() => { }, () => ShrinkModNetworkManager.UnregisterHandlers(handle.Info.ModId));
instance.OnInitialize(modContext);
handle.State = ShrinkModState.Initialized;
instance.OnReady(modContext);
handle.State = ShrinkModState.Ready;
ctx.Set(_provide[0], handle);
return UniTask.CompletedTask;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 167a3359a4a357a49ab81ba266caa07f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using UnityEngine;
namespace ShrinkModFramework
{
/// <summary>
/// 把工程内程序集和外部 DLL 的当前 revision 投影为延迟组件源。
/// 同一路径的旧外部程序集无法从 Mono AppDomain 卸载,但不会再进入候选集合。
/// </summary>
internal static class ShrinkModComponentDiscovery
{
public static IReadOnlyList<ShrinkModComponentSource> Discover(ShrinkModFrameworkSettings settings,
bool verboseLogging)
{
ShrinkExternalModAssemblyLoader.ScanExternalAssemblyRevisions(settings, verboseLogging);
var results = new List<ShrinkModComponentSource>();
var prefixes = settings?.assemblyNamePrefixes;
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
if (ShrinkExternalModAssemblyLoader.IsExternalAssembly(assembly) &&
!ShrinkExternalModAssemblyLoader.TryGetCurrentRevision(assembly, out _))
{
continue;
}
if (!ShouldScanAssembly(assembly, prefixes))
continue;
var assemblyRevision = GetAssemblyRevision(assembly);
foreach (var type in GetTypesSafely(assembly))
{
if (type == null || type.IsAbstract || type.IsInterface)
continue;
var modAttribute = type.GetCustomAttribute<ShrinkModAttribute>(false);
if (modAttribute == null)
continue;
if (!typeof(IShrinkMod).IsAssignableFrom(type))
{
Debug.LogWarning(
$"[ShrinkModFramework] 类型 {type.FullName} 标记了 ShrinkMod,但没有实现 IShrinkMod,已跳过。");
continue;
}
if (type.GetConstructor(Type.EmptyTypes) == null)
throw new InvalidOperationException($"模组 {type.FullName} 缺少无参构造函数,无法实例化。");
var dependencies = type
.GetCustomAttributes<ShrinkModDependencyAttribute>(false)
.Select(attribute => new ShrinkModDependency(
attribute.ModId, attribute.MinimumVersion, attribute.Optional))
.ToArray();
var info = new ShrinkModInfo(
modAttribute.ModId,
modAttribute.DisplayName,
modAttribute.Version,
modAttribute.LoadOrder,
type,
modAttribute.AutoApplyHarmonyPatches,
dependencies);
var revision = $"{assemblyRevision}:{type.FullName}";
results.Add(new ShrinkModComponentSource(info, revision,
() => (IShrinkMod)Activator.CreateInstance(type)));
}
}
return results;
}
private static string GetAssemblyRevision(Assembly assembly)
{
if (ShrinkExternalModAssemblyLoader.TryGetCurrentRevision(assembly, out var externalRevision))
return externalRevision;
try
{
return assembly.ManifestModule.ModuleVersionId.ToString("N");
}
catch
{
return assembly.FullName ?? assembly.GetName().Name ?? "unknown";
}
}
private static bool ShouldScanAssembly(Assembly assembly, string[] prefixes)
{
var name = assembly.GetName().Name;
if (string.IsNullOrEmpty(name))
return false;
if (name.StartsWith("Unity", StringComparison.Ordinal) ||
name.StartsWith("System", StringComparison.Ordinal) ||
name.StartsWith("mscorlib", StringComparison.Ordinal) ||
name.StartsWith("netstandard", StringComparison.Ordinal))
{
return false;
}
if (prefixes == null || prefixes.Length == 0)
return true;
return prefixes.Any(prefix => !string.IsNullOrWhiteSpace(prefix) &&
name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
}
private static IEnumerable<Type> GetTypesSafely(Assembly assembly)
{
try
{
return assembly.GetTypes();
}
catch (ReflectionTypeLoadException exception)
{
return exception.Types.Where(type => type != null);
}
catch
{
return Array.Empty<Type>();
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 95e3bad3c1c50b44db3b144af1aafda8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,27 @@
using System;
namespace ShrinkModFramework
{
/// <summary>
/// 延迟模组组件源。Revision 是来源内容的稳定指纹(外部 DLL 可使用文件哈希);
/// 同一 ModId 的 revision/factory 变化由 ShrinkModContextHost 作为替换事务处理。
/// </summary>
public sealed class ShrinkModComponentSource
{
public ShrinkModComponentSource(ShrinkModInfo info, string revision, Func<IShrinkMod> factory)
{
Info = info ?? throw new ArgumentNullException(nameof(info));
if (string.IsNullOrWhiteSpace(info.ModId))
throw new ArgumentException("ModId must not be empty.", nameof(info));
if (string.IsNullOrWhiteSpace(revision))
throw new ArgumentException("Revision must not be empty.", nameof(revision));
Revision = revision.Trim();
Factory = factory ?? throw new ArgumentNullException(nameof(factory));
}
public ShrinkModInfo Info { get; }
public string Revision { get; }
public Func<IShrinkMod> Factory { get; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0878db300b244c645bdc69c5b96b70d5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+262
View File
@@ -0,0 +1,262 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cysharp.Threading.Tasks;
using ShrinkContext;
namespace ShrinkModFramework
{
public sealed class ShrinkModTransactionException : Exception
{
public ShrinkModTransactionException(string message, Exception applyError, Exception restoreError = null)
: base(message, restoreError == null ? applyError : new AggregateException(applyError, restoreError))
{
ApplyError = applyError;
RestoreError = restoreError;
}
public Exception ApplyError { get; }
public Exception RestoreError { get; }
public bool PreviousCompositionRestored => RestoreError == null;
}
/// <summary>
/// 模组的 Cordis 组合宿主。配置应用是事务:新组合任一 fiber 失败时,重新协调到旧组件源;
/// Mono/IL2CPP 下旧程序集仍驻留,但旧/新模组实例及其已追踪效应会被正确卸载或恢复。
/// </summary>
public sealed class ShrinkModContextHost
{
private readonly Dictionary<string, ShrinkModHandle> _mods = new(StringComparer.Ordinal);
private readonly Dictionary<ShrinkModComponentSource, string> _catalogNames = new();
private readonly List<ShrinkModComponentSource> _currentSources = new();
private readonly ShrinkComponentCatalog _catalog = new();
private long _generation;
private int _catalogGeneration;
private bool _applying;
public ShrinkModContextHost(bool enableHarmonyPatching = true, bool verboseLogging = false)
{
EnableHarmonyPatching = enableHarmonyPatching;
VerboseLogging = verboseLogging;
Runtime = new ShrinkContextRuntime();
Loader = new ShrinkContextLoader(Runtime, _catalog);
RegistryManager = new ShrinkModRegistryManager();
}
public event Action<ShrinkModHandle> OnModReady;
public event Action<IReadOnlyDictionary<string, ShrinkModHandle>> OnAllModsReady;
public bool EnableHarmonyPatching { get; }
public bool VerboseLogging { get; }
public ShrinkContextRuntime Runtime { get; }
public ShrinkContextLoader Loader { get; }
public IReadOnlyDictionary<string, ShrinkModHandle> Mods => _mods;
public IReadOnlyList<ShrinkModComponentSource> CurrentSources => _currentSources.ToArray();
internal ShrinkModRegistryManager RegistryManager { get; }
public IReadOnlyShrinkModRegistry<T> GetOrCreateRegistry<T>(string name) =>
RegistryManager.GetOrCreateRegistry<T>(name);
public static string GetModKey(string modId)
{
if (string.IsNullOrWhiteSpace(modId))
throw new ArgumentException("ModId must not be empty.", nameof(modId));
return "shrink.mod/" + modId.Trim();
}
public async UniTask ApplyAsync(IReadOnlyList<ShrinkModComponentSource> desiredSources)
{
if (desiredSources == null)
throw new ArgumentNullException(nameof(desiredSources));
if (_applying)
throw new InvalidOperationException("A mod composition transaction is already running.");
var desired = ReuseUnchangedSources(ValidateAndOrder(desiredSources));
var previous = _currentSources.ToArray();
var previousById = previous.ToDictionary(source => source.Info.ModId, StringComparer.Ordinal);
_applying = true;
try
{
await Loader.ApplyAsync(BuildEntries(desired));
EnsureActive(desired);
_currentSources.Clear();
_currentSources.AddRange(desired);
foreach (var source in desired)
{
if (!previousById.TryGetValue(source.Info.ModId, out var oldSource) ||
!ReferenceEquals(source, oldSource))
{
OnModReady?.Invoke(_mods[source.Info.ModId]);
}
}
OnAllModsReady?.Invoke(Mods);
}
catch (Exception applyError)
{
Exception restoreError = null;
try
{
await Loader.ApplyAsync(BuildEntries(previous));
EnsureActive(previous);
}
catch (Exception ex)
{
restoreError = ex;
}
throw new ShrinkModTransactionException(
restoreError == null
? "Mod composition failed; the previous composition was restored."
: "Mod composition failed and restoring the previous composition also failed.",
applyError,
restoreError);
}
finally
{
_applying = false;
}
}
public async UniTask ShutdownAsync()
{
if (_applying)
throw new InvalidOperationException("Cannot shut down while a mod transaction is running.");
await Loader.ApplyAsync(Array.Empty<ShrinkLoaderEntry>());
_currentSources.Clear();
}
internal long NextGeneration() => ++_generation;
internal void RegisterHandle(ShrinkModHandle handle)
{
if (!_mods.TryAdd(handle.Info.ModId, handle))
throw new InvalidOperationException($"Mod handle already active: {handle.Info.ModId}");
}
internal void UnregisterHandle(ShrinkModHandle handle)
{
if (_mods.TryGetValue(handle.Info.ModId, out var current) && ReferenceEquals(current, handle))
_mods.Remove(handle.Info.ModId);
}
private IReadOnlyList<ShrinkLoaderEntry> BuildEntries(IEnumerable<ShrinkModComponentSource> sources)
{
var entries = new List<ShrinkLoaderEntry>();
foreach (var source in sources)
{
if (!_catalogNames.TryGetValue(source, out var catalogName))
{
catalogName = $"shrink.mod.source/{source.Info.ModId}/{++_catalogGeneration}";
_catalogNames.Add(source, catalogName);
_catalog.Register(catalogName, () => new ShrinkModComponent(this, source));
}
entries.Add(new ShrinkLoaderEntry(source.Info.ModId, catalogName));
}
return entries;
}
private void EnsureActive(IEnumerable<ShrinkModComponentSource> sources)
{
foreach (var source in sources)
{
if (!Loader.TryGetFiber(source.Info.ModId, out var fiber))
throw new InvalidOperationException($"Mod fiber was not created: {source.Info.ModId}");
if (fiber.LastError != null)
throw new InvalidOperationException($"Mod {source.Info.ModId} failed during apply.", fiber.LastError);
if (fiber.State != ShrinkFiberState.Active)
throw new InvalidOperationException(
$"Mod {source.Info.ModId} did not become active (state={fiber.State}).");
}
}
private static List<ShrinkModComponentSource> ValidateAndOrder(
IReadOnlyList<ShrinkModComponentSource> sources)
{
var map = new Dictionary<string, ShrinkModComponentSource>(StringComparer.Ordinal);
foreach (var source in sources)
{
if (source == null)
throw new ArgumentException("Mod sources must not contain null.", nameof(sources));
if (!map.TryAdd(source.Info.ModId, source))
throw new InvalidOperationException($"Duplicate mod source id: {source.Info.ModId}");
}
foreach (var source in sources)
{
foreach (var dependency in source.Info.Dependencies)
{
if (!map.TryGetValue(dependency.ModId, out var target))
{
if (!dependency.Optional)
throw new InvalidOperationException(
$"Mod {source.Info.ModId} is missing required dependency {dependency.ModId}.");
continue;
}
if (!string.IsNullOrEmpty(dependency.MinimumVersion) &&
ShrinkVersionUtility.Compare(target.Info.Version, dependency.MinimumVersion) < 0)
{
throw new InvalidOperationException(
$"Mod {source.Info.ModId} requires {dependency.ModId} >= {dependency.MinimumVersion}, " +
$"but found {target.Info.Version}.");
}
}
}
var result = new List<ShrinkModComponentSource>();
var visiting = new HashSet<string>(StringComparer.Ordinal);
var visited = new HashSet<string>(StringComparer.Ordinal);
foreach (var source in sources.OrderBy(item => item.Info.LoadOrder)
.ThenBy(item => item.Info.ModId, StringComparer.Ordinal))
{
Visit(source, map, visiting, visited, result);
}
return result;
}
private List<ShrinkModComponentSource> ReuseUnchangedSources(
IEnumerable<ShrinkModComponentSource> desired)
{
var currentById = _currentSources.ToDictionary(source => source.Info.ModId, StringComparer.Ordinal);
var normalized = new List<ShrinkModComponentSource>();
foreach (var source in desired)
{
if (currentById.TryGetValue(source.Info.ModId, out var current) &&
string.Equals(current.Revision, source.Revision, StringComparison.Ordinal))
{
normalized.Add(current);
}
else
{
normalized.Add(source);
}
}
return normalized;
}
private static void Visit(ShrinkModComponentSource source,
IReadOnlyDictionary<string, ShrinkModComponentSource> map,
ISet<string> visiting,
ISet<string> visited,
ICollection<ShrinkModComponentSource> result)
{
if (visited.Contains(source.Info.ModId))
return;
if (!visiting.Add(source.Info.ModId))
throw new InvalidOperationException($"Circular mod dependency includes {source.Info.ModId}.");
foreach (var dependency in source.Info.Dependencies.OrderBy(item => item.ModId, StringComparer.Ordinal))
{
if (map.TryGetValue(dependency.ModId, out var target))
Visit(target, map, visiting, visited, result);
}
visiting.Remove(source.Info.ModId);
visited.Add(source.Info.ModId);
result.Add(source);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5a1df7a5c357c614a976ff968dac34fd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+68
View File
@@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
namespace ShrinkModFramework
{
/// <summary>默认模组组件根;目录变更只提交完整的新组合,失败时由 Host 恢复旧组合。</summary>
public static class ShrinkModCordisRuntime
{
private static ShrinkModContextHost _host;
public static bool IsInitialized => _host != null;
public static ShrinkModContextHost Host => _host ?? throw new InvalidOperationException(
"ShrinkModCordisRuntime has not been initialized.");
public static IReadOnlyDictionary<string, ShrinkModHandle> Mods =>
_host?.Mods ?? EmptyMods;
private static readonly IReadOnlyDictionary<string, ShrinkModHandle> EmptyMods =
new Dictionary<string, ShrinkModHandle>();
public static async UniTask<IReadOnlyDictionary<string, ShrinkModHandle>> ApplyDiscoveredAsync(
ShrinkModFrameworkSettings settings = null)
{
settings ??= ShrinkModFrameworkSettings.Instance;
var verboseLogging = settings == null || settings.verboseLogging;
if (_host == null)
{
_host = new ShrinkModContextHost(
settings == null || settings.enableHarmonyPatching,
verboseLogging);
}
var revisionState = ShrinkExternalModAssemblyLoader.CaptureState();
ShrinkModNetworkManager.Configure(settings == null || settings.enableNetworkSync, verboseLogging);
try
{
var sources = ShrinkModComponentDiscovery.Discover(settings, verboseLogging);
await _host.ApplyAsync(sources);
}
catch
{
// DLL revision discovery is part of the same composition transaction:
// a failing replacement must not make the failed assembly current.
ShrinkExternalModAssemblyLoader.RestoreState(revisionState);
throw;
}
if (verboseLogging)
UnityEngine.Debug.Log($"[ShrinkModFramework] Cordis 模组组合已提交,共 {_host.Mods.Count} 个。");
return _host.Mods;
}
public static IReadOnlyShrinkModRegistry<T> GetOrCreateRegistry<T>(string name) =>
Host.GetOrCreateRegistry<T>(name);
internal static void ResetForDomainReload()
{
_host = null;
}
internal static void ResetForTesting()
{
if (_host != null)
_host.ShutdownAsync().GetAwaiter().GetResult();
_host = null;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b8767fd6541a55046a6d2f2ec992c446
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6f3fa4ee5dcc33646922caaf6b2b72d8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+10
View File
@@ -0,0 +1,10 @@
namespace ShrinkModFramework
{
public interface IShrinkMod
{
void OnConstruct(ShrinkModContext context);
void OnRegisterContent(ShrinkModContext context);
void OnInitialize(ShrinkModContext context);
void OnReady(ShrinkModContext context);
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6736c9aa27396dc4d9ceb5efd0db3ccb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+10
View File
@@ -0,0 +1,10 @@
namespace ShrinkModFramework
{
public abstract class ShrinkModBase : IShrinkMod
{
public virtual void OnConstruct(ShrinkModContext context) { }
public virtual void OnRegisterContent(ShrinkModContext context) { }
public virtual void OnInitialize(ShrinkModContext context) { }
public virtual void OnReady(ShrinkModContext context) { }
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: eb330a4a244f00444a73f5b1786ff80a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+98
View File
@@ -0,0 +1,98 @@
using System;
using System.Collections.Generic;
using ShrinkContext;
using UnityEngine;
namespace ShrinkModFramework
{
public sealed class ShrinkModContext
{
private readonly ShrinkModRegistryManager _registryManager;
private readonly IReadOnlyDictionary<string, ShrinkModHandle> _loadedMods;
private readonly bool _verboseLogging;
private readonly ShrinkCtx _effectContext;
internal ShrinkModContext(ShrinkModInfo modInfo, ShrinkModRegistryManager registryManager,
IReadOnlyDictionary<string, ShrinkModHandle> loadedMods, bool verboseLogging,
ShrinkCtx effectContext = null)
{
ModInfo = modInfo;
_registryManager = registryManager;
_loadedMods = loadedMods;
_verboseLogging = verboseLogging;
_effectContext = effectContext;
}
public ShrinkModInfo ModInfo { get; }
public IReadOnlyDictionary<string, ShrinkModHandle> LoadedMods => _loadedMods;
public bool IsContextManaged => _effectContext != null;
public IReadOnlyShrinkModRegistry<T> GetRegistry<T>(string name) =>
_registryManager.GetOrCreateRegistry<T>(name);
public bool TryGetRegistry<T>(string name, out IReadOnlyShrinkModRegistry<T> registry)
=> _registryManager.TryGetRegistry(name, out registry);
/// <summary>用当前 ModId 作为 namespace 注册内容,并返回最终完整键。</summary>
public string RegisterContent<T>(string registryName, string localKey, T value) =>
_registryManager.GetOrCreateMutableRegistry<T>(registryName)
.RegisterNamespaced(ModInfo.ModId, localKey, value);
/// <summary>以当前模组为 owner 覆盖已有内容;同目标、同优先级冲突会直接失败。</summary>
public void OverrideContent<T>(string registryName, string targetKey, int priority, T value) =>
_registryManager.GetOrCreateMutableRegistry<T>(registryName)
.RegisterOverride(ModInfo.ModId, targetKey, priority, value);
public bool IsModLoaded(string modId) => !string.IsNullOrWhiteSpace(modId) && _loadedMods.ContainsKey(modId);
public bool TryGetLoadedMod(string modId, out ShrinkModHandle handle)
{
if (!string.IsNullOrWhiteSpace(modId))
return _loadedMods.TryGetValue(modId, out handle);
handle = null;
return false;
}
/// <summary>
/// 在模组组件上下文中执行前向动作并就地登记逆操作。逆操作随 fiber 卸载按 LIFO 执行。
/// 旧 ShrinkModLoader 路径没有可逆上下文,调用本 API 会明确失败而不是静默丢失 cleanup。
/// </summary>
public void Effect(Action forward, Action inverse)
{
if (_effectContext == null)
throw new InvalidOperationException(
"ShrinkModContext.Effect requires ShrinkModContextHost. The legacy ShrinkModLoader cannot track inverses.");
_effectContext.Effect(forward, inverse);
}
/// <summary>为已完成的前向动作登记同步逆操作。</summary>
public void EffectInverse(Action inverse)
{
if (inverse == null)
throw new ArgumentNullException(nameof(inverse));
Effect(() => { }, inverse);
}
public void Log(string message)
{
if (_verboseLogging)
Debug.Log($"[ShrinkMod:{ModInfo.ModId}] {message}");
}
public void RegisterNetworkHandler<T>(string channel, System.Action<ShrinkModNetworkMessageContext, T> handler)
=> ShrinkModNetworkManager.RegisterHandler(ModInfo.ModId, channel, handler);
public void SendToServer<T>(string channel, T payload, string senderPeerId = null)
=> ShrinkModNetworkManager.SendToServer(ModInfo.ModId, channel, payload, senderPeerId);
public void SendToAllClients<T>(string channel, T payload, string senderPeerId = null)
=> ShrinkModNetworkManager.SendToAllClients(ModInfo.ModId, channel, payload, senderPeerId);
public void SendToClient<T>(string channel, T payload, string targetPeerId, string senderPeerId = null)
=> ShrinkModNetworkManager.SendToClient(ModInfo.ModId, channel, payload, targetPeerId, senderPeerId);
public void LogWarning(string message) => Debug.LogWarning($"[ShrinkMod:{ModInfo.ModId}] {message}");
public void LogError(string message) => Debug.LogError($"[ShrinkMod:{ModInfo.ModId}] {message}");
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3b8a0f4cfa66c5142a114eacadb74b75
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+20
View File
@@ -0,0 +1,20 @@
using System;
namespace ShrinkModFramework
{
public sealed class ShrinkModHandle
{
public ShrinkModInfo Info { get; }
public IShrinkMod Instance { get; }
public ShrinkModState State { get; internal set; }
public long Generation { get; internal set; }
internal IDisposable EventBinding { get; set; }
internal ShrinkModHandle(ShrinkModInfo info, IShrinkMod instance)
{
Info = info;
Instance = instance;
State = ShrinkModState.Discovered;
}
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6609ebbb46db87c42a2fa1d45bd12efe
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d81e389d24bac1a45bedd3a97d679a87
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,144 @@
using System;
using System.Linq;
using System.Reflection;
using ShrinkEventBus;
using UnityEngine;
namespace ShrinkModFramework
{
internal static class ShrinkModOptionalRuntimeIntegration
{
public static IDisposable RegisterModInstance(IShrinkMod modInstance, ShrinkModInfo modInfo,
bool verboseLogging)
{
if (modInstance == null || modInfo == null)
return null;
var modType = modInstance.GetType();
var eventBinding = TryAttachEventBusInstance(modType, modInstance, modInfo.ModId, verboseLogging);
TryRegisterCommandInstance(modType, modInstance, modInfo.ModId, verboseLogging);
TryRegisterNetworkInstance(modType, modInstance, modInfo.ModId, verboseLogging);
return eventBinding;
}
public static void RefreshGlobalBindings(bool verboseLogging)
{
TryRefreshNetworkEventBusBridge(verboseLogging);
}
public static IDisposable TryAttachEventBusInstance(Type modType, object modInstance,
string modId, bool verboseLogging)
{
if (modInstance is not IShrinkGeneratedSubscriber)
return null;
try
{
var busKey = ShrinkBusKey.Mod(modId);
EventBus.GetOrCreateBus(busKey, ShrinkBusOptions.DedicatedThread());
var binding = EventBus.Attach(modInstance, busKey);
if (verboseLogging)
Debug.Log($"[ShrinkModFramework] 模组 {modId} 已接入 ShrinkEventBus 实例订阅。");
return binding;
}
catch (Exception ex)
{
Debug.LogWarning($"[ShrinkModFramework] 模组 {modId} 接入 ShrinkEventBus 失败:{ex.Message}");
return null;
}
}
private static void TryRegisterCommandInstance(Type modType, object modInstance, string modId, bool verboseLogging)
{
if (!HasAttribute(modType, "ShrinkCommand.ShrinkCommandSubscriberAttribute", "ShrinkCommand.Runtime"))
return;
try
{
var runtimeType = FindType("ShrinkCommand.ShrinkCommandRuntime", "ShrinkCommand.Runtime");
var service = runtimeType?.GetProperty("Default", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
var registerMethod = service?.GetType().GetMethod("RegisterCommands",
BindingFlags.Public | BindingFlags.Instance,
null,
new[] { typeof(object) },
null);
if (registerMethod == null)
return;
registerMethod.Invoke(service, new[] { modInstance });
if (verboseLogging)
Debug.Log($"[ShrinkModFramework] 模组 {modId} 已接入 ShrinkCommand 默认服务。");
}
catch (Exception ex)
{
Debug.LogWarning($"[ShrinkModFramework] 模组 {modId} 接入 ShrinkCommand 失败:{ex.Message}");
}
}
private static void TryRegisterNetworkInstance(Type modType, object modInstance, string modId, bool verboseLogging)
{
if (!HasAttribute(modType, "ShrinkNetwork.ShrinkNetworkSubscriberAttribute", "ShrinkNetwork.Runtime"))
return;
try
{
var runtimeType = FindType("ShrinkNetwork.ShrinkNetworkRuntime", "ShrinkNetwork.Runtime");
var service = runtimeType?.GetProperty("Default", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
var registerMethod = service?.GetType().GetMethod("RegisterHandlers",
BindingFlags.Public | BindingFlags.Instance,
null,
new[] { typeof(object) },
null);
if (registerMethod == null)
return;
registerMethod.Invoke(service, new[] { modInstance });
if (verboseLogging)
Debug.Log($"[ShrinkModFramework] 模组 {modId} 已接入 ShrinkNetwork 默认服务。");
}
catch (Exception ex)
{
Debug.LogWarning($"[ShrinkModFramework] 模组 {modId} 接入 ShrinkNetwork 失败:{ex.Message}");
}
}
private static void TryRefreshNetworkEventBusBridge(bool verboseLogging)
{
try
{
var bridgeType = FindType("ShrinkNetwork.Integration.ShrinkNetworkEventBusBridge",
"ShrinkNetwork.Integration.EventBus");
var refreshMethod = bridgeType?.GetMethod("RefreshBindings",
BindingFlags.Public | BindingFlags.Static,
null,
Type.EmptyTypes,
null);
if (refreshMethod == null)
return;
refreshMethod.Invoke(null, null);
if (verboseLogging)
Debug.Log("[ShrinkModFramework] 已刷新 ShrinkNetwork.Integration.EventBus 绑定。");
}
catch (Exception ex)
{
Debug.LogWarning($"[ShrinkModFramework] 刷新 ShrinkNetwork.Integration.EventBus 绑定失败:{ex.Message}");
}
}
private static bool HasAttribute(Type targetType, string attributeFullName, string assemblyName)
{
var attributeType = FindType(attributeFullName, assemblyName);
return attributeType != null && targetType.GetCustomAttribute(attributeType, false) != null;
}
private static Type FindType(string fullName, string assemblyName)
{
return Type.GetType($"{fullName}, {assemblyName}", false) ??
AppDomain.CurrentDomain.GetAssemblies()
.Where(assembly => string.Equals(assembly.GetName().Name, assemblyName, StringComparison.Ordinal))
.Select(assembly => assembly.GetType(fullName, false))
.FirstOrDefault(type => type != null);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 733bd8496a2bbcb4fa29594a14d3b0b5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: fba48516fe357cd4eb44de16cea78788
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,281 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Security.Cryptography;
using UnityEngine;
namespace ShrinkModFramework
{
internal static class ShrinkExternalModAssemblyLoader
{
internal sealed class ExternalAssemblyRevision
{
public string Path;
public string Revision;
public Assembly Assembly;
public long LoadedBytes;
}
internal sealed class RevisionState
{
public Dictionary<string, ExternalAssemblyRevision> Current =
new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, string> Known =
new(StringComparer.OrdinalIgnoreCase);
}
private static readonly Dictionary<string, ExternalAssemblyRevision> CurrentAssemblyRevisions =
new(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<Assembly, ExternalAssemblyRevision> ExternalAssemblyHistory = new();
private static readonly Dictionary<string, string> KnownAssemblyFiles = new(StringComparer.OrdinalIgnoreCase);
private static readonly object ResolveLock = new();
private static bool _resolveRegistered;
private static int _lastWarnedResidentCount;
public static IReadOnlyList<Assembly> LoadExternalAssemblies(ShrinkModFrameworkSettings settings, bool verboseLogging)
{
return ScanExternalAssemblyRevisions(settings, verboseLogging)
.Select(revision => revision.Assembly)
.ToArray();
}
internal static IReadOnlyList<ExternalAssemblyRevision> ScanExternalAssemblyRevisions(
ShrinkModFrameworkSettings settings, bool verboseLogging)
{
if (settings != null && !settings.enableExternalDllMods)
{
CurrentAssemblyRevisions.Clear();
KnownAssemblyFiles.Clear();
return Array.Empty<ExternalAssemblyRevision>();
}
#if ENABLE_IL2CPP && !UNITY_EDITOR
Debug.LogWarning("[ShrinkModFramework] IL2CPP 运行时不支持外部 DLL 热加载,已跳过外部模组扫描。");
return Array.Empty<ExternalAssemblyRevision>();
#else
var modsDirectory = GetExternalModsDirectory(settings);
if (settings == null || settings.autoCreateExternalModsDirectory)
Directory.CreateDirectory(modsDirectory);
RegisterAssemblyResolve();
var dllPaths = Directory.GetFiles(modsDirectory, "*.dll", SearchOption.AllDirectories)
.Select(Path.GetFullPath)
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.ToArray();
var presentPaths = new HashSet<string>(dllPaths, StringComparer.OrdinalIgnoreCase);
foreach (var dllPath in dllPaths
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase))
{
var assemblyName = Path.GetFileNameWithoutExtension(dllPath);
KnownAssemblyFiles[assemblyName] = dllPath;
try
{
var bytes = File.ReadAllBytes(dllPath);
var revision = ComputeSha256(bytes);
if (CurrentAssemblyRevisions.TryGetValue(dllPath, out var current) &&
string.Equals(current.Revision, revision, StringComparison.Ordinal))
{
continue;
}
var pdbPath = Path.ChangeExtension(dllPath, ".pdb");
byte[] pdbBytes = null;
if (File.Exists(pdbPath))
pdbBytes = File.ReadAllBytes(pdbPath);
var assembly = pdbBytes != null
? Assembly.Load(bytes, pdbBytes)
: Assembly.Load(bytes);
var loadedRevision = new ExternalAssemblyRevision
{
Path = dllPath,
Revision = revision,
Assembly = assembly,
LoadedBytes = bytes.LongLength + (pdbBytes?.LongLength ?? 0L)
};
CurrentAssemblyRevisions[dllPath] = loadedRevision;
ExternalAssemblyHistory[assembly] = loadedRevision;
if (verboseLogging)
Debug.Log($"[ShrinkModFramework] 已加载外部模组程序集:{assembly.GetName().Name} ({revision[..12]})");
}
catch (Exception ex)
{
Debug.LogError($"[ShrinkModFramework] 加载外部 DLL 失败:{dllPath}\n{ex.Message}");
}
}
WarnIfResidentLimitExceeded(settings);
foreach (var missingPath in CurrentAssemblyRevisions.Keys
.Where(path => !presentPaths.Contains(path))
.ToArray())
{
CurrentAssemblyRevisions.Remove(missingPath);
var assemblyName = Path.GetFileNameWithoutExtension(missingPath);
if (KnownAssemblyFiles.TryGetValue(assemblyName, out var knownPath) &&
string.Equals(knownPath, missingPath, StringComparison.OrdinalIgnoreCase))
{
KnownAssemblyFiles.Remove(assemblyName);
}
}
return CurrentAssemblyRevisions.Values
.OrderBy(revision => revision.Path, StringComparer.OrdinalIgnoreCase)
.ToArray();
#endif
}
internal static bool IsExternalAssembly(Assembly assembly) =>
assembly != null && ExternalAssemblyHistory.ContainsKey(assembly);
internal static ShrinkExternalAssemblyDiagnostic CaptureDiagnostic(int softLimit)
{
var normalizedLimit = Math.Max(1, softLimit);
var currentAssemblies = new HashSet<Assembly>(
CurrentAssemblyRevisions.Values.Select(item => item.Assembly));
var revisions = ExternalAssemblyHistory.Values
.OrderBy(item => item.Path, StringComparer.OrdinalIgnoreCase)
.ThenBy(item => item.Revision, StringComparer.Ordinal)
.Select(item => new ShrinkExternalAssemblyRevisionDiagnostic(
item.Path,
item.Revision,
item.Assembly.GetName().Name ?? string.Empty,
item.LoadedBytes,
currentAssemblies.Contains(item.Assembly)))
.ToArray();
return new ShrinkExternalAssemblyDiagnostic(
CurrentAssemblyRevisions.Count,
revisions,
revisions.Sum(item => item.LoadedBytes),
normalizedLimit);
}
internal static RevisionState CaptureState()
{
return new RevisionState
{
Current = CurrentAssemblyRevisions.ToDictionary(
pair => pair.Key,
pair => pair.Value,
StringComparer.OrdinalIgnoreCase),
Known = KnownAssemblyFiles.ToDictionary(
pair => pair.Key,
pair => pair.Value,
StringComparer.OrdinalIgnoreCase)
};
}
internal static void RestoreState(RevisionState state)
{
if (state == null)
throw new ArgumentNullException(nameof(state));
CurrentAssemblyRevisions.Clear();
foreach (var pair in state.Current)
CurrentAssemblyRevisions[pair.Key] = pair.Value;
KnownAssemblyFiles.Clear();
foreach (var pair in state.Known)
KnownAssemblyFiles[pair.Key] = pair.Value;
}
internal static bool TryGetCurrentRevision(Assembly assembly, out string revision)
{
foreach (var current in CurrentAssemblyRevisions.Values)
{
if (ReferenceEquals(current.Assembly, assembly))
{
revision = current.Revision;
return true;
}
}
revision = null;
return false;
}
public static string GetExternalModsDirectory(ShrinkModFrameworkSettings settings)
{
var folderName = settings != null && !string.IsNullOrWhiteSpace(settings.externalModsFolderName)
? settings.externalModsFolderName.Trim()
: "Mods";
return Path.Combine(Application.persistentDataPath, folderName);
}
internal static void ResetForTesting()
{
CurrentAssemblyRevisions.Clear();
KnownAssemblyFiles.Clear();
if (_resolveRegistered)
{
AppDomain.CurrentDomain.AssemblyResolve -= OnAssemblyResolve;
_resolveRegistered = false;
}
}
internal static void ResetForDomainReload()
{
ResetForTesting();
ExternalAssemblyHistory.Clear();
_lastWarnedResidentCount = 0;
}
private static void WarnIfResidentLimitExceeded(ShrinkModFrameworkSettings settings)
{
var limit = Math.Max(1, settings != null ? settings.externalAssemblyRevisionSoftLimit : 16);
var residentCount = ExternalAssemblyHistory.Count;
if (residentCount < limit || residentCount == _lastWarnedResidentCount)
return;
_lastWarnedResidentCount = residentCount;
var bytes = ExternalAssemblyHistory.Values.Sum(item => item.LoadedBytes);
Debug.LogWarning(
$"[ShrinkModFramework] 外部 DLL 常驻 revision 已达到 {residentCount} 个(载入文件约 {bytes} bytes" +
$"软阈值 {limit})。Mono 无法卸载这些 Assembly;建议在维护窗口执行 Domain Reload 或重启进程。");
}
private static string ComputeSha256(byte[] bytes)
{
using var sha256 = SHA256.Create();
return BitConverter.ToString(sha256.ComputeHash(bytes)).Replace("-", string.Empty);
}
private static void RegisterAssemblyResolve()
{
lock (ResolveLock)
{
if (_resolveRegistered)
return;
AppDomain.CurrentDomain.AssemblyResolve += OnAssemblyResolve;
_resolveRegistered = true;
}
}
private static Assembly OnAssemblyResolve(object sender, ResolveEventArgs args)
{
var requestedName = new AssemblyName(args.Name).Name;
if (string.IsNullOrWhiteSpace(requestedName))
return null;
if (!KnownAssemblyFiles.TryGetValue(requestedName, out var path) || !File.Exists(path))
return null;
try
{
return Assembly.Load(File.ReadAllBytes(path));
}
catch
{
return null;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bd2c1a02b62d790449c129e226cde40b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
namespace ShrinkModFramework
{
internal static class ShrinkHarmonyPatchService
{
private sealed class EmptyLease : IDisposable
{
public static readonly EmptyLease Instance = new();
public void Dispose() { }
}
private sealed class HarmonyLease : IDisposable
{
private readonly Type _harmonyType;
private readonly object _harmony;
private readonly string _harmonyId;
private readonly bool _verboseLogging;
private bool _disposed;
public HarmonyLease(Type harmonyType, object harmony, string harmonyId, bool verboseLogging)
{
_harmonyType = harmonyType;
_harmony = harmony;
_harmonyId = harmonyId;
_verboseLogging = verboseLogging;
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
try
{
var unpatchSelf = _harmonyType.GetMethod("UnpatchSelf",
BindingFlags.Public | BindingFlags.Instance,
null,
Type.EmptyTypes,
null);
if (unpatchSelf != null)
{
unpatchSelf.Invoke(_harmony, null);
}
else
{
var unpatchAll = _harmonyType.GetMethod("UnpatchAll",
BindingFlags.Public | BindingFlags.Static,
null,
new[] { typeof(string) },
null);
if (unpatchAll == null)
throw new MissingMethodException("Harmony.UnpatchSelf()/UnpatchAll(string) 不存在。");
unpatchAll.Invoke(null, new object[] { _harmonyId });
}
if (_verboseLogging)
Debug.Log($"[ShrinkModFramework] 已撤回 Harmony 补丁:{_harmonyId}");
}
catch (Exception ex)
{
Debug.LogError($"[ShrinkModFramework] 撤回 Harmony 补丁失败:{_harmonyId}\n{ex.Message}");
}
finally
{
AppliedHarmonyIds.Remove(_harmonyId);
}
}
}
private static readonly HashSet<string> AppliedHarmonyIds = new(StringComparer.Ordinal);
public static void ApplyPatchesIfNeeded(ShrinkModInfo modInfo, bool enableHarmonyPatching, bool verboseLogging)
{
_ = ApplyPatchesCore(modInfo, enableHarmonyPatching, verboseLogging, reversible: false);
}
public static IDisposable AcquirePatchesIfNeeded(ShrinkModInfo modInfo, bool enableHarmonyPatching,
bool verboseLogging)
{
return ApplyPatchesCore(modInfo, enableHarmonyPatching, verboseLogging, reversible: true);
}
private static IDisposable ApplyPatchesCore(ShrinkModInfo modInfo, bool enableHarmonyPatching,
bool verboseLogging, bool reversible)
{
if (!enableHarmonyPatching || !modInfo.AutoApplyHarmonyPatches)
return EmptyLease.Instance;
var harmonyType = Type.GetType("HarmonyLib.Harmony, 0Harmony");
if (harmonyType == null)
{
if (verboseLogging)
Debug.Log($"[ShrinkModFramework] 未检测到 Harmony,跳过模组 {modInfo.ModId} 的补丁自动应用。");
return EmptyLease.Instance;
}
var harmonyId = $"shrink.mod.{modInfo.ModId}";
if (!AppliedHarmonyIds.Add(harmonyId))
return EmptyLease.Instance;
try
{
var harmony = Activator.CreateInstance(harmonyType, harmonyId);
var patchAll = harmonyType.GetMethod("PatchAll", new[] { typeof(Assembly) });
if (patchAll == null)
throw new MissingMethodException("Harmony.PatchAll(Assembly) 不存在。");
patchAll.Invoke(harmony, new object[] { modInfo.EntryType.Assembly });
if (verboseLogging)
Debug.Log($"[ShrinkModFramework] 已应用 Harmony 补丁:{modInfo.ModId}");
return reversible
? new HarmonyLease(harmonyType, harmony, harmonyId, verboseLogging)
: EmptyLease.Instance;
}
catch (Exception ex)
{
AppliedHarmonyIds.Remove(harmonyId);
throw new InvalidOperationException($"模组 {modInfo.ModId} 应用 Harmony 补丁失败:{ex.Message}", ex);
}
}
internal static void ResetForTesting() => AppliedHarmonyIds.Clear();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c1bf43d28651dea4fa7d5e5c56361da4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+57
View File
@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
namespace ShrinkModFramework
{
public sealed class ShrinkExternalAssemblyRevisionDiagnostic
{
internal ShrinkExternalAssemblyRevisionDiagnostic(string path, string revision, string assemblyName,
long loadedBytes, bool isCurrent)
{
Path = path;
Revision = revision;
AssemblyName = assemblyName;
LoadedBytes = loadedBytes;
IsCurrent = isCurrent;
}
public string Path { get; }
public string Revision { get; }
public string AssemblyName { get; }
public long LoadedBytes { get; }
public bool IsCurrent { get; }
}
/// <summary>Mono 外部程序集的 current 与常驻历史快照;LoadedBytes 仅统计载入 DLL/PDB 文件大小。</summary>
public sealed class ShrinkExternalAssemblyDiagnostic
{
internal ShrinkExternalAssemblyDiagnostic(int currentRevisionCount,
IReadOnlyList<ShrinkExternalAssemblyRevisionDiagnostic> residentRevisions,
long estimatedResidentBytes, int softLimit)
{
CurrentRevisionCount = currentRevisionCount;
ResidentRevisions = residentRevisions;
EstimatedResidentBytes = estimatedResidentBytes;
SoftLimit = softLimit;
}
public int CurrentRevisionCount { get; }
public IReadOnlyList<ShrinkExternalAssemblyRevisionDiagnostic> ResidentRevisions { get; }
public int ResidentRevisionCount => ResidentRevisions.Count;
public long EstimatedResidentBytes { get; }
public int SoftLimit { get; }
public bool IsSoftLimitExceeded => ResidentRevisionCount >= SoftLimit;
}
public static class ShrinkModDiagnostics
{
public static ShrinkExternalAssemblyDiagnostic CaptureExternalAssemblies(
ShrinkModFrameworkSettings settings = null)
{
if (settings == null)
settings = ShrinkModFrameworkSettings.Instance;
return ShrinkExternalModAssemblyLoader.CaptureDiagnostic(
settings != null ? settings.externalAssemblyRevisionSoftLimit : 16);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 20342cb92d99bc34ca99a41312b5a95d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+476
View File
@@ -0,0 +1,476 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using ShrinkEventBus;
using UnityEngine;
namespace ShrinkModFramework
{
public static class ShrinkModLoader
{
private sealed class DiscoveredMod
{
public ShrinkModInfo Info;
public Type EntryType;
}
private static readonly Dictionary<string, ShrinkModHandle> LoadedMods = new(StringComparer.Ordinal);
private static readonly List<ShrinkModHandle> LoadSequence = new();
private static readonly ShrinkModRegistryManager RegistryManager = new();
public static bool IsLoaded { get; private set; }
public static IReadOnlyDictionary<string, ShrinkModHandle> Mods =>
ShrinkModCordisRuntime.IsInitialized ? ShrinkModCordisRuntime.Mods : LoadedMods;
public static event Action<ShrinkModHandle> OnModReady;
public static event Action<IReadOnlyDictionary<string, ShrinkModHandle>> OnAllModsReady;
public static IReadOnlyDictionary<string, ShrinkModHandle> LoadAll(ShrinkModFrameworkSettings settings = null)
{
settings ??= ShrinkModFrameworkSettings.Instance;
if (IsLoaded)
{
Debug.Log("[ShrinkModLoader] 模组已装载,跳过重复装载。");
return Mods;
}
if (settings == null || settings.useContextHost)
return ApplyContextComposition(settings);
var verboseLogging = settings == null || settings.verboseLogging;
try
{
ShrinkModNetworkManager.Configure(settings == null || settings.enableNetworkSync, verboseLogging);
ShrinkExternalModAssemblyLoader.LoadExternalAssemblies(settings, verboseLogging);
var discovered = DiscoverMods(settings);
var ordered = ResolveLoadOrder(discovered, allowExistingLoadedDependencies: true);
foreach (var discoveredMod in ordered)
{
var handle = InstantiateAndTrack(discoveredMod, settings, verboseLogging);
LoadSequence.Add(handle);
}
RunLifecycle(LoadSequence, verboseLogging);
IsLoaded = true;
OnAllModsReady?.Invoke(LoadedMods);
Debug.Log($"[ShrinkModLoader] 模组装载完成,共 {LoadedMods.Count} 个。");
return LoadedMods;
}
catch
{
ResetForTesting();
throw;
}
}
public static IReadOnlyDictionary<string, ShrinkModHandle> LoadNewExternalMods(ShrinkModFrameworkSettings settings = null)
{
settings ??= ShrinkModFrameworkSettings.Instance;
if (settings == null || settings.useContextHost)
return ApplyContextComposition(settings);
if (!IsLoaded)
return LoadAll(settings);
var verboseLogging = settings == null || settings.verboseLogging;
ShrinkExternalModAssemblyLoader.LoadExternalAssemblies(settings, verboseLogging);
var discovered = DiscoverMods(settings)
.Where(mod => !LoadedMods.ContainsKey(mod.Info.ModId))
.ToList();
if (discovered.Count == 0)
return LoadedMods;
ValidateNewMods(discovered);
var ordered = ResolveLoadOrder(discovered, allowExistingLoadedDependencies: true);
var newHandles = new List<ShrinkModHandle>(ordered.Count);
try
{
foreach (var discoveredMod in ordered)
{
var handle = InstantiateAndTrack(discoveredMod, settings, verboseLogging);
LoadSequence.Add(handle);
newHandles.Add(handle);
}
RunLifecycle(newHandles, verboseLogging);
return LoadedMods;
}
catch
{
foreach (var handle in newHandles)
{
handle.EventBinding?.Dispose();
handle.EventBinding = null;
EventBus.RemoveBus(ShrinkBusKey.Mod(handle.Info.ModId));
LoadedMods.Remove(handle.Info.ModId);
LoadSequence.Remove(handle);
}
throw;
}
}
public static bool TryGetMod(string modId, out ShrinkModHandle handle)
{
if (!string.IsNullOrWhiteSpace(modId))
return Mods.TryGetValue(modId, out handle);
handle = null;
return false;
}
public static IReadOnlyShrinkModRegistry<T> GetOrCreateRegistry<T>(string name)
=> ShrinkModCordisRuntime.IsInitialized
? ShrinkModCordisRuntime.GetOrCreateRegistry<T>(name)
: RegistryManager.GetOrCreateRegistry<T>(name);
internal static void ResetForTesting()
{
ShrinkModCordisRuntime.ResetForTesting();
DisposeLegacyEventBindings();
LoadedMods.Clear();
LoadSequence.Clear();
RegistryManager.Clear();
IsLoaded = false;
OnModReady = null;
OnAllModsReady = null;
ShrinkModNetworkManager.ResetForDomainReload();
ShrinkExternalModAssemblyLoader.ResetForTesting();
ShrinkHarmonyPatchService.ResetForTesting();
}
internal static void ResetForDomainReload()
{
ShrinkModCordisRuntime.ResetForDomainReload();
DisposeLegacyEventBindings();
LoadedMods.Clear();
LoadSequence.Clear();
RegistryManager.Clear();
IsLoaded = false;
OnModReady = null;
OnAllModsReady = null;
ShrinkModNetworkManager.ResetForDomainReload();
ShrinkExternalModAssemblyLoader.ResetForDomainReload();
ShrinkHarmonyPatchService.ResetForTesting();
}
private static IReadOnlyDictionary<string, ShrinkModHandle> ApplyContextComposition(
ShrinkModFrameworkSettings settings)
{
var previousGenerations = Mods.ToDictionary(
pair => pair.Key,
pair => pair.Value.Generation,
StringComparer.Ordinal);
var result = ShrinkModCordisRuntime.ApplyDiscoveredAsync(settings).GetAwaiter().GetResult();
IsLoaded = true;
foreach (var pair in result.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
if (!previousGenerations.TryGetValue(pair.Key, out var generation) ||
generation != pair.Value.Generation)
{
OnModReady?.Invoke(pair.Value);
}
}
OnAllModsReady?.Invoke(result);
return result;
}
private static ShrinkModHandle InstantiateAndTrack(DiscoveredMod discoveredMod,
ShrinkModFrameworkSettings settings, bool verboseLogging)
{
var instance = CreateInstance(discoveredMod);
var handle = new ShrinkModHandle(discoveredMod.Info, instance);
LoadedMods.Add(handle.Info.ModId, handle);
ShrinkHarmonyPatchService.ApplyPatchesIfNeeded(
handle.Info,
settings == null || settings.enableHarmonyPatching,
verboseLogging);
return handle;
}
private static List<DiscoveredMod> DiscoverMods(ShrinkModFrameworkSettings settings)
{
var results = new List<DiscoveredMod>();
var prefixes = settings?.assemblyNamePrefixes;
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
if (!ShouldScanAssembly(assembly, prefixes))
continue;
foreach (var type in GetTypesSafely(assembly))
{
if (type == null || type.IsAbstract || type.IsInterface)
continue;
var modAttribute = type.GetCustomAttribute<ShrinkModAttribute>(false);
if (modAttribute == null)
continue;
if (!typeof(IShrinkMod).IsAssignableFrom(type))
{
Debug.LogWarning($"[ShrinkModLoader] 类型 {type.FullName} 标记了 ShrinkMod,但没有实现 IShrinkMod,已跳过。");
continue;
}
if (type.GetConstructor(Type.EmptyTypes) == null)
throw new InvalidOperationException($"模组 {type.FullName} 缺少无参构造函数,无法实例化。");
var dependencies = type
.GetCustomAttributes<ShrinkModDependencyAttribute>(false)
.Select(attr => new ShrinkModDependency(attr.ModId, attr.MinimumVersion, attr.Optional))
.ToArray();
results.Add(new DiscoveredMod
{
EntryType = type,
Info = new ShrinkModInfo(
modAttribute.ModId,
modAttribute.DisplayName,
modAttribute.Version,
modAttribute.LoadOrder,
type,
modAttribute.AutoApplyHarmonyPatches,
dependencies)
});
}
}
ValidateDiscoveredMods(results);
return results;
}
private static void ValidateDiscoveredMods(List<DiscoveredMod> discovered)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var mod in discovered)
{
if (!seen.Add(mod.Info.ModId))
throw new InvalidOperationException($"发现重复模组 ID{mod.Info.ModId}");
}
var map = discovered.ToDictionary(x => x.Info.ModId, x => x, StringComparer.Ordinal);
foreach (var mod in discovered)
{
foreach (var dependency in mod.Info.Dependencies)
{
if (!map.TryGetValue(dependency.ModId, out var target))
{
if (!dependency.Optional && !LoadedMods.ContainsKey(dependency.ModId))
throw new InvalidOperationException($"模组 {mod.Info.ModId} 缺少必选依赖 {dependency.ModId}");
continue;
}
ValidateDependencyVersion(mod.Info, dependency, target.Info.Version);
}
}
}
private static void ValidateNewMods(List<DiscoveredMod> newMods)
{
var seen = new HashSet<string>(LoadedMods.Keys, StringComparer.Ordinal);
foreach (var mod in newMods)
{
if (!seen.Add(mod.Info.ModId))
throw new InvalidOperationException($"发现重复模组 ID{mod.Info.ModId}");
}
var map = newMods.ToDictionary(x => x.Info.ModId, x => x, StringComparer.Ordinal);
foreach (var mod in newMods)
{
foreach (var dependency in mod.Info.Dependencies)
{
if (map.TryGetValue(dependency.ModId, out var newTarget))
{
ValidateDependencyVersion(mod.Info, dependency, newTarget.Info.Version);
continue;
}
if (LoadedMods.TryGetValue(dependency.ModId, out var loadedTarget))
{
ValidateDependencyVersion(mod.Info, dependency, loadedTarget.Info.Version);
continue;
}
if (!dependency.Optional)
throw new InvalidOperationException($"模组 {mod.Info.ModId} 缺少必选依赖 {dependency.ModId}");
}
}
}
private static void ValidateDependencyVersion(ShrinkModInfo owner, ShrinkModDependency dependency, string actualVersion)
{
if (!string.IsNullOrEmpty(dependency.MinimumVersion) &&
ShrinkVersionUtility.Compare(actualVersion, dependency.MinimumVersion) < 0)
{
throw new InvalidOperationException(
$"模组 {owner.ModId} 依赖 {dependency.ModId} >= {dependency.MinimumVersion},但当前只有 {actualVersion}");
}
}
private static List<DiscoveredMod> ResolveLoadOrder(List<DiscoveredMod> discovered, bool allowExistingLoadedDependencies)
{
var map = discovered.ToDictionary(x => x.Info.ModId, x => x, StringComparer.Ordinal);
var result = new List<DiscoveredMod>();
var visiting = new HashSet<string>(StringComparer.Ordinal);
var visited = new HashSet<string>(StringComparer.Ordinal);
foreach (var mod in discovered.OrderBy(x => x.Info.LoadOrder).ThenBy(x => x.Info.ModId, StringComparer.Ordinal))
Visit(mod, map, visiting, visited, result, allowExistingLoadedDependencies);
return result;
}
private static void Visit(DiscoveredMod current, Dictionary<string, DiscoveredMod> map,
HashSet<string> visiting, HashSet<string> visited, List<DiscoveredMod> result,
bool allowExistingLoadedDependencies)
{
if (visited.Contains(current.Info.ModId))
return;
if (!visiting.Add(current.Info.ModId))
throw new InvalidOperationException($"检测到模组循环依赖,涉及 {current.Info.ModId}");
foreach (var dependency in current.Info.Dependencies.OrderBy(x => x.ModId, StringComparer.Ordinal))
{
if (map.TryGetValue(dependency.ModId, out var target))
{
Visit(target, map, visiting, visited, result, allowExistingLoadedDependencies);
continue;
}
if (allowExistingLoadedDependencies && LoadedMods.ContainsKey(dependency.ModId))
continue;
if (!dependency.Optional)
throw new InvalidOperationException($"模组 {current.Info.ModId} 缺少必选依赖 {dependency.ModId}");
}
visiting.Remove(current.Info.ModId);
visited.Add(current.Info.ModId);
result.Add(current);
}
private static IShrinkMod CreateInstance(DiscoveredMod discovered)
{
try
{
return (IShrinkMod)Activator.CreateInstance(discovered.EntryType);
}
catch (Exception ex)
{
throw new InvalidOperationException($"实例化模组 {discovered.Info.ModId} 失败:{ex.Message}", ex);
}
}
private static void RunLifecycle(IEnumerable<ShrinkModHandle> handles, bool verboseLogging)
{
var stagedHandles = handles.ToList();
RunPhase(stagedHandles, ShrinkModState.Constructed, verboseLogging, (mod, context) => mod.OnConstruct(context), "构造");
RunPhase(stagedHandles, ShrinkModState.ContentRegistered, verboseLogging,
(mod, context) => mod.OnRegisterContent(context), "注册内容");
RegisterOptionalRuntimeIntegrations(stagedHandles, verboseLogging);
RunPhase(stagedHandles, ShrinkModState.Initialized, verboseLogging,
(mod, context) => mod.OnInitialize(context), "初始化");
RunPhase(stagedHandles, ShrinkModState.Ready, verboseLogging, (mod, context) => mod.OnReady(context), "完成就绪");
}
private static void RegisterOptionalRuntimeIntegrations(IEnumerable<ShrinkModHandle> handles, bool verboseLogging)
{
foreach (var handle in handles)
handle.EventBinding = ShrinkModOptionalRuntimeIntegration.RegisterModInstance(
handle.Instance, handle.Info, verboseLogging);
ShrinkModOptionalRuntimeIntegration.RefreshGlobalBindings(verboseLogging);
}
private static void DisposeLegacyEventBindings()
{
foreach (var handle in LoadedMods.Values)
{
handle.EventBinding?.Dispose();
handle.EventBinding = null;
EventBus.RemoveBus(ShrinkBusKey.Mod(handle.Info.ModId));
}
}
private static void RunPhase(IEnumerable<ShrinkModHandle> handles, ShrinkModState targetState, bool verboseLogging,
Action<IShrinkMod, ShrinkModContext> callback, string phaseName)
{
foreach (var handle in handles)
{
var context = new ShrinkModContext(handle.Info, RegistryManager, LoadedMods, verboseLogging);
try
{
callback(handle.Instance, context);
handle.State = targetState;
if (verboseLogging)
Debug.Log($"[ShrinkModLoader] {handle.Info.ModId} 已完成阶段:{phaseName}");
if (targetState == ShrinkModState.Ready)
OnModReady?.Invoke(handle);
}
catch (Exception ex)
{
throw new InvalidOperationException(
$"模组 {handle.Info.ModId} 在阶段 '{phaseName}' 执行失败:{ex.Message}", ex);
}
}
}
private static bool ShouldScanAssembly(Assembly assembly, string[] prefixes)
{
var name = assembly.GetName().Name;
if (string.IsNullOrEmpty(name))
return false;
if (name.StartsWith("Unity", StringComparison.Ordinal) ||
name.StartsWith("System", StringComparison.Ordinal) ||
name.StartsWith("mscorlib", StringComparison.Ordinal) ||
name.StartsWith("netstandard", StringComparison.Ordinal))
return false;
if (prefixes == null || prefixes.Length == 0)
return true;
for (var i = 0; i < prefixes.Length; i++)
{
var prefix = prefixes[i];
if (!string.IsNullOrWhiteSpace(prefix) &&
name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
private static IEnumerable<Type> GetTypesSafely(Assembly assembly)
{
try
{
return assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
return ex.Types.Where(t => t != null);
}
catch
{
return Array.Empty<Type>();
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3f1310a972c773f479f8e35265c63535
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c93eed41ccfddbd48b13b1791393a0c8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+29
View File
@@ -0,0 +1,29 @@
using System;
namespace ShrinkModFramework
{
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class ShrinkModAttribute : Attribute
{
public string ModId { get; }
public string DisplayName { get; }
public string Version { get; }
public int LoadOrder { get; }
public bool AutoApplyHarmonyPatches { get; set; } = true;
public ShrinkModAttribute(string modId, string displayName, string version, int loadOrder = 0)
{
if (string.IsNullOrWhiteSpace(modId))
throw new ArgumentException("模组 ID 不能为空。", nameof(modId));
if (string.IsNullOrWhiteSpace(displayName))
throw new ArgumentException("模组显示名不能为空。", nameof(displayName));
if (string.IsNullOrWhiteSpace(version))
throw new ArgumentException("模组版本不能为空。", nameof(version));
ModId = modId.Trim();
DisplayName = displayName.Trim();
Version = version.Trim();
LoadOrder = loadOrder;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 976e55706c1035f4ba692957cd335f32
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,22 @@
using System;
namespace ShrinkModFramework
{
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
public sealed class ShrinkModDependencyAttribute : Attribute
{
public string ModId { get; }
public string MinimumVersion { get; }
public bool Optional { get; }
public ShrinkModDependencyAttribute(string modId, string minimumVersion = null, bool optional = false)
{
if (string.IsNullOrWhiteSpace(modId))
throw new ArgumentException("依赖模组 ID 不能为空。", nameof(modId));
ModId = modId.Trim();
MinimumVersion = string.IsNullOrWhiteSpace(minimumVersion) ? null : minimumVersion.Trim();
Optional = optional;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d4af615bbf25f3e429a341accf01b17b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+54
View File
@@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
namespace ShrinkModFramework
{
[Serializable]
public sealed class ShrinkModInfo
{
public string ModId { get; }
public string DisplayName { get; }
public string Version { get; }
public int LoadOrder { get; }
public Type EntryType { get; }
public bool AutoApplyHarmonyPatches { get; }
public IReadOnlyList<ShrinkModDependency> Dependencies { get; }
public ShrinkModInfo(string modId, string displayName, string version, int loadOrder, Type entryType,
bool autoApplyHarmonyPatches, IReadOnlyList<ShrinkModDependency> dependencies)
{
ModId = modId;
DisplayName = displayName;
Version = version;
LoadOrder = loadOrder;
EntryType = entryType;
AutoApplyHarmonyPatches = autoApplyHarmonyPatches;
Dependencies = dependencies;
}
public override string ToString() => $"{DisplayName} ({ModId}@{Version})";
}
[Serializable]
public sealed class ShrinkModDependency
{
public string ModId { get; }
public string MinimumVersion { get; }
public bool Optional { get; }
public ShrinkModDependency(string modId, string minimumVersion, bool optional)
{
ModId = modId;
MinimumVersion = minimumVersion;
Optional = optional;
}
public override string ToString()
{
if (string.IsNullOrEmpty(MinimumVersion))
return Optional ? $"{ModId} (optional)" : ModId;
return Optional ? $"{ModId} >= {MinimumVersion} (optional)" : $"{ModId} >= {MinimumVersion}";
}
}
}

Some files were not shown because too many files have changed in this diff Show More