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

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);
}
}