diff -ruN -x __pycache__ -x PATCH.diff baseline/luraph_compiler/compiler.py agent2/luraph_compiler/compiler.py --- baseline/luraph_compiler/compiler.py 2026-10-23 04:08:14.521426108 +0000 +++ agent2/luraph_compiler/compiler.py 2026-10-23 06:29:37.926152144 +0000 @@ -39,6 +39,38 @@ LUAU = os.environ.get('LUAU') or ('/opt/ws-remote/bin/luau' if Path('/opt/ws-remote/bin/luau').is_file() else '/tmp/opencode/luau-src/build/luau') ROLE_FIELD = {'A': 'A', 'B': 'B', 'C': 'C', 'K1': 'K1', 'K2': 'K2', 'K3': 'K3'} +FIELD = {'A': 10, 'B': 5, 'C': 4, 'K1': 9, 'K2': 8, 'K3': 7} +REG_ROLES = {'dst', 'src', 'obj', 'key', 'val', 'lhs', 'rhs', 'cond', + 'base', 'from', 'to', 'reg', 'mid', 'src2', 'dst2', 'obj2'} +# Two-slot superinstructions: the handler advances pc by two and the +# following slot is XNOP. +WIDE_OPS = {0x120, 0x121, 0x122, 0x123, 0x124, 0x125, 0x126, 0x127} + + +def _spec(op): + return isa.BY_CODE.get(op) + + + +def _fget(p, field, pc, default=None): + return p.get(FIELD[field], {}).get(pc, default) + + +def _fset(p, field, pc, value): + p.setdefault(FIELD[field], {})[pc] = value + + +def _fclear(p, pc): + for f in FIELD.values(): + p.get(f, {}).pop(pc, None) + + +def _branch_target_field(op): + s = _spec(op) + if not s: + return None + return s['roles'].get('target') + class CompileError(Exception): @@ -703,7 +735,7 @@ names = {} for field in ('enc', 'key', 'opkey', 'root', 'protos', 'opmap', 'sbox', 'sbox_inv', 'regbase', 'params', 'vararg', 'stack', 'boxed', - 'code', 'A', 'B', 'C', 'K1', 'K2', 'K3'): + 'code', 'jt', 'A', 'B', 'C', 'K1', 'K2', 'K3'): while True: name = '_' + ''.join(rng.choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(9)) if name not in names.values(): @@ -721,10 +753,12 @@ f['regbase'], regshift, f['root'], graph['root'], f['protos'])] for pid in sorted(graph['prototypes']): p = graph['prototypes'][pid] - lines.append('[%d]={%s=%d,%s=%s,%s=%d,%s={%s},%s={' + jt = p.get(15) or [] + lines.append('[%d]={%s=%d,%s=%s,%s=%d,%s={%s},%s={%s},%s={' % (pid, f['params'], p[1], f['vararg'], 'true' if p.get(14) else 'false', f['stack'], p[6] + regshift, - f['boxed'], ','.join(str(x + regshift) for x in p.get(13, [])), f['code'])) + f['boxed'], ','.join(str(x + regshift) for x in p.get(13, [])), + f['jt'], ','.join(str(x) for x in jt), f['code'])) code = {} for pc, op in p[3].items(): rec = {'op': opmap[op] if opmap is not None else (op ^ opkey)} @@ -734,11 +768,9 @@ if regshift: roles = isa.BY_CODE.get(op, {}).get('roles', {}) field_roles = {v: k for k, v in roles.items()} - reg_roles = {'dst', 'src', 'obj', 'key', 'val', 'lhs', 'rhs', - 'cond', 'base', 'from', 'to', 'reg'} - for field in ('A', 'B', 'C'): + for field in ('A', 'B', 'C', 'K1', 'K2', 'K3'): role = field_roles.get(field) - if role in reg_roles or (role == 'cell' and op in (0x105, 0x106)): + if role in REG_ROLES or (role == 'cell' and op in (0x105, 0x106)): if field in rec: rec[field] += regshift if op == 49 and 'K2' in rec and isinstance(rec['K2'], list): @@ -841,11 +873,9 @@ return rec roles = isa.BY_CODE.get(op, {}).get('roles', {}) field_roles = {v: k for k, v in roles.items()} - reg_roles = {'dst', 'src', 'obj', 'key', 'val', 'lhs', 'rhs', - 'cond', 'base', 'from', 'to', 'reg'} - for field in ('A', 'B', 'C'): + for field in ('A', 'B', 'C', 'K1', 'K2', 'K3'): role = field_roles.get(field) - if role in reg_roles or (role == 'cell' and op in (0x105, 0x106)): + if role in REG_ROLES or (role == 'cell' and op in (0x105, 0x106)): if field in rec: rec[field] += regshift if op == 49 and 'K2' in rec and isinstance(rec['K2'], list): @@ -868,12 +898,27 @@ return bytes(out) +def _seal_record(rec, enc, key, sbox, field_map): + present = [(i, name) for i, name in enumerate(('A', 'B', 'C', 'K1', 'K2', 'K3')) + if name in rec] + data = bytearray() + data += _b_u32(rec['op']) + data += _b_u8(len(present)) + for fid, name in present: + data += _b_u8(field_map[fid]) + data += _b_value(rec[name], enc, key, sbox) + return bytes(data) + + def emit_binary(graph, enc=True, key=0, opmap=None, sbox=None, regshift=0, lazy=True): """Compact binary graph with per-prototype encrypted, lazily parsed code. Each build permutes operand field ids, prototype ids, and per-prototype register bases. Instruction streams are encrypted with per-prototype - seeds and decrypted only when the prototype is first executed. + seeds. Inside that layer, instructions are grouped into small blocks and + each block is individually encrypted; the VM decrypts a block only when + execution first enters one of its pcs. Operands therefore never exist in + plaintext in the stored artifact even after the prototype header is read. """ opkey = key & 0xFF proto_ids = sorted(graph['prototypes']) @@ -889,6 +934,7 @@ break field_map = [0, 1, 2, 3, 4, 5] rng.shuffle(field_map) + bsize = rng.choice((6, 8, 10)) payload = bytearray() payload += _b_u32(opkey) payload += _b_u32(id_map[graph['root']]) @@ -907,6 +953,10 @@ payload += _b_u32(len(boxed)) for x in boxed: payload += _b_u32(x) + jt = p.get(15) or [] + payload += _b_u32(len(jt)) + for x in jt: + payload += _b_u32(x) code = {} for pc, op in p[3].items(): rec = {'op': opmap[op] if opmap is not None else (op ^ opkey)} @@ -918,21 +968,28 @@ rec['C'] = id_map[rec['C']] code[pc] = _shift_rec(op, rec, proto_rb) maxpc = max(code) if code else 0 + nblocks = (maxpc + bsize - 1) // bsize if maxpc else 0 + blocks = [] + for b in range(nblocks): + raw = bytearray() + for pc in range(b * bsize + 1, min((b + 1) * bsize, maxpc) + 1): + rec = code.get(pc) + if rec is None: + raw += _b_u32(0) + raw += _b_u8(0) + continue + raw += _seal_record(rec, enc, key, sbox, field_map) + seed = rng.randrange(256) + blocks.append((seed, _xor_code(bytes(raw), seed, + (id_map[pid] * 131 + b + 1) & 0xFFFFFFFF))) cb = bytearray() cb += _b_u32(maxpc) - for pc in range(1, maxpc + 1): - rec = code.get(pc) - if rec is None: - cb += _b_u32(0) - cb += _b_u8(0) - continue - cb += _b_u32(rec['op']) - present = [(i, name) for i, name in enumerate(('A', 'B', 'C', 'K1', 'K2', 'K3')) - if name in rec] - cb += _b_u8(len(present)) - for fid, name in present: - cb += _b_u8(field_map[fid]) - cb += _b_value(rec[name], enc, key, sbox) + cb += _b_u8(bsize) + cb += _b_u32(nblocks) + for seed, data in blocks: + cb += _b_u8(seed) + cb += _b_u32(len(data)) + cb += data code_seed = rng.randrange(256) code_enc = _xor_code(bytes(cb), code_seed, id_map[pid]) payload += _b_u32(len(code_enc)) @@ -958,7 +1015,7 @@ payload += _b_u32(_b_crc(payload)) enc_payload = _xor_bytes(bytes(payload), key, regshift) key_raw = key ^ ((regshift * 37 + 0xA5) & 0xFF) - header = b'SGB2' + bytes((2, key_raw & 0xFF, regshift & 0xFF, + header = b'SGB2' + bytes((3, key_raw & 0xFF, regshift & 0xFF, (1 if enc else 0) | (2 if lazy else 0))) return (header + enc_payload).hex() @@ -1123,73 +1180,636 @@ return proc.stdout -def obfuscate_graph(graph: dict, key: int = 0) -> dict: - """Add per-build superinstructions and jump trampolines. +def _numeric_registers(p): + """Conservative must-be-number analysis used by the MBA pass. - A straight-line GETGLOBAL+GETTABLE pair is fused into one handler that - writes both original destinations. The second slot becomes an XNOP, so - no program counter or jump target moves. Prototypes that can branch are - fused only when the second slot is not a branch target. + A register qualifies only when every definition in the prototype produces + a number and all register sources of every such definition qualify too. + Parameters, globals, table reads and cells never qualify. """ - rng = random.Random((int(key) * 1103515245 + 0x6D2B79F5) & 0xFFFFFFFF) - role_fields = {'A': 10, 'B': 5, 'C': 4, 'K1': 9, 'K2': 8, 'K3': 7} + defs = {} + for pc, op in p[3].items(): + spec = _spec(op) + if not spec: + continue + roles = spec['roles'] + dst = None + for role in ('dst', 'mid', 'dst2'): + if role in roles: + dst = _fget(p, roles[role], pc) + break + if dst is None: + continue + defs.setdefault(dst, []).append((op, roles, pc)) + numeric = set() + num_ops = {0x04, 220, 108, 43, 147, 45, 0x10B, 0x111, 110, 126} + # Numeric-for prologue/step initialise the limit, step and index as + # numbers; a successful step keeps the index numeric. + for pc, op in p[3].items(): + if op in (0x10C, 0x10D): + base = _fget(p, 'A', pc) + if isinstance(base, (int, float)) and not isinstance(base, bool): + offs = (0, 1, 2) if op == 0x10C else (2,) + for off in offs: + defs.setdefault(int(base) + off, []).append((op, {}, pc)) + for _ in range(len(p[3]) + 4): + changed = False + for reg, entries in defs.items(): + if reg in numeric: + continue + ok = True + for op, roles, pc in entries: + if op == 81: # MOVE + src = _fget(p, roles['src'], pc) + if src not in numeric: + ok = False + break + elif op in num_ops: + good = True + for role in ('lhs', 'rhs'): + if role in roles: + src = _fget(p, roles[role], pc) + if src not in numeric: + good = False + if not good: + ok = False + break + elif op in (16,): # LOADK + k = _fget(p, roles['k'], pc) + if isinstance(k, float): + ok = ok and True + elif isinstance(k, int) and not isinstance(k, bool): + ok = ok and True + else: + ok = False + break + elif op == 0x12A: # OPAQUE + pass + elif op in (0x10C, 0x10D): # numeric-for state + pass + else: + ok = False + break + if ok and entries: + numeric.add(reg) + changed = True + if not changed: + break + return numeric + + +def _defined_at(p): + """Must-defined register sets per pc (forward CFG dataflow). + + Used with _numeric_registers so an MBA rewrite never touches a register + that is undefined on some incoming path. + """ + code = p.get(3) or {} + if not code: + return {} + maxpc = max(code) + params = p.get(1) or 0 + succ = {} + for pc, op in code.items(): + f = _branch_target_field(op) + s = _spec(op) + name = s['name'] if s else '' + if name in ('RET0', 'RET1', 'XRET', 'XRETMULTI'): + succ[pc] = [] + elif f: + target = p.get(FIELD[f], {}).get(pc) + edges = [] + if isinstance(target, (int, float)): + edges.append(int(target) + 1) + if name != 'JMP': + edges.append(pc + 1) + succ[pc] = [x for x in edges if 1 <= x <= maxpc] + else: + succ[pc] = [pc + 1] if pc + 1 <= maxpc else [] + ins = {1: set(range(params))} + changed = True + rounds = 0 + while changed and rounds <= maxpc + 2: + changed = False + rounds += 1 + for pc in sorted(code): + if pc not in ins: + continue + op = code[pc] + s = _spec(op) + out = set(ins[pc]) + if s: + for role in ('dst', 'mid', 'dst2', 'reg'): + if role in s['roles']: + r = p.get(FIELD[s['roles'][role]], {}).get(pc) + if isinstance(r, (int, float)): + out.add(int(r)) + if op in (46, 171, 106, 44, 69, 73, 0x107): + base = None + if 'base' in s['roles']: + base = p.get(FIELD[s['roles']['base']], {}).get(pc) + if isinstance(base, (int, float)): + out.add(int(base)) + if op == 157: # CLOSE clears a register range + lo = p.get(FIELD['B'], {}).get(pc) + hi = p.get(FIELD['A'], {}).get(pc) + if isinstance(lo, (int, float)) and isinstance(hi, (int, float)): + for r in range(int(lo), int(hi) + 1): + out.discard(r) + for nxt in succ.get(pc, []): + if nxt not in ins: + ins[nxt] = set(out) + changed = True + else: + keep = ins[nxt] & out + if keep != ins[nxt]: + ins[nxt] = keep + changed = True + return ins + + +def _mba_graph(graph, key=0): + """Additive-inverse and comparison-swap MBA rewrites. + + Only registers proven numeric by _numeric_registers are rewritten, so + metamethod dispatch, string coercion and error ordering are unchanged. + Integer LOADK constants in a subset of prototypes are split into two + exact integer terms and recombined by the VM's LOADKSUM handler. + """ + rng = random.Random((int(key) * 2246822519 + 0x27D4EB2F) & 0xFFFFFFFF) for pid, p in graph.get('prototypes', {}).items(): code = p.get(3) or {} - targets = set() - for pc, op in code.items(): - spec = next((v for v in isa.OPS.values() if v['op'] == op), None) + if not code: + continue + numeric = _numeric_registers(p) + defined = _defined_at(p) + + def okreg(pc, *regs): + live = defined.get(pc, set()) + return all(r in live for r in regs) + + for pc in sorted(code): + op = code[pc] + spec = _spec(op) if not spec: continue - field = spec['roles'].get('target') - if field in p: - value = p[field].get(pc) - if value is not None: - targets.add(value + 1) + roles = spec['roles'] + dst = _fget(p, roles.get('dst'), pc) if 'dst' in roles else None + if op == 108: # SUB a b -> a + (-b) + lhs = _fget(p, roles['lhs'], pc) + rhs = _fget(p, roles['rhs'], pc) + if (dst is not None and lhs in numeric and rhs in numeric + and okreg(pc, lhs, rhs)): + code[pc] = 0x12B + _fclear(p, pc) + _fset(p, 'A', pc, dst) + _fset(p, 'B', pc, lhs) + _fset(p, 'C', pc, rhs) + elif op == 220 or op == 4: # ADD a b -> a - (-b) + lhs = _fget(p, roles['lhs'], pc) + rhs = _fget(p, roles['rhs'], pc) + if (dst is not None and lhs in numeric and rhs in numeric + and okreg(pc, lhs, rhs)): + code[pc] = 0x12C + _fclear(p, pc) + _fset(p, 'A', pc, dst) + _fset(p, 'B', pc, lhs) + _fset(p, 'C', pc, rhs) + elif op == 110: # ADDK dst lhs k -> SUBK dst lhs -k + lhs = _fget(p, roles['lhs'], pc) + k = _fget(p, roles['k'], pc) + if (dst is not None and lhs in numeric and okreg(pc, lhs) + and isinstance(k, (int, float)) and not isinstance(k, bool) and k != 0): + code[pc] = 126 + _fclear(p, pc) + _fset(p, 'C', pc, dst) + _fset(p, 'B', pc, lhs) + _fset(p, 'K3', pc, -float(k)) + elif op == 126: # SUBK dst lhs k -> ADDK dst lhs -k + lhs = _fget(p, roles['lhs'], pc) + k = _fget(p, roles['k'], pc) + if (dst is not None and lhs in numeric and okreg(pc, lhs) + and isinstance(k, (int, float)) and not isinstance(k, bool) and k != 0): + code[pc] = 110 + _fclear(p, pc) + _fset(p, 'B', pc, dst) + _fset(p, 'A', pc, lhs) + _fset(p, 'K2', pc, -float(k)) + elif op == 141: # GT a b -> LT b a + lhs = _fget(p, roles['lhs'], pc) + rhs = _fget(p, roles['rhs'], pc) + if (dst is not None and lhs in numeric and rhs in numeric + and okreg(pc, lhs, rhs)): + code[pc] = 0x12D + _fclear(p, pc) + _fset(p, 'A', pc, dst) + _fset(p, 'B', pc, rhs) + _fset(p, 'C', pc, lhs) + elif op == 94: # LE a b -> GE b a + lhs = _fget(p, roles['lhs'], pc) + rhs = _fget(p, roles['rhs'], pc) + if (dst is not None and lhs in numeric and rhs in numeric + and okreg(pc, lhs, rhs)): + code[pc] = 166 + _fclear(p, pc) + _fset(p, 'B', pc, dst) + _fset(p, 'A', pc, rhs) + _fset(p, 'C', pc, lhs) + # Exact integer constant split: LOADK k becomes LOADKSUM k1 k2. + if rng.random() < 0.45: + for pc in sorted(code): + if code.get(pc) != 16: + continue + k = p.get(7, {}).get(pc) + if (isinstance(k, bool) or not isinstance(k, (int, float)) + or k != int(k) or abs(k) < 8 or abs(k) >= (1 << 40)): + continue + dst = p.get(5, {}).get(pc) + if dst is None or rng.random() > 0.17: + continue + k = int(k) + if k >= 0 and k < (1 << 30) and rng.random() < 0.7: + x = rng.randrange(0, k + 1) + y = k - x + code[pc] = 0x12F + _fclear(p, pc) + _fset(p, 'A', pc, dst) + _fset(p, 'K1', pc, float(x)) + _fset(p, 'K2', pc, float(y)) + else: + k1 = rng.randrange(-(1 << 20), 1 << 20) + if k1 == 0: + k1 = 7 + k2 = k - k1 + code[pc] = 0x12E + _fclear(p, pc) + _fset(p, 'A', pc, dst) + _fset(p, 'K1', pc, float(k1)) + _fset(p, 'K2', pc, float(k2)) + return graph + + +def _fuse_pairs(graph, key=0): + """Fuse adjacent instruction pairs into two-slot superinstructions. + + The second slot becomes XNOP, so every existing pc and every jump target + keeps its meaning. Fusions are skipped when the second slot is a branch + target or when either opcode is itself multi-slot. + """ + rng = random.Random((int(key) * 1103515245 + 0x6D2B79F5) & 0xFFFFFFFF) + for pid, p in graph.get('prototypes', {}).items(): + code = p.get(3) or {} + if len(code) < 2: + continue + targets = _targets(p) for pc in sorted(list(code)): - if code.get(pc) != 98 or code.get(pc + 1) != 0: + a = code.get(pc) + b = code.get(pc + 1) + if a is None or b is None or a in WIDE_OPS or b in WIDE_OPS: continue if pc + 1 in targets: continue - first_dst = p.get(5, {}).get(pc) - second_obj = p.get(4, {}).get(pc + 1) - if first_dst is None or second_obj != first_dst: + newop = None + fields = None + if a == 98 and b == 0: + d1 = _fget(p, 'B', pc) + if d1 is not None and _fget(p, 'C', pc + 1) == d1: + newop = 0x120 + fields = {'B': d1, 'A': _fget(p, 'A', pc + 1), + 'K1': _fget(p, 'K2', pc), + 'K2': _fget(p, 'K1', pc + 1)} + elif a == 0 and b == 0: + d1 = _fget(p, 'A', pc) + if d1 is not None and _fget(p, 'C', pc + 1) == d1: + newop = 0x121 + fields = {'A': d1, 'B': _fget(p, 'A', pc + 1), + 'C': _fget(p, 'C', pc), + 'K1': _fget(p, 'K1', pc), + 'K2': _fget(p, 'K1', pc + 1)} + elif a == 81 and b == 81: + newop = 0x122 + fields = {'A': _fget(p, 'A', pc), 'B': _fget(p, 'B', pc), + 'K1': _fget(p, 'A', pc + 1), 'K2': _fget(p, 'B', pc + 1)} + elif a == 16 and b == 16: + newop = 0x123 + fields = {'B': _fget(p, 'B', pc), 'K3': _fget(p, 'K3', pc), + 'A': _fget(p, 'B', pc + 1), 'K1': _fget(p, 'K3', pc + 1)} + elif a == 25 and b == 25: + newop = 0x124 + fields = {'B': _fget(p, 'B', pc), 'C': _fget(p, 'C', pc), + 'K2': _fget(p, 'K3', pc), + 'A': _fget(p, 'B', pc + 1), 'K1': _fget(p, 'C', pc + 1), + 'K3': _fget(p, 'K3', pc + 1)} + elif a == 117 and b == 0: + d1 = _fget(p, 'A', pc) + if d1 is not None and _fget(p, 'C', pc + 1) == d1: + newop = 0x125 + fields = {'B': d1, 'A': _fget(p, 'A', pc + 1), + 'C': _fget(p, 'B', pc), 'K1': _fget(p, 'K1', pc + 1)} + elif a == 35 and b == 25: + d1 = _fget(p, 'A', pc) + if d1 is not None and _fget(p, 'C', pc + 1) == d1: + newop = 0x126 + fields = {'A': d1, 'B': _fget(p, 'B', pc + 1), + 'K3': _fget(p, 'K3', pc + 1)} + elif a in (165, 127) and b in (14, 72): + d1 = _fget(p, 'B', pc) + if d1 is not None and _fget(p, 'B', pc + 1) == d1: + tfield = 'C' if b == 14 else 'A' + target = _fget(p, tfield, pc + 1) + if target is not None: + mode = (0 if a == 165 else 1) | (0 if b == 14 else 2) + newop = 0x127 + fields = {'B': d1, 'A': _fget(p, 'A', pc), + 'C': _fget(p, 'C', pc), + 'K1': target, 'K2': float(mode)} + if newop is None: continue - global_k = p.get(8, {}).get(pc) - field_k = p.get(9, {}).get(pc + 1) - second_dst = p.get(10, {}).get(pc + 1) - if global_k is None or field_k is None or second_dst is None: + if any(v is None for v in fields.values()): continue - code[pc] = 0x120 - p.setdefault(9, {})[pc] = global_k - p.setdefault(8, {})[pc] = field_k - p.setdefault(10, {})[pc] = second_dst + code[pc] = newop + _fclear(p, pc) + for name, value in fields.items(): + _fset(p, name, pc, value) code[pc + 1] = 0x108 - for f in (4, 5, 7, 8, 9, 10): - if f != 5: - p.get(f, {}).pop(pc + 1, None) - jmps = [pc for pc, op in code.items() if op == 173 and pc in p.get(5, {})] - if not jmps: + _fclear(p, pc + 1) + return graph + + +def _targets(p): + out = set() + for pc, op in (p.get(3) or {}).items(): + f = _branch_target_field(op) + if not f: continue - rng.shuffle(jmps) - maxpc = max(code) if code else 0 - for pc in jmps[:rng.randint(0, min(3, len(jmps)))]: - old_target = p[5][pc] - maxpc += 1 - code[maxpc] = 173 - p.setdefault(5, {})[maxpc] = old_target - p[5][pc] = maxpc - 1 - # Unreachable padding after the final return. It is never targeted; it - # only makes static instruction-count/CFG recovery noisier. + value = p.get(FIELD[f], {}).get(pc) + if isinstance(value, (int, float)) and not isinstance(value, bool): + out.add(int(value) + 1) + return out + + +def _insert_opaque(graph, key=0): + """Insert pc-derived opaque guards in front of selected entry points. + + The guard condition is always truthy, but is computed from pc and a + per-build salt inside the VM. The false edge leads to a short decoy block + of numeric operations on registers above the prototype's stack; the decoy + rejoins the guarded instruction. + """ + rng = random.Random((int(key) * 2654435761 + 0x7F4A7C15) & 0xFFFFFFFF) + for pid, p in graph.get('prototypes', {}).items(): + code = p.get(3) or {} + if len(code) < 6 or rng.random() > 0.55: + continue + targets = sorted(_targets(p)) + spots = {1} + if targets and rng.random() < 0.7: + spots.add(rng.choice(targets)) + spots = sorted(x for x in spots if x in code and x >= 1) + if not spots: + continue + stack = p.get(6) or 0 + tmp, d1, d2 = stack + 40, stack + 41, stack + 42 + records = [] + old_pc = {} + entry_pc = {} + maxpc = max(code) + for pc in range(1, maxpc + 1): + op = code.get(pc) + if op is None: + continue + if pc in spots: + k = rng.randint(1, 3) + pnew = len(records) + 1 + 4 + k + decoy = len(records) + 4 + entry_pc[pc] = len(records) + 1 + records.append({'op': 0x12A, + 'fields': {'A': tmp, + 'K1': float(rng.randrange(1, 1 << 20))}, + 'target': None}) + records.append({'op': 14, + 'fields': {'B': tmp, 'C': float(pnew - 1)}, + 'target': None}) + records.append({'op': 173, + 'fields': {'B': float(decoy - 1)}, + 'target': None}) + for j in range(k): + r = d1 if j % 2 == 0 else d2 + records.append({'op': 16, + 'fields': {'B': r, + 'K3': float(rng.randrange(1, 97))}, + 'target': None}) + records.append({'op': 173, + 'fields': {'B': float(pnew - 1)}, + 'target': None}) + old_pc[pc] = len(records) + 1 + rec = {'op': op, 'fields': {}, 'target': None} + f = _branch_target_field(op) + for name in ('A', 'B', 'C', 'K1', 'K2', 'K3'): + if pc in p.get(FIELD[name], {}): + rec['fields'][name] = p[FIELD[name]][pc] + if f and f in rec['fields']: + rec['old_target'] = rec['fields'].pop(f) + records.append(rec) + for rec in records: + if 'old_target' in rec: + ot = int(rec['old_target']) + 1 + nt = entry_pc.get(ot) or old_pc.get(ot) + if nt is None: + raise CompileError('opaque insertion lost target %d' % ot) + f = _branch_target_field(rec['op']) + rec['fields'][f] = float(nt - 1) + code.clear() + for f in FIELD.values(): + p.get(f, {}).clear() + for i, rec in enumerate(records): + pc = i + 1 + code[pc] = rec['op'] + for name, value in rec['fields'].items(): + _fset(p, name, pc, value) + return graph + + +def _flatten_proto(p, rng): + """State-machine flattening for one prototype. + + Blocks are permuted and every edge is forced through a jump-table + dispatcher that reads a state register outside the prototype's stack. + """ + code = dict(p.get(3) or {}) + if not code: + return False + maxpc = max(code) + targets = _targets(p) + starts = {1} + for t in targets: + if 1 <= t <= maxpc: + starts.add(t) + for pc, op in code.items(): + if _branch_target_field(op): + nxt = pc + (2 if op in WIDE_OPS else 1) + if nxt <= maxpc: + starts.add(nxt) + starts = sorted(starts) + blocks = [] + for i, s in enumerate(starts): + e = (starts[i + 1] - 1) if i + 1 < len(starts) else maxpc + blocks.append((s, e)) + starts_map = {s: idx + 1 for idx, (s, e) in enumerate(blocks)} + order = list(range(len(blocks))) + rng.shuffle(order) + state = (p.get(6) or 0) + 128 + disp = 2 + # Dispatcher: pc1 loads the entry id, pc2 jumps through the table. + out = [] + out.append({'op': 16, 'fields': {'B': state, 'K3': float(starts_map[1])}, + 'branch': None}) + out.append({'op': 0x129, 'fields': {'A': state}, 'branch': None}) + + def emit_tail(target_pc): + key = rng.randrange(1, 1 << 22) + out.append({'op': 0x128, + 'fields': {'K1': float(target_pc - 1 + key), 'K2': float(key)}, + 'branch': None}) + + def block_id_of(pc): + for bi, (s, e) in enumerate(blocks): + if s <= pc <= e: + return bi + 1 + return None + + new_start = {} + for bi in order: + s, e = blocks[bi] + new_start[bi + 1] = len(out) + 1 + last = e + while last > s and code.get(last) == 0x108: + last -= 1 + # XNOP is copied with the pair, but not treated as the logical tail. + for pc in range(s, e + 1): + if pc == last: + if code.get(pc) == 0x108: + continue + op = code.get(pc) + if op is None: + continue + rec = {'op': op, 'fields': {}, 'branch': None} + for name in ('A', 'B', 'C', 'K1', 'K2', 'K3'): + if pc in p.get(FIELD[name], {}): + rec['fields'][name] = p[FIELD[name]][pc] + # Branch targets are stored as block ids for the patch pass. + f = _branch_target_field(op) + if f and f in rec['fields']: + tgt_old = int(rec['fields'].pop(f)) + 1 + bid = block_id_of(tgt_old) + if bid is None: + return False + rec['branch'] = (f, bid) + rec['fields'][f] = 0.0 + out.append(rec) + op = code.get(last) + if op in (65, 58, 0x100, 0x101): + continue + if op == 173: + # Replace the unconditional jump with a state load + indirect jump. + tgt_old = int(p.get(FIELD['B'], {}).get(last, -1)) + 1 + bid = block_id_of(tgt_old) + if bid is None: + return False + out[-1] = {'op': 16, 'fields': {'B': state, 'K3': float(bid)}, 'branch': None} + emit_tail(disp) + continue + # Conditional or straight-line block: dispatch the fallthrough. + ft_old = last + (2 if op in WIDE_OPS else 1) + bid = block_id_of(ft_old) + if bid is None: + out.append({'op': 65, 'fields': {}, 'branch': None}) + continue + out.append({'op': 16, 'fields': {'B': state, 'K3': float(bid)}, 'branch': None}) + emit_tail(disp) + # Patch branch targets now that all block starts are known. + for rec in out: + if rec['branch']: + f, bid = rec['branch'] + rec['fields'][f] = float(new_start[bid] - 1) + code.clear() + for f in FIELD.values(): + p.get(f, {}).clear() + for i, rec in enumerate(out): + pc = i + 1 + code[pc] = rec['op'] + for name, value in rec['fields'].items(): + _fset(p, name, pc, value) + p[3] = code + p[15] = [new_start[i + 1] for i in range(len(blocks))] + return True + + +def _flatten_graph(graph, key=0): + rng = random.Random((int(key) * 3266489917 + 0x9E3779B9) & 0xFFFFFFFF) + for pid, p in graph.get('prototypes', {}).items(): + code = p.get(3) or {} + if not (14 <= len(code) <= 600): + continue + if rng.random() > 0.38: + continue + _flatten_proto(p, rng) + return graph + + +def _trampolines(graph, key=0): + rng = random.Random((int(key) * 1103515245 + 0x51ED270B) & 0xFFFFFFFF) for pid, p in graph.get('prototypes', {}).items(): code = p.get(3) or {} if not code: continue + jmps = [pc for pc, op in code.items() if op == 173 and pc in p.get(5, {})] + rng.shuffle(jmps) maxpc = max(code) + for pc in jmps: + if rng.random() > 0.6: + continue + hops = rng.randint(1, 2) + final = int(p[5][pc]) + chain = [] + for _ in range(hops): + maxpc += 1 + chain.append(maxpc) + # Last hop targets the original destination. + target = final + for hop in reversed(chain): + keyv = rng.randrange(1, 1 << 22) + code[hop] = 0x128 + _fclear(p, hop) + _fset(p, 'K1', hop, float(target + keyv)) + _fset(p, 'K2', hop, float(keyv)) + target = hop - 1 + p[5][pc] = float(chain[0] - 1) + # Unreachable padding after the last instruction. for _ in range(rng.randint(1, 3)): maxpc += 1 code[maxpc] = 16 - p.setdefault(5, {})[maxpc] = (p.get(6) or 0) + 1 - p.setdefault(7, {})[maxpc] = rng.randrange(-1000, 1000) + _fclear(p, maxpc) + _fset(p, 'B', maxpc, (p.get(6) or 0) + 1) + _fset(p, 'K3', maxpc, float(rng.randrange(-1000, 1000))) + return graph + + +def obfuscate_graph(graph: dict, key: int = 0) -> dict: + """Apply the devirtualization-resistance pipeline. + + 1. MBA rewrites on provably numeric registers. + 2. Pair fusion into two-slot superinstructions (GETTABLECHAIN, MOVE2, + LOADK2, SET2, UPCHAIN, NEWTABLESET, CMPJMP, ...). + 3. pc-derived opaque predicate guards. + 4. Selective state-machine flattening with a jump-table dispatcher. + 5. Encoded jump trampolines and unreachable padding. + """ + _mba_graph(graph, key) + _fuse_pairs(graph, key) + _insert_opaque(graph, key) + _flatten_graph(graph, key) + _trampolines(graph, key) return graph def compile_source(source: str, enc: bool = True, key: int = 0, keep_graph: str | None = None, hardened: bool = False, binary: bool = False, fast: bool = False) -> str: diff -ruN -x __pycache__ -x PATCH.diff baseline/luraph_compiler/handler_runtime.py agent2/luraph_compiler/handler_runtime.py --- baseline/luraph_compiler/handler_runtime.py 2026-10-23 04:00:04.213170420 +0000 +++ agent2/luraph_compiler/handler_runtime.py 2026-10-23 06:08:13.094861702 +0000 @@ -78,7 +78,7 @@ def _random_handler_names(rng): fields = ('ctx', 'q', 'V', 'top', 'ups', 'va', 'nva', 'rb', 'K', 'pack', 'unpack', 'cell', 'cellval', 'cellput', 'mkclos', 'truthy', - 'ENV', 'callwith', 'CELL_MT', 'pc', 'ins', 'boxed') + 'ENV', 'callwith', 'CELL_MT', 'pc', 'ins', 'boxed', 'jt', 'pid') out = {} used = set() for old in fields: @@ -105,6 +105,7 @@ "local K,pack,unpack,cell,cellval,cellput,mkclos,truthy,ENV,callwith,CELL_MT,boxed=" "ctx.K,ctx.pack,ctx.unpack,ctx.cell,ctx.cellval,ctx.cellput,ctx.mkclos,ctx.truthy,ctx.ENV,ctx.callwith,ctx.CELL_MT,ctx.boxed\n" "local pc=ctx.pc\n" + "local jt,pid=ctx.jt,ctx.pid\n" "if ctx.q==nil then return end\n" + body + "\nctx.pc=pc\nctx.top=top\nreturn\nend\n" @@ -114,6 +115,7 @@ "local K,pack,unpack,cell,cellval,cellput,mkclos,truthy,ENV,callwith,CELL_MT,boxed=" "ctx.K,ctx.pack,ctx.unpack,ctx.cell,ctx.cellval,ctx.cellput,ctx.mkclos,ctx.truthy,ctx.ENV,ctx.callwith,ctx.CELL_MT,ctx.boxed\n" "local pc=ctx.pc\n" + "local jt,pid=ctx.jt,ctx.pid\n" "if ctx.q==nil then return end\n" + body + "\nend\n" ) @@ -203,7 +205,7 @@ V.ensure(p) end local code = p.code - local n = #code + local n = p.npc or #code local pc = 1 local top = rb - 1 local ctx = { @@ -211,10 +213,11 @@ top = top, pc = pc, K = K, pack = pack, unpack = unpack, cell = cell, cellval = cellval, cellput = cellput, mkclos = mkclos, truthy = truthy, ENV = ENV, callwith = callwith, CELL_MT = CELL_MT, - boxed = p.boxed or {}, + boxed = p.boxed or {}, jt = p.jt or {}, pid = pid, } while pc >= 1 and pc <= n do - local ins = code[pc] + local ins = V.fetch(p, pc) + if not ins then error('', 0) end local op = V.opmap and V.opmap[ins[1]] or bit32.bxor(ins[1], V.opkey) local rec = h_get(op) ctx.pc, ctx.top = pc, top diff -ruN -x __pycache__ -x PATCH.diff baseline/luraph_compiler/isa.py agent2/luraph_compiler/isa.py --- baseline/luraph_compiler/isa.py 2026-10-23 04:02:07.758486824 +0000 +++ agent2/luraph_compiler/isa.py 2026-10-23 06:28:54.862041855 +0000 @@ -50,6 +50,41 @@ 'q[A] = q[C][K1]', 958) _op('GETCHAIN', 0x120, {'base': 'B', 'dst': 'A', 'k1': 'K1', 'k2': 'K2'}, 'extension', 'q[B] = _ENV[K1]; q[A] = q[B][K2]') +_op('GETTABLECHAIN', 0x121, {'mid': 'A', 'dst': 'B', 'obj': 'C', + 'k1': 'K1', 'k2': 'K2'}, 'extension', + 'q[A] = q[C][K1]; q[B] = q[A][K2]') +_op('MOVE2', 0x122, {'src': 'A', 'dst': 'B', 'src2': 'K1', 'dst2': 'K2'}, + 'extension', 'q[B] = q[A]; q[K2] = q[K1]') +_op('LOADK2', 0x123, {'dst2': 'A', 'dst': 'B', 'k2': 'K1', 'k1': 'K3'}, + 'extension', 'q[B] = K(K3); q[A] = K(K1)') +_op('SET2', 0x124, {'val2': 'A', 'val': 'B', 'obj': 'C', 'obj2': 'K1', + 'k1': 'K2', 'k2': 'K3'}, 'extension', + 'q[C][K(K2)] = q[B]; q[K1][K(K3)] = q[A]') +_op('UPCHAIN', 0x125, {'dst': 'A', 'mid': 'B', 'cell': 'C', 'k': 'K1'}, + 'extension', 'q[B] = up[C]; q[A] = q[B][K1]') +_op('NEWTABLESET', 0x126, {'dst': 'A', 'val': 'B', 'k': 'K3'}, + 'extension', 'q[A] = {}; q[A][K3] = q[B]') +_op('CMPJMP', 0x127, {'dst': 'B', 'lhs': 'A', 'rhs': 'C', + 'target': 'K1', 'mode': 'K2'}, 'extension', + 'q[B] = compare; branch on comparison result (mode selects eq/ne and ' + 'branch sense); advances two pc slots') +_op('JMPX', 0x128, {'hi': 'K1', 'lo': 'K2'}, 'extension', + 'pc = (K1 - K2) + 1; encoded unconditional jump') +_op('JMPD', 0x129, {'reg': 'A'}, 'extension', + 'pc = proto.jt[q[A]]; jump-table dispatch for flattened prototypes') +_op('OPAQUE', 0x12A, {'dst': 'A', 'salt': 'K1'}, 'extension', + 'q[A] = ((bxor(pc, salt) % 2) + 1), always truthy, pc-derived') +_op('ADDNEG', 0x12B, {'dst': 'A', 'lhs': 'B', 'rhs': 'C'}, 'extension', + 'q[A] = q[B] + (-q[C]); additive-inverse MBA of SUB') +_op('SUBNEG', 0x12C, {'dst': 'A', 'lhs': 'B', 'rhs': 'C'}, 'extension', + 'q[A] = q[B] - (-q[C]); additive-inverse MBA of ADD') +_op('LT', 0x12D, {'dst': 'A', 'lhs': 'B', 'rhs': 'C'}, 'extension', + 'q[A] = q[B] < q[C]') +_op('LOADKSUM', 0x12E, {'dst': 'A', 'k1': 'K1', 'k2': 'K2'}, 'extension', + 'q[A] = K(K1) + K(K2); exact integer split of a LOADK constant') +_op('LOADKMBA', 0x12F, {'dst': 'A', 'x': 'K1', 'y': 'K2'}, 'extension', + 'q[A] = bxor(x, y) + 2*band(x, y); exact MBA form of x + y for ' + 'non-negative int32 constants') _op('GETTABLE_RR', 119, {'dst': 'C', 'obj': 'A', 'key': 'B'}, 'verified', 'q[C] = q[A][q[B]]', 9) _op('SETTABLE', 25, {'obj': 'C', 'k': 'K3', 'val': 'B'}, 'verified', diff -ruN -x __pycache__ -x PATCH.diff baseline/luraph_compiler/vm_runtime.luau agent2/luraph_compiler/vm_runtime.luau --- baseline/luraph_compiler/vm_runtime.luau 2026-10-23 04:08:20.756442076 +0000 +++ agent2/luraph_compiler/vm_runtime.luau 2026-10-23 06:19:09.044541581 +0000 @@ -146,12 +146,14 @@ V.ensure(p) end local code = p.code + local jt = p.jt or {} local boxed = p.boxed or {} - local n = #code + local n = p.npc or #code local pc = 1 local top = rb - 1 while pc >= 1 and pc <= n do - local ins = code[pc] + local ins = V.fetch(p, pc) + if not ins then error('', 0) end local op = V.opmap and V.opmap[ins[1]] or bit32.bxor(ins[1], V.opkey) if op == 16 then -- LOADK q[ins.B] = K(ins.K3) @@ -490,6 +492,68 @@ pc = pc + 1 elseif op == 134 or op == 0x108 then -- PARAMCOPY / XNOP pc = pc + 1 + elseif op == 0x121 then -- GETTABLECHAIN + q[ins.A] = q[ins.C][K(ins.K1)] + q[ins.B] = q[ins.A][K(ins.K2)] + pc = pc + 2 + elseif op == 0x122 then -- MOVE2 + q[ins.B] = q[ins.A] + q[ins.K2] = q[ins.K1] + pc = pc + 2 + elseif op == 0x123 then -- LOADK2 + q[ins.B] = K(ins.K3) + q[ins.A] = K(ins.K1) + pc = pc + 2 + elseif op == 0x124 then -- SET2 + q[ins.C][K(ins.K2)] = q[ins.B] + q[ins.K1][K(ins.K3)] = q[ins.A] + pc = pc + 2 + elseif op == 0x125 then -- UPCHAIN + q[ins.B] = ups[ins.C + 1].v + q[ins.A] = q[ins.B][K(ins.K1)] + pc = pc + 2 + elseif op == 0x126 then -- NEWTABLESET + q[ins.A] = {} + q[ins.A][K(ins.K3)] = q[ins.B] + pc = pc + 2 + elseif op == 0x127 then -- CMPJMP + local mode = ins.K2 % 4 + local val + if mode % 2 == 0 then + val = q[ins.A] == q[ins.C] + else + val = q[ins.A] ~= q[ins.C] + end + q[ins.B] = val + local take = truthy(val) + if mode >= 2 then + take = not take + end + pc = take and ins.K1 + 1 or pc + 2 + elseif op == 0x128 then -- JMPX + pc = ins.K1 - ins.K2 + 1 + elseif op == 0x129 then -- JMPD + local dest = jt[cellval(q[ins.A])] + if not dest then error('', 0) end + pc = dest + elseif op == 0x12A then -- OPAQUE + q[ins.A] = (bit32.band(bit32.bxor(pc, ins.K1), 0xffffffff) % 2) + 1 + pc = pc + 1 + elseif op == 0x12B then -- ADDNEG + q[ins.A] = q[ins.B] + (-q[ins.C]) + pc = pc + 1 + elseif op == 0x12C then -- SUBNEG + q[ins.A] = q[ins.B] - (-q[ins.C]) + pc = pc + 1 + elseif op == 0x12D then -- LT + q[ins.A] = q[ins.B] < q[ins.C] + pc = pc + 1 + elseif op == 0x12E then -- LOADKSUM + q[ins.A] = K(ins.K1) + K(ins.K2) + pc = pc + 1 + elseif op == 0x12F then -- LOADKMBA + q[ins.A] = bit32.bxor(ins.K1, ins.K2) + 2 * bit32.band(ins.K1, ins.K2) + pc = pc + 1 else error('', 0) end @@ -558,7 +622,7 @@ local s = table.concat(bytes) if s:sub(1, 4) ~= 'SGB2' then error('', 0) end local version, key_raw, regbase, flag = s:byte(5, 8) - if version ~= 2 then error('', 0) end + if version ~= 2 and version ~= 3 then error('', 0) end local key = bit32.bxor(key_raw, bit32.band(regbase * 37 + 0xA5, 255)) local state = bit32.band(key * 257 + regbase * 17 + 0x5EC20, 0xffffffff) local plain = {} @@ -585,6 +649,7 @@ field_inv = field_inv, enc = bit32.band(flag, 1) == 1, lazy = bit32.band(flag, 2) == 2, + version = version, key = key, regbase = regbase, opkey = opkey, @@ -604,6 +669,14 @@ for i = 1, nbox do boxed[i], p = b_u32(d, p) end + local jt = {} + if version >= 3 then + local njt + njt, p = b_u32(d, p) + for i = 1, njt do + jt[i], p = b_u32(d, p) + end + end code_len, p = b_u32(d, p) code_seed, p = b_u8(d, p) local code_enc = d:sub(p, p + code_len - 1) @@ -615,8 +688,10 @@ stack = stack, regbase = proto_rb, boxed = boxed, + jt = jt, code_enc = code_enc, code_seed = code_seed, + sealed = version >= 3, } end local nop @@ -671,6 +746,25 @@ local q = 1 local maxpc maxpc, q = b_u32(d, q) + if p.sealed then + local bsize + bsize, q = b_u8(d, q) + local nb + nb, q = b_u32(d, q) + local blocks = {} + for b = 1, nb do + local seed, n + seed, q = b_u8(d, q) + n, q = b_u32(d, q) + blocks[b] = {k = seed, s = d:sub(q, q + n - 1)} + q = q + n + end + p.bsize = bsize + p.blocks = blocks + p.npc = maxpc + p.code = {} + return + end local code = {} for pc = 1, maxpc do local opcode @@ -690,9 +784,65 @@ end code[pc] = ins end + p.npc = maxpc p.code = code end +-- Per-block lazy instruction decode. A sealed block keeps its operand +-- stream encrypted until execution first enters one of its pcs. +local function decode_block(p, b) + local blk = p.blocks and p.blocks[b] + if not blk or blk.done then + return + end + local s = blk.s or '' + local state = bit32.band((blk.k or 0) * 257 + ((p.pid or 0) * 131 + b) * 17 + 0x5EC20, 0xffffffff) + local plain = table.create(#s) + for i = 1, #s do + state = b_xs(state) + plain[i] = string.char(bit32.bxor(s:byte(i), bit32.band(state, 255))) + end + local d = table.concat(plain) + local q = 1 + local first = (b - 1) * (p.bsize or 8) + 1 + local count = math.min(p.bsize or 8, (p.npc or 0) - first + 1) + local code = p.code + for j = 0, count - 1 do + local opcode + opcode, q = b_u32(d, q) + local nf + nf, q = b_u8(d, q) + local ins = {opcode} + for _ = 1, nf do + local fid, val + fid, q = b_u8(d, q) + val, q = b_val(d, q) + local idx = V.field_inv and V.field_inv[fid] or fid + local name = FIELD_NAMES[idx + 1] + if name then + ins[name] = val + end + end + code[first + j] = ins + end + blk.done = true + blk.s = nil +end + +function V.fetch(p, pc) + local code = p.code + local ins = code and code[pc] + if ins then + return ins + end + if not p.blocks then + return nil + end + local bsize = p.bsize or 8 + decode_block(p, math.floor((pc - 1) / bsize) + 1) + return p.code[pc] +end + V.ensure = ensure_code local M = {} @@ -711,6 +861,11 @@ if not graph.lazy then for _, p in pairs(V.protos) do V.ensure(p) + if p.blocks and p.code then + for pc = 1, p.npc or 0 do + V.fetch(p, pc) + end + end end end return M diff -ruN -x __pycache__ -x PATCH.diff baseline/NOTES.md agent2/NOTES.md --- baseline/NOTES.md 1970-01-01 00:00:00.000000000 +0000 +++ agent2/NOTES.md 2026-10-23 06:30:06.412225097 +0000 @@ -0,0 +1,212 @@ +# Devirtualization-resistance prototype (agent2) + +Scope: `/tmp/opencode/obf_work/agent2` only. Baseline untouched. +Files changed: `luraph_compiler/{compiler.py,isa.py,vm_runtime.luau,handler_runtime.py}`. +`OBFUSCATOR-FIXES.md`, `sample.lua`, `luraph_mode.py`, `secro_mode.py` and +`luraph_pack/*` are unchanged; the Secro outer/carrier path is untouched. + +## 1. Superinstruction fusion + +The existing `GETGLOBAL+GETTABLE -> GETCHAIN` pass was generalized into a +pair-fusion pass. All fused ops are two slots wide: the second slot is +rewritten to `XNOP`, the handler advances `pc` by two, and no pc or jump +target moves. The second slot is never fused when it is a branch target. +Fusions never cross a wide op. + +Added graph opcodes (extensions, outside the recovered 8-bit ISA): + +| op | name | semantics | +|---|---|---| +| 0x121 | GETTABLECHAIN | `q[A]=q[C][K1]; q[B]=q[A][K2]` | +| 0x122 | MOVE2 | two `MOVE`s | +| 0x123 | LOADK2 | two `LOADK`s | +| 0x124 | SET2 | two `SETTABLE`s, original order | +| 0x125 | UPCHAIN | `GETUPVAL` + `GETTABLE` | +| 0x126 | NEWTABLESET | `NEWTABLE` + first `SETTABLE` | +| 0x127 | CMPJMP | `EQ/NE` + `JMPIF/JMPIFNOT`, mode in K2 | +| 0x128 | JMPX | encoded `pc = (K1-K2)+1` jump | +| 0x129 | JMPD | jump-table dispatch on `q[A]` | +| 0x12A | OPAQUE | pc-derived always-truthy predicate | +| 0x12B/0x12C | ADDNEG/SUBNEG | additive-inverse MBA forms | +| 0x12D | LT | comparison used by the swap rewrite | +| 0x12E/0x12F | LOADKSUM/LOADKMBA | exact integer constant re-encodings | + +Representative sample counts (`compile_graph(sample.lua)`): 502 `MOVE2`, +203 `GETCHAIN`, 195 `LOADK2`, 163 `UPCHAIN`, 55 `SET2`, 41 `CMPJMP`, +36 `NEWTABLESET`, 24 `GETTABLECHAIN`, 1697 new-op instances total. + +## 2. MBA transformations + +`_numeric_registers` is a conservative must-be-number analysis over every +definition of a register; `_defined_at` is a must-defined forward CFG dataflow. +A rewrite is only applied when every operand is both provably numeric and +defined on every incoming path. + +- `SUB a b -> q[d]=q[a]+(-q[b])` (`ADDNEG`). +- `ADD a b -> q[d]=q[a]-(-q[b])` (`SUBNEG`). +- `ADDK/SUBK` negate the constant (`a+k = a-(-k)`), `k != 0`. +- `GT a b -> LT b a`; `LE a b -> GE b a` (exact for doubles including NaN and + signed-zero comparisons). +- Integer `LOADK k` is re-encoded either as `x XOR y + 2*(x AND y)` with + `x+y=k` (`LOADKMBA`, non-negative int32) or as `k1+k2` (`LOADKSUM`), both + exact in IEEE double arithmetic. + +Numeric-for limit/step/index registers are treated as numeric because the VM +only continues after `tonumber` succeeded. Cell loads, globals, table reads, +parameters and call results are never assumed numeric, so metamethod dispatch, +string coercion and error ordering are unchanged for transformed sites. + +## 3. Opaque predicates + +Selected prototypes get one guard at entry and (usually) one at a jump target. +`OPAQUE` computes `(bxor(pc, salt) % 2) + 1`: always 1 or 2, but derived from +the runtime pc, so it is not a constant in the instruction stream. The false +edge enters a short decoy block of `LOADK`s on registers above the prototype +stack and rejoins the guarded instruction. Jumps to a guarded pc are retargeted +to the guard, so the predicate also executes on branch entry. + +## 4. Selective control-flow flattening + +`_flatten_graph` picks roughly 38% of prototypes with 14..600 instructions. +For a selected prototype: + +1. Basic-block starts are all branch targets plus all branch fallthroughs. + Fallthrough uses the opcode width, so two-slot superinstructions are never + split. +2. Blocks are shuffled. +3. pc 1 loads a state register (`stack + 128`, outside the prototype stack); + pc 2 is `JMPD`, which reads `proto.jt[state]` and enters the block. +4. Every unconditional jump becomes `LOADK state, target_block; JMPX dispatcher`. + Conditional branches keep their direct taken edge; the fallthrough is + dispatched through the same state machine. Returns and vararg/call windows + are emitted unchanged. +5. The per-prototype jump table is stored as prototype field 15 and serialized + in both text and binary graphs. + +Sample: 30 of 184 prototypes flattened, 30 `JMPD` dispatchers. +The state register and all opaque/MBA scratch registers are above `p.stack`, +so they cannot alias live locals, boxed cells, call windows or vararg slots. + +## 5. Jump indirection + +Unconditional `JMP`s are routed through one or two appended `JMPX` trampolines +with per-jump random split keys. Every prototype also gets 1-3 unreachable +`LOADK` padding instructions. Flattened block tails use `JMPX` to the +dispatcher instead of a plain `JMP`. + +## 6. Per-block instruction sealing (SGB2 v3) + +`emit_binary` now writes version 3. Layout per prototype: + +``` +pid params vararg stack regbase boxed[] jt[] +code_len code_seed + payload: maxpc block_size nblocks + [ block_seed, block_len, sealed_block ]... +``` + +Instructions are grouped into blocks of 6/8/10 (per build). A block contains +`u32 opcode | u8 nfields | (u8 field_id, typed value)...` records, exactly as +before, but the whole block is XOR-encrypted with `block_seed` and a stream +seeded from `(proto_random_id, block_index)`. The outer per-prototype +encryption remains. + +`ensure_code` only records block metadata for v3. `V.fetch(p, pc)` finds the +block and decrypts/parses it on first execution, then caches the decoded +instructions in `p.code`. Jumps, loops, calls, varargs, returns and error +paths see normal decoded instruction records; nothing depends on block layout. +The SGB2 v2 reader is retained (old blobs decode eagerly as before), and the +fast profile still eagerly decodes all blocks in `M.load`. + +`handler_runtime.py` was updated to fetch through `V.fetch`, to use +`p.npc` rather than `#code`, and to pass `jt`/`pid` into handler contexts. + +## 7. Preserved correctness invariants + +- `CLOSEUPVALS -> XCLOSECELLS`, cell boxing/unboxing, `callwith` argument + unboxing, generalized `XFORGLOOP`, and the `p.boxed` handler context are + untouched. +- Fusion only rewrites two adjacent slots in place; branch targets and the + `XCLOSECELLS`/`XFORGLOOP`/`CLOSE` instructions are not moved. +- Flattening copies instruction records verbatim (including `CLOSURE` capture + lists, `XGETVARARG` counts, `XCALL` argc/nres and `XRETMULTI`). +- `jt` entries and `JMPD` states are 1-based pcs; `pc` arithmetic remains + absolute, so error/return behavior of the VM loop is unchanged. +- Secro outer/carrier packing still receives the same protected inner source + format; only the embedded SGB2 payload version changed. + +## 8. Tests + +All required suites were run from `agent2/luraph_compiler`: + +``` +python3 test_compiler.py 26 passed, 0 failed +python3 test_hardened.py 26 passed, 0 failed +python3 test_binary.py 26 passed, 0 failed +python3 test_macros.py 4/4 passed +``` + +Additional stress (temporary scripts outside the deliverable tree): + +- 26 suite cases x 24 keys, full `hardened+binary`: 624/624 exact stdout. +- 26 suite cases x 3 keys, `hardened+binary+fast`: 78/78 exact stdout. +- Edge cases x 20 keys: break/continue in nested loops, pcall around a thrown + error inside a loop, closures capturing loop locals, metatables/`__index`/ + `__add`, multiple returns, deep vararg forwarding: 120/120 exact stdout. + +`sample.lua` through `secro_mode.build` (full profile), for keys 7 and 42: + +- Output 747,592 and 788,070 bytes; stock `luau-compile --binary` accepts both. +- Stock `luau` executes into Operation One and fails at the first real Roblox + API call (`attempt to index nil with 'GetService'`), same failure point and + stack shape as the baseline artifact. No VM-internal error occurs. +- Fast Secro profile: small script built with `fast=True` ran and printed the + expected result (`FAST 2550`). + +## 9. Cost (desktop measurements) + +| measurement | baseline | agent2 | +|---|---|---| +| sample Secro artifact | 672,532 B | 747,592-788,070 B (+11-17%) | +| sample Secro build | 3.74 s | 3.8-4.7 s | +| sample load/start to first API error | 2.71 s | 3.12-3.17 s (+15-17%) | +| micro-benchmark artifact | 127,106 B | 164,410 B (+29%) | +| micro-benchmark run | 5.10-5.36 s | 5.57-5.60 s (+5-9%) | + +The benchmark exercises 2M loop iterations, table build/read and closure +calls. Cost comes from state dispatch, opaque guards at selected entries and +wider artifacts; per-block sealing itself adds no per-execution decryption +after a block's first use. + +## 10. Limitations + +- This raises the cost of graph recovery (opcode remapping, string fragments, + block sealing, superinstructions, flattened state machines); it is not a + proof against a determined dynamic analyst who can instrument the VM. +- Opaque predicates are always-true by construction; their strength is that + the truth value depends on the runtime pc and the handler is inside the VM. +- Flattening is selective (38%, size-bounded) and does not flatten returns or + call windows; a devirtualizer can still recover blocks that were not chosen. +- MBA rewrites are deliberately conservative. Registers reached only through + cells, globals, table reads or calls are not rewritten, so real-world + coverage is lower than a full typed IR pass would allow. +- `XOPAQUE`/`JMPD`/`JMPX` are VM-specific extensions; a recovered ISA without + the runtime cannot give them meaning. +- The full Operation One script cannot be run end-to-end outside Roblox; the + load test confirms decoding and entry into the script, then fails on the + same missing `game` global as baseline. +- SGB2 artifacts are version 3. The reader still accepts v2, but tools that + parse the binary graph outside the VM must be updated for the jump-table and + block fields. +- Compiler-signature hashing in SecroAuth will invalidate cached loaders once + these files are deployed; that is expected and not exercised here. + +## 11. Reproduction + +``` +diff -ru /tmp/opencode/obf_work/baseline /tmp/opencode/obf_work/agent2 \ + -x '__pycache__' > PATCH.diff +cd /tmp/opencode/obf_work/agent2/luraph_compiler +python3 test_compiler.py && python3 test_hardened.py && \ +python3 test_binary.py && python3 test_macros.py +```