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:
SashaGoncharov19 committed 2026-10-23 15:27:58 +04:00
commit 51d085e865
89 files changed
+7999

No files matched your search

+415
View File
@@ -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)))
'''
+276
View File
@@ -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)))
'''
+82
View File
@@ -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()
+79
View File
@@ -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()
+263
View File
@@ -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()
+63
View File
@@ -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()
+25
View File
@@ -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')
+99
View File
@@ -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()
+72
View File
@@ -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()
+45
View File
@@ -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')
+34
View File
@@ -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')
+55
View File
@@ -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))
+45
View File
@@ -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')
+147
View File
@@ -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)
+36
View File
@@ -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))
+35
View File
@@ -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;
}
}
+102
View File
@@ -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()
+36
View File
@@ -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))
+51
View File
@@ -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(); }
}
+81
View File
@@ -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()
+149
View File
@@ -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()
+28
View File
@@ -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))
+28
View File
@@ -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])