From 066addbe6bedeee193dd81ca041d19baceb0e63c Mon Sep 17 00:00:00 2001 From: Matt Stanley <5354790+mstan@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:33:28 -0700 Subject: [PATCH] Restore X3 Zero charge colors and stored saber glow; clear stale charge effects --- docs/mod-source-roms.md | 2 +- docs/zero-port.md | 58 ++++++++++++++++++++++++++++- src/mmx_renderer.c | 20 +++++++++- src/mmx_source_assets.cpp | 28 +++++++++++++- src/mmx_zero.c | 48 ++++++++++++++++++++---- src/mmx_zero.h | 9 ++++- src/mods/mmx_zero_plugin.c | 2 +- tests/mmx_adaptive_state_test.c | 66 +++++++++++++++++++++++++++++++++ tests/mmx_zero_test.c | 4 ++ tools/extract_zero.py | 54 ++++++++++++++++++++++++--- 10 files changed, 267 insertions(+), 24 deletions(-) diff --git a/docs/mod-source-roms.md b/docs/mod-source-roms.md index ad29be4..0065c0a 100644 --- a/docs/mod-source-roms.md +++ b/docs/mod-source-roms.md @@ -16,7 +16,7 @@ Supported X3 USA normalized SHA-256: `65b03268afac296330e8ff8d60dd0825879e13ed658b37713c034a3bd074f1d7`. `src/mmx_source_assets.cpp` repeats validation and extracts Zero assets natively -from the selected ROM, atomically publishing `cache/mmx-source/x3-zero-v6.bin` +from the selected ROM, atomically publishing `cache/mmx-source/x3-zero-v7.bin` beside the executable. Failed validation leaves the previous cache untouched. Activation always requires the selected ROM; it never falls back to developer assets. Runtime extraction requires no Python or additional download. diff --git a/docs/zero-port.md b/docs/zero-port.md index 50396c8..4ab9b10 100644 --- a/docs/zero-port.md +++ b/docs/zero-port.md @@ -59,7 +59,7 @@ stages only the tracked catalog; no private ROM paths, caches or save states. `tools/extract_zero.py` validates original USA X3 SHA-256 `65b03268afac296330e8ff8d60dd0825879e13ed658b37713c034a3bd074f1d7` -after accepting an optional 512-byte copier header. Its current `MMXZERO6` +after accepting an optional 512-byte copier header. Its current `MMXZERO7` cache contains: - 117 body poses (group `$4A`), 21 saber-body poses (`$4B`) and 14 blade poses @@ -70,6 +70,9 @@ cache contains: - Four original 8x8 HUD badge tiles and their palette. - `$474` bytes containing 136 original body-animation directory entries and records, plus 196 bytes of pose-specific firing data. +- Three original charge-flash palettes and 66 charge-particle poses (three + groups of 22 frames). Legacy v6 caches remain readable; the launcher + regenerates v7 from the user's X3 ROM to supply the new presentation. The loader rejects a wrong header, dimensions/counts, truncated/trailing data, invalid pixels, invalid bounds and invalid animation/muzzle entries. Missing @@ -311,7 +314,7 @@ Open Zero acceptance work after the weapon priority: capsules, ride armor, stage teleports and scripted player poses. Do not claim all states validated just because every sequence is mapped. 2. Remaining presentation fidelity: X3 buster projectile/effect differences, - saber/source audio, charge/hit palettes and X1 special effects during + saber/source audio, hit palettes and X1 special effects during movement. Keep the owner's accepted X life head. 3. Representative moving platforms, tight spaces, water, doors, bosses and native/widescreen campaign playthrough. Alter dimensions only for an @@ -344,3 +347,54 @@ weapon packages; its DLL dependency closure is validated. A fresh private install of the ZIP generated its 2,492,180-byte Zero cache from the selected X3 ROM and cold-booted for 180 frames (exit 0), with no save state or developer asset cache. This test used isolated local configuration. + +### Charge presentation and cancellation follow-up (#55 / #56) + +GitHub [55](https://github.com/mstan/MegaManXSNESRecomp/issues/55) reports +overlapping charge effects after hurt or character exchange; +[56](https://github.com/mstan/MegaManXSNESRecomp/issues/56) reports orange-only +charging without X1 arms. Central tracking: `beads-8wg.1.44`. + +Original X3 Zero was visually reviewed in a private original-game fixture, +including held charge, both beam releases, prolonged stored-saber idle and +saber use. His body flashes blue at the earlier charge stages, purple at the +double-shot stage, and green at full saber charge. Green continues to alternate +with the normal body palette after the beams have fired, until the saber is +used or the charge is cancelled. The orbiting particles disappear on release; +stored readiness is a body flash, not another particle cloud. + +Bounded original-USA source references: + +| Source | Finding | +| --- | --- | +| X3 `$84:ABC3..AD75` | Charge counter `player+$57`; thresholds `$14/$50/$8C/$C8`, saturated `$C9`; Zero's saber flag `+$B7` | +| X3 `$84:AF13..AF40` | Palette flash clock; toggles pair index `+$82` with XOR 2, reloading every two ticks | +| X3 `$86:B3B4` | Palette key pairs; **data bank is `$86`**, not the calling code bank `$84` | +| X3 `$86:8180`, `$81:804A` | Key `$136` = blue `$8C:AF60`, `$138` = purple `$8C:AF80`, `$13A` = green `$8C:A5E0`; 16-color body destination `$90` | +| X3 `$81:84C1..8577` | Charge particle actor class 1; groups `$6F/$70/$71`; follows the player and retires for stored-saber state `$0C` | +| X3 `$3F:DA87` | Shared particle animation: 22 one-frame poses and loop | +| X3 `$86:F732`, resource `$0A` | Compressed common CHR; 4096 decoded bytes at OBJ tile-bank offset `$1000` (VRAM word `$6800`) | +| X3 `$8C:B100`, `$8C:B0E0` | Particle base palette (key `$14`) and palette-2 variant; `$70/$71` use palette 2 before full saber charge. Key `$D2` / `$8C:B5A0` belongs at palette 3 for Zero's saber, not charge palette 0 | +| X1 `$82:82ED` | Native charge allocation searches **all** 32-byte slots `$0C98..0E17` | +| X1 `$81:9890` | Charge-loop stop command `$17`; cancellation must also retire live particle actors | + +The old port fed X1's visual charge state, whose final stage depends on X1 +arms, while Zero's actual combo used independent X3 thresholds. The renderer +also recognized only slot `$0C98`, and cancellation left small charge actors +alive. The fix extracts the original X3 presentation, gives it one animation +clock, and removes class-1 charge effects across the full native small-actor +pool on release/cancellation. Unrelated small effects and special-weapon +charging retain their native handling. The previously unused `cooldown` byte +is now `charge_phase`; the 40-byte save/capture layout and old-state prefixes +are unchanged. Existing charge thresholds, beam timing and X1 arm gating for +special weapons are unchanged. + +The native extractor and Python reference produce identical v7 caches; +ROMs, decoded images and private visual captures remain excluded from Git. +Focused runtime checks pass without/with arms, stored saber after both beam +lifetimes, exact save/replay of a held full charge, hurt while holding fire, +and both exchange directions followed by held-fire charging. The complete +existing Zero runtime suite and Zero/renderer CTests also pass. Native X3 +captures and corresponding port captures were visually inspected; this caught +and corrected the distinction between common charge palette 0 and Zero's +saber palette 3 before completion. diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c index 68d4546..6c93fdc 100644 --- a/src/mmx_renderer.c +++ b/src/mmx_renderer.c @@ -749,6 +749,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { unsigned piece_count = frame.expand && g_mmx_render_asset_repairs ? frame.expanded_count : frame.piece_count; const uint8_t *zero = stage || zero_menu || zero_title ? MmxZeroPose(frame.ram, &frame_zero) : NULL; const uint8_t *blade = stage && zero ? MmxZeroBlade(&frame_zero) : NULL; + const uint8_t *charge = stage && zero ? MmxZeroChargePose(&frame_zero) : NULL; Piece waiting[128]; unsigned waiting_count = stage && g_mmx_render_asset_repairs ? fortress_waiting_pieces(waiting, pieces, piece_count) : 0; @@ -815,7 +816,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { bool zero_body = zero && (s.object == 0xba8 || menu_body); bool swap_actor = swapping && (s.object == 0xba8 || s.object == 0xc38 || s.object == 0xc58 || s.object == 0xc78 || s.object == 0xc98); - bool zero_charge = zero && stage && s.object == 0xc98 && s.animation == 0x71; + bool zero_charge = zero && stage && MmxZeroNativeChargeObject(s.object,frame.ram[s.object+10]); bool zero_armor = zero && (s.object == 0xc38 || s.object == 0xc58 || s.object == 0xc78 || (zero_menu && (s.object == 0x1928 || s.object == 0x1948 || s.object == 0x1968))); bool oam_match = false; @@ -835,6 +836,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { (asset->live_tiles ? s.attr & 255 : 0) : s.attr; if (asset && asset->live_colors) attr = (attr & ~0x0e00u) | (s.attr & 0x0e00u); if (zero_armor || swap_actor) continue; + if (zero_charge && MmxZeroHasChargeArt() && !frame.ram[0xbdb]) continue; if (zero_body && !oam_match && !(frame.expand && frame.ram[s.object + 14] && (s.x + s.size <= 0 || s.x >= 256))) continue; if (zero_body) { @@ -861,7 +863,8 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { if (sting) { objects[dx] = (uint16_t)((z & ~255u) | (144 + sting_shade[pixel - 16])); object_colors[dx] = -1; /* Live CGRAM already includes fades. */ - } else object_colors[dx] = colors[pixel]; + } else object_colors[dx] = !menu_body && pixel >= 16 && pixel < 32 ? + MmxZeroBodyColors(&frame_zero)[pixel - 16] : colors[pixel]; } } } @@ -904,6 +907,19 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { if (anchored) { if (x < 25) x -= view.extra; else if (x >= 216) x += view.extra; } sprite(&p, r, x, sy, attr, size, y, view, objects, false, NULL, 0, object_colors, false, zero_icon, false); } + if (charge && zero_drawn[0] && !swapping) { + int zx=(int16_t)(word(frame.ram,0xbad)-word(frame.ram,0x1e4d)); + int zy=(int16_t)(word(frame.ram,0xbb0)-word(frame.ram,0x1e50))-8; + int row=y-zy+64; + unsigned palette=frame_zero.charge>=81 && frame_zero.charge<201 ? 32 : 0; + if(row>=0 && row=0 && dx=0 && py>=0 && px>1)&7)|(group==0x50?3:1))*16; + if (zero) color+=(((flags>>1)&7)|(group>=0x6f?0:group==0x50?3:1))*16; p.pixels[py*p.width+px]=uint8_t(color); } } } return p; } +Bytes charge_graphics(const Rom& r) { + /* Original X3 resource $0A; retain the game's bounded LZ backreferences. */ + unsigned rec=0x86f732+0x0a*5,length=r.integer(rec+3); + size_t p=r.offset(r.integer(rec,3));Bytes b; + while (b.size()>=1) { + if (control&bit) { + Bytes pair=r.raw(p,2);p+=2;unsigned count=pair[0]>>2,distance=((pair[0]&3)<<8)|pair[1]; + require(count && distance && distance<=b.size() && b.size()+count<=length,"Invalid source charge backreference."); + while (count--) b.push_back(b[b.size()-distance]); + } else b.push_back(r.raw(p++,1)[0]); + } + } + return b; +} Bytes zero_assets(const Rom& r) { - Bytes out{'M','M','X','Z','E','R','O','6'}; + Bytes out{'M','M','X','Z','E','R','O','7'}; for (unsigned v : {128,128,64,64,117,35}) put(out,v); std::array colors{}; for (unsigned key : {0xd0,0xd2}) { @@ -117,12 +133,20 @@ Bytes zero_assets(const Rom& r) { } } for (unsigned i=0;i<16;++i) colors[176+i]=r.integer(0x8cb5a0+i*2); + for (unsigned i=0;i<16;++i) colors[128+i]=r.integer(0x8cb100+i*2); + for (unsigned i=0;i<16;++i) colors[160+i]=r.integer(0x8cb0e0+i*2); for (unsigned i=128;i<256;++i) put(out,colors[i]); Bytes bounds=r.at(0x86b837,40);for (unsigned i=1;i<40;i+=4) bounds[i]-=8; append(out,bounds);append(out,r.at(0x2c8d20,64));append(out,r.at(0x2c8de0,64));append(out,r.at(0x8cb0e0,32)); append(out,r.at(0x3fcc74,0x474));append(out,r.at(0x399161,120));append(out,r.at(0x3991d9,76)); const unsigned groups[][3]={{0x4a,117,0x85d6a8},{0x4b,21,0x85db47},{0x50,14,0x85e6e0}}; for (auto& g : groups) { Tiles t;for (unsigned i=0;i static uint8_t *poses; +static uint8_t *charge_poses; +static uint16_t charge_colors[3][16]; static uint16_t colors[128]; static uint8_t saber_bounds[40]; static uint8_t hud_tiles[128]; @@ -66,6 +68,7 @@ void MmxZeroHealthRespawn(const uint8_t r[0x20000]) { void MmxZeroDisable(void) { MmxZeroResetState(); free(poses); poses = NULL; + free(charge_poses); charge_poses = NULL; } bool MmxZeroLoad(const char *path) { FILE *f = path ? fopen(path, "rb") : NULL; @@ -73,9 +76,10 @@ bool MmxZeroLoad(const char *path) { uint8_t header[20], palette[256], bounds[40], hud[160], anim[MMX_ZERO_ANIMATION_BYTES]; uint8_t emission[MMX_ZERO_MUZZLE_BYTES]; size_t size = (size_t)MMX_ZERO_POSES * MMX_ZERO_WIDTH * MMX_ZERO_HEIGHT; - uint8_t *data = NULL; + uint8_t *data = NULL, *particles = NULL, flash[96]; + bool modern = false; bool ok = fread(header, 1, sizeof(header), f) == sizeof(header) && - !memcmp(header, "MMXZERO6", 8) && word(header + 8) == MMX_ZERO_WIDTH && + ((modern = !memcmp(header, "MMXZERO7", 8)) || !memcmp(header, "MMXZERO6", 8)) && word(header + 8) == MMX_ZERO_WIDTH && word(header + 10) == MMX_ZERO_HEIGHT && word(header + 12) == 64 && word(header + 14) == 64 && word(header + 16) == 117 && word(header + 18) == 35 && fread(palette, 1, sizeof(palette), f) == sizeof(palette) && @@ -83,7 +87,15 @@ bool MmxZeroLoad(const char *path) { fread(hud, 1, sizeof(hud), f) == sizeof(hud) && fread(anim, 1, sizeof(anim), f) == sizeof(anim) && fread(emission, 1, sizeof(emission), f) == sizeof(emission); - if (ok) { data = malloc(size); ok = data && fread(data, 1, size, f) == size && fgetc(f) == EOF; } + if (ok) { data = malloc(size); ok = data && fread(data, 1, size, f) == size; } + size_t charge_size = (size_t)MMX_ZERO_CHARGE_POSES * MMX_ZERO_WIDTH * MMX_ZERO_HEIGHT; + if (ok && modern) { + particles = malloc(charge_size); + ok = particles && fread(flash, 1, sizeof(flash), f) == sizeof(flash) && + fread(particles, 1, charge_size, f) == charge_size; + if (ok) for (size_t i=0;i=16) { ok=false; break; } + } + if (ok) ok = fgetc(f) == EOF; fclose(f); if (ok) for (unsigned i = 0; i < sizeof(bounds); i += 4) if (!bounds[i + 2] || bounds[i + 2] > 64 || !bounds[i + 3] || bounds[i + 3] > 64) { ok = false; break; } @@ -94,8 +106,9 @@ bool MmxZeroLoad(const char *path) { } if (ok) for (unsigned i = 0; i < 117; ++i) if ((emission[i] & 1) || emission[i] > 74) { ok = false; break; } - if (!ok) { free(data); return false; } - MmxZeroDisable(); poses = data; + if (!ok) { free(data); free(particles); return false; } + MmxZeroDisable(); poses = data; charge_poses = particles; + if (modern) for (unsigned i=0;i<48;++i) charge_colors[i/16][i%16]=(uint16_t)(word(flash+i*2)&0x7fff); memcpy(saber_bounds, bounds, sizeof(bounds)); memcpy(animation, anim, sizeof(animation)); memcpy(muzzle, emission, sizeof(muzzle)); @@ -105,6 +118,21 @@ bool MmxZeroLoad(const char *path) { return true; } const uint16_t *MmxZeroColors(void) { return colors; } +bool MmxZeroHasChargeArt(void) { return charge_poses != NULL; } +const uint16_t *MmxZeroBodyColors(const MmxZeroState *s) { + if (!charge_poses || !s || s->active_x || s->swap_phase || s->slash || + (s->charge < 25 && !(s->combo && s->saber_ready)) || (s->charge_phase & 2)) return colors + 16; + return charge_colors[s->saber_ready || s->charge >= 201 ? 2 : s->charge >= 141 ? 1 : 0]; +} +const uint8_t *MmxZeroChargePose(const MmxZeroState *s) { + if (!charge_poses || !s || s->active_x || s->swap_phase || s->slash || s->burst || s->combo || s->charge < 21) return NULL; + unsigned group = s->charge < 81 ? 0 : s->charge < 141 ? 1 : 2; + return charge_poses + (size_t)(group * 22 + s->charge_phase % 22) * MMX_ZERO_WIDTH * MMX_ZERO_HEIGHT; +} +bool MmxZeroNativeChargeObject(unsigned object, unsigned kind) { + /* $82:82ED allocates any of twelve small actors, not just $0C98. */ + return object >= 0xc98 && object < 0xe18 && ((object - 0xc98) % 32) == 0 && kind == 1; +} const uint8_t *MmxZeroMenuPose(void) { return poses; } static void animation_record(unsigned offset) { if (offset < 272 || offset + 3 > sizeof(animation) || !animation[offset] || animation[offset + 2] >= 117) { @@ -280,6 +308,9 @@ static void clear_charge(uint8_t *r) { * Bypassing that release path without $17 leaves the SPC voice playing. */ if (r[0xc2f] & 64) { sound(r,0x17); r[0xc2f] &= (uint8_t)~64; } memset(r + 0xbff, 0, 5); + r[0xc2a] = 0; + for (unsigned d=0xc98;d<0xe18;d+=32) + if (MmxZeroNativeChargeObject(d,r[d+10])) memset(r+d,0,32); } unsigned MmxZeroSwapPose(const MmxZeroState *s) { if (!s || !s->swap_phase || s->swap_phase == 3) return 255; @@ -371,7 +402,6 @@ static void emit_burst(uint8_t *r) { ++r[0xbdd]; r[0x1f0d] = 4; sound(r,2); /* Native full-buster release sound ($81:A015), once per shot. */ state.shot_mask |= (uint8_t)(1u << ((d - 0x1228) / 64)); - state.cooldown = 0; } static void advance_burst(uint8_t *r) { unsigned flags = animation[state.burst_offset + 1]; @@ -412,7 +442,6 @@ static void track_burst_shots(const uint8_t *r) { * retained X1 beam executes its disappearance animation in the SAME slot * ($81:A3CE..A40D); C25 is decremented only afterwards. Wait for that real * lifetime, including impact/offscreen recovery, with no guessed delay. */ - state.cooldown = 0; state.shot_mask = (uint8_t)alive; } static bool burst_holds_air(void) { @@ -427,6 +456,9 @@ void MmxZeroPlayerTick(uint8_t r[0x20000]) { (action <= 8 || action == 0x10 || action == 0x12 || action == 0x14 || action == 0x20); if (!playable) { MmxZeroCancel(r); return; } bool held = (r[0xbdf] & 64) != 0, pressed = (r[0xbe3] & 64) != 0; + if (state.charge >= 21 || (state.combo && state.saber_ready)) + state.charge_phase = (uint8_t)((state.charge_phase + 1) % 88); + else state.charge_phase = 0; track_burst_shots(r); if (state.burst_end) { state.burst = state.burst_end = 0; @@ -445,7 +477,7 @@ void MmxZeroPlayerTick(uint8_t r[0x20000]) { state.combo = state.saber_ready ? 2 : 0; start_burst(r,2); } - } else if (state.saber_ready && !state.shot_mask && !state.cooldown && !r[0xc25]) { + } else if (state.saber_ready && !state.shot_mask && !r[0xc25]) { unsigned d = free_projectile(r); if (d) { clear_charge(r); state.combo = state.saber_ready = 0; state.slash = 1; diff --git a/src/mmx_zero.h b/src/mmx_zero.h index 10d826c..b20265d 100644 --- a/src/mmx_zero.h +++ b/src/mmx_zero.h @@ -3,13 +3,14 @@ #include #include -enum { MMX_ZERO_WIDTH = 128, MMX_ZERO_HEIGHT = 128, MMX_ZERO_POSES = 152, +enum { MMX_ZERO_WIDTH = 128, MMX_ZERO_HEIGHT = 128, MMX_ZERO_POSES = 152, MMX_ZERO_CHARGE_POSES = 66, MMX_ZERO_ANIMATION_BYTES = 0x474, MMX_ZERO_MUZZLE_BYTES = 196, MMX_ZERO_LEGACY_STATE_SIZE = 12, MMX_ZERO_ANIMATION_STATE_SIZE = 18, MMX_ZERO_COMBAT_STATE_SIZE = 30, MMX_ZERO_SWAP_STATE_SIZE = 36 }; typedef struct MmxZeroState { uint16_t charge, slash, projectile; - uint8_t combo, cooldown, air, facing; + /* Reuses the formerly unused cooldown byte without changing save layout. */ + uint8_t combo, charge_phase, air, facing; uint16_t hit_slots; uint16_t anim_offset; uint8_t anim_timer, anim_pose, anim_flags, anim_valid; @@ -36,6 +37,10 @@ const uint8_t *MmxZeroTeleportPose(unsigned pose); const uint8_t *MmxZeroPose(const uint8_t ram[0x20000], const MmxZeroState *snapshot); const uint8_t *MmxZeroBlade(const MmxZeroState *snapshot); const uint16_t *MmxZeroColors(void); +const uint16_t *MmxZeroBodyColors(const MmxZeroState *snapshot); +const uint8_t *MmxZeroChargePose(const MmxZeroState *snapshot); +bool MmxZeroHasChargeArt(void); +bool MmxZeroNativeChargeObject(unsigned object, unsigned kind); const uint8_t *MmxZeroMenuPose(void); /* Original X3 BGR555 badge pixel; -2 is transparent, -1 retains native art. */ int MmxZeroHudColor(unsigned x, unsigned y); diff --git a/src/mods/mmx_zero_plugin.c b/src/mods/mmx_zero_plugin.c index 64050fa..f10693f 100644 --- a/src/mods/mmx_zero_plugin.c +++ b/src/mods/mmx_zero_plugin.c @@ -97,7 +97,7 @@ static void activate(void) { RecompLauncherCModResource resource = {0}; if (!provider || !provider->feature_resource_get || !provider->feature_resource_get(provider->ctx,"megaman-x.character.zero","zero",0,&resource) || !resource.path[0]) return; - if (!snesrecomp_exe_dir_path("cache/mmx-source/x3-zero-v6.bin",path,sizeof(path))) return; + if (!snesrecomp_exe_dir_path("cache/mmx-source/x3-zero-v7.bin",path,sizeof(path))) return; char error[512]; if (!MmxSourceAssetsBuild(resource.path,3,1,path,error,sizeof(error))) { fprintf(stderr,"[mmx-source] %s\n",error); diff --git a/tests/mmx_adaptive_state_test.c b/tests/mmx_adaptive_state_test.c index 822fde3..8214142 100644 --- a/tests/mmx_adaptive_state_test.c +++ b/tests/mmx_adaptive_state_test.c @@ -364,6 +364,71 @@ static void zero_half_charge_checks(const char *fixture) { } check(RtlLoadSnapshot(fixture),"restore after native half-charge check"); } +static unsigned zero_charge_actors(void) { + unsigned n=0; + for(unsigned d=0xc98;d<0xe18;d+=32) n+=g_ram[d] && MmxZeroNativeChargeObject(d,g_ram[d+10]); + return n; +} +static void zero_charge_visual_checks(const char *fixture,uint8 *start,uint8 *expected,uint8 *actual,size_t cap) { + const char *capture=getenv("MMX_ZERO_TEST_CAPTURE"); + for(unsigned arms=0;arms<=2;arms+=2) { + check(RtlLoadSnapshot(fixture),"restore charge-color fixture"); + g_ram[0x1f99]=(uint8_t)arms; + const uint16_t *palettes[3]={0}; + for(unsigned tick=1;tick<=228;++tick) { + frame(SNES_PAD_Y); MmxZeroState z=MmxZeroGetState(); + check(zero_charge_actors()<=1,"one native charge effect while holding fire"); + if(z.charge>=25 && !(z.charge_phase&2)) { + unsigned tier=z.charge>=201?2:z.charge>=141?1:0; + palettes[tier]=MmxZeroBodyColors(&z); + check(palettes[tier]!=MmxZeroColors()+16,"body flashes before obtaining X1 arms"); + if(tick==29 || tick==145 || tick==205) { + char suffix[64];snprintf(suffix,sizeof(suffix),".charge-%u-%u.cap",arms,tier); + zero_capture(capture,suffix); + } + } + if(tick>=21) check(MmxZeroChargePose(&z)!=NULL,"original X3 charge particle poses available"); + } + check(palettes[0] && palettes[1] && palettes[2] && memcmp(palettes[0],palettes[1],32) && + memcmp(palettes[1],palettes[2],32),"blue, purple and green charge palettes are distinct"); + size_t n=RtlSaveSnapshotToMemory(start,cap); + for(unsigned i=0;i<16;++i) frame(SNES_PAD_Y); + size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore full-charge animation"); + for(unsigned i=0;i<16;++i) frame(SNES_PAD_Y); + size_t an=RtlSaveSnapshotToMemory(actual,cap); + same(expected,en,actual,an,"full-charge particles and palette replay exactly"); + frame(0);zero_replay(17);frame(SNES_PAD_Y);zero_replay(90); + MmxZeroState z=MmxZeroGetState(); + check(z.combo==2 && z.saber_ready && !z.burst && !MmxZeroChargePose(&z) && !zero_charge_actors(), + "stored saber retains readiness after beams without orbiting charge effects"); + unsigned green=0,base=0; + for(unsigned i=0;i<8;++i) { + frame(0);z=MmxZeroGetState();const uint16_t *p=MmxZeroBodyColors(&z); + green+=p==palettes[2];base+=p==MmxZeroColors()+16; + if(p==palettes[2]) zero_capture(capture,".stored-saber.cap"); + } + check(green==4 && base==4,"stored saber alternates original green and normal palette every two frames"); + frame(SNES_PAD_Y);z=MmxZeroGetState(); + check(z.slash && !z.saber_ready && MmxZeroBodyColors(&z)==MmxZeroColors()+16,"saber use consumes the green readiness flash"); + } + check(RtlLoadSnapshot(fixture),"restore interrupted charge fixture"); + for(unsigned i=0;i<160;++i) frame(SNES_PAD_Y); + /* Put the live charge actor in a later legal allocation, then take hurt. */ + check(g_ram[0xc98] && g_ram[0xca2]==1,"native charge actor allocated"); + memcpy(g_ram+0xd58,g_ram+0xc98,32);memset(g_ram+0xc98,0,32); + g_ram[0xbaa]=0x0e;g_ram[0xbab]=0;frame(SNES_PAD_Y); + check(!MmxZeroGetState().charge && !zero_charge_actors(),"hurt cancels charge including an effect outside the first pool slot"); + for(unsigned i=0;i<230;++i) {frame(SNES_PAD_Y);check(zero_charge_actors()<=1,"held fire after hurt never doubles the effect");} + zero_health_swap(); + check(MmxZeroGetState().active_x && !zero_charge_actors(),"swap cancels Zero charge effects before X arrives"); + for(unsigned i=0;i<150;++i) frame(SNES_PAD_Y); + zero_health_swap(); + check(!MmxZeroGetState().active_x && !zero_charge_actors(),"swap cancels X charge effects before Zero arrives"); + for(unsigned i=0;i<210;++i) {frame(SNES_PAD_Y);check(zero_charge_actors()<=1,"held fire after swap never doubles the effect");} + frame(0);check(MmxZeroGetState().saber_ready,"recharged Zero releases the full combo normally"); + puts("MMX ZERO CHARGE COLOR AND CANCELLATION CHECKS PASSED"); +} static void zero_state_checks(const char *assets, const char *fixture, uint8 *start, uint8 *expected, uint8 *actual, size_t cap) { check(fixture != NULL && MmxZeroLoad(assets), "Zero local assets load"); @@ -375,6 +440,7 @@ static void zero_state_checks(const char *assets, const char *fixture, uint8 *st g_config.widescreen = false; int w,h; MmxPrepareFrame(1280,720,&w,&h); check(w == 256 && g_mmx_custom_renderer, "Zero activates native-width compositor"); + if(getenv("MMX_ZERO_CHARGE_ONLY")) { zero_charge_visual_checks(fixture,start,expected,actual,cap); return; } if(getenv("MMX_ZERO_READY_ONLY")) { zero_ready_checks(fixture); return; } zero_swap_checks(fixture,start,expected,actual,cap); if(getenv("MMX_ZERO_SWAP_ONLY")) { puts("MMX SELECT SWAP CHECKS PASSED"); return; } diff --git a/tests/mmx_zero_test.c b/tests/mmx_zero_test.c index d497b3a..9ed9c5a 100644 --- a/tests/mmx_zero_test.c +++ b/tests/mmx_zero_test.c @@ -147,8 +147,12 @@ int main(void) { tick(64,64); assert(MmxZeroGetState().combo == 2 && !MmxZeroGetState().slash); ram[0xbaa] = 0x0e; tick(0,0); assert(!MmxZeroGetState().combo); player(); tick(64,64); ram[0xc2f]|=64; + /* The allocator can put stale charge effects anywhere in this pool. */ + ram[0xc98]=ram[0xd58]=1; ram[0xca2]=ram[0xd62]=1; + ram[0xd78]=1; ram[0xd82]=2; /* An unrelated small effect must survive. */ MmxZeroCancel(ram); MmxZeroCancel(ram); assert(ram[0xba3]==2 && ram[0xb72]==0x17 && !(ram[0xc2f]&64)); + assert(!ram[0xc98] && !ram[0xd58] && ram[0xd78]); ram[0xc2f]|=64; MmxZeroCancel(ram); assert(ram[0xba3]==2 && (ram[0xc2f]&64)); /* Native special charge is untouched. */ MmxZeroDisable(); MmxZeroSetCollisionRom(rom,sizeof(rom)); diff --git a/tools/extract_zero.py b/tools/extract_zero.py index 17a7bb2..689328a 100644 --- a/tools/extract_zero.py +++ b/tools/extract_zero.py @@ -55,12 +55,42 @@ def palette(rom): return colors[128:] +def common_tiles(rom): + # X3 resource $0A ($86:F732), decompressed by $80:B730 into OBJ $6800. + record = 0x86f732 + 0x0a * 5 + address, length = rom.integer(record, 3), rom.integer(record + 3) + offset = ((address >> 16) & 127) * 32768 + (address & 32767) + decoded = bytearray() + while len(decoded) < length: + control = rom.data[offset] + offset += 1 + for bit in (128, 64, 32, 16, 8, 4, 2, 1): + if len(decoded) == length: + break + if control & bit: + a, b = rom.data[offset:offset + 2] + offset += 2 + count, distance = a >> 2, ((a & 3) << 8) | b + if not count or not 0 < distance <= len(decoded) or len(decoded) + count > length: + raise ValueError('Invalid common graphics backreference') + for _ in range(count): + decoded.append(decoded[-distance]) + else: + decoded.append(rom.data[offset]) + offset += 1 + if length != 4096: + raise ValueError('Unexpected common graphics size') + tiles = bytearray(8192) + tiles[0x1000:0x2000] = decoded + return tiles + + def frame(rom, group, number, dma, tiles=None): # Several eye/hand poses have no transfer and inherit the previous CHR. if tiles is None: tiles = bytearray(8192) - address = (dma & 0xff0000) | ((dma + rom.integer(dma + number * 2)) & 0xffff) - for _ in range(32): + address = (dma & 0xff0000) | ((dma + rom.integer(dma + number * 2)) & 0xffff) if dma else 0 + for _ in range(32) if dma else (): count = rom.integer(address, 1) if not count: break @@ -74,7 +104,8 @@ def frame(rom, group, number, dma, tiles=None): break address += 6 else: - raise ValueError('Unterminated tile transfer list') + if dma: + raise ValueError('Unterminated tile transfer list') table = rom.integer(0x8d8000 + group * 3, 3) address = rom.integer(table + number * 3, 3) count = rom.integer(address, 1) @@ -97,7 +128,7 @@ def frame(rom, group, number, dma, tiles=None): px, py = ORIGIN_X + x + dx, ORIGIN_Y + y + dy if not (0 <= px < WIDTH and 0 <= py < HEIGHT): raise ValueError(f'Pose {group:02x}/{number:02x} exceeds extraction canvas at {px},{py}') - base = 3 if group == 0x50 else 1 + base = 0 if group >= 0x6f else 3 if group == 0x50 else 1 pixels[py * WIDTH + px] = color + (((flags >> 1) & 7) | base) * 16 return pixels @@ -105,6 +136,11 @@ def frame(rom, group, number, dma, tiles=None): def extract(path): rom = Rom(path) colors = palette(rom) + # The stage's common OBJ palette (key $14). Zero's key $D2 is loaded at + # palette 3 by $84:819F, not at palette 0 used by charge motes. + colors[:16] = struct.unpack('<16H', rom.read(0x8cb100, 32)) + # Charge groups $70/$71 OR palette 2 until the stored saber becomes ready. + colors[32:48] = struct.unpack('<16H', rom.read(0x8cb0e0, 32)) poses = [] for group, count, dma in GROUPS: tiles = bytearray(8192) @@ -121,8 +157,14 @@ def extract(path): animation = rom.read(0x3fcc74, 0x474) # Original Zero firing-pose map and signed Y/X pairs ($81:8BA9). muzzle = rom.read(0x399161, 120) + rom.read(0x3991d9, 76) - header = struct.pack('<8s6H', b'MMXZERO6', WIDTH, HEIGHT, ORIGIN_X, ORIGIN_Y, 117, 35) - return header + struct.pack('<128H', *colors) + bounds + hud + animation + muzzle + b''.join(poses), colors, poses + # Body palette pairs $86:B3B4: blue, purple, then green; each alternates + # with the base palette every two frames. Green persists with stored saber. + flash = b''.join(rom.read(a, 32) for a in (0x8caf60, 0x8caf80, 0x8ca5e0)) + # All three charge groups share the 22 one-frame poses at $3F:DA87. + tiles = common_tiles(rom) + particles = b''.join(frame(rom, group, n, 0, tiles) for group in (0x6f, 0x70, 0x71) for n in range(22)) + header = struct.pack('<8s6H', b'MMXZERO7', WIDTH, HEIGHT, ORIGIN_X, ORIGIN_Y, 117, 35) + return header + struct.pack('<128H', *colors) + bounds + hud + animation + muzzle + b''.join(poses) + flash + particles, colors, poses def main():