From cb93fd02c63351f81421d35117fff0b943fbbfb7 Mon Sep 17 00:00:00 2001 From: vringar Date: Thu, 7 May 2026 21:23:22 +0200 Subject: [PATCH] Detect zen_generation for single-generation EPYC BIOSes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EPYC server BIOSes use neither the 2PSP combo directory (AM4/AM5 combos) nor the tertiary self-tag (Zen 4 consumer), so neither of the two existing zen_generation detection paths fires for them. Every directory and entry comes back with zen_generation=None, silently — no warning is emitted, so downstream tools that bin or group by Zen generation just drop these rows. Add a third detection path that runs after FET parsing: scan the PSP_FW_BOOT_LOADER (entry type 0x01) for any ROM whose directories were uniformly left unclassified, read the version major byte (version[1] in the reverse-sliced HeaderFile.version), and look it up in a new BOOTLOADER_VERSION_TO_ZEN map (Naples through Turin). Apply the result to all directories in that ROM, including the $BHD/$BL2 BIOS directories that don't carry a boot loader of their own. The back-fill is deliberately conservative: - Only fires when *every* directory in the ROM is currently None. In a multi-generation combo BIOS, the boot loader belongs to one of several $PSP directories and cannot be safely associated with the sibling $BHD directories — so we leave classification to combo_dir / tertiary in that case. - Emits a warning when a boot loader exists but its version major is not in the table (replaces one silent-failure mode with a visible one). Verified against the issue's empirical corpus (Rome 4501, Milan 1002, Genoa 2305, Siena 0903, Turin V3.04 — the latter is a multi-ROM file shipping a Genoa fallback alongside Turin); each EPYC ROM goes from None across all directories to the expected Zen N. AM4 combo positive controls (X470D4U2-2T 3.40) keep their existing combo_dir output unchanged. Also adds a synthetic-ROM builder under tests/unit/ that produces 8 MB ROM-shaped binaries with controlled boot-loader version bytes, plus a unit test that exercises the back-fill across the full mapping table without needing the fixtures submodule. --- psptool/blob.py | 29 +++-- psptool/directory.py | 16 +++ psptool/psptool.py | 40 +++++++ tests/unit/synthetic_rom.py | 122 +++++++++++++++++++++ tests/unit/test_zen_generation_backfill.py | 79 +++++++++++++ 5 files changed, 274 insertions(+), 12 deletions(-) create mode 100644 tests/unit/synthetic_rom.py create mode 100644 tests/unit/test_zen_generation_backfill.py diff --git a/psptool/blob.py b/psptool/blob.py index 38a0205..64dd5a6 100644 --- a/psptool/blob.py +++ b/psptool/blob.py @@ -30,6 +30,22 @@ class Blob(NestedBuffer): _FIRMWARE_ENTRY_MAGIC = b'\xAA\x55\xAA\x55' # All structures per Rom must be in 16MB windows _MAX_PAGE_SIZE = 16 * 1024 * 1024 + + # ROM-relative offsets at which a FET may legitimately appear, in the + # try-order used by the parser (the loop breaks on first successful + # FET parse, so order is observable). As seen by a PSPTrace Zen 1 + # boot. The first entry is the canonical position for compact images + # and is what the synthetic-fixture builder targets via [0]. + POSSIBLE_FET_OFFSETS = ( + 0x020000, + 0xfa0000, + 0xf20000, + 0xe20000, + 0xc20000, + 0x820000, + 0x120000, + ) + class NoFirmwareEntryTableError(Exception): pass @@ -39,17 +55,6 @@ def __init__(self, buffer: bytearray, size: int, psptool): self.psptool = psptool self.roms: List[Rom] = [] - possible_fet_offsets = [ - # as seen by a PSPTrace Zen 1 boot - 0x020000, - 0xfa0000, - 0xf20000, - 0xe20000, - 0xc20000, - 0x820000, - 0x120000, - ] - possible_rom_sizes = [32, 16, 8] _rom_size = max(value for value in possible_rom_sizes if value * 1024 * 1024 <= self.buffer_size) rom_size = _rom_size * 1024 * 1024 @@ -58,7 +63,7 @@ def __init__(self, buffer: bytearray, size: int, psptool): # For each FET, we try to create a 16MB ROM starting at `FET - offset` for fet_location in self._find_fets(): fet_parsed = False - for fet_offset in possible_fet_offsets: + for fet_offset in self.POSSIBLE_FET_OFFSETS: if fet_location < fet_offset: # would lead to Blob underflow continue diff --git a/psptool/directory.py b/psptool/directory.py index ce711fc..7e85c59 100644 --- a/psptool/directory.py +++ b/psptool/directory.py @@ -38,6 +38,22 @@ class Directory(NestedBuffer): 'Zen 4/5': [b'\x03\x0D\xBC'] } + # Empirical mapping from PSP_FW_BOOT_LOADER (entry type 0x01) version + # major byte to Zen generation. Used as a third detection path for + # single-generation ROMs (e.g. EPYC server BIOSes) that have neither a + # 2PSP combo directory nor a tertiary self-tag and so hit neither of the + # existing zen_generation paths. Source for the majors below: the + # Test-PSPTool corpus and its bootloader_overview.py table, plus the + # original issue's empirical scrape across 37 EPYC ROMs. + BOOTLOADER_VERSION_TO_ZEN = { + 0x07: 'Zen 1', # Naples server (NaplesPI-SP3): H11DSI, MZ31-AR, S8026 + 0x09: 'Zen 1', 0x0A: 'Zen 1', # Naples / Raven Ridge consumer + 0x0B: 'Zen 2', 0x0C: 'Zen 2', # Rome + 0x13: 'Zen 3', # Milan + 0x29: 'Zen 4', # Genoa, Siena (Zen 4c shares the major) + 0x3D: 'Zen 5', # Turin (Bergamo also lands here per the issue) + } + @classmethod def get_possible_zen_generation(cls, zen_generation_id): zen_generation = 'unknown' diff --git a/psptool/psptool.py b/psptool/psptool.py index 017d7ba..069714b 100644 --- a/psptool/psptool.py +++ b/psptool/psptool.py @@ -50,12 +50,52 @@ def __init__(self, rom_bytes, verbose=False, filename=None): self.blob = Blob(rom_bytes, len(rom_bytes), self) self.cert_tree = CertificateTree.from_blob(self.blob, self) + self._backfill_zen_generation_from_bootloader() + def __repr__(self): if self.filename is not None: return f'PSPTool(filename={self.filename})' else: return f'PSPTool(len(rom_bytes)={self.blob.buffer_size}' + def _backfill_zen_generation_from_bootloader(self): + # Single-generation ROMs (notably EPYC server BIOSes) have no 2PSP + # combo directory and no tertiary self-tag, so neither of the two + # existing detection paths fires. Fall back to the PSP_FW_BOOT_LOADER + # version major byte, which is mandatory and stable per PSP firmware + # generation. Only fires when the entire ROM is unclassified — in a + # multi-generation combo BIOS the boot loader belongs to one of + # several $PSP directories and cannot be safely associated with the + # sibling $BHD directories. + for rom in self.blob.roms: + if not all(d.zen_generation is None for d in rom.directories): + continue + + bl_major = None + for directory in rom.directories: + for f in directory.files: + if f.type == 0x01 and isinstance(f, HeaderFile): + # version is reverse-sliced in HeaderFile (header[0x63:0x5f:-1]) + # so the printed-form major byte lives at version[1]. + bl_major = f.version[1] + break + if bl_major is not None: + break + + if bl_major is None: + continue + + gen = Directory.BOOTLOADER_VERSION_TO_ZEN.get(bl_major) + if gen is None: + self.ph.print_warning( + f"PSP_FW_BOOT_LOADER version major 0x{bl_major:02X} not in " + f"BOOTLOADER_VERSION_TO_ZEN; cannot infer zen_generation" + ) + continue + + for directory in rom.directories: + directory.zen_generation = gen + def to_file(self, filename): with open(filename, 'wb') as f: f.write(self.blob.get_buffer()) diff --git a/tests/unit/synthetic_rom.py b/tests/unit/synthetic_rom.py new file mode 100644 index 0000000..f9a1feb --- /dev/null +++ b/tests/unit/synthetic_rom.py @@ -0,0 +1,122 @@ +# PSPTool - Display, extract and manipulate PSP firmware inside UEFI images +# Copyright (C) 2026 contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +"""Synthetic ROM builder for unit tests. + +Constructs an 8 MB ROM-shaped binary that PSPTool.from_file() can parse +end-to-end: a Firmware Entry Table referencing a single $PSP directory, +which in turn references one PSP_FW_BOOT_LOADER (type 0x01) HeaderFile. +Most of the binary is zero padding; only the small windows that PSPTool +parses are populated. + +The boot loader's `version` field is the only knob tests need: setting +the major byte (printed as version[1]) chooses which Zen generation +PSPTool's back-fill assigns. The mapping itself lives in +psptool.directory.Directory.BOOTLOADER_VERSION_TO_ZEN. + +This is intentionally NOT a faithful PSP image; checksums on file +contents are zero, no signatures, no AGESA strings. It exercises the +parsing path that drives zen_generation back-fill and nothing more. +""" + +import struct + +from psptool.blob import Blob +from psptool.utils import fletcher32 + +ROM_SIZE = 8 * 1024 * 1024 +# Smallest FET offset PSPTool will probe — every other constant in this +# module is derived as a small offset above it, so updating the blob's +# offset table doesn't desync the builder. +FET_OFFSET = Blob.POSSIBLE_FET_OFFSETS[0] +PSP_DIR_OFFSET = FET_OFFSET + 0x1000 +BL_FILE_OFFSET = FET_OFFSET + 0x2000 + +FET_MAGIC = Blob._FIRMWARE_ENTRY_MAGIC +PSP_DIR_MAGIC = b'$PSP' + +DIRECTORY_HEADER_SIZE = 16 +DIRECTORY_ENTRY_SIZE = 16 +HEADER_FILE_SIZE = 0x100 # HeaderFile.HEADER_LEN; minimal file body + + +def _build_psp_directory(entry_offset: int, entry_size: int) -> bytes: + # Header layout: + # [0:4] magic '$PSP' + # [4:8] fletcher32(body) + # [8:12] entry count + # [12:16] additional_info: bit31=version=1, bits 25:24 = address_mode + # (1 = flash offset from start of BIOS) + additional_info = struct.pack(' bytes: + # HeaderFile reads version as header[0x63:0x5f:-1], i.e. bytes at + # 0x63, 0x62, 0x61, 0x60 in printed order. The major byte that drives + # zen_generation lives at version[1] = header[0x62]. + header = bytearray(HEADER_FILE_SIZE) + header[0x60] = 0x00 # version[3] (build, ignored for backfill) + header[0x61] = 0x00 # version[2] (minor, ignored) + header[0x62] = bl_major # version[1] (major — drives backfill) + header[0x63] = 0x00 # version[0] (printed-form leading zero) + # rom_size (header[0x6c:0x70]) = 0 means "use buffer_size" in HeaderFile + # All other fields stay zero: not encrypted, not signed, no checksum bits. + return bytes(header) + + +def build_synthetic_rom(bl_major: int) -> bytes: + """Return an 8 MB ROM blob whose PSP_FW_BOOT_LOADER version major + byte is `bl_major`. Pass a value from + Directory.BOOTLOADER_VERSION_TO_ZEN to drive a specific Zen + generation through the back-fill path. + """ + blob = bytearray(ROM_SIZE) + + # FET layout. The FET parser walks 4-byte words from FET_OFFSET until + # it sees 16 bytes of 0xFF. The first word is the FET magic itself + # (which the parser tries to interpret as a directory pointer and + # warns about — benign for tests). The next valid pointer points at + # our $PSP directory. + blob[FET_OFFSET - 4:FET_OFFSET] = b'\xff\xff\xff\xff' # required by _find_fets regex + blob[FET_OFFSET:FET_OFFSET + 4] = FET_MAGIC + blob[FET_OFFSET + 4:FET_OFFSET + 8] = struct.pack('