diff --git a/.gitea/workflows/publish-nuget.yml b/.gitea/workflows/publish-nuget.yml
new file mode 100644
index 0000000..06dc7b7
--- /dev/null
+++ b/.gitea/workflows/publish-nuget.yml
@@ -0,0 +1,52 @@
+name: Publish NuGet packages
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ env:
+ NUGET_AUTH_TOKEN: ${{ secrets.SHRINKSDK_PACKAGE_TOKEN }}
+ steps:
+ - name: Checkout tagged source
+ uses: actions/checkout@v4
+
+ - name: Set up .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Validate tag and pack projects
+ env:
+ GITEA_REF: ${{ gitea.ref }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ tag="${GITEA_REF#refs/tags/}"
+ version="$(node -p "require('./package.json').version")"
+ test "$tag" = "v$version"
+ mkdir -p packages
+ mapfile -d '' projects < <(find DotNet~ Godot~ -type f -name '*.csproj' -print0 2>/dev/null || true)
+ test "${#projects[@]}" -gt 0
+ for project in "${projects[@]}"; do
+ dotnet restore "$project" --configfile NuGet.Config
+ dotnet pack "$project" --configuration Release --no-restore --output "$PWD/packages" --include-symbols --include-source
+ done
+ find packages -maxdepth 1 -name '*.nupkg' -type f | grep -q .
+
+ - name: Publish packages
+ shell: bash
+ run: |
+ set -euo pipefail
+ : "${NUGET_AUTH_TOKEN:?SHRINKSDK_PACKAGE_TOKEN is required}"
+ dotnet nuget push 'packages/*.nupkg' --api-key "$NUGET_AUTH_TOKEN" --source https://git.crash.work/api/packages/ShrinkSDK/nuget/index.json --skip-duplicate
+ if compgen -G 'packages/*.snupkg' > /dev/null; then
+ dotnet nuget push 'packages/*.snupkg' --api-key "$NUGET_AUTH_TOKEN" --source https://git.crash.work/api/packages/ShrinkSDK/nuget/index.json --skip-duplicate
+ fi
diff --git a/.gitignore b/.gitignore
index 246935d..c3b8764 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,11 @@
/Tools~/**/[Oo]bj/
*.user
*.DotSettings.user
+/DotNet~/**/[Bb]in/
+/DotNet~/**/[Oo]bj/
+/Godot~/**/[Bb]in/
+/Godot~/**/[Oo]bj/
+/artifacts/
+/packages/
+!DotNet~/**/*.csproj
+!Godot~/**/*.csproj
diff --git a/.npmignore b/.npmignore
index 700f03d..6a59c77 100644
--- a/.npmignore
+++ b/.npmignore
@@ -6,3 +6,7 @@ Tools~/
*.sln
*.user
*.DotSettings.user
+DotNet~/
+Godot~/
+NuGet.Config
+Directory.Build.props
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..43b188b
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,13 @@
+
+
+ latest
+ enable
+ true
+ true
+ ShrinkSDK
+ ShrinkSDK
+ https://git.crash.work/ShrinkSDK
+ true
+ snupkg
+
+
diff --git a/DotNet~/Runtime/ShrinkModContext.cs b/DotNet~/Runtime/ShrinkModContext.cs
new file mode 100644
index 0000000..e38807e
--- /dev/null
+++ b/DotNet~/Runtime/ShrinkModContext.cs
@@ -0,0 +1,25 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+
+namespace ShrinkModFramework;
+
+public sealed class ShrinkModContext
+{
+ private readonly Dictionary _services = new();
+ public ShrinkModContext(string modDirectory) => ModDirectory = modDirectory ?? throw new ArgumentNullException(nameof(modDirectory));
+ public string ModDirectory { get; }
+ public void RegisterService(T service) where T : class => _services[typeof(T)] = service ?? throw new ArgumentNullException(nameof(service));
+ public bool TryGetService(out T? service) where T : class
+ {
+ service = _services.TryGetValue(typeof(T), out var value) ? value as T : null;
+ return service != null;
+ }
+ public T GetRequiredService() where T : class => TryGetService(out var value) ? value! : throw new InvalidOperationException($"Service is not registered: {typeof(T).FullName}");
+}
+
+public interface IShrinkModUnload
+{
+ void OnUnload(ShrinkModContext context);
+}
diff --git a/DotNet~/ShrinkSDK.ModFramework.csproj b/DotNet~/ShrinkSDK.ModFramework.csproj
new file mode 100644
index 0000000..d3539ff
--- /dev/null
+++ b/DotNet~/ShrinkSDK.ModFramework.csproj
@@ -0,0 +1,21 @@
+
+
+ netstandard2.1
+ false
+ ShrinkModFramework.Runtime
+ ShrinkModFramework
+ ShrinkSDK.ModFramework
+ 0.3.0
+ Engine-neutral ShrinkSDK mod contracts and lifecycle.
+ disable
+ false
+
+
+
+
+
+
+
+
+
+
diff --git a/Godot~/ShrinkGodotModLoader.cs b/Godot~/ShrinkGodotModLoader.cs
new file mode 100644
index 0000000..93d29c3
--- /dev/null
+++ b/Godot~/ShrinkGodotModLoader.cs
@@ -0,0 +1,66 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.Loader;
+
+namespace ShrinkModFramework.Godot;
+
+public sealed class ShrinkGodotModLoader : IDisposable
+{
+ private sealed record LoadedMod(AssemblyLoadContext LoadContext, WeakReference UnloadReference,
+ ShrinkModContext Context, IReadOnlyList Instances);
+ private readonly Dictionary _loaded = new(StringComparer.OrdinalIgnoreCase);
+
+ public IReadOnlyCollection LoadedAssemblyPaths => _loaded.Keys;
+
+ public IReadOnlyList Load(string assemblyPath)
+ {
+ assemblyPath = Path.GetFullPath(assemblyPath);
+ if (_loaded.ContainsKey(assemblyPath)) throw new InvalidOperationException($"Mod assembly is already loaded: {assemblyPath}");
+ var loadContext = new AssemblyLoadContext($"ShrinkMod:{Path.GetFileNameWithoutExtension(assemblyPath)}", true);
+ loadContext.Resolving += (_, name) => ResolveDependency(loadContext, Path.GetDirectoryName(assemblyPath)!, name);
+ try
+ {
+ var assembly = loadContext.LoadFromAssemblyPath(assemblyPath);
+ var entries = assembly.GetTypes().Where(type => !type.IsAbstract && typeof(IShrinkMod).IsAssignableFrom(type) &&
+ type.GetCustomAttribute() != null).OrderBy(type => type.GetCustomAttribute()!.LoadOrder).ToArray();
+ var context = new ShrinkModContext(Path.GetDirectoryName(assemblyPath)!);
+ var instances = entries.Select(type => (IShrinkMod)(Activator.CreateInstance(type) ??
+ throw new InvalidOperationException($"Failed to create mod entry: {type.FullName}"))).ToArray();
+ foreach (var mod in instances) mod.OnConstruct(context);
+ foreach (var mod in instances) mod.OnRegisterContent(context);
+ foreach (var mod in instances) mod.OnInitialize(context);
+ foreach (var mod in instances) mod.OnReady(context);
+ _loaded[assemblyPath] = new LoadedMod(loadContext, new WeakReference(loadContext), context, instances);
+ return instances;
+ }
+ catch { loadContext.Unload(); throw; }
+ }
+
+ public bool Unload(string assemblyPath)
+ {
+ assemblyPath = Path.GetFullPath(assemblyPath);
+ if (!_loaded.Remove(assemblyPath, out var loaded)) return false;
+ for (var index = loaded.Instances.Count - 1; index >= 0; index--)
+ if (loaded.Instances[index] is IShrinkModUnload unload) unload.OnUnload(loaded.Context);
+ loaded.LoadContext.Unload();
+ return true;
+ }
+
+ public void Dispose()
+ {
+ foreach (var path in _loaded.Keys.ToArray()) Unload(path);
+ }
+
+ private static Assembly? ResolveDependency(AssemblyLoadContext context, string directory, AssemblyName name)
+ {
+ var shared = AssemblyLoadContext.Default.Assemblies.FirstOrDefault(assembly => assembly.GetName().Name == name.Name);
+ if (shared != null) return shared;
+ var path = Path.Combine(directory, name.Name + ".dll");
+ return File.Exists(path) ? context.LoadFromAssemblyPath(path) : null;
+ }
+}
diff --git a/Godot~/ShrinkSDK.ModFramework.Godot.csproj b/Godot~/ShrinkSDK.ModFramework.Godot.csproj
new file mode 100644
index 0000000..dd855d9
--- /dev/null
+++ b/Godot~/ShrinkSDK.ModFramework.Godot.csproj
@@ -0,0 +1,15 @@
+
+
+ net8.0
+ ShrinkModFramework.Godot
+ ShrinkModFramework.Godot
+ ShrinkSDK.ModFramework.Godot
+ 0.1.0
+ Collectible AssemblyLoadContext host for ShrinkSDK mods in Godot.
+ false
+
+
+
+
+
+
diff --git a/NuGet.Config b/NuGet.Config
new file mode 100644
index 0000000..43e134b
--- /dev/null
+++ b/NuGet.Config
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index d6a26d0..c7bc2a1 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,8 @@
# ShrinkModFramework
-一个面向 Unity 的轻量模组框架,目标是给 `Shrink` 系列提供接近 Forge 的核心能力:
+一个面向 Unity 与 Godot C# 的轻量模组框架,目标是给 `Shrink` 系列提供接近 Forge 的核心能力:
+
+共享模组合同和加载规则位于 `DotNet~`,Godot `AssemblyLoadContext` 适配与打包工程位于 `Godot~`。Godot 项目安装 `ShrinkSDK.ModFramework.Godot`,会同时取得共享核心包。
- 模组声明
- 模组发现