Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion analysis/src/integrity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ pub fn check_demo_bytes(bytes: &[u8]) -> Result<IntegrityReport, String> {
/// `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: [")
Expand Down
7 changes: 6 additions & 1 deletion analysis/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
};

Expand Down
11 changes: 7 additions & 4 deletions docs/dodstudio_commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 22 additions & 3 deletions goldsrc-hooks/src/streaks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Option<u32>> {
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();
Expand Down Expand Up @@ -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)
Expand Down
58 changes: 56 additions & 2 deletions goldsrc-hooks/src/studio_panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>, highlights: Option<u32>) -> Option<String> {
let parts: Vec<String> = [("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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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!(
Expand All @@ -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));
}
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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!(
Expand Down
15 changes: 11 additions & 4 deletions goldsrc-hooks/src/studio_panel/hook/load_progress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<u32> {
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<f32> {
let name = crate::demo_reload::current_demo()?;
Expand Down
125 changes: 125 additions & 0 deletions goldsrc-hooks/src/studio_panel/hook/load_strip.rs
Original file line number Diff line number Diff line change
@@ -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<String> = 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<String>)> = 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<Vpanel>) -> Option<Vpanel> {
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))
}
}
Loading
Loading