feat(tools): mark behaviourally empty bodies in the progress report

Cherry-picked from commit f1be92e95c02be451971fc1a8eec139e738e936d.
This commit is contained in:
Leandro Soares 2026-10-11 11:44:28 +00:00 committed by MarcZX100
parent 0f863c1022
commit 90a82da283
3 changed files with 138 additions and 4 deletions

View File

@ -18,6 +18,8 @@ Comparisons identify functions by library and name. When a head group becomes a
Real removals and transitions back to stubs remain visible. Percentages retain the original snapshot totals, so eliminating duplicate accounting can change a percentage without changing available functionality. Older JSON snapshots without `shared_sources` are accepted.
Implemented definitions are also classified as behaviourally empty when their body holds only `(void)name;` discards and at most one constant return, bare `return;` or `throw`, including a return of a local `static` wrapper that is itself behaviourally empty; wrapper calls whose arguments contain calls do not qualify. Comparisons list newly implemented behaviourally empty functions without changing any count. Older JSON snapshots without `inert_names` are accepted.
## Verification
```sh

View File

@ -17,6 +17,11 @@ CMAKE_SHARED = re.compile(r'^\s*include\(\s*"?\$\{CMAKE_CURRENT_SOURCE_DIR\}/\.\
SOURCE_SHARED = re.compile(r'^\s*#\s*include\s*"prx/([\w.-]+)/[\w./-]+\.cpp"', re.M)
STUB_WRAPPER = re.compile(r"\bstatic\s+(?:\[\[noreturn\]\]\s+)?[\w:<>,\s*&]+?\s+(\w+)\s*\([^;{]*\)\s*\{")
NON_CODE = re.compile(r'"(?:\\.|[^"\\\n])*"|\'(?:\\.|[^\'\\\n])*\'|//[^\n]*|/\*.*?\*/', re.S)
DISCARD = re.compile(r"\(void\)\s*\w+\s*;")
EMPTY_RETURN = re.compile(r"return\s*;")
CONST_RETURN = re.compile(r"return\s+(?:[+-]?\s*(?:0[xX][0-9a-fA-F]+|\d+)[uUlL]*|\w+|\"(?:\\.|[^\"\\])*\")\s*;")
WRAPPER_RETURN = re.compile(r"return\s+(\w+)\s*\(([^()]*)\)\s*;")
BARE_THROW = re.compile(r"throw\b[^;]*;")
FLAT_SEGMENTS = ("GLOBAL_", "SCRATCH_")
OPCODE_SENTINELS = {"Invalid", "Count", "Unknown", "Unsupported"}
OPCODE_ALIASES = {
@ -91,22 +96,73 @@ def stub_calls(text):
return calls
def statements(body):
depth, start, parts = 0, 1, []
for i, character in enumerate(body[1:], 1):
if character == "{":
depth += 1
elif character == "}":
depth -= 1
elif character == ";" and not depth:
parts.append(body[start:i + 1])
start = i + 1
parts.append(body[start:])
return parts
def inert_statement(statement, wrappers):
if EMPTY_RETURN.fullmatch(statement) or CONST_RETURN.fullmatch(statement) or BARE_THROW.fullmatch(statement):
return True
call = WRAPPER_RETURN.fullmatch(statement)
return bool(call) and call.group(1) in wrappers
def inert_body(body, wrappers):
terminals = 0
for statement in statements(body):
statement = statement.strip()
if not statement:
continue
if DISCARD.fullmatch(statement):
continue
if terminals or not inert_statement(statement, wrappers):
return False
terminals += 1
return True
def inert_wrappers(text):
bodies = {m.group(1): text[m.end() - 1:body_end(text, m.end() - 1)] for m in STUB_WRAPPER.finditer(text)}
names = set()
while True:
found = {name for name, body in bodies.items() if name not in names and inert_body(body, names)}
if not found:
return names
names |= found
def scan_library(path):
done, todo = set(), set()
done, todo, inert, live = set(), set(), set(), set()
for source in path.rglob("*.cpp"):
if "tests" in source.relative_to(path).parts:
continue
text = code(source.read_text(errors="ignore"))
calls = stub_calls(text)
wrappers = inert_wrappers(text)
for match in DEFINITION.finditer(text):
name = match.group(1)
if name.endswith("_nid_no_patch"):
continue
body = text[match.end() - 1:body_end(text, match.end() - 1)]
(todo if any(call in body for call in calls) else done).add(name)
if any(call in body for call in calls):
todo.add(name)
continue
done.add(name)
(inert if inert_body(body, wrappers) else live).add(name)
todo -= done
inert -= live
group = {"name": path.name, "label": path.name.removeprefix("libSce"), "done": len(done), "todo": len(todo),
"done_names": sorted(done), "todo_names": sorted(todo)}
"done_names": sorted(done), "todo_names": sorted(todo), "inert_names": sorted(inert)}
if not (done or todo):
cmake = path / "CMakeLists.txt"
texts = [(CMAKE_SHARED, cmake.read_text())] if cmake.is_file() else []
@ -293,7 +349,8 @@ def summary(libraries, shaders):
f'<p>Generated by <a href="{SOURCE}/tools/progress.py">tools/progress.py</a> on every push to <code>main</code>. '
'Raw numbers: <a href="progress.json">progress.json</a>.</p>',
table("System libraries", "Library", libraries),
"<p>A function is implemented when it no longer calls <code>NotImplemented_nid_no_patch</code>. "
"<p>A function is implemented when it no longer calls <code>NotImplemented_nid_no_patch</code>; "
"comparison reports separately mark implemented functions whose body only returns a constant or throws. "
f'The total only includes functions already declared in <a href="{SOURCE}/core/libs/prx">core/libs/prx</a>, '
"not every function exported by the PS5 firmware. Shared-source libraries are counted only at their source library. "
"Libraries with no exports only provide an empty module for titles that load them. "
@ -329,6 +386,7 @@ def compare(title, column, unit, base, head):
base_all, head_all = base_done | names(base, "todo", owners), head_done | names(head, "todo", owners)
implemented, declared = head_done - base_done, head_all - base_all - head_done
regressed, removed = base_done & (head_all - head_done), base_all - head_all
inert = implemented & names(head, "inert", owners)
if not (implemented or declared or regressed or removed or shared):
return []
delta = round(head["percent"] - base["percent"], 2)
@ -338,6 +396,7 @@ def compare(title, column, unit, base, head):
changes = f', {", ".join(counts)} {unit}' if counts else ""
lines = [f'{icon} **{title}**: {head["percent"]}% ({delta:+}%{changes})', ""]
lines += details("✅", "implemented", column, implemented)
lines += details("⚪", "behaviourally empty (constant return or throw)", column, inert)
lines += details("🆕", "declared as stubs", column, declared)
lines += details("⚠️", "went back to stubs", column, regressed)
lines += details("🗑️", "removed", column, removed)

View File

@ -121,6 +121,79 @@ class ProgressTests(unittest.TestCase):
self.assertIn("-1 removed", report)
self.assertIn("Pending", report)
def test_inert_bodies_are_marked_without_changing_counts(self):
with tempfile.TemporaryDirectory() as directory:
library = Path(directory) / "libSceExample"
library.mkdir()
(library / "Export.cpp").write_text(
"static int Support() { return 0; }\n"
"static int Compute(int x) { return x * 2; }\n"
"int APS5_VABI A() { return 0; }\n"
"int APS5_VABI B(const void* p) { (void)p; return SCE_KERNEL_ERROR_EOPNOTSUPP; }\n"
"int APS5_VABI C(void* p) { (void)p; return 0x8A810001; }\n"
'int APS5_VABI D() { throw std::runtime_error("no"); }\n'
"int APS5_VABI E() { return Support(); }\n"
"int APS5_VABI F(int a, int b) { return a + b; }\n"
"int APS5_VABI G() { NotImplemented_nid_no_patch(__func__); return 0; }\n"
"int APS5_VABI H(int a) { return Compute(a); }\n")
group = progress.scan_library(library)
self.assertEqual(group["inert_names"], ["A", "B", "C", "D", "E"])
self.assertEqual(group["done_names"], ["A", "B", "C", "D", "E", "F", "H"])
self.assertEqual(group["todo_names"], ["G"])
def test_compare_marks_behaviourally_empty_implementations(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
library = root / "libSceExample"
library.mkdir()
kept = (
"int APS5_VABI Ready() { return 0; }\n"
"int APS5_VABI Stubbed() { NotImplemented_nid_no_patch(__func__); return 0; }\n"
"static int Compute(int x) { return x * 2; }\n"
"int APS5_VABI Wrapped(int a) { return Compute(a); }\n")
added = (
"static int Support() { return 0; }\n"
"int APS5_VABI A() { return 0; }\n"
"int APS5_VABI B(const void* p) { (void)p; return SCE_KERNEL_ERROR_EOPNOTSUPP; }\n"
"int APS5_VABI C(void* p) { (void)p; return 0x8A810001; }\n"
'int APS5_VABI D() { throw std::runtime_error("no"); }\n'
"int APS5_VABI E() { return Support(); }\n"
"int APS5_VABI F(int a, int b) { return a + b; }\n")
(library / "Export.cpp").write_text(kept)
with patch.object(progress, "PRX", root):
base = progress.collect_libraries()
for group in base["groups"]:
del group["inert_names"]
(library / "Export.cpp").write_text(kept + added)
with patch.object(progress, "PRX", root):
head = progress.collect_libraries()
report = "\n".join(progress.compare("Libraries", "Library", "functions", base, head))
self.assertIn("+6 implemented", report)
self.assertIn("<summary>⚪ 5 behaviourally empty (constant return or throw)</summary>", report)
empty = report.split("behaviourally empty (constant return or throw)</summary>")[1]
self.assertEqual([line for line in empty.splitlines() if line.startswith("|")],
["| Library | Name |", "| - | - |"]
+ [f"| libSceExample | `{name}` |" for name in "ABCDE"])
self.assertEqual(progress.compare("Libraries", "Library", "functions", head, head), [])
(library / "Export.cpp").write_text("int APS5_VABI Ready() { return 0; }\n")
with patch.object(progress, "PRX", root):
before = progress.collect_libraries()
(library / "Export.cpp").write_text(
"int APS5_VABI Ready() { return 0; }\nint APS5_VABI F(int a, int b) { return a + b; }\n")
with patch.object(progress, "PRX", root):
after = progress.collect_libraries()
report = "\n".join(progress.compare("Libraries", "Library", "functions", before, after))
self.assertIn("+1 implemented", report)
self.assertNotIn("behaviourally empty", report)
(library / "Export.cpp").write_text(
"int APS5_VABI Ready() { return 0; }\nint APS5_VABI F(int a, int b) { return a + b; }\nint APS5_VABI A() { return 0; }\n")
with patch.object(progress, "PRX", root):
grown = progress.collect_libraries()
report = "\n".join(progress.compare("Libraries", "Library", "functions", after, grown))
self.assertIn("+1 implemented", report)
self.assertIn("| libSceExample | `A` |", report)
self.assertIn("<summary>⚪ 1 behaviourally empty (constant return or throw)</summary>", report)
def test_empty_libraries(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)