Board: Decomp view (progress tiers, function inspector, next up per band, deferred) and RE tools in Health; Verified tier from the emulator
This commit is contained in:
1 parent
a9efc2e5c5
commit
87631bec52
5 files changed
+331
-6
No files matched your search
@@ -0,0 +1,200 @@
|
||||
"""The web board's Decomp view: progress tiers, a function inspector, each agent's next functions, deferrals and the
|
||||
state of the reverse-engineering tools. Everything here reads; nothing writes.
|
||||
|
||||
It runs on its own Harbor connection with its own lock and short caches, because the answers take seconds (they scan
|
||||
src/ and score whole bands) and must not hold up the chat."""
|
||||
import json
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .core import Harbor
|
||||
from .queue import FunctionQueue
|
||||
from .reverse import run_toolkit
|
||||
from .util import HarborError
|
||||
|
||||
PROGRESS_TTL = 120
|
||||
QUEUE_TTL = 600
|
||||
|
||||
|
||||
class DecompAPI:
|
||||
def __init__(self, project):
|
||||
self.project = project
|
||||
self.lock = threading.Lock()
|
||||
self.cache = {}
|
||||
self._local = threading.local()
|
||||
|
||||
@property
|
||||
def h(self):
|
||||
# SQLite connections belong to the thread that opened them, and the server answers on many threads
|
||||
if getattr(self._local, "h", None) is None:
|
||||
self._local.h = Harbor(self.project)
|
||||
return self._local.h
|
||||
|
||||
def _cached(self, key, ttl, fn):
|
||||
hit = self.cache.get(key)
|
||||
if hit and time.monotonic() - hit[0] < ttl:
|
||||
return hit[1]
|
||||
value = fn()
|
||||
self.cache[key] = (time.monotonic(), value)
|
||||
return value
|
||||
|
||||
def _queue(self):
|
||||
return FunctionQueue(self.h)
|
||||
|
||||
# ------------------------------------------------------------------ answers
|
||||
def progress(self):
|
||||
with self.lock:
|
||||
return self._cached("progress", PROGRESS_TTL, lambda: self._queue().progress())
|
||||
|
||||
def queues(self, n=6):
|
||||
def build():
|
||||
q = self._queue()
|
||||
similar = q.similar_readable()
|
||||
out = []
|
||||
for agent in self.project.ai_names:
|
||||
if not q.bands(agent):
|
||||
continue
|
||||
try:
|
||||
rows = q.next_functions(agent, n, similar_readable=similar)
|
||||
except HarborError as error:
|
||||
rows, note = [], str(error)
|
||||
else:
|
||||
note = None
|
||||
out.append({"agent": agent, "bands": [[f"0x{lo:X}", f"0x{hi:X}"] for lo, hi in q.bands(agent)],
|
||||
"next": rows, **({"note": note} if note else {})})
|
||||
return out
|
||||
with self.lock:
|
||||
return self._cached("queues", QUEUE_TTL, build)
|
||||
|
||||
def deferred(self):
|
||||
with self.lock:
|
||||
q = self._queue()
|
||||
return [{"address": f"0x{a:X}", "name": q.r.name_of(a), "reason": reason, "evidence": evidence,
|
||||
"author": author} for a, (reason, evidence, author) in sorted(q.deferrals().items())]
|
||||
|
||||
def function(self, target):
|
||||
"""Everything about one function, for the inspector."""
|
||||
with self.lock:
|
||||
q = self._queue()
|
||||
r = q.r
|
||||
info = r.lookup(target)
|
||||
addr = int(info.get("function", {}).get("start", info["address"]), 16)
|
||||
statuses = self._cached("statuses", PROGRESS_TTL, q.statuses)
|
||||
status, filename = statuses.get(addr, ("none", None))
|
||||
info["status"] = status
|
||||
if filename:
|
||||
info["src_file"] = filename
|
||||
for key, label in (("match_results", "match"), ("diffrun_results", "behaviour")):
|
||||
row = q._results(key).get(addr)
|
||||
if row:
|
||||
info[label] = {k: row[k] for k in ("status", "verdict", "similarity", "first_difference",
|
||||
"registers_in", "ours_registers_in", "reason", "weak")
|
||||
if k in row}
|
||||
deferral = q.deferrals().get(addr)
|
||||
if deferral:
|
||||
info["deferred"] = {"reason": deferral[0], "evidence": deferral[1], "by": deferral[2]}
|
||||
info["category"] = q._category(addr)
|
||||
library = q._results("library_results")
|
||||
if q.is_library(addr, library, q.library_ranges()):
|
||||
info["library"] = True
|
||||
try:
|
||||
info["disassembly"] = r.disassemble(f"0x{addr:X}") if int(info.get("function", {}).get("size", 0)) <= 0x1800 \
|
||||
else r.disassemble(f"0x{addr:X}", 0x600) + "\n; ... (first 0x600 bytes of a large function)"
|
||||
except HarborError as error:
|
||||
info["disassembly"] = f"; {error}"
|
||||
try:
|
||||
similar = run_toolkit(self.project.reverse, "similar", [f"0x{addr:X}", "--k", "6"], timeout=60)
|
||||
info["similar"] = similar.get("results", [])
|
||||
except (HarborError, subprocess.TimeoutExpired, OSError):
|
||||
info["similar"] = []
|
||||
return info
|
||||
|
||||
def tools(self):
|
||||
"""Is each reverse-engineering tool in place, and how fresh are its results?"""
|
||||
config = self.project.reverse or {}
|
||||
out = []
|
||||
|
||||
def fresh(path):
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
return None
|
||||
return round((time.time() - p.stat().st_mtime) / 60)
|
||||
|
||||
url = config.get("ghidra_url", "")
|
||||
port = int(url.rsplit(":", 1)[1].split("/")[0]) if url.count(":") >= 2 else 0
|
||||
up = False
|
||||
if port:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=1):
|
||||
up = True
|
||||
except OSError:
|
||||
up = False
|
||||
decomp = Path(config.get("annotations", "")).parent if config.get("annotations") else None
|
||||
head = applied = None
|
||||
if decomp and (decomp / ".git").exists():
|
||||
try:
|
||||
head = subprocess.run(["git", "-C", str(decomp), "rev-parse", "HEAD"], capture_output=True, text=True,
|
||||
timeout=10).stdout.strip() or None
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
head = None
|
||||
applied_file = Path(config.get("debugger_temp", "")).parent / "ghidra-mcp" / "applied-commit.txt" \
|
||||
if config.get("debugger_temp") else None
|
||||
if applied_file and applied_file.exists():
|
||||
applied = applied_file.read_text(encoding="utf-8").strip()
|
||||
out.append({"tool": "Shared Ghidra session", "ok": up and (not head or head == applied),
|
||||
"detail": ("up" if up else "down") + (f"; names from main {applied[:8]}" if applied else "")
|
||||
+ ("" if not head or head == applied else f"; main is at {head[:8]}: run ghidra-serve.ps1 -IfStale")})
|
||||
compiler = Path(r"C:\Program Files (x86)\Microsoft Visual C++ Toolkit 2003\bin\cl.exe")
|
||||
out.append({"tool": "Game compiler (Visual C++ 2003 Toolkit)", "ok": compiler.exists(),
|
||||
"detail": "cl 13.10.3052 installed" if compiler.exists() else "not found"})
|
||||
if config.get("debugger_path"):
|
||||
out.append({"tool": "Debugger (x32dbg + automation plugin)", "ok": Path(config["debugger_path"]).exists(),
|
||||
"detail": config["debugger_path"]})
|
||||
for key, label in (("match_results", "Whole-program match results"), ("diffrun_results", "Emulator results"),
|
||||
("library_results", "Library sweep"), ("similar_clusters", "Near-duplicate clusters"),
|
||||
("source_files", "Source files from the Xbox build"), ("mac_names", "Names from the Mac build")):
|
||||
if config.get(key):
|
||||
age = fresh(config[key])
|
||||
out.append({"tool": label, "ok": age is not None,
|
||||
"detail": "missing" if age is None else f"updated {age} min ago"})
|
||||
return out
|
||||
|
||||
|
||||
_APIS = {}
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def api_for(project):
|
||||
with _LOCK:
|
||||
if project.name not in _APIS:
|
||||
_APIS[project.name] = DecompAPI(project)
|
||||
return _APIS[project.name]
|
||||
|
||||
|
||||
def handle(project, path, query):
|
||||
"""GET /api/decomp/<what>: returns (status, body)."""
|
||||
api = api_for(project)
|
||||
what = path.rsplit("/", 1)[-1]
|
||||
if not (project.reverse or {}).get("facts"):
|
||||
return 404, {"error": "this project has no [reverse] table"}
|
||||
if what == "progress":
|
||||
return 200, api.progress()
|
||||
if what == "queues":
|
||||
return 200, api.queues()
|
||||
if what == "deferred":
|
||||
return 200, api.deferred()
|
||||
if what == "function":
|
||||
target = (query.get("target") or "").strip()
|
||||
if not target:
|
||||
return 400, {"error": "give an address or a name"}
|
||||
return 200, api.function(target)
|
||||
if what == "tools":
|
||||
return 200, api.tools()
|
||||
return 404, {"error": "not found"}
|
||||
|
||||
|
||||
def _json_safe(value):
|
||||
return json.loads(json.dumps(value, default=str))
|
||||
+9
-4
@@ -129,14 +129,14 @@ class FunctionQueue:
|
||||
return addr in library or any(lo <= addr < hi for lo, hi in ranges)
|
||||
|
||||
# ------------------------------------------------------------------ queue
|
||||
def score(self, addr, facts, named, rejected, similar_readable=frozenset()):
|
||||
def score(self, addr, facts, named, rejected, similar_readable=frozenset(), with_convention=True):
|
||||
d = facts[addr]
|
||||
size = int(d.get("size") or 0)
|
||||
calls = [int(c, 16) if isinstance(c, str) else c for c in d.get("calls", [])]
|
||||
named_share = (sum(1 for c in calls if c in named) / len(calls)) if calls else 1.0
|
||||
score = 1.0 / (1.0 + size / 250.0) # small first
|
||||
score *= 0.5 + 0.5 * named_share # callees with names make readable code possible
|
||||
conv = self.r.convention(addr) if size <= 4000 else None
|
||||
conv = self.r.convention(addr) if with_convention and size <= 4000 else None
|
||||
if conv and conv["summary"].startswith("custom"):
|
||||
score *= 0.75 # register arguments: prototypes.h work too
|
||||
if self._category(addr) not in ("game", "unsorted"):
|
||||
@@ -186,9 +186,12 @@ class FunctionQueue:
|
||||
head = self.r.read(addr, 10) if int(facts[addr].get("size") or 0) == 10 else b""
|
||||
if head[:1] and 0xB8 <= head[0] <= 0xBF and head[5:6] == b"\xe9":
|
||||
continue # mov reg, imm32; jmp: an at-exit destructor thunk or an SEH handler stub (compiler's)
|
||||
score, reasons = self.score(addr, facts, named, rejected, similar_readable)
|
||||
score, reasons = self.score(addr, facts, named, rejected, similar_readable, with_convention=False)
|
||||
ranked.append((score, addr, reasons))
|
||||
ranked.sort(reverse=True)
|
||||
# the convention read from the bytes only lowers a score, and it is the slow part: read it for the head only
|
||||
head = [(*self.score(a, facts, named, rejected, similar_readable), a) for _, a, _ in ranked[:max(4 * n, 40)]]
|
||||
ranked = sorted(((s, a, why) for s, why, a in head), reverse=True)
|
||||
return [{"address": f"0x{a:X}", "name": self.r.name_of(a), "score": round(s, 4), "why": ", ".join(why),
|
||||
"file": statuses.get(a, (None, None))[1]} for s, a, why in ranked[:n]]
|
||||
|
||||
@@ -207,7 +210,9 @@ class FunctionQueue:
|
||||
facts, statuses, deferred = self.r._facts(), self.statuses(), self.deferrals()
|
||||
exact = {a for a, row in self._results("match_results").items()
|
||||
if str(row.get("status")) in ("match", "exact", "exact-relaxed")}
|
||||
verified = {a for a, row in self._results("diffrun_results").items() if str(row.get("verdict")) == "same"}
|
||||
# behaves the same in the emulator, with at least one trial that ran to the end in both (not 'weak')
|
||||
verified = {a for a, row in self._results("diffrun_results").items()
|
||||
if str(row.get("verdict")) == "same" and not row.get("weak")}
|
||||
library = set(self._results("library_results"))
|
||||
ranges = self.library_ranges()
|
||||
tiers = {t: {"functions": 0, "bytes": 0} for t in TIER_ORDER + ("library", "engine")}
|
||||
|
||||
@@ -161,10 +161,29 @@ class Reverse:
|
||||
if re.fullmatch(r"(0x)?[0-9A-Fa-f]{5,8}", text):
|
||||
return int(text, 16)
|
||||
addr = self.address_of(text)
|
||||
if addr is None:
|
||||
addr = self._mac_index().get(text) or self._mac_index().get(text.replace("::", "_"))
|
||||
if addr is None:
|
||||
raise HarborError(f"{target!r} is neither an address nor a known name")
|
||||
return addr
|
||||
|
||||
def _mac_index(self):
|
||||
"""Original names from the Mac build -> PC address: 'CanCarry', 'TradeMenu::DoTrade', 'TradeMenu_DoTrade'."""
|
||||
def load(p):
|
||||
out = {}
|
||||
if p:
|
||||
for row in csv.DictReader(p.read_text(encoding="utf-8").splitlines()):
|
||||
head = (row.get("demangled") or "").split("(", 1)[0].strip()
|
||||
try:
|
||||
addr = int(row["pc_address"], 16)
|
||||
except (KeyError, ValueError):
|
||||
continue
|
||||
if head:
|
||||
out.setdefault(head, addr)
|
||||
out.setdefault(head.replace("::", "_"), addr)
|
||||
return out
|
||||
return self._load("mac_index", self.mac_names, load)
|
||||
|
||||
def convention(self, addr):
|
||||
"""The calling convention read from the bytes: register inputs (a dataflow pass) and the `ret N` cleanup."""
|
||||
if not self.conventions_path or not self.conventions_path.exists():
|
||||
|
||||
@@ -330,6 +330,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
"stopped": any(h.stopped(a) for a in h.project.ai_names)})
|
||||
return self._reply(200, out)
|
||||
h, lock = REGISTRY.get(name)
|
||||
if path.startswith("/api/decomp/"):
|
||||
# its own connection and lock: these answers take seconds and must not hold up the chat
|
||||
from . import decomp_api
|
||||
status, body = decomp_api.handle(h.project, path, query)
|
||||
return self._reply(status, body)
|
||||
if path.startswith("/files/"):
|
||||
filename = unquote(path[len("/files/"):])
|
||||
if not re.fullmatch(r"[0-9a-f]{12}-[A-Za-z0-9._-]{1,80}", filename):
|
||||
|
||||
+98
-2
@@ -55,6 +55,20 @@ body { overflow: hidden; }
|
||||
.content { flex: 1; min-height: 0; overflow-y: auto; padding: 24px 32px 56px; }
|
||||
.view { display: none; max-width: 1380px; margin: 0 auto; gap: 20px; }
|
||||
.view.on { display: grid; }
|
||||
.tierbar { display: flex; height: 22px; border-radius: 7px; overflow: hidden; background: var(--surface-3); gap: 2px; }
|
||||
.tierbar span { display: block; height: 100%; min-width: 3px; }
|
||||
.tier-exact { background: var(--green); } .tier-verified { background: var(--blue); } .tier-readable { background: var(--violet); }
|
||||
.tier-deferred { background: var(--amber); } .tier-raw { background: var(--line-2); }
|
||||
.tier-library, .tier-engine { background: repeating-linear-gradient(45deg, var(--surface-3) 0 4px, var(--line-2) 4px 8px); }
|
||||
.tier-key { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 8px; vertical-align: -1px; }
|
||||
td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.fn-head { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; margin-top: 16px; }
|
||||
.fn-head h3 { margin: 0; font-size: 16px; }
|
||||
.fn-facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 7px 16px; font-size: 13px; margin: 14px 0 0; }
|
||||
.fn-facts dt { color: var(--muted); } .fn-facts dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
.disasm { white-space: pre; font: 11.5px/1.5 var(--mono); max-height: 440px; overflow: auto; padding: 10px 12px; border-radius: 9px; background: var(--surface-3); margin: 8px 0 0; }
|
||||
.band { padding: 12px 18px; border-top: 1px solid var(--line); } .band:first-child { border-top: 0; }
|
||||
.band .cell-who { gap: 8px; } .band ul { margin: 8px 0 0; }
|
||||
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 460px), 1fr)); gap: 20px; align-items: start; }
|
||||
.sub-h { font-size: 11px; letter-spacing: .8px; text-transform: uppercase; color: var(--faint); font-weight: 700; margin: 20px 0 8px; }
|
||||
.flush th:first-child, .flush td:first-child { padding-left: 18px; }
|
||||
@@ -431,6 +445,7 @@ button.chip:hover { color: var(--accent-ink); background: var(--accent-soft); }
|
||||
<button class="nav-btn" data-go="launch" title="Launch (2)"><svg class="i"><use href="static/icons.svg#rocket"/></svg><span>Launch</span><span class="count-badge" id="navLaunch" hidden></span></button>
|
||||
<button class="nav-btn" data-go="tasks" title="Tasks (3)"><svg class="i"><use href="static/icons.svg#tasks"/></svg><span>Tasks</span><span class="count-badge" id="navTasks" hidden></span></button>
|
||||
<button class="nav-btn" data-go="review" title="Review (4)"><svg class="i"><use href="static/icons.svg#review"/></svg><span>Review</span><span class="count-badge" id="navReview" hidden></span></button>
|
||||
<button class="nav-btn" data-go="decomp" title="Decomp (8)"><svg class="i"><use href="static/icons.svg#terminal"/></svg><span>Decomp</span></button>
|
||||
<button class="nav-btn" data-go="activity" title="Activity (5)"><svg class="i"><use href="static/icons.svg#activity"/></svg><span>Activity</span><span class="count-badge" id="navActivity" hidden></span></button>
|
||||
<button class="nav-btn" data-go="knowledge" title="Knowledge (6)"><svg class="i"><use href="static/icons.svg#book"/></svg><span>Knowledge</span><span class="count-badge" id="navKnowledge" hidden></span></button>
|
||||
<button class="nav-btn" data-go="health" title="Health (7)"><svg class="i"><use href="static/icons.svg#heart"/></svg><span>Health</span></button>
|
||||
@@ -622,12 +637,44 @@ button.chip:hover { color: var(--accent-ink); background: var(--accent-soft); }
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- decomp -->
|
||||
<section class="view" id="view-decomp" aria-label="Decomp">
|
||||
<section class="card" aria-labelledby="h-progress">
|
||||
<header><h2 id="h-progress"><svg class="i"><use href="static/icons.svg#trophy"/></svg>Progress by function</h2><span class="count" id="progressSum"></span><span class="grow"></span>
|
||||
<button class="btn sm" id="refreshDecomp"><svg class="i sm"><use href="static/icons.svg#refresh"/></svg>Refresh</button></header>
|
||||
<div class="body" id="progress"></div>
|
||||
</section>
|
||||
<section class="card" aria-labelledby="h-inspect">
|
||||
<header><h2 id="h-inspect"><svg class="i"><use href="static/icons.svg#search"/></svg>Inspect a function</h2><span class="note">original name, source file, convention, match and near-duplicates</span></header>
|
||||
<div class="body">
|
||||
<form class="searchbar" id="fnForm"><div class="field"><svg class="i"><use href="static/icons.svg#search"/></svg>
|
||||
<input type="search" id="fnTarget" placeholder="An address (0x4ADC70) or a name (CanCarry)" aria-label="Function"></div>
|
||||
<button class="btn primary">Inspect</button></form>
|
||||
<div id="fnInfo"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="cols">
|
||||
<section class="card" aria-labelledby="h-next">
|
||||
<header><h2 id="h-next"><svg class="i"><use href="static/icons.svg#list"/></svg>Next up in each band</h2><span class="note">what next_functions hands out, easiest first</span></header>
|
||||
<div class="body flush scroll" id="nextUp"></div>
|
||||
</section>
|
||||
<section class="card" aria-labelledby="h-deferred">
|
||||
<header><h2 id="h-deferred"><svg class="i"><use href="static/icons.svg#clock"/></svg>Deferred</h2><span class="count" id="deferCount"></span></header>
|
||||
<div class="body flush scroll" id="deferred"></div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- health -->
|
||||
<section class="view" id="view-health" aria-label="Health">
|
||||
<section class="card" aria-labelledby="h-notify">
|
||||
<header><h2 id="h-notify"><svg class="i"><use href="static/icons.svg#bell"/></svg>Notifications</h2></header>
|
||||
<div class="body"><label class="switch" title="Windows notifications for questions, urgent messages and messages to you - also with this page closed"><input type="checkbox" id="toasts"> Windows notifications for questions, urgent messages and messages to you, also while this page is closed</label></div>
|
||||
</section>
|
||||
<section class="card" aria-labelledby="h-retools">
|
||||
<header><h2 id="h-retools"><svg class="i"><use href="static/icons.svg#terminal"/></svg>Reverse-engineering tools</h2><span class="count" id="reSum"></span></header>
|
||||
<div class="body flush scroll" id="retools"></div>
|
||||
</section>
|
||||
<section class="card" aria-labelledby="h-health">
|
||||
<header><h2 id="h-health"><svg class="i"><use href="static/icons.svg#heart"/></svg>Health and connections</h2><span class="count" id="healthSum"></span><span class="grow"></span>
|
||||
<button class="btn sm primary" id="runDoctor"><svg class="i sm"><use href="static/icons.svg#refresh"/></svg>Run checks</button></header>
|
||||
@@ -709,7 +756,8 @@ async function get(path){const r=await fetch(path,{cache:'no-store'});const j=aw
|
||||
const VIEWS={overview:['Overview','Who is working, what needs you, and what happened','home'],launch:['Launch','Bring agents online one careful step at a time','rocket'],
|
||||
tasks:['Tasks','The shared queue that idle agents take work from','tasks'],review:['Review','Submitted work, acknowledgements and claims','review'],
|
||||
activity:['Activity','Live tool calls, the team pulse and the scoreboard','activity'],knowledge:['Knowledge','Search every message, pinned decisions and threads','book'],
|
||||
health:['Health','Harbor in every agent app, the database, backups and the server','heart'],chat:['Chat','The team’s conversation','chat']};
|
||||
health:['Health','Harbor in every agent app, the database, backups and the server','heart'],
|
||||
decomp:['Decomp','Progress by function, the function inspector and what each agent takes next','terminal'],chat:['Chat','The team’s conversation','chat']};
|
||||
function show(view){if(!VIEWS[view])view='overview';current=view;
|
||||
document.querySelectorAll('.view').forEach(v=>v.classList.toggle('on',v.id==='view-'+view));
|
||||
document.querySelectorAll('.nav-btn[data-go]').forEach(b=>{if(b.dataset.go===view)b.setAttribute('aria-current','page');else b.removeAttribute('aria-current')});
|
||||
@@ -722,6 +770,8 @@ function show(view){if(!VIEWS[view])view='overview';current=view;
|
||||
if(data&&view==='overview')renderDigest();
|
||||
if(data&&view==='activity'){renderPulse();renderFeed()}
|
||||
if(data&&view==='launch')renderLaunch();
|
||||
if(data&&view==='decomp')renderDecomp();
|
||||
if(view==='health')renderTools();
|
||||
if(view==='health'&&!doctorRan)runDoctor()}
|
||||
function paintTitle(){document.title=(unseen&&document.hidden?'('+unseen+') ':'')+(data?data.project.title+' · ':'')+(current?VIEWS[current][0]:'Harbor')}
|
||||
|
||||
@@ -962,6 +1012,52 @@ el('searchForm').onsubmit=async e=>{e.preventDefault();const q=el('q').value.tri
|
||||
el('menuBtn').onclick=()=>el('shell').classList.add('open');
|
||||
el('scrim').onclick=()=>el('shell').classList.remove('open');
|
||||
window.addEventListener('hashchange',()=>{const v=location.hash.slice(1);if(v&&v!==current)show(v)});
|
||||
const TIERS=[['exact','Exact match','byte-identical with the game’s own compiler'],['verified','Verified','behaves the same as the game in the emulator'],
|
||||
['readable','Readable','readable C that passed review'],['deferred','Deferred','set aside with a reason after failed attempts'],['raw','Raw','not decompiled yet'],
|
||||
['library','Library','C runtime, D3DX9 and other linked libraries: never decompiled'],['engine','Engine','Gamebryo engine code: names only']];
|
||||
const fmt=n=>Number(n||0).toLocaleString('en-US');
|
||||
function renderDecomp(){renderProgress();renderNextUp();renderDeferred()}
|
||||
async function renderProgress(){let p;try{p=await get('api/decomp/progress')}catch(e){put('progress','<div class="empty err">'+esc(e.message)+'</div>');return}
|
||||
const t=p.tiers,own=['exact','verified','readable','deferred','raw'].reduce((a,k)=>a+t[k].functions,0),done=t.exact.functions+t.verified.functions+t.readable.functions;
|
||||
el('progressSum').textContent=fmt(done)+' of '+fmt(own)+' game functions decompiled';
|
||||
put('progress','<div class="tierbar" role="img" aria-label="'+esc(TIERS.map(([k,l])=>l+' '+t[k].functions).join(', '))+'">'+TIERS.map(([k,l])=>t[k].functions?'<span class="tier-'+k+'" style="flex:'+t[k].functions+'" title="'+esc(l)+': '+fmt(t[k].functions)+' functions"></span>':'').join('')+'</div>'
|
||||
+'<table style="margin-top:14px"><thead><tr><th>Tier</th><th style="text-align:right">Functions</th><th style="text-align:right">Bytes</th><th>Meaning</th></tr></thead><tbody>'
|
||||
+TIERS.map(([k,l,d])=>'<tr><td><span class="tier-key tier-'+k+'"></span>'+esc(l)+'</td><td class="num">'+fmt(t[k].functions)+'</td><td class="num">'+fmt(t[k].bytes)+'</td><td class="note">'+esc(d)+'</td></tr>').join('')+'</tbody></table>'
|
||||
+(p.sources&&!p.sources.diffrun_results?'<div class="note" style="margin-top:10px">The emulator check has not reported yet, so Verified is still empty.</div>':''))}
|
||||
async function renderNextUp(){if(!el('nextUp')._html)put('nextUp',empty('clock','Ranking the bands…','This takes a few seconds the first time.'));let q;
|
||||
try{q=await get('api/decomp/queues')}catch(e){put('nextUp','<div class="empty err">'+esc(e.message)+'</div>');return}
|
||||
put('nextUp',q.length?q.map(a=>'<div class="band"><div class="cell-who">'+avatar(a.agent,'xs')+'<b>'+esc(a.agent)+'</b><span class="note mono">'+esc(a.bands.map(b=>b.join('–')).join(', '))+'</span></div>'
|
||||
+(a.next.length?'<ul class="attn">'+a.next.map(f=>'<li><a href="#" class="mono" data-fn="'+esc(f.address)+'">'+esc(f.address)+'</a> '+esc(f.name||(f.file||'').replace(/\.c$/,''))+'<div class="note">'+esc(f.why)+'</div></li>').join('')+'</ul>'
|
||||
:'<div class="note">'+esc(a.note||'Nothing left in this band.')+'</div>')+'</div>').join(''):empty('list','No bands yet','The coordinator gives each agent an address band.'))}
|
||||
async function renderDeferred(){let d;try{d=await get('api/decomp/deferred')}catch(e){return}el('deferCount').textContent=d.length||'';
|
||||
put('deferred',d.length?'<table><thead><tr><th>Function</th><th>Reason</th><th>Evidence</th></tr></thead><tbody>'+d.map(x=>'<tr><td><a href="#" class="mono" data-fn="'+esc(x.address)+'">'+esc(x.address)+'</a><div class="note">'+esc(x.name||'')+'</div></td><td><span class="tag amber">'+esc(x.reason)+'</span><div class="note">'+esc(x.author)+'</div></td><td class="note">'+esc(x.evidence)+'</td></tr>').join('')+'</tbody></table>'
|
||||
:empty('check','Nothing deferred','After two failed attempts an agent sets a function aside here, with the reason and what it found.'))}
|
||||
async function inspectFn(){const target=el('fnTarget').value.trim();if(!target)return;put('fnInfo',empty('search','Looking it up…',''));let f;
|
||||
try{f=await get('api/decomp/function?target='+encodeURIComponent(target))}catch(e){put('fnInfo','<div class="empty err">'+esc(e.message)+'</div>');return}
|
||||
const fn=f.function||{},mac=f.mac_name,src=f.source_file,m=f.match,b=f.behaviour,c=f.convention_from_bytes;
|
||||
const row=(k,v)=>v?'<dt>'+esc(k)+'</dt><dd>'+v+'</dd>':'';
|
||||
const note=f.names_row?String(f.names_row).split(',').slice(3).join(',').replace(/^"|"$/g,'').replace(/""/g,'"'):'';
|
||||
const exact=m&&['exact','exact-relaxed','match'].includes(m.status),near=m&&m.status==='exact-but-targets-differ';
|
||||
put('fnInfo','<div class="fn-head"><h3 class="mono">'+esc(fn.start||f.address)+'</h3><b>'+esc(fn.name||'unnamed')+'</b>'
|
||||
+'<span class="tag '+(f.status==='readable'?'violet':'')+'">'+esc(f.status)+'</span>'+(f.library?'<span class="tag">library</span>':'')
|
||||
+(m?'<span class="tag '+(exact?'green':near?'amber':'')+'">'+(exact?'exact match':near?'same bytes, different targets':esc(m.status)+(m.similarity!=null?' · '+Math.round(m.similarity*100)+'% similar':''))+'</span>':'')
|
||||
+(b?'<span class="tag '+(b.verdict==='same'?'green':b.verdict==='differs'?'red':'')+'">emulator: '+esc(b.verdict)+'</span>':'')
|
||||
+'<span class="note">'+esc(fn.size??'?')+' bytes · '+esc(f.category||'')+'</span></div>'
|
||||
+'<dl class="fn-facts">'+row('Original name',mac?'<code>'+esc(mac.demangled)+'</code> <span class="note">'+esc(mac.confidence)+' confidence, from the Mac build</span>':'')
|
||||
+row('Source file',src?'<code>'+esc(src.source_file)+'</code>'+(src.lines?' line '+esc(src.lines):'')+' <span class="note">'+esc(src.confidence)+' confidence, from the Xbox build</span>':'')
|
||||
+row('Convention',c?esc(c.summary)+(c.ret_pops&&c.ret_pops.some(x=>x)?' · cleans up '+esc(c.ret_pops.join('/'))+' bytes':''):'')
|
||||
+row('Decompiled file',f.src_file?'<span class="mono">'+esc(f.src_file)+'</span>':'')
|
||||
+row('Deferred',f.deferred?'<span class="tag amber">'+esc(f.deferred.reason)+'</span> '+esc(f.deferred.evidence):'')
|
||||
+row('Emulator',b&&b.verdict!=='same'?esc(b.reason||''):b&&b.weak?'same, but no trial ran to the end in both':'')
|
||||
+row('Prototype',f.prototype?'<code>'+esc(f.prototype)+'</code>':'')+row('Team note',note?esc(note.slice(0,700)):'')+'</dl>'
|
||||
+(f.similar&&f.similar.length?'<h3 class="sub-h">Near-duplicates</h3><ul class="attn">'+f.similar.map(s=>'<li><a href="#" class="mono" data-fn="'+esc(s.address)+'">'+esc(s.address)+'</a> '+esc(s.name||'')+' <span class="note">'+Math.round(s.score*100)+'% alike · '+esc(s.status)+'</span></li>').join('')+'</ul>':'')
|
||||
+'<details data-k="disasm"><summary>Disassembly</summary><pre class="disasm">'+esc(f.disassembly||'')+'</pre></details>')}
|
||||
async function renderTools(){let t;try{t=await get('api/decomp/tools')}catch(e){put('retools','<div class="empty err">'+esc(e.message)+'</div>');return}
|
||||
const bad=t.filter(x=>!x.ok).length;el('reSum').textContent=bad?bad+' to check':'all in place';
|
||||
put('retools','<table><thead><tr><th>Status</th><th>Tool</th></tr></thead><tbody>'+t.map(x=>'<tr><td><span class="tag '+(x.ok?'green':'amber')+'"><span class="dot"></span>'+(x.ok?'ok':'check')+'</span></td><td>'+esc(x.tool)+'<div class="note">'+esc(x.detail)+'</div></td></tr>').join('')+'</tbody></table>')}
|
||||
el('fnForm').addEventListener('submit',e=>{e.preventDefault();inspectFn()});
|
||||
el('refreshDecomp').addEventListener('click',renderDecomp);
|
||||
document.addEventListener('click',e=>{const a=e.target.closest('[data-fn]');if(!a)return;e.preventDefault();if(current!=='decomp')show('decomp');el('fnTarget').value=a.dataset.fn;inspectFn();el('h-inspect').scrollIntoView({behavior:'smooth',block:'start'})});
|
||||
document.addEventListener('keydown',e=>{if(e.ctrlKey||e.metaKey||e.altKey||e.target.closest('input,textarea,select,[contenteditable]')||document.querySelector('dialog[open]'))return;
|
||||
const keys=Object.keys(VIEWS).filter(k=>k!=='chat');const i=Number(e.key)-1;if(i>=0&&i<keys.length){e.preventDefault();show(keys[i])}
|
||||
else if(e.key==='c'){e.preventDefault();show('chat');el('text').focus()}
|
||||
@@ -1208,7 +1304,7 @@ document.addEventListener('visibilitychange',()=>{if(!document.hidden){unseen=0;
|
||||
const startQ=new URLSearchParams(location.search).get('q');
|
||||
const onBoard=/\/board\/?$/.test(location.pathname);
|
||||
show(startQ?'knowledge':(location.hash.slice(1)||(onBoard?store.get('board.view','overview'):'chat')));
|
||||
refresh().then(()=>{renderDigest();if(current==='activity'){renderPulse();renderFeed()}if(current==='launch')renderLaunch();if(startQ){el('q').value=startQ;el('searchForm').requestSubmit()}});
|
||||
refresh().then(()=>{renderDigest();if(current==='activity'){renderPulse();renderFeed()}if(current==='launch')renderLaunch();if(current==='decomp')renderDecomp();if(current==='health')renderTools();if(startQ){el('q').value=startQ;el('searchForm').requestSubmit()}});
|
||||
setInterval(refresh,3000);
|
||||
setInterval(()=>{if(current==='overview')renderDigest()},30000);
|
||||
setInterval(()=>{if(current==='activity')renderPulse()},60000);
|
||||
|
||||
Reference in new issue
Block a user