still frames skip settled work, and a guild can design its tabard

- on a still frame the world stops redoing settled work — each cut an exact gate, measured with the process pinned to the efficiency cores, where the main thread IS the frame: point lookups go through the water index, a terrain column tests its cell's four fan triangles, not the chunk's 256 (−1.3 ms), the flare march resolves its tile once, the static-world publish and light blob write only on change, billboards, emitter gates (off the new-portal edge), doodad-anim verdicts, the visibility walk, the unit publisher (its filter a named type), a rampless unit's alpha and the stable feed skip when nothing moved, the rig evaluator reuses its scratch, dormant emitters skip their step, child wiring visits pending emitters only, the shade map persists, and the terrain streamer skips its walk once settled; alone parked 25.4 → 21.6–22.8 ms of main thread, 32 → 36–38 fps there with nothing on screen (1979, 1982)
- the UI tick got the same treatment: a solo run's colour rides the material, not the mesh — the glow pulse's one `Assets<Mesh>` write a frame had armed bevy's asset-changed probes over ~44 k `Mesh3d` rows (−1.0 ms); script object ids index a dense map instead of a hash, where every scripted method call starts (−0.7 ms); the draw list is fingerprint-cached, walked every frame and sorted only when it changed (the list a named type); and the instrument that made the gates count at all: the first battery read flat until the probe printed how many frames each gate input had read as changed — `noisy=[cam:0,dbg:299,view:0,win:0,claim:0]` — one dev system took `&mut` on `DebugState` every frame to test a flag, so bevy marked it changed on 299 of 300 frames and silently disarmed every gate reading `debug.is_changed()`; it reads the flag first now, and the counters stay on the probe line (1982)
- two of the new gates were wrong the day they landed and are fixed in the same snapshot: the streamer's settled skip engaged the frame `complete` flipped, with the retained pass still baking the new cells, and froze the undrawn count `presentable()` reads above zero — the loading screen stayed up for good after a `.tele`; it now waits for a paced, presentable, complete world and keeps that count fresh; and the still-frame gates read the camera's `GlobalTransform`, which moves only after propagation — which runs after them — so a teleport frame carried every host's pre-snap verdict across the snap (a lamppost glow due to park kept its clock, over-bright after the `.tele`); the gates read the seat's own `Transform` write too (1982)
- a guild master can design the guild's tabard: the reference's own `TabardFrame.xml` joins the manifest (benilla never had the window), opened only from the wire — the click ladder's TabardDesigner arm now sends `MSG_TABARDVENDOR_ACTIVATE` and the Battlemaster arm its own hello (1861's two deviations retire); the `TabardModel` method table runs over five ints bounded by the `.rdata` table `{170, 17, 6, 17, 51}`, the design previews on the player's own body so the pane and the world show one look, and `Save` runs the fourteen pre-flight checks in the reference's order; the world frame is the reference's own `WorldFrame.xml` too — the framerate readout is live on CTRL-R — and the carve settled its kind: a `Frame` to Lua, a one-shot type, the sole tenant of stratum WORLD, and not "over the UI" when hovered, so world clicks and camera look stay live while its scripts fire (1977, 1983, 1984)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-04 21:00:42 +07:00
co-authored by Claude Fable 5.1
parent e7ac8b94f3
commit e800974806
76 changed files with 2581 additions and 398 deletions
@@ -1,121 +0,0 @@
<Ui>
<!--
Print screen's UI half (decision 1487) — the centred "Screen Captured" line, and the
`TakeScreenshot()` the SCREENSHOT binding runs.
WHAT THE REFERENCE DOES, and why this file's ordering is the whole feature. Its
Bindings.xml maps SCREENSHOT to `TakeScreenshot()`; that function hides this frame, THEN
asks the engine to capture; the engine answers later with SCREENSHOT_SUCCEEDED or
SCREENSHOT_FAILED, and only then does the text appear. So the message can never be in the
picture, on either path:
· the ordinary press — the outcome event lands frames after the readback, because the
readback is asynchronous (`crate::screenshot`'s writer reports through a channel);
· the double press inside the 1.5 s fade — the PREVIOUS shot's text is still on screen,
and the Hide() below is what takes it off before the shutter.
That second case is the one worth naming: it is the only way a "Screen Captured" could ever
have reached a file, and it is why `TakeScreenshot` hides rather than just letting the fade
run out. B261 asked for exactly this contract.
PROVENANCE. The mechanism, the globals' names, the 1.5 s fade and the two strings are the
reference's (its WorldFrame.xml/.lua carry them; the strings are GlobalStrings.lua l.3395-6,
quoted as data like every other user-visible byte in this tree). The code is ours: the
reference recomputes elapsed from `GetTime() - this.startTime` on every tick and ignores the
elapsed its OnUpdate is handed — we accumulate the handed value instead, which is one clock
read fewer per frame and one field fewer on the frame.
SEATING. `parent="WorldFrame" setAllPoints="true"` with a CENTER-anchored string — the
reference's own nesting (WorldFrame.xml l.42, inside WorldFrame's `<Frames>`), and it is
load-bearing rather than decorative. That file's header states the law in one line:
"Children of the world frame are visible even when the UI is turned off." benilla's
WorldFrame is a stub (UIParent.xml) rather than the 3D layer, but it is a stub with the
property that matters here — top-level, so nothing hides it.
This read `parent="UIParent"` until 1757, on a note that argued the two seats are the same
because both frames are full-screen and their CENTERs coincide. That was true of the
GEOMETRY and blind to the rest: decision 1734 restored `SetFullScreenFrame`'s
`UIParent:Hide()`, so under a fullscreen panel the UIParent seat is a hidden one — and the
case that exposes it is this frame's own, because `CinematicFrame` answers the SCREENSHOT
key by hand (`RunBinding`, 1701) precisely so a fly-by can be captured, and the
confirmation for that capture could never appear. TOOLTIP strata is the reference's own —
the confirmation has to be readable over whatever panel is open, and an explicit strata is
not dragged down by a BACKGROUND-strata parent.
-->
<Script>
<![CDATA[
-- The two lines this frame can show, byte-exact from the 1.12 enUS GlobalStrings
-- (interface.MPQ `GlobalStrings.lua`, line-cited) — user-visible text, so its bytes are the
-- contract. Note the capital C: it is "Screen Captured", not "Screen captured".
SCREENSHOT_SUCCESS = "Screen Captured"; -- GlobalStrings.lua:3396
SCREENSHOT_FAILURE = "Screen Capture Failed";-- GlobalStrings.lua:3395
-- Seconds the line takes to fade from full to gone. The reference's own value, and a
-- GLOBAL there too — an addon may read or retune it, so it keeps the name.
SCREENSHOT_STATUS_FADETIME = 1.5;
-- The SCREENSHOT binding's body. Hide first, capture second: see the header.
function TakeScreenshot()
if ( ScreenshotStatus:IsVisible() ) then
ScreenshotStatus:Hide();
end
Screenshot();
end
function ScreenshotStatus_OnLoad()
this:RegisterEvent("SCREENSHOT_SUCCEEDED");
this:RegisterEvent("SCREENSHOT_FAILED");
end
function ScreenshotStatus_OnEvent(event)
if ( event == "SCREENSHOT_FAILED" ) then
ScreenshotStatusText:SetText(SCREENSHOT_FAILURE);
else
ScreenshotStatusText:SetText(SCREENSHOT_SUCCESS);
end
-- The fade restarts from full on every outcome, so a second shot re-announces itself
-- at full strength instead of inheriting whatever alpha the last one had faded to.
this.fade = 0;
this:SetAlpha(1.0);
this:Show();
end
function ScreenshotStatus_OnUpdate(elapsed)
local fade = (this.fade or 0) + (elapsed or 0);
this.fade = fade;
if ( fade >= SCREENSHOT_STATUS_FADETIME ) then
this:Hide();
return;
end
this:SetAlpha(1.0 - fade / SCREENSHOT_STATUS_FADETIME);
end
]]>
</Script>
<Frame name="ScreenshotStatus" parent="WorldFrame" setAllPoints="true" frameStrata="TOOLTIP" hidden="true">
<Layers>
<Layer level="ARTWORK">
<FontString name="ScreenshotStatusText" inherits="SystemFont">
<Anchors>
<Anchor point="CENTER" relativeTo="UIParent">
<Offset>
<AbsDimension x="0" y="0"/>
</Offset>
</Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnLoad>
ScreenshotStatus_OnLoad();
</OnLoad>
<OnEvent>
ScreenshotStatus_OnEvent(event);
</OnEvent>
<OnUpdate>
ScreenshotStatus_OnUpdate(arg1);
</OnUpdate>
</Scripts>
</Frame>
</Ui>
+2 -22
View File
@@ -98,28 +98,8 @@
</Scripts>
</Frame>
<!--
WorldFrame — the reference's full-screen, bottom-of-the-strata frame that the 3D world
renders into. **Ours renders nothing**: benilla draws the world through Bevy's render
graph, not through a FrameScript frame, so this is a NAMED HANDLE and nothing more. That
is a disclosed divergence, not a pretence — and it is what makes the two things addons
actually do with it work: `WorldFrame:GetWidth()`/`GetHeight()` (the screen rect, which
this frame really has) and `frame:SetParent(WorldFrame)` / `SetPoint(..., WorldFrame,
...)` (parking a tooltip or an overlay under everything). Measured demand: 29 of the 218
vanilla-corpus addons die on their first load with `attempt to index global 'WorldFrame'`
without it (the corpus harness, `benilla_app::addon_harness`).
**No `enableMouse`, deliberately** — the same reason UIParent above ships without it, and
here it is load-bearing rather than incidental. benilla's `PointerOverUi` is "is the
topmost mouse-enabled frame under the cursor a UI frame", and it gates camera look, world
clicks and hover targeting (`player/camera.rs`, `target/hover.rs`); a full-screen
mouse-enabled plate would answer yes for every world pixel and kill all three. The
reference gets away with it because *its* WorldFrame IS the world, so a click landing
there is a world click by construction. Reproducing that here means teaching the arbiter
that a WorldFrame hit is not a UI hit — engine work with its own tests, not a rename — so
until then the addon-facing `OnMouseDown`/`OnMouseUp` hooks on this frame never fire.
-->
<Frame name="WorldFrame" setAllPoints="true" frameStrata="BACKGROUND"/>
<!-- WorldFrame is the reference's own file now (`Interface\FrameXML\WorldFrame.xml`, decision
1983), loaded right above this one as its toc orders them; its kind is the engine's. -->
<!--
The UIParent bottom-stack position manager - UIPARENT_MANAGED_FRAME_POSITIONS and
@@ -37,6 +37,7 @@
-- 1673, and Petition + GuildRegistrar with 1672 — all of them are rows below.
UIPanelWindows = {}
UIPanelWindows["GossipFrame"] = { area = "left", pushable = 0 }
UIPanelWindows["TabardFrame"] = { area = "left", pushable = 0 }
UIPanelWindows["MerchantFrame"] = { area = "left", pushable = 0 }
-- The item-text reader (decision 0572, ItemTextFrame.xml): the ref's own l.20 verbatim
-- (`UIPanelWindows["ItemTextFrame"] = { area = "left", pushable = 0 }`). The missing row
+17 -5
View File
@@ -115,6 +115,15 @@ Interface\FrameXML\BasicControls.xml
# (UIPARENT_MANAGED_FRAME_POSITIONS + UIParent_ManageFramePositions, decision 0272, the ref
# mechanism that re-anchors the cast bar / chat over the bottom bars) are called later, applied by
# the loader's post-load bootstrap (ui_script/manifest.rs).
# ══ THE WORLD FRAME — the reference's own, off the player's chain (1751; decision 1983) ══
# WorldFrame.xml + WorldFrame.lua: the <WorldFrame> singleton (the engine's own kind, mouse-enabled
# by construction, its hits the world's), the framerate readout ToggleFramerate/TOGGLEFPS drives
# off GetFramerate, the OnUpdate that ticks StaticPopup and MirrorTimer frames while the UI is
# hidden, and the ScreenshotStatus child ("Screen Captured" over SCREENSHOT_SUCCEEDED/FAILED,
# the 1.5 s GetTime fade, TakeScreenshot for the SCREENSHOT binding). Right above UIParent.xml as
# the reference's toc has it (l.7-8). Needs Fonts.xml's SystemFont above; everything else it
# names is call-time.
Interface\FrameXML\WorldFrame.xml
UIParent.xml
# ══ THE ITEM-SLOT BASE — the reference's own, off the player's chain (1751, window 24) ══
# `ItemButtonTemplate` and the eight `SetItemButton*` verbs out of `ItemButtonTemplate.lua`. It
@@ -475,11 +484,6 @@ Interface\FrameXML\Minimap.xml
# sourced at the top of this manifest and `UIParent_OnEvent`'s VARIABLES_LOADED arm calls it. The
# value is the PLAYER's locale rather than our assumption about it.
Interface\FrameXML\GameTime.xml
# Print screen's UI half (decision 1487): the centred "Screen Captured"/"Screen Capture Failed"
# line, and the `TakeScreenshot()` the SCREENSHOT binding runs. Needs Fonts.xml (SystemFont) and
# UIParent.xml (its seat) — both far above — and nothing else; seated beside ZoneText.xml, whose
# AutoFollowStatus is the same full-screen-frame-with-a-centred-line shape.
ScreenshotStatus.xml
# ══ THE ZONE SPLASH — the reference's own, off the player's chain (1751, window 8) ══
# Two files, in the reference's own order and for its own reason.
#
@@ -1115,6 +1119,14 @@ Interface\FrameXML\CinematicFrame.xml
# engine — the two flag banks, the fire-once trigger, the four verbs — is `crate::tutorial`.
# Needs UIPanelTemplates (UICheckButtonTemplate), BasicControls (the dialog button textures),
# GameTooltip, and UiPanels' SetButtonPulse, all far above.
# ══ THE TABARD DESIGNER — the reference's own, off the player's chain (1751; decision 1977) ══
# TabardFrame.xml + TabardFrame.lua: the <TabardModel> pane (its ten methods are the engine's,
# `script/tabard.rs`), the five customization rows over CycleVariation, the emblem cells the two
# texture setters paint, Save/CanSaveTabardNow and CloseTabardCreation; opened only by the wire
# (`crate::ui_tabard`). Right before TutorialFrame.xml as the reference's toc has it (l.79-80).
# Needs UIPanelTemplates (UIPanelButtonTemplate, UIPanelCloseButton), MoneyFrame
# (SmallMoneyFrameTemplate), GameTooltip and UiPanels, all far above.
Interface\FrameXML\TabardFrame.xml
Interface\FrameXML\TutorialFrame.xml
# The game menu (decision 0674): the frame ESC opens. LAST on purpose — it is the outermost
# layer of the shell, it depends on everything it touches being loaded (UiPanels' popup engine
+10 -8
View File
@@ -1156,6 +1156,16 @@ const TABLE: &[Spec] = &[
//
// On a Mac keyboard the token arrives as F13, which is the reference's own Mac mapping rather
// than an accommodation (`KEY_PRINTSCREEN_MAC = "F13"`); `super::chord` does the translation.
// The stock WorldFrame.lua's framerate readout (decision 1983): `FramerateLabel`/`FramerateText`
// toggled by `ToggleFramerate()`, refreshed off `GetFramerate()` every quarter second by
// `WorldFrame_OnUpdate`. CTRL-R is the install's own DefaultBindings.wtf chord (1804).
spec!(
"TOGGLEFPS",
MISC,
Kind::Edge("ToggleFramerate();"),
Some("CTRL-R"),
None
),
spec!(
"SCREENSHOT",
MISC,
@@ -1848,14 +1858,6 @@ pub(crate) static ABSENT: &[Absent] = &[
// ── Action bar ──────────────────────────────────────────────────────────────────────
// ── Interface ───────────────────────────────────────────────────────────────────────
// ── Misc ────────────────────────────────────────────────────────────────────────────
absent!(
"TOGGLEFPS",
["ToggleFramerate"],
"the only framerate readout here is the dev HUD's cost pill, which is behind \
`#[cfg(feature = \"dev\")]` and is an instrument, not a player display (perf/hud.rs) — \
there is no player-facing framerate to toggle. (0997 recorded this as \"the perf pill is \
always-on by design\"; the pill is not the thing TOGGLEFPS toggles.)"
),
// The nine `hidden="true" debug="true"` rows. Every one of them names an instrument benilla
// really has (the tri counter, the collision display, the portal draw, the perf pill) — they
// are absent because those instruments answer to the dev plane's chords (0702/1043), not to a
@@ -59,6 +59,7 @@ impl Plugin for LiveFpsPlugin {
paced_samples: Vec::new(),
threads_at_start: None,
faults_at_start: None,
still_at_start: [0; 5],
cpu_at_start: None,
sys_at_start: None,
occluded_now: false,
@@ -81,6 +82,12 @@ enum LiveFpsPhase {
}
/// [`LiveFpsPlugin`] state.
/// The still-frame input counters (`benilla_world::dev_state::STILL_INPUTS_CHANGED`), now.
fn still_inputs_now() -> [u32; 5] {
let c = &benilla_world::dev_state::STILL_INPUTS_CHANGED;
std::array::from_fn(|i| c[i].load(std::sync::atomic::Ordering::Relaxed))
}
#[derive(Resource)]
struct LiveFps {
frames: usize,
@@ -115,6 +122,8 @@ struct LiveFps {
threads_at_start: Option<Vec<(usize, String, f64, f64)>>,
/// The page-fault counters at the window's first frame (`perf::process_faults`).
faults_at_start: Option<(u64, u64)>,
/// `dev_state::STILL_INPUTS_CHANGED` at the window's first frame — `noisy=` is the delta.
still_at_start: [u32; 5],
/// Process CPU seconds at the first sampled frame ([`crate::perf::process_cpu_secs`]) — the
/// baseline for the window's `cpu_ms`/`cpu_pct`.
cpu_at_start: Option<f64>,
@@ -306,6 +315,7 @@ fn drive_live_fps(
probe.cpu_at_start = crate::perf::process_cpu_secs();
probe.threads_at_start = crate::perf::thread_cpu_table();
probe.faults_at_start = crate::perf::process_faults();
probe.still_at_start = still_inputs_now();
main_split.restart();
probe.sys_at_start = crate::perf::system_cpu_ticks();
// The churn census restarts with the window — warmup noise (streaming, shader
@@ -516,15 +526,26 @@ fn drive_live_fps(
.inside_ms()
.map(|m| format!(" main_in={m:.2}"))
.unwrap_or_default();
let now_still = still_inputs_now();
let noisy: Vec<u32> = now_still
.iter()
.zip(probe.still_at_start)
.map(|(n, s)| n.wrapping_sub(s))
.collect();
// Page faults per frame beside the split: `sys` time with no
// syscall under it is the kernel zero-filling pages the allocator
// gave back and asked for again.
let faults =
match (probe.faults_at_start, crate::perf::process_faults()) {
(Some((mi0, ma0)), Some((mi1, ma1))) => format!(
" faults={:.0}/{:.1}",
" faults={:.0}/{:.1} noisy=[cam:{},dbg:{},view:{},win:{},claim:{}]",
(mi1 - mi0) as f64 / v_len as f64,
(ma1 - ma0) as f64 / v_len as f64
(ma1 - ma0) as f64 / v_len as f64,
noisy[0],
noisy[1],
noisy[2],
noisy[3],
noisy[4],
),
_ => String::new(),
};
+20 -3
View File
@@ -248,12 +248,29 @@ impl Plugin for DebugPanelPlugin {
/// here — fonts only initialize inside a real begin-pass). `run` is the initialization path.
fn feed_gated_egui_output(
mut contexts: Query<(&mut EguiContext, &mut EguiFullOutput, &EguiContextSettings)>,
// The empty pass's output after its first run, with the one-time texture delta (the font
// atlas) already delivered: every later gated frame feeds this clone instead of running a
// real begin/end pass — memory GC, a fresh `FullOutput`, a tessellation of nothing —
// 1.4 % of a parked frame's main thread for a panel that is closed (decision 1979).
mut cached: Local<Option<egui::FullOutput>>,
) {
for (mut ctx, mut full_output, settings) in &mut contexts {
if settings.run_manually && full_output.0.is_none() {
// `get_mut`, not `get`: the immutable getter sits behind bevy_egui's
// `immutable_ctx` feature, off in our build.
full_output.0 = Some(ctx.get_mut().run(egui::RawInput::default(), |_| {}));
full_output.0 = Some(match &*cached {
Some(out) => out.clone(),
None => {
// `get_mut`, not `get`: the immutable getter sits behind bevy_egui's
// `immutable_ctx` feature, off in our build. The first run is fed whole
// (its texture delta carries the fonts); the cache keeps everything but
// that delta, which must reach the GPU exactly once.
let out = ctx.get_mut().run(egui::RawInput::default(), |_| {});
let mut keep = out.clone();
keep.textures_delta = Default::default();
keep.shapes.clear();
*cached = Some(keep);
out
}
});
}
}
}
+23 -2
View File
@@ -561,6 +561,8 @@ pub(super) struct SkinKey {
/// guilds' members wear the *same* tabard display and must not share one atlas. Without it the
/// first guild to composite would lend its crest to every other guild on screen.
pub(super) emblem: Option<benilla_formats::GuildEmblem>,
/// The tabard designer's preview flag (1977): the emblem paints over an empty tabard slot.
pub(super) tabard_preview: bool,
}
/// Marks a net entity whose visual (model children or fallback cube) has been attached, so the attach
@@ -668,6 +670,15 @@ type WireBody = (
Option<&'static benilla_world::world_unit::WorldUnit>,
);
/// The edges on which a body's [`WorldUnit`](benilla_world::world_unit::WorldUnit) restatement can
/// move — [`publish_world_units`]' query filter (the anchor's removal is read separately).
type WireBodyMoved = Or<(
Changed<NetEntity>,
Changed<collision_height::CollisionHeight>,
Changed<ModelBound>,
Added<crate::transport::TransportAnchor>,
)>;
/// **Restate every wire body as a [`WorldUnit`](benilla_world::world_unit::WorldUnit)** — the game's half
/// of the unit inversion (see that module).
///
@@ -678,14 +689,24 @@ type WireBody = (
/// visible to the world this frame.
fn publish_world_units(
mut commands: Commands,
bodies: Query<WireBody>,
// Only bodies whose inputs moved this frame: the restatement is a pure function of these
// four, and walking every resident body to compare equal answers was ~1.3 k struct builds
// a frame at the Stormwind pin (decision 1979's floor). A body leaving its transport loses
// the anchor without a `Changed` — that edge is read off `unanchored`.
bodies: Query<WireBody, WireBodyMoved>,
all_bodies: Query<WireBody>,
mut unanchored: RemovedComponents<crate::transport::TransportAnchor>,
viewers: Query<(
Entity,
Has<crate::net::Embodied>,
Has<benilla_world::world_unit::ViewerUnit>,
)>,
) {
for (entity, net, height, bound, anchored, current) in &bodies {
let freed: Vec<Entity> = unanchored.read().collect();
let due = bodies
.iter()
.chain(freed.iter().filter_map(|&e| all_bodies.get(e).ok()));
for (entity, net, height, bound, anchored, current) in due {
let want = benilla_world::world_unit::WorldUnit {
// The wire kind is answered HERE and never handed over (1177): the engine asks "does
// this body displace water", and translating its own vocabulary into that answer is
@@ -154,6 +154,7 @@ pub(super) struct WornEquip {
/// no guild column, and a display-driven body never joins one, so this stays `None` there and a
/// tabard in an NPC's bodyslot-9 column keeps its own art.
pub(super) emblem: Option<benilla_formats::GuildEmblem>,
pub(super) tabard_preview: bool,
}
pub(super) fn resolve_worn_equip(
@@ -172,6 +173,7 @@ pub(super) fn resolve_worn_equip(
cloak: e.cloak,
helm: e.helm,
emblem: e.emblem,
tabard_preview: e.tabard_preview,
})
.unwrap_or_default(),
// A character-model NPC's worn gear ships in its display's CreatureDisplayInfoExtra columns
@@ -184,6 +186,7 @@ pub(super) fn resolve_worn_equip(
cloak: 0,
helm: npc.equipment[0],
emblem: None,
tabard_preview: false,
})
.unwrap_or_default(),
_ => WornEquip::default(),
@@ -196,16 +199,20 @@ pub(super) fn resolve_worn_equip(
/// composite plan the atlas blits. One helper for the world attach path and the glue-preview
/// builder — the selection law can't fork (decision 0465).
///
/// [`EquipGeosets::tabard_preview`](benilla_formats::EquipGeosets::tabard_preview) (B6) is left
/// `false` here: its only setter in the reference is the guild registrar's tabard designer, a
/// window benilla has not migrated yet (decision 1864).
/// [`EquipGeosets::tabard_preview`](benilla_formats::EquipGeosets::tabard_preview) (B6) is the
/// tabard designer's flag, up on the local player's body while that window is open (decision
/// 1977) — its only setter in the reference too.
pub(in crate::entities) fn equip_geosets(
displays: Option<&super::super::ItemDisplays>,
bodyslots: &[u32; 8],
cloak: u32,
helm: u32,
tabard_preview: bool,
) -> benilla_formats::EquipGeosets {
let mut eg = benilla_formats::EquipGeosets::default();
let mut eg = benilla_formats::EquipGeosets {
tabard_preview,
..Default::default()
};
if let Some(d) = displays {
let mut worn: [Option<&benilla_formats::ItemDisplay>; 8] = [None; 8];
for (i, id) in bodyslots.iter().enumerate() {
@@ -287,6 +294,7 @@ pub(super) fn build_char_skin_materials(
// display asks for it. `None` = no guild, or its identity has not arrived; both leave the
// tabard garment showing its own art.
emblem: Option<benilla_formats::GuildEmblem>,
tabard_preview: bool,
displays: Option<&super::super::ItemDisplays>,
sections: Option<&SkinSections>,
world_assets: Option<&WorldAssets>,
@@ -329,6 +337,7 @@ pub(super) fn build_char_skin_materials(
hair_color: look.hair_color,
equip,
emblem,
tabard_preview,
};
// `WOW_PROBE_SHARED_SKIN=1` — a PRICING lever, never a look: every body composites
// the same key, so every body part of one mesh shares one material and bevy's
@@ -345,6 +354,7 @@ pub(super) fn build_char_skin_materials(
hair_color: 0,
equip: [0; 8],
emblem: None,
tabard_preview: false,
};
}
match skin_cache.fetch(&key) {
@@ -375,6 +385,7 @@ pub(super) fn build_char_skin_materials(
key.hair_color,
worn,
key.emblem,
key.tabard_preview,
)
.ok()??;
// Through the upload gate like every other texture: a composite is
@@ -721,7 +721,13 @@ pub(super) fn attach_entity_visuals(
// resolved equipment display rows; an NPC / naked default otherwise.
// (The helm's hide-mask row pair, RF-0083: hair/facial/ears tuck under it. For a
// character-model NPC the helm id is its CreatureDisplayInfoExtra head column.)
let equip_geosets = equip_geosets(displays.as_deref(), &equip, worn.cloak, worn.helm);
let equip_geosets = equip_geosets(
displays.as_deref(),
&equip,
worn.cloak,
worn.helm,
worn.tabard_preview,
);
let visible_geosets: Option<Vec<u16>> = look.as_ref().and_then(|l| {
let cg = characters.as_deref()?;
Some(cg.0.visible_geosets(
@@ -743,6 +749,7 @@ pub(super) fn attach_entity_visuals(
equip,
worn.cloak,
worn.emblem,
worn.tabard_preview,
displays.as_deref(),
sections.as_deref(),
world_assets.as_deref(),
@@ -677,7 +677,7 @@ fn assemble(spec: &PreviewSpec, ctx: &mut PreviewCtx<'_, '_>) -> Option<Assemble
// The dressed geoset set: the worn armor's geoset groups + the cloak + the helm's hide-masks
// (RF-0083), exactly the world path's selection (the shared helper).
let eg = equip_geosets(ctx.displays.as_deref(), &bodyslots, cloak, helm);
let eg = equip_geosets(ctx.displays.as_deref(), &bodyslots, cloak, helm, false);
let visible = ctx.characters.map(|c| {
c.0.visible_geosets(race, sex, char_look.hair_style, char_look.facial_hair, &eg)
});
@@ -686,6 +686,7 @@ fn assemble(spec: &PreviewSpec, ctx: &mut PreviewCtx<'_, '_>) -> Option<Assemble
bodyslots,
cloak,
spec.emblem,
false,
ctx.displays.as_deref(),
ctx.sections,
ctx.world_assets,
@@ -153,7 +153,13 @@ pub(in crate::entities) fn redress_player_looks(
// The dressing inputs, re-resolved exactly as the first build resolved them.
let worn = resolve_worn_equip(net, Some(live), Some(dm));
let look = resolve_char_look(net, Some(dm), entity, &stores);
let eg = equip_geosets(displays.as_deref(), &worn.bodyslots, worn.cloak, worn.helm);
let eg = equip_geosets(
displays.as_deref(),
&worn.bodyslots,
worn.cloak,
worn.helm,
worn.tabard_preview,
);
let visible: Option<Vec<u16>> = look.as_ref().and_then(|l| {
let cg = characters.as_deref()?;
Some(cg.0.visible_geosets(l.race, l.sex, l.hair_style, l.facial_hair, &eg))
@@ -166,6 +172,7 @@ pub(in crate::entities) fn redress_player_looks(
worn.bodyslots,
worn.cloak,
worn.emblem,
worn.tabard_preview,
displays.as_deref(),
sections.as_deref(),
world_assets.as_deref(),
@@ -137,6 +137,10 @@ pub(crate) struct Equipment {
/// wears*: the composite key includes it, so the re-dress diff below is the one place a change
/// of guild, of tabard, and a late-arriving identity all converge — no second latch.
pub(crate) emblem: Option<benilla_formats::GuildEmblem>,
/// The tabard designer's preview (decision 1977, wow-re RF-0089 §7c `[cc+0xc]`): set on the
/// LOCAL player's body while the designer is open, so the body wears the tabard geoset over an
/// empty slot and the composite paints `emblem` (the design under preview) onto it.
pub(crate) tabard_preview: bool,
pub(crate) settled: bool,
}
@@ -189,6 +189,7 @@ pub(in crate::entities) fn resolve_equipment(
Option<&ResolveKey>,
Option<&DressKey>,
Option<&DisarmFreeze>,
Has<crate::net::SelfPlayer>,
)>,
held: Option<ResMut<ItemDisplays>>,
mut templates: ResMut<Items>,
@@ -211,6 +212,8 @@ pub(in crate::entities) fn resolve_equipment(
// what sends the `CMSG_GUILD_QUERY` whose answer paints the tabard. `Option` for the same
// reason `creatures` is: a harness without the UI plugins still resolves equipment.
mut guilds: Option<ResMut<crate::ui_guild::GuildState>>,
// The tabard designer's five under preview (decision 1977) — a change re-dresses our body.
tabard_design: Option<Res<crate::ui_tabard::TabardDesign>>,
) {
let Some(mut held) = held else {
return;
@@ -225,7 +228,8 @@ pub(in crate::entities) fn resolve_equipment(
);
let caches_moved = last_epochs.replace(epochs) != Some(epochs)
|| creatures.as_ref().is_some_and(|c| c.is_changed())
|| enchants.as_ref().is_some_and(|e| e.is_changed());
|| enchants.as_ref().is_some_and(|e| e.is_changed())
|| tabard_design.as_ref().is_some_and(|d| d.is_changed());
let mut glows = glows;
let enchant_rows = enchants.as_deref().map(|e| &e.0);
for (
@@ -242,6 +246,7 @@ pub(in crate::entities) fn resolve_equipment(
current_key,
current_dress,
freeze,
is_self,
) in &units
{
if !matches!(net_entity.kind, EntityKind::Unit | EntityKind::Player) {
@@ -333,6 +338,16 @@ pub(in crate::entities) fn resolve_equipment(
eq.emblem = guilds
.as_deref_mut()
.and_then(|g| crate::ui_guild::unit_guild_emblem(s, g, &net));
// The tabard designer's preview (decision 1977): while it is open on OUR body the
// five under design replace the guild's emblem and the tabard geoset is forced on
// over the empty slot — the reference's `[cc+0xc]` flag and its `0x47a610` install,
// both on the local player's character component and nobody else's.
if is_self {
if let Some(design) = tabard_design.as_deref().and_then(|d| d.preview()) {
eq.emblem = Some(design);
eq.tabard_preview = true;
}
}
if current_equipment != Some(&eq) {
commands.entity(entity).insert(eq);
}
+3
View File
@@ -172,6 +172,7 @@ mod ui_social;
mod ui_spellbook;
mod ui_stable;
mod ui_summon;
mod ui_tabard;
mod ui_talent;
mod ui_talent_wipe;
mod ui_taxi;
@@ -256,6 +257,7 @@ use ui_social::UiSocialPlugin;
use ui_spellbook::UiSpellbookPlugin;
use ui_stable::UiStablePlugin;
use ui_summon::UiSummonPlugin;
use ui_tabard::TabardUiPlugin;
use ui_talent::UiTalentPlugin;
use ui_talent_wipe::UiTalentWipePlugin;
use ui_taxi::UiTaxiPlugin;
@@ -796,6 +798,7 @@ pub fn run(build: BuildId) -> AppExit {
.add_plugins(UiSavedPlugin)
.add_plugins(NamePersistPlugin)
.add_plugins(UiStablePlugin)
.add_plugins(TabardUiPlugin)
.add_plugins(UiTrainerPlugin)
// The taxi map (decision 0484 phases 1-2): the SMSG_SHOWTAXINODES-fed TaxiState resource, the
// NPC-session range guard, and the TaxiFrame.xml window feed/drain (catalogs, node
+14
View File
@@ -1308,6 +1308,20 @@ pub(crate) enum ClientCommand {
/// `RequestBattlefieldPositions()` — `MSG_BATTLEGROUND_PLAYER_POSITIONS` outbound (empty;
/// decision 1980), throttled app-side to the reference's 5000 ms.
RequestBattlefieldPositions,
// ── The tabard designer (decision 1977) ──
/// The NPC-click ladder's TABARDDESIGNER arm — `MSG_TABARDVENDOR_ACTIVATE` out.
TabardVendorActivate {
npc: u64,
},
/// `TabardModel:Save()` past its pre-flight checks — `MSG_SAVE_GUILD_EMBLEM` out.
SaveGuildEmblem {
vendor: u64,
design: [u32; 5],
},
/// The ladder's BATTLEMASTER arm — `CMSG_BATTLEMASTER_HELLO`; the list window answers.
BattlemasterHello {
npc: u64,
},
/// `JoinBattlefield` when the list arrived without one — `CMSG_BATTLEFIELD_JOIN`.
BattlefieldJoin {
map_id: u32,
+10
View File
@@ -262,6 +262,7 @@ pub(super) fn apply_net_updates(
ResMut<crate::ui_battlefield::Battlefield>,
ResMut<crate::tutorial::Tutorials>,
ResMut<crate::ui_battlefield_positions::BattlefieldPositions>,
ResMut<crate::ui_tabard::TabardOpen>,
),
),
// The guard's directions marker (`SMSG_GOSSIP_POI`) and the map id it has to be
@@ -489,6 +490,7 @@ pub(super) fn apply_net_updates(
mut battlefield,
mut tutorials,
mut battlefield_positions,
mut tabard,
),
),
mut poi_marker,
@@ -981,6 +983,14 @@ pub(super) fn apply_net_updates(
SessionEvent::MeetingStoneNotice(notice) => meeting_stone.apply_notice(notice),
SessionEvent::TutorialFlags(bytes) => tutorials.apply_flags(&bytes),
SessionEvent::BattlefieldPositions(packet) => battlefield_positions.apply(packet),
SessionEvent::TabardVendorActivate(vendor) => tabard.open(vendor),
// A saved emblem evicts our guild's cached record (`0x5e715f`): the next query
// anywhere re-fetches it — no event, no packet.
SessionEvent::SaveGuildEmblemResult(result) => {
if tabard.apply_result(result) {
guild.evict_own_identity();
}
}
SessionEvent::PlayerBound { binder: npc, area } => {
debug!("net: bound to area {area} by {npc:#x}");
crate::ui_binder::apply::bound(
+5
View File
@@ -1086,6 +1086,11 @@ fn writer_loop(
ClientCommand::RequestBattlefieldPositions => {
w.request_battlefield_positions()
}
ClientCommand::TabardVendorActivate { npc } => w.tabard_vendor_activate(npc),
ClientCommand::SaveGuildEmblem { vendor, design } => {
w.save_guild_emblem(vendor, design)
}
ClientCommand::BattlemasterHello { npc } => w.battlemaster_hello(npc),
ClientCommand::BattlemasterJoin {
battlemaster,
map_id,
+4 -1
View File
@@ -170,8 +170,11 @@ const STABLE_SLOT: &str = "stable";
/// take the name from. It is not packed — the `.pub` is only what the loose
/// `Interface\AddOns\Blizzard_InspectUI\` folder holds, while the real `.xml` and `.lua` sit
/// inside `patch.MPQ`, which the chain mounts. 1832 migrated the window and the prefix went with it.
const MODEL_PANE_BOOTHS: [(&str, &str); 6] = [
const MODEL_PANE_BOOTHS: [(&str, &str); 7] = [
("CharacterModelFrame", PAPERDOLL_SLOT),
// The tabard designer's pane (1977): a PlayerModel of the player — the paper doll's own
// bake, whose body wears the design under preview while the window is up.
("TabardModel", PAPERDOLL_SLOT),
("PetModelFrame", PETDOLL_SLOT),
("InspectModelFrame", INSPECT_SLOT),
("PetStableModel", STABLE_SLOT),
+4 -1
View File
@@ -110,6 +110,9 @@
// standalone clamped texture of it would. Producers leave it off for UVs that run past `[0,1]` —
// that is the backdrop's tiling idiom, not a cell.
@group(2) @binding(11) var<uniform> uv_clamp: vec4<f32>;
// A whole-run colour multiplier (ONE for pooled materials): a solo quad's colour rides here
// instead of in its vertices, so a per-frame colour pulse is a material write, not a mesh write.
@group(2) @binding(12) var<uniform> tint: vec4<f32>;
// ITU-R BT.601 luma — the `PARAM c[0]` of that shader, read as raw f32 words: `0x3E991687`,
// `0x3F1645A2`, `0x3DE978D5`. Not `(0.3, 0.3, 0.3)`, not `(0.3, 0.59, 0.11)` (this file's own first
@@ -148,7 +151,7 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
let uv = select(in.uv, clamp(in.uv, lo, hi), uv_clamp.xy <= uv_clamp.zw);
let t = textureSample(quad_texture, quad_sampler, uv);
#ifdef VERTEX_COLORS
let c = in.color;
let c = in.color * tint;
#else
let c = vec4<f32>(1.0);
#endif
+6
View File
@@ -1148,6 +1148,12 @@ pub(super) fn apply_kit_debug(
config: Res<SoundConfig>,
listener: Res<AudioListener>,
) {
// Read before borrowing mutably: a `&mut` through `ResMut` marks `DebugState` changed, and
// this ran every frame — so every still-frame gate that reads `debug.is_changed()`
// (decision 1979) saw a changed debug state on every frame of every run.
if !debug.sound.play_kit {
return;
}
let s = &mut debug.sound;
if !std::mem::take(&mut s.play_kit) {
return;
+20 -16
View File
@@ -1316,12 +1316,12 @@ pub(crate) enum ServiceAction {
/// carrying no gossip bit gets nothing at all — no packet, no event, no error. That is not an
/// omission here, it is the reference's own answer.
///
/// **Two arms deviate deliberately, and here is the whole of it**: `TabardDesigner` should send
/// `MSG_TABARDVENDOR_ACTIVATE` and `Battlemaster` `CMSG_BATTLEMASTER_HELLO`, and benilla has
/// neither window to open with the reply. They keep the universal gossip greeting, which against
/// vmangos still puts a usable menu on screen; sending the faithful opcode into a reply we drop
/// would trade a working affordance for a wire detail nobody can see. Each retires the moment its
/// window exists — that is the condition, written down (decision 1861).
/// The `TabardDesigner` and `Battlemaster` arms send their own opcodes (`0x5f051a`'s
/// `MSG_TABARDVENDOR_ACTIVATE`, `0x5f0551`'s `CMSG_BATTLEMASTER_HELLO`) since decision 1977 —
/// 1861 had them on the universal gossip greeting while neither window existed; the battleground
/// list (1974) and the tabard designer (1977) answer the replies now. The tabard arm's one gate
/// — the shapeshift refusal `ERR_EMBLEMERROR_NOTABARDGEOSET` when the player's displayed model is
/// not the native one, and the same-vendor no-op — is named, not built (1977's record).
pub(crate) fn service_action(
arm: ServiceArm,
guid: u64,
@@ -1364,9 +1364,11 @@ pub(crate) fn service_action(
ServiceArm::Petitioner => {
ServiceAction::Send(ClientCommand::PetitionShowList { npc: guid })
}
// The two documented deviations — see this function's doc.
ServiceArm::TabardDesigner | ServiceArm::Battlemaster => {
ServiceAction::Send(ClientCommand::GossipHello { guid })
ServiceArm::TabardDesigner => {
ServiceAction::Send(ClientCommand::TabardVendorActivate { npc: guid })
}
ServiceArm::Battlemaster => {
ServiceAction::Send(ClientCommand::BattlemasterHello { npc: guid })
}
ServiceArm::Auctioneer => {
ServiceAction::Send(ClientCommand::AuctionHello { auctioneer: guid })
@@ -1864,13 +1866,15 @@ mod tests {
assert_eq!(sent(ServiceArm::SpiritHealer, false), "silent");
assert_eq!(sent(ServiceArm::SpiritGuide, false), "silent");
assert_eq!(sent(ServiceArm::SpiritHealer, true), "ask-spirit-healer");
// The two documented deviations keep the greeting until their windows exist.
for arm in [ServiceArm::TabardDesigner, ServiceArm::Battlemaster] {
assert!(matches!(
service_action(arm, 0x42, false, None),
ServiceAction::Send(ClientCommand::GossipHello { guid: 0x42 })
));
}
// The two arms 1861 had on the greeting send their own opcodes since 1977.
assert!(matches!(
service_action(ServiceArm::TabardDesigner, 0x42, false, None),
ServiceAction::Send(ClientCommand::TabardVendorActivate { npc: 0x42 })
));
assert!(matches!(
service_action(ServiceArm::Battlemaster, 0x42, false, None),
ServiceAction::Send(ClientCommand::BattlemasterHello { npc: 0x42 })
));
}
/// **The vendor arm is a fork, and only the vendor arm** — decision 1914.
+30
View File
@@ -278,6 +278,36 @@ impl GuildState {
self.identity_generation
}
/// The tabard designer's view of our guild record (decision 1977): `Some(five)` once the
/// record is cached — `-1`s for an undesigned tabard, as the wire carries them — and `None`
/// while it has not arrived or the player has no guild. A miss sends the query, the lazy-cache
/// idiom every other read of this cache uses.
pub(crate) fn own_emblem_record(
&mut self,
guild_id: u32,
commands: &NetCommands,
) -> Option<[i32; 5]> {
let e = self.resolve_identity(guild_id, commands)?.emblem;
Some([
e.emblem_style,
e.emblem_color,
e.border_style,
e.border_color,
e.background_color,
])
}
/// A saved emblem's eviction (`0x5e715f`, decision 1977): our guild's cached record is
/// dropped so the next query anywhere re-fetches it — the tabards of every member in sight
/// re-dress off the arrival, as the reference's guild-appearance refresh does.
pub(crate) fn evict_own_identity(&mut self) {
if self.identities.remove(&self.guild_id).is_some() {
self.queried.remove(&self.guild_id);
self.identity_generation = self.identity_generation.wrapping_add(1);
self.dirty = true;
}
}
/// `SMSG_GUILD_QUERY_RESPONSE` — fill (or negatively fill) the identity cache.
fn apply_query_response(&mut self, response: GuildQueryResponse) {
self.queried.remove(&response.guild_id);
+103 -19
View File
@@ -426,6 +426,14 @@ pub(crate) struct UiQuadMaterial {
/// **per axis**, with `min > max` on an axis disabling that axis. See [`UiQuad::uv_clamp`].
#[uniform(11)]
uv_clamp: Vec4,
/// A whole-run colour multiplier, `ONE` for a pooled material. A run of ONE quad with one
/// colour carries that colour here and draws white vertices (decision 1979): the stock
/// PlayerFrame status glow pulses its colour every frame, and as a vertex colour that was
/// one `Assets<Mesh>` write a frame — which arms bevy's asset-changed probes over every
/// `Mesh3d` row in the scene (~44 k at the Stormwind pin, six material families' worth).
/// A material write arms only the 2D quad family's probe (~110 rows).
#[uniform(12)]
tint: Vec4,
}
impl UiQuadMaterial {
@@ -452,6 +460,7 @@ impl UiQuadMaterial {
mask_rect: Vec4::new(0.0, 0.0, -1.0, -1.0),
mask: None,
// A tile samples its whole image; there is no cell to stay inside of.
tint: Vec4::ONE,
uv_clamp: UV_CLAMP_OFF,
}
}
@@ -823,6 +832,9 @@ impl Run {
/// material asset forever, so `prepare_assets` re-prepares nothing on a steady frame). Before
/// this, every rebuild despawned every batch and allocated fresh meshes + materials — ~9 ms/frame
/// of render-side churn in a live city (0365).
/// A solo run's material with the key and tint it was built for.
type SoloMaterial = (Handle<UiQuadMaterial>, MatKey, [f32; 4]);
#[derive(Default)]
struct BatchPools {
entities: Vec<Entity>,
@@ -840,6 +852,10 @@ struct BatchPools {
/// Goldshire pin).
offsets: Vec<Vec2>,
materials: std::collections::HashMap<MatKey, Handle<UiQuadMaterial>>,
/// Per slot: the solo-run material (key + tint it was built with), `None` while pooled.
solo: Vec<Option<SoloMaterial>>,
/// Per slot: the material the batch entity currently carries.
bound: Vec<Option<AssetId<UiQuadMaterial>>>,
}
/// One pooled batch slot's full identity: the mesh bytes plus everything the entity was last
@@ -1035,6 +1051,14 @@ fn rebuild_ui_mesh(
.collect();
if !retired.is_empty() {
pools.materials.retain(|key, _| !retired.contains(&key.0));
for slot in &mut pools.solo {
if slot
.as_ref()
.is_some_and(|(_, k, _)| retired.contains(&k.0))
{
*slot = None;
}
}
}
let (hidden, mesh_cost, cost_wanted) =
(&hide_and_meter.0, &mut hide_and_meter.1, &hide_and_meter.2);
@@ -1309,14 +1333,83 @@ fn rebuild_ui_mesh(
// GPU mesh allocator's free+realloc each frame (0.86 ms/frame at the Stormwind pin).
// Full content compare, not a hash — a stale batch drawn over a collision would be a
// rendering bug no one could reproduce.
// A solo run (one quad, one colour) hands its colour to the material's `tint` and
// stores white vertices — so a colour pulse is a material write, never a mesh write.
let solo_tint = (run.positions.len() == 4 && run.colors.windows(2).all(|w| w[0] == w[1]))
.then(|| run.colors[0]);
let colors = match solo_tint {
Some(_) => vec![[1.0; 4]; 4],
None => run.colors,
};
let stored = StoredRun {
positions: run.positions,
uvs: run.uvs,
colors: run.colors,
colors,
indices: run.indices,
z_bits: z.to_bits(),
key,
};
while pools.solo.len() <= used {
pools.solo.push(None);
pools.bound.push(None);
}
let material_handle = match solo_tint {
Some(tint) => {
let fresh = |materials: &mut Assets<UiQuadMaterial>| {
materials.add(UiQuadMaterial {
additive: u32::from(run.additive),
texture: Some(run.texture.clone()),
circular: u32::from(run.circular),
desaturate: u32::from(run.desaturated),
premultiplied: u32::from(run.premultiplied),
alpha_ref,
gamma_texel: u32::from(run.gamma_texel),
mask_rect,
mask: mask.clone(),
uv_clamp,
tint: Vec4::from_array(tint),
})
};
match &mut pools.solo[used] {
Some((handle, k, t)) if *k == key => {
if *t != tint {
if let Some(m) = materials.get_mut(&*handle) {
m.tint = Vec4::from_array(tint);
}
*t = tint;
}
handle.clone()
}
slot => {
let handle = fresh(materials);
*slot = Some((handle.clone(), key, tint));
handle
}
}
}
None => {
pools.solo[used] = None;
pools
.materials
.entry(key)
.or_insert_with(|| {
materials.add(UiQuadMaterial {
additive: u32::from(run.additive),
texture: Some(run.texture.clone()),
circular: u32::from(run.circular),
desaturate: u32::from(run.desaturated),
premultiplied: u32::from(run.premultiplied),
alpha_ref,
gamma_texel: u32::from(run.gamma_texel),
mask_rect,
mask: mask.clone(),
uv_clamp,
tint: Vec4::ONE,
})
})
.clone()
}
};
// The pan gate (1463): a run that matches its slot's base up to one constant XY delta
// moves on the batch entity's `Transform` — no mesh write, no `AssetChanged` arming.
// `Some(ZERO)` is the bit-identical case (1361's old skip gate), where even the
@@ -1326,6 +1419,14 @@ fn rebuild_ui_mesh(
.get(used)
.and_then(|prev| prev.translation_from(&stored).map(|d| (d, prev.z_bits)));
if let Some((d, z_bits)) = pan {
if pools.bound[used] != Some(material_handle.id()) {
pools.bound[used] = Some(material_handle.id());
if let Some(&entity) = pools.entities.get(used) {
commands
.entity(entity)
.insert(MeshMaterial2d(material_handle.clone()));
}
}
if pools.offsets[used] != d {
pools.offsets[used] = d;
if let Some(&entity) = pools.entities.get(used) {
@@ -1408,24 +1509,7 @@ fn rebuild_ui_mesh(
handle
}
};
let material_handle = pools
.materials
.entry(key)
.or_insert_with(|| {
materials.add(UiQuadMaterial {
additive: u32::from(run.additive),
texture: Some(run.texture),
circular: u32::from(run.circular),
desaturate: u32::from(run.desaturated),
premultiplied: u32::from(run.premultiplied),
alpha_ref,
gamma_texel: u32::from(run.gamma_texel),
mask_rect,
mask,
uv_clamp,
})
})
.clone();
pools.bound[used] = Some(material_handle.id());
// Reuse the batch entity at this slot — same Mesh2d handle (the pooled asset), a material
// handle that only changes when the run's identity does, a fresh z.
match pools.entities.get(used) {
@@ -158,11 +158,6 @@ const BOTTOM_EXEMPT: &[(&str, &str)] = &[
"the base itself — the bar everything else measures its clearance FROM, so it has no \
clearance of its own to compute",
),
(
"WorldFrame",
"a full-screen named handle, not a HUD frame (UIParent.xml) — setAllPoints, renders \
nothing",
),
("UIParent", "the full-screen root itself"),
(
"ZoneTextFrame",
@@ -1565,7 +1565,13 @@ fn convert_entry(
.any(|c| !(-0.001..=1.001).contains(c));
let handle = match (path.as_deref(), assets.as_mut()) {
(Some(p), Some(a)) => {
let resolved = if circular {
// The tabard designer's emblem cells (1977): the reference installs a
// generated 128×64 / 128×32 image — white RGB carrying the emblem BLP's own
// alpha — into the Texture the setter was handed; the region carries that as
// a token path and the resolver builds exactly that image.
let resolved = if let Some(blp) = benilla_ui::script::emblem_mask_path(p) {
a.emblem_mask_texture(blp, images)
} else if circular {
a.portrait_texture(p, images)
} else if tiled {
a.sprite_texture_tiled(p, images)
+6 -1
View File
@@ -156,7 +156,12 @@ pub(super) fn feed_ui_input(
let metering =
*HIT_COST.get_or_init(|| std::env::var("WOW_HIT_COST").as_deref() == Ok("1"));
let t0 = metering.then(std::time::Instant::now);
hover.0 = script.mouse_move(x, y);
// The world frame is mouse-enabled by construction and so a legitimate hover target for
// an addon's handlers, but its hit is the WORLD's (decision 1983): camera look, world
// clicks and hover targeting stay live over it.
hover.0 = script
.mouse_move(x, y)
.filter(|id| !script.is_world_frame(*id));
if let Some(t0) = t0 {
use std::sync::atomic::{AtomicU64, Ordering};
static ACC_US: AtomicU64 = AtomicU64::new(0);
+3
View File
@@ -1100,6 +1100,9 @@ mod quiver_tests;
#[cfg(test)]
mod world_entry_tests;
/// The guild tabard designer — the stock `TabardFrame.xml` off the chain (decision 1977).
#[cfg(test)]
mod tabard_tests;
/// The world map's POI pool — the guard's directions marker today, the AreaPOI landmarks later.
#[cfg(test)]
mod world_map_tests;
@@ -1,4 +1,4 @@
//! Print screen's UI half (`assets/ui/ScreenshotStatus.xml`) against the shipped XML — and above
//! Print screen's UI half — the stock `WorldFrame.xml`'s `ScreenshotStatus` (decision 1983) — and above
//! all **B261's third clause: the "Screen Captured" line must not be in the file it announces.**
//!
//! That contract is an ORDERING, so it is tested as one. Two paths could put text in a picture and
@@ -24,8 +24,8 @@ fn harness() -> UiScript {
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
r"Interface\FrameXML\WorldFrame.xml",
"UIParent.xml",
"ScreenshotStatus.xml",
] {
load_xml(&s, f);
}
@@ -1328,12 +1328,13 @@ fn the_inspect_cursor_pair_takes_both_arms() {
/// name-for-name comparison against the reference could not see it. **Sweeping what is actually
/// visible can**, which is why this is written against the observable and not against a list.
///
/// The three survivors are each required to survive:
/// The two survivors are each required to survive (a third, `WorldFrame`, was one while our
/// `UIParent.xml` declared it; since decision 1983 it is the reference's own file off the chain,
/// which this sweep over the SHIPPED tree does not walk — it stays up through a fly-by exactly as
/// before, top-level and never hidden):
///
/// - `CinematicFrame` — the frame being *shown*. The reference declares it with no parent for
/// exactly this reason, and `SetFullScreenFrame` shows it in the same breath as hiding UIParent.
/// - `WorldFrame` — the 3D scene's frame. Ours renders nothing (Bevy draws the world), but it is
/// the reference's own bottom-of-strata frame and a cinematic is a thing you watch *in* it.
/// - `BenillaFadeDriver` — a 1x1 frame with no textures and no layers, whose only content is an
/// `OnUpdate` running `UIFrameFadeUpdate`. It draws nothing, and it must not be hidden: a hidden
/// frame's `OnUpdate` does not run, so parenting it would freeze every in-flight `UIFrameFade`
@@ -1393,9 +1394,9 @@ fn a_cinematic_leaves_nothing_of_the_interface_on_screen() {
after.sort_unstable();
assert_eq!(
after,
["BenillaFadeDriver", "CinematicFrame", "WorldFrame"],
["BenillaFadeDriver", "CinematicFrame"],
"something is drawing over the fly-by (see this test's header for why exactly these \
three are allowed to survive)"
two are allowed to survive)"
);
// …and the player gets it all back.
@@ -0,0 +1,163 @@
//! The guild tabard designer — the stock `TabardFrame.xml` off the player's chain (decision
//! 1977) in a bare engine: the wire's open event seeds and shows the window, the customization
//! rows cycle the design, the emblem cells wear the mask token, the Save button follows
//! `CanSaveTabardNow`, and the cancel path closes through `CloseTabardCreation`.
use benilla_ui::script::{
emblem_mask_path, TabardHost, TabardIntent, UiScript, UnitGuild, UnitState,
};
/// The kit, with the player in a guild at `rank` (0 = the master) — `GetGuildInfo("player")`
/// reads the unit model's own guild tag.
fn harness(rank: u32) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for (token, name, guild) in [
(
"player",
"Probefour",
Some(UnitGuild {
name: "Probe Guild".into(),
rank_name: if rank == 0 { "Guild Master" } else { "Member" }.into(),
rank_index: rank,
}),
),
("npc", "Tabard Vendor", None),
] {
s.set_unit(
token,
Some(UnitState {
exists: true,
name: Some(name.into()),
level: 60,
guild,
..Default::default()
}),
);
}
for file in [
"Interface\\FrameXML\\Fonts.xml",
"UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\GameTooltip.xml",
r"Interface\FrameXML\TabardFrame.xml",
] {
super::test_ui::load_ui(&s, file);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s
}
/// The reference's open path: `MSG_TABARDVENDOR_ACTIVATE` → `OPEN_TABARD_FRAME`. The stock
/// handler seeds the design off the guild record, paints the four emblem cells, sets the
/// greeting and the Save button by the guild rank, and shows the window through the panel
/// manager.
#[test]
fn the_open_event_seeds_the_design_paints_the_cells_and_shows_the_window() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness(0);
s.set_tabard_host(TabardHost {
guild_record: Some([12, 4, 1, 2, 9]),
save_pending: false,
});
s.fire_event("OPEN_TABARD_FRAME", vec![]);
s.resolve();
assert!(s.eval::<bool>("return TabardFrame:IsVisible()").unwrap());
assert_eq!(s.tabard_design(), Some([12, 4, 1, 2, 9]));
assert_eq!(
s.eval::<String>("return TabardFrameNameText:GetText()")
.unwrap(),
"Tabard Vendor"
);
let cell = s
.eval::<String>("return TabardFrameEmblemTopLeft:GetTexture()")
.unwrap();
assert_eq!(
emblem_mask_path(&cell),
Some(r"Textures\GuildEmblems\Emblem_12_04_TU_U")
);
let cell = s
.eval::<String>("return TabardFrameEmblemBottomRight:GetTexture()")
.unwrap();
assert!(cell.ends_with("Emblem_12_04_TL_U"), "{cell}");
assert!(
s.eval::<bool>("return TabardFrameAcceptButton:IsEnabled() == 1")
.unwrap(),
"a guild master with a cached record can save"
);
assert_eq!(
s.eval::<String>("return TabardFrameGreetingText:GetText()")
.unwrap(),
s.eval::<String>("return TABARDVENDORGREETING").unwrap()
);
// The customization rows: the first row's right arrow cycles the emblem style and repaints.
s.run("TabardCustomization_Right(1)").unwrap();
assert_eq!(s.tabard_design(), Some([13, 4, 1, 2, 9]));
let cell = s
.eval::<String>("return TabardFrameEmblemTopRight:GetTexture()")
.unwrap();
assert!(cell.ends_with("Emblem_13_04_TU_U"), "{cell}");
s.run("TabardCustomization_Left(5) TabardCustomization_Left(5)")
.unwrap();
assert_eq!(s.tabard_design().unwrap()[4], 7);
// Save: the intent carries the five; the pending latch greys the button through the event.
s.run("TabardFrameAcceptButton:Click()").unwrap();
assert_eq!(
s.take_tabard_intents(),
vec![TabardIntent::Save([13, 4, 1, 2, 7])]
);
s.set_tabard_host(TabardHost {
guild_record: Some([12, 4, 1, 2, 9]),
save_pending: true,
});
s.fire_event("TABARD_SAVE_PENDING", vec![]);
assert!(
!s.eval::<bool>("return TabardFrameAcceptButton:IsEnabled() == 1")
.unwrap(),
"the latch greys the button"
);
// Cancel closes through the panel manager; the OnHide calls CloseTabardCreation.
s.run("TabardFrameCancelButton:Click()").unwrap();
s.resolve();
assert!(!s.eval::<bool>("return TabardFrame:IsVisible()").unwrap());
assert_eq!(s.take_tabard_intents(), vec![TabardIntent::Close]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A non-master, or a player with no guild, gets the no-guild greeting and a dead Save button —
/// the stock `TabardFrame_UpdateButtons` law over `GetGuildInfo("player")`.
#[test]
fn a_non_master_gets_the_no_guild_greeting_and_no_save() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness(3);
s.set_tabard_host(TabardHost {
guild_record: Some([-1; 5]),
save_pending: false,
});
s.fire_event("OPEN_TABARD_FRAME", vec![]);
assert!(!s
.eval::<bool>("return TabardFrameAcceptButton:IsEnabled() == 1")
.unwrap());
assert_eq!(
s.eval::<String>("return TabardFrameGreetingText:GetText()")
.unwrap(),
s.eval::<String>("return TABARDVENDORNOGUILDGREETING")
.unwrap()
);
// The close event from the engine side hides the window.
s.fire_event("CLOSE_TABARD_FRAME", vec![]);
s.resolve();
assert!(!s.eval::<bool>("return TabardFrame:IsVisible()").unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+4 -1
View File
@@ -187,6 +187,8 @@ pub(crate) fn feed_interact_npc(
// client's own `0x5f05bc` path), so there is no gossip session behind it at all. The registrar
// arm above is the same shape for the same reason.
stable: Option<Res<crate::ui_stable::StableOpen>>,
// The tabard designer's vendor (decision 1977) — its portrait and name banner, the same way.
tabard: Option<Res<crate::ui_tabard::TabardOpen>>,
index: Option<Res<GuidIndex>>,
mut out: ResMut<InteractNpc>,
) {
@@ -200,7 +202,8 @@ pub(crate) fn feed_interact_npc(
.or_else(|| bank.and_then(|s| s.npc()))
.or_else(|| auction.and_then(|s| s.npc()))
.or_else(|| registrar.and_then(|s| s.npc()))
.or_else(|| stable.and_then(|s| s.npc()));
.or_else(|| stable.and_then(|s| s.npc()))
.or_else(|| tabard.and_then(|s| s.npc()));
// Field 0 is the entity the portrait booth bakes and the facing chain steers by; field 1 is
// the same NPC's **guid**, which `crate::ui_unit`'s feed needs to resolve the `"npc"` unit
// token's name (a name lives in the `NameCache`, keyed by guid — there is no way back to one
+5 -1
View File
@@ -64,7 +64,11 @@ impl Plugin for UiStablePlugin {
// `PetStable_Update`'s own re-pick (`PetStable.lua:44-59`) runs inside the
// event dispatch `feed_stable` performs. Reading it before the tick would show
// the previous pet for a frame after every click.
feed_stable_booth.after(UiInput),
feed_stable_booth
.after(UiInput)
// Nothing samples the booth while no stable is open, and the feed's
// first line is a name scan over the whole frame arena (1979's floor).
.run_if(|open: Res<StableOpen>| open.npc.is_some()),
),
);
}
+391
View File
@@ -0,0 +1,391 @@
//! The guild tabard designer (decision 1977; wow-re `system/ui/scratch/tabard-designer.md`):
//! the app half of the stock `TabardFrame.xml` — the vendor session, the body preview, the save's
//! pre-flight checks and wire, the reply, and the four events.
//!
//! **The frame opens only from the wire.** `MSG_TABARDVENDOR_ACTIVATE` in (`0x5e70c0` → `0x4f5840`)
//! stores the vendor guid, raises the designer-preview flag on the local player's character
//! component, sets the global interaction target, and fires `OPEN_TABARD_FRAME`; there is no Lua
//! verb that opens it. The stock handler then `SetUnit("player")`s the pane, seeds the design
//! (`InitializeTabardColors`, the engine's) and `ShowUIPanel`s.
//!
//! **The body preview.** The five under design replace the guild's emblem on OUR body, over the
//! tabard geoset forced on with an empty slot (`Equipment::tabard_preview` → the composite and the
//! geoset law; `[cc+0xc]` and `0x47a610` in the reference). The `TabardModel` pane samples the
//! paper-doll booth, which bakes that same body — the reference's pane is a `PlayerModel` of the
//! player, so what the world shows and what the pane shows are one look.
//!
//! **`Save()`** runs the reference's fourteen pre-flight checks in order (`0x5e03f0`), each a
//! `UI_ERROR_MESSAGE` line — none silent — then sends `MSG_SAVE_GUILD_EMBLEM` and fires
//! `TABARD_SAVE_PENDING` with the latch already set. The reply's `u32` clears the latch and shows
//! its table row; a failure fires `TABARD_SAVE_PENDING` again; success evicts the guild record so
//! the next query anywhere re-fetches it (no event, no packet — `0x5e715f`).
//!
//! **Close.** `CloseTabardCreation()` (the OnHide's call), the walk-away leash and the world-leave
//! run one close core: with a vendor stored, drop the preview flag, clear the guid, fire
//! `CLOSE_TABARD_FRAME`; with none, nothing. The reference fires the event twice on the Lua route
//! (a re-entrancy through the interaction-target clear, `0x4f58a0 → 0x4931c0 → 0x493310 →
//! 0x4934a0`) — a shape the stock `HideUIPanel` handler absorbs and this close does not reproduce.
use bevy::prelude::*;
use benilla_formats::GuildEmblem;
use benilla_protocol::messages::GUILD_EMBLEM_RESULT_MESSAGES;
use benilla_ui::script::{TabardHost, TabardIntent, UiScript, TABARD_COUNTS, TABARD_CREATION_COST};
use crate::net::{ClientCommand, EnteredWorldMessage, NetCommands, ObjectStore, SelfPlayer};
use crate::portrait::PaperDollBooth;
use crate::ui_guild::GuildState;
use crate::ui_script::UiInput;
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
/// The open designer: the vendor it was opened on (`[0xbdcee8]`), and the save-in-flight latch
/// (`[0xc4d780]`).
#[derive(Resource, Default)]
pub(crate) struct TabardOpen {
vendor: Option<u64>,
save_pending: bool,
/// `OPEN_TABARD_FRAME` owed to the next feed.
open_event: bool,
/// `CLOSE_TABARD_FRAME` owed to the next feed.
close_event: bool,
/// `TABARD_SAVE_PENDING` fires owed to the next feed.
pending_events: u32,
/// The reply's line, keyed, owed to the next feed.
lines: Vec<&'static str>,
}
impl TabardOpen {
/// `MSG_TABARDVENDOR_ACTIVATE` in — `0x4f5840`.
pub(crate) fn open(&mut self, vendor: u64) {
self.vendor = Some(vendor);
self.open_event = true;
}
/// The close core `0x4f58a0`: only with a vendor stored.
fn close_core(&mut self) {
if self.vendor.take().is_some() {
self.close_event = true;
}
}
/// `MSG_SAVE_GUILD_EMBLEM` in — `0x5e70f0`: the latch clears first; a result past the six-row
/// table is ignored; the row's line shows unless it is the sentinel; a failure re-fires
/// `TABARD_SAVE_PENDING`. Returns whether the save SUCCEEDED (the caller evicts the record).
pub(crate) fn apply_result(&mut self, result: u32) -> bool {
let Some(row) = usize::try_from(result)
.ok()
.and_then(|i| GUILD_EMBLEM_RESULT_MESSAGES.get(i))
else {
return false;
};
self.save_pending = false;
if let Some(key) = row {
self.lines.push(key);
}
if result != 0 {
self.pending_events += 1;
}
result == 0
}
}
impl NpcSession for TabardOpen {
fn npc(&self) -> Option<u64> {
self.vendor
}
fn close(&mut self) {
self.close_core();
}
}
/// The five under design while the frame is open — what the local player's body wears
/// (`entities::equipment::resolve`). `None` closes the preview.
#[derive(Resource, Default)]
pub(crate) struct TabardDesign(Option<[i32; 5]>);
impl TabardDesign {
pub(crate) fn preview(&self) -> Option<GuildEmblem> {
self.0.map(|d| GuildEmblem {
emblem_style: d[0],
emblem_color: d[1],
border_style: d[2],
border_color: d[3],
background_color: d[4],
})
}
}
/// The sender's fourteen rejections in order (`0x5e03f0`), each a `UI_ERROR_MESSAGE` key — the
/// ten range checks share one line; the money check is the client's `ERR_NOT_ENOUGH_MONEY`
/// (0x25), not the server's `ERR_GUILDEMBLEM_NOTENOUGHMONEY`. `Ok` means send.
pub(crate) fn preflight(
design: [i32; 5],
record: Option<[i32; 5]>,
guild_rank: u32,
purse: u32,
) -> Result<[u32; 5], &'static str> {
if design.iter().any(|v| *v < 0) {
return Err("ERR_GUILDEMBLEM_INVALID_TABARD_COLORS");
}
if design
.iter()
.zip(TABARD_COUNTS)
.any(|(v, count)| *v >= count)
{
return Err("ERR_GUILDEMBLEM_INVALID_TABARD_COLORS");
}
let Some(record) = record else {
return Err("ERR_GUILDEMBLEM_NOGUILD");
};
if record == design {
return Err("ERR_GUILDEMBLEM_SAME");
}
if guild_rank != 0 {
return Err("ERR_GUILDEMBLEM_NOTGUILDMASTER");
}
if purse < TABARD_CREATION_COST {
return Err("ERR_NOT_ENOUGH_MONEY");
}
Ok(design.map(|v| v as u32))
}
#[allow(clippy::too_many_arguments)]
fn feed_tabard(
script: Option<NonSendMut<UiScript>>,
mut open: ResMut<TabardOpen>,
mut design: ResMut<TabardDesign>,
mut booth: ResMut<PaperDollBooth>,
mut guilds: ResMut<GuildState>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
commands: Res<NetCommands>,
mut last_host: Local<crate::ui_script::VmMemo<Option<TabardHost>>>,
mut last_identity: Local<crate::ui_script::VmMemo<Option<u64>>>,
mut sink: crate::ui_action::MessageSink,
) {
let Some(mut script) = script else {
return;
};
// The host facts: our guild's cached record (a lazy-cache read — a miss sends the query) and
// the latch. Pushed on change so the getters read the current state on every call.
let guild_id = self_q.iter().next().map_or(0, |s| s.0.player_guild_id());
let host = TabardHost {
guild_record: guilds.own_emblem_record(guild_id, &commands),
save_pending: open.save_pending,
};
let last = last_host.get(&script);
if *last != Some(host) {
*last = Some(host);
script.set_tabard_host(host);
}
// The events, in the order the reference's flow produces them.
if std::mem::take(&mut open.open_event) {
script.fire_event("OPEN_TABARD_FRAME", vec![]);
}
if std::mem::take(&mut open.close_event) {
script.fire_event("CLOSE_TABARD_FRAME", vec![]);
}
for _ in 0..std::mem::take(&mut open.pending_events) {
script.fire_event("TABARD_SAVE_PENDING", vec![]);
}
// `TABARD_CANSAVE_CHANGED`: the guild-appearance refresh that a guild record's arrival runs
// on every member in sight (`0x5e08e0`, its second act) — here, our identity cache moving.
let gen = guilds.identity_generation();
let last_gen = last_identity.get(&script);
if last_gen.is_some() && *last_gen != Some(gen) {
script.fire_event("TABARD_CANSAVE_CHANGED", vec![]);
}
*last_gen = Some(gen);
// The reply's line.
let lines: Vec<_> = open
.lines
.drain(..)
.filter_map(|key| crate::ui_action::keyed_line(&script, key))
.collect();
crate::ui_action::show_messages(&mut script, &mut sink, "ui_tabard", lines);
// The body preview follows the pane's five while the designer is open; the pane's yaw drives
// the paper-doll booth it samples.
let shown = open.vendor.is_some() && script.frame_visible("TabardFrame");
let current = shown.then(|| script.tabard_design()).flatten();
if design.0 != current {
design.0 = current;
}
if shown {
let yaw = script.model_pane_facing("TabardModel");
if booth.yaw != yaw {
booth.yaw = yaw;
}
}
}
fn drain_tabard(
script: Option<NonSendMut<UiScript>>,
mut open: ResMut<TabardOpen>,
mut guilds: ResMut<GuildState>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
commands: Res<NetCommands>,
mut sink: crate::ui_action::MessageSink,
) {
let Some(mut script) = script else {
return;
};
for intent in script.take_tabard_intents() {
match intent {
TabardIntent::Close => open.close_core(),
TabardIntent::Save(design) => {
// The vendor guid is the global UI interaction target, which the open set to
// this vendor (§2c); ours is the session's stored guid, the same value.
let Some(vendor) = open.vendor else {
continue;
};
let store = self_q.iter().next();
let guild_id = store.map_or(0, |s| s.0.player_guild_id());
let record = guilds.own_emblem_record(guild_id, &commands);
let rank = store.map_or(0, |s| s.0.player_guild_rank());
let purse = store.and_then(|s| s.0.player_money()).unwrap_or(0);
match preflight(design, record, rank, purse) {
Err(key) => {
if let Some(line) = crate::ui_action::keyed_line(&script, key) {
crate::ui_action::show_messages(
&mut script,
&mut sink,
"ui_tabard",
[line],
);
}
}
Ok(design) => {
let _ = commands
.0
.send(ClientCommand::SaveGuildEmblem { vendor, design });
// `0x5e05f5` then `0x5e05fc`: the latch, then the event — a handler
// reading `CanSaveTabardNow()` inside it already sees nil.
open.save_pending = true;
script.set_tabard_host(TabardHost {
guild_record: record,
save_pending: true,
});
script.fire_event("TABARD_SAVE_PENDING", vec![]);
}
}
}
}
}
}
fn reset_on_world_enter(
mut entered: MessageReader<EnteredWorldMessage>,
mut open: ResMut<TabardOpen>,
mut design: ResMut<TabardDesign>,
) {
if entered.read().next().is_none() {
return;
}
// The leave-world teardown ran the close core (`0x490b02`); a fresh world starts clean.
*open = TabardOpen::default();
design.0 = None;
}
pub(crate) struct TabardUiPlugin;
impl Plugin for TabardUiPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<TabardOpen>()
.init_resource::<TabardDesign>()
.add_systems(
Update,
(
reset_on_world_enter.before(feed_tabard),
close_npc_session_out_of_range::<TabardOpen>.before(feed_tabard),
feed_tabard.before(UiInput),
drain_tabard.after(UiInput),
),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_fourteen_checks_run_in_the_references_order() {
let rec = Some([1, 2, 3, 4, 5]);
assert_eq!(
preflight([-1, 0, 0, 0, 0], rec, 0, 200_000),
Err("ERR_GUILDEMBLEM_INVALID_TABARD_COLORS")
);
assert_eq!(
preflight([170, 0, 0, 0, 0], rec, 0, 200_000),
Err("ERR_GUILDEMBLEM_INVALID_TABARD_COLORS")
);
assert_eq!(
preflight([0, 0, 0, 0, 51], rec, 0, 200_000),
Err("ERR_GUILDEMBLEM_INVALID_TABARD_COLORS")
);
assert_eq!(
preflight([0; 5], None, 0, 200_000),
Err("ERR_GUILDEMBLEM_NOGUILD")
);
assert_eq!(
preflight([1, 2, 3, 4, 5], rec, 0, 200_000),
Err("ERR_GUILDEMBLEM_SAME")
);
assert_eq!(
preflight([0; 5], rec, 1, 200_000),
Err("ERR_GUILDEMBLEM_NOTGUILDMASTER")
);
assert_eq!(
preflight([0; 5], rec, 0, 99_999),
Err("ERR_NOT_ENOUGH_MONEY")
);
assert_eq!(
preflight([169, 16, 5, 16, 50], rec, 0, 100_000),
Ok([169, 16, 5, 16, 50])
);
// An undesigned record (-1s) is a cached record: the SAME check compares and passes.
assert_eq!(preflight([0; 5], Some([-1; 5]), 0, 100_000), Ok([0; 5]));
}
#[test]
fn the_reply_clears_the_latch_shows_its_row_and_refires_pending_on_failure() {
let mut t = TabardOpen {
save_pending: true,
..Default::default()
};
assert!(t.apply_result(0), "success");
assert!(!t.save_pending);
assert_eq!(t.lines, vec!["ERR_GUILDEMBLEM_SUCCESS"]);
assert_eq!(t.pending_events, 0);
t.save_pending = true;
assert!(!t.apply_result(3));
assert_eq!(t.lines.last(), Some(&"ERR_GUILDEMBLEM_NOTGUILDMASTER"));
assert_eq!(t.pending_events, 1);
t.save_pending = true;
assert!(!t.apply_result(5), "the sentinel row shows nothing");
assert_eq!(t.lines.len(), 2);
assert!(!t.save_pending);
t.save_pending = true;
assert!(
!t.apply_result(6),
"past the table: ignored, latch untouched"
);
assert!(t.save_pending);
}
#[test]
fn the_close_core_fires_only_with_a_vendor_stored() {
let mut t = TabardOpen::default();
t.close_core();
assert!(!t.close_event);
t.open(0xF130_0000_0000_0042);
assert!(t.open_event);
t.close_core();
assert!(t.close_event && t.vendor.is_none());
t.close_event = false;
t.close_core();
assert!(!t.close_event, "a second close is silent");
}
}
+5 -1
View File
@@ -361,7 +361,11 @@ fn is_instrument_consumer(rel: &str) -> bool {
/// the number the `FPS_PROBE` line prints as `fx=`. It is the reading that refuted the
/// additive-window regroup on the crowd rig (decision 1955): a lane whose merge walk is the
/// only place the count exists has to publish it or stay unmeasurable.
const CEILING: usize = 174;
/// And 174 → 175: `dev_state::STILL_INPUTS_CHANGED`, an instrument PUBLISH — how many frames
/// each whole-scene input of the still-frame skips (1979) read as changed. Counted in the
/// world crate so the probe names ONE static instead of the four resources the skips read; the
/// reading is what says whether a gate ever engages.
const CEILING: usize = 175;
/// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be
/// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it
@@ -500,6 +500,33 @@ impl WorldAssets {
loaded
}
/// The tabard designer's emblem cell (decision 1977): the emblem BLP decoded and rewritten to
/// **white carrying its own alpha** — `(a << 24) | 0x00FFFFFF` per texel, the reference's
/// `0x503431` loop — as an ordinary clamped sprite the caller tints. Cached per path like a
/// sprite; a file that fails to open yields `None` (the reference installs its static array's
/// stale contents instead; nothing here has a previous emblem to show).
pub fn emblem_mask_texture(
&mut self,
path: &str,
images: &mut Assets<Image>,
) -> Option<Handle<Image>> {
let key = format!("emblem-mask:{}", sprite_key(path));
if let Some(cached) = self.sprites.get(&key) {
return cached.clone();
}
let loaded =
decode_sprite(&self.chain, self.loose_root.as_deref(), path).map(|(w, h, mut rgba)| {
for px in rgba.chunks_exact_mut(4) {
px[0] = 0xFF;
px[1] = 0xFF;
px[2] = 0xFF;
}
images.add(sprite_image(w, h, rgba))
});
self.sprites.insert(key, loaded.clone());
loaded
}
/// A coverage **mask** ([`mask_image`] — linear/clamp, the texel bytes handed to the shader
/// 1:1): the minimap's `MinimapMask.blp` circle (decision 0203, [`crate::ui_pass::UiQuadMask`]).
/// Same extensionless→`.blp` resolve as [`Self::sprite_texture`]; its own cache, hits AND misses.
@@ -116,7 +116,7 @@ pub fn charatlas(chain: &mut Chain, look: &Look, out: Option<&std::path::Path>)
// the guild tabard's three layers come through the same list, because they land in the same
// rows and the question ("what repainted this cell?") is the same one.
println!("\nequipment blits (by ascending cell; later covers earlier within a tile):");
for step in equip_blits(&equipment, look.emblem) {
for step in equip_blits(&equipment, look.emblem, false) {
let (_x, y, w, h) = equip_tile(step.layer).expect("layer < 8");
let candidates = step.candidates(look.sex);
let basename = |p: &str| p.rsplit('\\').next().unwrap_or(p).to_string();
@@ -184,6 +184,7 @@ pub fn charatlas(chain: &mut Chain, look: &Look, out: Option<&std::path::Path>)
look.hair_color,
[None; 8],
None,
false,
)?
.context("no base skin row for this appearance")?;
let dressed = sections
@@ -198,6 +199,7 @@ pub fn charatlas(chain: &mut Chain, look: &Look, out: Option<&std::path::Path>)
look.hair_color,
equipment,
look.emblem,
false,
)?
.context("no base skin row for this appearance")?;
@@ -257,6 +257,7 @@ impl CharSections {
hair_color: u8,
equipment: [Option<&ItemDisplay>; 8],
emblem: Option<GuildEmblem>,
tabard_preview: bool,
) -> Result<Option<BlpMipChain>> {
let Some(base_path) = self.skin_texture(race, sex, skin) else {
return Ok(None);
@@ -288,7 +289,7 @@ impl CharSections {
}
// The equipment plan, built once: the blits below consume it, and the underwear reads it as its
// gate. One plan, so "what dresses this tile?" has a single answer (decision 0074's `equip_blits`).
let plan = equip_blits(&equipment, emblem);
let plan = equip_blits(&equipment, emblem, tabard_preview);
// The underwear ([`UNDERWEAR_TILES`]) — the tile's fallback, not an under-layer: a contribution
// in any of the group's TESTED columns and the client draws no underwear there at all, leaving
// the base skin it already blitted. Drawn before the equipment because the untested columns (the
@@ -602,9 +603,16 @@ pub fn equip_column(equipment: &[Option<&ItemDisplay>; 8], slot: usize, layer: u
pub fn equip_blits<'a>(
equipment: &[Option<&'a ItemDisplay>; 8],
emblem: Option<GuildEmblem>,
tabard_preview: bool,
) -> Vec<EquipBlit<'a>> {
let emblem = emblem
.filter(|_| equipment[SLOT_TABARD].is_some_and(|d: &ItemDisplay| d.takes_guild_emblem()));
// The tabard designer's preview (decision 1977, wow-re RF-0089 §6/§7c): the reference's
// `0x47a610` installs the five onto the character component with no ItemDisplayInfo test —
// the previewed tabard is the geoset flap over an EMPTY slot — so the emblem paints whenever
// the preview flag is up, and otherwise only over a worn tabard whose display asks for it.
let emblem = emblem.filter(|_| {
tabard_preview
|| equipment[SLOT_TABARD].is_some_and(|d: &ItemDisplay| d.takes_guild_emblem())
});
let mut plan = Vec::new();
for (layer, _tile) in EQUIP_TILES.iter().enumerate() {
let mut row: [Option<EquipBlit<'a>>; 8] = [None; 8];
@@ -663,7 +671,7 @@ const LAYER_ARM_LOWER: usize = 1;
/// is passed as `None` because it only ever writes layers 3 and 4 ([`EMBLEM_LAYERS`]) — this row
/// reads the same either way.
pub fn forearm_dressed(equipment: &[Option<&ItemDisplay>; 8]) -> bool {
equip_blits(equipment, None)
equip_blits(equipment, None, false)
.iter()
.any(|b| b.layer == LAYER_ARM_LOWER && (1..=6).contains(&b.column))
}
@@ -904,7 +912,7 @@ mod tests {
Some(&plain_gloves),
None,
];
let g1: Vec<_> = equip_blits(&eq, None)
let g1: Vec<_> = equip_blits(&eq, None, false)
.iter()
.filter(|s| s.layer == 1)
.map(worn_cell)
@@ -920,7 +928,7 @@ mod tests {
Some(&geoset_gloves),
None,
];
let g1: Vec<_> = equip_blits(&eq, None)
let g1: Vec<_> = equip_blits(&eq, None, false)
.iter()
.filter(|s| s.layer == 1)
.map(worn_cell)
@@ -1003,7 +1011,7 @@ mod tests {
Some(&guild_tabard),
];
assert_eq!(
cells(&equip_blits(&eq, Some(emblem))),
cells(&equip_blits(&eq, Some(emblem), false)),
want(&[
(3, 2, "Background"),
(3, 3, "Border"),
@@ -1018,7 +1026,7 @@ mod tests {
// look a guildless player wearing a Guild Tabard gets, and the look everyone gets for the
// frames between spawning and `SMSG_GUILD_QUERY_RESPONSE`.
assert_eq!(
cells(&equip_blits(&eq, None)),
cells(&equip_blits(&eq, None, false)),
want(&[(3, 4, "tabard_tu"), (4, 4, "tabard_tl")])
);
// A tabard that does not ask for the emblem keeps its art even for a guilded wearer: the
@@ -1034,11 +1042,11 @@ mod tests {
Some(&plain_tabard),
];
assert_eq!(
cells(&equip_blits(&plain, Some(emblem))),
cells(&equip_blits(&plain, Some(emblem), false)),
want(&[(3, 4, "tabard_tu"), (4, 4, "tabard_tl")])
);
// And no tabard at all is no emblem, however guilded the wearer.
assert!(equip_blits(&[None; 8], Some(emblem)).is_empty());
assert!(equip_blits(&[None; 8], Some(emblem), false).is_empty());
// Under a shirt: the shirt keeps cell 0, the emblem stacks above it — and nothing reaches
// the arm layers, which the shirt still owns alone.
@@ -1053,7 +1061,7 @@ mod tests {
Some(&guild_tabard),
];
assert_eq!(
cells(&equip_blits(&eq, Some(emblem))),
cells(&equip_blits(&eq, Some(emblem), false)),
want(&[
(0, 0, "shirt_au"),
(1, 0, "shirt_al"),
@@ -1216,7 +1224,7 @@ mod tests {
(1, 3),
"the bra is TextureName[1] into TorsoUpper"
);
equip_blits(eq, em)
equip_blits(eq, em, false)
.iter()
.any(|s| s.layer == layer && s.column < tested)
};
@@ -1292,7 +1300,7 @@ mod tests {
);
fn g6(eq: &[Option<&ItemDisplay>; 8]) -> Vec<(i8, String)> {
equip_blits(eq, None)
equip_blits(eq, None, false)
.iter()
.filter(|s| s.layer == 6)
.map(|s| {
@@ -1393,7 +1401,7 @@ mod tests {
);
let eq = [None, None, None, None, Some(&odd), None, None, None];
assert!(
equip_blits(&eq, None).is_empty(),
equip_blits(&eq, None, false).is_empty(),
"a -1 cell drops the contribution entirely"
);
@@ -1403,7 +1411,7 @@ mod tests {
);
let eq = [None, Some(&blank), None, None, None, None, None, None];
assert!(
equip_blits(&eq, None).is_empty(),
equip_blits(&eq, None, false).is_empty(),
"an empty texture name is not a contribution"
);
}
@@ -1488,7 +1496,7 @@ mod tests {
read_texture_mip_chain(&mut chain, "Character\\Human\\Male\\HumanMaleSkin00_03.blp")
.expect("read base skin");
let comp = cs
.composite_body(&mut chain, 1, 0, 3, 0, 1, 0, 0, [None; 8], None)
.composite_body(&mut chain, 1, 0, 3, 0, 1, 0, 0, [None; 8], None, false)
.expect("composite ok")
.expect("base skin row present");
@@ -1647,7 +1655,9 @@ mod tests {
"the sheet is authored exactly tile-sized"
);
let comp = cs
.composite_body(&mut chain, race, sex, skin, 0, 0, 0, 0, [None; 8], None)
.composite_body(
&mut chain, race, sex, skin, 0, 0, 0, 0, [None; 8], None, false,
)
.expect("composite ok")
.expect("base skin row present");
@@ -1785,7 +1795,9 @@ mod tests {
];
for (label, equipment, (g3_want, g3), (g5_want, g5)) in cases {
let comp = cs
.composite_body(&mut chain, race, sex, skin, 0, 0, 0, 0, equipment, None)
.composite_body(
&mut chain, race, sex, skin, 0, 0, 0, 0, equipment, None, false,
)
.expect("composite ok")
.expect("base skin row present");
for (tile, name, want_name, want) in [
@@ -1841,7 +1853,7 @@ mod tests {
[None, None, None, None, None, None, None, Some(tabard)];
// (2) — every layer resolves, and each is authored at its tile's exact extent.
for step in equip_blits(&equipment, Some(emblem)) {
for step in equip_blits(&equipment, Some(emblem), false) {
let path = step
.candidates(0)
.into_iter()
@@ -1858,7 +1870,7 @@ mod tests {
// (3) — the repaint is confined to the two torso tiles.
let mut compose = |em: Option<GuildEmblem>| {
cs.composite_body(&mut chain, 1, 0, 3, 0, 1, 0, 0, equipment, em)
cs.composite_body(&mut chain, 1, 0, 3, 0, 1, 0, 0, equipment, em, false)
.expect("composite ok")
.expect("base skin row present")
};
@@ -1935,7 +1947,7 @@ mod tests {
items.get(10141).expect("boots display"),
);
let mut compose = |equipment: [Option<&ItemDisplay>; 8]| {
cs.composite_body(&mut chain, 1, 0, 3, 0, 1, 0, 0, equipment, None)
cs.composite_body(&mut chain, 1, 0, 3, 0, 1, 0, 0, equipment, None, false)
.expect("composite ok")
.expect("base skin row present")
};
+105 -2
View File
@@ -194,6 +194,28 @@ impl ChunkMesh {
if !(0.0..=1.0).contains(&south) || !(0.0..=1.0).contains(&east) {
return None;
}
// The covering cell, then only its four fan triangles — the same corners, centre,
// order and hole rule the index buffer was built from (`adt_to_tile_mesh`), so the
// answer is the walk's answer without the walk: a column used to test up to 256
// triangles, and the sun-flare march asks ~96 columns a frame (decision 1979).
if self.positions.len() == 145 {
let row = ((south * 8.0) as u32).min(7);
let col = ((east * 8.0) as u32).min(7);
if self.holes & (1u16 << ((row >> 1) * 4 + (col >> 1))) != 0 {
return None;
}
let tl = (row * 17 + col) as usize;
let (tr, bl, br, ctr) = (tl + 1, tl + 17, tl + 18, (row * 17 + 9 + col) as usize);
let p = |i: usize| self.positions[i];
return [
[p(ctr), p(tl), p(bl)],
[p(ctr), p(bl), p(br)],
[p(ctr), p(br), p(tr)],
[p(ctr), p(tr), p(tl)],
]
.iter()
.find_map(|tri| triangle_z_at(tri, wow[0], wow[1]));
}
self.indices.chunks_exact(3).find_map(|t| {
let tri = [
*self.positions.get(t[0] as usize)?,
@@ -253,7 +275,30 @@ pub fn ground_effect_at(chunks: &[ChunkMesh], wow: [f32; 3]) -> Option<u32> {
/// `None` when the column lies outside every chunk (a different tile) or falls in an MCNK hole.
/// The terrain leg of the client's down-ray arbitration (see [`ChunkMesh::height_at`]).
pub fn terrain_height_at(chunks: &[ChunkMesh], wow: [f32; 3]) -> Option<f32> {
chunks.iter().find_map(|c| c.height_at(wow))
match chunk_at(chunks, wow) {
Some(c) => c.height_at(wow),
None => chunks.iter().find_map(|c| c.height_at(wow)),
}
}
/// The one MCNK of a full 16×16 tile that covers `wow`'s column — arithmetic on the tile's
/// north-west corner, not a walk. A tile's chunks are in MCIN order (row-major: row = the
/// south step along WoW x, column = the east step along y), and every chunk's first vertex is
/// its own north-west corner, so chunk 0's is the tile's. `None` when the slice is not a full
/// tile or its first chunk carries no geometry — the callers fall back to the linear walk,
/// which is what every column lookup used to be: 256 bounds tests per call, and the flare
/// oracle alone asks ~96 times a frame (decision 1979's floor).
fn chunk_at(chunks: &[ChunkMesh], wow: [f32; 3]) -> Option<&ChunkMesh> {
if chunks.len() != 256 {
return None;
}
let nw = *chunks[0].positions.first()?;
let row = (nw[0] - wow[0]) / CHUNK_SIZE;
let col = (nw[1] - wow[1]) / CHUNK_SIZE;
if !(0.0..16.0).contains(&row) || !(0.0..16.0).contains(&col) {
return None;
}
chunks.get(row as usize * 16 + col as usize)
}
/// Is a raw WoW-space position in MCSH terrain shadow, given **one tile's** chunks? `Some(true)` when the
@@ -266,7 +311,10 @@ pub fn terrain_height_at(chunks: &[ChunkMesh], wow: [f32; 3]) -> Option<f32> {
/// — `models/scratch/m2-interior-doodad-base-light.md §6`.) Feeds the model lobe's `sun_scale` (the
/// faithful per-instance terrain-shade, 2.5 sunlit / 0.5 shaded — wow-re byte-verified).
pub fn mcsh_shadowed_at(chunks: &[ChunkMesh], wow: [f32; 3]) -> Option<bool> {
chunks.iter().find_map(|c| c.mcsh_shadowed_at(wow))
match chunk_at(chunks, wow) {
Some(c) => c.mcsh_shadowed_at(wow),
None => chunks.iter().find_map(|c| c.mcsh_shadowed_at(wow)),
}
}
/// A placed M2 doodad (tree/bush/prop): which model, and where, in **raw WoW world coords**.
@@ -891,4 +939,59 @@ mod tests {
);
assert_eq!(here.chunks.iter().filter(|c| c.impassable).count(), 0);
}
/// The cell lookup must answer exactly like the raw triangle walk it replaced, height and
/// MCSH alike — sampled off-border across four real tiles (Elwynn and Stormwind), so a
/// wrong row/column convention, an off-by-one at an edge or a mishandled hole fails here,
/// not under a foot.
#[test]
fn the_chunk_index_answers_exactly_like_the_walk() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
// The raw walk the cell lookup replaced: every triangle of every chunk, in index order.
let walk = |chunks: &[ChunkMesh], wow: [f32; 3]| {
chunks.iter().find_map(|c| {
c.indices.chunks_exact(3).find_map(|t| {
let tri = [
c.positions[t[0] as usize],
c.positions[t[1] as usize],
c.positions[t[2] as usize],
];
triangle_z_at(&tri, wow[0], wow[1])
})
})
};
let mut hits = 0;
for (x, y) in [(32u32, 48u32), (31, 48), (30, 48), (31, 47)] {
let tile = load_tile_mesh(&mut chain, "Azeroth", x, y).expect("Elwynn/Stormwind tile");
assert_eq!(tile.chunks.len(), 256);
let nw = tile.chunks[0].positions[0];
let step = TILE_SIZE / 64.0;
for i in 0..64 {
for j in 0..64 {
let wow = [
nw[0] - (i as f32 + 0.37) * step,
nw[1] - (j as f32 + 0.61) * step,
0.0,
];
let walk_h = walk(&tile.chunks, wow);
assert_eq!(
terrain_height_at(&tile.chunks, wow),
walk_h,
"height at {wow:?}"
);
let walk_s = tile.chunks.iter().find_map(|c| c.mcsh_shadowed_at(wow));
assert_eq!(
mcsh_shadowed_at(&tile.chunks, wow),
walk_s,
"shadow at {wow:?}"
);
hits += usize::from(walk_h.is_some());
}
}
}
assert!(
hits > 12000,
"the sample grid should land on terrain almost everywhere: {hits}"
);
}
}
+4
View File
@@ -492,6 +492,10 @@ pub enum SessionEvent {
/// The battleground teammates' positions and the flag carrier
/// (`MSG_BATTLEGROUND_PLAYER_POSITIONS`), raw world floats (decision 1980).
BattlefieldPositions(crate::messages::BattlefieldPositions),
/// `MSG_TABARDVENDOR_ACTIVATE` — the vendor whose designer opens (decision 1977).
TabardVendorActivate(u64),
/// `MSG_SAVE_GUILD_EMBLEM` — the save's result row (decision 1977).
SaveGuildEmblemResult(u32),
/// A group join's verdict (`SMSG_GROUP_JOINED_BATTLEGROUND`): `0xFFFFFFFE` deserters, a map id
/// joined, anything else the generic failure — three message lines, no state (decision 1974).
GroupJoinedBattleground { result: u32 },
@@ -787,6 +787,8 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
ServerPacket::PvpLogData(data) => vec![SessionEvent::PvpLogData(data)],
ServerPacket::BattlefieldList(list) => vec![SessionEvent::BattlefieldList(list)],
ServerPacket::BattlefieldPositions(p) => vec![SessionEvent::BattlefieldPositions(p)],
ServerPacket::TabardVendorActivate(g) => vec![SessionEvent::TabardVendorActivate(g)],
ServerPacket::SaveGuildEmblemResult(r) => vec![SessionEvent::SaveGuildEmblemResult(r)],
ServerPacket::GroupJoinedBattleground { result } => {
vec![SessionEvent::GroupJoinedBattleground { result }]
}
@@ -62,6 +62,7 @@ mod spellbook;
mod spells;
mod stable;
mod summon;
mod tabard;
mod taxi;
mod trade;
mod trainer;
@@ -238,6 +239,9 @@ pub use stable::{
StabledPet,
};
pub use summon::{summon_response, SummonRequest};
pub use tabard::{
battlemaster_hello, save_guild_emblem, tabard_vendor_activate, GUILD_EMBLEM_RESULT_MESSAGES,
};
pub use taxi::{
activate_taxi, activate_taxi_express, taxi_node_status_query, taxi_query_available_nodes,
taxi_reply, TaxiMask,
@@ -251,6 +251,10 @@ pub const SMSG_AI_REACTION: u16 = 0x013C; // 316
pub const SMSG_SPELL_START: u16 = 0x0131; // 305
pub const SMSG_SPELL_GO: u16 = 0x0132; // 306
pub const SMSG_PLAY_SPELL_VISUAL: u16 = 0x01F3; // 499
/// The guild tabard designer's two-way pair (decision 1977; bodies in [`super::tabard`]): the
/// vendor activation that opens the frame, and the save whose reply is a six-row result.
pub const MSG_SAVE_GUILD_EMBLEM: u16 = 0x01F1; // 497
pub const MSG_TABARDVENDOR_ACTIVATE: u16 = 0x01F2; // 498
pub const SMSG_CANCEL_AUTO_REPEAT: u16 = 0x029C; // 668
pub const SMSG_SPELL_FAILED_OTHER: u16 = 0x02A6; // 678
@@ -606,6 +610,9 @@ pub const CMSG_INSPECT: u16 = 0x0114; // 276
/// Same three silent refusals as [`CMSG_INSPECT`] (`MiscHandler.cpp:962-972`), but *unlike* it
/// this handler does not set our selection. Decision 1512.
pub const MSG_INSPECT_HONOR_STATS: u16 = 0x02D6; // 726
/// The NPC-click ladder's BATTLEMASTER arm (`0x5e01a0`): a `u64` NPC guid, no gate; the server
/// answers with `SMSG_BATTLEFIELD_LIST` (decision 1977 retires 1861's greeting stand-in).
pub const CMSG_BATTLEMASTER_HELLO: u16 = 0x02D7; // 727
pub const CMSG_SET_SELECTION: u16 = 0x013D; // 317
pub const CMSG_ATTACKSWING: u16 = 0x0141; // 321
pub const CMSG_ATTACKSTOP: u16 = 0x0142; // 322
@@ -320,6 +320,10 @@ pub enum ServerPacket {
BattlefieldList(crate::messages::BattlefieldList),
/// `MSG_BATTLEGROUND_PLAYER_POSITIONS` inbound — the teammates and the flag carrier (1980).
BattlefieldPositions(crate::messages::BattlefieldPositions),
/// `MSG_TABARDVENDOR_ACTIVATE` inbound — the vendor guid that opens the tabard designer (1977).
TabardVendorActivate(u64),
/// `MSG_SAVE_GUILD_EMBLEM` inbound — the save's result row (1977).
SaveGuildEmblemResult(u32),
/// `SMSG_GROUP_JOINED_BATTLEGROUND` — a group join's verdict (decision 1974).
GroupJoinedBattleground {
result: u32,
@@ -1591,6 +1595,8 @@ impl ServerPacket {
ServerPacket::PvpLogData(_) => "MSG_PVP_LOG_DATA".into(),
ServerPacket::BattlefieldList(_) => "SMSG_BATTLEFIELD_LIST".into(),
ServerPacket::BattlefieldPositions(_) => "MSG_BATTLEGROUND_PLAYER_POSITIONS".into(),
ServerPacket::TabardVendorActivate(_) => "MSG_TABARDVENDOR_ACTIVATE".into(),
ServerPacket::SaveGuildEmblemResult(_) => "MSG_SAVE_GUILD_EMBLEM".into(),
ServerPacket::GroupJoinedBattleground { .. } => "SMSG_GROUP_JOINED_BATTLEGROUND".into(),
ServerPacket::BattlegroundPlayer { joined: true, .. } => {
"SMSG_BATTLEGROUND_PLAYER_JOINED".into()
@@ -14,9 +14,9 @@ use super::{
action_bar, area_trigger, attack, auction, bank, battlefield, binder, broadcast, channel, chat,
combat_log, death, duel, gameobject, gm_ticket, gossip, group, guild, instance, items, loot,
mail, meeting_stone, mirror_timer, monster_move, movement, opcode, page_text, pet, petition,
progression, pvp, quest, social, spellbook, spells, stable, summon, taxi, trade, trainer,
tutorial, update_object, vendor, world_state, Character, CreatureQueryInfo, JumpInfo, MoveMode,
ServerPacket, SpeedKind, SplineMode,
progression, pvp, quest, social, spellbook, spells, stable, summon, tabard, taxi, trade,
trainer, tutorial, update_object, vendor, world_state, Character, CreatureQueryInfo, JumpInfo,
MoveMode, ServerPacket, SpeedKind, SplineMode,
};
/// Read one `SMSG_FORCE_*_SPEED_CHANGE` body — `[packed mover guid][u32 counter][f32 speed]`,
@@ -413,6 +413,12 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
opcode::MSG_BATTLEGROUND_PLAYER_POSITIONS => {
ServerPacket::BattlefieldPositions(battlefield::read_battlefield_positions(&mut r)?)
}
opcode::MSG_TABARDVENDOR_ACTIVATE => {
ServerPacket::TabardVendorActivate(tabard::read_tabard_vendor_activate(&mut r)?)
}
opcode::MSG_SAVE_GUILD_EMBLEM => {
ServerPacket::SaveGuildEmblemResult(tabard::read_save_guild_emblem_result(&mut r)?)
}
opcode::SMSG_GROUP_JOINED_BATTLEGROUND => ServerPacket::GroupJoinedBattleground {
result: crate::wire::read_u32_le(&mut r)?,
},
@@ -0,0 +1,86 @@
//! The guild tabard designer's wire (decision 1977; wow-re `system/ui/scratch/tabard-designer.md`
//! §4, VERIFIED at the bytes): two `MSG_*` opcodes used in both directions, and the battlemaster
//! greeting the same TU ships beside them.
//!
//! - `MSG_TABARDVENDOR_ACTIVATE 0x1F2` — out: `u64 npcGuid` (the NPC-click ladder's
//! TABARDDESIGNER arm, `0x5e00e0`); in: `u64 vendorGuid`, the only thing that opens the frame
//! (`0x5e70c0` → `0x4f5840`).
//! - `MSG_SAVE_GUILD_EMBLEM 0x1F1` — out: `u64 vendorGuid` then the five `u32` design values in
//! the order *emblemStyle, emblemColor, borderStyle, borderColor, backgroundColor* (`0x5e03f0`);
//! in: one `u32` result (`0x5e70f0`), indexed into a six-row message table.
//! - `CMSG_BATTLEMASTER_HELLO 0x2D7` — out: `u64 npcGuid`, no gate (`0x5e01a0`).
use std::io::{self, Read};
use crate::wire::{read_u32_le, read_u64_le};
/// Body of `MSG_SAVE_GUILD_EMBLEM` outbound: the vendor guid raw little-endian (not packed), then
/// the five design values as `u32`s in the designer's slot order.
pub fn save_guild_emblem(vendor: u64, design: [u32; 5]) -> Vec<u8> {
let mut body = vendor.to_le_bytes().to_vec();
for v in design {
body.extend_from_slice(&v.to_le_bytes());
}
body
}
/// Body of `MSG_TABARDVENDOR_ACTIVATE` outbound: the NPC guid and nothing else.
pub fn tabard_vendor_activate(npc: u64) -> Vec<u8> {
npc.to_le_bytes().to_vec()
}
/// Body of `CMSG_BATTLEMASTER_HELLO`: the NPC guid and nothing else.
pub fn battlemaster_hello(npc: u64) -> Vec<u8> {
npc.to_le_bytes().to_vec()
}
/// `MSG_SAVE_GUILD_EMBLEM` inbound: one `u32` result.
pub(super) fn read_save_guild_emblem_result(r: &mut impl Read) -> io::Result<u32> {
read_u32_le(r)
}
/// `MSG_TABARDVENDOR_ACTIVATE` inbound: the vendor guid.
pub(super) fn read_tabard_vendor_activate(r: &mut impl Read) -> io::Result<u64> {
read_u64_le(r)
}
/// The reply handler's result table (`0x85fe88`, six rows — the consumer's own `0 ≤ result < 6`
/// bound): the message-catalog key each result shows, `None` for row 5, the `0x1d1` sentinel that
/// shows nothing. A result past the table is ignored outright.
pub const GUILD_EMBLEM_RESULT_MESSAGES: [Option<&str>; 6] = [
Some("ERR_GUILDEMBLEM_SUCCESS"),
Some("ERR_GUILDEMBLEM_INVALID_TABARD_COLORS"),
Some("ERR_GUILDEMBLEM_NOGUILD"),
Some("ERR_GUILDEMBLEM_NOTGUILDMASTER"),
Some("ERR_GUILDEMBLEM_NOTENOUGHMONEY"),
None,
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_save_body_is_the_guid_then_the_five_values_in_slot_order() {
let body = save_guild_emblem(0x0102_0304_0506_0708, [170, 1, 2, 3, 50]);
assert_eq!(body.len(), 28);
assert_eq!(&body[..8], &0x0102_0304_0506_0708u64.to_le_bytes());
assert_eq!(&body[8..12], &170u32.to_le_bytes());
assert_eq!(&body[24..28], &50u32.to_le_bytes());
assert_eq!(tabard_vendor_activate(7), 7u64.to_le_bytes());
assert_eq!(battlemaster_hello(9), 9u64.to_le_bytes());
}
#[test]
fn the_replies_read_their_one_field() {
assert_eq!(
read_save_guild_emblem_result(&mut &3u32.to_le_bytes()[..]).unwrap(),
3
);
assert_eq!(
read_tabard_vendor_activate(&mut &0xF130_0000_0000_0042u64.to_le_bytes()[..]).unwrap(),
0xF130_0000_0000_0042
);
assert_eq!(GUILD_EMBLEM_RESULT_MESSAGES[5], None);
}
}
@@ -94,6 +94,7 @@ mod social;
mod spells;
mod stable;
mod summon;
mod tabard;
mod taxi;
mod trade;
mod trainer;
@@ -0,0 +1,33 @@
//! The tabard designer's senders (decision 1977; bodies in [`crate::messages::tabard`]).
use anyhow::Result;
use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// `MSG_TABARDVENDOR_ACTIVATE` out — the NPC-click ladder's TABARDDESIGNER arm.
pub fn tabard_vendor_activate(&mut self, npc: u64) -> Result<()> {
self.send(
opcode::MSG_TABARDVENDOR_ACTIVATE,
&messages::tabard_vendor_activate(npc),
)
}
/// `MSG_SAVE_GUILD_EMBLEM` out — `TabardModel:Save()` past its fourteen pre-flight checks.
pub fn save_guild_emblem(&mut self, vendor: u64, design: [u32; 5]) -> Result<()> {
self.send(
opcode::MSG_SAVE_GUILD_EMBLEM,
&messages::save_guild_emblem(vendor, design),
)
}
/// `CMSG_BATTLEMASTER_HELLO` — the ladder's BATTLEMASTER arm.
pub fn battlemaster_hello(&mut self, npc: u64) -> Result<()> {
self.send(
opcode::CMSG_BATTLEMASTER_HELLO,
&messages::battlemaster_hello(npc),
)
}
}
+43 -9
View File
@@ -386,14 +386,27 @@ pub enum ZTarget {
/// [`UiScript::extract`](crate::script::UiScript::extract) drops hidden regions before they become
/// quads. (The 1.12 region-draw cluster that would pin the *draw-time* skip is still flagged unread
/// in wow-re's findings; the flag itself and its setter `0x77fcb0` are recorded.)
pub fn traversal(arena: &WidgetArena) -> Vec<(ZTarget, ZKey)> {
let mut out: Vec<(ZTarget, ZKey)> = Vec::new();
/// The sorted draw list, cached against a fingerprint of its inputs (decision 1979): the list
/// is a pure function of every visible frame's `(strata, level, insertion)` and every attached
/// region's layer/sub-level/kind/decl — a walk over ~2 k frames and ~6 k regions, then a sort,
/// and it was rebuilt on every frame for the extract, the pointer and the edit box alike. The
/// walk stays (it is the fingerprint); the sort and the allocation happen only when the walk
/// hashes differently from the last one, which on a still frame it never does.
/// The sorted draw list itself — shared, so a frame that changed nothing hands out the same one.
pub type DrawList = std::sync::Arc<[(ZTarget, ZKey)]>;
#[derive(Default, Debug, Clone)]
pub struct OrderCache(std::cell::RefCell<Option<(u64, DrawList)>>);
/// Every `(target, key)` of the draw list, in ARENA order — the one walk both the fingerprint
/// and the rebuild are made of, so they cannot disagree.
fn walk(arena: &WidgetArena, mut f: impl FnMut(ZTarget, ZKey)) {
for (fh, frame) in arena.iter_frames() {
if !frame.effective_visible {
continue;
}
let (strata, level, insertion) = (frame.strata, frame.level, frame.insertion_seq);
out.push((ZTarget::Frame(fh), ZKey::frame(strata, level, insertion)));
f(ZTarget::Frame(fh), ZKey::frame(strata, level, insertion));
for &rh in &frame.regions {
let Some(region) = arena.region(rh) else {
continue;
@@ -412,11 +425,33 @@ pub fn traversal(arena: &WidgetArena) -> Vec<(ZTarget, ZKey)> {
matches!(region.kind, RegionKind::FontString),
region.decl_seq as u16,
);
out.push((ZTarget::Region(rh), key));
f(ZTarget::Region(rh), key);
}
}
}
pub fn traversal(arena: &WidgetArena) -> DrawList {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
let mut n = 0usize;
walk(arena, |t, k| {
t.hash(&mut hasher);
k.hash(&mut hasher);
n += 1;
});
n.hash(&mut hasher);
let fingerprint = hasher.finish();
if let Some((cached, list)) = arena.order_cache.0.borrow().as_ref() {
if *cached == fingerprint {
return list.clone();
}
}
let mut out: Vec<(ZTarget, ZKey)> = Vec::with_capacity(n);
walk(arena, |t, k| out.push((t, k)));
out.sort_by_key(|&(_, k)| k);
out
let list: DrawList = out.into();
*arena.order_cache.0.borrow_mut() = Some((fingerprint, list.clone()));
list
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -617,7 +652,7 @@ mod tests {
.unwrap();
a.set_shown(hidden, false);
let list: Vec<ZTarget> = traversal(&a).into_iter().map(|(t, _)| t).collect();
let list: Vec<ZTarget> = traversal(&a).iter().map(|&(t, _)| t).collect();
// Expected painter order: MEDIUM frame + its region first (lower strata), then the DIALOG
// frame followed by its two regions (Background texture before Artwork fontstring). The
@@ -647,9 +682,8 @@ mod tests {
let tracking = a.create(FrameKind::Frame, Some("Tracking".into()), None);
let backdrop = a.create(FrameKind::Frame, Some("Backdrop".into()), None);
let order = |a: &WidgetArena| -> Vec<ZTarget> {
traversal(a).into_iter().map(|(t, _)| t).collect()
};
let order =
|a: &WidgetArena| -> Vec<ZTarget> { traversal(a).iter().map(|&(t, _)| t).collect() };
// Declaration order while both start shown: tracking before backdrop.
assert_eq!(
order(&a),
+1 -1
View File
@@ -100,7 +100,7 @@ impl UiScript {
// [`effective_clip`] walks a quad's owner up through this to find every ancestor ScrollFrame
// it is clipped by (nested ScrollFrames intersect).
let scroll_sources = scroll_clip_sources(&model);
for (target, zkey) in list {
for &(target, zkey) in list.iter() {
let (rect, alpha, content, clip, scale) = match target {
ZTarget::Frame(fh) => {
let frame = model.arena.frame(fh);
+6
View File
@@ -206,6 +206,10 @@ pub use petition::{
validate_guild_name, PetitionRecordView, PetitionRequest, PetitionState, PETITION_TYPE_CHARTER,
PETITION_TYPE_PETITION,
};
pub use tabard::{
emblem_mask_path, TabardHost, TabardIntent, EMBLEM_MASK_TOKEN, TABARD_COUNTS,
TABARD_CREATION_COST,
};
pub use worldmap_arrow::{ARROW_FOOTPRINT_PX, ARROW_MODEL};
pub use inspect::{InspectView, UnitReach};
@@ -1434,6 +1438,8 @@ impl UiScript {
if let Some(h) = model.arena.lookup(name) {
return model.arena.frame(h).map(|f| match f.kind {
crate::widget::FrameKind::Frame => "Frame",
// A `Frame` to Lua (1984): the registered name never becomes a class identity.
crate::widget::FrameKind::WorldFrame => "Frame",
crate::widget::FrameKind::Button => "Button",
crate::widget::FrameKind::CheckButton => "CheckButton",
// `GetObjectType 0x495b60` is two instructions and returns `"LootButton"`
+49 -4
View File
@@ -22,6 +22,38 @@ pub type TextureProbe = Box<dyn Fn(&str) -> bool>;
/// The host's texture **texel-size** oracle — see [`Model::texture_size_probe`].
pub type TextureSizeProbe = Box<dyn Fn(&str) -> Option<(u32, u32)>>;
/// A map from a minted object id to its handle, indexed directly: ids are dense (`next_id`
/// counts from 1), and every scripted method call on a frame or region starts with this lookup
/// — a few hundred a frame from the stock `OnUpdate` sweep alone (decision 1979's UI tick), so
/// the hash a `HashMap` paid per call was the wrong price for a one-word index.
#[derive(Debug, Clone)]
pub(crate) struct IdMap<T>(Vec<Option<T>>);
impl<T> Default for IdMap<T> {
fn default() -> Self {
Self(Vec::new())
}
}
impl<T: Copy> IdMap<T> {
pub(crate) fn get(&self, id: &u32) -> Option<&T> {
self.0.get(*id as usize).and_then(Option::as_ref)
}
pub(crate) fn contains_key(&self, id: &u32) -> bool {
self.get(id).is_some()
}
pub(crate) fn insert(&mut self, id: u32, v: T) -> Option<T> {
let i = id as usize;
if self.0.len() <= i {
self.0.resize(i + 1, None);
}
self.0[i].replace(v)
}
pub(crate) fn remove(&mut self, id: &u32) -> Option<T> {
self.0.get_mut(*id as usize).and_then(Option::take)
}
}
/// The Rust-side model behind the Lua VM — the arena, the layout inputs + resolved rects, the
/// id↔handle bijection, region visuals, and the event/script registrations. Held in `lua.app_data`
/// (interior-mutable) so callbacks reach it; contains **no** mlua handles (the MAXCSTACK discipline).
@@ -265,9 +297,9 @@ pub(crate) struct Model {
/// Monotonic id source (starts at 1; `0` is [`SCREEN`]).
pub(crate) next_id: u32,
pub(crate) id_to_frame: HashMap<u32, FrameHandle>,
pub(crate) id_to_frame: IdMap<FrameHandle>,
pub(crate) frame_to_id: HashMap<FrameHandle, u32>,
pub(crate) id_to_region: HashMap<u32, RegionHandle>,
pub(crate) id_to_region: IdMap<RegionHandle>,
pub(crate) region_to_id: HashMap<RegionHandle, u32>,
/// Region name → layout id — the region twin of the arena's frame-name publish (same
/// non-overwriting first-wins rule). `SetPoint`'s string `relativeTo` resolves through frames
@@ -1466,6 +1498,14 @@ pub(crate) struct Model {
/// why order matters). The pane's yaw is its own `ModelState` facing, like every migrated
/// window's (`UiScript::model_pane_facing`, 1751/1969).
pub(crate) dressup_intents: Vec<super::dressup::DressUpIntent>,
/// The tabard designer (1977): each `TabardModel`'s five values, the last seeded/cycled five
/// (the app's body preview), the host facts, and the queued intents.
pub(crate) tabard_designs: HashMap<crate::widget::FrameHandle, [i32; 5]>,
pub(crate) tabard_preview: Option<[i32; 5]>,
pub(crate) tabard_host: super::tabard::TabardHost,
pub(crate) tabard_intents: Vec<super::tabard::TabardIntent>,
/// The WorldFrame type's one-shot registry record (1984): `true` once the first is made.
pub(crate) world_frame_made: bool,
/// Unit token (lowercase) → what the app resolved about it this frame, for **every** token
/// that named a **live unit object**, creature as readily as player — the input to both
/// verified range predicates (`CanInspect`, `CheckInteractDistance`). An absent token is one
@@ -1699,9 +1739,9 @@ impl Model {
font_objects_by_lower: HashMap::new(),
region_resolved: HashMap::new(),
next_id: 1,
id_to_frame: HashMap::new(),
id_to_frame: IdMap::default(),
frame_to_id: HashMap::new(),
id_to_region: HashMap::new(),
id_to_region: IdMap::default(),
region_to_id: HashMap::new(),
region_names: HashMap::new(),
scripts: HashMap::new(),
@@ -2051,6 +2091,11 @@ impl Model {
inspect_notifies: Vec::new(),
inspect_clear: false,
dressup_intents: Vec::new(),
tabard_designs: HashMap::new(),
tabard_preview: None,
tabard_host: Default::default(),
tabard_intents: Vec::new(),
world_frame_made: false,
unit_reach: HashMap::new(),
chat_input: Vec::new(),
skills: skills::SkillsState::default(),
+23 -1
View File
@@ -153,9 +153,11 @@ fn enum_token(s: &str) -> String {
s.trim().to_ascii_uppercase()
}
/// The reference's strata NAME table (`0x8119f8`) has eight rows, `BACKGROUND`..`TOOLTIP`; stratum
/// 0 (`WORLD`) has no name and no XML or Lua can put a frame there — the WorldFrame's constructor
/// is its only writer (decision 1984, wow-re `worldframe-widget.md` §4).
fn strata_from_str(s: &str) -> Option<Strata> {
Some(match enum_token(s).as_str() {
"WORLD" => Strata::World,
"BACKGROUND" => Strata::Background,
"LOW" => Strata::Low,
"MEDIUM" => Strata::Medium,
@@ -203,6 +205,9 @@ pub fn frame_kind_from_tag(s: &str) -> Option<FrameKind> {
fn frame_kind_from_str(s: &str) -> Option<FrameKind> {
Some(match enum_token(s).as_str() {
"FRAME" => FrameKind::Frame,
// The world frame's own registered type (decision 1983; `0x495948` in the registration
// batch, the one row passing `1` as its third argument).
"WORLDFRAME" => FrameKind::WorldFrame,
// `TaxiRouteFrame` — a registered `CreateFrame` type that is a `CSimpleFrame` and NOTHING
// else, so it maps to `Frame` rather than earning a kind (decision 1828; wow-re
// `ui/scratch/taxiroute-widget-type.md`). Factory `0x495ba0` allocates `0x314`, the same
@@ -478,6 +483,20 @@ pub(super) fn create_frame(
) -> mlua::Result<Table> {
let frame_kind = frame_kind_from_str(&kind)
.ok_or_else(|| mlua::Error::runtime(format!("CreateFrame: unknown frame type '{kind}'")))?;
// The WorldFrame's registry record is a ONE-SHOT: the reference unlinks and releases it the
// moment the first `<WorldFrame>` is instantiated (`0x6ee439`), so a second one — from any
// XML, or `CreateFrame("WorldFrame")` — takes the lookup's miss leg, `Unknown frame type`
// (decision 1984). The loader reaches this through the same global, so it covers both.
if frame_kind == FrameKind::WorldFrame
&& lua
.app_data_ref::<Model>()
.expect("model app_data")
.world_frame_made
{
return Err(mlua::Error::runtime(format!(
"CreateFrame: unknown frame type '{kind}'"
)));
}
// **`name` and `inherits` are `lua_tostring` positions, and a NUMBER is a string to it.**
// `0x7060b0` reads both through `0x6f3690` with no type guard at all, so `CreateFrame("Frame",
// 5)` names the frame `"5"` — a `Value::String`-only match drops it (wow-re
@@ -569,6 +588,9 @@ pub(super) fn create_frame(
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let h = model.arena.create(frame_kind, name, parent_handle);
if frame_kind == FrameKind::WorldFrame {
model.world_frame_made = true;
}
// The client's CreateFrame inheritance (the ctor doc's "loader/CreateFrame concern" —
// widget/mod.rs `create`): a child enters its PARENT's stratum at the parent's level + 1.
// The ctor's bare MEDIUM/0 left a DIALOG-strata popup drawing its own translucent
@@ -119,6 +119,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
fn type_chain(kind: FrameKind) -> &'static [&'static str] {
match kind {
FrameKind::Frame => &["Frame", "Region"],
FrameKind::WorldFrame => &["Frame", "Region"],
FrameKind::Button => &["Button", "Frame", "Region"],
FrameKind::CheckButton => &["CheckButton", "Button", "Frame", "Region"],
// `CLootButton::IsObjectType 0x495af0` prepends its own name to the base's three
+14
View File
@@ -150,6 +150,20 @@ impl UiScript {
/// can be checked against is a handle set snapshotted earlier ([`Self::live_targets`]'s). The
/// addon harness's use probe asks exactly that — a pointer event is only an addon's to be
/// judged by when the frame under the cursor is one the addon itself created.
/// Whether a frame id names a [`FrameKind::WorldFrame`] — the reference's world frame, whose
/// mouse hits belong to the 3D world (decisions 1983/1984). The hit test still answers it (it
/// is mouse-enabled by construction, so an addon's `OnEnter`/`OnMouseDown` on it fire — the
/// reference runs those, then the click's `BUTTON1`/`BUTTON2` binding, which IS the world
/// click), but the app's pointer arbiter does not count it as being over the UI.
pub fn is_world_frame(&self, id: u32) -> bool {
let model = self.model_ref();
model
.id_to_frame
.get(&id)
.and_then(|h| model.arena.frame(*h))
.is_some_and(|f| f.kind == crate::widget::FrameKind::WorldFrame)
}
pub fn hit_test_frame(&self, x: f32, y: f32) -> Option<FrameHandle> {
let model = self.model_ref();
let sorted = order::traversal(&model.arena);
+536 -8
View File
@@ -1,12 +1,31 @@
//! The guild tabard designer's engine surface (decision 1977): the `TabardModel` kind's own method
//! table (`0x84ee40`, ten verbs — filled from wow-re `system/ui/scratch/tabard-designer.md`) and
//! the window's two globals.
//! The guild tabard designer's engine surface (decision 1977; wow-re
//! `system/ui/scratch/tabard-designer.md`, VERIFIED at the bytes unless marked): the
//! `TabardModel` kind's own method table (`0x84ee40`, ten verbs) and the window's two globals.
//!
//! `GetTabardCreationCost()` is a **hard-coded constant**: `0x6d6de0` is `mov eax,0x186a0; ret` —
//! 100 000 copper, ten gold, no `.data` cell and no server input (wow-re
//! `system/ui/scratch/petition-charter-api.md`, the adjacent-family paragraph; VERIFIED there).
//! **The designer's whole state is five ints on the frame** — emblem style, emblem colour,
//! border style, border colour, background colour — bounded by a `.rdata` constant table
//! (`0x808220`: 170 · 17 · 6 · 17 · 51) that no DBC feeds. A `TabardModel` is well-defined only
//! once `InitializeTabardColors()` has run: the constructor writes nothing.
//!
//! **What is the app's.** The guild record the seed reads and the save-in-flight latch
//! `CanSaveTabardNow` tests are pushed by the app ([`TabardHost`]); `Save()` and
//! `CloseTabardCreation()` queue [`TabardIntent`]s the app drains — the fourteen pre-flight checks
//! of the save (`0x5e03f0`) need the purse, the guild rank and the interaction target, none of
//! which this crate holds. The two `…EmblemTexture(texture)` methods are **setters**: the reference
//! decodes the emblem BLP and installs a white-carrying-its-alpha 128×64 / 128×32 texture into the
//! passed Texture widget. Here they set the region's texture to an [`EMBLEM_MASK_TOKEN`] path the
//! app resolves to exactly that image (`ui_script::extract`).
//!
//! `GetTabardCreationCost()` is a **hard-coded constant**: `0x6d6de0` is `mov eax,0x186a0; ret`
//! — 100 000 copper, ten gold, no `.data` cell and no server input.
use mlua::Lua;
use mlua::{Lua, Value};
use super::binding_abi::number_arg;
use super::object::{decode_id, frame_handle_of};
use super::region::region_handle_of;
use super::Model;
use crate::widget::{FrameHandle, FrameKind, RegionKind};
/// Registry key of the TabardModel method table — probed before PlayerModel's and Model's
/// (`object.rs`'s kind chain).
@@ -15,8 +34,312 @@ pub(super) const REG_TABARDMODEL_METHODS: &str = "__benilla_tabardmodel_methods"
/// `0x6d6de0`: the designer's price, in copper.
pub const TABARD_CREATION_COST: u32 = 100_000;
/// `0x808220`, the five slot counts in `CycleVariation` order: emblem style, emblem colour, border
/// style, border colour, background colour. A constant — the client consults no DBC.
pub const TABARD_COUNTS: [i32; 5] = [170, 17, 6, 17, 51];
/// The token a Texture region carries after `Get{Upper,Lower}EmblemTexture(texture)`: the prefix
/// plus the emblem path (`Textures\GuildEmblems\Emblem_%02d_%02d_T{U,L}_U`). The app draws
/// it as the reference's generated image — white RGB carrying the emblem's own alpha, a tintable
/// mask (`0x503431`: `(a << 24) | 0x00FFFFFF`).
pub const EMBLEM_MASK_TOKEN: &str = "benilla:emblem-mask:";
/// The BLP path inside an [`EMBLEM_MASK_TOKEN`] texture, or `None` for an ordinary path.
pub fn emblem_mask_path(texture: &str) -> Option<&str> {
texture.strip_prefix(EMBLEM_MASK_TOKEN)
}
/// What the app pushes for the designer's two host-side facts.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct TabardHost {
/// The local player's guild record as the DBCache holds it — `Some` once cached (its five
/// emblem fields, `-1` each for an undesigned tabard), `None` while it has not arrived or the
/// player has no guild.
pub guild_record: Option<[i32; 5]>,
/// `[0xc4d780]`, the save-in-flight latch: set when the save is sent, cleared by the reply.
pub save_pending: bool,
}
/// What the designer asks the app to do.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TabardIntent {
/// `TabardModel:Save()` — the five values, for the fourteen pre-flight checks and the send.
Save([i32; 5]),
/// `CloseTabardCreation()`.
Close,
}
impl super::UiScript {
/// Push the guild record and the save latch.
pub fn set_tabard_host(&mut self, host: TabardHost) {
self.model_mut().tabard_host = host;
}
/// The designer's current five values — the preview the app dresses the player's body in
/// while the frame is up — or `None` before `InitializeTabardColors()` has seeded them.
pub fn tabard_design(&self) -> Option<[i32; 5]> {
self.model_ref().tabard_preview
}
/// The queued intents since the last drain.
pub fn take_tabard_intents(&mut self) -> Vec<TabardIntent> {
std::mem::take(&mut self.model_mut().tabard_intents)
}
}
/// The three `this` raises every method shares (`0x847ef8` / `0x847ec0` / `0x847e98`), then the
/// frame handle of a `TabardModel`.
fn this_tabard(lua: &Lua, this: &Value) -> mlua::Result<FrameHandle> {
let Value::Table(t) = this else {
return Err(mlua::Error::runtime(
"Attempt to find 'this' in non-table object (used '.' instead of ':' ?)",
));
};
if decode_id(t).is_err() {
return Err(mlua::Error::runtime(
"Attempt to find 'this' in non-framescript object",
));
}
let wrong = || mlua::Error::runtime("Wrong object type for member function");
let h = frame_handle_of(lua, t).map_err(|_| wrong())?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let is_tabard = model
.arena
.frame(h)
.is_some_and(|f| f.kind == FrameKind::TabardModel);
drop(model);
if !is_tabard {
return Err(wrong());
}
Ok(h)
}
/// The five values of a designer that has been seeded, else nothing — the file-name getters and
/// the texture setters read the raw fields, which the reference leaves uninitialised before the
/// seed; here an unseeded designer reads as all zeros, the first legal design.
fn design(model: &Model, h: FrameHandle) -> [i32; 5] {
model.tabard_designs.get(&h).copied().unwrap_or([0; 5])
}
fn store(lua: &Lua, h: FrameHandle, five: [i32; 5]) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.tabard_designs.insert(h, five);
model.tabard_preview = Some(five);
}
/// `0x455c70`: `(count · rand) >> 32` — a draw in `[0, count)` (INFERRED uniform, VERIFIED scaling).
fn draw(rand: u32, count: i32) -> i32 {
((u64::from(count as u32) * u64::from(rand)) >> 32) as i32
}
/// A clock-seeded generator standing in for the client's table-driven one (`0x4531e0`); only the
/// range law above is the reference's.
fn random_words() -> impl Iterator<Item = u32> {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x9E37_79B9_7F4A_7C15);
let mut x = nanos | 1;
std::iter::from_fn(move || {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
Some((x >> 32) as u32)
})
}
/// `InitializeTabardColors()`'s worker `0x5028e0`: the guild record's five when the record is
/// cached and none is `-1`, else five random draws — never zeros.
fn seed(host: TabardHost) -> [i32; 5] {
match host.guild_record {
Some(rec) if rec.iter().all(|v| *v != -1) => rec,
_ => {
let mut words = random_words();
std::array::from_fn(|i| draw(words.next().unwrap_or(0), TABARD_COUNTS[i]))
}
}
}
/// `0x502ac0`: `v ← (v + count + delta) mod count`, a signed `idiv`; `|delta| ≥ count` is a silent
/// no-op (no repaint either). Returns whether anything changed.
fn cycle(five: &mut [i32; 5], slot: usize, delta: i32) -> bool {
let count = TABARD_COUNTS[slot];
if delta.wrapping_abs() >= count {
return false;
}
five[slot] = (five[slot] + count + delta) % count;
true
}
/// The emblem path for a half (`TU` upper, `TL` lower) — `0x47a520`'s format. The reference's
/// setters append `.BLP` before decoding (`0x503396`); here the path stays extensionless, the
/// form every region texture carries and the app's resolver completes.
fn emblem_path(five: [i32; 5], half: &str) -> String {
format!(
"Textures\\GuildEmblems\\Emblem_{:02}_{:02}_{half}_U",
five[0], five[1]
)
}
/// `Get{Upper,Lower}EmblemTexture(texture)` — the Texture-widget argument's three raises
/// (`0x84efc8` / `0x84f050` / `0x84f010` and the lower twins), then the install.
fn emblem_texture(lua: &Lua, this: Value, texture: Value, upper: bool) -> mlua::Result<()> {
let h = this_tabard(lua, &this)?;
let (name, half) = if upper {
("GetUpperEmblemTexture", "TU")
} else {
("GetLowerEmblemTexture", "TL")
};
let Value::Table(t) = texture else {
return Err(mlua::Error::runtime(format!(
"Usage: TabardModel:{name}(texture)"
)));
};
if decode_id(&t).is_err() {
return Err(mlua::Error::runtime(format!(
"TabardModel:{name}(): Couldn't find 'this' in texture object"
)));
}
let wrong = || {
mlua::Error::runtime(format!(
"TabardModel:{name}(): Wrong object type, expected texture"
))
};
let rh = region_handle_of(lua, &t).map_err(|_| wrong())?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if !model
.arena
.region(rh)
.is_some_and(|r| r.kind == RegionKind::Texture)
{
return Err(wrong());
}
let path = emblem_path(design(&model, h), half);
let data = model.region_data.entry(rh).or_default();
data.texture = Some(format!("{EMBLEM_MASK_TOKEN}{path}"));
data.fill = None;
data.portrait_unit = None;
data.circular = false;
Ok(())
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
// `InitializeTabardColors()` — `0x502b10`: the active player gates the seed; zero returns.
m.set(
"InitializeTabardColors",
lua.create_function(|lua, this: Value| {
let h = this_tabard(lua, &this)?;
let host = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
if !model.unit("player").is_some_and(|u| u.exists) {
return Ok(());
}
model.tabard_host
};
store(lua, h, seed(host));
Ok(())
})?,
)?;
// `Save()` — `0x502bd0`: the intent carries the five; the app does the fourteen checks.
m.set(
"Save",
lua.create_function(|lua, this: Value| {
let h = this_tabard(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if !model.unit("player").is_some_and(|u| u.exists) {
return Ok(());
}
let five = design(&model, h);
model.tabard_intents.push(TabardIntent::Save(five));
Ok(())
})?,
)?;
// `CycleVariation(variationIndex, delta)` — `0x502ca0`: both args `lua_isnumber` else the
// usage raise; the index 1..5 (unsigned-checked, so 0 is out) else its own raise; both
// `ftol`-truncated; the worker's silent no-op for a delta past the count.
m.set(
"CycleVariation",
lua.create_function(|lua, (this, index, delta): (Value, Value, Value)| {
let h = this_tabard(lua, &this)?;
const USAGE: &str = "Usage: CycleVariation(variationIndex, delta)";
let index = number_arg(lua, index, USAGE)?;
let delta = number_arg(lua, delta, USAGE)?;
let slot = usize::try_from(index)
.ok()
.and_then(|i| i.checked_sub(1))
.filter(|i| *i < 5)
.ok_or_else(|| mlua::Error::runtime("Invalid variationIndex in CycleVariation"))?;
let mut five = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
design(&model, h)
};
if cycle(&mut five, slot, delta as i32) {
store(lua, h, five);
}
Ok(())
})?,
)?;
// The four `…FileName` getters (`0x502dc0` / `0x502ea0` / `0x502f80` / `0x503070`): one
// string each, no extension — the loader appends `.blp`.
for (name, upper, background) in [
("GetUpperBackgroundFileName", true, true),
("GetLowerBackgroundFileName", false, true),
("GetUpperEmblemFileName", true, false),
("GetLowerEmblemFileName", false, false),
] {
m.set(
name,
lua.create_function(move |lua, this: Value| {
let h = this_tabard(lua, &this)?;
let five = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
design(&model, h)
};
let half = if upper { "TU" } else { "TL" };
Ok(if background {
format!("Textures\\GuildEmblems\\Background_{:02}_{half}_U", five[4])
} else {
format!(
"Textures\\GuildEmblems\\Emblem_{:02}_{:02}_{half}_U",
five[0], five[1]
)
})
})?,
)?;
}
// The two texture SETTERS (`0x503160` / `0x503540`), zero returns.
m.set(
"GetUpperEmblemTexture",
lua.create_function(|lua, (this, texture): (Value, Value)| {
emblem_texture(lua, this, texture, true)
})?,
)?;
m.set(
"GetLowerEmblemTexture",
lua.create_function(|lua, (this, texture): (Value, Value)| {
emblem_texture(lua, this, texture, false)
})?,
)?;
// `CanSaveTabardNow()` — `0x503910` never resolves `this`: the number 1, or nil, from
// `player && guildRecordCached && !savePending` (`0x5f0800`). Not a would-it-succeed test.
m.set(
"CanSaveTabardNow",
lua.create_function(|lua, _: mlua::MultiValue| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let can = model.unit("player").is_some_and(|u| u.exists)
&& model.tabard_host.guild_record.is_some()
&& !model.tabard_host.save_pending;
Ok(if can { Value::Integer(1) } else { Value::Nil })
})?,
)?;
lua.set_named_registry_value(REG_TABARDMODEL_METHODS, m)?;
let g = lua.globals();
@@ -25,12 +348,41 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"GetTabardCreationCost",
lua.create_function(|_, ()| Ok(i64::from(TABARD_CREATION_COST)))?,
)?;
// `CloseTabardCreation()` — `0x4f5900`: zero args, zero returns, no packet; the close core
// (`0x4f58a0`) and its `CLOSE_TABARD_FRAME` are the app's, gated on a stored vendor.
g.set(
"CloseTabardCreation",
lua.create_function(|lua, _: mlua::MultiValue| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.tabard_intents
.push(TabardIntent::Close);
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
use super::*;
use crate::script::{UiScript, UnitState};
fn vm() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_unit(
"player",
Some(UnitState {
exists: true,
name: Some("Probe".into()),
level: 60,
..Default::default()
}),
);
s.run(r#"t = CreateFrame("TabardModel", "TM") f = CreateFrame("Frame", "Plain") tex = f:CreateTexture("Cell")"#)
.unwrap();
s
}
#[test]
fn the_creation_cost_is_ten_gold_and_a_tabard_model_is_its_own_kind() {
@@ -53,4 +405,180 @@ mod tests {
"the inherited pane verbs"
);
}
#[test]
fn the_seed_takes_the_guild_record_or_draws_in_range() {
let mut s = vm();
s.set_tabard_host(TabardHost {
guild_record: Some([7, 3, 2, 12, 5]),
save_pending: false,
});
s.run("t:InitializeTabardColors()").unwrap();
assert_eq!(s.tabard_design(), Some([7, 3, 2, 12, 5]));
assert_eq!(
s.eval::<String>("return t:GetUpperEmblemFileName()")
.unwrap(),
r"Textures\GuildEmblems\Emblem_07_03_TU_U"
);
assert_eq!(
s.eval::<String>("return t:GetLowerBackgroundFileName()")
.unwrap(),
r"Textures\GuildEmblems\Background_05_TL_U"
);
// An undesigned record (a -1 anywhere) and no record both draw at random, in range.
for rec in [Some([7, 3, 2, -1, 5]), None] {
s.set_tabard_host(TabardHost {
guild_record: rec,
save_pending: false,
});
s.run("t:InitializeTabardColors()").unwrap();
let five = s.tabard_design().unwrap();
for (v, count) in five.iter().zip(TABARD_COUNTS) {
assert!((0..count).contains(v), "{five:?} within {TABARD_COUNTS:?}");
}
}
// No player: nothing is seeded (the design keeps its last value).
s.set_unit("player", None);
s.set_tabard_host(TabardHost {
guild_record: Some([1, 1, 1, 1, 1]),
save_pending: false,
});
s.run("t:InitializeTabardColors()").unwrap();
assert_ne!(s.tabard_design(), Some([1, 1, 1, 1, 1]));
}
#[test]
fn cycle_variation_wraps_both_ways_and_ignores_a_delta_past_the_count() {
let mut s = vm();
s.set_tabard_host(TabardHost {
guild_record: Some([0, 0, 0, 0, 0]),
save_pending: false,
});
s.run("t:InitializeTabardColors()").unwrap();
s.run("t:CycleVariation(1, -1)").unwrap();
assert_eq!(s.tabard_design().unwrap()[0], 169, "wraps down");
s.run("t:CycleVariation(1, 1) t:CycleVariation(3, 5) t:CycleVariation(3, 1)")
.unwrap();
assert_eq!(s.tabard_design().unwrap()[0], 0);
assert_eq!(
s.tabard_design().unwrap()[2],
0,
"5 then 1 over a count of 6 wraps to 0"
);
s.run("t:CycleVariation(5, 6) t:CycleVariation('2.9', '1.9')")
.unwrap();
assert_eq!(
s.tabard_design().unwrap()[4],
6,
"truncation: (2.9, 1.9) is (2, 1)"
);
assert_eq!(s.tabard_design().unwrap()[1], 1);
s.run("t:CycleVariation(2, 17)").unwrap();
assert_eq!(
s.tabard_design().unwrap()[1],
1,
"|delta| >= count is a silent no-op"
);
for (call, needle) in [
(
"t:CycleVariation(0, 1)",
"Invalid variationIndex in CycleVariation",
),
(
"t:CycleVariation(6, 1)",
"Invalid variationIndex in CycleVariation",
),
(
"t:CycleVariation(1)",
"Usage: CycleVariation(variationIndex, delta)",
),
(
"t:CycleVariation('x', 1)",
"Usage: CycleVariation(variationIndex, delta)",
),
("t.CycleVariation(1, 1)", "used '.' instead of ':'"),
("t.CycleVariation({}, 1, 1)", "non-framescript object"),
(
"t.CycleVariation(f, 1, 1)",
"Wrong object type for member function",
),
] {
let err = s.run(call).unwrap_err().to_string();
assert!(err.contains(needle), "{call}: {err}");
}
}
#[test]
fn the_texture_setters_install_the_mask_token_and_raise_on_a_non_texture() {
let mut s = vm();
s.set_tabard_host(TabardHost {
guild_record: Some([12, 4, 0, 0, 0]),
save_pending: false,
});
s.run("t:InitializeTabardColors() t:GetUpperEmblemTexture(tex)")
.unwrap();
let got = s.eval::<String>("return tex:GetTexture()").unwrap();
assert_eq!(
emblem_mask_path(&got),
Some(r"Textures\GuildEmblems\Emblem_12_04_TU_U")
);
s.run("t:GetLowerEmblemTexture(tex)").unwrap();
let got = s.eval::<String>("return tex:GetTexture()").unwrap();
assert!(got.ends_with("Emblem_12_04_TL_U"), "{got}");
for (call, needle) in [
(
"t:GetUpperEmblemTexture()",
"Usage: TabardModel:GetUpperEmblemTexture(texture)",
),
(
"t:GetUpperEmblemTexture({})",
"Couldn't find 'this' in texture object",
),
(
"t:GetLowerEmblemTexture(f)",
"Wrong object type, expected texture",
),
] {
let err = s.run(call).unwrap_err().to_string();
assert!(err.contains(needle), "{call}: {err}");
}
}
#[test]
fn can_save_is_one_or_nil_over_the_three_conjuncts_and_save_and_close_queue_intents() {
let mut s = vm();
s.set_tabard_host(TabardHost {
guild_record: None,
save_pending: false,
});
assert!(s
.eval::<bool>("return t:CanSaveTabardNow() == nil")
.unwrap());
s.set_tabard_host(TabardHost {
guild_record: Some([-1; 5]),
save_pending: false,
});
assert!(
s.eval::<bool>("return t:CanSaveTabardNow() == 1").unwrap(),
"a cached record counts even undesigned; the number 1, not true"
);
s.set_tabard_host(TabardHost {
guild_record: Some([-1; 5]),
save_pending: true,
});
assert!(s
.eval::<bool>("return t:CanSaveTabardNow() == nil")
.unwrap());
assert!(
s.eval::<bool>("return TabardModel_CanSave == nil and t.CanSaveTabardNow() == nil")
.unwrap(),
"never resolves this: a dot-call is fine"
);
s.run("t:InitializeTabardColors() t:Save() CloseTabardCreation()")
.unwrap();
let intents = s.take_tabard_intents();
assert!(matches!(intents[0], TabardIntent::Save(_)));
assert_eq!(intents[1], TabardIntent::Close);
assert!(s.take_tabard_intents().is_empty());
}
}
@@ -47,6 +47,7 @@ mod tooltip_spell;
mod tooltip_unit;
mod toplevel;
mod visibility;
mod worldframe;
mod worldmap;
/// **A bounded chunk reports instead of hanging** — the guard decision 1247's hang called for.
@@ -0,0 +1,98 @@
//! The `WorldFrame` kind (decisions 1983/1984; wow-re `worldframe-widget.md`): a `Frame` to Lua,
//! a one-shot type, born in stratum `WORLD` with mouse and wheel enabled, and a hit the app's
//! arbiter must be able to tell apart.
use crate::script::UiScript;
fn vm() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(r#"WorldFrame = CreateFrame("WorldFrame", "WorldFrame") WorldFrame:SetAllPoints()"#)
.unwrap();
s.resolve();
s
}
/// `GetObjectType()` answers `"Frame"` and `IsObjectType("WorldFrame")` is nil: the class
/// inherits the base table's type slots (the TaxiRouteFrame precedent), so the registered name
/// never becomes an identity.
#[test]
fn the_world_frame_is_a_frame_to_lua() {
let s = vm();
assert_eq!(
s.eval::<String>("return WorldFrame:GetObjectType()")
.unwrap(),
"Frame"
);
assert!(s
.eval::<bool>(r#"return WorldFrame:IsObjectType("Frame") == 1 and WorldFrame:IsObjectType("Region") == 1"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return WorldFrame:IsObjectType("WorldFrame") == nil"#)
.unwrap());
}
/// The registry record is destroyed on the first instantiation: a second one is an unknown type.
#[test]
fn a_second_world_frame_is_an_unknown_type() {
let s = vm();
let err = s
.run(r#"CreateFrame("WorldFrame", "Another")"#)
.unwrap_err()
.to_string();
assert!(err.contains("unknown frame type 'WorldFrame'"), "{err}");
assert!(s.eval::<bool>("return Another == nil").unwrap());
}
/// Born in stratum 0 (`WORLD`, below `BACKGROUND`) with key/mouse/wheel enabled; no XML or Lua can
/// name that stratum for another frame.
#[test]
fn the_world_frame_is_born_in_stratum_world_with_the_mouse_and_wheel() {
let s = vm();
assert_eq!(
s.eval::<String>("return WorldFrame:GetFrameStrata()")
.unwrap(),
"WORLD"
);
assert!(s
.eval::<bool>(
"return WorldFrame:IsMouseEnabled() == 1 and WorldFrame:IsMouseWheelEnabled() == 1"
)
.unwrap());
s.run(r#"plain = CreateFrame("Frame", "Plain") plain:SetFrameStrata("BACKGROUND")"#)
.unwrap();
let err = s
.run(r#"plain:SetFrameStrata("WORLD")"#)
.unwrap_err()
.to_string();
assert!(err.contains("unknown frameStrata 'WORLD'"), "{err}");
assert_eq!(
s.eval::<String>("return plain:GetFrameStrata()").unwrap(),
"BACKGROUND"
);
}
/// The hit test answers the world frame where nothing else is under the cursor — the addon-facing
/// scripts on it fire — and the app can tell that hit apart from a UI hit.
#[test]
fn a_hit_on_the_world_frame_is_told_apart_from_a_ui_hit() {
let mut s = vm();
let id = s
.hit_test(100.0, 100.0)
.expect("the world frame is under the cursor");
assert!(s.is_world_frame(id));
s.run(
r#"plate = CreateFrame("Frame", "Plate", WorldFrame) plate:SetWidth(50) plate:SetHeight(50)
plate:SetPoint("BOTTOMLEFT", WorldFrame, "BOTTOMLEFT", 75, 75) plate:EnableMouse(true)"#,
)
.unwrap();
s.resolve();
let over_plate = s
.hit_test(100.0, 100.0)
.expect("the plate is under the cursor now");
assert!(
!s.is_world_frame(over_plate),
"a child frame's hit is the UI's"
);
assert!(s.is_world_frame(s.hit_test(600.0, 600.0).unwrap()));
}
+12
View File
@@ -20,6 +20,18 @@ pub use messageframe::*;
pub enum FrameKind {
/// Plain `CSimpleFrame` — the base container.
Frame,
/// The reference's `CGWorldFrame` (decisions 1983/1984; wow-re `worldframe-widget.md`): the
/// singleton the 3D world renders behind. Registered as its own frame type (`"WorldFrame"`
/// @`0x843450`, factory `0x4959d0`) whose registry record is **destroyed on the first
/// instantiation** — a second `<WorldFrame>` or `CreateFrame("WorldFrame")` is an unknown type;
/// **a `Frame` to Lua** (`GetObjectType()` answers `"Frame"`, `IsObjectType("WorldFrame")` is
/// nil — its vtable inherits the base's type slots, the TaxiRouteFrame precedent); born with
/// key, mouse and wheel enabled (`0xE`) in **stratum 0, `WORLD`, below `BACKGROUND`**, which
/// no XML or Lua can name. benilla draws the world through Bevy, so the kind is a full-screen
/// frame whose hits are the WORLD's: the app's pointer arbiter treats a hovered WorldFrame as
/// not-over-UI while its own scripts still fire, as the reference's do before the click's
/// binding runs.
WorldFrame,
Button,
/// `CLootButton` (`Ui\\LootFrame.h:21`, factory `0x495a30`, size `0x4e0`) — a
/// **registered `CreateFrame` type**, one of the eight the client registers through
+14 -2
View File
@@ -420,6 +420,8 @@ pub struct WidgetArena {
regions: Arena<Region>,
names: HashMap<String, FrameHandle>,
next_insertion: u32,
/// The draw list's fingerprint cache ([`crate::order::traversal`]).
pub(crate) order_cache: crate::order::OrderCache,
/// Monotonic count of Minimap-kind frames ever created — an O(1) "a new Minimap widget
/// exists" signal, so the per-frame state feed (`set_minimap_inside`'s caller) re-pushes
/// exactly when one appears instead of walking every frame to find out.
@@ -484,6 +486,9 @@ pub fn mouse_enabled_by_ctor(kind: FrameKind) -> bool {
FrameKind::Button
| FrameKind::CheckButton
| FrameKind::EditBox
// `CGWorldFrame`'s ctor enables key + mouse + wheel (`0x481b09`/`0x481b14`/`0x481b1f`,
// wow-re `mouse-enable-law.md`); the hit is the world's, not the UI's (decision 1983).
| FrameKind::WorldFrame
// A Slider's thumb must be draggable: every scrollbar is a UIPanelScrollBarTemplate
// Slider that declares no `enableMouse` yet is draggable in-game (decision 0250).
| FrameKind::Slider
@@ -503,6 +508,7 @@ impl WidgetArena {
names: HashMap::new(),
next_insertion: 0,
minimap_created: 0,
order_cache: Default::default(),
ticked_kinds: Vec::new(),
tooltip_kinds: Vec::new(),
minimap_kinds: Vec::new(),
@@ -643,7 +649,13 @@ impl WidgetArena {
children: Vec::new(),
regions: Vec::new(),
title_region: None,
strata: Strata::default(),
// The WorldFrame's constructor is the one writer of stratum 0 (`WORLD`, below
// `BACKGROUND`); every other kind starts at the base ctor's MEDIUM (1984).
strata: if kind == FrameKind::WorldFrame {
Strata::World
} else {
Strata::default()
},
level: 0,
alpha,
effective_alpha,
@@ -656,7 +668,7 @@ impl WidgetArena {
// with no attribute, and nothing else is.
mouse_wheel_enabled: matches!(
kind,
FrameKind::ScrollingMessageFrame | FrameKind::ScrollFrame
FrameKind::ScrollingMessageFrame | FrameKind::ScrollFrame | FrameKind::WorldFrame
),
// Nothing is keyboard-enabled by construction: `0x76af00` is only ever reached from the
// XML attribute or an explicit call, never a ctor (`scripts-auto-enable.md` §1-2).
+17 -3
View File
@@ -548,8 +548,8 @@ pub struct BillboardPlace;
pub(crate) fn face_billboards(
mut commands: Commands,
time: Res<Time>,
cam: Query<&GlobalTransform, With<WorldCamera>>,
owners: Query<&GlobalTransform, (Without<WorldCamera>, Without<BillboardCard>)>,
cam: Query<(Ref<GlobalTransform>, Option<Ref<Transform>>), With<WorldCamera>>,
owners: Query<Ref<GlobalTransform>, (Without<WorldCamera>, Without<BillboardCard>)>,
mut cards: Query<
(
Entity,
@@ -562,17 +562,29 @@ pub(crate) fn face_billboards(
Without<WorldCamera>,
>,
) {
let Ok(cam_tf) = cam.single() else {
let Ok((cam_tf, cam_local)) = cam.single() else {
return;
};
// The camera basis — the VIEW-MATRIX axes the byte law substitutes (one shared orientation
// for every billboard; never a per-pivot aim).
// The propagated frame OR the seat's own write: a teleport frame moves the local before
// propagation runs (see doodad_anim's gate), and the old read carried a pre-snap placement
// across the snap.
let cam_moved = cam_tf.is_changed() || cam_local.as_ref().is_some_and(|l| l.is_changed());
let (fwd, right, up) = (*cam_tf.forward(), *cam_tf.right(), *cam_tf.up());
let elapsed_ms = time.elapsed().as_millis() as u32;
for (entity, mut card, mut tf, mut global, tag) in &mut cards {
// A card with no track is a pure function of the camera basis and its owner's frame:
// when neither moved this frame, the placement below would come out bit-identical
// (the write guards at the end already knew that; the recompute did not — 1979's
// floor, every resident card re-placed on a still frame).
let tracked = card.scale_anim.is_some() || card.seq_translation.is_some();
if let Some(owner) = card.follow {
match owners.get(owner) {
Ok(gt) => {
if !tracked && !cam_moved && !gt.is_changed() {
continue;
}
let pivot = card.local_pivot;
card.re_place(gt.compute_transform(), pivot);
// The card-provenance trace (`WOW_MOVE_TRACE`, ~2 Hz): where each following
@@ -602,6 +614,8 @@ pub(crate) fn face_billboards(
continue;
}
}
} else if !tracked && !cam_moved {
continue;
}
// The gseq attach anchor: stamped on the card's first placement pass — the reference's
// once-per-instance scene-clock snapshot (decision 0856).
+35
View File
@@ -29,6 +29,41 @@ pub fn deterministic_run() -> bool {
std::env::var("WOW_CAPTURE").is_ok()
}
/// How many frames each still-frame input has read as changed since startup — `[camera
/// transform, DebugState, ViewDistance, ExteriorWindows, CameraInteriorClaim]`. The still-frame
/// skips (decision 1979: the visibility walk, billboards, emitter gates, doodad hosts) engage
/// only when all five are still; an input rewritten every frame silently disarms every one of
/// them, and `FPS_PROBE`'s `noisy=` prints these as per-window deltas so that input is named.
pub static STILL_INPUTS_CHANGED: [std::sync::atomic::AtomicU32; 5] = [
std::sync::atomic::AtomicU32::new(0),
std::sync::atomic::AtomicU32::new(0),
std::sync::atomic::AtomicU32::new(0),
std::sync::atomic::AtomicU32::new(0),
std::sync::atomic::AtomicU32::new(0),
];
/// `Last`: count this frame's changed still-frame inputs into [`STILL_INPUTS_CHANGED`].
pub(crate) fn count_still_inputs(
cam: Query<Ref<GlobalTransform>, With<crate::view::WorldCamera>>,
debug: Res<DebugState>,
view: Res<crate::view::ViewDistance>,
windows: Res<crate::wmo_portal::ExteriorWindows>,
claim: Res<crate::wmo_portal::CameraInteriorClaim>,
) {
let changed = [
cam.iter().any(|t| t.is_changed()),
debug.is_changed(),
view.is_changed(),
windows.is_changed(),
claim.is_changed(),
];
for (slot, c) in STILL_INPUTS_CHANGED.iter().zip(changed) {
if c {
slot.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
}
}
/// Root debug state. One resource, grouped into per-subsystem sections. Defaults: panel hidden
/// (`open: false`), each section its own `Default` (so this derives cleanly).
#[derive(Resource)]
+126 -4
View File
@@ -474,12 +474,22 @@ fn gate_doodad_anim(
vis: Query<&Visibility>,
cam: Query<
(
&GlobalTransform,
Ref<GlobalTransform>,
&bevy::camera::primitives::Frustum,
&bevy::camera::Projection,
// The seat's own write, visible THIS frame: `GlobalTransform` only changes after
// propagation, which runs after this system — so on a teleport frame the global
// reads still while the camera has already moved. Reading the local too is what
// keeps the verdict reuse below from carrying a pre-snap verdict across a snap
// (the over-bright lamppost glow after a .tele, 2026-09-04).
Option<Ref<Transform>>,
),
With<crate::view::WorldCamera>,
>,
// A meshed host's verdict is its meshes' `Visibility`; when none moved this frame and the
// camera stood still, every host's verdict is last frame's (decision 1979's floor: ~1.5 k
// hosts × their submesh lookups on every still frame).
changed_vis: Query<(), (Changed<Visibility>, With<crate::model_render::ModelPart>)>,
// The far-clip wall — the meshless host's draw-set gate needs the same depth bound the
// emitters and the doodad meshes use…
view: Res<crate::view::ViewDistance>,
@@ -506,11 +516,19 @@ fn gate_doodad_anim(
let world_cam = cam.single().ok();
let exterior_gate = crate::exterior_cull::ExteriorGate::build(
&exterior_windows,
world_cam.map(|(tf, _, proj)| (tf, proj)),
world_cam.as_ref().map(|(tf, _, proj, _)| (&**tf, *proj)),
);
let camera_instance = camera_claim.0.map(|c| c.room.instance);
let verdicts_still = world_cam.as_ref().is_some_and(|(tf, _, _, local)| {
!tf.is_changed() && !local.as_ref().is_some_and(|l| l.is_changed())
}) && !exterior_windows.is_changed()
&& !camera_claim.is_changed()
&& !view.is_changed()
&& changed_vis.is_empty();
for (entity, mut host, lazy, pose, has_rig, player, drive) in &mut hosts {
let drawn = if host.meshes.is_empty() {
let drawn = if verdicts_still {
host.active
} else if host.meshes.is_empty() {
// Meshless (particles-only) host: the emitters' own draw-set law (see the `fade`
// field doc) — the reference ticks animation for any model in the draw set, and a
// 0-batch model is admitted on its fade sphere exactly like its emitters are.
@@ -519,7 +537,7 @@ fn gate_doodad_anim(
// term went missing here as well as in the emitters (decision 0678 / bug B39) — a
// meshless fire prop kept animating its bones at any distance past the wall.
let (radius, center) = host.fade;
world_cam.is_some_and(|(cam_tf, frustum, _)| {
world_cam.as_ref().is_some_and(|(cam_tf, frustum, _, _)| {
let cam_pos = cam_tf.translation();
// A meshless host is a placement's own particles-only model; it carries no building
// instance of its own, so the honest answer here is "test my sphere".
@@ -1193,6 +1211,110 @@ mod tests {
/// [`DoodadAnimHost::arm_clock`] — the sound lane's phase, which must be the SHARED clock's,
/// never the (draw-gated) player's. Three claims: it wraps with the clip, it is unaffected by
/// the host being parked, and an unarmed host has no phase at all.
/// The teleport frame (2026-09-04): this system runs before transform propagation, so the
/// camera's `GlobalTransform` still reads still on the frame its `Transform` was re-seated,
/// and the verdict reuse carried every host's pre-snap verdict across the snap — a lamppost
/// glow that should have parked kept its clock, a host that should have woken stayed parked.
/// The gate must read the seat's own write.
#[test]
fn a_reseated_camera_is_not_a_still_scene() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default()));
app.init_resource::<crate::view::ViewDistance>();
app.init_resource::<crate::wmo_portal::ExteriorWindows>();
app.init_resource::<crate::wmo_portal::CameraInteriorClaim>();
app.init_resource::<crate::rig_palette::RigPalettes>();
app.init_asset::<SkinnedMeshInverseBindposes>();
app.add_systems(Update, gate_doodad_anim);
// A camera 1000 yd from the host, looking away: the host's frustum verdict is "not
// drawn". No propagation plugin, so `GlobalTransform` never follows `Transform` —
// exactly the ordering the gate sees on the real snap frame.
let far =
Transform::from_xyz(1000.0, 0.0, 0.0).looking_at(Vec3::new(2000.0, 0.0, 0.0), Vec3::Y);
let projection = bevy::camera::Projection::from(bevy::camera::PerspectiveProjection {
far: 5000.0,
..default()
});
let frustum = bevy::camera::primitives::Frustum::from_clip_from_world(
&(projection.get_clip_from_view() * GlobalTransform::from(far).affine().inverse()),
);
let cam = app
.world_mut()
.spawn((
crate::view::WorldCamera,
GlobalTransform::from(far),
far,
frustum,
projection,
))
.id();
let node = AnimationNodeIndex::new(1);
let mut player = AnimationPlayer::default();
player.play(node).repeat();
let joint = app.world_mut().spawn(Transform::default()).id();
let drive = GlobalSeqDrive::new(
&anims(Vec::new(), None, true).global_bones,
&[Entity::PLACEHOLDER, joint],
)
.expect("one gseq bone maps");
let host = app
.world_mut()
.spawn((
DoodadAnimHost {
meshes: Vec::new(),
fade: (1.0, Vec3::ZERO),
clip: Some((node, 2.0)),
armed_at: 0.0,
window_hi: f32::INFINITY,
anim_id: Some(0),
active: true,
parked_at: 0.0,
},
player,
drive,
))
.id();
let active = |app: &App| {
app.world()
.entity(host)
.get::<DoodadAnimHost>()
.unwrap()
.active
};
app.update();
assert!(!active(&app), "far and facing away ⇒ parked");
app.update();
app.update();
assert!(!active(&app), "still ⇒ the verdict is reused, still parked");
// The snap: the seat writes the camera's local next to the host, facing it. The global
// (and the frustum built from it) stay stale this frame — and the host must still be
// re-judged rather than reused.
let near = Transform::from_xyz(0.0, 0.0, 10.0).looking_at(Vec3::ZERO, Vec3::Y);
let near_frustum = bevy::camera::primitives::Frustum::from_clip_from_world(
&(bevy::camera::Projection::from(bevy::camera::PerspectiveProjection {
far: 5000.0,
..default()
})
.get_clip_from_view()
* GlobalTransform::from(near).affine().inverse()),
);
{
let mut e = app.world_mut().entity_mut(cam);
*e.get_mut::<Transform>().unwrap() = near;
*e.get_mut::<bevy::camera::primitives::Frustum>().unwrap() = near_frustum;
// The propagated frame, written WITHOUT a change tick: on the real snap frame it is
// last frame's value (propagation has not run), and this is the closest a test can
// stand to that — the only "moved" signal on this frame is the local.
*e.get_mut::<GlobalTransform>()
.unwrap()
.bypass_change_detection() = GlobalTransform::from(near);
}
app.update();
assert!(active(&app), "the snap frame re-judges the host: drawn");
}
#[test]
fn arm_clock_wraps_on_the_shared_clock() {
let mut host = DoodadAnimHost {
+6 -2
View File
@@ -297,7 +297,9 @@ pub(crate) fn update_ground_shade(
dirty_roots: Res<ShadeDirtyRoots>,
mut self_log: Local<f32>,
) {
root_shade.clear();
// The map persists across frames (decision 1979): a settled root's byte is already in it,
// and re-inserting ~1.3 k entries a frame was the walk's cost. A despawned root's entry is
// dead weight nothing reads — entity ids are generational, so a reuse never aliases it.
let Some(streamer) = streamer else {
return;
};
@@ -383,7 +385,9 @@ pub(crate) fn update_ground_shade(
// Every root enters the card map, hidden or not — the insert is one hash write, and a
// hidden body's card used to walk its whole parent chain to a `None` precisely because
// its root was left out.
root_shade.insert(root, byte);
if root_shade.get(&root) != Some(&byte) {
root_shade.insert(root, byte);
}
// A root the election hid (1270/1475) draws nothing, so the re-assert walk below is
// skipped whole — 1473's audit found it running over every part of every off-view body.
// The ramps above kept stepping, so the byte is current the frame the body wakes; the
@@ -55,7 +55,7 @@ use crate::view::WorldCamera;
/// allows. Keeping the probes out of this struct keeps it stack-cheap: the ExtractResource clone
/// runs every frame, and a ~900 KB by-value blob overflowed a render-thread stack (measured live).
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
#[derive(Clone, Copy, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
struct LightStd430 {
rows: [[f32; 4]; LIGHT_HEADER_ROWS],
points: [[f32; 4]; 2 * MAX_POINT_LIGHTS],
@@ -307,8 +307,11 @@ fn build_light_data(
// Per-kind water swatches (shallow/deep rgb + alpha). River/lake use the non-ocean path.
let (rs, rd, rsa, rda) = l.water_colors(LiquidKind::Still);
let (os, od, osa, oda) = l.water_colors(LiquidKind::Ocean);
data.0.rows = [[0.0; 4]; LIGHT_HEADER_ROWS];
let rows = &mut data.0.rows;
// Built in a scratch copy and written through `ResMut` only when a row moved: the extract
// clones this 8.5 KB blob every frame it reads as changed, and a parked frame changes nothing.
let mut fresh = data.0;
fresh.rows = [[0.0; 4]; LIGHT_HEADER_ROWS];
let rows = &mut fresh.rows;
rows[3] = [l.spec[0], l.spec[1], l.spec[2], 20.0]; // 3 light_spec (w=terrain shininess 20)
rows[4] = [l.fog_color[0], l.fog_color[1], l.fog_color[2], fog_enable]; // 4 fog_color (w=enable)
rows[5] = [l.fog_start, l.fog_end, 0.0, farclip]; // 5 fog_params (z unused; w=farclip)
@@ -368,10 +371,10 @@ fn build_light_data(
.collect();
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
pts.truncate(MAX_POINT_LIGHTS);
data.0.rows[20] = [pts.len() as f32, 0.0, 0.0, 0.0];
fresh.rows[20] = [pts.len() as f32, 0.0, 0.0, 0.0];
for (i, (_, p, range, rgb)) in pts.iter().enumerate() {
data.0.points[2 * i] = [p.x, p.y, p.z, *range];
data.0.points[2 * i + 1] = [rgb[0], rgb[1], rgb[2], 0.0];
fresh.points[2 * i] = [p.x, p.y, p.z, *range];
fresh.points[2 * i + 1] = [rgb[0], rgb[1], rgb[2], 0.0];
}
// `WOW_POINTS_DUMP=1`: print the committed point table once a second — the numeric probe for
// "what is actually lighting this ground". A pool that reads wrong is one of a small set of
@@ -443,7 +446,7 @@ fn build_light_data(
(h ^ u64::from(v.to_bits())).wrapping_mul(0x1000_0000_01b3)
});
eprintln!("[light] rows {hash:#018x}");
for (i, r) in data.0.rows.iter().enumerate() {
for (i, r) in fresh.rows.iter().enumerate() {
eprintln!(
" {i:2} {:08x} {:08x} {:08x} {:08x} {:9.5} {:9.5} {:9.5} {:9.5}",
r[0].to_bits(),
@@ -458,6 +461,9 @@ fn build_light_data(
}
}
}
if data.0 != fresh {
data.0 = fresh;
}
}
/// Render-world: write the packed light into the shared buffer in place, before any draw reads it
+17 -1
View File
@@ -322,14 +322,30 @@ pub(crate) fn publish_model_alpha(
Option<&UnitAppearFade>,
Option<&DespawnFade>,
Has<crate::world_unit::ViewerUnit>,
Option<&ModelFade>,
Option<Ref<ModelFade>>,
Option<&mut ModelAlpha>,
),
With<crate::world_unit::WorldUnit>,
>,
// A declaration that went away is a change the `Ref` cannot see: those units are due.
mut undeclared: RemovedComponents<ModelFade>,
) {
let now = time.elapsed_secs();
let undeclared: bevy::platform::collections::HashSet<Entity> = undeclared.read().collect();
for (entity, appear, despawn, is_self, declared, current) in &mut units {
// With no ramp in flight and not the self body, the alpha is the declared fade alone —
// a published value that cannot have moved unless the declaration did. Every resident
// unit was recomputed and compared each frame (decision 1979's floor).
if appear.is_none()
&& despawn.is_none()
&& !is_self
&& current.is_some()
&& !declared.as_ref().is_some_and(|d| d.is_changed())
&& !undeclared.contains(&entity)
{
continue;
}
let declared = declared.as_deref();
let alpha = model_render_alpha(
now,
appear.copied(),
@@ -30,7 +30,11 @@ use benilla_assets::materials::WowModelMaterial;
pub(super) fn apply_model_visibility(
debug: Res<DebugState>,
view: Res<ViewDistance>,
cam: Query<(&GlobalTransform, &Projection), With<WorldCamera>>,
cam: Query<(Ref<GlobalTransform>, &Projection, Option<Ref<Transform>>), With<WorldCamera>>,
// The two edges the per-part skip below cannot read off its own row: a building streaming
// in (its portal set is new), and any owner's inherited verdict flipping (a card follows it).
new_portals: Query<(), Added<WmoPortalInstance>>,
owner_flips: Query<(), Changed<InheritedVisibility>>,
// The per-frame WMO portal PVS (computed by `crate::wmo_portal`), read here so the cull composes
// with the toggles + far-clip in this single Visibility authority rather than fighting it.
instances: Query<&WmoPortalInstance>,
@@ -57,16 +61,16 @@ pub(super) fn apply_model_visibility(
card_owners: Query<&InheritedVisibility>,
mut q: Query<(
&ModelPart,
&GlobalTransform,
Ref<GlobalTransform>,
&mut Visibility,
Option<&DoodadFade>,
Option<&mut MeshTag>,
Option<&mut MeshMaterial3d<WowModelMaterial>>,
Option<&Aabb>,
Option<&WmoGroupVis>,
Option<&crate::doodad_anim::MatAnim>,
Option<Ref<WmoGroupVis>>,
Option<Ref<crate::doodad_anim::MatAnim>>,
Has<crate::exterior_cull::ExteriorScene>,
Has<super::FarSideOfWater>,
Option<Ref<super::FarSideOfWater>>,
Option<&crate::billboard::BillboardCard>,
)>,
// The building's own MLIQ surfaces (canals, dungeon pools, Ragefire's lava). They carry a
@@ -90,12 +94,31 @@ pub(super) fn apply_model_visibility(
// The (single) world Camera3d; the egui overlay is a Camera2d. None before it spawns → no distance
// cull that frame (toggles still apply).
let cam_view = cam.iter().next();
let cam_t = cam_view.map(|(t, _)| t);
let cam_t = cam_view.as_ref().map(|(t, _, _)| t);
// Every whole-scene input of a part's verdict, still since last frame. With those still, a
// part whose own row is still too would come out of the walk below with the same
// `Visibility`, tag and material it already carries — so it skips the walk (decision 1979's
// floor: ~8.7 k resident parts re-verdicted on every still frame at the Stormwind pin).
// Both the propagated frame and the seat's own write: this system runs before propagation,
// so a teleport frame's move is only visible on the local `Transform` (see doodad_anim).
let scene_still = cam_view
.as_ref()
.is_some_and(|(t, _, l)| !t.is_changed() && !l.as_ref().is_some_and(|l| l.is_changed()))
&& !debug.is_changed()
&& !view.is_changed()
&& !windows.is_changed()
&& !claim.is_changed()
&& !far_twins.is_changed()
&& new_portals.is_empty()
&& owner_flips.is_empty();
let cam_pos = cam_t.map(|t| t.translation());
let cam_fwd = cam_t.map(|t| Vec3::from(t.forward()));
// The exterior gate, built ONCE for the whole walk (it is a handful of 6-plane frusta) and then
// asked per submesh below — the same value `crate::exterior_cull` asks for the objects it owns.
let gate = crate::exterior_cull::ExteriorGate::build(&windows, cam_view);
let gate = crate::exterior_cull::ExteriorGate::build(
&windows,
cam_view.as_ref().map(|(t, p, _)| (&**t, *p)),
);
// The placement the camera is standing in, exempt from its own window gate (see the param).
let own_instance = claim.0.map(|c| c.room.instance);
// Parallel: this walks EVERY model submesh in residency — ~100k in a city — and at that N a
@@ -116,6 +139,16 @@ pub(super) fn apply_model_visibility(
far_side,
card,
)| {
if scene_still
&& !xf.is_changed()
&& !group_vis.as_ref().is_some_and(|g| g.is_changed())
&& !mat_anim.as_ref().is_some_and(|a| a.is_changed())
&& !far_side.as_ref().is_some_and(|f| f.is_changed())
{
return;
}
let far_side = far_side.is_some();
let (group_vis, mat_anim) = (group_vis.as_deref(), mat_anim.as_deref());
let toggled_on =
m.kind_visible[kind_index(part.kind)] && m.blend_visible[blend_index(part.blend)];
// `farclip` is the *world-doodad/WMO* draw distance only. Creatures/GameObjects come from the
@@ -201,7 +234,7 @@ pub(super) fn apply_model_visibility(
// instead of overwriting it. Untagged content (units, GameObjects) is exempt by the
// carved law; the camera's own building is exempt because it is not exterior to itself.
let own_building = group_vis.is_some_and(|gv| Some(gv.instance) == own_instance);
let exterior_ok = !exterior || own_building || gate.admits(xf, aabb);
let exterior_ok = !exterior || own_building || gate.admits(&xf, aabb);
// **A billboard card draws only while the model it is a batch OF draws** (decision
// 1409). A card is split out to a world root because its transform belongs to the
+18 -5
View File
@@ -689,6 +689,9 @@ pub fn spawn_emitter(
model_instances: Vec::new(),
},
));
if emitter.recursion.is_some() {
spawned.insert(PendingChildren);
}
if let Some(node) = lit_node {
spawned.insert(crate::interior::EmitterLitBy(node));
}
@@ -737,14 +740,23 @@ pub(crate) fn owner_last_bias(reach: f32) -> f32 {
/// parent's anchor and rung). The reference wires at the child model's async-load completion
/// (`0x7b5dd0`); this system is that completion hook. A child never self-emits ambiently — its
/// only particle source is the per-parent-particle drive in the sim.
/// An emitter whose child-emitter model (`ParticleEmitter::recursion`) has not been resolved
/// yet — the only emitters [`wire_child_emitters`] visits. Without it the wiring walked every
/// resident emitter every frame to find the handful still waiting on a model (decision 1979).
#[derive(Component)]
pub(crate) struct PendingChildren;
pub(crate) fn wire_child_emitters(
mut commands: Commands,
models: Res<Assets<benilla_assets::M2Model>>,
mut emitters: Query<(
Entity,
&mut ParticleEmitter,
Has<crate::interior::EmitterLitBy>,
)>,
mut emitters: Query<
(
Entity,
&mut ParticleEmitter,
Has<crate::interior::EmitterLitBy>,
),
With<PendingChildren>,
>,
) {
for (entity, mut emitter, registered) in &mut emitters {
let Some(model) = emitter.recursion.as_ref().and_then(|h| models.get(h)) else {
@@ -775,6 +787,7 @@ pub(crate) fn wire_child_emitters(
.collect();
emitter.recursion = None;
emitter.children = children;
commands.entity(entity).remove::<PendingChildren>();
// A child emitter is a whole emitter record of the recursion model with its own flag word,
// so it takes its own lighting verdict (the same rule its texture/blend/fog identity
// follows). A LIT child under an UNLIT parent is therefore a light-node consumer whose
+71 -29
View File
@@ -241,9 +241,20 @@ pub(crate) struct SceneGates<'w, 's> {
exterior_windows: Res<'w, crate::wmo_portal::ExteriorWindows>,
camera_claim: Res<'w, crate::wmo_portal::CameraInteriorClaim>,
portals: Query<'w, 's, &'static crate::wmo_portal::WmoPortalInstance>,
/// Streamed-in buildings this frame: a room admit can only change with the camera, a
/// window, a claim, a surface, or a new portal set — the last is this.
new_portals: Query<'w, 's, (), Added<crate::wmo_portal::WmoPortalInstance>>,
}
impl SceneGates<'_, '_> {
/// Any of the three resources a draw-set verdict reads moved this frame.
pub(crate) fn changed(&self) -> bool {
self.view.is_changed()
|| self.exterior_windows.is_changed()
|| self.camera_claim.is_changed()
|| !self.new_portals.is_empty()
}
/// The three per-frame values every draw-set caller needs before it can ask
/// [`EmitterFade::in_draw_set`]: the far-clip wall, the built exterior gate, and the placement
/// the camera is standing in. Built ONCE per walk (the gate is a handful of 6-plane frusta).
@@ -435,7 +446,17 @@ pub(super) fn simulate_particles(
// The water-plane interleave inputs — see [`WaterInterleave`] and [`far_side_of_water`].
interleave: WaterInterleave,
mut commands: Commands,
cam: Query<(Entity, &GlobalTransform, &Frustum, &Camera, &Projection), With<WorldCamera>>,
cam: Query<
(
Entity,
Ref<GlobalTransform>,
&Frustum,
&Camera,
&Projection,
Option<Ref<Transform>>,
),
With<WorldCamera>,
>,
owners: Owners,
// MODEL-particle instance entities ([`super::model`]): the draw-set gate hides them with
// their frozen emitter.
@@ -462,7 +483,7 @@ pub(super) fn simulate_particles(
&mut ParticleEmitter,
&mut Transform,
&mut GlobalTransform,
Option<&EmitterFade>,
Option<Ref<EmitterFade>>,
Option<&RenderLayers>,
Option<&EffectLightOverride>,
Option<&crate::interior::EmitterLitBy>,
@@ -496,7 +517,7 @@ pub(super) fn simulate_particles(
// `$WOW_PARTICLE_DEPTHDUMP` and `$WOW_EMIT_DUMP`. Both inert without their env.
mut dumps: super::dumps::Dumps,
) {
let Ok((world_cam, cam_tf, frustum, camera, projection)) = cam.single() else {
let Ok((world_cam, cam_tf, frustum, camera, projection, cam_local)) = cam.single() else {
return;
};
// Clamp dt so a load hitch doesn't fling every particle out of existence in one step.
@@ -504,6 +525,13 @@ pub(super) fn simulate_particles(
if dt <= 0.0 {
return;
}
// Every input of an emitter's draw-set verdict, still since last frame: the verdict is then
// still too, and a gated emitter can skip its sphere/window/room tests outright — 2.4 k
// resident emitters against 11 active in a parked city (decision 1979's floor).
let gate_inputs_still = !cam_tf.is_changed()
&& !cam_local.as_ref().is_some_and(|l| l.is_changed())
&& !gates.changed()
&& !interleave.surfaces_changed();
let cam_pos = cam_tf.translation();
let density = tuning.density.clamp(0.25, 1.0);
let snap_filter = crate::collision::WorldCollision::body_filter();
@@ -516,7 +544,7 @@ pub(super) fn simulate_particles(
// The far-clip wall, the exterior gate and the camera's own room — built once for the whole
// emitter walk, the same values the model visibility authority and `exterior_cull` ask
// (0784/0786: one spelling of the window test).
let (farclip, exterior_gate, camera_instance) = gates.scene(Some((cam_tf, projection)));
let (farclip, exterior_gate, camera_instance) = gates.scene(Some((&*cam_tf, projection)));
// `$WOW_PARTICLE_DEPTHDUMP` (B16): is this a dump frame? Decided once per run.
let dump_frame = dumps.depth_frame(time.elapsed_secs());
// `$WOW_EMIT_DUMP`: is this a dump tick? Decided once per frame, for the whole walk.
@@ -584,7 +612,10 @@ pub(super) fn simulate_particles(
// "all effects render at unlimited distance", and it is why the terrain under them was
// already gone. `within_farclip` is the same rule the owner mesh uses in
// `debug_panel::visibility` — shared, so the two can no longer drift apart.
if let Some(f) = fade {
if let Some(f) = fade.as_ref() {
if emitter.gated && gate_inputs_still && !f.is_changed() {
continue;
}
let in_set = f.in_draw_set(
cam_pos,
cam_fwd,
@@ -725,6 +756,39 @@ pub(super) fn simulate_particles(
let water_gt = (*anchor).and_then(|e| owners.transforms.get(e).ok().copied());
let water_bound = *water_bound;
*age += dt;
let (clock_seq, elapsed_s) = match *host {
Some(h) => match hosts
.get(h)
.ok()
.and_then(|(p, a)| crate::doodad_anim::playing_seq(p, a))
{
Some((s, t)) => {
*seq = Some(s);
(Some(s), t)
}
None => (*seq, 0.0),
},
None => (*seq, *age),
};
// This frame's emitter PARAMETERS — the nine per-frame-sampled channels, on the same
// clock as the rate track (the reference's `m2_animate` emitter phase samples all ten;
// wow-re `part-emission-rate-animated.md` §1). Frost Nova rides its emission radius
// 0.19 → 13.2 yd out with the ring; Arcane Explosion 0 → 7.2 yd with the dome — births
// MUST read the frame's values, not `value[0]` (decision 0844).
// The instance's gseq cursor (0856/0858): the spawn age IS `sceneNow − attach` — the
// emitter spawns with its instance, and every lane's instance is fresh per play.
let gseq_now = f64::from(*age);
// Dormant (decision 1979): nothing alive, nothing draining, and the timing tracks say
// the emitter is not emitting at this clock — every placement, ride and water step
// below would compute state for zero particles. The clock derivation above already
// ran (it is what `emitting` reads, and `seq` must keep following the host).
if particles.is_empty()
&& !*draining
&& children.iter().all(|c| c.particles.is_empty())
&& !def.timing.emitting(clock_seq, elapsed_s, gseq_now)
{
continue;
}
// Anchored mode (see [`Particle`]): positions are emitter-relative, so tracking a moving
// owner needs nothing beyond refreshing `placement` — the cloud rides the anchor for free
// (the reference's per-frame `translate(−emitterPos)` draw-matrix rebuild).
@@ -792,7 +856,7 @@ pub(super) fn simulate_particles(
// placed doodad's takes its own distance fade, whose cutoff the draw-set gate above
// already applies as a hard stop.
*alpha = alpha_src.map_or(1.0, |e| owner_mul.alpha(e))
* fade.map_or(1.0, |f| f.distance_alpha(cam_pos));
* fade.as_ref().map_or(1.0, |f| f.distance_alpha(cam_pos));
// The cloud anchor (see the field doc): the model's live translation, or the last-known
// one while the pool drains. A whole-model owner keeps anchor == owner — identical math.
match *anchor {
@@ -928,28 +992,6 @@ pub(super) fn simulate_particles(
// (bug B27). A host with no live player yet keeps its last slot at that slot's opening
// pose. Pinned lanes (doodads, effect rigs, booths) run their slot on the spawn-age
// clock; the baked loops wrap a looping band and end-hold a clamped one.
let (clock_seq, elapsed_s) = match *host {
Some(h) => match hosts
.get(h)
.ok()
.and_then(|(p, a)| crate::doodad_anim::playing_seq(p, a))
{
Some((s, t)) => {
*seq = Some(s);
(Some(s), t)
}
None => (*seq, 0.0),
},
None => (*seq, *age),
};
// This frame's emitter PARAMETERS — the nine per-frame-sampled channels, on the same
// clock as the rate track (the reference's `m2_animate` emitter phase samples all ten;
// wow-re `part-emission-rate-animated.md` §1). Frost Nova rides its emission radius
// 0.19 → 13.2 yd out with the ring; Arcane Explosion 0 → 7.2 yd with the dome — births
// MUST read the frame's values, not `value[0]` (decision 0844).
// The instance's gseq cursor (0856/0858): the spawn age IS `sceneNow − attach` — the
// emitter spawns with its instance, and every lane's instance is fresh per play.
let gseq_now = f64::from(*age);
let now = def.params.sample(clock_seq, elapsed_s, gseq_now);
// 1. Age + integrate the live pool. The verified vanilla integrator (`particle_integrate`
@@ -1246,7 +1288,7 @@ pub(super) fn simulate_particles(
// sim's own `emitter_world` is the STORE's origin — 1591).
ride.to_world(emitter_world),
&cam,
cam_tf,
&cam_tf,
camera,
projection,
images.contains(&*texture),
+57 -46
View File
@@ -267,56 +267,67 @@ fn evaluate_rig_poses(
rigs.par_iter_mut().for_each(|(player, anims, mut rig)| {
let src = &anims.pose;
// This rig's contributing animations, ascending by node index (Bevy's effective blend
// order — sorted children folded through a LIFO stack, see the module doc).
let mut active: Vec<Active> = Vec::new();
for (&node, anim) in player.playing_animations() {
// Bevy's exact gate: a weight of literal 0.0 skips the node (paused does not).
if anim.weight() == 0.0 {
continue;
}
let Some(pn) = src.node(node) else { continue };
if src.clips.get(pn.clip as usize).is_none() {
continue;
}
active.push(Active {
node: node.index(),
clip: pn.clip as usize,
mask: pn.mask,
weight: anim.weight(),
seek: anim.seek_time(),
cursor: 0,
});
// order — sorted children folded through a LIFO stack, see the module doc). One scratch
// per worker thread, reused across rigs: a `Vec` per rig per frame was an allocation
// and a free for every live rig on every frame (decision 1979's floor).
thread_local! {
static ACTIVE: std::cell::RefCell<Vec<Active>> =
const { std::cell::RefCell::new(Vec::new()) };
}
active.sort_unstable_by_key(|a| a.node);
match active.as_mut_slice() {
[] => {}
[one] => {
// The steady state: one looping gait. A single contribution commits at full
// value whatever its weight (Bevy's register initializes unnormalized).
rig.pose_dirty = true;
for pb in &src.clips[one.clip].bones {
if src.bone_masks.get(pb.bone as usize).copied().unwrap_or(0) & one.mask != 0 {
continue;
}
let Some(tf) = rig.locals.get_mut(pb.bone as usize) else {
continue;
};
if let Some(v) = pb.translation.sample(one.seek) {
tf.translation = v;
}
if let Some(v) = pb.rotation.sample(one.seek) {
tf.rotation = v;
}
if let Some(v) = pb.scale.sample(one.seek) {
tf.scale = v;
ACTIVE.with(|cell| {
let mut active = cell.borrow_mut();
active.clear();
for (&node, anim) in player.playing_animations() {
// Bevy's exact gate: a weight of literal 0.0 skips the node (paused does not).
if anim.weight() == 0.0 {
continue;
}
let Some(pn) = src.node(node) else { continue };
if src.clips.get(pn.clip as usize).is_none() {
continue;
}
active.push(Active {
node: node.index(),
clip: pn.clip as usize,
mask: pn.mask,
weight: anim.weight(),
seek: anim.seek_time(),
cursor: 0,
});
}
active.sort_unstable_by_key(|a| a.node);
match active.as_mut_slice() {
[] => {}
[one] => {
// The steady state: one looping gait. A single contribution commits at full
// value whatever its weight (Bevy's register initializes unnormalized).
rig.pose_dirty = true;
for pb in &src.clips[one.clip].bones {
if src.bone_masks.get(pb.bone as usize).copied().unwrap_or(0) & one.mask
!= 0
{
continue;
}
let Some(tf) = rig.locals.get_mut(pb.bone as usize) else {
continue;
};
if let Some(v) = pb.translation.sample(one.seek) {
tf.translation = v;
}
if let Some(v) = pb.rotation.sample(one.seek) {
tf.rotation = v;
}
if let Some(v) = pb.scale.sample(one.seek) {
tf.scale = v;
}
}
}
many => {
rig.pose_dirty = true;
blend_rig(src, many, &mut rig);
}
}
many => {
rig.pose_dirty = true;
blend_rig(src, many, &mut rig);
}
}
});
});
}
+31 -7
View File
@@ -115,7 +115,7 @@ fn mark_world_camera(
/// regions are the SAME drain phase in the 1.12 order (both are the M2 scene, after the WMO
/// phase), so the cull sorts them near-first TOGETHER — a far cell must not shade before a
/// near building's furniture.
#[derive(Clone)]
#[derive(Clone, PartialEq)]
pub(crate) enum GxDoodadVis {
Cell((i32, i32)),
/// A prop region + this frame's per-referrer-SET verdicts.
@@ -124,7 +124,7 @@ pub(crate) enum GxDoodadVis {
/// One region's per-selection-grain verdicts for this frame — a WMO region's grain is the GROUP,
/// a prop region's the referrer-SET index, and both index these vectors the same way.
#[derive(Clone, Default)]
#[derive(Clone, Default, PartialEq)]
pub(crate) struct GxSel {
/// Drawn this frame: PVS ∧ frustum ∧ farclip ∧ the exterior window gate.
pub drawn: Vec<bool>,
@@ -900,11 +900,35 @@ pub(super) fn build(app: &mut App) {
/// Copy the collector's published half into the extractable resource.
pub(super) fn publish_gx_world(gx: Res<super::StaticGx>, mut out: ResMut<GxWorld>) {
let _t = super::gx_perf_guard(2);
out.cells.clone_from(&gx.world.cells);
out.visible.clone_from(&gx.world.visible);
out.wmos.clone_from(&gx.world.wmos);
out.props.clone_from(&gx.world.props);
out.visible_wmos.clone_from(&gx.world.visible_wmos);
// Compare before writing: a parked frame's collector walk produces the same five
// structures it produced last frame, and an unconditional `clone_from` through `ResMut`
// both re-cloned them here and marked the resource changed, so the render world cloned
// the whole set again at extract — 1435's two 0.2 ms rows, paid on every frame that
// changed nothing (decision 1979). The maps hold `Arc`s, so identity is pointer identity.
fn same_arcs<K: std::hash::Hash + Eq>(
a: &HashMap<K, std::sync::Arc<GxCellDraw>>,
b: &HashMap<K, std::sync::Arc<GxCellDraw>>,
) -> bool {
a.len() == b.len()
&& a.iter()
.all(|(k, v)| b.get(k).is_some_and(|w| std::sync::Arc::ptr_eq(v, w)))
}
let w = &gx.world;
if !same_arcs(&out.cells, &w.cells) {
out.cells.clone_from(&w.cells);
}
if out.visible != w.visible {
out.visible.clone_from(&w.visible);
}
if !same_arcs(&out.wmos, &w.wmos) {
out.wmos.clone_from(&w.wmos);
}
if !same_arcs(&out.props, &w.props) {
out.props.clone_from(&w.props);
}
if out.visible_wmos != w.visible_wmos {
out.visible_wmos.clone_from(&w.visible_wmos);
}
}
#[cfg(test)]
+9 -3
View File
@@ -28,7 +28,7 @@ use bevy::prelude::*;
use crate::clouds::{occ1_moon, occ1_sun, CloudCoverage};
use crate::dev_state::DebugState;
use crate::lighting::WowLighting;
use crate::terrain_stream::{terrain_height_under, TerrainStreamer};
use crate::terrain_stream::TerrainStreamer;
use crate::view::WorldCamera;
use crate::wdl::WdlStreamer;
use crate::wmo_portal::CameraInteriorClaim;
@@ -337,9 +337,15 @@ impl FlareGate<'_, '_> {
// 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).
let (streamer, adt_tiles, wdl) = (&self.streamer, &self.adt_tiles, &self.wdl);
let tile = std::cell::RefCell::new(None);
let oracle = |p| {
terrain_height_under(streamer, adt_tiles, p)
.or_else(|| wdl.as_ref().and_then(|w| w.height_under(p)))
crate::terrain_stream::terrain_height_under_cached(
streamer,
adt_tiles,
p,
&mut tile.borrow_mut(),
)
.or_else(|| wdl.as_ref().and_then(|w| w.height_under(p)))
};
// The round-robin drip (decision 1436): an unprimed mask marches every cell once,
// a primed one re-prices FLARE_RAYS_PER_FRAME — the slew smooths the ≤8-frame
+38 -1
View File
@@ -67,7 +67,7 @@ pub use spawn::{m2_anim_bound, m2_fade, point_light, spawn_model_entities, Spawn
use queries::update_current_area;
pub use queries::{
area_id_under, doodad_ground_shade, ground_effect_under, terrain_height_under,
AreaAuthoritySet, CurrentArea, ShadeResolve,
terrain_height_under_cached, AreaAuthoritySet, CurrentArea, ShadeResolve,
};
/// Wall-clock spent per frame spawning streamed-in geometry (terrain tiles in [`stream_terrain`],
@@ -110,6 +110,11 @@ pub struct TerrainStreamer {
/// neighbourhood's every placement every frame for the rest of the run — the settled-work scan
/// the latch exists to stop.
was_paced: bool,
/// The last frame found nothing to request, drop or spawn and every resident tile stood
/// furnished — so a frame with the same window, the same pacing and a complete load has
/// nothing to do and skips the window walk (three window-sized allocations and a scan of
/// every resident tile, on every still frame — decision 1979's floor).
settled: bool,
}
/// The placement id the map-global WMO is registered under. A WMO-only map authors **no** ADT tiles
@@ -731,6 +736,29 @@ fn stream_terrain(
let window = StreamWindow::at(view.farclip, center[0], center[1]);
let tiling = cfg.as_ref().map(|c| c.tex_tiles).unwrap_or(8.0);
let (cx, cy) = window.focus_tile();
let same_window = state.focus == (cx, cy) && state.reach == Some((window.inner, window.outer));
// Never while a load is in flight: `paced` is false from a snap until the body settles, and
// the tail this skip bypasses is where the load is RELEASED — the retained pass's undrawn
// count that `presentable()` reads, the burst-over `flush_now`, and the settle hold. A
// skip that engaged the frame `complete` flipped, with the pass still baking the new
// cells, froze that count above zero and held the loading screen up for good — the
// .tele hang of 2026-09-04, the day after this skip landed (1982). So the skip needs a
// paced, presentable, complete world, and even then keeps the undrawn count fresh so a
// later rebake un-skips it.
if same_window
&& state.settled
&& focus.paced
&& state.was_paced == focus.paced
&& (wdt_index.is_some() || state.wdt_ungated)
&& load_progress
.as_ref()
.is_none_or(|p| p.complete && p.presentable())
{
if let (Some(p), Some(gx)) = (load_progress.as_mut(), staticgx.as_deref()) {
p.gx_pending = crate::static_gx::StaticGx::undrawn_regions(gx);
}
return;
}
state.focus = (cx, cy);
// One line per change of reach — the slider moved, or the first frame — so a run log says
// what the residency window was when a tile count or a frame cost was read off it.
@@ -793,6 +821,7 @@ fn stream_terrain(
.filter(|&tile| !window.keeps(tile))
.collect()
};
let stale_empty = stale.is_empty();
if unload_budget > 0 && stale.len() > unload_budget {
stale.sort_by_key(|&(tx, ty)| std::cmp::Reverse((tx - cx).abs().max((ty - cy).abs())));
stale.truncate(unload_budget);
@@ -831,6 +860,7 @@ fn stream_terrain(
// the only cost of the window edge (~2 tiles out, in fog) filling over a few frames. World
// entry and teleports stay unstaggered: the loading screen exists to absorb that burst, and
// the settle release waits on exactly these tiles.
let mut fresh_empty = true;
if wdt_index.is_some() || state.wdt_ungated {
let live = focus.paced;
let mut fresh: Vec<(i32, i32)> = desired
@@ -838,6 +868,7 @@ fn stream_terrain(
.copied()
.filter(|c| !state.tiles.contains_key(c))
.collect();
fresh_empty = fresh.is_empty();
if live && fresh.len() > 1 {
fresh.sort_by_key(|&(tx, ty)| (tx - cx).abs().max((ty - cy).abs()));
fresh.truncate(1);
@@ -992,6 +1023,12 @@ fn stream_terrain(
// focus tile that doesn't exist (map edge) counts as resident so we never get stuck waiting
// for ground that isn't there.
// The pacing edge, read and advanced once per frame (see [`TerrainStreamer::was_paced`]).
state.settled = stale_empty
&& fresh_empty
&& state
.tiles
.values()
.all(|t| t.entity.is_some() && t.furnished);
let was_paced = std::mem::replace(&mut state.was_paced, focus.paced);
if let Some(p) = load_progress.as_mut() {
p.focus_tile = Some((cx, cy));
@@ -107,11 +107,31 @@ pub fn terrain_height_under(
streamer: &TerrainStreamer,
adt_tiles: &Assets<AdtTile>,
bevy_pos: Vec3,
) -> Option<f32> {
terrain_height_under_cached(streamer, adt_tiles, bevy_pos, &mut None)
}
/// [`terrain_height_under`] for a caller that asks many columns of the same tile in one go —
/// the sun-flare march asks ~96 a frame along two rays (decision 1979): the tile resolution
/// (a streamer map lookup and an asset lookup) is done once per tile change, not per column.
pub fn terrain_height_under_cached<'a>(
streamer: &TerrainStreamer,
adt_tiles: &'a Assets<AdtTile>,
bevy_pos: Vec3,
cache: &mut Option<((i32, i32), &'a AdtTile)>,
) -> Option<f32> {
let wow = bevy_to_wow(bevy_pos);
let (tx, ty) = world_to_tile(wow[0], wow[1]);
let ts = streamer.tiles.get(&(tx as i32, ty as i32))?;
let adt = adt_tiles.get(&ts.handle)?;
let key = (tx as i32, ty as i32);
let adt = match cache {
Some((k, adt)) if *k == key => *adt,
_ => {
let ts = streamer.tiles.get(&key)?;
let adt = adt_tiles.get(&ts.handle)?;
*cache = Some((key, adt));
adt
}
};
benilla_formats::terrain_height_at(&adt.chunks, wow)
}
@@ -261,6 +261,7 @@ impl Plugin for WorldFoundation {
// read, whose defaults ARE the player behaviour. The debug panel is only its editor
// and may not be installed at all.
.init_resource::<crate::dev_state::DebugState>()
.add_systems(Last, crate::dev_state::count_still_inputs)
// The collider-set stamp every cached collision answer is dated against
// (`collision::ColliderEpoch`). Tracked in `First` so a removal is stamped before any
// consumer runs; the attach half is stamped by the streamer's own attach loop.
+21 -3
View File
@@ -59,6 +59,13 @@ pub enum Subject {
#[derive(SystemParam)]
pub struct WorldPoint<'w, 's> {
liquids: Query<'w, 's, &'static WaterChunkInfo>,
/// The XY grid over the loaded surfaces ([`crate::liquid::WaterIndex`]): a point query walks
/// the handful of surfaces over that cell, not every loaded one. The verdict is identical to
/// the full walk by construction (a surface is registered in every cell its box overlaps —
/// spatial.rs), and it is what keeps the per-unit askers — the swim mark, the splash line,
/// the ground clamp, one each per moving creature per frame — off a 650-surface walk apiece
/// (decision 1979: ~0.35 ms of a raid's main thread was those three walks).
index: Res<'w, crate::liquid::WaterIndex>,
/// Liquid surfaces that also carry a sound class — the ambient-loop scan's population.
sound_sources: Query<'w, 's, (&'static LiquidSoundSource, &'static WaterChunkInfo)>,
/// The placed buildings a room's whole-group submersion override resolves against (1000).
@@ -93,6 +100,7 @@ pub struct WorldPoint<'w, 's> {
/// area — which is what a geometry harness wants: it supplies the geometry it is about and nothing
/// else answers.
pub fn init_world_point_resources(world: &mut bevy::prelude::World) {
world.init_resource::<crate::liquid::WaterIndex>();
world.init_resource::<Underwater>();
world.init_resource::<crate::liquid::SubmergedEye>();
world.init_resource::<PlayerWmoRoom>();
@@ -130,6 +138,16 @@ impl WorldPoint<'_, '_> {
/// `false` only on a unit's very first frame. It matters because an unsettled claim admits
/// *both* liquid sources — the exact false positive 0696 removed — so a state a subject can
/// only ENTER (swimming) must wait for it, while a state it can LEAVE must not.
/// The loaded surfaces whose footprint box covers `wow`'s XY — the index's candidates,
/// resolved to their chunk infos (a surface despawned since the index's last maintenance
/// simply resolves to nothing).
fn over(&self, wow: [f32; 3]) -> impl Iterator<Item = &WaterChunkInfo> {
self.index
.over(wow[0], wow[1])
.iter()
.filter_map(|&e| self.liquids.get(e).ok())
}
pub fn room_settled(&self, who: Subject) -> bool {
self.claim(who) != LiquidClaim::Unknown
}
@@ -138,21 +156,21 @@ impl WorldPoint<'_, '_> {
///
/// Every liquid, not only water: **you swim in lava and slime too** (0634).
pub fn liquid_at(&self, who: Subject, wow: [f32; 3]) -> Option<LiquidHit> {
liquid_at(self.liquids.iter(), wow, self.claim(who))
liquid_at(self.over(wow), wow, self.claim(who))
}
/// The **water** surface height at `wow` for this subject — [`Self::liquid_at`] with the
/// fullbright kinds (magma, slime) filtered out, for the consumers that are specifically about
/// water: the splash, the wake foam, footstep depth.
pub fn water_surface_at(&self, who: Subject, wow: [f32; 3]) -> Option<f32> {
water_surface_at(self.liquids.iter(), wow, self.claim(who))
water_surface_at(self.over(wow), wow, self.claim(who))
}
/// Every loaded liquid footprint covering this XY, one human-readable line each — the body of
/// the `/liquid` chat instrument. Prints every candidate, not just the winner, so a surface
/// that should not be claiming is visible next to the one that should.
pub fn describe_liquid_at(&self, who: Subject, wow: [f32; 3]) -> Vec<String> {
describe_at(self.liquids.iter(), wow, self.claim(who))
describe_at(self.over(wow), wow, self.claim(who))
}
/// What the camera eye is currently submerged in — **which liquid, not merely whether**. The