your character speaks its refusals, and clicks land on the right frame
- your character speaks its refusals: `VocalUISounds.dbc` gets an adapter, `sound::vocal` transcribes `0x4580f0`'s per-race table and `0x458250`'s play cycle — four plays of one line arm the annoyed variations, and the bus cap of one keeps the annoyed and ordinary lines from playing at once; a displayed message now carries its catalog RECORD, so its sound comes off the same row (tag 0x44 plays the named cue, anything else speaks the line), and 36 of the keys benilla raises have a voice — cast failures, inventory refusals, the quest/auction/petition lines, taxi, bank, stable, mail, the talent wipe; `EnableErrorSpeech` joins the CVars at the registrar's own default and the Audio page grows 1.12's fourth Sound checkbox with the dependency ours was missing (the master greys Ambience AND Error Speech, not Music); seven of the twelve taxi refusals turn out to be the YELLOW info line, not the red one they all took - at equal (strata, level) the hit sweep takes the EARLIER-linked frame: `0x764aa0` inserts with a strict `ja`, so equal keys are appended, and `0x7660d0` sweeps each bucket from index 0 upward — the reverse of the reverse-draw-order walk we had, so draw order is unchanged and only the hit sweep reorders within a plane; the wheel becomes its own index, and the five full-area wheel-catcher Buttons (Craft, QuestLog, Skill, TradeSkill, Trainer) are deleted, since under the true tie law each would have swallowed its whole window's clicks — a bug the Skill window's own comment had already recorded once - one rasterizer settle for the whole ground-decal family, and it is the measured one: the shipped client constructs `CGxDeviceD3d`, so the OpenGL arm holding the −4.0 slope factor is never executed (factor 0 on 2485 of 2485 captured `glPolygonOffset` calls), and every number derived from it was 256× too small; the reference groups the blob shadow with the selection RING and the footprints on their own at 10×, the reverse of ours — but ours is reversed-Z float where the bake residual is millimetre-scale and constant with distance, so `DECAL_RASTER` collapses to one value (32768: the ring and the reticle had been under their own conflict at 8192 and simply never walked on a slope) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -769,16 +769,6 @@
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<!-- The scroll-wheel catcher (TradeSkillFrame.xml's own note) — MUST be first so every real
|
||||
row below out-ranks it in hit-testing. -->
|
||||
<Button name="BenillaCraftWheelCatcher">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"/>
|
||||
<Anchor point="BOTTOMRIGHT" relativePoint="BOTTOMRIGHT"/>
|
||||
</Anchors>
|
||||
<Scripts><OnMouseWheel>BenillaCraftFrame_OnMouseWheel(arg1)</OnMouseWheel></Scripts>
|
||||
</Button>
|
||||
|
||||
<!-- The top-right close X (ref a UIPanelCloseButton at TOPRIGHT -29,-8, l.688-696; the
|
||||
minimize-button art stands in — this house dialect ships no such template,
|
||||
TradeSkillFrame.xml's own precedent). -->
|
||||
|
||||
@@ -660,7 +660,7 @@
|
||||
},
|
||||
Audio = {
|
||||
"RowEnableAll", "RowMaster", "RowSound", "RowMusic", "RowAmbience",
|
||||
"RowEnableMusic", "RowEnableAmbience",
|
||||
"RowEnableMusic", "RowEnableAmbience", "RowEnableErrorSpeech",
|
||||
},
|
||||
Graphics = {
|
||||
"RowUiScale", "RowFarclip", "RowWorldDetail",
|
||||
@@ -1249,15 +1249,21 @@
|
||||
return
|
||||
end
|
||||
if key ~= "Audio" then return end
|
||||
-- 1.12's own rule, both halves: SoundOptionsFrame_UpdateDependencies greys check
|
||||
-- button 2 (Enable Ambience) AND button 4 (Enable Error Speech) while the master
|
||||
-- enable is unticked. Enable Music is deliberately left alive — the quirk the
|
||||
-- reference's own file has.
|
||||
local on = GetCVar("MasterSoundEffects") ~= "0"
|
||||
local box = OptionsFrameContainerBodyAudioRowEnableAmbienceCheck
|
||||
local label = OptionsFrameContainerBodyAudioRowEnableAmbienceLabel
|
||||
if on then
|
||||
box:Enable()
|
||||
label:SetFontObject("GameFontNormal")
|
||||
else
|
||||
box:Disable()
|
||||
label:SetFontObject("GameFontDisable")
|
||||
for _, row in {"RowEnableAmbience", "RowEnableErrorSpeech"} do
|
||||
local box = getglobal("OptionsFrameContainerBodyAudio"..row.."Check")
|
||||
local label = getglobal("OptionsFrameContainerBodyAudio"..row.."Label")
|
||||
if on then
|
||||
box:Enable()
|
||||
label:SetFontObject("GameFontNormal")
|
||||
else
|
||||
box:Disable()
|
||||
label:SetFontObject("GameFontDisable")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2321,6 +2327,22 @@
|
||||
self.soundQuirk = 1
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<!-- 1.12's fourth Sound checkbox, ENABLE_ERROR_SPEECH
|
||||
(SoundOptionsFrame.lua l.2) — the race/sex refusal
|
||||
lines your character says aloud, decision 1815. Its
|
||||
label and tooltip are the reference's own
|
||||
GlobalStrings entries. -->
|
||||
<Frame name="$parentRowEnableErrorSpeech" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowEnableAmbience" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowEnableAmbience" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "EnableErrorSpeech", "Enable Error Speech",
|
||||
"OPTION_TOOLTIP_ENABLE_ERROR_SPEECH")
|
||||
self.soundQuirk = 1
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
</Frames>
|
||||
</Frame>
|
||||
<!-- The Graphics page (0959): the 1.12 OptionsFrameSliders rows
|
||||
|
||||
@@ -28,12 +28,14 @@
|
||||
exists yet)" — false since the faux kit landed; six other windows ride it. The
|
||||
wheel handler lives on the window Frame itself: the engine bubbles a spin UP the parent chain
|
||||
(`script/pointer.rs` `mouse_wheel`), so a spin over a title row (no handler of its own) rises to
|
||||
this window and scrolls. (A bare `<Frame>` is not a mouse HIT target — only
|
||||
Button/CheckButton/EditBox/ScrollingMessageFrame are, `widget/mod.rs`'s ctor comment, and
|
||||
`enableMouse` is a no-op stub — but it is still a valid bubble ANCESTOR: bubbling checks for the
|
||||
handler, not mouse-enablement.) The `QuestLogWheelCatcher` BUTTON, declared FIRST so every
|
||||
real row out-ranks it in hit-testing (`order::hit_test` walks the draw-order-descending list),
|
||||
now only gives the list's EMPTY tail a hit-target whose spin also bubbles here.
|
||||
this window and scrolls. (A bare `<Frame>` is not mouse-enabled by its ctor — that set is
|
||||
Button/CheckButton/Slider/ColorSelect/EditBox, decision 1795 — but `enableMouse` and the
|
||||
auto-arming `<Scripts>` names both reach it, and it is a valid bubble ANCESTOR either way:
|
||||
bubbling checks for the handler, not mouse-enablement.) There is no wheel-catcher frame: a
|
||||
full-area mouse-enabled `<Button>` declared FIRST would win every tie in its bucket, not lose
|
||||
it — among equal `(strata, level)` the EARLIER-linked frame takes the point (decision 1816),
|
||||
so such a catcher eats the whole window's clicks. The list's empty tail is covered by
|
||||
`QuestLogListScrollFrame`, which is wheel-interactive by construction, and its spin bubbles here.
|
||||
|
||||
v1 scope/deviations from the pin (each cited again at its own widget below):
|
||||
- Both pages sit on real scroll frames now: the LIST on a `FauxScrollFrameTemplate`
|
||||
@@ -1410,16 +1412,6 @@
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<!-- The scroll-wheel catcher (this file's header comment) — MUST be first so every real
|
||||
widget below out-ranks it in hit-testing. -->
|
||||
<Button name="QuestLogWheelCatcher">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"/>
|
||||
<Anchor point="BOTTOMRIGHT" relativePoint="BOTTOMRIGHT"/>
|
||||
</Anchors>
|
||||
<Scripts><OnMouseWheel>BenillaQuestLogFrame_OnMouseWheel(arg1)</OnMouseWheel></Scripts>
|
||||
</Button>
|
||||
|
||||
<!-- pin §1: the top-right close X (UIPanelCloseButton in the ref; our engine ships no such
|
||||
template — QuestFrame.xml/BagFrame.xml already draw it by hand on the same art). -->
|
||||
<Button name="QuestLogFrameCloseButton">
|
||||
@@ -1566,12 +1558,11 @@
|
||||
<!-- The detail pane's real ScrollFrame (decision 0112's mechanism): 300×261, TOPLEFT of
|
||||
this window at (19,-175) — the ref's own `QuestLogDetailScrollFrame` position, this
|
||||
file's "Detail chain start" arithmetic that used to live above (list bottom-left
|
||||
(19,-168) - 7 = (19,-175)). Mouse-wheel-interactive by construction (no wheel-catcher
|
||||
needed — `widget/mod.rs`'s ScrollFrame ctor comment), and declared AFTER
|
||||
`QuestLogWheelCatcher` in this `<Frames>` list so it out-ranks the catcher for
|
||||
any wheel/click landing inside its own rect (this file's header comment on hit-test
|
||||
z-order) — scrolling over the detail text no longer scrolls the LIST underneath it,
|
||||
a latent mix-up the flat layout had no way to avoid. -->
|
||||
(19,-168) - 7 = (19,-175)). Mouse-wheel-interactive by construction
|
||||
(`widget/mod.rs`'s ScrollFrame ctor comment), so a spin inside its own rect is taken
|
||||
here and scrolling the detail text no longer scrolls the LIST underneath it — a
|
||||
latent mix-up the flat layout had no way to avoid. It needs no declaration-order
|
||||
trick to win that: nothing else in this window's bucket covers the detail rect. -->
|
||||
<ScrollFrame name="QuestLogDetailScrollFrame" inherits="UIPanelScrollFrameTemplate">
|
||||
<Size><AbsDimension x="300" y="261"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="19" y="-175"/></Offset></Anchor></Anchors>
|
||||
@@ -1832,12 +1823,11 @@
|
||||
untimed selection nothing, and gated on the window being effectively visible by
|
||||
`UiScript::tick`'s own OnUpdate gate, so it costs a closed window nothing. -->
|
||||
<OnUpdate>QuestLog_OnUpdate(arg1)</OnUpdate>
|
||||
<!-- The list wheel lives on the WINDOW, not just the child catcher: the engine bubbles a
|
||||
spin up the PARENT chain (script/pointer.rs mouse_wheel), so a spin over a quest-title
|
||||
row (no OnMouseWheel of its own) must reach this window to scroll — a sibling catcher
|
||||
never sees it (bubbling reaches ancestors, not siblings). The QuestLogWheelCatcher
|
||||
button now only gives the list's empty tail a hit-target that also bubbles here. (The
|
||||
detail ScrollFrame keeps its own OnMouseWheel and is unaffected.) -->
|
||||
<!-- The list wheel lives on the WINDOW: the engine bubbles a spin up the PARENT chain
|
||||
(script/pointer.rs mouse_wheel), so a spin over a quest-title row (no OnMouseWheel of
|
||||
its own) reaches this window and scrolls. That is the whole mechanism — a sibling
|
||||
frame could never serve it anyway, since bubbling reaches ancestors, not siblings.
|
||||
(The detail ScrollFrame keeps its own OnMouseWheel and is unaffected.) -->
|
||||
<OnMouseWheel>BenillaQuestLogFrame_OnMouseWheel(arg1)</OnMouseWheel>
|
||||
<!-- pin §1, QuestLog_OnShow/OnHide (QuestLogFrame.lua:85-95), wired via the frame's
|
||||
OnShow/OnHide (QuestLogFrame.xml:949-954) — the window-sound convention (decision
|
||||
|
||||
@@ -593,19 +593,6 @@
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<!-- The scroll-wheel catcher (TrainerFrame.xml's own note) — MUST be first so every real
|
||||
row below out-ranks it in hit-testing, and sized to the LIST REGION ONLY (rows span
|
||||
y -79..-295, x 22..309), NEVER the whole page: unlike the trainer's standalone
|
||||
window, this page is CharacterFrame's LAST child, so a full-page catcher
|
||||
out-ranks every EARLIER CharacterFrame child — it ate the tab row and the close X
|
||||
whenever Skills was up (the 2026-07-17 "can't switch back to Character" report;
|
||||
character_tests.rs's tab_round_trip_with_a_selected_skill_by_point is the repro). -->
|
||||
<Button name="SkillWheelCatcher">
|
||||
<Size><AbsDimension x="290" y="222"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="20" y="-76"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnMouseWheel>BenillaSkillFrame_OnMouseWheel(arg1)</OnMouseWheel></Scripts>
|
||||
</Button>
|
||||
|
||||
<!-- The expand/collapse-all tab (ref SkillFrameExpandButtonFrame, l.242-317 — the SAME
|
||||
QuestLogSortTab art TrainerFrame.xml's own BenillaTrainerExpandButtonFrame uses).
|
||||
The tab is SIZED BY ITS LABEL, the ref's own OnLoad law (l.312-316:
|
||||
|
||||
@@ -1042,16 +1042,6 @@
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<!-- The scroll-wheel catcher (TrainerFrame.xml's own note) — MUST be first so every real
|
||||
row below out-ranks it in hit-testing. -->
|
||||
<Button name="BenillaTradeSkillWheelCatcher">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"/>
|
||||
<Anchor point="BOTTOMRIGHT" relativePoint="BOTTOMRIGHT"/>
|
||||
</Anchors>
|
||||
<Scripts><OnMouseWheel>BenillaTradeSkillFrame_OnMouseWheel(arg1)</OnMouseWheel></Scripts>
|
||||
</Button>
|
||||
|
||||
<!-- The top-right close X (ref a UIPanelCloseButton at TOPRIGHT -29,-8, l.838-846; the
|
||||
minimize-button art stands in — this house dialect ships no such template,
|
||||
TrainerFrame.xml's own precedent). -->
|
||||
|
||||
@@ -718,17 +718,6 @@
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<!-- The scroll-wheel catcher (this file's header note) — MUST be first so every real row
|
||||
below out-ranks it in hit-testing; it gives the list's EMPTY tail a hit-target whose
|
||||
spin also bubbles to the window. -->
|
||||
<Button name="BenillaTrainerWheelCatcher">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"/>
|
||||
<Anchor point="BOTTOMRIGHT" relativePoint="BOTTOMRIGHT"/>
|
||||
</Anchors>
|
||||
<Scripts><OnMouseWheel>BenillaTrainerFrame_OnMouseWheel(arg1)</OnMouseWheel></Scripts>
|
||||
</Button>
|
||||
|
||||
<!-- The top-right close X (ref a UIPanelCloseButton at TOPRIGHT (-29,-8); the minimize-button
|
||||
art stands in). -->
|
||||
<Button name="BenillaTrainerCloseButton">
|
||||
|
||||
@@ -498,7 +498,7 @@ fn push_shadows(
|
||||
.anchored(key.feet)
|
||||
.rung(
|
||||
benilla_world::sky_order::Rung::SHADOW_SORT,
|
||||
benilla_world::sky_order::Rung::SHADOW_RASTER,
|
||||
benilla_world::sky_order::Rung::DECAL_RASTER,
|
||||
)
|
||||
.owner(entity);
|
||||
batch.vertices(&verts.0);
|
||||
|
||||
@@ -232,6 +232,11 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
same("MasterSoundEffects", "1"),
|
||||
same("EnableMusic", "1"),
|
||||
same("EnableAmbience", "1"),
|
||||
// Error speech (1815) — the race/sex refusal lines your character says. A real 1.12 CVar
|
||||
// (`CVar::Register` at `0x457877`, registrar default `"1"`; wow-re
|
||||
// `re/cvar/cvar-register-sites.tsv` row 54) and a real 1.12 checkbox: SoundOptionsFrame.lua's
|
||||
// `ENABLE_ERROR_SPEECH`, index 4, which the master enable greys along with Ambience.
|
||||
same("EnableErrorSpeech", "1"),
|
||||
// Zone reverb (1153). The binary registers this one `"1"` (`0x4573be`) and we register it
|
||||
// `"0"` — the only row here that knowingly leaves the registrar's default, because the
|
||||
// reference's reverb is EAX-over-hardware and that hardware has not existed since Vista:
|
||||
@@ -963,6 +968,7 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
|
||||
"mastersoundeffects" => knobs.sound.enabled = v != 0.0,
|
||||
"enablemusic" => knobs.sound.music_enabled = v != 0.0,
|
||||
"enableambience" => knobs.sound.ambience_enabled = v != 0.0,
|
||||
"enableerrorspeech" => knobs.sound.error_speech = v != 0.0,
|
||||
// The client's own parse for this one is literally `!= 0` too (`0x4574d0`: `setne al`).
|
||||
"soundreverb" => knobs.sound.reverb = v != 0.0,
|
||||
"soundoutputlimiter" => knobs.sound.limiter = v != 0.0,
|
||||
@@ -1331,7 +1337,7 @@ fn sync_cvars(
|
||||
.collect(),
|
||||
);
|
||||
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
|
||||
let session: [(&str, String); 42] = [
|
||||
let session: [(&str, String); 43] = [
|
||||
("MasterVolume", sound.master.to_string()),
|
||||
("SoundVolume", sound.sfx.to_string()),
|
||||
("MusicVolume", sound.music.to_string()),
|
||||
@@ -1339,6 +1345,7 @@ fn sync_cvars(
|
||||
("MasterSoundEffects", flag(sound.enabled)),
|
||||
("EnableMusic", flag(sound.music_enabled)),
|
||||
("EnableAmbience", flag(sound.ambience_enabled)),
|
||||
("EnableErrorSpeech", flag(sound.error_speech)),
|
||||
("SoundReverb", flag(sound.reverb)),
|
||||
("SoundOutputLimiter", flag(sound.limiter)),
|
||||
("uiScale", scale.0.to_string()),
|
||||
@@ -1675,6 +1682,7 @@ mod tests {
|
||||
assert_eq!(d["MasterSoundEffects"] != 0.0, sound.enabled);
|
||||
assert_eq!(d["EnableMusic"] != 0.0, sound.music_enabled);
|
||||
assert_eq!(d["EnableAmbience"] != 0.0, sound.ambience_enabled);
|
||||
assert_eq!(d["EnableErrorSpeech"] != 0.0, sound.error_speech);
|
||||
// Welded like the rest — and deliberately NOT the binary's registrar "1" (1153).
|
||||
assert_eq!(d["SoundReverb"] != 0.0, sound.reverb);
|
||||
assert_eq!(d["SoundOutputLimiter"] != 0.0, sound.limiter);
|
||||
|
||||
@@ -365,7 +365,7 @@ fn push_footprints(
|
||||
.anchored(print.anchor)
|
||||
.rung(
|
||||
benilla_world::sky_order::Rung::FOOTPRINT,
|
||||
benilla_world::sky_order::Rung::SHADOW_RASTER,
|
||||
benilla_world::sky_order::Rung::DECAL_RASTER,
|
||||
)
|
||||
.owner(lane.0);
|
||||
batch.extend(print.verts.iter().map(|v| EffectVertex {
|
||||
|
||||
@@ -208,7 +208,7 @@ pub(super) fn auction_removed_notification(item_entry: u32, auction: &mut Auctio
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ui_action::MsgKind;
|
||||
use benilla_ui::messages::MsgKind;
|
||||
|
||||
fn keys(auction: &AuctionOpen) -> Vec<(&'static str, MsgKind, Option<u32>)> {
|
||||
auction
|
||||
|
||||
@@ -404,7 +404,7 @@ mod tests {
|
||||
errors.0,
|
||||
[(
|
||||
"GM mode is ON".to_string(),
|
||||
crate::ui_action::MsgKind::Error
|
||||
benilla_ui::messages::MsgKind::Error
|
||||
)],
|
||||
"the SendNotification half is the RED toast (0x4945b0's flag 1), never a second line"
|
||||
);
|
||||
@@ -425,7 +425,7 @@ mod tests {
|
||||
errors.0,
|
||||
[(
|
||||
"You must be at least level 58 to enter.".to_string(),
|
||||
crate::ui_action::MsgKind::Info
|
||||
benilla_ui::messages::MsgKind::Info
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use benilla_protocol::messages::{mail_action, mail_error, MailListEntry};
|
||||
|
||||
use crate::net::{ClientCommand, NetCommands};
|
||||
use crate::ui_items::{EquipError, EquipErrors};
|
||||
use crate::ui_mail::{mail_error_text, MailOpen, MailPending, MailSendAck};
|
||||
use crate::ui_mail::{mail_refusal, MailOpen, MailPending, MailSendAck};
|
||||
|
||||
/// `SessionEvent::MailList` (`SMSG_MAIL_LIST_RESULT`) — replace the session's rows + fire the inbox
|
||||
/// repaint (via the feed's diff). The inbox handler auto-purges expired mail: any row whose timer ran
|
||||
@@ -74,8 +74,8 @@ pub(super) fn send_mail_result(
|
||||
}
|
||||
mail.send_acks.push(MailSendAck {
|
||||
ok: error == mail_error::OK,
|
||||
error_text: (error != mail_error::OK && error != mail_error::EQUIP_ERROR)
|
||||
.then(|| mail_error_text(error)),
|
||||
refusal: (error != mail_error::OK && error != mail_error::EQUIP_ERROR)
|
||||
.then(|| mail_refusal(error)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -92,7 +92,7 @@ pub(super) fn send_mail_result(
|
||||
required_level: None,
|
||||
bag_slot: 255,
|
||||
}),
|
||||
other => mail.errors.push(mail_error_text(other)),
|
||||
other => mail.errors.push(mail_refusal(other)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -270,7 +270,7 @@ pub(super) fn stable_result(
|
||||
// `ERR_NOT_ENOUGH_MONEY`, reached through `DisplayError` row 0x25.
|
||||
code::ERR_MONEY => {
|
||||
debug!("net: stable purchase refused — not enough money");
|
||||
errors.0.push("You don't have enough money.".to_string());
|
||||
errors.0.push("ERR_NOT_ENOUGH_MONEY");
|
||||
}
|
||||
code::SUCCESS_STABLE | code::SUCCESS_UNSTABLE | code::SUCCESS_BUY_SLOT => {
|
||||
let Some(npc) = stable_open.npc else {
|
||||
|
||||
@@ -278,7 +278,10 @@ mod tests {
|
||||
quest_giver_invalid(13, &mut giver);
|
||||
let msgs = giver.take_messages();
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(msgs[0].kind(), crate::ui_action::MsgKind::Chat);
|
||||
assert_eq!(
|
||||
benilla_ui::messages::kind_of(msgs[0].key),
|
||||
benilla_ui::messages::MsgKind::Chat
|
||||
);
|
||||
assert_eq!(msgs[0], UiError::key("ERR_QUEST_ALREADY_ON"));
|
||||
assert!(!giver.is_open(), "the ref closes the window on a refusal");
|
||||
}
|
||||
@@ -294,7 +297,10 @@ mod tests {
|
||||
quest_giver_failed(373, 4, &mut giver, &mut log, &commands);
|
||||
let msgs = giver.take_messages();
|
||||
assert_eq!(msgs.len(), 2);
|
||||
assert_eq!(msgs[0].kind(), crate::ui_action::MsgKind::Chat);
|
||||
assert_eq!(
|
||||
benilla_ui::messages::kind_of(msgs[0].key),
|
||||
benilla_ui::messages::MsgKind::Chat
|
||||
);
|
||||
assert_eq!(
|
||||
msgs[0],
|
||||
UiError {
|
||||
@@ -303,7 +309,10 @@ mod tests {
|
||||
fill_d: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(msgs[1].kind(), crate::ui_action::MsgKind::Error);
|
||||
assert_eq!(
|
||||
benilla_ui::messages::kind_of(msgs[1].key),
|
||||
benilla_ui::messages::MsgKind::Error
|
||||
);
|
||||
assert_eq!(msgs[1], UiError::key("ERR_INV_FULL"));
|
||||
assert!(!giver.is_open());
|
||||
}
|
||||
@@ -316,7 +325,10 @@ mod tests {
|
||||
quest_log_full(&mut giver);
|
||||
let msgs = giver.take_messages();
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(msgs[0].kind(), crate::ui_action::MsgKind::Error);
|
||||
assert_eq!(
|
||||
benilla_ui::messages::kind_of(msgs[0].key),
|
||||
benilla_ui::messages::MsgKind::Error
|
||||
);
|
||||
assert_eq!(msgs[0], UiError::key("ERR_QUEST_LOG_FULL"));
|
||||
assert!(
|
||||
giver.is_open(),
|
||||
|
||||
@@ -615,13 +615,12 @@ fn warm_effect_lane(
|
||||
] {
|
||||
// The rasterizer settle is a PAIR — constant and slope-scale — and both halves are
|
||||
// pipeline-key axes, so they are warmed as the pairs that actually ship, not as a
|
||||
// cross product. The foam's is the reference's own (`Rung::FOAM_RASTER`, 1809) and is
|
||||
// the only one carrying a slope term; a hole here is a live compile the first time
|
||||
// anyone wades.
|
||||
// cross product: every ground decal shares `Rung::DECAL_RASTER` (1817) and the foam
|
||||
// takes its own, much smaller one. A hole here is a live compile the first time anyone
|
||||
// wades or drops a shadow.
|
||||
for (raster_bias, raster_slope) in [
|
||||
(0, 0.0),
|
||||
(benilla_world::sky_order::Rung::DECAL_RASTER, 0.0),
|
||||
(benilla_world::sky_order::Rung::SHADOW_RASTER, 0.0),
|
||||
(
|
||||
benilla_world::sky_order::Rung::FOAM_RASTER,
|
||||
benilla_world::sky_order::Rung::FOAM_RASTER_SLOPE,
|
||||
|
||||
@@ -81,6 +81,13 @@ impl SoundKits {
|
||||
pub(super) fn roll(&mut self) -> u32 {
|
||||
self.rng.next()
|
||||
}
|
||||
|
||||
/// The kit table itself, for the one consumer that must resolve ids **ahead of** playing them:
|
||||
/// [`super::vocal`]'s table build, which needs a kit's variation count (the reference reads
|
||||
/// `0x45cda0(kit) + 0x94` there for exactly the same reason).
|
||||
pub(super) fn catalog(&self) -> &SoundKitCatalog {
|
||||
&self.catalog
|
||||
}
|
||||
}
|
||||
|
||||
/// One playing channel the pump owns — the client's ~0x90-byte channel struct, benilla-shaped.
|
||||
@@ -252,6 +259,13 @@ impl Bus {
|
||||
/// foley off `$FSD` is here as well, uncapped beside the capped step it accompanies.
|
||||
pub(super) const DEFAULT: Bus = Bus(0);
|
||||
|
||||
/// Bus 1 — **cap 1**. The **error speech** line your own character says (`0x458250`,
|
||||
/// `0x4582d4 mov ebx,1`), and the reference's only tenant of this bus. Its cap of one is
|
||||
/// load-bearing rather than incidental: `0x458250` attempts the annoyed line *and then falls
|
||||
/// through and attempts the ordinary one*, and it is this cap that keeps the second attempt
|
||||
/// from being heard on top of the first (`super::vocal` records the whole cycle).
|
||||
pub(super) const ERROR_SPEECH: Bus = Bus(1);
|
||||
|
||||
/// Bus 5 — **cap 1**. The attacker's exertion vocal (`CreatureSoundData.Exertion` = class 0,
|
||||
/// `ExertionCritical` = class 1), routed through `0x624786` → `0x623b10`. One exertion voice
|
||||
/// in the whole world at a time: a pack of mobs swinging together grunts *once*.
|
||||
@@ -529,7 +543,8 @@ pub(crate) enum KitRef<'a> {
|
||||
/// Resolve → gate → pick → decode → play. `pos: None` is the 2D path (UI, self sounds);
|
||||
/// `category` is the caller's volume bucket (module docs — the client's per-driver flag bits).
|
||||
/// Silently succeeds without playing when the kit is out of range or duplicate-suppressed
|
||||
/// (matching the client: gates are not errors).
|
||||
/// (matching the client: gates are not errors) — `Ok(false)` is that outcome, see
|
||||
/// [`play_kit_ext`]'s return.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn play_kit(
|
||||
kits: &mut SoundKits,
|
||||
@@ -540,7 +555,7 @@ pub(crate) fn play_kit(
|
||||
kit_ref: KitRef<'_>,
|
||||
pos: Option<Vec3>,
|
||||
category: SoundCategory,
|
||||
) -> Result<()> {
|
||||
) -> Result<bool> {
|
||||
play_kit_ext(
|
||||
kits,
|
||||
assets,
|
||||
@@ -565,6 +580,12 @@ pub(crate) fn play_kit(
|
||||
/// handling is unpinned; decision record with the drone build), and a **voice category** — the
|
||||
/// category latched on the unit's one-shot bark slot ([`ActiveChannel::voice`]); `Some` only for
|
||||
/// the barks the reference stores in `[unit+0xb20]`.
|
||||
///
|
||||
/// **Returns whether a channel actually opened** — the reference's own answer: its play core
|
||||
/// `0x45ce60` hands back the FMOD channel, `0` when any gate refused (an unresolvable kit, the
|
||||
/// per-bus cap, the duplicate walk), and callers that care read that zero. Almost none do, and
|
||||
/// `Ok(false)` reads exactly like `Ok(())` did for them; [`super::vocal`]'s escalation counter is
|
||||
/// the one place the distinction is the mechanism.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn play_kit_ext(
|
||||
kits: &mut SoundKits,
|
||||
@@ -576,7 +597,7 @@ pub(super) fn play_kit_ext(
|
||||
pos: Option<Vec3>,
|
||||
category: SoundCategory,
|
||||
extras: PlayExtras,
|
||||
) -> Result<()> {
|
||||
) -> Result<bool> {
|
||||
let PlayExtras {
|
||||
variant,
|
||||
source,
|
||||
@@ -592,7 +613,7 @@ pub(super) fn play_kit_ext(
|
||||
// reveal. A dropped one-shot under an opaque cover is a sound the reference never played:
|
||||
// its world load blocks, and the events these sounds voice happen unheard.
|
||||
if config.world_hold {
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
let kit = match kit_ref {
|
||||
KitRef::Id(id) => kits.catalog.get(id),
|
||||
@@ -614,13 +635,13 @@ pub(super) fn play_kit_ext(
|
||||
let d_sq = pos.map(|p| math::dist_sq(listener, p));
|
||||
if let Some(d_sq) = d_sq {
|
||||
if cutoff > 0.0 && !math::audible(d_sq, cutoff) {
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
let weights: Vec<u32> = kit.files.iter().map(|(_, w)| *w).collect();
|
||||
if weights.is_empty() {
|
||||
return Ok(()); // a kit with no files is playable-as-nothing, not an error
|
||||
return Ok(false); // a kit with no files is playable-as-nothing, not an error
|
||||
}
|
||||
|
||||
// The **per-bus concurrency cap** — the always-on arm of the pre-play gate `0x7a66a0`, checked
|
||||
@@ -630,7 +651,7 @@ pub(super) fn play_kit_ext(
|
||||
// distance-culled one only because the cull hands the channel back — and benilla's pump reaps a
|
||||
// culled channel out of this list on the same edge.
|
||||
if bus_at_cap(out.channels.iter().map(|c| c.bus), bus) {
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Duplicate suppression — byte-verified (wow-re uisound-tables.md, corrected §5): the FMOD
|
||||
@@ -640,7 +661,7 @@ pub(super) fn play_kit_ext(
|
||||
// limit[0x87ce60], 13 categories) — stays a deferral until that limit table is read out.
|
||||
let live_same_kit = out.channels.iter().filter(|c| c.kit == id).count();
|
||||
if no_duplicates_blocks(dedupe_exempt, flags, live_same_kit) {
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// **The coherent-copy cap** (decision 1560) — the fallback for the rows the reference leaves
|
||||
@@ -662,14 +683,14 @@ pub(super) fn play_kit_ext(
|
||||
// genuinely distinct mobs' impacts), while removing the coherent stack that does the damage.
|
||||
if same_kit_cap_blocks(dedupe_exempt, live_same_kit) {
|
||||
out.copies_dropped += 1;
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// The variation: an explicit index when the caller drives the cycle (the client's
|
||||
// `variant != -1`), else the weighted pick with depletion.
|
||||
let pick = match variant {
|
||||
Some(i) if i < weights.len() => i,
|
||||
Some(_) => return Ok(()), // out-of-range explicit variant: playable-as-nothing
|
||||
Some(_) => return Ok(false), // out-of-range explicit variant: playable-as-nothing
|
||||
None => kits.pick_variation(id, &weights),
|
||||
};
|
||||
let path = kits.catalog.get(id).expect("resolved above").files[pick]
|
||||
@@ -707,7 +728,7 @@ pub(super) fn play_kit_ext(
|
||||
// that can *stop another sound*, and it must not do that for a play the cheaper gates above
|
||||
// were going to drop anyway.
|
||||
if !claim_voice(out, amp) {
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mixer = out.mixer.as_mut().context("no audio device")?;
|
||||
@@ -761,7 +782,7 @@ pub(super) fn play_kit_ext(
|
||||
bus,
|
||||
latch,
|
||||
});
|
||||
Ok(())
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Does `unit` hold a **live one-shot voice channel** — the reference's `[unit+0xb20]` handle,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! **The message catalog's sound half** — the branch of `CGGameUI::DisplayError 0x496720` that
|
||||
//! nobody had wired (decision 1815).
|
||||
//!
|
||||
//! Every displayed client message is a row of the registry at `0xb4b498`
|
||||
//! ([`benilla_ui::messages`], decision 1770), and the dispatcher reads two of that row's fields
|
||||
//! before it ever reaches the text: `+0x08`, a **sound cue name**, and `+0x0c`, a **type tag**.
|
||||
//! The test is one comparison (`0x49673d cmp [row+0xc],0x44 / je 0x496784`) and it picks between
|
||||
//! two entirely different sounds:
|
||||
//!
|
||||
//! - **tag `== 0x44`** — play `+0x08` as a `SoundEntries` **name** through `PlaySoundByName`
|
||||
//! (`0x458030`), unless the name is the literal `"NONE"`. 30 of the 465 rows name a cue:
|
||||
//! `QUESTADDED`, `igQuestFailed`, `LEVELUP`, `TaxiNodeDiscovered`, `FRIENDJOINGAME`, …
|
||||
//! - **tag `!= 0x44`** — ignore `+0x08` entirely and speak the tag as an **error-speech line id**
|
||||
//! in the player's own race and gender voice ([`super::vocal`]). 56 rows do this.
|
||||
//!
|
||||
//! benilla's catalog has carried both fields since 1770 and read neither; the one place a message
|
||||
//! is displayed (`crate::ui_action::show_messages`) queues the record here instead, and this drains
|
||||
//! it. So a message's sound now comes from the same row its text and its surface do, and cannot
|
||||
//! drift from them — which is the whole argument for the table existing.
|
||||
//!
|
||||
//! **Where in `0x496720` this happens matters and is easy to get backwards** — the sound branch
|
||||
//! runs *first*, before the row's key guard and before any text is resolved. See
|
||||
//! [`MessageSounds::push`].
|
||||
//!
|
||||
//! **The reference's own gate ordering is preserved across the two arms and it is not symmetric.**
|
||||
//! The cue arm plays like any other UI kit and is silenced by `MasterSoundEffects` at the mixer,
|
||||
//! the way every other kit here is; the speech arm tests `MasterSoundEffects` *and*
|
||||
//! `EnableErrorSpeech` in its own body, before touching any state (`0x458264`/`0x45827f`) — see
|
||||
//! [`super::vocal::speak_line`].
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_ui::messages::MessageRecord;
|
||||
|
||||
use crate::net::{ObjectStore, SelfPlayer};
|
||||
use benilla_assets::WorldAssets;
|
||||
|
||||
use super::kit::{self, KitRef, SoundCategory, SoundKits};
|
||||
use super::vocal::{self, VocalSpeech, NO_SPEECH_TAG};
|
||||
use super::{AudioListener, SoundConfig, SoundOutput};
|
||||
|
||||
/// Catalog rows displayed this frame, awaiting their sound.
|
||||
///
|
||||
/// Pushed by `crate::ui_action::show_messages` — the one sink every displayed message passes
|
||||
/// through — and drained by [`play_message_sounds`] after the UI input pass, so a refusal sounds on
|
||||
/// the frame its line appears. Rows, not resolved sounds: the *reading* of `+0x08`/`+0x0c` belongs
|
||||
/// here beside the players, not scattered across the windows that raise messages.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct MessageSounds(Vec<&'static MessageRecord>);
|
||||
|
||||
impl MessageSounds {
|
||||
/// Queue one displayed message's sound.
|
||||
///
|
||||
/// **The reference sounds a message BEFORE it decides whether to draw it**, and that ordering
|
||||
/// is read, not assumed: `0x496720`'s sound branch is at `0x49673d`, ahead of the
|
||||
/// `[record+0x00]` key guard at `0x4967bd`/`0x4967c5` and far ahead of the sink's
|
||||
/// empty-*text* guard at `0x4945b4`. So a message whose GlobalStrings entry is missing still
|
||||
/// makes its noise there, while benilla — where every raise site drops an empty line before it
|
||||
/// reaches [`show_messages`](crate::ui_action::show_messages) — would stay silent.
|
||||
///
|
||||
/// **On 5875's data that difference has no case**: of the 86 rows that sound at all (56 voice
|
||||
/// lines + 30 cues), every single one resolves a non-empty string in the shipped
|
||||
/// `GlobalStrings.lua` — measured, not assumed (decision 1815). The rows with no text are the
|
||||
/// silent ones, `ERR_CANT_BE_DISENCHANTED` among them, so nothing is lost by hanging the sound
|
||||
/// off the display. It is written down because the *ordering* is the surprising part, and a
|
||||
/// future locale or a patched table could give it a tenant.
|
||||
pub(crate) fn push(&mut self, record: &'static MessageRecord) {
|
||||
self.0.push(record);
|
||||
}
|
||||
|
||||
/// What is queued — for the tests that drive the producer
|
||||
/// (`crate::ui_action::show_messages`) and read back what the drain would be handed.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn queued(&self) -> &[&'static MessageRecord] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain [`MessageSounds`]: cue by name, or speech by line (module doc).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn play_message_sounds(
|
||||
mut queue: ResMut<MessageSounds>,
|
||||
mut speech: ResMut<VocalSpeech>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
kits: Option<ResMut<SoundKits>>,
|
||||
assets: Option<Res<WorldAssets>>,
|
||||
mut out: NonSendMut<SoundOutput>,
|
||||
config: Res<SoundConfig>,
|
||||
listener: Res<AudioListener>,
|
||||
) {
|
||||
if queue.0.is_empty() {
|
||||
return;
|
||||
}
|
||||
let records = std::mem::take(&mut queue.0);
|
||||
// Drained even when there is nothing to play it with (headless, no client data), so the queue
|
||||
// can never grow unbounded — `super::ui`'s posture exactly.
|
||||
let (Some(mut kits), Some(assets)) = (kits, assets) else {
|
||||
return;
|
||||
};
|
||||
// The reference reads the player's sex per play (`0x49676f` → `0x5ed5b0`), not at table-build
|
||||
// time; a descriptor that has not arrived yet means no voice, not a guessed one.
|
||||
let sex = self_q.iter().next().and_then(|s| s.0.unit_gender());
|
||||
for record in records {
|
||||
if record.type_tag == NO_SPEECH_TAG {
|
||||
let Some(name) = record.sound else {
|
||||
continue; // the 435 silent rows, and the `"NONE"` the generator already folded in
|
||||
};
|
||||
if let Err(e) = kit::play_kit(
|
||||
&mut kits,
|
||||
&assets,
|
||||
&mut out,
|
||||
&config,
|
||||
listener.pos,
|
||||
KitRef::Name(name),
|
||||
None, // 2D — `0x458030`'s plays are non-positional UI kits
|
||||
SoundCategory::Sfx,
|
||||
) {
|
||||
warn!("sound(message): cue {name:?} for {}: {e:#}", record.key);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(sex) = sex else { continue };
|
||||
vocal::speak_line(
|
||||
record.type_tag,
|
||||
&mut speech,
|
||||
u32::from(sex),
|
||||
&mut kits,
|
||||
&assets,
|
||||
&mut out,
|
||||
&config,
|
||||
listener.pos,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Registration hook for [`super::SoundPlugin`].
|
||||
pub(super) fn plugin(app: &mut App) {
|
||||
app.init_resource::<MessageSounds>().add_systems(
|
||||
Update,
|
||||
// After the UI input pass, beside `super::ui`'s own drain and for the same reason: the
|
||||
// message feeds run before it, so a refusal raised this frame sounds this frame.
|
||||
play_message_sounds.after(crate::ui_script::UiInput),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use benilla_ui::messages::{by_key, MsgKind};
|
||||
|
||||
use super::NO_SPEECH_TAG;
|
||||
|
||||
/// The two arms are disjoint over the real table, which is what lets the drain branch on one
|
||||
/// comparison: **no row both names a cue and carries a speech line**. (30 rows name a cue and
|
||||
/// every one of them is tagged `0x44`; 56 carry a line and none names a cue.)
|
||||
#[test]
|
||||
fn a_row_is_either_a_cue_or_a_voice_line_never_both() {
|
||||
let mut cues = 0;
|
||||
let mut voices = 0;
|
||||
for r in benilla_ui::messages::CATALOG {
|
||||
if r.type_tag == NO_SPEECH_TAG {
|
||||
cues += usize::from(r.sound.is_some());
|
||||
} else {
|
||||
voices += 1;
|
||||
assert!(
|
||||
r.sound.is_none(),
|
||||
"{} is both a cue and a voice line",
|
||||
r.key
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!((cues, voices), (30, 56));
|
||||
}
|
||||
|
||||
/// The lines a player hears most, pinned to the tags the shipped `VocalUISounds.dbc` voices —
|
||||
/// the join this whole module exists to make, asserted on both tables at once so a
|
||||
/// regenerated catalog that moved a tag fails here rather than going quiet in the world.
|
||||
#[test]
|
||||
fn the_everyday_refusals_carry_their_voice_lines() {
|
||||
for (key, tag, kind) in [
|
||||
("ERR_OUT_OF_MANA", 0x0f, MsgKind::Error),
|
||||
("ERR_OUT_OF_RAGE", 0x3f, MsgKind::Error),
|
||||
("ERR_OUT_OF_ENERGY", 0x40, MsgKind::Error),
|
||||
("ERR_SPELL_OUT_OF_RANGE", 0x2e, MsgKind::Error),
|
||||
("ERR_SPELL_COOLDOWN", 0x0c, MsgKind::Error),
|
||||
("ERR_ABILITY_COOLDOWN", 0x32, MsgKind::Error),
|
||||
("ERR_INV_FULL", 0x00, MsgKind::Error),
|
||||
("ERR_BAG_FULL", 0x1d, MsgKind::Error),
|
||||
("ERR_NOT_ENOUGH_MONEY", 0x28, MsgKind::Error),
|
||||
("ERR_ITEM_LOCKED", 0x3d, MsgKind::Error),
|
||||
("ERR_GENERIC_NO_TARGET", 0x2d, MsgKind::Error),
|
||||
// Not every voiced line is a red one: the taxi's refusal is the YELLOW info line and
|
||||
// still speaks, which is the reason the drain never reads `kind`.
|
||||
("ERR_TAXINOTENOUGHMONEY", 0x36, MsgKind::Info),
|
||||
// …nor is every one a UIErrorsFrame line at all — this one is a chat row that speaks.
|
||||
("ERR_ALREADY_IN_GROUP_S", 0x14, MsgKind::Chat),
|
||||
] {
|
||||
let r = by_key(key).unwrap_or_else(|| panic!("{key} is not a catalog row"));
|
||||
assert_eq!(r.type_tag, tag, "{key}");
|
||||
assert_eq!(r.kind, kind, "{key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ mod kit;
|
||||
mod limiter;
|
||||
mod liquid_loop;
|
||||
mod math;
|
||||
mod message;
|
||||
mod meter;
|
||||
mod missile;
|
||||
mod mix_tap;
|
||||
@@ -41,12 +42,14 @@ mod reverb;
|
||||
mod sheathe;
|
||||
mod spell;
|
||||
mod ui;
|
||||
mod vocal;
|
||||
mod water;
|
||||
mod weather;
|
||||
mod zone;
|
||||
pub(crate) use emote::EmoteSounds;
|
||||
pub(crate) use glue::GlueSound;
|
||||
pub(crate) use greeting::NpcGreetingRequest;
|
||||
pub(crate) use message::MessageSounds;
|
||||
pub(crate) use mixer::Mixer;
|
||||
pub(crate) use ui::{AutoEquipSound, LootPickupSound};
|
||||
pub(crate) use zone::ExplorationSounds;
|
||||
@@ -90,6 +93,12 @@ pub(crate) struct SoundConfig {
|
||||
/// the stream alive at zero, so re-enabling resumes mid-track where the reference re-picks.
|
||||
pub music_enabled: bool,
|
||||
pub ambience_enabled: bool,
|
||||
/// **Error speech** — 1.12's `EnableErrorSpeech` CVar (`CVar::Register` at `0x457877`,
|
||||
/// registrar default `"1"`, the stock Sound panel's fourth checkbox). Gates the race/sex
|
||||
/// refusal lines your own character says ([`vocal`], decision 1815) and nothing else: it is
|
||||
/// read inside `0x458250` alongside `MasterSoundEffects`, *before* any escalation state moves,
|
||||
/// so turning it off is silence rather than a muted play.
|
||||
pub error_speech: bool,
|
||||
/// Zone reverb — 1.12's `SoundReverb` CVar (`0x4573be` registration, callback `0x4574d0`,
|
||||
/// flag byte `[0x835a4c]`). The flag gates **both** EAX paths: the zone/environment preset
|
||||
/// (`0x45a75b`: flag zero ⇒ `FSOUND_Reverb_SetProperties` is never called) and the
|
||||
@@ -188,6 +197,7 @@ impl Default for SoundConfig {
|
||||
ambience: 0.6,
|
||||
music_enabled: true,
|
||||
ambience_enabled: true,
|
||||
error_speech: true,
|
||||
reverb: false,
|
||||
world_hold: false,
|
||||
music_suppressed: false,
|
||||
@@ -471,6 +481,8 @@ impl Plugin for SoundPlugin {
|
||||
emote::plugin(app);
|
||||
greeting::plugin(app);
|
||||
ui::plugin(app);
|
||||
vocal::plugin(app);
|
||||
message::plugin(app);
|
||||
glue::plugin(app);
|
||||
money::plugin(app);
|
||||
reverb::plugin(app);
|
||||
|
||||
@@ -91,10 +91,10 @@ fn route_spell_kit_sounds(
|
||||
)
|
||||
};
|
||||
match played {
|
||||
Ok(()) if looping => {
|
||||
Ok(_) if looping => {
|
||||
hold_loops.insert(entity, kit_sound);
|
||||
}
|
||||
Ok(()) => {}
|
||||
Ok(_) => {}
|
||||
Err(e) => warn!("spell kit sound {kit_sound}: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
//! **Vocal UI error speech** — the lines your own character says out loud when the client refuses
|
||||
//! something: *"I can't do that yet."*, *"Not enough mana."*, *"Out of range."* (decision 1815).
|
||||
//!
|
||||
//! One engine function owns this in the reference — `SndInterfacePlayVocalUISound` `0x458250`,
|
||||
//! whose **sole caller** is the message dispatcher `CGGameUI::DisplayError 0x496720` — and one
|
||||
//! builder fills the table it reads, `0x4580f0`, called once from the local player's world-entry
|
||||
//! (`0x5dea50`: race from the descriptor's own byte, sex from `0x5ed5b0`). Both are transcribed
|
||||
//! here; the byte-level decode of each, and the shipped-data facts behind it, are in decision 1815.
|
||||
//!
|
||||
//! ## The table (`0x4580f0` → `[0xb06240]`)
|
||||
//!
|
||||
//! `2 × 0x44` twelve-byte slots indexed `sex * 0x44 + line`, filled for the player's **race** from
|
||||
//! `VocalUISounds.dbc` ([`benilla_formats::VocalUiSound`]). Each slot carries the ordinary kit, the
|
||||
//! annoyed kit, and the annoyed kit's variation count — the reference reads that count at build
|
||||
//! time through `0x45cda0(kit) + 0x94`, which is the runtime kit record's populated-`File[i]`
|
||||
//! counter, i.e. [`benilla_formats::SoundKit::files`]`.len()`.
|
||||
//!
|
||||
//! **benilla resolves BOTH kits at build time; the reference resolves only the annoyed one there.**
|
||||
//! Same answer, and deliberately so: the reference's play core looks the ordinary id up at play
|
||||
//! time and treats an unresolvable one as "did not play" (`0x45cda0`'s `cmp ecx,count; jae → NULL`),
|
||||
//! whereas [`kit::play_kit_ext`] reports an unknown kit as an **error** — which for a table where
|
||||
//! 281 of 1 066 ids are `0`/`-1` would be a warning per refusal rather than the silence the
|
||||
//! reference produces. Resolving once, up front, keeps the observable identical and the log clean.
|
||||
//!
|
||||
//! ## The cycle (`0x458250`)
|
||||
//!
|
||||
//! Transcribed in [`VocalSpeech::speak`], and it is stranger than "play the line":
|
||||
//!
|
||||
//! 1. A **different** line than last time resets the escalation state (`0x4582ad`).
|
||||
//! 2. If escalation is armed, play the **annoyed** kit at variation `n` — an *explicit* index that
|
||||
//! walks `0, 1, 2, …` (the ordinary line uses `-1`, the weighted-random pool). Advance `n`; if
|
||||
//! the kit is exhausted or the play was refused, disarm and reset.
|
||||
//! 3. **Then attempt the ordinary line anyway** — there is no early return at `0x458316`, the
|
||||
//! annoyed arm falls straight through into `0x458324`. What stops you hearing both is the
|
||||
//! **bus-1 cap of 1** ([`kit::Bus::ERROR_SPEECH`]): the annoyed line is already holding the
|
||||
//! single slot, so the second attempt is refused at the gate and reports back that it did not
|
||||
//! play. That refusal is why [`kit::play_kit_ext`] returns a bool at all.
|
||||
//! 4. On an ordinary line that *did* play, count it; at **four** consecutive plays of the same
|
||||
//! line, arm the escalation and reset the counter (`0x458359 cmp eax,4`).
|
||||
//!
|
||||
//! **On 5875's data the escalation is inaudible, and that is a fact about the data, not a gap.**
|
||||
//! Every one of `VocalUISounds.dbc`'s 1 066 annoyed ids is absent from `SoundEntries.dbc`, so the
|
||||
//! variation count is always 0, step 2 always disarms on its first attempt, and step 3 always
|
||||
//! plays the ordinary line — pinned by `benilla_formats`'
|
||||
//! `the_annoyed_column_resolves_to_nothing_in_this_build`. The cycle is transcribed anyway
|
||||
//! because it *is* the mechanism, on the same footing as the dormant `0x800` volume-variation gate
|
||||
//! in [`super::kit`].
|
||||
//!
|
||||
//! ## Gates
|
||||
//!
|
||||
//! `MasterSoundEffects` **and** `EnableErrorSpeech`, both read before anything else happens
|
||||
//! (`0x458264`/`0x45827f`) — so a player with error speech off does not silently advance the
|
||||
//! escalation counter either. The second is a real 1.12 CVar (`CVar::Register` at `0x457877`,
|
||||
//! default `"1"`) wired to the stock Sound panel's fourth checkbox; [`super::SoundConfig`] carries
|
||||
//! it and `crate::cvars` registers it.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_formats::{SoundKitCatalog, VocalUiSoundCatalog, VOCAL_UI_LINES};
|
||||
|
||||
use crate::net::{ObjectStore, SelfPlayer};
|
||||
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
|
||||
use benilla_world::schedule::WorldStage;
|
||||
|
||||
use super::kit::{self, Bus, KitRef, PlayExtras, SoundCategory, SoundKits};
|
||||
use super::{SoundConfig, SoundOutput};
|
||||
|
||||
/// The message catalog's "**no** error speech" `type_tag` — and, not by coincidence, the table's
|
||||
/// width: the reference tests `cmp [row+0xc],0x44` for equality to take the named-cue branch
|
||||
/// (`0x49673d`) and `cmp edi,0x44; jge` as a bound on the line id (`0x45829c`), and the shipped
|
||||
/// `VocalUISounds.dbc` stops one short at 67. So "the sentinel" and "one past the last line" are
|
||||
/// the same number, and this constant is both.
|
||||
pub(crate) const NO_SPEECH_TAG: u8 = VOCAL_UI_LINES as u8;
|
||||
|
||||
/// How many consecutive plays of one line arm the annoyed escalation — `0x458359 cmp eax,4`.
|
||||
const ESCALATE_AFTER: u32 = 4;
|
||||
|
||||
/// One `sex × line` slot of the reference's `[0xb06240]` table, with both kits already resolved
|
||||
/// (module doc). A slot with no ordinary kit is silent for that race/sex/line — 281 of the file's
|
||||
/// 1 066 ids are `0` or `-1`, and that is what the data says rather than something to fill in.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Slot {
|
||||
normal: Option<u32>,
|
||||
annoyed: Option<u32>,
|
||||
/// The annoyed kit's variation count, the bound step 2 walks to (`0x4582f0`).
|
||||
annoyed_variations: u32,
|
||||
}
|
||||
|
||||
/// `VocalUISounds.dbc`, loaded once.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct VocalUiSounds(pub(crate) VocalUiSoundCatalog);
|
||||
|
||||
/// The built table plus the reference's three escalation globals — `[0x835a40]`
|
||||
/// `s_lastPlayedVocalUISound`, `[0xb05ee4]` (armed) and `[0xb05f78]` (the variation counter).
|
||||
/// Kept together because `0x4580f0` resets all three when it rebuilds, and so does this.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct VocalSpeech {
|
||||
/// The race the table was built for — `None` until the local player's descriptor names one.
|
||||
race: Option<u32>,
|
||||
/// `2 * VOCAL_UI_LINES` slots, `sex * VOCAL_UI_LINES + line`.
|
||||
table: Vec<Slot>,
|
||||
last_line: u8,
|
||||
armed: bool,
|
||||
variation: u32,
|
||||
}
|
||||
|
||||
impl Default for VocalSpeech {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
race: None,
|
||||
table: vec![Slot::default(); 2 * VOCAL_UI_LINES],
|
||||
// The builder's own reset value: `0x458109 mov [0x835a40],0x44` — the sentinel, so the
|
||||
// first line spoken after a rebuild is always "different from last time".
|
||||
last_line: NO_SPEECH_TAG,
|
||||
armed: false,
|
||||
variation: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VocalSpeech {
|
||||
/// `0x4580f0` — fill the table for one race and reset the escalation state.
|
||||
///
|
||||
/// Walked **backwards** over the file, which is the reference's own direction (`0x458140`
|
||||
/// counts down): on a duplicate `(race, line)` the lowest-indexed row is the one left
|
||||
/// standing. 5875's file has no duplicate pair, so this is a rule with no live case — kept
|
||||
/// because it costs a `.rev()` and losing it would be a silent divergence if a patch ever
|
||||
/// added one.
|
||||
fn build(&mut self, race: u32, catalog: &VocalUiSoundCatalog, kits: &SoundKitCatalog) {
|
||||
self.table.clear();
|
||||
self.table.resize(2 * VOCAL_UI_LINES, Slot::default());
|
||||
self.last_line = NO_SPEECH_TAG;
|
||||
self.armed = false;
|
||||
self.variation = 0;
|
||||
for row in catalog.rows().iter().rev() {
|
||||
if row.race != race {
|
||||
continue;
|
||||
}
|
||||
for sex in 0..2u32 {
|
||||
let live = |id: Option<u32>| id.filter(|k| kits.get(*k).is_some());
|
||||
let annoyed = live(row.pissed_kit(sex));
|
||||
self.table[sex as usize * VOCAL_UI_LINES + row.line as usize] = Slot {
|
||||
normal: live(row.normal_kit(sex)),
|
||||
annoyed,
|
||||
annoyed_variations: annoyed
|
||||
.and_then(|k| kits.get(k))
|
||||
.map_or(0, |k| k.files.len() as u32),
|
||||
};
|
||||
}
|
||||
}
|
||||
self.race = Some(race);
|
||||
debug!(
|
||||
"sound(vocal): error-speech table built for race {race} — {} of {} slots voiced",
|
||||
self.table.iter().filter(|s| s.normal.is_some()).count(),
|
||||
self.table.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Which race the table currently holds — the rebuild trigger, and what the tests read.
|
||||
fn built_for(&self) -> Option<u32> {
|
||||
self.race
|
||||
}
|
||||
|
||||
/// `0x458250(sex, line)` — the whole cycle (module doc). `play` is the kit player, returning
|
||||
/// whether a channel actually opened; the bus-1 cap living inside it is what makes step 3
|
||||
/// silent while step 2 is sounding.
|
||||
fn speak(&mut self, sex: u32, line: u8, play: &mut dyn FnMut(u32, Option<usize>) -> bool) {
|
||||
// `0x458254`/`0x45825b`: anything but male/female leaves without touching a thing.
|
||||
if sex > 1 {
|
||||
return;
|
||||
}
|
||||
// `0x45829c cmp edi,0x44; jge` — a signed bound on the line, checked before the state is
|
||||
// touched, so a catalog row carrying the sentinel cannot disturb an escalation in flight.
|
||||
if usize::from(line) >= VOCAL_UI_LINES {
|
||||
return;
|
||||
}
|
||||
// `0x4582ad`: a different line resets the escalation. Note the reference stamps
|
||||
// `s_lastPlayedVocalUISound` *unconditionally* below, whether or not anything sounds.
|
||||
if line != self.last_line {
|
||||
self.armed = false;
|
||||
self.variation = 0;
|
||||
}
|
||||
self.last_line = line;
|
||||
|
||||
let slot = self.table[sex as usize * VOCAL_UI_LINES + usize::from(line)];
|
||||
|
||||
if self.armed {
|
||||
let played = self.variation < slot.annoyed_variations
|
||||
&& slot
|
||||
.annoyed
|
||||
.is_some_and(|kit| play(kit, Some(self.variation as usize)));
|
||||
if played {
|
||||
self.variation += 1;
|
||||
} else {
|
||||
self.variation = 0;
|
||||
self.armed = false;
|
||||
}
|
||||
}
|
||||
|
||||
// No early return above this line — `0x458316`'s `jmp` lands on the store, not on the
|
||||
// exit, so the ordinary attempt always happens (module doc step 3).
|
||||
if slot.normal.is_some_and(|kit| play(kit, None)) {
|
||||
self.variation += 1;
|
||||
if self.variation >= ESCALATE_AFTER {
|
||||
self.armed = true;
|
||||
self.variation = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_vocal_ui_sounds(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
|
||||
let Some(assets) = assets else { return };
|
||||
let loaded = {
|
||||
let mut chain = assets.chain.lock_recover();
|
||||
benilla_formats::load_vocal_ui_sounds(&mut chain)
|
||||
};
|
||||
match loaded {
|
||||
Ok(cat) => {
|
||||
info!("sound: {} vocal UI error-speech rows", cat.len());
|
||||
commands.insert_resource(VocalUiSounds(cat));
|
||||
}
|
||||
Err(e) => warn!("sound: vocal UI sounds failed to load: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the table when the local player's race changes — the reference's world-entry call
|
||||
/// (`0x5dea72`), reached here as a state watch rather than an event because benilla's descriptor
|
||||
/// arrives incrementally and a character swap is just another race change. Cheap: it does nothing
|
||||
/// at all until the race actually moves.
|
||||
fn build_vocal_table(
|
||||
mut speech: ResMut<VocalSpeech>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
catalog: Option<Res<VocalUiSounds>>,
|
||||
kits: Option<Res<SoundKits>>,
|
||||
) {
|
||||
let (Some(catalog), Some(kits)) = (catalog, kits) else {
|
||||
return;
|
||||
};
|
||||
let race = self_q.iter().next().and_then(|s| s.0.unit_race());
|
||||
let Some(race) = race.map(u32::from) else {
|
||||
return; // no descriptor yet — keep whatever table we have, like the reference does
|
||||
};
|
||||
if speech.built_for() == Some(race) {
|
||||
return;
|
||||
}
|
||||
speech.build(race, &catalog.0, kits.catalog());
|
||||
}
|
||||
|
||||
/// Say one error-speech line in the local player's own voice — the app-side entry point
|
||||
/// [`super::message`] calls, and the only caller there should ever be (the reference has exactly
|
||||
/// one too).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn speak_line(
|
||||
line: u8,
|
||||
speech: &mut VocalSpeech,
|
||||
sex: u32,
|
||||
kits: &mut SoundKits,
|
||||
assets: &WorldAssets,
|
||||
out: &mut SoundOutput,
|
||||
config: &SoundConfig,
|
||||
listener: Vec3,
|
||||
) {
|
||||
// `0x458264`/`0x45827f`: BOTH CVars, ahead of every state write. A player with error speech
|
||||
// off is not merely muted — the escalation counter does not advance for them either.
|
||||
if !config.enabled || !config.error_speech {
|
||||
return;
|
||||
}
|
||||
let mut play = |kit: u32, variant: Option<usize>| {
|
||||
match kit::play_kit_ext(
|
||||
kits,
|
||||
assets,
|
||||
out,
|
||||
config,
|
||||
listener,
|
||||
KitRef::Id(kit),
|
||||
// 2D: `0x45ce60` opens through `0x7a5450`, the two-dimensional wrapper. It is your
|
||||
// own voice — no position, no rolloff.
|
||||
None,
|
||||
SoundCategory::Sfx,
|
||||
PlayExtras {
|
||||
variant,
|
||||
bus: Bus::ERROR_SPEECH,
|
||||
..default()
|
||||
},
|
||||
) {
|
||||
Ok(played) => played,
|
||||
Err(e) => {
|
||||
warn!("sound(vocal): error-speech kit {kit}: {e:#}");
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
speech.speak(sex, line, &mut play);
|
||||
}
|
||||
|
||||
/// Registration hook for [`super::SoundPlugin`].
|
||||
pub(super) fn plugin(app: &mut App) {
|
||||
app.init_resource::<VocalSpeech>()
|
||||
.add_systems(Startup, load_vocal_ui_sounds.after(AssetSet::Open))
|
||||
.add_systems(Update, build_vocal_table.in_set(WorldStage::Stream));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A recorder standing in for the kit player, with the **bus-1 cap of 1** modelled the way it
|
||||
/// actually bites: at most one play per `speak` call is admitted, because the reference's cap
|
||||
/// counts a channel that is still sounding and error speech has the bus to itself.
|
||||
#[derive(Default)]
|
||||
struct Rec {
|
||||
plays: Vec<(u32, Option<usize>)>,
|
||||
this_call: usize,
|
||||
}
|
||||
|
||||
impl Rec {
|
||||
fn play(&mut self, kit: u32, variant: Option<usize>) -> bool {
|
||||
if self.this_call > 0 {
|
||||
return false; // bus 1, cap 1 — the slot is taken
|
||||
}
|
||||
self.this_call += 1;
|
||||
self.plays.push((kit, variant));
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn speech_with(normal: Option<u32>, annoyed: Option<u32>, variations: u32) -> VocalSpeech {
|
||||
let mut s = VocalSpeech {
|
||||
race: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
for slot in &mut s.table {
|
||||
*slot = Slot {
|
||||
normal,
|
||||
annoyed,
|
||||
annoyed_variations: variations,
|
||||
};
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn say(s: &mut VocalSpeech, rec: &mut Rec, sex: u32, line: u8) -> Vec<(u32, Option<usize>)> {
|
||||
rec.this_call = 0;
|
||||
let start = rec.plays.len();
|
||||
let mut f = |kit: u32, v: Option<usize>| rec.play(kit, v);
|
||||
s.speak(sex, line, &mut f);
|
||||
rec.plays[start..].to_vec()
|
||||
}
|
||||
|
||||
/// **The 5875 case**: no annoyed audio, so the ordinary line plays every single time and the
|
||||
/// escalation is invisible — the behaviour a player actually gets today. The counter still
|
||||
/// turns over underneath (it arms on the 4th and disarms on the 5th), which is exactly why
|
||||
/// this has to be asserted rather than assumed.
|
||||
#[test]
|
||||
fn with_no_annoyed_audio_the_ordinary_line_plays_every_time() {
|
||||
let mut s = speech_with(Some(1875), None, 0);
|
||||
let mut rec = Rec::default();
|
||||
for i in 0..12 {
|
||||
assert_eq!(
|
||||
say(&mut s, &mut rec, 0, 0),
|
||||
vec![(1875, None)],
|
||||
"call {i} went quiet"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The escalation itself, with audio present: four ordinary plays, then the annoyed variations
|
||||
/// **in file order** (an explicit index, not the random pool), then back to ordinary. The
|
||||
/// ordinary attempt still happens on every annoyed call — the cap is what silences it, and the
|
||||
/// recorder proves only one sound per call comes out.
|
||||
#[test]
|
||||
fn four_of_the_same_line_escalates_then_walks_the_annoyed_variations() {
|
||||
let mut s = speech_with(Some(10), Some(20), 2);
|
||||
let mut rec = Rec::default();
|
||||
for i in 0..4 {
|
||||
assert_eq!(say(&mut s, &mut rec, 0, 5), vec![(10, None)], "call {i}");
|
||||
}
|
||||
// Armed: the annoyed kit, variation 0 then 1 — and nothing else, though the ordinary line
|
||||
// was attempted both times.
|
||||
assert_eq!(say(&mut s, &mut rec, 0, 5), vec![(20, Some(0))]);
|
||||
assert_eq!(say(&mut s, &mut rec, 0, 5), vec![(20, Some(1))]);
|
||||
// Exhausted (2 variations): disarm, and the ordinary line is heard again on that very call.
|
||||
assert_eq!(say(&mut s, &mut rec, 0, 5), vec![(10, None)]);
|
||||
for _ in 0..3 {
|
||||
assert_eq!(say(&mut s, &mut rec, 0, 5), vec![(10, None)]);
|
||||
}
|
||||
// …and the cycle comes round again.
|
||||
assert_eq!(say(&mut s, &mut rec, 0, 5), vec![(20, Some(0))]);
|
||||
}
|
||||
|
||||
/// A different line resets the escalation — three of one line then a fourth of another does
|
||||
/// not make anybody annoyed (`0x4582ad`).
|
||||
#[test]
|
||||
fn a_different_line_resets_the_escalation() {
|
||||
let mut s = speech_with(Some(10), Some(20), 2);
|
||||
let mut rec = Rec::default();
|
||||
for _ in 0..3 {
|
||||
say(&mut s, &mut rec, 0, 5);
|
||||
}
|
||||
assert_eq!(say(&mut s, &mut rec, 0, 6), vec![(10, None)]);
|
||||
// Back to line 5: the count restarts from zero, so it takes a fresh FOUR — the three
|
||||
// before the interruption bought nothing.
|
||||
for i in 0..4 {
|
||||
assert_eq!(say(&mut s, &mut rec, 0, 5), vec![(10, None)], "call {i}");
|
||||
}
|
||||
assert_eq!(say(&mut s, &mut rec, 0, 5), vec![(20, Some(0))]);
|
||||
}
|
||||
|
||||
/// A silent slot plays nothing and **does not count** — the reference advances its counter
|
||||
/// only on a play that returned a channel (`0x458344 je`), so a line your race has no audio
|
||||
/// for can never arm the escalation.
|
||||
#[test]
|
||||
fn a_voiceless_line_neither_sounds_nor_counts() {
|
||||
let mut s = speech_with(None, Some(20), 2);
|
||||
let mut rec = Rec::default();
|
||||
for _ in 0..10 {
|
||||
assert!(say(&mut s, &mut rec, 0, 5).is_empty());
|
||||
}
|
||||
assert!(!s.armed);
|
||||
}
|
||||
|
||||
/// The two front-door refusals, neither of which may disturb state: a sex past female
|
||||
/// (`0x45825b`) and a line at or past the sentinel (`0x45829c`).
|
||||
#[test]
|
||||
fn the_bounds_refuse_without_touching_the_escalation() {
|
||||
let mut s = speech_with(Some(10), Some(20), 2);
|
||||
let mut rec = Rec::default();
|
||||
for _ in 0..3 {
|
||||
say(&mut s, &mut rec, 0, 5);
|
||||
}
|
||||
assert!(
|
||||
say(&mut s, &mut rec, 2, 5).is_empty(),
|
||||
"sex 2 is not a voice"
|
||||
);
|
||||
assert!(
|
||||
say(&mut s, &mut rec, 0, NO_SPEECH_TAG).is_empty(),
|
||||
"the sentinel is not a line"
|
||||
);
|
||||
// The line-5 run is intact, so the fourth call still escalates.
|
||||
assert_eq!(say(&mut s, &mut rec, 0, 5), vec![(10, None)]);
|
||||
assert_eq!(say(&mut s, &mut rec, 0, 5), vec![(20, Some(0))]);
|
||||
}
|
||||
|
||||
/// Male and female read **different slots** of one table — the `sex * 0x44` stride, which is
|
||||
/// the whole reason the table is 2 × 0x44 rather than 0x44.
|
||||
#[test]
|
||||
fn the_sexes_index_different_slots() {
|
||||
let mut s = VocalSpeech {
|
||||
race: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
s.table[0].normal = Some(1875); // male, line 0
|
||||
s.table[VOCAL_UI_LINES].normal = Some(1999); // female, line 0
|
||||
let mut rec = Rec::default();
|
||||
assert_eq!(say(&mut s, &mut rec, 0, 0), vec![(1875, None)]);
|
||||
assert_eq!(say(&mut s, &mut rec, 1, 0), vec![(1999, None)]);
|
||||
}
|
||||
|
||||
/// The table build, on the real shipped DBCs: Human male line 0 is `HumanMale_InventoryFull`,
|
||||
/// the annoyed column comes back empty (1815's dormancy), and a rebuild for another race
|
||||
/// replaces the whole table rather than merging into it. Skips without client data.
|
||||
#[test]
|
||||
fn the_real_table_builds_per_race_and_replaces_on_change() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let catalog = benilla_formats::load_vocal_ui_sounds(&mut chain).expect("VocalUISounds");
|
||||
let kits = benilla_formats::load_sound_kit_catalog(&mut chain).expect("SoundEntries");
|
||||
|
||||
let mut s = VocalSpeech::default();
|
||||
s.build(1, &catalog, &kits);
|
||||
assert_eq!(s.built_for(), Some(1));
|
||||
let human_male = s.table[0];
|
||||
assert_eq!(
|
||||
kits.get(human_male.normal.expect("Human male line 0"))
|
||||
.map(|k| k.name.as_str()),
|
||||
Some("HumanMale_InventoryFull")
|
||||
);
|
||||
assert_eq!(
|
||||
kits.get(s.table[VOCAL_UI_LINES].normal.expect("Human female line 0"))
|
||||
.map(|k| k.name.as_str()),
|
||||
Some("HumanFemale_InventoryFull")
|
||||
);
|
||||
assert!(
|
||||
s.table.iter().all(|slot| slot.annoyed.is_none()),
|
||||
"an annoyed kit resolved — 1815's dormancy is over"
|
||||
);
|
||||
|
||||
// A race change is a full rebuild: Tauren line 0, not Human's, and no Human residue.
|
||||
s.build(6, &catalog, &kits);
|
||||
assert_eq!(
|
||||
kits.get(s.table[0].normal.expect("Tauren male line 0"))
|
||||
.map(|k| k.name.as_str()),
|
||||
Some("TaurenMale_InventoryFull")
|
||||
);
|
||||
// A race with no row for some line leaves that slot silent rather than the last race's.
|
||||
s.build(1, &catalog, &kits);
|
||||
let voiced_human = s.table.iter().filter(|s| s.normal.is_some()).count();
|
||||
s.build(9, &catalog, &kits); // Goblin: one row in the whole file
|
||||
let voiced_goblin = s.table.iter().filter(|s| s.normal.is_some()).count();
|
||||
assert!(
|
||||
voiced_goblin < voiced_human,
|
||||
"the rebuild merged instead of replacing ({voiced_goblin} vs {voiced_human})"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,12 @@
|
||||
//! absent key shows nothing (the reference's data-suppression face) and localization rides
|
||||
//! for free.
|
||||
|
||||
use benilla_ui::messages::MessageRecord;
|
||||
use benilla_ui::script::{ScriptValue, UiScript};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::net::ObjectStore;
|
||||
use crate::sound::MessageSounds;
|
||||
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
|
||||
use crate::ui_items::{count_of, InventoryScope};
|
||||
|
||||
@@ -108,12 +110,6 @@ impl UiError {
|
||||
fill_d: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where this message is shown, straight off the catalog row its key names — the reference's
|
||||
/// `DisplayError` reading `[record+0x04]`, which is the only place that answer has ever lived.
|
||||
pub(crate) fn kind(&self) -> MsgKind {
|
||||
benilla_ui::messages::kind_of(self.key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Client-LOCAL refusals queued for the UI error line straight by GlobalStrings key — the
|
||||
@@ -166,7 +162,51 @@ pub(crate) fn ui_error_text(e: &UiError, get: &dyn Fn(&str) -> Option<String>) -
|
||||
(!text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
/// **The one sink** for a resolved message line: put it on the surface its [`MsgKind`] names.
|
||||
/// One resolved line on its way to the screen — the text, where it goes, and the **catalog row it
|
||||
/// came from** when it has one.
|
||||
///
|
||||
/// The record rides along because a message's *sound* is one of its fields (`+0x08`/`+0x0c`) just
|
||||
/// as its surface is (`+0x04`), and the reference reads all three in the same breath inside
|
||||
/// `CGGameUI::DisplayError`. Carrying only the [`MsgKind`], as this did before 1815, threw the
|
||||
/// other two away at the call site and left the catalog's sound columns unreadable by anything.
|
||||
pub(crate) struct Shown {
|
||||
record: Option<&'static MessageRecord>,
|
||||
kind: MsgKind,
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl Shown {
|
||||
/// A **catalog** message, named by its GlobalStrings key — the reference's `DisplayError(id)`,
|
||||
/// which is nearly every line benilla shows. One lookup answers both "where does it go" and
|
||||
/// "what does it sound like".
|
||||
///
|
||||
/// A key with no row falls back to [`MsgKind::Error`] and no sound. That cannot arise in the
|
||||
/// reference (a message is an *index*, so an unknown key is not expressible), so it only ever
|
||||
/// means benilla named a key the client does not have — and
|
||||
/// `every_error_key_in_the_source_is_a_catalog_row` is what keeps it unreachable.
|
||||
pub(crate) fn keyed(key: &str, text: String) -> Self {
|
||||
let record = benilla_ui::messages::by_key(key);
|
||||
Self {
|
||||
record,
|
||||
kind: record.map_or(MsgKind::Error, |r| r.kind),
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
/// A line with **no catalog row** — the wire's own already-resolved text ([`UiErrorTexts`]),
|
||||
/// which reaches the reference's sink `0x4945b0` directly and never passes a record. Silent,
|
||||
/// faithfully: there is no `+0x08`/`+0x0c` to read.
|
||||
pub(crate) fn unkeyed(kind: MsgKind, text: String) -> Self {
|
||||
Self {
|
||||
record: None,
|
||||
kind,
|
||||
text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **The one sink** for a resolved message line: put it on the surface its [`MsgKind`] names, and
|
||||
/// queue the sound its catalog row names.
|
||||
///
|
||||
/// Every route into the client's message display ends here — the window queues that resolve a
|
||||
/// [`UiError`] (the questgiver refusals 0669, the auction house 1523, the party quest-share 1733),
|
||||
@@ -174,6 +214,10 @@ pub(crate) fn ui_error_text(e: &UiError, get: &dyn Fn(&str) -> Option<String>) -
|
||||
/// differ only in how they *build* a line and where the kind comes from; what happens to a built
|
||||
/// one is this, once, so the three cannot drift apart.
|
||||
///
|
||||
/// The sound is **queued, not played** — this runs deep inside the UI feeds, which have no audio
|
||||
/// resources and should not grow them; `crate::sound::message` drains the queue after the input
|
||||
/// pass, on the same frame (decision 1815).
|
||||
///
|
||||
/// `who` is the caller's module tag for the debug line — which exists because the chat path keeps
|
||||
/// no log of its own, so without it a live probe can count lines but never read one (0669's
|
||||
/// in-app leg).
|
||||
@@ -183,15 +227,21 @@ pub(crate) fn ui_error_text(e: &UiError, get: &dyn Fn(&str) -> Option<String>) -
|
||||
/// and benilla raises none of them yet; when it does, this is the line that has to read the field.
|
||||
pub(crate) fn show_messages(
|
||||
script: &mut UiScript,
|
||||
chat: &mut ChatLog,
|
||||
sink: &mut MessageSink,
|
||||
who: &str,
|
||||
lines: impl IntoIterator<Item = (MsgKind, String)>,
|
||||
lines: impl IntoIterator<Item = Shown>,
|
||||
) {
|
||||
for (kind, text) in lines {
|
||||
for Shown { record, kind, text } in lines {
|
||||
debug!("{who}: message ({kind:?}) {text:?}");
|
||||
// The reference sounds a message on the way past its text, not instead of it — so this
|
||||
// sits with the display and inherits its every guard (an empty line never gets here).
|
||||
if let Some(record) = record {
|
||||
sink.sounds.push(record);
|
||||
}
|
||||
match kind {
|
||||
MsgKind::Chat => {
|
||||
chat.push_event(ChatEvent::text_only(ChatEventKind::System, text));
|
||||
sink.chat
|
||||
.push_event(ChatEvent::text_only(ChatEventKind::System, text));
|
||||
}
|
||||
MsgKind::Info => {
|
||||
script.fire_event("UI_INFO_MESSAGE", vec![ScriptValue::Str(text)]);
|
||||
@@ -203,6 +253,16 @@ pub(crate) fn show_messages(
|
||||
}
|
||||
}
|
||||
|
||||
/// Everywhere a displayed message lands, besides the script VM: the chat window and the sound
|
||||
/// queue. One [`SystemParam`] rather than two, because [`show_messages`] writes both on every
|
||||
/// line and no caller wants one without the other — and because the feeds that raise messages are
|
||||
/// already at Bevy's parameter ceiling.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(crate) struct MessageSink<'w> {
|
||||
pub(crate) chat: ResMut<'w, ChatLog>,
|
||||
pub(crate) sounds: ResMut<'w, MessageSounds>,
|
||||
}
|
||||
|
||||
/// `UNIT_FIELD_FLAGS` bits the attack-start validator refuses on, paired with the message each
|
||||
/// raises — read at `0x612eec`+ in the binary's own test order (wow-re
|
||||
/// `object-layer/scratch/pet-command-validators.md` §2). All four are crowd control: the actor is
|
||||
@@ -368,6 +428,70 @@ pub(super) fn mount_result_key(mount: bool, code: u32) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod shown_tests {
|
||||
use super::{MessageSink, MessageSounds, MsgKind, Shown};
|
||||
use crate::ui_chat::ChatLog;
|
||||
use benilla_ui::script::UiScript;
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// The producer half of the message-sound path (decision 1815), driven through the real
|
||||
/// [`super::show_messages`] rather than around it: a **keyed** line queues its catalog row for
|
||||
/// `crate::sound::message` to sound, an **unkeyed** one queues nothing (the wire's own text has
|
||||
/// no record), and a key the client does not have queues nothing either — the same fallback
|
||||
/// that makes it a red line.
|
||||
#[test]
|
||||
fn a_keyed_line_queues_its_row_and_an_unkeyed_one_queues_nothing() {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<ChatLog>();
|
||||
world.init_resource::<MessageSounds>();
|
||||
let mut state = bevy::ecs::system::SystemState::<MessageSink>::new(&mut world);
|
||||
let mut script = UiScript::new().expect("VM");
|
||||
|
||||
{
|
||||
let mut sink = state.get_mut(&mut world);
|
||||
super::show_messages(
|
||||
&mut script,
|
||||
&mut sink,
|
||||
"test",
|
||||
[
|
||||
// A voiced refusal, a cue row, a chat row that still speaks, and one with
|
||||
// neither — every arm of the record's two sound columns.
|
||||
Shown::keyed("ERR_OUT_OF_MANA", "Not enough mana".into()),
|
||||
Shown::keyed("ERR_NEWTAXIPATH", "New flight path discovered!".into()),
|
||||
Shown::keyed("ERR_ALREADY_IN_GROUP_S", "Bob is already in a group".into()),
|
||||
Shown::keyed("ERR_CANT_STACK", "That item cannot stack".into()),
|
||||
Shown::unkeyed(MsgKind::Error, "the server said so".into()),
|
||||
Shown::keyed("ERR_NOT_A_REAL_MESSAGE", "invented".into()),
|
||||
],
|
||||
);
|
||||
}
|
||||
state.apply(&mut world);
|
||||
|
||||
let queued = world.resource::<MessageSounds>().queued().to_vec();
|
||||
let keys: Vec<&str> = queued.iter().map(|r| r.key).collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
[
|
||||
"ERR_OUT_OF_MANA",
|
||||
"ERR_NEWTAXIPATH",
|
||||
"ERR_ALREADY_IN_GROUP_S",
|
||||
"ERR_CANT_STACK",
|
||||
],
|
||||
"the unkeyed line and the unknown key have no row to sound"
|
||||
);
|
||||
// …and what each of them asks the drain for.
|
||||
assert_eq!(queued[0].type_tag, 0x0f, "the voice line");
|
||||
assert_eq!(queued[1].sound, Some("TaxiNodeDiscovered"), "the named cue");
|
||||
assert_eq!(queued[2].kind, MsgKind::Chat, "a chat row sounds too");
|
||||
assert_eq!(
|
||||
(queued[3].type_tag, queued[3].sound),
|
||||
(0x44, None),
|
||||
"queued and silent — the drain's own no-op"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mount_error_tests {
|
||||
use super::mount_result_key;
|
||||
|
||||
@@ -39,8 +39,8 @@ use crate::net::{NetCommands, ObjectStore, SelfPlayer};
|
||||
use super::errors::{first_missing_totem, first_short_reagent, mount_result_key};
|
||||
use super::weapon_icon::{auto_attack_icon, substitutes_weapon_icon};
|
||||
use super::{
|
||||
cast_fail, show_messages, ui_error_text, CastErrors, MountErrors, MsgKind, PlayerActions,
|
||||
Spells, UiError, UiErrorKeys, UiErrorTexts,
|
||||
cast_fail, show_messages, ui_error_text, CastErrors, MessageSink, MountErrors, PlayerActions,
|
||||
Shown, Spells, UiError, UiErrorKeys, UiErrorTexts,
|
||||
};
|
||||
|
||||
/// What an ITEM action shows when its icon cannot be resolved — the reference's own hardcoded
|
||||
@@ -94,9 +94,9 @@ pub(super) fn feed_actions(
|
||||
areas: Option<Res<crate::area::AreaTableRes>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut memory: Local<crate::ui_script::VmMemo<FeedMemory>>,
|
||||
// The combat log's own record of a failed cast (1703) — a different frame, a different
|
||||
// sentence, and the reference emits both from the one routine (`0x6e1a00`).
|
||||
mut chat_log: ResMut<crate::ui_chat::ChatLog>,
|
||||
// Where a displayed message lands: the chat window (the combat log's own record of a failed
|
||||
// cast, 1703, rides the same resource) and the catalog's sound queue (1815).
|
||||
mut sink: MessageSink,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -255,15 +255,13 @@ pub(super) fn feed_actions(
|
||||
.collect();
|
||||
cast_errors.0.extend(await_template);
|
||||
for line in fail_lines {
|
||||
chat_log.push_combat(line);
|
||||
sink.chat.push_combat(line);
|
||||
}
|
||||
show_messages(
|
||||
&mut script,
|
||||
&mut chat_log,
|
||||
&mut sink,
|
||||
"ui_action",
|
||||
texts
|
||||
.into_iter()
|
||||
.map(|l| (benilla_ui::messages::kind_of(l.key), l.text)),
|
||||
texts.into_iter().map(|l| Shown::keyed(l.key, l.text)),
|
||||
);
|
||||
|
||||
// (Dis)mount refusals ride the same route, keyed straight into GlobalStrings
|
||||
@@ -278,35 +276,35 @@ pub(super) fn feed_actions(
|
||||
.collect();
|
||||
show_messages(
|
||||
&mut script,
|
||||
&mut chat_log,
|
||||
&mut sink,
|
||||
"ui_action",
|
||||
mount_texts
|
||||
.into_iter()
|
||||
.map(|(key, text)| (benilla_ui::messages::kind_of(key), text)),
|
||||
.map(|(key, text)| Shown::keyed(key, text)),
|
||||
);
|
||||
|
||||
// Client-local by-key refusals (the `DisplayError` route — [`UiErrorKeys`]); the key IS the
|
||||
// GlobalStrings lookup, no code table between, and the key is also what names the surface:
|
||||
// [`UiError::kind`] reads the message record straight out of the catalog instead of the queue
|
||||
// carrying a hand-set flag alongside every push (decision 1770).
|
||||
let key_lines: Vec<(MsgKind, String)> = ui_error_keys
|
||||
let key_lines: Vec<Shown> = ui_error_keys
|
||||
.0
|
||||
.drain(..)
|
||||
.filter_map(|e| {
|
||||
ui_error_text(&e, &|key| script.lua().globals().get::<String>(key).ok())
|
||||
.map(|t| (e.kind(), t))
|
||||
.map(|t| Shown::keyed(e.key, t))
|
||||
})
|
||||
.collect();
|
||||
show_messages(&mut script, &mut chat_log, "ui_action", key_lines);
|
||||
show_messages(&mut script, &mut sink, "ui_action", key_lines);
|
||||
|
||||
// Already-resolved lines ([`UiErrorTexts`]) — the wire's own text, no key to look up and no
|
||||
// record behind it; the queued kind IS the reference's `0x4945b0` flag.
|
||||
let resolved: Vec<(MsgKind, String)> = ui_error_texts
|
||||
let resolved: Vec<Shown> = ui_error_texts
|
||||
.0
|
||||
.drain(..)
|
||||
.map(|(text, kind)| (kind, text))
|
||||
.map(|(text, kind)| Shown::unkeyed(kind, text))
|
||||
.collect();
|
||||
show_messages(&mut script, &mut chat_log, "ui_action", resolved);
|
||||
show_messages(&mut script, &mut sink, "ui_action", resolved);
|
||||
|
||||
// The ENGINE's own by-key refusals ride the very same line. `benilla_ui` is engine-free and
|
||||
// cannot reach [`UiErrorKeys`], so a refusal raised inside the script crate (today: dropping a
|
||||
@@ -315,15 +313,15 @@ pub(super) fn feed_actions(
|
||||
// frame late by construction (the refusal happens during the input pass this feed precedes),
|
||||
// which is invisible on a toast.
|
||||
let engine_keys = script.take_ui_errors();
|
||||
let engine_lines: Vec<(MsgKind, String)> = engine_keys
|
||||
let engine_lines: Vec<Shown> = engine_keys
|
||||
.into_iter()
|
||||
.filter_map(|key| {
|
||||
let e = UiError::key(key);
|
||||
ui_error_text(&e, &|k| script.lua().globals().get::<String>(k).ok())
|
||||
.map(|t| (e.kind(), t))
|
||||
.map(|t| Shown::keyed(e.key, t))
|
||||
})
|
||||
.collect();
|
||||
show_messages(&mut script, &mut chat_log, "ui_action", engine_lines);
|
||||
show_messages(&mut script, &mut sink, "ui_action", engine_lines);
|
||||
|
||||
let store = self_q.iter().next();
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ fn app_with_food_on_the_bar() -> (App, crossbeam_channel::Receiver<ClientCommand
|
||||
.init_resource::<UiErrorTexts>()
|
||||
// The cast-failure combat-log line (1703) rides the same drain.
|
||||
.init_resource::<crate::ui_chat::ChatLog>()
|
||||
.init_resource::<crate::sound::MessageSounds>()
|
||||
.insert_resource(ItemDisplays::icons_for_tests(
|
||||
ItemDisplayCatalog::from_displays(displays),
|
||||
))
|
||||
@@ -314,6 +315,7 @@ fn a_macro_slot_shows_the_macros_own_icon_and_follows_an_edit() {
|
||||
.init_resource::<UiErrorTexts>()
|
||||
// The cast-failure combat-log line (1703) rides the same drain.
|
||||
.init_resource::<crate::ui_chat::ChatLog>()
|
||||
.init_resource::<crate::sound::MessageSounds>()
|
||||
.insert_resource(NetCommands(tx));
|
||||
let mut script = UiScript::new().unwrap();
|
||||
script.set_macros(MacroState {
|
||||
|
||||
@@ -67,7 +67,7 @@ pub(crate) use cast_send::{CastCommit, CastLadder};
|
||||
pub(crate) use cast_target::AutoSelfCast;
|
||||
pub(crate) use errors::{
|
||||
attack_actor_refusal, reagent_totem_refusal, show_messages, ui_error_text, CastErrors,
|
||||
CastFail, MountErrors, MsgKind, UiError, UiErrorKeys, UiErrorTexts,
|
||||
CastFail, MessageSink, MountErrors, Shown, UiError, UiErrorKeys, UiErrorTexts,
|
||||
};
|
||||
// `pub(crate)`: the target chain registers the cursor pre-empt + the click commit, and the
|
||||
// spellbook/stance/craft drains thread the mode through the one cast-send path (decision 0792).
|
||||
|
||||
@@ -48,7 +48,7 @@ use crate::entities::ItemDisplays;
|
||||
use crate::items::Items;
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
|
||||
use crate::ui_action::{show_messages, ui_error_text, UiError};
|
||||
use crate::ui_action::{show_messages, ui_error_text, MessageSink, Shown, UiError};
|
||||
use crate::ui_script::UiInput;
|
||||
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
|
||||
|
||||
@@ -570,7 +570,7 @@ fn feed_auction(
|
||||
time: Res<Time>,
|
||||
catalogs: AuctionCatalogs,
|
||||
houses: Option<Res<AuctionHouses>>,
|
||||
mut chat: ResMut<crate::ui_chat::ChatLog>,
|
||||
mut sink: MessageSink,
|
||||
self_q: Query<&crate::net::Guid, With<SelfPlayer>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<AuctionState>>>,
|
||||
mut last_open: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
||||
@@ -614,11 +614,11 @@ fn feed_auction(
|
||||
fill_d: None,
|
||||
};
|
||||
if let Some(text) = ui_error_text(&err, &get) {
|
||||
lines.push((benilla_ui::messages::kind_of(msg.key), text));
|
||||
lines.push(Shown::keyed(msg.key, text));
|
||||
}
|
||||
}
|
||||
auction.messages = deferred;
|
||||
show_messages(&mut script, &mut chat, "ui_auction", lines);
|
||||
show_messages(&mut script, &mut sink, "ui_auction", lines);
|
||||
}
|
||||
|
||||
let self_guid = self_q.iter().next().map(|g| g.0);
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
use benilla_protocol::messages::bank_slot_result;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_ui::script::{BankState, ScriptValue, UiScript};
|
||||
use benilla_ui::script::{BankState, UiScript};
|
||||
|
||||
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
|
||||
use crate::ui_script::UiInput;
|
||||
@@ -105,14 +105,13 @@ impl Plugin for UiBankPlugin {
|
||||
/// The red error line for a `SMSG_BUY_BANK_SLOT_RESULT` code — the reference GlobalStrings'
|
||||
/// `ERR_BANKSLOT_*` texts verbatim (decision 0604: the codes map 1:1 onto them). `OK` never
|
||||
/// prints (vmangos doesn't send it; tolerated silently if a server ever does).
|
||||
fn bank_slot_error_text(result: u32) -> Option<String> {
|
||||
match result {
|
||||
bank_slot_result::FAILED_TOO_MANY => Some("You've reached your limit of bag slots!".into()),
|
||||
bank_slot_result::INSUFFICIENT_FUNDS => Some("You can't afford that.".into()),
|
||||
bank_slot_result::NOTBANKER => Some("That unit is not a banker!".into()),
|
||||
bank_slot_result::OK => None,
|
||||
other => Some(format!("Bank slot purchase failed ({other}).")),
|
||||
}
|
||||
fn bank_slot_error_key(result: u32) -> Option<&'static str> {
|
||||
Some(match result {
|
||||
bank_slot_result::FAILED_TOO_MANY => "ERR_BANKSLOT_FAILED_TOO_MANY", // 0x100
|
||||
bank_slot_result::INSUFFICIENT_FUNDS => "ERR_BANKSLOT_INSUFFICIENT_FUNDS", // 0x101
|
||||
bank_slot_result::NOTBANKER => "ERR_BANKSLOT_NOTBANKER", // 0x102
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Push the purchase-row snapshot + the open/close/purchased-count events (module doc).
|
||||
@@ -132,18 +131,32 @@ fn feed_bank(
|
||||
mut errors: ResMut<BankErrors>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<BankState>>>,
|
||||
mut last_banker: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
||||
mut sink: crate::ui_action::MessageSink,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let last = last.get(&script);
|
||||
let last_banker = last_banker.get(&script);
|
||||
// Purchase refusals surface as the client's red error line.
|
||||
for result in errors.0.drain(..) {
|
||||
if let Some(text) = bank_slot_error_text(result) {
|
||||
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
|
||||
}
|
||||
}
|
||||
// Purchase refusals go to the surface — and the voice — their message record names: the
|
||||
// insufficient-funds row carries error-speech line `0x16` (decision 1815). A code with no key
|
||||
// keeps its "(n)" line and stays silent, because there is no record behind it to sound.
|
||||
let lines: Vec<_> = errors
|
||||
.0
|
||||
.drain(..)
|
||||
.filter_map(|result| match bank_slot_error_key(result) {
|
||||
Some(key) => {
|
||||
let text = script.lua().globals().get::<String>(key).ok()?;
|
||||
(!text.is_empty()).then(|| crate::ui_action::Shown::keyed(key, text))
|
||||
}
|
||||
None if result == bank_slot_result::OK => None,
|
||||
None => Some(crate::ui_action::Shown::unkeyed(
|
||||
benilla_ui::messages::MsgKind::Error,
|
||||
format!("Bank slot purchase failed ({result})."),
|
||||
)),
|
||||
})
|
||||
.collect();
|
||||
crate::ui_action::show_messages(&mut script, &mut sink, "ui_bank", lines);
|
||||
let store = self_q.iter().next();
|
||||
let purchased = store
|
||||
.and_then(|s| s.0.player_bank_bag_slots_purchased())
|
||||
@@ -209,20 +222,35 @@ mod tests {
|
||||
/// The refusal-code → red-line map: the three vmangos failure codes print the reference
|
||||
/// GlobalStrings verbatim, OK stays silent, an unknown code prints its number.
|
||||
#[test]
|
||||
fn bank_slot_error_texts() {
|
||||
fn bank_slot_error_keys() {
|
||||
assert_eq!(
|
||||
bank_slot_error_text(bank_slot_result::FAILED_TOO_MANY).as_deref(),
|
||||
Some("You've reached your limit of bag slots!")
|
||||
bank_slot_error_key(bank_slot_result::FAILED_TOO_MANY),
|
||||
Some("ERR_BANKSLOT_FAILED_TOO_MANY")
|
||||
);
|
||||
assert_eq!(
|
||||
bank_slot_error_text(bank_slot_result::INSUFFICIENT_FUNDS).as_deref(),
|
||||
Some("You can't afford that.")
|
||||
bank_slot_error_key(bank_slot_result::INSUFFICIENT_FUNDS),
|
||||
Some("ERR_BANKSLOT_INSUFFICIENT_FUNDS")
|
||||
);
|
||||
assert_eq!(
|
||||
bank_slot_error_text(bank_slot_result::NOTBANKER).as_deref(),
|
||||
Some("That unit is not a banker!")
|
||||
bank_slot_error_key(bank_slot_result::NOTBANKER),
|
||||
Some("ERR_BANKSLOT_NOTBANKER")
|
||||
);
|
||||
assert_eq!(bank_slot_error_key(bank_slot_result::OK), None);
|
||||
assert_eq!(bank_slot_error_key(99), None, "the feed's own \"(n)\" arm");
|
||||
// Every key is a catalog row, and the insufficient-funds one is the spoken one (1815).
|
||||
for key in [
|
||||
"ERR_BANKSLOT_FAILED_TOO_MANY",
|
||||
"ERR_BANKSLOT_INSUFFICIENT_FUNDS",
|
||||
"ERR_BANKSLOT_NOTBANKER",
|
||||
] {
|
||||
let r = benilla_ui::messages::by_key(key).expect("catalog row");
|
||||
assert_eq!(r.kind, benilla_ui::messages::MsgKind::Error, "{key}");
|
||||
}
|
||||
assert_eq!(
|
||||
benilla_ui::messages::by_key("ERR_BANKSLOT_INSUFFICIENT_FUNDS")
|
||||
.unwrap()
|
||||
.type_tag,
|
||||
0x16
|
||||
);
|
||||
assert_eq!(bank_slot_error_text(bank_slot_result::OK), None);
|
||||
assert!(bank_slot_error_text(99).unwrap().contains("99"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
//! Its arm reads nothing off the wire — `0x5e4a09` is `push 0x1c3; call 0x496720`, three
|
||||
//! instructions — so the whole packet is "show message 451". benilla already models
|
||||
//! `CGGameUI::DisplayError 0x496720` and its message registry
|
||||
//! ([`crate::ui_action::MsgKind`]); what this packet needed was the two facts that registry
|
||||
//! ([`benilla_ui::messages::MsgKind`]); what this packet needed was the two facts that registry
|
||||
//! entry carries, and both are VERIFIED here:
|
||||
//!
|
||||
//! - **451 is `ERR_CHAT_RESTRICTED`.** The string `"ERR_CHAT_RESTRICTED"` (`0x83f6d4`) has exactly
|
||||
|
||||
@@ -789,7 +789,9 @@ pub(crate) fn feed_containers(
|
||||
commands: Res<NetCommands>,
|
||||
cooldowns: Res<crate::cooldowns::Cooldowns>,
|
||||
spells: Option<Res<crate::ui_action::Spells>>,
|
||||
mut equip_errors: ResMut<EquipErrors>,
|
||||
// The refusals and where they land, as one param (the 16-SystemParam ceiling this signature
|
||||
// already sits at): the queue, and `show_messages`' chat + sound sinks (1815).
|
||||
equip: (ResMut<EquipErrors>, crate::ui_action::MessageSink),
|
||||
// Reason 16's `%s` source (decision 0916); absent = every 16 keeps the generic line.
|
||||
bag_families: Option<Res<crate::ui_items::ItemBagFamilies>>,
|
||||
pending: Res<PendingItemOps>,
|
||||
@@ -808,6 +810,7 @@ pub(crate) fn feed_containers(
|
||||
return;
|
||||
};
|
||||
let (clock, mut petitions) = clock_and_petitions;
|
||||
let (mut equip_errors, mut sink) = equip;
|
||||
let (memory, vm_reset) = memory.get_reset(&script);
|
||||
// The gate (1439): the snapshot below is a function of the self descriptor's slot arrays,
|
||||
// the item stores (both epochs — `is_changed` on `Items`/`NameCache`/`Cooldowns` is
|
||||
@@ -914,6 +917,7 @@ pub(crate) fn feed_containers(
|
||||
// failed to map can't reach here (the table is total), and a key we typo'd is caught by
|
||||
// `equip_error`'s resolution test against the real `GlobalStrings.lua`, not at runtime.
|
||||
let player = self_q.0.iter().next();
|
||||
let mut refusals = Vec::new();
|
||||
for e in equip_errors.0.drain(..) {
|
||||
// Reason 16's substitution — the ONE reason whose text is chosen by the app rather than
|
||||
// by the table, because the choice needs the named bag (decision 0916). The reference's
|
||||
@@ -954,8 +958,14 @@ pub(crate) fn feed_containers(
|
||||
Some(family) => text.replace("%s", &family),
|
||||
None => text,
|
||||
};
|
||||
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
|
||||
refusals.push(crate::ui_action::Shown::keyed(key, text));
|
||||
}
|
||||
// Through the one sink, so the row that named the text also names the surface and the SOUND —
|
||||
// 18 of these keys carry an error-speech line (`ERR_INV_FULL`, `ERR_BAG_FULL`,
|
||||
// `ERR_NOT_ENOUGH_MONEY`, …), which is the largest single source of it in the client
|
||||
// (decision 1815). Every key this table can emit is `kind = 2`, so nothing moved off the red
|
||||
// line when the surface stopped being hardcoded here.
|
||||
crate::ui_action::show_messages(&mut script, &mut sink, "ui_items", refusals);
|
||||
// Both lock clears of the frame — the resolving field-update watch ([`resolve_item_locks`],
|
||||
// which runs ahead of EVERY feed) and the failure-driven clears
|
||||
// `net/apply/loot.rs::inventory_failure` queued (it has no `UiScript` to fire through). Both
|
||||
|
||||
@@ -50,7 +50,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use benilla_protocol::messages::{mail_error, mail_message_type, MailListEntry};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_ui::script::{MailInboxRow, MailState, ScriptValue, UiScript};
|
||||
use benilla_ui::script::{MailInboxRow, MailState, UiScript};
|
||||
|
||||
use crate::entities::ItemDisplays;
|
||||
use crate::items::Items;
|
||||
@@ -98,7 +98,7 @@ pub(crate) struct Stationery(pub(crate) benilla_formats::StationeryCatalog);
|
||||
pub(crate) struct MailSendAck {
|
||||
pub(crate) ok: bool,
|
||||
/// A pre-formatted red error line for a non-OK, non-equip send failure (else `None`).
|
||||
pub(crate) error_text: Option<String>,
|
||||
pub(crate) refusal: Option<MailRefusal>,
|
||||
}
|
||||
|
||||
/// The open mailbox, filled by the net bridge ([`crate::net::apply::mail`]) and read by
|
||||
@@ -124,9 +124,9 @@ pub(crate) struct MailOpen {
|
||||
last_list_query: Option<f64>,
|
||||
/// SEND-action results the net bridge queued for the feed to fire ([`MailSendAck`]).
|
||||
pub(crate) send_acks: Vec<MailSendAck>,
|
||||
/// Pre-formatted red error lines (a take/return/delete failure) the feed fires via
|
||||
/// `UI_ERROR_MESSAGE`.
|
||||
pub(crate) errors: Vec<String>,
|
||||
/// Refusals (a take/return/delete failure) the feed shows, resolved at the feed because a
|
||||
/// keyed one reads its text out of the VM ([`MailRefusal`]).
|
||||
pub(crate) errors: Vec<MailRefusal>,
|
||||
}
|
||||
|
||||
impl MailOpen {
|
||||
@@ -239,21 +239,54 @@ fn send_query_next_mail_time_on_enter(
|
||||
}
|
||||
}
|
||||
|
||||
/// The client's message string for a `SMSG_SEND_MAIL_RESULT` error (vmangos `MailResponseResult`);
|
||||
/// texts quoted verbatim from the reference `GlobalStrings.lua` where one exists (decision 0544).
|
||||
/// `NOT_YOUR_TEAM` has no 1.12 GlobalString — a best-effort English literal stands in (flagged).
|
||||
pub(crate) fn mail_error_text(error: u32) -> String {
|
||||
/// One queued mail refusal, on its way to the red line.
|
||||
///
|
||||
/// Two arms because 1.12's own vocabulary has two: most `MailResponseResult` codes name a
|
||||
/// **message-catalog key**, whose row decides the text (from the player's own `GlobalStrings`),
|
||||
/// the surface, and — for `ERR_NOT_ENOUGH_MONEY`, line `0x28` — the *voice* (decision 1815). Two
|
||||
/// codes have no 1.12 string at all, and those keep the best-effort English literal they have
|
||||
/// always had: no row, so no sound, which is the honest shape rather than a borrowed one.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum MailRefusal {
|
||||
Key(&'static str),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
/// The refusal a `SMSG_SEND_MAIL_RESULT` error shows (vmangos `MailResponseResult`); keys
|
||||
/// cross-checked against the reference `GlobalStrings.lua` — every one of them resolves there to
|
||||
/// exactly the string this used to inline (decision 0544, now read at runtime instead).
|
||||
pub(crate) fn mail_refusal(error: u32) -> MailRefusal {
|
||||
match error {
|
||||
mail_error::CANNOT_SEND_TO_SELF => "You can't send mail to yourself.".into(), // ERR_MAIL_TO_SELF
|
||||
mail_error::NOT_ENOUGH_MONEY => "You don't have enough money.".into(), // ERR_NOT_ENOUGH_MONEY
|
||||
mail_error::RECIPIENT_NOT_FOUND => "Cannot find mail recipient.".into(), // ERR_MAIL_TARGET_NOT_FOUND
|
||||
mail_error::NOT_YOUR_TEAM => "That player is not part of your alliance.".into(), // no 1.12 GlobalString
|
||||
mail_error::INTERNAL_ERROR => "Internal mail database error.".into(), // ERR_MAIL_DATABASE_ERROR
|
||||
mail_error::TRIAL_ACCOUNT => "Trial accounts cannot perform that action.".into(),
|
||||
mail_error::TOO_MANY_ATTACHMENTS => {
|
||||
"You have reached the in-game cap of unique mail recipients".into() // ERR_MAIL_REACHED_CAP
|
||||
mail_error::CANNOT_SEND_TO_SELF => MailRefusal::Key("ERR_MAIL_TO_SELF"), // 0x164
|
||||
mail_error::NOT_ENOUGH_MONEY => MailRefusal::Key("ERR_NOT_ENOUGH_MONEY"), // 0x25
|
||||
mail_error::RECIPIENT_NOT_FOUND => MailRefusal::Key("ERR_MAIL_TARGET_NOT_FOUND"), // 0x165
|
||||
mail_error::INTERNAL_ERROR => MailRefusal::Key("ERR_MAIL_DATABASE_ERROR"), // 0x166
|
||||
mail_error::TOO_MANY_ATTACHMENTS => MailRefusal::Key("ERR_MAIL_REACHED_CAP"), // 0x1c4
|
||||
// No 1.12 GlobalString for either — flagged literals, as before.
|
||||
mail_error::NOT_YOUR_TEAM => {
|
||||
MailRefusal::Text("That player is not part of your alliance.".into())
|
||||
}
|
||||
mail_error::TRIAL_ACCOUNT => {
|
||||
MailRefusal::Text("Trial accounts cannot perform that action.".into())
|
||||
}
|
||||
other => MailRefusal::Text(format!("Mail failed ({other}).")),
|
||||
}
|
||||
}
|
||||
|
||||
impl MailRefusal {
|
||||
/// Resolve to a displayable line, reading a keyed one out of the VM's own `GlobalStrings`.
|
||||
/// `None` for a key whose string is absent or empty — the reference's data-suppression face.
|
||||
fn shown(self, get: &dyn Fn(&str) -> Option<String>) -> Option<crate::ui_action::Shown> {
|
||||
match self {
|
||||
Self::Key(key) => {
|
||||
let text = get(key)?;
|
||||
(!text.is_empty()).then(|| crate::ui_action::Shown::keyed(key, text))
|
||||
}
|
||||
Self::Text(text) => Some(crate::ui_action::Shown::unkeyed(
|
||||
benilla_ui::messages::MsgKind::Error,
|
||||
text,
|
||||
)),
|
||||
}
|
||||
other => format!("Mail failed ({other})."),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,12 +509,18 @@ fn feed_mail(
|
||||
self_q: Query<(&crate::net::ObjectStore, &crate::net::Guid), With<crate::net::SelfPlayer>>,
|
||||
states: Res<crate::world_state::WorldStates>,
|
||||
// The random-suffix roll's catalogs (1547): an enclosed item's rolled name.
|
||||
props: Option<Res<crate::items::RandomProperties>>,
|
||||
enchants: Option<Res<crate::items::Enchants>>,
|
||||
// The roll catalogs and the message sink, paired (the 16-SystemParam ceiling this signature
|
||||
// sits at): the random-suffix roll (1547), and where a refusal lands (1815).
|
||||
rolls_and_sink: (
|
||||
Option<Res<crate::items::RandomProperties>>,
|
||||
Option<Res<crate::items::Enchants>>,
|
||||
crate::ui_action::MessageSink,
|
||||
),
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let (props, enchants, mut sink) = rolls_and_sink;
|
||||
let rolls = crate::items::RollCatalogs {
|
||||
props: props.as_deref(),
|
||||
enchants: enchants.as_deref(),
|
||||
@@ -490,10 +529,12 @@ fn feed_mail(
|
||||
let last_mailbox = last_mailbox.get(&script);
|
||||
let last_has_new_mail = last_has_new_mail.get(&script);
|
||||
|
||||
// Take/return/delete failures surface as the client's red error line (the equip/cast path shape).
|
||||
for text in std::mem::take(&mut mail.errors) {
|
||||
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
|
||||
}
|
||||
// Take/return/delete failures go to the surface — and the voice — their message record names.
|
||||
let refusals: Vec<_> = std::mem::take(&mut mail.errors)
|
||||
.into_iter()
|
||||
.filter_map(|r| r.shown(&|key| script.lua().globals().get::<String>(key).ok()))
|
||||
.collect();
|
||||
crate::ui_action::show_messages(&mut script, &mut sink, "ui_mail", refusals);
|
||||
// SEND acks: MAIL_FAILED on EVERY send result (unblocks the button — wow-re §5), plus
|
||||
// MAIL_SEND_SUCCESS on OK (resets the form) or the red error line on a failure.
|
||||
for ack in std::mem::take(&mut mail.send_acks) {
|
||||
@@ -501,8 +542,11 @@ fn feed_mail(
|
||||
if ack.ok {
|
||||
script.fire_event("MAIL_SEND_SUCCESS", vec![]);
|
||||
script.clear_send_mail_item();
|
||||
} else if let Some(text) = ack.error_text {
|
||||
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
|
||||
} else if let Some(line) = ack
|
||||
.refusal
|
||||
.and_then(|r| r.shown(&|key| script.lua().globals().get::<String>(key).ok()))
|
||||
{
|
||||
crate::ui_action::show_messages(&mut script, &mut sink, "ui_mail", [line]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ use bevy::prelude::*;
|
||||
use crate::items::Items;
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfGuid, SelfPlayer};
|
||||
use crate::ui_chat::ChatLog;
|
||||
use crate::ui_items::{find_item, ItemSearch};
|
||||
use crate::ui_session::{npc_switched, NpcSession};
|
||||
|
||||
@@ -68,7 +67,7 @@ pub(super) fn feed_petition(
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
self_guid: Res<SelfGuid>,
|
||||
commands: Res<NetCommands>,
|
||||
mut chat_log: ResMut<ChatLog>,
|
||||
mut sink: crate::ui_action::MessageSink,
|
||||
mut fed: Local<crate::ui_script::VmMemo<FedPetition>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
@@ -79,7 +78,14 @@ pub(super) fn feed_petition(
|
||||
// The composed lines, each onto the surface its message record names. Drained before the
|
||||
// snapshot so a refusal shows in the same frame as the state change that caused it.
|
||||
let composed = std::mem::take(&mut petition.lines);
|
||||
crate::ui_action::show_messages(&mut script, &mut chat_log, "ui_petition", composed);
|
||||
crate::ui_action::show_messages(
|
||||
&mut script,
|
||||
&mut sink,
|
||||
"ui_petition",
|
||||
composed
|
||||
.into_iter()
|
||||
.map(|(key, text)| crate::ui_action::Shown::keyed(key, text)),
|
||||
);
|
||||
|
||||
// **Rebuilt every frame, deliberately.** Half of what this window shows arrives from a CACHE,
|
||||
// not a packet, and reading that cache is what ISSUES its query (decision 0660's law). So a
|
||||
|
||||
@@ -36,8 +36,6 @@
|
||||
|
||||
use benilla_protocol::messages::petition_result;
|
||||
|
||||
use crate::ui_action::MsgKind;
|
||||
|
||||
/// One message this module composes: the **catalog key**, which names both the row and — through
|
||||
/// [`benilla_ui::messages`] — the surface it goes to, and the 1.12 text.
|
||||
///
|
||||
@@ -109,20 +107,22 @@ const ERR_GUILD_NAME_TOO_SHORT: Msg = msg(
|
||||
"That name is too short. Enter a new name.",
|
||||
); // :1622
|
||||
|
||||
/// One composed line: the surface its message record names, and the text.
|
||||
pub(super) type Line = (MsgKind, String);
|
||||
/// One composed line: the **message key** and the text.
|
||||
///
|
||||
/// It carries the key rather than the resolved [`MsgKind`] because the record answers more than
|
||||
/// where the line goes — `ERR_PETITION_SIGNED` and its neighbours name a sound cue too
|
||||
/// (`igPlayerInviteAccept`/`Decline`), and `crate::ui_action::Shown` reads all of it from the one
|
||||
/// row at the sink (decision 1815).
|
||||
pub(super) type Line = (&'static str, String);
|
||||
|
||||
/// A line with nothing to fill.
|
||||
fn line(m: Msg) -> Line {
|
||||
(benilla_ui::messages::kind_of(m.key), m.text.to_string())
|
||||
(m.key, m.text.to_string())
|
||||
}
|
||||
|
||||
/// A line with one `%s` substitution.
|
||||
fn fill(m: Msg, arg: &str) -> Line {
|
||||
(
|
||||
benilla_ui::messages::kind_of(m.key),
|
||||
m.text.replacen("%s", arg, 1),
|
||||
)
|
||||
(m.key, m.text.replacen("%s", arg, 1))
|
||||
}
|
||||
|
||||
/// The line one `SMSG_PETITION_SIGN_RESULTS` prints **when the signer is us** — the switch at
|
||||
@@ -209,6 +209,13 @@ pub(super) fn name_refused_line(key: &str) -> Line {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use benilla_ui::messages::MsgKind;
|
||||
|
||||
/// A composed line resolved the way the sink resolves it — key → catalog row → surface. The
|
||||
/// assertions below are about *where a line goes*, and this is the join that answers it.
|
||||
fn shown(line: Line) -> (MsgKind, String) {
|
||||
(benilla_ui::messages::kind_of(line.0), line.1)
|
||||
}
|
||||
|
||||
/// **The channel split, which does not follow the key names.** Success to chat, refusal to the
|
||||
/// red frame — swept out of the message catalog, not guessed. This test is the one that would
|
||||
@@ -216,22 +223,22 @@ mod tests {
|
||||
#[test]
|
||||
fn successes_go_to_chat_and_refusals_go_to_the_red_line() {
|
||||
assert_eq!(
|
||||
my_sign_line(petition_result::OK),
|
||||
my_sign_line(petition_result::OK).map(shown),
|
||||
Some((MsgKind::Chat, "Guild charter signed.".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
signed_by_other("Bob"),
|
||||
shown(signed_by_other("Bob")),
|
||||
(MsgKind::Chat, "Bob has signed your guild charter.".into())
|
||||
);
|
||||
assert_eq!(
|
||||
declined_line("Bob"),
|
||||
shown(declined_line("Bob")),
|
||||
(
|
||||
MsgKind::Chat,
|
||||
"Bob has declined your guild invitation.".into()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
offered_line("Bob"),
|
||||
shown(offered_line("Bob")),
|
||||
(MsgKind::Chat, "You have requested Bob's signature.".into())
|
||||
);
|
||||
|
||||
@@ -254,13 +261,13 @@ mod tests {
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
my_sign_line(code),
|
||||
my_sign_line(code).map(shown),
|
||||
Some((MsgKind::Error, text.into())),
|
||||
"code {code} is a RED line"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
no_charter_line(),
|
||||
shown(no_charter_line()),
|
||||
(MsgKind::Error, "You don't have a guild charter.".into())
|
||||
);
|
||||
}
|
||||
@@ -276,7 +283,7 @@ mod tests {
|
||||
"the sign switch's default arm reaches only the debug console"
|
||||
);
|
||||
assert_eq!(
|
||||
turn_in_line(petition_result::NEED_MORE),
|
||||
turn_in_line(petition_result::NEED_MORE).map(shown),
|
||||
Some((MsgKind::Error, "You need more signatures.".into()))
|
||||
);
|
||||
}
|
||||
@@ -341,18 +348,18 @@ mod tests {
|
||||
#[test]
|
||||
fn refused_names_resolve_their_key_and_degrade_safely() {
|
||||
assert_eq!(
|
||||
name_refused_line("ERR_GUILD_ENTER_NAME"),
|
||||
shown(name_refused_line("ERR_GUILD_ENTER_NAME")),
|
||||
(MsgKind::Error, "Enter a name for your guild.".into())
|
||||
);
|
||||
assert_eq!(
|
||||
name_refused_line("ERR_GUILD_NAME_INVALID_SPACE"),
|
||||
shown(name_refused_line("ERR_GUILD_NAME_INVALID_SPACE")),
|
||||
(
|
||||
MsgKind::Error,
|
||||
"Guild names cannot start or end with a space. Enter a new name.".into()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
name_refused_line("ERR_SOMETHING_UNCARVED"),
|
||||
shown(name_refused_line("ERR_SOMETHING_UNCARVED")),
|
||||
(
|
||||
MsgKind::Error,
|
||||
"That name contains invalid characters, Enter a new name.".into()
|
||||
|
||||
@@ -29,8 +29,7 @@ use crate::entities::ItemDisplays;
|
||||
use crate::items::Items;
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfPlayer};
|
||||
use crate::ui_action::{show_messages, ui_error_text, MsgKind, UiError};
|
||||
use crate::ui_chat::ChatLog;
|
||||
use crate::ui_action::{show_messages, ui_error_text, MessageSink, Shown, UiError};
|
||||
use crate::ui_script::UiInput;
|
||||
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
|
||||
|
||||
@@ -446,7 +445,7 @@ fn feed_quest(
|
||||
mut names: ResMut<NameCache>,
|
||||
states: Res<crate::world_state::WorldStates>,
|
||||
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
|
||||
mut chat: ResMut<ChatLog>,
|
||||
mut sink: MessageSink,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<QuestState>>>,
|
||||
mut last_name: Local<crate::ui_script::VmMemo<Option<String>>>,
|
||||
mut last_npc: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
||||
@@ -466,15 +465,15 @@ fn feed_quest(
|
||||
// VM's own GlobalStrings first (immutable script), then show each on the surface its message
|
||||
// record names — decision 0669's `DisplayError` kind. Drained BEFORE the snapshot's early-out
|
||||
// so a refusal that also closes the panel still gets its line out this frame.
|
||||
let lines: Vec<(MsgKind, String)> = giver
|
||||
let lines: Vec<Shown> = giver
|
||||
.take_messages()
|
||||
.into_iter()
|
||||
.filter_map(|msg| {
|
||||
let get = |key: &str| script.lua().globals().get::<String>(key).ok();
|
||||
ui_error_text(&msg, &get).map(|text| (msg.kind(), text))
|
||||
ui_error_text(&msg, &get).map(|text| Shown::keyed(msg.key, text))
|
||||
})
|
||||
.collect();
|
||||
show_messages(&mut script, &mut chat, "ui_quest", lines);
|
||||
show_messages(&mut script, &mut sink, "ui_quest", lines);
|
||||
let player = crate::npc_text::player_identity(&self_q, &mut names, &commands);
|
||||
let fresh = snapshot(
|
||||
&giver,
|
||||
|
||||
@@ -47,8 +47,7 @@ use bevy::prelude::*;
|
||||
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{ClientCommand, NetCommands};
|
||||
use crate::ui_action::{show_messages, ui_error_text, MsgKind, UiError};
|
||||
use crate::ui_chat::ChatLog;
|
||||
use crate::ui_action::{show_messages, ui_error_text, MessageSink, Shown, UiError};
|
||||
use crate::ui_script::UiInput;
|
||||
|
||||
/// How many frames a queued line waits for its `%s` before it is dropped — [`crate::ui_loot`]'s
|
||||
@@ -159,14 +158,14 @@ fn feed_quest_share(
|
||||
mut share: ResMut<QuestShare>,
|
||||
mut names: ResMut<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
mut chat: ResMut<ChatLog>,
|
||||
mut sink: MessageSink,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
|
||||
// The verdicts: one line per resolved name, the rest kept for the next frame.
|
||||
let mut lines: Vec<(MsgKind, String)> = Vec::new();
|
||||
let mut lines: Vec<Shown> = Vec::new();
|
||||
let mut waiting = Vec::new();
|
||||
for mut v in std::mem::take(&mut share.verdicts) {
|
||||
let Some(key) = verdict_message(v.msg) else {
|
||||
@@ -185,7 +184,7 @@ fn feed_quest_share(
|
||||
};
|
||||
let get = |k: &str| script.lua().globals().get::<String>(k).ok();
|
||||
if let Some(text) = ui_error_text(&err, &get) {
|
||||
lines.push((err.kind(), text));
|
||||
lines.push(Shown::keyed(err.key, text));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
@@ -202,7 +201,7 @@ fn feed_quest_share(
|
||||
}
|
||||
}
|
||||
share.verdicts = waiting;
|
||||
show_messages(&mut script, &mut chat, "ui_quest_share", lines);
|
||||
show_messages(&mut script, &mut sink, "ui_quest_share", lines);
|
||||
|
||||
// The escort confirm — held until the member's name lands, then raised with the reference's own
|
||||
// two args: `QUEST_ACCEPT = "%s is starting %s\nWould you like to as well?"`, player first.
|
||||
|
||||
@@ -2156,11 +2156,12 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
|
||||
// bar as the other two. Camera Following Style is counted on the key it wears at rest (Smart's
|
||||
// OPTION_TOOLTIP_CAMERA1) and Show When on its own (Always's OPTION_TOOLTIP_TARGETOFTARGET5);
|
||||
// their other entries ride the same census as the selection moves.
|
||||
// 54 of the 56 are 1.12's own; the other two are Render Scale (1639) and Display Mode
|
||||
// 55 of the 57 are 1.12's own; the other two are Render Scale (1639) and Display Mode
|
||||
// (1650), whose descriptions are benilla's and whose carve-out is above.
|
||||
// The 28th CVar row is Block Trades (1764), on the Controls page — its key
|
||||
// OPTION_TOOLTIP_BLOCK_TRADES is the reference's, so it is counted here like the rest.
|
||||
assert_eq!(checked, 56, "every tipped row carries a live key");
|
||||
// OPTION_TOOLTIP_BLOCK_TRADES is the reference's, so it is counted here like the rest; the
|
||||
// 29th is Enable Error Speech (1815), 1.12's own fourth Sound checkbox, ditto.
|
||||
assert_eq!(checked, 57, "every tipped row carries a live key");
|
||||
assert_eq!(
|
||||
untipped,
|
||||
vec![
|
||||
@@ -2267,8 +2268,9 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
|
||||
// Cloak / Show Helm, 1472; the four multibar switches, 1500).
|
||||
// …plus the Graphics page's Render Scale (1639) and Display Mode (1650), the two rows whose
|
||||
// descriptions are benilla's own rather than 1.12 GlobalStrings — see the guard above.
|
||||
// …and Block Trades (1764), the Controls page's 28th CVar row.
|
||||
assert_eq!(raised, 56, "every row but Auto Loot raises a description");
|
||||
// …and Block Trades (1764), the Controls page's 28th CVar row, and Enable Error Speech
|
||||
// (1815), the Audio page's fourth checkbox and 1.12's own.
|
||||
assert_eq!(raised, 57, "every row but Auto Loot raises a description");
|
||||
}
|
||||
|
||||
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
|
||||
|
||||
@@ -863,9 +863,8 @@ fn overflowing_detail_content_clips_to_the_scrollframe_rect() {
|
||||
}
|
||||
|
||||
/// Wheeling over the detail pane changes `GetVerticalScroll()` — the ScrollFrame is
|
||||
/// mouse-wheel-interactive by construction (decision 0112, no wheel-catcher needed), and is
|
||||
/// declared after `QuestLogWheelCatcher` in this window's `<Frames>` so it out-ranks the
|
||||
/// catcher within its own rect (this window's own XML comment on hit-test z-order).
|
||||
/// mouse-wheel-interactive by construction (decision 0112), and it is the only frame in this
|
||||
/// window's bucket covering the detail rect, so the spin lands on it outright.
|
||||
#[test]
|
||||
fn wheel_over_the_detail_pane_changes_vertical_scroll() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
@@ -36,9 +36,7 @@
|
||||
use benilla_protocol::messages::StabledPet;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_ui::script::{
|
||||
ScriptValue, StableIntent, StablePetSlot, StableState, UiScript, NUM_STABLE_SLOTS,
|
||||
};
|
||||
use benilla_ui::script::{StableIntent, StablePetSlot, StableState, UiScript, NUM_STABLE_SLOTS};
|
||||
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{ClientCommand, NetCommands};
|
||||
@@ -230,16 +228,23 @@ fn feed_stable(
|
||||
mut errors: ResMut<StableErrors>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<StableState>>>,
|
||||
mut last_npc: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
||||
mut sink: crate::ui_action::MessageSink,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let last = last.get(&script);
|
||||
let last_npc = last_npc.get(&script);
|
||||
// Refusals surface as the client's red error line (the trainer/merchant path's exact shape).
|
||||
for text in errors.0.drain(..) {
|
||||
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
|
||||
}
|
||||
// Refusals go to the surface — and the voice — their message record names (1815).
|
||||
let lines: Vec<_> = errors
|
||||
.0
|
||||
.drain(..)
|
||||
.filter_map(|key| {
|
||||
let text = script.lua().globals().get::<String>(key).ok()?;
|
||||
(!text.is_empty()).then(|| crate::ui_action::Shown::keyed(key, text))
|
||||
})
|
||||
.collect();
|
||||
crate::ui_action::show_messages(&mut script, &mut sink, "ui_stable", lines);
|
||||
|
||||
// The next slot's price is the client's own job (the wire never sends it) — `StableSlotPrices`
|
||||
// row `purchased + 1`, the bank's arrangement. `0` past the table, a state in which the
|
||||
@@ -412,10 +417,13 @@ fn stable_subject(
|
||||
(unit, display)
|
||||
}
|
||||
|
||||
/// Stable refusals staged for the feed to surface on the window's error line — only ever
|
||||
/// `ERR_NOT_ENOUGH_MONEY`, the single code the client speaks for (decision 1677).
|
||||
/// Stable refusals staged for the feed, as **message-catalog keys** — only ever
|
||||
/// `ERR_NOT_ENOUGH_MONEY`, the single code the client speaks for (decision 1677), reached through
|
||||
/// `DisplayError(0x25)` (wow-re `system/ui/scratch/stable-master-window.md` §5, VERIFIED). A key
|
||||
/// rather than the resolved text because that row carries error-speech line `0x28` too — the
|
||||
/// character says it aloud (decision 1815).
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct StableErrors(pub(crate) Vec<String>);
|
||||
pub(crate) struct StableErrors(pub(crate) Vec<&'static str>);
|
||||
|
||||
/// `StableSlotPrices.dbc`, loaded once at startup — the purchase row's price oracle. A missing
|
||||
/// table quotes `0`, which is the same shape as "past the ladder": degraded, never wrong.
|
||||
|
||||
@@ -54,11 +54,6 @@ use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
|
||||
use crate::ui_script::UiInput;
|
||||
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
|
||||
|
||||
/// `ERR_NOT_ENOUGH_MONEY` (GlobalStrings.lua, verbatim) — the red line the reference's
|
||||
/// `DisplayError(0x25)` resolves when Accept is clicked without the cost in your purse. The same
|
||||
/// string [`crate::ui_merchant`] and [`crate::ui_mail`] already inline for their own refusals.
|
||||
const ERR_NOT_ENOUGH_MONEY: &str = "You don't have enough money.";
|
||||
|
||||
/// The pending respec question. Written by the net drain's `TalentWipeConfirm` arm, read by
|
||||
/// [`feed_talent_wipe`] (which fires `CONFIRM_TALENT_WIPE` and publishes `CheckTalentMasterDist`'s
|
||||
/// answer) and by [`drain_talent_wipe`] (which turns the dialog's Accept into the answer packet).
|
||||
@@ -146,6 +141,7 @@ fn drain_talent_wipe(
|
||||
mut wipe: ResMut<TalentWipeState>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut sink: crate::ui_action::MessageSink,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -170,10 +166,21 @@ fn drain_talent_wipe(
|
||||
"ui_talent_wipe: respec costs {} copper, purse holds {money} — refused client-side",
|
||||
wipe.cost
|
||||
);
|
||||
script.fire_event(
|
||||
"UI_ERROR_MESSAGE",
|
||||
vec![ScriptValue::Str(ERR_NOT_ENOUGH_MONEY.to_string())],
|
||||
);
|
||||
// The reference's `DisplayError(0x25)` = `ERR_NOT_ENOUGH_MONEY`: the red line AND, since
|
||||
// that row's `+0x0c` is `0x28`, the spoken one (decision 1815).
|
||||
let text = script
|
||||
.lua()
|
||||
.globals()
|
||||
.get::<String>("ERR_NOT_ENOUGH_MONEY")
|
||||
.unwrap_or_default();
|
||||
if !text.is_empty() {
|
||||
crate::ui_action::show_messages(
|
||||
&mut script,
|
||||
&mut sink,
|
||||
"ui_talent_wipe",
|
||||
[crate::ui_action::Shown::keyed("ERR_NOT_ENOUGH_MONEY", text)],
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for _ in 0..confirms {
|
||||
|
||||
@@ -40,7 +40,7 @@ use crate::ui_script::UiInput;
|
||||
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
|
||||
|
||||
mod routing;
|
||||
use routing::{build_nodes, load_taxi_catalogs, taxi_error_text, TaxiCatalogs, TaxiRouteCache};
|
||||
use routing::{build_nodes, load_taxi_catalogs, taxi_error_key, TaxiCatalogs, TaxiRouteCache};
|
||||
|
||||
/// The open taxi map (`SMSG_SHOWTAXINODES`'s payload, held exactly as the wire delivered it).
|
||||
pub(crate) struct TaxiOpen {
|
||||
@@ -188,6 +188,7 @@ fn feed_taxi(
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<TaxiUiState>>>,
|
||||
mut last_name: Local<crate::ui_script::VmMemo<Option<String>>>,
|
||||
mut last_riding: Local<crate::ui_script::VmMemo<Option<bool>>>,
|
||||
mut sink: crate::ui_action::MessageSink,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -196,14 +197,29 @@ fn feed_taxi(
|
||||
let last_name = last_name.get(&script);
|
||||
let last_riding = last_riding.get(&script);
|
||||
|
||||
// The activate verdict (SMSG_ACTIVATETAXIREPLY), staged by the net bridge: a refusal surfaces
|
||||
// on the red error line (the trainer/merchant UI_ERROR_MESSAGE pattern); OK clears the map —
|
||||
// vmangos's own send order is mount + the flight's SMSG_MONSTER_MOVE right behind the reply,
|
||||
// so by the time this lands the ride is already starting (0260's self-spline rails render it)
|
||||
// and the taxi map has nothing left to show, matching the real client's own close-on-success.
|
||||
// The activate verdict (SMSG_ACTIVATETAXIREPLY), staged by the net bridge: a refusal goes to
|
||||
// the surface its message record names ([`taxi_error_key`] — seven of the twelve are the
|
||||
// YELLOW info line, not the red one); OK clears the map — vmangos's own send order is mount +
|
||||
// the flight's SMSG_MONSTER_MOVE right behind the reply, so by the time this lands the ride is
|
||||
// already starting (0260's self-spline rails render it) and the taxi map has nothing left to
|
||||
// show, matching the real client's own close-on-success.
|
||||
if let Some(code) = state.reply.take() {
|
||||
match taxi_error_text(code) {
|
||||
Some(text) => script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]),
|
||||
match taxi_error_key(code) {
|
||||
Some(key) => {
|
||||
let text = script
|
||||
.lua()
|
||||
.globals()
|
||||
.get::<String>(key)
|
||||
.unwrap_or_default();
|
||||
if !text.is_empty() {
|
||||
crate::ui_action::show_messages(
|
||||
&mut script,
|
||||
&mut sink,
|
||||
"ui_taxi",
|
||||
[crate::ui_action::Shown::keyed(key, text)],
|
||||
);
|
||||
}
|
||||
}
|
||||
None => state.open = None,
|
||||
}
|
||||
}
|
||||
@@ -217,12 +233,24 @@ fn feed_taxi(
|
||||
// "TaxiNodeDiscovered", `igNewTaxiNodeDiscovered.wav`. There is NO FrameScript event of
|
||||
// that name — 0496 §TU-5's "named-event hashtable" was a mislabel of the sound-kit table
|
||||
// (the 0516 correction).
|
||||
//
|
||||
// **All three of those facts now come from the row itself** (decision 1815) — the surface from
|
||||
// `+0x04`, the text from the key, the cue from `+0x08` — where the surface and the cue used to
|
||||
// be hand-carried here, which is exactly the drift the catalog exists to stop.
|
||||
if std::mem::take(&mut state.discovered) {
|
||||
script.fire_event(
|
||||
"UI_INFO_MESSAGE",
|
||||
vec![ScriptValue::Str("New flight path discovered!".into())],
|
||||
);
|
||||
script.queue_sound_kit("TaxiNodeDiscovered");
|
||||
let text = script
|
||||
.lua()
|
||||
.globals()
|
||||
.get::<String>("ERR_NEWTAXIPATH")
|
||||
.unwrap_or_default();
|
||||
if !text.is_empty() {
|
||||
crate::ui_action::show_messages(
|
||||
&mut script,
|
||||
&mut sink,
|
||||
"ui_taxi",
|
||||
[crate::ui_action::Shown::keyed("ERR_NEWTAXIPATH", text)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let Some(catalogs) = catalogs else {
|
||||
|
||||
@@ -277,29 +277,36 @@ pub(super) fn build_nodes(
|
||||
Some((map_id, ui, resolved))
|
||||
}
|
||||
|
||||
/// The client's message string for an `SMSG_ACTIVATETAXIREPLY` refusal — byte-exact 1.12 enUS
|
||||
/// `GlobalStrings` (interface.MPQ `GlobalStrings.lua`). The vmangos `ActivateTaxiReplies` enum
|
||||
/// names (`Objects/Player.h`) ARE these `GlobalString` names one-for-one (`ERR_TAXIOK`,
|
||||
/// `ERR_TAXIUNSPECIFIEDSERVERERROR`, …) — cross-checked against the extracted `GlobalStrings.lua`
|
||||
/// this session, byte for byte. `OK` returns `None` (no line — the flight starts).
|
||||
pub(super) fn taxi_error_text(code: u32) -> Option<String> {
|
||||
let text = match code {
|
||||
/// The **message id** an `SMSG_ACTIVATETAXIREPLY` refusal displays, as its GlobalStrings key.
|
||||
///
|
||||
/// The reference's parser `FUN_005ed1e0` indexes a 13-entry table at `[0x85fedc + 4*code]` and
|
||||
/// hands the result straight to `CGGameUI::DisplayError 0x496720`: `code == 0` closes the map,
|
||||
/// `1..=12` toast, `code >= 13` does nothing at all (wow-re `system/ui/scratch/taxi-system.md`
|
||||
/// §TU-1, VERIFIED byte-exact). So this returns a key rather than a string, and the catalog row
|
||||
/// behind it decides the text, the **surface** and the sound — which matters here more than
|
||||
/// anywhere: **seven of the twelve are `kind = 1`, the YELLOW info line**, not the red one they
|
||||
/// all used to take, and `ERR_TAXINOTENOUGHMONEY` carries error-speech line `0x36` (decision 1815).
|
||||
///
|
||||
/// `OK` and anything past the table return `None` — the reference's own `code >= 13` no-op, in
|
||||
/// place of the "Taxi activation failed (n)." literal that used to stand here and that the
|
||||
/// reference never shows.
|
||||
pub(super) fn taxi_error_key(code: u32) -> Option<&'static str> {
|
||||
Some(match code {
|
||||
taxi_reply::OK => return None,
|
||||
taxi_reply::UNSPECIFIED_SERVER_ERROR => "UNSPECIFIED TAXI SERVER ERROR",
|
||||
taxi_reply::NO_SUCH_PATH => "There is no direct path to that destination!",
|
||||
taxi_reply::NOT_ENOUGH_MONEY => "You don't have enough money!",
|
||||
taxi_reply::TOO_FAR => "You are too far away from the taxi stand!",
|
||||
taxi_reply::NO_VENDOR_NEARBY => "There is no taxi vendor nearby!",
|
||||
taxi_reply::NOT_VISITED => "You haven't reached that taxi node on foot yet!",
|
||||
taxi_reply::BUSY => "You are busy and can't use the taxi service now.",
|
||||
taxi_reply::ALREADY_MOUNTED => "You are already mounted! Dismount first.",
|
||||
taxi_reply::SHAPESHIFTED => "You can't take a taxi while shapeshifted!",
|
||||
taxi_reply::PLAYER_MOVING => "You are moving.",
|
||||
taxi_reply::SAME_NODE => "You are already there!",
|
||||
taxi_reply::NOT_STANDING => "You need to be standing to go anywhere.",
|
||||
other => return Some(format!("Taxi activation failed ({other}).")),
|
||||
};
|
||||
Some(text.to_string())
|
||||
taxi_reply::UNSPECIFIED_SERVER_ERROR => "ERR_TAXIUNSPECIFIEDSERVERERROR", // 0xac
|
||||
taxi_reply::NO_SUCH_PATH => "ERR_TAXINOSUCHPATH", // 0xab
|
||||
taxi_reply::NOT_ENOUGH_MONEY => "ERR_TAXINOTENOUGHMONEY", // 0xad
|
||||
taxi_reply::TOO_FAR => "ERR_TAXITOOFARAWAY", // 0xae
|
||||
taxi_reply::NO_VENDOR_NEARBY => "ERR_TAXINOVENDORNEARBY", // 0xaf
|
||||
taxi_reply::NOT_VISITED => "ERR_TAXINOTVISITED", // 0xb0
|
||||
taxi_reply::BUSY => "ERR_TAXIPLAYERBUSY", // 0xb1
|
||||
taxi_reply::ALREADY_MOUNTED => "ERR_TAXIPLAYERALREADYMOUNTED", // 0xb2
|
||||
taxi_reply::SHAPESHIFTED => "ERR_TAXIPLAYERSHAPESHIFTED", // 0xb3
|
||||
taxi_reply::PLAYER_MOVING => "ERR_TAXIPLAYERMOVING", // 0xb4
|
||||
taxi_reply::SAME_NODE => "ERR_TAXISAMENODE", // 0xaa
|
||||
taxi_reply::NOT_STANDING => "ERR_TAXINOTSTANDING", // 0xb6
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -80,3 +80,132 @@ fn every_error_key_in_the_source_is_a_catalog_row() {
|
||||
for them: {strays:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The other half of the same claim, on real data: a key that carries a VOICE line can actually
|
||||
/// be spoken** (decision 1815).
|
||||
///
|
||||
/// 56 catalog rows put an error-speech id in `+0x0c` instead of a cue name, and the client says
|
||||
/// those aloud in the player's own race and gender. The join runs
|
||||
/// `key → MessageRecord::type_tag → VocalUISounds.dbc(race, line) → SoundEntries(sex)`, across
|
||||
/// four tables and two crates, and every link is a lookup that can come back empty *silently* —
|
||||
/// the same failure mode the walk above exists for, one layer deeper.
|
||||
///
|
||||
/// So: for every voice-tagged key this workspace raises, every playable race must have a row, and
|
||||
/// that row must resolve real audio for both sexes. The two exceptions are exceptions **in the
|
||||
/// shipped data**, named here so a regression cannot hide behind them. Skips without client data.
|
||||
#[test]
|
||||
fn every_voiced_key_the_source_raises_has_audio_for_every_playable_race() {
|
||||
use benilla_formats::VOCAL_UI_LINES;
|
||||
|
||||
/// Lines the 5875 data ships no audio for, in any race: `0x07` (`ERR_FOOD_COOLDOWN`) and
|
||||
/// `0x20` (`ERR_LOOT_BAD_FACING`). Both have a `VocalUISounds` row per race whose kit ids are
|
||||
/// `-1` — the file's own "no line recorded" — so the client is silent for them by data.
|
||||
const NO_AUDIO_IN_5875: &[u8] = &[0x07, 0x20];
|
||||
/// Single `(race, sex)` gaps in the shipped file — one voice actor's line that never got
|
||||
/// recorded. Listed rather than tolerated wholesale, so a *new* hole fails the test.
|
||||
const RACE_SEX_GAPS: &[(u8, u32, u32)] = &[
|
||||
(0x21, 3, 0), // ERR_LOOT_LOCKED, Dwarf male
|
||||
(0x21, 4, 0), // ERR_LOOT_LOCKED, Night Elf male
|
||||
(0x31, 2, 0), // ERR_MUST_EQUIP_ITEM, Orc male
|
||||
];
|
||||
|
||||
let data = match benilla_formats::wow_data() {
|
||||
Some(d) => d,
|
||||
None => return,
|
||||
};
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let vocal = benilla_formats::load_vocal_ui_sounds(&mut chain).expect("VocalUISounds.dbc");
|
||||
let kits = benilla_formats::load_sound_kit_catalog(&mut chain).expect("SoundEntries.dbc");
|
||||
|
||||
let mut sources = Vec::new();
|
||||
walk(Path::new("src"), &mut sources);
|
||||
walk(Path::new("../benilla-ui/src"), &mut sources);
|
||||
let keys: BTreeSet<String> = sources.iter().flat_map(|s| err_keys(s)).collect();
|
||||
|
||||
let mut voiced = 0;
|
||||
for key in &keys {
|
||||
let Some(record) = benilla_ui::messages::by_key(key) else {
|
||||
continue; // the walk above is what polices this
|
||||
};
|
||||
let tag = record.type_tag;
|
||||
if usize::from(tag) >= VOCAL_UI_LINES {
|
||||
continue; // a cue row, not a spoken one
|
||||
}
|
||||
voiced += 1;
|
||||
if NO_AUDIO_IN_5875.contains(&tag) {
|
||||
continue;
|
||||
}
|
||||
for race in 1..=8u32 {
|
||||
let row = vocal
|
||||
.rows()
|
||||
.iter()
|
||||
.find(|r| r.race == race && r.line == u32::from(tag))
|
||||
.unwrap_or_else(|| panic!("{key} (line {tag:#04x}) has no row for race {race}"));
|
||||
for sex in 0..2u32 {
|
||||
if RACE_SEX_GAPS.contains(&(tag, race, sex)) {
|
||||
continue;
|
||||
}
|
||||
let kit = row
|
||||
.normal_kit(sex)
|
||||
.and_then(|k| kits.get(k))
|
||||
.unwrap_or_else(|| {
|
||||
panic!("{key} (line {tag:#04x}) is silent for race {race} sex {sex}")
|
||||
});
|
||||
assert!(
|
||||
!kit.files.is_empty(),
|
||||
"{key}: kit {} has no files for race {race} sex {sex}",
|
||||
kit.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// benilla raises a good share of the 45 distinct voiced lines — a floor, so a refactor that
|
||||
// quietly stopped routing refusals through the catalog fails here instead of going silent.
|
||||
assert!(
|
||||
voiced >= 30,
|
||||
"only {voiced} voice-tagged keys reached the catalog — the raise sites stopped naming them"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Why benilla may hang a message's sound off its display, even though the reference does not.**
|
||||
///
|
||||
/// `CGGameUI::DisplayError 0x496720` sounds first (`0x49673d`) and only then guards on the row's
|
||||
/// key (`0x4967bd`/`0x4967c5`) and, in the sink, on the resolved text (`0x4945b4`) — so *there*, a
|
||||
/// message with no GlobalStrings string still makes its noise. benilla drops an empty line before
|
||||
/// it reaches `show_messages`, so it would not. That is a divergence with **no case in 5875's
|
||||
/// data**, and this is the measurement that says so: every row that sounds also has text.
|
||||
///
|
||||
/// If this ever fails, `sound::message` has to sound the row independently of the display rather
|
||||
/// than alongside it. Skips without client data.
|
||||
#[test]
|
||||
fn every_sounding_catalog_row_also_has_text_to_show() {
|
||||
let data = match benilla_formats::wow_data() {
|
||||
Some(d) => d,
|
||||
None => return,
|
||||
};
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let src = chain
|
||||
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
|
||||
.expect("GlobalStrings.lua in the chain");
|
||||
let vm = benilla_ui::script::UiScript::new().expect("VM");
|
||||
vm.run(&String::from_utf8_lossy(&src)).expect("runs clean");
|
||||
|
||||
let mut sounding = 0;
|
||||
for r in benilla_ui::messages::CATALOG {
|
||||
let sounds = usize::from(r.type_tag) < benilla_formats::VOCAL_UI_LINES || r.sound.is_some();
|
||||
if !sounds {
|
||||
continue;
|
||||
}
|
||||
sounding += 1;
|
||||
let text: String = vm.lua().globals().get(r.key).unwrap_or_default();
|
||||
assert!(
|
||||
!text.is_empty(),
|
||||
"{} sounds (tag {:#04x}, cue {:?}) but has no 1.12 string — benilla would be silent \
|
||||
where the reference is not",
|
||||
r.key,
|
||||
r.type_tag,
|
||||
r.sound
|
||||
);
|
||||
}
|
||||
assert_eq!(sounding, 86, "56 voice lines + 30 named cues");
|
||||
}
|
||||
|
||||
@@ -169,6 +169,10 @@ mod sound_provider;
|
||||
pub use sound_provider::{load_sound_provider_catalog, SoundProvider, SoundProviderCatalog};
|
||||
mod sound_water;
|
||||
pub use sound_water::{load_water_sound_catalog, WaterSoundCatalog};
|
||||
mod vocal_ui_sounds;
|
||||
pub use vocal_ui_sounds::{
|
||||
load_vocal_ui_sounds, VocalUiSound, VocalUiSoundCatalog, VOCAL_UI_LINES,
|
||||
};
|
||||
mod weapon_impact;
|
||||
pub use weapon_impact::{
|
||||
impact_slot, load_weapon_impact_catalog, WeaponImpactCatalog, WeaponImpactRow,
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
//! `VocalUISounds.dbc` — the **error speech** lines: the "I can't do that" family your character
|
||||
//! says aloud in their own race and gender voice when the client refuses something (decision 1815).
|
||||
//!
|
||||
//! One row per `(race, line)`; the *line* is the message catalog's own `type_tag`
|
||||
//! ([`benilla_ui::messages::MessageRecord::type_tag`], the record's `+0x0c`), so a refusal that
|
||||
//! reaches `CGGameUI::DisplayError` already names its voice line. Its `0x44` value is the "no
|
||||
//! speech, play the named cue instead" sentinel — which is exactly the table's width, and exactly
|
||||
//! one past the shipped file's largest `VocalUIEnum` (67).
|
||||
//!
|
||||
//! Layout — VERIFIED against build 5875 (`WoW/Data/patch.MPQ`, decoded 2026-09-01): the WDBC header
|
||||
//! reports **533 records · 7 fields · 28 B/record**, matching the `imul ebx,ebx,0x1c` stride the
|
||||
//! reference's table builder walks with (`0x45813d`). Fields, read off that builder's own loads:
|
||||
//!
|
||||
//! | field | offset | what | where the reference reads it |
|
||||
//! |---|---|---|---|
|
||||
//! | 0 | `+0x00` | `ID` | — |
|
||||
//! | 1 | `+0x04` | [`VocalUiSound::line`] (`VocalUIEnum`), kept only when `< 0x44` | `0x45815c`/`0x45815f` |
|
||||
//! | 2 | `+0x08` | [`VocalUiSound::race`] | `0x45816b` (equality against the player's race) |
|
||||
//! | 3 | `+0x0c` | [`VocalUiSound::normal`]`[0]` — male | `0x458173` → `[0xb06240 + line*12]` |
|
||||
//! | 4 | `+0x10` | [`VocalUiSound::normal`]`[1]` — female | `0x458180` → `[0xb06570 + line*12]` |
|
||||
//! | 5 | `+0x14` | [`VocalUiSound::pissed`]`[0]` — male | `0x458190` → `[0xb06244 + line*12]` |
|
||||
//! | 6 | `+0x18` | [`VocalUiSound::pissed`]`[1]` — female | `0x4581a3` → `[0xb06574 + line*12]` |
|
||||
//!
|
||||
//! The sex grouping is the load-bearing half of that decode and it is doubly confirmed: the two
|
||||
//! sex blocks sit `0x330` = `0x44 * 12` bytes apart, and the shipped data's own kit **names** agree
|
||||
//! row for row — line 0 resolves `HumanMale_InventoryFull` / `HumanFemale_InventoryFull`, and
|
||||
//! message id 0 (`ERR_INV_FULL`) carries `type_tag` 0.
|
||||
//!
|
||||
//! **A sound id here can be `0` or `0xFFFF_FFFF`, and both mean "nothing".** The reference stores
|
||||
//! the raw field and lets its id-indexed kit lookup (`0x45cda0`: `cmp ecx,count; jae → NULL`) turn
|
||||
//! both into a miss — `-1` unsigned is past the count, and slot 0 of the table is empty because
|
||||
//! `SoundEntries` has no id 0. 190 of the file's 1 066 normal ids are `-1` and 8 are `0`; a
|
||||
//! `(race, sex, line)` with no kit is simply silent. [`VocalUiSound::normal_kit`] is that filter,
|
||||
//! written once so no consumer re-derives it.
|
||||
|
||||
use crate::Chain;
|
||||
use anyhow::{Context, Result};
|
||||
use benilla_dbc::{FieldType, Schema, SchemaField};
|
||||
|
||||
use crate::dbc::{parse, u32_at};
|
||||
|
||||
const VOCAL_UI_SOUNDS: &str = "DBFilesClient\\VocalUISounds.dbc";
|
||||
|
||||
/// How many error-speech lines the reference's table holds — `0x44`, the bound its builder
|
||||
/// (`0x45815f cmp eax,0x44; jge`) and its player (`0x45829c cmp edi,0x44; jge`) both test, and the
|
||||
/// width of each of the two sex blocks. Also the message catalog's "no speech" sentinel: a
|
||||
/// `type_tag` of exactly `0x44` means *play the record's named cue instead*.
|
||||
pub const VOCAL_UI_LINES: usize = 0x44;
|
||||
|
||||
/// One `VocalUISounds.dbc` row — the kits one race says one line with, per sex.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct VocalUiSound {
|
||||
pub id: u32,
|
||||
/// `VocalUIEnum` — the message catalog's `type_tag`. Always `< VOCAL_UI_LINES` in a row this
|
||||
/// loader kept; the reference drops the others at table-build time and so do we.
|
||||
pub line: u32,
|
||||
/// `ChrRaces` id (the descriptor's `UNIT_FIELD_BYTES_0` race byte).
|
||||
pub race: u32,
|
||||
/// The line's ordinary delivery, by sex (`0` male, `1` female). `0`/`-1` = none.
|
||||
pub normal: [u32; 2],
|
||||
/// The **annoyed** delivery, by sex — what the reference escalates to after four consecutive
|
||||
/// plays of the same line. Every one of the shipped file's 1 066 ids is absent from
|
||||
/// `SoundEntries.dbc`, so this column is **dormant in 5875's data**; it is carried because the
|
||||
/// mechanism reads it, not because anything can be heard from it.
|
||||
pub pissed: [u32; 2],
|
||||
}
|
||||
|
||||
impl VocalUiSound {
|
||||
/// The kit for this row's ordinary line at `sex`, with the reference's own two misses folded
|
||||
/// in (`0` and `-1` both resolve to no kit — module doc). `sex` past 1 is `None`: the
|
||||
/// reference refuses those at its own front door (`0x458254`/`0x45825b`).
|
||||
pub fn normal_kit(&self, sex: u32) -> Option<u32> {
|
||||
kit(self.normal, sex)
|
||||
}
|
||||
|
||||
/// The annoyed line's kit at `sex` — [`Self::normal_kit`]'s twin (and, on 5875's data, always
|
||||
/// a kit that resolves to nothing; see [`Self::pissed`]).
|
||||
pub fn pissed_kit(&self, sex: u32) -> Option<u32> {
|
||||
kit(self.pissed, sex)
|
||||
}
|
||||
}
|
||||
|
||||
fn kit(pair: [u32; 2], sex: u32) -> Option<u32> {
|
||||
let id = *pair.get(sex as usize)?;
|
||||
(id != 0 && id != u32::MAX).then_some(id)
|
||||
}
|
||||
|
||||
/// The whole table, as rows. Consumers build the reference's per-race lookup from it
|
||||
/// (`benilla_app::sound::vocal`); this crate stays the file's reader and nothing more.
|
||||
pub struct VocalUiSoundCatalog {
|
||||
rows: Vec<VocalUiSound>,
|
||||
}
|
||||
|
||||
impl VocalUiSoundCatalog {
|
||||
/// Every kept row, in **file order** — which matters: the reference builds its table by
|
||||
/// walking the file *backwards* (`0x458140`), so on a duplicate `(race, line)` the
|
||||
/// lowest-indexed row is the one left standing. A consumer that folds these in order gets the
|
||||
/// same answer. (5875's file has no duplicate pair, so this is a rule with no live case.)
|
||||
pub fn rows(&self) -> &[VocalUiSound] {
|
||||
&self.rows
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.rows.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rows.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Load `VocalUISounds.dbc` off the patch chain, dropping the rows the reference's builder drops
|
||||
/// (`VocalUIEnum >= 0x44`).
|
||||
pub fn load_vocal_ui_sounds(chain: &mut Chain) -> Result<VocalUiSoundCatalog> {
|
||||
let bytes = chain
|
||||
.read_file(VOCAL_UI_SOUNDS)
|
||||
.with_context(|| format!("reading {VOCAL_UI_SOUNDS}"))?;
|
||||
let mut schema = Schema::new("VocalUISounds");
|
||||
for i in 0..7 {
|
||||
schema.add_field(SchemaField::new(format!("f{i}"), FieldType::UInt32));
|
||||
}
|
||||
let rs = parse(&bytes, schema, "VocalUISounds")?;
|
||||
let mut rows = Vec::new();
|
||||
for r in rs.records() {
|
||||
let (Some(id), Some(line), Some(race)) = (u32_at(r, 0), u32_at(r, 1), u32_at(r, 2)) else {
|
||||
continue;
|
||||
};
|
||||
if line as usize >= VOCAL_UI_LINES {
|
||||
continue;
|
||||
}
|
||||
let f = |i| u32_at(r, i).unwrap_or(0);
|
||||
rows.push(VocalUiSound {
|
||||
id,
|
||||
line,
|
||||
race,
|
||||
normal: [f(3), f(4)],
|
||||
pissed: [f(5), f(6)],
|
||||
});
|
||||
}
|
||||
Ok(VocalUiSoundCatalog { rows })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The real 5875 file, and the facts the decode rests on: its shape, its `VocalUIEnum` domain
|
||||
/// (dense `0..=67`, one short of the sentinel), and the **sex column order** — read back
|
||||
/// through `SoundEntries` names, which is the check that would have caught a transposed pair.
|
||||
/// Skips without client data.
|
||||
#[test]
|
||||
fn real_vocal_ui_sounds_decode_with_the_sexes_the_right_way_round() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_vocal_ui_sounds(&mut chain).expect("load VocalUISounds");
|
||||
let kits = crate::load_sound_kit_catalog(&mut chain).expect("load SoundEntries");
|
||||
assert_eq!(cat.len(), 533, "every shipped row is under the 0x44 bound");
|
||||
|
||||
let lines: std::collections::BTreeSet<u32> = cat.rows().iter().map(|r| r.line).collect();
|
||||
assert_eq!(lines.iter().copied().max(), Some(67));
|
||||
assert_eq!(lines.len(), VOCAL_UI_LINES, "the enum is dense 0..=0x43");
|
||||
|
||||
// Human (race 1), line 0 = the message catalog's `ERR_INV_FULL` tag. Male and female must
|
||||
// land on their own voices — the whole point of the `+0x0c`/`+0x10` pair.
|
||||
let human_inv_full = cat
|
||||
.rows()
|
||||
.iter()
|
||||
.find(|r| r.race == 1 && r.line == 0)
|
||||
.expect("Human line 0");
|
||||
let name = |kit: Option<u32>| {
|
||||
kit.and_then(|k| kits.get(k))
|
||||
.map(|k| k.name.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
assert_eq!(
|
||||
name(human_inv_full.normal_kit(0)),
|
||||
"HumanMale_InventoryFull"
|
||||
);
|
||||
assert_eq!(
|
||||
name(human_inv_full.normal_kit(1)),
|
||||
"HumanFemale_InventoryFull"
|
||||
);
|
||||
assert_eq!(
|
||||
human_inv_full.normal_kit(2),
|
||||
None,
|
||||
"sex past 1 has no voice"
|
||||
);
|
||||
|
||||
// The naming law holds across races and lines, so a column slip anywhere shows up here.
|
||||
for (race, prefix) in [(3u32, "Dwarf"), (6, "Tauren"), (8, "Troll")] {
|
||||
let row = cat
|
||||
.rows()
|
||||
.iter()
|
||||
.find(|r| r.race == race && r.line == 0)
|
||||
.expect("row");
|
||||
assert_eq!(
|
||||
name(row.normal_kit(0)),
|
||||
format!("{prefix}Male_InventoryFull")
|
||||
);
|
||||
assert_eq!(
|
||||
name(row.normal_kit(1)),
|
||||
format!("{prefix}Female_InventoryFull")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **The annoyed column is dormant in 5875** — every one of its ids is absent from
|
||||
/// `SoundEntries.dbc`, so the reference's escalation has nothing to escalate *to* and the
|
||||
/// ordinary line plays every time. Asserted rather than assumed, because the play state
|
||||
/// machine's whole observable behaviour turns on it (`benilla_app::sound::vocal`). Skips
|
||||
/// without client data.
|
||||
#[test]
|
||||
fn the_annoyed_column_resolves_to_nothing_in_this_build() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_vocal_ui_sounds(&mut chain).expect("load VocalUISounds");
|
||||
let kits = crate::load_sound_kit_catalog(&mut chain).expect("load SoundEntries");
|
||||
let live = cat
|
||||
.rows()
|
||||
.iter()
|
||||
.flat_map(|r| [r.pissed_kit(0), r.pissed_kit(1)])
|
||||
.flatten()
|
||||
.filter(|k| kits.get(*k).is_some())
|
||||
.count();
|
||||
assert_eq!(
|
||||
live, 0,
|
||||
"an annoyed line resolved — 1815's dormancy is over"
|
||||
);
|
||||
// The control: the ordinary column is mostly live, so the sweep above is testing the data
|
||||
// and not a broken lookup.
|
||||
let normal_live = cat
|
||||
.rows()
|
||||
.iter()
|
||||
.flat_map(|r| [r.normal_kit(0), r.normal_kit(1)])
|
||||
.flatten()
|
||||
.filter(|k| kits.get(*k).is_some())
|
||||
.count();
|
||||
assert_eq!(normal_live, 785);
|
||||
}
|
||||
}
|
||||
@@ -71,9 +71,15 @@ impl Loader<'_> {
|
||||
// is in the set, `OnDragStop`/`OnReceiveDrag` are NOT. This is XML-load-time
|
||||
// ONLY: the Lua SetScript binding (`0x7748d0`) never auto-enables, so the law
|
||||
// lives here and not in SetScript itself (a runtime-created frame still needs an
|
||||
// explicit `EnableMouse(true)`, like the real client). The wheel kind
|
||||
// (`OnMouseWheel` = kind 3) has a separate index this engine doesn't model yet;
|
||||
// the KEYBOARD kinds are modelled and armed just below.
|
||||
// explicit `EnableMouse(true)`, like the real client). The KEYBOARD kinds are
|
||||
// modelled and armed just below, and the WHEEL kind (`OnMouseWheel` = kind 3) is
|
||||
// armed the same way right after — it used to say "a separate index this engine
|
||||
// doesn't model yet", and the hit-test carve
|
||||
// (`ui/scratch/hittest-no-fallthrough-law.md`) is what made modelling it necessary:
|
||||
// the wheel plane is the ONE place the engine really does gate on a handler and
|
||||
// continue past a frame that has none. Until it was its own flag, our wheel sweep
|
||||
// had to accept any mouse-enabled frame as a stand-in, and the first such frame
|
||||
// swallowed the wheel.
|
||||
const MOUSE_KIND: [&str; 5] = [
|
||||
"OnEnter",
|
||||
"OnLeave",
|
||||
@@ -84,6 +90,9 @@ impl Loader<'_> {
|
||||
if MOUSE_KIND.iter().any(|k| name.eq_ignore_ascii_case(k)) {
|
||||
self.call(wrapper, "EnableMouse", true, dbg);
|
||||
}
|
||||
if name.eq_ignore_ascii_case("OnMouseWheel") {
|
||||
self.call(wrapper, "EnableMouseWheel", true, dbg);
|
||||
}
|
||||
// The KEYBOARD kinds, the same walker rule one index over (`OnChar` = kind 0,
|
||||
// `OnKeyDown`/`OnKeyUp` = kind 1). Bucket membership is what the delivery walk
|
||||
// iterates ([`crate::script::keyboard`]) and it is the FLAG, never the presence of
|
||||
|
||||
@@ -423,36 +423,67 @@ pub fn traversal(arena: &WidgetArena) -> Vec<(ZTarget, ZKey)> {
|
||||
// Hit-testing — the mouse-focus capture walk
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Find the frame the cursor captures, per the documented top-down-by-draw-order mouse-focus model
|
||||
/// (`propagation.md`): mouse focus walks frames **top-down in reverse draw order** (the
|
||||
/// topmost-*drawn* frame first) and the first mouse-enabled, visible frame whose rect contains the
|
||||
/// cursor captures.
|
||||
/// Find the frame the cursor captures — the reference's flat hit-test index, **byte-carved**
|
||||
/// (wow-re `system/ui/scratch/hittest-no-fallthrough-law.md`).
|
||||
///
|
||||
/// ## Fidelity: spec-faithful, NOT byte-pinned
|
||||
/// The sweep `0x7660d0` walks the mouse plane strata high→low and, within a stratum, the index from
|
||||
/// 0 upward, ending the ENTIRE sweep on the first probe that returns non-zero. There is **no
|
||||
/// fall-through**: `0x7663f4 call [edx+0x68]` is followed by `0x7663f7 mov eax,1`, so the handler's
|
||||
/// return is destroyed before it is tested. A frame that takes the mouse and does nothing with the
|
||||
/// click eats it — a `RegisterForClicks` miss, a disabled button and an unbound script all end the
|
||||
/// event identically. (The one real fall-through in the engine is the WHEEL plane, which does gate
|
||||
/// on a handler and continue; the engine has the idiom and deliberately does not use it here.)
|
||||
///
|
||||
/// wow-5875-re located the real mouse-hit-test leaf (InputControl / CSimpleTop) *to its translation
|
||||
/// unit* but **did not byte-pin it** — it is a flagged RE-time item, not an anchor (`anchors.md`:
|
||||
/// "the exact mouse-hit-test leaf inside InputControl/CSimpleTop … NOT byte-pinned"). So this is
|
||||
/// faithful to the *documented model*, **not** bit-exact to the binary. The one primitive that *is*
|
||||
/// verified is `point_in_rect 0x76b020` (the clip/hit rect test); the top-down capture policy above
|
||||
/// is the documented model, not a byte anchor.
|
||||
/// **The index is FLAT** — sorted `(strata, frame level, insertion order)`, with no parent/child
|
||||
/// nesting term at all, which is exactly [`traversal`]'s key. The tie is the part that matters and
|
||||
/// the part we had backwards: `0x764aa0`'s comparison is a strict `ja`, so a frame arriving at a
|
||||
/// level that already exists is **appended after** its equals, and the sweep from index 0 therefore
|
||||
/// reaches the **earlier-linked** frame first. `SetFrameLevel` relinks the parent before recursing
|
||||
/// into children, and effective-Show registers self (`0x76ae85`) before its children (`0x76aeb6`) —
|
||||
/// so at equal levels **the parent wins over its own child**.
|
||||
///
|
||||
/// Pure over its inputs: `sorted` is a draw-order-**ascending** list (as [`traversal`] returns), and
|
||||
/// `hits(frame)` reports whether that frame is a capture candidate at the cursor — i.e. it is
|
||||
/// mouse-enabled **and** its resolved rect contains the point. Visibility is already handled
|
||||
/// upstream: [`traversal`] only emits effective-visible frames, so an (effective-)hidden frame never
|
||||
/// reaches `hits`. Regions never capture (only [`ZTarget::Frame`] entries are considered). Returns
|
||||
/// the first hit found walking from the top of the draw order downward.
|
||||
/// That is the whole reason `TargetFrame_OnLoad`'s `SetFrameLevel(textureFrame-1)` on the two bars
|
||||
/// works: the bars land at exactly `TargetFrame`'s own level, and the tie goes to the frame that
|
||||
/// registered first. Walking the draw order in reverse — which is what this did until the carve —
|
||||
/// inverts it and hands the click to the child, so right-clicking a unit frame opened nothing.
|
||||
///
|
||||
/// **The SIBLING case is the one that bites hardest, and it is why this is not a local fix**
|
||||
/// (decision 1816). All children of one parent share `parent.level + 1`, so every sibling set is one
|
||||
/// big tie, resolved purely by declaration order — and under the true law the FIRST-declared sibling
|
||||
/// wins, not the last. A full-area mouse-enabled overlay declared at the top of a `<Frames>` list
|
||||
/// therefore swallows its whole window. Five of our own windows shipped exactly that (the
|
||||
/// `*WheelCatcher` `<Button>`s), correct only under the inverted order and removed with this carve;
|
||||
/// `SkillFrame.xml` had already recorded one shipped bug from the same shape. Note that
|
||||
/// `<Button>` is mouse-enabled by its *constructor* (decision 1795), so such an overlay needs no
|
||||
/// `enableMouse` to compete — the wheel is a separate index and needs no mouse hit target at all.
|
||||
///
|
||||
/// Drawing is unaffected and stays later-on-top: this reordering is the HIT sweep's alone.
|
||||
///
|
||||
/// Pure over its inputs: `sorted` is draw-order-**ascending** (as [`traversal`] returns), and
|
||||
/// `hits(frame)` reports whether that frame is a capture candidate at the cursor. Visibility is
|
||||
/// handled upstream. Regions never capture.
|
||||
pub fn hit_test<F: Fn(FrameHandle) -> bool>(
|
||||
sorted: &[(ZTarget, ZKey)],
|
||||
hits: F,
|
||||
) -> Option<FrameHandle> {
|
||||
for (target, _) in sorted.iter().rev() {
|
||||
if let ZTarget::Frame(fh) = *target {
|
||||
if hits(fh) {
|
||||
return Some(fh);
|
||||
// Everything above the insertion field: one (strata, level) plane of the reference's index.
|
||||
let plane = |k: ZKey| k.0 >> LEVEL_SHIFT;
|
||||
let mut end = sorted.len();
|
||||
while end > 0 {
|
||||
let p = plane(sorted[end - 1].1);
|
||||
let mut start = end;
|
||||
while start > 0 && plane(sorted[start - 1].1) == p {
|
||||
start -= 1;
|
||||
}
|
||||
// Planes descend; WITHIN a plane the earlier-linked frame is probed first.
|
||||
for (target, _) in &sorted[start..end] {
|
||||
if let ZTarget::Frame(fh) = *target {
|
||||
if hits(fh) {
|
||||
return Some(fh);
|
||||
}
|
||||
}
|
||||
}
|
||||
end = start;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -930,7 +930,10 @@ mod tests {
|
||||
let e = s.run("lb:SetSlot('x')").unwrap_err().to_string();
|
||||
assert!(e.contains("Usage: SetSlot(index)"), "{e}");
|
||||
|
||||
// Never slotted → takes nothing, rather than silently taking row 1.
|
||||
// Never slotted → takes nothing, rather than silently taking row 1. `LB1` is still parked
|
||||
// under the cursor from the clicks above and, being linked first, would win the tie for the
|
||||
// point (decision 1816) and take row 2 again — get it out of the way first.
|
||||
s.run("LB1:Hide()").unwrap();
|
||||
s.run(r#"fresh = CreateFrame("LootButton", "LB2", UIParent)"#)
|
||||
.unwrap();
|
||||
hardware_click(&mut s, "LB2", "LeftButton");
|
||||
|
||||
@@ -187,9 +187,15 @@ impl UiScript {
|
||||
let sorted = order::traversal(&model.arena);
|
||||
let scroll_sources = scroll_clip_sources(&model);
|
||||
let fh = order::hit_test(&sorted, |fh| {
|
||||
(model.arena.is_mouse_wheel_enabled(fh)
|
||||
|| model.arena.is_mouse_enabled(fh)
|
||||
|| link_span_hit(&model, fh, x, y))
|
||||
// **The wheel gates on the handler and CONTINUES** — the one genuine fall-through in
|
||||
// the engine (wow-re `ui/scratch/hittest-no-fallthrough-law.md`). Down/up deliberately
|
||||
// do not use the idiom; the wheel does. So this asks for the WHEEL flag alone and lets
|
||||
// the sweep walk past a frame that merely takes the mouse, which is what puts the wheel
|
||||
// through a scroll pane's chrome and into the pane.
|
||||
//
|
||||
// It used to also accept `is_mouse_enabled` as a stand-in, because the loader did not
|
||||
// arm the wheel kind and no frame ever carried the flag. The loader arms it now.
|
||||
model.arena.is_mouse_wheel_enabled(fh)
|
||||
&& model.resolved.get(&fh).is_some_and(|r| {
|
||||
point_in_rect(inset_rect(*r, model.arena.hit_rect_insets(fh)), x, y)
|
||||
})
|
||||
|
||||
@@ -36,8 +36,13 @@ fn enable_mouse_gates_hit_testing() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The hit sweep's key, all three terms: strata, then level, then — at a tie — the **earlier-linked**
|
||||
/// frame, NOT the later one that draws on top (decision 1816; wow-re
|
||||
/// `ui/scratch/hittest-no-fallthrough-law.md` §4, `0x764aa0`'s strict `ja` appending equal keys and
|
||||
/// `0x7660d0` sweeping from index 0). This test used to assert the opposite at the tie and was named
|
||||
/// for it — draw order and hit order agree on strata and level and disagree on exactly this.
|
||||
#[test]
|
||||
fn topmost_by_draw_order_captures_among_overlapping_enabled_frames() {
|
||||
fn hit_order_is_strata_then_level_then_the_earlier_linked_frame() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
@@ -54,21 +59,25 @@ fn topmost_by_draw_order_captures_among_overlapping_enabled_frames() {
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// Same strata/level: B has the later insertion ⇒ drawn on top ⇒ captures.
|
||||
// Same strata/level: A was linked FIRST, so the sweep reaches it first and it captures —
|
||||
// even though B, linked later, draws on top of it.
|
||||
s.mouse_move(400.0, 300.0);
|
||||
assert_eq!(s.eval::<String>("return who").unwrap(), "B");
|
||||
assert_eq!(s.eval::<String>("return who").unwrap(), "A");
|
||||
|
||||
// Move off everything to clear the mouseover (fires OnLeave, resets focus) before re-testing.
|
||||
s.mouse_move(-10.0, -10.0);
|
||||
|
||||
// Raise A above B by strata (no rect change ⇒ no re-resolve needed): A now captures.
|
||||
// Raise A above B by strata (no rect change ⇒ no re-resolve needed): strata outranks the tie,
|
||||
// and A keeps the point for the stronger reason.
|
||||
s.run("A:SetFrameStrata('DIALOG')").unwrap();
|
||||
s.mouse_move(400.0, 300.0);
|
||||
assert_eq!(s.eval::<String>("return who").unwrap(), "A");
|
||||
|
||||
s.mouse_move(-10.0, -10.0);
|
||||
|
||||
// Put B in the same (DIALOG) strata but a higher frame level: B captures again.
|
||||
// Put B in the same (DIALOG) strata but a higher frame level: level outranks link order, so
|
||||
// B takes it back — the tie rule only decides frames that are equal on both.
|
||||
|
||||
s.run("B:SetFrameStrata('DIALOG'); B:SetFrameLevel(10)")
|
||||
.unwrap();
|
||||
s.mouse_move(400.0, 300.0);
|
||||
@@ -193,6 +202,10 @@ fn mouse_wheel_passes_delta_to_the_captured_frame() {
|
||||
wheel = nil
|
||||
local a = CreateFrame("Frame", "A")
|
||||
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(800, 600); a:EnableMouse(true)
|
||||
-- The wheel is its own index and its own flag: SetScript never auto-enables (that law is
|
||||
-- XML-load-time only), so a runtime-created frame needs this explicitly, exactly as it
|
||||
-- needs EnableMouse. Mouse-enablement alone puts nothing in the wheel index.
|
||||
a:EnableMouseWheel(true)
|
||||
a:SetScript("OnMouseWheel", function(self, delta) wheel = delta end)
|
||||
"#,
|
||||
)
|
||||
@@ -694,6 +707,9 @@ fn the_repick_re_hovers_without_a_mouse_move() {
|
||||
local over = CreateFrame("Button", "Over")
|
||||
over:SetPoint("BOTTOMLEFT", 100, 100); over:SetSize(100, 100); over:EnableMouse(true)
|
||||
over:SetScript("OnEnter", function() table.insert(entered, "over") end)
|
||||
-- Say "on top" with the LEVEL, not with declaration order: at equal levels the sweep takes
|
||||
-- the earlier-linked frame (`Under`), which is the tie law, not what this test is about.
|
||||
over:SetFrameLevel(10)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -351,8 +351,14 @@ pub struct Frame {
|
||||
/// tab index). Default 0. Distinct from the layout/wrapper id ([`Model`](crate::script::Model)'s
|
||||
/// bijection) — this one is data, not identity.
|
||||
pub wow_id: i64,
|
||||
/// Monotonic creation sequence — the frame's insertion order for the draw-order key. See the
|
||||
/// note on [`WidgetArena`] about how this relates to the client's live per-bucket insertion.
|
||||
/// The frame's **live link position** in its `(strata, level)` bucket — the client's intrusive
|
||||
/// list order, not a creation index. Seeded at creation and re-stamped to the bucket's tail by
|
||||
/// [`WidgetArena::resequence_to_tail`] on every relink the reference performs: becoming
|
||||
/// effectively visible, and a *changing* strata or level on an already-visible frame.
|
||||
///
|
||||
/// It orders **two** sweeps that disagree about which end wins. Drawing takes the LATER-linked
|
||||
/// frame on top; the HIT sweep probes the EARLIER-linked frame first and stops there
|
||||
/// (decision 1816). See [`crate::order::hit_test`], and the note on [`WidgetArena`].
|
||||
pub insertion_seq: u32,
|
||||
/// Per-kind behavior state (StatusBar value model, …); [`KindState::None`] for plain kinds.
|
||||
pub kind_state: KindState,
|
||||
|
||||
@@ -259,7 +259,11 @@ pub(crate) fn update_ground_fx_decals(
|
||||
lighting: super::particles::buffer::EffectLighting::None,
|
||||
anchor: decal.center,
|
||||
bias: crate::sky_order::Rung::GROUND_FX,
|
||||
raster_bias: crate::sky_order::Rung::GROUND_FX as i32,
|
||||
// The projector's shared coplanarity margin — NOT `GROUND_FX as i32`, which is a
|
||||
// *sort* rung borrowed for a rasterizer field because the two numbers happened to
|
||||
// coincide. That is the conflation 1806 is about, and it left this lane at +8192,
|
||||
// which the projector's own bake residual exceeds by 1.30× (1817).
|
||||
raster_bias: crate::sky_order::Rung::DECAL_RASTER,
|
||||
raster_slope: 0.0,
|
||||
cam_relative: false,
|
||||
main_entity: entity,
|
||||
|
||||
@@ -523,7 +523,7 @@ fn prepare_effects(
|
||||
// straight off a live frame: `item 4` vs `item 81` is what B347 was, measured.
|
||||
//
|
||||
// The lane is keyed on the SORT rung because that is the one field unique per lane: the raster
|
||||
// bias is shared (the footprint lane rides `SHADOW_RASTER`, and prints were being labelled
|
||||
// bias is shared (every ground decal rides `DECAL_RASTER`, and prints were being labelled
|
||||
// shadows here for exactly that reason).
|
||||
let trace = std::env::var_os("WOW_EFFECT_TRACE").is_some();
|
||||
let mut trace_lines: Vec<String> = Vec::new();
|
||||
@@ -901,9 +901,14 @@ mod tests {
|
||||
/// street height is micro-scale), a *sloped* receiver takes the full horizontal ulps onto
|
||||
/// its tilted normal — and the confirmed flicker walk was the Stormwind gate ramp. Model
|
||||
/// the 3-ulp worst case (0781: "~1–3 ulps") at the walk's own coordinates across zoom and
|
||||
/// slope, and pin the resize: the residual spends over half the 0781-era 4096 margin (with
|
||||
/// the GPU's FMA-contraction noise on top, the tie is lost — the live A/B), while
|
||||
/// `Rung::SHADOW_RASTER` dominates the same worst case with ≥2× headroom.
|
||||
/// slope, and pin both ends of the sizing.
|
||||
///
|
||||
/// **Since 1817 it pins the raise as well as the floor.** The residual is 2.59× the 0781-era
|
||||
/// +4096 margin, so it is also **1.30× the +8192** the selection ring and the ground-target
|
||||
/// reticle rode until the RE showed they share the blob shadow's lane and therefore its
|
||||
/// conflict — those two were under their own residual and had simply not been walked on a
|
||||
/// slope. The measurement is per-lane-independent by construction: the residual is the
|
||||
/// *bake's*, and the bake is one path, so one number serves every ground decal.
|
||||
#[test]
|
||||
fn raised_bias_dominates_the_bake_residual() {
|
||||
use crate::sky_order::Rung;
|
||||
@@ -922,6 +927,7 @@ mod tests {
|
||||
};
|
||||
let cam_dir = Vec3::new(0.35, 0.55, 0.76).normalize();
|
||||
let mut worst_over_old = 0.0_f32;
|
||||
let mut worst_over_retired_ring = 0.0_f32;
|
||||
let mut worst_over_new = 0.0_f32;
|
||||
// Receiver grades from level street to a steep ramp, tilted along 8 azimuths.
|
||||
for slope in [0.0_f32, 0.08, 0.2, 0.35] {
|
||||
@@ -950,23 +956,31 @@ mod tests {
|
||||
let conflict = delta_n as f64 * grad;
|
||||
let unit = bias_unit(depth) as f64;
|
||||
worst_over_old = worst_over_old.max((conflict / (4096.0 * unit)) as f32);
|
||||
worst_over_retired_ring =
|
||||
worst_over_retired_ring.max((conflict / (8192.0 * unit)) as f32);
|
||||
worst_over_new =
|
||||
worst_over_new.max((conflict / (Rung::SHADOW_RASTER as f64 * unit)) as f32);
|
||||
worst_over_new.max((conflict / (Rung::DECAL_RASTER as f64 * unit)) as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"bake residual worst: {worst_over_old:.3}x the 0781-era 4096 margin, \
|
||||
{worst_over_new:.3}x Rung::SHADOW_RASTER"
|
||||
{worst_over_retired_ring:.3}x the 8192 the ring rode until 1817, \
|
||||
{worst_over_new:.3}x Rung::DECAL_RASTER"
|
||||
);
|
||||
assert!(
|
||||
worst_over_old > 0.5,
|
||||
"the 3-ulp residual stayed far inside the 0781-era 4096 margin \
|
||||
(worst {worst_over_old:.2}×) — the resize's premise would be unfounded"
|
||||
);
|
||||
assert!(
|
||||
worst_over_retired_ring > 1.0,
|
||||
"the 3-ulp residual fits inside the retired +8192 (worst {worst_over_retired_ring:.2}×) \
|
||||
— then the ring and reticle were NOT under-biased and 1817's raise wants re-arguing"
|
||||
);
|
||||
assert!(
|
||||
worst_over_new < 0.5,
|
||||
"Rung::SHADOW_RASTER leaves under 2× headroom against the 3-ulp residual \
|
||||
"Rung::DECAL_RASTER leaves under 2× headroom against the 3-ulp residual \
|
||||
(worst {worst_over_new:.2}×) — raise it"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,11 +180,39 @@ impl Rung {
|
||||
/// near-side default is after the water. Everything with a *known* slot moved to the
|
||||
/// pre-water band below (B347).
|
||||
pub const GROUND_FX: f32 = 8192.0;
|
||||
/// The **rasterizer** constant the decal family shares — [`GROUND_FX`](Rung::GROUND_FX)'s
|
||||
/// number in its second role, named so the lanes whose *sort* rung left the +8192 neighbourhood
|
||||
/// (the ring, the reticle) still reach the coplanarity margin rather than a copy of it. The
|
||||
/// blob shadow is the one lane that needs more: [`SHADOW_RASTER`](Rung::SHADOW_RASTER), B131.
|
||||
pub const DECAL_RASTER: i32 = 8192;
|
||||
/// **The one rasterizer settle every ground decal shares** — every client of the shared
|
||||
/// surface-decal projector (`decal.rs`): the selection ring, the unit blob shadow, spell
|
||||
/// ground-fx quads, the footprints and the ground-target reticle. It funds the `GreaterEqual`
|
||||
/// tie against the drawn ground (`DECAL_WORLD_CLIP`, 0781).
|
||||
///
|
||||
/// **It used to be two constants, and the split was wrong** (1817, superseding B131's
|
||||
/// `SHADOW_RASTER`): the ring and reticle rode +8192 on the claim that "the blob shadow is the
|
||||
/// one lane that needs more". The binary groups them the other way — the blob shadow reaches
|
||||
/// the SAME EGxRs id-`0` arming site as the ring, corpse marker, click marker and sky decal
|
||||
/// (`0x6d7920` → `0x6d8047 call 0x6d7330` → `0x6d6fa0` → `0x6d7480`), and it is the
|
||||
/// **footprints** that are a lane of their own (`0x69a54a` in `0x69a3e0`), at 10× (wow-re
|
||||
/// `gx/scratch/decal-depth-bias-lanes.md`, §5 five-way). And our own measurement agrees with
|
||||
/// the grouping: the residual is 0781's bake error — the decal's CPU-baked world verts vs the
|
||||
/// receiver's GPU-transformed verts diverge by ~1–3 ulps of the *world coordinate*, millimetres
|
||||
/// at city magnitudes — which is a property of the bake, identical on every lane that uses it.
|
||||
/// `ground_fx` was a third value again, and not even a raster one: it passed `GROUND_FX as
|
||||
/// i32`, a **sort** rung borrowed for a rasterizer field because the numbers coincided — 1806's
|
||||
/// conflation, in the one place it had survived.
|
||||
/// So one number, and it is the measured one: **the residual is 2.59× the +4096 margin and
|
||||
/// therefore 1.30× the +8192 one**, i.e. the ring and the reticle were riding *under* their own
|
||||
/// conflict and had simply not been walked on a slope yet. Pinned by
|
||||
/// `raised_bias_dominates_the_bake_residual` (particles/render.rs), which now asserts both ends.
|
||||
///
|
||||
/// **What is deliberately NOT ported: the reference's magnitudes, or its 1 : 10 : 20 ratio.**
|
||||
/// Both are properties of a 24-bit *fixed-point forward-Z* buffer at near 0.1 / far 777 /
|
||||
/// depth-range [0, 0.94], where a constant normalized-depth bias is worth `k · d²` yards —
|
||||
/// 0.0065 yd at 10 yd on the ring lane, 0.16 yd at 50. Ours is reversed-Z **float**, where the
|
||||
/// bias unit is the ULP at the primitive's own depth, so the same constant is worth `z · C ·
|
||||
/// 2⁻²³` — **linear**, not quadratic. Neither profile converts into the other, and the quantity
|
||||
/// that sets ours is a millimetre-scale bake residual that does not grow with distance at all.
|
||||
/// The reference's 10× footprint step has no counterpart here for the same reason: the residual
|
||||
/// is the bake's, and the bake is one path.
|
||||
pub const DECAL_RASTER: i32 = 32768;
|
||||
/// **The pre-water decal band, rung 1: the selection ring** (and, when they are built, the
|
||||
/// corpse marker and the click-to-move marker — the same `+0x30` slot and the same call site).
|
||||
///
|
||||
@@ -229,7 +257,7 @@ impl Rung {
|
||||
/// 1.4e4 wide, so the ladder's usual margin does not fit and pretending it does would be the
|
||||
/// lie. **The depth of the rung costs nothing at the rasterizer** — unlike [`WATER_BIAS`] and
|
||||
/// the two rungs above, the effect stream carries sort and raster as separate fields
|
||||
/// ([`SHADOW_RASTER`](Rung::SHADOW_RASTER) is this lane's other half).
|
||||
/// ([`DECAL_RASTER`](Rung::DECAL_RASTER) is this lane's other half).
|
||||
pub const SHADOW_SORT: f32 = -5.2e4;
|
||||
/// **The pre-water band, rung 3: footprints** — the reference draws them at
|
||||
/// `0x483654` (`0x670240` → `0x69a3e0`), one slot AFTER the shadow pass and still before the
|
||||
@@ -244,48 +272,50 @@ impl Rung {
|
||||
/// on a water surface at all. When it can, that half wants a rung above [`WATER_BIAS`] — not
|
||||
/// this one.
|
||||
pub const RETICLE: f32 = -4.8e4;
|
||||
/// The blob shadow's **rasterizer** margin, sized independently of its sort rung (B131 split
|
||||
/// one constant into its two roles). It funds the `GreaterEqual` tie against the drawn ground
|
||||
/// (`DECAL_WORLD_CLIP`, 0781). The reference's mechanism is the same class: `shadowBias` cvar
|
||||
/// 0.1 → a constant-only polygon offset (−102.4 LSBs of its 24-bit buffer; no slope term
|
||||
/// exists in the binary — wow-re unit-blob-shadow RE, corroborated across every shadow draw of
|
||||
/// four apitraces). The *size* is ours: our residual is 0781's — the decal's CPU-baked world
|
||||
/// verts vs the receiver's GPU-transformed verts diverge by ~1–3 ulps of the world coordinate,
|
||||
/// millimetres at city magnitudes, landing on the depth tie where the receiver is *sloped*
|
||||
/// (the confirmed B131 flicker walk was the Stormwind gate ramp). 4096 absorbed under half the
|
||||
/// 3-ulp worst case at close zoom and lost the tie while moving; 32768 dominates it ≥2× at
|
||||
/// every zoom ≥1 yd and stays centimetre-order at 30 yd. Sizing pinned by
|
||||
/// `raised_bias_dominates_the_bake_residual` (particles/render.rs).
|
||||
pub const SHADOW_RASTER: i32 = 32768;
|
||||
|
||||
/// **The wade-foam decal's rasterizer settle — the reference's own, both halves** (VERIFIED,
|
||||
/// wow-re `terrain/scratch/foam-decal-depth-and-drain-slot.md` §3; folded back as 1809).
|
||||
/// **The wade-foam decal's rasterizer settle.** `0x68fd0f` arms EGxRs id `0` for the foam draw
|
||||
/// (`0x68fae0`), which is the client's third and largest ground-decal bias lane.
|
||||
///
|
||||
/// `0x68fd0f` arms EGxRs id `0` for the foam draw, and arming that id non-zero is what issues
|
||||
/// the client's sole `glPolygonOffset` — `factor` a hardcoded `0xc0800000` = **−4.0**
|
||||
/// (`0x59bf0a`, in capability arm `0x41`, whose only image-wide call site is inside the id-`0`
|
||||
/// applicator), `units` = `footstepBias(0.125) × [0x810390]` ⇒ **−8.0001**. The `-4.0` is not
|
||||
/// this lane's number: it is the immediate every consumer of EGxRs id `0` gets, and the foam
|
||||
/// reaches it by borrowing the **footprint's own cvar** — which is idiom reuse ("this is a
|
||||
/// ground decal, arm the ground-decal offset"), not a tie this decal has to win. Only the
|
||||
/// `units` term is the foam's own.
|
||||
/// **The shipped value is `D3DRS_DEPTHBIAS = −1.2207217514514923e−04`** =
|
||||
/// `−f32(footstepBias(0.125) × [0x810390])` — **−2048.03 ULPs** of the reference's 24-bit
|
||||
/// buffer, 20× the ring/shadow lane and 2× the footprints' (VERIFIED, wow-re
|
||||
/// `gx/scratch/decal-depth-bias-lanes.md`, §5 five-way, commit `6335d165`).
|
||||
///
|
||||
/// **The signs invert here.** GL depth runs 0 = near, so its negative offset pulls toward the
|
||||
/// eye; ours is reversed-Z (`CompareFunction::GreaterEqual`, near = 1), where toward the eye is
|
||||
/// *larger* depth — so a ported term is positive. [`DECAL_RASTER`](Rung::DECAL_RASTER) being
|
||||
/// positive for the same job corroborates the convention independently.
|
||||
/// **1809's `−8.0001` and its `factor = −4.0` were both dead code.** `gxApi` defaults to
|
||||
/// `"direct3d"` (`0x63a81d`) and no shipped `WTF` overrides it, so `GxDevCreate` builds
|
||||
/// `CGxDeviceD3d` and the OpenGL arm — the only place in the image where the `−4.0` slope
|
||||
/// factor exists — never runs. The GL arm also hard-codes a `×65536` *16-bit* ULP conversion,
|
||||
/// which is why every number derived from it came out **256× too small**. Measured from a
|
||||
/// fully independent state: `factor = 0` on 2485 of 2485 client-originated `glPolygonOffset`
|
||||
/// calls across four captures, which WoW's GL arm cannot emit.
|
||||
///
|
||||
/// **The constant is in ULPs of a float depth buffer** (per-primitive — the quantisation 1806
|
||||
/// is about) rather than GL's fixed `2⁻²⁴`: same order, not the same unit. Its world-space pull
|
||||
/// is `z · C · 2⁻²³`, i.e. ~1 µm per yard of view distance at `C = 8` — invisible by
|
||||
/// construction, which is the point. It is a guard against a driver rounding one pipeline's
|
||||
/// coplanar arithmetic differently from another's, not a tie-breaker anything rests on.
|
||||
/// **The signs invert here.** Reference depth runs 0 = near, so its negative offset pulls
|
||||
/// toward the eye; ours is reversed-Z (`CompareFunction::GreaterEqual`, near = 1), where toward
|
||||
/// the eye is *larger* depth — so a ported term is positive.
|
||||
///
|
||||
/// **We spend almost none of it, on purpose.** Our constant is in ULPs of a **float** buffer
|
||||
/// (per-primitive — the quantisation 1806 is about), so its world pull is `z · C · 2⁻²³`:
|
||||
/// ~1 µm per yard of view distance at `C = 8`. The reference's is `1.29847e−3 · d²` — 0.13 yd
|
||||
/// at 10 yd, 0.52 at 20, 3.2 at 50 — and wow-re **measured, from framebuffer pixels on two
|
||||
/// Northshire wade frames, that it does paint the splash decal onto dry bank**, by ~0.3–1.0 yd
|
||||
/// at an 11.9-yd camera. That bleed is the thing the director reported twice (B348), so the
|
||||
/// fidelity is available and deliberately not taken; the reference's own bound on it is not
|
||||
/// the depth test but the texgen box plus the decal texture's alpha-0 border under CLAMP.
|
||||
/// What ours funds instead is a guard against a driver rounding one pipeline's coplanar
|
||||
/// arithmetic differently from another's — [`FOAM_RASTER_SLOPE`](Rung::FOAM_RASTER_SLOPE) has
|
||||
/// the reason nothing more is needed.
|
||||
pub const FOAM_RASTER: i32 = 8;
|
||||
/// **The slope half is deliberately NOT armed — the tie it settles does not exist here** (B348
|
||||
/// second round, 1811; supersedes 1809 §3's transcription).
|
||||
///
|
||||
/// The reference arms `factor = -4.0` and it is the right instrument *there*. It is the wrong
|
||||
/// one here because our foam patch is not a decal *over* the liquid surface — it **is** the
|
||||
/// **The reference has no slope term at all** — 1811 argued this from our own geometry and the
|
||||
/// bytes then confirmed it outright: `D3DRS_SLOPESCALEDEPTHBIAS` is written nowhere in the
|
||||
/// image (`0xaf` never pushed, census of all 24 `call [reg+0xe4]` sites), and the `-4.0` that
|
||||
/// 1809 ported lives only in the OpenGL arm the shipped client never constructs (see
|
||||
/// [`FOAM_RASTER`](Rung::FOAM_RASTER)). A re-implementation that ports it is transcribing dead
|
||||
/// code, and a grazing view of a ground plane is precisely where the two arms diverge most.
|
||||
///
|
||||
/// 1811's own reason stands underneath, and is the one that would hold even if the term were
|
||||
/// live: our foam patch is not a decal *over* the liquid surface — it **is** the
|
||||
/// liquid surface's own triangles: [`water_fx::build_patch`] emits the wet cells straight out
|
||||
/// of [`WaterChunkInfo`]'s grid, in the liquid mesh's own winding, through the same
|
||||
/// `clip_from_world` (`DECAL_WORLD_CLIP`, 0781) against a mesh whose `Transform` is
|
||||
@@ -338,14 +368,14 @@ const _: () = {
|
||||
assert!(Rung::GROUND_FX - CLOUDS_BIAS > 1.0e4);
|
||||
assert!(GLARE_BIAS - Rung::GROUND_FX > 1.0e4);
|
||||
assert!(Rung::NAMEPLATE - GLARE_BIAS > 1.0e4);
|
||||
// The two raster margins are their own axis (B131) — not comparable to the sort rungs above,
|
||||
// only to zero and to each other: the shadow's is the raised one.
|
||||
assert!(Rung::SHADOW_RASTER > Rung::DECAL_RASTER && Rung::DECAL_RASTER > 0);
|
||||
// The foam's settle is the reference's own and is deliberately the SMALL one: its work is done
|
||||
// by the slope term, which the other three decal lanes do not carry (their own arming is
|
||||
// unread — see FOAM_RASTER). Both halves pull toward the eye under reversed-Z, so both are
|
||||
// positive; a negative here would push the decal INTO its receiver.
|
||||
assert!(Rung::FOAM_RASTER > 0 && Rung::FOAM_RASTER < Rung::DECAL_RASTER);
|
||||
// The raster margins are their own axis (B131) — not comparable to the sort rungs above, only
|
||||
// to zero and to each other. One number for every ground decal (1817); the foam is not one of
|
||||
// them, and takes far less.
|
||||
assert!(Rung::DECAL_RASTER > 0 && Rung::FOAM_RASTER < Rung::DECAL_RASTER);
|
||||
// The foam's settle is deliberately the SMALL one: unlike a ground decal its patch is the
|
||||
// liquid mesh's own triangles, so it has no bake residual to dominate (1811). It pulls toward
|
||||
// the eye under reversed-Z, so it is positive; a negative here would push it INTO its receiver.
|
||||
assert!(Rung::FOAM_RASTER > 0);
|
||||
// The slope half stays disarmed on this lane (1811): its pull grows as z² and it spends the
|
||||
// wet-lattice skirt onto the beach, while the tie it would settle is already won by the patch
|
||||
// being the liquid mesh's own triangles. A nonzero value here is a decision, not a tune.
|
||||
|
||||
Reference in New Issue
Block a user