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:
@@ -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
@@ -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())
|
||||
|
||||
@@ -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=>`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>>();
|
||||
|
||||
Reference in New Issue
Block a user