From a2ff31d95d014ad62bc9e1418d78837462f961ba Mon Sep 17 00:00:00 2001
From: Zheng-Yu7463 <982563036@qq.com>
Date: Tue, 13 Oct 2026 19:41:14 +0800
Subject: [PATCH 1/3] Fix Unity 6 macOS packaging and legacy runtime hooks
---
.github/workflows/build.yaml | 18 +-
Assembly-CSharp/Assembly-CSharp.csproj | 54 ++--
.../Attributes/ForwardedHooksAttribute.cs | 8 +
Assembly-CSharp/Language/Language.cs | 3 +-
Assembly-CSharp/Patches/GameManager.cs | 7 +-
Assembly-CSharp/Preloader.cs | 17 +-
MonoMod.Patcher/MonoModder.cs | 3 +-
.../HookGenerator.cs | 44 ++-
PostPatcher/PostPatcher.csproj | 8 +-
PrePatcher/PrePatcher.csproj | 10 +-
README.ModdingApi.md | 32 ++-
README.md | 29 +-
Tests/HookGen/HookGen.csproj | 10 +
Tests/HookGen/Program.cs | 261 ++++++++++++++++++
Tests/HookGen/README.md | 14 +
15 files changed, 423 insertions(+), 95 deletions(-)
create mode 100644 Assembly-CSharp/Attributes/ForwardedHooksAttribute.cs
create mode 100644 Tests/HookGen/HookGen.csproj
create mode 100644 Tests/HookGen/Program.cs
create mode 100644 Tests/HookGen/README.md
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 241abac9..17a9052e 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -67,26 +67,18 @@ jobs:
run: |
sudo apt-get update -y
sudo apt-get install -y mono-devel
- - name: Restore Dependencies
- run: |
- dotnet restore
- - name: Build PrePatcher & PostPatcher
- run: |
- dotnet build PrePatcher -o PrePatcher/Output -p:Configuration=Release
- dotnet build PostPatcher -o PostPatcher/Output -p:Configuration=Release
- - name: Build TC assembly patches
- run: |
- dotnet build TeamCherry.Localization --runtime $RID -p:Configuration=Release
- name: Build Assembly-CSharp
run: |
- dotnet build Assembly-CSharp --runtime $RID -p:Configuration=Release
+ dotnet build Assembly-CSharp --runtime "$RID" --configuration Release
- name: Merge osx dylibs
if: matrix.platform == 'macos'
run: |
wget https://github.com/konoui/lipo/releases/download/v0.9.4/lipo_linux_amd64 -O lipo
chmod +x lipo
- ./lipo -output libunityscenerepacker.dylib -create ./Assembly-CSharp/packages/unityscenerepacker/2.4.0/runtimes/osx-*/native/libunityscenerepacker.dylib
- mv libunityscenerepacker.dylib OutputFinal/
+ for native_library in ./Assembly-CSharp/packages/unityscenerepacker/2.4.0/runtimes/osx-x64/native/*.dylib; do
+ library_name=$(basename "$native_library")
+ ./lipo -output "OutputFinal/$library_name" -create "$native_library" "./Assembly-CSharp/packages/unityscenerepacker/2.4.0/runtimes/osx-arm64/native/$library_name"
+ done
- name: Upload Binary
if: inputs.upload-artifact
uses: actions/upload-artifact@v7
diff --git a/Assembly-CSharp/Assembly-CSharp.csproj b/Assembly-CSharp/Assembly-CSharp.csproj
index f28e5f91..01c34660 100644
--- a/Assembly-CSharp/Assembly-CSharp.csproj
+++ b/Assembly-CSharp/Assembly-CSharp.csproj
@@ -42,43 +42,36 @@
../OutputFinal
-
- C:/Program Files (x86)/Steam/steamapps/common/Hollow Knight
- $(HOME)/.local/share/Steam/steamapps/common/Hollow Knight
- $(GamePath)/hollow_knight_Data/Managed
mono
-
-
-
+
-
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -97,34 +90,23 @@
-
-
+
+
+
+
+
-
+
-
-
-
-
-
- .dll
- lib
- .so
- lib
- .dylib
-
-
-
-
-
+
@@ -187,4 +169,4 @@
-
\ No newline at end of file
+
diff --git a/Assembly-CSharp/Attributes/ForwardedHooksAttribute.cs b/Assembly-CSharp/Attributes/ForwardedHooksAttribute.cs
new file mode 100644
index 00000000..b5a68af4
--- /dev/null
+++ b/Assembly-CSharp/Attributes/ForwardedHooksAttribute.cs
@@ -0,0 +1,8 @@
+using System;
+
+namespace Modding;
+
+[AttributeUsage(AttributeTargets.Class, Inherited = false)]
+internal sealed class ForwardedHooksAttribute : Attribute
+{
+}
diff --git a/Assembly-CSharp/Language/Language.cs b/Assembly-CSharp/Language/Language.cs
index 2a51ed70..6af4c199 100644
--- a/Assembly-CSharp/Language/Language.cs
+++ b/Assembly-CSharp/Language/Language.cs
@@ -10,6 +10,7 @@
namespace Language;
[Obsolete("Use `TeamCherry.Localization.Language` instead.")]
+[Modding.ForwardedHooks]
public static class Language
{
public static void LoadLanguage() => COMPAT_LoadLanguage();
@@ -93,4 +94,4 @@ public static class Language
[MonoMod.MonoModLinkTo("TeamCherry.Localization.Language", "System.String GetLanguageFileContents(System.String)")]
[MonoMod.MonoModRemove]
extern private static string COMPAT_GetLanguageFileContents(string sheetTitle);
-}
\ No newline at end of file
+}
diff --git a/Assembly-CSharp/Patches/GameManager.cs b/Assembly-CSharp/Patches/GameManager.cs
index e9d50065..1113c84b 100644
--- a/Assembly-CSharp/Patches/GameManager.cs
+++ b/Assembly-CSharp/Patches/GameManager.cs
@@ -38,6 +38,11 @@ public static void ClearModLocalSettings(int saveSlot)
[MonoModPatch("global::GameManager")]
public class GameManager : global::GameManager
{
+ [MonoModIgnore]
+ private bool waitForManualLevelStart;
+
+ internal void PrepareForMenuTransition() => waitForManualLevelStart = true;
+
internal static string ModdedSavePath(int slot) =>
Path.Combine
(
@@ -716,4 +721,4 @@ public static void GameManager_OnWillActivateFirstLevel(ILContext il)
cursor.EmitDelegate(global::Modding.ModHooks.OnNewGame);
}
}
-}
\ No newline at end of file
+}
diff --git a/Assembly-CSharp/Preloader.cs b/Assembly-CSharp/Preloader.cs
index 2c85147d..c40d8f3d 100644
--- a/Assembly-CSharp/Preloader.cs
+++ b/Assembly-CSharp/Preloader.cs
@@ -90,7 +90,7 @@ Dictionary>> sceneHooks
yield return CleanUpPreloading();
UnmuteAllAudio();
- Logger.APILogger.LogError($"Finished preloading in {stopwatch.ElapsedMilliseconds / 1000:F2}s");
+ Logger.APILogger.Log($"Finished preloading in {stopwatch.ElapsedMilliseconds / 1000.0:F2}s");
}
///
@@ -477,10 +477,6 @@ IEnumerator DoLoad(AsyncOperation load)
progressBar.Progress = (1 * progressAlpha + progressBeta);
}
- ///
- /// Clean up everything from preloading.
- ///
- ///
private IEnumerator CleanUpPreloading()
{
// Reload the main menu to fix the music/shaders
@@ -488,12 +484,10 @@ private IEnumerator CleanUpPreloading()
ModLoader.LoadState |= ModLoader.ModLoadState.Preloaded;
- // adapted main menu loading from StartManager
- AsyncOperation loadOperation = UnityEngine.SceneManagement.SceneManager.LoadSceneAsync("Quit_To_Menu");
- loadOperation.allowSceneActivation = false;
+ // Quit_To_Menu is a transition scene and must not start a gameplay level.
+ ((Patches.GameManager)GameManager.instance).PrepareForMenuTransition();
Platform.Current.SetSceneLoadState(true, true);
- loadOperation.allowSceneActivation = true;
- yield return loadOperation;
+ yield return USceneManager.LoadSceneAsync("Quit_To_Menu");
while (USceneManager.GetActiveScene().name != Constants.MENU_SCENE)
{
yield return new WaitForEndOfFrame();
@@ -501,7 +495,6 @@ private IEnumerator CleanUpPreloading()
Destroy(progressBar);
yield return null;
- yield break;
}
///
@@ -511,4 +504,4 @@ private static void UnmuteAllAudio()
{
AudioListener.pause = false;
}
-}
\ No newline at end of file
+}
diff --git a/MonoMod.Patcher/MonoModder.cs b/MonoMod.Patcher/MonoModder.cs
index f403b354..74d30f5a 100644
--- a/MonoMod.Patcher/MonoModder.cs
+++ b/MonoMod.Patcher/MonoModder.cs
@@ -2240,8 +2240,7 @@ public virtual void DefaultPostProcessType(TypeDefinition type)
if (PreventInline && method.HasBody)
{
method.NoInlining = true;
- // Remove AggressiveInlining
- method.ImplAttributes &= (MethodImplAttributes)0x0100;
+ method.ImplAttributes &= ~MethodImplAttributes.AggressiveInlining;
}
method.FixShortLongOps();
diff --git a/MonoMod.RuntimeDetour.HookGen/HookGenerator.cs b/MonoMod.RuntimeDetour.HookGen/HookGenerator.cs
index 925117d6..0f8ba28f 100644
--- a/MonoMod.RuntimeDetour.HookGen/HookGenerator.cs
+++ b/MonoMod.RuntimeDetour.HookGen/HookGenerator.cs
@@ -287,7 +287,7 @@ public bool GenerateFor(TypeDefinition hookType, TypeDefinition hookILType, Meth
ILProcessor il;
GenericInstanceMethod endpointMethod;
- var methodRef = OutputModule.ImportReference(method);
+ var methodRef = OutputModule.ImportReference(GetHookTarget(method));
#region Hook
@@ -382,6 +382,48 @@ public bool GenerateFor(TypeDefinition hookType, TypeDefinition hookILType, Meth
return true;
}
+ private static MethodReference GetHookTarget(MethodDefinition method)
+ {
+ if (!method.DeclaringType.CustomAttributes.Any(attribute =>
+ attribute.AttributeType.FullName == "Modding.ForwardedHooksAttribute"))
+ return method;
+
+ if (!method.IsStatic || !method.HasBody || method.Body.HasExceptionHandlers)
+ throw new InvalidOperationException($"Forwarded hook method '{method.FullName}' must have a static forwarding body.");
+
+ var instructions = method.Body.Instructions.Where(instruction => instruction.OpCode != OpCodes.Nop).ToArray();
+ int argumentCount = method.Parameters.Count;
+ if (instructions.Length != argumentCount + 2 ||
+ instructions[argumentCount].OpCode != OpCodes.Call ||
+ instructions[argumentCount].Operand is not MethodReference target ||
+ target.HasThis || target.HasGenericParameters || target is GenericInstanceMethod ||
+ target.DeclaringType.FullName == method.DeclaringType.FullName ||
+ target.Parameters.Count != argumentCount ||
+ instructions[argumentCount + 1].OpCode != OpCodes.Ret)
+ throw new InvalidOperationException($"Forwarded hook method '{method.FullName}' must directly call one external static method and return its result.");
+
+ for (int index = 0; index < argumentCount; index++)
+ {
+ if (!LoadsArgument(instructions[index], index))
+ throw new InvalidOperationException($"Forwarded hook method '{method.FullName}' must pass its arguments unchanged and in order.");
+ }
+
+ return target;
+ }
+
+ private static bool LoadsArgument(Instruction instruction, int index)
+ {
+ return instruction.OpCode.Code switch
+ {
+ Code.Ldarg_0 => index == 0,
+ Code.Ldarg_1 => index == 1,
+ Code.Ldarg_2 => index == 2,
+ Code.Ldarg_3 => index == 3,
+ Code.Ldarg or Code.Ldarg_S => instruction.Operand is ParameterDefinition parameter && parameter.Index == index,
+ _ => false
+ };
+ }
+
public TypeDefinition GenerateDelegateFor(MethodDefinition method)
{
var name = HookGenerator.GetFriendlyName(method);
diff --git a/PostPatcher/PostPatcher.csproj b/PostPatcher/PostPatcher.csproj
index df3bc36d..e346dcaf 100644
--- a/PostPatcher/PostPatcher.csproj
+++ b/PostPatcher/PostPatcher.csproj
@@ -18,12 +18,12 @@
pdbonly
-
-
-
+
+
+
-
+
diff --git a/PrePatcher/PrePatcher.csproj b/PrePatcher/PrePatcher.csproj
index b6ff546f..07bc0b26 100644
--- a/PrePatcher/PrePatcher.csproj
+++ b/PrePatcher/PrePatcher.csproj
@@ -18,12 +18,12 @@
pdbonly
-
-
-
+
+
+
-
+
@@ -47,4 +47,4 @@
-
\ No newline at end of file
+
diff --git a/README.ModdingApi.md b/README.ModdingApi.md
index 097c3bb2..949b1e41 100644
--- a/README.ModdingApi.md
+++ b/README.ModdingApi.md
@@ -1,22 +1,26 @@
-Hollow Knight Modding API
-=========================
+# Hollow Knight Modding API
-This is a simple modding API used mainly for compatibility between mods. It does little on its own, but is required for several mods to run.
+This development build targets Hollow Knight 1.5.12620 (Unity 6). Use a build that matches both the game version and the game process architecture. Compatibility with every existing mod has not been established.
-How To Install
-==============
+## Installation
-Navigate to your Hollow Knight Game Install folder.
+Close the game and copy this package's files into its `Managed` directory:
- Steam: C:\Program Files (x86)\Steam\steamapps\common\Hollow Knight\
- GoG:
+- macOS: `hollow_knight.app/Contents/Resources/Data/Managed`
+- Windows and Linux: `hollow_knight_Data/Managed`
-Copy everything in this zip file into the game's folder. When prompted to overwrite files, say yes.
+Place each mod in its own subdirectory of `Managed/Mods`. The title screen lists loaded mods and any initialization failures.
-You may also use [Scarab](https://github.com/fifty-six/Scarab/releases/latest).
+## Apple Silicon
-How To Uninstall
-================
+MonoMod 25.3.6 requires an x86-64 game process on macOS. Use the `osx-x64` package and Rosetta. For example, replace the application path below with the location of your installation:
-Steam: Right Click on Hollow Knight -> Properties -> Local Files -> Verify Integrity of Game Files
-GoG: Options -> Manage Installation -> Verify / Repair
+```sh
+arch -x86_64 "/path/to/hollow_knight.app/Contents/MacOS/Hollow Knight"
+```
+
+Launching the universal game normally on Apple Silicon selects arm64, which this version of MonoMod does not support. A universal native dependency alone does not provide arm64 runtime hook support.
+
+## Removal
+
+Restore the original game files using Steam's **Properties → Installed Files → Verify integrity of game files**, or the equivalent repair command in your game client. This removes the API from the game assemblies; manually added mod files may remain in `Managed/Mods`.
diff --git a/README.md b/README.md
index 5bcee0f4..81dc6508 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,9 @@ Hollow Knight Modding API


-A Hollow Knight Modding API/loader. Uses [MonoMod](https://github.com/MonoMod/MonoMod).
+A Hollow Knight Modding API/loader for Hollow Knight 1.5.12620 (Unity 6). Uses [MonoMod](https://github.com/MonoMod/MonoMod).
+
+This branch builds on the [Unity 6 port](https://github.com/hk-modding/api/pull/164) and is under development. Existing mods may require changes for Unity 6.
If you're a mod developer, there are [examples](https://github.com/hk-modding/api/tree/master/Examples) in the repo.
@@ -13,12 +15,27 @@ Documentation can be found [here](https://hk-modding.github.io/api/).
Build
=======
-**If you want to use the API, for making mods or using them, please use a release or the installer.**
+Install the .NET SDK (8.0 or later). On macOS and Linux, also install Mono and make sure `mono` is on `PATH`; the build runs .NET Framework patching tools through Mono.
+
+Copy the `Managed` folder from a matching, unmodified Hollow Knight installation into the repository root as `Vanilla`. On macOS this folder is inside `hollow_knight.app/Contents/Resources/Data/Managed`.
+
+Build from the repository root, choosing the runtime for the game process:
+
+```sh
+dotnet build Assembly-CSharp --configuration Release --runtime osx-x64
+```
+
+Use `win-x64` on Windows or `linux-x64` on Linux. The project builds its patching tools and dependencies automatically and writes the installable files to `OutputFinal`.
+
+The patcher disables method inlining in modified game assemblies so runtime hooks can observe calls reliably.
+
+On Apple Silicon, use `osx-x64` and run the game through Rosetta for compatibility testing. MonoMod 25.3.6 does not support native `osx-arm64` runtime detours. The native dependency packager can select arm64 files, and CI produces universal macOS libraries, but neither establishes native arm64 mod compatibility.
+
+Builds leave the game installation untouched unless `CopyDir` is explicitly supplied. To install the result, close the game and copy the contents of `OutputFinal` into its `Managed` folder, or build with `-p:CopyDir="/absolute/path/to/Managed"`.
+
+The GitHub Actions workflow needs access to the private `hk-modding/hk-binary-archives` repository through `BINARY_ARCHIVE_DEPLOY_KEY`. Local builds use the supplied `Vanilla` folder.
-1. Clone the repository!
-2. Copy the Managed folder from your Hollow Knight installation into the solution folder and name it Vanilla.
-3. Build the solution using an IDE or `dotnet build`.
-4. The result will be in `OutputFinal`
+The [hook generator regression tests](Tests/HookGen/README.md) run without game files.
License
=======
diff --git a/Tests/HookGen/HookGen.csproj b/Tests/HookGen/HookGen.csproj
new file mode 100644
index 00000000..4e997adf
--- /dev/null
+++ b/Tests/HookGen/HookGen.csproj
@@ -0,0 +1,10 @@
+
+
+ Exe
+ net472
+ 10
+
+
+
+
+
diff --git a/Tests/HookGen/Program.cs b/Tests/HookGen/Program.cs
new file mode 100644
index 00000000..16688327
--- /dev/null
+++ b/Tests/HookGen/Program.cs
@@ -0,0 +1,261 @@
+using System;
+using System.ComponentModel;
+using System.IO;
+using System.Linq;
+using Mono.Cecil;
+using Mono.Cecil.Cil;
+using MonoMod;
+using MonoMod.RuntimeDetour.HookGen;
+
+internal static class Program
+{
+ private static int Main()
+ {
+ try
+ {
+ VerifyForwardedHooks();
+ RejectFacade("reordered arguments", fixture =>
+ {
+ var instructions = fixture.Ordered.Body.Instructions;
+ instructions[0].OpCode = OpCodes.Ldarg_1;
+ instructions[1].OpCode = OpCodes.Ldarg_0;
+ });
+ RejectFacade("transformed result", fixture =>
+ {
+ var instructions = fixture.CurrentLanguage.Body.Instructions;
+ instructions.Insert(instructions.Count - 1, Instruction.Create(OpCodes.Ldc_I4_1));
+ instructions.Insert(instructions.Count - 1, Instruction.Create(OpCodes.Add));
+ });
+ RejectFacade("recursive alias", fixture =>
+ fixture.CurrentLanguage.Body.Instructions[0].Operand = fixture.CurrentLanguage);
+ RejectFacade("instance facade", fixture =>
+ {
+ fixture.CurrentLanguage.IsStatic = false;
+ fixture.CurrentLanguage.HasThis = true;
+ });
+ RejectFacade("instance target", fixture =>
+ ((MethodReference)fixture.CurrentLanguage.Body.Instructions[0].Operand).HasThis = true);
+ Console.WriteLine("PASS: forwarded targets, legacy signatures, subscription pairs, ordinary hooks, and 5 invalid facades.");
+ return 0;
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine(exception);
+ return 1;
+ }
+ }
+
+ private static void VerifyForwardedHooks()
+ {
+ using var fixture = new Fixture();
+ fixture.CurrentLanguage.Body.Instructions.Insert(0, Instruction.Create(OpCodes.Nop));
+ using var output = fixture.Generate();
+ var language = output.GetType("On.Legacy.Language");
+
+ foreach (var method in fixture.Language.Methods)
+ {
+ var target = fixture.TargetLanguage.Methods.Single(candidate => candidate.Name == method.Name);
+ VerifyEndpoints(output, "Legacy.Language", method.Name, target);
+ }
+
+ foreach (string prefix in new[] { "orig_", "hook_" })
+ {
+ var current = language.NestedTypes.Single(type => type.Name == prefix + "CurrentLanguage")
+ .Methods.Single(method => method.Name == "Invoke");
+ Require(IsLegacyEnum(current.ReturnType), prefix + "CurrentLanguage must return the legacy enum");
+
+ var change = language.NestedTypes.Single(type => type.Name == prefix + "SwitchLanguage")
+ .Methods.Single(method => method.Name == "Invoke");
+ Require(IsLegacyEnum(change.Parameters.Last().ParameterType), prefix + "SwitchLanguage must accept the legacy enum");
+ }
+
+ VerifyEndpoints(output, "Legacy.Ordinary", "Proxy", fixture.Ordinary);
+ Require(output.GetType("On.Modding.ForwardedHooksAttribute") != null,
+ "the marker attribute's ordinary constructor must not be treated as a forwarding facade");
+ }
+
+ private static bool IsLegacyEnum(TypeReference type) =>
+ type.FullName == "Legacy.LanguageCode" && type.Scope.Name == "ForwardingFacade";
+
+ private static void VerifyEndpoints(ModuleDefinition output, string typeName, string eventName, MethodDefinition target)
+ {
+ foreach (string hookNamespace in new[] { "On", "IL" })
+ {
+ var hookType = output.GetType(hookNamespace + "." + typeName);
+ var hookEvent = hookType.Events.Single(candidate => candidate.Name == eventName);
+ string delegateName = "On." + typeName + "/hook_" + eventName;
+ VerifyEndpoint(hookEvent.AddMethod, target, hookNamespace == "On" ? "Add" : "Modify", delegateName);
+ VerifyEndpoint(hookEvent.RemoveMethod, target, hookNamespace == "On" ? "Remove" : "Unmodify", delegateName);
+ Require(hookEvent.AddMethod.Parameters.Single().ParameterType.FullName == hookEvent.EventType.FullName,
+ hookType.FullName + ": add must use the event's delegate type");
+ Require(hookEvent.RemoveMethod.Parameters.Single().ParameterType.FullName == hookEvent.EventType.FullName,
+ hookType.FullName + ": remove must use the event's delegate type");
+ }
+ }
+
+ private static void VerifyEndpoint(MethodDefinition endpoint, MethodDefinition target, string operation, string delegateName)
+ {
+ var token = (MethodReference)endpoint.Body.Instructions.Single(instruction => instruction.OpCode == OpCodes.Ldtoken).Operand;
+ Require(token.FullName == target.FullName && token.DeclaringType.Scope.Name == target.Module.Assembly.Name.Name,
+ endpoint.FullName + ": must address the intended method in the intended assembly");
+ var managerCall = endpoint.Body.Instructions
+ .Select(instruction => instruction.Operand as MethodReference)
+ .Single(method => method?.DeclaringType.FullName == "MonoMod.RuntimeDetour.HookGen.HookEndpointManager");
+ Require(managerCall.Name == operation, endpoint.FullName + ": wrong subscription operation");
+ var hookDelegate = ((GenericInstanceMethod)managerCall).GenericArguments.Single();
+ Require(hookDelegate.FullName == delegateName, endpoint.FullName + ": subscription must retain the legacy delegate");
+ }
+
+ private static void RejectFacade(string description, Action invalidate)
+ {
+ using var fixture = new Fixture();
+ invalidate(fixture);
+ try
+ {
+ using var output = fixture.Generate();
+ }
+ catch (InvalidOperationException exception) when (exception.Message.StartsWith("Forwarded hook method '", StringComparison.Ordinal))
+ {
+ return;
+ }
+ throw new InvalidOperationException("Accepted invalid facade: " + description);
+ }
+
+ private static void Require(bool condition, string message)
+ {
+ if (!condition) throw new InvalidOperationException(message);
+ }
+
+ private sealed class Fixture : IDisposable
+ {
+ private readonly FixtureResolver resolver = new FixtureResolver();
+ private readonly ModuleDefinition source;
+ private readonly ModuleDefinition target;
+ private MemoryStream generatedImage;
+
+ public TypeDefinition Language { get; }
+ public TypeDefinition TargetLanguage { get; }
+ public MethodDefinition CurrentLanguage { get; }
+ public MethodDefinition Ordered { get; }
+ public MethodDefinition Ordinary { get; }
+
+ public Fixture()
+ {
+ resolver.AddSearchDirectory(AppDomain.CurrentDomain.BaseDirectory);
+ source = CreateModule("ForwardingFacade");
+ target = CreateModule("ForwardingTarget");
+ source.ImportReference(typeof(EditorBrowsableState));
+ var legacyEnum = AddEnum(source, "Legacy");
+ var currentEnum = AddEnum(target, "Current");
+ Language = AddClass(source, "Legacy", "Language");
+ TargetLanguage = AddClass(target, "Current", "Language");
+
+ var marker = AddClass(source, "Modding", "ForwardedHooksAttribute");
+ marker.Attributes = TypeAttributes.NotPublic | TypeAttributes.Sealed;
+ marker.BaseType = source.ImportReference(typeof(Attribute));
+ var constructor = new MethodDefinition(".ctor", MethodAttributes.Public | MethodAttributes.SpecialName |
+ MethodAttributes.RTSpecialName, source.TypeSystem.Void) { HasThis = true };
+ marker.Methods.Add(constructor);
+ constructor.Body.Instructions.Add(Instruction.Create(OpCodes.Ldarg_0));
+ constructor.Body.Instructions.Add(Instruction.Create(OpCodes.Call,
+ new MethodReference(".ctor", source.TypeSystem.Void, marker.BaseType) { HasThis = true }));
+ constructor.Body.Instructions.Add(Instruction.Create(OpCodes.Ret));
+ Language.CustomAttributes.Add(new CustomAttribute(constructor));
+
+ CurrentLanguage = Forward("CurrentLanguage", legacyEnum, currentEnum, Array.Empty(), Array.Empty());
+ Forward("SwitchLanguage", source.TypeSystem.Void, target.TypeSystem.Void, new[] { legacyEnum }, new[] { currentEnum });
+ Ordered = Forward("Ordered", source.TypeSystem.Int32, target.TypeSystem.Int32,
+ Enumerable.Repeat(source.TypeSystem.Int32, 6).ToArray(),
+ Enumerable.Repeat(target.TypeSystem.Int32, 6).ToArray());
+ var ordinaryType = AddClass(source, "Legacy", "Ordinary");
+ Ordinary = AddMethod(ordinaryType, "Proxy", source.TypeSystem.Int32, Array.Empty());
+ var ordinaryTarget = AddMethod(TargetLanguage, "Proxy", target.TypeSystem.Int32, Array.Empty());
+ ordinaryTarget.Body.Instructions.Add(Instruction.Create(OpCodes.Ldc_I4_1));
+ ordinaryTarget.Body.Instructions.Add(Instruction.Create(OpCodes.Ret));
+ Ordinary.Body.Instructions.Add(Instruction.Create(OpCodes.Call, source.ImportReference(ordinaryTarget)));
+ Ordinary.Body.Instructions.Add(Instruction.Create(OpCodes.Ret));
+ }
+
+ public ModuleDefinition Generate()
+ {
+ using var modder = new MonoModder { Module = source, AssemblyResolver = resolver };
+ modder.DependencyDirs.Add(AppDomain.CurrentDomain.BaseDirectory);
+ modder.DependencyCache.Add(target.Assembly.Name.Name, target);
+ var generator = new HookGenerator(modder, "MMHOOK_ForwardingFacade.dll") { HookPrivate = true };
+ using var generated = generator.OutputModule;
+ generator.Generate();
+ generatedImage = new MemoryStream();
+ generated.Write(generatedImage);
+ generatedImage.Position = 0;
+ return ModuleDefinition.ReadModule(generatedImage, new ReaderParameters { ReadingMode = ReadingMode.Immediate });
+ }
+
+ private ModuleDefinition CreateModule(string name)
+ {
+ var module = ModuleDefinition.CreateModule(name, new ModuleParameters
+ {
+ Kind = ModuleKind.Dll,
+ AssemblyResolver = resolver
+ });
+ resolver.Register(module.Assembly);
+ return module;
+ }
+
+ private MethodDefinition Forward(string name, TypeReference oldReturn, TypeReference newReturn,
+ TypeReference[] oldArguments, TypeReference[] newArguments)
+ {
+ var original = AddMethod(TargetLanguage, name, newReturn, newArguments);
+ if (newReturn.MetadataType != MetadataType.Void)
+ original.Body.Instructions.Add(Instruction.Create(OpCodes.Ldc_I4_1));
+ original.Body.Instructions.Add(Instruction.Create(OpCodes.Ret));
+ var facade = AddMethod(Language, name, oldReturn, oldArguments);
+ var shortLoads = new[] { OpCodes.Ldarg_0, OpCodes.Ldarg_1, OpCodes.Ldarg_2, OpCodes.Ldarg_3 };
+ for (int index = 0; index < oldArguments.Length; index++)
+ facade.Body.Instructions.Add(index < shortLoads.Length
+ ? Instruction.Create(shortLoads[index])
+ : Instruction.Create(index == 4 ? OpCodes.Ldarg_S : OpCodes.Ldarg, facade.Parameters[index]));
+ facade.Body.Instructions.Add(Instruction.Create(OpCodes.Call, source.ImportReference(original)));
+ facade.Body.Instructions.Add(Instruction.Create(OpCodes.Ret));
+ return facade;
+ }
+
+ private static TypeDefinition AddClass(ModuleDefinition module, string ns, string name)
+ {
+ var type = new TypeDefinition(ns, name, TypeAttributes.Public | TypeAttributes.Abstract |
+ TypeAttributes.Sealed, module.TypeSystem.Object);
+ module.Types.Add(type);
+ return type;
+ }
+
+ private static TypeDefinition AddEnum(ModuleDefinition module, string ns)
+ {
+ var type = new TypeDefinition(ns, "LanguageCode", TypeAttributes.Public | TypeAttributes.Sealed,
+ module.ImportReference(typeof(Enum)));
+ type.Fields.Add(new FieldDefinition("value__", FieldAttributes.Public | FieldAttributes.SpecialName |
+ FieldAttributes.RTSpecialName, module.TypeSystem.Int32));
+ module.Types.Add(type);
+ return type;
+ }
+
+ private static MethodDefinition AddMethod(TypeDefinition type, string name, TypeReference result, TypeReference[] arguments)
+ {
+ var method = new MethodDefinition(name, MethodAttributes.Public | MethodAttributes.Static, result);
+ foreach (var argument in arguments) method.Parameters.Add(new ParameterDefinition(argument));
+ type.Methods.Add(method);
+ return method;
+ }
+
+ public void Dispose()
+ {
+ source.Dispose();
+ target.Dispose();
+ resolver.Dispose();
+ generatedImage?.Dispose();
+ }
+ }
+
+ private sealed class FixtureResolver : DefaultAssemblyResolver
+ {
+ public void Register(AssemblyDefinition assembly) => RegisterAssembly(assembly);
+ }
+}
diff --git a/Tests/HookGen/README.md b/Tests/HookGen/README.md
new file mode 100644
index 00000000..f28a0059
--- /dev/null
+++ b/Tests/HookGen/README.md
@@ -0,0 +1,14 @@
+# Hook generator regression tests
+
+From the repository root:
+
+```sh
+dotnet build Tests/HookGen --configuration Release
+mono Tests/HookGen/bin/Release/net472/HookGen.exe
+```
+
+On Windows, run the executable directly. These tests need no Hollow Knight files. They generate independent facade and target assemblies in memory, run the real hook generator, and inspect the serialized result for legacy signatures, external target references, matching subscription operations, unchanged ordinary hooks, and rejection of invalid forwarding methods.
+
+Forwarded methods must preserve the target's calling convention and compatible parameter and return types. Distinct enum types with the same underlying type are supported; the forwarding-body check does not establish type compatibility for other conversions.
+
+These checks validate generated assembly contracts. Native detours and game behavior require a separate runtime smoke test.
From 5ee8ec1d85f387167a5a0f45fbd11aac0be7cd00 Mon Sep 17 00:00:00 2001
From: Zheng-Yu7463 <982563036@qq.com>
Date: Tue, 13 Oct 2026 20:15:51 +0800
Subject: [PATCH 2/3] Document Steam app id requirement for Rosetta launches
---
README.ModdingApi.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/README.ModdingApi.md b/README.ModdingApi.md
index 949b1e41..99f36999 100644
--- a/README.ModdingApi.md
+++ b/README.ModdingApi.md
@@ -13,13 +13,13 @@ Place each mod in its own subdirectory of `Managed/Mods`. The title screen lists
## Apple Silicon
-MonoMod 25.3.6 requires an x86-64 game process on macOS. Use the `osx-x64` package and Rosetta. For example, replace the application path below with the location of your installation:
+Steam builds refuse to start when Steam initialization fails, and MonoMod 25.3.6 requires an x86-64 game process on macOS. Use the `osx-x64` package and launch through Rosetta with the app id environment variables set, keeping the Steam client running. Replace the application path below with the location of your installation:
```sh
-arch -x86_64 "/path/to/hollow_knight.app/Contents/MacOS/Hollow Knight"
+SteamAppId=367520 SteamGameId=367520 arch -x86_64 "/path/to/hollow_knight.app/Contents/MacOS/Hollow Knight"
```
-Launching the universal game normally on Apple Silicon selects arm64, which this version of MonoMod does not support. A universal native dependency alone does not provide arm64 runtime hook support.
+Launching the universal game normally, whether directly or through Steam, selects arm64, which this version of MonoMod does not support. A universal native dependency alone does not provide arm64 runtime hook support.
## Removal
From f0057c4950a9078da7699f13b050351a28b7ba8d Mon Sep 17 00:00:00 2001
From: Zheng-Yu7463 <982563036@qq.com>
Date: Tue, 13 Oct 2026 20:58:42 +0800
Subject: [PATCH 3/3] Build universal macOS native libraries
The macOS build copied only the osx-x64 native libraries from the
unityscenerepacker package, while CI merged them with the osx-arm64 ones into
universal binaries afterwards. Local builds therefore shipped x86-64-only
libraries: the game launches natively on Apple Silicon, but
UnitySceneRepacker fails to load in an arm64 process and Benchwarp teleports
time out.
Move the merge into the Assembly-CSharp build itself and let CI install its
lipo binary before the build, so every macOS build produces the same
universal libraries. The x86-64 slice is unchanged for Rosetta launches.
Verified on an M4 with Hollow Knight 1.5.12620: the arm64 game process loads
UnitySceneRepacker and completes a Benchwarp teleport to Town, and the
x86-64 slices of the rebuilt libraries match the previously installed ones
byte for byte.
---
.github/workflows/build.yaml | 16 +++++++---------
Assembly-CSharp/Assembly-CSharp.csproj | 15 ++++++++++++++-
README.ModdingApi.md | 8 ++++----
README.md | 2 +-
4 files changed, 26 insertions(+), 15 deletions(-)
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 17a9052e..822e8e37 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -67,18 +67,16 @@ jobs:
run: |
sudo apt-get update -y
sudo apt-get install -y mono-devel
+ - name: Install lipo
+ if: matrix.platform == 'macos'
+ run: |
+ mkdir -p "$HOME/.local/bin"
+ wget https://github.com/konoui/lipo/releases/download/v0.9.4/lipo_linux_amd64 -O "$HOME/.local/bin/lipo"
+ chmod +x "$HOME/.local/bin/lipo"
+ echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Build Assembly-CSharp
run: |
dotnet build Assembly-CSharp --runtime "$RID" --configuration Release
- - name: Merge osx dylibs
- if: matrix.platform == 'macos'
- run: |
- wget https://github.com/konoui/lipo/releases/download/v0.9.4/lipo_linux_amd64 -O lipo
- chmod +x lipo
- for native_library in ./Assembly-CSharp/packages/unityscenerepacker/2.4.0/runtimes/osx-x64/native/*.dylib; do
- library_name=$(basename "$native_library")
- ./lipo -output "OutputFinal/$library_name" -create "$native_library" "./Assembly-CSharp/packages/unityscenerepacker/2.4.0/runtimes/osx-arm64/native/$library_name"
- done
- name: Upload Binary
if: inputs.upload-artifact
uses: actions/upload-artifact@v7
diff --git a/Assembly-CSharp/Assembly-CSharp.csproj b/Assembly-CSharp/Assembly-CSharp.csproj
index 01c34660..08400389 100644
--- a/Assembly-CSharp/Assembly-CSharp.csproj
+++ b/Assembly-CSharp/Assembly-CSharp.csproj
@@ -42,6 +42,7 @@
../OutputFinal
+ lipo
mono
@@ -109,7 +110,19 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/README.ModdingApi.md b/README.ModdingApi.md
index 99f36999..7973465e 100644
--- a/README.ModdingApi.md
+++ b/README.ModdingApi.md
@@ -1,6 +1,6 @@
# Hollow Knight Modding API
-This development build targets Hollow Knight 1.5.12620 (Unity 6). Use a build that matches both the game version and the game process architecture. Compatibility with every existing mod has not been established.
+This development build targets Hollow Knight 1.5.12620 (Unity 6). Use a build that matches the game version. The macOS build contains universal native libraries for both x86-64 and arm64 game processes. Compatibility with every existing mod has not been established.
## Installation
@@ -13,13 +13,13 @@ Place each mod in its own subdirectory of `Managed/Mods`. The title screen lists
## Apple Silicon
-Steam builds refuse to start when Steam initialization fails, and MonoMod 25.3.6 requires an x86-64 game process on macOS. Use the `osx-x64` package and launch through Rosetta with the app id environment variables set, keeping the Steam client running. Replace the application path below with the location of your installation:
+The macOS package contains universal (x86-64 and arm64) native libraries, so the game runs natively on Apple Silicon as well as through Rosetta. Steam builds refuse to start when Steam initialization fails, so launch the game from Steam, or, when starting the executable directly, keep the Steam client running and set the app id environment variables:
```sh
-SteamAppId=367520 SteamGameId=367520 arch -x86_64 "/path/to/hollow_knight.app/Contents/MacOS/Hollow Knight"
+SteamAppId=367520 SteamGameId=367520 "/path/to/hollow_knight.app/Contents/MacOS/Hollow Knight"
```
-Launching the universal game normally, whether directly or through Steam, selects arm64, which this version of MonoMod does not support. A universal native dependency alone does not provide arm64 runtime hook support.
+Steam sets these variables itself when the game is started from the Steam library, where no wrapper script is needed.
## Removal
diff --git a/README.md b/README.md
index 81dc6508..5bd112af 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,7 @@ Use `win-x64` on Windows or `linux-x64` on Linux. The project builds its patchin
The patcher disables method inlining in modified game assemblies so runtime hooks can observe calls reliably.
-On Apple Silicon, use `osx-x64` and run the game through Rosetta for compatibility testing. MonoMod 25.3.6 does not support native `osx-arm64` runtime detours. The native dependency packager can select arm64 files, and CI produces universal macOS libraries, but neither establishes native arm64 mod compatibility.
+On Apple Silicon, use `osx-x64`. The build merges the x86-64 and arm64 native libraries into universal binaries with `lipo`, which ships with the macOS command line tools, so the package works with both native and Rosetta game processes. The GitHub Actions macOS job installs [konoui/lipo](https://github.com/konoui/lipo) on its Linux runner for the same merge. Runtime hooks, including mods such as Benchwarp that use native detours, run in a native arm64 game process.
Builds leave the game installation untouched unless `CopyDir` is explicitly supplied. To install the result, close the game and copy the contents of `OutputFinal` into its `Managed` folder, or build with `-p:CopyDir="/absolute/path/to/Managed"`.