Initial commit: LegacyForge mod loader for Minecraft Legacy Edition

SKSE-style external mod loader with zero game source modifications.
- LegacyForge.Launcher: C# console app that injects runtime DLL into game process
- LegacyForgeRuntime: C++ DLL with PDB symbol resolution, MinHook function hooking, and .NET CoreCLR hosting
- LegacyForge.Core: C# mod discovery and lifecycle management
- LegacyForge.API: Fabric-style mod API with namespaced string IDs, fluent property builders, and event system
- ExampleMod: Sample mod demonstrating block/item registration
This commit is contained in:
Jacobwasbeast
2026-03-06 15:11:53 -06:00
commit de22a24100
45 changed files with 2489 additions and 0 deletions

51
.gitignore vendored Normal file
View File

@@ -0,0 +1,51 @@
# Build outputs
bin/
obj/
out/
build/
x64/
x86/
Debug/
Release/
RelWithDebInfo/
MinSizeRel/
*.exe
*.dll
*.lib
*.pdb
*.ilk
*.exp
*.obj
*.o
# .NET
*.user
*.suo
*.vs/
.vs/
*.nupkg
project.lock.json
TestResults/
# CMake
CMakeCache.txt
CMakeFiles/
cmake_install.cmake
Makefile
*.cmake
!CMakeLists.txt
!cmake/*.cmake
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Config (user-specific)
legacyforge.json

48
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,48 @@
# Contributing to LegacyForge
Thank you for your interest in contributing to LegacyForge!
## Project Structure
- **LegacyForge.Launcher/** -- C# console app that launches the game and injects the runtime DLL
- **LegacyForgeRuntime/** -- C++ DLL injected into the game process (hooks, .NET hosting, native exports)
- **LegacyForge.Core/** -- C# assembly loaded inside the game process (mod discovery, lifecycle management)
- **LegacyForge.API/** -- C# class library that mod authors reference (IMod, Registry, Events)
- **ExampleMod/** -- Sample mod demonstrating the API
## Building
### Prerequisites
- Visual Studio 2022+ with C++ Desktop Development and .NET 8 workloads
- CMake 3.24+
- .NET 8.0 SDK
### C++ Runtime
```bash
cd LegacyForgeRuntime
cmake -B build -A x64
cmake --build build --config Release
```
### C# Projects
```bash
dotnet build LegacyForge.sln -c Release
```
## Guidelines
- Keep game source modifications at zero -- all integration is via injection and hooking
- Test hooks against the latest game build with PDB symbols
- Use namespaced string IDs (`"modid:name"`) for all registered content
- Catch exceptions from mod code to prevent crashes
- Document public API methods with XML doc comments
## Pull Requests
- Fork the repository and create a feature branch
- Include a clear description of what your PR does
- Make sure the C# projects build without warnings
- Test DLL injection on a real game build if possible

52
ExampleMod/ExampleMod.cs Normal file
View File

@@ -0,0 +1,52 @@
using LegacyForge.API;
using LegacyForge.API.Block;
using LegacyForge.API.Item;
using LegacyForge.API.Events;
namespace ExampleMod;
[Mod("examplemod", Name = "Example Mod", Version = "1.0.0", Author = "LegacyForge",
Description = "A sample mod demonstrating the LegacyForge API")]
public class ExampleMod : IMod
{
public static RegisteredBlock? RubyOre;
public static RegisteredItem? Ruby;
public void OnInitialize()
{
RubyOre = Registry.Block.Register("examplemod:ruby_ore",
new BlockProperties()
.Material(MaterialType.Stone)
.Hardness(3.0f)
.Resistance(15f)
.Sound(SoundType.Stone)
.Icon("ruby_ore"));
Ruby = Registry.Item.Register("examplemod:ruby",
new ItemProperties().MaxStackSize(64));
Registry.Recipe.AddFurnace("examplemod:ruby_ore", "examplemod:ruby", 1.0f);
GameEvents.OnBlockBreak += OnBlockBroken;
Logger.Info("Example Mod initialized! Ruby ore and ruby registered.");
}
private void OnBlockBroken(object? sender, BlockBreakEventArgs e)
{
if (RubyOre != null && e.BlockId == RubyOre.StringId.ToString())
{
Logger.Info($"Ruby ore broken at ({e.X}, {e.Y}, {e.Z})!");
}
}
public void OnTick()
{
// Per-tick logic goes here
}
public void OnShutdown()
{
Logger.Info("Example Mod shutting down.");
}
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>ExampleMod</RootNamespace>
<AssemblyName>ExampleMod</AssemblyName>
<Description>Example mod for LegacyForge demonstrating the mod API</Description>
<Version>1.0.0</Version>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LegacyForge.API\LegacyForge.API.csproj" />
</ItemGroup>
</Project>

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 LegacyForge Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,58 @@
namespace LegacyForge.API.Block;
public enum MaterialType
{
Air = 0,
Stone = 1,
Wood = 2,
Cloth = 3,
Plant = 4,
Dirt = 5,
Sand = 6,
Glass = 7,
Water = 8,
Lava = 9,
Ice = 10,
Metal = 11,
Snow = 12,
Clay = 13,
Explosive = 14,
Web = 15
}
public enum SoundType
{
None = 0,
Stone = 1,
Wood = 2,
Gravel = 3,
Grass = 4,
Metal = 5,
Glass = 6,
Cloth = 7,
Sand = 8,
Snow = 9
}
/// <summary>
/// Fluent builder for defining block properties.
/// </summary>
public class BlockProperties
{
internal MaterialType MaterialValue = MaterialType.Stone;
internal float HardnessValue = 1.0f;
internal float ResistanceValue = 5.0f;
internal SoundType SoundValue = SoundType.Stone;
internal string IconValue = "stone";
internal float LightEmissionValue = 0.0f;
internal int LightBlockValue = 255;
public BlockProperties Material(MaterialType material) { MaterialValue = material; return this; }
public BlockProperties Hardness(float hardness) { HardnessValue = hardness; return this; }
public BlockProperties Resistance(float resistance) { ResistanceValue = resistance; return this; }
public BlockProperties Sound(SoundType sound) { SoundValue = sound; return this; }
public BlockProperties Icon(string iconName) { IconValue = iconName; return this; }
public BlockProperties LightLevel(float level) { LightEmissionValue = level; return this; }
public BlockProperties LightBlocking(int level) { LightBlockValue = level; return this; }
public BlockProperties Indestructible() { HardnessValue = -1.0f; ResistanceValue = 6000000f; return this; }
}

View File

@@ -0,0 +1,51 @@
namespace LegacyForge.API.Block;
/// <summary>
/// Represents a block that has been registered with the game engine.
/// </summary>
public class RegisteredBlock
{
/// <summary>The namespaced string ID (e.g. "mymod:ruby_ore").</summary>
public Identifier StringId { get; }
/// <summary>The numeric ID allocated by the engine.</summary>
public int NumericId { get; }
internal RegisteredBlock(Identifier id, int numericId)
{
StringId = id;
NumericId = numericId;
}
}
/// <summary>
/// Block registration via the LegacyForge registry.
/// Accessed through <see cref="Registry.Block"/>.
/// </summary>
public static class BlockRegistry
{
/// <summary>
/// Register a new block with the game engine.
/// </summary>
/// <param name="id">Namespaced identifier (e.g. "mymod:ruby_ore").</param>
/// <param name="properties">Block properties built with <see cref="BlockProperties"/>.</param>
/// <returns>A handle to the registered block.</returns>
public static RegisteredBlock Register(Identifier id, BlockProperties properties)
{
int numericId = NativeInterop.native_register_block(
id.ToString(),
(int)properties.MaterialValue,
properties.HardnessValue,
properties.ResistanceValue,
(int)properties.SoundValue,
properties.IconValue,
properties.LightEmissionValue,
properties.LightBlockValue);
if (numericId < 0)
throw new InvalidOperationException($"Failed to register block '{id}'. No free IDs or invalid parameters.");
Logger.Debug($"Registered block '{id}' -> numeric ID {numericId}");
return new RegisteredBlock(id, numericId);
}
}

View File

@@ -0,0 +1,16 @@
namespace LegacyForge.API.Entity;
/// <summary>
/// Fluent builder for defining entity properties.
/// </summary>
public class EntityDefinition
{
internal float WidthValue = 0.6f;
internal float HeightValue = 1.8f;
internal int TrackingRangeValue = 80;
public EntityDefinition Width(float width) { WidthValue = width; return this; }
public EntityDefinition Height(float height) { HeightValue = height; return this; }
public EntityDefinition TrackingRange(int range) { TrackingRangeValue = range; return this; }
public EntityDefinition Size(float width, float height) { WidthValue = width; HeightValue = height; return this; }
}

View File

@@ -0,0 +1,38 @@
namespace LegacyForge.API.Entity;
/// <summary>
/// Represents an entity type that has been registered with the game engine.
/// </summary>
public class RegisteredEntity
{
public Identifier StringId { get; }
public int NumericId { get; }
internal RegisteredEntity(Identifier id, int numericId)
{
StringId = id;
NumericId = numericId;
}
}
/// <summary>
/// Entity registration via the LegacyForge registry.
/// Accessed through <see cref="Registry.Entity"/>.
/// </summary>
public static class EntityRegistry
{
public static RegisteredEntity Register(Identifier id, EntityDefinition definition)
{
int numericId = NativeInterop.native_register_entity(
id.ToString(),
definition.WidthValue,
definition.HeightValue,
definition.TrackingRangeValue);
if (numericId < 0)
throw new InvalidOperationException($"Failed to register entity '{id}'.");
Logger.Debug($"Registered entity '{id}' -> numeric ID {numericId}");
return new RegisteredEntity(id, numericId);
}
}

View File

@@ -0,0 +1,60 @@
namespace LegacyForge.API.Events;
public class TickEventArgs : EventArgs { }
public class BlockBreakEventArgs : EventArgs
{
public string BlockId { get; init; } = "";
public int X { get; init; }
public int Y { get; init; }
public int Z { get; init; }
public int PlayerId { get; init; }
}
public class BlockPlaceEventArgs : EventArgs
{
public string BlockId { get; init; } = "";
public int X { get; init; }
public int Y { get; init; }
public int Z { get; init; }
public int PlayerId { get; init; }
}
public class ChatEventArgs : EventArgs
{
public string Message { get; init; } = "";
public int PlayerId { get; init; }
}
public class EntitySpawnEventArgs : EventArgs
{
public string EntityId { get; init; } = "";
public float X { get; init; }
public float Y { get; init; }
public float Z { get; init; }
}
public class PlayerJoinEventArgs : EventArgs
{
public int PlayerId { get; init; }
public string PlayerName { get; init; } = "";
}
/// <summary>
/// Global game event subscriptions. Subscribe to these in your mod's OnInitialize().
/// Events are fired from the game's main thread via hooks in LegacyForgeRuntime.
/// </summary>
public static class GameEvents
{
public static event EventHandler<BlockBreakEventArgs>? OnBlockBreak;
public static event EventHandler<BlockPlaceEventArgs>? OnBlockPlace;
public static event EventHandler<ChatEventArgs>? OnChat;
public static event EventHandler<EntitySpawnEventArgs>? OnEntitySpawn;
public static event EventHandler<PlayerJoinEventArgs>? OnPlayerJoin;
internal static void FireBlockBreak(BlockBreakEventArgs e) => OnBlockBreak?.Invoke(null, e);
internal static void FireBlockPlace(BlockPlaceEventArgs e) => OnBlockPlace?.Invoke(null, e);
internal static void FireChat(ChatEventArgs e) => OnChat?.Invoke(null, e);
internal static void FireEntitySpawn(EntitySpawnEventArgs e) => OnEntitySpawn?.Invoke(null, e);
internal static void FirePlayerJoin(PlayerJoinEventArgs e) => OnPlayerJoin?.Invoke(null, e);
}

39
LegacyForge.API/IMod.cs Normal file
View File

@@ -0,0 +1,39 @@
namespace LegacyForge.API;
/// <summary>
/// The main interface all LegacyForge mods must implement.
/// Default interface methods allow mods to only override what they need.
/// </summary>
public interface IMod
{
/// <summary>
/// Called before vanilla registries are populated.
/// Use for very early setup that must happen before any game content loads.
/// </summary>
void OnPreInit() { }
/// <summary>
/// Called after vanilla registries are populated.
/// This is the main initialization point -- register your blocks, items,
/// entities, recipes, and event handlers here.
/// </summary>
void OnInitialize();
/// <summary>
/// Called after the game client has finished its own initialization.
/// Use for client-side setup like custom renderers or UI.
/// </summary>
void OnPostInitialize() { }
/// <summary>
/// Called once per game tick (20 times per second).
/// Use for ongoing mod logic.
/// </summary>
void OnTick() { }
/// <summary>
/// Called when the game is shutting down.
/// Use for cleanup and saving mod state.
/// </summary>
void OnShutdown() { }
}

View File

@@ -0,0 +1,39 @@
namespace LegacyForge.API;
/// <summary>
/// A namespaced identifier in the form "namespace:path" (e.g. "minecraft:stone", "mymod:ruby_ore").
/// </summary>
public readonly record struct Identifier
{
public string Namespace { get; }
public string Path { get; }
public Identifier(string ns, string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(ns);
ArgumentException.ThrowIfNullOrWhiteSpace(path);
Namespace = ns;
Path = path;
}
public Identifier(string id)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
var colonIndex = id.IndexOf(':');
if (colonIndex < 0)
{
Namespace = "minecraft";
Path = id;
}
else
{
Namespace = id[..colonIndex];
Path = id[(colonIndex + 1)..];
}
}
public override string ToString() => $"{Namespace}:{Path}";
public static implicit operator Identifier(string id) => new(id);
}

View File

@@ -0,0 +1,18 @@
namespace LegacyForge.API.Item;
/// <summary>
/// Fluent builder for defining item properties.
/// </summary>
public class ItemProperties
{
internal int MaxStackSizeValue = 64;
internal int MaxDamageValue = 0;
public ItemProperties MaxStackSize(int size) { MaxStackSizeValue = size; return this; }
/// <summary>
/// Set max damage for a tool/armor item. Setting this to a positive value
/// makes the item damageable with a durability bar.
/// </summary>
public ItemProperties MaxDamage(int damage) { MaxDamageValue = damage; MaxStackSizeValue = 1; return this; }
}

View File

@@ -0,0 +1,46 @@
namespace LegacyForge.API.Item;
/// <summary>
/// Represents an item that has been registered with the game engine.
/// </summary>
public class RegisteredItem
{
/// <summary>The namespaced string ID (e.g. "mymod:ruby").</summary>
public Identifier StringId { get; }
/// <summary>The numeric ID allocated by the engine.</summary>
public int NumericId { get; }
internal RegisteredItem(Identifier id, int numericId)
{
StringId = id;
NumericId = numericId;
}
}
/// <summary>
/// Item registration via the LegacyForge registry.
/// Accessed through <see cref="Registry.Item"/>.
/// </summary>
public static class ItemRegistry
{
/// <summary>
/// Register a new item with the game engine.
/// </summary>
/// <param name="id">Namespaced identifier (e.g. "mymod:ruby").</param>
/// <param name="properties">Item properties built with <see cref="ItemProperties"/>.</param>
/// <returns>A handle to the registered item.</returns>
public static RegisteredItem Register(Identifier id, ItemProperties properties)
{
int numericId = NativeInterop.native_register_item(
id.ToString(),
properties.MaxStackSizeValue,
properties.MaxDamageValue);
if (numericId < 0)
throw new InvalidOperationException($"Failed to register item '{id}'. No free IDs or invalid parameters.");
Logger.Debug($"Registered item '{id}' -> numeric ID {numericId}");
return new RegisteredItem(id, numericId);
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>LegacyForge.API</RootNamespace>
<AssemblyName>LegacyForge.API</AssemblyName>
<Description>Mod API for LegacyForge - Minecraft Legacy Edition mod loader</Description>
<Version>1.0.0</Version>
</PropertyGroup>
</Project>

30
LegacyForge.API/Logger.cs Normal file
View File

@@ -0,0 +1,30 @@
namespace LegacyForge.API;
public enum LogLevel
{
Debug = 0,
Info = 1,
Warning = 2,
Error = 3
}
/// <summary>
/// Logging facade that routes messages through the native runtime to the game's debug output.
/// </summary>
public static class Logger
{
internal static Action<string, LogLevel>? LogHandler;
public static void Debug(string message) => Log(message, LogLevel.Debug);
public static void Info(string message) => Log(message, LogLevel.Info);
public static void Warning(string message) => Log(message, LogLevel.Warning);
public static void Error(string message) => Log(message, LogLevel.Error);
public static void Log(string message, LogLevel level = LogLevel.Info)
{
if (LogHandler != null)
LogHandler(message, level);
else
Console.WriteLine($"[LegacyForge/{level}] {message}");
}
}

View File

@@ -0,0 +1,41 @@
namespace LegacyForge.API;
/// <summary>
/// Marks a class as a LegacyForge mod and provides metadata.
/// The class must also implement <see cref="IMod"/>.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class ModAttribute : Attribute
{
/// <summary>
/// The unique mod identifier (e.g. "examplemod"). Used as the default namespace
/// for content registered by this mod.
/// </summary>
public string Id { get; }
/// <summary>
/// Human-readable display name.
/// </summary>
public string Name { get; set; } = "";
/// <summary>
/// Semantic version string (e.g. "1.0.0").
/// </summary>
public string Version { get; set; } = "1.0.0";
/// <summary>
/// Mod author(s).
/// </summary>
public string Author { get; set; } = "";
/// <summary>
/// Short description of the mod.
/// </summary>
public string Description { get; set; } = "";
public ModAttribute(string id)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
Id = id;
}
}

View File

@@ -0,0 +1,64 @@
using System.Runtime.InteropServices;
namespace LegacyForge.API;
/// <summary>
/// Internal P/Invoke bindings to LegacyForgeRuntime.dll native exports.
/// Mod authors should use the Registry and Logger classes instead of calling these directly.
/// </summary>
internal static class NativeInterop
{
private const string RuntimeDll = "LegacyForgeRuntime";
[DllImport(RuntimeDll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
internal static extern int native_register_block(
string namespacedId,
int materialId,
float hardness,
float resistance,
int soundType,
string iconName,
float lightEmission,
int lightBlock);
[DllImport(RuntimeDll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
internal static extern int native_register_item(
string namespacedId,
int maxStackSize,
int maxDamage);
[DllImport(RuntimeDll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
internal static extern int native_register_entity(
string namespacedId,
float width,
float height,
int trackingRange);
[DllImport(RuntimeDll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
internal static extern void native_add_shaped_recipe(
string resultId,
int resultCount,
string pattern,
string ingredientIds);
[DllImport(RuntimeDll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
internal static extern void native_add_furnace_recipe(
string inputId,
string outputId,
float xp);
[DllImport(RuntimeDll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
internal static extern void native_log(string message, int level);
[DllImport(RuntimeDll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
internal static extern int native_get_block_id(string namespacedId);
[DllImport(RuntimeDll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
internal static extern int native_get_item_id(string namespacedId);
[DllImport(RuntimeDll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
internal static extern int native_get_entity_id(string namespacedId);
[DllImport(RuntimeDll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
internal static extern void native_subscribe_event(string eventName, IntPtr managedFnPtr);
}

View File

@@ -0,0 +1,40 @@
namespace LegacyForge.API.Recipe;
/// <summary>
/// Recipe registration via the LegacyForge registry.
/// Accessed through <see cref="Registry.Recipe"/>.
/// </summary>
public static class RecipeRegistry
{
/// <summary>
/// Add a shaped crafting recipe.
/// </summary>
/// <param name="result">The output item identifier.</param>
/// <param name="resultCount">Number of items produced.</param>
/// <param name="pattern">Crafting grid pattern rows (e.g. "XXX", " | ", " | " for a pickaxe).</param>
/// <param name="keys">Character-to-ingredient mappings.</param>
public static void AddShaped(Identifier result, int resultCount, string[] pattern,
params (char key, Identifier ingredient)[] keys)
{
string patternStr = string.Join(";", pattern);
string ingredientStr = string.Join(";",
keys.Select(k => $"{k.key}={k.ingredient}"));
NativeInterop.native_add_shaped_recipe(
result.ToString(), resultCount, patternStr, ingredientStr);
Logger.Debug($"Added shaped recipe -> {resultCount}x {result}");
}
/// <summary>
/// Add a furnace/smelting recipe.
/// </summary>
/// <param name="input">The input item/block identifier.</param>
/// <param name="output">The output item identifier.</param>
/// <param name="xp">Experience granted per smelt.</param>
public static void AddFurnace(Identifier input, Identifier output, float xp)
{
NativeInterop.native_add_furnace_recipe(input.ToString(), output.ToString(), xp);
Logger.Debug($"Added furnace recipe: {input} -> {output} ({xp} xp)");
}
}

View File

@@ -0,0 +1,45 @@
using LegacyForge.API.Block;
using LegacyForge.API.Item;
using LegacyForge.API.Entity;
using LegacyForge.API.Recipe;
namespace LegacyForge.API;
/// <summary>
/// Central access point for all LegacyForge registries.
/// Use Registry.Block, Registry.Item, Registry.Entity, or Registry.Recipe to register content.
/// </summary>
public static class Registry
{
/// <summary>Block registration. Call Register() with a namespaced ID and BlockProperties.</summary>
public static class Block
{
public static RegisteredBlock Register(Identifier id, BlockProperties properties)
=> BlockRegistry.Register(id, properties);
}
/// <summary>Item registration. Call Register() with a namespaced ID and ItemProperties.</summary>
public static class Item
{
public static RegisteredItem Register(Identifier id, ItemProperties properties)
=> ItemRegistry.Register(id, properties);
}
/// <summary>Entity registration. Call Register() with a namespaced ID and EntityDefinition.</summary>
public static class Entity
{
public static RegisteredEntity Register(Identifier id, EntityDefinition definition)
=> EntityRegistry.Register(id, definition);
}
/// <summary>Recipe registration for crafting and smelting.</summary>
public static class Recipe
{
public static void AddShaped(Identifier result, int count, string[] pattern,
params (char key, Identifier ingredient)[] keys)
=> RecipeRegistry.AddShaped(result, count, pattern, keys);
public static void AddFurnace(Identifier input, Identifier output, float xp)
=> RecipeRegistry.AddFurnace(input, output, xp);
}
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>LegacyForge.Core</RootNamespace>
<AssemblyName>LegacyForge.Core</AssemblyName>
<Description>LegacyForge core runtime - mod discovery and lifecycle management</Description>
<Version>1.0.0</Version>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LegacyForge.API\LegacyForge.API.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
{
"runtimeOptions": {
"tfm": "net8.0",
"rollForward": "LatestMinor",
"framework": {
"name": "Microsoft.NETCore.App",
"version": "8.0.0"
}
}
}

View File

@@ -0,0 +1,98 @@
using System.Runtime.InteropServices;
using LegacyForge.API;
namespace LegacyForge.Core;
/// <summary>
/// Entry point class loaded by the C++ DotNetHost via hostfxr.
/// All public static methods here are resolved as function pointers from native code.
/// Method signatures must match the component_entry_point_fn delegate:
/// public delegate int ComponentEntryPoint(IntPtr args, int sizeBytes);
/// </summary>
public static class LegacyForgeCore
{
private static ModManager? _modManager;
private static bool _initialized;
/// <summary>
/// Called once by C++ to initialize the managed runtime.
/// Sets up the native log handler and prepares the mod manager.
/// </summary>
public static int Initialize(IntPtr args, int sizeBytes)
{
if (_initialized) return 0;
_initialized = true;
Logger.LogHandler = (message, level) =>
{
string formatted = $"[LegacyForge/{level}] {message}";
try
{
NativeInterop.native_log(formatted, (int)level);
}
catch
{
Console.WriteLine(formatted);
}
};
Logger.Info("LegacyForge Core initialized");
_modManager = new ModManager();
return 0;
}
/// <summary>
/// Called by C++ to discover and load mod assemblies from the mods/ directory.
/// The mods path is passed as a UTF-8 string pointer.
/// </summary>
public static int DiscoverMods(IntPtr args, int sizeBytes)
{
string modsPath;
if (args != IntPtr.Zero && sizeBytes > 0)
modsPath = Marshal.PtrToStringUTF8(args, sizeBytes) ?? "mods";
else
modsPath = "mods";
Logger.Info($"Discovering mods in: {modsPath}");
var discovered = ModDiscovery.DiscoverMods(modsPath);
_modManager?.AddMods(discovered);
Logger.Info($"Loaded {discovered.Count} mod(s)");
return discovered.Count;
}
/// <summary>Called before MinecraftWorld_RunStaticCtors.</summary>
public static int PreInit(IntPtr args, int sizeBytes)
{
_modManager?.PreInit();
return 0;
}
/// <summary>Called after MinecraftWorld_RunStaticCtors.</summary>
public static int Init(IntPtr args, int sizeBytes)
{
_modManager?.Init();
return 0;
}
/// <summary>Called after Minecraft::init completes.</summary>
public static int PostInit(IntPtr args, int sizeBytes)
{
_modManager?.PostInit();
return 0;
}
/// <summary>Called each game tick from the Minecraft::tick hook.</summary>
public static int Tick(IntPtr args, int sizeBytes)
{
_modManager?.Tick();
return 0;
}
/// <summary>Called from the Minecraft::destroy hook during shutdown.</summary>
public static int Shutdown(IntPtr args, int sizeBytes)
{
_modManager?.Shutdown();
Logger.Info("LegacyForge shut down.");
return 0;
}
}

View File

@@ -0,0 +1,86 @@
using System.Reflection;
using System.Runtime.Loader;
using LegacyForge.API;
namespace LegacyForge.Core;
/// <summary>
/// Discovers and loads mod assemblies from the mods/ directory.
/// Each mod is loaded into its own AssemblyLoadContext for isolation.
/// </summary>
internal static class ModDiscovery
{
internal record DiscoveredMod(
IMod Instance,
ModAttribute Metadata,
Assembly Assembly);
internal static List<DiscoveredMod> DiscoverMods(string modsPath)
{
var mods = new List<DiscoveredMod>();
if (!Directory.Exists(modsPath))
{
Logger.Warning($"Mods directory not found: {modsPath}");
return mods;
}
var dllFiles = Directory.GetFiles(modsPath, "*.dll");
Logger.Info($"Scanning {modsPath} -- found {dllFiles.Length} DLL(s)");
foreach (var dllPath in dllFiles)
{
try
{
var discovered = LoadModAssembly(dllPath);
mods.AddRange(discovered);
}
catch (Exception ex)
{
Logger.Error($"Failed to load mod from {Path.GetFileName(dllPath)}: {ex.Message}");
}
}
return mods;
}
private static List<DiscoveredMod> LoadModAssembly(string dllPath)
{
var results = new List<DiscoveredMod>();
var fileName = Path.GetFileName(dllPath);
var loadContext = new AssemblyLoadContext(fileName, isCollectible: false);
var assembly = loadContext.LoadFromAssemblyPath(Path.GetFullPath(dllPath));
var modTypes = assembly.GetTypes()
.Where(t => t.IsClass && !t.IsAbstract && typeof(IMod).IsAssignableFrom(t));
foreach (var type in modTypes)
{
var attr = type.GetCustomAttribute<ModAttribute>();
if (attr == null)
{
Logger.Warning($"Class {type.FullName} in {fileName} implements IMod but is missing [Mod] attribute -- skipping");
continue;
}
try
{
var instance = (IMod)Activator.CreateInstance(type)!;
results.Add(new ModDiscovery.DiscoveredMod(instance, attr, assembly));
string name = string.IsNullOrEmpty(attr.Name) ? attr.Id : attr.Name;
Logger.Info($"Discovered mod: {name} v{attr.Version} by {attr.Author} ({fileName})");
}
catch (Exception ex)
{
Logger.Error($"Failed to instantiate mod {type.FullName}: {ex.Message}");
}
}
if (results.Count == 0)
Logger.Debug($"No IMod implementations found in {fileName}");
return results;
}
}

View File

@@ -0,0 +1,75 @@
using LegacyForge.API;
namespace LegacyForge.Core;
/// <summary>
/// Manages the lifecycle of all loaded mods.
/// Catches exceptions from individual mods to prevent one broken mod from crashing the game.
/// </summary>
internal class ModManager
{
private readonly List<ModDiscovery.DiscoveredMod> _mods = new();
internal int ModCount => _mods.Count;
internal void AddMods(IEnumerable<ModDiscovery.DiscoveredMod> mods)
{
_mods.AddRange(mods);
}
internal void PreInit()
{
Logger.Info("--- PreInit phase ---");
foreach (var mod in _mods)
SafeCall(mod, "OnPreInit", () => mod.Instance.OnPreInit());
}
internal void Init()
{
Logger.Info("--- Initialize phase ---");
foreach (var mod in _mods)
SafeCall(mod, "OnInitialize", () => mod.Instance.OnInitialize());
}
internal void PostInit()
{
Logger.Info("--- PostInitialize phase ---");
foreach (var mod in _mods)
SafeCall(mod, "OnPostInitialize", () => mod.Instance.OnPostInitialize());
}
internal void Tick()
{
foreach (var mod in _mods)
{
try
{
mod.Instance.OnTick();
}
catch (Exception ex)
{
Logger.Error($"[{mod.Metadata.Id}] OnTick error: {ex.Message}");
}
}
}
internal void Shutdown()
{
Logger.Info("--- Shutdown phase ---");
foreach (var mod in _mods)
SafeCall(mod, "OnShutdown", () => mod.Instance.OnShutdown());
}
private static void SafeCall(ModDiscovery.DiscoveredMod mod, string phase, Action action)
{
try
{
action();
}
catch (Exception ex)
{
Logger.Error($"[{mod.Metadata.Id}] {phase} failed: {ex.Message}");
Logger.Debug(ex.StackTrace ?? "");
}
}
}

View File

@@ -0,0 +1,35 @@
using System.Text.Json;
namespace LegacyForge.Launcher;
public class Config
{
public string? GameExePath { get; set; }
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true
};
public static Config Load(string path)
{
if (!File.Exists(path))
return new Config();
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<Config>(json, JsonOptions) ?? new Config();
}
catch
{
return new Config();
}
}
public void Save(string path)
{
var json = JsonSerializer.Serialize(this, JsonOptions);
File.WriteAllText(path, json);
}
}

View File

@@ -0,0 +1,183 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
namespace LegacyForge.Launcher;
/// <summary>
/// Handles launching the game process in a suspended state and injecting
/// the LegacyForgeRuntime DLL via CreateRemoteThread + LoadLibraryW.
/// </summary>
public static class Injector
{
public record InjectedProcess(
IntPtr ProcessHandle,
IntPtr ThreadHandle,
int ProcessId);
public static InjectedProcess LaunchSuspended(string exePath, string? workingDir = null)
{
workingDir ??= Path.GetDirectoryName(exePath);
var si = new STARTUPINFO { cb = Marshal.SizeOf<STARTUPINFO>() };
bool success = CreateProcess(
exePath,
null,
IntPtr.Zero,
IntPtr.Zero,
false,
CREATE_SUSPENDED,
IntPtr.Zero,
workingDir,
ref si,
out PROCESS_INFORMATION pi);
if (!success)
throw new Win32Exception(Marshal.GetLastWin32Error(),
$"Failed to launch process: {exePath}");
return new InjectedProcess(pi.hProcess, pi.hThread, pi.dwProcessId);
}
public static void InjectDll(InjectedProcess process, string dllPath)
{
string fullDllPath = Path.GetFullPath(dllPath);
if (!File.Exists(fullDllPath))
throw new FileNotFoundException($"Runtime DLL not found: {fullDllPath}");
byte[] dllPathBytes = Encoding.Unicode.GetBytes(fullDllPath + '\0');
IntPtr remoteMem = VirtualAllocEx(
process.ProcessHandle,
IntPtr.Zero,
(uint)dllPathBytes.Length,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE);
if (remoteMem == IntPtr.Zero)
{
TerminateProcess(process.ProcessHandle, 1);
throw new Win32Exception(Marshal.GetLastWin32Error(),
"Failed to allocate memory in target process");
}
bool wrote = WriteProcessMemory(
process.ProcessHandle,
remoteMem,
dllPathBytes,
(uint)dllPathBytes.Length,
out _);
if (!wrote)
{
TerminateProcess(process.ProcessHandle, 1);
throw new Win32Exception(Marshal.GetLastWin32Error(),
"Failed to write DLL path to target process");
}
IntPtr kernel32 = GetModuleHandle("kernel32.dll");
IntPtr loadLibraryW = GetProcAddress(kernel32, "LoadLibraryW");
IntPtr remoteThread = CreateRemoteThread(
process.ProcessHandle,
IntPtr.Zero,
0,
loadLibraryW,
remoteMem,
0,
out _);
if (remoteThread == IntPtr.Zero)
{
TerminateProcess(process.ProcessHandle, 1);
throw new Win32Exception(Marshal.GetLastWin32Error(),
"Failed to create remote thread for DLL injection");
}
WaitForSingleObject(remoteThread, 10000);
CloseHandle(remoteThread);
VirtualFreeEx(process.ProcessHandle, remoteMem, 0, MEM_RELEASE);
}
public static void ResumeProcess(InjectedProcess process)
{
ResumeThread(process.ThreadHandle);
CloseHandle(process.ThreadHandle);
}
#region Win32 Interop
private const uint CREATE_SUSPENDED = 0x00000004;
private const uint MEM_COMMIT = 0x1000;
private const uint MEM_RESERVE = 0x2000;
private const uint MEM_RELEASE = 0x8000;
private const uint PAGE_READWRITE = 0x04;
[StructLayout(LayoutKind.Sequential)]
private struct STARTUPINFO
{
public int cb;
public string lpReserved, lpDesktop, lpTitle;
public int dwX, dwY, dwXSize, dwYSize;
public int dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags;
public short wShowWindow, cbReserved2;
public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError;
}
[StructLayout(LayoutKind.Sequential)]
private struct PROCESS_INFORMATION
{
public IntPtr hProcess, hThread;
public int dwProcessId, dwThreadId;
}
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CreateProcess(
string lpApplicationName, string? lpCommandLine,
IntPtr lpProcessAttributes, IntPtr lpThreadAttributes,
bool bInheritHandles, uint dwCreationFlags,
IntPtr lpEnvironment, string? lpCurrentDirectory,
ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr VirtualAllocEx(
IntPtr hProcess, IntPtr lpAddress, uint dwSize,
uint flAllocationType, uint flProtect);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool VirtualFreeEx(
IntPtr hProcess, IntPtr lpAddress, uint dwSize, uint dwFreeType);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool WriteProcessMemory(
IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer,
uint nSize, out int lpNumberOfBytesWritten);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr CreateRemoteThread(
IntPtr hProcess, IntPtr lpThreadAttributes, uint dwStackSize,
IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags,
out uint lpThreadId);
[DllImport("kernel32.dll", CharSet = CharSet.Ansi)]
private static extern IntPtr GetModuleHandle(string lpModuleName);
[DllImport("kernel32.dll", CharSet = CharSet.Ansi)]
private static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName);
[DllImport("kernel32.dll")]
private static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
[DllImport("kernel32.dll")]
private static extern uint ResumeThread(IntPtr hThread);
[DllImport("kernel32.dll")]
private static extern bool CloseHandle(IntPtr hObject);
[DllImport("kernel32.dll")]
private static extern bool TerminateProcess(IntPtr hProcess, uint uExitCode);
#endregion
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>LegacyForge.Launcher</RootNamespace>
<AssemblyName>LegacyForge</AssemblyName>
<Description>LegacyForge launcher - injects the mod loader runtime into Minecraft Legacy Edition</Description>
<Version>1.0.0</Version>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,83 @@
namespace LegacyForge.Launcher;
class Program
{
private const string ConfigFile = "legacyforge.json";
private const string RuntimeDll = "LegacyForgeRuntime.dll";
static int Main(string[] args)
{
Console.WriteLine("╔══════════════════════════════════╗");
Console.WriteLine("║ LegacyForge v1.0 ║");
Console.WriteLine("║ Mod Loader for MC Legacy Edition║");
Console.WriteLine("╚══════════════════════════════════╝");
Console.WriteLine();
try
{
var config = Config.Load(ConfigFile);
if (args.Length > 0 && File.Exists(args[0]))
{
config.GameExePath = args[0];
config.Save(ConfigFile);
}
if (string.IsNullOrEmpty(config.GameExePath) || !File.Exists(config.GameExePath))
{
Console.Write("Enter path to MinecraftLegacy.exe: ");
string? input = Console.ReadLine()?.Trim().Trim('"');
if (string.IsNullOrEmpty(input) || !File.Exists(input))
{
Console.Error.WriteLine("Error: Invalid path or file not found.");
return 1;
}
config.GameExePath = Path.GetFullPath(input);
config.Save(ConfigFile);
Console.WriteLine($"Saved game path to {ConfigFile}");
}
if (!File.Exists(RuntimeDll))
{
Console.Error.WriteLine($"Error: {RuntimeDll} not found in current directory.");
Console.Error.WriteLine("Make sure the runtime DLL is next to LegacyForge.exe.");
return 1;
}
string modsDir = Path.Combine(AppContext.BaseDirectory, "mods");
if (!Directory.Exists(modsDir))
{
Directory.CreateDirectory(modsDir);
Console.WriteLine($"Created mods/ directory at {modsDir}");
}
int modCount = Directory.GetFiles(modsDir, "*.dll").Length;
Console.WriteLine($"Found {modCount} mod(s) in mods/");
Console.WriteLine($"Launching {Path.GetFileName(config.GameExePath)}...");
var process = Injector.LaunchSuspended(config.GameExePath);
Console.WriteLine($"Game process created (PID: {process.ProcessId}), injecting runtime...");
Injector.InjectDll(process, RuntimeDll);
Console.WriteLine("LegacyForgeRuntime.dll injected successfully.");
Injector.ResumeProcess(process);
Console.WriteLine("Game resumed. LegacyForge is active.");
Console.WriteLine();
Console.WriteLine("Press any key to exit the launcher (game will keep running).");
Console.ReadKey(true);
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Fatal error: {ex.Message}");
Console.Error.WriteLine(ex.StackTrace);
Console.WriteLine("Press any key to exit.");
Console.ReadKey(true);
return 1;
}
}
}

37
LegacyForge.sln Normal file
View File

@@ -0,0 +1,37 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LegacyForge.Launcher", "LegacyForge.Launcher\LegacyForge.Launcher.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LegacyForge.API", "LegacyForge.API\LegacyForge.API.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LegacyForge.Core", "LegacyForge.Core\LegacyForge.Core.csproj", "{C3D4E5F6-A7B8-9012-CDEF-123456789012}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExampleMod", "ExampleMod\ExampleMod.csproj", "{D4E5F6A7-B8C9-0123-DEF0-234567890123}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU
{D4E5F6A7-B8C9-0123-DEF0-234567890123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D4E5F6A7-B8C9-0123-DEF0-234567890123}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D4E5F6A7-B8C9-0123-DEF0-234567890123}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D4E5F6A7-B8C9-0123-DEF0-234567890123}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,79 @@
cmake_minimum_required(VERSION 3.24)
project(LegacyForgeRuntime LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# ── MinHook (fetched from GitHub) ──────────────────────────────────────
include(FetchContent)
FetchContent_Declare(
minhook
GIT_REPOSITORY https://github.com/TsudaKageworthy/minhook.git
GIT_TAG master
)
FetchContent_MakeAvailable(minhook)
# ── Locate .NET hosting headers and libs ───────────────────────────────
# Users can set DOTNET_ROOT or NETHOST_DIR; otherwise we try the default SDK path.
if(NOT DEFINED NETHOST_INCLUDE_DIR)
# Try to find nethost from the .NET SDK installation
file(GLOB NETHOST_CANDIDATES
"$ENV{ProgramFiles}/dotnet/packs/Microsoft.NETCore.App.Host.win-x64/*/runtimes/win-x64/native"
"$ENV{DOTNET_ROOT}/packs/Microsoft.NETCore.App.Host.win-x64/*/runtimes/win-x64/native"
)
list(SORT NETHOST_CANDIDATES ORDER DESCENDING)
if(NETHOST_CANDIDATES)
list(GET NETHOST_CANDIDATES 0 NETHOST_DIR)
endif()
endif()
if(NETHOST_DIR)
message(STATUS "Using nethost from: ${NETHOST_DIR}")
set(NETHOST_INCLUDE_DIR "${NETHOST_DIR}")
set(NETHOST_LIB "${NETHOST_DIR}/nethost.lib")
else()
message(STATUS "nethost not found automatically. Set NETHOST_DIR to the directory containing nethost.h and nethost.lib")
# Fall back to bundled headers if present
set(NETHOST_INCLUDE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/include")
set(NETHOST_LIB "")
endif()
# ── Runtime DLL target ─────────────────────────────────────────────────
add_library(LegacyForgeRuntime SHARED
src/dllmain.cpp
src/SymbolResolver.cpp
src/HookManager.cpp
src/GameHooks.cpp
src/DotNetHost.cpp
src/IdRegistry.cpp
src/NativeExports.cpp
)
target_include_directories(LegacyForgeRuntime PRIVATE
"${NETHOST_INCLUDE_DIR}"
)
target_link_libraries(LegacyForgeRuntime PRIVATE
minhook
dbghelp
)
if(EXISTS "${NETHOST_LIB}")
target_link_libraries(LegacyForgeRuntime PRIVATE "${NETHOST_LIB}")
else()
message(WARNING "nethost.lib not found. You may need to set NETHOST_DIR.")
endif()
target_compile_definitions(LegacyForgeRuntime PRIVATE
WIN32_LEAN_AND_MEAN
_CRT_SECURE_NO_WARNINGS
)
if(MSVC)
target_compile_options(LegacyForgeRuntime PRIVATE /W3 /MP)
endif()
set_target_properties(LegacyForgeRuntime PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
)

View File

@@ -0,0 +1,198 @@
#include "DotNetHost.h"
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <nethost.h>
#include <hostfxr.h>
#include <coreclr_delegates.h>
#include <cstdio>
#include <string>
// hostfxr function pointers
static hostfxr_initialize_for_runtime_config_fn init_fptr = nullptr;
static hostfxr_get_runtime_delegate_fn get_delegate_fptr = nullptr;
static hostfxr_close_fn close_fptr = nullptr;
// Managed entry points (component_entry_point_fn signature)
typedef int (CORECLR_DELEGATE_CALLTYPE *managed_entry_fn)(void* args, int sizeBytes);
static managed_entry_fn fn_Initialize = nullptr;
static managed_entry_fn fn_DiscoverMods = nullptr;
static managed_entry_fn fn_PreInit = nullptr;
static managed_entry_fn fn_Init = nullptr;
static managed_entry_fn fn_PostInit = nullptr;
static managed_entry_fn fn_Tick = nullptr;
static managed_entry_fn fn_Shutdown = nullptr;
static bool LoadHostfxr()
{
char_t buffer[MAX_PATH];
size_t buffer_size = sizeof(buffer) / sizeof(char_t);
int rc = get_hostfxr_path(buffer, &buffer_size, nullptr);
if (rc != 0)
{
printf("[LegacyForge] get_hostfxr_path failed: 0x%x\n", rc);
return false;
}
HMODULE lib = LoadLibraryW(buffer);
if (!lib)
{
printf("[LegacyForge] Failed to load hostfxr\n");
return false;
}
init_fptr = reinterpret_cast<hostfxr_initialize_for_runtime_config_fn>(
GetProcAddress(lib, "hostfxr_initialize_for_runtime_config"));
get_delegate_fptr = reinterpret_cast<hostfxr_get_runtime_delegate_fn>(
GetProcAddress(lib, "hostfxr_get_runtime_delegate"));
close_fptr = reinterpret_cast<hostfxr_close_fn>(
GetProcAddress(lib, "hostfxr_close"));
return init_fptr && get_delegate_fptr && close_fptr;
}
static load_assembly_and_get_function_pointer_fn GetDotNetLoadAssembly(const wchar_t* configPath)
{
hostfxr_handle cxt = nullptr;
int rc = init_fptr(configPath, nullptr, &cxt);
if (rc != 0 || cxt == nullptr)
{
printf("[LegacyForge] hostfxr_initialize failed: 0x%x\n", rc);
if (cxt) close_fptr(cxt);
return nullptr;
}
void* load_fn = nullptr;
rc = get_delegate_fptr(cxt, hdt_load_assembly_and_get_function_pointer, &load_fn);
if (rc != 0 || load_fn == nullptr)
{
printf("[LegacyForge] hostfxr_get_runtime_delegate failed: 0x%x\n", rc);
}
close_fptr(cxt);
return reinterpret_cast<load_assembly_and_get_function_pointer_fn>(load_fn);
}
static bool ResolveManagedMethod(
load_assembly_and_get_function_pointer_fn load_fn,
const wchar_t* assemblyPath,
const wchar_t* methodName,
managed_entry_fn* outFn)
{
int rc = load_fn(
assemblyPath,
L"LegacyForge.Core.LegacyForgeCore, LegacyForge.Core",
methodName,
nullptr, // delegate_type_name (null = default component_entry_point_fn)
nullptr,
reinterpret_cast<void**>(outFn));
return rc == 0 && *outFn != nullptr;
}
bool DotNetHost::Initialize()
{
if (!LoadHostfxr())
{
printf("[LegacyForge] Failed to load hostfxr library\n");
return false;
}
// Paths relative to the runtime DLL (which is in the same dir as LegacyForge.Core.dll)
wchar_t modulePath[MAX_PATH];
GetModuleFileNameW(nullptr, modulePath, MAX_PATH);
std::wstring exeDir(modulePath);
size_t lastSlash = exeDir.find_last_of(L"\\/");
if (lastSlash != std::wstring::npos)
exeDir = exeDir.substr(0, lastSlash + 1);
else
exeDir = L".\\";
// Look for LegacyForge files next to the runtime DLL, not the game exe
HMODULE hSelf = nullptr;
GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(&DotNetHost::Initialize), &hSelf);
wchar_t selfPath[MAX_PATH];
GetModuleFileNameW(hSelf, selfPath, MAX_PATH);
std::wstring selfDir(selfPath);
lastSlash = selfDir.find_last_of(L"\\/");
if (lastSlash != std::wstring::npos)
selfDir = selfDir.substr(0, lastSlash + 1);
else
selfDir = L".\\";
std::wstring configPath = selfDir + L"LegacyForge.Core.runtimeconfig.json";
std::wstring assemblyPath = selfDir + L"LegacyForge.Core.dll";
auto load_fn = GetDotNetLoadAssembly(configPath.c_str());
if (!load_fn)
{
printf("[LegacyForge] Failed to get load_assembly_and_get_function_pointer\n");
return false;
}
bool ok = true;
ok &= ResolveManagedMethod(load_fn, assemblyPath.c_str(), L"Initialize", &fn_Initialize);
ok &= ResolveManagedMethod(load_fn, assemblyPath.c_str(), L"DiscoverMods", &fn_DiscoverMods);
ok &= ResolveManagedMethod(load_fn, assemblyPath.c_str(), L"PreInit", &fn_PreInit);
ok &= ResolveManagedMethod(load_fn, assemblyPath.c_str(), L"Init", &fn_Init);
ok &= ResolveManagedMethod(load_fn, assemblyPath.c_str(), L"PostInit", &fn_PostInit);
ok &= ResolveManagedMethod(load_fn, assemblyPath.c_str(), L"Tick", &fn_Tick);
ok &= ResolveManagedMethod(load_fn, assemblyPath.c_str(), L"Shutdown", &fn_Shutdown);
if (!ok)
{
printf("[LegacyForge] Failed to resolve one or more managed entry points\n");
return false;
}
printf("[LegacyForge] All managed entry points resolved\n");
return true;
}
void DotNetHost::CallManagedInit()
{
if (fn_Initialize)
fn_Initialize(nullptr, 0);
}
void DotNetHost::CallDiscoverMods(const char* modsPath)
{
if (fn_DiscoverMods)
fn_DiscoverMods(const_cast<char*>(modsPath), static_cast<int>(strlen(modsPath)));
}
void DotNetHost::CallPreInit()
{
if (fn_PreInit) fn_PreInit(nullptr, 0);
}
void DotNetHost::CallInit()
{
if (fn_Init) fn_Init(nullptr, 0);
}
void DotNetHost::CallPostInit()
{
if (fn_PostInit) fn_PostInit(nullptr, 0);
}
void DotNetHost::CallTick()
{
if (fn_Tick) fn_Tick(nullptr, 0);
}
void DotNetHost::CallShutdown()
{
if (fn_Shutdown) fn_Shutdown(nullptr, 0);
}
void DotNetHost::Cleanup()
{
}

View File

@@ -0,0 +1,18 @@
#pragma once
#include <cstdint>
/// Hosts the .NET CoreCLR runtime inside the game process using the hostfxr API.
/// Loads LegacyForge.Core.dll and resolves managed entry point methods.
namespace DotNetHost
{
bool Initialize();
void Cleanup();
void CallManagedInit();
void CallDiscoverMods(const char* modsPath);
void CallPreInit();
void CallInit();
void CallPostInit();
void CallTick();
void CallShutdown();
}

View File

@@ -0,0 +1,48 @@
#include "GameHooks.h"
#include "DotNetHost.h"
#include <cstdio>
namespace GameHooks
{
RunStaticCtors_fn Original_RunStaticCtors = nullptr;
MinecraftTick_fn Original_MinecraftTick = nullptr;
MinecraftInit_fn Original_MinecraftInit = nullptr;
MinecraftDestroy_fn Original_MinecraftDestroy = nullptr;
void Hooked_RunStaticCtors()
{
printf("[LegacyForge] RunStaticCtors hook fired -- calling PreInit\n");
DotNetHost::CallPreInit();
Original_RunStaticCtors();
printf("[LegacyForge] RunStaticCtors complete -- calling Init\n");
DotNetHost::CallInit();
}
void __fastcall Hooked_MinecraftTick(void* thisPtr, bool bFirst, bool bUpdateTextures)
{
Original_MinecraftTick(thisPtr, bFirst, bUpdateTextures);
if (bFirst)
{
DotNetHost::CallTick();
}
}
void __fastcall Hooked_MinecraftInit(void* thisPtr)
{
Original_MinecraftInit(thisPtr);
printf("[LegacyForge] Minecraft::init complete -- calling PostInit\n");
DotNetHost::CallPostInit();
}
void __fastcall Hooked_MinecraftDestroy(void* thisPtr)
{
printf("[LegacyForge] Minecraft::destroy -- calling Shutdown\n");
DotNetHost::CallShutdown();
Original_MinecraftDestroy(thisPtr);
}
}

View File

@@ -0,0 +1,22 @@
#pragma once
#include <cstdint>
/// Function pointer typedefs matching the game's function signatures.
/// On x64 MSVC, member functions use __fastcall-like convention (this in rcx).
typedef void (*RunStaticCtors_fn)();
typedef void (__fastcall *MinecraftTick_fn)(void* thisPtr, bool bFirst, bool bUpdateTextures);
typedef void (__fastcall *MinecraftInit_fn)(void* thisPtr);
typedef void (__fastcall *MinecraftDestroy_fn)(void* thisPtr);
namespace GameHooks
{
extern RunStaticCtors_fn Original_RunStaticCtors;
extern MinecraftTick_fn Original_MinecraftTick;
extern MinecraftInit_fn Original_MinecraftInit;
extern MinecraftDestroy_fn Original_MinecraftDestroy;
void Hooked_RunStaticCtors();
void __fastcall Hooked_MinecraftTick(void* thisPtr, bool bFirst, bool bUpdateTextures);
void __fastcall Hooked_MinecraftInit(void* thisPtr);
void __fastcall Hooked_MinecraftDestroy(void* thisPtr);
}

View File

@@ -0,0 +1,77 @@
#include "HookManager.h"
#include "GameHooks.h"
#include "SymbolResolver.h"
#include <MinHook.h>
#include <cstdio>
bool HookManager::Install(const SymbolResolver& symbols)
{
if (MH_Initialize() != MH_OK)
{
printf("[LegacyForge] MH_Initialize failed\n");
return false;
}
// Hook MinecraftWorld_RunStaticCtors
if (symbols.pRunStaticCtors)
{
if (MH_CreateHook(symbols.pRunStaticCtors,
reinterpret_cast<void*>(&GameHooks::Hooked_RunStaticCtors),
reinterpret_cast<void**>(&GameHooks::Original_RunStaticCtors)) != MH_OK)
{
printf("[LegacyForge] Failed to hook RunStaticCtors\n");
return false;
}
}
// Hook Minecraft::tick
if (symbols.pMinecraftTick)
{
if (MH_CreateHook(symbols.pMinecraftTick,
reinterpret_cast<void*>(&GameHooks::Hooked_MinecraftTick),
reinterpret_cast<void**>(&GameHooks::Original_MinecraftTick)) != MH_OK)
{
printf("[LegacyForge] Failed to hook Minecraft::tick\n");
return false;
}
}
// Hook Minecraft::init
if (symbols.pMinecraftInit)
{
if (MH_CreateHook(symbols.pMinecraftInit,
reinterpret_cast<void*>(&GameHooks::Hooked_MinecraftInit),
reinterpret_cast<void**>(&GameHooks::Original_MinecraftInit)) != MH_OK)
{
printf("[LegacyForge] Failed to hook Minecraft::init\n");
return false;
}
}
// Hook Minecraft::destroy
if (symbols.pMinecraftDestroy)
{
if (MH_CreateHook(symbols.pMinecraftDestroy,
reinterpret_cast<void*>(&GameHooks::Hooked_MinecraftDestroy),
reinterpret_cast<void**>(&GameHooks::Original_MinecraftDestroy)) != MH_OK)
{
printf("[LegacyForge] Failed to hook Minecraft::destroy\n");
return false;
}
}
if (MH_EnableHook(MH_ALL_HOOKS) != MH_OK)
{
printf("[LegacyForge] MH_EnableHook(MH_ALL_HOOKS) failed\n");
return false;
}
printf("[LegacyForge] All hooks installed and enabled\n");
return true;
}
void HookManager::Cleanup()
{
MH_DisableHook(MH_ALL_HOOKS);
MH_Uninitialize();
}

View File

@@ -0,0 +1,9 @@
#pragma once
class SymbolResolver;
namespace HookManager
{
bool Install(const SymbolResolver& symbols);
void Cleanup();
}

View File

@@ -0,0 +1,78 @@
#include "IdRegistry.h"
#include <cstdio>
IdRegistry& IdRegistry::Instance()
{
static IdRegistry instance;
return instance;
}
IdRegistry::IdRegistry()
{
m_registries[static_cast<int>(Type::Block)].nextFreeId = BLOCK_MOD_START;
m_registries[static_cast<int>(Type::Item)].nextFreeId = ITEM_MOD_START;
m_registries[static_cast<int>(Type::Entity)].nextFreeId = ENTITY_MOD_START;
}
int IdRegistry::Register(Type type, const std::string& namespacedId)
{
std::lock_guard<std::mutex> lock(m_mutex);
auto& reg = m_registries[static_cast<int>(type)];
auto it = reg.stringToNum.find(namespacedId);
if (it != reg.stringToNum.end())
return it->second;
int maxId;
switch (type)
{
case Type::Block: maxId = BLOCK_MAX; break;
case Type::Item: maxId = ITEM_MAX; break;
case Type::Entity: maxId = ENTITY_MAX; break;
default: return -1;
}
if (reg.nextFreeId > maxId)
{
printf("[LegacyForge] IdRegistry: No free IDs for type %d (max %d)\n",
static_cast<int>(type), maxId);
return -1;
}
int id = reg.nextFreeId++;
// Skip IDs that are already taken by vanilla entries
while (reg.numToString.count(id) && id <= maxId)
id = reg.nextFreeId++;
if (id > maxId) return -1;
reg.stringToNum[namespacedId] = id;
reg.numToString[id] = namespacedId;
return id;
}
int IdRegistry::GetNumericId(Type type, const std::string& namespacedId) const
{
std::lock_guard<std::mutex> lock(m_mutex);
const auto& reg = m_registries[static_cast<int>(type)];
auto it = reg.stringToNum.find(namespacedId);
return (it != reg.stringToNum.end()) ? it->second : -1;
}
std::string IdRegistry::GetStringId(Type type, int numericId) const
{
std::lock_guard<std::mutex> lock(m_mutex);
const auto& reg = m_registries[static_cast<int>(type)];
auto it = reg.numToString.find(numericId);
return (it != reg.numToString.end()) ? it->second : "";
}
void IdRegistry::RegisterVanilla(Type type, int numericId, const std::string& namespacedId)
{
std::lock_guard<std::mutex> lock(m_mutex);
auto& reg = m_registries[static_cast<int>(type)];
reg.stringToNum[namespacedId] = numericId;
reg.numToString[numericId] = namespacedId;
}

View File

@@ -0,0 +1,48 @@
#pragma once
#include <string>
#include <unordered_map>
#include <mutex>
/// Maps namespaced string IDs ("namespace:path") to auto-allocated numeric IDs.
/// Separate pools for blocks, items, and entities.
/// Thread-safe for registration calls from the .NET runtime.
class IdRegistry
{
public:
enum class Type { Block, Item, Entity };
static IdRegistry& Instance();
/// Register a new entry and auto-allocate a numeric ID.
/// Returns the allocated numeric ID, or -1 on failure.
int Register(Type type, const std::string& namespacedId);
/// Look up the numeric ID for a string ID. Returns -1 if not found.
int GetNumericId(Type type, const std::string& namespacedId) const;
/// Look up the string ID for a numeric ID. Returns empty string if not found.
std::string GetStringId(Type type, int numericId) const;
/// Pre-register a vanilla entry with a known numeric ID.
void RegisterVanilla(Type type, int numericId, const std::string& namespacedId);
private:
IdRegistry();
struct RegistryData
{
std::unordered_map<std::string, int> stringToNum;
std::unordered_map<int, std::string> numToString;
int nextFreeId;
};
static constexpr int BLOCK_MOD_START = 256;
static constexpr int BLOCK_MAX = 4095;
static constexpr int ITEM_MOD_START = 1000;
static constexpr int ITEM_MAX = 31999;
static constexpr int ENTITY_MOD_START = 1000;
static constexpr int ENTITY_MAX = 9999;
RegistryData m_registries[3];
mutable std::mutex m_mutex;
};

View File

@@ -0,0 +1,128 @@
#include "NativeExports.h"
#include "IdRegistry.h"
#include <cstdio>
#include <cstring>
extern "C"
{
int native_register_block(
const char* namespacedId,
int materialId,
float hardness,
float resistance,
int soundType,
const char* iconName,
float lightEmission,
int lightBlock)
{
if (!namespacedId) return -1;
int id = IdRegistry::Instance().Register(IdRegistry::Type::Block, namespacedId);
if (id < 0)
{
printf("[LegacyForge] Failed to allocate block ID for '%s'\n", namespacedId);
return -1;
}
// TODO: Once we have access to game headers, create a GenericTile instance
// and insert it into Tile::tiles[id]. For now we just allocate the ID.
printf("[LegacyForge] Registered block '%s' -> ID %d (hardness=%.1f, resistance=%.1f)\n",
namespacedId, id, hardness, resistance);
return id;
}
int native_register_item(
const char* namespacedId,
int maxStackSize,
int maxDamage)
{
if (!namespacedId) return -1;
int id = IdRegistry::Instance().Register(IdRegistry::Type::Item, namespacedId);
if (id < 0)
{
printf("[LegacyForge] Failed to allocate item ID for '%s'\n", namespacedId);
return -1;
}
// TODO: Create GenericItem and insert into Item::items[256 + id]
printf("[LegacyForge] Registered item '%s' -> ID %d (stack=%d, durability=%d)\n",
namespacedId, id, maxStackSize, maxDamage);
return id;
}
int native_register_entity(
const char* namespacedId,
float width,
float height,
int trackingRange)
{
if (!namespacedId) return -1;
int id = IdRegistry::Instance().Register(IdRegistry::Type::Entity, namespacedId);
if (id < 0)
{
printf("[LegacyForge] Failed to allocate entity ID for '%s'\n", namespacedId);
return -1;
}
// TODO: Register with EntityIO via resolved PDB symbols
printf("[LegacyForge] Registered entity '%s' -> ID %d (%.1fx%.1f)\n",
namespacedId, id, width, height);
return id;
}
void native_add_shaped_recipe(
const char* resultId,
int resultCount,
const char* pattern,
const char* ingredientIds)
{
// TODO: Parse pattern and ingredients, call game's recipe registration
printf("[LegacyForge] Added shaped recipe: %dx %s\n", resultCount, resultId);
}
void native_add_furnace_recipe(
const char* inputId,
const char* outputId,
float xp)
{
// TODO: Call game's FurnaceRecipes::addRecipe via resolved symbols
printf("[LegacyForge] Added furnace recipe: %s -> %s (%.1f xp)\n", inputId, outputId, xp);
}
void native_log(const char* message, int level)
{
if (message)
printf("%s\n", message);
}
int native_get_block_id(const char* namespacedId)
{
if (!namespacedId) return -1;
return IdRegistry::Instance().GetNumericId(IdRegistry::Type::Block, namespacedId);
}
int native_get_item_id(const char* namespacedId)
{
if (!namespacedId) return -1;
return IdRegistry::Instance().GetNumericId(IdRegistry::Type::Item, namespacedId);
}
int native_get_entity_id(const char* namespacedId)
{
if (!namespacedId) return -1;
return IdRegistry::Instance().GetNumericId(IdRegistry::Type::Entity, namespacedId);
}
void native_subscribe_event(const char* eventName, void* managedFnPtr)
{
// TODO: Store managed callback pointers and invoke from game hooks
printf("[LegacyForge] Event subscription: %s\n", eventName ? eventName : "(null)");
}
} // extern "C"

View File

@@ -0,0 +1,51 @@
#pragma once
/// Exported C functions callable from C# via P/Invoke.
/// All registration functions accept namespaced string IDs and delegate
/// to IdRegistry for numeric ID allocation.
extern "C"
{
__declspec(dllexport) int native_register_block(
const char* namespacedId,
int materialId,
float hardness,
float resistance,
int soundType,
const char* iconName,
float lightEmission,
int lightBlock);
__declspec(dllexport) int native_register_item(
const char* namespacedId,
int maxStackSize,
int maxDamage);
__declspec(dllexport) int native_register_entity(
const char* namespacedId,
float width,
float height,
int trackingRange);
__declspec(dllexport) void native_add_shaped_recipe(
const char* resultId,
int resultCount,
const char* pattern,
const char* ingredientIds);
__declspec(dllexport) void native_add_furnace_recipe(
const char* inputId,
const char* outputId,
float xp);
__declspec(dllexport) void native_log(
const char* message,
int level);
__declspec(dllexport) int native_get_block_id(const char* namespacedId);
__declspec(dllexport) int native_get_item_id(const char* namespacedId);
__declspec(dllexport) int native_get_entity_id(const char* namespacedId);
__declspec(dllexport) void native_subscribe_event(
const char* eventName,
void* managedFnPtr);
}

View File

@@ -0,0 +1,121 @@
#include "SymbolResolver.h"
#include <cstdio>
#include <cstring>
#pragma comment(lib, "dbghelp.lib")
bool SymbolResolver::Initialize()
{
m_process = GetCurrentProcess();
SymSetOptions(SYMOPT_UNDNAME | SYMOPT_DEFERRED_LOADS | SYMOPT_LOAD_LINES | SYMOPT_DEBUG);
if (!SymInitialize(m_process, nullptr, TRUE))
{
return false;
}
m_initialized = true;
return true;
}
void* SymbolResolver::Resolve(const char* functionName)
{
if (!m_initialized) return nullptr;
char buffer[sizeof(SYMBOL_INFO) + MAX_SYM_NAME * sizeof(char)];
memset(buffer, 0, sizeof(buffer));
SYMBOL_INFO* symbol = reinterpret_cast<SYMBOL_INFO*>(buffer);
symbol->SizeOfStruct = sizeof(SYMBOL_INFO);
symbol->MaxNameLen = MAX_SYM_NAME;
if (SymFromName(m_process, functionName, symbol))
{
return reinterpret_cast<void*>(symbol->Address);
}
return nullptr;
}
struct EnumContext
{
const char* targetName;
void* result;
};
static BOOL CALLBACK EnumSymbolsCallback(PSYMBOL_INFO pSymInfo, ULONG SymbolSize, PVOID UserContext)
{
auto* ctx = static_cast<EnumContext*>(UserContext);
if (strstr(pSymInfo->Name, ctx->targetName) != nullptr)
{
ctx->result = reinterpret_cast<void*>(pSymInfo->Address);
return FALSE;
}
return TRUE;
}
bool SymbolResolver::ResolveGameFunctions()
{
// MinecraftWorld_RunStaticCtors is a free function (not mangled in complex ways)
pRunStaticCtors = Resolve("MinecraftWorld_RunStaticCtors");
if (!pRunStaticCtors)
{
// Try with wildcard enumeration
EnumContext ctx = { "MinecraftWorld_RunStaticCtors", nullptr };
SymEnumSymbols(m_process, 0, "*MinecraftWorld_RunStaticCtors*", EnumSymbolsCallback, &ctx);
pRunStaticCtors = ctx.result;
}
// Minecraft::tick -- MSVC mangles this. Try undecorated name first.
pMinecraftTick = Resolve("Minecraft::tick");
if (!pMinecraftTick)
{
EnumContext ctx = { "Minecraft::tick", nullptr };
SymEnumSymbols(m_process, 0, "*Minecraft*tick*", EnumSymbolsCallback, &ctx);
pMinecraftTick = ctx.result;
}
// Minecraft::init
pMinecraftInit = Resolve("Minecraft::init");
if (!pMinecraftInit)
{
EnumContext ctx = { "Minecraft::init", nullptr };
SymEnumSymbols(m_process, 0, "*Minecraft*init*", EnumSymbolsCallback, &ctx);
pMinecraftInit = ctx.result;
}
// Minecraft::destroy
pMinecraftDestroy = Resolve("Minecraft::destroy");
if (!pMinecraftDestroy)
{
EnumContext ctx = { "Minecraft::destroy", nullptr };
SymEnumSymbols(m_process, 0, "*Minecraft*destroy*", EnumSymbolsCallback, &ctx);
pMinecraftDestroy = ctx.result;
}
bool allResolved = pRunStaticCtors && pMinecraftTick && pMinecraftInit && pMinecraftDestroy;
if (pRunStaticCtors) printf("[LegacyForge] Resolved RunStaticCtors @ %p\n", pRunStaticCtors);
else printf("[LegacyForge] MISSING: MinecraftWorld_RunStaticCtors\n");
if (pMinecraftTick) printf("[LegacyForge] Resolved Minecraft::tick @ %p\n", pMinecraftTick);
else printf("[LegacyForge] MISSING: Minecraft::tick\n");
if (pMinecraftInit) printf("[LegacyForge] Resolved Minecraft::init @ %p\n", pMinecraftInit);
else printf("[LegacyForge] MISSING: Minecraft::init\n");
if (pMinecraftDestroy) printf("[LegacyForge] Resolved Minecraft::destroy @ %p\n", pMinecraftDestroy);
else printf("[LegacyForge] MISSING: Minecraft::destroy\n");
return allResolved;
}
void SymbolResolver::Cleanup()
{
if (m_initialized)
{
SymCleanup(m_process);
m_initialized = false;
}
}

View File

@@ -0,0 +1,23 @@
#pragma once
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <DbgHelp.h>
class SymbolResolver
{
public:
bool Initialize();
bool ResolveGameFunctions();
void Cleanup();
void* Resolve(const char* functionName);
void* pRunStaticCtors = nullptr;
void* pMinecraftTick = nullptr;
void* pMinecraftInit = nullptr;
void* pMinecraftDestroy = nullptr;
private:
HANDLE m_process = nullptr;
bool m_initialized = false;
};

View File

@@ -0,0 +1,75 @@
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include "SymbolResolver.h"
#include "HookManager.h"
#include "DotNetHost.h"
static HMODULE g_hModule = nullptr;
static void LogToFile(const char* msg)
{
FILE* f = nullptr;
fopen_s(&f, "legacyforge.log", "a");
if (f)
{
fprintf(f, "%s\n", msg);
fclose(f);
}
}
DWORD WINAPI InitThread(LPVOID lpParam)
{
LogToFile("[LegacyForge] InitThread started");
SymbolResolver symbols;
if (!symbols.Initialize())
{
LogToFile("[LegacyForge] ERROR: Failed to initialize symbol resolver. Is the PDB present?");
return 1;
}
LogToFile("[LegacyForge] Symbol resolver initialized");
if (!symbols.ResolveGameFunctions())
{
LogToFile("[LegacyForge] ERROR: Failed to resolve one or more game functions");
return 1;
}
LogToFile("[LegacyForge] Game functions resolved from PDB");
if (!HookManager::Install(symbols))
{
LogToFile("[LegacyForge] ERROR: Failed to install hooks");
return 1;
}
LogToFile("[LegacyForge] Hooks installed");
if (!DotNetHost::Initialize())
{
LogToFile("[LegacyForge] ERROR: Failed to initialize .NET host");
return 1;
}
LogToFile("[LegacyForge] .NET runtime initialized");
DotNetHost::CallManagedInit();
DotNetHost::CallDiscoverMods("mods");
LogToFile("[LegacyForge] Mod discovery complete. Ready.");
return 0;
}
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
g_hModule = hModule;
DisableThreadLibraryCalls(hModule);
CreateThread(nullptr, 0, InitThread, nullptr, 0, nullptr);
break;
case DLL_PROCESS_DETACH:
HookManager::Cleanup();
break;
}
return TRUE;
}

89
README.md Normal file
View File

@@ -0,0 +1,89 @@
# LegacyForge
An SKSE-style mod loader for Minecraft Legacy Edition. LegacyForge injects into the game process at runtime, hooks key engine functions, and hosts the .NET runtime to load C# mods. **Zero game source modifications required.**
## How It Works
1. **LegacyForge.exe** launches the game in a suspended state and injects `LegacyForgeRuntime.dll`
2. The runtime DLL uses PDB debug symbols to locate game functions (`MinecraftWorld_RunStaticCtors`, `Minecraft::tick`, etc.)
3. MinHook detours those functions to insert mod lifecycle callbacks
4. The .NET CoreCLR runtime is hosted inside the game process via hostfxr
5. `LegacyForge.Core` discovers and loads C# mod assemblies from the `mods/` folder
6. Mods use the `LegacyForge.API` to register blocks, items, entities, and subscribe to game events using Fabric-style namespaced string IDs
## Project Structure
```
ModLoader/
├── LegacyForge.Launcher/ C# launcher (the exe users run)
├── LegacyForgeRuntime/ C++ DLL (injected into game process)
├── LegacyForge.Core/ C# mod management (loaded inside game)
├── LegacyForge.API/ C# mod API (what mod authors reference)
└── ExampleMod/ Sample mod for reference
```
## Building
### Prerequisites
- Visual Studio 2022 or later (with C++ and .NET workloads)
- .NET 8.0 SDK or later
- CMake 3.24 or later
- The game must be compiled with PDB generation
### Build Steps
**C++ Runtime DLL:**
```bash
cd LegacyForgeRuntime
cmake -B build -A x64
cmake --build build --config Release
```
**C# Projects:**
```bash
dotnet build LegacyForge.sln
```
## Usage
1. Build LegacyForge (see above)
2. Copy the output files to a folder:
- `LegacyForge.exe`
- `LegacyForgeRuntime.dll`
- `LegacyForge.Core.dll`
- `LegacyForge.API.dll`
3. Create a `mods/` folder and drop mod DLLs in it
4. Run `LegacyForge.exe` -- it will ask for the game exe path on first launch
5. The game starts with mods loaded
## Writing a Mod
Create a new .NET 8 class library and reference `LegacyForge.API`:
```csharp
using LegacyForge.API;
[Mod("mymod", Name = "My Mod", Version = "1.0.0", Author = "You")]
public class MyMod : IMod
{
public void OnInitialize()
{
var myBlock = Registry.Block.Register("mymod:cool_block",
new BlockProperties()
.Material(MaterialType.Stone)
.Hardness(2.0f)
.Resistance(10f));
Logger.Info("My Mod loaded!");
}
}
```
Build it, copy the DLL to `mods/`, and launch via LegacyForge.
## License
[MIT](LICENSE)