fix(text): wrap lines account for inline icon advances

Wrap decisions measured inline |T icons as ~0 (the tokenizer hook), so
icon-bearing chat lines broke as if the icons weren't there and the
emitter's real advances ran past the frame's right edge.

Choke: FUN_TEXT_WRAP_STEPPER (0x005C7260), the wrap-stepper dispatcher
that lays out one wrapped line per call. Its exactly 4 callers are
every wrap consumer - the draw builder (render breaks), the height
measure (GetStringHeight), the chars-that-fit counter (ellipsis), and
the break-array computer - so one co-hook keeps render, height,
truncation, and break arrays mutually consistent. outBreak and
outNext-text are byte counts (==len means "all fit"); outWidth is the
measured icons-at-zero line width in the caller's units.

The hook shrinks the wrapWidth argument by a minimal feasible shrink,
found by probing the stepper sandboxed (local out-params + a copy of
the p10 first-line state byte): feasible(s) iff the line's icon
advances fit in the shrink plus the spare the break left
(iconSum <= s + probeW - outWidth). s=0 feasible -> no shrink (the
common fits-anyway case); otherwise escalate by the measured deficit,
then bisect the [infeasible, feasible] bracket to a quarter-glyph
(<=8 probes). Never overflows; packs each line to its physical
capacity. Gated on the shared InlineInterceptActive predicate + the
editbox flag bit 0x40 the dispatcher already carries.

Unit trap #2 (cost one flight): each caller passes fontH/wrapWidth in
its OWN space - node text units from the builder, the small
anchor-converted space from the fs-level callers chat wraps through.
px -> caller units = fontH / FUN_TEXT_FONT_HEIGHT(flag, fontH), the
engine's own pixel realization of the fontH param (FUN_005C6940's
final scale proves the convention for every gxu caller).

Rejected en route (documented in docs/InlineTextureEscapes.md): raw-px
subtraction (shredded lines to 2-glyph fragments), whole-remaining-
text sums (8-icon line -> one word per line), icon-count fixed-point
iteration (oscillates - 55-byte zero-width escapes make the
break<->count coupling discontinuous), max/average tiebreaks.

Cross-checked against 4.3.4 (renders |T natively): Blizzard extended
the token contract instead - texture token type 7 + a shared parser
(FUN_00617ba0) called inline by all five layout loops, fontH passed in
for :0 defaults, plain width, no padding. No single-choke trick exists
at source level; the probe search is the 1.12-shaped equivalent.

Diagnostics: _classicapi_InlineTexWrap(n) dumps the last 8
icon-bearing calls (widths, per-probe sums/breaks/raw out-params,
final shrink, floor hits).
This commit is contained in:
Brues
2026-08-16 23:34:28 -05:00
parent 94b6df9d9b
commit 245052256b
3 changed files with 328 additions and 9 deletions
+49 -6
View File
@@ -219,14 +219,57 @@ The same hook also fixes two layout consumers for free: tooltips auto-size
wide enough for icon-bearing lines, and auto-width fontstrings lay out at
their true rendered width.
### Wrap: FIXED at the shared stepper (`FUN_TEXT_WRAP_STEPPER` = 0x005C7260)
Wrap decisions also measured icons as ~0, so a chat line with prefix icons
broke as if the icons were not there and the emitter's real advances ran past
the right edge. The choke is the wrap-stepper dispatcher `FUN_005C7260`: it
lays out one wrapped line per call, and its exactly 4 callers are EVERY wrap
consumer — the draw builder `FUN_005CDC20` (render breaks), the height
measure `FUN_005C2070`, the chars-that-fit counter `FUN_005C21C0` (ellipsis),
and the break-array computer `FUN_005C2430` (behind `FUN_00772B60`).
The co-hook shrinks the `wrapWidth` argument by a **minimal feasible
shrink**, gated on the same `InlineInterceptActive` predicate plus the flags
editbox bit 0x40 the dispatcher already carries. The stepper reports each
probe's measured (icons-at-zero) line width in `outWidth`, which gives 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)`). The hook probes sandboxed (local out-params plus a COPY of the
`p10` in/out state byte, so the caller's first-line state is not consumed):
s = 0 first (feasible → no shrink at all — the common short-message case),
escalate by the measured deficit when infeasible, bisect down after the
first feasible hit; ≤4 probes, and with no feasible hit the last escalation
target wins (wraps early rather than overflowing). `outBreak` and
`outNext − text` are byte counts; `outBreak == len` means the whole text
fit. Two rejected earlier schemes, for the record: a naive whole-remaining-
text sum shredded an 8-icon line to one word per line, and a fixed-point
iteration on the icon count oscillated (a ~55-byte zero-width escape makes
the break↔icon-count coupling discontinuous — no fixed point exists).
`text` always points at the REMAINING text, so a continuation line whose
icons are behind it gets sum = 0. Because all four consumers share the hook,
render breaks, `GetStringHeight`, truncation, and break arrays shift
together. Diagnostics: `_classicapi_InlineTexWrap(n)` dumps the last 8
icon-bearing calls with per-probe raw out-params.
Unit trap #2 (hit on first flight, like the width hook's): each caller passes
fontH/wrapWidth in its OWN space — the draw builder passes node text units
(node+0x1C / node+0x3C), and the fs-level callers (the path chat wraps
through) pass the much smaller anchor-converted space. Subtracting a raw
pixel advance annihilated the small widths to the floor and shredded
icon-bearing chat lines into 2-glyph fragments. The space-agnostic conversion
uses the engine's own convention: the measure loops realize their fontH param
as pixels via `FUN_TEXT_FONT_HEIGHT(flag, fontH)` (see `FUN_005c6940`'s final
scale), so px → caller units is exactly `fontH / fontHPx`. The hook computes
the icon sum in true pixels (the same value the emitter reserves) and scales
by that ratio.
**Still icon-blind (accepted residuals):**
- The wrap-break computer (`FUN_00772B60`), the substring measure
(`FUN_00772AE0`), and hyperlink hit-testing still see icons as ~0. Wrap
decisions therefore ignore icon width (render can overflow the right edge
on a wrap-tight line). Hover hit-testing on an icon needs a hyperlink-region
feature we have not built. Revisit `FUN_00772AE0` (also cold) if a consumer
needs icon-aware substring widths.
- The substring measure (`FUN_00772AE0`) and hyperlink hit-testing still see
icons as ~0. Hover hit-testing on an icon needs a hyperlink-region feature
we have not built. Revisit `FUN_00772AE0` (also cold) if a consumer needs
icon-aware substring widths.
- A ~≤1px artifact when an icon is the last token: the gxu width loop ends on
the last *glyph's* ink width rather than its advance (`FUN_005c6b70` gets the
remaining-text pointer), and a trailing icon shifts the previous glyph's
+26
View File
@@ -6527,6 +6527,32 @@ enum Offsets {
// draw path is unaffected by this hook. See FUN_005c2810.
FUN_TEXT_TOKENIZER = 0x005C2810,
// Shared wrap-stepper dispatcher — lays out ONE wrapped line per call.
// `__fastcall(gxuFont /*ecx*/, byte *text /*edx*/, float fontH, float
// wrapWidth, int *outBreak, float *outWidth, void *outNext, float indent,
// uint flags, byte *p10)`, callee cleans 0x20 (verified RET 0x20; prologue
// PUSH EBP; MOV EBP,ESP; FLD [EBP+8] — MinHook-safe). Thin validator that
// routes flags&0x80 to the no-wrap path (FUN_005C7300, ignores wrapWidth)
// and everything else to the real stepper FUN_005C7470. Its exactly 4
// callers are EVERY wrap consumer: the draw builder FUN_005CDC20 (render
// breaks), the height measure FUN_005C2070 (GetStringHeight), the
// chars-that-fit counter FUN_005C21C0 (ellipsis truncation), and the
// break-array computer FUN_005C2430 (behind FUN_00772B60) — so a co-hook
// here keeps render, height, truncation, and break arrays mutually
// consistent. Text::InlineTexture shrinks the wrapWidth arg by the line's
// inline-icon advances (the tokenizer hook hides them from the measure, so
// breaks otherwise land as if icons were 0 wide and the emitter's real
// advances overflow the right edge). UNITS: each caller passes
// fontH/wrapWidth in its OWN space (node text units from the builder, the
// small anchor-converted space from the fs-level callers — the path chat
// wraps through); a raw-pixel subtraction annihilates the small spaces
// (shredded chat lines into 2-glyph fragments on first flight). Convert
// px→caller units by (fontH / FUN_TEXT_FONT_HEIGHT(flag, fontH)) — the
// engine's own pixel realization of the fontH param (FUN_005C6940's final
// scale uses exactly that call), so the ratio is valid in every caller's
// space.
FUN_TEXT_WRAP_STEPPER = 0x005C7260,
// Focused-editbox global — holds the CSimpleEditBox that currently has
// keyboard focus (the one showing a cursor), or 0 when no input field is
// active. Written by EditBox SetFocus (`mov [0xcf4dc8],esi` @0x0077e3f8),
+253 -3
View File
@@ -46,9 +46,12 @@
// field), 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. Still icon-blind (documented, accepted): the
// wrap-break computer (FUN_00772B60), the substring measure (FUN_00772AE0), and
// hyperlink hit-testing. See docs/InlineTextureEscapes.md for the RE map.
// match the rendered width — 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"
@@ -854,6 +857,214 @@ static const Game::HookAutoRegister _stringWidthHook{
Offsets::FUN_FONTSTRING_STRING_WIDTH, reinterpret_cast<void *>(&StringWidth_h),
reinterpret_cast<void **>(&g_stringWidthOriginal)};
// --- 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 on first flight): each caller passes fontH/wrapWidth in its
// OWN space — the draw builder passes the node's fontSize (+0x1C) and wrap
// width (+0x3C) in internal text units, while the fs-level callers (height,
// fit, break arrays — the path CHAT wraps through) pass the much smaller
// anchor-converted space. Subtracting a raw pixel advance annihilated those
// small widths to the floor and shredded icon-bearing chat lines into
// 2-glyph fragments. The space-agnostic conversion uses the engine's own
// convention: every gxu caller passes fontH in the units FUN_TEXT_FONT_HEIGHT
// expects, and the measure loops use FUN_TEXT_FONT_HEIGHT(flag, fontH) as the
// PIXEL realization of that fontH (see FUN_005c6940's final scale). So
// px→caller-units is exactly (fontH / fontHPx): compute the icon sum in true
// pixels (same value the emitter reserves), then scale by that ratio.
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;
// Diagnostics ring for the wrap hook — one record per icon-bearing stepper
// call, read back via _classicapi_InlineTexWrap(n). All widths are in the
// CALLER's unit space except the *Px fields.
constexpr uint32_t kWrapDiagCount = 8;
struct WrapDiag {
float wrapWidth = 0.0f; // width the caller passed
float fontH = 0.0f; // fontH the caller passed (caller units)
float fontHPx = 0.0f; // FUN_TEXT_FONT_HEIGHT's pixel realization of it
int len = 0; // text length scanned (to '\n'/'\0', cap 2048)
int passes = 0; // probe passes run (≤8)
float sumPx[8] = {}; // per-pass icon-advance sum before the probe break
int lineLen[8] = {}; // per-pass line length used for the sum (chars)
int pBreakA[8] = {}; // per-pass raw outBreak from the stepper
int pNextD[8] = {}; // per-pass raw outNext - text (-1 when null/inval)
float pWidthA[8] = {}; // per-pass raw outWidth from the stepper
float finalSumPx = 0.0f;
float shrunk = 0.0f; // width handed to the original
int floored = 0; // hit the minWidth floor
};
WrapDiag g_wrapDiag[kWrapDiagCount];
uint32_t g_wrapDiagNext = 0;
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. px → caller units is then
// (fontH / fontHPx); see the units note above.
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);
if (fontHPx > 0.0f) {
const float toUnits = fontH / fontHPx;
const float minWidth = fontH * 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).
WrapDiag &d = g_wrapDiag[g_wrapDiagNext % kWrapDiagCount];
++g_wrapDiagNext;
d = WrapDiag{};
d.wrapWidth = wrapWidth;
d.fontH = fontH;
d.fontHPx = fontHPx;
d.len = len;
// 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
// via the diag); 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);
const float iconUnits = sumPx * toUnits;
float slack = probeW - pWidth;
if (slack < 0.0f)
slack = 0.0f;
d.sumPx[pass] = sumPx;
d.lineLen[pass] = lineLen;
d.pBreakA[pass] = pBreak;
d.pNextD[pass] = (pNext != nullptr && pNext >= text)
? static_cast<int>(pNext - text)
: -1;
d.pWidthA[pass] = pWidth;
d.passes = pass + 1;
if (iconUnits <= s + slack) {
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 + fontH;
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 < fontH * 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;
d.floored = 1;
}
d.finalSumPx = (toUnits > 0.0f) ? (finalShrink / toUnits) : 0.0f;
d.shrunk = shrunk;
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,
@@ -1424,6 +1635,44 @@ int __fastcall Script_InlineTexRegionCal(void *L) {
return 2;
}
// _classicapi_InlineTexWrap([n]) -> wrapWidth, fontH, fontHPx, len, passes,
// finalSumPx, shrunkWidth, floored, then per pass:
// (sumPx, lineLen, rawOutBreak, rawOutNextDelta, rawOutWidth). n = 0
// (default) is the most recent icon-bearing wrap-stepper call, 1 the one
// before, … up to 7. wrapWidth/fontH/shrunk/rawOutWidth are in the CALLER's
// unit space; fontHPx and the sums are pixels. Healthy convergence: passes
// ≥ 2 with the last two sums equal and finalSumPx ≈ (icons on line 1) ×
// advance. Oscillating passes (sums alternating high/low) mean a spread-icon
// line with no true fixed point — the hook then splits the difference. The
// raw out-params expose the stepper's break semantics per caller.
int __fastcall Script_InlineTexWrap(void *L) {
uint32_t n = 0;
if (Game::Lua::IsNumber(L, 1))
n = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
if (n >= kWrapDiagCount || n >= g_wrapDiagNext)
return 0;
const WrapDiag &d =
g_wrapDiag[(g_wrapDiagNext - 1 - n) % kWrapDiagCount];
if (d.passes == 0)
return 0;
Game::Lua::PushNumber(L, d.wrapWidth);
Game::Lua::PushNumber(L, d.fontH);
Game::Lua::PushNumber(L, d.fontHPx);
Game::Lua::PushNumber(L, static_cast<double>(d.len));
Game::Lua::PushNumber(L, static_cast<double>(d.passes));
Game::Lua::PushNumber(L, d.finalSumPx);
Game::Lua::PushNumber(L, d.shrunk);
Game::Lua::PushNumber(L, static_cast<double>(d.floored));
for (int i = 0; i < d.passes; ++i) {
Game::Lua::PushNumber(L, d.sumPx[i]);
Game::Lua::PushNumber(L, static_cast<double>(d.lineLen[i]));
Game::Lua::PushNumber(L, static_cast<double>(d.pBreakA[i]));
Game::Lua::PushNumber(L, static_cast<double>(d.pNextD[i]));
Game::Lua::PushNumber(L, d.pWidthA[i]);
}
return 8 + d.passes * 5;
}
// _classicapi_InlineTexProbeFS("FontStringName") ->
// s, rect[0..3] (raw +0x64..+0x70: {yA, left, yB, right}), insetX, insetY,
// originX, originY, nodeFontH
@@ -1485,6 +1734,7 @@ void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("_classicapi_InlineTexProbeFS", &Script_InlineTexProbeFS);
Game::Lua::RegisterGlobalFunction("_classicapi_InlineTexMode", &Script_InlineTexMode);
Game::Lua::RegisterGlobalFunction("_classicapi_InlineTexRegionCal", &Script_InlineTexRegionCal);
Game::Lua::RegisterGlobalFunction("_classicapi_InlineTexWrap", &Script_InlineTexWrap);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};