the screen warps underwater, and the ocean darkens with depth

- the screen warps underwater: submerged, the reference runs a second pass list whose third pass is `FFXGlowWave` (`0x6cb1f0`), binding a 128×128 two-channel sine bump map that displaces both the screen and the blur texcoords by 3.0 full-res pixels — uv rotated 10°, scaled W/128 × 0.88·H/128, two phases at 3174 and 2805 ms; wow-re had carried the swap as "GlowWave, or a duplicate Glow" and the cross-check settled it (the two arms are mutually exclusive and the plain-Glow arm is unreachable on both backends; lava and slime warp too, and a ghost's death pass replaces the glow outright); a dry frame binds the same pipeline object as before this existed, and the phases advance live at 0.313/s and 0.355/s against a predicted 0.315 and 0.357
- ocean is a fifth submersion state — 96.8% of every wet sub-tile in the shipped corpus, so essentially all open water — and it alone runs a depth ramp on the light: `t = clamp(eye.z, −30, 0)`, ambient at `(k+1)/2`, diffuse at `k/4 + 3/4`, exactly k = 0 at −30 yd, gated on the full dword `== 1` at `0x6d2821` (the ramp does NOT drive the fog colour — that store is dead, overwritten before submit); the sun and moon glare fade over a 10-yard depth ramp applied to the slew TARGET, not the drawn alpha — a read one indirection out that two notes had published a verified negative on; the MCLQ fishable bit (0x40) is deliberately unread, since `0x69b5d0` has zero callers in the image and fishability is the server's verdict
- the dropdown kit is the reference's own `UIDropDownMenu.xml` (one manifest line for 1150 lines of ours, `DropDownList3` gained, and two fixtures that only worked because our copy was more forgiving); `UnitFactionGroup` returns the English name AND the localized one — ours returned the localized string twice, and the first return names a TEXTURE in every stock consumer (`UI-PVP-`..factionGroup), so on a non-English client every one of them named a file that does not exist

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-02 17:05:57 +07:00
co-authored by Claude Fable 5.1
parent 9105fa72a4
commit 7308046d60
49 changed files with 1208 additions and 1368 deletions
+4 -2
View File
@@ -157,8 +157,10 @@ pub struct MclqChunk {
pub min_height: f32,
pub max_height: f32,
pub vertices: Vec<LiquidVertex>,
/// Per-cell flags, row-major 8×8. Low nibble = liquid type (`0xf` = dry/hole); `0x40` fishable,
/// `0x80` shared.
/// Per-cell flags, row-major 8×8. Low nibble = liquid type (`0xf` = dry/hole); `0x80` shared.
/// `0x40` ("fishable") is carried but deliberately unread: its only compiled reader, the
/// reference's fishable query `0x69b5d0`, is a zero-caller dead island — fishability is the
/// server's verdict (`SPELL_FAILED_NOT_FISHABLE` passthru). Decision 1825.
pub tile_flags: [u8; 64],
}
File diff suppressed because it is too large Load Diff
+29
View File
@@ -399,6 +399,35 @@
-- surface (a focused test VM, and the reference's own `ADDON_LOADED` ordering), and this
-- runs on the first line of every `ToggleBag`/`ToggleBackpack`/`ToggleKeyRing`, so it is
-- the last place that should be the thing to raise.
-- ── Moved here when UIDropDownMenu.xml went to the chain (1751) ──────────────────────
-- The reference keeps `CloseMenus` in `UIParent.lua:984`, not in its dropdown file, and
-- this file is already where our slice of UIParent.lua lives (`IsOptionFrameOpen` below,
-- `CloseAllWindows`, `ToggleGameMenu`). It sits at manifest line 144, ahead of all five
-- consumers — the stock dropdown at 155, MicroMenu, GameMenuFrame and two test kits.
--
-- The BOUND is inlined rather than read from `UIDROPDOWNMENU_MAXLEVELS`, which lives in the
-- dropdown file: a kit that loads this file and not that one would otherwise fail on
-- `'for' limit must be a number`. Two is the reference's own list count (DropDownList1/2 are
-- what `UIDropDownMenu.xml` declares; the third exists only in later builds).
--
-- The BODY stays ours and is deliberately not the reference's: stock iterates a `UIMenus`
-- table nothing here declares, ours asks the dropdown lists directly. Same answer, and it
-- retires with this file rather than pretending to be stock.
function CloseMenus()
local closed
for i=1, 2, 1 do
local list = getglobal("DropDownList"..i)
if list and list:IsVisible() then
closed = 1
end
end
if closed then
CloseDropDownMenus()
end
return closed
end
function IsOptionFrameOpen()
if (GameMenuFrame and GameMenuFrame:IsVisible())
or (OptionsFrame and OptionsFrame:IsVisible())
+11 -4
View File
@@ -149,10 +149,17 @@ UiPanels.xml
# requirement: ItemRef.xml below expands one, and so do the 27 corpus addons that name
# GameTooltipTemplate in an XML inherits=.
GameTooltip.xml
# The dropdown kit (0203 phase 2's closing sub-slice, widened for 0434 phase 5):
# UIDropDownMenuTemplate + DropDownList1/2 + the UIDropDownMenu_* API — before its
# first customers (the unit popups below, the world map's pickers, the trainer filter).
UIDropDownMenu.xml
# ══ THE DROPDOWN KIT — the reference's own, off the player's chain (1751) ══
# UIDropDownMenuTemplate + the DropDownList frames + the whole `UIDropDownMenu_*` API — before its
# first customers (the unit popups below, the world map's pickers, the trainer filter). The stock
# file pulls its own `UIDropDownMenu.lua` and `<Include>`s UIDropDownMenuTemplates.xml, so this one
# line carries all three. We also gain `DropDownList3`, which our transcription stopped short of.
#
# Ours (0203 phase 2, widened for 0434 phase 5) defined every stock global plus six helpers of its
# own. Five were named nowhere outside the file. The sixth, `CloseMenus`, moved to UiPanels.xml —
# the reference keeps it in `UIParent.lua`, not its dropdown file, and UiPanels is both where our
# slice of UIParent.lua lives and ahead of all five of its consumers.
Interface\FrameXML\UIDropDownMenu.xml
# ══ THE UIMenu KIT — the reference's own, off the player's chain (1751, window 21) ══
# `UIMenuTemplate` and `UIMenuButtonTemplate`, with `UIMenu.lua`'s `UIMenu_OnShow`/`_OnUpdate` and
# the four `UIMenuButton_*` handlers arriving through the file's own `<Script file=>`.
+1 -1
View File
@@ -440,7 +440,7 @@ fn chat_vm() -> benilla_ui::script::UiScript {
for file in [
"Fonts.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
// The UIMenu kit is the reference's own file since 1751 window 21, so this reads both
// stores through the one loader that speaks them.
"Interface\\FrameXML\\UIMenu.xml",
@@ -44,7 +44,7 @@ fn chat_with_menu() -> UiScript {
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
"UIPanelTemplates.xml",
"Interface\\FrameXML\\ColorPickerFrame.xml",
@@ -22,7 +22,7 @@ fn chat_ui() -> UiScript {
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
"UIPanelTemplates.xml",
"Interface\\FrameXML\\ColorPickerFrame.xml",
@@ -20,7 +20,7 @@ fn chat_frame() -> UiScript {
// says so too, rather than a guard that would hide a real ordering fault. (The tooltip file is
// the dropdown kit's own dependency — its MenuBackdrop reads `TOOLTIP_DEFAULT_COLOR`.)
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit the chat menus build from
load_xml(&s, "ChatFrame.xml");
s.set_screen_size(1600.0, 900.0);
@@ -22,7 +22,7 @@ fn picker() -> UiScript {
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
"UIPanelTemplates.xml",
"Interface\\FrameXML\\ColorPickerFrame.xml",
@@ -427,6 +427,12 @@ fn a_dropdown_row_with_has_color_swatch_opens_the_picker_and_cancel_restores() {
restored = {}
picked = {}
local dd = CreateFrame("Frame", "TestColorDropDown", nil, "UIDropDownMenuTemplate")
-- The anchor needs a POSITION. `ToggleDropDownMenu` anchors the list to this frame and then
-- guards on `listFrame:GetCenter()`, hiding the list again and returning when it is nil
-- (ref UIDropDownMenu.lua:624-631) — so an unplaced dropdown opens no menu in the real
-- client either. Our deleted transcription carried no such guard and showed it regardless,
-- which is the only reason this fixture ever worked unanchored.
dd:SetPoint("CENTER", 0, 0)
UIDropDownMenu_Initialize(dd, function()
local info = {}
info.text = "Border Color"
@@ -21,7 +21,7 @@ fn load_combo_frame() -> UiScript {
load_xml(&s, "Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
@@ -33,7 +33,7 @@ const JERKY_LINK: &str = "|cffffffff|Hitem:117|h[Tough Jerky]|h|r";
/// The room's own files, past whatever bag interface the caller wants — both loaders below end
/// with these, in manifest order.
const ROOM_UI: &[&str] = &[
"UIDropDownMenu.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"UnitPopup.xml",
"Interface\\FrameXML\\ItemRef.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
@@ -23,7 +23,7 @@ fn load_dropdown_kit(s: &UiScript) {
"Fonts.xml",
"UIParent.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
] {
load_xml(s, file);
}
@@ -182,7 +182,7 @@ fn escape_closes_the_options_window_before_opening_the_menu() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml"); // the Keybindings page's faux-scroll kit (1008)
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
@@ -311,7 +311,7 @@ fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
}
// GameTooltip.xml (BAG_UI's, for the bag slots' tooltips) also carries TOOLTIP_DEFAULT_COLOR,
// which the dropdown backdrop's OnLoad reads — so the kit can load straight after it.
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
s.set_container(0, Some(one_item_backpack()));
@@ -329,7 +329,14 @@ fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
// Press 0 — mid-cast with a dropdown open (the ref's CloseMenus rung, l.1488): the menu
// closes and eats the press — the cast, the windows, and the target all survive.
s.set_casting(true);
s.run("DropDownList1:Show()").unwrap();
// `maxWidth` before the Show: stock `DropDownList`'s own `<OnShow>` sizes every button from it
// (ref UIDropDownMenuTemplates.xml:237-245), and `UIDropDownMenu_Initialize` is what normally
// sets it — so showing the list bare raises `SetWidth(nil)` in the real client too. Our deleted
// transcription had no such OnShow, which is why a bare Show worked here before. The subject of
// this test is the ESC ladder, not the menu's construction, so it sets the one field the show
// path reads rather than standing up a whole anchored dropdown.
s.run("DropDownList1.maxWidth = 100 DropDownList1:Show()")
.unwrap();
s.run("ToggleGameMenu()").unwrap();
assert!(
!s.eval::<bool>("return DropDownList1:IsShown()").unwrap(),
@@ -19,7 +19,7 @@ fn setup() -> UiScript {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
// The row right-click menu is the shared UnitPopup "FRIEND" menu, so the window's slice of
// the manifest includes it (it loads well before FriendsFrame.xml in the real order).
load_xml(&s, "UnitPopup.xml");
@@ -266,7 +266,7 @@ fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
"Interface\\FrameXML\\MerchantFrame.xml",
// The loot window is the reference's own since 1751 — `test_ui::LOOT_UI` carries
// what it needs and why, and PartyFrame's `MAX_PARTY_MEMBERS` is needed at LOAD.
"UIDropDownMenu.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"UnitPopup.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -547,7 +547,7 @@ fn escape_during_a_countdown_cancels_it_and_does_not_open_the_menu() {
fn the_world_map_cannot_open_behind_the_menu_and_gives_its_slot_back() {
let s = harness_with(&[
"GameTooltip.xml",
"UIDropDownMenu.xml", // the map's continent/zone pickers initialize into it at OnLoad
"Interface\\FrameXML\\UIDropDownMenu.xml", // the map's continent/zone pickers initialize into it at OnLoad
"ScrollTemplates.xml",
"WorldMapFrame.xml",
]);
@@ -595,7 +595,7 @@ fn the_world_map_cannot_open_behind_the_menu_and_gives_its_slot_back() {
fn nothing_opens_behind_the_world_map_and_escape_closes_it_first() {
let s = harness_with(&[
"GameTooltip.xml",
"UIDropDownMenu.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
"WorldMapFrame.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
@@ -772,7 +772,7 @@ fn backpack() -> ContainerState {
fn the_menu_rides_the_shared_era_window_scale() {
let mut s = harness_with(&[
"GameTooltip.xml",
"UIDropDownMenu.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
@@ -208,7 +208,7 @@ fn setup() -> UiScript {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
@@ -87,7 +87,7 @@ fn the_self_menu_row_gates_on_the_binding_and_confirms_before_sending() {
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"UnitPopup.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -47,32 +47,25 @@ pub(crate) fn harness() -> UiScript {
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
"UIParent.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"GameMenuFrame.xml",
] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
// `test_ui::load_ui`, not a local read: a manifest entry carrying a path separator is the
// REFERENCE's own file and must come off the player's chain, which
// `std::fs::read_to_string` under `assets/ui` cannot do — it goes looking for
// `assets/ui/Interface/FrameXML/...` and fails. The shared loader resolves both shapes, and
// its own doc already records this consolidation happening once before. Hand-rolling it
// here is what made this kit break the moment a file it loads migrated (1751).
// `load_ui_strict` for the files whose art actually matters here: an unknown template is
// a loader WARNING, not an error, so a silently skinless window would otherwise pass.
if file == "KeyBindingsPage.xml" || file == "OptionsFrame.xml" {
assert!(
report.warnings.is_empty(),
"{file}: loader warnings (dropped subtrees?): {:?}",
report.warnings
);
super::test_ui::load_ui_strict(&s, file);
} else {
super::test_ui::load_ui(&s, file);
}
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -574,7 +574,7 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
// with it (GameTooltip.xml reads the real name now), so the dependency is gone. At runtime
// every FrameXML file loads before any hover fires, so load it here too — with the dropdown
// kit its GroupLootDropDown initializes against at load (benilla.toc l.64 vs 383).
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_merchant(Some(MerchantState {
@@ -44,33 +44,24 @@ fn harness_on(mut s: UiScript) -> UiScript {
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml", // the Keybindings page's faux-scroll kit
"KeyBindingsPage.xml", // the Keybindings body's templates + script (1008)
"OptionsFrame.xml",
"GameMenuFrame.xml",
] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
// `test_ui::load_ui`, not a local read: a manifest entry carrying a path separator is the
// REFERENCE's own file and must come off the player's chain, which
// `std::fs::read_to_string` under `assets/ui` cannot do — it goes looking for
// `assets/ui/Interface/FrameXML/...` and fails. The shared loader resolves both shapes, and
// its own doc already records this consolidation happening once before. Hand-rolling it
// here is what made this kit break the moment a file it loads migrated (1751).
// The dialect announces DROPPED subtrees as warnings, not errors — for the new file,
// a warning is a silently-missing piece of chrome, so it fails here.
// a warning is a silently-missing piece of chrome, so it fails there.
if file == "OptionsFrame.xml" {
assert!(
report.warnings.is_empty(),
"{file}: loader warnings (dropped subtrees?): {:?}",
report.warnings
);
super::test_ui::load_ui_strict(&s, file);
} else {
super::test_ui::load_ui(&s, file);
}
}
// The app's own post-load pass, at the app's own moment: everything is loaded, so
@@ -726,7 +717,7 @@ fn interface_harness() -> UiScript {
// these again after these — re-running a UI file is what `/reload` does, and the
// loader takes it.
"GameTooltip.xml",
"UIDropDownMenu.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"UnitPopup.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -772,7 +763,7 @@ fn chat_harness() -> UiScript {
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"ChatFrame.xml",
],
@@ -29,7 +29,7 @@ fn load_pet_frame() -> UiScript {
load_xml(&s, "Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
@@ -106,7 +106,7 @@ fn setup() -> UiScript {
// decide where focus goes after the name box closes — a nil there raises before the charter is
// bought. Ours guarded it; the reference does not.
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_COLOR, read by the dropdown backdrops
load_xml(&s, "UIDropDownMenu.xml"); // ChatFrame's seven dropdowns inherit its template
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); // ChatFrame's seven dropdowns inherit its template
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit the chat menus build from
load_xml(&s, "ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GuildRegistrarFrame.xml");
@@ -980,7 +980,7 @@ fn reward_row_hover_serves_the_shared_item_tooltip() {
// that window set to `ITEM_QUALITY_COLORS`. 1751 retired the window and the alias with it, so
// Fonts.xml alone is the source now. Formerly: it
// needs the dropdown kit its GroupLootDropDown initializes against (benilla.toc l.64 vs 383).
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
@@ -29,7 +29,7 @@ fn setup() -> UiScript {
load_xml(&s, "UiPanels.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
@@ -21,7 +21,7 @@ fn harness() -> UiScript {
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_* (the dropdown kit's MenuBackdrop)
load_xml(&s, "UIDropDownMenu.xml"); // the unit popups' kit (TargetFrameDropDown's template)
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); // the unit popups' kit (TargetFrameDropDown's template)
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "ActionBar.xml"); // BENILLA_FALLBACK_ICON
+2 -2
View File
@@ -139,7 +139,7 @@ pub(super) const LOOT_UI: &[&str] = &[
"MoneyFrame.xml",
"UiPanels.xml", // StaticPopup, and the LOOT_BIND / CONFIRM_LOOT_DISTRIBUTION dialogs
"GameTooltip.xml", // TOOLTIP_DEFAULT_COLOR, read by the dropdown backdrop
"UIDropDownMenu.xml", // GroupLootDropDown's OnLoad calls UIDropDownMenu_Initialize
"Interface\\FrameXML\\UIDropDownMenu.xml", // GroupLootDropDown's OnLoad calls UIDropDownMenu_Initialize
"UnitPopup.xml",
// …and what its rows' OnLoad calls: every `PartyMemberFrame<N>` and its pet frame runs
// `UnitFrame_Initialize`, which lives in UnitFrame.lua and itself calls
@@ -209,7 +209,7 @@ pub(super) const CHARACTER_UI: &[&str] = &[
// The unit frames' four right-click dropdowns call `UIDropDownMenu_Initialize` at LOAD, so the
// kit and the menu table it initialises from both precede them — the manifest's own order
// (175 → 189 → 193 → 263).
"UIDropDownMenu.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\UIMenu.xml",
"UnitPopup.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
@@ -97,7 +97,7 @@ fn unit_frame_hover_takes_the_default_corner_and_drops_on_leave() {
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"UIDropDownMenu.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"UnitPopup.xml",
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\UnitFrame.xml",
@@ -163,7 +163,7 @@ fn your_own_portrait_explains_the_menu_instead_of_showing_your_health() {
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"UIDropDownMenu.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"UnitPopup.xml",
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\UnitFrame.xml",
@@ -63,7 +63,7 @@ fn load_tot() -> UiScript {
load_xml(&s, "Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "ActionBar.xml");
@@ -28,7 +28,7 @@ fn trainer_script() -> UiScript {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_* (the kit's MenuBackdrop), app order
load_xml(&s, "UIDropDownMenu.xml"); // the filter dropdown's kit
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); // the filter dropdown's kit
load_xml(&s, "ScrollTemplates.xml"); // the faux-scroll bar kit
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // BenillaMoney_Set/_Clear/_SetColor live here
load_xml(&s, "TrainerFrame.xml");
@@ -26,7 +26,7 @@ fn load_unit_frames(s: &UiScript) {
load_xml(s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(s, "GameTooltip.xml");
load_xml(s, "UIDropDownMenu.xml");
load_xml(s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(s, "UnitPopup.xml");
load_xml(s, "Interface\\FrameXML\\BuffFrame.xml");
load_xml(s, "Interface\\FrameXML\\UnitFrame.xml");
@@ -1200,7 +1200,7 @@ fn the_party_art_paints_over_the_bars() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
// The reference's own kit, in the manifest's order. `UIParent.xml` is not decoration here:
// `RaiseFrameLevel`/`LowerFrameLevel` live in it (ref UIParent.lua l.1890-1896) and stock
@@ -23,7 +23,7 @@ fn load_popup_frames(s: &UiScript) {
// reaches at LOAD (its tab row and its confirm dialogs).
"UiPanels.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"UnitPopup.xml",
"Interface\\FrameXML\\ItemRef.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
@@ -467,7 +467,7 @@ fn load_pet_menu_frames(s: &UiScript) {
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"UnitPopup.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -18,7 +18,7 @@ fn harness() -> UiScript {
"UiPanels.xml",
"UIPanelTemplates.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml", // the map's continent/zone pickers initialize into it at OnLoad
"Interface\\FrameXML\\UIDropDownMenu.xml", // the map's continent/zone pickers initialize into it at OnLoad
"ScrollTemplates.xml",
// The blip templates, which WorldMapFrame.xml instantiates with inherits=. Not
// optional: an unknown template is a loader WARNING, not an error, so leaving this
@@ -26,19 +26,13 @@ fn harness() -> UiScript {
"WorldMapFrameTemplates.xml",
"WorldMapFrame.xml",
] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
// `test_ui::load_ui`, not a local read: a manifest entry carrying a path separator is the
// REFERENCE's own file and must come off the player's chain, which
// `std::fs::read_to_string` under `assets/ui` cannot do — it goes looking for
// `assets/ui/Interface/FrameXML/...` and fails. The shared loader resolves both shapes, and
// its own doc already records this consolidation happening once before. Hand-rolling it
// here is what made this kit break the moment a file it loads migrated (1751).
super::test_ui::load_ui(&s, file);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s
+19
View File
@@ -1003,6 +1003,21 @@ fn rest_state_message(prev: u8, new: u8) -> Option<&'static str> {
/// is therefore the only reading the shipped art admits — a lowest-bit walk would answer "Player"
/// for every player in the game.
pub(crate) fn faction_group(store: &ObjectStore, factions: Option<&Factions>) -> Option<String> {
let catalog = factions?.catalog();
let template = catalog.template(store.0.unit_faction_template()?)?;
// The ENGLISH name (`InternalName`), because `UnitFactionGroup`'s first return is concatenated
// into a texture path by every stock consumer. The localized twin is below.
catalog
.faction_group_internal_name(template.group_mask & 6)
.map(str::to_string)
}
/// The localized group name for the same unit — `UnitFactionGroup`'s SECOND return, which stock
/// shows as text rather than building a path from.
pub(crate) fn faction_group_localized(
store: &ObjectStore,
factions: Option<&Factions>,
) -> Option<String> {
let catalog = factions?.catalog();
let template = catalog.template(store.0.unit_faction_template()?)?;
catalog
@@ -1239,6 +1254,7 @@ fn feed_units(
s.guid = guid.0;
s.raid_target = group.raid_target_index(guid.0);
s.faction_group = faction_group(store, factions.as_deref());
s.faction_group_localized = faction_group_localized(store, factions.as_deref());
// `GetGuildInfo("player")` — the unit's own PUBLIC descriptor fields (191/192) joined
// against the app's guild-identity cache, which the miss also asks for (decision 1257).
// Filled here rather than in `snapshot` for the reason `faction_group` and `can_attack`
@@ -1292,6 +1308,7 @@ fn feed_units(
s.is_connected = true; // see the player leg
s.raid_target = group.raid_target_index(guid);
s.faction_group = faction_group(store, factions.as_deref());
s.faction_group_localized = faction_group_localized(store, factions.as_deref());
// The byte-confirmed CanAttack 0x606980 (decision 0172) — the same predicate TAB and the
// combat flash run; `UnitCanAttack("player","target")` gates the target frame's
// difficulty-colored level (ref TargetFrame_CheckLevel).
@@ -1345,6 +1362,7 @@ fn feed_units(
s.is_connected = true; // see the player leg
s.raid_target = group.raid_target_index(guid);
s.faction_group = faction_group(store, factions.as_deref());
s.faction_group_localized = faction_group_localized(store, factions.as_deref());
s.can_attack = crate::target::can_attack(
Some(store),
factions.as_deref(),
@@ -1432,6 +1450,7 @@ fn feed_units(
s.is_connected = true;
s.raid_target = group.raid_target_index(guid);
s.faction_group = faction_group(store, factions.as_deref());
s.faction_group_localized = faction_group_localized(store, factions.as_deref());
s.can_attack = crate::target::can_attack(
Some(store),
factions.as_deref(),
+6 -20
View File
@@ -11,7 +11,7 @@ use benilla_ui::script::{
UnitState, BIDDER, LIST, OWNER,
};
const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
mod common;
/// The auction window's load prefix — the app's own order (`assets/ui/benilla.toc`), members only.
/// Every one of these is a real dependency: UiPanels for the panel manager + tab kit + StaticPopup
@@ -25,32 +25,18 @@ const FILES: [&str; 8] = [
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
"UIPanelTemplates.xml",
"AuctionFrame.xml",
];
fn load_ui(script: &UiScript) {
let dir = std::path::Path::new(UI_DIR);
let provider = |req: &str| -> Option<Vec<u8>> {
let norm = req.replace('\\', "/");
let base = norm.rsplit('/').next().unwrap_or(&norm);
std::fs::read(dir.join(&norm))
.or_else(|_| std::fs::read(dir.join(base)))
.ok()
};
// `common::load_ui`, not a local read: a manifest entry carrying a path separator is the
// REFERENCE's own file and has to come off the player's chain, which `std::fs::read` under
// `assets/ui` cannot do. This kit gained such an entry when the dropdown migrated (1751).
for file in FILES {
let text = std::fs::read_to_string(dir.join(file))
.unwrap_or_else(|e| panic!("reading {file}: {e}"));
let doc =
benilla_ui::framexml::parse(&text).unwrap_or_else(|e| panic!("parsing {file}: {e}"));
let report = benilla_ui::loader::load(script, &doc, &provider);
assert!(
report.errors.is_empty(),
"{file} loaded with errors: {:#?}",
report.errors
);
common::load_ui(script, file);
}
}
+6 -22
View File
@@ -15,7 +15,7 @@ use benilla_ui::script::{
TradeSkillRecipe, TradeSkillState, UiScript,
};
const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
mod common;
/// The tradeskill window's load prefix — the app's own order (`ui_script/mod.rs`), members only.
/// CraftFrame.xml rides along (it loads right after TradeSkillFrame.xml in the app and shares
@@ -25,34 +25,18 @@ const FILES: [&str; 8] = [
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
"TradeSkillFrame.xml",
"CraftFrame.xml",
];
fn load_ui(script: &UiScript) {
let dir = std::path::Path::new(UI_DIR);
let provider = |req: &str| -> Option<Vec<u8>> {
let norm = req.replace('\\', "/");
let base = norm.rsplit('/').next().unwrap_or(&norm);
std::fs::read(dir.join(&norm))
.or_else(|_| std::fs::read(dir.join(base)))
.ok()
};
// `common::load_ui`, not a local read: a manifest entry carrying a path separator is the
// REFERENCE's own file and has to come off the player's chain, which `std::fs::read` under
// `assets/ui` cannot do. This kit gained such an entry when the dropdown migrated (1751).
for file in FILES {
let text = std::fs::read_to_string(dir.join(file)).unwrap_or_else(|e| {
panic!("reading {file}: {e}");
});
let doc = benilla_ui::framexml::parse(&text).unwrap_or_else(|e| {
panic!("parsing {file}: {e}");
});
let report = benilla_ui::loader::load(script, &doc, &provider);
assert!(
report.errors.is_empty(),
"{file} loaded with errors: {:#?}",
report.errors
);
common::load_ui(script, file);
}
}
+27
View File
@@ -304,6 +304,15 @@ pub struct FactionCatalog {
/// `GetZonePVPInfo`'s territory line formats (`0x48d540` reads FactionGroup.dbc Name of the
/// row whose bit ∈ the zone's FactionGroupMask; wow-re ui `zonetext-pvpinfo.md`).
group_names: HashMap<u32, String>,
/// The same key → FactionGroup.dbc's **`InternalName`** (field 2), which is English on every
/// locale where `Name0` is not. Kept beside the localized map rather than derived from it
/// because `UnitFactionGroup` returns BOTH and they are not interchangeable: its first return
/// names a TEXTURE — stock builds `"Interface\TargetingFrame\UI-PVP-"..factionGroup`
/// (`PlayerFrame.lua:68`, `TargetFrame.lua:198`, `PartyMemberFrame.lua:125`) and
/// `"…\Battleground-"..UnitFactionGroup("player")` (`BattlefieldFrame.lua:195`) — so a
/// localized string there is a path that does not exist. `HonorFrame.lua:68` compares it to
/// the literal `"Alliance"` for the same reason.
group_internal_names: HashMap<u32, String>,
/// Faction id → the localized Name — what the item tooltip's "Requires <Faction> -
/// <Standing>" line prints.
names: HashMap<u32, String>,
@@ -348,6 +357,17 @@ impl FactionCatalog {
/// The localized FactionGroup name of the (first) group bit set in `mask` — "Alliance" for 2,
/// "Horde" for 4; `None` for an unowned mask. The `GetZonePVPInfo` territory-line lookup.
/// The **English** group name for a mask — FactionGroup.dbc's `InternalName`. This is the one
/// `UnitFactionGroup`'s FIRST return must carry, because callers concatenate it into a texture
/// path; [`Self::faction_group_name`] is the localized twin for the second return.
pub fn faction_group_internal_name(&self, mask: u32) -> Option<&str> {
(0..32)
.map(|b| 1u32 << b)
.filter(|bit| mask & bit != 0)
.find_map(|bit| self.group_internal_names.get(&bit))
.map(String::as_str)
}
pub fn faction_group_name(&self, mask: u32) -> Option<&str> {
(0..32)
.map(|b| 1u32 << b)
@@ -499,10 +519,16 @@ pub fn load_faction_catalog(chain: &mut Chain) -> Result<FactionCatalog> {
.with_context(|| format!("reading {FACTION_GROUP}"))?;
let rs = parse(&bytes, faction_group_schema(), "FactionGroup")?;
let mut group_names = HashMap::with_capacity(rs.records().len());
let mut group_internal_names = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let (Some(mask_id), Some(name)) = (u32_at(r, 1), crate::dbc::str_at(&rs, r, 3)) else {
continue;
};
// Field 2 is `InternalName`, field 3 is `Name0` — see the schema above. Both are read
// because `UnitFactionGroup` returns the pair and only the localized half was kept before.
if let Some(internal) = crate::dbc::str_at(&rs, r, 2) {
group_internal_names.insert(1u32 << mask_id, internal);
}
group_names.insert(1u32 << mask_id, name);
}
@@ -510,6 +536,7 @@ pub fn load_faction_catalog(chain: &mut Chain) -> Result<FactionCatalog> {
templates,
factions,
group_names,
group_internal_names,
names,
descriptions,
})
+164 -7
View File
@@ -78,16 +78,43 @@ const SLOT_DEATH: usize = 4;
const PARAM_SLIME: u32 = 6;
const PARAM_MAGMA: u32 = 7;
/// The ocean depth ramp's floor — `0x81162c` = −30.0f. Below this the factors hold flat.
const OCEAN_RAMP_FLOOR: f32 = -30.0;
/// The ramp's reciprocal, **as the binary stores it** — the f32 at `0x811628`, bit pattern
/// `0xbd088889`.
///
/// Written from the bits rather than as a decimal on purpose. The decimal wow-re quotes
/// (`−0.0333333351`) carries more digits than an f32 holds, so spelling it out is both a clippy
/// `excessive_precision` error and a small lie about what is in the file; and rounding it to
/// `−0.033_333_335` would read as a value someone chose. `from_bits` says the true thing: this
/// constant is a bit pattern lifted out of the image. (It happens to equal `-1.0f32 / 30.0` — see
/// [`Submersion::ocean_depth_factors`] for why that is not the point.)
const OCEAN_RAMP_RECIP: f32 = f32::from_bits(0xbd08_8889);
/// What the camera eye is submerged in, if anything — the atmosphere selector. Water and ocean pick
/// the zone's *underwater slot*; magma and slime replace the whole area blend with a fixed global row
/// (see [`PARAM_MAGMA`]/[`PARAM_SLIME`]).
///
/// **Five states, because the reference has five.** `[0xc7f288] ∈ {0xf dry, 0 water, 1 ocean,
/// 2 magma, 3 slime}` — and ocean is not a synonym for water. It is the only kind that runs the
/// depth ramp ([`Submersion::ocean_depth_factors`]), which is gated on the **full unmasked dword
/// `== 1`** at `0x6d2821` (not the nibble — `0x11` would pass a nibble test and must not). Every
/// other consumer image-wide treats the two alike, so this enum forks in exactly one place, which
/// is the shape of the finding.
///
/// Collapsing them, as we did until decision 1829, was not a small approximation: ocean is
/// **96.8% of every wet sub-tile** in the shipped corpus (14 069 166 of 14 528 712). The state we
/// were not modelling was essentially all open water.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum Submersion {
/// Dry — the ordinary clear/storm slot.
#[default]
Dry,
/// Under water, ocean or rapids: the zone's own underwater `LightParams`, area-blended as usual.
/// Under still water or rapids: the zone's own underwater `LightParams`, area-blended as usual.
Water,
/// Under **ocean**: the same underwater slot as [`Self::Water`], plus the depth ramp. Comes
/// only from the ADT MCLQ per-subtile flags (`(b & 0x0f) & 3 == 1`); a WMO cannot author it.
Ocean,
/// Under magma: the fixed global row, verbatim.
Magma,
/// Under slime: the fixed global row, verbatim.
@@ -107,25 +134,63 @@ impl Submersion {
/// Deliberately NOT [`Self::any`]: whether the reference swaps the submerged ambience bed and the
/// reverb preset for **lava and slime** as well is unverified, and widening it silently on the way
/// past would be inventing a behaviour. The atmosphere is verified per kind; the audio is not.
///
/// [`Submersion::Ocean`] belongs here with [`Submersion::Water`]: the split exists for the depth
/// ramp alone, and every other consumer — the underwater slot, the ambience bed, the reverb
/// column, the drift cloud's cell set — treats a sea exactly like a lake.
pub fn is_water(self) -> bool {
self == Submersion::Water
matches!(self, Submersion::Water | Submersion::Ocean)
}
/// The **ocean depth ramp** — the one thing ocean does that water does not (wow-re
/// `lighting/scratch/submerged-atmosphere.md` §3, VERIFIED; decision 1829).
///
/// Returns `(fac1, fac2)`, the multipliers on the committed light record's first and second
/// colour triples — `DNState+0x178 × fac1` and `DNState+0x174 × fac2`. `fac1` runs `1.0 → 0.5`
/// and `fac2` runs `1.0 → 0.75` over the first 30 yards of depth, so the fill darkens twice as
/// fast as the direct term and a deep sea goes flat as well as dim.
///
/// **A multiply by the stored constant, not a divide by 30.** wow-re's note says
/// "`−0.0333333351f` is not exactly −1/30 — a bit-exact reimplementation must use the stored
/// f32, not `t/30.0`". The conclusion is right and the reason as stated is not, which matters
/// because it changes what you have to be careful about: `0xbd088889` **is** the nearest f32 to
/// −1/30, bit for bit. What differs is the *operation* — `t · recip` and `t / 30.0` round
/// differently, and they disagree inside the ramp's own range (at `t = −29` by 6e−8 in `k`).
/// So the thing to transcribe is the multiply, and the constant is incidental.
///
/// The boundaries are exact, not near-misses: `k` reaches exactly `0.0` at −30 yd, so `fac1`
/// bottoms at exactly 0.5 and `fac2` at exactly 0.75.
///
/// **Not the fog colour.** The binary commits this ramp to three slots, but the fog-colour one
/// (`0x6d28b0` → `DNState+0x70`) is dead: `0x66ff60` calls the tint and *then* calls `0x6cee30`,
/// whose `0x6cee76` unconditionally overwrites that slot from the band-derived colour. The
/// underwater murk colour reaches the screen through the LightParams slot switch we already
/// have; this ramp darkens two light bands and nothing else.
pub fn ocean_depth_factors(self, eye_z: f32) -> Option<(f32, f32)> {
if self != Submersion::Ocean {
return None;
}
let t = eye_z.clamp(OCEAN_RAMP_FLOOR, 0.0);
let k = 1.0 - t * OCEAN_RAMP_RECIP;
Some(((k + 1.0) * 0.5, k * 0.25 + 0.75))
}
/// The fixed global `LightParams` row this submersion replaces the whole blend with, if any.
/// `None` for [`Submersion::Dry`] and [`Submersion::Water`], which go through the slot selection.
/// `None` for the dry and water-kind states, which go through the slot selection.
fn fixed_param(self) -> Option<u32> {
match self {
Submersion::Magma => Some(PARAM_MAGMA),
Submersion::Slime => Some(PARAM_SLIME),
Submersion::Dry | Submersion::Water => None,
Submersion::Dry | Submersion::Water | Submersion::Ocean => None,
}
}
}
/// Pick the `LightParams` slot for the current ghost/weather/submersion state (see `SLOT_*`).
/// Ghost wins outright — the client keeps ONE active slot, and the ghost watcher owns it while
/// the flag is up. Only [`Submersion::Water`] reaches the underwater slots; the fullbright liquids
/// never get here (their fixed row short-circuits the blend).
/// the flag is up. Only a **water kind** reaches the underwater slots (water or ocean alike — the
/// slot switch does not distinguish them); the fullbright liquids never get here (their fixed row
/// short-circuits the blend).
fn weather_slot(ghost: bool, stormy: bool, underwater: bool) -> usize {
if ghost {
return SLOT_DEATH;
@@ -539,7 +604,7 @@ impl LightCatalog {
return self.sample_param(p, time);
}
}
let slot = weather_slot(ghost, stormy, submersion == Submersion::Water);
let slot = weather_slot(ghost, stormy, submersion.is_water());
let atmo_of = |l: &Light| -> Option<Atmosphere> {
// Fall back to the clear slot if the requested slot is unset for this light (e.g. a zone
// with no dedicated underwater param degrades to the above-water atmosphere).
@@ -907,6 +972,98 @@ impl LightCatalog {
}
}
#[cfg(test)]
mod ocean_tests {
use super::*;
/// The ramp's shape: both factors are 1.0 at the surface, bottom out at 30 yards, and hold flat
/// below. `fac1` (the first light triple) halves; `fac2` (the second) only drops to 0.75 — the
/// fill darkens twice as fast as the direct term, which is what makes a deep sea read flat as
/// well as dim.
#[test]
fn the_ocean_ramp_runs_over_thirty_yards_and_then_holds() {
let f = |z: f32| Submersion::Ocean.ocean_depth_factors(z).unwrap();
let (a, b) = f(0.0);
assert_eq!(
(a, b),
(1.0, 1.0),
"at the surface the ramp is the identity"
);
// Above the surface clamps to the identity too — the ramp never brightens.
assert_eq!(f(12.0), (1.0, 1.0));
let (a30, b30) = f(-30.0);
assert!((a30 - 0.5).abs() < 1e-6 && (b30 - 0.75).abs() < 1e-6);
assert_eq!(f(-100.0), (a30, b30), "below 30 yd it holds flat");
// Linear, and fac1 falls exactly twice as fast as fac2.
let (a15, b15) = f(-15.0);
assert!((a15 - 0.75).abs() < 1e-6 && (b15 - 0.875).abs() < 1e-6);
assert!(((1.0 - a15) - 2.0 * (1.0 - b15)).abs() < 1e-6);
}
/// GOLDEN — the binary's own constant, and the reason it has to be a MULTIPLY.
///
/// wow-re's note warns that `−0.0333333351f` "is not exactly −1/30" and that a bit-exact port
/// must use the stored f32 rather than `t/30.0`. The warning is worth heeding and its stated
/// reason is not the real one: that bit pattern IS the nearest f32 to −1/30. The hazard is the
/// operation, not the operand — `t · recip` and `t / 30.0` round differently, and they disagree
/// *inside* the ramp's range, so a port that "simplifies" the multiply into a divide drifts.
/// This test pins both halves so neither can be tidied away.
#[test]
fn the_ramp_multiplies_by_the_binarys_own_reciprocal() {
assert_eq!(OCEAN_RAMP_RECIP.to_bits(), 0xbd088889);
assert_eq!(
OCEAN_RAMP_RECIP,
-1.0f32 / 30.0,
"the constant itself is just the nearest f32 to -1/30"
);
// …and yet the two forms are not interchangeable. -29 yd is a witness inside the range.
let t = -29.0f32;
assert_ne!(
1.0 - t * OCEAN_RAMP_RECIP,
1.0 + t / 30.0,
"multiply and divide must be observably different, or the warning is empty"
);
// The boundaries land exactly, with no f32 residue to explain away.
assert_eq!(
Submersion::Ocean.ocean_depth_factors(-30.0).unwrap(),
(0.5, 0.75)
);
}
/// The ramp is OCEAN'S ALONE — the gate is the full unmasked dword `== 1` (`0x6d2821`), and
/// every other state, still water included, is untouched at any depth.
#[test]
fn only_ocean_ramps() {
for s in [
Submersion::Dry,
Submersion::Water,
Submersion::Magma,
Submersion::Slime,
] {
assert!(
s.ocean_depth_factors(-30.0).is_none(),
"{s:?} must not ramp"
);
}
}
/// The split exists for the ramp and nothing else: everywhere a *water kind* is the question,
/// ocean answers with water — the underwater LightParams slot, the ambience bed, the reverb
/// column. And neither takes a fixed global row, which is what magma and slime do.
#[test]
fn ocean_is_a_water_kind_everywhere_but_the_ramp() {
assert!(Submersion::Ocean.is_water() && Submersion::Water.is_water());
assert!(!Submersion::Magma.is_water() && !Submersion::Slime.is_water());
assert!(Submersion::Ocean.any());
assert_eq!(Submersion::Ocean.fixed_param(), None);
assert_eq!(
weather_slot(false, false, Submersion::Ocean.is_water()),
SLOT_CLEAR_UNDERWATER,
"ocean reaches the underwater slot exactly as water does"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
+25 -5
View File
@@ -576,15 +576,35 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// the first names the `UI-PVP-<group>` texture, the second titles the player frame's hit-area
// tooltip. nil, nil for a unit with no side (Monster/neutral templates) or no snapshot — the
// ref's icon branches gate on exactly that (decision 0646 §1/§3).
//
// **The two are genuinely different strings and this used to push one twice.** They are
// FactionGroup.dbc's `InternalName` (field 2) and `Name0` (field 3), and only on enUS are they
// the same word — which is why the duplication survived, and why the old doc on
// `UnitState::faction_group` said so as if it settled the matter. Every stock consumer of the
// FIRST return concatenates it into a path: `"…\UI-PVP-"..factionGroup` at `PlayerFrame.lua:68`,
// `TargetFrame.lua:198` and `PartyMemberFrame.lua:125`, `"…\Battleground-"..` at
// `BattlefieldFrame.lua:195`, and `HonorFrame.lua:68` compares it to the literal "Alliance".
// On any localized client the old shape named a texture that does not exist. All five of those
// files are on our chain.
g.set(
"UnitFactionGroup",
lua.create_function(|lua, token: Option<String>| {
match with_unit(lua, &token, None, |u| u.faction_group.clone())? {
Some(group) => {
let s = Value::String(lua.create_string(&group)?);
Ok((s.clone(), s))
let pair = with_unit(lua, &token, (None, None), |u| {
(u.faction_group.clone(), u.faction_group_localized.clone())
})?;
match pair {
(Some(english), localized) => {
let a = Value::String(lua.create_string(&english)?);
// The localized half falls back to the English one rather than to nil: a unit
// with a side always has an `InternalName`, and `Name0` is empty for the
// Player/Monster rows, so nil here would blank a tooltip the reference fills.
let b = match localized {
Some(l) if !l.is_empty() => Value::String(lua.create_string(&l)?),
_ => a.clone(),
};
Ok((a, b))
}
None => Ok((Value::Nil, Value::Nil)),
(None, _) => Ok((Value::Nil, Value::Nil)),
}
})?,
)?;
+14 -2
View File
@@ -279,9 +279,21 @@ pub struct UnitState {
/// only one that can name a shipped `UI-PVP-<group>` texture. `None` for Monster/neutral
/// templates is a state the icon callers explicitly handle (`if ( factionGroup and … )`).
///
/// The Era binding returns it twice — English token first, localized name second; enUS ships
/// the same word for both (FactionGroup.dbc's `InternalName` and `Name0`).
/// This is the **English** half — FactionGroup.dbc's `InternalName` — because that is what
/// `UnitFactionGroup`'s first return must carry: every stock consumer concatenates it into a
/// texture path (`"…\UI-PVP-"..factionGroup` at `PlayerFrame.lua:68`, `TargetFrame.lua:198`,
/// `PartyMemberFrame.lua:125`; `"…\Battleground-"..` at `BattlefieldFrame.lua:195`), and
/// `HonorFrame.lua:68` compares it against the literal `"Alliance"`.
///
/// This field's doc used to say the binding "returns it twice … enUS ships the same word for
/// both". That is true of enUS and of nothing else: `Name0` is localized, so on any other
/// client the texture path would name a file that does not exist. The localized half lives in
/// [`Self::faction_group_localized`] now.
pub faction_group: Option<String>,
/// The **localized** group name — FactionGroup.dbc's `Name0`, and `UnitFactionGroup`'s SECOND
/// return, which stock uses as display text (`PlayerFrame.lua`'s PvP hit-area tooltip title).
/// Never interchangeable with the English half above.
pub faction_group_localized: Option<String>,
/// The unit's GUID (`OBJECT_FIELD_GUID`) — the identity the cross-token predicates compare
/// (`UnitIsUnit`, `UnitInParty`; decision 0434 §5's popup gating). `0` = the app's feed didn't
/// resolve one; two zero guids never compare equal.
@@ -586,8 +586,35 @@ fn faction_group_pair_and_the_pvp_toggle() {
let (english, localized) = s
.eval::<(String, String)>(r#"return UnitFactionGroup("target")"#)
.unwrap();
// With no localized name fed, the second return falls back to the English one rather than nil:
// FactionGroup.dbc leaves `Name0` empty for some rows, and nil here would blank a tooltip the
// reference fills.
assert_eq!((english.as_str(), localized.as_str()), ("Horde", "Horde"));
// **The two halves are DIFFERENT fields and this is the assertion that says so.** They are
// FactionGroup.dbc's `InternalName` and `Name0`; only on enUS do they hold the same word, which
// is why returning one twice went unnoticed. The first is concatenated into a texture path by
// every stock consumer (`"…\UI-PVP-"..factionGroup` — PlayerFrame.lua:68, TargetFrame.lua:198,
// PartyMemberFrame.lua:125; `"…\Battleground-"..` — BattlefieldFrame.lua:195), so a localized
// string there names a file that does not exist. This fixture is a deDE-shaped client.
s.set_unit(
"target",
Some(UnitState {
exists: true,
faction_group: Some("Horde".into()),
faction_group_localized: Some("Horde-Allianz".into()),
..Default::default()
}),
);
let (english, localized) = s
.eval::<(String, String)>(r#"return UnitFactionGroup("target")"#)
.unwrap();
assert_eq!(
(english.as_str(), localized.as_str()),
("Horde", "Horde-Allianz"),
"the FIRST return is the English InternalName — a texture path is built from it"
);
// No side (a Monster/neutral template, or a unit whose template hasn't streamed).
s.set_unit(
"target",
+3 -1
View File
@@ -164,7 +164,9 @@ pub struct WmoLiquid {
/// and white noise on the other is not being read at the wrong offset.
pub opacity: Vec<u8>,
/// Per-tile flag bytes, row-major `j·xtiles + i`, `xtiles·ytiles` long. Low nibble = liquid type;
/// `0xf` = hole (skip the tile). High bits are fishable (`0x40`) / shared (`0x80`).
/// `0xf` = hole (skip the tile); `0x80` shared (the strip-builder gate). `0x40` ("fishable") is
/// carried but deliberately unread: the reference's WMO-side reader `0x6b9e50` is only reached
/// from the zero-caller `0x69b5d0` island — fishability is the server's verdict. Decision 1825.
pub tile_flags: Vec<u8>,
}
+417 -29
View File
@@ -277,6 +277,122 @@ fn sync_haze(
}
}
/// **The GlowWave lane** — the underwater screen warp's two inputs (wow-re
/// `ffxeffects/scratch/glow-wave-underwater.md`, §5 cross-checked; decision 1824).
///
/// Underwater the reference swaps its whole post-process pass list: `CFFXGlow::Render 0x6cc630`
/// walks a second list whose third pass is **FFXGlowWave** (`0x6cb1f0`, render `0x6cb310`) rather
/// than FFXGlow, and that pass displaces the combine's two samples through a sine bump map. wow-re's
/// note had carried the swap as an undecided "GlowWave, *or* a duplicate Glow" for months; the round
/// that answered it found the arms mutually exclusive and the plain-Glow arm unreachable on both
/// backends, so a submerged frame is ALWAYS the wave.
///
/// **No liquid-type discrimination.** `[0xc7f288] ∈ {0xf dry, 0 water, 1 ocean, 2 magma, 3 slime}`
/// and `0x6cc644` compares against `0xf` alone — the warp runs in lava and slime exactly as in
/// water. (That is not the drift cloud's rule, which *does* fork per kind and gives slime no motes
/// at all; two neighbouring underwater systems reading the same byte with different questions.)
#[derive(Resource, Clone, Copy, Default, ExtractResource)]
pub struct FfxWave {
/// `(t mod 3174)/3174` — the u-axis phase, 3.174 s.
phase1: f32,
/// `(t mod 2805)/2805` — the v-axis phase, 2.805 s. Independent of [`Self::phase1`]: the two
/// rejoin only every ~49 minutes, which is what keeps the warp from reading as a loop.
phase2: f32,
/// Whether the pass-list swap itself is tripped: in-world (`0x467d00`) **and** the camera-eye
/// liquid probe non-dry (`0x672470 != 0xf`). Not a strength — the swap is a hard fork, and the
/// warp has no ramp in or out.
active: bool,
}
/// The two GlowWave phase periods in milliseconds — `[0xce89c4]` and `[0xce89c8]`, divided into an
/// integer millisecond clock exactly as the reference's `fild`/`fidiv` pair does.
const WAVE_PERIOD_MS: [u64; 2] = [3174, 2805];
/// The wave LUT's edge — 128×128 texels (`[0xce89a0]`'s 7-field descriptor: 128/128/128/128 and
/// the two 1/128 reciprocals).
const WAVE_LUT_EDGE: u32 = 128;
/// The reference's glow-wave LUT (`ffx_glow_wave_lut` `0x6cbea0`, a diffed PRIMITIVE), as the
/// texels our combine samples.
///
/// Two channels, one sine each and each depending on ONE axis — `du = sin(2πx/128)` across,
/// `dv = sin(2πy/128)` down — which is what makes the sampled value a *displacement* rather than an
/// intensity: the shipped format is the signed two-channel bump format on both backends
/// (`D3DFMT_V8U8` / `GL_DSDT8_NV`), and the pass is a dependent texture read.
///
/// We store the reference's **unsigned** pack (`(s·0.5 + 0.5)·255`, its path B) and bias it back in
/// the shader, because that is the permutation the live client actually runs: `gxApi` defaults to
/// direct3d, the shipped `WTF/` overrides nothing, and the caps tier that selects picks the biased
/// `ps_2_0` blob against an unsigned texture. `as u8` truncates toward zero, which is `__ftol`.
fn wave_lut_texels() -> Vec<u8> {
let pack = |s: f32| ((s * 0.5 + 0.5) * 255.0).clamp(0.0, 255.0) as u8;
let sine = |i: u32| (std::f32::consts::TAU * i as f32 / WAVE_LUT_EDGE as f32).sin();
let mut texels = Vec::with_capacity((WAVE_LUT_EDGE * WAVE_LUT_EDGE * 2) as usize);
for y in 0..WAVE_LUT_EDGE {
let dv = pack(sine(y));
for x in 0..WAVE_LUT_EDGE {
texels.push(pack(sine(x)));
texels.push(dv);
}
}
texels
}
/// Sync [`FfxWave`] from the same two inputs the reference's swap guard reads, plus the clock its
/// render method phases on (`OsGetAsyncTimeMs`, `0x6cb43a`).
///
/// The phases advance whether or not the wave is armed — they are a free-running wall clock in the
/// reference too, not a timer the swap starts — so surfacing and diving again does not restart the
/// pattern, and nothing has to be reset on the transition.
fn sync_wave(
time: Res<Time>,
live: Res<crate::schedule::WorldLive>,
underwater: Option<Res<crate::liquid::Underwater>>,
mut wave: ResMut<FfxWave>,
mut last_dump: Local<Option<u32>>,
) {
let ms = (time.elapsed_secs_f64() * 1000.0) as u64;
wave.phase1 = (ms % WAVE_PERIOD_MS[0]) as f32 / WAVE_PERIOD_MS[0] as f32;
wave.phase2 = (ms % WAVE_PERIOD_MS[1]) as f32 / WAVE_PERIOD_MS[1] as f32;
let verdict = underwater.map(|u| u.0);
wave.active = live.0 && verdict.is_some_and(|v| v.any());
// `WOW_WAVE_DUMP` — 1 Hz, and it reports on EVERY frame including the ones that do not warp,
// naming why. An instrument that only speaks while the effect is running cannot tell "not
// armed" from "armed and broken", which is exactly the hour the drift cloud's first probe cost
// (decision 1814 §6b): the effect was off, the screen was silent, and the silence was
// indistinguishable from a compile failure.
if std::env::var_os("WOW_WAVE_DUMP").is_some() {
let sec = time.elapsed_secs() as u32;
if last_dump.replace(sec) != Some(sec) {
let why = match (live.0, verdict) {
(false, _) => "off — not in world (the reference's 0x467d00 half of the guard)",
(_, None) => "off — no submersion verdict resolved yet",
(_, Some(benilla_formats::Submersion::Dry)) => "off — the eye is dry",
(_, Some(_)) => "WARPING",
};
info!(
"glow-wave: {why} — verdict {verdict:?} phase {:.3}/{:.3} (periods {} / {} ms)",
wave.phase1, wave.phase2, WAVE_PERIOD_MS[0], WAVE_PERIOD_MS[1]
);
}
}
}
/// Whether a view draws the warped combine — the pass-list swap, as a pure function.
///
/// Three conditions, each a fact about the reference rather than a taste call:
/// - the view runs the **WorldFrame** pass pair. The swap lives in `CFFXGlow::Render`, which the
/// glue screens' own pair also reaches — but its guard's first half is `0x467d00`, the in-world
/// objmgr gate, so a glue screen can never trip it. A bake runs no pair at all.
/// - the camera eye is in liquid and we are in-world ([`FfxWave::active`]).
/// - the player is **not a ghost**. `CFFXDeath` REPLACES `CFFXGlow` outright in the single active
/// pass slot `[0xce8bb4]`, and `CFFXDeath::Render 0x6cdf20` owns one list and no guard — so a
/// ghost underwater gets no warp, the same way it already gets no haze.
fn wave_armed(state: FfxState, wave: FfxWave, death: f32) -> bool {
matches!(state, FfxState::Player) && wave.active && death == 0.0
}
/// Sync the gain from the live zone lighting (the same source `sync_bloom` used). Off-world there
/// is no `Light.dbc` zone — the reference runs its `LightParams` **default 0.5** (wow-re
/// death-pass.md: "the zone/time-of-day ambient glow scalar, default 0.5"): the glue screens'
@@ -333,15 +449,32 @@ struct FfxGlowPipelines {
layout_filter: BindGroupLayoutDescriptor,
layout_combine: BindGroupLayoutDescriptor,
sampler: Sampler,
/// The 128×128 sine bump map and its own REPEAT sampler — built once here, not per view: the
/// LUT is 32 KB of resolution-independent constant, and the reference generates it once too
/// (the registration callback `0x6cbcf0`).
///
/// REPEAT is load-bearing, not a default. The wave texcoord's scale runs to `W/128` — ten full
/// cycles across a 1280-wide screen — so under CLAMP every cycle but the first would pin to the
/// edge texel and the warp would vanish over ~90% of the frame. It is byte-closed to
/// `D3DTADDRESS_WRAP` (`0x5a2646`/`0x5a266a` through the table `0x80a254 = {CLAMP, WRAP}`), and
/// misreading it was the costliest near-miss of the round that derived this.
wave_view: TextureView,
wave_sampler: Sampler,
downsample: CachedRenderPipelineId,
gauss_h: CachedRenderPipelineId,
gauss_v: CachedRenderPipelineId,
combine: CachedRenderPipelineId,
/// The underwater combine. A SEPARATE pipeline rather than a branch inside `fs_combine`,
/// compiled once at startup beside it: a dry frame then binds byte-for-byte the pipeline it
/// bound before this feature existed and pays nothing at all for the warp — no extra pass, no
/// extra target, not even a uniform branch.
combine_wave: CachedRenderPipelineId,
}
fn init_pipelines(
mut commands: Commands,
render_device: Res<RenderDevice>,
render_queue: Res<RenderQueue>,
fullscreen_shader: Res<FullscreenShader>,
asset_server: Res<AssetServer>,
pipeline_cache: Res<PipelineCache>,
@@ -367,7 +500,12 @@ fn init_pipelines(
texture_2d(TextureSampleType::Float { filterable: true }),
sampler(SamplerBindingType::Filtering),
texture_2d(TextureSampleType::Float { filterable: true }),
uniform_buffer_sized(false, Some(std::num::NonZero::new(16).unwrap())),
uniform_buffer_sized(false, Some(std::num::NonZero::new(32).unwrap())),
// The wave LUT + its REPEAT sampler ride EVERY combine's layout, dry included, so
// the two entry points stay interchangeable behind one bind group. Binding a
// texture the dry shader never samples costs the frame nothing.
texture_2d(TextureSampleType::Float { filterable: true }),
sampler(SamplerBindingType::Filtering),
),
),
);
@@ -378,6 +516,39 @@ fn init_pipelines(
address_mode_v: AddressMode::ClampToEdge,
..Default::default()
});
// LINEAR + REPEAT, both byte-closed: the reference's 128-texel sine is *sampled*, so linear
// filtering is what makes it a smooth wave rather than 128 steps, and the wrap is what lets the
// texcoord run past 1.0 into its tenth cycle.
let wave_sampler = render_device.create_sampler(&SamplerDescriptor {
min_filter: FilterMode::Linear,
mag_filter: FilterMode::Linear,
address_mode_u: AddressMode::Repeat,
address_mode_v: AddressMode::Repeat,
..Default::default()
});
let wave_view = render_device
.create_texture_with_data(
&render_queue,
&TextureDescriptor {
label: Some("ffx_glow_wave_lut"),
size: Extent3d {
width: WAVE_LUT_EDGE,
height: WAVE_LUT_EDGE,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
// Two unsigned channels, biased back to signed in the shader — the shipped
// permutation's own encoding (see [`wave_lut_texels`]).
format: TextureFormat::Rg8Unorm,
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
view_formats: &[],
},
TextureDataOrder::LayerMajor,
&wave_lut_texels(),
)
.create_view(&TextureViewDescriptor::default());
let pipeline = |label: &'static str,
layout: &BindGroupLayoutDescriptor,
entry: &'static str|
@@ -413,14 +584,22 @@ fn init_pipelines(
&layout_combine,
"fs_combine",
));
let combine_wave = pipeline_cache.queue_render_pipeline(pipeline(
"ffx_glow_combine_wave",
&layout_combine,
"fs_combine_wave",
));
commands.insert_resource(FfxGlowPipelines {
layout_filter,
layout_combine,
sampler,
wave_view,
wave_sampler,
downsample,
gauss_h,
gauss_v,
combine,
combine_wave,
});
}
@@ -431,8 +610,9 @@ fn init_pipelines(
struct FfxGlowTextures {
quarter_a: CachedTexture,
quarter_b: CachedTexture,
/// The combine's 16-byte `(gain, death, haze, pad)` uniform — persistent, rewritten each
/// frame with a queue write (queue writes land before the graph's submit executes).
/// The combine's 32-byte uniform — `(gain, death, haze, dither)` then the GlowWave phases.
/// Persistent, rewritten each frame with a queue write (queue writes land before the graph's
/// submit executes).
gain_buf: Buffer,
/// The two Gauss bind groups bind only the ¼-res ping-pong views + sampler — all stable
/// while the textures hold. The downsample and combine bind groups bind the view target's
@@ -498,7 +678,7 @@ fn prepare_textures(
);
let gain_buf = render_device.create_buffer(&BufferDescriptor {
label: Some("ffx_glow_gain"),
size: 16,
size: 32,
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
@@ -544,12 +724,17 @@ fn dither_armed() -> f32 {
/// pair this view runs decides what drives them, and a bake runs neither pair (decision 1481,
/// report B49 — now structural rather than arithmetic, decision 1731).
/// - **w** — the deband-dither arm ([`dither_armed`]), 0 or 1.
///
/// The second row is the GlowWave lane: the two phases, written on every frame and read only by
/// the warped entry point. They are unconditional because they are a free-running clock — gating
/// the *write* on the arm would make the pattern restart on every dive.
fn combine_uniform(
zone_gain: f32,
world: FfxPassState,
glue: FfxPassState,
glow: &FfxGlow,
) -> [f32; 4] {
wave: FfxWave,
) -> [f32; 8] {
let feed = match glow.state {
FfxState::Player => world,
FfxState::Glue => glue,
@@ -560,6 +745,10 @@ fn combine_uniform(
feed.death,
feed.haze,
dither_armed(),
wave.phase1,
wave.phase2,
0.0,
0.0,
]
}
@@ -614,20 +803,27 @@ impl ViewNode for FfxGlowNode {
) -> Result<(), NodeRunError> {
let pipelines = world.resource::<FfxGlowPipelines>();
let pipeline_cache = world.resource::<PipelineCache>();
let death = world.resource::<FfxDeathFade>().0;
let wave = *world.resource::<FfxWave>();
let uniform = combine_uniform(
world.resource::<FfxGlowGain>().0,
FfxPassState::world(
world.resource::<FfxDeathFade>().0,
world.resource::<FfxHazeMix>().0,
),
FfxPassState::world(death, world.resource::<FfxHazeMix>().0),
FfxPassState::glue(world.resource::<GlueFfx>().death()),
glow,
wave,
);
// THE PASS-LIST SWAP (`0x6cc630`). The reference forks the whole list here; we fork one
// pipeline, which is the same fork — the first two passes are identical in both lists.
let combine_id = if wave_armed(glow.state, wave, death) {
pipelines.combine_wave
} else {
pipelines.combine
};
let (Some(downsample), Some(gauss_h), Some(gauss_v), Some(combine)) = (
pipeline_cache.get_render_pipeline(pipelines.downsample),
pipeline_cache.get_render_pipeline(pipelines.gauss_h),
pipeline_cache.get_render_pipeline(pipelines.gauss_v),
pipeline_cache.get_render_pipeline(pipelines.combine),
pipeline_cache.get_render_pipeline(combine_id),
) else {
return Ok(()); // pipelines still compiling — draw the frame un-glowed
};
@@ -702,6 +898,8 @@ impl ViewNode for FfxGlowNode {
&pipelines.sampler,
&textures.quarter_a.default_view,
textures.gain_buf.as_entire_binding(),
&pipelines.wave_view,
&pipelines.wave_sampler,
)),
);
let mut pass = render_context
@@ -733,14 +931,16 @@ impl Plugin for FfxGlowPlugin {
.init_resource::<FfxDeathFade>()
.init_resource::<GlueFfx>()
.init_resource::<FfxHazeMix>()
.init_resource::<FfxWave>()
.add_plugins((
ExtractComponentPlugin::<FfxGlow>::default(),
ExtractResourcePlugin::<FfxGlowGain>::default(),
ExtractResourcePlugin::<FfxDeathFade>::default(),
ExtractResourcePlugin::<GlueFfx>::default(),
ExtractResourcePlugin::<FfxHazeMix>::default(),
ExtractResourcePlugin::<FfxWave>::default(),
))
.add_systems(Update, (sync_gain, sync_haze, ensure_ffx_glow));
.add_systems(Update, (sync_gain, sync_haze, sync_wave, ensure_ffx_glow));
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
return;
};
@@ -802,17 +1002,35 @@ mod tests {
fn a_ghosts_bake_is_not_death_combined() {
let zone = 0.5;
assert_eq!(
combine_uniform(zone, ghost_world(), living_glue(), &FfxGlow::WORLD),
[0.5, 1.0, 0.0, 0.0]
combine_uniform(
zone,
ghost_world(),
living_glue(),
&FfxGlow::WORLD,
FfxWave::default()
),
[0.5, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
);
assert_eq!(
combine_uniform(zone, ghost_world(), living_glue(), &FfxGlow::BOOTH),
[0.5, 0.0, 0.0, 0.0],
combine_uniform(
zone,
ghost_world(),
living_glue(),
&FfxGlow::BOOTH,
FfxWave::default()
),
[0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
"the booth keeps the zone glow and drops the death gate"
);
assert_eq!(
combine_uniform(zone, ghost_world(), living_glue(), &FfxGlow::UI_PANE),
[0.0, 0.0, 0.0, 0.0]
combine_uniform(
zone,
ghost_world(),
living_glue(),
&FfxGlow::UI_PANE,
FfxWave::default()
),
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
);
}
@@ -822,12 +1040,24 @@ mod tests {
fn a_drunk_players_bake_stays_sharp() {
let (zone, drunk) = (0.5, FfxPassState::world(0.0, 1.0));
assert_eq!(
combine_uniform(zone, drunk, living_glue(), &FfxGlow::WORLD),
[0.5, 0.0, 1.0, 0.0]
combine_uniform(
zone,
drunk,
living_glue(),
&FfxGlow::WORLD,
FfxWave::default()
),
[0.5, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]
);
assert_eq!(
combine_uniform(zone, drunk, living_glue(), &FfxGlow::BOOTH),
[0.5, 0.0, 0.0, 0.0]
combine_uniform(
zone,
drunk,
living_glue(),
&FfxGlow::BOOTH,
FfxWave::default()
),
[0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
);
}
@@ -841,18 +1071,36 @@ mod tests {
let ghost_glue = FfxPassState::glue(1.0);
let alive_world = FfxPassState::world(0.0, 0.0);
assert_eq!(
combine_uniform(zone, alive_world, ghost_glue, &FfxGlow::GLUE_SCENE),
[0.5, 1.0, 0.0, 0.0],
combine_uniform(
zone,
alive_world,
ghost_glue,
&FfxGlow::GLUE_SCENE,
FfxWave::default()
),
[0.5, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
"a ghost roster row death-combines the glue scene"
);
assert_eq!(
combine_uniform(zone, alive_world, ghost_glue, &FfxGlow::WORLD),
[0.5, 0.0, 0.0, 0.0],
combine_uniform(
zone,
alive_world,
ghost_glue,
&FfxGlow::WORLD,
FfxWave::default()
),
[0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
"…and never the world view, whose own player is alive"
);
assert_eq!(
combine_uniform(zone, ghost_world(), living_glue(), &FfxGlow::GLUE_SCENE),
[0.5, 0.0, 0.0, 0.0],
combine_uniform(
zone,
ghost_world(),
living_glue(),
&FfxGlow::GLUE_SCENE,
FfxWave::default()
),
[0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
"and a world ghost never reaches the glue view"
);
}
@@ -870,13 +1118,153 @@ mod tests {
0.5,
drunk_world,
FfxPassState::glue(1.0),
&FfxGlow::GLUE_SCENE
&FfxGlow::GLUE_SCENE,
FfxWave::default()
),
[0.5, 1.0, 0.0, 0.0],
[0.5, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
"the glue view death-combines on its own gate and never hazes"
);
}
/// **The pass-list swap** (`0x6cc630`) — the three conditions, each independently load-bearing.
///
/// The guard is `0x467d00() != 0 && 0x672470() != 0xf`, and the death pass REPLACES the glow
/// pass outright rather than composing with it, so a ghost has no wave for the same structural
/// reason it has no haze.
#[test]
fn the_warp_arms_only_for_a_living_submerged_world_view() {
let wet = FfxWave {
phase1: 0.0,
phase2: 0.0,
active: true,
};
let dry = FfxWave::default();
assert!(
wave_armed(FfxState::Player, wet, 0.0),
"in-world, submerged, alive — the reference walks its second list"
);
assert!(
!wave_armed(FfxState::Player, dry, 0.0),
"a dry camera keeps the first list"
);
assert!(
!wave_armed(FfxState::Player, wet, 1.0),
"a ghost runs CFFXDeath, which owns one list and no guard"
);
assert!(
!wave_armed(FfxState::Glue, wet, 0.0),
"the glue pair reaches the same Render, but 0x467d00 gates it out of the swap"
);
assert!(
!wave_armed(FfxState::None, wet, 0.0),
"a bake runs neither pair"
);
}
/// **A dry frame is untouched by this feature** — the perf claim, made structurally rather than
/// with a frame counter (the machine that would measure it is busy, and a stopwatch could not
/// prove this anyway).
///
/// Dry, the uniform's first row is bit-identical to what it was before the warp existed and the
/// phase row is inert; the node then selects `combine` — the same pipeline object, compiled at
/// the same startup, bound through the same layout. The warp costs a dry frame nothing because
/// there is nothing of it in a dry frame, not because its cost is small.
#[test]
fn a_dry_frame_pays_nothing_for_the_warp() {
let live = FfxWave {
phase1: 0.4,
phase2: 0.7,
active: false,
};
let u = combine_uniform(
0.5,
FfxPassState::world(0.0, 0.0),
living_glue(),
&FfxGlow::WORLD,
live,
);
assert_eq!(
u[..4],
[0.5, 0.0, 0.0, 0.0],
"the combine lane is exactly what it was before the wave lane existed"
);
assert!(
!wave_armed(FfxState::Player, live, 0.0),
"and the node binds the unwarped pipeline"
);
}
/// The phases are a FREE-RUNNING clock, not a timer the dive starts: they advance whether or not
/// the wave is armed, so surfacing and diving again does not restart the pattern. Their periods
/// are the reference's own two integer millisecond moduli, and being coprime-ish is what stops
/// the warp reading as a loop — they rejoin only every ~49 minutes.
#[test]
fn the_two_phases_run_independently_off_one_clock() {
let phase = |ms: u64, i: usize| (ms % WAVE_PERIOD_MS[i]) as f32 / WAVE_PERIOD_MS[i] as f32;
assert_eq!(WAVE_PERIOD_MS, [3174, 2805]);
// Each wraps on its own period and nowhere else.
assert!(phase(3173, 0) > 0.999 && phase(3174, 0) == 0.0);
assert!(phase(2804, 1) > 0.999 && phase(2805, 1) == 0.0);
// At its own wrap the OTHER phase is mid-stride — the whole point of two moduli.
assert!(phase(3174, 1) > 0.13 && phase(3174, 1) < 0.14);
// The joint period: lcm(3174, 2805) ms ≈ 49 min 28 s.
let gcd = |mut a: u64, mut b: u64| {
while b != 0 {
(a, b) = (b, a % b);
}
a
};
let joint =
WAVE_PERIOD_MS[0] / gcd(WAVE_PERIOD_MS[0], WAVE_PERIOD_MS[1]) * WAVE_PERIOD_MS[1];
assert_eq!(joint, 2_967_690);
}
/// **The LUT is a displacement map, and each channel bends ONE axis.** That is the fact that
/// makes the effect a geometric warp rather than a brightness shimmer, and it is visible in the
/// texels: `du` depends only on x, `dv` only on y (the shipped format is the signed two-channel
/// bump format on both backends — `D3DFMT_V8U8` / `GL_DSDT8_NV`).
#[test]
fn the_wave_lut_is_one_sine_per_axis() {
let lut = wave_lut_texels();
let edge = WAVE_LUT_EDGE as usize;
assert_eq!(lut.len(), edge * edge * 2);
let at = |x: usize, y: usize| {
let i = (y * edge + x) * 2;
(lut[i], lut[i + 1])
};
for x in 0..edge {
for y in [0usize, 37, 91, 127] {
assert_eq!(at(x, y).0, at(x, 0).0, "du must not vary down the texture");
assert_eq!(at(x, y).1, at(0, y).1, "dv must not vary across it");
}
}
// Biased back the way the shipped ps_2_0 permutation does, every texel is its axis's sine
// to within one 8-bit step — the quantization the reference itself ships.
for i in 0..edge {
let want = (std::f32::consts::TAU * i as f32 / edge as f32).sin();
let got = (at(i, i).0 as f32 / 255.0 - 0.5) * 2.0;
assert!(
(got - want).abs() <= 2.0 / 255.0,
"texel {i}: {got} vs sin {want}"
);
}
}
/// GOLDEN — the reference's own pack, down to the truncation. `ffx_glow_wave_lut`'s path B is
/// `(s·0.5 + 0.5)·255` → clamp → `__ftol`, and `__ftol` truncates toward zero rather than
/// rounding: `sin = 0` packs to **127**, not 128, so the map carries a half-step DC bias the
/// reference carries too. Rounding here would be a "cleaner" number and the wrong one.
#[test]
fn the_wave_pack_truncates_like_ftol() {
let lut = wave_lut_texels();
// x = 0 and x = 64 are the sine's two zeros; both truncate down.
assert_eq!(lut[0], 127, "sin(0) = 0 → trunc(127.5) = 127");
assert_eq!(lut[64 * 2], 127, "sin(π) ≈ 0 → 127");
// The quarter points saturate the ends of the range.
assert_eq!(lut[32 * 2], 255, "sin(π/2) = 1 → 255");
assert_eq!(lut[96 * 2], 0, "sin(3π/2) = −1 → 0");
}
/// GOLDEN — the three writers of the glue screens' `LightParams.glow`, and the alpha byte the
/// death combine quantizes the ghost one into. Every number byte-VERIFIED (wow-re
/// `glue-select-ghost-treatment.md`); each constant has exactly one reference image-wide.
+118 -10
View File
@@ -193,6 +193,24 @@ pub(super) fn setup_lighting(
/// sun sets (that's how the real client does it). **Phase 0:** this only writes the resolved values
/// into [`WowLighting`] (consumed by `apply_wow_lighting` → the materials). No PBR sun / ambient /
/// fog / sky are touched — those are rebuilt in-shader step by step.
/// The reference's `0x6d1620` — an HSV **VALUE-only** scale of a colour, as used by the ocean depth
/// ramp: unpack to RGB floats, `RGB → HSV` (`0x7bbc80`), multiply the third member (V) alone,
/// `HSV → RGB` (`0x7bbd60`), repack.
///
/// **It is a uniform RGB multiply, and that is a fact rather than an approximation.** HSV keeps
/// `V = max(r,g,b)` and `S = (max − min)/max`; scaling V while H and S are untouched scales the max
/// by `f`, and the min by `new_V·(1 − S) = f·min`, and the middle channel by the same ratio — so
/// every channel comes back multiplied by `f`. The detour is an identity for a V-only scale, so we
/// transcribe the identity. `the_hsv_detour_is_a_plain_multiply` checks that against an actual
/// round trip rather than leaving it as an argument in a comment.
///
/// The one thing we do NOT carry over is the reference's byte repack (`×255 + 512 >> 14`): its
/// light lane is 8-bit and ours is float end to end, and re-quantizing here would be inventing a
/// step this pipeline does not otherwise have.
fn hsv_value_scale(c: [f32; 3], f: f32) -> [f32; 3] {
[c[0] * f, c[1] * f, c[2] * f]
}
#[allow(clippy::too_many_arguments)]
pub(super) fn update_time_lighting(
world_time: Res<super::WorldTime>,
@@ -200,7 +218,7 @@ pub(super) fn update_time_lighting(
debug: Res<DebugState>,
cam: Query<&GlobalTransform, With<WorldCamera>>,
current_map: Option<Res<CurrentMap>>,
underwater: Option<Res<crate::liquid::Underwater>>,
eye_liquid: crate::liquid::EyeLiquid,
viewer: Res<crate::view::Viewer>,
weather: Option<Res<crate::weather::WeatherState>>,
view: Res<crate::view::ViewDistance>,
@@ -262,10 +280,7 @@ pub(super) fn update_time_lighting(
// fog + cool light. Feeding it through the normal atmosphere → `WowLighting` path means every
// existing consumer (terrain/model/water/WDL fog + the clear colour) becomes underwater for free
// (VERIFIED apitrace WoW.18: the reference just switches the active param; no overlay quad).
let submerged = underwater
.as_ref()
.map(|u| u.0)
.unwrap_or(benilla_formats::Submersion::Dry);
let submerged = eye_liquid.submersion();
// The ghost-world atmosphere (decision 0308 §7, byte-VERIFIED death-light.md): while
// PLAYER_FLAGS_GHOST is up the active LightParams slot is 4 — the death profile — applied
// instantly (the client rebuilds its color tables per frame off the single active slot).
@@ -328,9 +343,17 @@ pub(super) fn update_time_lighting(
)
};
let moon02 = daynight::moon02_state(day_f);
// **The ocean depth ramp** (decision 1829, wow-re `submerged-atmosphere.md` §3). Ocean alone
// runs it, and it darkens the two committed light triples — the first (`DNState+0x178`) by
// `fac1` down to 0.5, the second (`+0x174`) by `fac2` down to 0.75, over the first 30 yards.
// Every other submersion state, ocean's own fog colour included, is untouched: the fog-colour
// commit in the same block is dead code, overwritten from the bands immediately after.
let (fac1, fac2) = submerged
.ocean_depth_factors(eye_liquid.eye_z())
.unwrap_or((1.0, 1.0));
let resolved = WowLighting {
ambient: atmo.ambient,
diffuse: atmo.sun_diffuse,
ambient: hsv_value_scale(atmo.ambient, fac1),
diffuse: hsv_value_scale(atmo.sun_diffuse, fac2),
spec: atmo.sun_color, // row 9 (warm-white) — WoW's specular color
sun_dir,
celestial_dir,
@@ -433,11 +456,26 @@ pub(super) fn update_time_lighting(
// recomputes the same call offline — so a line from a live run can be replayed against the DBC
// instead of argued about. Without the map printed, "is this the atmosphere the zone actually
// authored, or the one next door?" costs a session (Stratholme, 2026-07-29).
if *last_dump != Some(minute) && std::env::var_os("WOW_LIGHT_DUMP").is_some() {
*last_dump = Some(minute);
// `WOW_LIGHT_DUMP=frame` drops the minute throttle, exactly as `WOW_FOG_DUMP=frame` does. The
// minute gate is right for comparing colours against a DBC replay, and useless for anything
// POSITIONAL: game minutes advance slowly enough that a whole probe logs two lines, and both of
// them land in the first moments after world entry — before the submersion verdict has settled,
// so the line reports `Dry` at a position that is plainly underwater (1829, chasing exactly
// that contradiction).
if let Some(mode) = std::env::var_os("WOW_LIGHT_DUMP") {
let key = (mode != *"frame").then_some(minute);
if key.is_some() && *last_dump == key {
return;
}
*last_dump = key;
let b = |c: [f32; 3]| [c[0] * 255.0, c[1] * 255.0, c[2] * 255.0].map(|v| v.round() as i32);
eprintln!(
"[light] map {map} at ({:.1}, {:.1}, {:.1}) minute {minute} ({source:?}) ambient {:?} diffuse {:?} spec {:?} sun_dir {:.3} fog {:?} start/end {:.0}/{:.0}",
// The submersion tail (1829) reports the RAMP ITSELF, not just its result. Comparing
// two dives at different depths cannot prove the ramp fired — the light row moves with
// the clock between runs, and the dump only fires on a game-minute change, so the two
// samples are never the same row. Printing the factors and their input settles it in
// one line instead.
"[light] map {map} at ({:.1}, {:.1}, {:.1}) minute {minute} ({source:?}) ambient {:?} diffuse {:?} spec {:?} sun_dir {:.3} fog {:?} start/end {:.0}/{:.0} submersion {submerged:?} eye_z {:.1} ocean_fac {:.4}/{:.4}",
wow_pos[0],
wow_pos[1],
wow_pos[2],
@@ -448,6 +486,9 @@ pub(super) fn update_time_lighting(
b(resolved.fog_color),
resolved.fog_start,
resolved.fog_end,
eye_liquid.eye_z(),
fac1,
fac2,
);
}
}
@@ -481,6 +522,73 @@ pub(super) fn apply_sky_backdrop(
clear.0 = Color::linear_rgb(l.fog_color[0], l.fog_color[1], l.fog_color[2]);
}
#[cfg(test)]
mod ocean_ramp_tests {
use super::hsv_value_scale;
/// A textbook `RGB → HSV → RGB` round trip, written out here so the identity claim in
/// [`hsv_value_scale`]'s doc is *checked* rather than argued. If it were ever false, our one
/// multiply would be silently wrong in colour rather than obviously wrong in code.
fn round_trip_v_scale(c: [f32; 3], f: f32) -> [f32; 3] {
let (r, g, b) = (c[0], c[1], c[2]);
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let v = max;
let sat = if max == 0.0 { 0.0 } else { (max - min) / max };
let hue = if max == min {
0.0
} else if max == r {
60.0 * (((g - b) / (max - min)) % 6.0)
} else if max == g {
60.0 * ((b - r) / (max - min) + 2.0)
} else {
60.0 * ((r - g) / (max - min) + 4.0)
};
// V *= f, H and S untouched — the reference's `0x6d1620`.
let v = v * f;
// HSV -> RGB
let cc = v * sat;
let x = cc * (1.0 - (((hue / 60.0) % 2.0) - 1.0).abs());
let m = v - cc;
let (r1, g1, b1) = match (hue / 60.0).floor() as i32 {
0 => (cc, x, 0.0),
1 => (x, cc, 0.0),
2 => (0.0, cc, x),
3 => (0.0, x, cc),
4 => (x, 0.0, cc),
_ => (cc, 0.0, x),
};
[r1 + m, g1 + m, b1 + m]
}
/// The reference scales HSV **V** alone; we transcribe that as a plain RGB multiply. This is an
/// identity, not an approximation — and the round trip above says so for real colours,
/// including the degenerate greys and blacks where the hue is undefined.
#[test]
fn the_hsv_detour_is_a_plain_multiply() {
let colours = [
[0.8, 0.4, 0.1],
[0.1, 0.1, 0.1],
[0.0, 0.0, 0.0],
[1.0, 1.0, 1.0],
[0.2, 0.9, 0.55],
[0.0, 0.3, 0.7],
];
for c in colours {
for f in [1.0f32, 0.875, 0.75, 0.5] {
let want = round_trip_v_scale(c, f);
let got = hsv_value_scale(c, f);
for i in 0..3 {
assert!(
(want[i] - got[i]).abs() < 1e-6,
"{c:?} × {f}: HSV round trip {want:?} vs multiply {got:?}"
);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
+3 -1
View File
@@ -418,7 +418,9 @@ fn simulate_drift(
Submersion::Dry => {}
// Slime disables outright, with no re-scatter (`0x680b6f`).
Submersion::Slime => cloud.mode = None,
Submersion::Water => {
// Water and ocean share the drift cloud's whole configuration — the mote cell set is
// keyed on liquid types 0 AND 1 (`[0x86a0a0]`), so the sea drifts exactly like a lake.
Submersion::Water | Submersion::Ocean => {
cloud.scatter(DriftMode::Water);
cloud.last_cam = eye;
}
+3 -2
View File
@@ -98,8 +98,8 @@ mod surface; // the against-real-client-files tests — they span both halves
// terrain cells is a leg that could reach nothing at all and never show it.
pub use query::{
camera_claim, describe_at, liquid_at, player_claim, surfaces_at, unit_claim, water_surface_at,
FoamPatch, LiquidClaim, LiquidHit, LiquidSource, RoomPlacements, Underwater, WaterChunkInfo,
WmoPool,
EyeLiquid, FoamPatch, LiquidClaim, LiquidHit, LiquidSource, RoomPlacements, SubmergedEye,
Underwater, WaterChunkInfo, WmoPool,
};
pub(crate) use spatial::{maintain_water_index, WaterIndex};
pub(crate) use surface::{
@@ -124,6 +124,7 @@ impl Plugin for LiquidPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(MaterialPlugin::<LiquidMaterial>::default())
.init_resource::<Underwater>()
.init_resource::<SubmergedEye>()
.init_resource::<WaterIndex>()
// PreUpdate: surfaces stream in/out via Update-side commands, so the edge is visible
// here the frame after — before any of that frame's consumers ask. A despawn's stale
+82 -5
View File
@@ -810,7 +810,11 @@ const SUBMERSION_EPS: f32 = 0.01;
fn submersion_of(kind: LiquidKind) -> benilla_formats::Submersion {
use benilla_formats::Submersion;
match kind {
LiquidKind::Still | LiquidKind::Rapids | LiquidKind::Ocean => Submersion::Water,
LiquidKind::Still | LiquidKind::Rapids => Submersion::Water,
// Ocean is its own verdict — it alone runs the depth ramp (decision 1829). It can only come
// from ADT MCLQ (`(b & 0x0f) & 3 == 1`); a WMO cannot author it, which is why no interior
// pool ever reaches this arm.
LiquidKind::Ocean => Submersion::Ocean,
LiquidKind::Magma => Submersion::Magma,
LiquidKind::Slime => Submersion::Slime,
}
@@ -852,11 +856,31 @@ fn submersion_of(kind: LiquidKind) -> benilla_formats::Submersion {
/// the defect was never visible to [`liquid_at`] (which answers "the liquid over this XY", lowest
/// wins, whether or not you are under it) but only to *this* rule — "every admitted surface the eye
/// is beneath". A rule no test can call is a rule that drifts from the one being reasoned about.
/// The verdict alone, for the tests that assert on it — a thin delegation to
/// [`submersion_claim_at`], never a second copy of the rule. It restated the filter for about an
/// hour of this change, and that was already one copy too many: the reason the surface height is
/// threaded out of a *single* pass is precisely that two passes can disagree (decision 0701).
#[cfg(test)]
pub(super) fn submersion_at<'a>(
liquids: impl Iterator<Item = &'a WaterChunkInfo>,
wow: [f32; 3],
claim: LiquidClaim,
) -> benilla_formats::Submersion {
submersion_claim_at(liquids, wow, claim).map_or_else(Default::default, |(s, _)| s)
}
/// [`submersion_at`]'s verdict **and the surface it came from** — the same rule, one value wider.
///
/// The surface height is what the reference's own probe keeps beside the verdict (`0x680a87` /
/// `0x680ab0` write the pair), and two consumers need it: the glare's depth fade wants
/// `surface − probe` and the ocean ramp wants to know it is standing on a sea. Deriving it a second
/// time from a second pass over the surfaces would be a rule that could drift from this one — the
/// mistake decision 0701 already paid for once.
pub(super) fn submersion_claim_at<'a>(
liquids: impl Iterator<Item = &'a WaterChunkInfo>,
wow: [f32; 3],
claim: LiquidClaim,
) -> Option<(benilla_formats::Submersion, f32)> {
liquids
.filter(|w| w.answers(claim, wow[2]))
.filter_map(|w| {
@@ -868,10 +892,53 @@ pub(super) fn submersion_at<'a>(
};
(wow[2] < z + eps).then_some((z, submersion_of(w.kind)))
})
// Lowest surface first: `total_cmp` on the surface z, so a tie is still deterministic.
.min_by(|a, b| a.0.total_cmp(&b.0))
.map(|(_, s)| s)
.unwrap_or_default()
.map(|(z, s)| (s, z))
}
/// The camera-eye liquid state as ONE system parameter — the verdict and the two scalars together.
///
/// They are read as a unit because they are written as a unit, by one probe in one frame slot
/// ([`super::SubmersionVerdict`]); binding them separately invites a consumer that takes the depth
/// from this frame and the verdict from the last. It also keeps a caller under Bevy's 16-parameter
/// ceiling, which is what forced the bundle — [`crate::lighting::resolve::update_time_lighting`]
/// reads sixteen other things.
#[derive(bevy::ecs::system::SystemParam)]
pub struct EyeLiquid<'w> {
verdict: Option<Res<'w, Underwater>>,
eye: Option<Res<'w, SubmergedEye>>,
}
impl EyeLiquid<'_> {
/// What the eye is in — [`benilla_formats::Submersion::Dry`] before the probe has ever run.
pub fn submersion(&self) -> benilla_formats::Submersion {
self.verdict.as_ref().map_or_else(Default::default, |v| v.0)
}
/// The eye's absolute world Z — the ocean ramp's raw input.
pub fn eye_z(&self) -> f32 {
self.eye.as_ref().map_or(0.0, |e| e.eye_z)
}
/// How far the probe sits below the liquid surface, in yards; `0.0` when dry.
pub fn depth(&self) -> f32 {
self.eye.as_ref().map_or(0.0, |e| e.depth)
}
}
/// The submerged camera's two scalars, published beside [`Underwater`] for the consumers that need
/// a *distance* rather than a verdict (decision 1829).
#[derive(bevy::prelude::Resource, Default, Clone, Copy)]
pub struct SubmergedEye {
/// `liquidSurfaceHeight − probeZ` in world-Z yards, **positive when submerged** and `0.0` when
/// dry — the quantity the reference writes at `0x680a87`/`0x680ab0`. Drives the glare's 10-yard
/// fade. Not a ray distance and not an occluder term.
pub depth: f32,
/// The camera eye's own absolute world Z. The ocean depth ramp clamps this raw
/// (`t = clamp(eye.z, −30, 0)`) rather than working from [`Self::depth`], because ocean
/// surfaces are pinned to z = 0 by a code branch (`0x69ba85`) rather than by data — so for the
/// one state that runs the ramp the two agree, and the absolute form is what the binary reads.
pub eye_z: f32,
}
/// How far the near rectangle's **lowest corner** sits below the eye (≤ 0, WoW-Z = Bevy-Y yards) —
@@ -896,6 +963,9 @@ pub(super) fn lowest_near_corner_drop(rotation: Quat, fov: f32, aspect: f32, nea
drop
}
// Eight parameters: the probe reads a camera, the loaded surfaces, two scoping resources and a
// clock, then writes the verdict and its two scalars. They are the inputs the rule has.
#[allow(clippy::too_many_arguments)]
pub(super) fn detect_submersion(
mut underwater: ResMut<Underwater>,
camera: Query<(&Transform, &Projection), With<WorldCamera>>,
@@ -903,6 +973,7 @@ pub(super) fn detect_submersion(
eye_claim: Res<crate::wmo_portal::CameraInteriorClaim>,
placements: RoomPlacements,
time: Res<Time>,
mut submerged_eye: ResMut<SubmergedEye>,
mut last_dump: Local<Option<u32>>,
) {
let Ok((cam, proj)) = camera.single() else {
@@ -924,7 +995,13 @@ pub(super) fn detect_submersion(
}
_ => eye[2],
};
underwater.0 = submersion_at(water.iter(), [eye[0], eye[1], probe_z], claim);
let verdict = submersion_claim_at(water.iter(), [eye[0], eye[1], probe_z], claim);
underwater.0 = verdict.map(|(s, _)| s).unwrap_or_default();
// Depth is measured from the PROBE, not the eye — the probe is what decided we are submerged at
// all, so a fade driven off the eye would disagree with the verdict in the inches-tall band the
// near rectangle owns.
submerged_eye.depth = verdict.map_or(0.0, |(_, z)| (z - probe_z).max(0.0));
submerged_eye.eye_z = eye[2];
// `WOW_FOG_DUMP` also explains *this* decision (`frame` drops the 1 Hz throttle, as there). The
// committed fog is whichever submerged atmosphere the verdict names, so a fog line alone cannot
// say whether an atmosphere that reads wrong is the wrong record or the right record never
+99 -17
View File
@@ -25,10 +25,24 @@
@group(0) @binding(1) var in_samp: sampler;
// Combine-pass extras (unused — and unbound — in the downsample/blur passes).
@group(0) @binding(2) var blur_tex: texture_2d<f32>;
// x = the zone glow weight `w` (BOTH combines' blur² weight — the death pack reuses it as
// primary.w); y = the FFXDeath gate (1 while ghost, else 0 — byte-verified instant, no ramp);
// zw reserved.
@group(0) @binding(3) var<uniform> glow: vec4<f32>;
struct FfxCombine {
// x = the zone glow weight `w` (BOTH combines' blur² weight — the death pack reuses it as
// primary.w); y = the FFXDeath gate (1 while ghost, else 0 — byte-verified instant, no ramp);
// z = the drunk/underwater haze mix; w = the deband-dither arm.
lane: vec4<f32>,
// xy = the GlowWave phases `(t mod 3174)/3174` and `(t mod 2805)/2805`; zw unused. Written on
// every frame, read only by `fs_combine_wave`.
wave: vec4<f32>,
}
@group(0) @binding(3) var<uniform> ffx: FfxCombine;
// The 128×128 wave LUT and its own REPEAT sampler — bound on EVERY combine, sampled only by the
// underwater entry. A bound-but-unsampled texture costs a dry frame nothing, and one layout keeps
// the two entries interchangeable at draw time (the alternative, a second layout, would make the
// pipeline swap a bind-group swap too).
@group(0) @binding(4) var wave_tex: texture_2d<f32>;
@group(0) @binding(5) var wave_samp: sampler;
// The reference's ONE downsample: full → ¼ directly, via Box4 — 4 bilinear taps at source-texel
// offsets {−1.5, +0.5}² (a 4×4 footprint; byte-pinned table 0xce89cc, wow-re blur-geometry.md).
@@ -85,8 +99,8 @@ fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
// out = luma + primary.rgb · sat(4·luma·(1−luma))
// The CPU pack `0x6cb930` builds primary as `(LightParams.glow·255)<<24 | 0x5393A8`: rgb is the
// CONSTANT steel blue-gray 0x53/0x93/0xA8, and primary.w (the blur² weight) is the same zone glow
// the glow pass uses — so glow.x serves both combines and glow.y is a pure 0/1 gate.
// **Deband dither** (`glow.w`, off by default — see `ffx_glow.rs`). The frame's ONE quantization
// the glow pass uses — so ffx.lane.x serves both combines and ffx.lane.y is a pure 0/1 gate.
// **Deband dither** (`ffx.lane.w`, off by default — see `ffx_glow.rs`). The frame's ONE quantization
// to 8 bits happens on `outg` below: it is a gamma-space float, and the surface's sRGB
// present-encode rounds it to a byte. A smooth surface therefore steps in 1/255, and at the
// gradients a lit character actually has (~1.3 levels/px on a bare arm) a body drifting the
@@ -114,43 +128,111 @@ fn screen_space_dither(frag_coord: vec2<f32>) -> vec3<f32> {
// rounds in — then the frame's single decode.
fn combine_out(outg: vec3<f32>, alpha: f32, frag_coord: vec2<f32>) -> vec4<f32> {
let dithered = clamp(
outg + screen_space_dither(frag_coord) * step(0.5, glow.w),
outg + screen_space_dither(frag_coord) * step(0.5, ffx.lane.w),
vec3<f32>(0.0),
vec3<f32>(1.0),
);
return vec4<f32>(srgb_to_linear(dithered), alpha);
}
@fragment
fn fs_combine(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
// The combine both entries run — the dry one at the fragment's own UV, the underwater one at the
// warped UV. `frag_coord` stays UNwarped: it seeds the deband dither, which is a property of the
// destination pixel, not of where the scene was read from.
fn combine_body(uv: vec2<f32>, frag_coord: vec2<f32>) -> vec4<f32> {
// GAMMA LANE (0161): the whole frame is already gamma bytes — the blur ran on gamma (like
// the reference's byte RTs) and the combine is raw byte math. The single srgb_to_linear here
// is THE frame's one decode: the sRGB present-encode then restores every byte exactly.
let scene = textureSample(in_tex, in_samp, in.uv);
let scene = textureSample(in_tex, in_samp, uv);
// Clamped to [0, 1] like the downsample's taps: the byte framebuffer this term stands for
// could never exceed 255, and an unclamped super-white here keeps the whole additive stack
// hard-white after the min() below — the screen term must saturate BEFORE the glow add.
let sg = clamp(scene.rgb, vec3<f32>(0.0), vec3<f32>(1.0));
let bg = max(textureSample(blur_tex, in_samp, in.uv).rgb, vec3<f32>(0.0));
let bg = max(textureSample(blur_tex, in_samp, uv).rgb, vec3<f32>(0.0));
// The scene alpha rides through untouched: the window surface ignores it and the opaque-clear
// booths hold 1 everywhere — only the transparent-clear create booth reads it, to composite
// its baked model straight over the UI page.
if glow.y > 0.0 {
if ffx.lane.y > 0.0 {
// The FFXDeath pack has no z lane (its COLOR rgb is the constant tint) — the ghost
// combine reads the un-hazed screen.
let glowed = sg + glow.x * bg * bg;
let glowed = sg + ffx.lane.x * bg * bg;
let luma = clamp(dot(glowed, vec3<f32>(0.299, 0.587, 0.144)), 0.0, 1.0);
let p = clamp(4.0 * luma * (1.0 - luma), 0.0, 1.0);
let ghost_tint = vec3<f32>(83.0, 147.0, 168.0) / 255.0; // 0x5393A8
let outg = min(vec3<f32>(luma) + ghost_tint * p, vec3<f32>(1.0));
return combine_out(outg, scene.a, in.position.xy);
return combine_out(outg, scene.a, frag_coord);
}
// The z lane — the drunk/underwater haze: cross-fade the screen toward the ¼-res blur
// BEFORE the glow add (`out = lerp(screen, blur, z) + w·blur²`, the shipped FFXGlow.bls;
// wow-re drunk-blur-z.md). z = max(drunkFraction, 84/255 while the eye is submerged) — 0 in
// the ordinary sober pass, 1 = fully blurred at 100 inebriation. The blur term clamps like
// the screen term: it stands for the same byte RT.
let hazed = mix(sg, min(bg, vec3<f32>(1.0)), glow.z);
let outg = min(hazed + glow.x * bg * bg, vec3<f32>(1.0));
return combine_out(outg, scene.a, in.position.xy);
let hazed = mix(sg, min(bg, vec3<f32>(1.0)), ffx.lane.z);
let outg = min(hazed + ffx.lane.x * bg * bg, vec3<f32>(1.0));
return combine_out(outg, scene.a, frag_coord);
}
@fragment
fn fs_combine(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
return combine_body(in.uv, in.position.xy);
}
// **FFXGlowWave — the underwater screen warp.** (wow-re `ffxeffects/scratch/glow-wave-underwater.md`,
// §5 cross-checked; benilla decision 1824.)
//
// While the camera eye is in ANY liquid the reference does not run the combine above at all:
// `CFFXGlow::Render 0x6cc630` walks a *second* pass list, whose third pass is **FFXGlowWave**
// (ctor `0x6cb1f0`, vtable `0x8114b4`, render `0x6cb310`) in place of FFXGlow. wow-re's note had
// long carried that swap as an undecided "GlowWave, *or* a duplicate Glow"; the arms are mutually
// exclusive (`ret` at `0x6cc403`, `push ebx` at `0x6cc404`) and the plain-Glow arm is unreachable
// on both backends, so underwater is ALWAYS the wave.
//
// The two passes are instruction-identical except that GlowWave binds a third texture — a 128×128
// two-channel bump map holding `du = sin(2πx/128)`, `dv = sin(2πy/128)` — and **displaces both the
// screen and the blur texcoords by it**. Everything downstream (the haze cross-fade, the blur²
// add, the gamma lane) is unchanged, which is why both entries share [`combine_body`]: the warp is
// a change of *where* the combine reads, not of what it computes.
//
// The wave texcoord is an affine map of the screen UV. The reference builds it CPU-side per vertex
// (`0x7bca80`) and hands the GPU three interpolated texcoord streams; an affine function of an
// interpolated UV is the interpolation of that affine function, so evaluating it per fragment here
// is the same map, not an approximation of it:
//
// uv' = uv · R(10°) · S(W/128, 0.88·H/128) · T(p1, p2)
//
// with the two phases running INDEPENDENTLY off one millisecond clock — `(t mod 3174)/3174` and
// `(t mod 2805)/2805`, i.e. 3.174 s and 2.805 s, rejoining only every ~49 minutes. The scale
// constants multiply the destination's pixel dimensions (a `rewave.md` gloss had them multiplying
// the phases; corrected at the bytes), so the wavelength is 128 px across and 145.45 px down
// REGARDLESS of resolution, and the pattern is screen-fixed rather than world-locked.
//
// Amplitude is **3.0 full-res pixels** on both samples — the reference reaches that number twice
// over, `(3.0, 0, 0, 3.0)` texels of the full RT and `0.75` texels of the ¼ RT being the same
// distance, which is the cross-check that the blur sample warps by the same amount as the screen.
const WAVE_ROT_RAD: f32 = 0.174532925; // 10°, the reference's rotation row
const WAVE_LUT_EDGE: f32 = 128.0; // the LUT is 128×128 texels ([0xce89a0]'s 7-field descriptor)
const WAVE_V_SCALE: f32 = 0.88; // [0xce89c0] — the v axis alone carries it; u's twin is 1.0
const WAVE_AMPLITUDE_PX: f32 = 3.0; // full-res pixels, both samples
// The screen-UV offset the warp applies, in UV units. `screen` is the full-res scene in pixels.
fn wave_offset(uv: vec2<f32>, screen: vec2<f32>) -> vec2<f32> {
let c = cos(WAVE_ROT_RAD);
let s = sin(WAVE_ROT_RAD);
let scale = vec2<f32>(screen.x, WAVE_V_SCALE * screen.y) / WAVE_LUT_EDGE;
let tex = vec2<f32>(c * uv.x - s * uv.y, s * uv.x + c * uv.y) * scale + ffx.wave.xy;
// SAMPLED, not evaluated: 128 texels of `sin` under LINEAR/REPEAT *is* the reference's sine —
// a piecewise-linear one, and its own. REPEAT is load-bearing and was the round's costliest
// near-miss: read as CLAMP, the effect dies over 90% of the screen (the scale runs the
// texcoord to 10 cycles across, so all but the first would pin to the edge texel).
//
// The 8-bit pack is biased back exactly as the shipped ps_2_0 permutation does — that blob
// carries `def c2 = (−0.5, 1.0, …)` and two extra instructions spelling `(x − 0.5)·2`, because
// the caps tier the live client selects hands it an UNSIGNED texture.
let d = (textureSample(wave_tex, wave_samp, tex).rg - vec2<f32>(0.5)) * 2.0;
return d * WAVE_AMPLITUDE_PX / screen;
}
@fragment
fn fs_combine_wave(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
let screen = vec2<f32>(textureDimensions(in_tex));
return combine_body(in.uv + wave_offset(in.uv, screen), in.position.xy);
}
+70 -2
View File
@@ -255,6 +255,12 @@ pub(super) struct FlareGate<'w, 's> {
/// ring (the same surface that draws the distant horizon mountains). Absent in assetless dev.
wdl: Option<Res<'w, WdlStreamer>>,
camera_interior: Res<'w, CameraInteriorClaim>,
/// The submerged-eye depth — the glare's own submersion fade (decision 1829). The glare is
/// NOT part of the sky pass the client skips underwater: it draws in its own top-level pass
/// (`0x6cf490` ← `0x6d48c0` ← `0x483740`, unconditional), while the sky is `0x6d4940` *inside*
/// `0x681070`, which is what `0x6812a4` skips. Both facts are true at once, which is why the
/// fade is live code and not a ramp on a pass that never runs.
submerged: Res<'w, crate::liquid::SubmergedEye>,
/// The live cloud coverage field — the occ1 sample source (one field serves the glare and
/// the visible layer, like the reference).
clouds: Res<'w, CloudCoverage>,
@@ -268,6 +274,26 @@ pub(super) struct FlareGate<'w, 's> {
primed: Local<'s, bool>,
}
/// The glare's **submersion fade** — `1 − clamp(depth × 0.1, 0, 1)`, a 10-yard linear ramp on the
/// glare alpha (wow-re `submerged-consumer-census.md`; decision 1829). `depth` is
/// `liquidSurfaceHeight − probeZ` in world-Z yards, positive when submerged, so a dry camera reads
/// `0` and the fade is the exact identity.
///
/// **This was a published negative until this round.** Two wow-re notes recorded the glare as
/// having no submersion term; the read is one indirection out (`0x6cf4fe call [edx+0x10]` → the
/// glare vtable's slot 4 → `0x6cf800`), which a direct-call census cannot see. It is worth
/// implementing rather than filing as trivia: the glare is additive, so a black underwater
/// celestial band would make it invisible anyway — and **288 of 374 `Light.dbc` rows (77%) carry a
/// non-zero one**, so on most of the map there is a glare down there to fade.
///
/// Applies to the **sun and moon glares alike** (`0x6d48cf` / `0x6d48ea`; slot 4 of both glare
/// vtables is `0x6cf800`, and they differ only at slot 3) and to **neither disc** — the discs have
/// no submerged path at all, so suppressing them outright with the skipped sky pass is right. Both
/// confirmed by census rather than assumed.
fn submersion_glare_fade(depth: f32) -> f32 {
1.0 - (depth * 0.1).clamp(0.0, 1.0)
}
/// The weather **celestial-alpha seed** (Addendum #6): under active weather the recompute writes
/// `floor(255·(1−bcc))` over the five body colour alpha bytes (sun disc, sun glare, white-moon
/// disc, moon glare, moon02) — gated `bcc > 0`, so clear weather leaves the per-frame broadcast
@@ -281,7 +307,22 @@ impl FlareGate<'_, '_> {
/// The slewed `[0, 1]` flare envelope along `dir` from the camera: slew target = `dn` (the
/// body's dnCurve sample) × the horizon smoothstep × `occ1` (the body's cloud occlusion) ×
/// the visible fraction of the body's quad (`half` = its angular half-size; the interior
/// claim zeroes it), approached at `rise`/[`FLARE_FALL`] per second.
/// claim zeroes it) and the **submersion fade** ([`submersion_glare_fade`]), all approached at
/// `rise`/[`FLARE_FALL`] per second.
///
/// The fade is INSIDE the slew — one of the factors composed into the target, not a multiply on
/// the drawn alpha. `[glare+0x34]` is the per-frame target accumulator (reset to 1.0 at
/// `0x6cf499`); `0x6cf501`/`0x6cf504` multiply the fade into it, and the limiter first reads it
/// 158 bytes later at `0x6cf59b`, writing the envelope `[glare+0x30]` at `0x6cf5ea`. So
/// surfacing ramps the glare back in over the rise time rather than snapping.
///
/// We shipped it the other way first, on the reasoning that a rate limiter should smooth
/// *visibility* and depth is not visibility. That reasoning was fine and the fact was against
/// it — wow-re's summary said the fade goes "into the glare alpha", which is the drawn byte
/// `[glare+0x1b]` and is not where it goes. Asked, and corrected at the bytes.
///
/// It falls out that a deep camera (`fade == 0`) drives `base` to zero, which skips the terrain
/// ray march entirely — the reference's own `0x6cf58c` shortcut, for free.
fn envelope(
&mut self,
cam_pos: Vec3,
@@ -291,7 +332,7 @@ impl FlareGate<'_, '_> {
half: f32,
rise: f32,
) -> f32 {
let base = dn * horizon_gate(dir) * occ1;
let base = dn * horizon_gate(dir) * occ1 * submersion_glare_fade(self.submerged.depth);
let target = if base > 0.0 && self.camera_interior.0.is_none() {
// Resident detailed terrain first; the coarse WDL surface everywhere else (it
// covers the whole map, so it also plugs the ADT-ring-to-farclip gap).
@@ -645,6 +686,30 @@ pub(super) fn follow_stars(
}
}
#[cfg(test)]
mod glare_fade_tests {
use super::submersion_glare_fade;
/// The 10-yard linear ramp, and — the half that matters most — the exact identity when dry.
/// A dry camera reads `depth = 0`, so this multiplies every glare in the game by 1.0; if that
/// were ever not exact, the feature would be a global dimmer rather than an underwater one.
#[test]
fn the_glare_fades_linearly_over_ten_yards() {
assert_eq!(submersion_glare_fade(0.0), 1.0, "dry is the exact identity");
assert_eq!(submersion_glare_fade(5.0), 0.5);
assert_eq!(submersion_glare_fade(10.0), 0.0);
assert_eq!(submersion_glare_fade(200.0), 0.0, "clamped, never negative");
// Linear in between, and monotone all the way down.
let mut prev = 1.0;
for i in 1..=10 {
let f = submersion_glare_fade(i as f32);
assert!(f <= prev && f >= 0.0);
assert!((f - (1.0 - i as f32 * 0.1)).abs() < 1e-6);
prev = f;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -668,6 +733,9 @@ mod tests {
app.init_resource::<CameraInteriorClaim>();
app.init_resource::<CloudCoverage>();
app.init_resource::<DebugState>();
// Dry (depth 0) — this fixture is about the glare tracking the camera within one frame,
// and the submersion fade is the exact identity there, so it cannot perturb the assertion.
app.init_resource::<crate::liquid::SubmergedEye>();
app.insert_resource(WowLighting {
moon_dir_white: dir,
moon_disc_scale: 1.0,
+1
View File
@@ -94,6 +94,7 @@ pub struct WorldPoint<'w, 's> {
/// else answers.
pub fn init_world_point_resources(world: &mut bevy::prelude::World) {
world.init_resource::<Underwater>();
world.init_resource::<crate::liquid::SubmergedEye>();
world.init_resource::<PlayerWmoRoom>();
world.init_resource::<CameraInteriorClaim>();
world.init_resource::<bevy::prelude::Assets<benilla_assets::WmoModel>>();