"""Read-only, bounded PE fingerprint audit. Never produces a loadable RVA profile.
Uses original function bytes, masks only encoded RIP/branch/VA references, then
requires the remaining bytes to match. This is evidence for manual migration;
neither a matching signature nor the generated address candidates approves hooks.
"""
from __future__ import annotations
import argparse
import bisect
import collections
import datetime as dt
import hashlib
import json
from pathlib import Path
import re
import struct
import capstone as cs
from capstone.x86 import X86_OP_IMM, X86_OP_MEM, X86_REG_RIP
import pefile
class Image:
def __init__(self, path: Path):
self.path = path
self.raw = path.read_bytes()
self.pe = pefile.PE(data=self.raw, fast_load=True)
self.base = self.pe.OPTIONAL_HEADER.ImageBase
self.size = self.pe.OPTIONAL_HEADER.SizeOfImage
self.text = next(s for s in self.pe.sections if s.Name.startswith(b'.text'))
self.text_bytes = self.text.get_data()
directory = self.pe.OPTIONAL_HEADER.DATA_DIRECTORY[3]
data = self.pe.get_data(directory.VirtualAddress, directory.Size)
self.functions = sorted(struct.iter_unpack('<III', data))
self.starts = [x[0] for x in self.functions]
self.unwind_roots = {}
grouped = collections.defaultdict(list)
for entry in self.functions:
current = entry
for _ in range(16):
header = self.data(current[2], 4)
if not (header[0] >> 3) & 4:
break
chained = current[2] + 4 + ((header[2] + 1) & ~1) * 2
current = struct.unpack('<III', self.data(chained, 12))
else:
raise ValueError('unwind chain exceeded bound')
self.unwind_roots[entry[0]] = current
grouped[current[0]].append(entry)
self.contiguous_unwind_spans = {}
for root, pieces in grouped.items():
original = self.unwind_roots[pieces[0][0]]
end = original[1]
for begin, finish, unwind in sorted(pieces):
if begin <= end + 16:
end = max(end, finish)
else:
break # Never swallow an unrelated gap or distant cold block.
self.contiguous_unwind_spans[root] = (root, end, original[2])
self.dis = cs.Cs(cs.CS_ARCH_X86, cs.CS_MODE_64)
self.dis.detail = True
def data(self, rva, size):
return self.pe.get_data(rva, size)
def section(self, rva):
s = self.pe.get_section_by_rva(rva)
return s.Name.rstrip(b'\0').decode() if s else None
def function(self, rva):
at = bisect.bisect_right(self.starts, rva) - 1
if at >= 0 and self.functions[at][0] <= rva < self.functions[at][1]:
region = self.functions[at]
root = self.unwind_roots[region[0]][0]
span = self.contiguous_unwind_spans[root]
return span if span[0] <= rva < span[1] else region
return None
def instructions(self, rva, size):
return list(self.dis.disasm(self.data(rva, size), self.base + rva))
def metadata(self):
p = self.pe
p.parse_data_directories(directories=[2])
versions = {}
for group in getattr(p, 'FileInfo', []):
for info in group:
if getattr(info, 'Key', b'') == b'StringFileInfo':
for table in info.StringTable:
versions.update({k.decode(errors='replace'): v.decode(errors='replace') for k, v in table.entries.items()})
return {
'path': str(self.path), 'length': len(self.raw),
'sha256': hashlib.sha256(self.raw).hexdigest(), 'sha1': hashlib.sha1(self.raw).hexdigest(),
'peTimestampUtc': dt.datetime.fromtimestamp(p.FILE_HEADER.TimeDateStamp, dt.timezone.utc).isoformat(),
'machine': hex(p.FILE_HEADER.Machine), 'characteristics': hex(p.FILE_HEADER.Characteristics),
'imageBase': hex(self.base), 'sizeOfImage': hex(self.size),
'entryPoint': hex(p.OPTIONAL_HEADER.AddressOfEntryPoint),
'linker': [p.OPTIONAL_HEADER.MajorLinkerVersion, p.OPTIONAL_HEADER.MinorLinkerVersion],
'dllCharacteristics': hex(p.OPTIONAL_HEADER.DllCharacteristics),
'checksum': hex(p.OPTIONAL_HEADER.CheckSum), 'functionRanges': len(self.functions),
'contiguousUnwindSpans': len(self.contiguous_unwind_spans),
'version': {k: versions[k] for k in ('FileVersion', 'ProductVersion', 'CompanyName', 'FileDescription') if k in versions},
'certificateDirectorySize': p.OPTIONAL_HEADER.DATA_DIRECTORY[4].Size,
'sections': [{'name': s.Name.rstrip(b'\0').decode(), 'rva': hex(s.VirtualAddress),
'virtualSize': hex(s.Misc_VirtualSize), 'rawOffset': hex(s.PointerToRawData),
'rawSize': hex(s.SizeOfRawData), 'characteristics': hex(s.Characteristics),
'sha256': hashlib.sha256(s.get_data()).hexdigest()} for s in p.sections]
}
def signature(image, rva, limit):
fn = image.function(rva)
length = min(limit, fn[1] - rva) if fn else min(limit, 96)
instructions = image.instructions(rva, length)
if not fn:
for at, ins in enumerate(instructions):
if ins.group(cs.CS_GRP_RET):
instructions = instructions[:at + 1]
break
if not instructions:
return None
length = instructions[-1].address + instructions[-1].size - image.base - rva
raw = image.data(rva, length)
mask = bytearray(b'\xff' * len(raw))
for ins in instructions:
offset = ins.address - image.base - rva
local_jump_rva = fn and any(o.type == X86_OP_MEM and o.mem.index != 0 and
fn[0] <= o.mem.disp < fn[1] + 512 for o in ins.operands)
if any(o.type == X86_OP_MEM and o.mem.base == X86_REG_RIP for o in ins.operands) or local_jump_rva:
mask[offset + ins.disp_offset:offset + ins.disp_offset + ins.disp_size] = b'\0' * ins.disp_size
relative = ins.group(cs.CS_GRP_CALL) or ins.group(cs.CS_GRP_JUMP)
absolute = any(o.type == X86_OP_IMM and image.base <= o.imm < image.base + image.size for o in ins.operands)
if relative or absolute:
mask[offset + ins.imm_offset:offset + ins.imm_offset + ins.imm_size] = b'\0' * ins.imm_size
runs = []
start = None
for at, value in enumerate([*mask, 0]):
if value and start is None:
start = at
elif not value and start is not None:
runs.append((start, at - start))
start = None
anchor, anchor_size = max(runs, key=lambda row: row[1], default=(0, 0))
return raw, bytes(mask), anchor, anchor_size, instructions
def match(old, new, rva, limit=384):
sig = signature(old, rva, limit)
if not sig:
return {'status': 'not-disassembled'}
raw, mask, anchor, size, instructions = sig
if size < 5:
return {'status': 'insufficient-signature', 'length': len(raw), 'anchorLength': size}
needle = raw[anchor:anchor + size]
fixed = [at for at, v in enumerate(mask) if v]
candidates = []
pos = 0
anchor_hits = 0
while True:
pos = new.text_bytes.find(needle, pos)
if pos < 0:
break
anchor_hits += 1
beginning = pos - anchor
if beginning >= 0 and beginning + len(raw) <= len(new.text_bytes):
other = new.text_bytes[beginning:beginning + len(raw)]
if all(raw[i] == other[i] for i in fixed):
candidates.append(new.text.VirtualAddress + beginning)
if len(candidates) > 64:
break
pos += 1
result = {'status': 'unique' if len(candidates) == 1 else 'ambiguous' if candidates else 'no-match',
'length': len(raw), 'anchorLength': size, 'anchorHits': anchor_hits,
'maskedBytes': len(mask) - sum(bool(v) for v in mask),
'fingerprintSha256': hashlib.sha256(bytes(a & b for a, b in zip(raw, mask))).hexdigest(),
'maskSha256': hashlib.sha256(mask).hexdigest(),
'oldWindowSha256': hashlib.sha256(raw).hexdigest(),
'candidates': [hex(x) for x in candidates[:64]]}
fn = old.function(rva)
if fn:
result['oldFunctionRange'] = [hex(fn[0]), hex(fn[1])]
if len(candidates) == 1:
target = candidates[0]
result['newRva'] = hex(target)
result['delta'] = target - rva
target_fn = new.function(target)
result['newAtFunctionEntry'] = bool(target_fn and target_fn[0] == target)
result['oldAtFunctionEntry'] = bool(fn and fn[0] == rva)
result['unmaskedBytesIdentical'] = old.data(rva, len(raw)) == new.data(target, len(raw))
result['newWindowSha256'] = hashlib.sha256(new.data(target, len(raw))).hexdigest()
return result
def references(old, new, rva, target, length):
before = old.instructions(rva, length)
after = new.instructions(target, length)
result = []
if len(before) != len(after):
return result
for a, b in zip(before, after):
if a.mnemonic != b.mnemonic or a.size != b.size:
return []
for left, right in zip(a.operands, b.operands):
pair = None
if left.type == right.type == X86_OP_MEM and left.mem.base == right.mem.base == X86_REG_RIP:
pair = a.address + a.size + left.mem.disp - old.base, b.address + b.size + right.mem.disp - new.base
elif left.type == right.type == X86_OP_IMM and old.base <= left.imm < old.base + old.size and new.base <= right.imm < new.base + new.size:
pair = left.imm - old.base, right.imm - new.base
if pair:
result.append((pair[0], pair[1], rva, a.address - old.base))
return result
def proto_fields(data):
def varint(at):
value = 0
for shift in range(0, 70, 7):
byte = data[at]; at += 1
value |= (byte & 127) << shift
if byte < 128:
return value, at
raise ValueError('oversized varint')
at = 0
while at < len(data):
tag, at = varint(at); field, wire = tag >> 3, tag & 7
if wire == 0:
value, at = varint(at)
elif wire == 2:
length, at = varint(at); value = data[at:at + length]; at += length
elif wire in (1, 5):
width = 8 if wire == 1 else 4; value = data[at:at + width]; at += width
else:
raise ValueError(f'unsupported wire type {wire}')
yield field, wire, value
def depot_row(path):
raw = path.read_bytes()
magic, size = struct.unpack_from('<II', raw)
if magic != 0x71F617D0:
raise ValueError('unexpected Steam manifest payload header')
for field, wire, value in proto_fields(raw[8:8 + size]):
if field != 1 or wire != 2:
continue
row = dict((f, v) for f, w, v in proto_fields(value) if f != 6)
name = row.get(1, b'').decode(errors='replace')
if name.endswith('elite-dangerous-odyssey-64\\EliteDangerous64.exe'):
return {'manifestPath': str(path), 'manifestSha256': hashlib.sha256(raw).hexdigest(),
'file': name, 'size': row.get(2), 'field4Hex': row.get(4, b'').hex(),
'contentSha1': row.get(5, b'').hex(), 'note': 'Local cached manifest; no external signature verification performed.'}
return None
def needed_rip_references(image, targets):
"""Candidate instruction offsets, validated with Capstone before using."""
output = collections.defaultdict(list)
pattern = (rb'(?:[\x40-\x4f]?[\x8d\x8b]|[\x40-\x4f]?[\x66\xf2\xf3]?\x0f[\x10\x28\x6f])'
rb'[\x05\x0d\x15\x1d\x25\x2d\x35\x3d]....')
for hit in re.finditer(pattern, image.text_bytes, re.S):
rva = image.text.VirtualAddress + hit.start()
raw = hit.group()
target = rva + len(raw) + struct.unpack_from('<i', raw, len(raw) - 4)[0]
if target not in targets or len(output[target]) >= 8:
continue
instruction = image.instructions(rva, len(raw))
function = image.function(rva)
if function and len(instruction) == 1 and instruction[0].size == len(raw) and any(
op.type == X86_OP_MEM and op.mem.base == X86_REG_RIP for op in instruction[0].operands):
output[target].append((function[0], rva))
return output
def full_needed_spans(old, new, matches):
result = []
for key, row in matches.items():
if 'source' not in row or row['status'] != 'unique':
continue
before, after = int(key, 16), int(row['newRva'], 16)
old_fn, new_fn = old.function(before), new.function(after)
check = {'oldRva': key, 'newRva': row['newRva']}
if not old_fn or not new_fn or old_fn[0] != before or new_fn[0] != after:
check['status'] = 'not-pdata-entry'
else:
old_length, new_length = old_fn[1] - before, new_fn[1] - after
check.update(oldPdataSpanLength=old_length, newPdataSpanLength=new_length)
if old_length != new_length:
check['status'] = 'pdata-span-length-changed'
elif old_length > 16384:
check['status'] = 'over-bound'
else:
sig = signature(old, before, old_length)
raw, mask, *_ = sig
actual = new.data(after, len(raw))
check['status'] = 'normalised-pdata-span-match' if len(raw) == old_length and all(
a == b for a, b, keep in zip(raw, actual, mask) if keep) else 'needs-body-review'
result.append(check)
return result
def source_inventory():
needed = collections.defaultdict(set)
for path in Path('native/ManagedPlugin').glob('Navigation*.cs'):
if 'Tests' in path.name:
continue
text = path.read_text(encoding='utf8')
for value in re.finditer(r'(?:image|expectedImage)\s*\+\s*0x([\da-fA-F]+)', text):
needed[int(value[1], 16)].add(path.name)
for declaration in re.finditer(r'const nuint ([^;]+);', text):
for value in re.finditer(r'\b(\w+)\s*=\s*0x([\da-fA-F]+)', declaration[1]):
needed[int(value[2], 16)].add(path.name + ':' + value[1])
# The appearance adapter selects these already-known tree headers through
# a switch then adds the selected offset to image. They are not captured
# by the literal `image + RVA` pattern, so inventory them explicitly.
for rva in (0x5FDF460, 0x5FDF2A0, 0x5FDF100, 0x5FDF4E0,
0x5FDF190, 0x5FDF330, 0x5FDF560, 0x5FDF3E0):
needed[rva].add('Navigation.GalaxyAppearance.cs:luminosity-tree-switch')
return needed
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--old', type=Path, default=Path('artifacts/ship-model-reference/client-copy/EliteDangerous64.exe'))
parser.add_argument('--new', type=Path, default=Path('D:/SteamLibrary/steamapps/common/Elite Dangerous/Products/elite-dangerous-odyssey-64/EliteDangerous64.exe'))
parser.add_argument('--output', type=Path, default=Path('artifacts/galaxy-client-compatibility/audit.json'))
args = parser.parse_args()
old, new = Image(args.old), Image(args.new)
needed = source_inventory()
# Additional known galaxy constructors/getters establish vtable and global
# reference mappings. This disassembles only these bounded function slices.
seeds = set(needed)
for path in Path('artifacts/galaxy-query-research').rglob('solo-*.c'):
value = re.fullmatch(r'solo-([\da-fA-F]+)', path.stem)
if value:
seeds.add(int(value[1], 16))
seeds.update((0x380E410, 0x647EF0, 0x636A80, 0xA22280, 0x150DD0,
0x1D507A0, 0x1D53050, 0x1D5F600, 0x2175F70,
0x2600E90, 0x26055E0, 0x2600870, 0x26007D0))
rip_refs = needed_rip_references(old, {rva for rva in needed if old.section(rva) != '.text'})
for rows in rip_refs.values():
seeds.update(fn for fn, instruction in rows)
# Add a bounded number of concrete vtable functions for cross-checking the
# whole slot relation rather than assuming a constant .rdata delta.
tables = {}
for rva in needed:
if old.section(rva) != '.rdata':
continue
slots = {}
for offset in range(0, 0x200, 8):
target = int.from_bytes(old.data(rva + offset, 8), 'little') - old.base
if old.section(target) != '.text':
break
slots[offset] = target
if len(slots) >= 3:
tables[rva] = slots
seeds.update(slots.values())
matches = {}
inferred = collections.defaultdict(lambda: collections.defaultdict(list))
for rva in sorted(seeds):
if old.section(rva) != '.text':
continue
found = match(old, new, rva)
found['oldRva'] = hex(rva)
if rva in needed:
found['source'] = sorted(needed[rva])
matches[hex(rva)] = found
if found['status'] == 'unique':
for before, after, source, instruction in references(old, new, rva, int(found['newRva'], 16), found['length']):
inferred[before][after].append({'function': hex(source), 'instruction': hex(instruction)})
# References outside the signature window must independently retain the
# same instruction encoding at the same function-relative offset. This is
# explicitly a reference candidate, not verification of an entire body.
for original_target, rows in rip_refs.items():
for function, instruction in rows:
matched = matches.get(hex(function), {})
if matched.get('status') != 'unique':
continue
new_instruction = int(matched['newRva'], 16) + instruction - function
before = old.instructions(instruction, 8)
if not before:
continue
size = before[0].size
check = signature(old, instruction, size)
after = new.instructions(new_instruction, size)
if not check or len(after) != 1 or before[0].mnemonic != after[0].mnemonic or before[0].size != after[0].size:
continue
raw, mask, *_ = check
actual = new.data(new_instruction, size)
if any(a != b for a, b, keep in zip(raw, actual, mask) if keep):
continue
for left, right in zip(before[0].operands, after[0].operands):
if left.type == right.type == X86_OP_MEM and left.mem.base == right.mem.base == X86_REG_RIP:
target = new_instruction + size + right.mem.disp
evidence = {'function': hex(function), 'instruction': hex(instruction),
'method': 'same-offset-instruction-independent-match'}
if evidence not in inferred[original_target][target]:
inferred[original_target][target].append(evidence)
table_checks = []
new_rdata = next(s for s in new.pe.sections if s.Name.startswith(b'.rdata'))
new_rdata_bytes = new_rdata.get_data()
for table, slots in tables.items():
known = {offset: int(matches[hex(target)]['newRva'], 16) for offset, target in slots.items()
if matches.get(hex(target), {}).get('status') == 'unique'}
options = collections.defaultdict(set)
for offset, target in known.items():
needle = struct.pack('<Q', new.base + target)
pos = 0
while (pos := new_rdata_bytes.find(needle, pos)) >= 0:
candidate = new_rdata.VirtualAddress + pos - offset
options[candidate].add(offset)
pos += 8
consistent = []
for candidate, witnesses in options.items():
if len(witnesses) >= 2 and len(witnesses) == len(known):
consistent.append((candidate, len(witnesses)))
entry = {'oldVtable': hex(table), 'slotsExamined': len(slots), 'uniqueMappedSlots': len(known),
'status': 'consistent-slot-candidate' if len(consistent) == 1 else 'unresolved',
'candidates': [{'newVtable': hex(c), 'delta': c - table, 'matchingSlots': n} for c, n in consistent]}
if len(consistent) == 1:
candidate = consistent[0][0]
inferred[table][candidate].append({'method': 'all-uniquely-mapped-vtable-slots', 'slots': len(known)})
for offset, before in slots.items():
after = int.from_bytes(new.data(candidate + offset, 8), 'little') - new.base
if new.section(after) == '.text':
inferred[before][after].append({'method': 'contextual-vtable-slot',
'table': hex(table), 'slot': hex(offset)})
table_checks.append(entry)
string_checks = []
for rva in needed:
if old.section(rva) != '.rdata' or inferred.get(rva):
continue
raw = old.data(rva, 256).split(b'\0', 1)[0]
if len(raw) < 8 or not all(32 <= b < 127 for b in raw):
continue
needle = raw + b'\0'
pos = new_rdata_bytes.find(needle)
if pos >= 0 and new_rdata_bytes.find(needle, pos + 1) < 0:
target = new_rdata.VirtualAddress + pos
witness = {'method': 'unique-complete-ascii-string', 'value': raw.decode('ascii')}
inferred[rva][target].append(witness)
string_checks.append({'oldRva': hex(rva), 'newRva': hex(target), **witness})
contextual = []
for key, row in matches.items():
if row['status'] == 'unique':
continue
options = inferred.get(int(key, 16), {})
candidates = []
for target, evidence in options.items():
check = signature(old, int(key, 16), 384)
if not check:
continue
raw, mask, *_ = check
actual = new.data(target, len(raw))
if len(actual) == len(raw) and all(a == b for a, b, keep in zip(raw, actual, mask) if keep):
candidates.append({'newRva': hex(target), 'delta': target - int(key, 16), 'evidence': evidence})
if candidates:
contextual.append({'oldRva': key, 'intrinsicStatus': row['status'],
'status': 'single-context-candidate' if len(candidates) == 1 else 'ambiguous-context',
'candidates': candidates})
addresses = []
for rva, sources in sorted(needed.items()):
if old.section(rva) == '.text':
continue
options = inferred.get(rva, {})
addresses.append({'oldRva': hex(rva), 'section': old.section(rva), 'source': sorted(sources),
'status': 'consistent-reference' if len(options) == 1 else 'conflicting-reference' if options else 'unresolved',
'candidates': [{'newRva': hex(target), 'delta': target - rva, 'references': refs} for target, refs in options.items()]})
manifests = [depot_row(Path('I:/steam/depotcache') / name) for name in
('359326_1091597132551610634.manifest', '359326_3767724865074145418.manifest')]
meta_old, meta_new = old.metadata(), new.metadata()
for item, metadata in zip(manifests, (meta_old, meta_new)):
item['matchesFileSize'] = item['size'] == metadata['length']
item['matchesContentSha1'] = item['contentSha1'] == metadata['sha1']
lines = Path('I:/steam/logs/content_log.txt').read_text(encoding='utf8', errors='replace').splitlines()
evidence = [line for line in lines if 'AppID 359320' in line and
('finished update' in line or 'starting commit' in line) and
('2026-10-20' in line or '2026-10-23' in line)]
report = {
'scope': 'Read-only offline candidates, not a compatibility approval or loadable profile. No game process started and no source hooks/hash guards changed.',
'generatedUtc': dt.datetime.now(dt.timezone.utc).isoformat(),
'old': meta_old, 'new': meta_new, 'localDepotEvidence': manifests,
'sanitizedSteamUpdateLog': evidence,
'method': 'Bounded original function slices <=384 bytes, encoded RIP/relative-branch/absolute-VA and function-local indexed jump-table RVA operands masked, remaining bytes exact. Full text searched for uniqueness. Short/ambiguous candidates remain unverified. Referenced addresses derived only from paired instruction positions. Additional contextual vtable/call candidates are reported separately, never promoted to intrinsic unique matches.',
'counts': {'neededRvas': len(needed), 'functionSeeds': len(matches),
'functionStatuses': dict(collections.Counter(x['status'] for x in matches.values())),
'neededFunctionStatuses': dict(collections.Counter(x['status'] for x in matches.values() if 'source' in x)),
'uniqueFunctionDeltas': dict(collections.Counter(str(x['delta']) for x in matches.values() if x['status'] == 'unique')),
'dataStatuses': dict(collections.Counter(x['status'] for x in addresses))},
'functions': matches, 'dataReferences': addresses, 'vtableChecks': table_checks,
'stringChecks': string_checks,
'contextualFunctionCandidates': contextual,
'neededPdataSpanChecks': full_needed_spans(old, new, matches),
'limitations': ['Signature windows do not verify complete function semantics or object layouts.',
'Internal call targets, vtable lengths/order, owner/thread/lifetime invariants and hook relocation ABI require independent review.',
'Local manifest/log consistency establishes local Steam provenance evidence, not independent authentication of the entire distribution.',
'Unresolved/ambiguous symbols must not be migrated by a global delta.']
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf8')
print(json.dumps({'output': str(args.output), 'oldVersion': meta_old['version'], 'newVersion': meta_new['version'],
'counts': report['counts'], 'depotHashesMatch': all(x['matchesContentSha1'] and x['matchesFileSize'] for x in manifests)}, indent=2))
if __name__ == '__main__':
main()
"""Read-only, bounded PE fingerprint audit. Never produces a loadable RVA profile.
Uses original function bytes, masks only encoded RIP/branch/VA references, then
requires the remaining bytes to match. This is evidence for manual migration;
neither a matching signature nor the generated address candidates approves hooks.
"""
from __future__ import annotations
import argparse
import bisect
import collections
import datetime as dt
import hashlib
import json
from pathlib import Path
import re
import struct
import capstone as cs
from capstone.x86 import X86_OP_IMM, X86_OP_MEM, X86_REG_RIP
import pefile
class Image:
def __init__(self, path: Path):
self.path = path
self.raw = path.read_bytes()
self.pe = pefile.PE(data=self.raw, fast_load=True)
self.base = self.pe.OPTIONAL_HEADER.ImageBase
self.size = self.pe.OPTIONAL_HEADER.SizeOfImage
self.text = next(s for s in self.pe.sections if s.Name.startswith(b'.text'))
self.text_bytes = self.text.get_data()
directory = self.pe.OPTIONAL_HEADER.DATA_DIRECTORY[3]
data = self.pe.get_data(directory.VirtualAddress, directory.Size)
self.functions = sorted(struct.iter_unpack('<III', data))
self.starts = [x[0] for x in self.functions]
self.unwind_roots = {}
grouped = collections.defaultdict(list)
for entry in self.functions:
current = entry
for _ in range(16):
header = self.data(current[2], 4)
if not (header[0] >> 3) & 4:
break
chained = current[2] + 4 + ((header[2] + 1) & ~1) * 2
current = struct.unpack('<III', self.data(chained, 12))
else:
raise ValueError('unwind chain exceeded bound')
self.unwind_roots[entry[0]] = current
grouped[current[0]].append(entry)
self.contiguous_unwind_spans = {}
for root, pieces in grouped.items():
original = self.unwind_roots[pieces[0][0]]
end = original[1]
for begin, finish, unwind in sorted(pieces):
if begin <= end + 16:
end = max(end, finish)
else:
break # Never swallow an unrelated gap or distant cold block.
self.contiguous_unwind_spans[root] = (root, end, original[2])
self.dis = cs.Cs(cs.CS_ARCH_X86, cs.CS_MODE_64)
self.dis.detail = True
def data(self, rva, size):
return self.pe.get_data(rva, size)
def section(self, rva):
s = self.pe.get_section_by_rva(rva)
return s.Name.rstrip(b'\0').decode() if s else None
def function(self, rva):
at = bisect.bisect_right(self.starts, rva) - 1
if at >= 0 and self.functions[at][0] <= rva < self.functions[at][1]:
region = self.functions[at]
root = self.unwind_roots[region[0]][0]
span = self.contiguous_unwind_spans[root]
return span if span[0] <= rva < span[1] else region
return None
def instructions(self, rva, size):
return list(self.dis.disasm(self.data(rva, size), self.base + rva))
def metadata(self):
p = self.pe
p.parse_data_directories(directories=[2])
versions = {}
for group in getattr(p, 'FileInfo', []):
for info in group:
if getattr(info, 'Key', b'') == b'StringFileInfo':
for table in info.StringTable:
versions.update({k.decode(errors='replace'): v.decode(errors='replace') for k, v in table.entries.items()})
return {
'path': str(self.path), 'length': len(self.raw),
'sha256': hashlib.sha256(self.raw).hexdigest(), 'sha1': hashlib.sha1(self.raw).hexdigest(),
'peTimestampUtc': dt.datetime.fromtimestamp(p.FILE_HEADER.TimeDateStamp, dt.timezone.utc).isoformat(),
'machine': hex(p.FILE_HEADER.Machine), 'characteristics': hex(p.FILE_HEADER.Characteristics),
'imageBase': hex(self.base), 'sizeOfImage': hex(self.size),
'entryPoint': hex(p.OPTIONAL_HEADER.AddressOfEntryPoint),
'linker': [p.OPTIONAL_HEADER.MajorLinkerVersion, p.OPTIONAL_HEADER.MinorLinkerVersion],
'dllCharacteristics': hex(p.OPTIONAL_HEADER.DllCharacteristics),
'checksum': hex(p.OPTIONAL_HEADER.CheckSum), 'functionRanges': len(self.functions),
'contiguousUnwindSpans': len(self.contiguous_unwind_spans),
'version': {k: versions[k] for k in ('FileVersion', 'ProductVersion', 'CompanyName', 'FileDescription') if k in versions},
'certificateDirectorySize': p.OPTIONAL_HEADER.DATA_DIRECTORY[4].Size,
'sections': [{'name': s.Name.rstrip(b'\0').decode(), 'rva': hex(s.VirtualAddress),
'virtualSize': hex(s.Misc_VirtualSize), 'rawOffset': hex(s.PointerToRawData),
'rawSize': hex(s.SizeOfRawData), 'characteristics': hex(s.Characteristics),
'sha256': hashlib.sha256(s.get_data()).hexdigest()} for s in p.sections]
}
def signature(image, rva, limit):
fn = image.function(rva)
length = min(limit, fn[1] - rva) if fn else min(limit, 96)
instructions = image.instructions(rva, length)
if not fn:
for at, ins in enumerate(instructions):
if ins.group(cs.CS_GRP_RET):
instructions = instructions[:at + 1]
break
if not instructions:
return None
length = instructions[-1].address + instructions[-1].size - image.base - rva
raw = image.data(rva, length)
mask = bytearray(b'\xff' * len(raw))
for ins in instructions:
offset = ins.address - image.base - rva
local_jump_rva = fn and any(o.type == X86_OP_MEM and o.mem.index != 0 and
fn[0] <= o.mem.disp < fn[1] + 512 for o in ins.operands)
if any(o.type == X86_OP_MEM and o.mem.base == X86_REG_RIP for o in ins.operands) or local_jump_rva:
mask[offset + ins.disp_offset:offset + ins.disp_offset + ins.disp_size] = b'\0' * ins.disp_size
relative = ins.group(cs.CS_GRP_CALL) or ins.group(cs.CS_GRP_JUMP)
absolute = any(o.type == X86_OP_IMM and image.base <= o.imm < image.base + image.size for o in ins.operands)
if relative or absolute:
mask[offset + ins.imm_offset:offset + ins.imm_offset + ins.imm_size] = b'\0' * ins.imm_size
runs = []
start = None
for at, value in enumerate([*mask, 0]):
if value and start is None:
start = at
elif not value and start is not None:
runs.append((start, at - start))
start = None
anchor, anchor_size = max(runs, key=lambda row: row[1], default=(0, 0))
return raw, bytes(mask), anchor, anchor_size, instructions
def match(old, new, rva, limit=384):
sig = signature(old, rva, limit)
if not sig:
return {'status': 'not-disassembled'}
raw, mask, anchor, size, instructions = sig
if size < 5:
return {'status': 'insufficient-signature', 'length': len(raw), 'anchorLength': size}
needle = raw[anchor:anchor + size]
fixed = [at for at, v in enumerate(mask) if v]
candidates = []
pos = 0
anchor_hits = 0
while True:
pos = new.text_bytes.find(needle, pos)
if pos < 0:
break
anchor_hits += 1
beginning = pos - anchor
if beginning >= 0 and beginning + len(raw) <= len(new.text_bytes):
other = new.text_bytes[beginning:beginning + len(raw)]
if all(raw[i] == other[i] for i in fixed):
candidates.append(new.text.VirtualAddress + beginning)
if len(candidates) > 64:
break
pos += 1
result = {'status': 'unique' if len(candidates) == 1 else 'ambiguous' if candidates else 'no-match',
'length': len(raw), 'anchorLength': size, 'anchorHits': anchor_hits,
'maskedBytes': len(mask) - sum(bool(v) for v in mask),
'fingerprintSha256': hashlib.sha256(bytes(a & b for a, b in zip(raw, mask))).hexdigest(),
'maskSha256': hashlib.sha256(mask).hexdigest(),
'oldWindowSha256': hashlib.sha256(raw).hexdigest(),
'candidates': [hex(x) for x in candidates[:64]]}
fn = old.function(rva)
if fn:
result['oldFunctionRange'] = [hex(fn[0]), hex(fn[1])]
if len(candidates) == 1:
target = candidates[0]
result['newRva'] = hex(target)
result['delta'] = target - rva
target_fn = new.function(target)
result['newAtFunctionEntry'] = bool(target_fn and target_fn[0] == target)
result['oldAtFunctionEntry'] = bool(fn and fn[0] == rva)
result['unmaskedBytesIdentical'] = old.data(rva, len(raw)) == new.data(target, len(raw))
result['newWindowSha256'] = hashlib.sha256(new.data(target, len(raw))).hexdigest()
return result
def references(old, new, rva, target, length):
before = old.instructions(rva, length)
after = new.instructions(target, length)
result = []
if len(before) != len(after):
return result
for a, b in zip(before, after):
if a.mnemonic != b.mnemonic or a.size != b.size:
return []
for left, right in zip(a.operands, b.operands):
pair = None
if left.type == right.type == X86_OP_MEM and left.mem.base == right.mem.base == X86_REG_RIP:
pair = a.address + a.size + left.mem.disp - old.base, b.address + b.size + right.mem.disp - new.base
elif left.type == right.type == X86_OP_IMM and old.base <= left.imm < old.base + old.size and new.base <= right.imm < new.base + new.size:
pair = left.imm - old.base, right.imm - new.base
if pair:
result.append((pair[0], pair[1], rva, a.address - old.base))
return result
def proto_fields(data):
def varint(at):
value = 0
for shift in range(0, 70, 7):
byte = data[at]; at += 1
value |= (byte & 127) << shift
if byte < 128:
return value, at
raise ValueError('oversized varint')
at = 0
while at < len(data):
tag, at = varint(at); field, wire = tag >> 3, tag & 7
if wire == 0:
value, at = varint(at)
elif wire == 2:
length, at = varint(at); value = data[at:at + length]; at += length
elif wire in (1, 5):
width = 8 if wire == 1 else 4; value = data[at:at + width]; at += width
else:
raise ValueError(f'unsupported wire type {wire}')
yield field, wire, value
def depot_row(path):
raw = path.read_bytes()
magic, size = struct.unpack_from('<II', raw)
if magic != 0x71F617D0:
raise ValueError('unexpected Steam manifest payload header')
for field, wire, value in proto_fields(raw[8:8 + size]):
if field != 1 or wire != 2:
continue
row = dict((f, v) for f, w, v in proto_fields(value) if f != 6)
name = row.get(1, b'').decode(errors='replace')
if name.endswith('elite-dangerous-odyssey-64\\EliteDangerous64.exe'):
return {'manifestPath': str(path), 'manifestSha256': hashlib.sha256(raw).hexdigest(),
'file': name, 'size': row.get(2), 'field4Hex': row.get(4, b'').hex(),
'contentSha1': row.get(5, b'').hex(), 'note': 'Local cached manifest; no external signature verification performed.'}
return None
def needed_rip_references(image, targets):
"""Candidate instruction offsets, validated with Capstone before using."""
output = collections.defaultdict(list)
pattern = (rb'(?:[\x40-\x4f]?[\x8d\x8b]|[\x40-\x4f]?[\x66\xf2\xf3]?\x0f[\x10\x28\x6f])'
rb'[\x05\x0d\x15\x1d\x25\x2d\x35\x3d]....')
for hit in re.finditer(pattern, image.text_bytes, re.S):
rva = image.text.VirtualAddress + hit.start()
raw = hit.group()
target = rva + len(raw) + struct.unpack_from('<i', raw, len(raw) - 4)[0]
if target not in targets or len(output[target]) >= 8:
continue
instruction = image.instructions(rva, len(raw))
function = image.function(rva)
if function and len(instruction) == 1 and instruction[0].size == len(raw) and any(
op.type == X86_OP_MEM and op.mem.base == X86_REG_RIP for op in instruction[0].operands):
output[target].append((function[0], rva))
return output
def full_needed_spans(old, new, matches):
result = []
for key, row in matches.items():
if 'source' not in row or row['status'] != 'unique':
continue
before, after = int(key, 16), int(row['newRva'], 16)
old_fn, new_fn = old.function(before), new.function(after)
check = {'oldRva': key, 'newRva': row['newRva']}
if not old_fn or not new_fn or old_fn[0] != before or new_fn[0] != after:
check['status'] = 'not-pdata-entry'
else:
old_length, new_length = old_fn[1] - before, new_fn[1] - after
check.update(oldPdataSpanLength=old_length, newPdataSpanLength=new_length)
if old_length != new_length:
check['status'] = 'pdata-span-length-changed'
elif old_length > 16384:
check['status'] = 'over-bound'
else:
sig = signature(old, before, old_length)
raw, mask, *_ = sig
actual = new.data(after, len(raw))
check['status'] = 'normalised-pdata-span-match' if len(raw) == old_length and all(
a == b for a, b, keep in zip(raw, actual, mask) if keep) else 'needs-body-review'
result.append(check)
return result
def source_inventory():
needed = collections.defaultdict(set)
for path in Path('native/ManagedPlugin').glob('Navigation*.cs'):
if 'Tests' in path.name:
continue
text = path.read_text(encoding='utf8')
for value in re.finditer(r'(?:image|expectedImage)\s*\+\s*0x([\da-fA-F]+)', text):
needed[int(value[1], 16)].add(path.name)
for declaration in re.finditer(r'const nuint ([^;]+);', text):
for value in re.finditer(r'\b(\w+)\s*=\s*0x([\da-fA-F]+)', declaration[1]):
needed[int(value[2], 16)].add(path.name + ':' + value[1])
# The appearance adapter selects these already-known tree headers through
# a switch then adds the selected offset to image. They are not captured
# by the literal `image + RVA` pattern, so inventory them explicitly.
for rva in (0x5FDF460, 0x5FDF2A0, 0x5FDF100, 0x5FDF4E0,
0x5FDF190, 0x5FDF330, 0x5FDF560, 0x5FDF3E0):
needed[rva].add('Navigation.GalaxyAppearance.cs:luminosity-tree-switch')
return needed
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--old', type=Path, default=Path('artifacts/ship-model-reference/client-copy/EliteDangerous64.exe'))
parser.add_argument('--new', type=Path, default=Path('D:/SteamLibrary/steamapps/common/Elite Dangerous/Products/elite-dangerous-odyssey-64/EliteDangerous64.exe'))
parser.add_argument('--output', type=Path, default=Path('artifacts/galaxy-client-compatibility/audit.json'))
args = parser.parse_args()
old, new = Image(args.old), Image(args.new)
needed = source_inventory()
# Additional known galaxy constructors/getters establish vtable and global
# reference mappings. This disassembles only these bounded function slices.
seeds = set(needed)
for path in Path('artifacts/galaxy-query-research').rglob('solo-*.c'):
value = re.fullmatch(r'solo-([\da-fA-F]+)', path.stem)
if value:
seeds.add(int(value[1], 16))
seeds.update((0x380E410, 0x647EF0, 0x636A80, 0xA22280, 0x150DD0,
0x1D507A0, 0x1D53050, 0x1D5F600, 0x2175F70,
0x2600E90, 0x26055E0, 0x2600870, 0x26007D0))
rip_refs = needed_rip_references(old, {rva for rva in needed if old.section(rva) != '.text'})
for rows in rip_refs.values():
seeds.update(fn for fn, instruction in rows)
# Add a bounded number of concrete vtable functions for cross-checking the
# whole slot relation rather than assuming a constant .rdata delta.
tables = {}
for rva in needed:
if old.section(rva) != '.rdata':
continue
slots = {}
for offset in range(0, 0x200, 8):
target = int.from_bytes(old.data(rva + offset, 8), 'little') - old.base
if old.section(target) != '.text':
break
slots[offset] = target
if len(slots) >= 3:
tables[rva] = slots
seeds.update(slots.values())
matches = {}
inferred = collections.defaultdict(lambda: collections.defaultdict(list))
for rva in sorted(seeds):
if old.section(rva) != '.text':
continue
found = match(old, new, rva)
found['oldRva'] = hex(rva)
if rva in needed:
found['source'] = sorted(needed[rva])
matches[hex(rva)] = found
if found['status'] == 'unique':
for before, after, source, instruction in references(old, new, rva, int(found['newRva'], 16), found['length']):
inferred[before][after].append({'function': hex(source), 'instruction': hex(instruction)})
# References outside the signature window must independently retain the
# same instruction encoding at the same function-relative offset. This is
# explicitly a reference candidate, not verification of an entire body.
for original_target, rows in rip_refs.items():
for function, instruction in rows:
matched = matches.get(hex(function), {})
if matched.get('status') != 'unique':
continue
new_instruction = int(matched['newRva'], 16) + instruction - function
before = old.instructions(instruction, 8)
if not before:
continue
size = before[0].size
check = signature(old, instruction, size)
after = new.instructions(new_instruction, size)
if not check or len(after) != 1 or before[0].mnemonic != after[0].mnemonic or before[0].size != after[0].size:
continue
raw, mask, *_ = check
actual = new.data(new_instruction, size)
if any(a != b for a, b, keep in zip(raw, actual, mask) if keep):
continue
for left, right in zip(before[0].operands, after[0].operands):
if left.type == right.type == X86_OP_MEM and left.mem.base == right.mem.base == X86_REG_RIP:
target = new_instruction + size + right.mem.disp
evidence = {'function': hex(function), 'instruction': hex(instruction),
'method': 'same-offset-instruction-independent-match'}
if evidence not in inferred[original_target][target]:
inferred[original_target][target].append(evidence)
table_checks = []
new_rdata = next(s for s in new.pe.sections if s.Name.startswith(b'.rdata'))
new_rdata_bytes = new_rdata.get_data()
for table, slots in tables.items():
known = {offset: int(matches[hex(target)]['newRva'], 16) for offset, target in slots.items()
if matches.get(hex(target), {}).get('status') == 'unique'}
options = collections.defaultdict(set)
for offset, target in known.items():
needle = struct.pack('<Q', new.base + target)
pos = 0
while (pos := new_rdata_bytes.find(needle, pos)) >= 0:
candidate = new_rdata.VirtualAddress + pos - offset
options[candidate].add(offset)
pos += 8
consistent = []
for candidate, witnesses in options.items():
if len(witnesses) >= 2 and len(witnesses) == len(known):
consistent.append((candidate, len(witnesses)))
entry = {'oldVtable': hex(table), 'slotsExamined': len(slots), 'uniqueMappedSlots': len(known),
'status': 'consistent-slot-candidate' if len(consistent) == 1 else 'unresolved',
'candidates': [{'newVtable': hex(c), 'delta': c - table, 'matchingSlots': n} for c, n in consistent]}
if len(consistent) == 1:
candidate = consistent[0][0]
inferred[table][candidate].append({'method': 'all-uniquely-mapped-vtable-slots', 'slots': len(known)})
for offset, before in slots.items():
after = int.from_bytes(new.data(candidate + offset, 8), 'little') - new.base
if new.section(after) == '.text':
inferred[before][after].append({'method': 'contextual-vtable-slot',
'table': hex(table), 'slot': hex(offset)})
table_checks.append(entry)
string_checks = []
for rva in needed:
if old.section(rva) != '.rdata' or inferred.get(rva):
continue
raw = old.data(rva, 256).split(b'\0', 1)[0]
if len(raw) < 8 or not all(32 <= b < 127 for b in raw):
continue
needle = raw + b'\0'
pos = new_rdata_bytes.find(needle)
if pos >= 0 and new_rdata_bytes.find(needle, pos + 1) < 0:
target = new_rdata.VirtualAddress + pos
witness = {'method': 'unique-complete-ascii-string', 'value': raw.decode('ascii')}
inferred[rva][target].append(witness)
string_checks.append({'oldRva': hex(rva), 'newRva': hex(target), **witness})
contextual = []
for key, row in matches.items():
if row['status'] == 'unique':
continue
options = inferred.get(int(key, 16), {})
candidates = []
for target, evidence in options.items():
check = signature(old, int(key, 16), 384)
if not check:
continue
raw, mask, *_ = check
actual = new.data(target, len(raw))
if len(actual) == len(raw) and all(a == b for a, b, keep in zip(raw, actual, mask) if keep):
candidates.append({'newRva': hex(target), 'delta': target - int(key, 16), 'evidence': evidence})
if candidates:
contextual.append({'oldRva': key, 'intrinsicStatus': row['status'],
'status': 'single-context-candidate' if len(candidates) == 1 else 'ambiguous-context',
'candidates': candidates})
addresses = []
for rva, sources in sorted(needed.items()):
if old.section(rva) == '.text':
continue
options = inferred.get(rva, {})
addresses.append({'oldRva': hex(rva), 'section': old.section(rva), 'source': sorted(sources),
'status': 'consistent-reference' if len(options) == 1 else 'conflicting-reference' if options else 'unresolved',
'candidates': [{'newRva': hex(target), 'delta': target - rva, 'references': refs} for target, refs in options.items()]})
manifests = [depot_row(Path('I:/steam/depotcache') / name) for name in
('359326_1091597132551610634.manifest', '359326_3767724865074145418.manifest')]
meta_old, meta_new = old.metadata(), new.metadata()
for item, metadata in zip(manifests, (meta_old, meta_new)):
item['matchesFileSize'] = item['size'] == metadata['length']
item['matchesContentSha1'] = item['contentSha1'] == metadata['sha1']
lines = Path('I:/steam/logs/content_log.txt').read_text(encoding='utf8', errors='replace').splitlines()
evidence = [line for line in lines if 'AppID 359320' in line and
('finished update' in line or 'starting commit' in line) and
('2026-10-20' in line or '2026-10-23' in line)]
report = {
'scope': 'Read-only offline candidates, not a compatibility approval or loadable profile. No game process started and no source hooks/hash guards changed.',
'generatedUtc': dt.datetime.now(dt.timezone.utc).isoformat(),
'old': meta_old, 'new': meta_new, 'localDepotEvidence': manifests,
'sanitizedSteamUpdateLog': evidence,
'method': 'Bounded original function slices <=384 bytes, encoded RIP/relative-branch/absolute-VA and function-local indexed jump-table RVA operands masked, remaining bytes exact. Full text searched for uniqueness. Short/ambiguous candidates remain unverified. Referenced addresses derived only from paired instruction positions. Additional contextual vtable/call candidates are reported separately, never promoted to intrinsic unique matches.',
'counts': {'neededRvas': len(needed), 'functionSeeds': len(matches),
'functionStatuses': dict(collections.Counter(x['status'] for x in matches.values())),
'neededFunctionStatuses': dict(collections.Counter(x['status'] for x in matches.values() if 'source' in x)),
'uniqueFunctionDeltas': dict(collections.Counter(str(x['delta']) for x in matches.values() if x['status'] == 'unique')),
'dataStatuses': dict(collections.Counter(x['status'] for x in addresses))},
'functions': matches, 'dataReferences': addresses, 'vtableChecks': table_checks,
'stringChecks': string_checks,
'contextualFunctionCandidates': contextual,
'neededPdataSpanChecks': full_needed_spans(old, new, matches),
'limitations': ['Signature windows do not verify complete function semantics or object layouts.',
'Internal call targets, vtable lengths/order, owner/thread/lifetime invariants and hook relocation ABI require independent review.',
'Local manifest/log consistency establishes local Steam provenance evidence, not independent authentication of the entire distribution.',
'Unresolved/ambiguous symbols must not be migrated by a global delta.']
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf8')
print(json.dumps({'output': str(args.output), 'oldVersion': meta_old['version'], 'newVersion': meta_new['version'],
'counts': report['counts'], 'depotHashesMatch': all(x['matchesContentSha1'] and x['matchesFileSize'] for x in manifests)}, indent=2))
if __name__ == '__main__':
main()