Files
ClassicAPI/src/text/InlineTexture.cpp
T
Brues 0c8468cf2f refactor(reload): auto-register per-/reload cleanup; fix Time::Timer stale refs
Modules that keep reload-fragile file-static state (Lua registry refs,
frame/object-pointer-keyed maps, cached event-slot indices) each expose a
PrepareForReload() that DllMain called from the FrameScript_Initialize hook.
That list was hand-maintained, so a new module could silently forget to wire
itself in -- the failure mode behind the Frame::Attributes stale-ref bug and
Tooltip::SetEvents issue #33.

Add Game::ReloadAutoRegister + Game::RunReloadCleanups(), mirroring the
existing ModuleAutoRegister / HookAutoRegister / WorldTick::AutoSubscribe
patterns. Each module self-registers with one file-scope line after its
PrepareForReload:

    static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};

DllMain's 11-call manual list collapses to a single RunReloadCleanups(); a
new module can no longer be forgotten. Player::NameCache::Flush stays an
explicit call -- it's a save-before-teardown, not a stale-state clear, and
also runs on the glue-return path.

A sweep of all file-static state found one uncovered module: Time::Timer
pins C_Timer.After/NewTimer/NewTicker callbacks in the Lua registry but never
released them on /reload -- a surviving/stale ref could fire a dead closure.
Add Time::Timer::PrepareForReload (release each pending timer's registry
callback, clear the queue) and self-register it.

- Game.{h,cpp}: ReloadAutoRegister + RunReloadCleanups.
- DllMain.cpp: collapse the manual list to RunReloadCleanups(); drop 6
  now-dead includes.
- 11 existing reload modules + Time::Timer: add the self-register line.

Behavior-preserving (same cleanups, before the Lua teardown); auto-register
order is unspecified across TUs but every callback only clears its own state.
2026-08-25 18:37:44 -05:00

2045 lines
106 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Inline texture escape (`|Tpath:height:width:...|t`) backport.
//
// Vanilla 1.12 has ZERO inline-texture support: the shared `|`-tokenizer
// (FUN_005c2810) has no `T`/`t` case, so `|T...|t` renders as literal text.
// This module teaches the text engine to render the icon inline.
//
// Two working pieces:
// 1. POSITIONING — co-hook the per-line glyph emitter (FUN_005ccbe0) and, for
// a line containing `|T…|t`, render the plain text runs by DELEGATING to the
// original emitter per segment (threading the pen through linkState[4]) while
// recording an icon at the pen between runs. This avoids reimplementing the
// emitter's intricate vertex math: the engine still lays out every glyph; we
// only track where each icon goes.
// 2. RENDERING — the 4.3.4 CSimpleEmbeddedTexture model, ported faithfully
// (the ONLY render path; a raw-GxU-quad mode existed during bring-up and
// was removed). A co-hook on CSimpleFontString::RebuildString
// (FUN_007724A0) maps each fresh text node to its OWNING FONTSTRING (1.12
// chat lines are real CSimpleFontStrings — the ScrollingMessageFrame's
// display refresh FUN_00788750 SetTexts/anchors/shows one per visible
// line). The paint-tail flush walks the icon records and queues each to
// `Text::InlineTexturePool`, which configures a pooled engine-managed
// CSimpleTexture ANCHORED TO THE OWNING FONTSTRING at the pen offset —
// exactly how 4.3.4's UpdateEmbeddedTextures anchors its embedded
// textures. The engine draws the region every frame (residency) and the
// anchor system moves it with its line on every scroll/shift (zero
// per-frame work, no render transforms, no mid-render mutation). See
// docs/InlineTextureResidency.md.
//
// Supports the full positional payload
// `|Tpath:height:width:offsetX:offsetY:texW:texH:left:right:top:bottom:r:g:b|t`
// (size, pen offset, sprite-sheet texcoord crop, and r:g:b vertex tint). MEASURE
// is corrected at the fs-level chokes: the tokenizer still reports an icon as
// ~zero to every measure/wrap loop (the token contract has no size fields), but
// a co-hook on the internal string-width getter (FUN_FONTSTRING_STRING_WIDTH)
// re-adds the same per-icon advances the emitter reserves — so GetStringWidth,
// the tooltip auto-size, and auto-width layout match the rendered width — a
// co-hook on the internal string-height getter (FUN_FONTSTRING_STRING_HEIGHT)
// adds the tallest icon's overflow past the font height — so a tall icon's
// LINE grows to fit it (chat stacking + auto-height rects; tall icons
// bottom-align at draw so the overflow points into the grown space) — and a
// co-hook on the shared wrap-stepper dispatcher (FUN_TEXT_WRAP_STEPPER)
// shrinks the wrap width by the line's icon advances so wrapped lines break at
// the visible edge instead of overflowing. Still icon-blind (documented,
// accepted): the substring measure (FUN_00772AE0) and hyperlink hit-testing.
// See docs/InlineTextureEscapes.md for the RE map.
#include "text/InlineTexture.h"
#include "Game.h"
#include "Offsets.h"
#include "text/InlineTexturePool.h"
#include "text/PtrProbe.h"
#include "texture/Transform.h"
#include <windows.h>
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <string>
#include <unordered_map>
#include <vector>
namespace Text::InlineTexture {
namespace {
// LAA-aware pointer sanity probe: Text::LooksReadable (text/PtrProbe.h).
// --- inline-texture descriptor parse ---------------------------------------
// Parsed
// `|Tpath:height:width:offsetX:offsetY:texW:texH:left:right:top:bottom:r:g:b|t`
// payload. Trailing fields are optional; texcoords crop a sprite sheet (e.g. the
// raid-target icons) to one cell, and r:g:b (0-255) tint the icon.
struct IconDesc {
std::string path;
float height = 0.0f;
float width = 0.0f; // defaults to height when the width field is absent/0
float offsetX = 0.0f; // pen-relative pixel shift
float offsetY = 0.0f;
float u0 = 0.0f, v0 = 0.0f, u1 = 1.0f, v1 = 1.0f; // full texture by default
// Vertex colour (modulates the texture). Packed 0xAARRGGBB — the same order
// the engine builds for `|cAARRGGBB` text (verified in the tokenizer's colour
// path), so full white = no tint. Set from the optional r:g:b (0-255) fields.
uint32_t color = 0xFFFFFFFFu;
};
// Reads a numeric field starting at `s` (bounded by `end`), stopping at the next
// ':' or the end. Returns the parsed value; sets `next` past the field's
// terminating ':' (or to `end`). Tolerates leading spaces and decimals.
float ParseField(const char *s, const char *end, const char **next) {
char buf[32];
int n = 0;
const char *p = s;
while (p < end && *p != ':' && n < static_cast<int>(sizeof(buf) - 1)) {
buf[n++] = *p;
++p;
}
buf[n] = '\0';
// Skip to just past the delimiter for the caller's next field.
while (p < end && *p != ':')
++p;
if (p < end && *p == ':')
++p;
*next = p;
return static_cast<float>(atof(buf));
}
// Parses the payload between `|T` and `|t` (`payload`, length `len`) into `out`.
// Returns false if there's no path or no positive height (nothing to draw).
bool ParseIcon(const char *payload, size_t len, IconDesc &out) {
const char *end = payload + len;
const char *colon = payload;
while (colon < end && *colon != ':')
++colon;
if (colon == payload)
return false; // empty path
out.path.assign(payload, static_cast<size_t>(colon - payload));
if (colon >= end)
return false; // path with no height
// Parse up to 13 numeric fields after the path: height, width, offsetX,
// offsetY, texW, texH, left, right, top, bottom, r, g, b.
float f[13] = {0};
int nf = 0;
const char *p = colon + 1;
while (p < end && nf < 13) {
const char *nx = p;
f[nf++] = ParseField(p, end, &nx);
p = nx;
}
if (nf < 1)
return false; // need at least the height field
// height/width of 0 means "auto" — resolved to the line's font height at emit
// time (retail's `:0:0` convention, e.g. GetCoinTextureString coins).
out.height = f[0];
out.width = (nf >= 2 && f[1] > 0.0f) ? f[1] : out.height;
out.offsetX = (nf >= 3) ? f[2] : 0.0f;
out.offsetY = (nf >= 4) ? f[3] : 0.0f;
// texW=f[4] texH=f[5] left=f[6] right=f[7] top=f[8] bottom=f[9] -> normalized
// texcoords cropping the sheet to one cell.
if (nf >= 10 && f[4] > 0.0f && f[5] > 0.0f) {
out.u0 = f[6] / f[4];
out.u1 = f[7] / f[4];
out.v0 = f[8] / f[5];
out.v1 = f[9] / f[5];
}
// r=f[10] g=f[11] b=f[12] (0-255) -> vertex tint. All three required (they're
// the last positional fields); clamped to a byte and packed 0xFFrrggbb.
if (nf >= 13) {
auto clampByte = [](float v) -> uint32_t {
if (v < 0.0f)
v = 0.0f;
if (v > 255.0f)
v = 255.0f;
return static_cast<uint32_t>(v + 0.5f);
};
out.color = 0xFF000000u | (clampByte(f[10]) << 16) | (clampByte(f[11]) << 8) |
clampByte(f[12]);
}
return true;
}
// --- per-node recorded icons -----------------------------------------------
// One icon to draw, in the render node's node-local coordinate space (the same
// space glyph verts live in — the paint pass translates it by the node origin).
struct IconRecord {
std::string path; // texture path
float x; // node-local pen x at the icon
float y; // node-local pen reference (penXYZ[1]) — near the text top
float fontH; // font pixel height of the line, for vertical centering
float w;
float h;
float offsetX, offsetY; // pen-relative pixel shift
float outlineInk; // OutlineInkPen of the line's face — half leads the icon
float u0, v0, u1, v1; // texture crop
uint32_t color; // vertex tint (0xAARRGGBB; white = untinted)
};
// Icons keyed by render node. Stale-record safety is structural, not
// versioned: the node-free co-hook (below) erases a node's records the moment
// its address can be reused, and the emitter's firstLine erase resets them on
// every rebuild. (An explicit per-build version check was tried and REJECTED —
// the builder runs every paint but only emits when dirty, so a version
// mismatch on a clean paint erased live records: the all-icons-gone
// regression.)
std::unordered_map<void *, std::vector<IconRecord>> 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
// flush to anchor icon regions to their line. Stale fs pointers are guarded by
// LooksReadable at use (chat/bubble fontstrings are pooled and long-lived);
// the map clears on /reload.
std::unordered_map<void *, void *> g_nodeOwner;
// node → the node's base colour (node+0x2c) at the time its segmented
// per-glyph colours were baked. A segmented (bit-3, icon-bearing) node never
// re-bakes on a colour change — the engine's colour setter skips the
// invalidate for bit-3 nodes because UNIFORM bit-3 text recolours live at
// paint — so a later SetTextColor RGB change (the glue AddonList's gold↔gray
// toggle) froze the glyphs at their bake-time colour while plain titles
// swapped fine. The flush compares this stamp against the live node colour
// and re-bakes on an RGB change via the engine's own invalidate; alpha-only
// changes take the cheap in-place alpha mirror instead (fades animate per
// frame — a re-bake per frame would be absurd, and alpha is safe to patch in
// place because every baked glyph carries the node alpha).
std::unordered_map<void *, uint32_t> g_nodeBakedColor;
// Region calibration, PEN units added to the region's position. x needs no
// constant — the +3 once calibrated here turned out to be the missing lead pad,
// now applied at draw. y keeps a 1px residual (the pen→anchor map's one true
// constant). Calibrated in-game during bring-up; fixed now.
constexpr float g_regionCalX = 0.0f;
constexpr 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.)
// Runtime toggle (default ON — the feature is proven; `_classicapi_InlineTexEnable(false)`
// still turns it off, and the SEH latch trips it on a flush OR rotation fault).
// When off, the emitter/tokenizer co-hooks fast-path straight to the originals
// and the fontstring-rotation apply is skipped.
bool g_inlineEnabled = true;
void *g_buildNode = nullptr;
uint32_t g_buildEmitSeq = 0;
// The CSimpleFontString that currently owns `node` (node is its live text node:
// fs+0xF8 → block, block+8 == node), or nullptr. Read-only — the validation
// variant with side effects (re-dirtying a stuck-blockless fs) lives inline in
// FlushLayout; this one just answers "whose verts are these?" for the rotation
// hook.
void *OwningFontStringOf(void *node) {
auto ow = g_nodeOwner.find(node);
if (ow == g_nodeOwner.end() || !LooksReadable(ow->second))
return nullptr;
void *block = Game::Read<void *>(ow->second, Offsets::OFF_FONTSTRING_TEXT_BLOCK);
if (!LooksReadable(block))
return nullptr;
void *cur = Game::Read<void *>(block, Offsets::OFF_TEXTBLOCK_NODE);
return (cur == node) ? ow->second : nullptr;
}
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_builderOriginal(node);
g_buildNode = nullptr;
// A fresh vertex bake happened iff the emitter emitted ≥1 line this build
// (clean paints re-run the builder but re-emit nothing). Only then are the
// node's glyph verts axis-aligned — the sole point it is safe to apply a
// fontstring rotation, since rotating already-rotated verts on a clean paint
// would make the text spin. Guarded: a bad vert read disables the inline
// feature (SEH latch) rather than faulting the client.
if (g_inlineEnabled && g_buildEmitSeq > 0) {
void *fs = OwningFontStringOf(node);
if (fs != nullptr) {
__try {
Texture::Transform::RotateFontStringNode(node, fs);
} __except (EXCEPTION_EXECUTE_HANDLER) {
g_inlineEnabled = false;
}
}
}
}
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_nodeBakedColor.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.
using RebuildString_t = void(__fastcall *)(void *fs);
RebuildString_t g_rebuildOriginal = nullptr;
void __fastcall RebuildString_h(void *fs) {
g_rebuildOriginal(fs);
if (!LooksReadable(fs))
return;
void *block = Game::Read<void *>(fs, Offsets::OFF_FONTSTRING_TEXT_BLOCK);
if (!LooksReadable(block))
return;
void *node = Game::Read<void *>(block, Offsets::OFF_TEXTBLOCK_NODE);
if (LooksReadable(node))
g_nodeOwner[node] = fs;
}
static const Game::HookAutoRegister _rebuildHook{Offsets::FUN_FONTSTRING_REBUILD_STRING,
reinterpret_cast<void *>(&RebuildString_h),
reinterpret_cast<void **>(&g_rebuildOriginal)};
// Pen-units-per-anchor-unit scale (K), per axis — the ENGINE'S OWN conversion,
// read live from the same four globals the text pipeline uses. Verified by
// decompiling the whole chain this session:
// FUN_0041ade0: block/node positions are stored NORMALIZED — fs-local anchor
// x ÷ [VAR_UI_COORD_SCALE_MUL] (= screen width in anchor units), y ÷
// [VAR_UI_ANCHOR_SCREEN_H];
// FUN_005cdf70 (origin finalize): node origin (+0x70/+0x74) =
// round(normalized × [VAR_TEXT_RASTER_X/Y]) — render-target PIXELS, with
// the justify shift and vertical align folded in.
// So K = raster ÷ anchorExtent per axis. Cross-checked against a live probe
// on a right-justified fs: rasterX/anchorW = 3025 matched the measured
// origin÷rect-right quotient to four digits.
//
// History: two earlier forms both failed. The empirical derivation
// (K = nodeOriginX / rectJustifyRefX) couldn't self-derive for chat (rect
// edge near x = 0 fails the divide-guard) and rode a global cache seeded by
// whichever fs derived first — right at the layout it was calibrated on,
// wrong elsewhere. A closed form from the SetPoint px factor
// ((div·1024/mul) × fsScale) divided by the wrong global pair and multiplied
// by a scale that doesn't belong (node positions are fed fs-LOCAL anchor
// units, so the fs chain never enters). Returns 0 while the globals aren't
// live yet (boot) — the caller's K > 1 gates treat that as "not ready".
struct PenScale {
float x = 0.0f, y = 0.0f;
};
PenScale PenPerAnchor() {
const float aw = Game::Read<float>(Offsets::VAR_UI_COORD_SCALE_MUL);
const float ah = Game::Read<float>(Offsets::VAR_UI_ANCHOR_SCREEN_H);
const int rx = Game::Read<int>(Offsets::VAR_TEXT_RASTER_X);
const int ry = Game::Read<int>(Offsets::VAR_TEXT_RASTER_Y);
PenScale k;
if (aw > 0.0f && rx > 0)
k.x = static_cast<float>(rx) / aw;
if (ah > 0.0f && ry > 0)
k.y = static_cast<float>(ry) / ah;
return k;
}
// True if `t` points into [buf, buf+strlen(buf)] — a TIGHT, exact extent (bounded
// strlen, cap 0x4000). The editbox's buffers are distinct heap allocations, so an
// in-range pointer is unambiguously that buffer's, with no false-match cap. Guards
// every deref: a null/unreadable buffer or below-range pointer just returns false.
bool PtrInBuffer(const uint8_t *t, const uint8_t *buf) {
if (!LooksReadable(buf) || t < buf)
return false;
size_t n = 0;
while (n < 0x4000u && buf[n] != '\0')
++n;
return t <= buf + n;
}
// The focused editbox's INPUT buffer ([fe+0x32C]/[fe+0x334]), or nullptr. This is
// the buffer the caret/width MEASURE path reads IN PLACE (FUN_0077da80 →
// FUN_00772ae0 → … → FUN_005c6940 loops the tokenizer), so the measure tokenizer's
// `text` points directly into it.
const uint8_t *FocusedEditboxInput() {
const void *fe = Game::Read<const void *>(Offsets::VAR_FOCUSED_EDITBOX);
if (!LooksReadable(fe))
return nullptr;
const uint8_t sel = Game::Read<uint8_t>(fe, Offsets::OFF_EDITBOX_BUFFER_SELECT);
const uint8_t *inBuf = Game::Read<const uint8_t *>(
fe, (sel & 8u) ? Offsets::OFF_EDITBOX_BUFFER_MASKED : Offsets::OFF_EDITBOX_BUFFER);
return LooksReadable(inBuf) ? inBuf : nullptr;
}
// True if `text` points into the focused editbox's input buffer — the MEASURE path
// (caret/GetStringWidth), which must measure RAW `|T…|t` so the caret stays aligned
// with the raw glyphs. Per-editbox, so a display FontString measured while an
// editbox is focused still measures icons (an improvement over the old global).
// Cheap: one pointer-range check on pipe-leading tokens, null-short-circuited.
bool TextInFocusedEditbox(const void *text) {
return PtrInBuffer(reinterpret_cast<const uint8_t *>(text), FocusedEditboxInput());
}
// True if `text`'s content equals the focused editbox's input text. The editbox
// RENDERS its content through a TRANSIENT copy — same bytes, different allocation,
// no pointer link to the editbox (verified in-game: the render buffer matched the
// input buffer's string exactly but sat in an unrelated heap block). The emitter
// gets that whole copy as one line, so a content compare identifies it. Bounded to
// 0x1000 bytes; only reached on a pipe-leading emit line while an editbox is focused.
bool EmitLineIsFocusedEditbox(const uint8_t *text) {
const uint8_t *inBuf = FocusedEditboxInput();
if (inBuf == nullptr || !LooksReadable(text))
return false;
// Compare render vs input with `||` collapsed to a single `|` on BOTH sides.
// Different editboxes handle typed escapes differently: some render the text
// as-is (render == input buffer, both single-pipe), some neutralize each `|`
// to `||` in the render while the buffer stays single-pipe. Collapsing pairs
// on both sides makes the compare robust to either, so the focused input's OWN
// line is recognized (and shown raw) regardless of doubling — no flag gate
// (which mis-fires on pfUI's editable chat display) needed. A chat history
// line's content differs, so it never matches and keeps its icons.
size_t r = 0, in = 0;
for (int guard = 0; guard < 0x1000; ++guard) {
uint8_t a = text[r];
if (a == '|' && text[r + 1] == '|') {
r += 2; // collapse `||` -> `|`
} else {
r += 1;
}
uint8_t b = inBuf[in];
if (b == '|' && inBuf[in + 1] == '|') {
in += 2; // collapse `||` -> `|`
} else {
in += 1;
}
if (a != b)
return false;
if (a == '\0')
return true; // both reached the terminator together
}
return true;
}
// Text range currently being delegated to the ORIGINAL emitter from a suppressed
// editbox render. The re-entrant tokenizer stands down across this whole span (not
// just the first token) so the delegated raw layout doesn't eat `|T` as a
// zero-width token. Single-threaded engine → a plain pair is safe.
const uint8_t *g_reentryLo = nullptr;
const uint8_t *g_reentryHi = nullptr;
inline bool TextInReentry(const uint8_t *t) {
return g_reentryLo != nullptr && t >= g_reentryLo && t < g_reentryHi;
}
// The tokenizer's stand-down environment, factored into ONE predicate so measure
// and render can never disagree: true when inline-texture interception is ACTIVE
// for `text` (spans are eaten as zero-width tokens and icons render). `editable`
// is the caller's editbox-PROXY bit at its own representation level — gxu flags
// bit 0x40 at the tokenizer, fs+0x120 bit 0x1000 at the string-width co-hook
// (FUN_0044D670 translates the latter into the former, so both call shapes
// evaluate the same engine bit). That bit is actually NON-SPACE-WRAP (what
// FontString:SetNonSpaceWrap toggles), used here as an editbox proxy — see
// NodeEditable. The tokenizer's `text[0] == '|'` position check stays at its call
// site — it's about where the tokenizer stands in the text, not about whether the
// feature is active.
bool InlineInterceptActive(const uint8_t *text, bool editable) {
return g_inlineEnabled && !editable && text != nullptr && !TextInReentry(text) &&
!TextInFocusedEditbox(text);
}
// A text node's flags (`[node+0x5c]`) bit 6 (0x40) is the NON-SPACE-WRAP bit (the
// one FontString:SetNonSpaceWrap sets; fs+0x120 bit 0x1000 → this via
// FUN_0044D420). We use it as an editbox PROXY: multi-line editors enable
// non-space-wrap (macro editbox node flags 0x4D, bit 6 set) while chat/display
// text does not (chat 0x20D, FontStrings 0x0D — bit 6 clear), so suppressing
// inline rendering when it is set covers editboxes the focused-buffer pointer test
// misses (multi-line editors build once, un-focused). Caveat: the correlation is
// not a guarantee — a DISPLAY fontstring with SetNonSpaceWrap(true) + inline |T
// icons would be wrongly suppressed here (rare, never observed; the focused-buffer
// pointer test remains the primary editbox guard). Mirrors 4.3.4's per-render
// texture-disable flag, adapted to 1.12's layout.
inline bool NodeEditable(const void *node) {
return (Game::Read<uint32_t>(node, Offsets::OFF_TEXT_NODE_FLAGS) & 0x40u) != 0;
}
// Geometry constants, calibrated in-game during bring-up (via a since-removed
// live-tune surface) and now fixed.
constexpr float g_vBias = 0.0f; // extra node-local Y added to the icon centre
constexpr float g_sizeScale = 1.0f; // multiplies the parsed icon size
// Icon vertical centre = penY + fontHeight * centerFrac. penY sits near the text
// top; ~0.6 centres the icon on the line across the fonts we render into (chat +
// pfUI's bubble).
constexpr float g_centerFrac = 0.6f;
// Horizontal breathing room around every inline icon, as a fraction of the LINE'S
// FONT HEIGHT — a text-relative gap, like the space between words. It is NOT a
// fraction of the icon's own width: that made the gap scale with icon size, so a
// small icon (e.g. a font-height coin next to a digit) jammed its neighbour while a
// huge icon floated away from it. Keying off the font height makes the gap constant
// for a given text size regardless of how big the icon is drawn — and a font-height
// icon is unchanged, so marks / default coins / default emotes keep their spacing.
// Applied FULL on the lead (left) and HALF on the trail (right): the preceding
// glyph's right-side bearing extends past the reported pen and eats into the left
// gap visually, so a lead-heavy split makes the left and right gaps LOOK even.
// Without any pad an icon crowds the char before it. 0.18 ≈ 2.7px lead / 1.35px
// trail at a 15px font.
constexpr float g_iconPadFrac = 0.18f;
// The 1.12 FontString text sanitizer DOUBLES any pipe that doesn't begin a
// recognized escape (so it renders as a literal `|`). Since 1.12 doesn't know
// `|T`, a caller's `|Tpath:h|t` arrives at the emitter as `||Tpath:h||t`
// (verified in-game: emitter text length +2, pipes doubled). So we accept both the
// doubled form (the real-world case) and a clean `|T` (should one ever reach us
// undoubled, e.g. after a future sanitizer hook). A literal user `||T…||t` is
// effectively nonexistent, and ordinary doubled pipes (`|| `) never match because
// the char after must be `T`.
// If an inline-texture escape starts at text[i], returns its opening-marker
// length: 3 for the doubled `||T`, 2 for a clean `|T`; 0 otherwise. The doubled
// form is checked first so the leading `|` of `||T` wins over reading the 2nd `|`
// as a clean `|T`.
int IconStartLen(const uint8_t *text, int len, int i) {
if (i + 2 < len && text[i] == '|' && text[i + 1] == '|' && text[i + 2] == 'T')
return 3;
if (i + 1 < len && text[i] == '|' && text[i + 1] == 'T')
return 2;
return 0;
}
// True if [text,text+len) contains any inline-texture escape.
bool HasInlineTexture(const uint8_t *text, int len) {
for (int i = 0; i + 1 < len; ++i)
if (IconStartLen(text, len, i))
return true;
return false;
}
// Finds the matching close (`||t` for the doubled form, `|t` for clean) at or
// after `from`. Returns its offset and sets *closeLen, or npos.
size_t FindIconClose(const uint8_t *text, int len, size_t from, bool doubled, int *closeLen) {
const size_t n = static_cast<size_t>(len);
if (doubled) {
for (size_t i = from; i + 2 < n; ++i)
if (text[i] == '|' && text[i + 1] == '|' && text[i + 2] == 't') {
*closeLen = 3;
return i;
}
} else {
for (size_t i = from; i + 1 < n; ++i)
if (text[i] == '|' && text[i + 1] == 't') {
*closeLen = 2;
return i;
}
}
return static_cast<size_t>(-1);
}
// Length of an inline-texture escape starting at `text[0]` (through its close
// marker), or 0 if none. Null-terminated variant for the tokenizer hook, which
// gets no length. Bounded so a malformed/unterminated span can't run away.
int InlineSpanLen(const uint8_t *text) {
int mlen;
if (text[0] == '|' && text[1] == '|' && text[2] == 'T')
mlen = 3; // doubled ||T
else if (text[0] == '|' && text[1] == 'T')
mlen = 2; // clean |T
else
return 0;
const bool doubled = (mlen == 3);
for (int i = mlen; i < 2048 && text[i] != '\0'; ++i) {
if (doubled) {
if (text[i] == '|' && text[i + 1] == '|' && text[i + 2] == 't')
return i + 3;
} else {
if (text[i] == '|' && text[i + 1] == 't')
return i + 2;
}
}
return 0; // unterminated → let the engine treat it as ordinary text
}
// THE per-icon pen advance the emitter reserves: icon width + full lead pad +
// half trail pad + any positive offsetX (a negative offsetX — deliberate
// leftward overlap — doesn't shrink the advance). Each pad is
// `fontHPen * g_iconPadFrac` — a font-relative gap, NOT icon-width-relative (see
// g_iconPadFrac). Single source shared by the emitter's icon loop, its
// centre/right-justify pre-shift, and the string-width co-hook — the three MUST
// agree or measured and rendered width drift. `fontHPen` resolves the retail `:0`
// auto-size (icon dims default to the line's font height); pen units in, out.
// The engine's outline-ink allowance for a gxu font face, in pen px (total,
// both sides). Outlined faces draw INK past the glyph advance metrics; the
// emitter itself grows the line height by exactly these constants
// (FUN_005CCBE0 prologue: face+0x180 bit 3 → 4.0, bit 0 → 2.0). An inline icon
// placed at the raw pen would sit inside that ink (the coin-into-outlined-
// digits clip), so the advance and the drawn lead each absorb half.
float OutlineInkPen(const void *fontFace) {
if (fontFace == nullptr)
return 0.0f;
const uint32_t flags = Game::Read<uint32_t>(fontFace, Offsets::OFF_FONTFACE_FLAGS);
if ((flags & 8u) != 0)
return Game::Read<float>(Offsets::FLOAT_OUTLINE_EXTRA_THICK);
if ((flags & 1u) != 0)
return Game::Read<float>(Offsets::FLOAT_OUTLINE_EXTRA_THIN);
return 0.0f;
}
// `outlineInk` is OutlineInkPen for the line's face — half of it leads, half
// trails, keeping the icon clear of outlined neighbours' ink on both sides.
// `snap` truncates the advance to whole pen px, mirroring the engine's own
// per-glyph __ftol (FUN_005CCBE0, node bit-7 clear). Native glyphs always sit
// on INTEGER pen pixels — rounded origin (FUN_005cdf70) + truncated advances —
// so a fractional icon advance shifted every glyph AFTER an icon onto
// half-pixel positions: bilinear-filtered, fuzzy/ghosted post-icon text, with
// visibility depending on the machine's filtering config (the "artifacting
// only on the text after the icon" report).
float IconAdvancePen(const IconDesc &d, float fontHPen, float outlineInk, bool snap) {
const float baseH = (d.height > 0.0f) ? d.height : fontHPen;
const float baseW = (d.width > 0.0f) ? d.width : baseH;
const float w = baseW * g_sizeScale;
const float offX = d.offsetX * g_sizeScale;
float adv = w + 1.5f * (fontHPen * g_iconPadFrac) + outlineInk + (offX > 0.0f ? offX : 0.0f);
if (snap)
adv = static_cast<float>(static_cast<int>(adv));
return adv;
}
// Sum of IconAdvancePen over every well-formed `|T…|t` (or sanitizer-doubled
// `||T…||t`) span in [text, text+len). Malformed/unterminated spans contribute
// nothing — mirroring the emitter, which renders them as plain text.
float SumIconAdvances(const uint8_t *text, int len, float fontHPen, float outlineInk, bool snap) {
float sum = 0.0f;
int i = 0;
while (i < len) {
const int ml = IconStartLen(text, len, i);
if (ml == 0) {
// Skip an escaped pipe as a pair so its 2nd `|` isn't re-read as a
// clean `|T` next iteration (same walk the emitter does).
if (text[i] == '|' && i + 1 < len && text[i + 1] == '|')
i += 2;
else
++i;
continue;
}
int cl = 0;
const size_t ce = FindIconClose(text, len, static_cast<size_t>(i) + ml, ml == 3, &cl);
if (ce == static_cast<size_t>(-1))
break; // unterminated → the rest is plain text
IconDesc d;
if (ParseIcon(reinterpret_cast<const char *>(text) + i + ml,
ce - (static_cast<size_t>(i) + ml), d))
sum += IconAdvancePen(d, fontHPen, outlineInk, snap);
i = static_cast<int>(ce) + cl;
}
return sum;
}
// The TRAIL half of the last icon's advance — 0.5×pad + 0.5×outline ink —
// when the measured text ENDS in a well-formed icon span, else 0. The engine's
// width convention stops at the last content's INK (the measure loop ends on
// the last glyph's ink width, not its advance); an icon's "ink" is its drawn
// art (lead + w), and the advance's trail exists purely to space a FOLLOWING
// glyph that a string-final icon doesn't have. The measure-REPORTING hooks
// (GetStringWidth + the substring measure) subtract this so a trailing icon —
// the money-string shape, "…|TCoin:0|t" — reports its true right edge; the
// render/justify/wrap paths keep the full advance (the pen genuinely moves).
// Strict end-at-len only: trailing spaces after the icon make the SPACE the
// last content, and the engine handles that itself.
float TrailingIconTrimPen(const uint8_t *text, int len, float fontHPen, float outlineInk) {
int i = 0;
bool endsInIcon = false;
while (i < len) {
const int ml = IconStartLen(text, len, i);
if (ml == 0) {
if (text[i] == '|' && i + 1 < len && text[i + 1] == '|')
i += 2;
else
++i;
endsInIcon = false;
continue;
}
int cl = 0;
const size_t ce = FindIconClose(text, len, static_cast<size_t>(i) + ml, ml == 3, &cl);
if (ce == static_cast<size_t>(-1))
return 0.0f; // unterminated → the tail renders as plain text
IconDesc d;
endsInIcon = ParseIcon(reinterpret_cast<const char *>(text) + i + ml,
ce - (static_cast<size_t>(i) + ml), d);
i = static_cast<int>(ce) + cl;
}
if (!endsInIcon)
return 0.0f;
return 0.5f * (fontHPen * g_iconPadFrac) + 0.5f * outlineInk;
}
// The tallest icon's VERTICAL OVERFLOW past the font height, in px, over every
// well-formed span in [text, text+len) — 0 when every icon fits the line.
// Only explicit `|T…:H|t` heights can overflow (the `:0` default IS the font
// height). Drives the line-height growth: the string-height co-hook adds this
// so a tall icon's line takes real vertical room (the 4.3.4 model, whose wrap
// and measure loops export a max-texture-height for exactly this), and the
// draw bottom-aligns tall icons so the overflow points INTO the grown space.
float MaxIconOverflowPx(const uint8_t *text, int len, float fontHPx) {
float maxH = 0.0f;
int i = 0;
while (i < len) {
const int ml = IconStartLen(text, len, i);
if (ml == 0) {
if (text[i] == '|' && i + 1 < len && text[i + 1] == '|')
i += 2;
else
++i;
continue;
}
int cl = 0;
const size_t ce = FindIconClose(text, len, static_cast<size_t>(i) + ml, ml == 3, &cl);
if (ce == static_cast<size_t>(-1))
break;
IconDesc d;
if (ParseIcon(reinterpret_cast<const char *>(text) + i + ml,
ce - (static_cast<size_t>(i) + ml), d)) {
const float h = ((d.height > 0.0f) ? d.height : fontHPx) * g_sizeScale;
if (h > maxH)
maxH = h;
}
i = static_cast<int>(ce) + cl;
}
return (maxH > fontHPx) ? (maxH - fontHPx) : 0.0f;
}
// --- tokenizer co-hook (measure/wrap correction) ---------------------------
// FUN_005c2810 — the shared `|`-escape tokenizer. Returns a token-type code,
// writes bytes-consumed to *bytesConsumed and the payload char to *payloadOut.
using Tokenizer_t = uint32_t(__fastcall *)(uint8_t *text, int *bytesConsumed, uint32_t *colorOut,
uint32_t flags, uint32_t *payloadOut);
Tokenizer_t g_tokenizerOriginal = nullptr;
uint32_t __fastcall Tokenizer_h(uint8_t *text, int *bytesConsumed, uint32_t *colorOut,
uint32_t flags, uint32_t *payloadOut) {
// Intervene at a pipe when enabled, not manually suppressed, flags bit 0x40
// (the non-space-wrap editbox proxy — see NodeEditable) clear, and the text is
// NOT the focused editbox's own text.
//
// Bit 0x40 (non-space-wrap; set on the macro editor 0x4D) is our proxy for
// "leave `|T` as literal" — it keeps the macro editor's measure literal so its
// caret stays aligned with the raw glyphs the emitter draws for it. Chat
// DISPLAY has it clear (flags 0x205, ICON-NODE probe), so this does NOT touch
// it — its icons still measure ~zero. Single-line inputs (chat / name box)
// lack bit 6 and are handled by the two focused-input signals:
// • MEASURE (caret/GetStringWidth loops this tokenizer over the input buffer
// in place) → TextInFocusedEditbox: `text` points into that buffer.
// • Re-entrant RENDER (the suppressed emitter delegates the raw line to the
// original, which re-enters here) → TextInReentry: `text` is inside the
// line span the emitter bracketed. Without it the span is eaten as a
// zero-width token and the editbox draws BLANK.
if (text != nullptr && text[0] == '|' &&
InlineInterceptActive(text, (flags & 0x40u) != 0)) {
const int span = InlineSpanLen(text);
if (span > 0) {
// Consume the whole escape as one glyph-type token with a payload that
// resolves to no glyph — so every measure/wrap caller advances past the
// path text and counts it as near-zero width instead of ~40 literal
// characters. The emitter detects icons itself and is unaffected (it
// only ever delegates plain, `|T`-free segments).
if (bytesConsumed)
*bytesConsumed = span;
if (payloadOut)
*payloadOut = 0;
return 6; // ordinary-glyph token type
}
}
return g_tokenizerOriginal(text, bytesConsumed, colorOut, flags, payloadOut);
}
static const Game::HookAutoRegister _tokenizerHook{Offsets::FUN_TEXT_TOKENIZER,
reinterpret_cast<void *>(&Tokenizer_h),
reinterpret_cast<void **>(&g_tokenizerOriginal)};
// --- string-width co-hook (measure-width icon fix) --------------------------
//
// FUN_FONTSTRING_STRING_WIDTH = CSimpleFontString::GetStringWidthInternal (see
// Offsets.h for the full derivation). The tokenizer hook above makes every
// measure path count a `|T` span as ~zero while the emitter reserves the real
// advance — so GetStringWidth, the GameTooltip auto-size, and auto-width layout
// undercount by the icons' widths. Fix at the single fs-level choke: call the
// original, then, when the tokenizer WOULD have eaten the spans (the shared
// InlineInterceptActive predicate) and the fs isn't an editbox, add the same
// per-icon advances the emitter reserves (the shared IconAdvancePen math).
//
// UNITS: the icon sum is computed in true PEN px — the exact numbers the
// emitter reserves (outline ink, snap truncation and all; see ResolveFsPenFont)
// — and bridged into the getter's return space via the font height known in
// both spaces: `sumPen × (fontHInt / fontHPen)`. Do NOT divide by fs+0x7C —
// that's the layout UI SCALE (~0.68-1.0), and the original's own
// `out / fs+0x7C` applies to a value FUN_0044D670 already ran through the
// gxu→internal converter, not to raw pixels (a /0x7C here inflated a 16px
// icon to +44k px on first flight). An earlier bridge used the SetPoint push
// factor (div×1024/mul) as the intermediate — the units cancelled the same
// way for `:0` icons, but it couldn't express the pen-integral truncation or
// the outline ink, leaving ≤1px + 2-4px per-icon divergences from the render.
//
// Idempotence: the original may serve the cached fs+0xFC — the icon sum is
// re-derived and re-added on EVERY call, and the cache is NEVER written.
//
// Residuals (documented in docs/InlineTextureEscapes.md): the measure loop ends
// on the last glyph's INK width rather than its advance, so a trailing icon
// leaves a ~≤1px artifact; the hyperlink hit-test past a tall icon OUTSIDE the
// link stays icon-blind. (Wrap breaks route through the stepper hook, and the
// substring measure has its own co-hook below.) The focused chat editbox's own
// display fontstring (editable bit CLEAR — only multi-line editors carry it)
// can reach this through the caret positioner's line-boundary branch with a
// content-suppressed raw render; that branch is multi-line-only in practice,
// so no content compare is spent here.
using StringWidthInternal_t = float(__fastcall *)(void *fs);
// FUN_FONTSTRING_FONT_HEIGHT is __thiscall(fs, mode-on-stack) — dummy-EDX
// __fastcall matches the register/stack layout (established pattern).
using FsFontHeight_t = float(__fastcall *)(void *fs, void *edx, int mode);
StringWidthInternal_t g_stringWidthOriginal = nullptr;
// The RENDER's exact font numbers for an fs, for the measure hooks. fontHInt
// is the fs height-getter's raw value — the same unit space the hooked
// internal getters RETURN (they divide their measure by fs+0x7C exactly as the
// rebuild multiplied it in), so `penValue × (fontHInt / fontHPen)` converts
// any pen-px quantity into the hook's return space with the font height as
// the exact unit bridge. fontHPen replicates the node's pen font height:
// nodeFontSize = internal × fs+0x7C ÷ [0x832A48] (the rebuild FUN_007724a0
// feeds internal × 0x7C to FUN_0044d420, which normalizes it via
// FUN_0041ae50), floored at [0x801628]/rasterY = 2 pen px (the node ctor,
// FUN_005cd6d0), realized by the emitter's own helper (flag 0 = the snap
// path: round(size × rasterY)).
// fs-built nodes are always in snap mode — none of the fs-flag → node-flag
// mappings in FUN_0044d420 produce bit 7 — so snap-truncated advances are
// exact here. The face for the outline ink resolves via
// [[fs+0xE0]+0x20] → node+0x44 (see OFF_FONTSTRING_FONT_HANDLE).
struct FsPenFont {
float fontHInt = 0.0f; // getter units — the hook-return space
float fontHPen = 0.0f; // render pen px (rounded + min-clamped like the node)
float outlineInk = 0.0f;
};
bool ResolveFsPenFont(void *fs, FsPenFont &out) {
out.fontHInt =
reinterpret_cast<FsFontHeight_t>(Offsets::FUN_FONTSTRING_FONT_HEIGHT)(fs, nullptr, 1);
const float scale = Game::Read<float>(fs, Offsets::OFF_LAYOUT_SCALE);
const float anchorH = Game::Read<float>(Offsets::VAR_UI_ANCHOR_SCREEN_H);
const int rasterY = Game::Read<int>(Offsets::VAR_TEXT_RASTER_Y);
if (!(out.fontHInt > 0.0f) || !(scale > 0.0f) || !(anchorH > 0.0f) || rasterY <= 0)
return false;
// The ÷anchorH hop is LOAD-BEARING: the block creator FUN_0044d420 runs
// the rebuild's fontH (internal × fs+0x7C) through the y-axis
// anchor→normalized converter FUN_0041ae50 (÷[0x832A48]) before it
// reaches node+0x18. Skipping it undersized fontHPen ~40%: phantom
// tall-icon overflow grew every icon-bearing chat line, and the
// fontHInt/fontHPen bridge inflated fixed-size/ink width terms (the
// money-tooltip right-anchor gap).
float sizeNorm = out.fontHInt * scale / anchorH;
const float minNorm = Game::Read<float>(Offsets::FLOAT_OUTLINE_EXTRA_THIN) /
static_cast<float>(rasterY);
if (sizeNorm < minNorm)
sizeNorm = minNorm;
out.fontHPen = reinterpret_cast<float(__fastcall *)(int, float)>(
Offsets::FUN_TEXT_FONT_HEIGHT)(0, sizeNorm);
if (!(out.fontHPen > 0.0f))
return false;
const void *handle = Game::Read<const void *>(fs, Offsets::OFF_FONTSTRING_FONT_HANDLE);
const void *face =
LooksReadable(handle) ? Game::Read<const void *>(handle, Offsets::OFF_FONT_HANDLE_FACE)
: nullptr;
out.outlineInk = LooksReadable(face) ? OutlineInkPen(face) : 0.0f;
return true;
}
float __fastcall StringWidth_h(void *fs) {
const float base = g_stringWidthOriginal(fs);
if (!LooksReadable(fs))
return base;
// fs+0x120 bit 0x1000 (non-space-wrap; → gxu bit 0x40) as an editbox proxy —
// see NodeEditable. A multi-line editor measures the raw markup it renders, so
// its width must stay unadjusted (caret alignment).
const bool editable =
(Game::Read<uint32_t>(fs, Offsets::OFF_FONTSTRING_MEASURE_FLAGS) & 0x1000u) != 0;
const uint8_t *text = Game::Read<const uint8_t *>(fs, Offsets::OFF_FONTSTRING_TEXT);
if (!LooksReadable(text) || !InlineInterceptActive(text, editable))
return base;
int len = 0;
while (len < 0x4000 && text[len] != '\0')
++len;
if (!HasInlineTexture(text, len))
return base;
FsPenFont pf;
if (!ResolveFsPenFont(fs, pf))
return base;
// The icon advances EXACTLY as the emitter reserves them — pen px, outline
// ink included, snap-truncated — bridged into the return space by the
// font height known in both spaces. A string-final icon reports its ink
// edge rather than its full advance (see TrailingIconTrimPen).
float sumPen = SumIconAdvances(text, len, pf.fontHPen, pf.outlineInk, true) -
TrailingIconTrimPen(text, len, pf.fontHPen, pf.outlineInk);
if (sumPen < 0.0f)
sumPen = 0.0f;
return base + sumPen * (pf.fontHInt / pf.fontHPen);
}
static const Game::HookAutoRegister _stringWidthHook{
Offsets::FUN_FONTSTRING_STRING_WIDTH, reinterpret_cast<void *>(&StringWidth_h),
reinterpret_cast<void **>(&g_stringWidthOriginal)};
// --- string-height co-hook (line growth for tall icons) ---------------------
//
// FUN_FONTSTRING_STRING_HEIGHT = GetStringHeightInternal (cache fs+0x100,
// lines×fontH + (lines−1)×spacing — icon-blind). Its three binary consumers
// (verified via xrefs):
// • FUN_00772A60 — the anchor's effective-height vmethod: explicit height if
// set, else THIS. Chat line fontstrings get only a SetWidth from the
// ScrollingMessageFrame refresh, so their rect height resolves here.
// • FUN_00788750 — the SMF display refresh: budgets vertical space per line
// with THIS and anchors each line to the previous line's rect.
// • FUN_0077D4D0 — the multi-line EDITBOX self-size (excluded below; editbox
// text renders raw markup, so its height must stay literal).
// Adding the tallest icon's overflow past the font height therefore grows the
// line's rect AND the chat stack's spacing in one place — a 24px emote in 15px
// chat takes real vertical room instead of overlapping its neighbours. This is
// the 4.3.4 model: its measure/wrap loops export a max-texture-height out-param
// and the layout grows the line (FUN_00618b90/FUN_00618e40 case 7).
//
// The draw side bottom-aligns tall icons (see FlushLayout's cy), so the
// overflow points UP — into the grown space — which matches the SMF's
// bottom-anchored stacking (older lines shift up by the growth).
//
// Same rules as the width hook: NEVER write the fs+0x100 cache (the delta is
// re-derived and re-added per call — idempotent); overflow computed in pen px
// and bridged by the font-height ratio, never /fs+0x7C. v1 limit (documented):
// the delta is the WHOLE text's max overflow, exact when the tall icons sit on
// one wrapped line (the emote case); two tall icons on different wrapped lines
// of one message under-grow.
using StringHeightInternal_t = float(__fastcall *)(void *fs);
StringHeightInternal_t g_stringHeightOriginal = nullptr;
float __fastcall StringHeight_h(void *fs) {
const float base = g_stringHeightOriginal(fs);
if (!LooksReadable(fs))
return base;
const bool editable =
(Game::Read<uint32_t>(fs, Offsets::OFF_FONTSTRING_MEASURE_FLAGS) & 0x1000u) != 0;
const uint8_t *text = Game::Read<const uint8_t *>(fs, Offsets::OFF_FONTSTRING_TEXT);
if (!LooksReadable(text) || !InlineInterceptActive(text, editable))
return base;
int len = 0;
while (len < 0x4000 && text[len] != '\0')
++len;
if (!HasInlineTexture(text, len))
return base;
FsPenFont pf;
if (!ResolveFsPenFont(fs, pf))
return base;
// Overflow computed in pen px — the space the render compares icon height
// against the font in — then bridged like the width hook.
return base + MaxIconOverflowPx(text, len, pf.fontHPen) * (pf.fontHInt / pf.fontHPen);
}
static const Game::HookAutoRegister _stringHeightHook{
Offsets::FUN_FONTSTRING_STRING_HEIGHT, reinterpret_cast<void *>(&StringHeight_h),
reinterpret_cast<void **>(&g_stringHeightOriginal)};
// --- substring-measure co-hook (wrapped tooltip segments count icons) --------
//
// FUN_FONTSTRING_MEASURE_SUBSTRING measures an ARBITRARY string in the fs's
// font (len 0 = strlen; same measure-core + out/fs+0x7C shape as the width
// getter, no cache). Its icon-relevant consumer is the GameTooltip auto-size
// (FUN_00530640): when a wrap-enabled line's icon-inclusive width exceeds the
// tooltip, it computes the break positions (FUN_00772B60 — icon-aware through
// the wrap-stepper hook) and measures EACH WRAPPED SEGMENT through this,
// taking the max as the tooltip width — so an icon-bearing segment undersized
// its tooltip. Same adjustment as the width hook: the segment's icon advances
// in true pen px, bridged by the font-height ratio. The editbox
// caret/selection callers (FUN_0077DA80 / FUN_0077DE70 / FUN_0077D0D0) must
// keep measuring raw markup: the multi-line editors carry the fs editable bit,
// and the focused single-line inputs measure their input buffer IN PLACE,
// which InlineInterceptActive's focused-buffer pointer test stands down on —
// the same predicate stack as everywhere else. A segment can never start or
// end inside a |T span (the tokenizer hook makes breaks treat a span as one
// token); an unterminated span from a hostile caller falls out as plain text
// (FindIconClose miss), contributing nothing.
using MeasureSubstring_t = float(__fastcall *)(void *fs, void *edx, const uint8_t *text, int len);
MeasureSubstring_t g_measureSubstringOriginal = nullptr;
float __fastcall MeasureSubstring_h(void *fs, void *edx, const uint8_t *text, int len) {
const float base = g_measureSubstringOriginal(fs, edx, text, len);
if (!LooksReadable(fs) || !LooksReadable(text))
return base;
const bool editable =
(Game::Read<uint32_t>(fs, Offsets::OFF_FONTSTRING_MEASURE_FLAGS) & 0x1000u) != 0;
if (!InlineInterceptActive(text, editable))
return base;
int n = len;
if (n <= 0) { // len 0 = whole string, mirroring the original's strlen
n = 0;
while (n < 0x4000 && text[n] != '\0')
++n;
} else if (n > 0x4000) {
n = 0x4000;
}
if (!HasInlineTexture(text, n))
return base;
FsPenFont pf;
if (!ResolveFsPenFont(fs, pf))
return base;
// Segment-final icons report their ink edge, like the width hook.
float sumPen = SumIconAdvances(text, n, pf.fontHPen, pf.outlineInk, true) -
TrailingIconTrimPen(text, n, pf.fontHPen, pf.outlineInk);
if (sumPen < 0.0f)
sumPen = 0.0f;
return base + sumPen * (pf.fontHInt / pf.fontHPen);
}
static const Game::HookAutoRegister _measureSubstringHook{
Offsets::FUN_FONTSTRING_MEASURE_SUBSTRING, reinterpret_cast<void *>(&MeasureSubstring_h),
reinterpret_cast<void **>(&g_measureSubstringOriginal)};
// --- hyperlink-rect co-hook (tall icons stay hoverable) ----------------------
//
// FUN_TEXT_LINK_RECT_ADD appends a link's hit rect with the TEXT band's y
// extent (fontH tall — see Offsets.h). A tall inline icon inside a link
// (|H…|h with a |T…:32|t emote) centres on the line and pokes past that band,
// so only its text-high middle slice was hoverable.
//
// The consumer geometry (FUN_00788c00 → FUN_007a3350 + two in-game
// experiments): the SMF rebuilds a HIT REGION per record, anchored to the
// LINE FONTSTRING's TOP-LEFT with the record's y band as offset/height. The
// band's y values are Y-UP (numeric max = screen top; extending the max
// visibly grew the region into the line ABOVE), and the band is TEXT-relative
// — but the string-height co-hook grew the fs rect by the icon overflow and
// the text centres in the grown rect, so the anchor (rect top) already sits
// HALF the overflow above the text top, pre-shifting the whole hit band up.
// Net: the unmodified band covers [iconTop .. textBottom − half] — the
// original "only the top half hovers" symptom — and the ONLY correction
// needed is pushing the screen BOTTOM down by the FULL overflow, i.e.
// DECREASING the numeric MIN edge. (Symmetric ± expansion moved the bottom
// edge UP — bottom third dead; extending the max grew the top into the line
// above — both verified in-game and both consistent with this model.)
// escStart/escLen span the whole |H…|h escape, so any |T inside is found; the
// InlineInterceptActive gate keeps raw-rendered contexts (suppressed editbox
// delegation) literal. Known v1 rough edge: a tall icon OUTSIDE the link but
// on the same line grows the line without this hook firing for the link,
// leaving that link's band half-overflow high.
using LinkRectAdd_t = void(__fastcall *)(void *node, void *edx, float yA, float xLeft, float yB,
float xRight, const char *linkStart, uint32_t linkLen,
const uint8_t *escStart, uint32_t escLen);
LinkRectAdd_t g_linkRectAddOriginal = nullptr;
void __fastcall LinkRectAdd_h(void *node, void *edx, float yA, float xLeft, float yB, float xRight,
const char *linkStart, uint32_t linkLen, const uint8_t *escStart,
uint32_t escLen) {
if (g_inlineEnabled && LooksReadable(node) && LooksReadable(escStart) && escLen > 0 &&
escLen < 0x4000 && InlineInterceptActive(escStart, false)) {
const int len = static_cast<int>(escLen);
if (HasInlineTexture(escStart, len)) {
// The line's font pixel height, the emitter's own recipe (ecx =
// (nodeFlags>>7)&1, stack = node fontSize).
const uint32_t nodeFlags = Game::Read<uint32_t>(node, Offsets::OFF_TEXT_NODE_FLAGS);
const float fontSize = Game::Read<float>(node, Offsets::OFF_TEXT_NODE_FONT_SIZE);
const float fontH = reinterpret_cast<float(__fastcall *)(int, float)>(
Offsets::FUN_TEXT_FONT_HEIGHT)(static_cast<int>((nodeFlags >> 7) & 1u), fontSize);
const float overflow = MaxIconOverflowPx(escStart, len, fontH);
if (overflow > 0.0f) {
// Push the SCREEN BOTTOM down: decrease the numeric MIN edge
// (y-up band) — see the anchor-shift analysis above.
if (yA <= yB)
yA -= overflow;
else
yB -= overflow;
}
}
}
g_linkRectAddOriginal(node, edx, yA, xLeft, yB, xRight, linkStart, linkLen, escStart, escLen);
}
static const Game::HookAutoRegister _linkRectAddHook{Offsets::FUN_TEXT_LINK_RECT_ADD,
reinterpret_cast<void *>(&LinkRectAdd_h),
reinterpret_cast<void **>(&g_linkRectAddOriginal)};
// --- wrap-stepper co-hook (icon-aware line breaks) ---------------------------
//
// FUN_TEXT_WRAP_STEPPER lays out one wrapped line per call (see Offsets.h).
// It measures the line through the tokenizer, which eats each `|T` span as
// ~zero width — so the break lands as if the icons weren't there, and the
// emitter's real advances then overflow the right edge (chat lines with
// prefix icons ran past the frame). Fix: shrink the wrapWidth argument by the
// advances of the icons in the remaining text (same IconAdvancePen math the
// emitter reserves), then call the original. `text` always points at the
// REMAINING text, so a continuation line whose icons are already behind it
// gets sum = 0 — chat prefix icons come out exact. An icon that would land on
// a LATER wrapped line still shrinks the earlier calls' width, wrapping those
// lines slightly early — the safe direction (never overflow), cosmetic only.
//
// Because all four wrap consumers route through this one dispatcher, the
// render's breaks, GetStringHeight's line count, ellipsis truncation, and the
// break arrays all shift together — no cross-consumer drift.
//
// UNITS (bit us TWICE): the stepper's inputs are gxu-NORMALIZED, per axis —
// fontH is normalized-Y (× rasterY = pen px, FUN_005c6fa0) and
// wrapWidth/outWidth are normalized-X (× rasterX = pen px). Both caller
// flavors land there: the draw builder passes node+0x1C/+0x3C (normalized at
// block creation via FUN_0041ae50/ae40), and the fs-level callers (height,
// fit, break arrays — the path CHAT wraps through) x-normalize their widths
// inside FUN_0044d670 before gxu. First flight subtracted raw pixels and
// annihilated the small normalized widths (2-glyph chat fragments); the
// second used fontH/fontHPx — a Y-axis factor on an X-axis width —
// overshrinking by the render aspect (×2.37 at 2560×1080; early-wrapped
// icon chat lines). The correct pen-x → width-units factor is 1/rasterX.
using WrapStepper_t = void(__fastcall *)(void *font, uint8_t *text, float fontH,
float wrapWidth, int *outBreak, float *outWidth,
void *outNext, float indent, uint32_t flags,
uint8_t *p10);
WrapStepper_t g_wrapStepperOriginal = nullptr;
void __fastcall WrapStepper_h(void *font, uint8_t *text, float fontH, float wrapWidth,
int *outBreak, float *outWidth, void *outNext, float indent,
uint32_t flags, uint8_t *p10) {
// flags bit 0x80 routes to the no-wrap path (wrapWidth unused); bit 0x40 is
// the editbox bit the tokenizer stands down on — same predicate, same gate.
// Positive fontH/wrapWidth gates mirror the original's own validation: a
// shrink below zero would trip its bail-to-zero-outputs path and blank the
// line, so the floor keeps a couple of glyphs' worth of progress instead.
if ((flags & 0x80u) == 0 && wrapWidth > 0.0f && fontH > 0.0f &&
InlineInterceptActive(text, (flags & 0x40u) != 0)) {
int len = 0;
while (len < 2048 && text[len] != '\0' && text[len] != '\n')
++len;
if (HasInlineTexture(text, len)) {
// Pixel realization of this caller's fontH — the same helper the
// measure loops and the emitter use.
const int fontFlag = static_cast<int>((flags >> 7) & 1u);
const float fontHPx = reinterpret_cast<float(__fastcall *)(int, float)>(
Offsets::FUN_TEXT_FONT_HEIGHT)(fontFlag, fontH);
const int rasterX = Game::Read<int>(Offsets::VAR_TEXT_RASTER_X);
if (fontHPx > 0.0f && rasterX > 0) {
// Pen-x px → the stepper's WIDTH units. fontH is normalized-Y
// (× rasterY = pen) but wrapWidth/outWidth are normalized-X
// (× rasterX = pen) — both caller flavors: the draw builder
// passes node+0x3C (x-normalized at block creation via
// FUN_0041ae40) and the fs-level callers x-normalize their
// widths through FUN_0044d670's own FUN_0041ae40 call before
// gxu. The first version scaled by fontH/fontHPx — a Y-AXIS
// conversion applied to an x-axis width — overshrinking the
// budget by rasterX/rasterY (the render aspect: ×1.33 at 4:3,
// ×2.37 at 2560×1080), so icon-bearing chat lines wrapped a
// word or two early, worse per icon and per aspect. The
// glyph-relative tolerances below use fontHPx × this factor so
// "half a glyph" means an actual glyph width in width units.
const float penToUnits = 1.0f / static_cast<float>(rasterX);
const float glyphU = fontHPx * penToUnits; // one glyph width, width units
const float minWidth = glyphU * 2.0f;
// Only icons that actually LAND on this line may shrink its
// width — subtracting every icon in the remaining text made an
// 8-icon line wrap absurdly early (one word per line). The
// break position depends on the shrink and vice versa, so run
// the stepper as a sandboxed probe (local out-params + a COPY
// of the p10 in/out state byte, so the caller's first-line
// state isn't consumed) and iterate to a fixed point: probe
// unshrunk first (latest possible break = upper bound on the
// line's icons), re-count icons before the resulting break,
// re-probe with that shrink. Converges when the icon set
// stabilizes (exact); on boundary oscillation take the larger
// of the last two sums (wraps at most one icon early — the
// safe direction).
//
// The probe width MUST be floored like the final width: a ≤0
// probe width trips the stepper's bail-to-zero-outputs path,
// the zeroed break falls back to lineLen = len, the sum snaps
// back to EVERY icon, and the loop "converges" on the naive
// whole-text shrink (the v2 bug — multi-icon lines still
// shredded while single-icon lines worked).
// Minimal-feasible-shrink search. The stepper reports the
// line's measured (icons-at-zero) width in outWidth, so each
// probe yields a direct feasibility check: the line renders
// inside the frame iff its icons fit in the shrink plus the
// spare the break left — iconSum ≤ s + (probeW − outWidth).
// Search for the SMALLEST feasible s (fullest lines):
// • s = 0 feasible → no shrink at all (line fits its icons
// in the natural slack — the common short-message case).
// • infeasible with no feasible found yet → escalate to the
// measured deficit (iconSum − slack; strictly increasing).
// • once a [lo = infeasible, best = feasible] bracket
// exists → bisect it, feasible probes lowering best,
// infeasible ones raising lo, until the bracket is under
// a quarter-glyph. Giving up on the first stale
// escalation candidate (an earlier version) left the
// bracket unsearched and cost the Marks line a whole
// mark+word pair per line.
// Bounded at 8 probes; if none lands feasible, the last
// escalation target is used (an upper-bound shrink — wraps
// early rather than overflowing).
float lo = -1.0f; // largest known-infeasible s
float best = -1.0f; // smallest known-feasible s
float s = 0.0f;
for (int pass = 0; pass < 8; ++pass) {
int pBreak = 0;
float pWidth = 0.0f;
uint8_t *pNext = nullptr;
uint8_t p10copy = (p10 != nullptr) ? *p10 : 0;
float probeW = wrapWidth - s;
if (probeW < minWidth)
probeW = minWidth;
g_wrapStepperOriginal(font, text, fontH, probeW, &pBreak, &pWidth,
static_cast<void *>(&pNext), indent, flags,
(p10 != nullptr) ? &p10copy : nullptr);
// outBreak and outNext−text are both BYTE counts (verified
// during bring-up); outBreak == len means the whole text fit.
int lineLen = len;
if (pBreak > 0 && pBreak < len)
lineLen = pBreak;
else if (pNext > text && pNext - text < len)
lineLen = static_cast<int>(pNext - text);
const float sumPx = SumIconAdvances(text, lineLen, fontHPx,
OutlineInkPen(font),
(flags & 0x80u) == 0);
const float iconUnits = sumPx * penToUnits;
float slack = probeW - pWidth;
if (slack < 0.0f)
slack = 0.0f;
// Half-glyph feasibility tolerance. An AUTO-WIDTH
// fontstring's wrap width IS the icon-inclusive string
// width (the GetStringWidth hook feeds the effective-width
// vmethod), so its line fits with EXACTLY zero slack —
// iconSum == slack up to cross-computation drift (the two
// hooks derive the icon sum through different unit chains,
// plus OUTLINE extras and the trailing-glyph ink/advance
// quirk). Without tolerance that drift reads "fits
// exactly" as "1px over", shrinks a zero-slack line, and
// forces a wrap that had no business existing (the
// TwitchEmotes addon-list label). Genuine icon overflow is
// >= a full icon (~1.27 fontH), so half a glyph separates
// drift from real overflow; the cost is that a real
// overflow may render up to half a glyph past the edge.
if (iconUnits <= s + slack + glyphU * 0.5f) {
if (best < 0.0f || s < best)
best = s;
if (s <= 0.0f)
break; // can't beat zero shrink
} else {
if (s > lo)
lo = s;
if (best < 0.0f) {
// No feasible found yet — escalate by the
// measured deficit (strictly increasing).
float next = iconUnits - slack;
if (next <= s)
next = s + glyphU;
s = next;
continue;
}
}
// A [lo, best] bracket exists — bisect until it's tighter
// than a quarter-glyph.
if (lo < 0.0f || best < 0.0f)
break; // feasible with no infeasible below → best = s
if (best - lo < glyphU * 0.25f)
break;
s = (lo + best) * 0.5f;
}
const float finalShrink = (best >= 0.0f) ? best : s;
float shrunk = wrapWidth - finalShrink;
if (shrunk < minWidth)
shrunk = minWidth;
wrapWidth = shrunk;
}
}
}
g_wrapStepperOriginal(font, text, fontH, wrapWidth, outBreak, outWidth, outNext, indent,
flags, p10);
}
static const Game::HookAutoRegister _wrapStepperHook{
Offsets::FUN_TEXT_WRAP_STEPPER, reinterpret_cast<void *>(&WrapStepper_h),
reinterpret_cast<void **>(&g_wrapStepperOriginal)};
// --- emitter co-hook (positioning) -----------------------------------------
// FUN_005ccbe0 — the per-line glyph emitter. __thiscall(node, text, len,
// colorState, penXYZ, pageMask, linkState); declared __fastcall with a dummy edx
// (the established pattern for co-hooking __thiscall engine methods).
using Emitter_t = void(__fastcall *)(void *node, void *edx, uint8_t *text, int len,
uint32_t *colorState, float *penXYZ, uint32_t *pageMask,
int *linkState);
Emitter_t g_emitterOriginal = nullptr;
void __fastcall Emitter_h(void *node, void *edx, uint8_t *text, int len, uint32_t *colorState,
float *penXYZ, uint32_t *pageMask, int *linkState) {
// Fast path — feature off: delegate verbatim, touching nothing.
if (!g_inlineEnabled) {
g_emitterOriginal(node, edx, text, len, colorState, penXYZ, pageMask, linkState);
return;
}
// Suppressed — render the node's text verbatim and drop any icons previously
// recorded for it, so editable input shows raw markup. Covers the FOCUSED
// editbox's own text (this render line's CONTENT equals the editbox input —
// the editbox renders through a transient copy with no pointer link), the
// pointer path (`text` inside the input buffer, rare), and any editable node
// (flags bit 6, e.g. the un-focused macro editor). Per-editbox, not global: a
// chat-history line's content differs, so its icons keep rendering while an
// editbox is focused.
const bool sup_ptr = TextInFocusedEditbox(text);
const bool sup_content = !sup_ptr && EmitLineIsFocusedEditbox(text);
// Suppress on the non-space-wrap proxy bit (bit 6; see NodeEditable). Verified
// via the ICON-NODE probe: chat DISPLAY has it clear (flags 0x205), the macro
// editor has it set (0x4D), the focused chat edit box has it clear but is
// content-matched (0x20D). So the bit cleanly catches the un-focused macro
// editor (which content-match can't, since it's not the focused editbox)
// without touching chat display. Content-match still handles the focused chat/
// name box. (Caveat: a display fs with SetNonSpaceWrap(true) would also match
// here — rare, never observed; see NodeEditable.)
const bool sup_editable = node != nullptr && NodeEditable(node);
if (sup_ptr || sup_content || sup_editable) {
if (node != nullptr)
g_nodeIcons.erase(node);
// 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;
g_reentryHi = (text != nullptr && len > 0) ? text + len : text;
g_emitterOriginal(node, edx, text, len, colorState, penXYZ, pageMask, linkState);
g_reentryLo = nullptr;
g_reentryHi = nullptr;
return;
}
if (node == nullptr || text == nullptr || len <= 0) {
g_emitterOriginal(node, edx, text, len, colorState, penXYZ, pageMask, linkState);
return;
}
// 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 =
(node == g_buildNode)
? (g_buildEmitSeq++ == 0)
: (text == Game::Read<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. 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];
// Stamp the base colour this bake is built from — the flush re-bakes the
// node if the live node colour's RGB later diverges (see g_nodeBakedColor).
g_nodeBakedColor[node] = Game::Read<uint32_t>(node, Offsets::OFF_TEXT_NODE_COLOR);
// 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
// batches, so segmenting would lose all but the last run. Handle it by doing
// the batch-clear ONCE per build (a len-0 original call with bit 3 still set,
// only on the first wrapped line), then clearing bit 3 so our per-segment calls
// APPEND. Restore the flags before returning.
uint32_t *const flagsPtr = Game::Ptr<uint32_t>(node, Offsets::OFF_TEXT_NODE_FLAGS);
const uint32_t savedFlags = *flagsPtr;
const bool batchClearMode = (savedFlags & 8u) != 0;
if (batchClearMode) {
if (firstLine)
g_emitterOriginal(node, edx, text, 0, colorState, penXYZ, pageMask, linkState);
*flagsPtr = savedFlags & ~8u;
// Clearing bit 3 switches the engine from the FontString's UNIFORM colour
// (node+0x2c, consulted live at every paint) to PER-GLYPH colour baked
// from colorState — a copy of [node+0x2c] the draw builder took at build
// start. Its alpha byte is the FOLDED live opacity (SetTextColor alpha ×
// frame-chain effective alpha; the engine re-folds it into fs colors[0]
// and node+0x2c on every alpha change — FUN_0077fac0), so it is correct
// AT BUILD TIME and needs no fixup here. But baked per-glyph alphas
// never refresh: the colour-change path (FUN_005ccb40) skips the
// rebuild-invalidate for bit-3 nodes, so a later fade (parent SetAlpha)
// updates +0x2c while the glyphs keep their build-time alpha. The
// flush's baked-alpha mirror (FlushLayout) re-syncs them per frame.
// (An earlier build forced alpha 0xFF here — a misread of a pfUI bubble
// that BUILT while faded out: 0x07 was the live folded alpha, not a
// stale default. The force inverted the bug: segmented text stuck fully
// opaque while its icons and iconless siblings faded.)
}
// Font pixel height of this line — used to centre icons vertically (penY sits
// near the text top). Mirrors the emitter's own call: ecx = (nodeFlags>>7)&1,
// stack = the node's font size [node+0x1c].
const int fontFlag = static_cast<int>((savedFlags >> 7) & 1u);
const float fontSize = Game::Read<float>(node, Offsets::OFF_TEXT_NODE_FONT_SIZE);
const float fontH = reinterpret_cast<float(__fastcall *)(int, float)>(
Offsets::FUN_TEXT_FONT_HEIGHT)(fontFlag, fontSize);
// Font face + native→pen advance scale, mirroring the emitter's own
// `local_10 = fontH / (float)nativeHeight`. Used by drawRun's terminal-
// advance correction (glyph-record advances are native-font units).
void *const fontFace = Game::Read<void *>(node, Offsets::OFF_TEXT_NODE_FONT_FACE);
using FontNativeHeight_t = int(__fastcall *)(void *font);
const int fontNativeH =
(fontFace != nullptr)
? reinterpret_cast<FontNativeHeight_t>(Offsets::FUN_TEXT_FONT_NATIVE_HEIGHT)(fontFace)
: 0;
const float penScale = (fontNativeH > 0) ? fontH / static_cast<float>(fontNativeH) : 0.0f;
// The original emitter never writes penXYZ[0]; we mutate it to thread the pen
// across segments, so snapshot and restore it. The draw builder does NOT reset
// penXYZ[0] between left-justified lines, so leaving it mutated would cascade-
// shift every following line.
const float startX = penXYZ[0];
const float penY = penXYZ[1];
float penX = penXYZ[0];
// Centre/right-justify correction. The engine positioned the pen (penXYZ[0])
// using this line's MEASURED width, which counts inline icons as ~zero (the
// tokenizer measure gap). The rendered line is wider by the icons' reserved
// width, so a centred line overflows its box to the right (empty space on the
// left) and a right-aligned line overflows past the right edge. Pre-sum this
// line's icon widths and shift the pen left by half that (centre) or all of it
// (right) so the whole text+icons block is justified as a unit. Left-justify
// (chat, justify 0) needs no shift. node+0x54: 1 = centre, 2 = right (verified
// in the draw builder FUN_005cdc20's justify branch).
// Outline faces draw ink past the glyph advances; the icon pads absorb it
// (see OutlineInkPen). Computed once per line from the node's face.
const float outlineInk = OutlineInkPen(fontFace);
// The node's pixel-snap mode (bit-7 clear) — the engine truncates every
// glyph advance and rounds the origin in this mode, so our icon advances
// and pen shifts must stay integral too (see IconAdvancePen's snap note).
const bool snapNode = (savedFlags & 0x80u) == 0;
const int justify = Game::Read<int>(node, Offsets::OFF_TEXT_NODE_JUSTIFY);
if (justify == 1 || justify == 2) {
// Shared helper so the pre-shift matches the real advances exactly
// (including the positive-offsetX term an earlier inline copy omitted).
const float iconW = SumIconAdvances(text, len, fontH, outlineInk, snapNode);
float shift = (justify == 1) ? iconW * 0.5f : iconW;
if (snapNode)
shift = static_cast<float>(static_cast<int>(shift));
penX -= shift;
}
// Draws a plain run [start,start+n) via the original emitter, threading the
// pen: the original starts at penXYZ[0] and leaves its final pen x in
// linkState[4] (a FLOAT bit pattern — `FSTP dword`; an int-cast yields
// garbage). n == 0 is a safe no-op that just finalizes linkState.
//
// THE READ-BACK IS LAZY AND NEEDS CORRECTION (verified in FUN_005ccbe0):
// the emitter's pen consumes a glyph's advance only when the NEXT token
// lands (each glyph is placed at pen + pairAdvance(prev, cur)), and its
// final write is `linkState[4] = x(lastGlyph) + pairAdvance(secondLast,
// last)` — the last PAIR re-added as a stand-in for the LAST glyph's own
// advance. For a 1-glyph run the stand-in is 0 (no pair exists), so the
// run reads as zero-width — the money-string "coin sits on the lone digit"
// bug ("|cffffffff0" → penAfter 0). For "…] 1" the stand-in is the SPACE's
// advance (narrower than the digit's) — the single-digit clip. Fix: walk
// the run with the engine's own tokenizer, and when it ends in a glyph
// replace the stand-in with the truth:
// pen = linkState[4] − pairAdvance(prev, last)·scale + baseAdvance(last)·scale
// (both terms truncated to int when node-flags bit 7 is clear, mirroring
// the emitter's per-placement __ftol snap). Runs ending in a NON-glyph
// token (trailing |c/|r) need no correction: the trailing iteration's
// recompute already folded pairAdvance(lastGlyph, junkPayload) ≈
// baseAdvance(lastGlyph) into the write. This mirrors 4.3.4's texture case,
// which computes the pending last-glyph advance explicitly at the icon
// (FUN_0061ea10 case 7) instead of trusting a stored pen.
using GlyphPairAdvance_t = float(__thiscall *)(void *font, uint32_t prevCh, uint32_t curCh);
using GlyphBaseAdvance_t = float(__thiscall *)(void *font, uint32_t ch);
auto drawRun = [&](size_t start, int n) {
penXYZ[0] = penX;
g_emitterOriginal(node, edx, text + start, n, colorState, penXYZ, pageMask, linkState);
penX = *reinterpret_cast<float *>(&linkState[4]);
if (n <= 0 || penScale <= 0.0f || fontFace == nullptr)
return;
// Token-walk the run (the ORIGINAL tokenizer — our co-hook would eat
// |T spans, but runs never contain them by construction) for its last
// two glyph payloads.
uint32_t prevCh = 0, lastCh = 0;
int glyphCount = 0;
bool endsInGlyph = false;
const uint8_t *p = text + start;
int rem = n;
while (rem > 0 && *p != 0) {
int consumed = 0;
uint32_t colorOut = 0, payload = 0;
const uint32_t tok = g_tokenizerOriginal(const_cast<uint8_t *>(p), &consumed,
&colorOut, savedFlags, &payload);
if (consumed <= 0 || consumed > rem)
break;
p += consumed;
rem -= consumed;
// Emitter switch handles 0(color)/1(reset)/2(break)/4(|H)/5(|h)
// specially; everything else (6 = glyph, 3 = literal ||) draws.
if (tok != 0 && tok != 1 && tok != 2 && tok != 4 && tok != 5) {
prevCh = lastCh;
lastCh = payload;
++glyphCount;
endsInGlyph = true;
} else {
endsInGlyph = false;
}
}
if (!endsInGlyph)
return;
const bool snap = (savedFlags & 0x80u) == 0;
// The stand-in the engine's final write added: pairAdvance(prev, last),
// 0 for a 1-glyph run (never computed). Same kerning variant the
// emitter picks (flags bit 4).
float standIn = 0.0f;
if (glyphCount >= 2) {
const uintptr_t pairFn = ((savedFlags & 0x10u) != 0)
? Offsets::FUN_TEXT_GLYPH_PAIR_ADVANCE_ALT
: Offsets::FUN_TEXT_GLYPH_PAIR_ADVANCE;
standIn =
reinterpret_cast<GlyphPairAdvance_t>(pairFn)(fontFace, prevCh, lastCh) * penScale;
if (snap)
standIn = static_cast<float>(static_cast<int>(standIn));
}
float terminal = reinterpret_cast<GlyphBaseAdvance_t>(
Offsets::FUN_TEXT_GLYPH_BASE_ADVANCE)(fontFace, lastCh) *
penScale;
if (snap)
terminal = static_cast<float>(static_cast<int>(terminal));
penX += terminal - standIn;
};
size_t runStart = 0;
int i = 0;
while (i < len) {
if (text[i] != '|') {
++i;
continue;
}
int mlen = IconStartLen(text, len, i);
if (mlen == 0) {
// Not an icon. Skip an escaped pipe as a pair so its 2nd `|` isn't
// re-read as a clean `|T` next iteration; otherwise advance one.
if (i + 1 < len && text[i + 1] == '|')
i += 2;
else
++i;
continue;
}
const bool doubled = (mlen == 3);
int closeLen = 0;
size_t close = FindIconClose(text, len, static_cast<size_t>(i) + mlen, doubled, &closeLen);
if (close == static_cast<size_t>(-1)) {
// Unterminated on this line — treat the rest as plain text.
break;
}
// Render the plain run before the icon (finalizes pen at the icon).
drawRun(runStart, i - static_cast<int>(runStart));
IconDesc d;
const char *payload = reinterpret_cast<const char *>(text) + i + mlen;
size_t payloadLen = close - (static_cast<size_t>(i) + mlen);
if (ParseIcon(payload, payloadLen, d)) {
// height/width of 0 => size to the line's font (retail :0:0).
const float baseH = (d.height > 0.0f) ? d.height : fontH;
const float baseW = (d.width > 0.0f) ? d.width : baseH;
const float w = baseW * g_sizeScale;
const float h = baseH * g_sizeScale;
IconRecord r;
r.path = d.path;
r.x = penX;
r.y = penY;
r.fontH = fontH;
r.w = w;
r.h = h;
r.offsetX = d.offsetX * g_sizeScale;
r.offsetY = d.offsetY * g_sizeScale;
r.outlineInk = outlineInk;
r.u0 = d.u0;
r.v0 = d.v0;
r.u1 = d.u1;
r.v1 = d.v1;
r.color = d.color;
icons.push_back(std::move(r));
// Reserve the icon width + a LEAD pad (full) and a TRAIL pad (half) so
// it never jams against adjacent text. Lead-heavy on purpose: the
// preceding glyph's right-side bearing eats into the left gap visually,
// so a symmetric layout pad looks tighter on the left / looser on the
// right — a half trail balances that. The lead pad is applied to the
// draw position in FlushLayout. Shared with the justify pre-shift and
// the string-width co-hook — never inline this math.
penX += IconAdvancePen(d, fontH, outlineInk, snapNode);
}
penXYZ[0] = penX;
i = static_cast<int>(close) + closeLen; // skip past the closing marker
runStart = static_cast<size_t>(i);
}
// Trailing plain run (also finalizes pen/link state for the builder).
drawRun(runStart, len - static_cast<int>(runStart));
// Restore the pen origin to match the engine's own post-call invariant, and the
// node flags (bit 3) we cleared for the segmented append.
penXYZ[0] = startX;
if (batchClearMode)
*flagsPtr = savedFlags;
}
static const Game::HookAutoRegister _emitterHook{Offsets::FUN_TEXT_EMITTER,
reinterpret_cast<void *>(&Emitter_h),
reinterpret_cast<void **>(&g_emitterOriginal)};
// --- paint co-hook: queue recorded icons as region placements ---------------
using Paint_t = void(__fastcall *)(void *layout);
Paint_t g_paintOriginal = nullptr;
// Builds the region placements for `node`'s recorded icons, expressed RELATIVE
// to the owning fontstring `fs`'s rect min corner (the pool anchors them there,
// so the engine moves them with the line). Fills `out` and returns true ONLY
// when everything the placement needs is ready this instant — K live (raster
// globals written) and the fs rect resolved. Returns false otherwise, and the
// caller must NOT queue an empty set (that would hide the icons and the dedup
// would freeze them hidden — the scroll-landing bug's second head). Shared by
// the paint flush and the SMF-refresh early apply so both produce
// bit-identical placements (the pool's dedup depends on that). The per-icon
// geometry mirrors exactly what the emitter reserves — see IconAdvancePen.
bool ComputePlacementsForNode(void *node, void *fs,
std::vector<Text::InlineTexturePool::Placement> &out) {
if (!LooksReadable(node) || !LooksReadable(fs))
return false;
auto itIcons = g_nodeIcons.find(node);
if (itIcons == g_nodeIcons.end() || itIcons->second.empty())
return false;
const PenScale K = PenPerAnchor();
if (!(K.x > 1.0f) || !(K.y > 1.0f))
return false;
auto *n = reinterpret_cast<uint8_t *>(node);
const float ox = Game::Read<float>(n, Offsets::OFF_TEXT_NODE_ORIGIN_X);
const float oy = Game::Read<float>(n, Offsets::OFF_TEXT_NODE_ORIGIN_Y);
// Same pixel-snap mode the emitter honours (bit-7 clear) — gates the
// drawn-rect snap in the placement loop below.
const bool snapNode = (Game::Read<uint32_t>(n, Offsets::OFF_TEXT_NODE_FLAGS) & 0x80u) == 0;
const float *rc = Game::Ptr<const float>(fs, 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];
if (rc[1] == rc[3])
return false; // unresolved rect reads 0-width
for (const IconRecord &r : itIcons->second) {
// Screen left = pen + the FULL lead pad (the emitter reserves
// w + 1.5×pad in the advance: lead 1×, trail 0.5×). Pad is
// fontH-relative — must match IconAdvancePen exactly.
const float cx = r.x + ox + r.offsetX + r.fontH * g_iconPadFrac + r.outlineInk * 0.5f;
// Centre on the line: penY sits near the text top. offsetY shifts up
// (WoW convention), so subtract it. Tall icons centre in the grown line
// (the string-height co-hook added the overflow).
const float cy = r.y + r.fontH * g_centerFrac + oy + g_vBias - r.offsetY;
float rx = cx + g_regionCalX;
float y0 = cy + g_regionCalY - r.h * 0.5f;
float w = r.w, h = r.h;
if (snapNode) {
// Land the drawn rect on WHOLE render-target pixels, like the
// engine's own glyphs (crisp texel sampling). Size snaps
// independently of position so a :16 icon is EXACTLY 16px.
rx = std::floor(rx + 0.5f);
y0 = std::floor(y0 + 0.5f);
w = std::floor(w + 0.5f);
h = std::floor(h + 0.5f);
if (w < 1.0f)
w = 1.0f;
if (h < 1.0f)
h = 1.0f;
}
Text::InlineTexturePool::Placement p;
p.path = r.path;
p.x0 = rx / K.x - fsLeft;
p.y0 = y0 / K.y - fsBottom;
p.x1 = (rx + w) / K.x - fsLeft;
p.y1 = (y0 + h) / K.y - fsBottom;
p.color = r.color;
p.u0 = r.u0;
p.v0 = r.v0;
p.u1 = r.u1;
p.v1 = r.v1;
out.push_back(std::move(p));
}
return true;
}
// Walks the layout's live render nodes and, for each recorded icon, computes
// its screen geometry (node-local pen coords translated by the node's screen
// origin +0x70/+0x74 — the SAME transform the paint pass applies to glyph
// batches) and queues it to Text::InlineTexturePool as a placement RELATIVE TO
// THE OWNING FONTSTRING. Runs after the original paint so icon coords and the
// fs rect are read in one coherent snapshot; the pool applies placements on
// the next FrameTick (regions are never mutated mid-render).
void FlushLayout(void *layout) {
if (!LooksReadable(layout))
return;
const int linkOff = Game::Read<int>(layout, Offsets::OFF_TEXT_LAYOUT_NODE_LINK);
void *node = Game::Read<void *>(layout, Offsets::OFF_TEXT_LAYOUT_NODE_HEAD);
for (int guard = 0; node != nullptr && (reinterpret_cast<uintptr_t>(node) & 1) == 0 &&
guard < 4096;
++guard) {
if (!LooksReadable(node))
break;
auto *n = reinterpret_cast<uint8_t *>(node);
void *const next = Game::Read<void *>(n, linkOff + 4);
// Resolve the owning fontstring up front: a node with NO icons must
// still clear its fontstring's regions — the fs may have just been
// re-SetText'd from icon text to plain text (chat line slot reuse).
// A node only speaks for its fontstring while it IS the fontstring's
// CURRENT text node (fs+0xF8 → handle+8): around a rebuild, the old and
// new node can both be walked in one paint, and letting the stale one
// queue (especially a clear) made icons vanish nondeterministically.
void *fs = nullptr;
{
auto ow = g_nodeOwner.find(node);
if (ow != g_nodeOwner.end() && LooksReadable(ow->second)) {
void *block = Game::Read<void *>(ow->second, Offsets::OFF_FONTSTRING_TEXT_BLOCK);
if (LooksReadable(block) &&
Game::Read<void *>(block, 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).
Game::Ref<uint8_t>(ow->second, Offsets::OFF_FONTSTRING_DIRTY_FLAGS) |= 1u;
}
}
}
auto it = g_nodeIcons.find(node);
// 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.empty() && fs != nullptr) {
const char *ftext = Game::Read<const char *>(fs, Offsets::OFF_FONTSTRING_TEXT);
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.empty()) {
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.
if (!NodeEditable(node)) {
// Baked-alpha mirror. Segmented lines carry PER-GLYPH colours (the
// emitter hook clears bit 3 during emit) and the paint uses those
// VERBATIM — while the node's live opacity (node+0x2c alpha:
// SetTextColor alpha × frame-chain effective alpha, re-folded by
// the engine on every alpha change) is only consulted for UNIFORM
// text. A bit-3 node's colour-change path skips the rebuild-
// invalidate, so a fade (parent SetAlpha) updated +0x2c while the
// baked glyph alphas froze at their build-time value: text stayed
// put while the icons faded. Mirror the live alpha into every
// baked colour, change-detected on each page's first entry (all
// baked alphas are uniform — the engine's own |c splice writes the
// node alpha too). One-frame lag (flush runs post-paint), same as
// the icon fade mirror. Chat (bit-3 clear) is excluded: the engine
// re-bakes those on colour change itself.
if ((Game::Read<uint32_t>(n, Offsets::OFF_TEXT_NODE_FLAGS) & 8u) != 0) {
const uint32_t liveColor = Game::Read<uint32_t>(n, Offsets::OFF_TEXT_NODE_COLOR);
auto baked = g_nodeBakedColor.find(node);
if (baked != g_nodeBakedColor.end() &&
((baked->second ^ liveColor) & 0x00FFFFFFu) != 0) {
// The base colour's RGB changed since the bake (the glue
// AddonList's gold↔gray toggle). Baked per-glyph RGB can't
// be patched in place — |c runs own their colours — so
// re-bake, exactly what the engine's colour setter does
// for accumulation nodes: invalidate the node and the next
// paint's pre-pass re-runs the builder (which re-stamps
// g_nodeBakedColor and re-records the icons). Stamp first
// so a delayed rebuild can't re-trigger every flush.
baked->second = liveColor;
reinterpret_cast<void(__fastcall *)(void *)>(
Offsets::FUN_TEXT_NODE_INVALIDATE)(node);
} else {
const uint8_t liveA = static_cast<uint8_t>(liveColor >> 24);
for (int page = 0; page < Offsets::TEXT_NODE_PAGE_COUNT; ++page) {
auto *buf = Game::Read<uint8_t *>(
n, Offsets::OFF_TEXT_NODE_PAGE_BUFFERS + page * 4);
if (buf == nullptr)
continue;
const int count = Game::Read<int>(buf, Offsets::OFF_TEXT_PAGE_COLOR_COUNT);
auto *colors = Game::Read<uint8_t *>(buf, Offsets::OFF_TEXT_PAGE_COLORS);
if (colors == nullptr || count <= 0 || colors[3] == liveA)
continue;
for (int i = 0; i < count; ++i)
colors[i * 4 + 3] = liveA;
}
}
}
// Geometry + readiness gate factored into ComputePlacementsForNode
// (shared with the SMF-refresh early apply so both produce
// bit-identical placements — the pool dedup depends on it). It
// returns false when K or the fs rect isn't ready; SKIP the queue
// then 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) — the scroll-landing
// bug's second head, where lines painted before K was available
// queued {} and stayed iconless until their text changed.
std::vector<Text::InlineTexturePool::Placement> places;
if (ComputePlacementsForNode(node, fs, places))
Text::InlineTexturePool::QueuePlacements(fs, std::move(places));
} else if (fs != nullptr) {
Text::InlineTexturePool::QueuePlacements(fs, {});
}
node = next;
}
}
// SEH latch: a fault anywhere in the flush disables the whole feature for the
// session instead of crashing the client (re-enable via
// _classicapi_InlineTexEnable after a /reload if it was transient).
void SafeFlush(void *layout) {
__try {
FlushLayout(layout);
} __except (EXCEPTION_EXECUTE_HANDLER) {
g_inlineEnabled = false;
}
}
void __fastcall Paint_h(void *layout) {
g_paintOriginal(layout);
if (g_inlineEnabled)
SafeFlush(layout);
}
static const Game::HookAutoRegister _paintHook{Offsets::FUN_TEXT_PAINT,
reinterpret_cast<void *>(&Paint_h),
reinterpret_cast<void **>(&g_paintOriginal)};
// --- SMF-refresh early apply (kills the one-frame chat-icon lag) -------------
//
// The lag: a ScrollingMessageFrame re-SetTexts every visible line slot on each
// new message (FUN_00788750), but the engine builds each line lazily at PAINT.
// So a line's icons are recorded — and its regions placed — one frame AFTER its
// glyphs draw, because a frame's textures (our icon regions) draw before its
// fontstring glyphs and the placement isn't computed until the glyph build runs
// mid-paint. This co-hook closes it: after the refresh sets up the lines (still
// PRE-RENDER — the refresh runs from the SMF's Lua/event-driven methods, never
// mid-render), force each icon-bearing line to resolve+build and apply its icon
// placement synchronously, so the icons draw with their glyphs the same frame.
//
// Additive and best-effort: any line we can't resolve/build in time (rect still
// pending) falls back to the existing paint-flush + FrameTick pipeline (correct,
// one-frame lagged). Gated by its own toggle (default on) so it can be A/B'd and
// disabled independently of the whole feature; the SEH latch trips it on a fault.
bool g_earlyApply = true;
using SmfRefresh_t = void(__fastcall *)(void *smf, void *edx, int newestIdx);
SmfRefresh_t g_smfRefreshOriginal = nullptr;
using EnsureBuilt_t = int(__fastcall *)(void *node);
using RealizeAnchor_t = void(__thiscall *)(void *anchor, int flag);
using RebuildStringFs_t = void(__fastcall *)(void *fs);
// Force `fs` to resolve its rect and lay out its text node NOW (pre-render),
// mirroring what the engine's pending-layout pass + paint would do later.
// Returns the live text node, or nullptr if it couldn't be built yet (rect
// still unresolved — caller falls back to the lagged pipeline). Uses only the
// engine's own supported force paths (the same Realize/RebuildString/ensure-
// built the Show and paint paths call).
void *ForceBuildFontString(void *fs) {
// 1. Synchronous rect resolve (Realize flag 0 — the call Show itself uses;
// it unlinks the anchor from the pending-layout list and resolves now).
// The caller walks lines bottom-up, so this line's relativeTo (the line
// below it / the frame) is already resolved when we get here.
reinterpret_cast<RealizeAnchor_t>(Offsets::FUN_REGION_LAYOUT_REALIZE)(
reinterpret_cast<uint8_t *>(fs) + Offsets::OFF_REGION_ANCHOR, 0);
// 2. Rebuild the text block — creates the fresh node (but only if the rect
// is now resolved). Internally gated on the fs dirty bit, so a clean
// line is a no-op and we avoid depending on reading that bit ourselves.
reinterpret_cast<RebuildStringFs_t>(Offsets::FUN_FONTSTRING_REBUILD_STRING)(fs);
void *block = Game::Read<void *>(fs, Offsets::OFF_FONTSTRING_TEXT_BLOCK);
if (!LooksReadable(block))
return nullptr; // rect still unresolved — no node created
void *node = Game::Read<void *>(block, Offsets::OFF_TEXTBLOCK_NODE);
if (!LooksReadable(node))
return nullptr;
// 3. Lay the node out now: runs the glyph emitter (our Emitter_h records the
// icons) and finalizes the node origin. Idempotent — paint's own ensure-
// built re-runs later and the flush dedups the identical placement.
reinterpret_cast<EnsureBuilt_t>(Offsets::FUN_TEXT_ENSURE_BUILT)(node);
return node;
}
// Walk the SMF's visible line array and early-apply each icon-bearing line.
// The C++-object-holding body is kept out of the SEH frame (see
// SafeSmfEarlyApply) — MSVC won't allow __try where objects need unwinding.
void SmfEarlyApplyImpl(void *smf) {
if (!LooksReadable(smf))
return;
const int count = Game::Read<int>(smf, Offsets::OFF_SMF_LINE_COUNT);
if (count <= 0 || count > 1024)
return;
auto *arrayBase = Game::Read<uint8_t *>(smf, Offsets::OFF_SMF_LINE_ARRAY);
if (!LooksReadable(arrayBase))
return;
for (int i = 0; i < count; ++i) {
uint8_t *entry = arrayBase + i * Offsets::SMF_LINE_STRIDE;
if (!LooksReadable(entry))
break;
void *fs = Game::Read<void *>(entry, Offsets::OFF_SMF_LINE_FONTSTRING);
if (!LooksReadable(fs))
continue;
// Only shown lines (the desired-shown latch the SMF just set) that carry
// inline markup — a plain chat line costs nothing but the text scan.
if (Game::Read<uint32_t>(fs, Offsets::OFF_REGION_DESIRED_SHOWN) == 0)
continue;
const uint8_t *text = Game::Read<const uint8_t *>(fs, Offsets::OFF_FONTSTRING_TEXT);
if (!LooksReadable(text))
continue;
int len = 0;
while (len < 0x4000 && text[len] != '\0')
++len;
if (!HasInlineTexture(text, len))
continue;
void *node = ForceBuildFontString(fs);
if (node == nullptr || NodeEditable(node))
continue; // couldn't build in time, or editable (raw markup)
std::vector<Text::InlineTexturePool::Placement> places;
if (ComputePlacementsForNode(node, fs, places))
Text::InlineTexturePool::ApplyNow(fs, std::move(places));
}
}
// SEH latch mirroring SafeFlush — a fault disables the early path for the
// session (the baseline paint+tick pipeline still runs) instead of crashing.
void SafeSmfEarlyApply(void *smf) {
__try {
SmfEarlyApplyImpl(smf);
} __except (EXCEPTION_EXECUTE_HANDLER) {
g_earlyApply = false;
}
}
void __fastcall SmfRefresh_h(void *smf, void *edx, int newestIdx) {
g_smfRefreshOriginal(smf, edx, newestIdx);
if (g_inlineEnabled && g_earlyApply)
SafeSmfEarlyApply(smf);
}
static const Game::HookAutoRegister _smfRefreshHook{
Offsets::FUN_SMF_DISPLAY_REFRESH, reinterpret_cast<void *>(&SmfRefresh_h),
reinterpret_cast<void **>(&g_smfRefreshOriginal)};
// --- Lua control surface ---------------------------------------------------
// _classicapi_InlineTexEnable([on]) -> enabled. The feature kill switch (also
// what the SEH latch trips on a flush fault).
int __fastcall Script_InlineTexEnable(void *L) {
if (Game::Lua::GetTop(L) == 0)
g_inlineEnabled = true;
else
g_inlineEnabled = Game::Lua::ToBoolean(L, 1) != 0;
Game::Lua::PushBool(L, g_inlineEnabled);
return 1;
}
// _classicapi_InlineTexEarly([on]) -> enabled. Toggles the SMF-refresh
// pre-render early apply (the chat-icon-lag fix) independently of the whole
// feature — for A/B'ing the lag against the baseline paint+tick pipeline, which
// keeps running either way. Also what the SMF SEH latch trips on a fault.
int __fastcall Script_InlineTexEarly(void *L) {
if (Game::Lua::GetTop(L) == 0)
g_earlyApply = true;
else
g_earlyApply = Game::Lua::ToBoolean(L, 1) != 0;
Game::Lua::PushBool(L, g_earlyApply);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("_classicapi_InlineTexEnable", &Script_InlineTexEnable);
Game::Lua::RegisterGlobalFunction("_classicapi_InlineTexEarly", &Script_InlineTexEarly);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
void PrepareForReload() {
// /reload frees every gxu text node — the per-node maps hold stale node
// pointers. Forget them (records rebuild as the reloaded UI re-emits its
// text). K needs no state at all (read live from engine globals).
g_nodeIcons.clear();
g_nodeOwner.clear();
g_nodeBakedColor.clear();
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
} // namespace Text::InlineTexture