Files
Cesarus85--mjolnir-vr/tools/campaign/gate.py
T

154 lines
6.2 KiB
Python

"""Gate harness: compile a unit (optionally with find/replace edits) and report per-function status.
usage: python tools/campaign/gate.py <unit-path-no-ext> [--edits edits.json] [--fn NAME ...] [--disas NAME]
Example: python tools/campaign/gate.py source/hs/hs --fn _hs_find_script_by_name --disas _hs_find_script_by_name
"""
import sys, json, os, re, subprocess, argparse
sys.path.insert(0, 'tools')
import coff_compare as cc
ap = argparse.ArgumentParser()
ap.add_argument('unit')
ap.add_argument('--source')
ap.add_argument('--edits')
ap.add_argument('--cflag', action='append', default=[])
ap.add_argument('--alias', action='append', default=[],
help='OURS=TARGET: rename C identifier OURS to TARGET in memory before compiling (word-boundary)')
ap.add_argument('--fn', action='append', default=[])
ap.add_argument('--disas')
ap.add_argument('--all', action='store_true')
ap.add_argument('--out', help='copy the compiled object to this path for later inspection')
a = ap.parse_args()
unit = a.unit.replace('\\', '/')
unit_src = unit + '.c'
src = a.source or unit_src
bn = open('build.ninja').read()
# the "build <obj>: cl <src>" line may be wrapped with a $ continuation, so match
# on the object path alone rather than requiring the leading "build " on that line
key = 'build\\base\\' + unit.replace('/', '\\') + '.obj:'
i = bn.index(key)
j = bn.index('cflags = ', i)
k = bn.index('\nbuild ', j)
cf = bn[j + len('cflags = '):k]
cf = cf.replace('$\n', ' ').replace('$\r\n', ' ')
cf = re.sub(r'\s+', ' ', cf).strip()
toks = re.findall(r'/I"[^"]+"|\S+', cf)
toks = [('/I' + t[3:].rstrip('"')) if t.startswith('/I"') else t for t in toks]
code = open(src, encoding='latin-1').read()
if a.edits:
for entry in json.load(open(a.edits)):
find, repl = entry[0], entry[1]
assert find in code, 'EDIT NOT FOUND: %r' % find[:80]
code = code.replace(find, repl)
for entry in a.alias:
ours_name, target_name = entry.split('=', 1)
pat = re.compile(r'(?<![A-Za-z0-9_])' + re.escape(ours_name) + r'(?![A-Za-z0-9_])')
assert pat.search(code), 'ALIAS NOT FOUND: %r' % ours_name
code = pat.sub(target_name, code)
scratch_src = f'scratch/_gate_{os.getpid()}.c'
open(scratch_src, 'w', encoding='latin-1', newline='\n').write(code)
obj = f'scratch/_gate_{os.getpid()}.obj'
if os.path.exists(obj):
os.remove(obj)
cl = os.environ.get('HALO_CL') or os.path.abspath(os.path.join('xbox', 'bin', 'vc7', 'CL.Exe'))
if not os.path.exists(cl):
cl = r'C:\halo-worktrees\claude-finish-hs-20260816\xbox\bin\vc7\CL.Exe'
incdir = os.path.dirname(unit_src)
cmd = [cl, '/nologo', '/c'] + toks + a.cflag + ['/I' + incdir, '/Fo' + obj, scratch_src]
r = subprocess.run(cmd, capture_output=True, text=True)
if not os.path.exists(obj) or r.returncode != 0:
print('COMPILE FAILED')
print(r.stdout[-4000:])
print(r.stderr[-2000:])
sys.exit(1)
if a.out:
import shutil
shutil.copyfile(obj, a.out)
target = cc.load(open('build/split/' + unit + '.obj', 'rb').read())
ours = cc.load(open(obj, 'rb').read())
def fn_syms(o):
secs = o['sections']
out = {}
for s in o['symbols']:
if (s['name'].startswith('_') and s['section'] > 0 and s['storage'] in (2, 3)
and s['value'] == 0 and secs[s['section'] - 1]['name'] == '.text'):
out.setdefault(s['name'], s)
return out
tsyms = fn_syms(target)
osyms = fn_syms(ours)
want = a.fn if a.fn else sorted(tsyms)
nexact = nres = nunw = 0
for name in want:
if name not in tsyms:
print('NOT-IN-TARGET', name)
continue
ti = cc.section_info(target, name)
if name not in osyms:
nunw += 1
if a.fn or a.all:
print(f'UNWRITTEN {ti["size"]:5d} {name}')
continue
oi = cc.section_info(ours, name)
if cc.section_infos_equal(ti, oi):
nexact += 1
if a.fn or a.all:
print(f'EXACT {ti["size"]:5d} {name}')
else:
nres += 1
why = []
if ti['size'] != oi['size']:
why.append(f'size {oi["size"]}!={ti["size"]}')
if ti['relocation_count'] != oi['relocation_count']:
why.append(f'relocs {oi["relocation_count"]}!={ti["relocation_count"]}')
if ti['normalized_sha256'] != oi['normalized_sha256']:
why.append('sha')
if not why:
# size, relocation count and normalized bytes all match, so the only
# thing left is relocation IDENTITY - usually a static that carries no
# public symbol in the image, which csplit names as
# "<nearest public symbol> + N". See tools/campaign/attest_scan.py.
why.append('reloc-identity')
print(f'residual {ti["size"]:5d} {name} [{", ".join(why)}]')
print(f'== exact {nexact} residual {nres} unwritten {nunw} (of {len(want)} listed)')
if a.disas:
import capstone
md = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_32)
def dis(o, name):
secs = o['sections']
for s in o['symbols']:
if s['name'] == name and s['section'] > 0 and s['value'] == 0 and secs[s['section'] - 1]['name'] == '.text':
sec = secs[s['section'] - 1]
data = cc._section_bytes(o, sec)
rels = {}
si = cc.section_info(o, name)
for rl in si.get('relocations', []):
rels[rl['address']] = str(rl['target'][1]) if len(rl['target']) > 1 else '?'
lines = []
for insn in md.disasm(bytes(data), 0):
ann = ''
for off, symn in rels.items():
if isinstance(off, int) and insn.address <= off < insn.address + insn.size:
ann += f' ; {symn}'
lines.append((f'{insn.address:4x}: {insn.mnemonic} {insn.op_str}', ann))
return lines
return [('<not found>', '')]
lt = dis(target, a.disas)
lo = dis(ours, a.disas)
import itertools
print(f'--- target ({len(lt)}) | ours ({len(lo)}) : {a.disas}')
for x, y in itertools.zip_longest(lt, lo, fillvalue=('', '')):
xa, xann = x
ya, yann = y
mark = ' ' if xa.split(':', 1)[-1] == ya.split(':', 1)[-1] else '|'
print(f'{(xa + xann):60s}{mark} {ya}{yann}')