mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-13 22:50:44 +00:00
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.
2045 lines
106 KiB
C++
2045 lines
106 KiB
C++
// 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
|