Static only. Last commit left "REQUIREMENT is a bit index into a progress bitfield" with the space unidentified. It is the achievement space, and both halves are now readable off the disc and the executable. - GamePart_Debriefing (0x8218CF38-0x82191B18) awards them: sub_8218F9A8 walks the on-disc ACHIEVEMENTS_REQUIREMENTS list (tables.pak #16, schema 744c0519), and for entry index n tests bit n, evaluates the entry when clear, and sets the bit when satisfied. The list is literally ACHIEVEMENT01..ACHIEVEMENT24 -- 24 entries, which is exactly where the challenge gate splits word A from word B. - The XEX carries the definitions: XACH at .pe 0x8FBCBC, 36-byte records {id, name_id, unlocked_desc_id, locked_desc_id, image_id u32, gamerscore u16, pad, flags u32, 16 zero bytes}, strings from one XSTR per language (English is table #5). tools/xach_dump.py parses it. SELF-CHECK: the 24 gamerscores sum to exactly 1000, the retail total -- a wrong stride does not land on a round 1000. - The two sources agree on ORDER independently: the requirement types ShootDownAircrafts 1000/10000, ShootDownShips 100, ShootDownWeight MegaTons, GetAllWeapons and GetAllAchievements line up with ids 19-24 exactly as XACH names them. So bit n <-> achievement n+1 is evidence, not inference. (Those last two are requirement TYPES, not debug cheats, despite how they read.) - Corollary: TimeAttack's REQUIREMENT 16 -- the one value that sits in direct value-before-key adjacency, so it survives IDXD dedup -- is bit 16 = achievement 17, "Solar System Defense Award", i.e. finish the story campaign. The other five values (25-29) are >= 24 and so index word B, a second flag space, plausibly a challenge-clear chain. Still 🟡. REFUTED, from the last commit: the stores to +1956 in 0x822AF278 / sub_822C8748 are NOT this singleton. That object comes from 0x822CEB30, checks a +2652 flag and stores string POINTERS at +1956/+2024 -- and a pointer ANDed with 1<<n is meaningless as a gate. So nothing in the image writes this singleton's +1956 field-wise, and where the mask persists (save vs Xbox profile) is open. XEX imports are by ordinal, so absent XamUser* strings are not evidence either way.
95 lines
3.6 KiB
Python
Executable File
95 lines
3.6 KiB
Python
Executable File
#!/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 <path-to.pe> [--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("<EFBFBD>") == 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())
|