1use std::fmt::Write as _;
22use std::path::PathBuf;
23
24use clap::{Args, Parser, Subcommand};
25use serde_json::Value;
26use spdr::{
27 CasLatencies, CrcStatus, DecodeError, Expo, ExpoProfile, Finding, IdentityAndBase,
28 Manufacturing, ModuleSpecific, Picoseconds, RatedTimings, Severity, Timings, VendorProfiles,
29 Xmp, XmpProfile, decode_identity_and_base, decode_manufacturing, decode_module_specific,
30 decode_timings, decode_vendor_profiles, lint, verify_base_crc,
31};
32
33#[derive(Parser)]
35#[command(name = "spdr", version, about = "Read-only DDR5 SPD content decoder")]
36pub struct Cli {
37 #[command(subcommand)]
38 pub command: Commands,
39}
40
41#[derive(Subcommand)]
45pub enum Commands {
46 Decode(DecodeArgs),
48 Lint(LintArgs),
50}
51
52#[derive(Args)]
54pub struct DecodeArgs {
55 pub file: PathBuf,
57 #[arg(long)]
59 pub json: bool,
60}
61
62#[derive(Args)]
65pub struct LintArgs {
66 pub file: PathBuf,
68 #[arg(long)]
70 pub json: bool,
71}
72
73pub struct DecodeResults<'a> {
76 pub identity: Result<IdentityAndBase, DecodeError>,
78 pub crc: Result<CrcStatus, DecodeError>,
80 pub timings: Result<Timings, DecodeError>,
82 pub module: Result<ModuleSpecific, DecodeError>,
84 pub manufacturing: Result<Manufacturing<'a>, DecodeError>,
86 pub vendor: Result<VendorProfiles<'a>, DecodeError>,
89}
90
91impl DecodeResults<'_> {
92 #[must_use]
96 pub fn all_decoded(&self) -> bool {
97 self.identity.is_ok()
98 && self.crc.is_ok()
99 && self.timings.is_ok()
100 && self.module.is_ok()
101 && self.manufacturing.is_ok()
102 && self.vendor.is_ok()
103 }
104}
105
106#[must_use]
109pub fn decode(bytes: &[u8]) -> DecodeResults<'_> {
110 DecodeResults {
111 identity: decode_identity_and_base(bytes),
112 crc: verify_base_crc(bytes),
113 timings: decode_timings(bytes),
114 module: decode_module_specific(bytes),
115 manufacturing: decode_manufacturing(bytes),
116 vendor: decode_vendor_profiles(bytes),
117 }
118}
119
120#[must_use]
124pub fn run() -> i32 {
125 let cli = Cli::parse();
126 match cli.command {
127 Commands::Decode(args) => run_decode(&args),
128 Commands::Lint(args) => run_lint(&args),
129 }
130}
131
132fn run_decode(args: &DecodeArgs) -> i32 {
139 let bytes = match std::fs::read(&args.file) {
140 Ok(bytes) => bytes,
141 Err(error) => {
142 eprintln!("spdr: cannot read {}: {error}", args.file.display());
143 return 2;
144 }
145 };
146
147 let results = decode(&bytes);
148
149 let rendered = if args.json {
150 match render_json(&results) {
151 Ok(json) => json,
152 Err(error) => {
153 eprintln!("spdr: failed to render JSON: {error}");
154 return 2;
155 }
156 }
157 } else {
158 render_human(&results)
159 };
160 println!("{rendered}");
161
162 if results.all_decoded() { 0 } else { 1 }
163}
164
165pub struct LintReport {
172 pub findings: Vec<Finding>,
174 pub base_decode_ok: bool,
179}
180
181#[must_use]
185pub fn lint_report(bytes: &[u8]) -> LintReport {
186 let mut findings = Vec::new();
187 lint(bytes, &mut |finding| findings.push(finding));
188 findings.sort_by(|a, b| {
189 severity_rank(a.severity())
190 .cmp(&severity_rank(b.severity()))
191 .then_with(|| a.code().cmp(b.code()))
192 });
193 LintReport {
194 base_decode_ok: decode_identity_and_base(bytes).is_ok(),
195 findings,
196 }
197}
198
199#[must_use]
204pub fn lint_exit_code(findings: &[Finding]) -> i32 {
205 let actionable = findings
206 .iter()
207 .any(|finding| matches!(finding.severity(), Severity::Error | Severity::Warning));
208 i32::from(actionable)
209}
210
211fn run_lint(args: &LintArgs) -> i32 {
217 let bytes = match std::fs::read(&args.file) {
218 Ok(bytes) => bytes,
219 Err(error) => {
220 eprintln!("spdr: cannot read {}: {error}", args.file.display());
221 return 2;
222 }
223 };
224
225 let report = lint_report(&bytes);
226
227 let rendered = if args.json {
228 match render_lint_json(&report) {
229 Ok(json) => json,
230 Err(error) => {
231 eprintln!("spdr: failed to render JSON: {error}");
232 return 2;
233 }
234 }
235 } else {
236 render_lint_human(&report)
237 };
238 println!("{rendered}");
239
240 lint_exit_code(&report.findings)
241}
242
243fn severity_rank(severity: Severity) -> u8 {
245 match severity {
246 Severity::Error => 0,
247 Severity::Warning => 1,
248 Severity::Info => 2,
249 }
250}
251
252fn severity_label(severity: Severity) -> &'static str {
254 match severity {
255 Severity::Error => "error",
256 Severity::Warning => "warning",
257 Severity::Info => "info",
258 }
259}
260
261#[must_use]
265pub fn render_lint_human(report: &LintReport) -> String {
266 let mut out = String::new();
267 out.push_str("[Lint]\n");
268
269 if !report.base_decode_ok {
270 out.push_str(
271 " Note: the base configuration did not decode, so the checks that depend on it (capacity and cross-field consistency) were skipped, while the reserved-bit check still ran; a clean result here is not a full bill of health.\n",
272 );
273 }
274
275 if report.findings.is_empty() {
276 out.push_str(
277 " No findings. The SPD is internally consistent under the current rule set.\n",
278 );
279 return out;
280 }
281
282 let _ = writeln!(out, " {}", lint_summary(&report.findings));
283 for finding in &report.findings {
284 let _ = writeln!(
285 out,
286 " {} · {}",
287 severity_label(finding.severity()),
288 finding.code()
289 );
290 let _ = writeln!(out, " {finding}");
291 }
292 out
293}
294
295fn lint_summary(findings: &[Finding]) -> String {
297 let (errors, warnings, infos) = severity_counts(findings);
298 let total = findings.len();
299 let noun = if total == 1 { "finding" } else { "findings" };
300
301 let mut parts = Vec::new();
302 if errors > 0 {
303 parts.push(count_phrase(errors, "error"));
304 }
305 if warnings > 0 {
306 parts.push(count_phrase(warnings, "warning"));
307 }
308 if infos > 0 {
309 parts.push(count_phrase(infos, "info"));
310 }
311 format!("{total} {noun}: {}.", parts.join(", "))
312}
313
314fn severity_counts(findings: &[Finding]) -> (usize, usize, usize) {
316 let mut errors = 0;
317 let mut warnings = 0;
318 let mut infos = 0;
319 for finding in findings {
320 match finding.severity() {
321 Severity::Error => errors += 1,
322 Severity::Warning => warnings += 1,
323 Severity::Info => infos += 1,
324 }
325 }
326 (errors, warnings, infos)
327}
328
329fn count_phrase(n: usize, label: &str) -> String {
331 if n == 1 || label == "info" {
332 format!("{n} {label}")
333 } else {
334 format!("{n} {label}s")
335 }
336}
337
338pub fn render_lint_json(report: &LintReport) -> Result<String, serde_json::Error> {
348 let findings = report
349 .findings
350 .iter()
351 .map(finding_to_json)
352 .collect::<Result<Vec<_>, _>>()?;
353 serde_json::to_string_pretty(&Value::Array(findings))
354}
355
356fn finding_to_json(finding: &Finding) -> Result<Value, serde_json::Error> {
358 let fields = match serde_json::to_value(finding)? {
361 Value::Object(map) if map.len() == 1 => map
362 .into_iter()
363 .next()
364 .map_or(Value::Null, |(_, value)| value),
365 other => other,
366 };
367 Ok(serde_json::json!({
368 "severity": severity_label(finding.severity()),
369 "code": finding.code(),
370 "message": finding.to_string(),
371 "fields": fields,
372 }))
373}
374
375const LABEL_WIDTH: usize = 30;
379
380#[must_use]
384pub fn render_human(results: &DecodeResults) -> String {
385 let mut out = String::new();
386
387 render_identity(&mut out, &results.identity);
388 out.push('\n');
389 render_crc(&mut out, &results.crc);
390 out.push('\n');
391 render_timings(&mut out, &results.timings);
392 out.push('\n');
393 render_module(&mut out, &results.module);
394 out.push('\n');
395 render_manufacturing(&mut out, &results.manufacturing);
396 out.push('\n');
397 render_vendor(&mut out, &results.vendor);
398
399 out
400}
401
402fn field(out: &mut String, label: &str, value: impl std::fmt::Display) {
405 let _ = writeln!(out, " {label:<LABEL_WIDTH$} {value}");
406}
407
408fn section_error(out: &mut String, error: &DecodeError) {
410 let _ = writeln!(out, " error: {error}");
411}
412
413fn render_identity(out: &mut String, result: &Result<IdentityAndBase, DecodeError>) {
414 out.push_str("[Identity and base]\n");
415 match result {
416 Ok(id) => {
417 field(
418 out,
419 "SPD device size:",
420 format_args!("{} bytes", id.spd_bytes_total),
421 );
422 field(out, "SPD revision:", id.spd_revision);
423 field(out, "DRAM device type:", id.device_type);
424 field(out, "Module type:", id.module_type);
425 field(out, "Hybrid module:", yes_no(id.hybrid));
426 field(
427 out,
428 "Density per die:",
429 format_args!("{} Gb", id.density_per_die.gigabits()),
430 );
431 field(out, "Package:", id.package_type);
432 field(out, "Dies per package:", id.die_count);
433 field(out, "Row address bits:", id.row_address_bits);
434 field(out, "Column address bits:", id.column_address_bits);
435 field(out, "I/O width:", format_args!("x{}", id.io_width.bits()));
436 field(out, "Bank groups:", id.bank_groups.count());
437 field(
438 out,
439 "Banks per bank group:",
440 id.banks_per_bank_group.count(),
441 );
442 field(
443 out,
444 "Package ranks per channel:",
445 id.package_ranks_per_channel,
446 );
447 field(
448 out,
449 "Rank mix:",
450 if id.rank_mix_asymmetric {
451 "asymmetric"
452 } else {
453 "symmetric"
454 },
455 );
456 field(out, "Channels per DIMM:", id.channels_per_dimm);
457 field(
458 out,
459 "Primary bus width per channel:",
460 format_args!("{} bits", id.primary_bus_width_bits),
461 );
462 }
463 Err(error) => section_error(out, error),
464 }
465}
466
467fn render_crc(out: &mut String, result: &Result<CrcStatus, DecodeError>) {
468 out.push_str("[Base configuration CRC]\n");
469 out.push_str(" Reported status of the base CRC (bytes 0-509). Not a verdict; the vendor section CRCs are separate.\n");
470 match result {
471 Ok(crc) => {
472 field(out, "Computed:", format_args!("{:#06X}", crc.computed));
473 field(out, "Stored:", format_args!("{:#06X}", crc.stored));
474 field(out, "Match:", yes_no(crc.matches));
475 }
476 Err(error) => section_error(out, error),
477 }
478}
479
480fn render_timings(out: &mut String, result: &Result<Timings, DecodeError>) {
481 out.push_str("[JEDEC base timings]\n");
482 out.push_str(
483 " SPD JEDEC base timings (the guaranteed fallback). The rated DDR5 speed is shown below in the vendor-profiles section.\n",
484 );
485 match result {
486 Ok(t) => {
487 let rate = t.base_data_rate_mt_s();
488 field(
489 out,
490 "Base data rate:",
491 format_args!("DDR5-{rate} ({rate} MT/s, JEDEC base)"),
492 );
493 field(
494 out,
495 "tCKAVGmin:",
496 format_args!("{} ps", t.tckavg_min.picoseconds()),
497 );
498 field(
499 out,
500 "tCKAVGmax:",
501 format_args!("{} ps", t.tckavg_max.picoseconds()),
502 );
503 field(
504 out,
505 "Supported CAS latencies:",
506 cas_list(t.supported_cas_latencies),
507 );
508 field(out, "tAA:", format_args!("{} ps", t.taa.picoseconds()));
509 field(out, "tRCD:", format_args!("{} ps", t.trcd.picoseconds()));
510 field(out, "tRP:", format_args!("{} ps", t.trp.picoseconds()));
511 field(out, "tRAS:", format_args!("{} ps", t.tras.picoseconds()));
512 field(out, "tRC:", format_args!("{} ps", t.trc.picoseconds()));
513 field(out, "tWR:", format_args!("{} ps", t.twr.picoseconds()));
514 field(out, "tRFC1:", format_args!("{} ps", t.trfc1.picoseconds()));
515 field(out, "tRFC2:", format_args!("{} ps", t.trfc2.picoseconds()));
516 field(
517 out,
518 "tRFCsb:",
519 format_args!("{} ps", t.trfcsb.picoseconds()),
520 );
521 field(out, "tRRD_L:", pair(t.trrd_l));
522 field(out, "tCCD_L:", pair(t.tccd_l));
523 field(out, "tCCD_L_WR:", pair(t.tccd_l_wr));
524 field(out, "tCCD_L_WR2:", pair(t.tccd_l_wr2));
525 field(out, "tFAW:", pair(t.tfaw));
526 field(out, "tWTR_L:", pair(t.twtr_l));
527 field(out, "tWTR_S:", pair(t.twtr_s));
528 field(out, "tRTP:", pair(t.trtp));
529 }
530 Err(error) => section_error(out, error),
531 }
532}
533
534fn render_module(out: &mut String, result: &Result<ModuleSpecific, DecodeError>) {
535 out.push_str("[Module-specific]\n");
536 match result {
537 Ok(ModuleSpecific::Unbuffered(m)) => {
538 field(out, "Module type:", "UDIMM (unbuffered)");
539 field(out, "Nominal height:", m.nominal_height);
540 field(out, "Max thickness, front:", m.max_thickness_front);
541 field(out, "Max thickness, back:", m.max_thickness_back);
542 field(out, "Reference raw card:", m.reference_raw_card);
543 field(
544 out,
545 "Rank 1 address mapping:",
546 if m.rank1_address_mirrored {
547 "mirrored"
548 } else {
549 "standard"
550 },
551 );
552 field(
553 out,
554 "Module attributes (raw):",
555 format_args!("{:#04X}", m.module_attributes_raw),
556 );
557 }
558 Ok(ModuleSpecific::NotYetDecoded(module_type)) => {
559 field(
560 out,
561 "Module type:",
562 format_args!("{module_type} (module-specific block not yet decoded)"),
563 );
564 }
565 Err(error) => section_error(out, error),
566 }
567}
568
569fn render_manufacturing(out: &mut String, result: &Result<Manufacturing, DecodeError>) {
570 out.push_str("[Manufacturing]\n");
571 match result {
572 Ok(m) => {
573 field(out, "Module manufacturer:", m.module_manufacturer);
574 field(out, "Manufacturing location:", m.manufacturing_location);
575 field(out, "Manufacturing date:", m.manufacturing_date);
576 field(out, "Serial number:", m.serial_number);
577 field(out, "Part number:", m.part_number);
578 field(out, "Revision code:", m.revision_code);
579 field(out, "DRAM manufacturer:", m.dram_manufacturer);
580 field(out, "DRAM stepping:", dram_stepping(m.dram_stepping));
581 }
582 Err(error) => section_error(out, error),
583 }
584}
585
586const VENDOR_LABEL_WIDTH: usize = 32;
592
593fn render_vendor(out: &mut String, result: &Result<VendorProfiles, DecodeError>) {
594 out.push_str("[Vendor profiles (XMP 3.0 / EXPO)]\n");
595 out.push_str(
596 " Rated overclock profiles. Each section is CRC-checked; the match is the region anchor.\n",
597 );
598 match result {
599 Ok(v) => {
600 render_xmp(out, &v.xmp);
601 render_expo(out, &v.expo);
602 }
603 Err(error) => section_error(out, error),
604 }
605}
606
607fn render_xmp(out: &mut String, xmp: &Xmp) {
608 match xmp {
609 Xmp::Absent => vline(out, 2, "Intel XMP 3.0:", "absent"),
610 Xmp::Present {
611 header_crc,
612 profile1,
613 profile2,
614 } => {
615 vline(out, 2, "Intel XMP 3.0:", "present");
616 vline(out, 4, "Header section CRC:", crc_summary(header_crc));
617 render_xmp_slot(out, 1, profile1);
618 render_xmp_slot(out, 2, profile2);
619 }
620 }
621}
622
623fn render_xmp_slot(out: &mut String, number: u8, slot: &Option<XmpProfile>) {
624 match slot {
625 Some(p) => {
626 let name = p.name.unwrap_or("(unnamed)");
627 vheading(out, 4, &format!("Profile {number}: {name}"));
628 render_rated(out, &p.rated);
629 vline(out, 6, "Section CRC:", crc_summary(&p.crc));
630 }
631 None => vheading(out, 4, &format!("Profile {number}: (not enabled)")),
632 }
633}
634
635fn render_expo(out: &mut String, expo: &Expo) {
636 match expo {
637 Expo::Absent => vline(out, 2, "AMD EXPO:", "absent"),
638 Expo::Present {
639 block_crc,
640 profile1,
641 profile2,
642 } => {
643 vline(out, 2, "AMD EXPO:", "present");
644 vline(out, 4, "Block section CRC:", crc_summary(block_crc));
645 render_expo_slot(out, 1, profile1);
646 render_expo_slot(out, 2, profile2);
647 }
648 }
649}
650
651fn render_expo_slot(out: &mut String, number: u8, slot: &Option<ExpoProfile>) {
652 match slot {
653 Some(p) => {
654 vheading(out, 4, &format!("Profile {number}:"));
655 render_rated(out, &p.rated);
656 }
657 None => vheading(out, 4, &format!("Profile {number}: (not populated)")),
658 }
659}
660
661fn render_rated(out: &mut String, r: &RatedTimings) {
663 vline(
664 out,
665 6,
666 "Data rate:",
667 format_args!("DDR5-{0} ({0} MT/s)", r.data_rate_mt_s),
668 );
669 vline(out, 6, "CAS latency:", format_args!("CL{}", r.cas_latency));
670 vline(
671 out,
672 6,
673 "tCKAVGmin:",
674 format_args!("{} ps", r.cycle_time.picoseconds()),
675 );
676 vline(out, 6, "tRCD:", timing_clocks(r.trcd, r.cycle_time));
677 vline(out, 6, "tRP:", timing_clocks(r.trp, r.cycle_time));
678 vline(out, 6, "tRAS:", timing_clocks(r.tras, r.cycle_time));
679 vline(
680 out,
681 6,
682 "VDD / VDDQ / VPP:",
683 format_args!("{} / {} / {}", r.vdd, r.vddq, r.vpp),
684 );
685}
686
687fn vline(out: &mut String, indent: usize, label: &str, value: impl std::fmt::Display) {
689 let pad = VENDOR_LABEL_WIDTH.saturating_sub(indent);
690 let _ = writeln!(out, "{:i$}{label:<pad$} {value}", "", i = indent);
691}
692
693fn vheading(out: &mut String, indent: usize, text: &str) {
695 let _ = writeln!(out, "{:i$}{text}", "", i = indent);
696}
697
698fn timing_clocks(t: Picoseconds, cycle_time: Picoseconds) -> String {
701 let ps = t.picoseconds();
702 let tck = cycle_time.picoseconds();
703 match (ps + tck / 2).checked_div(tck) {
704 Some(clocks) => format!("{ps} ps ({clocks} clocks)"),
705 None => format!("{ps} ps"),
706 }
707}
708
709fn crc_summary(crc: &CrcStatus) -> String {
711 format!(
712 "computed {:#06X}, stored {:#06X} ({})",
713 crc.computed,
714 crc.stored,
715 if crc.matches { "match" } else { "MISMATCH" }
716 )
717}
718
719fn yes_no(value: bool) -> &'static str {
720 if value { "yes" } else { "no" }
721}
722
723fn pair(value: spdr::TimingPair) -> String {
725 format!(
726 "{} ps / {} nCK",
727 value.time.picoseconds(),
728 value.clocks.cycles()
729 )
730}
731
732fn cas_list(value: CasLatencies) -> String {
734 let mut out = String::new();
735 for (i, cl) in value.iter().enumerate() {
736 if i > 0 {
737 out.push_str(", ");
738 }
739 let _ = write!(out, "{cl}");
740 }
741 if out.is_empty() {
742 out.push_str("(none)");
743 }
744 out
745}
746
747fn dram_stepping(value: u8) -> String {
749 if value == 0xFF {
750 "255 (not specified)".to_string()
751 } else {
752 format!("{value}")
753 }
754}
755
756macro_rules! section_value {
763 ($result:expr) => {
764 match $result {
765 Ok(value) => serde_json::to_value(value)?,
766 Err(error) => serde_json::json!({ "error": error.to_string() }),
767 }
768 };
769}
770
771pub fn render_json(results: &DecodeResults) -> Result<String, serde_json::Error> {
779 let mut object = serde_json::Map::new();
780 object.insert(
781 "identity_and_base".to_string(),
782 section_value!(&results.identity),
783 );
784 object.insert("base_crc".to_string(), section_value!(&results.crc));
785 object.insert(
786 "jedec_base_timings".to_string(),
787 section_value!(&results.timings),
788 );
789 object.insert(
790 "module_specific".to_string(),
791 section_value!(&results.module),
792 );
793 object.insert(
794 "manufacturing".to_string(),
795 section_value!(&results.manufacturing),
796 );
797 object.insert(
798 "vendor_profiles".to_string(),
799 section_value!(&results.vendor),
800 );
801 serde_json::to_string_pretty(&Value::Object(object))
802}