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'') def text(x, y, value, size): return (f'{escape(value)}') 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"] 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'{escape(group["name"])}: {group["done"]}/{total} ({100 * group["done"] / total:.0f}%){escape(suffix)}') 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'') 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("") return parts def render(libraries, shaders): width, height = 2 * PANEL_WIDTH + GAP, HEADER + MAP_HEIGHT parts = [f'', rect(0, 0, width, height, BORDER, 0)] parts += treemap("System libraries*", libraries, 0) parts += treemap("GPU shader instructions", shaders, PANEL_WIDTH + GAP) parts.append("") 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'' f'' f'' f'{label}{value}') def table(heading, column, data): rows = [f"

{heading}

", "", f""] 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'') continue total = group["done"] + group["todo"] if not total: rows.append(f'') continue percent = f'{100 * group["done"] / total:.0f}%' rows.append(f'') rows.append(f'') rows.append("
{column}ImplementedTotal%
{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"]}%
") return "\n".join(rows) def summary(libraries, shaders): extra = ", ".join(f"{escape(o)}" for o in shaders["extra"]) return "\n".join([ '', '', 'AnyPS5 progress', '', '', "

Progress

", 'progress', f'

Generated by tools/progress.py on every push to main. ' 'Raw numbers: progress.json.

', table("System libraries", "Library", libraries), "

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.

', table("GPU shader instructions", "Encoding", shaders), f'

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"
\n{icon} {len(items)} {title}\n", f"| {column} | Name |", "| - | - |"] rows += [f"| {group} | `{name}` |" for group, name in sorted(items)[:REPORT_ROWS]] if len(items) > REPORT_ROWS: rows.append(f"| ... | {len(items) - REPORT_ROWS} more |") return rows + ["\n
"] def compare(title, column, unit, base, head): 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 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) 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] 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) lines += details("🔗", "now sharing sources", column, shared) return lines + [""] def report(base, head): lines = compare("System libraries", "Library", "functions", base["libraries"], head["libraries"]) lines += compare("GPU shader instructions", "Encoding", "instructions", base["shaders"], head["shaders"]) return "\n".join(lines) if __name__ == "__main__": if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") parser = argparse.ArgumentParser() parser.add_argument("output", type=Path, nargs="?") parser.add_argument("--root", type=Path, help="source tree to measure instead of the one containing this script") parser.add_argument("--compare", type=Path, nargs=2, metavar=("BASE", "HEAD"), help="print a markdown report of the changes between two progress.json files") args = parser.parse_args() if args.compare: base, head = (json.loads(path.read_text(encoding="utf-8")) for path in args.compare) print(report(base, head), end="") raise SystemExit if not args.output: parser.error("the output directory is required") if args.root: PRX = args.root / "core" / "libs" / "prx" OPCODES = args.root / OPCODES.relative_to(ROOT) output = 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 / "progress.html").write_text(summary(libraries, shaders)) print(f'libraries {libraries["done"]}/{libraries["total"]} ({libraries["percent"]}%)') print(f'shaders {shaders["done"]}/{shaders["total"]} ({shaders["percent"]}%)')