logbook: an All code bar (game + engine + platform, by bytes, with function counts) next to the game-code bar, so engine work shows too
This commit is contained in:
1 parent
59dac57bc8
commit
71e6d6cfda
2 files changed
+1964
-1960
No files matched your search
+325
-321
@@ -1,321 +1,325 @@
|
||||
"""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=<version>` 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/<class>,
|
||||
platform/<library>); otherwise
|
||||
<category> 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"))
|
||||
"""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=<version>` 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/<class>,
|
||||
platform/<library>); otherwise
|
||||
<category> 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),
|
||||
# every function in the executable (game, engine, platform, CRT), so engine work shows too
|
||||
"all_code": {"bytes": stats.get("bytes"), "functions": stats.get("functions"),
|
||||
"decompiled_bytes": ((stats.get("by_status") or {}).get("decompiled") or {}).get("bytes"),
|
||||
"decompiled_functions": ((stats.get("by_status") or {}).get("decompiled") or {}).get("functions")},
|
||||
"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"))
|
||||
+1639
-1639
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user