diff --git a/CMakeLists.txt b/CMakeLists.txt index e04969c..f2deb0c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.16) -project(SuperMetroidSNESRecomp VERSION 0.3.15 LANGUAGES C) +project(SuperMetroidSNESRecomp VERSION 0.3.16 LANGUAGES C) # Cross-platform (macOS/Linux/MinGW) build for SuperMetroidSNESRecomp. # @@ -11,6 +11,8 @@ project(SuperMetroidSNESRecomp VERSION 0.3.15 LANGUAGES C) # - SDL2 + OpenGL set(CMAKE_C_STANDARD 11) +option(SM_VIEW_ACTIVITY "Process enemies throughout the visible wider view" ON) +add_compile_definitions(SM_VIEW_ACTIVITY=$) option(SM_RASTER_FULL_COPY "Use full-copy VRAM raster storage as the reference" OFF) option(SM_RASTER_TRACK_WRITES "Track VRAM writes for title-owned capture intervals" ON) add_compile_definitions(SM_RASTER_TRACK_WRITES=$) @@ -58,6 +60,9 @@ list(APPEND SNESRECOMP_RUNNER_INCLUDE_DIRS ${SNESRECOMP_ROOT}/runner/src/desktop # roots through the owning helper, or directly for focused tests/tools that # compile only that layer. if(BUILD_TESTING AND NOT ANDROID) + add_executable(sm_activity_test tests/sm_activity_test.c src/sm_activity.c) + target_include_directories(sm_activity_test PRIVATE src) + add_test(NAME sm_visible_enemy_activity COMMAND sm_activity_test) add_subdirectory(${SNESRECOMP_ROOT}/runner/tests/raster_snapshot raster-snapshot-tests) add_executable(sm_display_test tests/sm_display_test.c src/sm_display.c) target_include_directories(sm_display_test PRIVATE @@ -129,6 +134,25 @@ if(BUILD_TESTING AND NOT ANDROID) add_test(NAME sm_custom_video COMMAND sm_video_test ${CMAKE_CURRENT_BINARY_DIR}/sm-video-test.ini) + # The pause menu's Options tab: guest pause-state effects and pixels. It + # saves sm-video.ini, so it runs in its own directory, never beside the + # game executable. + add_executable(sm_pause_options_test + tests/sm_pause_options_test.c src/sm_pause_options.c src/sm_video.c + ${SNESRECOMP_ROOT}/runner/src/desktop/host_clock.c + ${SNESRECOMP_ROOT}/runner/src/snes_overlay_draw.c + ${RECOMP_UI_ROOT}/src/common/recomp_runtime_ui.c) + target_include_directories(sm_pause_options_test PRIVATE src ${RECOMP_UI_ROOT}/src + ${SNESRECOMP_RUNNER_INCLUDE_DIRS} + ${SNESRECOMP_ROOT}/runner/src/desktop) + # sm_pause_options.c includes host_api.h, which includes the SDL boundary + # header; the test fakes the API so no SDL runs, but the includes and + # compile definitions still need the selected backend. + snesrecomp_target_sdl(sm_pause_options_test) + file(MAKE_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/sm_pause_options_test.d) + add_test(NAME sm_pause_options COMMAND sm_pause_options_test + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/sm_pause_options_test.d) + add_executable(sm_renderer_test tests/sm_renderer_test.c src/sm_renderer.c src/sm_mode7.c) target_include_directories(sm_renderer_test PRIVATE src ${SNESRECOMP_RUNNER_INCLUDE_DIRS}) @@ -162,7 +186,7 @@ if(BUILD_TESTING AND NOT ANDROID) # Assertions are the test oracle, including Release configurations. foreach(sm_test sm_display_test sm_overlay_blit_test sm_savestate_gesture_test ppu_window_test sm_video_test - sm_renderer_test sm_mode7_test) + sm_pause_options_test sm_renderer_test sm_mode7_test) if(MSVC) target_compile_options(${sm_test} PRIVATE /UNDEBUG) else() @@ -171,6 +195,7 @@ if(BUILD_TESTING AND NOT ANDROID) endforeach() if(NOT WIN32) target_link_libraries(sm_video_test PRIVATE m) + target_link_libraries(sm_pause_options_test PRIVATE m) target_link_libraries(sm_renderer_test PRIVATE m) target_link_libraries(sm_render_capture PRIVATE m) target_link_libraries(sm_mode7_test PRIVATE m) @@ -212,6 +237,8 @@ set(SM_HOST_SOURCES src/sm_mode7.c # SM's mode-7 object projection src/sm_display.c # SM presentation geometry src/sm_video.c # SM aspect/HUD policy + presentation clock + src/sm_activity.c src/sm_activity_host.c + src/sm_pause_options.c # 3rd pause tab: the Game Options overlay src/sm_mods.c # built-in presentation mod provider ) @@ -273,7 +300,7 @@ file(GLOB SM_REPLAY_ID_INPUTS CONFIGURE_DEPENDS src/*.c src/*.h recomp/*.cfg pro list(APPEND SM_REPLAY_ID_INPUTS ${CMAKE_CURRENT_SOURCE_DIR}/CMakeLists.txt ${CMAKE_CURRENT_SOURCE_DIR}/framework_pins.txt ${CMAKE_CURRENT_SOURCE_DIR}/VERSION) set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${SM_REPLAY_ID_INPUTS}) -set(SM_REPLAY_SOURCE_ID "${CMAKE_C_FLAGS};${CMAKE_C_FLAGS_RELEASE};${CMAKE_CXX_FLAGS};${CMAKE_CXX_FLAGS_RELEASE}") +set(SM_REPLAY_SOURCE_ID "${CMAKE_C_FLAGS};${CMAKE_C_FLAGS_RELEASE};${CMAKE_CXX_FLAGS};${CMAKE_CXX_FLAGS_RELEASE};activity=${SM_VIEW_ACTIVITY}") foreach(input IN LISTS SM_REPLAY_ID_INPUTS) file(SHA256 ${input} input_hash) string(APPEND SM_REPLAY_SOURCE_ID ";${input_hash}") @@ -369,6 +396,9 @@ endif() recomp_target_launcher_ui(SuperMetroidSNESRecomp CONSOLE snes BOXART ${CMAKE_CURRENT_SOURCE_DIR}/recomp/launcher/boxart.tga) +if(SNESRECOMP_ENABLE_NET) + snesrecomp_enable_recomp_net(SuperMetroidSNESRecomp) +endif() # Omit the codegen host AND its include directory. The shared desktop host # detects that capability by header availability; the shipped launcher only diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 3c4f04a..c189a0d 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1097,6 +1097,47 @@ time is the PLM loop (force_lle) and analyzer cascades in HDMA objects and enemy AI (beads-8wg.2.148). The PLP-with-unknown-P exit-width hazard is beads-8wg.2.147. +## 2026-10-20: pause Options tab edits the runner's display settings + +The pause panel's Options tab previously reached only the five Super Metroid +video settings. It now also offers the five settings the runner already +applies live: fullscreen (windowed/borderless), vsync (off/on/adaptive), +frame blend, run-ahead (0..4), and window scale (1..10, disabled while +fullscreen, where the host ignores it) — plus a graceful Exit. Eleven rows +total, plus the existing Back-to-map action. + +The values cross the game/host boundary through the framework's new +`host_api.h` get/set pairs, and persist through the host marking the config +dirty and writing `config.ini` at shutdown; no second writer on the game +side. Window scale re-enables automatically when the user leaves +fullscreen, and navigation skips the disabled row in both directions, +matching the panel's existing disabled-row semantics. The Exit row asks +the host to end the run (`snesrecomp_request_exit`): the main loop exits +on its next pass, after the requesting frame has presented, through the +normal shutdown path — so a config the panel changed is written before +the window closes. + +The pin for this is **open framework work**: snesrecomp +[PR186](https://github.com/RetroPortingToolKit/snesrecomp/pull/186) (head +`adf6f6d09765570ad89c7f1bf523b02509e0ab8f` over `f68aa0f`, the previous +pin) adds the API, three new keymap entries, and the dirty-config shutdown +write; the submodule is pinned one commit beyond the PR head, +`425b53214b5e03c9f5405fc667e83b50911f9c21` (local branch +`pr186-with-request-exit`), which adds `snesrecomp_request_exit`. That +commit exists only locally until it is pushed to the PR's branch, and the +pin follows the PR to merge and must be re-verified when it lands. The PR's +new keys only act if bound in `[KeyMap]`; the shipped config binds none, so +existing keybinds are unaffected (the key enum shifted, but saved binds +store names). + +The panel test (`tests/sm_pause_options_test.c`) fakes the API with the +host's own clamps and drives all eleven rows: choice wrap at both ends, INT +clamping, the fullscreen-dependent enable row, the Exit action's host +latch, and the existing pad ownership, fade-out, snapshot, and gesture +sections. Its golden render hashes cover the eleven-row layout at +256/342/456/684 px; regenerate with `PAUSE_OPTIONS_TEST_CALIBRATE` if the +layout or rows change. All 10 test targets pass. + ## Owner-gated (do NOT do without explicit decision) Merging `investigate/sm-0012-blocker` or the multi-tier branches to main; diff --git a/VERSION b/VERSION index 9e29e10..ec96a62 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.15 +0.3.16 diff --git a/docs/custom-renderer.md b/docs/custom-renderer.md index 7e7bc9b..11c7053 100644 --- a/docs/custom-renderer.md +++ b/docs/custom-renderer.md @@ -7,6 +7,16 @@ This is an experimental custom renderer. The requested worktree and Mods functionality is implemented; this is not a full-game visual-fidelity or sustained-performance certification. +## Simulation in the wider view + +The renderer remains read-only. A separate simulation viewport extends the +ROM's enemy processing queues in slot order and the common player/enemy +projectile culls. Native 4:3 retains the original behavior. Enemies can attack +sooner when visible in a wider view. Fit samples its window geometry before +simulation; replay records that geometry, and snapshots preserve it. Netplay +uses the host's fixed initial viewport for every peer throughout the match. +The portable ROM-only activity reference remains a build-time selection. + ## October 25 feedback corrections HUD/dialogue BG3 pages remain screen-space through message HDMA and boss diff --git a/docs/releases/v0.3.16.md b/docs/releases/v0.3.16.md new file mode 100644 index 0000000..88a9a3e --- /dev/null +++ b/docs/releases/v0.3.16.md @@ -0,0 +1,11 @@ +This update fixes widescreen artifacts reported during the latest playthrough and adds the in-game Options tab. + +- Item and dialogue messages preserve HUD placement. Power bombs no longer bleed into the HUD, and the moving HUD pixel strip and unstable edge bars are corrected. +- Widescreen backgrounds, door transitions and liquid effects use the correct room and raster data. Crocomire's body stays anchored to the room when it leaves the original screen boundary. +- Enemies remain active across the wider visible view. They can notice and attack Samus sooner than in 4:3. Shared projectile bounds follow that view too. +- The Options tab applies settings during play, survives current-build save/load, and exits through normal configuration and native-save persistence. +- Run-ahead audio output, long-frame SPC catch-up, pause transitions and current-build snapshot validation are corrected. + +The owner accepted the prepared Windows visual/gameplay checks. Steam Deck hardware was unavailable for the remaining ordinary-gameplay audio check; that report remains unverified on the device. + +Keep your native SRAM saves and settings when updating. Emulator save states belong to their exact build. Supply your own supported ROM; downloads contain the precompiled runtime and assets only. diff --git a/framework_pins.txt b/framework_pins.txt index c616c3a..b5f4f6d 100644 --- a/framework_pins.txt +++ b/framework_pins.txt @@ -1,4 +1,4 @@ -snesrecomp=afe5656530708da22e644ece76e3c96b06b626c2 -recomp-ui=cac2b8f4aaa5d1061c6b8f2c3ed7e73c46b0400e -recomp-net=8c76fb643339144a3a84d5ba8f5defe6250dcd26 -retcomm-rbengine=2a03e73693acee0fb78076ea058642c931bef12e +snesrecomp=246c0d40a8bc5f706bc6ef71d7e8633842f8e42e +recomp-ui=ef13e3f1a00e2e03f91e4b74343fd77213f8a234 +recomp-net=8c76fb643339144a3a84d5ba8f5defe6250dcd26 +retcomm-rbengine=2a03e73693acee0fb78076ea058642c931bef12e diff --git a/recomp-ui b/recomp-ui index cac2b8f..ef13e3f 160000 --- a/recomp-ui +++ b/recomp-ui @@ -1 +1 @@ -Subproject commit cac2b8f4aaa5d1061c6b8f2c3ed7e73c46b0400e +Subproject commit ef13e3f1a00e2e03f91e4b74343fd77213f8a234 diff --git a/recomp/bank02.cfg b/recomp/bank02.cfg index 45b89b2..73cba77 100644 --- a/recomp/bank02.cfg +++ b/recomp/bank02.cfg @@ -265,6 +265,7 @@ func CopyToVramAtNextInterrupt e039 end:e071 func LoadRoomMusic e071 end:e09b func UpdateMusicTrackIndex e09b end:e0d5 hle_func e039 HleSmDoorTransitionVramWait +hle_func 90e8 HleSmGameState_15_Paused_Async func LoadNewMusicTrackIfChanged e0d5 end:e113 func NullFunc e113 end:e118 func PlayRoomMusicTrackAfterAFrames e118 end:e139 diff --git a/recomp/bank06.cfg b/recomp/bank06.cfg index 2960f1d..b083706 100644 --- a/recomp/bank06.cfg +++ b/recomp/bank06.cfg @@ -2,6 +2,9 @@ bank = 06 +hle_func e6e0 HleSmCheckIfEprojIsOffScreen +func SmNativeEprojCullBody e6e2 end:e722 + # >>> AUTO-INGESTED FROM sm DECOMP — do not hand-edit between markers >>> # Source: snesrev/sm decomp PC comments. # Regenerate via: python tools/ingest_sm_decomp.py diff --git a/recomp/bank10.cfg b/recomp/bank10.cfg index e99369e..b87e409 100644 --- a/recomp/bank10.cfg +++ b/recomp/bank10.cfg @@ -2,6 +2,9 @@ bank = 10 +hle_func b16a HleSmDeleteProjectileIfFarOffScreen +func SmNativeProjectileCullBody b16d end:b197 + # >>> AUTO-INGESTED FROM sm DECOMP — do not hand-edit between markers >>> # Source: snesrev/sm decomp PC comments. # Regenerate via: python tools/ingest_sm_decomp.py diff --git a/recomp/bank20.cfg b/recomp/bank20.cfg index 66fc094..ac6e9ec 100644 --- a/recomp/bank20.cfg +++ b/recomp/bank20.cfg @@ -2,6 +2,10 @@ bank = 20 +# Keep original queue construction and CPU residue; add visible margin slots. +hle_func 8eb6 HleSmDetermineWhichEnemiesToProcess +func SmNativeEnemyQueueBody 8eb7 end:8fd4 + # Dynamic JML sites still using the prototype host dispatcher. Migrate these # individually once their live continuations and width variants are covered. diff --git a/snesrecomp b/snesrecomp index afe5656..246c0d4 160000 --- a/snesrecomp +++ b/snesrecomp @@ -1 +1 @@ -Subproject commit afe5656530708da22e644ece76e3c96b06b626c2 +Subproject commit 246c0d40a8bc5f706bc6ef71d7e8633842f8e42e diff --git a/src/gen_stubs.c b/src/gen_stubs.c index b67d808..99bdbbf 100644 --- a/src/gen_stubs.c +++ b/src/gen_stubs.c @@ -8,9 +8,12 @@ #include #include "cpu_state.h" #include "common_rtl.h" +#include "common_cpu_infra.h" #include "cpu_trace.h" #include "variables.h" #include "sm_rtl.h" +#include "sm_pause_options.h" +#include "sm_activity.h" /* Wait replacements unwind compiled calls to the interruptible scheduler. * The original ROM bytes own the WRAM handshake and return-frame pop; the @@ -28,6 +31,104 @@ RecompReturn HleSmWaitForNmi(CpuState *cpu) { return sm_scheduler_yield(cpu, ((uint32)cpu->PB << 16) | 0x8338u); } +/* $A0:8EB6 ends in PLB/RTL. It has no nested calls or waits: the bounded + * interpreter call completes the original queue and architectural residue + * before we add visible margin enemies. No guest property bits are edited. */ +static void sm_prologue_cycles(CpuState *cpu, unsigned cycles) { + cpu->coprocessor_master_cycles = cpu->master_cycles; + cpu->cycles += cycles; + cpu->master_cycles += cycles * ((g_memsel && (cpu->PB & 0x80)) ? 6 : 8); +} +static void sm_push16(CpuState *cpu, uint16 value) { + cpu_write16(cpu, 0, (uint16)(cpu->S - 1), value); cpu->S -= 2; +} +static RecompReturn sm_native_tail(CpuState *cpu, uint32 body, uint32 entry, + uint16 entry_s, uint8 hrv, unsigned frame) { + cpu_tailcall_inherit_return_context(entry_s, hrv); + return cpu_dispatch_pc_from(cpu, body, (uint16)(entry_s + frame), entry); +} +RecompReturn HleSmDetermineWhichEnemiesToProcess(CpuState *cpu) { + uint32 entry = ((uint32)cpu->PB << 16) | 0x8eb6u; + uint32 body = (entry & 0xff0000u) | 0x8eb7u; + if (!SmActivityExtra() && !cpu->m_flag && !cpu->x_flag && cpu_dispatch_has_entry(cpu, body)) { + if (interp_bridge_lle_master_deadline_reached(cpu)) + return interp_bridge_lle_yield_unwind(cpu, entry); + uint16 entry_s = cpu->S; uint8 hrv = cpu->host_return_valid; + /* PHB; the generated tail owns PHK/PLB and the entire original queue. */ + sm_prologue_cycles(cpu, 3); + cpu_write8(cpu, 0, cpu->S, cpu->DB); --cpu->S; + return sm_native_tail(cpu, body, entry, entry_s, hrv, 3); + } + RecompReturn result = interp_tier_run_call_frame(cpu, entry, entry, 3, NULL); +#if SM_VIEW_ACTIVITY + if (result == RECOMP_RETURN_NORMAL && !SmActivityAugmentQueues(g_ram, SmActivityExtra())) { + static bool warned; + if (!warned) fprintf(stderr, "[sm-activity] invalid/full enemy queue; retained original ROM queues\n"); + warned = true; + } +#endif + return result; +} + +/* Widen the horizontal predicate only. The ROM still owns the vertical + * test, deletion effects, return carry/value and register restoration. */ +static bool sm_margin_projectile(int16 x, int left, int right) { +#if SM_VIEW_ACTIVITY + int extra = (int)SmActivityExtra(); + return extra && (x < left || x >= right) && x >= left - extra && x < right + extra; +#else + return false; +#endif +} +RecompReturn HleSmDeleteProjectileIfFarOffScreen(CpuState *cpu) { + uint32 entry = ((uint32)cpu->PB << 16) | 0xb16au; + if (!cpu->m_flag && !cpu->x_flag && !cpu->_flag_D) { + uint16 index = cpu_read16(cpu, cpu->DB, 0x0dde); + int16 x = (int16)(cpu_read16(cpu, cpu->DB, (uint16)(0x0b64 + index)) - + cpu_read16(cpu, cpu->DB, 0x0911)); + if (index < 20 && !(index & 1) && sm_margin_projectile(x, -64, 320)) { + cpu->X = index; /* Original LDX $0DDE; Y path overwrites A/N/Z/C/V. */ + sm_prologue_cycles(cpu, 5); + return interp_tier_run_call_frame(cpu, (entry & 0xff0000u) | 0xb184u, entry, 2, NULL); + } + } + uint32 body = (entry & 0xff0000u) | 0xb16du; + if (!cpu->m_flag && !cpu->x_flag && cpu_dispatch_has_entry(cpu, body)) { + if (interp_bridge_lle_master_deadline_reached(cpu)) + return interp_bridge_lle_yield_unwind(cpu, entry); + uint16 entry_s = cpu->S; uint8 hrv = cpu->host_return_valid; + sm_prologue_cycles(cpu, 5); /* LDX $0DDE */ + cpu->X = cpu_read16(cpu, cpu->DB, 0x0dde); + cpu->_flag_Z = cpu->X == 0; cpu->_flag_N = (cpu->X >> 15) != 0; + cpu->P = (cpu->P & ~0x82) | (cpu->_flag_Z ? 2 : 0) | (cpu->_flag_N ? 0x80 : 0); + return sm_native_tail(cpu, body, entry, entry_s, hrv, 2); + } + return interp_tier_run_call_frame(cpu, entry, entry, 2, NULL); +} +RecompReturn HleSmCheckIfEprojIsOffScreen(CpuState *cpu) { + uint32 entry = ((uint32)cpu->PB << 16) | 0xe6e0u; + if (!cpu->m_flag && !cpu->x_flag && !cpu->_flag_D && cpu->X < 36 && !(cpu->X & 1)) { + int16 x = (int16)(cpu_read16(cpu, cpu->DB, (uint16)(0x1a4b + cpu->X)) - + cpu_read16(cpu, cpu->DB, 0x0911)); + if (sm_margin_projectile(x, 0, 256)) { + uint16 entry_s = cpu->S; + /* PHX/PHY from $E6E0, before the original Y predicate at $E6FC. + * Pass the caller watermark, so PLY/PLX do not end the bridge early. */ + sm_prologue_cycles(cpu, 8); sm_push16(cpu, cpu->X); sm_push16(cpu, cpu->Y); + return interp_tier_dispatch_balanced(cpu, (entry & 0xff0000u) | 0xe6fcu, entry, entry_s, 2, false); + } + } + uint32 body = (entry & 0xff0000u) | 0xe6e2u; + if (!cpu->m_flag && !cpu->x_flag && cpu_dispatch_has_entry(cpu, body)) { + if (interp_bridge_lle_master_deadline_reached(cpu)) + return interp_bridge_lle_yield_unwind(cpu, entry); + uint16 entry_s = cpu->S; uint8 hrv = cpu->host_return_valid; + sm_prologue_cycles(cpu, 8); sm_push16(cpu, cpu->X); sm_push16(cpu, cpu->Y); + return sm_native_tail(cpu, body, entry, entry_s, hrv, 2); + } + return interp_tier_run_call_frame(cpu, entry, entry, 2, NULL); +} + /* $82:E039: inline-parameter VRAM update coroutine. */ RecompReturn HleSmDoorTransitionVramWait(CpuState *cpu) { return sm_scheduler_yield(cpu, 0x82E039u); @@ -217,3 +318,21 @@ SM_JML1784_WRAPPER(HleSmJmlIndirect1784_A3AC, 0x20A3ACu) #undef SM_JML1784_WRAPPER #undef SM_JML178C_WRAPPER + +/* GameState_15_Paused_Async ($82:90E8), the pause screen's per-frame state. + * Reached through Game_RunOneFrameOfGame's JSR ($8981,X); ends in RTS. + * + * The Options pause tab (src/sm_pause_options.c) reads and edits this + * frame's pad and pause-menu state first; then the ROM's own routine runs, + * unmodified, in both execution modes. Under the LLE scheduler that is a + * yield back to the interpreter at the routine's entry. Otherwise the ROM + * bytes run in the interpreter tier, which consumes the caller's 2-byte + * frame and still bounces nested calls into compiled code + * (docs/HLE_FUNC.md, "Delegating to the original"). */ +RecompReturn HleSmGameState_15_Paused_Async(CpuState *cpu) { + const uint32 entry = ((uint32)cpu->PB << 16) | 0x90E8u; + SmPauseOptions_GuestTick(); + if (interp_bridge_in_lle_scheduler()) + return interp_bridge_lle_yield_unwind(cpu, entry); + return interp_tier_run_call_frame(cpu, entry, entry, 2, NULL); +} diff --git a/src/main.c b/src/main.c index 0221166..bd364a4 100644 --- a/src/main.c +++ b/src/main.c @@ -47,9 +47,11 @@ #include "sm_display.h" #include "sm_renderer.h" #include "sm_video.h" +#include "sm_pause_options.h" #include "sm_spc_player.h" #include "sm_replay.h" #include "sm_startup.h" +#include "sm_activity.h" #if defined(RECOMP_LAUNCHER) #include "sm_mods.h" #endif @@ -62,7 +64,7 @@ extern const RtlGameInfo kSuperMetroidGameInfo; extern Snes *g_snes; extern uint8_t g_ram[0x20000]; -static const char *kSmVideoConfig = "sm-video.ini"; +static const char *kSmVideoConfig = SM_VIDEO_CONFIG_FILE; static SmViewport g_sm_viewport; static uint32_t g_sm_output[SM_MAX_WIDTH * SM_HEIGHT]; static uint64_t g_sm_capture_generation; @@ -79,7 +81,7 @@ static void SmSyncCaptureGeneration(void) { } static bool SmCustomRendererEnabled(void) { - return g_sm_video.enhanced || g_sm_video.fps_enabled; + return g_sm_video.enhanced || g_sm_video.fps_enabled || SmActivityNetworkWide(); } bool SmDisplay_IsWidescreenActive(void) { return g_sm_viewport.enhanced; } @@ -118,6 +120,10 @@ static void SmOnRomLoaded(const uint8_t *rom, size_t size) { static void SmOnReset(void) { SmRendererReset(); + /* The host also sends this notification after loading a snapshot. Keep + * the panel flag restored by SmOnStateLoaded when the guest is paused; + * ordinary gameplay and actual reset states close it through this sync. */ + SmPauseOptions_SyncToGuest(); } /* Door loading can take longer than a frame. Keep its debt only while the @@ -131,6 +137,7 @@ static int SmKeepPacingDebt(void) { static void SmPrepareFrame(int drawable_w, int drawable_h, int *frame_w, int *frame_h) { g_sm_viewport = SmCalculateViewport(&g_sm_video, drawable_w, drawable_h); + SmActivityViewport(&g_sm_viewport); SmReplayViewport(&g_sm_viewport); *frame_w = g_sm_viewport.width; *frame_h = SM_HEIGHT; @@ -140,6 +147,7 @@ static void SmPrepareFrame(int drawable_w, int drawable_h, int *frame_w, int *fr * per simulated frame, never for an interpolated present. */ static void SmBeginSimFrame(unsigned number) { SmSyncCaptureGeneration(); + SmPauseOptions_SyncToGuest(); if (SmCustomRendererEnabled()) SmRendererBeginFrame(g_ram, number); } @@ -155,15 +163,21 @@ static void SmEndSimFrame(const uint8_t *field, unsigned number) { static int SmDrawFrame(uint8_t *dst, size_t pitch, const uint8_t *field, int frame_w, int frame_h, double alpha) { - if (!SmCustomRendererEnabled()) + const bool overlay = SmPauseOptions_Visible(); + if (!SmCustomRendererEnabled() && !overlay) return 0; SmSyncCaptureGeneration(); - if (!SmRendererDraw(g_sm_output, g_sm_viewport, g_sm_video.hud_anchored, alpha)) { + int drew = 0; + if (SmCustomRendererEnabled()) + drew = SmRendererDraw(g_sm_output, g_sm_viewport, g_sm_video.hud_anchored, alpha); + if (!drew) { memset(g_sm_output, 0, sizeof(g_sm_output)); for (int y = 0; y < SM_HEIGHT; ++y) memcpy(g_sm_output + y * frame_w + g_sm_viewport.extra, field + y * 256 * 4, 256 * 4); } + if (overlay) + SmPauseOptions_Render(g_sm_output, frame_w, frame_w, frame_h); RtlWidescreenPresent(dst, pitch, (const uint8_t *)g_sm_output, frame_w, frame_h); return 1; } diff --git a/src/sm_activity.c b/src/sm_activity.c new file mode 100644 index 0000000..6be4b2e --- /dev/null +++ b/src/sm_activity.c @@ -0,0 +1,65 @@ +#include "sm_activity.h" +#include +#include + +static unsigned simulation_extra; +unsigned SmActivityExtra(void) { return simulation_extra; } +bool SmActivityExtraValid(unsigned extra) { return extra <= SM_ACTIVITY_EXTRA_MAX; } +void SmActivityRestore(unsigned extra) { simulation_extra = extra; } + +static uint16_t word(const uint8_t *ram, unsigned at) { + return (uint16_t)(ram[at] | (unsigned)ram[at + 1] << 8); +} +static void put(uint8_t *ram, unsigned at, unsigned value) { + ram[at] = (uint8_t)value; ram[at + 1] = (uint8_t)(value >> 8); +} + +bool SmActivityAugmentQueues(uint8_t *ram, unsigned extra) { + if (!SmActivityExtraValid(extra)) return false; + /* Let the original ROM own deletion, always-active/frozen enemies, its + * process-all path, and all CPU residue. Native geometry writes nothing. */ + if (!extra || word(ram, 0x183c)) return true; + unsigned bytes = word(ram, 0x17a4); + if ((bytes & 1) || bytes > 64) return false; + bool active[32] = {false}, changed = false; + for (unsigned i = 0; i < bytes; i += 2) { + unsigned slot = word(ram, 0x17ac + i); + if ((slot & 63) || slot >= 2048 || active[slot / 64]) return false; + active[slot / 64] = true; + } + const unsigned camera_x = word(ram, 0x911), camera_y = word(ram, 0x915); + for (unsigned i = 0; i < 32; ++i) { + unsigned at = 0xf78 + i * 64, id = word(ram, at); + unsigned properties = word(ram, at + 14); + if (active[i] || !id || id == 0xdaff || (properties & 0x200)) continue; + unsigned x = word(ram, at + 2), y = word(ram, at + 6), radius = word(ram, at + 10); + /* Match the ROM's signed 16-bit comparisons, extending only X. */ + if ((int16_t)(radius + x - camera_x + extra) >= 0 && + (int16_t)(radius + camera_x + 256 + extra - x) >= 0 && + (int16_t)(y + 8 - camera_y) >= 0 && + (int16_t)(camera_y + 248 - y) >= 0) { + active[i] = true; changed = true; + } + } + if (!changed) return true; + unsigned count = 0, interacting = 0; + for (unsigned i = 0; i < 32; ++i) if (active[i]) { + ++count; interacting += !(word(ram, 0xf78 + i * 64 + 14) & 0x400); + } + /* The two native lists are 32 words including their terminator. Refuse an + * impossible population rather than overwrite the adjacent queue. */ + /* All 32 slots fit if none interact: the active terminator and the empty + * interactive list share the same $17EC word. CA08's 32 sparks use this. */ + if (count > 31 && interacting) return false; + unsigned processed = 0, interactive = 0; + for (unsigned i = 0; i < 32; ++i) if (active[i]) { + unsigned slot = i * 64; + put(ram, 0x17ac + processed, slot); processed += 2; + if (!(word(ram, 0xf78 + slot + 14) & 0x400)) { + put(ram, 0x17ec + interactive, slot); interactive += 2; + } + } + put(ram, 0x17a4, processed); put(ram, 0x17ac + processed, 0xffff); + put(ram, 0x17a6, interactive); put(ram, 0x17ec + interactive, 0xffff); + return true; +} diff --git a/src/sm_activity.h b/src/sm_activity.h new file mode 100644 index 0000000..22e5d73 --- /dev/null +++ b/src/sm_activity.h @@ -0,0 +1,23 @@ +#pragma once +#include +#include + +/* Simulation geometry, distinct from the read-only renderer. */ +enum { SM_ACTIVITY_EXTRA_MAX = 214, SM_ACTIVITY_PROFILE = 1 }; +unsigned SmActivityExtra(void); +bool SmActivityExtraValid(unsigned extra); +void SmActivityRestore(unsigned extra); +bool SmActivityAugmentQueues(uint8_t *ram, unsigned extra); + +#if defined(SNES_HAS_LOBBY_CLIENT) && defined(RECOMP_LAUNCHER) +#include "netplay/snes_netplay.h" +static inline bool SmActivityGeometryMutable(void) { return !snes_netplay_active(); } +#else +static inline bool SmActivityGeometryMutable(void) { return true; } +#endif + +void SmActivityInitialize(void); +bool SmActivityFrameBegin(void); +struct SmViewport; +void SmActivityViewport(struct SmViewport *view); +bool SmActivityNetworkWide(void); diff --git a/src/sm_activity_host.c b/src/sm_activity_host.c new file mode 100644 index 0000000..bf03681 --- /dev/null +++ b/src/sm_activity_host.c @@ -0,0 +1,74 @@ +#include "sm_activity.h" +#include "sm_video.h" +#include "sm_replay.h" +#include "common_rtl.h" +#include "host_api.h" +#include +#if defined(SNES_HAS_LOBBY_CLIENT) +#include "snes_netplay.h" +#endif + +static bool network_profile_valid, local_profile_valid, network_error; +static unsigned network_extra, local_extra; + +static unsigned local_view(void) { +#if SM_VIEW_ACTIVITY + int w = 0, h = 0; + if (g_window) SDL_GetWindowSize(g_window, &w, &h); + return (unsigned)SmCalculateViewport(&g_sm_video, w, h).extra; +#else + return 0; +#endif +} +static bool network_active(void) { +#if defined(SNES_HAS_LOBBY_CLIENT) + return snes_netplay_active() != 0; +#else + return false; +#endif +} +#if defined(SNES_HAS_LOBBY_CLIENT) +static void capture_profile(uint8_t out[2]) { + if (!local_profile_valid) { local_extra = local_view(); local_profile_valid = true; } + out[0] = (uint8_t)local_extra; out[1] = SM_VIEW_ACTIVITY ? (0xa0 | SM_ACTIVITY_PROFILE) : 0xa0; +} +static void apply_profile(const uint8_t in[2]) { + unsigned expected = SM_VIEW_ACTIVITY ? (0xa0 | SM_ACTIVITY_PROFILE) : 0xa0; + if (in[1] != expected || !SmActivityExtraValid(in[0]) || + (network_profile_valid && network_extra != in[0])) { + if (!network_error) fprintf(stderr, "[sm-activity] incompatible or changing netplay viewport profile\n"); + network_error = true; snesrecomp_request_exit(); return; + } + if (!network_profile_valid) + fprintf(stderr, "[sm-activity] netplay viewport profile=%u extra=%u fixed for this match\n", expected, in[0]); + network_extra = in[0]; network_profile_valid = true; + SmActivityRestore(network_extra); +} +#endif +void SmActivityInitialize(void) { + network_profile_valid = local_profile_valid = network_error = false; + network_extra = local_extra = 0; SmActivityRestore(0); +#if defined(SNES_HAS_LOBBY_CLIENT) + snes_netplay_set_sync_byte_hooks(capture_profile, apply_profile); +#endif +} +bool SmActivityFrameBegin(void) { + if (network_active()) { + if (network_error || !network_profile_valid) return false; + SmActivityRestore(network_extra); + } else if (!RtlSpeculativeFrame() && !SmReplayPlaying()) { + network_profile_valid = local_profile_valid = false; + SmActivityRestore(local_view()); + } + return true; +} +bool SmActivityNetworkWide(void) { + return network_active() && network_profile_valid && network_extra != 0; +} +void SmActivityViewport(SmViewport *view) { + if (!network_active() || !network_profile_valid) return; + /* A local resize must not reveal a larger, sleeping simulation margin. */ + view->extra = (int)network_extra; view->width = 256 + 2 * view->extra; + view->aspect = (4.0 / 3.0) * view->width / 256.0; + view->enhanced = view->extra != 0; +} diff --git a/src/sm_cpu_infra.c b/src/sm_cpu_infra.c index 5489feb..155b7b9 100644 --- a/src/sm_cpu_infra.c +++ b/src/sm_cpu_infra.c @@ -4,17 +4,20 @@ #include "sm_replay.h" #include "sm_video.h" #include "sm_renderer.h" +#include "sm_activity.h" static bool SmFrameBegin(uint32_t *inputs) { + if (!SmActivityFrameBegin()) return false; if (!SmReplayBegin(inputs)) return false; /* Playback may restore a checkpoint and change presentation at this exact * boundary, so latch objects after those changes. */ - if (g_sm_video.enhanced || g_sm_video.fps_enabled) SmRendererLatchObjectState(g_ram); + if (g_sm_video.enhanced || g_sm_video.fps_enabled || SmActivityNetworkWide()) SmRendererLatchObjectState(g_ram); return true; } /* Called by the framework from SnesInit, once the machine exists. */ static void SmInitialize(void) { + SmActivityInitialize(); /* Add Super Metroid's object to the crash/exit report. Registered through * the framework hook rather than by forking post_mortem.c, which is how * this port previously carried its own 833-line copy of that file. */ diff --git a/src/sm_mods.c b/src/sm_mods.c index 8c20280..8e92a4c 100644 --- a/src/sm_mods.c +++ b/src/sm_mods.c @@ -1,4 +1,5 @@ #include "sm_mods.h" +#include "sm_activity.h" #include #include #include @@ -11,7 +12,7 @@ static const char *const packages[] = {"sm-widescreen", "sm-presentation-fps"}; static const char *const features[] = {"widescreen", "presentation-fps"}; static const char *const names[] = {"Widescreen", "Presentation FPS"}; static const char *const descriptions[] = { - "Render a wider room view with optional HUD anchoring.", + "Render a wider room view and keep enemies active throughout it, with optional HUD anchoring.", "Interpolate rendered motion at your display rate while keeping game timing unchanged." }; static const char *const aspects[] = {"Fit", "16:9", "21:9", "32:9"}; @@ -92,6 +93,9 @@ static int enable(void *ctx, const char *package, const char *feature, int on) { (void)ctx; int kind = identity(package, feature); if (kind < 0) return 0; + if (!kind && !SmActivityGeometryMutable()) { + COPY(error, "The initial host view stays fixed during this netplay match."); return 0; + } if (kind) video->fps_enabled = on != 0; else video->enhanced = on != 0; return 1; @@ -101,6 +105,9 @@ static int set_option(void *ctx, const char *package, const char *feature, (void)ctx; int kind = identity(package, feature); if (kind < 0 || !value) return 0; + if (!kind && option && !strcmp(option, "aspect") && !SmActivityGeometryMutable()) { + COPY(error, "The initial host view stays fixed during this netplay match."); return 0; + } for (int i = 0; i < 8; ++i) { const char *candidate = choice(kind, option, i); if (!candidate || strcmp(candidate, value)) continue; diff --git a/src/sm_pause_options.c b/src/sm_pause_options.c new file mode 100644 index 0000000..8d41b0f --- /dev/null +++ b/src/sm_pause_options.c @@ -0,0 +1,428 @@ +/* sm_pause_options.c — see sm_pause_options.h for why the third pause tab is + * a host panel and not a recompiled screen. */ + +#include "sm_pause_options.h" + +#include +#include + +#include "common_rtl.h" +#include "host_api.h" +#include "recomp_runtime_ui.h" +#include "snes_overlay_draw.h" +#include "snes_runahead.h" +#include "sm_video.h" +#include "sm_activity.h" +#include "variables.h" + +/* kPauseMenuFuncs ($82:90FF) states this tab moves the guest between. Leaving + * the panel uses the ROM's own tab change from its fade-out on: PauseMenu_2 + * fades out and continues to the Equipment Load, PauseMenu_5 to the Map Load, + * and the FadeIn after it lands on the screen pausemenu_button_label_mode + * names. */ +enum { + kPauseMenu_MapScreen = 0, + kPauseMenu_EquipmentScreen = 1, + kPauseMenu_FadeOutToEquipment = 2, + kPauseMenu_FadeOutToMap = 5, +}; +enum { kLabelMode_Map = 0, kLabelMode_Equipment = 2 }; + +/* Game states the panel can be up in: paused, and the unpause fade-out a + * Start press begins ($82:9324). */ +enum { kGameState_Paused = 0x0F, kGameState_UnpausingFadeOut = 0x10 }; + +static unsigned GameState(void) { return g_ram[0x0998]; } +static uint8 Inidisp(void) { return g_ram[0x0051]; } /* reg_INIDISP shadow */ + +/* ── model ────────────────────────────────────────────────────────────── */ + +static const char kSection[] = "Options"; +static const char *g_aspect_choices[SM_ASPECT_COUNT]; +/* The framework's choices are fixed vocabulary; the SM aspect names come from + * sm_video at panel creation. */ +static const char *g_fullscreen_choices[2] = { "Windowed", "Borderless" }; +static const char *g_vsync_choices[3] = { "Off", "On", "Adaptive" }; + +static const RecompRuntimeUiItem kItems[] = { + { "widescreen", kSection, "Widescreen", NULL, + RECOMP_RUNTIME_UI_BOOL, 0, 1, 1, NULL, 0, NULL }, + /* Only meaningful with widescreen on, so disabled (and skipped) without. */ + { "hud", kSection, "HUD anchoring", NULL, + RECOMP_RUNTIME_UI_BOOL, 0, 1, 1, NULL, 0, NULL }, + { "fps", kSection, "Presentation FPS", NULL, + RECOMP_RUNTIME_UI_BOOL, 0, 1, 1, NULL, 0, NULL }, + { "aspect", kSection, "Aspect ratio", NULL, + RECOMP_RUNTIME_UI_CHOICE, 0, SM_ASPECT_COUNT - 1, 1, + g_aspect_choices, SM_ASPECT_COUNT, NULL }, + /* The framework's own display settings (host_api.h, snesrecomp PR #186). + * Each change applies live to the running window; the host marks its + * config dirty and writes config.ini at shutdown, so it survives the + * restart. */ + { "fullscreen", kSection, "Fullscreen", NULL, + RECOMP_RUNTIME_UI_CHOICE, 0, 1, 1, + g_fullscreen_choices, 2, NULL }, + { "vsync", kSection, "VSync", NULL, + RECOMP_RUNTIME_UI_CHOICE, 0, 2, 1, + g_vsync_choices, 3, NULL }, + { "frame_blend", kSection, "Frame blend", NULL, + RECOMP_RUNTIME_UI_BOOL, 0, 1, 1, NULL, 0, NULL }, + { "run_ahead", kSection, "Run ahead", NULL, + RECOMP_RUNTIME_UI_INT, 0, SNES_RUNAHEAD_MAX, 1, NULL, 0, NULL }, + { "window_scale", kSection, "Window scale", NULL, + RECOMP_RUNTIME_UI_INT, 1, 10 /* the host's kMaxWindowScale */, 1, NULL, 0, NULL }, + /* Graceful exit: the host ends the run on its next loop pass, through its + * normal shutdown path, so any config the panel dirtied is written to + * config.ini first. */ + { "exit", kSection, "Exit", NULL, + RECOMP_RUNTIME_UI_ACTION, 0, 0, 0, NULL, 0, NULL }, + { "back", kSection, "Back to map", NULL, + RECOMP_RUNTIME_UI_ACTION, 0, 0, 0, NULL, 0, NULL }, +}; + +static RecompRuntimeUi *g_ui; +static bool g_leave; /* "Back to map" was accepted this frame */ + +static bool *BoolField(const RecompRuntimeUiItem *item) { + if (!strcmp(item->key, "widescreen")) return &g_sm_video.enhanced; + if (!strcmp(item->key, "hud")) return &g_sm_video.hud_anchored; + if (!strcmp(item->key, "fps")) return &g_sm_video.fps_enabled; + return NULL; +} + +static int GetValue(void *ctx, const RecompRuntimeUiItem *item, int *out) { + (void)ctx; + bool *b = BoolField(item); + if (b) { *out = *b; return 1; } + if (!strcmp(item->key, "aspect")) { *out = (int)g_sm_video.aspect; return 1; } + if (!strcmp(item->key, "fullscreen")) { *out = snesrecomp_get_fullscreen(); return 1; } + if (!strcmp(item->key, "vsync")) { *out = snesrecomp_get_vsync(); return 1; } + if (!strcmp(item->key, "frame_blend")) { *out = snesrecomp_get_frame_blend(); return 1; } + if (!strcmp(item->key, "run_ahead")) { *out = snesrecomp_get_run_ahead(); return 1; } + if (!strcmp(item->key, "window_scale")) { *out = snesrecomp_get_window_scale(); return 1; } + return 0; +} + +static int SetValue(void *ctx, const RecompRuntimeUiItem *item, int value) { + (void)ctx; + if (!snesrecomp_host_frame_is_live()) return 1; + bool *b = BoolField(item); + if (b) { + /* Turning widescreen on brings HUD anchoring up on Edges, so the + * feature it gates is active straight away. */ + if (b == &g_sm_video.enhanced && value && !g_sm_video.enhanced) + g_sm_video.hud_anchored = true; + *b = value != 0; + return 1; + } + if (!strcmp(item->key, "aspect") && value >= 0 && value < SM_ASPECT_COUNT) { + g_sm_video.aspect = (SmAspect)value; + return 1; + } + /* The host's setters clamp, apply the change to the running window, and + * mark its config dirty for the shutdown write; the model only proposes + * values inside each row's range, so all of them are accepted. */ + if (!strcmp(item->key, "fullscreen")) { snesrecomp_set_fullscreen(value); return 1; } + if (!strcmp(item->key, "vsync")) { snesrecomp_set_vsync(value); return 1; } + if (!strcmp(item->key, "frame_blend")) { snesrecomp_set_frame_blend(value); return 1; } + if (!strcmp(item->key, "run_ahead")) { snesrecomp_set_run_ahead(value); return 1; } + if (!strcmp(item->key, "window_scale")) { snesrecomp_set_window_scale(value); return 1; } + return 0; +} + +static int RunAction(void *ctx, const RecompRuntimeUiItem *item) { + (void)ctx; + if (!strcmp(item->key, "back")) { + g_leave = true; + return 1; + } + /* The host ends the run on its next loop pass -- after this frame has + * presented -- through its normal shutdown path. */ + if (!strcmp(item->key, "exit")) { + snesrecomp_request_exit(); + return 1; + } + return 0; +} + +static int IsEnabled(void *ctx, const RecompRuntimeUiItem *item) { + (void)ctx; + if (!strcmp(item->key, "widescreen") || !strcmp(item->key, "aspect")) + return SmActivityGeometryMutable(); + if (!strcmp(item->key, "hud")) return g_sm_video.enhanced; + /* The host ignores a scale change while fullscreen, so the row is too. */ + if (!strcmp(item->key, "window_scale")) return !snesrecomp_get_fullscreen(); + return 1; +} + +static void Save(void *ctx) { + (void)ctx; + if (!snesrecomp_host_frame_is_live()) return; + SmVideoSave(&g_sm_video, SM_VIDEO_CONFIG_FILE); +} + +static RecompRuntimeUi *Ui(void) { + if (g_ui) return g_ui; + for (int i = 0; i < SM_ASPECT_COUNT; ++i) + g_aspect_choices[i] = SmAspectName((SmAspect)i); + RecompRuntimeUiConfig config; + memset(&config, 0, sizeof(config)); + config.title = "Game options"; + config.items = kItems; + config.item_count = sizeof(kItems) / sizeof(kItems[0]); + config.callbacks.get_value = GetValue; + config.callbacks.set_value = SetValue; + config.callbacks.run_action = RunAction; + config.callbacks.is_enabled = IsEnabled; + config.callbacks.save = Save; + config.theme = "snes"; + config.navigation_flags = RECOMP_RUNTIME_UI_NAVIGATION_SKIP_DISABLED; + g_ui = recomp_runtime_ui_create(&config); + return g_ui; +} + +void SmPauseOptions_SetActive(bool active) { + RecompRuntimeUi *ui = Ui(); + if (!ui || active == (recomp_runtime_ui_is_open(ui) != 0)) return; + g_leave = false; + if (active) recomp_runtime_ui_open_section(ui, kSection); + else recomp_runtime_ui_close(ui); +} + +bool SmPauseOptions_IsActive(void) { + return g_ui && recomp_runtime_ui_is_open(g_ui); +} + +unsigned SmPauseOptions_Cursor(void) { + return SmPauseOptions_IsActive() + ? (unsigned)recomp_runtime_ui_view_selected_row(g_ui) : 0; +} + +bool SmPauseOptions_CursorValid(unsigned row) { + return row < sizeof(kItems) / sizeof(kItems[0]); +} + +void SmPauseOptions_RestoreCursor(unsigned row) { + if (!SmPauseOptions_IsActive() || !SmPauseOptions_CursorValid(row)) return; + recomp_runtime_ui_open_section(g_ui, kSection); + /* Navigation skips disabled rows. A setting changed outside the snapshot + * may disable its old row; then choose the next enabled row. */ + for (unsigned i = 0; i < sizeof(kItems) / sizeof(kItems[0]); ++i) { + unsigned before = SmPauseOptions_Cursor(); + if (before >= row) break; + recomp_runtime_ui_handle_input(g_ui, RECOMP_RUNTIME_UI_INPUT_DOWN, 1, 0); + if (SmPauseOptions_Cursor() == before) break; + } +} + +/* The panel is on its way out while the screen under it fades: a tab change + * the panel started, or the unpause Start began. It stays drawn, dimmed with + * the screen, and takes no input. All of it is read from guest RAM. */ +static bool Leaving(void) { + return GameState() == kGameState_UnpausingFadeOut || + menu_index == kPauseMenu_FadeOutToEquipment || + menu_index == kPauseMenu_FadeOutToMap; +} + +void SmPauseOptions_SyncToGuest(void) { + if (!SmPauseOptions_IsActive()) return; + const unsigned state = GameState(); + /* Closed once the fade it was waiting on is over: the guest is in a Load + * state, or the game has moved past the paused states. While open, the + * tick holds menu_index on the Map screen, so nothing else is reachable. */ + if (state == kGameState_Paused ? !(Leaving() || menu_index == kPauseMenu_MapScreen) + : state != kGameState_UnpausingFadeOut) + SmPauseOptions_SetActive(false); +} + +/* ── guest side ───────────────────────────────────────────────────────── */ + +enum { + kPadButtons = kButton_R | kButton_L | kButton_X | kButton_A | kButton_Right | + kButton_Left | kButton_Down | kButton_Up | kButton_Start | + kButton_Select | kButton_Y | kButton_B, +}; + +/* Swallow presses so the ROM's ReleaseButtonsFilter(3) computes no newly-held + * edge for them. A newly-pressed button is also in joypad1_lastkeys (it is + * held this frame), so clearing both is exact. */ +static void ConsumePad(uint16 mask) { + joypad1_newkeys &= (uint16)~mask; + joypad1_lastkeys &= (uint16)~mask; + newly_held_down_timed_held_input &= (uint16)~mask; +} + +/* Leave the panel for a ROM screen, starting from the fade-out. */ +static void LeaveTo(uint16 fade_out_state, uint16 label_mode) { + menu_index = fade_out_state; + pausemenu_button_label_mode = label_mode; +} + +void SmPauseOptions_GuestTick(void) { + static const struct { uint16 button; RecompRuntimeUiInput input; } kMenuButtons[] = { + { kButton_Up, RECOMP_RUNTIME_UI_INPUT_UP }, + { kButton_Down, RECOMP_RUNTIME_UI_INPUT_DOWN }, + { kButton_Left, RECOMP_RUNTIME_UI_INPUT_LEFT }, + { kButton_Right, RECOMP_RUNTIME_UI_INPUT_RIGHT }, + { kButton_A, RECOMP_RUNTIME_UI_INPUT_ACCEPT }, + }; + /* While the panel is up it owns the pad. Only Start reaches the ROM's Map + * screen beneath it, which unpauses on it as usual. Held buttons go too: + * ReleaseButtonsFilter turns a button held for a few frames into the pause + * screens' input, so a held D-pad would scroll the hidden map and a held R + * would change tab again as soon as the panel had gone. */ + const uint16 kPanelOwned = (uint16)(kPadButtons & ~kButton_Start); + const uint16 pressed = joypad1_newkeys; + + SmPauseOptions_SyncToGuest(); + if (SmPauseOptions_IsActive() && Leaving()) { + ConsumePad(kPanelOwned); + return; + } + if (!SmPauseOptions_IsActive()) { + /* The tab ring is Map -> Equipment -> Options -> Map (R forward, L back). + * The ROM ignores L on Map and R on Equipment, so these are free. */ + if ((menu_index == kPauseMenu_EquipmentScreen && (pressed & kButton_R)) || + (menu_index == kPauseMenu_MapScreen && (pressed & kButton_L))) { + SmPauseOptions_SetActive(true); + ConsumePad(kPanelOwned); + menu_index = kPauseMenu_MapScreen; /* the ROM runs its Map under the panel */ + } + return; + } + + ConsumePad(kPanelOwned); + if (pressed & kButton_R) { + LeaveTo(kPauseMenu_FadeOutToMap, kLabelMode_Map); + return; + } + if (pressed & kButton_L) { + LeaveTo(kPauseMenu_FadeOutToEquipment, kLabelMode_Equipment); + return; + } + for (size_t i = 0; i < sizeof(kMenuButtons) / sizeof(kMenuButtons[0]); ++i) + if (pressed & kMenuButtons[i].button) + recomp_runtime_ui_handle_input(g_ui, kMenuButtons[i].input, 1, 0); + if (g_leave || (pressed & kButton_B)) { + g_leave = false; + LeaveTo(kPauseMenu_FadeOutToMap, kLabelMode_Map); + return; + } + menu_index = kPauseMenu_MapScreen; +} + +/* ── rendering ────────────────────────────────────────────────────────── */ + +/* Panel colours (ARGB8888). */ +#define COL_BG 0xFF0A0E18u +#define COL_BORDER 0xFF3A4A6Au +#define COL_TITLE 0xFFFFFFFFu +#define COL_TEXT 0xFFC2CCE0u +#define COL_SELBG 0xFF2B4A8Cu +#define COL_SEL 0xFFFFFFFFu +#define COL_DIM 0xFF8A98B4u +#define COL_ACCENT 0xFF6BE06Bu +#define COL_TABON 0xFFFFD24Du + +static int TextLen(const char *s) { return (int)strlen(s); } + +static void DrawCentered(uint32_t *dst, int stride, int h_max, int width, + int y, const char *s, uint32_t col, int scale) { + int x = (width - TextLen(s) * 8 * scale) / 2; + if (x < 0) x = 0; + snes_ovl_draw_text(dst, stride, h_max, x, y, s, col, scale); +} + +bool SmPauseOptions_Visible(void) { + const unsigned state = GameState(); + return SmPauseOptions_IsActive() && + (state == kGameState_Paused || state == kGameState_UnpausingFadeOut); +} + +/* While leaving, the panel fades with the screen under it: the PPU's master + * brightness (INIDISP 0-15, forced blank = black) applied to the panel. */ +static void DimToScreenBrightness(uint32_t *dst, int stride, int width, int height) { + const uint8 inidisp = Inidisp(); + const unsigned level = (inidisp & 0x80) ? 0 : (inidisp & 0x0F); + if (level >= 15) return; + for (int y = 0; y < height; ++y) { + uint32_t *row = dst + (size_t)y * (size_t)stride; + for (int x = 0; x < width; ++x) { + const uint32_t p = row[x]; + const uint32_t r = ((p >> 16) & 0xFF) * level / 15; + const uint32_t g = ((p >> 8) & 0xFF) * level / 15; + const uint32_t b = (p & 0xFF) * level / 15; + row[x] = (p & 0xFF000000u) | (r << 16) | (g << 8) | b; + } + } +} + +static void DrawPanel(uint32_t *dst, int stride, int width, int height); + +void SmPauseOptions_Render(uint32_t *dst, int stride, int width, int height) { + if (!dst || stride <= 0 || height <= 0 || !SmPauseOptions_IsActive()) return; + if (width > stride) width = stride; + DrawPanel(dst, stride, width, height); + if (Leaving()) + DimToScreenBrightness(dst, stride, width, height); +} + +static void DrawPanel(uint32_t *dst, int stride, int width, int height) { + + /* Opaque backdrop: the guest draws nothing meaningful on this tab (it is + * parked on its Map screen), so cover the whole game rect. */ + snes_ovl_fill_rect(dst, stride, height, 0, 0, width, height, COL_BG); + snes_ovl_stroke_rect(dst, stride, height, 5, 5, width - 10, height - 10, COL_BORDER); + + /* Tab strip: MAP / EQUIP / OPTIONS, with the current tab lit. */ + { + int y = 10; + snes_ovl_draw_text(dst, stride, height, 18, y, "MAP", COL_DIM, 1); + snes_ovl_draw_text(dst, stride, height, 18 + 4 * 8, y, "EQUIP", COL_DIM, 1); + int ox = 18 + (4 + 6) * 8; + snes_ovl_fill_rect(dst, stride, height, ox - 3, y - 3, 8 * 8 + 6, 14, COL_SELBG); + snes_ovl_draw_text(dst, stride, height, ox, y, "OPTIONS", COL_TABON, 1); + } + + snes_ovl_fill_rect(dst, stride, height, 10, 24, width - 20, 1, COL_BORDER); + DrawCentered(dst, stride, height, width, 30, "GAME OPTIONS", COL_TITLE, 1); + snes_ovl_fill_rect(dst, stride, height, 10, 42, width - 20, 1, COL_BORDER); + + /* Rows, as the model reports them. The 8x8 font is upper-case only, so + * "On" and "Fit" draw as ON and FIT. Eleven rows at 13px pitch from y=50 + * reach 180, leaving room for the hint before the frame's bottom edge. */ + { + const size_t count = recomp_runtime_ui_view_row_count(g_ui); + const size_t selected = recomp_runtime_ui_view_selected_row(g_ui); + int row_y = 50; + const int row_h = 13; + for (size_t i = 0; i < count; ++i, row_y += row_h) { + RecompRuntimeUiRowView row; + if (!recomp_runtime_ui_view_row(g_ui, i, &row) || !row.item) continue; + const bool sel = i == selected; + /* HUD anchoring sits indented under the Widescreen row it depends on. */ + const int indent = !strcmp(row.item->key, "hud") ? 4 * 8 : 0; + uint32_t label_col = sel ? COL_SEL : (row.enabled ? COL_TEXT : COL_DIM); + uint32_t value_col = sel ? COL_ACCENT : (row.enabled ? COL_TEXT : COL_DIM); + if (sel) + snes_ovl_fill_rect(dst, stride, height, 14 + indent, row_y - 2, + width - 28 - indent, 13, COL_SELBG); + snes_ovl_draw_text(dst, stride, height, 22 + indent, row_y, + row.label, label_col, 1); + if (row.item->type != RECOMP_RUNTIME_UI_ACTION && row.value[0]) + snes_ovl_draw_text(dst, stride, height, + width - 22 - indent - TextLen(row.value) * 8, row_y, + row.value, value_col, 1); + } + } + + /* Hint row. */ + { + int y = height - 20; + snes_ovl_fill_rect(dst, stride, height, 10, y - 6, width - 20, 1, COL_BORDER); + snes_ovl_draw_text(dst, stride, height, 14, y, "A SELECT", COL_ACCENT, 1); + snes_ovl_draw_text(dst, stride, height, 14 + 10 * 8, y, "B BACK", COL_ACCENT, 1); + snes_ovl_draw_text(dst, stride, height, 14 + 17 * 8, y, "L/R", COL_ACCENT, 1); + snes_ovl_draw_text(dst, stride, height, 14 + 21 * 8, y, "TABS", COL_DIM, 1); + } +} diff --git a/src/sm_pause_options.h b/src/sm_pause_options.h new file mode 100644 index 0000000..3f8b016 --- /dev/null +++ b/src/sm_pause_options.h @@ -0,0 +1,68 @@ +#pragma once + +/* + * Options -- the third pause-menu tab (Map, Equipment, Options). + * + * Super Metroid's pause menu has room for exactly two tabs. Its L/R handler + * hard-codes the Map<->Equipment pair, and its text is pre-composed tilemap + * data DMA'd out of the ROM (EquipmentScreenTransferBG1Tilemap -> ram3800 + * copies fixed blobs), with no string compositor to hang a new screen off. + * So the third tab is a host panel: + * + * - The rows are a recomp-ui runtime-UI model (recomp_runtime_ui.h): it + * owns navigation, disabled rows and persistence. They edit g_sm_video + * (the same settings the launcher's Mods page edits, saved to + * SM_VIDEO_CONFIG_FILE) and, through host_api.h, the framework's own + * live display settings: fullscreen, vsync, frame blending, run-ahead + * and window scale. Those apply to the running window immediately, and + * the host writes config.ini at shutdown so they survive the restart + * and the launcher opens on what the player left. An Exit row asks the + * host to end the run gracefully (snesrecomp_request_exit): the host + * writes any config the panel dirtied and shuts down on its next loop + * pass, after the requesting frame has presented. + * - This module draws that model in the pause screen's style with the + * framework's 8x8-font overlay primitives (snes_overlay_draw.h). + * - SmPauseOptions_GuestTick() is the only guest-facing part. It runs at + * the entry of GameState_15_Paused_Async ($82:90E8, see + * HleSmGameState_15_Paused_Async), before the ROM's own routine, in both + * execution modes. While the panel is up the guest stays on its own Map + * screen (menu_index 0) and every button but Start is removed from the + * pad, so the ROM sees only Start (unpause). + * - Leaving is the ROM's own: R/B/"Back to map" start PauseMenu_5's + * fade-out to the Map, L starts PauseMenu_2's to Equipment, Start the + * unpause fade. The panel stays up through the fade, dimmed with the + * screen, and closes when the guest reaches the next state. + * + * Whether the panel is up changes what the guest does next frame, so it is + * part of every snapshot (SmStateSaveExtra / SmExecStateSave). Whether it is + * leaving is read from guest RAM. A file load reopens on the first row; + * execution snapshots preserve the cursor so rollback cannot keep navigation + * performed by a speculative frame. + */ + +#include +#include + +/* One paused frame: read joypad1_newkeys, drive the panel, and open or close + * it on L/R. Must run before the ROM's ReleaseButtonsFilter for the frame. */ +void SmPauseOptions_GuestTick(void); + +/* Opening resets the cursor to the first row; closing hides the panel. */ +void SmPauseOptions_SetActive(bool active); +bool SmPauseOptions_IsActive(void); + +/* Execution-snapshot cursor. File loads deliberately reopen on row zero. */ +unsigned SmPauseOptions_Cursor(void); +bool SmPauseOptions_CursorValid(unsigned row); +void SmPauseOptions_RestoreCursor(unsigned row); + +/* Close the panel once the guest has moved on (the fade it was leaving + * through is over, or the game left the paused states). Called before each + * frame and from the tick. */ +void SmPauseOptions_SyncToGuest(void); + +/* Whether to composite the panel over this frame. */ +bool SmPauseOptions_Visible(void); + +/* Composite the panel over an ARGB8888 frame. stride is in pixels. */ +void SmPauseOptions_Render(uint32_t *dst, int stride, int width, int height); diff --git a/src/sm_replay.c b/src/sm_replay.c index 8c6813d..541ee08 100644 --- a/src/sm_replay.c +++ b/src/sm_replay.c @@ -3,6 +3,7 @@ * All checkpoint serialization happens on the simulation thread at a boundary. */ #include "sm_replay.h" +#include "sm_activity.h" #include "sm_video.h" #include "diagnostics/replay_writer.h" #include "common_rtl.h" @@ -178,7 +179,7 @@ static bool start_segment(void) { segment_bytes=0; snprintf(path,sizeof(path),"%s-%06u.replay",stem,segment); char header[512]; - snprintf(header,sizeof(header),"SMREPLAY 1\nBUILD %s %s %s %s\nROM %08x\nMODE lle\n", + snprintf(header,sizeof(header),"SMREPLAY 2\nBUILD %s %s %s %s\nROM %08x\nMODE lle\n", SNESRECOMP_BUILD_VERSION,SM_REPLAY_REVISION,SM_REPLAY_ENGINE,SM_REPLAY_TARGET,rom_identity); if(!SnesReplayWriterBegin(writer,path,header,config_path,"mods/preloaded/state.toml", "Super Metroid presentation mods and effective renderer settings are recorded in every input record.\n")) { @@ -221,7 +222,7 @@ static bool replay_floor_active(void) { static bool load_playback(void) { char line[512], version[80], revision[80], engine[80], target[80], tail; unsigned rom; - if(!fgets(line,sizeof(line),stream) || strcmp(line,"SMREPLAY 1\n") || + if(!fgets(line,sizeof(line),stream) || strcmp(line,"SMREPLAY 2\n") || !fgets(line,sizeof(line),stream) || sscanf(line,"BUILD %79s %79s %79s %79s %c",version,revision,engine,target,&tail)!=4 || !fgets(line,sizeof(line),stream) || sscanf(line,"ROM %x %c",&rom,&tail)!=1 || !fgets(line,sizeof(line),stream) || strcmp(line,"MODE lle\n")) { @@ -266,12 +267,13 @@ static bool playback_begin(uint32_t *inputs) { } next_frame=(unsigned)snes_frame_counter; } else if(!playback_rows) {error("first input has no machine checkpoint");return false;} - unsigned frame, word, flags, aspect, fps; - if(sscanf(line,"I %u %x %x %u %u %c",&frame,&word,&flags,&aspect,&fps,&tail)!=5 || - flags>63 || aspect>=SM_ASPECT_COUNT || !SmValidFps(fps) || frame!=next_frame) { + unsigned frame, word, flags, aspect, fps, extra; + if(sscanf(line,"I %u %x %x %u %u %u %c",&frame,&word,&flags,&aspect,&fps,&extra,&tail)!=6 || + flags>63 || aspect>=SM_ASPECT_COUNT || !SmValidFps(fps) || !SmActivityExtraValid(extra) || frame!=next_frame) { error("invalid input/settings record or discontinuity without checkpoint");return false; } g_sm_video=(SmVideoSettings){(flags&1)!=0,(SmAspect)aspect,fps,(flags&2)!=0,(flags&4)!=0}; + SmActivityRestore(extra); snesrecomp_desktop_set_new_renderer((flags&8)!=0); g_config.no_sprite_limits=(flags&16)!=0; if(flags&32)RtlEnableExtendedFrameTiming(); if(!!(flags&32)!=rtl_apu_extended_frame_timing()) {error("different frame timing mode");return false;} @@ -299,8 +301,8 @@ bool SmReplayBegin(uint32_t *inputs) { if((!rows || discontinuity) && !write_snapshot())return true; generation=RtlStateGeneration(); recorded_load_sequence=load_sequence; char input[160]; - snprintf(input,sizeof(input),"I %u %08x %02x %u %u\n",frame,*inputs,policy(), - (unsigned)g_sm_video.aspect,g_sm_video.fps); + snprintf(input,sizeof(input),"I %u %08x %02x %u %u %u\n",frame,*inputs,policy(), + (unsigned)g_sm_video.aspect,g_sm_video.fps,SmActivityExtra()); if(!SnesReplayWriterLine(writer,input)) {error(SnesReplayWriterError(writer));return true;} next_frame=frame+1; pending=true; return true; /* Optional diagnostics never stop gameplay. */ diff --git a/src/sm_rtl.c b/src/sm_rtl.c index 1f78a14..73b8e36 100644 --- a/src/sm_rtl.c +++ b/src/sm_rtl.c @@ -1,7 +1,9 @@ #include "sm_rtl.h" #include "sm_renderer.h" #include "sm_spc_player.h" +#include "sm_pause_options.h" #include "sm_replay.h" +#include "sm_activity.h" #include "variables.h" #include "common_cpu_infra.h" #include "snes/snes.h" @@ -91,7 +93,9 @@ static int sm_rtl_diag_enabled(void) { * pointer-free execution residue, never a serialized host fiber or C stack. * No host stack or pointer is part of a file snapshot. */ #define SM_FILE_STATE_MAGIC 0x534D4653u /* SMFS */ -static uint32_t g_loaded_file_state[7]; +#define SM_FILE_STATE_VERSION 4u +static uint32_t g_loaded_file_state[8]; +static uint32_t g_loaded_pause_options; static bool g_loaded_file_state_valid; const char *SmSnapshotIdentity(void) { @@ -106,28 +110,32 @@ const char *SmSnapshotIdentity(void) { * extension before the common loader writes any guest bytes as well. The * canonical SM cartridge has no Cx4 execution-clock suffix. */ bool SmSnapshotPreflight(const void *data, size_t size) { - uint32_t version, header[7], stream_version, execution_size; + uint32_t version, header[8], stream_version, execution_size, pause_options; size_t guest = RtlSnapshotGuestSize(&version); - if (!data || size < 8 || !guest || guest > size || size - guest < sizeof(header) + 4) + if (!data || size < 8 || !guest || guest > size || size - guest < sizeof(header) + 8) return false; memcpy(&stream_version, (const uint8_t *)data + 4, 4); memcpy(header, (const uint8_t *)data + guest, sizeof(header)); - if (stream_version != version || header[0] != SM_FILE_STATE_MAGIC || header[1] != 2 || + if (stream_version != version || header[0] != SM_FILE_STATE_MAGIC || header[1] != SM_FILE_STATE_VERSION || header[2] > 0xFFFFFFu || header[3] > 1 || header[4] > 1 || - header[6] != SmSpcPlayer_StateSize()) return false; - memcpy(&execution_size, (const uint8_t *)data + guest + sizeof(header), 4); - size_t tail = size - guest - sizeof(header) - 4; + header[6] != SmSpcPlayer_StateSize() || !SmActivityExtraValid(header[7])) return false; + memcpy(&pause_options, (const uint8_t *)data + guest + sizeof(header), 4); + if (pause_options > 1) return false; + memcpy(&execution_size, (const uint8_t *)data + guest + sizeof(header) + 4, 4); + size_t tail = size - guest - sizeof(header) - 8; return execution_size >= 8 && execution_size <= tail && tail - execution_size == header[6]; } void SmStateSaveExtra(struct SaveLoadInfo *sli) { - uint32_t header[7] = { - SM_FILE_STATE_MAGIC, 2, g_lle_resume_pc, + uint32_t header[8] = { + SM_FILE_STATE_MAGIC, SM_FILE_STATE_VERSION, g_lle_resume_pc, g_game_started ? 1u : 0u, g_game_done ? 1u : 0u, - counter_global_frames, (uint32_t)SmSpcPlayer_StateSize() + counter_global_frames, (uint32_t)SmSpcPlayer_StateSize(), SmActivityExtra() }; sli->func(sli, header, sizeof(header)); + uint32_t pause_options = SmPauseOptions_IsActive() ? 1u : 0u; + sli->func(sli, &pause_options, sizeof(pause_options)); RtlSaveExecutionState(sli); if (header[6]) { void *spc = malloc(header[6]); @@ -145,12 +153,14 @@ void SmStateLoadExtra(struct SaveLoadInfo *sli, uint32_t version) { g_loaded_file_state_valid = false; sli->func(sli, g_loaded_file_state, sizeof(g_loaded_file_state)); const uint32_t *h = g_loaded_file_state; - if (h[0] != SM_FILE_STATE_MAGIC || h[1] != 2 || h[2] > 0xFFFFFFu || - h[3] > 1 || h[4] > 1 || h[6] != SmSpcPlayer_StateSize()) { + if (h[0] != SM_FILE_STATE_MAGIC || h[1] != SM_FILE_STATE_VERSION || h[2] > 0xFFFFFFu || + h[3] > 1 || h[4] > 1 || h[6] != SmSpcPlayer_StateSize() || !SmActivityExtraValid(h[7])) { fprintf(stderr, "[sm_state] unrecognized execution-state extension\n"); RtlStateStreamFail(sli); return; } + sli->func(sli, &g_loaded_pause_options, sizeof(g_loaded_pause_options)); + if (g_loaded_pause_options > 1) { RtlStateStreamFail(sli); return; } if (!RtlLoadExecutionState(sli)) { fprintf(stderr, "[sm_state] execution residue does not match this runtime\n"); RtlStateStreamFail(sli); @@ -180,6 +190,9 @@ void SmOnStateLoaded(uint32_t version) { g_game_started = g_loaded_file_state[3] != 0; g_game_done = g_loaded_file_state[4] != 0; counter_global_frames = (uint16_t)g_loaded_file_state[5]; + SmActivityRestore(g_loaded_file_state[7]); + SmPauseOptions_SetActive(false); + SmPauseOptions_SetActive(g_loaded_pause_options != 0); g_loaded_file_state_valid = false; } @@ -189,10 +202,11 @@ void SmOnStateLoaded(uint32_t version) { typedef struct SmExecHeader { uint32_t magic, version, resume_pc, spc_bytes; uint16_t frames; - uint8_t started, done; + uint8_t started, done, pause_options, selected_row; + uint16_t activity_extra; } SmExecHeader; #define SM_EXEC_MAGIC 0x534D4558u -#define SM_EXEC_VERSION 2u +#define SM_EXEC_VERSION 5u size_t SmExecStateBound(void) { return sizeof(SmExecHeader) + SmSpcPlayer_StateSize(); @@ -207,6 +221,9 @@ size_t SmExecStateSave(void *out, size_t capacity) { hdr.frames = counter_global_frames; hdr.started = g_game_started; hdr.done = g_game_done; + hdr.pause_options = SmPauseOptions_IsActive(); + hdr.selected_row = (uint8_t)SmPauseOptions_Cursor(); + hdr.activity_extra = (uint16_t)SmActivityExtra(); hdr.spc_bytes = (uint32_t)SmSpcPlayer_SaveState( (uint8_t *)out + sizeof(hdr), capacity - sizeof(hdr)); if (hdr.spc_bytes != SmSpcPlayer_StateSize()) return 0; @@ -219,13 +236,19 @@ int SmExecStateLoad(const void *in, size_t size) { if (!in || size < sizeof(hdr)) return 0; memcpy(&hdr, in, sizeof(hdr)); if (hdr.magic != SM_EXEC_MAGIC || hdr.version != SM_EXEC_VERSION || - hdr.resume_pc > 0xFFFFFFu || hdr.started > 1 || hdr.done > 1 || + hdr.resume_pc > 0xFFFFFFu || hdr.started > 1 || hdr.done > 1 || hdr.pause_options > 1 || + !SmPauseOptions_CursorValid(hdr.selected_row) || + (!hdr.pause_options && hdr.selected_row) || + !SmActivityExtraValid(hdr.activity_extra) || hdr.spc_bytes != SmSpcPlayer_StateSize() || size - sizeof(hdr) < hdr.spc_bytes) return 0; if (hdr.spc_bytes && !SmSpcPlayer_LoadState( (const uint8_t *)in + sizeof(hdr), hdr.spc_bytes)) return 0; g_lle_resume_pc = hdr.resume_pc; counter_global_frames = hdr.frames; + SmActivityRestore(hdr.activity_extra); + SmPauseOptions_SetActive(hdr.pause_options != 0); + SmPauseOptions_RestoreCursor(hdr.selected_row); g_game_started = hdr.started != 0; g_game_done = hdr.done != 0; return 1; diff --git a/src/sm_video.h b/src/sm_video.h index bde0ab1..745ef7c 100644 --- a/src/sm_video.h +++ b/src/sm_video.h @@ -25,6 +25,10 @@ typedef struct SmVideoSettings { extern SmVideoSettings g_sm_video; +/* Where g_sm_video lives beside the executable. The launcher's Mods page and + * the pause menu's Options tab both write it. */ +#define SM_VIDEO_CONFIG_FILE "sm-video.ini" + typedef struct SmViewport { int width, extra; double aspect; diff --git a/src/variables.h b/src/variables.h index 0cbaa64..2691867 100644 --- a/src/variables.h +++ b/src/variables.h @@ -19,9 +19,37 @@ extern uint16 counter_global_frames; /* Super Metroid's vblank handshake lives at $7E:05B4 (decomp: * `waiting_for_nmi`, src/variables.h `g_ram+0x5B4`). WaitForNMI * ($80:8338) sets it to 1 and spins until the NMI handler ($80:9583, - * Vector_NMI) clears it. In the recompiled build there is no mid-frame - * NMI, so that spin never terminates — WaitForNMI is HLE-replaced by a - * host-fiber yield (HleSmWaitForNmi -> sm_host_yield in sm_rtl.c). */ + * Vector_NMI) clears it. The interruptible scheduler yields at this polling + * boundary, then injects NMI before resuming the original guest bytes. */ #define waiting_for_nmi (*(uint8*)(g_ram + 0x05B4)) +/* Pad state written by ReadJoypadInputs ($80:9459): lastkeys is this frame's + * held sample, newkeys the newly-pressed edge. ReleaseButtonsFilter + * ($80:8146) derives newly_held_down_timed_held_input from them, and the + * pause screens read that. */ +#define joypad1_lastkeys (*(uint16*)(g_ram + 0x008B)) +#define joypad1_newkeys (*(uint16*)(g_ram + 0x008F)) +#define newly_held_down_timed_held_input (*(uint16*)(g_ram + 0x05E1)) + +/* Pause menu. menu_index selects kPauseMenuFuncs ($82:90FF); + * pausemenu_button_label_mode is the lit L/R label, which the Load/FadeIn + * states also read to pick the screen they land on (0 Map, 2 Equipment). */ +#define menu_index (*(uint16*)(g_ram + 0x0727)) +#define pausemenu_button_label_mode (*(uint16*)(g_ram + 0x0753)) + +/* Button bits in joypad1_newkeys/lastkeys: the SNES auto-joypad order the ROM + * reads (decomp: src/ida_types.h `enum Buttons`). */ +#define kButton_R 0x0010 +#define kButton_L 0x0020 +#define kButton_X 0x0040 +#define kButton_A 0x0080 +#define kButton_Right 0x0100 +#define kButton_Left 0x0200 +#define kButton_Down 0x0400 +#define kButton_Up 0x0800 +#define kButton_Start 0x1000 +#define kButton_Select 0x2000 +#define kButton_Y 0x4000 +#define kButton_B 0x8000 + #endif /* VARIABLES_H */ diff --git a/tests/replay_diagnostic_test.py b/tests/replay_diagnostic_test.py index b9f45e5..13b6ce0 100644 --- a/tests/replay_diagnostic_test.py +++ b/tests/replay_diagnostic_test.py @@ -59,6 +59,16 @@ def test_bad_settings_or_missing_execution(self): with self.assertRaises(ValueError): replay.inspect(self.path) + def test_simulation_viewport(self): + self.header = self.header.replace("SMREPLAY 1", "SMREPLAY 2") + for extra in (0, 43, 96, 213, 214): + self.write(self.input.rstrip() + f" {extra}\n" + self.complete) + replay.inspect(self.path) + for extra in (-1, 215, 65535): + self.write(self.input.rstrip() + f" {extra}\n" + self.complete) + with self.assertRaisesRegex(ValueError, "simulation viewport"): + replay.inspect(self.path) + if __name__ == "__main__": unittest.main() diff --git a/tests/sm_activity_test.c b/tests/sm_activity_test.c new file mode 100644 index 0000000..86a881c --- /dev/null +++ b/tests/sm_activity_test.c @@ -0,0 +1,49 @@ +#include "sm_activity.h" +#ifdef NDEBUG +#undef NDEBUG +#endif +#include +#include +#include +static unsigned char ram[0x20000], before[sizeof(ram)]; +static void put(unsigned at, unsigned value) { ram[at]=value;ram[at+1]=value>>8; } +static unsigned get(unsigned at) { return ram[at] | ram[at+1]<<8; } +static void enemy(unsigned i, int x, unsigned properties) { + unsigned at=0xf78+i*64; + put(at,0xd4bf);put(at+2,1000+x);put(at+6,100);put(at+10,8);put(at+14,properties); +} +static void setup(void) { + memset(ram,0,sizeof(ram));put(0x911,1000);put(0x915,0); + enemy(7,128,0);put(0x17a4,2);put(0x17a6,2); + put(0x17ac,7*64);put(0x17ae,0xffff);put(0x17ec,7*64);put(0x17ee,0xffff); +} +int main(void) { + const unsigned widths[]={43,96,213,214}; + for(unsigned k=0;k<4;k++) { + unsigned extra=widths[k];setup(); + enemy(1,-(int)extra,0);enemy(8,256+extra,0x400); + enemy(10,-(int)extra-9,0);enemy(11,256+extra+9,0); + enemy(12,275,0x200);enemy(13,275,0);put(0xf78+13*64,0xdaff); + enemy(14,275,0);put(0xf78+14*64+6,249); + memcpy(before,ram,sizeof(ram)); + assert(SmActivityAugmentQueues(ram,0));assert(!memcmp(before,ram,sizeof(ram))); + assert(SmActivityAugmentQueues(ram,extra)); + assert(get(0x17a4)==6 && get(0x17a6)==4); + assert(get(0x17ac)==64 && get(0x17ae)==448 && get(0x17b0)==512 && get(0x17b2)==0xffff); + assert(get(0x17ec)==64 && get(0x17ee)==448 && get(0x17f0)==0xffff); + assert(!memcmp(ram+0xf78,before+0xf78,2048)); /* properties never forged */ + memcpy(before,ram,sizeof(ram));assert(SmActivityAugmentQueues(ram,extra)); + assert(!memcmp(before,ram,sizeof(ram))); /* idempotent */ + } + setup();put(0x183c,1);enemy(1,-20,0);memcpy(before,ram,sizeof(ram)); + assert(SmActivityAugmentQueues(ram,214));assert(!memcmp(before,ram,sizeof(ram))); + setup();for(unsigned i=0;i<31;i++)enemy(i,275,0); + assert(SmActivityAugmentQueues(ram,43));assert(get(0x17a4)==62 && get(0x17ea)==0xffff); + setup();for(unsigned i=0;i<32;i++)enemy(i,275,0);memcpy(before,ram,sizeof(ram)); + assert(!SmActivityAugmentQueues(ram,43));assert(!memcmp(before,ram,sizeof(ram))); + setup();for(unsigned i=0;i<32;i++)enemy(i,275,0x400); + assert(SmActivityAugmentQueues(ram,43));assert(get(0x17a4)==64 && get(0x17a6)==0 && get(0x17ec)==0xffff); + memcpy(before,ram,sizeof(ram)); + assert(!SmActivityAugmentQueues(ram,215));assert(!memcmp(before,ram,sizeof(ram))); + puts("PASS: widened queue boundaries, slot order, exclusions, interaction, native identity and capacity"); +} diff --git a/tests/sm_pause_options_test.c b/tests/sm_pause_options_test.c new file mode 100644 index 0000000..e632c43 --- /dev/null +++ b/tests/sm_pause_options_test.c @@ -0,0 +1,361 @@ +/* The pause menu's Options tab (src/sm_pause_options.c): what one paused + * frame does to the guest's pause state and pad, and the panel's pixels. + * + * The render hashes are a golden picture of the open panel at its eleven-row + * layout (five SM video settings, five framework display settings, a + * graceful Exit, and Back) with the navigation the test has driven. A + * change to them is a visible change to the panel. + * + * The framework's live-settings API (host_api.h) is faked here: the fakes + * apply the same clamps the host's setters do and mark g_config_dirty, as + * the host does before its shutdown config.ini write. No SDL runs. + */ +#include "sm_pause_options.h" +#include "sm_video.h" +#include "types.h" +#include "host_api.h" + +#include +#include + +uint8 g_ram[0x20000]; +#include "variables.h" + +#define game_state_byte g_ram[0x0998] +#define inidisp_byte g_ram[0x0051] + +/* host_api.h fakes. The host applies the same clamps (runner/src/desktop/ + * host_main.c); the defaults match the shipped config.ini: vsync on, window + * scale 3, everything else off. */ +bool g_config_dirty; +bool f_host_live = true; +bool snesrecomp_host_frame_is_live(void) { return f_host_live; } +bool f_exit_requested; +int f_fullscreen; /* 0 = windowed, 1 = borderless */ +int f_vsync = 1; /* 0 = off, 1 = on, 2 = adaptive */ +int f_frame_blend; +int f_run_ahead; +int f_window_scale = 3; + +void snesrecomp_set_fullscreen(int mode) { + if (mode < 0) mode = 0; + if (mode > 1) mode = 1; + f_fullscreen = mode; + g_config_dirty = true; +} +int snesrecomp_get_fullscreen(void) { return f_fullscreen; } + +void snesrecomp_set_vsync(int mode) { + if (mode < 0) mode = 0; + if (mode > 2) mode = 2; + f_vsync = mode; + g_config_dirty = true; +} +int snesrecomp_get_vsync(void) { return f_vsync; } + +void snesrecomp_set_frame_blend(int enabled) { + f_frame_blend = enabled != 0; + g_config_dirty = true; +} +int snesrecomp_get_frame_blend(void) { return f_frame_blend; } + +void snesrecomp_set_run_ahead(int frames) { + if (frames < 0) frames = 0; + f_run_ahead = frames; + g_config_dirty = true; +} +int snesrecomp_get_run_ahead(void) { return f_run_ahead; } + +void snesrecomp_set_window_scale(int scale) { + if (scale < 1) scale = 1; + if (scale > 10) scale = 10; + f_window_scale = scale; + g_config_dirty = true; +} +int snesrecomp_get_window_scale(void) { return f_window_scale; } + +/* The host ends the run after the requesting frame; the fake only latches, + * so the test can keep driving the model afterwards. */ +void snesrecomp_request_exit(void) { if (f_host_live) f_exit_requested = true; } + +static uint32_t g_frame[684 * 224]; + +static uint32_t RenderHash(int width) { + for (size_t i = 0; i < sizeof(g_frame) / sizeof(g_frame[0]); ++i) + g_frame[i] = 0xFF123456u; + SmPauseOptions_Render(g_frame, width, width, 224); + uint32_t h = 2166136261u; + for (size_t i = 0; i < (size_t)width * 224; ++i) { h ^= g_frame[i]; h *= 16777619u; } + return h; +} + +/* A golden-pixel check. In a calibration build (PAUSE_OPTIONS_TEST_CALIBRATE, + * compiled by hand after a layout or row change) it prints the actual hash + * instead of asserting, so the constants below can be regenerated. */ +#ifndef PAUSE_OPTIONS_TEST_CALIBRATE +#define PAUSE_OPTIONS_TEST_CALIBRATE 0 +#endif + +static size_t g_hash_seq; + +static void HashCheck(int width, uint32_t expected) { + const uint32_t actual = RenderHash(width); +#if PAUSE_OPTIONS_TEST_CALIBRATE + printf("hash %zu width=%d actual=0x%08X (source 0x%08X)\n", + ++g_hash_seq, width, actual, expected); +#else + assert(actual == expected); +#endif +} + +/* One paused frame with `pressed` newly down (and `held` also down). */ +static void Frame(uint16 pressed, uint16 held) { + joypad1_newkeys = pressed; + joypad1_lastkeys = (uint16)(pressed | held); + newly_held_down_timed_held_input = pressed; + SmPauseOptions_GuestTick(); +} + +static void OnMap(void) { menu_index = 0; pausemenu_button_label_mode = 0; } + +/* The ROM finishing a fade-out: it moves on to the Load state. */ +static void FadeOutDone(void) { menu_index = (uint16)(menu_index + 1); } + +int main(void) { + SmVideoDefaults(&g_sm_video); + g_sm_video.hud_anchored = false; + assert(!g_sm_video.enhanced && !g_sm_video.fps_enabled); + game_state_byte = 0x0F; + inidisp_byte = 0x0F; + + /* L on Map opens the panel. While it is up it owns the pad: held buttons + * are swallowed too, so the hidden Map screen sees none of them. */ + OnMap(); + Frame(kButton_L, kButton_Y | kButton_Down); + assert(SmPauseOptions_IsActive() && SmPauseOptions_Visible() && menu_index == 0); + assert(joypad1_newkeys == 0 && newly_held_down_timed_held_input == 0); + assert(joypad1_lastkeys == 0); + Frame(0, kButton_Down); + assert(joypad1_lastkeys == 0); + /* Opened on Widescreen at the defaults: sm-video defaults, and the faked + * framework defaults (windowed, vsync on, no blend, no run-ahead, scale 3). */ + HashCheck(256, 0x3606AAC9u); + HashCheck(342, 0x99C47AB9u); + + /* Speculative/rollback UI may navigate for its picture, but cannot change + * live host settings or persist the proposed values. Rollback restores the + * cursor separately, even when the panel remains open throughout. */ + f_host_live = false; + Frame(kButton_Right, 0); + assert(!g_sm_video.enhanced && !g_sm_video.hud_anchored); + Frame(kButton_Down, 0); /* FPS: HUD disabled. */ + Frame(kButton_Down, 0); /* Aspect. */ + Frame(kButton_Down, 0); /* Fullscreen. */ + assert(SmPauseOptions_Cursor() == 4); + Frame(kButton_Right, 0); + assert(f_fullscreen == 0 && !g_config_dirty); + Frame(kButton_Down, 0); + Frame(kButton_Down, 0); + assert(SmPauseOptions_Cursor() == 6); + SmPauseOptions_RestoreCursor(4); + assert(SmPauseOptions_Cursor() == 4); + assert(SmPauseOptions_CursorValid(10) && !SmPauseOptions_CursorValid(11)); + SmPauseOptions_RestoreCursor(255); + assert(SmPauseOptions_Cursor() == 4); + SmPauseOptions_RestoreCursor(1); /* Disabled HUD chooses the next row. */ + assert(SmPauseOptions_Cursor() == 2); + SmPauseOptions_RestoreCursor(0); + f_host_live = true; + HashCheck(456, 0x34570E29u); + HashCheck(684, 0xAEA431B9u); + + /* HUD anchoring is disabled without widescreen, so Down skips it. */ + Frame(kButton_Down, 0); /* Presentation FPS */ + HashCheck(256, 0x92F73515u); + HashCheck(342, 0xF578B28Du); + Frame(kButton_Down, 0); /* Aspect ratio */ + Frame(kButton_Right, 0); /* Fit wraps to 4:3 */ + assert(g_sm_video.aspect == SM_ASPECT_STOCK); + HashCheck(256, 0xB71F48D9u); + HashCheck(684, 0x618DF429u); + Frame(kButton_Right, 0); + Frame(kButton_Left, 0); + assert(g_sm_video.aspect == SM_ASPECT_STOCK); + Frame(kButton_A, 0); /* accept steps a choice forward */ + assert(g_sm_video.aspect == SM_ASPECT_16_9); + HashCheck(256, 0x039665DFu); + HashCheck(342, 0x4DA19B87u); + + /* Turning widescreen on brings HUD anchoring up, from either direction. */ + Frame(kButton_Up, 0); /* Presentation FPS */ + Frame(kButton_Up, 0); /* skips the disabled HUD row to Widescreen */ + Frame(kButton_Left, 0); + assert(g_sm_video.enhanced && g_sm_video.hud_anchored); + Frame(kButton_Down, 0); /* HUD anchoring, now reachable */ + HashCheck(256, 0xC882F42Fu); + HashCheck(342, 0xB09568FFu); + Frame(kButton_A, 0); + assert(!g_sm_video.hud_anchored && g_sm_video.enhanced); + + /* Down leaves the SM rows for the framework's: Fullscreen, Windowed. */ + Frame(kButton_Down, 0); /* Presentation FPS */ + Frame(kButton_Down, 0); /* Aspect ratio */ + Frame(kButton_Down, 0); /* Fullscreen */ + HashCheck(256, 0xB9730931u); + HashCheck(684, 0x73DAB5B1u); + Frame(kButton_Right, 0); /* Borderless */ + assert(f_fullscreen == 1 && g_config_dirty); + HashCheck(256, 0x86C66586u); + + /* VSync is a three-way choice that wraps at both ends. */ + Frame(kButton_Down, 0); /* VSync */ + Frame(kButton_Right, 0); + assert(f_vsync == 2 && g_config_dirty); /* Adaptive */ + Frame(kButton_Right, 0); + assert(f_vsync == 0); /* Off */ + Frame(kButton_Right, 0); + assert(f_vsync == 1); /* back to On */ + HashCheck(256, 0x82D35CFCu); + + /* Frame blend toggles on a fresh press, either direction. */ + Frame(kButton_Down, 0); /* Frame blend */ + Frame(kButton_A, 0); + assert(f_frame_blend == 1 && g_config_dirty); + Frame(kButton_A, 0); + assert(f_frame_blend == 0); + HashCheck(256, 0x5EA3F98Eu); + + /* Run ahead counts 0..4 and stops at the ends. */ + Frame(kButton_Down, 0); /* Run ahead */ + Frame(kButton_Right, 0); + Frame(kButton_Right, 0); + Frame(kButton_Right, 0); + Frame(kButton_Right, 0); + assert(f_run_ahead == 4); + Frame(kButton_Right, 0); + assert(f_run_ahead == 4); + HashCheck(256, 0x6F38AA28u); + Frame(kButton_Left, 0); + assert(f_run_ahead == 3); + Frame(kButton_Left, 0); + Frame(kButton_Left, 0); + Frame(kButton_Left, 0); + Frame(kButton_Left, 0); + assert(f_run_ahead == 0); + Frame(kButton_Left, 0); + assert(f_run_ahead == 0); + + /* Still borderless, so Window scale is disabled: navigation skips it and + * lands on Exit. */ + Frame(kButton_Down, 0); /* Run ahead -> Exit */ + HashCheck(256, 0x3DC36EF0u); + HashCheck(684, 0xB88DA2B0u); + Frame(kButton_A, 0); /* graceful exit */ + assert(f_exit_requested); + HashCheck(256, 0x3DC36EF0u); /* the action changes nothing visible */ + + /* Down to Back, up again to Fullscreen (over the disabled scale row). */ + Frame(kButton_Down, 0); /* Back to map */ + HashCheck(256, 0x6C05F9C4u); + Frame(kButton_Up, 0); + Frame(kButton_Up, 0); + Frame(kButton_Up, 0); + Frame(kButton_Up, 0); + Frame(kButton_Up, 0); /* Fullscreen */ + Frame(kButton_Left, 0); /* Windowed */ + assert(f_fullscreen == 0 && g_config_dirty); + HashCheck(256, 0xB9730931u); + + /* Window scale, enabled again, counts 1..10. */ + Frame(kButton_Down, 0); /* VSync */ + Frame(kButton_Down, 0); /* Frame blend */ + Frame(kButton_Down, 0); /* Run ahead */ + Frame(kButton_Down, 0); /* Window scale */ + Frame(kButton_Right, 0); + Frame(kButton_Right, 0); + assert(f_window_scale == 5); + Frame(kButton_Left, 0); + assert(f_window_scale == 4); + HashCheck(256, 0xD674AAF4u); + HashCheck(684, 0xCBC3DBF4u); + + /* Back: the ROM's Equipment->Map fade-out. The panel stays up, + * dimmed with the screen, takes no input, and closes at the Load. */ + Frame(kButton_Down, 0); /* Exit */ + Frame(kButton_Down, 0); /* Back to map */ + const uint32_t before_leaving = RenderHash(256); + Frame(kButton_A, 0); + assert(SmPauseOptions_IsActive() && SmPauseOptions_Visible()); + assert(menu_index == 5 && pausemenu_button_label_mode == 0); + assert(RenderHash(256) == before_leaving); /* full brightness */ + inidisp_byte = 0x07; + RenderHash(256); + assert(g_frame[0] == 0xFF04060Bu); /* COL_BG 0A0E18 at 7/15 */ + inidisp_byte = 0x80; + RenderHash(256); + assert(g_frame[0] == 0xFF000000u && g_frame[100 * 256 + 100] == 0xFF000000u); + Frame(kButton_Down, kButton_R); + assert(menu_index == 5 && joypad1_newkeys == 0 && joypad1_lastkeys == 0); + FadeOutDone(); + SmPauseOptions_SyncToGuest(); + assert(!SmPauseOptions_IsActive() && !SmPauseOptions_Visible()); + inidisp_byte = 0x0F; + + /* R on Equipment opens it; R is ignored on Map, as the ROM does. */ + menu_index = 1; pausemenu_button_label_mode = 2; + Frame(kButton_R, 0); + assert(SmPauseOptions_IsActive() && menu_index == 0); + /* Start passes through: the ROM's Map screen unpauses on it. */ + Frame(kButton_Start, kButton_A); + assert(joypad1_newkeys == kButton_Start && newly_held_down_timed_held_input == kButton_Start); + assert(joypad1_lastkeys == kButton_Start); + /* L: the ROM's Map->Equipment fade-out. */ + Frame(kButton_L, 0); + assert(SmPauseOptions_IsActive() && menu_index == 2 && pausemenu_button_label_mode == 2); + FadeOutDone(); + Frame(0, 0); + assert(!SmPauseOptions_IsActive() && menu_index == 3); + + OnMap(); + Frame(kButton_R, 0); + assert(!SmPauseOptions_IsActive() && joypad1_newkeys == kButton_R); + Frame(kButton_L, 0); + Frame(kButton_R, 0); + assert(SmPauseOptions_IsActive() && menu_index == 5 && joypad1_newkeys == 0); + FadeOutDone(); + SmPauseOptions_SyncToGuest(); + OnMap(); + Frame(kButton_L, 0); + Frame(kButton_B, 0); + assert(SmPauseOptions_IsActive() && menu_index == 5 && joypad1_newkeys == 0); + FadeOutDone(); + SmPauseOptions_SyncToGuest(); + assert(!SmPauseOptions_IsActive()); + + /* Start: the panel stays up through the unpause fade-out ($10), then goes. */ + OnMap(); + Frame(kButton_L, 0); + game_state_byte = 0x10; + SmPauseOptions_SyncToGuest(); + assert(SmPauseOptions_Visible()); + game_state_byte = 0x11; + SmPauseOptions_SyncToGuest(); + assert(!SmPauseOptions_IsActive() && !SmPauseOptions_Visible()); + game_state_byte = 0x0F; + + /* Snapshots restore the flag; a restored panel opens on its first row. */ + OnMap(); + Frame(kButton_L, 0); + Frame(kButton_Down, 0); + SmPauseOptions_SetActive(false); + SmPauseOptions_SetActive(true); + g_sm_video.enhanced = false; g_sm_video.hud_anchored = false; + g_sm_video.fps_enabled = false; g_sm_video.aspect = SM_ASPECT_FIT; + f_fullscreen = 0; f_vsync = 1; f_frame_blend = 0; f_run_ahead = 0; + f_window_scale = 3; f_exit_requested = false; g_config_dirty = false; + /* The opening picture again. */ + HashCheck(256, 0x3606AAC9u); + + puts("sm_pause_options: ok"); + return 0; +} diff --git a/tests/sm_savestate_gesture_test.c b/tests/sm_savestate_gesture_test.c index 762ccd7..0da7a88 100644 --- a/tests/sm_savestate_gesture_test.c +++ b/tests/sm_savestate_gesture_test.c @@ -22,6 +22,7 @@ void RtlSaveLoad(int t, int slot) { (void)t; (void)slot; } * 2026-09); the gesture under test never reaches them. */ void RtlEnsureSaveDir(void) { } bool RtlSaveSnapshot(const char *filename) { (void)filename; return false; } +bool RtlSaveSnapshotRingBlanked(const char *filename) { (void)filename; return false; } bool RtlLoadSnapshot(const char *filename) { (void)filename; return false; } void RtlSaveSlotPath(int slot, char *buf, size_t buflen) { if (buf && buflen) snprintf(buf, buflen, "/nonexistent/slot%d.sav", slot); diff --git a/tools/replay_diagnostic.py b/tools/replay_diagnostic.py index 388d04c..a448f61 100644 --- a/tools/replay_diagnostic.py +++ b/tools/replay_diagnostic.py @@ -24,11 +24,12 @@ def inspect(path): if path.stat().st_size > 2 * 1024 * 1024: raise ValueError("oversized replay segment") lines = path.read_text(encoding="utf-8").splitlines() - if (len(lines) < 4 or lines[0] != "SMREPLAY 1" or + if (len(lines) < 4 or lines[0] not in ("SMREPLAY 1", "SMREPLAY 2") or len(lines[1].split()) != 5 or not lines[1].startswith("BUILD ") or len(lines[2].split()) != 2 or not lines[2].startswith("ROM ") or lines[3] != "MODE lle"): raise ValueError("unsupported or truncated replay header") + activity = lines[0] == "SMREPLAY 2" if not 0 <= int(lines[2].split()[1], 16) <= 0xffffffff: raise ValueError("invalid ROM identity") state_path = path.with_name(path.name + ".sav") @@ -52,9 +53,11 @@ def inspect(path): checkpoints += 1 checkpoint = True next_frame = None - elif fields[0] == "I" and len(fields) == 6 and not pending and not raster_pending and checkpoint: + elif fields[0] == "I" and len(fields) == (7 if activity else 6) and not pending and not raster_pending and checkpoint: frame, word, flags = int(fields[1]), int(fields[2], 16), int(fields[3], 16) aspect, fps = map(int, fields[4:6]) + if activity and not 0 <= int(fields[6]) <= 214: + raise ValueError("invalid simulation viewport") if not (0 <= frame <= 0xffffffff and 0 <= word <= 0xffffffff and 0 <= flags <= 63 and 0 <= aspect < 5 and fps in (0, 60, 90, 120, 144, 165, 240, 360) and @@ -87,6 +90,11 @@ def inspect(path): def main(): + # A redirected Windows console may use cp1252 while runtime diagnostics + # contain Unicode. Preserve the UTF-8 replay.log and never fail a valid + # reproduction just because its console cannot display one character. + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(errors="replace") parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("replay", type=Path) parser.add_argument("--check", action="store_true", help="validate records and checkpoint data without launching") diff --git a/tools/sm_progression_qa.py b/tools/sm_progression_qa.py index 45aa6c4..0df861b 100644 --- a/tools/sm_progression_qa.py +++ b/tools/sm_progression_qa.py @@ -129,7 +129,7 @@ def __init__(self, exe, rom, output, port=0, floor=None, wide=False, interpreter env.pop(key) # Never show a window: SDL2 and SDL3 spell the video driver hint # differently, so set both. - env.update(SDL_AUDIODRIVER='dummy', SDL_AUDIO_DRIVER='dummy', + env.update(SM_REPLAY_RECORD='0', SDL_AUDIODRIVER='dummy', SDL_AUDIO_DRIVER='dummy', SDL_VIDEODRIVER='dummy', SDL_VIDEO_DRIVER='dummy', SNESRECOMP_DEBUG_PORT=str(port), SNESRECOMP_CPU_TRACE_RING_ENTRIES='65536') self.capture_profile = capture_profile diff --git a/tools/test_frame_states.py b/tools/test_frame_states.py index ef87d90..bdcd3c3 100644 --- a/tools/test_frame_states.py +++ b/tools/test_frame_states.py @@ -42,9 +42,11 @@ def checksum(data): extension = schema.find(b'SFMS') assert extension >= 48 struct.pack_into(' 30, (label, image.size, counts) + return selected + + try: + session.boot() + session.land() + tap('start') + session.step(120) + assert session.state()['state'] == 15 + tap('l') + session.step(8) + assert cursor('opened') == 0 + tap('down') + tap('right') + assert 'HudAnchored=0' in (session.output / 'sm-video.ini').read_text() + tap('left') + assert 'HudAnchored=1' in (session.output / 'sm-video.ini').read_text() + for _ in range(4): + tap('down') + assert cursor('vsync') == 5 + tap('right') + session.command('savestate 4') + session.step(2) + tap('up') + assert cursor('moved-up') == 4 + session.command('loadstate 4') + session.step(8) + assert cursor('file-reopened') == 0 + report['file_panel_reopened_first_row'] = True + for _ in range(7): + tap('down') + assert cursor('run-ahead-row') == 7 + tap('right') + tap('down') + assert cursor('run-ahead-cursor') == 8 + report['run_ahead_cursor_preserved'] = True + # This profile is disposable; never write the owner's native saves. + sentinel = b'OPTIONQA' + session.command('write_sram 10 ' + sentinel.hex()) + tap('down') + assert cursor('exit-row') == 9 + session.command('set_controller a') + try: + session.command('continue') + except (OSError, ValueError): + pass # A queued SDL_QUIT may close the debug stream first. + code = session.process.wait(timeout=15) + assert code == 0, code + config = configparser.ConfigParser() + config.read(session.output / 'config.ini') + assert config.getint('Graphics', 'VSync') == 1 + assert config.getint('General', 'RunAhead') == 1 + assert (session.output / 'saves/save.srm').read_bytes()[0x10:0x18] == sentinel + report.update(passed=True, exit_code=code, config_persisted=True, sram_persisted=True) + finally: + session.close() + (root / 'report.json').write_text(json.dumps(report, indent=2) + '\n') + print('PASS: pause tab, live settings, snapshot restore, run-ahead cursor, Exit and SRAM/config persistence') + print(root / 'report.json') + + +if __name__ == '__main__': + main() diff --git a/tools/test_visible_activity.py b/tools/test_visible_activity.py new file mode 100644 index 0000000..e813ce3 --- /dev/null +++ b/tools/test_visible_activity.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""ROM-backed margin activation: real AI and projectiles, no enemy edits.""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import struct +import shutil + +from sm_progression_qa import Session, Rom + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--exe', type=Path, required=True) + p.add_argument('--rom', type=Path, required=True) + p.add_argument('--artifacts', type=Path, required=True) + p.add_argument('--aspect', default='16:9', choices=('4:3', '16:9', '21:9', '32:9')) + p.add_argument('--expect-native', action='store_true', help='reference build without wider activity') + p.add_argument('--fixture', type=Path, help='same-build AFCE right-margin snapshot; bypasses boot/traversal') + args = p.parse_args() + original = subprocess.Popen + + def launch(*a, **kw): + video = Path(kw['cwd'])/'sm-video.ini' + video.write_text('[SuperMetroidVideo]\nEnhancedRenderer=1\nAspect='+args.aspect+'\nPresentationEnabled=0\n') + return original(*a, **kw) + + subprocess.Popen = launch + s = Session(args.exe, args.rom, args.artifacts, wide=True, capture_profile=False) + report = {'passed': False, 'exe_sha256': hashlib.sha256(args.exe.read_bytes()).hexdigest(), 'aspect': args.aspect} + try: + rom = Rom(args.rom) + rooms = rom.rooms() + if args.fixture: + shutil.copyfile(args.fixture, args.artifacts/'saves/save3.sav') + s.step(2); s.command('loadstate 3'); s.step(2) + assert s.state()['room'] == 0xafce and s.state()['state'] == 8 + report['fixture'] = str(args.fixture.resolve()) + else: + s.boot(); s.land() + for at, value in ((0x9c2, 1999), (0x9c4, 1999)): + s.word(at, value) + door = next(d for d in rooms[0xa923]['doors'] if d['destination'] == 0xafce) + report['entry'] = s.transition(door) + # Ordinary movement drives scrolling. Hover only bypasses lava/floor + # gaps; never change enemies, AI counters, projectiles or success bits. + def hover(frames, buttons='0'): + for _ in range(frames): + for at, value in ((0xafa, 96), (0xb14, 96), (0xb2c, 0), (0xb2e, 0), (0x18a8, 120)): + s.word(at, value) + s.step(1, buttons) + enemy = 0xf78 + 7*64 + assert s.word(enemy) == 0xd4bf + def observation(): + return dict(frame=s.command('frame')['frame'], camera=s.word(0x911), + x=s.word(enemy+2)-s.word(0x911), y=s.word(enemy+6), + counter=s.word(enemy+44), properties=s.word(enemy+14)) + for _ in range(700): + if observation()['x'] <= 275: break + hover(1, 'right') + else: raise AssertionError('could not reach right margin') + hover(2) + before = observation() + assert 265 <= before['x'] <= 280, before + s.command('savestate 3') + projectiles = [] + for _ in range(240): + hover(1) + projectiles_ram = s.read(0x1997, 0x120) + camera = s.word(0x911) + for i in range(18): + identity = struct.unpack_from('= 256 or x < 0: + projectiles.append(dict(id=identity, x=x, y=struct.unpack_from(' 320, 'beam disappeared at the old culling boundary' + s.command('screenshot margin.bmp') + report['passed'] = True + finally: + s.close(); subprocess.Popen = original + (args.artifacts/'report.json').write_text(json.dumps(report, indent=2)+'\n') + print('PASS:', args.aspect, 'margin AI '+('active' if active else 'native culling')+', real projectiles and save/load') + + +if __name__ == '__main__': main()