diff --git a/analysis/src/integrity.rs b/analysis/src/integrity.rs index 40b901048..d38ef346b 100644 --- a/analysis/src/integrity.rs +++ b/analysis/src/integrity.rs @@ -238,7 +238,7 @@ pub fn check_demo_bytes(bytes: &[u8]) -> Result { /// `nom_fail` puts its message in the nom error's `input`, which `Display` /// prints as a list of byte values; any other nom error's `input` is the rest /// of the file, so that is cut short instead of printed whole. -fn parse_error_reason(error: &str) -> String { +pub fn parse_error_reason(error: &str) -> String { const MAX: usize = 200; let message = error .split_once("input: [") diff --git a/analysis/src/lib.rs b/analysis/src/lib.rs index d9b1f5d55..bba51f983 100644 --- a/analysis/src/lib.rs +++ b/analysis/src/lib.rs @@ -773,7 +773,12 @@ impl Analysis { })); let demo = match demo_res { Ok(Ok(d)) => d, - Ok(Err(e)) => return Err(format!("Parse error: {}", e)), + Ok(Err(e)) => { + return Err(format!( + "Parse error: {}", + integrity::parse_error_reason(&e.to_string()) + )); + } Err(_) => return Err("Parser panicked during demo structural decoding".to_string()), }; diff --git a/docs/dodstudio_commands.md b/docs/dodstudio_commands.md index 0a50ab77a..13f0a586c 100644 --- a/docs/dodstudio_commands.md +++ b/docs/dodstudio_commands.md @@ -417,13 +417,16 @@ The Demos tab lists the demos, with each one's map and date, and loads one with no demo playing. Click a column heading to sort by it; the boxes above filter by name or map (Search), map, HLTV or POV, and age in days. The Highlights tab lists every life with a kill in the demo playing -(player, kills, weapons, time), narrowed by a Min kills box. A POV demo lists -only the recording player's; an HLTV demo lists everyone's, with a Player box, -and Go also puts the camera on that player (`dodstudio_spec_target`). Go or a +(player, kills, weapons, time). A POV demo lists only the recording player's, +named in a greyed-out Player box; an HLTV demo lists everyone's, the Player box +narrows them, and Go also puts the camera on that player (`dodstudio_spec_target`). Go or a double-click jumps to 5 s before the first kill. It reads Studio's analyzer cache (`%APPDATA%\dod-studio\analyzer_cache`), so a demo Studio's Demo Analyzer or Master Queue already read shows at once; anything else is analysed in the -game, with a progress bar, and saved there for Studio too. A demo too big for +game, with a progress bar, and saved there for Studio too. That analysis +starts as soon as a demo plays, with the window open or not; while it or the +demo player's own load still runs, a strip along the top of every tab says how +far each has got (`Loading: Demo 45% Highlights 60%`, #797). A demo too big for the game's memory (analysis takes about 12 times the demo's size; the pre-Anniversary game has under 1 GB free) is refused with a note to open it in the Demo Analyzer instead. On the diff --git a/goldsrc-hooks/src/streaks.rs b/goldsrc-hooks/src/streaks.rs index f87221280..bef053791 100644 --- a/goldsrc-hooks/src/streaks.rs +++ b/goldsrc-hooks/src/streaks.rs @@ -27,8 +27,7 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; -/// Every life with a kill is listed; the tab's Min kills box narrows the -/// list, so nothing is hidden by a number fixed here. +/// Every life with a kill is listed: one kill is a highlight too. pub const MIN_KILLS: usize = 1; /// Go jumps this long before a streak's first kill. pub const LEAD_IN_SECS: f32 = 5.0; @@ -269,6 +268,19 @@ pub fn status() -> Option<(u64, Status, u32)> { )) } +/// The current analysis' progress (0 to 100) while it runs, `Some(None)` +/// when none runs, or `None` while the worker is saving its result. Cheaper +/// than [`status`] for the loading strip (#797), which asks every frame: it +/// never copies the streaks found. +pub fn loading_percent() -> Option> { + let job = JOB.try_lock().ok()?; + Some( + job.as_ref() + .filter(|j| matches!(j.status, Status::Loading)) + .map(|_| PERCENT.load(Ordering::Acquire)), + ) +} + /// The streaks, and where they came from (for the log). fn find(path: &Path, generation: u64) -> Result<(Found, &'static str), String> { let root = cache_root(); @@ -301,7 +313,14 @@ fn find(path: &Path, generation: u64) -> Result<(Found, &'static str), String> { "DoD Studio read it, but its result is not in the cache".to_string() }); } - Some(Err(why)) => return Err(format!("DoD Studio could not read it: {why}")), + // An older Studio sends the parse error with its byte dump; the + // Highlights tab has one line for it. + Some(Err(why)) => { + return Err(format!( + "DoD Studio could not read it: {}", + analysis::integrity::parse_error_reason(&why) + )); + } None => {} } let size = std::fs::metadata(path) diff --git a/goldsrc-hooks/src/studio_panel.rs b/goldsrc-hooks/src/studio_panel.rs index 65b84fd4f..7fe5a2b7e 100644 --- a/goldsrc-hooks/src/studio_panel.rs +++ b/goldsrc-hooks/src/studio_panel.rs @@ -1404,6 +1404,28 @@ fn help_bounds(sheet: (i32, i32, i32, i32)) -> (i32, i32, i32, i32) { const HELP_LINE: &str = "HelpLine"; const HELP_TALL: i32 = 22; +/// The loading strip's panel name, in `DodStudio.res` (#797). +const LOAD_STRIP: &str = "LoadStrip"; +/// How far the pages' controls move down while the strip shows. +const LOAD_STRIP_TALL: i32 = 20; + +/// Where the loading strip goes on a page `page_wide` across: along its top, +/// above the controls, which [`LOAD_STRIP_TALL`] pushes down. +fn load_strip_bounds(page_wide: i32) -> (i32, i32, i32, i32) { + (8, 2, (page_wide - 16).max(1), LOAD_STRIP_TALL - 2) +} + +/// What the loading strip says: how far the demo player has read the demo +/// and how far the highlights analysis has got, each while it runs; `None` +/// once both are done, and the strip goes. +fn load_strip_text(demo: Option, highlights: Option) -> Option { + let parts: Vec = [("Demo", demo), ("Highlights", highlights)] + .into_iter() + .filter_map(|(what, percent)| Some(format!("{what} {}%", percent?))) + .collect(); + (!parts.is_empty()).then(|| format!("Loading: {}", parts.join(" "))) +} + /// Every control's `"helptext"` in a `.res` file, by its name (an older layout's /// `"tooltiptext"` too; vgui2 shows that one as a tooltip on a check box): /// what the help line shows for it. (GameUI reads the key but shows no tooltip, so @@ -1674,6 +1696,8 @@ mod hook { mod list_icons; /// The Playback tab's loading progress (#465). mod load_progress; + /// The loading strip along the top of every tab (#797). + mod load_strip; /// The Demos tab's Player box, a dropdown narrowed as you type (#565). mod player_picker; /// The Review tab (#623). @@ -3380,8 +3404,11 @@ mod hook { // bigger than drawn still keeps its bottom row at the bottom // (#609). let rects: Vec<_> = design.controls.iter().map(|&(_, at)| at).collect(); + // The loading strip takes the top of the tab (#797): fit the + // controls to the rest and move them down under it. + let inset = load_strip::inset(); let (fit_design, fit_now) = - crate::window_layout::content_frame(&rects, size, (w, h)); + crate::window_layout::content_frame(&rects, size, (w, (h - inset).max(1))); if design.logged != Some((w, h)) { design.logged = Some((w, h)); crate::debug::report(&format!( @@ -3402,7 +3429,7 @@ mod hook { } else { crate::window_layout::fit_rect(at, fit_design, fit_now) }; - vgui.place(control, want); + vgui.place(control, (want.0, want.1 + inset, want.2, want.3)); } } } @@ -3850,6 +3877,7 @@ mod hook { pub(super) fn poll() { hook_taskbar(); name_stock_demo_rows(); + load_strip::request_highlights(); let pending = VIEWDEMO_PENDING.load(Ordering::Relaxed); if OBJECT.load(Ordering::Relaxed) == 0 && pending == 0 @@ -3887,6 +3915,7 @@ mod hook { }; hold_minimum(&vgui, frame, vp, &mut lent); if vgui.visible(vp) { + load_strip::update(&vgui, vp); fit_pages(&vgui, &mut lent); sync_settings(&vgui, &mut lent); update_help(&vgui, vp, &mut lent); @@ -5048,6 +5077,31 @@ mod tests { assert_eq!(action("reset_settings"), Action::ResetSettings); } + #[test] + fn the_loading_strip_names_each_load_still_running() { + assert_eq!( + load_strip_text(Some(45), Some(60)).as_deref(), + Some("Loading: Demo 45% Highlights 60%") + ); + assert_eq!( + load_strip_text(Some(0), None).as_deref(), + Some("Loading: Demo 0%") + ); + assert_eq!( + load_strip_text(None, Some(100)).as_deref(), + Some("Loading: Highlights 100%") + ); + assert_eq!(load_strip_text(None, None), None); + } + + #[test] + fn the_loading_strip_sits_above_the_controls_it_pushes_down() { + let (x, y, w, h) = load_strip_bounds(540); + assert_eq!((x, w), (8, 524)); + assert!(y >= 0 && y + h <= LOAD_STRIP_TALL); + assert!(load_strip_bounds(4).2 >= 1); + } + #[test] fn the_tab_strip_fills_the_client_area_inside_a_margin() { assert_eq!( diff --git a/goldsrc-hooks/src/studio_panel/hook/load_progress.rs b/goldsrc-hooks/src/studio_panel/hook/load_progress.rs index 533db9192..7436474f7 100644 --- a/goldsrc-hooks/src/studio_panel/hook/load_progress.rs +++ b/goldsrc-hooks/src/studio_panel/hook/load_progress.rs @@ -29,10 +29,7 @@ pub(super) unsafe fn update(vgui: &Vgui) { if page == 0 || !vgui.visible(page) { return; } - let percent = crate::demo_seek::buffered_while_loading().and_then(|buffered| { - let total = length_of_current_demo()?; - Some(crate::demo_file::load_percent(buffered, total)) - }); + let percent = demo_percent(); let mut shown = SHOWN.lock().unwrap_or_else(|e| e.into_inner()); if *shown == percent { return; @@ -74,6 +71,16 @@ pub(super) unsafe fn update(vgui: &Vgui) { } } +/// How much of the playing demo the demo player has read, while it is still +/// reading. Shared with the loading strip (#797), so both say the same, and +/// worked out whatever tab is showing. +pub(super) fn demo_percent() -> Option { + crate::demo_seek::buffered_while_loading().and_then(|buffered| { + let total = length_of_current_demo()?; + Some(crate::demo_file::load_percent(buffered, total)) + }) +} + /// The playing demo's playback length, read once per demo. fn length_of_current_demo() -> Option { let name = crate::demo_reload::current_demo()?; diff --git a/goldsrc-hooks/src/studio_panel/hook/load_strip.rs b/goldsrc-hooks/src/studio_panel/hook/load_strip.rs new file mode 100644 index 000000000..8b5c14e94 --- /dev/null +++ b/goldsrc-hooks/src/studio_panel/hook/load_strip.rs @@ -0,0 +1,125 @@ +//! The loading strip (#797): one line along the top of whichever tab is +//! showing, saying how far the demo player has read the demo and how far the +//! highlights analysis has got, while either still runs. The tabs' controls +//! sit below it while it shows ([`inset`]); it goes once both are done. +//! +//! It is `DodStudio.res`'s `LoadStrip` label, moved onto the page showing: +//! a page paints over whatever of the window lies under it, so a strip left +//! on the window would be hidden by the page it overlaps. +//! +//! The highlights analysis starts as soon as a demo plays ([`request_highlights`]), +//! window open or not; the Highlights tab only shows its result. The demo +//! load needs nothing: it runs at the same rate with the window closed. + +use std::ffi::CString; +use std::sync::atomic::{AtomicI32, AtomicU32, Ordering}; + +use super::*; + +/// The demo the highlights were last asked for, so the path is worked out +/// once per demo rather than every frame. +static REQUESTED: std::sync::Mutex = std::sync::Mutex::new(String::new()); +/// The strip, and the text on it (`None` while hidden): a rebuilt window's +/// new strip is written again. +static SHOWN: std::sync::Mutex<(Vpanel, Option)> = std::sync::Mutex::new((0, None)); +/// The highlights' last percent, [`NONE`] when none runs: kept for the frames +/// the analysis' worker holds its result's lock. +static HIGHLIGHTS: AtomicU32 = AtomicU32::new(NONE); +const NONE: u32 = u32::MAX; +/// How far the pages' controls sit down: [`LOAD_STRIP_TALL`] while the strip +/// shows, else 0. +static INSET: AtomicI32 = AtomicI32::new(0); + +/// Starts finding the playing demo's highlights. Runs every frame, window or +/// not; asks once per demo ([`crate::streaks::request`] ignores a repeat). +pub(super) fn request_highlights() { + let Some(name) = crate::demo_reload::current_demo() else { + return; + }; + let mut requested = REQUESTED.lock().unwrap_or_else(|e| e.into_inner()); + if *requested == name { + return; + } + crate::streaks::request(&crate::streaks::demo_path(&dod_dir(), &name)); + *requested = name; +} + +/// How far the tabs' controls move down for the strip. +pub(super) fn inset() -> i32 { + INSET.load(Ordering::Acquire) +} + +/// Shows, moves or hides the strip. Runs every frame while the window shows, +/// before the pages are fitted. +pub(super) unsafe fn update(vgui: &Vgui, frame_vp: Vpanel) { + unsafe { + let demo = load_progress::demo_percent(); + let highlights = match crate::streaks::loading_percent() { + Some(now) => { + HIGHLIGHTS.store(now.unwrap_or(NONE), Ordering::Release); + now + } + None => Some(HIGHLIGHTS.load(Ordering::Acquire)).filter(|&p| p != NONE), + }; + let text = load_strip_text(demo, highlights); + let page = PAGE_OBJECTS + .iter() + .map(|p| vpanel_of(p.load(Ordering::Acquire) as *mut c_void)) + .find(|&vp| vp != 0 && vgui.visible(vp)); + let Some(strip) = find(vgui, frame_vp, page) else { + // A layout edited before the strip existed: no room to make. + INSET.store(0, Ordering::Release); + return; + }; + INSET.store( + if text.is_some() { LOAD_STRIP_TALL } else { 0 }, + Ordering::Release, + ); + if let Some(page) = page { + if vgui.parent_of(strip) != page { + vgui.set_parent(strip, page); + } + let (_, _, wide, _) = vgui.rect(page); + vgui.place(strip, load_strip_bounds(wide)); + } + if vgui.visible(strip) != text.is_some() { + vgui.set_visible(strip, text.is_some()); + } + let mut shown = SHOWN.lock().unwrap_or_else(|e| e.into_inner()); + if shown.0 == strip && shown.1 == text { + return; + } + if shown.1.is_some() != text.is_some() { + crate::debug::report(&format!( + "studio_panel: loading strip: {}", + text.as_deref().unwrap_or("gone, both loads done") + )); + } + if let Some(text) = &text { + let object = vgui.object(strip); + if let (false, Ok(c_text)) = (object.is_null(), CString::new(text.as_str())) { + let set_text: SetTextFn = slot(object, LABEL_SLOT_SET_TEXT); + set_text(object, c_text.as_ptr()); + } + } + *shown = (strip, text); + } +} + +/// The strip: on the page showing (where it usually is), still on the window +/// (before its first move), or on a page shown before. +unsafe fn find(vgui: &Vgui, frame_vp: Vpanel, showing: Option) -> Option { + unsafe { + if let Some(vp) = showing.and_then(|page| vgui.child_named(page, LOAD_STRIP)) { + return Some(vp); + } + if let Some(vp) = vgui.child_named(frame_vp, LOAD_STRIP) { + return Some(vp); + } + PAGE_OBJECTS + .iter() + .map(|p| vpanel_of(p.load(Ordering::Acquire) as *mut c_void)) + .filter(|&vp| vp != 0 && Some(vp) != showing) + .find_map(|page| vgui.child_named(page, LOAD_STRIP)) + } +} diff --git a/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs b/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs index aa97042d7..7245f9650 100644 --- a/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs +++ b/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs @@ -5,10 +5,12 @@ //! Player, Kills, Time, Dur. and Details, its Load button is Go, and its `OnCommand` is //! ours: Go, or a double-click, seeks to just before the streak. //! -//! Every life with a kill is listed; a Min kills box narrows the list. A POV -//! demo lists only the recording player's (the footage follows no one else). -//! An HLTV demo lists everyone's, a Player box narrows them, and Go also puts -//! the camera on the streak's player (`dodstudio_spec_target`). +//! Every life with a kill is listed. A POV demo lists only the recording +//! player's (the footage follows no one else), named in a greyed-out Player +//! box. An HLTV demo lists everyone's, the Player box narrows them, and Go +//! also puts the camera on the streak's player (`dodstudio_spec_target`). +//! The hint line under the list says when there is nothing to list, and why; +//! how far the analysis has got is the loading strip's and the progress bar's. use std::ffi::{CString, c_char, c_void}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -23,13 +25,13 @@ pub(super) fn dialog() -> usize { DIALOG.load(Ordering::Acquire) } static ON_COMMAND: AtomicUsize = AtomicUsize::new(0); -/// What the status line and the list show now, so each is redrawn only when +/// What the hint line and the list show now, so each is redrawn only when /// it changes. static SHOWN_STATUS: std::sync::Mutex = std::sync::Mutex::new(String::new()); static SHOWN_ROWS: std::sync::Mutex = std::sync::Mutex::new(String::new()); -/// The Player and Min kills boxes' text the list was last narrowed by; -/// `None` after a refill. -static FILTERED: std::sync::Mutex> = std::sync::Mutex::new(None); +/// The list the Player box was last set for, so it is set once per demo and +/// then left to what is typed. +static BOXED_FOR: std::sync::Mutex = std::sync::Mutex::new(String::new()); /// The row keys Go reads: the bar time to seek to, and in an HLTV demo the /// player's number to put the camera on. @@ -47,14 +49,12 @@ const COLUMNS: [(&CStr, &CStr, i32); 5] = [ ]; const PLAYER_COLUMN: i32 = 1; const ROW_COLUMN_WIDE: i32 = 44; -const STATUS_LABEL: &str = "StreakStatus"; +/// The line under the list, beside Go (`Highlights.res`). +const HINT_LABEL: &str = "Hint"; const PROGRESS: &str = "StreakProgress"; -/// The Player box, and its label: shown for an HLTV demo only. +/// The Player box, and its label: shown once the streaks are found. const PLAYER_BOX: &str = "StreakPlayer"; const PLAYER_LABEL: &str = "StreakPlayerLabel"; -/// The Min kills box, and its label: shown once the streaks are found. -const MIN_KILLS_BOX: &str = "StreakMinKills"; -const MIN_KILLS_LABEL: &str = "StreakMinKillsLabel"; type KeyValuesCtor = unsafe extern "thiscall" fn( *mut c_void, @@ -89,6 +89,7 @@ pub(super) unsafe fn build( .unwrap_or_else(|e| e.into_inner()) .clear(); SHOWN_ROWS.lock().unwrap_or_else(|e| e.into_inner()).clear(); + BOXED_FOR.lock().unwrap_or_else(|e| e.into_inner()).clear(); let list = *((dialog as *const u8).add(build.frame_size) as *const *mut c_void); if list.is_null() { @@ -124,8 +125,9 @@ pub(super) unsafe fn build( } } -/// Keeps the tab showing the playing demo's streaks. Runs every frame; asks -/// for an analysis only while the tab is showing. +/// Keeps the tab showing the playing demo's streaks. Runs every frame while +/// the tab shows; the analysis itself starts as soon as the demo plays +/// (`load_strip::request_highlights`, #797), so asking here again is a no-op. pub(super) unsafe fn update(vgui: &Vgui) { unsafe { let page = vpanel_of(PAGE_OBJECTS[STREAKS_PAGE].load(Ordering::Acquire) as *mut c_void); @@ -152,7 +154,7 @@ pub(super) unsafe fn update(vgui: &Vgui) { ), None, ); - fill(list, base, build, "none", &[], false); + fill(list, base, build, "none", &[], false, ""); show_filters(vgui, page, None); return; }; @@ -162,13 +164,15 @@ pub(super) unsafe fn update(vgui: &Vgui) { let Some((generation, status, percent)) = streaks::status() else { return; }; - let file = path - .file_name() - .map_or(name.clone(), |f| f.to_string_lossy().into_owned()); match status { Status::Loading => { - let text = format!("Finding the highlights in {file}... {percent}%"); - show(vgui, page, build, (&text, &text), Some(percent)); + show( + vgui, + page, + build, + (&format!("loading {percent}"), GO_HINT), + Some(percent), + ); fill( list, base, @@ -176,25 +180,40 @@ pub(super) unsafe fn update(vgui: &Vgui) { &format!("{generation}:loading"), &[], false, + "", ); show_filters(vgui, page, None); } Status::Ready(found) => { - let text = ready_text(&found, &file); + let text = ready_hint(&found); show(vgui, page, build, (&text, &text), None); + let demo_key = format!("{generation}:ready"); + { + let mut boxed = BOXED_FOR.lock().unwrap_or_else(|e| e.into_inner()); + if *boxed != demo_key { + fill_player_box(vgui, page, &found); + *boxed = demo_key.clone(); + } + } + // A POV demo's box names its one player: every row shows. + let player = if found.hltv { + box_text(vgui, page, PLAYER_BOX).trim().to_string() + } else { + String::new() + }; fill( list, base, build, - &format!("{generation}:ready"), + &format!("{demo_key}|{player}"), &found.streaks, found.hltv, + &player, ); show_filters(vgui, page, Some(found.hltv)); - narrow(vgui, page, list, found.hltv); } Status::Failed(why) => { - let text = format!("Could not find the highlights in {file}: {why}"); + let text = format!("Could not find the highlights: {why}"); show(vgui, page, build, (&text, &text), None); fill( list, @@ -203,6 +222,7 @@ pub(super) unsafe fn update(vgui: &Vgui) { &format!("{generation}:failed"), &[], false, + "", ); show_filters(vgui, page, None); } @@ -210,96 +230,64 @@ pub(super) unsafe fn update(vgui: &Vgui) { } } -/// The status line once the streaks are found. -fn ready_text(found: &Found, file: &str) -> String { - let n = found.streaks.len(); - let lead = streaks::LEAD_IN_SECS; - match (&found.recorder, n) { - (Some(recorder), 0) => format!("No kills by {recorder} in {file}."), - (Some(recorder), _) => format!( - "{n} highlights by {recorder} in {file}. Go jumps to {lead} s before the first kill." - ), - (None, 0) => format!("No kills in {file}."), - (None, _) if found.hltv => { - format!("{n} highlights in {file}. Go jumps to {lead} s before and watches the player.") - } - (None, _) => { - format!("{n} highlights in {file}. Go jumps to {lead} s before the first kill.") +/// The hint under the list while the streaks are found or loading: what Go +/// does. +const GO_HINT: &str = "Double-click a streak, or pick one and Go."; + +/// The hint under the list once the streaks are found: what Go does, or that +/// there is nothing to go to. +fn ready_hint(found: &Found) -> String { + match (&found.recorder, found.streaks.is_empty()) { + (Some(recorder), true) => format!("No kills by {recorder} in this demo."), + (None, true) => "No kills in this demo.".to_string(), + (_, false) if found.hltv => { + "Double-click a streak, or pick one and Go: the camera follows its player.".to_string() } + (_, false) => GO_HINT.to_string(), } } -/// The filter boxes: none while nothing is listed (`None`), Min kills once -/// the streaks are found, and Player too for an HLTV demo (`Some(true)`). -unsafe fn show_filters(vgui: &Vgui, page: Vpanel, hltv: Option) { +/// The Player box for a newly listed demo: empty and editable in an HLTV +/// demo, the recording player's name and greyed out in a POV demo, which +/// lists no one else. +unsafe fn fill_player_box(vgui: &Vgui, page: Vpanel, found: &Found) { unsafe { - let shown = [ - (PLAYER_BOX, hltv == Some(true)), - (PLAYER_LABEL, hltv == Some(true)), - (MIN_KILLS_BOX, hltv.is_some()), - (MIN_KILLS_LABEL, hltv.is_some()), - ]; - for (name, on) in shown { - if let Some(vp) = vgui.child_named(page, name) - && vgui.visible(vp) != on - { - vgui.set_visible(vp, on); - } + let Some(entry) = vgui + .child_named(page, PLAYER_BOX) + .map(|vp| vgui.object(vp)) + .filter(|o| !o.is_null()) + else { + return; + }; + let name = match (&found.recorder, found.hltv) { + (Some(recorder), false) => recorder.replace('\0', ""), + _ => String::new(), + }; + if let Ok(name) = CString::new(name) { + let set_text: SetTextFn = slot(entry, TEXT_ENTRY_SLOT_SET_TEXT); + set_text(entry, name.as_ptr()); } + let set_enabled: ListIntVoidFn = slot(entry, PANEL_SLOT_SET_ENABLED); + set_enabled(entry, i32::from(found.hltv)); } } -/// Shows only the rows whose player matches the Player box (every word, case -/// ignored) with at least the Min kills box's kills, when either box changed -/// or the list was refilled. -unsafe fn narrow(vgui: &Vgui, page: Vpanel, list: *mut c_void, hltv: bool) { +/// The Player box and its label: hidden while nothing is listed (`None`), +/// shown once the streaks are found. +unsafe fn show_filters(vgui: &Vgui, page: Vpanel, hltv: Option) { unsafe { - let player = if hltv { - box_text(vgui, page, PLAYER_BOX).trim().to_string() - } else { - String::new() - }; - let min_kills: usize = box_text(vgui, page, MIN_KILLS_BOX) - .trim() - .parse() - .unwrap_or(0); - let wanted = format!("{player}|{min_kills}"); - let mut filtered = FILTERED.lock().unwrap_or_else(|e| e.into_inner()); - if filtered.as_deref() == Some(wanted.as_str()) { - return; - } - let first: ListFirstFn = slot(list, LIST_SLOT_FIRST_ITEM); - let next: ListItemIdFn = slot(list, LIST_SLOT_NEXT_ITEM); - let is_valid: ListIntFn = slot(list, LIST_SLOT_IS_VALID_ITEM_ID); - let get_item: ListItemFn = slot(list, LIST_SLOT_GET_ITEM); - let set_visible: ListSetVisibleFn = slot(list, LIST_SLOT_SET_ITEM_VISIBLE); - let mut id = first(list); - let mut guard = 0; - while is_valid(list, id) & 0xff != 0 && guard < 100_000 { - guard += 1; - let row = get_item(list, id); - if !row.is_null() { - let get_string: GetStringFn = slot(row, KEYVALUES_SLOT_GET_STRING); - let get = |key: &CStr| { - let raw = get_string(row, key.as_ptr(), c"".as_ptr()); - if raw.is_null() { - String::new() - } else { - text(raw) - } - }; - let kills: usize = get(COLUMNS[1].0).trim().parse().unwrap_or(0); - let shown = matches_filter(&get(COLUMNS[0].0), &player) && kills >= min_kills; - set_visible(list, id, shown as u32); + for name in [PLAYER_BOX, PLAYER_LABEL] { + if let Some(vp) = vgui.child_named(page, name) + && vgui.visible(vp) != hltv.is_some() + { + vgui.set_visible(vp, hltv.is_some()); } - id = next(list, id); } - *filtered = Some(wanted); } } -/// Sets the status line (when `key` changed) and the progress bar: shown -/// with `percent`, hidden without. +/// Sets the hint line (when `key` changed) and the progress bar: shown with +/// `percent`, hidden without. unsafe fn show( vgui: &Vgui, page: Vpanel, @@ -312,7 +300,7 @@ unsafe fn show( if *shown == key { return; } - if let Some(label) = vgui.child_named(page, STATUS_LABEL) { + if let Some(label) = vgui.child_named(page, HINT_LABEL) { set_label(vgui.object(label), text); } if let Some(bar) = vgui.child_named(page, PROGRESS) { @@ -331,7 +319,11 @@ unsafe fn show( } } -/// Lists `streaks`, when `key` (which list it is) changed. +/// Lists the `streaks` whose player matches `player` (every word, case +/// ignored; "" matches all), when `key` (which list, and which filter) +/// changed. Each keeps its Row # from the whole list. Typing in the Player +/// box lists the rows again rather than hiding the others: a row +/// `SetItemVisible` shows again goes to the end of the list, out of order. unsafe fn fill( list: *mut c_void, base: usize, @@ -339,6 +331,7 @@ unsafe fn fill( key: &str, streaks: &[Streak], hltv: bool, + player: &str, ) { unsafe { let mut shown = SHOWN_ROWS.lock().unwrap_or_else(|e| e.into_inner()); @@ -346,8 +339,6 @@ unsafe fn fill( return; } *shown = key.to_string(); - // Every row shows again: narrow it afresh. - *FILTERED.lock().unwrap_or_else(|e| e.into_inner()) = None; let delete_all: ListVoidFn = slot(list, LIST_SLOT_DELETE_ALL_ITEMS); delete_all(list); // One player in a POV demo: no Player column. @@ -357,6 +348,9 @@ unsafe fn fill( let ctor: KeyValuesCtor = std::mem::transmute(base + build.keyvalues_ctor); let add_item: AddItemFn = slot(list, LIST_SLOT_ADD_ITEM); for (index, streak) in streaks.iter().enumerate() { + if !matches_filter(&streak.player, player) { + continue; + } let target = match (hltv, streak.player_number) { (true, Some(number)) => number.to_string(), _ => String::new(), @@ -385,7 +379,8 @@ unsafe fn fill( } } // The list keeps its own copy; `row` stays ours, and is left - // (a few dozen bytes a row, once per demo) rather than freed + // (a few hundred bytes a row, per demo and per change to the + // Player box) rather than freed // through a slot nothing here has verified. add_item(list, row, 0, 0, 0); } diff --git a/goldsrc-hooks/ui/DodStudio.res b/goldsrc-hooks/ui/DodStudio.res index 1802256dc..7aa4f87ed 100644 --- a/goldsrc-hooks/ui/DodStudio.res +++ b/goldsrc-hooks/ui/DodStudio.res @@ -25,4 +25,17 @@ "labelText" "" "textAlignment" "west" } + "LoadStrip" + { + "ControlName" "Label" + "fieldName" "LoadStrip" + "xpos" "8" + "ypos" "2" + "wide" "540" + "tall" "18" + "visible" "0" + "enabled" "1" + "labelText" "" + "textAlignment" "west" + } } diff --git a/goldsrc-hooks/ui/Highlights.res b/goldsrc-hooks/ui/Highlights.res index d0a5bb16f..1cad98022 100644 --- a/goldsrc-hooks/ui/Highlights.res +++ b/goldsrc-hooks/ui/Highlights.res @@ -1,26 +1,11 @@ "dodstudio_ui/Highlights.res" { - "StreakStatus" - { - "ControlName" "Label" - "fieldName" "StreakStatus" - "xpos" "8" - "ypos" "6" - "wide" "520" - "tall" "52" - "visible" "1" - "enabled" "1" - "labelText" "" - "textAlignment" "north-west" - "wrap" "1" - "helptext" "The demo playing now, and how far along finding its highlights is." - } "StreakProgress" { "ControlName" "ProgressBar" "fieldName" "StreakProgress" "xpos" "8" - "ypos" "30" + "ypos" "9" "wide" "520" "tall" "14" "visible" "0" @@ -32,9 +17,9 @@ "ControlName" "Label" "fieldName" "StreakListSlot" "xpos" "8" - "ypos" "58" + "ypos" "34" "wide" "520" - "tall" "144" + "tall" "168" "visible" "1" "enabled" "1" "labelText" "" @@ -73,51 +58,25 @@ "ControlName" "Label" "fieldName" "StreakPlayerLabel" "xpos" "8" - "ypos" "27" + "ypos" "6" "wide" "44" "tall" "20" "visible" "0" "enabled" "1" "labelText" "Player" "textAlignment" "west" - "helptext" "Only this player's streaks: part of a name, or several words." + "helptext" "Only this player's streaks: part of a name, or several words. A POV demo has only the player who recorded it." } "StreakPlayer" { "ControlName" "TextEntry" "fieldName" "StreakPlayer" "xpos" "56" - "ypos" "27" + "ypos" "6" "wide" "180" "tall" "20" "visible" "0" "enabled" "1" - "helptext" "Only this player's streaks: part of a name, or several words." - } - "StreakMinKillsLabel" - { - "ControlName" "Label" - "fieldName" "StreakMinKillsLabel" - "xpos" "248" - "ypos" "27" - "wide" "56" - "tall" "20" - "visible" "0" - "enabled" "1" - "labelText" "Min kills" - "textAlignment" "west" - "helptext" "Only highlights with at least this many kills. Empty shows every one." - } - "StreakMinKills" - { - "ControlName" "TextEntry" - "fieldName" "StreakMinKills" - "xpos" "308" - "ypos" "27" - "wide" "40" - "tall" "20" - "visible" "0" - "enabled" "1" - "helptext" "Only highlights with at least this many kills. Empty shows every one." + "helptext" "Only this player's streaks: part of a name, or several words. A POV demo has only the player who recorded it." } }