Mod textures, display names, and atlas injection

Mod Atlas (ModAtlas.cpp/h):
- Build merged terrain.png and items.png from mod assets (blocks/*.png, items/*.png)
- Scan vanilla atlas for empty (fully transparent) cells; place mod textures only there
- Install merged atlases over game files before Minecraft::init; restore originals after
- Hook loadUVs to create SimpleIcon objects for mod textures
- Hook registerIcon to return mod icons when requested by name
- FixupModIcons: copy field_0x48 (source-image ptr) from vanilla icons after init

Mod Strings (ModStrings.cpp/h):
- Store mod display names by description ID
- Hook GetString to serve mod names for blocks/items

API changes:
- BlockProperties/ItemProperties: .Name(displayName), namespaced .Icon()
- NativeInterop: displayName params, native_allocate_description_id, native_register_string
- Registry.Assets for string registration
- Output: mods/LegacyForge.API/, mods/ExampleMod/ (per-mod folders)

Mod discovery:
- Scan mods/*/ for mod folders; load DLLs from each
- LegacyForge.API as mod in mods/LegacyForge.API/

ExampleMod:
- Ruby ore block and ruby item with custom textures and names
- Assets: blocks/ruby_ore.png, items/ruby.png, lang files
- Furnace recipe: ruby_ore -> ruby

Runtime: loadUVs, registerIcon, getResourceAsStream, GetString hooks; stb_image for PNG
This commit is contained in:
Jacobwasbeast
2026-03-06 22:04:15 -06:00
parent 336e037730
commit 2280cb1192
34 changed files with 10770 additions and 50 deletions

View File

@@ -20,13 +20,15 @@ public class ExampleMod : IMod
.Hardness(3.0f)
.Resistance(15f)
.Sound(SoundType.Stone)
.Icon("ruby_ore")
.Icon("examplemod:ruby_ore") // From assets/blocks/ruby_ore.png
.Name("Ruby Ore")
.InCreativeTab(CreativeTab.BuildingBlocks));
Ruby = Registry.Item.Register("examplemod:ruby",
new ItemProperties()
.MaxStackSize(64)
.Icon("ruby")
.Icon("examplemod:ruby") // From assets/items/ruby.png
.Name("Ruby")
.InCreativeTab(CreativeTab.Materials));
Registry.Recipe.AddFurnace("examplemod:ruby_ore", "examplemod:ruby", 1.0f);

View File

@@ -8,13 +8,20 @@
<AssemblyName>ExampleMod</AssemblyName>
<Description>Example mod for LegacyForge demonstrating the mod API</Description>
<Version>1.0.0</Version>
<OutputPath>..\build\mods</OutputPath>
<OutputPath>..\build\mods\ExampleMod</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LegacyForge.API\LegacyForge.API.csproj" />
<!-- Private=false: do not copy LegacyForge.API into ExampleMod. API lives in mods/LegacyForge.API/ -->
<ProjectReference Include="..\LegacyForge.API\LegacyForge.API.csproj">
<Private>false</Private>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<Content Include="assets\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,25 @@
# ExampleMod Assets
## Language files
Language files live in `assets/lang/` with the format `{locale}.lang` (e.g. `en-GB.lang`, `de-DE.lang`).
**Current API:** Use `BlockProperties.Name()` and `ItemProperties.Name()` when registering blocks and items. These set the display name shown in-game. The ModLoader hooks into the game's string lookup so your names appear correctly.
**Future:** Multi-locale support may load from these `.lang` files. Format: `key=value` per line, with `#` for comments.
## Textures
Mod textures are supported via the dynamic atlas system. Place PNG files in:
- **Blocks:** `assets/blocks/{name}.png` → icon `{modid}:{name}` (e.g. `ruby_ore.png``examplemod:ruby_ore`)
- **Items:** `assets/items/{name}.png` → icon `{modid}:{name}` (e.g. `ruby.png``examplemod:ruby`)
The mod ID is derived from the mod folder name (lowercase, hyphens removed). Use the namespaced icon in `BlockProperties.Icon()` and `ItemProperties.Icon()`:
```csharp
.Icon("examplemod:ruby_ore") // block from assets/blocks/ruby_ore.png
.Icon("examplemod:ruby") // item from assets/items/ruby.png
```
Textures must be 16×16 pixels (or any size; they are scaled). For vanilla icons, use names like `gold_ore`, `diamond`, etc.

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 B

View File

@@ -0,0 +1,5 @@
# ExampleMod language file (de-DE)
# German translations for ExampleMod content.
block.examplemod.ruby_ore=Rubinerz
item.examplemod.ruby=Rubin

View File

@@ -0,0 +1,7 @@
# ExampleMod language file (en-GB)
# Display names for blocks and items.
# In the current API, use BlockProperties.Name() and ItemProperties.Name() instead.
# This file documents the expected format for future multi-locale support.
block.examplemod.ruby_ore=Ruby Ore
item.examplemod.ruby=Ruby

View File

@@ -0,0 +1,27 @@
namespace LegacyForge.API.Assets;
/// <summary>
/// Asset registration for mods. Use for language strings and (future) texture paths.
/// Block and item display names are typically set via BlockProperties.Name() and ItemProperties.Name().
/// Use this registry for additional strings (e.g. GUI labels, messages).
/// </summary>
public static class AssetRegistry
{
/// <summary>
/// Register a display string for a custom description ID.
/// Use native_allocate_description_id() to get an ID, then wire it to your custom UI/entity.
/// </summary>
public static void RegisterString(int descriptionId, string displayName)
{
NativeInterop.native_register_string(descriptionId, displayName ?? "");
}
/// <summary>
/// Allocate a new description ID for custom strings.
/// IDs are in the mod range (10000+) and are looked up via the GetString hook.
/// </summary>
public static int AllocateDescriptionId()
{
return NativeInterop.native_allocate_description_id();
}
}

View File

@@ -47,14 +47,18 @@ public class BlockProperties
internal float LightEmissionValue = 0.0f;
internal int LightBlockValue = 255;
internal CreativeTab CreativeTabValue = CreativeTab.None;
internal string? NameValue;
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; }
/// <summary>Icon name in the terrain atlas. Use namespaced ID for mod textures (e.g. "examplemod:ruby_ore" from assets/blocks/ruby_ore.png), or vanilla names like "stone", "gold_ore".</summary>
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; }
public BlockProperties InCreativeTab(CreativeTab tab) { CreativeTabValue = tab; return this; }
/// <summary>Display name shown in-game (e.g. "Ruby Ore"). Used for localization.</summary>
public BlockProperties Name(string displayName) { NameValue = displayName; return this; }
}

View File

@@ -40,7 +40,8 @@ public static class BlockRegistry
(int)properties.SoundValue,
properties.IconValue,
properties.LightEmissionValue,
properties.LightBlockValue);
properties.LightBlockValue,
properties.NameValue ?? "");
if (numericId < 0)
throw new InvalidOperationException($"Failed to register block '{id}'. No free IDs or invalid parameters.");

View File

@@ -9,8 +9,10 @@ public class ItemProperties
internal int MaxDamageValue = 0;
internal string IconValue = "";
internal CreativeTab CreativeTabValue = CreativeTab.None;
internal string? NameValue;
public ItemProperties MaxStackSize(int size) { MaxStackSizeValue = size; return this; }
/// <summary>Icon name in the items atlas. Use namespaced ID for mod textures (e.g. "examplemod:ruby" from assets/items/ruby.png), or vanilla names like "diamond", "ingotIron".</summary>
public ItemProperties Icon(string iconName) { IconValue = iconName; return this; }
/// <summary>
@@ -19,4 +21,6 @@ public class ItemProperties
/// </summary>
public ItemProperties MaxDamage(int damage) { MaxDamageValue = damage; MaxStackSizeValue = 1; return this; }
public ItemProperties InCreativeTab(CreativeTab tab) { CreativeTabValue = tab; return this; }
/// <summary>Display name shown in-game (e.g. "Ruby"). Used for localization.</summary>
public ItemProperties Name(string displayName) { NameValue = displayName; return this; }
}

View File

@@ -36,7 +36,8 @@ public static class ItemRegistry
id.ToString(),
properties.MaxStackSizeValue,
properties.MaxDamageValue,
properties.IconValue);
properties.IconValue,
properties.NameValue ?? "");
if (numericId < 0)
throw new InvalidOperationException($"Failed to register item '{id}'. No free IDs or invalid parameters.");

View File

@@ -8,7 +8,7 @@
<AssemblyName>LegacyForge.API</AssemblyName>
<Description>Mod API for LegacyForge - Minecraft Legacy Edition mod loader</Description>
<Version>1.0.0</Version>
<OutputPath>..\build</OutputPath>
<OutputPath>..\build\mods\LegacyForge.API</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>

View File

@@ -19,14 +19,22 @@ internal static class NativeInterop
int soundType,
string iconName,
float lightEmission,
int lightBlock);
int lightBlock,
string displayName);
[DllImport(RuntimeDll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
internal static extern int native_register_item(
string namespacedId,
int maxStackSize,
int maxDamage,
string iconName);
string iconName,
string displayName);
[DllImport(RuntimeDll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
internal static extern int native_allocate_description_id();
[DllImport(RuntimeDll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
internal static extern void native_register_string(int descriptionId, string displayName);
[DllImport(RuntimeDll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
internal static extern int native_register_entity(

View File

@@ -2,12 +2,13 @@ using LegacyForge.API.Block;
using LegacyForge.API.Item;
using LegacyForge.API.Entity;
using LegacyForge.API.Recipe;
using LegacyForge.API.Assets;
namespace LegacyForge.API;
/// <summary>
/// Central access point for all LegacyForge registries.
/// Use Registry.Block, Registry.Item, Registry.Entity, or Registry.Recipe to register content.
/// Use Registry.Block, Registry.Item, Registry.Entity, Registry.Recipe, or Registry.Assets.
/// </summary>
public static class Registry
{
@@ -42,4 +43,13 @@ public static class Registry
public static void AddFurnace(Identifier input, Identifier output, float xp)
=> RecipeRegistry.AddFurnace(input, output, xp);
}
/// <summary>Asset registration for language strings and (future) textures.</summary>
public static class Assets
{
public static void RegisterString(int descriptionId, string displayName)
=> AssetRegistry.RegisterString(descriptionId, displayName);
public static int AllocateDescriptionId()
=> AssetRegistry.AllocateDescriptionId();
}
}

View File

@@ -0,0 +1,14 @@
using LegacyForge.API;
namespace LegacyForge.Core;
/// <summary>
/// Built-in mod representing the LegacyForge API. Counts in the mod list and appears
/// when mods/LegacyForge.API/ exists. Does not run lifecycle hooks.
/// </summary>
[Mod("legacyforge.api", Name = "LegacyForge API", Version = "1.0.0", Author = "LegacyForge",
Description = "Mod API and shared types")]
internal sealed class LegacyForgeApiMod : IMod
{
public void OnInitialize() { }
}

View File

@@ -21,28 +21,45 @@ internal static class ModDiscovery
return mods;
}
var dllFiles = Directory.GetFiles(modsPath, "*.dll");
Logger.Info($"Scanning {modsPath} -- found {dllFiles.Length} DLL(s)");
foreach (var dllPath in dllFiles)
// Count LegacyForge.API as a mod when its folder exists (mods/LegacyForge.API/)
var apiFolder = Path.Combine(modsPath, "LegacyForge.API");
if (Directory.Exists(apiFolder))
{
string fileName = Path.GetFileName(dllPath);
var apiMod = new LegacyForgeApiMod();
var attr = typeof(LegacyForgeApiMod).GetCustomAttribute<ModAttribute>()!;
mods.Add(new DiscoveredMod(apiMod, attr, typeof(ModDiscovery).Assembly));
Logger.Info($"Discovered mod: {attr.Name} v{attr.Version} by {attr.Author} (mods/LegacyForge.API/)");
}
// Skip the API assembly -- it's a shared dependency, not a mod
if (fileName.Equals("LegacyForge.API.dll", StringComparison.OrdinalIgnoreCase))
continue;
// Scan each mod folder: mods/ExampleMod/, mods/SomeMod/, etc.
// Each subfolder may contain one or more mod DLLs (we skip LegacyForge.API.dll)
var modFolders = Directory.GetDirectories(modsPath);
foreach (var folder in modFolders)
{
var folderName = Path.GetFileName(folder);
var dllFiles = Directory.GetFiles(folder, "*.dll", SearchOption.TopDirectoryOnly);
try
foreach (var dllPath in dllFiles)
{
var discovered = LoadModAssembly(dllPath);
mods.AddRange(discovered);
}
catch (Exception ex)
{
Logger.Error($"Failed to load mod from {fileName}: {ex.Message}");
string fileName = Path.GetFileName(dllPath);
// Skip the API assembly -- it's in its own folder and counted above
if (fileName.Equals("LegacyForge.API.dll", StringComparison.OrdinalIgnoreCase))
continue;
try
{
var discovered = LoadModAssembly(dllPath);
mods.AddRange(discovered);
}
catch (Exception ex)
{
Logger.Error($"Failed to load mod from {fileName}: {ex.Message}");
}
}
}
Logger.Info($"Scanning {modsPath} -- found {mods.Count} mod(s) total");
return mods;
}

View File

@@ -22,6 +22,15 @@ FetchContent_MakeAvailable(minhook)
# Ensure MinHook also uses static release CRT
set_target_properties(minhook PROPERTIES MSVC_RUNTIME_LIBRARY "MultiThreaded")
# ── stb_image (PNG load/save for mod atlas) ───────────────────────────
set(STB_DIR "${CMAKE_CURRENT_SOURCE_DIR}/third_party/stb")
if(NOT EXISTS "${STB_DIR}/stb_image.h")
message(WARNING "stb_image.h not found. Run: curl -sL https://raw.githubusercontent.com/nothings/stb/master/stb_image.h -o third_party/stb/stb_image.h")
endif()
if(NOT EXISTS "${STB_DIR}/stb_image_write.h")
message(WARNING "stb_image_write.h not found. Run: curl -sL https://raw.githubusercontent.com/nothings/stb/master/stb_image_write.h -o third_party/stb/stb_image_write.h")
endif()
# ── raw_pdb (fallback PDB parser for Wine/Proton) ────────────────────
set(RAWPDB_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
FetchContent_Declare(
@@ -81,10 +90,13 @@ add_library(LegacyForgeRuntime SHARED
src/CreativeInventory.cpp
src/MainMenuOverlay.cpp
src/GameObjectFactory.cpp
src/ModStrings.cpp
src/ModAtlas.cpp
)
target_include_directories(LegacyForgeRuntime PRIVATE
"${NETHOST_INCLUDE_DIR}"
"${STB_DIR}"
)
target_link_libraries(LegacyForgeRuntime PRIVATE

View File

@@ -2,7 +2,11 @@
#include "DotNetHost.h"
#include "CreativeInventory.h"
#include "MainMenuOverlay.h"
#include "ModStrings.h"
#include "ModAtlas.h"
#include "LogUtil.h"
#include <Windows.h>
#include <string>
#include <cstdio>
#include <cstring>
@@ -16,6 +20,75 @@ namespace GameHooks
MainMenuCustomDraw_fn Original_MainMenuCustomDraw = nullptr;
Present_fn Original_Present = nullptr;
OutputDebugStringA_fn Original_OutputDebugStringA = nullptr;
GetString_fn Original_GetString = nullptr;
GetResourceAsStream_fn Original_GetResourceAsStream = nullptr;
LoadUVs_fn Original_LoadUVs = nullptr;
RegisterIcon_fn Original_RegisterIcon = nullptr;
void __fastcall Hooked_LoadUVs(void* thisPtr)
{
LogUtil::Log("[LegacyForge] Hooked_LoadUVs: ENTER (textureMap=%p)", thisPtr);
if (Original_LoadUVs)
Original_LoadUVs(thisPtr);
LogUtil::Log("[LegacyForge] Hooked_LoadUVs: original returned, creating mod icons");
ModAtlas::CreateModIcons(thisPtr);
LogUtil::Log("[LegacyForge] Hooked_LoadUVs: DONE");
}
static int s_registerIconCallCount = 0;
void* __fastcall Hooked_RegisterIcon(void* thisPtr, const std::wstring& name)
{
s_registerIconCallCount++;
void* modIcon = ModAtlas::LookupModIcon(name);
if (modIcon)
{
LogUtil::Log("[LegacyForge] registerIcon #%d: '%ls' -> MOD ICON %p",
s_registerIconCallCount, name.c_str(), modIcon);
return modIcon;
}
void* result = Original_RegisterIcon ? Original_RegisterIcon(thisPtr, name) : nullptr;
if (s_registerIconCallCount <= 30 || !result)
{
LogUtil::Log("[LegacyForge] registerIcon #%d: '%ls' -> vanilla %p",
s_registerIconCallCount, name.c_str(), result);
}
return result;
}
void* Hooked_GetResourceAsStream(const void* fileName)
{
const std::wstring* path = static_cast<const std::wstring*>(fileName);
if (ModAtlas::HasModTextures() && Original_GetResourceAsStream && path)
{
std::string terrainPath = ModAtlas::GetMergedTerrainPath();
std::string itemsPath = ModAtlas::GetMergedItemsPath();
if (!terrainPath.empty() && path->find(L"terrain.png") != std::wstring::npos)
{
std::wstring ourPath(terrainPath.begin(), terrainPath.end());
LogUtil::Log("[LegacyForge] getResourceAsStream: redirecting terrain.png to merged atlas");
return Original_GetResourceAsStream(&ourPath);
}
if (!itemsPath.empty() && path->find(L"items.png") != std::wstring::npos)
{
std::wstring ourPath(itemsPath.begin(), itemsPath.end());
LogUtil::Log("[LegacyForge] getResourceAsStream: redirecting items.png to merged atlas");
return Original_GetResourceAsStream(&ourPath);
}
}
return Original_GetResourceAsStream ? Original_GetResourceAsStream(fileName) : nullptr;
}
const wchar_t* Hooked_GetString(int id)
{
if (ModStrings::IsModId(id))
{
const wchar_t* modStr = ModStrings::Get(id);
if (modStr)
return modStr;
}
return Original_GetString ? Original_GetString(id) : L"";
}
void Hooked_RunStaticCtors()
{
@@ -40,8 +113,43 @@ namespace GameHooks
void __fastcall Hooked_MinecraftInit(void* thisPtr)
{
char baseDir[MAX_PATH] = { 0 };
GetModuleFileNameA(nullptr, baseDir, MAX_PATH);
std::string base(baseDir);
size_t pos = base.find_last_of("\\/");
if (pos != std::string::npos) base.resize(pos + 1);
std::string gameResPath = base + "Common\\res\\TitleUpdate\\res";
HMODULE hMod = nullptr;
if (GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, (LPCSTR)&Hooked_MinecraftInit, &hMod) && hMod)
{
char dllPath[MAX_PATH] = { 0 };
if (GetModuleFileNameA(hMod, dllPath, MAX_PATH))
{
std::string dllDir(dllPath);
size_t dllPos = dllDir.find_last_of("\\/");
if (dllPos != std::string::npos)
{
dllDir.resize(dllPos + 1);
std::string modsPath = dllDir + "mods";
ModAtlas::BuildAtlases(modsPath, gameResPath);
goto atlas_done;
}
}
}
ModAtlas::BuildAtlases(base + "mods", gameResPath);
atlas_done:
// Overwrite the game's atlas PNGs with our merged versions BEFORE init
// loads them. The originals are backed up and restored after init.
ModAtlas::InstallAtlasFiles(gameResPath);
Original_MinecraftInit(thisPtr);
// After init, vanilla icons have their source-image pointer (field_0x48)
// fully populated. Copy it to our mod icons so getSourceHeight() works.
ModAtlas::FixupModIcons();
LogUtil::Log("[LegacyForge] Hook: Minecraft::init complete -- calling PostInit");
DotNetHost::CallPostInit();
}

View File

@@ -1,6 +1,7 @@
#pragma once
#include <Windows.h>
#include <cstdint>
#include <string>
/// Function pointer typedefs matching the game's actual function signatures.
/// x64 MSVC uses __fastcall-like convention (this in rcx, args in rdx/r8/r9).
@@ -13,6 +14,10 @@ typedef void (*CreativeStaticCtor_fn)();
typedef void (__fastcall *MainMenuCustomDraw_fn)(void* thisPtr, void* region);
typedef void (__fastcall *Present_fn)(void* thisPtr);
typedef void (WINAPI *OutputDebugStringA_fn)(const char* lpOutputString);
typedef const wchar_t* (*GetString_fn)(int);
typedef void* (*GetResourceAsStream_fn)(const void* fileName);
typedef void (__fastcall *LoadUVs_fn)(void* thisPtr);
typedef void* (__fastcall *RegisterIcon_fn)(void* thisPtr, const std::wstring& name);
namespace GameHooks
{
@@ -24,6 +29,10 @@ namespace GameHooks
extern MainMenuCustomDraw_fn Original_MainMenuCustomDraw;
extern Present_fn Original_Present;
extern OutputDebugStringA_fn Original_OutputDebugStringA;
extern GetString_fn Original_GetString;
extern GetResourceAsStream_fn Original_GetResourceAsStream;
extern LoadUVs_fn Original_LoadUVs;
extern RegisterIcon_fn Original_RegisterIcon;
void Hooked_RunStaticCtors();
void __fastcall Hooked_MinecraftTick(void* thisPtr, bool bFirst, bool bUpdateTextures);
@@ -33,4 +42,8 @@ namespace GameHooks
void __fastcall Hooked_MainMenuCustomDraw(void* thisPtr, void* region);
void __fastcall Hooked_Present(void* thisPtr);
void WINAPI Hooked_OutputDebugStringA(const char* lpOutputString);
const wchar_t* Hooked_GetString(int id);
void* Hooked_GetResourceAsStream(const void* fileName);
void __fastcall Hooked_LoadUVs(void* thisPtr);
void* __fastcall Hooked_RegisterIcon(void* thisPtr, const std::wstring& name);
}

View File

@@ -14,6 +14,8 @@ typedef void* (__fastcall *TileSetFloat_fn)(void* thisPtr, float val);
typedef void* (__fastcall *TileSetSoundType_fn)(void* thisPtr, const void* soundType);
// Tile* Tile::setIconName(const std::wstring&) — protected virtual
typedef void* (__fastcall *TileSetIconName_fn)(void* thisPtr, const std::wstring& name);
// Tile* Tile::setDescriptionId(unsigned int) — public virtual
typedef void* (__fastcall *TileSetDescriptionId_fn)(void* thisPtr, unsigned int id);
// TileItem::TileItem(int id)
typedef void (__fastcall *TileItemCtor_fn)(void* thisPtr, int id);
@@ -22,17 +24,21 @@ typedef void (__fastcall *TileItemCtor_fn)(void* thisPtr, int id);
typedef void (__fastcall *ItemCtor_fn)(void* thisPtr, int id);
// Item* Item::setIconName(const std::wstring&)
typedef void* (__fastcall *ItemSetIconName_fn)(void* thisPtr, const std::wstring& name);
// Item::getDescriptionId(int) — used to extract the descriptionId field offset
typedef unsigned int (__fastcall *ItemGetDescriptionId_fn)(void* thisPtr, int auxData);
static TileCtor_fn fnTileCtor = nullptr;
static TileSetFloat_fn fnSetDestroyTime = nullptr;
static TileSetFloat_fn fnSetExplodeable = nullptr;
static TileSetSoundType_fn fnSetSoundType = nullptr;
static TileSetIconName_fn fnTileSetIconName= nullptr;
static TileSetDescriptionId_fn fnTileSetDescriptionId = nullptr;
static TileItemCtor_fn fnTileItemCtor = nullptr;
static ItemCtor_fn fnItemCtor = nullptr;
static ItemSetIconName_fn fnItemSetIconName= nullptr;
static int s_itemDescIdOffset = -1; // offset of descriptionId field in Item, extracted from getDescriptionId
// Store ADDRESSES of Material*/SoundType* statics so we can dereference lazily
// (they're NULL at resolve time because staticCtor hasn't run yet).
@@ -76,6 +82,8 @@ bool ResolveSymbols(SymbolResolver& resolver)
"?setSoundType@Tile@@MEAAPEAV1@PEBVSoundType@1@@Z");
fnTileSetIconName = (TileSetIconName_fn)resolver.Resolve(
"?setIconName@Tile@@MEAAPEAV1@AEBV?$basic_string@_WU?$char_traits@_W@std@@V?$allocator@_W@2@@std@@@Z");
fnTileSetDescriptionId = (TileSetDescriptionId_fn)resolver.Resolve(
"?setDescriptionId@Tile@@UEAAPEAV1@I@Z");
// TileItem constructor
fnTileItemCtor = (TileItemCtor_fn)resolver.Resolve("??0TileItem@@QEAA@H@Z");
@@ -86,6 +94,34 @@ bool ResolveSymbols(SymbolResolver& resolver)
// Item::setIconName
fnItemSetIconName = (ItemSetIconName_fn)resolver.Resolve(
"?setIconName@Item@@QEAAPEAV1@AEBV?$basic_string@_WU?$char_traits@_W@std@@V?$allocator@_W@2@@std@@@Z");
// Item::setDescriptionId is inlined — extract the field offset from getDescriptionId instead.
// getDescriptionId(int) is "mov eax, [rcx+offset]; ret" so we parse the offset from its opcodes.
void* fnItemGetDescId = resolver.Resolve("?getDescriptionId@Item@@UEAAIH@Z");
if (fnItemGetDescId)
{
const uint8_t* code = static_cast<const uint8_t*>(fnItemGetDescId);
if (code[0] == 0x8B && code[1] == 0x41)
{
// mov eax, [rcx+disp8] — 8B 41 XX
s_itemDescIdOffset = static_cast<int>(code[2]);
LogUtil::Log("[LegacyForge] Item descriptionId offset = 0x%X (from getDescriptionId disp8)", s_itemDescIdOffset);
}
else if (code[0] == 0x8B && code[1] == 0x81)
{
// mov eax, [rcx+disp32] — 8B 81 XX XX XX XX
s_itemDescIdOffset = *reinterpret_cast<const int*>(code + 2);
LogUtil::Log("[LegacyForge] Item descriptionId offset = 0x%X (from getDescriptionId disp32)", s_itemDescIdOffset);
}
else
{
LogUtil::Log("[LegacyForge] Item::getDescriptionId has unexpected opcode pattern: %02X %02X %02X",
code[0], code[1], code[2]);
}
}
else
{
LogUtil::Log("[LegacyForge] MISSING: Item::getDescriptionId — cannot set item display names");
}
// Resolve Material* static pointer ADDRESSES (values are NULL until staticCtor runs)
auto resolveMat = [&](int idx, const char* sym) {
@@ -162,7 +198,7 @@ bool ResolveSymbols(SymbolResolver& resolver)
}
bool CreateTile(int tileId, int materialType, float hardness, float resistance,
int soundType, const wchar_t* iconName)
int soundType, const wchar_t* iconName, int descriptionId)
{
if (!s_resolved || !fnTileCtor)
{
@@ -201,8 +237,13 @@ bool CreateTile(int tileId, int materialType, float hardness, float resistance,
fnTileSetIconName(tile, name);
}
LogUtil::Log("[LegacyForge] Created Tile id=%d (material=%d, icon=%ls)", tileId, materialType,
iconName ? iconName : L"<none>");
if (fnTileSetDescriptionId && descriptionId >= 0)
{
fnTileSetDescriptionId(tile, static_cast<unsigned int>(descriptionId));
}
LogUtil::Log("[LegacyForge] Created Tile id=%d (material=%d, icon=%ls, descId=%d)", tileId, materialType,
iconName ? iconName : L"<none>", descriptionId);
// Create the corresponding TileItem so the block can appear in inventory.
// TileItem(tileId - 256) -> Item::Item(tileId - 256) -> id = tileId, items[tileId] = this
@@ -217,7 +258,7 @@ bool CreateTile(int tileId, int materialType, float hardness, float resistance,
return true;
}
bool CreateItem(int itemId, int maxStackSize, const wchar_t* iconName)
bool CreateItem(int itemId, int maxStackSize, const wchar_t* iconName, int descriptionId)
{
if (!s_resolved || !fnItemCtor)
{
@@ -239,8 +280,14 @@ bool CreateItem(int itemId, int maxStackSize, const wchar_t* iconName)
fnItemSetIconName(item, name);
}
LogUtil::Log("[LegacyForge] Created Item id=%d (ctorParam=%d, icon=%ls)",
itemId, ctorParam, iconName ? iconName : L"<none>");
if (s_itemDescIdOffset > 0 && descriptionId >= 0)
{
*reinterpret_cast<unsigned int*>(static_cast<char*>(item) + s_itemDescIdOffset) =
static_cast<unsigned int>(descriptionId);
}
LogUtil::Log("[LegacyForge] Created Item id=%d (ctorParam=%d, icon=%ls, descId=%d)",
itemId, ctorParam, iconName ? iconName : L"<none>", descriptionId);
return true;
}

View File

@@ -11,10 +11,12 @@ namespace GameObjectFactory
// and create a matching TileItem in Item::items[].
// materialType/soundType map to the C# API enums (see BlockProperties.cs).
// iconName is the texture atlas key (e.g. "ruby_ore").
// descriptionId: if >= 0, call setDescriptionId on the Tile for localization.
bool CreateTile(int tileId, int materialType, float hardness, float resistance,
int soundType, const wchar_t* iconName);
int soundType, const wchar_t* iconName, int descriptionId = -1);
// Create an Item game object. itemId is the FINAL id (256 + constructor param).
// The Item is registered in Item::items[itemId].
bool CreateItem(int itemId, int maxStackSize, const wchar_t* iconName);
// descriptionId: if >= 0, call setDescriptionId on the Item for localization.
bool CreateItem(int itemId, int maxStackSize, const wchar_t* iconName, int descriptionId = -1);
}

View File

@@ -1,5 +1,6 @@
#include "HookManager.h"
#include "GameHooks.h"
#include "ModAtlas.h"
#include "SymbolResolver.h"
#include "CreativeInventory.h"
#include "MainMenuOverlay.h"
@@ -67,6 +68,42 @@ bool HookManager::Install(const SymbolResolver& symbols)
GameObjectFactory::ResolveSymbols(const_cast<SymbolResolver&>(symbols));
if (symbols.pLoadUVs && symbols.pSimpleIconCtor && symbols.pOperatorNew)
{
ModAtlas::SetInjectSymbols(symbols.pSimpleIconCtor, symbols.pOperatorNew);
if (MH_CreateHook(symbols.pLoadUVs,
reinterpret_cast<void*>(&GameHooks::Hooked_LoadUVs),
reinterpret_cast<void**>(&GameHooks::Original_LoadUVs)) != MH_OK)
{
LogUtil::Log("[LegacyForge] Warning: Failed to hook PreStitchedTextureMap::loadUVs (mod textures may not appear)");
}
else
{
LogUtil::Log("[LegacyForge] Hooked PreStitchedTextureMap::loadUVs (mod texture injection)");
}
if (symbols.pRegisterIcon)
{
if (MH_CreateHook(symbols.pRegisterIcon,
reinterpret_cast<void*>(&GameHooks::Hooked_RegisterIcon),
reinterpret_cast<void**>(&GameHooks::Original_RegisterIcon)) != MH_OK)
{
LogUtil::Log("[LegacyForge] Warning: Failed to hook PreStitchedTextureMap::registerIcon");
}
else
{
LogUtil::Log("[LegacyForge] Hooked PreStitchedTextureMap::registerIcon (mod icon lookup)");
// Pass the trampoline to ModAtlas so it can look up vanilla icons
// for copying internal state (field_0x48 source image pointer).
ModAtlas::SetRegisterIconFn(GameHooks::Original_RegisterIcon);
}
}
}
else if (symbols.pLoadUVs)
{
LogUtil::Log("[LegacyForge] Mod texture injection unavailable: SimpleIcon/operator new not resolved");
}
if (symbols.pCreativeStaticCtor)
{
CreativeInventory::ResolveSymbols(const_cast<SymbolResolver&>(symbols));
@@ -113,6 +150,34 @@ bool HookManager::Install(const SymbolResolver& symbols)
}
}
if (symbols.pGetString)
{
if (MH_CreateHook(symbols.pGetString,
reinterpret_cast<void*>(&GameHooks::Hooked_GetString),
reinterpret_cast<void**>(&GameHooks::Original_GetString)) != MH_OK)
{
LogUtil::Log("[LegacyForge] Warning: Failed to hook CMinecraftApp::GetString (mod names unavailable)");
}
else
{
LogUtil::Log("[LegacyForge] Hooked CMinecraftApp::GetString (mod localization)");
}
}
if (symbols.pGetResourceAsStream)
{
if (MH_CreateHook(symbols.pGetResourceAsStream,
reinterpret_cast<void*>(&GameHooks::Hooked_GetResourceAsStream),
reinterpret_cast<void**>(&GameHooks::Original_GetResourceAsStream)) != MH_OK)
{
LogUtil::Log("[LegacyForge] Warning: Failed to hook InputStream::getResourceAsStream (mod atlas unavailable)");
}
else
{
LogUtil::Log("[LegacyForge] Hooked InputStream::getResourceAsStream (mod textures)");
}
}
{
void* pOutputDbgStr = reinterpret_cast<void*>(
GetProcAddress(GetModuleHandleA("kernel32.dll"), "OutputDebugStringA"));

View File

@@ -0,0 +1,449 @@
#include "ModAtlas.h"
#include "LogUtil.h"
#include <Windows.h>
#include <algorithm>
#include <cstring>
#include <fstream>
#include <sstream>
#include <string>
#include <unordered_map>
#define STB_IMAGE_IMPLEMENTATION
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include "stb_image.h"
#include "stb_image_write.h"
namespace ModAtlas
{
static std::string s_mergedDir;
static std::vector<ModTextureEntry> s_blockEntries;
static std::vector<ModTextureEntry> s_itemEntries;
static bool s_hasModTextures = false;
static void* s_simpleIconCtor = nullptr;
static void* (*s_operatorNew)(size_t) = nullptr;
// iconType is at offset 8 in PreStitchedTextureMap (verified via getIconType disassembly)
static std::string ToLower(const std::string& s)
{
std::string r = s;
for (char& c : r)
c = (char)tolower((unsigned char)c);
return r;
}
static void FindModTextures(const std::string& modsPath,
std::vector<std::pair<std::string, std::string>>& blocks,
std::vector<std::pair<std::string, std::string>>& items)
{
blocks.clear();
items.clear();
WIN32_FIND_DATAA fd;
std::string search = modsPath + "\\*";
HANDLE h = FindFirstFileA(search.c_str(), &fd);
if (h == INVALID_HANDLE_VALUE) return;
do
{
if (!(fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) continue;
if (fd.cFileName[0] == '.') continue;
std::string modFolder = modsPath + "\\" + fd.cFileName;
std::string assetsPath = modFolder + "\\assets";
if (GetFileAttributesA(assetsPath.c_str()) != INVALID_FILE_ATTRIBUTES)
{
std::string modId = ToLower(fd.cFileName);
size_t pos = modId.find('-');
while (pos != std::string::npos) { modId.erase(pos, 1); pos = modId.find('-'); }
std::string blocksPath = assetsPath + "\\blocks";
std::string itemsPath = assetsPath + "\\items";
auto scanDir = [&](const std::string& dir, std::vector<std::pair<std::string, std::string>>& out, const std::string& prefix)
{
std::string search2 = dir + "\\*.png";
HANDLE h2 = FindFirstFileA(search2.c_str(), &fd);
if (h2 == INVALID_HANDLE_VALUE) return;
do
{
if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue;
std::string name = fd.cFileName;
name.resize(name.size() - 4);
std::string iconName = modId + ":" + name;
std::string fullPath = dir + "\\" + fd.cFileName;
out.push_back({ iconName, fullPath });
} while (FindNextFileA(h2, &fd));
FindClose(h2);
};
if (GetFileAttributesA(blocksPath.c_str()) == FILE_ATTRIBUTE_DIRECTORY)
scanDir(blocksPath, blocks, "blocks");
if (GetFileAttributesA(itemsPath.c_str()) == FILE_ATTRIBUTE_DIRECTORY)
scanDir(itemsPath, items, "items");
}
} while (FindNextFileA(h, &fd));
FindClose(h);
}
static bool LoadPng(const std::string& path, int* w, int* h, int* comp, unsigned char** data)
{
*data = stbi_load(path.c_str(), w, h, comp, 4);
return *data != nullptr;
}
static void Blit16x16(unsigned char* dst, int dstW, int dstH, int dstX, int dstY,
const unsigned char* src, int srcW, int srcH)
{
for (int y = 0; y < 16; y++)
{
for (int x = 0; x < 16; x++)
{
int sx = (srcW > 0) ? (x * srcW / 16) : 0;
int sy = (srcH > 0) ? (y * srcH / 16) : 0;
int di = ((dstY + y) * dstW + (dstX + x)) * 4;
int si = (sy * srcW + sx) * 4;
if (si < srcW * srcH * 4 && di < dstW * dstH * 4)
{
dst[di] = src[si];
dst[di + 1] = src[si + 1];
dst[di + 2] = src[si + 2];
dst[di + 3] = src[si + 3];
}
}
}
}
static bool IsCellEmpty(const unsigned char* img, int imgW, int imgH,
int cellX, int cellY, int cellSize)
{
for (int y = 0; y < cellSize; y++)
{
for (int x = 0; x < cellSize; x++)
{
int px = cellX + x;
int py = cellY + y;
if (px >= imgW || py >= imgH) continue;
int idx = (py * imgW + px) * 4;
if (img[idx + 3] > 0)
return false;
}
}
return true;
}
static bool BuildAtlas(const std::string& vanillaPath, const std::string& outPath,
const std::vector<std::pair<std::string, std::string>>& modTextures,
int gridCols, int gridRows, int iconSize,
std::vector<ModTextureEntry>& entries)
{
int imgW = gridCols * iconSize;
int imgH = gridRows * iconSize;
unsigned char* img = (unsigned char*)calloc(imgW * imgH, 4);
if (!img) return false;
int vw = 0, vh = 0, vc = 0;
if (!vanillaPath.empty())
{
unsigned char* vanilla = stbi_load(vanillaPath.c_str(), &vw, &vh, &vc, 4);
if (vanilla)
{
int copyW = (vw < imgW) ? vw : imgW;
int copyH = (vh < imgH) ? vh : imgH;
for (int y = 0; y < copyH; y++)
memcpy(img + y * imgW * 4, vanilla + y * vw * 4, copyW * 4);
stbi_image_free(vanilla);
}
}
// Find all empty (fully transparent) cells in the atlas
std::vector<std::pair<int, int>> emptyCells;
for (int row = 0; row < gridRows; row++)
{
for (int col = 0; col < gridCols; col++)
{
if (IsCellEmpty(img, imgW, imgH, col * iconSize, row * iconSize, iconSize))
emptyCells.push_back({ row, col });
}
}
LogUtil::Log("[LegacyForge] ModAtlas: found %zu empty cells in %dx%d atlas",
emptyCells.size(), gridCols, gridRows);
size_t cellIdx = 0;
for (const auto& tex : modTextures)
{
const std::string& iconName = tex.first;
const std::string& path = tex.second;
if (cellIdx >= emptyCells.size())
{
LogUtil::Log("[LegacyForge] ModAtlas: no empty cells left for %s!", iconName.c_str());
break;
}
int sw = 0, sh = 0, sc = 0;
unsigned char* src = nullptr;
if (!LoadPng(path, &sw, &sh, &sc, &src))
{
LogUtil::Log("[LegacyForge] ModAtlas: failed to load %s", path.c_str());
continue;
}
int row = emptyCells[cellIdx].first;
int col = emptyCells[cellIdx].second;
cellIdx++;
Blit16x16(img, imgW, imgH, col * iconSize, row * iconSize, src, sw, sh);
stbi_image_free(src);
std::wstring wname(iconName.begin(), iconName.end());
entries.push_back({ wname, 0, row, col });
LogUtil::Log("[LegacyForge] ModAtlas: placed '%s' at row=%d col=%d", iconName.c_str(), row, col);
}
std::string dir = outPath.substr(0, outPath.find_last_of("\\/"));
CreateDirectoryA(dir.c_str(), nullptr);
int ok = stbi_write_png(outPath.c_str(), imgW, imgH, 4, img, imgW * 4);
free(img);
return ok != 0;
}
std::string BuildAtlases(const std::string& modsPath, const std::string& gameResPath)
{
s_blockEntries.clear();
s_itemEntries.clear();
s_hasModTextures = false;
std::vector<std::pair<std::string, std::string>> blockPaths, itemPaths;
FindModTextures(modsPath, blockPaths, itemPaths);
if (blockPaths.empty() && itemPaths.empty())
{
LogUtil::Log("[LegacyForge] ModAtlas: no mod textures found");
return "";
}
char baseDir[MAX_PATH] = { 0 };
GetModuleFileNameA(nullptr, baseDir, MAX_PATH);
std::string base(baseDir);
size_t pos = base.find_last_of("\\/");
if (pos != std::string::npos) base.resize(pos + 1);
s_mergedDir = base + "mods\\ModLoader\\generated";
CreateDirectoryA((base + "mods").c_str(), nullptr);
CreateDirectoryA((base + "mods\\ModLoader").c_str(), nullptr);
CreateDirectoryA(s_mergedDir.c_str(), nullptr);
std::string vanillaTerrain = gameResPath + "\\terrain.png";
std::string vanillaItems = gameResPath + "\\items.png";
std::string outTerrain = s_mergedDir + "\\terrain.png";
std::string outItems = s_mergedDir + "\\items.png";
if (!blockPaths.empty())
{
if (BuildAtlas(vanillaTerrain, outTerrain, blockPaths, 16, 32, 16, s_blockEntries))
{
s_hasModTextures = true;
LogUtil::Log("[LegacyForge] ModAtlas: built terrain.png with %zu mod textures", s_blockEntries.size());
}
}
if (!itemPaths.empty())
{
if (BuildAtlas(vanillaItems, outItems, itemPaths, 16, 16, 16, s_itemEntries))
{
s_hasModTextures = true;
for (auto& e : s_itemEntries) e.atlasType = 1;
LogUtil::Log("[LegacyForge] ModAtlas: built items.png with %zu mod textures", s_itemEntries.size());
}
}
return s_hasModTextures ? s_mergedDir : "";
}
std::string GetMergedTerrainPath()
{
return s_mergedDir.empty() ? "" : s_mergedDir + "\\terrain.png";
}
std::string GetMergedItemsPath()
{
return s_mergedDir.empty() ? "" : s_mergedDir + "\\items.png";
}
const std::vector<ModTextureEntry>& GetBlockEntries() { return s_blockEntries; }
const std::vector<ModTextureEntry>& GetItemEntries() { return s_itemEntries; }
bool HasModTextures() { return s_hasModTextures; }
static std::unordered_map<std::wstring, void*> s_modIcons;
static RegisterIcon_fn s_originalRegisterIcon = nullptr;
static std::string s_gameResPath;
static std::string s_backupTerrainPath;
static std::string s_backupItemsPath;
// Per-atlas-type textureMap pointers, saved during CreateModIcons for FixupModIcons.
static void* s_terrainTextureMap = nullptr;
static void* s_itemsTextureMap = nullptr;
void SetInjectSymbols(void* simpleIconCtor, void* operatorNew)
{
s_simpleIconCtor = simpleIconCtor;
s_operatorNew = reinterpret_cast<void* (*)(size_t)>(operatorNew);
}
void SetRegisterIconFn(RegisterIcon_fn fn)
{
s_originalRegisterIcon = fn;
}
void InstallAtlasFiles(const std::string& gameResPath)
{
if (!s_hasModTextures) return;
s_gameResPath = gameResPath;
std::string vanillaTerrain = gameResPath + "\\terrain.png";
std::string vanillaItems = gameResPath + "\\items.png";
std::string mergedTerrain = GetMergedTerrainPath();
std::string mergedItems = GetMergedItemsPath();
if (!mergedTerrain.empty() && !s_blockEntries.empty())
{
s_backupTerrainPath = vanillaTerrain + ".legacyforge_backup";
CopyFileA(vanillaTerrain.c_str(), s_backupTerrainPath.c_str(), FALSE);
if (CopyFileA(mergedTerrain.c_str(), vanillaTerrain.c_str(), FALSE))
LogUtil::Log("[LegacyForge] ModAtlas: installed merged terrain.png over game file");
else
LogUtil::Log("[LegacyForge] ModAtlas: WARNING - failed to copy merged terrain.png (err=%lu)", GetLastError());
}
if (!mergedItems.empty() && !s_itemEntries.empty())
{
s_backupItemsPath = vanillaItems + ".legacyforge_backup";
CopyFileA(vanillaItems.c_str(), s_backupItemsPath.c_str(), FALSE);
if (CopyFileA(mergedItems.c_str(), vanillaItems.c_str(), FALSE))
LogUtil::Log("[LegacyForge] ModAtlas: installed merged items.png over game file");
else
LogUtil::Log("[LegacyForge] ModAtlas: WARNING - failed to copy merged items.png (err=%lu)", GetLastError());
}
}
void CreateModIcons(void* textureMap)
{
if (!s_hasModTextures || !s_simpleIconCtor || !textureMap) return;
if (!s_operatorNew) { LogUtil::Log("[LegacyForge] ModAtlas: operator new not resolved, skipping icon creation"); return; }
int iconType = *reinterpret_cast<int*>(reinterpret_cast<char*>(textureMap) + 8);
LogUtil::Log("[LegacyForge] ModAtlas: CreateModIcons called for atlas type %d (textureMap=%p)", iconType, textureMap);
if (iconType == 0) s_terrainTextureMap = textureMap;
else if (iconType == 1) s_itemsTextureMap = textureMap;
typedef void (__fastcall* SimpleIconCtor_fn)(void* thisPtr, const std::wstring* name,
const std::wstring* filename, float u0, float v0, float u1, float v1);
auto ctor = reinterpret_cast<SimpleIconCtor_fn>(s_simpleIconCtor);
auto create = [&](const std::vector<ModTextureEntry>& entries, float vertRatio) {
for (const auto& e : entries)
{
if (e.atlasType != iconType) continue;
float u0 = static_cast<float>(e.col) / 16.0f;
float v0 = static_cast<float>(e.row) * vertRatio;
float u1 = static_cast<float>(e.col + 1) / 16.0f;
float v1 = static_cast<float>(e.row + 1) * vertRatio;
void* icon = s_operatorNew(128);
if (icon)
{
memset(icon, 0, 128);
ctor(icon, &e.iconName, &e.iconName, u0, v0, u1, v1);
s_modIcons[e.iconName] = icon;
LogUtil::Log("[LegacyForge] ModAtlas: created icon '%ls' (atlas=%d, row=%d, col=%d)",
e.iconName.c_str(), iconType, e.row, e.col);
}
}
};
if (iconType == 0)
create(s_blockEntries, 1.0f / 32.0f);
else if (iconType == 1)
create(s_itemEntries, 1.0f / 16.0f);
LogUtil::Log("[LegacyForge] ModAtlas: s_modIcons now has %zu entries total", s_modIcons.size());
}
void FixupModIcons()
{
if (s_modIcons.empty() || !s_originalRegisterIcon) return;
// After Minecraft::init, vanilla icons have field_0x48 properly set.
// Grab the source-image pointer from a vanilla icon for each atlas type
// and copy it to our mod icons.
auto fixForAtlas = [](void* textureMap, int atlasType, const wchar_t* probeName) {
if (!textureMap) return;
std::wstring probeStr(probeName);
void* probeIcon = s_originalRegisterIcon(textureMap, probeStr);
if (!probeIcon)
{
LogUtil::Log("[LegacyForge] FixupModIcons: could not find vanilla icon '%ls'", probeName);
return;
}
void* srcPtr = *reinterpret_cast<void**>(static_cast<char*>(probeIcon) + 0x48);
LogUtil::Log("[LegacyForge] FixupModIcons: vanilla '%ls' field_0x48 = %p", probeName, srcPtr);
if (!srcPtr)
{
LogUtil::Log("[LegacyForge] FixupModIcons: WARNING - vanilla source ptr still NULL for atlas %d", atlasType);
return;
}
int fixed = 0;
for (auto& kv : s_modIcons)
{
void* icon = kv.second;
void* existing = *reinterpret_cast<void**>(static_cast<char*>(icon) + 0x48);
if (!existing)
{
*reinterpret_cast<void**>(static_cast<char*>(icon) + 0x48) = srcPtr;
fixed++;
}
}
LogUtil::Log("[LegacyForge] FixupModIcons: patched field_0x48 on %d mod icons (atlas %d, srcPtr=%p)",
fixed, atlasType, srcPtr);
};
fixForAtlas(s_terrainTextureMap, 0, L"stone");
fixForAtlas(s_itemsTextureMap, 1, L"diamond");
// Restore backed-up vanilla atlas files
if (!s_backupTerrainPath.empty())
{
std::string vanillaTerrain = s_gameResPath + "\\terrain.png";
if (MoveFileExA(s_backupTerrainPath.c_str(), vanillaTerrain.c_str(), MOVEFILE_REPLACE_EXISTING))
LogUtil::Log("[LegacyForge] ModAtlas: restored original terrain.png");
s_backupTerrainPath.clear();
}
if (!s_backupItemsPath.empty())
{
std::string vanillaItems = s_gameResPath + "\\items.png";
if (MoveFileExA(s_backupItemsPath.c_str(), vanillaItems.c_str(), MOVEFILE_REPLACE_EXISTING))
LogUtil::Log("[LegacyForge] ModAtlas: restored original items.png");
s_backupItemsPath.clear();
}
}
void* LookupModIcon(const std::wstring& name)
{
auto it = s_modIcons.find(name);
if (it != s_modIcons.end())
return it->second;
return nullptr;
}
}

View File

@@ -0,0 +1,58 @@
#pragma once
#include <string>
#include <vector>
#include <unordered_map>
/// <summary>
/// Builds merged terrain.png and items.png atlases from mod assets.
/// Scans mods/*/assets/blocks/*.png and items/*.png, stitches into vanilla atlases.
/// </summary>
namespace ModAtlas
{
struct ModTextureEntry
{
std::wstring iconName; // e.g. "examplemod:ruby_ore"
int atlasType; // 0=blocks/terrain, 1=items
int row, col; // Grid position in atlas
};
/// Call before textures->stitch(). Returns path to generated dir, or empty if none.
std::string BuildAtlases(const std::string& modsPath, const std::string& gameResPath);
/// Get path to merged terrain.png (if built)
std::string GetMergedTerrainPath();
/// Get path to merged items.png (if built)
std::string GetMergedItemsPath();
/// Get mod texture entries for injection into texture map
const std::vector<ModTextureEntry>& GetBlockEntries();
const std::vector<ModTextureEntry>& GetItemEntries();
/// Whether atlases were built (have mod textures)
bool HasModTextures();
typedef void* (__fastcall *RegisterIcon_fn)(void* textureMap, const std::wstring& name);
/// Set resolved symbols for texture injection (called from HookManager)
void SetInjectSymbols(void* simpleIconCtor, void* operatorNew);
/// Set the original registerIcon function for vanilla icon lookups
void SetRegisterIconFn(RegisterIcon_fn fn);
/// Copy merged atlas PNGs over the game's vanilla atlas files.
/// Call before Minecraft::init so the game loads our textures.
void InstallAtlasFiles(const std::string& gameResPath);
/// Create SimpleIcon objects for our mod textures after loadUVs runs.
/// Call from loadUVs hook after original returns. atlasType is read from textureMap.
void CreateModIcons(void* textureMap);
/// Fix mod icons' internal source-image pointer (field_0x48) by copying
/// from a fully-initialized vanilla icon. Call AFTER Minecraft::init completes.
void FixupModIcons();
/// Look up a mod icon by name. Returns the SimpleIcon* or nullptr if not a mod icon.
void* LookupModIcon(const std::wstring& name);
}

View File

@@ -0,0 +1,39 @@
#include "ModStrings.h"
#include "LogUtil.h"
#include <vector>
#include <cstring>
namespace ModStrings
{
static std::mutex s_mutex;
static std::unordered_map<int, std::wstring> s_strings;
static int s_nextId = MOD_DESC_ID_BASE;
void Register(int descriptionId, const wchar_t* value)
{
if (!value) return;
std::lock_guard<std::mutex> lock(s_mutex);
s_strings[descriptionId] = value;
LogUtil::Log("[LegacyForge] ModStrings: registered id=%d -> %ls", descriptionId, value);
}
const wchar_t* Get(int descriptionId)
{
std::lock_guard<std::mutex> lock(s_mutex);
auto it = s_strings.find(descriptionId);
if (it != s_strings.end())
return it->second.c_str();
return nullptr;
}
int AllocateId()
{
std::lock_guard<std::mutex> lock(s_mutex);
return s_nextId++;
}
bool IsModId(int id)
{
return id >= MOD_DESC_ID_BASE;
}
}

View File

@@ -0,0 +1,20 @@
#pragma once
#include <unordered_map>
#include <string>
#include <mutex>
/// <summary>
/// Stores mod-registered display names for blocks and items.
/// Maps description IDs (allocated from MOD_DESC_ID_BASE) to wide strings.
/// Hooked into app.GetString() so the game displays mod names.
/// </summary>
namespace ModStrings
{
constexpr int MOD_DESC_ID_BASE = 10000;
void Register(int descriptionId, const wchar_t* value);
const wchar_t* Get(int descriptionId);
int AllocateId();
bool IsModId(int id);
}

View File

@@ -2,11 +2,22 @@
#include "IdRegistry.h"
#include "CreativeInventory.h"
#include "GameObjectFactory.h"
#include "ModStrings.h"
#include "LogUtil.h"
#include <Windows.h>
#include <cstring>
#include <string>
static std::wstring Utf8ToWide(const char* utf8)
{
if (!utf8 || !utf8[0]) return std::wstring();
int len = MultiByteToWideChar(CP_UTF8, 0, utf8, -1, nullptr, 0);
if (len <= 0) return std::wstring();
std::wstring result(len - 1, 0);
MultiByteToWideChar(CP_UTF8, 0, utf8, -1, &result[0], len);
return result;
}
extern "C"
{
@@ -18,7 +29,8 @@ int native_register_block(
int soundType,
const char* iconName,
float lightEmission,
int lightBlock)
int lightBlock,
const char* displayName)
{
if (!namespacedId) return -1;
@@ -32,17 +44,18 @@ int native_register_block(
LogUtil::Log("[LegacyForge] Registered block '%s' -> ID %d (hardness=%.1f, resistance=%.1f)",
namespacedId, id, hardness, resistance);
// Convert icon name from UTF-8 to wide string
std::wstring wIcon;
if (iconName)
std::wstring wIcon = Utf8ToWide(iconName);
int descId = -1;
if (displayName && displayName[0])
{
int len = MultiByteToWideChar(CP_UTF8, 0, iconName, -1, nullptr, 0);
wIcon.resize(len > 0 ? len - 1 : 0);
MultiByteToWideChar(CP_UTF8, 0, iconName, -1, &wIcon[0], len);
descId = ModStrings::AllocateId();
std::wstring wName = Utf8ToWide(displayName);
ModStrings::Register(descId, wName.c_str());
}
if (!GameObjectFactory::CreateTile(id, materialId, hardness, resistance,
soundType, wIcon.empty() ? nullptr : wIcon.c_str()))
soundType, wIcon.empty() ? nullptr : wIcon.c_str(), descId))
{
LogUtil::Log("[LegacyForge] Warning: failed to create game Tile for block '%s' id=%d", namespacedId, id);
}
@@ -54,7 +67,8 @@ int native_register_item(
const char* namespacedId,
int maxStackSize,
int maxDamage,
const char* iconName)
const char* iconName,
const char* displayName)
{
if (!namespacedId) return -1;
@@ -68,15 +82,17 @@ int native_register_item(
LogUtil::Log("[LegacyForge] Registered item '%s' -> ID %d (stack=%d, durability=%d)",
namespacedId, id, maxStackSize, maxDamage);
std::wstring wIcon;
if (iconName && iconName[0])
std::wstring wIcon = Utf8ToWide(iconName);
int descId = -1;
if (displayName && displayName[0])
{
int len = MultiByteToWideChar(CP_UTF8, 0, iconName, -1, nullptr, 0);
wIcon.resize(len > 0 ? len - 1 : 0);
MultiByteToWideChar(CP_UTF8, 0, iconName, -1, &wIcon[0], len);
descId = ModStrings::AllocateId();
std::wstring wName = Utf8ToWide(displayName);
ModStrings::Register(descId, wName.c_str());
}
if (!GameObjectFactory::CreateItem(id, maxStackSize, wIcon.empty() ? nullptr : wIcon.c_str()))
if (!GameObjectFactory::CreateItem(id, maxStackSize, wIcon.empty() ? nullptr : wIcon.c_str(), descId))
{
LogUtil::Log("[LegacyForge] Warning: failed to create game Item for '%s' id=%d", namespacedId, id);
}
@@ -84,6 +100,18 @@ int native_register_item(
return id;
}
int native_allocate_description_id()
{
return ModStrings::AllocateId();
}
void native_register_string(int descriptionId, const char* displayName)
{
if (!displayName) return;
std::wstring wName = Utf8ToWide(displayName);
ModStrings::Register(descriptionId, wName.c_str());
}
int native_register_entity(
const char* namespacedId,
float width,

View File

@@ -13,13 +13,18 @@ extern "C"
int soundType,
const char* iconName,
float lightEmission,
int lightBlock);
int lightBlock,
const char* displayName);
__declspec(dllexport) int native_register_item(
const char* namespacedId,
int maxStackSize,
int maxDamage,
const char* iconName);
const char* iconName,
const char* displayName);
__declspec(dllexport) int native_allocate_description_id();
__declspec(dllexport) void native_register_string(int descriptionId, const char* displayName);
__declspec(dllexport) int native_register_entity(
const char* namespacedId,

View File

@@ -12,6 +12,12 @@ static const char* SYM_EXIT_GAME = "?ExitGame@CConsoleMinecraftApp@@U
static const char* SYM_CREATIVE_STATIC_CTOR = "?staticCtor@IUIScene_CreativeMenu@@SAXXZ";
static const char* SYM_MAINMENU_CUSTOMDRAW = "?customDraw@UIScene_MainMenu@@UEAAXPEAUIggyCustomDrawCallbackRegion@@@Z";
static const char* SYM_PRESENT = "?Present@C4JRender@@QEAAXXZ";
static const char* SYM_GET_STRING = "?GetString@CMinecraftApp@@SAPEB_WH@Z";
static const char* SYM_GET_RESOURCE_AS_STREAM = "?getResourceAsStream@InputStream@@SAPEAV1@AEBV?$basic_string@_WU?$char_traits@_W@std@@V?$allocator@_W@2@@std@@@Z";
static const char* SYM_LOAD_UVS = "?loadUVs@PreStitchedTextureMap@@AEAAXXZ";
static const char* SYM_SIMPLE_ICON_CTOR = "??0SimpleIcon@@QEAA@AEBV?$basic_string@_WU?$char_traits@_W@std@@V?$allocator@_W@2@@std@@0MMMM@Z";
static const char* SYM_OPERATOR_NEW = "??2@YAPEAX_K@Z";
static const char* SYM_REGISTER_ICON = "?registerIcon@PreStitchedTextureMap@@UEAAPEAVIcon@@AEBV?$basic_string@_WU?$char_traits@_W@std@@V?$allocator@_W@2@@std@@@Z";
bool SymbolResolver::Initialize()
{
@@ -70,6 +76,17 @@ bool SymbolResolver::ResolveGameFunctions()
pCreativeStaticCtor = Resolve(SYM_CREATIVE_STATIC_CTOR);
pMainMenuCustomDraw = Resolve(SYM_MAINMENU_CUSTOMDRAW);
pPresent = Resolve(SYM_PRESENT);
pGetString = Resolve(SYM_GET_STRING);
pGetResourceAsStream = Resolve(SYM_GET_RESOURCE_AS_STREAM);
pLoadUVs = Resolve(SYM_LOAD_UVS);
pSimpleIconCtor = Resolve(SYM_SIMPLE_ICON_CTOR);
pOperatorNew = Resolve(SYM_OPERATOR_NEW);
pRegisterIcon = Resolve(SYM_REGISTER_ICON);
if (!pOperatorNew) pOperatorNew = GetProcAddress(GetModuleHandleA("vcruntime140.dll"), SYM_OPERATOR_NEW);
if (!pOperatorNew) pOperatorNew = GetProcAddress(GetModuleHandleA("vcruntime140d.dll"), SYM_OPERATOR_NEW);
if (!pOperatorNew) pOperatorNew = GetProcAddress(GetModuleHandle(nullptr), SYM_OPERATOR_NEW);
if (!pSimpleIconCtor) PdbParser::DumpMatching("??0SimpleIcon@@");
if (!pLoadUVs) PdbParser::DumpMatching("loadUVs@PreStitchedTextureMap");
auto logSym = [](const char* name, void* ptr) {
if (ptr)
@@ -85,6 +102,12 @@ bool SymbolResolver::ResolveGameFunctions()
logSym("CreativeStaticCtor", pCreativeStaticCtor);
logSym("MainMenuCustomDraw", pMainMenuCustomDraw);
logSym("C4JRender::Present", pPresent);
logSym("CMinecraftApp::GetString", pGetString);
logSym("InputStream::getResourceAsStream", pGetResourceAsStream);
logSym("PreStitchedTextureMap::loadUVs", pLoadUVs);
logSym("SimpleIcon::SimpleIcon", pSimpleIconCtor);
logSym("operator new", pOperatorNew);
logSym("registerIcon", pRegisterIcon);
bool ok = pRunStaticCtors && pMinecraftTick && pMinecraftInit;
if (ok)

View File

@@ -17,6 +17,12 @@ public:
void* pCreativeStaticCtor = nullptr; // IUIScene_CreativeMenu::staticCtor()
void* pMainMenuCustomDraw = nullptr; // UIScene_MainMenu::customDraw()
void* pPresent = nullptr; // C4JRender::Present()
void* pGetString = nullptr; // CMinecraftApp::GetString(int)
void* pGetResourceAsStream = nullptr; // InputStream::getResourceAsStream(wstring)
void* pLoadUVs = nullptr; // PreStitchedTextureMap::loadUVs()
void* pSimpleIconCtor = nullptr; // SimpleIcon::SimpleIcon(wstring,wstring,float*4)
void* pOperatorNew = nullptr; // global operator new(size_t) - for texture injection
void* pRegisterIcon = nullptr; // PreStitchedTextureMap::registerIcon(const wstring&)
private:
uintptr_t m_moduleBase = 0;

View File

@@ -0,0 +1 @@

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff