From 78ccae3e07024b4226742a66fce05f6bf1fcf8ba Mon Sep 17 00:00:00 2001 From: cneicy Date: Wed, 26 Aug 2026 02:50:31 +0800 Subject: [PATCH] chore: initialize standalone UPM package --- .gitea/workflows/publish.yml | 49 ++ .gitea/workflows/unity-verify.yml | 39 + .gitignore | 10 + .npmignore | 8 + CHANGELOG.md | 40 + CHANGELOG.md.meta | 7 + Development~/UnityProject/.gitignore | 6 + Development~/UnityProject/Assets/.gitkeep | 0 .../UnityProject/Packages/manifest.json | 15 + .../ProjectSettings/ProjectVersion.txt | 2 + Editor.meta | 8 + Editor/Scaffolding.meta | 8 + .../ExternalModProjectTemplate.meta | 8 + .../ExternalModProjectTemplate/.gitignore.txt | 3 + .../ExternalModProjectTemplate/README.md.txt | 102 +++ .../README.md.txt.meta | 7 + .../__PROJECT_NAME__.csproj.txt | 25 + .../__PROJECT_NAME__.csproj.txt.meta | 7 + .../ExternalModProjectTemplate/build.ps1.txt | 32 + .../build.ps1.txt.meta | 7 + .../dev-env.ps1.txt | 8 + .../dev-env.ps1.txt.meta | 7 + .../global.json.txt | 6 + .../global.json.txt.meta | 7 + .../ExternalModProjectTemplate/src.meta | 8 + .../src/__PROJECT_NAME__Contracts.cs.txt | 25 + .../src/__PROJECT_NAME__Contracts.cs.txt.meta | 7 + .../src/__PROJECT_NAME__Mod.cs.txt | 66 ++ .../src/__PROJECT_NAME__Mod.cs.txt.meta | 7 + Editor/Scaffolding/InRepoModTemplate.meta | 8 + .../InRepoModTemplate/README.md.txt | 39 + .../InRepoModTemplate/README.md.txt.meta | 7 + .../InRepoModTemplate/Runtime.meta | 8 + .../Runtime/__ASMDEF_NAME__.asmdef.txt | 18 + .../Runtime/__ASMDEF_NAME__.asmdef.txt.meta | 7 + .../Runtime/__PROJECT_NAME__Contracts.cs.txt | 25 + .../__PROJECT_NAME__Contracts.cs.txt.meta | 7 + .../Runtime/__PROJECT_NAME__Mod.cs.txt | 66 ++ .../Runtime/__PROJECT_NAME__Mod.cs.txt.meta | 7 + .../ShrinkExternalModTemplateGenerator.cs | 248 ++++++ ...ShrinkExternalModTemplateGenerator.cs.meta | 11 + .../ShrinkInRepoModTemplateGenerator.cs | 203 +++++ .../ShrinkInRepoModTemplateGenerator.cs.meta | 11 + Editor/Scaffolding/ShrinkModSdkExporter.cs | 585 +++++++++++++++ .../Scaffolding/ShrinkModSdkExporter.cs.meta | 11 + .../ShrinkEventBus.Generator.dll.bytes | Bin 0 -> 19968 bytes README.md | 710 ++++++++++++++++++ README.md.meta | 7 + Runtime.meta | 8 + Runtime/AssemblyInfo.cs | 3 + Runtime/AssemblyInfo.cs.meta | 11 + Runtime/Bootstrap.meta | 8 + Runtime/Bootstrap/ShrinkModBootstrap.cs | 32 + Runtime/Bootstrap/ShrinkModBootstrap.cs.meta | 11 + .../Bootstrap/ShrinkModFrameworkSettings.cs | 69 ++ .../ShrinkModFrameworkSettings.cs.meta | 11 + Runtime/Bootstrap/ShrinkModReloadDebouncer.cs | 34 + .../ShrinkModReloadDebouncer.cs.meta | 11 + .../Bootstrap/ShrinkModRuntimeBootstrap.cs | 34 + .../ShrinkModRuntimeBootstrap.cs.meta | 11 + Runtime/Bootstrap/ShrinkModRuntimeDriver.cs | 173 +++++ .../Bootstrap/ShrinkModRuntimeDriver.cs.meta | 11 + Runtime/Context.meta | 8 + Runtime/Context/ShrinkModComponent.cs | 91 +++ Runtime/Context/ShrinkModComponent.cs.meta | 11 + .../Context/ShrinkModComponentDiscovery.cs | 122 +++ .../ShrinkModComponentDiscovery.cs.meta | 11 + Runtime/Context/ShrinkModComponentSource.cs | 27 + .../Context/ShrinkModComponentSource.cs.meta | 11 + Runtime/Context/ShrinkModContextHost.cs | 262 +++++++ Runtime/Context/ShrinkModContextHost.cs.meta | 11 + Runtime/Context/ShrinkModCordisRuntime.cs | 68 ++ .../Context/ShrinkModCordisRuntime.cs.meta | 11 + Runtime/Core.meta | 8 + Runtime/Core/IShrinkMod.cs | 10 + Runtime/Core/IShrinkMod.cs.meta | 11 + Runtime/Core/ShrinkModBase.cs | 10 + Runtime/Core/ShrinkModBase.cs.meta | 11 + Runtime/Core/ShrinkModContext.cs | 98 +++ Runtime/Core/ShrinkModContext.cs.meta | 11 + Runtime/Core/ShrinkModHandle.cs | 20 + Runtime/Core/ShrinkModHandle.cs.meta | 11 + Runtime/Integration.meta | 8 + .../ShrinkModOptionalRuntimeIntegration.cs | 144 ++++ ...hrinkModOptionalRuntimeIntegration.cs.meta | 11 + Runtime/Loading.meta | 8 + .../ShrinkExternalModAssemblyLoader.cs | 281 +++++++ .../ShrinkExternalModAssemblyLoader.cs.meta | 11 + Runtime/Loading/ShrinkHarmonyPatchService.cs | 131 ++++ .../Loading/ShrinkHarmonyPatchService.cs.meta | 11 + Runtime/Loading/ShrinkModDiagnostics.cs | 57 ++ Runtime/Loading/ShrinkModDiagnostics.cs.meta | 11 + Runtime/Loading/ShrinkModLoader.cs | 476 ++++++++++++ Runtime/Loading/ShrinkModLoader.cs.meta | 11 + Runtime/Metadata.meta | 8 + Runtime/Metadata/ShrinkModAttribute.cs | 29 + Runtime/Metadata/ShrinkModAttribute.cs.meta | 11 + .../Metadata/ShrinkModDependencyAttribute.cs | 22 + .../ShrinkModDependencyAttribute.cs.meta | 11 + Runtime/Metadata/ShrinkModInfo.cs | 54 ++ Runtime/Metadata/ShrinkModInfo.cs.meta | 11 + Runtime/Metadata/ShrinkModState.cs | 12 + Runtime/Metadata/ShrinkModState.cs.meta | 11 + Runtime/Metadata/ShrinkVersionUtility.cs | 45 ++ Runtime/Metadata/ShrinkVersionUtility.cs.meta | 11 + Runtime/Network.meta | 8 + Runtime/Network/IShrinkModNetworkTransport.cs | 14 + .../IShrinkModNetworkTransport.cs.meta | 11 + Runtime/Network/ShrinkModNetworkManager.cs | 170 +++++ .../Network/ShrinkModNetworkManager.cs.meta | 11 + Runtime/Network/ShrinkModNetworkTypes.cs | 41 + Runtime/Network/ShrinkModNetworkTypes.cs.meta | 11 + Runtime/Registry.meta | 8 + Runtime/Registry/ShrinkModRegistry.cs | 223 ++++++ Runtime/Registry/ShrinkModRegistry.cs.meta | 11 + Runtime/Registry/ShrinkModRegistryManager.cs | 56 ++ .../Registry/ShrinkModRegistryManager.cs.meta | 11 + Runtime/ShrinkModFramework.Runtime.asmdef | 18 + .../ShrinkModFramework.Runtime.asmdef.meta | 7 + Tests.meta | 8 + Tests/Fixtures.meta | 8 + .../ExternalFixture.Consumer.dll.bytes | Bin 0 -> 4096 bytes .../ExternalFixture.Consumer.dll.bytes.meta | 7 + .../Fixtures/ExternalFixture.Mod.V1.dll.bytes | Bin 0 -> 5120 bytes .../ExternalFixture.Mod.V1.dll.bytes.meta | 7 + .../ExternalFixture.Mod.V2.Failing.dll.bytes | Bin 0 -> 5120 bytes ...ernalFixture.Mod.V2.Failing.dll.bytes.meta | 7 + .../Fixtures/ExternalFixture.Mod.V3.dll.bytes | Bin 0 -> 5120 bytes .../ExternalFixture.Mod.V3.dll.bytes.meta | 7 + .../ExternalFixture.Provider.dll.bytes | Bin 0 -> 4096 bytes .../ExternalFixture.Provider.dll.bytes.meta | 7 + Tests/ShrinkModContextHostTests.cs | 571 ++++++++++++++ Tests/ShrinkModContextHostTests.cs.meta | 11 + Tests/ShrinkModFramework.Tests.asmdef | 23 + Tests/ShrinkModFramework.Tests.asmdef.meta | 7 + .../FixtureBuilder/ExternalFixture.Mod.V1.cs | 25 + .../FixtureBuilder/ExternalFixture.Mod.V2.cs | 27 + .../FixtureBuilder/ExternalFixture.Mod.V3.cs | 25 + .../Rebuild-ShrinkModFixtures.ps1 | 44 ++ .../ShrinkModFixtureBuilder.csproj | 18 + package.json | 26 + package.json.meta | 7 + 142 files changed, 6572 insertions(+) create mode 100644 .gitea/workflows/publish.yml create mode 100644 .gitea/workflows/unity-verify.yml create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 CHANGELOG.md create mode 100644 CHANGELOG.md.meta create mode 100644 Development~/UnityProject/.gitignore create mode 100644 Development~/UnityProject/Assets/.gitkeep create mode 100644 Development~/UnityProject/Packages/manifest.json create mode 100644 Development~/UnityProject/ProjectSettings/ProjectVersion.txt create mode 100644 Editor.meta create mode 100644 Editor/Scaffolding.meta create mode 100644 Editor/Scaffolding/ExternalModProjectTemplate.meta create mode 100644 Editor/Scaffolding/ExternalModProjectTemplate/.gitignore.txt create mode 100644 Editor/Scaffolding/ExternalModProjectTemplate/README.md.txt create mode 100644 Editor/Scaffolding/ExternalModProjectTemplate/README.md.txt.meta create mode 100644 Editor/Scaffolding/ExternalModProjectTemplate/__PROJECT_NAME__.csproj.txt create mode 100644 Editor/Scaffolding/ExternalModProjectTemplate/__PROJECT_NAME__.csproj.txt.meta create mode 100644 Editor/Scaffolding/ExternalModProjectTemplate/build.ps1.txt create mode 100644 Editor/Scaffolding/ExternalModProjectTemplate/build.ps1.txt.meta create mode 100644 Editor/Scaffolding/ExternalModProjectTemplate/dev-env.ps1.txt create mode 100644 Editor/Scaffolding/ExternalModProjectTemplate/dev-env.ps1.txt.meta create mode 100644 Editor/Scaffolding/ExternalModProjectTemplate/global.json.txt create mode 100644 Editor/Scaffolding/ExternalModProjectTemplate/global.json.txt.meta create mode 100644 Editor/Scaffolding/ExternalModProjectTemplate/src.meta create mode 100644 Editor/Scaffolding/ExternalModProjectTemplate/src/__PROJECT_NAME__Contracts.cs.txt create mode 100644 Editor/Scaffolding/ExternalModProjectTemplate/src/__PROJECT_NAME__Contracts.cs.txt.meta create mode 100644 Editor/Scaffolding/ExternalModProjectTemplate/src/__PROJECT_NAME__Mod.cs.txt create mode 100644 Editor/Scaffolding/ExternalModProjectTemplate/src/__PROJECT_NAME__Mod.cs.txt.meta create mode 100644 Editor/Scaffolding/InRepoModTemplate.meta create mode 100644 Editor/Scaffolding/InRepoModTemplate/README.md.txt create mode 100644 Editor/Scaffolding/InRepoModTemplate/README.md.txt.meta create mode 100644 Editor/Scaffolding/InRepoModTemplate/Runtime.meta create mode 100644 Editor/Scaffolding/InRepoModTemplate/Runtime/__ASMDEF_NAME__.asmdef.txt create mode 100644 Editor/Scaffolding/InRepoModTemplate/Runtime/__ASMDEF_NAME__.asmdef.txt.meta create mode 100644 Editor/Scaffolding/InRepoModTemplate/Runtime/__PROJECT_NAME__Contracts.cs.txt create mode 100644 Editor/Scaffolding/InRepoModTemplate/Runtime/__PROJECT_NAME__Contracts.cs.txt.meta create mode 100644 Editor/Scaffolding/InRepoModTemplate/Runtime/__PROJECT_NAME__Mod.cs.txt create mode 100644 Editor/Scaffolding/InRepoModTemplate/Runtime/__PROJECT_NAME__Mod.cs.txt.meta create mode 100644 Editor/Scaffolding/ShrinkExternalModTemplateGenerator.cs create mode 100644 Editor/Scaffolding/ShrinkExternalModTemplateGenerator.cs.meta create mode 100644 Editor/Scaffolding/ShrinkInRepoModTemplateGenerator.cs create mode 100644 Editor/Scaffolding/ShrinkInRepoModTemplateGenerator.cs.meta create mode 100644 Editor/Scaffolding/ShrinkModSdkExporter.cs create mode 100644 Editor/Scaffolding/ShrinkModSdkExporter.cs.meta create mode 100644 Editor/Scaffolding/Support/ShrinkEventBus.Generator.dll.bytes create mode 100644 README.md create mode 100644 README.md.meta create mode 100644 Runtime.meta create mode 100644 Runtime/AssemblyInfo.cs create mode 100644 Runtime/AssemblyInfo.cs.meta create mode 100644 Runtime/Bootstrap.meta create mode 100644 Runtime/Bootstrap/ShrinkModBootstrap.cs create mode 100644 Runtime/Bootstrap/ShrinkModBootstrap.cs.meta create mode 100644 Runtime/Bootstrap/ShrinkModFrameworkSettings.cs create mode 100644 Runtime/Bootstrap/ShrinkModFrameworkSettings.cs.meta create mode 100644 Runtime/Bootstrap/ShrinkModReloadDebouncer.cs create mode 100644 Runtime/Bootstrap/ShrinkModReloadDebouncer.cs.meta create mode 100644 Runtime/Bootstrap/ShrinkModRuntimeBootstrap.cs create mode 100644 Runtime/Bootstrap/ShrinkModRuntimeBootstrap.cs.meta create mode 100644 Runtime/Bootstrap/ShrinkModRuntimeDriver.cs create mode 100644 Runtime/Bootstrap/ShrinkModRuntimeDriver.cs.meta create mode 100644 Runtime/Context.meta create mode 100644 Runtime/Context/ShrinkModComponent.cs create mode 100644 Runtime/Context/ShrinkModComponent.cs.meta create mode 100644 Runtime/Context/ShrinkModComponentDiscovery.cs create mode 100644 Runtime/Context/ShrinkModComponentDiscovery.cs.meta create mode 100644 Runtime/Context/ShrinkModComponentSource.cs create mode 100644 Runtime/Context/ShrinkModComponentSource.cs.meta create mode 100644 Runtime/Context/ShrinkModContextHost.cs create mode 100644 Runtime/Context/ShrinkModContextHost.cs.meta create mode 100644 Runtime/Context/ShrinkModCordisRuntime.cs create mode 100644 Runtime/Context/ShrinkModCordisRuntime.cs.meta create mode 100644 Runtime/Core.meta create mode 100644 Runtime/Core/IShrinkMod.cs create mode 100644 Runtime/Core/IShrinkMod.cs.meta create mode 100644 Runtime/Core/ShrinkModBase.cs create mode 100644 Runtime/Core/ShrinkModBase.cs.meta create mode 100644 Runtime/Core/ShrinkModContext.cs create mode 100644 Runtime/Core/ShrinkModContext.cs.meta create mode 100644 Runtime/Core/ShrinkModHandle.cs create mode 100644 Runtime/Core/ShrinkModHandle.cs.meta create mode 100644 Runtime/Integration.meta create mode 100644 Runtime/Integration/ShrinkModOptionalRuntimeIntegration.cs create mode 100644 Runtime/Integration/ShrinkModOptionalRuntimeIntegration.cs.meta create mode 100644 Runtime/Loading.meta create mode 100644 Runtime/Loading/ShrinkExternalModAssemblyLoader.cs create mode 100644 Runtime/Loading/ShrinkExternalModAssemblyLoader.cs.meta create mode 100644 Runtime/Loading/ShrinkHarmonyPatchService.cs create mode 100644 Runtime/Loading/ShrinkHarmonyPatchService.cs.meta create mode 100644 Runtime/Loading/ShrinkModDiagnostics.cs create mode 100644 Runtime/Loading/ShrinkModDiagnostics.cs.meta create mode 100644 Runtime/Loading/ShrinkModLoader.cs create mode 100644 Runtime/Loading/ShrinkModLoader.cs.meta create mode 100644 Runtime/Metadata.meta create mode 100644 Runtime/Metadata/ShrinkModAttribute.cs create mode 100644 Runtime/Metadata/ShrinkModAttribute.cs.meta create mode 100644 Runtime/Metadata/ShrinkModDependencyAttribute.cs create mode 100644 Runtime/Metadata/ShrinkModDependencyAttribute.cs.meta create mode 100644 Runtime/Metadata/ShrinkModInfo.cs create mode 100644 Runtime/Metadata/ShrinkModInfo.cs.meta create mode 100644 Runtime/Metadata/ShrinkModState.cs create mode 100644 Runtime/Metadata/ShrinkModState.cs.meta create mode 100644 Runtime/Metadata/ShrinkVersionUtility.cs create mode 100644 Runtime/Metadata/ShrinkVersionUtility.cs.meta create mode 100644 Runtime/Network.meta create mode 100644 Runtime/Network/IShrinkModNetworkTransport.cs create mode 100644 Runtime/Network/IShrinkModNetworkTransport.cs.meta create mode 100644 Runtime/Network/ShrinkModNetworkManager.cs create mode 100644 Runtime/Network/ShrinkModNetworkManager.cs.meta create mode 100644 Runtime/Network/ShrinkModNetworkTypes.cs create mode 100644 Runtime/Network/ShrinkModNetworkTypes.cs.meta create mode 100644 Runtime/Registry.meta create mode 100644 Runtime/Registry/ShrinkModRegistry.cs create mode 100644 Runtime/Registry/ShrinkModRegistry.cs.meta create mode 100644 Runtime/Registry/ShrinkModRegistryManager.cs create mode 100644 Runtime/Registry/ShrinkModRegistryManager.cs.meta create mode 100644 Runtime/ShrinkModFramework.Runtime.asmdef create mode 100644 Runtime/ShrinkModFramework.Runtime.asmdef.meta create mode 100644 Tests.meta create mode 100644 Tests/Fixtures.meta create mode 100644 Tests/Fixtures/ExternalFixture.Consumer.dll.bytes create mode 100644 Tests/Fixtures/ExternalFixture.Consumer.dll.bytes.meta create mode 100644 Tests/Fixtures/ExternalFixture.Mod.V1.dll.bytes create mode 100644 Tests/Fixtures/ExternalFixture.Mod.V1.dll.bytes.meta create mode 100644 Tests/Fixtures/ExternalFixture.Mod.V2.Failing.dll.bytes create mode 100644 Tests/Fixtures/ExternalFixture.Mod.V2.Failing.dll.bytes.meta create mode 100644 Tests/Fixtures/ExternalFixture.Mod.V3.dll.bytes create mode 100644 Tests/Fixtures/ExternalFixture.Mod.V3.dll.bytes.meta create mode 100644 Tests/Fixtures/ExternalFixture.Provider.dll.bytes create mode 100644 Tests/Fixtures/ExternalFixture.Provider.dll.bytes.meta create mode 100644 Tests/ShrinkModContextHostTests.cs create mode 100644 Tests/ShrinkModContextHostTests.cs.meta create mode 100644 Tests/ShrinkModFramework.Tests.asmdef create mode 100644 Tests/ShrinkModFramework.Tests.asmdef.meta create mode 100644 Tools~/FixtureBuilder/ExternalFixture.Mod.V1.cs create mode 100644 Tools~/FixtureBuilder/ExternalFixture.Mod.V2.cs create mode 100644 Tools~/FixtureBuilder/ExternalFixture.Mod.V3.cs create mode 100644 Tools~/FixtureBuilder/Rebuild-ShrinkModFixtures.ps1 create mode 100644 Tools~/FixtureBuilder/ShrinkModFixtureBuilder.csproj create mode 100644 package.json create mode 100644 package.json.meta diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml new file mode 100644 index 0000000..57cde74 --- /dev/null +++ b/.gitea/workflows/publish.yml @@ -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/ \ No newline at end of file diff --git a/.gitea/workflows/unity-verify.yml b/.gitea/workflows/unity-verify.yml new file mode 100644 index 0000000..e16153c --- /dev/null +++ b/.gitea/workflows/unity-verify.yml @@ -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" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..246935d --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..700f03d --- /dev/null +++ b/.npmignore @@ -0,0 +1,8 @@ +.git/ +.gitea/ +Development~/ +Tools~/ +*.csproj +*.sln +*.user +*.DotSettings.user diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fc9dc98 --- /dev/null +++ b/CHANGELOG.md @@ -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`。 + +### 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 导出方式与推荐工作流。 diff --git a/CHANGELOG.md.meta b/CHANGELOG.md.meta new file mode 100644 index 0000000..51ca634 --- /dev/null +++ b/CHANGELOG.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 47a420daff9e1b7468f48a442ad7290e +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Development~/UnityProject/.gitignore b/Development~/UnityProject/.gitignore new file mode 100644 index 0000000..aa2eb00 --- /dev/null +++ b/Development~/UnityProject/.gitignore @@ -0,0 +1,6 @@ +[Ll]ibrary/ +[Tt]emp/ +[Oo]bj/ +[Ll]ogs/ +[Uu]ser[Ss]ettings/ +TestResults/ \ No newline at end of file diff --git a/Development~/UnityProject/Assets/.gitkeep b/Development~/UnityProject/Assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Development~/UnityProject/Packages/manifest.json b/Development~/UnityProject/Packages/manifest.json new file mode 100644 index 0000000..01f6523 --- /dev/null +++ b/Development~/UnityProject/Packages/manifest.json @@ -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:../../.." + } +} diff --git a/Development~/UnityProject/ProjectSettings/ProjectVersion.txt b/Development~/UnityProject/ProjectSettings/ProjectVersion.txt new file mode 100644 index 0000000..587f809 --- /dev/null +++ b/Development~/UnityProject/ProjectSettings/ProjectVersion.txt @@ -0,0 +1,2 @@ +m_EditorVersion: 2022.3.62f3 +m_EditorVersionWithRevision: 2022.3.62f3 (96770f904ca7) diff --git a/Editor.meta b/Editor.meta new file mode 100644 index 0000000..c5645e2 --- /dev/null +++ b/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2a4090dedcf4f964195877944df52d40 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding.meta b/Editor/Scaffolding.meta new file mode 100644 index 0000000..ce9725c --- /dev/null +++ b/Editor/Scaffolding.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a4c5850a094f141429521fdc9e82295b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ExternalModProjectTemplate.meta b/Editor/Scaffolding/ExternalModProjectTemplate.meta new file mode 100644 index 0000000..00ff414 --- /dev/null +++ b/Editor/Scaffolding/ExternalModProjectTemplate.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2b6d34b770edc7b4bb76e93b13ffd2ab +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ExternalModProjectTemplate/.gitignore.txt b/Editor/Scaffolding/ExternalModProjectTemplate/.gitignore.txt new file mode 100644 index 0000000..4af5308 --- /dev/null +++ b/Editor/Scaffolding/ExternalModProjectTemplate/.gitignore.txt @@ -0,0 +1,3 @@ +bin/ +obj/ +artifacts/ diff --git a/Editor/Scaffolding/ExternalModProjectTemplate/README.md.txt b/Editor/Scaffolding/ExternalModProjectTemplate/README.md.txt new file mode 100644 index 0000000..e91f2d5 --- /dev/null +++ b/Editor/Scaffolding/ExternalModProjectTemplate/README.md.txt @@ -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` 或系统环境变量在**打开项目之前**先设置 diff --git a/Editor/Scaffolding/ExternalModProjectTemplate/README.md.txt.meta b/Editor/Scaffolding/ExternalModProjectTemplate/README.md.txt.meta new file mode 100644 index 0000000..0001ced --- /dev/null +++ b/Editor/Scaffolding/ExternalModProjectTemplate/README.md.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: dfa94a3d7a41c6b4e81e7675db94e55f +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ExternalModProjectTemplate/__PROJECT_NAME__.csproj.txt b/Editor/Scaffolding/ExternalModProjectTemplate/__PROJECT_NAME__.csproj.txt new file mode 100644 index 0000000..197fa9b --- /dev/null +++ b/Editor/Scaffolding/ExternalModProjectTemplate/__PROJECT_NAME__.csproj.txt @@ -0,0 +1,25 @@ + + + net471 + 9.0 + enable + disable + false + __ROOT_NAMESPACE__ + __PROJECT_NAME__ + false + true + artifacts\$(Configuration)\ + false + + + + + + + + + + diff --git a/Editor/Scaffolding/ExternalModProjectTemplate/__PROJECT_NAME__.csproj.txt.meta b/Editor/Scaffolding/ExternalModProjectTemplate/__PROJECT_NAME__.csproj.txt.meta new file mode 100644 index 0000000..206918b --- /dev/null +++ b/Editor/Scaffolding/ExternalModProjectTemplate/__PROJECT_NAME__.csproj.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 770057fe3b5782f4ba28e2e36f087b96 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ExternalModProjectTemplate/build.ps1.txt b/Editor/Scaffolding/ExternalModProjectTemplate/build.ps1.txt new file mode 100644 index 0000000..75e25ae --- /dev/null +++ b/Editor/Scaffolding/ExternalModProjectTemplate/build.ps1.txt @@ -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" diff --git a/Editor/Scaffolding/ExternalModProjectTemplate/build.ps1.txt.meta b/Editor/Scaffolding/ExternalModProjectTemplate/build.ps1.txt.meta new file mode 100644 index 0000000..f482fe7 --- /dev/null +++ b/Editor/Scaffolding/ExternalModProjectTemplate/build.ps1.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 956930660ab00e74692b22857f3159b7 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ExternalModProjectTemplate/dev-env.ps1.txt b/Editor/Scaffolding/ExternalModProjectTemplate/dev-env.ps1.txt new file mode 100644 index 0000000..6b0f9d6 --- /dev/null +++ b/Editor/Scaffolding/ExternalModProjectTemplate/dev-env.ps1.txt @@ -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" diff --git a/Editor/Scaffolding/ExternalModProjectTemplate/dev-env.ps1.txt.meta b/Editor/Scaffolding/ExternalModProjectTemplate/dev-env.ps1.txt.meta new file mode 100644 index 0000000..011b49c --- /dev/null +++ b/Editor/Scaffolding/ExternalModProjectTemplate/dev-env.ps1.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 56c53370b8dbbab4ea70d4566bc1fed8 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ExternalModProjectTemplate/global.json.txt b/Editor/Scaffolding/ExternalModProjectTemplate/global.json.txt new file mode 100644 index 0000000..930b4cc --- /dev/null +++ b/Editor/Scaffolding/ExternalModProjectTemplate/global.json.txt @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "__DOTNET_SDK_VERSION__", + "rollForward": "latestPatch" + } +} diff --git a/Editor/Scaffolding/ExternalModProjectTemplate/global.json.txt.meta b/Editor/Scaffolding/ExternalModProjectTemplate/global.json.txt.meta new file mode 100644 index 0000000..e84cf4c --- /dev/null +++ b/Editor/Scaffolding/ExternalModProjectTemplate/global.json.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 48ebb276437630c4495d82c4c8e4ae2e +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ExternalModProjectTemplate/src.meta b/Editor/Scaffolding/ExternalModProjectTemplate/src.meta new file mode 100644 index 0000000..380b3d9 --- /dev/null +++ b/Editor/Scaffolding/ExternalModProjectTemplate/src.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c8713158fd023ce48abfb7365184ac7c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ExternalModProjectTemplate/src/__PROJECT_NAME__Contracts.cs.txt b/Editor/Scaffolding/ExternalModProjectTemplate/src/__PROJECT_NAME__Contracts.cs.txt new file mode 100644 index 0000000..4c4a9ac --- /dev/null +++ b/Editor/Scaffolding/ExternalModProjectTemplate/src/__PROJECT_NAME__Contracts.cs.txt @@ -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; } + } +} diff --git a/Editor/Scaffolding/ExternalModProjectTemplate/src/__PROJECT_NAME__Contracts.cs.txt.meta b/Editor/Scaffolding/ExternalModProjectTemplate/src/__PROJECT_NAME__Contracts.cs.txt.meta new file mode 100644 index 0000000..2a30701 --- /dev/null +++ b/Editor/Scaffolding/ExternalModProjectTemplate/src/__PROJECT_NAME__Contracts.cs.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f4ff81758d9539947ba6f4df94c2db91 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ExternalModProjectTemplate/src/__PROJECT_NAME__Mod.cs.txt b/Editor/Scaffolding/ExternalModProjectTemplate/src/__PROJECT_NAME__Mod.cs.txt new file mode 100644 index 0000000..49d5991 --- /dev/null +++ b/Editor/Scaffolding/ExternalModProjectTemplate/src/__PROJECT_NAME__Mod.cs.txt @@ -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("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; + } + } +} diff --git a/Editor/Scaffolding/ExternalModProjectTemplate/src/__PROJECT_NAME__Mod.cs.txt.meta b/Editor/Scaffolding/ExternalModProjectTemplate/src/__PROJECT_NAME__Mod.cs.txt.meta new file mode 100644 index 0000000..31b39dc --- /dev/null +++ b/Editor/Scaffolding/ExternalModProjectTemplate/src/__PROJECT_NAME__Mod.cs.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a2a0ac418f07ef74393b89e9612e3f5f +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/InRepoModTemplate.meta b/Editor/Scaffolding/InRepoModTemplate.meta new file mode 100644 index 0000000..009f48e --- /dev/null +++ b/Editor/Scaffolding/InRepoModTemplate.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8b337545ac56d6f46a969397d332e35c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/InRepoModTemplate/README.md.txt b/Editor/Scaffolding/InRepoModTemplate/README.md.txt new file mode 100644 index 0000000..d07deaa --- /dev/null +++ b/Editor/Scaffolding/InRepoModTemplate/README.md.txt @@ -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__` diff --git a/Editor/Scaffolding/InRepoModTemplate/README.md.txt.meta b/Editor/Scaffolding/InRepoModTemplate/README.md.txt.meta new file mode 100644 index 0000000..a4aa1f5 --- /dev/null +++ b/Editor/Scaffolding/InRepoModTemplate/README.md.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8aa46c7dafda5194b94ab88513c56b44 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/InRepoModTemplate/Runtime.meta b/Editor/Scaffolding/InRepoModTemplate/Runtime.meta new file mode 100644 index 0000000..9b3f890 --- /dev/null +++ b/Editor/Scaffolding/InRepoModTemplate/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f7a48cec05ee9c842ad9bc0b7e7ec425 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/InRepoModTemplate/Runtime/__ASMDEF_NAME__.asmdef.txt b/Editor/Scaffolding/InRepoModTemplate/Runtime/__ASMDEF_NAME__.asmdef.txt new file mode 100644 index 0000000..65104c4 --- /dev/null +++ b/Editor/Scaffolding/InRepoModTemplate/Runtime/__ASMDEF_NAME__.asmdef.txt @@ -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 +} diff --git a/Editor/Scaffolding/InRepoModTemplate/Runtime/__ASMDEF_NAME__.asmdef.txt.meta b/Editor/Scaffolding/InRepoModTemplate/Runtime/__ASMDEF_NAME__.asmdef.txt.meta new file mode 100644 index 0000000..3c7199c --- /dev/null +++ b/Editor/Scaffolding/InRepoModTemplate/Runtime/__ASMDEF_NAME__.asmdef.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9358a269a6e4a9740a69798dba1c1655 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/InRepoModTemplate/Runtime/__PROJECT_NAME__Contracts.cs.txt b/Editor/Scaffolding/InRepoModTemplate/Runtime/__PROJECT_NAME__Contracts.cs.txt new file mode 100644 index 0000000..d8b0b45 --- /dev/null +++ b/Editor/Scaffolding/InRepoModTemplate/Runtime/__PROJECT_NAME__Contracts.cs.txt @@ -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; } + } +} diff --git a/Editor/Scaffolding/InRepoModTemplate/Runtime/__PROJECT_NAME__Contracts.cs.txt.meta b/Editor/Scaffolding/InRepoModTemplate/Runtime/__PROJECT_NAME__Contracts.cs.txt.meta new file mode 100644 index 0000000..6af0926 --- /dev/null +++ b/Editor/Scaffolding/InRepoModTemplate/Runtime/__PROJECT_NAME__Contracts.cs.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2dbfd0d836e24d84eabaff73259c7e90 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/InRepoModTemplate/Runtime/__PROJECT_NAME__Mod.cs.txt b/Editor/Scaffolding/InRepoModTemplate/Runtime/__PROJECT_NAME__Mod.cs.txt new file mode 100644 index 0000000..49d5991 --- /dev/null +++ b/Editor/Scaffolding/InRepoModTemplate/Runtime/__PROJECT_NAME__Mod.cs.txt @@ -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("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; + } + } +} diff --git a/Editor/Scaffolding/InRepoModTemplate/Runtime/__PROJECT_NAME__Mod.cs.txt.meta b/Editor/Scaffolding/InRepoModTemplate/Runtime/__PROJECT_NAME__Mod.cs.txt.meta new file mode 100644 index 0000000..9deaadb --- /dev/null +++ b/Editor/Scaffolding/InRepoModTemplate/Runtime/__PROJECT_NAME__Mod.cs.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5e1319b1cd7dc0d4b918d12152b358ab +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ShrinkExternalModTemplateGenerator.cs b/Editor/Scaffolding/ShrinkExternalModTemplateGenerator.cs new file mode 100644 index 0000000..a13e567 --- /dev/null +++ b/Editor/Scaffolding/ShrinkExternalModTemplateGenerator.cs @@ -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 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(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 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(); + 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 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 diff --git a/Editor/Scaffolding/ShrinkExternalModTemplateGenerator.cs.meta b/Editor/Scaffolding/ShrinkExternalModTemplateGenerator.cs.meta new file mode 100644 index 0000000..12e13c4 --- /dev/null +++ b/Editor/Scaffolding/ShrinkExternalModTemplateGenerator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 92c78e493d52a144389ddc6073111b0e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ShrinkInRepoModTemplateGenerator.cs b/Editor/Scaffolding/ShrinkInRepoModTemplateGenerator.cs new file mode 100644 index 0000000..ce7f6f4 --- /dev/null +++ b/Editor/Scaffolding/ShrinkInRepoModTemplateGenerator.cs @@ -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 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(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 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(); + 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 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 diff --git a/Editor/Scaffolding/ShrinkInRepoModTemplateGenerator.cs.meta b/Editor/Scaffolding/ShrinkInRepoModTemplateGenerator.cs.meta new file mode 100644 index 0000000..4afc9df --- /dev/null +++ b/Editor/Scaffolding/ShrinkInRepoModTemplateGenerator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b6cd6b51094b7e442bf0e5c09fcd42d3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ShrinkModSdkExporter.cs b/Editor/Scaffolding/ShrinkModSdkExporter.cs new file mode 100644 index 0000000..01e2035 --- /dev/null +++ b/Editor/Scaffolding/ShrinkModSdkExporter.cs @@ -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 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(StringComparer.OrdinalIgnoreCase); + var exported = new List(); + 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 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 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(""); + builder.AppendLine(" "); + builder.AppendLine(" net471"); + builder.AppendLine(" 9.0"); + builder.AppendLine(" enable"); + builder.AppendLine(" disable"); + builder.AppendLine(" false"); + builder.AppendLine(" SampleShrinkMod"); + builder.AppendLine(" SampleShrinkMod"); + builder.AppendLine(" false"); + builder.AppendLine(" true"); + builder.AppendLine(" artifacts\\$(Configuration)\\"); + builder.AppendLine(" false"); + builder.AppendLine(" "); + builder.AppendLine(); + builder.AppendLine(" "); + foreach (var reference in references) + { + builder.AppendLine($" "); + builder.AppendLine($" ..\\..\\..\\Libs\\{reference}.dll"); + builder.AppendLine(" true"); + builder.AppendLine(" "); + } + + builder.AppendLine(" "); + builder.AppendLine(); + builder.AppendLine(" "); + builder.AppendLine(" "); + builder.AppendLine(" "); + builder.AppendLine(""); + 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(""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 diff --git a/Editor/Scaffolding/ShrinkModSdkExporter.cs.meta b/Editor/Scaffolding/ShrinkModSdkExporter.cs.meta new file mode 100644 index 0000000..43ce8b9 --- /dev/null +++ b/Editor/Scaffolding/ShrinkModSdkExporter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ebad25910ea711d41b17c9dd2bbc6032 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/Support/ShrinkEventBus.Generator.dll.bytes b/Editor/Scaffolding/Support/ShrinkEventBus.Generator.dll.bytes new file mode 100644 index 0000000000000000000000000000000000000000..c5584c12bde4f9f5a76ce99e963e980d9df2a56d GIT binary patch literal 19968 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 literal 0 HcmV?d00001 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b584f87 --- /dev/null +++ b/README.md @@ -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/` + +默认就是: + +`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.")` +- `PatchAll(modAssembly)` + +这意味着: + +- 模组可以在自己的程序集里直接写 Harmony Patch 类 +- 框架负责发现模组后自动补丁,并在 ContextHost 路径登记逆操作 +- 如果没有 Harmony,框架只会跳过,不会强依赖崩溃 + +注意: + +- 当前是“可选桥接”,不是把 Harmony 打进框架里 +- 需要你自己把 `0Harmony.dll` 放进项目环境或外部模组依赖中 + +## 注册表 + +### 注册内容 + +```csharp +var key = context.RegisterContent("items", "sword", "Sword"); +// key == "demo.core:sword" +``` + +基础注册不接受任意完整键。框架始终使用当前 `ModId` 生成 `:`,避免模组误写其它 owner 的 namespace。 + +### 覆盖已有内容 + +```csharp +context.OverrideContent( + "items", + "core:iron_sword", + priority: 100, + value: "Overridden Sword"); +``` + +### 读取内容 + +```csharp +var itemRegistry = context.GetRegistry("items"); +if (itemRegistry.TryGet("demo.core:sword", out var itemName)) +{ + context.Log($"找到内容:{itemName}"); +} +``` + +规则: + +- 基础项的完整键固定为 `:` +- `context.GetRegistry()` 只返回 `IReadOnlyShrinkModRegistry`;写入只能经过 `RegisterContent` / `OverrideContent` +- 覆盖目标必须已经存在 +- 优先级越高越先命中;同目标、同优先级直接报冲突 +- 每条基础项和覆盖项都记录 owner;owner 卸载时自动撤回,并恢复下一个覆盖或基础值 +- `Entries` 返回当前生效项,`BaseEntries` 返回未应用覆盖的基础项 +- 注册表按类型和名称双重隔离 + +## 网络同步 + +框架内置的是**网络同步抽象层**,不是具体联网库。 + +核心接口: + +- `IShrinkModNetworkTransport` +- `ShrinkModNetworkManager` + +这意味着你可以把它接到: + +- NGO +- Mirror +- FishNet +- 自己的 Socket/Relay 层 + +### 注册消息处理器 + +```csharp +context.RegisterNetworkHandler("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 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("sync.level", (messageContext, level) => + { + _saveData.Level = level; + context.Log($"同步等级:{level}"); + }); + } + + [ShrinkSubscribe] + private void OnSaveCompleted(ShrinkDataSaver.Integration.SaveCompletedEvent evt) + { + // 这里只是示意:模组类实例会被框架自动接到 EventBus + } + + [ShrinkCommand("demo set-level ", Permission = "demo.admin")] + private string SetLevel(int value) + { + _saveData.Level = value; + return $"level={value}"; + } + + [ShrinkNetworkSubscribe] + private UniTask HandlePing(DemoPingRequest request) + { + return UniTask.FromResult(new DemoPingResponse + { + Message = "pong:" + request.Message + }); + } +} +``` + +### 各模块怎么用 + +#### 1. `ShrinkEventBus` + +适合: + +- 模组内部系统解耦 +- 监听 `ShrinkDataSaver.Integration.EventBus` +- 配合 `ShrinkNetwork.Integration.EventBus` 做事件即网络消息 + +推荐: + +- 模组类本身带 `[ShrinkEventSubscriber]` 并声明 `DefaultBus = "mod:"` +- 实例方法上写 `[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(...)` + +当前边界: + +- `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 靠,下一步最值得做的是: + +- 模组配置系统 +- 模组资源系统 +- 模组命令系统 +- 模组专属存档与同步策略 diff --git a/README.md.meta b/README.md.meta new file mode 100644 index 0000000..db616f8 --- /dev/null +++ b/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 647c1debb678f9543bb6da0f94a51e90 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime.meta b/Runtime.meta new file mode 100644 index 0000000..793bf70 --- /dev/null +++ b/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ddc14a844da2693418fe69ac4b2c0836 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/AssemblyInfo.cs b/Runtime/AssemblyInfo.cs new file mode 100644 index 0000000..fd912bf --- /dev/null +++ b/Runtime/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ShrinkModFramework.Tests")] diff --git a/Runtime/AssemblyInfo.cs.meta b/Runtime/AssemblyInfo.cs.meta new file mode 100644 index 0000000..b00bd24 --- /dev/null +++ b/Runtime/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4db6b958232258a439bebcb315ee304d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Bootstrap.meta b/Runtime/Bootstrap.meta new file mode 100644 index 0000000..a529481 --- /dev/null +++ b/Runtime/Bootstrap.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fb836b5fd9485e94cbefc8f1d743bc0f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Bootstrap/ShrinkModBootstrap.cs b/Runtime/Bootstrap/ShrinkModBootstrap.cs new file mode 100644 index 0000000..bf0c89c --- /dev/null +++ b/Runtime/Bootstrap/ShrinkModBootstrap.cs @@ -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); + } + } +} diff --git a/Runtime/Bootstrap/ShrinkModBootstrap.cs.meta b/Runtime/Bootstrap/ShrinkModBootstrap.cs.meta new file mode 100644 index 0000000..2191ce3 --- /dev/null +++ b/Runtime/Bootstrap/ShrinkModBootstrap.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 23fd762abde4cfe48bf93f9ff86ffdd5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Bootstrap/ShrinkModFrameworkSettings.cs b/Runtime/Bootstrap/ShrinkModFrameworkSettings.cs new file mode 100644 index 0000000..4f7a5c9 --- /dev/null +++ b/Runtime/Bootstrap/ShrinkModFrameworkSettings.cs @@ -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"); + +#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(path); + } + } +#endif + + if (!_instance) + { + _instance = CreateInstance(); + 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; + } +} diff --git a/Runtime/Bootstrap/ShrinkModFrameworkSettings.cs.meta b/Runtime/Bootstrap/ShrinkModFrameworkSettings.cs.meta new file mode 100644 index 0000000..4101c90 --- /dev/null +++ b/Runtime/Bootstrap/ShrinkModFrameworkSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8f9516fe9a338fb448308a9205c3d448 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Bootstrap/ShrinkModReloadDebouncer.cs b/Runtime/Bootstrap/ShrinkModReloadDebouncer.cs new file mode 100644 index 0000000..64a4e84 --- /dev/null +++ b/Runtime/Bootstrap/ShrinkModReloadDebouncer.cs @@ -0,0 +1,34 @@ +using System; + +namespace ShrinkModFramework +{ + /// 把文件系统的 burst 事件折叠为一次主线程 reload。 + 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; + } + } +} diff --git a/Runtime/Bootstrap/ShrinkModReloadDebouncer.cs.meta b/Runtime/Bootstrap/ShrinkModReloadDebouncer.cs.meta new file mode 100644 index 0000000..29b38ba --- /dev/null +++ b/Runtime/Bootstrap/ShrinkModReloadDebouncer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ec113876ec861b64cb8391eed726108b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Bootstrap/ShrinkModRuntimeBootstrap.cs b/Runtime/Bootstrap/ShrinkModRuntimeBootstrap.cs new file mode 100644 index 0000000..15230b8 --- /dev/null +++ b/Runtime/Bootstrap/ShrinkModRuntimeBootstrap.cs @@ -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}"); + } + } + } +} diff --git a/Runtime/Bootstrap/ShrinkModRuntimeBootstrap.cs.meta b/Runtime/Bootstrap/ShrinkModRuntimeBootstrap.cs.meta new file mode 100644 index 0000000..89f2b6a --- /dev/null +++ b/Runtime/Bootstrap/ShrinkModRuntimeBootstrap.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 329c6f80f39fd4840b987c16a6124b1c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Bootstrap/ShrinkModRuntimeDriver.cs b/Runtime/Bootstrap/ShrinkModRuntimeDriver.cs new file mode 100644 index 0000000..a80fb3a --- /dev/null +++ b/Runtime/Bootstrap/ShrinkModRuntimeDriver.cs @@ -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 _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(); + _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; + } + } +} diff --git a/Runtime/Bootstrap/ShrinkModRuntimeDriver.cs.meta b/Runtime/Bootstrap/ShrinkModRuntimeDriver.cs.meta new file mode 100644 index 0000000..b0e9dd7 --- /dev/null +++ b/Runtime/Bootstrap/ShrinkModRuntimeDriver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9a850cdc47a8b5b41a7559c3d1fd9efc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Context.meta b/Runtime/Context.meta new file mode 100644 index 0000000..aec6b79 --- /dev/null +++ b/Runtime/Context.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6eddd3eb11f065f4d912ab35a69576f3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Context/ShrinkModComponent.cs b/Runtime/Context/ShrinkModComponent.cs new file mode 100644 index 0000000..8284900 --- /dev/null +++ b/Runtime/Context/ShrinkModComponent.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Cysharp.Threading.Tasks; +using ShrinkContext; +using ShrinkEventBus; + +namespace ShrinkModFramework +{ + /// 把 IShrinkMod 四阶段生命周期映射为一个可逆 Cordis 组件。 + 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 Inject => _inject; + public IReadOnlyList 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; + } + } +} diff --git a/Runtime/Context/ShrinkModComponent.cs.meta b/Runtime/Context/ShrinkModComponent.cs.meta new file mode 100644 index 0000000..6f785a5 --- /dev/null +++ b/Runtime/Context/ShrinkModComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 167a3359a4a357a49ab81ba266caa07f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Context/ShrinkModComponentDiscovery.cs b/Runtime/Context/ShrinkModComponentDiscovery.cs new file mode 100644 index 0000000..50d0619 --- /dev/null +++ b/Runtime/Context/ShrinkModComponentDiscovery.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using UnityEngine; + +namespace ShrinkModFramework +{ + /// + /// 把工程内程序集和外部 DLL 的当前 revision 投影为延迟组件源。 + /// 同一路径的旧外部程序集无法从 Mono AppDomain 卸载,但不会再进入候选集合。 + /// + internal static class ShrinkModComponentDiscovery + { + public static IReadOnlyList Discover(ShrinkModFrameworkSettings settings, + bool verboseLogging) + { + ShrinkExternalModAssemblyLoader.ScanExternalAssemblyRevisions(settings, verboseLogging); + var results = new List(); + 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(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(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 GetTypesSafely(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException exception) + { + return exception.Types.Where(type => type != null); + } + catch + { + return Array.Empty(); + } + } + } +} diff --git a/Runtime/Context/ShrinkModComponentDiscovery.cs.meta b/Runtime/Context/ShrinkModComponentDiscovery.cs.meta new file mode 100644 index 0000000..e794e2f --- /dev/null +++ b/Runtime/Context/ShrinkModComponentDiscovery.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 95e3bad3c1c50b44db3b144af1aafda8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Context/ShrinkModComponentSource.cs b/Runtime/Context/ShrinkModComponentSource.cs new file mode 100644 index 0000000..351b8c0 --- /dev/null +++ b/Runtime/Context/ShrinkModComponentSource.cs @@ -0,0 +1,27 @@ +using System; + +namespace ShrinkModFramework +{ + /// + /// 延迟模组组件源。Revision 是来源内容的稳定指纹(外部 DLL 可使用文件哈希); + /// 同一 ModId 的 revision/factory 变化由 ShrinkModContextHost 作为替换事务处理。 + /// + public sealed class ShrinkModComponentSource + { + public ShrinkModComponentSource(ShrinkModInfo info, string revision, Func 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 Factory { get; } + } +} diff --git a/Runtime/Context/ShrinkModComponentSource.cs.meta b/Runtime/Context/ShrinkModComponentSource.cs.meta new file mode 100644 index 0000000..d17d85e --- /dev/null +++ b/Runtime/Context/ShrinkModComponentSource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0878db300b244c645bdc69c5b96b70d5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Context/ShrinkModContextHost.cs b/Runtime/Context/ShrinkModContextHost.cs new file mode 100644 index 0000000..64a002d --- /dev/null +++ b/Runtime/Context/ShrinkModContextHost.cs @@ -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; + } + + /// + /// 模组的 Cordis 组合宿主。配置应用是事务:新组合任一 fiber 失败时,重新协调到旧组件源; + /// Mono/IL2CPP 下旧程序集仍驻留,但旧/新模组实例及其已追踪效应会被正确卸载或恢复。 + /// + public sealed class ShrinkModContextHost + { + private readonly Dictionary _mods = new(StringComparer.Ordinal); + private readonly Dictionary _catalogNames = new(); + private readonly List _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 OnModReady; + public event Action> OnAllModsReady; + + public bool EnableHarmonyPatching { get; } + public bool VerboseLogging { get; } + public ShrinkContextRuntime Runtime { get; } + public ShrinkContextLoader Loader { get; } + public IReadOnlyDictionary Mods => _mods; + public IReadOnlyList CurrentSources => _currentSources.ToArray(); + internal ShrinkModRegistryManager RegistryManager { get; } + + public IReadOnlyShrinkModRegistry GetOrCreateRegistry(string name) => + RegistryManager.GetOrCreateRegistry(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 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()); + _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 BuildEntries(IEnumerable sources) + { + var entries = new List(); + 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 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 ValidateAndOrder( + IReadOnlyList sources) + { + var map = new Dictionary(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(); + var visiting = new HashSet(StringComparer.Ordinal); + var visited = new HashSet(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 ReuseUnchangedSources( + IEnumerable desired) + { + var currentById = _currentSources.ToDictionary(source => source.Info.ModId, StringComparer.Ordinal); + var normalized = new List(); + 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 map, + ISet visiting, + ISet visited, + ICollection 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); + } + } +} diff --git a/Runtime/Context/ShrinkModContextHost.cs.meta b/Runtime/Context/ShrinkModContextHost.cs.meta new file mode 100644 index 0000000..e218cc5 --- /dev/null +++ b/Runtime/Context/ShrinkModContextHost.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5a1df7a5c357c614a976ff968dac34fd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Context/ShrinkModCordisRuntime.cs b/Runtime/Context/ShrinkModCordisRuntime.cs new file mode 100644 index 0000000..fd5594f --- /dev/null +++ b/Runtime/Context/ShrinkModCordisRuntime.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using Cysharp.Threading.Tasks; + +namespace ShrinkModFramework +{ + /// 默认模组组件根;目录变更只提交完整的新组合,失败时由 Host 恢复旧组合。 + 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 Mods => + _host?.Mods ?? EmptyMods; + + private static readonly IReadOnlyDictionary EmptyMods = + new Dictionary(); + + public static async UniTask> 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 GetOrCreateRegistry(string name) => + Host.GetOrCreateRegistry(name); + + internal static void ResetForDomainReload() + { + _host = null; + } + + internal static void ResetForTesting() + { + if (_host != null) + _host.ShutdownAsync().GetAwaiter().GetResult(); + _host = null; + } + } +} diff --git a/Runtime/Context/ShrinkModCordisRuntime.cs.meta b/Runtime/Context/ShrinkModCordisRuntime.cs.meta new file mode 100644 index 0000000..0a13f73 --- /dev/null +++ b/Runtime/Context/ShrinkModCordisRuntime.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b8767fd6541a55046a6d2f2ec992c446 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core.meta b/Runtime/Core.meta new file mode 100644 index 0000000..7c5de0a --- /dev/null +++ b/Runtime/Core.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6f3fa4ee5dcc33646922caaf6b2b72d8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/IShrinkMod.cs b/Runtime/Core/IShrinkMod.cs new file mode 100644 index 0000000..1379f58 --- /dev/null +++ b/Runtime/Core/IShrinkMod.cs @@ -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); + } +} diff --git a/Runtime/Core/IShrinkMod.cs.meta b/Runtime/Core/IShrinkMod.cs.meta new file mode 100644 index 0000000..b782f3e --- /dev/null +++ b/Runtime/Core/IShrinkMod.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6736c9aa27396dc4d9ceb5efd0db3ccb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/ShrinkModBase.cs b/Runtime/Core/ShrinkModBase.cs new file mode 100644 index 0000000..e0e0fed --- /dev/null +++ b/Runtime/Core/ShrinkModBase.cs @@ -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) { } + } +} diff --git a/Runtime/Core/ShrinkModBase.cs.meta b/Runtime/Core/ShrinkModBase.cs.meta new file mode 100644 index 0000000..0b9cbae --- /dev/null +++ b/Runtime/Core/ShrinkModBase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: eb330a4a244f00444a73f5b1786ff80a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/ShrinkModContext.cs b/Runtime/Core/ShrinkModContext.cs new file mode 100644 index 0000000..855fc35 --- /dev/null +++ b/Runtime/Core/ShrinkModContext.cs @@ -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 _loadedMods; + private readonly bool _verboseLogging; + private readonly ShrinkCtx _effectContext; + + internal ShrinkModContext(ShrinkModInfo modInfo, ShrinkModRegistryManager registryManager, + IReadOnlyDictionary loadedMods, bool verboseLogging, + ShrinkCtx effectContext = null) + { + ModInfo = modInfo; + _registryManager = registryManager; + _loadedMods = loadedMods; + _verboseLogging = verboseLogging; + _effectContext = effectContext; + } + + public ShrinkModInfo ModInfo { get; } + public IReadOnlyDictionary LoadedMods => _loadedMods; + public bool IsContextManaged => _effectContext != null; + + public IReadOnlyShrinkModRegistry GetRegistry(string name) => + _registryManager.GetOrCreateRegistry(name); + + public bool TryGetRegistry(string name, out IReadOnlyShrinkModRegistry registry) + => _registryManager.TryGetRegistry(name, out registry); + + /// 用当前 ModId 作为 namespace 注册内容,并返回最终完整键。 + public string RegisterContent(string registryName, string localKey, T value) => + _registryManager.GetOrCreateMutableRegistry(registryName) + .RegisterNamespaced(ModInfo.ModId, localKey, value); + + /// 以当前模组为 owner 覆盖已有内容;同目标、同优先级冲突会直接失败。 + public void OverrideContent(string registryName, string targetKey, int priority, T value) => + _registryManager.GetOrCreateMutableRegistry(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; + } + + /// + /// 在模组组件上下文中执行前向动作并就地登记逆操作。逆操作随 fiber 卸载按 LIFO 执行。 + /// 旧 ShrinkModLoader 路径没有可逆上下文,调用本 API 会明确失败而不是静默丢失 cleanup。 + /// + 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); + } + + /// 为已完成的前向动作登记同步逆操作。 + 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(string channel, System.Action handler) + => ShrinkModNetworkManager.RegisterHandler(ModInfo.ModId, channel, handler); + + public void SendToServer(string channel, T payload, string senderPeerId = null) + => ShrinkModNetworkManager.SendToServer(ModInfo.ModId, channel, payload, senderPeerId); + + public void SendToAllClients(string channel, T payload, string senderPeerId = null) + => ShrinkModNetworkManager.SendToAllClients(ModInfo.ModId, channel, payload, senderPeerId); + + public void SendToClient(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}"); + } +} diff --git a/Runtime/Core/ShrinkModContext.cs.meta b/Runtime/Core/ShrinkModContext.cs.meta new file mode 100644 index 0000000..6c2d0ad --- /dev/null +++ b/Runtime/Core/ShrinkModContext.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3b8a0f4cfa66c5142a114eacadb74b75 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/ShrinkModHandle.cs b/Runtime/Core/ShrinkModHandle.cs new file mode 100644 index 0000000..1733322 --- /dev/null +++ b/Runtime/Core/ShrinkModHandle.cs @@ -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; + } + } +} diff --git a/Runtime/Core/ShrinkModHandle.cs.meta b/Runtime/Core/ShrinkModHandle.cs.meta new file mode 100644 index 0000000..bb29f13 --- /dev/null +++ b/Runtime/Core/ShrinkModHandle.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6609ebbb46db87c42a2fa1d45bd12efe +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Integration.meta b/Runtime/Integration.meta new file mode 100644 index 0000000..5242e09 --- /dev/null +++ b/Runtime/Integration.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d81e389d24bac1a45bedd3a97d679a87 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Integration/ShrinkModOptionalRuntimeIntegration.cs b/Runtime/Integration/ShrinkModOptionalRuntimeIntegration.cs new file mode 100644 index 0000000..a91d217 --- /dev/null +++ b/Runtime/Integration/ShrinkModOptionalRuntimeIntegration.cs @@ -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); + } + } +} diff --git a/Runtime/Integration/ShrinkModOptionalRuntimeIntegration.cs.meta b/Runtime/Integration/ShrinkModOptionalRuntimeIntegration.cs.meta new file mode 100644 index 0000000..b1f5801 --- /dev/null +++ b/Runtime/Integration/ShrinkModOptionalRuntimeIntegration.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 733bd8496a2bbcb4fa29594a14d3b0b5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Loading.meta b/Runtime/Loading.meta new file mode 100644 index 0000000..66eae57 --- /dev/null +++ b/Runtime/Loading.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fba48516fe357cd4eb44de16cea78788 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Loading/ShrinkExternalModAssemblyLoader.cs b/Runtime/Loading/ShrinkExternalModAssemblyLoader.cs new file mode 100644 index 0000000..efc1597 --- /dev/null +++ b/Runtime/Loading/ShrinkExternalModAssemblyLoader.cs @@ -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 Current = + new(StringComparer.OrdinalIgnoreCase); + public Dictionary Known = + new(StringComparer.OrdinalIgnoreCase); + } + + private static readonly Dictionary CurrentAssemblyRevisions = + new(StringComparer.OrdinalIgnoreCase); + private static readonly Dictionary ExternalAssemblyHistory = new(); + private static readonly Dictionary KnownAssemblyFiles = new(StringComparer.OrdinalIgnoreCase); + private static readonly object ResolveLock = new(); + private static bool _resolveRegistered; + private static int _lastWarnedResidentCount; + + public static IReadOnlyList LoadExternalAssemblies(ShrinkModFrameworkSettings settings, bool verboseLogging) + { + return ScanExternalAssemblyRevisions(settings, verboseLogging) + .Select(revision => revision.Assembly) + .ToArray(); + } + + internal static IReadOnlyList ScanExternalAssemblyRevisions( + ShrinkModFrameworkSettings settings, bool verboseLogging) + { + if (settings != null && !settings.enableExternalDllMods) + { + CurrentAssemblyRevisions.Clear(); + KnownAssemblyFiles.Clear(); + return Array.Empty(); + } + +#if ENABLE_IL2CPP && !UNITY_EDITOR + Debug.LogWarning("[ShrinkModFramework] IL2CPP 运行时不支持外部 DLL 热加载,已跳过外部模组扫描。"); + return Array.Empty(); +#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(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( + 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; + } + } + } +} diff --git a/Runtime/Loading/ShrinkExternalModAssemblyLoader.cs.meta b/Runtime/Loading/ShrinkExternalModAssemblyLoader.cs.meta new file mode 100644 index 0000000..a08c0e5 --- /dev/null +++ b/Runtime/Loading/ShrinkExternalModAssemblyLoader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bd2c1a02b62d790449c129e226cde40b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Loading/ShrinkHarmonyPatchService.cs b/Runtime/Loading/ShrinkHarmonyPatchService.cs new file mode 100644 index 0000000..bf44f5f --- /dev/null +++ b/Runtime/Loading/ShrinkHarmonyPatchService.cs @@ -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 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(); + } +} diff --git a/Runtime/Loading/ShrinkHarmonyPatchService.cs.meta b/Runtime/Loading/ShrinkHarmonyPatchService.cs.meta new file mode 100644 index 0000000..c783222 --- /dev/null +++ b/Runtime/Loading/ShrinkHarmonyPatchService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c1bf43d28651dea4fa7d5e5c56361da4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Loading/ShrinkModDiagnostics.cs b/Runtime/Loading/ShrinkModDiagnostics.cs new file mode 100644 index 0000000..68c15c5 --- /dev/null +++ b/Runtime/Loading/ShrinkModDiagnostics.cs @@ -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; } + } + + /// Mono 外部程序集的 current 与常驻历史快照;LoadedBytes 仅统计载入 DLL/PDB 文件大小。 + public sealed class ShrinkExternalAssemblyDiagnostic + { + internal ShrinkExternalAssemblyDiagnostic(int currentRevisionCount, + IReadOnlyList residentRevisions, + long estimatedResidentBytes, int softLimit) + { + CurrentRevisionCount = currentRevisionCount; + ResidentRevisions = residentRevisions; + EstimatedResidentBytes = estimatedResidentBytes; + SoftLimit = softLimit; + } + + public int CurrentRevisionCount { get; } + public IReadOnlyList 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); + } + } +} diff --git a/Runtime/Loading/ShrinkModDiagnostics.cs.meta b/Runtime/Loading/ShrinkModDiagnostics.cs.meta new file mode 100644 index 0000000..24c404b --- /dev/null +++ b/Runtime/Loading/ShrinkModDiagnostics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 20342cb92d99bc34ca99a41312b5a95d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Loading/ShrinkModLoader.cs b/Runtime/Loading/ShrinkModLoader.cs new file mode 100644 index 0000000..5247ae8 --- /dev/null +++ b/Runtime/Loading/ShrinkModLoader.cs @@ -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 LoadedMods = new(StringComparer.Ordinal); + private static readonly List LoadSequence = new(); + private static readonly ShrinkModRegistryManager RegistryManager = new(); + + public static bool IsLoaded { get; private set; } + public static IReadOnlyDictionary Mods => + ShrinkModCordisRuntime.IsInitialized ? ShrinkModCordisRuntime.Mods : LoadedMods; + + public static event Action OnModReady; + public static event Action> OnAllModsReady; + + public static IReadOnlyDictionary 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 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(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 GetOrCreateRegistry(string name) + => ShrinkModCordisRuntime.IsInitialized + ? ShrinkModCordisRuntime.GetOrCreateRegistry(name) + : RegistryManager.GetOrCreateRegistry(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 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 DiscoverMods(ShrinkModFrameworkSettings settings) + { + var results = new List(); + 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(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(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 discovered) + { + var seen = new HashSet(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 newMods) + { + var seen = new HashSet(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 ResolveLoadOrder(List discovered, bool allowExistingLoadedDependencies) + { + var map = discovered.ToDictionary(x => x.Info.ModId, x => x, StringComparer.Ordinal); + var result = new List(); + var visiting = new HashSet(StringComparer.Ordinal); + var visited = new HashSet(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 map, + HashSet visiting, HashSet visited, List 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 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 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 handles, ShrinkModState targetState, bool verboseLogging, + Action 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 GetTypesSafely(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + return ex.Types.Where(t => t != null); + } + catch + { + return Array.Empty(); + } + } + } +} diff --git a/Runtime/Loading/ShrinkModLoader.cs.meta b/Runtime/Loading/ShrinkModLoader.cs.meta new file mode 100644 index 0000000..aace715 --- /dev/null +++ b/Runtime/Loading/ShrinkModLoader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3f1310a972c773f479f8e35265c63535 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Metadata.meta b/Runtime/Metadata.meta new file mode 100644 index 0000000..4c1ea25 --- /dev/null +++ b/Runtime/Metadata.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c93eed41ccfddbd48b13b1791393a0c8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Metadata/ShrinkModAttribute.cs b/Runtime/Metadata/ShrinkModAttribute.cs new file mode 100644 index 0000000..d156c06 --- /dev/null +++ b/Runtime/Metadata/ShrinkModAttribute.cs @@ -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; + } + } +} diff --git a/Runtime/Metadata/ShrinkModAttribute.cs.meta b/Runtime/Metadata/ShrinkModAttribute.cs.meta new file mode 100644 index 0000000..baf8551 --- /dev/null +++ b/Runtime/Metadata/ShrinkModAttribute.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 976e55706c1035f4ba692957cd335f32 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Metadata/ShrinkModDependencyAttribute.cs b/Runtime/Metadata/ShrinkModDependencyAttribute.cs new file mode 100644 index 0000000..a3768f0 --- /dev/null +++ b/Runtime/Metadata/ShrinkModDependencyAttribute.cs @@ -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; + } + } +} diff --git a/Runtime/Metadata/ShrinkModDependencyAttribute.cs.meta b/Runtime/Metadata/ShrinkModDependencyAttribute.cs.meta new file mode 100644 index 0000000..943f6e3 --- /dev/null +++ b/Runtime/Metadata/ShrinkModDependencyAttribute.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d4af615bbf25f3e429a341accf01b17b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Metadata/ShrinkModInfo.cs b/Runtime/Metadata/ShrinkModInfo.cs new file mode 100644 index 0000000..c105adc --- /dev/null +++ b/Runtime/Metadata/ShrinkModInfo.cs @@ -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 Dependencies { get; } + + public ShrinkModInfo(string modId, string displayName, string version, int loadOrder, Type entryType, + bool autoApplyHarmonyPatches, IReadOnlyList 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}"; + } + } +} diff --git a/Runtime/Metadata/ShrinkModInfo.cs.meta b/Runtime/Metadata/ShrinkModInfo.cs.meta new file mode 100644 index 0000000..da16534 --- /dev/null +++ b/Runtime/Metadata/ShrinkModInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6497d32d6e9e7084aac7d2c0c2996a78 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Metadata/ShrinkModState.cs b/Runtime/Metadata/ShrinkModState.cs new file mode 100644 index 0000000..705063d --- /dev/null +++ b/Runtime/Metadata/ShrinkModState.cs @@ -0,0 +1,12 @@ +namespace ShrinkModFramework +{ + public enum ShrinkModState + { + Discovered = 0, + Constructed = 1, + ContentRegistered = 2, + Initialized = 3, + Ready = 4, + Unloaded = 5 + } +} diff --git a/Runtime/Metadata/ShrinkModState.cs.meta b/Runtime/Metadata/ShrinkModState.cs.meta new file mode 100644 index 0000000..b341141 --- /dev/null +++ b/Runtime/Metadata/ShrinkModState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bf0b4d15f9b6083489f0b8a954447c17 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Metadata/ShrinkVersionUtility.cs b/Runtime/Metadata/ShrinkVersionUtility.cs new file mode 100644 index 0000000..f753f51 --- /dev/null +++ b/Runtime/Metadata/ShrinkVersionUtility.cs @@ -0,0 +1,45 @@ +using System; + +namespace ShrinkModFramework +{ + internal static class ShrinkVersionUtility + { + public static int Compare(string left, string right) + { + if (ReferenceEquals(left, right)) return 0; + if (left == null) return -1; + if (right == null) return 1; + + var leftParts = left.Split('.'); + var rightParts = right.Split('.'); + var max = Math.Max(leftParts.Length, rightParts.Length); + + for (var i = 0; i < max; i++) + { + var leftValue = i < leftParts.Length ? ParseSegment(leftParts[i]) : 0; + var rightValue = i < rightParts.Length ? ParseSegment(rightParts[i]) : 0; + + if (leftValue != rightValue) + return leftValue.CompareTo(rightValue); + } + + return string.CompareOrdinal(left, right); + } + + private static int ParseSegment(string segment) + { + if (string.IsNullOrEmpty(segment)) + return 0; + + var digits = ""; + for (var i = 0; i < segment.Length; i++) + { + if (!char.IsDigit(segment[i])) + break; + digits += segment[i]; + } + + return int.TryParse(digits, out var value) ? value : 0; + } + } +} diff --git a/Runtime/Metadata/ShrinkVersionUtility.cs.meta b/Runtime/Metadata/ShrinkVersionUtility.cs.meta new file mode 100644 index 0000000..06022bb --- /dev/null +++ b/Runtime/Metadata/ShrinkVersionUtility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6fd43faddede97247997c7738c55fba6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Network.meta b/Runtime/Network.meta new file mode 100644 index 0000000..2221a73 --- /dev/null +++ b/Runtime/Network.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2f7e9a9f13efee44d98e217d8e6b6a70 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Network/IShrinkModNetworkTransport.cs b/Runtime/Network/IShrinkModNetworkTransport.cs new file mode 100644 index 0000000..c433abb --- /dev/null +++ b/Runtime/Network/IShrinkModNetworkTransport.cs @@ -0,0 +1,14 @@ +using System; + +namespace ShrinkModFramework +{ + public interface IShrinkModNetworkTransport + { + bool IsServer { get; } + bool IsClient { get; } + + event Action OnEnvelopeReceived; + + void Send(ShrinkModNetworkEnvelope envelope); + } +} diff --git a/Runtime/Network/IShrinkModNetworkTransport.cs.meta b/Runtime/Network/IShrinkModNetworkTransport.cs.meta new file mode 100644 index 0000000..87027e2 --- /dev/null +++ b/Runtime/Network/IShrinkModNetworkTransport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 70281ce2f2d7b0d41bc4206503df87ff +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Network/ShrinkModNetworkManager.cs b/Runtime/Network/ShrinkModNetworkManager.cs new file mode 100644 index 0000000..12a00e6 --- /dev/null +++ b/Runtime/Network/ShrinkModNetworkManager.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using UnityEngine; + +namespace ShrinkModFramework +{ + public static class ShrinkModNetworkManager + { + private sealed class HandlerRegistration + { + public Type PayloadType; + public Action Handler; + } + + private static readonly Dictionary Handlers = new(StringComparer.Ordinal); + private static IShrinkModNetworkTransport _transport; + private static bool _enabled = true; + private static bool _verboseLogging; + + public static bool HasTransport => _transport != null; + + public static void Configure(bool enabled, bool verboseLogging) + { + _enabled = enabled; + _verboseLogging = verboseLogging; + } + + public static void SetTransport(IShrinkModNetworkTransport transport) + { + if (_transport != null) + _transport.OnEnvelopeReceived -= OnEnvelopeReceived; + + _transport = transport; + + if (_transport != null) + { + _transport.OnEnvelopeReceived += OnEnvelopeReceived; + if (_verboseLogging) + Debug.Log("[ShrinkModNetwork] 已绑定网络传输层。"); + } + } + + public static void RegisterHandler(string modId, string channel, Action handler) + { + if (string.IsNullOrWhiteSpace(modId)) + throw new ArgumentException("模组 ID 不能为空。", nameof(modId)); + if (string.IsNullOrWhiteSpace(channel)) + throw new ArgumentException("频道名不能为空。", nameof(channel)); + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + var key = BuildKey(modId, channel); + if (Handlers.ContainsKey(key)) + throw new InvalidOperationException($"网络频道已注册:{key}"); + + Handlers.Add(key, new HandlerRegistration + { + PayloadType = typeof(T), + Handler = (context, payload) => handler(context, (T)payload) + }); + } + + public static void SendToServer(string modId, string channel, T payload, string senderPeerId = null) + { + SendInternal(modId, channel, payload, senderPeerId, null, ShrinkModNetworkTarget.Server); + } + + public static void SendToAllClients(string modId, string channel, T payload, string senderPeerId = null) + { + SendInternal(modId, channel, payload, senderPeerId, null, ShrinkModNetworkTarget.AllClients); + } + + public static void SendToClient(string modId, string channel, T payload, string targetPeerId, string senderPeerId = null) + { + SendInternal(modId, channel, payload, senderPeerId, targetPeerId, ShrinkModNetworkTarget.SpecificClient); + } + + internal static void ResetForDomainReload() + { + Handlers.Clear(); + SetTransport(null); + _enabled = true; + _verboseLogging = false; + } + + internal static void UnregisterHandlers(string modId) + { + if (string.IsNullOrWhiteSpace(modId)) + return; + + var prefix = modId.Trim() + "::"; + var keys = new List(); + foreach (var key in Handlers.Keys) + { + if (key.StartsWith(prefix, StringComparison.Ordinal)) + keys.Add(key); + } + + foreach (var key in keys) + Handlers.Remove(key); + } + + private static void SendInternal(string modId, string channel, T payload, string senderPeerId, string targetPeerId, + ShrinkModNetworkTarget target) + { + if (!_enabled) + return; + + if (_transport == null) + { + Debug.LogWarning($"[ShrinkModNetwork] 当前没有传输层,消息 {modId}/{channel} 被丢弃。"); + return; + } + + var envelope = new ShrinkModNetworkEnvelope + { + ModId = modId, + Channel = channel, + PayloadJson = JsonConvert.SerializeObject(payload), + SenderPeerId = senderPeerId, + TargetPeerId = targetPeerId, + Target = target + }; + + _transport.Send(envelope); + } + + private static void OnEnvelopeReceived(ShrinkModNetworkEnvelope envelope) + { + if (!_enabled || envelope == null) + return; + + var key = BuildKey(envelope.ModId, envelope.Channel); + if (!Handlers.TryGetValue(key, out var registration)) + { + if (_verboseLogging) + Debug.LogWarning($"[ShrinkModNetwork] 未找到频道处理器:{key}"); + return; + } + + try + { + var payload = JsonConvert.DeserializeObject(envelope.PayloadJson, registration.PayloadType); + var context = new ShrinkModNetworkMessageContext( + envelope.SenderPeerId, + envelope.TargetPeerId, + envelope.Target, + _transport != null && _transport.IsServer, + _transport != null && _transport.IsClient); + + registration.Handler(context, payload); + } + catch (Exception ex) + { + Debug.LogError($"[ShrinkModNetwork] 处理频道 {key} 失败:{ex.Message}"); + } + } + + private static string BuildKey(string modId, string channel) + { + if (string.IsNullOrWhiteSpace(modId)) + throw new ArgumentException("模组 ID 不能为空。", nameof(modId)); + if (string.IsNullOrWhiteSpace(channel)) + throw new ArgumentException("频道名不能为空。", nameof(channel)); + + return $"{modId.Trim()}::{channel.Trim()}"; + } + } +} diff --git a/Runtime/Network/ShrinkModNetworkManager.cs.meta b/Runtime/Network/ShrinkModNetworkManager.cs.meta new file mode 100644 index 0000000..22e11f4 --- /dev/null +++ b/Runtime/Network/ShrinkModNetworkManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3e1949e4943d16d4db129182316c8ed3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Network/ShrinkModNetworkTypes.cs b/Runtime/Network/ShrinkModNetworkTypes.cs new file mode 100644 index 0000000..c47dbc4 --- /dev/null +++ b/Runtime/Network/ShrinkModNetworkTypes.cs @@ -0,0 +1,41 @@ +using System; + +namespace ShrinkModFramework +{ + public enum ShrinkModNetworkTarget + { + Server = 0, + AllClients = 1, + SpecificClient = 2 + } + + [Serializable] + public sealed class ShrinkModNetworkEnvelope + { + public string ModId; + public string Channel; + public string PayloadJson; + public string SenderPeerId; + public string TargetPeerId; + public ShrinkModNetworkTarget Target; + } + + public readonly struct ShrinkModNetworkMessageContext + { + public string SenderPeerId { get; } + public string TargetPeerId { get; } + public ShrinkModNetworkTarget Target { get; } + public bool IsServer { get; } + public bool IsClient { get; } + + public ShrinkModNetworkMessageContext(string senderPeerId, string targetPeerId, ShrinkModNetworkTarget target, + bool isServer, bool isClient) + { + SenderPeerId = senderPeerId; + TargetPeerId = targetPeerId; + Target = target; + IsServer = isServer; + IsClient = isClient; + } + } +} diff --git a/Runtime/Network/ShrinkModNetworkTypes.cs.meta b/Runtime/Network/ShrinkModNetworkTypes.cs.meta new file mode 100644 index 0000000..929f994 --- /dev/null +++ b/Runtime/Network/ShrinkModNetworkTypes.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 010bc5b7cb953c642b9af5eed638f83c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Registry.meta b/Runtime/Registry.meta new file mode 100644 index 0000000..1e96f73 --- /dev/null +++ b/Runtime/Registry.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f988f5d97206da947a5d8210522370e2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Registry/ShrinkModRegistry.cs b/Runtime/Registry/ShrinkModRegistry.cs new file mode 100644 index 0000000..a93197d --- /dev/null +++ b/Runtime/Registry/ShrinkModRegistry.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ShrinkModFramework +{ + public interface IReadOnlyShrinkModRegistry + { + string Name { get; } + IReadOnlyCollection> Entries { get; } + IReadOnlyCollection> BaseEntries { get; } + bool Contains(string key); + bool TryGet(string key, out T value); + bool TryGetEntry(string key, out ShrinkRegistryEntry entry); + IReadOnlyList> GetOverrideCandidates(string key); + } + + internal interface IShrinkOwnedRegistry + { + void RemoveOwnedEntries(string ownerModId); + } + + internal sealed class ShrinkModRegistry : IReadOnlyShrinkModRegistry, IShrinkOwnedRegistry + { + private readonly Dictionary> _baseEntries = + new(StringComparer.Ordinal); + private readonly Dictionary>> _overrides = + new(StringComparer.Ordinal); + + public string Name { get; } + + internal ShrinkModRegistry(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("注册表名称不能为空。", nameof(name)); + + Name = name.Trim(); + } + + /// 当前生效项;存在覆盖时返回最高优先级的覆盖项。 + public IReadOnlyCollection> Entries => _baseEntries.Keys + .OrderBy(key => key, StringComparer.Ordinal) + .Select(ResolveEntry) + .ToArray(); + + /// 不应用覆盖的基础注册项快照。 + public IReadOnlyCollection> BaseEntries => _baseEntries.Values + .OrderBy(entry => entry.Key, StringComparer.Ordinal) + .ToArray(); + + private void RegisterBase(string ownerModId, string key, T value) + { + ownerModId = NormalizeOwner(ownerModId); + key = NormalizeKey(key); + if (_baseEntries.ContainsKey(key)) + throw new InvalidOperationException($"注册表 '{Name}' 中已存在键 '{key}'。"); + + _baseEntries.Add(key, new ShrinkRegistryEntry(ownerModId, key, value)); + } + + /// + /// 使用 owner 作为 namespace 注册内容,并返回最终键。localKey 只能是局部名称,不能包含冒号。 + /// + public string RegisterNamespaced(string ownerModId, string localKey, T value) + { + ownerModId = NormalizeOwner(ownerModId); + if (ownerModId.IndexOf(':') >= 0) + throw new ArgumentException("归属模组 ID 不能包含 ':'。", nameof(ownerModId)); + if (string.IsNullOrWhiteSpace(localKey)) + throw new ArgumentException("局部注册键不能为空。", nameof(localKey)); + + localKey = localKey.Trim(); + if (localKey.IndexOf(':') >= 0) + throw new ArgumentException("局部注册键不能包含 ':'。", nameof(localKey)); + + var key = ownerModId + ":" + localKey; + RegisterBase(ownerModId, key, value); + return key; + } + + /// + /// 为已有基础项注册显式覆盖。优先级越高越先命中;同一目标的相同优先级视为硬冲突。 + /// + public void RegisterOverride(string ownerModId, string targetKey, int priority, T value) + { + ownerModId = NormalizeOwner(ownerModId); + targetKey = NormalizeKey(targetKey); + if (!_baseEntries.ContainsKey(targetKey)) + throw new InvalidOperationException( + $"注册表 '{Name}' 的覆盖目标 '{targetKey}' 不存在。"); + + if (!_overrides.TryGetValue(targetKey, out var candidates)) + { + candidates = new List>(); + _overrides.Add(targetKey, candidates); + } + + if (candidates.Any(candidate => candidate.Priority == priority)) + throw new InvalidOperationException( + $"注册表 '{Name}' 的键 '{targetKey}' 在优先级 {priority} 存在覆盖冲突。"); + + candidates.Add(ShrinkRegistryEntry.CreateOverride(ownerModId, targetKey, priority, value)); + } + + public IReadOnlyList> GetOverrideCandidates(string key) + { + if (string.IsNullOrWhiteSpace(key) || !_overrides.TryGetValue(key.Trim(), out var candidates)) + return Array.Empty>(); + + return candidates + .OrderByDescending(candidate => candidate.Priority) + .ThenBy(candidate => candidate.OwnerModId, StringComparer.Ordinal) + .ToArray(); + } + + public bool Contains(string key) => + !string.IsNullOrWhiteSpace(key) && _baseEntries.ContainsKey(key.Trim()); + + public bool TryGet(string key, out T value) + { + if (TryGetEntry(key, out var entry)) + { + value = entry.Value; + return true; + } + + value = default; + return false; + } + + public bool TryGetEntry(string key, out ShrinkRegistryEntry entry) + { + if (!string.IsNullOrWhiteSpace(key) && _baseEntries.ContainsKey(key.Trim())) + { + entry = ResolveEntry(key.Trim()); + return true; + } + + entry = default; + return false; + } + + void IShrinkOwnedRegistry.RemoveOwnedEntries(string ownerModId) + { + if (string.IsNullOrWhiteSpace(ownerModId)) + return; + + ownerModId = ownerModId.Trim(); + var keys = _baseEntries + .Where(pair => string.Equals(pair.Value.OwnerModId, ownerModId, StringComparison.Ordinal)) + .Select(pair => pair.Key) + .ToArray(); + foreach (var key in keys) + _baseEntries.Remove(key); + + var emptyOverrideTargets = new List(); + foreach (var pair in _overrides) + { + pair.Value.RemoveAll(candidate => + string.Equals(candidate.OwnerModId, ownerModId, StringComparison.Ordinal)); + if (pair.Value.Count == 0) + emptyOverrideTargets.Add(pair.Key); + } + + foreach (var targetKey in emptyOverrideTargets) + _overrides.Remove(targetKey); + } + + private ShrinkRegistryEntry ResolveEntry(string key) + { + if (_overrides.TryGetValue(key, out var candidates) && candidates.Count > 0) + { + return candidates + .OrderByDescending(candidate => candidate.Priority) + .ThenBy(candidate => candidate.OwnerModId, StringComparer.Ordinal) + .First(); + } + + return _baseEntries[key]; + } + + private static string NormalizeOwner(string ownerModId) + { + if (string.IsNullOrWhiteSpace(ownerModId)) + throw new ArgumentException("归属模组 ID 不能为空。", nameof(ownerModId)); + return ownerModId.Trim(); + } + + private static string NormalizeKey(string key) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("注册键不能为空。", nameof(key)); + return key.Trim(); + } + } + + public readonly struct ShrinkRegistryEntry + { + public string OwnerModId { get; } + public string Key { get; } + public T Value { get; } + public bool IsOverride { get; } + public int Priority { get; } + + public ShrinkRegistryEntry(string ownerModId, string key, T value) + : this(ownerModId, key, value, false, 0) + { + } + + private ShrinkRegistryEntry(string ownerModId, string key, T value, bool isOverride, int priority) + { + OwnerModId = ownerModId; + Key = key; + Value = value; + IsOverride = isOverride; + Priority = priority; + } + + internal static ShrinkRegistryEntry CreateOverride( + string ownerModId, string key, int priority, T value) => + new(ownerModId, key, value, true, priority); + } +} diff --git a/Runtime/Registry/ShrinkModRegistry.cs.meta b/Runtime/Registry/ShrinkModRegistry.cs.meta new file mode 100644 index 0000000..491f487 --- /dev/null +++ b/Runtime/Registry/ShrinkModRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7cb75a17a6ee08f479d0be1fe145307f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Registry/ShrinkModRegistryManager.cs b/Runtime/Registry/ShrinkModRegistryManager.cs new file mode 100644 index 0000000..7f9cd04 --- /dev/null +++ b/Runtime/Registry/ShrinkModRegistryManager.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; + +namespace ShrinkModFramework +{ + public sealed class ShrinkModRegistryManager + { + private readonly Dictionary _registries = new(); + + public IReadOnlyShrinkModRegistry GetOrCreateRegistry(string name) => + GetOrCreateMutableRegistry(name); + + internal ShrinkModRegistry GetOrCreateMutableRegistry(string name) + { + var compositeKey = BuildCompositeKey(typeof(T), name); + if (_registries.TryGetValue(compositeKey, out var existing)) + return (ShrinkModRegistry)existing; + + var registry = new ShrinkModRegistry(name); + _registries.Add(compositeKey, registry); + return registry; + } + + public bool TryGetRegistry(string name, out IReadOnlyShrinkModRegistry registry) + { + var compositeKey = BuildCompositeKey(typeof(T), name); + if (_registries.TryGetValue(compositeKey, out var existing)) + { + registry = (ShrinkModRegistry)existing; + return true; + } + + registry = null; + return false; + } + + internal void Clear() => _registries.Clear(); + + internal void RemoveOwnedEntries(string ownerModId) + { + foreach (var registry in _registries.Values) + { + if (registry is IShrinkOwnedRegistry ownedRegistry) + ownedRegistry.RemoveOwnedEntries(ownerModId); + } + } + + private static string BuildCompositeKey(Type type, string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("注册表名称不能为空。", nameof(name)); + + return $"{type.AssemblyQualifiedName}::{name.Trim()}"; + } + } +} diff --git a/Runtime/Registry/ShrinkModRegistryManager.cs.meta b/Runtime/Registry/ShrinkModRegistryManager.cs.meta new file mode 100644 index 0000000..6e9ef1c --- /dev/null +++ b/Runtime/Registry/ShrinkModRegistryManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aa8f924fbddf4e544a8531969765b217 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/ShrinkModFramework.Runtime.asmdef b/Runtime/ShrinkModFramework.Runtime.asmdef new file mode 100644 index 0000000..ecedab2 --- /dev/null +++ b/Runtime/ShrinkModFramework.Runtime.asmdef @@ -0,0 +1,18 @@ +{ + "name": "ShrinkModFramework.Runtime", + "rootNamespace": "ShrinkModFramework", + "references": [ + "Newtonsoft.Json", + "ShrinkContext.Core.Runtime", + "ShrinkEventBus.Runtime", + "UniTask" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Runtime/ShrinkModFramework.Runtime.asmdef.meta b/Runtime/ShrinkModFramework.Runtime.asmdef.meta new file mode 100644 index 0000000..08200f4 --- /dev/null +++ b/Runtime/ShrinkModFramework.Runtime.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: eac56219787481141b1f9a9f40a1af62 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests.meta b/Tests.meta new file mode 100644 index 0000000..3a953bb --- /dev/null +++ b/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5e21e58316e865942a6bdcc1479a411d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Fixtures.meta b/Tests/Fixtures.meta new file mode 100644 index 0000000..aef7e6c --- /dev/null +++ b/Tests/Fixtures.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 39d24561b9507f340abccb25be811c21 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Fixtures/ExternalFixture.Consumer.dll.bytes b/Tests/Fixtures/ExternalFixture.Consumer.dll.bytes new file mode 100644 index 0000000000000000000000000000000000000000..217d428f1baf0ac6e7482efb53a25a416218645a GIT binary patch literal 4096 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 ALI3~& literal 0 HcmV?d00001 diff --git a/Tests/Fixtures/ExternalFixture.Mod.V1.dll.bytes.meta b/Tests/Fixtures/ExternalFixture.Mod.V1.dll.bytes.meta new file mode 100644 index 0000000..18c735c --- /dev/null +++ b/Tests/Fixtures/ExternalFixture.Mod.V1.dll.bytes.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 97a7806bf8820b94e83cc2011c09bc43 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Fixtures/ExternalFixture.Mod.V2.Failing.dll.bytes b/Tests/Fixtures/ExternalFixture.Mod.V2.Failing.dll.bytes new file mode 100644 index 0000000000000000000000000000000000000000..0afdbaa68a8fc0c6f2c2f83634917a9f94b86941 GIT binary patch literal 5120 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 literal 0 HcmV?d00001 diff --git a/Tests/Fixtures/ExternalFixture.Mod.V2.Failing.dll.bytes.meta b/Tests/Fixtures/ExternalFixture.Mod.V2.Failing.dll.bytes.meta new file mode 100644 index 0000000..6d63c72 --- /dev/null +++ b/Tests/Fixtures/ExternalFixture.Mod.V2.Failing.dll.bytes.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8517a178ed20f2a45b30d4c45fefe2c5 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Fixtures/ExternalFixture.Mod.V3.dll.bytes b/Tests/Fixtures/ExternalFixture.Mod.V3.dll.bytes new file mode 100644 index 0000000000000000000000000000000000000000..118f1cddafb80d2755bf5acf8e705ba78ad9dc19 GIT binary patch literal 5120 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"items"); + + var key = registry.RegisterNamespaced("example.mod", "iron_hammer", "Iron Hammer"); + + Assert.AreEqual("example.mod:iron_hammer", key); + Assert.IsTrue(registry.TryGet(key, out var value)); + Assert.AreEqual("Iron Hammer", value); + Assert.Throws(() => + registry.RegisterNamespaced("example.mod", "other:key", "invalid")); + Assert.Throws(() => + registry.RegisterNamespaced("invalid:owner", "key", "invalid")); + } + + [Test] + public void RegistryOverrides_HighestPriorityWinsAndOwnerRemovalRestoresPreviousValue() + { + var manager = new ShrinkModRegistryManager(); + var registry = manager.GetOrCreateMutableRegistry("items"); + registry.RegisterNamespaced("core", "iron_hammer", "Core"); + registry.RegisterOverride("mod.low", "core:iron_hammer", 10, "Low"); + registry.RegisterOverride("mod.high", "core:iron_hammer", 20, "High"); + + Assert.IsTrue(registry.TryGetEntry("core:iron_hammer", out var high)); + Assert.AreEqual("High", high.Value); + Assert.AreEqual("mod.high", high.OwnerModId); + Assert.IsTrue(high.IsOverride); + Assert.AreEqual(20, high.Priority); + Assert.AreEqual("mod.high", registry.Entries.Single().OwnerModId); + + manager.RemoveOwnedEntries("mod.high"); + Assert.IsTrue(registry.TryGetEntry("core:iron_hammer", out var low)); + Assert.AreEqual("Low", low.Value); + Assert.AreEqual("mod.low", low.OwnerModId); + + manager.RemoveOwnedEntries("mod.low"); + Assert.IsTrue(registry.TryGetEntry("core:iron_hammer", out var original)); + Assert.AreEqual("Core", original.Value); + Assert.AreEqual("core", original.OwnerModId); + Assert.IsFalse(original.IsOverride); + + manager.RemoveOwnedEntries("core"); + Assert.IsFalse(registry.Contains("core:iron_hammer")); + Assert.IsFalse(registry.TryGet("core:iron_hammer", out _)); + } + + [Test] + public void RegistryOverrides_RejectMissingTargetsAndSamePriorityConflicts() + { + var registry = new ShrinkModRegistryManager().GetOrCreateMutableRegistry("items"); + + Assert.Throws(() => + registry.RegisterOverride("mod.one", "core:missing", 10, "missing")); + + registry.RegisterNamespaced("core", "iron_hammer", "Core"); + registry.RegisterOverride("mod.one", "core:iron_hammer", 10, "One"); + + Assert.Throws(() => + registry.RegisterOverride("mod.two", "core:iron_hammer", 10, "Two")); + Assert.That(registry.GetOverrideCandidates("core:iron_hammer").Select(entry => entry.OwnerModId), + Is.EqualTo(new[] { "mod.one" })); + } + + [Test] + public void ModContext_ExposesReadOnlyRegistryAndOwnedWriteCommands() + { + var getRegistry = typeof(ShrinkModContext).GetMethod(nameof(ShrinkModContext.GetRegistry)); + Assert.IsNotNull(getRegistry); + Assert.IsTrue(getRegistry!.ReturnType.IsGenericType); + Assert.AreEqual(typeof(IReadOnlyShrinkModRegistry<>), + getRegistry.ReturnType.GetGenericTypeDefinition()); + + var readMethods = typeof(IReadOnlyShrinkModRegistry) + .GetMethods() + .Select(method => method.Name) + .ToArray(); + Assert.That(readMethods, Does.Not.Contain("Register")); + Assert.That(readMethods, Does.Not.Contain("RegisterNamespaced")); + Assert.That(readMethods, Does.Not.Contain("RegisterOverride")); + Assert.IsNotNull(typeof(ShrinkModContext).GetMethod(nameof(ShrinkModContext.RegisterContent))); + Assert.IsNotNull(typeof(ShrinkModContext).GetMethod(nameof(ShrinkModContext.OverrideContent))); + } + + [Test] + public void FailedReplacement_RestoresPreviousModEffectsAndRegistryContent() + { + var host = new ShrinkModContextHost(enableHarmonyPatching: false); + var activeEffects = new HashSet(StringComparer.Ordinal); + var oldActivationCount = 0; + var oldSource = Source("test.transaction", "1.0.0", "old", () => + new DelegateMod( + onConstruct: context => + { + oldActivationCount++; + context.Effect( + () => activeEffects.Add("old"), + () => activeEffects.Remove("old")); + }, + onRegisterContent: context => + context.RegisterContent("items", "item", "old"))); + + var failingSource = Source("test.transaction", "2.0.0", "broken", () => + new DelegateMod( + onConstruct: context => context.Effect( + () => activeEffects.Add("new-partial"), + () => activeEffects.Remove("new-partial")), + onRegisterContent: context => + context.RegisterContent("items", "item", "new"), + onInitialize: _ => throw new InvalidOperationException("new mod failed"))); + + try + { + Run(host.ApplyAsync(new[] { oldSource })); + Assert.That(activeEffects, Is.EquivalentTo(new[] { "old" })); + AssertRegistryValue(host, "old"); + + var error = Assert.Throws(() => + Run(host.ApplyAsync(new[] { failingSource }))); + + Assert.IsTrue(error!.PreviousCompositionRestored); + Assert.AreEqual(2, oldActivationCount, "旧模组应以新 fiber 重新激活,而不是复用已退役实例"); + Assert.That(activeEffects, Is.EquivalentTo(new[] { "old" }), + "失败新模组的部分效应必须回滚"); + Assert.AreEqual("1.0.0", host.Mods["test.transaction"].Info.Version); + AssertRegistryValue(host, "old"); + Assert.IsTrue(host.Loader.TryGetFiber("test.transaction", out var restoredFiber)); + Assert.AreEqual(ShrinkFiberState.Active, restoredFiber.State); + Assert.IsNull(restoredFiber.LastError); + } + finally + { + Run(host.ShutdownAsync()); + } + + Assert.IsEmpty(activeEffects); + Assert.IsEmpty(host.Mods); + Assert.IsFalse(host.GetOrCreateRegistry("items").Contains("test.transaction:item")); + } + + [Test] + public void ProviderRevisionChange_ReloadsUnchangedDependentByProviderUid() + { + var host = new ShrinkModContextHost(enableHarmonyPatching: false); + var consumerApplyCount = 0; + var activeConsumers = 0; + var providerV1 = Source("test.provider", "1.0.0", "v1", () => new DelegateMod()); + var providerV2 = Source("test.provider", "2.0.0", "v2", () => new DelegateMod()); + var dependency = new ShrinkModDependency("test.provider", "1.0.0", optional: false); + var consumer = Source("test.consumer", "1.0.0", "stable", () => + new DelegateMod(onConstruct: context => + { + consumerApplyCount++; + context.Effect(() => activeConsumers++, () => activeConsumers--); + }), dependency); + + try + { + Run(host.ApplyAsync(new[] { providerV1, consumer })); + var firstProviderGeneration = host.Mods["test.provider"].Generation; + var firstConsumerGeneration = host.Mods["test.consumer"].Generation; + Assert.AreEqual(1, consumerApplyCount); + Assert.AreEqual(1, activeConsumers); + + Run(host.ApplyAsync(new[] { providerV2, consumer })); + + Assert.AreEqual(2, consumerApplyCount, + "依赖组件源未变,但提供者 uid 变化必须触发消费者重载"); + Assert.AreEqual(1, activeConsumers); + Assert.Greater(host.Mods["test.provider"].Generation, firstProviderGeneration); + Assert.Greater(host.Mods["test.consumer"].Generation, firstConsumerGeneration); + Assert.AreEqual("2.0.0", host.Mods["test.provider"].Info.Version); + } + finally + { + Run(host.ShutdownAsync()); + } + + Assert.AreEqual(0, activeConsumers); + } + + [Test] + public void InvalidDesiredGraph_IsRejectedBeforeCurrentCompositionChanges() + { + var host = new ShrinkModContextHost(enableHarmonyPatching: false); + var activeProviderEffects = 0; + var provider = Source("test.provider", "1.0.0", "v1", () => + new DelegateMod(onConstruct: context => + context.Effect(() => activeProviderEffects++, () => activeProviderEffects--))); + var missingDependency = new ShrinkModDependency("test.missing", null, optional: false); + var invalid = Source("test.invalid", "1.0.0", "v1", () => new DelegateMod(), missingDependency); + + try + { + Run(host.ApplyAsync(new[] { provider })); + var generation = host.Mods["test.provider"].Generation; + + Assert.Throws(() => Run(host.ApplyAsync(new[] { invalid }))); + + Assert.AreEqual(1, activeProviderEffects); + Assert.AreEqual(generation, host.Mods["test.provider"].Generation, + "验证失败发生在协调前,现有 fiber 不应重建"); + Assert.AreEqual(1, host.Mods.Count); + } + finally + { + Run(host.ShutdownAsync()); + } + } + + [Test] + public void SameRevisionFromFreshScan_IsIdempotent() + { + var host = new ShrinkModContextHost(enableHarmonyPatching: false); + var activationCount = 0; + var sourceA = Source("test.stable", "1.0.0", "same-hash", () => + new DelegateMod(onConstruct: _ => activationCount++)); + var sourceB = Source("test.stable", "1.0.0", "same-hash", () => + new DelegateMod(onConstruct: _ => activationCount++)); + + try + { + Run(host.ApplyAsync(new[] { sourceA })); + var generation = host.Mods["test.stable"].Generation; + + Run(host.ApplyAsync(new[] { sourceB })); + + Assert.AreEqual(1, activationCount); + Assert.AreEqual(generation, host.Mods["test.stable"].Generation); + Assert.AreSame(sourceA, host.CurrentSources[0], + "相同内容指纹应沿用已提交 source,不以扫描对象引用制造变更"); + } + finally + { + Run(host.ShutdownAsync()); + } + } + + [Test] + public void PublicLoader_DefaultRoute_UsesContextHostAndRemainsIdempotent() + { + ResetStaticLoader(); + var settings = ScriptableObject.CreateInstance(); + settings.useContextHost = true; + settings.enableExternalDllMods = false; + settings.enableHarmonyPatching = false; + settings.verboseLogging = false; + settings.assemblyNamePrefixes = new[] { "__no_matching_mod_assembly__" }; + + try + { + var first = ShrinkModLoader.LoadAll(settings); + var second = ShrinkModLoader.LoadAll(settings); + + Assert.IsTrue(ShrinkModLoader.IsLoaded); + Assert.IsTrue(ShrinkModCordisRuntime.IsInitialized); + Assert.AreSame(first, second); + Assert.IsEmpty(first); + } + finally + { + ResetStaticLoader(); + Object.DestroyImmediate(settings); + } + } + + [Test] + public void ExternalDllRevision_FailedReplacementRestoresPreviousAndBrokenBytesDoNotReplaceIt() + { + ResetExternalRuntime(); + var settings = CreateExternalSettings(); + var directory = ShrinkExternalModAssemblyLoader.GetExternalModsDirectory(settings); + var target = Path.Combine(directory, "ExternalFixture.Mod.dll"); + + try + { + Directory.CreateDirectory(directory); + CopyFixture("ExternalFixture.Mod.V1.dll.bytes", target); + + ShrinkModLoader.LoadAll(settings); + AssertExternalFixture("1.0.0", "v1"); + var restoredGeneration = ShrinkModLoader.Mods["external.fixture"].Generation; + + CopyFixture("ExternalFixture.Mod.V2.Failing.dll.bytes", target); + var transaction = Assert.Throws(() => + ShrinkModLoader.LoadNewExternalMods(settings)); + + Assert.IsTrue(transaction!.PreviousCompositionRestored); + AssertExternalFixture("1.0.0", "v1"); + Assert.AreNotEqual(0, restoredGeneration); + Assert.Greater(ShrinkModLoader.Mods["external.fixture"].Generation, restoredGeneration); + var failedReplacementGeneration = ShrinkModLoader.Mods["external.fixture"].Generation; + + File.WriteAllBytes(target, new byte[] { 0, 1, 2, 3, 4, 5 }); + LogAssert.Expect(LogType.Error, + new Regex(@"\[ShrinkModFramework\] 加载外部 DLL 失败:.*ExternalFixture\.Mod\.dll")); + ShrinkModLoader.LoadNewExternalMods(settings); + + AssertExternalFixture("1.0.0", "v1"); + Assert.AreEqual(failedReplacementGeneration, + ShrinkModLoader.Mods["external.fixture"].Generation, + "坏 DLL 不应让失败 revision 重新成为当前 source"); + + CopyFixture("ExternalFixture.Mod.V3.dll.bytes", target); + ShrinkModLoader.LoadNewExternalMods(settings); + AssertExternalFixture("3.0.0", "v3"); + + settings.externalAssemblyRevisionSoftLimit = 2; + var diagnostics = ShrinkModDiagnostics.CaptureExternalAssemblies(settings); + Assert.AreEqual(1, diagnostics.CurrentRevisionCount, + "同一路径只有最终有效 revision 是 current"); + Assert.GreaterOrEqual(diagnostics.ResidentRevisionCount, 3, + "v1、失败但已加载的 v2、v3 Assembly 在 Mono 下都会常驻"); + Assert.Greater(diagnostics.EstimatedResidentBytes, 0L); + Assert.IsTrue(diagnostics.IsSoftLimitExceeded); + Assert.AreEqual(1, diagnostics.ResidentRevisions.Count(item => item.IsCurrent)); + } + finally + { + CleanupExternalRuntime(settings, directory); + } + } + + [Test] + public void ExternalDllWatcher_DebouncesBurstAndHandlesDeletion() + { + ResetExternalRuntime(); + var settings = CreateExternalSettings(); + settings.externalModsReloadDelaySeconds = 0.05f; + var directory = ShrinkExternalModAssemblyLoader.GetExternalModsDirectory(settings); + var target = Path.Combine(directory, "ExternalFixture.Mod.dll"); + + try + { + Directory.CreateDirectory(directory); + ShrinkModRuntimeDriver.EnsureCreated(settings); + Assert.IsTrue(ShrinkModRuntimeDriver.InstanceForTesting!.HasWatcherForTesting); + + CopyFixture("ExternalFixture.Mod.V1.dll.bytes", target); + ShrinkModLoader.LoadAll(settings); + AssertExternalFixture("1.0.0", "v1"); + + // Two writes arrive as one debounce window; only the final valid revision should commit. + CopyFixture("ExternalFixture.Mod.V2.Failing.dll.bytes", target); + CopyFixture("ExternalFixture.Mod.V3.dll.bytes", target); + Assert.IsTrue(PumpDriverUntil(() => + ShrinkModLoader.Mods.TryGetValue("external.fixture", out var handle) && + handle.Info.Version == "3.0.0"), + "watcher 应在 burst 后提交最后一个有效 revision"); + AssertExternalFixture("3.0.0", "v3"); + + File.Delete(target); + Assert.IsTrue(PumpDriverUntil(() => ShrinkModLoader.Mods.Count == 0), + "删除 DLL 应触发当前 source 卸载"); + } + finally + { + CleanupExternalRuntime(settings, directory); + } + } + + [Test] + public void ExternalDllDependencyRemoval_RestoresPreviousComposition() + { + ResetExternalRuntime(); + var settings = CreateExternalSettings(); + var directory = ShrinkExternalModAssemblyLoader.GetExternalModsDirectory(settings); + var providerPath = Path.Combine(directory, "ExternalFixture.Provider.dll"); + var consumerPath = Path.Combine(directory, "ExternalFixture.Consumer.dll"); + + try + { + Directory.CreateDirectory(directory); + CopyFixture("ExternalFixture.Provider.dll.bytes", providerPath); + CopyFixture("ExternalFixture.Consumer.dll.bytes", consumerPath); + + ShrinkModLoader.LoadAll(settings); + Assert.AreEqual(2, ShrinkModLoader.Mods.Count); + Assert.AreEqual(ShrinkModState.Ready, ShrinkModLoader.Mods["external.provider"].State); + Assert.AreEqual(ShrinkModState.Ready, ShrinkModLoader.Mods["external.consumer"].State); + + File.Delete(providerPath); + Assert.Throws(() => + ShrinkModLoader.LoadNewExternalMods(settings)); + + Assert.AreEqual(2, ShrinkModLoader.Mods.Count, + "依赖导入失败发生在协调前,旧组合必须保持 active"); + Assert.AreEqual(ShrinkModState.Ready, ShrinkModLoader.Mods["external.provider"].State); + Assert.AreEqual(ShrinkModState.Ready, ShrinkModLoader.Mods["external.consumer"].State); + + CopyFixture("ExternalFixture.Provider.dll.bytes", providerPath); + ShrinkModLoader.LoadNewExternalMods(settings); + Assert.AreEqual(2, ShrinkModLoader.Mods.Count); + } + finally + { + CleanupExternalRuntime(settings, directory); + } + } + + [Test] + public void ExternalReloadDebouncer_CoalescesBurstAndUsesMinimumDelay() + { + var debouncer = new ShrinkModReloadDebouncer(); + + debouncer.Schedule(10f, 0f); + Assert.IsTrue(debouncer.IsPending); + Assert.IsFalse(debouncer.TryConsume(10.049f)); + + debouncer.Schedule(10.04f, 0.5f); + Assert.IsFalse(debouncer.TryConsume(10.539f)); + Assert.IsTrue(debouncer.TryConsume(10.54f)); + Assert.IsFalse(debouncer.IsPending); + Assert.IsFalse(debouncer.TryConsume(11f)); + } + + private static ShrinkModComponentSource Source(string id, string version, string revision, + Func factory, params ShrinkModDependency[] dependencies) + { + return new ShrinkModComponentSource( + new ShrinkModInfo(id, id, version, 0, typeof(DelegateMod), + autoApplyHarmonyPatches: false, dependencies), + revision, + factory); + } + + private static void AssertRegistryValue(ShrinkModContextHost host, string expected) + { + var registry = host.GetOrCreateRegistry("items"); + Assert.IsTrue(registry.TryGet("test.transaction:item", out var value)); + Assert.AreEqual(expected, value); + } + + private static void Run(UniTask task) => task.GetAwaiter().GetResult(); + + private static void ResetStaticLoader() + { + var method = typeof(ShrinkModLoader).GetMethod("ResetForTesting", + BindingFlags.NonPublic | BindingFlags.Static); + Assert.IsNotNull(method); + method!.Invoke(null, null); + } + + private static void ResetExternalRuntime() + { + if (ShrinkModRuntimeDriver.InstanceForTesting != null) + Object.DestroyImmediate(ShrinkModRuntimeDriver.InstanceForTesting.gameObject); + ShrinkModLoader.ResetForTesting(); + } + + private static ShrinkModFrameworkSettings CreateExternalSettings() + { + var settings = ScriptableObject.CreateInstance(); + settings.useContextHost = true; + settings.enableExternalDllMods = true; + settings.autoCreateExternalModsDirectory = false; + settings.externalModsFolderName = "ShrinkModFrameworkTests_" + Guid.NewGuid().ToString("N"); + settings.assemblyNamePrefixes = new[] { "ExternalFixture." }; + settings.enableHarmonyPatching = false; + settings.enableNetworkSync = false; + settings.verboseLogging = false; + return settings; + } + + private static void CleanupExternalRuntime(ShrinkModFrameworkSettings settings, string directory) + { + try + { + if (ShrinkModRuntimeDriver.InstanceForTesting != null) + Object.DestroyImmediate(ShrinkModRuntimeDriver.InstanceForTesting.gameObject); + ShrinkModLoader.ResetForTesting(); + } + finally + { + if (Directory.Exists(directory)) + Directory.Delete(directory, true); + Object.DestroyImmediate(settings); + } + } + + private static void CopyFixture(string fixtureName, string targetPath) + { + var package = UnityEditor.PackageManager.PackageInfo.FindForAssembly( + typeof(ShrinkModContextHostTests).Assembly); + var testsRoot = package != null && !string.IsNullOrWhiteSpace(package.resolvedPath) + ? Path.Combine(package.resolvedPath, "Tests") + : UnityEditor.AssetDatabase.FindAssets("ShrinkModFramework.Tests t:asmdef") + .Select(UnityEditor.AssetDatabase.GUIDToAssetPath) + .Where(path => string.Equals(Path.GetFileName(path), + "ShrinkModFramework.Tests.asmdef", StringComparison.Ordinal)) + .Select(Path.GetDirectoryName) + .FirstOrDefault(path => !string.IsNullOrWhiteSpace(path)); + if (string.IsNullOrWhiteSpace(testsRoot)) + throw new InvalidOperationException("Could not resolve the ShrinkModFramework tests path."); + + var fixturePath = Path.Combine(testsRoot, "Fixtures", fixtureName); + File.Copy(fixturePath, targetPath, true); + } + + private static void AssertExternalFixture(string version, string registryValue) + { + Assert.IsTrue(ShrinkModLoader.Mods.TryGetValue("external.fixture", out var handle)); + Assert.AreEqual(version, handle!.Info.Version); + Assert.AreEqual(ShrinkModState.Ready, handle.State); + + var registry = ShrinkModLoader.GetOrCreateRegistry("external.fixture"); + Assert.IsTrue(registry.TryGet("external.fixture:version", out var value)); + Assert.AreEqual(registryValue, value); + } + + private static bool PumpDriverUntil(Func predicate) + { + for (var i = 0; i < 120; i++) + { + ShrinkModRuntimeDriver.InstanceForTesting?.PumpForTesting(); + if (predicate()) + return true; + Thread.Sleep(50); + } + + ShrinkModRuntimeDriver.InstanceForTesting?.PumpForTesting(); + return predicate(); + } + + private sealed class DelegateMod : IShrinkMod + { + private readonly Action? _onConstruct; + private readonly Action? _onRegisterContent; + private readonly Action? _onInitialize; + private readonly Action? _onReady; + + public DelegateMod( + Action? onConstruct = null, + Action? onRegisterContent = null, + Action? onInitialize = null, + Action? onReady = null) + { + _onConstruct = onConstruct; + _onRegisterContent = onRegisterContent; + _onInitialize = onInitialize; + _onReady = onReady; + } + + public void OnConstruct(ShrinkModContext context) => _onConstruct?.Invoke(context); + public void OnRegisterContent(ShrinkModContext context) => _onRegisterContent?.Invoke(context); + public void OnInitialize(ShrinkModContext context) => _onInitialize?.Invoke(context); + public void OnReady(ShrinkModContext context) => _onReady?.Invoke(context); + } + } +} diff --git a/Tests/ShrinkModContextHostTests.cs.meta b/Tests/ShrinkModContextHostTests.cs.meta new file mode 100644 index 0000000..5bd4892 --- /dev/null +++ b/Tests/ShrinkModContextHostTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 421cd535a998c804bad713cc816c91e4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/ShrinkModFramework.Tests.asmdef b/Tests/ShrinkModFramework.Tests.asmdef new file mode 100644 index 0000000..6f94f0c --- /dev/null +++ b/Tests/ShrinkModFramework.Tests.asmdef @@ -0,0 +1,23 @@ +{ + "name": "ShrinkModFramework.Tests", + "rootNamespace": "ShrinkModFramework.Tests", + "references": [ + "ShrinkModFramework.Runtime", + "ShrinkContext.Core.Runtime", + "UniTask", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Tests/ShrinkModFramework.Tests.asmdef.meta b/Tests/ShrinkModFramework.Tests.asmdef.meta new file mode 100644 index 0000000..59e8f8f --- /dev/null +++ b/Tests/ShrinkModFramework.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: feac4d1857e911a4a9961b66835f4109 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tools~/FixtureBuilder/ExternalFixture.Mod.V1.cs b/Tools~/FixtureBuilder/ExternalFixture.Mod.V1.cs new file mode 100644 index 0000000..589a8b6 --- /dev/null +++ b/Tools~/FixtureBuilder/ExternalFixture.Mod.V1.cs @@ -0,0 +1,25 @@ +using ShrinkModFramework; + +namespace ExternalFixture +{ + [ShrinkMod("external.fixture", "External Fixture", "1.0.0")] + public sealed class ExternalFixtureMod : IShrinkMod + { + public void OnConstruct(ShrinkModContext context) + { + } + + public void OnRegisterContent(ShrinkModContext context) + { + context.RegisterContent("external.fixture", "version", "v1"); + } + + public void OnInitialize(ShrinkModContext context) + { + } + + public void OnReady(ShrinkModContext context) + { + } + } +} diff --git a/Tools~/FixtureBuilder/ExternalFixture.Mod.V2.cs b/Tools~/FixtureBuilder/ExternalFixture.Mod.V2.cs new file mode 100644 index 0000000..e2629ae --- /dev/null +++ b/Tools~/FixtureBuilder/ExternalFixture.Mod.V2.cs @@ -0,0 +1,27 @@ +using System; +using ShrinkModFramework; + +namespace ExternalFixture +{ + [ShrinkMod("external.fixture", "External Fixture", "2.0.0")] + public sealed class ExternalFixtureMod : IShrinkMod + { + public void OnConstruct(ShrinkModContext context) + { + } + + public void OnRegisterContent(ShrinkModContext context) + { + context.RegisterContent("external.fixture", "version", "v2"); + } + + public void OnInitialize(ShrinkModContext context) + { + throw new InvalidOperationException("fixture replacement failure"); + } + + public void OnReady(ShrinkModContext context) + { + } + } +} diff --git a/Tools~/FixtureBuilder/ExternalFixture.Mod.V3.cs b/Tools~/FixtureBuilder/ExternalFixture.Mod.V3.cs new file mode 100644 index 0000000..6f822d8 --- /dev/null +++ b/Tools~/FixtureBuilder/ExternalFixture.Mod.V3.cs @@ -0,0 +1,25 @@ +using ShrinkModFramework; + +namespace ExternalFixture +{ + [ShrinkMod("external.fixture", "External Fixture", "3.0.0")] + public sealed class ExternalFixtureMod : IShrinkMod + { + public void OnConstruct(ShrinkModContext context) + { + } + + public void OnRegisterContent(ShrinkModContext context) + { + context.RegisterContent("external.fixture", "version", "v3"); + } + + public void OnInitialize(ShrinkModContext context) + { + } + + public void OnReady(ShrinkModContext context) + { + } + } +} diff --git a/Tools~/FixtureBuilder/Rebuild-ShrinkModFixtures.ps1 b/Tools~/FixtureBuilder/Rebuild-ShrinkModFixtures.ps1 new file mode 100644 index 0000000..e894ac2 --- /dev/null +++ b/Tools~/FixtureBuilder/Rebuild-ShrinkModFixtures.ps1 @@ -0,0 +1,44 @@ +[CmdletBinding()] +param( + [string]$UnityProjectRoot +) + +$ErrorActionPreference = 'Stop' + +$packageRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '../..')).Path +if (-not $UnityProjectRoot) { + $UnityProjectRoot = Join-Path $packageRoot 'Development~/UnityProject' +} + +$resolvedUnityProjectRoot = (Resolve-Path -LiteralPath $UnityProjectRoot).Path +$fixtureProject = Join-Path $PSScriptRoot 'ShrinkModFixtureBuilder.csproj' +$fixtureOutputRoot = Join-Path $resolvedUnityProjectRoot 'Temp/ShrinkModFixtureBuilder' +$fixtureTargetRoot = Join-Path $packageRoot 'Tests/Fixtures' +$runtimeAssemblyPath = Join-Path $resolvedUnityProjectRoot 'Temp/Bin/Debug/ShrinkModFramework.Runtime/ShrinkModFramework.Runtime.dll' + +if (-not (Test-Path -LiteralPath $runtimeAssemblyPath)) { + throw "ShrinkModFramework.Runtime.dll was not found. Compile the Unity development host first: $resolvedUnityProjectRoot" +} + +foreach ($variant in 'V1', 'V2', 'V3') { + $outputDirectory = Join-Path $fixtureOutputRoot $variant + $intermediateDirectory = Join-Path $fixtureOutputRoot "obj/$variant/" + dotnet build $fixtureProject /m:1 --nologo ` + -p:FixtureVariant=$variant ` + -p:RuntimeAssemblyPath=$runtimeAssemblyPath ` + -p:BaseIntermediateOutputPath=$intermediateDirectory ` + -p:OutputPath=$outputDirectory + if ($LASTEXITCODE -ne 0) { + throw "External fixture $variant build failed with exit code $LASTEXITCODE." + } + + $source = Join-Path $outputDirectory 'ExternalFixture.Mod.dll' + $targetName = switch ($variant) { + 'V1' { 'ExternalFixture.Mod.V1.dll.bytes' } + 'V2' { 'ExternalFixture.Mod.V2.Failing.dll.bytes' } + 'V3' { 'ExternalFixture.Mod.V3.dll.bytes' } + } + Copy-Item -LiteralPath $source -Destination (Join-Path $fixtureTargetRoot $targetName) -Force +} + +Write-Host 'ShrinkModFramework external fixture DLLs rebuilt.' \ No newline at end of file diff --git a/Tools~/FixtureBuilder/ShrinkModFixtureBuilder.csproj b/Tools~/FixtureBuilder/ShrinkModFixtureBuilder.csproj new file mode 100644 index 0000000..cc965dc --- /dev/null +++ b/Tools~/FixtureBuilder/ShrinkModFixtureBuilder.csproj @@ -0,0 +1,18 @@ + + + netstandard2.1 + 9.0 + enable + false + ExternalFixture.Mod + ExternalFixture + + + + + + $(RuntimeAssemblyPath) + false + + + \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..e655f9f --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "com.cneicy.shrink-mod-framework", + "version": "0.2.0", + "displayName": "ShrinkModFramework", + "description": "Unity 模组框架,提供发现、依赖、可逆生命周期、命名空间内容注册与优先级覆盖。", + "unity": "2022.3", + "dependencies": { + "com.unity.nuget.newtonsoft-json": "3.2.2", + "com.cneicy.shrink-context-core": "0.1.0", + "com.cneicy.shrink-eventbus": "2.0.0", + "com.cysharp.unitask": "2.5.10" + }, + "keywords": [ + "mod", + "modding", + "framework", + "plugin", + "registry" + ], + "author": { + "name": "cneicy", + "url": "https://git.crash.work/ShrinkSDK" + }, + "documentationUrl": "https://git.crash.work/ShrinkSDK/ShrinkModFramework", + "changelogUrl": "https://git.crash.work/ShrinkSDK/ShrinkModFramework/src/branch/main/CHANGELOG.md" +} diff --git a/package.json.meta b/package.json.meta new file mode 100644 index 0000000..b2ba689 --- /dev/null +++ b/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 385321b8e01136b458c6d5b0dc7ed31e +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: