From 166949bd66e1cda91aca26de35c4c223fc75de71 Mon Sep 17 00:00:00 2001 From: Matt Stanley <5354790+mstan@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:09:49 -0700 Subject: [PATCH] Tint the original READY banner red for Zero --- docs/zero-port.md | 14 ++++++++++++++ src/mmx_renderer.c | 24 ++++++++++++++++++------ tests/mmx_adaptive_state_test.c | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/docs/zero-port.md b/docs/zero-port.md index 753d11b..50396c8 100644 --- a/docs/zero-port.md +++ b/docs/zero-port.md @@ -21,6 +21,7 @@ exchange, `.33` separate HP, `.32` weapon expansion, `.34` later co-op, all unde | X1 specials | All eight native normal/charged attacks, Zero muzzle offsets and selected effect adjustments | Activation, energy, upgrade gate and both directions checked; remaining visual/state combinations need playtesting | | Hurt/fades | Replacement tied to submitted OAM, including hidden blink frames and pause fades | Renderer checks cover the reported X flashes and menu transitions | | HUD | Original X3 Z badge with its frame/palette | ROM-to-render comparison matched all 218 nontransparent badge pixels | +| READY | Original X1 stage-entry lettering and animation, red for Zero and blue for X | Native respawn captures checked for both identities; X matches original pixels | | Pause/title | Original Zero body in pause and title cursor; title confirmation still shoots | Upgraded/unupgraded menus and native title shot checked | | Life heads | **Original X 1-up artwork**, both menu and pickups | Owner explicitly rejected the custom Zero head; do not restore any custom version | | Select exchange | Grounded idle X/Zero exchange using original blue/red teleport art; game tasks freeze | Both directions, held/midair Select, frozen live projectile and deterministic replay checked | @@ -32,6 +33,19 @@ both games. It did not: the implementation uses bounded source investigation, asset tables and integration hooks. This does not establish perfect X3 fidelity or a complete campaign playthrough. +## READY appearance + +Post-0.0.1 READY follow-up: X1 `$80:9A3E` creates actor `$1CE8`, class `$0A`; +`$81:F091` selects animation group `$19`, at the original screen center. +Its sequence at `$AF:AF76` forms letters with two-frame poses 0..11 and then +flashes poses 12/13. The compositor changes only the blue color ramp of that +actor during arrival state `$D3=2` when Zero is active. Original neutral +highlights, geometry, visibility, timing and other users of its palette stay +intact. This requires no extra assets, ROM patch, simulation hook or save field. +Private `MMX_ZERO_READY_ONLY=1` runtime checks reach READY through actual death +and respawn, validate red lettering for Zero and unchanged lettering for X, +and optionally save `.zero-ready.cap` / `.x-ready.cap` for visual review. + ## Public distribution and asset provenance The [source-ROM release contract](mod-source-roms.md) is mandatory for **all** diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c index cceab80..68d4546 100644 --- a/src/mmx_renderer.c +++ b/src/mmx_renderer.c @@ -599,7 +599,7 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, static void sprite(const Ppu *p, const Raster *r, int x, int sy, unsigned attr, int size, int y, MmxRenderView view, uint16_t *out, bool margins_only, const MmxSpriteAsset *asset, unsigned raw_tile, int *object_color, - bool full_coordinates, unsigned zero_icon) { + bool full_coordinates, unsigned zero_icon, bool red_ready) { int row = full_coordinates ? y - sy : (y - sy) & 255; if (row < 0 || row >= size) return; if (attr & 0x8000) row = size - 1 - row; @@ -627,6 +627,14 @@ static void sprite(const Ppu *p, const Raster *r, int x, int sy, unsigned attr, out[dest] = (uint16_t)(z | pixel); object_color[dest] = hud_color >= 0 ? hud_color : asset && !asset->live_colors ? asset->colors[pixel] : -1; + if (red_ready) { + unsigned color=object_color[dest]>=0 ? (unsigned)object_color[dest] : r->palette[(z|pixel)&255]; + unsigned red=color&31,green=(color>>5)&31,blue=(color>>10)&31; + /* Keep the original highlights/neutral outline. Convert only the + * blue ramp to red, using its existing dark-to-light shading. */ + if (blue>red && blue>green) + object_color[dest]=(int)(blue | ((red= 256) ++stats.margin_sprite_pixels; } } @@ -787,7 +795,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { for (int i = (int)waiting_count - 1; i >= 0; --i) { Piece s = waiting[i]; const MmxSpriteAsset *asset = s.animation == 0x53 ? waiting_zero : NULL; - sprite(&p, r, s.x, s.y, s.attr, s.size, y, view, objects, true, asset, s.tile, object_colors, true, false); + sprite(&p, r, s.x, s.y, s.attr, s.size, y, view, objects, true, asset, s.tile, object_colors, true, false, false); } bool replaced[128] = {false}; bool zero_drawn[2] = {false,false}; @@ -799,6 +807,10 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { * their entire footprint, including x=255 which native D76A clips. * Only the explicit expanded list can add pieces beyond that budget. */ bool center = g_mmx_render_asset_repairs; + /* $80:9A3E spawns READY in $1CE8; $81:F091 selects group $19. + * Restrict the recolor to this actor during the arrival phase. */ + bool red_ready=stage && MmxZeroEnabled() && !frame_zero.active_x && + frame.ram[0xd3]==2 && s.object==0x1ce8 && s.animation==0x19; bool menu_body = s.object == 0x1988 && (s.animation == 0 || s.animation == 0x18); bool zero_body = zero && (s.object == 0xba8 || menu_body); bool swap_actor = swapping && (s.object == 0xba8 || s.object == 0xc38 || @@ -807,7 +819,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { 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; - if (g_mmx_render_asset_repairs || zero_body || zero_armor || zero_charge || swap_actor) for (int slot = 16; slot < 128; ++slot) { + if (g_mmx_render_asset_repairs || zero_body || zero_armor || zero_charge || swap_actor || red_ready) for (int slot = 16; slot < 128; ++slot) { unsigned pos = r->oam[slot * 2], hi = r->high_oam[slot / 4] >> (slot % 4 * 2); int ox = (pos & 255) | ((hi & 1) << 8); if (ox >= 256) ox -= 512; if (ox == s.x && (pos >> 8) == ((unsigned)s.y & 255) && r->oam[slot * 2 + 1] == s.attr) { @@ -864,7 +876,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { s.x += (s.x + s.size / 2 - cx) / 4; s.y += (s.y + s.size / 2 - cy) / 4 - 6; } - sprite(&p, r, s.x, s.y, attr, s.size, y, view, objects, !center, asset, s.tile, object_colors, true, false); + sprite(&p, r, s.x, s.y, attr, s.size, y, view, objects, !center, asset, s.tile, object_colors, true, false, red_ready); } int bar_first = -1, bar_count = 0; if (hud) for (int slot = 16; slot <= 48; ++slot) { @@ -890,7 +902,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { x == 8 && sy == 80 && attr == 0x3486 && size == 16; bool anchored = stage && hud && sy < 96 && (slot < 16 || (bar_count >= 4 && slot >= bar_first && slot < bar_first + bar_count)); 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); + sprite(&p, r, x, sy, attr, size, y, view, objects, false, NULL, 0, object_colors, false, zero_icon, false); } if (swapping) { unsigned pose = MmxZeroSwapPose(&frame_zero); @@ -913,7 +925,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { const uint8_t *layout = a ? sprite_arrangement(0,pose) : NULL; if (layout) for (int i = layout[0] - 1; i >= 0; --i) { Piece s = make_piece(layout + i * 4,x,sy,flip,0x22,0,0,0xba8); - sprite(&p,r,s.x,s.y,s.attr,s.size,y,view,objects,false,a,s.tile,object_colors,true,false); + sprite(&p,r,s.x,s.y,s.attr,s.size,y,view,objects,false,a,s.tile,object_colors,true,false,false); } } } diff --git a/tests/mmx_adaptive_state_test.c b/tests/mmx_adaptive_state_test.c index 24db2f6..822fde3 100644 --- a/tests/mmx_adaptive_state_test.c +++ b/tests/mmx_adaptive_state_test.c @@ -280,6 +280,38 @@ static void zero_health_pickup(unsigned small) { zero_replay(55); check(!g_ram[0x1628],"native health pickup finishes collection"); } +static void zero_ready_checks(const char *fixture) { + for(unsigned character=0;character<2;++character) { + check(RtlLoadSnapshot(fixture),"restore READY fixture"); + zero_replay(10); + if(character) zero_health_swap(); + g_ram[0xbcf]=128; g_ram[0xbaa]=0x0c; g_ram[0xbab]=0; + bool seen=false; + for(unsigned i=0;i<1000;++i) { + frame(0); + if(g_ram[0xd3]==2 && g_ram[0x1ce9] && g_ram[0x1cfe]==0x19 && !g_ram[0x1cf4]) { + zero_replay(28); /* Group $19 pose 12: fully formed lettering. */ + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-ready.cap":".zero-ready.cap"); + uint32_t pixels[256*224]; + check(MmxRendererDraw(pixels,(MmxRenderView){256,0,4.0/3.0},true),"READY frame renders"); + const uint32_t *stock=MmxRendererStockFrame(); + unsigned changed=0,red_pixels=0; + for(unsigned y=96;y<128;++y) for(unsigned x=80;x<176;++x) { + unsigned index=y*256+x,color=pixels[index]; + if(color!=stock[index]) { + ++changed; + if(((color>>16)&255)>(color&255)+40 && ((color>>16)&255)>((color>>8)&255)+40) ++red_pixels; + } + } + check(character ? !changed : changed>20 && changed==red_pixels, + "Zero READY changes only blue lettering to red; X READY matches original"); + seen=true; break; + } + } + check(seen,"native respawn reaches the original READY banner"); + } + puts("MMX READY CHECKS PASSED"); +} static void zero_health_checks(const char *fixture, uint8 *start, uint8 *expected, uint8 *actual, size_t cap) { check(RtlLoadSnapshot(fixture),"restore for separate HP"); zero_replay(10); @@ -343,6 +375,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_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; } zero_health_checks(fixture,start,expected,actual,cap);