the guild window opens: roster, ranks, notes and the message of the day
- the social window's third tab is live — the roster in both its views, the member card with its promote/demote/remove verbs, the rank editor's thirteen permissions, the guild information board, and the invite, leave, kick and note dialogs behind them - setting your hearthstone at an innkeeper now asks before it takes and says so afterwards, and the innkeeper's gossip lines get their own icons back - unavailable talents grey out, and every greyed icon in the client now goes through the same luminance fold the original used - the buff tooltip's remaining-time line reads gold with the client's own hour/minute ladder, the macro editor takes a shift-click to paste a spell, trainer rows repaint after a purchase, and a long book page stops flickering its ellipsis Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
//! The innkeeper-bind **Era API surface** (decision 1331) — two globals, no snapshot.
|
||||
//!
|
||||
//! These are the whole Lua side of setting a hearthstone, and they exist because the reference's
|
||||
//! `CONFIRM_BINDER` dialog is written against exactly them (`StaticPopup.lua:1308-1322`):
|
||||
//! `OnAccept` calls [`ConfirmBinder`], and `OnUpdate` hides the dialog the moment
|
||||
//! [`CheckBinderDist`] goes false. Both are engine bindings in 1.12 (`reference/1.12-globals.tsv`
|
||||
//! lists `ConfirmBinder` and `CheckBinderDist` as `function`/`engine`), and there are no others in
|
||||
//! the family — the question itself arrives as the `CONFIRM_BINDER` event's argument, so there is
|
||||
//! nothing to read back, exactly like [`super::duel`].
|
||||
//!
|
||||
//! The app owns the innkeeper's guid ([`crate::script::UiScript::set_binder_pending`]'s writer),
|
||||
//! which is why `ConfirmBinder()` takes no arguments and queues only a count.
|
||||
|
||||
use mlua::Lua;
|
||||
|
||||
use super::Model;
|
||||
|
||||
impl super::UiScript {
|
||||
/// Drain the `ConfirmBinder()` calls queued since the last drain — each one is a
|
||||
/// `CMSG_BINDER_ACTIVATE`. [`super::UiScript::take_played_time_asks`]'s shape, and a count for
|
||||
/// the same reason: the intent has no payload of its own.
|
||||
pub fn take_binder_confirms(&mut self) -> u32 {
|
||||
std::mem::take(&mut self.model_mut().binder_confirms)
|
||||
}
|
||||
|
||||
/// Push whether an innkeeper's bind question is live and still in range — the host's half of
|
||||
/// `CheckBinderDist()`. Idempotent; `false` covers both "no question pending" and "you walked
|
||||
/// away", which is all the dialog's OnUpdate needs to decide to hide.
|
||||
pub fn set_binder_pending(&mut self, pending: bool) {
|
||||
let mut model = self.model_mut();
|
||||
if model.binder_pending != pending {
|
||||
model.binder_pending = pending;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the two binder globals (the style [`super::duel`] registers its four).
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
|
||||
// ConfirmBinder() — the CONFIRM_BINDER dialog's Accept. The one call in the client that binds
|
||||
// a hearthstone: everything before it is a question.
|
||||
g.set(
|
||||
"ConfirmBinder",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.binder_confirms += 1;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// CheckBinderDist() — polled from the dialog's OnUpdate; false hides it.
|
||||
g.set(
|
||||
"CheckBinderDist",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(model.binder_pending)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -342,7 +342,8 @@ impl UiScript {
|
||||
// API change. The approximation is visible, and it is stated there and here
|
||||
// rather than discovered later.
|
||||
let fill = data.fill.or_else(|| data.gradient.map(|g| g.midpoint()));
|
||||
let has_texture = data.texture.is_some() || fill.is_some();
|
||||
let has_path = data.texture.is_some();
|
||||
let has_texture = has_path || fill.is_some();
|
||||
QuadContent::Texture {
|
||||
path: data.texture,
|
||||
color: has_texture
|
||||
@@ -353,6 +354,13 @@ impl UiScript {
|
||||
circular: data.circular,
|
||||
portrait_unit: data.portrait_unit,
|
||||
rotation: data.rotation,
|
||||
// Only ever set against real ART. A pathless solid has its colour
|
||||
// FOLDED into `color` two lines up (the renderer draws it as a tint on
|
||||
// a 1x1 white texel), so a shader that greys the *texel* would grey
|
||||
// white and change nothing — the flag would read as honoured while
|
||||
// doing nothing at all. No reference consumer desaturates a solid;
|
||||
// every one of them is an icon. Stated here rather than discovered.
|
||||
desaturated: data.desaturated && has_path,
|
||||
}
|
||||
};
|
||||
// A region draws at its owner's scale — same single hop as alpha (a region
|
||||
@@ -403,6 +411,13 @@ impl UiScript {
|
||||
/// **alpha included** (wow-re `system/ui/scratch/texture-color-composition.md`, VERIFIED — the
|
||||
/// stage-0 combine's `MODULATE(TEXTURE, DIFFUSE)` for both colour and alpha).
|
||||
///
|
||||
/// **That law is scoped to a region with no pixel shader bound** (`+0x128 == 0` — wow-re
|
||||
/// `texture-desaturate-law.md` §6.1's correction to that note). A DESATURATED region takes the
|
||||
/// fragment program instead, which supersedes the whole stage chain and reads the vertex colour's
|
||||
/// ALPHA only; its RGB never reaches the pixel. The colour computed here still travels — the
|
||||
/// renderer needs the alpha, and the RGB is simply unread on that branch (`ui_quad.wgsl`) — so
|
||||
/// nothing changes here, but the note this cites no longer says what it used to.
|
||||
///
|
||||
/// `fill` is the region's own solid-colour texture ([`RegionData::fill`] — the client generates a
|
||||
/// real 8×8 texel block from it), so where it is set it IS the texel and the product is the drawn
|
||||
/// colour. Where it isn't, the texel comes from `path`'s art and this returns the tint alone for the
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,7 @@ mod addon_message;
|
||||
mod aura;
|
||||
mod backdrop;
|
||||
mod bank;
|
||||
mod binder;
|
||||
mod binding_abi;
|
||||
mod button;
|
||||
mod channel;
|
||||
@@ -68,6 +69,7 @@ mod follow;
|
||||
pub(crate) mod font;
|
||||
mod font_block;
|
||||
mod gossip;
|
||||
mod guild;
|
||||
mod inspect;
|
||||
mod item_stats;
|
||||
mod item_text;
|
||||
@@ -144,6 +146,10 @@ pub use dressup::DressUpIntent;
|
||||
pub use duel::DuelRequest;
|
||||
pub use follow::FollowRequest;
|
||||
pub use gossip::{GossipMenu, GossipOptionView, GossipQuestRow};
|
||||
pub use guild::{
|
||||
GuildMemberInfo, GuildRankEdit, GuildRankInfo, GuildRequest, GuildState, LastOnline, UnitGuild,
|
||||
MAX_RANKS, MIN_RANKS, RANK_RIGHT_BITS,
|
||||
};
|
||||
pub use inspect::InspectView;
|
||||
pub use item_stats::{item_usable, ItemSetView, ItemTemplateView, PlayerReqState};
|
||||
pub use item_text::ItemTextState;
|
||||
@@ -496,6 +502,8 @@ impl UiScript {
|
||||
unit::install(&lua)?;
|
||||
party::install(&lua)?;
|
||||
social::install(&lua)?;
|
||||
guild::install(&lua)?;
|
||||
binder::install(&lua)?;
|
||||
duel::install(&lua)?;
|
||||
follow::install(&lua)?;
|
||||
session::install(&lua)?;
|
||||
|
||||
@@ -4,11 +4,11 @@ use crate::layout::{LayoutInput, LayoutSolver, Rect};
|
||||
use crate::widget::{FrameHandle, RegionHandle, WidgetArena};
|
||||
|
||||
use super::{
|
||||
backdrop, bank, char_stats, container, craft, cursor, death, duel, follow, gossip, inspect,
|
||||
item_text, loot, loot_roll, macros, mail, merchant, party, quest, quest_log, reputation,
|
||||
session, skills, slider, social, spellbook, taxi, trade, tradeskill, trainer, weapon_enchant,
|
||||
ActionSlot, AuraState, FontObject, ItemTemplateView, PlayerReqState, RegionData, ScriptValue,
|
||||
SoundRequest, UnitState,
|
||||
backdrop, bank, char_stats, container, craft, cursor, death, duel, follow, gossip, guild,
|
||||
inspect, item_text, loot, loot_roll, macros, mail, merchant, party, quest, quest_log,
|
||||
reputation, session, skills, slider, social, spellbook, taxi, trade, tradeskill, trainer,
|
||||
weapon_enchant, ActionSlot, AuraState, FontObject, ItemTemplateView, PlayerReqState,
|
||||
RegionData, ScriptValue, SoundRequest, UnitState,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -281,6 +281,19 @@ pub(crate) struct Model {
|
||||
/// Social intents (`AddFriend`/`RemoveFriend`/`SendWho`/…) queued since the app's last
|
||||
/// [`super::UiScript::take_social_requests`] drain — the outbound seam ([`social`]).
|
||||
pub(crate) social_requests: Vec<social::SocialRequest>,
|
||||
/// The guild snapshot the app pushes (roster, ranks, MOTD, info text — decision 1257):
|
||||
/// `GetNumGuildMembers`/`GetGuildRosterInfo`/`GuildControlGetRankFlags`/… read it
|
||||
/// ([`guild`]). Already display-resolved and already sorted + filtered, because the sort
|
||||
/// field and the show-offline toggle live app-side where the roster does.
|
||||
pub(crate) guild: guild::GuildState,
|
||||
/// The rank-control popup's staging buffer ([`guild::GuildRankEdit`]) — deliberately NOT part
|
||||
/// of [`Self::guild`], because a snapshot push mid-edit would discard the user's unsaved
|
||||
/// checkbox clicks. Flushed by `GuildControlSaveRank`.
|
||||
pub(crate) guild_control: guild::GuildRankEdit,
|
||||
/// Guild intents (`GuildInviteByName`/`GuildSetMOTD`/`GuildControlSaveRank`/…) queued since
|
||||
/// the app's last [`super::UiScript::take_guild_requests`] drain — the outbound seam
|
||||
/// ([`guild`]).
|
||||
pub(crate) guild_requests: Vec<guild::GuildRequest>,
|
||||
/// Whisper targets `ChatFrame_SendTell` queued since the app's last
|
||||
/// [`super::UiScript::take_tell_requests`] drain — the app opens its chat edit box prefilled
|
||||
/// `/w <name> ` (the unit popup's WHISPER action; [`party`] registers the global).
|
||||
@@ -996,6 +1009,15 @@ pub(crate) struct Model {
|
||||
/// Empty only before that packet has landed — `""`, never nil, matching [`Self::realm_name`]
|
||||
/// beside it: a consumer concatenates this (`Necrosis.lua:1089`), so nil would be a raise.
|
||||
pub(crate) bind_location: String,
|
||||
/// `ConfirmBinder()` calls queued since the app last drained them — each is one
|
||||
/// `CMSG_BINDER_ACTIVATE`. A COUNT for [`Self::played_time_asks`]'s reason: the intent carries
|
||||
/// no payload (the app holds the innkeeper's guid), so two calls are two sends.
|
||||
pub(crate) binder_confirms: u32,
|
||||
/// Is an innkeeper's bind question still live and in range — the answer `CheckBinderDist()`
|
||||
/// gives, pushed by the app each frame ([`super::UiScript::set_binder_pending`]). The
|
||||
/// CONFIRM_BINDER dialog polls it from OnUpdate and hides itself when it goes false, which is
|
||||
/// how walking away from the innkeeper takes the question off screen (decision 1331).
|
||||
pub(crate) binder_pending: bool,
|
||||
/// Frames per second, behind `GetFramerate()`. Pushed per tick by the app, which owns the
|
||||
/// clock this crate does not have.
|
||||
pub(crate) framerate: f64,
|
||||
@@ -1103,6 +1125,9 @@ impl Model {
|
||||
party_requests: Vec::new(),
|
||||
social: social::SocialState::default(),
|
||||
social_requests: Vec::new(),
|
||||
guild: guild::GuildState::default(),
|
||||
guild_control: guild::GuildRankEdit::default(),
|
||||
guild_requests: Vec::new(),
|
||||
tell_requests: Vec::new(),
|
||||
open_chat_requests: Vec::new(),
|
||||
default_language: None,
|
||||
@@ -1266,6 +1291,8 @@ impl Model {
|
||||
played_time_asks: 0,
|
||||
realm_name: String::new(),
|
||||
bind_location: String::new(),
|
||||
binder_confirms: 0,
|
||||
binder_pending: false,
|
||||
framerate: 0.0,
|
||||
modifiers: (false, false, false),
|
||||
money: 0,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Region method-table cluster: **paint** — what a Texture shows and how it is tinted,
|
||||
//! blended, cropped and layered. Split out of `region.rs` at the 0716 file-size budget.
|
||||
|
||||
use mlua::{Lua, Table, Value};
|
||||
use mlua::{Lua, MultiValue, Table, Value};
|
||||
|
||||
use crate::script::object::{as_f32, draw_layer_from_str};
|
||||
use crate::script::{Model, TexCoords};
|
||||
@@ -154,6 +154,25 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
let data = model.region_data.entry(rh).or_default();
|
||||
data.circular = false;
|
||||
data.portrait_unit = None;
|
||||
// **`SetTexture` CLEARS the desaturation** (wow-re `texture-desaturate-law.md` §2.3,
|
||||
// VERIFIED): `+0x128` is a `CGxShader*`, and `CSimpleTexture::SetTexture`
|
||||
// (`0x770200`) writes it from its 4th stack arg, for which the Lua binding
|
||||
// (`0x79bb40`) pushes slot 0 — permanently NULL — on both of its legs. A
|
||||
// re-implementation that keeps a desaturate boolean independent of the texture
|
||||
// handle diverges on every `icon:SetDesaturated(1)` followed by `icon:SetTexture(t)`.
|
||||
//
|
||||
// Scoped exactly as the binary scopes it:
|
||||
// · the **same path** is inert — `0x770225` returns before ever reaching the write,
|
||||
// so a repaint that re-sets the icon it already shows keeps its grey;
|
||||
// · `nil`/`""` DO clear — the `test esi,esi` leg falls through to the write;
|
||||
// · the **colour form** does NOT — that is `0x770360`, which is not among the four
|
||||
// writers of the field.
|
||||
let same_path = matches!((&arg, &data.texture),
|
||||
(Value::String(s), Some(cur)) if s.to_str().is_ok_and(|s| *s == **cur));
|
||||
let colour_form = matches!(&arg, Value::Number(_) | Value::Integer(_));
|
||||
if !same_path && !colour_form {
|
||||
data.desaturated = false;
|
||||
}
|
||||
// Both forms write the SAME `+0xcc` texture slot — the path form loads a file
|
||||
// there (`0x770200`), the colour form generates an 8×8 solid into it
|
||||
// (`0x770360`) — so each clears the other. NEITHER touches the vertex colour at
|
||||
@@ -227,26 +246,52 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// elseif ( not r or not shaderSupported ) then r,g,b = 0.5,0.5,0.5 end
|
||||
// icon:SetVertexColor(r, g, b)
|
||||
//
|
||||
// We have no desaturation in the renderer, so we answer **nil — unsupported**, which is a real
|
||||
// 1.12 machine's answer and not a lie. Claiming support would suppress that grey fallback and
|
||||
// leave disabled icons drawn at full colour: strictly worse-looking than saying no. When a
|
||||
// desaturating shader lands, flip the return and this comment with it.
|
||||
// The renderer now greys the texel (decision 1327 — `ui_quad.wgsl`'s luminance fold), so we
|
||||
// answer **1 — supported**, and the branch above takes its shader arm: the icon goes greyscale
|
||||
// AND wears the caller's own dim tint. Until 1327 this answered nil, which is a real 1.12
|
||||
// card's answer and was the honest one while nothing greyed — but it costs the *look*: a 0.65
|
||||
// grey multiply on colourful art is a dimmer, still-colourful icon, which is precisely what
|
||||
// B162 reported against the talent tree.
|
||||
//
|
||||
// Why it matters far past one verb: **98 of the 109 addons that draw and then raise on being
|
||||
// used, raise here** — `FuBar_Panel.lua:43`'s right-click reaches Dewdrop's `AddLine`
|
||||
// (`Dewdrop-2.0.lua:2172`), which calls `button.arrow:SetDesaturated(true)` unguarded. A
|
||||
// static scan costed this at 61 addons and it was declined; the use-probe costed it at 98 the
|
||||
// moment anyone right-clicks.
|
||||
//
|
||||
// **The argument's truth table is not `if flag then`** (wow-re `texture-desaturate-law.md` §1.1,
|
||||
// VERIFIED — `0x6f1c10(L, 2, default=1)` dispatched through the jump table at `0x6f1ce8`). Two
|
||||
// of its arms are the opposite of the obvious reading, and both are reachable:
|
||||
// · **no argument at all is ON**, not off — `LUA_TNONE` takes the `ja` default arm, so
|
||||
// `tex:SetDesaturated()` greys. This is why the flag arrives as a `MultiValue`: mlua hands a
|
||||
// missing `Value` parameter through as `Nil`, which is the one arm that means OFF.
|
||||
// · **a number truncating to 0 is OFF** (`0x6f3620`+`0x40a2b0`), so `SetDesaturated(0)` clears.
|
||||
// · a table/function/userdata is ON (same default arm as absent).
|
||||
// The string arm (`0x6f1c51`, comparing against `0x871460`/`0x853758`) is NOT modelled — no
|
||||
// corpus caller passes one, and the comparands were not read; a string takes the ON arm here.
|
||||
m.set(
|
||||
"SetDesaturated",
|
||||
lua.create_function(|lua, (this, flag): (Table, Value)| {
|
||||
let on = !matches!(flag, Value::Nil | Value::Boolean(false));
|
||||
lua.create_function(|lua, args: MultiValue| {
|
||||
let mut args = args.into_iter();
|
||||
let this: Table = match args.next() {
|
||||
Some(Value::Table(t)) => t,
|
||||
_ => return Ok(Value::Nil),
|
||||
};
|
||||
let on = match args.next() {
|
||||
None => true,
|
||||
Some(Value::Nil) => false,
|
||||
Some(Value::Boolean(b)) => b,
|
||||
Some(Value::Integer(i)) => i != 0,
|
||||
Some(Value::Number(n)) => n.trunc() != 0.0,
|
||||
Some(_) => true,
|
||||
};
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
model.region_data.entry(rh).or_default().desaturated = on;
|
||||
// nil, not false: the reference's `shaderSupported` is a 1|nil C answer and callers
|
||||
// 1, not true: the reference's `shaderSupported` is a 1|nil C answer (byte-verified —
|
||||
// `0x6f3810 lua_pushnumber(L, 1.0)`, never `false`, never zero values) and callers
|
||||
// write `not shaderSupported`.
|
||||
Ok(Value::Nil)
|
||||
Ok(Value::Number(1.0))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -912,23 +912,23 @@ fn editbox_justify_v_echoes_but_multiline_alone_decides_the_pixels() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **`Texture:SetDesaturated(flag)` answers `shaderSupported`, and ours says nil.**
|
||||
/// **`Texture:SetDesaturated(flag)` answers `shaderSupported`, and since 1327 ours says yes.**
|
||||
///
|
||||
/// `0x79c1e0` (wow-re ledger). The reference's own `ItemButtonTemplate.lua:69` is
|
||||
/// `local shaderSupported = icon:SetDesaturated(desaturated)`, and lines 70-78 fall back to a 0.5
|
||||
/// grey vertex tint when that answer is falsy — 1.12 shipped on cards without the shader, so the
|
||||
/// verb reporting "no" is a real machine's answer, not a stub.
|
||||
///
|
||||
/// We have no desaturating shader, so nil is the honest reply: claiming support would suppress
|
||||
/// FrameXML's grey fallback and leave disabled icons at full colour, which looks *more* wrong.
|
||||
/// The test pins the return shape rather than the state, because the return is what callers branch
|
||||
/// on — and `nil`, not `false`, because the C answer is `1|nil` and the reference writes
|
||||
/// `not shaderSupported`.
|
||||
/// benilla answered nil for exactly as long as nothing greyed. Decision 1327 gave the renderer the
|
||||
/// luminance fold, so the honest answer flipped: a caller that asks for grey now gets grey, and
|
||||
/// keeps its own tint instead of having it overwritten by the 0.5 fallback. The test pins the
|
||||
/// return SHAPE, because the return is what callers branch on — `1`, not `true`, because the C
|
||||
/// answer is `1|nil` and every reference consumer writes `not shaderSupported`.
|
||||
///
|
||||
/// 98 of the 109 draw-then-raise addons in the corpus die on this one verb, via
|
||||
/// `FuBar_Panel.lua:43` → Dewdrop `AddLine` → `button.arrow:SetDesaturated(true)`, unguarded.
|
||||
#[test]
|
||||
fn set_desaturated_reports_no_shader_support_and_does_not_raise() {
|
||||
fn set_desaturated_reports_shader_support_and_does_not_raise() {
|
||||
let s = crate::script::UiScript::new().unwrap();
|
||||
s.run(r#"f = CreateFrame("Frame", "DsF") tex = f:CreateTexture("DsTex", "ARTWORK")"#)
|
||||
.unwrap();
|
||||
@@ -936,11 +936,11 @@ fn set_desaturated_reports_no_shader_support_and_does_not_raise() {
|
||||
// Dewdrop's exact call — the one 98 addons reach. It must not raise.
|
||||
s.run("DsTex:SetDesaturated(true)").unwrap();
|
||||
|
||||
// ...and it answers nil, so the reference's `not shaderSupported` branch is taken.
|
||||
// ...and it answers truthy, so the reference's shader arm is taken.
|
||||
assert!(
|
||||
s.eval::<bool>("return DsTex:SetDesaturated(true) == nil")
|
||||
s.eval::<bool>("return DsTex:SetDesaturated(true) == 1")
|
||||
.unwrap(),
|
||||
"shaderSupported must be nil (1|nil C shape), not false"
|
||||
"shaderSupported must be 1 (1|nil C shape), not true"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', DsTex:SetDesaturated(true))")
|
||||
@@ -949,11 +949,13 @@ fn set_desaturated_reports_no_shader_support_and_does_not_raise() {
|
||||
"one return value"
|
||||
);
|
||||
|
||||
// The reference's own consumer, transcribed: a falsy answer greys via vertex colour.
|
||||
// The reference's own consumer, transcribed: a truthy answer keeps the CALLER's tint, and the
|
||||
// 0.5 fallback never fires. This is the half B162 turned on — the talent tree asks for
|
||||
// `(1, 0.65, 0.65, 0.65)` and must get 0.65, greyscale, not a flat 0.5 colour multiply.
|
||||
s.run(
|
||||
r#"
|
||||
local shaderSupported = DsTex:SetDesaturated(true)
|
||||
local r, g, b = 1, 1, 1
|
||||
local r, g, b = 0.65, 0.65, 0.65
|
||||
if not shaderSupported then r, g, b = 0.5, 0.5, 0.5 end
|
||||
DsTex:SetVertexColor(r, g, b)
|
||||
"#,
|
||||
@@ -962,7 +964,10 @@ fn set_desaturated_reports_no_shader_support_and_does_not_raise() {
|
||||
let (r, g, b) = s
|
||||
.eval::<(f32, f32, f32)>("return DsTex:GetVertexColor()")
|
||||
.unwrap();
|
||||
assert_eq!((r, g, b), (0.5, 0.5, 0.5), "the no-shader fallback greys");
|
||||
assert!(
|
||||
(r - 0.65).abs() < 1e-6 && (g - 0.65).abs() < 1e-6 && (b - 0.65).abs() < 1e-6,
|
||||
"the shader arm keeps the caller's tint, got {r},{g},{b}"
|
||||
);
|
||||
|
||||
// Both spellings of "off" are off — nil and false.
|
||||
s.run("DsTex:SetDesaturated(nil) DsTex:SetDesaturated(false)")
|
||||
|
||||
@@ -534,6 +534,155 @@ fn a_solid_colour_texel_multiplies_with_the_vertex_colour() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **`SetDesaturated` rides the extract, and only against real ART** (decision 1327).
|
||||
///
|
||||
/// The state was stored from the day the verb landed and read by nobody, which is why every
|
||||
/// greyed-out affordance in the UI was a brightness tint (B162's talent tree). The flag now travels
|
||||
/// on the quad, so this pins the two ends of that wire — set/clear — plus the one carve-out: a
|
||||
/// PATHLESS solid has its colour folded into the quad's tint and draws against a 1x1 white texel,
|
||||
/// so a shader that greys the texel would grey white and change nothing. Carrying the flag there
|
||||
/// would read as honoured while doing nothing at all, so extract drops it.
|
||||
#[test]
|
||||
fn desaturation_rides_the_extract_for_art_and_never_for_a_solid() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "DsOwner")
|
||||
f:SetPoint("BOTTOMLEFT", 0, 0)
|
||||
f:SetWidth(100) f:SetHeight(50)
|
||||
art = f:CreateTexture("DsArt", "ARTWORK")
|
||||
art:SetTexture("Interface\\Icons\\Spell_Nature_Sleep")
|
||||
art:SetAllPoints()
|
||||
solid = f:CreateTexture("DsSolid", "OVERLAY")
|
||||
solid:SetTexture(1, 0, 0)
|
||||
solid:SetAllPoints()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let grey = |s: &UiScript, want_path: bool| {
|
||||
s.extract()
|
||||
.iter()
|
||||
.find_map(|q| match &q.content {
|
||||
QuadContent::Texture {
|
||||
path, desaturated, ..
|
||||
} if path.is_some() == want_path => Some(*desaturated),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the quad")
|
||||
};
|
||||
assert!(!grey(&s, true), "art starts full colour");
|
||||
|
||||
s.run("art:SetDesaturated(1) solid:SetDesaturated(1)")
|
||||
.unwrap();
|
||||
assert!(grey(&s, true), "the flag reaches the art quad");
|
||||
assert!(
|
||||
!grey(&s, false),
|
||||
"a pathless solid never carries it — greying a white texel is a no-op dressed as a feature"
|
||||
);
|
||||
|
||||
// And it clears — the reference's own `SetItemButtonDesaturated(button, nil)` restore.
|
||||
s.run("art:SetDesaturated(nil)").unwrap();
|
||||
assert!(!grey(&s, true), "clearing the flag restores full colour");
|
||||
}
|
||||
|
||||
/// Read a Texture region's desaturation state straight off the model, by name.
|
||||
///
|
||||
/// It has no getter in Lua on purpose — `IsDesaturated` (`0x79c2c0`) is in wow-re's ledger but its
|
||||
/// return shape is not carved, and inventing one to make a test convenient is how an unverified API
|
||||
/// gets shipped (decision 1327's own residual). The extract quad is the other way to see it, but a
|
||||
/// cleared texture emits no quad at all, which is exactly the case these tests need to observe.
|
||||
fn desaturated(s: &UiScript, name: &str) -> bool {
|
||||
let lua = s.lua();
|
||||
let model = lua.app_data_ref::<crate::script::Model>().expect("model");
|
||||
let id = *model.region_names.get(name).expect("region name");
|
||||
let h = *model.id_to_region.get(&id).expect("region handle");
|
||||
model.region_data.get(&h).is_some_and(|d| d.desaturated)
|
||||
}
|
||||
|
||||
/// **`SetTexture` clears the desaturation — except when the path does not actually change**
|
||||
/// (wow-re `texture-desaturate-law.md` §2.3, VERIFIED; decision 1330).
|
||||
///
|
||||
/// `+0x128` is a `CGxShader*`, and `CSimpleTexture::SetTexture` writes it from a shader index the
|
||||
/// Lua binding always passes as slot 0 (permanently NULL). Storing a desaturate boolean *beside*
|
||||
/// the texture handle — which is what benilla did on 1327 — diverges on the single most common
|
||||
/// FrameXML shape there is: a repaint that re-sets the icon and expects the grey to follow the art.
|
||||
/// The same-path early-out (`0x770225`) is what makes the *idempotent* repaint keep its grey, and
|
||||
/// it is the half a plausible implementation drops.
|
||||
#[test]
|
||||
fn set_texture_clears_desaturation_unless_the_path_is_unchanged() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "ClrOwner")
|
||||
icon = f:CreateTexture("ClrIcon", "ARTWORK")
|
||||
icon:SetTexture("Interface\\Icons\\Spell_Nature_Sleep")
|
||||
icon:SetDesaturated(1)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let grey = |s: &UiScript| desaturated(s, "ClrIcon");
|
||||
|
||||
// The idempotent repaint: same file, so the client returns before it can clear anything.
|
||||
s.run(r#"icon:SetTexture("Interface\\Icons\\Spell_Nature_Sleep")"#)
|
||||
.unwrap();
|
||||
assert!(grey(&s), "re-setting the SAME art keeps the grey");
|
||||
|
||||
// A different file reaches the write and zeroes the slot.
|
||||
s.run(r#"icon:SetTexture("Interface\\Icons\\Spell_Fire_Fireball")"#)
|
||||
.unwrap();
|
||||
assert!(!grey(&s), "a texture CHANGE clears the desaturation");
|
||||
|
||||
// The clear forms take the same leg (`test esi,esi` falls through to the write).
|
||||
s.run("icon:SetDesaturated(1) icon:SetTexture(nil)")
|
||||
.unwrap();
|
||||
assert!(!grey(&s), "SetTexture(nil) clears it too");
|
||||
|
||||
// The COLOUR form is a different function (`0x770360`) and is not one of the field's writers.
|
||||
s.run(r#"icon:SetTexture("Interface\\Icons\\Spell_Nature_Sleep") icon:SetDesaturated(1)"#)
|
||||
.unwrap();
|
||||
s.run("icon:SetTexture(1, 0, 0)").unwrap();
|
||||
assert!(grey(&s), "the colour form does not touch the shader slot");
|
||||
}
|
||||
|
||||
/// **`SetDesaturated`'s argument truth table has two arms that read backwards** (wow-re
|
||||
/// `texture-desaturate-law.md` §1.1, VERIFIED at `0x6f1c10`'s jump table; decision 1330).
|
||||
///
|
||||
/// `0x6f1c10(L, 2, default=1)` takes its DEFAULT on `LUA_TNONE`, so a bare `SetDesaturated()` greys
|
||||
/// — the opposite of the `if flag then` an implementation writes without looking. And a number is
|
||||
/// truncated to an int, so `SetDesaturated(0)` clears where truthiness would have greyed.
|
||||
#[test]
|
||||
fn set_desaturated_takes_the_clients_argument_truth_table() {
|
||||
let s = script();
|
||||
s.run(r#"f = CreateFrame("Frame", "ArgOwner") tex = f:CreateTexture("ArgTex", "ARTWORK")"#)
|
||||
.unwrap();
|
||||
let grey = |s: &UiScript| desaturated(s, "ArgTex");
|
||||
|
||||
// NO ARGUMENT is ON — the LUA_TNONE default arm, not the nil arm.
|
||||
s.run("ArgTex:SetDesaturated()").unwrap();
|
||||
assert!(
|
||||
grey(&s),
|
||||
"a bare SetDesaturated() greys (LUA_TNONE default)"
|
||||
);
|
||||
|
||||
s.run("ArgTex:SetDesaturated(nil)").unwrap();
|
||||
assert!(!grey(&s), "nil clears");
|
||||
|
||||
// A number truncating to zero clears; a non-zero one greys.
|
||||
s.run("ArgTex:SetDesaturated(1) ArgTex:SetDesaturated(0)")
|
||||
.unwrap();
|
||||
assert!(!grey(&s), "0 clears — the number arm truncates to int");
|
||||
s.run("ArgTex:SetDesaturated(0.5)").unwrap();
|
||||
assert!(!grey(&s), "0.5 truncates to 0 and clears");
|
||||
|
||||
// Both booleans, and Dewdrop's `true` (the call 98 corpus addons reach).
|
||||
s.run("ArgTex:SetDesaturated(true)").unwrap();
|
||||
assert!(grey(&s), "true greys");
|
||||
s.run("ArgTex:SetDesaturated(false)").unwrap();
|
||||
assert!(!grey(&s), "false clears");
|
||||
}
|
||||
|
||||
/// The draw gate is the TEXTURE slot, never the colour (`texture-color-composition.md` §4,
|
||||
/// VERIFIED): `0x7706e0` tests `+0xcc` and emits NOTHING when it is empty, whatever the vertex
|
||||
/// colour holds. Since the tint deliberately survives `SetTexture(nil)` ("a tint outlives the art
|
||||
|
||||
@@ -422,3 +422,89 @@ fn owner_anchored_tooltip_clamps_to_screen() {
|
||||
.unwrap();
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// `0x52fa50`'s ladder as pure logic (law §3-BUFF-TIME-FORMAT) — the arm thresholds, the
|
||||
/// ceil/truncate asymmetry, and the `_P1` pick — over SYNTHETIC templates, so this asserts the
|
||||
/// mechanism and carries none of the reference's own eight strings. The real ones are read off the
|
||||
/// player's install and exercised in `benilla::ui_script::buff_tests`.
|
||||
#[test]
|
||||
fn the_duration_ladder_ceils_every_arm_but_seconds() {
|
||||
// Deliberately not the shipped wording: what is under test is which key is reached and what
|
||||
// number fills it, never what the string says.
|
||||
let table = |key: &str| -> Option<String> {
|
||||
Some(
|
||||
match key {
|
||||
"T_DAYS" => "<%d day>",
|
||||
"T_DAYS_P1" => "<%d days>",
|
||||
"T_HOURS" => "<%d hour>",
|
||||
"T_HOURS_P1" => "<%d hours>",
|
||||
"T_MIN" => "<%d min>",
|
||||
"T_MIN_P1" => "<%d mins>",
|
||||
"T_SEC" => "<%d sec>",
|
||||
"T_SEC_P1" => "<%d secs>",
|
||||
_ => return None,
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
};
|
||||
let d = |ms: u32| crate::script::tooltip::duration_text(ms, "T", true, &table);
|
||||
|
||||
// The three arm boundaries, from the compare instructions — each is exact, and the arm below
|
||||
// it counts in its own unit right up to the edge.
|
||||
assert_eq!(d(86_400_000).as_deref(), Some("<1 day>"), "the day edge");
|
||||
assert_eq!(
|
||||
d(86_399_999).as_deref(),
|
||||
Some("<24 hours>"),
|
||||
"one ms under a day is still HOURS, and ceil takes it to 24"
|
||||
);
|
||||
assert_eq!(d(3_600_000).as_deref(), Some("<1 hour>"), "the hour edge");
|
||||
assert_eq!(
|
||||
d(3_599_999).as_deref(),
|
||||
Some("<60 mins>"),
|
||||
"the carve's own example: no '1 hour' until the hour is whole"
|
||||
);
|
||||
assert_eq!(d(60_000).as_deref(), Some("<1 min>"), "the minute edge");
|
||||
assert_eq!(
|
||||
d(61_000).as_deref(),
|
||||
Some("<2 mins>"),
|
||||
"the carve's own example: 61 s ceils to 2, it does not truncate to 1"
|
||||
);
|
||||
|
||||
// The asymmetry that makes this one function rather than four format calls: roundUp reaches
|
||||
// the top three arms only.
|
||||
assert_eq!(
|
||||
d(59_999).as_deref(),
|
||||
Some("<59 secs>"),
|
||||
"seconds TRUNCATE — a ceil here would read 60"
|
||||
);
|
||||
assert_eq!(d(5_400).as_deref(), Some("<5 secs>"), "5.4 s is 5, not 6");
|
||||
assert_eq!(
|
||||
d(400).as_deref(),
|
||||
Some("<0 secs>"),
|
||||
"the lapsing second: 0, and PLURAL — the reference's own last reading"
|
||||
);
|
||||
assert_eq!(d(0).as_deref(), Some("<0 secs>"), "a fully lapsed aura");
|
||||
assert_eq!(
|
||||
d(1_000).as_deref(),
|
||||
Some("<1 sec>"),
|
||||
"exactly one is singular"
|
||||
);
|
||||
|
||||
// roundUp = 0 truncates the top arms too (the parameter is real, not a constant we folded).
|
||||
assert_eq!(
|
||||
crate::script::tooltip::duration_text(61_000, "T", false, &table).as_deref(),
|
||||
Some("<1 min>"),
|
||||
);
|
||||
|
||||
// A key the string table does not carry yields NO LINE rather than an invented one.
|
||||
assert_eq!(
|
||||
crate::script::tooltip::duration_text(1_000, "NOPE", true, &table),
|
||||
None
|
||||
);
|
||||
// …and a family shipping only the singular falls back to it rather than vanishing.
|
||||
let singular_only = |key: &str| (key == "S_SEC").then(|| "<%d s>".to_string());
|
||||
assert_eq!(
|
||||
crate::script::tooltip::duration_text(5_000, "S", true, &singular_only).as_deref(),
|
||||
Some("<5 s>"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The engine spell/aura tooltip channel (decision 0274 P2, law per 0276): the verified line
|
||||
//! shapes — name|rank, ONE cost|range line, ONE casttime|cooldown line, the passive omission,
|
||||
//! the aura variant (white description + the SetPlayerBuff-only remaining line), and SetAction's
|
||||
//! the aura variant (white description + the SetPlayerBuff-only remaining line, itself gold —
|
||||
//! B62), and SetAction's
|
||||
//! pure delegation.
|
||||
|
||||
use super::common::script;
|
||||
@@ -253,6 +254,12 @@ fn player_buff_hover_is_the_aura_variant() {
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
-- The duration line's wording comes from the VM's GlobalStrings, which the app runs off
|
||||
-- the player's own install at boot. This VM has no install, so the test supplies the pair
|
||||
-- itself — deliberately NOT the shipped wording, because what is under test here is that
|
||||
-- the engine reads the string table at all, not what the strings say.
|
||||
SPELL_TIME_REMAINING_MIN = "<%d m>"
|
||||
SPELL_TIME_REMAINING_MIN_P1 = "<%d mm>"
|
||||
local a = CreateFrame("Button", "BF1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
|
||||
local tt = CreateFrame("GameTooltip", "TT")
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
@@ -260,7 +267,8 @@ fn player_buff_hover_is_the_aura_variant() {
|
||||
tt:SetPlayerBuff(0)
|
||||
assert(tt:NumLines() == 3, "name + description + remaining, got " .. tt:NumLines())
|
||||
assert(TTTextLeft2:GetText() == "Intellect increased by 2.", "the AURA description column")
|
||||
assert(TTTextLeft3:GetText() == "2 minutes remaining", "got " .. TTTextLeft3:GetText())
|
||||
-- 90 s left ceils to 2 minutes, and 2 takes the _P1 twin.
|
||||
assert(TTTextLeft3:GetText() == "<2 mm>", "got " .. TTTextLeft3:GetText())
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -280,6 +288,16 @@ fn player_buff_hover_is_the_aura_variant() {
|
||||
if t == "Arcane Intellect" && (c[1] - 210.0 / 255.0).abs() < 1e-6)
|
||||
});
|
||||
assert!(gold_name, "aura name is gold, not white");
|
||||
// …and so is the DURATION-REMAINING line — the same `0xffffd200`, not the description's
|
||||
// white. Report B62: measured on the reporter's own 1.12.1 reference shot
|
||||
// (`media/1530672450247856148`), the "29 minutes remaining" glyphs are exactly
|
||||
// `(255, 210, 0)`, the same pixels as that shot's title row, while its description rows are
|
||||
// `(255, 255, 255)`. Ours drew the line white, which is the whole report.
|
||||
let gold_remaining = quads.iter().any(|q| {
|
||||
matches!(&q.content, QuadContent::Text { text: Some(t), color: Some(c), .. }
|
||||
if t == "<2 mm>" && *c == [1.0, 210.0 / 255.0, 0.0, 1.0])
|
||||
});
|
||||
assert!(gold_remaining, "duration-remaining line is gold, not white");
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
@@ -817,3 +835,106 @@ fn set_craft_spell_selects_the_builder_like_the_trainer_hover_does() {
|
||||
);
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// Law §3-BUFF-DURATION's **gate**: `0x532b00` skips the duration block on `untilCancelled`
|
||||
/// (`532bda: 8b 46 0c` / `532bdf: 75 2d`) — never on "has this aura a duration yet". The three
|
||||
/// cases below are the ones that separate the two questions, and the middle two are what the old
|
||||
/// `duration > 0 && left > 0` gate got wrong.
|
||||
#[test]
|
||||
fn the_remaining_line_is_gated_on_until_cancelled_not_on_the_duration() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_spell_tooltip(
|
||||
1459,
|
||||
SpellTooltipView {
|
||||
name: "Arcane Intellect".into(),
|
||||
aura_description: "Intellect increased by 2.".into(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
s.tick(10.0);
|
||||
s.run(
|
||||
r#"
|
||||
SPELL_TIME_REMAINING_MIN = "<%d m>"
|
||||
SPELL_TIME_REMAINING_MIN_P1 = "<%d mm>"
|
||||
SPELL_TIME_REMAINING_SEC = "<%d s>"
|
||||
SPELL_TIME_REMAINING_SEC_P1 = "<%d ss>"
|
||||
BENILLA_ANCHOR = CreateFrame("Button", "BF2")
|
||||
BENILLA_ANCHOR:SetPoint("CENTER", 0, 0); BENILLA_ANCHOR:SetSize(10, 10)
|
||||
BENILLA_TIP = CreateFrame("GameTooltip", "TT2")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let hover = r#"
|
||||
BENILLA_TIP:SetOwner(BENILLA_ANCHOR, "ANCHOR_RIGHT")
|
||||
BENILLA_TIP:SetPlayerBuff(0)
|
||||
BENILLA_LINES = BENILLA_TIP:NumLines()
|
||||
BENILLA_LAST = BENILLA_LINES == 3 and TT2TextLeft3:GetText() or ""
|
||||
"#;
|
||||
let mut show = |aura: AuraState| {
|
||||
s.set_auras("player", Some(vec![aura]));
|
||||
s.run(hover).unwrap();
|
||||
(
|
||||
s.eval::<i64>("return BENILLA_LINES").unwrap(),
|
||||
s.eval::<String>("return BENILLA_LAST").unwrap(),
|
||||
)
|
||||
};
|
||||
let base = AuraState {
|
||||
spell_id: 1459,
|
||||
name: Some("Arcane Intellect".into()),
|
||||
helpful: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// 1 · Permanent — and carrying a duration anyway, so only the FLAG can be doing the work.
|
||||
// Title + description, nothing else.
|
||||
assert_eq!(
|
||||
show(AuraState {
|
||||
duration: 1800.0,
|
||||
expiration_time: 100.0,
|
||||
until_cancelled: true,
|
||||
..base.clone()
|
||||
}),
|
||||
(2, String::new()),
|
||||
"untilCancelled suppresses the line even with a live duration on the record"
|
||||
);
|
||||
|
||||
// 2 · Timed, but no duration packet has landed yet — the frame an aura appears. The old gate
|
||||
// blanked this; the reference shows the line, counting from zero.
|
||||
assert_eq!(
|
||||
show(AuraState {
|
||||
duration: 0.0,
|
||||
expiration_time: 0.0,
|
||||
until_cancelled: false,
|
||||
..base.clone()
|
||||
}),
|
||||
(3, "<0 ss>".to_string()),
|
||||
"a timed aura's first frames still carry the line"
|
||||
);
|
||||
|
||||
// 3 · Lapsed — the reading the reference's own truncating seconds arm produces, which the old
|
||||
// gate hid entirely.
|
||||
assert_eq!(
|
||||
show(AuraState {
|
||||
duration: 30.0,
|
||||
expiration_time: 9.6, // 0.4 s in the past at now = 10
|
||||
until_cancelled: false,
|
||||
..base.clone()
|
||||
}),
|
||||
(3, "<0 ss>".to_string()),
|
||||
"a lapsed aura reads 0, it does not lose the line"
|
||||
);
|
||||
|
||||
// Control: an ordinary live aura still counts, so none of the above is the line going missing
|
||||
// for an unrelated reason.
|
||||
assert_eq!(
|
||||
show(AuraState {
|
||||
duration: 1800.0,
|
||||
expiration_time: 100.0,
|
||||
until_cancelled: false,
|
||||
..base
|
||||
}),
|
||||
(3, "<2 mm>".to_string()),
|
||||
);
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
@@ -400,6 +400,96 @@ pub(super) fn set_shown(lua: &Lua, h: FrameHandle, shown: bool) {
|
||||
/// `0xffffd200` at `0xc0d3e8` (§0-BINDINGS verdict, 2026-07-14).
|
||||
pub(in crate::script) const DEFAULT_TEXT_GOLD: [f32; 4] = [1.0, 210.0 / 255.0, 0.0, 1.0];
|
||||
|
||||
/// The client's duration-string formatter, `0x52fa50` — the one that turns a millisecond count
|
||||
/// into "2 hours remaining" / "45 seconds remaining" (wow-re `ui/scratch/tooltip-content-law.md`
|
||||
/// §3-BUFF-TIME-FORMAT, the 2026-08-14 carve). Its parameter vector is the binary's:
|
||||
/// `(V_ms, keyPrefix, roundUp)`, minus the `extraArg` slot only the enchant line fills.
|
||||
///
|
||||
/// **The ladder, with the thresholds decoded from the compare instructions** (`52fa76`, `52fab4`,
|
||||
/// `52fb03`, falling through to `0x52fb4f`): `≥ 86_400_000` → `_DAYS` · `≥ 3_600_000` → `_HOURS` ·
|
||||
/// `≥ 60_000` → `_MIN` · else `_SEC`. Each arm divides by its own threshold, so the number shown is
|
||||
/// always in the unit the arm names.
|
||||
///
|
||||
/// **`roundUp` ceils — but only in the top three arms; `_SEC` always truncates.** That asymmetry is
|
||||
/// the whole reason this is a shared function and not four `format!`s, and it is what produces the
|
||||
/// reference's three surprising readings: 61 s is "2 minutes remaining" (ceil, not "1"), 3 599 999 ms
|
||||
/// is "60 minutes remaining" (the hour arm needs the full hour, so there is no "1 hour" until
|
||||
/// exactly 3 600 000), and the final second before an aura lapses reads "**0** seconds remaining"
|
||||
/// (truncation, not "1").
|
||||
///
|
||||
/// **The text is the player's own `GlobalStrings.lua`, never ours.** `get` is the VM-global lookup
|
||||
/// (`load_global_strings` runs the shipped file into this VM at boot, off the patch chain); the
|
||||
/// template arrives as `"%d minutes remaining"` and the count fills its one `%d`. A key the string
|
||||
/// table doesn't carry yields **no line at all** rather than an invented one — the same disposition
|
||||
/// 0620 §5 took for a reagent whose template hasn't streamed. That is also why this house ships no
|
||||
/// copy of the eight strings: on an install they are read, and off one there is nothing to render.
|
||||
pub(in crate::script) fn duration_text(
|
||||
ms: u32,
|
||||
key_prefix: &str,
|
||||
round_up: bool,
|
||||
get: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Option<String> {
|
||||
const DAY_MS: u32 = 86_400_000;
|
||||
const HOUR_MS: u32 = 3_600_000;
|
||||
const MIN_MS: u32 = 60_000;
|
||||
const SEC_MS: u32 = 1_000;
|
||||
|
||||
let (suffix, divisor) = if ms >= DAY_MS {
|
||||
("DAYS", DAY_MS)
|
||||
} else if ms >= HOUR_MS {
|
||||
("HOURS", HOUR_MS)
|
||||
} else if ms >= MIN_MS {
|
||||
("MIN", MIN_MS)
|
||||
} else {
|
||||
("SEC", SEC_MS)
|
||||
};
|
||||
// `roundUp` reaches the day/hour/minute arms only — the seconds arm has no rounding branch at
|
||||
// all, so it truncates whatever `roundUp` says.
|
||||
let n = if round_up && divisor != SEC_MS {
|
||||
ms.div_ceil(divisor)
|
||||
} else {
|
||||
ms / divisor
|
||||
};
|
||||
let template = plural_template(&format!("{key_prefix}_{suffix}"), n, get)?;
|
||||
Some(template.replacen("%d", &n.to_string(), 1))
|
||||
}
|
||||
|
||||
/// The `_P1` pick behind `GetText(token, gender, n)`. **The rule is the bare token for exactly one
|
||||
/// and the `_P1` twin otherwise** — so a zero count takes the plural, which is why the lapsing
|
||||
/// second reads "0 second*s* remaining".
|
||||
///
|
||||
/// **Byte-pinned** (wow-re §3-BUFF-PLURAL, `aff4df61`), which upgrades what this house had read
|
||||
/// twice by inference — `FriendsFrame.xml`'s `guildPlural` and `TradeSkillFrame.xml`'s
|
||||
/// `pluralAbbr`. The exe resolves nothing itself: `0x52fa50` snprintf's the bare key, then
|
||||
/// `0x703bf0` looks up the **Lua global** `GetText` (`703c43: ba c8 2c 87 00`) and pcalls it with
|
||||
/// (key, ordinal, gender), using what comes back as the printf format. The predicate lives in
|
||||
/// `LocaleProperties.lua`'s `GetPluralIndex` — singular iff `not ordinal or ordinal == 1` — and
|
||||
/// `> 1` is REFUTED: `703c89: 7d 07` is a *signed* test, so a zero ordinal is pushed as the number
|
||||
/// `0` (Lua truthy) and takes the plural arm; only a negative becomes nil. Had it mapped zero to
|
||||
/// nil the lapsing line would read "0 second remaining".
|
||||
///
|
||||
/// We collapse that to Rust rather than route through a `GetText` global because this line is
|
||||
/// computed engine-side off the live clock, and because `LocaleProperties.lua` is a file this house
|
||||
/// does not transcribe.
|
||||
///
|
||||
/// **Gender is not modelled, and that is faithful, not a gap:** `0x52fa50` pushes a literal `0` on
|
||||
/// both arms (`52fb8d: 6a 00`, `52fbaa: 6a 00`), which `0x703bf0` turns into nil, and enUS folds
|
||||
/// nil to no suffix.
|
||||
///
|
||||
/// **Falls back to the bare token when `_P1` is absent** — also byte-pinned: a nil `genderTag`
|
||||
/// sends `GetText` down its short arm to `getglobal(token)`, the singular. (When *both* are absent
|
||||
/// the reference renders an EMPTY line, off its pre-seeded `0x882748`; we render no line at all.
|
||||
/// The divergence is unreachable with shipped data — all eight of this family's keys ship — and off
|
||||
/// an install there is no string table to be faithful to.)
|
||||
fn plural_template(token: &str, n: u32, get: &dyn Fn(&str) -> Option<String>) -> Option<String> {
|
||||
if n != 1 {
|
||||
if let Some(p1) = get(&format!("{token}_P1")).filter(|s| !s.is_empty()) {
|
||||
return Some(p1);
|
||||
}
|
||||
}
|
||||
get(token).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// A colour-component argument — `lua_tonumber`'s coercion (a numeric string counts, `""` and
|
||||
/// other strings don't).
|
||||
fn color_num(v: Option<&Value>) -> Option<f32> {
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
//! - reagents — inline red per missing item (the one builder that uses the `|cffff2020` escape;
|
||||
//! joins when a reagent feed exists);
|
||||
//! - description — gold, wrapped. An AURA's description is **white** (byte-verified difference),
|
||||
//! and only `SetPlayerBuff` appends the duration-remaining line.
|
||||
//! and only `SetPlayerBuff` appends the duration-remaining line, which is **gold** again
|
||||
//! (`0xffffd200`, the title's gold — B62; see [`render_spell`]'s tail).
|
||||
//!
|
||||
//! The engine renders VIEWS ([`SpellTooltipView`]) the app resolves at push time — the $-token
|
||||
//! substitution (values off Spell.dbc + the player's level), cast-time/duration/range text —
|
||||
@@ -266,8 +267,14 @@ fn render_spell(
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// The duration-remaining line (`SetPlayerBuff` only) is GOLD `0xffffd200` — the same gold as
|
||||
// the aura title it sits under, NOT the description's white. Measured off the reporter's own
|
||||
// 1.12.1 reference shot for B62 (`media/1530672450247856148`, Ice Armor in Wetlands): the
|
||||
// "29 minutes remaining" glyphs read exactly `(255, 210, 0)`, pixel-identical to that shot's
|
||||
// "Ice Armor" / "Magic" title row, while its description rows read `(255, 255, 255)`. Ours
|
||||
// rendered it white (`media/1530672304877469696`, `(255, 255, 255)`) — that white *is* B62.
|
||||
if let Some(rem) = remaining {
|
||||
append_line(lua, this, (rem, WHITE), None, false)?;
|
||||
append_line(lua, this, (rem, GOLD), None, false)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -412,9 +419,10 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
// GameTooltip:SetPlayerBuff(buffIndex) — the buff-bar hover: the aura variant (white
|
||||
// AuraDescription) + the duration-remaining line only this entry point appends
|
||||
// (byte-verified; remaining computed live off the aura's GetTime expiry — the TEXT shape is
|
||||
// INTERIM until a live capture pins SPELL_TIME_REMAINING's exact number formatting).
|
||||
// AuraDescription) + the duration-remaining line only this entry point appends (byte-verified;
|
||||
// remaining computed live off the aura's GetTime expiry). The line's TEXT is no longer interim:
|
||||
// §3-BUFF-TIME-FORMAT pinned `0x52fa50`'s four-arm ladder and its rounding, and
|
||||
// `tooltip::duration_text` is it.
|
||||
//
|
||||
// **The argument is a 1.12 CACHE POSITION, not a filtered ordinal** — the same 0-based handle
|
||||
// `GetPlayerBuff` returns and every `GetPlayerBuff*` sibling consumes (see
|
||||
@@ -440,22 +448,31 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
g.get::<f64>("__benilla_now").unwrap_or(0.0)
|
||||
};
|
||||
let (spell_id, name, remaining) = {
|
||||
let (spell_id, name, remaining_ms) = {
|
||||
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let hit = usize::try_from(index)
|
||||
.ok()
|
||||
.and_then(|pos| model.auras.get("player").and_then(|a| a.get(pos)));
|
||||
match hit {
|
||||
Some(a) => {
|
||||
let left = a.expiration_time - now;
|
||||
let rem = (a.duration > 0.0 && left > 0.0).then(|| {
|
||||
if left >= 60.0 {
|
||||
format!("{} minutes remaining", (left / 60.0).ceil() as i64)
|
||||
} else {
|
||||
format!("{} seconds remaining", left.ceil() as i64)
|
||||
}
|
||||
// The gate is `untilCancelled`, NOT "does this aura have a duration yet".
|
||||
// §3-BUFF-DURATION: `0x532b00` skips the whole duration block when the
|
||||
// cache record's `+0xc` is set (`532bda: 8b 46 0c` / `532bdf: 75 2d`), so
|
||||
// a permanent aura shows title + description and nothing more. That flag
|
||||
// is DBC-derived, so it is already right on the frame an aura appears —
|
||||
// before any `SMSG_UPDATE_AURA_DURATION` lands — which is exactly why
|
||||
// `AuraState::until_cancelled`'s own doc calls it a different question
|
||||
// from `expiration_time == 0.0`. Gating on the duration (what this did)
|
||||
// blanked the line for a timed aura's first frames, and hid the
|
||||
// reference's own "0 seconds remaining" once one lapsed.
|
||||
let ms = (!a.until_cancelled).then(|| {
|
||||
// The reference counts integer milliseconds off GetTickCount; ours is
|
||||
// a float second clock, so this is the nearest millisecond to it.
|
||||
((a.expiration_time - now) * 1000.0)
|
||||
.round()
|
||||
.clamp(0.0, f64::from(u32::MAX)) as u32
|
||||
});
|
||||
(a.spell_id, a.name.clone(), rem)
|
||||
(a.spell_id, a.name.clone(), ms)
|
||||
}
|
||||
// The miss clears rather than leaving the previous plate up — the same shape
|
||||
// SetUnitBuff uses, and the one Loatheb's scan depends on: it breaks its loop
|
||||
@@ -463,6 +480,14 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
None => (0, None, None),
|
||||
}
|
||||
};
|
||||
// The text is `0x52fa50`'s, over the player's own GlobalStrings — never a string of
|
||||
// ours. Off an install the table is absent and the line simply does not render.
|
||||
let remaining = remaining_ms.and_then(|ms| {
|
||||
let g = lua.globals();
|
||||
super::tooltip::duration_text(ms, "SPELL_TIME_REMAINING", true, &|key| {
|
||||
g.get::<String>(key).ok()
|
||||
})
|
||||
});
|
||||
set_spell_by_id(
|
||||
lua,
|
||||
&this,
|
||||
|
||||
@@ -123,6 +123,11 @@ pub enum QuadContent {
|
||||
/// player arrow spins by it, standing in for the reference's engine arrow model; 0203
|
||||
/// flags the stand-in). `0.0` for the overwhelmingly common unrotated case.
|
||||
rotation: f32,
|
||||
/// `Texture:SetDesaturated(1)` — draw the sampled texel as its **luminance** instead of
|
||||
/// its colour (decision 1327). The renderer greys the texel and *then* modulates by
|
||||
/// `color`, because the reference's own consumers pass a dim tint alongside the flag
|
||||
/// (`SetItemButtonDesaturated(button, 1, 0.65, 0.65, 0.65)`) and expect both to land.
|
||||
desaturated: bool,
|
||||
},
|
||||
/// One frame `Backdrop` piece (the tiled bg, or one of the 8 border pieces) — emitted at the
|
||||
/// owning frame's own draw slot, behind its regions ([`UiScript::extract`](super::UiScript::extract)). A textured quad
|
||||
@@ -446,8 +451,9 @@ pub(crate) struct RegionData {
|
||||
/// unset inherited from the owner frame's rect.
|
||||
pub(crate) anchors: Vec<Anchor>,
|
||||
/// `Texture:SetDesaturated(flag)` — the shader desaturation state (`0x79c1e0`, verified in
|
||||
/// wow-re's ledger). **Stored; the renderer does not yet honour it**, which is exactly why the
|
||||
/// binding answers "shader unsupported" — see `region.rs`'s `SetDesaturated`.
|
||||
/// wow-re's ledger). Rides the extract as [`QuadContent::Texture::desaturated`] and the
|
||||
/// renderer greys the texel by it (decision 1327), so the binding answers "shader supported"
|
||||
/// — see `region/paint.rs`'s `SetDesaturated`.
|
||||
pub(crate) desaturated: bool,
|
||||
/// `SetNonSpaceWrap` / `CanNonSpaceWrap` — FontString only (`0x79e9f0`/`0x79ead0`, wow-re's
|
||||
/// widget-method batch). **State only here.** The real client's gx flag `0x40` feeds a
|
||||
|
||||
@@ -90,6 +90,11 @@ pub struct UnitState {
|
||||
/// This token is a PLAYER character (guid family) — the unit tooltip's level line renders
|
||||
/// "Race Class (Player)" instead of the creature type (decision 0276's verified law).
|
||||
pub is_player: bool,
|
||||
/// The unit's guild membership (`GetGuildInfo(unit)`, decision 1257). `None` = guildless, or
|
||||
/// a creature, or a player whose `PLAYER_GUILDID` has not streamed yet. Filled from the
|
||||
/// PUBLIC descriptor fields 191/192 joined against the app's guild-identity cache — see
|
||||
/// [`super::guild::UnitGuild`].
|
||||
pub guild: Option<super::guild::UnitGuild>,
|
||||
/// The NPC subtitle line ("Stable Master") — the creature template's subname; the unit
|
||||
/// tooltip's second line. `None` for players/untitled creatures.
|
||||
pub subtitle: Option<String>,
|
||||
|
||||
Reference in New Issue
Block a user