1use std::fs::File;
4use std::io::BufReader;
5use std::path::Path;
6use tickwise::diff::{DiffClass, DiffError, DiffReport, FloatPolicy, structural_from};
7use tickwise::format::{FormatError, RecReader};
8
9const DEFAULT_LIMIT: usize = 100;
12
13#[derive(Debug, Clone, PartialEq, Default)]
16pub struct DiffOptions {
17 pub policy: FloatPolicy,
19 pub color: bool,
21 pub show_all: bool,
23}
24
25pub struct DiffOutput {
27 pub text: String,
29 pub differs: bool,
31}
32
33pub fn parse_args(args: &[String]) -> Result<(String, String, DiffOptions), String> {
35 let mut paths = Vec::new();
36 let mut options = DiffOptions {
37 color: true,
38 ..DiffOptions::default()
39 };
40 let mut iter = args.iter();
41 while let Some(arg) = iter.next() {
42 match arg.as_str() {
43 "--strict" => options.policy = FloatPolicy::strict(),
44 "--no-color" => options.color = false,
45 "--all" => options.show_all = true,
46 "--epsilon-f32" => {
47 let value = iter.next().ok_or("--epsilon-f32 needs a value")?;
48 options.policy.epsilon_f32 = value
49 .parse()
50 .map_err(|_| format!("--epsilon-f32: {value:?} is not a number"))?;
51 }
52 "--epsilon-f64" => {
53 let value = iter.next().ok_or("--epsilon-f64 needs a value")?;
54 options.policy.epsilon_f64 = value
55 .parse()
56 .map_err(|_| format!("--epsilon-f64: {value:?} is not a number"))?;
57 }
58 flag if flag.starts_with("--") => return Err(format!("unknown flag {flag}")),
59 path => paths.push(path.to_string()),
60 }
61 }
62 match paths.as_slice() {
63 [a, b] => Ok((a.clone(), b.clone(), options)),
64 _ => Err(
65 "usage: tickwise diff <a.dump> <b.dump> [--strict] [--epsilon-f32 X] \
66 [--epsilon-f64 X] [--all] [--no-color]"
67 .to_string(),
68 ),
69 }
70}
71
72struct Palette {
73 enabled: bool,
74}
75
76impl Palette {
77 fn paint(&self, code: &str, text: &str) -> String {
78 if self.enabled {
79 format!("\x1b[{code}m{text}\x1b[0m")
80 } else {
81 text.to_string()
82 }
83 }
84 fn class(&self, class: DiffClass) -> String {
85 match class {
86 DiffClass::Structural => self.paint("1;31", "structural"),
87 DiffClass::Exact => self.paint("33", "exact "),
88 DiffClass::SubEpsilonFloat => self.paint("36", "drift "),
89 }
90 }
91 fn good(&self, text: &str) -> String {
92 self.paint("32", text)
93 }
94 fn dim(&self, text: &str) -> String {
95 self.paint("2", text)
96 }
97}
98
99fn tick_list(ticks: &[u64]) -> String {
100 let shown: Vec<String> = ticks.iter().take(8).map(u64::to_string).collect();
101 if ticks.len() > 8 {
102 format!("{} and {} more", shown.join(", "), ticks.len() - 8)
103 } else {
104 shown.join(", ")
105 }
106}
107
108fn plural(n: usize, one: &str, many: &str) -> String {
109 if n == 1 {
110 format!("{n} {one}")
111 } else {
112 format!("{n} {many}")
113 }
114}
115
116pub fn render<A: AsRef<Path>, B: AsRef<Path>>(
118 a: A,
119 b: B,
120 options: &DiffOptions,
121) -> Result<DiffOutput, DiffError> {
122 let path_a = a.as_ref();
123 let path_b = b.as_ref();
124 let mut reader_a = RecReader::open(BufReader::new(
125 File::open(path_a).map_err(FormatError::from)?,
126 ))?;
127 let mut reader_b = RecReader::open(BufReader::new(
128 File::open(path_b).map_err(FormatError::from)?,
129 ))?;
130 let meta_a = reader_a.header().meta.clone();
131 let meta_b = reader_b.header().meta.clone();
132
133 let report = structural_from(&mut reader_a, &mut reader_b, options.policy)?;
134 let palette = Palette {
135 enabled: options.color,
136 };
137
138 let mut s = String::new();
139 s.push_str(&format!(
140 "diffing {} and {}\n\n",
141 path_a.display(),
142 path_b.display()
143 ));
144 let common: Vec<u64> = report.ticks.iter().map(|t| t.tick).collect();
145 let describe = |meta: &tickwise::SessionMeta, extra: &[u64]| {
146 let mut ticks: Vec<u64> = common.iter().chain(extra.iter()).copied().collect();
147 ticks.sort_unstable();
148 format!(
149 "game {}, seed {:#x}, dumps at ticks {}",
150 if meta.game_id.is_empty() {
151 "unset"
152 } else {
153 &meta.game_id
154 },
155 meta.rng_seed,
156 tick_list(&ticks)
157 )
158 };
159 s.push_str(&format!(
160 " first {}\n",
161 describe(&meta_a, &report.only_in_a)
162 ));
163 s.push_str(&format!(
164 " second {}\n",
165 describe(&meta_b, &report.only_in_b)
166 ));
167 s.push_str(&format!(
168 " float policy f32 epsilon {:e}, f64 epsilon {:e}\n\n",
169 report.policy.epsilon_f32, report.policy.epsilon_f64
170 ));
171
172 let mut total = 0;
173 for tick in &report.ticks {
174 let n = tick.differences.len();
175 total += n;
176 if tick.is_identical() {
177 s.push_str(&format!(
178 "tick {:<10} {}\n\n",
179 tick.tick,
180 palette.good(&format!("identical over {} fields", tick.fields_compared))
181 ));
182 continue;
183 }
184 s.push_str(&format!(
185 "tick {:<10} {} over {} fields: {} structural, {} exact, {} sub-epsilon float drift\n",
186 tick.tick,
187 plural(n, "difference", "differences"),
188 tick.fields_compared,
189 tick.count(DiffClass::Structural),
190 tick.count(DiffClass::Exact),
191 tick.count(DiffClass::SubEpsilonFloat),
192 ));
193 let limit = if options.show_all { n } else { DEFAULT_LIMIT };
194 for difference in tick.differences.iter().take(limit) {
195 s.push_str(&format!(
196 " {} {}: {}\n",
197 palette.class(difference.class),
198 difference.path,
199 difference.detail
200 ));
201 }
202 if n > limit {
203 s.push_str(&palette.dim(&format!(
204 " ... {} more, pass --all to see every difference\n",
205 n - limit
206 )));
207 }
208 s.push('\n');
209 }
210
211 if !report.only_in_a.is_empty() {
212 s.push_str(&format!(
213 " only in first dumps at ticks {}, no counterpart to diff\n",
214 tick_list(&report.only_in_a)
215 ));
216 }
217 if !report.only_in_b.is_empty() {
218 s.push_str(&format!(
219 " only in second dumps at ticks {}, no counterpart to diff\n",
220 tick_list(&report.only_in_b)
221 ));
222 }
223
224 let differs = !report.is_identical();
225 s.push_str(&format!(
226 " verdict {}\n",
227 verdict_line(&report, total, &palette)
228 ));
229 s.push_str(&format!(" next {}\n", next_hint(&report)));
230
231 Ok(DiffOutput { text: s, differs })
232}
233
234fn verdict_line(report: &DiffReport, total: usize, palette: &Palette) -> String {
235 if report.is_identical() {
236 palette.good(&format!(
237 "identical at {}",
238 plural(report.ticks.len(), "compared tick", "compared ticks")
239 ))
240 } else {
241 format!(
242 "{} across {}",
243 plural(total, "difference", "differences"),
244 plural(report.ticks.len(), "compared tick", "compared ticks")
245 )
246 }
247}
248
249fn next_hint(report: &DiffReport) -> String {
250 if report.is_identical() {
251 return "the dumps agree. If tickwise compare reported a divergence at this tick, \
252 the diverging state is not covered by state_dump, extend it"
253 .to_string();
254 }
255 let structural: usize = report
256 .ticks
257 .iter()
258 .map(|t| t.count(DiffClass::Structural))
259 .sum();
260 let exact: usize = report.ticks.iter().map(|t| t.count(DiffClass::Exact)).sum();
261 if structural > 0 {
262 "structural differences usually mean a collection in unspecified order or a field \
263 missed by a snapshot. Start with the first structural entry above"
264 .to_string()
265 } else if exact > 0 {
266 "an exact difference at the first divergent tick is your lead. Trace that field's \
267 last write backwards through the tick"
268 .to_string()
269 } else {
270 "only sub-epsilon float drift. If you target cross-platform determinism, rerun with \
271 --strict to see every bit, and consider fixed-point math for these fields"
272 .to_string()
273 }
274}