Files
benilla-octo/crates/benilla/src/debug_panel/mod.rs
T
SamandClaude Fable 5 c1ba444119 the micro menu, the ping meter, and the PvP flag
- The main menu bar's two empty recesses fill. The micro menu — the eight arched
  buttons: character, spellbook, talents, quest log, socials, world map, main menu,
  help — sits in the dark panel between the page arrows and the bags, transcribed
  from the reference's own layout: the character button wears your portrait, a button
  holds itself down while its panel is up, and the talent button appears at level 10.
  The latency meter fills the one slot the bar art leaves transparent for exactly it,
  tinted green/yellow/red on the reference's 300/600 ms thresholds and drawn UNDER
  the bar art, seen through its window, the way 1.12 actually does it. The socials
  and main-menu buttons find their panels one snapshot later; help still waits.
- The UI explains itself on hover: 1.12 ships its detailed two-line tooltips ON — a
  FrameXML default, not the opt-in extra it is often remembered as — so the micro
  buttons, your own portrait, the XP bar, the five bag buttons and the ping meter
  all answer a hover with the reference's own explanations.
- The PvP flag, end to end: crossed swords on the player, target and party frames,
  driven by the two descriptor bits the wire actually carries, and `/pvp` speaks —
  the yellow toast plus the system chat line, the OFF sentence being the one that
  warns the flag lingers for five minutes. It really does linger: turning it off
  waits out the server's timer, which looks like a bug and is the reference.
- Quest markers stopped freezing. The grey `!` over a questgiver above your level
  goes gold the moment you ding, an NPC re-entering view is asked afresh instead of
  wearing a session-old answer, and the marker drops when the questgiver bit goes
  off — the re-ask triggers mirror the reference's own set, plus one named addition
  (your own level changing).
- Water and heights: swimming downhill no longer bounces between swim and fall ten
  times a second — the surface line is a two-sided constraint, so the swimmer
  settles with the river instead of being left behind by it. And collision height
  is per-unit now, read from the unit's own model: a gnome used to swim with a
  third of a yard of water over her head because five depth lines were quoting a
  human's number.
- A round of pixel honesty in text and chat: the newest chat line no longer wears a
  wider drop shadow than every line above it (shadows move in whole pixels now),
  keeps its descenders, and hover boxes stop painting over the chat body. The chat
  input's history browse survives programmatic edits, a failed script chunk warns
  instead of vanishing, and recall deepens to 64 lines. Idle animation got honest
  too: a model's idle is Stand resolved through its own lookup table — never
  file order, which had the duel flag bobbing on its spawn clip — and an armed idle
  is culled on the box its animation authors, not the bind pose, so the flag stops
  vanishing at most camera angles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 19:17:47 +07:00

656 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Standardized in-window debug panel (egui).
//!
//! The panel is a rounded, translucent **window floated off the top-right corner** (a gap from the
//! edges, like the perf pill) — it overlays the 3D view, which renders full-screen underneath and is
//! never letterboxed. Fixed width (no resize handle). Press the backtick key (`` ` ``) to show/hide it.
//! Its look (translucent dark backing + crisp text) is shared with the perf pill and the inspector card
//! via [`OVERLAY_FILL`] / [`overlay_text`], so the dev overlays read as one family.
//!
//! ## How it's wired (so future subsystems slot in cleanly)
//! - [`DebugState`] is one resource grouped into per-subsystem sections ([`ModelDebug`],
//! [`LightingDebug`], …; new subsystems add sibling fields).
//! - The panel UI ([`debug_panel_ui`]) reads/writes that resource via egui widgets.
//! - Small **apply** systems turn the resource into world changes (e.g. [`apply_model_visibility`]).
//! Each only does work when its slice of the state actually changes (tracked with a `Local`
//! snapshot), so leaving the panel open costs nothing. (Lighting is now resolved in `lighting.rs`;
//! this section is time controls + a readout, not knobs.)
//! - [`EguiPointerOver`] publishes "the mouse is talking to the egui overlays" each pass; the
//! combined source of truth gameplay reads is [`crate::ui_script::PointerOverUi`] (owned by its
//! combiner, decision 0026 — dev plugins must be droppable without breaking gameplay reads).
//!
//! Adding a section for a new subsystem is therefore: a `FooDebug` field, a few widgets in the
//! panel, and one `apply_foo` system — no plumbing changes. (Weather is the worked example.)
//!
//! Rendering uses bevy_egui's manual-context mode: auto-creation is disabled in [`DebugPanelPlugin`]
//! and a dedicated full-window overlay camera composites egui over the 3D scene (alpha-blended, no
//! clear). See bevy_egui's `side_panel` example.
use bevy::camera::visibility::RenderLayers;
use bevy::camera::CameraOutputMode;
use bevy::prelude::*;
use bevy::render::render_resource::BlendState;
use bevy_egui::{
egui, EguiContexts, EguiGlobalSettings, EguiInput, EguiPlugin, EguiPreUpdateSet,
EguiPrimaryContextPass, PrimaryEguiContext,
};
use benilla_formats::ModelBlend;
use crate::lighting::{ClockSource, GameClock, WowLighting};
use crate::particles::ParticleTuning;
use crate::view::ViewDistance;
/// The debug state — resource-only, faithful defaults (decision 0026: the always-present config
/// layer; this module is only its editor).
mod state;
pub use state::DebugState;
/// The per-frame model-visibility apply system (toggles + the far-clip wall-cull + small-prop fade).
mod visibility;
use visibility::apply_model_visibility;
/// The World section — identity/map/zone/position readout + the copy-`.go xyz` affordance.
mod world;
use world::{world_section, WorldReadout};
/// Shared look for the floating dev overlays — this panel, the perf pill, the inspector card — so they
/// read as one family. The backing is a **near-solid dark** so the bright text keeps strong contrast
/// *no matter the scene behind it*: a lightly-translucent box let bright terrain/sky bleed through and
/// washed the text out, making legibility depend on the view. [`overlay_text`] sets the bright primary
/// tone; [`OVERLAY_TEXT_DIM`] the readable secondary one. (Lower the alpha for a more see-through box —
/// at the cost of view-independent contrast.)
pub(crate) const OVERLAY_FILL: egui::Color32 = egui::Color32::from_black_alpha(224);
/// Primary overlay text — a crisp near-white that reads over the translucent fill (egui's dark default
/// is only `gray(140)`). Set as the ui's `override_text_color`; explicit `.color(...)` calls still win.
pub(crate) const OVERLAY_TEXT: egui::Color32 = egui::Color32::from_gray(235);
/// De-emphasised overlay text (ids, distances, hints): dimmer than [`OVERLAY_TEXT`] but still crisp on
/// the translucent backing.
pub(crate) const OVERLAY_TEXT_DIM: egui::Color32 = egui::Color32::from_gray(180);
/// Apply the shared overlay text treatment for the **compact, fixed-size** surfaces (perf pill,
/// inspector card): brighten to [`OVERLAY_TEXT`] and disable wrapping. No-wrap also kills the
/// first-frame layout flash where a fresh auto-sized container hasn't cached its width yet and a label
/// like "60 fps" briefly breaks across two lines. (The resizable debug panel brightens the same way but
/// keeps wrapping, so its labels reflow instead of clipping when narrowed.)
pub(crate) fn overlay_text(ui: &mut egui::Ui) {
let style = ui.style_mut();
style.visuals.override_text_color = Some(OVERLAY_TEXT);
style.wrap_mode = Some(egui::TextWrapMode::Extend);
}
/// The egui dev overlay's half of the pointer arbitration, written each egui pass. The combined
/// truth — [`crate::ui_script::PointerOverUi`] — lives with its combiner in `ui_script` (decision
/// 0026: gameplay must not read dev-owned resources; the arbiter tolerates this half's absence),
/// which OR's this with the player-UI hover. Neither writer depends on the other.
#[derive(Resource, Default)]
pub(crate) struct EguiPointerOver(pub(crate) bool);
/// Strip the **Tab** key from egui's per-frame input so egui never treats it as focus navigation.
///
/// Tab is a bound game key — `TargetNearestEnemy` (decision 0166). Left to itself, egui reads Tab at
/// `begin_pass` (`Focus::begin_pass`) and pulls keyboard focus into whatever focusable widget is on
/// screen — the always-on perf pill — ringing it and then owning the keyboard. Our egui surfaces are
/// mouse-driven dev overlays with no tab-to-next-field need, so we drop Tab before the pass sees it.
/// Bevy's own `ButtonInput<KeyCode>` (what `target::scan` reads) is populated independently from the
/// same winit events, so TAB targeting still fires — this only removes the phantom egui focus.
fn strip_egui_tab_focus(mut inputs: Query<&mut EguiInput>) {
for mut input in &mut inputs {
input.events.retain(|e| {
!matches!(
e,
egui::Event::Key {
key: egui::Key::Tab,
..
}
)
});
}
}
fn track_pointer_over_ui(mut contexts: EguiContexts, mut over: ResMut<EguiPointerOver>) -> Result {
let ctx = contexts.ctx_mut()?;
over.0 = ctx.is_pointer_over_area() || ctx.wants_pointer_input();
Ok(())
}
/// Which world-model subsystem a spawned submesh belongs to — lets the panel scope toggles (e.g.
/// hide doodads without touching NPCs).
#[derive(Component, Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum ModelKind {
Doodad,
Wmo,
Creature,
GameObject,
}
impl ModelKind {
const ALL: [ModelKind; 4] = [Self::Doodad, Self::Wmo, Self::Creature, Self::GameObject];
fn index(self) -> usize {
match self {
Self::Doodad => 0,
Self::Wmo => 1,
Self::Creature => 2,
Self::GameObject => 3,
}
}
fn label(self) -> &'static str {
match self {
Self::Doodad => "Doodads (trees/props)",
Self::Wmo => "WMOs (buildings)",
Self::Creature => "Creatures (NPCs)",
Self::GameObject => "GameObjects",
}
}
}
/// Tags every spawned model submesh with the metadata the panel toggles on: its subsystem and its
/// blend mode (the "layer" — opaque trunk vs alpha-cut canopy).
#[derive(Component, Clone, Copy)]
pub struct ModelPart {
pub kind: ModelKind,
pub blend: ModelBlend,
}
fn blend_index(b: ModelBlend) -> usize {
match b {
ModelBlend::Opaque => 0,
ModelBlend::AlphaTest => 1,
ModelBlend::Blend => 2,
ModelBlend::Mod => 3,
ModelBlend::Mod2x => 4,
}
}
const BLEND_LABELS: [&str; 5] = [
"Opaque (trunk / walls)",
"AlphaTest (leaf / cutout)",
"Blend (transparent)",
"Mod (multiply)",
"Mod2x (2x multiply / sheen)",
];
/// Minute-of-day (`0..1440`) as `HH:MM`.
fn hhmm(minute: u32) -> String {
format!("{:02}:{:02}", minute / 60 % 24, minute % 60)
}
fn source_label(s: ClockSource) -> &'static str {
match s {
ClockSource::Server => "server clock",
ClockSource::Manual => "manual scrub",
ClockSource::Fallback => "noon (not connected)",
}
}
/// Ordering handle so the one system allowed to *override* the model-`Visibility` authority — the
/// self-avatar first-person hide ([`crate::player`]) — can run **after** it and win the frame.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ModelVisSet;
/// Adds the egui plugin, the debug-state resource, the panel UI, and the apply systems.
pub struct DebugPanelPlugin;
impl Plugin for DebugPanelPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(EguiPlugin::default());
// Manual context mode: we host egui on a dedicated overlay camera (below) so we can inset the
// *world* camera's viewport without clipping the panel. Disable right after adding the plugin
// (the resource exists by then) — before the auto-create system runs at startup.
{
let mut egui = app.world_mut().resource_mut::<EguiGlobalSettings>();
egui.auto_create_primary_context = false;
// Don't let egui drive the OS cursor icon. The player hides the OS cursor and draws its
// own `Point.blp` sprite, so egui's per-hover `CursorIcon::System` inserts are pointless
// churn (and a dev panel doesn't need resize/text cursors).
egui.enable_cursor_icon_updates = false;
}
app.init_resource::<DebugState>()
.init_resource::<EguiPointerOver>()
.add_systems(Startup, spawn_egui_camera)
// Keep Tab out of egui: it's a bound game key, never focus-navigation for our overlays.
// Runs after bevy_egui fills `EguiInput` and before egui's pass consumes it (the seam
// bevy_egui documents for input edits).
.add_systems(
PreUpdate,
strip_egui_tab_focus
.after(EguiPreUpdateSet::ProcessInput)
.before(EguiPreUpdateSet::BeginPass),
)
.add_systems(
EguiPrimaryContextPass,
(debug_panel_ui, track_pointer_over_ui),
)
// `apply_model_visibility` reads the WMO portal PVS, so it runs after the compute that
// fills it (`crate::wmo_portal::WmoPvsSet`).
.add_systems(
Update,
(
// After the UI keyboard feed so a backtick typed into a focused EditBox never
// toggles the panel.
toggle_panel.after(crate::ui_script::UiInput),
apply_model_visibility
.after(crate::wmo_portal::WmoPvsSet)
.in_set(ModelVisSet),
),
);
}
}
/// A full-window overlay camera that hosts the primary egui context and composites it over the 3D
/// scene (higher order, alpha blend, no clear). Renders no world geometry (`RenderLayers::none`).
/// Order 2 — above the player-UI quad pass (`ui_pass::PlayerUiPlugin`, order 1), which itself sits
/// above the order-0 world camera. Per decision 0025's overlay arbitration ("dev overlays composite
/// over" everything else) and 0068 §2: dev overlays always stay on top of the player UI.
fn spawn_egui_camera(mut commands: Commands) {
commands.spawn((
PrimaryEguiContext, // `#[require(EguiContext)]` ⇒ this camera carries EguiContext
Camera2d,
RenderLayers::none(),
Camera {
order: 2,
output_mode: CameraOutputMode::Write {
blend_state: Some(BlendState::ALPHA_BLENDING),
clear_color: ClearColorConfig::None,
},
clear_color: ClearColorConfig::None,
..default()
},
));
}
fn toggle_panel(
keys: Res<ButtonInput<KeyCode>>,
ui_capture: Res<crate::ui_script::UiKeyboardCapture>,
mut debug: ResMut<DebugState>,
) {
// Don't toggle while the chat input is open or a focused EditBox owns the keyboard — a backtick
// keypress there belongs in the text.
if !ui_capture.0 && keys.just_pressed(KeyCode::Backquote) {
debug.open = !debug.open;
}
}
/// Did the **dev-overlay chord** — `Ctrl`+`Cmd`+*key* — just fire? (decision 0585.)
///
/// The dev instruments used to sit on bare letters, which is a namespace we don't own: every letter is
/// a *game* binding in the reference client, so `P` both opened the spellbook and toggled the perf HUD.
/// `Ctrl`+`Cmd` is a plane no in-game binding can ever claim (1.12 builds its binding names from
/// `ALT-`/`CTRL-`/`SHIFT-` only, and the Mac client's Cmd isn't among them), so an instrument parked
/// there can never collide with a key the player wants. The other half of the rule lives at the
/// gameplay key feed (`ui_script::input`): a bare-key binding doesn't fire while a command modifier is
/// held, so this chord doesn't *also* trigger the letter's game action.
///
/// Both sides of each modifier count, and extra modifiers don't block. Deliberately **not** gated on
/// [`crate::ui_script::UiKeyboardCapture`] the way the bare-key toggles were: a chord can't be mistaken
/// for typed text, so the dev overlays stay reachable with the chat bar open.
pub(crate) fn dev_chord(keys: &ButtonInput<KeyCode>, key: KeyCode) -> bool {
let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight);
let cmd = keys.pressed(KeyCode::SuperLeft) || keys.pressed(KeyCode::SuperRight);
ctrl && cmd && keys.just_pressed(key)
}
/// Draw the panel as a translucent **overlay** on the right — the world renders full-screen
/// underneath (no viewport inset). `ui_script::PointerOverUi` keeps the cursor's panel
/// interactions from leaking into gameplay mouse-look.
#[allow(clippy::too_many_arguments)]
fn debug_panel_ui(
mut contexts: EguiContexts,
mut debug: ResMut<DebugState>,
mut view: ResMut<ViewDistance>,
clock: Res<GameClock>,
lighting: Res<WowLighting>,
parts: Query<&ModelPart>,
anim_hosts: Query<&crate::doodad_anim::DoodadAnimHost>,
mat_anims: Query<&crate::doodad_anim::MatAnim>,
uv_mats: Res<crate::doodad_anim::UvAnimMaterials>,
mut particles: ResMut<ParticleTuning>,
mut sound_cfg: ResMut<crate::sound::SoundConfig>,
mut cull_probe: ResMut<crate::wmo_portal::WmoCullProbe>,
net_status: Res<crate::net::NetStatus>,
dropped: Res<crate::net::DroppedOpcodes>,
mut weather_state: Option<ResMut<crate::weather::WeatherState>>,
mut world: WorldReadout,
) -> Result {
if !debug.open {
return Ok(());
}
// Live counts per layer/type.
let mut kind_counts = [0u32; 4];
let mut blend_counts = [0u32; 5];
for p in &parts {
kind_counts[p.kind.index()] += 1;
blend_counts[blend_index(p.blend)] += 1;
}
let ctx = contexts.ctx_mut()?;
// Full height: the window spans the view (an 8 px gap top and bottom), rather than auto-sizing to
// however much content the open sections happen to have.
let panel_h = (ctx.content_rect().height() - 32.0).max(120.0);
egui::Window::new("benilla_debug")
.title_bar(false)
.resizable(false)
.collapsible(false)
.movable(false)
.anchor(egui::Align2::RIGHT_TOP, egui::vec2(-8.0, 8.0))
.frame(
egui::Frame::NONE
.fill(OVERLAY_FILL)
.corner_radius(5.0)
.inner_margin(egui::Margin::symmetric(10, 8)),
)
.show(ctx, |ui| {
// Brighten to match the overlays; keep egui's default wrapping so labels reflow, not clip.
ui.visuals_mut().override_text_color = Some(OVERLAY_TEXT);
ui.set_width(280.0); // fixed width — no resize handle
ui.set_height(panel_h); // fill the height; sections scroll within it
egui::ScrollArea::vertical()
.auto_shrink([false, false])
// Leave room under the scroll for the pinned hotkey footer below. Two lines' worth:
// the footer keeps the panel's default wrapping, so a font whose metrics run wider
// than ours reflows it instead of having its second line clipped away.
.max_height(panel_h - 40.0)
.show(ui, |ui| {
// Disjoint borrows of the sections so each can drive its own widgets.
let DebugState {
models: m,
lighting: l,
sound: s,
weather: w,
..
} = &mut *debug;
// WHERE AM I — the `.gps` of the panel: identity, map, zone, position, tile
// residency, and the click-to-copy `.go xyz` teleport line that feeds the
// headless probes (`WOW_PROBE_CHAT`) and the FPS-journal loop.
egui::CollapsingHeader::new("World")
.default_open(true)
.show(ui, |ui| world_section(ui, &mut world));
egui::CollapsingHeader::new("Models")
.default_open(true)
.show(ui, |ui| {
ui.strong("Layer (blend mode)");
for i in 0..BLEND_LABELS.len() {
ui.checkbox(
&mut m.blend_visible[i],
format!("{} · {}", BLEND_LABELS[i], blend_counts[i]),
);
}
ui.add_space(6.0);
ui.strong("Type");
for k in ModelKind::ALL {
ui.checkbox(
&mut m.kind_visible[k.index()],
format!("{} · {}", k.label(), kind_counts[k.index()]),
);
}
// The doodad-animation cost meter (decision 0130): how many placed
// doodads carry an anim host, how many are ticking right now (the
// draw gate pauses hidden ones), how many batches sample a
// material-alpha loop (phase 2), and how many materials scroll their
// UVs (phase 3 — waterfalls).
let ticking = anim_hosts.iter().filter(|h| h.active).count();
// Of the material samplers, how many resolve to **0 right now** — the
// batches the reference culls this frame (`A <= 0`, wow-re
// `m2-alpha-combine-cull`). Non-zero as soon as a voidwalker/banshee/
// slime/infernal is in view: those models author geometry that only
// appears on death, and this counter is what says we are hiding it
// rather than drawing it. `dim` counts the partial factors — a batch
// drawn, but not at full strength.
let (mut hidden, mut dim) = (0usize, 0usize);
for m in &mat_anims {
if m.current <= 0.0 {
hidden += 1;
} else if m.current < 1.0 {
dim += 1;
}
}
ui.label(format!(
"animated doodads · {} ({} ticking) · {} material ({hidden} culled, {dim} dimmed) · {} uv",
anim_hosts.iter().count(),
ticking,
mat_anims.iter().count(),
uv_mats.0.len(),
));
ui.add_space(6.0);
// WMO portal cull A/B (decision 0031): off ⇒ every building group always
// draws (the cathedral reappears from the Trade District).
ui.checkbox(&mut m.portal_cull, "WMO portal visibility cull");
// The cull probe (decision 0022): a one-click full trace dump — stand where a
// room vanishes, click, and the exact seed evidence + per-portal verdicts land
// in a file.
if ui.button("dump WMO cull trace").clicked() {
cull_probe.dump_requested = true;
}
ui.add_space(6.0);
// Faithful `farclip`: doodads/WMOs hard-pop out past this (no fade). Vanilla
// range [177, 777]; the slider goes a little past 777 only for A/B against the
// old "draw everything in the tile window" look.
ui.add(
egui::Slider::new(&mut view.farclip, 177.0..=1200.0)
.text("view distance / farclip (yd)"),
);
});
egui::CollapsingHeader::new("Lighting")
.default_open(true)
.show(ui, |ui| {
// Time of day — drives the DBC-sampled colors + the sun's day-arc.
ui.strong(format!(
"Time of day: {} ({})",
hhmm(clock.minute),
source_label(clock.source),
));
ui.checkbox(&mut l.follow_server_time, "follow server clock");
ui.add_enabled_ui(!l.follow_server_time, |ui| {
ui.horizontal(|ui| {
ui.add(
egui::Slider::new(&mut l.manual_minute, 0..=1439)
.text("scrub time")
.custom_formatter(|n, _| hhmm(n as u32)),
);
// Exact per-minute entry: drag/scroll ±1 min, or click to type a raw
// minute (0..1439) or an `HH:MM` time — for landing on an exact moment.
ui.add(
egui::DragValue::new(&mut l.manual_minute)
.range(0..=1439)
.speed(1.0)
.custom_formatter(|n, _| hhmm(n as u32))
.custom_parser(|s| {
let s = s.trim();
if let Some((h, m)) = s.split_once(':') {
let h: u32 = h.trim().parse().ok()?;
let m: u32 = m.trim().parse().ok()?;
Some(((h % 24) * 60 + (m % 60)) as f64)
} else {
s.parse::<f64>().ok()
}
}),
);
});
});
ui.add_space(6.0);
ui.strong("Resolved (Light.dbc, this time)");
// 0..255 bytes (eyedrop-friendly vs the reference client) + a swatch.
let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as i32;
let swatch = |ui: &mut egui::Ui, name: &str, c: [f32; 3]| {
let (r, g, b) = (byte(c[0]), byte(c[1]), byte(c[2]));
let col = egui::Color32::from_rgb(r as u8, g as u8, b as u8);
// The label text is tinted the resolved color (a swatch the font always
// renders); the [r,g,b] are the exact 0..255 values for eyedrop A/Bs.
ui.colored_label(col, format!("{name} [{r}, {g}, {b}]"));
};
swatch(ui, "ambient (row 1)", lighting.ambient);
swatch(ui, "diffuse (row 0)", lighting.diffuse);
swatch(ui, "specular (row 9)", lighting.spec);
// The backdrop / fog colour (row 7) — what `ClearColor` shows behind the dome.
swatch(ui, "fog / backdrop (row 7)", lighting.fog_color);
let d = lighting.sun_dir;
ui.label(
egui::RichText::new(format!(
"sun dir ({:+.2}, {:+.2}, {:+.2})",
d.x, d.y, d.z
))
.weak(),
);
ui.add_space(8.0);
ui.checkbox(&mut l.disable_fog, "disable distance fog");
ui.checkbox(&mut l.disable_sky_dome, "disable sky dome");
});
egui::CollapsingHeader::new("Weather")
.default_open(false)
.show(ui, |ui| {
// Live state readout (the two ramped channels, decision 0302).
if let Some(ws) = weather_state.as_ref() {
ui.strong(format!(
"{:?} · effect {:.2} · sky {:.2} · storm blend {:.2}",
ws.effect_kind,
ws.effect_density,
ws.sky_density,
crate::weather::storm_blend(ws.sky_density),
));
}
// The `weatherDensity` setting (video-options Weather Intensity 0–3)
// — the real game setting, not a tuning knob. Scales the rain/snow/
// mist spawn gain via the `0x67b870` quality table; default 3 matches
// the reference install's Config.wtf.
if let Some(ws) = weather_state.as_mut() {
let mut wd = ws.weather_density;
ui.horizontal(|ui| {
ui.label("weather intensity");
for v in 0..=3u8 {
ui.selectable_value(&mut wd, v, format!("{v}"));
}
});
if wd != ws.weather_density {
ws.weather_density = wd;
}
}
// The override scrub — drives the same apply path as the wire.
let before = (w.force, w.kind, w.grade.to_bits(), w.instant);
ui.checkbox(&mut w.force, "override server weather");
ui.add_enabled_ui(w.force, |ui| {
ui.horizontal(|ui| {
for (v, name) in
[(0, "fine"), (1, "rain"), (2, "snow"), (3, "sand")]
{
ui.selectable_value(&mut w.kind, v, name);
}
});
ui.add(egui::Slider::new(&mut w.grade, 0.0..=1.0).text("grade"));
ui.checkbox(&mut w.instant, "instant (skip the ramp)");
});
if before != (w.force, w.kind, w.grade.to_bits(), w.instant) {
w.dirty = true;
}
});
// The object inspector is its own Ctrl+Cmd+I surface (see `interact.rs`), not a
// section here — identifying a thing shouldn't need the whole panel open.
egui::CollapsingHeader::new("Particles")
.default_open(true)
.show(ui, |ui| {
// The reference's own particleDensity CVar range — a real game
// setting (spawn rate only), not a tuning knob. No distance cull
// exists in the reference (wow-re part-distance-density.md).
// (The intensity/size/tint A/B sliders retired once the colour-space
// lane closed — 0150/0152; faithful 1.0 is the only path, the same
// way the glow-card knobs died with 0159.)
ui.add(
egui::Slider::new(&mut particles.density, 0.25..=1.0)
.text("particleDensity (CVar)"),
);
});
egui::CollapsingHeader::new("Sound")
.default_open(false)
.show(ui, |ui| {
ui.checkbox(&mut sound_cfg.enabled, "enabled");
ui.checkbox(&mut sound_cfg.muted, "muted (Ctrl+Cmd+M)");
ui.add(
egui::Slider::new(&mut sound_cfg.master, 0.0..=1.0).text("master"),
);
ui.add(egui::Slider::new(&mut sound_cfg.sfx, 0.0..=1.0).text("sfx"));
ui.add(
egui::Slider::new(&mut sound_cfg.music, 0.0..=1.0).text("music"),
);
ui.add(
egui::Slider::new(&mut sound_cfg.ambience, 0.0..=1.0)
.text("ambience"),
);
ui.separator();
ui.label("kit probe: a SoundEntries id or name");
ui.text_edit_singleline(&mut s.kit_query);
if ui.button("Play kit").clicked() {
s.play_kit = true;
}
});
// The wire-coverage section: connection state + the dropped-packet tally
// (every opcode the codec ignored or failed to parse, by count — a silent
// gap in wire coverage made visible; decision 0022's instrument disposition).
egui::CollapsingHeader::new("Net")
.default_open(false)
.show(ui, |ui| {
ui.label(if net_status.connected {
match net_status.latency_ms {
Some(ms) => format!("connected · {ms} ms ping"),
None => "connected".to_string(),
}
} else {
format!(
"disconnected — {}",
net_status
.last_reason
.as_deref()
.unwrap_or("never connected")
)
});
ui.add_space(4.0);
ui.strong("dropped packets (opcode · count)");
if dropped.0.is_empty() {
ui.label("none — every received opcode parsed");
} else {
let mut rows: Vec<_> = dropped.0.iter().collect();
rows.sort_by_key(|(_, t)| {
std::cmp::Reverse(t.unknown + t.unparseable)
});
for (op, t) in rows {
let name =
benilla_protocol::messages::opcode_name(*op).unwrap_or("?");
let mut line =
format!("{op:#06x} {name} · {}", t.unknown + t.unparseable);
if t.unparseable > 0 {
line.push_str(&format!(
" ({} parse-errored)",
t.unparseable
));
}
ui.label(line);
}
}
});
// Future sections add their own CollapsingHeader here.
});
// The pinned footer: the dev-surface hotkey map, so the other overlays are
// discoverable from the one surface people find first.
ui.separator();
ui.label(
egui::RichText::new("` panel · Ctrl+Cmd: P perf · I inspect · M mute")
.small()
.color(OVERLAY_TEXT_DIM),
);
});
Ok(())
}