Fix mod item names by injecting strings directly into game's StringTable

CMinecraftApp::GetString is inlined by the MSVC linker at call sites like
Item::getHoverName, so the MinHook-based GetString hook never fires for
item name lookups. The game's StringTable::getString(int) does a simple
vector index lookup, and mod IDs (10000+) are beyond the vector size,
returning empty strings.

Fix: parse the GetString function's x64 machine code before hooking to
locate the RIP-relative reference to app.m_stringTable, then after mods
register their strings, resize m_stringsVec and inject mod strings at the
correct indices. Also adds GetString fallback to CConsoleMinecraftApp
variant and diagnostic logging.
This commit is contained in:
Jacobwasbeast
2026-03-06 23:20:31 -06:00
parent 7a63261088
commit f5805fc740
5 changed files with 217 additions and 6 deletions

View File

@@ -79,17 +79,29 @@ namespace GameHooks
return Original_GetResourceAsStream ? Original_GetResourceAsStream(fileName) : nullptr;
}
static bool s_loggedGetString = false;
const wchar_t* Hooked_GetString(int id)
{
if (ModStrings::IsModId(id))
{
const wchar_t* modStr = ModStrings::Get(id);
if (modStr)
LogUtil::Log("[LegacyForge] GetString(id=%d) -> mod '%ls'", id,
(modStr && modStr[0]) ? modStr : L"<null/empty>");
if (modStr && modStr[0])
return modStr;
return L"[Mod]";
}
if (!s_loggedGetString && id > 0)
{
s_loggedGetString = true;
const wchar_t* r = Original_GetString ? Original_GetString(id) : L"";
LogUtil::Log("[LegacyForge] GetString(id=%d) -> vanilla '%ls' (first call sample)", id, r ? r : L"<null>");
return r;
}
return Original_GetString ? Original_GetString(id) : L"";
}
void Hooked_RunStaticCtors()
{
LogUtil::Log("[LegacyForge] Hook: RunStaticCtors -- calling PreInit");
@@ -99,6 +111,11 @@ namespace GameHooks
LogUtil::Log("[LegacyForge] Hook: RunStaticCtors complete -- calling Init");
DotNetHost::CallInit();
// Inject mod strings directly into the game's StringTable vector.
// This is necessary because the compiler inlines GetString at call
// sites like Item::getHoverName, bypassing our GetString hook.
ModStrings::InjectAllIntoGameTable();
}
void __fastcall Hooked_MinecraftTick(void* thisPtr, bool bFirst, bool bUpdateTextures)

View File

@@ -1,6 +1,7 @@
#include "HookManager.h"
#include "GameHooks.h"
#include "ModAtlas.h"
#include "ModStrings.h"
#include "SymbolResolver.h"
#include "CreativeInventory.h"
#include "MainMenuOverlay.h"
@@ -152,6 +153,9 @@ bool HookManager::Install(const SymbolResolver& symbols)
if (symbols.pGetString)
{
// Read GetString prologue bytes BEFORE MinHook overwrites them.
ModStrings::CaptureStringTableRef(symbols.pGetString);
if (MH_CreateHook(symbols.pGetString,
reinterpret_cast<void*>(&GameHooks::Hooked_GetString),
reinterpret_cast<void**>(&GameHooks::Original_GetString)) != MH_OK)
@@ -164,6 +168,7 @@ bool HookManager::Install(const SymbolResolver& symbols)
}
}
if (symbols.pGetResourceAsStream)
{
if (MH_CreateHook(symbols.pGetResourceAsStream,

View File

@@ -2,6 +2,7 @@
#include "LogUtil.h"
#include <vector>
#include <cstring>
#include <cstdint>
namespace ModStrings
{
@@ -9,6 +10,12 @@ namespace ModStrings
static std::unordered_map<int, std::wstring> s_strings;
static int s_nextId = MOD_DESC_ID_BASE;
// ---- Game string table injection ----
// Points to the field inside 'app' that holds the StringTable* pointer.
static void** s_pStringTableField = nullptr;
// Offset of m_stringsVec inside the StringTable object (found by heuristic scan).
static int s_vecOffset = -1;
void Register(int descriptionId, const wchar_t* value)
{
if (!value) return;
@@ -36,4 +43,175 @@ namespace ModStrings
{
return id >= MOD_DESC_ID_BASE;
}
// ---- Machine code parsing to locate string table ----
void CaptureStringTableRef(void* pGetStringFunc)
{
if (!pGetStringFunc) return;
const uint8_t* code = static_cast<const uint8_t*>(pGetStringFunc);
LogUtil::Log("[LegacyForge] ModStrings: scanning GetString prologue at %p", pGetStringFunc);
// Log first 32 bytes for diagnostics
char hexBuf[200];
for (int i = 0; i < 32 && i < 200/3; i++)
sprintf(hexBuf + i * 3, "%02X ", code[i]);
LogUtil::Log("[LegacyForge] ModStrings: bytes: %s", hexBuf);
// Search for RIP-relative memory accesses in first 80 bytes.
// GetString is: return app.m_stringTable->getString(iID);
// The compiler will load app.m_stringTable via a RIP-relative MOV.
for (int i = 0; i < 74; i++)
{
// REX.W prefix (0x48 or 0x4C for r8-r15)
if ((code[i] & 0xF8) != 0x48) continue;
uint8_t rex = code[i];
// MOV reg, [RIP + disp32] => 0x8B modrm
// LEA reg, [RIP + disp32] => 0x8D modrm
if (code[i + 1] != 0x8B && code[i + 1] != 0x8D) continue;
uint8_t modrm = code[i + 2];
// mod=00, rm=101 means [RIP + disp32]
if ((modrm & 0xC7) != 0x05) continue;
int32_t disp = *reinterpret_cast<const int32_t*>(code + i + 3);
uintptr_t effectiveAddr = reinterpret_cast<uintptr_t>(code + i + 7) + disp;
if (code[i + 1] == 0x8B)
{
// MOV reg, [RIP+disp] - directly loads a pointer value.
// This is likely loading app.m_stringTable (or &app if it's a pointer global).
// For a static member or global struct, the compiler often uses a direct
// RIP-relative MOV to load the m_stringTable pointer field.
s_pStringTableField = reinterpret_cast<void**>(effectiveAddr);
LogUtil::Log("[LegacyForge] ModStrings: MOV [RIP+disp] -> field at %p", s_pStringTableField);
break;
}
else // LEA
{
// LEA reg, [RIP+disp] -> &app
// Next instruction should load m_stringTable from app + offset
uintptr_t appAddr = effectiveAddr;
int j = i + 7;
// Look for MOV reg, [reg + disp8/disp32]
if (j + 3 < 80 && (code[j] & 0xF8) == 0x48 && code[j + 1] == 0x8B)
{
uint8_t modrm2 = code[j + 2];
uint8_t mod2 = modrm2 >> 6;
if (mod2 == 1)
{
int8_t off = static_cast<int8_t>(code[j + 3]);
s_pStringTableField = reinterpret_cast<void**>(appAddr + off);
LogUtil::Log("[LegacyForge] ModStrings: LEA+MOV [reg+%d] -> field at %p", (int)off, s_pStringTableField);
break;
}
else if (mod2 == 2)
{
int32_t off = *reinterpret_cast<const int32_t*>(code + j + 3);
s_pStringTableField = reinterpret_cast<void**>(appAddr + off);
LogUtil::Log("[LegacyForge] ModStrings: LEA+MOV [reg+%d] -> field at %p", off, s_pStringTableField);
break;
}
}
}
}
if (!s_pStringTableField)
LogUtil::Log("[LegacyForge] ModStrings: WARNING - could not locate string table reference");
}
// Heuristic: find the vector<wstring> inside a StringTable object.
static std::vector<std::wstring>* FindStringsVec(void* stringTable)
{
char* base = static_cast<char*>(stringTable);
// StringTable layout (MSVC x64):
// +0x00: bool isStatic
// +0x08: unordered_map<wstring,wstring> (size varies, typically 64 bytes)
// +0x??: vector<wstring> m_stringsVec
// We scan pointer-aligned offsets looking for a valid vector triple.
for (int off = 0x08; off < 0x120; off += 8)
{
uintptr_t* ptrs = reinterpret_cast<uintptr_t*>(base + off);
uintptr_t begin_ = ptrs[0];
uintptr_t end_ = ptrs[1];
uintptr_t cap_ = ptrs[2];
if (begin_ == 0 || end_ == 0 || cap_ == 0) continue;
if (begin_ > end_ || end_ > cap_) continue;
size_t sizeBytes = end_ - begin_;
// sizeof(std::wstring) is 32 on MSVC x64 (SSO buffer + size + capacity)
if (sizeBytes == 0 || sizeBytes % 32 != 0) continue;
size_t count = sizeBytes / 32;
if (count < 50 || count > 50000) continue;
// Quick validation: first element should be a valid wstring
const std::wstring* first = reinterpret_cast<const std::wstring*>(begin_);
if (first->size() > 0 && first->size() < 10000)
{
s_vecOffset = off;
LogUtil::Log("[LegacyForge] ModStrings: found m_stringsVec at StringTable+0x%X (%zu entries)",
off, count);
return reinterpret_cast<std::vector<std::wstring>*>(base + off);
}
}
return nullptr;
}
void InjectAllIntoGameTable()
{
if (!s_pStringTableField)
{
LogUtil::Log("[LegacyForge] ModStrings: no string table ref - cannot inject");
return;
}
void* stringTable = *s_pStringTableField;
if (!stringTable)
{
LogUtil::Log("[LegacyForge] ModStrings: m_stringTable pointer is NULL");
return;
}
LogUtil::Log("[LegacyForge] ModStrings: StringTable object at %p", stringTable);
std::vector<std::wstring>* vec = FindStringsVec(stringTable);
if (!vec)
{
LogUtil::Log("[LegacyForge] ModStrings: FAILED to locate m_stringsVec in StringTable");
return;
}
std::lock_guard<std::mutex> lock(s_mutex);
if (s_strings.empty())
{
LogUtil::Log("[LegacyForge] ModStrings: no mod strings to inject");
return;
}
// Find the highest ID we need
int maxId = 0;
for (auto& kv : s_strings)
if (kv.first > maxId) maxId = kv.first;
size_t oldSize = vec->size();
if (static_cast<size_t>(maxId) >= oldSize)
{
vec->resize(maxId + 1);
LogUtil::Log("[LegacyForge] ModStrings: resized m_stringsVec %zu -> %zu", oldSize, vec->size());
}
int count = 0;
for (auto& kv : s_strings)
{
(*vec)[kv.first] = kv.second;
count++;
}
LogUtil::Log("[LegacyForge] ModStrings: injected %d mod strings into game string table", count);
}
}

View File

@@ -4,11 +4,6 @@
#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;
@@ -17,4 +12,14 @@ namespace ModStrings
const wchar_t* Get(int descriptionId);
int AllocateId();
bool IsModId(int id);
// Parse CMinecraftApp::GetString machine code to locate the game's
// string table pointer. Must be called BEFORE MinHook overwrites
// the function prologue.
void CaptureStringTableRef(void* pGetStringFunc);
// After the string table is loaded (e.g. during PreInit), call this
// to inject all previously registered mod strings into the game's
// m_stringsVec so that inlined GetString calls find them.
void InjectAllIntoGameTable();
}

View File

@@ -77,6 +77,12 @@ bool SymbolResolver::ResolveGameFunctions()
pMainMenuCustomDraw = Resolve(SYM_MAINMENU_CUSTOMDRAW);
pPresent = Resolve(SYM_PRESENT);
pGetString = Resolve(SYM_GET_STRING);
if (!pGetString)
{
pGetString = Resolve("?GetString@CConsoleMinecraftApp@@SAPEB_WH@Z");
if (!pGetString)
PdbParser::DumpMatching("GetString");
}
pGetResourceAsStream = Resolve(SYM_GET_RESOURCE_AS_STREAM);
pLoadUVs = Resolve(SYM_LOAD_UVS);
pSimpleIconCtor = Resolve(SYM_SIMPLE_ICON_CTOR);