fix(progress): report shared-source libraries
This commit is contained in:
parent
4764e59c37
commit
efdbbf1a70
|
|
@ -72,3 +72,11 @@ add_subdirectory(core/Decoder/Jpeg)
|
|||
add_subdirectory(core/Decoder/Png)
|
||||
add_subdirectory(core/libs)
|
||||
add_subdirectory(core/relinker)
|
||||
|
||||
if(BUILD_TESTING)
|
||||
find_package(Python3 QUIET COMPONENTS Interpreter)
|
||||
if(Python3_Interpreter_FOUND)
|
||||
add_test(NAME progress_report COMMAND ${Python3_EXECUTABLE}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_progress.py)
|
||||
endif()
|
||||
endif()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
# Progress reporting
|
||||
|
||||
[`tools/progress.py`](../../tools/progress.py) generates `progress.json`, HTML, SVG maps and badges. The [Progress workflow](../../.github/workflows/progress.yml) publishes them after pushes to `main`. The [Progress report workflow](../../.github/workflows/progress-report.yml) compares the base and head trees of pull requests.
|
||||
|
||||
## Library counts
|
||||
|
||||
The scanner reads `APS5_VABI` function definitions under `core/libs/prx/<library>/`. A definition is pending when its body calls `NotImplemented_nid_no_patch`, directly or through a recognized local stub wrapper. Other definitions are counted as implemented. These counts describe declared functions, not every firmware export, verified ABI compatibility or playable games. Silent behavior and unverified assumptions remain in [Technical debt](TechnicalDebt.md).
|
||||
|
||||
A library with no scanned definitions can include a sibling library's source builder. The scanner recognizes literal `include(${CMAKE_CURRENT_SOURCE_DIR}/../<library>/<file>.cmake)` references, including quoted paths. It does not evaluate arbitrary CMake expressions.
|
||||
|
||||
For these wrappers, the JSON group adds `shared_sources` with the source library's directory name. Its own counts and name lists remain empty. The HTML table links to the source library instead of showing `0/0`; the source library's map tooltip lists its wrappers. Global totals count the definitions at the source library once.
|
||||
|
||||
## Comparisons
|
||||
|
||||
Comparisons identify functions by library and name. When a head group becomes a shared-source wrapper, its previous definitions are compared under the source library's identity. Removing a duplicate implementation is reported as sharing sources, without reporting the retained functions as removed.
|
||||
|
||||
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.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
python3 tools/tests/test_progress.py
|
||||
python3 tools/progress.py /tmp/anyps5-progress
|
||||
```
|
||||
|
||||
The regression creates a source library and a copied wrapper, moves the wrapper to a shared builder, checks output labels and totals, then verifies real regressions and removals are still reported. With `BUILD_TESTING=ON` and Python available, CTest registers it as `progress_report`.
|
||||
|
|
@ -95,8 +95,14 @@ def scan_library(path):
|
|||
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)
|
||||
todo -= done
|
||||
return {"name": path.name, "label": path.name.removeprefix("libSce"), "done": len(done), "todo": len(todo),
|
||||
"done_names": sorted(done), "todo_names": sorted(todo)}
|
||||
group = {"name": path.name, "label": path.name.removeprefix("libSce"), "done": len(done), "todo": len(todo),
|
||||
"done_names": sorted(done), "todo_names": sorted(todo)}
|
||||
cmake = path / "CMakeLists.txt"
|
||||
if not (done or todo) and cmake.is_file():
|
||||
shared = re.search(r'^\s*include\(\s*"?\$\{CMAKE_CURRENT_SOURCE_DIR\}/\.\./([\w.-]+)/[\w.-]+\.cmake"?\s*\)', cmake.read_text(), re.M)
|
||||
if shared and (path.parent / shared[1]).is_dir() and shared[1] != path.name:
|
||||
group["shared_sources"] = shared[1]
|
||||
return group
|
||||
|
||||
|
||||
def summarize(groups):
|
||||
|
|
@ -205,7 +211,9 @@ def treemap(title, data, left):
|
|||
parts = [text(left + 4, 21, f'{title}: {data["percent"]}% ({data["done"]}/{data["total"]})', 16)]
|
||||
for group, (x, y, w, h) in zip(groups, squarify([g["done"] + g["todo"] for g in groups], left, HEADER, PANEL_WIDTH, MAP_HEIGHT)):
|
||||
total = group["done"] + group["todo"]
|
||||
parts.append(f'<g><title>{escape(group["name"])}: {group["done"]}/{total} ({100 * group["done"] / total:.0f}%)</title>')
|
||||
shared = [g["name"] for g in data["groups"] if g.get("shared_sources") == group["name"]]
|
||||
suffix = "; shared by " + ", ".join(shared) if shared else ""
|
||||
parts.append(f'<g><title>{escape(group["name"])}: {group["done"]}/{total} ({100 * group["done"] / total:.0f}%){escape(suffix)}</title>')
|
||||
for i, cell in enumerate(cells(total, x + 1, y + 1, w - 2, h - 2)):
|
||||
parts.append(rect(*cell, DONE_COLOR if i < group["done"] else TODO_COLOR))
|
||||
parts.append(f'<rect x="{x:.2f}" y="{y:.2f}" width="{w:.2f}" height="{h:.2f}" fill="none" stroke="{BORDER}" stroke-width="2"/>')
|
||||
|
|
@ -242,6 +250,11 @@ def table(heading, column, data):
|
|||
rows = [f"<h2>{heading}</h2>", "<table>",
|
||||
f"<tr><th>{column}</th><th>Implemented</th><th>Total</th><th>%</th></tr>"]
|
||||
for group in sorted(data["groups"], key=lambda g: g["name"].lower()):
|
||||
if "shared_sources" in group:
|
||||
owner = escape(group["shared_sources"])
|
||||
rows.append(f'<tr><td>{escape(group["name"])}</td><td colspan="3">Shared sources: '
|
||||
f'<a href="{SOURCE}/core/libs/prx/{owner}">{owner}</a></td></tr>')
|
||||
continue
|
||||
total = group["done"] + group["todo"]
|
||||
percent = f'{100 * group["done"] / total:.0f}%' if total else "-"
|
||||
rows.append(f'<tr><td>{escape(group["name"])}</td><td>{group["done"]}</td><td>{total}</td><td>{percent}</td></tr>')
|
||||
|
|
@ -266,7 +279,8 @@ def summary(libraries, shaders):
|
|||
table("System libraries", "Library", libraries),
|
||||
"<p>A function is implemented when it no longer calls <code>NotImplemented_nid_no_patch</code>. "
|
||||
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.</p>",
|
||||
"not every function exported by the PS5 firmware. Shared-source libraries are counted only at their source library. "
|
||||
f'<a href="{SOURCE}/docs/dev/PROGRESS.md">Counting rules</a>.</p>',
|
||||
table("GPU shader instructions", "Encoding", shaders),
|
||||
f'<p>The total is the AMD RDNA 1 + RDNA 2 instruction list (<a href="{SOURCE}/tools/rdna_isa.txt">tools/rdna_isa.txt</a>). '
|
||||
f'An instruction is implemented when the <a href="{SOURCE}/core/shader/recompiler/RdnaDecoder">decoder</a> recognizes it. '
|
||||
|
|
@ -275,8 +289,9 @@ def summary(libraries, shaders):
|
|||
]) + "\n"
|
||||
|
||||
|
||||
def names(data, state):
|
||||
return {(g["name"], n) for g in data["groups"] for n in g.get(f"{state}_names", [])}
|
||||
def names(data, state, owners=None):
|
||||
owners = owners or {}
|
||||
return {(owners.get(g["name"], g["name"]), n) for g in data["groups"] for n in g.get(f"{state}_names", [])}
|
||||
|
||||
|
||||
def details(icon, title, column, items):
|
||||
|
|
@ -290,21 +305,26 @@ def details(icon, title, column, items):
|
|||
|
||||
|
||||
def compare(title, column, unit, base, head):
|
||||
base_done, head_done = names(base, "done"), names(head, "done")
|
||||
base_all, head_all = base_done | names(base, "todo"), head_done | names(head, "todo")
|
||||
owners = {g["name"]: g["shared_sources"] for g in head["groups"] if "shared_sources" in g}
|
||||
previous = {g["name"]: g.get("shared_sources") for g in base["groups"]}
|
||||
shared = {(name, owner) for name, owner in owners.items() if previous.get(name) != owner}
|
||||
base_done, head_done = names(base, "done", owners), names(head, "done", owners)
|
||||
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
|
||||
if not (implemented or declared or regressed or removed):
|
||||
if not (implemented or declared or regressed or removed or shared):
|
||||
return []
|
||||
delta = round(head["percent"] - base["percent"], 2)
|
||||
icon = "📈" if delta > 0 else "📉" if delta < 0 else "➖"
|
||||
counts = [f"{n:+} {label}" for n, label in ((len(implemented), "implemented"), (len(declared), "declared"),
|
||||
(-len(regressed), "reverted"), (-len(removed), "removed")) if n]
|
||||
lines = [f'{icon} **{title}**: {head["percent"]}% ({delta:+}%, {", ".join(counts)} {unit})', ""]
|
||||
changes = f', {", ".join(counts)} {unit}' if counts else ""
|
||||
lines = [f'{icon} **{title}**: {head["percent"]}% ({delta:+}%{changes})', ""]
|
||||
lines += details("✅", "implemented", column, implemented)
|
||||
lines += details("🆕", "declared as stubs", column, declared)
|
||||
lines += details("⚠️", "went back to stubs", column, regressed)
|
||||
lines += details("🗑️", "removed", column, removed)
|
||||
lines += details("🔗", "now sharing sources", column, shared)
|
||||
return lines + [""]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import progress
|
||||
|
||||
|
||||
class ProgressTests(unittest.TestCase):
|
||||
def test_shared_source_refactor_preserves_functions(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
owner = root / "libSceExample"
|
||||
alias = root / "libSceExample.native"
|
||||
empty = root / "libSceEmpty"
|
||||
for path in (owner, alias, empty):
|
||||
path.mkdir()
|
||||
source = "int APS5_VABI Ready() { return 0; }\nint APS5_VABI Pending() { NotImplemented_nid_no_patch(__func__); }\n"
|
||||
(owner / "Export.cpp").write_text(source)
|
||||
(alias / "Export.cpp").write_text(source)
|
||||
(owner / "Library.cmake").write_text("add_library(example SHARED Export.cpp)\n")
|
||||
(empty / "CMakeLists.txt").write_text('# include(${CMAKE_CURRENT_SOURCE_DIR}/../libSceExample/Library.cmake)\n')
|
||||
with patch.object(progress, "PRX", root):
|
||||
base = progress.collect_libraries()
|
||||
self.assertEqual((base["done"], base["total"]), (2, 4))
|
||||
(alias / "Export.cpp").unlink()
|
||||
(alias / "CMakeLists.txt").write_text('include("${CMAKE_CURRENT_SOURCE_DIR}/../libSceExample/Library.cmake")\n')
|
||||
head = progress.collect_libraries()
|
||||
self.assertEqual((head["done"], head["total"]), (1, 2))
|
||||
groups = {group["name"]: group for group in head["groups"]}
|
||||
self.assertEqual(groups[alias.name]["shared_sources"], owner.name)
|
||||
self.assertNotIn("shared_sources", groups[empty.name])
|
||||
html = progress.table("Libraries", "Library", head)
|
||||
self.assertIn('colspan="3">Shared sources:', html)
|
||||
self.assertIn("shared by " + alias.name, "\n".join(progress.treemap("Libraries", head, 0)))
|
||||
report = "\n".join(progress.compare("Libraries", "Library", "functions", base, head))
|
||||
self.assertIn("sharing sources", report)
|
||||
self.assertNotIn("removed", report)
|
||||
self.assertNotIn("implemented", report)
|
||||
self.assertEqual(progress.compare("Libraries", "Library", "functions", head, head), [])
|
||||
(owner / "Export.cpp").write_text(source.replace("return 0;", "NotImplemented_nid_no_patch(__func__);"))
|
||||
regressed = progress.collect_libraries()
|
||||
report = "\n".join(progress.compare("Libraries", "Library", "functions", base, regressed))
|
||||
self.assertIn("-1 reverted", report)
|
||||
self.assertIn("Ready", report)
|
||||
(owner / "Export.cpp").write_text("int APS5_VABI Ready() { return 0; }\n")
|
||||
removed = progress.collect_libraries()
|
||||
report = "\n".join(progress.compare("Libraries", "Library", "functions", base, removed))
|
||||
self.assertIn("-1 removed", report)
|
||||
self.assertIn("Pending", report)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in New Issue