1use std::collections::BTreeMap;
4use std::io::BufReader;
5use std::path::Path;
6use tickwise::format::{Chunk, FormatError, RecReader, SnapshotPolicy, kind};
7
8pub struct Report {
10 pub text: String,
12 pub corrupt: bool,
14}
15
16pub fn render<P: AsRef<Path>>(path: P) -> Result<Report, FormatError> {
18 let path = path.as_ref();
19 let file_size = std::fs::metadata(path)?.len();
20 let file = std::fs::File::open(path)?;
21 let mut reader = RecReader::open(BufReader::new(file))?;
22
23 let header = reader.header().clone();
24 let version = reader.version();
25 let tick_count = reader.tick_count();
26
27 let mut input_frames: u64 = 0;
28 let mut light_batches: u64 = 0;
29 let mut light_hashes: u64 = 0;
30 let mut full_hashes: u64 = 0;
31 let mut snapshot_ticks: Vec<u64> = Vec::new();
32 let mut markers: u64 = 0;
33 let mut dump_ticks: Vec<u64> = Vec::new();
34 let mut unknown_chunks: u64 = 0;
35 let mut stream_error: Option<FormatError> = None;
36
37 for item in reader.chunks()? {
38 match item {
39 Ok(Chunk::InputFrame { .. }) => input_frames += 1,
40 Ok(Chunk::LightHashBatch { hashes, .. }) => {
41 light_batches += 1;
42 light_hashes += hashes.len() as u64;
43 }
44 Ok(Chunk::FullHash { .. }) => full_hashes += 1,
45 Ok(Chunk::Snapshot { tick, .. }) => snapshot_ticks.push(tick),
46 Ok(Chunk::Marker { .. }) => markers += 1,
47 Ok(Chunk::StateDump { tick, .. }) => dump_ticks.push(tick),
48 Ok(Chunk::Unknown { .. }) => unknown_chunks += 1,
49 Err(err) => {
50 stream_error = Some(err);
51 break;
52 }
53 }
54 }
55
56 let mut bytes_by_kind: BTreeMap<u16, u64> = BTreeMap::new();
59 let mut index_error: Option<FormatError> = None;
60 match reader.read_index() {
61 Ok(entries) => {
62 for entry in entries {
63 *bytes_by_kind.entry(entry.kind).or_insert(0) += u64::from(entry.len) + 6;
64 }
65 }
66 Err(err) => index_error = Some(err),
67 }
68
69 let checksum_line = match reader.verify_checksum() {
70 Ok(()) => "checksum ok".to_string(),
71 Err(FormatError::ChecksumMismatch { stored, computed }) => {
72 format!("CHECKSUM MISMATCH, stored {stored:016x}, computed {computed:016x}")
73 }
74 Err(err) => return Err(err),
75 };
76 let corrupt =
77 checksum_line.starts_with("CHECKSUM") || stream_error.is_some() || index_error.is_some();
78
79 let meta = &header.meta;
80 let config = &header.config;
81 let mut s = String::new();
82
83 s.push_str(&format!("{}\n", path.display()));
84 s.push_str(&format!(" format version {version}\n"));
85 s.push_str(&format!(" game {}\n", or_unset(&meta.game_id)));
86 s.push_str(&format!(
87 " build {}\n",
88 or_unset(&meta.build_hash)
89 ));
90 s.push_str(&format!(" platform {}\n", or_unset(&meta.platform)));
91 s.push_str(&format!(
92 " tick rate {} ticks per second\n",
93 meta.tick_rate
94 ));
95 s.push_str(&format!(" rng seed {:#018x}\n", meta.rng_seed));
96 s.push_str(&format!(" created at unix {}\n", meta.created_at));
97 s.push_str(&match config.full_hash_interval {
98 0 => " full hashes disabled\n".to_string(),
99 n => format!(" full hashes every {n} ticks\n"),
100 });
101 s.push_str(&match config.snapshot_policy {
102 SnapshotPolicy::Off => " snapshots off\n".to_string(),
103 SnapshotPolicy::Every(n) => format!(" snapshots every {n} ticks\n"),
104 });
105 s.push_str(&format!(" hash algo id {}\n", config.hash_algo_id));
106 s.push_str(&format!(" input format id {}\n", config.input_format_id));
107 s.push('\n');
108 s.push_str(&format!(" ticks {tick_count}\n"));
109 s.push_str(&format!(" file size {}\n", human_bytes(file_size)));
110 s.push('\n');
111 s.push_str(" chunks\n");
112
113 let row = |label: &str, count: u64, bytes: Option<&u64>, note: &str| -> String {
114 let size = bytes.map_or("n/a".to_string(), |b| human_bytes(*b));
115 let mut line = format!(" {label:<22} {count:>8} {size:>10}");
116 if !note.is_empty() {
117 line.push_str(" ");
118 line.push_str(note);
119 }
120 line.push('\n');
121 line
122 };
123
124 s.push_str(&row(
125 "input frames",
126 input_frames,
127 bytes_by_kind.get(&kind::INPUT_FRAME),
128 "repeat suppressed",
129 ));
130 s.push_str(&row(
131 "light hash batches",
132 light_batches,
133 bytes_by_kind.get(&kind::LIGHT_HASH_BATCH),
134 &format!("holding {light_hashes} hashes"),
135 ));
136 s.push_str(&row(
137 "full hashes",
138 full_hashes,
139 bytes_by_kind.get(&kind::FULL_HASH),
140 "",
141 ));
142 s.push_str(&row(
143 "snapshots",
144 snapshot_ticks.len() as u64,
145 bytes_by_kind.get(&kind::SNAPSHOT),
146 &snapshot_note(&snapshot_ticks),
147 ));
148 s.push_str(&row(
149 "markers",
150 markers,
151 bytes_by_kind.get(&kind::MARKER),
152 "",
153 ));
154 if !dump_ticks.is_empty() {
155 s.push_str(&row(
156 "state dumps",
157 dump_ticks.len() as u64,
158 bytes_by_kind.get(&kind::STATE_DUMP),
159 &snapshot_note(&dump_ticks),
160 ));
161 }
162 if unknown_chunks > 0 {
163 let unknown_bytes: u64 = bytes_by_kind
164 .iter()
165 .filter(|(k, _)| !is_known_kind(**k))
166 .map(|(_, b)| *b)
167 .sum();
168 s.push_str(&row(
169 "unknown kinds",
170 unknown_chunks,
171 Some(&unknown_bytes),
172 "skipped safely",
173 ));
174 }
175 s.push('\n');
176
177 if let Some(err) = &stream_error {
178 s.push_str(&format!(" warning chunk stream error: {err}\n"));
179 }
180 if let Some(err) = &index_error {
181 s.push_str(&format!(" warning index unreadable: {err}\n"));
182 }
183 s.push_str(&format!(" integrity {checksum_line}\n"));
184 s.push_str(
185 " next record a second session, then find the first divergent tick:\n\
186 \x20 tickwise compare a.rec b.rec\n",
187 );
188
189 Ok(Report { text: s, corrupt })
190}
191
192fn is_known_kind(id: u16) -> bool {
193 matches!(
194 id,
195 kind::INPUT_FRAME
196 | kind::LIGHT_HASH_BATCH
197 | kind::FULL_HASH
198 | kind::SNAPSHOT
199 | kind::MARKER
200 | kind::STATE_DUMP
201 )
202}
203
204fn or_unset(value: &str) -> &str {
205 if value.is_empty() { "unset" } else { value }
206}
207
208fn snapshot_note(ticks: &[u64]) -> String {
209 match ticks {
210 [] => String::new(),
211 few if few.len() <= 8 => {
212 let list: Vec<String> = few.iter().map(u64::to_string).collect();
213 format!("at ticks {}", list.join(", "))
214 }
215 many => format!(
216 "first at tick {}, last at tick {}",
217 many[0],
218 many[many.len() - 1]
219 ),
220 }
221}
222
223fn human_bytes(bytes: u64) -> String {
224 const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
225 let mut value = bytes as f64;
226 let mut unit = 0;
227 while value >= 1024.0 && unit < UNITS.len() - 1 {
228 value /= 1024.0;
229 unit += 1;
230 }
231 if unit == 0 {
232 format!("{bytes} B")
233 } else {
234 format!("{value:.1} {}", UNITS[unit])
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn human_bytes_picks_sane_units() {
244 assert_eq!(human_bytes(0), "0 B");
245 assert_eq!(human_bytes(512), "512 B");
246 assert_eq!(human_bytes(2048), "2.0 KiB");
247 assert_eq!(human_bytes(5 * 1024 * 1024), "5.0 MiB");
248 }
249
250 #[test]
251 fn snapshot_notes_stay_short() {
252 assert_eq!(snapshot_note(&[]), "");
253 assert_eq!(snapshot_note(&[0, 100]), "at ticks 0, 100");
254 let many: Vec<u64> = (0..20).map(|i| i * 100).collect();
255 assert_eq!(snapshot_note(&many), "first at tick 0, last at tick 1900");
256 }
257}