diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 241abac..822e8e3 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -67,26 +67,16 @@ 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
+ - name: Install lipo
+ if: matrix.platform == 'macos'
run: |
- dotnet build TeamCherry.Localization --runtime $RID -p:Configuration=Release
+ 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 -p: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/
+ dotnet build Assembly-CSharp --runtime "$RID" --configuration Release
- 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 f28e5f9..0840038 100644
--- a/Assembly-CSharp/Assembly-CSharp.csproj
+++ b/Assembly-CSharp/Assembly-CSharp.csproj
@@ -42,43 +42,37 @@
../OutputFinal
-
- C:/Program Files (x86)/Steam/steamapps/common/Hollow Knight
- $(HOME)/.local/share/Steam/steamapps/common/Hollow Knight
- $(GamePath)/hollow_knight_Data/Managed
+ lipo
mono
-
-
-
+
-
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -97,37 +91,38 @@
-
-
+
+
+
+
+
-
+
-
-
+
+
+
+
+
+
+
+
-
-
- .dll
- lib
- .so
- lib
- .dylib
-
-
-
-
-
+
+
+
+
-
+
@@ -187,4 +182,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 0000000..b5a68af
--- /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 2a51ed7..6af4c19 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 e9d5006..1113c84 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 2c85147..c40d8f3 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 f403b35..74d30f5 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 925117d..0f8ba28 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 df3bc36..e346dca 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 b6ff546..07bc0b2 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 097c3bb..7973465 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 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.
-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
-================
+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:
-Steam: Right Click on Hollow Knight -> Properties -> Local Files -> Verify Integrity of Game Files
-GoG: Options -> Manage Installation -> Verify / Repair
+```sh
+SteamAppId=367520 SteamGameId=367520 "/path/to/hollow_knight.app/Contents/MacOS/Hollow Knight"
+```
+
+Steam sets these variables itself when the game is started from the Steam library, where no wrapper script is needed.
+
+## 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 5bcee0f..5bd112a 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`. 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"`.
+
+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 0000000..4e997ad
--- /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 0000000..1668832
--- /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 0000000..f28a005
--- /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.