Created
July 25, 2026 20:58
-
-
Save tlentulen/b7359fe4df37443bc59a1866cfb80b54 to your computer and use it in GitHub Desktop.
cyberpunk2077-mac-gogrichpresence-fix
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| patch_richpresence.py v3 — Cyberpunk 2077 (macOS, GOG build) | |
| Verified against the real libGameServicesGOG.dylib (80 KB, 34 exports, links | |
| against @rpath/libGalaxy.dylib). | |
| What the library actually does: | |
| _SetRichPresence (0x1528) is a 5-instruction thunk that tail-calls | |
| gs::GameServicesGOG::SetRichPresence (0x1bec). That function calls | |
| galaxy::api::Friends(); with no Galaxy session it returns null, so control | |
| goes to the error path, which calls gs::WriteLog("...Failed to set status | |
| '%.*s'", 8, str) -> openlog/syslog. A null string there is the SIGSEGV. | |
| The same error path then tail-calls the game's completion callback | |
| (mov w0,#0; mov x1,x19; ldr x2,[sp,#0x18]; br x2) — which is why skipping | |
| the whole function hangs the loading screen. | |
| Modes: | |
| --mode list Identity check + decoded listing of the function, following | |
| the thunk one level. Writes nothing. Start here. | |
| --mode nolog RECOMMENDED. `ret` at gs::WriteLog's entry (one 4-byte edit). | |
| Kills every log call in the library, so no null string can | |
| reach syslog anywhere — and every callback, entitlement query | |
| and listener still runs untouched. | |
| --mode nop-log Narrower: nop only the two WriteLog calls on the rich-presence | |
| error path (8 bytes). Same effect for this one crash. | |
| --mode ret BLUNT, and known to hang: skips the whole function, so the | |
| game never receives its completion callback. Kept for | |
| reference only. | |
| Usage: | |
| python3 patch_richpresence.py --mode list | |
| python3 patch_richpresence.py --mode nolog --dry-run | |
| python3 patch_richpresence.py --mode nolog | |
| python3 patch_richpresence.py --restore | |
| No third-party modules. Python 3.8+. | |
| """ | |
| import argparse | |
| import os | |
| import shutil | |
| import struct | |
| import subprocess | |
| import sys | |
| DEFAULT_DYLIB = os.path.expanduser( | |
| "~/Documents/Cyberpunk 2077/Cyberpunk2077.app/Contents/" | |
| "Frameworks/libGameServicesGOG.dylib") | |
| SYMBOL = "_SetRichPresence" | |
| WRITELOG = "__ZN2gs8WriteLogEPKcz" # gs::WriteLog(char const*, ...) | |
| # Names whose calls are safe to remove: they only write log output, and none of | |
| # them feed a value back into the caller's logic. | |
| LOG_NAMES = { | |
| "syslog", "vsyslog", "asl_log", "asl_vlog", "os_log", "os_log_impl", | |
| "_os_log_impl", "os_log_with_type", "printf", "vprintf", "fprintf", | |
| "vfprintf", "puts", "NSLog", "NSLogv", | |
| } | |
| LOG_PREFIXES = ("_ZN2gs8WriteLog",) # gs::WriteLog(char const*, ...) | |
| # Mach-O constants | |
| FAT_MAGIC, FAT_CIGAM = 0xCAFEBABE, 0xBEBAFECA | |
| FAT_MAGIC_64, FAT_CIGAM_64 = 0xCAFEBABF, 0xBFBAFECA | |
| MH_MAGIC_64, MH_CIGAM_64 = 0xFEEDFACF, 0xCFFAEDFE | |
| MH_MAGIC_32, MH_CIGAM_32 = 0xFEEDFACE, 0xCEFAEDFE | |
| LC_SEGMENT_64, LC_SYMTAB, LC_DYSYMTAB, LC_CODE_SIGNATURE = 0x19, 0x02, 0x0B, 0x1D | |
| N_STAB, N_TYPE, N_SECT = 0xE0, 0x0E, 0x0E | |
| SECTION_TYPE, S_SYMBOL_STUBS = 0x000000FF, 0x08 | |
| INDIRECT_SYMBOL_LOCAL, INDIRECT_SYMBOL_ABS = 0x80000000, 0x40000000 | |
| CPU_TYPE_X86_64, CPU_TYPE_ARM64 = 0x01000007, 0x0100000C | |
| ARCH_NAMES = {CPU_TYPE_ARM64: "arm64", CPU_TYPE_X86_64: "x86_64"} | |
| ARM64_RET = bytes((0xC0, 0x03, 0x5F, 0xD6)) # ret | |
| ARM64_NOP = bytes((0x1F, 0x20, 0x03, 0xD5)) # nop | |
| X86_RET = bytes((0xC3,)) | |
| class Slice: | |
| def __init__(self, offset, size, cputype, endian): | |
| self.offset, self.size = offset, size | |
| self.cputype, self.endian = cputype, endian | |
| self.segments = [] # (name, vmaddr, vmsize, fileoff, filesize) | |
| self.sections = [] # dict per section | |
| self.symbols = [] # (name, value, n_type, n_sect) | |
| self.symtab = None | |
| self.dysymtab = None | |
| self.has_signature = False | |
| @property | |
| def arch(self): | |
| return ARCH_NAMES.get(self.cputype, "cpu 0x%08x" % self.cputype) | |
| # ---------------------------------------------------------------- parsing ---- | |
| def find_slices(buf): | |
| magic = struct.unpack_from(">I", buf, 0)[0] | |
| if magic in (FAT_MAGIC, FAT_MAGIC_64, FAT_CIGAM, FAT_CIGAM_64): | |
| big = magic in (FAT_MAGIC, FAT_MAGIC_64) | |
| wide = magic in (FAT_MAGIC_64, FAT_CIGAM_64) | |
| end = ">" if big else "<" | |
| nfat = struct.unpack_from(end + "I", buf, 4)[0] | |
| out, pos = [], 8 | |
| for _ in range(nfat): | |
| if wide: | |
| cputype, _s, off, size, _a = struct.unpack_from(end + "iiQQI", buf, pos) | |
| pos += 32 | |
| else: | |
| cputype, _s, off, size, _a = struct.unpack_from(end + "iiIII", buf, pos) | |
| pos += 20 | |
| out.append((off, size)) | |
| return out | |
| if magic in (MH_MAGIC_64, MH_CIGAM_64, MH_MAGIC_32, MH_CIGAM_32): | |
| return [(0, len(buf))] | |
| raise SystemExit("Not a Mach-O file (magic 0x%08x)." % magic) | |
| def parse_slice(buf, offset, size): | |
| magic = struct.unpack_from("<I", buf, offset)[0] | |
| if magic == MH_MAGIC_64: | |
| end = "<" | |
| elif magic == MH_CIGAM_64: | |
| end = ">" | |
| else: | |
| return None # 32-bit slice: not interesting | |
| cputype, _s, _ft, ncmds, _sc, _fl, _r = struct.unpack_from(end + "iiIIIII", buf, offset + 4) | |
| sl = Slice(offset, size, cputype & 0xFFFFFFFF, end) | |
| pos = offset + 32 | |
| for _ in range(ncmds): | |
| cmd, cmdsize = struct.unpack_from(end + "II", buf, pos) | |
| if cmdsize == 0: | |
| break | |
| if cmd == LC_SEGMENT_64: | |
| segname = buf[pos + 8:pos + 24].rstrip(b"\0").decode("utf-8", "replace") | |
| vmaddr, vmsize, fileoff, filesize = struct.unpack_from(end + "QQQQ", buf, pos + 24) | |
| nsects = struct.unpack_from(end + "I", buf, pos + 64)[0] | |
| sl.segments.append((segname, vmaddr, vmsize, fileoff, filesize)) | |
| sp = pos + 72 | |
| for _i in range(nsects): | |
| sectname = buf[sp:sp + 16].rstrip(b"\0").decode("utf-8", "replace") | |
| addr, ssize = struct.unpack_from(end + "QQ", buf, sp + 32) | |
| soff, _align, _reloff, _nreloc, flags, res1, res2 = struct.unpack_from( | |
| end + "IIIIIII", buf, sp + 48) | |
| sl.sections.append(dict(seg=segname, name=sectname, addr=addr, | |
| size=ssize, offset=soff, flags=flags, | |
| res1=res1, res2=res2)) | |
| sp += 80 | |
| elif cmd == LC_SYMTAB: | |
| sl.symtab = struct.unpack_from(end + "IIII", buf, pos + 8) | |
| elif cmd == LC_DYSYMTAB: | |
| f = struct.unpack_from(end + "18I", buf, pos + 8) | |
| sl.dysymtab = dict(indirectsymoff=f[12], nindirectsyms=f[13]) | |
| elif cmd == LC_CODE_SIGNATURE: | |
| sl.has_signature = True | |
| pos += cmdsize | |
| load_symbols(buf, sl) | |
| return sl | |
| def load_symbols(buf, sl): | |
| if not sl.symtab: | |
| return | |
| symoff, nsyms, stroff, strsize = sl.symtab | |
| end = sl.endian | |
| symbase, strbase = sl.offset + symoff, sl.offset + stroff | |
| for i in range(nsyms): | |
| n_strx, n_type, n_sect, _d, n_value = struct.unpack_from( | |
| end + "IBBHQ", buf, symbase + i * 16) | |
| name = "" | |
| if 0 < n_strx < strsize: | |
| s = strbase + n_strx | |
| e = buf.find(b"\0", s) | |
| name = buf[s:e].decode("utf-8", "replace") | |
| sl.symbols.append((name, n_value, n_type, n_sect)) | |
| def indirect_symbols(buf, sl): | |
| if not sl.dysymtab: | |
| return [] | |
| off = sl.offset + sl.dysymtab["indirectsymoff"] | |
| n = sl.dysymtab["nindirectsyms"] | |
| return list(struct.unpack_from(sl.endian + "%dI" % n, buf, off)) if n else [] | |
| def defined_symbol(sl, name): | |
| for nm, value, n_type, n_sect in sl.symbols: | |
| if nm == name and not (n_type & N_STAB) \ | |
| and (n_type & N_TYPE) == N_SECT and n_sect != 0: | |
| return value | |
| return None | |
| def vmaddr_to_fileoff(sl, vmaddr): | |
| for name, seg_vmaddr, vmsize, fileoff, filesize in sl.segments: | |
| if name == "__PAGEZERO" or vmsize == 0: | |
| continue | |
| if seg_vmaddr <= vmaddr < seg_vmaddr + vmsize: | |
| delta = vmaddr - seg_vmaddr | |
| if delta >= filesize: | |
| raise SystemExit("vmaddr 0x%x has no file data in %s." % (vmaddr, name)) | |
| return sl.offset + fileoff + delta | |
| raise SystemExit("Could not map vmaddr 0x%x to a file offset." % vmaddr) | |
| def section_of(sl, vmaddr): | |
| for s in sl.sections: | |
| if s["addr"] <= vmaddr < s["addr"] + s["size"]: | |
| return s | |
| return None | |
| def function_end(sl, start): | |
| """End of the function: next defined symbol, else end of its section.""" | |
| sect = section_of(sl, start) | |
| limit = sect["addr"] + sect["size"] if sect else start + 0x4000 | |
| nxt = limit | |
| for _nm, value, n_type, n_sect in sl.symbols: | |
| if (n_type & N_STAB) or (n_type & N_TYPE) != N_SECT or n_sect == 0: | |
| continue | |
| if start < value < nxt: | |
| nxt = value | |
| return nxt | |
| def stub_map(buf, sl): | |
| """vmaddr of each symbol stub -> symbol name it jumps to.""" | |
| out = {} | |
| indirect = indirect_symbols(buf, sl) | |
| if not indirect: | |
| return out | |
| for s in sl.sections: | |
| if (s["flags"] & SECTION_TYPE) != S_SYMBOL_STUBS: | |
| continue | |
| stride = s["res2"] or 12 | |
| for i in range(s["size"] // stride): | |
| idx = s["res1"] + i | |
| if idx >= len(indirect): | |
| break | |
| symidx = indirect[idx] | |
| if symidx & (INDIRECT_SYMBOL_LOCAL | INDIRECT_SYMBOL_ABS): | |
| continue | |
| if symidx < len(sl.symbols): | |
| out[s["addr"] + i * stride] = sl.symbols[symidx][0] | |
| return out | |
| def symbol_at(sl, vmaddr): | |
| for nm, value, n_type, n_sect in sl.symbols: | |
| if value == vmaddr and not (n_type & N_STAB) \ | |
| and (n_type & N_TYPE) == N_SECT and n_sect != 0: | |
| return nm | |
| return None | |
| def is_log_name(name): | |
| bare = name.lstrip("_") | |
| if bare in LOG_NAMES or name in LOG_NAMES: | |
| return True | |
| return any(bare.startswith(p.lstrip("_")) for p in LOG_PREFIXES) | |
| def exported_count(sl): | |
| return sum(1 for _nm, _v, t, s in sl.symbols | |
| if not (t & N_STAB) and (t & N_TYPE) == N_SECT and s != 0 and (t & 0x01)) | |
| # ------------------------------------------------------- tiny arm64 decoder -- | |
| def _sx(val, bits): | |
| return val - (1 << bits) if val & (1 << (bits - 1)) else val | |
| def decode(insn, addr): | |
| """Decode the handful of arm64 encodings we need. Returns a string.""" | |
| if insn == 0xD65F03C0: | |
| return "ret" | |
| if insn == 0xD503201F: | |
| return "nop" | |
| if insn == 0xD503233F: | |
| return "paciasp" | |
| if insn == 0xD50323BF: | |
| return "autiasp" | |
| if (insn & 0xFC000000) == 0x94000000: | |
| return "bl\t0x%x" % (addr + _sx(insn & 0x03FFFFFF, 26) * 4) | |
| if (insn & 0xFC000000) == 0x14000000: | |
| return "b\t0x%x" % (addr + _sx(insn & 0x03FFFFFF, 26) * 4) | |
| if (insn & 0xFFFFFC1F) == 0xD63F0000: | |
| return "blr\tx%d" % ((insn >> 5) & 0x1F) | |
| if (insn & 0xFFFFFC1F) == 0xD61F0000: | |
| return "br\tx%d" % ((insn >> 5) & 0x1F) | |
| if (insn & 0xFEFFF800) in (0xD63F0800, 0xD61F0800): # blraaz/braaz family | |
| return ("blraa" if insn & 0x02000000 else "braa") + "\tx%d" % ((insn >> 5) & 0x1F) | |
| if (insn & 0x9F000000) == 0x90000000: # adrp | |
| immlo, immhi = (insn >> 29) & 0x3, (insn >> 5) & 0x7FFFF | |
| imm = _sx((immhi << 2) | immlo, 21) << 12 | |
| return "adrp\tx%d, 0x%x" % (insn & 0x1F, (addr & ~0xFFF) + imm) | |
| if (insn & 0xFF000000) == 0x91000000: # add xd, xn, #imm | |
| imm = (insn >> 10) & 0xFFF | |
| if (insn >> 22) & 0x3 == 1: | |
| imm <<= 12 | |
| return "add\tx%d, x%d, #0x%x" % (insn & 0x1F, (insn >> 5) & 0x1F, imm) | |
| if (insn & 0xFFC00000) == 0xF9400000: | |
| return "ldr\tx%d, [x%d, #0x%x]" % (insn & 0x1F, (insn >> 5) & 0x1F, | |
| ((insn >> 10) & 0xFFF) * 8) | |
| if (insn & 0xFFC00000) == 0xB9400000: | |
| return "ldr\tw%d, [x%d, #0x%x]" % (insn & 0x1F, (insn >> 5) & 0x1F, | |
| ((insn >> 10) & 0xFFF) * 4) | |
| if (insn & 0xFFC00000) == 0xF9000000: | |
| return "str\tx%d, [x%d, #0x%x]" % (insn & 0x1F, (insn >> 5) & 0x1F, | |
| ((insn >> 10) & 0xFFF) * 8) | |
| for mask, mnem in ((0xA9800000, "stp-pre"), (0xA9000000, "stp"), | |
| (0xA8800000, "stp-post"), (0xA9C00000, "ldp-pre"), | |
| (0xA9400000, "ldp"), (0xA8C00000, "ldp-post")): | |
| if (insn & 0xFFC00000) == mask: | |
| imm = _sx((insn >> 15) & 0x7F, 7) * 8 | |
| return "%s\tx%d, x%d, [x%d, #%d]" % ( | |
| mnem, insn & 0x1F, (insn >> 10) & 0x1F, (insn >> 5) & 0x1F, imm) | |
| if (insn & 0xFFE0FFE0) == 0xAA0003E0: | |
| return "mov\tx%d, x%d" % (insn & 0x1F, (insn >> 16) & 0x1F) | |
| if (insn & 0xFFE0FFE0) == 0x2A0003E0: | |
| return "mov\tw%d, w%d" % (insn & 0x1F, (insn >> 16) & 0x1F) | |
| if (insn & 0x7F800000) == 0x52800000: | |
| return "movz\t%s%d, #0x%x" % ("x" if insn & 0x80000000 else "w", | |
| insn & 0x1F, (insn >> 5) & 0xFFFF) | |
| if (insn & 0x7F800000) == 0x72800000: | |
| return "movk\t%s%d, #0x%x" % ("x" if insn & 0x80000000 else "w", | |
| insn & 0x1F, (insn >> 5) & 0xFFFF) | |
| if (insn & 0xFF000000) == 0x54000000: | |
| return "b.cond\t0x%x" % (addr + _sx((insn >> 5) & 0x7FFFF, 19) * 4) | |
| if (insn & 0x7F000000) in (0x34000000, 0x35000000): | |
| return "cbz/cbnz\t0x%x" % (addr + _sx((insn >> 5) & 0x7FFFF, 19) * 4) | |
| return ".word\t0x%08x" % insn | |
| def branch_target(insn, addr): | |
| """Return (kind, target) for direct branches, else (None, None).""" | |
| if (insn & 0xFC000000) == 0x94000000: | |
| return "bl", addr + _sx(insn & 0x03FFFFFF, 26) * 4 | |
| if (insn & 0xFC000000) == 0x14000000: | |
| return "b", addr + _sx(insn & 0x03FFFFFF, 26) * 4 | |
| return None, None | |
| def disassemble(buf, sl, start, end, stubs, indent=" "): | |
| """Decoded listing of [start, end), with branch targets resolved.""" | |
| lines, base = [], vmaddr_to_fileoff(sl, start) | |
| for pos in range(0, max(0, end - start), 4): | |
| insn = struct.unpack_from("<I", buf, base + pos)[0] | |
| addr = start + pos | |
| text = decode(insn, addr) | |
| kind, target = branch_target(insn, addr) | |
| note = "" | |
| if target is not None: | |
| name = stubs.get(target) or symbol_at(sl, target) | |
| if name: | |
| note = " ; %s%s" % (name, " <-- LOG" if is_log_name(name) else "") | |
| elif stubs and section_of(sl, target) and \ | |
| (section_of(sl, target)["flags"] & SECTION_TYPE) == S_SYMBOL_STUBS: | |
| note = " ; (symbol stub)" | |
| else: | |
| note = " ; (internal)" | |
| lines.append("%s0x%x %08x %s%s" % (indent, addr, insn, text, note)) | |
| return lines | |
| def tail_targets(buf, sl, start, end, stubs): | |
| """Direct b/bl targets that land on internal code (thunk following).""" | |
| out, base = [], vmaddr_to_fileoff(sl, start) | |
| for pos in range(0, max(0, end - start), 4): | |
| insn = struct.unpack_from("<I", buf, base + pos)[0] | |
| kind, target = branch_target(insn, start + pos) | |
| if target is None or target in stubs: | |
| continue | |
| sect = section_of(sl, target) | |
| if not sect or (sect["flags"] & SECTION_TYPE) == S_SYMBOL_STUBS: | |
| continue | |
| if start <= target < end: | |
| continue # branch inside the same function | |
| out.append((kind, target, symbol_at(sl, target) or "sub_%x" % target)) | |
| return out | |
| def analyze_calls(buf, sl, start): | |
| """Every arm64 `bl` inside the function, resolved to a name where possible.""" | |
| if sl.cputype != CPU_TYPE_ARM64: | |
| return None | |
| end_addr = function_end(sl, start) | |
| stubs = stub_map(buf, sl) | |
| calls = [] | |
| base_off = vmaddr_to_fileoff(sl, start) | |
| for pos in range(0, end_addr - start, 4): | |
| insn = struct.unpack_from("<I", buf, base_off + pos)[0] | |
| addr = start + pos | |
| kind, target = branch_target(insn, addr) | |
| if kind is None: | |
| continue | |
| if kind == "b" and start <= target < end_addr: | |
| continue # local jump, not a call | |
| name = stubs.get(target) or symbol_at(sl, target) or "" | |
| calls.append(dict(kind=kind, addr=addr, fileoff=base_off + pos, | |
| target=target, name=name, | |
| log=is_log_name(name) if name else False)) | |
| return dict(start=start, end=end_addr, calls=calls, stubs=stubs) | |
| # ---------------------------------------------------------------- actions ---- | |
| def read_file(path): | |
| with open(path, "rb") as fh: | |
| return bytearray(fh.read()) | |
| def collect(buf): | |
| slices = [] | |
| for off, size in find_slices(bytes(buf)): | |
| sl = parse_slice(bytes(buf), off, size) | |
| if sl: | |
| slices.append(sl) | |
| return slices | |
| def plan_for(buf, sl, mode): | |
| """Return (list of (fileoff, new_bytes, note), info) for one slice.""" | |
| start = defined_symbol(sl, SYMBOL) | |
| if start is None: | |
| return None, None | |
| if mode == "nolog": | |
| wl = defined_symbol(sl, WRITELOG) | |
| if wl is None: | |
| return [], dict(start=start, no_writelog=True) | |
| return ([(vmaddr_to_fileoff(sl, wl), ARM64_RET, | |
| "ret at %s entry (0x%x) — disables all logging in the library" | |
| % (WRITELOG, wl))], | |
| dict(start=start, writelog=wl)) | |
| if mode == "ret": | |
| patch = ARM64_RET if sl.cputype == CPU_TYPE_ARM64 else X86_RET | |
| off = vmaddr_to_fileoff(sl, start) | |
| return [(off, patch, "ret at function entry")], dict(start=start) | |
| info = analyze_calls(bytes(buf), sl, start) | |
| if info is None: | |
| return [], dict(start=start, unsupported=True) | |
| # The exported symbol is usually a thunk; follow its tail call one level so | |
| # we see the log calls in the real implementation. | |
| calls = list(info["calls"]) | |
| for _kind, target, _name in tail_targets(bytes(buf), sl, start, info["end"], | |
| info["stubs"]): | |
| deeper = analyze_calls(bytes(buf), sl, target) | |
| if deeper: | |
| calls.extend(deeper["calls"]) | |
| info = dict(info, calls=calls) | |
| edits = [] | |
| for c in info["calls"]: | |
| if not c["log"]: | |
| continue | |
| if c["kind"] == "bl": | |
| edits.append((c["fileoff"], ARM64_NOP, | |
| "nop out call to %s at 0x%x" % (c["name"], c["addr"]))) | |
| else: | |
| # tail call: nothing runs after it, so returning here is equivalent | |
| edits.append((c["fileoff"], ARM64_RET, | |
| "return instead of tail-calling %s at 0x%x" | |
| % (c["name"], c["addr"]))) | |
| return edits, info | |
| def codesign(path): | |
| try: | |
| subprocess.run(["codesign", "--remove-signature", path], | |
| check=False, capture_output=True) | |
| res = subprocess.run(["codesign", "--force", "--sign", "-", | |
| "--timestamp=none", path], | |
| capture_output=True, text=True) | |
| if res.returncode != 0: | |
| print(" ! codesign failed: %s" % res.stderr.strip()) | |
| print(" Run manually: codesign -f -s - \"%s\"" % path) | |
| return False | |
| subprocess.run(["xattr", "-d", "com.apple.quarantine", path], | |
| check=False, capture_output=True) | |
| print(" re-signed ad hoc: OK") | |
| return True | |
| except FileNotFoundError: | |
| print(" ! codesign not found. Then run: codesign -f -s - \"%s\"" % path) | |
| return False | |
| def main(): | |
| ap = argparse.ArgumentParser( | |
| description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) | |
| ap.add_argument("--dylib", default=DEFAULT_DYLIB) | |
| ap.add_argument("--mode", choices=("list", "nolog", "nop-log", "ret"), | |
| default="list") | |
| ap.add_argument("--dry-run", action="store_true") | |
| ap.add_argument("--restore", action="store_true", | |
| help="restore from the .orig backup this script made") | |
| args = ap.parse_args() | |
| path, backup = args.dylib, args.dylib + ".orig" | |
| if args.restore: | |
| if not os.path.exists(backup): | |
| raise SystemExit("No backup at %s" % backup) | |
| shutil.copy2(backup, path) | |
| print("Restored %s from %s" % (path, backup)) | |
| codesign(path) | |
| return | |
| if not os.path.exists(path): | |
| raise SystemExit("Not found: %s" % path) | |
| print("Target: %s" % path) | |
| buf = read_file(path) | |
| # Plan against the pristine backup when we have one: after a successful | |
| # nop-log patch the instruction we look for is gone from the live file, so | |
| # analysing the live file would report "nothing found" instead of | |
| # "already patched". | |
| srcbuf = buf | |
| if os.path.exists(backup): | |
| candidate = read_file(backup) | |
| if len(candidate) == len(buf): | |
| srcbuf = candidate | |
| print(" (planning against the pristine backup %s)" | |
| % os.path.basename(backup)) | |
| slices = collect(srcbuf) | |
| # Identity check. The real library is small (~80 KB) and exports only ~34 | |
| # symbols, so export count alone says nothing. What it does have is imports | |
| # from the Galaxy SDK; the no-op stub has none. | |
| size_kb = len(buf) / 1024.0 | |
| galaxy = sum(1 for sl in slices for nm, _v, t, _s in sl.symbols | |
| if not (t & N_STAB) and (t & N_TYPE) == 0 and "galaxy" in nm) | |
| counts = [exported_count(sl) for sl in slices] | |
| print(" file size: %.1f KB, exported: %s, galaxy::api imports: %d" | |
| % (size_kb, ", ".join(str(c) for c in counts) or "n/a", galaxy)) | |
| if galaxy: | |
| print(" -> real GOG library (it links against the Galaxy SDK)") | |
| else: | |
| print(" *** No galaxy::api imports: this is the NO-OP STUB, not the") | |
| print(" original library. Restore your backup first:") | |
| print(" cd \"%s\"" % os.path.dirname(path)) | |
| print(" cp libGameServicesGOG.dylib.bak libGameServicesGOG.dylib") | |
| print(" Patching the stub cannot bring the DLC back.\n") | |
| found_any, all_edits = False, [] | |
| for sl in slices: | |
| edits, info = plan_for(srcbuf, sl, "list" if args.mode == "list" else args.mode) | |
| if info is None: | |
| print(" [%s] %s not found in this slice" % (sl.arch, SYMBOL)) | |
| continue | |
| found_any = True | |
| print(" [%s] %s at 0x%x" % (sl.arch, SYMBOL, info["start"])) | |
| if args.mode == "list": | |
| calls = analyze_calls(bytes(srcbuf), sl, info["start"]) | |
| if calls is None: | |
| print(" (listing supported on arm64 only)") | |
| continue | |
| start, end_addr = calls["start"], calls["end"] | |
| stubs = calls["stubs"] | |
| print(" function spans 0x%x-0x%x (%d bytes), %d call(s)/branch(es):" | |
| % (start, end_addr, end_addr - start, len(calls["calls"]))) | |
| for line in disassemble(bytes(srcbuf), sl, start, end_addr, stubs): | |
| print(line) | |
| # A tiny function that jumps somewhere else is a thunk: the real work | |
| # (and the crashing log call) lives in the target. Follow it one level. | |
| for kind, target, name in tail_targets(bytes(srcbuf), sl, start, | |
| end_addr, stubs): | |
| t_end = function_end(sl, target) | |
| span = min(t_end, target + 0x200) | |
| print(" --- %s target %s at 0x%x (0x%x-0x%x, first %d bytes) ---" | |
| % (kind, name, target, target, t_end, span - target)) | |
| for line in disassemble(bytes(srcbuf), sl, target, span, stubs, | |
| indent=" "): | |
| print(line) | |
| continue | |
| if not edits: | |
| print(" nothing to patch in this slice" | |
| + (" (no logging call found — send me the --mode list" | |
| " output)" if args.mode == "nop-log" else "") | |
| + (" (gs::WriteLog not found — is this the real library?)" | |
| if args.mode == "nolog" else "")) | |
| continue | |
| for off, new, note in edits: | |
| cur = bytes(buf[off:off + len(new)]) | |
| state = "already patched" if cur == new else "will patch" | |
| print(" %s: %s (offset 0x%x, %s -> %s)" | |
| % (state, note, off, cur.hex(), new.hex())) | |
| all_edits.extend(edits) | |
| if not found_any: | |
| raise SystemExit( | |
| "%s is not exported by this file — you are pointing at the no-op " | |
| "stub, not the original. Restore the original first." % SYMBOL) | |
| if args.mode == "list": | |
| print("\nList mode: nothing written.") | |
| return | |
| if args.dry_run: | |
| print("\nDry run: nothing written.") | |
| return | |
| if not all_edits: | |
| print("\nNo edits to apply.") | |
| return | |
| if all(bytes(buf[o:o + len(n)]) == n for o, n, _ in all_edits): | |
| print("\nAlready patched. Nothing to do.") | |
| return | |
| if not os.path.exists(backup): | |
| shutil.copy2(path, backup) | |
| print(" backup written: %s" % backup) | |
| else: | |
| print(" backup already exists: %s (left as is)" % backup) | |
| for off, new, _note in all_edits: | |
| buf[off:off + len(new)] = new | |
| tmp = path + ".new" | |
| with open(tmp, "wb") as fh: | |
| fh.write(buf) | |
| shutil.copymode(path, tmp) | |
| os.replace(tmp, path) | |
| print(" applied %d edit(s)" % len(all_edits)) | |
| codesign(path) | |
| print("\nDone (mode: %s). Launch the game and load a Phantom Liberty save." | |
| % args.mode) | |
| print("To undo: python3 %s --restore" % os.path.basename(__file__)) | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
cyberpunk2077-mac-gogrichpresence-fix
Fixes the
SIGSEGVcrash when loading a save in Cyberpunk 2077 on macOS (GOGbuild) without GOG Galaxy installed — by disabling one function inside
libGameServicesGOG.dylibinstead of replacing the whole library with a stub.Replacing the library works, but it also silences the additional-content API, so
the game stops recognising DLC and refuses saves that need Phantom Liberty. This
patches four bytes instead, so achievements, cloud saves and the DLC entitlement
path all keep working.
Symptoms this fixes
SIGSEGV/EXC_BAD_ACCESSandlibGameServicesGOG.dylibin the faulting frames.longer sees your DLC.
SetRichPresenceto return immediately: thecrash stops, but the game hangs on the loading screen (see
docs/ANALYSIS.md — the function tail-calls a completion
callback the loader waits for).
Quick start
Default path is
~/Documents/Cyberpunk 2077/Cyberpunk2077.app/Contents/Frameworks/libGameServicesGOG.dylib.Use
--dylib /path/to/libGameServicesGOG.dylibfor any other install location(e.g. under
/Applications, which needssudo).The script backs the original up to
libGameServicesGOG.dylib.origbefore itsfirst edit, and re-signs the result ad hoc — required, since macOS on Apple
Silicon refuses to load a dylib whose signature no longer matches.
What it changes
--mode nologwrites a singleretat the entry ofgs::WriteLog(char const*, ...).That function only logs:
openlog→syslog→closelog, plus an optionaluser-supplied logger via
vsnprintf. The crash happens because, with no Galaxysession,
gs::GameServicesGOG::SetRichPresencetakes its error branch and logs"[GameServicesGOG]: Failed to set status '%.*s'"with a null string pointer.Returning from the logger immediately removes that crash — and every other
null-string log crash in the library — while leaving all program logic, callbacks
and listeners intact.
Four bytes. Nothing else in the file changes.
Modes
listSetRichPresence, following its tail call one levelnologretatgs::WriteLogentry (4 bytes)nop-lognopover just the twoWriteLogcalls on the rich-presence error path (8 bytes)retretatSetRichPresenceentryAdd
--dry-runto any patching mode to preview the exact byte changes.The tool parses Mach-O itself (no dependencies beyond Python 3.8+), resolves
symbols through the symbol table and
__stubs/ indirect symbol table, handlesuniversal binaries, is idempotent, and plans against the
.origbackup when oneexists — so re-running never compounds edits.
About DLC recognition
This patch fixes the crash. It does not fabricate DLC ownership.
CheckAdditionalContentStatusasksgalaxy::api::Apps()->IsDlcOwned(...)andforwards the result through
IsDlcOwnedListener::OnDlcCheckSuccess(id, owned).With no Galaxy session,
Apps()is null and the function skips straight to thecompletion callback without reporting anything as owned. The entitlement is an
answer from GOG, not a flag in your game files.
So if you own Phantom Liberty and want the game to see it, sign in with GOG
Galaxy at least once. With a live session,
Friends()andApps()are non-null,which means this crash cannot occur in the first place and the DLC check actually
runs. This patch is what makes the game playable without Galaxy — base game
saves, achievements and cloud saves included.
Requirements
x86_64 slices are patched in
retmode only.codesignfrom the Xcode Command Line Tools (xcode-select --install).Verifying a patch
Verification during development was done by diffing the patched file against the
original byte for byte and re-disassembling the affected functions with capstone
to confirm the completion-callback tail call survived. See
docs/ANALYSIS.md.
Disclaimer
Modifies a file inside your own game install. Keep the
.origbackup the scriptmakes; a game update will replace the library and you can simply re-run the tool.