"""The Logbook's progress map, in the style of decomp.dev: one unit per original source file, its functions and how far each has come, plus the headline share of game code decompiled and the Code and Data bars. GET /api/logbook/progress-map answers with one compact JSON document. It is computed once and kept until one of its inputs changes (main's HEAD, the Logbook's symbol table and statistics, the match, emulator and library results, facts, names, source files, globals, or a deferral note); `?have=` answers {"version", "same": true} when the caller already holds the current one, so a page that polls does not download or re-lay-out anything. Definitions follow the Logbook exactly (open-seas tools/progress/progress.py, which writes stats.json): - the symbol table is <[logbook] stats_repo>/re/symbols/pc102.csv: its category and status per function; - counted ("game code") = category game and status not superseded or wontfix; - decompiled = status decompiled, reimplemented or verified, so percent = decompiled game bytes / game bytes, the Logbook's game_bytes_decompiled_percent. Decompiled functions are split further, best first: exact (byte match in the [reverse] match_results), verified (behaves the same in the emulator, diffrun_results, not 'weak'), readable. The rest: deferred (a live DEFERRED note), raw, and, not counted, unsorted, engine (Gamebryo, named only) and library (C runtime, D3DX9, platform wrappers). "results" says which commit the exact and verified tiers are from (harbor.results_refresh keeps them current). Without a stats_repo the categories come from names.csv / facts and readable from the src headers (the queue's view). Units, first that applies: the source file from the Xbox build's assert sites ([reverse] source_files); the D3DX9 block (platform/d3dx9) or the import-thunk stubs from library_ranges; the unit facts gives (engine/, platform/); otherwise plus its 64 KB address block, labelled "(no source file)".""" import csv import hashlib import json import subprocess import threading import time from pathlib import Path from .queue import FunctionQueue STATUSES = ("exact", "verified", "readable", "deferred", "raw", "unsorted", "engine", "library") DECOMPILED = ("decompiled", "reimplemented", "verified") EXCLUDED = ("superseded", "wontfix") UNIT_FIELDS = ("name", "kind", "lo", "hi", "bytes", "counted", "exact", "verified", "readable", "functions") FUNCTION_FIELDS = ("unit", "address", "size", "status", "name") KEY_TTL = 15 # seconds a computed input key is trusted before the files and HEAD are looked at again def _int(text): return int(str(text), 16) if str(text).lower().startswith("0x") else int(text) def _mtime(path): try: return Path(path).stat().st_mtime_ns if path else None except OSError: return None def _git(repo, *args): try: out = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, timeout=20, encoding="utf-8", errors="replace") except (OSError, subprocess.TimeoutExpired): return "" return out.stdout.strip() if out.returncode == 0 else "" def _utc(iso): import datetime try: return datetime.datetime.fromisoformat(iso).astimezone(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") except ValueError: return None class ProgressMap: def __init__(self, project, queue_factory): self.project = project self.queue_factory = queue_factory # -> FunctionQueue on a connection this thread may use self.lock = threading.Lock() self.value = None # (key, document) self.checked = (0.0, None) # (monotonic time, key) self.computations = 0 # ------------------------------------------------------------------ inputs def _config(self): return self.project.reverse or {}, getattr(self.project, "logbook", None) or {} def _symbols_path(self): _, lb = self._config() return Path(lb["stats_repo"]) / "re" / "symbols" / "pc102.csv" if lb.get("stats_repo") else None def _decomp(self): rev, lb = self._config() if lb.get("decomp"): return Path(lb["decomp"]) return Path(rev["annotations"]).parent if rev.get("annotations") else None def _logbook_dir(self): from . import logbook return logbook.folder(self.project) def key(self, q): rev, _ = self._config() decomp = self._decomp() head = _git(decomp, "rev-parse", "HEAD") if decomp and (decomp / ".git").exists() else "" paths = [self._symbols_path(), self._logbook_dir() / "stats.json", self._logbook_dir() / "history.json", self._logbook_dir() / "results.json"] paths += [rev.get(k) for k in ("facts", "match_results", "diffrun_results", "library_results", "library_ranges", "source_files")] if rev.get("annotations"): paths += [Path(rev["annotations"]) / "names.csv", Path(rev["annotations"]) / "globals.csv"] try: note = q.h.conn.execute("SELECT MAX(id) FROM notes").fetchone()[0] except Exception: note = None raw = json.dumps([head, note, [str(p) for p in paths], [_mtime(p) for p in paths]]) return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16], head # ------------------------------------------------------------------ answer def get(self, have=None): with self.lock: q = self.queue_factory() now = time.monotonic() if self.value and now - self.checked[0] < KEY_TTL: key = self.checked[1] else: key, head = self.key(q) self.checked = (now, key) if not self.value or self.value[0] != key: document = self.compute(q, head) if document is None and self.value: # the symbol table is being rewritten: keep the last document = self.value[1] elif document is None: document = self.compute(q, head, use_symbols=False) document["version"] = key self.value = (key, document) self.computations += 1 if have and have == self.value[0]: return {"version": have, "same": True} return self.value[1] # ------------------------------------------------------------------ the computation def _symbols(self, expected): """pc102.csv as {address: (category, status, name)}, or None while it is missing or half written.""" path = self._symbols_path() if not path or not path.exists(): return None try: with path.open(encoding="utf-8", newline="") as stream: rows = {_int(r["address"]): (r["category"], r["status"], r.get("name") or "") for r in csv.DictReader(stream)} except (OSError, ValueError, KeyError): return None return rows if len(rows) >= 0.98 * expected else None def _source_files(self, path): out = {} if path and Path(path).exists(): with open(path, encoding="utf-8", newline="") as stream: for row in csv.DictReader(stream): if row.get("source_file"): out[_int(row["pc_address"])] = row["source_file"] return out def _all_ranges(self, path): """[(lo, hi, label)] of the library_ranges csv (the D3DX9 block and the compiler's stubs).""" out = [] if path and Path(path).exists(): with open(path, encoding="utf-8", newline="") as stream: for r in csv.DictReader(stream): verdict = r.get("verdict", "") label = "platform/d3dx9" if verdict.startswith("D3DX") else \ "Import thunks and unwind funclets" if "stubs" in verdict else None if label: out.append((_int(r["start"]), _int(r["end"]), label)) return out def _data(self, facts, rev): """Named globals against the distinct .data/.rdata addresses the code touches (needs sections.json).""" facts_path = Path(rev["facts"]) if rev.get("facts") else None sections_path = facts_path.parent / "sections.json" if facts_path else None globals_path = Path(rev["annotations"]) / "globals.csv" if rev.get("annotations") else None if not sections_path or not sections_path.exists() or not globals_path or not globals_path.exists(): return None spans = [(s["start"], s["end"]) for s in json.loads(sections_path.read_text(encoding="utf-8")) if s.get("name") in (".data", ".rdata")] touched = set() for d in facts.values(): for ref in d.get("data", []): a = _int(ref) if any(lo <= a < hi for lo, hi in spans): touched.add(a) with globals_path.open(encoding="utf-8", newline="") as stream: named = {_int(r["address"]) for r in csv.DictReader(line for line in stream if not line.startswith("#")) if r.get("address") and r.get("name")} if not touched: return None return {"addresses": len(touched), "named": len(touched & named), "globals": len(named), "bytes": sum(hi - lo for lo, hi in spans)} def compute(self, q, head, use_symbols=True): rev, _ = self._config() facts = q.r._facts() symbols = self._symbols(len(facts)) if use_symbols else None if use_symbols and self._symbols_path() and symbols is None and self._symbols_path().exists(): return None names = q.r._csv("names.csv") statuses = None if symbols else q.statuses() exact = {a for a, row in q._results("match_results").items() if str(row.get("status")) in ("match", "exact", "exact-relaxed")} verified = {a for a, row in q._results("diffrun_results").items() if str(row.get("verdict")) == "same" and not row.get("weak")} library = set(q._results("library_results")) ranges = self._all_ranges(rev.get("library_ranges")) lib_ranges = [(lo, hi) for lo, hi, _ in ranges] deferred = q.deferrals() sources = self._source_files(rev.get("source_files")) units, unit_index, functions = [], {}, [] game_functions = game_done = 0 for addr in sorted(facts): d = facts[addr] size = int(d.get("size") or 0) if symbols and addr in symbols: category, status, sym_name = symbols[addr] else: category = q._category(addr) status = "decompiled" if (statuses or {}).get(addr, ("raw",))[0] == "readable" else "classified" sym_name = "" row = names.get(addr) name = (row[1] if row and len(row) > 1 and row[1] else "") or sym_name or d.get("name") or f"FUN_{addr:06X}" counted = category == "game" and status not in EXCLUDED done = status in DECOMPILED is_lib = q.is_library(addr, library, lib_ranges) or category in ("library", "platform", "crt") if counted or (done and not is_lib) or (category == "unsorted" and not is_lib): tier = "exact" if done and addr in exact else "verified" if done and addr in verified else \ "readable" if done else "deferred" if addr in deferred else \ "raw" if counted else "unsorted" elif is_lib: tier = "library" else: tier = "engine" source = sources.get(addr) in_range = next((label for lo, hi, label in ranges if lo <= addr < hi), None) if source: unit = source elif in_range: unit = in_range elif d.get("unit"): unit = d["unit"] else: unit = f"{category} ยท 0x{addr & ~0xFFFF:06X} block (no source file)" if unit not in unit_index: unit_index[unit] = len(units) units.append({"name": unit, "lo": addr, "hi": addr + size, "bytes": 0, "counted": 0, "exact": 0, "verified": 0, "readable": 0, "functions": 0, "kinds": {}}) u = units[unit_index[unit]] u["hi"] = max(u["hi"], addr + size) u["bytes"] += size u["functions"] += 1 kind = "game" if counted else "library" if tier == "library" else "engine" if category == "engine" \ else "unsorted" if category == "unsorted" else "library" u["kinds"][kind] = u["kinds"].get(kind, 0) + size if counted: game_functions += 1 game_done += done u["counted"] += size if tier in ("exact", "verified", "readable"): u[tier] += size functions.append([unit_index[unit], addr, size, STATUSES.index(tier), name]) for u in units: kinds = u.pop("kinds") u["kind"] = max(kinds, key=kinds.get) if kinds else "unsorted" # what most of its bytes are code = {k: sum(u[k] for u in units) for k in ("exact", "verified", "readable")} code["bytes"] = sum(u["counted"] for u in units) code["functions"], code["decompiled_functions"] = game_functions, game_done percent = round(100 * (code["exact"] + code["verified"] + code["readable"]) / code["bytes"], 2) \ if code["bytes"] else 0.0 from . import logbook stats = logbook._read(self.project, "stats.json", None) or {} history = sorted(logbook._read(self.project, "history.json", []), key=lambda p: p.get("at", "")) decomp = self._decomp() commit = {"sha": stats.get("commit"), "at": None} if commit["sha"] and decomp: commit["at"] = _utc(_git(decomp, "log", "-1", "--format=%cI", commit["sha"])) or None main = {"sha": head[:7] if head else None, "at": _utc(_git(decomp, "log", "-1", "--format=%cI", head)) if head else None} from . import results_refresh return { "project": self.project.title or self.project.name, "results": results_refresh.freshness(self.project), "source": "logbook symbol table" if symbols else "src headers and names.csv", "commit": commit, "main": main, "updated_at": stats.get("updated_at"), "percent": percent, "logbook_percent": stats.get("game_bytes_decompiled_percent"), "code": code, "data": self._data(facts, rev), "history": [[p.get("at"), p.get("decompiled"), p.get("commit"), p.get("game_bytes")] for p in history if p.get("decompiled") is not None], "statuses": list(STATUSES), "unit_fields": list(UNIT_FIELDS), "units": [[u[k] for k in UNIT_FIELDS] for u in units], "function_fields": list(FUNCTION_FIELDS), "functions": functions, } _MAPS = {} _LOCK = threading.Lock() def map_for(project): with _LOCK: if project.name not in _MAPS: from . import decomp_api api = decomp_api.api_for(project) _MAPS[project.name] = ProgressMap(project, lambda: FunctionQueue(api.h)) return _MAPS[project.name] def handle(project, query): if not (project.reverse or {}).get("facts"): return 404, {"error": "this project has no [reverse] table"} return 200, map_for(project).get(query.get("have"))