mirror of
https://github.com/Jacobwasbeast/LegacyWeaveLoader.git
synced 2026-05-21 21:24:30 +00:00
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:
51
.gitignore
vendored
Normal file
51
.gitignore
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
# Build outputs
|
||||
bin/
|
||||
obj/
|
||||
out/
|
||||
build/
|
||||
x64/
|
||||
x86/
|
||||
Debug/
|
||||
Release/
|
||||
RelWithDebInfo/
|
||||
MinSizeRel/
|
||||
*.exe
|
||||
*.dll
|
||||
*.lib
|
||||
*.pdb
|
||||
*.ilk
|
||||
*.exp
|
||||
*.obj
|
||||
*.o
|
||||
|
||||
# .NET
|
||||
*.user
|
||||
*.suo
|
||||
*.vs/
|
||||
.vs/
|
||||
*.nupkg
|
||||
project.lock.json
|
||||
TestResults/
|
||||
|
||||
# CMake
|
||||
CMakeCache.txt
|
||||
CMakeFiles/
|
||||
cmake_install.cmake
|
||||
Makefile
|
||||
*.cmake
|
||||
!CMakeLists.txt
|
||||
!cmake/*.cmake
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Config (user-specific)
|
||||
legacyforge.json
|
||||
48
CONTRIBUTING.md
Normal file
48
CONTRIBUTING.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Contributing to LegacyForge
|
||||
|
||||
Thank you for your interest in contributing to LegacyForge!
|
||||
|
||||
## Project Structure
|
||||
|
||||
- **LegacyForge.Launcher/** -- C# console app that launches the game and injects the runtime DLL
|
||||
- **LegacyForgeRuntime/** -- C++ DLL injected into the game process (hooks, .NET hosting, native exports)
|
||||
- **LegacyForge.Core/** -- C# assembly loaded inside the game process (mod discovery, lifecycle management)
|
||||
- **LegacyForge.API/** -- C# class library that mod authors reference (IMod, Registry, Events)
|
||||
- **ExampleMod/** -- Sample mod demonstrating the API
|
||||
|
||||
## Building
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Visual Studio 2022+ with C++ Desktop Development and .NET 8 workloads
|
||||
- CMake 3.24+
|
||||
- .NET 8.0 SDK
|
||||
|
||||
### C++ Runtime
|
||||
|
||||
```bash
|
||||
cd LegacyForgeRuntime
|
||||
cmake -B build -A x64
|
||||
cmake --build build --config Release
|
||||
```
|
||||
|
||||
### C# Projects
|
||||
|
||||
```bash
|
||||
dotnet build LegacyForge.sln -c Release
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Keep game source modifications at zero -- all integration is via injection and hooking
|
||||
- Test hooks against the latest game build with PDB symbols
|
||||
- Use namespaced string IDs (`"modid:name"`) for all registered content
|
||||
- Catch exceptions from mod code to prevent crashes
|
||||
- Document public API methods with XML doc comments
|
||||
|
||||
## Pull Requests
|
||||
|
||||
- Fork the repository and create a feature branch
|
||||
- Include a clear description of what your PR does
|
||||
- Make sure the C# projects build without warnings
|
||||
- Test DLL injection on a real game build if possible
|
||||
52
ExampleMod/ExampleMod.cs
Normal file
52
ExampleMod/ExampleMod.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using LegacyForge.API;
|
||||
using LegacyForge.API.Block;
|
||||
using LegacyForge.API.Item;
|
||||
using LegacyForge.API.Events;
|
||||
|
||||
namespace ExampleMod;
|
||||
|
||||
[Mod("examplemod", Name = "Example Mod", Version = "1.0.0", Author = "LegacyForge",
|
||||
Description = "A sample mod demonstrating the LegacyForge API")]
|
||||
public class ExampleMod : IMod
|
||||
{
|
||||
public static RegisteredBlock? RubyOre;
|
||||
public static RegisteredItem? Ruby;
|
||||
|
||||
public void OnInitialize()
|
||||
{
|
||||
RubyOre = Registry.Block.Register("examplemod:ruby_ore",
|
||||
new BlockProperties()
|
||||
.Material(MaterialType.Stone)
|
||||
.Hardness(3.0f)
|
||||
.Resistance(15f)
|
||||
.Sound(SoundType.Stone)
|
||||
.Icon("ruby_ore"));
|
||||
|
||||
Ruby = Registry.Item.Register("examplemod:ruby",
|
||||
new ItemProperties().MaxStackSize(64));
|
||||
|
||||
Registry.Recipe.AddFurnace("examplemod:ruby_ore", "examplemod:ruby", 1.0f);
|
||||
|
||||
GameEvents.OnBlockBreak += OnBlockBroken;
|
||||
|
||||
Logger.Info("Example Mod initialized! Ruby ore and ruby registered.");
|
||||
}
|
||||
|
||||
private void OnBlockBroken(object? sender, BlockBreakEventArgs e)
|
||||
{
|
||||
if (RubyOre != null && e.BlockId == RubyOre.StringId.ToString())
|
||||
{
|
||||
Logger.Info($"Ruby ore broken at ({e.X}, {e.Y}, {e.Z})!");
|
||||
}
|
||||
}
|
||||
|
||||
public void OnTick()
|
||||
{
|
||||
// Per-tick logic goes here
|
||||
}
|
||||
|
||||
public void OnShutdown()
|
||||
{
|
||||
Logger.Info("Example Mod shutting down.");
|
||||
}
|
||||
}
|
||||
17
ExampleMod/ExampleMod.csproj
Normal file
17
ExampleMod/ExampleMod.csproj
Normal file
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>ExampleMod</RootNamespace>
|
||||
<AssemblyName>ExampleMod</AssemblyName>
|
||||
<Description>Example mod for LegacyForge demonstrating the mod API</Description>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LegacyForge.API\LegacyForge.API.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 LegacyForge Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
58
LegacyForge.API/Block/BlockProperties.cs
Normal file
58
LegacyForge.API/Block/BlockProperties.cs
Normal 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; }
|
||||
}
|
||||
51
LegacyForge.API/Block/BlockRegistry.cs
Normal file
51
LegacyForge.API/Block/BlockRegistry.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
16
LegacyForge.API/Entity/EntityDefinition.cs
Normal file
16
LegacyForge.API/Entity/EntityDefinition.cs
Normal 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; }
|
||||
}
|
||||
38
LegacyForge.API/Entity/EntityRegistry.cs
Normal file
38
LegacyForge.API/Entity/EntityRegistry.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
60
LegacyForge.API/Events/GameEvents.cs
Normal file
60
LegacyForge.API/Events/GameEvents.cs
Normal 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
39
LegacyForge.API/IMod.cs
Normal 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() { }
|
||||
}
|
||||
39
LegacyForge.API/Identifier.cs
Normal file
39
LegacyForge.API/Identifier.cs
Normal 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);
|
||||
}
|
||||
18
LegacyForge.API/Item/ItemProperties.cs
Normal file
18
LegacyForge.API/Item/ItemProperties.cs
Normal 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; }
|
||||
}
|
||||
46
LegacyForge.API/Item/ItemRegistry.cs
Normal file
46
LegacyForge.API/Item/ItemRegistry.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
13
LegacyForge.API/LegacyForge.API.csproj
Normal file
13
LegacyForge.API/LegacyForge.API.csproj
Normal 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
30
LegacyForge.API/Logger.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
41
LegacyForge.API/ModAttribute.cs
Normal file
41
LegacyForge.API/ModAttribute.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
64
LegacyForge.API/NativeInterop.cs
Normal file
64
LegacyForge.API/NativeInterop.cs
Normal 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);
|
||||
}
|
||||
40
LegacyForge.API/Recipe/RecipeRegistry.cs
Normal file
40
LegacyForge.API/Recipe/RecipeRegistry.cs
Normal 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)");
|
||||
}
|
||||
}
|
||||
45
LegacyForge.API/Registry.cs
Normal file
45
LegacyForge.API/Registry.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
17
LegacyForge.Core/LegacyForge.Core.csproj
Normal file
17
LegacyForge.Core/LegacyForge.Core.csproj
Normal file
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>LegacyForge.Core</RootNamespace>
|
||||
<AssemblyName>LegacyForge.Core</AssemblyName>
|
||||
<Description>LegacyForge core runtime - mod discovery and lifecycle management</Description>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LegacyForge.API\LegacyForge.API.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
10
LegacyForge.Core/LegacyForge.Core.runtimeconfig.json
Normal file
10
LegacyForge.Core/LegacyForge.Core.runtimeconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"runtimeOptions": {
|
||||
"tfm": "net8.0",
|
||||
"rollForward": "LatestMinor",
|
||||
"framework": {
|
||||
"name": "Microsoft.NETCore.App",
|
||||
"version": "8.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
98
LegacyForge.Core/LegacyForgeCore.cs
Normal file
98
LegacyForge.Core/LegacyForgeCore.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using LegacyForge.API;
|
||||
|
||||
namespace LegacyForge.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Entry point class loaded by the C++ DotNetHost via hostfxr.
|
||||
/// All public static methods here are resolved as function pointers from native code.
|
||||
/// Method signatures must match the component_entry_point_fn delegate:
|
||||
/// public delegate int ComponentEntryPoint(IntPtr args, int sizeBytes);
|
||||
/// </summary>
|
||||
public static class LegacyForgeCore
|
||||
{
|
||||
private static ModManager? _modManager;
|
||||
private static bool _initialized;
|
||||
|
||||
/// <summary>
|
||||
/// Called once by C++ to initialize the managed runtime.
|
||||
/// Sets up the native log handler and prepares the mod manager.
|
||||
/// </summary>
|
||||
public static int Initialize(IntPtr args, int sizeBytes)
|
||||
{
|
||||
if (_initialized) return 0;
|
||||
_initialized = true;
|
||||
|
||||
Logger.LogHandler = (message, level) =>
|
||||
{
|
||||
string formatted = $"[LegacyForge/{level}] {message}";
|
||||
try
|
||||
{
|
||||
NativeInterop.native_log(formatted, (int)level);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Console.WriteLine(formatted);
|
||||
}
|
||||
};
|
||||
|
||||
Logger.Info("LegacyForge Core initialized");
|
||||
_modManager = new ModManager();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by C++ to discover and load mod assemblies from the mods/ directory.
|
||||
/// The mods path is passed as a UTF-8 string pointer.
|
||||
/// </summary>
|
||||
public static int DiscoverMods(IntPtr args, int sizeBytes)
|
||||
{
|
||||
string modsPath;
|
||||
if (args != IntPtr.Zero && sizeBytes > 0)
|
||||
modsPath = Marshal.PtrToStringUTF8(args, sizeBytes) ?? "mods";
|
||||
else
|
||||
modsPath = "mods";
|
||||
|
||||
Logger.Info($"Discovering mods in: {modsPath}");
|
||||
var discovered = ModDiscovery.DiscoverMods(modsPath);
|
||||
_modManager?.AddMods(discovered);
|
||||
Logger.Info($"Loaded {discovered.Count} mod(s)");
|
||||
return discovered.Count;
|
||||
}
|
||||
|
||||
/// <summary>Called before MinecraftWorld_RunStaticCtors.</summary>
|
||||
public static int PreInit(IntPtr args, int sizeBytes)
|
||||
{
|
||||
_modManager?.PreInit();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Called after MinecraftWorld_RunStaticCtors.</summary>
|
||||
public static int Init(IntPtr args, int sizeBytes)
|
||||
{
|
||||
_modManager?.Init();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Called after Minecraft::init completes.</summary>
|
||||
public static int PostInit(IntPtr args, int sizeBytes)
|
||||
{
|
||||
_modManager?.PostInit();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Called each game tick from the Minecraft::tick hook.</summary>
|
||||
public static int Tick(IntPtr args, int sizeBytes)
|
||||
{
|
||||
_modManager?.Tick();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Called from the Minecraft::destroy hook during shutdown.</summary>
|
||||
public static int Shutdown(IntPtr args, int sizeBytes)
|
||||
{
|
||||
_modManager?.Shutdown();
|
||||
Logger.Info("LegacyForge shut down.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
86
LegacyForge.Core/ModDiscovery.cs
Normal file
86
LegacyForge.Core/ModDiscovery.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
using LegacyForge.API;
|
||||
|
||||
namespace LegacyForge.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers and loads mod assemblies from the mods/ directory.
|
||||
/// Each mod is loaded into its own AssemblyLoadContext for isolation.
|
||||
/// </summary>
|
||||
internal static class ModDiscovery
|
||||
{
|
||||
internal record DiscoveredMod(
|
||||
IMod Instance,
|
||||
ModAttribute Metadata,
|
||||
Assembly Assembly);
|
||||
|
||||
internal static List<DiscoveredMod> DiscoverMods(string modsPath)
|
||||
{
|
||||
var mods = new List<DiscoveredMod>();
|
||||
|
||||
if (!Directory.Exists(modsPath))
|
||||
{
|
||||
Logger.Warning($"Mods directory not found: {modsPath}");
|
||||
return mods;
|
||||
}
|
||||
|
||||
var dllFiles = Directory.GetFiles(modsPath, "*.dll");
|
||||
Logger.Info($"Scanning {modsPath} -- found {dllFiles.Length} DLL(s)");
|
||||
|
||||
foreach (var dllPath in dllFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var discovered = LoadModAssembly(dllPath);
|
||||
mods.AddRange(discovered);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Failed to load mod from {Path.GetFileName(dllPath)}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return mods;
|
||||
}
|
||||
|
||||
private static List<DiscoveredMod> LoadModAssembly(string dllPath)
|
||||
{
|
||||
var results = new List<DiscoveredMod>();
|
||||
var fileName = Path.GetFileName(dllPath);
|
||||
|
||||
var loadContext = new AssemblyLoadContext(fileName, isCollectible: false);
|
||||
var assembly = loadContext.LoadFromAssemblyPath(Path.GetFullPath(dllPath));
|
||||
|
||||
var modTypes = assembly.GetTypes()
|
||||
.Where(t => t.IsClass && !t.IsAbstract && typeof(IMod).IsAssignableFrom(t));
|
||||
|
||||
foreach (var type in modTypes)
|
||||
{
|
||||
var attr = type.GetCustomAttribute<ModAttribute>();
|
||||
if (attr == null)
|
||||
{
|
||||
Logger.Warning($"Class {type.FullName} in {fileName} implements IMod but is missing [Mod] attribute -- skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var instance = (IMod)Activator.CreateInstance(type)!;
|
||||
results.Add(new ModDiscovery.DiscoveredMod(instance, attr, assembly));
|
||||
|
||||
string name = string.IsNullOrEmpty(attr.Name) ? attr.Id : attr.Name;
|
||||
Logger.Info($"Discovered mod: {name} v{attr.Version} by {attr.Author} ({fileName})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Failed to instantiate mod {type.FullName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (results.Count == 0)
|
||||
Logger.Debug($"No IMod implementations found in {fileName}");
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
75
LegacyForge.Core/ModManager.cs
Normal file
75
LegacyForge.Core/ModManager.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using LegacyForge.API;
|
||||
|
||||
namespace LegacyForge.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the lifecycle of all loaded mods.
|
||||
/// Catches exceptions from individual mods to prevent one broken mod from crashing the game.
|
||||
/// </summary>
|
||||
internal class ModManager
|
||||
{
|
||||
private readonly List<ModDiscovery.DiscoveredMod> _mods = new();
|
||||
|
||||
internal int ModCount => _mods.Count;
|
||||
|
||||
internal void AddMods(IEnumerable<ModDiscovery.DiscoveredMod> mods)
|
||||
{
|
||||
_mods.AddRange(mods);
|
||||
}
|
||||
|
||||
internal void PreInit()
|
||||
{
|
||||
Logger.Info("--- PreInit phase ---");
|
||||
foreach (var mod in _mods)
|
||||
SafeCall(mod, "OnPreInit", () => mod.Instance.OnPreInit());
|
||||
}
|
||||
|
||||
internal void Init()
|
||||
{
|
||||
Logger.Info("--- Initialize phase ---");
|
||||
foreach (var mod in _mods)
|
||||
SafeCall(mod, "OnInitialize", () => mod.Instance.OnInitialize());
|
||||
}
|
||||
|
||||
internal void PostInit()
|
||||
{
|
||||
Logger.Info("--- PostInitialize phase ---");
|
||||
foreach (var mod in _mods)
|
||||
SafeCall(mod, "OnPostInitialize", () => mod.Instance.OnPostInitialize());
|
||||
}
|
||||
|
||||
internal void Tick()
|
||||
{
|
||||
foreach (var mod in _mods)
|
||||
{
|
||||
try
|
||||
{
|
||||
mod.Instance.OnTick();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"[{mod.Metadata.Id}] OnTick error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal void Shutdown()
|
||||
{
|
||||
Logger.Info("--- Shutdown phase ---");
|
||||
foreach (var mod in _mods)
|
||||
SafeCall(mod, "OnShutdown", () => mod.Instance.OnShutdown());
|
||||
}
|
||||
|
||||
private static void SafeCall(ModDiscovery.DiscoveredMod mod, string phase, Action action)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"[{mod.Metadata.Id}] {phase} failed: {ex.Message}");
|
||||
Logger.Debug(ex.StackTrace ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
35
LegacyForge.Launcher/Config.cs
Normal file
35
LegacyForge.Launcher/Config.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LegacyForge.Launcher;
|
||||
|
||||
public class Config
|
||||
{
|
||||
public string? GameExePath { get; set; }
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public static Config Load(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
return new Config();
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<Config>(json, JsonOptions) ?? new Config();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new Config();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(string path)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(this, JsonOptions);
|
||||
File.WriteAllText(path, json);
|
||||
}
|
||||
}
|
||||
183
LegacyForge.Launcher/Injector.cs
Normal file
183
LegacyForge.Launcher/Injector.cs
Normal file
@@ -0,0 +1,183 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace LegacyForge.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// Handles launching the game process in a suspended state and injecting
|
||||
/// the LegacyForgeRuntime DLL via CreateRemoteThread + LoadLibraryW.
|
||||
/// </summary>
|
||||
public static class Injector
|
||||
{
|
||||
public record InjectedProcess(
|
||||
IntPtr ProcessHandle,
|
||||
IntPtr ThreadHandle,
|
||||
int ProcessId);
|
||||
|
||||
public static InjectedProcess LaunchSuspended(string exePath, string? workingDir = null)
|
||||
{
|
||||
workingDir ??= Path.GetDirectoryName(exePath);
|
||||
|
||||
var si = new STARTUPINFO { cb = Marshal.SizeOf<STARTUPINFO>() };
|
||||
|
||||
bool success = CreateProcess(
|
||||
exePath,
|
||||
null,
|
||||
IntPtr.Zero,
|
||||
IntPtr.Zero,
|
||||
false,
|
||||
CREATE_SUSPENDED,
|
||||
IntPtr.Zero,
|
||||
workingDir,
|
||||
ref si,
|
||||
out PROCESS_INFORMATION pi);
|
||||
|
||||
if (!success)
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error(),
|
||||
$"Failed to launch process: {exePath}");
|
||||
|
||||
return new InjectedProcess(pi.hProcess, pi.hThread, pi.dwProcessId);
|
||||
}
|
||||
|
||||
public static void InjectDll(InjectedProcess process, string dllPath)
|
||||
{
|
||||
string fullDllPath = Path.GetFullPath(dllPath);
|
||||
if (!File.Exists(fullDllPath))
|
||||
throw new FileNotFoundException($"Runtime DLL not found: {fullDllPath}");
|
||||
|
||||
byte[] dllPathBytes = Encoding.Unicode.GetBytes(fullDllPath + '\0');
|
||||
|
||||
IntPtr remoteMem = VirtualAllocEx(
|
||||
process.ProcessHandle,
|
||||
IntPtr.Zero,
|
||||
(uint)dllPathBytes.Length,
|
||||
MEM_COMMIT | MEM_RESERVE,
|
||||
PAGE_READWRITE);
|
||||
|
||||
if (remoteMem == IntPtr.Zero)
|
||||
{
|
||||
TerminateProcess(process.ProcessHandle, 1);
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error(),
|
||||
"Failed to allocate memory in target process");
|
||||
}
|
||||
|
||||
bool wrote = WriteProcessMemory(
|
||||
process.ProcessHandle,
|
||||
remoteMem,
|
||||
dllPathBytes,
|
||||
(uint)dllPathBytes.Length,
|
||||
out _);
|
||||
|
||||
if (!wrote)
|
||||
{
|
||||
TerminateProcess(process.ProcessHandle, 1);
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error(),
|
||||
"Failed to write DLL path to target process");
|
||||
}
|
||||
|
||||
IntPtr kernel32 = GetModuleHandle("kernel32.dll");
|
||||
IntPtr loadLibraryW = GetProcAddress(kernel32, "LoadLibraryW");
|
||||
|
||||
IntPtr remoteThread = CreateRemoteThread(
|
||||
process.ProcessHandle,
|
||||
IntPtr.Zero,
|
||||
0,
|
||||
loadLibraryW,
|
||||
remoteMem,
|
||||
0,
|
||||
out _);
|
||||
|
||||
if (remoteThread == IntPtr.Zero)
|
||||
{
|
||||
TerminateProcess(process.ProcessHandle, 1);
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error(),
|
||||
"Failed to create remote thread for DLL injection");
|
||||
}
|
||||
|
||||
WaitForSingleObject(remoteThread, 10000);
|
||||
CloseHandle(remoteThread);
|
||||
VirtualFreeEx(process.ProcessHandle, remoteMem, 0, MEM_RELEASE);
|
||||
}
|
||||
|
||||
public static void ResumeProcess(InjectedProcess process)
|
||||
{
|
||||
ResumeThread(process.ThreadHandle);
|
||||
CloseHandle(process.ThreadHandle);
|
||||
}
|
||||
|
||||
#region Win32 Interop
|
||||
|
||||
private const uint CREATE_SUSPENDED = 0x00000004;
|
||||
private const uint MEM_COMMIT = 0x1000;
|
||||
private const uint MEM_RESERVE = 0x2000;
|
||||
private const uint MEM_RELEASE = 0x8000;
|
||||
private const uint PAGE_READWRITE = 0x04;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct STARTUPINFO
|
||||
{
|
||||
public int cb;
|
||||
public string lpReserved, lpDesktop, lpTitle;
|
||||
public int dwX, dwY, dwXSize, dwYSize;
|
||||
public int dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags;
|
||||
public short wShowWindow, cbReserved2;
|
||||
public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct PROCESS_INFORMATION
|
||||
{
|
||||
public IntPtr hProcess, hThread;
|
||||
public int dwProcessId, dwThreadId;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern bool CreateProcess(
|
||||
string lpApplicationName, string? lpCommandLine,
|
||||
IntPtr lpProcessAttributes, IntPtr lpThreadAttributes,
|
||||
bool bInheritHandles, uint dwCreationFlags,
|
||||
IntPtr lpEnvironment, string? lpCurrentDirectory,
|
||||
ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern IntPtr VirtualAllocEx(
|
||||
IntPtr hProcess, IntPtr lpAddress, uint dwSize,
|
||||
uint flAllocationType, uint flProtect);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool VirtualFreeEx(
|
||||
IntPtr hProcess, IntPtr lpAddress, uint dwSize, uint dwFreeType);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool WriteProcessMemory(
|
||||
IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer,
|
||||
uint nSize, out int lpNumberOfBytesWritten);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern IntPtr CreateRemoteThread(
|
||||
IntPtr hProcess, IntPtr lpThreadAttributes, uint dwStackSize,
|
||||
IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags,
|
||||
out uint lpThreadId);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Ansi)]
|
||||
private static extern IntPtr GetModuleHandle(string lpModuleName);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Ansi)]
|
||||
private static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern uint ResumeThread(IntPtr hThread);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern bool CloseHandle(IntPtr hObject);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern bool TerminateProcess(IntPtr hProcess, uint uExitCode);
|
||||
|
||||
#endregion
|
||||
}
|
||||
14
LegacyForge.Launcher/LegacyForge.Launcher.csproj
Normal file
14
LegacyForge.Launcher/LegacyForge.Launcher.csproj
Normal file
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>LegacyForge.Launcher</RootNamespace>
|
||||
<AssemblyName>LegacyForge</AssemblyName>
|
||||
<Description>LegacyForge launcher - injects the mod loader runtime into Minecraft Legacy Edition</Description>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
83
LegacyForge.Launcher/Program.cs
Normal file
83
LegacyForge.Launcher/Program.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
namespace LegacyForge.Launcher;
|
||||
|
||||
class Program
|
||||
{
|
||||
private const string ConfigFile = "legacyforge.json";
|
||||
private const string RuntimeDll = "LegacyForgeRuntime.dll";
|
||||
|
||||
static int Main(string[] args)
|
||||
{
|
||||
Console.WriteLine("╔══════════════════════════════════╗");
|
||||
Console.WriteLine("║ LegacyForge v1.0 ║");
|
||||
Console.WriteLine("║ Mod Loader for MC Legacy Edition║");
|
||||
Console.WriteLine("╚══════════════════════════════════╝");
|
||||
Console.WriteLine();
|
||||
|
||||
try
|
||||
{
|
||||
var config = Config.Load(ConfigFile);
|
||||
|
||||
if (args.Length > 0 && File.Exists(args[0]))
|
||||
{
|
||||
config.GameExePath = args[0];
|
||||
config.Save(ConfigFile);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(config.GameExePath) || !File.Exists(config.GameExePath))
|
||||
{
|
||||
Console.Write("Enter path to MinecraftLegacy.exe: ");
|
||||
string? input = Console.ReadLine()?.Trim().Trim('"');
|
||||
|
||||
if (string.IsNullOrEmpty(input) || !File.Exists(input))
|
||||
{
|
||||
Console.Error.WriteLine("Error: Invalid path or file not found.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
config.GameExePath = Path.GetFullPath(input);
|
||||
config.Save(ConfigFile);
|
||||
Console.WriteLine($"Saved game path to {ConfigFile}");
|
||||
}
|
||||
|
||||
if (!File.Exists(RuntimeDll))
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {RuntimeDll} not found in current directory.");
|
||||
Console.Error.WriteLine("Make sure the runtime DLL is next to LegacyForge.exe.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
string modsDir = Path.Combine(AppContext.BaseDirectory, "mods");
|
||||
if (!Directory.Exists(modsDir))
|
||||
{
|
||||
Directory.CreateDirectory(modsDir);
|
||||
Console.WriteLine($"Created mods/ directory at {modsDir}");
|
||||
}
|
||||
|
||||
int modCount = Directory.GetFiles(modsDir, "*.dll").Length;
|
||||
Console.WriteLine($"Found {modCount} mod(s) in mods/");
|
||||
Console.WriteLine($"Launching {Path.GetFileName(config.GameExePath)}...");
|
||||
|
||||
var process = Injector.LaunchSuspended(config.GameExePath);
|
||||
Console.WriteLine($"Game process created (PID: {process.ProcessId}), injecting runtime...");
|
||||
|
||||
Injector.InjectDll(process, RuntimeDll);
|
||||
Console.WriteLine("LegacyForgeRuntime.dll injected successfully.");
|
||||
|
||||
Injector.ResumeProcess(process);
|
||||
Console.WriteLine("Game resumed. LegacyForge is active.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Press any key to exit the launcher (game will keep running).");
|
||||
Console.ReadKey(true);
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Fatal error: {ex.Message}");
|
||||
Console.Error.WriteLine(ex.StackTrace);
|
||||
Console.WriteLine("Press any key to exit.");
|
||||
Console.ReadKey(true);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
37
LegacyForge.sln
Normal file
37
LegacyForge.sln
Normal file
@@ -0,0 +1,37 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LegacyForge.Launcher", "LegacyForge.Launcher\LegacyForge.Launcher.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LegacyForge.API", "LegacyForge.API\LegacyForge.API.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LegacyForge.Core", "LegacyForge.Core\LegacyForge.Core.csproj", "{C3D4E5F6-A7B8-9012-CDEF-123456789012}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExampleMod", "ExampleMod\ExampleMod.csproj", "{D4E5F6A7-B8C9-0123-DEF0-234567890123}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D4E5F6A7-B8C9-0123-DEF0-234567890123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D4E5F6A7-B8C9-0123-DEF0-234567890123}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D4E5F6A7-B8C9-0123-DEF0-234567890123}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D4E5F6A7-B8C9-0123-DEF0-234567890123}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
79
LegacyForgeRuntime/CMakeLists.txt
Normal file
79
LegacyForgeRuntime/CMakeLists.txt
Normal file
@@ -0,0 +1,79 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(LegacyForgeRuntime LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
# ── MinHook (fetched from GitHub) ──────────────────────────────────────
|
||||
include(FetchContent)
|
||||
FetchContent_Declare(
|
||||
minhook
|
||||
GIT_REPOSITORY https://github.com/TsudaKageworthy/minhook.git
|
||||
GIT_TAG master
|
||||
)
|
||||
FetchContent_MakeAvailable(minhook)
|
||||
|
||||
# ── Locate .NET hosting headers and libs ───────────────────────────────
|
||||
# Users can set DOTNET_ROOT or NETHOST_DIR; otherwise we try the default SDK path.
|
||||
if(NOT DEFINED NETHOST_INCLUDE_DIR)
|
||||
# Try to find nethost from the .NET SDK installation
|
||||
file(GLOB NETHOST_CANDIDATES
|
||||
"$ENV{ProgramFiles}/dotnet/packs/Microsoft.NETCore.App.Host.win-x64/*/runtimes/win-x64/native"
|
||||
"$ENV{DOTNET_ROOT}/packs/Microsoft.NETCore.App.Host.win-x64/*/runtimes/win-x64/native"
|
||||
)
|
||||
list(SORT NETHOST_CANDIDATES ORDER DESCENDING)
|
||||
if(NETHOST_CANDIDATES)
|
||||
list(GET NETHOST_CANDIDATES 0 NETHOST_DIR)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(NETHOST_DIR)
|
||||
message(STATUS "Using nethost from: ${NETHOST_DIR}")
|
||||
set(NETHOST_INCLUDE_DIR "${NETHOST_DIR}")
|
||||
set(NETHOST_LIB "${NETHOST_DIR}/nethost.lib")
|
||||
else()
|
||||
message(STATUS "nethost not found automatically. Set NETHOST_DIR to the directory containing nethost.h and nethost.lib")
|
||||
# Fall back to bundled headers if present
|
||||
set(NETHOST_INCLUDE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/include")
|
||||
set(NETHOST_LIB "")
|
||||
endif()
|
||||
|
||||
# ── Runtime DLL target ─────────────────────────────────────────────────
|
||||
add_library(LegacyForgeRuntime SHARED
|
||||
src/dllmain.cpp
|
||||
src/SymbolResolver.cpp
|
||||
src/HookManager.cpp
|
||||
src/GameHooks.cpp
|
||||
src/DotNetHost.cpp
|
||||
src/IdRegistry.cpp
|
||||
src/NativeExports.cpp
|
||||
)
|
||||
|
||||
target_include_directories(LegacyForgeRuntime PRIVATE
|
||||
"${NETHOST_INCLUDE_DIR}"
|
||||
)
|
||||
|
||||
target_link_libraries(LegacyForgeRuntime PRIVATE
|
||||
minhook
|
||||
dbghelp
|
||||
)
|
||||
|
||||
if(EXISTS "${NETHOST_LIB}")
|
||||
target_link_libraries(LegacyForgeRuntime PRIVATE "${NETHOST_LIB}")
|
||||
else()
|
||||
message(WARNING "nethost.lib not found. You may need to set NETHOST_DIR.")
|
||||
endif()
|
||||
|
||||
target_compile_definitions(LegacyForgeRuntime PRIVATE
|
||||
WIN32_LEAN_AND_MEAN
|
||||
_CRT_SECURE_NO_WARNINGS
|
||||
)
|
||||
|
||||
if(MSVC)
|
||||
target_compile_options(LegacyForgeRuntime PRIVATE /W3 /MP)
|
||||
endif()
|
||||
|
||||
set_target_properties(LegacyForgeRuntime PROPERTIES
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
|
||||
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
|
||||
)
|
||||
198
LegacyForgeRuntime/src/DotNetHost.cpp
Normal file
198
LegacyForgeRuntime/src/DotNetHost.cpp
Normal file
@@ -0,0 +1,198 @@
|
||||
#include "DotNetHost.h"
|
||||
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <Windows.h>
|
||||
#include <nethost.h>
|
||||
#include <hostfxr.h>
|
||||
#include <coreclr_delegates.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
// hostfxr function pointers
|
||||
static hostfxr_initialize_for_runtime_config_fn init_fptr = nullptr;
|
||||
static hostfxr_get_runtime_delegate_fn get_delegate_fptr = nullptr;
|
||||
static hostfxr_close_fn close_fptr = nullptr;
|
||||
|
||||
// Managed entry points (component_entry_point_fn signature)
|
||||
typedef int (CORECLR_DELEGATE_CALLTYPE *managed_entry_fn)(void* args, int sizeBytes);
|
||||
|
||||
static managed_entry_fn fn_Initialize = nullptr;
|
||||
static managed_entry_fn fn_DiscoverMods = nullptr;
|
||||
static managed_entry_fn fn_PreInit = nullptr;
|
||||
static managed_entry_fn fn_Init = nullptr;
|
||||
static managed_entry_fn fn_PostInit = nullptr;
|
||||
static managed_entry_fn fn_Tick = nullptr;
|
||||
static managed_entry_fn fn_Shutdown = nullptr;
|
||||
|
||||
static bool LoadHostfxr()
|
||||
{
|
||||
char_t buffer[MAX_PATH];
|
||||
size_t buffer_size = sizeof(buffer) / sizeof(char_t);
|
||||
|
||||
int rc = get_hostfxr_path(buffer, &buffer_size, nullptr);
|
||||
if (rc != 0)
|
||||
{
|
||||
printf("[LegacyForge] get_hostfxr_path failed: 0x%x\n", rc);
|
||||
return false;
|
||||
}
|
||||
|
||||
HMODULE lib = LoadLibraryW(buffer);
|
||||
if (!lib)
|
||||
{
|
||||
printf("[LegacyForge] Failed to load hostfxr\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
init_fptr = reinterpret_cast<hostfxr_initialize_for_runtime_config_fn>(
|
||||
GetProcAddress(lib, "hostfxr_initialize_for_runtime_config"));
|
||||
get_delegate_fptr = reinterpret_cast<hostfxr_get_runtime_delegate_fn>(
|
||||
GetProcAddress(lib, "hostfxr_get_runtime_delegate"));
|
||||
close_fptr = reinterpret_cast<hostfxr_close_fn>(
|
||||
GetProcAddress(lib, "hostfxr_close"));
|
||||
|
||||
return init_fptr && get_delegate_fptr && close_fptr;
|
||||
}
|
||||
|
||||
static load_assembly_and_get_function_pointer_fn GetDotNetLoadAssembly(const wchar_t* configPath)
|
||||
{
|
||||
hostfxr_handle cxt = nullptr;
|
||||
int rc = init_fptr(configPath, nullptr, &cxt);
|
||||
if (rc != 0 || cxt == nullptr)
|
||||
{
|
||||
printf("[LegacyForge] hostfxr_initialize failed: 0x%x\n", rc);
|
||||
if (cxt) close_fptr(cxt);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void* load_fn = nullptr;
|
||||
rc = get_delegate_fptr(cxt, hdt_load_assembly_and_get_function_pointer, &load_fn);
|
||||
if (rc != 0 || load_fn == nullptr)
|
||||
{
|
||||
printf("[LegacyForge] hostfxr_get_runtime_delegate failed: 0x%x\n", rc);
|
||||
}
|
||||
|
||||
close_fptr(cxt);
|
||||
return reinterpret_cast<load_assembly_and_get_function_pointer_fn>(load_fn);
|
||||
}
|
||||
|
||||
static bool ResolveManagedMethod(
|
||||
load_assembly_and_get_function_pointer_fn load_fn,
|
||||
const wchar_t* assemblyPath,
|
||||
const wchar_t* methodName,
|
||||
managed_entry_fn* outFn)
|
||||
{
|
||||
int rc = load_fn(
|
||||
assemblyPath,
|
||||
L"LegacyForge.Core.LegacyForgeCore, LegacyForge.Core",
|
||||
methodName,
|
||||
nullptr, // delegate_type_name (null = default component_entry_point_fn)
|
||||
nullptr,
|
||||
reinterpret_cast<void**>(outFn));
|
||||
|
||||
return rc == 0 && *outFn != nullptr;
|
||||
}
|
||||
|
||||
bool DotNetHost::Initialize()
|
||||
{
|
||||
if (!LoadHostfxr())
|
||||
{
|
||||
printf("[LegacyForge] Failed to load hostfxr library\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Paths relative to the runtime DLL (which is in the same dir as LegacyForge.Core.dll)
|
||||
wchar_t modulePath[MAX_PATH];
|
||||
GetModuleFileNameW(nullptr, modulePath, MAX_PATH);
|
||||
|
||||
std::wstring exeDir(modulePath);
|
||||
size_t lastSlash = exeDir.find_last_of(L"\\/");
|
||||
if (lastSlash != std::wstring::npos)
|
||||
exeDir = exeDir.substr(0, lastSlash + 1);
|
||||
else
|
||||
exeDir = L".\\";
|
||||
|
||||
// Look for LegacyForge files next to the runtime DLL, not the game exe
|
||||
HMODULE hSelf = nullptr;
|
||||
GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
|
||||
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
reinterpret_cast<LPCWSTR>(&DotNetHost::Initialize), &hSelf);
|
||||
|
||||
wchar_t selfPath[MAX_PATH];
|
||||
GetModuleFileNameW(hSelf, selfPath, MAX_PATH);
|
||||
std::wstring selfDir(selfPath);
|
||||
lastSlash = selfDir.find_last_of(L"\\/");
|
||||
if (lastSlash != std::wstring::npos)
|
||||
selfDir = selfDir.substr(0, lastSlash + 1);
|
||||
else
|
||||
selfDir = L".\\";
|
||||
|
||||
std::wstring configPath = selfDir + L"LegacyForge.Core.runtimeconfig.json";
|
||||
std::wstring assemblyPath = selfDir + L"LegacyForge.Core.dll";
|
||||
|
||||
auto load_fn = GetDotNetLoadAssembly(configPath.c_str());
|
||||
if (!load_fn)
|
||||
{
|
||||
printf("[LegacyForge] Failed to get load_assembly_and_get_function_pointer\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ok = true;
|
||||
ok &= ResolveManagedMethod(load_fn, assemblyPath.c_str(), L"Initialize", &fn_Initialize);
|
||||
ok &= ResolveManagedMethod(load_fn, assemblyPath.c_str(), L"DiscoverMods", &fn_DiscoverMods);
|
||||
ok &= ResolveManagedMethod(load_fn, assemblyPath.c_str(), L"PreInit", &fn_PreInit);
|
||||
ok &= ResolveManagedMethod(load_fn, assemblyPath.c_str(), L"Init", &fn_Init);
|
||||
ok &= ResolveManagedMethod(load_fn, assemblyPath.c_str(), L"PostInit", &fn_PostInit);
|
||||
ok &= ResolveManagedMethod(load_fn, assemblyPath.c_str(), L"Tick", &fn_Tick);
|
||||
ok &= ResolveManagedMethod(load_fn, assemblyPath.c_str(), L"Shutdown", &fn_Shutdown);
|
||||
|
||||
if (!ok)
|
||||
{
|
||||
printf("[LegacyForge] Failed to resolve one or more managed entry points\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
printf("[LegacyForge] All managed entry points resolved\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
void DotNetHost::CallManagedInit()
|
||||
{
|
||||
if (fn_Initialize)
|
||||
fn_Initialize(nullptr, 0);
|
||||
}
|
||||
|
||||
void DotNetHost::CallDiscoverMods(const char* modsPath)
|
||||
{
|
||||
if (fn_DiscoverMods)
|
||||
fn_DiscoverMods(const_cast<char*>(modsPath), static_cast<int>(strlen(modsPath)));
|
||||
}
|
||||
|
||||
void DotNetHost::CallPreInit()
|
||||
{
|
||||
if (fn_PreInit) fn_PreInit(nullptr, 0);
|
||||
}
|
||||
|
||||
void DotNetHost::CallInit()
|
||||
{
|
||||
if (fn_Init) fn_Init(nullptr, 0);
|
||||
}
|
||||
|
||||
void DotNetHost::CallPostInit()
|
||||
{
|
||||
if (fn_PostInit) fn_PostInit(nullptr, 0);
|
||||
}
|
||||
|
||||
void DotNetHost::CallTick()
|
||||
{
|
||||
if (fn_Tick) fn_Tick(nullptr, 0);
|
||||
}
|
||||
|
||||
void DotNetHost::CallShutdown()
|
||||
{
|
||||
if (fn_Shutdown) fn_Shutdown(nullptr, 0);
|
||||
}
|
||||
|
||||
void DotNetHost::Cleanup()
|
||||
{
|
||||
}
|
||||
18
LegacyForgeRuntime/src/DotNetHost.h
Normal file
18
LegacyForgeRuntime/src/DotNetHost.h
Normal file
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
/// Hosts the .NET CoreCLR runtime inside the game process using the hostfxr API.
|
||||
/// Loads LegacyForge.Core.dll and resolves managed entry point methods.
|
||||
namespace DotNetHost
|
||||
{
|
||||
bool Initialize();
|
||||
void Cleanup();
|
||||
|
||||
void CallManagedInit();
|
||||
void CallDiscoverMods(const char* modsPath);
|
||||
void CallPreInit();
|
||||
void CallInit();
|
||||
void CallPostInit();
|
||||
void CallTick();
|
||||
void CallShutdown();
|
||||
}
|
||||
48
LegacyForgeRuntime/src/GameHooks.cpp
Normal file
48
LegacyForgeRuntime/src/GameHooks.cpp
Normal file
@@ -0,0 +1,48 @@
|
||||
#include "GameHooks.h"
|
||||
#include "DotNetHost.h"
|
||||
#include <cstdio>
|
||||
|
||||
namespace GameHooks
|
||||
{
|
||||
RunStaticCtors_fn Original_RunStaticCtors = nullptr;
|
||||
MinecraftTick_fn Original_MinecraftTick = nullptr;
|
||||
MinecraftInit_fn Original_MinecraftInit = nullptr;
|
||||
MinecraftDestroy_fn Original_MinecraftDestroy = nullptr;
|
||||
|
||||
void Hooked_RunStaticCtors()
|
||||
{
|
||||
printf("[LegacyForge] RunStaticCtors hook fired -- calling PreInit\n");
|
||||
DotNetHost::CallPreInit();
|
||||
|
||||
Original_RunStaticCtors();
|
||||
|
||||
printf("[LegacyForge] RunStaticCtors complete -- calling Init\n");
|
||||
DotNetHost::CallInit();
|
||||
}
|
||||
|
||||
void __fastcall Hooked_MinecraftTick(void* thisPtr, bool bFirst, bool bUpdateTextures)
|
||||
{
|
||||
Original_MinecraftTick(thisPtr, bFirst, bUpdateTextures);
|
||||
|
||||
if (bFirst)
|
||||
{
|
||||
DotNetHost::CallTick();
|
||||
}
|
||||
}
|
||||
|
||||
void __fastcall Hooked_MinecraftInit(void* thisPtr)
|
||||
{
|
||||
Original_MinecraftInit(thisPtr);
|
||||
|
||||
printf("[LegacyForge] Minecraft::init complete -- calling PostInit\n");
|
||||
DotNetHost::CallPostInit();
|
||||
}
|
||||
|
||||
void __fastcall Hooked_MinecraftDestroy(void* thisPtr)
|
||||
{
|
||||
printf("[LegacyForge] Minecraft::destroy -- calling Shutdown\n");
|
||||
DotNetHost::CallShutdown();
|
||||
|
||||
Original_MinecraftDestroy(thisPtr);
|
||||
}
|
||||
}
|
||||
22
LegacyForgeRuntime/src/GameHooks.h
Normal file
22
LegacyForgeRuntime/src/GameHooks.h
Normal file
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
/// Function pointer typedefs matching the game's function signatures.
|
||||
/// On x64 MSVC, member functions use __fastcall-like convention (this in rcx).
|
||||
typedef void (*RunStaticCtors_fn)();
|
||||
typedef void (__fastcall *MinecraftTick_fn)(void* thisPtr, bool bFirst, bool bUpdateTextures);
|
||||
typedef void (__fastcall *MinecraftInit_fn)(void* thisPtr);
|
||||
typedef void (__fastcall *MinecraftDestroy_fn)(void* thisPtr);
|
||||
|
||||
namespace GameHooks
|
||||
{
|
||||
extern RunStaticCtors_fn Original_RunStaticCtors;
|
||||
extern MinecraftTick_fn Original_MinecraftTick;
|
||||
extern MinecraftInit_fn Original_MinecraftInit;
|
||||
extern MinecraftDestroy_fn Original_MinecraftDestroy;
|
||||
|
||||
void Hooked_RunStaticCtors();
|
||||
void __fastcall Hooked_MinecraftTick(void* thisPtr, bool bFirst, bool bUpdateTextures);
|
||||
void __fastcall Hooked_MinecraftInit(void* thisPtr);
|
||||
void __fastcall Hooked_MinecraftDestroy(void* thisPtr);
|
||||
}
|
||||
77
LegacyForgeRuntime/src/HookManager.cpp
Normal file
77
LegacyForgeRuntime/src/HookManager.cpp
Normal file
@@ -0,0 +1,77 @@
|
||||
#include "HookManager.h"
|
||||
#include "GameHooks.h"
|
||||
#include "SymbolResolver.h"
|
||||
#include <MinHook.h>
|
||||
#include <cstdio>
|
||||
|
||||
bool HookManager::Install(const SymbolResolver& symbols)
|
||||
{
|
||||
if (MH_Initialize() != MH_OK)
|
||||
{
|
||||
printf("[LegacyForge] MH_Initialize failed\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hook MinecraftWorld_RunStaticCtors
|
||||
if (symbols.pRunStaticCtors)
|
||||
{
|
||||
if (MH_CreateHook(symbols.pRunStaticCtors,
|
||||
reinterpret_cast<void*>(&GameHooks::Hooked_RunStaticCtors),
|
||||
reinterpret_cast<void**>(&GameHooks::Original_RunStaticCtors)) != MH_OK)
|
||||
{
|
||||
printf("[LegacyForge] Failed to hook RunStaticCtors\n");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Hook Minecraft::tick
|
||||
if (symbols.pMinecraftTick)
|
||||
{
|
||||
if (MH_CreateHook(symbols.pMinecraftTick,
|
||||
reinterpret_cast<void*>(&GameHooks::Hooked_MinecraftTick),
|
||||
reinterpret_cast<void**>(&GameHooks::Original_MinecraftTick)) != MH_OK)
|
||||
{
|
||||
printf("[LegacyForge] Failed to hook Minecraft::tick\n");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Hook Minecraft::init
|
||||
if (symbols.pMinecraftInit)
|
||||
{
|
||||
if (MH_CreateHook(symbols.pMinecraftInit,
|
||||
reinterpret_cast<void*>(&GameHooks::Hooked_MinecraftInit),
|
||||
reinterpret_cast<void**>(&GameHooks::Original_MinecraftInit)) != MH_OK)
|
||||
{
|
||||
printf("[LegacyForge] Failed to hook Minecraft::init\n");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Hook Minecraft::destroy
|
||||
if (symbols.pMinecraftDestroy)
|
||||
{
|
||||
if (MH_CreateHook(symbols.pMinecraftDestroy,
|
||||
reinterpret_cast<void*>(&GameHooks::Hooked_MinecraftDestroy),
|
||||
reinterpret_cast<void**>(&GameHooks::Original_MinecraftDestroy)) != MH_OK)
|
||||
{
|
||||
printf("[LegacyForge] Failed to hook Minecraft::destroy\n");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (MH_EnableHook(MH_ALL_HOOKS) != MH_OK)
|
||||
{
|
||||
printf("[LegacyForge] MH_EnableHook(MH_ALL_HOOKS) failed\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
printf("[LegacyForge] All hooks installed and enabled\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
void HookManager::Cleanup()
|
||||
{
|
||||
MH_DisableHook(MH_ALL_HOOKS);
|
||||
MH_Uninitialize();
|
||||
}
|
||||
9
LegacyForgeRuntime/src/HookManager.h
Normal file
9
LegacyForgeRuntime/src/HookManager.h
Normal file
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
class SymbolResolver;
|
||||
|
||||
namespace HookManager
|
||||
{
|
||||
bool Install(const SymbolResolver& symbols);
|
||||
void Cleanup();
|
||||
}
|
||||
78
LegacyForgeRuntime/src/IdRegistry.cpp
Normal file
78
LegacyForgeRuntime/src/IdRegistry.cpp
Normal file
@@ -0,0 +1,78 @@
|
||||
#include "IdRegistry.h"
|
||||
#include <cstdio>
|
||||
|
||||
IdRegistry& IdRegistry::Instance()
|
||||
{
|
||||
static IdRegistry instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
IdRegistry::IdRegistry()
|
||||
{
|
||||
m_registries[static_cast<int>(Type::Block)].nextFreeId = BLOCK_MOD_START;
|
||||
m_registries[static_cast<int>(Type::Item)].nextFreeId = ITEM_MOD_START;
|
||||
m_registries[static_cast<int>(Type::Entity)].nextFreeId = ENTITY_MOD_START;
|
||||
}
|
||||
|
||||
int IdRegistry::Register(Type type, const std::string& namespacedId)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
auto& reg = m_registries[static_cast<int>(type)];
|
||||
|
||||
auto it = reg.stringToNum.find(namespacedId);
|
||||
if (it != reg.stringToNum.end())
|
||||
return it->second;
|
||||
|
||||
int maxId;
|
||||
switch (type)
|
||||
{
|
||||
case Type::Block: maxId = BLOCK_MAX; break;
|
||||
case Type::Item: maxId = ITEM_MAX; break;
|
||||
case Type::Entity: maxId = ENTITY_MAX; break;
|
||||
default: return -1;
|
||||
}
|
||||
|
||||
if (reg.nextFreeId > maxId)
|
||||
{
|
||||
printf("[LegacyForge] IdRegistry: No free IDs for type %d (max %d)\n",
|
||||
static_cast<int>(type), maxId);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int id = reg.nextFreeId++;
|
||||
|
||||
// Skip IDs that are already taken by vanilla entries
|
||||
while (reg.numToString.count(id) && id <= maxId)
|
||||
id = reg.nextFreeId++;
|
||||
|
||||
if (id > maxId) return -1;
|
||||
|
||||
reg.stringToNum[namespacedId] = id;
|
||||
reg.numToString[id] = namespacedId;
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
int IdRegistry::GetNumericId(Type type, const std::string& namespacedId) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
const auto& reg = m_registries[static_cast<int>(type)];
|
||||
auto it = reg.stringToNum.find(namespacedId);
|
||||
return (it != reg.stringToNum.end()) ? it->second : -1;
|
||||
}
|
||||
|
||||
std::string IdRegistry::GetStringId(Type type, int numericId) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
const auto& reg = m_registries[static_cast<int>(type)];
|
||||
auto it = reg.numToString.find(numericId);
|
||||
return (it != reg.numToString.end()) ? it->second : "";
|
||||
}
|
||||
|
||||
void IdRegistry::RegisterVanilla(Type type, int numericId, const std::string& namespacedId)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
auto& reg = m_registries[static_cast<int>(type)];
|
||||
reg.stringToNum[namespacedId] = numericId;
|
||||
reg.numToString[numericId] = namespacedId;
|
||||
}
|
||||
48
LegacyForgeRuntime/src/IdRegistry.h
Normal file
48
LegacyForgeRuntime/src/IdRegistry.h
Normal file
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <mutex>
|
||||
|
||||
/// Maps namespaced string IDs ("namespace:path") to auto-allocated numeric IDs.
|
||||
/// Separate pools for blocks, items, and entities.
|
||||
/// Thread-safe for registration calls from the .NET runtime.
|
||||
class IdRegistry
|
||||
{
|
||||
public:
|
||||
enum class Type { Block, Item, Entity };
|
||||
|
||||
static IdRegistry& Instance();
|
||||
|
||||
/// Register a new entry and auto-allocate a numeric ID.
|
||||
/// Returns the allocated numeric ID, or -1 on failure.
|
||||
int Register(Type type, const std::string& namespacedId);
|
||||
|
||||
/// Look up the numeric ID for a string ID. Returns -1 if not found.
|
||||
int GetNumericId(Type type, const std::string& namespacedId) const;
|
||||
|
||||
/// Look up the string ID for a numeric ID. Returns empty string if not found.
|
||||
std::string GetStringId(Type type, int numericId) const;
|
||||
|
||||
/// Pre-register a vanilla entry with a known numeric ID.
|
||||
void RegisterVanilla(Type type, int numericId, const std::string& namespacedId);
|
||||
|
||||
private:
|
||||
IdRegistry();
|
||||
|
||||
struct RegistryData
|
||||
{
|
||||
std::unordered_map<std::string, int> stringToNum;
|
||||
std::unordered_map<int, std::string> numToString;
|
||||
int nextFreeId;
|
||||
};
|
||||
|
||||
static constexpr int BLOCK_MOD_START = 256;
|
||||
static constexpr int BLOCK_MAX = 4095;
|
||||
static constexpr int ITEM_MOD_START = 1000;
|
||||
static constexpr int ITEM_MAX = 31999;
|
||||
static constexpr int ENTITY_MOD_START = 1000;
|
||||
static constexpr int ENTITY_MAX = 9999;
|
||||
|
||||
RegistryData m_registries[3];
|
||||
mutable std::mutex m_mutex;
|
||||
};
|
||||
128
LegacyForgeRuntime/src/NativeExports.cpp
Normal file
128
LegacyForgeRuntime/src/NativeExports.cpp
Normal file
@@ -0,0 +1,128 @@
|
||||
#include "NativeExports.h"
|
||||
#include "IdRegistry.h"
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
extern "C"
|
||||
{
|
||||
|
||||
int native_register_block(
|
||||
const char* namespacedId,
|
||||
int materialId,
|
||||
float hardness,
|
||||
float resistance,
|
||||
int soundType,
|
||||
const char* iconName,
|
||||
float lightEmission,
|
||||
int lightBlock)
|
||||
{
|
||||
if (!namespacedId) return -1;
|
||||
|
||||
int id = IdRegistry::Instance().Register(IdRegistry::Type::Block, namespacedId);
|
||||
if (id < 0)
|
||||
{
|
||||
printf("[LegacyForge] Failed to allocate block ID for '%s'\n", namespacedId);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// TODO: Once we have access to game headers, create a GenericTile instance
|
||||
// and insert it into Tile::tiles[id]. For now we just allocate the ID.
|
||||
printf("[LegacyForge] Registered block '%s' -> ID %d (hardness=%.1f, resistance=%.1f)\n",
|
||||
namespacedId, id, hardness, resistance);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
int native_register_item(
|
||||
const char* namespacedId,
|
||||
int maxStackSize,
|
||||
int maxDamage)
|
||||
{
|
||||
if (!namespacedId) return -1;
|
||||
|
||||
int id = IdRegistry::Instance().Register(IdRegistry::Type::Item, namespacedId);
|
||||
if (id < 0)
|
||||
{
|
||||
printf("[LegacyForge] Failed to allocate item ID for '%s'\n", namespacedId);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// TODO: Create GenericItem and insert into Item::items[256 + id]
|
||||
printf("[LegacyForge] Registered item '%s' -> ID %d (stack=%d, durability=%d)\n",
|
||||
namespacedId, id, maxStackSize, maxDamage);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
int native_register_entity(
|
||||
const char* namespacedId,
|
||||
float width,
|
||||
float height,
|
||||
int trackingRange)
|
||||
{
|
||||
if (!namespacedId) return -1;
|
||||
|
||||
int id = IdRegistry::Instance().Register(IdRegistry::Type::Entity, namespacedId);
|
||||
if (id < 0)
|
||||
{
|
||||
printf("[LegacyForge] Failed to allocate entity ID for '%s'\n", namespacedId);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// TODO: Register with EntityIO via resolved PDB symbols
|
||||
printf("[LegacyForge] Registered entity '%s' -> ID %d (%.1fx%.1f)\n",
|
||||
namespacedId, id, width, height);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
void native_add_shaped_recipe(
|
||||
const char* resultId,
|
||||
int resultCount,
|
||||
const char* pattern,
|
||||
const char* ingredientIds)
|
||||
{
|
||||
// TODO: Parse pattern and ingredients, call game's recipe registration
|
||||
printf("[LegacyForge] Added shaped recipe: %dx %s\n", resultCount, resultId);
|
||||
}
|
||||
|
||||
void native_add_furnace_recipe(
|
||||
const char* inputId,
|
||||
const char* outputId,
|
||||
float xp)
|
||||
{
|
||||
// TODO: Call game's FurnaceRecipes::addRecipe via resolved symbols
|
||||
printf("[LegacyForge] Added furnace recipe: %s -> %s (%.1f xp)\n", inputId, outputId, xp);
|
||||
}
|
||||
|
||||
void native_log(const char* message, int level)
|
||||
{
|
||||
if (message)
|
||||
printf("%s\n", message);
|
||||
}
|
||||
|
||||
int native_get_block_id(const char* namespacedId)
|
||||
{
|
||||
if (!namespacedId) return -1;
|
||||
return IdRegistry::Instance().GetNumericId(IdRegistry::Type::Block, namespacedId);
|
||||
}
|
||||
|
||||
int native_get_item_id(const char* namespacedId)
|
||||
{
|
||||
if (!namespacedId) return -1;
|
||||
return IdRegistry::Instance().GetNumericId(IdRegistry::Type::Item, namespacedId);
|
||||
}
|
||||
|
||||
int native_get_entity_id(const char* namespacedId)
|
||||
{
|
||||
if (!namespacedId) return -1;
|
||||
return IdRegistry::Instance().GetNumericId(IdRegistry::Type::Entity, namespacedId);
|
||||
}
|
||||
|
||||
void native_subscribe_event(const char* eventName, void* managedFnPtr)
|
||||
{
|
||||
// TODO: Store managed callback pointers and invoke from game hooks
|
||||
printf("[LegacyForge] Event subscription: %s\n", eventName ? eventName : "(null)");
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
51
LegacyForgeRuntime/src/NativeExports.h
Normal file
51
LegacyForgeRuntime/src/NativeExports.h
Normal file
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
|
||||
/// Exported C functions callable from C# via P/Invoke.
|
||||
/// All registration functions accept namespaced string IDs and delegate
|
||||
/// to IdRegistry for numeric ID allocation.
|
||||
extern "C"
|
||||
{
|
||||
__declspec(dllexport) int native_register_block(
|
||||
const char* namespacedId,
|
||||
int materialId,
|
||||
float hardness,
|
||||
float resistance,
|
||||
int soundType,
|
||||
const char* iconName,
|
||||
float lightEmission,
|
||||
int lightBlock);
|
||||
|
||||
__declspec(dllexport) int native_register_item(
|
||||
const char* namespacedId,
|
||||
int maxStackSize,
|
||||
int maxDamage);
|
||||
|
||||
__declspec(dllexport) int native_register_entity(
|
||||
const char* namespacedId,
|
||||
float width,
|
||||
float height,
|
||||
int trackingRange);
|
||||
|
||||
__declspec(dllexport) void native_add_shaped_recipe(
|
||||
const char* resultId,
|
||||
int resultCount,
|
||||
const char* pattern,
|
||||
const char* ingredientIds);
|
||||
|
||||
__declspec(dllexport) void native_add_furnace_recipe(
|
||||
const char* inputId,
|
||||
const char* outputId,
|
||||
float xp);
|
||||
|
||||
__declspec(dllexport) void native_log(
|
||||
const char* message,
|
||||
int level);
|
||||
|
||||
__declspec(dllexport) int native_get_block_id(const char* namespacedId);
|
||||
__declspec(dllexport) int native_get_item_id(const char* namespacedId);
|
||||
__declspec(dllexport) int native_get_entity_id(const char* namespacedId);
|
||||
|
||||
__declspec(dllexport) void native_subscribe_event(
|
||||
const char* eventName,
|
||||
void* managedFnPtr);
|
||||
}
|
||||
121
LegacyForgeRuntime/src/SymbolResolver.cpp
Normal file
121
LegacyForgeRuntime/src/SymbolResolver.cpp
Normal file
@@ -0,0 +1,121 @@
|
||||
#include "SymbolResolver.h"
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#pragma comment(lib, "dbghelp.lib")
|
||||
|
||||
bool SymbolResolver::Initialize()
|
||||
{
|
||||
m_process = GetCurrentProcess();
|
||||
|
||||
SymSetOptions(SYMOPT_UNDNAME | SYMOPT_DEFERRED_LOADS | SYMOPT_LOAD_LINES | SYMOPT_DEBUG);
|
||||
|
||||
if (!SymInitialize(m_process, nullptr, TRUE))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
m_initialized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void* SymbolResolver::Resolve(const char* functionName)
|
||||
{
|
||||
if (!m_initialized) return nullptr;
|
||||
|
||||
char buffer[sizeof(SYMBOL_INFO) + MAX_SYM_NAME * sizeof(char)];
|
||||
memset(buffer, 0, sizeof(buffer));
|
||||
|
||||
SYMBOL_INFO* symbol = reinterpret_cast<SYMBOL_INFO*>(buffer);
|
||||
symbol->SizeOfStruct = sizeof(SYMBOL_INFO);
|
||||
symbol->MaxNameLen = MAX_SYM_NAME;
|
||||
|
||||
if (SymFromName(m_process, functionName, symbol))
|
||||
{
|
||||
return reinterpret_cast<void*>(symbol->Address);
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
struct EnumContext
|
||||
{
|
||||
const char* targetName;
|
||||
void* result;
|
||||
};
|
||||
|
||||
static BOOL CALLBACK EnumSymbolsCallback(PSYMBOL_INFO pSymInfo, ULONG SymbolSize, PVOID UserContext)
|
||||
{
|
||||
auto* ctx = static_cast<EnumContext*>(UserContext);
|
||||
if (strstr(pSymInfo->Name, ctx->targetName) != nullptr)
|
||||
{
|
||||
ctx->result = reinterpret_cast<void*>(pSymInfo->Address);
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
bool SymbolResolver::ResolveGameFunctions()
|
||||
{
|
||||
// MinecraftWorld_RunStaticCtors is a free function (not mangled in complex ways)
|
||||
pRunStaticCtors = Resolve("MinecraftWorld_RunStaticCtors");
|
||||
if (!pRunStaticCtors)
|
||||
{
|
||||
// Try with wildcard enumeration
|
||||
EnumContext ctx = { "MinecraftWorld_RunStaticCtors", nullptr };
|
||||
SymEnumSymbols(m_process, 0, "*MinecraftWorld_RunStaticCtors*", EnumSymbolsCallback, &ctx);
|
||||
pRunStaticCtors = ctx.result;
|
||||
}
|
||||
|
||||
// Minecraft::tick -- MSVC mangles this. Try undecorated name first.
|
||||
pMinecraftTick = Resolve("Minecraft::tick");
|
||||
if (!pMinecraftTick)
|
||||
{
|
||||
EnumContext ctx = { "Minecraft::tick", nullptr };
|
||||
SymEnumSymbols(m_process, 0, "*Minecraft*tick*", EnumSymbolsCallback, &ctx);
|
||||
pMinecraftTick = ctx.result;
|
||||
}
|
||||
|
||||
// Minecraft::init
|
||||
pMinecraftInit = Resolve("Minecraft::init");
|
||||
if (!pMinecraftInit)
|
||||
{
|
||||
EnumContext ctx = { "Minecraft::init", nullptr };
|
||||
SymEnumSymbols(m_process, 0, "*Minecraft*init*", EnumSymbolsCallback, &ctx);
|
||||
pMinecraftInit = ctx.result;
|
||||
}
|
||||
|
||||
// Minecraft::destroy
|
||||
pMinecraftDestroy = Resolve("Minecraft::destroy");
|
||||
if (!pMinecraftDestroy)
|
||||
{
|
||||
EnumContext ctx = { "Minecraft::destroy", nullptr };
|
||||
SymEnumSymbols(m_process, 0, "*Minecraft*destroy*", EnumSymbolsCallback, &ctx);
|
||||
pMinecraftDestroy = ctx.result;
|
||||
}
|
||||
|
||||
bool allResolved = pRunStaticCtors && pMinecraftTick && pMinecraftInit && pMinecraftDestroy;
|
||||
|
||||
if (pRunStaticCtors) printf("[LegacyForge] Resolved RunStaticCtors @ %p\n", pRunStaticCtors);
|
||||
else printf("[LegacyForge] MISSING: MinecraftWorld_RunStaticCtors\n");
|
||||
|
||||
if (pMinecraftTick) printf("[LegacyForge] Resolved Minecraft::tick @ %p\n", pMinecraftTick);
|
||||
else printf("[LegacyForge] MISSING: Minecraft::tick\n");
|
||||
|
||||
if (pMinecraftInit) printf("[LegacyForge] Resolved Minecraft::init @ %p\n", pMinecraftInit);
|
||||
else printf("[LegacyForge] MISSING: Minecraft::init\n");
|
||||
|
||||
if (pMinecraftDestroy) printf("[LegacyForge] Resolved Minecraft::destroy @ %p\n", pMinecraftDestroy);
|
||||
else printf("[LegacyForge] MISSING: Minecraft::destroy\n");
|
||||
|
||||
return allResolved;
|
||||
}
|
||||
|
||||
void SymbolResolver::Cleanup()
|
||||
{
|
||||
if (m_initialized)
|
||||
{
|
||||
SymCleanup(m_process);
|
||||
m_initialized = false;
|
||||
}
|
||||
}
|
||||
23
LegacyForgeRuntime/src/SymbolResolver.h
Normal file
23
LegacyForgeRuntime/src/SymbolResolver.h
Normal file
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <Windows.h>
|
||||
#include <DbgHelp.h>
|
||||
|
||||
class SymbolResolver
|
||||
{
|
||||
public:
|
||||
bool Initialize();
|
||||
bool ResolveGameFunctions();
|
||||
void Cleanup();
|
||||
|
||||
void* Resolve(const char* functionName);
|
||||
|
||||
void* pRunStaticCtors = nullptr;
|
||||
void* pMinecraftTick = nullptr;
|
||||
void* pMinecraftInit = nullptr;
|
||||
void* pMinecraftDestroy = nullptr;
|
||||
|
||||
private:
|
||||
HANDLE m_process = nullptr;
|
||||
bool m_initialized = false;
|
||||
};
|
||||
75
LegacyForgeRuntime/src/dllmain.cpp
Normal file
75
LegacyForgeRuntime/src/dllmain.cpp
Normal file
@@ -0,0 +1,75 @@
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <Windows.h>
|
||||
#include "SymbolResolver.h"
|
||||
#include "HookManager.h"
|
||||
#include "DotNetHost.h"
|
||||
|
||||
static HMODULE g_hModule = nullptr;
|
||||
|
||||
static void LogToFile(const char* msg)
|
||||
{
|
||||
FILE* f = nullptr;
|
||||
fopen_s(&f, "legacyforge.log", "a");
|
||||
if (f)
|
||||
{
|
||||
fprintf(f, "%s\n", msg);
|
||||
fclose(f);
|
||||
}
|
||||
}
|
||||
|
||||
DWORD WINAPI InitThread(LPVOID lpParam)
|
||||
{
|
||||
LogToFile("[LegacyForge] InitThread started");
|
||||
|
||||
SymbolResolver symbols;
|
||||
if (!symbols.Initialize())
|
||||
{
|
||||
LogToFile("[LegacyForge] ERROR: Failed to initialize symbol resolver. Is the PDB present?");
|
||||
return 1;
|
||||
}
|
||||
LogToFile("[LegacyForge] Symbol resolver initialized");
|
||||
|
||||
if (!symbols.ResolveGameFunctions())
|
||||
{
|
||||
LogToFile("[LegacyForge] ERROR: Failed to resolve one or more game functions");
|
||||
return 1;
|
||||
}
|
||||
LogToFile("[LegacyForge] Game functions resolved from PDB");
|
||||
|
||||
if (!HookManager::Install(symbols))
|
||||
{
|
||||
LogToFile("[LegacyForge] ERROR: Failed to install hooks");
|
||||
return 1;
|
||||
}
|
||||
LogToFile("[LegacyForge] Hooks installed");
|
||||
|
||||
if (!DotNetHost::Initialize())
|
||||
{
|
||||
LogToFile("[LegacyForge] ERROR: Failed to initialize .NET host");
|
||||
return 1;
|
||||
}
|
||||
LogToFile("[LegacyForge] .NET runtime initialized");
|
||||
|
||||
DotNetHost::CallManagedInit();
|
||||
DotNetHost::CallDiscoverMods("mods");
|
||||
LogToFile("[LegacyForge] Mod discovery complete. Ready.");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
|
||||
{
|
||||
switch (ul_reason_for_call)
|
||||
{
|
||||
case DLL_PROCESS_ATTACH:
|
||||
g_hModule = hModule;
|
||||
DisableThreadLibraryCalls(hModule);
|
||||
CreateThread(nullptr, 0, InitThread, nullptr, 0, nullptr);
|
||||
break;
|
||||
|
||||
case DLL_PROCESS_DETACH:
|
||||
HookManager::Cleanup();
|
||||
break;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
89
README.md
Normal file
89
README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# LegacyForge
|
||||
|
||||
An SKSE-style mod loader for Minecraft Legacy Edition. LegacyForge injects into the game process at runtime, hooks key engine functions, and hosts the .NET runtime to load C# mods. **Zero game source modifications required.**
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **LegacyForge.exe** launches the game in a suspended state and injects `LegacyForgeRuntime.dll`
|
||||
2. The runtime DLL uses PDB debug symbols to locate game functions (`MinecraftWorld_RunStaticCtors`, `Minecraft::tick`, etc.)
|
||||
3. MinHook detours those functions to insert mod lifecycle callbacks
|
||||
4. The .NET CoreCLR runtime is hosted inside the game process via hostfxr
|
||||
5. `LegacyForge.Core` discovers and loads C# mod assemblies from the `mods/` folder
|
||||
6. Mods use the `LegacyForge.API` to register blocks, items, entities, and subscribe to game events using Fabric-style namespaced string IDs
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
ModLoader/
|
||||
├── LegacyForge.Launcher/ C# launcher (the exe users run)
|
||||
├── LegacyForgeRuntime/ C++ DLL (injected into game process)
|
||||
├── LegacyForge.Core/ C# mod management (loaded inside game)
|
||||
├── LegacyForge.API/ C# mod API (what mod authors reference)
|
||||
└── ExampleMod/ Sample mod for reference
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Visual Studio 2022 or later (with C++ and .NET workloads)
|
||||
- .NET 8.0 SDK or later
|
||||
- CMake 3.24 or later
|
||||
- The game must be compiled with PDB generation
|
||||
|
||||
### Build Steps
|
||||
|
||||
**C++ Runtime DLL:**
|
||||
|
||||
```bash
|
||||
cd LegacyForgeRuntime
|
||||
cmake -B build -A x64
|
||||
cmake --build build --config Release
|
||||
```
|
||||
|
||||
**C# Projects:**
|
||||
|
||||
```bash
|
||||
dotnet build LegacyForge.sln
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
1. Build LegacyForge (see above)
|
||||
2. Copy the output files to a folder:
|
||||
- `LegacyForge.exe`
|
||||
- `LegacyForgeRuntime.dll`
|
||||
- `LegacyForge.Core.dll`
|
||||
- `LegacyForge.API.dll`
|
||||
3. Create a `mods/` folder and drop mod DLLs in it
|
||||
4. Run `LegacyForge.exe` -- it will ask for the game exe path on first launch
|
||||
5. The game starts with mods loaded
|
||||
|
||||
## Writing a Mod
|
||||
|
||||
Create a new .NET 8 class library and reference `LegacyForge.API`:
|
||||
|
||||
```csharp
|
||||
using LegacyForge.API;
|
||||
|
||||
[Mod("mymod", Name = "My Mod", Version = "1.0.0", Author = "You")]
|
||||
public class MyMod : IMod
|
||||
{
|
||||
public void OnInitialize()
|
||||
{
|
||||
var myBlock = Registry.Block.Register("mymod:cool_block",
|
||||
new BlockProperties()
|
||||
.Material(MaterialType.Stone)
|
||||
.Hardness(2.0f)
|
||||
.Resistance(10f));
|
||||
|
||||
Logger.Info("My Mod loaded!");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Build it, copy the DLL to `mods/`, and launch via LegacyForge.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
Reference in New Issue
Block a user