1use {
11 super::{
12 cargo_deps::{discover_dep_src_roots, discover_path_dep_roots},
13 highlight::highlight_asm,
14 model::{DebugNode, DebugSession, DebugStep, DebugTx, ProgramDisasm, StaticInsn},
15 source::{discover_platform_tools_stdlib_roots, SourceResolver, CI_PLATFORM_TOOLS_PREFIX},
16 },
17 crate::flamegraph::trace::{
18 discover_invocations, find_unstripped_binary, load_function_map, stream_trace,
19 InvocationFiles, INSN_ENTRY_SIZE, KNOWN_SYSCALLS, REGS_ENTRY_SIZE,
20 },
21 anyhow::{anyhow, bail, Context, Result},
22 solana_compute_budget::compute_budget::ComputeBudget,
23 solana_sbpf::{ebpf, static_analysis::Analysis},
24 std::{
25 collections::BTreeMap,
26 fs,
27 path::{Path, PathBuf},
28 sync::Arc,
29 },
30};
31
32pub fn build_session(
38 profile_dir: &Path,
39 programs: &BTreeMap<String, PathBuf>,
40 manifest_dir: Option<&Path>,
41 crate_dir: Option<&Path>,
42 test_filter: Option<&str>,
43) -> Result<DebugSession> {
44 let mut txs: Vec<DebugTx> = Vec::new();
45 let mut src_roots: Vec<PathBuf> = Vec::new();
46 if let Some(c) = crate_dir {
52 src_roots.extend(discover_path_dep_roots(c));
53 }
54 if let Some(p) = manifest_dir {
55 src_roots.push(p.to_path_buf());
56 src_roots.extend(discover_dep_src_roots(p));
57 }
58
59 let path_rewrites: Vec<(PathBuf, PathBuf)> = discover_platform_tools_stdlib_roots()
64 .into_iter()
65 .map(|replacement| (PathBuf::from(CI_PLATFORM_TOOLS_PREFIX), replacement))
66 .collect();
67
68 let cwd = crate_dir.map(|p| p.to_path_buf());
69
70 if !profile_dir.exists() {
71 return Ok(DebugSession {
72 txs,
73 src_roots,
74 path_rewrites,
75 cwd,
76 programs: BTreeMap::new(),
77 });
78 }
79
80 let mut program_ctx: BTreeMap<String, ProgramCtx> = BTreeMap::new();
84
85 for entry in fs::read_dir(profile_dir)
86 .with_context(|| format!("read profile dir {}", profile_dir.display()))?
87 {
88 let entry = entry?;
89 let dir = entry.path();
90 if !dir.is_dir() {
91 continue;
92 }
93 let Some(test_name) = dir.file_name().and_then(|s| s.to_str()).map(str::to_owned) else {
94 continue;
95 };
96 if let Some(filter) = test_filter {
97 if !test_name.contains(filter) {
98 continue;
99 }
100 }
101
102 let invocations = discover_invocations(&dir)
103 .with_context(|| format!("discover invocations in {}", dir.display()))?;
104 if invocations.is_empty() {
105 continue;
106 }
107
108 let mut tx_groups: BTreeMap<u32, Vec<&InvocationFiles>> = BTreeMap::new();
109 for inv in &invocations {
110 tx_groups.entry(inv.tx_seq).or_default().push(inv);
111 }
112
113 for (tx_seq, mut invs) in tx_groups {
114 invs.reverse();
121
122 let mut nodes: Vec<DebugNode> = Vec::with_capacity(invs.len());
123 let mut tx_total_cu: u64 = 0;
124
125 for inv in invs {
126 let ctx = match load_program_ctx(
127 &inv.program_id,
128 programs,
129 manifest_dir,
130 &mut program_ctx,
131 ) {
132 Some(c) => c,
133 None => continue,
134 };
135
136 let regular_regs = fs::read(&inv.regs_path)
137 .with_context(|| format!("read {}", inv.regs_path.display()))?;
138 let regular_insns = fs::read(&inv.insns_path)
139 .with_context(|| format!("read {}", inv.insns_path.display()))?;
140 let regular_count = (regular_regs.len() / REGS_ENTRY_SIZE)
141 .min(regular_insns.len() / INSN_ENTRY_SIZE);
142
143 let (regs, insns, exact_cu) =
144 match load_gdb_sidecars(inv, ®ular_regs, ®ular_insns, regular_count)? {
145 Some(sidecar) => (sidecar.regs, sidecar.insns, Some(sidecar.cu_costs)),
146 None => (regular_regs, regular_insns, None),
147 };
148
149 let count = (regs.len() / REGS_ENTRY_SIZE).min(insns.len() / INSN_ENTRY_SIZE);
150 if count == 0 {
151 continue;
152 }
153
154 let program_label = program_label(&inv.program_id, programs);
155 let mut steps: Vec<DebugStep> = Vec::with_capacity(count);
156 let mut node_cu: u64 = 0;
157
158 let budget = ComputeBudget::new_with_defaults(false, false);
159 let mut step_idx = 0usize;
160
161 stream_trace(
162 ®s,
163 &insns,
164 count,
165 &ctx.symbols,
166 &ctx.syscalls,
167 &program_label,
168 &budget,
169 |s| {
170 let cu_cost = exact_cu
171 .as_ref()
172 .and_then(|costs| costs.get(step_idx).copied())
173 .unwrap_or(s.cu_cost);
174 step_idx += 1;
175 node_cu += cu_cost;
176 let disasm = disassemble(&ctx.analysis, &s.insn, s.pc as usize);
177 let disasm_spans = highlight_asm(&disasm).spans;
181 let src_loc = ctx.source.resolve(s.pc);
182 steps.push(DebugStep {
183 pc: s.pc,
184 regs: s.regs,
185 insn: s.insn,
186 disasm,
187 disasm_spans,
188 func: s.func.to_owned(),
189 call_depth: s.call_stack.len().saturating_sub(1).max(1),
192 cu_cost,
193 cu_cumulative: node_cu,
194 syscall: s.syscall,
195 src_loc,
196 });
197 },
198 );
199
200 tx_total_cu += node_cu;
201 nodes.push(DebugNode {
202 program_label,
203 program_id: inv.program_id.clone(),
204 steps,
205 });
206 }
207
208 if nodes.iter().all(|n| n.steps.is_empty()) {
209 continue;
210 }
211
212 txs.push(DebugTx {
213 test_name: test_name.clone(),
214 tx_seq,
215 total_cu: tx_total_cu,
216 nodes,
217 });
218 }
219 }
220
221 txs.sort_by(|a, b| {
224 a.test_name
225 .cmp(&b.test_name)
226 .then_with(|| a.tx_seq.cmp(&b.tx_seq))
227 });
228
229 let mut programs_disasm: BTreeMap<String, ProgramDisasm> = BTreeMap::new();
234 for (pid, ctx) in &program_ctx {
235 let mut d = build_static_disasm(ctx);
236 d.has_dwarf = !ctx.source.is_empty();
237 programs_disasm.insert(pid.clone(), d);
238 }
239
240 Ok(DebugSession {
241 txs,
242 src_roots,
243 path_rewrites,
244 cwd,
245 programs: programs_disasm,
246 })
247}
248
249struct GdbSidecars {
250 regs: Vec<u8>,
251 insns: Vec<u8>,
252 cu_costs: Vec<u64>,
253}
254
255fn load_gdb_sidecars(
256 inv: &InvocationFiles,
257 regular_regs: &[u8],
258 regular_insns: &[u8],
259 regular_count: usize,
260) -> Result<Option<GdbSidecars>> {
261 let regs_path = inv.regs_path.with_extension("gdb.regs");
262 let insns_path = inv.regs_path.with_extension("gdb.insns");
263 let cu_path = inv.regs_path.with_extension("gdb.cu");
264
265 if !regs_path.exists() && !insns_path.exists() && !cu_path.exists() {
266 return Ok(None);
267 }
268 if !regs_path.exists() || !insns_path.exists() || !cu_path.exists() {
269 bail!(
270 "incomplete gdb sidecar set for {}; expected {}, {}, and {}",
271 inv.regs_path.display(),
272 regs_path.display(),
273 insns_path.display(),
274 cu_path.display(),
275 );
276 }
277
278 let mut regs = fs::read(®s_path).with_context(|| format!("read {}", regs_path.display()))?;
279 let insns = fs::read(&insns_path).with_context(|| format!("read {}", insns_path.display()))?;
280 let cu = fs::read(&cu_path).with_context(|| format!("read {}", cu_path.display()))?;
281 let count = (regs.len() / REGS_ENTRY_SIZE).min(insns.len() / INSN_ENTRY_SIZE);
282 if count != regular_count {
283 bail!(
284 "gdb sidecar count mismatch for {}: gdb has {count} steps, regular trace has \
285 {regular_count}",
286 inv.regs_path.display(),
287 );
288 }
289 validate_and_normalize_gdb_regs(&mut regs, regular_regs, count)
290 .with_context(|| format!("validate {}", regs_path.display()))?;
291 if insns != regular_insns {
292 bail!(
293 "gdb sidecar instruction bytes differ from regular trace for {}",
294 inv.regs_path.display(),
295 );
296 }
297 if cu.len() / std::mem::size_of::<u64>() != count {
298 bail!(
299 "gdb sidecar CU count mismatch for {}: gdb has {} CU entries, trace has {count} steps",
300 inv.regs_path.display(),
301 cu.len() / std::mem::size_of::<u64>(),
302 );
303 }
304
305 Ok(Some(GdbSidecars {
306 regs,
307 insns,
308 cu_costs: cu_costs_from_remaining(&cu, count),
309 }))
310}
311
312fn validate_and_normalize_gdb_regs(
313 gdb_regs: &mut [u8],
314 regular_regs: &[u8],
315 count: usize,
316) -> Result<()> {
317 if gdb_regs.len() < count * REGS_ENTRY_SIZE || regular_regs.len() < count * REGS_ENTRY_SIZE {
318 bail!("register trace is shorter than declared step count");
319 }
320
321 let mut text_addr = None::<u64>;
322 for i in 0..count {
323 let offset = i * REGS_ENTRY_SIZE;
324 for reg in 0..12 {
325 let start = offset + reg * 8;
326 let gdb = u64::from_le_bytes(gdb_regs[start..start + 8].try_into().unwrap());
327 let regular = u64::from_le_bytes(regular_regs[start..start + 8].try_into().unwrap());
328
329 if reg == 11 {
330 let expected_text_addr = gdb
331 .checked_sub(
332 regular
333 .checked_mul(INSN_ENTRY_SIZE as u64)
334 .ok_or_else(|| anyhow!("trace PC overflow at step {i}"))?,
335 )
336 .ok_or_else(|| anyhow!("gdb PC precedes trace PC at step {i}"))?;
337 match text_addr {
338 Some(addr) if addr != expected_text_addr => bail!(
339 "gdb PC/text address mismatch at step {i}: expected text addr {addr:#x}, \
340 got {expected_text_addr:#x}",
341 ),
342 None => text_addr = Some(expected_text_addr),
343 _ => {}
344 }
345 gdb_regs[start..start + 8].copy_from_slice(®ular.to_le_bytes());
346 } else if gdb != regular {
347 bail!("gdb register r{reg} mismatch at step {i}: gdb={gdb:#x}, trace={regular:#x}",);
348 }
349 }
350 }
351 Ok(())
352}
353
354fn cu_costs_from_remaining(data: &[u8], count: usize) -> Vec<u64> {
355 let mut costs = Vec::with_capacity(count);
356 let mut prev = None::<u64>;
357 for i in 0..count {
358 let offset = i * std::mem::size_of::<u64>();
359 let Some(bytes) = data.get(offset..offset + 8) else {
360 costs.push(0);
361 continue;
362 };
363 let remaining = u64::from_le_bytes(bytes.try_into().unwrap());
364 costs.push(prev.map(|p| p.saturating_sub(remaining)).unwrap_or(0));
365 prev = Some(remaining);
366 }
367 costs
368}
369
370fn build_static_disasm(ctx: &ProgramCtx) -> ProgramDisasm {
377 let (_, text) = ctx.executable.get_text_bytes();
378 let n = text.len() / solana_sbpf::ebpf::INSN_SIZE;
380 let mut insns: Vec<StaticInsn> = Vec::with_capacity(n);
381 let mut pc_to_idx: BTreeMap<u64, usize> = BTreeMap::new();
382
383 let mut pc = 0;
384 while pc < n {
385 let mut raw = ebpf::get_insn_unchecked(text, pc);
386 let mut step = 1;
393 if raw.opc == ebpf::LD_DW_IMM && pc + 1 < n {
394 ebpf::augment_lddw_unchecked(text, &mut raw);
395 step = 2;
396 }
397 let disasm = ctx.analysis.disassemble_instruction(&raw, pc);
398 let disasm_spans = highlight_asm(&disasm).spans;
399 let func_label = ctx.symbols.get(&(pc as u64)).cloned();
400 pc_to_idx.insert(pc as u64, insns.len());
401 if step == 2 {
405 pc_to_idx.insert((pc + 1) as u64, insns.len());
406 }
407 insns.push(StaticInsn {
408 pc: pc as u64,
409 disasm_spans,
410 func_label,
411 });
412 pc += step;
413 }
414
415 ProgramDisasm {
420 insns,
421 pc_to_idx,
422 has_dwarf: false,
423 }
424}
425
426struct ProgramCtx {
427 symbols: BTreeMap<u64, String>,
428 syscalls: BTreeMap<u32, String>,
429 analysis: Analysis<'static>,
434 executable: &'static solana_sbpf::elf::Executable<NoopCtx>,
437 source: SourceResolver,
438}
439
440#[derive(Default)]
441struct NoopCtx;
442impl solana_sbpf::vm::ContextObject for NoopCtx {
443 fn consume(&mut self, _amount: u64) {}
444 fn get_remaining(&self) -> u64 {
445 0
446 }
447}
448
449fn syscall_stub(
453 _vm: *mut solana_sbpf::vm::EbpfVm<NoopCtx>,
454 _r1: u64,
455 _r2: u64,
456 _r3: u64,
457 _r4: u64,
458 _r5: u64,
459) {
460}
461
462fn load_program_ctx<'a>(
463 program_id: &str,
464 programs: &BTreeMap<String, PathBuf>,
465 manifest_dir: Option<&Path>,
466 cache: &'a mut BTreeMap<String, ProgramCtx>,
467) -> Option<&'a ProgramCtx> {
468 if !cache.contains_key(program_id) {
469 let ctx = build_program_ctx(program_id, programs, manifest_dir)?;
470 cache.insert(program_id.to_owned(), ctx);
471 }
472 cache.get(program_id)
473}
474
475fn build_program_ctx(
476 program_id: &str,
477 programs: &BTreeMap<String, PathBuf>,
478 manifest_dir: Option<&Path>,
479) -> Option<ProgramCtx> {
480 let elf_path = programs.get(program_id)?;
481 let elf_bytes = fs::read(elf_path).ok()?;
482 let (symbols, syscalls) = match load_function_map(elf_path, manifest_dir) {
483 Ok(x) => x,
484 Err(e) => {
485 eprintln!(
491 "warning: can't parse {} ({e}). Run `cargo build-sbf -p <crate>` from the \
492 workspace root to produce a debugger-compatible `target/deploy/<name>.so`.",
493 elf_path.display()
494 );
495 return None;
496 }
497 };
498
499 let mut loader_inner =
506 solana_sbpf::program::BuiltinProgram::new_loader(solana_sbpf::vm::Config {
507 enable_symbol_and_section_labels: true,
508 ..solana_sbpf::vm::Config::default()
509 });
510 for name in KNOWN_SYSCALLS {
511 let _ = loader_inner.register_function(name, syscall_stub);
516 }
517 let loader = Arc::new(loader_inner);
518 let executable = solana_sbpf::elf::Executable::<NoopCtx>::from_elf(&elf_bytes, loader).ok()?;
519 let exec_ref: &'static solana_sbpf::elf::Executable<NoopCtx> = Box::leak(Box::new(executable));
523 let analysis = Analysis::from_executable(exec_ref).ok()?;
524
525 let dwarf_path =
529 find_unstripped_binary(elf_path, manifest_dir).unwrap_or_else(|| elf_path.to_path_buf());
530 let source = SourceResolver::from_elf_path(&dwarf_path);
531
532 Some(ProgramCtx {
533 symbols,
534 syscalls,
535 analysis,
536 executable: exec_ref,
537 source,
538 })
539}
540
541fn disassemble(analysis: &Analysis<'_>, insn_bytes: &[u8; 8], pc: usize) -> String {
542 let insn = ebpf::Insn {
543 ptr: pc,
544 opc: insn_bytes[0],
545 dst: insn_bytes[1] & 0x0f,
546 src: (insn_bytes[1] & 0xf0) >> 4,
547 off: i16::from_le_bytes([insn_bytes[2], insn_bytes[3]]),
548 imm: i32::from_le_bytes([insn_bytes[4], insn_bytes[5], insn_bytes[6], insn_bytes[7]])
549 as i64,
550 };
551 analysis.disassemble_instruction(&insn, pc)
552}
553
554fn program_label(program_id: &str, programs: &BTreeMap<String, PathBuf>) -> String {
555 let short = short_pid(program_id);
556 match programs.get(program_id) {
557 Some(elf) => elf
558 .file_stem()
559 .and_then(|s| s.to_str())
560 .map(|n| format!("{n} ({short})"))
561 .unwrap_or_else(|| format!("program {program_id}")),
562 None => format!("[unresolved {short}]"),
563 }
564}
565
566fn short_pid(pid: &str) -> String {
567 if pid.len() <= 13 {
568 pid.to_owned()
569 } else {
570 format!("{}…{}", &pid[..8], &pid[pid.len() - 4..])
571 }
572}
573
574#[cfg(test)]
575mod tests {
576 use {super::*, tempfile::tempdir};
577
578 fn invocation(dir: &Path) -> InvocationFiles {
579 InvocationFiles {
580 inv_seq: 1,
581 tx_seq: 1,
582 program_id: "Program1111111111111111111".to_string(),
583 regs_path: dir.join("0001__tx1.regs"),
584 insns_path: dir.join("0001__tx1.insns"),
585 }
586 }
587
588 fn regs_entries(pcs: &[u64]) -> Vec<[u64; 12]> {
589 pcs.iter()
590 .map(|pc| {
591 let mut regs = [0u64; 12];
592 regs[0] = 0x55;
593 regs[11] = *pc;
594 regs
595 })
596 .collect()
597 }
598
599 fn regs_bytes(entries: &[[u64; 12]]) -> Vec<u8> {
600 let mut out = Vec::with_capacity(entries.len() * REGS_ENTRY_SIZE);
601 for regs in entries {
602 for reg in regs {
603 out.extend_from_slice(®.to_le_bytes());
604 }
605 }
606 out
607 }
608
609 fn insns_bytes(count: usize, byte: u8) -> Vec<u8> {
610 vec![byte; count * INSN_ENTRY_SIZE]
611 }
612
613 fn cu_bytes(values: &[u64]) -> Vec<u8> {
614 values
615 .iter()
616 .flat_map(|value| value.to_le_bytes())
617 .collect()
618 }
619
620 fn gdb_entries_for_regular(entries: &[[u64; 12]], text_addr: u64) -> Vec<[u64; 12]> {
621 entries
622 .iter()
623 .map(|regular| {
624 let mut gdb = *regular;
625 gdb[11] = text_addr + regular[11] * INSN_ENTRY_SIZE as u64;
626 gdb
627 })
628 .collect()
629 }
630
631 fn write_sidecars(
632 inv: &InvocationFiles,
633 gdb_regs: &[[u64; 12]],
634 gdb_insns: &[u8],
635 cu_remaining: &[u64],
636 ) {
637 fs::write(
638 inv.regs_path.with_extension("gdb.regs"),
639 regs_bytes(gdb_regs),
640 )
641 .unwrap();
642 fs::write(inv.regs_path.with_extension("gdb.insns"), gdb_insns).unwrap();
643 fs::write(
644 inv.regs_path.with_extension("gdb.cu"),
645 cu_bytes(cu_remaining),
646 )
647 .unwrap();
648 }
649
650 fn sidecar_err(
651 inv: &InvocationFiles,
652 regular_regs: &[u8],
653 regular_insns: &[u8],
654 regular_count: usize,
655 ) -> String {
656 match load_gdb_sidecars(inv, regular_regs, regular_insns, regular_count) {
657 Ok(_) => panic!("expected gdb sidecar validation error"),
658 Err(err) => format!("{err:#}"),
659 }
660 }
661
662 #[test]
663 fn gdb_sidecars_normalize_virtual_pc_and_override_cu_costs() {
664 let dir = tempdir().unwrap();
665 let inv = invocation(dir.path());
666 let regular_entries = regs_entries(&[0, 1, 2]);
667 let regular_regs = regs_bytes(®ular_entries);
668 let regular_insns = insns_bytes(3, 0xab);
669 let gdb_entries = gdb_entries_for_regular(®ular_entries, 0x1000);
670 write_sidecars(&inv, &gdb_entries, ®ular_insns, &[100, 93, 90]);
671
672 let sidecar = load_gdb_sidecars(&inv, ®ular_regs, ®ular_insns, 3)
673 .unwrap()
674 .unwrap();
675
676 assert_eq!(sidecar.regs, regular_regs);
677 assert_eq!(sidecar.insns, regular_insns);
678 assert_eq!(sidecar.cu_costs, vec![0, 7, 3]);
679 }
680
681 #[test]
682 fn gdb_sidecars_reject_incomplete_sets() {
683 let dir = tempdir().unwrap();
684 let inv = invocation(dir.path());
685 let regular_entries = regs_entries(&[0]);
686 let regular_regs = regs_bytes(®ular_entries);
687 let regular_insns = insns_bytes(1, 0xab);
688 fs::write(
689 inv.regs_path.with_extension("gdb.regs"),
690 regs_bytes(&gdb_entries_for_regular(®ular_entries, 0x1000)),
691 )
692 .unwrap();
693
694 let err = sidecar_err(&inv, ®ular_regs, ®ular_insns, 1);
695
696 assert!(err.contains("incomplete gdb sidecar set"));
697 }
698
699 #[test]
700 fn gdb_sidecars_reject_count_mismatch() {
701 let dir = tempdir().unwrap();
702 let inv = invocation(dir.path());
703 let regular_entries = regs_entries(&[0, 1]);
704 let regular_regs = regs_bytes(®ular_entries);
705 let regular_insns = insns_bytes(2, 0xab);
706 let gdb_entries = gdb_entries_for_regular(®ular_entries[..1], 0x1000);
707 write_sidecars(
708 &inv,
709 &gdb_entries,
710 ®ular_insns[..INSN_ENTRY_SIZE],
711 &[100],
712 );
713
714 let err = sidecar_err(&inv, ®ular_regs, ®ular_insns, 2);
715
716 assert!(err.contains("gdb sidecar count mismatch"));
717 }
718
719 #[test]
720 fn gdb_sidecars_reject_instruction_mismatch() {
721 let dir = tempdir().unwrap();
722 let inv = invocation(dir.path());
723 let regular_entries = regs_entries(&[0, 1]);
724 let regular_regs = regs_bytes(®ular_entries);
725 let regular_insns = insns_bytes(2, 0xab);
726 let gdb_entries = gdb_entries_for_regular(®ular_entries, 0x1000);
727 write_sidecars(&inv, &gdb_entries, &insns_bytes(2, 0xcd), &[100, 99]);
728
729 let err = sidecar_err(&inv, ®ular_regs, ®ular_insns, 2);
730
731 assert!(err.contains("gdb sidecar instruction bytes differ"));
732 }
733
734 #[test]
735 fn gdb_sidecars_reject_register_mismatch() {
736 let dir = tempdir().unwrap();
737 let inv = invocation(dir.path());
738 let regular_entries = regs_entries(&[0]);
739 let regular_regs = regs_bytes(®ular_entries);
740 let regular_insns = insns_bytes(1, 0xab);
741 let mut gdb_entries = gdb_entries_for_regular(®ular_entries, 0x1000);
742 gdb_entries[0][0] = 0x66;
743 write_sidecars(&inv, &gdb_entries, ®ular_insns, &[100]);
744
745 let err = sidecar_err(&inv, ®ular_regs, ®ular_insns, 1);
746
747 assert!(err.contains("gdb register r0 mismatch"));
748 }
749
750 #[test]
751 fn gdb_sidecars_reject_inconsistent_text_address() {
752 let dir = tempdir().unwrap();
753 let inv = invocation(dir.path());
754 let regular_entries = regs_entries(&[0, 1]);
755 let regular_regs = regs_bytes(®ular_entries);
756 let regular_insns = insns_bytes(2, 0xab);
757 let mut gdb_entries = gdb_entries_for_regular(®ular_entries, 0x1000);
758 gdb_entries[1][11] = 0x2000 + INSN_ENTRY_SIZE as u64;
759 write_sidecars(&inv, &gdb_entries, ®ular_insns, &[100, 99]);
760
761 let err = sidecar_err(&inv, ®ular_regs, ®ular_insns, 2);
762
763 assert!(err.contains("gdb PC/text address mismatch"));
764 }
765}