Commit Graph

4 Commits

Author SHA1 Message Date
90e0e66c7b re(achievements): earned state comes from XAM, and my bit numbering was wrong
Two findings, one of them a correction to the previous commit.

FOUND: GamePart_Debriefing enumerates XACHIEVEMENT_DETAILS from XAM
(0x8218F888). The records are 36 bytes -- confirmed by the arithmetic, not by
eye: the count is a byte count divided by 36 through the 0x38E38E39 multiply-high
magic plus srawi 3. Field +0 is used as a shift amount, field +32 is tested for
0x00020000, and the buffer sits behind a handle that is waited on (0x824AA330
with -1) then closed (0x824AA3E0). That is the XDK struct exactly, with
XACHIEVEMENT_DETAILS_ACHIEVED == 0x20000, via the
XamUserCreateAchievementEnumerator / XEnumerate pattern.

So the title does NOT persist earned achievements itself -- it asks the console.
The lever for achievement-gated content is the emulator's PROFILE data, not the
savegame. Independently, the singleton the challenge gate reads is not the object
holding the save block: re-scanning all 202 accessor call sites WITH function-
boundary stops touches only +80/+1956/+1960/+1964 and none of the known save
offsets (+304/+316/+320/+336/+380/+440). The earlier scan that seemed to find
them had register tracking bleeding into the next function -- a false positive I
am recording rather than quietly dropping.

CORRECTED: the previous commit claimed "bit n <-> achievement n+1" and treated
the Debriefing award pass and the challenge gate as the same bit space, both as
confirmed. Neither holds up:

- the only place the image is observed turning an achievement into a bit does
  1 << dwId with 1-based ids, so bit = the id and bit 0 is unused;
- the "list index is the bit index" step assumed 0x82448338's out-parameter is
  the loop ordinal. It is the child entry's first word out of a 12-byte array,
  and whether that is an ordinal, an id or a name hash is not pinned;
- the Debriefing's masks are its own fields (+208/+736/+740). Nothing observed
  copies them into the singleton's +80, and no writer of that bitmask has been
  found at all.

The requirement-type/id agreement still stands, but it confirms the LIST ORDER,
not the bit numbering. Consequence: if the join holds, TimeAttack's REQUIREMENT
16 is achievement 16 (Night Ravens Patch), not 17 (Solar System Defense Award).
Both read plausibly as a first-challenge gate, which is exactly why it needs
evidence and not the better story.

Also noted: +1960 is a third bitfield on the same singleton, and a what-changed
pass at 0x8219F3A4 diffs it and reports each newly set bit as bit + 64 -- so
there is a wider flag-id space whose bases are not yet worked out.
2026-08-13 19:43:35 +00:00
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
8ecd70f1bc re(challenge): the unlock is a bit test, and the mission table is on disc
Static only. Bounding each GamePart's code block by its factory creator thunk
(id -> creator recovered for 22 of 24 registrations at 0x8280C000-0x8280F800)
puts GamePart_ChallengeMission at 0x82187E60-0x8218CF10. Resolving every string
that block references gives the screen's config schema, and the record itself is
on disc -- tables.pak schema 54a10697, one copy per language, English entry #64.

Six missions: TimeAttack (record Time), ScoreAttack (record Points) and
Extra01..Extra04, each with MISSION_ID / REQUIREMENT / REQUIREMENT_DESC /
THUMBNAIL / STAGE_DESC / NEW_STAGE and a NORMAL_BUTTON / GRAY_BUTTON pair -- so
the screen always lists all six and greys out what is not earned.

THE GATE (0x82189970-0x821899D8), read off the code:

  REQUIREMENT absent      -> available
  REQUIREMENT == "Always" -> available
  else n = atoi(REQUIREMENT)
      n == 0              -> locked
      n <  24             -> test bit n      of the word at singleton+80
      n >= 24             -> test bit (n-24) of the word at singleton+1956

The singleton is 0x821707C0 (lazy, global 0x828F48BC). So availability is one
bit in a progress bitfield and REQUIREMENT is a bit INDEX -- not a stage number,
score or difficulty.

Values per mission are 🟡: the pool's numeric tokens are 16/25/26/27/29 and
24/28 already appear earlier as font metrics, so they would be deduped -- which
fits 24..29 but IDXD dedup makes positional pairing unsound here, so it is
recorded as a hypothesis, not a table.

Negative: the requirement TEXT is not in GP_CHALLENGE.pak (TextIndex over it =
0 entries; its only prose is embedded font copyright). Its PATH is a per-language
branch the loader does not currently reproduce.

Next: three stores to +1956 sit in 0x822C7DD0 / 0x822C8748, the same region as
the save serializer 0x822C00E8 -- if the bits are save-backed, a hand-written
save unlocks all six challenge missions and the last 42 units become one run.
2026-08-13 19:14:48 +00:00
10a96844ba re(challenge): the EX missions are a MODE, not a stage number
Static-only (no emulator, no pad input). Three findings, each with its own
evidence:

- The disc holds exactly 29 StageResource records in three families --
  S01-S16 story, S18-S23 tutorial (all bg=Original), S24-S29 challenge, plus
  Test. That is 16 + 6 + 6 + 1, matching weapon.tbl's stage01..16 /
  tutorial01..06 / challenge01..06 key set exactly. S17 does not exist.
  GP_CHALLENGE.pak has 0 IDXD objects -- it is the menu screen; challenge
  missions reuse GP_MAIN_GAME_E.pak's records.

- The GamePart id table is at 0x820A1630 (29 ids). Indices are confirmed by
  the image's own RegisterToFactory<N, class silph::GamePart_*> text, not by
  position: GP_CHALLENGE = 26, GP_TUTORIAL = 25, GP_BUNK = 10.

- The stage loader selects its config section from a mission-KIND field at
  object+144: 3 -> EXTRA, 5|6 -> CHALLENGE, else FILE (two independent sites,
  0x82184df0 and 0x82185ed0; two more classify {3,5,6} as one group). The
  constructor sets it to 0 and every write inside the class only clears it,
  and no immediate 3/5/6 store to it exists image-wide -- so the kind is
  supplied by the launching GamePart, never derived from the stage number.

That last point is a mechanism (unproven) for why patching the save's stage
field to 27 kills the load: the record is a challenge stage but the kind stays
FILE. Names an untried, zero-cost discriminator -- try stage 18-23.

Also flagged, not resolved: roster_target says S10 (a STORY stage) still
fields an unharvested unit, which contradicts the "story campaign complete"
claim by one unit.
2026-08-13 18:59:52 +00:00