56fb28fa2b7fa45a6cc067ea147dc94a7cf08d4c
1532
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
56fb28fa2b |
gates: clippy clean again, and the mail repro that never was for commit
`scripts/check.sh` has been failing before it ran a single test, on 62 clippy findings in code none of the recent rounds touched — 32 in `benilla-world`, 30 in `benilla-app` — so the in-loop gate could not tell anyone anything. Every one is a newer clippy noticing older code (`as_chunks` and `is_multiple_of` are both lints younger than the lines they fire on), and they are fixed, not silenced, except where the silence is the point: - **Argued allows, the house idiom.** `clippy::type_complexity` on ten Bevy system signatures and two inline caster queries, each with the one-line reason beside it, as the other 67 in this workspace have. `clippy::approx_constant` on `liquid/waves.rs`'s `TWO_PI`, whose own doc already says why it is spelled `6.2831853`: it mirrors a shader line, and `f32::consts::TAU` would stop reading against the file it copies. - **Two runtime assertions became compile-time ones** (`EXT_SEL_EMPTY is index 255`, the endless-stream frame count): a claim about constants should fail the build that breaks it, not a test run after it. - **Three real, if harmless, redundancies gone**: `len > MAX_TILT_TAN && len > 0.0` (the constant is `tan(4°)`, so the second test never spoke), `u32::from(..) + 1 | flags` now parenthesised where `+` binds tighter than `|`, and `palette.get(0)`. - The rest is mechanical: `chunks_exact(3)` → `as_chunks::<3>()`, `map_or(true, ..)` → `is_none_or`, two `Default::default()`-then-assign test fixtures, a doc line that began `1.` and so turned its own paragraph into a list item, and `local_utc_offset_secs` moved above the test module it had drifted below. `tests/repro_mail.rs` is deleted. Its first line says "TEMPORARY repro harness — not for commit"; it reads a loose mirror of FrameXML out of `/tmp/opencode/ vanilla-ui`, which no longer exists, so it has been failing on a missing directory rather than telling anyone about mail. What is left of the gate's red is one test: `world_api_wall` counts 174 crossings of the `benilla-world` boundary against a ceiling of 107 — 90 of them already filed with a close plan, 84 unsorted. That is not cleanup; the wall's own message says the remedies are to close the crossing or to publish it as engine API with the record that says so, and 84 of those is a design pass on what the engine's public surface is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
3e616e5cb3 |
reload: one VM and no flush storm, 615 ms off every rebuild
Two costs read straight out of the player's own `WOW_UI_HANDLERS=1` session log (2026-10-18 20:33), where a `/reload` blocks the main thread for 7.84 s. **The saved-variables flush, 305 ms.** The reload's first act is to write every loaded addon's saved variables, one file each, and `write_atomic_bytes` ends each with `sync_all()` — a disk round trip per file, 60 of them, inside the one blocked frame. Measured on this project's own btrfs home: **327 ms flushed, 6 ms not**, which is the +27→332 ms the log spends between `ReloadUI — ending the UI session` and the last `wrote …/saved/*.lua`. `write_atomic_bytes_bulk` keeps the temp file and the rename (so a file is whole or absent) and gives up only a power loss in the seconds after; the reference writes these with plain buffered CRT writes and a `.bak` rotation whose result it discards, so this is still the stronger of the two. **A whole VM built and thrown away, 310 ms.** `end_ui_session` installs a fresh boot VM, which is right for the roots that stop there — the character screen runs on it — but `run_pending_reload` then calls `load_ingame_ui_on_world_entry`, which mints its own in `mint_entry_vm`. So every reload ran `UiScript::new`, `GlobalStrings.lua`, the emote table and the font registry twice and let the first one go without running a line: the log prints `GlobalStrings loaded` at +350 ms and again at +662 ms. `end_session` now takes what to leave behind, and the reload leaves the dying VM until the mint replaces it — safe because the two calls are one exclusive system with nothing in between, the old VM is still dropped before any file loads, and `fold_dying_vm_cvars` drains what it folds, so the mint's second fold of the same VM finds nothing. Only when the rebuild is certain to mint: with no UI wanted (a capture) the load returns early and the world would be left with no VM at all. `chunkbench` is the instrument the next item needs, and the one that priced this one's neighbours: it runs a file list three ways — a VM per file, one VM with the stock collector, one with it stopped — so parse, execute and the GC's share are separate numbers. Over his enabled addon set (1,471 files, 70 MiB) the parse is 703 ms against 74 ms to compile the same chunks from bytecode, which is what a bytecode cache would be worth; the collector is not the problem (829 ms against 731 ms stopped), and pfQuest's 23 database files are 417 ms and 110 MiB of heap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
089990a929 |
chain: the icon catalog lists its own directory, not all 102,144 names
Profiled off the player's own 2026-10-18 session log, where one blocked frame ran 7,725 ms: the file loading is 4,505 ms of it (pfQuest's `init\data.xml` alone 695 ms, pfQuest-octo's 311 ms, all of FrameXML 693 ms), then `load_icon_catalog` 396 ms, the random-property table 259 ms, and the first VM tick the remaining ~2.3 s. This takes the icon catalog. It walked `Chain::list()`, and the walk is what the catalog costs — the module says so itself: classifying the three buckets out of the names is 1.4 ms. Per name a full list allocates a normalized dedup key, hashes it into a 102k-entry set, resolves the winning archive and asks it for a size; the prefix this caller wants was tested *after* all of that, on every name in the chain. So `Chain::list_under(prefix)` tests it first, with the same case-insensitive, separator-blind compare the caller was doing for itself. Measured on this install, and the test prints the pair it quotes: 102,144 entries in 471 ms listed whole against 51 ms for the 3,205 under `Interface\Icons\`, the same entries and the same sizes either way. 420 ms off every login, and off every `/reload`, which rebuilds the catalog for the new VM. Nothing else in the client lists the chain; `benilla-extract` does and wants the lot, so `list()` keeps its name and its body, now shared. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
101ddc3d97 |
tests: the UiScript wall's ceiling follows the four files that argued past it
`ui_script_wall.rs` has been failing since `2af91704`, 888 commits ago: 43
gameplay files name `UiScript` against a ceiling of 40. A ratchet that cannot
pass notices nothing, which is the one thing it is for — so the ceiling
follows the count, with each newcomer argued in place, as the test's own
message asks.
Three of the four do not cross the line the wall draws:
- `spell/net.rs` names the identifier only to say it holds none — three doc
comments explaining why its work is queued instead. The count reads doc
comments by design, so documenting the line reads as crossing it.
- `target/by_name.rs` and `target/scan.rs` hold the VM because `TargetByName`
and the scan must select inside the Lua call as `0x489d60` does; that is
`bbe2e3aa`, without which an addon's own target restore cannot see what it
selected.
- `debug_panel/{mod,lighting_controls}.rs` write CVars through
`UiScript::set_cvar_engine`, the queue Lua's `SetCVar` feeds, so the dev
panel cannot hold a different value than the interface.
Nothing moves and nothing is excused wholesale: the next file to learn that
the VM exists still fails this test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
2a51c9817a |
frames: EnumerateFrames, the walk over every frame the client has
A real 1.12 global we did not have (`reference/1.12-globals.tsv`), and the natural way for an addon to ask the live UI which frames carry some script or property — the player's backlog item, raised because nothing answered. Read off `0x705f60`. The frame manager owns a Storm list (`[0xcf0bd8]+0xcc0`, link offset `0x304` written by its ctor at `0x7641b8`) and the `CSimpleFrame` ctor links every new frame through `0x7646a0`, which puts the newcomer in the sentinel's own slot `[0xcc4]` and the frame it displaced in the newcomer's `+0x308`. So the list is walked **newest to oldest**: with no frame the binding answers `[0xcc4]`, with one `[frame+0x308]`, and the first frame ever created holds the tagged sentinel `&head|1` there, which is the `test $0x1,%al` that ends the walk with nil. Three details that are not guesses: - The only type test is `lua_type(L, 1) == LUA_TTABLE` (`cmp $0x5`), so a number, a string or a boolean is not an error — it falls through to the head and starts the walk over. - A table is resolved through `T[0]` exactly as our `decode_id` does, and both rejections raise through `luaL_error` (`0x6f4940`, which never returns) with the client's own wording: `0x872f08` for a table with no object behind it, `0x872ecc` for one that is some other kind of object — a region, which is not in this list at all. - Regions and the walk's order aside, hidden and anonymous frames are in it: the link is the ctor's, not the draw list's. The arena grows the list it already implied. `Frame::insertion_seq` could not serve: that is the draw-order stamp, and a `Show` or a strata change restamps it (`resequence_to_tail`), where creation order never moves. So `created` is the handle list in creation order with `created_seq` the frame's index into it, and a step is one lookup. A destroyed frame's slot is kept, not compacted — its handle stops resolving and the walk skips it — because an index shift would break every other frame's `created_seq`, and nothing in play destroys a frame anyway, as nothing in 1.12 can. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
780ca25833 |
npc: the interaction token reaches the VM before the window's own event
HCQuest raised `bad argument #1 to 'strupper' (string expected, got nil)` at `HCQuest.lua:16` once in the player's 2026-10-16 session, from `questDetail` on `QUEST_DETAIL`. Its three arguments are `GetTitleText()`, `QuestFrameNpcNameText:GetText()` and `GetObjectiveText()`, and the `and` short-circuit means the giver is only read for a quest whose title matches one of the addon's 49 — which is why it fired once and not on every quest. So the nil is the name string, and `QuestFrame_SetPortrait` fills it from `UnitName("npc")` (`QuestFrame.lua:63`). `feed_interact_npc` seats `InteractNpc` before every feed, but the `"npc"` token only reaches the VM in `feed_units`, and `feed_units` and `feed_quest` are two systems in one `UiFeed` with no edge between them: the order was declared by nothing, so `QUEST_DETAIL` could be signalled into a VM whose `"npc"` was still absent. `SetText(nil)` then leaves the string empty for the life of the panel, and the stock quest frame shows no giver name either. Five window feeds were in that position; each now declares `.after(crate::ui_unit::UnitFeed)`, as `feed_auction` and `feed_trainer` already did, with the stock line that reads the token in the comment: quest, gossip (where the chain's own `Turtle_ArenaUI.lua:285` and `Turtle_GuildBankUI.lua:378` gate on `UnitName`/`UnitExists("npc")`, so a late token means the window simply never opens), bank, taxi, tabard. The reference needs no such edge: `CGGameUI::SetInteractNPC` writes `[0xb4e2d0]` on the interact itself, so the pointer is live before any window's `SignalEvent`. The test reads the declared graph and names the FrameXML site per window, so a new window that reads the token and forgets the edge fails with the line it reads it at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
a2909fb0df |
ui_text: a font file answers to a family of its own, so no sibling stands in
Naming a face to `cosmic-text` is a family query, and `fontdb` matches a family by exact name alone: two files whose name tables declare the same family are one family to the shaper, and whichever it reaches first answers for both. The player's AddOns folder is full of that — four addons ship the same `Myriad-Pro.ttf` and three the same `Hooge.ttf`, which is where our own substitution warning came from three times a session — and in one pair it is not even the same design: `pfUI-CustomMedia\fonts\NiceDamage.ttf` and `MikScrollingBattleText\Fonts\pepsi.ttf` both declare `! PEPSI !`, so one of them draws the other's letters at the other's widths. So each file is re-registered under `benilla:<lowercased path>`, unique for the same reason `path_to_face` is keyed that way. The face's own axes are kept, and the shaper's per-character fallback is untouched — it walks the whole database and needs no family match — so the substitution warning now means only what is left: this face has no glyph for that character, and it says so. The test registers one real file at two paths and asserts each answers for itself; its second half is the behaviour that makes the rename necessary, `fontdb` answering with the first face for both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
df921cdb47 |
creature: a display that names a model is drawn, whatever its scale column says
A screenshot of the Gnomeregan Reclamation Facility (OctoWoW's own area 5211) with its NPCs replaced by flat grey boxes, and the target frame filled — `M. C. Volterwhite`, 57, 4.13k — behind an empty portrait. The boxes are `entities::attach`'s model-less debug cube, and the empty portrait is the same cause: the display resolved to nothing to draw. `new_creature_display` took a row only `if m.scale > 0.0`, where `scale` is `CreatureModelData.ModelScale × CreatureDisplayInfo.CreatureModelScale`. That column does not scale a spawned unit: the server folds it into `OBJECT_FIELD_SCALE_X`, which is why `Creatures::model_scale` says in its own doc that the glue screens' pet is its only reader. So the gate was testing a number nothing downstream consults, and a `0.0` in it meant "no model". His chain's `CreatureDisplayInfo.dbc` (the effective copy, `patch-A.mpq` — patches sort ascending by lowercase name, so A wins over 5) carries a contiguous custom block, **20256-20356, 100 rows, every one a character model**: 27 HumanMale, 16 HumanFemale, 16 TrollMale, 12 GnomeMale, 10 NightElfFemale and the rest, all authored with that column left at `0.0`. One batch of custom NPCs, one zone's worth of grey boxes. Stock 1.12 ships three of its own that way too (displays 16, 17 and 19: HumanThief, HUMLBlackSmith, HUMLMerchant), which is the proof the column was never a visibility switch. The gate is gone. The one shape left that earns a cube — a display id with no row in either DBC — now says so once per id, as the GameObject leg already does, so the next zone full of cubes names the ids instead of needing a screenshot. A test over the real chain pins the data: every zero-scale row still names a real model, and the only row that answers `None` does so for want of a `CreatureModelData` row (display 185, `ModelID = 0`), not for its scale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
e527931885 |
worldmap: WORLD_MAP_UPDATE is signalled inline, so the map opens on the player's zone
The world-map flash, and it is one frame of the wrong paint rather than a wrong selection. `WorldMapFrame.xml`'s OnShow runs `SetMapToCurrentZone()`, and `WorldMapFrame_OnEvent` answers `WORLD_MAP_UPDATE` with `WorldMapFrame_Update()` **only** `if ( this:IsVisible() )`. Our `store_selection` pushed that event on the deferred lane, so it was heard a tick later — by which time the panel was already on screen, still carrying whatever tiles the last `WorldMapFrame_Update` left. Those are the World sheet's: `WorldMapFrame_OnLoad` runs its own `WorldMapFrame_Update` before the player's zone is known, and a nil `GetMapInfo()` takes FrameXML's own `-- Temporary Hack` branch, `mapFileName = "World"` (`WorldMapFrame.lua:47-55`), painting `Interface\WorldMap\World\World1..12` into the twelve detail tiles. Signalled inline instead, as the setter `0x4a67a0`'s own `SignalEvent` is, so the repaint happens inside the OnShow and the first frame drawn is the player's zone. Signalling moves out of `store_selection` — the writer cannot do it, only a caller holding the Lua state can — and each of the four call sites does it: `SetMapZoom`, `SetMapToCurrentZone`, `ProcessMapClick` (only when the click actually moved the selection, since the setter is the only signal site) and the engine-side world-enter sync. `worldmap_update_event_fires_on_next_tick` asserted the deferred behaviour deliberately, on the reason "a binding cannot re-enter dispatch". That was not so: `UpdateSpells` had already been doing exactly this with the same `fire_event_into`, with its own test pinning the synchronous dispatch. Replaced by one that asserts the inline fire and one that covers the two other legs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
8e0d1bae8d |
lua: a numeral ends where its digits do, so 1then is two tokens
`AdvancedTradeSkillWindow2.lua:3878` is `if Item.ReplaceWords == 1then`, with no space, and it compiles on the reference. Its `read_numeral` (`0x6ffee0`) is 5.0's: digits, at most one `.` and its digits, then — given an `e`/`E` — the marker, an optional sign (the `0x2b`/`0x2d` test at `0x6fff04`, reached with the marker already saved) and its own digit loop. The only character-class test in the whole routine is the ctype digit mask at `0x6fff47`; past it the buffer is terminated and handed to `luaO_str2d` (`0x6f5900`). 5.1 instead ends with `while (isalnum(ls->current) || ls->current == '_') save_and_next(ls);`, which swallowed `1then` into the numeral and failed it — so we raised `malformed number` and lost a 4000-line file and every global it defines, and its own XML then raised four more times calling them. The addon was dead. The ninth hunk of the `lua-src` fork, which is where a grammar difference belongs: `BENILLA.md` is explicit that rewriting the chunk source means parsing Lua with a regex. A replacement, not a deletion — 5.1 had no exponent-digit loop of its own, so deleting the slurp alone would have cost `1e2`, which is what the new test caught. The same boundary is the deliberate half: 1.12 has **no hex literals**. The digit loop stops at the `x`, so the reference reads `0xFF` as `0` and the name `xFF` and cannot parse the statement around it. A comment- and string-stripping scan of the installed corpus finds hex in code in exactly two files, `pfQuest/toolbox/pngLua/deflate.lua` and `numberlua.lua`, which no `.toc` row reaches — zero loaded sites, the same standard the fork's three other deletions were held to. Our own shipped Lua and his saved variables have none either. Two of our own test chunks did, and are now decimal: no chunk of benilla's Lua may use a construct the client's parser rejects. Verified: the whole chain still loads with no failure, the `WOW_DATA` failure set is unchanged, and `AdvancedTradeSkillWindow2` now publishes `ATSW_OnLoad` and `ATSWTypeColor`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
4764e4fb2d |
framexml: the twenty-one rows of the chain's own toc the manifest still lacked
benilla loads `assets/ui/benilla.toc`, not the player's `Interface\FrameXML\FrameXML.toc`, so a row the chain carries and the manifest omits is silently absent — no read attempt, no warning. Six went in with the arena templates; this is the rest of the diff but one, each with a comment on what it is for and in the chain's own order. The six baked Octo extensions are the player-visible half. `/pvp` now asks before a War Mode character spends its permanent, persisted opt-out. The exhaustion tooltip reads this realm's 175% instead of 1.12's baked 150%, and `/rested` answers. A player's hover shows the realm's own two challenges, Samurai and Together Forever: the server was already setting bits 10 and 11, and `TargetFrame_UpdateChallenges` walks a list that stopped at 10. The one-shot War Mode offer on arrival from the hardcore realm renders instead of being dropped. `/bug` and `/suggest` exist again — Turtle's FrameXML dropped the BugReport frame, and this rebuilds the dialog over chunked `.bug` SAY commands. And on C'Thun the Ghost debuff's tooltip carries the hardcore afterlife line, which is why the realm reverted that string in Spell.dbc. With them: `OctoToolReqTip`, the realm's own 40 KB recipe-to-tool table wrapping `GetTradeSkillTools`, so a recipe whose tool OctoWoW changed names its real one; six `OctoLocale` rows, each gated on `GetLocale()` and inert here; and the three Turtle windows the manifest said needed verbs built first — arena (4 rows), barbershop, guild bank (2). They need exactly one, `CameraZoomOut`, now built. All three publish their frames and open only on an exact gossip trigger string, so answering nil for `GetGossipText` cannot hide ordinary gossip. `StatsFrame.xml` is the row left out, and the manifest says so where it would sit: the debug-build overlay whose OnUpdate is the only reader of `GetDebugStats`, a binding we do not have, so the row would publish a `ToggleStats` that raises twice a second once pressed. `UNLEARNED_SPELL` joins `UNPRODUCED` rather than getting a producer. It is the only entry there that is not a 1.12 event at all: 5875's event-name pool carries `LEARNED_SPELL_IN_TAB` and `SPELLS_CHANGED` and no unlearn sibling, so the reference ignores that registration exactly as we do, and the War Mode re-scan rides PLAYER_LOGIN and LEARNED_SPELL_IN_TAB, which both fire. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
f185f3b549 |
camera: CameraZoomIn and CameraZoomOut, and a bare one is a yard
The first two rows of the reference's camera table `0x84f7a0` (`0x50b400`, `0x50b450`), and the one verb the chain's own barbershop, arena and guild-bank windows call that we did not publish — `Turtle_Barbershop.lua:211` zooms the world camera out two yards as its preview opens. Read off the binary rather than guessed, because the argument handling is not the one every sibling in this file uses: both thunks test is-number (`0x6f34d0`), take `tonumber` (`0x6f3620`) when it holds, and otherwise store `1.0f` outright (`0x50b42d`, `0x50b47d`). So `CameraZoomOut()` steps a yard out, where `SetView()` and `FlipCameraYaw()` return silently. A test pins that, since it is exactly what reading the callers would have got wrong. Deviation, named in the module: the reference turns the yards into a timed glide — divide by the rate at `[0xbe0fbc]+0x24`, scale, truncate (`0x40a2b0`) into a step count — and a call during a running glide adds into its pending-zoom field (`0x50fca9`). Our rig holds one target distance and glides to it every frame, so a second call re-aims the same target instead of queueing behind the first. The clamp is the rig's own, the one the wheel and the zoom keys already share. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
cbcaef264e |
target: a new target is answered for before anything reads it
Two orderings let the inspect window re-show itself on a creature and then raise inside
`InspectPaperDollFrame_SetLevel`, where TurtleWoW's three-spec `PLAYER_LEVEL` meets a creature's
nil `UnitRace`.
`feed_unit_reach` now runs ahead of `feed_units`, not behind it. `fire_event` dispatches into Lua
synchronously, so a handler of `PLAYER_TARGET_CHANGED` that asks `CanInspect("target")` was being
answered off the previous frame's reach map — the old player target, in range and inspectable — and
`InspectFrame_Show` showed the window for the creature that had just replaced it.
And `PLAYER_TARGET_CHANGED` now leads the token `UNIT_*` fan-out. The reference has no token diff at
all: re-pointing `"target"` writes no descriptor field, so its watches stay silent and this is the
only event a new target raises. Behind the fan-out, `InspectPaperDollFrame_OnEvent`'s `UNIT_LEVEL`
arm ran against an `InspectFrame.unit` the next event was about to clear. Every snapshot is pushed
before either fires, so a handler reads the new target either way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
981e2ee498 |
framexml: six rows of the chain's own toc the manifest never had
benilla loads `assets/ui/benilla.toc`, not the player's `Interface\FrameXML\FrameXML.toc`, so a row the chain carries and the manifest omits is silently absent — no read, no warning, just a missing global or template surfacing as somebody else's error. Diffing the two found 29 such rows. Six of them land here. `Turtle_ArenaUI\Turtle_ArenaUITemplates.xml` is the one that was costing four errors per inspect: `Blizzard_InspectUI\InspectArenaFrame.xml` inherits `ArenaTeamTemplate` from it and then reads `$parentText`, `$parentDetails` and nine `$parentDetails*` children by name, so all three team-button OnLoads and `InspectArenaFrame_OnShow` raised on nil. `Globals.lua` publishes `print`, `TURTLE_WOW_VERSION`, `TW_CLASS_TOKEN`, `Turtle_AvailableChallenges`, `GAME_YELLOW` and the realm's three `ADDON_MSG_*_DELIMITER`s. `Overrides.lua` is Turtle's two-continent map. `Hooks.lua` is the realm's tooltip layer: the guild line, the shift-click hint and `FixSetInspect`. `ChronoboonTimers.lua` and `SpellChatLinks.lua` are self-contained. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
a1359d8df1 |
gameobject: a nameless or modelless object says so, for the hardcore graves
"note that they are not corpses but actual grave stones from hardcore deaths,
custom twow stuff" — so the corpse plate was never the path, and the three
corpse legs added last commit cannot see this at all. A hardcore gravestone is
one of the server's own GameObjects, which reaches a plate through
`go_inputs.templates` and a mesh pick, not through `CORPSE_FIELD_OWNER`.
Two lines, one per failure shape, each said once:
- a display id that names no model (`entities::display`, built once per id and
cached). This is the quiet one: a GameObject with no model draws nothing
*and* cannot be hovered, because the pick set is built from its mesh parts —
so a custom display whose `GameObjectDisplayInfo` row this chain's DBC does
not carry has no tooltip for exactly the reason it has no art.
- a hovered object with no template yet (`ui_tooltip`), the plate's only
silent exit. A line with no sequel means the
`SMSG_GAMEOBJECT_QUERY_RESPONSE` never came, and a server's own entry is
where that is most likely.
Neither line on the next grave he walks past would mean the object is picked and
named, and the fault is in the plate itself rather than upstream of it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
02265cc224 |
compat: nampower's equipment and item-cache getters, for the character panel
"then we gotta fix bettercharacterpanel, that one is driving me nuts."
BetterCharacterPanel is built on five nampower getters and had none of them.
`GetEquippedItem` was 8 raises in one session of his log; the other four failed
inside the addon's own `pcall`s, so its item-level column, quality colours,
missing-enchant warning and two-hand test were all dead at once. DoiteAuras
reads the same getter in six places.
The spec is the installed `nampower.dll` itself rather than inference, which is
what unblocked this — the earlier note here said to find the upstream spec
first, and the DLL *is* the spec:
- its usage strings give the signatures,
- "Slot must be a number between 1 and 19" gives the band, so the slots are
1.12's own `GetInventoryItemLink` numbering and `NampowerAPI.lua`'s
`slot - 1` in `IsItemInSlot` is that shim's bug, not the contract,
- its string pool gives the key names in order, and
- "Item not found in DBCache" is a raise, which is exactly why the addon
`pcall`s these three and calls `GetEquippedItem` bare.
`itemLevel` is the one field 1.12 cannot reach at all: `GetItemInfo`'s fourth
value is the *required* level, and later clients are the ones that insert
`itemLevel` there — so the panel's item-level column had no possible source
before this. The cache row now carries it, with `buyPrice` and `displayInfoID`
beside it, and an equipped item carries the raw descriptor numbers the tooltip's
resolved enchant lines could not answer (an id whose `SpellItemEnchantment` row
hides its name is still an id).
Fields left absent rather than guessed are listed in code and in
`docs/ADDON_COMPAT.md`; nothing installed reads any of them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
994da2ba85 |
mesh_tag: an interior prop with no probe slot is still indoors
The SH-probe table is 13 tag bits wide and nothing can widen it — the probe field is the last of the u32 — so a dense interior can ask for more slots than exist. His session hit it (`live 8192, peak 8192`), and every prop that missed out was then tagged as plain exterior: furniture lit by the sky, which in a Dun Morogh snowstorm reads as the weather coming indoors. A prop that misses a slot is still *indoors*, and saying so costs no bits. `PropSeat` names the three states a spawner can be in — `Exterior`, `Probe(slot)` and `Matte` — and `Matte` is the exterior payload with `MATTE_INDOOR_BIT` and a saturated lane: the room's day/night law, which is exactly what `interior::AppliedLaw::Matte` already gives an entity whose classification finds the table full. A dim room instead of a sunlit one. The seat also replaces the bare `Option<u16>` the assembler took, so a caller now says which of the three it means rather than leaving `None` to stand for both "sky-lit" and "wanted a probe and lost". Still true: the table can fill, and the props past it lose their room's *baked* light. Lowering that demand (sharing by a quantized fold, or scoping the allocation) is the next lever and wants a measurement first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
4e92fcf1a1 |
corpse: each way a grave can draw no plate now says which one it was
He hovered a grave for the instrumentation and the log has **no** corpse line at all — which, as the earlier note said, means the question is now a different one: either no corpse object arrived, or the pick never landed on it, or the plate refused it silently. Two of those were still silent. The bone-pile refusal now logs too, though it is the reference's own answer (`0x5d76d0`: bones with `CORPSE_FIELD_DYNAMIC_FLAGS` bit 0 clear shows no name) — from the player's chair it looks identical to a miss, and it is the likeliest shape for a grave. A picked corpse with no fields logs as well. `log_new_corpses` is the third leg: one line per corpse object the server sends, with its flags, owner and chosen model, on `Added<ObjectStore>` so it touches only the frame's new arrivals. It answers whether the grave he hovered was a body or a bone pile without asking him to hover anything, and separates "nothing arrived" from the two plate-side misses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
13989fb09b |
messageframe: a scroll that moves the view fires OnMessageScrollChanged
WIM sets this script on every window it builds — the history frame at load, then one per correspondent (six whisper windows in one session of his log) — and we refused the name by raise, which killed the rest of the builder function each time, not just the handler. It was on the deliberate "real 1.12 kinds nothing fires raise rather than silently accept" list. That rule is right and this was the wrong side of it: the slot now fires, from the ScrollingMessageFrame's own scroll verbs when a scroll actually moves the view, which is what a chat addon hangs its scroll-down arrow on. A scroll refused at either end moves nothing and says nothing; whether the reference fires one there is untraced. The raise list keeps its rule and its test — `OnInputLanguageChanged` and the movie frame's are still raises, and a name leaves that list only when something fires it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
15f069667e |
saved: a widget writes as its name, so a saved frame comes back a frame
Decursive raised `attempt to call method 'AddMessage' (a nil value)` on every
login (`Decursive.lua:1393`). It keeps its output window in its own saved table
and guards the reload with `if Dcr_Saved.Dcr_OutputWindow == nil or not ...`
(`:1245`) — which a non-nil *data* table passes.
Our writer walked the frame like any other table: his `Decursive.lua` came back
holding ChatFrame1's own fields (`buttonSide`, `channelList`, `editBox`, …),
twice the size of the real client's file and with no methods on it at all.
The reference tests the value's `T[0]` identity before its fields —
`lua_rawgeti(v, 0)` then `lua_touserdata`, `0x704613`-`0x704628`, reading the
lightuserdata `0x701bd0` wrote — and never walks a widget. A named one writes as
its **bare global name**, a Lua expression, so the reloaded chunk's own lookup
hands the live object back; an anonymous one as `nil --[[ unnamed object ]]`
(`0x872e0c`). His own real-client file is the proof:
["Dcr_OutputWindow"] = ChatFrame1,
Deviation: a name that is no Lua identifier writes as an anonymous object, where
the reference writes it raw and would leave a file that will not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
68138334ed |
spell_fx: an effect on a host with no attachment points seats at its box top
Reported from play: "ore vein is still not sparkling" — so the switch fix landed and the sparkle still was not there. Reproduced offline in the `fxview` GameObject lane (`WOW_CAPTURE=fxview WOW_FX_GO=310 WOW_FX_GO_TYPE=3`, a copper mining node), which runs the real chain. Everything upstream works: the gate fires, `attach_spell_fx` spawns the instance (`kit spawn … Particles\LootFX.mdl`), and `WOW_PARTICLE_CENSUS` finds all four of LootFX's emitters at the node's position, live, ungated and drawn — while the captured frame is byte-identical to one taken with the sparkle gated off. Nothing was missing; nothing was visible. The attach cascade's last rung is the host's origin, and for a unit that is right: `0x13` is attachment 19, Base, which creature models author at the ground point under the feet, and the loot sparkle is a ground twinkle. A 1.12 GameObject model authors no attachment points at all (`copper_Miningnode_01.m2`: 0 — `benilla-m2`'s `dump_attach`), so the cascade fell through to the node's pivot, which is inside the rock: that model's own 397 vertices run y ∈ [-3.83, +2.73], and `Particles\LootFX.m2` is a ~1 yd twinkle (four emitters, 0.06–0.14 yd particles, ~0.9 yd reach) with nowhere to go. So a host with no attachment point seats the effect at the top centre of its own box. Not its middle — a node is modelled around its pivot, not above it, and this one's box centre is 0.55 yd underground, which capture-tested as invisible too, as did half the box height. At the box top the capture differs from the gated-off one; a peacebloom, whose model is small, already showed at the pivot. The placement is ours and says so: SuperWoW publishes no source and the reference has no object arm to copy. It stays subtle by design — row 14 of `SpellVisualEffectName` scales 1.0 — so it reads as a few twinkles over the node, not a beacon. If that is not what the real client shows him, the next lever is the effect's scale, and that one wants his eyes, not mine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
ed92574cea |
tooltip: SetOwner hides the plate, so a rebuild gives the addon its OnShow back
Reported from play with a video: hovering a bag item draws the enhanced tooltip — MasterTradeSkills' recipe block, AdvancedTradeSkillWindow2's Bags/Bank/Total — for about nine frames of sixty, then the plate falls back to the bare one and stays bare while the cursor sits still. The loop is stock. `ContainerFrameItemButton_OnUpdate` re-enters `..._OnEnter` every `TOOLTIP_UPDATE_TIME` (`ContainerFrame.lua:693` in his own patch-4.mpq; pfUI's bag slots are `ContainerFrameItemButtonTemplate`, so they run it too), and each turn is `SetOwner` + `SetBagItem` — a clear that wipes every line an addon appended. What puts them back in the reference is a hide/show pair, and we had neither half: the `SetOwner` binding hides the plate before it even reads the owner argument (`0x531179` zeroes the content gate at `+0xd0`, `0x531183` calls `[vtable+0x84]` = `0x530a60`, which is the `SetOwner(NULL)` core plus the base hide `0x76ad50`), and the content `Set*` re-shows it through the tooltip's own Show override (`0x530a80`). So every rebuild raises `OnHide` then `OnShow`. Ours cleared in place, and the base show early-outs on an already-visible frame (`0x76ae3b`), so the plate never hid and the `OnShow` never came. That is why exactly two addons lost their lines while others kept theirs: `LibItemTooltip-1.0`, the tooltip contract of MasterTradeSkills and Gromissingcrafts, hangs entirely on a scratch child frame of `GameTooltip` listening for `OnShow`, where pfUI's ItemID and sell-price lines hook the `Set*` methods themselves and so re-run on every refresh. The reference also clears twice per `SetOwner` (once inside the hide, once in the core) and so fires `OnTooltipCleared` twice; that falls out of calling the same hide, and the existing lifecycle test already allowed for it. Addresses read off the player's own `WoW.exe` with objdump, not from memory. The new test is the hover loop in miniature — three turns of `SetOwner` + a content `Set*` under a `LibItemTooltip`-shaped child probe — and fails on the old behaviour with one `OnShow` instead of three. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
1287c328e0 |
creature_anim: the loot sparkle's switch starts where its CVar does
Reported from play: no sparkle on a mining vein, while lootable mobs sparkled fine. That split is the diagnosis — only the GameObject arm reads the switch. `LootSparkleMode` derived `Default`, so it booted `false` while its row registered `"1"`. A boot fires `CvarChanged` only for rows `config.toml` actually carries, and writing `"1"` over a value already `"1"` is a no-op that fires nothing either, so nothing could ever tell the resource its real default — not the boot, and not the player toggling the CVar. The feature was inert for everyone who had never written that row. `vplates.rs`'s `VPlateMode::default` carries this warning in as many words, from `NameplateOcclusion` shipping inert the same way, and this is the same mistake one feature later. So: a hand-written `Default`, and the twin of that module's test, reading the registered row rather than restating it. Swept the rest — of the 32 resources a `CvarChanged` observer writes, 6 derive `Default` and every one of their rows registers `"0"`, so this was the only one. The doc row kept two stale claims and both are corrected: the test has had two arms (the activate bit *or* `GAMEOBJECT_TYPE_CHEST`) since it was widened, and `FxStage` is immaterial here — `Particles\LootFX.m2`, the `"HARDCODED Loot Art"` row's own model, holds one 2400 ms sequence with `flags = 0`, and bit 0 clear loops, so `FxAnimLife::arm` repeats it whatever the stage says. Still unverified in world: whether veins, herbs and chests sparkle, and whether anything sparkles that should not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
0807144f38 |
modelframe: a unit pane holds the unit it was given
`SetUnit` resolved nothing: it stored the token and the extract re-read it every paint. unitscan's alert is the live consumer — it `SetUnit'target'`s the rare it just found by name, and the addon restores the player's own target on the very next line — so the card showed the rare for one frame and then went empty. It only looked right while our by-name targeting was leaving the target on the rare, which is the accident the previous commit removed. The reference resolves the token in `SetUnit`'s own rebuild worker (`0x505b50`, the one `RefreshUnit` also runs) and nothing re-resolves it per paint. Three things say so independently: FrameXML re-issues `SetUnit` from `PaperDollFrame_OnShow` and on `UNIT_MODEL_CHANGED`, which would be pointless against a live token; `RefreshUnit` exists at all; and pfUI detects a portrait change with *no event* by pinning a scratch pane and comparing `GetModel` against the shown one (`api/unitframes.lua:2069`), a test that says nothing unless a pane holds what it was given. `ModelState::unit_guid` is that pin, read through one helper (`Model::unit_pane_subject`) so the paint gate and the extract cannot disagree about whether a pane has a subject. A pane seated *before* its unit streamed in keeps re-reading its token — ours arrives asynchronously where the reference's is already in the object manager — and `RefreshUnit` re-pins, as its worker does. `modelframe.rs`'s own extract test passed only because its `SetUnit` ran with no target; its comment claimed the general rule, so it now states the real one and pins both halves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
92290bb18f |
ui_tooltip: the corpse plate says why it drew none
"Mousing over player graves provides no tooltip" could have been any of three silent `return`s on a path a player can see, and the log said nothing at all. Each now reports itself once per corpse and reason: an empty `CORPSE_FIELD_OWNER`, an owner name that has not answered its `CMSG_NAME_QUERY`, or a missing `CORPSE_TOOLTIP` global. The third is already ruled out by probe — `CORPSE_TOOLTIP` reads "Corpse of %s" on this chain — and the gate above them is correct for a grave (`corpse_mouseover_eligible`: a body always, a bone pile only while lootable, and a grave is not bones). So this is the measurement, not the fix: a hover with no `corpse plate:` line at all would mean the pick is not landing on the corpse, which is a different bug in a different file. The name case is normally transient, the query being a round trip away, and is logged anyway: if it never answers, that one line is the whole diagnosis. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
aeba81ec2e |
creature_anim: SuperWoW's LootSparkle, the sparkle on lootable treasure
1.12 sparkles a lootable corpse and nothing else — a chest, a herb, a vein or a
quest object is found by eye. Sparkling those arrived in 2.3.0 ("interactable
game objects sparkle and display their names") and SuperWoW backports it as
`LootSparkle`, which upstream describes as "the sparkling effect on lootable
treasure". So on this install the sparkle appears on mobs and not on objects,
which is what was reported.
`arm_loot_fx` grows a GameObject arm beside its unit and corpse ones, hanging the
same "HARDCODED Loot Art" row (0x13) at the same tag, driven by the same field
edges. The three-way decision is a named predicate with a test, because a corpse
falling into the unit arm would read its CORPSE_* bytes as a live unit's, and
because the switch being off has to answer *exactly* 1.12 rather than
approximately it.
The object test has two arms, because the mod's own phrase is "lootable treasure"
and neither arm alone is that:
- `GO_DYNFLAG_LO_ACTIVATE`, the server's per-viewer bit, documented on its own
field reader as "the sparkle". But vmangos raises it through
`GameObject::ActivateToQuest`, so on a chest it means *quest* loot: an ordinary
chest, herb or vein need never carry it, and keying on it alone risks lighting
up nothing but quest objects.
- `GAMEOBJECT_TYPE_CHEST`, which is exactly chests, herbs and mining nodes —
treasure by construction, and a type the client reads without the server's
help. Excluded when `GO_FLAG_IN_USE` or `NO_INTERACT` is set, so an emptied or
disabled chest stops sparkling.
Both arms are a judgement about what the mod does rather than a trace of it —
there is no source and no published default — so they are written to be read and
corrected, and the predicate's own doc says which symptom indicts which arm: only
quest objects lighting up means the first is doing all the work, doors and signs
lighting up means the second is too wide.
Defaulted on: upstream publishes no default, and the sparkle is present on this
install with SuperWoW loaded, which is what "as if its own default config loaded"
means for this compat layer. `SetCVar("LootSparkle", "0")` is stock 1.12.
**Not verified in world.** The harness has no scene, so the test covers the
decision and not the rendering.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
3cc71f4c7d |
scrollframe: the child's re-anchor is a SetPoint at the setter, not a per-frame override
`SetVerticalScroll`'s own note here already said it: `0x786db0` stores the offset,
**re-anchors the child (0x787100)** and fires its event, "all only on a change".
0x787100 passes the offsets to `SetPoint` — so the engine writes the child's
TOPLEFT once, when the scroll state changes. This applied it instead as a layout
override, recomputed every frame, which is a different thing: it made the
engine's anchor permanent and unoverridable.
That is why AI_VoiceOver's options could be opened and read but not scrolled.
AceGUI-3.0 scrolls by moving the child **itself** — `SetScroll` is
`content:ClearAllPoints()` then `content:SetPoint("TOPLEFT", 0, offset)`
(AceGUIContainer-ScrollFrame.lua:62-77) and it never calls `SetVerticalScroll` at
all — so the override put the content back at the top on every frame. Measured
through the harness: `SetScroll(1000)` computes offset 170 (content 549 − view
379) and the content's top now moves 563 → 733 where before it did not move.
`SetScrollChild` and both offset setters now write the merged TOPLEFT through
`layout_methods::write_anchor`, which is the commit `SetPoint` itself uses, so
the merge rule is shared rather than reimplemented and a child authored
TOPLEFT/TOPRIGHT keeps the TOPRIGHT that gives it its width. The whole override
path goes away with it: no third element on the layout plan, no override in the
node hash, no special case in the edge walk or in `retarget`, and
`LayoutSolver::set_frame_anchored` is gone.
One behaviour genuinely changes and the test says so now: `SetScrollChild(nil)`
no longer restores the child's authored anchor, because the authored TOPLEFT was
replaced the first time the frame took the child. Nothing was ever saved to
restore — that expectation was an artefact of the override model, not a trace.
The child keeps where the last scroll left it, and its own `SetPoint` wins from
there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
bbe2e3aaa9 |
target: TargetByName selects inside the call, as the reference's does
unitscan probes for a watched rare by targeting its name, reading the name back,
and calling TargetLastTarget to put the old target back — but only
`if prevTarget ~= foundTarget` (unitscan.lua:112-130). Two things had to be true
inside that one Lua frame and neither was, so the restore never fired and every
scan kept your target. TargetLastTarget itself was fine and was never even
reached.
**The read after the call.** `TargetByName 0x489d60` runs the resolver and
selects before it returns, so the next line sees the pick. Ours queued a request
the app drained later in the frame, and the token snapshots had been pushed
*before* Lua ran — so the probing frame saw no change at all, and by the frame
that did see the mob, `prevTarget` had already been re-read as the mob.
The app now keeps each query a script has asked for resolved a frame ahead
(`by_name::feed_named_targets`, one candidate walk for the whole asked set, and
nothing walked at all when no script uses the verb). With the unit already in
hand the binding answers `"target"` for the rest of the Lua frame and queues the
selection *by GUID*, so the optimistic answer and the committed selection cannot
name different units. The override is dropped at the top of the next tick. A
query asked for the very first time keeps the old one-frame-late path, and that
ask is what starts it being resolved.
**The order of two selection verbs in one frame.** `TargetLastTarget`'s memory
was sampled once a frame, so mid-frame it still named whatever preceded the
*previous* selection: the restore would have landed on the wrong unit even with a
synchronous read. The reference stamps inside the selection itself, so its memory
is always the thing being left behind; `selection_requests` now calls
`LastTarget::note` per request, and the per-frame sampler stays as the catch-all
for selection changes that are not requests (a stream-out, a death clear, a
click-off). The two compose: the test walks the whole probe-and-restore sequence
and checks both the mid-frame pick and the memory the frame ends with.
Fixes the `/target X` then `UnitName("target")` macro idiom generally, not just
unitscan — SuperCleveRoidMacros is built on it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
0515274afc |
tests: four stale expectations this window's own commits left behind
Four benilla-ui tests were red and had been since the commits that changed what they pin. They were missed because the round's gate was run as `cargo test -p benilla-app -p benilla-ui` through a grep that only showed benilla-app's results; scripts/check.sh is the gate that scopes this correctly. - SetRaidTarget's third argument became a flag rather than the literal word "local" ( |
||
|
|
80648267d5 |
ui_aura: another unit's aura durations, learned from the GO that applies them
The target frame's debuff icons had no countdown. libdebuff was tracking fine — the probe run said `libdebuff guids: 2` after a fight, and the AURA_CAST_ON_OTHER events carried the right lengths (spell 772 Rend, dur 9000) — because the timers pfUI-for-ClassicAPI draws do not come through libdebuff at all. Its unit frames read duration/expirationTime straight off C_UnitAuras.UnitAuraBySlot (api/unitframes.lua:1680,1788), and libdebuff's own header says the plumbing "now runs on ClassicAPI's C_UnitAuras (which provides sourceUnit/sourceGUID and non-player expirationTime)". So the number came from us, and we answered 0 for every unit but the player. There is no packet to answer from: SMSG_UPDATE_AURA_DURATION is self-only, and SMSG_SET_EXTRA_AURA_INFO — which carries a caster's durations on others — is a 2.0 opcode this protocol does not have. Hence every vanilla duration addon keeping its own table. This is that table, kept client-side instead, because this client *is* the client: SMSG_SPELL_GO names the spell and its hit list, so the apply instant is the packet's own arrival rather than a combat-log line somebody has to parse, and every reader of the aura lists gets the timer at once instead of each inventing a tracker. Keyed by (guid, spell) so a re-cast refreshes in place and a recycled slot cannot inherit a stale stamp. Length comes from the same ui_cast_events::aura_duration_ms behind nampower's arg8 and GetSpellDuration, so the three cannot disagree, and its 0 — permanent, no SpellDuration.dbc row, or a combo-point/level-scaled one — stamps nothing: a timerless icon beats one counting to the wrong moment. The join is driven by the unit's own UNIT_FIELD_AURA, so a stamp for an aura the target resisted is never read, and a run-out stamp reports nothing rather than a negative remainder. Not covered, by construction: an aura already on a unit before we streamed it, and one applied out of our range. No GO, no timer — which is what the mods this stands in for also do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
608b4b961b |
modelframe: a unit pane paints, so its OnUpdateModel fires
The paint that fires OnUpdateModel is gated on [widget+0x318] != 0 (0x76d24c) — the pane's loaded model. This read that as "has a file path", and a SetUnit pane keeps no path at all: its subject resolves per render, app-side, so SetUnit writes `unit` and nils `path`. Every unit pane's OnUpdateModel was therefore permanently inert. Not cosmetic. unitscan's alert model is made visible *only* from inside its own OnUpdateModel: reset() sets alpha 0 and arms the handler with a ten-paint settle delay, and the eleventh call is what sets alpha back to 1 (unitscan.lua:268-292). With the handler never firing, the alert drew a correctly placed, correctly sized, fully transparent model and the horn played over an empty card. The layout probe that said the model frame was a healthy 200x120 was right; it was just invisible. A unit token that names nothing still fires nothing, which is the "!= 0" half of the gate. Nothing in stock FrameXML or pfUI is woken by this: probed, none of CharacterModelFrame, DressUpModel, TabardModel or PetModelFrame registers an OnUpdateModel at all, and the stock consumer (Cooldown.lua) is file-backed and was already firing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
75432f7dde |
layout: a scroll child's re-anchor merges one point, as SetPoint does
A live ScrollFrame re-points its child's TOPLEFT to the frame's own TOPLEFT at the scroll offsets, which the reference does by calling SetPoint on the child (0x787100). This replaced the child's whole anchor list instead — and 1.12's SetPoint merges a point into the list, it does not clear it. The difference is not academic. AceGUI's scroll child is authored TOPLEFT *and* TOPRIGHT to its parent with an explicit height and no width (AceGUIContainer-ScrollFrame.lua:192-195), so the corner pair is where its width comes from. Dropping TOPRIGHT left it with one corner and no width, so it resolved to no rect at all — and with no rect for the child, nothing below it resolved either. That is what drew AI_VoiceOver's options window as chrome and tabs over an empty content area. The whole widget tree was there the entire time: probed through addon_harness, the panel builds TabGroup → ScrollFrame → four InlineGroups of CheckBoxes, Dropdowns, Sliders and Buttons, every one sized and IsVisible() true — and every one from the scroll child down answering GetLeft() == nil. After this, the same probe reads the scroll child at L=190 T=563 and its first checkbox at L=200 T=530 B=506, inside the viewport. The override's edges now include the authored anchors' targets as well, since the merge means the child reads both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
85cd750a4e |
vplates: no plate for a critter, by the client's own no-TAB flag
grom asked for the name and level over every rabbit to be gone. pfUI cannot do it: its option is
"Hide Healthbar On Critters", and the branch it takes is `level:Show() name:Show() health:Hide()`
— bar hidden, name and level kept, a label floating in empty space.
The reason it shows up here and not on his real client is our own compat. That pfUI branch needs
`UnitCreatureTypeID` on a plate GUID, which needs the `NAME_PLATE_UNIT_ADDED` → `C_NamePlate` →
`UnitGUID` chain; stock 1.12 has none of it, so the check returns nil there and a critter gets an
ordinary plate nobody notices. We answer all three, so the feature wakes up and strips the bar.
So the gate goes where the plate is decided. It keys on `CreatureType.dbc`'s **no-TAB flag**
rather than on the id 8: that bit is the client's own mark for a creature the player does not
mean to interact with, it is set on Critter alone in the 1.12 data, and `target::scan`'s TAB
gate already reads it — one notion of "critter", from the data, with two consumers. An
unresolved type keeps its plate, as the scan treats an out-of-range index as targetable.
A deviation, and `nameplateShowCritters` says so: the reference's ladder (`0x60f600`) has no
creature-type test at all. Off by default, because the floating name is the thing nobody wants;
`SetCVar("nameplateShowCritters", "1")` restores vanilla. Removing the plate leaves nothing
behind — the overhead-name path gates a creature on `UnitNameNPC`, which is "0".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
07d41dc321 |
portrait: measure SetPosition's unit instead of fitting it
One `WOW_BOOTH_LOG` run with Tmog open printed every term, so the factor is arithmetic now rather
than a fit:
- The cards draw through the paper-doll camera, logged `eye=(0.003, 1.103, -2.919)`, and the same
run logged the dwarf's `head_y=1.364`.
- Tmog's browse cards frame on the slot being browsed, so its per-race **head** row is a request
to put that race's head on the camera's axis. The dwarf row's vertical is `-1.2`.
- Centring the head needs `1.364 - 1.103 = 0.261` model units against a request of `1.2`:
**0.2175**, rounded to 0.22.
The same run confirmed the whole chain end to end: the logged pose inverted back to
`(3.72, 0, 0.489)` at facing 3.2, and `Tmog.lua:332` reads `{ 3.8, 0, 0.5, 3.2 }` — the small gap
is the previous factor, `G48` at grom's actual aspect.
It cross-checks on depth: the head row's `6.3` then moves the model 1.37 of its 2.92 standoff,
leaving it 1.55 out — a view about 0.75 units tall around a head of roughly 0.35. The two earlier
values are what make the arithmetic worth trusting: 1.0 put every card *behind* its camera and the
grid drew empty; `G48` ≈ 0.49 left them 1.06 out, the extreme close-up in the screenshot.
Still a measurement of an addon's table against this client rather than of the reference's own
root, and the constant says so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
3d4c242c09 |
sound: decode addon .ogg, which is most of the addon audio on this install
unitscan's rare alert was reported as silent. The binding and the loose-file read were both already right: kira was built without the `ogg` container and `vorbis` codec features, so `sfx_from_bytes` returned an error for every Vorbis file and `sound/ui.rs` logged it at `debug!`. A silent alert with nothing in the log to say why. It is not one horn. **75 `.ogg` files ship with the installed addons** — DoiteAuras 57, BigWigs 11, unitscan 3, HCWarn 2, TWThreat and Cleanup one each — so every alert any of them raises by `PlaySoundFile` was dead, HCWarn's hardcore death warnings included. AI_VoiceOver ships `.mp3` and always played, which is exactly what made this look like an addon-specific fault rather than a missing decoder. Stock 1.12 plays wav and mp3 only. The deviation is from 1.12 and toward the client the player actually runs: these are Turtle/OctoWoW addons shipping ogg because that client plays it. `docs/ADDON_COMPAT.md` carries the row. The test decodes a real `.ogg` out of the player's own addon folder and skips when there is none, the same gate the chain-backed tests use for a WoW install — rather than carrying someone else's audio into the tree as a fixture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
0a90795555 |
compat: publish LOCALIZED_CLASS_NAMES_MALE/_FEMALE, the table tInvert exists to flip
Half this pair was already here. `ClassInfo::class_file`'s own doc says the token is what FrameXML keys `RAID_CLASS_COLORS` and `LOCALIZED_CLASS_NAMES_MALE` on, and `compat/classic_api/lua_ext.rs`'s `tInvert` names pfUI's `pfUI.lua:227` as the reason it exists — the line that inverts exactly this table. The table itself was never published, so that line inverted `nil`. Measured against the live chain: `pfUI.env.L["class"]` holds **0 entries**, and `L["class"]["Warrior"]` is nil. Every lookup through it then finds no class and skips, silently, because the consumer tests `if class then` — so the guild roster lost its name colours and class icons (`modules/socialmod.lua:18`), the friends list lost the same, and `modules/panel.lua:359` fell back to white. Chat stayed coloured throughout, which is what made it look like a pfUI setting rather than a missing global. Published from the class table the app already feeds for `C_CreatureInfo.GetClassInfo`, at that same feed, so there is one source for the names and no second reader of the DBC. Both tables carry the same string: 1.12's `ChrClasses.dbc` has one name column, so the gendered pair later clients ship cannot be derived from this data — a copy is what it honestly supports, and omitting `_FEMALE` would hand a caller nil where its sibling answers. A run that never feeds the table leaves both absent rather than empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
751cac062b |
compat: C_Spell.GetSpellEffectMechanics, the per-effect sibling
`SuperCleveRoidMacros/ClassicAPI.lua:267` calls it unguarded, so its absence was a raise a
session. It is not a second spelling of `GetSpellMechanicByID`, which this tree already answers:
that one reads `Spell.dbc`'s spell-level `Mechanic`, and vanilla stores some mechanics on an
*effect* instead. The consumer's own comment names the case — Rake's bleed is effect-level, so
the spell-level column says 0 while this answers `{0, 15, 0}` — and a macro conditional asking
whether its target is bleeding gets the wrong answer from the spell-level field alone.
`SpellDisplay::effect_mechanic` was already parsed for the cast-immunity scan, so this is a field
on the probe payload and a reader, not a new lookup. The three-way answer is its sibling's, for
the sibling's reason: nothing at all for an id no spell owns, three zeros for a real spell that
carries no mechanic anywhere, the ids otherwise — collapsing those two would reclassify crowd
control as ordinary damage, which is the hand-kept table this function exists to replace.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
d25ef0f1d9 |
target: TargetLastTarget, a 1.12 verb we never registered
`TargetLastTarget` is in the reference's global table (`reference/1.12-globals.tsv:17678`, beside
`TargetLastEnemy`) and was simply missing. unitscan is what noticed: it probes for a watched rare
by targeting the name and reading `UnitName("target")` back, then calls this to put the old target
back (`unitscan.lua:84`). With the global absent the probe's target just stayed — which looks
exactly like the addon stealing your target every scan tick, and is what it was reported as.
Its memory is the selection held **before** the current one, any unit rather than only an
attackable one, so it is a separate stamp from `TargetLastEnemy`'s `[0xb4e2e8]` — sampled per
frame for the reason that one is, so that every writer is covered including the ones that are not
`SetSelection` calls here. The latch lives in the resource rather than a system `Local`: it is
part of the memory, and a `Local` is unreachable from a test that drives the system directly.
An empty or stale memory **deselects**. That is the one arm on this queue that does — the
reference's `0x489b00` reaches `0x493540(0, 0)` where `TargetLastEnemy`'s shim tests nothing and
returns, a difference `click.rs` already had written down — and it is what makes the verb a
toggle: probe something with nothing selected, and this puts you back to nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
fe00d16ec6 |
trainer: a service's skill line is the profession, not its group header
`GetTrainerServiceSkillLine` answered the group header text. At a class trainer the two are one string, so nothing showed; at a tradeskill trainer they are not. Every group there is labelled from `TRADESKILL_SERVICE_STEP`/`_LEARN` (`0x4d7786`), and `TRADESKILL_SERVICE_STEP` is the string "Development Skills" — so standing at the mining trainer, the stock confirmation dialog, which formats this return straight into `PROFESSION_CONFIRMATION2` (`Blizzard_TrainerUI.lua:28`), asked whether to learn **Development Skills** as a second profession. `TrainerService` now carries the taught spell's own line beside the group label, resolved through the same `SkillLineAbility` lookup the group key uses, and the getter prefers it. The header row is left alone: "Development Skills" there is the client's own label for a skill-stepping tradeskill group, not a lookup that went wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
7098d64203 |
party: SetRaidTarget's third argument is a flag, not the word "local"
SuperWoW's extra argument marks the icon local-only, and its callers disagree on how to spell it: the wiki writes `SetRaidTarget(unit, i, "local")`, while SoloRaidTargetIcons passes the boolean `IsSuperWoWLoaded() and not IsPlayerInPartyOrRaid()` (`main.lua:51`). Typing the parameter as a `String` raised on that boolean — "bad argument #3: error converting Lua boolean to String", five times in one session's log — and it raised *only while solo*, which is the one case that addon exists for. So marking a target outside a group did nothing at all. Lua truthiness takes both spellings, which is what a flag means in this language. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
072d691449 |
portrait: fit SetPosition's unit to the one table that calibrates it
Shipping one model unit per `SetPosition` unit put Tmog's browse cards behind their own cameras and the grid drew empty: its per-slot table asks for depths of 3.8 (gnome) to 11.8 (night elf) against authored cameras that stand about 2 to 6 units off. That table is itself the measurement — each row has to land that race's head in a card — and across gnome, dwarf, human and night elf the displacement they need sits at 0.45–0.55 of what they ask for. `G48`, the client's own `1/√(a²+1)` at `[0x832a48]`, is 0.50 on a 16:9-ish panel and 0.6 at 4:3, and it is the constant `Model::layout_unit` already inverts for the orthographic leg. So the translate is `pos · G48`. Fitted, not traced, and the extract's arm says so: a capture of the reference's root would settle it, and the wheel zoom this landed for in the first place works at either value because the player drives it by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
06011e5e5b |
ui_script: the LoadOnDemand log line stops claiming "not loaded"
A LoadOnDemand addon can already be in memory when the walk reaches its row, because an addon ahead of it loaded it from its own `ADDON_LOADED` — which is how every AI_VoiceOver data module actually arrives: 224 ms of Lua inside AI_VoiceOver's handler, before the walk gets to the `AI_VoiceOverData_*` rows at all. The line was printed unconditionally, so it went on saying "no LoadAddOn() yet" about addons that had loaded. That reads as a bug and is not one; it cost a session chasing a missing `GetAddOnEnableState` that was never the problem. Gated on a new `UiScript::addon_is_loaded`. `docs/ADDON_COMPAT.md` now records why that global stays unbuilt, since the evidence was gathered anyway: upstream ClassicAPI's own `docs/API.md` does not define it either, and AI_VoiceOver gates a modern-shaped `GetAddOnInfo` on the same `if not GetAddOnEnableState`, so defining it would leave `VoiceOver.lua:84` reading `loadable` where it means `reason` and pop the "delete your old folder" dialog on every login. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
75f04b4f28 |
compat: FontString:GetStringHeight, which AceGUI calls unguarded
1.12 registers no `GetStringHeight` (`script/button.rs`: `0x782390` reads the label's height instead) and ClassicAPI backports it, so `compat/classic_api/frame.rs` is its home. That file had it on its own not-built list under "needs per-line layout facts the host measurer is not asked for today" — true of `GetNumLines` and `GetWrappedWidth`, and not of this one: the whole-string height is the other half of the number `GetStringWidth` already reads. `measure::current_measure` now owns the staleness rule for both, so the two cannot disagree about when a measure has gone stale, and a changed string still reads 0 rather than the previous string's height. It is not cosmetic. AceGUI-3.0 calls it **unguarded** — `AceGUIWidget-Label.lua:54` is `height = label:GetStringHeight()` and `AceGUIWidget-CheckBox.lua:90` is `self:SetHeight(28 + self.desc:GetStringHeight())` — so every Ace options panel on this install drew its labels and checkbox descriptions at the library's own "avoid zero-height labels" fallback of one pixel: a window with all of its prose invisible. `addon_harness --probe AI_VoiceOver` builds that panel's full 32-widget tree with zero errors and every `Label` at `624x1`, which is what "can't see the actual options in VoiceOver" was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
7fef604615 |
portrait: a unit pane honours Model:SetPosition
`SetPosition` is the widget's model-root translate and the extract spent it only on the file-pane
path. The booth path dropped the value and posed `R(yaw) · S(s)` alone, so Tmog's wheel zoom, its
right-drag pan and the per-slot framing of every browse card moved nothing at all.
It shows on a `<PlayerModel>` and not on a plain `<Model>` for one reason: this widget's camera is
frozen (`0x7acf10`) where `<Model>`'s perspective leg republishes its authored camera through the
very root being moved (`0x718960`), which cancels. `capture::probe_model_camera`'s `position` leg
pins that half; this is the other.
The extract publishes the translate in bevy's axes into a new `PanePositions`, keyed by slot so the
four stock window panes and the stock dressing room are served by the same channel rather than a
second path; a slot that is not drawn keeps the translation it has rather than snapping back to the
origin. The booths apply it as one `RootPose { yaw, scale, pos }`, latched together — keyed on
`(yaw, scale)` alone a position-only change compares equal and writes nothing, so a wheel would
zoom once and then stop. `WOW_BOOTH_LOG` gains `pane pose slot=… yaw=… scale=… pos=[…]`, one line
per change.
One model unit per `SetPosition` unit, and deliberately not the reference's `pos · layoutScale`:
that factor belongs to the group the reference's root scales with it, and this booth's root carries
only the renormalizer (`framing::pane_root_scale_at` drops `modelScale` and `layoutScale` both), so
taking the translate's share alone would move a `scale="4"` pane's model four times its own size.
The magnitude is the one part nothing in the tree pins — the only two calibrated `SetPosition`
sites are the ortho leg's pixel ladder and the `<Model>` leg where it cancels — and the extract's
arm says what to revisit if a wheel notch turns out to zoom too hard.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
c1ef619295 |
trainer: a rank-less service subtexts empty, not nil
`GetTrainerServiceInfo`'s second return was nil whenever the spell had no rank. The client reads that field out of the spell record's string block, where an absent rank is the empty string at offset 0 rather than a null, so it is always a string — and the stock window depends on that: it formats the subtext unguarded at `Blizzard_TrainerUI.lua:275`, twenty lines before its own `not serviceSubText` fallback. So a nil raised inside `ClassTrainer_SelectFirstLearnableSkill`, which `ClassTrainerFrame_Show` calls on the line above `ClassTrainerFrame_Update` (l.48-49). The update never ran, so the list came up with no rows at all, and `SelectTrainerService` (l.277) never ran, so the Train button had nothing to act on. A trainer window that shows nothing and trains nothing, from one nil. Every rank-less service reaches that line: every profession recipe, and class abilities like Parry. The name beside it stays optional, because the stock window says so itself — it tests `serviceName` before using it as a header's (l.209) and falls back to `UNKNOWN` for a service's (l.287). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
7bd3635463 | Update README.md | ||
|
|
48b43d0662 | Update README.md | ||
|
|
fc63dd022a |
ui_pass: a resized image re-prepares the materials that sample it
A booth pane's render target is resized between 256 and 512 as the pane it is lent to changes size, and `GpuImage::prepare_asset` builds a brand-new texture and view every time it runs. Nothing re-prepares a material because an image it names changed — `PreparedMaterial2d` prepares from its own asset events alone — so the UI's cached quad material went on holding the dead view and sampled a texture nothing rendered into any more. That is what "the preview is frozen" was: the first window to claim a slot bound a live texture and every later one bound a corpse, while the camera kept rendering and the bakes kept restaging into the texture that was no longer on screen. The cards never froze because a 256 card never resizes its slot; the big previews, which are the only panes that ask for 512, froze every time. `rebuild_ui_mesh` already hears the image stream for removals. It now also hears `Modified` and touches every cached material naming that image, which re-extracts it and rebuilds the bind group against the image's current view. Only the two resize sites emit this event, so a steady frame does no work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
77ffc36fbd |
portrait: the mirror clears the stage latch, so a reopened window restages
`Booth::staged` records which pane's bake a booth holds, and only `stage_pane_look` ever wrote it. Nothing cleared it, so a booth that fell back to mirroring the live unit still claimed to hold the pane's bake. That is a stale latch with a delay fuse. A pane's bake lives as long as its room, and every pane numbers its own bakes from 1, so a window that closes and reopens brings the same pane back at revision 1 — meeting a latch that still said `(that pane, 1)` from its previous life. `needs_restage` read that as "already current" and the window came back showing whatever the mirror had left in the booth. It was the same shape as the pane-to-pane collision the latch already guards, one lifetime further out. Clearing on the way through the mirror can only cost one extra restage. Also adds `[booth] pane geom`, a `WOW_BOOTH_LOG` line per pooled slot whenever the aspect its camera is framed through, the square bake it renders into, or the square the UI samples changes — the three numbers that decide whether a pane is drawn un-stretched, and the ones a pane whose content and framing both check out must be disagreeing on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
1a26bb0b2a |
portrait: one stuck pane stops starving the rest of its page
`build_pane_previews` assembles one pane a frame and chose the first stale one. A pane that cannot be assembled — an item model that never resolves — is stale by definition, so it was chosen again every frame and gave the frame up every time: every pane behind it in slot order waited on it forever and stayed on the mirrored live player, which looks exactly like the per-pane dress-up never having run. The shop's Smoky Wooden Torch was doing this to its page. The choice now offers every stale pane in slot order and the caller takes the first it can actually make, so a pane that cannot be assembled costs its own turn and nothing else. Walking past one is free: every `assemble` bail is a lookup, all of them before the skin composite. Also adds `[booth] pane source`, a `WOW_BOOTH_LOG` line per pooled slot whenever where its character comes from changes — its own bake, or the mirror because the room is closed, the bake has no parts, or its rig is still loading. Those three look identical on screen and were the reason this took a second run to see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |