From f89fd26ea6348422c1035647d4d3f32c8d87ba5b Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 12 Sep 2026 01:47:31 -0700 Subject: [PATCH 1/2] desktop: migrate MMX hosts to shared save and rewind menus --- runner/cmake/mmx_state_tests.cmake | 20 + runner/src/common_rtl.c | 37 + runner/src/common_rtl.h | 9 + runner/src/desktop/config.h | 1 + runner/src/desktop/host_main.c | 97 +- runner/src/desktop/host_main.h | 5 + runner/src/desktop/mmx23_host_main.inc | 2146 +----------------------- runner/src/desktop/mmx_config.c | 33 +- runner/src/snes/cx4.c | 4 + runner/src/snes/cx4.h | 4 + runner/tests/mmx_state_runtime.c | 129 ++ runner/tests/run_mmx_state_tests.py | 23 + 12 files changed, 380 insertions(+), 2128 deletions(-) create mode 100644 runner/cmake/mmx_state_tests.cmake create mode 100644 runner/tests/mmx_state_runtime.c create mode 100644 runner/tests/run_mmx_state_tests.py diff --git a/runner/cmake/mmx_state_tests.cmake b/runner/cmake/mmx_state_tests.cmake new file mode 100644 index 00000000..f4f5b647 --- /dev/null +++ b/runner/cmake/mmx_state_tests.cmake @@ -0,0 +1,20 @@ +# Optional real-ROM integration check; the game and host sources are identical +# to the executable. The test entry includes the host to exercise its adapter. +function(snesrecomp_target_mmx_state_tests target game_main) + option(MMX_STATE_TESTS "Build ROM-backed MMX save/rewind checks" OFF) + if(NOT MMX_STATE_TESTS) + return() + endif() + get_target_property(_sources ${target} SOURCES) + list(REMOVE_ITEM _sources src/main.c "${game_main}" + "${SNESRECOMP_RUNNER_ROOT}/src/desktop/host_main.c") + add_executable(mmx_state_tests + "${SNESRECOMP_RUNNER_ROOT}/tests/mmx_state_runtime.c" ${_sources}) + foreach(_property INCLUDE_DIRECTORIES COMPILE_DEFINITIONS COMPILE_OPTIONS LINK_LIBRARIES LINK_OPTIONS) + get_target_property(_value ${target} ${_property}) + if(_value) + set_property(TARGET mmx_state_tests PROPERTY ${_property} "${_value}") + endif() + endforeach() + target_compile_definitions(mmx_state_tests PRIVATE MMX_GAME_MAIN="${game_main}") +endfunction() diff --git a/runner/src/common_rtl.c b/runner/src/common_rtl.c index a162be69..990e2cca 100644 --- a/runner/src/common_rtl.c +++ b/runner/src/common_rtl.c @@ -398,7 +398,11 @@ void rtl_reset_host_pacing(void) { snes_refresh_state_set(0u, g_cpu.master_cycles); } +static uint64_t s_state_generation; +uint64_t RtlStateGeneration(void) { return s_state_generation; } + void RtlReset(int mode) { + ++s_state_generation; rtl_reset_host_pacing(); snes_reset(g_snes, true); g_snes->beamMasterLast = g_cpu.master_cycles; @@ -871,6 +875,7 @@ bool RtlLoadSnapshot(const char *filename) { * game one hook to rebuild it against the freshly restored WRAM. */ if (g_rtl_game_info && g_rtl_game_info->on_state_loaded) g_rtl_game_info->on_state_loaded(hdr[1]); + ++s_state_generation; return true; } @@ -918,6 +923,7 @@ bool RtlLoadSnapshotFromMemory(const void *data, size_t size) { PpuResetWidescreenOamHistory(g_snes->ppu); if (g_rtl_game_info && g_rtl_game_info->on_state_loaded) g_rtl_game_info->on_state_loaded(hdr[1]); + ++s_state_generation; return true; } @@ -1106,6 +1112,37 @@ static void rtl_rb_residue_apply(const RtlRollbackResidue *r) { ppu_rb_residue_set(g_snes->ppu, &r->ppu_rb); } +static RtlRollbackResidue s_loaded_execution; +static bool s_loaded_execution_valid; + +void RtlSaveExecutionState(SaveLoadInfo *sli) { + RtlRollbackResidue r; + rtl_rb_residue_capture(&r); + r.cpu.ram = NULL; /* Never persist a process address. */ + uint32 size = sizeof(r); + sli->func(sli, &size, sizeof(size)); + sli->func(sli, &r, sizeof(r)); + cx4_saveload_clock(g_snes->cart->cx4, sli); +} + +bool RtlLoadExecutionState(SaveLoadInfo *sli) { + uint32 size = 0; + s_loaded_execution_valid = false; + sli->func(sli, &size, sizeof(size)); + if (size != sizeof(s_loaded_execution)) return false; + memset(&s_loaded_execution, 0, sizeof(s_loaded_execution)); + sli->func(sli, &s_loaded_execution, sizeof(s_loaded_execution)); + cx4_saveload_clock(g_snes->cart->cx4, sli); + return s_loaded_execution_valid = + s_loaded_execution.magic == RTL_RB_RESIDUE_MAGIC && + s_loaded_execution.version == RTL_RB_RESIDUE_VERSION; +} + +void RtlApplyExecutionState(void) { + if (s_loaded_execution_valid) rtl_rb_residue_apply(&s_loaded_execution); + s_loaded_execution_valid = false; +} + size_t RtlRollbackSaveToMemory(void *data, size_t capacity) { size_t guest; RtlRollbackResidue residue; diff --git a/runner/src/common_rtl.h b/runner/src/common_rtl.h index 4cc3fc28..ccb8fbdb 100644 --- a/runner/src/common_rtl.h +++ b/runner/src/common_rtl.h @@ -323,6 +323,15 @@ void RtlSramFilePath(char *buf, size_t buflen); void RtlMigrateLegacySram(const char *legacy_title); bool RtlSaveSnapshot(const char *filename); bool RtlLoadSnapshot(const char *filename); +/* Opt-in native execution extension for title state callbacks. Save/load in + * state_*_extra; apply in on_state_loaded, after generic post-load cleanup. + * Version/size checked, pointer-free; intended for matching runtime builds. */ +struct SaveLoadInfo; +void RtlSaveExecutionState(struct SaveLoadInfo *sli); +bool RtlLoadExecutionState(struct SaveLoadInfo *sli); +void RtlApplyExecutionState(void); +/* Host timeline invalidation after a successful load or reset. */ +uint64_t RtlStateGeneration(void); size_t RtlSaveSnapshotToMemory(void *data, size_t capacity); bool RtlLoadSnapshotFromMemory(const void *data, size_t size); diff --git a/runner/src/desktop/config.h b/runner/src/desktop/config.h index e43845c7..ad71e786 100644 --- a/runner/src/desktop/config.h +++ b/runner/src/desktop/config.h @@ -139,6 +139,7 @@ enum { extern Config g_config; +void ConfigUseStateMenuDefaults(void); void ParseConfigFile(const char *filename); // Re-apply only the [KeyMap] section (launcher hotkey editor wrote it after // the initial parse). Keyboard command map is rebuilt; gamepad map and all diff --git a/runner/src/desktop/host_main.c b/runner/src/desktop/host_main.c index 02f68eb8..ee15d0af 100644 --- a/runner/src/desktop/host_main.c +++ b/runner/src/desktop/host_main.c @@ -327,6 +327,7 @@ static struct RendererFuncs g_renderer_funcs; /* Set by the hotkeys; consumed once in the frame loop. */ static int g_savestate_menu_hotkey; static int g_rewind_hotkey; +static uint64_t g_state_generation; /* The last field actually presented, kept so an overlay can freeze the guest * and still have something to draw behind itself. Sized like g_my_pixels. */ @@ -339,6 +340,11 @@ static GamepadInfo g_gamepad[2]; extern Snes *g_snes; +void snesrecomp_desktop_set_widescreen(int enabled) { + g_config.widescreen = enabled != 0; + WriteConfigFile(g_active_config_file); +} + static void GameReset(void) { if (g_game->on_reset) g_game->on_reset(); g_reset_clock = true; @@ -366,15 +372,19 @@ static void PreparePpuFrame(void) { if (fh <= 0 || fh > 240) fh = 224; g_snes_width = fw; g_snes_height = fh; - /* The PPU's own widescreen never activates here. */ - g_ws_extra = 0; - g_ws_active = false; + /* Native widescreen ports rasterize directly into the widened field. */ + g_ws_extra = g_game->native_widescreen ? (fw - 256) / 2 : 0; + g_ws_active = g_ws_extra != 0; g_new_ppu = (g_ppu_render_flags & kPpuRenderFlags_NewRenderer) != 0; if (g_config.no_sprite_limits) g_ppu_render_flags |= kPpuRenderFlags_NoSpriteLimits; else g_ppu_render_flags &= ~kPpuRenderFlags_NoSpriteLimits; - PpuBeginDrawing(g_ppu, g_my_pixels, 256 * 4, 0); + uint32 flags = g_game->native_widescreen ? g_ppu_render_flags : 0; + if (g_ws_active) flags |= kPpuRenderFlags_NewRenderer; + PpuBeginDrawing(g_ppu, g_my_pixels, + (g_game->native_widescreen ? fw : 256) * 4, flags); + PpuSetExtraSpace(g_ppu, (uint8)g_ws_extra); } // --- Scripted input --- @@ -740,6 +750,16 @@ static void CaptureSimulationFrame(unsigned number) { if (g_game->end_sim_frame) g_game->end_sim_frame(g_my_pixels, number); } +/* Snapshots and their thumbnails share the same completed raster boundary. */ +static void NoteStateFrame(void) { + if (g_ppu && g_ppu->renderBuffer) { + int width = g_game->native_widescreen ? g_snes_width : 256; + snes_savestate_menu_note_frame((const uint32_t *)g_ppu->renderBuffer, width, g_snes_height); + snes_rewind_note_framebuffer((const uint32_t *)g_ppu->renderBuffer, width, g_snes_height); + } + snes_rewind_note_frame(); +} + void RtlDrawPpuFrame(uint8 *pixel_buffer, size_t pitch, uint32 render_flags) { (void)render_flags; if (!pixel_buffer) return; @@ -967,6 +987,8 @@ static bool HandleDeviceEvent(const SDL_Event *event) { * bug as F1 through HandleInput. Controller bits still flow, so the panel * can be navigated. */ static bool g_overlay_modal; +static void SetAudioPaused(bool paused); +static void ResetAudioTimeline(void); /* Buttons still held when a panel closed, masked from the guest until each * is released. The button that closed the panel must not also act in the @@ -998,6 +1020,14 @@ static void PumpOverlayEvents(bool *running, void (*key_down)(int key, int repea *running = false; break; case SDL_KEYDOWN: + /* The browser consumes SNES control bits too. Dispatch only controls; + * slot/reset hotkeys must not change the guest behind a modal panel. */ + if (key_down == snes_savestate_menu_handle_key) { + int cmd = FindCmdForSdlKey(SNESRECOMP_SDL_EVENT_KEY(event), + SNESRECOMP_SDL_EVENT_MOD(event)); + if (cmd >= kKeys_Controls && cmd <= kKeys_Controls_Last) + HandleCommand(cmd, true); + } key_down(SNESRECOMP_SDL_EVENT_KEY(event), SNESRECOMP_SDL_EVENT_REPEAT(event)); break; case SDL_KEYUP: @@ -1285,6 +1315,7 @@ static void RunSavestateMenuLoop(bool *running) { host_report_breadcrumb("save-state browser OPEN - guest frozen until it " "closes (pad B, or Escape/Backspace on the keyboard)"); g_overlay_modal = true; + SetAudioPaused(true); while (snes_savestate_menu_is_open() && *running) { /* Key presses go straight to the overlay, NOT through HandleInput: the * game's own hotkeys must not fire while a panel owns the screen (F1 @@ -1300,6 +1331,8 @@ static void RunSavestateMenuLoop(bool *running) { frames++; } g_overlay_modal = false; + ResetAudioTimeline(); + SetAudioPaused(g_paused); OverlayNoteClosed(); host_report_breadcrumb("save-state browser CLOSED after %u pumps - guest resuming", frames); @@ -1329,6 +1362,7 @@ static void RunRewindLoop(bool *running) { host_report_breadcrumb("rewind filmstrip OPEN - guest frozen until it closes " "(pad B, or Escape; Left/Right scrub, A or Enter commits)"); g_overlay_modal = true; + SetAudioPaused(true); while (snes_rewind_is_open() && *running) { PumpOverlayEvents(running, &RewindKeyDown); if (!*running) @@ -1365,6 +1399,9 @@ static void RunRewindLoop(bool *running) { frames++; } g_overlay_modal = false; + ResetAudioTimeline(); + SetAudioPaused(g_paused); + g_state_generation = RtlStateGeneration(); /* keep the trimmed rewind history */ OverlayNoteClosed(); host_report_breadcrumb("rewind filmstrip CLOSED after %u pumps - guest resuming", frames); @@ -1395,6 +1432,16 @@ static uint8 *g_audio_stream_buffer; static size_t g_audio_stream_buffer_size; #endif +static void ResetAudioTimeline(void) { + RtlApuLock(); + g_audiobuffer_end = g_audiobuffer_cur; + g_audio_primed = false; +#if SNESRECOMP_SDL3 + if (g_audio_stream) SDL_ClearAudioStream(g_audio_stream); +#endif + RtlApuUnlock(); +} + void RtlApuLock(void) { SDL_LockMutex(g_audio_mutex); ++g_apu_lock_depth; @@ -1872,6 +1919,7 @@ int snesrecomp_desktop_main(const SnesDesktopHostGame *game, int argc, char **ar framedump_dir = argv[1]; argc -= 2, argv += 2; } + if (game->state_menu_hotkeys) ConfigUseStateMenuDefaults(); ParseConfigFile(config_file); g_active_config_file = config_file; /* Local overrides (gitignored). Last parser to set a key wins. */ @@ -2150,8 +2198,8 @@ int snesrecomp_desktop_main(const SnesDesktopHostGame *game, int argc, char **ar } g_gamepad[0].joystick_id = g_gamepad[1].joystick_id = -1; - g_ws_extra = 0; - g_ws_active = false; + g_ws_extra = g_game->native_widescreen ? (g_snes_width - 256) / 2 : 0; + g_ws_active = g_ws_extra != 0; g_ppu_render_flags = g_config.new_renderer * kPpuRenderFlags_NewRenderer | g_config.no_sprite_limits * kPpuRenderFlags_NoSpriteLimits; @@ -2459,10 +2507,17 @@ error_reading:; /* Rewind ring: reads the env overrides and reserves slot headers; the * buffer itself is allocated lazily on the first capture. */ snes_rewind_configure(); + g_state_generation = RtlStateGeneration(); host_report_breadcrumb("entering main loop"); while (running) { + if (g_state_generation != RtlStateGeneration()) { + ResetAudioTimeline(); + snes_rewind_shutdown(); + snes_rewind_configure(); + g_state_generation = RtlStateGeneration(); + } SDL_Event event; /* Inert unless SNESRECOMP_CRASH_TEST is set — support drill for the @@ -2510,7 +2565,7 @@ error_reading:; SetAudioPaused(audiopaused); } - if (g_paused) { + if (g_paused && !g_savestate_menu_hotkey && !g_rewind_hotkey) { snes_host_clock_reset(&video_clock, MonotonicSeconds(), g_simulation_hz, presentation_hz); SDL_Delay(16); continue; @@ -2562,8 +2617,8 @@ error_reading:; frameCtr++; g_screenshot_frame = frameCtr; snes_osd_note_frame(); - snes_rewind_note_frame(); CaptureSimulationFrame(frameCtr); + NoteStateFrame(); snes_netplay_finish_frame(); if (burst >= snes_host_catchup_budget()) break; @@ -2744,6 +2799,7 @@ error_reading:; GameReset(); continue; /* guest was frozen: no frame to run or present */ } + if (g_paused) continue; /* The script ticks HERE, after every path that can leave this iteration * without running a frame. Ticked above the overlay checks, an * iteration that opened a panel consumed a script frame the guest never @@ -2762,16 +2818,6 @@ error_reading:; RtlRunFrame(inputs | GetActiveControllers() | debug_server_get_controller_active_mask()); ApplyScriptForcePokes(); snes_osd_note_frame(); - /* One guest frame happened: offer it to the rewind ring, and offer the - * field as the next save's thumbnail. Both are no-ops while a panel is - * open, so a thumbnail is of the game and not of the overlay. */ - snes_rewind_note_frame(); - if (g_ppu && g_ppu->renderBuffer) { - snes_savestate_menu_note_frame((const uint32_t *)g_ppu->renderBuffer, - 256, g_snes_height); - snes_rewind_note_framebuffer((const uint32_t *)g_ppu->renderBuffer, - 256, g_snes_height); - } ProfileEnd(kProfileGuest, profile_start); frameCtr++; g_screenshot_frame = frameCtr; @@ -2805,6 +2851,7 @@ error_reading:; profile_start = ProfileStart(); CaptureSimulationFrame(frameCtr); + NoteStateFrame(); g_audio_producer_active = false; ProfileEnd(kProfileRaster, profile_start); profile_start = ProfileStart(); @@ -2860,6 +2907,7 @@ error_reading:; HandleCommand(kKeys_Save + 0, true); RtlWriteSram(); + snes_rewind_shutdown(); // clean sdl SetAudioPaused(true); @@ -3297,7 +3345,18 @@ static const char kDefaultConfigIniContent[] = "ControlsP2 = DpadUp, DpadDown, DpadLeft, DpadRight, Back, Start, B, A, Y, X, Lb, Rb\n"; static const char *DefaultConfigIni(void) { - return g_game->default_config_ini ? g_game->default_config_ini : kDefaultConfigIniContent; + if (g_game->default_config_ini) return g_game->default_config_ini; + if (g_game->state_menu_hotkeys) { + static char menu_config[sizeof(kDefaultConfigIniContent) + 128]; + const char *keys = strstr(kDefaultConfigIniContent, "SaveStateMenu = F11\n"); + const char *after_load = strchr(strstr(keys, "Load ="), '\n') + 1; + snprintf(menu_config, sizeof(menu_config), "%.*s%s%s", + (int)(keys - kDefaultConfigIniContent), kDefaultConfigIniContent, + "SaveStateMenu = F7\nRewind = F8\n" + "Load = F1,F2,F3,F4,F5,F6,F11,F12,F9,F10\n", after_load); + return menu_config; + } + return kDefaultConfigIniContent; } /* Write the default config.ini next to the executable and chdir there. Silent diff --git a/runner/src/desktop/host_main.h b/runner/src/desktop/host_main.h index e855b2d3..bbb97d62 100644 --- a/runner/src/desktop/host_main.h +++ b/runner/src/desktop/host_main.h @@ -83,6 +83,10 @@ typedef struct SnesDesktopHostGame { /* Guest-side polling that decides the default frame width. Left at 0 the * host presents 256x224. */ int frame_width, frame_height; + /* Use the runner's widened PPU field instead of a game-owned compositor. */ + int native_widescreen; + /* F7/F8 menus, with legacy slot 7/8 loads moved to F11/F12. */ + int state_menu_hotkeys; /* ── Hooks. Every one is optional. ────────────────────────────────────── */ @@ -144,6 +148,7 @@ int snesrecomp_desktop_main(const SnesDesktopHostGame *game, int argc, char **ar * default). For per-title code that composes against the current frame. */ int snesrecomp_desktop_frame_width(void); int snesrecomp_desktop_frame_height(void); +void snesrecomp_desktop_set_widescreen(int enabled); /* Ask the host to reset its pacing clock at the next opportunity (a title * that just changed its presentation settings). */ diff --git a/runner/src/desktop/mmx23_host_main.inc b/runner/src/desktop/mmx23_host_main.inc index 2f83e16e..e8a4b630 100644 --- a/runner/src/desktop/mmx23_host_main.inc +++ b/runner/src/desktop/mmx23_host_main.inc @@ -1,179 +1,28 @@ -/* Shared Mega Man X2/X3 desktop host shell. - * - * SUPERSEDED for new work by host_main.h / host_main.c: the same host as a - * linkable unit parameterized by a SnesDesktopHostGame descriptor instead of - * MMX_* macros, with the in-game overlays, the frozen-frame present and the - * decoupled presentation clock. The X2/X3 hosts still include this file; - * migrate them by writing a descriptor (see SuperMetroidRecomp/src/main.c). - * - * The including game wrapper supplies identity, header, display, widescreen, - * mod, ROM-digest, and debug-port macros. Keeping this as an include makes the - * process entry point compile in the game target while eliminating the two - * nearly identical ~2,000-line hosts. - */ -#include +/* Compatibility adapter for the X2/X3 identity shims. The desktop host, + * menus, audio, input and presentation loop are owned by host_main.c. */ #include -#include -#include -#include -#include "debug_server.h" -#include "desktop/sdl_compat.h" -#ifdef _WIN32 -#include -#include "platform/win32/volume_control.h" -#include -#else -#include -#include -#include -#endif - +#include "desktop/host_main.h" +#include "desktop/config.h" #include "snes/ppu.h" -#include "snes/ws_shadow.h" #include "widescreen.h" - -#include "types.h" #include MMX_RTL_HEADER -#include "common_cpu_infra.h" -#include "framedump.h" -#include "config.h" #include MMX_DISPLAY_HEADER -#include "util.h" #include MMX_SPC_HEADER -#if SNESRECOMP_ENABLE_MODS -#include "mod_runtime.h" -#endif -#if defined(SNES_LAUNCHER) || defined(RECOMP_LAUNCHER) -#if defined(RECOMP_LAUNCHER) -/* Shared recomp-ui launcher (F:\Projects\recomp-ui) — the console-agnostic - * extraction of launcher_ng, consumed as a junction/submodule. Built with - * its recomp_ui.cmake defines RECOMP_LAUNCHER. The game drives - * it as the SNES profile (launcher_profile_apply("snes", ...)). */ -#include "recomp_launcher.h" /* recomp_launcher_run_window() */ -#include "launcher_profile.h" /* launcher_profile_apply("snes", &gi) — SNES identity */ -#elif defined(SNES_LAUNCHER) -#include "launcher_capi.h" /* in-tree launcher_ng (snes_launcher_run_window) */ -#endif -#endif - -#include "snes/snes.h" -#ifdef __SWITCH__ -#include "switch_impl.h" -#endif - -#include "launcher.h" -#include "launcher_cache.h" -#include "keybinds.h" -#include "host_report.h" - -typedef struct GamepadInfo { - uint32 modifiers; - SDL_JoystickID joystick_id; - SDL_Joystick *joystick; - bool raw_joystick; - uint8 index; - uint8 axis_buttons; - uint16 last_cmd[kGamepadBtn_Count]; - Sint16 last_axis_x, last_axis_y; -} GamepadInfo; - -#if SNESRECOMP_SDL3 -static void SDLCALL AudioStreamCallback( - void *userdata, SDL_AudioStream *stream, int additional_amount, - int total_amount); -#else -static void SDLCALL AudioCallback(void *userdata, Uint8 *stream, int len); -#endif -static void EnsureConfigIni(void); -static void RenderNumber(uint8 *dst, size_t pitch, int n, uint8 big); -static void OpenOneGamepad(int i); -static void OpenOneJoystick(int i); -static uint32 GetActiveControllers(void); -static void HandleVolumeAdjustment(int volume_adjustment); -static void HandleGamepadAxisInput(GamepadInfo *gi, int axis, Sint16 value); -static int RemapSdlButton(int button); -static void HandleGamepadInput(GamepadInfo *gi, int button, bool pressed); -static void HandleInput(int keyCode, int keyMod, bool pressed); -static void HandleCommand(uint32 j, bool pressed); -void OpenGLRenderer_Create(struct RendererFuncs *funcs); - -struct SpcPlayer *g_spc_player; - -static uint8_t g_my_pixels[(256 + 2 * kWsExtraMax) * 4 * 240]; - - -enum { - kDefaultFullscreen = 0, - kMaxWindowScale = 10, - kDefaultFreq = 44100, - kDefaultChannels = 2, - kDefaultSamples = 2048, -}; - -/* Release stamp baked in by make_release.ps1 via - * /p:SnesRecompBuildVersion= (vcxproj turns the MSBuild property - * into this define). Local/IDE builds report "dev"; the post-mortem - * report's build.pe_timestamp still uniquely identifies those. */ #ifndef SNESRECOMP_BUILD_VERSION #define SNESRECOMP_BUILD_VERSION "dev" #endif +#ifndef MMX_DESKTOP_ENTRY +#define MMX_DESKTOP_ENTRY main +#endif +extern const RtlGameInfo kMmxGameInfo; -static const char kWindowTitle[] = MMX_WINDOW_TITLE; -static uint32 g_win_flags = SDL_WINDOW_RESIZABLE; -static SDL_Window *g_window; - -static uint8 g_paused, g_turbo, g_cursor = true; -static uint8 g_current_window_scale; -static uint32 g_input_state; -/* Gamepad-driven SNES controller bits, kept separate from g_input_state - * (keyboard) so the per-frame keybinds.ini polling at the top of the - * main loop doesn't clear bits the gamepad just set. OR'd into `inputs` - * once per frame alongside g_input_state and axis_buttons. */ -static uint32 g_pad_buttons; -static bool g_display_perf; -static int g_curr_fps; -static int g_ppu_render_flags = 0; -static int g_snes_width, g_snes_height; -static int g_last_drawable_width, g_last_drawable_height; -/* Required by the shared PPU widescreen runtime; host-only, never serialized. */ -bool g_ws_active; -int g_ws_extra; -static const char *g_active_config_file; -static int g_sdl_audio_mixer_volume = SNESRECOMP_SDL_MIX_MAXVOLUME; -static struct RendererFuncs g_renderer_funcs; - -static GamepadInfo g_gamepad[2]; - -extern Snes *g_snes; - -static void MmxDisplay_PreparePpuFrame(void) { - int drawable_width = 0, drawable_height = 0; - if (g_renderer_funcs.GetOutputSize) - g_renderer_funcs.GetOutputSize(&drawable_width, &drawable_height); - if (drawable_width <= 0 || drawable_height <= 0) - SDL_GetWindowSize(g_window, &drawable_width, &drawable_height); - if (drawable_width > 0 && drawable_height > 0) { - g_last_drawable_width = drawable_width; - g_last_drawable_height = drawable_height; - } else { - drawable_width = g_last_drawable_width; - drawable_height = g_last_drawable_height; - } - - int width = MmxDisplay_ComputeFrameWidth(drawable_width, drawable_height, - g_config.widescreen); - g_snes_width = width; - g_ws_extra = (width - 256) / 2; - g_ws_active = g_ws_extra != 0; - /* The legacy pixel-at-a-time PPU has a hard-coded 256-column loop. Keep the - * user's renderer selection, but use the priority-buffer PPU while a real - * widescreen frame is active; disabling widescreen restores that selection. */ - uint32 render_flags = g_ppu_render_flags; - if (g_ws_active) - render_flags |= kPpuRenderFlags_NewRenderer; - PpuBeginDrawing(g_ppu, g_my_pixels, (size_t)width * 4, render_flags); - PpuSetExtraSpace(g_ppu, (uint8)g_ws_extra); +static void MmxPrepareFrame(int dw, int dh, int *w, int *h) { + *w = MmxDisplay_ComputeFrameWidth(dw, dh, g_config.widescreen); + *h = 224; +} +static void MmxBeginFrame(unsigned number) { + (void)number; /* 16:9 HUD anchoring: measured slot map in docs/OAM_SURVEY.md. * HP (0-5, X=8) and weapon (7-13, X=24) anchor LEFT; the boss * bar (16-22, X=232) anchors RIGHT, so they move in opposite @@ -207,1950 +56,39 @@ static void MmxDisplay_PreparePpuFrame(void) { PpuSetWidescreenBg3Widen(g_ppu, 0); #endif PpuSetWidescreenLineEnhancer(g_ppu, NULL, NULL); + MmxConfigureWsBgMargins(); } void MmxDisplay_SetWidescreenEnabled(bool enabled) { - if (g_config.widescreen == enabled) - return; - g_config.widescreen = enabled; - WriteConfigFile(g_active_config_file); - printf("Widescreen renderer = %s\n", enabled ? "on" : "off"); + snesrecomp_desktop_set_widescreen(enabled); } - bool MmxDisplay_IsWidescreenEnabled(void) { return g_config.widescreen; } bool MmxDisplay_IsWidescreenActive(void) { return g_ws_active; } -int MmxDisplay_GetCurrentFrameWidth(void) { return g_snes_width > 0 ? g_snes_width : 256; } - -/* Widescreen BG margins: exact per-frame fill from the game's decoded level - * structures. The self-validating implementation remains game-owned. */ -static void MmxDisplay_PrepareBg2Shadow(void) { - MmxConfigureWsBgMargins(); -} - -// --- Scripted input --- -typedef struct { - uint32 mask; // button bits to hold - int hold_frames; // frames to hold mask (0 = release) - int wait_frames; // frames to wait after hold ends before next entry -} ScriptEntry; - -static ScriptEntry *g_script_entries; -static int g_script_count; -static int g_script_index; // current entry -static int g_script_phase; // 0=holding, 1=waiting -static int g_script_counter; // frames left in current phase - -static uint32 ParseButtonMask(const char *name) { - if (strcmp(name, "start") == 0) return 0x0008; - if (strcmp(name, "select") == 0) return 0x0004; - if (strcmp(name, "up") == 0) return 0x0010; - if (strcmp(name, "down") == 0) return 0x0020; - if (strcmp(name, "left") == 0) return 0x0040; - if (strcmp(name, "right") == 0) return 0x0080; - if (strcmp(name, "a") == 0) return 0x0100; - if (strcmp(name, "b") == 0) return 0x0001; - if (strcmp(name, "x") == 0) return 0x0200; - if (strcmp(name, "y") == 0) return 0x0002; - if (strcmp(name, "l") == 0) return 0x0400; - if (strcmp(name, "r") == 0) return 0x0800; - fprintf(stderr, "script: unknown button '%s'\n", name); - return 0; -} - -static void LoadScript(const char *path) { - FILE *f = fopen(path, "r"); - if (!f) { fprintf(stderr, "script: cannot open '%s'\n", path); return; } - - // Two-pass: count then fill - int cap = 64; - g_script_entries = (ScriptEntry *)malloc(cap * sizeof(ScriptEntry)); - g_script_count = 0; - - char line[256]; - // pending wait accumulates between press commands - int pending_wait = 0; - while (fgets(line, sizeof(line), f)) { - // strip comment and newline - char *c = strchr(line, '#'); if (c) *c = 0; - char cmd[64], arg1[64]; - int n = 0; - if (sscanf(line, "%63s %63s %d", cmd, arg1, &n) < 1) continue; - if (strcmp(cmd, "wait") == 0) { - int frames = (sscanf(line, "%*s %d", &n) == 1) ? n : 0; - pending_wait += frames; - } else if (strcmp(cmd, "loadstate") == 0) { - // loadstate N — load savestate slot N (0-indexed, F1=0) - int slot = 0; - sscanf(line, "%*s %d", &slot); - if (g_script_count >= cap) { - cap *= 2; - g_script_entries = (ScriptEntry *)realloc(g_script_entries, cap * sizeof(ScriptEntry)); - } - ScriptEntry *e = &g_script_entries[g_script_count++]; - e->mask = 0x80000000 | (slot & 0xF); // special flag: high bit = loadstate - e->hold_frames = 1; - e->wait_frames = pending_wait; - pending_wait = 0; - } else if (strcmp(cmd, "press") == 0) { - int hold = (sscanf(line, "%*s %*s %d", &n) == 1) ? n : 1; - if (g_script_count >= cap) { - cap *= 2; - g_script_entries = (ScriptEntry *)realloc(g_script_entries, cap * sizeof(ScriptEntry)); - } - ScriptEntry *e = &g_script_entries[g_script_count++]; - e->mask = ParseButtonMask(arg1); - e->hold_frames = hold; - e->wait_frames = pending_wait; - pending_wait = 0; - } - } - fclose(f); - - if (g_script_count > 0) { - g_script_index = 0; - g_script_phase = 1; // start with the wait_frames of first entry - g_script_counter = g_script_entries[0].wait_frames; - fprintf(stderr, "script: loaded %d entries from '%s'\n", g_script_count, path); - } -} - -static uint32 TickScript(void) { - if (!g_script_entries || g_script_index >= g_script_count) - return 0; - - ScriptEntry *e = &g_script_entries[g_script_index]; - - if (g_script_phase == 1) { - // waiting - if (g_script_counter > 0) { g_script_counter--; return 0; } - // done waiting — start hold - g_script_phase = 0; - g_script_counter = e->hold_frames; - } - - if (g_script_phase == 0) { - if (g_script_counter > 0) { - g_script_counter--; - if (e->mask & 0x80000000) { - // loadstate command - RtlSaveLoad(kSaveLoad_Load, e->mask & 0xF); - return 0; - } - return e->mask; - } - // hold done — advance - g_script_index++; - if (g_script_index < g_script_count) { - e = &g_script_entries[g_script_index]; - g_script_phase = 1; - g_script_counter = e->wait_frames; - } - return 0; - } - return 0; -} - -void NORETURN Die(const char *error) { - /* Record the message before exiting: the atexit post-mortem dump - * includes it and preserves a timestamped crash copy (see - * host_report_has_fatal in post_mortem.c). */ - host_report_fatal(error); - SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, kWindowTitle, error, NULL); - fprintf(stderr, "Error: %s\n", error); - exit(1); -} - -static GamepadInfo *GetGamepadInfo(SDL_JoystickID id) { - return (g_gamepad[0].joystick_id == id) ? &g_gamepad[0] : - (g_gamepad[1].joystick_id == id) ? &g_gamepad[1] : NULL; -} - -void ChangeWindowScale(int scale_step) { - if ((SDL_GetWindowFlags(g_window) & (SNESRECOMP_SDL_WINDOW_FULLSCREEN_DESKTOP | SDL_WINDOW_FULLSCREEN | SDL_WINDOW_MINIMIZED | SDL_WINDOW_MAXIMIZED)) != 0) - return; - /* Display index is resolved inside the bounds shim below (SDL3 uses - * DisplayID, not an index). */ - int max_scale = kMaxWindowScale; - SDL_Rect bounds; - int bt = -1, bl, bb, br; - // note this takes into effect Windows display scaling, i.e., resolution is divided by scale factor - /* Both return true-on-success in SDL3 (0-on-success in SDL2); the raw - * comparisons compile clean and silently invert. */ - if (snesrecomp_sdl_get_display_usable_bounds(g_window, &bounds)) { - // this call may take a while before it is reported by Windows (or not at all in my testing) - if (!snesrecomp_sdl_get_window_borders_size(g_window, &bt, &bl, &bb, &br)) { - // guess based on Windows 10/11 defaults - bl = br = bb = 1; - bt = 31; - } - // Allow a scale level slightly above the max that fits on screen - int logical_width = MmxDisplay_GetWindowBaseWidth(g_snes_width); - int logical_height = MmxDisplay_GetWindowBaseHeight(); - int mw = (bounds.w - bl - br + logical_width / 4) / logical_width; - int mh = (bounds.h - bt - bb + logical_height / 4) / logical_height; - max_scale = IntMin(mw, mh); - } - int new_scale = IntMax(IntMin(g_current_window_scale + scale_step, max_scale), 1); - g_current_window_scale = new_scale; - int w = new_scale * MmxDisplay_GetWindowBaseWidth(g_snes_width); - int h = new_scale * MmxDisplay_GetWindowBaseHeight(); - - //SDL_RenderSetLogicalSize(g_renderer, w, h); - SDL_SetWindowSize(g_window, w, h); - if (bt >= 0) { - // Center the window on top of the mouse - int mx, my; - /* SDL3 returns float coords; the shim keeps the int signature. */ - snesrecomp_sdl_get_global_mouse_state(&mx, &my); - int wx = IntMax(IntMin(mx - w / 2, bounds.x + bounds.w - bl - br - w), bounds.x + bl); - int wy = IntMax(IntMin(my - h / 2, bounds.y + bounds.h - bt - bb - h), bounds.y + bt); - SDL_SetWindowPosition(g_window, wx, wy); - } else { - SDL_SetWindowPosition(g_window, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED); - } -} - -#define RESIZE_BORDER 20 -static SDL_HitTestResult HitTestCallback(SDL_Window *win, const SDL_Point *pt, void *data) { - uint32 flags = SDL_GetWindowFlags(win); - if ((flags & SNESRECOMP_SDL_WINDOW_FULLSCREEN_DESKTOP) != 0 || (flags & SDL_WINDOW_FULLSCREEN) != 0) - return SDL_HITTEST_NORMAL; - - if ((SDL_GetModState() & KMOD_CTRL) != 0) - return SDL_HITTEST_DRAGGABLE; - - int w, h; - SDL_GetWindowSize(win, &w, &h); - - if (pt->y < RESIZE_BORDER) { - return (pt->x < RESIZE_BORDER) ? SDL_HITTEST_RESIZE_TOPLEFT : - (pt->x >= w - RESIZE_BORDER) ? SDL_HITTEST_RESIZE_TOPRIGHT : SDL_HITTEST_RESIZE_TOP; - } else if (pt->y >= h - RESIZE_BORDER) { - return (pt->x < RESIZE_BORDER) ? SDL_HITTEST_RESIZE_BOTTOMLEFT : - (pt->x >= w - RESIZE_BORDER) ? SDL_HITTEST_RESIZE_BOTTOMRIGHT : SDL_HITTEST_RESIZE_BOTTOM; - } else { - if (pt->x < RESIZE_BORDER) { - return SDL_HITTEST_RESIZE_LEFT; - } else if (pt->x >= w - RESIZE_BORDER) { - return SDL_HITTEST_RESIZE_RIGHT; - } - } - return SDL_HITTEST_NORMAL; -} - -void RtlDrawPpuFrame(uint8 *pixel_buffer, size_t pitch, uint32 render_flags) { - MmxDisplay_PrepareBg2Shadow(); - g_rtl_game_info->draw_ppu_frame(); - RtlWidescreenPresent(pixel_buffer, pitch, g_my_pixels, g_snes_width, g_snes_height); -} - -#ifdef ENABLE_ORACLE_BACKEND -/* Remap the runner's 12-bit per-player input word to the SNES hardware - * joypad bit order the snes9x bridge expects. See the emu_oracle_run_frame - * call site for the bit layouts and rationale. */ -static uint16_t mmx_runner_to_snes_joypad(uint16_t r) { - uint16_t s = 0; - if (r & 0x001) s |= 0x8000; /* B */ - if (r & 0x002) s |= 0x4000; /* Y */ - if (r & 0x004) s |= 0x2000; /* SELECT */ - if (r & 0x008) s |= 0x1000; /* START */ - if (r & 0x010) s |= 0x0800; /* UP */ - if (r & 0x020) s |= 0x0400; /* DOWN */ - if (r & 0x040) s |= 0x0200; /* LEFT */ - if (r & 0x080) s |= 0x0100; /* RIGHT */ - if (r & 0x100) s |= 0x0080; /* A */ - if (r & 0x200) s |= 0x0040; /* X */ - if (r & 0x400) s |= 0x0020; /* L */ - if (r & 0x800) s |= 0x0010; /* R */ - return s; -} -#endif - -static void DrawPpuFrameWithPerf(void) { - /* Geometry must be fixed before the presenter allocates/locks its surface. */ - MmxDisplay_PreparePpuFrame(); - const int render_scale = 1; - uint8 *pixel_buffer = 0; - int pitch = 0; - - g_renderer_funcs.BeginDraw(g_snes_width * render_scale, - g_snes_height * render_scale, - &pixel_buffer, &pitch); - if (g_display_perf || g_config.display_perf_title) { - static float history[64], average; - static int history_pos; - uint64 before = SDL_GetPerformanceCounter(); - RtlDrawPpuFrame(pixel_buffer, pitch, g_ppu_render_flags); - uint64 after = SDL_GetPerformanceCounter(); - float v = (double)SDL_GetPerformanceFrequency() / (after - before); - average += v - history[history_pos]; - history[history_pos] = v; - history_pos = (history_pos + 1) & 63; - g_curr_fps = average * (1.0f / 64); - } else { - RtlDrawPpuFrame(pixel_buffer, pitch, g_ppu_render_flags); - } - if (g_display_perf) - RenderNumber(pixel_buffer + pitch * render_scale, pitch, g_curr_fps, render_scale == 4); - - g_renderer_funcs.EndDraw(); -} - -static SDL_mutex *g_audio_mutex; -static uint8 *g_audiobuffer, *g_audiobuffer_cur, *g_audiobuffer_end; -static int g_frames_per_block; -static uint8 g_audio_channels; -static SDL_AudioDeviceID g_audio_device; -#if SNESRECOMP_SDL3 -/* SDL3 replaced the pull callback with an SDL_AudioStream the app pushes into, - * so the mixer needs a scratch buffer sized to whatever the stream asks for. */ -static SDL_AudioStream *g_audio_stream; -static uint8 *g_audio_stream_buffer; -static size_t g_audio_stream_buffer_size; -#endif - -void RtlApuLock(void) { - SDL_LockMutex(g_audio_mutex); -} - -void RtlApuUnlock(void) { - SDL_UnlockMutex(g_audio_mutex); -} - -/* Backend-agnostic mixer body. SDL2 calls it from its pull callback; SDL3 calls - * it to fill a scratch buffer that is then pushed into the audio stream. */ -static void FillAudioBuffer(Uint8 *stream, int len) { - /* Boot-stage marker: proves the audio thread reached the mixer at - * least once (the "crashed before the first sound" class of report). */ - static SDL_atomic_t first_cb; - if (SDL_AtomicCAS(&first_cb, 0, 1)) - host_report_breadcrumb("first audio callback (len=%d)", len); - if (!snesrecomp_sdl_lock_mutex(g_audio_mutex)) Die("Mutex lock failed!"); - while (len != 0) { - if (g_audiobuffer_end - g_audiobuffer_cur == 0) { - RtlRenderAudio((int16 *)g_audiobuffer, g_frames_per_block, g_audio_channels); - g_audiobuffer_cur = g_audiobuffer; - g_audiobuffer_end = g_audiobuffer + g_frames_per_block * g_audio_channels * sizeof(int16); - } - int n = IntMin(len, g_audiobuffer_end - g_audiobuffer_cur); - if (g_sdl_audio_mixer_volume == SNESRECOMP_SDL_MIX_MAXVOLUME) { - memcpy(stream, g_audiobuffer_cur, n); - } else { - SDL_memset(stream, 0, n); -#if SNESRECOMP_SDL3 - /* SDL3 takes a 0..1 float gain instead of a 0..128 integer volume. */ - SDL_MixAudio(stream, g_audiobuffer_cur, SDL_AUDIO_S16, n, - (float)g_sdl_audio_mixer_volume / - SNESRECOMP_SDL_MIX_MAXVOLUME); -#else - SDL_MixAudioFormat(stream, g_audiobuffer_cur, AUDIO_S16, n, - g_sdl_audio_mixer_volume); -#endif - } - g_audiobuffer_cur += n; - stream += n; - len -= n; - } - SDL_UnlockMutex(g_audio_mutex); -} - -#if SNESRECOMP_SDL3 -static void SDLCALL AudioStreamCallback( - void *userdata, SDL_AudioStream *stream, int additional_amount, - int total_amount) { - (void)userdata; - (void)total_amount; - if (additional_amount <= 0) return; - if ((size_t)additional_amount > g_audio_stream_buffer_size) { - uint8 *resized = - (uint8 *)realloc(g_audio_stream_buffer, additional_amount); - if (!resized) return; - g_audio_stream_buffer = resized; - g_audio_stream_buffer_size = (size_t)additional_amount; - } - FillAudioBuffer(g_audio_stream_buffer, additional_amount); - SDL_PutAudioStreamData(stream, g_audio_stream_buffer, additional_amount); -} -#else -static void SDLCALL AudioCallback(void *userdata, Uint8 *stream, int len) { - (void)userdata; - FillAudioBuffer(stream, len); -} -#endif - -static void SetAudioPaused(bool paused) { -#if SNESRECOMP_SDL3 - if (g_audio_stream) { - if (paused) SDL_PauseAudioStreamDevice(g_audio_stream); - else SDL_ResumeAudioStreamDevice(g_audio_stream); - } -#else - if (g_audio_device) SDL_PauseAudioDevice(g_audio_device, paused); -#endif -} - - -// State for sdl renderer -static SDL_Renderer *g_renderer; -static SDL_Texture *g_texture; -static SDL_Rect g_sdl_renderer_rect; -static SDL_Rect g_sdl_present_rect; - -static bool SdlRenderer_Init(SDL_Window *window) { - if (g_config.shader) - fprintf(stderr, "Warning: Shaders are supported only with the OpenGL backend\n"); - - /* SDL3 dropped the renderer flags argument (software vs accelerated is - * chosen by driver name, vsync is set separately) and removed - * SDL_RendererInfo entirely. snesrecomp_sdl_create_renderer() hides both. */ - bool want_software = g_config.output_method == kOutputMethod_SDLSoftware; - SDL_Renderer *renderer = snesrecomp_sdl_create_renderer( - g_window, want_software, /*vsync=*/true); - if (renderer == NULL) { - printf("Failed to create renderer: %s\n", SDL_GetError()); - return false; - } - if (kDebugFlag) { - const char *name = snesrecomp_sdl_renderer_name(renderer); - printf("Renderer: %s (vsync=%d)\n", name ? name : "(unknown)", - snesrecomp_sdl_get_render_vsync(renderer)); - } - g_renderer = renderer; - - int tex_mult = 1; - g_texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_ARGB8888, SDL_TEXTUREACCESS_STREAMING, - g_snes_width * tex_mult, g_snes_height * tex_mult); - if (g_texture == NULL) { - printf("Failed to create texture: %s\n", SDL_GetError()); - return false; - } - /* SNES frames are opaque RGB with a zero alpha byte; SDL3 would blend - * them away to the black clear colour. */ - snesrecomp_sdl_set_texture_opaque(g_texture); - /* SDL3 sets filtering per-texture rather than through the global - * SDL_HINT_RENDER_SCALE_QUALITY hint, so this must follow texture creation. */ - snesrecomp_sdl_set_texture_linear(g_texture, g_config.linear_filtering); - return true; -} - -static void SdlRenderer_Destroy(void) { - SDL_DestroyTexture(g_texture); - SDL_DestroyRenderer(g_renderer); -} - -static void SdlRenderer_GetOutputSize(int *width, int *height) { - if (!snesrecomp_sdl_get_render_output_size(g_renderer, width, height)) { - *width = 0; - *height = 0; - } -} - -static void SdlRenderer_BeginDraw(int width, int height, uint8 **pixels, int *pitch) { - int texture_width, texture_height; - snesrecomp_sdl_get_texture_size(g_texture, &texture_width, &texture_height); - if (texture_width != width || texture_height != height) { - SDL_DestroyTexture(g_texture); - g_texture = SDL_CreateTexture(g_renderer, SDL_PIXELFORMAT_ARGB8888, - SDL_TEXTUREACCESS_STREAMING, width, height); - if (!g_texture) Die("SDL widescreen texture allocation failed"); - } - /* SNES frames are opaque RGB with a zero alpha byte; SDL3 would blend - * them away to the black clear colour. */ - snesrecomp_sdl_set_texture_opaque(g_texture); - /* Use the same explicit viewport as OpenGL. SDL's integer logical size - * rounded the 342x224 16:9 frame to 399x224 and produced a 1920x1078 - * destination on a 1080p display. The shared geometry snaps that - * unrepresentable sub-pixel remainder to a clean full-height viewport. */ - int output_width = 0, output_height = 0; - SdlRenderer_GetOutputSize(&output_width, &output_height); - MmxDisplayViewport viewport; - MmxDisplay_ComputeViewport(width, height, output_width, output_height, - g_config.ignore_aspect_ratio, false, &viewport); - g_sdl_present_rect.x = viewport.x; - g_sdl_present_rect.y = viewport.y; - g_sdl_present_rect.w = viewport.width; - g_sdl_present_rect.h = viewport.height; - g_sdl_renderer_rect.w = width; - g_sdl_renderer_rect.h = height; - if (!snesrecomp_sdl_lock_texture(g_texture, &g_sdl_renderer_rect, - (void **)pixels, pitch)) { - printf("Failed to lock texture: %s\n", SDL_GetError()); - return; - } -} - -static void SdlRenderer_EndDraw(void) { - // uint64 before = SDL_GetPerformanceCounter(); - SDL_UnlockTexture(g_texture); - // uint64 after = SDL_GetPerformanceCounter(); - // float v = (double)(after - before) / SDL_GetPerformanceFrequency(); - // printf("%f ms\n", v * 1000); - SDL_RenderClear(g_renderer); - /* SDL3's SDL_RenderTexture takes SDL_FRect, not SDL_Rect. */ - snesrecomp_sdl_render_texture(g_renderer, g_texture, &g_sdl_renderer_rect, - &g_sdl_present_rect); - SDL_RenderPresent(g_renderer); // vsyncs to 60 FPS? -} - -static const struct RendererFuncs kSdlRendererFuncs = { - &SdlRenderer_Init, - &SdlRenderer_Destroy, - &SdlRenderer_GetOutputSize, - &SdlRenderer_BeginDraw, - &SdlRenderer_EndDraw, -}; - - -void MkDir(const char *s) { -#if defined(_WIN32) - _mkdir(s); -#else - mkdir(s, 0755); -#endif -} - -#include -#include "cpu_state.h" -#include "cpu_trace.h" -#include "post_mortem.h" -extern uint8_t g_ram[0x20000]; -static void dump_sprite_state(void) { - // Dump SMW sprite-state arrays so dispatch-OOB crashes name the offending slot. - fprintf(stderr, "Sprite state at crash:\n"); - fprintf(stderr, " $9E (sprite type) :"); - for (int k = 0; k < 12; k++) fprintf(stderr, " %02x", g_ram[0x9e + k]); - fprintf(stderr, "\n $14C8 (status) :"); - for (int k = 0; k < 12; k++) fprintf(stderr, " %02x", g_ram[0x14c8 + k]); - fprintf(stderr, "\n $0100 (GameMode) : %02x\n", g_ram[0x100]); - fprintf(stderr, " $7F:8000 (init sig) : %02x %02x\n", g_ram[0x18000], g_ram[0x18001]); - fprintf(stderr, " v2 CpuState: A=%04X X=%04X Y=%04X S=%04X D=%04X DB=%02X PB=%02X " - "P=%02X m=%u x=%u e=%u\n", - g_cpu.A, g_cpu.X, g_cpu.Y, g_cpu.S, g_cpu.D, g_cpu.DB, g_cpu.PB, - g_cpu.P, g_cpu.m_flag, g_cpu.x_flag, g_cpu.emulation); -} -static void crash_handler(int sig) { - extern const char *g_last_recomp_func; - extern void RecompStackDump(void); - fprintf(stderr, "\n*** CRASH (signal %d) in recomp func: %s ***\n", - sig, g_last_recomp_func ? g_last_recomp_func : "(unknown)"); - dump_sprite_state(); - RecompStackDump(); - cpu_trace_dump_dbpb("CRASH — DB/PB mutations"); - cpu_trace_dump_recent("CRASH — main trace ring", 256); - fflush(stderr); - recomp_post_mortem_dump("signal", NULL); - _exit(128 + sig); -} - -#ifdef _WIN32 -#include -static LONG WINAPI seh_handler(EXCEPTION_POINTERS* info) { - extern const char *g_last_recomp_func; - extern void RecompStackDump(void); - DWORD code = info->ExceptionRecord->ExceptionCode; - void* addr = info->ExceptionRecord->ExceptionAddress; - fprintf(stderr, "\n*** SEH CRASH code=0x%08lX at %p, last recomp func: %s ***\n", - code, addr, g_last_recomp_func ? g_last_recomp_func : "(unknown)"); - if (code == EXCEPTION_ACCESS_VIOLATION) { - ULONG_PTR kind = info->ExceptionRecord->ExceptionInformation[0]; - ULONG_PTR fault_addr = info->ExceptionRecord->ExceptionInformation[1]; - fprintf(stderr, " access violation: %s at 0x%p\n", - kind == 0 ? "read" : (kind == 1 ? "write" : "execute"), - (void*)fault_addr); - } - dump_sprite_state(); - RecompStackDump(); - cpu_trace_dump_dbpb("SEH CRASH — DB/PB mutations"); - cpu_trace_dump_recent("SEH CRASH — main trace ring", 256); - fflush(stderr); - recomp_post_mortem_dump("seh", info); - return EXCEPTION_EXECUTE_HANDLER; -} -#endif - -static void post_mortem_atexit(void) { - recomp_post_mortem_dump("atexit", NULL); -} - -/* Resolve a relative CLI path against the launch cwd before - * snesrecomp_anchor_to_exe_dir() redefines what relative means. - * Returns `buf` on success, the original pointer otherwise. */ -static const char *AbsolutizePathArg(const char *path, char *buf, size_t size) { - extern int snesrecomp_abspath(const char *path, char *out, size_t max_len); - return (path && snesrecomp_abspath(path, buf, size)) ? buf : path; -} - -#undef main -/* Issue #4: bring the selected ROM into the exe directory so it sits beside the - * saves/, config.ini and keybinds.ini already anchored there. Copies (never - * moves) the ROM under its basename and rewrites `rom_path` to the local copy. - * No-op when already in the exe dir or the copy can't be made. */ -static int RelocateRomToExeDir(char *rom_path, size_t cap) { - if (!rom_path || !rom_path[0]) return 0; - const char *base = rom_path; - for (const char *p = rom_path; *p; p++) - if (*p == '/' || *p == '\\') base = p + 1; - if (!*base) return 0; - - char dst[1024]; - if (!snesrecomp_exe_dir_path(base, dst, sizeof(dst))) return 0; -#ifdef _WIN32 - if (_stricmp(dst, rom_path) == 0) return 0; -#else - if (strcmp(dst, rom_path) == 0) return 0; -#endif - - FILE *in = fopen(rom_path, "rb"); - if (!in) return 0; - FILE *out = fopen(dst, "wb"); - if (!out) { fclose(in); return 0; } - char buf[65536]; - size_t n; - int ok = 1; - while ((n = fread(buf, 1, sizeof(buf), in)) > 0) - if (fwrite(buf, 1, n, out) != n) { ok = 0; break; } - fclose(in); - fclose(out); - if (!ok) { remove(dst); return 0; } - - snprintf(rom_path, cap, "%s", dst); - printf("[Launcher] Copied ROM into the game directory: %s\n", dst); - return 1; -} - -int main(int argc, char** argv) { - /* Windows: do NOT install a SIGSEGV handler. The MSVC CRT's signal - * shim intercepts access violations BEFORE the unhandled-exception - * filter, so with one installed, crashes reached crash_handler with - * no EXCEPTION_POINTERS — no fault context in the minidump/report - * (verified via the SNESRECOMP_CRASH_TEST drill). Leaving it out - * routes AVs to seh_handler below with the full exception record. - * SIGABRT stays: abort() never raises an SEH exception. */ -#ifndef _WIN32 - signal(SIGSEGV, crash_handler); -#endif - signal(SIGABRT, crash_handler); -#ifdef _WIN32 - SetUnhandledExceptionFilter(seh_handler); - /* Suppress the Windows error dialog so SEH unwinds straight to our - * filter and we can write the post-mortem report without the user - * having to dismiss a popup first. */ - SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); -#endif - atexit(post_mortem_atexit); - host_report_init(MMX_GAME_NAME " " MMX_GAME_REGION, SNESRECOMP_BUILD_VERSION); - /* ARM the backwards watcher BEFORE any recompiled code runs. Without - * this, the trace ring records but no tripwires fire. With this: - * - DB-watch on every byte SMW shouldn't legitimately use as DB - * - PB-watch on every non-zero PB - * - S-watch when stack leaves $0100-$1FFF - * - Func-watch on the bank03.cfg empty stub - * - Off-rails dumps (rate-limited) from RomPtr/cart_readLorom soft fails - * Each tripwire dumps the trace BACKWARDS so we see the chain that - * birthed the bad state, not just where it died. */ - /* Heap-allocate the cpu trace ring before any tripwire arms. The - * default 64M entries cover ~64K frames at typical block rates, - * which means the ring no longer rolls over within any realistic - * investigation window. Override via SNESRECOMP_CPU_TRACE_RING_ENTRIES. */ - cpu_trace_init(); - cpu_trace_arm_default_watches(); - setvbuf(stdout, NULL, _IONBF, 0); - setvbuf(stderr, NULL, _IONBF, 0); -#ifdef __SWITCH__ - SwitchImpl_Init(); -#endif - argc--, argv++; - /* Path-carrying args are resolved against the LAUNCH cwd; the anchor - * below changes what relative paths mean, so absolutize them first. */ - const char *config_file = NULL; - if (argc >= 2 && strcmp(argv[0], "--config") == 0) { - static char config_abs[1024]; - config_file = AbsolutizePathArg(argv[1], config_abs, sizeof(config_abs)); - argc -= 2, argv += 2; - } - int start_paused = 0; - if (argc >= 1 && strcmp(argv[0], "--paused") == 0) { - start_paused = 1; - argc -= 1, argv += 1; - } - const char *script_file = NULL; - if (argc >= 2 && strcmp(argv[0], "--script") == 0) { - static char script_abs[1024]; - script_file = AbsolutizePathArg(argv[1], script_abs, sizeof(script_abs)); - argc -= 2, argv += 2; - } - const char *framedump_dir = NULL; - if (argc >= 2 && strcmp(argv[0], "--framedump") == 0) { - static char framedump_abs[1024]; - framedump_dir = AbsolutizePathArg(argv[1], framedump_abs, sizeof(framedump_abs)); - argc -= 2, argv += 2; - } - /* Force the GUI launcher even when SkipLauncher = 1 (the other way back is to - * set SkipLauncher = 0 in config.ini). */ - int force_launcher = 0; - if (argc >= 1 && strcmp(argv[0], "--launcher") == 0) { - force_launcher = 1; - argc -= 1, argv += 1; - } - if (argc >= 1 && argv[0] && argv[0][0] != '-' && argv[0][0] != '\0') { - /* Positional ROM path. */ - static char rom_abs[1024]; - argv[0] = (char *)AbsolutizePathArg(argv[0], rom_abs, sizeof(rom_abs)); - } - - /* The config is config.ini next to the executable — nothing else, - * no directory walking. Anchoring cwd to the exe dir also pins - * keybinds.ini, rom.cfg and saves/ there, however the process was - * launched. (On read-only installs the anchor declines and cwd - * stays authoritative; see launcher.h.) */ - { - extern int snesrecomp_anchor_to_exe_dir(void); - int anchored = snesrecomp_anchor_to_exe_dir(); - host_report_breadcrumb("exe-dir anchor: %s", - anchored ? "ok" : "declined (cwd stays authoritative)"); - } - - if (!config_file) - EnsureConfigIni(); - /* Pin config.ini to the exe directory for all reads/writes, regardless of the - * current working directory (defense in depth with snesrecomp_anchor_to_exe_dir - * above + OFN_NOCHANGEDIR on the file dialogs). EnsureConfigIni already created - * it next to the exe; this keeps a stray chdir from relocating later writes. */ - static char config_exe_path[1024]; - if (!config_file && - snesrecomp_exe_dir_path("config.ini", config_exe_path, sizeof(config_exe_path))) - config_file = config_exe_path; - ParseConfigFile(config_file); - g_active_config_file = config_file; - // Apply local overrides if present (gitignored). Lets a developer - // mute audio etc. without touching the checked-in config.ini. Last - // parser to set a key wins, so local overrides take precedence. - { - FILE *f_local = fopen("config.local.ini", "rb"); - if (f_local) { - fclose(f_local); - ParseConfigFile("config.local.ini"); - } - } - host_report_breadcrumb( - "config parsed: output=%d new_renderer=%d scale=%d fullscreen=%d " - "audio=%d freq=%d samples=%d skip_launcher=%d", - g_config.output_method, g_config.new_renderer, g_config.window_scale, - g_config.fullscreen, g_config.enable_audio, g_config.audio_freq, - g_config.audio_samples, g_config.skip_launcher); - - /* Resolve the SNES ROM path: argv[0] -> rom.cfg cache -> file picker. - * On success, replace argv so the existing ReadWholeFile + oracle init - * paths below pick up the resolved path without further changes. - * - * The launcher auto-strips a 512-byte SMC copier header before hashing, - * so headered and unheadered dumps both verify against the same hash. */ - static char rom_path_buf[512]; - int mods_ready = 0; - { - /* 1.5 MiB LoROM, Rev 1 (v1.1). SHA-256 over the unheadered payload (the - * launcher strips a 512-byte SMC copier header before hashing). - * USA "Mega Man X (USA) (Rev 1)" crc32 DED53C64 - * JP "Rockman X (Japan) (Rev 1)" crc32 5584641E - * SHA-256 computed locally from verified dumps. */ - static const uint8_t kMmxRomSha256[32] = { MMX_ROM_SHA256_BYTES }; -#if SNESRECOMP_ENABLE_MODS - mods_ready = snes_mod_runtime_initialize_c( - "mods", MMX_MOD_GAME_ID, MMX_ROM_SHA256_HEX); - if (!mods_ready) { - fprintf(stderr, "SNES mods unavailable: %s\n", - snes_mod_runtime_last_error_c()); - } -#endif - int rom_resolved_by_launcher = 0; - -#if defined(SNES_LAUNCHER) || defined(RECOMP_LAUNCHER) - /* GUI launcher: pick/verify ROM + tune settings before boot. MMX exposes - * the PPU widescreen option but has no MSU-1. Skipped for headless paths, - * positional ROM, or explicit environment override. */ - { - int headless = start_paused || (script_file != NULL) || (framedump_dir != NULL); - int have_positional = (argc >= 1 && argv[0] && argv[0][0] != '-' && argv[0][0] != '\0'); - const char *no_launcher = getenv("SNESRECOMP_NO_LAUNCHER"); - int want_launcher = !headless && !have_positional && !(no_launcher && *no_launcher); - - /* SkipLauncher (#5): boot straight from the cached ROM unless --launcher - * forces the GUI. A missing/unreadable cache falls through to the launcher. */ - if (want_launcher && g_config.skip_launcher && !force_launcher) { - char cached[512]; cached[0] = '\0'; - if (snesrecomp_rom_cache_read(cached, sizeof(cached))) { - FILE *probe = fopen(cached, "rb"); - if (probe) { - fclose(probe); - snprintf(rom_path_buf, sizeof(rom_path_buf), "%s", cached); - rom_resolved_by_launcher = 1; - want_launcher = 0; - host_report_breadcrumb("launcher skipped (SkipLauncher=1, cached rom)"); - } - } - } - - if (want_launcher) { - host_report_breadcrumb("launcher: opening GUI"); -#if defined(RECOMP_LAUNCHER) - RecompLauncherCSettings ls; /* recomp-ui ABI: same base fields as SnesLauncher, plus additive */ -#else - SnesLauncherCSettings ls; -#endif - memset(&ls, 0, sizeof(ls)); - ls.output_method = g_config.output_method; - ls.window_scale = g_config.window_scale ? g_config.window_scale : 2; - ls.fullscreen = g_config.fullscreen; - ls.ignore_aspect = g_config.ignore_aspect_ratio; - ls.linear_filter = g_config.linear_filtering; - ls.widescreen = g_config.widescreen; - ls.enable_audio = g_config.enable_audio; - ls.audio_freq = g_config.audio_freq; - ls.volume = 100; - ls.player_src[0] = g_config.enable_gamepad[0] ? 2 : 1; - ls.player_src[1] = g_config.enable_gamepad[1] ? 2 : 0; - /* MMX stores deadzone as a raw stick radius; the launcher edits a 0-100%. - * Convert in both directions. */ - ls.deadzone[0] = ls.deadzone[1] = g_config.gamepad_deadzone * 100 / 32767; - ls.skip_launcher = g_config.skip_launcher; - ls.msu1_enabled = 0; /* MMX: no MSU-1 (panel hidden) */ - - char init_rom[512]; init_rom[0] = '\0'; - snesrecomp_rom_cache_read(init_rom, sizeof(init_rom)); - -#if defined(RECOMP_LAUNCHER) - RecompLauncherCGameInfo gi; - memset(&gi, 0, sizeof(gi)); - /* SNES system identity (theme=CRT, platform="SUPER NINTENDO", rom_noun - * "ROM", widescreen_supported=1). One profile call keeps the identity - * from drifting across SNES titles, exactly as the PSX host does. */ - launcher_profile_apply("snes", &gi); -#else - SnesLauncherCGameInfo gi; - memset(&gi, 0, sizeof(gi)); -#endif - gi.name = MMX_GAME_NAME; - gi.region = MMX_GAME_REGION; - gi.sram_path = NULL; /* hide SAVES panel — no battery SRAM - (header ramSize=0, chips=ROM+COPRO); - progress is carried by passwords. */ - gi.expected_crc = MMX_ROM_CRC32; - gi.has_expected_crc = 1; - gi.known_sha256 = &kMmxRomSha256; /* single accepted digest */ - gi.num_known_sha256 = 1; - /* Widescreen is a game-owned, default-disabled Mods feature. Keep the - * generic Settings toggle hidden so there is one authoritative state. */ - gi.widescreen_supported = 0; - gi.num_players = 1; /* MMX is 1-player — hide the Player 2 row */ - gi.msu1_supported = 0; /* hide MSU-1 panel */ - gi.config_path = config_file; /* hotkey editor targets the live config */ -#if SNESRECOMP_ENABLE_MODS - gi.mods = mods_ready ? snes_mod_runtime_launcher_provider_c() : NULL; -#endif - -#if defined(RECOMP_LAUNCHER) - /* cwd is anchored to the exe dir (snesrecomp_anchor_to_exe_dir above), - * and recomp_ui.cmake stages assets to /assets, so "." resolves - * assets correctly. */ - int act = recomp_launcher_run_window( - MMX_LAUNCHER_TITLE, - &ls, &gi, ".", init_rom, rom_path_buf, sizeof(rom_path_buf)); -#else - int act = snes_launcher_run_window( - MMX_LAUNCHER_TITLE, - &ls, &gi, "launcher", init_rom, rom_path_buf, sizeof(rom_path_buf)); -#endif - host_report_breadcrumb("launcher: action=%d rom=%s", act, - rom_path_buf[0] ? rom_path_buf : "(none)"); - if (act == 1) return 0; /* user closed the launcher */ - if (act == 0) { - g_config.output_method = (uint8)ls.output_method; - g_config.window_scale = (uint8)ls.window_scale; - g_config.fullscreen = (uint8)ls.fullscreen; - g_config.ignore_aspect_ratio = ls.ignore_aspect != 0; - g_config.linear_filtering = ls.linear_filter != 0; - g_config.widescreen = ls.widescreen != 0; - g_config.enable_audio = true; /* always on */ - g_config.audio_freq = (uint16)ls.audio_freq; - g_config.enable_gamepad[0] = ls.player_src[0] == 2; - g_config.enable_gamepad[1] = ls.player_src[1] == 2; - g_config.gamepad_deadzone = ls.deadzone[0] * 32767 / 100; - g_config.skip_launcher = ls.skip_launcher != 0; - WriteConfigFile(config_file); - /* The launcher's Hotkeys editor writes [KeyMap] straight into the - * config file, which was parsed before the launcher ran — re-apply - * so rebinds work on THIS boot, not the next one. (WriteConfigFile - * above preserves [KeyMap] lines, so order is safe.) */ - ConfigReloadKeyMap(config_file); - if (rom_path_buf[0]) { - snesrecomp_rom_cache_write(rom_path_buf); - rom_resolved_by_launcher = 1; - } - } - /* act == 2 (unavailable) -> console resolver below */ - } - } -#endif - - if (!rom_resolved_by_launcher) { - char *la_argv[2] = { - (char *)"mmx", - (char *)((argc >= 1 && argv[0]) ? argv[0] : "") - }; - int la_argc = (la_argv[1][0] != '\0') ? 2 : 1; - if (!snesrecomp_launcher_resolve_rom_sha256(la_argc, la_argv, rom_path_buf, - sizeof(rom_path_buf), kMmxRomSha256)) { - /* User cancelled the picker or repeatedly chose a non-matching ROM. */ - return 1; - } - } - } - /* Issue #4: co-locate the ROM with the exe (interactive launches only). */ - if (!start_paused && script_file == NULL && framedump_dir == NULL) { - if (RelocateRomToExeDir(rom_path_buf, sizeof(rom_path_buf))) { - snesrecomp_rom_cache_write(rom_path_buf); - } - } -#if SNESRECOMP_ENABLE_MODS - if (mods_ready) { - if (!snes_mod_runtime_commit_c(rom_path_buf)) { - fprintf(stderr, "SNES mod plan rejected: %s\n", - snes_mod_runtime_last_error_c()); - return 1; - } - snes_mod_runtime_activate_plugins_c(); - } -#endif - - static char *resolved_argv[2]; - resolved_argv[0] = rom_path_buf; - resolved_argv[1] = NULL; - argv = resolved_argv; - argc = 1; - host_report_breadcrumb("rom resolved: %s", rom_path_buf); - - // Initialize debug server - { - extern int debug_server_init(int port); - extern void debug_server_set_ram(uint8_t *ram, uint32_t ram_size); - /* A distinct per-game port lets sibling games run concurrently. */ - if (debug_server_init(MMX_DEBUG_PORT) == 0) { -#if SNESRECOMP_TRACE - fprintf(stderr, "[main] Debug server ready on port %d\n", MMX_DEBUG_PORT); -#endif - } - if (start_paused) { - debug_server_start_paused(); -#if SNESRECOMP_TRACE - fprintf(stderr, "[main] Started paused — send 'step N' or 'continue' via TCP\n"); -#endif - } - } - - g_gamepad[0].joystick_id = g_gamepad[1].joystick_id = -1; - /* A persisted widescreen launch opens a useful 16:9 window before the first - * display-derived frame is calculated. Custom WindowSize remains authoritative. */ - g_snes_width = g_config.widescreen - ? MmxDisplay_ComputeFrameWidth(16, 9, true) : 256; - g_snes_height = 224; - g_ppu_render_flags = g_config.new_renderer * kPpuRenderFlags_NewRenderer | - g_config.no_sprite_limits * kPpuRenderFlags_NoSpriteLimits; - - if (g_config.fullscreen == 1) - g_win_flags ^= SNESRECOMP_SDL_WINDOW_FULLSCREEN_DESKTOP; - else if (g_config.fullscreen == 2) - g_win_flags ^= SDL_WINDOW_FULLSCREEN; - - // Window scale (1=100%, 2=200%, 3=300%, etc.) - g_current_window_scale = (g_config.window_scale == 0) ? 2 : IntMin(g_config.window_scale, kMaxWindowScale); - - // audio_freq: Use common sampling rates (see user config file. values higher than 48000 are not supported.) - if (g_config.audio_freq < 11025 || g_config.audio_freq > 48000) - g_config.audio_freq = kDefaultFreq; - - // Currently, the SPC/DSP implementation only supports up to stereo. - if (g_config.audio_channels < 1 || g_config.audio_channels > 2) - g_config.audio_channels = kDefaultChannels; - - // audio_samples: power of 2 - if (g_config.audio_samples <= 0 || ((g_config.audio_samples & (g_config.audio_samples - 1)) != 0)) - g_config.audio_samples = kDefaultSamples; - - SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); - - // set up SDL - /* Return convention flipped in SDL3 (0 == success became true == success); - * the raw `!= 0` form compiles clean and fails init on every good start. */ - if (!snesrecomp_sdl_init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_GAMECONTROLLER)) { - host_report_breadcrumb("SDL_Init FAILED: %s", SDL_GetError()); - printf("Failed to init SDL: %s\n", SDL_GetError()); - return 1; - } - host_report_breadcrumb("SDL init ok: video=%s audio=%s", - SDL_GetCurrentVideoDriver() ? SDL_GetCurrentVideoDriver() : "(none)", - SDL_GetCurrentAudioDriver() ? SDL_GetCurrentAudioDriver() : "(none)"); - - /* Load (or generate) keybinds.ini next to the executable (cwd is - * anchored there; on read-only installs it tracks the config). */ - keybinds_init(NULL); - - bool custom_size = g_config.window_width != 0 && g_config.window_height != 0; - int window_width = custom_size ? g_config.window_width : - g_current_window_scale * MmxDisplay_GetWindowBaseWidth(g_snes_width); - int window_height = custom_size ? g_config.window_height : - g_current_window_scale * MmxDisplay_GetWindowBaseHeight(); - - if (g_config.output_method == kOutputMethod_OpenGL) { - g_win_flags |= SDL_WINDOW_OPENGL; - OpenGLRenderer_Create(&g_renderer_funcs); - } else { - g_renderer_funcs = kSdlRendererFuncs; - } - - /* Load the SNES ROM. argv[0] is the launcher-resolved path (always - * non-NULL after snesrecomp_launcher_resolve_rom returned success). */ - uint8 *kRom = NULL; - uint32 kRom_SIZE = 0; - if (argv[0]) { - size_t size; - kRom = ReadWholeFile(argv[0], &size); - kRom_SIZE = (uint32)size; - if (!kRom) - goto error_reading; - } - host_report_breadcrumb("rom loaded: %u bytes", kRom_SIZE); - - extern const RtlGameInfo kMmxGameInfo; - RtlRegisterGame(&kMmxGameInfo); - Snes *snes = SnesInit(kRom, kRom_SIZE); - host_report_breadcrumb("SnesInit: %s", snes ? "ok" : "FAILED"); - if (snes == NULL) { -error_reading:; -#ifdef __SWITCH__ - ThrowMissingROM(); -#else - char buf[256]; - snprintf(buf, sizeof(buf), "unable to load rom"); - Die(buf); -#endif - return 1; - } - - // Connect debug server to SNES RAM - { - extern void debug_server_set_ram(uint8_t *ram, uint32_t ram_size); - debug_server_set_ram(snes->ram, 0x20000); - } - -#ifdef ENABLE_ORACLE_BACKEND - // Start the emulator-oracle backend with the same ROM. Gated on the - // Oracle build configuration only; Release|x64 never sees any of this. - // The runner typically loads smw.sfc from cwd via the asset pipeline - // (argv[0] is usually NULL), so we default to "smw.sfc" in cwd when - // argv[0] was not supplied. - if (g_config.enable_snes9x_oracle) { - extern int snes_oracle_init_default(const char *rom_path); - const char *rom_path = (argv[0] && *argv[0]) ? argv[0] : "smw.sfc"; - int rc = snes_oracle_init_default(rom_path); - if (rc != 0) - fprintf(stderr, "[oracle] init failed rc=%d (rom=%s)\n", rc, rom_path); - else - fprintf(stderr, "[oracle] backend ready (rom=%s)\n", rom_path); - } else { - /* Disabled in config.ini. Tell the framework dispatcher so every TCP - * emu_* command returns a structured warning instead of silently - * no-op'ing — and explicitly tells callers re-enabling is NOT a - * fix. The reason string MUST be a string literal (stored by - * reference, not copied). Also dump it loudly to stderr at startup - * so it's impossible to miss in the boot log. */ - extern void snes_oracle_set_disabled_by_game(const char *reason); - static const char *kReason = - "MMX freeze repros load a save state to reach the failure scene. " - "The snes9x oracle starts from boot and cannot follow save-state " - "loads, so any recomp-vs-oracle WRAM/PC comparison ends up " - "diffing two unrelated game moments. A prior session burned real " - "time chasing false 'divergences' that were just content " - "mismatch. Disabled in config.ini ([General] EnableSnes9xOracle = " - "false) until save-state-aware oracle or input-record/replay " - "parity exists. Re-enabling without fixing that is NOT a " - "solution."; - snes_oracle_set_disabled_by_game(kReason); - fprintf(stderr, - "\n=== snes9x oracle DISABLED for MMX ===\n" - "Reason: %s\n" - "All emu_* TCP commands will refuse with a structured warning.\n" - "Do NOT re-enable as a workaround.\n\n", - kReason); - } -#endif - - /* SDL3 dropped the x/y arguments from SDL_CreateWindow. */ - SDL_Window *window = snesrecomp_sdl_create_window( - kWindowTitle, window_width, window_height, g_win_flags); - if(window == NULL) { - host_report_breadcrumb("SDL_CreateWindow FAILED: %s", SDL_GetError()); - printf("Failed to create window: %s\n", SDL_GetError()); - return 1; - } - g_window = window; - SDL_SetWindowHitTest(window, HitTestCallback, NULL); - host_report_breadcrumb("window created: %dx%d flags=0x%x", - window_width, window_height, g_win_flags); - - if (!g_renderer_funcs.Initialize(window)) { - host_report_breadcrumb("renderer init FAILED (output_method=%d)", - g_config.output_method); - return 1; - } - host_report_breadcrumb("renderer initialized: %s", - g_config.output_method == kOutputMethod_OpenGL ? "opengl" : - g_config.output_method == kOutputMethod_SDLSoftware ? "sdl-software" : "sdl"); - - g_audio_mutex = SDL_CreateMutex(); - if (!g_audio_mutex) Die("No mutex"); - - g_spc_player = SmwSpcPlayer_Create(); - - g_spc_player->initialize(g_spc_player); - host_report_breadcrumb("SPC player initialized"); - - if (g_config.enable_audio) { - /* Enumerate output devices into the breadcrumb ring: which device - * SDL picks (and what else was available) is exactly the per-machine - * variable a non-reproducible audio/boot crash report needs. */ - { -#if SNESRECOMP_SDL3 - int ndev = 0; - SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&ndev); - host_report_breadcrumb("audio outputs: %d device(s)", ndev); - for (int i = 0; i < ndev && i < 8; i++) - host_report_breadcrumb("audio output[%d]: %s", i, - SDL_GetAudioDeviceName(devices[i])); - SDL_free(devices); -#else - int ndev = SDL_GetNumAudioDevices(0); - host_report_breadcrumb("audio outputs: %d device(s)", ndev); - for (int i = 0; i < ndev && i < 8; i++) - host_report_breadcrumb("audio output[%d]: %s", i, - SDL_GetAudioDeviceName(i, 0)); -#endif - } - SDL_AudioSpec want = { 0 }, have; - want.freq = g_config.audio_freq; - want.format = AUDIO_S16; - want.channels = 2; -#if SNESRECOMP_SDL3 - /* SDL3 has no `samples`/`callback` in SDL_AudioSpec: the device is opened - * as a stream and the callback is supplied separately. */ - have = want; - g_audio_stream = SDL_OpenAudioDeviceStream( - SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &want, AudioStreamCallback, NULL); - if (g_audio_stream) { - g_audio_device = SDL_GetAudioStreamDevice(g_audio_stream); - SDL_GetAudioStreamFormat(g_audio_stream, &have, NULL); - } -#else - want.samples = g_config.audio_samples; - want.callback = &AudioCallback; - g_audio_device = SDL_OpenAudioDevice(NULL, 0, &want, &have, 0); -#endif - if (g_audio_device == 0) { - host_report_breadcrumb("audio device open FAILED: %s", SDL_GetError()); - printf("Failed to open audio device: %s\n", SDL_GetError()); - return 1; - } - g_audio_channels = 2; - /* One native DSP block is 534 samples at the SPC's true output rate - * of 32040 Hz (1.024 MHz / 32). The old divisor of 32000 understated - * the rate, playing everything a constant -2.2 cents flat (measured - * vs the snes9x oracle, issue #4); the truncating division also - * undersized the block for non-multiple rates. Round to the nearest - * frame: 32040->534 (1:1, no resample), 48000->800, 44100->735. */ - /* The consumer converts the SPC's native 32040 Hz onto this rate and - * cannot infer it; see RtlSetAudioOutputRate in common_rtl.h. */ - RtlSetAudioOutputRate(have.freq); - g_frames_per_block = (534 * have.freq + 32040 / 2) / 32040; - g_audiobuffer = (uint8 *)calloc(g_frames_per_block * have.channels * sizeof(int16), 1); - host_report_breadcrumb( - "audio device opened: freq=%d (want %d) ch=%d samples=%d frames_per_block=%d", - have.freq, want.freq, have.channels, -#if SNESRECOMP_SDL3 - /* SDL_AudioSpec has no `samples` in SDL3; the stream sizes each pull - * itself, so report the configured request for continuity. */ - g_config.audio_samples, -#else - have.samples, -#endif - g_frames_per_block); - } else { - host_report_breadcrumb("audio disabled in config"); - } - - MmxDisplay_PreparePpuFrame(); - - MkDir("saves"); - - RtlReadSram(); - - { -#if SNESRECOMP_SDL3 - int njs = 0; - SDL_JoystickID *joysticks = SDL_GetJoysticks(&njs); -#else - int njs = SDL_NumJoysticks(); -#endif - printf("[Gamepad] SDL reports %d joystick(s) at startup. " - "enable_gamepad=[%d,%d]\n", - njs, g_config.enable_gamepad[0], g_config.enable_gamepad[1]); - for (int i = 0; i < njs; i++) { -#if SNESRECOMP_SDL3 - /* SDL3 enumerates by instance ID rather than by index. */ - SDL_JoystickID joystick = joysticks[i]; - const char *name = SDL_GetJoystickNameForID(joystick); - int is_gc = SDL_IsGamepad(joystick); -#else - SDL_JoystickID joystick = i; - const char *name = SDL_JoystickNameForIndex(i); - int is_gc = SDL_IsGameController(i); -#endif - printf("[Gamepad] #%d name=%s is_game_controller=%d\n", - i, name ? name : "(null)", is_gc); - OpenOneGamepad(i); - } - if (njs == 0) { - printf("[Gamepad] No joysticks detected. " - "On Windows, plug controller in BEFORE launching, " - "or check that XInput drivers are installed.\n"); - } - } - - if (g_config.autosave) - HandleCommand(kKeys_Load + 0, true); - - if (script_file) - LoadScript(script_file); - - if (framedump_dir) - FrameDump_Init(framedump_dir); - - bool running = true; - uint32 lastTick = SDL_GetTicks(); - uint32 curTick = 0; - uint32 frameCtr = 0; - uint8 audiopaused = true; - GamepadInfo *gi; - - host_report_breadcrumb("entering main loop"); - - while (running) { - SDL_Event event; - - /* Inert unless SNESRECOMP_CRASH_TEST is set — support drill for the - * whole crash-capture pipeline (minidump + report + crash copy). */ - host_report_crash_test_tick(); - - while (SDL_PollEvent(&event)) { - switch (event.type) { - case SDL_CONTROLLERDEVICEADDED: - OpenOneGamepad(SNESRECOMP_SDL_EVENT_DEVICE(event)); - break; - case SDL_CONTROLLERDEVICEREMOVED: - gi = GetGamepadInfo(SNESRECOMP_SDL_EVENT_DEVICE(event)); - if (gi) { - memset(gi, 0, sizeof(GamepadInfo)); - gi->joystick_id = -1; - } - break; - case SDL_CONTROLLERAXISMOTION: - gi = GetGamepadInfo(SNESRECOMP_SDL_EVENT_AXIS_DEVICE(event)); - if (gi) - HandleGamepadAxisInput(gi, SNESRECOMP_SDL_EVENT_AXIS(event), SNESRECOMP_SDL_EVENT_AXIS_VALUE(event)); - break; - case SDL_CONTROLLERBUTTONDOWN: - case SDL_CONTROLLERBUTTONUP: { - gi = GetGamepadInfo(SNESRECOMP_SDL_EVENT_BUTTON_DEVICE(event)); - if (gi) { - int b = RemapSdlButton(SNESRECOMP_SDL_EVENT_BUTTON(event)); - if (b >= 0) - HandleGamepadInput(gi, b, event.type == SDL_CONTROLLERBUTTONDOWN); - } - break; - } - case SDL_JOYDEVICEADDED: - OpenOneJoystick(event.jdevice.which); - break; - case SDL_JOYDEVICEREMOVED: - gi = GetGamepadInfo(event.jdevice.which); - if (gi) { - if (gi->joystick) SDL_JoystickClose(gi->joystick); - memset(gi, 0, sizeof(GamepadInfo)); - gi->joystick_id = -1; - } - break; - case SDL_JOYAXISMOTION: - gi = GetGamepadInfo(event.jaxis.which); - if (gi && gi->raw_joystick) - HandleGamepadAxisInput(gi, event.jaxis.axis, event.jaxis.value); - break; - case SDL_JOYBUTTONDOWN: - case SDL_JOYBUTTONUP: - gi = GetGamepadInfo(event.jbutton.which); - if (gi && gi->raw_joystick && event.jbutton.button < 16) { - /* SDL's raw Steam virtual gamepad layout is the standard Xbox - * button order: A, B, X, Y, back, guide, start, L3, R3, L1, R1, - * d-pad up/down/left/right. */ - static const uint8 raw_buttons[] = { - kGamepadBtn_A, kGamepadBtn_B, kGamepadBtn_X, kGamepadBtn_Y, - kGamepadBtn_Back, kGamepadBtn_Guide, kGamepadBtn_Start, - kGamepadBtn_L3, kGamepadBtn_R3, kGamepadBtn_L1, kGamepadBtn_R1, - kGamepadBtn_DpadUp, kGamepadBtn_DpadDown, - kGamepadBtn_DpadLeft, kGamepadBtn_DpadRight - }; - HandleGamepadInput(gi, raw_buttons[event.jbutton.button], - event.type == SDL_JOYBUTTONDOWN); - } - break; - case SDL_MOUSEWHEEL: - if (SDL_GetModState() & KMOD_CTRL && event.wheel.y != 0) - ChangeWindowScale(event.wheel.y > 0 ? 1 : -1); - break; - case SDL_MOUSEBUTTONDOWN: - /* SDL3 replaced SDL_MouseButtonEvent.state/SDL_PRESSED with a bool - * `down`; the event type already tells us it is a press. */ - if (event.button.button == SDL_BUTTON_LEFT && event.button.clicks == 2) { - if ((g_win_flags & SNESRECOMP_SDL_WINDOW_FULLSCREEN_DESKTOP) == 0 && (g_win_flags & SDL_WINDOW_FULLSCREEN) == 0 && SDL_GetModState() & KMOD_SHIFT) { - g_win_flags ^= SDL_WINDOW_BORDERLESS; - SDL_SetWindowBordered(g_window, (g_win_flags & SDL_WINDOW_BORDERLESS) == 0 ? SDL_TRUE : SDL_FALSE); - } - } - break; - case SDL_KEYDOWN: - HandleInput(SNESRECOMP_SDL_EVENT_KEY(event), SNESRECOMP_SDL_EVENT_MOD(event), true); - break; - case SDL_KEYUP: - HandleInput(SNESRECOMP_SDL_EVENT_KEY(event), SNESRECOMP_SDL_EVENT_MOD(event), false); - break; - case SDL_QUIT: - running = false; - break; - } - } - - if (g_paused != audiopaused) { - audiopaused = g_paused; - if (g_audio_device) - SetAudioPaused(audiopaused); - } - - if (g_paused) { - SDL_Delay(16); - continue; - } - - // Clear gamepad inputs when joypad directional inputs to avoid wonkiness - if (g_input_state & 0xf0) - g_gamepad[0].axis_buttons = 0; - if (g_input_state & 0xf0000) - g_gamepad[1].axis_buttons = 0; - { - int ls = debug_server_consume_loadstate(); - if (ls >= 0) - RtlSaveLoad(kSaveLoad_Load, ls); - int ss = debug_server_consume_savestate(); - if (ss >= 0) - RtlSaveLoad(kSaveLoad_Save, ss); - } - debug_server_wait_if_paused(); - - /* Drive the SNES controller bits in g_input_state from keybinds.ini. - * config.ini's [KeyMap] still owns system commands (state save/load, - * fullscreen, pause, etc.); the 12 controller buttons per player - * come from keybinds.ini. - * - * Mapping below: keybinds bit layout (see keybinds.h) -> kKeys_Controls - * index (config.ini [Controls] order: Up Down Left Right Select Start - * A B X Y L R). HandleCommand is idempotent for set/clear, so calling - * it every frame is safe. */ - { - const uint8_t *keys = snesrecomp_sdl_get_keyboard_state(); - /* Only a slot whose SOURCE is the keyboard reads the keyboard. - * - * Both slots' bits used to be applied every frame, so a keyboard key - * bound in slot 2 moved player 2 even when player 2 was a gamepad -- - * and the two inputs fought over the same character. The launcher - * records each slot's device in [Controller] SourceP1/SourceP2; a file - * without the section keeps the historical behaviour, player 1 on the - * keyboard and player 2 silent. */ - const bool kb_ok_p1 = (g_config.player_src[0] == 1); - const bool kb_ok_p2 = (g_config.player_src[1] == 1); - uint16_t kb_p1 = kb_ok_p1 ? keybinds_read_player(keys, 1) : 0; - uint16_t kb_p2 = kb_ok_p2 ? keybinds_read_player(keys, 2) : 0; - static const uint8 kKb2CtrlsIdx[12] = { 7, 6, 5, 4, 9, 8, 3, 11, 2, 10, 1, 0 }; - for (int i = 0; i < 12; i++) { - HandleCommand(kKeys_Controls + i, (kb_p1 >> kKb2CtrlsIdx[i]) & 1); - HandleCommand(kKeys_ControlsP2 + i, (kb_p2 >> kKb2CtrlsIdx[i]) & 1); - } - } - - uint32 inputs = g_input_state | g_pad_buttons | g_gamepad[0].axis_buttons | g_gamepad[1].axis_buttons << 12; - inputs |= TickScript(); - inputs |= debug_server_get_controller_inputs(); - RtlRunFrame(inputs | GetActiveControllers() | debug_server_get_controller_active_mask()); - -#ifdef ENABLE_ORACLE_BACKEND - // Step the oracle emulator with the same input. The runner's per-player - // input word is a 12-bit layout (B=0x001,Y=0x002,SELECT=0x004, - // START=0x008,UP=0x010,DOWN=0x020,LEFT=0x040,RIGHT=0x080,A=0x100, - // X=0x200,L=0x400,R=0x800 — see debug_server.c k_controller_names), - // but snes9x_bridge reads s_joypad[] in SNES hardware bit order - // ($4218/$4219: B=15,Y=14,SELECT=13,START=12,UP=11,DOWN=10,LEFT=9, - // RIGHT=8,A=7,X=6,L=5,R=4). Without this remap, START (runner bit 3) - // lands on an unused bridge bit and the real-ROM boot can't be - // navigated — the "oracle desyncs to garbage" failure prior sessions - // hit. Remap so a from-boot highway reference is reachable (legitimate - // use; the disabled path is only the save-state repros). - { - extern void emu_oracle_run_frame(uint16_t j1, uint16_t j2); - emu_oracle_run_frame(mmx_runner_to_snes_joypad((uint16_t)(inputs & 0xFFF)), - mmx_runner_to_snes_joypad((uint16_t)((inputs >> 12) & 0xFFF))); - } -#endif - - // Bank validation removed — 100% oracle mode, no banks enabled. - - frameCtr++; - if (frameCtr == 1) - host_report_breadcrumb("first frame simulated"); - else if (frameCtr % 3600 == 0) /* ~once a minute at 60 fps */ - host_report_breadcrumb("heartbeat: frame=%u", frameCtr); - /* Dev-only headless turbo stress (SNESRECOMP_FORCE_TURBO=1): forces the - * turbo path every frame so an automated soak reproduces the turbo - * wedge/freeze the user hits by holding Tab under LLE (deterministic repro - * of the raster-IRQ-skip stack leak; see the disableRender branch below). - * Env-gated; no ship effect. */ - { static int s_ft = -1; - if (s_ft < 0) { const char *e = getenv("SNESRECOMP_FORCE_TURBO"); - s_ft = (e && e[0] && e[0] != '0') ? 1 : 0; } - if (s_ft) g_turbo = 1; } - /* Process-local finite turbo stress. Unlike synthetic keyboard input this - * cannot leak into another running recomp. Format is start_frame,count; - * it is dev-only and inert unless explicitly configured. */ - { static int s_start = -2, s_end = -2; - if (s_start == -2) { - const char *e = getenv("SNESRECOMP_TURBO_BURST"); - int start = -1, count = 0; - if (e && sscanf(e, "%d,%d", &start, &count) == 2 && - start >= 0 && count > 0) { - s_start = start; - s_end = start + count; - } else { - s_start = s_end = -1; - } - } - if (s_start >= 0) { - if (frameCtr >= (uint32)s_start && frameCtr < (uint32)s_end) - g_turbo = 1; - else if (frameCtr == (uint32)s_end) - g_turbo = 0; - } - } - RtlAudioSetFastForward(g_turbo != 0); - g_snes->disableRender = g_turbo && (frameCtr & 0xf) != 0; - - if (!g_snes->disableRender) { - DrawPpuFrameWithPerf(); - } else { - /* Turbo (render skipped): draw_ppu_frame is NOT purely cosmetic — it - * also simulates HDMA and, - * critically, fires the raster IRQ (I_IRQ) that clears MMX's $0BA0 - * raster-ack flag. Under LLE the game runs the REAL NMI/IRQ handshake - * (NmiHandler's `LDA $0B9D ; ORA $0BA0 ; BNE` gate + the $83F1/$82C8 DMA - * path); if the raster IRQ never fires, $0BA0 never clears, that path - * spins forever, the 5s frame watchdog longjmps out mid-frame WITHOUT - * restoring the guest stack pointer, and cpu->S leaks ~17 bytes every - * abandoned frame until it underflows into the task table (the "turbo - * garble/freeze"). So we still run the guest-state simulation every - * frame here — we just skip the host present (BeginDraw/memcpy/EndDraw, - * the GPU-bound part turbo exists to elide). */ - MmxDisplay_PreparePpuFrame(); - g_rtl_game_info->draw_ppu_frame(); - } - - // if vsync isn't working, delay manually - curTick = SDL_GetTicks(); - - /* Frame-delay pacing locks the loop to ~60 fps so audio stays in sync with - * the sound device. On by default. Power users on an exactly-60 Hz / - * vsync-correct display can set DisableFrameDelay = 1 in config.ini - * (cfg-only, no UI) to skip it for slightly better perf — at the risk of - * audio desync on other displays. */ - if (!g_snes->disableRender && !g_config.disable_frame_delay) { - static const uint8 delays[3] = { 17, 17, 16 }; // 60 fps - lastTick += delays[frameCtr % 3]; - - if (lastTick > curTick) { - uint32 delta = lastTick - curTick; - if (delta > 500) { - lastTick = curTick - 500; - delta = 500; - } - // printf("Sleeping %d\n", delta); - SDL_Delay(delta); - } else if (curTick - lastTick > 500) { - lastTick = curTick; - } - } - } - - if (g_config.autosave) - HandleCommand(kKeys_Save + 0, true); - - RtlWriteSram(); - - // clean sdl - SetAudioPaused(true); -#if SNESRECOMP_SDL3 - /* Destroying the stream closes the device it was opened against. */ - SDL_DestroyAudioStream(g_audio_stream); - g_audio_stream = NULL; -#else - SDL_CloseAudioDevice(g_audio_device); -#endif - SDL_DestroyMutex(g_audio_mutex); - free(g_audiobuffer); - - g_renderer_funcs.Destroy(); - -#ifdef __SWITCH__ - SwitchImpl_Exit(); -#endif - - SDL_DestroyWindow(window); - SDL_Quit(); - return 0; -} - -static void RenderDigit(uint8 *dst, size_t pitch, int digit, uint32 color, bool big) { - static const uint8 kFont[] = { - 0x1c, 0x36, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x36, 0x1c, - 0x18, 0x1c, 0x1e, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x7e, - 0x3e, 0x63, 0x60, 0x30, 0x18, 0x0c, 0x06, 0x03, 0x63, 0x7f, - 0x3e, 0x63, 0x60, 0x60, 0x3c, 0x60, 0x60, 0x60, 0x63, 0x3e, - 0x30, 0x38, 0x3c, 0x36, 0x33, 0x7f, 0x30, 0x30, 0x30, 0x78, - 0x7f, 0x03, 0x03, 0x03, 0x3f, 0x60, 0x60, 0x60, 0x63, 0x3e, - 0x1c, 0x06, 0x03, 0x03, 0x3f, 0x63, 0x63, 0x63, 0x63, 0x3e, - 0x7f, 0x63, 0x60, 0x60, 0x30, 0x18, 0x0c, 0x0c, 0x0c, 0x0c, - 0x3e, 0x63, 0x63, 0x63, 0x3e, 0x63, 0x63, 0x63, 0x63, 0x3e, - 0x3e, 0x63, 0x63, 0x63, 0x7e, 0x60, 0x60, 0x60, 0x30, 0x1e, +int MmxDisplay_GetCurrentFrameWidth(void) { return snesrecomp_desktop_frame_width(); } + +int MMX_DESKTOP_ENTRY(int argc, char **argv) { + ConfigUseStateMenuDefaults(); + static const SnesDesktopHostGame game = { + .display_name = MMX_GAME_NAME, + .window_title = MMX_WINDOW_TITLE, + .launcher_title = MMX_LAUNCHER_TITLE, + .region = MMX_GAME_REGION, + .rom_file = MMX_ROM_FILE, + .expected_sha256_hex = MMX_ROM_SHA256_HEX, + .expected_crc32_hex = MMX_ROM_CRC32_HEX, + .game_id = MMX_MOD_GAME_ID, + .build_version = SNESRECOMP_BUILD_VERSION, + .game_info = &kMmxGameInfo, + .debug_port = MMX_DEBUG_PORT, + .widescreen_supported = 1, + .native_widescreen = 1, + .state_menu_hotkeys = 1, + .simulation_hz = 60.0, + .create_spc_player = SmwSpcPlayer_Create, + .prepare_frame = MmxPrepareFrame, + .begin_sim_frame = MmxBeginFrame, + .window_base_width = MmxDisplay_GetWindowBaseWidth, + .window_base_height = MmxDisplay_GetWindowBaseHeight, }; - const uint8 *p = kFont + digit * 10; - if (!big) { - for (int y = 0; y < 10; y++, dst += pitch) { - int v = *p++; - for (int x = 0; v; x++, v >>= 1) { - if (v & 1) - ((uint32 *)dst)[x] = color; - } - } - } else { - for (int y = 0; y < 10; y++, dst += pitch * 2) { - int v = *p++; - for (int x = 0; v; x++, v >>= 1) { - if (v & 1) { - ((uint32 *)dst)[x * 2 + 1] = ((uint32 *)dst)[x * 2] = color; - ((uint32 *)(dst + pitch))[x * 2 + 1] = ((uint32 *)(dst + pitch))[x * 2] = color; - } - } - } - } -} - - -static void RenderNumber(uint8 *dst, size_t pitch, int n, uint8 big) { - char buf[32], *s; - int i; - sprintf(buf, "%d", n); - for (s = buf, i = 2 * 4; *s; s++, i += 8 * 4) - RenderDigit(dst + ((pitch + i + 4) << big), pitch, *s - '0', 0x404040, big); - for (s = buf, i = 2 * 4; *s; s++, i += 8 * 4) - RenderDigit(dst + (i << big), pitch, *s - '0', 0xffffff, big); -} - -static void HandleCommand(uint32 j, bool pressed) { - static const uint8 kKbdRemap[] = { 4, 5, 6, 7, 2, 3, 8, 0, 9, 1, 10, 11 }; - if (j < kKeys_Controls) - return; - - if (j <= kKeys_Controls_Last) { - uint32 m = 1 << kKbdRemap[j - kKeys_Controls]; - g_input_state = pressed ? (g_input_state | m) : (g_input_state & ~m); - return; - } - - if (j <= kKeys_ControlsP2_Last) { - uint32 m = 0x1000 << kKbdRemap[j - kKeys_ControlsP2]; - g_input_state = pressed ? (g_input_state | m) : (g_input_state & ~m); - return; - } - - if (j == kKeys_Turbo) { - g_turbo = pressed; - return; - } - - if (!pressed) - return; - if (j <= kKeys_Load_Last) { - RtlSaveLoad(kSaveLoad_Load, j - kKeys_Load); - } else if (j <= kKeys_Save_Last) { - RtlSaveLoad(kSaveLoad_Save, j - kKeys_Save); - } else { - switch (j) { - case kKeys_Fullscreen: - g_win_flags ^= SNESRECOMP_SDL_WINDOW_FULLSCREEN_DESKTOP; - SDL_SetWindowFullscreen(g_window, g_win_flags & SNESRECOMP_SDL_WINDOW_FULLSCREEN_DESKTOP); - g_cursor = !g_cursor; - snesrecomp_sdl_show_cursor(g_cursor); - break; - case kKeys_Reset: - RtlReset(1); - break; - case kKeys_Pause: g_paused = !g_paused; break; - case kKeys_PauseDimmed: - g_paused = !g_paused; - // SDL_RenderPresent may not be called more than once per frame. - // Seems to work on Windows still. Temporary measure until it's fixed. -#ifdef _WIN32 - if (g_paused) { - SDL_SetRenderDrawBlendMode(g_renderer, SDL_BLENDMODE_BLEND); - SDL_SetRenderDrawColor(g_renderer, 0, 0, 0, 159); - SDL_RenderFillRect(g_renderer, NULL); - SDL_RenderPresent(g_renderer); - } -#endif - break; - case kKeys_WindowBigger: ChangeWindowScale(1); break; - case kKeys_WindowSmaller: ChangeWindowScale(-1); break; - case kKeys_DisplayPerf: g_display_perf ^= 1; break; - case kKeys_ToggleRenderer: - g_ppu_render_flags ^= kPpuRenderFlags_NewRenderer; - printf("New renderer = %x\n", g_ppu_render_flags & kPpuRenderFlags_NewRenderer); - break; - case kKeys_ToggleWidescreen: -#if SNESRECOMP_ENABLE_MODS - printf("Widescreen is controlled from Mods; restart after changing it.\n"); -#else - MmxDisplay_SetWidescreenEnabled(!g_config.widescreen); -#endif - break; - case kKeys_VolumeUp: - case kKeys_VolumeDown: HandleVolumeAdjustment(j == kKeys_VolumeUp ? 1 : -1); break; - default: assert(0); - } - } -} - -static void HandleInput(int keyCode, int keyMod, bool pressed) { - int j = FindCmdForSdlKey(keyCode, (SDL_Keymod)keyMod); - if (j != 0) - HandleCommand(j, pressed); -} - -static uint32 GetActiveControllers() { - uint32 ctrl = g_config.has_keyboard_controls; - ctrl |= g_gamepad[0].joystick_id != -1 ? 1 : 0; - ctrl |= g_gamepad[1].joystick_id != -1 ? 2 : 0; - return ctrl << 30; -} - -static void OpenOneGamepad(int i) { - if (!SDL_IsGameController(i)) { - OpenOneJoystick(i); - return; - } - { - SDL_GameController *controller = SDL_GameControllerOpen(i); - if (!controller) { - fprintf(stderr, "Could not open gamepad %d: %s\n", i, SDL_GetError()); - return; - } - - uint32 joystick_id = SDL_JoystickInstanceID(SDL_GameControllerGetJoystick(controller)); - if (GetGamepadInfo(joystick_id)) { - SDL_GameControllerClose(controller); - return; - } - - uint8 scan_order[3] = { SDL_GameControllerGetPlayerIndex(controller), 0, 1 }; - - int found_idx = -1; - for (int i = 0; i < 3; i++) { - uint8 j = scan_order[i]; - if (j < 2 && g_config.enable_gamepad[j] && (i == 0 || g_gamepad[j].joystick_id == -1)) { - found_idx = j; - break; - } - } - - printf("Found controller '%s' assigning to player %d\n", SDL_GameControllerName(controller), found_idx + 1); - if (found_idx >= 0) { - GamepadInfo *gi = &g_gamepad[found_idx]; - memset(gi, 0, sizeof(GamepadInfo)); - gi->index = found_idx; - gi->joystick_id = joystick_id; - } - } -} - -static void OpenOneJoystick(int i) { - if (SDL_IsGameController(i)) return; - SDL_Joystick *joystick = SDL_JoystickOpen(i); - if (!joystick) { - fprintf(stderr, "Could not open raw joystick %d: %s\n", i, SDL_GetError()); - return; - } - SDL_JoystickID id = SDL_JoystickInstanceID(joystick); - if (GetGamepadInfo(id)) { SDL_JoystickClose(joystick); return; } - int slot = -1; - for (int j = 0; j < 2; ++j) { - if (g_config.enable_gamepad[j] && g_gamepad[j].joystick_id == -1) { - slot = j; break; - } - } - if (slot < 0) { SDL_JoystickClose(joystick); return; } - GamepadInfo *gi = &g_gamepad[slot]; - memset(gi, 0, sizeof(*gi)); - gi->joystick = joystick; - gi->raw_joystick = true; - gi->index = slot; - gi->joystick_id = id; - printf("Found unmapped raw joystick '%s' assigning to player %d\n", - SDL_JoystickName(joystick), slot + 1); -} - -static int RemapSdlButton(int button) { - switch (button) { - case SDL_CONTROLLER_BUTTON_A: return kGamepadBtn_A; - case SDL_CONTROLLER_BUTTON_B: return kGamepadBtn_B; - case SDL_CONTROLLER_BUTTON_X: return kGamepadBtn_X; - case SDL_CONTROLLER_BUTTON_Y: return kGamepadBtn_Y; - case SDL_CONTROLLER_BUTTON_BACK: return kGamepadBtn_Back; - case SDL_CONTROLLER_BUTTON_GUIDE: return kGamepadBtn_Guide; - case SDL_CONTROLLER_BUTTON_START: return kGamepadBtn_Start; - case SDL_CONTROLLER_BUTTON_LEFTSTICK: return kGamepadBtn_L3; - case SDL_CONTROLLER_BUTTON_RIGHTSTICK: return kGamepadBtn_R3; - case SDL_CONTROLLER_BUTTON_LEFTSHOULDER: return kGamepadBtn_L1; - case SDL_CONTROLLER_BUTTON_RIGHTSHOULDER: return kGamepadBtn_R1; - case SDL_CONTROLLER_BUTTON_DPAD_UP: return kGamepadBtn_DpadUp; - case SDL_CONTROLLER_BUTTON_DPAD_DOWN: return kGamepadBtn_DpadDown; - case SDL_CONTROLLER_BUTTON_DPAD_LEFT: return kGamepadBtn_DpadLeft; - case SDL_CONTROLLER_BUTTON_DPAD_RIGHT: return kGamepadBtn_DpadRight; - default: return -1; - } -} - -/* Set/clear a SNES controller bit from a gamepad source. Mirrors - * HandleCommand's kKeys_Controls / kKeys_ControlsP2 logic but writes - * to g_pad_buttons so the per-frame keyboard polling can't clobber - * gamepad-set bits. Non-controller commands (system shortcuts bound - * via config.ini [GamepadMap]) fall through to HandleCommand so things - * like state save/load on a gamepad button still work. */ -static void SetPadButtonOrFallthrough(uint32 j, bool pressed) { - static const uint8 kKbdRemap[] = { 4, 5, 6, 7, 2, 3, 8, 0, 9, 1, 10, 11 }; - if (j >= kKeys_Controls && j <= kKeys_Controls_Last) { - uint32 m = 1u << kKbdRemap[j - kKeys_Controls]; - g_pad_buttons = pressed ? (g_pad_buttons | m) : (g_pad_buttons & ~m); - return; - } - if (j >= kKeys_ControlsP2 && j <= kKeys_ControlsP2_Last) { - uint32 m = 0x1000u << kKbdRemap[j - kKeys_ControlsP2]; - g_pad_buttons = pressed ? (g_pad_buttons | m) : (g_pad_buttons & ~m); - return; - } - HandleCommand(j, pressed); -} - -static void HandleGamepadInput(GamepadInfo *gi, int button, bool pressed) { - if (!!(gi->modifiers & (1 << button)) == pressed) - return; - gi->modifiers ^= 1 << button; - if (pressed) - gi->last_cmd[button] = FindCmdForGamepadButton(button + gi->index * kGamepadBtn_Count, gi->modifiers); - if (gi->last_cmd[button] != 0) - SetPadButtonOrFallthrough(gi->last_cmd[button], pressed); -} - -static void HandleVolumeAdjustment(int volume_adjustment) { -#if SYSTEM_VOLUME_MIXER_AVAILABLE - int current_volume = GetApplicationVolume(); - int new_volume = IntMin(IntMax(0, current_volume + volume_adjustment * 5), 100); - SetApplicationVolume(new_volume); - printf("[System Volume]=%i\n", new_volume); -#else - g_sdl_audio_mixer_volume = IntMin(IntMax(0, g_sdl_audio_mixer_volume + volume_adjustment * (SNESRECOMP_SDL_MIX_MAXVOLUME >> 4)), SNESRECOMP_SDL_MIX_MAXVOLUME); - printf("[SDL mixer volume]=%i\n", g_sdl_audio_mixer_volume); -#endif -} - -// Approximates atan2(y, x) normalized to the [0,4) range -// with a maximum error of 0.1620 degrees -// normalized_atan(x) ~ (b x + x^2) / (1 + 2 b x + x^2) -static float ApproximateAtan2(float y, float x) { - uint32 sign_mask = 0x80000000; - float b = 0.596227f; - // Extract the sign bits - uint32 ux_s = sign_mask & *(uint32 *)&x; - uint32 uy_s = sign_mask & *(uint32 *)&y; - // Determine the quadrant offset - float q = (float)((~ux_s & uy_s) >> 29 | ux_s >> 30); - // Calculate the arctangent in the first quadrant - float bxy_a = b * x * y; - if (bxy_a < 0.0f) bxy_a = -bxy_a; // avoid fabs - float num = bxy_a + y * y; - float atan_1q = num / (x * x + bxy_a + num + 0.000001f); - // Translate it to the proper quadrant - uint32_t uatan_2q = (ux_s ^ uy_s) | *(uint32 *)&atan_1q; - return q + *(float *)&uatan_2q; -} - -static void HandleGamepadAxisInput(GamepadInfo *gi, int axis, Sint16 value) { - if (axis == SDL_CONTROLLER_AXIS_LEFTX || axis == SDL_CONTROLLER_AXIS_LEFTY) { - *(axis == SDL_CONTROLLER_AXIS_LEFTX ? &gi->last_axis_x : &gi->last_axis_y) = value; - int buttons = 0; - if (gi->last_axis_x * gi->last_axis_x + gi->last_axis_y * gi->last_axis_y >= g_config.gamepad_deadzone * g_config.gamepad_deadzone) { - // in the non deadzone part, divide the circle into eight 45 degree - // segments rotated by 22.5 degrees that control which direction to move. - // todo: do this without floats? - static const uint8 kSegmentToButtons[8] = { - 1 << 4, // 0 = up - 1 << 4 | 1 << 7, // 1 = up, right - 1 << 7, // 2 = right - 1 << 7 | 1 << 5, // 3 = right, down - 1 << 5, // 4 = down - 1 << 5 | 1 << 6, // 5 = down, left - 1 << 6, // 6 = left - 1 << 6 | 1 << 4, // 7 = left, up - }; - uint8 angle = (uint8)(int)(ApproximateAtan2(gi->last_axis_y, gi->last_axis_x) * 64.0f + 0.5f); - buttons = kSegmentToButtons[(uint8)(angle + 16 + 64) >> 5]; - } - gi->axis_buttons = buttons; - } else if ((axis == SDL_CONTROLLER_AXIS_TRIGGERLEFT || axis == SDL_CONTROLLER_AXIS_TRIGGERRIGHT)) { - if (value < 12000 || value >= 16000) // hysteresis - HandleGamepadInput(gi, axis == SDL_CONTROLLER_AXIS_TRIGGERLEFT ? kGamepadBtn_L2 : kGamepadBtn_R2, value >= 12000); - } -} - -/* Default config.ini content written next to the executable when no - * config.ini exists there on launch. Mirrors the repo-root config.ini - * but stripped of dev-only comments; keep them in lock-step when - * adding new tunables that should be user-discoverable. The - * [GamepadMap] section gives a plugged-in Xbox controller working - * defaults out of the box. */ -static const char kDefaultConfigIniContent[] = - "[General]\n" - "# Automatically save state on quit and reload on start\n" - "Autosave = 0\n" - "\n" - "# Disable the SDL_Delay that paces each frame (slightly better perf if your\n" - "# display is set to exactly 60hz; may desync audio on other displays)\n" - "DisableFrameDelay = 0\n" - "\n" - "[Graphics]\n" - "# Window size (Auto or WidthxHeight)\n" - "WindowSize = Auto\n" - "\n" - "# Fullscreen mode (0=windowed, 1=desktop fullscreen, 2=fullscreen w/mode change)\n" - "Fullscreen = 0\n" - "\n" - "# Window scale (1=100%, 2=200%, 3=300%, etc.)\n" - "WindowScale = 3\n" - "\n" - "# Use the optimized SNES PPU implementation\n" - "NewRenderer = 1\n" - "\n" - "# Don't keep the aspect ratio\n" - "IgnoreAspectRatio = 0\n" - "\n" - "# Display aspect: 4:3 (CRT), 8:7 (square pixels), or 1:1 (square frame)\n" - "DisplayAspect = 4:3\n" - "\n" - "# Render real extra PPU columns to match a widescreen display.\n" - "# OPT-IN ENHANCEMENT, off by default: the faithful ground floor\n" - MMX_WIDESCREEN_STATUS_LINES - "Widescreen = 0\n" - "\n" - "# Relax the hardware 32-sprites/34-tiles-per-scanline caps, which\n" - "# removes sprite flicker in busy scenes. ENHANCEMENT, but ON by\n" - "# default (matches Mega Man X 1) because it is a pure renderer\n" - "# flag needing no per-title survey -- unlike Widescreen above.\n" - "# It does suppress the readable overflow bits at $213E, so set 0\n" - "# for the strictly faithful floor.\n" - "NoSpriteLimits = 1\n" - "\n" - "[Sound]\n" - "EnableAudio = 1\n" - "AudioFreq = 32040\n" - "AudioChannels = 2\n" - "AudioSamples = 512\n" - "\n" - "[KeyMap]\n" - "# This section is for system-level shortcuts (save/load state,\n" - "# fullscreen, pause, etc.). The 12 SNES controller buttons live\n" - "# in keybinds.ini next to the executable.\n" - "Fullscreen = Alt+Return\n" - "Reset = Ctrl+r\n" - "Pause = Shift+p\n" - "PauseDimmed = p\n" - "Turbo = Tab\n" - "WindowBigger = Ctrl+Up\n" - "WindowSmaller = Ctrl+Down\n" - "# Toggle true PPU widescreen rendering at runtime.\n" - "ToggleWidescreen = Alt+w\n" - "VolumeUp = Shift+=\n" - "VolumeDown = Shift+-\n" - "Load = F1, F2, F3, F4, F5, F6, F7, F8, F9, F10\n" - "Save = Shift+F1,Shift+F2,Shift+F3,Shift+F4,Shift+F5,Shift+F6,Shift+F7,Shift+F8,Shift+F9,Shift+F10\n" - "\n" - "[GamepadMap]\n" - "# Enable each player's gamepad slot. SDL_GameController-compatible\n" - "# controllers (Xbox, PlayStation, Switch Pro, etc.) auto-detect\n" - "# when plugged in. Set to false to force keyboard-only.\n" - "EnableGamepad1 = true\n" - "EnableGamepad2 = true\n" - "\n" - "# Default Xbox-layout mapping. Order matches kKeys_Controls:\n" - "# Up, Down, Left, Right, Select, Start, A, B, X, Y, L, R\n" - "# Edit + restart to rebind. Shoulder = L1/Lb (top), trigger = L2.\n" - "Controls = DpadUp, DpadDown, DpadLeft, DpadRight, Back, Start, B, A, Y, X, Lb, Rb\n" - "ControlsP2 = DpadUp, DpadDown, DpadLeft, DpadRight, Back, Start, B, A, Y, X, Lb, Rb\n"; - -/* Ensure config.ini exists next to the executable (cwd after - * snesrecomp_anchor_to_exe_dir). First launch from a clean release - * directory writes the default so the config the user can edit is - * always sitting right beside the exe. */ -static void EnsureConfigIni(void) { - FILE *f = fopen("config.ini", "rb"); - if (f) { - fclose(f); - } else { - f = fopen("config.ini", "w"); - if (!f) { - fprintf(stderr, "Warning: could not write default config.ini\n"); - } else { - fputs(kDefaultConfigIniContent, f); - fclose(f); - printf("[config.ini] Generated default config next to the executable\n"); - } - } - /* Release zips through v1.0.6 shipped a decorative mmx.ini that the - * exe never read. If one is still sitting next to the exe, say - * loudly that editing it does nothing. */ - f = fopen("mmx.ini", "rb"); - if (f) { - fclose(f); - fprintf(stderr, - "Note: mmx.ini is not read; settings live in config.ini " - "next to the executable.\n"); - } + return snesrecomp_desktop_main(&game, argc, argv); } diff --git a/runner/src/desktop/mmx_config.c b/runner/src/desktop/mmx_config.c index f69e4e27..79d573c9 100644 --- a/runner/src/desktop/mmx_config.c +++ b/runner/src/desktop/mmx_config.c @@ -17,6 +17,7 @@ enum { }; Config g_config; +static bool s_state_menu_defaults; #define REMAP_SDL_KEYCODE(key) ((key) & SDLK_SCANCODE_MASK ? kKeyMod_ScanCode : 0) | (key) & (kKeyMod_ScanCode - 1) #define _(x) REMAP_SDL_KEYCODE(x) @@ -24,7 +25,7 @@ Config g_config; #define A(x) REMAP_SDL_KEYCODE(x) | kKeyMod_Alt #define C(x) REMAP_SDL_KEYCODE(x) | kKeyMod_Ctrl #define N 0 -static const uint16 kDefaultKbdControls[kKeys_Total] = { +static uint16 kDefaultKbdControls[kKeys_Total] = { 0, // Controls _(SDLK_UP), _(SDLK_DOWN), _(SDLK_LEFT), _(SDLK_RIGHT), _(SDLK_RSHIFT), _(SDLK_RETURN), _(SDLK_x), _(SDLK_z), _(SDLK_s), _(SDLK_a), _(SDLK_c), _(SDLK_v), @@ -49,6 +50,14 @@ static const uint16 kDefaultKbdControls[kKeys_Total] = { _(SDLK_F11), _(SDLK_F12), }; +/* Opt in before parsing: existing hosts keep their legacy slot defaults. */ +void ConfigUseStateMenuDefaults(void) { + s_state_menu_defaults = true; + kDefaultKbdControls[kKeys_Load + 6] = _(SDLK_F11); + kDefaultKbdControls[kKeys_Load + 7] = _(SDLK_F12); + kDefaultKbdControls[kKeys_SaveStateMenu] = _(SDLK_F7); + kDefaultKbdControls[kKeys_Rewind] = _(SDLK_F8); +} #undef _ #undef A #undef C @@ -105,8 +114,17 @@ static bool KeyMapHash_Add(uint16 key, uint16 cmd) { uint16 *cur = &keymap_hash_first[j]; while (*cur) { KeyMapHashEnt *ent = &keymap_hash[*cur - 1]; - if (ent->key == key) - return false; + if (ent->key == key) { + /* Launcher-editable menu actions take precedence over legacy slot + * shortcuts, independently of config line/default registration order. */ + bool new_menu = cmd == kKeys_SaveStateMenu || cmd == kKeys_Rewind; + bool old_menu = ent->cmd == kKeys_SaveStateMenu || ent->cmd == kKeys_Rewind; + bool new_slot = cmd >= kKeys_Load && cmd <= kKeys_Save_Last; + bool old_slot = ent->cmd >= kKeys_Load && ent->cmd <= kKeys_Save_Last; + if (new_menu && old_slot) ent->cmd = cmd; + keymap_hash_size--; + return (new_menu && old_slot) || (old_menu && new_slot); + } cur = &ent->next; } *cur = i + 1; @@ -141,8 +159,9 @@ int FindCmdForSdlKey(SDL_Keycode code, SDL_Keymod mod) { static void ParseKeyArray(char *value, int cmd, int size) { char *s; int i = 0; - for (; i < size && (s = NextDelim(&value, ',')) != NULL; i++, cmd += (cmd != 0)) { - if (*s == 0) + for (; (i < size || size == 1) && (s = NextDelim(&value, ',')) != NULL; + i++, cmd += (cmd != 0 && size != 1)) { + if (*s == 0 || StringEqualsNoCase(s, "None") || StringEqualsNoCase(s, "(unbound)")) continue; int key_with_mod = 0; for (;;) { @@ -157,6 +176,10 @@ static void ParseKeyArray(char *value, int cmd, int size) { } } SDL_Keycode key = SDL_GetKeyFromName(s); + /* Old config.ini files loaded slots 7/8 on the new shared menu keys. + * Migrate those two defaults without rewriting the user's config. */ + if (s_state_menu_defaults && !key_with_mod && cmd == kKeys_Load + 6 && key == SDLK_F7) key = SDLK_F11; + if (s_state_menu_defaults && !key_with_mod && cmd == kKeys_Load + 7 && key == SDLK_F8) key = SDLK_F12; if (key == SDLK_UNKNOWN) { fprintf(stderr, "Unknown key: '%s'\n", s); continue; diff --git a/runner/src/snes/cx4.c b/runner/src/snes/cx4.c index be2f87a6..7e2f1392 100644 --- a/runner/src/snes/cx4.c +++ b/runner/src/snes/cx4.c @@ -1199,6 +1199,10 @@ int cx4_load_firmware(Cx4 *c, const char *rom_path) { return 1; } +void cx4_saveload_clock(Cx4 *c, struct SaveLoadInfo *sli) { + if (c && sli) sli->func(sli, &c->last_master, sizeof(c->last_master)); +} + void cx4_saveload(Cx4 *c, struct SaveLoadInfo *sli) { if (!c || !sli) return; /* Guest-visible device state only. The data ROM is static; the rings are host diff --git a/runner/src/snes/cx4.h b/runner/src/snes/cx4.h index 865c4271..589fc7ac 100644 --- a/runner/src/snes/cx4.h +++ b/runner/src/snes/cx4.h @@ -129,4 +129,8 @@ void cx4_rdrom_index_range(const Cx4 *cx4, uint32_t *lo, uint32_t *hi); * which on hardware requires a reset to clear. A loud stuck-state indicator. */ int cx4_locked(const Cx4 *cx4); +/* Clock extension for execution snapshots. The original RTLS Cx4 payload + * predates this anchor; stream it from the versioned title extension. */ +void cx4_saveload_clock(Cx4 *cx4, struct SaveLoadInfo *sli); + #endif /* SNES_CX4_H */ diff --git a/runner/tests/mmx_state_runtime.c b/runner/tests/mmx_state_runtime.c new file mode 100644 index 00000000..a56534c8 --- /dev/null +++ b/runner/tests/mmx_state_runtime.c @@ -0,0 +1,129 @@ +/* ROM-backed checks for the X2/X3 adapter and the real shared desktop host. + * The caller supplies an empty working directory; only slot 12 is used. */ +#define MMX_DESKTOP_ENTRY MmxDesktopMain +#include "../src/desktop/host_main.c" +#include MMX_GAME_MAIN +#include "common/launcher_binds.h" + +static void check(int ok, const char *what) { + if (!ok) { fprintf(stderr, "FAIL: %s\n", what); exit(1); } + printf("ok: %s\n", what); +} +static void frame(unsigned input) { + RtlRunFrame(input | (1u << 30)); + CaptureSimulationFrame(1); +} +static void replay(int count) { + for (int i = 0; i < count; ++i) frame(i < count / 2 ? SNES_PAD_RIGHT : 0); +} +static void same(const void *a, size_t an, const void *b, size_t bn, const char *what) { + if (an != bn || memcmp(a, b, an)) { + size_t i = 0; + while (i < an && i < bn && ((const uint8 *)a)[i] == ((const uint8 *)b)[i]) ++i; + fprintf(stderr, "first difference at %zu / %zu / %zu\n", i, an, bn); + + } + check(an && an == bn && !memcmp(a, b, an), what); +} +int main(int argc, char **argv) { + check(argc == 2 || argc == 3, "ROM supplied"); + SDL_SetMainReady(); + check(snesrecomp_sdl_init(SDL_INIT_EVENTS), "SDL initializes"); + g_audio_mutex = SDL_CreateMutex(); + static const SnesDesktopHostGame game = { + .native_widescreen = 1, .state_menu_hotkeys = 1, + .prepare_frame = MmxPrepareFrame, .begin_sim_frame = MmxBeginFrame, + }; + g_game = &game; + ConfigUseStateMenuDefaults(); + FILE *f = fopen("config.ini", "w"); + fputs("[KeyMap]\nLoad = F1,F2,F3,F4,F5,F6,F7,F8,F9,F10\n", f); fclose(f); + ParseConfigFile("config.ini"); + check(FindCmdForSdlKey(SDLK_F7, 0) == kKeys_SaveStateMenu && + FindCmdForSdlKey(SDLK_F8, 0) == kKeys_Rewind && + FindCmdForSdlKey(SDLK_F11, 0) == kKeys_Load + 6 && + FindCmdForSdlKey(SDLK_F12, 0) == kKeys_Load + 7, "F7/F8 and legacy config migration"); + HandleInput(SDLK_F7, 0, true); HandleInput(SDLK_F8, 0, true); + check(g_savestate_menu_hotkey && g_rewind_hotkey, "host dispatches F7/F8"); + g_savestate_menu_hotkey = g_rewind_hotkey = 0; + LauncherModel model = {0}; + g_launcher_config_path = "config.ini"; + launcher_binds_set_hotkey(&model, LNG_HK_SAVE_STATE_MENU, SDLK_F9, KMOD_CTRL); + launcher_binds_set_hotkey(&model, LNG_HK_REWIND, SDLK_F9, 0); + ConfigReloadKeyMap("config.ini"); + check(FindCmdForSdlKey(SDLK_F9, KMOD_CTRL) == kKeys_SaveStateMenu && + FindCmdForSdlKey(SDLK_F9, 0) == kKeys_Rewind, "launcher rebind and slot collision"); + launcher_binds_set_hotkey(&model, LNG_HK_REWIND, 0, 0); + ConfigReloadKeyMap("config.ini"); + check(!FindCmdForSdlKey(SDLK_F8, 0) && + FindCmdForSdlKey(SDLK_F9, 0) != kKeys_Rewind, "launcher clear persists"); + g_launcher_config_path = NULL; + f = fopen(argv[1], "rb"); check(f != NULL, "ROM opens"); + fseek(f, 0, SEEK_END); long rom_size = ftell(f); rewind(f); + uint8 *rom = malloc(rom_size); + check(fread(rom, 1, rom_size, f) == (size_t)rom_size, "ROM reads"); fclose(f); + g_config.new_renderer = true; g_config.widescreen = true; + g_last_drawable_width = 1280; g_last_drawable_height = 720; + g_ppu_render_flags = kPpuRenderFlags_NewRenderer; + RtlRegisterGame(&kMmxGameInfo); + check(SnesInit(rom, rom_size) != NULL, "game initializes"); + g_spc_player = SmwSpcPlayer_Create(); + g_spc_player->initialize(g_spc_player); + MkDir("saves"); + size_t cap = 2u * 1024u * 1024u; + uint8 *start = malloc(cap), *expected = malloc(cap), *actual = malloc(cap); + if (argc == 3) { + check(RtlLoadSnapshot("saves/save11.sav"), "file loads in new process"); + replay(10); + size_t n = RtlSaveSnapshotToMemory(actual, cap); + f = fopen("expected.bin", "rb"); check(f != NULL, "reference opens"); + size_t en = fread(expected, 1, cap, f); fclose(f); + same(expected, en, actual, n, "new-process replay"); + puts("MMX STATE CHECKS PASSED"); return 0; + } + for (int phase = 0; phase < 2; ++phase) { + for (int i = 0; i < 600; ++i) frame(i == 200 || i == 350 ? SNES_PAD_START : 0); + size_t n = RtlSaveSnapshotToMemory(start, cap); + replay(30); size_t en = RtlSaveSnapshotToMemory(expected, cap); + check(RtlLoadSnapshotFromMemory(start, n), "memory load"); + size_t an = RtlSaveSnapshotToMemory(actual, cap); + same(start, n, actual, an, "complete immediate restore"); + replay(30); an = RtlSaveSnapshotToMemory(actual, cap); + same(expected, en, actual, an, "30-frame deterministic replay"); + } + snes_savestate_menu_poll_open(SNES_PAD_SELECT | SNES_PAD_R); + check(snes_savestate_menu_is_open(), "save browser opens"); + uint64 clock = g_cpu.master_cycles; + snes_savestate_menu_handle_key(SDLK_EQUALS, 0); + SDL_Event key = {0}; + key.type = SDL_KEYDOWN; +#if SNESRECOMP_SDL3 + key.key.key = SDLK_s; +#else + key.key.keysym.sym = SDLK_s; +#endif + SDL_PushEvent(&key); + bool running = true; + PumpOverlayEvents(&running, snes_savestate_menu_handle_key); + check(OverlayNavInputs() & SNES_PAD_X, "browser receives keyboard save action"); + snes_savestate_menu_poll_nav(OverlayNavInputs(), 1); + HandleInput(SDLK_s, 0, false); + check(clock == g_cpu.master_cycles, "saving does not advance guest"); + snes_savestate_menu_poll_nav(0, 2); + snes_savestate_menu_poll_nav(SNES_PAD_B, 3); + check(!snes_savestate_menu_is_open(), "B closes save browser"); + replay(10); size_t en = RtlSaveSnapshotToMemory(expected, cap); + f = fopen("expected.bin", "wb"); fwrite(expected, 1, en, f); fclose(f); + snes_rewind_configure(); + for (int i = 0; i < 12; ++i) { frame(0); snes_rewind_note_frame(); } + size_t n = RtlSaveSnapshotToMemory(start, cap); + for (int i = 0; i < 6; ++i) { frame(SNES_PAD_RIGHT); snes_rewind_note_frame(); } + check(snes_rewind_open(), "rewind opens"); + for (int i = 0; i < 6; ++i) snes_rewind_step(-1); + snes_rewind_commit(); + size_t an = RtlSaveSnapshotToMemory(actual, cap); + same(start, n, actual, an, "rewind restores selected frame"); + snes_rewind_shutdown(); + puts("MMX STATE CHECKS PASSED"); + return 0; +} diff --git a/runner/tests/run_mmx_state_tests.py b/runner/tests/run_mmx_state_tests.py new file mode 100644 index 00000000..88b46b86 --- /dev/null +++ b/runner/tests/run_mmx_state_tests.py @@ -0,0 +1,23 @@ +"""Run the real-ROM MMX state checks without touching a player's saves.""" +import argparse +import os +from pathlib import Path +import subprocess +import tempfile + +p = argparse.ArgumentParser() +p.add_argument('--exe', type=Path, required=True) +p.add_argument('--rom', type=Path, required=True) +a = p.parse_args() +env = dict(os.environ, SNESRECOMP_REWIND_INTERVAL='1') +with tempfile.TemporaryDirectory(prefix='mmx-state-') as temp: + for extra in ([], ['--resume']): + result = subprocess.run([str(a.exe.resolve()), str(a.rom.resolve()), *extra], + cwd=temp, env=env, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + timeout=180, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0) + print(result.stdout, end='') + result.check_returncode() + if 'MMX STATE CHECKS PASSED' not in result.stdout: + raise RuntimeError('Runtime check exited without its completion marker') From e7cad8da7228c7853bbf8755480de5b1c39f165b Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 12 Sep 2026 01:50:24 -0700 Subject: [PATCH 2/2] desktop: clear the audio stream outside the APU lock --- runner/src/desktop/host_main.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/runner/src/desktop/host_main.c b/runner/src/desktop/host_main.c index ee15d0af..3bfc1512 100644 --- a/runner/src/desktop/host_main.c +++ b/runner/src/desktop/host_main.c @@ -1436,10 +1436,12 @@ static void ResetAudioTimeline(void) { RtlApuLock(); g_audiobuffer_end = g_audiobuffer_cur; g_audio_primed = false; + RtlApuUnlock(); #if SNESRECOMP_SDL3 + /* The stream callback takes the APU mutex: never acquire SDL's stream + * lock while holding that mutex in the opposite order. */ if (g_audio_stream) SDL_ClearAudioStream(g_audio_stream); #endif - RtlApuUnlock(); } void RtlApuLock(void) {