fix(text): pointer probes must allow Large-Address-Aware heap (>2GB)

LooksReadable capped plausible pointers at 0x7FFF0000. This client is LAA
(VanillaFixes), so heap allocations above 0x80000000 are valid and routine
once the heap grows - and the flush's layout walk BREAKS on an "unreadable"
node, so a single high-heap node silently truncated the walk and every node
AFTER it in the layout list lost its icons. That was the irreducible random
per-ROW missing-chat-icon bug that survived every renderer architecture
since the guard shipped with the first inline-texture commit (aa94ea5): it
fired only when the allocator placed a node/fontstring above 2GB, varying
per session with addon load and uptime. The same check also rejected
high-heap fontstrings (dropNoFs storms, "unreadable" owner chains).

Bound raised to 0xFFFF0000 in both copies.

Diagnosed with the per-node build/emit instrumentation added here: the
smoking-gun Broken() trio was builtKnown=1 (engine built the node),
emitOutcome=8 (emitter recorded its icons), nodeSeenAge=-1 (our walk never
once saw it). _classicapi_InlineTexBroken now reports both new fields.
This commit is contained in:
Brues
2026-08-16 18:38:05 -05:00
parent b5df20dffe
commit dc61f77fdf
3 changed files with 71 additions and 6 deletions
+52 -3
View File
@@ -69,9 +69,15 @@ namespace {
// True if `p` looks like a readable in-process pointer (heap/.data range), so we
// can probe engine structures without faulting on a bad/uninitialized field.
// UPPER BOUND IS 4GB-ish, NOT 2GB: this client is Large Address Aware
// (VanillaFixes), so heap allocations above 0x80000000 are valid and common
// once the heap grows. A 0x7FFF0000 cap silently rejected high nodes and
// fontstrings — and because the flush's layout walk BREAKS on an "unreadable"
// node, one high node truncated the walk and every node after it lost its
// icons (the per-row random missing-icon bug).
bool LooksReadable(const void *p) {
auto a = reinterpret_cast<uintptr_t>(p);
return a >= 0x00010000u && a < 0x7FFF0000u;
return a >= 0x00010000u && a < 0xFFFF0000u;
}
// --- textured-quad rendering primitive --------------------------------------
@@ -363,6 +369,10 @@ uint32_t g_buildEmitSeq = 0;
// latest build (see NodeIcons).
uint32_t g_buildCounter = 0;
std::unordered_map<void *, uint32_t> g_nodeBuilt;
// Last emitter outcome per node (diagnostic): 1 feature-off delegate, 2 null
// args, 3 manual suppress, 4 focused-editbox pointer suppress, 5 content
// suppress, 6 editable suppress, 7 no-markup line, 8 recorded icons.
std::unordered_map<void *, uint8_t> g_nodeEmit;
using DrawBuilder_t = void(__fastcall *)(void *node);
DrawBuilder_t g_builderOriginal = nullptr;
@@ -395,6 +405,7 @@ void __fastcall NodeFree_h(void *node) {
g_nodeOwner.erase(node);
g_nodeSeen.erase(node);
g_nodeBuilt.erase(node);
g_nodeEmit.erase(node);
}
g_nodeFreeOriginal(node);
}
@@ -746,8 +757,10 @@ void __fastcall Emitter_h(void *node, void *edx, uint8_t *text, int len, uint32_
// Content-match still handles the focused chat/name box.
const bool sup_editable = node != nullptr && NodeEditable(node);
if (sup_manual || sup_ptr || sup_content || sup_editable) {
if (node != nullptr)
if (node != nullptr) {
g_nodeIcons.erase(node);
g_nodeEmit[node] = sup_manual ? 3 : (sup_ptr ? 4 : (sup_content ? 5 : 6));
}
// Bracket the delegated raw layout so the re-entrant tokenizer stands down
// across the whole line (else it eats `|T` as a zero-width token → BLANK).
g_reentryLo = text;
@@ -779,8 +792,10 @@ void __fastcall Emitter_h(void *node, void *edx, uint8_t *text, int len, uint32_
? (g_buildEmitSeq++ == 0)
: (text == *reinterpret_cast<uint8_t **>(reinterpret_cast<uint8_t *>(node) +
Offsets::OFF_TEXT_NODE_TEXT));
if (firstLine)
if (firstLine) {
g_nodeIcons.erase(node);
g_nodeEmit.erase(node); // fresh build → fresh outcome
}
if (!HasInlineTexture(text, len)) {
// No inline texture on this line — render it normally. Note: firstLine
@@ -790,6 +805,8 @@ void __fastcall Emitter_h(void *node, void *edx, uint8_t *text, int len, uint32_
// scanning node text (that scan read a stale/preprocessed pointer on
// pfUI chat lines and erased LIVE records every frame — the persistent
// iconless LFG lines).
if (g_nodeEmit[node] != 8) // don't demote a build that recorded on an earlier line
g_nodeEmit[node] = 7;
g_emitterOriginal(node, edx, text, len, colorState, penXYZ, pageMask, linkState);
return;
}
@@ -797,6 +814,7 @@ void __fastcall Emitter_h(void *node, void *edx, uint8_t *text, int len, uint32_
// This line owns inline textures — append them, rendering the plain runs by
// delegating to the original per segment.
NodeIcons &nodeEntry = g_nodeIcons[node];
g_nodeEmit[node] = 8; // this build recorded icons
nodeEntry.builtSeq = g_buildCounter; // records belong to the current build
std::vector<IconRecord> &icons = nodeEntry.icons;
@@ -1403,6 +1421,36 @@ int DebugNodeSeenAge(void *fs) {
return static_cast<int>(g_flushSeq - it->second);
}
namespace {
// fs → current node (fs+0xF8 → handle+8), nullptr when the chain is unreadable.
void *DebugCurrentNode(void *fs) {
if (!LooksReadable(fs))
return nullptr;
void *block = *reinterpret_cast<void **>(reinterpret_cast<uint8_t *>(fs) +
Offsets::OFF_FONTSTRING_TEXT_BLOCK);
if (!LooksReadable(block))
return nullptr;
void *node = *reinterpret_cast<void **>(reinterpret_cast<uint8_t *>(block) +
Offsets::OFF_TEXTBLOCK_NODE);
return LooksReadable(node) ? node : nullptr;
}
} // namespace
int DebugNodeBuiltKnown(void *fs) {
void *node = DebugCurrentNode(fs);
if (node == nullptr)
return -1;
return g_nodeBuilt.find(node) != g_nodeBuilt.end() ? 1 : 0;
}
int DebugNodeEmitOutcome(void *fs) {
void *node = DebugCurrentNode(fs);
if (node == nullptr)
return -1;
auto it = g_nodeEmit.find(node);
return it == g_nodeEmit.end() ? -1 : static_cast<int>(it->second);
}
unsigned int DebugNodeFlags(void *fs) {
if (!LooksReadable(fs))
return 0xFFFFFFFFu;
@@ -1427,6 +1475,7 @@ void PrepareForReload() {
g_nodeOwner.clear();
g_nodeSeen.clear();
g_nodeBuilt.clear();
g_nodeEmit.clear();
}
} // namespace Text::InlineTexture
+11
View File
@@ -41,4 +41,15 @@ unsigned int DebugNodeFlags(void *fs);
// the node's layout is not being painted — the flush can never queue for it.
int DebugNodeSeenAge(void *fs);
// Debug: 1 if the tracked draw builder (FUN_TEXT_DRAW_BUILDER) has ever run
// for the fs's current node, else 0 (-1 chain unreadable). 0 with visible text
// means the node is built/rendered through a path we don't hook.
int DebugNodeBuiltKnown(void *fs);
// Debug: the last emitter outcome for the fs's current node (-1 = emitter
// never entered for it): 1 feature-off, 2 null args, 3 manual suppress,
// 4 focused-editbox pointer suppress, 5 content suppress, 6 editable suppress,
// 7 no-markup line(s) only, 8 recorded icons.
int DebugNodeEmitOutcome(void *fs);
} // namespace Text::InlineTexture
+8 -3
View File
@@ -58,9 +58,12 @@ using SetPoint_t = void(__thiscall *)(void *anchor, int point, void *relAnchor,
using Realize_t = void(__thiscall *)(void *anchor, int flag);
using ShowHide_t = void(__fastcall *)(void *tex);
// NOTE upper bound: the client is Large Address Aware — heap pointers above
// 0x80000000 are valid (a 2GB cap here silently dropped high fontstrings/nodes;
// see InlineTexture.cpp's LooksReadable note).
bool LooksReadable(const void *p) {
auto a = reinterpret_cast<uintptr_t>(p);
return a >= 0x00010000u && a < 0x7FFF0000u;
return a >= 0x00010000u && a < 0xFFFF0000u;
}
using SetTexCoord_t = void(__thiscall *)(void *tex, const float *coords4);
@@ -567,7 +570,7 @@ int __fastcall Script_InlineTexFsDump(void *L) {
int __fastcall Script_InlineTexBroken(void *L) {
int pushed = 0;
for (auto &kv : g_fsIcons) {
if (pushed >= 8)
if (pushed >= 12)
break;
FsPlacements &np = kv.second;
if (!np.want.empty())
@@ -601,7 +604,9 @@ int __fastcall Script_InlineTexBroken(void *L) {
static_cast<double>(Text::InlineTexture::DebugNodeFlags(kv.first)));
Game::Lua::PushNumber(L,
static_cast<double>(Text::InlineTexture::DebugNodeSeenAge(kv.first)));
pushed += 4;
Game::Lua::PushNumber(L, Text::InlineTexture::DebugNodeBuiltKnown(kv.first));
Game::Lua::PushNumber(L, Text::InlineTexture::DebugNodeEmitOutcome(kv.first));
pushed += 6;
}
return pushed;
}