Files
Syplheed-Reborn/tools/xach_dump.py
Claude (auto-RE) 33ae20896e re(challenge): the gate's bit space is the game's 24 ACHIEVEMENTS
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.
2026-08-13 19:32:43 +00:00

95 lines
3.6 KiB
Python
Executable File
Raw Blame History

#!/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())