grass fades on a plane, model panes render, and the FPS journal ships
- the grass fade is a plane across the view, not a ring around the player: `0x6b2b80` texgens the camera-space position through `T(0,0,−52.5)·Ry(π/2)·S(1/17.5)`, so the fade coordinate is view-space depth, `u = (z_eye − 52.5)/17.5` — we measured `distance(eye, fragment)`, a sphere, which meets the ground as a circle centred on the player that slides as they walk and bites a piece out of every tuft it crosses, and that was the hard border; the ramp is `0x6b2320`'s quantised 64×8 table, `alpha = 4·(63 − col)`, read bilinearly as `(254 − 256·u)/255` and capped at `252/255` — plateau to 52.64 yd, zero at 69.86 yd, near grass 98.8 % opaque and never 100 — where we had a clean 1 → 0 line; the product `tex0.a × ramp` is what the 128/255 `detailDoodadAlpha` cutout tests (both stages MODULATE, `[0xc7b2fc]`, confirmed verbatim in all four apitrace captures), so the tuft eroding out at 61.11 yd is the reference's own behaviour and the "fix" that would have removed it never landed; the pass owns its render state — blend mode 2, ALPHAREF 128, cull off, depth-write on, the per-slot draw `0x6b2d60` binding nothing but the texture — where we passed the batch's authored blend and cull through, and the `clutter_state` probe counted the cost: of 322 detail batches, 2 author Opaque (no cutout, their margin drawn as a solid card) and 5 single-sided; the build gate measures the chunk's box against the frustum corner ray derived from the live projection (≈1.31× at 16:9, ≈1.45× at 21:9), not its centre plus a 24 yd margin that popped whole chunks in on relief (2004) - a `<Model>` widget's animation state is Lua-observable and lives in the engine: a private scene clock its own `OnUpdate` advances by `trunc(elapsed·1000)` ms on visible frames only (`0x76d7f0`), an arm with an anchor the sampler re-reads every frame (`0x7121a0`), `OnUpdateModel` at the top of every paint (`0x76d1a0`) and `OnAnimFinished` from the completion callback (`0x76cdc0`); the file's sequences and bounds are facts the host hands over, `SetModel` seeds Stand now or when they land (`0x70ebd0`) with a queued arm replaying after the seed, and the XML `scale=` on a model pane is the model's scale (`CSimpleModel::LoadXML` `0x76cac0`), which the loader had never read (2007); and the pane renders its M2 — every visible pane with a file gets a cell of one render-target atlas at its device size through one orthographic camera, the root at `1280·modelScale·layoutScale` FrameXML units per model unit, the tile's `AnimationPlayer` paused and seeked to the engine's play head, the light black, particles in their own unit (`768·√(a²+1)` per model unit, neither scale), composited as one premultiplied quad at the callback rank; two corrections off the bytes: `OnUpdateModel` fires only with a model installed (`[widget+0x318] ≠ 0`) and `OnAnimFinished` fires once per arm for a looping sequence too (`0x719370`); the loader now applies a `<Model>`'s `file=` at all, which every XML-declared pane had lacked; retired: the minimap ping's byte-measured sprite, since `MiniMapPing` and `WorldMapPing` draw their own file on the pane's clock (2013) - the stance feed fires `UPDATE_SHAPESHIFT_FORMS` only for a list change: the reference fires it at `0x4b28ff`/`0x4b2e43` from learn, unlearn and rank-replace alone, while ours fired on any change of the pushed view, and every fire runs the stock `ShapeshiftBar_Update`, which shows `ShapeshiftBarMiddle` unconditionally past two forms and `Show()`s an already-shown frame — no `OnShow`, no manage pass — so a stance switch or the category-47 cooldown arming and expiring left the middle strip drawn alone over a raised bottom-left bar; castability now rides `SPELL_UPDATE_USABLE` (`0x4b31c0`), active/texture/cooldown are pushed silently and repainted on `PLAYER_AURAS_CHANGED` and `SPELL_UPDATE_COOLDOWN`, the feed ordered before both handler sets so they read the fresh list (2009); and the loot-roll frame waits for its item template (B371): `GroupLootFrame_OnShow` paints once and nothing repaints it, and we fired `START_LOOT_ROLL` on the frame the packet arrived, against a snapshot whose name, texture and quality were still `None` — the reference's `0x61b310` ends in the item-template cache lookup `0x55ba30`, a hit firing the event there and a miss firing it from the arrival callback `0x61b460`, so the feed holds a fresh roll's event until the template answers, with two releases that are ours: a negative answer (a frame that never opens cannot even be passed on) and a 2 s deadline paid out of the roll's minute; `arg2` stays the wire's countdown, as `0x61b430` pushes it (2010) - the FPS journal ships to players: it had registered inside the dev-only `DevToolsPlugin` and been armed by an environment variable, so nothing we shipped could have written a row on the one GPU-bound frame in the corpus, a Steam Deck's at 94 % busy; `/console fpsJournal 1` — a CVar of our own, persisted like every CVar — opens `benilla-config/Diagnostics/fps-journal.csv` and appends one row a second, `0` closes it mid-run, and `WOW_FPS_JOURNAL=<path>` stays as the harness lever; eight GPU columns appended, `gpu_ms` then `gpu_opaque, gpu_static, gpu_transp, gpu_glow, gpu_post, gpu_ui, gpu_other`, read off bevy's `RenderDiagnosticsPlugin`, which times passes where the device offers `TIMESTAMP_QUERY_INSIDE_PASSES` (Vulkan and DX12) and leaves the cells empty on an Apple GPU, empty being the honest value; `static_gx`, the four `ffx_glow_*` passes and `ui_gamma_decode` open diagnostic spans so a city's own draw is named in the split; a fresh file opens with the adapter, backend, driver and `gpu_spans yes|no` line; and a probe's `/console` line, which skips the stock `ChatEdit_ParseText` and had landed in a parser arm that knew only `reloadUI`, now forwards to `ConsoleExec` and writes a CVar the way the typed line does (2008) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -746,6 +746,19 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
"1639: benilla's own — the reference has no off-screen buffer to hang a resolution dial \
|
||||
on; its nearest equivalent, `gxResolution`, drops the interface with the world",
|
||||
),
|
||||
// **The FPS journal** (decision 2008) — benilla's own, and the one instrument that ships:
|
||||
// `/console fpsJournal 1` appends a per-second row of position, frame cost and the GPU's
|
||||
// per-pass split to `benilla-config/Diagnostics/fps-journal.csv` in any build, which is how
|
||||
// a player on hardware we do not own measures for us. The knob is
|
||||
// [`crate::perf::FpsJournalSetting`]. Off by default; persisted like every row, so a
|
||||
// reporter who turns it on keeps it on until they turn it off — the file is theirs to
|
||||
// attach and theirs to delete.
|
||||
ours(
|
||||
"fpsJournal",
|
||||
"0",
|
||||
"2008: benilla's own — 1.12 has no player-side perf log; its nearest thing is the \
|
||||
Ctrl+R framerate label, a number with no file behind it",
|
||||
),
|
||||
same(crate::char_select::CVAR_LAST_CHARACTER, "0"),
|
||||
];
|
||||
|
||||
@@ -888,6 +901,7 @@ pub(crate) struct KnobParams<'w> {
|
||||
block_trades: ResMut<'w, crate::ui_trade::BlockTrades>,
|
||||
auto_self_cast: ResMut<'w, crate::ui_action::AutoSelfCast>,
|
||||
realmlist: ResMut<'w, crate::realmlist::Realmlist>,
|
||||
fps_journal: ResMut<'w, crate::perf::FpsJournalSetting>,
|
||||
}
|
||||
|
||||
impl KnobParams<'_> {
|
||||
@@ -923,6 +937,7 @@ impl KnobParams<'_> {
|
||||
block_trades: &mut self.block_trades,
|
||||
auto_self_cast: &mut self.auto_self_cast,
|
||||
realmlist: &mut self.realmlist,
|
||||
fps_journal: &mut self.fps_journal,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -954,6 +969,7 @@ struct Knobs<'a> {
|
||||
block_trades: &'a mut crate::ui_trade::BlockTrades,
|
||||
auto_self_cast: &'a mut crate::ui_action::AutoSelfCast,
|
||||
realmlist: &'a mut crate::realmlist::Realmlist,
|
||||
fps_journal: &'a mut crate::perf::FpsJournalSetting,
|
||||
}
|
||||
|
||||
/// Apply one CVar to its knob resource (parse + the knob's own clamp). `false` = not a knob this
|
||||
@@ -1076,6 +1092,10 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
|
||||
"renderscale" => {
|
||||
knobs.render_scale.0 = v.clamp(*RENDER_SCALE_RANGE.start(), *RENDER_SCALE_RANGE.end());
|
||||
}
|
||||
// The FPS journal switch (2008): a flag, the client's int-parse + `!= 0`. The journal
|
||||
// system reads the knob every frame, so the file opens on the next second and closes
|
||||
// the second it is turned off.
|
||||
"fpsjournal" => knobs.fps_journal.0 = v != 0.0,
|
||||
// Multisampling (1629) — the reference's own `atoi`-then-clamp `[1, 16]` at `0x63b250`.
|
||||
// Writing the knob live is faithful, not a bug: the CVar holds the PENDING value (latched),
|
||||
// and nothing reads this resource after the world camera's spawn.
|
||||
@@ -1334,6 +1354,7 @@ fn sync_cvars(
|
||||
msaa_formats,
|
||||
tex_filter,
|
||||
realmlist,
|
||||
fps_journal,
|
||||
} = ¶ms;
|
||||
// The config file's values go in FIRST (decision 1291): registration — ours below, or an
|
||||
// addon's `RegisterCVar` later — starts a key at its saved value. This is what carries a
|
||||
@@ -1366,7 +1387,7 @@ fn sync_cvars(
|
||||
.collect(),
|
||||
);
|
||||
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
|
||||
let session: [(&str, String); 44] = [
|
||||
let session: [(&str, String); 45] = [
|
||||
("MasterVolume", sound.master.to_string()),
|
||||
("SoundVolume", sound.sfx.to_string()),
|
||||
("MusicVolume", sound.music.to_string()),
|
||||
@@ -1427,6 +1448,7 @@ fn sync_cvars(
|
||||
("gxMultisample", msaa.samples.to_string()),
|
||||
("trilinear", flag(tex_filter.trilinear)),
|
||||
("anisotropic", tex_filter.aniso.to_string()),
|
||||
("fpsJournal", flag(fps_journal.0)),
|
||||
// The other string-valued row (1667): what the next logon attempt will actually dial,
|
||||
// including a `$WOW_HOST` the player never typed.
|
||||
(
|
||||
@@ -1874,6 +1896,7 @@ mod tests {
|
||||
let mut realmlist =
|
||||
crate::realmlist::Realmlist::unpinned(crate::realmlist::DEFAULT_REALMLIST);
|
||||
let mut auto_self_cast = crate::ui_action::AutoSelfCast::default();
|
||||
let mut fps_journal = crate::perf::FpsJournalSetting::default();
|
||||
let mut knobs = Knobs {
|
||||
sound: &mut sound,
|
||||
auto_self_cast: &mut auto_self_cast,
|
||||
@@ -1898,6 +1921,7 @@ mod tests {
|
||||
tex_filter: &mut tex_filter,
|
||||
msaa_formats: &msaa_formats,
|
||||
realmlist: &mut realmlist,
|
||||
fps_journal: &mut fps_journal,
|
||||
};
|
||||
assert!(apply_to_knobs("MusicVolume", "0.7", &mut knobs));
|
||||
assert_eq!(knobs.sound.music, 0.7);
|
||||
@@ -1967,6 +1991,12 @@ mod tests {
|
||||
assert_eq!(knobs.render_scale.0, *RENDER_SCALE_RANGE.end());
|
||||
assert!(apply_to_knobs("renderscale", "0", &mut knobs));
|
||||
assert_eq!(knobs.render_scale.0, *RENDER_SCALE_RANGE.start());
|
||||
// The FPS journal switch (2008): a flag, case-insensitive, off as shipped.
|
||||
assert!(!knobs.fps_journal.0);
|
||||
assert!(apply_to_knobs("fpsJournal", "1", &mut knobs));
|
||||
assert!(knobs.fps_journal.0);
|
||||
assert!(apply_to_knobs("fpsjournal", "0", &mut knobs));
|
||||
assert!(!knobs.fps_journal.0);
|
||||
// Enable flags: any nonzero is on, zero is off (the client's int-parse + != 0).
|
||||
assert!(apply_to_knobs("EnableMusic", "0", &mut knobs));
|
||||
assert!(!knobs.sound.music_enabled);
|
||||
@@ -2176,6 +2206,7 @@ mod tests {
|
||||
.init_resource::<crate::ui_guild::GuildMemberNotify>()
|
||||
.init_resource::<crate::ui_trade::BlockTrades>()
|
||||
.init_resource::<crate::ui_action::AutoSelfCast>()
|
||||
.init_resource::<crate::perf::FpsJournalSetting>()
|
||||
.add_plugins(CvarPlugin);
|
||||
app.insert_non_send_resource(UiScript::new().unwrap());
|
||||
app
|
||||
|
||||
@@ -407,11 +407,9 @@ impl Plugin for DevProbesPlugin {
|
||||
app.add_plugins(crate::capture::ProbeLookPlugin);
|
||||
app.add_plugins(crate::capture::ProbePitchPlugin);
|
||||
app.add_plugins(crate::capture::ProbeCamPlugin);
|
||||
// The FPS journal: `WOW_FPS_JOURNAL=<csv>` appends per-second position + frame-time rows on a
|
||||
// director-driven run — "where does it dip" as coordinates (see `perf::FpsJournalPlugin`).
|
||||
if std::env::var("WOW_FPS_JOURNAL").is_ok() {
|
||||
app.add_plugins(crate::perf::FpsJournalPlugin);
|
||||
}
|
||||
// (The FPS journal — `WOW_FPS_JOURNAL=<csv>`, or the `fpsJournal` CVar — is
|
||||
// registered from `lib.rs` in every build since 2008: it is the one instrument a
|
||||
// player runs for us.)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ mod names;
|
||||
mod net;
|
||||
mod npc_text;
|
||||
mod pending_item_ops;
|
||||
#[cfg(feature = "dev")]
|
||||
/// Ships in part: the FPS journal and the clocks it reads (2008); the rest is `dev` (1173).
|
||||
mod perf;
|
||||
mod pipe_warm;
|
||||
mod player;
|
||||
@@ -152,6 +152,7 @@ mod ui_macro;
|
||||
mod ui_mail;
|
||||
mod ui_merchant;
|
||||
mod ui_mirror;
|
||||
mod ui_models;
|
||||
mod ui_net;
|
||||
mod ui_party;
|
||||
mod ui_pass;
|
||||
@@ -529,6 +530,10 @@ pub fn run(build: BuildId) -> AppExit {
|
||||
// context the perf pill needs). `--no-default-features` compiles every one of them out; see
|
||||
// `dev.rs` for what is in the group and the one rule that governs the boundary.
|
||||
.add_plugins(dev::DevToolsPlugin)
|
||||
// The FPS journal — the one instrument that ships (2008): `/console fpsJournal 1` in any
|
||||
// build appends a per-second row of position, frame cost and the GPU's per-pass split to
|
||||
// `benilla-config/Diagnostics/fps-journal.csv`; `WOW_FPS_JOURNAL=<csv>` is the harness lever.
|
||||
.add_plugins(perf::FpsJournalPlugin)
|
||||
.add_plugins(BowstringPlugin)
|
||||
.add_plugins(FishingLinePlugin)
|
||||
.add_plugins(QuestMarkersPlugin)
|
||||
@@ -602,6 +607,9 @@ pub fn run(build: BuildId) -> AppExit {
|
||||
// The pet-bar / spellbook autocast shine, drawn on the append lane from the conversion's
|
||||
// parked sites — zero per-frame script-layout traffic (decision 1383, B282).
|
||||
.add_plugins(autocast_shine::AutocastShinePlugin)
|
||||
// The `<Model>` widgets' M2s, rendered as tiles of one atlas and composited at the
|
||||
// callback rank (decision 2008).
|
||||
.add_plugins(ui_models::UiModelsPlugin)
|
||||
// The shared AreaTable catalog + the ZONE_CHANGED event family / zone-text host globals
|
||||
// behind GetZoneText & co. (the zone-entry splash arc, decision 0287).
|
||||
.add_plugins(area::AreaPlugin)
|
||||
|
||||
@@ -300,7 +300,7 @@ pub(crate) fn screenshots_dir() -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
/// `benilla-config/Diagnostics/` — where the stuck-thread self-sampler drops its profiles
|
||||
/// ([`crate::perf::stall`]).
|
||||
/// ([`crate::perf::stall`]) and the FPS journal its rows ([`fps_journal_path`]).
|
||||
///
|
||||
/// **This was `~/Library/Logs/benilla/` until 2026-08-27, hand-built from `$HOME`** — a platform
|
||||
/// log directory, which is the exact shape the one-folder rule names as forbidden ("never a
|
||||
@@ -316,12 +316,17 @@ pub(crate) fn screenshots_dir() -> Option<PathBuf> {
|
||||
/// this instrument was built for — the director's, and the long probe rounds — keep it. The
|
||||
/// alternative, an ungated second accessor, would put "which paths are exempt from hermetic?" back
|
||||
/// into someone's head, which is what the single rule exists to prevent.
|
||||
// Same one reader as `sound::output::device_open`: the macOS-only stall watchdog.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
pub(crate) fn diagnostics_dir() -> Option<PathBuf> {
|
||||
home().map(|h| h.join("Diagnostics"))
|
||||
}
|
||||
|
||||
/// `benilla-config/Diagnostics/fps-journal.csv` — the FPS journal's rows while the `fpsJournal`
|
||||
/// CVar is on (decision 2008): the file a reporter attaches. `None` on a hermetic run like
|
||||
/// everything here; the harness names its own path through `WOW_FPS_JOURNAL` instead.
|
||||
pub(crate) fn fps_journal_path() -> Option<PathBuf> {
|
||||
diagnostics_dir().map(|d| d.join("fps-journal.csv"))
|
||||
}
|
||||
|
||||
/// Make an arbitrary realm/character name safe as one path component: anything outside
|
||||
/// `[A-Za-z0-9_]` becomes `_`, so a realm called `Hydraxian Waterlords` or one with a slash cannot
|
||||
/// escape the folder or collide with the path separator.
|
||||
|
||||
@@ -274,10 +274,6 @@ struct MinimapAssets {
|
||||
/// (`Rotating-MinimapArrow.mdx`) the reference re-animates per blip source. See
|
||||
/// [`blips::RimArrow`] for the sequence→layer table and why there are four of them.
|
||||
rim_arrows: blips::RimArrowArt,
|
||||
/// The ping marker's three drawn layers — the flat re-expression of `MinimapPing.mdx`'s
|
||||
/// coincident additive quads, at the model's own byte-measured sizes and animation
|
||||
/// ([`ping::PingArt`], decisions 1596/1599).
|
||||
ping: ping::PingArt,
|
||||
/// The unit-blip atlas (`Interface\Minimap\ObjectIcons`, five 32-px dot cells) — the
|
||||
/// quest-giver dots.
|
||||
object_icons: Option<Handle<Image>>,
|
||||
@@ -323,29 +319,6 @@ fn setup_minimap(
|
||||
}
|
||||
let object_icons =
|
||||
assets.sprite_texture("Interface\\Minimap\\ObjectIcons", &mut images);
|
||||
// `ping6` is deliberately absent: the model carries it on two quads whose weight
|
||||
// track is a single key of 0, so the client culls those batches before it reads their
|
||||
// blend mode. Loading it would be loading art that never draws.
|
||||
let ping = ping::PingArt {
|
||||
ping5: assets.sprite_texture("Interface\\Minimap\\Ping\\ping5", &mut images),
|
||||
ping2: assets.sprite_texture("Interface\\Minimap\\Ping\\ping2", &mut images),
|
||||
ping4: assets.sprite_texture("Interface\\Minimap\\Ping\\ping4", &mut images),
|
||||
};
|
||||
// Per layer, not just "none of them": the three do different jobs, and a silently
|
||||
// absent `ping4` would cost the ring — the one thing the eye actually reads — while
|
||||
// the marker still looked plausible (1203's rule, applied to art).
|
||||
for (name, present) in [
|
||||
("ping5", ping.ping5.is_some()),
|
||||
("ping2", ping.ping2.is_some()),
|
||||
("ping4", ping.ping4.is_some()),
|
||||
] {
|
||||
if !present {
|
||||
warn!(
|
||||
"minimap: Interface\\Minimap\\Ping\\{name} missing — the ping marker \
|
||||
will draw without that layer"
|
||||
);
|
||||
}
|
||||
}
|
||||
if mask.is_none() {
|
||||
warn!("minimap: MinimapMask.blp missing — the map will draw square");
|
||||
}
|
||||
@@ -366,7 +339,6 @@ fn setup_minimap(
|
||||
poi,
|
||||
rim_arrows,
|
||||
object_icons,
|
||||
ping,
|
||||
forms,
|
||||
});
|
||||
}
|
||||
@@ -471,14 +443,7 @@ fn emit_minimap(
|
||||
// always spent in the frame it was made. Held across frames it would seat against geometry
|
||||
// the player never clicked on; and a click made on a frame the map does not draw is simply
|
||||
// not a ping (decision 1596).
|
||||
// The stock `MiniMapPing` frame's alpha is read beside it: the ping's lifetime is that
|
||||
// frame's (`ping.rs`), so the sprite wants the frame as it stands after this tick.
|
||||
let (click, ping_shown) = script.map_or((None, None), |mut s| {
|
||||
(
|
||||
s.take_minimap_ping_request(),
|
||||
s.frame_effective_alpha(ping::PING_FRAME),
|
||||
)
|
||||
});
|
||||
let click = script.and_then(|mut s| s.take_minimap_ping_request());
|
||||
let (Some(slot), Some(assets), Some(map), Some(catalog)) =
|
||||
(widget.0.as_ref(), assets, map, catalog)
|
||||
else {
|
||||
@@ -939,14 +904,12 @@ fn emit_minimap(
|
||||
}
|
||||
}
|
||||
|
||||
// The ping draws LAST — above the dots and the player arrow. In the reference `MiniMapPing` is
|
||||
// a Lua Frame child of the Minimap, so it composites over everything the engine drew into the
|
||||
// widget's hole; ours is engine-drawn but keeps that place in the order.
|
||||
//
|
||||
// This is also where a `Minimap:PingLocation` click is drained: the geometry it must resolve
|
||||
// against is the geometry standing right here, this frame (decision 1596).
|
||||
// A `Minimap:PingLocation` click is seated here, against the geometry standing right here,
|
||||
// this frame (decision 1596). The marker itself is the stock `MiniMapPing` `<Model>`, a Lua
|
||||
// child of the Minimap that composites over everything the engine drew into the widget's
|
||||
// hole — rendered by `crate::ui_models` since decision 2008.
|
||||
if let Some(ctx) = &blip_ctx {
|
||||
ping::emit_ping(ctx, &mut ping, click, ping_shown, &assets.ping, &mut quads);
|
||||
ping::seat_click(ctx, &mut ping, click);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,33 +3,33 @@
|
||||
//! ## The pin
|
||||
//!
|
||||
//! A ping marks a **place in the world**, so a world point `(x, y)` is the only thing this module
|
||||
//! stores. Where it lands on screen is *derived*, every frame, by [`BlipCtx::offset`] — the exact
|
||||
//! same function the party dots, the quest dots and the corpse blip go through. It therefore
|
||||
//! stores. Where it lands on screen is *derived* — by the stock `Minimap_OnUpdate`, which
|
||||
//! re-seats the `MiniMapPing` frame every frame from `GetPingPosition()`, the normalized offset
|
||||
//! [`drive_minimap_ping`] republishes from the pin against the live view radius. It therefore
|
||||
//! cannot drift from the map, cannot lag the pan by a frame, and cannot survive a zoom change at
|
||||
//! the old scale: there is no second copy of the position to fall out of step with.
|
||||
//!
|
||||
//! That is the whole difference from the first attempt (decision 0453 / 0471), which stored the
|
||||
//! world point in the engine but drew the marker from **Lua** — a `MiniMapPing` frame re-seated by
|
||||
//! `Minimap_OnUpdate` through `SetPoint` from a normalized offset the app pushed. Four
|
||||
//! independent ways to be wrong, and it was wrong in three of them; 1596 §2 has the autopsy.
|
||||
//! world point in the engine but drew the marker from **Lua** off a stale push; 1596 §2 has the
|
||||
//! autopsy.
|
||||
//!
|
||||
//! ## The three legs
|
||||
//!
|
||||
//! - **In** — a click reaches Lua's `Minimap_OnClick` (ours, and hookable: the corpus's
|
||||
//! - **In** — a click reaches Lua's `Minimap_OnClick` (the stock one, hookable: the corpus's
|
||||
//! `CleanMinimap` replaces that global outright), which calls `Minimap:PingLocation(dx, dy)`
|
||||
//! with centre-relative offsets in **UI units**. [`emit_ping`]'s caller drains it in the *same
|
||||
//! with centre-relative offsets in **UI units**. [`seat_click`]'s caller drains it in the *same
|
||||
//! frame it draws the map*, converting through that frame's own geometry: UI units × the 0582
|
||||
//! seam scale = window px, ÷ `px_per_yd` = yards. (Skipping that seam multiply is what put the
|
||||
//! first version's ping ~27 % too far from the player at 1080p.)
|
||||
//! - **Across** — our own ping sends `MSG_MINIMAP_PING` (raw world floats; the server relays them
|
||||
//! verbatim to the rest of the group and nowhere else). A group member's arrives through the
|
||||
//! session event and seats the same way. A ping is drawn **locally at click time**, never waited
|
||||
//! for off the wire: vanilla pings work solo.
|
||||
//! session event and seats the same way. A ping is seated **locally at click time**, never
|
||||
//! waited for off the wire: vanilla pings work solo.
|
||||
//! - **Out** — `MINIMAP_PING (unitToken, nx, ny)` fires for addons, with the same normalized
|
||||
//! offsets the byte-verified relay `0x4ee330` hands Lua (`(−dy·k, dx·k)`, `k = 1/(2·radius)` —
|
||||
//! wow-re `party-group-wire.md` §TU-D). `Minimap:GetPingPosition()` reads the live value back.
|
||||
//!
|
||||
//! ## Lifetime — the stock `Minimap.lua`'s, not ours (1974)
|
||||
//! ## Lifetime and pixels — the stock `Minimap.lua`'s and its `<Model>`'s, not ours
|
||||
//!
|
||||
//! The reference splits the ping in two: the engine stores the world point in a pair of statics
|
||||
//! **nothing ever clears** (wow-re `minimap-ping-law.md` §3 — six instructions touch those cells
|
||||
@@ -37,10 +37,13 @@
|
||||
//! `MiniMapPing` `<Model>` it shows on `MINIMAP_PING`, re-seats every frame from
|
||||
//! `GetPingPosition()`, holds 5 s (`MINIMAPPING_TIMER`), "fades" 0.5 s through a `SetAlpha(255·t)`
|
||||
//! that clamps to full until the last ~2 ms, and hides. Since 1751's swap that file runs here
|
||||
//! verbatim, and this engine's `<Model>` draws no pixels — so the sprite below is **that frame's
|
||||
//! renderer**: it draws while `MiniMapPing` is effectively visible, at its effective alpha, and
|
||||
//! keeps no clock of its own. The world point is likewise never cleared: `GetPingPosition()`
|
||||
//! answers two numbers always, which the stock `Minimap_OnUpdate` multiplies without a nil test.
|
||||
//! verbatim (1974), and since decision 2008 the `<Model>` it shows **renders its own file**
|
||||
//! (`Interface\MiniMap\Ping\MinimapPing.mdx`, through `crate::ui_models`): the spinner on its
|
||||
//! global-sequence clock, the static centre, the ring on the looping Stand — the model's own
|
||||
//! bones, weight tracks and additive quads, on the pane's private clock that runs only while the
|
||||
//! frame is shown (decision 2007). The sprite this module used to draw in their place (1596/1599's
|
||||
//! byte-measured re-expression of those quads) is gone with it, and so is the world-map ping gap
|
||||
//! 1980 named: `WorldMapPing` is the same file on the map sheet.
|
||||
//!
|
||||
//! What is *not* a lifetime: proximity. The first version applied the client's 10-yd
|
||||
//! `d² < 100` auto-clear to the party ping, and that clear belongs to the **`SMSG_GOSSIP_POI`
|
||||
@@ -55,116 +58,6 @@ use benilla_ui::script::{ScriptValue, UiScript};
|
||||
use super::blips::BlipCtx;
|
||||
use crate::net::{ClientCommand, Guid, NetCommands, SelfPlayer};
|
||||
use crate::player::Player;
|
||||
use crate::ui_pass::{UiQuad, UiQuads};
|
||||
|
||||
/// The stock frame whose pixels this module draws: `Minimap.xml`'s `<Model name="MiniMapPing">`.
|
||||
/// Its show/hide and alpha ARE the ping's lifetime (module doc). The reference's "fade" pops
|
||||
/// rather than fades — `Minimap_OnUpdate` writes `SetAlpha(255 · t/0.5)` and `Frame:SetAlpha`
|
||||
/// (`0x774e90`) clamps to [0,1] first, so the frame reads full alpha for 498 of the 500 ms — and
|
||||
/// that arrives here through the frame's effective alpha, with no constant of ours to keep it.
|
||||
pub(super) const PING_FRAME: &str = "MiniMapPing";
|
||||
|
||||
/// **The marker, byte-measured** (wow-re `system/ui/scratch/minimap-ping-law.md` §10, VERIFIED).
|
||||
///
|
||||
/// `MiniMapPing` is a `<Model>` on `Interface\MiniMap\Ping\MinimapPing.mdx` at XML `scale="0.4"`,
|
||||
/// which puts **1 model unit at 512 px** on the client's stock basis. The model is *five coincident
|
||||
/// full-UV quads* sharing one centre, every one of them `blendMode = 4` — **additive**
|
||||
/// `SRC_ALPHA/ONE` — with `flags = 0x0011` (unlit, no depth write). Three of them draw:
|
||||
///
|
||||
/// | texture | model units | px | behaviour |
|
||||
/// |---|---|---|---|
|
||||
/// | `ping5` | `0.069 × 0.350634` | **12.39** | spins CW, one turn per 4833 ms, alpha 1 |
|
||||
/// | `ping2` | `0.025` | **12.80** | fully static — its bone has no tracks at all |
|
||||
/// | `ping4` | `0.00625 × (1→10)` | **3.20 → 32.00** | the expanding, fading ring |
|
||||
///
|
||||
/// `ping6` (two of the five quads) is **culled, not invisible**: its weight track is a single key
|
||||
/// of 0, so the batch is skipped before `blendMode` is read. It is not emitted here.
|
||||
///
|
||||
/// Sizes are px on the frozen 140.8-px minimap basis, like every other blip constant
|
||||
/// ([`super::blips::BLIP_BASIS_PX`]) — the model's px-per-unit and the widget's size both scale
|
||||
/// with the same screen basis, so their ratio is the constant.
|
||||
///
|
||||
/// The paused first version drew one flat 40 px stack: **~3× too big, and missing the ring** —
|
||||
/// which is the motion the eye actually reads.
|
||||
const PING5_PX: f32 = 0.069 * 0.350_634 * PX_PER_MODEL_UNIT;
|
||||
const PING2_PX: f32 = 0.025 * PX_PER_MODEL_UNIT;
|
||||
const PING4_PX: f32 = 0.006_25 * PX_PER_MODEL_UNIT;
|
||||
|
||||
/// `scale="0.4"` on a `<Model>` frame ⇒ `(5/3) · 0.4 · 768` px per model unit at the reference's
|
||||
/// screen basis (aspect- and resolution-independent — the terms cancel).
|
||||
const PX_PER_MODEL_UNIT: f32 = 512.0;
|
||||
|
||||
/// The **sequence** clock: sequence 1 "Stand", looped. `ping4`'s ring rides this.
|
||||
const PING_SEQ_MS: f32 = 833.0;
|
||||
/// The **global** clock (`globalSequence = 0`), which never consults the sequence window at all.
|
||||
/// `ping5`'s spin rides this — the two periods are coprime, so they re-phase only after ~67 min.
|
||||
const PING_SPIN_MS: f32 = 4833.0;
|
||||
|
||||
/// `ping4`'s alpha: linear `0 → 1` over the first 400 ms of the loop, then `1 → 0` over the
|
||||
/// remaining 433 ms. Peak alpha lands at 400 ms, when the ring is `1 + 9·400/833 = 5.32×`.
|
||||
const PING4_ALPHA_PEAK_MS: f32 = 400.0;
|
||||
|
||||
/// `ping5`'s spin, as the model's own 21 keys — `(ms, degrees clockwise on screen)`, unwrapped and
|
||||
/// monotonic (wow-re §10.3). The rate is **not** uniform: ~67.5 °/s over the first half-turn,
|
||||
/// ~83.1 °/s over the second, with a 108 °/s burst across 225°→270° that an average hides.
|
||||
///
|
||||
/// Kept as the table rather than collapsed to a constant rate for exactly that reason. wow-re's
|
||||
/// three transcription warnings do not bite here — they are about interpolating the model's
|
||||
/// *quaternions* (a shortest-path slerp collapses the revolution to no motion, and a `w ≥ 0`
|
||||
/// canonicalisation reverses it at 180°). Interpolating the **angle** linearly, as below, is the
|
||||
/// sanctioned form: it deviates by at most 0.014°.
|
||||
const PING5_SPIN: [(f32, f32); 21] = [
|
||||
(0.0, 0.0),
|
||||
(333.0, 22.5),
|
||||
(667.0, 45.0),
|
||||
(889.0, 60.0),
|
||||
(1111.0, 75.0),
|
||||
(1333.0, 90.0),
|
||||
(1666.0, 112.5),
|
||||
(2000.0, 135.0),
|
||||
(2222.0, 150.0),
|
||||
(2444.0, 165.0),
|
||||
(2667.0, 180.0),
|
||||
(2847.0, 195.0),
|
||||
(3027.0, 210.0),
|
||||
(3208.0, 225.0),
|
||||
(3416.0, 247.5),
|
||||
(3625.0, 270.0),
|
||||
(3805.0, 285.0),
|
||||
(3986.0, 300.0),
|
||||
(4167.0, 315.0),
|
||||
(4500.0, 337.5),
|
||||
(4833.0, 360.0),
|
||||
];
|
||||
|
||||
/// The spin angle at `ms` into the global clock, in **radians clockwise on screen** — which is our
|
||||
/// quad `rotation`'s own sense (the player arrow negates a WoW facing for the same reason).
|
||||
fn spin_radians(ms: f32) -> f32 {
|
||||
let t = ms.rem_euclid(PING_SPIN_MS);
|
||||
let deg = PING5_SPIN
|
||||
.windows(2)
|
||||
.find(|w| t <= w[1].0)
|
||||
.map_or(0.0, |w| {
|
||||
let (t0, a0) = w[0];
|
||||
let (t1, a1) = w[1];
|
||||
let u = if t1 > t0 { (t - t0) / (t1 - t0) } else { 0.0 };
|
||||
a0 + (a1 - a0) * u
|
||||
});
|
||||
deg.to_radians()
|
||||
}
|
||||
|
||||
/// The **art**, one handle per drawn layer. Named rather than a list: the three do different
|
||||
/// things, and a `Vec` that silently changed order would change the animation.
|
||||
#[derive(Default)]
|
||||
pub(super) struct PingArt {
|
||||
/// The spinner (draw order 0 in the model — though with additive and no depth write, order
|
||||
/// cannot change the image).
|
||||
pub(super) ping5: Option<Handle<Image>>,
|
||||
/// The static centre.
|
||||
pub(super) ping2: Option<Handle<Image>>,
|
||||
/// The expanding ring.
|
||||
pub(super) ping4: Option<Handle<Image>>,
|
||||
}
|
||||
|
||||
/// The stored ping — the reference's two statics. One, never cleared: the reference keeps no
|
||||
/// list, and no map tag either (a worldport mid-ping re-projects the old point against the new
|
||||
@@ -180,20 +73,11 @@ struct LivePing {
|
||||
|
||||
/// The engine-owned ping state (decision 1596). Seated by a click (drained in the renderer, with
|
||||
/// that frame's geometry) or by a group member's `MSG_MINIMAP_PING`; announced by
|
||||
/// [`drive_minimap_ping`]; shown for exactly as long as the stock `MiniMapPing` frame is.
|
||||
/// [`drive_minimap_ping`]; drawn by the stock `MiniMapPing` frame for exactly as long as that
|
||||
/// frame shows itself.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct MinimapPing {
|
||||
live: Option<LivePing>,
|
||||
/// [`PING_FRAME`]'s effective alpha while it is effectively visible, as the renderer last read
|
||||
/// it — `None` hidden. The model clock below runs only while this is `Some`.
|
||||
shown: Option<f32>,
|
||||
/// The model's own clock, in **milliseconds, accumulated only while a ping is shown** — which
|
||||
/// is the reference's behaviour rather than a simplification of it. `SetSequence(0)` runs once
|
||||
/// in the ref's `OnLoad` and stores an *anchor*; the sampler free-runs from it, and a Model
|
||||
/// frame's private clock advances only while the frame is shown. The ref then only `Show()`s
|
||||
/// per ping. So **ping N resumes where ping N−1 left off**, and no two consecutive pings look
|
||||
/// alike (wow-re §10, VERIFIED). One accumulator reproduces that; a per-ping reset would not.
|
||||
shown_ms: f32,
|
||||
/// A ping seated since the last [`drive_minimap_ping`] — it still owes the world an outbound
|
||||
/// `MSG_MINIMAP_PING` (if it is ours) and a `MINIMAP_PING` event (either way).
|
||||
fresh: bool,
|
||||
@@ -231,95 +115,29 @@ fn click_to_world(ctx: &BlipCtx, ui: (f32, f32), seam: f32) -> Option<(f32, f32)
|
||||
Some((ctx.wx + up_yd, ctx.wy - right_yd))
|
||||
}
|
||||
|
||||
/// Seat this frame's `Minimap:PingLocation` click and draw the ping — both inside the
|
||||
/// renderer, against the geometry the player actually clicked on and the map actually drew at.
|
||||
/// `shown` is the stock [`PING_FRAME`]'s effective alpha this frame (`None` = hidden), read
|
||||
/// beside the click: the frame is the ping's lifetime (module doc), so nothing draws without it.
|
||||
/// Seat this frame's `Minimap:PingLocation` click — inside the renderer, against the geometry
|
||||
/// the player actually clicked on and the map actually drew at.
|
||||
///
|
||||
/// The seat happens here rather than in a system of its own precisely so there is no window in
|
||||
/// which a click is held against a *stale* view scale: the first version parked the click for a
|
||||
/// separate system that read the scale the renderer had left behind on the previous frame, and
|
||||
/// dropped the click outright whenever that leftover was still zero. (The *drain* is the caller's,
|
||||
/// one step earlier, so the click is spent even on a frame that draws no map — see there.)
|
||||
pub(super) fn emit_ping(
|
||||
ctx: &BlipCtx,
|
||||
ping: &mut MinimapPing,
|
||||
click: Option<(f32, f32)>,
|
||||
shown: Option<f32>,
|
||||
art: &PingArt,
|
||||
quads: &mut UiQuads,
|
||||
) {
|
||||
pub(super) fn seat_click(ctx: &BlipCtx, ping: &mut MinimapPing, click: Option<(f32, f32)>) {
|
||||
if let Some(world) = click.and_then(|c| click_to_world(ctx, c, ctx.seam)) {
|
||||
ping.seat(world, 0);
|
||||
}
|
||||
|
||||
ping.shown = shown;
|
||||
let Some(alpha) = shown else { return };
|
||||
let Some(live) = ping.live.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let (px, py) = live.world;
|
||||
|
||||
// The reference's Lua hides the marker outside the disc and keeps the ping alive
|
||||
// (`Minimap_SetPing`'s else-branch is `MiniMapPing:Hide()`, not a clear) — so walking back
|
||||
// into range brings it back for the rest of its 5 s. The stock file makes that test itself
|
||||
// and hides the frame; this one is the same test in yards, for the frame it cannot see.
|
||||
let d = (px - ctx.wx).hypot(py - ctx.wy);
|
||||
if d >= ctx.radius_yd {
|
||||
return;
|
||||
}
|
||||
let at = ctx.center + ctx.offset([px, py, 0.0]);
|
||||
// px on the frozen 140.8 basis → this widget's px, the blip layer's own scalar.
|
||||
let k = ctx.side / super::blips::BLIP_BASIS_PX;
|
||||
let a = alpha * ctx.alpha;
|
||||
let mut layer = |art: &Option<Handle<Image>>, side_px: f32, alpha: f32, rotation: f32| {
|
||||
let Some(texture) = art.clone() else { return };
|
||||
if alpha <= 0.0 {
|
||||
return;
|
||||
}
|
||||
quads.overlays.push(UiQuad {
|
||||
rect: Rect::from_center_size(at, Vec2::splat(side_px * k)),
|
||||
z_key: ctx.z,
|
||||
texture: Some(texture),
|
||||
color: [1.0, 1.0, 1.0, alpha],
|
||||
// Every one of the model's quads is `blendMode = 4`, SRC_ALPHA/ONE.
|
||||
additive: true,
|
||||
rotation,
|
||||
..default()
|
||||
});
|
||||
};
|
||||
|
||||
// The spinner, on the 4833 ms global clock.
|
||||
layer(&art.ping5, PING5_PX, a, spin_radians(ping.shown_ms));
|
||||
// The static centre.
|
||||
layer(&art.ping2, PING2_PX, a, 0.0);
|
||||
// The ring, on the 833 ms sequence loop: geometry scale `1 + 9·t/833` about its own centre
|
||||
// (the model carries NO texture-transform chunk, so this is a real size change, not a UV one),
|
||||
// under a two-leg alpha that peaks 400 ms in.
|
||||
let t = ping.shown_ms.rem_euclid(PING_SEQ_MS);
|
||||
let ring_alpha = if t <= PING4_ALPHA_PEAK_MS {
|
||||
t / PING4_ALPHA_PEAK_MS
|
||||
} else {
|
||||
(PING_SEQ_MS - t) / (PING_SEQ_MS - PING4_ALPHA_PEAK_MS)
|
||||
};
|
||||
layer(
|
||||
&art.ping4,
|
||||
PING4_PX * (1.0 + 9.0 * t / PING_SEQ_MS),
|
||||
a * ring_alpha,
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Announce a fresh ping and republish the position — everything that is *not* geometry.
|
||||
///
|
||||
/// Runs before the script tick so the `MINIMAP_PING` event and the position behind
|
||||
/// `Minimap:GetPingPosition()` land in the same tick, and so an addon's handler sees a ping that
|
||||
/// is already on screen (the renderer seated and drew it at the end of the previous frame).
|
||||
/// is already seated (the renderer seated it at the end of the previous frame).
|
||||
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
|
||||
pub(super) fn drive_minimap_ping(
|
||||
script: Option<bevy::ecs::system::NonSendMut<UiScript>>,
|
||||
mut ping: ResMut<MinimapPing>,
|
||||
time: Res<Time>,
|
||||
player: Res<Player>,
|
||||
widget: Res<super::MinimapWidget>,
|
||||
inside: Res<super::MinimapInside>,
|
||||
@@ -327,13 +145,6 @@ pub(super) fn drive_minimap_ping(
|
||||
self_q: Query<&Guid, With<SelfPlayer>>,
|
||||
commands: Res<NetCommands>,
|
||||
) {
|
||||
// The model clock runs only while the frame is shown — see `shown_ms`. (The reference's
|
||||
// `Hide()` covers the off-disc case too; ours keeps running there, a difference of a few
|
||||
// tenths of a phase on a marker nobody can see.)
|
||||
if ping.shown.is_some() {
|
||||
ping.shown_ms += time.delta_secs() * 1000.0;
|
||||
}
|
||||
|
||||
let Some(mut script) = script else { return };
|
||||
let Some(live) = ping.live.as_ref() else {
|
||||
return;
|
||||
@@ -495,196 +306,33 @@ mod tests {
|
||||
/// **No proximity clear** (decision 1596 §2.2). The first version applied the client's 10-yd
|
||||
/// `d² < 100` auto-clear to the party ping; wow-re `party-group-wire.md` §TU-D shows that
|
||||
/// clear belongs to the `SMSG_GOSSIP_POI` marker, and that `MSG_MINIMAP_PING` has no C-side
|
||||
/// storage to clear at all. Standing on your own ping must not delete it.
|
||||
/// storage to clear at all. Standing on your own ping must not delete it — and a frame with
|
||||
/// no click seats nothing over it.
|
||||
#[test]
|
||||
fn reaching_the_ping_does_not_clear_it() {
|
||||
let mut c = ctx();
|
||||
let mut ping = MinimapPing::default();
|
||||
let mut quads = UiQuads::default();
|
||||
ping.seat((1.0, 1.0), 0);
|
||||
// Walk onto the point: it still draws.
|
||||
// Walk onto the point: the pin stands.
|
||||
c.wx = 1.0;
|
||||
c.wy = 1.0;
|
||||
emit_ping(&c, &mut ping, None, Some(1.0), &art(), &mut quads);
|
||||
assert!(!quads.overlays.is_empty(), "a reached ping still draws");
|
||||
seat_click(&c, &mut ping, None);
|
||||
let live = ping.live.as_ref().expect("a reached ping is still a ping");
|
||||
assert_eq!(live.world, (1.0, 1.0));
|
||||
}
|
||||
|
||||
/// **The lifetime is the stock frame's** (1974): the sprite draws while `MiniMapPing` is
|
||||
/// shown, at the frame's alpha, and nothing here ages it. The model clock advances only
|
||||
/// while the frame is shown.
|
||||
/// A click seats a fresh pin at the clicked world point, replacing the last one; the
|
||||
/// announcement flag rides the seat.
|
||||
#[test]
|
||||
fn the_marker_draws_only_while_the_stock_frame_is_shown() {
|
||||
fn a_click_seats_a_fresh_pin() {
|
||||
let c = ctx();
|
||||
let mut ping = MinimapPing::default();
|
||||
let mut quads = UiQuads::default();
|
||||
ping.seat((0.0, 0.0), 0);
|
||||
emit_ping(&c, &mut ping, None, None, &art(), &mut quads);
|
||||
assert!(quads.overlays.is_empty(), "hidden frame: nothing drawn");
|
||||
assert!(ping.shown.is_none());
|
||||
emit_ping(&c, &mut ping, None, Some(0.5), &art(), &mut quads);
|
||||
assert!(!quads.overlays.is_empty(), "shown frame: drawn");
|
||||
assert!(
|
||||
quads
|
||||
.overlays
|
||||
.iter()
|
||||
.all(|q| (q.color[3] - 0.5).abs() < 1e-6 || q.color[3] == 0.0),
|
||||
"at the frame's alpha (the ring is transparent at phase 0)"
|
||||
);
|
||||
assert_eq!(ping.shown, Some(0.5));
|
||||
assert!(
|
||||
ping.live.is_some(),
|
||||
"the world point outlives the frame's show"
|
||||
);
|
||||
}
|
||||
|
||||
fn art() -> PingArt {
|
||||
PingArt {
|
||||
ping5: Some(Handle::default()),
|
||||
ping2: Some(Handle::default()),
|
||||
ping4: Some(Handle::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// **It draws, and where.** The pin's whole claim is that the marker's rect comes out of
|
||||
/// [`BlipCtx::offset`] like every other blip's — so this drives the real emitter and checks
|
||||
/// the rect, rather than trusting the caller. It also pins the two layers' byte-measured
|
||||
/// sizes, and the fact that the ring contributes **nothing** at phase 0 (its alpha track
|
||||
/// starts at zero, so a "three layers ⇒ three quads" assertion would be wrong).
|
||||
#[test]
|
||||
fn the_emitter_puts_the_measured_layers_at_the_pinned_point() {
|
||||
let c = ctx();
|
||||
let mut ping = MinimapPing::default();
|
||||
let mut quads = UiQuads::default();
|
||||
let art = art();
|
||||
|
||||
// No ping: nothing drawn.
|
||||
emit_ping(&c, &mut ping, None, Some(1.0), &art, &mut quads);
|
||||
assert!(quads.overlays.is_empty());
|
||||
|
||||
// A click 30 UI units up (north) at seam 1 seats a ping 30/px_per_yd yards north.
|
||||
emit_ping(
|
||||
&c,
|
||||
&mut ping,
|
||||
Some((0.0, 30.0)),
|
||||
Some(1.0),
|
||||
&art,
|
||||
&mut quads,
|
||||
);
|
||||
assert_eq!(
|
||||
quads.overlays.len(),
|
||||
2,
|
||||
"the ring is transparent at phase 0"
|
||||
);
|
||||
let want = c.center + c.offset([30.0 / c.px_per_yd, 0.0, 0.0]);
|
||||
let k = c.side / super::super::blips::BLIP_BASIS_PX;
|
||||
for q in &quads.overlays {
|
||||
let mid = (q.rect.min + q.rect.max) * 0.5;
|
||||
assert!((mid - want).length() < 1e-3, "{mid:?} vs {want:?}");
|
||||
assert!(q.additive, "every quad of the model is SRC_ALPHA/ONE");
|
||||
}
|
||||
// ping5 (12.39 px) draws first, ping2 (12.80 px) second — the model's own order.
|
||||
assert!((quads.overlays[0].rect.width() - PING5_PX * k).abs() < 1e-3);
|
||||
assert!((quads.overlays[1].rect.width() - PING2_PX * k).abs() < 1e-3);
|
||||
assert!(
|
||||
(PING5_PX - 12.39).abs() < 0.01 && (PING2_PX - 12.80).abs() < 0.01,
|
||||
"the byte-measured sizes: {PING5_PX} / {PING2_PX}"
|
||||
);
|
||||
|
||||
// Out of range it stops drawing WITHOUT dying: walk 200 yd away, then back. (The
|
||||
// reference's Lua hides the marker off-disc; it does not clear the ping.)
|
||||
quads.overlays.clear();
|
||||
let mut far = ctx();
|
||||
far.wx = -200.0;
|
||||
emit_ping(&far, &mut ping, None, Some(1.0), &art, &mut quads);
|
||||
assert!(quads.overlays.is_empty(), "off the disc: hidden");
|
||||
emit_ping(&c, &mut ping, None, Some(1.0), &art, &mut quads);
|
||||
assert_eq!(quads.overlays.len(), 2, "back in range: visible again");
|
||||
}
|
||||
|
||||
/// **The ring is the motion the eye reads** (wow-re §10.4): a geometry scale `1 + 9·t/833`
|
||||
/// from 3.2 px to 32 px across the sequence loop, under an alpha that rises over 400 ms and
|
||||
/// falls over the remaining 433 — peaking when the ring is 5.32× its base. The paused version
|
||||
/// had no ring at all.
|
||||
#[test]
|
||||
fn the_ring_expands_and_peaks_at_four_hundred_milliseconds() {
|
||||
let c = ctx();
|
||||
let k = c.side / super::super::blips::BLIP_BASIS_PX;
|
||||
let art = art();
|
||||
let ring_at = |ms: f32| {
|
||||
let mut ping = MinimapPing::default();
|
||||
ping.seat((0.0, 0.0), 0);
|
||||
ping.shown_ms = ms;
|
||||
let mut quads = UiQuads::default();
|
||||
emit_ping(&c, &mut ping, None, Some(1.0), &art, &mut quads);
|
||||
// The ring is the third layer whenever it is visible at all.
|
||||
quads
|
||||
.overlays
|
||||
.get(2)
|
||||
.map(|q| (q.rect.width() / k, q.color[3]))
|
||||
};
|
||||
assert_eq!(ring_at(0.0), None, "alpha 0 at the loop start: not emitted");
|
||||
let (w, a) = ring_at(PING4_ALPHA_PEAK_MS).expect("visible at the peak");
|
||||
assert!((a - 1.0).abs() < 1e-3, "peak alpha: {a}");
|
||||
assert!(
|
||||
(w / PING4_PX - 5.32).abs() < 0.01,
|
||||
"5.32x its base at the peak: {}",
|
||||
w / PING4_PX
|
||||
);
|
||||
// It keeps growing past the alpha peak, all the way to 10x, while fading out.
|
||||
let (w_late, a_late) = ring_at(PING_SEQ_MS - 1.0).expect("still visible near the end");
|
||||
assert!(w_late > w, "still expanding: {w_late} vs {w}");
|
||||
assert!(a_late < 0.02, "nearly gone: {a_late}");
|
||||
assert!(
|
||||
(w_late / PING4_PX - 10.0).abs() < 0.02,
|
||||
"10x at the loop end: {}",
|
||||
w_late / PING4_PX
|
||||
);
|
||||
assert!(
|
||||
(PING4_PX - 3.2).abs() < 0.01 && (PING4_PX * 10.0 - 32.0).abs() < 0.05,
|
||||
"3.2 → 32.0 px"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The spin is not uniform**, and reproducing it as a constant rate would be wrong by up to
|
||||
/// 15° (wow-re §10.3's 108 °/s burst across 225°→270°). The table's own keys, and the
|
||||
/// direction, are what this pins: one clockwise turn per 4833 ms, monotonic, never reversing.
|
||||
#[test]
|
||||
fn the_spin_follows_the_models_own_non_uniform_keys() {
|
||||
for (ms, deg) in PING5_SPIN {
|
||||
let got = spin_radians(ms).to_degrees();
|
||||
// The last key is a full turn, which wraps to 0 — the same rotation.
|
||||
let want = if ms >= PING_SPIN_MS { 0.0 } else { deg };
|
||||
assert!((got - want).abs() < 0.01, "at {ms} ms: {got} vs {want}");
|
||||
}
|
||||
// Monotonic clockwise across the whole revolution (positive rotation = CW on screen, the
|
||||
// sense the player arrow's `-facing` already establishes).
|
||||
let mut prev = -1.0;
|
||||
for i in 0..480 {
|
||||
let t = i as f32 * (PING_SPIN_MS / 480.0);
|
||||
let deg = spin_radians(t).to_degrees();
|
||||
assert!(deg >= prev - 1e-3, "never reverses: {deg} after {prev}");
|
||||
prev = deg;
|
||||
}
|
||||
// The burst the half-turn average hides: 225°→270° takes 417 ms, not the ~667 a uniform
|
||||
// rate would give.
|
||||
let quarter = spin_radians(3208.0).to_degrees();
|
||||
let burst_end = spin_radians(3625.0).to_degrees();
|
||||
assert!((quarter - 225.0).abs() < 0.01 && (burst_end - 270.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
/// The model clock is **not** reset per ping: the reference's Model frame free-runs and only
|
||||
/// ticks while shown, so consecutive pings resume mid-animation. Seating a second ping must
|
||||
/// leave the phase alone.
|
||||
#[test]
|
||||
fn a_second_ping_resumes_the_animation_rather_than_restarting_it() {
|
||||
let mut ping = MinimapPing::default();
|
||||
ping.seat((0.0, 0.0), 0);
|
||||
ping.shown_ms = 1234.0;
|
||||
ping.seat((10.0, 10.0), 0);
|
||||
assert!(
|
||||
(ping.shown_ms - 1234.0).abs() < f32::EPSILON,
|
||||
"the clock is the model's, not the ping's"
|
||||
);
|
||||
ping.seat((5.0, 5.0), 0);
|
||||
ping.fresh = false;
|
||||
seat_click(&c, &mut ping, Some((0.0, 20.0))); // 20 UI units up = 20 px = 28.57 yd north
|
||||
let live = ping.live.as_ref().unwrap();
|
||||
assert!((live.world.0 - 20.0 / c.px_per_yd).abs() < 1e-3 && live.world.1.abs() < 1e-3);
|
||||
assert!(ping.fresh, "a seat owes the world its event");
|
||||
}
|
||||
|
||||
/// A degenerate frame (the widget has not drawn yet) drops the click rather than seating a
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
//! The FPS JOURNAL (`WOW_FPS_JOURNAL=<csv path>`): once a second, append one row of where the
|
||||
//! player is, what the frame cost, and **what is resident** (see [`JOURNAL_HEADER`] for the column
|
||||
//! order, written into every fresh file) — the "where does it dip" instrument for a
|
||||
//! director-driven run. They play normally; the journal turns "it drops in the Dwarven District"
|
||||
//! into coordinates a headless probe can tele straight back to. Negligible cost: one line of IO a
|
||||
//! second, samples reused from the frame meters.
|
||||
//! The FPS JOURNAL — `/console fpsJournal 1` in any build, or `WOW_FPS_JOURNAL=<csv path>` on a
|
||||
//! harness run: once a second, append one row of where the player is, what the frame cost on
|
||||
//! the wall, on the CPU and on the GPU, and **what is resident** (see [`JOURNAL_HEADER`] for the
|
||||
//! column order, written into every fresh file) — the "where does it dip" instrument for a
|
||||
//! director-driven run, and since decision 2008 the instrument a PLAYER on hardware we do not
|
||||
//! own can run for us. They play normally; the journal turns "it drops in the Dwarven District"
|
||||
//! into coordinates a headless probe can tele straight back to, and its GPU columns turn a Steam
|
||||
//! Deck's "GPU 94 % busy" into which pass is eating it. Negligible cost: one line of IO a second,
|
||||
//! samples reused from the frame meters.
|
||||
//!
|
||||
//! **Player-facing, so it lives outside the `dev` seam** (2008, on 1495's precedent): the people
|
||||
//! whose frames we need to read run the player build, which compiles every other instrument out
|
||||
//! (1173). The CVar's knob is [`FpsJournalSetting`]; the file is
|
||||
//! `benilla-config/Diagnostics/fps-journal.csv` ([`crate::local_state::fps_journal_path`]), the
|
||||
//! thing a reporter attaches. `WOW_FPS_JOURNAL` names another path and turns the journal on for
|
||||
//! the run regardless of the CVar — the harness lever it always was. A fresh file opens with one
|
||||
//! `#` line naming the adapter, the backend and whether the device can time passes, so a journal
|
||||
//! from a machine we have never seen says what it was read on.
|
||||
//!
|
||||
//! **The GPU columns read bevy's render diagnostics** (`RenderDiagnosticsPlugin`, registered here
|
||||
//! so it is present in every build): one timestamp pair per render pass, resolved a few frames
|
||||
//! later into `render/<pass>/elapsed_gpu` measurements. Those exist where the device offers
|
||||
//! `TIMESTAMP_QUERY_INSIDE_PASSES` — Vulkan and DX12, so the Linux and Windows builds — and never
|
||||
//! on an Apple GPU, which samples counters only at stage boundaries (the `perf` module header);
|
||||
//! there the columns stay EMPTY rather than zero, so "no reading" cannot be mistaken for "free".
|
||||
//! Our own passes (`static_gx`, the `ffx_glow` chain, `ui_gamma_decode`) open spans of their own,
|
||||
//! because a city's biggest draw must not land in `gpu_other`. The buckets are [`gpu_bucket`]; a
|
||||
//! pass this file does not name is still counted, under `gpu_other`, and `gpu_ms` is the sum of
|
||||
//! every pass — the GPU's busy time inside passes, which is what "GPU-bound" reads against the
|
||||
//! wall `mean_ms` beside it.
|
||||
//!
|
||||
//! The residency columns make it the **leak curve** instrument too (B131): `FPS_PROBE`'s residency
|
||||
//! meter samples once per run, which can only compare two runs at one point each — it cannot tell
|
||||
@@ -13,40 +37,64 @@
|
||||
//! the position — so a same-map traverse (no `MapChange`, so no map-scoped eviction) shows its
|
||||
//! accumulation as a slope instead of a before/after pair.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
use bevy::diagnostic::DiagnosticsStore;
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::diagnostic::RenderDiagnosticsPlugin;
|
||||
use bevy::render::renderer::{RenderAdapterInfo, RenderDevice};
|
||||
use bevy::time::Real;
|
||||
|
||||
use super::clock::{main_thread_cpu_secs, process_cpu_secs};
|
||||
|
||||
pub(crate) struct FpsJournalPlugin;
|
||||
|
||||
/// The journal's column order, written as the first line of a fresh file. Appended-to files keep
|
||||
/// whatever header they were created with — the columns only ever grow at the end.
|
||||
/// The `fpsJournal` CVar's knob (2008): on, the journal appends to the player's
|
||||
/// `Diagnostics/fps-journal.csv` from the next second on; off, it stops mid-run and the file
|
||||
/// keeps what it has. `/console fpsJournal 1` is the whole recipe a reporter needs.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct FpsJournalSetting(pub(crate) bool);
|
||||
|
||||
/// The journal's column order, written as the first line of a fresh file (after the `#` adapter
|
||||
/// line). Appended-to files keep whatever header they were created with — the columns only ever
|
||||
/// grow at the end, so an older journal still parses against its own header.
|
||||
const JOURNAL_HEADER: &str = "t,x,y,z,mean_ms,p95_ms,streamed,entities,cpu_ms,mats,meshes,images,\
|
||||
m2,uv,tint,pmat,emat,skin,cmat,tex,cgeo,evicted,fx,fy,fz,main_ms\n";
|
||||
m2,uv,tint,pmat,emat,skin,cmat,tex,cgeo,evicted,fx,fy,fz,main_ms,\
|
||||
gpu_ms,gpu_opaque,gpu_static,gpu_transp,gpu_glow,gpu_post,gpu_ui,\
|
||||
gpu_other\n";
|
||||
|
||||
impl Plugin for FpsJournalPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let path = std::env::var("WOW_FPS_JOURNAL").unwrap_or_default();
|
||||
// The header goes in exactly once, at creation: the rows are appended for the life of the
|
||||
// run (and across runs, deliberately — a journal accumulates legs).
|
||||
if !path.is_empty() && !std::path::Path::new(&path).exists() {
|
||||
let _ = std::fs::write(&path, JOURNAL_HEADER);
|
||||
}
|
||||
app.insert_resource(FpsJournal {
|
||||
path,
|
||||
window: Vec::new(),
|
||||
last_flush: 0.0,
|
||||
cpu_at_flush: process_cpu_secs(),
|
||||
main_at_flush: None,
|
||||
})
|
||||
.add_systems(Update, journal_fps);
|
||||
// bevy's per-pass render diagnostics — the source of the GPU columns, and (under the
|
||||
// `tracy` feature) the hook Tracy's GPU zones ride. Present in every build: its per-frame
|
||||
// cost is one query resolve and one buffer map on the render thread, and a player's
|
||||
// journal is exactly the build that has to carry it (2008).
|
||||
app.add_plugins(RenderDiagnosticsPlugin)
|
||||
.init_resource::<FpsJournalSetting>()
|
||||
.insert_resource(FpsJournal {
|
||||
env_path: std::env::var("WOW_FPS_JOURNAL")
|
||||
.ok()
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(PathBuf::from),
|
||||
path: None,
|
||||
window: Vec::new(),
|
||||
last_flush: 0.0,
|
||||
cpu_at_flush: None,
|
||||
main_at_flush: None,
|
||||
gpu: GpuAccum::default(),
|
||||
})
|
||||
.add_systems(Update, journal_fps);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
struct FpsJournal {
|
||||
path: String,
|
||||
/// `WOW_FPS_JOURNAL`: a fixed path, on for the whole run whatever the CVar says.
|
||||
env_path: Option<PathBuf>,
|
||||
/// Where rows go while the journal is on; `None` = off, or nowhere to write (a hermetic
|
||||
/// run has no state folder, and the CVar has no other place to point).
|
||||
path: Option<PathBuf>,
|
||||
window: Vec<f32>,
|
||||
last_flush: f32,
|
||||
/// Process CPU seconds at the previous flush — the row's `cpu_ms` is this second's CPU cost
|
||||
@@ -57,6 +105,142 @@ struct FpsJournal {
|
||||
/// thread's work, `main_ms` the serialized part of it. A leg where they diverge is a leg whose
|
||||
/// cost moved off (or onto) the critical path — which the all-threads column alone cannot say.
|
||||
main_at_flush: Option<f64>,
|
||||
/// This second's GPU spans, folded per frame from the diagnostics store.
|
||||
gpu: GpuAccum,
|
||||
}
|
||||
|
||||
/// The GPU columns after `gpu_ms`, in header order.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum GpuBucket {
|
||||
/// bevy's `main_opaque_pass_3d` — terrain, the model lane's opaque parts, the sky shells.
|
||||
Opaque = 0,
|
||||
/// Our retained static pass (`static_gx`): the WMOs and the doodads, the city's own draw.
|
||||
Static,
|
||||
/// bevy's transparent (and transmissive) 3D passes — water, glow cards, particles.
|
||||
Transparent,
|
||||
/// The `ffx_glow` chain: the quarter-res downsample, the two Gauss taps, the combine.
|
||||
Glow,
|
||||
/// The full-screen tail on every camera: tonemapping, upscaling, the MSAA writeback.
|
||||
Post,
|
||||
/// The 2D camera's passes, bevy UI, and our `ui_gamma_decode`.
|
||||
Ui,
|
||||
/// Every span this file does not name — counted, never dropped.
|
||||
Other,
|
||||
}
|
||||
|
||||
const GPU_BUCKETS: usize = 7;
|
||||
|
||||
/// Which column a diagnostics path lands in. `None` = not a top-level GPU span: a CPU span, a
|
||||
/// non-render diagnostic, or a span nested under another (its parent already carries it).
|
||||
fn gpu_bucket(path: &str) -> Option<GpuBucket> {
|
||||
let pass = path.strip_prefix("render/")?.strip_suffix("/elapsed_gpu")?;
|
||||
if pass.contains('/') {
|
||||
return None;
|
||||
}
|
||||
Some(match pass {
|
||||
"main_opaque_pass_3d" => GpuBucket::Opaque,
|
||||
"static_gx" => GpuBucket::Static,
|
||||
"main_transparent_pass_3d" | "main_transmissive_pass_3d" => GpuBucket::Transparent,
|
||||
p if p.starts_with("ffx_glow") => GpuBucket::Glow,
|
||||
"tonemapping" | "upscaling" | "msaa_writeback" | "postprocessing" => GpuBucket::Post,
|
||||
"main_opaque_pass_2d" | "main_transparent_pass_2d" | "ui" | "ui_gamma_decode" => {
|
||||
GpuBucket::Ui
|
||||
}
|
||||
_ => GpuBucket::Other,
|
||||
})
|
||||
}
|
||||
|
||||
/// One second's GPU spans: summed per bucket, divided at the flush by the number of frames
|
||||
/// whose readback landed — not by the wall window's frame count, because bevy's diagnostics
|
||||
/// mutex hands the store at most one frame per sync and drops the rest when readbacks bunch up,
|
||||
/// so the honest divisor is the frames actually read.
|
||||
#[derive(Default)]
|
||||
struct GpuAccum {
|
||||
sum: [f64; GPU_BUCKETS],
|
||||
frames: u32,
|
||||
/// The newest measurement time consumed, so each fold reads only what arrived since. All
|
||||
/// measurements of one sync share one `Instant`, which is what makes "a frame" countable.
|
||||
seen: Option<Instant>,
|
||||
}
|
||||
|
||||
impl GpuAccum {
|
||||
/// Fold in every GPU measurement newer than the last fold.
|
||||
fn fold(&mut self, store: &DiagnosticsStore) {
|
||||
let mut newest = self.seen;
|
||||
let mut frame_times: Vec<Instant> = Vec::new();
|
||||
for diagnostic in store.iter() {
|
||||
let Some(bucket) = gpu_bucket(diagnostic.path().as_str()) else {
|
||||
continue;
|
||||
};
|
||||
for m in diagnostic
|
||||
.measurements()
|
||||
.filter(|m| self.seen.is_none_or(|s| m.time > s))
|
||||
{
|
||||
self.sum[bucket as usize] += m.value;
|
||||
if !frame_times.contains(&m.time) {
|
||||
frame_times.push(m.time);
|
||||
}
|
||||
if newest.is_none_or(|n| m.time > n) {
|
||||
newest = Some(m.time);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.frames += frame_times.len() as u32;
|
||||
self.seen = newest;
|
||||
}
|
||||
|
||||
/// The row's GPU cells — `,gpu_ms,<one per bucket>` — and the reset. All empty when no
|
||||
/// frame was read this second: the platform has no in-pass timestamps, and an empty cell
|
||||
/// says so where a zero would lie.
|
||||
fn columns(&mut self) -> String {
|
||||
let mut s = String::new();
|
||||
if self.frames == 0 {
|
||||
s.push_str(&",".repeat(GPU_BUCKETS + 1));
|
||||
} else {
|
||||
let n = f64::from(self.frames);
|
||||
let total: f64 = self.sum.iter().sum();
|
||||
s.push_str(&format!(",{:.2}", total / n));
|
||||
for bucket in self.sum {
|
||||
s.push_str(&format!(",{:.2}", bucket / n));
|
||||
}
|
||||
}
|
||||
self.sum = [0.0; GPU_BUCKETS];
|
||||
self.frames = 0;
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// The `#` line a fresh file opens with: what the rows were read on, and whether the GPU
|
||||
/// columns can ever fill (the device's in-pass timestamp feature, the one bevy's pass spans
|
||||
/// need). Everything a reader needs to know before comparing two journals.
|
||||
fn preamble(adapter: Option<&RenderAdapterInfo>, device: Option<&RenderDevice>) -> String {
|
||||
let (gpu, backend, driver) = adapter.map_or_else(
|
||||
|| ("?".to_string(), "?".to_string(), "?".to_string()),
|
||||
|a| {
|
||||
let driver = format!("{} {}", a.driver, a.driver_info).trim().to_string();
|
||||
(
|
||||
a.name.clone(),
|
||||
format!("{:?}", a.backend),
|
||||
// Metal reports no driver string at all; a `?` reads better than a blank.
|
||||
if driver.is_empty() {
|
||||
"?".to_string()
|
||||
} else {
|
||||
driver
|
||||
},
|
||||
)
|
||||
},
|
||||
);
|
||||
let spans = device.map_or("?", |d| {
|
||||
let f = d.features();
|
||||
if f.contains(
|
||||
wgpu::Features::TIMESTAMP_QUERY | wgpu::Features::TIMESTAMP_QUERY_INSIDE_PASSES,
|
||||
) {
|
||||
"yes"
|
||||
} else {
|
||||
"no"
|
||||
}
|
||||
});
|
||||
format!("# benilla fps journal | gpu {gpu} | backend {backend} | driver {driver} | gpu_spans {spans}\n")
|
||||
}
|
||||
|
||||
/// The journal's residency columns, grouped because `journal_fps` is near Bevy's system-param
|
||||
@@ -85,20 +269,81 @@ struct JournalResidency<'w> {
|
||||
scope: Res<'w, benilla_world::art_scope::ArtScopeState>,
|
||||
}
|
||||
|
||||
/// The GPU side: the diagnostics store the render spans sync into, and the two facts the
|
||||
/// preamble names. All optional — a headless app without a renderer has none of them, and the
|
||||
/// journal then writes its CPU columns and leaves the GPU cells empty.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
struct JournalGpu<'w> {
|
||||
store: Option<Res<'w, DiagnosticsStore>>,
|
||||
adapter: Option<Res<'w, RenderAdapterInfo>>,
|
||||
device: Option<Res<'w, RenderDevice>>,
|
||||
}
|
||||
|
||||
/// `NonSendMarker` pins this to the main thread, which the `main_ms` column requires:
|
||||
/// [`main_thread_cpu_secs`] reports *the calling thread*, so on a worker it would silently log
|
||||
/// whichever pool thread ran the flush.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn journal_fps(
|
||||
_pin_to_main_thread: bevy::ecs::system::NonSendMarker,
|
||||
mut journal: ResMut<FpsJournal>,
|
||||
setting: Res<FpsJournalSetting>,
|
||||
time: Res<Time<Real>>,
|
||||
player: Option<Res<crate::player::Player>>,
|
||||
streamed: Query<(), With<crate::net::NetEntity>>,
|
||||
entities: Query<()>,
|
||||
residency: JournalResidency,
|
||||
gpu: JournalGpu,
|
||||
) {
|
||||
let now = time.elapsed_secs();
|
||||
// The switch, read every frame: the env lever or the CVar. Turning on opens (or creates) the
|
||||
// file and restarts every per-second baseline; turning off drops the half-second in hand.
|
||||
let wanted = journal.env_path.is_some() || setting.0;
|
||||
match (wanted, journal.path.is_some()) {
|
||||
(false, false) => return,
|
||||
(false, true) => {
|
||||
info!("fps journal: off");
|
||||
journal.path = None;
|
||||
journal.window.clear();
|
||||
journal.gpu = GpuAccum::default();
|
||||
return;
|
||||
}
|
||||
(true, false) => {
|
||||
let Some(path) = journal
|
||||
.env_path
|
||||
.clone()
|
||||
.or_else(crate::local_state::fps_journal_path)
|
||||
else {
|
||||
return; // hermetic: no state folder, and nothing else to write into
|
||||
};
|
||||
// The header goes in exactly once, at creation: the rows are appended for the life
|
||||
// of the run (and across runs, deliberately — a journal accumulates legs).
|
||||
if !path.exists() {
|
||||
let head = format!(
|
||||
"{}{JOURNAL_HEADER}",
|
||||
preamble(gpu.adapter.as_deref(), gpu.device.as_deref())
|
||||
);
|
||||
if let Err(e) = crate::local_state::write_atomic(&path, &head) {
|
||||
warn!("fps journal: cannot create {}: {e}", path.display());
|
||||
return;
|
||||
}
|
||||
}
|
||||
info!("fps journal: writing {}", path.display());
|
||||
journal.last_flush = now;
|
||||
journal.cpu_at_flush = process_cpu_secs();
|
||||
journal.main_at_flush = main_thread_cpu_secs();
|
||||
// Only spans that land from here on count: the store may hold a history.
|
||||
journal.gpu = GpuAccum {
|
||||
seen: Some(Instant::now()),
|
||||
..GpuAccum::default()
|
||||
};
|
||||
journal.path = Some(path);
|
||||
}
|
||||
(true, true) => {}
|
||||
}
|
||||
journal.window.push(time.delta_secs() * 1000.0);
|
||||
if let Some(store) = gpu.store.as_deref() {
|
||||
journal.gpu.fold(store);
|
||||
}
|
||||
if now - journal.last_flush < 1.0 {
|
||||
return;
|
||||
}
|
||||
@@ -151,17 +396,154 @@ fn journal_fps(
|
||||
let main_now = main_thread_cpu_secs();
|
||||
match (journal.main_at_flush, main_now) {
|
||||
(Some(t0), Some(t1)) => {
|
||||
line.push_str(&format!(",{:.2}\n", (t1 - t0) * 1000.0 / v.len() as f64))
|
||||
line.push_str(&format!(",{:.2}", (t1 - t0) * 1000.0 / v.len() as f64))
|
||||
}
|
||||
_ => line.push_str(",\n"),
|
||||
_ => line.push(','),
|
||||
}
|
||||
journal.main_at_flush = main_now;
|
||||
// The GPU cells (2008), last of all.
|
||||
let gpu_cells = journal.gpu.columns();
|
||||
line.push_str(&gpu_cells);
|
||||
line.push('\n');
|
||||
use std::io::Write;
|
||||
let Some(path) = journal.path.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&journal.path)
|
||||
.open(path)
|
||||
{
|
||||
let _ = f.write_all(line.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy::diagnostic::{Diagnostic, DiagnosticMeasurement, DiagnosticPath};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn buckets_name_every_pass_we_draw_and_skip_what_is_not_a_gpu_span() {
|
||||
use GpuBucket::*;
|
||||
let b = |p: &str| gpu_bucket(p);
|
||||
assert_eq!(b("render/main_opaque_pass_3d/elapsed_gpu"), Some(Opaque));
|
||||
assert_eq!(b("render/static_gx/elapsed_gpu"), Some(Static));
|
||||
assert_eq!(
|
||||
b("render/main_transparent_pass_3d/elapsed_gpu"),
|
||||
Some(Transparent)
|
||||
);
|
||||
assert_eq!(b("render/ffx_glow_gauss_h/elapsed_gpu"), Some(Glow));
|
||||
assert_eq!(b("render/ffx_glow_combine/elapsed_gpu"), Some(Glow));
|
||||
assert_eq!(b("render/tonemapping/elapsed_gpu"), Some(Post));
|
||||
assert_eq!(b("render/upscaling/elapsed_gpu"), Some(Post));
|
||||
assert_eq!(b("render/ui/elapsed_gpu"), Some(Ui));
|
||||
assert_eq!(b("render/ui_gamma_decode/elapsed_gpu"), Some(Ui));
|
||||
assert_eq!(b("render/main_transparent_pass_2d/elapsed_gpu"), Some(Ui));
|
||||
// Unnamed passes are counted, not dropped.
|
||||
assert_eq!(
|
||||
b("render/early_mesh_preprocessing/elapsed_gpu"),
|
||||
Some(Other)
|
||||
);
|
||||
// CPU spans, non-render diagnostics and nested spans are not GPU cells.
|
||||
assert_eq!(b("render/main_opaque_pass_3d/elapsed_cpu"), None);
|
||||
assert_eq!(b("fps"), None);
|
||||
assert_eq!(b("render/outer/inner/elapsed_gpu"), None);
|
||||
}
|
||||
|
||||
fn push(store: &mut DiagnosticsStore, path: &str, time: Instant, value: f64) {
|
||||
let p = DiagnosticPath::new(path.to_string());
|
||||
if store.get(&p).is_none() {
|
||||
store.add(Diagnostic::new(p.clone()));
|
||||
}
|
||||
store
|
||||
.get_mut(&p)
|
||||
.unwrap()
|
||||
.add_measurement(DiagnosticMeasurement { time, value });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flush_averages_per_frame_read_and_a_fold_takes_only_what_arrived() {
|
||||
let mut store = DiagnosticsStore::default();
|
||||
let t0 = Instant::now();
|
||||
let t1 = t0 + Duration::from_millis(10);
|
||||
let t2 = t0 + Duration::from_millis(20);
|
||||
// Frame 1: opaque 2, static 3, and the full-screen tail on two cameras (0.5 + 0.3).
|
||||
push(
|
||||
&mut store,
|
||||
"render/main_opaque_pass_3d/elapsed_gpu",
|
||||
t0,
|
||||
2.0,
|
||||
);
|
||||
push(&mut store, "render/static_gx/elapsed_gpu", t0, 3.0);
|
||||
push(&mut store, "render/tonemapping/elapsed_gpu", t0, 0.5);
|
||||
push(&mut store, "render/tonemapping/elapsed_gpu", t0, 0.3);
|
||||
// A CPU span beside them, never counted.
|
||||
push(
|
||||
&mut store,
|
||||
"render/main_opaque_pass_3d/elapsed_cpu",
|
||||
t0,
|
||||
99.0,
|
||||
);
|
||||
let mut acc = GpuAccum::default();
|
||||
acc.fold(&store);
|
||||
assert_eq!(acc.frames, 1);
|
||||
// Frame 2 lands; the fold reads only it (frame 1's values would double otherwise).
|
||||
push(
|
||||
&mut store,
|
||||
"render/main_opaque_pass_3d/elapsed_gpu",
|
||||
t1,
|
||||
4.0,
|
||||
);
|
||||
acc.fold(&store);
|
||||
acc.fold(&store); // nothing new: a no-op, not a double count
|
||||
assert_eq!(acc.frames, 2);
|
||||
let cols = acc.columns();
|
||||
// gpu_ms = (2 + 3 + 0.8 + 4) / 2; opaque (2 + 4) / 2; static 3 / 2; post 0.8 / 2.
|
||||
assert_eq!(cols, ",4.90,3.00,1.50,0.00,0.00,0.40,0.00,0.00");
|
||||
// The reset: a second with nothing read writes empty cells, one per column.
|
||||
assert_eq!(acc.columns(), ",,,,,,,,");
|
||||
// And a later frame is still read after the reset.
|
||||
push(
|
||||
&mut store,
|
||||
"render/main_opaque_pass_3d/elapsed_gpu",
|
||||
t2,
|
||||
1.0,
|
||||
);
|
||||
acc.fold(&store);
|
||||
assert_eq!(acc.columns(), ",1.00,1.00,0.00,0.00,0.00,0.00,0.00,0.00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_header_ends_with_the_gpu_cells_in_bucket_order() {
|
||||
let cols: Vec<&str> = JOURNAL_HEADER.trim_end().split(',').collect();
|
||||
let gpu: Vec<&str> = cols[cols.len() - (GPU_BUCKETS + 1)..].to_vec();
|
||||
assert_eq!(
|
||||
gpu,
|
||||
[
|
||||
"gpu_ms",
|
||||
"gpu_opaque",
|
||||
"gpu_static",
|
||||
"gpu_transp",
|
||||
"gpu_glow",
|
||||
"gpu_post",
|
||||
"gpu_ui",
|
||||
"gpu_other"
|
||||
]
|
||||
);
|
||||
// An empty second writes exactly one cell per GPU column.
|
||||
assert_eq!(
|
||||
GpuAccum::default().columns().matches(',').count(),
|
||||
gpu.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preamble_names_the_adapter_or_says_it_cannot() {
|
||||
assert_eq!(
|
||||
preamble(None, None),
|
||||
"# benilla fps journal | gpu ? | backend ? | driver ? | gpu_spans ?\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! `perf` — performance instrumentation + the standing dev HUD ([`PerfPlugin`]).
|
||||
//! `perf` — performance instrumentation + the standing dev HUD ([`PerfPlugin`]), and the one
|
||||
//! instrument that ships to players: the FPS journal ([`FpsJournalPlugin`], decision 2008).
|
||||
//!
|
||||
//! Owns the **frame-cost measurement layer** that every future subsystem is measured against (the
|
||||
//! standard), and draws the always-on cost pill (top-center) — the whole HUD since 1454; anything
|
||||
@@ -9,10 +10,17 @@
|
||||
//! - [`clock`] — the three CPU clocks (process, main thread, machine) every number is denominated in,
|
||||
//! - [`stats`] — the rolling windows the pill reads,
|
||||
//! - [`hud`] — the standing cost pill,
|
||||
//! - [`trace`] / [`journal`] — the two CSV instruments (`WOW_STREAM_TRACE`, `WOW_FPS_JOURNAL`),
|
||||
//! - [`trace`] / [`journal`] — the two CSV instruments (`WOW_STREAM_TRACE`, `WOW_FPS_JOURNAL`);
|
||||
//! the journal also answers the `fpsJournal` CVar, in every build (2008),
|
||||
//! - [`stall`] — the stuck-main-thread self-sampler (macOS),
|
||||
//! - [`census`] — the env-gated premise counters.
|
||||
//!
|
||||
//! **The dev seam runs through this module.** 1173 named `perf` dev wholesale; 2008 carved the
|
||||
//! journal, and the clocks it reads, out to the player side: `clock` and `journal` compile in the
|
||||
//! player build, everything else — the HUD, the meters, the census counters, [`PerfPlugin`]
|
||||
//! itself — is `#[cfg(feature = "dev")]`. The rule is unchanged (`dev.rs`): dev may see anything;
|
||||
//! nothing may depend on dev. The journal depends on nothing on the dev side.
|
||||
//!
|
||||
//! **The law the whole surface obeys (0717): while synced, wall frame time measures the display's
|
||||
//! present grant, not our cost.** Only the CPU series measure work. That is why the pill's headline
|
||||
//! is `cpu ms` and not fps — on a 120 Hz-adaptive panel, cost can double with the granted interval
|
||||
@@ -27,53 +35,71 @@
|
||||
//!
|
||||
//! ⚠️ **macOS/Metal — GPU timing.** We have `Features::TIMESTAMP_QUERY` on this machine, but
|
||||
//! **`TIMESTAMP_QUERY_INSIDE_PASSES` is `false`**: Apple GPUs sample counters only at stage
|
||||
//! boundaries (`MTLCounterSamplingPoint::AtStageBoundary`). Bevy's `RenderDiagnosticsPlugin` writes
|
||||
//! its timestamps *inside* passes, so on Apple Silicon every span falls through to the CPU-only
|
||||
//! branch and the store carries **zero** `elapsed_gpu` paths — verified on a live run: 14 diagnostic
|
||||
//! paths, all `elapsed_cpu`. (bevy_render's own "Vulkan and DX12 only" comment is stale as a
|
||||
//! statement about the *platform* — an Intel Mac would report `INSIDE_PASSES` and emit GPU spans.)
|
||||
//! Pass-boundary `timestamp_writes` **does** work here and is the route to real GPU-ms — measured
|
||||
//! 0.015/0.019/0.049 ms across 256²/1920×1080/4096² clears, 12/12 reproducible. It needs two
|
||||
//! sentinel render-graph nodes at ~0.03 ms/frame, and the query set must be resolved in a
|
||||
//! *different* command buffer than the timed pass or Metal returns zeros. Until that is built, a
|
||||
//! GPU-bound frame is identified rather than timed: it is the one that runs long while the CPU
|
||||
//! meters stay flat (read them side by side in the journal or a probe line).
|
||||
//! boundaries (`MTLCounterSamplingPoint::AtStageBoundary`). Bevy's `RenderDiagnosticsPlugin`
|
||||
//! (registered by the journal, so present in every build) writes its timestamps *inside* passes,
|
||||
//! so on Apple Silicon every span falls through to the CPU-only branch and the store carries
|
||||
//! **zero** `elapsed_gpu` paths — verified on a live run: 14 diagnostic paths, all `elapsed_cpu`.
|
||||
//! (bevy_render's own "Vulkan and DX12 only" comment is stale as a statement about the
|
||||
//! *platform* — an Intel Mac would report `INSIDE_PASSES` and emit GPU spans.) Where the feature
|
||||
//! IS present — Vulkan and DX12, so the Linux and Windows builds — those spans are the journal's
|
||||
//! `gpu_*` columns (2008), which is how a Steam Deck's frame gets read without a Deck here.
|
||||
//! Pass-boundary `timestamp_writes` **does** work on Apple and is the route to whole-frame
|
||||
//! GPU-ms — built as [`gpu`] (`WOW_GPU_MS=1`): two sentinel render-graph nodes at ~0.03 ms/frame,
|
||||
//! the query set resolved in a *different* command buffer than the timed pass or Metal returns
|
||||
//! zeros (1389). Per-pass on Apple remains unbuilt: a GPU-bound frame here is identified rather
|
||||
//! than timed — the one that runs long while the CPU meters stay flat, read side by side in the
|
||||
//! journal or a probe line.
|
||||
|
||||
#[cfg(feature = "dev")]
|
||||
mod blend_check;
|
||||
#[cfg(feature = "dev")]
|
||||
mod census;
|
||||
mod clock;
|
||||
#[cfg(feature = "dev")]
|
||||
mod gpu;
|
||||
#[cfg(feature = "dev")]
|
||||
mod hud;
|
||||
mod journal;
|
||||
#[cfg(feature = "dev")]
|
||||
mod main_split;
|
||||
#[cfg(feature = "dev")]
|
||||
mod phases;
|
||||
#[cfg(target_os = "macos")]
|
||||
#[cfg(all(feature = "dev", target_os = "macos"))]
|
||||
mod stall;
|
||||
#[cfg(feature = "dev")]
|
||||
mod stats;
|
||||
#[cfg(feature = "dev")]
|
||||
mod trace;
|
||||
|
||||
#[cfg(feature = "dev")]
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::diagnostic::RenderDiagnosticsPlugin;
|
||||
|
||||
#[cfg(feature = "dev")]
|
||||
pub(crate) use blend_check::BlendMismatchShared;
|
||||
#[cfg(feature = "dev")]
|
||||
pub(crate) use clock::{process_cpu_secs, process_faults, system_cpu_ticks, thread_cpu_table};
|
||||
#[cfg(feature = "dev")]
|
||||
pub(crate) use gpu::{GpuMsShared, WgpuCensusShared};
|
||||
#[cfg(feature = "dev")]
|
||||
pub(crate) use hud::PerfHud;
|
||||
pub(crate) use journal::FpsJournalPlugin;
|
||||
pub(crate) use journal::{FpsJournalPlugin, FpsJournalSetting};
|
||||
#[cfg(feature = "dev")]
|
||||
pub(crate) use main_split::MainThreadSplit;
|
||||
|
||||
/// The frame budget: a 60 fps floor. No frame should exceed this.
|
||||
pub const FRAME_BUDGET_MS: f32 = 1000.0 / 60.0;
|
||||
|
||||
/// The dev-side instruments, as one plugin — everything in this module but the journal (2008).
|
||||
#[cfg(feature = "dev")]
|
||||
pub struct PerfPlugin;
|
||||
|
||||
#[cfg(feature = "dev")]
|
||||
impl Plugin for PerfPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// Render-pass timing (CPU-only on Apple Silicon — see the module header). Harmless if
|
||||
// the backend can't record it; also the hook Tracy GPU uses on Vulkan/DX12.
|
||||
// (bevy's render-pass diagnostics — the CPU spans here, the GPU spans on Vulkan/DX12, and
|
||||
// the hook Tracy's GPU zones ride — are registered by `FpsJournalPlugin`, in every build.)
|
||||
main_split::plugin(app);
|
||||
app.add_plugins(RenderDiagnosticsPlugin)
|
||||
.init_resource::<stats::FrameStats>()
|
||||
app.init_resource::<stats::FrameStats>()
|
||||
.init_resource::<PerfHud>()
|
||||
.insert_resource(trace::StreamTrace {
|
||||
path: std::env::var("WOW_STREAM_TRACE").unwrap_or_default(),
|
||||
|
||||
@@ -765,6 +765,7 @@ fn warm_quad(colors: bool, skinned: bool) -> RenderSubmesh {
|
||||
},
|
||||
interior: false,
|
||||
emissive: false,
|
||||
icon_slot: false,
|
||||
sidn: None,
|
||||
window: false,
|
||||
additive: false,
|
||||
|
||||
@@ -120,7 +120,7 @@ pub(super) fn reap_dead_variants(
|
||||
/// **`None` means the source material is not resident yet** — there is no twin to hand back, and
|
||||
/// the world material is NOT an acceptable substitute in a booth (wrong light buffer; see
|
||||
/// [`BoothRig::variant`]). Callers wait and retry rather than baking something wrong.
|
||||
pub(super) fn material_variant(
|
||||
pub(crate) fn material_variant(
|
||||
variants: &mut HashMap<AssetId<WowModelMaterial>, Handle<WowModelMaterial>>,
|
||||
buffer: &bevy::render::render_resource::Buffer,
|
||||
world: &Handle<WowModelMaterial>,
|
||||
|
||||
@@ -97,7 +97,8 @@ pub(crate) use glue_booth::{
|
||||
PreviewBillboard, PreviewEffects, PreviewPart, PreviewRider, SelectLook, GLUE_SLOT,
|
||||
};
|
||||
mod light;
|
||||
use light::{material_variant, model_pane_light, studio_light, BoothLight};
|
||||
pub(crate) use light::material_variant;
|
||||
use light::{model_pane_light, studio_light, BoothLight};
|
||||
mod test_bake;
|
||||
|
||||
/// The portrait slots we bake, each with its own render layer/camera: the player + target unit
|
||||
@@ -226,6 +227,9 @@ pub(crate) const WARM_BOOTH_LAYER: usize = DRESSUP_LAYER + 1;
|
||||
/// "the next layer past the paper doll's" in their own file landed on the same number, and the
|
||||
/// clash was silent in both rendering and the emitter→camera match.
|
||||
pub(crate) const MINIMAP_COMPOSITE_LAYER: usize = WARM_BOOTH_LAYER + 1;
|
||||
/// The UI model tiles' layer (`crate::ui_models`, decision 2008): every `<Model>` widget's M2
|
||||
/// renders into one atlas through one camera on this layer.
|
||||
pub(crate) const UI_MODELS_LAYER: usize = MINIMAP_COMPOSITE_LAYER + 1;
|
||||
|
||||
// The ladder must stay collision-free: a booth camera's layer is its identity for both rendering
|
||||
// and the emitter→camera match, and the failure above was silent in both.
|
||||
@@ -242,7 +246,8 @@ const _: () = assert!(
|
||||
&& INSPECT_LAYER != GLUE_LAYER
|
||||
&& DRESSUP_LAYER > GLUE_LAYER
|
||||
&& WARM_BOOTH_LAYER > DRESSUP_LAYER
|
||||
&& MINIMAP_COMPOSITE_LAYER > WARM_BOOTH_LAYER,
|
||||
&& MINIMAP_COMPOSITE_LAYER > WARM_BOOTH_LAYER
|
||||
&& UI_MODELS_LAYER > MINIMAP_COMPOSITE_LAYER,
|
||||
"booth render layers must be distinct — see GLUE_LAYER"
|
||||
);
|
||||
const _: () = assert!(
|
||||
@@ -1035,6 +1040,9 @@ pub(crate) struct BoothPanes(pub(crate) HashMap<String, f32>);
|
||||
pub(crate) struct BoothBridge<'w> {
|
||||
pub(crate) images: Res<'w, PortraitImages>,
|
||||
pub(crate) panes: ResMut<'w, BoothPanes>,
|
||||
/// The file panes' half of the same seam (decision 2008): the `ModelPane` arm publishes a
|
||||
/// tile request per pane and samples the tile's atlas cell back.
|
||||
pub(crate) tiles: ResMut<'w, crate::ui_models::UiModelTiles>,
|
||||
}
|
||||
|
||||
/// The group-facing inputs [`sync_portraits`] needs, in one param: who is in the party
|
||||
@@ -1261,10 +1269,15 @@ impl Plugin for PortraitPlugin {
|
||||
/// A fresh transparent render-target image of `size²`, usable as a camera target and sampled by the
|
||||
/// UI. Portrait slots pass [`PORTRAIT_SIZE`]; the paper doll passes [`PAPERDOLL_SIZE`].
|
||||
fn new_target_image(size: u32) -> Image {
|
||||
new_target_image_sized(size, size)
|
||||
}
|
||||
|
||||
/// [`new_target_image`] at any size — the UI model tiles' atlas (`crate::ui_models`).
|
||||
pub(crate) fn new_target_image_sized(width: u32, height: u32) -> Image {
|
||||
let mut image = Image::new_fill(
|
||||
Extent3d {
|
||||
width: size,
|
||||
height: size,
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
TextureDimension::D2,
|
||||
@@ -1300,7 +1313,7 @@ fn new_target_image(size: u32) -> Image {
|
||||
/// booth — because the warm pass compiles the samples=1 twin of every model pipeline against
|
||||
/// exactly this shape behind the loading cover (decisions 0938/0958): a booth camera whose shape
|
||||
/// drifts from the warm booth's is a live pipeline stall on its first bake.
|
||||
fn booth_view_shape() -> impl Bundle {
|
||||
pub(crate) fn booth_view_shape() -> impl Bundle {
|
||||
(
|
||||
Camera3d::default(),
|
||||
bevy::render::view::Hdr,
|
||||
|
||||
@@ -53,10 +53,10 @@ mod weapon_icon;
|
||||
/// The cooldown-event cut: [`state::feed_action_state`] fires the store-change flush trio
|
||||
/// (`ACTIONBAR_UPDATE_COOLDOWN`/`SPELL_UPDATE_COOLDOWN`/`BAG_UPDATE_COOLDOWN`) **synchronously**
|
||||
/// (`UiScript::fire_event` walks the handlers inline), so every feed that pushes cooldown
|
||||
/// triples the handlers re-read (the container feed's slot cooldowns, the spellbook feed's) must
|
||||
/// run `.before(CooldownEvents)` — or a handler reads last frame's triples and the pie stays
|
||||
/// missing until the next store change. The action states themselves are safe by construction
|
||||
/// (pushed by the same system, before it fires).
|
||||
/// triples the handlers re-read (the container feed's slot cooldowns, the spellbook feed's, the
|
||||
/// stance feed's — decision 2009) must run `.before(CooldownEvents)` — or a handler reads last
|
||||
/// frame's triples and the pie stays missing until the next store change. The action states
|
||||
/// themselves are safe by construction (pushed by the same system, before it fires).
|
||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct CooldownEvents;
|
||||
|
||||
|
||||
@@ -138,6 +138,15 @@ impl PlayerAuraCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// The aura-event cut: [`feed_auras`] fires `PLAYER_AURAS_CHANGED` / `UNIT_AURA("player")`
|
||||
/// **synchronously** (`UiScript::fire_event` walks the handlers inline), so a feed whose pushed
|
||||
/// state a handler re-reads on them runs `.before(AuraEvents)` — the stance feed, whose
|
||||
/// `isActive` is the form aura's own slot and is pushed silently on that edge (decision 2009) —
|
||||
/// or the handler reads last frame's state and the repaint waits for the next unrelated event.
|
||||
/// The twin of [`crate::ui_action::CooldownEvents`].
|
||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct AuraEvents;
|
||||
|
||||
/// Adds the aura feed. The `UnitAura` bindings live in `benilla-ui`; this supplies their data and
|
||||
/// fires `UNIT_AURA`, and drains `CancelUnitBuff` back to the wire.
|
||||
pub(crate) struct UiAuraPlugin;
|
||||
@@ -149,7 +158,13 @@ impl Plugin for UiAuraPlugin {
|
||||
.init_resource::<AuraFeedMemory>()
|
||||
// Feed before the VM dispatch (like the unit feed), so a frame's OnEvent sees the fresh
|
||||
// list; drain the cancels after, once the VM has queued them.
|
||||
.add_systems(Update, feed_auras.in_set(UnitFeed).before(UiInput))
|
||||
.add_systems(
|
||||
Update,
|
||||
feed_auras
|
||||
.in_set(UnitFeed)
|
||||
.in_set(AuraEvents)
|
||||
.before(UiInput),
|
||||
)
|
||||
.add_systems(Update, drain_aura_cancels.after(UiInput))
|
||||
// The ONLY teardown of the aura state, and it hangs off the session edge — never off
|
||||
// the avatar entity's existence, which a worldport interrupts mid-session (0900).
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
mod parse;
|
||||
#[cfg(test)]
|
||||
pub(super) use parse::lua_long_string;
|
||||
pub(super) use parse::{parse_line, ParsedChat};
|
||||
|
||||
use crate::creature_anim::{move_flags, MovementState};
|
||||
@@ -916,10 +918,13 @@ pub(super) fn drain_chat_input(
|
||||
}
|
||||
ParsedChat::ConsoleUnknown { cmd } => {
|
||||
let text = if cmd.is_empty() {
|
||||
"console: no command given (this client implements: reloadUI)".to_string()
|
||||
"console: no command given (this client implements: reloadUI, and \
|
||||
`<cvar> <value>`)"
|
||||
.to_string()
|
||||
} else {
|
||||
format!(
|
||||
"console: '{cmd}' is not implemented (this client implements: reloadUI)"
|
||||
"console: '{cmd}' is not implemented (this client implements: reloadUI, \
|
||||
and `<cvar> <value>`)"
|
||||
)
|
||||
};
|
||||
chat_log.push_event(super::event::ChatEvent::text_only(
|
||||
|
||||
@@ -229,6 +229,17 @@ pub(in crate::ui_chat) fn parse_line(table: &SlashCommands, line: &str) -> Parse
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` as a Lua long-bracket string literal (`[[…]]`), at a bracket level the text cannot close
|
||||
/// early — the one quoting that needs no escaping of what a player typed.
|
||||
pub(in crate::ui_chat) fn lua_long_string(s: &str) -> String {
|
||||
let mut level = 0;
|
||||
while s.contains(&format!("]{}]", "=".repeat(level))) {
|
||||
level += 1;
|
||||
}
|
||||
let eq = "=".repeat(level);
|
||||
format!("[{eq}[{s}]{eq}]")
|
||||
}
|
||||
|
||||
/// The per-command argument grammar. Each arm is the reference handler's own body reduced to what
|
||||
/// it does with `msg` — the aliases that reach it are the table's business, never this function's.
|
||||
fn slash_command(index: SlashIndex, args: &str) -> ParsedChat {
|
||||
@@ -385,11 +396,18 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat {
|
||||
"BenillaScriptLog_Toggle()".into()
|
||||
},
|
||||
},
|
||||
S::Console => match args.split_whitespace().next() {
|
||||
Some(cmd) if cmd.eq_ignore_ascii_case("reloadui") => ParsedChat::ReloadUi,
|
||||
_ => ParsedChat::ConsoleUnknown {
|
||||
cmd: args.to_string(),
|
||||
},
|
||||
// `/console <line>` — the stock ChatFrame.lua's handler is one line, `ConsoleExec(msg)`,
|
||||
// and a TYPED `/console` never reaches this arm: the reference's `ChatEdit_ParseText`
|
||||
// finds its own `SlashCmdList["CONSOLE"]` first (1948). What does reach it is a line
|
||||
// that skipped the edit box — a `WOW_PROBE_CHAT` rig, or a chain whose ChatFrame.lua
|
||||
// lacks the handler — and 0637's contract is that a probe line is "what the director
|
||||
// would type". So this forwards to the same verb the stock handler calls: a registered
|
||||
// CVar name writes the CVar (`fpsJournal 1`, 2008), and `reloadUI` or anything else
|
||||
// comes back through `engine_verbs` exactly as it does from the Lua route. (Before 2008
|
||||
// this arm knew `reloadui` and answered everything else "not implemented" — which is
|
||||
// what a probe's `/console fpsJournal 1` got, while the same line typed worked.)
|
||||
S::Console => ParsedChat::Lua {
|
||||
body: format!("ConsoleExec({})", lua_long_string(args)),
|
||||
},
|
||||
// `/script` = the ref's `RunScript(msg)`: the typed text IS the chunk, un-escaped.
|
||||
S::Script => {
|
||||
|
||||
@@ -1316,6 +1316,26 @@ fn real_alias_table_resolves_the_shipped_commands() {
|
||||
}
|
||||
assert_eq!(parse_line("/macrohelp"), ParsedChat::MacroHelp);
|
||||
assert_eq!(parse_line("/convertraid"), ParsedChat::ConvertRaid);
|
||||
// `/console` from a line that skipped the stock edit box (a probe rig) forwards to the verb
|
||||
// the stock handler calls, so a CVar write lands like a typed one (2008). The long-bracket
|
||||
// quoting steps its level past anything the text could close.
|
||||
assert_eq!(
|
||||
parse_line("/console fpsJournal 1"),
|
||||
ParsedChat::Lua {
|
||||
body: "ConsoleExec([[fpsJournal 1]])".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_line("/console reloadUI"),
|
||||
ParsedChat::Lua {
|
||||
body: "ConsoleExec([[reloadUI]])".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(super::input::lua_long_string("a]]b"), "[=[a]]b]=]");
|
||||
assert_eq!(
|
||||
super::input::lua_long_string("a]]b]=]c"),
|
||||
"[==[a]]b]=]c]==]"
|
||||
);
|
||||
// The whole shipped surface, so a table that half-loaded fails loudly: **225 distinct emote
|
||||
// commands** over the 169 `EmotesText` names (the strings repeat — `EMOTE87_CMD1` and `_CMD2`
|
||||
// are both "/sit" — and EMOTE27 "UNUSED" has no row, so it contributes none), and **68 distinct
|
||||
|
||||
@@ -24,6 +24,7 @@ use bevy::core_pipeline::FullscreenShader;
|
||||
use bevy::ecs::query::QueryItem;
|
||||
use bevy::image::BevyDefault as _;
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::diagnostic::RecordDiagnostics;
|
||||
use bevy::render::extract_component::{ExtractComponent, ExtractComponentPlugin};
|
||||
use bevy::render::render_graph::{
|
||||
NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner,
|
||||
@@ -130,6 +131,8 @@ impl ViewNode for UiGammaNode {
|
||||
&layout,
|
||||
&BindGroupEntries::sequential((post.source, &pipelines.sampler)),
|
||||
);
|
||||
// Its diagnostic span: the journal's `gpu_ui` column counts this decode (2008).
|
||||
let diagnostics = render_context.diagnostic_recorder();
|
||||
let mut pass = render_context
|
||||
.command_encoder()
|
||||
.begin_render_pass(&RenderPassDescriptor {
|
||||
@@ -144,9 +147,11 @@ impl ViewNode for UiGammaNode {
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
let span = diagnostics.pass_span(&mut pass, "ui_gamma_decode");
|
||||
pass.set_pipeline(decode);
|
||||
pass.set_bind_group(0, &bind, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
span.end(&mut pass);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,34 +17,55 @@
|
||||
//! announcement lines into the chat window once their names resolve. [`drain_loot_rolls`] pulls the
|
||||
//! `RollOnLoot` votes back out as [`ClientCommand::LootRoll`].
|
||||
//!
|
||||
//! ## The late item template, and the `?` that outlives it
|
||||
//! ## The late item template, and the hold that answers it
|
||||
//!
|
||||
//! The reference's `GetLootRollItemInfo` reads live C state off the roll node, so by the time
|
||||
//! `START_LOOT_ROLL` fires there is something to paint and `GroupLootFrame_OnShow` can paint once
|
||||
//! and be done. Ours reads a **pushed snapshot**, and a snapshot is at least one step behind the
|
||||
//! thing that changed it — twice over:
|
||||
//! A `GroupLootFrame` paints **once**, from its `OnShow`, off whatever `GetLootRollItemInfo`
|
||||
//! answers at that instant. Ours answers out of a **pushed snapshot**, and a snapshot is at least
|
||||
//! one step behind the thing that changed it — twice over:
|
||||
//!
|
||||
//! 1. A roll is added to [`LootRolls::active`] and to `opened` in the same [`LootRolls::start`]
|
||||
//! call, so the *first* snapshot that contains a roll is built in the same pass that announces
|
||||
//! it. Push before the announce and the OnShow sees it; announce first and it paints a blank.
|
||||
//! 1. A roll is added to [`LootRolls::active`] by [`LootRolls::start`], so the *first* snapshot
|
||||
//! that contains a roll is built in the same pass that would announce it. Push before the
|
||||
//! announce and the OnShow sees it; announce first and it paints a blank.
|
||||
//! 2. `name`/`texture`/`quality`/`bindOnPickUp` come from the ask-once item-template cache and are
|
||||
//! `None` until the query lands, which is typically several frames after the roll opens.
|
||||
//!
|
||||
//! (1) is fixed by ordering — the push precedes the events here, as it does in [`crate::ui_loot`].
|
||||
//!
|
||||
//! **(2) is currently a live defect: a roll whose template lands late paints `?` and keeps it.**
|
||||
//! We used to fire an `UPDATE_LOOT_ROLL(rollID)` when an open roll's display identity changed, and
|
||||
//! our own `GroupLootFrame` re-entered its paint from it. That frame is the reference's now, and
|
||||
//! the reference has no such seam — so the repaint stopped when the window migrated, and the event
|
||||
//! went on being fired into a room with nobody in it. 1883 removed it: an event the 1.12 client
|
||||
//! does not have, which nothing listened for, was not fixing anything.
|
||||
//! (2) is **bug B371**: a reported roll frame came up with an empty icon slot and no name, and
|
||||
//! kept both for the roll's full minute. The fix is decision 2010's, and it is not a seam of ours — **it is the gate the
|
||||
//! reference already has**, which decision 1805 named in one line while looking at the window next
|
||||
//! door and which the disassembly settles:
|
||||
//!
|
||||
//! **Decision 1838 already carries the fix**, and it is the reference's own invariant rather than a
|
||||
//! re-invented seam: hold the roll until its template resolves, so the snapshot is populated when
|
||||
//! the frame first paints, exactly as the reference's C state is. The cost is the roll timer — a
|
||||
//! roll announced late is a roll with less time on it — so it wants a deadline (announce anyway
|
||||
//! after N ms, with the `?`) rather than an unbounded wait. Not built yet; 1883 only removed the
|
||||
//! event that was standing in for it.
|
||||
//! > `0x61b310`, the `SMSG_LOOT_START_ROLL` handler, allocates and lists the roll node and then
|
||||
//! > ends in the item-template cache lookup `0x55ba30(node+0x1c, node+0x10, 0x61b460, node, 0)`.
|
||||
//! > A **hit** (non-zero return) calls `0x61b430`, which fires `START_LOOT_ROLL (0x1f9, "%d%d")`.
|
||||
//! > A **miss** issues the query and leaves the callback `0x61b460` armed — a trampoline whose
|
||||
//! > worker is that same `0x61b430` — so on a cold cache the event fires from the cache *arrival*,
|
||||
//! > not from the packet. (wow-re `system/object-layer/scratch/lootroll-chat-and-lifecycle.md` §5
|
||||
//! > and `scratch/w2e-decomp.c`'s `FUN_0061b310`/`FUN_0061b430`.)
|
||||
//!
|
||||
//! So `GetLootRollItemInfo`'s cache-miss tail is nearly unreachable in the real client, and was our
|
||||
//! common case. There is no repaint to fall back on — the same finding 1805 landed for
|
||||
//! `LOOT_OPENED` one window over. Our retired `GroupLootFrame` re-entered its paint from a
|
||||
//! benilla-only `UPDATE_LOOT_ROLL(rollID)`; 1838 migrated the frame to the stock one, which has no
|
||||
//! such seam, and 1883 removed the event once nothing was listening for it.
|
||||
//!
|
||||
//! [`feed_loot_rolls`] therefore **holds a fresh roll's `START_LOOT_ROLL` until its template
|
||||
//! answers**. Two releases are ours rather than the reference's, and both are the ones 1805 already
|
||||
//! reasoned through for the loot window:
|
||||
//!
|
||||
//! - a **negative** answer — the server does not know the entry — fires the event anyway. The
|
||||
//! reference's callback is success-gated, but a roll frame that never opens is a roll we cannot
|
||||
//! even *pass* on, and the cache-miss tail is exactly what the reference paints in the
|
||||
//! neighbouring case.
|
||||
//! - a **deadline** ([`TEMPLATE_HOLD_MAX_MS`]) fires it if no answer arrives at all, because unlike
|
||||
//! a pending chat line this wait is paid out of the roll's own minute.
|
||||
//!
|
||||
//! `arg2` stays the **wire's** countdown on a held roll, because that is what `0x61b430` pushes
|
||||
//! (`node+0x3c`, the packet field). Only the bar moves: `GetLootRollTimeLeft` is
|
||||
//! `node+0x28 - now()` off a deadline stamped when the *packet* arrived, so a roll announced late
|
||||
//! opens with its bar already a sliver down — which is [`ActiveRoll::remaining_ms`] ticking from
|
||||
//! [`LootRolls::start`], unchanged.
|
||||
//!
|
||||
//! ## Two client-side behaviours, and why
|
||||
//!
|
||||
@@ -79,6 +100,15 @@ use crate::ui_script::UiInput;
|
||||
/// reasoning as [`crate::ui_loot`]'s receive lines (a negative-cached entry never resolves).
|
||||
const LINE_MAX_TRIES: u16 = 120;
|
||||
|
||||
/// How long a fresh roll may wait for its item-template answer before `START_LOOT_ROLL` fires
|
||||
/// anyway, with the frame unresolved (decision 2010 — see the module docs' hold).
|
||||
///
|
||||
/// This is [`LINE_MAX_TRIES`]' budget in the unit that actually matters here. That one counts
|
||||
/// *frames*, because a pending chat line costs nothing but frames; a held roll is paid out of the
|
||||
/// roll's own minute, so its bound has to be wall clock or a stuttering machine turns 120 frames
|
||||
/// into eight seconds of a sixty-second bar. 2 s is the same budget at 60 fps, and 3.3% of a roll.
|
||||
const TEMPLATE_HOLD_MAX_MS: u32 = 2_000;
|
||||
|
||||
/// `item_template.bonding` = *bind on pickup* (VERIFIED vmangos `ItemPrototype.h`'s `ItemBondingType`:
|
||||
/// `NO_BIND` 0, `BIND_WHEN_PICKED_UP` 1, `BIND_WHEN_EQUIPPED` 2, `BIND_WHEN_USE` 3, `QUEST_ITEM` 4).
|
||||
/// Only `1` drives `GroupLootFrame_OnShow`'s gold BoP backdrop — a BoE roll shows the plain plate.
|
||||
@@ -97,10 +127,23 @@ struct ActiveRoll {
|
||||
/// tooltip's `+0x424` and passes no item object, so — like a loot slot — the roll is the only
|
||||
/// enchant source the roll window's hover can have.
|
||||
random_property_id: u32,
|
||||
/// `SMSG_LOOT_START_ROLL`'s `countdownTime` — the roll's **duration**, kept verbatim because it
|
||||
/// is what `START_LOOT_ROLL` carries as `arg2` (`0x61b430` pushes `node+0x3c`, the packet
|
||||
/// field) and what `GroupLootFrame_OpenNewFrame` makes the Timer bar's maximum. A roll held for
|
||||
/// its template still declares its full duration; only [`Self::remaining_ms`] is short.
|
||||
countdown_ms: u32,
|
||||
/// Milliseconds left; ticked down by [`feed_loot_rolls`], saturating at `0`. The roll is *not*
|
||||
/// dropped at zero — the server closes it with `SMSG_LOOT_ROLL_WON`/`SMSG_LOOT_ALL_PASSED` when
|
||||
/// its own timer fires, and the frame stays up (bar empty) until that lands.
|
||||
remaining_ms: u32,
|
||||
/// How long this roll has been waiting for its item template, ticked alongside
|
||||
/// [`Self::remaining_ms`] and frozen once [`Self::announced`] flips. Bounded by
|
||||
/// [`TEMPLATE_HOLD_MAX_MS`].
|
||||
held_ms: u32,
|
||||
/// Whether `START_LOOT_ROLL` has gone out for this roll yet — the hold in the module docs. A
|
||||
/// roll that resolves while still held simply never announces: it leaves [`LootRolls::active`]
|
||||
/// with the rest of its state.
|
||||
announced: bool,
|
||||
}
|
||||
|
||||
/// One queued chat announcement, awaiting the item/player names it needs to render.
|
||||
@@ -129,15 +172,16 @@ pub(crate) struct LootRolls {
|
||||
next_id: u32,
|
||||
/// Announcement lines awaiting their names.
|
||||
pending: Vec<PendingLine>,
|
||||
/// Newly opened rolls → one `START_LOOT_ROLL(rollID, rollTime)` each, drained by the feed.
|
||||
opened: Vec<(u32, u32)>,
|
||||
/// Newly closed rolls → one `CANCEL_LOOT_ROLL(rollID)` each, drained by the feed.
|
||||
cancelled: Vec<u32>,
|
||||
}
|
||||
|
||||
impl LootRolls {
|
||||
/// A roll opened (`SMSG_LOOT_START_ROLL`): allocate its client-internal id and queue the
|
||||
/// `START_LOOT_ROLL` the `GroupLootFrame` machinery listens for.
|
||||
/// A roll opened (`SMSG_LOOT_START_ROLL`): allocate its client-internal id and list the roll.
|
||||
/// The `START_LOOT_ROLL` the `GroupLootFrame` machinery listens for is **not** queued here —
|
||||
/// [`feed_loot_rolls`] fires it once the roll's item template is in hand, which is the
|
||||
/// reference's own gate (module docs). The node exists from this call either way, so
|
||||
/// `GetLootRollItemInfo`/`GetLootRollTimeLeft` answer for a roll whose frame has not opened.
|
||||
///
|
||||
/// A **duplicate** start for a `(looted_target, item_slot)` we already hold is ignored rather
|
||||
/// than opening a second frame for the same item: the server re-sends every active roll on
|
||||
@@ -163,9 +207,11 @@ impl LootRolls {
|
||||
item_slot: p.item_slot,
|
||||
item_id: p.item_id,
|
||||
random_property_id: p.random_property_id,
|
||||
countdown_ms: p.countdown_ms,
|
||||
remaining_ms: p.countdown_ms,
|
||||
held_ms: 0,
|
||||
announced: false,
|
||||
});
|
||||
self.opened.push((roll_id, p.countdown_ms));
|
||||
}
|
||||
|
||||
/// One roller's vote or dice result (`SMSG_LOOT_ROLL`) — queue its chat line.
|
||||
@@ -197,6 +243,11 @@ impl LootRolls {
|
||||
|
||||
/// Drop the roll on `(looted_target, item_slot)` if we still hold it, queueing its
|
||||
/// `CANCEL_LOOT_ROLL`. Idempotent — a resolution for a roll we already closed is a no-op.
|
||||
///
|
||||
/// The cancel is queued whether or not the roll ever announced, which is the reference's own
|
||||
/// shape (`0x61b9e0`/`0x61b640` guard only on the node's already-closed byte `+0x3a`) and is
|
||||
/// harmless either way: stock `GroupLootFrame_OnEvent` compares `arg1` against each frame's
|
||||
/// `rollID`, and no frame holds one that never opened.
|
||||
fn close(&mut self, looted_target: u64, item_slot: u32) {
|
||||
if let Some(i) = self
|
||||
.active
|
||||
@@ -218,10 +269,14 @@ impl LootRolls {
|
||||
}
|
||||
|
||||
/// Tick every open roll's bar down by `delta_ms`, saturating at zero (see
|
||||
/// [`ActiveRoll::remaining_ms`] for why a spent roll is not dropped here).
|
||||
/// [`ActiveRoll::remaining_ms`] for why a spent roll is not dropped here), and age the template
|
||||
/// hold of every roll whose `START_LOOT_ROLL` has not gone out yet.
|
||||
fn tick(&mut self, delta_ms: u32) {
|
||||
for r in &mut self.active {
|
||||
r.remaining_ms = r.remaining_ms.saturating_sub(delta_ms);
|
||||
if !r.announced {
|
||||
r.held_ms = r.held_ms.saturating_add(delta_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +285,6 @@ impl LootRolls {
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.active.clear();
|
||||
self.pending.clear();
|
||||
self.opened.clear();
|
||||
self.cancelled.clear();
|
||||
}
|
||||
}
|
||||
@@ -571,13 +625,42 @@ fn feed_loot_rolls(
|
||||
*last = fresh;
|
||||
}
|
||||
|
||||
for (roll_id, roll_time) in std::mem::take(&mut rolls.opened) {
|
||||
debug!("ui_loot_roll: roll {roll_id} opened ({roll_time} ms)");
|
||||
// **A fresh roll's `START_LOOT_ROLL` waits for its item template** — the reference's own gate
|
||||
// (`0x61b310`'s cache lookup fires the event from the arrival callback `0x61b460` on a miss;
|
||||
// module docs, decision 2010, bug B371). The snapshot above already asked for every open roll's
|
||||
// template and, crucially, already went into the VM — so a roll released here is one the
|
||||
// `OnShow` below it can actually paint.
|
||||
for r in &mut rolls.active {
|
||||
if r.announced {
|
||||
continue;
|
||||
}
|
||||
if items.template(r.item_id, 0, &commands).is_none() {
|
||||
// The two releases that are ours rather than the reference's, both 1805's reasoning:
|
||||
// an entry the server cannot describe, and an answer that never comes at all.
|
||||
let unknown = items.template_answered_unknown(r.item_id);
|
||||
if !unknown && r.held_ms < TEMPLATE_HOLD_MAX_MS {
|
||||
continue;
|
||||
}
|
||||
debug!(
|
||||
"ui_loot_roll: roll {} opening unresolved ({})",
|
||||
r.roll_id,
|
||||
if unknown {
|
||||
"the server does not know the entry"
|
||||
} else {
|
||||
"template hold expired"
|
||||
}
|
||||
);
|
||||
}
|
||||
r.announced = true;
|
||||
debug!(
|
||||
"ui_loot_roll: roll {} opened ({} ms, held {} ms)",
|
||||
r.roll_id, r.countdown_ms, r.held_ms
|
||||
);
|
||||
script.fire_event(
|
||||
"START_LOOT_ROLL",
|
||||
vec![
|
||||
ScriptValue::Int(roll_id as i64),
|
||||
ScriptValue::Int(roll_time as i64),
|
||||
ScriptValue::Int(r.roll_id as i64),
|
||||
ScriptValue::Int(r.countdown_ms as i64),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -880,15 +963,24 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_allocates_ids_and_queues_the_open() {
|
||||
fn start_lists_the_roll_with_monotonic_ids_and_announces_nothing() {
|
||||
let mut r = LootRolls::default();
|
||||
r.start(start(0xAA, 0, 17182));
|
||||
r.start(start(0xAA, 1, 4306));
|
||||
assert_eq!(r.active.len(), 2);
|
||||
// Distinct, monotonic ids; each queued a START_LOOT_ROLL with the wire's countdown.
|
||||
assert_eq!(r.opened, vec![(1, 60_000), (2, 60_000)]);
|
||||
assert_eq!(r.active[0].roll_id, 1);
|
||||
assert_eq!(r.active[1].roll_id, 2);
|
||||
// The wire's countdown is kept whole (it is `START_LOOT_ROLL`'s `arg2`) and the bar starts
|
||||
// there; the announce itself waits for the item template (`feed_loot_rolls`).
|
||||
for roll in &r.active {
|
||||
assert_eq!(roll.countdown_ms, 60_000);
|
||||
assert_eq!(roll.remaining_ms, 60_000);
|
||||
assert!(
|
||||
!roll.announced,
|
||||
"the packet does not announce — the template does"
|
||||
);
|
||||
assert_eq!(roll.held_ms, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// vmangos re-sends every active roll on reconnect (`SendLootStartRollsForPlayer`) — a repeat
|
||||
@@ -899,7 +991,7 @@ mod tests {
|
||||
r.start(start(0xAA, 0, 17182));
|
||||
r.start(start(0xAA, 0, 17182));
|
||||
assert_eq!(r.active.len(), 1);
|
||||
assert_eq!(r.opened, vec![(1, 60_000)]);
|
||||
assert_eq!(r.next_id, 1, "and the duplicate burns no id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -907,7 +999,6 @@ mod tests {
|
||||
let mut r = LootRolls::default();
|
||||
r.start(start(0xAA, 0, 17182));
|
||||
r.start(start(0xAA, 1, 4306));
|
||||
r.opened.clear();
|
||||
|
||||
r.won(LootRollWon {
|
||||
looted_target: 0xAA,
|
||||
@@ -979,6 +1070,21 @@ mod tests {
|
||||
assert_eq!(r.active.len(), 1, "the server closes it, not the tick");
|
||||
}
|
||||
|
||||
/// The template hold ages with the bar, and **stops** at the announce — otherwise a roll that
|
||||
/// opened cleanly would keep accruing a wait nothing is waiting for.
|
||||
#[test]
|
||||
fn tick_ages_the_hold_only_while_the_roll_is_unannounced() {
|
||||
let mut r = LootRolls::default();
|
||||
r.start(start(0xAA, 0, 17182));
|
||||
r.tick(500);
|
||||
r.tick(250);
|
||||
assert_eq!(r.active[0].held_ms, 750);
|
||||
r.active[0].announced = true;
|
||||
r.tick(1_000);
|
||||
assert_eq!(r.active[0].held_ms, 750, "the hold is over");
|
||||
assert_eq!(r.active[0].remaining_ms, 60_000 - 1_750, "the bar is not");
|
||||
}
|
||||
|
||||
/// Ids are never reused, so a late packet for a closed roll cannot address a fresh one.
|
||||
#[test]
|
||||
fn ids_are_never_reused() {
|
||||
@@ -988,4 +1094,197 @@ mod tests {
|
||||
r.start(start(0xBB, 0, 4306));
|
||||
assert_eq!(r.active[0].roll_id, 2);
|
||||
}
|
||||
|
||||
// ── The template hold (decision 2010, bug B371) ──────────────────────────────────────────
|
||||
//
|
||||
// Driven through the real system in a real schedule, because the whole question is what
|
||||
// happens ACROSS frames and `feed_loot_rolls`' snapshot memo is a `Local`: `run_system_once`
|
||||
// builds a new system each call and hands it a fresh memo, which reads every pass as a first
|
||||
// push. Same reason, same shape, as `ui_loot`'s `the_window_waits_for_every_item_template`.
|
||||
|
||||
const ROLLED: u32 = 17182;
|
||||
|
||||
/// A `feed_loot_rolls` app with a listener that records, **at the instant the event fires**,
|
||||
/// what `GetLootRollItemInfo` answers for the roll — the exact call stock
|
||||
/// `GroupLootFrame_OnShow` makes, so a blank here is the director's blank.
|
||||
fn hold_app() -> (App, crossbeam_channel::Receiver<ClientCommand>) {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
app.init_resource::<LootRolls>()
|
||||
.init_resource::<Items>()
|
||||
.init_resource::<NameCache>()
|
||||
.init_resource::<SelfGuid>()
|
||||
.init_resource::<ChatLog>()
|
||||
.init_resource::<crate::ui_loot::LootConfig>()
|
||||
// `Time::default()` has a zero delta, so the hold only ages where a test says it does.
|
||||
.init_resource::<Time>()
|
||||
.insert_resource(NetCommands(tx))
|
||||
.add_systems(Update, feed_loot_rolls);
|
||||
|
||||
let script = UiScript::new().unwrap();
|
||||
script
|
||||
.run(
|
||||
"STARTS = {}\n\
|
||||
local f = CreateFrame(\"Frame\")\n\
|
||||
f:RegisterEvent(\"START_LOOT_ROLL\")\n\
|
||||
f:SetScript(\"OnEvent\", function()\n\
|
||||
local _, name = GetLootRollItemInfo(arg1)\n\
|
||||
tinsert(STARTS, arg1 .. \":\" .. arg2 .. \":\" .. (name or \"<blank>\"))\n\
|
||||
end)",
|
||||
)
|
||||
.unwrap();
|
||||
app.insert_non_send_resource(script);
|
||||
(app, rx)
|
||||
}
|
||||
|
||||
/// `rollID:rollTime:name` for each `START_LOOT_ROLL` seen so far, in order.
|
||||
fn starts(app: &mut App) -> Vec<String> {
|
||||
let script = app.world_mut().non_send_resource_mut::<UiScript>();
|
||||
let n = script.eval::<i64>("return getn(STARTS)").unwrap();
|
||||
(1..=n)
|
||||
.map(|i| {
|
||||
script
|
||||
.eval::<String>(&format!("return STARTS[{i}]"))
|
||||
.unwrap()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn advance(app: &mut App, ms: u64) {
|
||||
app.world_mut()
|
||||
.resource_mut::<Time>()
|
||||
.advance_by(std::time::Duration::from_millis(ms));
|
||||
}
|
||||
|
||||
fn sent(rx: &crossbeam_channel::Receiver<ClientCommand>) -> Vec<ClientCommand> {
|
||||
rx.try_iter().collect()
|
||||
}
|
||||
|
||||
/// **The roll waits for its item template** — the reference's own gate, and B371's fix.
|
||||
///
|
||||
/// `0x61b310` ends in `0x55ba30(node+0x1c, node+0x10, callback 0x61b460, node, 0)`: a cache
|
||||
/// **hit** calls `0x61b430` and fires `START_LOOT_ROLL` there and then; a **miss** issues the
|
||||
/// query and fires from the arrival callback instead. So a roll on an entry we have never seen
|
||||
/// must announce nothing on the first pass — and must ask the server — then announce exactly
|
||||
/// once when the answer lands, with the item already readable by the `OnShow`.
|
||||
///
|
||||
/// Both answers are pinned: the real template, and the **negative** one, which releases the
|
||||
/// roll here where the reference's success-gated callback would hold it (1805's divergence — a
|
||||
/// frame that never opens is a roll we cannot even pass on).
|
||||
#[test]
|
||||
fn the_roll_waits_for_its_item_template() {
|
||||
for (answer, painted) in [
|
||||
(Some(crate::items::test_template("Sulfuras")), "Sulfuras"),
|
||||
(None, "<blank>"),
|
||||
] {
|
||||
let (mut app, rx) = hold_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<LootRolls>()
|
||||
.start(start(0xAA, 0, ROLLED));
|
||||
|
||||
// Pass one: the template is unknown, so nothing announces — and the ask goes out.
|
||||
app.update();
|
||||
assert!(
|
||||
starts(&mut app).is_empty(),
|
||||
"no START_LOOT_ROLL while the template is pending"
|
||||
);
|
||||
assert!(
|
||||
sent(&rx).iter().any(
|
||||
|c| matches!(c, ClientCommand::ItemQuery { entry, .. } if *entry == ROLLED)
|
||||
),
|
||||
"and the template was asked for"
|
||||
);
|
||||
// A second pass with nothing new changes nothing — the hold is not a one-shot.
|
||||
app.update();
|
||||
assert!(starts(&mut app).is_empty());
|
||||
|
||||
// The answer lands. The roll announces, once, carrying the WIRE's countdown as `arg2`
|
||||
// (`0x61b430` pushes `node+0x3c`) — and the name is already there for the OnShow.
|
||||
app.world_mut()
|
||||
.resource_mut::<Items>()
|
||||
.insert_template(ROLLED, answer.clone());
|
||||
app.update();
|
||||
assert_eq!(starts(&mut app), vec![format!("1:60000:{painted}")]);
|
||||
app.update();
|
||||
assert_eq!(starts(&mut app).len(), 1, "and only once");
|
||||
}
|
||||
}
|
||||
|
||||
/// A repeat drop costs nothing: the template is already cached, so `0x61b310`'s hit path is
|
||||
/// ours too and the roll announces on the very pass the packet lands.
|
||||
#[test]
|
||||
fn a_cached_template_announces_the_same_pass() {
|
||||
let (mut app, _rx) = hold_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<Items>()
|
||||
.insert_template(ROLLED, Some(crate::items::test_template("Sulfuras")));
|
||||
app.world_mut()
|
||||
.resource_mut::<LootRolls>()
|
||||
.start(start(0xAA, 0, ROLLED));
|
||||
app.update();
|
||||
assert_eq!(starts(&mut app), vec!["1:60000:Sulfuras".to_string()]);
|
||||
}
|
||||
|
||||
/// The deadline, which is ours and not the reference's: an entry the server never answers at
|
||||
/// all must not hold the frame shut for the roll's whole minute — we would have no way to
|
||||
/// Need, Greed or even Pass on it. It opens unresolved instead, which is what the reference's
|
||||
/// own cache-miss tail paints in the neighbouring case.
|
||||
#[test]
|
||||
fn an_unanswered_template_opens_the_roll_at_the_deadline() {
|
||||
let (mut app, _rx) = hold_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<LootRolls>()
|
||||
.start(start(0xAA, 0, ROLLED));
|
||||
|
||||
// Two steps to one millisecond short of the budget: still holding.
|
||||
for _ in 0..2 {
|
||||
advance(&mut app, (TEMPLATE_HOLD_MAX_MS / 2 - 1) as u64);
|
||||
app.update();
|
||||
}
|
||||
assert!(
|
||||
starts(&mut app).is_empty(),
|
||||
"still holding at {} ms",
|
||||
TEMPLATE_HOLD_MAX_MS - 2
|
||||
);
|
||||
|
||||
// The step that crosses it opens the frame — blank, but votable.
|
||||
advance(&mut app, 2);
|
||||
app.update();
|
||||
assert_eq!(starts(&mut app), vec!["1:60000:<blank>".to_string()]);
|
||||
advance(&mut app, TEMPLATE_HOLD_MAX_MS as u64);
|
||||
app.update();
|
||||
assert_eq!(starts(&mut app).len(), 1, "and only once");
|
||||
}
|
||||
|
||||
/// A roll resolved while it is still held never announces at all — there is no frame to open
|
||||
/// and none to leave up. Its `CANCEL_LOOT_ROLL` still goes out, which is the reference's shape
|
||||
/// (`0x61b9e0` guards only on the node's already-closed byte) and is a no-op in the stock Lua.
|
||||
#[test]
|
||||
fn a_roll_resolved_while_held_never_announces() {
|
||||
let (mut app, _rx) = hold_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<LootRolls>()
|
||||
.start(start(0xAA, 0, ROLLED));
|
||||
app.update();
|
||||
assert!(starts(&mut app).is_empty());
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<LootRolls>()
|
||||
.all_passed(LootAllPassed {
|
||||
looted_target: 0xAA,
|
||||
item_slot: 0,
|
||||
item_id: ROLLED,
|
||||
random_property_id: 0,
|
||||
});
|
||||
// Even once the template lands, the roll it belonged to is gone.
|
||||
app.world_mut()
|
||||
.resource_mut::<Items>()
|
||||
.insert_template(ROLLED, Some(crate::items::test_template("Sulfuras")));
|
||||
app.update();
|
||||
app.update();
|
||||
assert!(
|
||||
starts(&mut app).is_empty(),
|
||||
"a roll that closed while held opens no frame"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,919 @@
|
||||
//! **UI model tiles** (decision 2008) — the renderer for a `<Model>` widget's M2: the cooldown
|
||||
//! sweep, the autocast shine, the minimap and world-map pings, the item-push card, the map's
|
||||
//! arrow, and whatever an addon parks in a `CreateFrame("Model")`.
|
||||
//!
|
||||
//! ## The law (wow-re `system/ui/scratch/modelframe-render-law.md`, `e1b1794b`)
|
||||
//!
|
||||
//! A `<Model>` with no M2 camera picked draws its scene **orthographically over the frame's
|
||||
//! rect**: origin at the rect's bottom-left, `+X` right, `+Y` up, `Z` depth only, with the root
|
||||
//! matrix `T(pos · layoutScale) · R(facing, +Z) · S(G48 · 5/3 · modelScale · layoutScale)` — so one
|
||||
//! model unit is `1280 · modelScale · layoutScale` FrameXML units, aspect-independent (§2/§3).
|
||||
//! Every batch draws once, LEQUAL over a depth buffer cleared for the widget's own rect, straight
|
||||
//! into the back buffer (§6); every in-game UI M2 is UNLIT on every material (§5.7); the
|
||||
//! animator is the world's, on the widget's private clock (§4). A particle's half-extent is the
|
||||
//! one quantity outside the unit law — added in eye space, it maps at `768 · √(a²+1)` FrameXML
|
||||
//! units per model unit and carries neither scale (`clip-and-scale.md` §6).
|
||||
//!
|
||||
//! ## The shape here: tiles in one atlas, composited at the callback rank
|
||||
//!
|
||||
//! The reference draws into the back buffer between two 2-D batches; this engine's UI is one
|
||||
//! quad pass, so a scene becomes a **tile**: every visible pane holding a file renders into its
|
||||
//! own cell of one shared render-target atlas, at the pane's device-pixel size, through ONE
|
||||
//! orthographic camera whose view is the atlas plane — each tile's model root is placed at its
|
||||
//! cell, scaled to pixels per model unit, and the camera never moves. The extract's `ModelPane`
|
||||
//! arm then draws the cell as a premultiplied quad at `ZKey::callback(Artwork)` (1995's rank —
|
||||
//! after every texture and font string of the pane's layer), which is the same picture the
|
||||
//! reference's callback drain produces: the cell is cleared to transparent like the reference
|
||||
//! clears its depth, the 2-D layers under it stay under it, and the ones over it stay over it.
|
||||
//! Cells never overlap, so one depth buffer serves every tile.
|
||||
//!
|
||||
//! The pipeline is the booths' (`crate::portrait`): the same HDR view shape, the same
|
||||
//! `FfxGlow::UI_PANE` decode, the same material twin with only the light storage swapped, the
|
||||
//! same collapsed rig lane and palette mirror, the same effect lane. What is new is the
|
||||
//! orthographic preset, the atlas packing, and the clock: a tile samples the file at the play
|
||||
//! head the ENGINE holds (decision 2007 — `UiScript::visible_model_panes`, read once per frame),
|
||||
//! so the `AnimationPlayer` is paused and seeked rather than advanced, and every per-sequence
|
||||
//! material track is sampled off that same cursor.
|
||||
//!
|
||||
//! ## What a tile's light is
|
||||
//!
|
||||
//! `<Model>`'s embedded light is DISABLED (§5.2), and a LIT batch under no light renders black —
|
||||
//! which never shows on the shipped UI M2s because all of them are unlit, and which is the
|
||||
//! faithful answer for an addon's lit one. So the tile light buffer is a black light (no
|
||||
//! ambient, no diffuse, fog off) and unlit batches bypass it. `SetLight` is stored by the engine
|
||||
//! and not read here (the reference's `SetLight(0, …)` is a no-op too; the enabled form has no
|
||||
//! caller in 1.12's FrameXML).
|
||||
//!
|
||||
//! ## What is deliberately NOT here yet
|
||||
//!
|
||||
//! - The **perspective leg** (`SetCamera(n)` naming a real M2 camera; the character panes): a
|
||||
//! plain `<Model>` in the shipped interface never picks one, and the character panes keep
|
||||
//! their booths. Named, not built.
|
||||
//! - **Texture-transform rotation** (the cooldown indicator's sweep is four quadrant quads whose
|
||||
//! UV rotation tracks turn the mask): the formats bake carries translation only. The cooldown
|
||||
//! stays on the native `Cooldown` widget until that channel lands; this module renders every
|
||||
//! other UI file.
|
||||
//! - The **implicit rect** of a size-less `<Model>` from the file's bounds (§3): the map arrow
|
||||
//! keeps its 1980 footprint and its sprite for now.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use bevy::camera::visibility::{NoFrustumCulling, RenderLayers};
|
||||
use bevy::camera::{OrthographicProjection, Projection, RenderTarget, ScalingMode};
|
||||
use bevy::mesh::MeshTag;
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::render_resource::Buffer;
|
||||
use bevy::render::renderer::{RenderDevice, RenderQueue};
|
||||
|
||||
use benilla_assets::materials::WowModelMaterial;
|
||||
use benilla_assets::{m2_url, M2Model, WorldAssets};
|
||||
use benilla_ui::script::{ModelPaneFrame, UiScript};
|
||||
use benilla_ui::widget::{FrameHandle, ModelFileFacts, SequenceFacts};
|
||||
use benilla_world::doodad_anim::spawn_anim_host;
|
||||
use benilla_world::lighting::LightBlob;
|
||||
use benilla_world::model_forms::ModelForms;
|
||||
use benilla_world::model_render::M2BatchMaterials;
|
||||
use benilla_world::particles::buffer::EffectLightOverride;
|
||||
use benilla_world::particles::{
|
||||
spawn_emitter, EmitClock, EmitterFrames, OwnerLoss, ParticleEmitter,
|
||||
};
|
||||
use benilla_world::rig_anim::{GlobalSeqDrive, RigPose};
|
||||
use benilla_world::rig_palette::{RigPaletteMirrors, RigPalettes, RigPart, RigSkin};
|
||||
|
||||
use crate::portrait::{
|
||||
booth_view_shape, material_variant, new_target_image_sized, StageRig, UI_MODELS_LAYER,
|
||||
};
|
||||
|
||||
/// One pane's request for a tile this frame — what the extract knows about the widget: its
|
||||
/// size on the device, the unit ladder the render law derives from it, and the Lua-set scene.
|
||||
/// Published by the extract's `ModelPane` arm (keyed by the pane's frame handle, overwritten on
|
||||
/// every conversion), read by [`sync_tiles`].
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct TileRequest {
|
||||
/// The `SetModel` path, as written.
|
||||
pub path: String,
|
||||
/// The pane's rect on the device, whole pixels — the tile's cell size.
|
||||
pub size_px: UVec2,
|
||||
/// Device pixels per **model unit** for the geometry: `1280 · modelScale · layoutScale`
|
||||
/// FrameXML units per unit, times the seam scale, times the DPI.
|
||||
pub px_per_unit: f32,
|
||||
/// Device pixels per **layout unit** — `SetPosition`'s space (`T(pos · layoutScale)`):
|
||||
/// `768 · √(a²+1) · layoutScale` FrameXML units per unit, times seam and DPI.
|
||||
pub pos_px_per_unit: f32,
|
||||
/// Device pixels per model unit for a **particle's half-extent** — eye space, no scale:
|
||||
/// `768 · √(a²+1)` FrameXML units per unit, times seam and DPI.
|
||||
pub star_px_per_unit: f32,
|
||||
/// `SetFacing`, radians about the screen normal (CCW positive, the reference's `+Z`).
|
||||
pub facing: f32,
|
||||
/// `SetPosition`, layout units.
|
||||
pub position: Vec3,
|
||||
/// `ReplaceIconTexture`'s path — the type-14 batches' texture.
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
/// Where a tile sits in the atlas — texel space, `y` down — for the composite quad.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Cell {
|
||||
pub origin: UVec2,
|
||||
pub size: UVec2,
|
||||
}
|
||||
|
||||
/// The extract ↔ renderer bridge (the `BoothPanes` pattern): requests in, cells and the atlas
|
||||
/// out. Lives on `crate::portrait::BoothBridge` so the extract reaches it through the seam it
|
||||
/// already holds.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct UiModelTiles {
|
||||
/// The last request per pane. A pane that stops being extracted keeps its stale entry
|
||||
/// (the memoized conversion cannot tell the renderer it vanished); which panes DRAW is the
|
||||
/// engine's paint list, never this map.
|
||||
pub requests: HashMap<FrameHandle, TileRequest>,
|
||||
/// This frame's cell per tile that has something to draw.
|
||||
pub cells: HashMap<FrameHandle, Cell>,
|
||||
/// The atlas image and its size — `None` until the first tile.
|
||||
pub atlas: Option<Handle<Image>>,
|
||||
pub atlas_size: UVec2,
|
||||
/// The window's DPI as the last extract saw it (device px per logical px) — the extract
|
||||
/// writes it, the arm reads it to size cells.
|
||||
pub dpi: f32,
|
||||
}
|
||||
|
||||
/// The renderer's own fixed state: the camera, its layer, the black light buffer and the
|
||||
/// per-material twin cache against it.
|
||||
#[derive(Resource)]
|
||||
struct TileRig {
|
||||
layer: RenderLayers,
|
||||
light: Option<Buffer>,
|
||||
variants: HashMap<AssetId<WowModelMaterial>, Handle<WowModelMaterial>>,
|
||||
}
|
||||
|
||||
/// Marks the tile camera.
|
||||
#[derive(Component)]
|
||||
struct TileCamera;
|
||||
|
||||
/// Marks a tile's root entity (the model root, at its cell).
|
||||
#[derive(Component)]
|
||||
struct TileRoot;
|
||||
|
||||
/// One batch of a built tile whose alpha the file animates: sampled here off the pane's play
|
||||
/// head (a hosted `MatAnim` would read the paused player, which has no node for a sequence
|
||||
/// that keys no bone — the cooldown's sweep is exactly that).
|
||||
struct AlphaPart {
|
||||
entity: Entity,
|
||||
anim: Arc<benilla_formats::AlphaAnim>,
|
||||
}
|
||||
|
||||
/// A live tile: its entity tree and what it was built from.
|
||||
struct Tile {
|
||||
root: Entity,
|
||||
/// The file key the tree was built for (a `SetModel` to another file rebuilds).
|
||||
key: String,
|
||||
/// The icon override the tree was built with (a change rebuilds the materials).
|
||||
icon: Option<String>,
|
||||
m2: Handle<M2Model>,
|
||||
/// The tree is spawned (parts, rig, emitters) — until then the root is bare.
|
||||
built: bool,
|
||||
/// The graph node per `AnimationData` id the file keys a bone for, and its file slot.
|
||||
clips: HashMap<u16, (AnimationNodeIndex, usize)>,
|
||||
/// Which id the player is currently arming (to re-arm only on change).
|
||||
armed: Option<u16>,
|
||||
alpha_parts: Vec<AlphaPart>,
|
||||
emitters: Vec<Entity>,
|
||||
/// The last frame this tile was on the engine's paint list.
|
||||
last_seen: u64,
|
||||
}
|
||||
|
||||
/// Frames a tile survives off the paint list before its tree is torn down — long enough that a
|
||||
/// cooldown that re-arms every few seconds, or a ping, keeps its tree.
|
||||
const TILE_LINGER_FRAMES: u64 = 600;
|
||||
|
||||
/// The atlas edge the first tile allocates, and the cap a grown atlas stops at.
|
||||
const ATLAS_MIN: u32 = 512;
|
||||
const ATLAS_MAX: u32 = 4096;
|
||||
|
||||
/// Gutter between cells (texels): a tile's bilinear edge never samples a neighbour.
|
||||
const GUTTER: u32 = 2;
|
||||
|
||||
/// The tile camera's order — after every booth (`-100 …`), before the UI camera (`1`).
|
||||
const TILE_CAMERA_ORDER: isize = -10;
|
||||
|
||||
/// The renderer's per-frame state that is not the bridge.
|
||||
#[derive(Default)]
|
||||
struct TileState {
|
||||
tiles: HashMap<FrameHandle, Tile>,
|
||||
/// Files the engine asked facts for, loading.
|
||||
pending_facts: HashMap<String, Handle<M2Model>>,
|
||||
/// Files whose facts were handed over — the handle kept alive for the tiles.
|
||||
loaded: HashMap<String, Handle<M2Model>>,
|
||||
frame: u64,
|
||||
}
|
||||
|
||||
pub(crate) struct UiModelsPlugin;
|
||||
|
||||
impl Plugin for UiModelsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<UiModelTiles>()
|
||||
.init_non_send_resource::<TileState>()
|
||||
.add_systems(Startup, setup_tiles)
|
||||
// After the extract published this frame's requests, and before the pose/palette
|
||||
// passes read the roots' transforms (they run in PostUpdate).
|
||||
.add_systems(Update, sync_tiles.after(crate::ui_script::UiInput));
|
||||
}
|
||||
}
|
||||
|
||||
/// Startup: the camera, the layer, the black light.
|
||||
fn setup_tiles(
|
||||
mut commands: Commands,
|
||||
device: Res<RenderDevice>,
|
||||
queue: Res<RenderQueue>,
|
||||
mut mirrors: ResMut<RigPaletteMirrors>,
|
||||
) {
|
||||
// `<Model>`'s embedded light is disabled: no ambient, no diffuse, no fog (render law §5.2).
|
||||
// The direction is irrelevant at zero intensity; the builder wants one.
|
||||
let blob = LightBlob::model([0.0; 3], [0.0; 3], Vec3::NEG_Y);
|
||||
let light = blob.create(&device, "wow_ui_model_light");
|
||||
blob.write(&queue, &light);
|
||||
// Tile rigs skin from THIS buffer's palette region (decision 0720's mirror law).
|
||||
mirrors.0.insert("ui_models", light.clone());
|
||||
let layer = RenderLayers::layer(UI_MODELS_LAYER);
|
||||
commands.spawn((
|
||||
Name::new("ui model tiles camera"),
|
||||
booth_view_shape(),
|
||||
Camera {
|
||||
order: TILE_CAMERA_ORDER,
|
||||
// The reference clears DEPTH for the widget's rect and leaves colour to the 2-D
|
||||
// pass; a tile composites over the 2-D pass instead, so its colour clears to
|
||||
// nothing — the premultiplied transparent the booth panes use (decision 1083).
|
||||
clear_color: ClearColorConfig::Custom(Color::NONE),
|
||||
is_active: false,
|
||||
..default()
|
||||
},
|
||||
// Decode, no scene glow: a UI model draws in the UI strata after the WorldFrame's
|
||||
// FFX apply (decision 0638's law for the body panes, the same widget family).
|
||||
benilla_world::ffx_glow::FfxGlow::UI_PANE,
|
||||
Projection::Orthographic(OrthographicProjection {
|
||||
near: 0.1,
|
||||
far: 2000.0,
|
||||
scaling_mode: ScalingMode::Fixed {
|
||||
width: ATLAS_MIN as f32,
|
||||
height: ATLAS_MIN as f32,
|
||||
},
|
||||
..OrthographicProjection::default_3d()
|
||||
}),
|
||||
Transform::from_xyz(0.0, 0.0, 1000.0),
|
||||
layer.clone(),
|
||||
TileCamera,
|
||||
));
|
||||
commands.insert_resource(TileRig {
|
||||
layer,
|
||||
light: Some(light),
|
||||
variants: HashMap::new(),
|
||||
});
|
||||
}
|
||||
|
||||
/// The bevy-space → tile-camera-space rotation: WoW `+X` (bevy `−Z`) to the right, WoW `+Y`
|
||||
/// (bevy `−X`) up, WoW `+Z` (bevy `+Y`) toward the viewer — the ortho leg's axes (§2). A proper
|
||||
/// rotation (determinant +1), so winding survives.
|
||||
fn wow_to_screen() -> Quat {
|
||||
Quat::from_mat3(&Mat3::from_cols(
|
||||
Vec3::new(0.0, -1.0, 0.0),
|
||||
Vec3::new(0.0, 0.0, 1.0),
|
||||
Vec3::new(-1.0, 0.0, 0.0),
|
||||
))
|
||||
}
|
||||
|
||||
/// The assets a tile build reads and writes, in one param (the 16-parameter ceiling).
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
struct TileAssets<'w> {
|
||||
asset_server: Res<'w, AssetServer>,
|
||||
m2s: Res<'w, Assets<M2Model>>,
|
||||
images: ResMut<'w, Assets<Image>>,
|
||||
world: Option<ResMut<'w, WorldAssets>>,
|
||||
forms: ResMut<'w, ModelForms>,
|
||||
meshes: ResMut<'w, Assets<Mesh>>,
|
||||
}
|
||||
|
||||
/// The render-side resources a tile build spends.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
struct TileRender<'w> {
|
||||
/// The batch materials — and, through it, the material store the twins are added to (a
|
||||
/// second `ResMut<Assets<WowModelMaterial>>` beside it would conflict at schedule time).
|
||||
mats: M2BatchMaterials<'w>,
|
||||
palettes: ResMut<'w, RigPalettes>,
|
||||
rig: ResMut<'w, TileRig>,
|
||||
}
|
||||
|
||||
/// The per-frame pass: feed the engine the facts it asked for, keep one tile per visible pane,
|
||||
/// pack the atlas, place every tile at its cell and its play head, and aim the camera.
|
||||
#[allow(clippy::too_many_arguments, clippy::type_complexity)] // a Bevy system's full input set
|
||||
fn sync_tiles(
|
||||
mut commands: Commands,
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut state: NonSendMut<TileState>,
|
||||
mut bridge: ResMut<UiModelTiles>,
|
||||
mut assets: TileAssets,
|
||||
mut render: TileRender,
|
||||
mut cams: Query<
|
||||
(
|
||||
&mut Camera,
|
||||
&mut RenderTarget,
|
||||
&mut Projection,
|
||||
&mut Transform,
|
||||
),
|
||||
With<TileCamera>,
|
||||
>,
|
||||
mut roots: Query<
|
||||
(
|
||||
&mut Transform,
|
||||
&mut Visibility,
|
||||
Option<&mut AnimationPlayer>,
|
||||
),
|
||||
(With<TileRoot>, Without<TileCamera>),
|
||||
>,
|
||||
mut parts: Query<(&mut MeshTag, &mut Visibility), (Without<TileRoot>, Without<TileCamera>)>,
|
||||
mut emitters: Query<&mut ParticleEmitter>,
|
||||
) {
|
||||
state.frame += 1;
|
||||
let frame = state.frame;
|
||||
let Some(mut script) = script else {
|
||||
// No VM: nothing paints. Tear everything down so a dead UI leaves no live camera.
|
||||
for (_, tile) in state.tiles.drain() {
|
||||
commands.entity(tile.root).despawn();
|
||||
}
|
||||
bridge.cells.clear();
|
||||
set_camera_active(&mut cams, false);
|
||||
return;
|
||||
};
|
||||
|
||||
// ── 1. Facts: what the engine asked for, answered when the file lands ───────────────
|
||||
for key in script.model_facts_wanted() {
|
||||
if state.loaded.contains_key(&key) || state.pending_facts.contains_key(&key) {
|
||||
continue;
|
||||
}
|
||||
let handle = assets.asset_server.load::<M2Model>(m2_url(&key));
|
||||
state.pending_facts.insert(key, handle);
|
||||
}
|
||||
let landed: Vec<(String, Handle<M2Model>)> = state
|
||||
.pending_facts
|
||||
.iter()
|
||||
.filter(|(_, h)| assets.m2s.contains(*h))
|
||||
.map(|(k, h)| (k.clone(), h.clone()))
|
||||
.collect();
|
||||
for (key, handle) in landed {
|
||||
if let Some(model) = assets.m2s.get(&handle) {
|
||||
script.set_model_facts(&key, facts_of(model));
|
||||
}
|
||||
state.pending_facts.remove(&key);
|
||||
state.loaded.insert(key, handle);
|
||||
}
|
||||
|
||||
// ── 2. The paint list, and one tile per pane on it ──────────────────────────────────
|
||||
let panes: Vec<ModelPaneFrame> = script.visible_model_panes();
|
||||
let mut live: Vec<(FrameHandle, TileRequest, ModelPaneFrame)> = Vec::new();
|
||||
for pane in panes {
|
||||
let Some(req) = bridge.requests.get(&pane.handle) else {
|
||||
continue; // not extracted yet — next frame
|
||||
};
|
||||
if req.size_px.x == 0 || req.size_px.y == 0 {
|
||||
continue;
|
||||
}
|
||||
live.push((pane.handle, req.clone(), pane));
|
||||
}
|
||||
// Stable cell order: the engine's registry order (creation order), so a pane keeps its
|
||||
// cell across frames.
|
||||
let _ = &live;
|
||||
|
||||
for (handle, req, _) in &live {
|
||||
let key = benilla_ui::widget::model_key(&req.path);
|
||||
let Some(m2) = state.loaded.get(&key).cloned() else {
|
||||
continue; // facts not landed ⇒ the engine would not have listed it; defensive
|
||||
};
|
||||
let stale = state
|
||||
.tiles
|
||||
.get(handle)
|
||||
.is_some_and(|t| t.key != key || t.icon != req.icon);
|
||||
if stale {
|
||||
if let Some(t) = state.tiles.remove(handle) {
|
||||
commands.entity(t.root).despawn();
|
||||
}
|
||||
}
|
||||
let tile = state.tiles.entry(*handle).or_insert_with(|| Tile {
|
||||
root: commands
|
||||
.spawn((
|
||||
Transform::IDENTITY,
|
||||
Visibility::Hidden,
|
||||
render.rig.layer.clone(),
|
||||
TileRoot,
|
||||
))
|
||||
.id(),
|
||||
key: key.clone(),
|
||||
icon: req.icon.clone(),
|
||||
m2: m2.clone(),
|
||||
built: false,
|
||||
clips: HashMap::new(),
|
||||
armed: None,
|
||||
alpha_parts: Vec::new(),
|
||||
emitters: Vec::new(),
|
||||
last_seen: frame,
|
||||
});
|
||||
tile.last_seen = frame;
|
||||
if !tile.built {
|
||||
if let Some(model) = assets.m2s.get(&tile.m2) {
|
||||
let icon_tex = req.icon.as_deref().and_then(|p| {
|
||||
assets
|
||||
.world
|
||||
.as_mut()
|
||||
.and_then(|w| w.sprite_texture(p, &mut assets.images))
|
||||
});
|
||||
if let Some(built) = build_tile(
|
||||
&mut commands,
|
||||
tile.root,
|
||||
model,
|
||||
&tile.m2,
|
||||
icon_tex,
|
||||
&mut assets.forms,
|
||||
&mut assets.meshes,
|
||||
&mut render,
|
||||
) {
|
||||
info!(
|
||||
"ui_models: tile built for {} — {} parts, {} emitters, {} animated alphas",
|
||||
req.path,
|
||||
model.submeshes.len(),
|
||||
built.emitters.len(),
|
||||
built.alpha_parts.len()
|
||||
);
|
||||
tile.clips = built.clips;
|
||||
tile.alpha_parts = built.alpha_parts;
|
||||
tile.emitters = built.emitters;
|
||||
tile.built = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Retire tiles that left the paint list long ago ───────────────────────────────
|
||||
let dead: Vec<FrameHandle> = state
|
||||
.tiles
|
||||
.iter()
|
||||
.filter(|(_, t)| frame.saturating_sub(t.last_seen) > TILE_LINGER_FRAMES)
|
||||
.map(|(h, _)| *h)
|
||||
.collect();
|
||||
for h in dead {
|
||||
if let Some(t) = state.tiles.remove(&h) {
|
||||
commands.entity(t.root).despawn();
|
||||
}
|
||||
bridge.requests.remove(&h);
|
||||
}
|
||||
|
||||
// ── 4. Pack the atlas ───────────────────────────────────────────────────────────────
|
||||
let drawing: Vec<&(FrameHandle, TileRequest, ModelPaneFrame)> = live
|
||||
.iter()
|
||||
.filter(|(h, _, _)| state.tiles.get(h).is_some_and(|t| t.built))
|
||||
.collect();
|
||||
let sizes: Vec<UVec2> = drawing.iter().map(|(_, r, _)| r.size_px).collect();
|
||||
let (cells, atlas_size) = pack(&sizes);
|
||||
if atlas_size != bridge.atlas_size || bridge.atlas.is_none() {
|
||||
if atlas_size.x > 0 {
|
||||
let image = assets
|
||||
.images
|
||||
.add(new_target_image_sized(atlas_size.x, atlas_size.y));
|
||||
for (_, mut target, mut proj, mut tf) in &mut cams {
|
||||
*target = RenderTarget::Image(image.clone().into());
|
||||
*proj = Projection::Orthographic(OrthographicProjection {
|
||||
near: 0.1,
|
||||
far: 2000.0,
|
||||
scaling_mode: ScalingMode::Fixed {
|
||||
width: atlas_size.x as f32,
|
||||
height: atlas_size.y as f32,
|
||||
},
|
||||
..OrthographicProjection::default_3d()
|
||||
});
|
||||
// The camera looks down `−Z` at the atlas plane, centred; a tile at world
|
||||
// `(x, y)` lands at texel `(x, H − y)`.
|
||||
*tf = Transform::from_xyz(
|
||||
atlas_size.x as f32 * 0.5,
|
||||
atlas_size.y as f32 * 0.5,
|
||||
1000.0,
|
||||
);
|
||||
}
|
||||
bridge.atlas = Some(image);
|
||||
}
|
||||
bridge.atlas_size = atlas_size;
|
||||
}
|
||||
bridge.cells.clear();
|
||||
let atlas_h = atlas_size.y as f32;
|
||||
|
||||
// ── 5. Place every drawing tile: cell, unit ladder, facing, play head ───────────────
|
||||
let mut hidden: Vec<Entity> = state.tiles.values().map(|t| t.root).collect();
|
||||
for (i, (handle, req, pane)) in drawing.iter().enumerate() {
|
||||
let Some(cell) = cells.get(i).copied() else {
|
||||
continue; // did not fit the capped atlas
|
||||
};
|
||||
let Some(tile) = state.tiles.get_mut(handle) else {
|
||||
continue;
|
||||
};
|
||||
hidden.retain(|&e| e != tile.root);
|
||||
bridge.cells.insert(*handle, cell);
|
||||
let Ok((mut tf, mut vis, player)) = roots.get_mut(tile.root) else {
|
||||
continue;
|
||||
};
|
||||
// The root: the cell's bottom-left in camera space, plus `SetPosition` in layout units.
|
||||
let cell_bl = Vec2::new(
|
||||
cell.origin.x as f32,
|
||||
atlas_h - (cell.origin.y + cell.size.y) as f32,
|
||||
);
|
||||
let pos = Vec2::new(req.position.x, req.position.y) * req.pos_px_per_unit;
|
||||
let depth = req.position.z * req.pos_px_per_unit;
|
||||
// `T(pos) · R(facing about WoW +Z) · S(px per unit)`, in camera space: the facing turns
|
||||
// about bevy `+Y` (WoW's `+Z` after `wow_to_bevy`), then the axis fix, then the scale.
|
||||
*tf = Transform {
|
||||
translation: Vec3::new(cell_bl.x + pos.x, cell_bl.y + pos.y, depth),
|
||||
rotation: wow_to_screen() * Quat::from_rotation_y(req.facing),
|
||||
scale: Vec3::splat(req.px_per_unit),
|
||||
};
|
||||
*vis = Visibility::Visible;
|
||||
|
||||
// The play head: the engine's cursor drives the paused player and every alpha track.
|
||||
let (armed, cursor_s, seq_slot) = match pane.play {
|
||||
Some(ph) => {
|
||||
let slot = tile.clips.get(&ph.anim_id).map(|&(_, s)| s);
|
||||
(Some(ph.anim_id), ph.cursor_ms as f32 / 1000.0, slot)
|
||||
}
|
||||
None => (None, 0.0, None),
|
||||
};
|
||||
if let Some(mut player) = player {
|
||||
if tile.armed != armed {
|
||||
player.stop_all();
|
||||
if let Some((node, _)) = armed.and_then(|id| tile.clips.get(&id)) {
|
||||
player.play(*node).pause();
|
||||
}
|
||||
tile.armed = armed;
|
||||
}
|
||||
if let Some((node, _)) = armed.and_then(|id| tile.clips.get(&id)) {
|
||||
if let Some(active) = player.animation_mut(*node) {
|
||||
active.seek_to(cursor_s);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The file slot the material tracks read: the armed sequence's, or the file's first
|
||||
// when the armed id keys no bone (its slot is still in the facts' order — the cooldown).
|
||||
let seq_slot = seq_slot.or_else(|| {
|
||||
let id = armed?;
|
||||
let facts_slot = assets
|
||||
.m2s
|
||||
.get(&tile.m2)?
|
||||
.sequences
|
||||
.iter()
|
||||
.find(|s| s.anim_id == id)
|
||||
.map(|s| s.seq_index);
|
||||
facts_slot
|
||||
});
|
||||
let gseq_s = pane.clock_ms as f64 / 1000.0;
|
||||
for part in &tile.alpha_parts {
|
||||
let a = part.anim.sample(seq_slot, cursor_s, gseq_s);
|
||||
if let Ok((mut tag, mut pvis)) = parts.get_mut(part.entity) {
|
||||
// The `A ≤ 0` cull (wow-re `m2-alpha-combine-cull`): a batch the artist keyed
|
||||
// off in this sequence is skipped, not drawn at zero.
|
||||
let want = if a > 0.0 {
|
||||
Visibility::Inherited
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
if *pvis != want {
|
||||
*pvis = want;
|
||||
}
|
||||
let bits = benilla_world::mesh_tag::with_alpha(tag.0, a);
|
||||
if tag.0 != bits {
|
||||
tag.0 = bits;
|
||||
}
|
||||
}
|
||||
}
|
||||
for &e in &tile.emitters {
|
||||
if let Ok(mut em) = emitters.get_mut(e) {
|
||||
em.set_size_scale(req.star_px_per_unit);
|
||||
}
|
||||
}
|
||||
}
|
||||
for root in hidden {
|
||||
if let Ok((_, mut vis, _)) = roots.get_mut(root) {
|
||||
if *vis != Visibility::Hidden {
|
||||
*vis = Visibility::Hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
set_camera_active(
|
||||
&mut cams,
|
||||
!bridge.cells.is_empty() && bridge.atlas.is_some(),
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)] // the system's own query, borrowed
|
||||
fn set_camera_active(
|
||||
cams: &mut Query<
|
||||
(
|
||||
&mut Camera,
|
||||
&mut RenderTarget,
|
||||
&mut Projection,
|
||||
&mut Transform,
|
||||
),
|
||||
With<TileCamera>,
|
||||
>,
|
||||
active: bool,
|
||||
) {
|
||||
for (mut cam, _, _, _) in cams {
|
||||
if cam.is_active != active {
|
||||
cam.is_active = active;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine's facts for a resident file: its sequence table and header bounds.
|
||||
fn facts_of(model: &M2Model) -> ModelFileFacts {
|
||||
ModelFileFacts {
|
||||
sequences: model
|
||||
.sequences
|
||||
.iter()
|
||||
.map(|s| SequenceFacts {
|
||||
anim_id: s.anim_id,
|
||||
duration_ms: s.duration_ms,
|
||||
looping: s.looping,
|
||||
})
|
||||
.collect(),
|
||||
bbox: model
|
||||
.bounds
|
||||
.as_ref()
|
||||
.map_or(([0.0; 3], [0.0; 3]), |b| (b.bbox_min, b.bbox_max)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Shelf-pack `sizes` (with a gutter) into the smallest power-of-two square atlas from
|
||||
/// [`ATLAS_MIN`] to [`ATLAS_MAX`] that fits; returns the cells (one per size that fit, in order)
|
||||
/// and the atlas size chosen (`0×0` for no sizes).
|
||||
fn pack(sizes: &[UVec2]) -> (Vec<Cell>, UVec2) {
|
||||
if sizes.is_empty() {
|
||||
return (Vec::new(), UVec2::ZERO);
|
||||
}
|
||||
let mut edge = ATLAS_MIN;
|
||||
loop {
|
||||
let cells = shelf_pack(sizes, edge);
|
||||
if cells.len() == sizes.len() || edge >= ATLAS_MAX {
|
||||
return (cells, UVec2::splat(edge));
|
||||
}
|
||||
edge *= 2;
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows of cells left to right, a new row when one would overflow; stops at the first size
|
||||
/// that cannot fit the remaining height (so the returned cells are a prefix of `sizes`).
|
||||
fn shelf_pack(sizes: &[UVec2], edge: u32) -> Vec<Cell> {
|
||||
let mut cells = Vec::with_capacity(sizes.len());
|
||||
let (mut x, mut y, mut row_h) = (GUTTER, GUTTER, 0u32);
|
||||
for &size in sizes {
|
||||
let (w, h) = (size.x, size.y);
|
||||
if w + 2 * GUTTER > edge || h + 2 * GUTTER > edge {
|
||||
break;
|
||||
}
|
||||
if x + w + GUTTER > edge {
|
||||
x = GUTTER;
|
||||
y += row_h + GUTTER;
|
||||
row_h = 0;
|
||||
}
|
||||
if y + h + GUTTER > edge {
|
||||
break;
|
||||
}
|
||||
cells.push(Cell {
|
||||
origin: UVec2::new(x, y),
|
||||
size,
|
||||
});
|
||||
x += w + GUTTER;
|
||||
row_h = row_h.max(h);
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
/// What [`build_tile`] made.
|
||||
struct BuiltTile {
|
||||
clips: HashMap<u16, (AnimationNodeIndex, usize)>,
|
||||
alpha_parts: Vec<AlphaPart>,
|
||||
emitters: Vec<Entity>,
|
||||
}
|
||||
|
||||
/// Spawn a file's parts, rig and emitters under `root` on the tile layer — the booth bake's
|
||||
/// recipe (`portrait::booth::spawn_booth_model`) for a file with no unit. `None` when a material
|
||||
/// is not resident yet (the caller retries next frame rather than latch a world-lit twin).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_tile(
|
||||
commands: &mut Commands,
|
||||
root: Entity,
|
||||
model: &M2Model,
|
||||
handle: &Handle<M2Model>,
|
||||
icon_tex: Option<Handle<Image>>,
|
||||
forms: &mut ModelForms,
|
||||
meshes: &mut Assets<Mesh>,
|
||||
render: &mut TileRender,
|
||||
) -> Option<BuiltTile> {
|
||||
if !render.mats.ready() {
|
||||
return None;
|
||||
}
|
||||
let light = render.rig.light.clone()?;
|
||||
let layer = render.rig.layer.clone();
|
||||
// The render forms, now (the booth/marker lanes' exception to the paced furnisher: one small
|
||||
// model, on demand).
|
||||
forms.ensure_now_rigged(handle, &model.submeshes, meshes);
|
||||
let built = forms.slices(handle);
|
||||
let (stat_forms, skin_forms) = (built.stat, built.skin.unwrap_or(&[]));
|
||||
|
||||
// Materials first — every one must be resident before anything spawns, or a retry would
|
||||
// leave half a tree behind.
|
||||
let mut part_mats: Vec<Handle<WowModelMaterial>> = Vec::with_capacity(model.submeshes.len());
|
||||
for (i, sub) in model.submeshes.iter().enumerate() {
|
||||
let texture = if sub.icon_slot {
|
||||
icon_tex.clone()
|
||||
} else {
|
||||
sub.texture.clone()
|
||||
};
|
||||
let world = render.mats.steady(sub, texture, (i + 1) as u16)?;
|
||||
// The twin: same material, the tile's black light, fog OFF (the `rig` leg forces it;
|
||||
// the shade selector it also flips is inert on an unlit batch).
|
||||
let twin = material_variant(
|
||||
&mut render.rig.variants,
|
||||
&light,
|
||||
&world,
|
||||
render.mats.materials(),
|
||||
true,
|
||||
)?;
|
||||
part_mats.push(twin);
|
||||
}
|
||||
|
||||
// The rig: the collapsed pose buffer + a palette slot, when the file has bones. The tile
|
||||
// camera is not the world camera, so bone billboards are left to the rest pose (none of the
|
||||
// shipped UI files authors one).
|
||||
let mut pose: Option<RigPose> = None;
|
||||
let mut slot: u16 = 0;
|
||||
let mut clips: HashMap<u16, (AnimationNodeIndex, usize)> = HashMap::new();
|
||||
if !model.skeleton.joints.is_empty() {
|
||||
let p = RigPose::new(root, &model.skeleton).without_camera_billboards();
|
||||
slot = RigSkin::allocate_bones(
|
||||
&mut render.palettes,
|
||||
model.skeleton.joints.len() as u32,
|
||||
model.inverse_bindposes.clone(),
|
||||
)
|
||||
.map_or(0, |rig| {
|
||||
let s = rig.slot;
|
||||
commands.entity(root).insert(rig);
|
||||
render.palettes.mark_mirrored(s);
|
||||
s
|
||||
});
|
||||
if let Some(anims) = &model.animations {
|
||||
for c in &anims.clips {
|
||||
clips.entry(c.anim_id).or_insert((c.node, c.seq_index));
|
||||
}
|
||||
// A paused player: the engine's play head seeks it every frame (2007's clock).
|
||||
let mut player = AnimationPlayer::default();
|
||||
player.stop_all();
|
||||
commands.entity(root).insert((
|
||||
player,
|
||||
AnimationGraphHandle(anims.graph.clone()),
|
||||
anims.clone(),
|
||||
));
|
||||
if let Some(drive) = GlobalSeqDrive::new_rig(&anims.global_bones, p.locals.len()) {
|
||||
commands.entity(root).insert(drive);
|
||||
}
|
||||
}
|
||||
pose = Some(p);
|
||||
}
|
||||
|
||||
// The parts.
|
||||
let mut alpha_parts = Vec::new();
|
||||
for (i, sub) in model.submeshes.iter().enumerate() {
|
||||
let use_rig = slot != 0 && skin_forms.get(i).is_some();
|
||||
let mesh = if use_rig {
|
||||
skin_forms[i].clone()
|
||||
} else {
|
||||
stat_forms
|
||||
.get(i)
|
||||
.map(|(h, _)| h.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let tag_slot = if use_rig { slot } else { 0 };
|
||||
let mut child = commands.spawn((
|
||||
Mesh3d(mesh),
|
||||
MeshMaterial3d(part_mats[i].clone()),
|
||||
MeshTag(benilla_world::mesh_tag::spawn_tag(tag_slot, 1.0)),
|
||||
Transform::IDENTITY,
|
||||
layer.clone(),
|
||||
ChildOf(root),
|
||||
// Skinned or not, a tile's part is framed by construction; there is no cull to lose.
|
||||
NoFrustumCulling,
|
||||
));
|
||||
if use_rig {
|
||||
child.insert(RigPart(root));
|
||||
}
|
||||
let entity = child.id();
|
||||
if let Some(anim) = &sub.alpha_anim {
|
||||
alpha_parts.push(AlphaPart {
|
||||
entity,
|
||||
anim: anim.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The emitters: on their bone's anchor (the collapsed rig's demand-spawned entity), or the
|
||||
// root for a boneless file; clocked by the root's player like any hosted cloud; lit by the
|
||||
// tile's buffer; sized in the tile's pixels (`set_size_scale`, written per frame).
|
||||
let mut emitters = Vec::new();
|
||||
for em in &model.emitters {
|
||||
let (owner, pivot) = match pose.as_mut() {
|
||||
Some(p) => p
|
||||
.anchor_for(commands, root, em.def.bone)
|
||||
.map_or((root, [0.0; 3]), |joint| (joint, em.bone_pivot)),
|
||||
None => (root, [0.0; 3]),
|
||||
};
|
||||
let Some(e) = spawn_emitter(
|
||||
commands,
|
||||
em,
|
||||
Transform::IDENTITY,
|
||||
EmitterFrames {
|
||||
owner: Some((owner, pivot)),
|
||||
anchor: Some(root),
|
||||
alpha: None,
|
||||
light_node: None,
|
||||
on_owner_loss: OwnerLoss::Free,
|
||||
},
|
||||
EmitClock::Host(root),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
commands.entity(e).insert((
|
||||
layer.clone(),
|
||||
ChildOf(root),
|
||||
EffectLightOverride(light.clone()),
|
||||
));
|
||||
emitters.push(e);
|
||||
}
|
||||
|
||||
if let Some(p) = pose {
|
||||
commands.entity(root).insert((p, StageRig));
|
||||
}
|
||||
// `spawn_anim_host` is the world's placement recipe (variation re-rolls, the residency
|
||||
// window); a widget arms exactly what Lua asked and nothing else, so it is not used here —
|
||||
// named so nobody reaches for it.
|
||||
let _ = spawn_anim_host;
|
||||
Some(BuiltTile {
|
||||
clips,
|
||||
alpha_parts,
|
||||
emitters,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The packer keeps every cell inside the atlas, gutters between, and answers cells in
|
||||
/// request order — the composite's UVs and the roots' placements both index by it.
|
||||
#[test]
|
||||
fn the_packer_keeps_cells_apart_and_in_order() {
|
||||
let sizes: Vec<UVec2> = (0..40).map(|_| UVec2::new(72, 72)).collect();
|
||||
let (cells, atlas) = pack(&sizes);
|
||||
assert_eq!(cells.len(), 40);
|
||||
// 6 per row at 512 (74 px each with the gutter) is 36; the 40th needs the next size.
|
||||
assert_eq!(atlas, UVec2::splat(1024));
|
||||
for (i, a) in cells.iter().enumerate() {
|
||||
assert!(a.origin.x + a.size.x <= atlas.x && a.origin.y + a.size.y <= atlas.y);
|
||||
for b in &cells[i + 1..] {
|
||||
let apart = a.origin.x + a.size.x + GUTTER <= b.origin.x
|
||||
|| b.origin.x + b.size.x + GUTTER <= a.origin.x
|
||||
|| a.origin.y + a.size.y + GUTTER <= b.origin.y
|
||||
|| b.origin.y + b.size.y + GUTTER <= a.origin.y;
|
||||
assert!(apart, "cells {i} and another overlap or touch");
|
||||
}
|
||||
}
|
||||
// Growth: a wall of big tiles needs a bigger atlas; an impossible one is capped and
|
||||
// the tail simply does not fit.
|
||||
let big: Vec<UVec2> = (0..8).map(|_| UVec2::new(400, 400)).collect();
|
||||
let (cells, atlas) = pack(&big);
|
||||
assert_eq!(cells.len(), 8);
|
||||
assert_eq!(atlas, UVec2::splat(2048));
|
||||
let huge = vec![UVec2::new(5000, 10)];
|
||||
let (cells, atlas) = pack(&huge);
|
||||
assert!(cells.is_empty());
|
||||
assert_eq!(atlas, UVec2::splat(ATLAS_MAX));
|
||||
}
|
||||
|
||||
/// The axis fix is a proper rotation that puts WoW `+X` right, `+Y` up, `+Z` toward the
|
||||
/// viewer.
|
||||
#[test]
|
||||
fn the_axis_fix_is_the_ortho_legs_frame() {
|
||||
let q = wow_to_screen();
|
||||
let wow = |v: [f32; 3]| q * benilla_assets::coords::wow_to_bevy(v);
|
||||
assert!((wow([1.0, 0.0, 0.0]) - Vec3::X).length() < 1e-6);
|
||||
assert!((wow([0.0, 1.0, 0.0]) - Vec3::Y).length() < 1e-6);
|
||||
assert!((wow([0.0, 0.0, 1.0]) - Vec3::Z).length() < 1e-6);
|
||||
// A facing of +90° about WoW +Z turns +X into +Y on screen (CCW).
|
||||
let turned = q
|
||||
* Quat::from_rotation_y(std::f32::consts::FRAC_PI_2)
|
||||
* benilla_assets::coords::wow_to_bevy([1.0, 0.0, 0.0]);
|
||||
assert!((turned - Vec3::Y).length() < 1e-5, "{turned}");
|
||||
}
|
||||
}
|
||||
@@ -357,6 +357,9 @@ fn splice_simple(eq: &benilla_ui::script::ExtractedQuad) -> bool {
|
||||
QuadContent::Frame
|
||||
| QuadContent::Cooldown { .. }
|
||||
| QuadContent::Backdrop { .. }
|
||||
// A model pane writes at most one quad and one idempotent tile request (decision 2008)
|
||||
// — and the map's arrow changes facing on every turn while the map is open.
|
||||
| QuadContent::ModelPane { .. }
|
||||
// Both colour-picker arms write one quad and nothing else — and they change on every
|
||||
// step of a drag, which is exactly the traffic the splice exists for.
|
||||
| QuadContent::ColorWheel
|
||||
@@ -615,6 +618,8 @@ pub(super) fn drive_script(
|
||||
// the atlas's bake generation, which followed the same two terms; with nothing to re-bake, the
|
||||
// honest thing to watch is the term itself.)
|
||||
let dpi = window.scale_factor();
|
||||
// The tile renderer sizes its cells in device pixels off this (decision 2008).
|
||||
booths.tiles.dpi = dpi;
|
||||
let generation = font_atlas.as_deref().map(|a| a.generation);
|
||||
let seam_moved = *last_seam != s || *last_dpi != dpi;
|
||||
if seam_moved {
|
||||
@@ -1346,10 +1351,14 @@ fn convert_entry(
|
||||
// A pane with no name, or one no window claims, draws nothing (`SetModel` panes included:
|
||||
// this engine holds their scene and renders no M2 into it). Nothing is stubbed white.
|
||||
QuadContent::ModelPane {
|
||||
handle,
|
||||
name,
|
||||
model,
|
||||
facing,
|
||||
model_scale: _,
|
||||
model_scale,
|
||||
position,
|
||||
own_alpha,
|
||||
icon,
|
||||
} => {
|
||||
use crate::portrait::PortraitSource;
|
||||
// The world-map arrow (decision 1980): the stock `WorldMapFrame.lua` /
|
||||
@@ -1390,6 +1399,58 @@ fn convert_entry(
|
||||
});
|
||||
return;
|
||||
}
|
||||
// A FILE pane (decision 2008): publish what the tile renderer needs — the pane's
|
||||
// device-pixel size and the render law's unit ladder off it — and draw the tile's
|
||||
// atlas cell when the renderer has one. The request is idempotent, so the memoized
|
||||
// conversion may re-publish it freely; which panes draw is the engine's paint list.
|
||||
if let Some(path) = model.as_deref() {
|
||||
let tiles = &mut booths.tiles;
|
||||
let dpi = tiles.dpi.max(0.01);
|
||||
let aspect = if h > 0.0 { w / h } else { 4.0 / 3.0 };
|
||||
let diag = (aspect * aspect + 1.0).sqrt();
|
||||
let layout = eq.scale;
|
||||
let size_px = UVec2::new(
|
||||
(rect.width() * dpi).round().max(1.0) as u32,
|
||||
(rect.height() * dpi).round().max(1.0) as u32,
|
||||
);
|
||||
tiles.requests.insert(
|
||||
handle,
|
||||
crate::ui_models::TileRequest {
|
||||
path: path.to_string(),
|
||||
size_px,
|
||||
// `1 model unit = 1280 · modelScale · layoutScale` FrameXML units (§3).
|
||||
px_per_unit: 1280.0 * model_scale * layout * s * dpi,
|
||||
// `SetPosition` is in layout units: `768 · √(a²+1)` FrameXML per unit.
|
||||
pos_px_per_unit: 768.0 * diag * layout * s * dpi,
|
||||
// A particle's half-extent: eye space, neither scale (§6).
|
||||
star_px_per_unit: 768.0 * diag * s * dpi,
|
||||
facing,
|
||||
position: Vec3::new(position.0, position.1, position.2),
|
||||
icon: icon.clone(),
|
||||
},
|
||||
);
|
||||
if let (Some(cell), Some(atlas)) = (tiles.cells.get(&handle), tiles.atlas.clone()) {
|
||||
let a = tiles.atlas_size.as_vec2();
|
||||
let (u0, v0) = (cell.origin.x as f32 / a.x, cell.origin.y as f32 / a.y);
|
||||
let (u1, v1) = (
|
||||
(cell.origin.x + cell.size.x) as f32 / a.x,
|
||||
(cell.origin.y + cell.size.y) as f32 / a.y,
|
||||
);
|
||||
out.push(UiQuad {
|
||||
rect,
|
||||
z_key: eq.z,
|
||||
texture: Some(atlas),
|
||||
uv: UvRect::from_tex_coords([u0, u1, v0, v1]),
|
||||
// The instance draws at the widget's OWN alpha (render law §4.4).
|
||||
color: [1.0, 1.0, 1.0, own_alpha],
|
||||
// A render target: premultiplied by construction (`UiQuad` doc).
|
||||
premultiplied: true,
|
||||
clip,
|
||||
..default()
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(slot) = name.as_deref().and_then(crate::portrait::model_pane_booth) else {
|
||||
return;
|
||||
};
|
||||
@@ -1998,6 +2059,7 @@ mod clip_plumb_tests {
|
||||
app.init_resource::<Assets<Image>>();
|
||||
app.init_resource::<PortraitImages>();
|
||||
app.init_resource::<crate::portrait::BoothPanes>();
|
||||
app.init_resource::<crate::ui_models::UiModelTiles>();
|
||||
app.init_resource::<crate::minimap::MinimapWidget>();
|
||||
app.init_resource::<crate::autocast_shine::ShineSites>();
|
||||
app.init_resource::<crate::ui_script::UiFrameCost>();
|
||||
@@ -2085,6 +2147,7 @@ mod clip_plumb_tests {
|
||||
app.init_resource::<Assets<Image>>();
|
||||
app.init_resource::<PortraitImages>();
|
||||
app.init_resource::<crate::portrait::BoothPanes>();
|
||||
app.init_resource::<crate::ui_models::UiModelTiles>();
|
||||
app.init_resource::<crate::minimap::MinimapWidget>();
|
||||
app.init_resource::<crate::autocast_shine::ShineSites>();
|
||||
app.init_resource::<crate::ui_script::UiFrameCost>();
|
||||
@@ -2292,6 +2355,7 @@ mod extract_gate_tests {
|
||||
app.init_resource::<Assets<Image>>();
|
||||
app.init_resource::<PortraitImages>();
|
||||
app.init_resource::<crate::portrait::BoothPanes>();
|
||||
app.init_resource::<crate::ui_models::UiModelTiles>();
|
||||
app.init_resource::<crate::minimap::MinimapWidget>();
|
||||
app.init_resource::<crate::autocast_shine::ShineSites>();
|
||||
app.init_resource::<crate::ui_script::UiFrameCost>();
|
||||
|
||||
@@ -445,28 +445,29 @@ fn in_flight_roll_does_not_error_and_falls_back() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The production ordering** — the one every other test in this file quietly assumes away.
|
||||
/// **The chain frame paints once and never again — which is exactly why the app holds the event.**
|
||||
///
|
||||
/// `feed_loot_rolls` drains `rolls.opened` in the *same pass* that builds the snapshot, so the model
|
||||
/// a `START_LOOT_ROLL` OnShow reads can never already contain the roll that just opened: the entry
|
||||
/// is added to `LootRolls::active` and to `opened` in one call. Every other test here calls
|
||||
/// `set_loot_rolls` **first** and so paints from a model the app would not have had yet — which is
|
||||
/// exactly how a roll dialog that shipped "green" reached the director showing a `?` icon and a
|
||||
/// blank name.
|
||||
/// This is the Lua half of decision 2010's fix (bug B371). It drives the sequence the app used to
|
||||
/// produce — `START_LOOT_ROLL` first, against a model that does not carry the roll yet — and pins
|
||||
/// that *nothing repairs it*: the snapshot landing afterwards paints no name, no icon and no
|
||||
/// decoration, for the life of the dialog. Our retired frame did repair it, from a benilla-only
|
||||
/// `UPDATE_LOOT_ROLL(rollID)`; 1838 migrated to the stock frame, which has no such seam and never
|
||||
/// listened, and 1883 removed the event.
|
||||
///
|
||||
/// What this pins now is the DEFECT, deliberately: a roll whose template lands after the frame is
|
||||
/// up keeps its `?`. Our retired frame repaired it from an `UPDATE_LOOT_ROLL(rollID)`; the stock
|
||||
/// frame has no such seam and never listened, so 1883 stopped firing an event the 1.12 client does
|
||||
/// not have. Decision 1838 carries the real fix — hold the roll until its template resolves, which
|
||||
/// is the reference's own invariant. This test goes green-to-red the day that lands, which is the
|
||||
/// point of writing it against the current behaviour rather than deleting it.
|
||||
/// The reference is in the same position and answers it upstream, in C: `0x61b310` fires
|
||||
/// `START_LOOT_ROLL` from the item-template cache's *arrival callback* (`0x61b460`) when the record
|
||||
/// is not resident. So `feed_loot_rolls` holds the event until the template is in — pinned
|
||||
/// app-side in `ui_loot_roll.rs`'s `the_roll_waits_for_its_item_template`, which is the test that
|
||||
/// fails if the hold is lost. This one is its premise: it says what that hold is protecting
|
||||
/// against, and it must keep reading blank — a repaint appearing here would mean a seam the 1.12
|
||||
/// client does not have had grown back.
|
||||
#[test]
|
||||
fn a_roll_that_opens_before_its_snapshot_stays_blank() {
|
||||
fn nothing_repaints_a_frame_that_opened_before_its_snapshot() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = setup();
|
||||
load_group_loot(&s);
|
||||
|
||||
// The app's order: the event first, against a model that has no such roll at all.
|
||||
// The order the app must never produce: the event first, against a model with no such roll.
|
||||
s.fire_event(
|
||||
"START_LOOT_ROLL",
|
||||
vec![ScriptValue::Int(7), ScriptValue::Int(42_000)],
|
||||
@@ -481,25 +482,23 @@ fn a_roll_that_opens_before_its_snapshot_stays_blank() {
|
||||
s.eval::<Option<String>>("return GroupLootFrame1Name:GetText()")
|
||||
.unwrap(),
|
||||
None,
|
||||
"nothing to paint yet — this is the state the director saw"
|
||||
"nothing to paint yet — this is the state B371 reported"
|
||||
);
|
||||
|
||||
// ...and then the snapshot carrying it lands.
|
||||
s.set_loot_rolls(rolls());
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
// **The repaint does not happen, and this is the migration's one real loss.** Our retired file
|
||||
// split the paint out of `OnShow` precisely so an `UPDATE_LOOT_ROLL` could re-enter it — an
|
||||
// event the 1.12 client does not have, which 1883 stopped firing once the stock frame proved
|
||||
// nothing listened for it. The
|
||||
// reference has no such seam, because its `GetLootRollItemInfo` reads live C state that is
|
||||
// already populated when `START_LOOT_ROLL` fires. Ours reads a pushed snapshot, and while
|
||||
// `feed_loot_rolls` does push it BEFORE firing, the item template can still be in flight.
|
||||
// **The repaint does not happen — there is no path for it.** Our retired file split the paint
|
||||
// out of `OnShow` precisely so an `UPDATE_LOOT_ROLL` could re-enter it; that is an event the
|
||||
// 1.12 client does not have, and 1883 stopped firing it once the stock frame proved nothing
|
||||
// listened. An adapter was tried before the hold and does not work either (1838): calling
|
||||
// `GroupLootFrame_OnShow()` from inside a handler repaints nothing, and the `Hide()`/`Show()`
|
||||
// round trip that does repaint only does so from a plain chunk.
|
||||
//
|
||||
// The fix is app-side ordering, not a Lua shim: hold the roll until its template resolves.
|
||||
// Decision 1838 carries that, and the engine question an adapter ran into on the way — a
|
||||
// `Hide()`/`Show()` round trip re-fires `OnShow` from a plain chunk but not from inside an
|
||||
// event handler.
|
||||
// So the ordering is the app's to get right, and it now is: `feed_loot_rolls` holds
|
||||
// `START_LOOT_ROLL` until `Items::template` answers, which is the reference's own gate
|
||||
// (decision 2010).
|
||||
assert_eq!(
|
||||
s.eval::<Option<String>>("return GroupLootFrame1Name:GetText()")
|
||||
.unwrap(),
|
||||
|
||||
@@ -350,7 +350,7 @@ fn get_ping_position_answers_two_numbers_always() {
|
||||
|
||||
// The stock lifetime, end to end: MINIMAP_PING shows the model frame and starts the 5 s
|
||||
// timer; OnUpdate re-seats it from GetPingPosition every frame; past 5 s it "fades" and
|
||||
// hides. The app's sprite follows this frame (`minimap/ping.rs`).
|
||||
// hides. The frame's own file draws in it (`crate::ui_models`, decision 2008).
|
||||
assert!(
|
||||
!s.eval::<bool>("return MiniMapPing:IsVisible()").unwrap(),
|
||||
"hidden until a ping"
|
||||
@@ -449,3 +449,173 @@ fn the_meeting_stone_icon_follows_the_queue_across_meetingstone_changed() {
|
||||
s.fire_event("MEETINGSTONE_CHANGED", vec![]);
|
||||
assert!(!vis(&s), "area 0 hides it again");
|
||||
}
|
||||
|
||||
/// **The ping's pixels are the stock `<Model>`'s own file** (decision 2008): shown by
|
||||
/// `MINIMAP_PING` with the file's facts landed, the extract publishes ONE tile request for the
|
||||
/// pane — at its device size and the render law's unit ladder — and, once the renderer has
|
||||
/// handed back a cell, draws that cell as one premultiplied quad over the pane's rect. Drives the
|
||||
/// real `drive_script` in the headless harness the clip-plumb tests use.
|
||||
#[test]
|
||||
fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() {
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
|
||||
use benilla_ui::widget::{ModelFileFacts, SequenceFacts};
|
||||
|
||||
use crate::ui_models::{Cell, UiModelTiles};
|
||||
use crate::ui_pass::UiQuads;
|
||||
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.run("function GetMinimapZoneText() return '' end")
|
||||
.unwrap();
|
||||
s.run("function PlaySound() end").unwrap();
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
|
||||
|
||||
// The file's facts, as `benilla-extract m2seq` reads them off the install: the ping's
|
||||
// `SetSequence(0)` from its OnLoad was queued behind them and replays now.
|
||||
const PING: &str = r"Interface\MiniMap\Ping\MinimapPing.mdx";
|
||||
let seq = |anim_id, duration_ms, looping| SequenceFacts {
|
||||
anim_id,
|
||||
duration_ms,
|
||||
looping,
|
||||
};
|
||||
s.set_model_facts(
|
||||
PING,
|
||||
ModelFileFacts {
|
||||
sequences: vec![seq(127, 1333, false), seq(0, 833, true), seq(1, 333, false)],
|
||||
bbox: ([0.0; 3], [0.0; 3]),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
s.visible_model_panes().is_empty(),
|
||||
"hidden until a ping: nothing to paint"
|
||||
);
|
||||
let ping_at = |s: &mut UiScript, nx: f64, ny: f64| {
|
||||
s.fire_event(
|
||||
"MINIMAP_PING",
|
||||
vec![
|
||||
benilla_ui::script::ScriptValue::Str("player".into()),
|
||||
benilla_ui::script::ScriptValue::Number(nx),
|
||||
benilla_ui::script::ScriptValue::Number(ny),
|
||||
],
|
||||
);
|
||||
};
|
||||
ping_at(&mut s, 0.25, -0.125);
|
||||
let panes = s.visible_model_panes();
|
||||
assert_eq!(panes.len(), 1, "the ping pane is on the paint list");
|
||||
let pane = panes[0];
|
||||
assert_eq!(
|
||||
pane.play.map(|p| (p.anim_id, p.cursor_ms)),
|
||||
Some((0, 0)),
|
||||
"the OnLoad's SetSequence(0) replayed over the seed: Stand, at 0"
|
||||
);
|
||||
|
||||
let mut app = App::new();
|
||||
app.insert_non_send_resource(s);
|
||||
app.init_resource::<UiQuads>();
|
||||
app.init_resource::<Assets<Image>>();
|
||||
app.init_resource::<crate::portrait::PortraitImages>();
|
||||
app.init_resource::<crate::portrait::BoothPanes>();
|
||||
app.init_resource::<UiModelTiles>();
|
||||
app.init_resource::<crate::minimap::MinimapWidget>();
|
||||
app.init_resource::<crate::autocast_shine::ShineSites>();
|
||||
app.init_resource::<crate::ui_script::UiFrameCost>();
|
||||
app.init_resource::<crate::ui_script::UiCostWanted>();
|
||||
app.init_resource::<Time>();
|
||||
app.init_resource::<Time<Real>>();
|
||||
app.init_resource::<crate::ui_script::UiClock>();
|
||||
app.init_resource::<crate::ui_script::UiScaleCvar>();
|
||||
app.world_mut().spawn((
|
||||
Window {
|
||||
resolution: UVec2::new(1024, 768).into(),
|
||||
..default()
|
||||
},
|
||||
PrimaryWindow,
|
||||
));
|
||||
app.add_systems(Update, super::extract::drive_script);
|
||||
app.update();
|
||||
|
||||
let premultiplied = |app: &App| {
|
||||
app.world()
|
||||
.resource::<UiQuads>()
|
||||
.quads
|
||||
.iter()
|
||||
.filter(|q| q.premultiplied)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
{
|
||||
let tiles = app.world().resource::<UiModelTiles>();
|
||||
let req = tiles
|
||||
.requests
|
||||
.get(&pane.handle)
|
||||
.expect("one request for the ping pane");
|
||||
assert_eq!(req.path, PING);
|
||||
// `<Size>50×50</Size>` at s = 1 (768-tall window), DPI 1: 50 device px a side.
|
||||
assert_eq!(req.size_px, UVec2::new(50, 50));
|
||||
// `scale="0.4"`: 1280 · 0.4 = 512 px per model unit — 1596's own "1 model unit at 512 px".
|
||||
assert!(
|
||||
(req.px_per_unit - 512.0).abs() < 1e-3,
|
||||
"{}",
|
||||
req.px_per_unit
|
||||
);
|
||||
// A layout unit, and a particle's unit: 768 · √((4/3)² + 1) = 1280 at 4:3.
|
||||
assert!(
|
||||
(req.pos_px_per_unit - 1280.0).abs() < 0.1,
|
||||
"{}",
|
||||
req.pos_px_per_unit
|
||||
);
|
||||
assert!(
|
||||
(req.star_px_per_unit - 1280.0).abs() < 0.1,
|
||||
"{}",
|
||||
req.star_px_per_unit
|
||||
);
|
||||
assert_eq!(req.icon, None);
|
||||
assert!(premultiplied(&app).is_empty(), "no cell yet: nothing drawn");
|
||||
}
|
||||
|
||||
// The renderer hands a cell back; a fresh ping moves the pane, so the memoized conversion
|
||||
// runs again and the arm draws the cell.
|
||||
{
|
||||
let atlas = app
|
||||
.world_mut()
|
||||
.resource_mut::<Assets<Image>>()
|
||||
.add(Image::default());
|
||||
let mut tiles = app.world_mut().resource_mut::<UiModelTiles>();
|
||||
tiles.atlas = Some(atlas);
|
||||
tiles.atlas_size = UVec2::splat(512);
|
||||
tiles.cells.insert(
|
||||
pane.handle,
|
||||
Cell {
|
||||
origin: UVec2::new(2, 2),
|
||||
size: UVec2::new(50, 50),
|
||||
},
|
||||
);
|
||||
}
|
||||
{
|
||||
let mut script = app.world_mut().non_send_resource_mut::<UiScript>();
|
||||
// The stock handler seats the frame from `GetPingPosition()`, not the event's args.
|
||||
script.set_minimap_ping((-0.125, 0.25));
|
||||
ping_at(&mut script, -0.125, 0.25);
|
||||
}
|
||||
app.update();
|
||||
let drawn = premultiplied(&app);
|
||||
assert_eq!(drawn.len(), 1, "the cell, once");
|
||||
let q = &drawn[0];
|
||||
assert!((q.rect.width() - 50.0).abs() < 1e-3 && (q.rect.height() - 50.0).abs() < 1e-3);
|
||||
let [tl, _, br, _] = q.uv.corners;
|
||||
assert!((tl[0] - 2.0 / 512.0).abs() < 1e-6 && (tl[1] - 2.0 / 512.0).abs() < 1e-6);
|
||||
assert!((br[0] - 52.0 / 512.0).abs() < 1e-6 && (br[1] - 52.0 / 512.0).abs() < 1e-6);
|
||||
assert_eq!(q.color, [1.0, 1.0, 1.0, 1.0], "the frame's own alpha");
|
||||
assert!(q.texture.is_some());
|
||||
}
|
||||
|
||||
@@ -1716,3 +1716,211 @@ fn the_middle_strip_tiles_along_its_length_only() {
|
||||
assert_eq!(wraps_of(&s, "ShapeshiftBarMiddle"), vec![(false, false)]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The director's picture (2026-09-05, decision 2009)**: a three-stance warrior with the
|
||||
/// bottom-left bar up, and a silver plate around Defensive Stance — `ShapeshiftBarMiddle` drawn
|
||||
/// alone, both end caps down.
|
||||
///
|
||||
/// The reference fires `UPDATE_SHAPESHIFT_FORMS` for exactly one thing: the form LIST changed
|
||||
/// (learn / unlearn / rank — wow-re `shapeshift-bar-api.md`, the fires at `0x4b28ff`/`0x4b2e43`).
|
||||
/// Our feed fired it for any change in the pushed view — a stance switch, a castable flip, the
|
||||
/// shared 1 s category cooldown arming and then EXPIRING. Every fire runs the stock
|
||||
/// `ShapeshiftBar_Update` (BonusActionBarFrame.lua l.170): `ShapeshiftBarMiddle:Show()`
|
||||
/// unconditionally past two forms, then `ShapeshiftBarFrame:Show()` on a frame already shown —
|
||||
/// no OnShow, so no manage pass, and the strip stays up over the raised bar (UIParent.lua
|
||||
/// l.1706-1712 is the only thing that takes it down). 2000 and 2001 each fixed a real thing in
|
||||
/// this band and neither touched this, which is the director's "no change from before".
|
||||
///
|
||||
/// Driven through the feed's own diff ([`crate::ui_shapeshift::push_forms`]), so what fails here
|
||||
/// is the feed's rule, not a hand-fired event. A form's STATE rides the reference's state events,
|
||||
/// which the feeds owning those transitions fire (`PLAYER_AURAS_CHANGED` for the form aura's
|
||||
/// slot, `SPELL_UPDATE_COOLDOWN` for the store's generation edge) — checked below by firing the
|
||||
/// one the switch really carries and reading the checked ring.
|
||||
#[test]
|
||||
fn a_forms_state_change_leaves_the_shelf_down_over_the_raised_bar() {
|
||||
use crate::ui_shapeshift::{push_forms, FormsEdge, StanceMemory};
|
||||
use benilla_ui::script::ShapeshiftFormView;
|
||||
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_action_bar(&s);
|
||||
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
|
||||
// A recorder for the two events a push may announce.
|
||||
s.run(
|
||||
"STANCE_LOG = {} local f = CreateFrame('Frame') \
|
||||
f:RegisterEvent('UPDATE_SHAPESHIFT_FORMS') f:RegisterEvent('SPELL_UPDATE_USABLE') \
|
||||
f:SetScript('OnEvent', function() table.insert(STANCE_LOG, event) end)",
|
||||
)
|
||||
.unwrap();
|
||||
let log = |s: &UiScript| {
|
||||
s.eval::<String>("local l = table.concat(STANCE_LOG, ',') STANCE_LOG = {} return l")
|
||||
.unwrap()
|
||||
};
|
||||
let shown = |s: &UiScript, region: &str| {
|
||||
s.eval::<bool>(&format!("return {region}:IsShown()"))
|
||||
.unwrap()
|
||||
};
|
||||
let shelf = [
|
||||
"ShapeshiftBarLeft",
|
||||
"ShapeshiftBarMiddle",
|
||||
"ShapeshiftBarRight",
|
||||
];
|
||||
let ring = |s: &UiScript| {
|
||||
s.eval::<f64>("return ShapeshiftButton1NormalTexture:GetWidth()")
|
||||
.unwrap()
|
||||
};
|
||||
let checked = |s: &UiScript, i: u32| {
|
||||
s.eval::<bool>(&format!("return ShapeshiftButton{i}:GetChecked() == 1"))
|
||||
.unwrap()
|
||||
};
|
||||
let stance = |id: u32, active: bool, castable: bool, cooldown| ShapeshiftFormView {
|
||||
spell_id: id,
|
||||
texture: Some(format!("Interface\\Icons\\Stance_{id}")),
|
||||
name: format!("Stance {id}"),
|
||||
active,
|
||||
castable,
|
||||
cooldown,
|
||||
};
|
||||
let mut memory = StanceMemory::default();
|
||||
|
||||
// Login: the list arrives — the reference's learn edge — with Battle Stance active.
|
||||
assert_eq!(
|
||||
push_forms(
|
||||
&mut s,
|
||||
&mut memory,
|
||||
vec![
|
||||
stance(2457, true, true, None),
|
||||
stance(71, false, true, None),
|
||||
stance(2458, false, true, None),
|
||||
],
|
||||
),
|
||||
FormsEdge::List
|
||||
);
|
||||
assert_eq!(log(&s), "UPDATE_SHAPESHIFT_FORMS");
|
||||
assert!(checked(&s, 1) && !checked(&s, 2));
|
||||
// The bottom-left bar up, the way the Options row raises it: the pass seats the stance bar a
|
||||
// row higher and takes the whole shelf down (UIParent.lua l.1706-1716).
|
||||
show_bars(&s, &[1]);
|
||||
for region in shelf {
|
||||
assert!(!shown(&s, region), "{region} is down under a raised bar");
|
||||
}
|
||||
assert_eq!(ring(&s), 50.0);
|
||||
|
||||
// A stance switch: the form byte flips to Defensive and category 47 arms for a second on all
|
||||
// three. State, not list — the reference announces it through the aura and cooldown events.
|
||||
let cd = Some((0, 1000, true));
|
||||
let edge = push_forms(
|
||||
&mut s,
|
||||
&mut memory,
|
||||
vec![
|
||||
stance(2457, false, true, cd),
|
||||
stance(71, true, true, cd),
|
||||
stance(2458, false, true, cd),
|
||||
],
|
||||
);
|
||||
for region in shelf {
|
||||
assert!(
|
||||
!shown(&s, region),
|
||||
"{region}: the director's plate — the shelf re-shown over the raised bar"
|
||||
);
|
||||
}
|
||||
assert_eq!(ring(&s), 50.0);
|
||||
assert_eq!(edge, FormsEdge::Silent);
|
||||
assert_eq!(log(&s), "", "a state move fires no list edge");
|
||||
// …and the checked ring follows on the event the switch really carries.
|
||||
assert!(
|
||||
checked(&s, 1) && !checked(&s, 2),
|
||||
"no repaint before the state event"
|
||||
);
|
||||
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
|
||||
assert!(
|
||||
!checked(&s, 1) && checked(&s, 2),
|
||||
"the aura event repaints the ring"
|
||||
);
|
||||
for region in shelf {
|
||||
assert!(
|
||||
!shown(&s, region),
|
||||
"{region}: a state repaint never touches the shelf"
|
||||
);
|
||||
}
|
||||
|
||||
// The cooldown running out is the same silent edge — the widget hides itself, the reference's
|
||||
// store fires nothing at expiry, and neither do we.
|
||||
assert_eq!(
|
||||
push_forms(
|
||||
&mut s,
|
||||
&mut memory,
|
||||
vec![
|
||||
stance(2457, false, true, None),
|
||||
stance(71, true, true, None),
|
||||
stance(2458, false, true, None),
|
||||
],
|
||||
),
|
||||
FormsEdge::Silent
|
||||
);
|
||||
assert_eq!(log(&s), "");
|
||||
assert!(!shown(&s, "ShapeshiftBarMiddle"));
|
||||
|
||||
// A castable flip is a usability move — the reference's own SPELL_UPDATE_USABLE — and the
|
||||
// stock bar greys the icon on it without touching the shelf.
|
||||
assert_eq!(
|
||||
push_forms(
|
||||
&mut s,
|
||||
&mut memory,
|
||||
vec![
|
||||
stance(2457, false, false, None),
|
||||
stance(71, true, true, None),
|
||||
stance(2458, false, true, None),
|
||||
],
|
||||
),
|
||||
FormsEdge::Usable
|
||||
);
|
||||
assert_eq!(log(&s), "SPELL_UPDATE_USABLE");
|
||||
let grey = s
|
||||
.eval::<f64>("local r = ShapeshiftButton1Icon:GetVertexColor() return r")
|
||||
.unwrap();
|
||||
assert!(
|
||||
(grey - 0.4).abs() < 1e-6,
|
||||
"not castable: the 0.4 grey, got {grey}"
|
||||
);
|
||||
assert!(!shown(&s, "ShapeshiftBarMiddle"));
|
||||
|
||||
// Nothing moved: nothing pushed, nothing fired.
|
||||
assert_eq!(
|
||||
push_forms(
|
||||
&mut s,
|
||||
&mut memory,
|
||||
vec![
|
||||
stance(2457, false, false, None),
|
||||
stance(71, true, true, None),
|
||||
stance(2458, false, true, None),
|
||||
],
|
||||
),
|
||||
FormsEdge::Unchanged
|
||||
);
|
||||
assert_eq!(log(&s), "");
|
||||
|
||||
// The control — a fourth stance LEARNED is the list edge, and the reference's own Update
|
||||
// shows the strip across the raised bar until the next pass (the sibling test pins that).
|
||||
assert_eq!(
|
||||
push_forms(
|
||||
&mut s,
|
||||
&mut memory,
|
||||
vec![
|
||||
stance(2457, false, false, None),
|
||||
stance(71, true, true, None),
|
||||
stance(2458, false, true, None),
|
||||
stance(768, false, true, None),
|
||||
],
|
||||
),
|
||||
FormsEdge::List
|
||||
);
|
||||
assert_eq!(log(&s), "UPDATE_SHAPESHIFT_FORMS");
|
||||
assert!(
|
||||
shown(&s, "ShapeshiftBarMiddle"),
|
||||
"the learn edge: Update shows the strip"
|
||||
);
|
||||
s.run("UIParent_ManageFramePositions()").unwrap();
|
||||
assert!(!shown(&s, "ShapeshiftBarMiddle"));
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -28,13 +28,34 @@
|
||||
//! `SpellShapeshiftForm.dbc` flags bit `0x2` blocks it (warrior stances: silent no-op, the
|
||||
//! `0x4b4963` guard); any other form casts through the shared [`send_spell_cast`] path.
|
||||
//!
|
||||
//! The refresh model is one diff-and-fire: the feed rebuilds the pushed list each frame
|
||||
//! (identity, active, castable, cooldown) and fires `UPDATE_SHAPESHIFT_FORMS` on any change — the
|
||||
//! real client's learn/unlearn edge (`0x5e9c20`/`0x5e9fe0`) plus its separate per-state repaint events
|
||||
//! collapse into one (our `StanceBar.xml` documented that as its deliberate divergence; since 1938 the
|
||||
//! stock shapeshift bar registers the reference's six events, and this one event drives them all). Cooldown
|
||||
//! remainders re-push only on a [`Cooldowns::generation`] change (`state.rs`'s own churn gate) —
|
||||
//! the engine extrapolates the sweep from the absolute start between pushes.
|
||||
//! **The refresh model keeps the reference's two edges apart** (decision 2009). The feed rebuilds
|
||||
//! the pushed list each frame and diffs it, and what it announces depends on WHAT moved:
|
||||
//!
|
||||
//! - **The list** — which spells sit on the bar, in what order — fires `UPDATE_SHAPESHIFT_FORMS`,
|
||||
//! the real client's learn/unlearn/rank edge (`0x5e9c20`/`0x5e9fe0`/`0x4b2f50` → event `0x183`
|
||||
//! at `0x4b28ff`/`0x4b2e43`, VERIFIED in wow-re's `shapeshift-bar-api.md`) and the ONLY thing
|
||||
//! that fires it there. The stock `ShapeshiftBar_Update` treats every fire as a list rebuild: it
|
||||
//! re-seats the shelf, shows the middle strip past two forms unconditionally, and `Show()`s a
|
||||
//! frame that is already shown — no `OnShow`, no `UIParent_ManageFramePositions`, and nothing
|
||||
//! takes the strip back down over a raised bottom-left bar. Firing it on a state change painted
|
||||
//! exactly that plate (the one-event model this file carried until 2009: our own `StanceBar.xml`
|
||||
//! had documented the collapse as a deliberate divergence, and 1938's stock bar inherited it).
|
||||
//! - **A form's state** — active, texture, cooldown — is pushed silently; the stock bar repaints
|
||||
//! state on the reference's own state events, which the feeds that own those transitions fire:
|
||||
//! `PLAYER_AURAS_CHANGED` for the form byte (a MOD_SHAPESHIFT aura holds an aura slot — the
|
||||
//! warrior stances are `Attributes 0x9050010`, not passive, so vmangos slots them — and the
|
||||
//! switch is an aura change, `ui_aura`'s edge) and `SPELL_UPDATE_COOLDOWN` for the cooldown
|
||||
//! store's generation edge (`ui_action::state`). The silent push is safe because this feed runs
|
||||
//! `.before(AuraEvents)` and `.before(CooldownEvents)`: a handler re-reads this list, so it is
|
||||
//! fresh before those events walk.
|
||||
//! - **A form's castability** fires `SPELL_UPDATE_USABLE` from here: the reference's `0x4b31c0`
|
||||
//! fires it on every usable recompute, and a form spell that is not on an action bar has no
|
||||
//! other feed watching its usability.
|
||||
//!
|
||||
//! A cooldown's natural EXPIRY changes the pushed triple (`Some` → `None`) and announces nothing —
|
||||
//! the reference's widget hides itself from `(start, duration)` and its store fires nothing at
|
||||
//! expiry either (`cooldowns.rs`); the old model fired the list edge there too, so one stance
|
||||
//! switch (category 47, 1 s) re-showed the strip three times over.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -121,13 +142,65 @@ fn form_texture(d: &benilla_formats::SpellDisplay, active: bool) -> Option<Strin
|
||||
}
|
||||
}
|
||||
|
||||
/// What the feed last pushed (the `state.rs` pattern). No cooldown-churn gate needed: the
|
||||
/// triple carries the ABSOLUTE start, which is frame-stable for a running cooldown.
|
||||
/// What the feed last pushed (the `state.rs` pattern). The triple carries the ABSOLUTE start, so
|
||||
/// a running cooldown re-derives the same view every frame; only an arm or an expiry moves it.
|
||||
#[derive(Default)]
|
||||
struct StanceMemory {
|
||||
pub(crate) struct StanceMemory {
|
||||
pushed: Option<Vec<ShapeshiftFormView>>,
|
||||
}
|
||||
|
||||
/// Which of the reference's edges one push crossed — what [`push_forms`] announced.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum FormsEdge {
|
||||
/// Nothing moved: no push, no event.
|
||||
Unchanged,
|
||||
/// The list itself (membership or order) — `UPDATE_SHAPESHIFT_FORMS`, the learn/unlearn edge.
|
||||
List,
|
||||
/// A form's usability — `SPELL_UPDATE_USABLE`.
|
||||
Usable,
|
||||
/// State the other feeds' events already announce (active, texture, cooldown) — pushed, silent.
|
||||
Silent,
|
||||
}
|
||||
|
||||
/// Push the rebuilt list and announce the edge it crossed (module docs). Split out of the system
|
||||
/// so the harness can drive the real diff against the stock bar without a Bevy world.
|
||||
pub(crate) fn push_forms(
|
||||
script: &mut UiScript,
|
||||
memory: &mut StanceMemory,
|
||||
fresh: Vec<ShapeshiftFormView>,
|
||||
) -> FormsEdge {
|
||||
let old: &[ShapeshiftFormView] = memory.pushed.as_deref().unwrap_or(&[]);
|
||||
let edge = if old == fresh.as_slice() {
|
||||
FormsEdge::Unchanged
|
||||
} else if old.len() != fresh.len()
|
||||
|| old
|
||||
.iter()
|
||||
.zip(&fresh)
|
||||
.any(|(o, n)| o.spell_id != n.spell_id)
|
||||
{
|
||||
FormsEdge::List
|
||||
} else if old
|
||||
.iter()
|
||||
.zip(&fresh)
|
||||
.any(|(o, n)| o.castable != n.castable)
|
||||
{
|
||||
FormsEdge::Usable
|
||||
} else {
|
||||
FormsEdge::Silent
|
||||
};
|
||||
if edge == FormsEdge::Unchanged {
|
||||
return edge;
|
||||
}
|
||||
memory.pushed = Some(fresh.clone());
|
||||
script.set_shapeshift_forms(fresh);
|
||||
match edge {
|
||||
FormsEdge::List => script.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]),
|
||||
FormsEdge::Usable => script.fire_event("SPELL_UPDATE_USABLE", vec![]),
|
||||
FormsEdge::Silent | FormsEdge::Unchanged => {}
|
||||
}
|
||||
edge
|
||||
}
|
||||
|
||||
pub(crate) struct UiShapeshiftPlugin;
|
||||
|
||||
impl Plugin for UiShapeshiftPlugin {
|
||||
@@ -135,9 +208,15 @@ impl Plugin for UiShapeshiftPlugin {
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
// Feed rides with the unit feed (before UiInput, like ui_action's own); the drain
|
||||
// runs after the input pass so a stance click goes out the same frame.
|
||||
feed_shapeshift_bar.in_set(UnitFeed).before(UiInput),
|
||||
// Feed rides with the unit feed (before UiInput, like ui_action's own), and
|
||||
// BEFORE the two event cuts whose handlers re-read this list — a form's state is
|
||||
// pushed silently and repainted on those events (module docs); the drain runs
|
||||
// after the input pass so a stance click goes out the same frame.
|
||||
feed_shapeshift_bar
|
||||
.in_set(UnitFeed)
|
||||
.before(UiInput)
|
||||
.before(crate::ui_action::CooldownEvents)
|
||||
.before(crate::ui_aura::AuraEvents),
|
||||
drain_shapeshift_casts.after(UiInput),
|
||||
),
|
||||
);
|
||||
@@ -236,14 +315,10 @@ fn feed_shapeshift_bar(
|
||||
})
|
||||
.collect();
|
||||
|
||||
if memory.pushed.as_ref() != Some(&fresh) {
|
||||
debug!(
|
||||
"ui_shapeshift: {} form(s), active form byte {form_byte}",
|
||||
fresh.len()
|
||||
);
|
||||
memory.pushed = Some(fresh.clone());
|
||||
script.set_shapeshift_forms(fresh);
|
||||
script.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
|
||||
let count = fresh.len();
|
||||
let edge = push_forms(&mut script, memory, fresh);
|
||||
if edge != FormsEdge::Unchanged {
|
||||
debug!("ui_shapeshift: {count} form(s), active form byte {form_byte}, {edge:?} edge");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,8 @@ mod tex_filter;
|
||||
pub use tex_filter::{publish_tex_filter, tex_filter, TexFilterSetting, ANISO_RANGE};
|
||||
mod m2;
|
||||
pub use m2::{
|
||||
EmitterBillboard, M2Model, M2ModelLoader, ModelEmitter, ModelLight, ModelRibbon, PortraitCamera,
|
||||
EmitterBillboard, M2Model, M2ModelLoader, M2SequenceInfo, ModelEmitter, ModelLight,
|
||||
ModelRibbon, PortraitCamera,
|
||||
};
|
||||
mod wmo;
|
||||
pub use benilla_formats::{WmoPortalInfo, WmoPortalRef};
|
||||
|
||||
@@ -84,6 +84,13 @@ pub struct M2Model {
|
||||
/// file-order-first one; they coincide on every effect model probed (single-sequence models).
|
||||
/// `None` for a model with no sequences at all.
|
||||
pub first_seq_span: Option<f32>,
|
||||
/// **Every sequence the file owns, in file order** — id, slot, length, loop — whatever
|
||||
/// [`Self::animations`] kept a clip for. The UI model pane's clock needs exactly this
|
||||
/// (decision 2008): which `AnimationData` ids the file answers to, how long each runs and
|
||||
/// whether it clamps — for sequences that key no bone too, which `ModelAnimations` drops
|
||||
/// (the cooldown indicator's sweep keys a constant on its one bone; its texture transforms
|
||||
/// and colour tracks are what move). Empty for a file with no sequences.
|
||||
pub sequences: Vec<M2SequenceInfo>,
|
||||
/// The model's attachment points (decision 0072 — held items): weapon/shield hand slots, sheath
|
||||
/// points, etc. Each carries the bone it rides + its bind-pose-relative offset (see
|
||||
/// [`ModelAttachment`]). Empty for a model with no attachment table (most doodads/WMO props).
|
||||
@@ -126,6 +133,20 @@ pub struct M2Model {
|
||||
pub global_flags: u32,
|
||||
}
|
||||
|
||||
/// One sequence of an M2, as the file table has it — see [`M2Model::sequences`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct M2SequenceInfo {
|
||||
/// The `AnimationData.dbc` id (`M2Sequence+0x00`).
|
||||
pub anim_id: u16,
|
||||
/// The sequence's file slot — the axis the per-sequence material bakes are keyed on
|
||||
/// ([`benilla_formats::ModelAnimation::seq_index`]).
|
||||
pub seq_index: usize,
|
||||
/// `end − start` on the file's timeline, ms.
|
||||
pub duration_ms: u32,
|
||||
/// Loops (flag bit 0 clear); a clamped sequence holds its last frame.
|
||||
pub looping: bool,
|
||||
}
|
||||
|
||||
/// An M2's authored portrait-camera rig in Bevy space (see [`M2Model::portrait_camera`]): eye/target
|
||||
/// in model-local yards at scale 1 (the client bakes portraits with root scale reset to 1), the
|
||||
/// projection scalars straight from the record.
|
||||
@@ -358,6 +379,7 @@ impl AssetLoader for M2ModelLoader {
|
||||
skin_slot: sub.skin_slot,
|
||||
geoset_id: sub.geoset_id,
|
||||
char_slot: sub.char_slot,
|
||||
icon_slot: sub.icon_slot,
|
||||
blend: sub.blend,
|
||||
two_sided: sub.two_sided,
|
||||
interior: false, // M2 has no interior/exterior group concept
|
||||
@@ -505,6 +527,15 @@ impl AssetLoader for M2ModelLoader {
|
||||
// least one sequence produced an animated clip.
|
||||
let sequences = parse_m2_animations(&bytes);
|
||||
let first_seq_span = sequences.first().map(|a| a.duration).filter(|d| *d > 0.0);
|
||||
let sequence_infos: Vec<M2SequenceInfo> = sequences
|
||||
.iter()
|
||||
.map(|a| M2SequenceInfo {
|
||||
anim_id: a.anim_id,
|
||||
seq_index: a.seq_index,
|
||||
duration_ms: a.end_ms.saturating_sub(a.start_ms),
|
||||
looping: a.looping,
|
||||
})
|
||||
.collect();
|
||||
let animations = {
|
||||
let mut graph = AnimationGraph::new();
|
||||
let root = graph.root;
|
||||
@@ -801,6 +832,7 @@ impl AssetLoader for M2ModelLoader {
|
||||
inverse_bindposes,
|
||||
animations,
|
||||
first_seq_span,
|
||||
sequences: sequence_infos,
|
||||
attachments,
|
||||
markers,
|
||||
portrait_camera,
|
||||
|
||||
@@ -84,6 +84,10 @@ pub struct ModelSubmesh {
|
||||
/// This batch is unlit (see [`benilla_formats::RenderSubmesh::emissive`]) — rendered fullbright:
|
||||
/// M2 `UNLIT (0x01)` glass/glow, or WMO `UNLIT` on an exterior-group batch. `false` otherwise.
|
||||
pub emissive: bool,
|
||||
/// This batch's texture is the **icon slot** (M2 texture type 14, see
|
||||
/// [`benilla_formats::RenderSubmesh::icon_slot`]): the spawn site fills it from
|
||||
/// `Model:ReplaceIconTexture`'s path. `false` for every other batch and all of WMO.
|
||||
pub icon_slot: bool,
|
||||
/// The WMO MOMT **SIDN** night-glow colour (see [`benilla_formats::RenderSubmesh::sidn`]) — the
|
||||
/// authored emissive RGB the shader ramps by the night fraction on lit lanes. `None` for M2.
|
||||
pub sidn: Option<[u8; 3]>,
|
||||
|
||||
@@ -72,10 +72,11 @@ struct WowFragOut {
|
||||
// Per-material model uniforms packed at binding 100 (see `WowModelExt` in terrain.rs). Light + fog + the
|
||||
// SH coeffs moved OUT to the shared global-light storage buffer (below); only the per-material draw flags
|
||||
// remain. Field order MUST match the Rust struct.
|
||||
// clutter_fade — x = full-opacity radius (yd); y = fully-gone radius (yd); w = enabled (>0.5).
|
||||
// The client draws clutter only within ~70 yd with a ramp over the last quarter;
|
||||
// we reproduce by multiplying cutout alpha by clamp((y−d)/(y−x)) so distant grass
|
||||
// erodes away through the alpha test (ground-effects.md Q4/Q10). 0 = off.
|
||||
// clutter_fade — x = plateau-end VIEW DEPTH (yd, = 0.75·far); y = ramp-zero view depth (yd, the
|
||||
// ~70 yd detail-doodad horizon `[0x867958]`); w = enabled (>0.5). The client draws
|
||||
// clutter only within that horizon, with the reference's quantised 64-texel ramp
|
||||
// over the last quarter, addressed by VIEW-SPACE DEPTH — see the fragment note
|
||||
// (2004). 0 = off.
|
||||
// model_flags — x = is_wmo (>0.5 ⇒ the WMO surface lanes); y = fade-blend twin;
|
||||
// z = interior (>0.5): a WMO interior group (with is_wmo ⇒ the INT/TRANS batch-class
|
||||
// lanes below) OR an interior M2 doodad prop (without is_wmo ⇒ lit by its folded
|
||||
@@ -679,14 +680,31 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
// Ground-clutter distance fade: multiply the cutout alpha by the camera-distance ramp BEFORE the
|
||||
// alpha test, so distant detail doodads ERODE out by `clutter_fade.y` yd (the client's ~70-yd
|
||||
// horizon — clutter's own faithful alpha-test fade). No-op where `clutter_fade.w == 0`. Distinct
|
||||
// from the world-doodad fade below; this stays before the test, that one does not.
|
||||
// Ground-clutter distance fade — the reference's stage-1 ramp, byte-exact and capture-confirmed
|
||||
// (wow-re `terrain/scratch/detail-doodad-distance-fade.md`, 2004). Three facts shape this:
|
||||
//
|
||||
// * **The coordinate is VIEW-SPACE DEPTH, not radial distance.** `0x6b2b80` texgens
|
||||
// `D3DTSS_TCI_CAMERASPACEPOSITION` (`EGxRs 0x30 = 3`, `TEXCOORDINDEX = stage | 0x20000`)
|
||||
// through `T(0,0,−52.5)·Ry(π/2)·S(1/17.5)`, giving `u = (z_eye − 52.5) / 17.5`. The fade
|
||||
// boundary is therefore a PLANE across the view, not a sphere around the camera — which is
|
||||
// what a radial `distance()` drew here until 2004, and a sphere reads as a hard ring on the
|
||||
// ground that sweeps as the player moves.
|
||||
// * **The ramp is a quantised 64-texel table, not a clean 1→0 line.** `0x6b2320` fills a 64×8
|
||||
// CLAMP/LINEAR texture, RGB white, `alpha = 4·(63 − col)`; a bilinear read across texel
|
||||
// centres is `alpha = (254 − 256·u)/255`, capped at texel 0's `252/255`. So the plateau ends
|
||||
// at `near + 0.0078·band` and the ramp reaches zero at `far − 0.0078·band`, never quite at
|
||||
// the two named radii, and near clutter tops out at 98.8 % opaque, never 100 %.
|
||||
// * **It multiplies into the alpha the CUTOUT reads.** Both stages take texenv preset 1
|
||||
// (`MODULATE` on colour AND alpha) and `ALPHAREF` is `detailDoodadAlpha` = 128, so the test
|
||||
// sees `tex0.a × ramp`: survivors blend at 0.502..0.988 (the visible opacity fade) and the
|
||||
// tuft erodes out entirely at ramp 128/255, i.e. 61.11 yd of the 70 yd horizon. That erosion
|
||||
// is the reference's own behaviour, not our artefact — the blend is what makes it read as a
|
||||
// fade rather than a cut, so what matters is that the survivors are genuinely translucent.
|
||||
if (m.clutter_fade.w > 0.5) {
|
||||
let d = distance(view.world_position.xyz, in.world_position.xyz);
|
||||
let f = clamp((m.clutter_fade.y - d) / max(m.clutter_fade.y - m.clutter_fade.x, 0.001), 0.0, 1.0);
|
||||
base_color.a = base_color.a * f;
|
||||
let z_eye = -(view.view_from_world * vec4<f32>(in.world_position.xyz, 1.0)).z;
|
||||
let u = (z_eye - m.clutter_fade.x) / max(m.clutter_fade.y - m.clutter_fade.x, 0.001);
|
||||
let ramp = clamp((254.0 - 256.0 * u) / 255.0, 0.0, 252.0 / 255.0);
|
||||
base_color.a = base_color.a * ramp;
|
||||
}
|
||||
|
||||
// Faithful per-object WORLD-DOODAD distance fade (`FUN_00683f80`/`model_fade.rs`): the fade alpha
|
||||
|
||||
@@ -654,6 +654,7 @@ impl AssetLoader for WmoModelLoader {
|
||||
skin_slot: sub.skin_slot, // always None for WMO groups (no creature skins)
|
||||
geoset_id: 0, // WMO has no M2 geoset concept
|
||||
char_slot: None, // WMO is never a character body
|
||||
icon_slot: false, // M2-only (texture type 14)
|
||||
blend: sub.blend,
|
||||
two_sided: sub.two_sided,
|
||||
interior: sub.interior,
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Dump the **render state and texture alpha profile** of every ground-clutter (detail-doodad) model
|
||||
//! the `GroundEffect*` catalog resolves — the inputs benilla's clutter draw derives its material from.
|
||||
//!
|
||||
//! `cargo run -p benilla-formats --example clutter_state`
|
||||
//!
|
||||
//! Two questions it answers, both load-bearing for the ~70 yd distance fade:
|
||||
//! * what **blend mode / two-sidedness** each detail M2's batches author — the reference's
|
||||
//! detail-doodad pass (`0x6b2b80`) ignores these and forces one state for every tuft, so a
|
||||
//! per-batch value benilla passes through is a divergence to see, not to assume;
|
||||
//! * how each detail texture's **alpha is distributed per mip level** — a cutout whose alpha is
|
||||
//! binary (0/255) snaps out of an alpha test all at once, while a smoothly-distributed one
|
||||
//! erodes gradually. The fade multiplies this alpha, so the distribution *is* the fade's shape.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
|
||||
let mut chain = benilla_formats::open_chain(&data)?;
|
||||
let catalog = benilla_formats::load_ground_effect_catalog(&mut chain, true)?;
|
||||
|
||||
// Every distinct model the catalog can place. `GroundEffectCatalog` exposes rows by id only, so
|
||||
// sweep the id space the DBC uses (ids are small and dense; a miss costs one hash lookup).
|
||||
let mut models: BTreeSet<String> = BTreeSet::new();
|
||||
for id in 0..4096u32 {
|
||||
if let Some(e) = catalog.effect(id) {
|
||||
models.extend(e.models().into_iter().map(str::to_string));
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{} effect rows, {} distinct detail models",
|
||||
catalog.len(),
|
||||
models.len()
|
||||
);
|
||||
|
||||
let mut textures: BTreeMap<String, ()> = BTreeMap::new();
|
||||
let mut blends: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut two_sided = (0usize, 0usize);
|
||||
let mut densities: Vec<f32> = Vec::new();
|
||||
for path in &models {
|
||||
let Ok(bytes) = chain.read_file(path) else {
|
||||
println!(" MISSING {path}");
|
||||
continue;
|
||||
};
|
||||
let subs = match benilla_formats::parse_m2_render_submeshes(&bytes, "", &[]) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
println!(" UNPARSED {path}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for s in &subs {
|
||||
*blends.entry(format!("{:?}", s.blend)).or_default() += 1;
|
||||
if s.two_sided {
|
||||
two_sided.0 += 1;
|
||||
} else {
|
||||
two_sided.1 += 1;
|
||||
}
|
||||
if let Some(t) = s.texture.as_deref() {
|
||||
textures.insert(t.to_string(), ());
|
||||
}
|
||||
if let Some(dens) = texel_density(s, 256.0) {
|
||||
densities.push(dens);
|
||||
}
|
||||
println!(
|
||||
" {path}: blend={:?} two_sided={} wrap=({},{}) tex={}",
|
||||
s.blend,
|
||||
s.two_sided,
|
||||
s.wrap_x,
|
||||
s.wrap_y,
|
||||
s.texture.as_deref().unwrap_or("<none>")
|
||||
);
|
||||
}
|
||||
}
|
||||
println!("\nbatch blend modes: {blends:?}");
|
||||
// Screen-space mip level per distance: the vertical FOV benilla's camera uses (Bevy's
|
||||
// `PerspectiveProjection` default, ~= the reference's 44.1 deg) over a 1080-tall viewport.
|
||||
let fov: f32 = std::f32::consts::PI / 4.0;
|
||||
let px_per_yard = |d: f32| (1080.0 / 2.0) / ((fov / 2.0).tan() * d);
|
||||
densities.sort_by(f32::total_cmp);
|
||||
if !densities.is_empty() {
|
||||
let med = densities[densities.len() / 2];
|
||||
println!(
|
||||
"\ntexel density over {} batches: min {:.1} / median {med:.1} / max {:.1} texels-per-yard",
|
||||
densities.len(),
|
||||
densities[0],
|
||||
densities[densities.len() - 1]
|
||||
);
|
||||
for d in [10.0f32, 20.0, 30.0, 40.0, 52.5, 61.2, 70.0] {
|
||||
let lod = (med / px_per_yard(d)).log2().max(0.0);
|
||||
println!(
|
||||
" d={d:5.1} yd: {:6.1} px/yd -> mip {lod:.2}",
|
||||
px_per_yard(d)
|
||||
);
|
||||
}
|
||||
}
|
||||
println!("two_sided: {} yes / {} no", two_sided.0, two_sided.1);
|
||||
|
||||
println!("\n=== texture alpha per mip (share of texels in each band) ===");
|
||||
for tex in textures.keys() {
|
||||
let chainx = match benilla_formats::read_texture_mip_chain(&mut chain, tex) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
println!(" {tex}: unreadable ({e})");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !chainx.is_rgba8() {
|
||||
println!(" {tex}: block-compressed levels (not CPU-readable here)");
|
||||
continue;
|
||||
}
|
||||
println!(
|
||||
" {tex} ({}x{}, {} mips)",
|
||||
chainx.width,
|
||||
chainx.height,
|
||||
chainx.mips.len()
|
||||
);
|
||||
for (i, mip) in chainx.mips.iter().enumerate() {
|
||||
let (w, h) = chainx.mip_size(i as u32);
|
||||
let n = mip.len() / 4;
|
||||
let (mut zero, mut low, mut mid, mut high, mut full) = (0u32, 0u32, 0u32, 0u32, 0u32);
|
||||
for px in mip.chunks_exact(4) {
|
||||
match px[3] {
|
||||
0 => zero += 1,
|
||||
1..=63 => low += 1,
|
||||
64..=191 => mid += 1,
|
||||
192..=254 => high += 1,
|
||||
255 => full += 1,
|
||||
}
|
||||
}
|
||||
let p = |v: u32| 100.0 * f64::from(v) / n.max(1) as f64;
|
||||
println!(
|
||||
" mip{i} {w:3}x{h:3}: a=0 {:5.1}% 1-63 {:5.1}% 64-191 {:5.1}% 192-254 {:5.1}% 255 {:5.1}%",
|
||||
p(zero), p(low), p(mid), p(high), p(full)
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Texels-per-yard for a batch: the atlas footprint its UVs cover divided by the world size its
|
||||
/// geometry covers. With the screen-space pixels-per-yard at a distance this gives the mip level the
|
||||
/// GPU selects there — and the mip level decides the alpha the cutout test sees.
|
||||
fn texel_density(sub: &benilla_formats::RenderSubmesh, atlas: f32) -> Option<f32> {
|
||||
if sub.positions.is_empty() || sub.uvs.len() != sub.positions.len() {
|
||||
return None;
|
||||
}
|
||||
let span = |it: &mut dyn Iterator<Item = f32>| {
|
||||
let (mut lo, mut hi) = (f32::MAX, f32::MIN);
|
||||
for v in it {
|
||||
lo = lo.min(v);
|
||||
hi = hi.max(v);
|
||||
}
|
||||
hi - lo
|
||||
};
|
||||
// The tuft's tallest axis in world space (WoW +Z is up) against the same axis in UV (V).
|
||||
let world = span(&mut sub.positions.iter().map(|p| p[2]));
|
||||
let uv = span(&mut sub.uvs.iter().map(|t| t[1]));
|
||||
(world > 1e-3).then(|| uv * atlas / world)
|
||||
}
|
||||
@@ -39,7 +39,9 @@ fn resolve_texture(
|
||||
tex: &benilla_m2::M2Texture,
|
||||
dir: &str,
|
||||
skins: &[Option<String>],
|
||||
) -> (Option<String>, Option<u8>, Option<CharSkinSlot>) {
|
||||
) -> (Option<String>, Option<u8>, Option<CharSkinSlot>, bool) {
|
||||
// Texture type 14 is the ICON slot — `ReplaceIconTexture`'s target (decision 2008).
|
||||
let icon_slot = matches!(tex.texture_type, M2TextureType::Other(14));
|
||||
let embedded = {
|
||||
let f = tex.filename.string.to_string_lossy();
|
||||
(!f.is_empty()).then(|| f.into_owned())
|
||||
@@ -65,10 +67,10 @@ fn resolve_texture(
|
||||
// Skin slots resolve to the variation if supplied, else the (usually empty) embedded name; the slot
|
||||
// is reported so a skin-less load can fill it at spawn.
|
||||
match tex.texture_type {
|
||||
M2TextureType::Monster1 => (variation(0).or(embedded), Some(0), None),
|
||||
M2TextureType::Monster2 => (variation(1).or(embedded), Some(1), None),
|
||||
M2TextureType::Monster3 => (variation(2).or(embedded), Some(2), None),
|
||||
_ => (embedded, None, char_slot),
|
||||
M2TextureType::Monster1 => (variation(0).or(embedded), Some(0), None, false),
|
||||
M2TextureType::Monster2 => (variation(1).or(embedded), Some(1), None, false),
|
||||
M2TextureType::Monster3 => (variation(2).or(embedded), Some(2), None, false),
|
||||
_ => (embedded, None, char_slot, icon_slot),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,9 +453,9 @@ pub fn parse_m2_render_submeshes(
|
||||
.and_then(|&ti| model.textures.get(ti as usize));
|
||||
// The record's address mode (`flags & 0x1/0x2`). Absent record ⇒ repeat, the old default.
|
||||
let (wrap_x, wrap_y) = tex_record.map_or((true, true), |t| (t.wrap_x, t.wrap_y));
|
||||
let (texture, skin_slot, char_slot) = tex_record
|
||||
let (texture, skin_slot, char_slot, icon_slot) = tex_record
|
||||
.map(|t| resolve_texture(t, dir, skins))
|
||||
.unwrap_or((None, None, None));
|
||||
.unwrap_or((None, None, None, false));
|
||||
let material = model.materials.get(batch.material_index as usize);
|
||||
let blend = match material.map(|m| m.blend_mode.bits()) {
|
||||
Some(0) | None => ModelBlend::Opaque,
|
||||
@@ -692,6 +694,7 @@ pub fn parse_m2_render_submeshes(
|
||||
sub.wrap_x = wrap_x; // texture record flags 0x1/0x2 — clamp is a silhouette decision
|
||||
sub.wrap_y = wrap_y;
|
||||
sub.char_slot = char_slot; // character runtime slot (body/hair) — filled per-player at spawn
|
||||
sub.icon_slot = icon_slot; // texture type 14 — `ReplaceIconTexture`'s slot (decision 2008)
|
||||
// Skeletal skin binding (decision 0019), per local vertex in `globals` order: the M2
|
||||
// vertex's 4 bone indices (global bone-array indices → joint indices directly) + their
|
||||
// normalised weights. The skinned-mesh builder uploads these; the static mesh ignores them.
|
||||
|
||||
@@ -84,6 +84,7 @@ fn remap_submesh(
|
||||
skin_slot: None,
|
||||
geoset_id: 0, // set by the caller per batch (the M2 path; WMO leaves it 0)
|
||||
char_slot: None, // set by the caller per batch (M2 only)
|
||||
icon_slot: false, // set by the caller per batch (M2 texture type 14)
|
||||
blend,
|
||||
// Repeat is the pre-0763 behaviour and stays WMO's; the M2 batch loop overrides both
|
||||
// from the texture record's own flags.
|
||||
|
||||
@@ -429,6 +429,12 @@ pub struct RenderSubmesh {
|
||||
/// while the interior drawer IGNORES the flag: lit/unlit there is dictated by the batch section
|
||||
/// alone ([`Self::wmo_batch`]). Byte law: wow-re `wmo-lit-selector` §1.2/§1.3.
|
||||
pub emissive: bool,
|
||||
/// This batch's texture record is **type 14 — the icon slot**: no file of its own, filled at
|
||||
/// runtime by `Model:ReplaceIconTexture` (`0x710ec0` swaps every type-14 handle on the
|
||||
/// instance). The shipped user is `Interface\ItemAnimations\ForcedBackpackItem.m2`, the
|
||||
/// bag buttons' item-push card, whose one batch is the pushed item's icon (decision 2008).
|
||||
/// `false` for every other batch and all of WMO.
|
||||
pub icon_slot: bool,
|
||||
/// The MOMT **SIDN** (`0x10` — self-illum day/night) authored colour, RGB gamma bytes: the
|
||||
/// windows-glow-at-night mechanism. The real client scales it per frame by the night fraction
|
||||
/// (1 overnight, 0 all day, ramping 20:30→21:30 and 06:00→07:00) and binds it as the GL material
|
||||
@@ -598,6 +604,7 @@ impl Default for RenderSubmesh {
|
||||
vertex_colors: Vec::new(),
|
||||
interior: false,
|
||||
emissive: false,
|
||||
icon_slot: false,
|
||||
sidn: None,
|
||||
window: false,
|
||||
additive: false,
|
||||
|
||||
@@ -103,6 +103,7 @@ fn the_shape_ignores_edge_on_correct_and_degenerate_batches() {
|
||||
vertex_colors: vec![],
|
||||
interior: false,
|
||||
emissive: false,
|
||||
icon_slot: false,
|
||||
sidn: None,
|
||||
window: false,
|
||||
additive: false,
|
||||
|
||||
@@ -24,6 +24,43 @@ impl Loader<'_> {
|
||||
self.call(wrapper, "SetAlpha", a, dbg);
|
||||
}
|
||||
}
|
||||
// `scale=` on a MODEL pane is the model's own scale, not the frame's: `CSimpleModel::
|
||||
// LoadXML` (`0x76cac0`) writes it into `+0x3a0` at `76cb61` — the field `SetModelScale`
|
||||
// writes — and raises `Frame %s: Invalid model scale: %s` at `76cb92` for `≤ 0` (a raise,
|
||||
// not a clamp; wow-re `modelframe-render-law.md` §2). Every `scale=` in the shipped
|
||||
// FrameXML sits on a model pane (the cooldown indicator's 0.75, the autocast shine's
|
||||
// 1.2/1.22, the pings' 0.4, the dressing room's 2.0), and until decision 2007 the loader
|
||||
// read none of them. Whether the generic frame loader (`0x769820`) reads a `scale`
|
||||
// attribute of its own is not carved; a plain frame's `scale=` is left as it was.
|
||||
//
|
||||
// Gated on the kind the element MATERIALIZES as, not on its tag: a `<Model>` playing the
|
||||
// cooldown indicator is this engine's `Cooldown` widget (`frame_kind_of`), which has no
|
||||
// model scale — and the shipped template carries exactly that pairing (`scale="0.75"`
|
||||
// on the cooldown file), applied through every action and bag button that inherits it.
|
||||
// `file=` on a model pane is `SetModel` (`CSimpleModel::LoadXML` `0x76cac0` installs the
|
||||
// file into the widget, resident or streaming — decision 2013). Until 2013 no XML-declared
|
||||
// pane ever held a file: the loader read `file=` for the cooldown mapping alone, and the
|
||||
// pings, the shine and the item card were bare panes to the engine. Gated on the
|
||||
// materialized kind like `scale=` below: a `<Model>` playing the cooldown indicator is
|
||||
// this engine's `Cooldown` widget, which has no file.
|
||||
let model_kind = super::model_kind_tag(&super::frame_kind_of(el));
|
||||
if model_kind {
|
||||
if let Some(file) = el.attr("file") {
|
||||
let text = self.resolve_text(file, dbg);
|
||||
self.call(wrapper, "SetModel", text, dbg);
|
||||
}
|
||||
}
|
||||
if let Some(scale) = el.attr("scale") {
|
||||
if model_kind {
|
||||
match scale.trim().parse::<f32>() {
|
||||
Ok(s) if s > 0.0 => self.call(wrapper, "SetModelScale", s, dbg),
|
||||
_ => self.warn_once(
|
||||
&format!("model-scale:{dbg}"),
|
||||
format!("Frame {dbg}: Invalid model scale: {scale}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(id) = el.attr("id") {
|
||||
if let Ok(n) = id.parse::<i64>() {
|
||||
self.call(wrapper, "SetID", n, dbg);
|
||||
|
||||
@@ -315,6 +315,15 @@ pub fn join_ref(base: &str, path: &str) -> String {
|
||||
/// Without this, decision 1751's bag swap silently loses the bag-slot cooldown sweep: our own
|
||||
/// `BenillaBagSlotTemplate` carried a real `<Cooldown>` child, the reference's carries a `<Model>`,
|
||||
/// and `CooldownFrame_SetTimer`'s Model branch shows and hides the frame and nothing more.
|
||||
/// Is `tag` one of the four model-pane kinds — the `CSimpleModel` family, whose own `LoadXML`
|
||||
/// (`0x76cac0`) reads the `scale=` attribute into the MODEL scale (`geometry.rs`'s
|
||||
/// `apply_attrs`, decision 2007).
|
||||
pub(super) fn model_kind_tag(tag: &str) -> bool {
|
||||
["Model", "PlayerModel", "DressUpModel", "TabardModel"]
|
||||
.iter()
|
||||
.any(|k| k.eq_ignore_ascii_case(tag))
|
||||
}
|
||||
|
||||
fn frame_kind_of(el: &Element) -> String {
|
||||
const COOLDOWN_MODEL: &str = "ui-cooldown-indicator.mdx";
|
||||
if el.tag.eq_ignore_ascii_case("Model")
|
||||
|
||||
@@ -132,10 +132,14 @@ impl UiScript {
|
||||
// because the client's `CGCharacterModelBase` extends `CSimpleModel`, and
|
||||
// both draw the same way here.
|
||||
Some(crate::widget::KindState::Model(m)) => QuadContent::ModelPane {
|
||||
handle: fh,
|
||||
name: frame.and_then(|f| f.name.clone()),
|
||||
model: m.path.clone(),
|
||||
facing: m.facing,
|
||||
model_scale: m.scale,
|
||||
position: m.position,
|
||||
own_alpha: frame.map_or(1.0, |f| f.alpha),
|
||||
icon: m.icon.clone(),
|
||||
},
|
||||
_ => QuadContent::Frame,
|
||||
};
|
||||
|
||||
@@ -202,6 +202,7 @@ pub use guild::{
|
||||
GuildMemberInfo, GuildRankEdit, GuildRankInfo, GuildRequest, GuildState, LastOnline, UnitGuild,
|
||||
MAX_RANKS, MIN_RANKS, RANK_RIGHT_BITS,
|
||||
};
|
||||
pub use modelframe::ModelPaneFrame;
|
||||
pub use petition::{
|
||||
validate_guild_name, PetitionRecordView, PetitionRequest, PetitionState, PETITION_TYPE_CHARTER,
|
||||
PETITION_TYPE_PETITION,
|
||||
@@ -447,10 +448,15 @@ pub const SCREEN: crate::layout::Handle = 0;
|
||||
/// corpus call sites across 91 addons); it removes working behaviour from the 23 sites that remain,
|
||||
/// so it is still a change to make deliberately rather than as a side effect of widening this list
|
||||
/// — but the FrameXML half of "with FrameXML fixed first" is most of the way there now.
|
||||
const SCRIPT_KINDS: [&str; 35] = [
|
||||
const SCRIPT_KINDS: [&str; 37] = [
|
||||
"OnLoad",
|
||||
"OnEvent",
|
||||
"OnUpdate",
|
||||
// The model pane's two: fired by the tick's model pass — `OnUpdateModel` at the top of every
|
||||
// paint of a visible pane, `OnAnimFinished` when a clamped sequence completes (decision 2007;
|
||||
// `object::events_regions::set_script`'s doc has the sites).
|
||||
"OnUpdateModel",
|
||||
"OnAnimFinished",
|
||||
"OnShow",
|
||||
"OnHide",
|
||||
"OnClick",
|
||||
|
||||
@@ -324,6 +324,19 @@ pub(crate) struct Model {
|
||||
/// resolve at a 3,988-frame roster, which made it the biggest phase left in the preamble
|
||||
/// (decision 1634's `[layout-pre] watched=`).
|
||||
pub(crate) on_size_changed_frames: Vec<FrameHandle>,
|
||||
/// The frames carrying an `OnUpdateModel` script — the list the tick fires at the top of
|
||||
/// every paint of a visible model pane (`0x76d1a0`'s first act; decision 2007). Same one
|
||||
/// writer as the two lists above.
|
||||
pub(crate) on_update_model_frames: Vec<FrameHandle>,
|
||||
/// What the engine knows about each model **file** a pane has named — the sequences and
|
||||
/// bounds a pane's clock needs ([`crate::widget::ModelFileFacts`]), handed over by the host
|
||||
/// once the asset is resident, keyed by [`crate::widget::model_key`]. The reference reads
|
||||
/// these off the loaded `MD20`; this engine parses no M2, so the facts arrive from the app's
|
||||
/// loader through `UiScript::set_model_facts`.
|
||||
pub(crate) model_facts: HashMap<String, std::sync::Arc<crate::widget::ModelFileFacts>>,
|
||||
/// Model files a pane named that no facts have arrived for yet — drained by the host
|
||||
/// (`UiScript::model_facts_wanted`) to load them. Deduplicated on push.
|
||||
pub(crate) model_facts_wanted: Vec<String>,
|
||||
/// Edit boxes whose text changed and whose `OnTextChanged` has **not fired yet** — the
|
||||
/// reference's `textChanged` dirty bit (`[E+0x31c]` bit 0), which `SetText`/`Insert` raise and
|
||||
/// only the dirty-word drain `0x77d3e0` clears (decision 1831).
|
||||
@@ -1749,6 +1762,9 @@ impl Model {
|
||||
scripts: HashMap::new(),
|
||||
on_update_frames: Vec::new(),
|
||||
on_size_changed_frames: Vec::new(),
|
||||
on_update_model_frames: Vec::new(),
|
||||
model_facts: HashMap::new(),
|
||||
model_facts_wanted: Vec::new(),
|
||||
dirty_editboxes: Vec::new(),
|
||||
event_to_frames: HashMap::new(),
|
||||
frame_events: HashMap::new(),
|
||||
|
||||
@@ -59,13 +59,26 @@
|
||||
//! `base + 8*i`, and settle "which table owns method M" by counting image-wide dword references to
|
||||
//! M's name VA.
|
||||
//!
|
||||
//! ## The clock is the engine's; the pixels are the host's (decision 2007)
|
||||
//!
|
||||
//! A pane's animation state is not "a sequence index the host interprets": the reference widget
|
||||
//! owns a private `CM2Scene` whose clock its own `OnUpdate` advances, arms sequences through
|
||||
//! `0x7121a0` with an anchor the sampler re-reads, fires `OnUpdateModel` at the top of every
|
||||
//! paint and `OnAnimFinished` from the completion callback (wow-re
|
||||
//! `ui/scratch/modelframe-render-law.md` §4). All of that is Lua-observable — the shipped
|
||||
//! cooldown is nothing but those two handlers scrubbing `SetSequenceTime` — so it lives here:
|
||||
//! [`ModelState::clock_ms`] / [`ModelState::armed`], the tick's model pass, and the bindings
|
||||
//! below. What the engine does NOT have is the file: which ids it owns, how long each sequence
|
||||
//! runs, whether it loops. Those are the file's **facts** ([`crate::widget::ModelFileFacts`]),
|
||||
//! handed over by the host once the asset is resident ([`crate::script::UiScript::set_model_facts`])
|
||||
//! — the reference's own "asset ready" edge, on which the loader's Stand seed runs.
|
||||
//!
|
||||
//! ## What is deliberately NOT here
|
||||
//!
|
||||
//! Seven of `Model`'s own 23 — `AdvanceTime 0x76eca0`, `ReplaceIconTexture 0x76ed70`,
|
||||
//! `SetFogNear 0x76f1e0`, `GetFogNear 0x76f2d0`, `SetFogFar 0x76f390`, `GetFogFar 0x76f480`,
|
||||
//! `ClearFog 0x76f540`. No corpus caller, and their bodies are uncarved: `ClearFog`'s exact effect
|
||||
//! on the colour/near/far triple is a guess until someone reads it, and a guessed clear reads as
|
||||
//! knowledge. Named, not stubbed.
|
||||
//! Five of `Model`'s own 23 — `SetFogNear 0x76f1e0`, `GetFogNear 0x76f2d0`, `SetFogFar 0x76f390`,
|
||||
//! `GetFogFar 0x76f480`, `ClearFog 0x76f540`. No corpus caller, and their bodies are uncarved:
|
||||
//! `ClearFog`'s exact effect on the colour/near/far triple is a guess until someone reads it, and
|
||||
//! a guessed clear reads as knowledge. Named, not stubbed.
|
||||
//!
|
||||
//! Also absent, and correctly so: `SetCreature` and `SetCustomRace`. Neither string exists in
|
||||
//! 5875 in any form (substring scan of the whole mapped image returns 0, against a positive
|
||||
@@ -75,11 +88,41 @@
|
||||
//! And any interpretation of `SetLight`'s numbers — the engine core has no lighting model, so the
|
||||
//! tuple is stored verbatim rather than typed into a scene semantics nobody has verified.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use mlua::{Lua, MultiValue, Table, Value};
|
||||
|
||||
use super::object::frame_handle_of;
|
||||
use super::Model;
|
||||
use crate::widget::{KindState, ModelState};
|
||||
use crate::widget::{model_key, KindState, ModelFileFacts, ModelState};
|
||||
|
||||
impl Model {
|
||||
/// The facts the host has handed over for `path`, or `None` — in which case the path is
|
||||
/// queued for the host to load ([`crate::script::UiScript::model_facts_wanted`]), once.
|
||||
pub(crate) fn model_facts_for(&mut self, path: &str) -> Option<Arc<ModelFileFacts>> {
|
||||
let key = model_key(path);
|
||||
match self.model_facts.get(&key) {
|
||||
Some(facts) => Some(facts.clone()),
|
||||
None => {
|
||||
if !self.model_facts_wanted.contains(&key) {
|
||||
self.model_facts_wanted.push(key);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The facts of the file `this` pane currently holds (`None` for an empty pane or a file the
|
||||
/// host has not loaded yet).
|
||||
fn facts_of_pane(lua: &Lua, this: &Table) -> mlua::Result<Option<Arc<ModelFileFacts>>> {
|
||||
let path = with_model(lua, this, |m| m.path.clone())?;
|
||||
Ok(path.and_then(|p| {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.model_facts_for(&p)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Registry key of the `Model` method table (the MAXCSTACK discipline: Lua-side root, named key).
|
||||
pub(super) const REG_MODEL_METHODS: &str = "__benilla_model_methods";
|
||||
@@ -186,10 +229,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let path = string_arg(&path)
|
||||
.ok_or_else(|| usage(lua, &this, "SetModel(\"file\")"))?
|
||||
.to_string();
|
||||
with_model(lua, &this, |m| {
|
||||
m.path = Some(path);
|
||||
m.unit = None;
|
||||
})
|
||||
// The file's facts, if the host has handed them over — the reference's "asset
|
||||
// resident" test (`71d5a3`), which decides whether the loader's Stand seed runs
|
||||
// now or when the file lands ([`ModelState::set_file`]).
|
||||
let facts = lua
|
||||
.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.model_facts_for(&path);
|
||||
with_model(lua, &this, |m| m.set_file(path, facts.as_deref()))
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
@@ -198,37 +245,30 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
m.set(
|
||||
"ClearModel",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
with_model(lua, &this, |m| {
|
||||
m.path = None;
|
||||
m.unit = None;
|
||||
m.sequence_time = None;
|
||||
})
|
||||
})?,
|
||||
lua.create_function(|lua, this: Table| with_model(lua, &this, ModelState::clear_file))?,
|
||||
)?;
|
||||
// ── Animation ───────────────────────────────────────────────────────────────────────────
|
||||
// ── Animation (decision 2007) ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Both verbs are the same arm, `0x7121a0(model, -1, id, 0, ms, 1.0f, 0, 1)` — `SetSequence`
|
||||
// with `ms = 0` (`0x76dec0` → `0x76cf50`), `SetSequenceTime` with the caller's `ms`
|
||||
// (`0x76dfc0` → `0x76cf80`). The arm interrupts whatever plays, resolves the id through the
|
||||
// file's `animationLookup`, and bakes the anchor `cursor_lo = sceneClock − trunc(ms)` that
|
||||
// the sampler re-reads every frame (render law §4.2). The id is an `AnimationData` id, not a
|
||||
// file slot; one the file does not own stops the playing track and arms nothing.
|
||||
m.set(
|
||||
"SetSequence",
|
||||
lua.create_function(|lua, (this, seq): (Table, Value)| {
|
||||
let seq = int(&seq);
|
||||
with_model(lua, &this, |m| {
|
||||
m.sequence = seq;
|
||||
// A fresh sequence starts unscrubbed: `SetSequenceTime` is a scrub INTO the
|
||||
// current sequence, so carrying the old pair across a change would park the new
|
||||
// animation at a time that belongs to the previous one. The cooldown indicator
|
||||
// drives exactly this pair every frame and is the reason to get it right.
|
||||
m.sequence_time = None;
|
||||
})
|
||||
let facts = facts_of_pane(lua, &this)?;
|
||||
with_model(lua, &this, |m| m.arm(seq, 0, facts.as_deref()))
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"SetSequenceTime",
|
||||
lua.create_function(|lua, (this, seq, ms): (Table, Value, Value)| {
|
||||
let pair = (int(&seq), int(&ms));
|
||||
with_model(lua, &this, |m| {
|
||||
m.sequence = pair.0;
|
||||
m.sequence_time = Some(pair);
|
||||
})
|
||||
let (seq, ms) = (int(&seq), int(&ms));
|
||||
let facts = facts_of_pane(lua, &this)?;
|
||||
with_model(lua, &this, |m| m.arm(seq, i64::from(ms), facts.as_deref()))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -361,23 +401,29 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// no unit, and stores nothing on the `0x3dc`-byte widget — the override lives on the CM2Model
|
||||
// and dies with it when `SetModel`/`ClearModel` releases the instance.
|
||||
//
|
||||
// **Which is why storing nothing here is faithful rather than lazy.** The reference's two
|
||||
// "not ready" cases behave OPPOSITELY: with a `CM2Model` present but its data not resident the
|
||||
// call is queued on `[cm2+0x3c]` and replayed; with **no CM2Model at all**
|
||||
// (`[widget+0x318] == 0`) it is **dropped and never replayed**. This engine renders no
|
||||
// FrameXML models, so our panes are permanently the second case — dropping it is what the
|
||||
// client does in exactly our state.
|
||||
// The reference's two "not ready" cases behave OPPOSITELY: with a `CM2Model` present but its
|
||||
// data not resident the call is queued on `[cm2+0x3c]` and replayed; with **no CM2Model at
|
||||
// all** (`[widget+0x318] == 0`) it is **dropped and never replayed**. Since decision 2007 a
|
||||
// pane with a file has an instance the host renders, so the override is STORED on the pane
|
||||
// (`ModelState::icon`, the queued-and-replayed case — the host applies it whenever the file
|
||||
// is resident) and a pane with no file drops it, as the client does. The stock
|
||||
// `MainMenuBarBagButtons.lua` is the caller: `ItemAnim_OnEvent` puts the pushed item's icon
|
||||
// on `ForcedBackpackItem.mdx`, whose one batch carries no texture of its own.
|
||||
//
|
||||
// The argument gate is real and is shape A: `lua_isstring` (tags 3|4) then `lua_tostring`,
|
||||
// raising `Usage: %s:ReplaceIconTexture("texture")` on anything else. It is kept because it is
|
||||
// the whole observable behaviour left.
|
||||
// The argument gate is shape A: `lua_isstring` (tags 3|4) then `lua_tostring`, raising
|
||||
// `Usage: %s:ReplaceIconTexture("texture")` on anything else.
|
||||
m.set(
|
||||
"ReplaceIconTexture",
|
||||
lua.create_function(|lua, (this, path): (Table, Value)| {
|
||||
if string_arg(&path).is_none() {
|
||||
let Some(path) = string_arg(&path) else {
|
||||
return Err(usage(lua, &this, "ReplaceIconTexture(\"texture\")"));
|
||||
}
|
||||
with_model(lua, &this, |_| ())
|
||||
};
|
||||
let path = path.to_string();
|
||||
with_model(lua, &this, |m| {
|
||||
if m.path.is_some() {
|
||||
m.icon = Some(path);
|
||||
}
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -485,4 +531,89 @@ impl crate::script::UiScript {
|
||||
pub fn model_pane_facing(&self, name: &str) -> f32 {
|
||||
self.model_pane(name).map_or(0.0, |m| m.facing)
|
||||
}
|
||||
|
||||
/// The host hands over what it knows about a model **file** (decision 2007): its sequences
|
||||
/// and bounds, read off the loaded asset. Every pane holding that file then runs the
|
||||
/// reference's residency completion — the loader's Stand seed for a pane that was waiting,
|
||||
/// the ownership check for an arm that was queued ([`ModelState::seed_from_facts`]) — and
|
||||
/// its clock starts answering ([`ModelState::play_head`]).
|
||||
pub fn set_model_facts(&mut self, path: &str, facts: ModelFileFacts) {
|
||||
let key = model_key(path);
|
||||
let facts = Arc::new(facts);
|
||||
let mut model = self.model_mut();
|
||||
model.model_facts.insert(key.clone(), facts.clone());
|
||||
model.model_facts_wanted.retain(|k| *k != key);
|
||||
// A one-off walk per file, not per frame: the panes that named this file are the
|
||||
// waiters the reference links on the streaming drain (`0x71d640`).
|
||||
let handles: Vec<_> = model
|
||||
.arena
|
||||
.iter_frames()
|
||||
.filter_map(|(h, f)| match &f.kind_state {
|
||||
KindState::Model(m) if m.path.as_deref().is_some_and(|p| model_key(p) == key) => {
|
||||
Some(h)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for h in handles {
|
||||
if let Some(KindState::Model(m)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
|
||||
m.seed_from_facts(&facts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The model files panes have named that no facts have arrived for — drained: the host
|
||||
/// loads each once and answers through [`Self::set_model_facts`]. Keys are
|
||||
/// [`crate::widget::model_key`]s (case-folded, `/`-separated, no extension).
|
||||
pub fn model_facts_wanted(&mut self) -> Vec<String> {
|
||||
std::mem::take(&mut self.model_mut().model_facts_wanted)
|
||||
}
|
||||
|
||||
/// Does the engine hold facts for `path`'s file — the host's "already answered" test.
|
||||
pub fn has_model_facts(&self, path: &str) -> bool {
|
||||
self.model_ref().model_facts.contains_key(&model_key(path))
|
||||
}
|
||||
|
||||
/// **The paint list**: every effectively-visible model pane holding a file whose facts the
|
||||
/// engine has — with its scene clock and, when something is armed, its play head. The host's
|
||||
/// renderer reads this once per frame (decision 2008) instead of the extract carrying a
|
||||
/// cursor that moves every tick (`QuadContent::ModelPane`'s doc says why). Handle order —
|
||||
/// the arena's registry order, stable across frames.
|
||||
pub fn visible_model_panes(&self) -> Vec<ModelPaneFrame> {
|
||||
let model = self.model_ref();
|
||||
model
|
||||
.arena
|
||||
.ticked_kinds()
|
||||
.iter()
|
||||
.filter_map(|&h| {
|
||||
let f = model.arena.frame(h)?;
|
||||
if !f.effective_visible {
|
||||
return None;
|
||||
}
|
||||
let KindState::Model(m) = &f.kind_state else {
|
||||
return None;
|
||||
};
|
||||
let path = m.path.as_deref()?;
|
||||
let facts = model.model_facts.get(&model_key(path))?;
|
||||
Some(ModelPaneFrame {
|
||||
handle: h,
|
||||
clock_ms: m.clock_ms,
|
||||
play: m.play_head(facts),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// One visible model pane as the host's renderer sees it this frame —
|
||||
/// [`UiScript::visible_model_panes`]'s row.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ModelPaneFrame {
|
||||
pub handle: crate::widget::FrameHandle,
|
||||
/// The pane's private scene clock, ms ([`ModelState::clock_ms`]) — the global-sequence
|
||||
/// clock of everything the pane draws.
|
||||
pub clock_ms: u64,
|
||||
/// Where the armed sequence stands, or `None` while nothing is armed (the file draws at its
|
||||
/// rest pose).
|
||||
pub play: Option<crate::widget::ModelPlayHead>,
|
||||
}
|
||||
|
||||
@@ -339,11 +339,17 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
/// frame/attribute system; there is no such slot in any 1.12 resolver. That addon is asking for a
|
||||
/// later client and should hear so.
|
||||
/// * **`OnHorizontalScroll` · `OnHyperlinkEnter` · `OnHyperlinkLeave` · `OnMessageScrollChanged` ·
|
||||
/// `OnUpdateModel` · `OnAnimFinished` · `OnMovieFinished`/`ShowSubtitle`/`HideSubtitle` ·
|
||||
/// `OnInputLanguageChanged`** — **raising.** Real 1.12 slots that we do not
|
||||
/// fire, and measured at **zero** call sites across the 218-addon corpus, so there is nothing to
|
||||
/// weigh against the trap: they land when their mechanism does (horizontal scroll isn't modeled at
|
||||
/// all — see [`crate::script::scrollframe`]'s module doc).
|
||||
/// `OnMovieFinished`/`ShowSubtitle`/`HideSubtitle` · `OnInputLanguageChanged`** — **raising.**
|
||||
/// Real 1.12 slots that we do not fire, and measured at **zero** call sites across the
|
||||
/// 218-addon corpus, so there is nothing to weigh against the trap: they land when their
|
||||
/// mechanism does (horizontal scroll isn't modeled at all — see
|
||||
/// [`crate::script::scrollframe`]'s module doc).
|
||||
/// * **`OnUpdateModel` · `OnAnimFinished`** — **accepted since decision 2007**, because the tick
|
||||
/// fires them: the model pane's scene clock runs in this engine now (`tick.rs`), `OnUpdateModel`
|
||||
/// at the top of every paint of a visible pane with a file (`0x76d1a0`) and `OnAnimFinished`
|
||||
/// from a sequence's natural completion (`0x76cdc0`, mode 0 — once per arm, a loop's first
|
||||
/// pass included). The shipped
|
||||
/// `Cooldown.xml` and `MainMenuBarBagButtons.xml` declare both.
|
||||
fn set_script(lua: &Lua, this: &Table, name: &str, func: Option<Function>) -> mlua::Result<()> {
|
||||
let kind = SCRIPT_KINDS
|
||||
.iter()
|
||||
@@ -373,6 +379,7 @@ fn set_script(lua: &Lua, this: &Table, name: &str, func: Option<Function>) -> ml
|
||||
match kind {
|
||||
"OnUpdate" => model.on_update_frames.push(h),
|
||||
"OnSizeChanged" => model.on_size_changed_frames.push(h),
|
||||
"OnUpdateModel" => model.on_update_model_frames.push(h),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -387,6 +394,7 @@ fn set_script(lua: &Lua, this: &Table, name: &str, func: Option<Function>) -> ml
|
||||
"OnSizeChanged" => {
|
||||
model.on_size_changed_frames.retain(|&x| x != h);
|
||||
}
|
||||
"OnUpdateModel" => model.on_update_model_frames.retain(|&x| x != h),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,12 +621,12 @@ fn the_unfired_script_kinds_still_raise_rather_than_silently_accepting() {
|
||||
"OnCursorChanged",
|
||||
// 2.0's secure-frame system — no such slot exists in any 1.12 resolver.
|
||||
"OnAttributeChanged",
|
||||
// Real 1.12 slots we do not fire, and zero corpus call sites.
|
||||
// Real 1.12 slots we do not fire, and zero corpus call sites. (`OnUpdateModel` and
|
||||
// `OnAnimFinished` left this list with decision 2007: the tick's model pass fires
|
||||
// them — `script::tests::model_clock`.)
|
||||
"OnHorizontalScroll",
|
||||
"OnHyperlinkEnter",
|
||||
"OnMessageScrollChanged",
|
||||
"OnUpdateModel",
|
||||
"OnAnimFinished",
|
||||
"OnInputLanguageChanged",
|
||||
] {
|
||||
let err = s
|
||||
|
||||
@@ -23,6 +23,7 @@ mod keyboard;
|
||||
mod layout_gate;
|
||||
mod measure;
|
||||
mod minimap;
|
||||
mod model_clock;
|
||||
mod modelframe;
|
||||
mod movable;
|
||||
mod object_model;
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
//! **The model pane's clock, arm, and two handlers** (decision 2007; wow-re
|
||||
//! `ui/scratch/modelframe-render-law.md` §4).
|
||||
//!
|
||||
//! A `<Model>` widget owns a private scene whose clock its own `OnUpdate` advances while the
|
||||
//! frame is visible; `SetSequence`/`SetSequenceTime` arm a sequence with an anchor the sampler
|
||||
//! re-reads; `OnUpdateModel` fires at the top of every paint and `OnAnimFinished` from the
|
||||
//! completion of a clamped sequence. Every one of those is Lua-observable — the shipped cooldown
|
||||
//! sweep is nothing but those handlers driving `SetSequenceTime` — so the law is tested here
|
||||
//! against the stock `Cooldown.lua`, transcribed verbatim.
|
||||
//!
|
||||
//! The engine parses no M2, so a file's sequences and bounds are **facts** the host hands over
|
||||
//! ([`ModelFileFacts`]); these tests hand over the facts `benilla-extract m2seq` reads off the
|
||||
//! shipped files.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::{Model, UiScript};
|
||||
use crate::widget::{KindState, ModelFileFacts, ModelState, SequenceFacts};
|
||||
|
||||
/// A file's facts from `(anim_id, duration_ms, looping)` rows, in file order.
|
||||
fn facts(rows: &[(u16, u32, bool)]) -> ModelFileFacts {
|
||||
ModelFileFacts {
|
||||
sequences: rows
|
||||
.iter()
|
||||
.map(|&(anim_id, duration_ms, looping)| SequenceFacts {
|
||||
anim_id,
|
||||
duration_ms,
|
||||
looping,
|
||||
})
|
||||
.collect(),
|
||||
bbox: ([0.0; 3], [0.0; 3]),
|
||||
}
|
||||
}
|
||||
|
||||
/// `UI-Cooldown-Indicator.m2` (`m2seq`): seq 0 = id 0, 1000 ms, clamp; seq 1 = id 1, 1000 ms,
|
||||
/// clamp.
|
||||
const COOLDOWN_FILE: &str = r"Interface\Cooldown\UI-Cooldown-Indicator.mdx";
|
||||
fn cooldown_facts() -> ModelFileFacts {
|
||||
facts(&[(0, 1000, false), (1, 1000, false)])
|
||||
}
|
||||
|
||||
/// `MinimapPing.m2` (`m2seq`): seq 0 = id 127, 1333 ms, clamp; seq 1 = id 0, 833 ms, LOOP; seq
|
||||
/// 2 = id 1, 333 ms, clamp. `SetSequence(0)` is the looping one.
|
||||
const PING_FILE: &str = r"Interface\MiniMap\Ping\MinimapPing.mdx";
|
||||
fn ping_facts() -> ModelFileFacts {
|
||||
facts(&[(127, 1333, false), (0, 833, true), (1, 333, false)])
|
||||
}
|
||||
|
||||
/// The pane's scene state, read through the arena (1.12 has no getter for any of it).
|
||||
fn pane(s: &UiScript, name: &str) -> ModelState {
|
||||
let model = s.lua().app_data_ref::<Model>().expect("model");
|
||||
let fh = model.arena.lookup(name).expect("pane frame");
|
||||
match &model.arena.frame(fh).expect("live frame").kind_state {
|
||||
KindState::Model(m) => m.clone(),
|
||||
_ => panic!("{name} is not a Model"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `(anim_id, cursor_ms)` of the armed sequence under the facts the engine holds.
|
||||
fn play_head(s: &UiScript, name: &str, path: &str) -> Option<(u16, u32)> {
|
||||
let m = pane(s, name);
|
||||
let model = s.lua().app_data_ref::<Model>().expect("model");
|
||||
let facts = model.model_facts.get(&crate::widget::model_key(path))?;
|
||||
m.play_head(facts).map(|p| (p.anim_id, p.cursor_ms))
|
||||
}
|
||||
|
||||
/// The widget's `OnUpdate` (`0x76d7f0`) advances the scene clock by `trunc(elapsed · 1000)` —
|
||||
/// only while the frame is visible (the UI pump walks visible frames), so a hidden pane's clock
|
||||
/// stands still and a re-shown pane resumes where it stopped.
|
||||
#[test]
|
||||
fn the_clock_runs_only_while_the_pane_is_shown_and_truncates() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_model_facts(PING_FILE, ping_facts());
|
||||
s.run(&format!(
|
||||
r#"p = CreateFrame("Model", "Ping", UIParent) p:SetModel("{}") p:SetSequence(0)"#,
|
||||
PING_FILE.replace('\\', "\\\\")
|
||||
))
|
||||
.unwrap();
|
||||
s.tick(0.5);
|
||||
assert_eq!(pane(&s, "Ping").clock_ms, 500);
|
||||
s.tick(0.0166); // 16.6 ms → 16, not 17
|
||||
assert_eq!(pane(&s, "Ping").clock_ms, 516, "trunc, no +0.5 (76d854)");
|
||||
|
||||
s.run("Ping:Hide()").unwrap();
|
||||
s.tick(1.0);
|
||||
assert_eq!(
|
||||
pane(&s, "Ping").clock_ms,
|
||||
516,
|
||||
"hidden: the clock stands still"
|
||||
);
|
||||
s.run("Ping:Show()").unwrap();
|
||||
s.tick(0.1);
|
||||
assert_eq!(
|
||||
pane(&s, "Ping").clock_ms,
|
||||
616,
|
||||
"re-shown: resumes where it stopped"
|
||||
);
|
||||
|
||||
// The looping sequence wraps on its 833 ms and never completes.
|
||||
assert_eq!(play_head(&s, "Ping", PING_FILE), Some((0, 616)));
|
||||
s.tick(0.5); // clock 1116 → 1116 mod 833
|
||||
assert_eq!(play_head(&s, "Ping", PING_FILE), Some((0, 283)));
|
||||
}
|
||||
|
||||
/// `SetModel` runs the loader's completion (`0x70ebd0`) — arm **Stand** (id 0 if the file owns
|
||||
/// it, else `animations[0]`'s id), variation 0 — synchronously when the facts are known, and when
|
||||
/// they land otherwise; until then the pane is a waiter and the host is asked for the file.
|
||||
#[test]
|
||||
fn set_model_seeds_stand_now_or_when_the_facts_land() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
// Facts first: the arm is immediate.
|
||||
s.set_model_facts(COOLDOWN_FILE, cooldown_facts());
|
||||
s.run(&format!(
|
||||
r#"a = CreateFrame("Model", "Known", UIParent) a:SetModel("{}")"#,
|
||||
COOLDOWN_FILE.replace('\\', "\\\\")
|
||||
))
|
||||
.unwrap();
|
||||
let m = pane(&s, "Known");
|
||||
assert!(!m.pending_seed);
|
||||
assert_eq!(
|
||||
m.armed.map(|a| a.anim_id),
|
||||
Some(0),
|
||||
"Stand, armed at the call"
|
||||
);
|
||||
assert_eq!(play_head(&s, "Known", COOLDOWN_FILE), Some((0, 0)));
|
||||
|
||||
// Facts unknown: a waiter, and the file goes on the host's list once.
|
||||
s.run(&format!(
|
||||
r#"b = CreateFrame("Model", "Waiting", UIParent) b:SetModel("{0}")
|
||||
c = CreateFrame("Model", "Waiting2", UIParent) c:SetModel("{0}")"#,
|
||||
PING_FILE.replace('\\', "\\\\")
|
||||
))
|
||||
.unwrap();
|
||||
let m = pane(&s, "Waiting");
|
||||
assert!(m.pending_seed && m.armed.is_none());
|
||||
assert_eq!(
|
||||
s.model_facts_wanted(),
|
||||
vec!["interface/minimap/ping/minimapping".to_string()],
|
||||
"one request per file, case-folded, no extension"
|
||||
);
|
||||
assert!(s.model_facts_wanted().is_empty(), "drained");
|
||||
|
||||
// The facts land: every waiter holding the file arms its Stand at its own clock.
|
||||
s.tick(0.25);
|
||||
s.set_model_facts(PING_FILE, ping_facts());
|
||||
for name in ["Waiting", "Waiting2"] {
|
||||
let m = pane(&s, name);
|
||||
assert!(!m.pending_seed);
|
||||
assert_eq!(
|
||||
m.armed.map(|a| (a.anim_id, a.anchor_ms)),
|
||||
Some((0, 250)),
|
||||
"{name}: Stand (the file owns id 0), anchored at the clock the file landed on"
|
||||
);
|
||||
}
|
||||
|
||||
// A file that does not own id 0 seeds `animations[0]`'s own id.
|
||||
s.set_model_facts(
|
||||
r"Interface\Odd.mdx",
|
||||
facts(&[(204, 500, true), (166, 500, true)]),
|
||||
);
|
||||
s.run(r#"d = CreateFrame("Model", "Odd", UIParent) d:SetModel("Interface\\Odd.mdx")"#)
|
||||
.unwrap();
|
||||
assert_eq!(pane(&s, "Odd").armed.map(|a| a.anim_id), Some(204));
|
||||
}
|
||||
|
||||
/// `SetSequence(id)` with an id the file does not own **stops what was playing and arms
|
||||
/// nothing** — `0x7121a0`'s interrupt runs before its bounds check (§4.2). A queued arm on a
|
||||
/// file still loading is kept until the facts say otherwise.
|
||||
#[test]
|
||||
fn an_unowned_id_stops_the_track_and_arms_nothing() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_model_facts(PING_FILE, ping_facts());
|
||||
s.run(&format!(
|
||||
r#"p = CreateFrame("Model", "P", UIParent) p:SetModel("{}") p:SetSequence(0)"#,
|
||||
PING_FILE.replace('\\', "\\\\")
|
||||
))
|
||||
.unwrap();
|
||||
assert!(pane(&s, "P").armed.is_some());
|
||||
s.run("P:SetSequence(42)").unwrap();
|
||||
assert_eq!(pane(&s, "P").sequence, 42, "the raw id is still recorded");
|
||||
assert!(pane(&s, "P").armed.is_none(), "…but nothing plays");
|
||||
|
||||
// Queued on an unloaded file, then refused when the facts land.
|
||||
s.run(r#"q = CreateFrame("Model", "Q", UIParent) q:SetModel("Interface\\Late.mdx") q:SetSequence(7)"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
pane(&s, "Q").armed.map(|a| a.anim_id),
|
||||
Some(7),
|
||||
"kept for the replay"
|
||||
);
|
||||
s.set_model_facts(r"Interface\Late.mdx", facts(&[(0, 100, true)]));
|
||||
assert!(
|
||||
pane(&s, "Q").armed.is_none(),
|
||||
"the seed armed Stand, then the queued arm replayed and named an id the file does not \
|
||||
own — nothing plays"
|
||||
);
|
||||
|
||||
// The other order of the same replay: a queued arm the file DOES own wins over the seed,
|
||||
// at its original offset, re-anchored on the clock the file landed on.
|
||||
s.run(r#"r = CreateFrame("Model", "R", UIParent) r:SetModel("Interface\\Later.mdx") r:SetSequenceTime(5, 40)"#)
|
||||
.unwrap();
|
||||
s.tick(0.3);
|
||||
s.set_model_facts(
|
||||
r"Interface\Later.mdx",
|
||||
facts(&[(0, 100, true), (5, 900, false)]),
|
||||
);
|
||||
let a = pane(&s, "R").armed.expect("the queued arm replays");
|
||||
assert_eq!((a.anim_id, a.armed_at_ms, a.anchor_ms), (5, 300, 300 - 40));
|
||||
}
|
||||
|
||||
/// The shipped cooldown, on the two handlers and nothing else — `Cooldown.lua` verbatim:
|
||||
/// `SetTimer` arms sequence 0 and shows; every paint `OnUpdateModel` scrubs
|
||||
/// `SetSequenceTime(0, elapsed/duration · 1000)` until done, then flips to sequence 1 at 0; the
|
||||
/// flash's completion (`OnAnimFinished`, 1000 ms later) hides the frame.
|
||||
#[test]
|
||||
fn the_cooldown_machine_runs_on_the_two_handlers() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_model_facts(COOLDOWN_FILE, cooldown_facts());
|
||||
// `SetTimer`'s gate is `start > 0`: a session clock still at 0 refuses the timer, as the
|
||||
// reference's would — so the session is a second old before the button is pressed.
|
||||
s.tick(1.0);
|
||||
s.run(&format!(
|
||||
r#"
|
||||
-- Cooldown.lua, 1.12.1 (the stock file, transcribed for the test — decision 1602).
|
||||
function CooldownFrame_SetTimer(this, start, duration, enable)
|
||||
if ( start > 0 and duration > 0 and enable > 0) then
|
||||
this.start = start;
|
||||
this.duration = duration;
|
||||
this.stopping = 0;
|
||||
this:SetSequence(0);
|
||||
this:Show();
|
||||
else
|
||||
this:Hide();
|
||||
end
|
||||
end
|
||||
function CooldownFrame_OnUpdateModel()
|
||||
if ( this.stopping == 0 ) then
|
||||
local finished = (GetTime() - this.start) / this.duration;
|
||||
if ( finished < 1.0 ) then
|
||||
local time = finished * 1000;
|
||||
this:SetSequenceTime(0, time);
|
||||
return;
|
||||
end
|
||||
this.stopping = 1;
|
||||
this:SetSequence(1);
|
||||
this:SetSequenceTime(1, 0);
|
||||
else
|
||||
this:AdvanceTime();
|
||||
end
|
||||
end
|
||||
function CooldownFrame_OnAnimFinished()
|
||||
if ( this.stopping == 1 ) then
|
||||
this:Hide();
|
||||
end
|
||||
end
|
||||
cd = CreateFrame("Model", "CD", UIParent)
|
||||
cd:SetModel("{}")
|
||||
cd:SetScript("OnUpdateModel", CooldownFrame_OnUpdateModel)
|
||||
cd:SetScript("OnAnimFinished", CooldownFrame_OnAnimFinished)
|
||||
cd:Hide()
|
||||
fired = {{}}
|
||||
cd:SetScript("OnAnimFinished", function() table.insert(fired, "finished") CooldownFrame_OnAnimFinished() end)
|
||||
CooldownFrame_SetTimer(cd, GetTime(), 2, 1)
|
||||
"#,
|
||||
COOLDOWN_FILE.replace('\\', "\\\\")
|
||||
))
|
||||
.unwrap();
|
||||
assert!(s.frame_visible("CD"));
|
||||
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((0, 0)));
|
||||
|
||||
// The sweep: each paint scrubs sequence 0 to the elapsed fraction.
|
||||
s.tick(0.5);
|
||||
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((0, 250)));
|
||||
s.tick(1.0);
|
||||
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((0, 750)));
|
||||
// A scrub is an ANCHOR, not a freeze: between two paints the clock runs on from it. The
|
||||
// cooldown never sees that (it re-scrubs every paint), the ping relies on it.
|
||||
assert_eq!(pane(&s, "CD").armed.map(|a| a.anchor_ms), Some(1500 - 750));
|
||||
|
||||
// Done: the flip to the flash, sequence 1 at 0.
|
||||
s.tick(0.6);
|
||||
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((1, 0)));
|
||||
assert!(s.frame_visible("CD"));
|
||||
assert_eq!(s.eval::<i64>("return cd.stopping").unwrap(), 1);
|
||||
|
||||
// The flash runs its 1000 ms; the completion fires once, and the handler hides the frame.
|
||||
s.tick(0.5);
|
||||
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((1, 500)));
|
||||
assert!(s.eval::<bool>("return table.getn(fired) == 0").unwrap());
|
||||
s.tick(0.6);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return table.getn(fired)").unwrap(),
|
||||
1,
|
||||
"OnAnimFinished on the clamped sequence's natural completion"
|
||||
);
|
||||
assert!(
|
||||
!s.frame_visible("CD"),
|
||||
"…which the stock handler turns into Hide()"
|
||||
);
|
||||
// Hidden: no paint, no second completion, the cursor holds at the end.
|
||||
s.tick(1.0);
|
||||
assert_eq!(s.eval::<i64>("return table.getn(fired)").unwrap(), 1);
|
||||
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((1, 1000)));
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `OnUpdateModel` runs with `this` set, once per paint of a **visible** pane, before the
|
||||
/// completion is read — so a handler that re-arms in the same paint completes nothing.
|
||||
#[test]
|
||||
fn on_update_model_fires_per_visible_paint_with_this() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_model_facts(COOLDOWN_FILE, cooldown_facts());
|
||||
s.run(&format!(
|
||||
r#"
|
||||
seen = {{}}
|
||||
m = CreateFrame("Model", "M", UIParent)
|
||||
m:SetModel("{}")
|
||||
m:SetScript("OnUpdateModel", function() table.insert(seen, this:GetName()) end)
|
||||
m:SetScript("OnAnimFinished", function() error("must not fire") end)
|
||||
m:SetSequence(1)
|
||||
"#,
|
||||
COOLDOWN_FILE.replace('\\', "\\\\")
|
||||
))
|
||||
.unwrap();
|
||||
s.tick(0.1);
|
||||
s.tick(0.1);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return table.getn(seen)").unwrap(),
|
||||
2,
|
||||
"one per tick"
|
||||
);
|
||||
assert_eq!(s.eval::<String>("return seen[1]").unwrap(), "M");
|
||||
s.run("M:Hide()").unwrap();
|
||||
s.tick(0.1);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return table.getn(seen)").unwrap(),
|
||||
2,
|
||||
"no paint while hidden"
|
||||
);
|
||||
// Re-arming every paint keeps the clamped sequence from ever completing.
|
||||
s.run("M:Show() M:SetScript(\"OnUpdateModel\", function() this:SetSequenceTime(1, 0) end)")
|
||||
.unwrap();
|
||||
for _ in 0..30 {
|
||||
s.tick(0.1);
|
||||
}
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// A LOOPING sequence fires `OnAnimFinished` once too — at the end of its first pass — and keeps
|
||||
/// looping (`0x719370` enqueues the completion before it tests the loop flag; wow-re
|
||||
/// `modelframe-texanim-and-sequence-law.md` Q4). And a pane with no file fires no
|
||||
/// `OnUpdateModel` at all (`76d24c`'s gate), however visible.
|
||||
#[test]
|
||||
fn a_loop_completes_once_and_a_fileless_pane_paints_nothing() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_model_facts(PING_FILE, ping_facts());
|
||||
s.run(
|
||||
r#"
|
||||
done = 0 painted = 0
|
||||
p = CreateFrame("Model", "Loop", UIParent)
|
||||
p:SetScript("OnAnimFinished", function() done = done + 1 end)
|
||||
p:SetScript("OnUpdateModel", function() painted = painted + 1 end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.tick(0.1);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return painted").unwrap(),
|
||||
0,
|
||||
"no file: no paint"
|
||||
);
|
||||
s.run(&format!(
|
||||
r#"p:SetModel("{}") p:SetSequence(0)"#,
|
||||
PING_FILE.replace('\\', "\\\\")
|
||||
))
|
||||
.unwrap();
|
||||
s.tick(0.5);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return painted").unwrap(),
|
||||
1,
|
||||
"a file: one paint per tick"
|
||||
);
|
||||
assert_eq!(s.eval::<i64>("return done").unwrap(), 0);
|
||||
s.tick(0.4); // 900 ms ≥ the 833 ms loop
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return done").unwrap(),
|
||||
1,
|
||||
"the first pass completes"
|
||||
);
|
||||
assert_eq!(
|
||||
play_head(&s, "Loop", PING_FILE),
|
||||
Some((0, 67)),
|
||||
"…and the loop goes on"
|
||||
);
|
||||
s.tick(1.0);
|
||||
assert_eq!(s.eval::<i64>("return done").unwrap(), 1, "once per arm");
|
||||
s.run("p:SetSequence(0)").unwrap();
|
||||
s.tick(0.9);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return done").unwrap(),
|
||||
2,
|
||||
"a re-arm completes again"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `ReplaceIconTexture` is the type-14 texture override on the INSTANCE: stored over a file
|
||||
/// (queued and replayed while it streams), dropped with no file, released by `SetModel` and
|
||||
/// `ClearModel`.
|
||||
#[test]
|
||||
fn replace_icon_texture_lives_and_dies_with_the_instance() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
card = CreateFrame("Model", "Card", UIParent)
|
||||
card:ReplaceIconTexture("Interface\\Icons\\INV_Misc_Bag_08")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
pane(&s, "Card").icon,
|
||||
None,
|
||||
"no instance: dropped, never replayed"
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
card:SetModel("Interface\\ItemAnimations\\ForcedBackpackItem.mdx")
|
||||
card:ReplaceIconTexture("Interface\\Icons\\INV_Misc_Bag_08")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
pane(&s, "Card").icon.as_deref(),
|
||||
Some(r"Interface\Icons\INV_Misc_Bag_08")
|
||||
);
|
||||
s.run(r#"card:SetModel("Interface\\ItemAnimations\\ForcedBackpackItem.mdx")"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
pane(&s, "Card").icon,
|
||||
None,
|
||||
"a fresh instance has no override"
|
||||
);
|
||||
s.run(r#"card:ReplaceIconTexture("x") card:ClearModel()"#)
|
||||
.unwrap();
|
||||
assert_eq!(pane(&s, "Card").icon, None);
|
||||
assert!(
|
||||
s.run("card:ReplaceIconTexture(nil)").is_err(),
|
||||
"the shape-A usage raise stays"
|
||||
);
|
||||
}
|
||||
|
||||
/// The XML `scale=` on a model pane is the MODEL's scale (`0x76cac0` → `+0x3a0`), never the
|
||||
/// frame's — the cooldown template's 0.75 must not shrink the widget's rect.
|
||||
#[test]
|
||||
fn the_model_scale_attribute_is_the_models_own() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
// The pet bar's shine (`PetActionBarFrame.xml:25`), and a `scale="0"` beside it.
|
||||
let doc = crate::framexml::parse(
|
||||
r#"<Ui>
|
||||
<Frame name="Host"><Size><AbsDimension x="30" y="30"/></Size>
|
||||
<Anchors><Anchor point="CENTER"/></Anchors>
|
||||
<Frames>
|
||||
<Model name="ShinePane" file="Interface\Buttons\UI-AutoCastButton.mdx" scale="1.2" hidden="true" setAllPoints="true"/>
|
||||
<Model name="ZeroPane" file="Interface\Buttons\UI-AutoCastButton.mdx" scale="0" hidden="true" setAllPoints="true"/>
|
||||
</Frames>
|
||||
</Frame>
|
||||
</Ui>"#,
|
||||
)
|
||||
.expect("valid FrameXML");
|
||||
let report = crate::loader::load(&s, &doc, &|_| None);
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64)>("return ShinePane:GetModelScale(), ShinePane:GetScale()")
|
||||
.unwrap(),
|
||||
(1.2_f32 as f64, 1.0),
|
||||
"the attribute lands on SetModelScale; the frame scale is untouched"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return ZeroPane:GetModelScale()").unwrap(),
|
||||
1.0,
|
||||
"≤ 0 is the reference's raise, not a clamp — the default stands"
|
||||
);
|
||||
assert!(
|
||||
report
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|w| w.contains("Invalid model scale")),
|
||||
"{:?}",
|
||||
report.warnings
|
||||
);
|
||||
}
|
||||
@@ -337,13 +337,13 @@ fn the_two_model_tables_are_the_references_own() {
|
||||
}
|
||||
}
|
||||
|
||||
/// **`SetSequenceTime` is a scrub INTO the current sequence**, so changing the sequence drops it.
|
||||
///
|
||||
/// The cooldown indicator drives this pair every frame — `SetSequence(n)` then
|
||||
/// `SetSequenceTime(n, ms)` — and carrying a stale scrub across a sequence change would park the
|
||||
/// new animation at a time belonging to the previous one.
|
||||
/// **`SetSequence` and `SetSequenceTime` are one arm** (`0x7121a0`): each interrupts what plays
|
||||
/// and anchors the new sequence's cursor — at 0, or at the caller's `ms` — on the pane's own
|
||||
/// clock. The clock law itself (advance, wrap, completion, the two handlers) is
|
||||
/// `script::tests::model_clock`; this is the binding-level shape, with no file facts known, which
|
||||
/// is the state the reference's queued replay covers.
|
||||
#[test]
|
||||
fn a_sequence_change_drops_the_scrub() {
|
||||
fn the_two_sequence_verbs_arm_the_pane_on_its_own_clock() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
@@ -356,30 +356,33 @@ fn a_sequence_change_drops_the_scrub() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 1.12 has no `GetSequence`, so the scrub is read off the model the way `simplehtml`'s tests
|
||||
// 1.12 has no `GetSequence`, so the arm is read off the model the way `simplehtml`'s tests
|
||||
// read their blocks — through the arena, because there is no Lua getter to read it through.
|
||||
let scrub = |s: &UiScript| {
|
||||
let armed = |s: &UiScript| {
|
||||
let lua = s.lua();
|
||||
let model = lua.app_data_ref::<crate::script::Model>().expect("model");
|
||||
let fh = model.arena.lookup("MSeq").expect("MSeq frame");
|
||||
match &model.arena.frame(fh).expect("live frame").kind_state {
|
||||
crate::widget::KindState::Model(m) => (m.sequence, m.sequence_time),
|
||||
crate::widget::KindState::Model(m) => {
|
||||
(m.sequence, m.armed.map(|a| (a.anim_id, a.anchor_ms)))
|
||||
}
|
||||
_ => panic!("MSeq is not a Model"),
|
||||
}
|
||||
};
|
||||
assert_eq!(scrub(&s), (0, Some((0, 250))));
|
||||
// The scrub anchors the cursor 250 ms in: `anchor = clock − ms` at clock 0.
|
||||
assert_eq!(armed(&s), (0, Some((0, -250))));
|
||||
|
||||
s.run("MSeq:SetSequence(3)").unwrap();
|
||||
assert_eq!(
|
||||
scrub(&s),
|
||||
(3, None),
|
||||
"a new sequence starts unscrubbed — the old (sequence, ms) pair is not carried across"
|
||||
armed(&s),
|
||||
(3, Some((3, 0))),
|
||||
"a new sequence starts at its own 0 — the old anchor is not carried across"
|
||||
);
|
||||
|
||||
// ...and ClearModel drops the scrub with the content.
|
||||
// ...and ClearModel releases the instance, arm included.
|
||||
s.run("MSeq:SetSequenceTime(3, 40) MSeq:ClearModel()")
|
||||
.unwrap();
|
||||
assert_eq!(scrub(&s).1, None);
|
||||
assert_eq!(armed(&s).1, None);
|
||||
}
|
||||
|
||||
/// `SetLight`'s numbers are stored and returned **verbatim**, however many there are.
|
||||
|
||||
@@ -184,6 +184,7 @@ impl super::UiScript {
|
||||
self.push_error(e);
|
||||
}
|
||||
}
|
||||
self.tick_model_panes(elapsed);
|
||||
// Advance every ScrollingMessageFrame's per-line fade (the client's OnUpdate `0x788460`).
|
||||
// Independent of the frame's own OnUpdate script — the fade is C++ behavior, not Lua. The
|
||||
// AtBottom freeze gate lives inside `ScrollingMessageState::tick`.
|
||||
@@ -260,3 +261,125 @@ impl super::UiScript {
|
||||
self.report_handler_profile(elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
impl UiScript {
|
||||
/// The model panes' per-frame pass (decision 2007; wow-re `modelframe-render-law.md` §4).
|
||||
///
|
||||
/// Three things the reference does each frame for a **visible** model pane, in this order:
|
||||
/// the widget's own `OnUpdate` (`0x76d7f0`, walked by the UI pump with the Lua OnUpdates the
|
||||
/// caller just fired) advances its private scene clock by `trunc(elapsed · 1000)` ms — a
|
||||
/// truncation, no `+0.5` (`76d854 __ftol`); the paint (`vt+0x98`, `0x76d1a0`) fires
|
||||
/// `OnUpdateModel` before it builds the scene; and the scene's animate runs the completion
|
||||
/// callback (`0x76cdc0`) when the armed sequence has run its length — `OnAnimFinished`, on
|
||||
/// natural completion only (mode 0), once per arm, for a looping sequence's first pass as
|
||||
/// much as for a clamped one's end (the flag test sits after the enqueue — wow-re
|
||||
/// `modelframe-texanim-and-sequence-law.md`, Q4). A hidden pane gets none of the three: its
|
||||
/// clock stands still and it completes nothing, which is what makes the minimap ping "resume
|
||||
/// where the last one stopped".
|
||||
///
|
||||
/// The completion is read AFTER `OnUpdateModel` because the handler may re-arm (the cooldown
|
||||
/// flips to its flash there), and a fresh arm has nothing to complete.
|
||||
fn tick_model_panes(&mut self, elapsed: f32) {
|
||||
let dt_ms = (elapsed * 1000.0).trunc().max(0.0) as u64;
|
||||
let update_ids: Vec<u32> = {
|
||||
let mut model = self.model_mut();
|
||||
let ticked: Vec<FrameHandle> = model.arena.ticked_kinds().to_vec();
|
||||
for h in ticked {
|
||||
let Some(f) = model.arena.frame_mut(h) else {
|
||||
continue;
|
||||
};
|
||||
if !f.effective_visible {
|
||||
continue;
|
||||
}
|
||||
if let crate::widget::KindState::Model(m) = &mut f.kind_state {
|
||||
m.clock_ms += dt_ms;
|
||||
}
|
||||
}
|
||||
// The handler population, maintained by `SetScript` like `on_update_frames`; a
|
||||
// destroyed frame's handle compacts out on its first miss.
|
||||
if model
|
||||
.on_update_model_frames
|
||||
.iter()
|
||||
.any(|&h| model.arena.frame(h).is_none())
|
||||
{
|
||||
let arena = &model.arena;
|
||||
let live: Vec<FrameHandle> = model
|
||||
.on_update_model_frames
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&h| arena.frame(h).is_some())
|
||||
.collect();
|
||||
model.on_update_model_frames = live;
|
||||
}
|
||||
// Visible MODEL panes WITH A MODEL INSTALLED: the handler is `CSimpleModel::LoadXML`'s
|
||||
// (`+0x3cc`) and the paint that fires it (`76d1bc`) is reached only past the
|
||||
// `[widget+0x318] ≠ 0` gate at `76d24c` — a file set, resident or still streaming;
|
||||
// a pane with no file paints nothing and fires nothing (wow-re
|
||||
// `modelframe-texanim-and-sequence-law.md`, Q4). A script of that name on any
|
||||
// other kind is inert, as it is in the reference.
|
||||
let frames: Vec<FrameHandle> = model
|
||||
.on_update_model_frames
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&h| {
|
||||
model.arena.frame(h).is_some_and(|f| {
|
||||
f.effective_visible
|
||||
&& matches!(&f.kind_state, crate::widget::KindState::Model(m) if m.path.is_some())
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut ids: Vec<u32> = frames.into_iter().map(|h| model.frame_id(h)).collect();
|
||||
ids.sort_unstable(); // creation order — the OnUpdate sweep's own law
|
||||
ids
|
||||
};
|
||||
for id in update_ids {
|
||||
if let Err(e) = event::fire_widget_handler(&self.lua, id, "OnUpdateModel", Vec::new()) {
|
||||
self.push_error(e);
|
||||
}
|
||||
}
|
||||
let finished_ids: Vec<u32> = {
|
||||
let mut model = self.model_mut();
|
||||
let ticked: Vec<FrameHandle> = model.arena.ticked_kinds().to_vec();
|
||||
let mut due: Vec<FrameHandle> = Vec::new();
|
||||
for h in ticked {
|
||||
let Some(f) = model.arena.frame(h) else {
|
||||
continue;
|
||||
};
|
||||
if !f.effective_visible {
|
||||
continue;
|
||||
}
|
||||
let crate::widget::KindState::Model(m) = &f.kind_state else {
|
||||
continue;
|
||||
};
|
||||
let Some(path) = m.path.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(facts) = model.model_facts.get(&crate::widget::model_key(path)) else {
|
||||
continue;
|
||||
};
|
||||
if m.completion_due(facts) {
|
||||
due.push(h);
|
||||
}
|
||||
}
|
||||
let mut ids = Vec::with_capacity(due.len());
|
||||
for h in due {
|
||||
if let Some(crate::widget::KindState::Model(m)) =
|
||||
model.arena.frame_mut(h).map(|f| &mut f.kind_state)
|
||||
{
|
||||
if let Some(a) = &mut m.armed {
|
||||
a.finished = true;
|
||||
}
|
||||
}
|
||||
ids.push(model.frame_id(h));
|
||||
}
|
||||
ids.sort_unstable();
|
||||
ids
|
||||
};
|
||||
for id in finished_ids {
|
||||
if let Err(e) = event::fire_widget_handler(&self.lua, id, "OnAnimFinished", Vec::new())
|
||||
{
|
||||
self.push_error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,13 @@ pub enum QuadContent {
|
||||
/// and for the same reason (the scene state is [`crate::widget::ModelState`]; the render is
|
||||
/// not this crate's).
|
||||
///
|
||||
/// **The clock is deliberately NOT here.** A pane's play head moves every tick, and the
|
||||
/// host memoizes its whole quad conversion on the extracted list being unchanged — a
|
||||
/// cursor in this variant would re-convert the entire interface every frame a cooldown or a
|
||||
/// ping is showing. The host reads the play heads straight off the engine instead
|
||||
/// (`UiScript::visible_model_panes`, decision 2008); this variant carries only what the
|
||||
/// layout and the Lua setters decide.
|
||||
///
|
||||
/// **Why the NAME travels rather than the scene.** benilla draws a body pane by sampling an
|
||||
/// off-screen bake the app already keeps per *window* (the paper doll's, the inspect window's,
|
||||
/// the pet page's), and which bake a pane samples is a fact about that window, not about the
|
||||
@@ -112,6 +119,9 @@ pub enum QuadContent {
|
||||
/// invent a meaning for. A pane with no name, or one no window has claimed, draws nothing;
|
||||
/// that is also what a `SetModel` pane does today, and it is honest rather than a white slab.
|
||||
ModelPane {
|
||||
/// The pane's frame handle — the renderer's key for the tile it keeps per pane (a name
|
||||
/// is optional and shared by nothing; the handle is neither). Decision 2007.
|
||||
handle: crate::widget::FrameHandle,
|
||||
/// The pane's global frame name (`$parent`-expanded), or `None` for an anonymous
|
||||
/// `CreateFrame("Model")` — pfUI's autocast shine is the corpus example of the latter.
|
||||
name: Option<String>,
|
||||
@@ -124,6 +134,17 @@ pub enum QuadContent {
|
||||
facing: f32,
|
||||
/// `SetModelScale`'s factor (1 default).
|
||||
model_scale: f32,
|
||||
/// `SetPosition`'s offset, in the ortho leg's layout units (render law §2: the root is
|
||||
/// `T(pos · layoutScale) · R(facing) · S(…)`).
|
||||
position: (f32, f32, f32),
|
||||
/// The frame's **own** alpha — what the model instance draws at. The reference re-pushes
|
||||
/// `[widget+0xc8]/255` into the instance on every `Frame:SetAlpha` (`0x76d120`), and
|
||||
/// nothing folds the parent chain in: a pane under a faded parent draws at its own
|
||||
/// alpha (render law §4.4). [`ExtractedQuad::alpha`] carries the effective one.
|
||||
own_alpha: f32,
|
||||
/// `ReplaceIconTexture`'s path — the type-14 texture override, or `None` for the file's
|
||||
/// own textures.
|
||||
icon: Option<String>,
|
||||
},
|
||||
/// A `Texture` region: a BLP path *or* a solid/vertex color (or both — a tinted texture).
|
||||
Texture {
|
||||
|
||||
@@ -152,8 +152,16 @@ fn create(lua: &Lua, which: Arrow, parent: Value) -> mlua::Result<()> {
|
||||
// The widget's rect is the resident model's bounding box (§2.2); the footprint at this scale.
|
||||
wrapper.call_method::<()>("SetWidth", side)?;
|
||||
wrapper.call_method::<()>("SetHeight", side)?;
|
||||
// `SetModel("Interface\Minimap\MinimapArrow.mdx")` in C++ (`0x4a7a80` → `0x76c8e0`): the
|
||||
// same file set every pane takes, seeded with the file's facts when the host has them
|
||||
// (decision 2007 — the arrow's Stand loops its 3.333 s with no bone keyed, so nothing
|
||||
// moves; the arm is the reference's, not a look).
|
||||
let facts = lua
|
||||
.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.model_facts_for(ARROW_MODEL);
|
||||
super::modelframe::with_model(lua, &wrapper, |m| {
|
||||
m.path = Some(ARROW_MODEL.to_string());
|
||||
m.set_file(ARROW_MODEL.to_string(), facts.as_deref());
|
||||
m.scale = scale;
|
||||
m.position = (side * 0.5, side * 0.5, 0.0);
|
||||
})?;
|
||||
|
||||
@@ -415,12 +415,33 @@ pub struct ModelState {
|
||||
/// not — `SetUnit` is [`FrameKind::PlayerModel`]'s (`0x84f1fc[0]` → `0x505d70`), and a plain
|
||||
/// `<Model>` has no way to reach it.
|
||||
pub unit: Option<String>,
|
||||
/// `SetSequence(n)` — the animation index the pane plays.
|
||||
/// `SetSequence(n)` — the last animation id asked for (the raw id lands in
|
||||
/// `[bone0 block + 0xf8]`, which `PlayerModel`'s per-paint stomp reads). What actually
|
||||
/// PLAYS is [`Self::armed`].
|
||||
pub sequence: i32,
|
||||
/// `SetSequenceTime(sequence, ms)` — the scrub point, as `(sequence, milliseconds)`. The
|
||||
/// cooldown indicator's whole mechanism is this pair driven per frame, which is why it is
|
||||
/// stored rather than folded into [`Self::sequence`].
|
||||
pub sequence_time: Option<(i32, i32)>,
|
||||
/// **The widget's private scene clock**, milliseconds — `[scene+0xc]` of the `CM2Scene` the
|
||||
/// widget owns (`0x76cfc0`, cached at `+0x314`; never the world's `[0xc7b298]`). Advanced by
|
||||
/// the widget's own `OnUpdate` (`0x76d7f0`: `trunc(elapsed · 1000)`, no `+0.5`), which the UI
|
||||
/// pump walks for **visible** frames only — so a hidden pane's clock stands still and a
|
||||
/// re-shown one resumes where it stopped (the minimap ping's "ping N resumes where ping N−1
|
||||
/// left off"). Nothing else advances it: `AdvanceTime` is inert. The scene outlives the
|
||||
/// model, so `SetModel` does not reset it. Decision 2007.
|
||||
pub clock_ms: u64,
|
||||
/// What is armed on bone slot 0 — the sequence the pane plays and the anchor its cursor is
|
||||
/// read against. `None` while nothing plays: before any file, after `ClearModel`, or after
|
||||
/// a `SetSequence` naming an id the file does not own (which stops what was playing and
|
||||
/// arms nothing — `0x7121a0`'s interrupt runs before its bounds check).
|
||||
pub armed: Option<ArmedSequence>,
|
||||
/// `SetModel` ran but the loader's own arm has not — the file's facts ([`ModelFileFacts`])
|
||||
/// were not known at the call. The reference links the instance as a waiter for the
|
||||
/// streaming drain and runs the completion (`0x70ebd0`: arm Stand, variation 0) when the
|
||||
/// asset lands; [`ModelState::seed_from_facts`] is that completion, run when the host hands
|
||||
/// the facts over.
|
||||
pub pending_seed: bool,
|
||||
/// `ReplaceIconTexture(path)` — the type-14 texture override (`0x76cfe0(0xe, path)` →
|
||||
/// `0x710ec0`), which lives on the model instance and dies with it: `SetModel` and
|
||||
/// `ClearModel` clear it. `None` = the file's own textures.
|
||||
pub icon: Option<String>,
|
||||
/// The pane's yaw in radians — `CSimpleModel+0x39c`. **One slot, written by two verbs on two
|
||||
/// different classes**: `Model:SetFacing` (`0x76dce0`) and `PlayerModel:SetRotation`
|
||||
/// (`0x505f00` → `0x505bb0`, whose last act is `0x505c44 mov [esi+0x39c], eax` — literally the
|
||||
@@ -464,7 +485,10 @@ impl Default for ModelState {
|
||||
path: None,
|
||||
unit: None,
|
||||
sequence: 0,
|
||||
sequence_time: None,
|
||||
clock_ms: 0,
|
||||
armed: None,
|
||||
pending_seed: false,
|
||||
icon: None,
|
||||
facing: 0.0,
|
||||
scale: 1.0,
|
||||
camera: 0,
|
||||
@@ -475,6 +499,225 @@ impl Default for ModelState {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a pane's bone slot 0 is playing — the reference's `0x7121a0` arm (`SetSequence`
|
||||
/// `0x76dec0` → `0x76cf50`, `SetSequenceTime` `0x76dfc0` → `0x76cf80`, and the loader's own
|
||||
/// seed `0x70ebd0`): the id, and the **anchor** the cursor is read against. The reference
|
||||
/// bakes `cursor_lo = sceneClock − trunc(ms)` once and its sampler re-reads that anchor every
|
||||
/// frame; there is no counter that advances on its own, which is what lets the cooldown scrub
|
||||
/// the pane every paint without the clock fighting it. Decision 2007.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ArmedSequence {
|
||||
/// The `AnimationData.dbc` id — `SetSequence`'s argument, or the loader's Stand seed.
|
||||
pub anim_id: i32,
|
||||
/// The scene-clock value the cursor counts from: `cursor = clock_ms − anchor_ms`, so a
|
||||
/// `SetSequenceTime(id, ms)` at clock `c` stores `c − ms`.
|
||||
pub anchor_ms: i64,
|
||||
/// The scene clock when the arm was made — so a queued arm (made before the file's facts
|
||||
/// were known) can replay at residency with its original offset: `armed_at − anchor` is the
|
||||
/// `ms` the call asked for.
|
||||
pub armed_at_ms: u64,
|
||||
/// The completion callback has fired for this arm — the sequence ran its length (a clamp's
|
||||
/// end, or a loop's first pass) and the widget's `OnAnimFinished` ran. Once per arm: a
|
||||
/// re-arm starts a fresh one.
|
||||
pub finished: bool,
|
||||
}
|
||||
|
||||
/// The playing sequence's cursor, as the renderer samples it — [`ModelState::play_head`]'s
|
||||
/// answer under the file's facts.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ModelPlayHead {
|
||||
/// The `AnimationData.dbc` id of the armed sequence (the file owns it — see
|
||||
/// [`ModelFileFacts::owns`]).
|
||||
pub anim_id: u16,
|
||||
/// Milliseconds into the sequence: wrapped for a looping one, held at the last frame for a
|
||||
/// clamped one that has completed.
|
||||
pub cursor_ms: u32,
|
||||
}
|
||||
|
||||
/// One sequence of a model file, as the clock needs it — see [`ModelFileFacts`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SequenceFacts {
|
||||
/// The `AnimationData.dbc` id (`M2Sequence+0x00`).
|
||||
pub anim_id: u16,
|
||||
/// The sequence's length, `end − start` on the file's timeline (`+0x08 − +0x04`).
|
||||
pub duration_ms: u32,
|
||||
/// The sequence loops (the flag the formats crate decodes as `looping`); a clamped one
|
||||
/// holds its last frame and fires the completion callback once.
|
||||
pub looping: bool,
|
||||
}
|
||||
|
||||
/// What the engine needs to know about a model **file** to run a pane's clock — the reference
|
||||
/// reads these off the resident `MD20` (`animationLookup` at `md20+0x24`, the sequence table,
|
||||
/// the header bounds); here the host's M2 loader has them and hands them over through
|
||||
/// `UiScript::set_model_facts` once the asset lands. Keyed by the `SetModel` path
|
||||
/// ([`model_key`]), shared by every pane holding that file. Decision 2007.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ModelFileFacts {
|
||||
/// The sequences the file owns, **in file order** — the first is `animations[0]`, the
|
||||
/// loader's fallback seed when the file does not own id 0.
|
||||
pub sequences: Vec<SequenceFacts>,
|
||||
/// The header bounding box, raw WoW model space (`min`, `max`) — the implicit rect of a
|
||||
/// size-less `<Model>` (render law §3) and the arrow's re-centring.
|
||||
pub bbox: ([f32; 3], [f32; 3]),
|
||||
}
|
||||
|
||||
impl ModelFileFacts {
|
||||
/// Does the file own `anim_id` — the reference's `0x711960` ("does `animationLookup` map
|
||||
/// it"): a model owns an id iff some sequence carries it.
|
||||
pub fn owns(&self, anim_id: u16) -> bool {
|
||||
self.sequences.iter().any(|s| s.anim_id == anim_id)
|
||||
}
|
||||
|
||||
/// The sequence `SetSequence(anim_id)` plays: the id's **first** file slot — variation 0,
|
||||
/// which is what both the loader's seed and the widget's arm pass (`0x7121a0`'s third
|
||||
/// argument is `0`, never `-1`, on this path).
|
||||
pub fn sequence(&self, anim_id: u16) -> Option<&SequenceFacts> {
|
||||
self.sequences.iter().find(|s| s.anim_id == anim_id)
|
||||
}
|
||||
|
||||
/// The loader's idle seed (`0x70ebd0`'s tail, `0x710153`–`0x71019b`): **id 0 (`Stand`) if
|
||||
/// the file owns it, else `animations[0]`'s own id**; `None` only for a file with no
|
||||
/// sequences at all.
|
||||
pub fn stand_id(&self) -> Option<u16> {
|
||||
if self.owns(0) {
|
||||
Some(0)
|
||||
} else {
|
||||
self.sequences.first().map(|s| s.anim_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The key a model path is filed under: case-folded, forward slashes, no extension — so
|
||||
/// `Interface\Cooldown\UI-Cooldown-Indicator.mdx` and its shipped `.m2` twin are one file, as
|
||||
/// they are for the loader.
|
||||
pub fn model_key(path: &str) -> String {
|
||||
let p = path.to_ascii_lowercase().replace('\\', "/");
|
||||
let stem = p
|
||||
.strip_suffix(".mdx")
|
||||
.or_else(|| p.strip_suffix(".mdl"))
|
||||
.or_else(|| p.strip_suffix(".m2"))
|
||||
.unwrap_or(&p);
|
||||
stem.to_string()
|
||||
}
|
||||
|
||||
impl ModelState {
|
||||
/// `SetModel(path)` — `vt+0x94` (`0x76cce0`): a **fresh instance** of the file (`CreateModel`
|
||||
/// with flags 5), which displaces a unit, drops the previous instance's icon override and
|
||||
/// its arm, and runs the loader's completion (`0x70ebd0`) synchronously when the file is
|
||||
/// resident — [`Self::seed_from_facts`] — else waits for it ([`Self::pending_seed`]). The
|
||||
/// scene clock is the WIDGET's, not the instance's, and keeps running.
|
||||
pub fn set_file(&mut self, path: String, facts: Option<&ModelFileFacts>) {
|
||||
self.path = Some(path);
|
||||
self.unit = None;
|
||||
self.icon = None;
|
||||
self.armed = None;
|
||||
self.pending_seed = true;
|
||||
if let Some(facts) = facts {
|
||||
self.seed_from_facts(facts);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClearModel` — releases the instance: no file, no unit, no override, nothing armed.
|
||||
pub fn clear_file(&mut self) {
|
||||
self.path = None;
|
||||
self.unit = None;
|
||||
self.icon = None;
|
||||
self.armed = None;
|
||||
self.pending_seed = false;
|
||||
}
|
||||
|
||||
/// `SetSequence` / `SetSequenceTime` — the `0x7121a0` arm. It **interrupts** whatever plays
|
||||
/// first (the completion callback fires with a non-zero mode there, which the widget's
|
||||
/// `OnAnimFinished` gate ignores — §4.5: natural completion only), then arms `id` at `ms`
|
||||
/// into it — or arms nothing when the file does not own the id (the bounds check at
|
||||
/// `71247c` returns having armed nothing). With the facts not known yet the arm is kept and
|
||||
/// re-checked when they land ([`Self::seed_from_facts`]) — the reference's queued replay for
|
||||
/// a file still streaming.
|
||||
pub fn arm(&mut self, id: i32, ms: i64, facts: Option<&ModelFileFacts>) {
|
||||
self.sequence = id;
|
||||
let owned = u16::try_from(id)
|
||||
.ok()
|
||||
.is_some_and(|id| facts.is_none_or(|f| f.owns(id)));
|
||||
self.armed = owned.then(|| ArmedSequence {
|
||||
anim_id: id,
|
||||
anchor_ms: self.clock_ms as i64 - ms,
|
||||
armed_at_ms: self.clock_ms,
|
||||
finished: false,
|
||||
});
|
||||
}
|
||||
|
||||
/// The loader's completion for a file that just became resident (`0x70ebd0`), then the
|
||||
/// replay of what was queued behind the load: the seed arms Stand (variation 0); an explicit
|
||||
/// `SetSequence`/`SetSequenceTime` made while the file streamed replays AFTER it — at its
|
||||
/// original offset — and decides the final state, which for an id the file does not own is
|
||||
/// **nothing armed** (the interrupt ran, the bounds check armed nothing). Idempotent once
|
||||
/// the seed has run: a second hand-over of the same facts only re-checks ownership.
|
||||
pub fn seed_from_facts(&mut self, facts: &ModelFileFacts) {
|
||||
let queued = self.armed.filter(|_| self.pending_seed);
|
||||
if self.pending_seed {
|
||||
self.pending_seed = false;
|
||||
self.armed = facts.stand_id().map(|id| ArmedSequence {
|
||||
anim_id: i32::from(id),
|
||||
anchor_ms: self.clock_ms as i64,
|
||||
armed_at_ms: self.clock_ms,
|
||||
finished: false,
|
||||
});
|
||||
}
|
||||
if let Some(q) = queued {
|
||||
let offset = q.armed_at_ms as i64 - q.anchor_ms;
|
||||
self.arm(q.anim_id, offset, Some(facts));
|
||||
} else if let Some(armed) = self.armed {
|
||||
if !u16::try_from(armed.anim_id).is_ok_and(|id| facts.owns(id)) {
|
||||
self.armed = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the armed sequence stands on the scene clock, under `facts`: wrapped for a looping
|
||||
/// sequence, held at the end for a clamped one. `None` when nothing is armed or the arm names
|
||||
/// a sequence the facts do not carry.
|
||||
pub fn play_head(&self, facts: &ModelFileFacts) -> Option<ModelPlayHead> {
|
||||
let armed = self.armed?;
|
||||
let anim_id = u16::try_from(armed.anim_id).ok()?;
|
||||
let seq = facts.sequence(anim_id)?;
|
||||
let raw = (self.clock_ms as i64 - armed.anchor_ms).max(0) as u64;
|
||||
let dur = u64::from(seq.duration_ms);
|
||||
let cursor = if dur == 0 {
|
||||
0
|
||||
} else if seq.looping {
|
||||
raw % dur
|
||||
} else {
|
||||
raw.min(dur)
|
||||
};
|
||||
Some(ModelPlayHead {
|
||||
anim_id,
|
||||
cursor_ms: cursor as u32,
|
||||
})
|
||||
}
|
||||
|
||||
/// Has the armed sequence run its length without its completion having fired — the edge the
|
||||
/// tick turns into `OnAnimFinished` (the completion callback `0x76cdc0`, `mode == 0`). Once
|
||||
/// per arm, and **for a looping sequence too**: `0x719370` enqueues the completion when the
|
||||
/// widget clock reaches `lo + duration · replays` and tests the loop flag only after it, so a
|
||||
/// loop's first pass completes exactly like a clamp's end (wow-re
|
||||
/// `modelframe-texanim-and-sequence-law.md`, Q4; corrects 2007's "clamped only").
|
||||
pub fn completion_due(&self, facts: &ModelFileFacts) -> bool {
|
||||
let Some(armed) = self.armed else {
|
||||
return false;
|
||||
};
|
||||
if armed.finished {
|
||||
return false;
|
||||
}
|
||||
let Some(seq) = u16::try_from(armed.anim_id)
|
||||
.ok()
|
||||
.and_then(|id| facts.sequence(id))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
(self.clock_ms as i64 - armed.anchor_ms) >= i64::from(seq.duration_ms)
|
||||
}
|
||||
}
|
||||
|
||||
/// The Minimap widget's modeled state — the client keeps **two independent zoom indices**, chosen by
|
||||
/// whether the player is inside a WMO (wow-re minimap node, `wmo-interior-minimap.md` finding 2 Q7
|
||||
/// CORRECTION): the inside flag `0xceaa60` routes the outdoor index `0x86f698` (CVar `minimapZoom`,
|
||||
|
||||
@@ -173,13 +173,14 @@ impl<T> Arena<T> {
|
||||
|
||||
mod kinds;
|
||||
pub use kinds::{
|
||||
slider_fraction, slider_grab, ButtonFont, ButtonState, ColorSelectState, CooldownState,
|
||||
EditAction, EditBoxState, EditOutcome, EditUnit, FrameKind, InsertMode, KindState,
|
||||
MessageFrameState, MessageLine, MinimapState, ModelState, RegionKind, ScrollFrameState,
|
||||
ScrollingMessageState, SliderState, StatusBarState, TooltipState, COOLDOWN_FLASH_SECS,
|
||||
MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_MASK, MINIMAP_DEFAULT_PLAYER_MODEL,
|
||||
MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP,
|
||||
TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH,
|
||||
model_key, slider_fraction, slider_grab, ArmedSequence, ButtonFont, ButtonState,
|
||||
ColorSelectState, CooldownState, EditAction, EditBoxState, EditOutcome, EditUnit, FrameKind,
|
||||
InsertMode, KindState, MessageFrameState, MessageLine, MinimapState, ModelFileFacts,
|
||||
ModelPlayHead, ModelState, RegionKind, ScrollFrameState, ScrollingMessageState, SequenceFacts,
|
||||
SliderState, StatusBarState, TooltipState, COOLDOWN_FLASH_SECS, MINIMAP_DEFAULT_ARROW_MODEL,
|
||||
MINIMAP_DEFAULT_MASK, MINIMAP_DEFAULT_PLAYER_MODEL, MINIMAP_DEFAULT_ZOOM,
|
||||
MINIMAP_ENGINE_CHILDREN, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS,
|
||||
TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -732,9 +733,17 @@ impl WidgetArena {
|
||||
if matches!(kind, FrameKind::Minimap) {
|
||||
self.minimap_created += 1;
|
||||
}
|
||||
// The model panes ride the same registry: their scene clock advances per tick and their
|
||||
// completion edge is read there (decision 2007).
|
||||
let ticked = matches!(
|
||||
kind,
|
||||
FrameKind::ScrollingMessageFrame | FrameKind::MessageFrame | FrameKind::Cooldown
|
||||
FrameKind::ScrollingMessageFrame
|
||||
| FrameKind::MessageFrame
|
||||
| FrameKind::Cooldown
|
||||
| FrameKind::Model
|
||||
| FrameKind::PlayerModel
|
||||
| FrameKind::DressUpModel
|
||||
| FrameKind::TabardModel
|
||||
);
|
||||
let (index, generation) = self.frames.insert(frame);
|
||||
let handle = FrameHandle { index, generation };
|
||||
|
||||
@@ -21,7 +21,7 @@ use benilla_assets::LockRecover;
|
||||
use benilla_assets::{AssetSet, WorldAssets};
|
||||
use benilla_formats::{
|
||||
load_ground_effect_catalog, load_m2_mesh, scatter_ground_doodads, ChunkMesh,
|
||||
GroundDoodadPlacement, GroundEffectCatalog, RenderSubmesh, SHADOW_MAP_SIZE,
|
||||
GroundDoodadPlacement, GroundEffectCatalog, ModelBlend, RenderSubmesh, SHADOW_MAP_SIZE,
|
||||
};
|
||||
|
||||
/// Ground clutter as its own subsystem: loads the `GroundEffect*` catalog + config at startup, and runs
|
||||
@@ -175,24 +175,33 @@ pub(crate) struct ClutterGeometry(
|
||||
/// per-tile hitch. Owned by its tile (despawned on unload, which cascades to `built`).
|
||||
#[derive(Component)]
|
||||
pub(crate) struct ClutterChunk {
|
||||
/// Chunk centre in Bevy space — its distance from the camera gates build vs teardown.
|
||||
center: Vec3,
|
||||
/// The chunk's clutter AABB in Bevy space (its terrain box, grown by a tuft's height).
|
||||
/// Build/teardown gate on the camera's distance to the BOX — its nearest point — which is what
|
||||
/// "any of this chunk is still within reach" actually means. A centre distance has to carry a
|
||||
/// chunk-radius margin to approximate it, and that margin is a horizontal figure: on relief a
|
||||
/// 33 yd chunk's vertical spread pushes its centre past the margin while its near corner is
|
||||
/// still deep inside the visible band, and the whole chunk's grass pops in on approach (2004).
|
||||
bounds: (Vec3, Vec3),
|
||||
/// Scattered placements grouped by model path; each becomes one merged mesh per submesh when built.
|
||||
models: Vec<(String, Vec<ShadedPlacement>)>,
|
||||
/// The spawned merged meshes while built (children of this entity); empty ⇒ not currently built.
|
||||
built: Vec<Entity>,
|
||||
}
|
||||
|
||||
/// A chunk centre this far (yd) beyond the fade horizon still has near-corner grass inside the chunk, so
|
||||
/// we build out to here. ≈ the 33.3 yd chunk's half-diagonal. At the horizon the fade ramp is already
|
||||
/// alpha 0, so building/tearing down at this range is invisible (no pop) — the same reason the reference
|
||||
/// ties the build distance to the fade end.
|
||||
const CLUTTER_BUILD_MARGIN: f32 = 24.0;
|
||||
/// Slack (yd) past the reach computed below, so a chunk is built a moment before any of its grass
|
||||
/// could be visible and the per-frame build cap has room to spend. Past the reach the ramp is
|
||||
/// already alpha 0, so building/tearing down here is invisible — the same reason the reference ties
|
||||
/// its build distance to the fade end.
|
||||
const CLUTTER_BUILD_MARGIN: f32 = 8.0;
|
||||
|
||||
/// Hysteresis (yd) between the build distance and the teardown distance, so a chunk hovering at the
|
||||
/// boundary doesn't thrash build↔despawn every frame.
|
||||
const CLUTTER_TEARDOWN_HYSTERESIS: f32 = 6.0;
|
||||
|
||||
/// How far above the terrain surface a tuft reaches (yd) — the chunk's clutter AABB is its terrain
|
||||
/// box grown by this, so the gate measures the geometry that actually draws.
|
||||
const CLUTTER_TUFT_HEIGHT: f32 = 3.0;
|
||||
|
||||
/// Build at most this many chunks' clutter per frame, so entering a dense area (or a teleport) spreads
|
||||
/// the mesh-build + GPU upload over a few frames instead of one stutter.
|
||||
const CLUTTER_BUILDS_PER_FRAME: usize = 8;
|
||||
@@ -295,17 +304,20 @@ pub(crate) fn scatter_tile_clutter(
|
||||
if by_model.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Chunk centre (average of its vertices) in Bevy space — what the LOD system measures.
|
||||
let n = chunk.positions.len().max(1) as f32;
|
||||
let sum = chunk
|
||||
.positions
|
||||
.iter()
|
||||
.fold([0.0f32; 3], |a, p| [a[0] + p[0], a[1] + p[1], a[2] + p[2]]);
|
||||
let center = wow_to_bevy([sum[0] / n, sum[1] / n, sum[2] / n]);
|
||||
// The chunk's terrain AABB in Bevy space — what the LOD system measures against.
|
||||
let mut lo = Vec3::splat(f32::MAX);
|
||||
let mut hi = Vec3::splat(f32::MIN);
|
||||
for p in &chunk.positions {
|
||||
let v = wow_to_bevy(*p);
|
||||
lo = lo.min(v);
|
||||
hi = hi.max(v);
|
||||
}
|
||||
// Tufts stand ON the surface, so the drawn geometry reaches a little above the terrain box.
|
||||
hi.y += CLUTTER_TUFT_HEIGHT;
|
||||
entities.push(
|
||||
commands
|
||||
.spawn(ClutterChunk {
|
||||
center,
|
||||
bounds: (lo, hi),
|
||||
models: by_model.into_iter().collect(),
|
||||
built: Vec::new(),
|
||||
})
|
||||
@@ -385,10 +397,17 @@ fn build_chunk_clutter(
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors);
|
||||
mesh.insert_indices(Indices::U32(indices));
|
||||
let mesh = meshes.add(mesh);
|
||||
// **The detail-doodad pass owns its render state** — it is not the batch's (2004).
|
||||
// The reference sets one state for every tuft in the frame (`0x6b2b80`: blend mode 2
|
||||
// = SRC_ALPHA/INV_SRC_ALPHA, ALPHAREF = `detailDoodadAlpha` 128, cull OFF, depth-write
|
||||
// ON) and its per-slot draw `0x6b2d60` binds nothing but the texture — the M2 batch's
|
||||
// own blend/cull is never consulted for a detail doodad. Passing `sub.blend` through
|
||||
// gave the two Opaque-authored detail models no cutout at all (their transparent
|
||||
// margin drew as solid card) and left five single-sided ones showing only one face.
|
||||
let material = assets.model_material(
|
||||
sub.texture.as_deref(),
|
||||
sub.blend,
|
||||
sub.two_sided,
|
||||
ModelBlend::AlphaTest, // the pass's cutout, not the batch's authored blend
|
||||
true, // cull off for every tuft, like the reference's pass
|
||||
Some(alpha_ref), // detail-doodad alpha-test ref (≈0.5), not the higher general key
|
||||
Some(fade_far), // detail-doodad distance fade (≈70 yd horizon — opacity, dc30e1f)
|
||||
false, // clutter is not WMO (keeps its ground-normal N·L path)
|
||||
@@ -430,15 +449,40 @@ fn build_chunk_clutter(
|
||||
out
|
||||
}
|
||||
|
||||
/// The per-chunk clutter LOD: build a chunk's clutter when its centre comes within the detail-doodad
|
||||
/// horizon (+ a chunk-radius margin) and tear it down when it leaves — the reference's `CDetailDoodad`
|
||||
/// lifecycle (`ground-effects.md` §7). Bounds live grass to a ~70 yd bubble (not every loaded tile) and,
|
||||
/// with the per-frame build cap, spreads the cost so a tile-load no longer builds 256 chunks at once.
|
||||
/// Build/teardown happen where the fade ramp is already alpha 0, so they're invisible.
|
||||
/// Squared distance from a point to an axis-aligned box — zero inside it. The clutter LOD's measure:
|
||||
/// "is any of this chunk within reach", which a centre distance can only approximate.
|
||||
fn box_distance_squared(p: Vec3, (lo, hi): (Vec3, Vec3)) -> f32 {
|
||||
(lo - p).max(p - hi).max(Vec3::ZERO).length_squared()
|
||||
}
|
||||
|
||||
/// How far (as a multiple of the fade horizon) a chunk can sit from the eye and still have grass the
|
||||
/// shader will draw.
|
||||
///
|
||||
/// The fade is keyed on **view-space depth** (`wow_model.wgsl`, the reference's camera-space texgen),
|
||||
/// so the horizon is a plane across the view, not a sphere around the eye: a fragment out at the
|
||||
/// frustum's corner is `1/cos θ` further in straight-line distance than one dead ahead at the same
|
||||
/// depth. The LOD gate measures straight-line distance — it must not depend on where the camera
|
||||
/// happens to be pointing, or a turn would drop and rebuild chunks that are still being drawn — so it
|
||||
/// takes the worst case, the corner ray, and that is exactly this factor. Derived from the live
|
||||
/// projection rather than pinned, because it moves with the aspect ratio: ~1.31 at 16:9 and ~1.45 at
|
||||
/// 21:9, and a constant tuned on one monitor silently clips the screen edges on a wider one.
|
||||
fn frustum_corner_reach(fov_y: f32, aspect: f32) -> f32 {
|
||||
let tan_v = (fov_y * 0.5).tan();
|
||||
let tan_h = tan_v * aspect;
|
||||
(1.0 + tan_v * tan_v + tan_h * tan_h).sqrt()
|
||||
}
|
||||
|
||||
/// The per-chunk clutter LOD: build a chunk's clutter when its BOX comes within reach of the
|
||||
/// detail-doodad horizon and tear it down when it leaves — the reference's `CDetailDoodad` lifecycle
|
||||
/// (per-chunk build/unlink at the 70 yd `[0x867958]`, which it measures as the nearest view-space
|
||||
/// depth of the chunk's bounding sphere and then never frees). Bounds live grass to a bubble around
|
||||
/// the player instead of every loaded tile and, with the per-frame build cap, spreads the cost so a
|
||||
/// tile-load no longer builds 256 chunks at once. Build/teardown happen where the fade ramp is
|
||||
/// already alpha 0, so they are invisible.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn stream_chunk_clutter(
|
||||
mut commands: Commands,
|
||||
cam: Query<&GlobalTransform, With<WorldCamera>>,
|
||||
cam: Query<(&GlobalTransform, Option<&Projection>), With<WorldCamera>>,
|
||||
mut chunks: Query<(Entity, &mut ClutterChunk)>,
|
||||
cfg: Res<ClutterConfig>,
|
||||
geometry: Option<ResMut<ClutterGeometry>>,
|
||||
@@ -454,14 +498,26 @@ pub(crate) fn stream_chunk_clutter(
|
||||
let (Some(mut geometry), Some(mut assets)) = (geometry, assets) else {
|
||||
return;
|
||||
};
|
||||
let Some(cam_pos) = cam.iter().next().map(|t| t.translation()) else {
|
||||
let Some((cam_tf, projection)) = cam.iter().next() else {
|
||||
return;
|
||||
};
|
||||
let build_d2 = (cfg.fade_far + CLUTTER_BUILD_MARGIN).powi(2);
|
||||
let drop_d2 = (cfg.fade_far + CLUTTER_BUILD_MARGIN + CLUTTER_TEARDOWN_HYSTERESIS).powi(2);
|
||||
let cam_pos = cam_tf.translation();
|
||||
// The straight-line reach the view-depth fade implies at the frustum's corner (see the helper).
|
||||
// A missing/non-perspective projection falls back to Bevy's own perspective default, the one
|
||||
// `view.rs` leaves in place (≈ the reference's 44.1° vertical) at 16:9.
|
||||
let reach = cfg.fade_far
|
||||
* match projection {
|
||||
Some(Projection::Perspective(p)) => frustum_corner_reach(p.fov, p.aspect_ratio),
|
||||
_ => {
|
||||
let d = PerspectiveProjection::default();
|
||||
frustum_corner_reach(d.fov, d.aspect_ratio)
|
||||
}
|
||||
};
|
||||
let build_d2 = (reach + CLUTTER_BUILD_MARGIN).powi(2);
|
||||
let drop_d2 = (reach + CLUTTER_BUILD_MARGIN + CLUTTER_TEARDOWN_HYSTERESIS).powi(2);
|
||||
let mut budget = CLUTTER_BUILDS_PER_FRAME;
|
||||
for (ent, mut cc) in &mut chunks {
|
||||
let d2 = cam_pos.distance_squared(cc.center);
|
||||
let d2 = box_distance_squared(cam_pos, cc.bounds);
|
||||
if cc.built.is_empty() {
|
||||
if d2 <= build_d2 && budget > 0 {
|
||||
budget -= 1;
|
||||
@@ -487,3 +543,75 @@ pub(crate) fn stream_chunk_clutter(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The ramp `wow_model.wgsl` evaluates, mirrored here so the reference's four landmarks are
|
||||
/// **checkable** rather than a comment. Not a second implementation to keep in sync — a pin: if
|
||||
/// anyone retunes `0.75`, the `254`/`256` texel-centre constants or the `252/255` cap without
|
||||
/// re-deriving them, these numbers move and the test says so.
|
||||
fn ramp(z: f32, far: f32) -> f32 {
|
||||
let near = far * 0.75;
|
||||
let u = (z - near) / (far - near);
|
||||
((254.0 - 256.0 * u) / 255.0).clamp(0.0, 252.0 / 255.0)
|
||||
}
|
||||
|
||||
/// wow-re `terrain/scratch/detail-doodad-distance-fade.md`: a 64-texel CLAMP/LINEAR ramp whose
|
||||
/// texel centres give `alpha = (254 − 256u)/255`, capped at texel 0's `252/255`. So the plateau
|
||||
/// runs to 52.63672 yd (not 52.5), the ramp hits zero at 69.86328 yd (not 70), the slope is
|
||||
/// −0.0573670 per yard, and the 128/255 detail-doodad cutout erases a fully-opaque texel at
|
||||
/// 61.11328 yd — the horizon grass actually vanishes at, well short of the 70 yd draw distance.
|
||||
#[test]
|
||||
fn the_ramp_hits_the_reference_landmarks() {
|
||||
assert!(
|
||||
(ramp(0.0, 70.0) - 252.0 / 255.0).abs() < 1e-6,
|
||||
"near plateau"
|
||||
);
|
||||
assert!(
|
||||
(ramp(52.63672, 70.0) - 252.0 / 255.0).abs() < 1e-5,
|
||||
"plateau ends at 52.63672, not 52.5"
|
||||
);
|
||||
assert!(
|
||||
ramp(52.7, 70.0) < 252.0 / 255.0,
|
||||
"past the plateau it falls"
|
||||
);
|
||||
assert!(ramp(69.86328, 70.0) < 1e-5, "zero at 69.86328, not 70");
|
||||
assert_eq!(ramp(70.0, 70.0), 0.0);
|
||||
let slope = ramp(60.0, 70.0) - ramp(61.0, 70.0);
|
||||
assert!((slope - 0.0573670).abs() < 1e-6, "slope per yard");
|
||||
// The cutout erases a fully-opaque texel where the ramp crosses the 128/255 reference.
|
||||
assert!(ramp(61.11328, 70.0) < DETAIL_DOODAD_ALPHA_REF + 1e-5);
|
||||
assert!(ramp(61.11, 70.0) > DETAIL_DOODAD_ALPHA_REF);
|
||||
}
|
||||
|
||||
/// The gate measures the chunk's box, not its centre: zero inside, and the nearest point outside.
|
||||
#[test]
|
||||
fn box_distance_measures_the_nearest_point() {
|
||||
let b = (Vec3::new(-1.0, -1.0, -1.0), Vec3::new(1.0, 1.0, 1.0));
|
||||
assert_eq!(box_distance_squared(Vec3::ZERO, b), 0.0);
|
||||
assert_eq!(box_distance_squared(Vec3::new(4.0, 0.0, 0.0), b), 9.0);
|
||||
// A tall thin chunk: a point level with the top face is 3 yd away, though its CENTRE — the
|
||||
// old measure — is 5 yd away. That gap is the relief case that popped whole chunks.
|
||||
let tall = (Vec3::new(-1.0, -4.0, -1.0), Vec3::new(1.0, 4.0, 1.0));
|
||||
assert_eq!(box_distance_squared(Vec3::new(4.0, 4.0, 0.0), tall), 9.0);
|
||||
}
|
||||
|
||||
/// The fade is keyed on view depth, so the LOD's straight-line reach is the corner ray's — and it
|
||||
/// widens with the aspect ratio, which is why it is derived and not pinned to one monitor.
|
||||
#[test]
|
||||
fn the_corner_reach_widens_with_the_aspect() {
|
||||
let fov = PerspectiveProjection::default().fov;
|
||||
let wide = frustum_corner_reach(fov, 16.0 / 9.0);
|
||||
let ultrawide = frustum_corner_reach(fov, 21.0 / 9.0);
|
||||
assert!((wide - 1.309).abs() < 0.01, "16:9 reach was {wide}");
|
||||
assert!(
|
||||
(ultrawide - 1.451).abs() < 0.01,
|
||||
"21:9 reach was {ultrawide}"
|
||||
);
|
||||
assert!(ultrawide > wide);
|
||||
// Dead ahead is the degenerate case: no width, no extra reach.
|
||||
assert!((frustum_corner_reach(0.0, 0.0) - 1.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ use bevy::core_pipeline::FullscreenShader;
|
||||
use bevy::ecs::query::QueryItem;
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::camera::ExtractedCamera;
|
||||
use bevy::render::diagnostic::RecordDiagnostics;
|
||||
use bevy::render::extract_component::{ExtractComponent, ExtractComponentPlugin};
|
||||
use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin};
|
||||
use bevy::render::render_graph::{
|
||||
@@ -847,7 +848,10 @@ impl ViewNode for FfxGlowNode {
|
||||
);
|
||||
|
||||
// The filter passes (byte-pinned chain): source→¼ (one Box4), ¼a→¼b (H), ¼b→¼a (V).
|
||||
let filter_passes: [(&str, &RenderPipeline, &BindGroup, &TextureView); 3] = [
|
||||
// Each pass opens its own diagnostic span (`render/ffx_glow_*/elapsed_gpu` on a device
|
||||
// that times passes): the journal's `gpu_glow` column is their sum (2008).
|
||||
let diagnostics = render_context.diagnostic_recorder();
|
||||
let filter_passes: [(&'static str, &RenderPipeline, &BindGroup, &TextureView); 3] = [
|
||||
(
|
||||
"ffx_glow_down_quarter",
|
||||
downsample,
|
||||
@@ -883,9 +887,11 @@ impl ViewNode for FfxGlowNode {
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
let span = diagnostics.pass_span(&mut pass, label);
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, bind, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
span.end(&mut pass);
|
||||
}
|
||||
|
||||
// Combine: screen + w·blur² (gamma-space byte math in the shader) → the post destination.
|
||||
@@ -916,9 +922,11 @@ impl ViewNode for FfxGlowNode {
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
let span = diagnostics.pass_span(&mut pass, "ffx_glow_combine");
|
||||
pass.set_pipeline(combine);
|
||||
pass.set_bind_group(0, &bind, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
span.end(&mut pass);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,14 @@ impl M2BatchMaterials<'_> {
|
||||
self.light.is_some()
|
||||
}
|
||||
|
||||
/// The material store this param already holds — for a spawner that builds a batch's world
|
||||
/// material here and then clones it against a light buffer of its own (the UI model tiles'
|
||||
/// twin, decision 2013). Taking a second `ResMut<Assets<WowModelMaterial>>` beside this
|
||||
/// param is a schedule-time conflict (B0002), which is why the store is reached through it.
|
||||
pub fn materials(&mut self) -> &mut Assets<WowModelMaterial> {
|
||||
&mut self.materials
|
||||
}
|
||||
|
||||
/// One steady material for an authored batch drawn in the world on the sky lane.
|
||||
///
|
||||
/// `order` is the authored batch index + 1 (`0` = unordered), the transparent-pass sort bias
|
||||
|
||||
@@ -262,6 +262,10 @@ pub struct ParticleEmitter {
|
||||
/// The live instance pool (one slot per drawn particle, grown on demand, hidden past the
|
||||
/// live count). Each slot's mesh entities carry per-instance tint-clone materials.
|
||||
model_instances: Vec<model::ModelInstance>,
|
||||
/// The lane's size unit for this cloud's unflagged particle half-extents —
|
||||
/// [`quads::DrawFrame::size_scale`]. `1.0` (a yard) for every world lane; a UI model tile
|
||||
/// sets its pixels-per-unit through [`Self::set_size_scale`].
|
||||
size_scale: f32,
|
||||
}
|
||||
|
||||
/// One wired CHILD emitter (see [`ParticleEmitter::children`]): the recursion model's own
|
||||
@@ -352,6 +356,13 @@ impl ParticleEmitter {
|
||||
self.particles.len()
|
||||
}
|
||||
|
||||
/// Set the lane's size unit for this cloud — see [`quads::DrawFrame::size_scale`]. A UI
|
||||
/// model tile (decision 2008) stores its cloud in device pixels and passes the reference's
|
||||
/// pixels-per-model-unit for a particle's half-extent; every world lane leaves the default.
|
||||
pub fn set_size_scale(&mut self, size_scale: f32) {
|
||||
self.size_scale = size_scale;
|
||||
}
|
||||
|
||||
/// The authored def — read by the particle census probe ([`crate::capture`]), which prints
|
||||
/// per-emitter facts (blend, texture, rate keys) beside the live count.
|
||||
pub fn def(&self) -> &ParticleEmitterDef {
|
||||
@@ -687,6 +698,7 @@ pub fn spawn_emitter(
|
||||
children: Vec::new(),
|
||||
geometry: emitter.geometry.clone(),
|
||||
model_instances: Vec::new(),
|
||||
size_scale: 1.0,
|
||||
},
|
||||
));
|
||||
if emitter.recursion.is_some() {
|
||||
|
||||
@@ -169,7 +169,7 @@ pub(super) fn dump_emitter(
|
||||
.enumerate()
|
||||
{
|
||||
let center = particle_center(dframe, placement, p);
|
||||
let half = particle_half(def, placement, p);
|
||||
let half = particle_half(def, placement, p, dframe.size_scale);
|
||||
// The additive contribution's other factor: the over-life colour this particle's quad
|
||||
// carries (raw authored values, as `expand_quads` pushes them).
|
||||
let rgba = def.over_life.sample((p.age / p.life).clamp(0.0, 1.0)).color;
|
||||
|
||||
@@ -70,6 +70,14 @@ pub(super) struct DrawFrame {
|
||||
/// The owning MODEL's render alpha, folded into every particle's alpha channel — the
|
||||
/// reference's `emitter+0x1a8` (decision 0827). 1.0 for a model that isn't fading.
|
||||
pub(crate) alpha: f32,
|
||||
/// The lane's **size unit** — what one model unit of a particle's half-extent is in this
|
||||
/// cloud's stored frame. `1.0` everywhere in the world (a yard is a yard). A UI model tile
|
||||
/// (decision 2008) stores its particles in device pixels, and the reference maps a
|
||||
/// widget's particle half-extent through the screen — `768·√(a²+1)` FrameXML units per
|
||||
/// unit, the instance scale NOT included (wow-re `modelframe-clip-and-scale.md` §6) — so
|
||||
/// the tile sets this to that many pixels per unit; an emitter flagged to scale with its
|
||||
/// instance already reads the instance through `placement.scale` and takes 1.0 here.
|
||||
pub(crate) size_scale: f32,
|
||||
}
|
||||
|
||||
/// One particle's world-space quad centre — THE point [`expand_quads`] rasterizes around, factored
|
||||
@@ -99,13 +107,18 @@ pub(super) fn draw_gated(def: &ParticleEmitterDef, p: &Particle) -> bool {
|
||||
/// One particle's rendered half-extent — mirrors the `half` term inside [`expand_quads`] (over-life
|
||||
/// size × gated twinkle × instance scale), which computes it inline because it needs the noise and
|
||||
/// over-life samples for other attributes too. **If that expression changes, change this.**
|
||||
pub(super) fn particle_half(def: &ParticleEmitterDef, placement: &Transform, p: &Particle) -> f32 {
|
||||
pub(super) fn particle_half(
|
||||
def: &ParticleEmitterDef,
|
||||
placement: &Transform,
|
||||
p: &Particle,
|
||||
size_scale: f32,
|
||||
) -> f32 {
|
||||
let noise = twinkle_noise(def.twinkle_speed, p.age, p.phase);
|
||||
let u_age = (p.age / p.life).clamp(0.0, 1.0);
|
||||
let scale = if def.scale_size_by_instance() {
|
||||
placement.scale.x.max(1e-4)
|
||||
} else {
|
||||
1.0
|
||||
size_scale
|
||||
};
|
||||
def.over_life.sample(u_age).size * def.twinkle(noise) * scale
|
||||
}
|
||||
@@ -124,11 +137,13 @@ pub(super) fn expand_quads(
|
||||
let anchored = frame.anchored;
|
||||
let (cam_right, cam_up) = (cam.right, cam.up);
|
||||
// Size scales with the instance transform only when the emitter flags it (0x200) — an
|
||||
// instance-scaled prop otherwise scales its particle *positions* only (wow-re B2).
|
||||
// instance-scaled prop otherwise scales its particle *positions* only (wow-re B2). The
|
||||
// unflagged size is in the lane's size unit ([`DrawFrame::size_scale`] — a yard, or a UI
|
||||
// tile's pixels per model unit).
|
||||
let scale = if def.scale_size_by_instance() {
|
||||
placement.scale.x.max(1e-4)
|
||||
} else {
|
||||
1.0
|
||||
frame.size_scale
|
||||
};
|
||||
// The XY-quad head basis (file flag 0x1000, wow-re `part-tiled-corner-builder.md`,
|
||||
// VERIFIED): the quad lies flat in the emitter's model-space XY plane carried by the
|
||||
@@ -349,6 +364,7 @@ mod tests {
|
||||
anchored: true,
|
||||
ride: crate::ride_frame::StoredFrame::default(),
|
||||
alpha,
|
||||
size_scale: 1.0,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
expand_quads(&def, &pool, &frame, &Transform::IDENTITY, &cam, &mut out);
|
||||
|
||||
@@ -738,6 +738,7 @@ pub(super) fn simulate_particles(
|
||||
seq,
|
||||
rng,
|
||||
owner_reach,
|
||||
size_scale,
|
||||
water_bound,
|
||||
texture,
|
||||
recursion: _,
|
||||
@@ -1218,6 +1219,7 @@ pub(super) fn simulate_particles(
|
||||
anchored,
|
||||
alpha: *alpha,
|
||||
ride: *ride,
|
||||
size_scale: *size_scale,
|
||||
};
|
||||
let cam = CamBasis {
|
||||
right: e_right,
|
||||
@@ -1658,6 +1660,7 @@ mod tests {
|
||||
anchored: true, // 0x10 CLEAR — the world store
|
||||
alpha: 1.0,
|
||||
ride: crate::ride_frame::StoredFrame::default(), // on the ground: no fold
|
||||
size_scale: 1.0,
|
||||
};
|
||||
// Every host pose we can think of, including ones no bone reaches.
|
||||
for placement in [
|
||||
@@ -1699,6 +1702,7 @@ mod tests {
|
||||
anchored: true,
|
||||
alpha: 1.0,
|
||||
ride,
|
||||
size_scale: 1.0,
|
||||
};
|
||||
assert_eq!(
|
||||
particle_center(&frame, &placement, &p),
|
||||
@@ -1729,6 +1733,7 @@ mod tests {
|
||||
anchored: false, // 0x10 SET — the emitter-local store
|
||||
alpha: 1.0,
|
||||
ride: crate::ride_frame::StoredFrame::default(),
|
||||
size_scale: 1.0,
|
||||
};
|
||||
let moved = Transform::from_translation(Vec3::new(10.0, 0.0, 0.0));
|
||||
assert_eq!(
|
||||
|
||||
@@ -21,6 +21,7 @@ use bevy::image::Image;
|
||||
use bevy::mesh::VertexBufferLayout;
|
||||
use bevy::platform::collections::HashMap;
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::diagnostic::RecordDiagnostics;
|
||||
use bevy::render::extract_component::{ExtractComponent, ExtractComponentPlugin};
|
||||
use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin};
|
||||
use bevy::render::mesh::allocator::MeshAllocator;
|
||||
@@ -811,6 +812,9 @@ impl ViewNode for StaticGxNode {
|
||||
}
|
||||
let depth_attachment = depth.get_attachment(StoreOp::Store);
|
||||
let color_attachment = target.get_color_attachment();
|
||||
// The pass's diagnostic span — `render/static_gx/elapsed_gpu` where the device times
|
||||
// passes (Vulkan/DX12), the CPU span everywhere: the journal's `gpu_static` column (2008).
|
||||
let diagnostics = render_context.diagnostic_recorder();
|
||||
let mut pass = render_context.begin_tracked_render_pass(RenderPassDescriptor {
|
||||
label: Some("static_gx"),
|
||||
color_attachments: &[Some(color_attachment)],
|
||||
@@ -818,6 +822,7 @@ impl ViewNode for StaticGxNode {
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
let span = diagnostics.pass_span(&mut pass, "static_gx");
|
||||
pass.set_bind_group(0, &view_bind.0, &[view_offset.offset]);
|
||||
for (gpu, draw, sel) in &resolved {
|
||||
let Some(mesh) = meshes.get(draw.mesh.id()) else {
|
||||
@@ -855,6 +860,7 @@ impl ViewNode for StaticGxNode {
|
||||
);
|
||||
}
|
||||
}
|
||||
span.end(&mut pass);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user