Skip to content

Mega Man X5: Implement New Game - #6369

Open
Shinnuu wants to merge 77 commits into
ArchipelagoMW:mainfrom
Shinnuu:mmx5-upstream
Open

Mega Man X5: Implement New Game#6369
Shinnuu wants to merge 77 commits into
ArchipelagoMW:mainfrom
Shinnuu:mmx5-upstream

Conversation

@Shinnuu

@Shinnuu Shinnuu commented Aug 2, 2026

Copy link
Copy Markdown

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 .apmmx5 and the player's own dump is patched locally through the normal Open Patch flow (no patcher tool, no external xdelta; the edit list lives in disc.py and 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. pickupsanity adds 32 more (every freestanding energy/1-UP capsule), rematch_checks adds 8 (Boss Rush rematch kills) and reploid_checks 14 (injured-Reploid rescues), for 102 in the datapackage. 36 items by default, with four options adding more. Three goals: all_mavericks (default), sigma, and launch. 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. Only pickupsanity, text_skip and launch_odds change 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?

  • Unit tests: 400 in 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.
  • Live, on a real server + BizHawk client. Every gameplay option has been exercised in a running game, not only in tests:
    • full clean-run loop on a fresh save — intro check, boss kill firing its three checks, capsule check with the vanilla grant suppressed, tank checks, armor items driving the in-game part bits, and the sigma goal detected from the ending mode bytes (server auto-released on goal, confirming a genuine CLIENT_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.
  • Generation sweeps: multi-seed runs per option and in combination, including randomize_options, all producing a computed playthrough.
  • Patch pipeline: patched output verified byte-identical from both accepted dumps; fresh-install simulation on a stock checkout (apworld in 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), a stage_unlocks reachability 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

  • The reverse-engineering notes behind every RAM address (verified RAM map, overlay/disassembly findings, placement inventory) are maintained publicly on the fork branch mmx5-apworld rather than shipped in this PR's docs/, keeping the webhost docs player-facing. Code comments reference them by filename plus that URL, so the same source is correct on both branches.
  • The 0.5.0 rematch-identity fix widened that window to 256 bytes and it is now verified rather than assumed: every one of the eight is checked to occur exactly once across the whole disc image, and a test re-derives the shipped table from a disc when one is present (skipped otherwise — no disc is committed). In the interest of disclosure: that change and the boss-HP lifebar clamp in the same release are covered by unit tests, mutation testing and the disc re-derivation, but were not replayed live before release; the enemy-drop fix was, on a real patched disc.
  • DeathLink is deliberately not included yet: the candidate death flag is ambiguous across two endgame transitions and needs disambiguation before it can be wired safely.
  • docs/CODEOWNERS and the .apmmx5 inno_setup.iss association are included; I'm taking maintainership.

Shinnuu added 30 commits July 30, 2026 22:12
…, 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.
Shinnuu added 9 commits August 5, 2026 23:48
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.
Shinnuu added 4 commits August 6, 2026 19:26
… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

affects: core Issues/PRs that touch core and may need additional validation. waiting-on: peer-review Issue/PR has not been reviewed by enough people yet.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants