diff --git a/CMakeLists.txt b/CMakeLists.txt index 1dfdd43..0bae398 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,31 +25,32 @@ option(DISRUPTOR_FRAME_INTERPOLATION ON) set(DISRUPTOR_EXTRA_SOURCES - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_backdrop_tiles.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_billboard_aspect.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_capsule.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_cheats.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_control_probe.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_far_rendering.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_frame_rate.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_intro_skip.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_language.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_language_disc.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_language_hints.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_language_source.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_restart.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_mouse_aim.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_name_entry.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_render_arena.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_grown_quad.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_hud_widgets.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_sprite_depth.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_vertical_camera.cpp") + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/backdrop_tiles.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/billboard_aspect.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/capsule.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/cheats.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/control_probe.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/far_rendering.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/frame/frame_rate.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/intro_skip.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/language/language.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/language/language_disc.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/language/language_hints.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/language/language_source.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/restart.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/mouse_aim.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/name_entry.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/render_arena.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/grown_quad.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/hud_widgets.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/sprite_depth.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/vertical_camera.cpp") # The project deliberately uses the redistributable OpenBIOS backend only. set(PSXRECOMP_BIOS_STEMS "OpenBIOS" CACHE STRING "" FORCE) set(PSX_RECOMP_UI OFF CACHE BOOL "" FORCE) +include_directories("${CMAKE_CURRENT_SOURCE_DIR}/src") include("${PSXRECOMP_ROOT}/runtime/runtime.cmake") if(DISRUPTOR_DEV_MENU) @@ -88,10 +89,11 @@ if(DISRUPTOR_DEV_MENU) endif() list(APPEND DISRUPTOR_EXTRA_SOURCES - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_dev_menu.cpp") + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/settings/options.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/dev_menu.cpp") if(DISRUPTOR_FRAME_INTERPOLATION) list(APPEND DISRUPTOR_EXTRA_SOURCES - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_present_rate.cpp") + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/frame/present_rate.cpp") endif() endif() @@ -166,9 +168,9 @@ endif() if(WIN32) # UTF-8 as the game's code page: a path in settings.toml may hold letters the system's own page lacks. if(MINGW) - target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor.rc") + target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/game/disruptor.rc") else() - target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor.manifest") + target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/game/disruptor.manifest") endif() endif() @@ -178,454 +180,69 @@ if(WIN32) endif() if(BUILD_TESTING) find_package(Python3 REQUIRED COMPONENTS Interpreter) - - add_test(NAME disruptor_backdrop_tiles_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_backdrop_tiles_contract.py" - --require-artifacts) - - add_test(NAME disruptor_depth24_sync_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_depth24_sync_contract.py") - - add_test(NAME disruptor_geometry_provenance_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_geometry_provenance_contract.py") - - add_test(NAME disruptor_phase_a_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_phase_a_contract.py") - - add_test(NAME disruptor_perspective_texture_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_perspective_texture_contract.py") - - add_test(NAME disruptor_pinned_corner_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_pinned_corner_contract.py") - - add_test(NAME disruptor_dev_menu_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_dev_menu_contract.py") - - add_test(NAME disruptor_loaded_state_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_loaded_state_contract.py") - - add_test(NAME disruptor_cheats_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_cheats_contract.py") - - add_test(NAME disruptor_vertical_camera_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_vertical_camera_contract.py") - - add_test(NAME disruptor_far_rendering_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_far_rendering_contract.py") - - add_test(NAME disruptor_frame_kind_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_frame_kind_contract.py") - - add_test(NAME disruptor_temporal_presenter_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_temporal_presenter_contract.py") - - add_test(NAME disruptor_menu_wide_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_menu_wide_contract.py") - - add_test(NAME disruptor_hud_scale_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_hud_scale_contract.py") - - add_test(NAME disruptor_hud_widget_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_hud_widget_contract.py") - - add_test(NAME disruptor_name_entry_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_name_entry_contract.py" - --require-artifacts) - set_tests_properties(disruptor_name_entry_contract - PROPERTIES LABELS retail) - - add_test(NAME disruptor_grown_quad_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_grown_quad_contract.py" - --require-artifacts) - - add_test(NAME disruptor_sprite_range_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_sprite_range_contract.py" - --require-artifacts) - set_tests_properties(disruptor_sprite_range_contract - PROPERTIES LABELS retail) - - add_test(NAME disruptor_intro_skip_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_intro_skip_contract.py" - --require-artifacts) - - add_test(NAME disruptor_language_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_language_contract.py" - --require-artifacts) - - add_test(NAME disruptor_language_disc_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_language_disc_contract.py" - --require-artifacts) - - add_test(NAME disruptor_frame_rate_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_frame_rate_contract.py" - --require-artifacts) - - add_test(NAME disruptor_billboard_aspect_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_billboard_aspect_contract.py") - - add_test(NAME disruptor_launcher_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_launcher_contract.py") - - add_test(NAME disruptor_capsule_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_capsule_contract.py") - - add_test(NAME disruptor_gap_filling_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gap_filling_contract.py") - - add_test(NAME disruptor_world_depth_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_world_depth_contract.py") - - add_test(NAME disruptor_smooth_turn_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_smooth_turn_contract.py") - - add_test(NAME disruptor_ambient_occlusion_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_ambient_occlusion_contract.py") - - add_test(NAME disruptor_debug_tab_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_debug_tab_contract.py") + include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/DisruptorTests.cmake") + + disruptor_contract(backdrop_tiles RETAIL) + disruptor_contract(depth24_sync) + disruptor_contract(geometry_provenance) + disruptor_contract(phase_a) + disruptor_contract(perspective_texture) + disruptor_contract(pinned_corner) + disruptor_contract(dev_menu) + disruptor_contract(loaded_state) + disruptor_contract(save_point) + disruptor_contract(cheats) + disruptor_contract(vertical_camera) + disruptor_contract(far_rendering) + disruptor_contract(frame_kind) + disruptor_contract(temporal_presenter) + disruptor_contract(menu_wide) + disruptor_contract(hud_scale) + disruptor_contract(hud_widget) + disruptor_contract(name_entry RETAIL) + disruptor_contract(grown_quad RETAIL) + disruptor_contract(sprite_range RETAIL) + disruptor_contract(intro_skip RETAIL) + disruptor_contract(language RETAIL) + disruptor_contract(language_disc RETAIL) + disruptor_contract(frame_rate RETAIL) + disruptor_contract(billboard_aspect) + disruptor_contract(launcher) + disruptor_contract(capsule) + disruptor_contract(gap_filling) + disruptor_contract(world_depth) + disruptor_contract(smooth_turn) + disruptor_contract(ambient_occlusion) + disruptor_contract(debug_tab) + disruptor_contract(test_registration) add_test(NAME disruptor_framework_overlay_new_file COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_framework_overlay_new_file.py") - - # These need the retail image or the translated code. A machine without - # the game leaves them out with `ctest -LE retail`. - set_tests_properties( - disruptor_backdrop_tiles_contract disruptor_grown_quad_contract - disruptor_intro_skip_contract disruptor_language_contract - disruptor_language_disc_contract disruptor_frame_rate_contract - PROPERTIES LABELS retail) + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_framework_overlay_new_file.py") endif() if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) - add_executable(disruptor-user-settings-test - tests/user_settings_persistence_test.cpp - "${PSXRECOMP_ROOT}/recompiler/src/config_loader.cpp" - "${PSXRECOMP_ROOT}/recompiler/src/ps1_exe_parser.cpp") - target_include_directories(disruptor-user-settings-test PRIVATE - "${PSXRECOMP_ROOT}/recompiler/src" - "${PSXRECOMP_ROOT}/recompiler/include" - "${PSXRECOMP_ROOT}/recompiler/lib/fmt/include" - "${PSXRECOMP_ROOT}/recompiler/lib/toml11") - target_compile_definitions(disruptor-user-settings-test PRIVATE - FMT_HEADER_ONLY=1 - "$<$:NOMINMAX>" - "$<$:WIN32_LEAN_AND_MEAN>") - target_compile_features(disruptor-user-settings-test PRIVATE cxx_std_20) - add_test(NAME disruptor_user_settings_persistence - COMMAND disruptor-user-settings-test) - - add_executable(disruptor-mouse-aim-test - tests/disruptor_mouse_aim_test.cpp) - target_include_directories(disruptor-mouse-aim-test PRIVATE - tests/stubs) - target_compile_definitions(disruptor-mouse-aim-test PRIVATE PSX_SDL3=1) - target_compile_features(disruptor-mouse-aim-test PRIVATE cxx_std_20) - - add_executable(disruptor-cheats-test - tests/disruptor_cheats_test.cpp) - target_include_directories(disruptor-cheats-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-cheats-test PRIVATE cxx_std_20) - - add_executable(disruptor-vertical-camera-test - tests/disruptor_vertical_camera_test.cpp) - target_include_directories(disruptor-vertical-camera-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-vertical-camera-test PRIVATE cxx_std_20) - - add_executable(disruptor-far-rendering-test - tests/disruptor_far_rendering_test.cpp) - target_include_directories(disruptor-far-rendering-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-far-rendering-test PRIVATE cxx_std_20) - - add_executable(disruptor-render-arena-test - tests/disruptor_render_arena_test.cpp) - target_include_directories(disruptor-render-arena-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-render-arena-test PRIVATE cxx_std_20) - add_test(NAME disruptor_render_arena COMMAND disruptor-render-arena-test) - - add_executable(disruptor-name-entry-test - tests/disruptor_name_entry_test.cpp) - target_include_directories(disruptor-name-entry-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-name-entry-test PRIVATE cxx_std_20) - add_test(NAME disruptor_name_entry COMMAND disruptor-name-entry-test) - - add_executable(disruptor-backdrop-tiles-test - tests/disruptor_backdrop_tiles_test.cpp) - target_include_directories(disruptor-backdrop-tiles-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-backdrop-tiles-test PRIVATE cxx_std_20) - add_test(NAME disruptor_backdrop_tiles - COMMAND disruptor-backdrop-tiles-test) - - add_executable(disruptor-gpu-ws-frame-kind-test - tests/gpu_ws_frame_kind_test.cpp) - target_include_directories(disruptor-gpu-ws-frame-kind-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-ws-frame-kind-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_ws_frame_kind - COMMAND disruptor-gpu-ws-frame-kind-test) - - add_executable(disruptor-gpu-ws-menu-test - tests/gpu_ws_menu_test.cpp) - target_include_directories(disruptor-gpu-ws-menu-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-ws-menu-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_ws_menu - COMMAND disruptor-gpu-ws-menu-test) - - add_executable(disruptor-gpu-ws-hud-scale-test - tests/gpu_ws_hud_scale_test.cpp) - target_include_directories(disruptor-gpu-ws-hud-scale-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-ws-hud-scale-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_ws_hud_scale - COMMAND disruptor-gpu-ws-hud-scale-test) - - add_executable(disruptor-gpu-ws-hud-widget-test - tests/gpu_ws_hud_widget_test.cpp) - target_include_directories(disruptor-gpu-ws-hud-widget-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-ws-hud-widget-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_ws_hud_widget - COMMAND disruptor-gpu-ws-hud-widget-test) - - add_executable(disruptor-intro-skip-test - tests/disruptor_intro_skip_test.cpp) - target_include_directories(disruptor-intro-skip-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-intro-skip-test PRIVATE cxx_std_20) - add_test(NAME disruptor_intro_skip - COMMAND disruptor-intro-skip-test) - - add_executable(disruptor-frame-rate-test - tests/disruptor_frame_rate_test.cpp) - target_include_directories(disruptor-frame-rate-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-frame-rate-test PRIVATE cxx_std_20) - add_test(NAME disruptor_frame_rate - COMMAND disruptor-frame-rate-test) - - add_executable(disruptor-language-test - tests/disruptor_language_test.cpp) - target_include_directories(disruptor-language-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-language-test PRIVATE cxx_std_20) - add_test(NAME disruptor_language - COMMAND disruptor-language-test) - - add_executable(disruptor-language-hints-test - tests/disruptor_language_hints_test.cpp) - target_include_directories(disruptor-language-hints-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-language-hints-test PRIVATE cxx_std_20) - add_test(NAME disruptor_language_hints - COMMAND disruptor-language-hints-test) - - add_executable(disruptor-language-disc-test - tests/disruptor_language_disc_test.cpp) - target_compile_features(disruptor-language-disc-test PRIVATE cxx_std_20) - add_test(NAME disruptor_language_disc - COMMAND disruptor-language-disc-test) - - add_executable(disruptor-language-overlay-test - tests/disruptor_language_overlay_test.cpp) - target_include_directories(disruptor-language-overlay-test PRIVATE - "${CMAKE_CURRENT_SOURCE_DIR}/psxrecomp-overlay/runtime/include" - "${PSXRECOMP_ROOT}/runtime/include" - "${CMAKE_CURRENT_SOURCE_DIR}/src") - target_compile_features(disruptor-language-overlay-test PRIVATE cxx_std_20) - add_test(NAME disruptor_language_overlay - COMMAND disruptor-language-overlay-test) - add_test(NAME disruptor_language_overlay_tall - COMMAND disruptor-language-overlay-test tall) - - add_executable(disruptor-capsule-test - tests/disruptor_capsule_test.cpp) - target_compile_features(disruptor-capsule-test PRIVATE cxx_std_20) - target_include_directories(disruptor-capsule-test PRIVATE src) - add_test(NAME disruptor_capsule - COMMAND disruptor-capsule-test) - - add_executable(host-pick-slot-test - tests/host_pick_slot_test.cpp) - target_compile_features(host-pick-slot-test PRIVATE cxx_std_20) - add_test(NAME host_pick_slot - COMMAND host-pick-slot-test) - - add_executable(host-borderless-test - tests/host_borderless_test.cpp) - target_compile_features(host-borderless-test PRIVATE cxx_std_20) - add_test(NAME host_borderless - COMMAND host-borderless-test) - add_test(NAME host_borderless_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_borderless_contract.py") - - add_executable(disruptor-restart-test - tests/disruptor_restart_test.cpp) - target_include_directories(disruptor-restart-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-restart-test PRIVATE cxx_std_20) - add_test(NAME disruptor_restart - COMMAND disruptor-restart-test run "${CMAKE_CURRENT_BINARY_DIR}/disruptor_restart_test.txt") - - add_executable(disruptor-present-rate-test - tests/disruptor_present_rate_test.cpp) - target_include_directories(disruptor-present-rate-test PRIVATE - "${CMAKE_CURRENT_SOURCE_DIR}/tests/present_rate_fakes") - target_compile_features(disruptor-present-rate-test PRIVATE cxx_std_20) - add_test(NAME disruptor_present_rate - COMMAND disruptor-present-rate-test) - - add_executable(disruptor-grown-quad-test - tests/disruptor_grown_quad_test.cpp) - target_include_directories(disruptor-grown-quad-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-grown-quad-test PRIVATE cxx_std_20) - add_test(NAME disruptor_grown_quad - COMMAND disruptor-grown-quad-test) - - add_executable(disruptor-sprite-depth-test - tests/disruptor_sprite_depth_test.cpp) - target_include_directories(disruptor-sprite-depth-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-sprite-depth-test PRIVATE cxx_std_20) - add_test(NAME disruptor_sprite_depth - COMMAND disruptor-sprite-depth-test) - - add_executable(disruptor-billboard-aspect-test - tests/disruptor_billboard_aspect_test.cpp) - target_include_directories(disruptor-billboard-aspect-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-billboard-aspect-test PRIVATE cxx_std_20) - - add_executable(disruptor-gpu-temporal-sprite-test - tests/gpu_temporal_sprite_test.cpp) - target_include_directories(disruptor-gpu-temporal-sprite-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-temporal-sprite-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_temporal_sprite - COMMAND disruptor-gpu-temporal-sprite-test) - - add_executable(disruptor-gpu-ws-tag-match-test - tests/gpu_ws_tag_match_test.cpp) - target_include_directories(disruptor-gpu-ws-tag-match-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-ws-tag-match-test PRIVATE cxx_std_20) - - add_executable(disruptor-gpu-temporal-match-test - tests/gpu_temporal_match_test.cpp) - target_include_directories(disruptor-gpu-temporal-match-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-temporal-match-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_temporal_match - COMMAND disruptor-gpu-temporal-match-test) - - add_executable(disruptor-gpu-temporal-mesh-test - tests/gpu_temporal_mesh_test.cpp) - target_include_directories(disruptor-gpu-temporal-mesh-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-temporal-mesh-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_temporal_mesh - COMMAND disruptor-gpu-temporal-mesh-test) - - add_executable(disruptor-gpu-pinned-corner-test - tests/gpu_pinned_corner_test.cpp) - target_include_directories(disruptor-gpu-pinned-corner-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-pinned-corner-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_pinned_corner - COMMAND disruptor-gpu-pinned-corner-test) - - add_executable(disruptor-gpu-turned-perspective-test - tests/gpu_turned_perspective_test.cpp) - target_include_directories(disruptor-gpu-turned-perspective-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-turned-perspective-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_turned_perspective - COMMAND disruptor-gpu-turned-perspective-test) - - add_executable(disruptor-gpu-world-depth-test - tests/gpu_world_depth_test.cpp) - target_include_directories(disruptor-gpu-world-depth-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-world-depth-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_world_depth - COMMAND disruptor-gpu-world-depth-test) - - add_executable(disruptor-presentation-yaw-math-test - tests/presentation_yaw_math_test.cpp) - target_compile_features(disruptor-presentation-yaw-math-test PRIVATE cxx_std_20) - - add_executable(disruptor-gte-precision-store-pc-test - tests/gte_precision_store_pc_test.cpp - "${PSXRECOMP_ROOT}/runtime/src/gte.cpp") - target_include_directories(disruptor-gte-precision-store-pc-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_definitions(disruptor-gte-precision-store-pc-test PRIVATE - PSX_NO_DEBUG_TOOLS=1 - PSX_GTE_REGISTER_TEST=1 - PSX_ENABLE_BLOCK_CYCLES=1) - target_compile_features(disruptor-gte-precision-store-pc-test PRIVATE - cxx_std_20) - - add_test(NAME disruptor_mouse_aim - COMMAND disruptor-mouse-aim-test) - add_test(NAME disruptor_cheats - COMMAND disruptor-cheats-test) - add_test(NAME disruptor_vertical_camera - COMMAND disruptor-vertical-camera-test) - add_test(NAME disruptor_far_rendering - COMMAND disruptor-far-rendering-test) - add_test(NAME disruptor_billboard_aspect - COMMAND disruptor-billboard-aspect-test) - add_test(NAME disruptor_gpu_ws_tag_match - COMMAND disruptor-gpu-ws-tag-match-test) + disruptor_unit_test(disruptor_user_settings_persistence tests/unit/user_settings_persistence_test.cpp + TARGET disruptor-user-settings-test + SOURCES + "${PSXRECOMP_ROOT}/recompiler/src/config_loader.cpp" + "${PSXRECOMP_ROOT}/recompiler/src/ps1_exe_parser.cpp" + INCLUDES + "${PSXRECOMP_ROOT}/recompiler/src" + "${PSXRECOMP_ROOT}/recompiler/include" + "${PSXRECOMP_ROOT}/recompiler/lib/fmt/include" + "${PSXRECOMP_ROOT}/recompiler/lib/toml11" + DEFINITIONS + FMT_HEADER_ONLY=1 + "$<$:NOMINMAX>" + "$<$:WIN32_LEAN_AND_MEAN>") + + disruptor_unit_test(disruptor_mouse_aim tests/unit/disruptor_mouse_aim_test.cpp + INCLUDES tests/support/stubs + DEFINITIONS PSX_SDL3=1) set_tests_properties(disruptor_mouse_aim PROPERTIES ENVIRONMENT "PSX_DISRUPTOR_MOUSE_AIM=;PSX_DISRUPTOR_VERTICAL_LOOK=;PSX_DISRUPTOR_MODERN_CONTROLS=;PSX_DISRUPTOR_HIGH_PRECISION_CAMERA=") - add_test(NAME disruptor_presentation_yaw_math - COMMAND disruptor-presentation-yaw-math-test) - - add_test(NAME disruptor_gte_precision_store_pc - COMMAND disruptor-gte-precision-store-pc-test) - add_test(NAME disruptor_mouse_aim_registration COMMAND disruptor-mouse-aim-test --registration) set_tests_properties(disruptor_mouse_aim_registration PROPERTIES @@ -645,4 +262,54 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-mouse-aim-test --registration) set_tests_properties(disruptor_high_precision_camera_registration PROPERTIES ENVIRONMENT "PSX_DISRUPTOR_MOUSE_AIM=;PSX_DISRUPTOR_VERTICAL_LOOK=;PSX_DISRUPTOR_MODERN_CONTROLS=;PSX_DISRUPTOR_HIGH_PRECISION_CAMERA=1") + + disruptor_unit_test(disruptor_cheats tests/unit/disruptor_cheats_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_vertical_camera tests/unit/disruptor_vertical_camera_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_far_rendering tests/unit/disruptor_far_rendering_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_render_arena tests/unit/disruptor_render_arena_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_name_entry tests/unit/disruptor_name_entry_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_backdrop_tiles tests/unit/disruptor_backdrop_tiles_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_intro_skip tests/unit/disruptor_intro_skip_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_frame_rate tests/unit/disruptor_frame_rate_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_language tests/unit/disruptor_language_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_language_hints tests/unit/disruptor_language_hints_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_language_disc tests/unit/disruptor_language_disc_test.cpp) + disruptor_unit_test(disruptor_language_overlay tests/unit/disruptor_language_overlay_test.cpp + INCLUDES + "${CMAKE_CURRENT_SOURCE_DIR}/psxrecomp-overlay/runtime/include" + "${PSXRECOMP_ROOT}/runtime/include" + "${CMAKE_CURRENT_SOURCE_DIR}/src") + add_test(NAME disruptor_language_overlay_tall + COMMAND disruptor-language-overlay-test tall) + disruptor_unit_test(disruptor_capsule tests/unit/disruptor_capsule_test.cpp INCLUDES src) + disruptor_unit_test(disruptor_restart tests/unit/disruptor_restart_test.cpp RUNTIME_HEADERS + ARGUMENTS run "${CMAKE_CURRENT_BINARY_DIR}/disruptor_restart_test.txt") + disruptor_unit_test(disruptor_present_rate tests/unit/disruptor_present_rate_test.cpp + INCLUDES "${CMAKE_CURRENT_SOURCE_DIR}/tests/support/present_rate_fakes") + disruptor_unit_test(disruptor_grown_quad tests/unit/disruptor_grown_quad_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_sprite_depth tests/unit/disruptor_sprite_depth_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_billboard_aspect tests/unit/disruptor_billboard_aspect_test.cpp RUNTIME_HEADERS) + + disruptor_unit_test(disruptor_gpu_ws_frame_kind tests/unit/gpu_ws_frame_kind_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_ws_menu tests/unit/gpu_ws_menu_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_ws_hud_scale tests/unit/gpu_ws_hud_scale_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_ws_hud_widget tests/unit/gpu_ws_hud_widget_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_ws_tag_match tests/unit/gpu_ws_tag_match_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_temporal_sprite tests/unit/gpu_temporal_sprite_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_temporal_match tests/unit/gpu_temporal_match_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_temporal_mesh tests/unit/gpu_temporal_mesh_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_pinned_corner tests/unit/gpu_pinned_corner_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_turned_perspective tests/unit/gpu_turned_perspective_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_world_depth tests/unit/gpu_world_depth_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_save_point tests/unit/save_point_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_presentation_yaw_math tests/unit/presentation_yaw_math_test.cpp) + disruptor_unit_test(disruptor_gte_precision_store_pc tests/unit/gte_precision_store_pc_test.cpp RUNTIME_HEADERS + SOURCES "${PSXRECOMP_ROOT}/runtime/src/gte.cpp" + DEFINITIONS PSX_NO_DEBUG_TOOLS=1 PSX_GTE_REGISTER_TEST=1 PSX_ENABLE_BLOCK_CYCLES=1) + + disruptor_unit_test(host_pick_slot tests/unit/host_pick_slot_test.cpp) + disruptor_unit_test(host_borderless tests/unit/host_borderless_test.cpp) + add_test(NAME host_borderless_contract + COMMAND "${Python3_EXECUTABLE}" -B + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_borderless_contract.py") endif() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34b4a63..bd661e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,7 @@ Before submitting a change: - Run the relevant tests and review both the working diff and staged paths. - Keep experimental visual features optional. -- Update [STATUS.md](STATUS.md) when behavior, verified coverage, or a known +- Update [STATUS.md](docs/STATUS.md) when behavior, verified coverage, or a known limitation changes. Keep it focused on the current build. For bug reports, include the level, steps to reproduce, render scale/aspect, diff --git a/PSXRECOMP_OVERLAY_FILES.txt b/PSXRECOMP_OVERLAY_FILES.txt index f8d67f5..a0f2939 100644 --- a/PSXRECOMP_OVERLAY_FILES.txt +++ b/PSXRECOMP_OVERLAY_FILES.txt @@ -31,6 +31,7 @@ runtime/include/gpu_sw_renderer.h +runtime/include/iso_overlay.h runtime/include/mod_memory.h runtime/include/psx_cyc.h ++runtime/include/save_point.h runtime/include/overlay_dispatch_preamble.c.inc runtime/include/ws_aspect_cone_math.h runtime/src/boot_state.c @@ -40,6 +41,7 @@ runtime/src/gpu_gl_renderer.c +runtime/src/gpu_gl_temporal.c.inc runtime/src/gpu_render.c runtime/src/gte.cpp +runtime/src/interrupts.c runtime/src/iso_reader_c.cpp runtime/src/main.cpp runtime/src/memory.c diff --git a/README.md b/README.md index c11dfe3..e81369b 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,18 @@ disc revision; the redistributable OpenBIOS backend is used for booting. the screen too, movies stay at 4:3, and the HUD retains its proportions. A HUD size setting shrinks it toward the screen edges. - Optional geometry and perspective-texture correction. +- Optional gap filling, a depth-sorted world, and experimental ambient + occlusion on top of geometry correction. - Optional 60 FPS gameplay and in-between frames. - Keyboard/controller input and optional modern WASD/mouse controls. +- Optional smooth mouse turning between the game's 256 turn steps. - Experimental vertical mouse look and weapon aim. - In-game settings for controls, display, fullscreen, VSync, volume, and mute. +- Diagnostic views and a screen recorder for reports of a fault in the picture. - Memory-card saves and twelve save-state slots. - Windows launcher with disc-image browsing, automatic copy/CUE setup, - full-disc SHA-256 verification, the game's settings, and game launch. + full-disc SHA-256 verification, the game's settings with a hint for each, + and game launch. - Optional French, German, and Japanese game content from an additional disc image you own; the supported USA disc remains required. - Optional intro skipping and a key to skip a logo or movie. @@ -56,7 +61,7 @@ Place these files from your own supported disc in `input/`: - `SLUS_002.24` - `Disruptor (USA).cue` and the `.bin` track it references -Check the required hashes and disc format in [DISC.md](DISC.md). Then run from +Check the required hashes and disc format in [DISC.md](docs/DISC.md). Then run from an x64 developer PowerShell: ```powershell @@ -100,8 +105,8 @@ retest and save-state restart check passed after fixing an ultrawide rendering buffer overflow. Full campaign and release validation remain incomplete; minor geometry and texture artifacts can remain. -- [Current status and known limits](STATUS.md) +- [Current status and known limits](docs/STATUS.md) - [Build details](docs/BUILD.md) -- [Supported disc revision](DISC.md) +- [Supported disc revision](docs/DISC.md) - [Contributing](CONTRIBUTING.md) - [Third-party notices](THIRD_PARTY_NOTICES.md) diff --git a/build.sh b/build.sh index 01cddf6..4f911f2 100755 --- a/build.sh +++ b/build.sh @@ -86,7 +86,7 @@ if [ -n "$WITHOUT_GAME" ]; then printf '%s\n' $OBJECTS $TESTS | xargs -n 100 ninja -C "$BUILD" ctest --test-dir "$BUILD" --output-on-failure --no-tests=error -LE retail # The retail contracts still check the sources when the image is not asked for. - for contract in tests/test_*_contract.py; do + for contract in tests/contracts/test_*_contract.py; do python3 -B "$contract" done echo "Built and tested without the game: the runtime was compiled and not linked." diff --git a/cmake/DisruptorTests.cmake b/cmake/DisruptorTests.cmake new file mode 100644 index 0000000..bd5391a --- /dev/null +++ b/cmake/DisruptorTests.cmake @@ -0,0 +1,56 @@ +# disruptor_contract( [RETAIL]) +# +# Registers tests/contracts/test__contract.py as the test disruptor__contract. +# RETAIL: the contract needs the retail image or the translated code. It is passed +# --require-artifacts and labelled so that `ctest -LE retail` leaves it out. +function(disruptor_contract name) + cmake_parse_arguments(PARSE_ARGV 1 CONTRACT "RETAIL" "" "") + if(CONTRACT_UNPARSED_ARGUMENTS) + message(FATAL_ERROR "disruptor_contract(${name}): unknown arguments ${CONTRACT_UNPARSED_ARGUMENTS}") + endif() + set(script "${PROJECT_SOURCE_DIR}/tests/contracts/test_${name}_contract.py") + if(NOT EXISTS "${script}") + message(FATAL_ERROR "disruptor_contract(${name}): ${script} does not exist") + endif() + set(arguments "") + if(CONTRACT_RETAIL) + set(arguments --require-artifacts) + endif() + add_test(NAME disruptor_${name}_contract + COMMAND "${Python3_EXECUTABLE}" -B "${script}" ${arguments}) + if(CONTRACT_RETAIL) + set_tests_properties(disruptor_${name}_contract PROPERTIES LABELS retail) + endif() +endfunction() + +# disruptor_unit_test( [TARGET ] [RUNTIME_HEADERS] +# [SOURCES ...] [INCLUDES ...] [DEFINITIONS ...] +# [ARGUMENTS ...]) +# +# Builds one C++20 executable from , a path from the project's root, and registers +# it as the test . The executable is named after the test, disruptor_far_rendering +# giving disruptor-far-rendering-test, unless TARGET names it. +# RUNTIME_HEADERS puts the framework's runtime headers on its include path. +function(disruptor_unit_test test source) + cmake_parse_arguments(PARSE_ARGV 2 UNIT "RUNTIME_HEADERS" "TARGET" + "SOURCES;INCLUDES;DEFINITIONS;ARGUMENTS") + if(UNIT_UNPARSED_ARGUMENTS) + message(FATAL_ERROR "disruptor_unit_test(${test}): unknown arguments ${UNIT_UNPARSED_ARGUMENTS}") + endif() + set(target "${UNIT_TARGET}") + if(NOT target) + string(REPLACE "_" "-" target "${test}-test") + endif() + add_executable(${target} "${PROJECT_SOURCE_DIR}/${source}" ${UNIT_SOURCES}) + if(UNIT_RUNTIME_HEADERS) + target_include_directories(${target} PRIVATE "${PSXRECOMP_ROOT}/runtime/include") + endif() + if(UNIT_INCLUDES) + target_include_directories(${target} PRIVATE ${UNIT_INCLUDES}) + endif() + if(UNIT_DEFINITIONS) + target_compile_definitions(${target} PRIVATE ${UNIT_DEFINITIONS}) + endif() + target_compile_features(${target} PRIVATE cxx_std_20) + add_test(NAME ${test} COMMAND ${target} ${UNIT_ARGUMENTS}) +endfunction() diff --git a/tools/stage_launcher.cmake b/cmake/stage_launcher.cmake similarity index 92% rename from tools/stage_launcher.cmake rename to cmake/stage_launcher.cmake index e92c3dc..ed5bf93 100644 --- a/tools/stage_launcher.cmake +++ b/cmake/stage_launcher.cmake @@ -3,7 +3,7 @@ foreach(pair IN ITEMS "game.toml|game.toml" "mouse-aim.ini|mouse-aim.ini" "docs/PLAYING.md|GETTING_STARTED.md" "release/windows/Play Disruptor.cmd|Play Disruptor.cmd" - "keybinds-modern.ini|keybinds.ini" + "config/keybinds-modern.ini|keybinds.ini" "release/windows/settings.toml|settings.toml") string(REPLACE "|" ";" paths "${pair}") list(GET paths 0 source) diff --git a/keybinds-modern.ini b/config/keybinds-modern.ini similarity index 100% rename from keybinds-modern.ini rename to config/keybinds-modern.ini diff --git a/keybinds-original.ini b/config/keybinds-original.ini similarity index 100% rename from keybinds-original.ini rename to config/keybinds-original.ini diff --git a/docs/BUILD.md b/docs/BUILD.md index f900920..b4f5782 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -3,7 +3,7 @@ ## Requirements - The verified Disruptor USA executable and BIN/CUE files in `input/`; see - [DISC.md](../DISC.md). + [DISC.md](DISC.md). - Git, Python 3, CMake 3.20+, and a C++20 toolchain. - Windows x64: Visual Studio with **Desktop development with C++**, or MSYS2 MinGW-w64. Ninja is recommended. @@ -129,6 +129,27 @@ files are listed in `PSXRECOMP_OVERLAY_FILES.txt`. Runtime preferences live in `settings.toml` beside the executable; game files, generated code, and build directories are ignored by Git. +## Layout + +| Folder | Holds | +| --- | --- | +| `src/game/` | The game's own module by area: `input`, `render`, `frame`, `language`, `session`, `settings`, `ui` | +| `src/launcher/` | The Windows launcher | +| `psxrecomp-overlay/` | The framework files this project replaces or adds | +| `tests/unit/` | Unit tests, one executable each | +| `tests/contracts/` | Source contracts, run by Python | +| `tests/support/` | Stubs and fakes the unit tests build against | +| `cmake/` | Build helpers: test registration, launcher staging | +| `config/` | Key presets | +| `tools/` | Regeneration, the overlay, packaging, capsule replay | + +`game.toml`, `game-widescreen.toml` and `mouse-aim.ini` stay at the root: the +game is started from there and reads them by those names. + +A unit test is registered in `CMakeLists.txt` with +`disruptor_unit_test( tests/unit/)` and a contract with +`disruptor_contract()` for `tests/contracts/test__contract.py`. + ## Replay capsules Ctrl+F12 in the game starts a recording and Ctrl+F12 again ends it. The game diff --git a/DISC.md b/docs/DISC.md similarity index 100% rename from DISC.md rename to docs/DISC.md diff --git a/docs/PLAYING.md b/docs/PLAYING.md index fe09183..857f1d2 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -31,9 +31,10 @@ and share feedback. 6. At the game menu, use the arrow keys and **Enter** to select **New Game**, or choose **Practice Mode** to try a training mission. -The launcher's **Display**, **Enhancements**, **Controls** and **Audio** tabs -hold the settings of the in-game menu. A change is saved to `settings.toml` -at once and applies the next time the game starts. **Original** and +The launcher's **Display**, **Enhancements**, **Controls**, **Audio** and +**Experimental** tabs hold the settings of the in-game menu. A long page +scrolls, and a row shows a hint under the pointer. A change is saved to +`settings.toml` at once and applies the next time the game starts. **Original** and **Enhanced** set a group of them in one step, and **Reset settings** returns them to those of a new installation. The tabs are locked while the game runs. If the game stops with an error, the launcher shows the end of `startup.log`. @@ -71,7 +72,10 @@ folder; these backups can be removed manually once the repaired copy works. Modern mouse/keyboard controls, geometry correction, and perspective textures are enabled initially. Vertical mouse look is experimental and starts off; -enable it in **Settings → Controls** if desired. +enable it in **Settings → Controls** if desired. A setting shows its +description under the pointer, the same one as in the launcher. A setting that +needs another one stays greyed until that one is on. In-between frames that +are on can still be switched off. **Settings → Enhancements** offers 1x–8x resolution, widescreen options, HUD size for widescreen, and 60 FPS gameplay and in-between frames. Both @@ -80,6 +84,16 @@ The **Shadows** setting offers Vanilla and Improved shapes when geometry correction is enabled. HUD size ranges from 50% to 100% in widescreen and leaves the weapon size unchanged. +With geometry correction, **Gap filling** closes hairline gaps between +polygons and starts on. **Depth-sorted world** draws the world by depth where +the game's own order puts a far polygon over a near one; it starts off. +**Settings → Experimental** has ambient occlusion, which shades corners and +the floor under things and starts off. **Smooth mouse turning** in +**Settings → Controls** turns the picture between the game's 256 turn steps +while the mouse is captured; it starts off too. **Settings → Debug** has +diagnostic views and a two-second screen recorder for a report of a fault in +the picture. + **Skip the logos and the title movie** in Enhancements skips the boot intro automatically. It starts off; the other movies still play normally. Escape can skip the logo or movie currently on screen when the settings menu is closed. diff --git a/STATUS.md b/docs/STATUS.md similarity index 87% rename from STATUS.md rename to docs/STATUS.md index 51ce4ef..d00d04f 100644 --- a/STATUS.md +++ b/docs/STATUS.md @@ -9,8 +9,8 @@ and public-release validation are incomplete. | Gameplay | Boot, menus, FMVs, audio, and the first mission have been exercised on Windows. | | Rendering | OpenGL supports 1x–8x resolution, widescreen, geometry correction, and perspective textures. Minor edge gaps and localized texture wobble can remain. | | Controls | Modern keyboard/mouse controls are available. Vertical look and weapon aim remain experimental. | -| Settings | Controls, Enhancements, Cheats, and System tabs; supported preferences persist between runs. | -| Interpolation and diagnostics | In-between frames are experimental and off until switched on in Settings. The Diagnostics tab and experimental debug controls are removed. | +| Settings | Controls, Enhancements, Experimental, Language, System, Debug, and Cheats tabs; supported preferences persist between runs. | +| Interpolation and diagnostics | In-between frames are off until switched on in Settings. The Debug tab has diagnostic views and a screen recorder. | | Ultrawide corruption | Fixed primitive-buffer overflow. The user's 8x / 32:9 training retest stayed correct through 8,934 frames and closed normally. | | Save states | Save states require a matching build and language. The alpha.3 code-generation changes make alpha.1 and alpha.2 states incompatible; use memory-card saves for progress across releases. | | Automated checks | Windows Release build, code-generation audit, and all 59 root CTests passed. | @@ -35,5 +35,5 @@ and public-release validation are incomplete. individually exercised live. - Live volume adjustment and its relaunch persistence need user validation. -See [build details](docs/BUILD.md) and [disc requirements](DISC.md) before +See [build details](BUILD.md) and [disc requirements](DISC.md) before building or reporting a problem. diff --git a/docs/images/launcher.png b/docs/images/launcher.png index a8d0b6a..42ce75f 100644 Binary files a/docs/images/launcher.png and b/docs/images/launcher.png differ diff --git a/input/README.md b/input/README.md index 5523d20..75ef03d 100644 --- a/input/README.md +++ b/input/README.md @@ -5,7 +5,7 @@ Copy these files from your own **Disruptor (USA, SLUS-00224)** disc here: - `SLUS_002.24` - `Disruptor (USA).cue` and the `.bin` track named in its `FILE` entry -Check the hashes and MODE2/2352 format in [DISC.md](../DISC.md). Build and run +Check the hashes and MODE2/2352 format in [DISC.md](../docs/DISC.md). Build and run instructions are in [docs/BUILD.md](../docs/BUILD.md). This directory's game files and the generated `SLUS_002.24.code` analysis image diff --git a/psxrecomp-overlay/runtime/include/save_point.h b/psxrecomp-overlay/runtime/include/save_point.h new file mode 100644 index 0000000..0de4332 --- /dev/null +++ b/psxrecomp-overlay/runtime/include/save_point.h @@ -0,0 +1,26 @@ +/* When an interrupt check polls for a waiting save or load of a state. */ +#ifndef PSXRECOMP_SAVE_POINT_H +#define PSXRECOMP_SAVE_POINT_H + +#ifdef __cplusplus +extern "C" { +#endif + +int savestate_pending(void); + +/* 1 when this check must poll. A periodic poll that meets a request at a check with no address of its own is owed to the next that has one. */ +static inline int psx_save_poll_due(int periodic, int save_point, int *owed) { + if (!save_point) { + if (periodic) *owed = savestate_pending(); + return 0; + } + if (!periodic && !*owed) return 0; + *owed = 0; + return 1; +} + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/psxrecomp-overlay/runtime/src/interrupts.c b/psxrecomp-overlay/runtime/src/interrupts.c new file mode 100644 index 0000000..f105a56 --- /dev/null +++ b/psxrecomp-overlay/runtime/src/interrupts.c @@ -0,0 +1,1669 @@ +/* + * interrupts.c — v4 interrupt delivery for recompiled BIOS. + * + * Pure hardware simulation. No BIOS state, no HLE, no interpreter. + * + * Since recompiled code runs as native C (no per-instruction stepping), + * interrupt delivery happens at dispatch loop boundaries. The dispatch + * loop calls psx_check_interrupts() after each function returns. + * + * Vblank is fired on a dispatch-count schedule to approximate 60 Hz. + * + * ReturnFromException handling: + * On real hardware, ReturnFromException (B0:0x17 or SYSCALL(3)) + * restores the full register context from the TCB and jumps to the + * saved EPC. This effectively "longjmps" out of the exception + * handler, bypassing any remaining chain-walk code. + * + * In our recompiled model the exception handler runs as a nested + * psx_dispatch call. A normal function return would unwind only one + * frame, leaving the chain walker running with corrupted registers. + * We use setjmp/longjmp to model the real hardware behaviour: + * psx_check_interrupts sets a jump point before dispatching the + * handler, and psx_exception_longjmp() (called by the runtime's + * ReturnFromException implementation) longjmps back, unwinding the + * entire handler call tree in one step. + */ + +#include "interrupts.h" +#include "sio.h" +#include "timers.h" +#include "gpu.h" +#include "cdrom.h" +#include "dma.h" +#include "cpu_state.h" +#include "debug_server.h" +#include "event_ring.h" +#include "lockstep.h" +#include "psx_cycles.h" +#include "psx_scheduler.h" +#include "save_point.h" +#include +#include +#include +#include + +/* Event-timeline ring: execution-mode flag owned by dirty_ram_interp.c. The + * static BIOS exception handler is NOT interp code, so we clear it around the + * handler dispatch (see psx_check_interrupts). */ +extern int g_dirty_interp_active; +extern uint32_t g_dirty_safe_resume_pc; + +/* IRQ-delivery context ring (MMX6 VSync-vs-CD-DMA hunt; dumped via `irqctx_ring`). */ +#define IRQCTX_RING_CAP 4096u +typedef struct { + uint64_t seq, cycle; + uint32_t frame, istat, imask, sr, d44, cdrom_active, is_vblank; + int dma_depth; + /* Exception-EXIT half (Tomba menu IRQ-resume wedge): filled when this + * delivery returns to the interrupted code, so the ring shows for EVERY + * delivery which escape path fired and whether the interrupted GPRs were + * restored. take_pc/real_epc are the entry-side resume selection. */ + uint32_t take_pc; /* resume PC selected at entry (0 = boundary/sentinel) */ + uint32_t real_epc; /* g_exception_real_epc installed at entry */ + uint32_t exit_pc; /* cpu->pc at the restore decision */ + uint32_t exit_reason; /* g_exc_escape_reason at exit */ + uint32_t same_thread; /* same_thread_resume discriminator result */ + uint32_t restored; /* saved_gpr restore fired */ + uint32_t v1_exit; /* cpu->gpr[3] at exit before restore decision */ + uint32_t v1_saved; /* saved_gpr[3] (interrupted code's v1) */ + uint32_t ra_exit; /* cpu->gpr[31] at exit before restore decision */ + uint32_t ra_saved; /* saved_gpr[31] */ + uint32_t redirects; /* jmp_val==2 RestoreState redirects in this delivery */ + /* Ape memcard native<->interp resume-desync probe: the live sp and the + * dirty pump-site at IRQ ENTRY. The interrupted-thread TCB save captures + * exactly this (resume_pc=take_pc, sp=entry_sp); a pair where take_pc is a + * dirty return point but entry_sp belongs to a native callee's frame is the + * corrupt snapshot (0x801384AC paired with 0x801EFE80). */ + uint32_t entry_sp; /* cpu->gpr[29] at exception entry */ + uint32_t pump_site; /* g_cosim_dirty_pump_site at entry (which delivery path) */ +} IrqCtxEntry; +IrqCtxEntry g_irqctx_ring[IRQCTX_RING_CAP]; +uint64_t g_irqctx_seq = 0; +extern uint64_t psx_get_cycle_count(void); + +/* Record an interrupt-delivery decision into the event ring. GATE outcomes are + * edge-suppressed (they repeat every block while blocked); DELIVER is always + * recorded (once per interrupt); the not-pending (idle) case emits nothing but + * still updates the edge key so the next gate/deliver is captured. */ +static void irq_record_outcome(uint8_t kind, uint8_t detail, uint32_t aux) { + static uint16_t s_last = 0xFFFFu; + uint16_t key = ((uint16_t)kind << 8) | detail; + int repeat = (key == s_last); + s_last = key; + if (kind == EV_NONE) return; /* idle: update key only */ + /* IRQ_DELIVER: aux carries the architectural TAKE-PC (the resume PC), so the + * interrupt-take point can be compared across backends at the SAME guest + * cycle — native takes the IRQ at a basic-block boundary, the dirty-interp + * per-instruction. A take-PC that diverges at identical cycle is the + * take-point-granularity signature (PRINCIPLES "Control Flow Semantics": + * model the interrupt frame explicitly; the class fix is precise event + * slicing). The independent Beetle oracle records the same field. */ + if (kind == EV_IRQ_DELIVER) { event_ring_record_aux(EV_IRQ_DELIVER, detail, aux); return; } + if (!repeat) event_ring_record(kind, detail);/* GATE: edge only */ +} + +/* COP0 register indices */ +#define COP0_SR 12 +#define COP0_CAUSE 13 +#define COP0_EPC 14 + +/* I_STAT and I_MASK are owned by memory.c */ +extern uint32_t i_stat; +extern uint32_t i_mask; + +/* Device-event cycle ring (device_trace.c): every hardware IRQ-raise edge is + * recorded here with its guest cycle. */ +#include "device_trace.h" + +/* Central IRQ-raise choke point. All device sources call this to set their + * I_STAT bit so the device-event ring sees every raise from one place with the + * exact guest cycle. Pure addition over `i_stat |= (1<seq = s_cosim_irq_seq++; + e->cycle = psx_get_cycle_count(); + e->kind = kind; + e->cpu_pc = cpu ? cpu->pc : 0; + e->take_pc = take_pc; + e->dirty_resume_pc = dirty_resume_pc; + e->compiled_resume_pc = compiled_resume_pc; + e->epc = cpu ? cpu->cop0[COP0_EPC] : 0; + e->sr_before = sr_before; + e->sr_after = cpu ? cpu->cop0[COP0_SR] : 0; + e->cause = cpu ? cpu->cop0[COP0_CAUSE] : 0; + e->istat = i_stat; + e->imask = i_mask; + e->func = g_debug_current_func_addr; + e->block = cosim_last_block(); + e->native = overlay_loader_get_inprogress(); + { /* remaining cooldown cycles (0 = inactive) for the cosim ring display */ + uint64_t now = psx_get_cycle_count(); + e->cooldown = (post_exception_cooldown_until > now) + ? (int32_t)(post_exception_cooldown_until - now) : 0; + } + e->dirty_site = g_cosim_dirty_pump_site; +} + +void interrupts_cosim_irq_dump(char *out, int cap) +{ + if (!out || cap <= 0) return; + char *p = out; + size_t rem = (size_t)cap; + uint64_t total = s_cosim_irq_seq < COSIM_IRQ_RING_CAP ? s_cosim_irq_seq : COSIM_IRQ_RING_CAP; + uint64_t count = total; + if (count > 16u) count = 16u; + int w = snprintf(p, rem, "irqtrace count %llu", + (unsigned long long)count); + if (w < 0 || (size_t)w >= rem) { out[cap - 1] = 0; return; } + p += w; rem -= (size_t)w; + for (uint64_t i = 0; i < count && rem > 1; i++) { + uint64_t seq = s_cosim_irq_seq - count + i; + CosimIrqEntry *e = &s_cosim_irq_ring[seq & (COSIM_IRQ_RING_CAP - 1u)]; + w = snprintf(p, rem, + " ; seq %llu kind %u cyc %llu pc %08x take %08x dirty %08x compiled %08x epc %08x sr0 %08x sr1 %08x cause %08x istat %08x imask %08x func %08x block %08x native %08x cool %d site %d", + (unsigned long long)e->seq, + e->kind, + (unsigned long long)e->cycle, + e->cpu_pc, e->take_pc, e->dirty_resume_pc, + e->compiled_resume_pc, e->epc, e->sr_before, + e->sr_after, e->cause, e->istat, e->imask, + e->func, e->block, e->native, + e->cooldown, e->dirty_site); + if (w < 0 || (size_t)w >= rem) { break; } + p += w; rem -= (size_t)w; + } + w = snprintf(p, rem, " | deliveries"); + if (w >= 0 && (size_t)w < rem) { p += w; rem -= (size_t)w; } + uint64_t emitted = 0; + for (uint64_t scanned = 0; scanned < total && emitted < 16u && rem > 1; scanned++) { + uint64_t seq = s_cosim_irq_seq - 1u - scanned; + CosimIrqEntry *e = &s_cosim_irq_ring[seq & (COSIM_IRQ_RING_CAP - 1u)]; + if (e->kind != 1u) continue; + w = snprintf(p, rem, + " ; seq %llu cyc %llu pc %08x take %08x dirty %08x compiled %08x epc %08x sr0 %08x sr1 %08x cause %08x istat %08x imask %08x func %08x block %08x native %08x cool %d site %d", + (unsigned long long)e->seq, + (unsigned long long)e->cycle, + e->cpu_pc, e->take_pc, e->dirty_resume_pc, + e->compiled_resume_pc, e->epc, e->sr_before, + e->sr_after, e->cause, e->istat, e->imask, + e->func, e->block, e->native, + e->cooldown, e->dirty_site); + if (w < 0 || (size_t)w >= rem) { break; } + p += w; rem -= (size_t)w; + emitted++; + } + uint64_t latest_delivery = UINT64_MAX; + for (uint64_t scanned = 0; scanned < total; scanned++) { + uint64_t seq = s_cosim_irq_seq - 1u - scanned; + CosimIrqEntry *e = &s_cosim_irq_ring[seq & (COSIM_IRQ_RING_CAP - 1u)]; + if (e->kind == 1u) { latest_delivery = e->seq; break; } + } + if (latest_delivery != UINT64_MAX && rem > 1) { + w = snprintf(p, rem, " | pre_delivery"); + if (w >= 0 && (size_t)w < rem) { p += w; rem -= (size_t)w; } + uint64_t first = (latest_delivery >= 15u) ? (latest_delivery - 15u) : 0u; + for (uint64_t seq = first; seq <= latest_delivery && rem > 1; seq++) { + CosimIrqEntry *e = &s_cosim_irq_ring[seq & (COSIM_IRQ_RING_CAP - 1u)]; + w = snprintf(p, rem, + " ; seq %llu kind %u cyc %llu pc %08x take %08x dirty %08x compiled %08x epc %08x sr0 %08x sr1 %08x cause %08x istat %08x imask %08x func %08x block %08x native %08x cool %d site %d", + (unsigned long long)e->seq, + e->kind, + (unsigned long long)e->cycle, + e->cpu_pc, e->take_pc, e->dirty_resume_pc, + e->compiled_resume_pc, e->epc, e->sr_before, + e->sr_after, e->cause, e->istat, e->imask, + e->func, e->block, e->native, + e->cooldown, e->dirty_site); + if (w < 0 || (size_t)w >= rem) { break; } + p += w; rem -= (size_t)w; + } + } + if (rem >= 2) { + p[0] = '\n'; + p[1] = 0; + } else if (cap == 1) { + out[0] = 0; + } else { + out[cap - 2] = '\n'; + out[cap - 1] = 0; + } +} +#endif + +static void note_sio_progress_cycle(void) { + uint32_t cur_sio_seq = sio_get_seq(); + if (cur_sio_seq != last_sio_seq_seen) { + last_sio_seq_seen = cur_sio_seq; + last_sio_progress_cycle = psx_get_cycle_count(); + } +} + +static int should_defer_vblank_for_sio(void) { + if (!sio_card_protocol_active()) return 0; + uint64_t now = psx_get_cycle_count(); + uint64_t since_progress = now >= last_sio_progress_cycle + ? now - last_sio_progress_cycle + : 0; + return since_progress < VBLANK_DEFER_STALE_CYCLES; +} + +static void fire_vblank_edge(void) { + /* Subtract one VBlank period rather than reset to 0 so cycle overshoot + * carries forward. Prevents long-running blocks from rounding multiple + * VBlanks together. */ + cycles_since_vblank -= VBLANK_CYCLES; + dispatch_count = 0; + /* DEQUEUE: this VBlank fired. ENQUEUE: next VBlank scheduled one period out. */ + event_ring_record_aux(EV_DEQ, (uint8_t)SRC_VBLANK, + (uint32_t)psx_get_cycle_count()); + event_ring_record_aux(EV_ENQ, (uint8_t)SRC_VBLANK, + (uint32_t)(psx_get_cycle_count() + VBLANK_CYCLES)); + psx_irq_raise(IRQ_VBLANK, 0); + g_vblank_raise_count++; + event_ring_record(EV_ISTAT_RAISE, IRQ_VBLANK); + gpu_vblank_tick(); /* Toggle LCF (GPUSTAT bit 31) */ +#ifndef PSX_ENABLE_BLOCK_CYCLES + timers_tick(33868); /* ~1 NTSC frame worth of cycles */ + cdrom_tick(); /* Process pending CDROM responses */ +#endif +} + +void interrupts_service_scheduled_events(void) { + note_sio_progress_cycle(); + if (in_exception) return; + while (cycles_since_vblank >= VBLANK_CYCLES) { + if (should_defer_vblank_for_sio()) return; + fire_vblank_edge(); + } +} + +uint32_t interrupts_cycles_to_vblank(void) { + if (cycles_since_vblank >= VBLANK_CYCLES) return 0; + return VBLANK_CYCLES - cycles_since_vblank; +} + +void interrupts_advance_cycles(uint32_t cycles) { + cycles_since_vblank += cycles; + interrupts_service_scheduled_events(); +} + +/* Diagnostic: total times the exception handler was entered (in_exception + * transitioned 0->1). Diff this between two snapshots to measure handler + * dispatch rate. */ +static uint64_t exception_entries_total; + +/* Diagnostic: psx_check_interrupts called while already in_exception (the + * `return` path at line ~159). Real hardware can't double-fault into the + * same handler; on our model this should stay near zero — non-zero means + * something is calling psx_check_interrupts from inside the recompiled + * exception handler tree. */ +static uint64_t exception_reentry_blocks; +/* Nested-exception depth (0 = not in exception). in_exception stays the + * boolean the rest of the runtime reads; this tracks how deep the + * synchronous-handler recursion is so the nested-delivery path (hardware + * IEc-re-enabled semantics, see the gate below) can cap host-stack use. */ +static int exception_nest_depth; +/* Nested-delivery gate veto attribution (freeze_check). One counter per guard + * in the refinement gate below; first-true-wins ordering matches the gate. */ +uint64_t g_nestgate_depth, g_nestgate_rfepend, g_nestgate_escreason, g_nestgate_iec; + +/* After the exception handler returns, suppress the next interrupt delivery + * to give the interrupted code at least one block of execution — matching + * real hardware where at least one instruction runs after RFE before the + * pending interrupt can re-fire. Without this, unhandled interrupts cause + * a livelock: the handler runs, doesn't clear I_STAT, returns, and the + * very next psx_check_interrupts re-enters immediately. */ +/* IRQ raise/deliver/ack telemetry (Tomba 2 exception-reentry-storm diagnosis). + * Always-on, surfaced in the freeze heartbeat. Distinguishes: + * raise≈deliver≈ack≈1/frame → healthy + * deliver≫ack → handler runs but doesn't clear i_stat (re-delivered) + * raise≫1/frame → something keeps RE-RAISING the bit (cycle/condition bug) + * g_vblank_ack_count is incremented in memory.c at the I_STAT write that clears + * the VBLANK bit. The others are incremented here. */ +uint64_t g_vblank_raise_count = 0; /* bit0 set at the cycle-paced raise site */ +uint64_t g_vblank_deliver_count = 0; /* VBLANK delivered to the guest (exception taken) */ +uint64_t g_irq_deliver_count = 0; /* ANY hardware interrupt delivered */ +uint64_t g_cdrom_deliver_count = 0; /* CD IRQ (IRQ_CDROM) delivered to the guest — FMV dispatch probe */ +extern uint64_t g_vblank_ack_count; /* defined in memory.c */ + +/* Blocks of guaranteed main-code forward progress imposed after a CLAIMED + * non-SIO interrupt (DMA/VBLANK/timer/...). With cooldown 0 (the prior blanket + * policy, commit 6d2cb65) the block leader at the interrupted PC re-takes the + * exception before the block body executes, so under a fast-disc DMA flood the + * main code is pinned at one PC forever (reentry-storm freeze). A few blocks + * guarantee the interrupted block — and the field loop — advance between + * deliveries. SIO is exempt (card reads need immediate back-to-back IRQs). + * + * FAITHFULNESS (MMX5 cp-3259 fork, 2026-07-04): the cooldown window is counted + * in GUEST CYCLES, not psx_check_interrupts CALLS. A call is a backend-dependent + * unit — the compiled path checks at block edges, the dirty-RAM interp checks + * per-instruction, and compiled has extra check sites (e.g. at a `jr ra` return + * transition) the interp lacks. A per-call countdown therefore reached zero at a + * DIFFERENT guest cycle in each backend → IRQ delivered at a different cycle/EPC + * → the two backends forked (cosim first-divergence cp 3259 @ cyc ~213.5M). Guest + * cycles are advanced identically by both backends (shared cycle-cost model, ruler- + * validated), so a guest-cycle deadline makes the delivery decision backend- + * independent. Magnitudes below preserve the old behavior's breathing room: the + * claimed window ~= the old "8 blocks", the unclaimed ~= the old "500 blocks" + * (a compiled block edge ~= one psx_check_interrupts call ~= ~15 guest cycles). */ +#define POST_EXC_CLAIMED_COOLDOWN_CYCLES 128u /* claimed non-SIO: a few blocks */ +#define POST_EXC_UNCLAIMED_COOLDOWN_CYCLES 8192u /* unclaimed: generous boot window */ + +/* setjmp target for ReturnFromException during handler dispatch. + * + * IMPORTANT: longjmp(exception_jmpbuf, ...) MUST execute on the same + * Windows fiber that called setjmp; otherwise RSP is restored to that + * fiber's stack while the OS still tracks a different fiber as current, + * corrupting fiber state and eventually deadlocking SwitchToFiber. + * + * s_exception_owner_fiber records which fiber called setjmp. If a + * longjmp request originates on a different fiber, the caller must: + * (1) set s_pending_exception_longjmp = code + * (2) SwitchToFiber back to s_exception_owner_fiber + * That fiber's wrapped SwitchToFiber call will observe the flag on + * return and execute the longjmp on the correct stack. */ +jmp_buf exception_jmpbuf; /* non-static so traps.c can deferred-longjmp */ +/* Monotonic epoch of the exception setjmp frame: bumped each time the setjmp + * loop is (re)armed at exception entry. Lets an observer holding a frame on + * the C stack (the overlay shadow-diff, run_shadow_diff) decide whether a + * longjmp targets a frame BELOW it (armed before it — the unwind escapes its + * frame and skips its epilogue) or a frame armed after it (contained). The + * single global exception_jmpbuf always holds the latest-armed frame, so the + * current epoch identifies every longjmp's target. */ +uint64_t g_exc_setjmp_epoch = 0; +uint64_t psx_exception_setjmp_epoch(void) { return g_exc_setjmp_epoch; } +/* The fiber that owns the current exception setjmp. A longjmp must run on + * that same fiber/stack, so a non-owner defers by switching back to it + * first (see deferred_exception_longjmp). Used on all platforms now that + * the thread scheduler is fiber-based everywhere. */ +void* g_exception_owner_fiber = NULL; +int g_pending_exception_longjmp = 0; +extern int g_psx_dispatch_depth; + +/* Set by psx_check_interrupts_at while a compiled block-leader interrupt check + * is in progress. If the delivered handler later RFEs to the sentinel outside + * the synchronous host window, dirty_ram_dispatch can resume at this guest PC + * instead of treating the sentinel as pc=0 termination. */ +static uint32_t s_compiled_interrupt_resume_pc = 0; +/* Set by psx_check_interrupts_at for the one call it makes: in any other call the latch above is an earlier call's. */ +static int s_resume_pc_given = 0; +static int s_save_poll_owed = 0; +extern void savestate_poll(CPUState* cpu, uint32_t resume_pc); +static uint32_t s_last_interrupt_check_pc = 0; +static uint64_t s_last_interrupt_check_cycle = UINT64_MAX; + +/* Deferred cooperative thread switch from nested exception delivery. + * + * A genuine in-exception ChangeThread (kind-30, escape site below) must be + * honored at the OUTERMOST dispatch boundary, never mid-nested-host-dispatch. + * Only at the outermost boundary is the outgoing thread's guest state fully + * materialized in CPUState (its true block PC is live, GPRs current); nested + * inside a call unit / dirty pump the PC lives only in the resume-PC latch, + * which desyncs from the live GPRs when the IRQ was taken deep inside a nested + * call (the async-poll callback), so switching there saves a poisoned context + * (latched PC ahead of the callback's registers) → the resumed thread dispatches + * a jumptable with a smeared index → misaligned DISPATCH FATAL. When a switch is + * detected nested, we record it here, let the outgoing thread resume + * transparently, and honor it at the next outermost block-leader boundary (top + * of psx_check_interrupts) where the thread can be re-saved cleanly. */ +static int s_defer_switch_pending = 0; +static uint32_t s_defer_switch_target = 0; /* TCB PCB[0] should name after the switch */ +static uint32_t s_defer_switch_from = 0; /* the interrupted thread to re-save cleanly */ + +/* A/B toggle: PSX_DEFER_SWITCH=0 forces the legacy immediate-switch behavior + * (longjmp the instant a mid-exception ChangeThread is detected, regardless of + * nesting) so the deferred-switch fix can be compared against the baseline + * without a rebuild. Default ON. Latched at first query. */ +static int defer_switch_enabled(void) { + static int s = -1; + if (s < 0) { const char *e = getenv("PSX_DEFER_SWITCH"); s = (e && e[0] == '0') ? 0 : 1; } + return s; +} + +static int same_guest_pc(uint32_t a, uint32_t b) { + return (((a ^ b) & 0x1FFFFFFFu) == 0); +} + +int psx_get_in_exception(void) { return in_exception; } + +/* Co-sim (COSIM_ORACLE.md): fold the GENUINE guest-timing interrupt statics into the + * state hash. Deliberately EXCLUDES total_checks / dispatch_count / post_exception_ + * cooldown / s_compiled_interrupt_resume_pc — those are counted in psx_check_interrupts + * CALLS or are backend-internal, so they legitimately differ between the compiled and + * interp backends by call frequency and would be a false first-divergence. The one + * quantity that is a real guest-cycle timing value is cycles_since_vblank (drives the + * VBLANK deadline); in_exception mirrors the architectural handler-active state. */ +uint64_t interrupts_cosim_hash(uint64_t h) { + const uint64_t P = 1099511628211ULL; + uint32_t v = cycles_since_vblank; + for (int i = 0; i < 4; i++) { h ^= (uint8_t)(v >> (i*8)); h *= P; } + uint64_t sp = last_sio_progress_cycle; + for (int i = 0; i < 8; i++) { h ^= (uint8_t)(sp >> (i*8)); h *= P; } + h ^= (uint8_t)in_exception; h *= P; + return h; +} +/* Co-sim field dump: expose the exact irqctl-timing fields so a divergence can be + * field-diffed (is it cycles_since_vblank = VBLANK-raise-timing, or in_exception, ...). */ +void interrupts_cosim_dump(uint32_t *csv, int *inexc) { + if (csv) *csv = cycles_since_vblank; + if (inexc) *inexc = in_exception; +} + +/* ===== Fix B: faithful exception-return escape state (see psx_runtime.h) ===== */ +int g_rfe_escape_pending = 0; +int g_exc_escape_reason = PSX_EXC_ESCAPE_NONE; +uint32_t g_exception_real_epc = 0; +extern void psx_exception_longjmp(void); + +/* Called by the recompiled `rfe` opcode (after it pops the COP0 SR stack). When we + * are inside the SYNCHRONOUS exception-handler window (in_exception) AND the handler + * is returning to a REAL EPC (not the legacy boundary-sentinel fallback), arm the + * host escape so the trampoline unwinds back to psx_check_interrupts after the jr + * commits cpu->pc = real EPC. On a fiber/thread resume (in_exception==0) this is a + * no-op and the real EPC is dispatched directly — that is how a suspended thread + * resumes at its own PC. */ +void psx_rfe_mark_escape(void) { + if (!in_exception) return; + /* Arm on EVERY handler-window RFE, including LEGACY_SENTINEL deliveries. + * The old blanket decline assumed a sentinel delivery always resumes AT + * the sentinel — but a handler that switches threads (the guest's own + * cooperative scheduler moving PCB[0] in its VBlank callback) RFEs to the + * NEW thread's real EPC instead; with nothing armed, no exit path ever + * fires and in_exception wedges the VBlank authority forever (Tomba 2 + * splash livelock). The take site keeps the legacy contract intact: a + * resume that DOES land on the sentinel still declines there and exits + * through the sentinel detector's saved-GPR restore. */ + g_rfe_escape_pending = 1; + if (g_exc_escape_reason != PSX_EXC_ESCAPE_LEGACY_SENTINEL) + g_exc_escape_reason = PSX_EXC_ESCAPE_RFE_RETURN; +} + +/* Called in the dispatch trampoline after a function returns (cpu->pc holds the + * real resume EPC). If an RFE armed the escape inside the synchronous handler, + * longjmp back to psx_check_interrupts (cpu->pc preserved across the longjmp). */ +void psx_rfe_escape_check(CPUState* cpu) { + /* Escape ONLY when this RFE is the synchronous handler completing on the SAME + * fiber that set up the exception (the owner of exception_jmpbuf). in_exception + * is a single global, so a thread RESUMED on a different fiber (its own real EPC + * was restored and it later RFEs) must NOT longjmp here — that would be a + * cross-fiber jump to a stale jmpbuf (crash). For that case we fall through and + * the trampoline keeps dispatching cpu->pc = the real resume EPC, which is + * exactly how a suspended thread continues at its own PC. */ + extern void* psx_fiber_current(void); /* psx_fiber.h is included further down */ + if (g_rfe_escape_pending && in_exception && + cpu->pc != PSX_EXC_SENTINEL_PC && + psx_fiber_current() == g_exception_owner_fiber) { + g_rfe_escape_pending = 0; + /* A LEGACY_SENTINEL delivery escaping to a NON-sentinel PC means the + * handler switched threads and RFE'd to the new thread's real EPC. + * Record the completion truthfully as an RFE return so the epilogue's + * restore discriminator does NOT overwrite the kernel's freshly + * restored TCB registers with the ENTERING thread's saved_gpr (the + * cross-thread corruption Fix B exists to prevent). A resume that + * lands ON the sentinel never reaches this longjmp (declined above): + * it exits through the legacy sentinel detector, whose saved-GPR + * restore contract stays keyed on the untouched LEGACY reason. */ + if (g_exc_escape_reason == PSX_EXC_ESCAPE_LEGACY_SENTINEL) + g_exc_escape_reason = PSX_EXC_ESCAPE_RFE_RETURN; + /* Same shadow-frame hardening as deferred_exception_longjmp. */ + extern void overlay_loader_shadow_escape_fixup(uint64_t target_epoch); + overlay_loader_shadow_escape_fixup(g_exc_setjmp_epoch); + longjmp(exception_jmpbuf, 1); /* same fiber: safe; lands in psx_check_interrupts */ + } +} + +void psx_get_freeze_diag(uint64_t *out_total_checks, + uint32_t *out_dispatch_count, + int *out_in_exception, + int *out_post_exc_cooldown, + uint64_t *out_exc_entries, + uint64_t *out_exc_reentry_blocks) { + if (out_total_checks) *out_total_checks = total_checks; + if (out_dispatch_count) *out_dispatch_count = dispatch_count; + if (out_in_exception) *out_in_exception = in_exception; + if (out_post_exc_cooldown) { + uint64_t now = psx_get_cycle_count(); + *out_post_exc_cooldown = (post_exception_cooldown_until > now) + ? (int)(post_exception_cooldown_until - now) : 0; + } + if (out_exc_entries) *out_exc_entries = exception_entries_total; + if (out_exc_reentry_blocks) *out_exc_reentry_blocks = exception_reentry_blocks; +} + +void interrupts_init(void) { + dispatch_count = 0; + in_exception = 0; + exception_nest_depth = 0; + g_psx_dispatch_depth = 0; + total_checks = 0; + post_exception_cooldown_until = 0; + exception_entries_total = 0; + exception_reentry_blocks = 0; + cycles_since_vblank = 0; + /* Rematch / session_reboot re-enters bring-up without process exit. + * Stale I_STAT/I_MASK from the prior match (e.g. VBlank pending + game + * mask) makes boot take a phantom IRQ at ~cycle 4 and wedge under + * netplay lockstep. Cold boot also wants zeros (BSS already is). */ + i_stat = 0; + i_mask = 0; + g_vblank_raise_count = 0; + last_sio_seq_seen = sio_get_seq(); + last_sio_progress_cycle = psx_get_cycle_count(); +} + +/* + * Called by the runtime's ReturnFromException implementation (traps.c) + * when the recompiled BIOS handler or a chain callback invokes + * B0:0x17 or SYSCALL(3) during exception handling. + * + * At this point the caller has already: + * - Restored all GPRs from the TCB save area + * - Done RFE on the saved SR + * - Set cpu->pc = saved EPC + * + * We longjmp back to psx_check_interrupts, which will clear + * in_exception and return, effectively letting the interrupted + * code resume at the saved EPC through normal dispatch. + */ +#include "psx_fiber.h" +/* Defer a longjmp to the fiber that owns the current exception setjmp. + * If we're on the owning fiber already, longjmp immediately. Otherwise + * record the requested code, switch back to the owner, and the post-switch + * check in traps.c psx_change_thread_fiber executes the longjmp on the + * correct stack. */ +static void deferred_exception_longjmp(int code) { + /* If this unwind blows through a live shadow-diff frame (a frame armed + * BEFORE the shadow started), restore the shadow-scoped globals its + * skipped epilogue would have restored — otherwise the diff instrument + * silently dies for the rest of the run (s_in_shadow stuck). No-op when + * no shadow is live or the target frame was armed inside the shadow. */ + extern void overlay_loader_shadow_escape_fixup(uint64_t target_epoch); + overlay_loader_shadow_escape_fixup(g_exc_setjmp_epoch); + if (!g_exception_owner_fiber || psx_fiber_current() == g_exception_owner_fiber) { + longjmp(exception_jmpbuf, code); + } + g_pending_exception_longjmp = code; + psx_fiber_switch(g_exception_owner_fiber); + /* If we end up back here, the owner didn't honor the flag (bug). + * Fall through to direct longjmp as a last resort — even though + * the stack is wrong, the alternative is hanging silently. */ + longjmp(exception_jmpbuf, code); +} + +void psx_exception_longjmp(void) { + debug_server_log_restore_event(2, debug_cpu_ptr ? debug_cpu_ptr->pc : 0, 1); + deferred_exception_longjmp(1); +} + +void psx_restore_state_escape(void) { + if (in_exception) { + debug_server_log_restore_event(1, debug_cpu_ptr ? debug_cpu_ptr->pc : 0, 2); + deferred_exception_longjmp(2); + } + /* Not in exception context — return normally, let caller's `return;` handle it. */ +} + +/* Cycle-budgeted precise event slicing — single source of truth. + * + * Returns the minimum guest-CPU-cycle distance to the next DELIVERABLE hardware + * interrupt: a source raises its I_STAT bit AND that bit is unmasked in i_mask + * (CPU-side SR/IE gating is checked separately at take time). UINT32_MAX means + * no maskable hardware event is currently scheduled. + * + * The two-tier block executor uses this at each block leader: if a whole block + * fits inside this distance (and the block has no IRQ-visibility side effects), + * it runs as fast compiled C; otherwise it runs through the per-instruction + * interpreter so the interrupt is taken at its exact architectural cycle (see + * PRECISE_IRQ_SLICE.md). Each peripheral owns its own next-IRQ query; this just + * takes the min. Every query is a conservative UNDER-estimate (smaller distance + * => slice more => never run past an event), so this is too. */ +uint32_t cycles_to_next_event(void) { + uint32_t best = 0xFFFFFFFFu; + /* VBLANK is paced here (interrupts.c owns cycles_since_vblank). The deferred + * card-SIO case only pushes VBlank LATER, so this estimate stays a safe + * under-estimate. */ + if (i_mask & (1u << IRQ_VBLANK)) { + uint32_t d = (cycles_since_vblank >= VBLANK_CYCLES) + ? 0u : (VBLANK_CYCLES - cycles_since_vblank); + if (d < best) best = d; + } + uint32_t t = timers_cycles_to_irq(i_mask); if (t < best) best = t; + uint32_t c = cdrom_cycles_to_irq(i_mask); if (c < best) best = c; + uint32_t d = dma_cycles_to_irq(i_mask); if (d < best) best = d; + uint32_t s = sio_cycles_to_irq(i_mask); if (s < best) best = s; + return best; +} + +static uint64_t s_need_defer, s_need_irq, s_skip_none, s_skip_sr; +static uint64_t s_skip_cooldown, s_skip_nested; + +void psx_interrupt_delivery_diag(uint64_t *need_defer, uint64_t *need_irq, + uint64_t *skip_none, uint64_t *skip_sr, + uint64_t *skip_cooldown, uint64_t *skip_nested) { + if (need_defer) *need_defer = s_need_defer; + if (need_irq) *need_irq = s_need_irq; + if (skip_none) *skip_none = s_skip_none; + if (skip_sr) *skip_sr = s_skip_sr; + if (skip_cooldown) *skip_cooldown = s_skip_cooldown; + if (skip_nested) *skip_nested = s_skip_nested; +} + +int psx_interrupt_delivery_needed(const CPUState* cpu) { + if (s_defer_switch_pending) { s_need_defer++; return 1; } + if ((i_stat & i_mask) == 0) { s_skip_none++; return 0; } + + uint32_t sr = cpu->cop0[COP0_SR]; + if (!(sr & 0x01u) || !(sr & (1u << 10))) { s_skip_sr++; return 0; } + + if (post_exception_cooldown_until != 0 && + psx_get_cycle_count() < post_exception_cooldown_until) { + s_skip_cooldown++; + return 0; + } + + if (in_exception && + (exception_nest_depth >= 2 || + g_rfe_escape_pending || + g_exc_escape_reason != PSX_EXC_ESCAPE_NONE)) { + s_skip_nested++; + return 0; + } + s_need_irq++; + return 1; +} + +void psx_check_interrupts(CPUState* cpu) { + const int save_point = s_resume_pc_given; + s_resume_pc_given = 0; + psx_cyc_batch_flush(); + extern int g_ls_suppress_record; +#define PSX_CHECK_INTERRUPTS_RETURN() do { if (g_ls_suppress_record > 0) g_ls_suppress_record--; return; } while (0) +#ifdef PSX_COSIM + extern uint32_t g_dirty_safe_resume_pc; +#define COSIM_IRQ_TAKE_PC() (g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc : s_compiled_interrupt_resume_pc) +#define COSIM_IRQ_NOTE(kind_) cosim_irq_note(cpu, (kind_), COSIM_IRQ_TAKE_PC(), g_dirty_safe_resume_pc, s_compiled_interrupt_resume_pc, cpu->cop0[COP0_SR]) +#endif + + /* MotK VLC / FMV hot edge: sticky unmasked I_STAT (CD/VBlank) while + * IEc or IM2 is clear — no architectural delivery possible. Skip the + * mid-path bookkeeping / irq_deliver_eval that used to run every BB. + * Guest-visible timing unchanged (same non-delivery). LTO can collapse + * this into the VLC call sites. */ + static uint32_t s_fast_maintenance; + if (!in_exception && !s_defer_switch_pending && (i_stat & i_mask) != 0) { + uint32_t sr = cpu->cop0[COP0_SR]; + if (!(sr & 0x01u) || !(sr & (1u << 10))) { + const int periodic = (++s_fast_maintenance & 0x3FFFu) == 0; + if (psx_save_poll_due(periodic, save_point, &s_save_poll_owed)) + savestate_poll(cpu, s_compiled_interrupt_resume_pc); + if (periodic) debug_server_poll(); + return; + } + } + + /* Genuine entry fast paths. Generated resident code calls this at every + * basic-block edge, which can mean tens of millions of calls inside an FMV + * polling loop. psx_advance_cycles() has already serviced every device + * deadline before this point, so no-pending and IEc-clear checks have no + * architectural work to do. The old fast paths came after diagnostics and + * savestate_poll, making a harmless callback expensive enough to reduce + * Tomba 2's Whoopee FMV to ~1 guest fps. + * + * Idle-skip used to force the slow path on every edge so + * psx_idle_note_check could observe poll PCs. That made MotK's VLC + * (branchy, changing resume PCs, idle_skip=true) pay full IRQ bookkeeping + * millions of times per second for zero skips. Keep the fast path; run the + * idle note here when enabled, then re-check I_STAT in case a skip raised + * an event. Guest timing is unchanged — skip still advances via + * psx_advance_cycles. + * + * Poll host maintenance periodically so save/load and the debug socket stay + * responsive even when the guest spends a long time in this path. At the + * observed 1M+ block edges/s this is sub-frame latency. */ + if (g_idle_skip_enabled < 0) (void)psx_idle_skip_is_enabled(); + /* COP0 software interrupts (CAUSE.IP0/IP1 vs SR.IM0/IM1, bits 8-9): raised + * purely by guest mtc0 to CAUSE, with no INTC involvement. Games use this + * as a re-entrant exception-dispatch mechanism (Jackie Chan Stuntmaster's + * handler saves SR/EPC, sets CAUSE=0x100 + SR=0x101, and relies on the + * immediate sw-int to chain into its second stage; without it the CD INT3 + * ack never runs and the machine deadlocks with SR.IM2 stripped). */ + uint32_t sw_pending = cpu->cop0[COP0_CAUSE] & cpu->cop0[COP0_SR] & 0x0300u; + if (!in_exception && (i_stat & i_mask) == 0 && sw_pending == 0 && + !s_defer_switch_pending) { + if (g_idle_skip_enabled > 0) { + uint32_t check_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc + : s_compiled_interrupt_resume_pc; + s_last_interrupt_check_pc = check_pc; + s_last_interrupt_check_cycle = psx_get_cycle_count(); + psx_idle_note_check(cpu, check_pc); + } + if ((i_stat & i_mask) == 0 && sw_pending == 0) { + const int periodic = (++s_fast_maintenance & 0x3FFFu) == 0; + if (psx_save_poll_due(periodic, save_point, &s_save_poll_owed)) + savestate_poll(cpu, s_compiled_interrupt_resume_pc); + if (periodic) debug_server_poll(); + return; + } + /* Idle skip advanced time and a device raised I_STAT — deliver below. */ + } + if (in_exception && !(cpu->cop0[COP0_SR] & 0x01u)) return; + + /* Mid path: unmasked IRQ already pending, ordinary compiled edge. + * MotK VLC / FMV spend most BB edges here (sticky CD/VBlank bits). + * Devices are already caught up via psx_advance_cycles, so skip + * service/idle/savestate housekeeping and go straight to delivery. */ + if (!in_exception && !s_defer_switch_pending && (i_stat & i_mask) != 0) { + uint32_t check_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc + : s_compiled_interrupt_resume_pc; + s_last_interrupt_check_pc = check_pc; + s_last_interrupt_check_cycle = psx_get_cycle_count(); + g_ls_suppress_record++; + total_checks++; + const int periodic = (total_checks & 0x3FFFu) == 0; + if (psx_save_poll_due(periodic, save_point, &s_save_poll_owed)) + savestate_poll(cpu, s_compiled_interrupt_resume_pc); + if (periodic) debug_server_poll(); + goto irq_deliver_eval; + } + + { + uint32_t check_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc + : s_compiled_interrupt_resume_pc; + s_last_interrupt_check_pc = check_pc; + s_last_interrupt_check_cycle = psx_get_cycle_count(); + } + g_ls_suppress_record++; + total_checks++; + if ((total_checks & 0x3FFFu) == 0) { + debug_server_poll(); + } + + /* SIO delayed IRQ delivery removed from here. + * sio_tick() is now called only from SIO register accesses + * (sio_read/sio_write) and I_STAT reads (memory.c). The BIOS + * pad detection sequence clears I_STAT bit 7 then polls I_STAT + * waiting for it to re-appear. If we tick here, the IRQ fires + * during the delay loop BEFORE the clear, and the BIOS never + * sees it. */ + + interrupts_service_scheduled_events(); + + /* Idle-loop cycle skip (psx_cycles.c): detect a side-effect-free poll + * loop by its repeated same-PC checks and fast-forward guest time to the + * next internal device event in whole loop quanta. Runs BEFORE the + * deliverability evaluation below so an event raised by the skip is + * delivered in this same check, exactly at the boundary real execution + * would have taken it. Skip when an unmasked IRQ is already pending — + * that is not an idle poll (FMV/VLC edges with sticky CD/VBlank bits), + * and the fast path already noted when I_STAT was clear. */ + if ((i_stat & i_mask) == 0) + psx_idle_note_check(cpu, s_last_interrupt_check_pc); + + /* User save states: this is a block-leader boundary with a known resume PC; + * only act outside the exception handler so a restore's stack-unwind can't + * strand a half-finished handler. Near-free when nothing is staged. A load + * longjmps to the scheduler and never returns here. */ + if (!in_exception && psx_save_poll_due(1, save_point, &s_save_poll_owed)) + savestate_poll(cpu, s_compiled_interrupt_resume_pc); + + /* Deferred cooperative thread switch: honor at the next real thread-save + * boundary (see s_defer_switch_* above). A + * genuine in-exception ChangeThread was deferred because it was detected at a + * poisoned point (the IRQ fired deep inside a nested call, e.g. the + * async-poll callback, so the resume-PC latch desynced from the live GPRs). + * + * The dirty-RAM interpreter also pumps interrupts at synthetic transfer and + * call-return sites. Those sites are precise enough to deliver hardware IRQs, + * but not necessarily safe to snapshot a cooperative thread: the interpreter + * may have committed a candidate resume PC while the live CPUState stack/GPRs + * still describe the previous local frame. Keep the deferral pending there and + * honor it only once execution reaches a normal interrupt poll with site 0. + * Near-free when nothing is pending. */ + if (s_defer_switch_pending && !in_exception) { + if (psx_hle_scheduler_enabled()) { + uint32_t from_tcb = s_defer_switch_from; + uint32_t to_tcb = s_defer_switch_target; + uint32_t resume_pc = s_last_interrupt_check_pc; /* materialized block PC */ + if (from_tcb == 0u || to_tcb == 0u || + psx_sched_current_tcb(cpu) != from_tcb) { + /* STALE: the guest already re-scheduled cooperatively (PCB[0] no + * longer names the deferred thread) - abandon the deferral. This is + * the ONLY case we clear pending without honoring; dropping when a + * clean resume PC merely isn't available yet would LOSE the guest's + * ChangeThread and wedge (the bug this replaces). */ + s_defer_switch_pending = 0; + s_defer_switch_target = 0; + s_defer_switch_from = 0; + debug_server_log_thread_event(33, cpu, from_tcb, to_tcb, resume_pc); + } else if (g_cosim_dirty_pump_site != 0) { + /* Not a real suspend boundary: dirty interpreter pump sites expose + * committed PCs for IRQ timing, but the matching CPUState may not + * be materialized yet. */ + } else if (resume_pc != 0u && (resume_pc & 0x3u) == 0u && + psx_is_dispatchable(resume_pc)) { + /* Materialized clean boundary: re-save the deferred thread cleanly + * (this OVERWRITES any poisoned/sentinel EPC the guest handler wrote + * for it while nested), re-point PCB[0], and honor the switch. */ + extern int overlay_loader_shadow_native_thread_switch_bail(void); + if (overlay_loader_shadow_native_thread_switch_bail()) + PSX_CHECK_INTERRUPTS_RETURN(); + s_defer_switch_pending = 0; + s_defer_switch_target = 0; + s_defer_switch_from = 0; + psx_sched_save_context(cpu, from_tcb, resume_pc); + psx_sched_set_current_tcb(cpu, to_tcb); + debug_server_log_thread_event(32, cpu, from_tcb, to_tcb, resume_pc); + g_dirty_interp_active = 0; + s_compiled_interrupt_resume_pc = 0; + g_sched_escape.target_tcb = to_tcb; + g_sched_escape.resume_pc = 0; + g_sched_escape.reason = PSX_RUN_YIELD_TO_TCB; + longjmp(g_scheduler_jmpbuf, 1); /* unwind to psx_scheduler_run */ + } + /* else: KEEP pending - this outermost boundary has no materialized + * resume PC (both latches 0, e.g. a scheduled-event check not tied to a + * block leader). Wait for the next block-leader boundary that does. */ + } + } + + /* Dispatch-loop maintenance only when NOT inside the exception handler. */ + if (!in_exception) { +#if SIO_MODEL_CYCLE_PACED + /* Builds without block-cycle accounting need a small dispatch-loop + * SIO quantum. With PSX_ENABLE_BLOCK_CYCLES, psx_advance_cycles() + * already drives SIO timing, so the fixed quantum would double-count. + * arms shift/ack), so this gate never opens — per-call cost on + * this hot path is one volatile load + one branch. */ +#ifndef PSX_ENABLE_BLOCK_CYCLES + if (g_sio_timing_active) { + sio_tick_quantum(); + } +#endif +#endif + dispatch_count++; + } + + /* Event ring: generic i_stat-edge backstop. Catches raises from sites we + * don't instrument precisely (memory.c MMIO acks, SIO, SPU). Bounded by + * actual transitions, not by check frequency. */ + { + static uint32_t s_last_istat = 0; + if (i_stat != s_last_istat) { + s_last_istat = i_stat; + event_ring_record(EV_ISTAT_CHANGE, 0); + } + } + +irq_deliver_eval: + /* Check if any interrupts are pending (INTC hardware or COP0 software). */ + if ((i_stat & i_mask) == 0 && sw_pending == 0) { irq_record_outcome(EV_NONE, 0, 0); PSX_CHECK_INTERRUPTS_RETURN(); } + /* Nested delivery (hardware semantics). Real R3000A has no 'in exception' + * gate — delivery is governed by SR alone. The handler normally runs with + * IEc=0 (hardware bit-shift on entry), so the SR gates below block + * re-entry faithfully on their own. A game that RE-ENABLES IEc inside the + * handler is asking for a nested interrupt and real hardware delivers it: + * Tomba2's lava attract demo parks in the kernel event-poll INSIDE the + * VBlank handler waiting for the NEXT VBlank — the old unconditional + * in_exception block starved it to ~1 fps (VBlank pending+unmasked, + * ~1100 reentry blocks/s, 1 delivery/s). The synchronous-handler + * machinery below is nesting-safe: the interrupted-context save + * (saved_gpr/hi/lo) and owner state (prev_owner_fiber, prev_pending, + * dispatch depth) are host-stack locals restored on unwind, and + * in_exception is restored to its pre-delivery value at the epilogue. + * Depth cap: a pathological IEc-re-enable loop would recurse the host + * stack; real hardware would wedge the guest instead — we stop nesting + * there (counted) so the rings expose it. */ + if (in_exception) { + /* Refinements over plain SR gating (first attempt wedged BOOT at + * f436): (1) never nest while an RFE/escape unwind is in flight — + * the guest's RFE pops SR (IEc back to 1) BEFORE the host escape + * completes, and a delivery in that window lands in half-unwound + * machinery; (2) one nesting level only — enough for the + * wait-inside-handler idiom, no host-stack pyramids. */ + if (exception_nest_depth >= 2 || + g_rfe_escape_pending || + g_exc_escape_reason != PSX_EXC_ESCAPE_NONE || + !(cpu->cop0[COP0_SR] & 0x01)) { + /* Per-guard veto attribution (always-on counters, freeze_check): + * which refinement is actually vetoing nested delivery. A wedge + * where ONE guard dominates for thousands of frames names the + * false veto (Tomba2 Whoopee-logo forensics 2026-07-03). */ + if (exception_nest_depth >= 2) g_nestgate_depth++; + else if (g_rfe_escape_pending) g_nestgate_rfepend++; + else if (g_exc_escape_reason != PSX_EXC_ESCAPE_NONE) g_nestgate_escreason++; + else g_nestgate_iec++; + exception_reentry_blocks++; + irq_record_outcome(EV_IRQ_GATE, GATE_IN_EXCEPTION, 0); +#ifdef PSX_COSIM + COSIM_IRQ_NOTE(2u); +#endif + PSX_CHECK_INTERRUPTS_RETURN(); + } + /* IEc re-enabled inside the handler: fall through to the SR gates + * and deliver a nested exception, as hardware would. */ + } + + /* Post-exception cooldown: let at least one block of guest time elapse after + * RFE before the next delivery. Gated on the guest-cycle deadline (not a + * per-call countdown) so compiled and interp agree on the delivery cycle. */ + if (post_exception_cooldown_until != 0) { + if (psx_get_cycle_count() < post_exception_cooldown_until) { + irq_record_outcome(EV_IRQ_GATE, GATE_COOLDOWN, 0); +#ifdef PSX_COSIM + COSIM_IRQ_NOTE(3u); +#endif + PSX_CHECK_INTERRUPTS_RETURN(); + } + post_exception_cooldown_until = 0; /* window elapsed */ + } + + /* Check COP0 SR: IEc (bit 0) must be set, and IM2 (bit 10) must be set. */ + uint32_t sr = cpu->cop0[COP0_SR]; + if (!(sr & 0x01)) { + irq_record_outcome(EV_IRQ_GATE, GATE_SR_IE, 0); +#ifdef PSX_COSIM + COSIM_IRQ_NOTE(4u); +#endif + PSX_CHECK_INTERRUPTS_RETURN(); + } /* Interrupts globally disabled */ + /* Deliverability per source: the INTC line needs SR.IM2; software + * interrupts need only their own IM bit (already folded into sw_pending, + * recomputed here against the CURRENT sr — the fast-path snapshot above + * may predate an SR write earlier in this same check). */ + sw_pending = cpu->cop0[COP0_CAUSE] & sr & 0x0300u; + int hw_deliverable = ((i_stat & i_mask) != 0) && ((sr & (1 << 10)) != 0); + if (!hw_deliverable && sw_pending == 0) { + irq_record_outcome(EV_IRQ_GATE, GATE_SR_IM2, 0); +#ifdef PSX_COSIM + COSIM_IRQ_NOTE(5u); +#endif + PSX_CHECK_INTERRUPTS_RETURN(); + } /* No deliverable interrupt source (hw masked and no sw pending) */ + + /* Architectural take-PC = the resume PC (same selection the async-RFE block + * below uses): the dirty-interp commits the exact interrupted instruction in + * g_dirty_safe_resume_pc; compiled code passes its block-entry PC via + * psx_check_interrupts_at -> s_compiled_interrupt_resume_pc. Combined with the + * event's `mode`, this is the take-point for the cross-backend diff. */ + { + extern uint32_t g_dirty_safe_resume_pc; + uint32_t take_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc + : s_compiled_interrupt_resume_pc; + irq_record_outcome(EV_IRQ_DELIVER, 0, take_pc); + } + g_irq_deliver_count++; + if ((i_stat & i_mask) & (1u << IRQ_VBLANK)) g_vblank_deliver_count++; + if ((i_stat & i_mask) & (1u << IRQ_CDROM)) g_cdrom_deliver_count++; + /* IRQ-delivery context ring (MMX6 VSync-vs-CD-DMA hunt). Capture, at every IRQ + * delivery, whether the kernel VSync callback-block word at 0x80079D44 is the + * clobbered game value AND whether a CD DMA (ch3) is mid-transfer / a DMA is + * executing. If VBlank gets delivered with d44 already==0x016F0110 while the CD + * DMA is active, that is ChatGPT's class (c)+(#2): IRQ delivered in the DMA + * clobber window because the CPU isn't stalled / the event wasn't torn down. */ + { + extern int dma_cdrom_transfer_active(void); + extern int g_dma_exec_depth; + extern uint64_t s_frame_count; + g_irqctx_ring[g_irqctx_seq & (IRQCTX_RING_CAP - 1u)] = (IrqCtxEntry){ + .seq = g_irqctx_seq, .cycle = psx_get_cycle_count(), + .frame = (uint32_t)s_frame_count, .istat = i_stat, .imask = i_mask, + .sr = cpu->cop0[COP0_SR], .d44 = cpu->read_word(0x80079D44u), + .cdrom_active = (uint32_t)dma_cdrom_transfer_active(), + .dma_depth = g_dma_exec_depth, + .is_vblank = (uint32_t)(((i_stat & i_mask) & (1u << IRQ_VBLANK)) != 0), + }; + g_irqctx_seq++; + } + ls_note_exception_entry(); + /* Nested delivery: remember whether we interrupted an OUTER handler so + * the epilogue restores in_exception to the pre-delivery value instead + * of clearing it (a nested level-2 exit must leave level-1 flagged). */ + int prev_in_exception = in_exception; + in_exception = 1; + exception_nest_depth++; + exception_entries_total++; + uint32_t pre_handler_istat = i_stat; /* snapshot for cooldown decision */ + + /* Set COP0 Cause: ExcCode=0 (interrupt). IP2 reflects the INTC line, so + * set it only when the hardware source is what's being delivered; a pure + * software interrupt must present the guest-written IP0/IP1 bits + * unmodified (the guest's dispatcher discriminates stages by exactly + * these bits — see sw_pending rationale at the top of this function). */ + cpu->cop0[COP0_CAUSE] = (cpu->cop0[COP0_CAUSE] & ~0x7C) | (0 << 2); + if (hw_deliverable) + cpu->cop0[COP0_CAUSE] |= (1 << 10); + + /* Push SR exception stack: shift bits [5:0] left by 2. */ + cpu->cop0[COP0_SR] = (sr & ~0x3F) | ((sr & 0x0F) << 2); + + /* EPC: set to a sentinel value. The recompiled exception handler reads + * memory at [EPC] to check for COP2 branch delay. We use a dedicated + * address in the kernel scratch area. Address 0x80000048 is chosen + * because it's between the exception vectors (0x80-0xBF) and the + * kernel data pointer area (0x100+), and not used by the BIOS. + * + * The sentinel is the HOST escape token for the SYNCHRONOUS handler: when the + * recompiled handler RFEs to it while in_exception, psx_unknown_dispatch / + * dirty_ram_dispatch longjmp back here and resume the interrupted code via the + * host-GPR-restore below. This is unchanged (Tomba 1 / MMX6 / Ape rely on it). */ + /* Fix B: COP0.EPC = the REAL interrupted resume PC, NOT a sentinel. The recompiled + * BIOS exception handler saves COP0.EPC into the interrupted thread's TCB EPC slot, + * so a thread suspended here (ChangeThread) resumes at its OWN real PC — never via a + * single global that the next thread's IRQs overwrite (the MMX6 cooperative-thread + * freeze). The host escape out of the nested synchronous handler is keyed on the + * RFE-pending flag (psx_rfe_mark_escape / psx_rfe_escape_check), not on pc==sentinel. + * + * real_pc = the committed interrupted PC, latched by the delivery site (the dirty + * pump exposes g_dirty_safe_resume_pc; a compiled block-leader check exposes + * s_compiled_interrupt_resume_pc). When BOTH are 0 the IRQ was taken at a clean + * trampoline boundary (the interrupted function already returned, pc was 0) — there + * is no mid-function resume PC, so fall back to the legacy sentinel + saved_gpr path + * for THIS delivery only (reason = LEGACY_SENTINEL gates the saved_gpr restore and + * disarms the RFE-flag escape). */ + g_rfe_escape_pending = 0; + { + uint32_t real_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc + : s_compiled_interrupt_resume_pc; + /* Accept the real resume PC from guest RAM (<2MB) OR the BIOS ROM + * window. The old RAM-only guard rejected ROM-space block leaders + * (e.g. OpenBIOS mcWaitForStatus spinning at 0xBFC076xx during a + * card op), forcing EVERY such delivery onto the legacy sentinel. + * A compiled handler escapes the sentinel host-side, so SCPH1001 + * never noticed — but an INTERPRETED handler (OpenBIOS: the patch + * slots unbless the exception handler) saves/restores EPC + * architecturally through the TCB, churning the sentinel through + * guest state thousands of times per frame until one interleaving + * leaves it unrepaired (observed as a torn IntRP walk and a wild + * dispatch to 0x54000000 during the first memcard write at boot). + * ROM acceptance is gated on psx_is_dispatchable so a non-leader + * ROM pc still falls back to the sentinel (pre-fix behavior); + * RAM acceptance is unchanged byte-for-byte. */ + uint32_t real_phys = real_pc & 0x1FFFFFFFu; + int resume_in_ram = real_phys < 0x00200000u; + int resume_in_rom = real_phys >= 0x1FC00000u && real_phys < 0x1FC80000u && + psx_is_dispatchable(real_pc); + if (real_pc != 0u && (real_pc & 0x3u) == 0u && + (resume_in_ram || resume_in_rom)) { + cpu->cop0[COP0_EPC] = real_pc; /* architectural: the real resume PC */ + g_exception_real_epc = real_pc; + g_exc_escape_reason = PSX_EXC_ESCAPE_NONE; /* set at the actual RFE/SYSCALL return */ + } else { + uint32_t sentinel = PSX_EXC_SENTINEL_PC; + cpu->write_word(sentinel, 0x00000000u); /* NOP, read by the handler's BD check */ + cpu->cop0[COP0_EPC] = sentinel; + g_exception_real_epc = sentinel; + g_exc_escape_reason = PSX_EXC_ESCAPE_LEGACY_SENTINEL; + } +#ifdef PSX_COSIM + cosim_irq_note(cpu, 1u, real_pc, g_dirty_safe_resume_pc, + s_compiled_interrupt_resume_pc, sr); +#endif + /* Ring exit-half init: record the entry-side resume selection now; the + * exit fields are filled at the restore decision below. No nesting is + * possible between here and there (in_exception blocks re-entry), so + * (g_irqctx_seq - 1) is this delivery's entry. */ + { + IrqCtxEntry *e = &g_irqctx_ring[(g_irqctx_seq - 1u) & (IRQCTX_RING_CAP - 1u)]; + e->take_pc = real_pc; + e->real_epc = g_exception_real_epc; + e->exit_pc = 0; e->exit_reason = 0; e->same_thread = 0; + e->restored = 0; e->v1_exit = 0; e->v1_saved = 0; + e->ra_exit = 0; e->ra_saved = 0; e->redirects = 0; + e->entry_sp = cpu->gpr[29]; + { extern int g_cosim_dirty_pump_site; + e->pump_site = (uint32_t)g_cosim_dirty_pump_site; } + } + } + + /* Save the interrupted code's full register state. + * + * On real hardware, the exception handler saves all GPRs to the + * TCB save area at entry, and ReturnFromException restores them + * before jumping back to EPC. The interrupted code always gets + * its exact pre-exception register values back. + * + * In our model, the recompiled handler runs as a C function and + * its `return;` goes back here — not to EPC. The handler's + * normal exit path (0xBFC10944) restores registers from the + * kernel jmpbuf (intended for longjmp to WaitEvent's caller), + * which corrupts the interrupted code's registers. + * + * We save all GPRs/HI/LO before the handler and restore them + * after, so the interrupted code resumes with its original state + * — matching real hardware behaviour. */ + uint32_t saved_gpr[32]; + uint32_t saved_hi, saved_lo; + for (int i = 0; i < 32; i++) saved_gpr[i] = cpu->gpr[i]; + saved_hi = cpu->hi; + saved_lo = cpu->lo; + + /* Same-thread discriminator input (mmx6_card_load_regression_state): the + * kernel's CURRENT-TCB pointer at exception ENTRY. PCB ptr lives at kernel + * 0x108; PCB[0] = running thread's TCB. ChangeThread inside the handler + * moves PCB[0] to the new thread, so entry-vs-exit TCB equality is the + * STRUCTURAL "no thread switch happened" test — unlike PC equality, it is + * immune to two threads parked at the SAME guest PC (shared spin loops: + * MMX6's card poll), where the PC heuristic force-restores the OLD thread's + * GPRs into the NEW thread. */ + extern uint32_t psx_read_word(uint32_t addr); /* memory.c (plain RAM read) */ + uint32_t entry_pcb = psx_read_word(0x108u); + uint32_t entry_tcb = entry_pcb ? psx_read_word(entry_pcb & 0x1FFFFFFFu) : 0u; + + /* Dispatch the BIOS exception handler. + * BEV (SR bit 22) selects between 0x80000080 and 0xBFC00180. + * + * setjmp is placed here so ReturnFromException (longjmp code 1) + * and RestoreState (longjmp code 2) can escape the handler call + * tree. + * + * The loop handles the PSX VSync mechanism (SaveState/RestoreState): + * - Code 0: normal entry — dispatch the handler. + * - Code 2: RestoreState redirect — re-dispatch to cpu->pc + * (e.g. VSync callback loop at 0xBFC421D8), still in exception + * context. The redirected code eventually calls ReturnFromException. + * - Code 1: ReturnFromException — exit the loop entirely. */ + uint32_t target_pc; + if (sr & 0x00400000u) { + target_pc = 0xBFC00180u; + } else { + uint32_t w0 = cpu->read_word(0x80000080u); + uint32_t w1 = cpu->read_word(0x80000084u); + uint32_t hi_val = (w0 & 0xFFFF) << 16; + int16_t lo_val = (int16_t)(w1 & 0xFFFF); + target_pc = hi_val + (uint32_t)(int32_t)lo_val; + /* The RAM exception vector is a LUI/ADDIU pair that materializes the + * installed handler address in $k0 before transferring to it. The + * host-side fast path decodes that pair and dispatches straight to the + * target, so it must also commit the pair's architectural register + * result. Vigilante 8's handler uses $k0 as its table base on its very + * first instruction; leaving the interrupted value in $k0 made it load + * a BIOS instruction word (0xAD400000) as a jump target. */ + uint32_t vector_reg = (w1 >> 16) & 31u; + if (vector_reg != 0u) cpu->gpr[vector_reg] = target_pc; + } + + /* Record which fiber owns this setjmp. Any subsequent longjmp must + * happen on this same fiber; if a non-owner fiber needs to longjmp + * it must switch back here first (see deferred_exception_longjmp). */ + void *prev_owner_fiber = g_exception_owner_fiber; + int prev_pending = g_pending_exception_longjmp; + g_exception_owner_fiber = psx_fiber_current(); + g_pending_exception_longjmp = 0; + /* A bail unwind can never be in flight at exception entry: bail-mode + * returns skip every block leader, so psx_check_interrupts is never + * reached while g_psx_call_bail is set. If it ever is, count the + * anomaly and clear so the handler dispatch isn't poisoned. */ + if (g_psx_call_bail) { + g_psx_bail_anomaly++; + g_psx_call_bail = 0; + } + /* The static BIOS exception handler is not dirty-RAM-interp code. Clear the + * interp mode flag across the handler dispatch so events recorded inside it + * are tagged STATIC. The restore sits after the loop the longjmp lands in, + * so the EPC-sentinel longjmp can't leave the flag wrong. */ + int prev_interp_active = g_dirty_interp_active; + g_dirty_interp_active = 0; + /* Ape memcard fix #3 (2026-07-07): clear the dirty-interp resume-PC latch + * across the handler dispatch, exactly like g_dirty_interp_active above. + * + * The interrupted dirty block set g_dirty_safe_resume_pc = its committed PC + * and left it LIVE across its own psx_check_interrupts call (the dirty pump + * only restores it AFTER that call returns). Without clearing it here, a + * NESTED interrupt taken inside the handler — e.g. a VBLANK delivered while + * the kernel's async-poll memory-card callback runs (in_exception, IEc + * re-enabled) — inherits the OUTER interrupted block's stale resume PC as its + * EPC. same-thread-restore then matches (cpu->pc == that stale EPC) and + * resumes the OUTER block with the CALLBACK's registers: the Ape memcard + * consumer at 0x801382CC gets v1 (jumptable index) = the callback's pointer + * 0x800B4E30, bounds-check already passed, `jr jumptable[garbage]` faults + * (DISPATCH FATAL misaligned 0x3) — or, in the soft variant, drives a wrong + * branch that skips DeliverEvent(0xF0000011,4) and the card scene wedges. + * (irqctx_ring proof: exit_pc=0x801382CC, ra_saved=0x80010094 [callback], + * v1_saved=0x800B4E30, restored=1.) + * + * Cleared here so a nested delivery latches the ACTUALLY-running code's PC: + * the callback's own dirty pump re-sets g_dirty_safe_resume_pc for its + * blocks, and compiled handler code uses s_compiled_interrupt_resume_pc. + * Restored after the dispatch loop (below), which the RFE/escape longjmp + * lands in — so the escape can't leave the latch wrong. A/B off: + * PSX_EXC_CLEAR_RESUME_LATCH=0. */ + static int s_clear_resume_latch = -1; + if (s_clear_resume_latch < 0) { + const char *e = getenv("PSX_EXC_CLEAR_RESUME_LATCH"); + s_clear_resume_latch = (e && *e) ? atoi(e) : 1; + } + uint32_t saved_dirty_resume_pc = g_dirty_safe_resume_pc; + if (s_clear_resume_latch) g_dirty_safe_resume_pc = 0; + /* Wall-time sampler phase: the handler dispatch runs recompiled static + * BIOS text. Same save/restore contract as g_dirty_interp_active above — + * the restore sits after the setjmp loop, covering longjmp landings. */ + extern int g_exec_phase; + int prev_exec_phase = g_exec_phase; + g_exec_phase = 3; + /* Dispatch-depth contract across the async handler (Tomba 2 splash, post + * overlay-floor fix). The interrupt can be delivered from a NESTED dispatch + * (the local-flow pump runs inside dirty_ram_dispatch at depth > 0). The + * handler must run as its OWN outermost context (so its tail-call trampoline + * flattens), and a ReturnFromException longjmp skips the handler frames' + * `--g_psx_dispatch_depth` decrements — so we cannot leave the counter at 0. + * Save the interrupted code's nesting here and RESTORE it after the handler; + * otherwise the outer frames unwind below zero (dispatch_depth -> -1), the + * `outermost` test misfires, and the top-level dispatch returns to PC=0 + * ("execution completed" abnormal exit). */ + int saved_dispatch_depth = g_psx_dispatch_depth; + g_psx_dispatch_depth = 0; + /* Nested delivery clobber guard: exception_jmpbuf is a single global, so a + * nested exception's setjmp overwrites the OUTER frame's context. The outer + * handler's later RFE would then longjmp into the dead inner frame (host + * stack corruption — first hit by Jackie Chan Stuntmaster's software- + * interrupt dispatcher, which nests by design). Save the armed frame here + * and restore it in the epilogue so each nesting level's RFE lands in its + * own live frame. */ + jmp_buf saved_exception_jmpbuf; + memcpy(&saved_exception_jmpbuf, &exception_jmpbuf, sizeof(jmp_buf)); + g_exc_setjmp_epoch++; /* new setjmp frame armed (see decl above) */ + for (;;) { + int jmp_val = setjmp(exception_jmpbuf); + if (jmp_val == 2) { + /* RestoreState redirect: re-dispatch to cpu->pc. + * GPRs were already set by RestoreState — do NOT restore. + * Stay in exception context so ReturnFromException works. */ + g_irqctx_ring[(g_irqctx_seq - 1u) & (IRQCTX_RING_CAP - 1u)].redirects++; + g_psx_dispatch_depth = 0; + debug_server_log_restore_event(3, cpu->pc, (uint32_t)jmp_val); + target_pc = cpu->pc; + continue; + } + if (jmp_val == 1) { + g_psx_dispatch_depth = 0; + debug_server_log_restore_event(4, cpu->pc, (uint32_t)jmp_val); + } + if (jmp_val == 0) { + /* Normal entry (or after RestoreState redirect): dispatch. */ + psx_dispatch(cpu, target_pc); + } + /* jmp_val 0 (normal return) or 1 (ReturnFromException): done. */ + break; + } + /* Restore previous exception-owner state. Supports nested exceptions + * if they ever arise (uncommon but harmless). */ + g_exception_owner_fiber = prev_owner_fiber; + g_pending_exception_longjmp = prev_pending; + g_dirty_interp_active = prev_interp_active; + /* Re-arm the OUTER frame's jmpbuf (see save at entry): after a nested + * delivery returns, the outer handler is live again and its RFE must land + * in the outer setjmp frame, not this exited one. */ + memcpy(&exception_jmpbuf, &saved_exception_jmpbuf, sizeof(jmp_buf)); + /* Ape memcard fix #3: restore the resume-PC latch cleared at handler entry + * (see above). Sits with the other post-dispatch-loop restores so an + * RFE/RestoreState longjmp — which lands in the setjmp loop above — can't + * strand it at 0. */ + if (s_clear_resume_latch) g_dirty_safe_resume_pc = saved_dirty_resume_pc; + g_exec_phase = prev_exec_phase; + /* Restore the interrupted code's dispatch nesting (see save above). The + * handler's frames were unwound by longjmp without decrementing, so the + * live counter is meaningless here — overwrite, don't decrement. */ + g_psx_dispatch_depth = saved_dispatch_depth; + + /* Restore the interrupted code's registers. + * + * The handler has done its work (acknowledged i_stat, delivered + * events, etc.) via MMIO writes and RAM writes — those side + * effects are in memory. We restore GPRs so the interrupted + * code continues with its pre-exception state. + * + * For SR: if the handler did ReturnFromException (RFE already + * applied, IEc restored), we keep that. If the handler exited + * normally (jmpbuf path, no RFE), IEc is still 0 from the + * exception push — we do RFE to pop the SR stack. + * + * Fix B: only restore saved_gpr on the LEGACY (jmpbuf / boundary-sentinel) + * exit. On a real RFE/ReturnFromException the recompiled BIOS handler already + * restored the RESUMED thread's GPRs from its TCB; overwriting them with our + * saved_gpr would clobber the resumed thread with the state of the thread that + * ENTERED the exception (the cross-thread corruption fix B exists to prevent). */ + /* Fix B refinement (2026-07-01, Tomba pause-menu wedge): restore the + * interrupted GPRs whenever this resume returns to the SAME thread it + * interrupted — either the LEGACY sentinel exit, OR a real-EPC RFE that + * resumes at exactly the PC we installed as EPC (no ChangeThread happened). + * + * Real hardware saves and restores ALL GPRs across every exception, so a + * transparent same-thread interrupt must give the interrupted code its exact + * pre-exception registers back. The original Fix B skipped this for all + * real-EPC resumes, trusting the recompiled BIOS handler's TCB restore — but + * for a same-thread compiled resume that restore is incomplete, so a live + * value held in a register across a block-leader IRQ check gets clobbered by + * the handler. Concretely: Tomba's pause-menu frame-wait spin holds its loop + * bound (256) in gpr[3] across 0x80016588; a VBLANK there returned gpr[3]=1, + * so the wait exited early every frame and the menu never opened. + * + * A GENUINE ChangeThread resumes a DIFFERENT thread at its own PC (!= our + * EPC); there the BIOS handler restored the NEW thread's GPRs from its TCB + * and saved_gpr holds the thread that ENTERED the exception — restoring it + * would reintroduce the MMX6 cooperative-thread corruption Fix B prevents. */ + /* Discriminator selector (verification + candidate fix, + * mmx6_card_load_regression_state — the PC heuristic below was BISECTED as + * the "A game data could not be found" regression B on MMX6 while being + * the fix for Tomba's pause menu): + * PSX_SAME_THREAD_RESTORE=0 original Fix B (legacy-sentinel-only restore) + * PSX_SAME_THREAD_RESTORE=1 PC-equality heuristic (13c5e0c behavior) + * PSX_SAME_THREAD_RESTORE=2 kernel current-TCB equality (default; + * structural same-thread test — a genuine + * ChangeThread moves PCB[0] even when the new + * thread resumes at the SAME guest PC) */ + static int s_str_mode = -1; + if (s_str_mode < 0) { + const char *e = getenv("PSX_SAME_THREAD_RESTORE"); + s_str_mode = (e && *e) ? atoi(e) : 1; /* default = 13c5e0c behavior; + mode 2 (TCB check) is the hardening candidate, pending a Tomba + pause-menu gate. The MMX6 "not found" this selector was built to + verify turned out to be a STALE GENERATED IMAGE artifact. */ + if (s_str_mode < 0 || s_str_mode > 2) s_str_mode = 1; + } + extern uint32_t psx_read_word(uint32_t addr); /* memory.c (plain RAM read) */ + uint32_t exit_pcb = psx_read_word(0x108u); + uint32_t exit_tcb = exit_pcb ? psx_read_word(exit_pcb & 0x1FFFFFFFu) : 0u; + int same_thread_resume = 0; + if (s_str_mode == 1) { + same_thread_resume = + (g_exc_escape_reason != PSX_EXC_ESCAPE_LEGACY_SENTINEL) && + g_exception_real_epc != 0u && + same_guest_pc(cpu->pc, g_exception_real_epc); + } else if (s_str_mode == 2) { + same_thread_resume = + (g_exc_escape_reason != PSX_EXC_ESCAPE_LEGACY_SENTINEL) && + g_exception_real_epc != 0u && + same_guest_pc(cpu->pc, g_exception_real_epc) && + entry_tcb != 0u && entry_tcb == exit_tcb; + } + /* Same-thread completion of a SENTINEL (legacy) delivery via an explicit + * guest RFE / ReturnFromException. The guest TCB never holds the host + * sentinel (psx_assert_no_sentinel_pc), so the kernel's restore resumed + * cpu->pc at an APPROXIMATE saved PC (typically a stale earlier dirty + * boundary) — but with PCB[0] unmoved the TRUE continuation of the + * interrupted code is the LIVE host chain below this frame. Exit exactly + * like the legacy sentinel detector: restore the interrupted GPRs and + * return pc=0 so the interrupted compiled leader continues natively. + * Without this the approximate PC surfaces up the trampoline, severs the + * live chain, and one hop later lands on an un-re-enterable mid-function + * PC — the top-level "execution completed, PC=0" abnormal exit (Tomba 2 + * splash, frame 1385). A GENUINE in-handler switch (PCB[0] moved) skips + * this and is honored/deferred by the scheduler block below. */ + if (g_exception_real_epc == (uint32_t)PSX_EXC_SENTINEL_PC && + entry_tcb != 0u && entry_tcb == exit_tcb && + (g_exc_escape_reason == PSX_EXC_ESCAPE_RFE_RETURN || + g_exc_escape_reason == PSX_EXC_ESCAPE_SYSCALL_RETURN)) { + same_thread_resume = 1; + cpu->pc = 0; /* continue the interrupted live chain */ + } + int do_restore = + (g_exc_escape_reason == PSX_EXC_ESCAPE_LEGACY_SENTINEL || same_thread_resume); + /* Ring exit-half: every delivery records which escape path it took and + * whether the interrupted GPRs were restored (Tomba menu wedge evidence). */ + { + IrqCtxEntry *e = &g_irqctx_ring[(g_irqctx_seq - 1u) & (IRQCTX_RING_CAP - 1u)]; + e->exit_pc = cpu->pc; + e->exit_reason = (uint32_t)g_exc_escape_reason; + e->same_thread = (uint32_t)same_thread_resume; + e->restored = (uint32_t)do_restore; + e->v1_exit = cpu->gpr[3]; + e->v1_saved = saved_gpr[3]; + e->ra_exit = cpu->gpr[31]; + e->ra_saved = saved_gpr[31]; + } + if (do_restore) { + for (int i = 0; i < 32; i++) cpu->gpr[i] = saved_gpr[i]; + cpu->hi = saved_hi; + cpu->lo = saved_lo; + } + g_rfe_escape_pending = 0; + g_exc_escape_reason = PSX_EXC_ESCAPE_NONE; + + if (!(cpu->cop0[COP0_SR] & 0x01)) { + uint32_t sr2 = cpu->cop0[COP0_SR]; + cpu->cop0[COP0_SR] = (sr2 & 0xFFFFFFC0u) | ((sr2 >> 2) & 0x0Fu); + } + + /* Nested delivery: a level-2 exit must leave level-1 flagged. */ + in_exception = prev_in_exception; + if (exception_nest_depth > 0) exception_nest_depth--; + + /* Adaptive cooldown: if the handler acknowledged the interrupt (cleared + * some I_STAT bits), the interrupt won't immediately re-fire and we need + * no cooldown. If I_STAT is unchanged (no handler claimed the interrupt), + * give the main code a generous window to make progress — e.g. to let + * the shell finish installing handlers. On real hardware, the CPU + * executes at least one instruction between exceptions; in our model + * each "block" is many instructions, but the handler also consumes + * hundreds of sub-dispatches per invocation. */ + if ((i_stat & i_mask) != 0 && i_stat == pre_handler_istat) { + /* unclaimed: give main code guest-time to install handlers */ + post_exception_cooldown_until = psx_get_cycle_count() + POST_EXC_UNCLAIMED_COOLDOWN_CYCLES; + } else { + /* Claimed: the handler acknowledged at least one I_STAT bit. Per-source + * policy (this used to be a blanket cooldown=0 — commit 6d2cb65 — which + * pins main code under a fast-disc DMA flood: the interrupted block's + * leader re-takes the exception before its body runs): + * - SIO (card reads): 128 consecutive SIO IRQs must fire within one + * blocking wait; any gap stalls the card protocol → re-fire now. + * - DMA/VBLANK/timer/etc: guarantee a few blocks of main-code + * progress between deliveries so a flood can't starve the loop. */ + uint32_t claimed = pre_handler_istat & ~i_stat; /* bits handler cleared */ + uint32_t sio_active = (claimed | (i_stat & i_mask)) & (1u << IRQ_SIO0); + post_exception_cooldown_until = sio_active + ? 0 /* SIO: no cooldown — card reads need immediate back-to-back IRQs */ + : psx_get_cycle_count() + POST_EXC_CLAIMED_COOLDOWN_CYCLES; + } + if (g_ls_suppress_record > 0) g_ls_suppress_record--; + + /* In-exception thread switch (Ape Escape NEW GAME memcard scene). + * + * A game can run its OWN cooperative scheduler inside the kernel exception + * handler: its VBlank/exception callback moves dword_108->entry (PCB[0], the + * current-thread pointer) to the next thread, and ReturnFromException (0xF40) + * then restores THAT thread. On real hardware the RFE atomically switches + * execution to the new thread. (docs/psx_bios_disasm.txt: both ExceptionHandler + * 0xC80 and ReturnFromException 0xF40 key off dword_108->entry.) + * + * Under the HLE TCB scheduler the interrupted thread is running via a + * structured dispatch on the host stack. Returning normally here does NOT + * transfer to the new thread — the generated block leader just falls through + * to the interrupted thread's next block (it never re-reads cpu->pc). The + * interrupted thread therefore keeps physically executing while PCB[0] already + * names a different thread; a subsequent exception then saves the running + * (old) thread's EPC into the NEW thread's TCB, corrupting it. That is the + * Ape "Checking… MEMORY CARD" wedge: the transition thread's ec8 PC is written + * into main's TCB EPC, main is later RFE'd there with main's regs, and its + * store lands on the transition thread's TCB status word (kill → target_missing + * forever). See ape_thread_smear_rootcause.md. + * + * Honor the switch exactly as a cooperative ChangeThread is honored: unwind to + * psx_scheduler_run and dispatch whatever PCB[0] now points at, from its + * committed TCB context (the guest handler already saved the interrupted + * thread into ITS own TCB before the switch). Gated to the OUTERMOST exception + * (prev_in_exception==0 → no live outer exception_jmpbuf frame to skip) and to + * the HLE scheduler (the legacy fiber bridge has no g_scheduler_jmpbuf and + * switches threads by fiber). */ + if (prev_in_exception == 0 && psx_hle_scheduler_enabled() && + entry_tcb != 0u && exit_tcb != 0u && entry_tcb != exit_tcb) { + extern uint32_t psx_read_word(uint32_t addr); /* memory.c (plain RAM read) */ + uint32_t new_state = psx_read_word(exit_tcb & 0x1FFFFFFFu); + if (new_state == 0x4000u) { /* the new current thread must be runnable */ + /* A native-only shadow pass must never commit the handler's TCB/RAM + * changes or escape past the authoritative restore. Bail before + * configuring either immediate or deferred scheduler state. */ + extern int overlay_loader_shadow_native_thread_switch_bail(void); + if (overlay_loader_shadow_native_thread_switch_bail()) + PSX_CHECK_INTERRUPTS_RETURN(); + /* Fix #2: honor the switch ONLY at the outermost dispatch boundary, + * where the outgoing thread's guest state is fully materialized in + * CPUState. Nested inside a host call unit / dirty pump, the outgoing + * thread's PC lives only in the resume-PC latch, which desyncs from the + * live GPRs when the IRQ was taken deep inside a nested call (the + * async-poll callback) — switching there commits a poisoned TCB context + * and the resumed thread faults on a smeared jumptable index. */ + extern int g_call_unit_depth; + int at_outermost = (g_psx_dispatch_depth == 0 && g_call_unit_depth == 0); + /* Defer only when nested AND the outgoing thread has a valid resume PC + * of its OWN (g_exception_real_epc — the architectural EPC latched for + * THIS delivery). The guest handler's in-exception ReturnFromException + * (traps.c syscall 3, in_exception path) has already restored the TARGET + * thread's GPRs and set cpu->pc to the target's EPC; to resume the + * OUTGOING thread transparently we must put cpu->pc back to ITS resume PC + * as well, not leave it pointing at the target. If we have no clean + * outgoing resume PC (sentinel / clean-trampoline boundary), or the + * toggle is off, or we are already outermost, switch immediately. */ + /* Low BIOS/kernel code is already scheduler code; deferring it can + * starve a target thread by re-entering the same VBlank EPC forever. */ + uint32_t epc_phys = g_exception_real_epc & 0x1FFFFFFFu; + int low_kernel_epc = (epc_phys < 0x00010000u); + int can_defer = defer_switch_enabled() && !low_kernel_epc && !at_outermost && + g_exception_real_epc != 0u && + (g_exception_real_epc & 0x3u) == 0u && + g_exception_real_epc != (uint32_t)PSX_EXC_SENTINEL_PC && + psx_is_dispatchable(g_exception_real_epc); + if (!can_defer) { + /* Outermost / no clean deferral: honor immediately — the guest + * handler's save of the outgoing thread is consistent here (or this + * is the pre-existing baseline path). */ + debug_server_log_thread_event(30, cpu, entry_tcb, exit_tcb, cpu->pc); + /* The longjmp skips psx_check_interrupts_at's restore of the compiled + * resume-PC latch; clear it so the next thread's block-leader delivery + * recomputes real_pc cleanly rather than inheriting a stale value. */ + s_compiled_interrupt_resume_pc = 0; + /* This escape uses g_scheduler_jmpbuf (unwinding to psx_scheduler_run), + * NOT exception_jmpbuf — so it BYPASSES the landing above that restores + * g_dirty_interp_active. From the dirty-RAM entry poll the flag is 1 + * and would LEAK across the unwind; we abandon the dirty-interp + * dispatch here, so it MUST be 0 (dirty_ram_interp.c ~205-210 contract; + * no-op from compiled code where the flag was already 0). */ + g_dirty_interp_active = 0; + g_sched_escape.target_tcb = exit_tcb; + g_sched_escape.resume_pc = 0; + g_sched_escape.reason = PSX_RUN_YIELD_TO_TCB; + longjmp(g_scheduler_jmpbuf, 1); /* unwind to psx_scheduler_run; never returns */ + } + /* Nested in host dispatch: DEFER. The guest handler has already saved a + * POISONED context for the outgoing thread (latched PC ahead of the live + * registers), but we do NOT switch off it. Instead resume the outgoing + * thread transparently — restoring its clean pre-exception GPRs/HI/LO/SR + * (do_restore above ran false for this genuine cross-thread case) — so it + * finishes its nested calls; record the switch as pending and honor it at + * the next outermost boundary (top of this function), re-saving the thread + * cleanly there. Keep PCB[0] naming the STILL-RUNNING thread during the + * interim so any interim exception saves into the right TCB (closes the + * mis-save window the immediate switch used to avoid); the guest's intent + * to run exit_tcb is preserved as the pending switch. Coalesces: the newest + * guest ChangeThread wins the target. */ + for (int i = 0; i < 32; i++) cpu->gpr[i] = saved_gpr[i]; + cpu->hi = saved_hi; + cpu->lo = saved_lo; + cpu->cop0[COP0_SR] = sr; /* pre-exception SR (IEc was set — gate above) */ + cpu->pc = g_exception_real_epc; /* resume the OUTGOING thread at ITS own PC, + * not the target PC the in-exception RFE + * left in cpu->pc — else it runs the target's + * code under the outgoing thread and never + * reaches its own block leaders to be honored. */ + psx_sched_set_current_tcb(cpu, entry_tcb); + s_defer_switch_from = entry_tcb; + s_defer_switch_target = exit_tcb; + s_defer_switch_pending = 1; + s_compiled_interrupt_resume_pc = 0; + debug_server_log_thread_event(31, cpu, entry_tcb, exit_tcb, cpu->pc); + /* fall through: return normally — the outgoing thread resumes. */ + } + } +#ifdef PSX_COSIM +#undef COSIM_IRQ_NOTE +#undef COSIM_IRQ_TAKE_PC +#endif +#undef PSX_CHECK_INTERRUPTS_RETURN +} + +/* Compatibility shim: the ape-flavored generated code calls + * psx_check_interrupts_at(cpu, resume_pc); the mmx6-fw baseline runtime delivers + * interrupts via psx_check_interrupts (cpu->pc / scratch sentinel). Forwarding + * here gives the mmx6 baseline interrupt behavior — sufficient to build+run the + * current generated code on the good baseline for instrumented comparison. */ +void psx_check_interrupts_at(CPUState* cpu, uint32_t resume_pc) { + uint32_t prev = s_compiled_interrupt_resume_pc; + s_compiled_interrupt_resume_pc = resume_pc; + s_resume_pc_given = 1; + psx_check_interrupts(cpu); /* flushes load-charge batch on entry */ + s_compiled_interrupt_resume_pc = prev; +} + +int psx_interrupts_checked_at_current_cycle(uint32_t resume_pc) { + return s_last_interrupt_check_cycle == psx_get_cycle_count() && + same_guest_pc(s_last_interrupt_check_pc, resume_pc); +} + +void psx_check_interrupts_dispatch_entry(CPUState* cpu, uint32_t resume_pc) { + if (psx_interrupts_checked_at_current_cycle(resume_pc)) { + return; + } + psx_check_interrupts_at(cpu, resume_pc); +} diff --git a/run.ps1 b/run.ps1 index b3f70cf..2462f11 100644 --- a/run.ps1 +++ b/run.ps1 @@ -36,9 +36,9 @@ if ($MouseAim -or $VerticalLook -or $ModernControls) { $RuntimeDirectory = Split-Path -Parent $Runtime $KeybindPresetName = if ($ModernControls) { - 'keybinds-modern.ini' + 'config/keybinds-modern.ini' } else { - 'keybinds-original.ini' + 'config/keybinds-original.ini' } $KeybindPreset = Join-Path $Root $KeybindPresetName Copy-Item -LiteralPath $KeybindPreset -Destination (Join-Path $RuntimeDirectory 'keybinds.ini') -Force diff --git a/run.sh b/run.sh index 79bdcd1..97473ae 100755 --- a/run.sh +++ b/run.sh @@ -57,10 +57,10 @@ if [ "$PERSPECTIVE_TEXTURES" -eq 1 ]; then fi if [ "$MODERN_CONTROLS" -eq 1 ]; then - cp "$ROOT/keybinds-modern.ini" "$BUILD/keybinds.ini" + cp "$ROOT/config/keybinds-modern.ini" "$BUILD/keybinds.ini" echo "Modern controls enabled: WASD, LMB fire, RMB psionic, Space jump, E use." else - cp "$ROOT/keybinds-original.ini" "$BUILD/keybinds.ini" + cp "$ROOT/config/keybinds-original.ini" "$BUILD/keybinds.ini" fi GAME_CONFIG=game.toml diff --git a/src/disruptor.manifest b/src/game/disruptor.manifest similarity index 100% rename from src/disruptor.manifest rename to src/game/disruptor.manifest diff --git a/src/disruptor.rc b/src/game/disruptor.rc similarity index 100% rename from src/disruptor.rc rename to src/game/disruptor.rc diff --git a/src/disruptor_frame_rate.cpp b/src/game/frame/frame_rate.cpp similarity index 99% rename from src/disruptor_frame_rate.cpp rename to src/game/frame/frame_rate.cpp index 87b7d4e..521f022 100644 --- a/src/disruptor_frame_rate.cpp +++ b/src/game/frame/frame_rate.cpp @@ -12,7 +12,7 @@ * wait counts loop iterations for its timeout and always runs at stock cost. */ -#include "disruptor_frame_rate.h" +#include "game/frame/frame_rate.h" #include "cpu_state.h" #include "interrupts.h" diff --git a/src/disruptor_frame_rate.h b/src/game/frame/frame_rate.h similarity index 100% rename from src/disruptor_frame_rate.h rename to src/game/frame/frame_rate.h diff --git a/src/disruptor_present_rate.cpp b/src/game/frame/present_rate.cpp similarity index 79% rename from src/disruptor_present_rate.cpp rename to src/game/frame/present_rate.cpp index aba9174..1b7051b 100644 --- a/src/disruptor_present_rate.cpp +++ b/src/game/frame/present_rate.cpp @@ -3,7 +3,9 @@ * Built only together with the frame interpolator. */ -#include "disruptor_present_rate.h" +#include "game/frame/present_rate.h" + +#include "game/ui/hint.h" #include "host_ui.h" #include "imgui.h" @@ -20,14 +22,15 @@ int disruptor_present_rate_enable(int enabled) { return psx_host_video_set_interpolation(enabled ? 1 : 0, target, blend) != 0; } -int disruptor_present_rate_switch(int available, int *enabled) { +int disruptor_present_rate_switch(const char *label, const char *hint, int available, int *enabled) { int on = 0, changed = 0; psx_host_video_get_interpolation(&on, nullptr, nullptr); bool checked = on != 0; const bool locked = !available && !checked; if (locked) ImGui::BeginDisabled(); - if (ImGui::Checkbox("In-between frames", &checked) && - disruptor_present_rate_enable(checked ? 1 : 0)) { + const bool clicked = ImGui::Checkbox(label, &checked); + disruptor::ui::hint(hint); + if (clicked && disruptor_present_rate_enable(checked ? 1 : 0)) { if (enabled) *enabled = checked ? 1 : 0; changed = 1; } @@ -43,7 +46,7 @@ int disruptor_present_rate_apply(int frames_per_second) { return enabled && psx_host_video_set_interpolation(1, frames_per_second, blend) != 0; } -int disruptor_present_rate_control(int *accepted) { +int disruptor_present_rate_control(const char *label, const char *hint, int *accepted) { int enabled = 0, target = 0, changed = 0; double display_hz = 0.0; psx_host_video_get_interpolation(&enabled, &target, nullptr); @@ -52,9 +55,10 @@ int disruptor_present_rate_control(int *accepted) { int shown = disruptor_present_rate_shown(target, top); if (!enabled) ImGui::BeginDisabled(); ImGui::SetNextItemWidth(260.0f); - if (ImGui::SliderInt("In-between frame rate", &shown, kDisruptorPresentRateLowest, top, "%d FPS", - ImGuiSliderFlags_AlwaysClamp) && - disruptor_present_rate_apply(shown)) { + const bool moved = ImGui::SliderInt(label, &shown, kDisruptorPresentRateLowest, top, "%d FPS", + ImGuiSliderFlags_AlwaysClamp); + disruptor::ui::hint(hint); + if (moved && disruptor_present_rate_apply(shown)) { if (accepted) *accepted = shown; changed = 1; } diff --git a/src/disruptor_present_rate.h b/src/game/frame/present_rate.h similarity index 91% rename from src/disruptor_present_rate.h rename to src/game/frame/present_rate.h index 601811c..9832f5f 100644 --- a/src/disruptor_present_rate.h +++ b/src/game/frame/present_rate.h @@ -28,10 +28,10 @@ constexpr int disruptor_present_rate_shown(int target_fps, int top) { /* Switches in-between frames on or off at the rate and blend the host has. Returns 0 when the host refused. */ int disruptor_present_rate_enable(int enabled); /* Draws the switch: frames that are off cannot be switched on while `available` is 0. Returns 1 and the state in `enabled` when the user changed it and the host took it. */ -int disruptor_present_rate_switch(int available, int *enabled); +int disruptor_present_rate_switch(const char *label, const char *hint, int available, int *enabled); /* Sets the rate of in-between frames that are on. Returns 0 when they are off or the host refused. */ int disruptor_present_rate_apply(int frames_per_second); /* Draws the control. Returns 1 and the rate in `accepted` when the user changed it and the host took it. */ -int disruptor_present_rate_control(int *accepted); +int disruptor_present_rate_control(const char *label, const char *hint, int *accepted); #endif diff --git a/src/disruptor_control_probe.cpp b/src/game/input/control_probe.cpp similarity index 100% rename from src/disruptor_control_probe.cpp rename to src/game/input/control_probe.cpp diff --git a/src/disruptor_mouse_aim.cpp b/src/game/input/mouse_aim.cpp similarity index 99% rename from src/disruptor_mouse_aim.cpp rename to src/game/input/mouse_aim.cpp index 290cc73..87b3343 100644 --- a/src/disruptor_mouse_aim.cpp +++ b/src/game/input/mouse_aim.cpp @@ -18,10 +18,10 @@ */ #include "psx_sdl.h" -#include "disruptor_mouse_aim.h" -#include "disruptor_capsule.h" -#include "disruptor_vertical_camera.h" -#include "../psxrecomp-overlay/runtime/include/gpu.h" +#include "game/input/mouse_aim.h" +#include "game/session/capsule.h" +#include "game/input/vertical_camera.h" +#include "../../../psxrecomp-overlay/runtime/include/gpu.h" #include #include diff --git a/src/disruptor_mouse_aim.h b/src/game/input/mouse_aim.h similarity index 100% rename from src/disruptor_mouse_aim.h rename to src/game/input/mouse_aim.h diff --git a/src/disruptor_name_entry.cpp b/src/game/input/name_entry.cpp similarity index 100% rename from src/disruptor_name_entry.cpp rename to src/game/input/name_entry.cpp diff --git a/src/disruptor_vertical_camera.cpp b/src/game/input/vertical_camera.cpp similarity index 99% rename from src/disruptor_vertical_camera.cpp rename to src/game/input/vertical_camera.cpp index 5217d64..203a2e8 100644 --- a/src/disruptor_vertical_camera.cpp +++ b/src/game/input/vertical_camera.cpp @@ -14,7 +14,7 @@ * results and memory are left byte-for-byte untouched. */ -#include "disruptor_vertical_camera.h" +#include "game/input/vertical_camera.h" #include "cpu_state.h" #include "lockstep.h" diff --git a/src/disruptor_vertical_camera.h b/src/game/input/vertical_camera.h similarity index 100% rename from src/disruptor_vertical_camera.h rename to src/game/input/vertical_camera.h diff --git a/src/disruptor_language.cpp b/src/game/language/language.cpp similarity index 98% rename from src/disruptor_language.cpp rename to src/game/language/language.cpp index 7ea1891..193efe6 100644 --- a/src/disruptor_language.cpp +++ b/src/game/language/language.cpp @@ -23,14 +23,14 @@ * which characters to give that routine for the letters the other disc * writes. That swap is made for every string func_80044BDC draws. * - * The pack comes from the language disc (disruptor_language_disc.cpp) and - * holds what disruptor_language_pack.h says, by the rules written there. + * The pack comes from the language disc (language_disc.cpp) and + * holds what language_pack.h says, by the rules written there. */ -#include "disruptor_language.h" +#include "game/language/language.h" #include "cpu_state.h" -#include "disruptor_language_pack.h" +#include "game/language/language_pack.h" #include "lockstep.h" #include "mod_plugins.h" #include "psx_netplay.h" diff --git a/src/disruptor_language.h b/src/game/language/language.h similarity index 100% rename from src/disruptor_language.h rename to src/game/language/language.h diff --git a/src/disruptor_language_disc.cpp b/src/game/language/language_disc.cpp similarity index 99% rename from src/disruptor_language_disc.cpp rename to src/game/language/language_disc.cpp index 5550797..21dcc0d 100644 --- a/src/disruptor_language_disc.cpp +++ b/src/game/language/language_disc.cpp @@ -29,9 +29,9 @@ * header says, so it takes the other disc's header and that second part. */ -#include "disruptor_language_disc.h" +#include "game/language/language_disc.h" -#include "disruptor_language_pack.h" +#include "game/language/language_pack.h" #include #include diff --git a/src/disruptor_language_disc.h b/src/game/language/language_disc.h similarity index 100% rename from src/disruptor_language_disc.h rename to src/game/language/language_disc.h diff --git a/src/disruptor_language_hints.cpp b/src/game/language/language_hints.cpp similarity index 98% rename from src/disruptor_language_hints.cpp rename to src/game/language/language_hints.cpp index 0a89bb7..3d97a7f 100644 --- a/src/disruptor_language_hints.cpp +++ b/src/game/language/language_hints.cpp @@ -21,10 +21,10 @@ * code then shows the upper half. */ -#include "disruptor_language.h" +#include "game/language/language.h" #include "cpu_state.h" -#include "disruptor_language_disc.h" +#include "game/language/language_disc.h" #include "lockstep.h" #include "mod_plugins.h" #include "psx_netplay.h" diff --git a/src/disruptor_language_pack.h b/src/game/language/language_pack.h similarity index 100% rename from src/disruptor_language_pack.h rename to src/game/language/language_pack.h diff --git a/src/disruptor_language_source.cpp b/src/game/language/language_source.cpp similarity index 96% rename from src/disruptor_language_source.cpp rename to src/game/language/language_source.cpp index 66cf7f2..19220d0 100644 --- a/src/disruptor_language_source.cpp +++ b/src/game/language/language_source.cpp @@ -3,15 +3,15 @@ * * The player names an image of another region's disc. When the runtime sets * its save states up, or at the latest the first time the drive reads the - * game disc, the two are laid out as one (see disruptor_language_disc.cpp), + * game disc, the two are laid out as one (see language_disc.cpp), * and from then on every sector comes from that layout. A disc that is not a * known version, or does not fit, is refused whole and the game disc is read * as it is. */ -#include "disruptor_language.h" +#include "game/language/language.h" -#include "disruptor_language_disc.h" +#include "game/language/language_disc.h" #include "iso_overlay.h" #include "mod_plugins.h" #include "savestate.h" diff --git a/src/disruptor_backdrop_tiles.cpp b/src/game/render/backdrop_tiles.cpp similarity index 100% rename from src/disruptor_backdrop_tiles.cpp rename to src/game/render/backdrop_tiles.cpp diff --git a/src/disruptor_billboard_aspect.cpp b/src/game/render/billboard_aspect.cpp similarity index 99% rename from src/disruptor_billboard_aspect.cpp rename to src/game/render/billboard_aspect.cpp index 160f736..d9b5859 100644 --- a/src/disruptor_billboard_aspect.cpp +++ b/src/game/render/billboard_aspect.cpp @@ -14,7 +14,7 @@ * Guest RAM, Y, UVs, HUD SPRTs, and the first-person weapon path are untouched. */ -#include "disruptor_billboard_aspect.h" +#include "game/render/billboard_aspect.h" #include "cpu_state.h" #include "gpu.h" diff --git a/src/disruptor_billboard_aspect.h b/src/game/render/billboard_aspect.h similarity index 100% rename from src/disruptor_billboard_aspect.h rename to src/game/render/billboard_aspect.h diff --git a/src/disruptor_far_rendering.cpp b/src/game/render/far_rendering.cpp similarity index 99% rename from src/disruptor_far_rendering.cpp rename to src/game/render/far_rendering.cpp index a37633d..6e52bee 100644 --- a/src/disruptor_far_rendering.cpp +++ b/src/game/render/far_rendering.cpp @@ -14,7 +14,7 @@ * the new invocation is rejected, so a missed seam fails closed. */ -#include "disruptor_far_rendering.h" +#include "game/render/far_rendering.h" #include "cpu_state.h" #include "lockstep.h" diff --git a/src/disruptor_far_rendering.h b/src/game/render/far_rendering.h similarity index 100% rename from src/disruptor_far_rendering.h rename to src/game/render/far_rendering.h diff --git a/src/disruptor_grown_quad.cpp b/src/game/render/grown_quad.cpp similarity index 100% rename from src/disruptor_grown_quad.cpp rename to src/game/render/grown_quad.cpp diff --git a/src/disruptor_hud_widgets.cpp b/src/game/render/hud_widgets.cpp similarity index 100% rename from src/disruptor_hud_widgets.cpp rename to src/game/render/hud_widgets.cpp diff --git a/src/disruptor_render_arena.cpp b/src/game/render/render_arena.cpp similarity index 100% rename from src/disruptor_render_arena.cpp rename to src/game/render/render_arena.cpp diff --git a/src/disruptor_sprite_depth.cpp b/src/game/render/sprite_depth.cpp similarity index 100% rename from src/disruptor_sprite_depth.cpp rename to src/game/render/sprite_depth.cpp diff --git a/src/disruptor_capsule.cpp b/src/game/session/capsule.cpp similarity index 99% rename from src/disruptor_capsule.cpp rename to src/game/session/capsule.cpp index bfef518..a4001b5 100644 --- a/src/disruptor_capsule.cpp +++ b/src/game/session/capsule.cpp @@ -2,7 +2,7 @@ /* PSX_DISRUPTOR_CAPSULE_FRAMES= writes each replayed frame as a picture, which is whole only without a window. */ /* PSX_DISRUPTOR_CAPSULE_RECORD= is the key for a script: two seconds in, it records that many frames and ends the run. */ /* Measured: a loaded state takes its interrupts a few instructions apart, so one frame in four differs in a few bytes, nine in a row at most. */ -#include "disruptor_capsule.h" +#include "game/session/capsule.h" #include #include diff --git a/src/disruptor_capsule.h b/src/game/session/capsule.h similarity index 100% rename from src/disruptor_capsule.h rename to src/game/session/capsule.h diff --git a/src/disruptor_cheats.cpp b/src/game/session/cheats.cpp similarity index 99% rename from src/disruptor_cheats.cpp rename to src/game/session/cheats.cpp index a06a6bf..1be308f 100644 --- a/src/disruptor_cheats.cpp +++ b/src/game/session/cheats.cpp @@ -10,7 +10,7 @@ * below are guarded by source/codegen contract tests for this exact executable. */ -#include "disruptor_cheats.h" +#include "game/session/cheats.h" #include "cpu_state.h" #include "mod_plugins.h" diff --git a/src/disruptor_cheats.h b/src/game/session/cheats.h similarity index 100% rename from src/disruptor_cheats.h rename to src/game/session/cheats.h diff --git a/src/disruptor_intro_skip.cpp b/src/game/session/intro_skip.cpp similarity index 99% rename from src/disruptor_intro_skip.cpp rename to src/game/session/intro_skip.cpp index 486a83e..e166ec2 100644 --- a/src/disruptor_intro_skip.cpp +++ b/src/game/session/intro_skip.cpp @@ -19,7 +19,7 @@ * after launch instead of 14. */ -#include "disruptor_intro_skip.h" +#include "game/session/intro_skip.h" #include "cpu_state.h" #include "lockstep.h" diff --git a/src/disruptor_intro_skip.h b/src/game/session/intro_skip.h similarity index 100% rename from src/disruptor_intro_skip.h rename to src/game/session/intro_skip.h diff --git a/src/disruptor_restart.cpp b/src/game/session/restart.cpp similarity index 98% rename from src/disruptor_restart.cpp rename to src/game/session/restart.cpp index 93529d4..ccb3432 100644 --- a/src/disruptor_restart.cpp +++ b/src/game/session/restart.cpp @@ -9,7 +9,7 @@ * ended by then, the new one ends itself and the player starts the game. */ -#include "disruptor_restart.h" +#include "game/session/restart.h" #include "mod_plugins.h" diff --git a/src/disruptor_restart.h b/src/game/session/restart.h similarity index 100% rename from src/disruptor_restart.h rename to src/game/session/restart.h diff --git a/src/game/settings/options.cpp b/src/game/settings/options.cpp new file mode 100644 index 0000000..15325e4 --- /dev/null +++ b/src/game/settings/options.cpp @@ -0,0 +1,221 @@ +#include "game/settings/options.h" + +#include "game/frame/present_rate.h" + +#include +#include +#include +#include + +namespace disruptor::settings { +namespace { +using Saved = bool (*)(const Settings&); +using Get = int (*)(const Settings&); +using Set = void (*)(Settings&, int); + +template bool saved(const Settings& settings) { return settings.*Has; } +template int read(const Settings& settings) { return static_cast(settings.*Value); } +template void write(Settings& settings, int value) { + settings.*Has = true; + settings.*Value = static_cast>(value); +} + +constexpr int aspect_widths[] = {4, 16, 21, 32}; +constexpr int aspect_heights[] = {3, 9, 9, 9}; +constexpr int widest_aspect = 3, window_aspect = 4; + +bool aspect_saved(const Settings& settings) { return settings.has_aspect_ratio || settings.has_adaptive_view; } + +int aspect(const Settings& settings) { + if (settings.has_adaptive_view && settings.adaptive_view) return window_aspect; + for (int index = 0; index <= widest_aspect; ++index) + if (settings.aspect_num * aspect_heights[index] == settings.aspect_den * aspect_widths[index]) return index; + return -1; +} + +void set_aspect(Settings& settings, int index) { + const int fixed = index == window_aspect ? widest_aspect : index; + settings.has_aspect_ratio = settings.has_adaptive_view = true; + settings.adaptive_view = index == window_aspect; + settings.aspect_num = aspect_widths[fixed]; + settings.aspect_den = aspect_heights[fixed]; +} + +int scale(const Settings& settings) { return settings.supersampling - 1; } +void set_scale(Settings& settings, int index) { settings.has_supersampling = true; settings.supersampling = index + 1; } +int vsync(const Settings& settings) { return std::clamp(settings.vsync + 1, 0, 2); } +void set_vsync(Settings& settings, int index) { settings.has_vsync = true; settings.vsync = index - 1; } + +template int thousandths(const Settings& settings) { + return static_cast(std::lround(settings.*Value * 1000.0)); +} +template void set_thousandths(Settings& settings, int value) { + settings.*Has = true; + settings.*Value = value / 1000.0; +} + +constexpr const char* display_modes[] = {"Windowed", "Borderless fullscreen", "Exclusive fullscreen"}; +constexpr const char* aspects[] = {"4:3", "16:9", "21:9", "32:9", "Match window (up to 32:9)"}; +constexpr const char* scales[] = {"1x (native)", "2x", "3x", "4x", "5x", "6x", "7x", "8x"}; +constexpr const char* vsyncs[] = {"Adaptive", "Immediate", "Synchronised"}; +constexpr const char* shadows[] = {"Vanilla", "Improved"}; + +constexpr const char* geometry_key = "disruptor.geometry_correction"; +constexpr const char* frames_key = "video.frame_interpolation"; +constexpr const char* rate_key = "video.frame_interpolation_fps"; +constexpr const char* occlusion_key = "disruptor.ambient_occlusion"; +constexpr const char* aim_key = "disruptor.mouse_aim"; +constexpr const char* needs_geometry[] = {geometry_key}; +constexpr const char* needs_frames[] = {frames_key}; +constexpr const char* needs_occlusion[] = {occlusion_key}; +constexpr const char* needs_aim_and_geometry[] = {aim_key, geometry_key}; + +template +constexpr Option toggle(const char* key, const char* label, int initial, const char* hint, + std::span needs = {}) { + return {key, Kind::toggle, label, saved, read, write, initial, 0, 1, {}, needs, + Unit::none, hint}; +} + +constexpr Option choice(const char* key, const char* label, int initial, const char* hint, + std::span choices, Saved saved, Get get, Set set) { + return {key, Kind::choice, label, saved, get, set, initial, 0, static_cast(choices.size()) - 1, + choices, {}, Unit::none, hint}; +} + +constexpr Option slider(const char* key, const char* label, int initial, const char* hint, + int lowest, int highest, Unit unit, Saved saved, Get get, Set set, + std::span needs = {}) { + return {key, Kind::slider, label, saved, get, set, initial, lowest, highest, {}, needs, unit, hint}; +} + +constexpr Option option_list[] = { + choice("video.fullscreen", "Display mode", 0, + "A window, a borderless window the size of the screen, or exclusive fullscreen. Alt+Enter " + "switches between a window and fullscreen while playing.", display_modes, + saved<&Settings::has_fullscreen>, read<&Settings::fullscreen>, + write<&Settings::has_fullscreen, &Settings::fullscreen>), + choice("video.aspect_ratio", "Aspect ratio", 0, + "4:3 is the original picture. The wider choices show more of the world at the sides. Match " + "window follows the window's shape, up to 32:9. Movies stay 4:3.", aspects, + aspect_saved, aspect, set_aspect), + choice("video.supersampling", "Internal resolution scale", 3, + "Draws the picture at this multiple of the original resolution. Higher is sharper. What it " + "costs the graphics card grows roughly with the square of the value.", scales, + saved<&Settings::has_supersampling>, scale, set_scale), + choice("video.vsync", "VSync", 2, + "Synchronised waits for the display, so the picture never tears. Immediate does not wait. " + "Adaptive waits only while the game keeps up with the display.", + vsyncs, saved<&Settings::has_vsync>, vsync, set_vsync), + slider("disruptor.hud_scale", "HUD size", 100, + "Widescreen only. Shrinks the health and ammunition displays toward their screen edges. " + "The weapon keeps its size.", 50, 100, Unit::percent, + saved<&Settings::has_hud_scale>, read<&Settings::hud_scale>, + write<&Settings::has_hud_scale, &Settings::hud_scale>), + + toggle<&Settings::has_geometry_correction, &Settings::geometry_correction>( + "disruptor.geometry_correction", "Exact-provenance geometry", 1, + "Places polygons from the game's own arithmetic before it is rounded to whole pixels, which " + "steadies the PlayStation's wobbling edges. Other options need it."), + toggle<&Settings::has_perspective_textures, &Settings::perspective_textures>( + "disruptor.perspective_textures", "Perspective-correct world textures", 1, + "Draws wall and floor textures in true perspective. The PlayStation's own way bends them near " + "the viewer.", needs_geometry), + toggle<&Settings::has_gap_filling, &Settings::gap_filling>( + "disruptor.gap_filling", "Gap filling", 1, + "Closes hairline gaps between polygons and keeps the game's own patches off their neighbours.", needs_geometry), + toggle<&Settings::has_depth_sorted_world, &Settings::depth_sorted_world>( + "disruptor.depth_sorted_world", "Depth-sorted world", 0, + "Draws world polygons by their depth where the game's own order puts a far one over a near " + "one.", needs_geometry), + choice("disruptor.improved_shadows", "Shadows", 0, + "Vanilla is the game's own shape. Improved lays a shadow flat on the floor: as wide as the " + "game makes it and as tall as the floor's perspective gives. Improved needs exact " + "geometry.", shadows, + saved<&Settings::has_improved_shadows>, read<&Settings::improved_shadows>, + write<&Settings::has_improved_shadows, &Settings::improved_shadows>), + toggle<&Settings::has_frame_unlock, &Settings::frame_unlock>( + "disruptor.frame_unlock", "60 FPS gameplay", 0, + "Lets the game run at up to 60 frames a second in place of its original rate."), + toggle<&Settings::has_frame_interpolation, &Settings::frame_interpolation>( + "video.frame_interpolation", "In-between frames", 0, + "Shows extra pictures between the game's frames, built from the game's own geometry, for " + "smoother motion.", needs_geometry), + slider("video.frame_interpolation_fps", "In-between frame rate", 0, + "Pictures shown per second while in-between frames are on.", + kDisruptorPresentRateLowest, kDisruptorPresentRateHighest, Unit::frames, + saved<&Settings::has_frame_interpolation_fps>, read<&Settings::frame_interpolation_fps>, + write<&Settings::has_frame_interpolation_fps, &Settings::frame_interpolation_fps>, needs_frames), + toggle<&Settings::has_skip_intro, &Settings::skip_intro>( + "disruptor.skip_intro", "Skip the logos and the title movie", 0, + "Starts at the main menu without the logos and the title movie. Escape skips a logo or a " + "movie either way."), + + toggle<&Settings::has_mouse_aim, &Settings::mouse_aim>( + "disruptor.mouse_aim", "Horizontal mouse aim", 1, + "Turns the view with the mouse. In the game a middle-click captures the mouse, and a " + "middle-click or Escape releases it."), + toggle<&Settings::has_modern_controls, &Settings::modern_controls>( + "disruptor.modern_controls", "Modern keyboard and mouse controls", 1, + "WASD moves, the left mouse button fires, the right one uses psionics, Space jumps and E " + "interacts."), + slider("disruptor.horizontal_sensitivity", "Horizontal sensitivity", 80, + "How far the view turns for a movement of the mouse.", 5, 2000, Unit::thousandths, + saved<&Settings::has_horizontal_sensitivity>, thousandths<&Settings::horizontal_sensitivity>, + set_thousandths<&Settings::has_horizontal_sensitivity, &Settings::horizontal_sensitivity>), + toggle<&Settings::has_invert_horizontal, &Settings::invert_horizontal>( + "disruptor.invert_horizontal", "Invert horizontal mouse", 0, + "Turns the view left when the mouse moves right."), + toggle<&Settings::has_high_precision_camera, &Settings::high_precision_camera>( + "disruptor.high_precision_camera", "Smooth mouse turning", 0, + "The game turns in 256 steps of a circle. With this on, the picture also turns by the part of " + "a step in between. Needs mouse aim and exact geometry.", needs_aim_and_geometry), + toggle<&Settings::has_vertical_look, &Settings::vertical_look>( + "disruptor.vertical_look", "Vertical mouse look", 0, + "Looks up and down with the mouse, to about 31 degrees each way. The original game has no " + "such view. Experimental."), + slider("disruptor.vertical_sensitivity", "Vertical sensitivity", 80, + "How far the view tilts for a movement of the mouse.", 5, 2000, Unit::thousandths, + saved<&Settings::has_vertical_sensitivity>, thousandths<&Settings::vertical_sensitivity>, + set_thousandths<&Settings::has_vertical_sensitivity, &Settings::vertical_sensitivity>), + toggle<&Settings::has_invert_vertical, &Settings::invert_vertical>( + "disruptor.invert_vertical", "Invert vertical mouse", 0, + "Looks down when the mouse moves forward."), + + slider("audio.master_volume", "Master volume", 100, + "The loudness of everything the game plays.", 0, 100, Unit::percent, + saved<&Settings::has_master_volume>, read<&Settings::master_volume>, + write<&Settings::has_master_volume, &Settings::master_volume>), + toggle<&Settings::has_audio_muted, &Settings::audio_muted>("audio.muted", "Mute all audio", 0, + "Silences the game and keeps the volume setting."), + + toggle<&Settings::has_ambient_occlusion, &Settings::ambient_occlusion>( + "disruptor.ambient_occlusion", "Ambient occlusion", 0, + "Shades corners and the floor under things. Unfinished.", needs_geometry), + slider("disruptor.occlusion_reach", "Occlusion reach", 160, + "How far from a corner or an object the shade spreads.", 50, 800, Unit::none, + saved<&Settings::has_occlusion_reach>, read<&Settings::occlusion_reach>, + write<&Settings::has_occlusion_reach, &Settings::occlusion_reach>, needs_occlusion), + slider("disruptor.occlusion_strength", "Occlusion strength", 80, + "How dark the shade is.", 0, 300, Unit::percent, + saved<&Settings::has_occlusion_strength>, read<&Settings::occlusion_strength>, + write<&Settings::has_occlusion_strength, &Settings::occlusion_strength>, needs_occlusion), +}; +} // namespace + +std::span options() { return option_list; } + +const Option* find_option(const char* key) { + for (const auto& option : option_list) + if (std::string_view(option.key) == key) return &option; + return nullptr; +} + +bool applies(const Option& option, const std::function& on) { + return std::all_of(option.needs.begin(), option.needs.end(), [&](const char* key) { + const Option* needed = find_option(key); + return needed && on(*needed) && applies(*needed, on); + }); +} + +} // namespace disruptor::settings diff --git a/src/game/settings/options.h b/src/game/settings/options.h new file mode 100644 index 0000000..c939a5a --- /dev/null +++ b/src/game/settings/options.h @@ -0,0 +1,38 @@ +#pragma once + +#include "config_loader.h" + +#include +#include + +// The settings a player can change, said once for the launcher and for the in-game menu. +namespace disruptor::settings { + +using Settings = PSXRecompV4::UserSettings; + +enum class Kind { toggle, choice, slider }; +enum class Unit { none, percent, frames, thousandths }; + +// A value is 0 or 1 for a toggle, an index into `choices` for a choice and a whole number of `unit` for a slider. +struct Option { + const char* key; // "section.key" in settings.toml + Kind kind; + const char* label; // UTF-8, as is `hint` and every choice + bool (*saved)(const Settings&); + int (*get)(const Settings&); + void (*set)(Settings&, int); + int initial; // what a fresh installation runs with + int lowest; + int highest; + std::span choices; + std::span needs; // the toggles that must be on for this option to apply + Unit unit; + const char* hint; +}; + +std::span options(); +const Option* find_option(const char* key); +// Whether every toggle the option needs is on, as `on` tells, and every toggle those need in turn. +bool applies(const Option& option, const std::function& on); + +} // namespace disruptor::settings diff --git a/src/disruptor_dev_menu.cpp b/src/game/ui/dev_menu.cpp similarity index 75% rename from src/disruptor_dev_menu.cpp rename to src/game/ui/dev_menu.cpp index 6a4ffa5..df034e0 100644 --- a/src/disruptor_dev_menu.cpp +++ b/src/game/ui/dev_menu.cpp @@ -7,15 +7,17 @@ * gameplay APIs whose save consequences are stated in the UI. */ -#include "disruptor_cheats.h" -#include "disruptor_billboard_aspect.h" -#include "disruptor_far_rendering.h" -#include "disruptor_frame_rate.h" -#include "disruptor_capsule.h" -#include "disruptor_intro_skip.h" -#include "disruptor_mouse_aim.h" -#include "disruptor_present_rate.h" -#include "disruptor_restart.h" +#include "game/session/cheats.h" +#include "game/render/billboard_aspect.h" +#include "game/render/far_rendering.h" +#include "game/frame/frame_rate.h" +#include "game/session/capsule.h" +#include "game/session/intro_skip.h" +#include "game/input/mouse_aim.h" +#include "game/frame/present_rate.h" +#include "game/session/restart.h" +#include "game/settings/options.h" +#include "game/ui/hint.h" #include "config_loader.h" #include "gpu.h" #include "host_ui.h" @@ -38,6 +40,7 @@ #include #include #include +#include #include #include @@ -82,45 +85,28 @@ struct DevMenuState { DevMenuState g_menu; namespace fs = std::filesystem; - -enum PreferenceDirty : uint32_t { - PREF_MOUSE_AIM = 1u << 0, - PREF_MODERN_CONTROLS = 1u << 1, - PREF_SENSITIVITY = 1u << 2, - PREF_INVERT_X = 1u << 3, - PREF_GEOMETRY = 1u << 5, - PREF_TEXTURES = 1u << 6, - PREF_VSYNC = 1u << 7, - PREF_VERTICAL_LOOK = 1u << 10, - PREF_VERTICAL_SENS = 1u << 11, - PREF_INVERT_Y = 1u << 12, - PREF_ASPECT = 1u << 13, - PREF_SUPERSAMPLING = 1u << 14, - PREF_FULLSCREEN = 1u << 15, - PREF_MASTER_VOLUME = 1u << 16, - PREF_AUDIO_MUTED = 1u << 17, - PREF_HUD_SCALE = 1u << 18, - PREF_FRAME_UNLOCK = 1u << 19, - PREF_SHADOW_SHAPE = 1u << 20, - PREF_PRESENT_RATE = 1u << 21, - PREF_SKIP_INTRO = 1u << 22, - PREF_PRESENT_ENABLED = 1u << 23, - PREF_LANGUAGE_DISC = 1u << 24, - PREF_OCCLUSION = 1u << 25, - PREF_OCCLUSION_SHAPE = 1u << 26, - PREF_GAP_FILLING = 1u << 27, - PREF_WORLD_DEPTH = 1u << 28, - PREF_DIAGNOSTIC_VIEW = 1u << 29, - PREF_SMOOTH_TURN = 1u << 30, -}; +using disruptor::settings::find_option; +using disruptor::settings::Option; +using disruptor::settings::Unit; struct PreferenceState { fs::path path; PSXRecompV4::UserSettings pending; - uint32_t dirty = 0; + std::set changed; + bool language_disc_changed = false; + bool diagnostic_view_changed = false; bool loaded = false; bool save_failed = false; std::string status; + + bool dirty() const { + return !changed.empty() || language_disc_changed || + diagnostic_view_changed; + } + void clean() { + changed.clear(); + language_disc_changed = diagnostic_view_changed = false; + } }; PreferenceState g_preferences; @@ -203,66 +189,94 @@ void set_modern_controls_live(bool enabled) { disruptor_modern_controls_set_enabled(enabled ? 1 : 0); } -void mark_mouse_aim(bool value) { - g_preferences.pending.has_mouse_aim = true; - g_preferences.pending.mouse_aim = value; - g_preferences.dirty |= PREF_MOUSE_AIM; +void mark(const char *key, int value) { + const Option *option = find_option(key); + option->set(g_preferences.pending, value); + g_preferences.changed.insert(option); } -void mark_modern_controls(bool value) { - g_preferences.pending.has_modern_controls = true; - g_preferences.pending.modern_controls = value; - g_preferences.dirty |= PREF_MODERN_CONTROLS; +bool changed(const char *key) { + return g_preferences.changed.contains(find_option(key)); } -void mark_sensitivity(double value) { - g_preferences.pending.has_horizontal_sensitivity = true; - g_preferences.pending.horizontal_sensitivity = value; - g_preferences.dirty |= PREF_SENSITIVITY; +bool prerequisite_on(const Option &option) { + const std::string_view key(option.key); + if (key == "disruptor.geometry_correction") + return gpu_geometry_correction_enabled() != 0; + if (key == "disruptor.mouse_aim") return disruptor_mouse_aim_enabled() != 0; + if (key == "disruptor.ambient_occlusion") return gl_renderer_ssao() != 0; + return false; } -void mark_invert_x(bool value) { - g_preferences.pending.has_invert_horizontal = true; - g_preferences.pending.invert_horizontal = value; - g_preferences.dirty |= PREF_INVERT_X; +bool applies(const char *key) { + return disruptor::settings::applies(*find_option(key), prerequisite_on); } -void mark_vertical_look(bool value) { - g_preferences.pending.has_vertical_look = true; - g_preferences.pending.vertical_look = value; - g_preferences.dirty |= PREF_VERTICAL_LOOK; +bool option_toggle(const char *key, bool *value) { + const Option &option = *find_option(key); + const bool moved = ImGui::Checkbox(option.label, value); + disruptor::ui::hint(option.hint); + return moved; } -void mark_vertical_sensitivity(double value) { - g_preferences.pending.has_vertical_sensitivity = true; - g_preferences.pending.vertical_sensitivity = value; - g_preferences.dirty |= PREF_VERTICAL_SENS; +bool option_slider(const char *key, int *value) { + const Option &option = *find_option(key); + const char *format = option.unit == Unit::percent ? "%d%%" : "%d"; + const bool moved = + ImGui::SliderInt(option.label, value, option.lowest, option.highest, + format, ImGuiSliderFlags_AlwaysClamp); + disruptor::ui::hint(option.hint); + return moved; } -void mark_invert_y(bool value) { - g_preferences.pending.has_invert_vertical = true; - g_preferences.pending.invert_vertical = value; - g_preferences.dirty |= PREF_INVERT_Y; +bool option_slider(const char *key, float *value) { + const Option &option = *find_option(key); + const bool moved = ImGui::SliderFloat( + option.label, value, option.lowest / 1000.0f, + option.highest / 1000.0f, "%.3f", ImGuiSliderFlags_Logarithmic); + disruptor::ui::hint(option.hint); + return moved; } -void mark_geometry(bool value) { - g_preferences.pending.has_geometry_correction = true; - g_preferences.pending.geometry_correction = value; - g_preferences.dirty |= PREF_GEOMETRY; +bool option_list(const char *key, int *index, size_t skipped = 0) { + const Option &option = *find_option(key); + const auto shown = option.choices.subspan(skipped); + const bool moved = ImGui::Combo(option.label, index, shown.data(), + static_cast(shown.size())); + disruptor::ui::hint(option.hint); + return moved; } -void mark_textures(bool value) { - g_preferences.pending.has_perspective_textures = true; - g_preferences.pending.perspective_textures = value; - g_preferences.dirty |= PREF_TEXTURES; +void mark_mouse_aim(bool value) { mark("disruptor.mouse_aim", value); } + +void mark_modern_controls(bool value) { mark("disruptor.modern_controls", value); } + +void mark_sensitivity(double value) { + g_preferences.pending.has_horizontal_sensitivity = true; + g_preferences.pending.horizontal_sensitivity = value; + g_preferences.changed.insert( + find_option("disruptor.horizontal_sensitivity")); } -void mark_vsync(int value) { - g_preferences.pending.has_vsync = true; - g_preferences.pending.vsync = value; - g_preferences.dirty |= PREF_VSYNC; +void mark_invert_x(bool value) { mark("disruptor.invert_horizontal", value); } + +void mark_vertical_look(bool value) { mark("disruptor.vertical_look", value); } + +void mark_vertical_sensitivity(double value) { + g_preferences.pending.has_vertical_sensitivity = true; + g_preferences.pending.vertical_sensitivity = value; + g_preferences.changed.insert( + find_option("disruptor.vertical_sensitivity")); } +void mark_invert_y(bool value) { mark("disruptor.invert_vertical", value); } + +void mark_geometry(bool value) { mark("disruptor.geometry_correction", value); } + +void mark_textures(bool value) { mark("disruptor.perspective_textures", value); } + +void mark_vsync(int value) { mark("video.vsync", value + 1); } + void mark_aspect(int numerator, int denominator, bool adaptive) { g_preferences.pending.has_aspect_ratio = true; g_preferences.pending.aspect_num = numerator; @@ -271,88 +285,42 @@ void mark_aspect(int numerator, int denominator, bool adaptive) { * settings update as the fallback/initial aspect. */ g_preferences.pending.has_adaptive_view = true; g_preferences.pending.adaptive_view = adaptive; - g_preferences.dirty |= PREF_ASPECT; + g_preferences.changed.insert(find_option("video.aspect_ratio")); } -void mark_supersampling(int value) { - g_preferences.pending.has_supersampling = true; - g_preferences.pending.supersampling = value; - g_preferences.dirty |= PREF_SUPERSAMPLING; -} +void mark_supersampling(int value) { mark("video.supersampling", value - 1); } -void mark_fullscreen(int value) { - g_preferences.pending.has_fullscreen = true; - g_preferences.pending.fullscreen = value; - g_preferences.dirty |= PREF_FULLSCREEN; -} +void mark_fullscreen(int value) { mark("video.fullscreen", value); } -void mark_master_volume(int value) { - g_preferences.pending.has_master_volume = true; - g_preferences.pending.master_volume = value; - g_preferences.dirty |= PREF_MASTER_VOLUME; -} +void mark_master_volume(int value) { mark("audio.master_volume", value); } -void mark_audio_muted(bool value) { - g_preferences.pending.has_audio_muted = true; - g_preferences.pending.audio_muted = value; - g_preferences.dirty |= PREF_AUDIO_MUTED; -} +void mark_audio_muted(bool value) { mark("audio.muted", value); } -void mark_skip_intro(bool value) { - g_preferences.pending.has_skip_intro = true; - g_preferences.pending.skip_intro = value; - g_preferences.dirty |= PREF_SKIP_INTRO; -} +void mark_skip_intro(bool value) { mark("disruptor.skip_intro", value); } void mark_language_disc(const std::string &path) { g_language_disc = path; g_preferences.pending.has_language_disc = true; g_preferences.pending.language_disc = path; - g_preferences.dirty |= PREF_LANGUAGE_DISC; + g_preferences.language_disc_changed = true; } -void mark_hud_scale(int value) { - g_preferences.pending.has_hud_scale = true; - g_preferences.pending.hud_scale = value; - g_preferences.dirty |= PREF_HUD_SCALE; -} +void mark_hud_scale(int value) { mark("disruptor.hud_scale", value); } -void mark_frame_unlock(bool value) { - g_preferences.pending.has_frame_unlock = true; - g_preferences.pending.frame_unlock = value; - g_preferences.dirty |= PREF_FRAME_UNLOCK; -} +void mark_frame_unlock(bool value) { mark("disruptor.frame_unlock", value); } -void mark_shadow_shape(bool improved) { - g_preferences.pending.has_improved_shadows = true; - g_preferences.pending.improved_shadows = improved; - g_preferences.dirty |= PREF_SHADOW_SHAPE; -} +void mark_shadow_shape(bool improved) { mark("disruptor.improved_shadows", improved); } -void mark_occlusion(bool value) { - g_preferences.pending.has_ambient_occlusion = true; - g_preferences.pending.ambient_occlusion = value; - g_preferences.dirty |= PREF_OCCLUSION; -} +void mark_occlusion(bool value) { mark("disruptor.ambient_occlusion", value); } void mark_occlusion_shape(int reach, int strength) { - g_preferences.pending.has_occlusion_reach = g_preferences.pending.has_occlusion_strength = true; - g_preferences.pending.occlusion_reach = reach; - g_preferences.pending.occlusion_strength = strength; - g_preferences.dirty |= PREF_OCCLUSION_SHAPE; + mark("disruptor.occlusion_reach", reach); + mark("disruptor.occlusion_strength", strength); } -void mark_gap_filling(bool value) { - g_preferences.pending.has_gap_filling = true; - g_preferences.pending.gap_filling = value; - g_preferences.dirty |= PREF_GAP_FILLING; -} +void mark_gap_filling(bool value) { mark("disruptor.gap_filling", value); } -void mark_world_depth(bool value) { - g_preferences.pending.has_depth_sorted_world = true; - g_preferences.pending.depth_sorted_world = value; - g_preferences.dirty |= PREF_WORLD_DEPTH; -} +void mark_world_depth(bool value) { mark("disruptor.depth_sorted_world", value); } // Views 1 to 4 are layers of the occlusion pass, 5 is the mesh drawn over in-between frames. void apply_diagnostic_view(int view) { @@ -363,7 +331,7 @@ void apply_diagnostic_view(int view) { void mark_diagnostic_view(int value) { g_preferences.pending.has_diagnostic_view = true; g_preferences.pending.diagnostic_view = value; - g_preferences.dirty |= PREF_DIAGNOSTIC_VIEW; + g_preferences.diagnostic_view_changed = true; } // With the vertical divided too a renderer turn of one step misses the game's next step in a median of 8604 pixels of 307200, without in 24274. @@ -372,146 +340,28 @@ void apply_smooth_turn(bool enabled) { if (!env_override_present("PSX_GEOMETRY_FULL_YAW")) gl_renderer_set_full_yaw(enabled ? 1 : 0); } -void mark_smooth_turn(bool value) { - g_preferences.pending.has_high_precision_camera = true; - g_preferences.pending.high_precision_camera = value; - g_preferences.dirty |= PREF_SMOOTH_TURN; -} +void mark_smooth_turn(bool value) { mark("disruptor.high_precision_camera", value); } -void mark_present_rate(int value) { - g_preferences.pending.has_frame_interpolation_fps = true; - g_preferences.pending.frame_interpolation_fps = value; - g_preferences.dirty |= PREF_PRESENT_RATE; -} +void mark_present_rate(int value) { mark("video.frame_interpolation_fps", value); } -void mark_present_enabled(bool value) { - g_preferences.pending.has_frame_interpolation = true; - g_preferences.pending.frame_interpolation = value; - g_preferences.dirty |= PREF_PRESENT_ENABLED; -} +void mark_present_enabled(bool value) { mark("video.frame_interpolation", value); } void merge_dirty_preferences(PSXRecompV4::UserSettings &settings) { const auto &pending = g_preferences.pending; - if (g_preferences.dirty & PREF_MOUSE_AIM) { - settings.has_mouse_aim = true; - settings.mouse_aim = pending.mouse_aim; - } - if (g_preferences.dirty & PREF_MODERN_CONTROLS) { - settings.has_modern_controls = true; - settings.modern_controls = pending.modern_controls; - } - if (g_preferences.dirty & PREF_SENSITIVITY) { - settings.has_horizontal_sensitivity = true; - settings.horizontal_sensitivity = pending.horizontal_sensitivity; - } - if (g_preferences.dirty & PREF_INVERT_X) { - settings.has_invert_horizontal = true; - settings.invert_horizontal = pending.invert_horizontal; - } - if (g_preferences.dirty & PREF_VERTICAL_LOOK) { - settings.has_vertical_look = true; - settings.vertical_look = pending.vertical_look; - } - if (g_preferences.dirty & PREF_VERTICAL_SENS) { - settings.has_vertical_sensitivity = true; - settings.vertical_sensitivity = pending.vertical_sensitivity; - } - if (g_preferences.dirty & PREF_INVERT_Y) { - settings.has_invert_vertical = true; - settings.invert_vertical = pending.invert_vertical; - } - if (g_preferences.dirty & PREF_GEOMETRY) { - settings.has_geometry_correction = true; - settings.geometry_correction = pending.geometry_correction; - } - if (g_preferences.dirty & PREF_TEXTURES) { - settings.has_perspective_textures = true; - settings.perspective_textures = pending.perspective_textures; - } - if (g_preferences.dirty & PREF_VSYNC) { - settings.has_vsync = true; - settings.vsync = pending.vsync; - } - if (g_preferences.dirty & PREF_ASPECT) { - settings.has_aspect_ratio = true; - settings.aspect_num = pending.aspect_num; - settings.aspect_den = pending.aspect_den; - settings.has_adaptive_view = true; - settings.adaptive_view = pending.adaptive_view; - } - if (g_preferences.dirty & PREF_SUPERSAMPLING) { - settings.has_supersampling = true; - settings.supersampling = pending.supersampling; - } - if (g_preferences.dirty & PREF_FULLSCREEN) { - settings.has_fullscreen = true; - settings.fullscreen = pending.fullscreen; - } - if (g_preferences.dirty & PREF_MASTER_VOLUME) { - settings.has_master_volume = true; - settings.master_volume = pending.master_volume; - } - if (g_preferences.dirty & PREF_AUDIO_MUTED) { - settings.has_audio_muted = true; - settings.audio_muted = pending.audio_muted; - } - if (g_preferences.dirty & PREF_SKIP_INTRO) { - settings.has_skip_intro = true; - settings.skip_intro = pending.skip_intro; - } - if (g_preferences.dirty & PREF_LANGUAGE_DISC) { + for (const Option *option : g_preferences.changed) + option->set(settings, option->get(pending)); + if (g_preferences.language_disc_changed) { settings.has_language_disc = true; settings.language_disc = pending.language_disc; } - if (g_preferences.dirty & PREF_HUD_SCALE) { - settings.has_hud_scale = true; - settings.hud_scale = pending.hud_scale; - } - if (g_preferences.dirty & PREF_FRAME_UNLOCK) { - settings.has_frame_unlock = true; - settings.frame_unlock = pending.frame_unlock; - } - if (g_preferences.dirty & PREF_SHADOW_SHAPE) { - settings.has_improved_shadows = true; - settings.improved_shadows = pending.improved_shadows; - } - if (g_preferences.dirty & PREF_OCCLUSION) { - settings.has_ambient_occlusion = true; - settings.ambient_occlusion = pending.ambient_occlusion; - } - if (g_preferences.dirty & PREF_OCCLUSION_SHAPE) { - settings.has_occlusion_reach = settings.has_occlusion_strength = true; - settings.occlusion_reach = pending.occlusion_reach; - settings.occlusion_strength = pending.occlusion_strength; - } - if (g_preferences.dirty & PREF_GAP_FILLING) { - settings.has_gap_filling = true; - settings.gap_filling = pending.gap_filling; - } - if (g_preferences.dirty & PREF_WORLD_DEPTH) { - settings.has_depth_sorted_world = true; - settings.depth_sorted_world = pending.depth_sorted_world; - } - if (g_preferences.dirty & PREF_DIAGNOSTIC_VIEW) { + if (g_preferences.diagnostic_view_changed) { settings.has_diagnostic_view = true; settings.diagnostic_view = pending.diagnostic_view; } - if (g_preferences.dirty & PREF_SMOOTH_TURN) { - settings.has_high_precision_camera = true; - settings.high_precision_camera = pending.high_precision_camera; - } - if (g_preferences.dirty & PREF_PRESENT_RATE) { - settings.has_frame_interpolation_fps = true; - settings.frame_interpolation_fps = pending.frame_interpolation_fps; - } - if (g_preferences.dirty & PREF_PRESENT_ENABLED) { - settings.has_frame_interpolation = true; - settings.frame_interpolation = pending.frame_interpolation; - } } bool flush_preferences() { - if (g_preferences.dirty == 0) return true; + if (!g_preferences.dirty()) return true; if (g_preferences.path.empty()) { g_preferences.save_failed = true; g_preferences.status = "Runtime settings path is unavailable."; @@ -534,7 +384,7 @@ bool flush_preferences() { return false; } - g_preferences.dirty = 0; + g_preferences.clean(); g_preferences.save_failed = false; g_preferences.status = "Settings saved."; std::fprintf(stdout, "disruptor: saved in-game settings to %s\n", @@ -638,74 +488,75 @@ void apply_saved_preferences(const PSXRecompV4::UserSettings &settings) { void apply_pending_preferences() { const auto &pending = g_preferences.pending; - if (g_preferences.dirty & PREF_MOUSE_AIM) + if (changed("disruptor.mouse_aim")) disruptor_mouse_aim_set_enabled(pending.mouse_aim ? 1 : 0); - if (g_preferences.dirty & PREF_MODERN_CONTROLS) + if (changed("disruptor.modern_controls")) set_modern_controls_live(pending.modern_controls); - if (g_preferences.dirty & PREF_SENSITIVITY) + if (changed("disruptor.horizontal_sensitivity")) (void)disruptor_mouse_set_horizontal_sensitivity( pending.horizontal_sensitivity); - if (g_preferences.dirty & PREF_INVERT_X) + if (changed("disruptor.invert_horizontal")) disruptor_mouse_set_invert_horizontal( pending.invert_horizontal ? 1 : 0); - if (g_preferences.dirty & PREF_VERTICAL_LOOK) + if (changed("disruptor.vertical_look")) disruptor_mouse_set_vertical_look_enabled( pending.vertical_look ? 1 : 0); - if (g_preferences.dirty & PREF_VERTICAL_SENS) + if (changed("disruptor.vertical_sensitivity")) (void)disruptor_mouse_set_vertical_sensitivity( pending.vertical_sensitivity); - if (g_preferences.dirty & PREF_INVERT_Y) + if (changed("disruptor.invert_vertical")) disruptor_mouse_set_invert_vertical( pending.invert_vertical ? 1 : 0); - if (g_preferences.dirty & PREF_GEOMETRY) + if (changed("disruptor.geometry_correction")) gpu_geometry_correction_set(pending.geometry_correction ? 1 : 0); - if (g_preferences.dirty & PREF_TEXTURES) + if (changed("disruptor.perspective_textures")) gpu_texture_correction_set( pending.perspective_textures && gpu_geometry_correction_enabled() ? 1 : 0); - if (g_preferences.dirty & PREF_VSYNC) + if (changed("video.vsync")) (void)psx_host_video_set_vsync(pending.vsync); - if (g_preferences.dirty & PREF_ASPECT) { + if (changed("video.aspect_ratio")) { if (pending.adaptive_view) (void)psx_host_video_set_adaptive_view(1); else (void)psx_host_video_set_display_aspect( pending.aspect_num, pending.aspect_den); } - if (g_preferences.dirty & PREF_SUPERSAMPLING) + if (changed("video.supersampling")) (void)psx_host_video_set_internal_scale(pending.supersampling); - if (g_preferences.dirty & PREF_FULLSCREEN) + if (changed("video.fullscreen")) g_menu.fullscreen_apply_failed = psx_host_video_set_fullscreen_mode(pending.fullscreen) == 0; - if (g_preferences.dirty & PREF_MASTER_VOLUME) + if (changed("audio.master_volume")) (void)psx_host_audio_set_master_volume(pending.master_volume); - if (g_preferences.dirty & PREF_AUDIO_MUTED) + if (changed("audio.muted")) (void)psx_host_audio_set_muted(pending.audio_muted ? 1 : 0); - if (g_preferences.dirty & PREF_HUD_SCALE) + if (changed("disruptor.hud_scale")) gpu_ws_set_hud_scale(pending.hud_scale); - if (g_preferences.dirty & PREF_FRAME_UNLOCK) + if (changed("disruptor.frame_unlock")) disruptor_frame_rate_set_unlocked(pending.frame_unlock ? 1 : 0); - if (g_preferences.dirty & PREF_SKIP_INTRO) + if (changed("disruptor.skip_intro")) disruptor_intro_skip_set_enabled(pending.skip_intro ? 1 : 0); - if (g_preferences.dirty & PREF_SHADOW_SHAPE) + if (changed("disruptor.improved_shadows")) gpu_set_shadow_shape(pending.improved_shadows ? 1 : 0); - if (g_preferences.dirty & PREF_OCCLUSION) + if (changed("disruptor.ambient_occlusion")) gl_renderer_set_ssao(pending.ambient_occlusion ? 1 : 0); - if (g_preferences.dirty & PREF_OCCLUSION_SHAPE) + if (changed("disruptor.occlusion_reach") || + changed("disruptor.occlusion_strength")) gl_renderer_set_ssao_shape(static_cast(pending.occlusion_reach), pending.occlusion_strength / 100.0f); - if (g_preferences.dirty & PREF_GAP_FILLING) + if (changed("disruptor.gap_filling")) gl_renderer_set_gap_fill(pending.gap_filling ? 1 : 0); - if (g_preferences.dirty & PREF_WORLD_DEPTH) + if (changed("disruptor.depth_sorted_world")) gl_renderer_set_world_depth(pending.depth_sorted_world ? 1 : 0); - if (g_preferences.dirty & PREF_DIAGNOSTIC_VIEW) + if (g_preferences.diagnostic_view_changed) apply_diagnostic_view(pending.diagnostic_view); - if (g_preferences.dirty & PREF_SMOOTH_TURN) + if (changed("disruptor.high_precision_camera")) apply_smooth_turn(pending.high_precision_camera); #ifndef PSX_DISABLE_FRAME_INTERPOLATION - if (g_preferences.dirty & PREF_PRESENT_ENABLED) + if (changed("video.frame_interpolation")) (void)disruptor_present_rate_enable(pending.frame_interpolation ? 1 : 0); - if (g_preferences.dirty & PREF_PRESENT_RATE) + if (changed("video.frame_interpolation_fps")) (void)disruptor_present_rate_apply(pending.frame_interpolation_fps); #endif } @@ -879,14 +730,14 @@ void draw_status_badge(const char *label, const ImVec4 &colour) { void draw_controls_tab() { bool mouse_aim = disruptor_mouse_aim_enabled() != 0; - if (ImGui::Checkbox("Horizontal mouse aim", &mouse_aim)) { + if (option_toggle("disruptor.mouse_aim", &mouse_aim)) { disruptor_mouse_aim_set_enabled(mouse_aim ? 1 : 0); mark_mouse_aim(mouse_aim); } draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); bool modern = disruptor_modern_controls_enabled() != 0; - if (ImGui::Checkbox("Modern keyboard and mouse controls", &modern)) { + if (option_toggle("disruptor.modern_controls", &modern)) { set_modern_controls_live(modern); mark_modern_controls(modern); } @@ -894,37 +745,31 @@ void draw_controls_tab() { float sensitivity = static_cast(disruptor_mouse_horizontal_sensitivity()); - if (ImGui::SliderFloat("Horizontal sensitivity", &sensitivity, - 0.005f, 2.0f, "%.3f", - ImGuiSliderFlags_Logarithmic)) { + if (option_slider("disruptor.horizontal_sensitivity", &sensitivity)) { const double applied = disruptor_mouse_set_horizontal_sensitivity(sensitivity); mark_sensitivity(applied); } bool invert_x = disruptor_mouse_invert_horizontal() != 0; - if (ImGui::Checkbox("Invert horizontal mouse", &invert_x)) { + if (option_toggle("disruptor.invert_horizontal", &invert_x)) { disruptor_mouse_set_invert_horizontal(invert_x ? 1 : 0); mark_invert_x(invert_x); } - const bool turn_applies = mouse_aim && gpu_geometry_correction_enabled() != 0; + const bool turn_applies = applies("disruptor.high_precision_camera"); bool smooth = disruptor_high_precision_camera_enabled() != 0; if (!turn_applies) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Smooth mouse turning", &smooth)) { + if (option_toggle("disruptor.high_precision_camera", &smooth)) { apply_smooth_turn(smooth); mark_smooth_turn(smooth); } if (!turn_applies) ImGui::EndDisabled(); draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled( - "The game turns in 256 steps of a circle. While the mouse is captured " - "the picture is turned by the part of a step in between. Needs mouse aim " - "and exact geometry."); ImGui::SeparatorText("Vertical look"); bool vertical_look = disruptor_mouse_vertical_look_enabled() != 0; - if (ImGui::Checkbox("Vertical mouse look", &vertical_look)) { + if (option_toggle("disruptor.vertical_look", &vertical_look)) { disruptor_mouse_set_vertical_look_enabled(vertical_look ? 1 : 0); mark_vertical_look(vertical_look); } @@ -932,16 +777,14 @@ void draw_controls_tab() { float vertical_sensitivity = static_cast(disruptor_mouse_vertical_sensitivity()); - if (ImGui::SliderFloat("Vertical sensitivity", &vertical_sensitivity, - 0.005f, 2.0f, "%.3f", - ImGuiSliderFlags_Logarithmic)) { + if (option_slider("disruptor.vertical_sensitivity", &vertical_sensitivity)) { const double applied = disruptor_mouse_set_vertical_sensitivity(vertical_sensitivity); mark_vertical_sensitivity(applied); } bool invert_y = disruptor_mouse_invert_vertical() != 0; - if (ImGui::Checkbox("Invert vertical mouse", &invert_y)) { + if (option_toggle("disruptor.invert_vertical", &invert_y)) { disruptor_mouse_set_invert_vertical(invert_y ? 1 : 0); mark_invert_y(invert_y); } @@ -982,8 +825,6 @@ void apply_geometry_enabled(bool enabled) { void draw_aspect_controls() { static constexpr int kAspectNumerators[] = {16, 21, 32, 32}; static constexpr int kAspectDenominators[] = {9, 9, 9, 9}; - static const char *kAspectLabels[] = { - "16:9", "21:9", "32:9", "Match window (up to 32:9)"}; static constexpr int kFixedWideAspectCount = 3; static constexpr int kAdaptiveAspectIndex = 3; static constexpr int kAspectCount = 4; @@ -1030,8 +871,7 @@ void draw_aspect_controls() { draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); int selected = g_menu.selected_wide_aspect; - if (ImGui::Combo("Aspect ratio", &selected, kAspectLabels, - kAspectCount)) { + if (option_list("video.aspect_ratio", &selected, 1)) { g_menu.selected_wide_aspect = selected; if (widescreen) { if (selected == kAdaptiveAspectIndex) { @@ -1063,39 +903,29 @@ void draw_aspect_controls() { if (gpu_sprite_placement_available()) { int shadow_shape = gpu_shadow_shape(); ImGui::SetNextItemWidth(260.0f); - if (ImGui::Combo("Shadows", &shadow_shape, "Vanilla\0Improved\0")) { + if (option_list("disruptor.improved_shadows", &shadow_shape)) { gpu_set_shadow_shape(shadow_shape); mark_shadow_shape(shadow_shape != 0); } draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled( - "Improved lays a shadow flat on the floor: as wide as the game makes it and as tall " - "as the floor's perspective gives. Works with geometry correction on."); } int hud_scale = gpu_ws_hud_scale(); ImGui::SetNextItemWidth(260.0f); - if (ImGui::SliderInt("HUD size", &hud_scale, 50, 100, "%d%%", - ImGuiSliderFlags_AlwaysClamp)) { + if (option_slider("disruptor.hud_scale", &hud_scale)) { gpu_ws_set_hud_scale(hud_scale); mark_hud_scale(hud_scale); } draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled( - "Widescreen only. Shrinks the health and ammunition displays toward " - "their screen edges. The weapon keeps its size."); } void draw_internal_resolution_controls() { - static const char *kScaleLabels[] = { - "1x (native)", "2x", "3x", "4x", "5x", "6x", "7x", "8x"}; static constexpr int kScaleCount = 8; int selected = std::clamp( psx_host_video_get_internal_scale(), 1, kScaleCount) - 1; ImGui::SeparatorText("Rendering resolution"); - if (ImGui::Combo("Internal resolution scale", &selected, - kScaleLabels, kScaleCount)) { + if (option_list("video.supersampling", &selected)) { const int requested = selected + 1; g_menu.internal_scale_apply_failed = psx_host_video_set_internal_scale(requested) == 0; @@ -1119,57 +949,52 @@ void draw_enhancements_tab() { draw_internal_resolution_controls(); bool geometry = gpu_geometry_correction_enabled() != 0; - if (ImGui::Checkbox("Exact-provenance geometry", &geometry)) + if (option_toggle("disruptor.geometry_correction", &geometry)) apply_geometry_enabled(geometry); draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); bool textures = gpu_texture_correction_enabled() != 0; - if (!geometry) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Perspective-correct world textures", &textures)) { + const bool textures_apply = applies("disruptor.perspective_textures"); + if (!textures_apply) ImGui::BeginDisabled(); + if (option_toggle("disruptor.perspective_textures", &textures)) { gpu_texture_correction_set(textures ? 1 : 0); mark_textures(textures); } - if (!geometry) ImGui::EndDisabled(); + if (!textures_apply) ImGui::EndDisabled(); draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - if (!geometry) + if (!textures_apply) ImGui::TextDisabled("Enable exact geometry before perspective textures."); bool gaps = gl_renderer_gap_fill() != 0; - if (!geometry) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Gap filling", &gaps)) { + const bool gaps_apply = applies("disruptor.gap_filling"); + if (!gaps_apply) ImGui::BeginDisabled(); + if (option_toggle("disruptor.gap_filling", &gaps)) { gl_renderer_set_gap_fill(gaps ? 1 : 0); mark_gap_filling(gaps); } - if (!geometry) ImGui::EndDisabled(); + if (!gaps_apply) ImGui::EndDisabled(); draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled( - "Closes hairline gaps between polygons and keeps the game's own " - "patches off their neighbours."); bool by_depth = gl_renderer_world_depth() != 0; - if (!geometry) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Depth-sorted world", &by_depth)) { + const bool depth_applies = applies("disruptor.depth_sorted_world"); + if (!depth_applies) ImGui::BeginDisabled(); + if (option_toggle("disruptor.depth_sorted_world", &by_depth)) { gl_renderer_set_world_depth(by_depth ? 1 : 0); mark_world_depth(by_depth); } - if (!geometry) ImGui::EndDisabled(); + if (!depth_applies) ImGui::EndDisabled(); draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled( - "Draws world polygons by their depth where the game's own order puts a " - "far one over a near one."); ImGui::SeparatorText("Synchronisation"); int vsync = psx_host_video_get_vsync(); int vsync_index = std::clamp(vsync + 1, 0, 2); - static const char *kVsyncLabels[] = { - "Adaptive", "Immediate", "Synchronised"}; - if (ImGui::Combo("VSync", &vsync_index, kVsyncLabels, 3)) { + if (option_list("video.vsync", &vsync_index)) { const int requested = vsync_index - 1; if (psx_host_video_set_vsync(requested)) mark_vsync(requested); } bool unlocked = disruptor_frame_rate_unlocked() != 0; - if (ImGui::Checkbox("60 FPS gameplay", &unlocked)) { + if (option_toggle("disruptor.frame_unlock", &unlocked)) { disruptor_frame_rate_set_unlocked(unlocked ? 1 : 0); mark_frame_unlock(unlocked); } @@ -1187,20 +1012,26 @@ void draw_enhancements_tab() { #ifndef PSX_DISABLE_FRAME_INTERPOLATION int present_enabled = 0; - if (disruptor_present_rate_switch(geometry ? 1 : 0, &present_enabled)) + if (disruptor_present_rate_switch( + find_option("video.frame_interpolation")->label, + find_option("video.frame_interpolation")->hint, + applies("video.frame_interpolation") ? 1 : 0, + &present_enabled)) mark_present_enabled(present_enabled != 0); int present_rate = 0; - if (disruptor_present_rate_control(&present_rate)) mark_present_rate(present_rate); + if (disruptor_present_rate_control( + find_option("video.frame_interpolation_fps")->label, + find_option("video.frame_interpolation_fps")->hint, &present_rate)) + mark_present_rate(present_rate); #endif ImGui::SeparatorText("Startup"); bool skip_intro = disruptor_intro_skip_enabled() != 0; - if (ImGui::Checkbox("Skip the logos and the title movie", &skip_intro)) { + if (option_toggle("disruptor.skip_intro", &skip_intro)) { disruptor_intro_skip_set_enabled(skip_intro ? 1 : 0); mark_skip_intro(skip_intro); } draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled("Escape skips the logo or movie on screen."); } const char *cheat_result_message(int result, const char *success) { @@ -1224,29 +1055,30 @@ void draw_experimental_tab() { "correction on."); ImGui::Spacing(); - const bool geometry = gpu_geometry_correction_enabled() != 0; bool occlusion = gl_renderer_ssao() != 0; - if (!geometry) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Ambient occlusion", &occlusion)) { + const bool occlusion_applies = applies("disruptor.ambient_occlusion"); + if (!occlusion_applies) ImGui::BeginDisabled(); + if (option_toggle("disruptor.ambient_occlusion", &occlusion)) { gl_renderer_set_ssao(occlusion ? 1 : 0); mark_occlusion(occlusion); } - if (!geometry) ImGui::EndDisabled(); + if (!occlusion_applies) ImGui::EndDisabled(); draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); int reach = static_cast(std::lround(gl_renderer_ssao_radius())); int strength = static_cast(std::lround(gl_renderer_ssao_strength() * 100.0f)); - if (!geometry) ImGui::BeginDisabled(); + const bool shape_applies = applies("disruptor.occlusion_reach") && + applies("disruptor.occlusion_strength"); + if (!shape_applies) ImGui::BeginDisabled(); ImGui::SetNextItemWidth(260.0f); - const bool reach_moved = ImGui::SliderInt("Occlusion reach", &reach, 50, 800, "%d", ImGuiSliderFlags_AlwaysClamp); + const bool reach_moved = option_slider("disruptor.occlusion_reach", &reach); ImGui::SetNextItemWidth(260.0f); const bool strength_moved = - ImGui::SliderInt("Occlusion strength", &strength, 0, 300, "%d%%", ImGuiSliderFlags_AlwaysClamp); - if (!geometry) ImGui::EndDisabled(); + option_slider("disruptor.occlusion_strength", &strength); + if (!shape_applies) ImGui::EndDisabled(); if (reach_moved || strength_moved) { gl_renderer_set_ssao_shape(static_cast(reach), strength / 100.0f); mark_occlusion_shape(reach, strength); } - ImGui::TextDisabled("Shades corners and the floor under things."); } void draw_debug_tab() { @@ -1344,12 +1176,10 @@ void draw_cheats_tab() { } void draw_fullscreen_controls() { - static const char *kFullscreenLabels[] = { - "Windowed", "Borderless fullscreen", "Exclusive fullscreen"}; int mode = std::clamp(psx_host_video_get_fullscreen_mode(), 0, 2); ImGui::SeparatorText("Window presentation"); - if (ImGui::Combo("Display mode", &mode, kFullscreenLabels, 3)) { + if (option_list("video.fullscreen", &mode)) { g_menu.fullscreen_apply_failed = psx_host_video_set_fullscreen_mode(mode) == 0; if (!g_menu.fullscreen_apply_failed) @@ -1371,15 +1201,14 @@ void draw_audio_controls() { ImGui::SeparatorText("Audio output"); int volume = psx_host_audio_get_master_volume(); ImGui::SetNextItemWidth(260.0f); - if (ImGui::SliderInt("Master volume", &volume, 0, 100, "%d%%", - ImGuiSliderFlags_AlwaysClamp) && + if (option_slider("audio.master_volume", &volume) && psx_host_audio_set_master_volume(volume)) { mark_master_volume(volume); } draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); bool muted = psx_host_audio_get_muted() != 0; - if (ImGui::Checkbox("Mute all audio", &muted) && + if (option_toggle("audio.muted", &muted) && psx_host_audio_set_muted(muted ? 1 : 0)) { mark_audio_muted(muted); } @@ -1403,14 +1232,14 @@ void draw_system_tab() { if (g_preferences.save_failed) ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.25f, 1.0f), "Not saved"); - else if (g_preferences.dirty) + else if (g_preferences.dirty()) ImGui::TextColored(ImVec4(1.0f, 0.73f, 0.25f, 1.0f), "Unsaved changes"); else ImGui::TextColored(ImVec4(0.35f, 0.90f, 0.45f, 1.0f), "Saved"); if (!g_preferences.status.empty()) ImGui::TextWrapped("%s", g_preferences.status.c_str()); - if (g_preferences.dirty && ImGui::Button("Save now")) + if (g_preferences.dirty() && ImGui::Button("Save now")) (void)flush_preferences(); ImGui::TextDisabled( "Current vertical pitch, menu layout, " diff --git a/src/game/ui/hint.h b/src/game/ui/hint.h new file mode 100644 index 0000000..267ccb8 --- /dev/null +++ b/src/game/ui/hint.h @@ -0,0 +1,16 @@ +#pragma once + +#include "imgui.h" + +namespace disruptor::ui { + +// Shows `text` while the pointer rests on the control drawn last. +inline void hint(const char *text) { + if (!ImGui::BeginItemTooltip()) return; + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 30.0f); + ImGui::TextUnformatted(text); + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); +} + +} // namespace disruptor::ui diff --git a/src/launcher/CMakeLists.txt b/src/launcher/CMakeLists.txt index 10696dc..d5ccca3 100644 --- a/src/launcher/CMakeLists.txt +++ b/src/launcher/CMakeLists.txt @@ -24,7 +24,8 @@ add_library(disruptor-launcher-model STATIC launcher_settings.cpp region_disc.cpp startup_log.cpp - "${PROJECT_SOURCE_DIR}/src/disruptor_language_disc.cpp") + "${PROJECT_SOURCE_DIR}/src/game/language/language_disc.cpp" + "${PROJECT_SOURCE_DIR}/src/game/settings/options.cpp") target_include_directories(disruptor-launcher-model PUBLIC "${PROJECT_SOURCE_DIR}/src") target_link_libraries(disruptor-launcher-model PUBLIC disruptor-disc-import disruptor-launcher-config) @@ -58,19 +59,19 @@ add_custom_command(TARGET disruptor-launcher POST_BUILD COMMAND "${CMAKE_COMMAND}" "-DSOURCE=${PROJECT_SOURCE_DIR}" "-DDESTINATION=$" - -P "${PROJECT_SOURCE_DIR}/tools/stage_launcher.cmake" + -P "${PROJECT_SOURCE_DIR}/cmake/stage_launcher.cmake" VERBATIM) set_property(TARGET disruptor-launcher APPEND PROPERTY LINK_DEPENDS - "${PROJECT_SOURCE_DIR}/tools/stage_launcher.cmake" + "${PROJECT_SOURCE_DIR}/cmake/stage_launcher.cmake" "${PROJECT_SOURCE_DIR}/game.toml" "${PROJECT_SOURCE_DIR}/mouse-aim.ini" "${PROJECT_SOURCE_DIR}/docs/PLAYING.md" "${PROJECT_SOURCE_DIR}/release/windows/Play Disruptor.cmd" - "${PROJECT_SOURCE_DIR}/keybinds-modern.ini" + "${PROJECT_SOURCE_DIR}/config/keybinds-modern.ini" "${PROJECT_SOURCE_DIR}/release/windows/settings.toml") if(BUILD_TESTING) - add_executable(disruptor-disc-import-test "${PROJECT_SOURCE_DIR}/tests/disc_import_test.cpp") + add_executable(disruptor-disc-import-test "${PROJECT_SOURCE_DIR}/tests/unit/disc_import_test.cpp") set_target_properties(disruptor-disc-import-test PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}") target_link_libraries(disruptor-disc-import-test PRIVATE disruptor-disc-import) @@ -81,18 +82,18 @@ if(BUILD_TESTING) endif() add_test(NAME disruptor_disc_import COMMAND disruptor-disc-import-test) - add_executable(disruptor-launcher-settings-test "${PROJECT_SOURCE_DIR}/tests/launcher_settings_test.cpp") + add_executable(disruptor-launcher-settings-test "${PROJECT_SOURCE_DIR}/tests/unit/launcher_settings_test.cpp") target_link_libraries(disruptor-launcher-settings-test PRIVATE disruptor-launcher-model) target_compile_definitions(disruptor-launcher-settings-test PRIVATE "DISRUPTOR_SOURCE_DIR=LR\"(${PROJECT_SOURCE_DIR})\"") add_test(NAME disruptor_launcher_settings COMMAND disruptor-launcher-settings-test) - add_executable(disruptor-launcher-flow-test "${PROJECT_SOURCE_DIR}/tests/launcher_flow_test.cpp") + add_executable(disruptor-launcher-flow-test "${PROJECT_SOURCE_DIR}/tests/unit/launcher_flow_test.cpp") target_link_libraries(disruptor-launcher-flow-test PRIVATE disruptor-launcher-model) add_test(NAME disruptor_launcher_flow COMMAND disruptor-launcher-flow-test) # The synthetic discs include the layout code whole, so this test takes region_disc.cpp and not the library. add_executable(disruptor-region-disc-test - "${PROJECT_SOURCE_DIR}/tests/region_disc_test.cpp" region_disc.cpp) + "${PROJECT_SOURCE_DIR}/tests/unit/region_disc_test.cpp" region_disc.cpp) target_include_directories(disruptor-region-disc-test PRIVATE "${PROJECT_SOURCE_DIR}/src") target_link_libraries(disruptor-region-disc-test PRIVATE disruptor-disc-import) add_test(NAME disruptor_region_disc COMMAND disruptor-region-disc-test) diff --git a/src/launcher/launcher_settings.cpp b/src/launcher/launcher_settings.cpp index cefd0fb..9b84723 100644 --- a/src/launcher/launcher_settings.cpp +++ b/src/launcher/launcher_settings.cpp @@ -1,207 +1,48 @@ #include "launcher_settings.h" -#include "disruptor_present_rate.h" +#include "game/frame/present_rate.h" #include #include -#include #include #include -#include #include namespace fs = std::filesystem; namespace disruptor::launcher { namespace { -using Saved = bool (*)(const Settings&); -using Get = int (*)(const Settings&); -using Set = void (*)(Settings&, int); - -template bool saved(const Settings& settings) { return settings.*Has; } -template int read(const Settings& settings) { return static_cast(settings.*Value); } -template void write(Settings& settings, int value) { - settings.*Has = true; - settings.*Value = static_cast>(value); -} - -constexpr int aspect_widths[] = {4, 16, 21, 32}; -constexpr int aspect_heights[] = {3, 9, 9, 9}; -constexpr int widest_aspect = 3, window_aspect = 4; - -bool aspect_saved(const Settings& settings) { return settings.has_aspect_ratio || settings.has_adaptive_view; } - -int aspect(const Settings& settings) { - if (settings.has_adaptive_view && settings.adaptive_view) return window_aspect; - for (int index = 0; index <= widest_aspect; ++index) - if (settings.aspect_num * aspect_heights[index] == settings.aspect_den * aspect_widths[index]) return index; - return -1; -} - -void set_aspect(Settings& settings, int index) { - const int fixed = index == window_aspect ? widest_aspect : index; - settings.has_aspect_ratio = settings.has_adaptive_view = true; - settings.adaptive_view = index == window_aspect; - settings.aspect_num = aspect_widths[fixed]; - settings.aspect_den = aspect_heights[fixed]; -} - -int scale(const Settings& settings) { return settings.supersampling - 1; } -void set_scale(Settings& settings, int index) { settings.has_supersampling = true; settings.supersampling = index + 1; } -int vsync(const Settings& settings) { return std::clamp(settings.vsync + 1, 0, 2); } -void set_vsync(Settings& settings, int index) { settings.has_vsync = true; settings.vsync = index - 1; } - -template int thousandths(const Settings& settings) { - return static_cast(std::lround(settings.*Value * 1000.0)); -} -template void set_thousandths(Settings& settings, int value) { - settings.*Has = true; - settings.*Value = value / 1000.0; -} - -constexpr const wchar_t* display_modes[] = {L"Windowed", L"Borderless fullscreen", L"Exclusive fullscreen"}; -constexpr const wchar_t* aspects[] = {L"4:3", L"16:9", L"21:9", L"32:9", L"Match window (up to 32:9)"}; -constexpr const wchar_t* scales[] = {L"1x (native)", L"2x", L"3x", L"4x", L"5x", L"6x", L"7x", L"8x"}; -constexpr const wchar_t* vsyncs[] = {L"Adaptive", L"Immediate", L"Synchronised"}; -constexpr const wchar_t* shadows[] = {L"Vanilla", L"Improved"}; constexpr const wchar_t* pages[] = {L"Display", L"Enhancements", L"Controls", L"Audio", L"Experimental"}; - -constexpr const char* geometry_key = "disruptor.geometry_correction"; -constexpr const char* frames_key = "video.frame_interpolation"; constexpr const char* rate_key = "video.frame_interpolation_fps"; -constexpr const char* occlusion_key = "disruptor.ambient_occlusion"; -constexpr const char* aim_key = "disruptor.mouse_aim"; - -template -constexpr Option toggle(const char* key, Page page, const wchar_t* label, int initial, const wchar_t* hint, - const char* needs = nullptr) { - return {key, page, Kind::toggle, label, saved, read, write, initial, 0, 1, {}, needs, - Unit::none, hint}; -} - -constexpr Option choice(const char* key, Page page, const wchar_t* label, int initial, const wchar_t* hint, - std::span choices, Saved saved, Get get, Set set) { - return {key, page, Kind::choice, label, saved, get, set, initial, 0, static_cast(choices.size()) - 1, - choices, nullptr, Unit::none, hint}; -} - -constexpr Option slider(const char* key, Page page, const wchar_t* label, int initial, const wchar_t* hint, - int lowest, int highest, Unit unit, Saved saved, Get get, Set set, - const char* needs = nullptr) { - return {key, page, Kind::slider, label, saved, get, set, initial, lowest, highest, {}, needs, unit, hint}; -} - -constexpr Option option_list[] = { - choice("video.fullscreen", Page::display, L"Display mode", 0, - L"A window, a borderless window the size of the screen, or exclusive fullscreen. Alt+Enter " - L"switches between a window and fullscreen while playing.", display_modes, - saved<&Settings::has_fullscreen>, read<&Settings::fullscreen>, - write<&Settings::has_fullscreen, &Settings::fullscreen>), - choice("video.aspect_ratio", Page::display, L"Aspect ratio", 0, - L"4:3 is the original picture. The wider choices show more of the world at the sides. Match " - L"window follows the window's shape, up to 32:9. Movies stay 4:3.", aspects, - aspect_saved, aspect, set_aspect), - choice("video.supersampling", Page::display, L"Internal resolution scale", 3, - L"Draws the picture at this multiple of the original resolution. Higher is sharper. What it " - L"costs the graphics card grows roughly with the square of the value.", scales, - saved<&Settings::has_supersampling>, scale, set_scale), - choice("video.vsync", Page::display, L"VSync", 2, - L"Synchronised waits for the display, so the picture never tears. Immediate does not wait. " - L"Adaptive waits only while the game keeps up with the display.", - vsyncs, saved<&Settings::has_vsync>, vsync, set_vsync), - slider("disruptor.hud_scale", Page::display, L"HUD size", 100, - L"Widescreen only. Shrinks the health and ammunition displays toward their screen edges. " - L"The weapon keeps its size.", 50, 100, Unit::percent, - saved<&Settings::has_hud_scale>, read<&Settings::hud_scale>, - write<&Settings::has_hud_scale, &Settings::hud_scale>), - - toggle<&Settings::has_geometry_correction, &Settings::geometry_correction>( - "disruptor.geometry_correction", Page::enhancements, L"Exact-provenance geometry", 1, - L"Places polygons from the game's own arithmetic before it is rounded to whole pixels, which " - L"steadies the PlayStation's wobbling edges. Other options need it."), - toggle<&Settings::has_perspective_textures, &Settings::perspective_textures>( - "disruptor.perspective_textures", Page::enhancements, L"Perspective-correct world textures", 1, - L"Draws wall and floor textures in true perspective. The PlayStation's own way bends them near " - L"the viewer.", geometry_key), - toggle<&Settings::has_gap_filling, &Settings::gap_filling>( - "disruptor.gap_filling", Page::enhancements, L"Gap filling", 1, - L"Closes hairline gaps between polygons and keeps the game's own patches off their neighbours.", geometry_key), - toggle<&Settings::has_depth_sorted_world, &Settings::depth_sorted_world>( - "disruptor.depth_sorted_world", Page::enhancements, L"Depth-sorted world", 0, - L"Draws world polygons by their depth where the game's own order puts a far one over a near " - L"one.", geometry_key), - choice("disruptor.improved_shadows", Page::enhancements, L"Shadows", 0, - L"Vanilla is the game's own shape. Improved lays a shadow flat on the floor: as wide as the " - L"game makes it and as tall as the floor's perspective gives. Improved needs exact " - L"geometry.", shadows, - saved<&Settings::has_improved_shadows>, read<&Settings::improved_shadows>, - write<&Settings::has_improved_shadows, &Settings::improved_shadows>), - toggle<&Settings::has_frame_unlock, &Settings::frame_unlock>( - "disruptor.frame_unlock", Page::enhancements, L"60 FPS gameplay", 0, - L"Lets the game run at up to 60 frames a second in place of its original rate."), - toggle<&Settings::has_frame_interpolation, &Settings::frame_interpolation>( - "video.frame_interpolation", Page::enhancements, L"In-between frames", 0, - L"Shows extra pictures between the game's frames, built from the game's own geometry, for " - L"smoother motion.", geometry_key), - slider("video.frame_interpolation_fps", Page::enhancements, L"In-between frame rate", 0, - L"Pictures shown per second while in-between frames are on.", - kDisruptorPresentRateLowest, kDisruptorPresentRateHighest, Unit::frames, - saved<&Settings::has_frame_interpolation_fps>, read<&Settings::frame_interpolation_fps>, - write<&Settings::has_frame_interpolation_fps, &Settings::frame_interpolation_fps>, frames_key), - toggle<&Settings::has_skip_intro, &Settings::skip_intro>( - "disruptor.skip_intro", Page::enhancements, L"Skip the logos and the title movie", 0, - L"Starts at the main menu without the logos and the title movie. Escape skips a logo or a " - L"movie either way."), - - toggle<&Settings::has_mouse_aim, &Settings::mouse_aim>( - "disruptor.mouse_aim", Page::controls, L"Horizontal mouse aim", 1, - L"Turns the view with the mouse. In the game a middle-click captures the mouse, and a middle- " - L"click or Escape releases it."), - toggle<&Settings::has_modern_controls, &Settings::modern_controls>( - "disruptor.modern_controls", Page::controls, L"Modern keyboard and mouse controls", 1, - L"WASD moves, the left mouse button fires, the right one uses psionics, Space jumps and E " - L"interacts."), - slider("disruptor.horizontal_sensitivity", Page::controls, L"Horizontal sensitivity", 80, - L"How far the view turns for a movement of the mouse.", 5, 2000, Unit::thousandths, - saved<&Settings::has_horizontal_sensitivity>, thousandths<&Settings::horizontal_sensitivity>, - set_thousandths<&Settings::has_horizontal_sensitivity, &Settings::horizontal_sensitivity>), - toggle<&Settings::has_invert_horizontal, &Settings::invert_horizontal>( - "disruptor.invert_horizontal", Page::controls, L"Invert horizontal mouse", 0, - L"Turns the view left when the mouse moves right."), - toggle<&Settings::has_high_precision_camera, &Settings::high_precision_camera>( - "disruptor.high_precision_camera", Page::controls, L"Smooth mouse turning", 0, - L"The game turns in 256 steps of a circle. With this on, the picture also turns by the part of " - L"a step in between. Needs exact geometry.", aim_key), - toggle<&Settings::has_vertical_look, &Settings::vertical_look>( - "disruptor.vertical_look", Page::controls, L"Vertical mouse look", 0, - L"Looks up and down with the mouse, to about 31 degrees each way. The original game has no " - L"such view. Experimental."), - slider("disruptor.vertical_sensitivity", Page::controls, L"Vertical sensitivity", 80, - L"How far the view tilts for a movement of the mouse.", 5, 2000, Unit::thousandths, - saved<&Settings::has_vertical_sensitivity>, thousandths<&Settings::vertical_sensitivity>, - set_thousandths<&Settings::has_vertical_sensitivity, &Settings::vertical_sensitivity>), - toggle<&Settings::has_invert_vertical, &Settings::invert_vertical>( - "disruptor.invert_vertical", Page::controls, L"Invert vertical mouse", 0, - L"Looks down when the mouse moves forward."), - - slider("audio.master_volume", Page::audio, L"Master volume", 100, - L"The loudness of everything the game plays.", 0, 100, Unit::percent, - saved<&Settings::has_master_volume>, read<&Settings::master_volume>, - write<&Settings::has_master_volume, &Settings::master_volume>), - toggle<&Settings::has_audio_muted, &Settings::audio_muted>("audio.muted", Page::audio, L"Mute all audio", 0, - L"Silences the game and keeps the volume setting."), - toggle<&Settings::has_ambient_occlusion, &Settings::ambient_occlusion>( - "disruptor.ambient_occlusion", Page::experimental, L"Ambient occlusion", 0, - L"Shades corners and the floor under things. Unfinished.", geometry_key), - slider("disruptor.occlusion_reach", Page::experimental, L"Occlusion reach", 160, - L"How far from a corner or an object the shade spreads.", 50, 800, Unit::none, - saved<&Settings::has_occlusion_reach>, read<&Settings::occlusion_reach>, - write<&Settings::has_occlusion_reach, &Settings::occlusion_reach>, occlusion_key), - slider("disruptor.occlusion_strength", Page::experimental, L"Occlusion strength", 80, - L"How dark the shade is.", 0, 300, Unit::percent, - saved<&Settings::has_occlusion_strength>, read<&Settings::occlusion_strength>, - write<&Settings::has_occlusion_strength, &Settings::occlusion_strength>, occlusion_key), +constexpr Placement placed[] = { + {"video.fullscreen", Page::display}, + {"video.aspect_ratio", Page::display}, + {"video.supersampling", Page::display}, + {"video.vsync", Page::display}, + {"disruptor.hud_scale", Page::display}, + {"disruptor.geometry_correction", Page::enhancements}, + {"disruptor.perspective_textures", Page::enhancements}, + {"disruptor.gap_filling", Page::enhancements}, + {"disruptor.depth_sorted_world", Page::enhancements}, + {"disruptor.improved_shadows", Page::enhancements}, + {"disruptor.frame_unlock", Page::enhancements}, + {"video.frame_interpolation", Page::enhancements}, + {"video.frame_interpolation_fps", Page::enhancements}, + {"disruptor.skip_intro", Page::enhancements}, + {"disruptor.mouse_aim", Page::controls}, + {"disruptor.modern_controls", Page::controls}, + {"disruptor.horizontal_sensitivity", Page::controls}, + {"disruptor.invert_horizontal", Page::controls}, + {"disruptor.high_precision_camera", Page::controls}, + {"disruptor.vertical_look", Page::controls}, + {"disruptor.vertical_sensitivity", Page::controls}, + {"disruptor.invert_vertical", Page::controls}, + {"audio.master_volume", Page::audio}, + {"audio.muted", Page::audio}, + {"disruptor.ambient_occlusion", Page::experimental}, + {"disruptor.occlusion_reach", Page::experimental}, + {"disruptor.occlusion_strength", Page::experimental}, }; constexpr PresetValue original_values[] = { @@ -226,14 +67,16 @@ bool listed(const std::vector& discs, const std::string& path) { } } // namespace -std::span options() { return option_list; } +std::span layout() { return placed; } std::span presets() { return preset_list; } std::span page_labels() { return pages; } -const Option* find_option(const char* key) { - for (const auto& option : option_list) - if (std::string_view(option.key) == key) return &option; - return nullptr; +std::wstring wide(std::string_view utf8_text) { + const int bytes = static_cast(utf8_text.size()); + const int size = MultiByteToWideChar(CP_UTF8, 0, utf8_text.data(), bytes, nullptr, 0); + std::wstring text(static_cast(std::max(size, 0)), L'\0'); + if (size > 0) MultiByteToWideChar(CP_UTF8, 0, utf8_text.data(), bytes, text.data(), size); + return text; } std::string utf8(const fs::path& path) { @@ -278,15 +121,15 @@ int SettingsFile::shown(const Option& option) const { } bool SettingsFile::applies(const Option& option) const { - const Option* needed = option.needs ? find_option(option.needs) : nullptr; - return !needed || (shown(*needed) != 0 && applies(*needed)); + return settings::applies(option, [this](const Option& needed) { return shown(needed) != 0; }); } void SettingsFile::put(Settings& settings, const Option& option, int value) { option.set(settings, value); if (option.kind != Kind::toggle || value != 0) return; - for (const auto& other : option_list) { - if (other.kind == Kind::toggle && other.needs && std::string_view(other.needs) == option.key && + const auto named = [&](const char* key) { return std::string_view(key) == option.key; }; + for (const auto& other : options()) { + if (other.kind == Kind::toggle && std::any_of(other.needs.begin(), other.needs.end(), named) && stored(settings, other) != 0) put(settings, other, 0); } @@ -314,7 +157,7 @@ bool SettingsFile::apply(const Preset& preset) { bool SettingsFile::reset() { return change([](Settings& settings) { - for (const auto& option : option_list) option.set(settings, option.initial); + for (const auto& option : options()) option.set(settings, option.initial); }); } @@ -326,9 +169,9 @@ bool shipped(const Option& option) { bool SettingsFile::complete() { if (!load()) return false; const auto whole = [this](const Option& option) { return !shipped(option) || option.saved(settings_); }; - if (std::all_of(std::begin(option_list), std::end(option_list), whole)) return true; + if (std::all_of(options().begin(), options().end(), whole)) return true; return change([](Settings& settings) { - for (const auto& option : option_list) + for (const auto& option : options()) if (shipped(option) && !option.saved(settings)) option.set(settings, option.initial); }); } diff --git a/src/launcher/launcher_settings.h b/src/launcher/launcher_settings.h index dc7da7d..4b29f9f 100644 --- a/src/launcher/launcher_settings.h +++ b/src/launcher/launcher_settings.h @@ -1,48 +1,37 @@ #pragma once -#include "config_loader.h" +#include "game/settings/options.h" #include #include #include +#include #include namespace disruptor::launcher { -using Settings = PSXRecompV4::UserSettings; +using settings::find_option; +using settings::Kind; +using settings::Option; +using settings::options; +using settings::Settings; +using settings::Unit; enum class Page { display, enhancements, controls, audio, experimental }; -enum class Kind { toggle, choice, slider }; -enum class Unit { none, percent, frames, thousandths }; - -// A value is 0 or 1 for a toggle, an index into `choices` for a choice and a whole number of `unit` for a slider. -struct Option { - const char* key; // "section.key" in settings.toml - Page page; - Kind kind; - const wchar_t* label; - bool (*saved)(const Settings&); - int (*get)(const Settings&); - void (*set)(Settings&, int); - int initial; // what a fresh installation runs with - int lowest; - int highest; - std::span choices; - const char* needs; // the toggle that must be on for this row to apply - Unit unit; - const wchar_t* hint; // shown when the pointer rests on the row -}; +// A row of the settings pages. The rows of a page are shown in the order they are listed. +struct Placement { const char* key; Page page; }; struct PresetValue { const char* key; int value; }; struct Preset { const wchar_t* label; std::span values; }; -std::span options(); +std::span layout(); std::span presets(); -const Option* find_option(const char* key); // Whether a release's settings.toml names the row: for these the game's own value is not the one a release runs with. bool shipped(const Option& option); std::span page_labels(); +// Bytes that are not UTF-8 come out as U+FFFD. +std::wstring wide(std::string_view utf8_text); std::string utf8(const std::filesystem::path& path); std::filesystem::path from_utf8(const std::string& text); // The PSX_ variables of this process, which the game it starts lets replace settings for that run. diff --git a/src/launcher/main.cpp b/src/launcher/main.cpp index 83e76bb..0ab83b7 100644 --- a/src/launcher/main.cpp +++ b/src/launcher/main.cpp @@ -114,7 +114,13 @@ std::optional pick_image(HWND owner, const wchar_t* title) { struct Update { std::wstring status; unsigned percent; }; struct Placed { HWND window; int x, y, width, height; bool heading; }; -struct Row { const Option* option; int order = 0; HWND label = nullptr, control = nullptr, value = nullptr; }; +struct Row { + const Option* option; + Page page; + int order = 0; + std::wstring hint; + HWND label = nullptr, control = nullptr, value = nullptr; +}; struct App { fs::path root; @@ -273,10 +279,10 @@ struct App { } const bool on = editable() && settings.applies(option); const int slot = row.order - first; - const bool here = static_cast(option.page) == page && slot >= 0 && slot < page_slots; + const bool here = static_cast(row.page) == page && slot >= 0 && slot < page_slots; if (here) put_row(row, slot); const int shown = here ? SW_SHOW : SW_HIDE; - if (here) tip_of(slot, option.hint); + if (here) tip_of(slot, row.hint.c_str()); for (HWND part : {row.label, row.control, row.value}) { if (!part) continue; EnableWindow(part, on); @@ -413,20 +419,22 @@ void create_controls(App& app) { } std::vector rows_on_page(page_labels().size(), 0); index = 0; - for (const auto& option : options()) { - const int order = rows_on_page[static_cast(option.page)]++, y = 38 + 28 * std::min(order, page_slots - 1); + for (const auto& placement : layout()) { + const Option& option = *find_option(placement.key); + const int order = rows_on_page[static_cast(placement.page)]++, y = 38 + 28 * std::min(order, page_slots - 1); + const std::wstring label = wide(option.label); const int id = option_base + index++; - Row row{&option, order}; + Row row{&option, placement.page, order, wide(option.hint)}; if (option.kind == Kind::toggle) { - row.control = app.add(app.tabs, L"BUTTON", option.label, BS_AUTOCHECKBOX | WS_TABSTOP, 0, + row.control = app.add(app.tabs, L"BUTTON", label.c_str(), BS_AUTOCHECKBOX | WS_TABSTOP, 0, 18, y, 560, 24, id); } else { - row.label = app.add(app.tabs, L"STATIC", option.label, SS_LEFT, 0, 18, y + 4, 250, 20); + row.label = app.add(app.tabs, L"STATIC", label.c_str(), SS_LEFT, 0, 18, y + 4, 250, 20); if (option.kind == Kind::choice) { row.control = app.add(app.tabs, WC_COMBOBOXW, L"", CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP, 0, 280, y, 250, 240, id); - for (const wchar_t* label : option.choices) - SendMessageW(row.control, CB_ADDSTRING, 0, reinterpret_cast(label)); + for (const char* choice : option.choices) + SendMessageW(row.control, CB_ADDSTRING, 0, reinterpret_cast(wide(choice).c_str())); } else { row.control = app.add(app.tabs, TRACKBAR_CLASSW, L"", TBS_HORZ | TBS_NOTICKS | WS_TABSTOP, 0, 274, y, 256, 26, id); @@ -443,7 +451,7 @@ void create_controls(App& app) { for (const auto& row : app.rows) { TTTOOLINFOW tool{sizeof(tool), TTF_IDISHWND | TTF_SUBCLASS, app.tabs}; tool.uId = reinterpret_cast(row.control); - tool.lpszText = const_cast(row.option->hint); + tool.lpszText = const_cast(row.hint.c_str()); SendMessageW(app.tips, TTM_ADDTOOLW, 0, reinterpret_cast(&tool)); } for (int slot = 0; slot < page_slots; ++slot) { diff --git a/src/launcher/region_disc.cpp b/src/launcher/region_disc.cpp index a28a567..2d21910 100644 --- a/src/launcher/region_disc.cpp +++ b/src/launcher/region_disc.cpp @@ -1,7 +1,7 @@ #include "region_disc.h" #include "disc_import.h" -#include "disruptor_language_disc.h" +#include "game/language/language_disc.h" #include #include diff --git a/src/launcher/startup_log.cpp b/src/launcher/startup_log.cpp index e4851ae..9ff3956 100644 --- a/src/launcher/startup_log.cpp +++ b/src/launcher/startup_log.cpp @@ -1,6 +1,6 @@ #include "startup_log.h" -#include +#include "launcher_settings.h" #include #include @@ -10,14 +10,6 @@ namespace fs = std::filesystem; namespace disruptor::launcher { namespace { constexpr std::streamoff most_bytes = 16384; - -// Bytes that are not UTF-8 come out as U+FFFD: a log in another encoding is still shown. -std::wstring decode(const std::string& text) { - const int size = MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast(text.size()), nullptr, 0); - std::wstring result(static_cast(std::max(size, 0)), L'\0'); - if (size > 0) MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast(text.size()), result.data(), size); - return result; -} } // namespace std::wstring log_tail(const fs::path& log, size_t lines) { @@ -43,7 +35,7 @@ std::wstring log_tail(const fs::path& log, size_t lines) { } std::string joined; for (auto line = kept.rbegin(); line != kept.rend(); ++line) joined += *line + "\r\n"; - return decode(joined); + return wide(joined); } } // namespace disruptor::launcher diff --git a/tests/test_ambient_occlusion_contract.py b/tests/contracts/test_ambient_occlusion_contract.py similarity index 62% rename from tests/test_ambient_occlusion_contract.py rename to tests/contracts/test_ambient_occlusion_contract.py index c5374f7..3710a88 100644 --- a/tests/test_ambient_occlusion_contract.py +++ b/tests/contracts/test_ambient_occlusion_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -22,12 +22,24 @@ def between(source: str, opening: str, closing: str) -> str: cmake = read("CMakeLists.txt") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") -menu = read("src/disruptor_dev_menu.cpp") +menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") +options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") settings = read("psxrecomp-overlay/recompiler/src/config_loader.h") -require(" add_test(NAME disruptor_ambient_occlusion_contract\n" in cmake, "this contract is registered") +require(" disruptor_contract(ambient_occlusion)\n" in cmake, "this contract is registered") require( '" v_deep = abs(a_camz) > 16.0 ? 1.0 : -1000.0;\\n"' in renderer @@ -67,57 +79,51 @@ def between(source: str, opening: str, closing: str) -> str: "or a strength that is not one changes nothing", ) -experimental = between(menu, "void draw_experimental_tab() {", "\n}\n") +experimental = between(menu, "void draw_experimental_tab() {", "\n}\n") + "\n" require( - """ const bool geometry = gpu_geometry_correction_enabled() != 0; - bool occlusion = gl_renderer_ssao() != 0; - if (!geometry) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Ambient occlusion", &occlusion)) { + """ bool occlusion = gl_renderer_ssao() != 0; + const bool occlusion_applies = applies("disruptor.ambient_occlusion"); + if (!occlusion_applies) ImGui::BeginDisabled(); + if (option_toggle("disruptor.ambient_occlusion", &occlusion)) { gl_renderer_set_ssao(occlusion ? 1 : 0); mark_occlusion(occlusion); } - if (!geometry) ImGui::EndDisabled(); + if (!occlusion_applies) ImGui::EndDisabled(); """ in experimental - and """ const bool reach_moved = ImGui::SliderInt("Occlusion reach", &reach, 50, 800, "%d", ImGuiSliderFlags_AlwaysClamp); + and """ const bool shape_applies = applies("disruptor.occlusion_reach") && + applies("disruptor.occlusion_strength"); + if (!shape_applies) ImGui::BeginDisabled(); + ImGui::SetNextItemWidth(260.0f); + const bool reach_moved = option_slider("disruptor.occlusion_reach", &reach); """ in experimental - and """ ImGui::SliderInt("Occlusion strength", &strength, 0, 300, "%d%%", ImGuiSliderFlags_AlwaysClamp); - if (!geometry) ImGui::EndDisabled(); + and """ option_slider("disruptor.occlusion_strength", &strength); + if (!shape_applies) ImGui::EndDisabled(); if (reach_moved || strength_moved) { gl_renderer_set_ssao_shape(static_cast(reach), strength / 100.0f); mark_occlusion_shape(reach, strength); } """ in experimental - and menu.count('"Ambient occlusion"') == 1 + and menu.count('option_toggle("disruptor.ambient_occlusion"') == 1 and experimental.count("ImGui::BeginDisabled()") == experimental.count("ImGui::EndDisabled()") == 2 - and experimental.count("ImGui::Checkbox(") == 1 + and experimental.count("option_toggle(") == 1 + and "ImGui::Checkbox(" not in experimental and ' if (ImGui::BeginTabItem("Experimental")) {\n draw_experimental_tab();\n' in menu, - "the Experimental tab holds the occlusion alone: a switch and two bounded sliders, greyed without geometry correction", + "the Experimental tab holds the occlusion alone: a switch greyed without geometry correction and two bounded sliders greyed without the switch", ) require( - """void mark_occlusion(bool value) { - g_preferences.pending.has_ambient_occlusion = true; - g_preferences.pending.ambient_occlusion = value; - g_preferences.dirty |= PREF_OCCLUSION; -} + """void mark_occlusion(bool value) { mark("disruptor.ambient_occlusion", value); } void mark_occlusion_shape(int reach, int strength) { - g_preferences.pending.has_occlusion_reach = g_preferences.pending.has_occlusion_strength = true; - g_preferences.pending.occlusion_reach = reach; - g_preferences.pending.occlusion_strength = strength; - g_preferences.dirty |= PREF_OCCLUSION_SHAPE; + mark("disruptor.occlusion_reach", reach); + mark("disruptor.occlusion_strength", strength); } """ in menu - and """ if (g_preferences.dirty & PREF_OCCLUSION) { - settings.has_ambient_occlusion = true; - settings.ambient_occlusion = pending.ambient_occlusion; - } - if (g_preferences.dirty & PREF_OCCLUSION_SHAPE) { - settings.has_occlusion_reach = settings.has_occlusion_strength = true; - settings.occlusion_reach = pending.occlusion_reach; - settings.occlusion_strength = pending.occlusion_strength; - } -""" in menu + and STAGED in menu + and MERGED in menu + and ' toggle<&Settings::has_ambient_occlusion, &Settings::ambient_occlusion>(\n "disruptor.ambient_occlusion",' in options + and "write<&Settings::has_occlusion_reach, &Settings::occlusion_reach>, needs_occlusion)" in options + and "write<&Settings::has_occlusion_strength, &Settings::occlusion_strength>, needs_occlusion)" in options and """ if (!env_override_present("PSX_GL_SSAO")) { if (settings.has_ambient_occlusion) gl_renderer_set_ssao(settings.ambient_occlusion ? 1 : 0); """ in menu @@ -127,12 +133,13 @@ def between(source: str, opening: str, closing: str) -> str: settings.has_occlusion_strength && !env_override_present("PSX_GL_SSAO_STRENGTH") ? settings.occlusion_strength / 100.0f : -1.0f); """ in menu - and """ if (g_preferences.dirty & PREF_OCCLUSION) + and """ if (changed("disruptor.ambient_occlusion")) gl_renderer_set_ssao(pending.ambient_occlusion ? 1 : 0); - if (g_preferences.dirty & PREF_OCCLUSION_SHAPE) + if (changed("disruptor.occlusion_reach") || + changed("disruptor.occlusion_strength")) gl_renderer_set_ssao_shape(static_cast(pending.occlusion_reach), pending.occlusion_strength / 100.0f); """ in menu, - "the switch and the shape are each staged under a bit of their own, merged into the file's settings, applied at the " + "the switch and the shape are each staged as options of the shared table, merged into the file's settings, applied at the " "next start unless the environment decides that one, and applied when another run saved them", ) @@ -155,16 +162,20 @@ def between(source: str, opening: str, closing: str) -> str: "the settings file keeps the switch, off until made, and a reach and a strength only inside the sliders' bounds", ) -switch = between(launcher, '"disruptor.ambient_occlusion", Page::experimental, L"Ambient occlusion", 0,', "),\n") -reach = between(launcher, 'slider("disruptor.occlusion_reach", Page::experimental, L"Occlusion reach", 160,', "),\n") -strength = between(launcher, 'slider("disruptor.occlusion_strength", Page::experimental, L"Occlusion strength", 80,', "),\n") +switch = between(options, '"disruptor.ambient_occlusion", "Ambient occlusion", 0,', "),\n") +reach = between(options, 'slider("disruptor.occlusion_reach", "Occlusion reach", 160,', "),\n") +strength = between(options, 'slider("disruptor.occlusion_strength", "Occlusion strength", 80,', "),\n") require( - switch.endswith("geometry_key") + switch.endswith("needs_geometry") and "50, 800, Unit::none," in reach - and reach.endswith("occlusion_key") + and reach.endswith("needs_occlusion") and "0, 300, Unit::percent," in strength - and strength.endswith("occlusion_key") - and 'constexpr const char* occlusion_key = "disruptor.ambient_occlusion";\n' in launcher + and strength.endswith("needs_occlusion") + and "constexpr const char* needs_occlusion[] = {occlusion_key};\n" in options + and 'constexpr const char* occlusion_key = "disruptor.ambient_occlusion";\n' in options + and ' {"disruptor.ambient_occlusion", Page::experimental},\n' + ' {"disruptor.occlusion_reach", Page::experimental},\n' + ' {"disruptor.occlusion_strength", Page::experimental},\n' in launcher and launcher.count("Page::experimental") == 3, "the launcher's Experimental page holds these three rows: the switch needs geometry correction, the sliders the switch, " "with the menu's own bounds", diff --git a/tests/test_backdrop_tiles_contract.py b/tests/contracts/test_backdrop_tiles_contract.py similarity index 94% rename from tests/test_backdrop_tiles_contract.py rename to tests/contracts/test_backdrop_tiles_contract.py index b60ad84..a011091 100644 --- a/tests/test_backdrop_tiles_contract.py +++ b/tests/contracts/test_backdrop_tiles_contract.py @@ -6,7 +6,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 BACKDROP_BEGIN = 0x8003AF68 @@ -53,7 +53,7 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_backdrop_tiles.cpp") +source = read("src/game/render/backdrop_tiles.cpp") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") gpu_header = read("psxrecomp-overlay/runtime/include/gpu.h") codegen = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") @@ -61,13 +61,13 @@ def require(condition: bool, message: str) -> None: audit = read("tools/audit_codegen.py") require( - "src/disruptor_backdrop_tiles.cpp" in cmake - and "disruptor_backdrop_tiles_contract" in cmake - and "add_test(NAME disruptor_backdrop_tiles\n" in cmake, + "src/game/render/backdrop_tiles.cpp" in cmake + and " disruptor_contract(backdrop_tiles RETAIL)\n" in cmake + and " disruptor_unit_test(disruptor_backdrop_tiles " in cmake, "backdrop tile source, its unit test and its contract test must remain registered", ) require( - 'tests/test_backdrop_tiles_contract.py"\n --require-artifacts)' in cmake, + " disruptor_contract(backdrop_tiles RETAIL)\n" in cmake, "the build gate must run this contract with its inputs required", ) require( diff --git a/tests/test_billboard_aspect_contract.py b/tests/contracts/test_billboard_aspect_contract.py similarity index 94% rename from tests/test_billboard_aspect_contract.py rename to tests/contracts/test_billboard_aspect_contract.py index 7d37aee..bfe1997 100644 --- a/tests/test_billboard_aspect_contract.py +++ b/tests/contracts/test_billboard_aspect_contract.py @@ -6,7 +6,7 @@ import sys -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -19,8 +19,8 @@ def require(condition: bool, message: str) -> None: raise SystemExit(1) -source = read("src/disruptor_billboard_aspect.cpp") -header = read("src/disruptor_billboard_aspect.h") +source = read("src/game/render/billboard_aspect.cpp") +header = read("src/game/render/billboard_aspect.h") codegen = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") dirty = read("psxrecomp-overlay/runtime/src/dirty_ram_interp.c") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") @@ -69,8 +69,8 @@ def require(condition: bool, message: str) -> None: "disruptor_billboard_aspect_instruction_hook(cpu, pc, insn, 1)" in dirty, "dirty-interpreter parity must be project-gated and post-instruction") require("PSX_HAS_DISRUPTOR_BILLBOARD_ASPECT=1" in cmake and - "src/disruptor_billboard_aspect.cpp" in cmake and - "tests/disruptor_billboard_aspect_test.cpp" in cmake, + "src/game/render/billboard_aspect.cpp" in cmake and + "tests/unit/disruptor_billboard_aspect_test.cpp" in cmake, "runtime source, dirty seam, and focused behavior test must be enabled") require("gpu_ws_tag_primitive" in gpu_h and diff --git a/tests/test_borderless_contract.py b/tests/contracts/test_borderless_contract.py similarity index 92% rename from tests/test_borderless_contract.py rename to tests/contracts/test_borderless_contract.py index 0be89d4..e2202fa 100644 --- a/tests/test_borderless_contract.py +++ b/tests/contracts/test_borderless_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -60,8 +60,9 @@ def require(condition: bool, message: str) -> None: ) require( "+runtime/include/host_borderless.h" in read("PSXRECOMP_OVERLAY_FILES.txt").splitlines() - and " add_test(NAME host_borderless\n COMMAND host-borderless-test)\n" in cmake - and 'tests/test_borderless_contract.py")' in cmake, + and " disruptor_unit_test(host_borderless " in cmake + and " add_test(NAME host_borderless_contract\n" in cmake + and '/tests/contracts/test_borderless_contract.py")' in cmake, "the header is part of the overlay and its tests remain registered", ) diff --git a/tests/test_capsule_contract.py b/tests/contracts/test_capsule_contract.py similarity index 93% rename from tests/test_capsule_contract.py rename to tests/contracts/test_capsule_contract.py index 696a2d6..63e2b09 100644 --- a/tests/test_capsule_contract.py +++ b/tests/contracts/test_capsule_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -16,9 +16,9 @@ def require(condition: bool, message: str) -> None: host = read("psxrecomp-overlay/runtime/src/main.cpp") -capsule = read("src/disruptor_capsule.cpp") -mouse = read("src/disruptor_mouse_aim.cpp") -menu = read("src/disruptor_dev_menu.cpp") +capsule = read("src/game/session/capsule.cpp") +mouse = read("src/game/input/mouse_aim.cpp") +menu = read("src/game/ui/dev_menu.cpp") cmake = read("CMakeLists.txt") require( @@ -128,12 +128,12 @@ def require(condition: bool, message: str) -> None: "Ctrl+F12 must start and end a recording and must not reach the save state keys", ) require( - ' "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_capsule.cpp"\n' in cmake + ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/capsule.cpp"\n' in cmake and " PSX_HAS_DISRUPTOR_CAPSULE=1\n" in cmake - and '#define DISRUPTOR_CAPSULE_NO_HOST 1\n#include "../src/disruptor_capsule.cpp"\n' - in read("tests/disruptor_capsule_test.cpp") - and " add_test(NAME disruptor_capsule\n COMMAND disruptor-capsule-test)\n" in cmake - and 'tests/test_capsule_contract.py")' in cmake, + and '#define DISRUPTOR_CAPSULE_NO_HOST 1\n#include "../../src/game/session/capsule.cpp"\n' + in read("tests/unit/disruptor_capsule_test.cpp") + and " disruptor_unit_test(disruptor_capsule " in cmake + and " disruptor_contract(capsule)\n" in cmake, "the capsule and its tests must remain built and registered", ) require( diff --git a/tests/test_cheats_contract.py b/tests/contracts/test_cheats_contract.py similarity index 95% rename from tests/test_cheats_contract.py rename to tests/contracts/test_cheats_contract.py index da5c441..7723aff 100644 --- a/tests/test_cheats_contract.py +++ b/tests/contracts/test_cheats_contract.py @@ -8,7 +8,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -23,13 +23,13 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_cheats.cpp") -header = read("src/disruptor_cheats.h") +source = read("src/game/session/cheats.cpp") +header = read("src/game/session/cheats.h") game = read("game.toml") wide = read("game-widescreen.toml") require("disruptor_cheats_test.cpp" in cmake and - "add_test(NAME disruptor_cheats" in cmake, + " disruptor_unit_test(disruptor_cheats " in cmake, "behavioral cheat test must remain registered") for config, name in ((game, "game.toml"), (wide, "game-widescreen.toml")): entry_match = re.search( diff --git a/tests/test_debug_tab_contract.py b/tests/contracts/test_debug_tab_contract.py similarity index 92% rename from tests/test_debug_tab_contract.py rename to tests/contracts/test_debug_tab_contract.py index 4f138a0..060c45e 100644 --- a/tests/test_debug_tab_contract.py +++ b/tests/contracts/test_debug_tab_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -22,11 +22,11 @@ def between(source: str, opening: str, closing: str) -> str: cmake = read("CMakeLists.txt") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") -menu = read("src/disruptor_dev_menu.cpp") +menu = read("src/game/ui/dev_menu.cpp") loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") settings = read("psxrecomp-overlay/recompiler/src/config_loader.h") -require(" add_test(NAME disruptor_debug_tab_contract\n" in cmake, "this contract is registered") +require(" disruptor_contract(debug_tab)\n" in cmake, "this contract is registered") debug = between(menu, "void draw_debug_tab() {", "void draw_cheats_tab() {") require( @@ -61,10 +61,10 @@ def between(source: str, opening: str, closing: str) -> str: """void mark_diagnostic_view(int value) { g_preferences.pending.has_diagnostic_view = true; g_preferences.pending.diagnostic_view = value; - g_preferences.dirty |= PREF_DIAGNOSTIC_VIEW; + g_preferences.diagnostic_view_changed = true; } """ in menu - and """ if (g_preferences.dirty & PREF_DIAGNOSTIC_VIEW) { + and """ if (g_preferences.diagnostic_view_changed) { settings.has_diagnostic_view = true; settings.diagnostic_view = pending.diagnostic_view; } @@ -72,7 +72,7 @@ def between(source: str, opening: str, closing: str) -> str: and """ apply_diagnostic_view(settings.has_diagnostic_view && env_override_present("PSX_DISRUPTOR_CAPSULE_REPLAY") ? settings.diagnostic_view : 0); """ in menu - and """ if (g_preferences.dirty & PREF_DIAGNOSTIC_VIEW) + and """ if (g_preferences.diagnostic_view_changed) apply_diagnostic_view(pending.diagnostic_view); """ in menu, "a view is written to the file so that a capsule carries it, and at a start only a capsule's replay takes it from there", @@ -88,7 +88,7 @@ def between(source: str, opening: str, closing: str) -> str: and """ if (s.has_diagnostic_view && s.diagnostic_view >= 0 && s.diagnostic_view < kDisruptorDiagnosticViewCount) f << "diagnostic_view = " << quoted(kDisruptorDiagnosticViews[s.diagnostic_view]) << "\\n"; """ in loader - and '"disruptor.diagnostic_view",' in read("tests/test_launcher_contract.py"), + and '"disruptor.diagnostic_view",' in read("tests/contracts/test_launcher_contract.py"), "the file names a view by its word, a word it does not know is no view, and the launcher is decided not to offer it", ) diff --git a/tests/test_depth24_sync_contract.py b/tests/contracts/test_depth24_sync_contract.py similarity index 92% rename from tests/test_depth24_sync_contract.py rename to tests/contracts/test_depth24_sync_contract.py index 3f40ac4..69d0d20 100644 --- a/tests/test_depth24_sync_contract.py +++ b/tests/contracts/test_depth24_sync_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -38,10 +38,7 @@ def enclosing(source: str, block: str) -> list[str]: gpu = read("psxrecomp-overlay/runtime/src/gpu.c") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") -REGISTRATION = ( - ' add_test(NAME disruptor_depth24_sync_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' - ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_depth24_sync_contract.py")\n' -) +REGISTRATION = " disruptor_contract(depth24_sync)\n" require( cmake.count(REGISTRATION) == 1 and enclosing(cmake, REGISTRATION) == ["if(BUILD_TESTING)"], "this contract must stay registered as written, under the condition every contract of the repository is under", diff --git a/tests/test_dev_menu_contract.py b/tests/contracts/test_dev_menu_contract.py similarity index 68% rename from tests/test_dev_menu_contract.py rename to tests/contracts/test_dev_menu_contract.py index 92d22df..49a3a9a 100644 --- a/tests/test_dev_menu_contract.py +++ b/tests/contracts/test_dev_menu_contract.py @@ -7,7 +7,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -58,11 +58,23 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: main_cpp = read("psxrecomp-overlay/runtime/src/main.cpp") gl = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") gte = read("psxrecomp-overlay/runtime/src/gte.cpp") -menu = read("src/disruptor_dev_menu.cpp") -mouse = read("src/disruptor_mouse_aim.cpp") -cheats = read("src/disruptor_cheats.cpp") -cheats_h = read("src/disruptor_cheats.h") -far_rendering_h = read("src/disruptor_far_rendering.h") +menu = read("src/game/ui/dev_menu.cpp") +options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) +mouse = read("src/game/input/mouse_aim.cpp") +cheats = read("src/game/session/cheats.cpp") +cheats_h = read("src/game/session/cheats.h") +far_rendering_h = read("src/game/render/far_rendering.h") config_h = read("psxrecomp-overlay/recompiler/src/config_loader.h") config_cpp = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") run_ps1 = read("run.ps1") @@ -76,11 +88,11 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: "5b17c01f69545bde732b14936d89ce0f508adb83e8b56fa82448371845172bc3" in cmake.lower(), "Dear ImGui source must remain versioned and integrity-pinned") -require("src/disruptor_dev_menu.cpp" in cmake and +require("src/game/ui/dev_menu.cpp" in cmake and "target_link_libraries(psx-runtime PRIVATE disruptor-imgui)" in cmake, "menu implementation must be linked only into the game runtime") -require("src/disruptor_cheats.cpp" in cmake and - "tests/disruptor_cheats_test.cpp" in cmake, +require("src/game/session/cheats.cpp" in cmake and + "tests/unit/disruptor_cheats_test.cpp" in cmake, "version-pinned cheats and their behavioral test must be built") require("+runtime/include/host_ui.h" in manifest, "new host UI ABI header must remain reviewed in the overlay manifest") @@ -450,9 +462,9 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: function_body(menu, "apply_saved_preferences"), "saved or environment-enabled modern controls must install conflict-free binds") for token in ( - "Vertical mouse look", - "Vertical sensitivity", - "Invert vertical mouse", + 'option_toggle("disruptor.vertical_look", &vertical_look)', + 'option_slider("disruptor.vertical_sensitivity", &vertical_sensitivity)', + 'option_toggle("disruptor.invert_vertical", &invert_y)', "Recenter vertical view", "disruptor_mouse_set_vertical_look_enabled", "disruptor_mouse_set_vertical_sensitivity", @@ -516,14 +528,12 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: aspect_controls = function_body(menu, "draw_aspect_controls") for token in ( 'Checkbox("Widescreen"', - 'Combo("Aspect ratio"', - '"16:9", "21:9", "32:9"', + 'option_list("video.aspect_ratio", &selected, 1)', "psx_host_video_get_display_aspect", "psx_host_video_set_display_aspect", "psx_host_video_get_adaptive_view", "psx_host_video_set_adaptive_view", "mark_aspect", - '"Match window (up to 32:9)"', 'draw_status_badge("LIVE"', ): require(token in aspect_controls, @@ -532,8 +542,7 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: scale_controls = function_body(menu, "draw_internal_resolution_controls") for token in ( 'SeparatorText("Rendering resolution")', - 'Combo("Internal resolution scale"', - '"1x (native)", "2x", "3x", "4x", "5x", "6x", "7x", "8x"', + 'option_list("video.supersampling", &selected)', "kScaleCount = 8", "psx_host_video_get_internal_scale", "psx_host_video_set_internal_scale", @@ -542,12 +551,13 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: ): require(token in scale_controls, f"live internal-resolution controls are missing {token}") -require("PREF_SUPERSAMPLING" in - function_body(menu, "merge_dirty_preferences") and - "settings.has_supersampling = true" in - function_body(menu, "merge_dirty_preferences"), +require('void mark_supersampling(int value) { mark("video.supersampling", value - 1); }\n' in menu and + MERGED in function_body(menu, "merge_dirty_preferences") and + "void set_scale(Settings& settings, int index) { settings.has_supersampling = true; " + "settings.supersampling = index + 1; }\n" in options and + "saved<&Settings::has_supersampling>, scale, set_scale)" in options, "internal scale must be merged into the user settings") -require("PREF_SUPERSAMPLING" in +require('changed("video.supersampling")' in function_body(menu, "apply_pending_preferences") and "psx_host_video_set_internal_scale" in function_body(menu, "apply_pending_preferences"), @@ -558,8 +568,7 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: fullscreen_controls = function_body(menu, "draw_fullscreen_controls") for token in ( 'SeparatorText("Window presentation")', - 'Combo("Display mode"', - '"Windowed", "Borderless fullscreen", "Exclusive fullscreen"', + 'option_list("video.fullscreen", &mode)', "psx_host_video_get_fullscreen_mode", "psx_host_video_set_fullscreen_mode", "mark_fullscreen", @@ -571,14 +580,11 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: ): require(token in fullscreen_controls, f"live fullscreen controls are missing {token}") -require("PREF_FULLSCREEN" in - function_body(menu, "merge_dirty_preferences") and - "settings.has_fullscreen = true" in - function_body(menu, "merge_dirty_preferences") and - "settings.fullscreen = pending.fullscreen" in - function_body(menu, "merge_dirty_preferences"), +require('void mark_fullscreen(int value) { mark("video.fullscreen", value); }\n' in menu and + MERGED in function_body(menu, "merge_dirty_preferences") and + "write<&Settings::has_fullscreen, &Settings::fullscreen>)" in options, "explicit fullscreen selections must be merged into user settings") -require("PREF_FULLSCREEN" in +require('changed("video.fullscreen")' in function_body(menu, "apply_pending_preferences") and "psx_host_video_set_fullscreen_mode" in function_body(menu, "apply_pending_preferences"), @@ -594,8 +600,8 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: audio_controls = function_body(menu, "draw_audio_controls") for token in ( 'SeparatorText("Audio output")', - 'SliderInt("Master volume"', - 'Checkbox("Mute all audio"', + 'option_slider("audio.master_volume", &volume)', + 'option_toggle("audio.muted", &muted)', "psx_host_audio_get_master_volume", "psx_host_audio_set_master_volume", "psx_host_audio_get_muted", @@ -608,17 +614,16 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: require(token in audio_controls, f"live host-audio controls are missing {token}") merge_preferences = function_body(menu, "merge_dirty_preferences") -require("PREF_MASTER_VOLUME" in merge_preferences and - "settings.has_master_volume = true" in merge_preferences and - "settings.master_volume = pending.master_volume" in merge_preferences and - "PREF_AUDIO_MUTED" in merge_preferences and - "settings.has_audio_muted = true" in merge_preferences and - "settings.audio_muted = pending.audio_muted" in merge_preferences, +require('void mark_master_volume(int value) { mark("audio.master_volume", value); }\n' in menu and + 'void mark_audio_muted(bool value) { mark("audio.muted", value); }\n' in menu and + MERGED in merge_preferences and + "write<&Settings::has_master_volume, &Settings::master_volume>)" in options and + ' toggle<&Settings::has_audio_muted, &Settings::audio_muted>("audio.muted",' in options, "host-audio selections must be merged into user settings") pending_preferences = function_body(menu, "apply_pending_preferences") -require("PREF_MASTER_VOLUME" in pending_preferences and +require('changed("audio.master_volume")' in pending_preferences and "psx_host_audio_set_master_volume" in pending_preferences and - "PREF_AUDIO_MUTED" in pending_preferences and + 'changed("audio.muted")' in pending_preferences and "psx_host_audio_set_muted" in pending_preferences, "pending host-audio settings must survive a soft runtime session") saved_preferences = function_body(menu, "apply_saved_preferences") @@ -671,15 +676,14 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: require("disruptor_billboard_aspect_all_site_mask" in session_body and "disruptor_billboard_aspect_set_site_mask" in session_body, f"{function_name} must restore every billboard repair path") -require("PREF_ASPECT" in function_body(menu, "merge_dirty_preferences") and - "settings.has_aspect_ratio = true" in - function_body(menu, "merge_dirty_preferences") and - "settings.has_adaptive_view = true" in - function_body(menu, "merge_dirty_preferences") and - "settings.adaptive_view = pending.adaptive_view" in - function_body(menu, "merge_dirty_preferences"), +require('g_preferences.changed.insert(find_option("video.aspect_ratio"));' in + function_body(menu, "mark_aspect") and + MERGED in function_body(menu, "merge_dirty_preferences") and + " settings.has_aspect_ratio = settings.has_adaptive_view = true;\n" + " settings.adaptive_view = index == window_aspect;\n" in options and + "aspect_saved, aspect, set_aspect)" in options, "fixed and adaptive aspect choices must persist atomically") -require("PREF_ASPECT" in function_body(menu, "apply_pending_preferences") and +require('changed("video.aspect_ratio")' in function_body(menu, "apply_pending_preferences") and "psx_host_video_set_display_aspect" in function_body(menu, "apply_pending_preferences") and "psx_host_video_set_adaptive_view" in @@ -729,4 +733,186 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: "export PSX_VIDEO_ASPECT=16:9" in run_sh, "explicit widescreen launch switches must override a saved aspect") +shared_keys = set(re.findall(r'\b(?:toggle|choice|slider)(?:<[^>]*>)?\(\s*"([\w.]+)"', options)) +staged_keys = set(re.findall(r'\bmark\("([\w.]+)"', menu)) | set( + re.findall(r'g_preferences\.changed\.insert\(\s*find_option\("([\w.]+)"\)\)', menu)) +applied_keys = set(re.findall(r'\bchanged\("([\w.]+)"\)', function_body(menu, "apply_pending_preferences"))) +require(len(shared_keys) == 27 and staged_keys == shared_keys and applied_keys == shared_keys and + set(re.findall(r'\bfind_option\("([\w.]+)"', menu)) <= shared_keys, + "the menu must stage every shared option, apply every one it staged and name no key the table lacks") +require('void mark_vsync(int value) { mark("video.vsync", value + 1); }\n' in menu and + " g_preferences.pending.horizontal_sensitivity = value;\n" + " g_preferences.changed.insert(\n" + ' find_option("disruptor.horizontal_sensitivity"));\n' in function_body(menu, "mark_sensitivity") and + " g_preferences.pending.vertical_sensitivity = value;\n" + " g_preferences.changed.insert(\n" + ' find_option("disruptor.vertical_sensitivity"));\n' in function_body(menu, "mark_vertical_sensitivity") and + "(void)disruptor_mouse_set_horizontal_sensitivity(\n pending.horizontal_sensitivity);" in + function_body(menu, "apply_pending_preferences") and + "int vsync(const Settings& settings) { return std::clamp(settings.vsync + 1, 0, 2); }\n" in options and + " return static_cast(std::lround(settings.*Value * 1000.0));\n" in options and + " settings.*Value = value / 1000.0;\n" in options, + "a value the menu holds in its own unit must be staged in the shared table's, and a sensitivity kept whole " + "for the running game and rounded only where it is written") +require(STAGED in menu and + " return g_preferences.changed.contains(find_option(key));\n" in menu and + " std::set changed;\n" in menu and + " return !changed.empty() || language_disc_changed ||\n" + " diagnostic_view_changed;\n" in menu and + " changed.clear();\n" + " language_disc_changed = diagnostic_view_changed = false;\n" in menu and + " if (!g_preferences.dirty()) return true;\n" in function_body(menu, "flush_preferences") and + " g_preferences.clean();\n g_preferences.save_failed = false;\n" in function_body(menu, "flush_preferences") and + "PreferenceDirty" not in menu and "PREF_" not in menu and + ' list(APPEND DISRUPTOR_EXTRA_SOURCES\n' + ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/settings/options.cpp"\n' + ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/dev_menu.cpp")\n' in cmake, + "a staged change must be an option of the shared table, and a save must write and then forget every one") + +HELPERS = ( + "bool option_toggle(const char *key, bool *value) {\n" + " const Option &option = *find_option(key);\n" + " const bool moved = ImGui::Checkbox(option.label, value);\n" + " disruptor::ui::hint(option.hint);\n" + " return moved;\n" + "}\n" + "\n" + "bool option_slider(const char *key, int *value) {\n" + " const Option &option = *find_option(key);\n" + ' const char *format = option.unit == Unit::percent ? "%d%%" : "%d";\n' + " const bool moved =\n" + " ImGui::SliderInt(option.label, value, option.lowest, option.highest,\n" + " format, ImGuiSliderFlags_AlwaysClamp);\n" + " disruptor::ui::hint(option.hint);\n" + " return moved;\n" + "}\n" + "\n" + "bool option_slider(const char *key, float *value) {\n" + " const Option &option = *find_option(key);\n" + " const bool moved = ImGui::SliderFloat(\n" + " option.label, value, option.lowest / 1000.0f,\n" + ' option.highest / 1000.0f, "%.3f", ImGuiSliderFlags_Logarithmic);\n' + " disruptor::ui::hint(option.hint);\n" + " return moved;\n" + "}\n" + "\n" + "bool option_list(const char *key, int *index, size_t skipped = 0) {\n" + " const Option &option = *find_option(key);\n" + " const auto shown = option.choices.subspan(skipped);\n" + " const bool moved = ImGui::Combo(option.label, index, shown.data(),\n" + " static_cast(shown.size()));\n" + " disruptor::ui::hint(option.hint);\n" + " return moved;\n" + "}\n" +) +shared_labels = re.findall(r'\b(?:toggle<[^>]*>|choice|slider)\(\s*"[\w.]+",\s*"([^"]+)"', options) +shared_choices = re.findall(r'"([^"]+)"', "".join(re.findall(r"constexpr const char\* \w+\[\] = \{([^}]*)\};", options))) +drawn_keys = set(re.findall(r'\boption_(?:toggle|slider|list)\("([\w.]+)"', menu)) | set( + re.findall(r'find_option\("([\w.]+)"\)->label', menu)) +present = read("src/game/frame/present_rate.cpp") +require(HELPERS in menu and len(shared_labels) == 27 and drawn_keys == shared_keys and + not [text for text in [*shared_labels, *shared_choices] if f'"{text}"' in menu + present] and + " static constexpr int kScaleCount = 8;\n" in menu and + '"1x (native)", "2x", "3x", "4x", "5x", "6x", "7x", "8x"' in options and + '{"4:3", "16:9", "21:9", "32:9", "Match window (up to 32:9)"}' in options and + '{"Windowed", "Borderless fullscreen", "Exclusive fullscreen"}' in options and + '{"Adaptive", "Immediate", "Synchronised"}' in options and '{"Vanilla", "Improved"}' in options and + '"How far the view turns for a movement of the mouse.", 5, 2000, Unit::thousandths,' in options and + '"How far the view tilts for a movement of the mouse.", 5, 2000, Unit::thousandths,' in options and + '"The loudness of everything the game plays.", 0, 100, Unit::percent,' in options, + "the menu must draw every shared option by its key, with the table's label, bounds and choices and none of its own") + +RULE = ( + "bool applies(const Option& option, const std::function& on) {\n" + " return std::all_of(option.needs.begin(), option.needs.end(), [&](const char* key) {\n" + " const Option* needed = find_option(key);\n" + " return needed && on(*needed) && applies(*needed, on);\n" + " });\n" + "}\n" +) +LIVE = ( + "bool prerequisite_on(const Option &option) {\n" + " const std::string_view key(option.key);\n" + ' if (key == "disruptor.geometry_correction")\n' + " return gpu_geometry_correction_enabled() != 0;\n" + ' if (key == "disruptor.mouse_aim") return disruptor_mouse_aim_enabled() != 0;\n' + ' if (key == "disruptor.ambient_occlusion") return gl_renderer_ssao() != 0;\n' + " return false;\n" + "}\n" + "\n" + "bool applies(const char *key) {\n" + " return disruptor::settings::applies(*find_option(key), prerequisite_on);\n" + "}\n" +) +key_names = dict(re.findall(r'constexpr const char\* (\w+_key) = "([\w.]+)";', options)) +needs_lists = { + name: [key_names[word.strip()] for word in words.split(",")] + for name, words in re.findall(r"constexpr const char\* (needs_\w+)\[\] = \{([^}]*)\};", options) +} +table_rows = re.split(r"\n (?=toggle<|choice\(|slider\()", options[options.index("constexpr Option option_list[]") :])[1:] +needing = {} +for row in table_rows: + row = row.split("\n};")[0] + ending = re.search(r"\b(needs_\w+)\),?\s*$", row) + if ending: + needing[re.search(r'"([\w.]+)"', row)[1]] = needs_lists[ending[1]] +asked = set(re.findall(r'\bapplies\("([\w.]+)"\)', menu)) +told = set(re.findall(r'key == "([\w.]+)"', function_body(menu, "prerequisite_on"))) +require(RULE in options and LIVE in menu and len(needing) == 9 and + needing["disruptor.high_precision_camera"] == ["disruptor.mouse_aim", "disruptor.geometry_correction"] and + asked == set(needing) - {"video.frame_interpolation_fps"} and + told == {key for keys in needing.values() for key in keys} - {"video.frame_interpolation"} and + " if (!enabled) ImGui::BeginDisabled();\n" in function_body(present, "disruptor_present_rate_control") and + "if (!geometry)" not in menu and "turn_applies = mouse_aim" not in menu, + "the menu must grey a control by the shared table's rule alone, asked of the running game, and the " + "rate control must grey itself while in-between frames are off") + + +def greyed_by_own_rule(source: str, key: str) -> bool: + rule = re.search(r'const bool (\w+) =[^;]*\bapplies\("' + re.escape(key) + r'"\)[^;]*;\n', source) + if rule is None: + return False + begin, end = f" if (!{rule[1]}) ImGui::BeginDisabled();\n", f" if (!{rule[1]}) ImGui::EndDisabled();\n" + control = re.search(r'\boption_(?:toggle|slider|list)\("' + re.escape(key) + '"', source) + if control is None or source.count(begin) != 1 or source.count(end) != 1: + return False + opened, closed = source.index(begin), source.index(end) + return (rule.end() <= opened < control.start() < closed and + "Disabled()" not in source[opened + len(begin):control.start()] and + "BeginDisabled()" not in source[control.start():closed]) + + +greyed = asked - {"video.frame_interpolation"} +require(len(greyed) == 7 and all(greyed_by_own_rule(menu, key) for key in greyed) and + not greyed_by_own_rule(menu.replace(" if (!textures_apply) ImGui::BeginDisabled();\n", + " if (false) ImGui::BeginDisabled();\n"), "disruptor.perspective_textures") and + not greyed_by_own_rule(menu.replace('applies("disruptor.gap_filling")', 'applies("disruptor.vsync")'), + "disruptor.gap_filling"), + "each control that needs something must be drawn inside a disabled block opened by its own rule") + +HINT = ( + "inline void hint(const char *text) {\n" + " if (!ImGui::BeginItemTooltip()) return;\n" + " ImGui::PushTextWrapPos(ImGui::GetFontSize() * 30.0f);\n" + " ImGui::TextUnformatted(text);\n" + " ImGui::PopTextWrapPos();\n" + " ImGui::EndTooltip();\n" + "}\n" +) +LITERAL = r'"((?:[^"\\]|\\.)*)"' +shared_hints = [ + "".join(re.findall(LITERAL, pieces)) + for pieces in re.findall( + r'\b(?:toggle<[^>]*>|choice|slider)\(\s*"[\w.]+",\s*"[^"]+",\s*-?\d+,\s*((?:"(?:[^"\\]|\\.)*"\s*)+)', options) +] +written = "".join(re.findall(LITERAL, menu + present)) +require(HINT in read("src/game/ui/hint.h") and len(shared_hints) == 27 and + all(20 <= len(text) <= 240 for text in shared_hints) and + not [text for text in shared_hints if text[:32] in written] and + "Needs mouse aim and exact geometry." in shared_hints[ + [key for key in re.findall(r'\b(?:toggle<[^>]*>|choice|slider)\(\s*"([\w.]+)"', options)].index( + "disruptor.high_precision_camera")], + "the menu must show an option's description from the shared table while the pointer rests on its control, " + "and write none of the descriptions itself") + print("developer menu contract: PASS") diff --git a/tests/test_far_rendering_contract.py b/tests/contracts/test_far_rendering_contract.py similarity index 96% rename from tests/test_far_rendering_contract.py rename to tests/contracts/test_far_rendering_contract.py index 821ab31..78dae0a 100644 --- a/tests/test_far_rendering_contract.py +++ b/tests/contracts/test_far_rendering_contract.py @@ -6,7 +6,7 @@ import sys -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -19,8 +19,8 @@ def require(condition: bool, message: str) -> None: raise SystemExit(1) -source = read("src/disruptor_far_rendering.cpp") -header = read("src/disruptor_far_rendering.h") +source = read("src/game/render/far_rendering.cpp") +header = read("src/game/render/far_rendering.h") codegen = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") dirty = read("psxrecomp-overlay/runtime/src/dirty_ram_interp.c") main = read("psxrecomp-overlay/runtime/src/main.cpp") @@ -28,9 +28,9 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") configs = read("game.toml") + read("game-widescreen.toml") -require("src/disruptor_far_rendering.cpp" in cmake and - "tests/disruptor_far_rendering_test.cpp" in cmake and - "add_test(NAME disruptor_far_rendering" in cmake, +require("src/game/render/far_rendering.cpp" in cmake and + "tests/unit/disruptor_far_rendering_test.cpp" in cmake and + " disruptor_unit_test(disruptor_far_rendering " in cmake, "runtime source and focused behavior test must remain enabled") require("PSX_HAS_DISRUPTOR_FAR_RENDERING=1" in cmake, "the Disruptor target must opt into dirty-interpreter far hooks") diff --git a/tests/test_frame_kind_contract.py b/tests/contracts/test_frame_kind_contract.py similarity index 91% rename from tests/test_frame_kind_contract.py rename to tests/contracts/test_frame_kind_contract.py index 0965026..01475e3 100644 --- a/tests/test_frame_kind_contract.py +++ b/tests/contracts/test_frame_kind_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -47,13 +47,10 @@ def enclosing(source: str, block: str) -> list[str]: CONTRACTS = ["if(BUILD_TESTING)"] UNIT_TESTS = ["if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING)"] REGISTERED = { - ' "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_render_arena.cpp"\n': [], - ' add_test(NAME disruptor_frame_kind_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' - ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_frame_kind_contract.py")\n': CONTRACTS, - " add_executable(disruptor-gpu-ws-frame-kind-test\n tests/gpu_ws_frame_kind_test.cpp)\n": UNIT_TESTS, - " add_test(NAME disruptor_gpu_ws_frame_kind\n COMMAND disruptor-gpu-ws-frame-kind-test)\n": UNIT_TESTS, - " add_executable(disruptor-render-arena-test\n tests/disruptor_render_arena_test.cpp)\n": UNIT_TESTS, - " add_test(NAME disruptor_render_arena COMMAND disruptor-render-arena-test)\n": UNIT_TESTS, + ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/render_arena.cpp"\n': [], + " disruptor_contract(frame_kind)\n": CONTRACTS, + " disruptor_unit_test(disruptor_gpu_ws_frame_kind tests/unit/gpu_ws_frame_kind_test.cpp RUNTIME_HEADERS)\n": UNIT_TESTS, + " disruptor_unit_test(disruptor_render_arena tests/unit/disruptor_render_arena_test.cpp RUNTIME_HEADERS)\n": UNIT_TESTS, } for block, conditions in REGISTERED.items(): require( diff --git a/tests/test_frame_rate_contract.py b/tests/contracts/test_frame_rate_contract.py similarity index 74% rename from tests/test_frame_rate_contract.py rename to tests/contracts/test_frame_rate_contract.py index c74cd30..f975773 100644 --- a/tests/test_frame_rate_contract.py +++ b/tests/contracts/test_frame_rate_contract.py @@ -6,7 +6,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -43,21 +43,33 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_frame_rate.cpp") -header = read("src/disruptor_frame_rate.h") -menu = read("src/disruptor_dev_menu.cpp") +source = read("src/game/frame/frame_rate.cpp") +header = read("src/game/frame/frame_rate.h") +menu = read("src/game/ui/dev_menu.cpp") +options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) manifest = read("PSXRECOMP_OVERLAY_FILES.txt") cycle_header = read("psxrecomp-overlay/runtime/include/psx_cyc.h") cycle_source = read("psxrecomp-overlay/runtime/src/psx_cycles.c") require( - 'tests/test_frame_rate_contract.py"\n --require-artifacts)' in cmake, + " disruptor_contract(frame_rate RETAIL)\n" in cmake, "the build gate must run this contract with its inputs required", ) require( - "src/disruptor_frame_rate.cpp" in cmake - and "disruptor_frame_rate_contract" in cmake - and "add_test(NAME disruptor_frame_rate\n" in cmake, + "src/game/frame/frame_rate.cpp" in cmake + and " disruptor_contract(frame_rate RETAIL)\n" in cmake + and " disruptor_unit_test(disruptor_frame_rate " in cmake, "frame-rate source, its unit test and its contract test must remain registered", ) @@ -121,13 +133,15 @@ def require(condition: bool, message: str) -> None: require( "disruptor_frame_rate_set_unlocked(unlocked ? 1 : 0);\n mark_frame_unlock(unlocked);" in menu - and '"60 FPS gameplay"' in menu, + and ' if (option_toggle("disruptor.frame_unlock", &unlocked)) {\n' in menu + and '"disruptor.frame_unlock", "60 FPS gameplay", 0,' in options, "the settings menu must apply the switch live and stage it for saving", ) require( - "if (g_preferences.dirty & PREF_FRAME_UNLOCK) {\n" - " settings.has_frame_unlock = true;\n" - " settings.frame_unlock = pending.frame_unlock;" in menu, + 'void mark_frame_unlock(bool value) { mark("disruptor.frame_unlock", value); }\n' in menu + and STAGED in menu + and MERGED in menu + and ' toggle<&Settings::has_frame_unlock, &Settings::frame_unlock>(\n "disruptor.frame_unlock",' in options, "a changed switch must reach settings.toml", ) require( @@ -161,12 +175,12 @@ def require(condition: bool, message: str) -> None: "the divisor must charge whole cycles and carry the remainder", ) -present_rate = read("src/disruptor_present_rate.h") -present_control = read("src/disruptor_present_rate.cpp") +present_rate = read("src/game/frame/present_rate.h") +present_control = read("src/game/frame/present_rate.cpp") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") host = read("psxrecomp-overlay/runtime/src/main.cpp") require( - "add_test(NAME disruptor_present_rate\n" in cmake + " disruptor_unit_test(disruptor_present_rate " in cmake and "constexpr int kDisruptorPresentRateLowest = 30;" in present_rate and "constexpr int kDisruptorPresentRateLeastTop = 120;" in present_rate, "the in-between frame rate control must run from 30 FPS to at least 120 and keep its unit test", @@ -174,29 +188,37 @@ def require(condition: bool, message: str) -> None: require( " if(DISRUPTOR_FRAME_INTERPOLATION)\n" " list(APPEND DISRUPTOR_EXTRA_SOURCES\n" - ' "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_present_rate.cpp")\n' + ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/frame/present_rate.cpp")\n' in cmake and "#ifndef PSX_DISABLE_FRAME_INTERPOLATION\n" " int present_enabled = 0;\n" - " if (disruptor_present_rate_switch(geometry ? 1 : 0, &present_enabled))\n" + " if (disruptor_present_rate_switch(\n" + ' find_option("video.frame_interpolation")->label,\n' + ' find_option("video.frame_interpolation")->hint,\n' + ' applies("video.frame_interpolation") ? 1 : 0,\n' + " &present_enabled))\n" " mark_present_enabled(present_enabled != 0);\n" " int present_rate = 0;\n" - " if (disruptor_present_rate_control(&present_rate)) mark_present_rate(present_rate);\n" + " if (disruptor_present_rate_control(\n" + ' find_option("video.frame_interpolation_fps")->label,\n' + ' find_option("video.frame_interpolation_fps")->hint, &present_rate))\n' + " mark_present_rate(present_rate);\n" "#endif\n" in menu and "#ifndef PSX_DISABLE_FRAME_INTERPOLATION\n" - " if (g_preferences.dirty & PREF_PRESENT_ENABLED)\n" + ' if (changed("video.frame_interpolation"))\n' " (void)disruptor_present_rate_enable(pending.frame_interpolation ? 1 : 0);\n" - " if (g_preferences.dirty & PREF_PRESENT_RATE)\n" + ' if (changed("video.frame_interpolation_fps"))\n' " (void)disruptor_present_rate_apply(pending.frame_interpolation_fps);\n" "#endif\n" in menu, "a build without the frame interpolator must have no rate control, in the menu or in the sources", ) require( - 'if (ImGui::SliderInt("In-between frame rate", &shown, kDisruptorPresentRateLowest, top, "%d FPS",\n' - " ImGuiSliderFlags_AlwaysClamp) &&\n" - " disruptor_present_rate_apply(shown)) {\n" + ' const bool moved = ImGui::SliderInt(label, &shown, kDisruptorPresentRateLowest, top, "%d FPS",\n' + " ImGuiSliderFlags_AlwaysClamp);\n" + " disruptor::ui::hint(hint);\n" + " if (moved && disruptor_present_rate_apply(shown)) {\n" " if (accepted) *accepted = shown;\n" " changed = 1;\n" in present_control @@ -214,16 +236,22 @@ def require(condition: bool, message: str) -> None: ) require( " return psx_host_video_set_interpolation(enabled ? 1 : 0, target, blend) != 0;\n" in present_control - and 'if (ImGui::Checkbox("In-between frames", &checked) &&\n' - " disruptor_present_rate_enable(checked ? 1 : 0)) {\n" + and '"video.frame_interpolation", "In-between frames", 0,' in options + and 'slider("video.frame_interpolation_fps", "In-between frame rate", 0,' in options + and "int disruptor_present_rate_switch(const char *label, const char *hint, int available, int *enabled) {\n" + in present_control + and "int disruptor_present_rate_control(const char *label, const char *hint, int *accepted) {\n" in present_control + and " const bool clicked = ImGui::Checkbox(label, &checked);\n" + " disruptor::ui::hint(hint);\n" + " if (clicked && disruptor_present_rate_enable(checked ? 1 : 0)) {\n" " if (enabled) *enabled = checked ? 1 : 0;\n" in present_control, "the switch must keep the rate and the blend mode and report only a state the host accepted", ) require( - " if (g_preferences.dirty & PREF_PRESENT_ENABLED) {\n" - " settings.has_frame_interpolation = true;\n" - " settings.frame_interpolation = pending.frame_interpolation;\n" in menu, + 'void mark_present_enabled(bool value) { mark("video.frame_interpolation", value); }\n' in menu + and MERGED in menu + and ' toggle<&Settings::has_frame_interpolation, &Settings::frame_interpolation>(\n "video.frame_interpolation",' in options, "a changed switch must reach settings.toml", ) require( @@ -244,9 +272,9 @@ def require(condition: bool, message: str) -> None: "the host must restore the saved switch, and geometry in-between frames not without exact geometry", ) require( - " if (g_preferences.dirty & PREF_PRESENT_RATE) {\n" - " settings.has_frame_interpolation_fps = true;\n" - " settings.frame_interpolation_fps = pending.frame_interpolation_fps;\n" in menu, + 'void mark_present_rate(int value) { mark("video.frame_interpolation_fps", value); }\n' in menu + and MERGED in menu + and "write<&Settings::has_frame_interpolation_fps, &Settings::frame_interpolation_fps>, needs_frames)" in options, "a changed in-between frame rate must reach settings.toml", ) require( diff --git a/tests/test_framework_overlay_new_file.py b/tests/contracts/test_framework_overlay_new_file.py similarity index 98% rename from tests/test_framework_overlay_new_file.py rename to tests/contracts/test_framework_overlay_new_file.py index 6617bab..660600a 100644 --- a/tests/test_framework_overlay_new_file.py +++ b/tests/contracts/test_framework_overlay_new_file.py @@ -9,7 +9,7 @@ import tempfile -ROOT = pathlib.Path(__file__).resolve().parents[1] +ROOT = pathlib.Path(__file__).resolve().parents[2] APPLIER = ROOT / "tools" / "apply_framework_overlay.py" PROJECT_OVERLAY = ROOT / "psxrecomp-overlay" PROJECT_MANIFEST = ROOT / "PSXRECOMP_OVERLAY_FILES.txt" diff --git a/tests/test_gap_filling_contract.py b/tests/contracts/test_gap_filling_contract.py similarity index 80% rename from tests/test_gap_filling_contract.py rename to tests/contracts/test_gap_filling_contract.py index 4a29417..292cb94 100644 --- a/tests/test_gap_filling_contract.py +++ b/tests/contracts/test_gap_filling_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -22,11 +22,23 @@ def between(source: str, opening: str, closing: str) -> str: cmake = read("CMakeLists.txt") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") -menu = read("src/disruptor_dev_menu.cpp") +menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") +options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") -require(" add_test(NAME disruptor_gap_filling_contract\n" in cmake, "this contract is registered") +require(" disruptor_contract(gap_filling)\n" in cmake, "this contract is registered") require( " const int filler = s_grown_under && exact && s_next_world == 2;\n" in renderer @@ -130,37 +142,31 @@ def between(source: str, opening: str, closing: str) -> str: enhancements = between(menu, "void draw_enhancements_tab() {", "const char *cheat_result_message(") require( """ bool gaps = gl_renderer_gap_fill() != 0; - if (!geometry) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Gap filling", &gaps)) { + const bool gaps_apply = applies("disruptor.gap_filling"); + if (!gaps_apply) ImGui::BeginDisabled(); + if (option_toggle("disruptor.gap_filling", &gaps)) { gl_renderer_set_gap_fill(gaps ? 1 : 0); mark_gap_filling(gaps); } - if (!geometry) ImGui::EndDisabled(); + if (!gaps_apply) ImGui::EndDisabled(); """ in enhancements - and menu.count('"Gap filling"') == 1 + and menu.count('option_toggle("disruptor.gap_filling"') == 1 and enhancements.count("ImGui::BeginDisabled()") == enhancements.count("ImGui::EndDisabled()"), "the setting is on the Enhancements tab alone, greyed without geometry correction, applied and staged when changed", ) require( - """void mark_gap_filling(bool value) { - g_preferences.pending.has_gap_filling = true; - g_preferences.pending.gap_filling = value; - g_preferences.dirty |= PREF_GAP_FILLING; -} -""" in menu - and """ if (g_preferences.dirty & PREF_GAP_FILLING) { - settings.has_gap_filling = true; - settings.gap_filling = pending.gap_filling; - } -""" in menu + 'void mark_gap_filling(bool value) { mark("disruptor.gap_filling", value); }\n' in menu + and STAGED in menu + and MERGED in menu + and ' toggle<&Settings::has_gap_filling, &Settings::gap_filling>(\n "disruptor.gap_filling",' in options and """ if (settings.has_gap_filling && !env_override_present("PSX_GL_CRACK_FILL") && !env_override_present("PSX_GL_GROWN_UNDER")) gl_renderer_set_gap_fill(settings.gap_filling ? 1 : 0); """ in menu - and """ if (g_preferences.dirty & PREF_GAP_FILLING) + and """ if (changed("disruptor.gap_filling")) gl_renderer_set_gap_fill(pending.gap_filling ? 1 : 0); """ in menu, - "a change is staged under a bit of its own, merged into the file's settings, applied at the next start unless the " + "a change is staged as an option of the shared table, merged into the file's settings, applied at the next start unless the " "environment decides either filler, and applied when another run saved it", ) @@ -177,9 +183,12 @@ def between(source: str, opening: str, closing: str) -> str: "the settings file keeps the choice, which is on until changed", ) -row = between(launcher, '"disruptor.gap_filling", Page::enhancements, L"Gap filling", 1,', "),\n") +row = between(options, '"disruptor.gap_filling", "Gap filling", 1,', "),\n") require( - row.endswith("geometry_key") and launcher.count('"disruptor.gap_filling"') == 1, + row.endswith("needs_geometry") + and "constexpr const char* needs_geometry[] = {geometry_key};\n" in options + and options.count('"disruptor.gap_filling"') == launcher.count('"disruptor.gap_filling"') == 1 + and ' {"disruptor.gap_filling", Page::enhancements},\n' in launcher, "the launcher offers it on the Enhancements page, on at first and only with geometry correction", ) diff --git a/tests/test_geometry_provenance_contract.py b/tests/contracts/test_geometry_provenance_contract.py similarity index 99% rename from tests/test_geometry_provenance_contract.py rename to tests/contracts/test_geometry_provenance_contract.py index a61795c..6486009 100644 --- a/tests/test_geometry_provenance_contract.py +++ b/tests/contracts/test_geometry_provenance_contract.py @@ -8,7 +8,7 @@ import sys -ROOT = pathlib.Path(__file__).resolve().parents[1] +ROOT = pathlib.Path(__file__).resolve().parents[2] GPU = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "gpu.c" GTE = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "gte.cpp" MEMORY = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "memory.c" @@ -18,7 +18,7 @@ GTE_HEADER = ( ROOT / "psxrecomp-overlay" / "runtime" / "include" / "gte_precision.h" ) -MOUSE = ROOT / "src" / "disruptor_mouse_aim.cpp" +MOUSE = ROOT / "src" / "game" / "input" / "mouse_aim.cpp" MAIN = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "main.cpp" CODEGEN = ( ROOT / "psxrecomp-overlay" / "recompiler" / "src" / "code_generator.cpp" diff --git a/tests/test_grown_quad_contract.py b/tests/contracts/test_grown_quad_contract.py similarity index 91% rename from tests/test_grown_quad_contract.py rename to tests/contracts/test_grown_quad_contract.py index e6a9051..d2f59d8 100644 --- a/tests/test_grown_quad_contract.py +++ b/tests/contracts/test_grown_quad_contract.py @@ -6,7 +6,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -36,14 +36,14 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_grown_quad.cpp") +source = read("src/game/render/grown_quad.cpp") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") require( - '"${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_grown_quad.cpp"' in cmake + '"${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/grown_quad.cpp"' in cmake and "PSX_HAS_DISRUPTOR_GROWN_QUAD=1" in cmake - and "add_test(NAME disruptor_grown_quad\n" in cmake - and 'tests/test_grown_quad_contract.py"\n --require-artifacts)' in cmake, + and " disruptor_unit_test(disruptor_grown_quad " in cmake + and " disruptor_contract(grown_quad RETAIL)\n" in cmake, "the grown quad source, its renderer switch, its unit test and this contract with its input must stay registered", ) require( diff --git a/tests/test_hud_scale_contract.py b/tests/contracts/test_hud_scale_contract.py similarity index 81% rename from tests/test_hud_scale_contract.py rename to tests/contracts/test_hud_scale_contract.py index 7722d05..393c175 100644 --- a/tests/test_hud_scale_contract.py +++ b/tests/contracts/test_hud_scale_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -20,7 +20,19 @@ def require(condition: bool, message: str) -> None: scale_header = read("psxrecomp-overlay/runtime/include/gpu_ws_hud_scale.h") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") header = read("psxrecomp-overlay/runtime/include/gpu.h") -menu = read("src/disruptor_dev_menu.cpp") +menu = read("src/game/ui/dev_menu.cpp") +options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) settings_loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") require( @@ -28,7 +40,7 @@ def require(condition: bool, message: str) -> None: "overlay manifest must list the HUD scale arithmetic", ) require( - "add_test(NAME disruptor_gpu_ws_hud_scale\n" in cmake and "disruptor_hud_scale_contract" in cmake, + " disruptor_unit_test(disruptor_gpu_ws_hud_scale " in cmake and " disruptor_contract(hud_scale)\n" in cmake, "the HUD scale unit test and its contract must remain registered", ) require( @@ -97,16 +109,18 @@ def require(condition: bool, message: str) -> None: ) require( - ' if (ImGui::SliderInt("HUD size", &hud_scale, 50, 100, "%d%%",\n' - " ImGuiSliderFlags_AlwaysClamp)) {\n" + ' if (option_slider("disruptor.hud_scale", &hud_scale)) {\n' " gpu_ws_set_hud_scale(hud_scale);\n" - " mark_hud_scale(hud_scale);" in menu, + " mark_hud_scale(hud_scale);" in menu + and 'slider("disruptor.hud_scale", "HUD size", 100,' in options + and '"The weapon keeps its size.", 50, 100, Unit::percent,' in options, "the settings menu must apply the size live and stage it for saving", ) require( - " if (g_preferences.dirty & PREF_HUD_SCALE) {\n" - " settings.has_hud_scale = true;\n" - " settings.hud_scale = pending.hud_scale;" in menu, + 'void mark_hud_scale(int value) { mark("disruptor.hud_scale", value); }\n' in menu + and STAGED in menu + and MERGED in menu + and "write<&Settings::has_hud_scale, &Settings::hud_scale>)" in options, "a changed size must reach settings.toml", ) require( diff --git a/tests/test_hud_widget_contract.py b/tests/contracts/test_hud_widget_contract.py similarity index 92% rename from tests/test_hud_widget_contract.py rename to tests/contracts/test_hud_widget_contract.py index 74a7776..9bacf9e 100644 --- a/tests/test_hud_widget_contract.py +++ b/tests/contracts/test_hud_widget_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -24,18 +24,16 @@ def body(source: str, signature: str) -> str: manifest = read("PSXRECOMP_OVERLAY_FILES.txt") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") header = read("psxrecomp-overlay/runtime/include/gpu.h") -module = read("src/disruptor_hud_widgets.cpp") +module = read("src/game/render/hud_widgets.cpp") require( "+runtime/include/gpu_ws_hud_widget.h" in manifest.splitlines(), "overlay manifest must list the HUD widget arithmetic", ) require( - " add_executable(disruptor-gpu-ws-hud-widget-test\n tests/gpu_ws_hud_widget_test.cpp)\n" in cmake - and " add_test(NAME disruptor_gpu_ws_hud_widget\n COMMAND disruptor-gpu-ws-hud-widget-test)\n" in cmake - and ' add_test(NAME disruptor_hud_widget_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' - ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_hud_widget_contract.py")\n' in cmake - and '"${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_hud_widgets.cpp"' in cmake, + " disruptor_unit_test(disruptor_gpu_ws_hud_widget tests/unit/gpu_ws_hud_widget_test.cpp RUNTIME_HEADERS)\n" in cmake + and " disruptor_contract(hud_widget)\n" in cmake + and '"${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/hud_widgets.cpp"' in cmake, "the widget unit test, its contract and the game's widget list must remain in the build", ) require( diff --git a/tests/test_intro_skip_contract.py b/tests/contracts/test_intro_skip_contract.py similarity index 88% rename from tests/test_intro_skip_contract.py rename to tests/contracts/test_intro_skip_contract.py index 71af171..7399fae 100644 --- a/tests/test_intro_skip_contract.py +++ b/tests/contracts/test_intro_skip_contract.py @@ -5,7 +5,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -45,24 +45,36 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_intro_skip.cpp") -header = read("src/disruptor_intro_skip.h") -menu = read("src/disruptor_dev_menu.cpp") +source = read("src/game/session/intro_skip.cpp") +header = read("src/game/session/intro_skip.h") +menu = read("src/game/ui/dev_menu.cpp") +options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) generator = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") interpreter = read("psxrecomp-overlay/runtime/src/dirty_ram_interp.c") audit = read("tools/audit_codegen.py") settings_loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") require( - "src/disruptor_intro_skip.cpp" in cmake + "src/game/session/intro_skip.cpp" in cmake and "PSX_HAS_DISRUPTOR_INTRO_SKIP=1" in cmake - and "disruptor_intro_skip_contract" in cmake - and "add_test(NAME disruptor_intro_skip\n" in cmake, + and " disruptor_contract(intro_skip RETAIL)\n" in cmake + and " disruptor_unit_test(disruptor_intro_skip " in cmake, "intro-skip source, its interpreter switch and both tests must remain registered", ) require( - 'tests/test_intro_skip_contract.py"\n --require-artifacts)' in cmake, + " disruptor_contract(intro_skip RETAIL)\n" in cmake, "the build gate must run this contract with its inputs required", ) for address, word in SEAMS.items(): @@ -161,13 +173,15 @@ def require(condition: bool, message: str) -> None: require( "disruptor_intro_skip_set_enabled(skip_intro ? 1 : 0);\n mark_skip_intro(skip_intro);" in menu - and '"Skip the logos and the title movie"' in menu, + and ' if (option_toggle("disruptor.skip_intro", &skip_intro)) {\n' in menu + and '"disruptor.skip_intro", "Skip the logos and the title movie", 0,' in options, "the settings menu must apply the switch live and stage it for saving", ) require( - "if (g_preferences.dirty & PREF_SKIP_INTRO) {\n" - " settings.has_skip_intro = true;\n" - " settings.skip_intro = pending.skip_intro;" in menu, + 'void mark_skip_intro(bool value) { mark("disruptor.skip_intro", value); }\n' in menu + and STAGED in menu + and MERGED in menu + and ' toggle<&Settings::has_skip_intro, &Settings::skip_intro>(\n "disruptor.skip_intro",' in options, "a changed switch must reach settings.toml", ) require( diff --git a/tests/test_language_contract.py b/tests/contracts/test_language_contract.py similarity index 97% rename from tests/test_language_contract.py rename to tests/contracts/test_language_contract.py index 551b8ab..0baa532 100644 --- a/tests/test_language_contract.py +++ b/tests/contracts/test_language_contract.py @@ -7,7 +7,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -87,15 +87,15 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_language.cpp") -rules = read("src/disruptor_language_pack.h") -header = read("src/disruptor_language.h") -hints = read("src/disruptor_language_hints.cpp") +source = read("src/game/language/language.cpp") +rules = read("src/game/language/language_pack.h") +header = read("src/game/language/language.h") +hints = read("src/game/language/language_hints.cpp") require( - '/src/disruptor_language.cpp"' in cmake - and "add_test(NAME disruptor_language\n" in cmake - and 'tests/test_language_contract.py"\n --require-artifacts)' in cmake, + '/src/game/language/language.cpp"' in cmake + and " disruptor_unit_test(disruptor_language " in cmake + and " disruptor_contract(language RETAIL)\n" in cmake, "the language source and both of its tests must remain registered, the contract with its inputs required", ) require( @@ -192,8 +192,8 @@ def require(condition: bool, message: str) -> None: "by the rules the disc code writes it by", ) require( - '/src/disruptor_language_hints.cpp"' in cmake - and "add_test(NAME disruptor_language_hints\n" in cmake + '/src/game/language/language_hints.cpp"' in cmake + and " disruptor_unit_test(disruptor_language_hints " in cmake and "constexpr uint32_t kRead = 0x80011888u, kLoadImage = 0x8004D348u, kMoveImage = 0x8004D410u;" in hints and "constexpr uint32_t kReadReturn = 0x80020928u, kStageReturn = 0x80020954u;" in hints and "constexpr std::array kMoves = {{{0x80043508u, 0xA0u}, {0x80043518u, 0x190u}}};" in hints diff --git a/tests/test_language_disc_contract.py b/tests/contracts/test_language_disc_contract.py similarity index 96% rename from tests/test_language_disc_contract.py rename to tests/contracts/test_language_disc_contract.py index bc7dd88..cd55795 100644 --- a/tests/test_language_disc_contract.py +++ b/tests/contracts/test_language_disc_contract.py @@ -5,7 +5,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -65,28 +65,27 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") overlay_files = read("PSXRECOMP_OVERLAY_FILES.txt") -disc = read("src/disruptor_language_disc.cpp") -disc_header = read("src/disruptor_language_disc.h") -rules = read("src/disruptor_language_pack.h") -source = read("src/disruptor_language_source.cpp") +disc = read("src/game/language/language_disc.cpp") +disc_header = read("src/game/language/language_disc.h") +rules = read("src/game/language/language_pack.h") +source = read("src/game/language/language_source.cpp") header = read("psxrecomp-overlay/runtime/include/iso_overlay.h") host = read("psxrecomp-overlay/runtime/src/main.cpp") host_header = read("psxrecomp-overlay/runtime/include/host_ui.h") pick_slot = read("psxrecomp-overlay/runtime/include/host_pick_slot.h") -restart = read("src/disruptor_restart.cpp") +restart = read("src/game/session/restart.cpp") settings_header = read("psxrecomp-overlay/recompiler/src/config_loader.h") settings_loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") -menu = read("src/disruptor_dev_menu.cpp") +menu = read("src/game/ui/dev_menu.cpp") reader = read("psxrecomp-overlay/runtime/src/iso_reader_c.cpp") require( - '/src/disruptor_language_disc.cpp"' in cmake - and '/src/disruptor_language_source.cpp"' in cmake - and "add_test(NAME disruptor_language_disc\n" in cmake - and "add_test(NAME disruptor_language_overlay\n" in cmake + '/src/game/language/language_disc.cpp"' in cmake + and '/src/game/language/language_source.cpp"' in cmake + and " disruptor_unit_test(disruptor_language_disc " in cmake + and " disruptor_unit_test(disruptor_language_overlay tests/unit/disruptor_language_overlay_test.cpp\n" in cmake and "add_test(NAME disruptor_language_overlay_tall\n COMMAND disruptor-language-overlay-test tall)" in cmake - and " tests/disruptor_language_overlay_test.cpp)" in cmake - and 'tests/test_language_disc_contract.py"\n --require-artifacts)' in cmake, + and " disruptor_contract(language_disc RETAIL)\n" in cmake, "the language disc sources and their three tests must remain registered, the contract with its inputs required", ) require( @@ -343,7 +342,7 @@ def require(condition: bool, message: str) -> None: and " return s_disc_pick.take(out, size);" in host and " if (state_ != State::kNone) return false;\n state_ = State::kOpen;" in pick_slot and " if (state_ != State::kClosed) return 0;\n state_ = State::kNone;" in pick_slot - and "add_test(NAME host_pick_slot\n" in cmake + and " disruptor_unit_test(host_pick_slot " in cmake and "CLSID_FileOpenDialog" in host and "FOS_NOCHANGEDIR" in host and "GetOpenFileName" not in host.replace("IFileOpenDialog, not GetOpenFileName:", "") @@ -353,8 +352,8 @@ def require(condition: bool, message: str) -> None: and " if (psx_host_pick_disc_image_poll(picked, static_cast(sizeof picked)) == 1) mark_language_disc(picked);" in menu and ' if (ImGui::Button("Restart now") && !psx_netplay_active() && flush_preferences() && disruptor_restart_arm()) {' in menu and " quit.type = SDL_QUIT;\n SDL_PushEvent(&quit);" in menu - and '/src/disruptor_restart.cpp"' in cmake - and "add_test(NAME disruptor_restart\n" in cmake + and '/src/game/session/restart.cpp"' in cmake + and " disruptor_unit_test(disruptor_restart " in cmake and " if (!before) return GetLastError() == ERROR_INVALID_PARAMETER;" in restart and " return waited == WAIT_OBJECT_0;" in restart and " if (!earlier_game_ended(std::strtoul(earlier, nullptr, 10), kLongestWait)) {" in restart @@ -362,8 +361,8 @@ def require(condition: bool, message: str) -> None: and " if (program.empty() || !CreateProcessW(program.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr, &startup, &started)) {" in restart and " program.resize(GetModuleFileNameW(nullptr, program.data(), static_cast(program.size())));" in restart and ' if (ImGui::Button("English") && !g_language_disc.empty()) mark_language_disc(std::string());' in menu - and " g_preferences.pending.has_language_disc = true;\n g_preferences.pending.language_disc = path;\n g_preferences.dirty |= PREF_LANGUAGE_DISC;" in menu - and " if (g_preferences.dirty & PREF_LANGUAGE_DISC) {\n settings.has_language_disc = true;\n settings.language_disc = pending.language_disc;" in menu + and " g_preferences.pending.has_language_disc = true;\n g_preferences.pending.language_disc = path;\n g_preferences.language_disc_changed = true;" in menu + and " if (g_preferences.language_disc_changed) {\n settings.has_language_disc = true;\n settings.language_disc = pending.language_disc;" in menu and " if (settings.has_language_disc) g_language_disc = settings.language_disc;" in menu, "the menu must show the saved disc, take a new one from a file dialog that neither stops the game nor moves its " "working directory, or none, stage it for saving, and offer a restart that saves first and is refused under netplay", diff --git a/tests/test_launcher_contract.py b/tests/contracts/test_launcher_contract.py similarity index 77% rename from tests/test_launcher_contract.py rename to tests/contracts/test_launcher_contract.py index a797db2..0108a2d 100644 --- a/tests/test_launcher_contract.py +++ b/tests/contracts/test_launcher_contract.py @@ -4,7 +4,7 @@ import re from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] # Keys the game saves and the launcher leaves to the in-game menu, the command line or a hand edit. NOT_OFFERED = { @@ -38,17 +38,33 @@ def plain_values(text: str) -> dict[str, str]: table = read("src/launcher/launcher_settings.cpp") +options = read("src/game/settings/options.cpp") window = read("src/launcher/main.cpp") region = read("src/launcher/region_disc.cpp") launcher_cmake = read("src/launcher/CMakeLists.txt") cmake = read("CMakeLists.txt") loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") -menu = read("src/disruptor_dev_menu.cpp") + read("src/disruptor_present_rate.cpp") - -rows = re.findall(r'\b(toggle|choice|slider)(?:<[^>]*>)?\(\s*"([\w.]+)", Page::(\w+), L"([^"]+)", (-?\d+)', table) -keys = [key for _, key, _, _, _ in rows] -initial = {key: int(value) for _, key, _, _, value in rows} -require(len(rows) == 27 and len(set(keys)) == 27, "the launcher's table must hold its 27 rows, each key once") +menu = read("src/game/ui/dev_menu.cpp") + read("src/game/frame/present_rate.cpp") + +ROW = r'\b(toggle|choice|slider)(?:<[^>]*>)?\(\s*"([\w.]+)", "([^"]+)", (-?\d+)' +rows = re.findall(ROW, options) +keys = [key for _, key, _, _ in rows] +initial = {key: int(value) for _, key, _, value in rows} +require(len(rows) == 27 and len(set(keys)) == 27, "the shared table must hold its 27 options, each key once") +placed = re.findall(r'^ \{"([\w.]+)", Page::\w+\},$', table, re.MULTILINE) +require( + sorted(placed) == sorted(keys) + and not re.findall(ROW, table) + and ' "${PROJECT_SOURCE_DIR}/src/game/settings/options.cpp")\n' in launcher_cmake + and "std::span layout() { return placed; }\n" in table + and " for (const auto& placement : layout()) {\n const Option& option = *find_option(placement.key);\n" in window, + "the launcher puts each shared option on one page and describes none itself", +) +require( + " return settings::applies(option, [this](const Option& needed) { return shown(needed) != 0; });\n" in table + and "std::any_of(other.needs.begin(), other.needs.end(), named)" in table, + "the launcher greys a row by the shared rule asked of its file, and a toggle switched off takes with it the toggles that need it", +) writer = loader[loader.index("bool save_user_settings(") :] saved, section = set(), "" @@ -65,18 +81,15 @@ def plain_values(text: str) -> dict[str, str]: f"keys listed here are no longer saved: {sorted((NOT_OFFERED | COVERED) - saved)}", ) -for kind, key, _, label, _ in rows: - require(f'"{label}"' in menu, f"the row {key} must be named as the in-game menu names it: {label}") -for label in ( - "Windowed", "Borderless fullscreen", "Exclusive fullscreen", "16:9", "21:9", "32:9", "Match window (up to 32:9)", - "1x (native)", "8x", "Adaptive", "Immediate", "Synchronised", -): - require(f'L"{label}"' in table and f'"{label}"' in menu, f"the choice {label} must read as in the in-game menu") -require('L"Vanilla", L"Improved"' in table and '"Vanilla\\0Improved\\0"' in menu, "the shadow choices must match") +drawn = set(re.findall(r'\boption_(?:toggle|slider|list)\("([\w.]+)"', menu)) | set( + re.findall(r'find_option\("([\w.]+)"\)->label', menu)) +require(drawn == set(keys), f"the in-game menu must draw every row by its key: {sorted(set(keys) ^ drawn)}") +for kind, key, label, _ in rows: + require(f'"{label}"' not in menu, f"the in-game menu must take the name of {key} from the shared table") shipped = plain_values(read("release/windows/settings.toml")) require(not set(shipped) - set(keys), f"the shipped settings.toml sets keys the launcher does not show: {sorted(set(shipped) - set(keys))}") -mouse = read("src/disruptor_mouse_aim.cpp") +mouse = read("src/game/input/mouse_aim.cpp") require( "constexpr double kDefaultHorizontalSensitivity = 0.080;" in mouse and "constexpr double kDefaultVerticalSensitivity = 0.080;" in mouse @@ -91,7 +104,7 @@ def plain_values(text: str) -> dict[str, str]: require( '"${PSXRECOMP_ROOT}/recompiler/src/config_loader.cpp"' in launcher_cmake - and '"${PROJECT_SOURCE_DIR}/src/disruptor_language_disc.cpp"' in launcher_cmake + and '"${PROJECT_SOURCE_DIR}/src/game/language/language_disc.cpp"' in launcher_cmake and " Settings fresh = PSXRecompV4::load_user_settings(path_);" in table and " if (!PSXRecompV4::save_user_settings(path_, fresh)) return false;" in table and " if (!readable_) return false;\n alter(fresh);" in table, @@ -145,17 +158,17 @@ def plain_values(text: str) -> dict[str, str]: "drag and drop, the per-monitor layout and the log tail must stay", ) require( - UTF8_PAGE in read("src/disruptor.manifest") and UTF8_PAGE in read("src/launcher/launcher.manifest") - and '1 RT_MANIFEST "disruptor.manifest"' in read("src/disruptor.rc") - and 'target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor.manifest")' in cmake - and 'target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor.rc")' in cmake, + UTF8_PAGE in read("src/game/disruptor.manifest") and UTF8_PAGE in read("src/launcher/launcher.manifest") + and '1 RT_MANIFEST "disruptor.manifest"' in read("src/game/disruptor.rc") + and 'target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/game/disruptor.manifest")' in cmake + and 'target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/game/disruptor.rc")' in cmake, "the game and the launcher must both run with UTF-8 as their code page, or a path outside it cannot be named", ) require( "add_test(NAME disruptor_launcher_settings COMMAND disruptor-launcher-settings-test)" in launcher_cmake and "add_test(NAME disruptor_launcher_flow COMMAND disruptor-launcher-flow-test)" in launcher_cmake and "add_test(NAME disruptor_region_disc COMMAND disruptor-region-disc-test)" in launcher_cmake - and "add_test(NAME disruptor_launcher_contract" in cmake, + and " disruptor_contract(launcher)\n" in cmake, "the launcher's tests must remain registered", ) diff --git a/tests/test_loaded_state_contract.py b/tests/contracts/test_loaded_state_contract.py similarity index 97% rename from tests/test_loaded_state_contract.py rename to tests/contracts/test_loaded_state_contract.py index 103b84f..742b49e 100644 --- a/tests/test_loaded_state_contract.py +++ b/tests/contracts/test_loaded_state_contract.py @@ -7,7 +7,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -24,8 +24,8 @@ def require(condition: bool, message: str) -> None: state = read("psxrecomp-overlay/runtime/src/boot_state.c") cycles = read("psxrecomp-overlay/runtime/include/psx_cyc.h") latch = read("psxrecomp/runtime/src/fntrace.c") -menu = read("src/disruptor_dev_menu.cpp") -rate = read("src/disruptor_frame_rate.cpp") +menu = read("src/game/ui/dev_menu.cpp") +rate = read("src/game/frame/frame_rate.cpp") require( "static void engage_widescreen_at_game_entry(void) {\n" diff --git a/tests/test_menu_wide_contract.py b/tests/contracts/test_menu_wide_contract.py similarity index 98% rename from tests/test_menu_wide_contract.py rename to tests/contracts/test_menu_wide_contract.py index fc55076..33bd4dd 100644 --- a/tests/test_menu_wide_contract.py +++ b/tests/contracts/test_menu_wide_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -194,8 +194,8 @@ def require(condition: bool, message: str) -> None: ) require( "+runtime/include/gpu_ws_menu.h" in read("PSXRECOMP_OVERLAY_FILES.txt").splitlines() - and " add_test(NAME disruptor_gpu_ws_menu\n COMMAND disruptor-gpu-ws-menu-test)\n" in cmake - and 'tests/test_menu_wide_contract.py")' in cmake, + and " disruptor_unit_test(disruptor_gpu_ws_menu " in cmake + and " disruptor_contract(menu_wide)\n" in cmake, "the header is part of the overlay and its tests remain registered", ) diff --git a/tests/test_name_entry_contract.py b/tests/contracts/test_name_entry_contract.py similarity index 95% rename from tests/test_name_entry_contract.py rename to tests/contracts/test_name_entry_contract.py index c8f7a76..ccb38e7 100644 --- a/tests/test_name_entry_contract.py +++ b/tests/contracts/test_name_entry_contract.py @@ -5,7 +5,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 ROUTINE = 0x8001C484 @@ -93,16 +93,14 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -module = read("src/disruptor_name_entry.cpp") +module = read("src/game/input/name_entry.cpp") host = read("psxrecomp-overlay/runtime/src/main.cpp") require( - ' "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_name_entry.cpp"\n' in cmake + ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/name_entry.cpp"\n' in cmake and " PSX_HAS_DISRUPTOR_NAME_ENTRY=1\n" in cmake - and " add_test(NAME disruptor_name_entry COMMAND disruptor-name-entry-test)\n" in cmake - and 'tests/test_name_entry_contract.py"\n --require-artifacts)\n' - " set_tests_properties(disruptor_name_entry_contract\n PROPERTIES LABELS retail)\n" - in cmake, + and " disruptor_unit_test(disruptor_name_entry " in cmake + and " disruptor_contract(name_entry RETAIL)\n" in cmake, "the module, its switch for the host, its unit test and this contract with its input must stay registered", ) require( diff --git a/tests/test_perspective_texture_contract.py b/tests/contracts/test_perspective_texture_contract.py similarity index 99% rename from tests/test_perspective_texture_contract.py rename to tests/contracts/test_perspective_texture_contract.py index 8d5ca25..81af699 100644 --- a/tests/test_perspective_texture_contract.py +++ b/tests/contracts/test_perspective_texture_contract.py @@ -13,7 +13,7 @@ from math import isclose -ROOT = pathlib.Path(__file__).resolve().parents[1] +ROOT = pathlib.Path(__file__).resolve().parents[2] OVERLAY = ROOT / "psxrecomp-overlay" / "runtime" MAIN = OVERLAY / "src" / "main.cpp" GPU = OVERLAY / "src" / "gpu.c" diff --git a/tests/test_phase_a_contract.py b/tests/contracts/test_phase_a_contract.py similarity index 98% rename from tests/test_phase_a_contract.py rename to tests/contracts/test_phase_a_contract.py index e9411e4..94de2be 100644 --- a/tests/test_phase_a_contract.py +++ b/tests/contracts/test_phase_a_contract.py @@ -8,7 +8,7 @@ import sys -ROOT = pathlib.Path(__file__).resolve().parents[1] +ROOT = pathlib.Path(__file__).resolve().parents[2] GPU = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "gpu.c" GTE = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "gte.cpp" GL = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "gpu_gl_renderer.c" @@ -16,7 +16,7 @@ GTE_HEADER = ( ROOT / "psxrecomp-overlay" / "runtime" / "include" / "gte_precision.h" ) -MOUSE = ROOT / "src" / "disruptor_mouse_aim.cpp" +MOUSE = ROOT / "src" / "game" / "input" / "mouse_aim.cpp" OVERLAY_LIST = ROOT / "PSXRECOMP_OVERLAY_FILES.txt" diff --git a/tests/test_pinned_corner_contract.py b/tests/contracts/test_pinned_corner_contract.py similarity index 93% rename from tests/test_pinned_corner_contract.py rename to tests/contracts/test_pinned_corner_contract.py index e4b6dcd..8defb02 100644 --- a/tests/test_pinned_corner_contract.py +++ b/tests/contracts/test_pinned_corner_contract.py @@ -4,7 +4,7 @@ import re from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -50,17 +50,8 @@ def enclosing(source: str, block: str) -> list[str]: temporal = read("psxrecomp-overlay/runtime/src/gpu_gl_temporal.c.inc") mesh = read("psxrecomp-overlay/runtime/include/gpu_temporal_mesh.h") -REGISTRATION = ( - ' add_test(NAME disruptor_pinned_corner_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' - ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_pinned_corner_contract.py")\n' -) -UNIT_TEST = ( - " add_executable(disruptor-gpu-pinned-corner-test\n tests/gpu_pinned_corner_test.cpp)\n" - " target_include_directories(disruptor-gpu-pinned-corner-test PRIVATE\n" - ' "${PSXRECOMP_ROOT}/runtime/include")\n' - " target_compile_features(disruptor-gpu-pinned-corner-test PRIVATE cxx_std_20)\n" - " add_test(NAME disruptor_gpu_pinned_corner\n COMMAND disruptor-gpu-pinned-corner-test)\n" -) +REGISTRATION = " disruptor_contract(pinned_corner)\n" +UNIT_TEST = " disruptor_unit_test(disruptor_gpu_pinned_corner tests/unit/gpu_pinned_corner_test.cpp RUNTIME_HEADERS)\n" require( cmake.count(REGISTRATION) == 1 and enclosing(cmake, REGISTRATION) == ["if(BUILD_TESTING)"], "this contract must stay registered as written, under the condition every contract of the repository is under", diff --git a/tests/contracts/test_save_point_contract.py b/tests/contracts/test_save_point_contract.py new file mode 100644 index 0000000..a7cc45d --- /dev/null +++ b/tests/contracts/test_save_point_contract.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Source contract: a state is saved or loaded only at an interrupt check that was given its own resume address. + +A check made without one (the RAM interpreter's are) left the compiled latch as an earlier call had it, and a state +saved there kept that address with the registers of other code: one save of 315 in the measured runs. +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +POLL = "savestate_poll(cpu, s_compiled_interrupt_resume_pc);" + + +def read(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def between(source: str, opening: str, closing: str) -> str: + start = source.index(opening) + return source[start : source.index(closing, start)] + + +def polls_are_the_four(source: str) -> bool: + """The name is in the file six times: its declaration, the four polls and one comment of the framework's.""" + return len(re.findall(r"\bsavestate_poll\b", source)) == 6 and source.count(POLL) == 4 + + +cmake = read("CMakeLists.txt") +manifest = read("PSXRECOMP_OVERLAY_FILES.txt").splitlines() +interrupts = read("psxrecomp-overlay/runtime/src/interrupts.c") +rule = read("psxrecomp-overlay/runtime/include/save_point.h") + +require( + " disruptor_contract(save_point)\n" in cmake + and " disruptor_unit_test(disruptor_save_point " in cmake, + "this contract and the rule's unit test are registered", +) +require( + "runtime/src/interrupts.c" in manifest and "+runtime/include/save_point.h" in manifest, + "the overlay replaces the framework's interrupt check and adds the rule's header", +) + +require( + "int savestate_pending(void);\n" in rule + and "static inline int psx_save_poll_due(int periodic, int save_point, int *owed) {\n" + " if (!save_point) {\n" + " if (periodic) *owed = savestate_pending();\n" + " return 0;\n" + " }\n" + " if (!periodic && !*owed) return 0;\n" + " *owed = 0;\n" + " return 1;\n" + "}\n" + in rule, + "a check without the mark never polls and owes a periodic poll that met a request, a marked one polls when " + "periodic or owed", +) + +require( + '#include "save_point.h"\n' in interrupts + and "static uint32_t s_compiled_interrupt_resume_pc = 0;\n" in interrupts + and "static int s_resume_pc_given = 0;\n" in interrupts + and len(re.findall(r"\bs_resume_pc_given\b", interrupts)) == 4 + and interrupts.count("s_resume_pc_given = 1;") == 1 + and interrupts.count("s_resume_pc_given = 0;") == 2, + "the mark is declared off, set in one place, taken in one place and read nowhere else", +) + +require( + "void psx_check_interrupts(CPUState* cpu) {\n" + " const int save_point = s_resume_pc_given;\n" + " s_resume_pc_given = 0;\n" in interrupts, + "a check takes the mark before anything else, so a check that leaves by a long jump cannot pass it on", +) + +given = between(interrupts, "void psx_check_interrupts_at(CPUState* cpu, uint32_t resume_pc) {", "\n}\n") +require( + " uint32_t prev = s_compiled_interrupt_resume_pc;\n" + " s_compiled_interrupt_resume_pc = resume_pc;\n" + " s_resume_pc_given = 1;\n" + " psx_check_interrupts(cpu);" + in given + and given.rstrip().endswith("s_compiled_interrupt_resume_pc = prev;"), + "the mark is set with the address, right before the one call it is for", +) + +check = between(interrupts, "void psx_check_interrupts(CPUState* cpu) {", "\nvoid psx_check_interrupts_at(") +fast = ( + " const int periodic = (++s_fast_maintenance & 0x3FFFu) == 0;\n" + " if (psx_save_poll_due(periodic, save_point, &s_save_poll_owed))\n" + f" {POLL}\n" + " if (periodic) debug_server_poll();\n" + " return;\n" +) +middle = ( + " const int periodic = (total_checks & 0x3FFFu) == 0;\n" + " if (psx_save_poll_due(periodic, save_point, &s_save_poll_owed))\n" + f" {POLL}\n" + " if (periodic) debug_server_poll();\n" + " goto irq_deliver_eval;\n" +) +last = f" if (!in_exception && psx_save_poll_due(1, save_point, &s_save_poll_owed))\n {POLL}\n" +require( + check.count(fast) == 2 and check.count(middle) == 1 and check.count(last) == 1, + "the two fast paths and the middle path poll periodically, the end of the check at every check outside an exception", +) +require( + polls_are_the_four(interrupts) + and check.count(POLL) == 4 + and len(re.findall(r"\bpsx_save_poll_due\b", interrupts)) == 4 + and len(re.findall(r"\bsave_point\b", check)) == 5 + and len(re.findall(r"\bs_save_poll_owed\b", interrupts)) == 5 + and "static int s_save_poll_owed = 0;\n" in interrupts + and "savestate_pending" not in interrupts, + "every poll goes through the rule with this check's mark and passes the address this check was given", +) +flushed = " s_resume_pc_given = 0;\n psx_cyc_batch_flush();\n" +require( + interrupts.count(flushed) == 1 + and not polls_are_the_four( + interrupts.replace(flushed, flushed + " savestate_poll (cpu, s_compiled_interrupt_resume_pc);\n") + ) + and not polls_are_the_four(interrupts.replace(flushed, flushed + " savestate_poll(cpu, check_pc);\n")), + "a fifth poll is seen however it is spelled", +) + +print("Disruptor save point source contract: PASS") diff --git a/tests/test_smooth_turn_contract.py b/tests/contracts/test_smooth_turn_contract.py similarity index 69% rename from tests/test_smooth_turn_contract.py rename to tests/contracts/test_smooth_turn_contract.py index 0f37728..f182d04 100644 --- a/tests/test_smooth_turn_contract.py +++ b/tests/contracts/test_smooth_turn_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -24,16 +24,28 @@ def between(source: str, opening: str, closing: str) -> str: overlay = read("PSXRECOMP_OVERLAY_FILES.txt") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") -mouse = read("src/disruptor_mouse_aim.cpp") -menu = read("src/disruptor_dev_menu.cpp") +mouse = read("src/game/input/mouse_aim.cpp") +menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") +options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) require( overlay.count("+runtime/include/gpu_turned_perspective.h\n") == 1 and '#include "gpu_turned_perspective.h"\n' in renderer - and " add_executable(disruptor-gpu-turned-perspective-test\n tests/gpu_turned_perspective_test.cpp)\n" in cmake - and " add_test(NAME disruptor_gpu_turned_perspective\n COMMAND disruptor-gpu-turned-perspective-test)\n" in cmake - and " add_test(NAME disruptor_smooth_turn_contract\n" in cmake, + and " disruptor_unit_test(disruptor_gpu_turned_perspective tests/unit/gpu_turned_perspective_test.cpp RUNTIME_HEADERS)\n" + in cmake + and " disruptor_contract(smooth_turn)\n" in cmake, "the turned perspective's header is applied to the framework and its test and this contract are registered", ) @@ -74,16 +86,16 @@ def between(source: str, opening: str, closing: str) -> str: controls = between(menu, "void draw_controls_tab() {", "void apply_geometry_enabled(") require( - """ const bool turn_applies = mouse_aim && gpu_geometry_correction_enabled() != 0; + """ const bool turn_applies = applies("disruptor.high_precision_camera"); bool smooth = disruptor_high_precision_camera_enabled() != 0; if (!turn_applies) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Smooth mouse turning", &smooth)) { + if (option_toggle("disruptor.high_precision_camera", &smooth)) { apply_smooth_turn(smooth); mark_smooth_turn(smooth); } if (!turn_applies) ImGui::EndDisabled(); """ in controls - and menu.count('"Smooth mouse turning"') == 1 + and menu.count('option_toggle("disruptor.high_precision_camera"') == 1 and controls.count("ImGui::BeginDisabled()") == controls.count("ImGui::EndDisabled()"), "the setting is on the Controls tab alone, greyed without mouse aim or geometry correction, applied and staged when changed", ) @@ -94,35 +106,30 @@ def between(source: str, opening: str, closing: str) -> str: if (!env_override_present("PSX_GEOMETRY_FULL_YAW")) gl_renderer_set_full_yaw(enabled ? 1 : 0); } """ in menu - and """void mark_smooth_turn(bool value) { - g_preferences.pending.has_high_precision_camera = true; - g_preferences.pending.high_precision_camera = value; - g_preferences.dirty |= PREF_SMOOTH_TURN; -} -""" in menu - and """ if (g_preferences.dirty & PREF_SMOOTH_TURN) { - settings.has_high_precision_camera = true; - settings.high_precision_camera = pending.high_precision_camera; - } -""" in menu + and 'void mark_smooth_turn(bool value) { mark("disruptor.high_precision_camera", value); }\n' in menu + and STAGED in menu + and MERGED in menu + and ' toggle<&Settings::has_high_precision_camera, &Settings::high_precision_camera>(\n "disruptor.high_precision_camera",' in options and """ if (const char *forced = std::getenv("PSX_DISRUPTOR_HIGH_PRECISION_CAMERA")) apply_smooth_turn(forced[0] != '\\0' && forced[0] != '0'); else if (settings.has_high_precision_camera) apply_smooth_turn(settings.high_precision_camera); """ in menu - and """ if (g_preferences.dirty & PREF_SMOOTH_TURN) + and """ if (changed("disruptor.high_precision_camera")) apply_smooth_turn(pending.high_precision_camera); """ in menu, - "a change switches the mouse module and the renderer's full turn together, is staged under a bit of its own, merged " + "a change switches the mouse module and the renderer's full turn together, is staged as an option of the shared table, merged " "into the file's settings, applied at the next start with the environment first, and applied when another run saved it", ) -row = between(launcher, '"disruptor.high_precision_camera", Page::controls, L"Smooth mouse turning", 0,', "),\n") +row = between(options, '"disruptor.high_precision_camera", "Smooth mouse turning", 0,', "),\n") require( - row.endswith("aim_key") - and 'constexpr const char* aim_key = "disruptor.mouse_aim";\n' in launcher - and launcher.count('"disruptor.high_precision_camera"') == 1, - "the launcher offers it on the Controls page, off at first and only with mouse aim", + row.endswith("needs_aim_and_geometry") + and "constexpr const char* needs_aim_and_geometry[] = {aim_key, geometry_key};\n" in options + and 'constexpr const char* aim_key = "disruptor.mouse_aim";\n' in options + and options.count('"disruptor.high_precision_camera"') == launcher.count('"disruptor.high_precision_camera"') == 1 + and ' {"disruptor.high_precision_camera", Page::controls},\n' in launcher, + "the launcher offers it on the Controls page, off at first and only with mouse aim and geometry correction", ) print("Disruptor smooth mouse turning source contract: PASS") diff --git a/tests/test_sprite_range_contract.py b/tests/contracts/test_sprite_range_contract.py similarity index 97% rename from tests/test_sprite_range_contract.py rename to tests/contracts/test_sprite_range_contract.py index 2ce1ece..997114e 100644 --- a/tests/test_sprite_range_contract.py +++ b/tests/contracts/test_sprite_range_contract.py @@ -6,7 +6,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -183,13 +183,12 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_sprite_depth.cpp") +source = read("src/game/render/sprite_depth.cpp") gte = read("psxrecomp-overlay/runtime/src/gte.cpp") require( - 'tests/test_sprite_range_contract.py"\n --require-artifacts)' in cmake - and " set_tests_properties(disruptor_sprite_range_contract\n PROPERTIES LABELS retail)\n" in cmake - and " add_test(NAME disruptor_sprite_depth\n" in cmake, + " disruptor_contract(sprite_range RETAIL)\n" in cmake + and " disruptor_unit_test(disruptor_sprite_depth " in cmake, "this contract with its input and the unit test of the range must stay registered", ) require( diff --git a/tests/test_temporal_presenter_contract.py b/tests/contracts/test_temporal_presenter_contract.py similarity index 98% rename from tests/test_temporal_presenter_contract.py rename to tests/contracts/test_temporal_presenter_contract.py index 975af03..9f77a83 100644 --- a/tests/test_temporal_presenter_contract.py +++ b/tests/contracts/test_temporal_presenter_contract.py @@ -4,7 +4,7 @@ import re from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -39,10 +39,10 @@ def shader(source: str, name: str) -> str: temporal = read("psxrecomp-overlay/runtime/src/gpu_gl_temporal.c.inc") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") -billboards = read("src/disruptor_billboard_aspect.cpp") +billboards = read("src/game/render/billboard_aspect.cpp") require( - "disruptor_temporal_presenter_contract" in cmake and "PSX_HAS_DISRUPTOR_SPRITE_DEPTH=1" in cmake, + " disruptor_contract(temporal_presenter)\n" in cmake and "PSX_HAS_DISRUPTOR_SPRITE_DEPTH=1" in cmake, "the presenter contract and the sprite-depth interpreter switch must remain registered", ) @@ -229,7 +229,7 @@ def shader(source: str, name: str) -> str: " squashed ? (float)ws_xnum / (float)ws_xden : 1.0f, gpu_shadow_shape(), sides);" in sides and '#include "gpu_temporal_sprite.h"' in gpu and "static inline void gpu_temporal_sprite_sides(const GpuTemporalSprite *sprite, float left, float top, int w, int h," in sprite_header - and "tests/gpu_temporal_sprite_test.cpp" in read("CMakeLists.txt") + and "tests/unit/gpu_temporal_sprite_test.cpp" in read("CMakeLists.txt") and "+runtime/include/gpu_temporal_sprite.h" in read("PSXRECOMP_OVERLAY_FILES.txt") and " if (!t || t->stamp != (uint32_t)s_frame_count || !t->placed || width16 <= 0 || height16 <= 0) return;" in sizer and " t->placed = t->sized = t->shadow = t->whole = 0;" in gpu, diff --git a/tests/contracts/test_test_registration_contract.py b/tests/contracts/test_test_registration_contract.py new file mode 100644 index 0000000..3687686 --- /dev/null +++ b/tests/contracts/test_test_registration_contract.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Source contract: every contract and every unit test in the tree is registered once, through the two helper functions. + +A contract or a test file that nothing registers is never run, and nothing else would say so. +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +ARTIFACTS = "--require-" + "artifacts" +EXPLICIT = { + "test_borderless_contract.py": "host_borderless_contract", + "test_framework_overlay_new_file.py": "disruptor_framework_overlay_new_file", +} +CONTRACT_HELPER = """function(disruptor_contract name) + cmake_parse_arguments(PARSE_ARGV 1 CONTRACT "RETAIL" "" "") + if(CONTRACT_UNPARSED_ARGUMENTS) + message(FATAL_ERROR "disruptor_contract(${name}): unknown arguments ${CONTRACT_UNPARSED_ARGUMENTS}") + endif() + set(script "${PROJECT_SOURCE_DIR}/tests/contracts/test_${name}_contract.py") + if(NOT EXISTS "${script}") + message(FATAL_ERROR "disruptor_contract(${name}): ${script} does not exist") + endif() + set(arguments "") + if(CONTRACT_RETAIL) + set(arguments --require-artifacts) + endif() + add_test(NAME disruptor_${name}_contract + COMMAND "${Python3_EXECUTABLE}" -B "${script}" ${arguments}) + if(CONTRACT_RETAIL) + set_tests_properties(disruptor_${name}_contract PROPERTIES LABELS retail) + endif() +endfunction() +""" +UNIT_HELPER = """function(disruptor_unit_test test source) + cmake_parse_arguments(PARSE_ARGV 2 UNIT "RUNTIME_HEADERS" "TARGET" + "SOURCES;INCLUDES;DEFINITIONS;ARGUMENTS") + if(UNIT_UNPARSED_ARGUMENTS) + message(FATAL_ERROR "disruptor_unit_test(${test}): unknown arguments ${UNIT_UNPARSED_ARGUMENTS}") + endif() + set(target "${UNIT_TARGET}") + if(NOT target) + string(REPLACE "_" "-" target "${test}-test") + endif() + add_executable(${target} "${PROJECT_SOURCE_DIR}/${source}" ${UNIT_SOURCES}) + if(UNIT_RUNTIME_HEADERS) + target_include_directories(${target} PRIVATE "${PSXRECOMP_ROOT}/runtime/include") + endif() + if(UNIT_INCLUDES) + target_include_directories(${target} PRIVATE ${UNIT_INCLUDES}) + endif() + if(UNIT_DEFINITIONS) + target_compile_definitions(${target} PRIVATE ${UNIT_DEFINITIONS}) + endif() + target_compile_features(${target} PRIVATE cxx_std_20) + add_test(NAME ${test} COMMAND ${target} ${UNIT_ARGUMENTS}) +endfunction() +""" + + +def read(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def contract_faults(cmake: str, scripts: dict[str, bool]) -> list[str]: + """What is wrong with how the build file registers these contract files, each given with whether it takes artifacts.""" + faults = [] + for script, retail in sorted(scripts.items()): + if script in EXPLICIT: + names = cmake.count(f" add_test(NAME {EXPLICIT[script]}\n") + runs = cmake.count(f"/tests/contracts/{script}") + if names != 1 or runs != 1: + faults.append(f"{script} is named {names} times and run {runs} times, not once each") + continue + name = re.fullmatch(r"test_(\w+)_contract\.py", script) + if name is None: + faults.append(f"{script} is not named as a contract") + continue + plain = cmake.count(f" disruptor_contract({name.group(1)})\n") + marked = cmake.count(f" disruptor_contract({name.group(1)} RETAIL)\n") + if (plain, marked) != ((0, 1) if retail else (1, 0)): + faults.append(f"{script} is registered {plain} times plainly and {marked} times as retail") + if f"/tests/contracts/{script}" in cmake: + faults.append(f"{script} is also run by name") + registered = re.findall(r"^\s*disruptor_contract\((\w+)", cmake, re.MULTILINE) + faults += [ + f"{name} is registered and has no file" for name in registered if f"test_{name}_contract.py" not in scripts + ] + return faults + + +def unit_faults(builds: str, cmake: str, sources: list[str]) -> list[str]: + """What is wrong with how the build files register these unit test files.""" + faults = [f"{source} is built {builds.count(source)} times" for source in sources if builds.count(source) != 1] + tail = cmake.split("include(CTest)")[1] + units = re.findall(r"^\s*disruptor_unit_test\((\w+) (tests/unit/\w+\.cpp)", tail, re.MULTILINE) + tests = [test for test, _ in units] + faults += [f"{test} is registered twice" for test in sorted(set(tests)) if tests.count(test) > 1] + faults += [f"{source} has no file" for _, source in units if source not in sources] + if "add_executable(" in tail: + faults.append("an executable is built past the helper") + return faults + + +cmake = read("CMakeLists.txt") +launcher_cmake = read("src/launcher/CMakeLists.txt") +helpers = read("cmake/DisruptorTests.cmake") + +require( + ' include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/DisruptorTests.cmake")\n' in cmake + and cmake.index("find_package(Python3 REQUIRED COMPONENTS Interpreter)") < cmake.index("cmake/DisruptorTests.cmake") + and cmake.index("cmake/DisruptorTests.cmake") < cmake.index("disruptor_contract("), + "the helpers are loaded after Python is found and before the first registration", +) +require( + helpers.count(CONTRACT_HELPER) == 1 and helpers.count("\nfunction(") == 2, + "a contract runs its own file, and a retail one is told to require its artifacts and is labelled retail", +) +require( + helpers.count(UNIT_HELPER) == 1, + "a unit test is one executable named after its test, built with what it was given and always run under that name", +) + +scripts = { + path.name: ARTIFACTS in path.read_text(encoding="utf-8") + for path in (ROOT / "tests/contracts").glob("test_*.py") + if path.name != Path(__file__).name +} +scripts[Path(__file__).name] = False +require(contract_faults(cmake, scripts) == [], "; ".join(contract_faults(cmake, scripts))) +require(sum(scripts.values()) == 8, "eight contracts take the retail artifacts") + +sources = sorted(f"tests/unit/{path.name}" for path in (ROOT / "tests/unit").glob("*.cpp")) +require( + unit_faults(cmake + launcher_cmake, cmake, sources) == [], + "; ".join(unit_faults(cmake + launcher_cmake, cmake, sources)), +) + +plain_line, retail_line = " disruptor_contract(depth24_sync)\n", " disruptor_contract(name_entry RETAIL)\n" +again = ' add_test(NAME host_borderless_again\n COMMAND x "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_borderless_contract.py")\n' +unit_line = " disruptor_unit_test(host_pick_slot tests/unit/host_pick_slot_test.cpp)\n" +require( + cmake.count(plain_line) == cmake.count(retail_line) == cmake.count(unit_line) == 1 + and contract_faults(cmake.replace(plain_line, ""), scripts) != [] + and contract_faults(cmake.replace(plain_line, plain_line * 2), scripts) != [] + and contract_faults(cmake.replace(plain_line, " disruptor_contract(depth24_sync RETAIL)\n"), scripts) != [] + and contract_faults(cmake.replace(retail_line, " disruptor_contract(name_entry)\n"), scripts) != [] + and contract_faults(cmake.replace(plain_line, plain_line + " disruptor_contract(no_such)\n"), scripts) != [] + and contract_faults(cmake.replace(plain_line, plain_line + again), scripts) != [] + and contract_faults(cmake, scripts | {"test_new_contract.py": False}) != [] + and unit_faults((cmake + launcher_cmake).replace(unit_line, ""), cmake.replace(unit_line, ""), sources) != [] + and unit_faults(cmake + launcher_cmake + unit_line, cmake + unit_line, sources) != [] + and unit_faults(cmake + launcher_cmake, cmake, [*sources, "tests/unit/new_test.cpp"]) != [] + and unit_faults(cmake + launcher_cmake, cmake + " add_executable(extra x.cpp)\n", sources) != [], + "a missing, doubled, mislabelled or fileless registration is seen, in the contracts and in the unit tests", +) + +print("Disruptor test registration source contract: PASS") diff --git a/tests/test_vertical_camera_contract.py b/tests/contracts/test_vertical_camera_contract.py similarity index 93% rename from tests/test_vertical_camera_contract.py rename to tests/contracts/test_vertical_camera_contract.py index 4695e11..66a3e8a 100644 --- a/tests/test_vertical_camera_contract.py +++ b/tests/contracts/test_vertical_camera_contract.py @@ -6,7 +6,7 @@ import sys -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -19,9 +19,9 @@ def require(condition: bool, message: str) -> None: raise SystemExit(1) -camera = read("src/disruptor_vertical_camera.cpp") -camera_h = read("src/disruptor_vertical_camera.h") -mouse = read("src/disruptor_mouse_aim.cpp") +camera = read("src/game/input/vertical_camera.cpp") +camera_h = read("src/game/input/vertical_camera.h") +mouse = read("src/game/input/mouse_aim.cpp") codegen = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") dirty = read("psxrecomp-overlay/runtime/src/dirty_ram_interp.c") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") @@ -84,7 +84,7 @@ def require(condition: bool, message: str) -> None: require("gpu_geometry_camera_projection_center_y_set" in camera and "gpu_geometry_camera_projection_center_y_set" in gpu, "guest and presentation horizons must share one renderer setter") -require("disruptor-vertical-camera-test" in cmake and +require(" disruptor_unit_test(disruptor_vertical_camera " in cmake and "disruptor_vertical_look_registration" in cmake, "behavior and vertical-only registration tests must remain enabled") require("disruptor_vertical_camera_instruction_hook" in camera_h, diff --git a/tests/test_world_depth_contract.py b/tests/contracts/test_world_depth_contract.py similarity index 77% rename from tests/test_world_depth_contract.py rename to tests/contracts/test_world_depth_contract.py index 9934c50..58adbfc 100644 --- a/tests/test_world_depth_contract.py +++ b/tests/contracts/test_world_depth_contract.py @@ -1,10 +1,9 @@ #!/usr/bin/env python3 """Source contract for the depth-sorted world: its rule's header, the renderer's two passes and the setting.""" -import re from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -26,16 +25,27 @@ def between(source: str, opening: str, closing: str) -> str: header = read("psxrecomp-overlay/runtime/include/gpu_world_depth.h") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") presenter = read("psxrecomp-overlay/runtime/src/gpu_gl_temporal.c.inc") -menu = read("src/disruptor_dev_menu.cpp") +menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") +options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") require( overlay.count("+runtime/include/gpu_world_depth.h\n") == 1 and '#include "gpu_world_depth.h"\n' in renderer - and " add_executable(disruptor-gpu-world-depth-test\n tests/gpu_world_depth_test.cpp)\n" in cmake - and " add_test(NAME disruptor_gpu_world_depth\n COMMAND disruptor-gpu-world-depth-test)\n" in cmake - and " add_test(NAME disruptor_world_depth_contract\n" in cmake, + and " disruptor_unit_test(disruptor_gpu_world_depth tests/unit/gpu_world_depth_test.cpp RUNTIME_HEADERS)\n" in cmake + and " disruptor_contract(world_depth)\n" in cmake, "the rule's header is applied to the framework and its test and this contract are registered", ) @@ -111,39 +121,32 @@ def between(source: str, opening: str, closing: str) -> str: enhancements = between(menu, "void draw_enhancements_tab() {", "const char *cheat_result_message(") require( """ bool by_depth = gl_renderer_world_depth() != 0; - if (!geometry) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Depth-sorted world", &by_depth)) { + const bool depth_applies = applies("disruptor.depth_sorted_world"); + if (!depth_applies) ImGui::BeginDisabled(); + if (option_toggle("disruptor.depth_sorted_world", &by_depth)) { gl_renderer_set_world_depth(by_depth ? 1 : 0); mark_world_depth(by_depth); } - if (!geometry) ImGui::EndDisabled(); + if (!depth_applies) ImGui::EndDisabled(); """ in enhancements - and menu.count('"Depth-sorted world"') == 1 + and menu.count('option_toggle("disruptor.depth_sorted_world"') == 1 and enhancements.count("ImGui::BeginDisabled()") == enhancements.count("ImGui::EndDisabled()"), "the setting is on the Enhancements tab alone, greyed without geometry correction, applied and staged when changed", ) -bits = re.findall(r"PREF_(\w+)\s*= 1u << (\d+),", menu) require( - len({number for _, number in bits}) == len(bits) - and """void mark_world_depth(bool value) { - g_preferences.pending.has_depth_sorted_world = true; - g_preferences.pending.depth_sorted_world = value; - g_preferences.dirty |= PREF_WORLD_DEPTH; -} -""" in menu - and """ if (g_preferences.dirty & PREF_WORLD_DEPTH) { - settings.has_depth_sorted_world = true; - settings.depth_sorted_world = pending.depth_sorted_world; - } -""" in menu + " std::set changed;\n" in menu + and 'void mark_world_depth(bool value) { mark("disruptor.depth_sorted_world", value); }\n' in menu + and STAGED in menu + and MERGED in menu + and ' toggle<&Settings::has_depth_sorted_world, &Settings::depth_sorted_world>(\n "disruptor.depth_sorted_world",' in options and """ if (settings.has_depth_sorted_world && !env_override_present("PSX_GL_WORLD_DEPTH")) gl_renderer_set_world_depth(settings.depth_sorted_world ? 1 : 0); """ in menu - and """ if (g_preferences.dirty & PREF_WORLD_DEPTH) + and """ if (changed("disruptor.depth_sorted_world")) gl_renderer_set_world_depth(pending.depth_sorted_world ? 1 : 0); """ in menu, - "a change is staged under a bit of its own, merged into the file's settings, applied at the next start unless the " + "a change is staged as an option of the shared table, merged into the file's settings, applied at the next start unless the " "environment decides, and applied when another run saved it", ) @@ -161,9 +164,12 @@ def between(source: str, opening: str, closing: str) -> str: "the settings file keeps the choice, which is off until made", ) -row = between(launcher, '"disruptor.depth_sorted_world", Page::enhancements, L"Depth-sorted world", 0,', "),\n") +row = between(options, '"disruptor.depth_sorted_world", "Depth-sorted world", 0,', "),\n") require( - row.endswith("geometry_key") and launcher.count('"disruptor.depth_sorted_world"') == 1, + row.endswith("needs_geometry") + and "constexpr const char* needs_geometry[] = {geometry_key};\n" in options + and options.count('"disruptor.depth_sorted_world"') == launcher.count('"disruptor.depth_sorted_world"') == 1 + and ' {"disruptor.depth_sorted_world", Page::enhancements},\n' in launcher, "the launcher offers it on the Enhancements page, off at first and only with geometry correction", ) diff --git a/tests/disruptor_language_discs.h b/tests/support/disruptor_language_discs.h similarity index 99% rename from tests/disruptor_language_discs.h rename to tests/support/disruptor_language_discs.h index e386c53..4f715e4 100644 --- a/tests/disruptor_language_discs.h +++ b/tests/support/disruptor_language_discs.h @@ -3,7 +3,7 @@ /* Synthetic discs for the language tests. The disc code is included whole, so that a test reaches its internals. */ -#include "../src/disruptor_language_disc.cpp" +#include "../../src/game/language/language_disc.cpp" namespace { diff --git a/tests/present_rate_fakes/host_ui.h b/tests/support/present_rate_fakes/host_ui.h similarity index 100% rename from tests/present_rate_fakes/host_ui.h rename to tests/support/present_rate_fakes/host_ui.h diff --git a/tests/present_rate_fakes/imgui.h b/tests/support/present_rate_fakes/imgui.h similarity index 72% rename from tests/present_rate_fakes/imgui.h rename to tests/support/present_rate_fakes/imgui.h index 7c3725b..364c2dc 100644 --- a/tests/present_rate_fakes/imgui.h +++ b/tests/support/present_rate_fakes/imgui.h @@ -3,10 +3,16 @@ enum { ImGuiSliderFlags_AlwaysClamp = 16 }; namespace ImGui { void BeginDisabled(); +bool BeginItemTooltip(); bool Checkbox(const char *label, bool *value); void EndDisabled(); +void EndTooltip(); +float GetFontSize(); +void PopTextWrapPos(); +void PushTextWrapPos(float position); void SetNextItemWidth(float width); bool SliderInt(const char *label, int *value, int lowest, int highest, const char *format, int flags); void TextDisabled(const char *text, ...); +void TextUnformatted(const char *text); } // namespace ImGui #endif diff --git a/tests/stubs/psx_sdl.h b/tests/support/stubs/psx_sdl.h similarity index 100% rename from tests/stubs/psx_sdl.h rename to tests/support/stubs/psx_sdl.h diff --git a/tests/disc_import_test.cpp b/tests/unit/disc_import_test.cpp similarity index 100% rename from tests/disc_import_test.cpp rename to tests/unit/disc_import_test.cpp diff --git a/tests/disruptor_backdrop_tiles_test.cpp b/tests/unit/disruptor_backdrop_tiles_test.cpp similarity index 99% rename from tests/disruptor_backdrop_tiles_test.cpp rename to tests/unit/disruptor_backdrop_tiles_test.cpp index 3a7aba1..309df41 100644 --- a/tests/disruptor_backdrop_tiles_test.cpp +++ b/tests/unit/disruptor_backdrop_tiles_test.cpp @@ -85,7 +85,7 @@ void gpu_ws_tag_screen_tile(CPUState *cpu, std::uint32_t primitive_addr) { } // extern "C" -#include "../src/disruptor_backdrop_tiles.cpp" +#include "../../src/game/render/backdrop_tiles.cpp" namespace { diff --git a/tests/disruptor_billboard_aspect_test.cpp b/tests/unit/disruptor_billboard_aspect_test.cpp similarity index 99% rename from tests/disruptor_billboard_aspect_test.cpp rename to tests/unit/disruptor_billboard_aspect_test.cpp index eaf570b..f432096 100644 --- a/tests/disruptor_billboard_aspect_test.cpp +++ b/tests/unit/disruptor_billboard_aspect_test.cpp @@ -66,7 +66,7 @@ void disruptor_sprite_depth_packet(CPUState *, std::uint32_t, std::uint32_t) {} } // extern "C" // Include the implementation to exercise its exact private packet guards. -#include "../src/disruptor_billboard_aspect.cpp" +#include "../../src/game/render/billboard_aspect.cpp" namespace { diff --git a/tests/disruptor_capsule_test.cpp b/tests/unit/disruptor_capsule_test.cpp similarity index 99% rename from tests/disruptor_capsule_test.cpp rename to tests/unit/disruptor_capsule_test.cpp index 641675a..933d541 100644 --- a/tests/disruptor_capsule_test.cpp +++ b/tests/unit/disruptor_capsule_test.cpp @@ -1,5 +1,5 @@ #define DISRUPTOR_CAPSULE_NO_HOST 1 -#include "../src/disruptor_capsule.cpp" +#include "../../src/game/session/capsule.cpp" #include #include diff --git a/tests/disruptor_cheats_test.cpp b/tests/unit/disruptor_cheats_test.cpp similarity index 99% rename from tests/disruptor_cheats_test.cpp rename to tests/unit/disruptor_cheats_test.cpp index 88d4bbc..cdfbe04 100644 --- a/tests/disruptor_cheats_test.cpp +++ b/tests/unit/disruptor_cheats_test.cpp @@ -104,7 +104,7 @@ void psx_mod_write_word(std::uint32_t address, std::uint32_t value) { } // extern "C" // Include the implementation to exercise its version-pinned internal guards. -#include "../src/disruptor_cheats.cpp" +#include "../../src/game/session/cheats.cpp" namespace { diff --git a/tests/disruptor_far_rendering_test.cpp b/tests/unit/disruptor_far_rendering_test.cpp similarity index 99% rename from tests/disruptor_far_rendering_test.cpp rename to tests/unit/disruptor_far_rendering_test.cpp index 41b3682..e0a2bc1 100644 --- a/tests/disruptor_far_rendering_test.cpp +++ b/tests/unit/disruptor_far_rendering_test.cpp @@ -109,7 +109,7 @@ void psx_mod_write_word(std::uint32_t address, std::uint32_t value) { } // extern "C" -#include "../src/disruptor_far_rendering.cpp" +#include "../../src/game/render/far_rendering.cpp" namespace { diff --git a/tests/disruptor_frame_rate_test.cpp b/tests/unit/disruptor_frame_rate_test.cpp similarity index 99% rename from tests/disruptor_frame_rate_test.cpp rename to tests/unit/disruptor_frame_rate_test.cpp index 2e3e847..3d18c22 100644 --- a/tests/disruptor_frame_rate_test.cpp +++ b/tests/unit/disruptor_frame_rate_test.cpp @@ -71,7 +71,7 @@ int psx_mod_register_function_entry_plugin( uint32_t interrupts_cycles_to_vblank(void) { return g_cycles_to_vblank; } -#include "../src/disruptor_frame_rate.cpp" +#include "../../src/game/frame/frame_rate.cpp" namespace { diff --git a/tests/disruptor_grown_quad_test.cpp b/tests/unit/disruptor_grown_quad_test.cpp similarity index 99% rename from tests/disruptor_grown_quad_test.cpp rename to tests/unit/disruptor_grown_quad_test.cpp index 6b0c94c..a1fd507 100644 --- a/tests/disruptor_grown_quad_test.cpp +++ b/tests/unit/disruptor_grown_quad_test.cpp @@ -21,7 +21,7 @@ extern "C" std::uint32_t psx_mod_read_word(std::uint32_t address) { return found == g_memory.end() ? 0xDEADBEEFu : found->second; } -#include "../src/disruptor_grown_quad.cpp" +#include "../../src/game/render/grown_quad.cpp" namespace { diff --git a/tests/disruptor_intro_skip_test.cpp b/tests/unit/disruptor_intro_skip_test.cpp similarity index 99% rename from tests/disruptor_intro_skip_test.cpp rename to tests/unit/disruptor_intro_skip_test.cpp index 00a5707..3de0806 100644 --- a/tests/disruptor_intro_skip_test.cpp +++ b/tests/unit/disruptor_intro_skip_test.cpp @@ -43,7 +43,7 @@ int psx_mod_register_function_entry_plugin(const char *, uint32_t address, PSXMo } extern "C" void psx_host_set_fast_forward_query(int (*query)(void)) { g_registered_query = query; } -#include "../src/disruptor_intro_skip.cpp" +#include "../../src/game/session/intro_skip.cpp" namespace { diff --git a/tests/disruptor_language_disc_test.cpp b/tests/unit/disruptor_language_disc_test.cpp similarity index 99% rename from tests/disruptor_language_disc_test.cpp rename to tests/unit/disruptor_language_disc_test.cpp index 48e29b2..5996c3e 100644 --- a/tests/disruptor_language_disc_test.cpp +++ b/tests/unit/disruptor_language_disc_test.cpp @@ -1,4 +1,4 @@ -#include "disruptor_language_discs.h" +#include "../support/disruptor_language_discs.h" #include diff --git a/tests/disruptor_language_hints_test.cpp b/tests/unit/disruptor_language_hints_test.cpp similarity index 99% rename from tests/disruptor_language_hints_test.cpp rename to tests/unit/disruptor_language_hints_test.cpp index 06d5382..f5cad85 100644 --- a/tests/disruptor_language_hints_test.cpp +++ b/tests/unit/disruptor_language_hints_test.cpp @@ -60,7 +60,7 @@ int psx_mod_register_function_entry_plugin(const char *, uint32_t address, PSXMo } extern "C" int disruptor_language_disc_tall_hints(void) { return g_tall; } -#include "../src/disruptor_language_hints.cpp" +#include "../../src/game/language/language_hints.cpp" namespace { diff --git a/tests/disruptor_language_overlay_test.cpp b/tests/unit/disruptor_language_overlay_test.cpp similarity index 98% rename from tests/disruptor_language_overlay_test.cpp rename to tests/unit/disruptor_language_overlay_test.cpp index 700d78e..c5252d8 100644 --- a/tests/disruptor_language_overlay_test.cpp +++ b/tests/unit/disruptor_language_overlay_test.cpp @@ -1,4 +1,4 @@ -#include "disruptor_language_discs.h" +#include "../support/disruptor_language_discs.h" #include "iso_reader.h" #include "mod_runtime.h" @@ -67,8 +67,8 @@ extern "C" void savestate_get_integrity(uint32_t *bios_checksum, uint32_t *entry *entry_pc = g_states_entry; } -#include "../psxrecomp-overlay/runtime/src/iso_reader_c.cpp" -#include "../src/disruptor_language_source.cpp" +#include "../../psxrecomp-overlay/runtime/src/iso_reader_c.cpp" +#include "../../src/game/language/language_source.cpp" namespace { diff --git a/tests/disruptor_language_test.cpp b/tests/unit/disruptor_language_test.cpp similarity index 99% rename from tests/disruptor_language_test.cpp rename to tests/unit/disruptor_language_test.cpp index e883afa..a962e92 100644 --- a/tests/disruptor_language_test.cpp +++ b/tests/unit/disruptor_language_test.cpp @@ -63,8 +63,8 @@ extern "C" int disruptor_language_disc_pack(const uint8_t **data, uint32_t *size return 1; } -#include "../src/disruptor_language.cpp" -#include "../src/disruptor_language_disc.cpp" +#include "../../src/game/language/language.cpp" +#include "../../src/game/language/language_disc.cpp" namespace { diff --git a/tests/disruptor_mouse_aim_test.cpp b/tests/unit/disruptor_mouse_aim_test.cpp similarity index 99% rename from tests/disruptor_mouse_aim_test.cpp rename to tests/unit/disruptor_mouse_aim_test.cpp index c37d6b2..5a3e40f 100644 --- a/tests/disruptor_mouse_aim_test.cpp +++ b/tests/unit/disruptor_mouse_aim_test.cpp @@ -1,5 +1,5 @@ #include "psx_sdl.h" -#include "../psxrecomp-overlay/runtime/include/gpu.h" +#include "../../psxrecomp-overlay/runtime/include/gpu.h" #include #include @@ -184,7 +184,7 @@ const char *SDL_GetError() { // Include the implementation so its internal pure helpers and state can be // exercised against the mocks above without exposing a test-only production ABI. -#include "../src/disruptor_mouse_aim.cpp" +#include "../../src/game/input/mouse_aim.cpp" namespace { diff --git a/tests/disruptor_name_entry_test.cpp b/tests/unit/disruptor_name_entry_test.cpp similarity index 99% rename from tests/disruptor_name_entry_test.cpp rename to tests/unit/disruptor_name_entry_test.cpp index eb75d51..be1e3b1 100644 --- a/tests/disruptor_name_entry_test.cpp +++ b/tests/unit/disruptor_name_entry_test.cpp @@ -62,7 +62,7 @@ int psx_mod_register_vblank_plugin(const char *, PSXModVBlankCallback callback) } } -#include "../src/disruptor_name_entry.cpp" +#include "../../src/game/input/name_entry.cpp" namespace { diff --git a/tests/disruptor_present_rate_test.cpp b/tests/unit/disruptor_present_rate_test.cpp similarity index 77% rename from tests/disruptor_present_rate_test.cpp rename to tests/unit/disruptor_present_rate_test.cpp index e52247b..44782c3 100644 --- a/tests/disruptor_present_rate_test.cpp +++ b/tests/unit/disruptor_present_rate_test.cpp @@ -1,4 +1,4 @@ -#include "../src/disruptor_present_rate.h" +#include "../../src/game/frame/present_rate.h" #include #include @@ -28,6 +28,7 @@ struct Host { double display_hz = 143.86; }; struct Slider { + std::string label; bool moved = false; int moved_to = 0; int shown = -1; @@ -38,6 +39,7 @@ struct Slider { std::string text; }; struct Switch { + std::string label; bool clicked = false; bool shown = false; int disabled_when_drawn = -1; @@ -75,9 +77,13 @@ extern "C" void gl_renderer_interpolation_diag(int *, int *, int *, double *host if (host_hz) *host_hz = g_host.display_hz; } +std::string g_hint; + namespace ImGui { void BeginDisabled() { ++g_slider.disabled; } -bool Checkbox(const char *, bool *value) { +bool BeginItemTooltip() { return true; } +bool Checkbox(const char *label, bool *value) { + g_switch.label = label; g_switch.shown = *value; g_switch.disabled_when_drawn = g_slider.disabled; const bool taken = g_switch.clicked && g_slider.disabled == 0; @@ -85,8 +91,13 @@ bool Checkbox(const char *, bool *value) { return taken; } void EndDisabled() { --g_slider.disabled; } +void EndTooltip() {} +float GetFontSize() { return 13.0f; } +void PopTextWrapPos() {} +void PushTextWrapPos(float) {} void SetNextItemWidth(float) {} -bool SliderInt(const char *, int *value, int lowest, int highest, const char *, int) { +bool SliderInt(const char *label, int *value, int lowest, int highest, const char *, int) { + g_slider.label = label; g_slider.shown = *value; g_slider.disabled_when_drawn = g_slider.disabled; g_slider.lowest = lowest; @@ -95,9 +106,10 @@ bool SliderInt(const char *, int *value, int lowest, int highest, const char *, return g_slider.moved && g_slider.disabled == 0; } void TextDisabled(const char *text, ...) { g_slider.text = text; } +void TextUnformatted(const char *text) { g_hint = text; } } // namespace ImGui -#include "../src/disruptor_present_rate.cpp" +#include "../../src/game/frame/present_rate.cpp" namespace { @@ -119,8 +131,9 @@ void test_the_control_hands_over_what_the_user_chose() { launcher_override(""); g_host = Host{}; g_slider = Slider{}; - expect(disruptor_present_rate_control(&accepted) == 0 && accepted == -7 && g_host.sets == 0, + expect(disruptor_present_rate_control("Rate", "Rate hint", &accepted) == 0 && accepted == -7 && g_host.sets == 0, "a control nobody touched changes nothing"); + expect(g_slider.label == "Rate" && g_hint == "Rate hint", "the control is drawn under the label and with the hint it was given"); expect(g_slider.shown == 60 && g_slider.lowest == 30 && g_slider.highest == 144 && g_slider.disabled == 0 && g_slider.disabled_when_drawn == 0, "it shows the host's target between 30 and the display's rounded rate"); @@ -128,19 +141,19 @@ void test_the_control_hands_over_what_the_user_chose() { g_slider.text.find("launcher") == std::string::npos, "and says where its top comes from"); launcher_override("90"); - (void)disruptor_present_rate_control(&accepted); + (void)disruptor_present_rate_control("Rate", "Rate hint", &accepted); expect(g_slider.text.find("PSX_FRAME_INTERPOLATION_FPS of the launcher replaces it") != std::string::npos && g_slider.text.find("not known") == std::string::npos, "with the launcher's override set it says the override wins at the next start"); for (const char *refused : {"29", "1001", "-5"}) { launcher_override(refused); - (void)disruptor_present_rate_control(&accepted); + (void)disruptor_present_rate_control("Rate", "Rate hint", &accepted); expect(g_slider.text.find("launcher") == std::string::npos, "a launcher rate the start refuses is not announced as replacing the saved one"); } launcher_override(""); g_host.display_hz = 1000.6; - (void)disruptor_present_rate_control(&accepted); + (void)disruptor_present_rate_control("Rate", "Rate hint", &accepted); expect(g_slider.highest == 120 && g_slider.text.find("outside the host's range") != std::string::npos, "a refresh rate past the host's range gives 120, as the sentence says"); g_host.display_hz = 143.86; @@ -148,21 +161,21 @@ void test_the_control_hands_over_what_the_user_chose() { g_slider = Slider{}; g_slider.moved = true; g_slider.moved_to = 30; - expect(disruptor_present_rate_control(&accepted) == 1 && accepted == 30 && g_host.set_target == 30 && g_host.set_blend == 2, + expect(disruptor_present_rate_control("Rate", "Rate hint", &accepted) == 1 && accepted == 30 && g_host.set_target == 30 && g_host.set_blend == 2, "a moved control sets the chosen rate and reports it"); g_host = Host{}; g_host.accepts = 0; accepted = -7; - expect(disruptor_present_rate_control(&accepted) == 0 && accepted == -7 && g_host.sets == 1, + expect(disruptor_present_rate_control("Rate", "Rate hint", &accepted) == 0 && accepted == -7 && g_host.sets == 1, "a rate the host refuses is not reported as chosen"); g_host = Host{}; - expect(disruptor_present_rate_control(nullptr) == 1 && g_host.set_target == 30, "the caller may ask for no report"); + expect(disruptor_present_rate_control("Rate", "Rate hint", nullptr) == 1 && g_host.set_target == 30, "the caller may ask for no report"); g_host = Host{}; g_host.display_hz = 59.94; g_host.target = 0; g_slider = Slider{}; - (void)disruptor_present_rate_control(&accepted); + (void)disruptor_present_rate_control("Rate", "Rate hint", &accepted); expect(g_slider.highest == 120 && g_slider.shown == 120, "on a 60 Hz display the top is 120 and a target of 0 is shown as the top"); g_host = Host{}; @@ -171,7 +184,7 @@ void test_the_control_hands_over_what_the_user_chose() { g_slider.moved = true; g_slider.moved_to = 45; accepted = -7; - expect(disruptor_present_rate_control(&accepted) == 0 && accepted == -7 && g_host.sets == 0 && g_slider.disabled == 0 && + expect(disruptor_present_rate_control("Rate", "Rate hint", &accepted) == 0 && accepted == -7 && g_host.sets == 0 && g_slider.disabled == 0 && g_slider.disabled_when_drawn == 1, "with in-between frames off the control is drawn disabled and sets nothing"); expect(g_slider.text.find("In-between frames are off") != std::string::npos, "and says why"); @@ -196,38 +209,41 @@ void test_the_switch_turns_the_frames_on_and_off() { g_host.enabled = 0; g_slider = Slider{}; g_switch = Switch{}; - expect(disruptor_present_rate_switch(1, &state) == 0 && state == -7 && g_host.sets == 0 && !g_switch.shown && + expect(disruptor_present_rate_switch("Switch", "Switch hint", 1, &state) == 0 && g_switch.label == "Switch" && + g_hint == "Switch hint", + "the switch is drawn under the label and with the hint it was given"); + expect(disruptor_present_rate_switch("Switch", "Switch hint", 1, &state) == 0 && state == -7 && g_host.sets == 0 && !g_switch.shown && g_switch.disabled_when_drawn == 0, "a switch nobody touched changes nothing and shows the host's state"); g_switch.clicked = true; - expect(disruptor_present_rate_switch(1, &state) == 1 && state == 1 && g_host.set_enabled == 1, + expect(disruptor_present_rate_switch("Switch", "Switch hint", 1, &state) == 1 && state == 1 && g_host.set_enabled == 1, "a click on a switch that is off turns the frames on and reports it"); g_host = Host{}; state = -7; - expect(disruptor_present_rate_switch(1, &state) == 1 && state == 0 && g_host.set_enabled == 0 && g_switch.shown, + expect(disruptor_present_rate_switch("Switch", "Switch hint", 1, &state) == 1 && state == 0 && g_host.set_enabled == 0 && g_switch.shown, "a click on a switch that is on turns them off and reports it"); g_host = Host{}; g_host.enabled = 0; g_host.accepts = 0; state = -7; - expect(disruptor_present_rate_switch(1, &state) == 0 && state == -7 && g_host.sets == 1, + expect(disruptor_present_rate_switch("Switch", "Switch hint", 1, &state) == 0 && state == -7 && g_host.sets == 1, "a switch the host refuses is not reported as changed"); g_host = Host{}; g_host.enabled = 0; - expect(disruptor_present_rate_switch(1, nullptr) == 1 && g_host.set_enabled == 1, "the caller may ask for no report"); + expect(disruptor_present_rate_switch("Switch", "Switch hint", 1, nullptr) == 1 && g_host.set_enabled == 1, "the caller may ask for no report"); g_host = Host{}; g_host.enabled = 0; g_slider = Slider{}; state = -7; - expect(disruptor_present_rate_switch(0, &state) == 0 && state == -7 && g_host.sets == 0 && g_slider.disabled == 0 && + expect(disruptor_present_rate_switch("Switch", "Switch hint", 0, &state) == 0 && state == -7 && g_host.sets == 0 && g_slider.disabled == 0 && g_switch.disabled_when_drawn == 1, "without exact geometry the switch is drawn disabled and sets nothing"); expect(g_slider.text.find("Enable exact geometry") != std::string::npos, "and says what it waits for"); g_host = Host{}; g_slider = Slider{}; state = -7; - expect(disruptor_present_rate_switch(0, &state) == 1 && state == 0 && g_host.set_enabled == 0 && + expect(disruptor_present_rate_switch("Switch", "Switch hint", 0, &state) == 1 && state == 0 && g_host.set_enabled == 0 && g_switch.disabled_when_drawn == 0 && g_slider.text.empty(), "frames that are on can be switched off without exact geometry too"); } diff --git a/tests/disruptor_render_arena_test.cpp b/tests/unit/disruptor_render_arena_test.cpp similarity index 99% rename from tests/disruptor_render_arena_test.cpp rename to tests/unit/disruptor_render_arena_test.cpp index 60b6fbf..4cc9f56 100644 --- a/tests/disruptor_render_arena_test.cpp +++ b/tests/unit/disruptor_render_arena_test.cpp @@ -52,7 +52,7 @@ int psx_mod_register_function_entry_plugin( const char *, uint32_t, PSXModFunctionEntryCallback) { return 1; } } -#include "../src/disruptor_render_arena.cpp" +#include "../../src/game/render/render_arena.cpp" int main() { CPUState cpu{}; diff --git a/tests/disruptor_restart_test.cpp b/tests/unit/disruptor_restart_test.cpp similarity index 99% rename from tests/disruptor_restart_test.cpp rename to tests/unit/disruptor_restart_test.cpp index 6e817f9..42322bc 100644 --- a/tests/disruptor_restart_test.cpp +++ b/tests/unit/disruptor_restart_test.cpp @@ -1,4 +1,4 @@ -#include "../src/disruptor_restart.cpp" +#include "../../src/game/session/restart.cpp" #include #include diff --git a/tests/disruptor_sprite_depth_test.cpp b/tests/unit/disruptor_sprite_depth_test.cpp similarity index 99% rename from tests/disruptor_sprite_depth_test.cpp rename to tests/unit/disruptor_sprite_depth_test.cpp index 3370a99..d416903 100644 --- a/tests/disruptor_sprite_depth_test.cpp +++ b/tests/unit/disruptor_sprite_depth_test.cpp @@ -124,7 +124,7 @@ extern "C" void gte_ws_x_squash(std::int32_t *num, std::int32_t *den) { *den = g_range_den; } -#include "../src/disruptor_sprite_depth.cpp" +#include "../../src/game/render/sprite_depth.cpp" namespace { diff --git a/tests/disruptor_vertical_camera_test.cpp b/tests/unit/disruptor_vertical_camera_test.cpp similarity index 99% rename from tests/disruptor_vertical_camera_test.cpp rename to tests/unit/disruptor_vertical_camera_test.cpp index 228a1db..b10789d 100644 --- a/tests/disruptor_vertical_camera_test.cpp +++ b/tests/unit/disruptor_vertical_camera_test.cpp @@ -132,7 +132,7 @@ void gpu_geometry_camera_projection_center_y_set(double center_y) { } // extern "C" // Include the implementation to exercise the version-pinned internal guards. -#include "../src/disruptor_vertical_camera.cpp" +#include "../../src/game/input/vertical_camera.cpp" namespace { diff --git a/tests/gpu_pinned_corner_test.cpp b/tests/unit/gpu_pinned_corner_test.cpp similarity index 100% rename from tests/gpu_pinned_corner_test.cpp rename to tests/unit/gpu_pinned_corner_test.cpp diff --git a/tests/gpu_temporal_match_test.cpp b/tests/unit/gpu_temporal_match_test.cpp similarity index 100% rename from tests/gpu_temporal_match_test.cpp rename to tests/unit/gpu_temporal_match_test.cpp diff --git a/tests/gpu_temporal_mesh_test.cpp b/tests/unit/gpu_temporal_mesh_test.cpp similarity index 100% rename from tests/gpu_temporal_mesh_test.cpp rename to tests/unit/gpu_temporal_mesh_test.cpp diff --git a/tests/gpu_temporal_sprite_test.cpp b/tests/unit/gpu_temporal_sprite_test.cpp similarity index 100% rename from tests/gpu_temporal_sprite_test.cpp rename to tests/unit/gpu_temporal_sprite_test.cpp diff --git a/tests/gpu_turned_perspective_test.cpp b/tests/unit/gpu_turned_perspective_test.cpp similarity index 100% rename from tests/gpu_turned_perspective_test.cpp rename to tests/unit/gpu_turned_perspective_test.cpp diff --git a/tests/gpu_world_depth_test.cpp b/tests/unit/gpu_world_depth_test.cpp similarity index 100% rename from tests/gpu_world_depth_test.cpp rename to tests/unit/gpu_world_depth_test.cpp diff --git a/tests/gpu_ws_frame_kind_test.cpp b/tests/unit/gpu_ws_frame_kind_test.cpp similarity index 100% rename from tests/gpu_ws_frame_kind_test.cpp rename to tests/unit/gpu_ws_frame_kind_test.cpp diff --git a/tests/gpu_ws_hud_scale_test.cpp b/tests/unit/gpu_ws_hud_scale_test.cpp similarity index 100% rename from tests/gpu_ws_hud_scale_test.cpp rename to tests/unit/gpu_ws_hud_scale_test.cpp diff --git a/tests/gpu_ws_hud_widget_test.cpp b/tests/unit/gpu_ws_hud_widget_test.cpp similarity index 100% rename from tests/gpu_ws_hud_widget_test.cpp rename to tests/unit/gpu_ws_hud_widget_test.cpp diff --git a/tests/gpu_ws_menu_test.cpp b/tests/unit/gpu_ws_menu_test.cpp similarity index 100% rename from tests/gpu_ws_menu_test.cpp rename to tests/unit/gpu_ws_menu_test.cpp diff --git a/tests/gpu_ws_tag_match_test.cpp b/tests/unit/gpu_ws_tag_match_test.cpp similarity index 100% rename from tests/gpu_ws_tag_match_test.cpp rename to tests/unit/gpu_ws_tag_match_test.cpp diff --git a/tests/gte_precision_store_pc_test.cpp b/tests/unit/gte_precision_store_pc_test.cpp similarity index 100% rename from tests/gte_precision_store_pc_test.cpp rename to tests/unit/gte_precision_store_pc_test.cpp diff --git a/tests/host_borderless_test.cpp b/tests/unit/host_borderless_test.cpp similarity index 97% rename from tests/host_borderless_test.cpp rename to tests/unit/host_borderless_test.cpp index 181ecc3..65dd4e7 100644 --- a/tests/host_borderless_test.cpp +++ b/tests/unit/host_borderless_test.cpp @@ -1,4 +1,4 @@ -#include "../psxrecomp-overlay/runtime/include/host_borderless.h" +#include "../../psxrecomp-overlay/runtime/include/host_borderless.h" #include diff --git a/tests/host_pick_slot_test.cpp b/tests/unit/host_pick_slot_test.cpp similarity index 96% rename from tests/host_pick_slot_test.cpp rename to tests/unit/host_pick_slot_test.cpp index c692c4c..af43edd 100644 --- a/tests/host_pick_slot_test.cpp +++ b/tests/unit/host_pick_slot_test.cpp @@ -1,4 +1,4 @@ -#include "../psxrecomp-overlay/runtime/include/host_pick_slot.h" +#include "../../psxrecomp-overlay/runtime/include/host_pick_slot.h" #include #include diff --git a/tests/launcher_flow_test.cpp b/tests/unit/launcher_flow_test.cpp similarity index 100% rename from tests/launcher_flow_test.cpp rename to tests/unit/launcher_flow_test.cpp diff --git a/tests/launcher_settings_test.cpp b/tests/unit/launcher_settings_test.cpp similarity index 84% rename from tests/launcher_settings_test.cpp rename to tests/unit/launcher_settings_test.cpp index 1bf44c1..36ba231 100644 --- a/tests/launcher_settings_test.cpp +++ b/tests/unit/launcher_settings_test.cpp @@ -3,6 +3,7 @@ #include +#include #include #include @@ -12,6 +13,7 @@ #include #include #include +#include namespace fs = std::filesystem; using namespace disruptor::launcher; @@ -53,9 +55,9 @@ void table() { require(!one.choices.empty() && one.initial >= 0 && one.initial < static_cast(one.choices.size()) && one.highest == static_cast(one.choices.size()) - 1, "a choice starts outside its list"); if (one.kind == Kind::toggle) require(one.initial == 0 || one.initial == 1, "a toggle starts as neither"); - if (one.needs) require(option(one.needs).kind == Kind::toggle && &option(one.needs) != &one, "a row needs no toggle"); - require(static_cast(one.page) < page_labels().size(), "a row is on no page"); - require(one.hint && std::wcslen(one.hint) >= 20 && std::wcslen(one.hint) <= 240, "a row has no hint to show"); + for (const char* need : one.needs) + require(option(need).kind == Kind::toggle && &option(need) != &one, "a row needs no toggle"); + require(one.hint && std::strlen(one.hint) >= 20 && std::strlen(one.hint) <= 240, "a row has no hint to show"); } require(presets().size() == 2, "the two presets are expected"); for (const auto& preset : presets()) @@ -64,6 +66,75 @@ void table() { "a preset sets a value outside its option"); } +// The in-game menu saves a staged value by get and set from one settings to another. +void carried() { + for (const auto& one : options()) { + std::set values{one.initial}; + for (int value = one.lowest; value <= one.highest; ++value) values.insert(value); + for (const int value : values) { + Settings staged{}, file{}; + one.set(staged, value); + one.set(file, one.get(staged)); + require(one.saved(file) && one.get(file) == value, "a staged value is not carried whole"); + } + } + const Option& aspect = option("video.aspect_ratio"); + const int shapes[][3] = {{4, 3, 0}, {16, 9, 0}, {21, 9, 0}, {32, 9, 0}, {32, 9, 1}}; + for (const auto& shape : shapes) { + Settings staged{}, file{}; + staged.has_aspect_ratio = staged.has_adaptive_view = true; + staged.aspect_num = shape[0]; + staged.aspect_den = shape[1]; + staged.adaptive_view = shape[2] != 0; + aspect.set(file, aspect.get(staged)); + require(file.has_aspect_ratio && file.has_adaptive_view && file.aspect_num == shape[0] && + file.aspect_den == shape[1] && file.adaptive_view == staged.adaptive_view, + "an aspect the menu stages is not carried whole"); + } + Settings probe{}; + option("video.vsync").set(probe, 0); + option("video.supersampling").set(probe, 3); + option("disruptor.horizontal_sensitivity").set(probe, 83); + require(probe.vsync == -1 && probe.supersampling == 4 && probe.horizontal_sensitivity == 0.083, + "the menu's own values are not the table's"); + const Option& turn = option("disruptor.horizontal_sensitivity"); + Settings typed{}, written{}; + typed.has_horizontal_sensitivity = true; + typed.horizontal_sensitivity = 0.0834; + turn.set(written, turn.get(typed)); + require(written.has_horizontal_sensitivity && written.horizontal_sensitivity == 0.083, + "a sensitivity is not written in whole thousandths"); +} + +// The rule both front ends ask: every toggle an option needs is on, and so is what those need. +void needed() { + const auto on = [](std::set keys) { + return [keys = std::move(keys)](const Option& toggle) { return keys.contains(toggle.key); }; + }; + const Option& smooth = option("disruptor.high_precision_camera"); + const Option& reach = option("disruptor.occlusion_reach"); + require(disruptor::settings::applies(option("video.vsync"), on({})), "an option that needs nothing always applies"); + require(disruptor::settings::applies(smooth, on({"disruptor.mouse_aim", "disruptor.geometry_correction"})) && + !disruptor::settings::applies(smooth, on({"disruptor.mouse_aim"})) && + !disruptor::settings::applies(smooth, on({"disruptor.geometry_correction"})), + "an option applies only with every toggle it needs"); + require(disruptor::settings::applies(reach, on({"disruptor.ambient_occlusion", "disruptor.geometry_correction"})) && + !disruptor::settings::applies(reach, on({"disruptor.ambient_occlusion"})) && + !disruptor::settings::applies(reach, on({"disruptor.geometry_correction"})), + "and only when what it needs applies itself"); +} + +void placements() { + std::set placed; + for (const auto& row : layout()) { + require(find_option(row.key) != nullptr, "a row names no option"); + require(placed.insert(row.key).second, "an option is on two rows"); + require(static_cast(row.page) < page_labels().size(), "a row is on no page"); + } + require(placed.size() == options().size(), "an option is on no page"); + require(wide("caf\xC3\xA9") == L"café" && wide("").empty(), "an option's text is read as UTF-8"); +} + void absent(const fs::path& folder) { SettingsFile settings(folder / "none" / "settings.toml"); require(settings.load() && settings.readable(), "a missing file must read as defaults"); @@ -167,10 +238,13 @@ void dependants(const fs::path& folder) { const Option& by_depth = option("disruptor.depth_sorted_world"); const Option& occlusion = option("disruptor.ambient_occlusion"); const Option& reach = option("disruptor.occlusion_reach"); - require(settings.set(aim, 1) && settings.set(smooth, 1) && settings.applies(smooth) && settings.set(geometry, 0) && - settings.shown(smooth) == 1 && settings.applies(smooth), "smooth turning needs mouse aim, not geometry"); - require(settings.set(aim, 0) && settings.shown(smooth) == 0 && !settings.applies(smooth) && - has(file, "high_precision_camera = false"), "and goes off with mouse aim"); + require(settings.set(aim, 1) && settings.set(smooth, 1) && settings.applies(smooth) && settings.set(aim, 0) && + settings.shown(smooth) == 0 && !settings.applies(smooth) && has(file, "high_precision_camera = false"), + "smooth turning needs mouse aim and goes off with it"); + require(settings.set(aim, 1) && settings.applies(smooth) && settings.set(smooth, 1) && + has(file, "high_precision_camera = true") && settings.set(geometry, 0) && settings.shown(smooth) == 0 && + !settings.applies(smooth) && has(file, "high_precision_camera = false") && settings.set(aim, 0), + "and geometry, and goes off with that too"); require(settings.shown(gaps) == 0 && has(file, "gap_filling = false"), "gap filling, on in a fresh installation, went off with geometry above and was written so"); require(settings.set(geometry, 1) && settings.set(gaps, 1) && settings.set(by_depth, 1) && settings.set(occlusion, 1) && @@ -416,6 +490,9 @@ int main() { fs::remove_all(folder); fs::create_directories(folder); table(); + carried(); + needed(); + placements(); absent(folder); one_change(folder); round_trip(folder); diff --git a/tests/presentation_yaw_math_test.cpp b/tests/unit/presentation_yaw_math_test.cpp similarity index 100% rename from tests/presentation_yaw_math_test.cpp rename to tests/unit/presentation_yaw_math_test.cpp diff --git a/tests/region_disc_test.cpp b/tests/unit/region_disc_test.cpp similarity index 98% rename from tests/region_disc_test.cpp rename to tests/unit/region_disc_test.cpp index 0954636..e3bc7ce 100644 --- a/tests/region_disc_test.cpp +++ b/tests/unit/region_disc_test.cpp @@ -1,4 +1,4 @@ -#include "disruptor_language_discs.h" +#include "../support/disruptor_language_discs.h" #include "region_disc.h" diff --git a/tests/unit/save_point_test.cpp b/tests/unit/save_point_test.cpp new file mode 100644 index 0000000..bf99603 --- /dev/null +++ b/tests/unit/save_point_test.cpp @@ -0,0 +1,101 @@ +#include "save_point.h" + +#include +#include + +namespace { + +int g_request_waits = 0; +int g_asked = 0; + +void expect(bool condition, const char *message) { + if (condition) return; + std::fprintf(stderr, "FAIL: %s\n", message); + std::exit(1); +} + +constexpr int kPeriodic = 1, kBetween = 0, kMarked = 1, kUnmarked = 0; + +void a_marked_periodic_check_polls() { + int owed = 0; + g_request_waits = 0; + expect(psx_save_poll_due(kPeriodic, kMarked, &owed) == 1, "a marked periodic check polls, a request waiting or not"); + expect(owed == 0, "and owes nothing"); + expect(psx_save_poll_due(kBetween, kMarked, &owed) == 0, "a marked check between periodic ones does not poll"); +} + +void an_unmarked_check_never_polls() { + for (int waits = 0; waits < 2; ++waits) { + for (int periodic = 0; periodic < 2; ++periodic) { + for (int owed_before = 0; owed_before < 2; ++owed_before) { + int owed = owed_before; + g_request_waits = waits; + expect(psx_save_poll_due(periodic, kUnmarked, &owed) == 0, "an unmarked check does not poll, whatever else holds"); + expect(owed == (periodic ? waits : owed_before), "it owes a periodic poll only when a request waits"); + } + } + } +} + +void a_periodic_poll_missed_with_a_request_is_owed_to_the_next_marked_check() { + int owed = 0; + g_request_waits = 1; + expect(psx_save_poll_due(kPeriodic, kUnmarked, &owed) == 0 && owed == 1, "the poll is owed"); + expect(psx_save_poll_due(kBetween, kUnmarked, &owed) == 0 && owed == 1, "an unmarked check in between leaves it owed"); + expect(psx_save_poll_due(kBetween, kMarked, &owed) == 1 && owed == 0, "the next marked check takes it"); + expect(psx_save_poll_due(kBetween, kMarked, &owed) == 0, "and the one after it does not poll again"); +} + +void nothing_is_owed_when_no_request_waits() { + int owed = 0; + g_request_waits = 0; + expect(psx_save_poll_due(kPeriodic, kUnmarked, &owed) == 0 && owed == 0, "a periodic poll missed with nothing waiting is not owed"); + expect(psx_save_poll_due(kBetween, kMarked, &owed) == 0, "so the next marked check does not poll"); + owed = 1; + expect(psx_save_poll_due(kPeriodic, kUnmarked, &owed) == 0 && owed == 0, "a request served elsewhere takes the owed poll away"); +} + +void the_request_is_asked_for_only_at_an_unmarked_periodic_check() { + int owed = 0; + g_asked = 0; + for (int index = 0; index < 1000; ++index) (void)psx_save_poll_due(kBetween, kMarked, &owed); + for (int index = 0; index < 1000; ++index) (void)psx_save_poll_due(kBetween, kUnmarked, &owed); + (void)psx_save_poll_due(kPeriodic, kMarked, &owed); + expect(g_asked == 0, "the other checks do not ask whether a request waits"); + (void)psx_save_poll_due(kPeriodic, kUnmarked, &owed); + expect(g_asked == 1, "an unmarked periodic check asks once"); +} + +void periodic_polls_that_always_meet_an_unmarked_check_do_not_starve_a_request() { + constexpr int kPeriod = 16384; + int owed = 0, polls = 0, polled_at = -1; + g_request_waits = 1; + for (int check = 1; check <= 3 * kPeriod && g_request_waits; ++check) { + const int periodic = check % kPeriod == 0; + if (psx_save_poll_due(periodic, periodic ? kUnmarked : kMarked, &owed)) { + ++polls; + polled_at = check; + g_request_waits = 0; + } + } + expect(polls == 1, "the request is served"); + expect(polled_at == kPeriod + 1, "at the first marked check after the periodic one that met it"); +} + +} // namespace + +extern "C" int savestate_pending(void) { + ++g_asked; + return g_request_waits; +} + +int main() { + a_marked_periodic_check_polls(); + an_unmarked_check_never_polls(); + a_periodic_poll_missed_with_a_request_is_owed_to_the_next_marked_check(); + nothing_is_owed_when_no_request_waits(); + the_request_is_asked_for_only_at_an_unmarked_periodic_check(); + periodic_polls_that_always_meet_an_unmarked_check_do_not_starve_a_request(); + std::puts("save point tests passed"); + return 0; +} diff --git a/tests/user_settings_persistence_test.cpp b/tests/unit/user_settings_persistence_test.cpp similarity index 100% rename from tests/user_settings_persistence_test.cpp rename to tests/unit/user_settings_persistence_test.cpp diff --git a/tools/package_release.py b/tools/package_release.py index 6b21bfd..0f68c15 100644 --- a/tools/package_release.py +++ b/tools/package_release.py @@ -37,7 +37,7 @@ def main() -> None: "DisruptorLauncher.exe": build / "DisruptorLauncher.exe", "game.toml": ROOT / "game.toml", "mouse-aim.ini": ROOT / "mouse-aim.ini", - "keybinds.ini": ROOT / "keybinds-modern.ini", + "keybinds.ini": ROOT / "config/keybinds-modern.ini", "settings.toml": ROOT / "release/windows/settings.toml", "Play Disruptor.cmd": ROOT / "release/windows/Play Disruptor.cmd", "GETTING_STARTED.md": ROOT / "docs/PLAYING.md",