//! Optional debug HUD overlay showing camera and map diagnostics.
//!
//! Enabled by setting [`RustialBevyConfig::debug`] to `true`.
//! Spawns a text overlay in the top-left corner displaying camera
//! state, zoom level, tile counts, and FPS.
use bevy::prelude::*;
use bevy::render::view::screenshot::{save_to_disk, Screenshot, ScreenshotCaptured};
use rustial_engine::{
visible_tiles, visible_tiles_flat_view_with_config, FlatTileSelectionConfig,
TerrainDiagnostics, WebMercator,
};
use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use crate::components::TileEntity;
use crate::plugin::RustialBevyConfig;
use crate::plugin::MapStateResource;
/// Marker component for the debug HUD text entity.
#[derive(Component)]
pub struct DebugHudText;
/// Tracks frame timing for FPS calculation.
#[derive(Resource)]
pub struct DebugHudState {
frame_count: u32,
elapsed: f32,
fps: f32,
/// Cached "strict" tile count, recomputed once per second to avoid
/// running the full flat-view tile selection every frame.
strict_tile_count: usize,
}
impl Default for DebugHudState {
fn default() -> Self {
Self {
frame_count: 0,
elapsed: 0.0,
fps: 0.0,
strict_tile_count: 0,
}
}
}
/// State for optional once-per-second structured debug CSV export.
#[derive(Resource)]
pub struct DebugFileCsvState {
initialized: bool,
sample_index: u64,
output_path: PathBuf,
}
impl Default for DebugFileCsvState {
fn default() -> Self {
Self {
initialized: false,
sample_index: 0,
output_path: PathBuf::from("docs").join("debug").join("rustial_debug_values.csv"),
}
}
}
#[derive(Debug, Clone)]
struct DebugSnapshotRecord {
fps: f32,
zoom_level: u8,
zoom_pct: u32,
selected_tile_count: usize,
strict_visible_tile_count: usize,
terrain_mesh_count: usize,
terrain_enabled: bool,
terrain_loaded_tiles: usize,
terrain_pending_tiles: usize,
terrain_placeholder_tiles: usize,
terrain_cache_entries: usize,
terrain_hillshade_count: usize,
terrain_source_max_zoom: u8,
terrain_last_desired_zoom: u8,
terrain_mesh_resolution: u16,
terrain_vertical_exaggeration: f64,
terrain_skirt_depth_m: f64,
terrain_min_elevation_m: Option<f64>,
terrain_max_elevation_m: Option<f64>,
terrain_elevation_texture_tiles: usize,
terrain_materialized_vertices: usize,
terrain_materialized_indices: usize,
terrain_source_queued_requests: usize,
terrain_source_in_flight_requests: usize,
terrain_source_max_concurrent_requests: usize,
terrain_source_known_requests: usize,
terrain_source_cancelled_in_flight_requests: usize,
terrain_source_network_failures: usize,
terrain_source_decode_failures: usize,
terrain_source_unsupported_format_failures: usize,
terrain_source_other_failures: usize,
terrain_source_ignored_completed_responses: usize,
pitch_deg: f64,
yaw_deg: f64,
distance_m: f64,
center_lat: f64,
center_lon: f64,
loaded_count: usize,
pending_count: usize,
visible_entities: usize,
hidden_entities: usize,
viewport_width_km: f64,
mercator_world_width_km: f64,
spans_full_world_x: bool,
layer_name: String,
raw_candidate_tiles: usize,
visible_tiles: usize,
exact_visible_tiles: usize,
fallback_visible_tiles: usize,
missing_visible_tiles: usize,
overzoomed_visible_tiles: usize,
requested_tiles: usize,
exact_cache_hits: usize,
cache_misses: usize,
cancelled_stale_pending: usize,
budget_hit: bool,
dropped_by_budget: usize,
cache_total_entries: usize,
cache_loaded_entries: usize,
cache_expired_entries: usize,
cache_reloading_entries: usize,
cache_pending_entries: usize,
cache_failed_entries: usize,
cache_renderable_entries: usize,
queued_requests: usize,
in_flight_requests: usize,
max_concurrent_requests: usize,
known_requests: usize,
cancelled_in_flight_requests: usize,
counter_frames: u64,
counter_requested_tiles: u64,
counter_exact_cache_hits: u64,
counter_fallback_hits: u64,
counter_cache_misses: u64,
counter_cancelled_stale_pending: u64,
counter_cancelled_evicted_pending: u64,
}
impl DebugSnapshotRecord {
fn csv_header() -> &'static str {
"sample_index,sample_image,fps,zoom_level,zoom_pct,selected_tiles,strict_tiles,terrain_count,terrain_enabled,terrain_loaded_tiles,terrain_pending_tiles,terrain_placeholder_tiles,terrain_cache_entries,terrain_hillshade_tiles,terrain_source_max_zoom,terrain_last_desired_zoom,terrain_mesh_resolution,terrain_vertical_exaggeration,terrain_skirt_depth_m,terrain_min_elevation_m,terrain_max_elevation_m,terrain_elevation_texture_tiles,terrain_materialized_vertices,terrain_materialized_indices,terrain_source_queued_requests,terrain_source_in_flight_requests,terrain_source_max_concurrent_requests,terrain_source_known_requests,terrain_source_cancelled_in_flight_requests,terrain_source_network_failures,terrain_source_decode_failures,terrain_source_unsupported_format_failures,terrain_source_other_failures,terrain_source_ignored_completed_responses,pitch_deg,yaw_deg,distance_m,center_lat,center_lon,loaded_tiles,pending_tiles,visible_entities,hidden_entities,viewport_width_km,mercator_world_width_km,full_world_x,layer_name,raw_candidate_tiles,visible_tiles,exact_visible_tiles,fallback_visible_tiles,missing_visible_tiles,overzoomed_visible_tiles,requested_tiles,exact_cache_hits,cache_misses,cancelled_stale_pending,budget_hit,dropped_by_budget,cache_total_entries,cache_loaded_entries,cache_expired_entries,cache_reloading_entries,cache_pending_entries,cache_failed_entries,cache_renderable_entries,queued_requests,in_flight_requests,max_concurrent_requests,known_requests,cancelled_in_flight_requests,counter_frames,counter_requested_tiles,counter_exact_cache_hits,counter_fallback_hits,counter_cache_misses,counter_cancelled_stale_pending,counter_cancelled_evicted_pending"
}
fn tile_summary_text(&self) -> String {
let source = format!(
"pool: queued={} in-flight={}/{} known={} ghost={}",
self.queued_requests,
self.in_flight_requests,
self.max_concurrent_requests,
self.known_requests,
self.cancelled_in_flight_requests,
);
format!(
"layer: {}\nselection: raw={} vis={} exact={} fallback={} missing={} overzoom={} req={} hit={} miss={} cancel={} budget={} drop={}\ncache: total={} loaded={} expired={} reload={} pending={} failed={} renderable={}\n{}\ncounters: frames={} req={} hit={} fallback={} miss={} stale_cancel={} evicted_cancel={}",
self.layer_name,
self.raw_candidate_tiles,
self.visible_tiles,
self.exact_visible_tiles,
self.fallback_visible_tiles,
self.missing_visible_tiles,
self.overzoomed_visible_tiles,
self.requested_tiles,
self.exact_cache_hits,
self.cache_misses,
self.cancelled_stale_pending,
if self.budget_hit { "hit" } else { "ok" },
self.dropped_by_budget,
self.cache_total_entries,
self.cache_loaded_entries,
self.cache_expired_entries,
self.cache_reloading_entries,
self.cache_pending_entries,
self.cache_failed_entries,
self.cache_renderable_entries,
source,
self.counter_frames,
self.counter_requested_tiles,
self.counter_exact_cache_hits,
self.counter_fallback_hits,
self.counter_cache_misses,
self.counter_cancelled_stale_pending,
self.counter_cancelled_evicted_pending,
)
}
fn terrain_summary_text(&self) -> String {
let min_elev = self
.terrain_min_elevation_m
.map(|v| format!("{v:.0}"))
.unwrap_or_else(|| "n/a".to_string());
let max_elev = self
.terrain_max_elevation_m
.map(|v| format!("{v:.0}"))
.unwrap_or_else(|| "n/a".to_string());
format!(
"terrain: enabled={} meshes={} loaded={} pending={} placeholder={} cache={} hillshade={} src-zmax={} desired-z={} res={} exag={:.2} skirt={:.0}m elev=[{}, {}] tex={} verts={} idx={} src[q={},f={}/{},k={},c={},net={},dec={},fmt={},other={},ignored={}]",
if self.terrain_enabled { "yes" } else { "no" },
self.terrain_mesh_count,
self.terrain_loaded_tiles,
self.terrain_pending_tiles,
self.terrain_placeholder_tiles,
self.terrain_cache_entries,
self.terrain_hillshade_count,
self.terrain_source_max_zoom,
self.terrain_last_desired_zoom,
self.terrain_mesh_resolution,
self.terrain_vertical_exaggeration,
self.terrain_skirt_depth_m,
min_elev,
max_elev,
self.terrain_elevation_texture_tiles,
self.terrain_materialized_vertices,
self.terrain_materialized_indices,
self.terrain_source_queued_requests,
self.terrain_source_in_flight_requests,
self.terrain_source_max_concurrent_requests,
self.terrain_source_known_requests,
self.terrain_source_cancelled_in_flight_requests,
self.terrain_source_network_failures,
self.terrain_source_decode_failures,
self.terrain_source_unsupported_format_failures,
self.terrain_source_other_failures,
self.terrain_source_ignored_completed_responses,
)
}
fn hud_text(&self) -> String {
format!(
"{:.0} fps zoom: {} ({}%) tiles: {} selected / {} strict terrain: {}\n\
pitch: {:.1} yaw: {:.1} dist: {:.0} m\n\
center: {:.4}, {:.4}\n\
data: {} loaded / {} pending entities: {} visible / {} hidden\n\
viewport-x: {:.0} km / {:.0} km full-world-x: {}\n\
{}\n\
{}",
self.fps,
self.zoom_level,
self.zoom_pct,
self.selected_tile_count,
self.strict_visible_tile_count,
self.terrain_mesh_count,
self.pitch_deg,
self.yaw_deg,
self.distance_m,
self.center_lat,
self.center_lon,
self.loaded_count,
self.pending_count,
self.visible_entities,
self.hidden_entities,
self.viewport_width_km,
self.mercator_world_width_km,
if self.spans_full_world_x { "yes" } else { "no" },
self.terrain_summary_text(),
self.tile_summary_text(),
)
}
fn csv_row(&self, sample_index: u64, sample_image_path: &Path) -> String {
let mut fields = Vec::with_capacity(52);
fields.push(sample_index.to_string());
fields.push(csv_escape(&sample_image_path.display().to_string()));
fields.push(format!("{:.3}", self.fps));
fields.push(self.zoom_level.to_string());
fields.push(self.zoom_pct.to_string());
fields.push(self.selected_tile_count.to_string());
fields.push(self.strict_visible_tile_count.to_string());
fields.push(self.terrain_mesh_count.to_string());
fields.push(self.terrain_enabled.to_string());
fields.push(self.terrain_loaded_tiles.to_string());
fields.push(self.terrain_pending_tiles.to_string());
fields.push(self.terrain_placeholder_tiles.to_string());
fields.push(self.terrain_cache_entries.to_string());
fields.push(self.terrain_hillshade_count.to_string());
fields.push(self.terrain_source_max_zoom.to_string());
fields.push(self.terrain_last_desired_zoom.to_string());
fields.push(self.terrain_mesh_resolution.to_string());
fields.push(format!("{:.3}", self.terrain_vertical_exaggeration));
fields.push(format!("{:.3}", self.terrain_skirt_depth_m));
fields.push(
self.terrain_min_elevation_m
.map(|v| format!("{v:.3}"))
.unwrap_or_default(),
);
fields.push(
self.terrain_max_elevation_m
.map(|v| format!("{v:.3}"))
.unwrap_or_default(),
);
fields.push(self.terrain_elevation_texture_tiles.to_string());
fields.push(self.terrain_materialized_vertices.to_string());
fields.push(self.terrain_materialized_indices.to_string());
fields.push(self.terrain_source_queued_requests.to_string());
fields.push(self.terrain_source_in_flight_requests.to_string());
fields.push(self.terrain_source_max_concurrent_requests.to_string());
fields.push(self.terrain_source_known_requests.to_string());
fields.push(self.terrain_source_cancelled_in_flight_requests.to_string());
fields.push(self.terrain_source_network_failures.to_string());
fields.push(self.terrain_source_decode_failures.to_string());
fields.push(self.terrain_source_unsupported_format_failures.to_string());
fields.push(self.terrain_source_other_failures.to_string());
fields.push(self.terrain_source_ignored_completed_responses.to_string());
fields.push(format!("{:.3}", self.pitch_deg));
fields.push(format!("{:.3}", self.yaw_deg));
fields.push(format!("{:.3}", self.distance_m));
fields.push(format!("{:.6}", self.center_lat));
fields.push(format!("{:.6}", self.center_lon));
fields.push(self.loaded_count.to_string());
fields.push(self.pending_count.to_string());
fields.push(self.visible_entities.to_string());
fields.push(self.hidden_entities.to_string());
fields.push(format!("{:.3}", self.viewport_width_km));
fields.push(format!("{:.3}", self.mercator_world_width_km));
fields.push(self.spans_full_world_x.to_string());
fields.push(csv_escape(&self.layer_name));
fields.push(self.raw_candidate_tiles.to_string());
fields.push(self.visible_tiles.to_string());
fields.push(self.exact_visible_tiles.to_string());
fields.push(self.fallback_visible_tiles.to_string());
fields.push(self.missing_visible_tiles.to_string());
fields.push(self.overzoomed_visible_tiles.to_string());
fields.push(self.requested_tiles.to_string());
fields.push(self.exact_cache_hits.to_string());
fields.push(self.cache_misses.to_string());
fields.push(self.cancelled_stale_pending.to_string());
fields.push(self.budget_hit.to_string());
fields.push(self.dropped_by_budget.to_string());
fields.push(self.cache_total_entries.to_string());
fields.push(self.cache_loaded_entries.to_string());
fields.push(self.cache_expired_entries.to_string());
fields.push(self.cache_reloading_entries.to_string());
fields.push(self.cache_pending_entries.to_string());
fields.push(self.cache_failed_entries.to_string());
fields.push(self.cache_renderable_entries.to_string());
fields.push(self.queued_requests.to_string());
fields.push(self.in_flight_requests.to_string());
fields.push(self.max_concurrent_requests.to_string());
fields.push(self.known_requests.to_string());
fields.push(self.cancelled_in_flight_requests.to_string());
fields.push(self.counter_frames.to_string());
fields.push(self.counter_requested_tiles.to_string());
fields.push(self.counter_exact_cache_hits.to_string());
fields.push(self.counter_fallback_hits.to_string());
fields.push(self.counter_cache_misses.to_string());
fields.push(self.counter_cancelled_stale_pending.to_string());
fields.push(self.counter_cancelled_evicted_pending.to_string());
fields.join(",")
}
}
fn csv_escape(value: &str) -> String {
let escaped = value.replace('"', "\"\"");
format!("\"{}\"", escaped)
}
/// State for optional once-per-second debug JPG export.
#[derive(Resource)]
pub struct DebugFileJpgState {
sample_index: u64,
request_in_flight: bool,
output_dir: PathBuf,
}
impl Default for DebugFileJpgState {
fn default() -> Self {
Self {
sample_index: 0,
request_in_flight: false,
output_dir: PathBuf::from("docs").join("debug"),
}
}
}
/// State for optional once-per-second debug text export.
#[derive(Resource)]
pub struct DebugFileTxtState {
initialized: bool,
sample_index: u64,
output_path: PathBuf,
}
impl Default for DebugFileTxtState {
fn default() -> Self {
Self {
initialized: false,
sample_index: 0,
output_path: PathBuf::from("docs").join("debug").join("rustial_debug_values.txt"),
}
}
}
/// Spawn the debug HUD text entity.
pub fn spawn_debug_hud(mut commands: Commands) {
commands.spawn((
Text::new(""),
TextFont {
font_size: 14.0,
..default()
},
TextColor(Color::BLACK),
Node {
position_type: PositionType::Absolute,
top: Val::Px(8.0),
left: Val::Px(8.0),
..default()
},
DebugHudText,
));
}
/// Update the debug HUD text every frame.
pub fn update_debug_hud(
mut commands: Commands,
state: Res<MapStateResource>,
config: Res<RustialBevyConfig>,
time: Res<Time>,
mut hud_state: ResMut<DebugHudState>,
mut debug_file_state: ResMut<DebugFileTxtState>,
mut debug_csv_state: ResMut<DebugFileCsvState>,
mut debug_jpg_state: ResMut<DebugFileJpgState>,
mut query: Query<&mut Text, With<DebugHudText>>,
tile_entities: Query<(&TileEntity, &Visibility)>,
) {
// FPS + expensive diagnostics: update once per second.
hud_state.frame_count += 1;
hud_state.elapsed += time.delta_secs();
let mut sample_due = false;
if hud_state.elapsed >= 1.0 {
hud_state.fps = hud_state.frame_count as f32 / hud_state.elapsed;
hud_state.frame_count = 0;
hud_state.elapsed = 0.0;
sample_due = true;
// Recompute the strict tile count only on the 1-second tick
// to avoid running the full flat-view tile selection every frame.
let cam = state.0.camera();
hud_state.strict_tile_count = if let Some(view) = cam.flat_tile_view() {
visible_tiles_flat_view_with_config(
state.0.viewport_bounds(),
state.0.zoom_level(),
&view,
&FlatTileSelectionConfig {
footprint_pitch_threshold_rad: 0.0,
footprint_min_tiles: 0,
..FlatTileSelectionConfig::default()
},
)
.len()
} else {
visible_tiles(state.0.viewport_bounds(), state.0.zoom_level()).len()
};
}
let cam = state.0.camera();
let terrain_mesh_count = state.0.terrain_meshes().len();
let terrain_diag = state.0.terrain().diagnostics();
let selected_tile_count = state.0.visible_tiles().len();
let tile_diag = state.0.tile_pipeline_diagnostics();
let strict_visible_tile_count = hud_state.strict_tile_count;
// Engine-side tile data availability.
let loaded_count = state.0.visible_tiles().iter().filter(|t| t.data.is_some()).count();
let pending_count = selected_tile_count - loaded_count;
// Bevy-side entity visibility.
let total_entities = tile_entities.iter().count();
let visible_entities = tile_entities
.iter()
.filter(|(_, vis)| !matches!(vis, Visibility::Hidden))
.count();
let hidden_entities = total_entities - visible_entities;
let viewport_bounds = state.0.viewport_bounds();
let viewport_width_m = viewport_bounds.max.position.x - viewport_bounds.min.position.x;
let mercator_world_width_m = WebMercator::world_size();
let spans_full_world_x = viewport_width_m >= mercator_world_width_m - 1.0;
let fractional = state.0.fractional_zoom();
let frac_part = fractional - (state.0.zoom_level() as f64);
let pct = (frac_part * 100.0).round() as u32;
let debug_record = build_debug_snapshot_record(
hud_state.fps,
state.0.zoom_level(),
pct,
selected_tile_count,
strict_visible_tile_count,
terrain_mesh_count,
&terrain_diag,
cam.pitch().to_degrees(),
cam.yaw().to_degrees(),
cam.distance(),
cam.target().lat,
cam.target().lon,
loaded_count,
pending_count,
visible_entities,
hidden_entities,
viewport_width_m / 1_000.0,
mercator_world_width_m / 1_000.0,
spans_full_world_x,
tile_diag.as_ref(),
);
let debug_snapshot = debug_record.hud_text();
for mut text in query.iter_mut() {
*text = Text::new(debug_snapshot.clone());
}
if config.debug_file_txt && sample_due {
if let Err(err) = append_debug_snapshot(&mut debug_file_state, &debug_snapshot) {
log::warn!("debug_file_txt export failed: {err}");
}
}
if config.debug_file_csv && sample_due {
if let Err(err) = append_debug_snapshot_csv(&mut debug_csv_state, &debug_record) {
log::warn!("debug_file_csv export failed: {err}");
}
}
if config.debug_file_jpg && sample_due && !debug_jpg_state.request_in_flight {
if let Err(err) = request_debug_screenshot(&mut commands, &mut debug_jpg_state) {
log::warn!("debug_file_jpg export failed: {err}");
}
}
}
fn build_debug_snapshot_record(
fps: f32,
zoom_level: u8,
zoom_pct: u32,
selected_tile_count: usize,
strict_visible_tile_count: usize,
terrain_mesh_count: usize,
terrain_diag: &TerrainDiagnostics,
pitch_deg: f64,
yaw_deg: f64,
distance_m: f64,
center_lat: f64,
center_lon: f64,
loaded_count: usize,
pending_count: usize,
visible_entities: usize,
hidden_entities: usize,
viewport_width_km: f64,
mercator_world_width_km: f64,
spans_full_world_x: bool,
tile_diag: Option<&rustial_engine::TilePipelineDiagnostics>,
) -> DebugSnapshotRecord {
let mut record = DebugSnapshotRecord {
fps,
zoom_level,
zoom_pct,
selected_tile_count,
strict_visible_tile_count,
terrain_mesh_count,
terrain_enabled: terrain_diag.enabled,
terrain_loaded_tiles: terrain_diag.visible_loaded_tiles,
terrain_pending_tiles: terrain_diag.visible_pending_tiles,
terrain_placeholder_tiles: terrain_diag.visible_placeholder_tiles,
terrain_cache_entries: terrain_diag.cache_entries,
terrain_hillshade_count: terrain_diag.visible_hillshade_tiles,
terrain_source_max_zoom: terrain_diag.source_max_zoom,
terrain_last_desired_zoom: terrain_diag.last_desired_zoom,
terrain_mesh_resolution: terrain_diag.mesh_resolution,
terrain_vertical_exaggeration: terrain_diag.vertical_exaggeration,
terrain_skirt_depth_m: terrain_diag.skirt_depth_m,
terrain_min_elevation_m: terrain_diag.visible_min_elevation_m,
terrain_max_elevation_m: terrain_diag.visible_max_elevation_m,
terrain_elevation_texture_tiles: terrain_diag.elevation_texture_tiles,
terrain_materialized_vertices: terrain_diag.materialized_vertex_count,
terrain_materialized_indices: terrain_diag.materialized_index_count,
terrain_source_queued_requests: 0,
terrain_source_in_flight_requests: 0,
terrain_source_max_concurrent_requests: 0,
terrain_source_known_requests: 0,
terrain_source_cancelled_in_flight_requests: 0,
terrain_source_network_failures: 0,
terrain_source_decode_failures: 0,
terrain_source_unsupported_format_failures: 0,
terrain_source_other_failures: 0,
terrain_source_ignored_completed_responses: 0,
pitch_deg,
yaw_deg,
distance_m,
center_lat,
center_lon,
loaded_count,
pending_count,
visible_entities,
hidden_entities,
viewport_width_km,
mercator_world_width_km,
spans_full_world_x,
layer_name: "none".to_string(),
raw_candidate_tiles: 0,
visible_tiles: 0,
exact_visible_tiles: 0,
fallback_visible_tiles: 0,
missing_visible_tiles: 0,
overzoomed_visible_tiles: 0,
requested_tiles: 0,
exact_cache_hits: 0,
cache_misses: 0,
cancelled_stale_pending: 0,
budget_hit: false,
dropped_by_budget: 0,
cache_total_entries: 0,
cache_loaded_entries: 0,
cache_expired_entries: 0,
cache_reloading_entries: 0,
cache_pending_entries: 0,
cache_failed_entries: 0,
cache_renderable_entries: 0,
queued_requests: 0,
in_flight_requests: 0,
max_concurrent_requests: 0,
known_requests: 0,
cancelled_in_flight_requests: 0,
counter_frames: 0,
counter_requested_tiles: 0,
counter_exact_cache_hits: 0,
counter_fallback_hits: 0,
counter_cache_misses: 0,
counter_cancelled_stale_pending: 0,
counter_cancelled_evicted_pending: 0,
};
if let Some(source) = terrain_diag.source_diagnostics.as_ref() {
record.terrain_source_queued_requests = source.queued_requests;
record.terrain_source_in_flight_requests = source.in_flight_requests;
record.terrain_source_max_concurrent_requests = source.max_concurrent_requests;
record.terrain_source_known_requests = source.known_requests;
record.terrain_source_cancelled_in_flight_requests = source.cancelled_in_flight_requests;
record.terrain_source_network_failures = source.failure_diagnostics.network_failures;
record.terrain_source_decode_failures = source.failure_diagnostics.decode_failures;
record.terrain_source_unsupported_format_failures = source.failure_diagnostics.unsupported_format_failures;
record.terrain_source_other_failures = source.failure_diagnostics.other_failures;
record.terrain_source_ignored_completed_responses = source.failure_diagnostics.ignored_completed_responses;
}
if let Some(diag) = tile_diag {
record.layer_name = diag.layer_name.clone();
record.raw_candidate_tiles = diag.selection_stats.raw_candidate_tiles;
record.visible_tiles = diag.selection_stats.visible_tiles;
record.exact_visible_tiles = diag.selection_stats.exact_visible_tiles;
record.fallback_visible_tiles = diag.selection_stats.fallback_visible_tiles;
record.missing_visible_tiles = diag.selection_stats.missing_visible_tiles;
record.overzoomed_visible_tiles = diag.selection_stats.overzoomed_visible_tiles;
record.requested_tiles = diag.selection_stats.requested_tiles;
record.exact_cache_hits = diag.selection_stats.exact_cache_hits;
record.cache_misses = diag.selection_stats.cache_misses;
record.cancelled_stale_pending = diag.selection_stats.cancelled_stale_pending;
record.budget_hit = diag.selection_stats.budget_hit;
record.dropped_by_budget = diag.selection_stats.dropped_by_budget;
record.cache_total_entries = diag.cache_stats.total_entries;
record.cache_loaded_entries = diag.cache_stats.loaded_entries;
record.cache_expired_entries = diag.cache_stats.expired_entries;
record.cache_reloading_entries = diag.cache_stats.reloading_entries;
record.cache_pending_entries = diag.cache_stats.pending_entries;
record.cache_failed_entries = diag.cache_stats.failed_entries;
record.cache_renderable_entries = diag.cache_stats.renderable_entries;
record.counter_frames = diag.counters.frames;
record.counter_requested_tiles = diag.counters.requested_tiles;
record.counter_exact_cache_hits = diag.counters.exact_cache_hits;
record.counter_fallback_hits = diag.counters.fallback_hits;
record.counter_cache_misses = diag.counters.cache_misses;
record.counter_cancelled_stale_pending = diag.counters.cancelled_stale_pending;
record.counter_cancelled_evicted_pending = diag.counters.cancelled_evicted_pending;
if let Some(source) = diag.source_diagnostics.as_ref() {
record.queued_requests = source.queued_requests;
record.in_flight_requests = source.in_flight_requests;
record.max_concurrent_requests = source.max_concurrent_requests;
record.known_requests = source.known_requests;
record.cancelled_in_flight_requests = source.cancelled_in_flight_requests;
}
}
record
}
fn append_debug_snapshot(
state: &mut DebugFileTxtState,
snapshot: &str,
) -> std::io::Result<()> {
if let Some(parent) = state.output_path.parent() {
fs::create_dir_all(parent)?;
}
let mut options = OpenOptions::new();
options.create(true).write(true);
if state.initialized {
options.append(true);
} else {
options.truncate(true);
}
let mut file = options.open(&state.output_path)?;
state.sample_index += 1;
let sample_image_path = state
.output_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join(format!("sample_{:04}.jpg", state.sample_index));
if !state.initialized {
writeln!(file, "# rustial debug snapshot export")?;
writeln!(file, "# file: {}", state.output_path.display())?;
writeln!(file)?;
state.initialized = true;
}
writeln!(file, "[{}]", sample_image_path.display())?;
writeln!(file, "{}", snapshot)?;
writeln!(file)?;
Ok(())
}
fn append_debug_snapshot_csv(
state: &mut DebugFileCsvState,
snapshot: &DebugSnapshotRecord,
) -> std::io::Result<()> {
if let Some(parent) = state.output_path.parent() {
fs::create_dir_all(parent)?;
}
let mut options = OpenOptions::new();
options.create(true).write(true);
if state.initialized {
options.append(true);
} else {
options.truncate(true);
}
let mut file = options.open(&state.output_path)?;
state.sample_index += 1;
let sample_image_path = state
.output_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(format!("sample_{:04}.jpg", state.sample_index));
if !state.initialized {
writeln!(file, "{}", DebugSnapshotRecord::csv_header())?;
state.initialized = true;
}
writeln!(file, "{}", snapshot.csv_row(state.sample_index, &sample_image_path))?;
Ok(())
}
fn request_debug_screenshot(
commands: &mut Commands,
state: &mut DebugFileJpgState,
) -> std::io::Result<()> {
fs::create_dir_all(&state.output_dir)?;
state.sample_index += 1;
state.request_in_flight = true;
let path = state
.output_dir
.join(format!("sample_{:04}.jpg", state.sample_index));
commands
.spawn(Screenshot::primary_window())
.observe(save_to_disk(path.clone()))
.observe(
move |_captured: On<ScreenshotCaptured>,
mut jpg_state: ResMut<DebugFileJpgState>| {
jpg_state.request_in_flight = false;
log::info!("debug_file_jpg saved: {}", path.display());
},
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_record() -> DebugSnapshotRecord {
DebugSnapshotRecord {
fps: 29.5,
zoom_level: 12,
zoom_pct: 34,
selected_tile_count: 54,
strict_visible_tile_count: 28,
terrain_mesh_count: 12,
terrain_enabled: true,
terrain_loaded_tiles: 9,
terrain_pending_tiles: 2,
terrain_placeholder_tiles: 1,
terrain_cache_entries: 17,
terrain_hillshade_count: 12,
terrain_source_max_zoom: 15,
terrain_last_desired_zoom: 12,
terrain_mesh_resolution: 64,
terrain_vertical_exaggeration: 1.500,
terrain_skirt_depth_m: 120.0,
terrain_min_elevation_m: Some(-12.0),
terrain_max_elevation_m: Some(1034.0),
terrain_elevation_texture_tiles: 12,
terrain_materialized_vertices: 0,
terrain_materialized_indices: 0,
terrain_source_queued_requests: 0,
terrain_source_in_flight_requests: 2,
terrain_source_max_concurrent_requests: 0,
terrain_source_known_requests: 2,
terrain_source_cancelled_in_flight_requests: 0,
terrain_source_network_failures: 1,
terrain_source_decode_failures: 2,
terrain_source_unsupported_format_failures: 0,
terrain_source_other_failures: 0,
terrain_source_ignored_completed_responses: 3,
pitch_deg: 40.5,
yaw_deg: 2.2,
distance_m: 63_992.0,
center_lat: -33.9249,
center_lon: 18.4251,
loaded_count: 34,
pending_count: 0,
visible_entities: 60,
hidden_entities: 0,
viewport_width_km: 189.0,
mercator_world_width_km: 40_075.0,
spans_full_world_x: false,
layer_name: "__rustial_builtin_http_tiles".to_string(),
raw_candidate_tiles: 34,
visible_tiles: 34,
exact_visible_tiles: 0,
fallback_visible_tiles: 34,
missing_visible_tiles: 0,
overzoomed_visible_tiles: 0,
requested_tiles: 0,
exact_cache_hits: 0,
cache_misses: 0,
cancelled_stale_pending: 0,
budget_hit: false,
dropped_by_budget: 0,
cache_total_entries: 141,
cache_loaded_entries: 7,
cache_expired_entries: 0,
cache_reloading_entries: 0,
cache_pending_entries: 128,
cache_failed_entries: 6,
cache_renderable_entries: 7,
queued_requests: 96,
in_flight_requests: 32,
max_concurrent_requests: 32,
known_requests: 128,
cancelled_in_flight_requests: 0,
counter_frames: 1355,
counter_requested_tiles: 3421,
counter_exact_cache_hits: 0,
counter_fallback_hits: 420,
counter_cache_misses: 77472,
counter_cancelled_stale_pending: 2376,
counter_cancelled_evicted_pending: 0,
}
}
#[test]
fn csv_row_contains_sample_image_and_layer_name() {
let row = sample_record().csv_row(42, Path::new("docs/debug/sample_0042.jpg"));
assert!(row.contains("42,"));
assert!(row.contains("\"docs/debug/sample_0042.jpg\""));
assert!(row.contains("\"__rustial_builtin_http_tiles\""));
assert!(row.contains(",false,"));
assert!(row.contains(",true,9,2,1,17,12,15,12,64,1.500,120.000,-12.000,1034.000,12,0,0,0,2,0,2,0,1,2,0,0,3,"));
}
#[test]
fn append_debug_snapshot_csv_writes_header_once() {
let unique = format!(
"rustial_debug_values_{}_{}.csv",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time")
.as_nanos()
);
let output_path = std::env::temp_dir().join(unique);
let mut state = DebugFileCsvState {
initialized: false,
sample_index: 0,
output_path: output_path.clone(),
};
append_debug_snapshot_csv(&mut state, &sample_record()).expect("first csv append succeeds");
append_debug_snapshot_csv(&mut state, &sample_record()).expect("second csv append succeeds");
let content = fs::read_to_string(&output_path).expect("csv output should exist");
let lines: Vec<_> = content.lines().collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], DebugSnapshotRecord::csv_header());
assert!(lines[1].contains("sample_0001.jpg"));
assert!(lines[2].contains("sample_0002.jpg"));
let _ = fs::remove_file(output_path);
}
}