#!/usr/bin/env python3 """Dump the title's Xbox 360 achievement table (XACH) out of the decrypted `.pe`. The XEX embeds an SPA/XDBF resource holding the achievement definitions and one string table per language. `GamePart_Debriefing` awards these (it walks the `ACHIEVEMENTS_REQUIREMENTS` config list and sets bit *i* for entry *i*), and `GamePart_ChallengeMission` gates each challenge mission on a bit of the same space — so this table names the bits. Layout, derived here and self-checked (see below): XDBF section header : magic[4] "XACH", version u32, size u32, count u16 record, 36 bytes : id u16, name_id u16, unlocked_desc_id u16, locked_desc_id u16, image_id u32, gamerscore u16, pad u16, flags u32, then 16 bytes of zeroes XSTR section header : magic[4] "XSTR", version u32, size u32, count u16 string entry : id u16, len u16, `len` bytes of ASCII Self-check: the 24 records' gamerscore sums to **1000**, the retail total — a wrong stride or field offset does not add up to a round 1000. Usage: xach_dump.py [--lang-index N] """ import struct import sys def find_all(buf, needle): out, i = [], 0 while True: i = buf.find(needle, i) if i < 0: return out out.append(i) i += 1 def parse_xstr(d, off): count = struct.unpack_from(">H", d, off + 12)[0] o, table = off + 14, {} for _ in range(count): sid, ln = struct.unpack_from(">HH", d, o) table[sid] = d[o + 4 : o + 4 + ln].decode("ascii", "replace") o += 4 + ln return table def main(): if len(sys.argv) < 2: print(__doc__) return 1 d = open(sys.argv[1], "rb").read() # The real XACH section is the one whose header count is small (the other # "XACH" hit is the XDBF entry table, which merely *names* it). xach = None for off in find_all(d, b"XACH"): ver, size, count = struct.unpack_from(">IIH", d, off + 4) if ver == 1 and 0 < count < 256 and size >= count * 36: xach = (off, count) break if xach is None: print("no XACH section found", file=sys.stderr) return 2 off, count = xach # One XSTR per language. Default to English: the only table whose achievement # strings are pure 7-bit ASCII (every other localisation carries accents or # multi-byte text, which our ASCII decode turns into replacement chars). strs = [parse_xstr(d, o) for o in find_all(d, b"XSTR") if len(d) - o > 16] ids = [struct.unpack_from(">HHHH", d, off + 14 + i * 36)[1:] for i in range(count)] lang = int(sys.argv[sys.argv.index("--lang-index") + 1]) if "--lang-index" in sys.argv else None if lang is None: def ascii_score(t): txt = "".join(t.get(s, "") for rec in ids for s in rec) return (txt.count("�") == 0 and len(txt) > 0, len(txt)) lang = max(range(len(strs)), key=lambda i: ascii_score(strs[i])) S = strs[lang] print(f"XACH @0x{off:X} {count} achievements (string table #{lang} of {len(strs)})\n") total = 0 for i in range(count): b = off + 14 + i * 36 aid, nid, did, lid = struct.unpack_from(">HHHH", d, b) gs = struct.unpack_from(">H", d, b + 12)[0] total += gs print(f"id {aid:2d} | bit {aid - 1:2d} | {gs:3d}G | {S.get(nid, '?')}") print(f" unlocked: {S.get(did, '?')}") print(f" locked : {S.get(lid, '?')}") print(f"\ntotal gamerscore = {total}" + (" [OK: retail total]" if total == 1000 else " [!! expected 1000]")) return 0 if __name__ == "__main__": raise SystemExit(main())