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.
This commit is contained in:
78
crates/sylpheed-formats/examples/challenge_screen.rs
Normal file
78
crates/sylpheed-formats/examples/challenge_screen.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
//! RE probe: the GamePart_ChallengeMission screen config.
|
||||
//!
|
||||
//! The class's code range (`0x82187E60`..`0x8218CF10`, bounded by the factory
|
||||
//! creator thunks either side) references these config keys:
|
||||
//! MISSIONS, MISSION_ID, NEW_STAGE, REQUIREMENT, REQUIREMENT_DESC, "Always",
|
||||
//! GRAY_BUTTON, NORMAL_BUTTON, THUMBNAIL, STAGE_DESC, TEXT_STAGE,
|
||||
//! RECORD_TYPE, "Time", TEXT_RECORD, BASE_INFO
|
||||
//! i.e. the challenge list is a *config record* with a per-mission REQUIREMENT.
|
||||
//! It lives in `tables.pak` (one copy per language), not in GP_CHALLENGE.pak.
|
||||
//!
|
||||
//! Run: cargo run --release -p sylpheed-formats --example challenge_screen -- <disc-root>
|
||||
|
||||
use sylpheed_formats::{idxd::IdxdObject, pak::PakArchive};
|
||||
|
||||
const KEYS: &[&str] = &[
|
||||
"MISSIONS",
|
||||
"MISSION_ID",
|
||||
"REQUIREMENT",
|
||||
"REQUIREMENT_DESC",
|
||||
"NEW_STAGE",
|
||||
"GRAY_BUTTON",
|
||||
"NORMAL_BUTTON",
|
||||
"RECORD_TYPE",
|
||||
"THUMBNAIL",
|
||||
"STAGE_DESC",
|
||||
];
|
||||
|
||||
fn find(h: &[u8], n: &[u8]) -> bool {
|
||||
h.windows(n.len()).any(|w| w == n)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::args().nth(1).unwrap_or_else(|| {
|
||||
std::env::var("SYLPHEED_DISC").expect("pass disc root or set SYLPHEED_DISC")
|
||||
});
|
||||
|
||||
let dat = format!("{disc}/dat");
|
||||
let mut paks: Vec<_> = std::fs::read_dir(&dat)
|
||||
.expect("dat dir")
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
|
||||
.collect();
|
||||
paks.sort();
|
||||
|
||||
for p in &paks {
|
||||
let Ok(arc) = PakArchive::open(p) else { continue };
|
||||
for (i, e) in arc.entries().iter().enumerate() {
|
||||
let Ok(b) = arc.read(e) else { continue };
|
||||
if KEYS.iter().filter(|k| find(&b, k.as_bytes())).count() < KEYS.len() {
|
||||
continue;
|
||||
}
|
||||
let name = p.file_name().unwrap().to_string_lossy().to_string();
|
||||
match IdxdObject::parse(&b) {
|
||||
Ok(o) => {
|
||||
let toks = o.tokens();
|
||||
// language tag: the PATH value, e.g. "dat\GP_CHALLENGE.pak+eng\"
|
||||
let lang = toks
|
||||
.iter()
|
||||
.find(|t| t.contains("GP_CHALLENGE.pak+"))
|
||||
.map(|t| t.to_string())
|
||||
.unwrap_or_default();
|
||||
println!(
|
||||
"\n===== {name} entry #{i} schema {:08x} {} tokens [{lang}] =====",
|
||||
o.schema_hash,
|
||||
toks.len()
|
||||
);
|
||||
// Print the pool in order; the record is key/value interleaved and
|
||||
// IDXD dedupes repeats, so pairing is read by eye, not asserted.
|
||||
for (j, t) in toks.iter().enumerate() {
|
||||
println!(" {j:3} {t}");
|
||||
}
|
||||
}
|
||||
Err(e) => println!("\n===== {name} entry #{i}: IDXD parse failed: {e} ====="),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user