Native iPhone port of Factorio 2.0.77
Run the original macOS ARM64 game code on iOS: - tools/retarget.py: header-only Mach-O retargeting to an iOS dylib - platform/native: UIKit host and macOS API shims (AppKit for SDL's Cocoa backend, CoreGraphics displays, CoreAudio device, Metal device properties, Wuffs PNG decoding, Steam and peripheral stubs) - touch, overlay, iOS keyboard and hardware keyboard input - tools/build_native.py: build, sign, install, data copy, launch - docs: build guide, controls, architecture Also includes the earlier static-translation research (ARM64 -> WASM -> native) under docs/research, runtime/, tests/ and tools/. No game files are included; the build reads them from a local install.
This commit is contained in:
commit
51d085e865
89 files changed
+7999
No files matched your search
@@ -0,0 +1,415 @@
|
||||
"""Bounded A64 integer-to-WAT compiler with validated immediate control flow.
|
||||
|
||||
Handles selected integer operations, NZCV arithmetic, branches, and declared
|
||||
host calls. It is not a whole-program lifter. Guest registers remain 64-bit;
|
||||
memory addresses are checked before narrowing to the 64 KiB prototype memory.
|
||||
Straight-line exports use run(ctx); CFG exports use run(ctx, instruction_budget).
|
||||
Budget exhaustion returns status 3 with partial register/memory effects; it is
|
||||
an abort, not a resumable checkpoint. Incoming NZCV is not part of this ABI.
|
||||
"""
|
||||
import struct
|
||||
|
||||
|
||||
class UnsupportedInstruction(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def signed(value, bits):
|
||||
return value - (1 << bits) if value & (1 << (bits - 1)) else value
|
||||
|
||||
|
||||
def const(value):
|
||||
return f"(i64.const {signed(value & ((1 << 64) - 1), 64)})"
|
||||
|
||||
|
||||
class Compiler:
|
||||
def __init__(self, hosts=None):
|
||||
self.hosts = hosts or {}
|
||||
self.instructions = []
|
||||
self.calls = []
|
||||
|
||||
def reg(self, index, width=64, sp=False):
|
||||
value = f"(local.get ${'sp' if index == 31 else 'x' + str(index)})" if index != 31 or sp else const(0)
|
||||
return value if width == 64 else f"(i64.and {value} {const(0xffffffff)})"
|
||||
|
||||
def put(self, index, value, width=64, sp=False):
|
||||
if index == 31 and not sp:
|
||||
return f"(drop {value})"
|
||||
if width == 32:
|
||||
value = f"(i64.and {value} {const(0xffffffff)})"
|
||||
return f"(local.set ${'sp' if index == 31 else 'x' + str(index)} {value})"
|
||||
|
||||
def address(self, base, offset):
|
||||
return f"(i64.add {self.reg(base, sp=True)} {const(offset)})"
|
||||
|
||||
def load(self, size, address):
|
||||
op = {1: 'i64.load8_u', 2: 'i64.load16_u', 4: 'i64.load32_u', 8: 'i64.load'}[size]
|
||||
return f"({op} (call $addr {address} {const(size)}))"
|
||||
|
||||
def store(self, size, address, value):
|
||||
op = {1: 'i64.store8', 2: 'i64.store16', 4: 'i64.store32', 8: 'i64.store'}[size]
|
||||
return f"({op} (call $addr {address} {const(size)}) {value})"
|
||||
|
||||
def arithmetic(self, rd, left, right, width, subtract, flags, sp=False):
|
||||
operation = 'sub' if subtract else 'add'
|
||||
mask = const((1 << width) - 1)
|
||||
result = [f'(local.set $a {left})', f'(local.set $b {right})',
|
||||
f'(local.set $result (i64.and (i64.{operation} (local.get $a) (local.get $b)) {mask}))']
|
||||
if flags:
|
||||
a, b, r = '(local.get $a)', '(local.get $b)', '(local.get $result)'
|
||||
overflow = f'(i64.and (i64.xor {a} {b}) (i64.xor {a} {r}))' if subtract else \
|
||||
f'(i64.and (i64.xor {a} {r}) (i64.xor {b} {r}))'
|
||||
result += [f'(local.set $n (i32.wrap_i64 (i64.shr_u {r} {const(width-1)})))',
|
||||
f'(local.set $z (i64.eqz {r}))',
|
||||
f'(local.set $c (i64.{"ge_u" if subtract else "lt_u"} {a if subtract else r} {b if subtract else a}))',
|
||||
f'(local.set $v (i32.wrap_i64 (i64.shr_u {overflow} {const(width-1)})))']
|
||||
result.append(self.put(rd, '(local.get $result)', width, sp=sp and not flags))
|
||||
return result
|
||||
|
||||
def condition(self, cond):
|
||||
n, z, c, v = ['(local.get $' + name + ')' for name in ('n', 'z', 'c', 'v')]
|
||||
conditions = [z, c, n, v, f'(i32.and {c} (i32.eqz {z}))',
|
||||
f'(i32.eq {n} {v})', f'(i32.and (i32.eq {n} {v}) (i32.eqz {z}))', '(i32.const 1)']
|
||||
result = conditions[cond >> 1]
|
||||
return f'(i32.eqz {result})' if cond & 1 and cond < 14 else result
|
||||
|
||||
def decode(self, word, pc):
|
||||
rd, rn = word & 31, (word >> 5) & 31
|
||||
width = 64 if word >> 31 else 32
|
||||
if word == 0xd65f03c0:
|
||||
return ["(br $done)"], 'ret'
|
||||
# Narrow 128-bit load/store copy idiom; compile() validates the adjacent
|
||||
# matching pair and forbids control-flow entry into its store half.
|
||||
if word & 0xffe00c00 == 0x3cc00000:
|
||||
address = self.address(rn, signed((word >> 12) & 511, 9))
|
||||
return [f'(local.set $base {address})',
|
||||
f'(local.set $qlo {self.load(8, "(local.get $base)")})',
|
||||
f'(local.set $qhi {self.load(8, "(i64.add (local.get $base) (i64.const 8))")})'], 'copy128-load'
|
||||
if word & 0xffc00000 == 0x3d800000:
|
||||
address = self.address(rn, ((word >> 10) & 4095) * 16)
|
||||
return [f'(local.set $base {address})',
|
||||
self.store(8, '(local.get $base)', '(local.get $qlo)'),
|
||||
self.store(8, '(i64.add (local.get $base) (i64.const 8))', '(local.get $qhi)')], 'copy128-store'
|
||||
# Conditional select family (CSEL/CSINC/CSINV/CSNEG), including CSET.
|
||||
if word & 0x3fe00800 == 0x1a800000:
|
||||
rm, cond = (word >> 16) & 31, (word >> 12) & 15
|
||||
alternative = self.reg(rm, width)
|
||||
invert, increment = bool(word & (1 << 30)), bool(word & (1 << 10))
|
||||
if invert:
|
||||
alternative = f'(i64.xor {alternative} {const((1 << width)-1)})'
|
||||
if increment:
|
||||
alternative = f'(i64.add {alternative} (i64.const 1))'
|
||||
return [self.put(rd, f'(select {self.reg(rn, width)} {alternative} {self.condition(cond)})', width)], 'select-flags'
|
||||
if word & 0xfc000000 == 0x94000000:
|
||||
target = pc + signed(word & 0x3ffffff, 26) * 4
|
||||
if target not in self.hosts:
|
||||
raise UnsupportedInstruction(f"undeclared call to {target:#x}")
|
||||
host = self.hosts[target]
|
||||
self.calls.append({'site': hex(pc), 'target': hex(target), 'host': host})
|
||||
return [self.put(30, const(pc + 4)),
|
||||
f"(local.set $status (call ${host} {self.reg(0)} {self.reg(1)} {self.reg(2)}))",
|
||||
"(br_if $done (local.get $status))"], 'bl-host'
|
||||
# ADD/SUB immediate, including CMP/CMN aliases.
|
||||
if word & 0x1f000000 == 0x11000000:
|
||||
if word & (1 << 23):
|
||||
raise UnsupportedInstruction('reserved ADD/SUB immediate')
|
||||
immediate = ((word >> 10) & 4095) << (12 if word & (1 << 22) else 0)
|
||||
flags = bool(word & (1 << 29))
|
||||
return self.arithmetic(rd, self.reg(rn, width, sp=True), const(immediate), width,
|
||||
bool(word & (1 << 30)), flags, sp=True), 'arithmetic' + ('-flags' if flags else '')
|
||||
if word & 0x1f200000 == 0x0b000000:
|
||||
shift, amount = (word >> 22) & 3, (word >> 10) & 63
|
||||
if shift == 3 or amount >= width:
|
||||
raise UnsupportedInstruction('reserved arithmetic shift')
|
||||
source = self.reg((word >> 16) & 31, width)
|
||||
if shift == 2 and width == 32:
|
||||
source = f'(i64.extend_i32_u (i32.shr_s (i32.wrap_i64 {source}) (i32.const {amount})))'
|
||||
elif amount:
|
||||
source = f'(i64.{["shl", "shr_u", "shr_s"][shift]} {source} {const(amount)})'
|
||||
source = f'(i64.and {source} {const((1 << width)-1)})'
|
||||
flags = bool(word & (1 << 29))
|
||||
return self.arithmetic(rd, self.reg(rn, width), source, width,
|
||||
bool(word & (1 << 30)), flags), 'arithmetic' + ('-flags' if flags else '')
|
||||
# Move-wide immediate (MOVZ/MOVN/MOVK).
|
||||
if word & 0x1f800000 == 0x12800000:
|
||||
operation, shift = (word >> 29) & 3, ((word >> 21) & 3) * 16
|
||||
if operation == 1 or shift >= width:
|
||||
raise UnsupportedInstruction('reserved move-wide encoding')
|
||||
value = ((word >> 5) & 65535) << shift
|
||||
if operation == 0:
|
||||
expression = const((~value) & ((1 << width) - 1))
|
||||
elif operation == 2:
|
||||
expression = const(value)
|
||||
else:
|
||||
mask = (~(65535 << shift)) & ((1 << width) - 1)
|
||||
expression = f"(i64.or (i64.and {self.reg(rd, width)} {const(mask)}) {const(value)})"
|
||||
return [self.put(rd, expression, width)], 'move-wide'
|
||||
# Logical shifted register, including the MOV register alias.
|
||||
if word & 0x1f000000 == 0x0a000000:
|
||||
op, shift_kind = (word >> 29) & 3, (word >> 22) & 3
|
||||
amount, rm = (word >> 10) & 63, (word >> 16) & 31
|
||||
if op == 3 or shift_kind == 2 or amount >= width:
|
||||
raise UnsupportedInstruction('flag-setting, arithmetic shift, or reserved logical encoding')
|
||||
source = self.reg(rm, width)
|
||||
if amount:
|
||||
if shift_kind == 3 and width == 32:
|
||||
source = f"(i64.extend_i32_u (i32.rotr (i32.wrap_i64 {source}) (i32.const {amount})))"
|
||||
else:
|
||||
shift = {0: 'shl', 1: 'shr_u', 3: 'rotr'}[shift_kind]
|
||||
source = f"(i64.{shift} {source} {const(amount)})"
|
||||
if word & (1 << 21):
|
||||
source = f"(i64.xor {source} {const((1 << width) - 1)})"
|
||||
operation = {0: 'and', 1: 'or', 2: 'xor'}[op]
|
||||
return [self.put(rd, f"(i64.{operation} {self.reg(rn, width)} {source})", width)], 'logical-register'
|
||||
# Unsigned bitfield. Covers UBFX, UBFIZ and immediate logical shifts.
|
||||
if word & 0x7f800000 == 0x53000000:
|
||||
if ((word >> 22) & 1) != (width == 64):
|
||||
raise UnsupportedInstruction('reserved UBFM width')
|
||||
r, s = (word >> 16) & 63, (word >> 10) & 63
|
||||
if max(r, s) >= width:
|
||||
raise UnsupportedInstruction('reserved UBFM range')
|
||||
value = self.reg(rn, width)
|
||||
if s >= r:
|
||||
value = f"(i64.and (i64.shr_u {value} {const(r)}) {const((1 << (s - r + 1)) - 1)})"
|
||||
else:
|
||||
value = f"(i64.shl (i64.and {value} {const((1 << (s + 1)) - 1)}) {const(width - r)})"
|
||||
return [self.put(rd, value, width)], 'unsigned-bitfield'
|
||||
# Integer load/store with unsigned scaled immediate.
|
||||
if word & 0x3b000000 == 0x39000000 and not word & (1 << 26):
|
||||
size, op = 1 << (word >> 30), (word >> 22) & 3
|
||||
if op not in (0, 1):
|
||||
raise UnsupportedInstruction('signed/prefetch load variant')
|
||||
address = self.address(rn, ((word >> 10) & 4095) * size)
|
||||
code = self.put(rd, self.load(size, address)) if op else self.store(size, address, self.reg(rd))
|
||||
return [code], ('load' if op else 'store') + str(size * 8)
|
||||
# Integer unscaled or pre/post-indexed immediate accesses.
|
||||
if word & 0x3b200000 == 0x38000000 and not word & (1 << 26):
|
||||
size, op, mode = 1 << (word >> 30), (word >> 22) & 3, (word >> 10) & 3
|
||||
if op not in (0, 1) or mode == 2:
|
||||
raise UnsupportedInstruction('signed/prefetch/unprivileged unscaled access')
|
||||
if mode in (1, 3) and rn != 31 and rn == rd:
|
||||
raise UnsupportedInstruction('unscaled access with unpredictable writeback overlap')
|
||||
offset = signed((word >> 12) & 511, 9)
|
||||
result = []
|
||||
if mode == 3:
|
||||
result.append(self.put(rn, self.address(rn, offset), sp=True))
|
||||
result.append(f'(local.set $base {self.address(rn, offset if mode == 0 else 0)})')
|
||||
address = '(local.get $base)'
|
||||
result.append(self.put(rd, self.load(size, address)) if op else self.store(size, address, self.reg(rd)))
|
||||
if mode == 1:
|
||||
result.append(self.put(rn, self.address(rn, offset), sp=True))
|
||||
return result, 'unscaled-access'
|
||||
# Integer pairs, including SP pre/post-indexed stack saves.
|
||||
if word & 0x3a000000 == 0x28000000 and not word & (1 << 26):
|
||||
opc, mode, rt2 = word >> 30, (word >> 23) & 3, (word >> 10) & 31
|
||||
if opc not in (0, 2) or mode == 0:
|
||||
raise UnsupportedInstruction('non-temporal/signed/reserved pair')
|
||||
if mode in (1, 3) and rn != 31 and rn in (rd, rt2):
|
||||
raise UnsupportedInstruction('pair with constrained-unpredictable writeback overlap')
|
||||
size = 8 if opc == 2 else 4
|
||||
offset = signed((word >> 15) & 127, 7) * size
|
||||
result = []
|
||||
if mode == 3:
|
||||
result.append(self.put(rn, self.address(rn, offset), sp=True))
|
||||
effective_offset = offset if mode == 2 else 0
|
||||
# Snapshot the base so register destinations cannot alter later addresses.
|
||||
result.append(f"(local.set $base {self.address(rn, effective_offset)})")
|
||||
for index, register in enumerate((rd, rt2)):
|
||||
address = f"(i64.add (local.get $base) {const(index * size)})"
|
||||
if word & (1 << 22):
|
||||
result.append(self.put(register, self.load(size, address)))
|
||||
else:
|
||||
result.append(self.store(size, address, self.reg(register)))
|
||||
if mode == 1:
|
||||
result.append(self.put(rn, self.address(rn, offset), sp=True))
|
||||
return result, ('load' if word & (1 << 22) else 'store') + '-pair'
|
||||
raise UnsupportedInstruction(f"unsupported opcode 0x{word:08x}")
|
||||
|
||||
def compile(self, body, address):
|
||||
self.instructions = []
|
||||
self.calls = []
|
||||
if not body or len(body) % 4:
|
||||
raise ValueError('A64 input must contain complete instructions')
|
||||
words = list(struct.unpack('<' + 'I' * (len(body) // 4), body))
|
||||
for i, word in enumerate(words):
|
||||
if word & 0xffe00c00 == 0x3cc00000:
|
||||
if i + 1 >= len(words) or words[i+1] & 0xffc0001f != 0x3d800000 | (word & 31):
|
||||
raise UnsupportedInstruction('128-bit load requires adjacent matching store')
|
||||
if word & 0xffc00000 == 0x3d800000:
|
||||
if not i or words[i-1] & 0xffe00c1f != 0x3cc00000 | (word & 31):
|
||||
raise UnsupportedInstruction('128-bit store requires adjacent matching load')
|
||||
for j, candidate in enumerate(words):
|
||||
branch = self.branch(candidate, address+j*4)
|
||||
if branch and branch[0] == address+i*4:
|
||||
raise UnsupportedInstruction('control flow enters 128-bit copy halfway')
|
||||
if any(self.branch(word, address + i * 4) is not None for i, word in enumerate(words)):
|
||||
return self.compile_cfg(words, address)
|
||||
code = []
|
||||
saw_ret = False
|
||||
defined_flags = False
|
||||
for index, (word,) in enumerate(struct.iter_unpack('<I', body)):
|
||||
pc = address + index * 4
|
||||
if saw_ret:
|
||||
raise UnsupportedInstruction('bytes after terminal RET require a control-flow model')
|
||||
try:
|
||||
emitted, kind = self.decode(word, pc)
|
||||
except UnsupportedInstruction as error:
|
||||
raise UnsupportedInstruction(f'{pc:#x}: {error}') from error
|
||||
if kind == 'select-flags' and ((word >> 12) & 15) < 14 and not defined_flags:
|
||||
raise UnsupportedInstruction(f'conditional select reads undefined NZCV at {pc:#x}')
|
||||
if kind == 'arithmetic-flags':
|
||||
defined_flags = True
|
||||
elif kind == 'bl-host':
|
||||
defined_flags = False
|
||||
self.instructions.append({'address': hex(pc), 'word': f'{word:08x}', 'kind': kind})
|
||||
code += [f';; {pc:#x}: {word:08x} {kind}', *emitted]
|
||||
saw_ret = kind == 'ret'
|
||||
if not saw_ret:
|
||||
raise UnsupportedInstruction('missing terminal RET')
|
||||
registers = [f'x{i}' for i in range(31)] + ['sp']
|
||||
imports = '\n'.join(f'(import "env" "{name}" (func ${name} (param i64 i64 i64) (result i32)))'
|
||||
for name in sorted(set(self.hosts.values())))
|
||||
declarations = '\n'.join(f'(local ${name} i64)' for name in registers)
|
||||
loads = '\n'.join(f'(local.set ${name} (i64.load offset={i * 8} (local.get $ctx)))'
|
||||
for i, name in enumerate(registers))
|
||||
stores = '\n'.join(f'(i64.store offset={i * 8} (local.get $ctx) (local.get ${name}))'
|
||||
for i, name in enumerate(registers))
|
||||
return f'''(module
|
||||
{imports}
|
||||
(memory (export "memory") 1 1)
|
||||
(func $addr (param $value i64) (param $size i64) (result i32)
|
||||
(if (i64.gt_u (local.get $value) (i64.sub (i64.const 65536) (local.get $size)))
|
||||
(then unreachable))
|
||||
(i32.wrap_i64 (local.get $value)))
|
||||
(func (export "run") (param $ctx i32) (result i32)
|
||||
{declarations}
|
||||
(local $base i64) (local $status i32)
|
||||
(local $a i64) (local $b i64) (local $result i64) (local $qlo i64) (local $qhi i64)
|
||||
(local $n i32) (local $z i32) (local $c i32) (local $v i32)
|
||||
{loads}
|
||||
(block $done
|
||||
{chr(10).join(code)}
|
||||
)
|
||||
{stores}
|
||||
(local.get $status)))
|
||||
'''
|
||||
|
||||
def branch(self, word, pc):
|
||||
"""Return (target, condition) for supported immediate branches."""
|
||||
if word & 0xfc000000 == 0x14000000:
|
||||
return pc + signed(word & 0x3ffffff, 26) * 4, None
|
||||
if word & 0xff000010 == 0x54000000:
|
||||
cond = word & 15
|
||||
return pc + signed((word >> 5) & 0x7ffff, 19) * 4, self.condition(cond)
|
||||
if word & 0x7e000000 == 0x34000000:
|
||||
width = 64 if word >> 31 else 32
|
||||
zero = f'(i64.eqz {self.reg(word & 31, width)})'
|
||||
condition = f'(i32.eqz {zero})' if word & (1 << 24) else zero
|
||||
return pc + signed((word >> 5) & 0x7ffff, 19) * 4, condition
|
||||
if word & 0x7e000000 == 0x36000000:
|
||||
bit = ((word >> 31) << 5) | ((word >> 19) & 31)
|
||||
zero = f'(i64.eqz (i64.and {self.reg(word & 31)} {const(1 << bit)}))'
|
||||
condition = f'(i32.eqz {zero})' if word & (1 << 24) else zero
|
||||
return pc + signed((word >> 5) & 0x3fff, 14) * 4, condition
|
||||
return None
|
||||
|
||||
def compile_cfg(self, words, address):
|
||||
"""Validated instruction-block dispatcher; guest loops consume a caller budget.
|
||||
|
||||
Each instruction is currently a block. This deliberately simple CFG is
|
||||
correct before block coalescing/structured-loop optimization is added.
|
||||
"""
|
||||
end = address + len(words) * 4
|
||||
nodes = []
|
||||
reachable = set()
|
||||
successors = {}
|
||||
pending = [address]
|
||||
while pending:
|
||||
pc = pending.pop()
|
||||
if pc in reachable:
|
||||
continue
|
||||
if not address <= pc < end or (pc - address) % 4:
|
||||
raise UnsupportedInstruction(f'control flow outside declared body: {pc:#x}')
|
||||
reachable.add(pc)
|
||||
word = words[(pc - address) // 4]
|
||||
branch = self.branch(word, pc)
|
||||
if branch:
|
||||
edges = [branch[0]]
|
||||
if branch[1] is not None:
|
||||
edges.append(pc + 4)
|
||||
elif word != 0xd65f03c0:
|
||||
edges = [pc + 4]
|
||||
else:
|
||||
edges = []
|
||||
successors[pc] = edges
|
||||
pending.extend(edges)
|
||||
if len(reachable) != len(words):
|
||||
raise UnsupportedInstruction('unreachable bytes require explicit code/data classification')
|
||||
if not any(word == 0xd65f03c0 for word in words):
|
||||
raise UnsupportedInstruction('missing RET in control-flow body')
|
||||
for i, word in enumerate(words):
|
||||
pc = address + i * 4
|
||||
branch = self.branch(word, pc)
|
||||
if branch:
|
||||
target, condition = branch
|
||||
next_pc = f'(i32.const {(target-address)//4})'
|
||||
if condition is not None:
|
||||
next_pc = f'(select {next_pc} (i32.const {i+1}) {condition})'
|
||||
emitted = [f'(local.set $pc {next_pc})', '(br $dispatch)']
|
||||
kind = 'branch-immediate'
|
||||
else:
|
||||
emitted, kind = self.decode(word, pc)
|
||||
emitted += [f'(local.set $pc (i32.const {i+1}))', '(br $dispatch)']
|
||||
self.instructions.append({'address': hex(pc), 'word': f'{word:08x}', 'kind': kind})
|
||||
nodes.append(f'(if (i32.eq (local.get $pc) (i32.const {i})) (then\n' + '\n'.join(emitted) + '\n))')
|
||||
# Require flags to be defined on every path before a conditional read.
|
||||
# Host calls invalidate flags: their ARM64 ABI does not preserve NZCV.
|
||||
incoming = {pc: pc != address for pc in reachable}
|
||||
outgoing = dict(incoming)
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for i, instruction in enumerate(self.instructions):
|
||||
pc = address + i * 4
|
||||
predecessors = [p for p, edges in successors.items() if pc in edges]
|
||||
value = pc != address and all(outgoing[p] for p in predecessors)
|
||||
after = True if instruction['kind'] == 'arithmetic-flags' else \
|
||||
False if instruction['kind'] == 'bl-host' else value
|
||||
if (incoming[pc], outgoing[pc]) != (value, after):
|
||||
incoming[pc], outgoing[pc] = value, after
|
||||
changed = True
|
||||
for i, word in enumerate(words):
|
||||
cond = (word & 15) if word & 0xff000010 == 0x54000000 else \
|
||||
((word >> 12) & 15) if self.instructions[i]['kind'] == 'select-flags' else 14
|
||||
if cond < 14 and not incoming[address+i*4]:
|
||||
raise UnsupportedInstruction(f'conditional operation reads undefined NZCV at {address+i*4:#x}')
|
||||
registers = [f'x{i}' for i in range(31)] + ['sp']
|
||||
imports = '\n'.join(f'(import "env" "{name}" (func ${name} (param i64 i64 i64) (result i32)))'
|
||||
for name in sorted(set(self.hosts.values())))
|
||||
declarations = '\n'.join(f'(local ${name} i64)' for name in registers)
|
||||
loads = '\n'.join(f'(local.set ${name} (i64.load offset={i * 8} (local.get $ctx)))'
|
||||
for i, name in enumerate(registers))
|
||||
stores = '\n'.join(f'(i64.store offset={i * 8} (local.get $ctx) (local.get ${name}))'
|
||||
for i, name in enumerate(registers))
|
||||
return f'''(module
|
||||
{imports}
|
||||
(memory (export "memory") 1 1)
|
||||
(func $addr (param $value i64) (param $size i64) (result i32)
|
||||
(if (i64.gt_u (local.get $value) (i64.sub (i64.const 65536) (local.get $size))) (then unreachable))
|
||||
(i32.wrap_i64 (local.get $value)))
|
||||
(func (export "run") (param $ctx i32) (param $budget i32) (result i32)
|
||||
{declarations}
|
||||
(local $base i64) (local $status i32) (local $pc i32)
|
||||
(local $a i64) (local $b i64) (local $result i64) (local $qlo i64) (local $qhi i64)
|
||||
(local $n i32) (local $z i32) (local $c i32) (local $v i32)
|
||||
{loads}
|
||||
(block $done
|
||||
(loop $dispatch
|
||||
(if (i32.eqz (local.get $budget)) (then (local.set $status (i32.const 3)) (br $done)))
|
||||
(local.set $budget (i32.sub (local.get $budget) (i32.const 1)))
|
||||
{chr(10).join(nodes)}
|
||||
unreachable
|
||||
))
|
||||
{stores}
|
||||
(local.get $status)))
|
||||
'''
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Bounded multi-function A64 dispatcher with shared registers and guest memory.
|
||||
|
||||
Direct BL enters a declared guest function, B can tail-call declared code, and
|
||||
RET dispatches through the guest link register. No native recursion or JIT is
|
||||
used. Guest stack instructions retain their original semantics. All guest
|
||||
instructions share one budget. Indirect calls and arbitrary computed returns
|
||||
are unsupported. Host calls use an explicit status ABI, with an opt-in context
|
||||
adapter that can return x0. Static ADRP pages must be explicitly mapped. The
|
||||
zero-vector stores require a MOVI-zero definition on every incoming path.
|
||||
"""
|
||||
import struct
|
||||
from a64_lift import Compiler, UnsupportedInstruction, const, signed
|
||||
|
||||
|
||||
class ProgramCompiler(Compiler):
|
||||
def __init__(self, hosts=None, *, returning_hosts=(), memory_pages=1, static_pages=None):
|
||||
super().__init__(hosts)
|
||||
if type(memory_pages) is not int or not 1 <= memory_pages <= 32768:
|
||||
raise ValueError('Guest memory must contain 1..32768 fixed WASM pages')
|
||||
self.memory_pages = memory_pages
|
||||
self.returning_hosts = set(returning_hosts)
|
||||
if not self.returning_hosts <= set(self.hosts):
|
||||
raise ValueError('Returning hosts must be declared host targets')
|
||||
self.static_pages = dict(static_pages or {})
|
||||
self.zero_stores = {}
|
||||
for original, guest in self.static_pages.items():
|
||||
if original < 0 or original >= 1 << 64 or original % 4096 or guest < 0 or guest % 4096 or guest + 4096 > memory_pages*65536:
|
||||
raise ValueError('Static pages require aligned original and bounded guest addresses')
|
||||
signatures = {}
|
||||
for target, name in self.hosts.items():
|
||||
signature = target in self.returning_hosts
|
||||
if name in signatures and signatures[name] != signature:
|
||||
raise ValueError('A host import cannot have conflicting ABIs')
|
||||
signatures[name] = signature
|
||||
|
||||
def decode(self, word, pc):
|
||||
if pc in self.zero_stores:
|
||||
rn = (word >> 5) & 31
|
||||
mode, count = 2, 1
|
||||
if word & 0xffc00000 == 0x3d800000:
|
||||
offset = ((word >> 10) & 4095)*16
|
||||
elif word & 0xffe00000 == 0x3c800000:
|
||||
mode = (word >> 10)&3
|
||||
if mode == 2: raise UnsupportedInstruction('Unprivileged vector store')
|
||||
offset = signed((word >> 12)&511,9)
|
||||
else:
|
||||
mode, count = (word >> 23)&3, 2
|
||||
if mode == 0: raise UnsupportedInstruction('Non-temporal vector pair')
|
||||
offset = signed((word >> 15)&127,7)*16
|
||||
code = []
|
||||
if mode == 3: code.append(self.put(rn,self.address(rn,offset),sp=True))
|
||||
code.append(f'(local.set $base {self.address(rn,offset if mode in (0,2) else 0)})')
|
||||
code += [self.store(8,f'(i64.add (local.get $base) {const(i*8)})',const(0)) for i in range(count*2)]
|
||||
if mode == 1: code.append(self.put(rn,self.address(rn,offset),sp=True))
|
||||
return code, 'zero128-store'
|
||||
if word & 0xffffffe0 == 0x6f00e400:
|
||||
return ['(nop)'], 'zero128-immediate'
|
||||
if word & 0x9f000000 == 0x90000000:
|
||||
immediate = ((word >> 5) & 0x7ffff) << 2 | ((word >> 29) & 3)
|
||||
page = ((pc & ~4095) + signed(immediate, 21)*4096) & ((1 << 64)-1)
|
||||
if page not in self.static_pages:
|
||||
raise UnsupportedInstruction(f'Unmapped ADRP page {page:#x}')
|
||||
return [self.put(word & 31, const(self.static_pages[page]))], 'mapped-static-page'
|
||||
if word & 0x1f800000 == 0x12000000:
|
||||
width = 64 if word >> 31 else 32
|
||||
n, r, s, operation = (word >> 22)&1, (word >> 16)&63, (word >> 10)&63, (word >> 29)&3
|
||||
length = ((n << 6) | ((~s)&63)).bit_length()-1
|
||||
if operation == 3 or length < 1 or (width == 32 and n):
|
||||
raise UnsupportedInstruction('Unsupported logical immediate encoding')
|
||||
levels = (1 << length)-1
|
||||
if s & levels == levels:
|
||||
raise UnsupportedInstruction('Reserved logical immediate mask')
|
||||
element_size, rotation = 1 << length, r & levels
|
||||
mask = (1 << element_size)-1
|
||||
element = (1 << ((s & levels)+1))-1
|
||||
element = ((element >> rotation) | (element << (element_size-rotation))) & mask
|
||||
immediate = sum(element << shift for shift in range(0,width,element_size))
|
||||
expression = f'(i64.{["and","or","xor"][operation]} {self.reg((word >> 5)&31,width)} {const(immediate)})'
|
||||
return [self.put(word&31,expression,width)], 'logical-immediate'
|
||||
# ASR immediate only; other signed bitfield forms remain unsupported.
|
||||
if word & 0x7f800000 == 0x13000000:
|
||||
width = 64 if word >> 31 else 32
|
||||
r, s = (word >> 16) & 63, (word >> 10) & 63
|
||||
if ((word >> 22) & 1) != (width == 64) or s != width-1 or r >= width:
|
||||
raise UnsupportedInstruction('Unsupported signed bitfield form')
|
||||
value = self.reg((word >> 5) & 31, width)
|
||||
expression = f'(i64.shr_s {value} {const(r)})' if width == 64 else \
|
||||
f'(i64.extend_i32_u (i32.shr_s (i32.wrap_i64 {value}) (i32.const {r})))'
|
||||
return [self.put(word & 31, expression, width)], 'arithmetic-shift'
|
||||
if word & 0xffe0001f == 0xd4200000:
|
||||
return ['unreachable'], 'break-trap'
|
||||
if word & 0xfc000000 == 0x94000000:
|
||||
target = pc + signed(word & 0x3ffffff, 26)*4
|
||||
if target in self.returning_hosts:
|
||||
host = self.hosts[target]
|
||||
self.calls.append({'site':hex(pc), 'target':hex(target), 'host':host, 'abi':'context-x0-result'})
|
||||
registers = [f'x{i}' for i in range(31)]+['sp']
|
||||
# Snapshot the complete register file; the adapter may return x0
|
||||
# only. All other registers remain in their guest locals.
|
||||
return [self.put(30, const(pc+4)),
|
||||
*[f'(i64.store offset={i*8} (local.get $ctx) (local.get ${name}))' for i,name in enumerate(registers)],
|
||||
f'(local.set $status (call ${host} (local.get $ctx)))',
|
||||
'(br_if $done (local.get $status))',
|
||||
'(local.set $x0 (i64.load (local.get $ctx)))'], 'bl-host'
|
||||
return super().decode(word, pc)
|
||||
|
||||
def compile(self, functions, entry):
|
||||
self.instructions, self.calls = [], []
|
||||
if entry not in functions or not functions:
|
||||
raise ValueError('Entry must name a declared guest function')
|
||||
sites, pairs = {}, set()
|
||||
self.zero_stores = {}
|
||||
for address, body in sorted(functions.items()):
|
||||
if address < 0 or address % 4 or not body or len(body) % 4 or address + len(body) > 1 << 64:
|
||||
raise ValueError('Guest functions require aligned, nonempty 64-bit instruction ranges')
|
||||
words = struct.unpack('<'+'I'*(len(body)//4),body)
|
||||
# Identify stores first; validate definitely-zero source registers
|
||||
# against the complete control-flow graph below. Other SIMD remains
|
||||
# unsupported. The existing adjacent load/copy idiom stays separate.
|
||||
for i, word in enumerate(words):
|
||||
if word & 0xffe00000 == 0x3c800000 or word & 0xffc00000 == 0x3d800000:
|
||||
is_copy = (word & 0xffc00000 == 0x3d800000 and i and
|
||||
words[i-1] & 0xffe00c1f == 0x3cc00000 | (word&31))
|
||||
if not is_copy: self.zero_stores[address+i*4] = [word&31]
|
||||
elif word & 0x3a000000 == 0x28000000 and word & (1<<26) and word >> 30 == 2 and not word & (1<<22):
|
||||
self.zero_stores[address+i*4] = [word&31,(word>>10)&31]
|
||||
for i, word in enumerate(words):
|
||||
pc = address+i*4
|
||||
if pc in sites:
|
||||
raise ValueError('Overlapping guest functions')
|
||||
sites[pc] = word
|
||||
if word & 0xffe00c00 == 0x3cc00000:
|
||||
if i+1 >= len(words) or words[i+1] & 0xffc0001f != 0x3d800000 | (word & 31):
|
||||
raise UnsupportedInstruction('128-bit load requires adjacent matching store')
|
||||
if word & 0xffc00000 == 0x3d800000 and pc not in self.zero_stores:
|
||||
if not i or words[i-1] & 0xffe00c1f != 0x3cc00000 | (word & 31):
|
||||
raise UnsupportedInstruction('128-bit store requires adjacent matching load')
|
||||
pairs.add(pc)
|
||||
if set(sites) & set(self.hosts):
|
||||
raise ValueError('Guest and host entry points overlap')
|
||||
addresses = sorted(sites)
|
||||
indices = {pc:i for i,pc in enumerate(addresses)}
|
||||
guest_calls = {}
|
||||
for pc, word in sites.items():
|
||||
if word & 0xfc000000 == 0x94000000:
|
||||
target = pc + ((word & 0x3ffffff) - (0x4000000 if word & 0x2000000 else 0))*4
|
||||
if target not in self.hosts:
|
||||
if target not in functions:
|
||||
raise UnsupportedInstruction(f'undeclared guest BL target {target:#x}')
|
||||
if pc+4 not in sites:
|
||||
raise UnsupportedInstruction('Guest call has no declared continuation')
|
||||
guest_calls[pc] = target
|
||||
returns = sorted(pc+4 for pc in guest_calls)
|
||||
successors, kinds, nodes = {}, {}, []
|
||||
for pc in addresses:
|
||||
word = sites[pc]
|
||||
branch = self.branch(word, pc)
|
||||
if branch:
|
||||
target, condition = branch
|
||||
edges = [target] + ([pc+4] if condition is not None else [])
|
||||
next_pc = f'(i32.const {indices.get(target, -1)})'
|
||||
if condition is not None:
|
||||
next_pc = f'(select {next_pc} (i32.const {indices.get(pc+4, -1)}) {condition})'
|
||||
emitted = [f'(local.set $pc {next_pc})','(br $dispatch)']
|
||||
kind = 'branch-immediate'
|
||||
if target in pairs:
|
||||
raise UnsupportedInstruction('Control flow enters a 128-bit copy halfway')
|
||||
elif pc in guest_calls:
|
||||
target = guest_calls[pc]
|
||||
emitted = [self.put(30,const(pc+4)),
|
||||
f'(local.set $pc (i32.const {indices[target]}))','(br $dispatch)']
|
||||
edges, kind = [target], 'bl-guest'
|
||||
self.calls.append({'site':hex(pc),'target':hex(target),'kind':'guest'})
|
||||
elif word == 0xd65f03c0:
|
||||
emitted = ['(br_if $done (i64.eq (local.get $x30) (local.get $exit_lr)))']
|
||||
emitted += [f'(if (i64.eq (local.get $x30) {const(target)}) (then '
|
||||
f'(local.set $pc (i32.const {indices[target]})) (br $dispatch)))' for target in returns]
|
||||
emitted += ['(local.set $status (i32.const 4))','(br $done)']
|
||||
edges, kind = returns, 'ret'
|
||||
else:
|
||||
try:
|
||||
emitted, kind = self.decode(word,pc)
|
||||
except UnsupportedInstruction as error:
|
||||
raise UnsupportedInstruction(f'{pc:#x}: {error}') from error
|
||||
emitted += [f'(local.set $pc (i32.const {indices.get(pc+4,-1)}))','(br $dispatch)']
|
||||
edges = [] if kind == 'break-trap' else [pc+4]
|
||||
for target in edges:
|
||||
if target not in sites:
|
||||
raise UnsupportedInstruction(f'Control flow outside declared code: {target:#x}')
|
||||
successors[pc], kinds[pc] = edges, kind
|
||||
self.instructions.append({'address':hex(pc),'word':f'{word:08x}','kind':kind})
|
||||
nodes.append(f'(if (i32.eq (local.get $pc) (i32.const {indices[pc]})) (then\n'+ '\n'.join(emitted)+'\n))')
|
||||
reachable, pending = set(), [entry]
|
||||
while pending:
|
||||
pc = pending.pop()
|
||||
if pc not in reachable:
|
||||
reachable.add(pc)
|
||||
pending.extend(successors[pc])
|
||||
if reachable != set(sites):
|
||||
raise UnsupportedInstruction('Unreachable declared code requires explicit classification')
|
||||
if not any(kind=='ret' for kind in kinds.values()):
|
||||
raise UnsupportedInstruction('Program has no RET')
|
||||
# Conservative flag analysis includes every possible declared return edge.
|
||||
predecessors = {pc:[] for pc in sites}
|
||||
for pc, edges in successors.items():
|
||||
for target in edges: predecessors[target].append(pc)
|
||||
incoming = {pc: pc!=entry for pc in sites}
|
||||
outgoing = dict(incoming)
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for pc in addresses:
|
||||
value = pc!=entry and all(outgoing[p] for p in predecessors[pc])
|
||||
after = True if kinds[pc]=='arithmetic-flags' else False if kinds[pc]=='bl-host' else value
|
||||
if (incoming[pc],outgoing[pc])!=(value,after):
|
||||
incoming[pc],outgoing[pc],changed = value,after,True
|
||||
for pc,word in sites.items():
|
||||
condition = word & 15 if word & 0xff000010 == 0x54000000 else \
|
||||
(word>>12)&15 if kinds[pc]=='select-flags' else 14
|
||||
if condition<14 and not incoming[pc]:
|
||||
raise UnsupportedInstruction(f'Conditional operation reads undefined NZCV at {pc:#x}')
|
||||
# Definitely-zero facts are intersected at every merge. Calls clear all
|
||||
# facts (the ABI preserves no complete 128-bit vector register). An
|
||||
# adjacent copy load also overwrites its destination vector register.
|
||||
zero_in = {pc:0 if pc==entry else 0xffffffff for pc in sites}
|
||||
zero_out = dict(zero_in)
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for pc in addresses:
|
||||
before = 0 if pc==entry else 0xffffffff
|
||||
for parent in predecessors[pc]: before &= zero_out[parent]
|
||||
after = before
|
||||
if kinds[pc] in ('bl-host','bl-guest'): after = 0
|
||||
elif kinds[pc]=='zero128-immediate': after |= 1 << (sites[pc]&31)
|
||||
elif kinds[pc]=='copy128-load': after &= ~(1 << (sites[pc]&31))
|
||||
if (zero_in[pc],zero_out[pc])!=(before,after):
|
||||
zero_in[pc],zero_out[pc],changed = before,after,True
|
||||
for pc, registers in self.zero_stores.items():
|
||||
if any(not zero_in[pc] & (1 << register) for register in registers):
|
||||
raise UnsupportedInstruction(f'Vector store reads undefined/nonzero Q register at {pc:#x}')
|
||||
registers = [f'x{i}' for i in range(31)]+['sp']
|
||||
declarations = '\n'.join(f'(local ${name} i64)' for name in registers)
|
||||
loads = '\n'.join(f'(local.set ${name} (i64.load offset={i*8} (local.get $ctx)))' for i,name in enumerate(registers))
|
||||
stores = '\n'.join(f'(i64.store offset={i*8} (local.get $ctx) (local.get ${name}))' for i,name in enumerate(registers))
|
||||
returning_names = {self.hosts[target] for target in self.returning_hosts}
|
||||
imports = '\n'.join(f'(import "env" "{name}" (func ${name} (param {"i32" if name in returning_names else "i64 i64 i64"}) (result i32)))'
|
||||
for name in sorted(set(self.hosts.values())))
|
||||
# The external return sentinel must not alias any declared instruction.
|
||||
sentinel_checks = '\n'.join(f'(if (i64.eq (local.get $exit_lr) {const(pc)}) (then (return (i32.const 2))))'
|
||||
for pc in addresses)
|
||||
return f'''(module
|
||||
{imports}
|
||||
(memory (export "memory") {self.memory_pages} {self.memory_pages})
|
||||
(func $addr (param $value i64) (param $size i64) (result i32)
|
||||
(if (i64.gt_u (local.get $value) (i64.sub (i64.const {self.memory_pages*65536}) (local.get $size))) (then unreachable))
|
||||
(i32.wrap_i64 (local.get $value)))
|
||||
(func (export "run") (param $ctx i32) (param $budget i32) (result i32)
|
||||
{declarations}
|
||||
(local $base i64) (local $status i32) (local $pc i32) (local $exit_lr i64)
|
||||
(local $a i64) (local $b i64) (local $result i64) (local $qlo i64) (local $qhi i64)
|
||||
(local $n i32) (local $z i32) (local $c i32) (local $v i32)
|
||||
(if (i32.gt_u (local.get $ctx) (i32.const {self.memory_pages*65536-256})) (then (return (i32.const 2))))
|
||||
{loads}
|
||||
(local.set $exit_lr (local.get $x30))
|
||||
{sentinel_checks}
|
||||
(local.set $pc (i32.const {indices[entry]}))
|
||||
(block $done (loop $dispatch
|
||||
(if (i32.eqz (local.get $budget)) (then (local.set $status (i32.const 3)) (br $done)))
|
||||
(local.set $budget (i32.sub (local.get $budget) (i32.const 1)))
|
||||
{chr(10).join(nodes)}
|
||||
unreachable
|
||||
))
|
||||
{stores}
|
||||
(local.get $status)))
|
||||
'''
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lift original MapDeserialiser initialization and its actual C1 target dependency."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from a64_program import ProgramCompiler
|
||||
from lift_rng import arm64_image, file_range
|
||||
from build_target_deserialiser import RANGES as TARGET_RANGES, HOSTS as TARGET_HOSTS
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
ENTRY=0x1014DEF40
|
||||
RANGES=[(ENTRY,1272,'afef8906673fbfdab8f47b5663112ce9c4666a757bd1c80ab7e670e121f383ea'),
|
||||
(0x1012C1044,116,'c607983877925865ddb27bca5841f45a88f90896250f36864deac97462687d08'),
|
||||
*TARGET_RANGES[1:]]
|
||||
HOSTS={**TARGET_HOSTS,0x10035E264:'read_or_throw',0x1027D3918:'zero_memory',
|
||||
0x1000EA874:'version_error',0x101D5C248:'unsupported',0x1000EABE4:'unsupported',
|
||||
0x102226250:'unsupported',0x102226290:'unsupported',0x101B89938:'unsupported'}
|
||||
STATIC_PAGES={0x102FC2000:65536,0x102D55000:69632,0x1014DF000:73728}
|
||||
|
||||
def run(*args): subprocess.run(list(map(str,args)),cwd=ROOT,check=True)
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('binary',type=Path)
|
||||
args=parser.parse_args();image,_=arm64_image(args.binary.read_bytes())
|
||||
source_hash=hashlib.sha256(image).hexdigest()
|
||||
if source_hash!='2cb4de127b50d54c6c5666acfef2263fae16d11a3361ad39353ecf8a66a4d9c7':
|
||||
raise ValueError('Unrecognized ARM64 image')
|
||||
bodies={}
|
||||
for address,size,expected in RANGES:
|
||||
offset=file_range(image,address,size);data=image[offset:offset+size]
|
||||
if hashlib.sha256(data).hexdigest()!=expected: raise ValueError('Unrecognized constructor input')
|
||||
bodies[address]=data
|
||||
functions={address:bodies[address] for address,_,_ in RANGES[:-1]}
|
||||
compiler=ProgramCompiler(HOSTS,returning_hosts={0x1027D36E4},memory_pages=16,static_pages=STATIC_PAGES)
|
||||
wat=compiler.compile(functions,ENTRY).replace('(import "env"','(import "deser_host"')
|
||||
out=ROOT/'build/deserialiser';out.mkdir(parents=True,exist_ok=True)
|
||||
(out/'factorio_deser.wat').write_text(wat)
|
||||
run(ROOT/'build/wabt/wat2wasm',out/'factorio_deser.wat','-o',out/'factorio_deser.wasm')
|
||||
run(ROOT/'build/wabt/wasm2c',out/'factorio_deser.wasm','-o',out/'factorio_deser.c','--module-name=factorio_deser')
|
||||
assembly=['.text','.p2align 2','.globl _factorio_deser_original']
|
||||
for address,data in functions.items():
|
||||
if address==ENTRY: assembly.append('_factorio_deser_original:')
|
||||
for i,(word,) in enumerate(struct.iter_unpack('<I',data)):
|
||||
pc=address+i*4;assembly.append(f'Ldeser_{pc:x}:')
|
||||
if word & 0xfc000000 == 0x94000000:
|
||||
target=pc+((word&0x3ffffff)-(0x4000000 if word&0x2000000 else 0))*4
|
||||
assembly.append('bl '+(f'Ldeser_{target:x}' if target in functions else '_native_deser_'+HOSTS[target]))
|
||||
elif pc==0x10244A50C: assembly.append('adrp x9, _native_deser_vtable@PAGE')
|
||||
elif pc==0x10244A510: assembly.append('add x9, x9, _native_deser_vtable@PAGEOFF')
|
||||
else: assembly.append(f'.long 0x{word:08x}')
|
||||
assembly += ['.section __DATA,__const','.p2align 3','.globl _native_deser_vtable',
|
||||
'_native_deser_vtable:','.quad 0,0,0,0']
|
||||
(out/'oracle.S').write_text('\n'.join(assembly)+'\n')
|
||||
runtime=ROOT/'third_party/wabt/wasm2c'
|
||||
includes=['-I'+str(p) for p in (out,ROOT/'runtime',runtime)]
|
||||
flags=['-O2','-DWASM_RT_USE_MMAP=0','-DWASM_RT_MEMCHECK_BOUNDS_CHECK=1']
|
||||
sources=[out/'factorio_deser.c',ROOT/'runtime/deserialiser_bridge.c',ROOT/'runtime/guest_heap.c']
|
||||
run('xcrun','clang',*flags,*includes,*sources,runtime/'wasm-rt-impl.c',runtime/'wasm-rt-mem-impl.c',
|
||||
out/'oracle.S',ROOT/'tests/deserialiser_equivalence.c','-o',out/'verify_deserialiser')
|
||||
run(out/'verify_deserialiser')
|
||||
sdk=subprocess.check_output(['xcrun','--sdk','iphoneos','--show-sdk-path'],text=True).strip()
|
||||
objects=[]
|
||||
for source in sources:
|
||||
obj=out/(source.stem+'.ios.o')
|
||||
run('xcrun','--sdk','iphoneos','clang','-target','arm64-apple-ios15.0','-isysroot',sdk,
|
||||
*flags,*includes,'-c',source,'-o',obj);objects.append(obj)
|
||||
run('xcrun','libtool','-static','-o',out/'libfactorio_deser_ios.a',*objects)
|
||||
manifest={'arm64_sha256':source_hash,'ranges':[{'address':hex(a),'bytes':n,'sha256':h} for a,n,h in RANGES],
|
||||
'instructions':compiler.instructions,'calls':compiler.calls,'memory_bytes':1048576,
|
||||
'scope':'original constructor initialization and version decisions; host reads/allocation/bzero; no world loading or unwinding',
|
||||
'exceptions':'abort status 7 at cxa_allocate_exception; original exception objects are not constructed',
|
||||
'static_pages':{hex(a):b for a,b in STATIC_PAGES.items()}}
|
||||
(out/'manifest.json').write_text(json.dumps(manifest,indent=2)+'\n')
|
||||
(out/'verification.json').write_text(json.dumps({'status':'pass','native_object_graph_comparisons':512,
|
||||
'normal_boundary_cases':3,'truncated_headers':9,'version_errors':8,'allocation_failures':12,
|
||||
'instructions':len(compiler.instructions),'owned_target_allocations':12,'owned_target_heap_bytes':10712,
|
||||
'scope':'constructor initialization and explicit abort paths; not world loading or C++ unwinding'},indent=2)+'\n')
|
||||
print('PASS: original MapDeserialiser initialization graph, desktop comparisons and iOS library')
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Byte-guarded automatic CFG lift of Factorio's original MapHeader constructor."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from a64_program import ProgramCompiler
|
||||
from lift_rng import arm64_image, file_range
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ADDRESS, SIZE = 0x1014C6AAC, 332
|
||||
EXPECTED = '4f940fe2feb23b8301d080d3678634fa33891d4d53de87c844d70c1319282ff6'
|
||||
READ = 0x10035E264
|
||||
ENTRY, ENTRY_SIZE = 0x10010582C, 4
|
||||
ENTRY_HASH = 'cb0ac2c0409e9c0c1a8de8acf7ce68abc43ca0ab4fb20c2a97c27daab156df81'
|
||||
|
||||
|
||||
def run(*args):
|
||||
subprocess.run(list(map(str, args)), cwd=ROOT, check=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('binary', type=Path)
|
||||
args = parser.parse_args()
|
||||
image, slice_offset = arm64_image(args.binary.read_bytes())
|
||||
offset = file_range(image, ADDRESS, SIZE)
|
||||
body = image[offset:offset+SIZE]
|
||||
if hashlib.sha256(body).hexdigest() != EXPECTED:
|
||||
raise ValueError('Unrecognized MapHeader constructor bytes')
|
||||
entry_offset = file_range(image, ENTRY, ENTRY_SIZE)
|
||||
entry_body = image[entry_offset:entry_offset+ENTRY_SIZE]
|
||||
if hashlib.sha256(entry_body).hexdigest() != ENTRY_HASH:
|
||||
raise ValueError('Unrecognized MapHeader public constructor entry')
|
||||
compiler = ProgramCompiler({READ: 'read_or_throw'})
|
||||
wat = compiler.compile({ENTRY:entry_body,ADDRESS:body}, ENTRY).replace('(import "env"', '(import "map_host"')
|
||||
out = ROOT / 'build/map-header'
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
(out / 'factorio_map_header.wat').write_text(wat)
|
||||
manifest = {'symbol': 'MapHeader::MapHeader(MapDeserialiser&)', 'address': hex(ADDRESS),
|
||||
'bytes': SIZE, 'sha256': EXPECTED, 'source_file_offset': slice_offset+offset,
|
||||
'instructions': compiler.instructions, 'host_calls': compiler.calls,
|
||||
'rea_bytes_evidence': 'ev_edc9090e494bf4c92f5c0e5d9f4366272062c6d2d3d758442448ab720bf4e422',
|
||||
'public_entry': {'address':hex(ENTRY),'bytes':ENTRY_SIZE,'sha256':ENTRY_HASH,
|
||||
'rea_bytes_evidence':'ev_525aeb761646ca31af2fbacfc51ec786e6036b6259fd09505063144d0a5feba1'},
|
||||
'scope': 'version-dependent header constructor; declared memory-stream adapter; no C++ unwinding'}
|
||||
(out / 'manifest.json').write_text(json.dumps(manifest, indent=2)+'\n')
|
||||
assembly = ['.text', '.p2align 2', '.globl _factorio_map_header_original', '_factorio_map_header_original:',
|
||||
'b Lmap_header_c2', 'Lmap_header_c2:'] # Relocate the C1 tail B to the copied C2 body.
|
||||
for (word,) in struct.iter_unpack('<I', body):
|
||||
assembly.append('bl _native_map_header_read' if word & 0xfc000000 == 0x94000000 else f'.long 0x{word:08x}')
|
||||
(out / 'oracle.S').write_text('\n'.join(assembly)+'\n')
|
||||
run(ROOT / 'build/wabt/wat2wasm', out / 'factorio_map_header.wat', '-o', out / 'factorio_map_header.wasm')
|
||||
run(ROOT / 'build/wabt/wasm2c', out / 'factorio_map_header.wasm', '-o', out / 'factorio_map_header.c', '--module-name=factorio_map_header')
|
||||
runtime = ROOT / 'third_party/wabt/wasm2c'
|
||||
includes = ['-I'+str(p) for p in (out, ROOT/'runtime', runtime)]
|
||||
flags = ['-O2', '-DWASM_RT_USE_MMAP=0', '-DWASM_RT_MEMCHECK_BOUNDS_CHECK=1']
|
||||
sources = [out/'factorio_map_header.c', ROOT/'runtime/map_header_bridge.c']
|
||||
run('xcrun', 'clang', *flags, *includes, *sources, runtime/'wasm-rt-impl.c', runtime/'wasm-rt-mem-impl.c',
|
||||
out/'oracle.S', ROOT/'tests/map_header_equivalence.c', '-o', out/'verify_map_header')
|
||||
run(out/'verify_map_header')
|
||||
sdk = subprocess.check_output(['xcrun', '--sdk', 'iphoneos', '--show-sdk-path'], text=True).strip()
|
||||
objects = []
|
||||
for source in sources:
|
||||
obj = out/(source.stem+'.ios.o')
|
||||
run('xcrun', '--sdk', 'iphoneos', 'clang', '-target', 'arm64-apple-ios15.0', '-isysroot', sdk,
|
||||
*flags, *includes, '-c', source, '-o', obj)
|
||||
objects.append(obj)
|
||||
run('xcrun', 'libtool', '-static', '-o', out/'libfactorio_map_header_ios.a', *objects)
|
||||
(out/'verification.json').write_text(json.dumps({'status':'pass', 'native_comparisons':20000,
|
||||
'instructions':len(compiler.instructions), 'host_call_sites':len(compiler.calls),
|
||||
'whole_map_loading':False, 'scope':'controlled payload at constructor stream position'}, indent=2)+'\n')
|
||||
print('PASS: original MapHeader CFG lift, native oracle comparisons, and iOS static library')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build, sign and optionally install the native iOS host for the retargeted Factorio binary.
|
||||
|
||||
Usage:
|
||||
python3 tools/build_native.py [--install] [--push-data] [--launch] [options]
|
||||
|
||||
--install install build/native/Factorio.app on the connected device
|
||||
--push-data copy the installed game's data/ folder into the app's Documents (once, ~1.5 GB)
|
||||
--launch launch with the console attached; output is also saved to build/native/console.log
|
||||
--no-build skip building (install/launch the existing bundle)
|
||||
|
||||
Options (each also readable from the environment variable shown):
|
||||
--game PATH factorio.app/Contents of a local install (FX_GAME; default: Steam location)
|
||||
--team ID Apple developer team ID (FX_TEAM; default: from your provisioning profiles)
|
||||
--bundle-id ID app bundle identifier (FX_BUNDLE_ID; default: local.factorio.native)
|
||||
--device ID devicectl device identifier or name (FX_DEVICE; default: first paired device)
|
||||
|
||||
The game executable is the unmodified ARM64 slice with only Mach-O header changes (see
|
||||
tools/retarget.py). Shims supply the macOS APIs iOS lacks. No game data is put in the bundle.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import plistlib
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
STEAM_GAME = Path.home() / "Library/Application Support/Steam/steamapps/common/Factorio/factorio.app/Contents"
|
||||
OUT = ROOT / "build/native"
|
||||
APP = OUT / "Factorio.app"
|
||||
SRC = ROOT / "platform/native"
|
||||
MIN_IOS = "17.0"
|
||||
# Set from command-line options / environment in main().
|
||||
GAME = STEAM_GAME
|
||||
BUNDLE_ID = "local.factorio.native"
|
||||
TEAM = None
|
||||
|
||||
SLICE = ROOT / "build/factorio.arm64"
|
||||
WUFFS = ROOT / "third_party/wuffs/wuffs-v0.3.c"
|
||||
WUFFS_URL = "https://raw.githubusercontent.com/google/wuffs/v0.3.4/release/c/wuffs-v0.3.c"
|
||||
WUFFS_SHA256 = "a3db4bd979663423de00309d1ba07d7fa8576845223d3e02764181bd6da23f90"
|
||||
|
||||
SDK = subprocess.check_output(["xcrun", "--sdk", "iphoneos", "--show-sdk-path"], text=True).strip()
|
||||
CC = ["xcrun", "--sdk", "iphoneos", "clang", "-target", f"arm64-apple-ios{MIN_IOS}", "-isysroot", SDK,
|
||||
"-O2", "-g", "-Wall", "-Wno-unused-function", "-Wno-objc-property-no-attribute",
|
||||
f"-I{SRC / 'shim'}"]
|
||||
|
||||
# shim name -> (sources, defines, re-exported iOS framework or None, links fx_AppKit)
|
||||
SHIMS = {
|
||||
"fx_CoreGraphics": (["coregraphics.c"], [], "CoreGraphics", True),
|
||||
"fx_CoreVideo": (["small.c"], ["FX_LIB_COREVIDEO"], "CoreVideo", True),
|
||||
"fx_CoreServices": (["small.c"], ["FX_LIB_CORESERVICES"], "CoreServices", True),
|
||||
"fx_Security": (["small.c"], ["FX_LIB_SECURITY"], "Security", True),
|
||||
"fx_IOKit": (["small.c"], ["FX_LIB_IOKIT"], "IOKit", True),
|
||||
"fx_Foundation": (["frameworks.m"], ["FX_LIB_FOUNDATION"], "Foundation", True),
|
||||
"fx_Metal": (["frameworks.m"], ["FX_LIB_METAL"], "Metal", True),
|
||||
"fx_CoreAudio": (["audio.c"], ["FX_LIB_COREAUDIO"], "CoreAudio", True),
|
||||
"fx_AudioToolbox": (["audio.c"], ["FX_LIB_AUDIOTOOLBOX"], "AudioToolbox", True),
|
||||
"fx_Carbon": (["small.c"], ["FX_LIB_CARBON"], None, True),
|
||||
"fx_OpenCL": (["small.c"], ["FX_LIB_OPENCL"], None, True),
|
||||
"fx_ForceFeedback": (["small.c"], ["FX_LIB_FORCEFEEDBACK"], None, True),
|
||||
"fx_LDAP": (["small.c"], ["FX_LIB_LDAP"], None, True),
|
||||
"fx_ApplicationServices": (["small.c"], ["FX_LIB_APPLICATIONSERVICES"], None, True),
|
||||
"fx_Cocoa": (["small.c"], ["FX_LIB_EMPTY"], None, False),
|
||||
"fx_OpenGL": (["small.c"], ["FX_LIB_EMPTY"], None, False),
|
||||
"libsteam_api": (["small.c"], ["FX_LIB_STEAM"], None, True),
|
||||
}
|
||||
|
||||
|
||||
def run(cmd, **kw):
|
||||
print("+", " ".join(str(c) for c in cmd), flush=True)
|
||||
return subprocess.run([str(c) for c in cmd], check=True, **kw)
|
||||
|
||||
|
||||
def dylib(name, sources, defines=(), reexport=None, link_appkit=True, extra=()):
|
||||
out = APP / "Frameworks" / f"{name}.dylib"
|
||||
cmd = CC + ["-dynamiclib", "-o", out, "-install_name", f"@rpath/{name}.dylib",
|
||||
"-compatibility_version", "1000", "-current_version", "1000",
|
||||
"-Wl,-rpath,@loader_path", *[f"-D{d}" for d in defines],
|
||||
*[SRC / "shim" / s for s in sources], "-framework", "CoreFoundation", *extra]
|
||||
if any(s.endswith(".m") for s in sources):
|
||||
cmd += ["-fobjc-arc", "-framework", "Foundation"]
|
||||
if reexport:
|
||||
cmd += [f"-Wl,-reexport_framework,{reexport}"]
|
||||
if link_appkit:
|
||||
cmd += [APP / "Frameworks/fx_AppKit.dylib"]
|
||||
run(cmd)
|
||||
|
||||
|
||||
def build_icon():
|
||||
"""Compile an asset catalog with the app icon, made from the game's own icon; returns plist keys."""
|
||||
work = OUT / "icon"
|
||||
iconset = work / "Assets.xcassets/AppIcon.appiconset"
|
||||
iconset.mkdir(parents=True, exist_ok=True)
|
||||
tool = work / "make_icon"
|
||||
run(["xcrun", "clang", "-O2", "-fobjc-arc", ROOT / "tools/make_icon.m", "-framework", "Foundation",
|
||||
"-framework", "CoreGraphics", "-framework", "ImageIO", "-o", tool])
|
||||
run([tool, GAME / "data/core/graphics/[email protected]", iconset / "AppIcon-1024.png"])
|
||||
(iconset / "Contents.json").write_text(json.dumps({"images": [{"filename": "AppIcon-1024.png", "idiom": "universal",
|
||||
"platform": "ios", "size": "1024x1024"}], "info": {"author": "xcode", "version": 1}}))
|
||||
(work / "Assets.xcassets/Contents.json").write_text(json.dumps({"info": {"author": "xcode", "version": 1}}))
|
||||
partial = work / "partial-info.plist"
|
||||
run(["xcrun", "actool", work / "Assets.xcassets", "--compile", APP, "--platform", "iphoneos",
|
||||
"--minimum-deployment-target", MIN_IOS, "--app-icon", "AppIcon", "--target-device", "iphone",
|
||||
"--target-device", "ipad", "--output-partial-info-plist", partial], stdout=subprocess.DEVNULL)
|
||||
with open(partial, "rb") as f:
|
||||
return plistlib.load(f)
|
||||
|
||||
|
||||
def prepare_inputs():
|
||||
"""Extract the ARM64 slice from the local install and fetch the pinned Wuffs source."""
|
||||
if not SLICE.exists():
|
||||
binary = GAME / "MacOS/factorio"
|
||||
if not binary.exists():
|
||||
raise SystemExit(f"Factorio executable not found at {binary}; pass --game PATH/factorio.app/Contents")
|
||||
SLICE.parent.mkdir(parents=True, exist_ok=True)
|
||||
run(["lipo", "-thin", "arm64", binary, "-output", SLICE])
|
||||
if not WUFFS.exists() or hashlib.sha256(WUFFS.read_bytes()).hexdigest() != WUFFS_SHA256:
|
||||
WUFFS.parent.mkdir(parents=True, exist_ok=True)
|
||||
print("+ fetch", WUFFS_URL, flush=True)
|
||||
data = urllib.request.urlopen(WUFFS_URL, timeout=120).read()
|
||||
if hashlib.sha256(data).hexdigest() != WUFFS_SHA256:
|
||||
raise SystemExit("downloaded Wuffs source does not match the pinned SHA-256")
|
||||
WUFFS.write_bytes(data)
|
||||
|
||||
|
||||
def build():
|
||||
prepare_inputs()
|
||||
run([sys.executable, ROOT / "tools/retarget.py", SLICE, OUT / "Factorio.dylib"])
|
||||
(APP / "Frameworks").mkdir(parents=True, exist_ok=True)
|
||||
dylib("fx_AppKit", ["appkit.m", "boot.m", "crash.c", "keyboard.m", "metal_compat.m", "pngdecode.c", "stack.s"], link_appkit=False,
|
||||
extra=["-fobjc-arc", "-framework", "Foundation", "-framework", "UIKit", "-framework", "QuartzCore",
|
||||
"-framework", "CoreGraphics", "-framework", "Metal", "-framework", "ImageIO", "-framework", "AVFAudio", "-framework", "Accelerate",
|
||||
# UIKitCore already has a private NSColor; export ours under the AppKit symbol name.
|
||||
"-Wl,-alias,_OBJC_CLASS_$_FXNSColor,_OBJC_CLASS_$_NSColor"])
|
||||
for name, (sources, defines, reexport, link_appkit) in SHIMS.items():
|
||||
extra = ["-framework", "Metal"] if name == "fx_Metal" else []
|
||||
dylib(name, sources, defines, reexport, link_appkit, extra)
|
||||
run(["cp", OUT / "Factorio.dylib", APP / "Frameworks/Factorio.dylib"])
|
||||
run(CC + ["-o", APP / "FactorioNative", SRC / "host/main.c", APP / "Frameworks/fx_AppKit.dylib",
|
||||
"-Wl,-rpath,@executable_path/Frameworks"])
|
||||
info = {
|
||||
"CFBundleExecutable": "FactorioNative", "CFBundleIdentifier": BUNDLE_ID, "CFBundleName": "Factorio",
|
||||
"CFBundleDisplayName": "Factorio", "CFBundlePackageType": "APPL", "CFBundleVersion": "1",
|
||||
"CFBundleShortVersionString": "2.0.77", "CFBundleInfoDictionaryVersion": "6.0",
|
||||
"CFBundleSupportedPlatforms": ["iPhoneOS"], "MinimumOSVersion": MIN_IOS, "UIDeviceFamily": [1, 2],
|
||||
"DTPlatformName": "iphoneos", "UIRequiresFullScreen": True, "UIStatusBarHidden": True,
|
||||
"UISupportedInterfaceOrientations": ["UIInterfaceOrientationLandscapeLeft", "UIInterfaceOrientationLandscapeRight"],
|
||||
"UILaunchScreen": {"UIColorName": ""}, "UIFileSharingEnabled": True,
|
||||
"LSSupportsOpeningDocumentsInPlace": True, "GCSupportsControllerUserInteraction": True,
|
||||
}
|
||||
info.update(build_icon())
|
||||
with open(APP / "Info.plist", "wb") as f:
|
||||
plistlib.dump(info, f, fmt=plistlib.FMT_BINARY)
|
||||
symbols = OUT / "dSYM"
|
||||
run(["rm", "-rf", symbols])
|
||||
symbols.mkdir()
|
||||
for d in list(APP.rglob("*.dSYM")):
|
||||
d.rename(symbols / d.name)
|
||||
sign()
|
||||
|
||||
|
||||
def find_profile():
|
||||
"""A development profile covering BUNDLE_ID (explicit or wildcard), optionally for TEAM."""
|
||||
folder = Path.home() / "Library/Developer/Xcode/UserData/Provisioning Profiles"
|
||||
candidates = []
|
||||
for path in sorted(folder.glob("*.mobileprovision")):
|
||||
plist = plistlib.loads(subprocess.check_output(["security", "cms", "-D", "-i", str(path)]))
|
||||
team = plist["TeamIdentifier"][0]
|
||||
app_id = plist["Entitlements"]["application-identifier"]
|
||||
if TEAM and team != TEAM:
|
||||
continue
|
||||
if app_id == f"{team}.{BUNDLE_ID}":
|
||||
candidates.insert(0, (path, plist, team)) # explicit App ID wins over wildcard
|
||||
elif app_id == f"{team}.*":
|
||||
candidates.append((path, plist, team))
|
||||
if not candidates:
|
||||
raise SystemExit(f"no provisioning profile covers {BUNDLE_ID}" + (f" for team {TEAM}" if TEAM else "") +
|
||||
". Create one in Xcode (any project, automatic signing, same team) or pass --team.")
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def signing_identity(profile):
|
||||
"""The keychain identity whose certificate is embedded in the profile."""
|
||||
allowed = {hashlib.sha1(cert).hexdigest().upper() for cert in profile.get("DeveloperCertificates", [])}
|
||||
listing = subprocess.check_output(["security", "find-identity", "-v", "-p", "codesigning"], text=True)
|
||||
for line in listing.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) > 2 and parts[1] in allowed:
|
||||
return parts[1]
|
||||
raise SystemExit("no signing identity in the keychain matches the provisioning profile")
|
||||
|
||||
|
||||
def sign():
|
||||
profile, plist, team = find_profile()
|
||||
run(["cp", profile, APP / "embedded.mobileprovision"])
|
||||
identity = signing_identity(plist)
|
||||
entitlements = {"application-identifier": f"{team}.{BUNDLE_ID}", "com.apple.developer.team-identifier": team,
|
||||
"get-task-allow": True}
|
||||
if plist["Entitlements"].get("com.apple.developer.kernel.increased-memory-limit"):
|
||||
entitlements["com.apple.developer.kernel.increased-memory-limit"] = True
|
||||
ent = OUT / "entitlements.plist"
|
||||
with open(ent, "wb") as f:
|
||||
plistlib.dump(entitlements, f)
|
||||
for lib in sorted((APP / "Frameworks").glob("*.dylib")):
|
||||
run(["codesign", "-f", "-s", identity, "--timestamp=none", lib], stdout=subprocess.DEVNULL)
|
||||
run(["codesign", "-f", "-s", identity, "--timestamp=none", "--entitlements", ent, APP])
|
||||
print(json.dumps({"app": str(APP), "profile": plist["Name"], "entitlements": entitlements}, indent=2))
|
||||
|
||||
|
||||
def device(requested=None):
|
||||
if requested:
|
||||
return requested
|
||||
listing = OUT / "devices.json"
|
||||
subprocess.run(["xcrun", "devicectl", "list", "devices", "--json-output", listing],
|
||||
check=True, stdout=subprocess.DEVNULL)
|
||||
data = json.loads(listing.read_text())
|
||||
for d in data["result"]["devices"]:
|
||||
if d.get("connectionProperties", {}).get("pairingState") == "paired":
|
||||
return d["identifier"]
|
||||
raise SystemExit("no paired device")
|
||||
|
||||
|
||||
def main():
|
||||
global GAME, BUNDLE_ID, TEAM
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--install", action="store_true")
|
||||
parser.add_argument("--push-data", action="store_true")
|
||||
parser.add_argument("--launch", action="store_true")
|
||||
parser.add_argument("--no-build", action="store_true")
|
||||
parser.add_argument("--game", default=os.environ.get("FX_GAME", str(STEAM_GAME)))
|
||||
parser.add_argument("--team", default=os.environ.get("FX_TEAM"))
|
||||
parser.add_argument("--bundle-id", default=os.environ.get("FX_BUNDLE_ID", BUNDLE_ID))
|
||||
parser.add_argument("--device", default=os.environ.get("FX_DEVICE"))
|
||||
args = parser.parse_args()
|
||||
GAME, BUNDLE_ID, TEAM = Path(args.game), args.bundle_id, args.team
|
||||
if not args.no_build:
|
||||
build()
|
||||
if args.install or args.push_data or args.launch:
|
||||
dev = device(args.device)
|
||||
if args.install:
|
||||
run(["xcrun", "devicectl", "device", "install", "app", "--device", dev, APP])
|
||||
if args.push_data:
|
||||
run(["xcrun", "devicectl", "device", "copy", "to", "--device", dev, "--domain-type", "appDataContainer",
|
||||
"--domain-identifier", BUNDLE_ID, "--source", GAME / "data", "--destination", "Documents/factorio/data"])
|
||||
if args.launch:
|
||||
with open(OUT / "console.log", "w") as log:
|
||||
proc = subprocess.Popen(["xcrun", "devicectl", "device", "process", "launch", "--device", dev,
|
||||
"--terminate-existing", "--console", BUNDLE_ID],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
for line in proc.stdout:
|
||||
sys.stdout.write(line)
|
||||
log.write(line)
|
||||
log.flush()
|
||||
proc.wait()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build and validate the bounded Factorio RNG no-JIT prototype on Apple Silicon."""
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from lift_rng import generate
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def run(*command):
|
||||
print("+", " ".join(map(str, command)), flush=True)
|
||||
subprocess.run(list(map(str, command)), cwd=ROOT, check=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("binary", type=Path)
|
||||
args = parser.parse_args()
|
||||
wabt = ROOT / "third_party/wabt"
|
||||
if not (wabt / "CMakeLists.txt").exists():
|
||||
parser.error("Fetch official WABT tag 1.0.42 into third_party/wabt first")
|
||||
revision = subprocess.check_output(["git", "-C", str(wabt), "rev-parse", "HEAD"], text=True).strip()
|
||||
if revision != "ff0ef7e0009402740c805a9744c09b05be063e48":
|
||||
parser.error("WABT checkout must match the pinned 1.0.42 commit")
|
||||
run("cmake", "-S", wabt, "-B", ROOT / "build/wabt", "-DCMAKE_BUILD_TYPE=Release",
|
||||
"-DBUILD_TESTS=OFF", "-DBUILD_LIBWASM=OFF", "-DUSE_INTERNAL_SHA256=ON",
|
||||
"-DHAVE_OPENSSL_SHA_H=OFF")
|
||||
run("cmake", "--build", ROOT / "build/wabt", "--target", "wat2wasm", "wasm2c", "--parallel", "6")
|
||||
out = ROOT / "build/rng"
|
||||
generate(args.binary.resolve(), out)
|
||||
run(ROOT / "build/wabt/wat2wasm", out / "factorio_rng.wat", "-o", out / "factorio_rng.wasm")
|
||||
run(ROOT / "build/wabt/wasm2c", out / "factorio_rng.wasm", "-o", out / "factorio_rng.c", "--module-name=factorio_rng")
|
||||
sources = [out / "factorio_rng.c", wabt / "wasm2c/wasm-rt-impl.c", wabt / "wasm2c/wasm-rt-mem-impl.c"]
|
||||
includes = ["-I" + str(out), "-I" + str(wabt / "wasm2c")]
|
||||
flags = ["-O2", "-DWASM_RT_USE_MMAP=0", "-DWASM_RT_MEMCHECK_BOUNDS_CHECK=1"]
|
||||
run("xcrun", "clang", *flags, *includes, *sources, out / "oracle.S", ROOT / "tests/rng_equivalence.c",
|
||||
"-o", out / "verify_rng")
|
||||
run(out / "verify_rng")
|
||||
# This archive contains the translated function and wasm2c runtime only.
|
||||
# The native oracle is a desktop verification input, never iOS runtime code.
|
||||
objects = []
|
||||
sdk = subprocess.check_output(["xcrun", "--sdk", "iphoneos", "--show-sdk-path"], text=True).strip()
|
||||
for source in sources:
|
||||
obj = out / (source.stem + ".ios.o")
|
||||
run("xcrun", "--sdk", "iphoneos", "clang", "-target", "arm64-apple-ios15.0", "-isysroot", sdk,
|
||||
*flags, *includes, "-c", source, "-o", obj)
|
||||
objects.append(obj)
|
||||
run("xcrun", "libtool", "-static", "-o", out / "libfactorio_rng_ios.a", *objects)
|
||||
undefined = subprocess.check_output(["xcrun", "nm", "-u", str(out / "libfactorio_rng_ios.a")], text=True)
|
||||
if "steam" in undefined.lower():
|
||||
raise RuntimeError("Steam import found in the single-player port library")
|
||||
report = {"status": "pass", "native_comparisons": 876544, "constructor_comparisons": 1024, "starting_states": 6848,
|
||||
"steps_per_state": 128, "ios_artifact": "libfactorio_rng_ios.a",
|
||||
"ios_device_execution": "not run", "full_game": "not implemented",
|
||||
"wabt_commit": revision, "runtime_jit": False,
|
||||
"steam_import_audit": "pass: no Steam undefined symbols"}
|
||||
(out / "verification.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the bounded 2.0.77 envelope adapter and real map-header probe."""
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
OUT=ROOT/'build/save-layout'
|
||||
OUT.mkdir(parents=True,exist_ok=True)
|
||||
RUNTIME=ROOT/'third_party/wabt/wasm2c'
|
||||
includes=['-I'+str(p) for p in (ROOT/'runtime',ROOT/'build/version',ROOT/'build/map-header',RUNTIME)]
|
||||
flags=['-O2','-DWASM_RT_USE_MMAP=0','-DWASM_RT_MEMCHECK_BOUNDS_CHECK=1']
|
||||
sources=[ROOT/'runtime/save_layout.c',ROOT/'runtime/save_header.c',ROOT/'runtime/version_bridge.c',
|
||||
ROOT/'runtime/map_header_bridge.c',ROOT/'build/version/factorio_version.c',
|
||||
ROOT/'build/map-header/factorio_map_header.c',RUNTIME/'wasm-rt-impl.c',RUNTIME/'wasm-rt-mem-impl.c',
|
||||
ROOT/'tests/save_layout_probe.c']
|
||||
subprocess.run(['xcrun','clang',*flags,*includes,*map(str,sources),'-lz','-o',str(OUT/'probe')],cwd=ROOT,check=True)
|
||||
subprocess.run(['xcrun','clang',*flags,*includes,*map(str,sources[:-1]),
|
||||
str(ROOT/'tests/save_layout_native.c'),str(ROOT/'build/map-header/oracle.S'),
|
||||
'-lz','-o',str(OUT/'verify_native')],cwd=ROOT,check=True)
|
||||
sdk=subprocess.check_output(['xcrun','--sdk','iphoneos','--show-sdk-path'],text=True).strip()
|
||||
obj=OUT/'save_layout.ios.o'
|
||||
subprocess.run(['xcrun','--sdk','iphoneos','clang','-target','arm64-apple-ios15.0','-isysroot',sdk,
|
||||
*flags,*includes,'-c',str(ROOT/'runtime/save_layout.c'),'-o',str(obj)],check=True)
|
||||
subprocess.run(['xcrun','libtool','-static','-o',str(OUT/'libfactorio_save_layout_ios.a'),str(obj)],check=True)
|
||||
print('Built bounded scenario-envelope adapter and linked AOT constructor probe')
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Translate the pinned TargetDeserialiser initialization graph, without unwinding."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from a64_program import ProgramCompiler
|
||||
from lift_rng import arm64_image, file_range
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RANGES = [
|
||||
(0x101AB7C74, 108, '2ada8a5d403d8d30f9bc053f3cad0e74d08103d6c96bcdbf4db44d481ba6d4d0'),
|
||||
(0x10244A4BC, 1260, '0e450e3ad613dded236f6627d78a07894d48b46e845baa40877d6929043b171a'),
|
||||
(0x102FC2D98, 32, 'c991e960829a78707ab67384d3dbf5d296aaa8fc9f33878ae7c1e7571f779dce')]
|
||||
HOSTS = {0x1027D36E4:'allocate', 0x1027D36B4:'unsupported_delete',
|
||||
0x10244A9D4:'unsupported_length', 0x100035550:'unsupported_array_length'}
|
||||
|
||||
def run(*args):
|
||||
subprocess.run(list(map(str,args)),cwd=ROOT,check=True)
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('binary',type=Path)
|
||||
args=parser.parse_args()
|
||||
image,slice_offset=arm64_image(args.binary.read_bytes())
|
||||
slice_hash=hashlib.sha256(image).hexdigest()
|
||||
if slice_hash!='2cb4de127b50d54c6c5666acfef2263fae16d11a3361ad39353ecf8a66a4d9c7':
|
||||
raise ValueError('Unrecognized Factorio ARM64 image')
|
||||
bodies={}
|
||||
for address,size,expected in RANGES:
|
||||
offset=file_range(image,address,size)
|
||||
data=image[offset:offset+size]
|
||||
if hashlib.sha256(data).hexdigest()!=expected:
|
||||
raise ValueError(f'Unrecognized target bytes at {address:#x}')
|
||||
bodies[address]=data
|
||||
compiler=ProgramCompiler(HOSTS,returning_hosts={0x1027D36E4}, memory_pages=16,
|
||||
static_pages={0x102FC2000:65536})
|
||||
functions={address:bodies[address] for address,_,_ in RANGES[:2]}
|
||||
wat=compiler.compile(functions,RANGES[0][0]).replace('(import "env"','(import "target_host"')
|
||||
out=ROOT/'build/target-deserialiser'
|
||||
out.mkdir(parents=True,exist_ok=True)
|
||||
(out/'factorio_target.wat').write_text(wat)
|
||||
run(ROOT/'build/wabt/wat2wasm',out/'factorio_target.wat','-o',out/'factorio_target.wasm')
|
||||
run(ROOT/'build/wabt/wasm2c',out/'factorio_target.wasm','-o',out/'factorio_target.c','--module-name=factorio_target')
|
||||
# Only the desktop oracle executes copied ARM64 instructions. Relocate
|
||||
# direct calls, immediate branches and the one ADRP+ADD vtable reference.
|
||||
assembly=['.text','.p2align 2','.globl _factorio_target_original']
|
||||
labels={address:f'Ltarget_{address:x}' for address in functions}
|
||||
for address,data in functions.items():
|
||||
if address==RANGES[0][0]: assembly.append('_factorio_target_original:')
|
||||
for i,(word,) in enumerate(struct.iter_unpack('<I',data)):
|
||||
pc=address+4*i
|
||||
assembly.append(f'Ltarget_{pc:x}:')
|
||||
if word & 0xfc000000 == 0x94000000:
|
||||
target=pc+((word&0x3ffffff)-(0x4000000 if word&0x2000000 else 0))*4
|
||||
symbol=labels.get(target) or '_native_target_'+HOSTS[target]
|
||||
assembly.append('bl '+symbol)
|
||||
elif pc==0x10244A50C:
|
||||
assembly.append('adrp x9, _native_target_vtable@PAGE')
|
||||
elif pc==0x10244A510:
|
||||
assembly.append('add x9, x9, _native_target_vtable@PAGEOFF')
|
||||
else:
|
||||
assembly.append(f'.long 0x{word:08x}')
|
||||
assembly += ['.section __DATA,__const','.p2align 3','.globl _native_target_vtable',
|
||||
'_native_target_vtable:', '.quad 0, 0, 0, 0']
|
||||
(out/'oracle.S').write_text('\n'.join(assembly)+'\n')
|
||||
runtime=ROOT/'third_party/wabt/wasm2c'
|
||||
includes=['-I'+str(p) for p in (out,ROOT/'runtime',runtime)]
|
||||
flags=['-O2','-DWASM_RT_USE_MMAP=0','-DWASM_RT_MEMCHECK_BOUNDS_CHECK=1']
|
||||
sources=[out/'factorio_target.c',ROOT/'runtime/target_bridge.c',ROOT/'runtime/guest_heap.c']
|
||||
run('xcrun','clang',*flags,*includes,*sources,runtime/'wasm-rt-impl.c',runtime/'wasm-rt-mem-impl.c',
|
||||
out/'oracle.S',ROOT/'tests/target_equivalence.c','-o',out/'verify_target')
|
||||
run(out/'verify_target')
|
||||
sdk=subprocess.check_output(['xcrun','--sdk','iphoneos','--show-sdk-path'],text=True).strip()
|
||||
objects=[]
|
||||
for source in sources:
|
||||
obj=out/(source.stem+'.ios.o')
|
||||
run('xcrun','--sdk','iphoneos','clang','-target','arm64-apple-ios15.0','-isysroot',sdk,
|
||||
*flags,*includes,'-c',source,'-o',obj)
|
||||
objects.append(obj)
|
||||
run('xcrun','libtool','-static','-o',out/'libfactorio_target_ios.a',*objects)
|
||||
manifest={'arm64_sha256':slice_hash,'ranges':[{'address':hex(a),'bytes':s,'sha256':h} for a,s,h in RANGES],
|
||||
'instructions':compiler.instructions,'calls':compiler.calls,'memory_bytes':1048576,
|
||||
'static_page':{'original':'0x102fc2000','guest':65536},
|
||||
'scope':'constructor initialization only; static vtable identity is opaque; no virtual calls, free or C++ unwinding',
|
||||
'rea_assembly_evidence':['ev_1a00dc8f1f23ce6c1e54f8d189d163fbdd012621890e0288643b8f83a8b11611',
|
||||
'ev_b08fa6c3b275200505c3e78c71046526444b5c0ec603ef09d18d5db53dbf0eab'],
|
||||
'rea_exact_byte_records':'validated separately by tools/check_initialization_rea.py; see rea-provenance.json',
|
||||
'excluded_unwind_ranges':['0x101ab7ce0..0x101ab7d0c','0x10244a9a8..0x10244a9d4']}
|
||||
(out/'manifest.json').write_text(json.dumps(manifest,indent=2)+'\n')
|
||||
(out/'verification.json').write_text(json.dumps({'status':'pass','native_object_graph_comparisons':512,
|
||||
'targets_per_graph':64,'allocations_per_graph':4,'heap_bytes_per_graph':2104,
|
||||
'allocation_failure_cases':8,'instructions':len(compiler.instructions),
|
||||
'scope':'normal constructor initialization and allocation aborts; no C++ unwinding'},indent=2)+'\n')
|
||||
print('PASS: original TargetDeserialiser graph, bounded heap, desktop oracle and iOS library')
|
||||
|
||||
if __name__=='__main__': main()
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Statically compile the original MapVersion constructor and its declared I/O adapter."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from a64_lift import Compiler
|
||||
from lift_rng import arm64_image, file_range
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ADDRESS, SIZE = 0x10150BF28, 192
|
||||
EXPECTED = 'b9254262cde89c9e79a9fdf6d700860ff9fd623d53c576efaf431600096c8a9b'
|
||||
READ = 0x10035E264
|
||||
|
||||
|
||||
def run(*args):
|
||||
print('+', ' '.join(map(str, args)), flush=True)
|
||||
subprocess.run(list(map(str, args)), cwd=ROOT, check=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('binary', type=Path)
|
||||
args = parser.parse_args()
|
||||
image, slice_offset = arm64_image(args.binary.read_bytes())
|
||||
offset = file_range(image, ADDRESS, SIZE)
|
||||
body = image[offset:offset + SIZE]
|
||||
if hashlib.sha256(body).hexdigest() != EXPECTED:
|
||||
raise ValueError('Unrecognized MapVersion constructor bytes')
|
||||
compiler = Compiler({READ: 'read_or_throw'})
|
||||
wat = compiler.compile(body, ADDRESS)
|
||||
out = ROOT / 'build/version'
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
(out / 'factorio_version.wat').write_text(wat)
|
||||
manifest = {'symbol': 'MapVersion::MapVersion(Deserialiser&)', 'address': hex(ADDRESS),
|
||||
'source_file_offset': slice_offset + offset, 'bytes': SIZE, 'sha256': EXPECTED,
|
||||
'instructions': compiler.instructions, 'host_calls': compiler.calls,
|
||||
'scope': 'automatic straight-line integer lift; host read errors return status rather than C++ exceptions'}
|
||||
(out / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
||||
assembly = ['.text', '.p2align 2', '.globl _factorio_version_original', '_factorio_version_original:']
|
||||
for (word,) in struct.iter_unpack('<I', body):
|
||||
assembly.append(' bl _native_version_read' if word & 0xfc000000 == 0x94000000 else f' .long 0x{word:08x}')
|
||||
(out / 'oracle.S').write_text('\n'.join(assembly) + '\n')
|
||||
run(ROOT / 'build/wabt/wat2wasm', out / 'factorio_version.wat', '-o', out / 'factorio_version.wasm')
|
||||
run(ROOT / 'build/wabt/wasm2c', out / 'factorio_version.wasm', '-o', out / 'factorio_version.c', '--module-name=factorio_version')
|
||||
includes = ['-I' + str(out), '-I' + str(ROOT / 'runtime'), '-I' + str(ROOT / 'third_party/wabt/wasm2c')]
|
||||
flags = ['-O2', '-DWASM_RT_USE_MMAP=0', '-DWASM_RT_MEMCHECK_BOUNDS_CHECK=1']
|
||||
sources = [out / 'factorio_version.c', ROOT / 'runtime/version_bridge.c', ROOT / 'runtime/save_header.c']
|
||||
runtime = [ROOT / 'third_party/wabt/wasm2c' / x for x in ['wasm-rt-impl.c', 'wasm-rt-mem-impl.c']]
|
||||
run('xcrun', 'clang', *flags, *includes, *sources, *runtime, out / 'oracle.S',
|
||||
ROOT / 'tests/version_equivalence.c', '-lz', '-o', out / 'verify_version')
|
||||
run(out / 'verify_version')
|
||||
run('xcrun', 'clang', *flags, *includes, *sources, *runtime, ROOT / 'tests/save_probe.c',
|
||||
'-lz', '-o', out / 'save_probe')
|
||||
sdk = subprocess.check_output(['xcrun', '--sdk', 'iphoneos', '--show-sdk-path'], text=True).strip()
|
||||
objects = []
|
||||
for source in sources:
|
||||
obj = out / (source.stem + '.ios.o')
|
||||
run('xcrun', '--sdk', 'iphoneos', 'clang', '-target', 'arm64-apple-ios15.0', '-isysroot', sdk,
|
||||
*flags, *includes, '-c', source, '-o', obj)
|
||||
objects.append(obj)
|
||||
run('xcrun', 'libtool', '-static', '-o', out / 'libfactorio_version_ios.a', *objects)
|
||||
(out / 'verification.json').write_text(json.dumps({'status': 'pass', 'native_comparisons': 20000,
|
||||
'instruction_count': len(compiler.instructions), 'host_calls': len(compiler.calls),
|
||||
'scope': 'MapVersion constructor success path; stream errors use a host status adapter',
|
||||
'whole_map_loading': False}, indent=2) + '\n')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check build-5 constructor, actual-save and regression reports from the iPhone."""
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
ROOT=Path(__file__).resolve().parents[1];OUT=ROOT/'build/deserialiser'
|
||||
names=('deserialiser','save-initialization','target','layout','map-header','version','version-probe')
|
||||
reports={name:json.loads((OUT/('iphone-'+name+'-result.json')).read_text()) for name in names}
|
||||
assert all(r['app_build']=='5' and r['app_version']=='0.1.0' for r in reports.values())
|
||||
assert len({r['run_id'] for r in reports.values()})==1 and len({r['build_id'] for r in reports.values()})==1
|
||||
times=[datetime.fromisoformat(r['recorded_at'].replace('Z','+00:00')) for r in reports.values()]
|
||||
assert (max(times)-min(times)).total_seconds()<30
|
||||
constructor=reports['deserialiser'];actual=reports['save-initialization']
|
||||
assert constructor['status']=='pass' and constructor['code']==0 and constructor['newer_version_status']==7
|
||||
assert actual['status']==0 and actual['version']==[2,0,77,0,0]
|
||||
for report in (constructor,actual):
|
||||
for key,value in {'allocations':12,'reads':5,'target_capacity':64,'heap_bytes':10712,
|
||||
'stream_cursor':9,'owns_target':1}.items():assert report[key]==value
|
||||
assert reports['target']['status']=='pass' and reports['target']['initialized_targets']==64
|
||||
assert reports['version']['status']=='pass' and reports['version-probe']['status']==0
|
||||
assert reports['map-header']['status']=='pass' and reports['map-header']['modern_64_bit'] and reports['map-header']['legacy_32_bit_sentinel']
|
||||
desktop=json.loads((ROOT/'build/save-layout/desktop-real-save.json').read_text())
|
||||
for report in (actual,reports['layout'],reports['version-probe']):
|
||||
assert report['status']==0 and report['input_sha256']==desktop['input_sha256']
|
||||
assert len({reports[name]['probe_id'] for name in ('save-initialization','layout','version-probe')})==1
|
||||
for key in ('header_offset','next_offset','mod_count','tick_fields_hex'):
|
||||
assert reports['layout'][key]==desktop['decoded_layout'][key]
|
||||
binary=ROOT/'build/ios-device/Build/Products/Release-iphoneos/FactorioPort.app/FactorioPort'
|
||||
assert constructor['build_id'].encode() in binary.read_bytes()
|
||||
dependencies=subprocess.check_output(['xcrun','otool','-L',str(binary)],text=True)
|
||||
imports=subprocess.check_output(['xcrun','nm','-u',str(binary)],text=True)
|
||||
symbols=subprocess.check_output(['xcrun','nm',str(binary)],text=True)
|
||||
assert 'steam' not in (dependencies+imports).lower()
|
||||
for symbol in ('_factorio_rng_original','_factorio_version_original','_factorio_map_header_original',
|
||||
'_factorio_target_original','_factorio_deser_original','_native_deser_allocate'):
|
||||
assert symbol not in symbols
|
||||
result={'status':'pass','app_build':'5','run_id':constructor['run_id'],'build_id':constructor['build_id'],
|
||||
'device':'iPhone 15 Pro','constructor':constructor,'actual_save_initialization':actual,
|
||||
'same_launch_regressions':'pass','steam_dependency_or_import':False,'desktop_oracle_symbols':False,
|
||||
'signed_binary_sha256':hashlib.sha256(binary.read_bytes()).hexdigest(),
|
||||
'scope':'original constructor initialized from actual-save prefix; no loaded world or gameplay'}
|
||||
(OUT/'device-verification.json').write_text(json.dumps(result,indent=2)+'\n')
|
||||
print('PASS: build 5 MapDeserialiser, actual-save prefix, same-launch regressions and signed binary audit')
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify initialization inputs against complete, source-bound REA byte records."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from lift_rng import arm64_image,file_range
|
||||
from build_target_deserialiser import RANGES as TARGET_RANGES
|
||||
from build_deserialiser import RANGES as DESER_RANGES
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('binary',type=Path);parser.add_argument('bundle',type=Path)
|
||||
args=parser.parse_args();image,_=arm64_image(args.binary.read_bytes())
|
||||
source_hash=hashlib.sha256(image).hexdigest()
|
||||
bundle=json.loads(args.bundle.read_text())
|
||||
for name,ranges in [('target-deserialiser',TARGET_RANGES),('deserialiser',DESER_RANGES)]:
|
||||
checks=[]
|
||||
for address,size,expected in ranges:
|
||||
offset=file_range(image,address,size);data=image[offset:offset+size]
|
||||
assert hashlib.sha256(data).hexdigest()==expected
|
||||
matches=[]
|
||||
for record in bundle['records']:
|
||||
if record.get('operation')!='read_bytes' or record.get('confidence')!='observed' or record.get('authority')!='shipped-artifact':continue
|
||||
if record.get('subject',{}).get('digest',{}).get('sha256')!=source_hash:continue
|
||||
value=record['normalized_result']
|
||||
if value['address']==hex(address) and value['complete'] and value['returned_bytes']==size and value['requested_bytes']==size:
|
||||
if bytes.fromhex(value['bytes_hex'])!=data:raise ValueError('REA bytes contradict the pinned local input')
|
||||
matches.append(record['evidence_id'])
|
||||
if not matches:raise ValueError(f'Missing complete REA byte record at {address:#x} ({size} bytes)')
|
||||
checks.append({'address':hex(address),'bytes':size,'sha256':expected,'evidence_ids':matches})
|
||||
report={'status':'pass','arm64_sha256':source_hash,'checks':checks,
|
||||
'scope':'exact translation input bytes; not whole-engine behavioral equivalence'}
|
||||
(ROOT/'build'/name/'rea-provenance.json').write_text(json.dumps(report,indent=2)+'\n')
|
||||
print(f'PASS: {name}, {len(checks)} pinned input ranges match complete REA observations')
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bind local RNG translation inputs to complete, exported REA byte evidence."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from lift_rng import arm64_image, file_range, FUNCTION_VA, BODY_SIZE, SEED_VA, SEED_SIZE, CONSTANT_VA
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("binary", type=Path)
|
||||
parser.add_argument("bundle", type=Path)
|
||||
parser.add_argument("--include-version", action="store_true")
|
||||
parser.add_argument("--include-map-header", action="store_true")
|
||||
parser.add_argument("--include-map-header-entry", action="store_true")
|
||||
args = parser.parse_args()
|
||||
data = args.binary.read_bytes()
|
||||
image, _ = arm64_image(data)
|
||||
slice_hash = hashlib.sha256(image).hexdigest()
|
||||
bundle = json.loads(args.bundle.read_text())
|
||||
checks = []
|
||||
ranges = [(FUNCTION_VA, BODY_SIZE), (SEED_VA, SEED_SIZE), (CONSTANT_VA, 32)]
|
||||
if args.include_version:
|
||||
ranges.append((0x10150bf28, 192))
|
||||
if args.include_map_header:
|
||||
ranges.append((0x1014c6aac, 332))
|
||||
if args.include_map_header_entry:
|
||||
ranges.append((0x10010582c, 4))
|
||||
for address, length in ranges:
|
||||
matches = []
|
||||
offset = file_range(image, address, length)
|
||||
expected = image[offset:offset + length]
|
||||
for record in bundle["records"]:
|
||||
if record.get("operation") != "read_bytes":
|
||||
continue
|
||||
if record.get("authority") != "shipped-artifact" or record.get("confidence") != "observed":
|
||||
continue
|
||||
if record.get("subject", {}).get("digest", {}).get("sha256") != slice_hash:
|
||||
continue
|
||||
value = record["normalized_result"]
|
||||
if (value["address"] == hex(address) and value["complete"]
|
||||
and value["returned_bytes"] == length and value["requested_bytes"] == length):
|
||||
if bytes.fromhex(value["bytes_hex"]) != expected:
|
||||
raise ValueError("REA byte evidence contradicts the local input")
|
||||
matches.append(record["evidence_id"])
|
||||
if not matches:
|
||||
raise ValueError(f"No complete matching REA byte evidence for {hex(address)}")
|
||||
checks.append({"address": hex(address), "bytes": length, "evidence_ids": matches,
|
||||
"sha256": hashlib.sha256(expected).hexdigest()})
|
||||
report = {"status": "pass", "scope": "translation input bytes, not full behavioral equivalence",
|
||||
"artifact_sha256": hashlib.sha256(data).hexdigest(), "arm64_slice_sha256": slice_hash,
|
||||
"checks": checks}
|
||||
destination = "build/map-header/rea-provenance.json" if args.include_map_header else \
|
||||
"build/version/rea-provenance.json" if args.include_version else "build/rng/rea-provenance.json"
|
||||
Path(destination).write_text(json.dumps(report, indent=2) + "\n")
|
||||
print(json.dumps(report, indent=2))
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate downloaded build-4 reports and audit the signed iOS executable."""
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
OUT=ROOT/'build/target-deserialiser'
|
||||
reports={name:json.loads((OUT/('iphone-'+name+'-result.json')).read_text())
|
||||
for name in ('target','layout','map-header','version')}
|
||||
target=reports['target']
|
||||
assert all(r['app_build']=='4' and r['app_version']=='0.1.0' for r in reports.values())
|
||||
assert len({r['run_id'] for r in reports.values()})==1
|
||||
assert len({r['build_id'] for r in reports.values()})==1
|
||||
times=[datetime.fromisoformat(r['recorded_at'].replace('Z','+00:00')) for r in reports.values()]
|
||||
assert (max(times)-min(times)).total_seconds()<30
|
||||
assert target['status']=='pass' and target['code']==0
|
||||
assert target['allocations']==4 and target['pool_capacity']==64 and target['initialized_targets']==64
|
||||
assert target['heap_bytes']==2104
|
||||
assert reports['version']['status']=='pass'
|
||||
assert reports['map-header']['status']=='pass'
|
||||
assert reports['map-header']['modern_64_bit'] and reports['map-header']['legacy_32_bit_sentinel']
|
||||
layout=reports['layout']
|
||||
desktop=json.loads((ROOT/'build/save-layout/desktop-real-save.json').read_text())
|
||||
assert layout['status']==0 and layout['input_sha256']==desktop['input_sha256']
|
||||
for key in ('header_offset','next_offset','mod_count','tick_fields_hex'):
|
||||
assert layout[key]==desktop['decoded_layout'][key]
|
||||
binary=ROOT/'build/ios-device/Build/Products/Release-iphoneos/FactorioPort.app/FactorioPort'
|
||||
assert target['build_id'].encode() in binary.read_bytes()
|
||||
dependencies=subprocess.check_output(['xcrun','otool','-L',str(binary)],text=True)
|
||||
imports=subprocess.check_output(['xcrun','nm','-u',str(binary)],text=True)
|
||||
symbols=subprocess.check_output(['xcrun','nm',str(binary)],text=True)
|
||||
assert 'steam' not in (dependencies+imports).lower()
|
||||
for symbol in ('_factorio_rng_original','_factorio_version_original','_factorio_map_header_original',
|
||||
'_factorio_target_original','_native_target_allocate'):
|
||||
assert symbol not in symbols
|
||||
report={'status':'pass','app_build':'4','run_id':target['run_id'],'build_id':target['build_id'],
|
||||
'device':'iPhone 15 Pro','target_constructor':target,
|
||||
'same_launch_version_map_header_and_actual_save':'pass',
|
||||
'input_sha256':layout['input_sha256'],'steam_dependency_or_import':False,
|
||||
'desktop_oracle_symbols':False,'signed_binary_sha256':hashlib.sha256(binary.read_bytes()).hexdigest(),
|
||||
'scope':'original initialization graph and existing header probes; no loaded world or gameplay'}
|
||||
(OUT/'device-verification.json').write_text(json.dumps(report,indent=2)+'\n')
|
||||
print('PASS: build 4 same-launch device reports, actual-save regression, signed binary dependency/oracle audit')
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Specialized, byte-guarded static lift of one Factorio 2.0.77 function.
|
||||
|
||||
This is NOT a general ARM64 lifter or a translated game engine. Original code
|
||||
and constants are read locally; no game binary is stored in this source file.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
FUNCTION_VA = 0x1004DFD14
|
||||
CONSTANT_VA = 0x10296B4E8
|
||||
SEED_VA = 0x101AA1848
|
||||
SEED_SIZE = 24
|
||||
EXPECTED_SEED = "b1d9c0b86c5590e9c62c7e433ef7b8635d963dd7db98939ebda558004260c7ef"
|
||||
BODY_SIZE = 104
|
||||
EXPECTED_BODY = "4fd978c1f02f6c828413991ba37e9d84f5a7805d4c75603ff61f7ef9759db9d3"
|
||||
|
||||
|
||||
def arm64_image(data):
|
||||
if data[:4] == bytes.fromhex("cffaedfe"):
|
||||
if struct.unpack_from("<I", data, 4)[0] != 0x100000C:
|
||||
raise ValueError("Expected ARM64 Mach-O")
|
||||
return data, 0
|
||||
if data[:4] != bytes.fromhex("cafebabe"):
|
||||
raise ValueError("Expected FAT32 or thin ARM64 Mach-O")
|
||||
count = struct.unpack_from(">I", data, 4)[0]
|
||||
if count > 32 or 8 + count * 20 > len(data):
|
||||
raise ValueError("Invalid FAT table")
|
||||
for i in range(count):
|
||||
cpu, _, offset, size, _ = struct.unpack_from(">IIIII", data, 8 + i * 20)
|
||||
if cpu == 0x100000C:
|
||||
if offset + size > len(data):
|
||||
raise ValueError("Truncated ARM64 slice")
|
||||
return data[offset:offset + size], offset
|
||||
raise ValueError("No ARM64 slice")
|
||||
|
||||
|
||||
def file_range(image, address, size):
|
||||
cursor = 32
|
||||
for _ in range(struct.unpack_from("<I", image, 16)[0]):
|
||||
command, length = struct.unpack_from("<II", image, cursor)
|
||||
if length < 8 or cursor + length > len(image):
|
||||
raise ValueError("Invalid Mach-O load command")
|
||||
if command == 0x19:
|
||||
va, _, offset, file_size = struct.unpack_from("<QQQQ", image, cursor + 24)
|
||||
if va <= address and address + size <= va + file_size:
|
||||
result = offset + address - va
|
||||
if result + size > len(image):
|
||||
raise ValueError("Truncated segment")
|
||||
return result
|
||||
cursor += length
|
||||
raise ValueError("Requested range is not backed by one file segment")
|
||||
|
||||
|
||||
def generate(binary, output):
|
||||
data = binary.read_bytes()
|
||||
image, slice_offset = arm64_image(data)
|
||||
body_offset = file_range(image, FUNCTION_VA, BODY_SIZE)
|
||||
body = image[body_offset:body_offset + BODY_SIZE]
|
||||
if hashlib.sha256(body).hexdigest() != EXPECTED_BODY:
|
||||
raise ValueError("Unrecognized function bytes; refusing to translate another build")
|
||||
seed_offset = file_range(image, SEED_VA, SEED_SIZE)
|
||||
seed_body = image[seed_offset:seed_offset + SEED_SIZE]
|
||||
if hashlib.sha256(seed_body).hexdigest() != EXPECTED_SEED:
|
||||
raise ValueError("Unrecognized seed constructor bytes")
|
||||
constants_offset = file_range(image, CONSTANT_VA, 32)
|
||||
constants = image[constants_offset:constants_offset + 32]
|
||||
shifts = struct.unpack("<6i2I", constants)
|
||||
if shifts != (13, 2, -19, -25, 12, 4, 0xFFFFE000, 0xFFFFFF80):
|
||||
raise ValueError("Unrecognized SIMD shift/mask constants")
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
# Scalarize two NEON lanes; i32 operations preserve modulo-2^32 arithmetic.
|
||||
# bfxil w8,w9,#11,#21 replaces the low 21 bits of (c << 17).
|
||||
wat = '''(module
|
||||
(memory (export "memory") 1)
|
||||
(func (export "seed") (param $state i32) (param $seed i32)
|
||||
(local $value i32)
|
||||
(local.set $value (select (local.get $seed) (i32.const 341)
|
||||
(i32.gt_u (local.get $seed) (i32.const 341))))
|
||||
(i32.store (local.get $state) (local.get $value))
|
||||
(i32.store offset=4 (local.get $state) (local.get $value))
|
||||
(i32.store offset=8 (local.get $state) (local.get $value)))
|
||||
(func (export "get_int") (param $state i32) (result i32)
|
||||
(local $a i32) (local $b i32) (local $c i32)
|
||||
(local.set $a (i32.load (local.get $state)))
|
||||
(local.set $b (i32.load offset=4 (local.get $state)))
|
||||
(local.set $c (i32.load offset=8 (local.get $state)))
|
||||
(local.set $a
|
||||
(i32.or
|
||||
(i32.shr_u (i32.xor (i32.shl (local.get $a) (i32.const 13)) (local.get $a)) (i32.const 19))
|
||||
(i32.and (i32.shl (local.get $a) (i32.const 12)) (i32.const -8192))))
|
||||
(local.set $b
|
||||
(i32.or
|
||||
(i32.shr_u (i32.xor (i32.shl (local.get $b) (i32.const 2)) (local.get $b)) (i32.const 25))
|
||||
(i32.and (i32.shl (local.get $b) (i32.const 4)) (i32.const -128))))
|
||||
(local.set $c
|
||||
(i32.or
|
||||
(i32.and (i32.shl (local.get $c) (i32.const 17)) (i32.const -2097152))
|
||||
(i32.shr_u (i32.xor (local.get $c) (i32.shl (local.get $c) (i32.const 3))) (i32.const 11))))
|
||||
(i32.store (local.get $state) (local.get $a))
|
||||
(i32.store offset=4 (local.get $state) (local.get $b))
|
||||
(i32.store offset=8 (local.get $state) (local.get $c))
|
||||
(i32.xor (i32.xor (local.get $a) (local.get $b)) (local.get $c))))
|
||||
'''
|
||||
(output / "factorio_rng.wat").write_text(wat)
|
||||
# Native oracle: retain all source instructions except the four ADRP page
|
||||
# references, relocated to the same extracted constants in this test image.
|
||||
assembly = [".text", ".p2align 2", ".globl _factorio_rng_original", "_factorio_rng_original:"]
|
||||
for offset in range(0, len(body), 4):
|
||||
word = struct.unpack_from("<I", body, offset)[0]
|
||||
if offset in (4, 20, 32, 44):
|
||||
assembly.append(" adrp x8, Lrng_constants_page@PAGE")
|
||||
else:
|
||||
assembly.append(f" .long 0x{word:08x}")
|
||||
assembly += [".p2align 2", ".globl _factorio_rng_seed_original", "_factorio_rng_seed_original:"]
|
||||
assembly += [f" .long 0x{struct.unpack_from('<I', seed_body, i)[0]:08x}" for i in range(0, SEED_SIZE, 4)]
|
||||
assembly += [".section __TEXT,__const", ".p2align 12", "Lrng_constants_page:",
|
||||
" .space 0x4e8", " .byte " + ",".join(str(x) for x in constants)]
|
||||
(output / "oracle.S").write_text("\n".join(assembly) + "\n")
|
||||
manifest = {
|
||||
"status": "two-function-specialized-lift", "symbol": "RandomGenerator::getInt()",
|
||||
"artifact_sha256": hashlib.sha256(data).hexdigest(), "slice_offset": slice_offset,
|
||||
"function_va": hex(FUNCTION_VA), "function_size": BODY_SIZE,
|
||||
"function_file_offset": slice_offset + body_offset,
|
||||
"function_sha256": EXPECTED_BODY, "constants_va": hex(CONSTANT_VA),
|
||||
"seed_constructor": {"symbol": "RandomGenerator::RandomGenerator(unsigned int)",
|
||||
"va": hex(SEED_VA), "size": SEED_SIZE,
|
||||
"file_offset": slice_offset + seed_offset, "sha256": EXPECTED_SEED},
|
||||
"constants_file_offset": slice_offset + constants_offset,
|
||||
"constants_sha256": hashlib.sha256(constants).hexdigest(),
|
||||
"translation": "Manually reviewed NEON scalarization, not general automatic lifting",
|
||||
"abi": "seed(i32 state_offset, i32 seed); get_int(i32 state_offset) -> i32; 12 state bytes",
|
||||
"oracle": "Original instruction words with four ADRP relocations; no game startup or platform calls",
|
||||
}
|
||||
(output / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
print(json.dumps(manifest, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("binary", type=Path)
|
||||
parser.add_argument("--output", type=Path, default=Path("build/rng"))
|
||||
args = parser.parse_args()
|
||||
generate(args.binary, args.output)
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Summarize observed REA loader frontiers without inferring graph closure."""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOTS = ['0x1014c93f8', '0x1014c9618', '0x1000fa1d0', '0x1014c6aac']
|
||||
TARGET = '2cb4de127b50d54c6c5666acfef2263fae16d11a3361ad39353ecf8a66a4d9c7'
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('bundle', type=Path)
|
||||
args = parser.parse_args()
|
||||
records = json.loads(args.bundle.read_text())['records']
|
||||
records = [r for r in records if r.get('authority') == 'shipped-artifact'
|
||||
and r.get('confidence') == 'observed'
|
||||
and r.get('subject', {}).get('digest', {}).get('sha256') == TARGET]
|
||||
roots = []
|
||||
for address in ROOTS:
|
||||
selected = [r for r in records if r.get('parameters', {}).get('procedure') == address]
|
||||
calls = [r for r in selected if r['operation'] == 'procedure_callees']
|
||||
if not calls:
|
||||
raise ValueError('Missing observed call frontier for ' + address)
|
||||
record = calls[-1]
|
||||
info = [r['normalized_result'] for r in selected if r['operation'] == 'procedure_info']
|
||||
assembly = [r for r in selected if r['operation'] == 'procedure_assembly']
|
||||
roots.append({'address': address, 'direct_callees': record['normalized_result'],
|
||||
'direct_callee_count': len(record['normalized_result']),
|
||||
'evidence_id': record['evidence_id'], 'relationship_coverage': record.get('relationship_coverage'),
|
||||
'procedure': info[-1] if info else None,
|
||||
'unresolved_indirect_sites': [line for line in assembly[-1]['normalized_result'].splitlines()
|
||||
if ': blr ' in line or ': br ' in line] if assembly else None,
|
||||
'scope': 'observed immediate frontier; not transitive dependency closure'})
|
||||
output = Path('build/startup/loader-frontier.json')
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps({'status': 'not-runnable', 'arm64_sha256': TARGET,
|
||||
'roots': roots}, indent=2) + '\n')
|
||||
print('PASS: observed direct call frontiers:', ', '.join(str(r['direct_callee_count']) for r in roots))
|
||||
@@ -0,0 +1,35 @@
|
||||
// Builds the 1024x1024 iOS app icon from the game's own 128 px icon (read from the local install).
|
||||
// The gear is scaled with high-quality interpolation onto an opaque dark background
|
||||
// (iOS app icons must be opaque).
|
||||
// Usage: make_icon SOURCE_PNG OUTPUT_PNG
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <CoreGraphics/CoreGraphics.h>
|
||||
#import <ImageIO/ImageIO.h>
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
@autoreleasepool {
|
||||
if (argc != 3) { fprintf(stderr, "usage: make_icon SOURCE_PNG OUTPUT_PNG\n"); return 2; }
|
||||
CGImageSourceRef src = CGImageSourceCreateWithURL((__bridge CFURLRef)[NSURL fileURLWithPath:@(argv[1])], NULL);
|
||||
CGImageRef gear = src ? CGImageSourceCreateImageAtIndex(src, 0, NULL) : NULL;
|
||||
if (!gear) { fprintf(stderr, "cannot read %s\n", argv[1]); return 1; }
|
||||
const size_t size = 1024;
|
||||
CGColorSpaceRef rgb = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
|
||||
CGContextRef ctx = CGBitmapContextCreate(NULL, size, size, 8, 0, rgb, (CGBitmapInfo)kCGImageAlphaNoneSkipLast);
|
||||
// Dark warm gradient, close to Factorio's menu backdrop.
|
||||
CGFloat stops[] = {0.20, 0.17, 0.14, 1, 0.07, 0.06, 0.05, 1};
|
||||
CGGradientRef gradient = CGGradientCreateWithColorComponents(rgb, stops, NULL, 2);
|
||||
CGContextDrawRadialGradient(ctx, gradient, CGPointMake(size / 2.0, size * 0.55), 0,
|
||||
CGPointMake(size / 2.0, size / 2.0), size * 0.75, kCGGradientDrawsAfterEndLocation);
|
||||
CGContextSetInterpolationQuality(ctx, kCGInterpolationHigh);
|
||||
CGFloat inset = size * 0.12;
|
||||
CGContextDrawImage(ctx, CGRectMake(inset, inset, size - 2 * inset, size - 2 * inset), gear);
|
||||
CGImageRef icon = CGBitmapContextCreateImage(ctx);
|
||||
CGImageDestinationRef dst = CGImageDestinationCreateWithURL((__bridge CFURLRef)[NSURL fileURLWithPath:@(argv[2])],
|
||||
CFSTR("public.png"), 1, NULL);
|
||||
CGImageDestinationAddImage(dst, icon, NULL);
|
||||
BOOL ok = CGImageDestinationFinalize(dst);
|
||||
CFRelease(dst); CGImageRelease(icon); CGGradientRelease(gradient); CGContextRelease(ctx);
|
||||
CGColorSpaceRelease(rgb); CGImageRelease(gear); CFRelease(src);
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resolve every import of the macOS ARM64 slice against the iOS SDK's text stubs.
|
||||
|
||||
Usage: python3 tools/native_audit.py build/factorio.arm64 [--json out.json]
|
||||
|
||||
For each LC_LOAD_DYLIB the import is looked up in the iOS library with the same
|
||||
framework name, following re-exported libraries. Imports that do not resolve
|
||||
must be supplied by a shim. Nothing on the device is consulted, so this is a
|
||||
link-level audit only; Objective-C selectors are not checked.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SDK = Path(subprocess.check_output(["xcrun", "--sdk", "iphoneos", "--show-sdk-path"], text=True).strip())
|
||||
|
||||
|
||||
def tbd_documents():
|
||||
"""Yield (install_name, symbols, reexports) for every tbd document in the SDK."""
|
||||
files = list((SDK / "usr/lib").rglob("*.tbd")) + list((SDK / "System/Library").rglob("*.tbd"))
|
||||
for path in files:
|
||||
text = path.read_text(errors="ignore")
|
||||
for doc in re.split(r"^--- !tapi-tbd", text, flags=re.M):
|
||||
name = re.search(r"^install-name:\s*'?([^'\n]+)'?", doc, re.M)
|
||||
if not name:
|
||||
continue
|
||||
exports = doc.split("\nreexports:")[0].split("\nundefineds:")[0]
|
||||
symbols = set()
|
||||
for kind, prefix in (("symbols", ""), ("weak-symbols", ""), ("thread-local-symbols", ""),
|
||||
("objc-classes", "class"), ("objc-eh-types", "eh"), ("objc-ivars", "ivar")):
|
||||
for block in re.findall(rf"\b{kind}:\s*\[([^\]]*)\]", exports):
|
||||
for item in block.split(","):
|
||||
item = item.strip().strip("'\"")
|
||||
if not item:
|
||||
continue
|
||||
if prefix == "class":
|
||||
symbols.update({f"_OBJC_CLASS_$_{item}", f"_OBJC_METACLASS_$_{item}"})
|
||||
elif prefix == "eh":
|
||||
symbols.add(f"_OBJC_EHTYPE_$_{item}")
|
||||
elif prefix == "ivar":
|
||||
symbols.add(f"_OBJC_IVAR_$_{item}")
|
||||
else:
|
||||
symbols.add(item)
|
||||
reexports = set()
|
||||
for block in re.findall(r"reexported-libraries:.*?libraries:\s*\[([^\]]*)\]", doc, re.S):
|
||||
reexports.update(x.strip().strip("'\"") for x in block.split(",") if x.strip())
|
||||
yield name.group(1), symbols, reexports
|
||||
|
||||
|
||||
def main():
|
||||
binary = sys.argv[1]
|
||||
libs = {}
|
||||
for name, symbols, reexports in tbd_documents():
|
||||
entry = libs.setdefault(name, [set(), set()])
|
||||
entry[0] |= symbols
|
||||
entry[1] |= reexports
|
||||
|
||||
def resolves(install, symbol, seen=()):
|
||||
if install not in libs or install in seen:
|
||||
return False
|
||||
symbols, reexports = libs[install]
|
||||
return symbol in symbols or any(resolves(r, symbol, seen + (install,)) for r in reexports)
|
||||
|
||||
def ios_path(mac_path):
|
||||
if mac_path in libs: # e.g. IOKit keeps its Versions/A install name on iOS
|
||||
return mac_path
|
||||
m = re.match(r"/System/Library/Frameworks/(\w+)\.framework/", mac_path)
|
||||
if m:
|
||||
return f"/System/Library/Frameworks/{m.group(1)}.framework/{m.group(1)}"
|
||||
return mac_path
|
||||
|
||||
loads = re.findall(r"cmd LC_LOAD(?:_WEAK)?_DYLIB\n\s+cmdsize \d+\n\s+name (\S+)",
|
||||
subprocess.check_output(["otool", "-l", binary], text=True))
|
||||
short = {Path(p).name.replace(".dylib", "").split(".")[0]: p for p in loads}
|
||||
undef = subprocess.check_output(["nm", "-m", "-u", binary], text=True)
|
||||
report = {}
|
||||
for line in undef.splitlines():
|
||||
m = re.search(r"external (\S+) \(from (\S+)\)", line)
|
||||
if not m:
|
||||
continue
|
||||
symbol, lib = m.groups()
|
||||
mac = short.get(lib)
|
||||
target = ios_path(mac) if mac else None
|
||||
status = "resolved" if target and resolves(target, symbol) else "missing"
|
||||
lib_report = report.setdefault(lib, {"mac_path": mac, "ios_path": target,
|
||||
"ios_library_exists": target in libs,
|
||||
"resolved": 0, "missing": []})
|
||||
if status == "resolved":
|
||||
lib_report["resolved"] += 1
|
||||
else:
|
||||
lib_report["missing"].append(symbol)
|
||||
for lib, r in sorted(report.items(), key=lambda kv: -len(kv[1]["missing"])):
|
||||
print(f"{lib:20} exists={r['ios_library_exists']!s:5} resolved={r['resolved']:4} missing={len(r['missing']):3} "
|
||||
+ " ".join(r["missing"][:12]) + (" ..." if len(r["missing"]) > 12 else ""))
|
||||
if "--json" in sys.argv:
|
||||
Path(sys.argv[sys.argv.index("--json") + 1]).write_text(json.dumps(report, indent=2) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build and audit an unsigned iOS prototype; does not sign or install it."""
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
subprocess.run(["xcodegen", "generate", "--spec", "platform/ios/project.yml"], cwd=root, check=True)
|
||||
build = subprocess.run([
|
||||
"xcodebuild", "-quiet", "-project", "platform/ios/FactorioPort.xcodeproj",
|
||||
"-scheme", "FactorioPort", "-sdk", "iphoneos", "-destination", "generic/platform=iOS",
|
||||
"-configuration", "Release", "-derivedDataPath", "build/ios", "CODE_SIGNING_ALLOWED=NO", "build"
|
||||
], cwd=root, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
(root / "build/ios-build.log").write_text(build.stdout)
|
||||
if build.returncode:
|
||||
print(build.stdout[-12000:])
|
||||
raise SystemExit(build.returncode)
|
||||
app = root / "build/ios/Build/Products/Release-iphoneos/FactorioPort.app"
|
||||
binary = app / "FactorioPort"
|
||||
dependencies = subprocess.check_output(["xcrun", "otool", "-L", str(binary)], text=True)
|
||||
imports = subprocess.check_output(["xcrun", "nm", "-u", str(binary)], text=True)
|
||||
if "steam" in (dependencies + imports).lower():
|
||||
raise RuntimeError("Steam dependency/import found in local single-player target")
|
||||
ipa = root / "build/FactorioRNG-unsigned.ipa"
|
||||
with zipfile.ZipFile(ipa, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for path in sorted(app.rglob("*")):
|
||||
if path.is_file():
|
||||
archive.write(path, Path("Payload") / app.name / path.relative_to(app))
|
||||
report = {"status": "pass", "ios_build": "unsigned device build", "device_execution": "not run",
|
||||
"steam_dependency_or_import": False,
|
||||
"binary_sha256": hashlib.sha256(binary.read_bytes()).hexdigest(),
|
||||
"dynamic_dependencies": dependencies.splitlines()[1:], "ipa": str(ipa)}
|
||||
(root / "build/ios-verification.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
print(json.dumps(report, indent=2))
|
||||
@@ -0,0 +1,51 @@
|
||||
// A temporary MCP client; does not modify any saved MCP client or REA configuration.
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { createInterface } from 'node:readline';
|
||||
|
||||
const [packageRoot, hopperLauncher] = process.argv.slice(2);
|
||||
if (!packageRoot || !hopperLauncher) {
|
||||
throw new Error('Usage: node tools/rea-session.mjs REA_PACKAGE_ROOT HOPPER_LAUNCHER (the vendor hopper helper)');
|
||||
}
|
||||
const dependencies = resolve(packageRoot, '..', '@modelcontextprotocol');
|
||||
const { Client } = await import(pathToFileURL(join(dependencies, 'client/dist/index.mjs')));
|
||||
const { StdioClientTransport } = await import(pathToFileURL(join(dependencies, 'client/dist/stdio.mjs')));
|
||||
const transport = new StdioClientTransport({
|
||||
command: join(packageRoot, '..', '.bin', 'rea'), args: ['--mcp'],
|
||||
env: { ...process.env, HOPPER_LAUNCHER_PATH: hopperLauncher,
|
||||
REA_HOPPER_STARTUP_TIMEOUT_MS: '600000' },
|
||||
stderr: 'pipe',
|
||||
});
|
||||
// Consume diagnostics without printing inherited environment or bridge credentials.
|
||||
transport.stderr?.on('data', () => {});
|
||||
const client = new Client({ name: 'factorio-port-investigation', version: '0.1.0' });
|
||||
const allowed = new Set(['open_binary', 'binary_session', 'list_documents',
|
||||
'search_procedures', 'procedure_assembly', 'procedure_info', 'read_bytes',
|
||||
'search_strings', 'read_function_instructions', 'procedure_callers', 'procedure_callees',
|
||||
'procedure_pseudo_code', 'analyze_function', 'export_evidence_bundle', 'close_binary']);
|
||||
await mkdir('evidence', { recursive: true });
|
||||
await client.connect(transport);
|
||||
console.log('REA MCP connected. Enter one JSON tool request per line; quit closes the target.');
|
||||
const input = createInterface({ input: process.stdin, terminal: false });
|
||||
let sequence = 0;
|
||||
try {
|
||||
for await (const line of input) {
|
||||
if (line.trim() === 'quit') break;
|
||||
try {
|
||||
const request = JSON.parse(line);
|
||||
if (!allowed.has(request.name)) throw new Error('Tool outside investigation allowlist');
|
||||
console.log(`Calling ${request.name}`);
|
||||
const result = await client.callTool(request, { timeout: 660000 });
|
||||
const file = `evidence/${Date.now()}-${++sequence}-${request.name}.json`;
|
||||
await writeFile(file, JSON.stringify(result, null, 2), { mode: 0o600 });
|
||||
const data = result.structuredContent ?? result.content?.filter(x => x.type === 'text').map(x => {
|
||||
try { return JSON.parse(x.text); } catch { return x.text; }
|
||||
});
|
||||
console.log(JSON.stringify({ file, isError: result.isError ?? false, data }).slice(0, 14000));
|
||||
} catch (error) { console.log(JSON.stringify({ error: error.message })); }
|
||||
}
|
||||
} finally {
|
||||
try { await client.callTool({ name: 'close_binary', arguments: {} }); }
|
||||
finally { await client.close(); }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Record exact local executable and installed data identities, without copying assets."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import plistlib
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from lift_rng import arm64_image
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
EXPECTED_BINARY = '00b014d78df07e05ab1cee853904c8f7b3814ca652c043461a9d49fbad46cdf6'
|
||||
EXPECTED_SLICE = '2cb4de127b50d54c6c5666acfef2263fae16d11a3361ad39353ecf8a66a4d9c7'
|
||||
|
||||
|
||||
def digest(path):
|
||||
result = hashlib.sha256()
|
||||
with path.open('rb') as source:
|
||||
for block in iter(lambda: source.read(1024 * 1024), b''):
|
||||
result.update(block)
|
||||
return result.hexdigest()
|
||||
|
||||
|
||||
def command(*args):
|
||||
return subprocess.check_output(args, cwd=ROOT, text=True).strip()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('app', type=Path)
|
||||
args = parser.parse_args()
|
||||
app = args.app.resolve(strict=True)
|
||||
binary = app / 'Contents/MacOS/factorio'
|
||||
source = binary.read_bytes()
|
||||
image, offset = arm64_image(source)
|
||||
binary_hash, slice_hash = hashlib.sha256(source).hexdigest(), hashlib.sha256(image).hexdigest()
|
||||
if binary_hash != EXPECTED_BINARY or slice_hash != EXPECTED_SLICE:
|
||||
raise ValueError('Target differs from the inspected Factorio 2.0.77 build')
|
||||
info = plistlib.loads((app / 'Contents/Info.plist').read_bytes())
|
||||
assets = app / 'Contents/data'
|
||||
entries = []
|
||||
for path in sorted(assets.rglob('*')):
|
||||
if path.is_symlink():
|
||||
raise ValueError(f'Asset symlink requires explicit inventory policy: {path}')
|
||||
if path.is_file():
|
||||
entries.append({'path': path.relative_to(assets).as_posix(),
|
||||
'bytes': path.stat().st_size, 'sha256': digest(path)})
|
||||
if not entries:
|
||||
raise ValueError('No installed data files found')
|
||||
packages = []
|
||||
for path in sorted(assets.glob('*/info.json')):
|
||||
metadata = json.loads(path.read_text())
|
||||
packages.append({'directory': path.parent.name, 'name': metadata.get('name'),
|
||||
'version': metadata.get('version'), 'dependencies': metadata.get('dependencies')})
|
||||
asset_manifest = {'scope': 'all regular files under installed Contents/data; no user mods or saves',
|
||||
'files': entries, 'file_count': len(entries),
|
||||
'total_bytes': sum(e['bytes'] for e in entries), 'packages': packages,
|
||||
'gameplay_configuration': 'not selected; inventory does not prove save compatibility'}
|
||||
out = ROOT / 'build/target'
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
asset_path = out / 'assets.json'
|
||||
asset_path.write_text(json.dumps(asset_manifest, indent=2) + '\n')
|
||||
report = {'recorded_at': datetime.now(timezone.utc).isoformat(), 'app': str(app),
|
||||
'version': info.get('CFBundleShortVersionString'), 'binary_sha256': binary_hash,
|
||||
'arm64_sha256': slice_hash, 'arm64_slice_offset': offset, 'arm64_bytes': len(image),
|
||||
'binary_uuids': command('xcrun', 'dwarfdump', '--uuid', str(binary)).splitlines(),
|
||||
'xcode': command('xcodebuild', '-version'),
|
||||
'iphoneos_sdk': command('xcrun', '--sdk', 'iphoneos', '--show-sdk-version'),
|
||||
'wabt_revision': command('git', '-C', str(ROOT / 'third_party/wabt'), 'rev-parse', 'HEAD'),
|
||||
'asset_manifest_sha256': digest(asset_path), 'asset_file_count': len(entries),
|
||||
'source_files': {str(p.relative_to(ROOT)): digest(p) for base in ('tools', 'runtime', 'platform/ios', 'tests')
|
||||
for p in sorted((ROOT / base).glob('*'))
|
||||
if p.is_file() and p.suffix in ('.py', '.mjs', '.c', '.h', '.m', '.yml')}}
|
||||
(out / 'manifest.json').write_text(json.dumps(report, indent=2) + '\n')
|
||||
print(f'PASS: pinned executable and ARM64 slice; recorded {len(entries)} asset files ({asset_manifest["total_bytes"]} bytes)')
|
||||
print(out / 'manifest.json')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Retarget the unmodified macOS ARM64 slice into an iOS-loadable dylib.
|
||||
|
||||
Usage: python3 tools/retarget.py build/factorio.arm64 build/native/Factorio.dylib
|
||||
|
||||
Only Mach-O metadata changes; every instruction and data byte stays the same:
|
||||
* MH_EXECUTE -> MH_DYLIB, plus an LC_ID_DYLIB in the header padding.
|
||||
* __PAGEZERO shrinks to one page just below __TEXT, so dyld can slide the image.
|
||||
Segment indices stay unchanged, so rebase/bind opcodes remain valid.
|
||||
* LC_BUILD_VERSION platform macOS -> iOS.
|
||||
* macOS framework paths -> iOS paths, or to @loader_path shims for frameworks
|
||||
(or individual symbols) that iOS lacks. Shims re-export the real framework
|
||||
when one exists.
|
||||
The host app reads LC_MAIN at runtime to find the original entry point.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
EXPECTED_SHA256 = "2cb4de127b50d54c6c5666acfef2263fae16d11a3361ad39353ecf8a66a4d9c7"
|
||||
MH_MAGIC_64, MH_EXECUTE, MH_DYLIB = 0xFEEDFACF, 2, 6
|
||||
MH_NO_REEXPORTED_DYLIBS = 0x100000
|
||||
LC_SEGMENT_64, LC_ID_DYLIB, LC_LOAD_DYLIB, LC_LOAD_WEAK_DYLIB = 0x19, 0xD, 0xC, 0x80000018
|
||||
LC_BUILD_VERSION, LC_MAIN = 0x32, 0x80000028
|
||||
PLATFORM_IOS = 2
|
||||
MIN_IOS = (17 << 16)
|
||||
SDK_IOS = (26 << 16) | (5 << 8)
|
||||
INSTALL_NAME = "@rpath/Factorio.dylib"
|
||||
|
||||
# Framework name -> replacement install name. Absent: use the iOS path of the same framework.
|
||||
SHIMS = {
|
||||
"AppKit": "@loader_path/fx_AppKit.dylib",
|
||||
"Cocoa": "@loader_path/fx_Cocoa.dylib",
|
||||
"Carbon": "@loader_path/fx_Carbon.dylib",
|
||||
"OpenGL": "@loader_path/fx_OpenGL.dylib",
|
||||
"OpenCL": "@loader_path/fx_OpenCL.dylib",
|
||||
"ForceFeedback": "@loader_path/fx_ForceFeedback.dylib",
|
||||
"LDAP": "@loader_path/fx_LDAP.dylib",
|
||||
"ApplicationServices": "@loader_path/fx_ApplicationServices.dylib",
|
||||
# Present on iOS but missing some imported symbols: shim re-exports the real framework.
|
||||
"CoreGraphics": "@loader_path/fx_CoreGraphics.dylib",
|
||||
"CoreVideo": "@loader_path/fx_CoreVideo.dylib",
|
||||
"CoreServices": "@loader_path/fx_CoreServices.dylib",
|
||||
"Security": "@loader_path/fx_Security.dylib",
|
||||
"Metal": "@loader_path/fx_Metal.dylib",
|
||||
"Foundation": "@loader_path/fx_Foundation.dylib",
|
||||
"IOKit": "@loader_path/fx_IOKit.dylib",
|
||||
"CoreAudio": "@loader_path/fx_CoreAudio.dylib",
|
||||
"AudioToolbox": "@loader_path/fx_AudioToolbox.dylib",
|
||||
}
|
||||
|
||||
|
||||
def cstr(data, offset):
|
||||
return data[offset:data.index(b"\0", offset)].decode()
|
||||
|
||||
|
||||
def main():
|
||||
source, output = Path(sys.argv[1]), Path(sys.argv[2])
|
||||
data = bytearray(source.read_bytes())
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
if digest != EXPECTED_SHA256:
|
||||
raise SystemExit(f"unexpected input {digest}; only the inspected 2.0.77 ARM64 slice is supported")
|
||||
magic, _cpu, _sub, filetype, ncmds, sizeofcmds, flags, _ = struct.unpack_from("<IiiIIIII", data, 0)
|
||||
if magic != MH_MAGIC_64 or filetype != MH_EXECUTE:
|
||||
raise SystemExit("input is not a 64-bit Mach-O executable")
|
||||
|
||||
report = {"input_sha256": digest, "dylibs": [], "changes": []}
|
||||
offset, first_section = 32, None
|
||||
text_vmaddr = None
|
||||
for _ in range(ncmds):
|
||||
cmd, cmdsize = struct.unpack_from("<II", data, offset)
|
||||
if cmd == LC_SEGMENT_64:
|
||||
segname = data[offset + 8:offset + 24].rstrip(b"\0").decode()
|
||||
vmaddr, vmsize, fileoff, filesize = struct.unpack_from("<QQQQ", data, offset + 24)
|
||||
nsects = struct.unpack_from("<I", data, offset + 64)[0]
|
||||
if segname == "__TEXT":
|
||||
text_vmaddr = vmaddr
|
||||
for s in range(nsects):
|
||||
sect_offset = struct.unpack_from("<I", data, offset + 72 + s * 80 + 48)[0]
|
||||
if sect_offset and (first_section is None or sect_offset < first_section):
|
||||
first_section = sect_offset
|
||||
if segname == "__PAGEZERO":
|
||||
pagezero = offset
|
||||
elif cmd == LC_BUILD_VERSION:
|
||||
platform, minos, sdk = struct.unpack_from("<III", data, offset + 8)
|
||||
struct.pack_into("<III", data, offset + 8, PLATFORM_IOS, MIN_IOS, SDK_IOS)
|
||||
report["changes"].append({"build_version": [platform, hex(minos), hex(sdk)],
|
||||
"to": [PLATFORM_IOS, hex(MIN_IOS), hex(SDK_IOS)]})
|
||||
elif cmd == LC_MAIN:
|
||||
report["entryoff"] = struct.unpack_from("<Q", data, offset + 8)[0]
|
||||
elif cmd in (LC_LOAD_DYLIB, LC_LOAD_WEAK_DYLIB):
|
||||
name_offset = struct.unpack_from("<I", data, offset + 8)[0]
|
||||
old = cstr(data, offset + name_offset)
|
||||
framework = None
|
||||
if old.startswith("/System/Library/Frameworks/"):
|
||||
framework = old.split("/")[4].removesuffix(".framework")
|
||||
if framework in SHIMS:
|
||||
new = SHIMS[framework]
|
||||
elif framework == "IOKit":
|
||||
new = old
|
||||
elif framework:
|
||||
new = f"/System/Library/Frameworks/{framework}.framework/{framework}"
|
||||
else:
|
||||
new = old # libSystem, libobjc, libresolv, @loader_path/libsteam_api.dylib
|
||||
room = cmdsize - name_offset
|
||||
encoded = new.encode() + b"\0"
|
||||
if len(encoded) > room:
|
||||
raise SystemExit(f"{new} does not fit in {room} bytes")
|
||||
data[offset + name_offset:offset + cmdsize] = encoded.ljust(room, b"\0")
|
||||
report["dylibs"].append({"from": old, "to": new, "weak": cmd == LC_LOAD_WEAK_DYLIB})
|
||||
offset += cmdsize
|
||||
|
||||
if text_vmaddr is None or first_section is None:
|
||||
raise SystemExit("__TEXT segment not found")
|
||||
# Shrink __PAGEZERO to a single 16 KiB page directly below __TEXT.
|
||||
struct.pack_into("<QQ", data, pagezero + 24, text_vmaddr - 0x4000, 0x4000)
|
||||
report["changes"].append({"pagezero": [hex(text_vmaddr - 0x4000), "0x4000"]})
|
||||
|
||||
# Append LC_ID_DYLIB in the padding before the first section.
|
||||
name = INSTALL_NAME.encode() + b"\0"
|
||||
id_size = (24 + len(name) + 7) & ~7
|
||||
end = 32 + sizeofcmds
|
||||
if end + id_size > first_section:
|
||||
raise SystemExit("no header padding for LC_ID_DYLIB")
|
||||
if any(data[end:end + id_size]):
|
||||
raise SystemExit("header padding is not empty")
|
||||
struct.pack_into("<IIIIII", data, end, LC_ID_DYLIB, id_size, 24, 2, 0x10000, 0x10000)
|
||||
data[end + 24:end + 24 + len(name)] = name
|
||||
struct.pack_into("<IIIII", data, 12, MH_DYLIB, ncmds + 1, sizeofcmds + id_size,
|
||||
flags | MH_NO_REEXPORTED_DYLIBS, 0)
|
||||
report["changes"].append({"filetype": "MH_EXECUTE->MH_DYLIB", "install_name": INSTALL_NAME})
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_bytes(data)
|
||||
report["output_sha256"] = hashlib.sha256(data).hexdigest()
|
||||
changed = sum(1 for a, b in zip(data, source.read_bytes()) if a != b)
|
||||
report["changed_bytes"] = changed
|
||||
report["changed_bytes_outside_header"] = sum(
|
||||
1 for i, (a, b) in enumerate(zip(data[first_section:], source.read_bytes()[first_section:])) if a != b)
|
||||
if report["changed_bytes_outside_header"]:
|
||||
raise SystemExit("retarget changed bytes outside the Mach-O header")
|
||||
(output.parent / "retarget.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
print(json.dumps({k: v for k, v in report.items() if k != "dylibs"}, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Export the observed startup frontier; never equates discovery with runnable code."""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
bundle = json.loads(Path('evidence/factorio-functions-final.json').read_text())
|
||||
record = next(r for r in bundle['records'] if r['operation'] == 'analyze_function'
|
||||
and r['parameters']['procedure'] == '0x100161584')
|
||||
native = record['normalized_result']
|
||||
callees = []
|
||||
for callee in native['callees']:
|
||||
symbol = callee['name']
|
||||
route = 'exclude-optional-steam' if 'Steam' in symbol else 'host-abi-adapter' if symbol.startswith('imp___stubs') else 'pending-lift'
|
||||
callees.append({'address': callee['address'], 'symbol': symbol,
|
||||
'planned_route': route, 'route_authority': 'analyst planning from symbol identity'})
|
||||
indirect = [line for line in native['assembly'] if re.search(r': blr\s', line)]
|
||||
result = {'status': 'not-runnable', 'root': native['procedure']['name'],
|
||||
'root_address': '0x100161584', 'evidence_id': record['evidence_id'],
|
||||
'observed_direct_callees': len(callees), 'observed_basic_blocks': len(native['basic_blocks']),
|
||||
'observed_indirect_call_sites': indirect, 'callees': callees,
|
||||
'limitations': ['This is one observed function frontier, not a transitive closure.',
|
||||
'Hopper does not expose complete body ranges or all indirect targets.',
|
||||
'The straight-line lifter cannot yet compile this startup function.',
|
||||
'Graphical startup includes inlined Steam initialization; changing the factory alone is insufficient.']}
|
||||
out = Path('build/startup'); out.mkdir(parents=True, exist_ok=True)
|
||||
(out / 'frontier.json').write_text(json.dumps(result, indent=2) + '\n')
|
||||
print(json.dumps({key: result[key] for key in ['status', 'root_address', 'evidence_id',
|
||||
'observed_direct_callees', 'observed_basic_blocks']}, indent=2))
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Symbolize [fx-crash] lines from build/native/console.log against the original factorio.dSYM.
|
||||
|
||||
Usage: python3 tools/symbolize_native.py [console.log]
|
||||
Factorio.dylib offsets map to the original executable's addresses as 0x100000000 + offset.
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DSYM = Path.home() / ("Library/Application Support/Steam/steamapps/common/Factorio/"
|
||||
"factorio.app/Contents/MacOS/factorio.dSYM/Contents/Resources/DWARF/factorio")
|
||||
log = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "build/native/console.log"
|
||||
lines = [l.rstrip() for l in log.read_text(errors="replace").splitlines() if "[fx-crash]" in l]
|
||||
addrs = []
|
||||
for line in lines:
|
||||
m = re.search(r"Factorio\.dylib\+(0x[0-9a-f]+)", line)
|
||||
if m:
|
||||
addrs.append(hex(0x100000000 + int(m.group(1), 16)))
|
||||
symbols = iter(subprocess.check_output(["atos", "-o", str(DSYM), "-arch", "arm64", "-l", "0x100000000", *addrs],
|
||||
text=True).splitlines()) if addrs else iter(())
|
||||
for line in lines:
|
||||
if "Factorio.dylib+" in line:
|
||||
print(line.split("[fx-crash] ", 1)[1], "=>", next(symbols))
|
||||
elif not re.search(r"\] x\d+ =", line):
|
||||
print(line.split("[fx-crash] ", 1)[1])
|
||||
Reference in new issue
Block a user