commit de22a241005f584159c4ccd042a3a2c6e53ff6d8 Author: Jacobwasbeast Date: Fri Mar 6 15:11:53 2026 -0600 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..046925b --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1c4dd44 --- /dev/null +++ b/CONTRIBUTING.md @@ -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 diff --git a/ExampleMod/ExampleMod.cs b/ExampleMod/ExampleMod.cs new file mode 100644 index 0000000..d232241 --- /dev/null +++ b/ExampleMod/ExampleMod.cs @@ -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."); + } +} diff --git a/ExampleMod/ExampleMod.csproj b/ExampleMod/ExampleMod.csproj new file mode 100644 index 0000000..3505083 --- /dev/null +++ b/ExampleMod/ExampleMod.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + ExampleMod + ExampleMod + Example mod for LegacyForge demonstrating the mod API + 1.0.0 + + + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f89b5fc --- /dev/null +++ b/LICENSE @@ -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. diff --git a/LegacyForge.API/Block/BlockProperties.cs b/LegacyForge.API/Block/BlockProperties.cs new file mode 100644 index 0000000..6fb7e43 --- /dev/null +++ b/LegacyForge.API/Block/BlockProperties.cs @@ -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 +} + +/// +/// Fluent builder for defining block properties. +/// +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; } +} diff --git a/LegacyForge.API/Block/BlockRegistry.cs b/LegacyForge.API/Block/BlockRegistry.cs new file mode 100644 index 0000000..1a1b794 --- /dev/null +++ b/LegacyForge.API/Block/BlockRegistry.cs @@ -0,0 +1,51 @@ +namespace LegacyForge.API.Block; + +/// +/// Represents a block that has been registered with the game engine. +/// +public class RegisteredBlock +{ + /// The namespaced string ID (e.g. "mymod:ruby_ore"). + public Identifier StringId { get; } + + /// The numeric ID allocated by the engine. + public int NumericId { get; } + + internal RegisteredBlock(Identifier id, int numericId) + { + StringId = id; + NumericId = numericId; + } +} + +/// +/// Block registration via the LegacyForge registry. +/// Accessed through . +/// +public static class BlockRegistry +{ + /// + /// Register a new block with the game engine. + /// + /// Namespaced identifier (e.g. "mymod:ruby_ore"). + /// Block properties built with . + /// A handle to the registered block. + 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); + } +} diff --git a/LegacyForge.API/Entity/EntityDefinition.cs b/LegacyForge.API/Entity/EntityDefinition.cs new file mode 100644 index 0000000..f6cb8fe --- /dev/null +++ b/LegacyForge.API/Entity/EntityDefinition.cs @@ -0,0 +1,16 @@ +namespace LegacyForge.API.Entity; + +/// +/// Fluent builder for defining entity properties. +/// +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; } +} diff --git a/LegacyForge.API/Entity/EntityRegistry.cs b/LegacyForge.API/Entity/EntityRegistry.cs new file mode 100644 index 0000000..42bdbe4 --- /dev/null +++ b/LegacyForge.API/Entity/EntityRegistry.cs @@ -0,0 +1,38 @@ +namespace LegacyForge.API.Entity; + +/// +/// Represents an entity type that has been registered with the game engine. +/// +public class RegisteredEntity +{ + public Identifier StringId { get; } + public int NumericId { get; } + + internal RegisteredEntity(Identifier id, int numericId) + { + StringId = id; + NumericId = numericId; + } +} + +/// +/// Entity registration via the LegacyForge registry. +/// Accessed through . +/// +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); + } +} diff --git a/LegacyForge.API/Events/GameEvents.cs b/LegacyForge.API/Events/GameEvents.cs new file mode 100644 index 0000000..ab56091 --- /dev/null +++ b/LegacyForge.API/Events/GameEvents.cs @@ -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; } = ""; +} + +/// +/// 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. +/// +public static class GameEvents +{ + public static event EventHandler? OnBlockBreak; + public static event EventHandler? OnBlockPlace; + public static event EventHandler? OnChat; + public static event EventHandler? OnEntitySpawn; + public static event EventHandler? 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); +} diff --git a/LegacyForge.API/IMod.cs b/LegacyForge.API/IMod.cs new file mode 100644 index 0000000..73cb658 --- /dev/null +++ b/LegacyForge.API/IMod.cs @@ -0,0 +1,39 @@ +namespace LegacyForge.API; + +/// +/// The main interface all LegacyForge mods must implement. +/// Default interface methods allow mods to only override what they need. +/// +public interface IMod +{ + /// + /// Called before vanilla registries are populated. + /// Use for very early setup that must happen before any game content loads. + /// + void OnPreInit() { } + + /// + /// Called after vanilla registries are populated. + /// This is the main initialization point -- register your blocks, items, + /// entities, recipes, and event handlers here. + /// + void OnInitialize(); + + /// + /// Called after the game client has finished its own initialization. + /// Use for client-side setup like custom renderers or UI. + /// + void OnPostInitialize() { } + + /// + /// Called once per game tick (20 times per second). + /// Use for ongoing mod logic. + /// + void OnTick() { } + + /// + /// Called when the game is shutting down. + /// Use for cleanup and saving mod state. + /// + void OnShutdown() { } +} diff --git a/LegacyForge.API/Identifier.cs b/LegacyForge.API/Identifier.cs new file mode 100644 index 0000000..705c517 --- /dev/null +++ b/LegacyForge.API/Identifier.cs @@ -0,0 +1,39 @@ +namespace LegacyForge.API; + +/// +/// A namespaced identifier in the form "namespace:path" (e.g. "minecraft:stone", "mymod:ruby_ore"). +/// +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); +} diff --git a/LegacyForge.API/Item/ItemProperties.cs b/LegacyForge.API/Item/ItemProperties.cs new file mode 100644 index 0000000..2db1eac --- /dev/null +++ b/LegacyForge.API/Item/ItemProperties.cs @@ -0,0 +1,18 @@ +namespace LegacyForge.API.Item; + +/// +/// Fluent builder for defining item properties. +/// +public class ItemProperties +{ + internal int MaxStackSizeValue = 64; + internal int MaxDamageValue = 0; + + public ItemProperties MaxStackSize(int size) { MaxStackSizeValue = size; return this; } + + /// + /// Set max damage for a tool/armor item. Setting this to a positive value + /// makes the item damageable with a durability bar. + /// + public ItemProperties MaxDamage(int damage) { MaxDamageValue = damage; MaxStackSizeValue = 1; return this; } +} diff --git a/LegacyForge.API/Item/ItemRegistry.cs b/LegacyForge.API/Item/ItemRegistry.cs new file mode 100644 index 0000000..90721ac --- /dev/null +++ b/LegacyForge.API/Item/ItemRegistry.cs @@ -0,0 +1,46 @@ +namespace LegacyForge.API.Item; + +/// +/// Represents an item that has been registered with the game engine. +/// +public class RegisteredItem +{ + /// The namespaced string ID (e.g. "mymod:ruby"). + public Identifier StringId { get; } + + /// The numeric ID allocated by the engine. + public int NumericId { get; } + + internal RegisteredItem(Identifier id, int numericId) + { + StringId = id; + NumericId = numericId; + } +} + +/// +/// Item registration via the LegacyForge registry. +/// Accessed through . +/// +public static class ItemRegistry +{ + /// + /// Register a new item with the game engine. + /// + /// Namespaced identifier (e.g. "mymod:ruby"). + /// Item properties built with . + /// A handle to the registered item. + 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); + } +} diff --git a/LegacyForge.API/LegacyForge.API.csproj b/LegacyForge.API/LegacyForge.API.csproj new file mode 100644 index 0000000..f58a748 --- /dev/null +++ b/LegacyForge.API/LegacyForge.API.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + LegacyForge.API + LegacyForge.API + Mod API for LegacyForge - Minecraft Legacy Edition mod loader + 1.0.0 + + + diff --git a/LegacyForge.API/Logger.cs b/LegacyForge.API/Logger.cs new file mode 100644 index 0000000..5ff540c --- /dev/null +++ b/LegacyForge.API/Logger.cs @@ -0,0 +1,30 @@ +namespace LegacyForge.API; + +public enum LogLevel +{ + Debug = 0, + Info = 1, + Warning = 2, + Error = 3 +} + +/// +/// Logging facade that routes messages through the native runtime to the game's debug output. +/// +public static class Logger +{ + internal static Action? 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}"); + } +} diff --git a/LegacyForge.API/ModAttribute.cs b/LegacyForge.API/ModAttribute.cs new file mode 100644 index 0000000..ee1bb90 --- /dev/null +++ b/LegacyForge.API/ModAttribute.cs @@ -0,0 +1,41 @@ +namespace LegacyForge.API; + +/// +/// Marks a class as a LegacyForge mod and provides metadata. +/// The class must also implement . +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class ModAttribute : Attribute +{ + /// + /// The unique mod identifier (e.g. "examplemod"). Used as the default namespace + /// for content registered by this mod. + /// + public string Id { get; } + + /// + /// Human-readable display name. + /// + public string Name { get; set; } = ""; + + /// + /// Semantic version string (e.g. "1.0.0"). + /// + public string Version { get; set; } = "1.0.0"; + + /// + /// Mod author(s). + /// + public string Author { get; set; } = ""; + + /// + /// Short description of the mod. + /// + public string Description { get; set; } = ""; + + public ModAttribute(string id) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + Id = id; + } +} diff --git a/LegacyForge.API/NativeInterop.cs b/LegacyForge.API/NativeInterop.cs new file mode 100644 index 0000000..abe69f3 --- /dev/null +++ b/LegacyForge.API/NativeInterop.cs @@ -0,0 +1,64 @@ +using System.Runtime.InteropServices; + +namespace LegacyForge.API; + +/// +/// Internal P/Invoke bindings to LegacyForgeRuntime.dll native exports. +/// Mod authors should use the Registry and Logger classes instead of calling these directly. +/// +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); +} diff --git a/LegacyForge.API/Recipe/RecipeRegistry.cs b/LegacyForge.API/Recipe/RecipeRegistry.cs new file mode 100644 index 0000000..dfe1121 --- /dev/null +++ b/LegacyForge.API/Recipe/RecipeRegistry.cs @@ -0,0 +1,40 @@ +namespace LegacyForge.API.Recipe; + +/// +/// Recipe registration via the LegacyForge registry. +/// Accessed through . +/// +public static class RecipeRegistry +{ + /// + /// Add a shaped crafting recipe. + /// + /// The output item identifier. + /// Number of items produced. + /// Crafting grid pattern rows (e.g. "XXX", " | ", " | " for a pickaxe). + /// Character-to-ingredient mappings. + 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}"); + } + + /// + /// Add a furnace/smelting recipe. + /// + /// The input item/block identifier. + /// The output item identifier. + /// Experience granted per smelt. + 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)"); + } +} diff --git a/LegacyForge.API/Registry.cs b/LegacyForge.API/Registry.cs new file mode 100644 index 0000000..315ced1 --- /dev/null +++ b/LegacyForge.API/Registry.cs @@ -0,0 +1,45 @@ +using LegacyForge.API.Block; +using LegacyForge.API.Item; +using LegacyForge.API.Entity; +using LegacyForge.API.Recipe; + +namespace LegacyForge.API; + +/// +/// Central access point for all LegacyForge registries. +/// Use Registry.Block, Registry.Item, Registry.Entity, or Registry.Recipe to register content. +/// +public static class Registry +{ + /// Block registration. Call Register() with a namespaced ID and BlockProperties. + public static class Block + { + public static RegisteredBlock Register(Identifier id, BlockProperties properties) + => BlockRegistry.Register(id, properties); + } + + /// Item registration. Call Register() with a namespaced ID and ItemProperties. + public static class Item + { + public static RegisteredItem Register(Identifier id, ItemProperties properties) + => ItemRegistry.Register(id, properties); + } + + /// Entity registration. Call Register() with a namespaced ID and EntityDefinition. + public static class Entity + { + public static RegisteredEntity Register(Identifier id, EntityDefinition definition) + => EntityRegistry.Register(id, definition); + } + + /// Recipe registration for crafting and smelting. + 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); + } +} diff --git a/LegacyForge.Core/LegacyForge.Core.csproj b/LegacyForge.Core/LegacyForge.Core.csproj new file mode 100644 index 0000000..66d72a5 --- /dev/null +++ b/LegacyForge.Core/LegacyForge.Core.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + LegacyForge.Core + LegacyForge.Core + LegacyForge core runtime - mod discovery and lifecycle management + 1.0.0 + + + + + + + diff --git a/LegacyForge.Core/LegacyForge.Core.runtimeconfig.json b/LegacyForge.Core/LegacyForge.Core.runtimeconfig.json new file mode 100644 index 0000000..f0eda9a --- /dev/null +++ b/LegacyForge.Core/LegacyForge.Core.runtimeconfig.json @@ -0,0 +1,10 @@ +{ + "runtimeOptions": { + "tfm": "net8.0", + "rollForward": "LatestMinor", + "framework": { + "name": "Microsoft.NETCore.App", + "version": "8.0.0" + } + } +} diff --git a/LegacyForge.Core/LegacyForgeCore.cs b/LegacyForge.Core/LegacyForgeCore.cs new file mode 100644 index 0000000..c17d50e --- /dev/null +++ b/LegacyForge.Core/LegacyForgeCore.cs @@ -0,0 +1,98 @@ +using System.Runtime.InteropServices; +using LegacyForge.API; + +namespace LegacyForge.Core; + +/// +/// 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); +/// +public static class LegacyForgeCore +{ + private static ModManager? _modManager; + private static bool _initialized; + + /// + /// Called once by C++ to initialize the managed runtime. + /// Sets up the native log handler and prepares the mod manager. + /// + 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; + } + + /// + /// Called by C++ to discover and load mod assemblies from the mods/ directory. + /// The mods path is passed as a UTF-8 string pointer. + /// + 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; + } + + /// Called before MinecraftWorld_RunStaticCtors. + public static int PreInit(IntPtr args, int sizeBytes) + { + _modManager?.PreInit(); + return 0; + } + + /// Called after MinecraftWorld_RunStaticCtors. + public static int Init(IntPtr args, int sizeBytes) + { + _modManager?.Init(); + return 0; + } + + /// Called after Minecraft::init completes. + public static int PostInit(IntPtr args, int sizeBytes) + { + _modManager?.PostInit(); + return 0; + } + + /// Called each game tick from the Minecraft::tick hook. + public static int Tick(IntPtr args, int sizeBytes) + { + _modManager?.Tick(); + return 0; + } + + /// Called from the Minecraft::destroy hook during shutdown. + public static int Shutdown(IntPtr args, int sizeBytes) + { + _modManager?.Shutdown(); + Logger.Info("LegacyForge shut down."); + return 0; + } +} diff --git a/LegacyForge.Core/ModDiscovery.cs b/LegacyForge.Core/ModDiscovery.cs new file mode 100644 index 0000000..d6b1075 --- /dev/null +++ b/LegacyForge.Core/ModDiscovery.cs @@ -0,0 +1,86 @@ +using System.Reflection; +using System.Runtime.Loader; +using LegacyForge.API; + +namespace LegacyForge.Core; + +/// +/// Discovers and loads mod assemblies from the mods/ directory. +/// Each mod is loaded into its own AssemblyLoadContext for isolation. +/// +internal static class ModDiscovery +{ + internal record DiscoveredMod( + IMod Instance, + ModAttribute Metadata, + Assembly Assembly); + + internal static List DiscoverMods(string modsPath) + { + var mods = new List(); + + 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 LoadModAssembly(string dllPath) + { + var results = new List(); + 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(); + 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; + } +} diff --git a/LegacyForge.Core/ModManager.cs b/LegacyForge.Core/ModManager.cs new file mode 100644 index 0000000..fc75cf1 --- /dev/null +++ b/LegacyForge.Core/ModManager.cs @@ -0,0 +1,75 @@ +using LegacyForge.API; + +namespace LegacyForge.Core; + +/// +/// Manages the lifecycle of all loaded mods. +/// Catches exceptions from individual mods to prevent one broken mod from crashing the game. +/// +internal class ModManager +{ + private readonly List _mods = new(); + + internal int ModCount => _mods.Count; + + internal void AddMods(IEnumerable 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 ?? ""); + } + } +} diff --git a/LegacyForge.Launcher/Config.cs b/LegacyForge.Launcher/Config.cs new file mode 100644 index 0000000..226b353 --- /dev/null +++ b/LegacyForge.Launcher/Config.cs @@ -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(json, JsonOptions) ?? new Config(); + } + catch + { + return new Config(); + } + } + + public void Save(string path) + { + var json = JsonSerializer.Serialize(this, JsonOptions); + File.WriteAllText(path, json); + } +} diff --git a/LegacyForge.Launcher/Injector.cs b/LegacyForge.Launcher/Injector.cs new file mode 100644 index 0000000..c9c153a --- /dev/null +++ b/LegacyForge.Launcher/Injector.cs @@ -0,0 +1,183 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; + +namespace LegacyForge.Launcher; + +/// +/// Handles launching the game process in a suspended state and injecting +/// the LegacyForgeRuntime DLL via CreateRemoteThread + LoadLibraryW. +/// +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() }; + + 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 +} diff --git a/LegacyForge.Launcher/LegacyForge.Launcher.csproj b/LegacyForge.Launcher/LegacyForge.Launcher.csproj new file mode 100644 index 0000000..b0ddf4f --- /dev/null +++ b/LegacyForge.Launcher/LegacyForge.Launcher.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + enable + LegacyForge.Launcher + LegacyForge + LegacyForge launcher - injects the mod loader runtime into Minecraft Legacy Edition + 1.0.0 + + + diff --git a/LegacyForge.Launcher/Program.cs b/LegacyForge.Launcher/Program.cs new file mode 100644 index 0000000..012dace --- /dev/null +++ b/LegacyForge.Launcher/Program.cs @@ -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; + } + } +} diff --git a/LegacyForge.sln b/LegacyForge.sln new file mode 100644 index 0000000..e4b4ed9 --- /dev/null +++ b/LegacyForge.sln @@ -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 diff --git a/LegacyForgeRuntime/CMakeLists.txt b/LegacyForgeRuntime/CMakeLists.txt new file mode 100644 index 0000000..06b99c7 --- /dev/null +++ b/LegacyForgeRuntime/CMakeLists.txt @@ -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" +) diff --git a/LegacyForgeRuntime/src/DotNetHost.cpp b/LegacyForgeRuntime/src/DotNetHost.cpp new file mode 100644 index 0000000..ffbfb52 --- /dev/null +++ b/LegacyForgeRuntime/src/DotNetHost.cpp @@ -0,0 +1,198 @@ +#include "DotNetHost.h" + +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include + +#include +#include + +// 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( + GetProcAddress(lib, "hostfxr_initialize_for_runtime_config")); + get_delegate_fptr = reinterpret_cast( + GetProcAddress(lib, "hostfxr_get_runtime_delegate")); + close_fptr = reinterpret_cast( + 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_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(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(&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(modsPath), static_cast(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() +{ +} diff --git a/LegacyForgeRuntime/src/DotNetHost.h b/LegacyForgeRuntime/src/DotNetHost.h new file mode 100644 index 0000000..52b64b6 --- /dev/null +++ b/LegacyForgeRuntime/src/DotNetHost.h @@ -0,0 +1,18 @@ +#pragma once +#include + +/// 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(); +} diff --git a/LegacyForgeRuntime/src/GameHooks.cpp b/LegacyForgeRuntime/src/GameHooks.cpp new file mode 100644 index 0000000..cf463d8 --- /dev/null +++ b/LegacyForgeRuntime/src/GameHooks.cpp @@ -0,0 +1,48 @@ +#include "GameHooks.h" +#include "DotNetHost.h" +#include + +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); + } +} diff --git a/LegacyForgeRuntime/src/GameHooks.h b/LegacyForgeRuntime/src/GameHooks.h new file mode 100644 index 0000000..78c43b3 --- /dev/null +++ b/LegacyForgeRuntime/src/GameHooks.h @@ -0,0 +1,22 @@ +#pragma once +#include + +/// 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); +} diff --git a/LegacyForgeRuntime/src/HookManager.cpp b/LegacyForgeRuntime/src/HookManager.cpp new file mode 100644 index 0000000..975e221 --- /dev/null +++ b/LegacyForgeRuntime/src/HookManager.cpp @@ -0,0 +1,77 @@ +#include "HookManager.h" +#include "GameHooks.h" +#include "SymbolResolver.h" +#include +#include + +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(&GameHooks::Hooked_RunStaticCtors), + reinterpret_cast(&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(&GameHooks::Hooked_MinecraftTick), + reinterpret_cast(&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(&GameHooks::Hooked_MinecraftInit), + reinterpret_cast(&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(&GameHooks::Hooked_MinecraftDestroy), + reinterpret_cast(&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(); +} diff --git a/LegacyForgeRuntime/src/HookManager.h b/LegacyForgeRuntime/src/HookManager.h new file mode 100644 index 0000000..2d7cb39 --- /dev/null +++ b/LegacyForgeRuntime/src/HookManager.h @@ -0,0 +1,9 @@ +#pragma once + +class SymbolResolver; + +namespace HookManager +{ + bool Install(const SymbolResolver& symbols); + void Cleanup(); +} diff --git a/LegacyForgeRuntime/src/IdRegistry.cpp b/LegacyForgeRuntime/src/IdRegistry.cpp new file mode 100644 index 0000000..0213589 --- /dev/null +++ b/LegacyForgeRuntime/src/IdRegistry.cpp @@ -0,0 +1,78 @@ +#include "IdRegistry.h" +#include + +IdRegistry& IdRegistry::Instance() +{ + static IdRegistry instance; + return instance; +} + +IdRegistry::IdRegistry() +{ + m_registries[static_cast(Type::Block)].nextFreeId = BLOCK_MOD_START; + m_registries[static_cast(Type::Item)].nextFreeId = ITEM_MOD_START; + m_registries[static_cast(Type::Entity)].nextFreeId = ENTITY_MOD_START; +} + +int IdRegistry::Register(Type type, const std::string& namespacedId) +{ + std::lock_guard lock(m_mutex); + auto& reg = m_registries[static_cast(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(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 lock(m_mutex); + const auto& reg = m_registries[static_cast(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 lock(m_mutex); + const auto& reg = m_registries[static_cast(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 lock(m_mutex); + auto& reg = m_registries[static_cast(type)]; + reg.stringToNum[namespacedId] = numericId; + reg.numToString[numericId] = namespacedId; +} diff --git a/LegacyForgeRuntime/src/IdRegistry.h b/LegacyForgeRuntime/src/IdRegistry.h new file mode 100644 index 0000000..42511a8 --- /dev/null +++ b/LegacyForgeRuntime/src/IdRegistry.h @@ -0,0 +1,48 @@ +#pragma once +#include +#include +#include + +/// 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 stringToNum; + std::unordered_map 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; +}; diff --git a/LegacyForgeRuntime/src/NativeExports.cpp b/LegacyForgeRuntime/src/NativeExports.cpp new file mode 100644 index 0000000..04e3004 --- /dev/null +++ b/LegacyForgeRuntime/src/NativeExports.cpp @@ -0,0 +1,128 @@ +#include "NativeExports.h" +#include "IdRegistry.h" +#include +#include + +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" diff --git a/LegacyForgeRuntime/src/NativeExports.h b/LegacyForgeRuntime/src/NativeExports.h new file mode 100644 index 0000000..500e415 --- /dev/null +++ b/LegacyForgeRuntime/src/NativeExports.h @@ -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); +} diff --git a/LegacyForgeRuntime/src/SymbolResolver.cpp b/LegacyForgeRuntime/src/SymbolResolver.cpp new file mode 100644 index 0000000..c36e7f3 --- /dev/null +++ b/LegacyForgeRuntime/src/SymbolResolver.cpp @@ -0,0 +1,121 @@ +#include "SymbolResolver.h" +#include +#include + +#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(buffer); + symbol->SizeOfStruct = sizeof(SYMBOL_INFO); + symbol->MaxNameLen = MAX_SYM_NAME; + + if (SymFromName(m_process, functionName, symbol)) + { + return reinterpret_cast(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(UserContext); + if (strstr(pSymInfo->Name, ctx->targetName) != nullptr) + { + ctx->result = reinterpret_cast(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; + } +} diff --git a/LegacyForgeRuntime/src/SymbolResolver.h b/LegacyForgeRuntime/src/SymbolResolver.h new file mode 100644 index 0000000..3fb1eab --- /dev/null +++ b/LegacyForgeRuntime/src/SymbolResolver.h @@ -0,0 +1,23 @@ +#pragma once +#define WIN32_LEAN_AND_MEAN +#include +#include + +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; +}; diff --git a/LegacyForgeRuntime/src/dllmain.cpp b/LegacyForgeRuntime/src/dllmain.cpp new file mode 100644 index 0000000..d57f896 --- /dev/null +++ b/LegacyForgeRuntime/src/dllmain.cpp @@ -0,0 +1,75 @@ +#define WIN32_LEAN_AND_MEAN +#include +#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; +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..f95cc96 --- /dev/null +++ b/README.md @@ -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)