fix(text): make inline-icon state lifetime-exact; fix every scroll-landing loss

Chat icons randomly landed shown-or-hidden when lines scrolled, and stale
icons ghosted at parked positions. Root causes, each found by in-game
triangulation (FsDump/Broken/Stats diagnostics, included), all fixed:

- Placements are FS-RELATIVE, computed at PAINT time in one coherent
  snapshot (icon coords + fs rect in the same flush). The old apply-time fs
  rect read raced the chat relayout after SetText and parked icons off
  their line, where the dedup froze them.
- The queue tail gate now matches the per-icon build gate EXACTLY (incl.
  K); any not-ready input skips-and-retries instead of queueing {} - an
  empty queue froze lines iconless via the dedup (early-session K window).
- K (pen->anchor scale) is derived PER NODE from its own fontstring - pen
  space scales with each fs's frame-scale chain, so a global chat-derived K
  mis-sized icons on differently-scaled fontstrings. Global cache kept as a
  +/-10%-clamped fallback.
- Stuck-blockless fontstrings (RebuildString releases the block on a
  SetText that lands on an unresolved rect and never rebuilds; the zombie
  node keeps painting) are re-dirtied so the engine rebuilds - the icon
  pipeline then resumes naturally.
- First-line-of-build detection comes from a draw-builder co-hook
  (FUN_005CDC20) build stamp - exact - with the old text-pointer heuristic
  only as fallback for emitter paths outside the builder.
- THE LIFETIME FIX: co-hook the gxu text-node free (FUN_005CD950, the
  single site where every node dies and its address becomes reusable) and
  erase ALL per-node state there. Anything in the maps now refers to a
  live node by construction: inherited ghost records on recycled nameplate
  fontstrings, orphaned owners (dropNoFs 61k -> 0), and reload leftovers
  lose their mechanism of existence.
- Ghost guard rescans the FONTSTRING's text (fs+0xF0, proven trustworthy),
  not the node text pointer (which lied on pfUI chat lines and made the old
  scan erase LIVE records every frame - the persistent iconless LFG lines).
  False matches can only false-keep, never false-erase.
- Freshness TTL: regions stay only while the paint keeps re-queueing their
  fontstring (deduped touches count); parked/orphaned fs regions expire in
  ~1s. Do NOT build-version records against the builder counter - the
  builder runs every paint but only emits when dirty, so a version check
  erases good records on every clean paint (the all-icons-gone regression).

Diagnostics added (Lua): _classicapi_InlineTexFsDump (pipeline triangulation
with chain classification), _classicapi_InlineTexBroken (names the broken
lines), _classicapi_InlineTexStats gains queued/drop/nudge counters and the
SEH fault address.

Also removes the FrameTick glue-screen attempt (b164d68): hooking the UI
render root regressed in-world chat; glue-screen icon support will be
re-approached without touching the in-world render path. The
FUN_UI_RENDER_ROOT offset stays documented for that retry.
This commit is contained in:
Brues
2026-08-16 18:16:25 -05:00
parent 061c11e6bc
commit b5df20dffe
5 changed files with 533 additions and 69 deletions
+41
View File
@@ -340,6 +340,31 @@ enum Offsets {
// therefore *( *(fs+0xF8) + 8 ).
OFF_FONTSTRING_TEXT_BLOCK = 0xF8, // HTEXTBLOCK handle (0 when dirty/empty)
OFF_TEXTBLOCK_NODE = 0x8, // the gxu text node inside the handle
// Byte of fontstring state flags; bit 1 = "text block needs rebuild".
// RebuildString (0x7724A0) gates on it at entry, releases the old block
// (+0xF8 = 0), and only CREATES a new one if the fs rect is resolved —
// a SetText during an unresolved rect leaves the fs blockless with the
// refcounted zombie node still painting. If the bit is also clear at that
// point, nothing ever rebuilds (the stuck-blockless state InlineTexture's
// flush nudges by re-setting this bit).
OFF_FONTSTRING_DIRTY_FLAGS = 0x60,
// The per-node draw builder: walks a node's wrapped lines and calls the
// glyph emitter (FUN_TEXT_EMITTER) once per line. Called from the paint's
// per-node pre-pass FUN_005cd6a0 (`__thiscall(node)`, no stack args). We
// co-hook it to stamp exact BUILD BOUNDARIES for the emitter's first-line
// detection — the old `text == node+text-ptr` heuristic silently failed on
// pfUI-processed chat lines (stale/preprocessed pointer), leaving inherited
// records on reused node addresses (ghost icons) or never clearing them.
FUN_TEXT_DRAW_BUILDER = 0x005CDC20,
// The gxu text-node FREE: unlinks the node from its layout lists and pushes
// it onto the node free list (DAT_00c2b98c) for reuse. Single caller —
// FUN_005c1d00, the HTEXTBLOCK handle release (handle vtbl 0x008026e4 dtor
// 0x0044D5C0 → 0x005c1d00 → here). This is the choke point where every
// text node dies and its address becomes reusable: InlineTexture hooks it
// to erase ALL per-node state exactly at death, making stale-record and
// stale-owner bugs (ghost icons, orphaned records) structurally impossible
// instead of heuristically guarded.
FUN_TEXT_NODE_FREE = 0x005CD950,
// THE pen↔anchor unit bridge. RebuildString (0x7724A0) multiplies every
// text-unit quantity by region+0x7C when crossing into node creation:
// nodePos = inset×s + rect corner, nodeFontH = fontPx×s, spacing = +0xF4×s,
@@ -5293,6 +5318,22 @@ enum Offsets {
// `RET 0x4` (callee cleans the one stack arg).
FUN_WORLD_TICK = 0x0066FD50,
// UI render root — the CSimpleTop singleton's (`DAT_00cf0bd8`) per-frame
// render callback. Registered at priority 1.0 into the global HLAYER list
// (`FUN_00442800`) and drained every frame by the master scene-render pump
// `FUN_00442350`; body is just `FUN_00765650(root, ...)` (UI layout pass)
// then `FUN_007657d0(root)` (the strata walk that draws every UI frame).
// Unlike FUN_WORLD_TICK this fires in BOTH glue and world: the root ctor
// `FUN_00764180` runs from the world-init path (`FUN_0048FBF0`) AND the
// glue-boot path (`FUN_0046A7B0`), so the UI renders — and this callback
// fires — on the login/character-select screens too. This is the tick to
// drive UI-object upkeep that must also run on glue (inline-texture icon
// regions), where FUN_WORLD_TICK is silent. `__stdcall(void *bounds,
// int flag)`, `RET 0x8`; clean PUSH EBP / MOV EBP,ESP prologue (MinHook-
// safe). Single caller (the HLAYER drain), quiet render region — not a
// known collision target for the other Octo DLLs.
FUN_UI_RENDER_ROOT = 0x00764330,
// SMSG_BINDPOINTUPDATE handler. The server sends this packet on
// initial login (to sync the player's current bind) and again
// every time the player binds at a new innkeeper. The handler
+295 -58
View File
@@ -301,11 +301,19 @@ struct IconRecord {
uint32_t color; // vertex tint (0xAARRGGBB; white = untinted)
};
// Icons keyed by render node. Rebuilt whenever the emitter runs for a node (see
// Emitter_h), so it tracks the node's current text. Entries for freed nodes are
// never dereferenced (flush only walks the layout's live node list) and are
// cleared when the address is reused and rebuilt.
std::unordered_map<void *, std::vector<IconRecord>> g_nodeIcons;
// Icons keyed by render node, VERSIONED BY BUILD: `builtSeq` is the builder
// invocation that produced the records. The flush only trusts records whose
// builtSeq matches the node's LATEST build (g_nodeBuilt) — a build that never
// ran the emitter (empty text) leaves records from a previous life at a reused
// address, and unversioned records ghosted: pfUI nameplate fontstrings churn
// node addresses constantly, inherited dead emote records, passed the
// authority check (genuinely their current node), and drew emote icons at the
// parked nameplate position at the bottom of the screen.
struct NodeIcons {
uint32_t builtSeq = 0;
std::vector<IconRecord> icons;
};
std::unordered_map<void *, NodeIcons> g_nodeIcons;
// node → owning CSimpleFontString, recorded by the RebuildString co-hook (the
// 4.3.4 ownership model — 1.12 chat lines ARE fontstrings). Consulted by the
@@ -314,6 +322,13 @@ std::unordered_map<void *, std::vector<IconRecord>> g_nodeIcons;
// long-lived); the map clears on /reload.
std::unordered_map<void *, void *> g_nodeOwner;
// Flush visit stamps: which flush call last WALKED each owned node. Lets the
// Broken() diagnostic distinguish "node painted but queue dropped" from "node's
// layout is never painted at all" (the two remaining theories for a visible
// markup line with an empty want).
uint32_t g_flushSeq = 0;
std::unordered_map<void *, uint32_t> g_nodeSeen;
// Render mode: 0 = raw quads (pen space, pixel-true, needs residency holders),
// 1 = engine regions (4.3.4 model — resident by construction; the default), 2 =
// both (bring-up A/B: if the region placement is right, the two coincide).
@@ -334,6 +349,60 @@ float g_penPerAnchor = 0.0f;
float g_regionCalX = 0.0f;
float g_regionCalY = -1.0f;
// Draw-builder co-hook: exact build boundaries for first-line detection. The
// builder runs once per node build and calls the emitter per wrapped line;
// stamping the node + zeroing the emit counter here lets the emitter know
// "this is the first line of a fresh build" with certainty. (The previous
// heuristic — comparing the emit text pointer against node+text — failed on
// pfUI-processed lines and caused both the erased-live-records bug and the
// ghost-icons-on-reused-nodes bug.)
void *g_buildNode = nullptr;
uint32_t g_buildEmitSeq = 0;
// Monotonic builder-invocation counter + per-node latest build. Records carry
// the build they were made in; the flush rejects records older than the node's
// latest build (see NodeIcons).
uint32_t g_buildCounter = 0;
std::unordered_map<void *, uint32_t> g_nodeBuilt;
using DrawBuilder_t = void(__fastcall *)(void *node);
DrawBuilder_t g_builderOriginal = nullptr;
void __fastcall DrawBuilder_h(void *node) {
g_buildNode = node;
g_buildEmitSeq = 0;
g_nodeBuilt[node] = ++g_buildCounter;
g_builderOriginal(node);
g_buildNode = nullptr;
}
static const Game::HookAutoRegister _builderHook{Offsets::FUN_TEXT_DRAW_BUILDER,
reinterpret_cast<void *>(&DrawBuilder_h),
reinterpret_cast<void **>(&g_builderOriginal)};
// Node-free co-hook: THE lifetime fix. Every gxu text node dies through
// FUN_TEXT_NODE_FREE (single engine call site) before its address goes back to
// the free list for reuse. Erasing all per-node state here makes it a hard
// invariant that anything in our maps refers to a LIVE node — the entire class
// of stale-record/stale-owner bugs (inherited ghost icons on recycled
// nameplates, orphaned records inflating dropNoFs, reload leftovers) becomes
// structurally impossible rather than heuristically guarded.
using NodeFree_t = void(__fastcall *)(void *node);
NodeFree_t g_nodeFreeOriginal = nullptr;
void __fastcall NodeFree_h(void *node) {
if (node != nullptr) {
g_nodeIcons.erase(node);
g_nodeOwner.erase(node);
g_nodeSeen.erase(node);
g_nodeBuilt.erase(node);
}
g_nodeFreeOriginal(node);
}
static const Game::HookAutoRegister _nodeFreeHook{Offsets::FUN_TEXT_NODE_FREE,
reinterpret_cast<void *>(&NodeFree_h),
reinterpret_cast<void **>(&g_nodeFreeOriginal)};
// RebuildString co-hook: after the engine (re)builds a fontstring's text block,
// record node → fontstring. `fs+0xF8` is an HTEXTBLOCK handle; the node lives
// at handle+8.
@@ -358,11 +427,15 @@ static const Game::HookAutoRegister _rebuildHook{Offsets::FUN_FONTSTRING_REBUILD
reinterpret_cast<void *>(&RebuildString_h),
reinterpret_cast<void **>(&g_rebuildOriginal)};
// Derives/refreshes K from this node + its fontstring: K = originX / justifyRefX
// Derives K for THIS node from its own fontstring: K = originX / justifyRefX
// (node+0x54: 0 = left, 1 = centre, 2 = right — the draw builder's encoding).
// Guards: resolved rect, no x inset (RebuildString adds insetX×s to the node
// position, which would bias the ratio), ref far enough from 0 for precision.
// Falls back to the cached value when this node can't vouch.
// K is PER-FONTSTRING, not global: pen space is scaled by the fs's effective
// scale chain, so a pfUI money display at a custom frame scale has a different
// K than chat (the deterministic 0.53× half-size icons were exactly this —
// a global chat-derived K applied to a differently-scaled fs). The node's own
// derivation is preferred; the global cache is a fallback for nodes whose
// justify ref sits too close to 0 to divide by (guarded), updated only within
// ±10% (a mid-layout stale-rect read must not poison it).
float DeriveK(const uint8_t *n, const uint8_t *f, float originX) {
if (LooksReadable(f)) {
const float left = *reinterpret_cast<const float *>(f + Offsets::OFF_REGION_RECT + 4);
@@ -374,8 +447,12 @@ float DeriveK(const uint8_t *n, const uint8_t *f, float originX) {
(justify == 1) ? (left + right) * 0.5f : ((justify == 2) ? right : left);
if (std::fabs(ref) > 0.02f && originX > 1.0f) {
const float k = originX / ref;
if (k > 1.0f)
g_penPerAnchor = k;
if (k > 1.0f) {
if (g_penPerAnchor <= 1.0f ||
(k > g_penPerAnchor * 0.9f && k < g_penPerAnchor * 1.1f))
g_penPerAnchor = k;
return k; // this node's own K — always right for its fs
}
}
}
}
@@ -686,26 +763,42 @@ void __fastcall Emitter_h(void *node, void *edx, uint8_t *text, int len, uint32_
return;
}
// The draw builder walks a node's wrapped lines by calling the emitter once per
// line on the SAME node, advancing the text pointer; the first line's pointer
// equals the node's text start. Clear the icon list once here so wrapped lines
// ACCUMULATE their icons instead of each wiping the previous.
// The draw builder walks a node's wrapped lines by calling the emitter once
// per line on the SAME node. First-line detection comes from the builder
// co-hook's build stamp (exact), NOT from comparing text pointers against
// node+text — that heuristic silently failed on pfUI-processed lines,
// leaving reused nodes' inherited records alive (ghost icons). Clear the
// icon list once per build so wrapped lines ACCUMULATE their icons instead
// of each wiping the previous.
// Build-stamp detection when this emit is inside a tracked builder run
// (exact); the old text-pointer heuristic as fallback for any emitter
// caller that doesn't route through FUN_TEXT_DRAW_BUILDER — `false` there
// would mean never-erase, which is how reused nodes kept dead records.
const bool firstLine =
text == *reinterpret_cast<uint8_t **>(reinterpret_cast<uint8_t *>(node) +
Offsets::OFF_TEXT_NODE_TEXT);
(node == g_buildNode)
? (g_buildEmitSeq++ == 0)
: (text == *reinterpret_cast<uint8_t **>(reinterpret_cast<uint8_t *>(node) +
Offsets::OFF_TEXT_NODE_TEXT));
if (firstLine)
g_nodeIcons.erase(node);
if (!HasInlineTexture(text, len)) {
// No inline texture on this line — render it normally without touching the
// node's icon list (icons from other wrapped lines are preserved).
// No inline texture on this line — render it normally. Note: firstLine
// already erased the node's stale records above, so a REUSED node
// address whose new text has no markup is cleaned HERE, by the emitter.
// This is the ghost-icon protection; the flush must NOT re-derive it by
// 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).
g_emitterOriginal(node, edx, text, len, colorState, penXYZ, pageMask, linkState);
return;
}
// This line owns inline textures — append them, rendering the plain runs by
// delegating to the original per segment.
std::vector<IconRecord> &icons = g_nodeIcons[node];
NodeIcons &nodeEntry = g_nodeIcons[node];
nodeEntry.builtSeq = g_buildCounter; // records belong to the current build
std::vector<IconRecord> &icons = nodeEntry.icons;
// Bit 3 of the node flags gates the emitter's per-call batch-clear. When set
// (the standalone-FontString case), each original call would wipe the page
@@ -888,6 +981,15 @@ static const Game::HookAutoRegister _emitterHook{Offsets::FUN_TEXT_EMITTER,
uint32_t g_faultCode = 0;
uintptr_t g_faultAddr = 0;
// Flush-gate telemetry (cumulative): how often an icon-bearing node's region
// queue was issued vs dropped, and by which gate. Read via InlineTexStats.
uint32_t g_statQueued = 0; // QueuePlacements(fs, places) issued
uint32_t g_statDropNoFs = 0; // owner missing / authority failed
uint32_t g_statDropRect = 0; // fs rect unresolved this paint
uint32_t g_statDropK = 0; // pen→anchor scale not yet derived
uint32_t g_statNudged = 0; // stuck-blockless fs redirtied for engine rebuild
uint32_t g_statDropEditable = 0; // editable node queued {} over an owner fs
int CaptureFault(EXCEPTION_POINTERS *ep, uint32_t *code, uintptr_t *addr) {
*code = ep->ExceptionRecord->ExceptionCode;
*addr = reinterpret_cast<uintptr_t>(ep->ExceptionRecord->ExceptionAddress);
@@ -906,6 +1008,7 @@ Paint_t g_paintOriginal = nullptr;
void FlushLayout(void *layout) {
if (!LooksReadable(layout))
return;
++g_flushSeq;
auto *L = reinterpret_cast<uint8_t *>(layout);
const int linkOff = *reinterpret_cast<int *>(L + Offsets::OFF_TEXT_LAYOUT_NODE_LINK);
void *node = *reinterpret_cast<void **>(L + Offsets::OFF_TEXT_LAYOUT_NODE_HEAD);
@@ -929,46 +1032,72 @@ void FlushLayout(void *layout) {
if (g_renderMode != 0) {
auto ow = g_nodeOwner.find(node);
if (ow != g_nodeOwner.end() && LooksReadable(ow->second)) {
void *block = *reinterpret_cast<void **>(
reinterpret_cast<uint8_t *>(ow->second) + Offsets::OFF_FONTSTRING_TEXT_BLOCK);
auto *of = reinterpret_cast<uint8_t *>(ow->second);
void *block =
*reinterpret_cast<void **>(of + Offsets::OFF_FONTSTRING_TEXT_BLOCK);
g_nodeSeen[node] = g_flushSeq;
if (LooksReadable(block) &&
*reinterpret_cast<void **>(reinterpret_cast<uint8_t *>(block) +
Offsets::OFF_TEXTBLOCK_NODE) == node)
Offsets::OFF_TEXTBLOCK_NODE) == node) {
fs = ow->second;
} else if (block == nullptr) {
// Stuck-blockless fs: RebuildString released the block (a
// SetText landed on an unresolved rect) and never rebuilt;
// this zombie node keeps painting the old text. Re-set the
// fs's rebuild-dirty bit so the engine rebuilds next update
// and the icon pipeline resumes. Byte write only — safe
// from the paint tail (consumed by the fs's own update).
uint8_t *flags = of + Offsets::OFF_FONTSTRING_DIRTY_FLAGS;
if ((*flags & 1u) == 0) {
*flags |= 1u;
++g_statNudged;
}
}
}
}
auto it = g_nodeIcons.find(node);
if (it == g_nodeIcons.end() || it->second.empty()) {
// Ghost guard, scanned against the FONTSTRING's text (fs+0xF0 — proven
// trustworthy by the Broken() dumps, unlike the node's text pointer,
// which lied on pfUI chat lines and made the old node-text scan erase
// LIVE records). A node with records whose owning fs currently shows
// text WITHOUT any |T (e.g. a recycled nameplate name that inherited a
// dead emote node's address) is stale: erase and clear. False matches
// can only false-KEEP (safe), never false-erase.
// (NOTE: do NOT build-version records against the builder counter — the
// builder runs every paint but only emits when dirty, so a version
// check erases good records on every clean paint: the all-icons-gone
// regression.)
if (it != g_nodeIcons.end() && !it->second.icons.empty() && fs != nullptr) {
const char *ftext = *reinterpret_cast<const char *const *>(
reinterpret_cast<uint8_t *>(fs) + 0xF0);
bool fsHasMarkup = false;
if (LooksReadable(ftext)) {
for (int k = 1; k < 2048 && ftext[k] != '\0'; ++k)
if (ftext[k] == 'T' && ftext[k - 1] == '|') {
fsHasMarkup = true;
break;
}
}
if (!fsHasMarkup) {
g_nodeIcons.erase(it);
it = g_nodeIcons.end();
}
}
if (it == g_nodeIcons.end() || it->second.icons.empty()) {
if (fs != nullptr)
Text::InlineTexturePool::QueuePlacements(fs, {});
node = next;
continue;
}
// Drop stale records: the chat frame reuses its line FontStrings, and when
// a line is cleared or reused for markup-free text the draw builder
// early-returns on it, so the emitter never runs and never erases the old
// record — drawing it would paint ghost icons at the parked position. So
// gate on the node's live text still containing `|T`.
const uint8_t *ntext = *reinterpret_cast<uint8_t *const *>(n + Offsets::OFF_TEXT_NODE_TEXT);
bool hasMarkup = false;
if (LooksReadable(ntext)) {
for (int k = 0; k < 2048 && ntext[k] != '\0'; ++k)
if (ntext[k] == '|' && ntext[k + 1] == 'T') {
hasMarkup = true;
break;
}
}
if (!hasMarkup) {
g_nodeIcons.erase(it);
// Hide any icon regions still serving this fontstring (chat reuses
// line fontstrings for markup-free text — ghost regions otherwise).
if (fs != nullptr)
Text::InlineTexturePool::QueuePlacements(fs, {});
node = next;
continue;
}
// NOTE: no node-text stale scan here. Records are authoritative from the
// emitter alone: a reused node address is always rebuilt → dirty → the
// emitter runs before any flush walks it, and its firstLine erase (or
// suppression erase) cleans stale records. The old flush-side scan of
// node+text for |T read a stale/preprocessed pointer on pfUI chat lines
// and ERASED LIVE RECORDS every frame — the persistent iconless LFG
// lines (FsDump chain bucket 3).
// Never draw over editable text (flags bit 6) — safety net for records
// made before the emitter's editable-suppress applied.
@@ -978,10 +1107,27 @@ void FlushLayout(void *layout) {
const bool wantQuads = g_renderMode != 1;
const bool wantRegions = g_renderMode != 0;
float K = 0.0f;
if (wantRegions)
// The fs rect, read HERE in the same flush as the icon coords — a
// coherent snapshot. Placements are stored FS-RELATIVE: an
// apply-time rect read raced the chat relayout (SetText invalidates
// the rect briefly), and a placement applied against a mid-relayout
// rect parked the icon off the line — then the dedup (unchanged
// absolute want) froze it there. Relative offsets are also
// position-invariant, so scrolling no longer re-places anything.
float fsLeft = 0.0f, fsBottom = 0.0f;
bool fsRectValid = false;
if (wantRegions) {
K = DeriveK(n, reinterpret_cast<uint8_t *>(fs), ox);
if (fs != nullptr) {
const float *rc = reinterpret_cast<const float *>(
reinterpret_cast<uint8_t *>(fs) + Offsets::OFF_REGION_RECT);
fsBottom = (rc[0] < rc[2]) ? rc[0] : rc[2];
fsLeft = (rc[1] < rc[3]) ? rc[1] : rc[3];
fsRectValid = rc[1] != rc[3]; // unresolved rect reads 0-width
}
}
std::vector<Text::InlineTexturePool::Placement> places;
for (const IconRecord &r : it->second) {
for (const IconRecord &r : it->second.icons) {
// Screen left = pen + the FULL lead pad. The emitter reserves
// w + 1.5×pad in the advance (lead 1×, trail 0.5×) — drawing at
// the raw pen put all of that gap AFTER the icon, which is why
@@ -997,15 +1143,15 @@ void FlushLayout(void *layout) {
r.v0, r.u1, r.v1, r.color, r.z);
Text::InlineTexturePool::Hold(r.path.c_str());
}
if (wantRegions && K > 1.0f && fs != nullptr) {
if (wantRegions && K > 1.0f && fs != nullptr && fsRectValid) {
const float rx = cx + g_regionCalX;
const float ry = cy + g_regionCalY;
Text::InlineTexturePool::Placement p;
p.path = r.path;
p.x0 = rx / K;
p.y0 = (ry - r.h * 0.5f) / K;
p.x1 = (rx + r.w) / K;
p.y1 = (ry + r.h * 0.5f) / K;
p.x0 = rx / K - fsLeft;
p.y0 = (ry - r.h * 0.5f) / K - fsBottom;
p.x1 = (rx + r.w) / K - fsLeft;
p.y1 = (ry + r.h * 0.5f) / K - fsBottom;
p.color = r.color;
p.u0 = r.u0;
p.v0 = r.v0;
@@ -1014,9 +1160,28 @@ void FlushLayout(void *layout) {
places.push_back(std::move(p));
}
}
if (wantRegions && fs != nullptr)
Text::InlineTexturePool::QueuePlacements(fs, std::move(places));
// The queue gate must match the per-icon build gate EXACTLY —
// including K. If any input wasn't ready this paint (unresolved fs
// rect, K not yet derived), SKIP the queue and retry next paint: an
// empty queue here would HIDE the line's icons and the dedup would
// freeze it hidden forever (identical empty re-queues never dirty).
// That was the scroll-landing bug's second head: lines painted
// before the session's first K derivation queued {} and stayed
// iconless until their text changed.
if (wantRegions) {
if (fs == nullptr)
++g_statDropNoFs;
else if (!fsRectValid)
++g_statDropRect;
else if (!(K > 1.0f))
++g_statDropK;
else {
++g_statQueued;
Text::InlineTexturePool::QueuePlacements(fs, std::move(places));
}
}
} else if (fs != nullptr) {
++g_statDropEditable;
Text::InlineTexturePool::QueuePlacements(fs, {});
}
node = next;
@@ -1081,13 +1246,22 @@ int __fastcall Script_InlineTexTune(void *L) {
return 4;
}
// _classicapi_InlineTexStats() -> enabled, suppressed, trackedNodes, faultCode.
// _classicapi_InlineTexStats() -> enabled, suppressed, trackedNodes, faultCode,
// queued, dropNoFs, dropRect, dropK, faultAddr (flush-gate telemetry cumulative;
// faultAddr = the faulting instruction VA when the SEH latch tripped, 0 if never).
int __fastcall Script_InlineTexStats(void *L) {
Game::Lua::PushBool(L, g_inlineEnabled);
Game::Lua::PushBool(L, g_suppressInline);
Game::Lua::PushNumber(L, static_cast<double>(g_nodeIcons.size()));
Game::Lua::PushNumber(L, static_cast<double>(g_faultCode));
return 4;
Game::Lua::PushNumber(L, static_cast<double>(g_statQueued));
Game::Lua::PushNumber(L, static_cast<double>(g_statDropNoFs));
Game::Lua::PushNumber(L, static_cast<double>(g_statDropRect));
Game::Lua::PushNumber(L, static_cast<double>(g_statDropK));
Game::Lua::PushNumber(L, static_cast<double>(g_faultAddr));
Game::Lua::PushNumber(L, static_cast<double>(g_statNudged));
Game::Lua::PushNumber(L, static_cast<double>(g_statDropEditable));
return 11;
}
// _classicapi_InlineTexMode([n]) -> mode. 0 = quads (pen-space, needs holders),
@@ -1183,6 +1357,67 @@ static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
int DebugChainState(void *fs) {
if (!LooksReadable(fs))
return 0;
void *block = *reinterpret_cast<void **>(reinterpret_cast<uint8_t *>(fs) +
Offsets::OFF_FONTSTRING_TEXT_BLOCK);
if (block == nullptr) {
// Blockless: rebuild pending (dirty bit set → engine will rebuild) vs
// STUCK (bit clear → nothing will ever rebuild; the flush nudges these).
const uint8_t flags = *(reinterpret_cast<uint8_t *>(fs) +
Offsets::OFF_FONTSTRING_DIRTY_FLAGS);
return (flags & 1u) ? 5 : 6;
}
if (!LooksReadable(block))
return 0;
void *node = *reinterpret_cast<void **>(reinterpret_cast<uint8_t *>(block) +
Offsets::OFF_TEXTBLOCK_NODE);
if (!LooksReadable(node))
return 0;
auto ow = g_nodeOwner.find(node);
if (ow == g_nodeOwner.end())
return 1;
if (ow->second != fs)
return 2;
auto it = g_nodeIcons.find(node);
if (it == g_nodeIcons.end() || it->second.icons.empty())
return 3;
return 4;
}
int DebugNodeSeenAge(void *fs) {
if (!LooksReadable(fs))
return -1;
void *block = *reinterpret_cast<void **>(reinterpret_cast<uint8_t *>(fs) +
Offsets::OFF_FONTSTRING_TEXT_BLOCK);
if (!LooksReadable(block))
return -1;
void *node = *reinterpret_cast<void **>(reinterpret_cast<uint8_t *>(block) +
Offsets::OFF_TEXTBLOCK_NODE);
if (!LooksReadable(node))
return -1;
auto it = g_nodeSeen.find(node);
if (it == g_nodeSeen.end())
return -1;
return static_cast<int>(g_flushSeq - it->second);
}
unsigned int DebugNodeFlags(void *fs) {
if (!LooksReadable(fs))
return 0xFFFFFFFFu;
void *block = *reinterpret_cast<void **>(reinterpret_cast<uint8_t *>(fs) +
Offsets::OFF_FONTSTRING_TEXT_BLOCK);
if (!LooksReadable(block))
return 0xFFFFFFFFu;
void *node = *reinterpret_cast<void **>(reinterpret_cast<uint8_t *>(block) +
Offsets::OFF_TEXTBLOCK_NODE);
if (!LooksReadable(node))
return 0xFFFFFFFFu;
return *reinterpret_cast<const uint32_t *>(reinterpret_cast<const uint8_t *>(node) +
Offsets::OFF_TEXT_NODE_FLAGS);
}
void PrepareForReload() {
// /reload frees every gxu text node — g_nodeIcons/g_nodeOwner hold stale
// node pointers. Forget them (records rebuild as the reloaded UI re-emits
@@ -1190,6 +1425,8 @@ void PrepareForReload() {
// cache); K survives (resolution/uiScale don't change across /reload).
g_nodeIcons.clear();
g_nodeOwner.clear();
g_nodeSeen.clear();
g_nodeBuilt.clear();
}
} // namespace Text::InlineTexture
+21
View File
@@ -20,4 +20,25 @@ namespace Text::InlineTexture {
// icon records are dropped (they rebuild as the reloaded UI re-emits its text).
void PrepareForReload();
// Debug (for _classicapi_InlineTexFsDump): classify the fs → node → records
// chain for a fontstring whose live text has |T but whose queued want is empty.
// 0 = fs/block/node unreadable
// 1 = node not in the owner map (RebuildString hook missed it)
// 2 = node mapped to a DIFFERENT fs (owner-map corruption)
// 3 = authoritative, but no icon records (emitter never recorded this text)
// 4 = authoritative WITH records (flush-side gate dropped the queue: rect
// validity, K, or suppression)
// 5 = blockless, rebuild PENDING (dirty bit set — engine will rebuild)
// 6 = blockless and STUCK (dirty bit clear — the flush nudges these)
int DebugChainState(void *fs);
// Debug: the fs's current text node's flags word (+0x5C), or 0xFFFFFFFF when
// the chain is unreadable. Bit 6 (0x40) = editable (flush queues {} for these).
unsigned int DebugNodeFlags(void *fs);
// Debug: flush calls elapsed since the fs's CURRENT node was last walked by
// FlushLayout (-1 = never seen / chain unreadable). A large, growing age means
// the node's layout is not being painted — the flush can never queue for it.
int DebugNodeSeenAge(void *fs);
} // namespace Text::InlineTexture
+168 -8
View File
@@ -37,6 +37,7 @@
#include "Game.h"
#include "Offsets.h"
#include "text/InlineTexture.h"
#include "tick/WorldTick.h"
#include <string>
@@ -93,9 +94,16 @@ struct FsPlacements {
bool dirty = false;
std::vector<IconRegion> regions; // engine regions currently serving this fs
int shown = 0; // how many of `regions` are visible
// Maintain-tick of the last QueuePlacements touch (deduped ones included).
// A LIVE painted icon line is re-queued every paint; a parked/orphaned fs
// (recycled chat line whose node never rebuilds — the "ghost icons at the
// parked position" class) stops being touched entirely, so its regions
// expire via the freshness check in Maintain.
uint32_t lastTouchTick = 0;
};
std::unordered_map<void *, FsPlacements> g_fsIcons;
uint32_t g_maintainTick = 0;
// Holder geometry/alpha, live-tunable via _classicapi_InlineTexHolderCfg for
// in-game verification. Units are the region anchor space ([0..1] across the
@@ -195,12 +203,11 @@ void *CreateRegion(void *parent) {
// engine moves the icon with its line for free).
void ApplyPlacement(IconRegion &r, void *fs, const Placement &p) {
auto *f = reinterpret_cast<uint8_t *>(fs);
// Region rect at +0x64 is {yA, left, yB, right} — x at [1]/[3], y at
// [0]/[2]; take min per axis rather than pinning which index is which
// corner (the RE note's warning). Anchor-space y grows up.
const float *rc = reinterpret_cast<const float *>(f + Offsets::OFF_REGION_RECT);
const float fsBottom = (rc[0] < rc[2]) ? rc[0] : rc[2];
const float fsLeft = (rc[1] < rc[3]) ? rc[1] : rc[3];
// Placement coords are FS-RELATIVE (from the fs rect's min corner), computed
// at PAINT time in the same flush as the icon coords. Do NOT read the fs
// rect here: an apply-time read raced the chat relayout (SetText briefly
// invalidates the rect) and parked icons off their line, where the dedup
// then froze them — the scroll-landing "randomly hidden icon" bug.
if (r.path != p.path) {
const uint32_t blend = *reinterpret_cast<uint32_t *>(Offsets::VAR_TEXTURE_BLEND_DEFAULT);
@@ -238,9 +245,9 @@ void ApplyPlacement(IconRegion &r, void *fs, const Placement &p) {
void *texAnchor = reinterpret_cast<uint8_t *>(r.tex) + Offsets::OFF_REGION_ANCHOR;
void *fsAnchor = f + Offsets::OFF_REGION_ANCHOR;
setPoint(texAnchor, Offsets::FRAMEPOINT_BOTTOMLEFT, fsAnchor, Offsets::FRAMEPOINT_BOTTOMLEFT,
(p.x0 - fsLeft) * scale, (p.y0 - fsBottom) * scale, 1);
p.x0 * scale, p.y0 * scale, 1);
setPoint(texAnchor, Offsets::FRAMEPOINT_TOPRIGHT, fsAnchor, Offsets::FRAMEPOINT_BOTTOMLEFT,
(p.x1 - fsLeft) * scale, (p.y1 - fsBottom) * scale, 1);
p.x1 * scale, p.y1 * scale, 1);
reinterpret_cast<Realize_t>(Offsets::FUN_REGION_LAYOUT_REALIZE)(texAnchor, 0);
Show(r.tex);
}
@@ -263,10 +270,26 @@ void Maintain() {
}
}
++g_maintainTick;
for (auto &kv : g_fsIcons) {
void *fs = kv.first;
FsPlacements &np = kv.second;
// Freshness expiry: a LIVE painted icon line re-queues every paint, so
// its touch stamp stays current. A parked/orphaned fs (recycled chat
// line whose node never rebuilds and may not even be walked) stops
// being touched — hide its regions and drop the stale want so nothing
// ghosts at the parked position. ~1s at a per-frame tick.
constexpr uint32_t kWantTTL = 60;
if (np.shown > 0 && g_maintainTick - np.lastTouchTick > kWantTTL) {
for (int i = 0; i < np.shown; ++i)
Hide(np.regions[static_cast<size_t>(i)].tex);
np.shown = 0;
np.want.clear();
np.dirty = false;
continue;
}
// Mirror the fontstring's visibility EVERY tick, not just on dirty:
// expired/hidden chat lines get their fs Hidden by the message frame,
// and our regions are parented to the CHAT FRAME (regions can't parent
@@ -368,10 +391,12 @@ void QueuePlacements(void *fs, std::vector<Placement> &&icons) {
FsPlacements np;
np.want = std::move(icons);
np.dirty = true;
np.lastTouchTick = g_maintainTick;
g_fsIcons.emplace(fs, std::move(np));
return;
}
FsPlacements &np = it->second;
np.lastTouchTick = g_maintainTick; // freshness — deduped touches count too
if (np.want == icons)
return; // identical re-queue (the per-paint steady state) — no work
np.want = std::move(icons);
@@ -448,6 +473,139 @@ int __fastcall Script_InlineTexRegionDump(void *L) {
return 0;
}
// _classicapi_InlineTexFsDump() -> total, markup, missingWant, lostApply,
// hiddenRegion, healthy, fsHidden, dirty. Triangulates WHERE the pipeline
// loses an icon when a scrolled chat line lands without one:
// markup = tracked fs whose LIVE text (fs+0xF0) contains |T
// missingWant = markup fs with EMPTY want -> paint side never queued
// lostApply = want>0, not dirty, shown<want -> tick side lost the apply
// hiddenRegion= applied but a region reads +0xC8==0 while its fs is shown
// healthy = markup fs fully applied and all regions shown
// Run while a scrolled-in line is visibly missing its icon.
int __fastcall Script_InlineTexFsDump(void *L) {
int total = 0, markup = 0, missingWant = 0, lostApply = 0, hiddenRegion = 0, healthy = 0,
fsHidden = 0, dirty = 0;
int chain[7] = {0, 0, 0, 0, 0, 0, 0}; // DebugChainState buckets for missingWant fs
for (auto &kv : g_fsIcons) {
++total;
FsPlacements &np = kv.second;
auto *f = reinterpret_cast<uint8_t *>(kv.first);
if (!LooksReadable(f))
continue;
const bool fsShown =
*reinterpret_cast<const uint32_t *>(f + Offsets::OFF_REGION_ACTUALLY_SHOWN) != 0;
if (!fsShown)
++fsHidden;
if (np.dirty)
++dirty;
bool hasMarkup = false;
const char *text = *reinterpret_cast<const char *const *>(f + 0xF0);
if (LooksReadable(text))
for (int k = 0; k < 2048 && text[k] != '\0'; ++k)
if (text[k] == '|' && (text[k + 1] == 'T' || text[k + 1] == 't')) {
hasMarkup = (text[k + 1] == 'T');
if (hasMarkup)
break;
}
if (hasMarkup)
++markup;
const int want = static_cast<int>(np.want.size());
// Anomalies only count for VISIBLE fontstrings: a hidden line's node
// isn't painted, so it legitimately stops re-queueing — an empty want
// there is bookkeeping, not a bug (this blind spot produced phantom
// missingWant counts next to fsHidden).
if (hasMarkup && want == 0) {
if (fsShown) {
++missingWant;
const int cs = Text::InlineTexture::DebugChainState(kv.first);
if (cs >= 0 && cs <= 6)
++chain[cs];
}
continue;
}
if (want > 0 && !np.dirty && np.shown < want) {
++lostApply;
continue;
}
bool anyRegionHidden = false;
for (int i = 0; i < np.shown && i < static_cast<int>(np.regions.size()); ++i) {
auto *t = reinterpret_cast<uint8_t *>(np.regions[static_cast<size_t>(i)].tex);
if (LooksReadable(t) &&
*reinterpret_cast<const uint32_t *>(t + Offsets::OFF_REGION_ACTUALLY_SHOWN) == 0) {
anyRegionHidden = true;
break;
}
}
if (fsShown && want > 0 && anyRegionHidden) {
++hiddenRegion;
continue;
}
if (hasMarkup && fsShown && want > 0 && np.shown >= want)
++healthy;
}
Game::Lua::PushNumber(L, total);
Game::Lua::PushNumber(L, markup);
Game::Lua::PushNumber(L, missingWant);
Game::Lua::PushNumber(L, lostApply);
Game::Lua::PushNumber(L, hiddenRegion);
Game::Lua::PushNumber(L, healthy);
Game::Lua::PushNumber(L, fsHidden);
Game::Lua::PushNumber(L, dirty);
// Chain classification of the missingWant fs (see DebugChainState):
// [9] unreadable, [10] node unmapped, [11] mapped to other fs,
// [12] authoritative but NO records, [13] authoritative WITH records,
// [14] blockless rebuild-pending, [15] blockless STUCK (nudged).
for (int i = 0; i < 7; ++i)
Game::Lua::PushNumber(L, chain[i]);
return 15;
}
// _classicapi_InlineTexBroken() -> up to 2 × (textSnippet, chainState,
// nodeFlags, nodeSeenAge) for VISIBLE markup fontstrings whose want is empty —
// names the actual broken lines; nodeSeenAge large/growing = the node's layout
// is never painted, so the flush can never queue it.
int __fastcall Script_InlineTexBroken(void *L) {
int pushed = 0;
for (auto &kv : g_fsIcons) {
if (pushed >= 8)
break;
FsPlacements &np = kv.second;
if (!np.want.empty())
continue;
auto *f = reinterpret_cast<uint8_t *>(kv.first);
if (!LooksReadable(f))
continue;
if (*reinterpret_cast<const uint32_t *>(f + Offsets::OFF_REGION_ACTUALLY_SHOWN) == 0)
continue;
const char *text = *reinterpret_cast<const char *const *>(f + 0xF0);
if (!LooksReadable(text))
continue;
bool hasMarkup = false;
for (int k = 0; k < 2048 && text[k] != '\0'; ++k)
if (text[k] == '|' && text[k + 1] == 'T') {
hasMarkup = true;
break;
}
if (!hasMarkup)
continue;
char snip[96];
int n = 0;
while (n < 95 && text[n] != '\0') {
snip[n] = text[n];
++n;
}
snip[n] = '\0';
Game::Lua::PushString(L, snip);
Game::Lua::PushNumber(L, Text::InlineTexture::DebugChainState(kv.first));
Game::Lua::PushNumber(L,
static_cast<double>(Text::InlineTexture::DebugNodeFlags(kv.first)));
Game::Lua::PushNumber(L,
static_cast<double>(Text::InlineTexture::DebugNodeSeenAge(kv.first)));
pushed += 4;
}
return pushed;
}
// _classicapi_InlineTexHolderCfg([visible][, size][, alpha]) -> the three.
// Live-reconfigures every holder; omitted args keep their value. `visible` and
// `size` are anchor units (~[0..1] across the screen; px ≈ units × screenWidth),
@@ -473,6 +631,8 @@ void RegisterLuaFunctions() {
&Script_InlineTexHolderCfg);
Game::Lua::RegisterGlobalFunction("_classicapi_InlineTexRegionDump",
&Script_InlineTexRegionDump);
Game::Lua::RegisterGlobalFunction("_classicapi_InlineTexFsDump", &Script_InlineTexFsDump);
Game::Lua::RegisterGlobalFunction("_classicapi_InlineTexBroken", &Script_InlineTexBroken);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+8 -3
View File
@@ -47,9 +47,14 @@ void Hold(const char *path);
// construction (no flicker, no holders needed); engine-anchored → tracks the
// fontstring's movement for free.
// One icon's target geometry, in ABSOLUTE anchor-space screen coordinates
// (y up, same units as the region rect at +0x64). The pool converts to
// fs-relative SetPoint offsets when applying.
// One icon's target geometry, in anchor-space units RELATIVE TO THE OWNING
// FONTSTRING's rect min corner (y up, same units as the region rect at +0x64).
// Computed entirely at PAINT time — icon coords and fs rect read in the same
// flush, a coherent snapshot — so the pool applies it verbatim with no
// apply-time rect read (an apply-time read raced the chat relayout after
// SetText and parked icons off their line, where the dedup froze them: the
// scroll-landing "randomly hidden icon" bug). Relative offsets are also
// position-invariant, so scrolling a line re-queues nothing.
struct Placement {
std::string path;
float x0, y0, x1, y1;