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:
KeelrLeek committed 2026-10-05 16:08:51 -05:00
1 parent a9efc2e5c5
commit 87631bec52
5 files changed
+331 -6

No files matched your search

+200
View File
@@ -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
View File
@@ -129,14 +129,14 @@ class FunctionQueue:
return addr in library or any(lo <= addr < hi for lo, hi in ranges) return addr in library or any(lo <= addr < hi for lo, hi in ranges)
# ------------------------------------------------------------------ queue # ------------------------------------------------------------------ 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] d = facts[addr]
size = int(d.get("size") or 0) size = int(d.get("size") or 0)
calls = [int(c, 16) if isinstance(c, str) else c for c in d.get("calls", [])] 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 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 = 1.0 / (1.0 + size / 250.0) # small first
score *= 0.5 + 0.5 * named_share # callees with names make readable code possible 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"): if conv and conv["summary"].startswith("custom"):
score *= 0.75 # register arguments: prototypes.h work too score *= 0.75 # register arguments: prototypes.h work too
if self._category(addr) not in ("game", "unsorted"): 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"" 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": 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) 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.append((score, addr, reasons))
ranked.sort(reverse=True) 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), 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]] "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() facts, statuses, deferred = self.r._facts(), self.statuses(), self.deferrals()
exact = {a for a, row in self._results("match_results").items() exact = {a for a, row in self._results("match_results").items()
if str(row.get("status")) in ("match", "exact", "exact-relaxed")} 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")) library = set(self._results("library_results"))
ranges = self.library_ranges() ranges = self.library_ranges()
tiers = {t: {"functions": 0, "bytes": 0} for t in TIER_ORDER + ("library", "engine")} tiers = {t: {"functions": 0, "bytes": 0} for t in TIER_ORDER + ("library", "engine")}
+19
View File
@@ -161,10 +161,29 @@ class Reverse:
if re.fullmatch(r"(0x)?[0-9A-Fa-f]{5,8}", text): if re.fullmatch(r"(0x)?[0-9A-Fa-f]{5,8}", text):
return int(text, 16) return int(text, 16)
addr = self.address_of(text) 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: if addr is None:
raise HarborError(f"{target!r} is neither an address nor a known name") raise HarborError(f"{target!r} is neither an address nor a known name")
return addr 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): def convention(self, addr):
"""The calling convention read from the bytes: register inputs (a dataflow pass) and the `ret N` cleanup.""" """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(): if not self.conventions_path or not self.conventions_path.exists():
+5
View File
@@ -330,6 +330,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
"stopped": any(h.stopped(a) for a in h.project.ai_names)}) "stopped": any(h.stopped(a) for a in h.project.ai_names)})
return self._reply(200, out) return self._reply(200, out)
h, lock = REGISTRY.get(name) 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/"): if path.startswith("/files/"):
filename = unquote(path[len("/files/"):]) filename = unquote(path[len("/files/"):])
if not re.fullmatch(r"[0-9a-f]{12}-[A-Za-z0-9._-]{1,80}", filename): if not re.fullmatch(r"[0-9a-f]{12}-[A-Za-z0-9._-]{1,80}", filename):
+98 -2
View File
@@ -55,6 +55,20 @@ body { overflow: hidden; }
.content { flex: 1; min-height: 0; overflow-y: auto; padding: 24px 32px 56px; } .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 { display: none; max-width: 1380px; margin: 0 auto; gap: 20px; }
.view.on { display: grid; } .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; } .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; } .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; } .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="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="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="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="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="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> <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> </div>
</section> </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 --> <!-- health -->
<section class="view" id="view-health" aria-label="Health"> <section class="view" id="view-health" aria-label="Health">
<section class="card" aria-labelledby="h-notify"> <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> <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> <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>
<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"> <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> <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> <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'], 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'], 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'], 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; 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('.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')}); 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==='overview')renderDigest();
if(data&&view==='activity'){renderPulse();renderFeed()} if(data&&view==='activity'){renderPulse();renderFeed()}
if(data&&view==='launch')renderLaunch(); if(data&&view==='launch')renderLaunch();
if(data&&view==='decomp')renderDecomp();
if(view==='health')renderTools();
if(view==='health'&&!doctorRan)runDoctor()} if(view==='health'&&!doctorRan)runDoctor()}
function paintTitle(){document.title=(unseen&&document.hidden?'('+unseen+') ':'')+(data?data.project.title+' · ':'')+(current?VIEWS[current][0]:'Harbor')} 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('menuBtn').onclick=()=>el('shell').classList.add('open');
el('scrim').onclick=()=>el('shell').classList.remove('open'); el('scrim').onclick=()=>el('shell').classList.remove('open');
window.addEventListener('hashchange',()=>{const v=location.hash.slice(1);if(v&&v!==current)show(v)}); 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; 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])} 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()} 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 startQ=new URLSearchParams(location.search).get('q');
const onBoard=/\/board\/?$/.test(location.pathname); const onBoard=/\/board\/?$/.test(location.pathname);
show(startQ?'knowledge':(location.hash.slice(1)||(onBoard?store.get('board.view','overview'):'chat'))); 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(refresh,3000);
setInterval(()=>{if(current==='overview')renderDigest()},30000); setInterval(()=>{if(current==='overview')renderDigest()},30000);
setInterval(()=>{if(current==='activity')renderPulse()},60000); setInterval(()=>{if(current==='activity')renderPulse()},60000);