fishing becomes whole: the bite, the line, the splash and the reel
- the whole loop plays: the line arcs rod tip to bobber, the bite splashes with its own sound, and clicking the bobber reels in - the verdicts read like 1.12: yellow "your fish got away" lines, the reel-in sound, the fishing loot window - "You feel rested." prints on the transition, rest states come from the client's own Exhaustion table, and level 60 trades the XP strip for the max-level rail - the Skills pane lists what the real client lists: the hidden rows, the sort, gray numberless single-rank lines Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -149,17 +149,23 @@ fn set_slot_texture(lua: &Lua, this: &Table, slot: Slot, args: &MultiValue) -> m
|
||||
// plain region.
|
||||
Some(Value::String(s)) if s.to_str()?.is_empty() => {
|
||||
data.texture = None;
|
||||
data.color = None;
|
||||
data.fill = None;
|
||||
}
|
||||
Some(Value::String(s)) => data.texture = Some(s.to_str()?.to_string()),
|
||||
Some(Value::String(s)) => {
|
||||
data.texture = Some(s.to_str()?.to_string());
|
||||
data.fill = None;
|
||||
}
|
||||
// The colour form generates a solid texture into the same slot the path form loads into
|
||||
// ([`RegionData::fill`]) — never a tint, so each clears the other.
|
||||
Some(v @ (Value::Number(_) | Value::Integer(_))) => {
|
||||
let arg = |i: usize| args.get(i).map(as_f32);
|
||||
data.color = Some([
|
||||
data.fill = Some([
|
||||
as_f32(v),
|
||||
arg(1).unwrap_or(0.0),
|
||||
arg(2).unwrap_or(0.0),
|
||||
arg(3).unwrap_or(1.0),
|
||||
]);
|
||||
data.texture = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
.region_data
|
||||
.entry(rh)
|
||||
.or_default()
|
||||
.color = Some([r, g, b, a.unwrap_or(1.0)]);
|
||||
.vertex_color = Some([r, g, b, a.unwrap_or(1.0)]);
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
|
||||
@@ -149,8 +149,8 @@ impl UiScript {
|
||||
data.font_height = fo.height.or(data.font_height);
|
||||
data.font_shadow = fo.shadow.or(data.font_shadow);
|
||||
data.outline = fo.outline;
|
||||
if data.color.is_none() {
|
||||
data.color = fo.color;
|
||||
if data.vertex_color.is_none() {
|
||||
data.vertex_color = fo.color;
|
||||
}
|
||||
// The object's own justify (`<NormalFont inherits=… justifyH="LEFT"/>` —
|
||||
// how the ref left-aligns a ButtonText).
|
||||
@@ -164,7 +164,7 @@ impl UiScript {
|
||||
// The button-level state color wins over the font object AND the region's own
|
||||
// explicit color — the client's Button color slots repaint the label outright.
|
||||
if let Some(c) = state_color {
|
||||
data.color = Some(c);
|
||||
data.vertex_color = Some(c);
|
||||
}
|
||||
// Region-rect precedence (decision 0068 v1): anchored regions resolved in
|
||||
// [`resolve`] → their owner-relative rect; else an explicitly-sized region draws
|
||||
@@ -198,7 +198,7 @@ impl UiScript {
|
||||
let content = if is_text {
|
||||
QuadContent::Text {
|
||||
text: data.text,
|
||||
color: data.color,
|
||||
color: data.vertex_color,
|
||||
justify_h: data.justify_h,
|
||||
justify_v: data.justify_v,
|
||||
font: data.font_path,
|
||||
@@ -211,7 +211,7 @@ impl UiScript {
|
||||
} else {
|
||||
QuadContent::Texture {
|
||||
path: data.texture,
|
||||
color: data.color,
|
||||
color: texture_color(data.fill, data.vertex_color),
|
||||
additive: data.additive,
|
||||
tex_coords: data.tex_coords,
|
||||
circular: data.circular,
|
||||
@@ -263,6 +263,26 @@ impl UiScript {
|
||||
}
|
||||
}
|
||||
|
||||
/// The single colour a Texture region draws with: **`texel × vertexColour`**, per channel and
|
||||
/// **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).
|
||||
///
|
||||
/// `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
|
||||
/// renderer to modulate the sample by; `None` means untinted.
|
||||
///
|
||||
/// The correction this encodes: a `<Color 1,1,1,0.2>` trough later `SetVertexColor(0,0,0.75,0.5)`'d
|
||||
/// draws at alpha **0.1**. benilla used to store one colour slot and let the second call *replace*
|
||||
/// the first, which drew it at 0.5.
|
||||
fn texture_color(fill: Option<[f32; 4]>, vertex: Option<[f32; 4]>) -> Option<[f32; 4]> {
|
||||
match (fill, vertex) {
|
||||
(Some(f), Some(v)) => Some([f[0] * v[0], f[1] * v[1], f[2] * v[2], f[3] * v[3]]),
|
||||
(Some(c), None) | (None, Some(c)) => Some(c),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A StatusBar's bar-fill rect: the frame rect scaled by the value fraction — rightward from the
|
||||
/// left edge (horizontal) or upward from the bottom (vertical), the documented fill directions
|
||||
/// (reverse-fill is an Era extension, not modeled yet).
|
||||
|
||||
@@ -65,6 +65,10 @@ pub struct LootRow {
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct LootState {
|
||||
pub rows: Vec<LootRow>,
|
||||
/// Whether this loot came from fishing (`IsFishingLoot()` — the wire `SMSG_LOOT_RESPONSE`
|
||||
/// `loot_type == 3`, decision 1086). `LootFrame_OnShow` keys the "FISHING REEL IN" sound and
|
||||
/// the FishingLoot portrait overlay on it (`LootFrame.lua:137-140`).
|
||||
pub fishing: bool,
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
@@ -188,6 +192,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// IsFishingLoot() → whether the open loot came from fishing (false when none is open).
|
||||
// `LootFrame_OnShow` keys the reel-in sound + the fishing portrait overlay on it
|
||||
// (`LootFrame.lua:137-140`; decision 1086).
|
||||
g.set(
|
||||
"IsFishingLoot",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(model.loot.as_ref().is_some_and(|l| l.fishing))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// LootSlot(slot) — queue the 1-based row pick; the app maps it to the coin or the item wire slot.
|
||||
g.set(
|
||||
"LootSlot",
|
||||
@@ -252,6 +267,7 @@ mod tests {
|
||||
link: None,
|
||||
},
|
||||
],
|
||||
fishing: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +316,15 @@ mod tests {
|
||||
assert!(s.eval::<bool>("return GetLootSlotLink(1) == nil").unwrap());
|
||||
assert!(s.eval::<bool>("return GetLootSlotLink(3) == nil").unwrap());
|
||||
|
||||
// IsFishingLoot: false on an ordinary loot, true when the snapshot says fishing, false
|
||||
// again with no loot open (decision 1086).
|
||||
assert!(s.eval::<bool>("return not IsFishingLoot()").unwrap());
|
||||
let mut fished = loot();
|
||||
fished.fishing = true;
|
||||
s.set_loot(Some(fished));
|
||||
assert!(s.eval::<bool>("return IsFishingLoot()").unwrap());
|
||||
s.set_loot(Some(loot()));
|
||||
|
||||
// Out of range → nil.
|
||||
assert!(s.eval::<bool>("return GetLootSlotInfo(9) == nil").unwrap());
|
||||
assert!(s.eval::<bool>("return GetLootSlotLink(9) == nil").unwrap());
|
||||
|
||||
@@ -702,14 +702,26 @@ pub(crate) struct Model {
|
||||
/// The player's rest snapshot, pushed together each frame any part moves
|
||||
/// ([`UiScript::set_rest_state`]) — player-globals like `money`. `rest_state` is the raw
|
||||
/// `PLAYER_BYTES_2` byte 3 (1 = rested, 2 = normal — the server writes it with hysteresis
|
||||
/// off the pool; `0` until the feed lands, read as normal), `rest_pool` the raw
|
||||
/// off the pool). **Defaults to 2, not 0**: every live 1.12 descriptor carries 2 from
|
||||
/// character creation on, and the tick's faithful FrameXML compares `GetRestState() >= 3`
|
||||
/// unguarded — a pre-feed 0 would render the binary's nil-triple fail path into an event
|
||||
/// handler the real client can only ever run with the byte present. Byte 0 stays reachable
|
||||
/// by explicit push, and the fail path stays faithful there. `rest_pool` is the raw
|
||||
/// `PLAYER_REST_STATE_EXPERIENCE` value in **base kill-XP units**, `resting` the
|
||||
/// `PLAYER_FLAGS_RESTING (0x20)` bit (inside an inn/city). `GetRestState`/`GetXPExhaustion`/
|
||||
/// `IsResting` read these; the ×2 display scaling of the pool lives in the `GetXPExhaustion`
|
||||
/// binding, where the real client applies it (decision 1082).
|
||||
/// `IsResting` read these; the pool's display scaling is `exhaustion` row 1's factor,
|
||||
/// applied in the `GetXPExhaustion` binding, where the real client applies it (decisions
|
||||
/// 1082/1087).
|
||||
pub(crate) rest_state: u8,
|
||||
pub(crate) rest_pool: u32,
|
||||
pub(crate) resting: bool,
|
||||
/// Exhaustion.dbc as the rest bindings consume it — rest-state byte → (localized name,
|
||||
/// factor), the table `GetRestState` indexes directly and whose row 1 scales
|
||||
/// `GetXPExhaustion` (wow-re rested-xp-bindings.md; decision 1087). Seeded with the shipped
|
||||
/// 5875 enUS rows so the engine tests and a failed DBC read behave like the shipped client
|
||||
/// (the GlobalStrings-fallback posture); the app overwrites it with the install's real —
|
||||
/// localized — rows at startup ([`UiScript::set_exhaustion_rows`]).
|
||||
pub(crate) exhaustion: HashMap<u8, (String, f64)>,
|
||||
|
||||
/// The paper doll's combat-stats snapshot (`None` until the app's feed lands), the
|
||||
/// equipment/ammo slot views, and the model pane's persistent bake yaw — the character-window
|
||||
@@ -1005,9 +1017,18 @@ impl Model {
|
||||
player_next_level_xp: 0,
|
||||
combo_points: 0,
|
||||
combo_target: 0,
|
||||
rest_state: 0,
|
||||
rest_state: 2,
|
||||
rest_pool: 0,
|
||||
resting: false,
|
||||
exhaustion: [
|
||||
(1, ("Rested".to_string(), 2.0)),
|
||||
(2, ("Normal".to_string(), 1.0)),
|
||||
(3, ("XXXTired".to_string(), 1.0)),
|
||||
(4, ("XXXTired".to_string(), 0.5)),
|
||||
(5, ("XXXExhausted".to_string(), 0.25)),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
player_combat_stats: None,
|
||||
pet_combat_stats: None,
|
||||
inventory_slots: Default::default(),
|
||||
|
||||
@@ -61,7 +61,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let data = model.region_data.entry(rh).or_default();
|
||||
data.portrait_unit = Some(unit);
|
||||
data.texture = None;
|
||||
data.color = None;
|
||||
data.fill = None;
|
||||
data.circular = true;
|
||||
Ok(())
|
||||
})?,
|
||||
@@ -81,7 +81,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let data = model.region_data.entry(rh).or_default();
|
||||
data.portrait_unit = Some(token);
|
||||
data.texture = None;
|
||||
data.color = None;
|
||||
data.fill = None;
|
||||
data.circular = false;
|
||||
Ok(())
|
||||
})?,
|
||||
@@ -209,11 +209,28 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
|
||||
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
model.region_data.entry(rh).or_default().color = Some([r, g, b, a.unwrap_or(1.0)]);
|
||||
model.region_data.entry(rh).or_default().vertex_color =
|
||||
Some([r, g, b, a.unwrap_or(1.0)]);
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
// GetVertexColor — the setter's own pair, a real 5875 binding (`0x79aa50`, wow-re
|
||||
// `system/ui/ledger.tsv`; it sits directly above `SetVertexColor 0x79abd0` in the same region
|
||||
// method family). Never set = the untinted white every region draws at by default.
|
||||
m.set(
|
||||
"GetVertexColor",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
let c = model
|
||||
.region_data
|
||||
.get(&rh)
|
||||
.and_then(|d| d.vertex_color)
|
||||
.unwrap_or([1.0, 1.0, 1.0, 1.0]);
|
||||
Ok((c[0], c[1], c[2], c[3]))
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"SetTexture",
|
||||
lua.create_function(
|
||||
@@ -225,29 +242,35 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
|
||||
// and any live-unit-portrait binding.
|
||||
data.circular = false;
|
||||
data.portrait_unit = None;
|
||||
// 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
|
||||
// `+0xb8`: a tint outlives the art it was tinting.
|
||||
match &arg {
|
||||
// SetTexture("") clears, same as SetTexture(nil) — the ref lua blanks state
|
||||
// art with the empty string (QuestLogFrame.lua:165-166).
|
||||
Value::String(s) if s.to_str()?.is_empty() => {
|
||||
data.texture = None;
|
||||
data.color = None;
|
||||
data.fill = None;
|
||||
}
|
||||
Value::String(s) => {
|
||||
data.texture = Some(s.to_str()?.to_string());
|
||||
data.fill = None;
|
||||
}
|
||||
Value::Number(_) | Value::Integer(_) => {
|
||||
data.color = Some([
|
||||
data.fill = Some([
|
||||
as_f32(&arg),
|
||||
g.unwrap_or(0.0),
|
||||
b.unwrap_or(0.0),
|
||||
a.unwrap_or(1.0),
|
||||
]);
|
||||
data.texture = None;
|
||||
}
|
||||
// SetTexture(nil) clears (the live API's blank-the-region form); a cleared
|
||||
// texture region draws nothing.
|
||||
Value::Nil => {
|
||||
data.texture = None;
|
||||
data.color = None;
|
||||
data.fill = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -681,7 +704,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
|
||||
d.outline = font.outline;
|
||||
d.font_shadow = font.shadow;
|
||||
if let Some(c) = font.color {
|
||||
d.color = Some(c);
|
||||
d.vertex_color = Some(c);
|
||||
}
|
||||
if let Some(j) = font.justify_h {
|
||||
d.justify_h = j;
|
||||
@@ -773,8 +796,9 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
|
||||
Ok((path, height, flags))
|
||||
})?,
|
||||
)?;
|
||||
// SetTextColor(r, g, b [, a]) — a FontString's text color (distinct name from SetVertexColor,
|
||||
// same storage in v1: the resolved text color the renderer draws with).
|
||||
// SetTextColor(r, g, b [, a]) — a FontString's text color. A different binding name for the
|
||||
// same `+0xb8` vertex-colour slot `SetVertexColor` writes: a FontString has no texel of its own
|
||||
// to multiply against, so its vertex colour IS the colour it draws.
|
||||
m.set(
|
||||
"SetTextColor",
|
||||
lua.create_function(
|
||||
@@ -785,7 +809,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
|
||||
.region_data
|
||||
.entry(rh)
|
||||
.or_default()
|
||||
.color = Some([r, g, b, a.unwrap_or(1.0)]);
|
||||
.vertex_color = Some([r, g, b, a.unwrap_or(1.0)]);
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
|
||||
@@ -9,32 +9,47 @@
|
||||
//! id doing double duty as both the row's identity and the group key (here the GROUP key is the
|
||||
//! category, the row's identity is the skill id).
|
||||
//!
|
||||
//! ## The engine grouping law (INTERIM — the 0437 §5 dispatch resolved (decision 0446), but never
|
||||
//! adjudicated this law; it stays unpinned, its own follow-up named in decision 0530)
|
||||
//! ## The engine grouping law (PINNED at the bytes — decision 1091; wow-re
|
||||
//! `system/tradeskill/scratch/skillframe-display-list.md`, the list build `0x4d2cb0` + its
|
||||
//! comparator `0x4d3070`. The 0530 follow-up this once carried is closed.)
|
||||
//!
|
||||
//! Groups ordered by `category_order` ascending (`category_id` breaks a tie, for determinism);
|
||||
//! one header row per non-empty group (text = `category_name`); entries within a group sorted by
|
||||
//! name ascending (the trainer's own [`collate`] — case-insensitive, raw-byte tie-break). Every
|
||||
//! group starts EXPANDED (the trainer's own default-expanded rule). Visible rows are headers
|
||||
//! always, plus the entries of expanded groups; every index the Lua API takes/returns is 1-based
|
||||
//! into that visible list.
|
||||
//! one header row per non-empty group (text = `category_name`); within a group, **untrained lines
|
||||
//! (rank 0) sink below trained ones**, then name ascending ([`collate`] — case-insensitive,
|
||||
//! raw-byte tie-break; the client's own `stricmp`). Every group starts EXPANDED and is **re-expanded
|
||||
//! on every push** ([`UiScript::set_skills`] — the client's own `expandedMask = 0xFFFFFFFF`).
|
||||
//! Visible rows are headers always, plus the entries of expanded groups; every index the Lua API
|
||||
//! takes/returns is 1-based into that visible list.
|
||||
//!
|
||||
//! **Which lines reach the engine at all is the app's half** of the same law (`ui_char.rs`'s
|
||||
//! `feed_skills`): the client's list build drops a line with no `SkillLine`/`SkillRaceClassInfo`/
|
||||
//! `SkillLineCategory` row, one whose `SkillRaceClassInfo.flags` carries `0x2`, and an untrained
|
||||
//! one that no flag admits. The engine sorts what survives.
|
||||
//!
|
||||
//! ## The Era API shape (matched to the real `SkillFrame.lua`, transcribed onto this engine)
|
||||
//!
|
||||
//! `GetNumSkillLines()` → the visible row count. `GetSkillLineInfo(i)` → the ref's own 13-tuple
|
||||
//! `GetNumSkillLines()` → the visible row count. `GetSkillLineInfo(i)` → the ref's own tuple
|
||||
//! (`name, isHeader, isExpanded, skillRank, numTempPoints, skillModifier, skillMaxRank,
|
||||
//! isAbandonable, stepCost, rankCost, minLevel, skillCostType, skillDescription`): a header row
|
||||
//! shapes `(category_name, 1, expanded, 0, 0, 0, 0, nil, 0, 0, 0, nil, nil)`, an entry row shapes
|
||||
//! `(name, nil, nil, value, 0, modifier, max, abandonable, 0, 0, 0, nil, description)`.
|
||||
//! `numTempPoints` is always `0` (1.12 training points are dead data — `PLAYER_SKILL_INFO`
|
||||
//! carries no temp/perm split the client would animate through a "buy a rank" flow);
|
||||
//! `skillDescription` is REAL (`SkillLine.dbc` col 12 through the app feed — the detail pane's
|
||||
//! body text); `isAbandonable` is REAL too ([`SkillEntry::abandonable`], the unlearn button's
|
||||
//! gate — and `AbandonSkill(i)` is its outbound half, a VISIBLE index queued out by skill id for
|
||||
//! the app's `CMSG_UNLEARN_SKILL`, [`UiScript::take_skill_abandons`]); the remaining
|
||||
//! `stepCost`/`rankCost`/`minLevel`/`skillCostType` are vestigial stubs backing the ref's
|
||||
//! *training-up* branches (a trainer-taught skill step), which never apply to a line that only
|
||||
//! ever changes as a server descriptor delta.
|
||||
//! isAbandonable, stepCost, rankCost, minLevel, skillCostType, skillDescription`) — **13 values on
|
||||
//! a skill row, 12 on a header** (`0x4d3a20` vs `0x4d3768`; a header stops after `skillCostType`
|
||||
//! and carries no description slot at all). A header shapes
|
||||
//! `(category_name, 1, expanded, 0, 0, 0, 0, nil, nil, nil, 0, 0)`, an entry
|
||||
//! `(name, nil, nil, rank, 0, tempBonus, max, abandonable, nil, nil, minLevel, costIndex+1,
|
||||
//! description)`, with `rank`/`max` computed by [`displayed_ranks`] — the permanent bonus folded
|
||||
//! in, then `max` forced to `1` on a **single-rank** line whatever the server said
|
||||
//! ([`SkillEntry::mono`]; the pane's proficiency gate is `skillMaxRank == 1`).
|
||||
//!
|
||||
//! `numTempPoints` is always `0`: its only writer in the real client is `AddSkillUp`, wired solely
|
||||
//! to the training-up arrow this pane doesn't ship. `stepCost`/`rankCost` are always **nil** —
|
||||
//! for DATA reasons, not code ones (`SkillLine.skillCostsID` is 0 in all 123 rows, and the
|
||||
//! step-cost gate's flag bits are set on no line a player can hold) — and nil, not `0`, is what
|
||||
//! the ref's `if (stepCost)` / `elseif (rankCost or …)` branches read, since `0` is truthy in Lua.
|
||||
//! `minLevel` and `skillCostType` are REAL numbers off the `SkillRaceClassInfo` row (visually inert
|
||||
//! on this build: every branch they colour is repainted by the normal-skill/proficiency branches
|
||||
//! after it). `skillDescription` is REAL (`SkillLine.dbc` col 12 through the app feed — the detail
|
||||
//! pane's body text); `isAbandonable` is REAL too ([`SkillEntry::abandonable`], the unlearn
|
||||
//! button's gate — and `AbandonSkill(i)` is its outbound half, a VISIBLE index queued out by skill
|
||||
//! id for the app's `CMSG_UNLEARN_SKILL`, [`UiScript::take_skill_abandons`]).
|
||||
//!
|
||||
//! `ExpandSkillHeader(i)`/`CollapseSkillHeader(i)` take a header's VISIBLE index (`0` = all
|
||||
//! groups, the trainer's own collapse-all shape). `SetSelectedSkill(i)`/`GetSelectedSkill()` are a
|
||||
@@ -50,7 +65,7 @@
|
||||
//! sites nothing in [`SkillFrame.xml`] ever reaches. (The `UNLEARN_SKILL` popup, once in that
|
||||
//! list, ships for real now — the abandon slice above.)
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
@@ -63,12 +78,38 @@ pub struct SkillEntry {
|
||||
pub skill_id: u32,
|
||||
/// `SkillLine.dbc` name ("First Aid").
|
||||
pub name: String,
|
||||
/// Current rank.
|
||||
/// Current rank, raw off the descriptor. `0` = **untrained**, which is a sort key of its own
|
||||
/// (untrained lines sink under their category's trained ones — the client's comparator
|
||||
/// `0x4d3070`).
|
||||
pub value: u32,
|
||||
/// Max rank (a 1.12 proficiency line can be `0/0` — render barless, the ref's own shape).
|
||||
/// Max rank, as the server's own `PLAYER_SKILL_INFO` descriptor holds it — see [`Self::mono`]
|
||||
/// for the DBC override the API return goes through.
|
||||
pub max: u32,
|
||||
/// Temp+perm bonus (the green "+n"; negative possible).
|
||||
pub modifier: i32,
|
||||
/// `SkillRaceClassInfo.flags & 0x400` (`SKILL_FLAG_MONO_VALUE`) for the player's race/class:
|
||||
/// a **single-rank** line. The real client's `GetSkillLineInfo` overrides `skillMaxRank` to
|
||||
/// `1` for these, whatever the descriptor says (wow-re
|
||||
/// `system/tradeskill/scratch/skillframe-seed-abandon.md`: `0x4d3610`, `4d38b1 test ah,0x4`) —
|
||||
/// so a hunter's `Beast Mastery`, which vmangos happily reports as `300/300`, reads as a
|
||||
/// proficiency and `SkillFrame.lua`'s `skillMaxRank == 1` branch draws it gray with no rank
|
||||
/// text. The raw [`Self::max`] stays untouched here; the override lives at the API return,
|
||||
/// exactly where the binary puts it.
|
||||
pub mono: bool,
|
||||
/// The **temporary** bonus (auras/consumables/enchants; negative possible) — and only that:
|
||||
/// `GetSkillLineInfo`'s `skillModifier` is a signed read of the descriptor's temp half alone
|
||||
/// (`+0x850`), the green "+n" in the rank text. The permanent half is [`Self::perm_bonus`],
|
||||
/// which the client folds into the numbers instead.
|
||||
pub temp_bonus: i32,
|
||||
/// The **permanent** bonus (talents; `+0x852`, negative possible). Not a return of its own:
|
||||
/// the client adds it into BOTH `skillRank` and `skillMaxRank` before the mono override
|
||||
/// (`0x4d380c`/`0x4d385d`), and only when the raw value is nonzero — so a line the player
|
||||
/// doesn't have stays at a flat `0`, never `0 + bonus`.
|
||||
pub perm_bonus: i32,
|
||||
/// `SkillRaceClassInfo.reqLevel` — `GetSkillLineInfo`'s 11th return, pushed as a real number
|
||||
/// (`0x4d39ef`; nonzero in practice: Mail 40, Dual Wield 20).
|
||||
pub min_level: u32,
|
||||
/// `SkillRaceClassInfo.skillCostID` — `GetSkillLineInfo`'s 12th return is this **plus one**
|
||||
/// (`0x4d3a06`), which is why the engine adds the 1, not the feed.
|
||||
pub cost_index: u32,
|
||||
/// `SkillLine.dbc` categoryId.
|
||||
pub category_id: u32,
|
||||
/// Resolved category name ("Professions") — the header text.
|
||||
@@ -80,9 +121,10 @@ pub struct SkillEntry {
|
||||
/// format renders it verbatim).
|
||||
pub description: String,
|
||||
/// Whether the line can be unlearned — `GetSkillLineInfo`'s 8th return (`isAbandonable`), the
|
||||
/// detail pane's unlearn-button gate. App-resolved from `SkillRaceClassInfo.flags & 0x20`
|
||||
/// detail pane's unlearn-button gate. App-resolved, and a **conjunction**: the descriptor's
|
||||
/// skill **step** must be nonzero AND `SkillRaceClassInfo.flags & 0x20` must be set
|
||||
/// (`SKILL_FLAG_UNLEARNABLE` — the server's own `CMSG_UNLEARN_SKILL` gate, vmangos
|
||||
/// `SkillHandler.cpp`).
|
||||
/// `SkillHandler.cpp`). Both halves are the client's (`0x4d3953`–`0x4d3975`).
|
||||
pub abandonable: bool,
|
||||
}
|
||||
|
||||
@@ -110,15 +152,18 @@ pub(crate) struct SkillGroup {
|
||||
|
||||
impl super::UiScript {
|
||||
/// Replace the skills snapshot (0437 phase 4). Builds the display tree ([`build_groups`]) from
|
||||
/// the flat entries, prunes the collapsed set to categories that still exist in the new push
|
||||
/// (the trainer's own collapse-survives-an-update rule, [`super::trainer::UiScript::set_trainer`]),
|
||||
/// and re-anchors the selection to the SAME skill id if it's still present — else clears it
|
||||
/// (the tradeskill's own by-spell-id selection-persistence precedent).
|
||||
/// the flat entries, **re-expands every group**, and re-anchors the selection to the SAME
|
||||
/// skill id if it's still present — else clears it (the tradeskill's own by-spell-id
|
||||
/// selection-persistence precedent).
|
||||
///
|
||||
/// The re-expand is the client's, not a convenience: its list rebuild writes
|
||||
/// `expandedMask = 0xFFFFFFFF` unconditionally (`0x4d2cb0`, store at `0x4d2ce2`), so a fold
|
||||
/// survives only until the next skill-field change — the trainer's collapse-survives-an-update
|
||||
/// rule this pane once borrowed is simply a different window's law (decision 1091).
|
||||
pub fn set_skills(&mut self, state: SkillsState) {
|
||||
let mut model = self.model_mut();
|
||||
let groups = build_groups(&state.entries);
|
||||
let live: HashSet<u32> = groups.iter().map(|g| g.category_id).collect();
|
||||
model.skills_collapsed.retain(|c| live.contains(c));
|
||||
model.skills_collapsed.clear();
|
||||
if let Some(sid) = model.skills_selected {
|
||||
if !state.entries.iter().any(|e| e.skill_id == sid) {
|
||||
model.skills_selected = None;
|
||||
@@ -171,8 +216,14 @@ fn build_groups(entries: &[SkillEntry]) -> Vec<SkillGroup> {
|
||||
}
|
||||
let mut groups: Vec<SkillGroup> = map.into_values().collect();
|
||||
for g in &mut groups {
|
||||
g.entries
|
||||
.sort_by(|&a, &b| collate(&entries[a].name, &entries[b].name));
|
||||
// Within a category the client sorts UNTRAINED (rank 0) lines below trained ones, then by
|
||||
// name (`0x4d3070`: the `untrained` byte set at build time, `0x4d2e19 sete`, compared
|
||||
// before the `stricmp` at `0x4d318d`).
|
||||
g.entries.sort_by(|&a, &b| {
|
||||
(entries[a].value == 0)
|
||||
.cmp(&(entries[b].value == 0))
|
||||
.then_with(|| collate(&entries[a].name, &entries[b].name))
|
||||
});
|
||||
}
|
||||
groups.sort_by(|a, b| {
|
||||
a.order
|
||||
@@ -261,6 +312,35 @@ fn selected_index(model: &Model) -> u32 {
|
||||
.map_or(0, |p| (p + 1) as u32)
|
||||
}
|
||||
|
||||
/// `GetSkillLineInfo`'s `(skillRank, skillMaxRank)` pair for one entry — the client's own
|
||||
/// arithmetic, in its own order (`0x4d380c`–`0x4d38cb`):
|
||||
///
|
||||
/// ```text
|
||||
/// skillRank = rank > 0 ? rank + permBonus : rank // a line at 0 stays flat 0
|
||||
/// skillMaxRank = max > 0 ? max + permBonus : max
|
||||
/// if mono { skillMaxRank = 1; if skillRank > 1 { skillRank = 1 } } // override, then a MIN
|
||||
/// ```
|
||||
///
|
||||
/// The mono arm is an unconditional **override** of the max (not a clamp) and a **min** on the
|
||||
/// rank — the asymmetry is the binary's, and it is what makes a `300/300` class line read as the
|
||||
/// `1/1` proficiency `SkillFrame.lua` draws gray. Saturating at 0 keeps a negative permanent
|
||||
/// malus from wrapping the unsigned descriptor values.
|
||||
fn displayed_ranks(e: &SkillEntry) -> (i64, i64) {
|
||||
let fold = |v: u32| {
|
||||
if v > 0 {
|
||||
(i64::from(v) + i64::from(e.perm_bonus)).max(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
let (mut rank, mut max) = (fold(e.value), fold(e.max));
|
||||
if e.mono {
|
||||
max = 1;
|
||||
rank = rank.min(1);
|
||||
}
|
||||
(rank, max)
|
||||
}
|
||||
|
||||
/// A `bool` as the Era `1`/`nil` shape (the trainer/tradeskill's own helper, duplicated per the
|
||||
/// established per-module convention).
|
||||
fn era_bool(b: bool) -> Value {
|
||||
@@ -284,8 +364,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetSkillLineInfo(index) → the ref's own 13-tuple (module doc). `index` 1-based into the
|
||||
// visible tree; out of range → a single nil.
|
||||
// GetSkillLineInfo(index) → the ref's own tuple (module doc): 13 values on a skill row
|
||||
// (`0x4d3a20`), 12 on a header (`0x4d3768`). `index` 1-based into the visible tree; out of
|
||||
// range → a single nil.
|
||||
g.set(
|
||||
"GetSkillLineInfo",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -309,28 +390,33 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
Value::Integer(0), // skillModifier
|
||||
Value::Integer(0), // skillMaxRank
|
||||
Value::Nil, // isAbandonable
|
||||
Value::Integer(0), // stepCost
|
||||
Value::Integer(0), // rankCost
|
||||
Value::Nil, // stepCost
|
||||
Value::Nil, // rankCost
|
||||
Value::Integer(0), // minLevel
|
||||
Value::Nil, // skillCostType
|
||||
Value::Nil, // skillDescription
|
||||
Value::Integer(0), // skillCostType — the 12th and LAST header return
|
||||
]))
|
||||
}
|
||||
Row::Entry(ei) => {
|
||||
let e = &model.skills.entries[ei];
|
||||
let (rank, max) = displayed_ranks(e);
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
Value::String(lua.create_string(&e.name)?),
|
||||
Value::Nil, // isHeader
|
||||
Value::Nil, // isExpanded
|
||||
Value::Integer(i64::from(e.value)),
|
||||
Value::Integer(rank),
|
||||
Value::Integer(0), // numTempPoints — always 0 (module doc)
|
||||
Value::Integer(i64::from(e.modifier)),
|
||||
Value::Integer(i64::from(e.max)),
|
||||
Value::Integer(i64::from(e.temp_bonus)), // skillModifier — TEMP only
|
||||
Value::Integer(max),
|
||||
era_bool(e.abandonable), // isAbandonable
|
||||
Value::Integer(0), // stepCost
|
||||
Value::Integer(0), // rankCost
|
||||
Value::Integer(0), // minLevel
|
||||
Value::Nil, // skillCostType
|
||||
// stepCost/rankCost are nil on this build for DATA reasons, not code
|
||||
// ones (module doc) — and nil is load-bearing: `0` is truthy in Lua, so
|
||||
// returning it would send SkillFrame.lua down its "learnable skill"
|
||||
// branch on every row.
|
||||
Value::Nil,
|
||||
Value::Nil,
|
||||
Value::Integer(i64::from(e.min_level)), // minLevel — a real number
|
||||
// skillCostType — SkillCostIndex + 1, the client's own `0x4d3a06`.
|
||||
Value::Integer(i64::from(e.cost_index) + 1),
|
||||
Value::String(lua.create_string(&e.description)?), // skillDescription
|
||||
]))
|
||||
}
|
||||
@@ -412,7 +498,7 @@ mod tests {
|
||||
name: &str,
|
||||
value: u32,
|
||||
max: u32,
|
||||
modifier: i32,
|
||||
temp_bonus: i32,
|
||||
category_id: u32,
|
||||
category_name: &str,
|
||||
category_order: u32,
|
||||
@@ -422,7 +508,10 @@ mod tests {
|
||||
name: name.into(),
|
||||
value,
|
||||
max,
|
||||
modifier,
|
||||
temp_bonus,
|
||||
perm_bonus: 0,
|
||||
min_level: 0,
|
||||
cost_index: 0,
|
||||
category_id,
|
||||
category_name: category_name.into(),
|
||||
category_order,
|
||||
@@ -430,6 +519,9 @@ mod tests {
|
||||
// Fixture rule: the Professions category (id 2) is abandonable, weapons are not —
|
||||
// the real SkillRaceClassInfo 0x20 split's shape.
|
||||
abandonable: category_id == 2,
|
||||
// Fixture rule: the Class Skills category (id 3) is single-rank, the real 0x400
|
||||
// split's shape (a hunter's Beast Mastery).
|
||||
mono: category_id == 3,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,8 +633,10 @@ mod tests {
|
||||
assert_eq!(s.eval::<i64>("return GetNumSkillLines()").unwrap(), 6);
|
||||
}
|
||||
|
||||
/// A re-push keeps the SELECTION (by skill id) but throws every fold away — the client's list
|
||||
/// rebuild re-expands unconditionally (`expandedMask = 0xFFFFFFFF`, decision 1091).
|
||||
#[test]
|
||||
fn selection_and_expansion_persist_across_a_repush() {
|
||||
fn a_repush_keeps_the_selection_and_re_expands_every_group() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_skills(state());
|
||||
|
||||
@@ -556,12 +650,12 @@ mod tests {
|
||||
s.run("CollapseSkillHeader(4)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetNumSkillLines()").unwrap(), 4);
|
||||
|
||||
// A re-push (values ticked up) keeps BOTH the fold and the selection's skill identity —
|
||||
// Swords is still row 3, Professions is still collapsed.
|
||||
// A re-push (values ticked up): the fold is GONE — all 6 rows are back — while the
|
||||
// selection still points at Swords, which is still row 3.
|
||||
let mut ticked = state();
|
||||
ticked.entries[0].value = 201; // Swords
|
||||
s.set_skills(ticked);
|
||||
assert_eq!(s.eval::<i64>("return GetNumSkillLines()").unwrap(), 4);
|
||||
assert_eq!(s.eval::<i64>("return GetNumSkillLines()").unwrap(), 6);
|
||||
assert_eq!(s.eval::<i64>("return GetSelectedSkill()").unwrap(), 3);
|
||||
let (name, rank) = s
|
||||
.eval::<(String, i64)>("local n,_,_,r = GetSkillLineInfo(3) return n,r")
|
||||
@@ -580,7 +674,8 @@ mod tests {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_skills(state());
|
||||
|
||||
// Header row 1 ("Weapon Skills"): (name, 1, expanded, 0, 0, 0, 0, nil, 0, 0, 0, nil, nil).
|
||||
// Header row 1 ("Weapon Skills"): 12 values — (name, 1, expanded, 0, 0, 0, 0, nil, nil,
|
||||
// nil, 0, 0), and NO 13th (the client's header path pushes 12, `0x4d3768`).
|
||||
let (name, is_header, is_expanded, rank, temp, modifier, max) = s
|
||||
.eval::<(String, i64, Option<i64>, i64, i64, i64, i64)>(
|
||||
"local n,h,e,r,t,m,mx = GetSkillLineInfo(1) return n,h,e,r,t,m,mx",
|
||||
@@ -598,19 +693,22 @@ mod tests {
|
||||
),
|
||||
("Weapon Skills", 1, Some(1), 0, 0, 0, 0)
|
||||
);
|
||||
let (abandon_nil, step, rank_cost, min_level, cost_type_nil, desc_nil) = s
|
||||
.eval::<(bool, i64, i64, i64, bool, bool)>(
|
||||
"local _,_,_,_,_,_,_,a,st,rc,ml,ct,d = GetSkillLineInfo(1) \
|
||||
return a==nil, st, rc, ml, ct==nil, d==nil",
|
||||
let (abandon_nil, step_nil, rank_cost_nil, min_level, cost_type, count) = s
|
||||
.eval::<(bool, bool, bool, i64, i64, i64)>(
|
||||
"local a,st,rc,ml,ct = select(8, GetSkillLineInfo(1)) \
|
||||
return a==nil, st==nil, rc==nil, ml, ct, select('#', GetSkillLineInfo(1))",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(abandon_nil);
|
||||
assert_eq!((step, rank_cost, min_level), (0, 0, 0));
|
||||
assert!(cost_type_nil);
|
||||
assert!(desc_nil);
|
||||
assert!(
|
||||
step_nil && rank_cost_nil,
|
||||
"stepCost/rankCost are nil, not 0"
|
||||
);
|
||||
assert_eq!((min_level, cost_type), (0, 0));
|
||||
assert_eq!(count, 12, "a header row returns 12 values, not 13");
|
||||
|
||||
// Entry row 2 ("Defense", value 180, max 300, modifier +5): (name, nil, nil, 180, 0, 5,
|
||||
// 300, nil, 0, 0, 0, nil, nil).
|
||||
// Entry row 2 ("Defense", value 180, max 300, temp bonus +5): (name, nil, nil, 180, 0, 5,
|
||||
// 300, nil, nil, nil, 0, 1, description) — 13 values.
|
||||
let (name, is_header, is_expanded, rank, temp, modifier, max) = s
|
||||
.eval::<(String, Option<i64>, Option<i64>, i64, i64, i64, i64)>(
|
||||
"local n,h,e,r,t,m,mx = GetSkillLineInfo(2) return n,h,e,r,t,m,mx",
|
||||
@@ -628,12 +726,103 @@ mod tests {
|
||||
),
|
||||
("Defense", None, None, 180, 0, 5, 300)
|
||||
);
|
||||
// The 13th return is the REAL description now (SkillLine.dbc col 12 through the feed) —
|
||||
// a string on an entry row, still nil on a header (asserted above).
|
||||
// The 13th return is the REAL description (SkillLine.dbc col 12 through the feed) — an
|
||||
// entry row's alone: a header stops at 12 (asserted above).
|
||||
let (count, desc) = s
|
||||
.eval::<(i64, String)>(
|
||||
"return select('#', GetSkillLineInfo(2)), select(13, GetSkillLineInfo(2))",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((count, desc.as_str()), (13, "About Defense."));
|
||||
// minLevel and skillCostType are real NUMBERS on an entry — and the cost type is the
|
||||
// row's index PLUS ONE (the client's own `0x4d3a06`), so a fixture at index 0 reads 1.
|
||||
let (min_level, cost_type) = s
|
||||
.eval::<(i64, i64)>("local ml,ct = select(11, GetSkillLineInfo(2)) return ml,ct")
|
||||
.unwrap();
|
||||
assert_eq!((min_level, cost_type), (0, 1));
|
||||
}
|
||||
|
||||
/// The permanent bonus folds into BOTH numbers, the temporary one is the `skillModifier`
|
||||
/// return on its own, and a line at 0 stays flat 0 (the client's `rank > 0 ?` guard).
|
||||
#[test]
|
||||
fn the_permanent_bonus_folds_into_the_numbers_and_the_temporary_one_does_not() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let mut st = state();
|
||||
st.entries[1].perm_bonus = 10; // Defense 180/300, temp +5
|
||||
st.entries
|
||||
.push(entry(182, "Herbalism", 0, 0, 0, 2, "Professions", 2));
|
||||
st.entries.last_mut().unwrap().perm_bonus = 10;
|
||||
s.set_skills(st);
|
||||
|
||||
// Defense (row 2): 190/310, modifier still the temp +5 alone.
|
||||
let (rank, modifier, max) = s
|
||||
.eval::<(i64, i64, i64)>("local _,_,_,r,_,m,mx = GetSkillLineInfo(2) return r,m,mx")
|
||||
.unwrap();
|
||||
assert_eq!((rank, modifier, max), (190, 5, 310));
|
||||
|
||||
// Herbalism 0/0 (row 7, an untrained line sorted under the trained ones): a flat 0/0 —
|
||||
// the bonus is not added to a zero.
|
||||
let (name, rank, max) = s
|
||||
.eval::<(String, i64, i64)>("local n,_,_,r,_,_,mx = GetSkillLineInfo(7) return n,r,mx")
|
||||
.unwrap();
|
||||
assert_eq!((name.as_str(), rank, max), ("Herbalism", 0, 0));
|
||||
}
|
||||
|
||||
/// Within a category, untrained (rank 0) lines sink below the trained ones, whatever their
|
||||
/// name — the client's comparator (`0x4d3070`), which tests `untrained` before `stricmp`.
|
||||
#[test]
|
||||
fn untrained_lines_sort_under_the_trained_ones_of_their_category() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let mut st = state();
|
||||
// "Alchemy" would sort first in Professions by name; at rank 0 it goes last.
|
||||
st.entries
|
||||
.push(entry(171, "Alchemy", 0, 300, 0, 2, "Professions", 2));
|
||||
s.set_skills(st);
|
||||
let names: Vec<String> = (5..=7)
|
||||
.map(|i| {
|
||||
s.eval::<String>(&format!("return (GetSkillLineInfo({i}))"))
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(names, ["First Aid", "Fishing", "Alchemy"]);
|
||||
}
|
||||
|
||||
/// A single-rank line reports `1/1` however high the server's descriptor is (the client's own
|
||||
/// `SkillRaceClassInfo.flags & 0x400` arm: an override on the max, a min on the rank) — the
|
||||
/// pane's proficiency gate. A normal line is unaffected.
|
||||
#[test]
|
||||
fn a_mono_line_reports_max_rank_one_whatever_the_server_said() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let mut st = state();
|
||||
// Beast Mastery: category 3 ⇒ mono by the fixture rule, server-side 300/300.
|
||||
st.entries.push(entry(
|
||||
50,
|
||||
"Beast Mastery",
|
||||
300,
|
||||
300,
|
||||
0,
|
||||
3,
|
||||
"Class Skills",
|
||||
0,
|
||||
));
|
||||
s.set_skills(st);
|
||||
|
||||
// Row 1/2 = the Class Skills header + its single entry (category_order 0 sorts first).
|
||||
let (name, rank, modifier, max) = s
|
||||
.eval::<(String, i64, i64, i64)>(
|
||||
"local n,_,_,r,_,m,mx = GetSkillLineInfo(2) return n,r,m,mx",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return select(13, GetSkillLineInfo(2))")
|
||||
(name.as_str(), rank, modifier, max),
|
||||
("Beast Mastery", 1, 0, 1),
|
||||
"the descriptor's 300/300 reads 1/1 — max overridden, rank clamped under it"
|
||||
);
|
||||
// Defense (a weapon line, not mono) still reports its real 300.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return (select(7, GetSkillLineInfo(4)))")
|
||||
.unwrap(),
|
||||
"About Defense."
|
||||
300
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -200,9 +200,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let data = model.region_data.entry(rh).or_default();
|
||||
match &a1 {
|
||||
Value::String(s) => data.texture = Some(s.to_str()?.to_string()),
|
||||
Value::String(s) => {
|
||||
data.texture = Some(s.to_str()?.to_string());
|
||||
data.fill = None;
|
||||
}
|
||||
// A solid thumb writes the same slot the path form does — each clears the other.
|
||||
Value::Number(_) | Value::Integer(_) => {
|
||||
data.color = Some([
|
||||
data.texture = None;
|
||||
data.fill = Some([
|
||||
num_f32(&a1),
|
||||
num_f32(&a2),
|
||||
num_f32(&a3),
|
||||
|
||||
@@ -172,9 +172,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let data = model.region_data.entry(rh).or_default();
|
||||
match &a1 {
|
||||
Value::String(s) => data.texture = Some(s.to_str()?.to_string()),
|
||||
Value::String(s) => {
|
||||
data.texture = Some(s.to_str()?.to_string());
|
||||
data.fill = None;
|
||||
}
|
||||
// A solid bar writes the same slot the path form does — each clears the other.
|
||||
// `SetStatusBarColor` is the TINT (the fill region's vertex colour) and is a
|
||||
// separate slot entirely: a bar can carry art AND a colour, and they multiply.
|
||||
Value::Number(_) | Value::Integer(_) => {
|
||||
data.color = Some([
|
||||
data.texture = None;
|
||||
data.fill = Some([
|
||||
num_f32(&a1),
|
||||
num_f32(&a2),
|
||||
num_f32(&a3),
|
||||
@@ -211,7 +218,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let id = ensure_bar(lua, &this, None)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let rh = *model.id_to_region.get(&id).expect("bar region id");
|
||||
model.region_data.entry(rh).or_default().color = Some([r, g, b, a.unwrap_or(1.0)]);
|
||||
model.region_data.entry(rh).or_default().vertex_color =
|
||||
Some([r, g, b, a.unwrap_or(1.0)]);
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
@@ -223,7 +231,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let c = bar
|
||||
.and_then(|rh| model.region_data.get(&rh))
|
||||
.and_then(|d| d.color)
|
||||
.and_then(|d| d.vertex_color)
|
||||
.unwrap_or([1.0, 1.0, 1.0, 1.0]);
|
||||
Ok((c[0], c[1], c[2], c[3]))
|
||||
})?,
|
||||
|
||||
@@ -450,3 +450,79 @@ fn region_alpha_is_its_own_and_multiplies_the_owner_frames() {
|
||||
"a hidden region emits no quad"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The texture-colour composition law** (wow-re `system/ui/scratch/texture-color-composition.md`,
|
||||
/// VERIFIED): a region's own solid colour is a real *texel* (`SetTexture(r,g,b,a)` generates an 8×8
|
||||
/// block at `+0xcc`), its vertex colour is a *separate* slot (`+0xb8`), and the draw
|
||||
/// **multiplies** them per channel, alpha included — it does not replace.
|
||||
///
|
||||
/// This is the reference `SkillFrame` row trough, verbatim: declared `<Color 1,1,1,0.2>`, then
|
||||
/// `SetVertexColor(0, 0, 0.75, 0.5)`'d. It draws at alpha `0.2 × 0.5 = 0.1`. benilla stored ONE
|
||||
/// colour slot until this test, which made the second call replace the first and drew it at 0.5.
|
||||
#[test]
|
||||
fn a_solid_colour_texel_multiplies_with_the_vertex_colour() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "Owner")
|
||||
f:SetPoint("BOTTOMLEFT", 0, 0)
|
||||
f:SetWidth(100) f:SetHeight(50)
|
||||
trough = f:CreateTexture("Trough", "BACKGROUND")
|
||||
trough:SetTexture(1, 1, 1, 0.2)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
let solid = |s: &UiScript| {
|
||||
s.extract()
|
||||
.iter()
|
||||
.find_map(|q| match &q.content {
|
||||
QuadContent::Texture {
|
||||
path: None, color, ..
|
||||
} => Some(*color),
|
||||
_ => None,
|
||||
})
|
||||
.expect("solid-colour quad")
|
||||
};
|
||||
assert_eq!(
|
||||
solid(&s),
|
||||
Some([1.0, 1.0, 1.0, 0.2]),
|
||||
"untinted, the texel draws as declared"
|
||||
);
|
||||
|
||||
s.run("trough:SetVertexColor(0, 0, 0.75, 0.5)").unwrap();
|
||||
assert_eq!(
|
||||
solid(&s),
|
||||
Some([0.0, 0.0, 0.75, 0.1]),
|
||||
"texel x vertex, alpha included: 0.2 x 0.5 = 0.1, NOT 0.5"
|
||||
);
|
||||
// The API readback is the vertex slot itself, not the product — the two are distinct storage.
|
||||
assert_eq!(
|
||||
s.eval::<(f32, f32, f32, f32)>("return trough:GetVertexColor()")
|
||||
.unwrap(),
|
||||
(0.0, 0.0, 0.75, 0.5)
|
||||
);
|
||||
|
||||
// Art and a solid colour share the `+0xcc` slot: setting a path releases the generated texel,
|
||||
// and what is left is the tint alone for the renderer to modulate the sample by.
|
||||
s.run(r#"trough:SetTexture("Interface\\Bar.blp")"#).unwrap();
|
||||
let tinted = s
|
||||
.extract()
|
||||
.iter()
|
||||
.find_map(|q| match &q.content {
|
||||
QuadContent::Texture {
|
||||
path: Some(p),
|
||||
color,
|
||||
..
|
||||
} if p.contains("Bar") => Some(*color),
|
||||
_ => None,
|
||||
})
|
||||
.expect("art quad");
|
||||
assert_eq!(
|
||||
tinted,
|
||||
Some([0.0, 0.0, 0.75, 0.5]),
|
||||
"the vertex colour outlives the texel it was multiplying"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ fn apply_font(d: &mut RegionData, name: &str, fo: Option<&FontObject>) {
|
||||
d.outline = f.outline;
|
||||
d.font_shadow = f.shadow;
|
||||
if let Some(c) = f.color {
|
||||
d.color = Some(c);
|
||||
d.vertex_color = Some(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> {
|
||||
d.font_height = src.font_height;
|
||||
d.outline = src.outline;
|
||||
d.font_shadow = src.font_shadow;
|
||||
d.color = src.color;
|
||||
d.vertex_color = src.vertex_color;
|
||||
}
|
||||
None => {
|
||||
let font = if i == 1 {
|
||||
@@ -262,7 +262,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> {
|
||||
fn write_cell(model: &mut Model, rh: crate::widget::RegionHandle, text: &str, color: [f32; 4]) {
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
d.text = Some(text.to_string());
|
||||
d.color = Some(color);
|
||||
d.vertex_color = Some(color);
|
||||
d.hidden = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -314,16 +314,35 @@ pub(crate) struct RegionData {
|
||||
/// the owner's `+0xc8` at draw). [`UiScript::extract`](super::UiScript::extract) folds this into
|
||||
/// [`ExtractedQuad::alpha`] alongside that owner alpha.
|
||||
///
|
||||
/// **Open (wow-re gap):** the findings verify a two-factor draw multiply
|
||||
/// (`ownColorAlpha × frameAlpha`) but never pin whether `SetAlpha` writes a *distinct* field or
|
||||
/// simply the alpha channel of [`Self::color`]. We keep it distinct so `SetAlpha` on a
|
||||
/// FontString cannot clobber its font object's color. The two readings agree on every reference
|
||||
/// call site (none sets both on one region); a site that does is the trigger to settle it.
|
||||
/// **Settled for a Texture, kept distinct anyway:** `Texture:SetAlpha 0x79b580` is
|
||||
/// `SetVertexColor(r, g, b, a)` on the *same* `+0xa8`/`+0xb8` slot as [`Self::vertex_color`], so
|
||||
/// in the real client the two clobber each other. We keep them separate fields because the draw
|
||||
/// result is identical either way — both readings end in one product,
|
||||
/// `texel × ownColorAlpha × frameAlpha` — and no reference call site sets both on one region;
|
||||
/// separate storage additionally keeps `SetAlpha` on a FontString from wiping its font object's
|
||||
/// colour, which the CSimpleFontString half of that binding was never pinned for. The
|
||||
/// divergence is now *known* rather than open: a call site that sets both is where it bites.
|
||||
pub(crate) alpha: Option<f32>,
|
||||
/// A texture path (`SetTexture("Interface\\...")`).
|
||||
pub(crate) texture: Option<String>,
|
||||
/// A color: a solid fill (`SetTexture(r,g,b,a)`) or a vertex tint (`SetVertexColor`).
|
||||
pub(crate) color: Option<[f32; 4]>,
|
||||
/// The **solid-colour texture** (`SetTexture(r, g, b, a)`, and XML `<Texture><Color/></Texture>`
|
||||
/// with no `file=`) — the `+0xcc` slot [`Self::texture`] also occupies, so the two are mutually
|
||||
/// exclusive by construction: every writer of one clears the other. Not a tint. The real client
|
||||
/// really does *generate an 8×8 texture* here (`CSimpleTexture::SetTexture(const CImVector*)`
|
||||
/// `0x770360` → `0x44a9c0` → the `rep stos` generator `0x5c5350` fills 64 texels with the packed
|
||||
/// ARGB), which is why the XML alpha rides in the **texel** and multiplies with
|
||||
/// [`Self::vertex_color`] rather than being replaced by it.
|
||||
pub(crate) fill: Option<[f32; 4]>,
|
||||
/// The **vertex colour** (`SetVertexColor 0x79abd0` → `0x77f750`, writing `+0xb8`; a StatusBar's
|
||||
/// `SetStatusBarColor`; a FontString's `SetTextColor` and its font object's colour) — storage
|
||||
/// distinct from [`Self::fill`]/[`Self::texture`]'s `+0xcc`.
|
||||
///
|
||||
/// **Draw law** (wow-re `system/ui/scratch/texture-color-composition.md`, VERIFIED):
|
||||
/// `drawn = texel × vertexColour`, per channel, **alpha included**. `None` = never set = the
|
||||
/// untinted white every region draws at by default. This is why the reference `SkillFrame`'s
|
||||
/// row trough — declared `<Color 1,1,1,0.2>`, then `SetVertexColor(0, 0, 0.75, 0.5)`'d — draws
|
||||
/// at alpha `0.2 × 0.5 = 0.1`, not `0.5`. [`super::extract`] does that multiply.
|
||||
pub(crate) vertex_color: Option<[f32; 4]>,
|
||||
/// This region is a **portrait**: draw its texture masked to the inscribed circle (set by
|
||||
/// `SetPortraitToTexture`, and by `SetPortraitTexture`'s round unit binding). WoW portraits
|
||||
/// are circular — the frame ring is a thin band whose transparent corners would otherwise
|
||||
|
||||
@@ -540,46 +540,57 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// The rest-state trio (decision 1082) — player-level globals over the app's rest feed
|
||||
// The rest-state trio (decisions 1082/1087) — player-level globals over the app's rest feed
|
||||
// ([`UiScript::set_rest_state`]), the MainMenuBar exhaustion tick's and the player frame's
|
||||
// whole wire. INTERIM pending the dispatched wow-re verdict on `GetRestState 0x48d350` /
|
||||
// `GetXPExhaustion 0x48d3f0` / `IsResting 0x516ea0`; each claim below is flagged at its site.
|
||||
// whole wire. Byte-VERIFIED, wow-re `system/ui/scratch/rested-xp-bindings.md` (a §5 pair +
|
||||
// orchestrator arbitration): the surface is Exhaustion.dbc DATA, not client constants — the
|
||||
// rows live in the model ([`UiScript::set_exhaustion_rows`]; shipped-table fallback).
|
||||
//
|
||||
// GetRestState() → (stateID, stateName, multiplier). The id IS the wire byte
|
||||
// (`PLAYER_BYTES_2` byte 3): 1 = Rested (earn 200% of base kill XP), 2 = Normal (100%) — the
|
||||
// only two the live server ever writes (vmangos `SetRestBonus`; the beta tired/exhausted
|
||||
// tiers 3..5 survive as dead FrameXML branches). 0-before-feed reads as Normal. The names are
|
||||
// the live client's own returns (INTERIM: enUS spellings, source string table unverified);
|
||||
// the multiplier is what `EXHAUST_TOOLTIP1` renders ×100 ("200% of normal experience").
|
||||
// GetRestState() → (stateID, stateName, multiplier) — `0x48d350`: the raw `PLAYER_BYTES_2`
|
||||
// byte 3 indexes Exhaustion.dbc DIRECTLY (the `[0xc0dd78]` ID→row array) and the triple is
|
||||
// `(row.ID, row.name[locale], row.factor)`: 1 → (1, "Rested", 2.0), 2 → (2, "Normal", 1.0),
|
||||
// and FrameXML's dead 3..5 branches map to the real beta rows (XXXTired 1.0/0.5,
|
||||
// XXXExhausted 0.25). Every failure — byte 0 (pre-feed), byte past the table, no row —
|
||||
// returns (nil, nil, nil), the binary's own fail path. The multiplier is what
|
||||
// `EXHAUST_TOOLTIP1` renders ×100 ("200% of normal experience").
|
||||
g.set(
|
||||
"GetRestState",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(if model.rest_state == 1 {
|
||||
(1i64, "Rested", 2.0f64)
|
||||
} else {
|
||||
(2i64, "Normal", 1.0f64)
|
||||
Ok(match model.exhaustion.get(&model.rest_state) {
|
||||
Some((name, factor)) => (
|
||||
Value::Number(f64::from(model.rest_state)),
|
||||
Value::String(lua.create_string(name)?),
|
||||
Value::Number(*factor),
|
||||
),
|
||||
None => (Value::Nil, Value::Nil, Value::Nil),
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
// GetXPExhaustion() → the rested span in BAR-XP units, or nil once the pool is dry — the tick
|
||||
// parks at `currXP + this` and the fill bar spans up to it (`ExhaustionTick_Update`). ×2 of
|
||||
// the wire pool: the server drains `PLAYER_REST_STATE_EXPERIENCE` 1:1 against BASE kill XP
|
||||
// while granting +100% (vmangos `GetXPRestBonus`), so the bar advances two for every one the
|
||||
// pool loses (INTERIM: the ×2-in-client and the nil-when-zero shape are the dispatch's C1).
|
||||
// GetXPExhaustion() → the rested span in BAR-XP units — `0x48d3f0`: the u32 pool × the f32
|
||||
// factor of **Exhaustion.dbc row ID 1, hard-coded** (`[[0xc0dd78]+4]`, whatever the current
|
||||
// state) — 2.0 in the shipped data, which is the whole "rested XP is double" law: the server
|
||||
// drains the pool 1:1 against BASE kill XP while granting +100% (vmangos `GetXPRestBonus`),
|
||||
// and the client scales by exactly this row's factor. **nil is decided by the rest-state
|
||||
// byte, never by the pool** (`0x48d43b dec/jne`): byte ≠ 1 → nil whatever the pool holds
|
||||
// (vmangos's 0 < bonus ≤ 10 hysteresis window sends byte 2 with a nonzero pool — nil there),
|
||||
// and a rested byte with pool 0 → the NUMBER 0. The tick parks at `currXP + this`
|
||||
// (`ExhaustionTick_Update`).
|
||||
g.set(
|
||||
"GetXPExhaustion",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(if model.rest_pool == 0 {
|
||||
Ok(if model.rest_state != 1 {
|
||||
Value::Nil
|
||||
} else {
|
||||
Value::Integer(i64::from(model.rest_pool) * 2)
|
||||
let factor = model.exhaustion.get(&1).map_or(2.0, |(_, f)| *f);
|
||||
Value::Number(f64::from(model.rest_pool) * factor)
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
// IsResting() → 1/nil: inside a rest area (inn/city) right now — `PLAYER_FLAGS_RESTING
|
||||
// (0x20)`. The player frame's flashing zzz reads exactly this.
|
||||
// IsResting() → 1/nil: inside a rest area (inn/city) right now — `0x516ea0`, byte-VERIFIED:
|
||||
// PLAYER_FLAGS `shr 5; test 1` = bit 0x20, pushed as the NUMBER 1.0 or nil (the Lua-vanilla
|
||||
// predicate shape, not a boolean). The player frame's flashing zzz reads exactly this.
|
||||
g.set(
|
||||
"IsResting",
|
||||
lua.create_function(|lua, ()| {
|
||||
@@ -591,10 +602,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
// GetTimeToWellRested() → nil, always: nothing on the 1.12 wire carries a time-to-full
|
||||
// estimate, and `ExhaustionToolTipText` nil-guards the call (its EXHAUST_TOOLTIP4 line simply
|
||||
// never renders). INTERIM: the dispatch's Q5 — if the binary computes one client-side, this
|
||||
// grows the real formula.
|
||||
// GetTimeToWellRested() → nil, always — `0x48d4b0`, byte-VERIFIED: the whole binding is 11
|
||||
// bytes, `pushnil; return 1`. FrameXML's EXHAUST_TOOLTIP4 countdown branch is dead in 5875.
|
||||
g.set(
|
||||
"GetTimeToWellRested",
|
||||
lua.create_function(|_, ()| Ok(Value::Nil))?,
|
||||
|
||||
@@ -289,6 +289,19 @@ impl super::UiScript {
|
||||
model.resting = resting;
|
||||
}
|
||||
|
||||
/// Push Exhaustion.dbc — `(rest-state byte, localized name, factor)` rows for the
|
||||
/// `GetRestState`/`GetXPExhaustion` bindings (they read the table exactly as `0x48d350` /
|
||||
/// `0x48d3f0` read the client's own copy; decision 1087). Called once at startup off the
|
||||
/// patch chain; an empty push is ignored so a failed DBC read keeps the shipped-table
|
||||
/// fallback the model seeds.
|
||||
pub fn set_exhaustion_rows(&mut self, rows: Vec<(u8, String, f64)>) {
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut model = self.model_mut();
|
||||
model.exhaustion = rows.into_iter().map(|(id, n, f)| (id, (n, f))).collect();
|
||||
}
|
||||
|
||||
/// Push the player's banked combo points (`PLAYER_FIELD_BYTES` byte 1) and the unit they sit
|
||||
/// on (`PLAYER_FIELD_COMBO_TARGET`) — the pair `Player::SetComboPoints` writes together, taken
|
||||
/// together so they can never be read half-updated. Player-level PRIVATE fields, same shape as
|
||||
|
||||
Reference in New Issue
Block a user