import argparse
import json
import os
import re
import sys
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*)?(?:try\s*)?\{")
STUB = "NotImplemented_nid_no_patch"
CALL = re.compile(r"\b(\w+)\s*\(")
CMAKE_SHARED = re.compile(r'^\s*include\(\s*"?\$\{CMAKE_CURRENT_SOURCE_DIR\}/\.\./([\w.-]+)/[\w.-]+\.cmake"?\s*\)', re.M)
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 = {
"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",
}
OPCODE_VARIANTS = {
"SEndpgm": ("S_ENDPGM_SAVED", "S_ENDPGM_ORDERED_PS_DONE", "S_SETKILL"),
"VFmaF32": ("V_FMA_MIX_F32",),
"SAddI32": ("S_ADDK_I32",),
"SCmpEqI32": ("S_CMPK_EQ_I32",),
"SCmpLgI32": ("S_CMPK_LG_I32",),
"SCmpGtI32": ("S_CMPK_GT_I32",),
"SCmpGeI32": ("S_CMPK_GE_I32",),
"SCmpLtI32": ("S_CMPK_LT_I32",),
"SCmpLeI32": ("S_CMPK_LE_I32",),
"SCmpEqU32": ("S_CMPK_EQ_U32",),
"SCmpLgU32": ("S_CMPK_LG_U32",),
"SCmpGtU32": ("S_CMPK_GT_U32",),
"SCmpGeU32": ("S_CMPK_GE_U32",),
"SCmpLtU32": ("S_CMPK_LT_U32",),
"SCmpLeU32": ("S_CMPK_LE_U32",),
"SWaitcnt": ("S_WAITCNT_VSCNT", "S_WAITCNT_VMCNT", "S_WAITCNT_EXPCNT", "S_WAITCNT_LGKMCNT"),
"STtracedata": ("S_TTRACEDATA_IMM",),
"SCbranchCdbg": ("S_CBRANCH_CDBGSYS", "S_CBRANCH_CDBGUSER", "S_CBRANCH_CDBGSYS_OR_USER", "S_CBRANCH_CDBGSYS_AND_USER"),
"VAddI32": ("V_ADD_CO_U32",),
"VSubI32": ("V_SUB_CO_U32",),
"VSubrevI32": ("V_SUBREV_CO_U32",),
"VMacF32": ("V_FMAC_F32",),
"VMadmkF32": ("V_FMAMK_F32",),
"VMadakF32": ("V_FMAAK_F32",),
"VMacLegacyF32": ("V_FMAC_LEGACY_F32",),
"VMadLegacyF32": ("V_FMA_LEGACY_F32",),
"ImageSample": ("IMAGE_SAMPLE_L", "IMAGE_SAMPLE_B", "IMAGE_SAMPLE_C_LZ", "IMAGE_SAMPLE_L_O", "IMAGE_SAMPLE_D_CL_O"),
}
REPORT_ROWS = 100
PANEL_WIDTH, GAP, MAP_HEIGHT, HEADER = 495, 10, 280, 30
DONE_COLOR, TODO_COLOR, BORDER, TEXT = "#2ea043", "#6e7681", "#0d1117", "#ffffff"
def blank(match):
if match.group(0).startswith(('"', "'")):
return "LITERAL"
return re.sub(r"[^\n]", " ", match.group(0))
def code(text):
return NON_CODE.sub(blank, text)
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 stub_calls(text):
calls = {STUB}
for match in STUB_WRAPPER.finditer(text):
body = text[match.end() - 1:body_end(text, match.end() - 1)]
if any(call.group(1) == STUB for call in CALL.finditer(body)):
calls.add(match.group(1))
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, 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)]
if any(call.group(1) in calls for call in CALL.finditer(body)):
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), "inert_names": sorted(inert)}
if not (done or todo):
cmake = path / "CMakeLists.txt"
texts = [(CMAKE_SHARED, cmake.read_text())] if cmake.is_file() else []
texts += [(SOURCE_SHARED, source.read_text(errors="ignore")) for source in sorted(path.glob("*.cpp"))]
owners = [m[1] for pattern, content in texts for m in pattern.finditer(content)]
owners = [o for o in owners if o != path.name and (path.parent / o).is_dir()]
if owners:
group["shared_sources"] = owners[0]
return group
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:
supported.update(name for name in OPCODE_VARIANTS.get(opcode, ()) if name in isa)
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,
"done_names": [], "todo_names": []})
state = "done" if name in supported else "todo"
group[state] += 1
group[f"{state}_names"].append(name)
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 or w <= 0 or h <= 0:
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'
| {column} | Implemented | Total | % |
|---|---|---|---|
| {escape(group["name"])} | Shared sources: ' f'{owner} | ||
| {escape(group["name"])} | No exports | ||
| {escape(group["name"])} | {group["done"]} | {total} | {percent} |
| Total | {data["done"]} | {data["total"]} | {data["percent"]}% |
{escape(o)}" for o in shaders["extra"])
return "\n".join([
'',
'',
'Generated by tools/progress.py on every push to main. '
'Raw numbers: progress.json.
A function is implemented when it no longer calls NotImplemented_nid_no_patch; "
"comparison reports separately mark implemented functions whose body only returns a constant or throws. "
f'The total only includes functions already declared in core/libs/prx, '
"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. "
f'Counting rules.
The total is the AMD RDNA 1 + RDNA 2 instruction list (tools/rdna_isa.txt). ' f'An instruction is implemented when the decoder recognizes it. ' f"Decoded opcodes not present in AMD's public list are not counted: {extra}.
", 'Progress history (one point per day).
', "", ]) + "\n" 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): if not items: return [] rows = [f"