Files
BT411/tools/manifest410.py
T
CydandClaude Fable 5 ec43e365fa 4.10 literal-source campaign: manifest DP upgrade, Route A closed, toolchain facts
- manifest410.py: monotone-DP TU labeling (data-cluster affinity + CLASSMAP
  seeds + recon @addr soft votes) replaces naive fill; BT region = 917 fn /
  ~230 KB, extents match every independently-known TU boundary.
- source-completeness: ROUTE A CLOSED (Nick holds no further assets);
  toolchain = BC++ 4.52 PROVEN by CW32.LIB byte-match, archived at
  TeslaRel410/BORLAND; OPT.MAK = the shipped binary's exact recipe; extender
  corrected to Borland PowerPack DPMI32 (was "Phar Lap TNT"); "all BT headers
  survive" corrected (17/36 bt-side; mech.hpp etc. reconstruction-only);
  engine gaps enumerated (vdata.hpp first, back-dated from BT412).
- phases/phase-03: rounds 1-3 of the source410 campaign - 6/10 surviving
  originals compile clean under BC4.52; console wire IDs recovered from the
  binary ctors (Killed=9 Damaged=10 ScoreUpdate=13 DWH=15 [T1], TeamScore
  guessed 12 [T4] - answers the TeslaSuite console-port spec's open item);
  round-3 finding: MECH.HPP is the capstone grown with the mech TU
  reconstructions, BTREG.CPP green = the header-family milestone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 07:33:59 -05:00

549 lines
23 KiB
Python

#!/usr/bin/env python3
"""
manifest410.py -- per-TU manifest of BTL4OPT.EXE for the 4.10 literal-source
reconstruction (the "size of the mountain" measurement).
Inputs (all already in the repo):
reference/decomp/all/part_*.c -- Ghidra export; every function is headed by
/* @00ADDR file=TAG name=NAME */
reference/decomp/section_dump.txt -- hex dump of the data sections (VA -> bytes);
used to resolve s_*_00ADDR string symbols to
their REAL contents (assert file paths).
game/original/BT/BT.MAK, BT_L4/BTL4.MAK -- the authoritative TU census + link
order for bt.lib (36 TUs) / btl4.lib (13 TUs).
game/reconstructed/*.cpp|*.hpp -- the port; @00ADDR annotations are harvested
to measure which binary ranges are already
semantically decoded.
Attribution signals, strongest first:
1. an assert string inside the body naming the source file (+ line number);
2. the exporter's file= tag;
3. link-order fill: a function between two anchors of the SAME TU inherits it
(confident); between anchors of DIFFERENT TUs it lands in a boundary zone
(uncertain -- counted separately, never claimed).
Outputs:
reference/BT410_SOURCE_MANIFEST.md -- human summary tables
reference/BT410_SOURCE_MANIFEST.json -- machine form (per-function attribution)
Usage: py -3 tools/manifest410.py (from the BT411 repo root)
"""
import json
import os
import re
import sys
from collections import defaultdict
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
DECOMP_DIR = os.path.join(ROOT, "reference", "decomp", "all")
SECTION_DUMP = os.path.join(ROOT, "reference", "decomp", "section_dump.txt")
RECON_DIR = os.path.join(ROOT, "game", "reconstructed")
ORIG_BT = os.path.join(ROOT, "game", "original", "BT")
ORIG_BTL4 = os.path.join(ROOT, "game", "original", "BT_L4")
OUT_MD = os.path.join(ROOT, "reference", "BT410_SOURCE_MANIFEST.md")
OUT_JSON = os.path.join(ROOT, "reference", "BT410_SOURCE_MANIFEST.json")
# ---------------------------------------------------------------- census ----
# The authoritative TU lists, in LINK ORDER, from the surviving makefiles.
BT_LIB_TUS = [ # game/original/BT/BT.MAK BT_OBJS
"btmssn", "messmgr", "mechdmg", "dmgtable", "mech", "mech2", "mech3",
"mech4", "mechsub", "mechtech", "heat", "mechmppr", "powersub", "sensor",
"gnrator", "gyro", "torso", "hud", "myomers", "mechweap", "emitter",
"ppc", "projweap", "mislanch", "ammobin", "gauss", "projtile", "misthrst",
"seeker", "missile", "btplayer", "btteam", "btdirect", "btreg", "btcnsl",
"bttool",
]
BTL4_LIB_TUS = [ # game/original/BT_L4/BTL4.MAK BTL4_OBJS
"btl4mode", "btl4rdr", "btl4gaug", "btl4gau2", "btl4gau3", "btl4grnd",
"btl4galm", "btl4vid", "btl4mppr", "btl4arnd", "btl4mssn", "btl4app",
"btl4pb",
]
MAIN_TUS = ["btl4"] # btl4.obj, linked directly
def tu_key(prefix, name):
return "%s/%s.cpp" % (prefix, name)
CENSUS = (
[tu_key("bt", n) for n in BT_LIB_TUS]
+ [tu_key("bt_l4", n) for n in BTL4_LIB_TUS]
+ [tu_key("bt_l4", n) for n in MAIN_TUS]
)
CENSUS_SET = set(CENSUS)
# ------------------------------------------------------- section dump -------
def load_string_table():
"""Parse section_dump.txt (objdump -s style: ' VA GGGGGGGG x4 ascii')
into (VA->byte map, [(section_name, lo, hi)]); strings are read lazily
via read_cstr()."""
mem = {}
sections = [] # (name, lo, hi)
cur_name, cur_lo, cur_hi = None, None, None
pat = re.compile(r"^ ([0-9a-f]{6,8}) ((?:[0-9a-f]{2,8} ){1,4})", re.I)
sec = re.compile(r"^Contents of section (\S+):")
with open(SECTION_DUMP, "r", errors="replace") as f:
for line in f:
sm = sec.match(line)
if sm:
if cur_name is not None:
sections.append((cur_name, cur_lo, cur_hi))
cur_name, cur_lo, cur_hi = sm.group(1), None, None
continue
m = pat.match(line)
if not m:
continue
va = int(m.group(1), 16)
off = 0
for group in m.group(2).split():
if len(group) % 2:
continue
for i in range(0, len(group), 2):
mem[va + off] = int(group[i:i + 2], 16)
off += 1
if cur_lo is None:
cur_lo = va
cur_hi = va + off
if cur_name is not None:
sections.append((cur_name, cur_lo, cur_hi))
return mem, sections
def read_cstr(mem, va, maxlen=260):
out = []
for i in range(maxlen):
b = mem.get(va + i)
if b is None or b == 0:
break
out.append(chr(b) if 32 <= b < 127 else "?")
return "".join(out)
PATH_RE = re.compile(r"^[a-z]:[\\/].*\.(cpp|hpp|tcp|thp|c|h|asm)$", re.I)
def normalize_path(p):
r"""d:\tesla_bt\bt_l4\BTL4MSSN.CPP -> bt_l4/btl4mssn.cpp (last two comps)."""
parts = re.split(r"[\\/]+", p.lower())
if len(parts) >= 2:
return "%s/%s" % (parts[-2], parts[-1])
return parts[-1]
# ------------------------------------------------------------ decomp --------
FUNC_HDR = re.compile(r"^/\* @([0-9a-f]{8}) file=(\S+) name=(\S+) \*/")
SYM_LINE = re.compile(r"s_\w+?_(00[0-9a-f]{6})\s*,\s*(0x[0-9a-f]+|\d+)\s*[),]")
# every symbolic reference into the image (code or data)
ANY_REF = re.compile(r"\b(?:s_\w+?|DAT|PTR_FUN|PTR_DAT|PTR_LAB|UNK)_(00[0-9a-f]{6})\b")
def parse_decomp(mem):
"""Return sorted list of dicts:
addr, name, tag, part, paths{norm:[lines]}, refs[set of VAs]."""
funcs = []
for fn in sorted(os.listdir(DECOMP_DIR)):
if not fn.endswith(".c"):
continue
part = fn
cur = None
with open(os.path.join(DECOMP_DIR, fn), "r", errors="replace") as f:
for line in f:
m = FUNC_HDR.match(line)
if m:
cur = {
"addr": int(m.group(1), 16),
"tag": m.group(2),
"name": m.group(3),
"part": part,
"paths": defaultdict(list), # norm path -> [line numbers]
"refs": set(), # referenced VAs
}
funcs.append(cur)
continue
if cur is None:
continue
for sm in SYM_LINE.finditer(line):
va = int(sm.group(1), 16)
s = read_cstr(mem, va)
if PATH_RE.match(s):
cur["paths"][normalize_path(s)].append(int(sm.group(2), 0))
for sm in ANY_REF.finditer(line):
va = int(sm.group(1), 16)
cur["refs"].add(va)
s = read_cstr(mem, va)
if PATH_RE.match(s):
cur["paths"][normalize_path(s)] # touch (no line info)
funcs.sort(key=lambda d: d["addr"])
return funcs
# ------------------------------------------------------- attribution --------
#
# Anchors are sparse (release build: only hard Fail() sites carry file
# strings), so attribution leans on a second linker invariant: each OBJ's
# DATA (vtables, literals, statics) is laid out contiguously per section in
# the SAME link order as its code. Every data VA referenced by an anchored
# function becomes a known (VA -> TU) point; an unanchored function's data
# refs then VOTE: a ref whose nearest known neighbors below AND above in the
# same section agree contributes one vote for that TU. Assignments feed
# back as new known points/anchors and the pass iterates to a fixed point,
# then plain link-order fill closes the gaps between agreeing code anchors.
#
def _data_sections(sections):
out = [(n, lo, hi) for (n, lo, hi) in sections
if n.upper() != "CODE" and lo is not None]
# .bss is not in the dump (no contents) but its statics are laid out in
# the same per-OBJ link order -- treat the VA range above the last dumped
# data section as a virtual section so bss refs can vote too.
data_hi = max(hi for (n, lo, hi) in out if n.upper() == "DATA")
out.append((".bss~", data_hi, 0x540000))
return out
# Verified TU anchor addresses from game/reconstructed/CLASSMAP.md and the
# context files ([T1] each: ctors / named functions whose owning TU is
# established). These seed the data-cluster voting where Fail() strings are
# absent, and let the link-order sanity check run.
SEED_ANCHORS = {
0x49B54C: "bt/btmssn.cpp", # BTMission ctor
0x49BCA4: "bt/messmgr.cpp", # SubsystemMessageManager ctor
0x49DE14: "bt/dmgtable.cpp", # DamageZonePercentTable SelectZone
0x49EA48: "bt/dmgtable.cpp", # DamageLookupTable ctor
0x49F624: "bt/mech.cpp", # mech<->player bind
0x4A2D48: "bt/mech.cpp", # Mech::Make
0x4ADDA0: "bt/heat.cpp", # heat subsystem ctor (file= tagged)
0x4AFBE0: "bt/mechmppr.cpp", # CycleControlMode
0x4B0EFC: "bt/powersub.cpp", # PoweredSubsystem::HandleMessage
0x4B3778: "bt/gyro.cpp", # Gyroscope ctor
0x4B6918: "bt/torso.cpp", # Torso::Recenter
0x4B8D18: "bt/myomers.cpp", # MyomersSimulation
0x4B9728: "bt/mechweap.cpp", # MechWeapon::SendDamageMessage
0x4BBFCC: "bt/projweap.cpp", # CheckForJam
0x4BEF78: "bt/missile.cpp", # Missile MoveAndCollide
0x4C0BC8: "bt/btplayer.cpp", # BTPlayer ctor
0x4C1C24: "bt_l4/btl4rdr.cpp", # MapDisplay ctor
0x4C2F94: "bt_l4/btl4gaug.cpp", # gauge TU start (CLASSMAP extent)
0x4C6798: "bt_l4/btl4gau2.cpp", # gau2 TU start (CLASSMAP extent)
0x4CC40C: "bt_l4/btl4vid.cpp", # BTReticleRenderable ctor
0x4D1AE4: "bt_l4/btl4mppr.cpp", # L4MechControlsMapper::SetControlMode
0x4D2C30: "bt_l4/btl4mssn.cpp", # BTL4Mission::SetPlayerData
0x4D34C4: "bt_l4/btl4app.cpp", # BTL4Application ctor
0x4D3BE4: "bt_l4/btl4pb.cpp", # BTL4PlaybackApplication ctor
}
def _section_of(va, dsecs):
for i, (_, lo, hi) in enumerate(dsecs):
if lo <= va < hi:
return i
return None
def attribute(funcs, sections, recon_affinity=None):
"""Set f['file'] + f['how'] in {assert, seed, tag, dp, rtl, none}.
recon_affinity: {addr: tu_key} soft votes from name-matched
game/reconstructed files' @address annotations."""
dsecs = _data_sections(sections)
recon_affinity = recon_affinity or {}
# pass 1: direct evidence
for f in funcs:
if f["paths"]:
best = max(
f["paths"].items(),
key=lambda kv: (len(kv[1]) > 0, len(kv[1]), kv[0]),
)[0]
f["file"], f["how"] = best, "assert"
elif f["addr"] in SEED_ANCHORS:
f["file"], f["how"] = SEED_ANCHORS[f["addr"]], "seed"
elif f["tag"] != "?":
f["file"], f["how"] = f["tag"].lower(), "tag"
else:
f["file"], f["how"] = None, "none"
# ---- pass 2: monotone DP labeling of the BT region -------------------
#
# bt.lib + btl4.lib are the last game libs before the Borland RTL, so
# inside [first BT anchor .. RTL sentinel) the TU label sequence along
# the address axis must be NON-DECREASING in makefile link order. A DP
# picks, among all monotone labelings that respect the anchors, the one
# maximizing data-cluster affinity. The REGION TOTALS are exact under
# the sentinels even where an individual TU boundary is soft.
#
import bisect
# anchor-known data points (assert/seed/tag only -- no cascade); VAs
# referenced from >1 file are shared globals: non-discriminative.
claims = defaultdict(set)
for f in funcs:
if f["file"] is not None:
for va in f["refs"]:
claims[va].add(f["file"])
shared = {va for va, owners in claims.items() if len(owners) > 1}
known = [[] for _ in dsecs]
for f in funcs:
if f["file"] is None:
continue
for va in f["refs"]:
if va not in shared:
si = _section_of(va, dsecs)
if si is not None:
known[si].append((va, f["file"]))
for k in known:
k.sort()
def affinity(f):
votes = defaultdict(float)
for va in f["refs"]:
if va in shared:
continue
si = _section_of(va, dsecs)
if si is None or not known[si]:
continue
k = known[si]
i = bisect.bisect_left(k, (va, ""))
if i < len(k) and k[i][0] == va:
votes[k[i][1]] += 2.0 # unique exact VA match
continue
lo = k[i - 1][1] if i > 0 else None
hi = k[i][1] if i < len(k) else None
if lo is not None and lo == hi:
votes[lo] += 1.0 # inside one TU's data cluster
return votes
RTL_SENTINEL = 0x4D4B58 # first known Borland RTL fn
# btl4.obj is linked right after the startup OBJ (see BTL4.MAK tlink32
# response), i.e. at the BOTTOM of the image -- it is NOT part of the
# bt.lib/btl4.lib region and is excluded from the ordered labeling.
labels = [t for t in CENSUS if t != "bt_l4/btl4.cpp"] + ["<rtl>"]
label_ix = {t: i for i, t in enumerate(labels)}
lo_i = next(i for i, f in enumerate(funcs)
if f["file"] in CENSUS_SET)
hi_i = next(i for i, f in enumerate(funcs) if f["addr"] >= RTL_SENTINEL)
region = funcs[lo_i:hi_i + 1]
NEG = -1e18
n, m = len(region), len(labels)
score = [[0.0] * m for _ in range(n)]
for r, f in enumerate(region):
if f["file"] in label_ix: # anchored: pin it
for c in range(m):
score[r][c] = 0.0 if c == label_ix[f["file"]] else NEG
elif f["addr"] >= RTL_SENTINEL:
for c in range(m):
score[r][c] = 0.0 if labels[c] == "<rtl>" else NEG
else:
votes = affinity(f)
t = recon_affinity.get(f["addr"])
if t is not None:
votes[t] += 0.75 # port citation, soft
for t, v in votes.items():
if t in label_ix:
score[r][label_ix[t]] = v
# DP: best[r][c] = max over c' <= c of best[r-1][c'] + score[r][c]
best = [row[:] for row in score]
back = [[0] * m for _ in range(n)]
for c in range(1, m):
if best[0][c] < best[0][c - 1]:
best[0][c] = best[0][c - 1]
for r in range(1, n):
run_best, run_ix = NEG, 0
for c in range(m):
if best[r - 1][c] > run_best:
run_best, run_ix = best[r - 1][c], c
back[r][c] = run_ix
best[r][c] = (run_best + score[r][c]) if run_best > NEG / 2 \
and score[r][c] > NEG / 2 else NEG
# trace back from the best final cell
c = max(range(m), key=lambda cc: best[n - 1][cc])
for r in range(n - 1, -1, -1):
f = region[r]
if f["file"] is None:
f["file"] = labels[c]
f["how"] = "dp"
c = back[r][c]
for f in funcs:
if f["file"] == "<rtl>":
f["file"], f["how"] = None, "rtl"
return funcs
# ------------------------------------------------- reconstructed map --------
ADDR_ANNOT = re.compile(r"@\s?(00[0-9a-f]{6})\b|@([0-9a-f]{6})\b", re.I)
def recon_addresses():
"""Harvest every @00XXXXXX annotation from game/reconstructed sources."""
hits = defaultdict(set) # file -> set of addrs
for fn in sorted(os.listdir(RECON_DIR)):
if not fn.endswith((".cpp", ".hpp", ".md")):
continue
with open(os.path.join(RECON_DIR, fn), "r", errors="replace") as f:
text = f.read()
for m in ADDR_ANNOT.finditer(text):
a = int(m.group(1) or m.group(2), 16)
if 0x400000 <= a <= 0x540000:
hits[fn].add(a)
return hits
# ------------------------------------------------------ survivors -----------
def survivors():
"""Map census key -> 'full' | 'partial(TCP)' from game/original."""
out = {}
for d, prefix in ((ORIG_BT, "bt"), (ORIG_BTL4, "bt_l4")):
if not os.path.isdir(d):
continue
for fn in os.listdir(d):
base, ext = os.path.splitext(fn.lower())
key = tu_key(prefix, base)
if ext == ".cpp" and key in CENSUS_SET:
out[key] = "full"
elif ext == ".tcp":
k2 = tu_key(prefix, base)
if k2 in CENSUS_SET and out.get(k2) != "full":
out[k2] = "partial(TCP)"
return out
# ------------------------------------------------------------- main ---------
def main():
print("loading section dump ...")
mem, sections = load_string_table()
print(" %d bytes mapped, sections: %s" % (
len(mem), ", ".join("%s@%x-%x" % s for s in sections)))
print("parsing decomp ...")
funcs = parse_decomp(mem)
print(" %d functions" % len(funcs))
# soft affinity: a recon file named like a census TU votes for that TU
# at every address it cites (cross-citations are outvoted by the DP's
# monotone constraint)
recon_pre = recon_addresses()
recon_affinity = {}
for fn, addrs in recon_pre.items():
base = os.path.splitext(fn)[0].lower()
for key in (tu_key("bt", base), tu_key("bt_l4", base)):
if key in CENSUS_SET:
for a in addrs:
recon_affinity[a] = key
attribute(funcs, sections, recon_affinity)
# sizes: delta to next function
for f, g in zip(funcs, funcs[1:]):
f["size"] = g["addr"] - f["addr"]
funcs[-1]["size"] = 0
recon = recon_addresses()
recon_all = set().union(*recon.values()) if recon else set()
surv = survivors()
# per-TU aggregation (census TUs + whatever engine files showed up)
tus = defaultdict(lambda: {
"funcs": 0, "bytes": 0, "assert": 0, "data": 0, "fill": 0, "tag": 0,
"lo": None, "hi": None, "max_line": 0, "recon_hits": 0,
})
boundary = 0
unattributed = 0
for f in funcs:
if f["how"] == "boundary":
boundary += 1
continue
if f["file"] is None:
unattributed += 1
continue
t = tus[f["file"]]
t["funcs"] += 1
t["bytes"] += f["size"]
bucket = {"assert": "assert", "dp": "fill", "data": "data"}.get(f["how"], "tag")
t[bucket] += 1
t["lo"] = f["addr"] if t["lo"] is None else min(t["lo"], f["addr"])
t["hi"] = max(t["hi"] or 0, f["addr"] + f["size"])
for lines in f["paths"].values():
for n in lines:
t["max_line"] = max(t["max_line"], n)
if f["addr"] in recon_all:
t["recon_hits"] += 1
# ------------------------------------------------------------- write ----
def tbl_row(key):
t = tus.get(key)
s = surv.get(key, "")
if not t:
return "| %s | %s | — | — | — | — | — | — | 0 |" % (key, s or "—")
return "| %s | %s | %d | %.1f | %06x-%06x | %d | %d | %d | %d |" % (
key, s or "—", t["funcs"], t["bytes"] / 1024.0,
t["lo"], t["hi"], t["assert"], t["data"] + t["fill"],
t["max_line"], t["recon_hits"],
)
bt_keys = [tu_key("bt", n) for n in BT_LIB_TUS]
btl4_keys = [tu_key("bt_l4", n) for n in BTL4_LIB_TUS + MAIN_TUS]
engine_keys = sorted(k for k in tus if k not in CENSUS_SET)
# link-order sanity: census TU code ranges must ascend in makefile order
order_violations = []
prev = None
for k in bt_keys + btl4_keys:
t = tus.get(k)
if not t or t["lo"] is None:
continue
if prev and prev[1] > t["lo"]:
order_violations.append("%s (ends %06x) overlaps %s (starts %06x)"
% (prev[0], prev[1], k, t["lo"]))
prev = (k, t["hi"])
bt_total = sum(tus[k]["bytes"] for k in bt_keys if k in tus)
btl4_total = sum(tus[k]["bytes"] for k in btl4_keys if k in tus)
bt_funcs = sum(tus[k]["funcs"] for k in bt_keys if k in tus)
btl4_funcs = sum(tus[k]["funcs"] for k in btl4_keys if k in tus)
hdr = ("| TU | survives | funcs | KB | addr range | assert-anchored | "
"DP-inferred | max assert line | recon @addr hits |\n"
"|---|---|---|---|---|---|---|---|---|")
with open(OUT_MD, "w", encoding="utf-8") as f:
f.write("# BT 4.10 source manifest — measured from BTL4OPT.EXE\n\n")
f.write("Generated by `tools/manifest410.py`. Census = the surviving "
"makefiles (BT.MAK 36 TUs, BTL4.MAK 13 TUs + btl4.obj, in "
"LINK ORDER). Attribution = Fail() assert strings resolved "
"via section_dump [T1 anchors] + CLASSMAP seed addresses "
"[T1] + a monotone-in-link-order DP over data-section "
"cluster affinity and recon @addr citations [T3 for "
"unanchored boundaries]. A `—` row = TU NOT LOCALIZED: its "
"code is contained inside an adjacent row's range (no "
"anchor evidence yet); 8 such TUs survive as full .cpp "
"anyway. `bt_l4/btl4.cpp` links at the image BOTTOM with "
"the startup OBJ and is not part of this region.\n\n")
f.write("Totals: **%d functions** in the image; BT-side: **%d funcs / "
"%.0f KB** (bt.lib) + **%d funcs / %.0f KB** (btl4.lib+main); "
"boundary-uncertain: %d; unattributed (outside any anchor "
"span): %d.\n\n" % (
len(funcs), bt_funcs, bt_total / 1024.0,
btl4_funcs, btl4_total / 1024.0, boundary, unattributed))
f.write("`recon @addr hits` = binary function addresses cited in "
"`game/reconstructed/` — a proxy for how much of the TU is "
"already semantically decoded (not a completeness claim).\n\n")
f.write("## bt.lib (link order)\n\n%s\n" % hdr)
for k in bt_keys:
f.write(tbl_row(k) + "\n")
f.write("\n## btl4.lib + main (link order)\n\n%s\n" % hdr)
for k in btl4_keys:
f.write(tbl_row(k) + "\n")
if order_violations:
f.write("\n## ⚠ Link-order violations (attribution suspects)\n\n")
for v in order_violations:
f.write("- %s\n" % v)
f.write("\n## Engine/other TUs seen in the image (context only — "
"their source survives)\n\n")
f.write("| TU | funcs | KB |\n|---|---|---|\n")
for k in engine_keys:
t = tus[k]
f.write("| %s | %d | %.1f |\n" % (k, t["funcs"], t["bytes"] / 1024.0))
with open(OUT_JSON, "w", encoding="utf-8") as f:
json.dump({
"census": CENSUS,
"functions": [
{"addr": "%08x" % fu["addr"], "name": fu["name"],
"file": fu["file"], "how": fu["how"], "size": fu["size"],
"lines": {k: v for k, v in fu["paths"].items() if v}}
for fu in funcs
],
}, f, indent=1)
print("wrote %s\nwrote %s" % (OUT_MD, OUT_JSON))
print("BT-side: %d funcs / %.0f KB btl4-side: %d funcs / %.0f KB "
"boundary %d unattributed %d" % (
bt_funcs, bt_total / 1024.0, btl4_funcs, btl4_total / 1024.0,
boundary, unattributed))
if __name__ == "__main__":
sys.exit(main())