app+ui+protocol: the four measured-out names, and the unit descriptor by name

Each of these had a reason for being out. Each reason turned out to be
answerable, and two of them were simply wrong.

`GetSpellPower(school)` is **delegated to `GetSpellBonusDamage`**, which
this client already answered — one number under two mods' names, as
SuperCleveRoidMacros says itself ("Reads the same client field
nampower's GetSpellPower does"). Delegating is the only way to guarantee
they cannot drift, and the test asserts equality across all seven
schools and the off-range values rather than a literal. The earlier
"needs a descriptor band fed" was wrong: it was already fed.

`GetRaidTargets()` is the 8-slot **board**, now pushed beside the party
roster. The earlier reason was right about the scan it rejected and
wrong about the alternative: `GroupState::raid_targets` is the
reference's own table and copying it costs eight words a push. A mark
with nothing on it is absent, because a zero GUID is a string that
passes `if t[4]`.

`LearnTalentRank` is one `LearnTalent` press per missing rank, which is
what a player clicking the button does. The earlier reason — the burst
cannot be checked before it is sent — is true and is not a reason to
withhold it: the check that matters is the server's and the client has
never had another. What can be checked locally is, so a rank already
held queues nothing and one past the maximum is clamped.

`GetUnitData` is the whole descriptor, from a new raw block on
`UnitState`. The earlier reason — "a decision about which fields" — is
now a table of seventy columns under the DLL's own names, read off its
string pool in UnitFields order; `GetUnitField`'s scalar half reads the
same block, so the two cannot answer differently for one name, and a
test walks the table asserting exactly that.

**The indices are derived and the derivation is checked.** Only thirteen
`FIELD_UNIT_*` constants existed here; the rest come from 1.12's block
sizes. Those thirteen span index 22 to 147 and every one lands where the
layout puts it — thirteen independent confirmations, run as a test
rather than asserted in a comment, with two more that no columns overlap
and that nothing falls inside the aura blocks' 47..=124.

Three decisions at that mapping:

- An unstreamed column is absent, not zero. `ObjectFields::at` is
  `get_raw`'s strictness rather than `get_u32`'s absent-is-zero
  convenience: `health = 0` for a unit whose health has not arrived is a
  number a caller divides by.
- `aura`/`auraApplications` come from the filtered per-unit aura list,
  not the raw slots — the same place `GetUnitField` has always answered
  them from. An unfiltered second answer would be a different unit by
  the same name.
- `status` and `zoneId` are left out of nampower's seven computed
  columns: nothing says what `status` is a status *of*, and a zone is
  only knowable for the player. The other five are filled by the caller,
  which knows them.

`SpellRecValue` is now `RecValue`: it serves both record readers, and a
second copy of the same four shapes would drift.

58 of 62 registered names. The four left are `DisenchantAll` and the
queue trio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-24 12:08:46 -06:00
co-authored by Claude Opus 5
parent e716709bca
commit b652c90a4b
15 changed files with 918 additions and 50 deletions
+30 -12
View File
@@ -845,15 +845,34 @@ Two also worth recording:
it, so that postcondition already holds at all times. Defining it is the honest answer: the
capability is present, and a missing global makes `HasFeature` report it absent when it is not.
**Still out, now with the field lists that make each a decision rather than a shrug:**
**The four measured-out names — built too, on grom's "62/62".** Each had a reason for being out;
each reason turned out to be answerable, and where it was not the deviation is stated.
| Name | Why not, and what it would take |
| Name | What it took, and what it cost |
|---|---|
| `GetUnitData(unitToken, [copy])` / `GetUnitField`'s full surface | The names are read: 80 of them at `0x92620` — `charm summon charmedBy summonedBy createdBy persuaded channelObject health power1..5 maxHealth maxPower1..5 level factionTemplate bytes0 auraState baseAttackTime … aura auraFlags auraLevels auraApplications powerCostModifier powerCostMultiplier`, plus five computed extras (`guid status zoneId posX posY`). They are the **raw descriptor**, which lives app-side in `ObjectStore` and not in the VM's resolved `UnitState`, so the full surface is a live per-unit feed. `GetUnitField` already answers the only two names anything reads (`aura`, `auraApplications`) and `GetUnitData` has **zero** corpus call sites, so the feed would be built for nothing. The partial alternative is worse: a caller reading `bytes0` and getting nil while `health` works cannot tell which names are real |
| `GetRaidTargets()` | The reference keeps a raid-target **board** (`0xb712a8`); this client feeds the mark per unit (`UnitState::raid_target`), which `GetRaidTargetIndex` answers from. A scan over currently-named tokens would silently omit a marked unit no token names — plausible and wrong, which is worse than absent. Zero corpus call sites |
| `DisenchantAll(itemIdOrName \| quality, [includeSoulbound])` | **Queue machinery, not a getter.** It picks the next matching bag item, casts Disenchant, waits for the resolution, repeats — a server-paced sequencer with aborts on failure and on a bag change, which is the same thing `QueueSpellByName` is and which this client deliberately does not have. pfUI binds `/disenchantall` to it (`modules/nampower.lua:140`), so the *user-visible* cost is one slash command. Say the word and the sequencer is its own piece of work; it is not a binding |
| `GetSpellPower(school)` | The player's spell damage bonus by school, which needs the `PLAYER_FIELD_MOD_DAMAGE_DONE_POS` band fed per school. Nothing in the corpus calls it — the "7 call sites" an earlier sweep counted are BetterCharacterStats' own method of the same name |
| `StartItemExport` | Writes a file of its own; it belongs with the seven withheld, not with the getters |
| `GetSpellPower(school)` | **delegated to `GetSpellBonusDamage`**, which this client already answered. They are one number under two mods' names — `SuperCleveRoidMacros` says so itself ("Reads the same client field nampower's `GetSpellPower` does") — and delegating is the only way to guarantee they cannot drift. The earlier "needs a descriptor band fed" was wrong: it was already fed |
| `GetRaidTargets()` | the 8-slot **board**, now pushed beside the party roster. The earlier reason — "a scan over named tokens would silently omit a marked unit" — was right about the scan and wrong about the alternative: `GroupState::raid_targets` is the reference's own table and copying it costs eight words a push. A mark with nothing on it is **absent**, because a zero GUID is a string that passes `if t[4]` |
| `LearnTalentRank(page, index, rank)` | one `LearnTalent` press per missing rank, which is what a player clicking the button does. The earlier reason — the burst cannot be checked before it is sent — is true and is not a reason to withhold it: the check that matters is the server's and the client has never had another. What *can* be checked locally is, so a rank already held queues nothing and one past the maximum is clamped rather than sending presses the server will refuse |
| `GetUnitData(unitToken, [copy])` | the whole descriptor, from a new raw block on `UnitState` (`ui_script/unit_rec.rs`). The earlier reason — "a decision about which fields" — is now a table of seventy columns under the DLL's own names, and `GetUnitField`'s scalar half reads the same block, so the two cannot answer differently for one name |
**The indices for `GetUnitData` are derived, and the derivation is checked.** Only thirteen
`FIELD_UNIT_*` constants existed in this tree; the rest come from 1.12's `UnitFields` block sizes.
Those thirteen are spread from index 22 to 147, and every one lands exactly where the layout puts
it — thirteen independent confirmations, run as a test rather than asserted in a comment, alongside
one that no two columns overlap and one that nothing falls inside the aura blocks' 47..=124.
Three decisions at that mapping:
- **An unstreamed column is absent, not zero.** `at()` is `get_raw`'s strictness rather than
`get_u32`'s absent-is-zero convenience: `health = 0` for a unit whose health has not arrived is a
number a caller divides by, and nil is what their own guard already tests.
- **`aura` and `auraApplications` come from the filtered per-unit aura list**, not the raw slots —
the same place `GetUnitField` has always answered them from. The two verbs must not disagree
about which auras a unit has, and the filter drops exactly the ones a tracker does not want.
- **`status` and `zoneId` are left out** of nampower's seven computed columns. Nothing in the
string pool or the corpus says what `status` is a status *of*, and a zone is only knowable for
the player. The other five (`guid`, `posX`, `posY`, `power`, `maxPower`) are filled by the
caller, which knows them.
### The whole registered list, accounted for
@@ -871,13 +890,12 @@ comm -23 /tmp/np /tmp/ours
| Bucket | Count | Names |
|---|---|---|
| **Built** | 54 | `CancelPlayerAuraSlot`, `CancelPlayerAuraSpellId`, `CastSpellByName`, `CastSpellByNameNoQueue`, `CastSpellNoQueue`, `FindPlayerItemSlot`, `GetBagItem`, `GetBagItems`, `GetEquippedItem`, `GetEquippedItems`, `GetItemIconTexture`, `GetItemIdCooldown`, `GetItemLevel`, `GetItemStats`, `GetItemStatsField`, `GetNampowerVersion`, `GetPlayerAuraDuration`, `GetQuestDialogQuestId`, `GetQuestLogQuestIds`, `GetSpellDuration`, `GetSpellIconTexture`, `GetSpellIdCooldown`, `GetSpellIdForName`, `GetSpellModifiers`, `GetSpellNameAndRankForId`, `GetSpellRangeData`, `GetSpellRec`, `GetSpellRecField`, `ImportFile`, `ReadCustomFile`, `StartItemExport`, `WriteCustomFile`, `GetSpellSlotTypeIdForName`, `GetTrinketCooldown`, `GetTrinkets`, `GetUnitField`, `GetUnitGUID`, `IsAuraHidden`, `IsSpellInRange`, `IsSpellUsable`, `PlayerIsMoving`, `PlayerIsRooted`, `PlayerIsSwimming`, `SetLocalRaidTargetIndex`, `SetMouseoverUnit`, `UseItemIdOrName`, `UseTrinket` |
| **Built** | 58 | `CancelPlayerAuraSlot`, `CancelPlayerAuraSpellId`, `CastSpellByName`, `CastSpellByNameNoQueue`, `CastSpellNoQueue`, `FindPlayerItemSlot`, `GetBagItem`, `GetBagItems`, `GetEquippedItem`, `GetEquippedItems`, `GetItemIconTexture`, `GetItemIdCooldown`, `GetItemLevel`, `GetItemStats`, `GetItemStatsField`, `GetNampowerVersion`, `GetPlayerAuraDuration`, `GetQuestDialogQuestId`, `GetQuestLogQuestIds`, `GetSpellDuration`, `GetSpellIconTexture`, `GetSpellIdCooldown`, `GetSpellIdForName`, `GetSpellModifiers`, `GetSpellNameAndRankForId`, `GetRaidTargets`, `GetSpellPower`, `GetSpellRangeData`, `GetSpellRec`, `GetSpellRecField`, `GetUnitData`, `ImportFile`, `ReadCustomFile`, `StartItemExport`, `WriteCustomFile`, `GetSpellSlotTypeIdForName`, `GetTrinketCooldown`, `GetTrinkets`, `GetUnitField`, `GetUnitGUID`, `IsAuraHidden`, `IsSpellInRange`, `IsSpellUsable`, `PlayerIsMoving`, `PlayerIsRooted`, `PlayerIsSwimming`, `LearnTalentRank`, `SetLocalRaidTargetIndex`, `SetMouseoverUnit`, `UseItemIdOrName`, `UseTrinket` |
| **Not ours to add** | 4 | `QueueSpellByName`, `QueueScript`, `ChannelStopCastingNextTick`, `LearnTalentRank` |
| **Measured out, with the reason at each** | 4 | `DisenchantAll` (queue machinery — see the table above), `GetUnitData`, `GetRaidTargets`, `GetSpellPower` |
| **The queue, and the one verb that needs it** | 4 | `QueueSpellByName`, `QueueScript`, `ChannelStopCastingNextTick`, `DisenchantAll` |
54 + 4 + 4 = 62, and the `comm -23` above is the check: run it and the eight it prints are exactly
the last two rows. Nothing is in two buckets and nothing is in none.
58 + 4 = 62, and the `comm -23` above is the check: run it and the four it prints are exactly the
last row — `DisenchantAll` and the queue trio.
The two that were at the head of that last bucket are now built — see "The cast feed" above for
why they came first and what each decision cost.