From 18c8cdd762d7b253c0c4303b6192c8d929706049 Mon Sep 17 00:00:00 2001 From: grom Date: Mon, 2 Nov 2026 10:00:50 -0700 Subject: [PATCH] docs: the MOD blend, SetEnabled, and the label that rode into the canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One new section and two amended rows, the three faults behind what grom recorded on 2026-11-02: the fullscreen tint that drew white, the chat traceback from picking a profile, and the hovered-zone label scaling with the map's zoom. Each carries what was measured rather than what was argued, and the general shape — answering half of an era's call shapes is what makes an addon's `pcall` feature test lie. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- docs/ADDON_COMPAT.md | 47 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index 31722db8..4ad3ea60 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -251,7 +251,7 @@ separately, so "cannot say" has to be distinguishable from "no". | Combat | partial | `compat/classic_api/misc.rs`. `InCombatLockdown()` → always `false`, and that is the honest answer rather than a stub: the lockdown exists on modern clients to refuse secure-frame edits mid-combat and 1.12 has no secure-template system at all, so nothing here is ever locked down. `StartAttack`/`StopAttack` deliberately not added here — they are 1.12's *own* verbs that benilla hasn't built yet, so registering them from `compat/` would put a stock gap in the wrong layer and hide it from the reference-surface accounting | | Class, Totem, Tracking, Instance | todo, blocked | `FillLocalizedClassList`: the nine localized class names are DBC-resolved app-side (`benilla-app`'s `ui_unit::class_names`) and `benilla-ui` deliberately holds no DBC — the player's own class reaches it through `PlayerRecord`, but not all nine, so this needs a feed like `SelfMovement`'s, not a binding. `Totem` (4 functions): no totem state exists in the model at all; 1.12's own totem bar is unbuilt, so there is nothing to wrap. `Tracking` (3): `Model::tracking` is the one *active* tracking aura (behind the stock `GetTrackingTexture`), not the enumerable list of known tracking spells these index into. `GetInstanceInfo`: `script/instance.rs` feeds only the instance *type*; the modern tuple also wants the instance name, difficulty index and name, and max players — none tracked, and the name would have to be guessed from zone text | | Hooks (`hooksecurefunc`) | done | `compat/classic_api/hooks.rs`, both documented forms (over a global, over a table field). Pure Lua machinery, no game state. The contract it keeps, each covered by its own test: the original runs first with the caller's arguments; **the original's return values pass through untouched** and the hook's are discarded; a hook that errors has its error recorded (`Model::record_script_error`) and the original's returns still come back, while the *original's* own error still propagates; and hooking twice **chains** in install order rather than replacing. A name holding no function is a silent no-op — the reference raises, but an addon written against a client that has the verb it meant to hook is the common case here and taking its UI down over a missing one would be worse. The original and hook live in the Lua registry, since this crate holds no persistent `Function` handle | -| Frame (widget methods) | partial | `compat/classic_api/frame.rs`. These are **methods, not globals**, written straight into the stock class method tables (`__benilla_frame_methods` and the two region leaves) — `object.rs`'s `frame_meta_for` *links* those tables rather than copying them ("a later write to a table shows through"), so a late install from `compat/` reaches every widget already made and every one made later. Built: `SetShown`, `GetSize`/`SetSize`, `GetRect`, `SetFormattedText` (FontString), `HookScript`, `IsEventRegistered`, `GetEffectiveAlpha`, and `SetAttribute`/`SetAttributeNoHandler`/`GetAttribute`/`ClearAttribute`. The geometry/visibility/text ones are composed from the stock verbs as a Lua chunk, so they reuse the stock resolution exactly instead of duplicating it in Rust — note the chunks return a function rather than reading `...`, since this VM is 1.12's Lua **5.0** where `...` is a parameter-list token only, never an expression. Skipped, each with the reason in that file's header: the **mask** and **atlas** families (benilla's UI quad carries one texture and a UV rect — no mask slot, no atlas registry; needs a render-path feature first), the **secure-template** family (`RegisterStateDriver`, `RegisterUnitWatch`, `SecureCmdOptionParse`, `SecureButton_*`, … — modern restricted execution, which 1.12 has no equivalent of; `SetAttribute`/`GetAttribute` ARE built because stripped of secure semantics they are just a per-frame key/value store, and that much is honest, but the drivers that *watch* attributes are the actual subsystem and are not), the **per-line** text-measurement getters (`GetNumLines`, `GetWrappedWidth`, `IsTruncated`, … — need per-line layout facts the host measurer is not asked for today; `measure.rs` answers one whole-string width/height). **`GetStringHeight` was on that skipped list and has been built**, because the blanket reason did not apply to it: the whole-string height is the other half of the number `GetStringWidth` already reads, so `measure::current_measure` now owns the staleness rule for both and the method is a read, not a new host question. AceGUI-3.0 calls it **unguarded** (`AceGUIWidget-Label.lua:54` `height = label:GetStringHeight()`; `AceGUIWidget-CheckBox.lua:90,210` `self:SetHeight(28 + self.desc:GetStringHeight())`), so a nil method there raises and takes the library's own zero-height fallback with it. **Correction, and it matters:** those are the `Libs/` (Mainline), `2.4.3/` and `3.3.5/` library trees AI_VoiceOver ships — **not** the `1.12/` tree its TOC actually loads, which reads `label:GetHeight()` instead and has had every one-argument `SetPoint` rewritten besides. This method is therefore ClassicAPI **parity surface**, not the fix for anything on this install, and the earlier claim here that it explained "can't see the VoiceOver options" was wrong. That symptom was **ours and elsewhere**: the ScrollFrame child re-anchor replaced the child's whole anchor list where 1.12's `SetPoint` merges one point into it, so AceGUI's `TOPLEFT`/`TOPRIGHT`-authored scroll child lost the half that gave it a width and resolved to no rect — and nothing below it resolved either. Fixed in `layout.rs`'s `set_frame_anchored`; measured through the same probe, which went from every node under the scroll child answering `GetLeft() == nil` to a populated, correctly placed tree. Kept because the blanket "needs per-line layout facts" reason genuinely does not apply to it and a 2.4.3/3.3.5 client of this library would need it; what was learned is that an addon shipping several library copies has to be read through its own TOC chain before any call site in it is evidence. **`SetColorTexture` has since been built too**, on the same footing: it is the modern spelling of `SetTexture`'s colour arm, which 1.12 already has (`0x770360`), so it composes in four lines of the same Lua chunk and adds no behaviour. Traced by porting the `Addon_Ports` pair — `Cinematic/Options.lua:701` paints its list border with it and died at session start without it, and OpenMapClassic its map chrome. Also in this family, each plausible and untraced this pass: `IsMouseOver`/`GetMouseFoci`/`IsDragging`/`SetResizeBounds`/`RegisterUnitEvent` (now traced: `Cinematic/Frames.lua:2611` is the next load blocker)/desaturation/rotation/the EditBox cursor-and-highlight getters. All 12 new methods are registered in `widget_surface.rs`'s `ALLOWED_BEYOND` with their reasons | +| Frame (widget methods) | partial | `compat/classic_api/frame.rs`. These are **methods, not globals**, written straight into the stock class method tables (`__benilla_frame_methods` and the two region leaves) — `object.rs`'s `frame_meta_for` *links* those tables rather than copying them ("a later write to a table shows through"), so a late install from `compat/` reaches every widget already made and every one made later. Built: `SetShown`, `GetSize`/`SetSize`, `GetRect`, `SetFormattedText` (FontString), `HookScript`, `IsEventRegistered`, `GetEffectiveAlpha`, and `SetAttribute`/`SetAttributeNoHandler`/`GetAttribute`/`ClearAttribute`. The geometry/visibility/text ones are composed from the stock verbs as a Lua chunk, so they reuse the stock resolution exactly instead of duplicating it in Rust — note the chunks return a function rather than reading `...`, since this VM is 1.12's Lua **5.0** where `...` is a parameter-list token only, never an expression. Skipped, each with the reason in that file's header: the **mask** and **atlas** families (benilla's UI quad carries one texture and a UV rect — no mask slot, no atlas registry; needs a render-path feature first), the **secure-template** family (`RegisterStateDriver`, `RegisterUnitWatch`, `SecureCmdOptionParse`, `SecureButton_*`, … — modern restricted execution, which 1.12 has no equivalent of; `SetAttribute`/`GetAttribute` ARE built because stripped of secure semantics they are just a per-frame key/value store, and that much is honest, but the drivers that *watch* attributes are the actual subsystem and are not), the **per-line** text-measurement getters (`GetNumLines`, `GetWrappedWidth`, `IsTruncated`, … — need per-line layout facts the host measurer is not asked for today; `measure.rs` answers one whole-string width/height). **`GetStringHeight` was on that skipped list and has been built**, because the blanket reason did not apply to it: the whole-string height is the other half of the number `GetStringWidth` already reads, so `measure::current_measure` now owns the staleness rule for both and the method is a read, not a new host question. AceGUI-3.0 calls it **unguarded** (`AceGUIWidget-Label.lua:54` `height = label:GetStringHeight()`; `AceGUIWidget-CheckBox.lua:90,210` `self:SetHeight(28 + self.desc:GetStringHeight())`), so a nil method there raises and takes the library's own zero-height fallback with it. **Correction, and it matters:** those are the `Libs/` (Mainline), `2.4.3/` and `3.3.5/` library trees AI_VoiceOver ships — **not** the `1.12/` tree its TOC actually loads, which reads `label:GetHeight()` instead and has had every one-argument `SetPoint` rewritten besides. This method is therefore ClassicAPI **parity surface**, not the fix for anything on this install, and the earlier claim here that it explained "can't see the VoiceOver options" was wrong. That symptom was **ours and elsewhere**: the ScrollFrame child re-anchor replaced the child's whole anchor list where 1.12's `SetPoint` merges one point into it, so AceGUI's `TOPLEFT`/`TOPRIGHT`-authored scroll child lost the half that gave it a width and resolved to no rect — and nothing below it resolved either. Fixed in `layout.rs`'s `set_frame_anchored`; measured through the same probe, which went from every node under the scroll child answering `GetLeft() == nil` to a populated, correctly placed tree. Kept because the blanket "needs per-line layout facts" reason genuinely does not apply to it and a 2.4.3/3.3.5 client of this library would need it; what was learned is that an addon shipping several library copies has to be read through its own TOC chain before any call site in it is evidence. **`SetColorTexture` has since been built too**, on the same footing: it is the modern spelling of `SetTexture`'s colour arm, which 1.12 already has (`0x770360`), so it composes in four lines of the same Lua chunk and adds no behaviour. Traced by porting the `Addon_Ports` pair — `Cinematic/Options.lua:701` paints its list border with it and died at session start without it, and OpenMapClassic its map chrome. Also in this family, each plausible and untraced this pass: `IsMouseOver`/`GetMouseFoci`/`IsDragging`/`SetResizeBounds`/`RegisterUnitEvent` (now traced: `Cinematic/Frames.lua:2611` is the next load blocker)/desaturation/rotation/the EditBox cursor-and-highlight getters. **`SetEnabled` has since been built**, on that same footing and for the same kind of reason: retail replaced 1.12's `Enable`/`Disable` pair (`0x780160`, `0x7801b0`) with the one setter, and a later-era addon writes the setter **unguarded on its own controls** even where it guards on someone else's — `Cinematic/Options.lua:4176` is a tick's own `Refresh`, greying it when a rule owns the row, and `:305` right above it is the same file guarding `control.SetEnabled and …` on a control it did not make. The raise unwound `RefreshOptions` → `SettingsChanged` → `SetProfile` → `Welcome.lua:43`, so **picking a profile from the welcome window applied the profile and then abandoned the panel refresh**, with the traceback in chat; that is what grom saw on 2026-11-02. Composed from the stock pair on Button's table, which CheckButton and LootButton inherit. EditBox takes it too, written out rather than composed because the reference gives a box no enabled state at all: it writes the mouse and keyboard the frame already owns and drops focus, so input is refused and the text is **not** greyed, which 1.12 has no slot for. A **Slider still answers neither this nor the trio** — addons duck-type on them to tell a control from a bar, which `tests/slider.rs` pins. All 12 new methods are registered in `widget_surface.rs`'s `ALLOWED_BEYOND` with their reasons | | Container (`C_Container`) | done | `compat/classic_api/container.rs`; almost entirely translation over the stock 1.12 bag verbs' own data (`Model.containers`), not new game logic. `GetContainerItemID/Info`, `HasContainerItem`, `GetContainerItemDurability`, `GetContainerNumFreeSlots`/`GetContainerFreeSlots`, `CalculateTotalNumberOfFreeBagSlots`, `PlayerHasHearthstone`/`UseHearthstone` (scans bags 0-4 for item id 6948), `SwapItems`/`MoveItem`/`AutoStoreItem` (queue the same `ContainerMove`/`BagAutoStore` the stock pickup/place path does), `SortBags`/`SortBankBags` (no-ops — 1.12 has no sort algorithm to run) and the three sort-preference toggle pairs (stored, nothing downstream reads them) are real. Deliberately nil/false, not guessed: `GetContainerItemCharges` (same charge-slot-identity blocker as SuperWoW's), `GetContainerItemRepairCost` (server-only in vanilla), `GetContainerItemQuestInfo`/`GetContainerItemEquipmentSetInfo` (no such tracking exists, and no such feature exists in 1.12 at all for the latter), `IsContainerItemOpenable` (no lockbox flag tracked) | | CreatureInfo (`C_CreatureInfo`) | partial | `compat/classic_api/creature_info.rs`, the 3 of 9 anything installed actually calls. `GetRaceInfo` and `GetFactionInfo` read a **new app→VM feed of the player's own `ChrRaces.dbc`** (`benilla-formats`' `chr_races.rs` → `benilla-app`'s `chr_races.rs` → `UiScript::set_race_info`), built because this crate holds no DBC and a frozen race table gets a modified realm's names wrong: **OctoWoW's row 10 displays "High Elf" where Blizzard's says "Blood Elf"**, while both keep `clientFileString "BloodElf"` for the art paths — the two columns genuinely disagree, so neither can be derived from the other, and a test pins that. The side on `GetFactionInfo` comes from `ui_unit.rs`'s `race_faction_group`, the same answer `UnitFactionGroup("player")` gives, so the two cannot disagree about a race; a non-playable row carries no side and reads nil rather than a guess. **`GetRaceInfo` answering nil past the last race is load-bearing** — pfUI walks `i = 1, 2, 3…` until nil, so an always-non-nil answer hangs the client at load; its own test says so. `GetCreatureID` decodes the template entry out of bits 24-47 of the 16-hex-digit GUID token the rest of `compat/` passes around, accepting **only** the creature high word so a player or pet GUID answers nil instead of a plausible-looking wrong number (the decode is inlined rather than calling `benilla_protocol::guid::entry` — this crate depends on no protocol or engine crate, and one shift is not worth breaking that boundary for). Not built: `GetCreatureInfoByID`, `RequestLoadCreatureByID`, `GetCreatureFamilyInfo`/`IDs`, `GetCreatureTypeInfo`/`IDs` (each needs a creature/family/type table this VM is not fed, and nothing installed asks) and `GetClassInfo` (the class table exists app-side as `ChrClasses` but has no VM feed yet — the same shape of work this row's race feed just did, if an addon ever wants it). With no install the race table stays empty and every reader answers nil, so an addon's race walk finds none and still loads | | Item (`C_Item`) | partial | `compat/classic_api/item.rs`, 37 entries. Modern id-first and `ItemLocation`-keyed readers over the same state the stock item verbs read: `Model::item_templates` (the one `ItemTemplateView` cache every item tooltip renders through — a miss records the same `CMSG_ITEM_QUERY` ask the stock `GetItemInfo` does, so a modern caller warms the one shared cache, not a parallel one), `Model::containers` and `Model::inventory_slots`. **`ItemLocation` is accepted as the plain table addons build** (`{bagID=, slotIndex=}` / `{equipmentSlotIndex=}`): modern WoW's mixin is pure Lua sugar over exactly those two fields and every entry point only reads them, so the table *is* the contract; the mixin constructors themselves belong with `Mixin`/`CreateFromMixins` in the `Lua` namespace and are not faked here. Built: the existence/identity trio, `GetItemData`/`GetItemDataByID` (the upstream's kitchen-sink table, including the `isConjured`/`isOpenable`/`isLootable`/`isWrapper` flag bools off the template's raw `flags`), `GetItemInfoInstant`, the by-id and by-location getter pairs (name, quality, max stack, sell price, set id, inventory type, icon), `GetItemLink`, `GetStackCount`, `GetItemCount` (bags + worn, with the `includeBank` and charge-multiplying `includeUses` flags), `GetItemSpell` (the on-use trigger only — triggers 0/5, not an equip or chance-on-hit effect), `IsUsableItem`/`IsConsumableItem`/`IsEquippableItem`/`IsEquippedItem`/`IsItemOpenable`/`IsBound`, the `IsItemDataCached` pair (which deliberately record **no** ask — polling is not asking), the two inventory-slot name getters, and `UseItemByName`/`EquipItemByName` over the stock container-use queue. Not built, each with the reason in that file's header: the **guid** family (`GetItemGUID`, `GetItemLocation`, `IsItemGUIDInInventory`, …) — nothing in the model keys items by instance guid, and the modern `"Item-Server-…"` string has no source here; the **item-level** family (`GetCurrentItemLevel`, `GetDetailedItemLevelInfo`, `GetAverageItemLevel`) — item level is not in `SMSG_ITEM_QUERY_SINGLE_RESPONSE` and 1.12 exposes no such concept to the client; `GetItemStats`/`GetItemStatDelta` — the modern shape is keyed by `ITEM_MOD_*` *string* names resolved app-side, and returning the raw numeric keys under the modern name would silently mismatch; `GetItemUniqueness` (wants a limit-category pair 1.12 has no counterpart for); the `LockItem`/`UnlockItem` family (`ContainerSlot::locked` is a pending-operation flag the app owns, not a lock Lua may set); and `UseAtCursor`/`UseAtUnit`/`PickupItem`/`RequestLoadItemData*` (each needs an app-drained queue that does not exist yet) | @@ -460,7 +460,7 @@ a loot roll"; it does not, because that module never runs.** The call path was n | `C_EncodingUtil` (8), `C_FriendList.SetFriendNotes`, `C_Item.GetCurrentItemLevel`, `C_Map.GetAreaTriggerInfo`, `C_SpellBook.GetSkillLineRank`, `C_TaxiMap.GetTaxiNodesForMap`, `C_Spell.IsSpellInRange`/`IsNextMeleeSpell`/`IsRangedAutoAttackSpell`/`ResetsMeleeSwing`, `C_Item.RequestLoadItemDataByID`, `C_LootHistory.*` | not built | Each either guarded by its caller (all of pfQuest's) or behind a user action not yet taken: `SetFriendNotes` on editing a friend note, `SerializeJSON` on sharing a config, `GetCurrentItemLevel` behind `if ShaguScore` (an addon not installed), the hunter range pair on a hunter's bar. None raises in ordinary play as installed | | `C_Map`'s uiMapID half (14 members), `C_MapExplorationInfo`, `Enum.UIMapType`, `CreateVector2D`, `UiMapPoint` — **the map graph** | done | `script/uimap.rs` builds the graph, `compat/classic_api/uimap.rs` publishes it; supersedes the "nine waypoint members, the layered-art trio, the four-member projection family" clause of the `C_Map.GetAreas`/`GetAreaTriggerInfo` row above. **What 1.12 withholds is not a getter, it is the tree.** Its map is addressed by a `(continent, zone)` pair of *dropdown indices* — a position in two lists, which moves when a zone is inserted — and it publishes no map's rectangle on any other map at all, because the window draws one zone at a time into a fixed 1002x668 frame and never needs to know where that zone sits. A seamless, pannable map is exactly that missing relation: one rectangle per map composed up to a common root. **Built from the realm's own catalog, which is the point.** OctoWoW's `WorldMapArea.dbc` carries 175 rows against stock 1.12's 51 — 124 added (`Alah'Thalas`, `Balor`, `Blackstone Isle`, `Gilneas`, `Grim Reaches`, `Icepoint`, `Lapidis Isle`, `Moonwhisper`, `Northwind`, `Tel'Abim`, `Thalassian Highlands`, the Timbermaw tunnels, Hyjal) and 49 of the shipped rows' world rects re-tuned, measured with `dbc_diff`. A graph transcribed from Classic Era would be the right shape describing a different world. No new DBC read: the graph is a pure function of the catalog `ui_world_map.rs` already pushes, rebuilt inside `set_world_map_catalog`/`set_world_map_direct_areas` so the two cannot disagree. **Era's ids are reused where the map is the same map**, keyed on the `WorldMapArea` **art folder** rather than the zone name, because the folder is the column a realm has no reason to touch — this realm spells Azshara's `Aszhara` and Hillsbrad's `Hilsbrad`, exactly as 1.12 does, while its localized names are the ordinary ones. That join is what lets an Era addon's own uiMapID-keyed data (level ranges, colour grading, flight paths) find this realm's zones. The ids are **read off the consumer's own data files**, which carry the zone name in a comment beside each (`Data/Levels.lua`, `Data/Grade.lua`, `Data/Tones.lua` give the 40 outdoor zones) plus its source constants for the sheet (`WORLD = 947`), the two continents (`CONT_OF = { [1415] = 0, [1414] = 1 }`) and three cities; the other three cities are the gap that contiguous block leaves. A row with no Era counterpart is minted at `100000 + areaID` — unique because `AreaTable` ids are, stable because the id is the realm's own, and clear of anything Era can add. **One realm quirk had to be handled and it is measurable:** this install's `WorldMapArea.dbc` carries a *third* `areaID == 0` row whose art folder is `World` — the world sheet's own row — and the reference's catalog walk (`0x4a5d00`, which selects continents by exactly that column) files it beside Kalimdor and Azeroth. `SetMapZoom(3, 0)` then `GetMapInfo()` answers `World`, and because the row carries `Map.dbc` id 0 its zone list is Eastern Kingdoms' own re-collected, so left in it is a phantom continent holding a second copy of all 39 Eastern Kingdoms zones. It is kept out of the graph rather than merged into the sheet, because its `loc` rect is in map 0's coordinate space while the sheet spans both continents. **Art is answered as paths, not fileIDs**: `GetMapArtLayerTextures` hands back `Interface\WorldMap\\N`, so the asset chain serves whichever copy wins — on this install the upscaled pack, `patch-M.mpq`, three copies deep. The 1002x668 / 256x256 layer geometry is the **logical** size, not the shipped pixel size, because the drawn fractions are normalized and a uniform upscale leaves them unchanged; that is the same invariance `map.rs`'s `art_scale` has to *correct* for, and only because `GetMapOverlays` publishes absolute pixels. `C_MapExplorationInfo.GetExploredMapTextures` is the same `WorldMapOverlay` rows and the same reveal gate the stock window draws (`WorldMapFrame.lua:107-160`), in the shape a canvas reads. **Not built, listed rather than stubbed**: `GetMapPosFromWorldPos` (an inverse search over every rect, no caller), `GetMapGroupID`/`GetMapLinksForMap`/`GetBountySetIDForMap` (about maps this client has no concept of) and `C_MapExplorationInfo.GetExploredAreaIDsAtPosition`. A member answering a plausible-looking nothing is worse than an absent one, which `if C_Map.X then` can see. **One harness gap closed with it**: `seat_a_session` now seats the player a world *position* and the per-frame map feed the live client pushes every frame, because every verb that answers *where* the player is reads the position rather than the zone name — with none answering, a map addon draws its art and no player arrow, which the survey's "drew something" column cannot tell from drawing nothing. Corpus roster unchanged by both: 85 ok / 1 fail, digest identical | | Texture masking, the later-era texture dials, `CreateMaskTexture`, `SetPropagateKeyboardInput`, `EditBox:HasFocus`/`SetCursorPosition`, the frame and font-string pools, `Ambiguate`, `IsModifiedClick`, `GetQuestDifficultyColor` — **what a canvas map draws with** | done | **The mask pair is the one that matters and the one nothing can stand in for.** 1.12 has no texture masking at all — its single masked thing is the minimap disc, cut engine-side — and no idiom reaches the same end, because the whole point is that a *rectangular* texture draws as a non-rectangular shape. A seamless world map is that operation and nothing else: a zone's art is a rectangle of parchment and the map wants it cut to the zone's coastline, so without it every zone draws as a paper rectangle over its neighbours' oceans and labels. The consumer's own changelog names the symptom ("no overlapping parchment rectangles / duplicated sea labels"), and it calls `AddMaskTexture` **unguarded** (`Map/Close.lua:162`). It was affordable because the render pass already had the slot: `UiQuad::mask` is a **screen-anchored** alpha mask, built for the minimap's fixed circle with tiles panning under it — which is exactly `AddMaskTexture`'s semantics, since the mask is another region with its own anchors and the subject keeps only the fragments its alpha covers *at that place*. So the work was plumbing, not a new material: `RegionData::masks` holds the handles, `extract` resolves the first that has both art and a resolved rect, and the host converts it through the same y-flip as the ScrollFrame clip. **One mask per quad, stated rather than hidden**: widening it is a second texture binding and a second uniform on every UI quad in the client. The list is kept whole anyway, because the methods must round-trip and because the *order* decides the winner — first added, which for a stitched map is the land mask, the one that shapes the coast. A second mask is recorded and not applied. Two traps are pinned by tests: a mask region is **skipped at extract rather than marked hidden**, because its rect is what it covers and hiding it would take it out of the layout too; and a mask whose art the archives lack drops the **mask**, not the quad — honouring zero coverage would blank the art it was meant to shape, which reads as the map's own art failing to load. **`SetRotation` on a Texture** was almost free: 1.12 registers it on PlayerModel alone (`0x84f1fc`) but the renderer has carried `RegionData::rotation` since the minimap arrow, so this is the Lua setter for an angle the quad path already negates for the pass's clockwise convention. The map's player arrow and compass rail need it. **`SetDesaturation`** is the fractional form of 1.12's own `SetDesaturated`, rounded onto the one material flag this renderer has (it greys all-or-nothing, not by weight) with the getter answering the caller's own fraction — an approximation, and the honest one, since the only corpus use is dimming art the caller has already decided to dim. **`SetHorizTile`/`SetVertTile` are applied, not stored**: 1.12 reaches the same end with `SetTexCoord(0, n, 0, 1)`, the idiom this renderer already keys its wrap mode on, but has no switch meaning "however many times it takes" because that count is `rect / texels` and 1.12's Lua is never told the texel size — so the flag rides across and the host computes the count once the art has loaded, with an explicit `SetTexCoord` winning. **`SetPropagateKeyboardInput` is stored, not applied**, and the reason is that 1.12's answer is already always-propagate: its key routing hands the key to the binding system regardless of what a handler did, so there is no swallow mode for `false` to select. Refusing it costs a caller its whole key path — `Map/Sketch.lua:579` arms a catcher frame and flips the flag *inside its own handler*, so a nil method raises once per keypress. **`EditBox:HasFocus` publishes a fact this client already tracks** (`Model::focused_editbox`, the reference's own `DAT_00cf4dc8`) and simply never exposed; it is called unguarded inside an `OnEditFocusLost` callback, so a nil method raised every time a search box lost focus. `SetCursorPosition` lands through `HighlightText`'s own clamp and UTF-8 snap with the selection collapsed, so there is one definition of where a caret may sit. **`CreateFramePool`/`CreateFontStringPool`** supersede the "not built: nothing calls either" note in `pools.rs` — a canvas map pools a frame per pin and a FontString per label, and calls both at file scope the way pfUI calls `CreateTexturePool`. Upstream's mixin and default-resetter names are kept, because a caller passing `FramePool_HideAndClearAnchors` **by name** needs them. The frame factory names its frames `--` as upstream does, which matters: a pooled frame has to be reachable by name for a template's `$parent` children to resolve. **`IsModifiedClick` composes two verbs this client already answers** rather than adding state: 1.12 has the modified-click *bindings* (`GetModifiedClick`/`SetModifiedClick`) and its own FrameXML tests the key itself, but registers no predicate doing both steps. `GetQuestDifficultyColor` is a **forwarder** to 1.12's own FrameXML `GetDifficultyColor`, so the colours are the player's chain's and the two spellings cannot drift. `Ambiguate` is the identity on the part that matters — 1.12 has no connected realms, so a name here carries no realm — with the `-Realm` split still applied, because a custom realm can hand out a hyphenated name. **Three stay unbuilt, with reasons**: `GetUnitSpeed` (1.12 sends no speed field for any unit, so the only honest answer is the absent one — and its caller already falls back to a distance-over-time estimate of its own, which is a better number than anything this could invent), `GetAddOnMemoryUsage`/`UpdateAddOnMemoryUsage` (per-addon attribution needs a per-addon Lua allocator this VM does not have; both callers are guarded and print a debug line), and `Texture:SetAtlas`/`C_Texture.GetAtlasInfo` (1.12 ships no atlases, and every call site here is gated on `GetAtlasInfo` answering, so the absence *is* the working path). Corpus roster unchanged: 85 ok / 1 fail, digest identical; the 12 data-half reds are byte-identical to HEAD's | -| The Era **map canvas** (`assets/ui/MapCanvas.lua`), `Frame:CreateLine`, `C_Texture.GetAtlasInfo`, `Frame:IsMouseOver` — **openmap draws** | done | **What 1.12 withholds here is a framework, not a verb.** Its world map is a fixed 1002x668 frame showing one zone: twelve detail tiles, a hit button over them, POI buttons in its coordinates, two dropdowns. No canvas, no pan, no zoom, no pins, and no notion of where one map sits on another. Every map addon written after 4.0 drives the *canvas* framework instead — a scaled, pannable child frame, pins acquired from pools at named frame levels, and data providers notified on every map, view and scale change — and there is no stock 1.12 file to migrate from because there is no stock feature. Same footing as `CompatTemplates.xml`: a FrameXML-era name addons reach for, which this client provides or the addon does not run. **The one constraint that shaped every decision: `WorldMapFrame` already has eight consumers on this install** (Atlas-CFM, FlightMap, LevelRange-Turtle, pfQuest, pfUI, pfUI-locplus, SoftSpoken, SoftSpoken_Places), all 1.12 addons that anchor icons to `WorldMapDetailFrame` and `WorldMapButton` and read the `(continent, zone)` selection. So nothing is replaced: the canvas is **added** as `WorldMapFrame.ScrollContainer` and reparents those two frames **into** itself. At rest it is the rect they had, at scale 1 with no pan, so the stock window is pixel-identical — and the payoff is that the 1.12 art and every icon anchored to it now pan and scale **with** the canvas for free, which no amount of per-addon work could achieve. `SetMapID` moves the 1.12 selection too (`BenillaSelectMapID`/`BenillaMapIDForSelection`, the one join between the selection and the graph), so the two can never show different places. **Clipping comes from a real ScrollFrame**: Era's container is a plain Frame with `SetClipsChildren`, which this client has no verb for, but it clips a ScrollFrame's scroll child *and every descendant of it* from one shared source (`script/clip.rs`) in both the draw pass and the pointer hit-test — so the canvas is a ScrollFrame and drawing and hitting agree. **The base-art layer is a proxy, not the frame.** A canvas addon hides `detailLayerPool`'s layers while it draws its own; `Hide()` on `WorldMapDetailFrame` would take the POI buttons and every addon's icons down with it, because they are its children and its siblings' children. Switching off its BACKGROUND and ARTWORK **draw layers** removes exactly the stock tiles, the exploration overlays and the hover highlight — regions, all three — and leaves every child frame drawing. **Three regressions found by measurement, each pinned by a test, each of which looked like something else.** (1) *The anchor cycle*: anchoring the container to the detail frame and then reparenting that frame into the container's child is a cycle, and a cycle resolves to nothing — the symptom was `WorldMapButton:GetCenter()` answering nil inside the stock hover pass, a raise in the client's file. (2) *The base scale*: a layer whose `minScale` is below 1 opens the map zoomed **out** of its own window, measurable as a 300x200 canvas floating in a 1002x668 container; the canvas is sized to the layer and the container is the rect that layer filled, so 1 is already the whole map. (3) *`dataProviders` is a set, not an array*: callers iterate it with `for provider in pairs(...)`, which binds the **key**, so an array handed the loop the number 1 and the next line indexed a number — inside the addon's own startup, which caught it, printed it and left the whole addon disabled with no error anywhere. The same trap `pools.rs` already names for `activeObjects`. **`Frame:CreateLine`** is a new region shape and nothing stands in for it: a line between two *moving* points is a rotated quad whose angle and length both change every frame. It is a Texture carrying two endpoints (`RegionData::line`) rather than a `RegionKind` of its own, because callers paint a line with `SetColorTexture` and `SetVertexColor` exactly as they paint a texture; it draws through `UiQuad::corners`, the explicit-corner path the cooldown wedge already uses. Its rect is the box of its ends plus half the thickness, so `GetLeft` and the clip have something true to read. **`C_Texture.GetAtlasInfo` answers nil for every name, and the nil is the answer** — 1.12 ships no atlas table and no atlas sheets — but registering the *getter* is what makes the common guard `not C_Texture or not C_Texture.GetAtlasInfo or C_Texture.GetAtlasInfo(name)` take its fallback branch instead of calling `SetAtlas` on a client that has no atlases; with the namespace absent the first two disjuncts are true, and that raise is what disabled the addon one step earlier. **`Frame:IsMouseOver`** is a method spelling of FrameXML's own `MouseIsOver`, which 1.12 has as a function and 2.0 moved onto the widget. **The harness grew a second surface, and that is a measurement finding worth keeping.** 1.12's world map is a `UIPanelWindows` entry with `area = "full"`, and its own `ShowUIPanel` **hides `UIParent`** — so while the map is up nothing else in the interface draws at all. Opening it at the end of the ordinary render probe dropped the corpus's "drew something" column from 37/86 to **18/86**, and the nineteen it lost were ordinary addons whose frames hang off `UIParent` (minimap buttons, a threat meter, an auction helper), none of which had changed. So the map is its own pass, read after the ordinary one and unioned in, and the stock map is opened and closed once **before** the render baseline so the regions its first update creates lazily (`WorldMapOverlay*`, `WorldMapFramePOI*`) are the client's and not the surveyed addon's. Per-addon result: **no addon lost a single quad and nineteen gained** — pfQuest 374 → 911, pfUI 1063 → 2259, ShaguDPS 363 → 938, Atlas-CFM 294 → 410, and SoftSpoken_Places moved verdict from `overlay(3)` to `own(50)`. That whole surface had simply never been driven. **Result**: OpenMapClassic now reads 1/1 on all five columns — loads, session start, UI probe, **drew something**, **survived being used** — with the stitched map active over **74 of the realm's own zones**, its custom ones included (the harness's seated zone resolves to `102040`, a minted id for Alah'Thalas). Corpus roster unchanged at 85 ok / 1 fail; the 12 data-half reds byte-identical to HEAD's | +| The Era **map canvas** (`assets/ui/MapCanvas.lua`), `Frame:CreateLine`, `C_Texture.GetAtlasInfo`, `Frame:IsMouseOver` — **openmap draws** | done | **What 1.12 withholds here is a framework, not a verb.** Its world map is a fixed 1002x668 frame showing one zone: twelve detail tiles, a hit button over them, POI buttons in its coordinates, two dropdowns. No canvas, no pan, no zoom, no pins, and no notion of where one map sits on another. Every map addon written after 4.0 drives the *canvas* framework instead — a scaled, pannable child frame, pins acquired from pools at named frame levels, and data providers notified on every map, view and scale change — and there is no stock 1.12 file to migrate from because there is no stock feature. Same footing as `CompatTemplates.xml`: a FrameXML-era name addons reach for, which this client provides or the addon does not run. **The one constraint that shaped every decision: `WorldMapFrame` already has eight consumers on this install** (Atlas-CFM, FlightMap, LevelRange-Turtle, pfQuest, pfUI, pfUI-locplus, SoftSpoken, SoftSpoken_Places), all 1.12 addons that anchor icons to `WorldMapDetailFrame` and `WorldMapButton` and read the `(continent, zone)` selection. So nothing is replaced: the canvas is **added** as `WorldMapFrame.ScrollContainer` and reparents those two frames **into** itself. At rest it is the rect they had, at scale 1 with no pan, so the stock window is pixel-identical — and the payoff is that the 1.12 art and every icon anchored to it now pan and scale **with** the canvas for free, which no amount of per-addon work could achieve. `SetMapID` moves the 1.12 selection too (`BenillaSelectMapID`/`BenillaMapIDForSelection`, the one join between the selection and the graph), so the two can never show different places. **Clipping comes from a real ScrollFrame**: Era's container is a plain Frame with `SetClipsChildren`, which this client has no verb for, but it clips a ScrollFrame's scroll child *and every descendant of it* from one shared source (`script/clip.rs`) in both the draw pass and the pointer hit-test — so the canvas is a ScrollFrame and drawing and hitting agree. **The base-art layer is a proxy, not the frame.** A canvas addon hides `detailLayerPool`'s layers while it draws its own; `Hide()` on `WorldMapDetailFrame` would take the POI buttons and every addon's icons down with it, because they are its children and its siblings' children. Switching off its BACKGROUND and ARTWORK **draw layers** removes exactly the stock tiles, the exploration overlays and the hover highlight — regions, all three — and leaves every child frame drawing. **Four regressions found by measurement, each of which looked like something else.** (1) *The anchor cycle*: anchoring the container to the detail frame and then reparenting that frame into the container's child is a cycle, and a cycle resolves to nothing — the symptom was `WorldMapButton:GetCenter()` answering nil inside the stock hover pass, a raise in the client's file. (2) *The base scale*: a layer whose `minScale` is below 1 opens the map zoomed **out** of its own window, measurable as a 300x200 canvas floating in a 1002x668 container; the canvas is sized to the layer and the container is the rect that layer filled, so 1 is already the whole map. (3) *`dataProviders` is a set, not an array*: callers iterate it with `for provider in pairs(...)`, which binds the **key**, so an array handed the loop the number 1 and the next line indexed a number — inside the addon's own startup, which caught it, printed it and left the whole addon disabled with no error anywhere. The same trap `pools.rs` already names for `activeObjects`. (4) *The hovered-zone label rode into the canvas*: `WorldMapFrameAreaFrame` hangs off `WorldMapButton` in 1.12's own XML, so reparenting the button put it inside the **zooming** child — and its label is a 102 px THICKOUTLINE FontString, so at zoom 2.5 every letter is a 261 px glyph. Measured in grom's 2026-11-02 run: one `ui_text: a 261.10907 logical-px request … past the 256 ceiling` and then **20 `glyph sheet full … resetting at the frame boundary`**, one per hover, each costing every string on screen a frame — the map "flashing the interface". It is chrome, not map content, so it goes back on the window anchored to the container's top, which at rest is the pixel it was already at. **`Frame:CreateLine`** is a new region shape and nothing stands in for it: a line between two *moving* points is a rotated quad whose angle and length both change every frame. It is a Texture carrying two endpoints (`RegionData::line`) rather than a `RegionKind` of its own, because callers paint a line with `SetColorTexture` and `SetVertexColor` exactly as they paint a texture; it draws through `UiQuad::corners`, the explicit-corner path the cooldown wedge already uses. Its rect is the box of its ends plus half the thickness, so `GetLeft` and the clip have something true to read. **`C_Texture.GetAtlasInfo` answers nil for every name, and the nil is the answer** — 1.12 ships no atlas table and no atlas sheets — but registering the *getter* is what makes the common guard `not C_Texture or not C_Texture.GetAtlasInfo or C_Texture.GetAtlasInfo(name)` take its fallback branch instead of calling `SetAtlas` on a client that has no atlases; with the namespace absent the first two disjuncts are true, and that raise is what disabled the addon one step earlier. **`Frame:IsMouseOver`** is a method spelling of FrameXML's own `MouseIsOver`, which 1.12 has as a function and 2.0 moved onto the widget. **The harness grew a second surface, and that is a measurement finding worth keeping.** 1.12's world map is a `UIPanelWindows` entry with `area = "full"`, and its own `ShowUIPanel` **hides `UIParent`** — so while the map is up nothing else in the interface draws at all. Opening it at the end of the ordinary render probe dropped the corpus's "drew something" column from 37/86 to **18/86**, and the nineteen it lost were ordinary addons whose frames hang off `UIParent` (minimap buttons, a threat meter, an auction helper), none of which had changed. So the map is its own pass, read after the ordinary one and unioned in, and the stock map is opened and closed once **before** the render baseline so the regions its first update creates lazily (`WorldMapOverlay*`, `WorldMapFramePOI*`) are the client's and not the surveyed addon's. Per-addon result: **no addon lost a single quad and nineteen gained** — pfQuest 374 → 911, pfUI 1063 → 2259, ShaguDPS 363 → 938, Atlas-CFM 294 → 410, and SoftSpoken_Places moved verdict from `overlay(3)` to `own(50)`. That whole surface had simply never been driven. **Result**: OpenMapClassic now reads 1/1 on all five columns — loads, session start, UI probe, **drew something**, **survived being used** — with the stitched map active over **74 of the realm's own zones**, its custom ones included (the harness's seated zone resolves to `102040`, a minted id for Alah'Thalas). Corpus roster unchanged at 85 ok / 1 fail; the 12 data-half reds byte-identical to HEAD's | ## The offline UI-load harness — why 349 tests were failing, and what is fixed @@ -518,6 +518,49 @@ bugs rather than display ones. | `ColorPickerPlus`: two "not found" warnings | **not a bug** | `ColorPickerPlus.toc`'s `## Notes:` wraps onto two unprefixed continuation lines, and a TOC line that does not start with `#` is a file entry. The reference's parser does the same thing and fails to find them just as silently; the addon loads and runs regardless (`ColorPickerPlus.lua ran`). A malformed TOC in the addon, not a loader fault — worth leaving loud rather than teaching the parser to guess which unprefixed lines are prose | +## The `MOD` blend, and the fullscreen tint that drew white + +`SetBlendMode("MOD")` is EGxBlend 4, `glBlendFunc(GL_DST_COLOR, GL_ZERO)`: the quad **multiplies** +what is already in the target. The UI pass used to draw it as straight alpha — one pipeline served +BLEND and ADD, the shader emitting `(rgb·a, a)` or `(rgb·a, 0)` into a single +`(One, OneMinusSrcAlpha)` state — because no scalar source alpha can stand in for a per-channel +destination factor, and the mode had no caller worth a second pipeline. + +It had one. **Nine of Cinematic's ten colour-tint presets are MOD** (`Tint.lua:21`), and the tint is +a fullscreen texture over `UIParent` at BACKGROUND strata. Its colour is built to be multiplied — +`grade()` returns `1 − (1 − c)·strength` with **alpha 1**, i.e. white at strength 0 and the tint +colour at strength 1 — so drawn with straight alpha instead it is an *opaque near-white quad over +the whole screen*. Measured in grom's 2026-11-02 recording: 16.8 s of `srgb(255,255,255)` across +the world, the UI still drawn on top of it, starting the instant he picked a profile. + +Fixed where the blend belongs, in the pipeline: `UiQuadMaterial` now carries a `modulate` flag that +is a **specialization key** as well as a uniform, and `specialize` gives it +`src_factor: Dst, dst_factor: Zero` with the destination's own alpha kept — the same state, off the +same `0x70c190` table, that the world's Mod batches already use (`benilla-assets/src/materials.rs`). +The shader returns the multiplier un-premultiplied, because neither alpha appears in that formula. +Runs no longer merge across the flag, so a MOD quad gets its own batch. + +Two more faults sat on the same path, each enough on its own to white the screen out, and both are +fixed with it: + +- **A colour fill and a gradient were alternatives, and the fill won.** `extract.rs` read + `data.fill.or_else(|| data.gradient.map(midpoint))`, so a region that set both drew the fill and + the gradient was dropped. They are not alternatives: `` writes the four **vertex + colours** (`+0xb8`, `0x77304d`), which `loader/regions.rs` already reads as "unlike `` it + tints the art". A region carrying both now draws their product. Cinematic's tint is exactly that + shape — `SetColorTexture(1, 1, 1, 1)` once, re-gradiented a few times a second — and so is its + vignette, whose four edges were drawing as **opaque white bars**. +- **`SetGradient` ignored retail's colour-object form.** 1.12 takes six loose numbers; retail takes + `(orientation, fromColor, toColor)`. This client answers `CreateColor`, so an addon that + feature-detects takes the object path and never tries the numbers — Cinematic's helper does + exactly that, inside a `pcall` whose success *is* the feature test (`Tint.lua:546`). The stops + each coerced to 0 through `as_f32`, the call still **succeeded**, and the gradient it set was + black. Both spellings now read a colour object when the first stop is a table. + +The lesson is the shape of it: a compat layer that answers `CreateColor` has promised the rest of +that era's call shapes too, and a blend mode the parser accepts but the renderer ignores is a +silent lie that only shows up as something enormous. + ## A wgpu validation error no longer ends the session A `pbr_prepass_pipeline` failed shader-interface validation 42 minutes into a session, right