Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 17 additions & 12 deletions psptool/blob.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down
16 changes: 16 additions & 0 deletions psptool/directory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
40 changes: 40 additions & 0 deletions psptool/psptool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
122 changes: 122 additions & 0 deletions tests/unit/synthetic_rom.py
Original file line number Diff line number Diff line change
@@ -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('<I', (1 << 31) | (1 << 24))
count = struct.pack('<I', 1)

# One DirectoryEntry: type=0x01 PSP_FW_BOOT_LOADER, subprog=0, flags=0,
# size, offset, rsv0=0
entry = struct.pack(
'<BBHIII',
0x01, # type
0x00, # subprogram
0x0000, # flags
entry_size, # size
entry_offset, # offset (raw — directory addr_mode 1 returns this as-is)
0x00000000, # rsv0
)

body = count + additional_info + entry # bytes after the checksum field
checksum = fletcher32(body)
return PSP_DIR_MAGIC + checksum + body


def _build_header_file(bl_major: int) -> 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('<I', PSP_DIR_OFFSET)
blob[FET_OFFSET + 8:FET_OFFSET + 24] = b'\xff' * 16 # FET terminator

psp_dir = _build_psp_directory(BL_FILE_OFFSET, HEADER_FILE_SIZE)
blob[PSP_DIR_OFFSET:PSP_DIR_OFFSET + len(psp_dir)] = psp_dir

bl_header = _build_header_file(bl_major)
blob[BL_FILE_OFFSET:BL_FILE_OFFSET + len(bl_header)] = bl_header

return bytes(blob)


if __name__ == '__main__':
# Smoke check: build a Zen 2 ROM and dump its size and the version
# bytes for sanity.
data = build_synthetic_rom(0x0C)
print(f'len={len(data)} fet[0:4]={data[FET_OFFSET:FET_OFFSET+4].hex()} '
f'psp_dir[0:4]={data[PSP_DIR_OFFSET:PSP_DIR_OFFSET+4]!r} '
f'bl_version[60:64]={data[BL_FILE_OFFSET+0x60:BL_FILE_OFFSET+0x64].hex()}')
79 changes: 79 additions & 0 deletions tests/unit/test_zen_generation_backfill.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# 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.

import contextlib
import io
import os
import tempfile
import unittest

from psptool import PSPTool
from psptool.directory import Directory

from .synthetic_rom import build_synthetic_rom


class TestZenGenerationBackfill(unittest.TestCase):
"""Drive the PSP_FW_BOOT_LOADER back-fill path with synthetic ROMs.

Each test builds an 8 MB ROM whose only PSP_FW_BOOT_LOADER carries a
chosen version-major byte, parses it through PSPTool, and asserts
that the resulting directory.zen_generation matches what
BOOTLOADER_VERSION_TO_ZEN dictates.
"""

def _parse_synthetic(self, bl_major):
data = build_synthetic_rom(bl_major)
with tempfile.NamedTemporaryFile(suffix='.rom', delete=False) as f:
f.write(data)
path = f.name
try:
with io.StringIO() as stderr_buf, contextlib.redirect_stderr(stderr_buf):
pt = PSPTool.from_file(path)
warnings = stderr_buf.getvalue()
return pt, warnings
finally:
os.unlink(path)

def test_each_known_major_resolves_to_expected_zen(self):
for bl_major, expected in Directory.BOOTLOADER_VERSION_TO_ZEN.items():
with self.subTest(f'major=0x{bl_major:02X}'):
pt, _ = self._parse_synthetic(bl_major)
gens = [d.zen_generation for r in pt.blob.roms for d in r.directories]
self.assertEqual(
gens, [expected],
f'major=0x{bl_major:02X}: expected one directory tagged {expected!r}, got {gens!r}',
)

def test_unknown_major_leaves_zen_generation_none_and_warns(self):
# 0x99 is intentionally not in BOOTLOADER_VERSION_TO_ZEN.
self.assertNotIn(0x99, Directory.BOOTLOADER_VERSION_TO_ZEN)
pt, warnings = self._parse_synthetic(0x99)
gens = [d.zen_generation for r in pt.blob.roms for d in r.directories]
self.assertEqual(gens, [None])
self.assertIn('0x99', warnings)
self.assertIn('not in BOOTLOADER_VERSION_TO_ZEN', warnings)

def test_back_fill_does_not_overwrite_pre_classified_directories(self):
# If the directory already has a zen_generation set (would be the
# case in a combo BIOS where combo_dir classified it), back-fill
# must not touch it. Verify by mutating the parsed object: pin
# the directory to a known string, run back-fill again, observe
# it stays put even though it would otherwise resolve via the
# 0x0C boot loader.
pt, _ = self._parse_synthetic(0x0C)
directory = pt.blob.roms[0].directories[0]
self.assertEqual(directory.zen_generation, 'Zen 2')

directory.zen_generation = 'pre-classified'
pt._backfill_zen_generation_from_bootloader()
self.assertEqual(directory.zen_generation, 'pre-classified')


if __name__ == '__main__':
unittest.main()