forked from Keelrleek/Harbor
Logbook progress map in the style of decomp.dev: "<project> is NN.NN% decompiled", a layered Code bar (exact solid, verified lighter, readable hatched, with per-segment tooltips), a Data bar (named globals of the data addresses the code touches), Previous / Next / Latest through the history reports, a Units dropdown and filter (text, <70%, >10kb), and a canvas treemap of units (original source files, else 64 KB blocks) that drills into functions
- GET /api/logbook/progress-map: one compact document, cached until an input changes (main's HEAD, the Logbook symbol table and statistics, match/emulator/library results, facts, names, source files, globals, deferral notes); ?have=<version> answers {"same": true}
- definitions follow the Logbook's progress.py (pc102.csv category game, status decompiled or later), so the headline equals game_bytes_decompiled_percent; engine, library and unsorted code are shown but not counted
- history points now record the commit and the game-code bytes
- the page lays out once per load, filter or size change, hit-tests hover on the computed rectangles, builds the 600-entry units list only when opened, and polls once a minute only while the Logbook is visible
This commit is contained in:
1 parent
315b63b571
commit
d5eb82261e
5 files changed
+556
-4
No files matched your search
+5
-2
@@ -4,7 +4,8 @@ executable, the trend, milestones and the ship's log. (It replaced a separately
|
||||
One JSON file per collection in the project's logbook folder ([logbook] dir, default <harbor home>/logbook/<project>):
|
||||
stats.json the latest statistics (functions, bytes, by_category, percentages, commit, updated_at)
|
||||
map.json the chart of the executable (low, high, cells: "game,engine,platform,crt,unsorted,done" per cell)
|
||||
history.json [{at, classified, decompiled, weighted, verified}] one point per statistics report
|
||||
history.json [{at, classified, done, decompiled, weighted, verified, commit, game_bytes}] one point per
|
||||
statistics report (commit and game_bytes since 2026-10-06)
|
||||
milestones.json [{id, code, title, phase, state, order, items: [{text, done}]}]
|
||||
log.json [{id, at, kind, title, detail}] the ship's log, newest last
|
||||
Writes replace the file atomically. Nothing here talks to the network."""
|
||||
@@ -93,7 +94,9 @@ def set_stats(project, stats, history_point=True):
|
||||
"done": round(100 * handled / total, 2) if total else 0,
|
||||
"decompiled": stats.get("game_bytes_decompiled_percent", 0),
|
||||
"weighted": stats.get("weighted_percent", 0),
|
||||
"verified": stats.get("game_bytes_verified_percent", 0)})
|
||||
"verified": stats.get("game_bytes_verified_percent", 0),
|
||||
**({"commit": stats["commit"]} if stats.get("commit") else {}),
|
||||
**({"game_bytes": cats["game"]["bytes"]} if (cats.get("game") or {}).get("bytes") else {})})
|
||||
_write(project, "history.json", history)
|
||||
return stats
|
||||
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""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).
|
||||
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"]
|
||||
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}
|
||||
return {
|
||||
"project": self.project.title or self.project.name,
|
||||
"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"))
|
||||
@@ -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 == "/api/logbook/progress-map":
|
||||
# its own connection and lock, like the Decomp view; cached until an input changes
|
||||
from . import progress_map
|
||||
status, body = progress_map.handle(h.project, query)
|
||||
return self._reply(status, body)
|
||||
if path == "/api/logbook":
|
||||
from . import logbook
|
||||
return self._reply(200, logbook.read_all(h.project, log_limit=min(int(query.get("limit", 80)), 500)))
|
||||
|
||||
+149
-1
@@ -56,6 +56,25 @@ body { overflow: hidden; }
|
||||
.view { display: none; max-width: 1380px; margin: 0 auto; gap: 20px; }
|
||||
.view.on { display: grid; }
|
||||
.lb { --lb-game: var(--blue); --lb-engine: var(--violet); --lb-platform: var(--amber); --lb-crt: var(--muted); --lb-unsorted: var(--line-2); --lb-done: var(--green); }
|
||||
/* progress map (decomp.dev style) */
|
||||
.lb { --pm-exact: var(--green); --pm-verified: color-mix(in srgb, var(--green) 62%, var(--surface)); --pm-readable: color-mix(in srgb, color-mix(in srgb, var(--green) 52%, #d8c22e) 68%, var(--surface));
|
||||
--pm-raw: var(--line-2); --pm-deferred: color-mix(in srgb, var(--line-2) 62%, var(--amber)); --pm-unsorted: color-mix(in srgb, var(--line-2) 50%, var(--surface-3));
|
||||
--pm-engine: color-mix(in srgb, var(--blue) 30%, var(--line-2)); --pm-library: color-mix(in srgb, var(--blue) 22%, var(--line-2)); }
|
||||
.pm-title { margin: 0 0 14px; font: 700 22px/1.25 var(--font-display); letter-spacing: -.4px; } .pm-title b { color: var(--green); font-variant-numeric: tabular-nums; }
|
||||
.pm-bars { display: grid; gap: 12px; }
|
||||
.pm-bar-l { display: flex; align-items: baseline; gap: 6px; font-size: 13px; margin-bottom: 5px; flex-wrap: wrap; } .pm-bar-l .grow { flex: 1; } .pm-bar-l .mono { font: 600 12.5px var(--mono); font-variant-numeric: tabular-nums; }
|
||||
.pm-bar { display: flex; height: 14px; border-radius: 7px; overflow: hidden; background: var(--surface-3); }
|
||||
.pm-seg { display: block; height: 100%; min-width: 2px; cursor: default; }
|
||||
.pm-seg.pm-exact { background: var(--pm-exact); } .pm-seg.pm-verified { background: var(--pm-verified); }
|
||||
.pm-seg.pm-readable { background: repeating-linear-gradient(135deg, var(--pm-readable) 0 4px, color-mix(in srgb, var(--pm-readable) 45%, var(--surface-3)) 4px 8px); }
|
||||
.pm-seg.pm-named { background: var(--blue); }
|
||||
.pm-commit { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; margin-top: 12px; font-size: 13px; color: var(--muted); } .pm-commit b { color: var(--text); font-weight: 600; } .pm-commit .grow { flex: 1; }
|
||||
.pm-tools { display: flex; flex-wrap: wrap; gap: 8px 10px; align-items: center; margin-top: 16px; } .pm-tools select { max-width: min(100%, 340px); } .pm-tools input { flex: 1 1 220px; min-width: 0; }
|
||||
.pm-crumb { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; margin-top: 12px; font-size: 13px; } .pm-crumb:empty { display: none; } .pm-crumb .pm-bar-row { flex: 1 1 100%; }
|
||||
.pm-wrap { position: relative; margin-top: 12px; } canvas#pmMap { width: 100%; height: 560px; display: block; border-radius: 9px; cursor: pointer; }
|
||||
.pm-tip { position: fixed; z-index: 50; pointer-events: none; max-width: 340px; padding: 8px 10px; border-radius: 8px; background: var(--surface); color: var(--text); border: 1px solid var(--line-2); box-shadow: var(--shadow-3); font-size: 12.5px; line-height: 1.45; }
|
||||
.pm-tip .mono { font-family: var(--mono); font-size: 11.5px; color: var(--muted); } .pm-tip b { overflow-wrap: anywhere; }
|
||||
@media (max-width: 700px) { canvas#pmMap { height: 380px; } .pm-title { font-size: 19px; } }
|
||||
.lb-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 10px 20px; }
|
||||
.lb-head .chip { display: inline-block; font: 600 11.5px/1 var(--font, inherit); letter-spacing: .06em; text-transform: uppercase; color: var(--accent-ink); background: var(--accent-soft); border-radius: 999px; padding: 6px 10px; }
|
||||
.lb-head p { margin: 8px 0 0; color: var(--muted); max-width: 70ch; }
|
||||
@@ -677,6 +696,22 @@ button.chip:hover { color: var(--accent-ink); background: var(--accent-soft); }
|
||||
<div><span class="chip" id="lbMilestone">Milestone</span>
|
||||
<p>Decompiling Sid Meier’s Pirates! (2004) into readable C, then rebuilding it in Rust, charted function by function against the PC 1.0.2 executable.</p></div>
|
||||
<div class="lb-stamp" id="lbStamp">Loading…</div></div></section>
|
||||
<section class="card" aria-labelledby="h-pm" id="pmCard">
|
||||
<div class="body">
|
||||
<h2 class="pm-title" id="h-pm"><span id="pmProject">The project</span> is <b id="pmPct">–</b> decompiled</h2>
|
||||
<div class="pm-bars" id="pmBars"></div>
|
||||
<div class="pm-commit"><span id="pmCommit"></span><span class="grow"></span>
|
||||
<button class="btn sm" id="pmPrev" type="button">Previous</button><button class="btn sm" id="pmNext" type="button">Next</button><button class="btn sm" id="pmLatest" type="button">Latest</button></div>
|
||||
<div class="note" id="pmHistNote" style="margin-top:6px"></div>
|
||||
<div class="pm-tools"><select id="pmUnits" aria-label="Jump to a unit"></select>
|
||||
<input type="search" id="pmFilter" placeholder="Filter: name, <70%, >10kb" aria-label="Filter units"><span class="note" id="pmCount"></span></div>
|
||||
<div class="pm-crumb" id="pmCrumb"></div>
|
||||
<div class="pm-wrap"><canvas id="pmMap" aria-label="Treemap of units, area by code bytes, colour by share decompiled"></canvas></div>
|
||||
<div class="lb-legend"><span><i class="lb-sw" style="background:var(--pm-exact)"></i>exact match</span><span><i class="lb-sw" style="background:var(--pm-verified)"></i>verified in the emulator</span><span><i class="lb-sw" style="background:var(--pm-readable)"></i>readable</span><span><i class="lb-sw" style="background:var(--pm-raw)"></i>raw</span><span><i class="lb-sw" style="background:var(--pm-deferred)"></i>deferred</span><span><i class="lb-sw" style="background:var(--pm-unsorted)"></i>unsorted (not counted)</span><span><i class="lb-sw" style="background:var(--pm-engine)"></i>engine and libraries: named, not decompiled (not counted)</span></div>
|
||||
<div class="note" style="margin-top:8px">A unit is an original source file (from the Xbox build’s asserts) or, without one, a 64 KB address block. Area is code bytes; a unit’s green is the share of its game-code bytes decompiled. Click a unit to see its functions.</div>
|
||||
<div class="pm-tip" id="pmTip" hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="lb-figs" id="lbFigs"></div>
|
||||
<section class="card" aria-labelledby="h-lbmap">
|
||||
<header><h2 id="h-lbmap"><svg class="i"><use href="static/icons.svg#layout"/></svg>Chart of the executable</h2><span class="note">bytes per category along the code addresses</span></header>
|
||||
@@ -1154,7 +1189,7 @@ async function renderLogbook(){try{LB=await get('api/logbook')}catch(e){put('lbS
|
||||
[fmt(uns),'functions not sorted yet','each one is either game code (to decompile) or engine/library code (to name)']]
|
||||
.map(([v,l,n])=>'<div class="lb-fig"><div class="v">'+esc(v)+'</div><div class="l">'+esc(l)+'</div><div class="n">'+esc(n)+'</div></div>').join(''));
|
||||
put('lbLegend',LB_CATS.map(([k,n])=>'<span><i class="lb-sw'+(k==='unsorted'?' lb-hatch':'')+'" style="background:var(--lb-'+k+')"></i>'+esc(n)+'</span>').join('')+'<span><i class="lb-sw" style="background:var(--lb-done)"></i>decompiled share under each column</span>');
|
||||
drawLbMap();renderLbWhere(s);renderLbTrend(LB.history);renderLbMilestones(LB.milestones);
|
||||
drawLbMap();pmLoad().then(()=>{if(PM.doc)pmLayout()});renderLbWhere(s);renderLbTrend(LB.history);renderLbMilestones(LB.milestones);
|
||||
el('lbLogCount').textContent=LB.log_total?LB.log_total+' entries':'';
|
||||
put('lbLog',LB.log.length?LB.log.map(e=>'<li><time>'+esc(central(e.at))+'</time><div><div class="t"><span class="kind">'+esc(e.kind)+'</span>'+esc(e.title)+'</div><div class="d">'+esc(e.detail)+'</div></div></li>').join(''):'<li class="note">No entries yet.</li>')}
|
||||
async function renderLbWhere(s){const total=s.bytes||0,cat=k=>(s.by_category||{})[k]||{functions:0,bytes:0},uns=cat('unsorted'),sorted=total?100*(total-uns.bytes)/total:0;
|
||||
@@ -1182,6 +1217,119 @@ function renderLbMilestones(list){let last='';put('lbMilestones',list.length?lis
|
||||
const items=m.items||[],done=items.filter(i=>i.done).length,li=items.map(i=>'<li class="'+(i.done?'done':'')+'">'+esc(i.text)+'</li>').join(''),open=/progress/i.test(m.state||'');
|
||||
return head+'<div class="lb-ms"><h3><span class="code">'+esc(m.code)+'</span>'+esc(m.title)+'<span class="note">'+done+'/'+items.length+(m.state?' · '+esc(m.state):'')+'</span></h3>'+(open?'<ul>'+li+'</ul>':'<details data-k="ms-'+esc(m.id)+'"><summary class="note">Show '+items.length+' steps</summary><ul>'+li+'</ul></details>')+'</div>'}).join(''):empty('flag','No milestones yet',''))}
|
||||
window.addEventListener('resize',()=>{if(current==='logbook')drawLbMap()});
|
||||
/* progress map (decomp.dev style): one compact document per change, laid out once per load, filter or resize, drawn on a canvas */
|
||||
const PM={doc:null,version:null,unit:null,hist:null,rects:[],items:[],hover:-1,base:null,colors:null,busy:false,topFilter:''};
|
||||
const PM_LABEL={exact:'exact match',verified:'verified in the emulator',readable:'readable',deferred:'deferred',raw:'raw',unsorted:'unsorted (not counted)',engine:'engine: named, not decompiled',library:'library: named, not decompiled'};
|
||||
const PM_KIND={game:'game code',unsorted:'unsorted',engine:'engine',library:'library'};
|
||||
const kB=b=>b>=1e6?(b/1e6).toFixed(2)+' MB':(b/1e3).toFixed(2)+' kB';
|
||||
const hx=a=>'0x'+Number(a).toString(16).toUpperCase();
|
||||
const pc=(b,t)=>(t?100*b/t:0).toFixed(2);
|
||||
function rel(iso){const t=Date.parse(iso||'');if(!t)return 'at an unknown time';const s=(Date.now()-t)/1000;if(s<90)return 'just now';const m=s/60;if(m<90)return Math.round(m)+' minutes ago';const h=m/60;if(h<36)return Math.round(h)+' hours ago';return Math.round(h/24)+' days ago'}
|
||||
async function pmLoad(){if(PM.busy)return;PM.busy=true;try{
|
||||
const d=await get('api/logbook/progress-map'+(PM.version?'?have='+encodeURIComponent(PM.version):''));if(d.same)return;
|
||||
const U=d.unit_fields;d.units=d.units.map((u,i)=>{const o={i,fns:[]};U.forEach((k,j)=>o[k]=u[j]);o.pct=o.counted?100*(o.exact+o.verified+o.readable)/o.counted:null;o.lname=o.name.toLowerCase();return o});
|
||||
d.functions.forEach(f=>{const o={unit:f[0],address:f[1],size:f[2],status:d.statuses[f[3]],name:f[4]};o.lname=o.name.toLowerCase();d.units[o.unit].fns.push(o)});delete d.functions;
|
||||
PM.doc=d;PM.version=d.version;if(PM.unit!=null&&!d.units[PM.unit])PM.unit=null;
|
||||
PM.latest=(()=>{const h=d.history;for(let i=h.length-1;i>=0;i--)if(h[i][0]===d.updated_at)return i;return h.length})();
|
||||
pmUnitsSelect();pmHeader();pmLayout()}
|
||||
catch(e){put('pmBars','<div class="err">'+esc(e.message)+'</div>')}finally{PM.busy=false}}
|
||||
function pmBar(label,size,segs,total,extra){const sum=segs.reduce((a,s)=>a+s[1],0);
|
||||
return '<div class="pm-bar-row"><div class="pm-bar-l"><b>'+esc(label)+'</b><span class="note">('+esc(size)+')</span>'+(extra?'<span class="note">'+esc(extra)+'</span>':'')+'<span class="grow"></span><span class="mono">'+pc(sum,total)+'%</span></div>'
|
||||
+'<div class="pm-bar" role="img" aria-label="'+esc(label+': '+segs.filter(s=>s[1]>0).map(s=>s[2]).join('; '))+'">'+segs.map(([c,b,t])=>b>0?'<span class="pm-seg '+c+'" style="width:'+(total?100*b/total:0).toFixed(3)+'%" data-tip="'+esc(t)+'"></span>':'').join('')+'</div></div>'}
|
||||
function pmCodeSegs(x,total){return [['pm-exact',x.exact,pc(x.exact,total)+'% perfect match ('+kB(x.exact)+')'],
|
||||
['pm-verified',x.verified,pc(x.verified,total)+'% decompiled, verified in the emulator, not exact ('+kB(x.verified)+')'],
|
||||
['pm-readable',x.readable,pc(x.readable,total)+'% decompiled, readable only ('+kB(x.readable)+')']]}
|
||||
function pmHeader(){const d=PM.doc;if(!d)return;const h=d.history,pt=PM.hist!=null?h[PM.hist]:null,c=d.code;
|
||||
el('pmProject').textContent=d.project;
|
||||
if(!pt){el('pmPct').textContent=d.percent.toFixed(2)+'%';
|
||||
put('pmBars',pmBar('Code',kB(c.bytes),pmCodeSegs(c,c.bytes),c.bytes,'game code · '+fmt(c.decompiled_functions)+' of '+fmt(c.functions)+' functions')
|
||||
+(d.data?pmBar('Data',fmt(d.data.addresses)+' addresses',[['pm-named',d.data.named,pc(d.data.named,d.data.addresses)+'% of the data addresses the code touches have a name in globals.csv ('+fmt(d.data.named)+' of '+fmt(d.data.addresses)+')']],d.data.addresses,'globals named'):''));
|
||||
const at=d.commit.at||d.updated_at,moved=d.main.sha&&d.commit.sha&&d.main.sha.slice(0,7)!==String(d.commit.sha).slice(0,7);
|
||||
put('pmCommit','Updated <b title="'+esc(at||'')+'">'+esc(rel(at))+'</b> in commit <b class="mono">'+esc(d.commit.sha||'–')+'</b>'+(moved?' <span class="note">· main has moved on to '+esc(d.main.sha)+'; the statistics follow within minutes</span>':''));
|
||||
el('pmHistNote').textContent=d.percent!==d.logbook_percent&&d.logbook_percent!=null?'The Logbook statistics say '+d.logbook_percent+'%; they are being rebuilt.':''}
|
||||
else{const p=pt[1],gb=pt[3];el('pmPct').textContent=Number(p).toFixed(2)+'%';
|
||||
put('pmBars',pmBar('Code',gb?kB(gb):'size not recorded',[['pm-readable',p,Number(p).toFixed(2)+'% decompiled'+(gb?' ('+kB(gb*p/100)+')':'')+'; the split into exact, verified and readable was not recorded']],100,'game code'));
|
||||
put('pmCommit','Recorded <b title="'+esc(pt[0])+'">'+esc(rel(pt[0]))+'</b>'+(pt[2]?' in commit <b class="mono">'+esc(pt[2])+'</b>':' <span class="note">· commit not recorded</span>')+' <span class="note">· report '+(PM.hist+1)+' of '+h.length+'</span>');
|
||||
el('pmHistNote').textContent='An earlier report: the headline and the Code bar are from then. The map and the Data bar show Latest, since per-function history is not stored.'}
|
||||
el('pmPrev').disabled=!h.length||PM.hist===0||(PM.hist==null&&PM.latest<1);el('pmNext').disabled=pt==null;el('pmLatest').disabled=pt==null}
|
||||
function pmStep(k){const d=PM.doc;if(!d)return;const L=PM.latest;let i=PM.hist==null?L:PM.hist;
|
||||
i=k==='latest'?L:k==='prev'?Math.max(0,i-1):i+1;PM.hist=i>=L?null:i;pmHeader()}
|
||||
function pmFilter(){const conds=[],words=[],cmp=(op,a,b)=>op==='<'?a<b:op==='<='?a<=b:op==='>'?a>b:a>=b;
|
||||
for(const t of (el('pmFilter').value||'').trim().toLowerCase().split(/\s+/).filter(Boolean)){let m;
|
||||
if((m=t.match(/^([<>]=?)(\d+(?:\.\d+)?)%$/)))conds.push(['pct',m[1],+m[2]]);
|
||||
else if((m=t.match(/^([<>]=?)(\d+(?:\.\d+)?)(b|k|kb|m|mb)?$/)))conds.push(['size',m[1],+m[2]*({b:1,k:1e3,kb:1e3,m:1e6,mb:1e6}[m[3]||'b'])]);
|
||||
else words.push(t)}
|
||||
return (name,kind,pct,size,more)=>conds.every(([k,op,v])=>k==='pct'?pct!=null&&cmp(op,pct,v):cmp(op,size,v))&&words.every(w=>name.includes(w)||kind===w||(more?more(w):false))}
|
||||
function pmLayout(){const d=PM.doc,c=el('pmMap');if(!d||!c||!c.clientWidth)return;const test=pmFilter();let items,of;
|
||||
if(PM.unit==null){items=d.units.filter(u=>u.bytes>0&&test(u.lname,u.kind,u.pct,u.bytes,w=>u.fns.some(f=>f.lname.includes(w))));of=d.units.length+' units'}
|
||||
else{const u=d.units[PM.unit];items=u.fns.filter(f=>f.size>0&&test(f.lname,f.status,['exact','verified','readable'].includes(f.status)?100:['engine','library'].includes(f.status)?null:0,f.size));of=u.fns.length+' functions'}
|
||||
const sz=x=>x.fns?x.bytes:x.size;items.sort((a,b)=>sz(b)-sz(a));
|
||||
PM.items=items;PM.w=c.clientWidth;PM.h=c.clientHeight;PM.rects=squarify(items.map(sz),0,0,PM.w,PM.h);PM.hover=-1;
|
||||
el('pmCount').textContent=items.length+' of '+of+' · '+kB(items.reduce((a,x)=>a+sz(x),0));pmCrumb();pmPaint()}
|
||||
function squarify(vals,x,y,w,h){const out=new Array(vals.length),total=vals.reduce((a,b)=>a+b,0);if(!total||w<=0||h<=0)return out;
|
||||
const scale=w*h/total,worst=(s,mn,mx,side)=>Math.max(side*side*mx/(s*s),s*s/(side*side*mn));let i=0;
|
||||
while(i<vals.length){const side=Math.min(w,h);let a=vals[i]*scale,sum=a,mn=a,mx=a,best=worst(sum,mn,mx,side),j=i+1;
|
||||
while(j<vals.length){const b=vals[j]*scale,s2=sum+b,m2=Math.min(mn,b),M2=Math.max(mx,b),w2=worst(s2,m2,M2,side);if(w2>best)break;sum=s2;mn=m2;mx=M2;best=w2;j++}
|
||||
if(w>=h){const cw=sum/h;let yy=y;for(let k=i;k<j;k++){const hh=vals[k]*scale/cw;out[k]=[x,yy,cw,hh];yy+=hh}x+=cw;w-=cw}
|
||||
else{const rh=sum/w;let xx=x;for(let k=i;k<j;k++){const ww=vals[k]*scale/rh;out[k]=[xx,y,ww,rh];xx+=ww}y+=rh;h-=rh}
|
||||
i=j}
|
||||
return out}
|
||||
function pmColors(){if(PM.colors)return PM.colors;const host=el('view-logbook'),probe=document.createElement('i'),cv=document.createElement('canvas');host.appendChild(probe);cv.width=cv.height=1;
|
||||
const g=cv.getContext('2d',{willReadFrequently:true}),rgb=v=>{probe.style.color='var('+v+')';const s=getComputedStyle(probe).color;g.clearRect(0,0,1,1);g.fillStyle='#808080';g.fillStyle=s;g.fillRect(0,0,1,1);const p=g.getImageData(0,0,1,1).data;return [p[0],p[1],p[2]]};
|
||||
const C={};['exact','verified','readable','deferred','raw','unsorted','engine','library'].forEach(k=>C[k]=rgb('--pm-'+k));C.bg=rgb('--surface');C.text=rgb('--text');probe.remove();
|
||||
return PM.colors=C}
|
||||
const pmCss=a=>'rgb('+a.map(v=>Math.round(v)).join(',')+')';
|
||||
function pmFill(it,C){if(!it.fns)return C[it.status];if(it.kind!=='game'||it.pct==null)return C[it.kind==='unsorted'?'unsorted':it.kind==='engine'?'engine':'library'];
|
||||
const t=it.pct<=0?0:.18+.82*it.pct/100;return C.raw.map((v,k)=>v+(C.exact[k]-v)*t)}
|
||||
function pmPaint(){const c=el('pmMap');if(!c||!c.clientWidth)return;const r=window.devicePixelRatio||1,W=c.clientWidth,H=c.clientHeight,C=pmColors();
|
||||
c.width=Math.round(W*r);c.height=Math.round(H*r);const off=PM.base||(PM.base=document.createElement('canvas'));off.width=c.width;off.height=c.height;
|
||||
const g=off.getContext('2d');g.setTransform(r,0,0,r,0,0);g.fillStyle=pmCss(C.bg);g.fillRect(0,0,W,H);g.textBaseline='top';
|
||||
const font=getComputedStyle(c).fontFamily;
|
||||
PM.items.forEach((it,i)=>{const q=PM.rects[i];if(!q||q[2]<.5||q[3]<.5)return;const [x,y,w,h]=q,f=pmFill(it,C);g.fillStyle=pmCss(f);g.fillRect(x+.5,y+.5,Math.max(.5,w-1),Math.max(.5,h-1));
|
||||
if(w<44||h<17)return;const light=(.299*f[0]+.587*f[1]+.114*f[2])>150;g.fillStyle=light?'rgba(0,0,0,.82)':'rgba(255,255,255,.92)';
|
||||
g.save();g.beginPath();g.rect(x+1,y+1,w-2,h-2);g.clip();g.font='600 11.5px '+font;g.fillText(it.fns?it.name.split('/').pop():it.name,x+5,y+4);
|
||||
if(h>32){g.font='11px '+font;g.globalAlpha=.8;g.fillText(it.fns?(it.pct!=null&&it.kind==='game'?it.pct.toFixed(1)+'%':PM_KIND[it.kind])+' · '+kB(it.bytes):PM_LABEL[it.status].split(':')[0]+' · '+it.size+' B',x+5,y+18)}g.restore()});
|
||||
pmBlit()}
|
||||
function pmBlit(){const c=el('pmMap');if(!PM.base||!c.width)return;const g=c.getContext('2d'),r=window.devicePixelRatio||1;g.setTransform(1,0,0,1,0,0);g.drawImage(PM.base,0,0);
|
||||
const q=PM.rects[PM.hover];if(q){g.setTransform(r,0,0,r,0,0);g.strokeStyle=pmCss(pmColors().text);g.lineWidth=2;g.strokeRect(q[0]+1,q[1]+1,Math.max(1,q[2]-2),Math.max(1,q[3]-2))}}
|
||||
function pmTipHtml(it){if(it.fns){const g=it.kind==='game'&&it.pct!=null;
|
||||
return '<b>'+esc(it.name)+'</b><div class="mono">'+hx(it.lo)+'–'+hx(it.hi)+' · '+kB(it.bytes)+' · '+fmt(it.functions)+' functions</div>'
|
||||
+(g?'<div><b>'+it.pct.toFixed(2)+'%</b> of '+kB(it.counted)+' game code decompiled</div><div class="mono">exact '+kB(it.exact)+' · verified '+kB(it.verified)+' · readable '+kB(it.readable)+'</div>'
|
||||
:'<div>'+esc(PM_KIND[it.kind])+(it.kind==='unsorted'?': not counted until sorted':': named, not decompiled; not counted')+'</div>')+'<div class="mono">click to see its functions</div>'}
|
||||
return '<b>'+esc(it.name)+'</b><div class="mono">'+hx(it.address)+'–'+hx(it.address+it.size)+' · '+fmt(it.size)+' bytes</div><div>'+esc(PM_LABEL[it.status])+'</div><div class="mono">click to inspect in the Decomp view</div>'}
|
||||
function pmTip(html,x,y){const t=el('pmTip');if(html==null){t.hidden=true;t._for=null;return}if(t._for!==html){t.innerHTML=html;t._for=html}t.hidden=false;
|
||||
const w=t.offsetWidth,h=t.offsetHeight;t.style.left=Math.max(8,Math.min(x+14,innerWidth-w-8))+'px';t.style.top=(y+16+h>innerHeight?y-h-10:y+16)+'px'}
|
||||
function pmCrumb(){const d=PM.doc;if(PM.unit==null){put('pmCrumb','');return}const u=d.units[PM.unit];
|
||||
put('pmCrumb','<button class="btn sm" type="button" id="pmBack">← All units</button><b>'+esc(u.name)+'</b><span class="tag">'+esc(PM_KIND[u.kind])+'</span><span class="note mono">'+hx(u.lo)+'–'+hx(u.hi)+' · '+fmt(u.functions)+' functions · '+kB(u.bytes)+'</span>'
|
||||
+(u.counted?pmBar('Game code in this unit',kB(u.counted),pmCodeSegs(u,u.counted),u.counted):''))}
|
||||
function pmDrill(i){if(PM.unit==null)PM.topFilter=el('pmFilter').value;PM.unit=i;el('pmFilter').value='';if(el('pmUnits')._filled)el('pmUnits').value=String(i);else pmUnitsSelect();pmTip(null);pmLayout()}
|
||||
function pmBack(){PM.unit=null;el('pmFilter').value=PM.topFilter||'';if(el('pmUnits')._filled)el('pmUnits').value='';else pmUnitsSelect();pmTip(null);pmLayout()}
|
||||
function pmUnitsSelect(){const d=PM.doc,s=el('pmUnits'),u=PM.unit!=null?d.units[PM.unit]:null;s._filled=false; /* the full list is built only when someone opens it */
|
||||
put(s,'<option value="">All units ('+d.units.length+')</option>'+(u?'<option value="'+u.i+'">'+esc(u.name)+'</option>':''));s.value=u?String(u.i):''}
|
||||
function pmFillUnits(){const d=PM.doc,s=el('pmUnits');if(!d||s._filled)return;s._filled=true;const groups={game:[],unsorted:[],engine:[],library:[]};d.units.forEach(u=>(groups[u.kind]||groups.library).push(u));
|
||||
put(s,'<option value="">All units ('+d.units.length+')</option>'+[['game','Game code'],['unsorted','Unsorted'],['engine','Engine (Gamebryo)'],['library','Libraries']].map(([k,l])=>groups[k].length?'<optgroup label="'+esc(l)+'">'
|
||||
+groups[k].sort((a,b)=>a.name.localeCompare(b.name)).map(u=>'<option value="'+u.i+'">'+esc(u.name)+(k==='game'&&u.pct!=null?' · '+Math.floor(u.pct)+'%':'')+'</option>').join('')+'</optgroup>':'').join(''));
|
||||
s.value=PM.unit==null?'':String(PM.unit)}
|
||||
(()=>{const c=el('pmMap');let pending=null;document.body.appendChild(el('pmTip')); /* fixed position must be relative to the viewport, not a card */
|
||||
const hit=(x,y)=>{for(let i=0;i<PM.rects.length;i++){const q=PM.rects[i];if(q&&x>=q[0]&&x<q[0]+q[2]&&y>=q[1]&&y<q[1]+q[3])return i}return -1};
|
||||
c.addEventListener('mousemove',e=>{const b=c.getBoundingClientRect(),i=hit(e.clientX-b.left,e.clientY-b.top);
|
||||
if(i!==PM.hover){PM.hover=i;if(!pending)pending=requestAnimationFrame(()=>{pending=null;pmBlit()})}pmTip(i<0?null:pmTipHtml(PM.items[i]),e.clientX,e.clientY)});
|
||||
c.addEventListener('mouseleave',()=>{PM.hover=-1;pmBlit();pmTip(null)});
|
||||
c.addEventListener('click',e=>{const b=c.getBoundingClientRect(),i=hit(e.clientX-b.left,e.clientY-b.top);if(i<0)return;const it=PM.items[i];
|
||||
if(it.fns)return pmDrill(it.i);pmTip(null);show('decomp');el('fnTarget').value=hx(it.address);inspectFn();el('h-inspect').scrollIntoView({block:'start'})});
|
||||
const card=el('pmCard');card.addEventListener('mouseover',e=>{const s=e.target.closest('[data-tip]');if(s)pmTip(esc(s.dataset.tip),e.clientX,e.clientY)});
|
||||
card.addEventListener('mousemove',e=>{const s=e.target.closest('[data-tip]');if(s)pmTip(esc(s.dataset.tip),e.clientX,e.clientY)});
|
||||
card.addEventListener('mouseout',e=>{if(e.target.closest('[data-tip]'))pmTip(null)});
|
||||
card.addEventListener('click',e=>{if(e.target.closest('#pmBack'))pmBack()});
|
||||
['pointerdown','focus','keydown'].forEach(t=>el('pmUnits').addEventListener(t,pmFillUnits));
|
||||
el('pmUnits').addEventListener('change',e=>{const v=e.target.value;if(v==='')pmBack();else pmDrill(Number(v))});
|
||||
let ft=0;el('pmFilter').addEventListener('input',()=>{clearTimeout(ft);ft=setTimeout(pmLayout,180)});
|
||||
el('pmPrev').onclick=()=>pmStep('prev');el('pmNext').onclick=()=>pmStep('next');el('pmLatest').onclick=()=>pmStep('latest');
|
||||
let rt=0;new ResizeObserver(()=>{clearTimeout(rt);rt=setTimeout(()=>{if(current==='logbook'&&c.clientWidth&&(c.clientWidth!==PM.w||c.clientHeight!==PM.h))pmLayout()},120)}).observe(c); /* only when its size really changes */
|
||||
const recolor=()=>{PM.colors=null;if(current==='logbook'&&PM.doc)pmPaint()};
|
||||
new MutationObserver(recolor).observe(document.documentElement,{attributes:true,attributeFilter:['data-theme','data-palette']});
|
||||
matchMedia('(prefers-color-scheme: dark)').addEventListener('change',recolor);
|
||||
every(()=>{if(current==='logbook')return pmLoad()},60000,null)})();
|
||||
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'})});
|
||||
|
||||
Reference in new issue
Block a user