Skip to content

Commit ec0d48a

Browse files
committed
Detect zen_generation for single-generation EPYC BIOSes
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.
1 parent edae097 commit ec0d48a

5 files changed

Lines changed: 273 additions & 12 deletions

File tree

psptool/blob.py

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,21 @@ class Blob(NestedBuffer):
3030
_FIRMWARE_ENTRY_MAGIC = b'\xAA\x55\xAA\x55'
3131
# All structures per Rom must be in 16MB windows
3232
_MAX_PAGE_SIZE = 16 * 1024 * 1024
33+
34+
# ROM-relative offsets at which a FET may legitimately appear, ordered
35+
# from smallest to largest. As seen by a PSPTrace Zen 1 boot. The
36+
# smallest entry is the canonical position for compact images and is
37+
# what the synthetic-fixture builder targets.
38+
POSSIBLE_FET_OFFSETS = (
39+
0x020000,
40+
0x120000,
41+
0x820000,
42+
0xc20000,
43+
0xe20000,
44+
0xf20000,
45+
0xfa0000,
46+
)
47+
3348
class NoFirmwareEntryTableError(Exception):
3449
pass
3550

@@ -39,17 +54,6 @@ def __init__(self, buffer: bytearray, size: int, psptool):
3954
self.psptool = psptool
4055
self.roms: List[Rom] = []
4156

42-
possible_fet_offsets = [
43-
# as seen by a PSPTrace Zen 1 boot
44-
0x020000,
45-
0xfa0000,
46-
0xf20000,
47-
0xe20000,
48-
0xc20000,
49-
0x820000,
50-
0x120000,
51-
]
52-
5357
possible_rom_sizes = [32, 16, 8]
5458
_rom_size = max(value for value in possible_rom_sizes if value * 1024 * 1024 <= self.buffer_size)
5559
rom_size = _rom_size * 1024 * 1024
@@ -58,7 +62,7 @@ def __init__(self, buffer: bytearray, size: int, psptool):
5862
# For each FET, we try to create a 16MB ROM starting at `FET - offset`
5963
for fet_location in self._find_fets():
6064
fet_parsed = False
61-
for fet_offset in possible_fet_offsets:
65+
for fet_offset in self.POSSIBLE_FET_OFFSETS:
6266
if fet_location < fet_offset:
6367
# would lead to Blob underflow
6468
continue

psptool/directory.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,22 @@ class Directory(NestedBuffer):
3838
'Zen 4/5': [b'\x03\x0D\xBC']
3939
}
4040

41+
# Empirical mapping from PSP_FW_BOOT_LOADER (entry type 0x01) version
42+
# major byte to Zen generation. Used as a third detection path for
43+
# single-generation ROMs (e.g. EPYC server BIOSes) that have neither a
44+
# 2PSP combo directory nor a tertiary self-tag and so hit neither of the
45+
# existing zen_generation paths. Source for the majors below: the
46+
# Test-PSPTool corpus and its bootloader_overview.py table, plus the
47+
# original issue's empirical scrape across 37 EPYC ROMs.
48+
BOOTLOADER_VERSION_TO_ZEN = {
49+
0x07: 'Zen 1', # Naples server (NaplesPI-SP3): H11DSI, MZ31-AR, S8026
50+
0x09: 'Zen 1', 0x0A: 'Zen 1', # Naples / Raven Ridge consumer
51+
0x0B: 'Zen 2', 0x0C: 'Zen 2', # Rome
52+
0x13: 'Zen 3', # Milan
53+
0x29: 'Zen 4', # Genoa, Siena (Zen 4c shares the major)
54+
0x3D: 'Zen 5', # Turin (Bergamo also lands here per the issue)
55+
}
56+
4157
@classmethod
4258
def get_possible_zen_generation(cls, zen_generation_id):
4359
zen_generation = 'unknown'

psptool/psptool.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,52 @@ def __init__(self, rom_bytes, verbose=False, filename=None):
5050
self.blob = Blob(rom_bytes, len(rom_bytes), self)
5151
self.cert_tree = CertificateTree.from_blob(self.blob, self)
5252

53+
self._backfill_zen_generation_from_bootloader()
54+
5355
def __repr__(self):
5456
if self.filename is not None:
5557
return f'PSPTool(filename={self.filename})'
5658
else:
5759
return f'PSPTool(len(rom_bytes)={self.blob.buffer_size}'
5860

61+
def _backfill_zen_generation_from_bootloader(self):
62+
# Single-generation ROMs (notably EPYC server BIOSes) have no 2PSP
63+
# combo directory and no tertiary self-tag, so neither of the two
64+
# existing detection paths fires. Fall back to the PSP_FW_BOOT_LOADER
65+
# version major byte, which is mandatory and stable per PSP firmware
66+
# generation. Only fires when the entire ROM is unclassified — in a
67+
# multi-generation combo BIOS the boot loader belongs to one of
68+
# several $PSP directories and cannot be safely associated with the
69+
# sibling $BHD directories.
70+
for rom in self.blob.roms:
71+
if not all(d.zen_generation is None for d in rom.directories):
72+
continue
73+
74+
bl_major = None
75+
for directory in rom.directories:
76+
for f in directory.files:
77+
if f.type == 0x01 and isinstance(f, HeaderFile):
78+
# version is reverse-sliced in HeaderFile (header[0x63:0x5f:-1])
79+
# so the printed-form major byte lives at version[1].
80+
bl_major = f.version[1]
81+
break
82+
if bl_major is not None:
83+
break
84+
85+
if bl_major is None:
86+
continue
87+
88+
gen = Directory.BOOTLOADER_VERSION_TO_ZEN.get(bl_major)
89+
if gen is None:
90+
self.ph.print_warning(
91+
f"PSP_FW_BOOT_LOADER version major 0x{bl_major:02X} not in "
92+
f"BOOTLOADER_VERSION_TO_ZEN; cannot infer zen_generation"
93+
)
94+
continue
95+
96+
for directory in rom.directories:
97+
directory.zen_generation = gen
98+
5999
def to_file(self, filename):
60100
with open(filename, 'wb') as f:
61101
f.write(self.blob.get_buffer())

tests/unit/synthetic_rom.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# PSPTool - Display, extract and manipulate PSP firmware inside UEFI images
2+
# Copyright (C) 2026 contributors
3+
#
4+
# This program is free software: you can redistribute it and/or modify
5+
# it under the terms of the GNU General Public License as published by
6+
# the Free Software Foundation, either version 3 of the License, or
7+
# (at your option) any later version.
8+
9+
"""Synthetic ROM builder for unit tests.
10+
11+
Constructs an 8 MB ROM-shaped binary that PSPTool.from_file() can parse
12+
end-to-end: a Firmware Entry Table referencing a single $PSP directory,
13+
which in turn references one PSP_FW_BOOT_LOADER (type 0x01) HeaderFile.
14+
Most of the binary is zero padding; only the small windows that PSPTool
15+
parses are populated.
16+
17+
The boot loader's `version` field is the only knob tests need: setting
18+
the major byte (printed as version[1]) chooses which Zen generation
19+
PSPTool's back-fill assigns. The mapping itself lives in
20+
psptool.directory.Directory.BOOTLOADER_VERSION_TO_ZEN.
21+
22+
This is intentionally NOT a faithful PSP image; checksums on file
23+
contents are zero, no signatures, no AGESA strings. It exercises the
24+
parsing path that drives zen_generation back-fill and nothing more.
25+
"""
26+
27+
import struct
28+
29+
from psptool.blob import Blob
30+
from psptool.utils import fletcher32
31+
32+
ROM_SIZE = 8 * 1024 * 1024
33+
# Smallest FET offset PSPTool will probe — every other constant in this
34+
# module is derived as a small offset above it, so updating the blob's
35+
# offset table doesn't desync the builder.
36+
FET_OFFSET = Blob.POSSIBLE_FET_OFFSETS[0]
37+
PSP_DIR_OFFSET = FET_OFFSET + 0x1000
38+
BL_FILE_OFFSET = FET_OFFSET + 0x2000
39+
40+
FET_MAGIC = Blob._FIRMWARE_ENTRY_MAGIC
41+
PSP_DIR_MAGIC = b'$PSP'
42+
43+
DIRECTORY_HEADER_SIZE = 16
44+
DIRECTORY_ENTRY_SIZE = 16
45+
HEADER_FILE_SIZE = 0x100 # HeaderFile.HEADER_LEN; minimal file body
46+
47+
48+
def _build_psp_directory(entry_offset: int, entry_size: int) -> bytes:
49+
# Header layout:
50+
# [0:4] magic '$PSP'
51+
# [4:8] fletcher32(body)
52+
# [8:12] entry count
53+
# [12:16] additional_info: bit31=version=1, bits 25:24 = address_mode
54+
# (1 = flash offset from start of BIOS)
55+
additional_info = struct.pack('<I', (1 << 31) | (1 << 24))
56+
count = struct.pack('<I', 1)
57+
58+
# One DirectoryEntry: type=0x01 PSP_FW_BOOT_LOADER, subprog=0, flags=0,
59+
# size, offset, rsv0=0
60+
entry = struct.pack(
61+
'<BBHIII',
62+
0x01, # type
63+
0x00, # subprogram
64+
0x0000, # flags
65+
entry_size, # size
66+
entry_offset, # offset (raw — directory addr_mode 1 returns this as-is)
67+
0x00000000, # rsv0
68+
)
69+
70+
body = count + additional_info + entry # bytes after the checksum field
71+
checksum = fletcher32(body)
72+
return PSP_DIR_MAGIC + checksum + body
73+
74+
75+
def _build_header_file(bl_major: int) -> bytes:
76+
# HeaderFile reads version as header[0x63:0x5f:-1], i.e. bytes at
77+
# 0x63, 0x62, 0x61, 0x60 in printed order. The major byte that drives
78+
# zen_generation lives at version[1] = header[0x62].
79+
header = bytearray(HEADER_FILE_SIZE)
80+
header[0x60] = 0x00 # version[3] (build, ignored for backfill)
81+
header[0x61] = 0x00 # version[2] (minor, ignored)
82+
header[0x62] = bl_major # version[1] (major — drives backfill)
83+
header[0x63] = 0x00 # version[0] (printed-form leading zero)
84+
# rom_size (header[0x6c:0x70]) = 0 means "use buffer_size" in HeaderFile
85+
# All other fields stay zero: not encrypted, not signed, no checksum bits.
86+
return bytes(header)
87+
88+
89+
def build_synthetic_rom(bl_major: int) -> bytes:
90+
"""Return an 8 MB ROM blob whose PSP_FW_BOOT_LOADER version major
91+
byte is `bl_major`. Pass a value from
92+
Directory.BOOTLOADER_VERSION_TO_ZEN to drive a specific Zen
93+
generation through the back-fill path.
94+
"""
95+
blob = bytearray(ROM_SIZE)
96+
97+
# FET layout. The FET parser walks 4-byte words from FET_OFFSET until
98+
# it sees 16 bytes of 0xFF. The first word is the FET magic itself
99+
# (which the parser tries to interpret as a directory pointer and
100+
# warns about — benign for tests). The next valid pointer points at
101+
# our $PSP directory.
102+
blob[FET_OFFSET - 4:FET_OFFSET] = b'\xff\xff\xff\xff' # required by _find_fets regex
103+
blob[FET_OFFSET:FET_OFFSET + 4] = FET_MAGIC
104+
blob[FET_OFFSET + 4:FET_OFFSET + 8] = struct.pack('<I', PSP_DIR_OFFSET)
105+
blob[FET_OFFSET + 8:FET_OFFSET + 24] = b'\xff' * 16 # FET terminator
106+
107+
psp_dir = _build_psp_directory(BL_FILE_OFFSET, HEADER_FILE_SIZE)
108+
blob[PSP_DIR_OFFSET:PSP_DIR_OFFSET + len(psp_dir)] = psp_dir
109+
110+
bl_header = _build_header_file(bl_major)
111+
blob[BL_FILE_OFFSET:BL_FILE_OFFSET + len(bl_header)] = bl_header
112+
113+
return bytes(blob)
114+
115+
116+
if __name__ == '__main__':
117+
# Smoke check: build a Zen 2 ROM and dump its size and the version
118+
# bytes for sanity.
119+
data = build_synthetic_rom(0x0C)
120+
print(f'len={len(data)} fet[0:4]={data[FET_OFFSET:FET_OFFSET+4].hex()} '
121+
f'psp_dir[0:4]={data[PSP_DIR_OFFSET:PSP_DIR_OFFSET+4]!r} '
122+
f'bl_version[60:64]={data[BL_FILE_OFFSET+0x60:BL_FILE_OFFSET+0x64].hex()}')
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# PSPTool - Display, extract and manipulate PSP firmware inside UEFI images
2+
# Copyright (C) 2026 contributors
3+
#
4+
# This program is free software: you can redistribute it and/or modify
5+
# it under the terms of the GNU General Public License as published by
6+
# the Free Software Foundation, either version 3 of the License, or
7+
# (at your option) any later version.
8+
9+
import contextlib
10+
import io
11+
import os
12+
import tempfile
13+
import unittest
14+
15+
from psptool import PSPTool
16+
from psptool.directory import Directory
17+
18+
from .synthetic_rom import build_synthetic_rom
19+
20+
21+
class TestZenGenerationBackfill(unittest.TestCase):
22+
"""Drive the PSP_FW_BOOT_LOADER back-fill path with synthetic ROMs.
23+
24+
Each test builds an 8 MB ROM whose only PSP_FW_BOOT_LOADER carries a
25+
chosen version-major byte, parses it through PSPTool, and asserts
26+
that the resulting directory.zen_generation matches what
27+
BOOTLOADER_VERSION_TO_ZEN dictates.
28+
"""
29+
30+
def _parse_synthetic(self, bl_major):
31+
data = build_synthetic_rom(bl_major)
32+
with tempfile.NamedTemporaryFile(suffix='.rom', delete=False) as f:
33+
f.write(data)
34+
path = f.name
35+
try:
36+
with io.StringIO() as stderr_buf, contextlib.redirect_stderr(stderr_buf):
37+
pt = PSPTool.from_file(path)
38+
warnings = stderr_buf.getvalue()
39+
return pt, warnings
40+
finally:
41+
os.unlink(path)
42+
43+
def test_each_known_major_resolves_to_expected_zen(self):
44+
for bl_major, expected in Directory.BOOTLOADER_VERSION_TO_ZEN.items():
45+
with self.subTest(f'major=0x{bl_major:02X}'):
46+
pt, _ = self._parse_synthetic(bl_major)
47+
gens = [d.zen_generation for r in pt.blob.roms for d in r.directories]
48+
self.assertEqual(
49+
gens, [expected],
50+
f'major=0x{bl_major:02X}: expected one directory tagged {expected!r}, got {gens!r}',
51+
)
52+
53+
def test_unknown_major_leaves_zen_generation_none_and_warns(self):
54+
# 0x99 is intentionally not in BOOTLOADER_VERSION_TO_ZEN.
55+
self.assertNotIn(0x99, Directory.BOOTLOADER_VERSION_TO_ZEN)
56+
pt, warnings = self._parse_synthetic(0x99)
57+
gens = [d.zen_generation for r in pt.blob.roms for d in r.directories]
58+
self.assertEqual(gens, [None])
59+
self.assertIn('0x99', warnings)
60+
self.assertIn('not in BOOTLOADER_VERSION_TO_ZEN', warnings)
61+
62+
def test_back_fill_does_not_overwrite_pre_classified_directories(self):
63+
# If the directory already has a zen_generation set (would be the
64+
# case in a combo BIOS where combo_dir classified it), back-fill
65+
# must not touch it. Verify by mutating the parsed object: pin
66+
# the directory to a known string, run back-fill again, observe
67+
# it stays put even though it would otherwise resolve via the
68+
# 0x0C boot loader.
69+
pt, _ = self._parse_synthetic(0x0C)
70+
directory = pt.blob.roms[0].directories[0]
71+
self.assertEqual(directory.zen_generation, 'Zen 2')
72+
73+
directory.zen_generation = 'pre-classified'
74+
pt._backfill_zen_generation_from_bootloader()
75+
self.assertEqual(directory.zen_generation, 'pre-classified')
76+
77+
78+
if __name__ == '__main__':
79+
unittest.main()

0 commit comments

Comments
 (0)