feat(tools): add progress map for system libraries and shader instructions

tools/progress.py counts implemented prx functions and RDNA instructions
recognized by the shader recompiler, and renders treemaps, badges and an
index page. The Progress workflow deploys them to GitHub Pages on every
push to main, so nothing generated is committed to the repository.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
adriadam10 2026-09-27 18:21:34 +02:00
parent 9958d26f4f
commit 3c6b0af6f7
5 changed files with 1477 additions and 0 deletions

30
.github/workflows/progress.yml vendored Normal file
View File

@ -0,0 +1,30 @@
name: Progress
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
progress:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v7
- run: python3 tools/progress.py out
- uses: actions/upload-pages-artifact@v5
with:
path: out
- id: deployment
uses: actions/deploy-pages@v5

View File

@ -1,5 +1,11 @@
# About
[![libraries](https://boykopovar.github.io/AnyPS5/badge-libraries.svg)](https://boykopovar.github.io/AnyPS5/) [![shaders](https://boykopovar.github.io/AnyPS5/badge-shaders.svg)](https://boykopovar.github.io/AnyPS5/)
[![progress map](https://boykopovar.github.io/AnyPS5/progress.svg)](https://boykopovar.github.io/AnyPS5/)
<sub>* System libraries: percentage of the functions known to the project so far (declared in [core/libs/prx](core/libs/prx)), not of every PS5 system function. The total grows as more functions are declared.</sub>
Tool for automatic executables porting to Linux and Windows.
Includes a [relinker](core/relinker) that converts executable to the target system's native format and implementations of [system prx libraries](core/libs/prx) suitable for dynamic linking. No emulation or separate runtime process.
@ -14,6 +20,10 @@ The [shader recompiler](core/shader/recompiler/Recompiler.cpp) successfully prod
The real game reaches the logo, main menu, and [gameplay](https://gist.github.com/user-attachments/assets/81d28e9b-c237-4545-b2ca-720071129816) with audio.
[Progress](https://boykopovar.github.io/AnyPS5/), generated by [tools/progress.py](tools/progress.py) on every push to `main`:
- **System libraries\***: each block is a [prx library](core/libs/prx) sized by its functions; green is implemented, blue still calls `NotImplemented`. The total only includes functions already declared in the project, not every function of the PS5 firmware.
- **GPU shader instructions**: each block is an instruction encoding of the AMD RDNA 1 + RDNA 2 [instruction list](tools/rdna_isa.txt); green is recognized by the [shader recompiler](core/shader/recompiler).
[Technical debt of the project](docs/TechnicalDebt.md), [code style conventions](docs/CONVENTIONS.md)
## Build

27
tools/README.md Normal file
View File

@ -0,0 +1,27 @@
# Tools
## progress.py
Generates the progress map shown in the main [README](../README.md).
```sh
python3 tools/progress.py <output-dir>
```
Requires only Python 3.9+ with the standard library. It writes:
- `progress.svg`: treemaps for system libraries and GPU shader instructions
- `badge-libraries.svg`, `badge-shaders.svg`: percentage badges
- `index.html`: the map with per-library and per-encoding tables
- `progress.json`: raw numbers
The [Progress workflow](../.github/workflows/progress.yml) runs it on every push to `main` and deploys the output to GitHub Pages, so nothing generated is committed to the repository.
### What is measured
- **System libraries**: functions defined in [core/libs/prx](../core/libs/prx). A function is implemented when it no longer calls `NotImplemented_nid_no_patch`. The total only includes functions already declared in the project, not every function of the PS5 firmware.
- **GPU shader instructions**: instructions from [rdna_isa.txt](rdna_isa.txt) that appear in `RdnaOpcode` of the [shader recompiler](../core/shader/recompiler/RdnaDecoder). FLAT opcodes also count for their GLOBAL and SCRATCH forms, since the decoder accepts all three segments. Decoded opcodes missing from AMD's public list are not counted.
## rdna_isa.txt
The AMD RDNA 1 + RDNA 2 instruction list (name and primary encoding), extracted from the [AMD Machine-Readable GPU ISA Specification](https://gpuopen.com/machine-readable-isa/) (MIT License).

241
tools/progress.py Normal file
View File

@ -0,0 +1,241 @@
import argparse
import json
import os
import re
from html import escape
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PRX = ROOT / "core" / "libs" / "prx"
OPCODES = ROOT / "core" / "shader" / "recompiler" / "RdnaDecoder" / "include" / "RdnaDecoder" / "RdnaOpcode.hpp"
ISA = Path(__file__).resolve().parent / "rdna_isa.txt"
SOURCE = f'https://github.com/{os.environ.get("GITHUB_REPOSITORY", "boykopovar/AnyPS5")}/blob/main'
DEFINITION = re.compile(r"\bAPS5_VABI\s+(\w+)\s*\([^;{]*\)\s*(?:noexcept\s*)?\{")
STUB = "NotImplemented_nid_no_patch"
FLAT_SEGMENTS = ("GLOBAL_", "SCRATCH_")
OPCODE_SENTINELS = {"Invalid", "Count", "Unknown", "Unsupported"}
OPCODE_ALIASES = {
"TBufferLoadFormatX": "TBUFFER_LOAD_FORMAT_X",
"TBufferLoadFormatXyzw": "TBUFFER_LOAD_FORMAT_XYZW",
"ExpMrt": "EXP",
"ExpPos": "EXP",
"ExpParam": "EXP",
"VAddcU32": "V_ADD_CO_CI_U32",
"VMadMixloF16": "V_FMA_MIXLO_F16",
"VMadMixhiF16": "V_FMA_MIXHI_F16",
}
PANEL_WIDTH, GAP, MAP_HEIGHT, HEADER = 495, 10, 280, 30
DONE_COLOR, TODO_COLOR, BORDER, TEXT = "#2ea043", "#1f6feb", "#0d1117", "#ffffff"
def body_end(text, start):
depth = 0
for i in range(start, len(text)):
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
if depth == 0:
return i
return len(text)
def scan_library(path):
done, todo = set(), set()
for source in path.rglob("*.cpp"):
text = source.read_text(errors="ignore")
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 STUB in body else done).add(name)
todo -= done
return {"name": path.name, "label": path.name.removeprefix("libSce"), "done": len(done), "todo": len(todo)}
def summarize(groups):
done = sum(g["done"] for g in groups)
total = done + sum(g["todo"] for g in groups)
return {"done": done, "total": total, "percent": round(100 * done / total, 2) if total else 0, "groups": groups}
def collect_libraries():
return summarize([scan_library(p) for p in sorted(PRX.iterdir()) if p.is_dir()])
def camel(name):
return "".join(part.capitalize() for part in name.split("_"))
def collect_shaders():
isa = {}
for line in ISA.read_text().splitlines():
if line and not line.startswith("#"):
name, encoding = line.split()
isa[name] = encoding
by_camel = {camel(name): name for name in isa}
enum = re.search(r"enum class RdnaOpcode[^{]*\{(.*?)\};", OPCODES.read_text(), re.S).group(1)
opcodes = [o for o in re.findall(r"^\s*([A-Z]\w*)\s*[,=]", enum, re.M) if o not in OPCODE_SENTINELS]
supported, extra = set(), []
for opcode in opcodes:
name = OPCODE_ALIASES.get(opcode) or by_camel.get(opcode)
if name in isa:
supported.add(name)
if name.startswith("FLAT_"):
supported.update(n for n in (s + name.removeprefix("FLAT_") for s in FLAT_SEGMENTS) if n in isa)
else:
extra.append(opcode)
groups = {}
for name, encoding in isa.items():
group = groups.setdefault(encoding, {"name": encoding, "label": encoding, "done": 0, "todo": 0})
group["done" if name in supported else "todo"] += 1
result = summarize(sorted(groups.values(), key=lambda g: g["name"]))
result["extra"] = extra
return result
def worst_ratio(row, side):
area = sum(row)
return max(max(side * side * r / area ** 2, area ** 2 / (side * side * r)) for r in row)
def place(row, x, y, w, h, rects):
thickness = sum(row) / min(w, h)
offset = 0
for area in row:
length = area / thickness
if w >= h:
rects.append((x, y + offset, thickness, length))
else:
rects.append((x + offset, y, length, thickness))
offset += length
return (x + thickness, y, w - thickness, h) if w >= h else (x, y + thickness, w, h - thickness)
def squarify(values, x, y, w, h):
total = sum(values)
areas = [v * w * h / total for v in values]
rects, row = [], []
while areas:
side = min(w, h)
if not row or worst_ratio(row + [areas[0]], side) <= worst_ratio(row, side):
row.append(areas.pop(0))
continue
x, y, w, h = place(row, x, y, w, h, rects)
row = []
if row:
place(row, x, y, w, h, rects)
return rects
def cells(count, x, y, w, h):
if not count:
return []
rows = max(1, min(count, round((count * h / w) ** 0.5)))
result = []
for r in range(rows):
in_row = count // rows + (r < count % rows)
cw = w / in_row
result += [(x + c * cw, y + r * h / rows, cw, h / rows) for c in range(in_row)]
return result
def rect(x, y, w, h, color, stroke=0.5):
return (f'<rect x="{x:.2f}" y="{y:.2f}" width="{w:.2f}" height="{h:.2f}" '
f'fill="{color}" stroke="{BORDER}" stroke-width="{stroke}"/>')
def text(x, y, value, size):
return (f'<text x="{x:.2f}" y="{y:.2f}" font-family="sans-serif" font-size="{size}" fill="{TEXT}" '
f'stroke="{BORDER}" stroke-width="3" paint-order="stroke">{escape(value)}</text>')
def treemap(title, data, left):
groups = sorted((g for g in data["groups"] if g["done"] + g["todo"]), key=lambda g: -(g["done"] + g["todo"]))
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>')
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"/>')
label = group["label"][:int((w - 10) / 7)]
if (label == group["label"] or len(label) >= 3) and h > 22:
parts.append(text(x + 5, y + 16, label, 12))
parts.append("</g>")
return parts
def render(libraries, shaders):
width, height = 2 * PANEL_WIDTH + GAP, HEADER + MAP_HEIGHT
parts = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" width="{width}" height="{height}">',
rect(0, 0, width, height, BORDER, 0)]
parts += treemap("System libraries*", libraries, 0)
parts += treemap("GPU shader instructions", shaders, PANEL_WIDTH + GAP)
parts.append("</svg>")
return "\n".join(parts)
def badge(label, data):
percent = data["percent"]
color = "#4c1" if percent >= 90 else "#97ca00" if percent >= 60 else "#dfb317" if percent >= 30 else "#fe7d37"
value = f"{percent}%"
left, right = 10 + 7 * len(label), 10 + 7 * len(value)
width = left + right
return (f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="20" role="img" aria-label="{label}: {value}">'
f'<rect width="{left}" height="20" fill="#555"/><rect x="{left}" width="{right}" height="20" fill="{color}"/>'
f'<g fill="#fff" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11" text-anchor="middle">'
f'<text x="{left / 2}" y="14">{label}</text><text x="{left + right / 2}" y="14">{value}</text></g></svg>')
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()):
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>')
rows.append(f'<tr><th>Total</th><th>{data["done"]}</th><th>{data["total"]}</th><th>{data["percent"]}%</th></tr>')
rows.append("</table>")
return "\n".join(rows)
def summary(libraries, shaders):
extra = ", ".join(f"<code>{escape(o)}</code>" for o in shaders["extra"])
return "\n".join([
'<!DOCTYPE html>',
'<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">',
'<title>AnyPS5 progress</title>',
'<style>body{font-family:sans-serif;max-width:1000px;margin:auto;padding:16px}img{max-width:100%}'
'table{border-collapse:collapse}th,td{border:1px solid #ccc;padding:2px 8px}td+td,th+th{text-align:right}</style>',
'</head><body>',
"<h1>Progress</h1>",
'<img src="progress.svg" alt="progress">',
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>. "
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>",
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. '
f"Decoded opcodes not present in AMD's public list are not counted: {extra}.</p>",
"</body></html>",
]) + "\n"
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
output = parser.parse_args().output
output.mkdir(parents=True, exist_ok=True)
libraries, shaders = collect_libraries(), collect_shaders()
(output / "progress.json").write_text(json.dumps({"libraries": libraries, "shaders": shaders}, indent=2))
(output / "badge-libraries.svg").write_text(badge("libraries*", libraries))
(output / "badge-shaders.svg").write_text(badge("shaders", shaders))
(output / "progress.svg").write_text(render(libraries, shaders))
(output / "index.html").write_text(summary(libraries, shaders))
print(f'libraries {libraries["done"]}/{libraries["total"]} ({libraries["percent"]}%)')
print(f'shaders {shaders["done"]}/{shaders["total"]} ({shaders["percent"]}%)')

1169
tools/rdna_isa.txt Normal file

File diff suppressed because it is too large Load Diff