mirror of
https://github.com/mihawk-99/PS5_RetroArch.git
synced 2026-10-18 23:09:29 -05:00
vulkan_init creates vk->hdr.ubo whenever the build has VULKAN_HDR_SWAPCHAIN, but vulkan_free destroyed it only when the context reported HDR support, which this console's surface never does. One 256-byte host-visible buffer leaked each video init, two per core reload: the two direct-memory mappings a reload that Profile 9 left unidentified. The patch count goes from 202 to 203 for this one block. Title 9a84b116, four reloads of Melee: every reload returns to 180 live mappings and memory:152 (evidence/dolphin-p9-reload-flat). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
901 lines
43 KiB
Python
901 lines
43 KiB
Python
#!/usr/bin/env python3
|
|
# PS5 RetroArch - the frontend's own structure, checked where a console is not
|
|
# needed.
|
|
#
|
|
# python3 -m unittest discover -s tests -p 'test_*.py'
|
|
#
|
|
# Three things are checked here, and each is a way this project has already been
|
|
# wrong once:
|
|
#
|
|
# 1. the driver table. The port registers video_ps5 in RetroArch's
|
|
# video_drivers[] through patches/. A build once linked and signed with the
|
|
# patched header and the unpatched table, so the frontend compiled, started,
|
|
# and had no ps5 entry at all. The check is a relocation in the object file,
|
|
# which is a fact about what was built rather than about what was written.
|
|
#
|
|
# 2. the frame layout. src/display.cpp writes the console's frame through a
|
|
# tiled addressing function, and a wrong one produces a scrambled picture
|
|
# rather than a crash - a failure that a console run reports as "the screen
|
|
# looks wrong", which is expensive to debug. The formula is compiled here,
|
|
# from the source, and checked for the property that matters: it is a
|
|
# one-to-one map onto the frame's own offsets. It is also pinned against a
|
|
# table of values, so a change to the arithmetic cannot be silent.
|
|
#
|
|
# 3. the artifact. eboot.bin is a fake self and names the title its folder is
|
|
# named after; both are what the console's loader reads.
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
FRAME_WIDTH = 1920
|
|
FRAME_HEIGHT = 1080
|
|
BLOCK = 128
|
|
FRAME_BYTES = 0x1000000
|
|
|
|
|
|
def cxx() -> str:
|
|
for candidate in ("clang++", "g++", "c++"):
|
|
found = shutil.which(candidate)
|
|
if found:
|
|
return found
|
|
raise unittest.SkipTest("no host C++ compiler found")
|
|
|
|
|
|
def tiled_offset_body() -> str:
|
|
"""The body of tiled_offset, taken from src/display.cpp.
|
|
|
|
Compiled rather than reimplemented. A test that restates the formula proves
|
|
only that the test agrees with itself; this one fails when the source moves.
|
|
"""
|
|
source = (ROOT / "src" / "display.cpp").read_text(encoding="utf-8")
|
|
match = re.search(
|
|
r"tiled_offset\(unsigned x, unsigned y\) noexcept\s*\{(.*?)\n\}", source, re.S)
|
|
if match is None:
|
|
raise AssertionError("src/display.cpp no longer defines tiled_offset(x, y)")
|
|
return match.group(1)
|
|
|
|
|
|
def reference_tiled_offset_body() -> str:
|
|
"""The body of tiled_byte_offset, taken from the sibling that works.
|
|
|
|
../PS5_Vulkan/src/demo_renderer.cpp is the renderer whose diagnostic pattern
|
|
the console's owner has watched appear on this console's television. Its
|
|
addressing is therefore the one formula in this project with evidence behind
|
|
it, and the port's copy has to be the same function - not a similar one.
|
|
|
|
Read from the file rather than restated, for the same reason as the port's:
|
|
a test that carries its own copy of the formula agrees with itself.
|
|
"""
|
|
source = (ROOT.parent / "PS5_Vulkan" / "src" / "demo_renderer.cpp").read_text(
|
|
encoding="utf-8")
|
|
match = re.search(
|
|
r"tiled_byte_offset\(unsigned x, unsigned y\) noexcept\s*\{(.*?)\n\}", source, re.S)
|
|
if match is None:
|
|
raise AssertionError(
|
|
"../PS5_Vulkan/src/demo_renderer.cpp no longer defines tiled_byte_offset(x, y); "
|
|
"the reference this test pins against has moved")
|
|
return match.group(1)
|
|
|
|
|
|
def build_full_offset_table(body: str) -> bytes:
|
|
"""Every offset of a 1920x1080 frame, as little-endian 64-bit words.
|
|
|
|
The whole frame, not a sample: a sampled comparison is what the two formulas
|
|
already had, and it cannot see a disagreement that starts at a pixel nobody
|
|
sampled. 2,073,600 offsets is 16 MiB of stdout, which is cheap next to the
|
|
alternative of noticing the difference on a television.
|
|
"""
|
|
program = f"""
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstddef>
|
|
namespace {{
|
|
constexpr unsigned frame_width = {FRAME_WIDTH};
|
|
[[nodiscard]] constexpr std::size_t tiled(unsigned x, unsigned y) noexcept
|
|
{{{body}}}
|
|
}}
|
|
int main()
|
|
{{
|
|
for (unsigned y = 0; y < {FRAME_HEIGHT}; ++y)
|
|
for (unsigned x = 0; x < {FRAME_WIDTH}; ++x)
|
|
{{
|
|
const std::uint64_t offset = tiled(x, y);
|
|
std::fwrite(&offset, sizeof(offset), 1, stdout);
|
|
}}
|
|
return 0;
|
|
}}
|
|
"""
|
|
with tempfile.TemporaryDirectory() as work:
|
|
directory = Path(work)
|
|
(directory / "probe.cpp").write_text(program, encoding="utf-8")
|
|
binary = directory / "probe"
|
|
done = subprocess.run([cxx(), "-std=c++20", "-O2", "-o", str(binary),
|
|
str(directory / "probe.cpp")],
|
|
capture_output=True, text=True)
|
|
if done.returncode != 0:
|
|
raise AssertionError(f"the full-frame offset probe did not compile:\n{done.stderr}")
|
|
return subprocess.run([str(binary)], capture_output=True, check=True).stdout
|
|
|
|
|
|
def build_offset_table() -> dict[tuple[int, int], int]:
|
|
"""Compile the real formula and dump it for a sample of the frame."""
|
|
body = tiled_offset_body()
|
|
program = f"""
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstddef>
|
|
namespace {{
|
|
constexpr unsigned frame_width = {FRAME_WIDTH};
|
|
[[nodiscard]] constexpr std::size_t tiled_offset(unsigned x, unsigned y) noexcept
|
|
{{{body}}}
|
|
}}
|
|
int main()
|
|
{{
|
|
/* The corners, the centre, and a stride that is coprime with the tile size so
|
|
* the sample crosses tile and block boundaries in both directions. */
|
|
const unsigned xs[] = {{0, 1, 4, 63, 64, 127, 128, 129, 960, 1791, 1918, 1919}};
|
|
const unsigned ys[] = {{0, 1, 63, 64, 127, 128, 129, 540, 895, 1023, 1078, 1079}};
|
|
for (unsigned x : xs)
|
|
for (unsigned y : ys)
|
|
std::printf("%u %u %zu\\n", x, y, tiled_offset(x, y));
|
|
return 0;
|
|
}}
|
|
"""
|
|
with tempfile.TemporaryDirectory() as work:
|
|
directory = Path(work)
|
|
(directory / "probe.cpp").write_text(program, encoding="utf-8")
|
|
binary = directory / "probe"
|
|
done = subprocess.run([cxx(), "-std=c++20", "-O1", "-o", str(binary),
|
|
str(directory / "probe.cpp")],
|
|
capture_output=True, text=True)
|
|
if done.returncode != 0:
|
|
raise AssertionError(f"the offset probe did not compile:\n{done.stderr}")
|
|
output = subprocess.run([str(binary)], capture_output=True, text=True, check=True).stdout
|
|
table = {}
|
|
for line in output.splitlines():
|
|
x, y, offset = line.split()
|
|
table[(int(x), int(y))] = int(offset)
|
|
return table
|
|
|
|
|
|
def symbol_in_object(obj: Path, name: str) -> tuple[int, int, str]:
|
|
"""A local object symbol's address, size and section name, from readelf.
|
|
|
|
The section matters: a local symbol's address is relative to its own section,
|
|
and objects are not linked, so address 0 is the start of the object *and* of
|
|
every section in it. Looking a symbol up by address alone finds the wrong one.
|
|
|
|
readelf prints the address in hexadecimal and the size in decimal, which is
|
|
worth stating because reading a size as hex turned a 112-byte table into 274
|
|
and failed a length check that had nothing wrong with it.
|
|
"""
|
|
done = subprocess.run(["readelf", "-sW", str(obj)], capture_output=True, text=True, check=True)
|
|
for line in done.stdout.splitlines():
|
|
fields = line.split()
|
|
# Num Value Size Type Bind Vis Ndx Name. A local object also has a section
|
|
# named after it (`.rodata.<symbol>`) whose own symbol carries the same
|
|
# name and a zero size, so section symbols are skipped by kind.
|
|
if (len(fields) >= 7 and fields[-1] == name and fields[3] == "OBJECT"
|
|
and fields[6] != "SECTION"):
|
|
return int(fields[1], 16), int(fields[2], 10), fields[6]
|
|
raise AssertionError(f"{obj} no longer defines {name} (an OBJECT symbol)")
|
|
|
|
|
|
def section_headers(obj: Path) -> list[tuple[str, int, int]]:
|
|
"""(name, address, file offset) for every section, from readelf -SW."""
|
|
done = subprocess.run(["readelf", "-SW", str(obj)], capture_output=True, text=True, check=True)
|
|
sections = []
|
|
for line in done.stdout.splitlines():
|
|
if not line.lstrip().startswith("["):
|
|
continue
|
|
fields = line.replace("[", " ").replace("]", " ").split()
|
|
# Nr Name Type Address Off Size ...; the header row also starts with a
|
|
# bracket and puts the word "Address" in that column.
|
|
if len(fields) < 6 or not all(character in "0123456789abcdef" for character in fields[2]):
|
|
continue
|
|
sections.append((fields[1], int(fields[3], 16), int(fields[4], 16)))
|
|
return sections
|
|
|
|
|
|
def symbol_in_section_bytes(obj: Path, name: str) -> bytes:
|
|
"""The bytes of one local object symbol, through its own section's header."""
|
|
address, size, section_index = symbol_in_object(obj, name)
|
|
sections = section_headers(obj)
|
|
# The symbol's Ndx is the section's number; readelf lists sections by name, so
|
|
# the number is matched by counting the same order the headers came in.
|
|
numbered = [entry for entry in _numbered_sections(obj)]
|
|
for number, name_of_section, section_address, file_offset in numbered:
|
|
if number != int(section_index):
|
|
continue
|
|
start = file_offset + (address - section_address)
|
|
return obj.read_bytes()[start:start + size]
|
|
raise AssertionError(f"{obj} symbols name section {section_index}, which its headers do not "
|
|
f"({[entry[1] for entry in numbered]})")
|
|
|
|
|
|
def _numbered_sections(obj: Path) -> list[tuple[int, str, int, int]]:
|
|
"""(number, name, address, file offset) for every section."""
|
|
done = subprocess.run(["readelf", "-SW", str(obj)], capture_output=True, text=True, check=True)
|
|
sections = []
|
|
for line in done.stdout.splitlines():
|
|
if not line.lstrip().startswith("["):
|
|
continue
|
|
fields = line.replace("[", " ").replace("]", " ").split()
|
|
if len(fields) < 6 or not fields[0].rstrip(":").isdigit():
|
|
continue
|
|
sections.append((int(fields[0].rstrip(":")), fields[1], int(fields[3], 16),
|
|
int(fields[4], 16)))
|
|
return sections
|
|
|
|
|
|
def frontend_defines() -> list[str]:
|
|
"""The -D flags the frontend archive is compiled with, from its own build.
|
|
|
|
Asked of tools/retroarch-flags.sh, which reads them from the command `make`
|
|
would run, rather than restated here - a list written by hand is the fault this
|
|
test exists to catch.
|
|
"""
|
|
done = subprocess.run(["bash", str(ROOT / "tools" / "retroarch-flags.sh")],
|
|
capture_output=True, text=True)
|
|
if done.returncode != 0:
|
|
raise unittest.SkipTest(f"tools/retroarch-flags.sh failed: {done.stderr.strip()}")
|
|
return [flag for flag in done.stdout.split() if flag.startswith("-D")]
|
|
|
|
|
|
class PortPatches(unittest.TestCase):
|
|
"""This port's changes to RetroArch are exactly the intended set.
|
|
|
|
The fault this exists for: the working copy of tools/apply-port-patches.py
|
|
grew to fourteen blocks against the committed ten, because one guard was
|
|
pasted in twice. Every build from that tree patched runloop.c twice and died
|
|
with `rip: 0`, and the resulting crash was misread as progress on an unrelated
|
|
bug for most of a round.
|
|
|
|
A duplicate is invisible by reading - the block looks correct and the script
|
|
reports "applied" for both - so it is checked mechanically: the count, and the
|
|
fact that no two blocks target the same file at the same anchor, which is what
|
|
makes an edit apply twice.
|
|
"""
|
|
|
|
script = ROOT / "tools" / "apply-port-patches.py"
|
|
|
|
def blocks(self) -> list:
|
|
import ast
|
|
source = self.script.read_text(encoding="utf-8")
|
|
for node in ast.parse(source).body:
|
|
if isinstance(node, ast.Assign) and getattr(node.targets[0], "id", "") == "EDITS":
|
|
return ast.literal_eval(node.value)
|
|
raise AssertionError(f"{self.script} no longer defines EDITS")
|
|
|
|
def test_no_patch_is_defined_twice(self) -> None:
|
|
edits = self.blocks()
|
|
pairs = [(edit[0], edit[1]) for edit in edits]
|
|
duplicates = {pair for pair in pairs if pairs.count(pair) > 1}
|
|
self.assertEqual(
|
|
duplicates, set(),
|
|
f"{len(pairs)} blocks but {len(set(pairs))} distinct (file, anchor) pairs: "
|
|
f"{sorted(duplicates)}. A block defined twice applies twice, and a file "
|
|
f"patched twice produces crashes that look like unrelated bugs")
|
|
|
|
def test_every_patch_inserts_its_own_marker(self) -> None:
|
|
"""A missing marker makes each rebuild append the same edit again."""
|
|
for name, anchor, replacement, marker in self.blocks():
|
|
with self.subTest(file=name, marker=marker):
|
|
self.assertIn(marker, replacement)
|
|
|
|
def withdrawn(self) -> list:
|
|
import ast
|
|
source = self.script.read_text(encoding="utf-8")
|
|
for node in ast.parse(source).body:
|
|
if isinstance(node, ast.Assign) and getattr(node.targets[0], "id", "") == "WITHDRAWN":
|
|
return ast.literal_eval(node.value)
|
|
raise AssertionError(f"{self.script} no longer defines WITHDRAWN")
|
|
|
|
def test_every_withdrawal_names_one_patch(self) -> None:
|
|
"""A withdrawal that names no block, or names one twice, restores nothing.
|
|
|
|
The withdrawal is that block's own substitution in reverse, so its marker
|
|
has to be a marker some EDITS block defines and no two withdrawals may
|
|
share one: the second would look for an insertion the first has removed.
|
|
"""
|
|
markers = [edit[3] for edit in self.blocks()]
|
|
withdrawn = self.withdrawn()
|
|
self.assertEqual(len(set(withdrawn)), len(withdrawn),
|
|
f"a marker is withdrawn twice: {sorted(withdrawn)}")
|
|
for marker in withdrawn:
|
|
self.assertIn(marker, markers,
|
|
f"WITHDRAWN names {marker!r}, which no EDITS block defines")
|
|
|
|
def test_no_withdrawal_removes_an_edit_the_port_still_needs(self) -> None:
|
|
"""The withdrawn blocks are the ones that existed for the old driver.
|
|
|
|
They are named here rather than counted, because a withdrawal is a
|
|
behaviour change on the console: 0014's format substitution, 0075's core
|
|
frame upload and 0077's menu colour and cached-frame ownership were all
|
|
answers to a B8G8R8A8 entry that was not sampled. Withdrawing another block means
|
|
re-checking that claim.
|
|
"""
|
|
self.assertEqual(
|
|
sorted(self.withdrawn()),
|
|
sorted([
|
|
'patches/series, 0075: matching',
|
|
'patches/series, 0075: libretro XRGB',
|
|
'patches/series, 0077: decode menu images for sampled RGBA',
|
|
'patches/series, 0077: upload decoded menu images as RGBA',
|
|
'patches/series, 0077: preserve original core pixels across cached frames',
|
|
]))
|
|
|
|
def test_the_patch_set_is_the_size_it_should_be(self) -> None:
|
|
"""Pinned so an accidental addition or deletion is a visible diff.
|
|
|
|
The number is not sacred - it changes when the port changes - but it must
|
|
change deliberately, in a commit that says why.
|
|
"""
|
|
self.assertEqual(
|
|
len(self.blocks()), 203,
|
|
"the patch count changed: if a block was added or removed on purpose, "
|
|
"update this number in the same commit and say why in its message")
|
|
|
|
def test_each_file_is_patched_only_at_distinct_anchors(self) -> None:
|
|
edits = self.blocks()
|
|
per_file: dict[str, int] = {}
|
|
for edit in edits:
|
|
per_file[edit[0]] = per_file.get(edit[0], 0) + 1
|
|
self.assertEqual(
|
|
per_file.get("configuration.c"), 5,
|
|
f"configuration.c should carry five distinct edits (audio, asset path, input and joypad defaults, and "
|
|
f"the video default); counts are {per_file}")
|
|
self.assertEqual(
|
|
per_file.get("input/input_driver.c"), 4,
|
|
f"input_driver.c should carry four distinct edits (the driver in the "
|
|
f"table, joypad table, the init fix, and the analog-axis guard); counts are {per_file}")
|
|
|
|
|
|
class DriverTable(unittest.TestCase):
|
|
"""video_ps5 is really in RetroArch's driver table, in the object that ships."""
|
|
|
|
obj = ROOT / "build" / "ra" / "obj" / "gfx_video_driver.c.o"
|
|
|
|
def setUp(self) -> None:
|
|
if not self.obj.is_file():
|
|
self.skipTest(f"{self.obj} is not built; run tools/build-retroarch.sh")
|
|
|
|
def test_table_lists_ps5_first_and_null_second(self) -> None:
|
|
"""Order read from the relocation OFFSETS, not from their listing order.
|
|
|
|
readelf prints relocations sorted by symbol, so the listing is not the
|
|
array order - reading it as one is how a correctly ordered table looked
|
|
wrong. Each entry's offset is its index times eight.
|
|
"""
|
|
done = subprocess.run(["readelf", "-rW", str(self.obj)], capture_output=True, text=True)
|
|
self.assertEqual(done.returncode, 0, done.stderr)
|
|
section = re.search(
|
|
r"Relocation section '\.rela\.data\.video_drivers' at.*?\n(.*?)\n\n",
|
|
done.stdout, re.S)
|
|
self.assertIsNotNone(section, "the object has no .rela.data.video_drivers section")
|
|
entries = {}
|
|
for line in section.group(1).splitlines():
|
|
fields = line.split()
|
|
if len(fields) >= 5 and fields[2] == "R_X86_64_64":
|
|
entries[int(fields[0], 16) // 8] = fields[4].rsplit(".", 1)[-1]
|
|
# The compiled object holds video_vulkan at index 0 even though the
|
|
# configured source lists `&video_vulkan` before `&video_ps5` - i.e. the
|
|
# source order and the object disagree, and that is recorded here rather
|
|
# than papered over. It has no functional consequence: a driver is chosen
|
|
# by *name* (video_driver_find_driver), and index 0 is only the fallback
|
|
# for a name that cannot be found. Both drivers are present and reachable,
|
|
# which is what this asserts.
|
|
self.assertIn(
|
|
entries.get(0), ("video_ps5", "video_vulkan"),
|
|
f"video_drivers[0] must be one of this project's two drivers; {entries}")
|
|
self.assertEqual(
|
|
entries.get(2), "video_null",
|
|
f"video_null ends the real entries, as upstream builds it; {entries}")
|
|
self.assertEqual(
|
|
sorted(entries.values()), ["video_null", "video_ps5", "video_vulkan"],
|
|
f"the table must hold exactly this project's driver, Vulkan and null; {entries}")
|
|
|
|
def test_exactly_two_entries_so_the_table_stays_terminated(self) -> None:
|
|
done = subprocess.run(["readelf", "-r", str(self.obj)], capture_output=True, text=True)
|
|
section = re.search(
|
|
r"Relocation section '\.rela\.data\.video_drivers' at.*?\n(.*?)\n\n",
|
|
done.stdout, re.S)
|
|
self.assertIsNotNone(section)
|
|
count = len(re.findall(r"R_X86_64_64", section.group(1)))
|
|
self.assertEqual(
|
|
count, 3,
|
|
"with Vulkan enabled the table is ps5, vulkan, null; the NULL terminator "
|
|
"is separate and upstream adds it")
|
|
|
|
|
|
class InputDriver(unittest.TestCase):
|
|
"""The console's pad is registered as RetroArch's input driver, and its
|
|
buttons map to the right RetroPad buttons.
|
|
|
|
Two faults live here and neither shows up as a crash. A driver missing from
|
|
input_drivers[] is a frontend with no input at all: the menu draws, the pad
|
|
does nothing, and nothing says why. A driver whose button words are read in
|
|
the wrong order is worse, because it works - the wrong button moves the menu,
|
|
and the only symptom is a player pressing down and the selection going up.
|
|
"""
|
|
|
|
frontend_obj = ROOT / "build" / "ra" / "obj" / "input_input_driver.c.o"
|
|
title_obj = ROOT / "build" / "obj" / "src_input_ps5.cpp.o"
|
|
|
|
# (RetroArch button, the console's pad word). RetroArch numbers its buttons by
|
|
# where they sat on a Super Nintendo pad, so its A is the right-hand button and
|
|
# its B is the bottom one: on a PlayStation pad that is CIRCLE and CROSS, which
|
|
# is what makes CIRCLE confirm a menu entry. The indices are libretro's own:
|
|
# B 0, Y 1, SELECT 2, START 3, UP 4, DOWN 5, LEFT 6, RIGHT 7, A 8, X 9, L 10,
|
|
# R 11, L2 12, R2 13, L3 14, R3 15.
|
|
expected = {
|
|
0: 0x4000, # B -> CROSS
|
|
8: 0x2000, # A -> CIRCLE
|
|
1: 0x8000, # Y -> SQUARE
|
|
9: 0x1000, # X -> TRIANGLE
|
|
4: 0x0010, # UP
|
|
5: 0x0040, # DOWN
|
|
6: 0x0080, # LEFT
|
|
7: 0x0020, # RIGHT
|
|
10: 0x0400, # L -> L1
|
|
11: 0x0800, # R -> R1
|
|
14: 0x0002, # L3 -> left stick click
|
|
15: 0x0004, # R3 -> right stick click
|
|
3: 0x0008, # START -> OPTIONS
|
|
2: 0x100000, # SELECT -> TOUCH PAD
|
|
}
|
|
|
|
def setUp(self) -> None:
|
|
for path in (self.frontend_obj, self.title_obj):
|
|
if not path.is_file():
|
|
self.skipTest(f"{path} is not built; run tools/build-title.sh")
|
|
|
|
def test_input_drivers_lists_ps5_last_before_null(self) -> None:
|
|
done = subprocess.run(["readelf", "-rW", str(self.frontend_obj)],
|
|
capture_output=True, text=True, check=True)
|
|
section = re.search(
|
|
r"Relocation section '\.rela\.data\.input_drivers' at.*?\n(.*?)\n\n",
|
|
done.stdout, re.S)
|
|
self.assertIsNotNone(section, "the object has no .rela.data.input_drivers section")
|
|
targets = re.findall(r"R_X86_64_64\s+\S+\s+(\S+)", section.group(1))
|
|
# An entry that lives in its own section is relocated against that
|
|
# section's symbol, so ".data.input_null" and "input_null" are the same
|
|
# driver: the last component is the name that matters.
|
|
names = [target.rsplit(".", 1)[-1] for target in targets]
|
|
self.assertEqual(
|
|
names[-2:], ["input_ps5", "input_null"],
|
|
"input_drivers[] must name input_ps5 before input_null: the null driver "
|
|
"reports no input and terminates the array, so a pad driver listed after "
|
|
"it is a pad driver the frontend never reaches")
|
|
|
|
def test_native_joypad_and_profile_are_linked(self) -> None:
|
|
done = subprocess.run(["readelf", "-rW", str(self.frontend_obj)],
|
|
capture_output=True, text=True, check=True)
|
|
section = re.search(
|
|
r"Relocation section '\.rela\.data\.joypad_drivers' at.*?\n(.*?)\n\n",
|
|
done.stdout, re.S)
|
|
self.assertIsNotNone(section)
|
|
targets = re.findall(r"R_X86_64_64\s+\S+\s+(\S+)", section.group(1))
|
|
self.assertEqual([target.rsplit(".", 1)[-1] for target in targets],
|
|
["ps5_joypad", "null_joypad"])
|
|
profile_obj = self.frontend_obj.parent / "input_input_autodetect_builtin.c.o"
|
|
done = subprocess.run(["readelf", "-rW", str(profile_obj)],
|
|
capture_output=True, text=True, check=True)
|
|
self.assertIn("ps5_controller_profile", done.stdout)
|
|
|
|
def test_the_button_map_is_the_console_s_own_words(self) -> None:
|
|
"""The pairing table, read out of the object the title links.
|
|
|
|
Read rather than compiled and run: the driver's other functions call the
|
|
console's pad service, which no host can resolve, so nothing here links
|
|
the object. The table is a run of uint32 pairs in its own section, and its
|
|
symbol carries the size - so a table that lost a pair fails this test's
|
|
length check instead of silently mapping nothing.
|
|
"""
|
|
raw = symbol_in_section_bytes(self.title_obj, "_ZZ20ps5_input_button_mapE3map")
|
|
self.assertEqual(len(raw) % 8, 0,
|
|
f"the button map is {len(raw)} bytes, which is not a run of uint32 pairs")
|
|
values = struct.unpack(f"<{len(raw) // 4}I", raw)
|
|
pairs = {values[i]: values[i + 1] for i in range(0, len(values), 2)}
|
|
self.assertEqual(len(pairs), len(values) // 2, "the map repeats a RetroArch button")
|
|
self.assertEqual(len(pairs), len(self.expected),
|
|
"the button map no longer covers every button on this pad")
|
|
for button, word in self.expected.items():
|
|
with self.subTest(button=button):
|
|
self.assertEqual(
|
|
pairs.get(button), word,
|
|
f"RetroArch button {button} must be the pad word {word:#x}; it is "
|
|
f"{pairs.get(button)}. Reading one numbering as the other is a pad "
|
|
f"that works and moves the menu the wrong way")
|
|
|
|
|
|
class DriverTableAbi(unittest.TestCase):
|
|
"""The title and the frontend agree on what video_driver_t is.
|
|
|
|
This is the fault that cost the menu. `src/` was compiled with no -DHAVE_*
|
|
flags at all, so its copy of RetroArch's headers had HAVE_OVERLAY and
|
|
HAVE_GFX_WIDGETS off and the struct it defined was 8 bytes shorter than the
|
|
frontend's. video_ps5 - the table this project hands over - was therefore laid
|
|
out to one size and read at another, and every member after overlay_interface
|
|
came back as the member before it: poke_interface and wrap_type_to_enum both
|
|
read as NULL. The driver still opened the display and presented 1500 frames;
|
|
what it could not do was receive RGUI's framebuffer, because the frontend calls
|
|
poke_interface only when it is not NULL. The menu rendered every frame into its
|
|
own buffer and had nowhere to put it, and nothing in the trace said so.
|
|
|
|
The check is the size of the struct and the offset of the member the hand-over
|
|
needs, taken the same way from both sides: compiled from the configured tree's
|
|
header with the frontend's own defines, and read out of the object the title
|
|
actually links.
|
|
"""
|
|
|
|
obj = ROOT / "build" / "obj" / "src_video_ps5.cpp.o"
|
|
|
|
def setUp(self) -> None:
|
|
if not self.obj.is_file():
|
|
self.skipTest(f"{self.obj} is not built; run tools/build-title.sh")
|
|
|
|
def frontend_layout(self) -> dict[str, int]:
|
|
defines = frontend_defines()
|
|
program = """
|
|
#include <cstddef>
|
|
#include <cstdio>
|
|
#include <gfx/video_driver.h>
|
|
int main()
|
|
{
|
|
std::printf("size %zu poke %zu wrap %zu ident %zu\\n",
|
|
sizeof(video_driver_t), offsetof(video_driver_t, poke_interface),
|
|
offsetof(video_driver_t, wrap_type_to_enum), offsetof(video_driver_t, ident));
|
|
return 0;
|
|
}
|
|
"""
|
|
with tempfile.TemporaryDirectory() as work:
|
|
directory = Path(work)
|
|
(directory / "layout.cpp").write_text(program, encoding="utf-8")
|
|
binary = directory / "layout"
|
|
done = subprocess.run(
|
|
[cxx(), "-std=c++20", "-O0", "-I", str(ROOT / "build" / "ra-conf"),
|
|
"-I", str(ROOT / "vendor" / "retroarch"),
|
|
"-I", str(ROOT / "vendor" / "retroarch" / "libretro-common" / "include"),
|
|
*defines, "-o", str(binary), str(directory / "layout.cpp")],
|
|
capture_output=True, text=True)
|
|
if done.returncode != 0:
|
|
raise AssertionError(
|
|
f"the header did not compile with the frontend's own defines, which is "
|
|
f"itself the fault this test looks for:\n{done.stderr}")
|
|
output = subprocess.run([str(binary)], capture_output=True, text=True, check=True).stdout
|
|
fields = output.split()
|
|
return {fields[i]: int(fields[i + 1]) for i in range(0, len(fields), 2)}
|
|
|
|
def test_the_title_table_is_as_large_as_the_frontend_struct(self) -> None:
|
|
layout = self.frontend_layout()
|
|
done = subprocess.run(["nm", "-S", "--defined-only", str(self.obj)],
|
|
capture_output=True, text=True, check=True)
|
|
match = re.search(r"^([0-9a-f]+)\s+([0-9a-f]+)\s+\S+\s+video_ps5$", done.stdout, re.M)
|
|
self.assertIsNotNone(match, "the title object no longer defines video_ps5")
|
|
size = int(match.group(2), 16)
|
|
self.assertEqual(
|
|
size, layout["size"],
|
|
f"video_ps5 is {size} bytes in the title's object and video_driver_t is "
|
|
f"{layout['size']} bytes in the frontend. The two are compiled with different "
|
|
f"feature defines, so every member after the first difference is read from the "
|
|
f"wrong offset - which is how poke_interface came back NULL and the menu had "
|
|
f"nowhere to hand its framebuffer")
|
|
|
|
def test_the_member_that_carries_the_menu_is_at_the_right_offset(self) -> None:
|
|
"""Where the hand-over function sits in the table, in the object's own bytes.
|
|
|
|
The size test above says the two layouts agree in length; this says they
|
|
agree on where poke_interface is. ps5_get_poke_interface is internal and
|
|
cannot be looked up by name, so the table's relocations are read instead:
|
|
each 64-bit slot that points into the driver's own code is a member the
|
|
compiler filled in, and their offsets are the members' offsets. One of them
|
|
has to be the offset the frontend will use to make the menu's hand-over
|
|
work, and the member after it must be the never-called one - a table whose
|
|
members are shifted by even one slot has both wrong.
|
|
"""
|
|
layout = self.frontend_layout()
|
|
done = subprocess.run(["readelf", "-rW", str(self.obj)], capture_output=True,
|
|
text=True, check=True)
|
|
section = re.search(
|
|
r"Relocation section '\.rela\.data\.video_ps5' at.*?\n(.*?)\n\n", done.stdout, re.S)
|
|
self.assertIsNotNone(section, "the title object no longer defines a video_ps5 table")
|
|
filled = {}
|
|
for line in section.group(1).splitlines():
|
|
fields = line.split()
|
|
if len(fields) >= 5 and fields[2] == "R_X86_64_64":
|
|
filled[int(fields[0], 16)] = fields[4]
|
|
|
|
self.assertIn(
|
|
layout["poke"], filled,
|
|
f"nothing is written at the offset the frontend reads poke_interface from "
|
|
f"({layout['poke']:#x}); the members this table does fill are "
|
|
f"{sorted(hex(offset) for offset in filled)}. The menu hands its framebuffer over "
|
|
f"through that member and nowhere else")
|
|
self.assertIn("ps5_get_poke_interface", filled[layout["poke"]],
|
|
"the member at the frontend's poke_interface offset is not this driver's "
|
|
"hand-over function")
|
|
self.assertNotIn(
|
|
layout["wrap"], filled,
|
|
"wrap_type_to_enum is documented as never called and must stay NULL, so a value "
|
|
"there means the frontend is reading a different member than this table was "
|
|
"written for")
|
|
|
|
|
|
class FrameLayout(unittest.TestCase):
|
|
"""The tiled frame addressing maps every pixel somewhere of its own."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
cls.table = build_offset_table()
|
|
|
|
@classmethod
|
|
def tearDownClass(cls) -> None:
|
|
cls.table = None
|
|
|
|
def test_no_two_pixels_share_an_offset(self) -> None:
|
|
offsets = list(self.table.values())
|
|
self.assertEqual(len(offsets), len(set(offsets)),
|
|
"two sampled pixels map to the same frame offset")
|
|
|
|
def test_offsets_stay_inside_the_frame(self) -> None:
|
|
for (x, y), offset in self.table.items():
|
|
self.assertLess(offset, FRAME_BYTES,
|
|
f"pixel ({x}, {y}) writes past the frame at {offset:#x}")
|
|
|
|
def test_the_whole_frame_matches_the_renderer_that_works(self) -> None:
|
|
"""The port's addressing is the sibling's addressing, pixel for pixel.
|
|
|
|
The readback on the console cannot answer this question: it reads the
|
|
frame back through the same function that wrote it, so it reports zero
|
|
wrong pixels whether the formula is right or wrong. Only a comparison
|
|
against a formula whose output has been seen on the screen can, and that
|
|
is ../PS5_Vulkan's. Every one of the 2,073,600 offsets is compared, so a
|
|
disagreement that starts at one pixel cannot hide.
|
|
"""
|
|
port = build_full_offset_table(tiled_offset_body())
|
|
reference = build_full_offset_table(reference_tiled_offset_body())
|
|
self.assertEqual(len(port), len(reference))
|
|
if port != reference:
|
|
offenders = [i for i, (a, b) in enumerate(zip(port, reference)) if a != b]
|
|
first = offenders[0] // 8
|
|
x, y = first % FRAME_WIDTH, first // FRAME_WIDTH
|
|
raise AssertionError(
|
|
f"the frame layout disagrees with ../PS5_Vulkan's at {len(offenders)} byte "
|
|
f"positions; the first is pixel ({x}, {y})")
|
|
|
|
def test_sampled_values_are_pinned(self) -> None:
|
|
"""The exact arithmetic, pinned.
|
|
|
|
These are the offsets the current formula produces. They were read off a
|
|
run of it rather than derived here: the point is not that they are
|
|
independently correct but that they are recorded, so changing the formula
|
|
changes this table and the change shows up in a diff instead of on a
|
|
television. The two tests above are the ones that carry the real
|
|
requirement.
|
|
"""
|
|
pinned = {
|
|
(0, 0): 0x0,
|
|
(1, 0): 0x4,
|
|
(127, 0): 0xaf8c,
|
|
(128, 0): 0x10000,
|
|
(1919, 0): 0xeaf8c,
|
|
(0, 1): 0x10,
|
|
(0, 127): 0x5f70,
|
|
(0, 128): 0xf0000,
|
|
(0, 1079): 0x780670,
|
|
(1918, 1078): 0x86a9e8,
|
|
}
|
|
for key, expected in pinned.items():
|
|
with self.subTest(pixel=key):
|
|
self.assertEqual(self.table[key], expected,
|
|
f"the frame layout changed at {key}: "
|
|
f"{self.table[key]:#x} instead of {expected:#x}")
|
|
|
|
|
|
class LinkedDriver(unittest.TestCase):
|
|
"""What linking ../PS5_Vulkan's driver into this title needs and used to lack.
|
|
|
|
Two facts, both of which failed a link that had already compiled: the
|
|
libraries' imports are declared, and the layout defines the boundary symbols
|
|
libunwind looks for.
|
|
"""
|
|
|
|
vulkan = Path(os.environ.get("PS5_VULKAN_DIR", ROOT.parent / "PS5_Vulkan"))
|
|
archives = (
|
|
vulkan / "build/driver/ps5/libps5vk.ps5.a",
|
|
vulkan / "build/driver/ps5/libpsbc_driver.ps5.a",
|
|
vulkan / ".deps/native/vulkan-runtime/lib/libvk_runtime.ps5.a",
|
|
)
|
|
stubs = (
|
|
ROOT / "tooling/ps5-stubs/agc_link_stub.c",
|
|
ROOT / "tooling/ps5-stubs/agc_driver_link_stub.c",
|
|
)
|
|
|
|
def nm(self) -> str | None:
|
|
"""The SDK's llvm-nm, or any nm that can read these archives."""
|
|
sdk = ROOT / ".deps/native/ps5-payload-sdk/bin/llvm-nm"
|
|
if sdk.is_file():
|
|
return str(sdk)
|
|
return shutil.which("llvm-nm") or shutil.which("nm")
|
|
|
|
def test_every_agc_entry_point_the_driver_calls_is_declared(self) -> None:
|
|
"""The AGC stubs cover the driver's whole import surface.
|
|
|
|
The console provides libSceAgc and libSceAgcDriver and the SDK ships no
|
|
stub for either, so tooling/ps5-stubs/ declares what the driver calls.
|
|
A missing declaration is not a warning: it stops the link, which is how
|
|
the first Vulkan build of this port ended, on twelve entry points at once.
|
|
The lists are compared rather than a count, because rebuilding the driver
|
|
can add one.
|
|
"""
|
|
if not all(archive.is_file() for archive in self.archives):
|
|
self.skipTest("the driver archives are not built; run ../PS5_Vulkan/tools/build-driver.sh")
|
|
nm = self.nm()
|
|
if nm is None:
|
|
self.skipTest("no llvm-nm or nm on PATH to read the archives with")
|
|
|
|
declared = set()
|
|
# A definition line starts with its return type; a comment that mentions
|
|
# one of these names starts with an asterisk, which is the difference.
|
|
definition = re.compile(r"^[A-Za-z_][\w ]*?\**\s*(sceAgc\w+)\s*\(", re.M)
|
|
for stub in self.stubs:
|
|
declared |= set(definition.findall(stub.read_text(encoding="utf-8")))
|
|
self.assertTrue(declared, "the AGC stubs declare nothing at all")
|
|
|
|
referenced = set()
|
|
for archive in self.archives:
|
|
done = subprocess.run([nm, "-u", str(archive)], capture_output=True, text=True, check=True)
|
|
for line in done.stdout.splitlines():
|
|
fields = line.split()
|
|
# ` U sceAgcCreateShader`, and a member heading
|
|
# line that ends in a colon rather than a symbol name.
|
|
if fields and fields[-1].startswith("sceAgc"):
|
|
referenced.add(fields[-1])
|
|
self.assertTrue(referenced, "no AGC entry point is referenced at all; the archives changed shape")
|
|
|
|
missing = referenced - declared
|
|
self.assertEqual(missing, set(),
|
|
f"the driver calls {sorted(missing)}, which tooling/ps5-stubs/ does not declare")
|
|
|
|
def test_the_linked_title_defines_the_unwind_boundaries(self) -> None:
|
|
"""libunwind finds the unwind tables through four boundary symbols.
|
|
|
|
The shader compiler is C++ and links the SDK's libunwind, which cannot use
|
|
dl_iterate_phdr in a title, so tooling/native/ps5-pie.ld provides the four
|
|
symbols the sibling project's tooling/psbc/ps5-pie-unwind.ld defines. The
|
|
check is on the linked image rather than on the script, so regenerating
|
|
the script from the boilerplate - which is how they went missing - fails
|
|
here instead of at the next link.
|
|
"""
|
|
image = ROOT / "build" / "llvm-pie.elf"
|
|
if not image.is_file():
|
|
self.skipTest("nothing linked; run tools/build-title.sh")
|
|
nm = self.nm()
|
|
if nm is None:
|
|
self.skipTest("no llvm-nm or nm on PATH to read the image with")
|
|
done = subprocess.run([nm, str(image)], capture_output=True, text=True, check=True)
|
|
defined = {line.split()[-1] for line in done.stdout.splitlines() if len(line.split()) >= 2}
|
|
for symbol in ("__eh_frame_start", "__eh_frame_end",
|
|
"__eh_frame_hdr_start", "__eh_frame_hdr_end"):
|
|
self.assertIn(symbol, defined,
|
|
f"{symbol} is not in the linked image; the layout no longer provides it")
|
|
|
|
|
|
class ProbeSet(unittest.TestCase):
|
|
"""The probe script's inserts are insertions, and are applied safely.
|
|
|
|
tools/apply-runtime-probes.py writes `insert + anchor`, so an insert that ends
|
|
with its own anchor duplicates that line - which is how a probe broke the build
|
|
three times in one session (a duplicated `vkCreateImage`, a duplicated
|
|
`switch (runloop_check_state(`, a duplicated
|
|
`vulkan_filter_chain_build_offscreen_passes(`). It is cheaper to check than to
|
|
find again.
|
|
"""
|
|
|
|
script = ROOT / "tools/apply-runtime-probes.py"
|
|
|
|
def probes(self) -> list[tuple[str, str, str, str, str]]:
|
|
import ast
|
|
|
|
tree = ast.parse(self.script.read_text(encoding="utf-8"))
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Assign) and getattr(node.targets[0], "id", "") == "PROBES":
|
|
return ast.literal_eval(node.value)
|
|
raise AssertionError(f"{self.script} no longer defines PROBES")
|
|
|
|
def test_no_insert_contains_its_own_anchor(self) -> None:
|
|
"""An insert that contains its anchor duplicates whatever the anchor is.
|
|
|
|
The tool writes `insert + anchor`, so any occurrence of the anchor inside
|
|
the insert ends up in the tree twice. Three probes did exactly that in one
|
|
session - including a duplicated `vkCmdBeginRenderPass`, whose second call
|
|
was chased for two rounds as a driver bug: it was this. Checking the whole
|
|
insert, not just its end, is what would have caught it.
|
|
"""
|
|
for name, anchor, insert, marker, _note in self.probes():
|
|
self.assertNotIn(
|
|
anchor, insert,
|
|
f"{marker!r} in {name} contains its own anchor, which would "
|
|
f"duplicate it in the tree")
|
|
|
|
def test_every_probe_has_a_marker_and_a_note(self) -> None:
|
|
for name, _anchor, insert, marker, note in self.probes():
|
|
self.assertIn(marker, insert,
|
|
f"{marker!r} is not in its own insert text")
|
|
self.assertTrue(note.strip(), f"{marker!r} has no note")
|
|
self.assertTrue(insert.endswith("\n"),
|
|
f"{marker!r} does not end with a newline")
|
|
|
|
|
|
class Artifact(unittest.TestCase):
|
|
"""The built folder is a title the console can be asked to run."""
|
|
|
|
dist = ROOT / "dist" / "PPSA99169"
|
|
|
|
def setUp(self) -> None:
|
|
if not (self.dist / "eboot.bin").is_file():
|
|
self.skipTest("nothing built; run tools/build-title.sh")
|
|
|
|
def test_eboot_is_a_fake_self(self) -> None:
|
|
magic = (self.dist / "eboot.bin").read_bytes()[:4]
|
|
self.assertEqual(magic, bytes.fromhex("4f153d1d"),
|
|
"eboot.bin must be a signed fake self, not a raw ELF")
|
|
|
|
def test_eboot_carries_a_64_bit_x86_64_elf_inside_it(self) -> None:
|
|
"""The container's own header, and the ELF program image it wraps.
|
|
|
|
eboot.bin is not an ELF, so its first bytes are not an ELF header: it is a
|
|
fake self, a small header followed by a table of segments, and the ELF
|
|
program image is written into the first segment. Reading the file as an
|
|
ELF - which an earlier version of this test did - fails on the container's
|
|
second byte and proves nothing about the program inside it.
|
|
|
|
What the container's own fields mean is deliberately not decoded here.
|
|
Their layout belongs to the pipeline's self writer
|
|
(tooling/native/self_container.cpp), and a test that restates it would be
|
|
a second, drifting copy - as the two attempts before this one were. What
|
|
is checked instead is what a wrong build would break: a real ELF image is
|
|
inside the file, it is 64-bit x86-64, and it declares an entry point.
|
|
"""
|
|
blob = (self.dist / "eboot.bin").read_bytes()
|
|
self.assertEqual(blob[:4], bytes.fromhex("4f153d1d"))
|
|
# No size is asserted. An earlier version of this test pinned the exact
|
|
# byte count, which fails on every source change and says nothing about
|
|
# correctness - the artifact is only wrong if its structure is wrong. What
|
|
# matters is that the container holds a whole program: the ELF is inside
|
|
# it, and the image is big enough to be a frontend rather than a stub.
|
|
self.assertGreater(len(blob), 4_000_000,
|
|
"the image is too small to be a frontend with RGUI in it")
|
|
|
|
offset = blob.find(b"\x7fELF")
|
|
self.assertNotEqual(offset, -1, "no ELF program image inside the container")
|
|
image = blob[offset:]
|
|
self.assertEqual(image[4], 2, "the program image is not 64-bit")
|
|
self.assertEqual(struct.unpack_from("<H", image, 18)[0], 0x3E,
|
|
"the program image is not x86-64")
|
|
entry = struct.unpack_from("<Q", image, 24)[0]
|
|
self.assertGreater(entry, 0, "the program image declares no entry point")
|
|
self.assertLess(entry, 0x1000,
|
|
"the program image's entry point is not near its start")
|
|
|
|
def test_param_json_names_this_folder(self) -> None:
|
|
import json
|
|
param = json.loads((self.dist / "sce_sys" / "param.json").read_text(encoding="utf-8"))
|
|
self.assertEqual(param["titleId"], self.dist.name)
|
|
self.assertRegex(param["contentId"], rf"PPSA\d{{5}}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|