Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/mod-source-roms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 56 additions & 2 deletions docs/zero-port.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
20 changes: 18 additions & 2 deletions src/mmx_renderer.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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];
}
}
}
Expand Down Expand Up @@ -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<MMX_ZERO_HEIGHT) for(int col=0;col<MMX_ZERO_WIDTH;++col) {
unsigned pixel=charge[row*MMX_ZERO_WIDTH+col];
int dx=zx+((frame.ram[0xbb9]&64)?63-col:col-64)+view.extra;
if(pixel && dx>=0 && dx<view.width) {
objects[dx]=(uint16_t)(0xe680|pixel); object_colors[dx]=MmxZeroColors()[palette+pixel];
}
}
}
if (swapping) {
unsigned pose = MmxZeroSwapPose(&frame_zero);
int x = (int16_t)(word(frame.ram,0xbad) - word(frame.ram,0x1e4d));
Expand Down
28 changes: 26 additions & 2 deletions src/mmx_source_assets.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,31 @@ Pose pose(const Rom& r,unsigned group,unsigned number,const Tiles& t,bool zero=f
if (color) {
int px=x+int(dx)-p.left,py=y+int(dy)-p.top;
require(px>=0 && py>=0 && px<p.width && py<p.height,"Source sprite exceeds canvas.");
if (zero) color+=(((flags>>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()<length) {
unsigned control=r.raw(p++,1)[0];
for (unsigned bit=128;bit && b.size()<length;bit>>=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<unsigned,256> colors{};
for (unsigned key : {0xd0,0xd2}) {
Expand All @@ -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<g[1];++i) { transfer(r,g[2],i,t);append(out,pose(r,g[0],i,t,true).pixels); } }
for (unsigned a : {0x8caf60,0x8caf80,0x8ca5e0}) append(out,r.at(a,32));
Tiles t; Bytes common=charge_graphics(r);
require(common.size()==4096,"Unexpected Zero common graphics size.");
std::copy(common.begin(),common.end(),t.bytes.begin()+0x1000);
for (unsigned group : {0x6f,0x70,0x71}) for (unsigned i=0;i<22;++i)
append(out,pose(r,group,i,t,true).pixels);
return out;
}
void publish(const char *output,const Bytes& bytes) {
Expand Down
48 changes: 40 additions & 8 deletions src/mmx_zero.c
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
#include <string.h>

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];
Expand Down Expand Up @@ -66,24 +68,34 @@ 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;
if (!f) return false;
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) &&
fread(bounds, 1, sizeof(bounds), f) == sizeof(bounds) &&
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<charge_size;++i) if (particles[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; }
Expand All @@ -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));
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand All @@ -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;
Expand Down
Loading
Loading