480 lines
16 KiB
Rust
480 lines
16 KiB
Rust
use crate::stats::HostStats;
|
|
use prometeu_firmware::Firmware;
|
|
|
|
const PANEL_X: usize = 6;
|
|
const PANEL_Y: usize = 3;
|
|
const PANEL_WIDTH: usize = 170;
|
|
const PANEL_PADDING_X: usize = 8;
|
|
const PANEL_PADDING_Y: usize = 3;
|
|
const LINE_HEIGHT: usize = 12;
|
|
const CHAR_SCALE: usize = 1;
|
|
const BAR_WIDTH: usize = PANEL_WIDTH - (PANEL_PADDING_X * 2);
|
|
const BAR_HEIGHT: usize = 6;
|
|
|
|
const BG: [u8; 4] = [10, 18, 32, 208];
|
|
const BORDER: [u8; 4] = [90, 126, 170, 255];
|
|
const TEXT: [u8; 4] = [235, 240, 255, 255];
|
|
const DIM: [u8; 4] = [150, 168, 196, 255];
|
|
const WARN: [u8; 4] = [255, 104, 104, 255];
|
|
const BAR_BG: [u8; 4] = [30, 42, 61, 255];
|
|
const BAR_FILL: [u8; 4] = [91, 184, 255, 255];
|
|
const BAR_WARN: [u8; 4] = [255, 150, 102, 255];
|
|
const OVERLAY_HEAP_FALLBACK_BYTES: usize = 8 * 1024 * 1024;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct OverlayMetric {
|
|
label: &'static str,
|
|
value: String,
|
|
warn: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct OverlayBar {
|
|
label: &'static str,
|
|
value: String,
|
|
ratio: f32,
|
|
warn: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct OverlaySnapshot {
|
|
rows: Vec<(OverlayMetric, OverlayMetric)>,
|
|
bars: Vec<OverlayBar>,
|
|
footer: Vec<OverlayMetric>,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct Rect {
|
|
x: usize,
|
|
y: usize,
|
|
width: usize,
|
|
height: usize,
|
|
}
|
|
|
|
struct FrameCanvas<'a> {
|
|
frame: &'a mut [u8],
|
|
width: usize,
|
|
height: usize,
|
|
}
|
|
|
|
pub(crate) fn capture_snapshot(stats: &HostStats, firmware: &Firmware) -> OverlaySnapshot {
|
|
let tel = firmware.os.atomic_telemetry.snapshot();
|
|
let recent_logs = firmware.os.log_service.get_recent(10);
|
|
let violations_count =
|
|
recent_logs.iter().filter(|e| e.tag >= 0xCA01 && e.tag <= 0xCA07).count();
|
|
|
|
let mut footer = Vec::new();
|
|
if violations_count > 0
|
|
&& let Some(event) =
|
|
recent_logs.into_iter().rev().find(|e| e.tag >= 0xCA01 && e.tag <= 0xCA07)
|
|
{
|
|
footer.push(OverlayMetric {
|
|
label: "CERT",
|
|
value: truncate_value(&event.msg, 28),
|
|
warn: true,
|
|
});
|
|
}
|
|
|
|
if let Some(report) = firmware.os.last_crash_report.as_ref() {
|
|
footer.push(OverlayMetric {
|
|
label: "CRASH",
|
|
value: truncate_value(&report.summary(), 28),
|
|
warn: true,
|
|
});
|
|
}
|
|
|
|
let cycles_ratio = ratio(tel.cycles_used, tel.cycles_budget);
|
|
let heap_total_bytes = firmware
|
|
.os
|
|
.certifier
|
|
.config
|
|
.max_heap_bytes
|
|
.or(if tel.heap_max_bytes > 0 { Some(tel.heap_max_bytes) } else { None })
|
|
.unwrap_or(OVERLAY_HEAP_FALLBACK_BYTES);
|
|
let heap_ratio = ratio(tel.heap_used_bytes as u64, heap_total_bytes as u64);
|
|
let glyph_ratio = ratio(tel.glyph_slots_used as u64, tel.glyph_slots_total as u64);
|
|
let sound_ratio = ratio(tel.sound_slots_used as u64, tel.sound_slots_total as u64);
|
|
|
|
OverlaySnapshot {
|
|
rows: vec![
|
|
(
|
|
OverlayMetric {
|
|
label: "FPS",
|
|
value: format!("{:.1}", stats.current_fps),
|
|
warn: false,
|
|
},
|
|
OverlayMetric {
|
|
label: "CERT",
|
|
value: violations_count.to_string(),
|
|
warn: violations_count > 0,
|
|
},
|
|
),
|
|
(
|
|
OverlayMetric {
|
|
label: "HOST",
|
|
value: format!("{:.2}ms", stats.average_host_cpu_ms()),
|
|
warn: false,
|
|
},
|
|
OverlayMetric { label: "STEPS", value: tel.vm_steps.to_string(), warn: false },
|
|
),
|
|
(
|
|
OverlayMetric { label: "SYSC", value: tel.syscalls.to_string(), warn: false },
|
|
OverlayMetric { label: "LOGS", value: tel.logs_count.to_string(), warn: false },
|
|
),
|
|
],
|
|
bars: vec![
|
|
OverlayBar {
|
|
label: "HEAP",
|
|
value: format!(
|
|
"{}K{}",
|
|
tel.heap_used_bytes.div_ceil(1024),
|
|
if heap_total_bytes > 0 {
|
|
format!(" / {}K", heap_total_bytes.div_ceil(1024))
|
|
} else {
|
|
String::new()
|
|
}
|
|
),
|
|
ratio: heap_ratio,
|
|
warn: tel.heap_used_bytes >= heap_total_bytes,
|
|
},
|
|
OverlayBar {
|
|
label: "BUDGET",
|
|
value: if tel.cycles_budget > 0 {
|
|
format!(
|
|
"{:.1}K/{:.1}K",
|
|
tel.cycles_used as f64 / 1000.0,
|
|
tel.cycles_budget as f64 / 1000.0
|
|
)
|
|
} else {
|
|
"0.0K/0.0K".to_string()
|
|
},
|
|
ratio: cycles_ratio,
|
|
warn: cycles_ratio >= 0.9,
|
|
},
|
|
OverlayBar {
|
|
label: "GLYPH",
|
|
value: format!("{} / {} slots", tel.glyph_slots_used, tel.glyph_slots_total),
|
|
ratio: glyph_ratio,
|
|
warn: tel.glyph_slots_total > 0 && tel.glyph_slots_used >= tel.glyph_slots_total,
|
|
},
|
|
OverlayBar {
|
|
label: "SOUNDS",
|
|
value: format!("{} / {} slots", tel.sound_slots_used, tel.sound_slots_total),
|
|
ratio: sound_ratio,
|
|
warn: tel.sound_slots_total > 0 && tel.sound_slots_used >= tel.sound_slots_total,
|
|
},
|
|
],
|
|
footer,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn draw_overlay(
|
|
frame: &mut [u8],
|
|
frame_width: usize,
|
|
frame_height: usize,
|
|
snapshot: &OverlaySnapshot,
|
|
) {
|
|
let mut canvas = FrameCanvas { frame, width: frame_width, height: frame_height };
|
|
let panel_height = frame_height.saturating_sub(PANEL_Y * 2);
|
|
let panel_rect = Rect { x: PANEL_X, y: PANEL_Y, width: PANEL_WIDTH, height: panel_height };
|
|
|
|
fill_rect_alpha(&mut canvas, panel_rect, BG);
|
|
stroke_rect(&mut canvas, panel_rect, BORDER);
|
|
|
|
let mut y = PANEL_Y + PANEL_PADDING_Y;
|
|
for (left, right) in &snapshot.rows {
|
|
draw_metric_pair(canvas.frame, canvas.width, canvas.height, y, left, right);
|
|
y += LINE_HEIGHT;
|
|
}
|
|
|
|
for bar in &snapshot.bars {
|
|
let color = if bar.warn { WARN } else { TEXT };
|
|
draw_text(
|
|
canvas.frame,
|
|
canvas.width,
|
|
canvas.height,
|
|
PANEL_X + PANEL_PADDING_X,
|
|
y,
|
|
bar.label,
|
|
DIM,
|
|
);
|
|
draw_text(canvas.frame, canvas.width, canvas.height, PANEL_X + 48, y, &bar.value, color);
|
|
y += LINE_HEIGHT - 2;
|
|
|
|
let bar_x = PANEL_X + PANEL_PADDING_X;
|
|
let bar_rect = Rect { x: bar_x, y, width: BAR_WIDTH, height: BAR_HEIGHT };
|
|
fill_rect(&mut canvas, bar_rect, BAR_BG);
|
|
let fill_width = ((BAR_WIDTH as f32) * bar.ratio.clamp(0.0, 1.0)).round() as usize;
|
|
fill_rect(
|
|
&mut canvas,
|
|
Rect { x: bar_x, y, width: fill_width, height: BAR_HEIGHT },
|
|
if bar.warn { BAR_WARN } else { BAR_FILL },
|
|
);
|
|
stroke_rect(&mut canvas, bar_rect, BORDER);
|
|
y += BAR_HEIGHT + 6;
|
|
}
|
|
|
|
for line in &snapshot.footer {
|
|
let color = if line.warn { WARN } else { TEXT };
|
|
draw_text(
|
|
canvas.frame,
|
|
canvas.width,
|
|
canvas.height,
|
|
PANEL_X + PANEL_PADDING_X,
|
|
y,
|
|
line.label,
|
|
DIM,
|
|
);
|
|
draw_text(canvas.frame, canvas.width, canvas.height, PANEL_X + 48, y, &line.value, color);
|
|
y += LINE_HEIGHT;
|
|
}
|
|
}
|
|
|
|
fn draw_metric_pair(
|
|
frame: &mut [u8],
|
|
frame_width: usize,
|
|
frame_height: usize,
|
|
y: usize,
|
|
left: &OverlayMetric,
|
|
right: &OverlayMetric,
|
|
) {
|
|
let left_x = PANEL_X + PANEL_PADDING_X;
|
|
let right_x = PANEL_X + 86;
|
|
|
|
draw_metric(frame, frame_width, frame_height, left_x, y, left);
|
|
draw_metric(frame, frame_width, frame_height, right_x, y, right);
|
|
}
|
|
|
|
fn draw_metric(
|
|
frame: &mut [u8],
|
|
frame_width: usize,
|
|
frame_height: usize,
|
|
x: usize,
|
|
y: usize,
|
|
metric: &OverlayMetric,
|
|
) {
|
|
let color = if metric.warn { WARN } else { TEXT };
|
|
draw_text(frame, frame_width, frame_height, x, y, metric.label, DIM);
|
|
draw_text(frame, frame_width, frame_height, x + 30, y, &metric.value, color);
|
|
}
|
|
|
|
fn ratio(value: u64, total: u64) -> f32 {
|
|
if total == 0 { 0.0 } else { (value as f32 / total as f32).clamp(0.0, 1.0) }
|
|
}
|
|
|
|
fn truncate_value(value: &str, max_len: usize) -> String {
|
|
let mut upper = value.to_ascii_uppercase();
|
|
if upper.len() > max_len {
|
|
upper.truncate(max_len);
|
|
}
|
|
upper
|
|
}
|
|
|
|
fn draw_text(
|
|
frame: &mut [u8],
|
|
frame_width: usize,
|
|
frame_height: usize,
|
|
x: usize,
|
|
y: usize,
|
|
text: &str,
|
|
color: [u8; 4],
|
|
) {
|
|
let mut cursor_x = x;
|
|
for ch in text.chars() {
|
|
if ch == '\n' {
|
|
continue;
|
|
}
|
|
draw_char(frame, frame_width, frame_height, cursor_x, y, ch.to_ascii_uppercase(), color);
|
|
cursor_x += 6 * CHAR_SCALE;
|
|
}
|
|
}
|
|
|
|
fn draw_char(
|
|
frame: &mut [u8],
|
|
frame_width: usize,
|
|
frame_height: usize,
|
|
x: usize,
|
|
y: usize,
|
|
ch: char,
|
|
color: [u8; 4],
|
|
) {
|
|
let glyph = glyph_bits(ch);
|
|
for (row, bits) in glyph.iter().enumerate() {
|
|
for col in 0..5 {
|
|
if bits & (1 << (4 - col)) != 0 {
|
|
let mut canvas = FrameCanvas { frame, width: frame_width, height: frame_height };
|
|
fill_rect(
|
|
&mut canvas,
|
|
Rect {
|
|
x: x + col * CHAR_SCALE,
|
|
y: y + row * CHAR_SCALE,
|
|
width: CHAR_SCALE,
|
|
height: CHAR_SCALE,
|
|
},
|
|
color,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn fill_rect_alpha(canvas: &mut FrameCanvas<'_>, rect: Rect, color: [u8; 4]) {
|
|
let max_x = (rect.x + rect.width).min(canvas.width);
|
|
let max_y = (rect.y + rect.height).min(canvas.height);
|
|
for py in rect.y..max_y {
|
|
for px in rect.x..max_x {
|
|
blend_pixel(canvas.frame, canvas.width, px, py, color);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn fill_rect(canvas: &mut FrameCanvas<'_>, rect: Rect, color: [u8; 4]) {
|
|
let max_x = (rect.x + rect.width).min(canvas.width);
|
|
let max_y = (rect.y + rect.height).min(canvas.height);
|
|
for py in rect.y..max_y {
|
|
for px in rect.x..max_x {
|
|
write_pixel(canvas.frame, canvas.width, px, py, color);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn stroke_rect(canvas: &mut FrameCanvas<'_>, rect: Rect, color: [u8; 4]) {
|
|
if rect.width == 0 || rect.height == 0 {
|
|
return;
|
|
}
|
|
fill_rect(canvas, Rect { x: rect.x, y: rect.y, width: rect.width, height: 1 }, color);
|
|
fill_rect(
|
|
canvas,
|
|
Rect { x: rect.x, y: rect.y + rect.height.saturating_sub(1), width: rect.width, height: 1 },
|
|
color,
|
|
);
|
|
fill_rect(canvas, Rect { x: rect.x, y: rect.y, width: 1, height: rect.height }, color);
|
|
fill_rect(
|
|
canvas,
|
|
Rect { x: rect.x + rect.width.saturating_sub(1), y: rect.y, width: 1, height: rect.height },
|
|
color,
|
|
);
|
|
}
|
|
|
|
fn blend_pixel(frame: &mut [u8], frame_width: usize, x: usize, y: usize, color: [u8; 4]) {
|
|
let idx = (y * frame_width + x) * 4;
|
|
let alpha = color[3] as f32 / 255.0;
|
|
let inv = 1.0 - alpha;
|
|
frame[idx] = (frame[idx] as f32 * inv + color[0] as f32 * alpha).round() as u8;
|
|
frame[idx + 1] = (frame[idx + 1] as f32 * inv + color[1] as f32 * alpha).round() as u8;
|
|
frame[idx + 2] = (frame[idx + 2] as f32 * inv + color[2] as f32 * alpha).round() as u8;
|
|
frame[idx + 3] = 0xFF;
|
|
}
|
|
|
|
fn write_pixel(frame: &mut [u8], frame_width: usize, x: usize, y: usize, color: [u8; 4]) {
|
|
let idx = (y * frame_width + x) * 4;
|
|
frame[idx] = color[0];
|
|
frame[idx + 1] = color[1];
|
|
frame[idx + 2] = color[2];
|
|
frame[idx + 3] = color[3];
|
|
}
|
|
|
|
fn glyph_bits(ch: char) -> [u8; 7] {
|
|
match ch {
|
|
'A' => [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
|
|
'B' => [0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E],
|
|
'C' => [0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E],
|
|
'D' => [0x1E, 0x12, 0x11, 0x11, 0x11, 0x12, 0x1E],
|
|
'E' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F],
|
|
'F' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10],
|
|
'G' => [0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0E],
|
|
'H' => [0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
|
|
'I' => [0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E],
|
|
'J' => [0x01, 0x01, 0x01, 0x01, 0x11, 0x11, 0x0E],
|
|
'K' => [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11],
|
|
'L' => [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F],
|
|
'M' => [0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11],
|
|
'N' => [0x11, 0x11, 0x19, 0x15, 0x13, 0x11, 0x11],
|
|
'O' => [0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
|
|
'P' => [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10],
|
|
'Q' => [0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D],
|
|
'R' => [0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11],
|
|
'S' => [0x0F, 0x10, 0x10, 0x0E, 0x01, 0x01, 0x1E],
|
|
'T' => [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],
|
|
'U' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
|
|
'V' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04],
|
|
'W' => [0x11, 0x11, 0x11, 0x15, 0x15, 0x15, 0x0A],
|
|
'X' => [0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11],
|
|
'Y' => [0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04],
|
|
'Z' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F],
|
|
'0' => [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E],
|
|
'1' => [0x04, 0x0C, 0x14, 0x04, 0x04, 0x04, 0x1F],
|
|
'2' => [0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F],
|
|
'3' => [0x1E, 0x01, 0x01, 0x0E, 0x01, 0x01, 0x1E],
|
|
'4' => [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02],
|
|
'5' => [0x1F, 0x10, 0x10, 0x1E, 0x01, 0x01, 0x1E],
|
|
'6' => [0x0E, 0x10, 0x10, 0x1E, 0x11, 0x11, 0x0E],
|
|
'7' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08],
|
|
'8' => [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E],
|
|
'9' => [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x01, 0x0E],
|
|
':' => [0x00, 0x04, 0x04, 0x00, 0x04, 0x04, 0x00],
|
|
'.' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x06],
|
|
'/' => [0x01, 0x02, 0x02, 0x04, 0x08, 0x08, 0x10],
|
|
'%' => [0x19, 0x19, 0x02, 0x04, 0x08, 0x13, 0x13],
|
|
'(' => [0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02],
|
|
')' => [0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08],
|
|
'-' => [0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00],
|
|
'_' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F],
|
|
'+' => [0x00, 0x04, 0x04, 0x1F, 0x04, 0x04, 0x00],
|
|
' ' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
|
|
'?' => [0x0E, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04],
|
|
_ => [0x0E, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04],
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn sample_snapshot() -> OverlaySnapshot {
|
|
OverlaySnapshot {
|
|
rows: vec![
|
|
(
|
|
OverlayMetric { label: "FPS", value: "60.0".to_string(), warn: false },
|
|
OverlayMetric { label: "CERT", value: "2".to_string(), warn: true },
|
|
),
|
|
(
|
|
OverlayMetric { label: "HOST", value: "1.23ms".to_string(), warn: false },
|
|
OverlayMetric { label: "STEPS", value: "420".to_string(), warn: false },
|
|
),
|
|
],
|
|
bars: vec![
|
|
OverlayBar {
|
|
label: "HEAP",
|
|
value: "1024K / 8192K".to_string(),
|
|
ratio: 0.125,
|
|
warn: false,
|
|
},
|
|
OverlayBar {
|
|
label: "BUDGET",
|
|
value: "9.0K/10.0K".to_string(),
|
|
ratio: 0.9,
|
|
warn: true,
|
|
},
|
|
],
|
|
footer: vec![OverlayMetric {
|
|
label: "CRASH",
|
|
value: "VM PANIC".to_string(),
|
|
warn: true,
|
|
}],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn draw_overlay_writes_to_host_rgba_frame() {
|
|
let mut frame = vec![0u8; 320 * 180 * 4];
|
|
draw_overlay(&mut frame, 320, 180, &sample_snapshot());
|
|
assert!(frame.iter().any(|&byte| byte != 0));
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_value_normalizes_and_caps() {
|
|
assert_eq!(truncate_value("panic: lowercase", 12), "PANIC: LOWER");
|
|
}
|
|
}
|