Mega Man X5: Implement New Game - #6369
Open
Shinnuu wants to merge 77 commits into
Open
Conversation
…on, weapon/heart grants
…, retry grants on guard failure, add diagnostics
…ired into the client The heart-tank bitfield (low byte of u32 0x800D1C80) is placement-data-driven, NOT stageId-1 order, so every bit needed its own evidence. Two bits came from live pickups: bit 6 Dark Dizzy (prior session) and bit 2 Izzy Glow - watched 0x40->0x44 at the pickup frame with X's max HP going +2 in the same frame and Zero's (0x0D1C48) untouched, which is also the first hard evidence that heart tanks are per-character. The other six came from walking the spawn engine's placement records (list = *(0x80072EAC + stage*8 + area*4), record id byte IS the bit index) in one enter-and-leave visit per stage, using the workspace's new mmx5_placement_dump.lua harvester: * bit0 Grizzly Slash, bit1 Squid Adler, bit2 Izzy Glow, bit3 Duff McWhalen, bit4 The Skiver, bit5 Axle the Red, bit6 Dark Dizzy, bit7 Mattrex. * Axle the Red needed care: his list carries 11 phantom heart records (ids 0-2) gated on a nonexistent armor level 5 - dummied placements. The real heart is the stage's unique UNGATED id=05 record, and 5 was also the only bit left unassigned, so the attribution is confirmed twice over. Also upgrades the weapon bit-order comment from inferred to verified for bit 5: the Izzy Glow results commit was watched writing 0x0D1C4C 03->23, validating the ammo-slot ordering the whole table was inferred from (bits 0, 1, 5 now live-confirmed). With the full table the client sends heart checks for every stage instead of only Dark Dizzy's; the unmapped-bit guard is now vestigial but kept as a safety net. Needs a client restart to take effect, and the first reconnect after restart re-applies all received hearts (+2 max HP each) - the known items_processed drift, tracked for A3 hardening (persist processed-count in spare save-struct bytes or server datastorage). Tank attribution from the same harvest (Sub#1 Grizzly, Sub#2 Dizzy, W Skiver, EX Izzy) lives in the workspace RAM notes; it lands here when tank detection is implemented.
…lient This lands the validated A1 architecture in shippable form. The whole design pivoted twice today on live evidence, and both pivots are in this commit: THE NOP THAT MUST NOT SHIP - the original plan suppressed the results-screen weapon commit (sb v0,0x4C(s0) at 0x800EECCC). Live-proven to work, and live-proven to be a trap: story chapters advance on popcount(0x800D1C4C) (2 kills -> Enigma event, 6 -> shuttle; hub fn 0x800EEF14), so suppressing the commit freezes the plot and softlocks the endgame. 0x1C4C therefore stays vanilla-written and becomes the game-side check record. CAPABILITY DECOUPLING INSTEAD - the three stage-load repopulation sites (0x8003C324/0x8003D660/0x8003D814, static EXE) copy save weapons 0x1C4C into the live player struct. The basepatch changes each lbu's offset byte 0x4C -> 0x4D, so capability derives from 0x800D1C4D: an unused, memory-card- persisted save byte that becomes the AP-owned weapons bitfield. Validated live on a prototype disc: a 3-boss save shows an EMPTY weapon menu until the AP byte is written, then exactly the granted weapons appear on next stage entry. Kills record, items are AP's alone, persistence rides the memcard. worlds/mmx5/disc.py - self-contained disc model: PS1-RAM-address -> raw .bin offset mapping (SLUS EXE + results-overlay module regions), the AP edit list, and Mode2 Form1 EDC/ECC regeneration. Parity regen is MANDATORY per modified sector - BizHawk's disc layer uses the RSPC parity to error-correct unparitied edits back to vanilla (observed live: a bare 4-byte NOP silently reverted until the sector was re-paritied). apply_basepatch() is the single funnel for all image writes so regen always runs last. worlds/mmx5/Rom.py - APProcedurePatch container (.apmmx5 -> .bin + .cue), modeled on MMX4's but pure Python: no xdelta executable, no separate basepatch file - players need only their disc image, md5-pinned (09e670f6e666211b7fcdbb7d48b716e1, the dump this was developed against). Per-seed edits travel as seed_edits.json inside the patch container rather than APTokenMixin tokens, because raw token pokes would bypass parity regen. __init__.py - host settings (rom_file with md5) + generate_output, so every generated seed emits the player's .apmmx5. client.py - disc-mode autodetection: probes the first repopulation site's offset byte (0x4C vanilla / 0x4D patched) at validate_rom and logs the mode. On patched discs weapon grants OR into 0x1C4D and never touch 0x1C4C (which would falsely advance story chapters); vanilla discs keep the development hybrid behavior. Guarded writes now guard the byte actually written. Round-trip verified: Generate.py -> .apmmx5 -> patch() reproduces the live-validated prototype disc byte-for-byte (md5 match), correct cue. Carried forward: pickup jump-table stubs (awaiting EXE free-space canary validation through play), the Enigma/shuttle launch-roll site (DuckStation watchpoint session), and moving hearts/tanks/armor grants to AP-owned state once their capability readers get the same treatment.
… fixed Every fresh client session re-applied the entire received-items list, because "how many grants has this save absorbed" lived in a Python field that died with the process. Watched live all session as +2 max HP per Heart Tank on every reconnect (0x22 crept to 0x2E across the day). The same hole also mis-served savestates: rewinding past a grant left the client convinced it had already applied everything. The count now lives IN the save: u16 LE at 0x800D1C4E, inside the 0x1C4D-0x1C50 run of bytes the game never writes but the memory card serializes (file offsets +0x4B..+0x50 - same run that already hosts the AP weapons capability byte at 0x1C4D). That single choice buys all three missing properties at once: * client restart / reconnect: count rides RAM -> no re-apply * memcard reload on a later session: count rides the card -> no re-apply * savestate rewind past a grant: count rewinds WITH the granted effects, so the client correctly re-applies exactly what the rewind undid Write discipline: the new count is committed in the SAME guarded-write batch as the effects it accounts for, and the guard compares the count itself - if anything moves between read and write the whole batch drops and retries next cycle. No effects-without-count or count-without-effects window exists. Pre-scheme saves read count=0 and absorb one final full re-apply on first connect (accepted: the playtest save is already drifted), then hold. A save claiming MORE items than the server has sent gets clamped with a warning - that path is the placeholder for wrong-save/wrong-seed protection, which wants a seed stamp in another spare byte (last A3 persistence item). Carried forward: live smoke-test of this + disc-mode detection + 0x1C4D grants together; tank/armor grants still gated on pickup-stub work (their vanilla state bits double as our check-detection source, so hybrid grants would self-check).
…robe on gate rise The live smoke test flushed out two ways the one-shot boot-time disc probe could lie, both observed in the log within minutes of each other: * BOOT RACE - validate_rom can run while the BIOS is still streaming the EXE from disc: the signature at 0x10000 is present (it loads early) but the probe site at 0x3C324 still reads zeros, and the old code classified that as "vanilla". Probe classification is now three-state; an unrecognized read logs as undetermined and the first gameplay cycle (EXE guaranteed resident) resolves it before any grant is written. * SAVESTATE EXE SWAP - savestates restore ALL of RAM including the loaded EXE. Loading a state made on an unpatched boot over a patched-disc session silently reverts the repopulation sites in RAM (watched live: patched disc, valid parity, probe reading vanilla bytes - the state predated proto v2). Disc mode is therefore no longer a boot-time constant: the client re-probes on every save-struct gate rising edge, so a state swap flips the mode within one cycle instead of poisoning the session. Grants hold (with a warning) while the mode is unresolved - never guess which byte to write. Smoke-tested end to end after the fixes: fresh boot on the patched disc -> AP-PATCHED at boot AND at gate rise, grants applied items 0->6 with weapons routed to 0x1C4D (empty weapon menu on a no-weapon-items seed = the pure-randomization contract working), hearts +8 once, and a post-grant savestate cycle + in-game save produced zero re-grants - the persistence counter's first live validation.
Companion to research repo 5565ba6 (pickup check-record stub, proto
v3): the client now consumes the stub's mailbox ring, and the world
gains the four tank locations the stub makes randomizable.
World content (25 -> 29 locations, pool auto-balanced):
* names/items/locations/__init__: Sub-Tank x2 (Grizzly Slash, Dark
Dizzy), W-Tank (The Skiver), EX-Tank (Izzy Glow) - stage homes from
the 2026-07-31 placement-record harvest. Location ids take the
reserved +3 slot in each stage block; ids are append-only as before.
Generation verified (29 items filled, playthrough calculates).
Basepatch (disc.py): the 19-word pickup stub at 0x800776A0 + the five
jump-table redirects (kinds 0/1/9/A/B at 0x80011068+) join BASE_EDITS.
Cross-checked byte-for-byte against the research pipeline: stub bytes
identical, and apply_basepatch(vanilla) md5-matches the built proto v3
disc exactly (83621887448097ef36d7cdb58c7be8af).
Client (client.py):
* second tri-state probe (jump-table kind-0 entry at 0x011068:
0x800540A0 vanilla / 0x800776A0 stubbed), resolved at validate and
re-probed on every gate rising edge alongside the disc-mode probe -
savestates can swap the EXE mid-session.
* ring consumption: 16 slots of {stage, kind, id, seq} read each poll;
seq bit7 = valid. Hearts map id->stage via the placement bit map
with a stage cross-check that also drops Axle the Red's armor-gated
decoy heart records (ids 0/1/2 in stage 7 would otherwise send
Grizzly/Squid/Izzy hearts). Tanks map by unique (kind, id). Kind-1
EX energy-ups have no locations yet - logged once and consumed.
* ack protocol: a record is consumed (seq zeroed, guarded on the whole
slot so a concurrent stub overwrite loses cleanly) only AFTER the
server confirms the location - until then the idempotent check is
re-sent each poll, so a disconnect can never eat a check. Unmapped
records are consumed immediately.
* tank grants: idempotent OR of received tank items into save byte
0x1C7F (u16 0x1C7E bits 12-15: sub1/sub2/W/EX, engine-honored, menu
shows them immediately). First/second received Sub-Tank -> bits
12/13. Granted sub-tanks start empty, same as a vanilla save-reload.
* pickup checks are now dual-path: save-struct bits on vanilla/v2
discs (hybrid mode unchanged), ring records on stub discs (where
suppressed pickups never set save bits).
Carried forward: live validation of the stub + ring + client loop on
the proto v3 disc (test protocol in the workspace handoff), EX
energy-up locations once their stage map completes, armor capsule
hook (spec item 6), launch determinism wiring.
Closes the A3 remainder. The failure it prevents: connecting a save
that was progressed under a different seed (or slot) would send that
save's bits as checks and mis-apply grants against the wrong
items_received order - the processed-count clamp only caught the
"save ahead of server" direction.
Mechanism (client.py):
* stamp = 1-byte deterministic hash of f"{seed_name}:{auth}" (never 0;
Python's salted hash() avoided on purpose), stored in 0x800D1C50 -
the LAST spare byte of the memcard-persisted unused run (0x1C4D
weapons, 0x1C4E/4F processed count, 0x1C50 stamp - the run is full
now).
* a SANE save (max-HP bounds) carrying a nonzero foreign stamp halts
BOTH check detection and grants, with a warn-once log that names the
deliberate-reuse escape hatch (zero 0x1C4D-0x1C50 via Lua console).
Recovery is logged when the right save comes back.
* unstamped saves (byte 0) pass freely and adopt the stamp inside the
first guarded grant batch - same batch, same guard, so a savestate
rewind cannot split stamp from effects. Pre-scheme saves therefore
keep their documented first-connect behavior.
* insanity (menu garbage / boot) skips the stamp gate the same way it
skips grants - no spurious wrong-save warnings from garbage reads.
Generation + py_compile re-verified. Live validation rides the same
proto v3 session as the ring work (research handoff has the protocol).
Companion to research repo fef1151 (first live record). The live run proved the stub chain end to end - the Grizzly heart collected through the stub with the vanilla grant fully suppressed (no jingle, no 0x1C80 bit, no max-HP change in the diff log) - but the record's stage byte read 0xE4. 0x800D1C41 turned out to be a hub/menu-time field only; its earlier 02/06/09 readings all predated stage entry, so it was never valid at pickup time. * disc.py stub word 7: lbu offset 0x1C41 -> 0x1C0C. 0x800D1C0C is the spawn engine's own stage input (list = *(0x80072EAC + stage*8 + area*4)), the same 1=Grizzly..8=Skiver numbering the placement harvester and the client's STAGE_ID_TO_NAME already use, and the community "Level Modifier" code's target. * apply_basepatch(vanilla) md5-verified byte-identical to the research pipeline's proto v4 disc (96470d12) - both pipelines agree. Carried forward: live retest on v4 to confirm sane stage bytes in ring records; client mapping needs no change (it already keys on the 1..8 ids).
Companion to research repo e7e03de. Second live finding: with the stage source fixed, the Izzy heart still recorded kind=6 id=00 instead of kind=0 id=02. Root cause was a transcription bug present since the first stub build: the two loads that must read the item object (s1) were hand-typed as 0x926C.... (rs=s3) instead of 0x922C.... (rs=s1) - one hex digit, wrong register field. Dispatch and suppression were unaffected (the dispatcher indexes on the real kind byte), but the recorded kind/id came from whatever s3 pointed at. * disc.py stub words 9/11: 926C0082/926C0002 -> 922C0082/922C0002 (lbu t4,0x82(s1) / lbu t4,2(s1)), comments note the buggy history so the hex is auditable. * The research pipeline now composes these words from field encoders with named registers (mmx5_build_patch.py) and asserts the corrected values; disc.py keeps documented hex but is cross-checked byte-for-byte against it - apply_basepatch(vanilla) md5 ae1954f9 matches the built proto v5 disc exactly. Carried forward: live retest on v5 (expect stage=6 kind=0 id=02 for the Izzy heart); the proto Lua now identifies the RESIDENT stub variant so savestate-reverted EXEs can no longer masquerade as the current build.
The recomp project folks reached out with questions, and Ivor wants the research notes readable from the fork (matching the convention of other worlds keeping docs under worlds/<game>/docs). The private research repo keeps the working copies plus everything that stays private (ai-docs/, .claude/); this mirror carries only the four technical references, each stamped with a provenance header saying where the living copies are and that the mirror re-syncs on address changes. * mmx5-ram-notes.md - the verified RAM map (corrections banners on top, including the 2026-07-31 stub-validation update: 0x1C41 disproven mid-stage, 0x1C0C is the spawn engine's stage input). * mmx5-overlay-findings.md - THE technical map: pickup dispatcher + jump table, placement-record format/spawner, patch recipes, disc streaming + EDC/ECC facts (raw overlays in ROCK_X5.BIN - the compressed-overlays claim is disproven in 9.0), hub, launch resolution decode (score formula + RNG fn 0x8002DF78). * mmx5-ghidra-findings.md - player struct, damage pipeline, i-frames. * mmx5-cheat-archive.md - community code archive (gamehacking.org 89275) with cross-validation notes against our map. No game data, ROMs, or disc images ride along - text only.
capture s1 itself
The v5 retest deepened the mystery instead of closing it: with the
register encoding verifiably fixed in RAM ("stub RESIDENT: v5 (good)"
banner), the Izzy heart AGAIN recorded {stage=46, kind=6, id=00} -
byte-identical to the v4 run. The values are deterministic, not noise.
Meanwhile suppression keeps working (no jingle, no 0x1C80 bit, no
max-HP change at the pickup), so the collection genuinely flowed
through the stub via the jump table.
That is a contradiction under the current model: the dispatcher
(0x8005406C..98) indexes the table with lbu v1,0x82(s1) - the stub can
only be reached when that byte is one of the redirected kinds
{0,1,9,A,B} - yet the stub reading the SAME byte moments later gets 6.
Either s1 is not what the disassembly notes say in this context, some
other code path reaches the stub, or the byte is rewritten in the
window. No more inference; instrument and measure.
* mmx5_build_patch.py: stub v6 (20 words) adds ONE instruction -
sw s1,0xA0(t2) - a parallel pointer ring at 0x801FA0A0+slot*4
capturing the actual s1 the stub saw. Debug-only; remove pre-ship.
* mmx5_ap_patch_proto.lua:
- variant probe knows v6 (word +0x40 == 0xAD5100A0); tail-integrity
check is variant-aware (+0x4C on v6, +0x48 before).
- on each record with a v6 stub resident, the watcher dumps the
captured object: header bytes +00..17, kind byte +82, x/y, and the
placement-record pointer at +10 with its 8 raw bytes - enough to
identify what object s1 REALLY points at.
- probe() console helper logs the RAM jump table (all 14 entries),
the live stage bytes (1C0C/1C0D/1C41 + mode), and the dispatcher
opcodes 0x80054020..9C as they exist in RAM (catches runtime
rewrites of the table or dispatcher that disc reads cannot see).
* Proto v6 disc built + SaveRAM cloned; disc.py carries the same word
(marked debug) so the pipelines stay md5-identical (fd0e2522).
Carried forward: user runs probe() in-stage then collects the heart on
v6; the captured pointer + object dump decides between wrong-s1,
second-caller, and byte-rewrite theories.
ROOT CAUSE FOUND via the v6 s1 capture. The captured pointer was the
genuine Izzy heart object (x/y 768/662, placement record 800F32FC =
01 2F 02 10 00 03 80 02 - flags/minor=item/id=02/spawned/x=768/y=640),
yet the ring record read {stage=46, kind=6, id=0}. Against the true
values {6, 0, 2} the fields are shifted one store late: the kind slot
held the STAGE value, the id slot held the KIND value, and the stage
slot held stale register contents (0x2E from the caller). Only the seq
byte - ALU-computed, not loaded - was ever right.
That is the R3000 LOAD DELAY SLOT: a loaded value is not available to
the immediately following instruction. The stub did `lbu t4,X;
sb t4,Y` three times back to back, so every sb stored the previous
load. The game's own dispatcher carries a compiler-inserted NOP after
its lbu (0x8005406C: lbu; nop; sltiu - visible in the probe() dump of
live RAM) for exactly this reason. A second latent instance - the ring
index computed by `lw t1; andi t2,t1,0xF` in the delay slot - only
landed records in slot 0 because the caller's stale t1 happened to
mask to 0.
Everything else the mystery had accused is now exonerated and proven:
* probe() showed the RAM jump table fully redirected (0/1/9/A/B ->
stub) and 0x1C0C == 06 mid-stage in Izzy - the stage source is GOOD.
* suppression is airtight across every run (no jingle, no 0x1C80 bit,
no max-HP change), and the earlier records' "impossible kind=6" was
the stage value photobombing the kind slot all along.
v7 changes:
* mmx5_build_patch.py: loads interleave through t4/t5/t6 with first
use >= 1 instruction after each load - no NOPs needed, still 20
words. lui t3 moved into lw t1's delay slot. New static guard walks
the words and asserts no load feeds the instruction after it.
s1-capture word kept for this one validation round; strip pre-ship.
* worlds sync (disc.py): same v7 body with the delay-slot comment
block; cross-pipeline md5 1d2cc293 verified identical.
* mmx5_ap_patch_proto.lua: variant probe fingerprints v7 by the t5
kind load at +0x20 (0x922D0082) and now labels v5/v6 as BUGGY load
delays; tail check already variant-aware.
Carried forward: v7 live run - expect the first fully correct record
{stage=6 kind=0 id=02 seq=80} for the Izzy heart, then EX-tank
{6/B/2A}, then the client end-to-end on the smoke seed. Also queue a
patch-spec ground-rule addition: hand-written MIPS for this project
MUST be load-delay audited (the assembler guard now enforces it).
Built against Ivor's v1 provisional design answers (recorded in the questions doc): Sigma default + optional launch goal, all-4-per-launcher rule (all 8 under the launch goal), vanilla failure consequences, parts on the Energy-Up locations, countdown frozen, vanilla chapter pacing. Disc side (both pipelines, proto v8, md5 7615a816 verified identical): * launch-overlay region mapping CORRECTED by disc scan: the resolution fn's andi sits at sector 24319 user-data offset 0xD4 = RAM 0x800FA0D4 exactly, so the module maps as RAM 0x800FA000 = sector 24319 offset 0. The old research note's "+208" was wrong (overlay-findings 11 fixed; the jal+andi idiom appears at 8 disc sites - the launch copy is the one whose layout matches the module's RAM addresses). * the one-word determinism patch: `andi v1,v0,0xF` -> `li v1,0`. Success <=> score > 0; with the client pinning score, score<=0 is the game's own clean failure path (no parts -> guaranteed vanilla-style failure, story pivots as normal). Selftest guards the vanilla word. World (fork): goal option (sigma/launch), Enigma Part x4 + Shuttle Part x4 (progression under the launch goal via create_item), 8 Energy-Up locations at the per-stage +4 slot (world is 37 locations / 36 items + filler; the max-WE upgrade effect is displaced in v1 - can return as filler-tier later). Launch-goal completion = all 8 parts; both goal variants generate and calculate playthroughs. Client: * score pinning every sane cycle on patched discs: accumulators zeroed, modifier = 1 only when the offered launcher is fully parted (chapter offering inferred from kill popcount: shuttle era at >= 6) - or all 8 under goal=launch. Vanilla accrual can never decide a launch. * countdown pinned to 8h (frozen; design answer 5). * launch-goal victory: 0x1CCB bit7 -> StatusUpdate CLIENT_GOAL. * kind-1 ring records (Energy-Ups) now map to locations by stage byte. * Heart Tank items grant +2 max HP to BOTH characters (0x1C47/48) - the old X-only write quietly favored X regardless of who found it. Carried forward: LIVE validation of a launch on v8 (partless failure + fully-parted success, act byte 5), shuttle-path feeder verification, strip the debug s1-capture stub word pre-ship, EX energy-up id->stage map completion via passive harvest (client maps by stage byte so ids are informational).
The workspace Reference copy gained the 2026-08-01 disc-scan correction (section 11: launch module maps RAM 0x800FA000 = sector 24319 user offset 0; the old +208 note was wrong, and the jal+andi idiom appears at 8 disc sites of which the launch fn is the 24319 copy). Mirror re-copied per the standing sync rule so outside readers see the fix.
The launch mechanism is proven in production conditions, both
directions, with the diff log as witness:
* partless failure x2: score pin zeroed vanilla accrual (0x1CC2-C5
held 02/02/05/05 from play history -> all 00), launch resolved
attempted-without-success (0x1CCB=01), act byte 2 -> 3 - the story
pivoted down the vanilla failure path. Deterministic, reproduced.
* fully-parted success: 4 server-sent Enigma Parts flipped the pin to
powered (0x1CCA 0 -> 1 within a cycle of the items arriving), the
launch resolved act 2 -> 5 (Eurasia destroyed) with 0x1CCB = 0x81
(attempted + success). The resolution fn consumed 0x1CCA exactly as
overlay-findings 11 decoded, and the client pin re-asserted it and
re-froze the countdown within 20 frames of the success bonus.
One client bug found and fixed by the first live attempt: disc-mode/
stub probes only resolved inside the gameplay gate (stage entry), but
launches happen at the HUB (modes 0x13-0x15). A boot-to-hub path left
ap_patched unresolved, score pinning silently no-oped, and vanilla
accrual + the zeroed roll auto-succeeded the launch - the exact
failure mode the ship rule warns about. Probes now resolve on ANY
sane cycle and re-arm on EVERY gate transition (not just rising);
the in-stage duplicate resolution collapsed to a hold-grants guard.
Test-ops note for the logs: a launch only counts with the client
attached ("Connected to BizHawk" + handler running) - two early
attempts were void because Reboot Core killed the connector.
Remaining on launches: shuttle-path feeder verification (any post-6-
kills playthrough settles it). Docs: handoff queue item 2 and spec
item 4 closed as LIVE-VALIDATED.
…pping Companion to the workspace commit of the same session, which carries the full narrative: the v9 capsule freeze (a skipped `addiu a0,zero,0x86` delay slot in the spawn-gate retarget), the state-6 overlap gate that made a working hook look dead, and the live validation runs. Restating what lands here. disc.py - spawn-gate retarget corrected to match the builder The id!=8 branch at 0x80055018 now targets the despawn ladder's JOIN at 0x80055130 (word 0x14C20045) rather than the spawn path 0x80055148 directly. Jumping straight to the spawn path skipped the join beq's delay slot, which supplies a0 = 0x86 to the call at 0x8005518C; without it a0 still held the capsule object pointer and the game hung the moment a capsule spawned. The join is reached with a3 = 0 from the fn prologue, so the spawn branch is always taken and the delay slot still runs - identical always-spawn semantics, no freeze. apply_basepatch output is byte-identical to Scripts/mmx5_build_patch.py --build (md5 f025d28eb82effb43977a58bec3c5377). client.py - ARMOR_ITEM_BITS derived from the game's mask table The old `1 << i` was wrong for half the parts. The game's capsule mask table at 0x8007C370 is `01 04 02 08 10 40 20 80` - a permutation that swaps body and arm inside each set. Corrected: Falcon Body 0x02->0x04, Falcon Arm 0x04->0x02, Gaea Body 0x20->0x40, Gaea Arm 0x40->0x20. The derivation is that capsule id equals part index, established from two live captures reading the id off the live capsule object in stages whose vanilla part is documented: * Tidal Whale = capsule id 1 = Falcon Body = ARMOR_PARTS[1] * Dark Necrobat = capsule id 4 = Gaea Head = ARMOR_PARTS[4] Confirmed visually afterwards: sending all four Falcon parts produced a pause menu showing exactly the parts sent. Nibble membership (0x0F Falcon / 0xF0 Gaea) is what the results overlay checks for set completion, so the old mapping only mislabelled the status screen - but it now matches the game rather than guessing, and the provisional caveat in the comment is retired. Validation covered by this change set * Capsule recording: Tidal Whale id 1 -> `stage=3 kind=20 id=01 seq=80`, client consumed the record, server committed the check. * Suppression: Dark Necrobat id 4 with 0x1CA1 pre-cleared -> recorded `stage=2 kind=20 id=04 seq=80` and 0x1CA1 read back 00. * Armor grants: /send of 4 Falcon parts -> 0x1CA1 0x0F, then 0x1C4A 01 -> 03 at the next results screen. Note the completion condition is `(0x1CA1 & 0x0F) == 0x0F` AND `(0x1CCC & 0x0F) != 0x0F` - that second term is an ack latch that will silently block the unlock on a save carrying a stale 0x0F there. * STAGE_ID_TO_NAME corroborated twice live: Whale->3, Necrobat->2. * Heart pickup regression on v10: `stage=6 kind=0 id=02 seq=81`. docs/mmx5-overlay-findings.md re-synced from the Reference working copy: adds the full capsule state machine (12-state table 0x8007C380, the proximity/overlap gates, the 0x800AA195 sequence handoff), the exact armor-completion condition with its ack latch, and the capsule-id-equals-part-index mapping. CARRIED FORWARD Victory detection for the sigma goal is still a TODO and cannot be resolved statically - the patchless kill detect keys on results entry with stage id 1-8, which the final fight will not produce, and Sigma's code lives in a stage overlay never dumped. Zero capsule behaviour is unverified: v10 spawns capsules for Zero (no character gate exists on ids 0-7).
…mpletes
The sigma goal has shipped since the world was scaffolded but never actually
completed: victory detection was a TODO because the Sigma defeat signal was
unknown, and unknowable from static analysis. The patchless maverick kill detect
keys on results-screen entry with a stage id of 1-8, and endgame bosses do not
route through the results screen at all; Sigma's own code lives in a stage
overlay that has never been dumped. A live playthrough to the credits with five
bracketing RAM dumps produced the answer. Full evidence in the workspace commit
of the same session and in docs/mmx5-ram-notes.md.
DETECTION
After the final blow the mode byte 0x800D1C00 walks:
0x0A -> 0x13 -> 0x14 -> 0x10 -> 0x11 (credits, holds)
ENDING_MODES is {0x10, 0x11}. The two modes it deliberately excludes matter as
much as the two it includes:
* 0x13 and 0x14 also fire for the X-vs-Zero duel, so they are generic story
cutscene modes. 0x14 is additionally the Enigma firing cutscene mode already
documented. Treating either as victory would complete the goal partway
through the endgame.
* 0x0D is the death / game-over screen - observed oscillating 0A<->0D during
failed Sigma attempts. A goal that fires on game over would be worse than no
goal at all.
PLACEMENT
The check runs at the TOP of game_watcher, before the save-struct gate, and
this is load-bearing rather than stylistic. That gate admits only modes 0x0A
(gameplay) and 0x0C (results); every ending mode fails it, so a check placed
after the gate - which is where the TODO sat - would be unreachable for the
entire ending and the goal would never fire. Guarded by the existing
victory_sent flag shared with the launch goal, so it sends exactly once.
Also added GOAL_SIGMA = 0 to sit alongside GOAL_LAUNCH = 1 rather than leaving
the sigma branch keyed on a bare literal.
DEATHLINK NOTE
The DeathLink TODO now carries a warning instead of an invitation. 0x800D1C1C
was the obvious candidate and it does go 00 -> 01 across the Sigma fight - but
it does the same across the X-vs-Zero duel, so it is not obviously "the player
died" and wiring it up on that assumption would produce spurious deaths on
story boss kills. Needs disambiguating first.
Generation passes (py -3.13 Generate.py). Docs mirror re-synced: the endgame
section now carries the mode table, the corrected non-contiguous endgame stage
ids (Zero Space 1 = 0x10, X-vs-Zero duel = 0x12, Sigma = 0x0C), and the latched
byte candidates should a more durable marker ever be wanted.
CARRIED FORWARD
Victory detection is implemented but NOT yet live-tested end to end - the
sequence was captured from dumps and the mode log, and the client change has
not been exercised against a running server with a sigma-goal seed. Next
playthrough that reaches the credits should confirm the goal actually lands.
The detection landed last commit built purely from RAM dumps and the mode log - correct in principle but never exercised against a running server. It has now been proven end to end and the docs say so. Run: loaded a savestate from just before the Sigma kill, landed the final blow, mode walked 0A->13->14 and then, after roughly 4700 frames of cutscene, 14->10->11. The client fired CLIENT_GOAL, the server committed it (smoke.apsave 3870 -> 4010 bytes, written the same minute), and AP auto-released the slot's remaining items. That last part is the load-bearing evidence rather than the client's own log line: this seed carries collect_mode auto / remaining_mode goal, and AP only auto-releases a slot's remaining items on a genuine goal status. The release therefore confirms the SERVER processed it, which a client-side log alone could never establish. * Recorded the cutscene delay as a testing note, because it reads exactly like a failure: about 78 seconds separate the kill (13->14) from the ending modes (14->10). During that window the mode log sits at 14 and the server state file is untouched, so the obvious conclusion is "the goal did not fire". It had simply not happened yet. Anyone re-testing should wait for 14->10 before drawing any conclusion. * Handoff next-work item 2 struck through, with the implementation summary and the reason the check must sit above the save-struct gate carried into it so the constraint is not rediscovered by someone tidying the method later. CARRIED FORWARD Unchanged: the 0x1C1C DeathLink candidate still needs disambiguating (it moves on story boss defeats, not obviously on player death), Sigma's HP address is still unknown, and the pre-ship chores are still open.
…uestions Tonight's work closed several things the docs still described as open, and left two superseded handoffs whose next-work queues would actively mislead the next session. Sweeping them. SUPERSEDED HANDOFFS Both older 2026-08-01 handoffs now carry a banner pointing at `2026-08-01_mmx5-b1-core-fully-validated.md` as the entry point, and name what in them is stale rather than leaving the reader to work it out: * `capsule-hook-built-session-end`: its banner previously ended "Everything else below still stands", which is no longer true - next-work item 1 (capsule live-validation) and item 2 (Sigma victory detection) are both complete, and the Zero-capsule sub-item rested on a wrong premise that is corrected inline. The design/disassembly narrative is still good and is kept. * `fresh-start-post-b1-core`: same treatment; its two stale items are the capsule hook (built and validated on v10 - v9 froze the game) and Sigma victory detection. Struck the individual Sigma line in each rather than deleting, so the history of what was believed when stays legible. OPEN QUESTIONS - TWO ANSWERED * "Does granting a weapon in 0x0D1C4C make the stage count as beaten for stage select / ending gates?" YES. The hub fn 0x800EEF14 derives the story chapter from popcount(0x1C4C), and a save carrying 0x1C4C = FF walked the entire colony-resolution -> Zero Space -> Sigma path to the credits. The bitfield IS the endgame gate, which is precisely why the AP patch must never suppress its commit and instead moves capability to 0x1C4D. Caveat recorded: stage select displays no per-stage beaten indicator, so nothing reads it for UI. * "Armor capsule pickup: which bits per capsule; does armor activate immediately or need stage re-entry?" Bits live in 0x1CA1 (0x1CA0's low byte is the armor LEVEL, not a bitfield), one per capsule via maskTable[id], and capsule id == part index. Activation is NOT immediate: the results overlay sets the set-completion flag at the next results screen once a nibble fills, gated additionally by the 0x1CCC ack latch. CLAUDE.md Registered `Scripts/mmx5_capsule_watch.lua` alongside the other research scripts, and added three entries to "Facts that keep biting" - each one cost real time tonight: * Armor capsules require the player to physically OVERLAP them. Proximity alone opens the capsule but the sequence stalls at state 6 waiting on a collision flag, and standing beside an open capsule is indistinguishable from a dead hook. This burned a full session. * BizHawk SaveRAM autoflush is off, so in-game saves live only in emulated RAM until Ctrl+S or a clean close, and savestates carry the memcard - loading an old state silently rolls back saves made after it. This is what ate a real endgame save tonight. * Endgame stage ids are not contiguous (Zero Space 1 = 0x10, X-vs-Zero duel = 0x12, Sigma = 0x0C), so they must be read on entry rather than inferred. Docs mirror re-synced.
set_rules() carried exactly one rule (the Sigma-stages entrance) and left every location always-reachable, so generation could place a progression item behind a requirement the player could not satisfy and emit an unbeatable seed. No mechanism was broken; the logic layer simply did not exist. It does now. THE REQUIREMENT TABLE, AND WHY IT IS TRUSTED Sourced from the retropixel MMX5 items guide, but adopted only because it cross-validates against data this project gathered independently, at six points: * Falcon Body = Duff McWhalen - matches capsule id 1, read live off the capsule object during validation. * Gaea Head = Dark Dizzy - matches capsule id 4, read live. * All four tank stages match the client's TANK_RECORD_TO_STAGE, which came from the placement harvest rather than any guide. Those agreements also re-confirm capsule id == part index, which is the join that lets live id readings and the guide's part names be reconciled at all. A CORRECTION TO THE PLAN DOC An earlier draft flagged a "circularity hazard": Falcon Leg appearing to require Falcon Armor, which would be unobtainable by construction. That was wrong - it came from reading a walkthrough line too literally, and it does not survive contact with the real table. NO Falcon part requires Falcon Armor. The three capsules that do require it are all GAEA parts, which is sequential and perfectly sound. The doc now records both the error and the correction. What does survive from that note, and is encoded deliberately: where a rule depends on armor it must key on the four part ITEMS, never on "reached the capsule that vanilla-holds them", because AP shuffles the parts away from their vanilla capsules. RULES ADDED * Capsules: Goo Shaver (McWhalen), C-Shot (Izzy Glow), F-Laser (Dark Dizzy), Falcon Armor (Skiver / Mattrex / Axle the Red). Grizzly Slash and Squid Adler need no items - the latter is gated on collecting 8 jet-bike energy balls, which is execution, not inventory. * Heart tanks: Gaea Armor for Grizzly / Squid Adler / Izzy Glow / Axle the Red, Falcon Armor for Duff McWhalen, nothing for Dark Dizzy / Skiver / Mattrex. * Tanks: Falcon Armor for the Dark Dizzy sub-tank, Ground Fire for the Izzy Glow EX-tank; Grizzly sub-tank and Skiver W-tank free. This produces real dependency depth: Gaea Armor needs all four Gaea parts, three of which need Falcon Armor, so every Gaea-gated heart transitively requires both complete sets. Deep, but acyclic - and generation confirms it. The Sigma-stages entrance keeps its all-8-weapons rule, now with a comment explaining that this is deliberately STRICTER than the game (which only needs the colony situation resolved, per the endgame gating we mapped last session). Stricter only narrows placement and can never strand progression, so it stays. VERIFICATION 6/6 seeds generated cleanly with playthrough calculation passing - that step is AP proving completability, so it is a real check rather than a smoke test. CARRIED FORWARD Energy-Up requirements have never been surveyed and remain unrestricted in logic. That is the one remaining place a seed could in principle strand progression, and it is flagged in the plan doc so the absence is not mistaken for a verified-free result.
…xist
Ivor identified that MMX5 has no stage-pickup Energy Ups at all. They are the
post-boss DNA reward choice ("Weapon + Energy") that Alia offers after a
Maverick dies, applied to whoever finishes the NEXT stage - max 8 per run, one
per Maverick, and none of them lying around in a stage.
Our own data corroborates it completely, which is worth stating because it means
this was visible in the logs all along:
* The pickup stub has NEVER recorded a kind-1 item. Across every logged session
the only kinds seen are 0 (heart, 19 records), 6 (3), 0xB EX-Tank (2) and
0x20 capsule (17). The client's kind-1 -> Energy-Up branch has never fired.
* The placement harvest contains no ids 0x10-0x17.
* No guide describes an "EX item" pickup category - the only EX thing in a
stage is the single EX-Tank, already modelled as a tank location.
SEVERITY
This is not cosmetic. locations.py creates 8 Energy Up locations that can never
be checked, and nothing stops progression being placed there. In a MULTIWORLD
that strands ANOTHER player's item and breaks THEIR seed, not just ours. It also
undermines the design doc's plan to home the 8 Enigma/Shuttle part items on
these locations, which would have broken the launch goal specifically.
WHY THIS COMMIT ONLY FLAGS IT
Both obvious repairs fail on item/location balance (36 items, 37 locations), and
I verified both rather than assuming:
* LocationProgressType.EXCLUDED on all 8 -> generation dies outright: excluded
locations may hold only filler and the pool has exactly ONE filler slot.
Fill.py: "There are 7 more excluded locations than excludable items."
* Deleting the 8 locations -> 36 items cannot fit in 29 locations.
Either fix therefore needs a POOL change, which is a design decision rather than
a bug fix, so the code carries a prominent warning at the point of use instead of
a silent half-repair that would look fixed without being fixed. Generation is
left working (4/4 seeds) rather than broken.
OPTIONS, written up in the reachability plan
1. Replace them with the 8 per-boss DNA-reward choices (recommended) - real,
player-facing, exactly one per Maverick, literally what players call an
Energy Up, and balance-neutral. Detection groundwork already exists: the
pending-DNA buffer at 0x800D1D28-2A is documented, written at the DNA select
screen and zeroed when delivered at the next results sequence.
2. Delete the locations and cut 8 items - the natural cut is the 4 Enigma + 4
Shuttle parts, but those are exactly what the launch goal requires, so it
would have to be goal-conditional.
3. Ship sigma-goal-only for v1 with the part items removed.
…checks
The 8 "Energy Up pickup" locations did not exist in game - MMX5 has no Energy Up
items lying in stages at all. They are the post-boss DNA reward choice Alia
offers ("Weapon + Life" / "Weapon + Energy"), one per Maverick, applied to
whoever finishes the next stage. Those locations could never be checked, and
nothing stopped progression landing on them; in a multiworld that strands
ANOTHER player's item and breaks THEIR seed.
Replaced rather than deleted, which keeps the item/location balance intact
(still 37 locations vs 36 items) and turns 8 dead entries into 8 real checks.
* names.energy_up_location -> names.dna_location, "{stage} - DNA Reward".
* Location id kept at base+4 so every other location id stays stable.
* Detection moved off the pickup ring and onto the save struct. The reward
lands in the same u32 as heart tanks: ids 0-7 set bits 8-15 (Life Up), ids
8-15 set bits 24-31 (Energy Up), both at bit (stage id - 1) of their byte.
Either choice checks the location, because the CHOICE is the event, not
which branch of it the player took.
* Deleted the kind-1 ring branch outright and left a comment explaining why,
so it does not get re-added. It was dead code from the start: across every
logged session the stub recorded only kinds 0 (heart), 6, 0xB (EX-Tank) and
0x20 (capsule). Kind 1 never fired once.
* The client logs the first DNA reward seen per stage, deliberately - see the
caveat below.
CAVEAT
The "reward id == stage id - 1" relation rests on a SINGLE observation
(2026-07-31: Life Up id 5 on stage 6 -> u32 bit 13). It is the weakest link in
this change, which is why the first-sighting logger exists: confirming it costs
one boss kill on a second stage.
WHY THE LAUNCHER PARTS DO NOT GET THEIR OWN LOCATIONS
Recording this because it looks like they should, and the reasoning is what
found the phantom in the first place. Vanilla grants TWO things per Maverick -
the weapon AND an Enigma/Shuttle part - both derived from the same 0x1C4C bit
(parts table 0x800F5194 = [0,4,5,2,7,6,1,3]: Enigma page = Grizzly/Adler/Izzy/
McWhalen, Shuttle page = Skiver/Axle/Dizzy/Mattrex). Modelling only the weapon
left 8 part ITEMS with no home, and that asymmetry is precisely why a phantom
pickup location got invented to hold them.
A dedicated part location per boss would nonetheless be wrong: it would fire
from the same trigger as the boss check, always and simultaneously. Two
locations that can never be checked independently add no gameplay meaning and
exist only to absorb items - the same smell as the phantom they would replace.
They are also unnecessary, because AP shuffles items freely: the 8 part items
need 8 slots somewhere among the 37 locations, not slots mirroring their
vanilla source.
Verified: 5/5 seeds generate with playthrough calculation passing.
…l risk Two things settled about the new DNA reward locations, one a decision and one a risk that could invalidate them entirely. DECISION: the vanilla stat grant stays UNSUPPRESSED The player picks "Weapon + Life" or "Weapon + Energy", gets that boost as normal, and receives the AP item on top. The choice does not influence which AP item appears - that is whatever generation placed there - but it still shapes their own build, which is worth keeping. The decisive reason is not preference, it is mechanical: our detection reads bits 8-15 / 24-31 of 0x800D1C80, which is precisely where the vanilla applier writes. Suppressing the grant would mean those bits never set and the check never fires. Purity would therefore require rebuilding detection on the pending buffer at 0x800D1D28-2A FIRST, then adding Life Up / Energy Up items - which the pool cannot currently absorb anyway (37 locations vs 36 items leaves no room for 16 more). Recorded so a later "let AP own all stat growth" attempt knows the real order of operations rather than starting with the suppression. RISK: the DNA choice may not always be offered Alia offers the choice only after beating a Maverick of boss level 4 or higher (level 8+ additionally yields an equippable Part). Boss level rises with elapsed time - and our design PINS the countdown at 8 hours per design answer 5. If pinning holds boss levels below 4, the prompt never appears and all 8 DNA locations are phantoms: exactly the failure just fixed, reintroduced through a different mechanism. Flagged in the plan and in the client at the point of use, and it must be checked before these locations are trusted - one Maverick kill on a pinned-countdown seed settles it. If the prompt does not appear, the options are to unpin the countdown, seed the boss level directly, or find a different set of 8 locations. The client's once-per-stage DNA logger now serves both open questions: whether the prompt appears at all, and whether reward id really equals stage id - 1.
…ad-bearing Ivor: if the DNA prompt is only offered at boss level 4+, a player could be locked out of ever getting all 8. That escalates the open risk from "these locations might not exist" to "these locations might be permanently lost mid-playthrough", which is strictly worse - generation places progression on a missable check just as readily as any other, and the player only discovers the problem after the boss is dead. The chain, with the escape hatch now closed: * Alia offers the choice only for a boss of LEVEL 4+. * BOSSES DO NOT RESPAWN. Confirmed live this session: the player dropped into Grizzly's boss room on an already-cleared stage and it simply ended - there was no boss to fight. So there is no rematch at a higher level. * Therefore any Maverick killed below level 4 loses its DNA reward permanently. Suggestive evidence already sitting in the test save: Grizzly is beaten yet carries no DNA bit - exactly the signature of a missed reward. Not proof, since that kill may predate the countdown pin. WHAT MAY SAVE IT, AND WHY THAT NEEDS SHOUTING ABOUT The client forces the countdown to exactly 8 hours on EVERY cycle, from the first stage onward. If boss level derives from elapsed time, every boss sits permanently at "8 hours" level - and if that is at or above 4, the prompt is always offered and nothing is ever missable. Which would make COUNTDOWN_FROZEN accidentally load-bearing for a completely unrelated subsystem. Removing or retuning the freeze - precisely the kind of tidy-up a later maintainer would do, since it currently looks like a comfort setting - would silently make 8 checks permanently missable. Flagged at the constant itself, not only in the plan doc, because that is where someone about to change it will actually be looking. If the prompt turns out NOT to be guaranteed, DNA rewards cannot serve as AP locations at all and a different set of 8 is needed. The live test that settles this is now design-critical rather than a curiosity: kill any living boss with the pin active and see whether Alia offers the choice.
…d entirely Ivor: we want any boss kill to always give the DNA check, so ignore the choice and grant it with the kill. Correct, and it dissolves a problem rather than managing it. Detecting the actual reward made the check MISSABLE. Alia offers the choice only for a boss of level 4+, and bosses do not respawn - confirmed live this session when entering an already-cleared boss room simply ended the stage. Any Maverick killed early therefore lost its DNA reward permanently, and generation would happily have placed progression on a check the player could never obtain. The test save already shows the fingerprint: Grizzly beaten, no DNA bit. Checking on the kill instead removes every dependency at once - boss level, the countdown pin, and whether the player even noticed the prompt. Both locations now fire from the same 0x800D1C4C bit. SIDE EFFECT WORTH NAMING: COUNTDOWN_FROZEN IS FREE AGAIN The pin had accidentally become load-bearing for a subsystem it has no relation to - it forces 8 hours from the first stage, which may have been the only reason every boss cleared the level-4 bar. That coupling is gone. The pin is back to being purely about sortie budget and can be retuned on its own merits, and the warning comment on the constant has been replaced with a note recording that the coupling existed and why it no longer does. AN EARLIER OBJECTION IN THIS REPO WAS WRONG I argued against a per-boss "Launcher Part" location on the grounds that two locations sharing one trigger are redundant padding. That reasoning does not survive: vanilla genuinely grants MULTIPLE rewards per Maverick - the weapon, the DNA choice, and an Enigma/Shuttle part - so mapping one event onto several locations is faithful to the game rather than invented. Ivor's original "each stage clear becomes 3 checks" framing was right and I talked us out of it. The launcher-part location can be revisited on the same reasoning if more locations are ever wanted; the plan doc now records this rather than the old objection. The 0x1C80 reward bits (Life-Up 8-15, Energy-Up 24-31) stay useful for research - Scripts/mmx5_dna_watch.lua decodes them - but nothing in the client depends on them now, which also retires the unverified "reward id == stage id - 1" relation as a correctness risk. Removed the now-dead dna_seen bookkeeping. Verified: 5/5 seeds generate with playthrough calculation passing.
…operly
Ivor supplied the actual formula, which resolves several threads that had been
running on inference. Recorded in Reference/mmx5-ram-notes.md and mirrored.
base from HOURS REMAINING (16-17 -> 1, 14-15 -> 3, ... 8-9 -> 9, ... 0-1 -> 17)
+1 per Maverick defeated (max +8)
+1 per Special Weapon/Technique owned (max +8; ignored vs Dynamo and while
using Gaea Armor)
+ Hunter Rank bonus (E/C/B/A +0, SA +2, GA +4, PA +8, MEH/MMH +16)
recalculated at the start of each stage; scales boss HP only
It checks out against our own live reading: The Skiver at LEVEL 19 with the
countdown pinned at 8 hours = base 9 + 5 Mavericks + 5 weapons. The formula and
the observation agree exactly.
WHAT IT SETTLES
* The countdown pin at 8 h fixes the base at 9 - above BOTH reward thresholds
(4+ for Life/Energy Up, 8+ for Life+/Energy+ AND an equippable Part). A
pinned seed therefore always offers the top tier, and the prompt can never
fail for time reasons.
* Scaling survives the freeze. The base is fixed but the Maverick and weapon
terms keep climbing - roughly level 9 at the first boss to about 25 at the
last. The objection that freezing the countdown would flatten difficulty is
answered; design answer 5 stands.
WHAT IT REVEALS - a second reason the DNA rewiring was necessary
EASY MODE LOCKS EVERY BOSS AT LEVEL 1, with no Life/Energy Ups and no Parts at
all. Under the prompt-based DNA detection I originally wrote, every one of the
8 DNA checks would have been permanently unobtainable for any player on Easy -
a whole-difficulty-mode dead zone that no amount of testing on Normal would
have surfaced. Checking off the boss kill is immune. Noted at the point of use
in client.py so it is not "improved" back later.
ALSO WORTH FLAGGING
* Any future option to unfreeze or retune the countdown changes reward pacing:
hours remaining drives the base, so vanilla pacing starts bosses at level 1-3
and withholds the prompt early. Harmless for checks now, but it is a design
consequence rather than a neutral toggle.
* Level 8+ additionally grants equippable DNA Parts (u32 0x800D1C84) - an
entire reward stream we do not model at all. Recorded as candidate future
locations.
…ry constant Ivor asked who chose the 8-hour pin value and whether the save had simply been sitting at 8 already. Checked, and the suspicion was justified. Design answer 5 says only "countdown frozen" - it specifies NO value. The commit that implemented it (b7a0863) records "countdown pinned to 8h (frozen; design answer 5)", presenting 8 as though it followed from the decision. It did not. The 8 was an undocumented implementer's choice, made before the boss-level formula was known - i.e. before anyone realised the number sets boss difficulty at all. Nothing in the repo falsifies "it was whatever the save read at the time". That it lands well (base 9 clears both reward thresholds) was luck. Now it is a deliberate option instead of an accident: relaxed 17 h -> base 1 gentlest; reward tiers unlock late standard 8 h -> base 9 top reward tier from the first boss (old value) intense 1 h -> base 17 hardest; top tier throughout The hours-remaining -> base mapping is the game's own (recorded in Reference/mmx5-ram-notes.md). Pinning fixes only the BASE; +1 per Maverick and +1 per weapon still accumulate, so bosses keep scaling across a run regardless of setting. Every setting is seed-safe: AP checks never depend on boss level, because the DNA reward locations are checked when the boss dies rather than when the reward prompt appears. This option moves difficulty and vanilla stat pacing only. * COUNTDOWN_FROZEN replaced by countdown_frozen_value(ctx), reading boss_difficulty from slot_data with the old 8 h as the fallback. * The provenance is recorded at the constant itself, so the next person to wonder where the number came from does not have to dig through git log. * Documented that pinning at 0 must never be offered - the colony crash triggers on the countdown expiring. * Deliberately NOT offered: a "vanilla countdown" (unpinned) setting. It would be fine for checks, but reintroduces the untested question of the timer genuinely running out mid-run, which needs its own investigation. Verified: boss_difficulty reaches slot_data in a freshly generated seed (alongside goal), and 4/4 seeds generate with playthrough calculation passing.
New option boss_hp_randomization (default off): weak 40-80%, regular 70-130%,
strong 120-200%, chaotic 25-250%. The roll SCALES the game's own value, so Boss
Level still applies and intense difficulty compounds. Every boss is affected.
Client-side only - no disc edits, works on any already-patched disc. Rolls are
deterministic per (seed, slot, stage, vanilla HP) via SHA-256, so a retry is the
same fight; different stages roll differently.
The lever is 0x800D1CA2, live-proven to be boss max HP (pinned to 40 -> boss
spawned with 40). Two non-obvious behaviours it forces, both regression-tested:
* RESTORE ON EXIT. 0x1CA2 is also the Boss-Level accumulator (+= level_raw at
each stage start), so leaving our value in place makes it the base for the
next accumulate and the multiplier compounds to the 127 ceiling within a few
stages. The client hands the vanilla number back on leaving gameplay.
* BASELINE ONLY ON STAGE CHANGE, AND NEVER ZERO. Savestates restore 0x1CA2
with the rest of RAM, so 'anything I did not write is vanilla' would adopt a
stale value after a state load; and the stage id flips during a stage load
before the byte is recomputed, so a poll can catch a 0 - which is the
kill-boss value, and would be restored over a real number on exit. Both were
observed live before being fixed.
Live-tested: Grizzly Slash 70 -> 75, boss spawned with exactly 75 HP.
120 world tests + 217 AP-general green, verify_release 28/28.
New option secret_armors_in_pool (default off) adds the two secret armors to
the pool. Vanilla hides both behind one Zero Space capsule at the very end of
the game; shuffled into the multiworld they can appear at any point.
Client-side only, no disc edits. Grants use the capsule's own code paths
(disasm 2026-08-01): char 0 (X) -> 0x800D1C4B = 1 (Ultimate), otherwise
0x800D1C4A |= 0x10 (Black Zero). The stage-load character init already mirrors
0x1C4B into the live player struct, so a received armor applies at the next
stage load exactly like the weapons byte.
Three deliberate constraints, each regression-tested:
* NEVER progression - each armor only benefits one character, so requiring
one could strand a seed played entirely as the other.
* Black Zero ORs its bit into 0x1C4A instead of assigning: that byte also
carries the Falcon/Gaea set-completion flags the results overlay owns, and
clobbering them would un-complete an armor set.
* Item table count is 0 and create_items adds them under the option, so they
consume filler slots rather than adding locations (the capsule is not a
check - making it one would need the id-8 spawn-gate fix, since granting an
armor despawns it).
140 world tests + 217 AP-general green, verify_release 28/28. Not live-tested;
open question noted in the changelog about whether Ultimate also needs
0x800D1C4A & 8.
New option, off by default. One random Maverick stage is open at the start and the other seven each need their own "<Boss> Access Codes" progression item. Client-side, no disc change, so it works on an already-patched disc. The hook is the hub's slot -> stage-id table at 0x800F5050 plus the game's own "stage id 0 -> do nothing" branch at 0x800EFCA4: write 0 over a slot and confirming its icon is a silent no-op. That handler is the table's only reader in the whole hub module, so nothing else on screen changes. Three behaviours the client has to get right, all regression-tested: - Re-assert every cycle. The table is overlay data reloaded from disc on every hub entry, and savestates swap it too. - Check an instruction anchor first (0x800EFC88). Every other overlay maps different code there; writing 8 bytes into a stage's data would corrupt it. - Restore 0x800D1C0C. The store at 0x800EFC98 lands before the game's zero test, so a blocked confirm parks 0 there - a value vanilla never writes, and one an in-hub save would commit to the memory card. Live-tested: seed rolled Grizzly Slash, that stage entered normally, the other seven did nothing on confirm, and sending Duff McWhalen Access Codes opened that stage without leaving the stage select - which is what exercises the per-poll re-assert rather than only the write at hub load. Also records both secret armors as live-tested. They apply on different schedules: Ultimate Armor at the next stage load (the character init mirrors 0x1C4B into the player struct), Black Zero immediately. Writing only 0x1C4B is enough for Ultimate - 0x1C4A & 8 is read by the capsule's despawn ladder but is not a second ownership flag. 28 new tests (168 total for the world).
A stage's Access Codes must never land inside that stage. The rules make this structurally impossible - every requirement in this world is an ITEM, so the whole graph is one dependency AP's fill already respects - but the invariant is what a future rules change would quietly break, so it gets a test rather than an argument. Runs fill and asserts (a) no access item sits in the stage it unlocks and (b) can_beat_game() from nothing. With pickupsanity on: those 32 extra locations sit inside the locked regions, which is where a self-lock would hide. Corroborated outside the suite by 40 generated seeds with both options on - all produced a playthrough, none self-locked.
Four options had shipped or been built with no player-facing documentation at all - pickupsanity, boss_hp_randomization, secret_armors_in_pool and stage_unlocks existed only in the CHANGELOG, which is not what a new player reads. All seven game options are now on the page. Also adds a "Which options change the disc?" section. Verified against Rom.py: exactly text_skip, launch_odds and pickupsanity produce disc edits; everything else including all three goals is client-side and works on a disc already patched. Two corrections while in here: - "Small Energy filler items do nothing in-game" stopped being true when pickupsanity landed - they heal 4 HP through the queued-refill counter, and they are the filler standing in for a collected capsule's energy. - The opening claimed all eight stages are open from the start with no qualifier. Plus the secret-armor delivery timing, which differs per armor.
New option, ON by default: clearing Zero Space 1, Zero Space 2 or the X vs Zero fight each sends a check. Sigma is not one, because beating him is the goal. This CHANGES THE DEFAULT SEED, 45 locations -> 48. Until now every check in a normal seed sat in the eight Maverick stages, so the entire endgame was pure travel with nothing to find. Client-side, no disc change. Detection rides the story ACT byte (0x800D1C79), which the hub's stage-select confirm handler already uses to pick the endgame destination - so ACT doubles as the endgame progress counter. Confirmed live 2026-08-06, one step per clear: 5 -> 6 (f323080), 6 -> 7 (f341110), 7 -> 8 (f353000). The client latches the highest ACT it has seen rather than reading it live, because two other things write that byte: the all_mavericks goal pushes it back below 5 to hold the endgame shut, and training mode parks 0x0A in it - which is above all three thresholds and would otherwise fire every check at once, the same shape as the phantom intro check in 0.1.1. Both pinned by tests. Also the first option that gives the pool MORE room rather than less: three locations and no new items, so filler goes 9 -> 12. Ultimate Armor fix, from watching the same session: the game writes 0x800D1C4B itself (observed 01 -> 02 at a results screen, with Ultimate still selectable afterwards), so that byte is not the boolean it was taken for. The grant now writes only when it reads ZERO. Under the old `!= 1` test every later item batch would have rewritten it, and if 0x1C4B is a selection rather than a flag that silently resets the player's armor choice. Docs: all seven game options are now on the player page - endgame_checks had no CHANGELOG entry at all, which would have shipped a changed default undocumented.
DNA Parts become multiworld items. X5 has 16 but a run yields 8 - each Maverick offers two and Alia's Life+/Energy+ prompt makes you forfeit the other - so the seed picks one per pair and shuffles those 8. Vanilla grants are suppressed, so Parts arrive only from the multiworld. The DNA Part locations are unchanged and still check on the boss kill: that check was always there, only its reward was static. Client-side, no disc change. One write per cycle does both halves: OR in what the player received, clear what the game granted. Only bits 2..17 are touched; the rest of the word has no known meaning and is left alone. The name-to-bit map was read out of the GAME - force every bit on, read the Parts screen - not from the web, which returns Mega Man X6 Part facts for X5 queries constantly. Full table in docs/mmx5-ghidra-findings.md 9.15. Corroboration that the mapping is right rather than a lucky alignment: bits 11-16 are exactly the six character-locked Parts, X's three then Zero's three. Live-tested 2026-08-06: with a script force-granting all 16, the client cleared them continuously (the suppression, visible in its log), and Hyper Dash sent from the multiworld appeared on the Parts screen as the only Part held. --- and a bug this exposed, which predates it --- The world never checked that its item pool fit its locations. create_items only ever topped UP with filler, so dna_parts_in_pool + stage_unlocks + secret_armors_in_pool produced 53 items for 48 locations, generated without complaint, and SILENTLY DROPPED Ultimate Armor and a DNA Part. Worst failure shape available: a seed that looks fine and is quietly incomplete. Reachable since stage_unlocks shipped; Parts only made it easy to hit. Generation now refuses it, with a message naming the remedy. The guard lives in generate_early, NOT create_items: Generate.py retries a world that raises there, so the error never surfaces and generation spins instead (observed - it ran to a two-minute timeout). Capacity is computed analytically by _capacity() before any of it is built. Worth recording for whoever hits this next: WorldTestBase does not run fill, so an over-full pool looks identical to a balanced one there. "filler left 0" read as "fits exactly" and it did not - only a real Generate.py run showed the missing items.
New Toggle, off by default. Rolls goal, boss_difficulty, launch_odds, text_skip, pickupsanity, boss_hp_randomization, secret_armors_in_pool, stage_unlocks and dna_parts_in_pool. endgame_checks is excluded: it only ever adds checks, so there is nothing to gamble on. The rolling is trivial; the two corrections after it are the feature: 1. `launch` goal + vanilla odds can be UNWINNABLE - that goal needs a successful launch, there are two attempts, and a full part set is still only 75%. Picking it deliberately is a legitimate gamble and already warns at generation. Having a coin flip hand it to you is just a broken seed, so the odds are forced back to deterministic. 2. The item-adding options can together want more items than the seed has locations. Rather than refusing, pickupsanity is switched on - +32 locations, which covers every combination (worst case 53 items vs 80). A guard test fails if a new option is added to MMX5Options without being classified as rolled or excluded, so the list cannot silently go stale. Two testing traps this hit, both now pinned: - str() on a Choice gives "Goal(Launch)", not "launch". The unwinnable-combo test was written with str() and so matched nothing and PASSED VACUOUSLY. It now compares with == and asserts the precondition actually occurred, so it cannot go quiet again. - Reproducibility needed testing directly: had the roll used the `random` module instead of self.random, every other test would still pass while seeds became silently unreplayable. Verified: 247 tests, 8/8 real Generate.py runs, verify_release 28/28, all 11 options documented on the player page.
Seven options added since 0.2.0, every one exercised in a live game: randomize_options, dna_parts_in_pool, endgame_checks, stage_unlocks, secret_armors_in_pool, boss_hp_randomization, pickupsanity. One default changed: endgame_checks is ON, so a seed generated without touching it has 48 locations instead of 45. Only pickupsanity changes the disc; the rest work on an already-patched one. AP floor stays 0.6.7 - still the newest PUBLISHED release (gh release list, checked 2026-08-06). The checkout reports 0.6.8 because it tracks main, which is the unreleased next version; taking the floor from there is what made v0.1.0 silently unloadable. Also makes the research-note references branch-agnostic. Several cited `worlds/mmx5/docs/mmx5-ghidra-findings.md` by path, which is a dead link on the upstream PR branch where those mirrors are removed - they now name the file and say it lives on the fork.
Reported live 2026-08-06: a tester in an 8-player multiworld had their world send items to seven other players before they started. Every Boss Defeated, DNA Reward and DNA Part check fired with no boss beaten. First guess was a reused save; it was not. Cause. On a disc the probe reported as vanilla, the client ran "hybrid mode" and wrote AP-granted weapons into 0x800D1C4C - the VANILLA kill record, and the same byte check detection reads as ground truth for all 24 of those locations. So every weapon received from the multiworld marked its boss defeated and fired three checks. Reproduced exactly: 8 weapons received, zero bosses beaten, 24 checks sent, items released to other players, unrecoverable. Fix. An unpatched disc now holds checks AND items and logs an error naming the remedy. Placed BEFORE check detection, not just before grants: a save already poisoned by a hybrid session still carries those bits and would re-fire them on the next connect. Hybrid mode predates the disc patch and the module header always called it interim. Every supported flow produces a patched disc - the .apmmx5 IS the delivery mechanism - so it was unreachable in correct use and destructive in incorrect use. Why no test caught it: run_watcher hard-coded the AP-patch probe to PATCHED, so NOTHING in the suite could reach that branch. A test default silently excluded a whole code path and the bug survived four releases. run_watcher now takes patch_probe, and test_unpatched_disc.py covers both directions - unpatched holds everything (including a poisoned save), patched is completely unaffected and real kills still fire all three checks. 254 tests, verify_release 28/28.
… bug The 0.3.1 notes said the fix was 'reported by a tester ... reproduced exactly', which claims causation. The tester says their disc WAS patched, and on a patched disc the client never writes 0x1C4C - so hybrid mode cannot be their cause. The bug is real and reproducible on its own terms and stays fixed. But the notes now say it was found while investigating a report, that causation is not established, and that the same symptom is reachable another way. Names the other way, which is not fixed: the only residency test is 0x10 <= maxHP <= 0x40, which RAM from a previous game satisfies - and RAM survives a soft reset. Asks for client logs, which is the artifact that would actually identify it.
Closes the phantom-check class rather than one route into it.
save_sane was the only gate, and its residency test is just
0x10 <= maxHP <= 0x40. RAM left over from a previous game satisfies that
exactly - and RAM survives a soft reset, so "I started a new save" says nothing
about what the struct held when we read it. Demonstrated: maxHP 0x20 with
kills 0xFF and ACT 2 - a state the game cannot produce - fired all 24 boss /
DNA Reward / DNA Part checks.
Two further requirements for save-derived checks:
(a) IN GAMEPLAY (mode 0x0A/0x0C). A save is definitionally resident there. The
title screen, data-select menu and attract demo are not, and those are
exactly where leftover RAM gets read as progress. Costs nothing: kills
commit at the results screen, and detection is level-triggered, so
anything noticed elsewhere fires on the next gameplay poll.
(b) STABLE across two consecutive polls. A struct being written during a load
can read as a plausible half-state for a frame.
The endgame ACT high-water mark is gated on the same trust, because it latches:
one bad read there is permanent for the session and cannot be undone by a later
good one.
Deliberately NOT using internal consistency ("8 kills implies ACT >= 5"): the
all_mavericks goal makes that briefly false on purpose by withholding ACT, so it
would reject real saves.
Harness: run_watcher grows `settled`, defaulting True, which pre-seeds the
stability signature so a one-cycle test behaves like a client that has been
polling - what almost every test here means to simulate. settled=False
exercises the first-read-after-connect path. Explicit knob on purpose: a silent
default that hides a code path is precisely how the hybrid-mode bug survived
four releases.
263 tests, verify_release 28/28.
Found while writing the review brief: max_act_seen was tightened to the new save_trusted gate but mavericks_defeated was left on save_sane. Both LATCH, and both are unrecoverable once wrong - mavericks_defeated decides the all_mavericks goal, so a stale 0xFF read would score 8 permanently and hand out a false victory that no later good read can undo. Same hazard, same gate now.
…rial review 0.3.1 closed one route to "checks fire for things you never did". A review of that fix found two more routes and a hole in the fix itself. A3b - a genuinely resident save carrying pre-AP progress. Now the leading candidate for the original report, and defeats BOTH earlier fixes: a cloned memcard, Continue on the wrong slot, or a savestate predating first connect is a real live save with real bits, and the A3 stamp gate passes it because a save this seed never touched is stamped 0. The stamp is now written at first TRUSTED sight of a fresh save (not with the first grant batch, which a player who never receives an item would never reach), and an unstamped save that already carries progress is held with the exact adoption command in the message. ACT is deliberately NOT progress for that test. Counting it locked out an ordinary flow - boot game, play the intro, then open the client - behind a Lua command, for a save that can claim exactly one location. Weapons/hearts/tanks/ armor are where the 24-check blast radius is, and those still hold. Trust gate hardened. 0.3.1 required in_gameplay + stable, which was weaker than it looked: the stability signature is recorded on menu polls too and stale RAM never changes, so ONE poll landing in a gameplay mode was trusted instantly off a signature the title screen established. Now also requires the previous poll to have been gameplay, and 0x0C only after a trusted 0x0A. Unpatched hard-stop moved above every writer. In 0.3.1 it sat below the boss-HP, DNA-Parts and stage-unlock blocks, so an unpatched disc was still written to - the "holds all checks and items" comment was false. The goal is held too, via `is not False` rather than `is True` so an unreadable probe cannot swallow a legitimate ending. ap_patched now starts None, since False means "known vanilla" and triggers refusal. Corrected during review: requirement (d) cited a stage-load mode walk "0A->0B->0C->0E" from ram-notes. That is NOT the mode byte - it is 0x800D1CB4, the per-stage counter documented there as a known decoy after it fooled us during the rematch hunt. The only mode walk on record is 0A->13->14. (c) and (d) stay as cheap conservatism, but the comment now says they rest on "this byte is unmapped", not on evidence. Also ruled out: research scripts writing check bytes. They do not ship - the released apworld contains no .lua at all. Still not a diagnosis. The tester's client log was never collected, so these are three closed routes to one symptom. Stated plainly in the changelog. 274 tests, verify_release 28/28.
A tester's seed was UNBEATABLE. Goal all_mavericks + stage_unlocks + pickupsanity placed Dark Dizzy's and Axle the Red's Access Codes on locations inside SIGMA'S STAGE. Killing those two Mavericks needs their codes; reaching Sigma needs all 8 Mavericks dead. No way out. Generation did not notice: the spoiler's playthrough "won" at sphere 6 having entered four stages. The Sigma entrance required the 8 weapon ITEMS, and its comment said that added no constraint because "every boss is reachable and killable with no items at all". That was true when written and died the day stage_unlocks shipped - nobody revisited the rule, including me, twice: once building the feature and once sweeping 40 seeds for self-locked codes. That sweep only asked whether a stage's codes were inside ITS OWN stage; it never asked whether codes could land behind the endgame. all_mavericks is about KILLS; the rule was about WEAPONS. Interchangeable only while every boss is reachable, which locked stages break. Fix: with stage_unlocks on, the endgame requires every Access Codes item too. Applied for EVERY goal - reaching the endgame in-game needs the colony to resolve, which needs story progress, which needs kills, and logic does not model the kill count the story wants. Stricter only narrows placement; looser strands seeds. Verified on the tester's exact option set: 6 seeds, zero stranded codes, all beatable. Three regression tests, including one asserting 8 weapons alone no longer open the endgame. 284 tests, verify_release 28/28.
Two tester-facing fixes, both from one report (2026-08-08) after a solo run with stage_unlocks + dna_parts_in_pool + pickupsanity was played to completion with every check firing correctly. Client-only: the disc hash in verify_release is unchanged (65cd8caf0d1bc40e2156f2286b7215ec), so existing patched discs and saves carry over untouched. * Weapons received mid-stage did nothing until the player left and re-entered the stage, which reads exactly like a lost item. Grants land in the SAVE struct, and that is what a stage LOAD reads; the pause menu and the fire button consult a separate volatile bitfield at 0x8009A169 that nothing was updating. _live_weapons_apply now OR-s the granted bits into it during gameplay. The restore path was confirmed statically rather than assumed: 0x8003C324 is `lbu $v0, 0x4C($a1)` / `sb $v0, 0xC9($s0)` with $s0 = the player struct 0x8009A0A0, so $s0+0xC9 IS 0x8009A169 - and that store is one of the three the AP disc patch retargets 0x4C -> 0x4D, which is why a patched disc restores from the AP capability byte rather than the kill record. Deliberately downstream of the grant path: the write is an OR of bits the save struct already holds, gated on gameplay, and produces no write at all when the capability byte is empty. It can never conjure an item, only re-state one. Armor is untouched and still applies at the next stage entry - the game picks X's armor while the stage loads, which is design, not delay. * With pickupsanity on, a randomized capsule stayed inert for the whole run even after its check was long since sent. Revisiting an emptied stage - the Boss Rush above all - meant walking past capsules that did nothing. The stub is installed by ITEM KIND in the collect dispatch table (0x80011068), so it cannot be relaxed for one capsule. But only one stage's placement list is live at a time, which makes a stage the finest grain available: _pickup_dispatch_apply restores the vanilla handlers when every pickupsanity location in the CURRENT stage is confirmed by the server, and installs the stub everywhere else. Stub is the default, the disc's own bytes are the stub, and a reload restores them, so it fails safe in both directions. A check merely sent and not yet acknowledged keeps the stub. Stages holding no pickup locations at all - Squid Adler, and the intro whose single capsule is deliberately never a location - now behave like vanilla too. Suppressing those was never intended. * Pickupsanity presence probe moved off the dispatch table. It read the kind-2 entry, which is precisely the word the change above rewrites: restore a cleared stage's capsules, walk out, and the gate-transition re-probe would read "vanilla" and switch pickupsanity check detection off for the rest of the session. _classify_ring2 now takes the stub's own first word as the authority (presence of the stub is a property of the DISC; what the table points at right now is a property of where the player is standing) and keeps the dispatch entry only to separate a loaded vanilla EXE from boot zeros. * Setup guide: the "received items appear at the next stage load" and "Small Energy does nothing" notes were both stale. Replaced with what the client actually does, plus the Ultimate Armor caveat - it needs one armorless stage entry to appear, which has caught several players out and previously lived only in a YAML tooltip. * Research docs re-synced from the workspace: ghidra-findings gains 9.16, the offline static session that produced the 0x8003C324 confirmation above, resolves 0x80072DD4 as the per-(stage,area) object-type manifest, and records that a hi/lo reference scanner MUST respect basic blocks - the naive version reported 376 save-struct accesses where the correct figure is 202. Tests: 300 pass (was 284). test_live_state.py adds 16 covering both fixes and their guards - that the live mirror never invents a weapon the save lacks, never writes outside gameplay, and that the dispatch toggle never relaxes suppression for an unconfirmed check or on a disc without the stub. One test pins the probe words against disc.py so the client and the patcher cannot drift apart. verify_release 28/28. Carried forward: the sub-tank overcap a tester also reported is NOT fixed - it has not been measured yet, and the fix depends on what the measurement says. It is item 5 on the live-session list in the feature backlog.
Three client-side features from the 2026-08-08 live measurement session; no disc changes (release-gate hash identical to the verified v12 build). * rematch_checks (+8 locations): Boss Rush rematch kills send checks. Detection = standard boss-HP slot walked to 0 (persists 600+ frames) with the fight identified by a 16-byte fingerprint of the boss module streamed to 0x800FA000 (chunk 29+stage_id). The sub-stage byte is NOT usable - the same rematch read 0x05/0x06 in different sessions. Unknown fingerprint sends nothing (Sigma's own fights land there); player-alive gate keeps a mid-fight death from reading as a kill. Live-validated end to end (Squid). * reploid_checks (+14 locations): rescuing injured Reploids sends checks. The real Reploids are exactly the gate-4/id-0 placement records - inverted vs the pickup gate rule - proven by four live rescues, a phantom record caught absent in situ, and a predicted third Izzy Reploid. Detection = lives-increment during trusted gameplay + player overlap with the record (1-UPs rejected by position; tracker resets outside trusted gameplay). Squid Adler's six ship from disc data without an on-screen sighting - accepted-risk call, documented in the option text. Live-validated (The Skiver - Reploid 2, previously unverified record 38). * dna_parts_in_pool is now a choice: off / vanilla_pairs (= old true) / all (+16 items, both halves of every pair), funded by the new locations. Also: verify_release.py now generates its default build hermetically from a temp all-defaults YAML (a leftover Players/ YAML with pickupsanity on was silently changing the disc under the hash comparison), and expects the 102-location datapackage. Suite: 362 tests. Setup guide updated.
Tester report: failed re-patches sent a player deleting every X5 file he had. Root cause was our own hash refusal being uninformative - the common failure is pointing the base-image setting at an ALREADY-PATCHED disc from an earlier seed. * The base-ROM rejection now probes the offered file for the AP patch signature (the first capability retarget byte, 0x4C->0x4D - the same site the client probes in RAM, no vanilla data needed) and, when it is one of ours, names the problem, the setting to fix, and the standalone MMX5-Unpatcher release asset - instead of a bare hash mismatch. Regression test in test_unpatched_disc.py. * Setup guide: troubleshooting entries for the already-patched trap and for disc reuse (only pickupsanity/text_skip/launch_odds change the disc; same options = same disc, and a reused disc keeps its memory card). The unpatcher itself is deliberately NOT part of the apworld or this repo: it is a standalone tool (MMX5-Unpatcher.exe, built from the author's research repo) shipped as a release asset, since its restore manifest necessarily carries vanilla disc bytes.
Reported against 0.4.1 by two testers. Re-patch is needed ONLY for pickupsanity seeds; every other seed still produces the verified v12 disc. Enemy health drops did nothing under pickupsanity until a stage was fully checked. The collect dispatcher indexes by ITEM KIND, so a dropped capsule and a placed one take the same jump, and the stub ended in an unconditional consume-no-effect - it ate both. The stub now tests whether the object was spawner-placed (obj+0x10, plus the record's minor/id) and hands anything else to the real vanilla handler. Sound because the allocator zeroes 156 bytes of the pool slot on every allocation, so a drop cannot inherit a stale pointer. LIVE-CONFIRMED: an enemy-dropped Small Energy healed 37->41, exactly +4 with headroom left, no ring record, grant queue untouched, and the client had not restored the vanilla handlers. Rematch checks could credit a boss you never fought. Fights were identified by a 16-byte fingerprint of the boss module; The Skiver's value occurs 40x on the disc (12 of those in the base EXE - it is a function epilogue followed by the next prologue) and Squid Adler's 11x, twice inside the Sigma module itself. Those are precisely the two bosses that misfired. The window is now 256 bytes, verified to occur exactly once on the whole disc for all eight, and a kill additionally requires that the client watched the bar fill during the current arming, with one send per module load. Boss lifebars corrupted on low rolls: the bar sprite and its screen position are both f(0x1CA2 - 0x20) with an upper clamp only, so under 32 the sprite index runs off the front of the artwork. Rolls now floor at 32 - applied as min(BOSS_HP_MIN, vanilla) so the floor can never RAISE a boss above its vanilla HP, which a bare max(0x20, rolled) would have done wherever the accumulator still sits low, turning "weak" into a buff. Zero Space is excluded from boss HP randomization: 0x1CA2 scales the lifebar for every boss but sets HP for only some - rematches read 127 there while fighting with 58 - so rolling it desynced their bars at any value. Sigma shares the stage and keeps vanilla HP as a deliberate cost. Client accepts both stub generations, so discs patched before 0.5.0 keep working instead of reading as unstubbed mid-run. 398 tests, gate 30/30. The stub is machine-verified in test_disc.py: decode, R3000 load-delay audit, branch targets, jump table, all 32 real records accepted, and a disc-gated re-derivation of the fingerprint table.
Client only, no disc change, no re-patch. Gaea Armor cannot use special weapons, and the engine enforces it at stage load: 0x8003C2D4 branches on the character/armor selector 0x800D1C49 == 3, zeroes the live weapons byte (player+0xC9 = 0x8009A169) and skips the repopulation entirely. Since 0.3.4 _live_weapons_apply has mirrored granted weapons into that byte so a weapon arriving mid-stage works immediately - and it did so under Gaea too, re-arming a state the game guarantees never happens. Cycling to one of those weapons with L1/R1 crashed the game (tester report on 0.4.1). The mirror now returns early on selector 3. Nothing is lost: the weapons are already committed to the save struct and return the moment the player uses any other armor, which is the only state they were usable in. Affects 0.3.4 through 0.5.0, and is NOT limited to secret_armors_in_pool - any seed where the player collects the four Gaea parts can reach it. The selector value is code-proven; that 3 is specifically Gaea is inferred from behaviour (the only X form that forbids special weapons) plus the tester's repro. 400 tests, with the guard mutation-checked.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What is this fixing or adding?
Adds Archipelago support for Mega Man X5 (PS1, NTSC-U, SLUS-01334) as a new world: apworld + BizHawkClient, with client-side disc patching — the generator emits a small
.apmmx5and the player's own dump is patched locally through the normal Open Patch flow (no patcher tool, no external xdelta; the edit list lives indisc.pyand every write funnels through per-sector Mode2/Form1 EDC/ECC regeneration in pure Python).World: 48 locations by default — the intro clear; per Maverick stage the boss, Heart Tank, armor capsule, DNA Reward and DNA Part; four tank pickups; and three Zero Space stage clears.
pickupsanityadds 32 more (every freestanding energy/1-UP capsule),rematch_checksadds 8 (Boss Rush rematch kills) andreploid_checks14 (injured-Reploid rescues), for 102 in the datapackage. 36 items by default, with four options adding more. Three goals:all_mavericks(default),sigma, andlaunch. Accepts the Redump dump plus a one-extra-trailing-zero-sector variant, verified byte-equivalent.Options (all default off unless noted):
goal,boss_difficulty,launch_odds,text_skip,pickupsanity,boss_hp_randomization,secret_armors_in_pool,stage_unlocks,endgame_checks(default on),rematch_checks,reploid_checks,dna_parts_in_pool(off / one-per-pair / all 16),randomize_options. Onlypickupsanity,text_skipandlaunch_oddschange the disc; everything else is client-side and works on an already-patched image.Generation refuses option sets whose items exceed the available locations, with a message naming the fix, rather than letting items be dropped silently.
Client: BizHawk 2.7+ (tested on 2.10). Checks are detected from the memcard-persisted save struct plus a patched-in mailbox ring for pickup-style checks; grants are idempotent (OR-based) with the processed-items counter stored in spare save bytes, so reconnects and savestates don't re-apply items, and a seed/slot stamp refuses to touch a save from a different seed.
How was this tested?
worlds/mmx5/test/, covering reachability rules, the disc edit list and stub integrity, per-option item/location accounting, pool-capacity refusal, and the client's save-struct reads and writes against synthetic saves. All pass on Python 3.13; the world also compiles clean under 3.11. The general suite scoped to this world passes.sigmagoal detected from the ending mode bytes (server auto-released on goal, confirming a genuineCLIENT_GOAL);pickupsanity— capsule check sent and confirmed, vanilla effect suppressed, savestate re-loot produced no duplicate check; and in 0.5.0, an enemy-dropped energy capsule observed healing normally again on the patched disc while writing no check record (see below);boss_hp_randomization— rolled value observed on the boss that spawned;secret_armors_in_pool— both armors received and applied in game;stage_unlocks— locked stages inert at the stage select, and a stage opened live the moment its item arrived;endgame_checks— all three thresholds observed across a real endgame run;dna_parts_in_pool— Part received from the multiworld and suppression of the game's own grant both observed;rematch_checks— a Boss Rush rematch kill identified by its boss-module fingerprint, check confirmed by the server end to end;reploid_checks— rescues matched to their placement records live (including a Reploid the placement data predicted that the tester was sure did not exist), check confirmed by the server.randomize_options, all producing a computed playthrough.custom_worlds/→ template options → generation → Open Patch flow) reproduces the reference build exactly.All three goals have been played to completion, by me and by testers running their own seeds. The world has been in tester hands since v0.1.0 (2026-08-03) across fourteen releases, most recently v0.5.1 (2026-08-10). Every bug those runs surfaced is fixed and described in
worlds/mmx5/CHANGELOG.md; the ones worth calling out here are a client that could derive checks from a save struct that was not resident yet (0.3.1/0.3.2), astage_unlocksreachability rule that could place a stage’s Access Codes behind the endgame and strand a seed (0.3.3, with regression tests using the affected option set), item-delivery timing plus pickup re-collection (0.3.4), and two in 0.5.0 — the pickup stub suppressing enemy-dropped energy as well as placed pickups (it hooks the collect dispatcher by item kind, and both share a kind), and rematch checks that could credit the wrong boss because the 16-byte code window used to identify a fight was not unique to it; and in 0.5.1 a crash — the client mirrors granted weapons into the live weapon list so a mid-stage pickup works immediately, but Gaea Armor cannot use special weapons and the engine clears that list for it, so re-arming them and switching weapons crashed the game.Notes for reviewers
mmx5-apworldrather than shipped in this PR'sdocs/, keeping the webhost docs player-facing. Code comments reference them by filename plus that URL, so the same source is correct on both branches.docs/CODEOWNERSand the.apmmx5inno_setup.issassociation are included; I'm taking maintainership.