1
Fork 0
mirror of https://github.com/awgil/ffxiv_reverse.git synced 2025-04-23 15:07:46 +00:00
ffxiv_reverse/idbtoolkit/populate_idb.py

519 lines
20 KiB
Python
Raw Normal View History

2023-05-08 23:23:04 +01:00
import idaapi
import ida_bytes
import ida_enum
import ida_name
import ida_nalt
import ida_funcs
import ida_xref
import ida_typeinf
import ida_struct
import ida_funcs
2023-05-13 20:27:26 +01:00
import json
2023-05-08 23:23:04 +01:00
import yaml
import os
import collections
import datetime
import itertools
2023-05-13 20:27:26 +01:00
idaapi.require('ii') # to simplify development, we want to reload my modules if they change
2023-05-08 23:23:04 +01:00
import ii
def msg(text):
print(f'{datetime.datetime.now()} {text}')
2023-05-13 20:27:26 +01:00
# json, unlike yml, always stores keys as strings - but we use int keys in some cases
def as_int(k):
return int(k) if isinstance(k, str) else k
2023-05-08 23:23:04 +01:00
def has_custom_name(ea):
name = ida_name.get_ea_name(ea)
if not ida_name.is_uname(name):
return False # it's an officially IDA dummy name (see https://hex-rays.com/blog/igors-tip-of-the-week-34-dummy-names/)
if name.startswith('nullsub_'):
return False # nullsub_ is not considered a dummy, but it's autogenerated nonetheless
if name.startswith('staticinit_'):
return False # staticinit_ is our custom prefix which is functionally a dummy
if len(name) >= 2 and name[0] == 'a':
byte = ida_bytes.get_byte(ea)
if byte < 0x80 and name[1] == chr(byte).upper():
return False # string constant: foo => aFoo
return True
2023-05-13 20:27:26 +01:00
renamed_eas = {}
def rename_ea(ea, name):
if ea not in renamed_eas:
2023-05-08 23:23:04 +01:00
dupEA = ii.find_global_name_ea(name)
2023-05-13 20:27:26 +01:00
if dupEA == ea:
renamed_eas[ea] = {} # already has same name, probably from previous run
elif dupEA != idaapi.BADADDR:
2023-05-08 23:23:04 +01:00
msg(f'Skipping rename for {hex(ea)}: same name {name} is already used by {hex(dupEA)}')
ii.add_comment_ea_auto(ea, f'duplicate name: {name}')
2023-05-13 20:27:26 +01:00
else:
if has_custom_name(ea):
ii.add_comment_ea_auto(ea, f'original name: {ida_name.get_ea_name(ea)}')
if ida_name.set_name(ea, name) == 0:
# no need to write a message, we get a messagebox anyway...
ii.add_comment_ea_auto(ea, f'rename failed: {name}')
else:
renamed_eas[ea] = {} # placeholder value
2023-05-08 23:23:04 +01:00
elif ida_name.get_ea_name(ea) != name:
2023-05-13 20:27:26 +01:00
#msg(f'Skipping rename for existing name: {hex(ea)} {ida_name.get_ea_name(ea)} -> {name}')
2023-05-08 23:23:04 +01:00
ii.add_comment_ea_auto(ea, f'alt name: {name}')
2023-05-13 20:27:26 +01:00
def add_sig_comment(ea, sig):
2023-05-08 23:23:04 +01:00
if sig:
ii.add_comment_ea_auto(ea, f'signature: {sig["sig"]} +{sig["sigOffset"]}')
# ensure specified ea is a function start; returns success (either function already existed or was created)
def ensure_function_at(ea, message):
if ida_funcs.get_func(ea):
return True
added = ida_funcs.add_func(ea)
if message:
msg(f'Created new {message} at {hex(ea)}' if added else f'Failed to create {message} at {hex(ea)}')
return added
def calc_vtable_length(ea):
# assume that length is from start until next name
end = ea + 8
while ida_name.get_ea_name(end) == '' and ii.ea_has_data_offset(end):
end += 8
return (end - ea) >> 3
2023-05-13 20:27:26 +01:00
applied_function_types = {}
2023-05-08 23:23:04 +01:00
def apply_function_type(ea, tinfo):
func = ida_funcs.get_func(ea)
if not func:
raise Exception(f'Function not defined')
2023-05-13 20:27:26 +01:00
if ea in applied_function_types:
ii.add_comment_func(func, f'alt sig: {tinfo}')
return
2023-05-08 23:23:04 +01:00
existing = ida_typeinf.print_type(ea, 0)
if existing:
2023-05-13 20:27:26 +01:00
#msg(f'Skipping function type assignment @ {hex(ea)}: {existing} -> {tinfo}')
ii.add_comment_func(func, f'original sig: {existing}')
if not ida_typeinf.apply_tinfo(ea, tinfo, 0):
2023-05-08 23:23:04 +01:00
raise Exception(f'Apply failed')
2023-05-13 20:27:26 +01:00
applied_function_types[ea] = {}
2023-05-08 23:23:04 +01:00
def populate_static_initializers():
msg('*** Populating static initializers ***')
def find_main_iniiterm():
eaInitterm = ii.find_global_name_ea('_initterm')
if eaInitterm == idaapi.BADADDR:
raise Exception('Failed to find _initterm address')
# there are several _initterm calls, interesting one is from main, others are purely framework ones
mainXrefs = [xref for xref in ii.enumerate_xrefs_to(eaInitterm) if ida_funcs.get_func_name(xref).startswith('?__scrt_common_main')]
if len(mainXrefs) != 1:
raise Exception(f'Found {len(mainXrefs)} calls to _initterm from main, 1 expected')
return mainXrefs[0]
def parse_initterm_arguments(callEA):
# assume arguments are of the form 'lea rcx/rdx, addr'
args = ii.get_call_argument_assignment_eas(callEA)
if len(args) != 2:
raise Exception(f'Unexpected args for _initterm call: 2 expected, got {len(args)}')
start = ii.get_instruction_operand_immediate(args[0], 1)
end = ii.get_instruction_operand_immediate(args[1], 1)
# both start and end have 0's, between them have function pointers
if start + 8 * calc_vtable_length(start) != end:
raise Exception('Unexpected _initterm table contents')
return (start, end)
try:
eaInittermCall = find_main_iniiterm()
start, end = parse_initterm_arguments(eaInittermCall)
for ea in range(start + 8, end, 8):
func = ida_bytes.get_qword(ea)
ensure_function_at(func, "static initializer")
if not has_custom_name(func):
ida_name.set_name(func, f'staticinit_{(ea - start) >> 3}')
except Exception as e:
msg(f'Static initializer error: {e}')
def populate_global_names(data):
msg('** Populating exported global names **')
2023-05-13 20:27:26 +01:00
for k, g in data['globals'].items():
ea = as_int(k)
for n in g['names']:
rename_ea(ea, n)
add_sig_comment(ea, g['address'])
2023-05-08 23:23:04 +01:00
def populate_function_names(data):
msg('** Populating exported function names **')
2023-05-13 20:27:26 +01:00
for k, f in data['functions'].items():
ea = as_int(k)
2023-05-08 23:23:04 +01:00
ensure_function_at(ea, "function") # if function is not referenced from anywhere, define one manually
2023-05-13 20:27:26 +01:00
for n in f['names']:
rename_ea(ea, n)
add_sig_comment(ea, f['address'])
def populate_vtable_names(data):
msg('** Populating exported vtable names **')
for name, s in data['structs'].items():
vtable = s['vTable']
if not vtable:
continue # not interested in classes without vtables
primaryEA = vtable['ea']
if primaryEA != 0:
rename_ea(primaryEA, f'vtbl_{name}')
add_sig_comment(primaryEA, vtable['address'])
for v in vtable['secondary']:
rename_ea(v['ea'], f'vtbl_{v["derived"]}___{name}')
2023-05-08 23:23:04 +01:00
# TODO: calculate masks for bitfield values properly
2023-05-08 23:23:04 +01:00
def populate_enums(data):
msg('** Populating exported enums **')
def populate_enum(name, isBitfield, isSigned, width, values):
if ida_enum.get_enum(name) != idaapi.BADADDR:
raise Exception(f'{name} already exists in database')
eid = ii.add_enum(name, isBitfield, isSigned, width)
if (eid == idaapi.BADADDR):
raise Exception(f'Failed to create {name}')
for val in values:
en = val['name']
ev = val['value']
qn = f'{name}.{en}' # enum names in ida are global, so qualify them
res = ida_enum.add_enum_member(eid, qn, ev, ev if isBitfield else -1)
if res != 0:
msg(f'Failed to add enum member {name}.{en} = {ev}: {res}')
ii.add_comment_enum(eid, f'could not add field {en} = {ev}')
with ii.mass_type_updater(ida_typeinf.UTP_ENUM):
2023-05-13 20:27:26 +01:00
for name, e in data['enums'].items():
2023-05-08 23:23:04 +01:00
try:
populate_enum(name, e['isBitfield'], e['isSigned'], e['width'], e['values'])
except Exception as e:
msg(f'Enum error: {e}')
def populate_vtables(data):
2023-05-13 20:27:26 +01:00
msg('** Populating exported vtables **')
# for each vtable, we determine its size, create the structure, add crossrefs from structure to instances, and rename functions
def common_vtable_length(vtbl):
primaryEA = vtbl['ea']
primaryLen = calc_vtable_length(primaryEA) if primaryEA != 0 else 0
vlen = primaryLen
for sec in vtbl['secondary']:
secEA = sec['ea']
secLen = calc_vtable_length(secEA)
2023-05-08 23:23:04 +01:00
if vlen == 0:
2023-05-13 20:27:26 +01:00
vlen = secLen
if primaryLen != 0 and primaryLen != secLen:
msg(f'Mismatch between vtable sizes at {hex(primaryEA)} ({primaryLen}) and {hex(secEA)} ({secLen})')
if vlen == 0 or vlen > secLen:
vlen = secLen
return vlen
def calc_vf_names(vtable, vfuncs, idx):
names = vfuncs[idx]['names'] if idx in vfuncs else []
if len(names) > 0:
if not ida_struct.get_member_by_name(vtable, names[0]):
return names # all good, use custom names
else:
msg(f'Duplicate vtable field {ida_struct.get_struc_name(vtable.id)}.{names[0]}, using fallback for {idx}')
names.insert(0, f'vf{idx}')
return names
def create_vtable_instance(vtable, numVFs, ea, prefix, signatures):
# note: i feel that creating vtable global is, while correct, makes viewing it slightly worse (not seeing vf offsets etc)
# but at very least create custom xref (so that find-refs on vtable struct works)
# TODO: reconsider...
ida_xref.add_dref(ea, vtable.id, ida_xref.XREF_USER | ida_xref.dr_I)
for idx in range(0, numVFs):
vfuncEA = ida_bytes.get_qword(ea + idx * 8)
vfuncName = ida_name.get_ea_name(vfuncEA)
if vfuncName == "_purecall":
continue # abstract virtual function
2023-05-08 23:23:04 +01:00
2023-05-13 20:27:26 +01:00
inner = ida_struct.get_innermost_member(vtable, idx * 8)
if not inner:
msg(f'Failed to find field for vfunc {idx} of {ida_struct.get_struc_name(vtable.id)}')
continue
leafName = ida_struct.get_member_name(inner[0].id)
if f'.{leafName}' in vfuncName:
continue # this function is probably not overridden
2023-05-08 23:23:04 +01:00
2023-05-13 20:27:26 +01:00
rename_ea(vfuncEA, f'{prefix}.{leafName}')
if signatures and idx in signatures:
add_sig_comment(vfuncEA, signatures[idx]['address'])
2023-05-08 23:23:04 +01:00
2023-05-13 20:27:26 +01:00
def populate_vtable(cname, vtbl):
vlen = common_vtable_length(vtbl)
if vlen == 0:
return # don't bother creating a vtable if there are no instances
2023-05-08 23:23:04 +01:00
2023-05-13 20:27:26 +01:00
# create structure
vtable = ii.add_struct(f'{cname}_vtbl')
if not vtable:
raise Exception(f'Failed to create vtable structure for {cname}')
# add base, if any
primaryBase = vtbl['base']
if primaryBase and not ii.add_struct_baseclass(vtable, f'{primaryBase}_vtbl'):
msg(f'Failed to add base for vtable for {cname}')
# check that all custom vfuncs are in range
firstNewVF = ida_struct.get_struc_size(vtable) >> 3
vfuncs = {} # this always has ints as keys
for k, vf in vtbl['vFuncs'].items():
idx = as_int(k)
if idx < firstNewVF:
msg(f'Class {cname} overrides vfunc {idx} inherited from base {primaryBase}')
elif idx >= vlen:
msg(f'Class {cname} defines vfunc {idx} which is outside bounds ({vlen})')
else:
vfuncs[idx] = vf
2023-05-08 23:23:04 +01:00
2023-05-13 20:27:26 +01:00
# add fields
for idx in range(firstNewVF, vlen):
names = calc_vf_names(vtable, vfuncs, idx)
if not ii.add_struct_member_ptr(vtable, idx << 3, names[0]):
msg(f'Failed to add vfunc {idx} to vtable {cname}')
else:
for n in names[1:]:
ii.add_comment_member(ida_struct.get_member(vtable, idx << 3), f'alt name: {n}')
#ida_struct.save_struc(vtable)
# process vtable instances
primaryEA = vtbl['ea']
if primaryEA != 0:
create_vtable_instance(vtable, vlen, primaryEA, cname, vfuncs)
for sec in vtbl['secondary']:
create_vtable_instance(vtable, vlen, sec['ea'], f'{sec["derived"]}___{cname}', None)
# this assumes that structures are ordered correctly, so vtable of the base is always created before vtable of derived
with ii.mass_type_updater(ida_typeinf.UTP_STRUCT):
for name, s in data['structs'].items():
vtable = s['vTable']
if not vtable:
continue
try:
populate_vtable(name, vtable)
except Exception as e:
msg(f'Create vtable error: {e}')
2023-05-08 23:23:04 +01:00
def populate_structs(data):
2023-05-13 20:27:26 +01:00
msg('** Populating exported structs **')
def add_base(s, type, offset, size):
curSize = ida_struct.get_struc_size(s)
if curSize != offset:
# treat this as a warning...
msg(f'Unexpected offset for {ida_struct.get_struc_name(s.id)} base {type}: expected {hex(offset)}, got {hex(curSize)}')
if not ii.add_struct_baseclass(s, type):
msg(f'Failed to add {ida_struct.get_struc_name(s.id)} base {type}')
return
if size != 0:
2023-05-08 23:23:04 +01:00
actualSize = ida_struct.get_member_size(ida_struct.get_member(s, curSize))
if actualSize != size:
msg(f'Unexpected size for {ida_struct.get_struc_name(s.id)} base {type}: expected {hex(size)}, got {hex(actualSize)}')
2023-05-13 20:27:26 +01:00
def add_vptr(s, vtname):
if not ii.add_struct_member_ptr(s, 0, "__vftable"):
msg(f'Failed to add vtable pointer to {ida_struct.get_struc_name(s.id)}')
elif not ii.set_struct_member_by_offset_type(s, 0, vtname + '*' if ida_struct.get_struc_id(vtname) != idaapi.BADADDR else 'void*'):
msg(f'Failed to set vtable pointer type for {ida_struct.get_struc_name(s.id)} (vtbl-struct-id={hex(ida_struct.get_struc_id(vtname))})')
def add_field(s, offset, fdata):
names = fdata['names']
type = fdata['type']
arrLen = fdata['arrayLength']
if fdata['isStruct']:
success = ii.add_struct_member_substruct(s, offset, names[0], type, arrLen if arrLen > 0 else 1)
else:
typeSuffix = f'[{arrLen}]' if arrLen > 0 else ''
success = ii.add_struct_member_typed(s, offset, names[0], type + typeSuffix)
if not success:
msg(f'Failed to add field {ida_struct.get_struc_name(s.id)}.{names[0]}')
return
member = ida_struct.get_member(s, offset)
for n in names[1:]:
ii.add_comment_member(member, f'alt name: {n}')
if not ida_struct.is_union(s.id):
actualSize = ida_struct.get_member_size(member)
expectedSize = fdata['size']
if actualSize != expectedSize:
msg(f'Unexpected size for {ida_struct.get_struc_name(s.id)}.{names[0]}: expected {hex(expectedSize)}, got {hex(actualSize)}')
# structure creation is done in two passes: we first create empty structs, they can then be used as a kind of 'forward declarations' for pointer types
with ii.mass_type_updater(ida_typeinf.UTP_STRUCT):
for name, s in data['structs'].items():
if not ii.add_struct(name, s['isUnion']):
msg(f'Failed to create structure {name}')
# note: we commit changes before second pass, to ensure that setting types works correctly
with ii.mass_type_updater(ida_typeinf.UTP_STRUCT):
for cname, cdata in data['structs'].items():
s = ii.get_struct_by_name(cname)
if not s:
continue
isUnion = cdata['isUnion']
# start with bases (if any)
for b in cdata['bases']:
add_base(s, b['type'], b['offset'], b['size'])
# now add primary vtable, if needed
if ida_struct.get_struc_size(s) == 0 and cdata['vTable']:
add_vptr(s, cname + '_vtbl')
# now add fields
for offset, fgroup in itertools.groupby(cdata['fields'], lambda f: f['offset']):
if offset < ida_struct.get_struc_size(s):
msg(f'Unexpected offset for {cname}+{hex(offset)}, current size if {ida_struct.get_struc_size(s)}')
2023-05-08 23:23:04 +01:00
continue
2023-05-13 20:27:26 +01:00
flist = [f for f in fgroup] # group can only be iterated over once
if isUnion or len(flist) == 1:
add_field(s, offset, flist[0])
else:
uname = f'union{hex(offset)[2:]}'
su = ii.add_struct(f'{cname}_{uname}', True)
for f in flist:
add_field(su, 0, f)
ii.add_struct_member_substruct(s, offset, uname, f'{cname}_{uname}')
# add tail, if structure is larger than last field
expectedSize = cdata['size']
if expectedSize != 0:
2023-05-08 23:23:04 +01:00
finalSize = ida_struct.get_struc_size(s)
if finalSize > expectedSize:
msg(f'Structure {cname} is too large: {hex(finalSize)} > {hex(expectedSize)}')
2023-05-13 20:27:26 +01:00
elif finalSize < expectedSize:
success = ii.add_struct_member_byte(s, 0, 'padding', expectedSize) if isUnion else ii.add_struct_member_byte(s, finalSize, f'field_{hex(finalSize)[2:]}', expectedSize - finalSize)
if not success:
msg(f'Failed to extend structure {cname}')
2023-05-08 23:23:04 +01:00
def populate_global_types(data):
msg('** Populating exported global types **')
def process_global(ea, type, expectedSize):
if not type:
return 0 # nothing to do, type unknown
tif = ii.parse_cdecl(type)
if not tif:
raise Exception(f'Failed to parse type {type}')
actualSize = tif.get_size()
if actualSize != expectedSize:
msg(f'Mismatched global size {type} @ {hex(ea)}: expected {hex(expectedSize)}, got {hex(actualSize)}')
for nameEA, name in ii.enumerate_names():
if nameEA > ea and nameEA < ea + actualSize:
msg(f'Existing global {name} is now a part of global {type} @ {hex(ea)} at offset {hex(nameEA - ea)}')
if not ida_typeinf.apply_tinfo(ea, tif, 0):
2023-05-13 20:27:26 +01:00
raise Exception(f'Failed to apply {type} @ {hex(ea)}')
2023-05-08 23:23:04 +01:00
return actualSize
minEA = 0
2023-05-13 20:27:26 +01:00
for k, g in data['globals'].items():
ea = as_int(k)
2023-05-08 23:23:04 +01:00
if ea < minEA:
2023-05-13 20:27:26 +01:00
msg(f'Skipping global {g["type"]} {g["names"][0]} @ {hex(ea)}, since it is a part of another global')
2023-05-08 23:23:04 +01:00
continue # this global was already consumed by another global
minEA = ea
try:
minEA += process_global(ea, g['type'], g['size'])
except Exception as e:
msg(f'Global type error: {e}')
def populate_function_types(data):
msg('** Populating exported function types **')
2023-05-13 20:27:26 +01:00
for k, f in data['functions'].items():
ea = as_int(k)
sig = f['type']
2023-05-08 23:23:04 +01:00
if not sig:
continue
2023-05-13 20:27:26 +01:00
tif = ii.parse_cdecl(sig.replace('^', '__fastcall func'))
2023-05-08 23:23:04 +01:00
try:
apply_function_type(ea, tif)
except Exception as e:
msg(f'Failed to apply function type {tif} @ {hex(ea)}: {e}')
2023-05-13 20:27:26 +01:00
def populate_vfunc_types(data):
2023-05-08 23:23:04 +01:00
msg('** Populating exported virtual function types **')
2023-05-13 20:27:26 +01:00
def update_vtable_fields(vtable, vfuncs):
for k, vfunc in vfuncs.items():
sig = vfunc['type']
2023-05-08 23:23:04 +01:00
if not sig:
continue
2023-05-13 20:27:26 +01:00
idx = as_int(k)
2023-05-08 23:23:04 +01:00
m = ida_struct.get_member(vtable, idx * 8)
if not m or ida_struct.get_member_name(m.id) == 'baseclass_0':
continue
2023-05-13 20:27:26 +01:00
type = sig.replace('^', '(__fastcall *)')
2023-05-08 23:23:04 +01:00
if not ii.set_struct_member_type(vtable, m, type):
2023-05-13 20:27:26 +01:00
msg(f'Failed to set vtable {ida_struct.get_struc_name(vtable.id)} entry #{idx} type to {type}')
2023-05-08 23:23:04 +01:00
def propagate_vfunc_type(eaRef, tinfo, cname, shift):
vfuncEA = ida_bytes.get_qword(eaRef)
vfuncName = ida_name.get_ea_name(vfuncEA)
if vfuncName == "_purecall":
return # abstract virtual function
# replace 'this' pointer type with proper one
try:
fi = ida_typeinf.func_type_data_t()
if not tinfo.get_func_details(fi):
raise Exception('Failed to get func details')
elif fi.size() == 0:
raise Exception('Func has 0 args')
elif fi[0].name != 'this':
raise Exception(f'First arg is not this: {fi[0].name}')
elif not fi[0].type.is_ptr():
raise Exception(f'First arg has unexpected type {fi[0].type}')
if shift == 0:
fi[0].type = ii.parse_cdecl(cname + '*')
elif shift > 0:
fi[0].type = ii.parse_cdecl(f'{fi[0].type} __shifted({cname}, {shift})')
# else: failed to find base, keep base*
tifAdj = ida_typeinf.tinfo_t()
if not tifAdj.create_func(fi):
raise Exception(f'Failed to build updated tinfo: {tifAdj}')
apply_function_type(vfuncEA, tifAdj)
except Exception as e:
msg(f'Failed to apply virtual function type {tinfo} @ {hex(vfuncEA)}: {e}')
2023-05-13 20:27:26 +01:00
def propagate_types_to_instances(cname, vdata, vtable):
2023-05-08 23:23:04 +01:00
numVFs = ida_struct.get_struc_size(vtable) >> 3
for idx in range(numVFs):
inner = ida_struct.get_innermost_member(vtable, idx * 8)
itype = ii.get_struct_member_tinfo(inner[0]) if inner else None
if not itype or not itype.is_funcptr():
continue
itype = ida_typeinf.remove_pointer(itype)
2023-05-13 20:27:26 +01:00
primaryEA = vdata['ea']
if primaryEA != 0:
propagate_vfunc_type(primaryEA + idx * 8, itype, cname, 0)
for sec in vdata['secondary']:
propagate_vfunc_type(sec['ea'] + idx * 8, itype, sec['derived'], sec['offset'])
for cname, cdata in data['structs'].items():
vdata = cdata['vTable']
vtable = ii.get_struct_by_name(f'{cname}_vtbl') if vdata else None
2023-05-08 23:23:04 +01:00
if not vtable:
continue
2023-05-13 20:27:26 +01:00
update_vtable_fields(vtable, vdata['vFuncs'])
propagate_types_to_instances(cname, vdata, vtable)
def populate_exported(fileName, isYaml):
msg(f'*** Populating exported items from {fileName} ***')
# note: I found that parsing json is orders of magnitude faster than yaml :(
with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), fileName), 'r') as fd:
data = yaml.safe_load(fd) if isYaml else json.load(fd)
populate_global_names(data)
populate_function_names(data)
populate_vtable_names(data)
populate_enums(data)
populate_vtables(data) # vtable population kind-of requires populated global/vtable names (otherwise conceivably vtable can have no refs, which would make us determine size of preceeding vtable incorrectly)
populate_structs(data) # structs require existence of vtable types
populate_global_types(data) # this and below requires all types to be defined
populate_function_types(data)
populate_vfunc_types(data)
2023-05-08 23:23:04 +01:00
breakpoint()
populate_static_initializers()
2023-05-13 20:27:26 +01:00
populate_exported('info.json', False)
2023-05-08 23:23:04 +01:00
msg('*** Finished! ***')