diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index a4eb2227..967c5ee4 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -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::() .init_resource::() .init_resource::() + .init_resource::() .add_plugins(CvarPlugin); app.insert_non_send_resource(UiScript::new().unwrap()); app diff --git a/crates/benilla-app/src/dev.rs b/crates/benilla-app/src/dev.rs index e0f498d1..a8a25deb 100644 --- a/crates/benilla-app/src/dev.rs +++ b/crates/benilla-app/src/dev.rs @@ -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=` 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=`, or the `fpsJournal` CVar — is + // registered from `lib.rs` in every build since 2008: it is the one instrument a + // player runs for us.) } } } diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index e8bd7394..88774630 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -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=` 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 `` 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) diff --git a/crates/benilla-app/src/local_state.rs b/crates/benilla-app/src/local_state.rs index 7087fcfb..e1b82c01 100644 --- a/crates/benilla-app/src/local_state.rs +++ b/crates/benilla-app/src/local_state.rs @@ -300,7 +300,7 @@ pub(crate) fn screenshots_dir() -> Option { } /// `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 { /// 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 { 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 { + 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. diff --git a/crates/benilla-app/src/minimap/mod.rs b/crates/benilla-app/src/minimap/mod.rs index a5c3213a..c4124aaa 100644 --- a/crates/benilla-app/src/minimap/mod.rs +++ b/crates/benilla-app/src/minimap/mod.rs @@ -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>, @@ -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` ``, 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); } } diff --git a/crates/benilla-app/src/minimap/ping.rs b/crates/benilla-app/src/minimap/ping.rs index 76e804b9..228fd690 100644 --- a/crates/benilla-app/src/minimap/ping.rs +++ b/crates/benilla-app/src/minimap/ping.rs @@ -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 ``'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` `` 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 `` 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 `` 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 ``. -/// 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 `` 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 `` 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>, - /// The static centre. - pub(super) ping2: Option>, - /// The expanding ring. - pub(super) ping4: Option>, -} /// 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, - /// [`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, - /// 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, - 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>, 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>, mut ping: ResMut, - time: Res