diff --git a/.gitattributes b/.gitattributes index e0db6f2..2d50abf 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,3 +11,6 @@ *.c text eol=lf *.cpp text eol=lf *.h text eol=lf + +# A patch must reach every machine byte for byte: one framework file has CRLF lines. +framework/patches/** -text diff --git a/CMakeLists.txt b/CMakeLists.txt index a477231..5bfed12 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,31 +25,38 @@ 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/host.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/settings/saved.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/precision_routes.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") +# The framework's settings reader takes the game's section of settings.toml from here, in every target alike. +include_directories("${CMAKE_CURRENT_SOURCE_DIR}/src/game/settings") +add_compile_definitions(PSX_HAS_GAME_USER_SETTINGS=1) include("${PSXRECOMP_ROOT}/runtime/runtime.cmake") if(DISRUPTOR_DEV_MENU) @@ -88,10 +95,20 @@ 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/menu/preferences.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/menu/rows.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/menu/tab_controls.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/menu/tab_enhancements.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/menu/tab_experimental.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/menu/tab_debug.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/menu/tab_cheats.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/menu/tab_system.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/menu/tab_language.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/menu/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() @@ -130,16 +147,7 @@ psxrecomp_add_runtime_target(psx-runtime EXE_NAME "DisruptorRecompiled" ) target_compile_definitions(psx-runtime PRIVATE - PSX_HAS_DISRUPTOR_BACKDROP_TILES=1 - PSX_HAS_DISRUPTOR_BILLBOARD_ASPECT=1 - PSX_HAS_DISRUPTOR_CAPSULE=1 - PSX_HAS_DISRUPTOR_FAR_RENDERING=1 - PSX_HAS_DISRUPTOR_INTRO_SKIP=1 - PSX_HAS_DISRUPTOR_LANGUAGE=1 - PSX_HAS_DISRUPTOR_NAME_ENTRY=1 - PSX_HAS_DISRUPTOR_SPRITE_DEPTH=1 - PSX_HAS_DISRUPTOR_GROWN_QUAD=1 - PSX_HAS_DISRUPTOR_VERTICAL_CAMERA=1) + PSX_HAS_GAME_HOST=1) if(NOT DISRUPTOR_FRAME_INTERPOLATION) target_compile_definitions(psx-runtime PRIVATE # Keep the experimental sources available, but compile out frame @@ -147,7 +155,7 @@ if(NOT DISRUPTOR_FRAME_INTERPOLATION) PSX_DISABLE_FRAME_INTERPOLATION=1) endif() if(DISRUPTOR_DEV_MENU) - target_compile_definitions(psx-runtime PRIVATE DISRUPTOR_DEV_MENU=1) + target_compile_definitions(psx-runtime PRIVATE PSX_FRAME_INTERPOLATION_GEOMETRY_DEFAULT=1) target_link_libraries(psx-runtime PRIVATE disruptor-imgui) endif() if(WIN32) @@ -166,9 +174,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() @@ -177,467 +185,82 @@ if(WIN32) add_subdirectory(src/launcher) 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_save_point_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_save_point_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") - - add_test(NAME disruptor_framework_overlay_new_file + find_package(Python3 3.10 REQUIRED COMPONENTS Interpreter) + 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(instruction_hooks) + disruptor_contract(state_key) + disruptor_contract(test_registration) + + add_test(NAME disruptor_framework_tool + COMMAND "${Python3_EXECUTABLE}" -B + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_framework_tool.py") + # The contracts and some unit tests read framework files from the checkout: it must hold what framework/ says. + file(REAL_PATH "${PSXRECOMP_ROOT}" framework_checkout) + file(REAL_PATH "${CMAKE_CURRENT_SOURCE_DIR}/psxrecomp" framework_expected) + if(NOT framework_checkout STREQUAL framework_expected) + message(FATAL_ERROR "The tests read the framework from ${framework_expected}, not ${framework_checkout}") + endif() + add_test(NAME disruptor_framework_in_step 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}/tools/framework.py" check --framework "${PSXRECOMP_ROOT}") 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-save-point-test - tests/save_point_test.cpp) - target_include_directories(disruptor-save-point-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-save-point-test PRIVATE cxx_std_20) - add_test(NAME disruptor_save_point - COMMAND disruptor-save-point-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" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/settings/saved.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 @@ -657,4 +280,53 @@ 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 + "${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..ae6b777 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,16 +4,17 @@ Keep contributions source-only. Do not commit disc images, extracted game code or assets, generated translations, captured overlays, proprietary BIOS files, binaries, saves, captures, logs, credentials, or machine-specific paths. -Edit project source, configuration, seeds, tests, or `psxrecomp-overlay/`, then -regenerate locally. Every framework-overlay change must be listed in -`PSXRECOMP_OVERLAY_FILES.txt`; do not hand-edit `generated/*.c` or include +Edit project source, configuration, seeds, tests, or the framework checkout, +then regenerate locally. Save a framework change with +`python tools/framework.py refresh`, and list a newly changed file in +`framework/FILES.txt` first. Do not hand-edit `generated/*.c` or include unrelated framework changes. 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/README.md b/README.md index e1e4005..2de9ef9 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. @@ -48,7 +53,7 @@ and share feedback. ## Build and run -Install Git, Python 3, CMake 3.20+, and a Windows x64 C++ toolchain. Visual Studio +Install Git, Python 3.10+, CMake 3.20+, and a Windows x64 C++ toolchain. Visual Studio with **Desktop development with C++** is supported; Ninja is recommended. Place these files from your own supported disc in `input/`: @@ -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/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 9a69b7a..2f18ba0 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # Third-party notices -`psxrecomp-overlay/` contains modified files based on PSXRecomp revision +`framework/` contains patches to, and files written for, PSXRecomp revision `854a658372b304c222a0d0cbbae16c66264b785e`. PSXRecomp is copyright Matthew Stan and is provided under the PolyForm Noncommercial License 1.0.0. The upstream license is reproduced in diff --git a/build.ps1 b/build.ps1 index 9472874..453e974 100644 --- a/build.ps1 +++ b/build.ps1 @@ -2,7 +2,7 @@ $ErrorActionPreference = 'Stop' $Root = Split-Path -Parent $MyInvocation.MyCommand.Path $Framework = Join-Path $Root 'psxrecomp' -$Pin = (Get-Content (Join-Path $Root 'PSXRECOMP_PIN') -Raw).Trim() +$Pin = (Get-Content (Join-Path $Root 'framework/PIN') -Raw).Trim() $Exe = Join-Path $Root 'input/SLUS_002.24' # Ninja inherits its target architecture from the active compiler environment. @@ -101,6 +101,10 @@ foreach ($Command in @('git', 'cmake', 'ctest', 'python')) { throw "Missing prerequisite: $Command" } } +python -c "import sys; sys.exit(sys.version_info < (3, 10))" +if ($LASTEXITCODE -ne 0) { + throw 'Python 3.10 or newer is required.' +} if (!(Test-Path $Exe)) { throw "Copy the verified SLUS_002.24 into input/ before building." } @@ -114,10 +118,7 @@ if ($LASTEXITCODE -ne 0) { } Invoke-Checked { git -C $Framework checkout --detach $Pin } Invoke-Checked { - python (Join-Path $Root 'tools/apply_framework_overlay.py') --framework $Framework -} -Invoke-Checked { - python (Join-Path $Root 'tools/patch_openbios_seeds.py') --framework $Framework + python (Join-Path $Root 'tools/framework.py') apply --framework $Framework } $Generator = @() diff --git a/build.sh b/build.sh index 01cddf6..bff5102 100755 --- a/build.sh +++ b/build.sh @@ -3,7 +3,7 @@ set -eu ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) FRAMEWORK="$ROOT/psxrecomp" -PIN=$(tr -d '[:space:]' < "$ROOT/PSXRECOMP_PIN") +PIN=$(tr -d '[:space:]' < "$ROOT/framework/PIN") for command in git cmake ctest python3; do command -v "$command" >/dev/null 2>&1 || { @@ -11,6 +11,10 @@ for command in git cmake ctest python3; do exit 1 } done +python3 -c 'import sys; sys.exit(sys.version_info < (3, 10))' || { + echo "Python 3.10 or newer is required." >&2 + exit 1 +} # DISRUPTOR_WITHOUT_GAME=1 is for a machine without the game: every source is # compiled, the runtime is not linked, and the tests that read no retail data run. WITHOUT_GAME="${DISRUPTOR_WITHOUT_GAME:-}" @@ -24,8 +28,7 @@ if [ ! -d "$FRAMEWORK/.git" ]; then fi git -C "$FRAMEWORK" fetch --quiet origin "$PIN" git -C "$FRAMEWORK" checkout --detach "$PIN" -python3 "$ROOT/tools/apply_framework_overlay.py" --framework "$FRAMEWORK" -python3 "$ROOT/tools/patch_openbios_seeds.py" --framework "$FRAMEWORK" +python3 "$ROOT/tools/framework.py" apply --framework "$FRAMEWORK" GENERATOR="" if command -v ninja >/dev/null 2>&1; then GENERATOR="-G Ninja"; fi @@ -86,7 +89,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..70a9a5f 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -3,8 +3,8 @@ ## Requirements - The verified Disruptor USA executable and BIN/CUE files in `input/`; see - [DISC.md](../DISC.md). -- Git, Python 3, CMake 3.20+, and a C++20 toolchain. + [DISC.md](DISC.md). +- Git, Python 3.10+, CMake 3.20+, and a C++20 toolchain. - Windows x64: Visual Studio with **Desktop development with C++**, or MSYS2 MinGW-w64. Ninja is recommended. - Linux: GCC and the platform development libraries needed to build SDL. @@ -118,17 +118,61 @@ cmake --build build --config Release --parallel ctest --test-dir build -C Release --output-on-failure ``` -Changes to `psxrecomp-overlay/` must be applied before rebuilding: +The project's changes to the framework are patches against the revision in +`framework/PIN`. `framework/FILES.txt` lists every changed file, a `+` marking +one the project adds. To change the framework, edit the file in the `psxrecomp` +checkout, build and test, then save the edit as a patch: ```sh -python tools/apply_framework_overlay.py --framework psxrecomp +python tools/framework.py refresh ``` -The framework revision is pinned in `PSXRECOMP_PIN`, and the reviewed overlay -files are listed in `PSXRECOMP_OVERLAY_FILES.txt`. Runtime preferences live in +After pulling changes to `framework/`, bring the checkout up to date: + +```sh +python tools/framework.py apply +``` + +`apply` refuses to overwrite an edit that `refresh` has not saved, and +`python tools/framework.py check` tells whether the checkout holds exactly what +`framework/` describes. Runtime preferences live in `settings.toml` beside the executable; game files, generated code, and build directories are ignored by Git. +The framework reaches the game through one header, `runtime/include/game_host.h`, +which `src/game/host.cpp` answers. The instructions the game hooks are listed in +`game.toml` under `[[recompiler.instruction_hook]]` for code that runs from RAM. +The code generator holds its own copy for native code, and a contract keeps the +two equal. The game's section of `settings.toml` belongs to the game as well: +its fields are in `src/game/settings/game_user_settings.h`, and +`src/game/settings/saved.cpp` reads and writes them for the framework's reader. + +A save state carries a key made of the code generator's sources, listed in +`psxrecomp/runtime/codegen_hash_sources.cmake`. Any edit to one of them, a +comment included, makes every earlier save state and capsule unloadable. Those +files change only together with the pin. + +## Layout + +| Folder | Holds | +| --- | --- | +| `src/game/` | The game's own module by area: `input`, `render`, `frame`, `language`, `session`, `settings`, `ui` | +| `src/launcher/` | The Windows launcher | +| `framework/` | The pinned framework revision, the project's patches to it and the files it 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 38b99e5..37fbc1d 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -77,6 +77,9 @@ are enabled initially. Vertical mouse look is experimental and starts off; enable it in **Settings → Controls** if desired. **Smooth mouse turning** in the same tab turns the picture between the game's 256 heading steps. It requires captured mouse aim and exact geometry. +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 diff --git a/STATUS.md b/docs/STATUS.md similarity index 97% rename from STATUS.md rename to docs/STATUS.md index a3602da..8b3e26e 100644 --- a/STATUS.md +++ b/docs/STATUS.md @@ -36,5 +36,5 @@ validation is 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/PSXRECOMP_OVERLAY_FILES.txt b/framework/FILES.txt similarity index 85% rename from PSXRECOMP_OVERLAY_FILES.txt rename to framework/FILES.txt index a0f2939..dedcd28 100644 --- a/PSXRECOMP_OVERLAY_FILES.txt +++ b/framework/FILES.txt @@ -1,5 +1,5 @@ -# Reviewed files applied from psxrecomp-overlay/ to the pinned framework. -# Prefix an entry with '+' only when the overlay intentionally creates it. +# Files of the pinned framework that this project changes, each with a patch under patches/. +# '+' marks a file the project adds, kept whole under files/. recompiler/include/code_generator.h recompiler/src/code_generator.cpp recompiler/src/config_loader.cpp @@ -9,6 +9,7 @@ recompiler/tests/recompiler_patch_test.cpp recompiler/tests/test_reachable_discovery_codegen.py runtime/CMakeLists.txt runtime/include/dirty_ram_interp.h ++runtime/include/game_host.h +runtime/include/gte_precision.h runtime/include/gpu.h +runtime/include/gpu_ws_hud_scale.h @@ -49,3 +50,5 @@ runtime/src/overlay_loader.c runtime/src/psx_cycles.c runtime/src/ws_aspect_cone_math.c runtime/tests/test_ws_aspect_cone_math.c +recompiler/seeds/openbios_elf_seeds.json +recompiler/src/full_function_emitter.cpp diff --git a/PSXRECOMP_PIN b/framework/PIN similarity index 100% rename from PSXRECOMP_PIN rename to framework/PIN diff --git a/framework/files/runtime/include/game_host.h b/framework/files/runtime/include/game_host.h new file mode 100644 index 0000000..d4a2ed0 --- /dev/null +++ b/framework/files/runtime/include/game_host.h @@ -0,0 +1,45 @@ +#ifndef PSX_GAME_HOST_H +#define PSX_GAME_HOST_H + +/* What the host asks of the game project it is built into. A project that defines PSX_HAS_GAME_HOST supplies every one. */ + +#include +#include + +typedef struct CPUState CPUState; + +#ifdef __cplusplus +namespace PSXRecompV4 { struct UserSettings; } +/* Called when the user's settings are read, before the drive first reads the disc. True asks for a boot without the BIOS animation. */ +bool psx_game_settings_loaded(const PSXRecompV4::UserSettings &settings); +extern "C" { +#endif + +/* Called when the disc's serial is known, `length` characters of it. 0 stops the start. */ +int psx_game_configure(const char *game_id, size_t length); +/* Nonzero asks for a boot without the BIOS animation whatever the settings say. */ +int psx_game_fast_boot_forced(void); +/* Called when save states have their folder, before any can be asked for. */ +void psx_game_states_configured(void); +/* Called after a save state is restored. */ +void psx_game_state_loaded(void); +/* A key went down or up. `plain` is set for a press without Ctrl, Alt or the system key and for every release. Nonzero when the game took a press. */ +int psx_game_key(int scancode, uint32_t keycode, int down, int plain); +/* The keyboard's state as a pad may read it: `count` keys, nonzero for down, copied from `keys` to `kept` with the keys + * the game is typing with shown as up. Called at every reading, which can be several a frame. */ +void psx_game_keys_for_pad(const uint8_t *keys, uint8_t *kept, int count); +/* Called around the sampling of the pad, outside netplay. */ +void psx_game_before_input(void); +void psx_game_after_input(void); +/* Nonzero when the four-cornered polygon whose command sits at `command_address` is the game's second copy of the one + * before it, grown outwards. Then each corner's source address and source word are those of the first copy's corner. */ +int psx_game_grown_quad_sources(uint32_t command_address, const uint32_t *packet, uint32_t source_address[4], + uint32_t source_word[4]); +/* Supplied by the generated code, from [[recompiler.instruction_hook]]: the game's calls for an instruction that ran from RAM. */ +void psx_game_instruction_ran(CPUState *cpu, uint32_t address, uint32_t instruction); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/psxrecomp-overlay/runtime/include/gpu_pinned_corner.h b/framework/files/runtime/include/gpu_pinned_corner.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_pinned_corner.h rename to framework/files/runtime/include/gpu_pinned_corner.h diff --git a/psxrecomp-overlay/runtime/include/gpu_temporal_match.h b/framework/files/runtime/include/gpu_temporal_match.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_temporal_match.h rename to framework/files/runtime/include/gpu_temporal_match.h diff --git a/psxrecomp-overlay/runtime/include/gpu_temporal_mesh.h b/framework/files/runtime/include/gpu_temporal_mesh.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_temporal_mesh.h rename to framework/files/runtime/include/gpu_temporal_mesh.h diff --git a/psxrecomp-overlay/runtime/include/gpu_temporal_sprite.h b/framework/files/runtime/include/gpu_temporal_sprite.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_temporal_sprite.h rename to framework/files/runtime/include/gpu_temporal_sprite.h diff --git a/psxrecomp-overlay/runtime/include/gpu_turned_perspective.h b/framework/files/runtime/include/gpu_turned_perspective.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_turned_perspective.h rename to framework/files/runtime/include/gpu_turned_perspective.h diff --git a/psxrecomp-overlay/runtime/include/gpu_world_depth.h b/framework/files/runtime/include/gpu_world_depth.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_world_depth.h rename to framework/files/runtime/include/gpu_world_depth.h diff --git a/psxrecomp-overlay/runtime/include/gpu_ws_frame_kind.h b/framework/files/runtime/include/gpu_ws_frame_kind.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_ws_frame_kind.h rename to framework/files/runtime/include/gpu_ws_frame_kind.h diff --git a/psxrecomp-overlay/runtime/include/gpu_ws_hud_scale.h b/framework/files/runtime/include/gpu_ws_hud_scale.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_ws_hud_scale.h rename to framework/files/runtime/include/gpu_ws_hud_scale.h diff --git a/psxrecomp-overlay/runtime/include/gpu_ws_hud_widget.h b/framework/files/runtime/include/gpu_ws_hud_widget.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_ws_hud_widget.h rename to framework/files/runtime/include/gpu_ws_hud_widget.h diff --git a/psxrecomp-overlay/runtime/include/gpu_ws_menu.h b/framework/files/runtime/include/gpu_ws_menu.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_ws_menu.h rename to framework/files/runtime/include/gpu_ws_menu.h diff --git a/psxrecomp-overlay/runtime/include/gpu_ws_screen_tile.h b/framework/files/runtime/include/gpu_ws_screen_tile.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_ws_screen_tile.h rename to framework/files/runtime/include/gpu_ws_screen_tile.h diff --git a/psxrecomp-overlay/runtime/include/gpu_ws_tag_match.h b/framework/files/runtime/include/gpu_ws_tag_match.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_ws_tag_match.h rename to framework/files/runtime/include/gpu_ws_tag_match.h diff --git a/psxrecomp-overlay/runtime/include/gte_precision.h b/framework/files/runtime/include/gte_precision.h similarity index 98% rename from psxrecomp-overlay/runtime/include/gte_precision.h rename to framework/files/runtime/include/gte_precision.h index ccef735..4bb4bba 100644 --- a/psxrecomp-overlay/runtime/include/gte_precision.h +++ b/framework/files/runtime/include/gte_precision.h @@ -1,7 +1,7 @@ /* Exact GTE projection provenance shared by the GTE and GPU paths. * - * SWC2 records the projection associated with a guest RAM word. Some games, - * including Disruptor, instead read SXY through MFC2 and later use an ordinary + * SWC2 records the projection associated with a guest RAM word. Some games + * instead read SXY through MFC2 and later use an ordinary * SW. Those routes are admitted only through opcode/dataflow-reviewed MFC2 * and store PCs registered by the game runtime; a separately reviewed route * may seed a finite set of scratchpad words for a later exact packet copy. diff --git a/psxrecomp-overlay/runtime/include/host_borderless.h b/framework/files/runtime/include/host_borderless.h similarity index 100% rename from psxrecomp-overlay/runtime/include/host_borderless.h rename to framework/files/runtime/include/host_borderless.h diff --git a/psxrecomp-overlay/runtime/include/host_pick_slot.h b/framework/files/runtime/include/host_pick_slot.h similarity index 100% rename from psxrecomp-overlay/runtime/include/host_pick_slot.h rename to framework/files/runtime/include/host_pick_slot.h diff --git a/psxrecomp-overlay/runtime/include/host_ui.h b/framework/files/runtime/include/host_ui.h similarity index 100% rename from psxrecomp-overlay/runtime/include/host_ui.h rename to framework/files/runtime/include/host_ui.h diff --git a/psxrecomp-overlay/runtime/include/iso_overlay.h b/framework/files/runtime/include/iso_overlay.h similarity index 100% rename from psxrecomp-overlay/runtime/include/iso_overlay.h rename to framework/files/runtime/include/iso_overlay.h diff --git a/psxrecomp-overlay/runtime/include/save_point.h b/framework/files/runtime/include/save_point.h similarity index 100% rename from psxrecomp-overlay/runtime/include/save_point.h rename to framework/files/runtime/include/save_point.h diff --git a/psxrecomp-overlay/runtime/src/gpu_gl_temporal.c.inc b/framework/files/runtime/src/gpu_gl_temporal.c.inc similarity index 100% rename from psxrecomp-overlay/runtime/src/gpu_gl_temporal.c.inc rename to framework/files/runtime/src/gpu_gl_temporal.c.inc diff --git a/framework/patches/recompiler/include/code_generator.h.patch b/framework/patches/recompiler/include/code_generator.h.patch new file mode 100644 index 0000000..638deb2 --- /dev/null +++ b/framework/patches/recompiler/include/code_generator.h.patch @@ -0,0 +1,34 @@ +diff --git a/recompiler/include/code_generator.h b/recompiler/include/code_generator.h +index db776ad6748afc7f0f31731ef183d9b6c6d76e0e..8e6e02e72c421768b1729489c1e161584027af27 100644 +--- a/recompiler/include/code_generator.h ++++ b/recompiler/include/code_generator.h +@@ -110,6 +110,13 @@ + // Signed lower-bound counterpart: `slti rt,sx,-W` compares against + // `-W-x_margin`, preserving the original result at 4:3. + std::set ws_cull_slti_lower_sites; ++ // Manual screen-window counterparts for zero-bound branches, constant / ++ // zero-store clamps, right-edge constants, and packed [left,right] spans. ++ // Each configured site is instruction-shape checked by codegen. ++ std::set ws_cull_lower_branch_sites; ++ std::set ws_cull_lower_value_sites; ++ std::set ws_cull_upper_value_sites; ++ std::set ws_cull_span_value_sites; + + // `bltz rs, reject` emitted through psx_ws_cull_bltz — the explicit + // LEFT-edge counterpart to ws_cull_slti_sites ([widescreen.cull] +@@ -148,6 +155,15 @@ + // Exact `addi[u] rt,zero,imm` 12-bit angular half-extents. The runtime + // scales tan(angle) by the current horizontal reveal factor. + std::vector ws_cull_angle_sites; ++ ++ // Exact `addi[u] rt,rs,+/-half_fov` wrapping-yaw sites. The runtime widens ++ // only the signed offset in the site's declared angular unit system. ++ std::vector ws_cull_yaw_cone_sites; ++ ++ // Exact signed `slt dst,bound,horizontal` side-plane comparisons. The ++ // horizontal operand is projection-scaled only while widescreen is live. ++ std::vector ++ ws_cull_horizontal_slt_sites; + + // Exact model-participation cosine compares that gain a camera-horizontal + // aspect envelope while preserving the vanilla vertical cone. diff --git a/framework/patches/recompiler/seeds/openbios_elf_seeds.json.patch b/framework/patches/recompiler/seeds/openbios_elf_seeds.json.patch new file mode 100644 index 0000000..6e699e2 --- /dev/null +++ b/framework/patches/recompiler/seeds/openbios_elf_seeds.json.patch @@ -0,0 +1,25 @@ +diff --git a/recompiler/seeds/openbios_elf_seeds.json b/recompiler/seeds/openbios_elf_seeds.json +index 32c77f16e016ecfa8c96e6a295fb5e8892371ffd..cb55a0042829b79c3c5e4d639139d10386d181d1 100644 +--- a/recompiler/seeds/openbios_elf_seeds.json ++++ b/recompiler/seeds/openbios_elf_seeds.json +@@ -1,7 +1,7 @@ + { + "schema": "psxrecomp phase2 seeds", + "source": "gen_bios_seeds_from_elf.py over openbios.elf, shell.elf + 5 empirical seeds from openbios_dispatch_miss.json", +- "seed_count": 646, ++ "seed_count": 647, + "seeds": [ + { + "address": "0xBFC00000", +@@ -1429,6 +1429,11 @@ + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { ++ "address": "0xBFC095F8", ++ "label": "data_after_osDbgPrintf", ++ "rationale": "verified exclusive end of osDbgPrintf; prevents unconditional-branch fallthrough analysis from entering adjacent data" ++ }, ++ { + "address": "0xBFC0A500", + "label": "elf_entry_shell_elf", + "rationale": "ELF entry point (e_entry) of shell.elf" diff --git a/framework/patches/recompiler/src/code_generator.cpp.patch b/framework/patches/recompiler/src/code_generator.cpp.patch new file mode 100644 index 0000000..bb7e319 --- /dev/null +++ b/framework/patches/recompiler/src/code_generator.cpp.patch @@ -0,0 +1,829 @@ +diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp +index a2154f104ac7f6fb7a1f2bb9db772de7a4745099..f54752e2a15f4535694ca6c2fc2e2651490102e0 100644 +--- a/recompiler/src/code_generator.cpp ++++ b/recompiler/src/code_generator.cpp +@@ -18,6 +18,297 @@ + + namespace PSXRecomp { + ++/* Disruptor's resident geometry builders read SXY2 with MFC2 and later use an ++ * ordinary SW. Keep these hooks deliberately narrower than the general code ++ * generator: the runtime also checks the game serial, PC and raw instruction. ++ * A shape mismatch at either source PC is a build error, not a silent widening ++ * of the provenance trust boundary. */ ++static bool disruptor_precision_mfc2_pc(uint32_t address) { ++ return address == 0x8004633Cu || address == 0x8004795Cu; ++} ++ ++static bool disruptor_precision_store_pc(uint32_t address) { ++ switch (address) { ++ case 0x8004641Cu: ++ case 0x800464BCu: ++ case 0x80046524u: ++ case 0x80046540u: ++ case 0x80046594u: ++ case 0x800465D8u: ++ return true; ++ default: ++ return false; ++ } ++} ++ ++static bool disruptor_precision_scratch_store_pc(uint32_t address) { ++ return address == 0x80047A0Cu; ++} ++ ++struct DisruptorPrecisionCopySite { ++ uint32_t pc; ++ uint32_t instruction; ++ uint8_t gpr; ++}; ++ ++static const DisruptorPrecisionCopySite kDisruptorPrecisionCopyLoads[] = { ++ {0x80046C4Cu, 0x8D0C0004u, 12u}, ++ {0x80046C50u, 0x8D2D0004u, 13u}, ++ {0x80046C54u, 0x8D4E0004u, 14u}, ++ {0x80046C60u, 0x8D6F0004u, 15u}, ++ {0x80047A80u, 0x8CA80084u, 8u}, ++ {0x80047A84u, 0x8CA900A4u, 9u}, ++ {0x80047A88u, 0x8CAA00C4u, 10u}, ++ {0x80047A8Cu, 0x8CAB00BCu, 11u}, ++ {0x80047B40u, 0x8CA800A4u, 8u}, ++ {0x80047B44u, 0x8CA9008Cu, 9u}, ++ {0x80047B48u, 0x8CAA00ACu, 10u}, ++ {0x80047B4Cu, 0x8CAB00C4u, 11u}, ++ {0x80047C00u, 0x8CA800C4u, 8u}, ++ {0x80047C04u, 0x8CA900ACu, 9u}, ++ {0x80047C08u, 0x8CAA0094u, 10u}, ++ {0x80047C0Cu, 0x8CAB00B4u, 11u}, ++ {0x80047CC4u, 0x8CA800BCu, 8u}, ++ {0x80047CC8u, 0x8CA900C4u, 9u}, ++ {0x80047CCCu, 0x8CAA00B4u, 10u}, ++ {0x80047CD0u, 0x8CAB009Cu, 11u}, ++ {0x80047D6Cu, 0x8CA80084u, 8u}, ++ {0x80047D70u, 0x8CA9008Cu, 9u}, ++ {0x80047D74u, 0x8CAA0094u, 10u}, ++ {0x80047D78u, 0x8CAB009Cu, 11u}, ++}; ++ ++static const DisruptorPrecisionCopySite kDisruptorPrecisionCopyStores[] = { ++ {0x80046D04u, 0xAF2C0008u, 12u}, ++ {0x80046D08u, 0xAF2D0014u, 13u}, ++ {0x80046D10u, 0xAF2E002Cu, 14u}, ++ {0x80046D0Cu, 0xAF2F0020u, 15u}, ++ {0x80047A90u, 0xAF280008u, 8u}, ++ {0x80047A94u, 0xAF290014u, 9u}, ++ {0x80047A9Cu, 0xAF2A002Cu, 10u}, ++ {0x80047A98u, 0xAF2B0020u, 11u}, ++ {0x80047B50u, 0xAF280008u, 8u}, ++ {0x80047B54u, 0xAF290014u, 9u}, ++ {0x80047B5Cu, 0xAF2A002Cu, 10u}, ++ {0x80047B58u, 0xAF2B0020u, 11u}, ++ {0x80047C10u, 0xAF280008u, 8u}, ++ {0x80047C14u, 0xAF290014u, 9u}, ++ {0x80047C1Cu, 0xAF2A002Cu, 10u}, ++ {0x80047C18u, 0xAF2B0020u, 11u}, ++ {0x80047CD4u, 0xAF280008u, 8u}, ++ {0x80047CD8u, 0xAF290014u, 9u}, ++ {0x80047CE0u, 0xAF2A002Cu, 10u}, ++ {0x80047CDCu, 0xAF2B0020u, 11u}, ++ {0x80047D7Cu, 0xAF280008u, 8u}, ++ {0x80047D80u, 0xAF290014u, 9u}, ++ {0x80047D88u, 0xAF2A002Cu, 10u}, ++ {0x80047D84u, 0xAF2B0020u, 11u}, ++}; ++ ++template ++static const DisruptorPrecisionCopySite *disruptor_precision_copy_site( ++ const DisruptorPrecisionCopySite (&sites)[N], uint32_t address) { ++ for (const DisruptorPrecisionCopySite &site : sites) { ++ if (site.pc == address) return &site; ++ } ++ return nullptr; ++} ++ ++/* Disruptor has no architectural pitch state. The project implements its ++ * opt-in vertical camera at a deliberately finite set of resident instruction ++ * sites: CPU-projected screen-Y centres, proven vertical participation tests, ++ * the two projectile-construction convergence points, and the common renderer ++ * epilogue. Every complete instruction word is pinned so a different retail ++ * revision fails generation instead of silently modifying unrelated code. ++ * ++ * The generated callback is appended after the vanilla instruction. At zero ++ * pitch it is an exact no-op; the runtime callback decodes the pinned word to ++ * identify the destination register or the required post-instruction action. */ ++struct DisruptorVerticalCameraSite { ++ uint32_t pc; ++ uint32_t instruction; ++}; ++ ++static const DisruptorVerticalCameraSite kDisruptorVerticalCameraSites[] = { ++ /* CPU world projection centres (ORI rt,zero,120). */ ++ {0x8003AD54u, 0x34020078u}, ++ {0x8003ADE4u, 0x34020078u}, ++ {0x8003B140u, 0x34020078u}, ++ {0x8003B1ECu, 0x34020078u}, ++ {0x8003B764u, 0x34020078u}, ++ {0x8003B990u, 0x34020078u}, ++ {0x8003BDA4u, 0x34020078u}, ++ {0x8003C4B4u, 0x34020078u}, ++ {0x8003C99Cu, 0x34020078u}, ++ {0x8003D07Cu, 0x34020078u}, ++ {0x8003D398u, 0x34030078u}, ++ {0x8003F6B0u, 0x34020078u}, ++ /* Proven camera-vertical object participation pairs. */ ++ {0x8003B900u, 0x0205102Au}, ++ {0x8003B90Cu, 0x0202102Au}, ++ {0x8003BD14u, 0x0206102Au}, ++ {0x8003BD20u, 0x0202102Au}, ++ {0x8003CDB8u, 0x0065102Au}, ++ {0x8003CDC4u, 0x0062102Au}, ++ /* Normal-weapon and psionic construction convergence loads. */ ++ {0x8002E4B8u, 0x92640022u}, ++ {0x8002EAF0u, 0x92430020u}, ++ /* Common renderer epilogue: restore the neutral projection horizon. */ ++ {0x8004279Cu, 0x8FBF016Cu}, ++}; ++ ++static const DisruptorVerticalCameraSite *disruptor_vertical_camera_site( ++ uint32_t address) { ++ for (const DisruptorVerticalCameraSite &site : ++ kDisruptorVerticalCameraSites) { ++ if (site.pc == address) return &site; ++ } ++ return nullptr; ++} ++ ++/* Disruptor's experimental far-rendering override is host-only: reviewed ++ * loads of the retail far/fade globals are substituted after the original LW, ++ * leaving guest RAM untouched. Additional reviewed portal, object-decision, ++ * and packet-boundary instructions feed read-only diagnostics through the same ++ * callback. The renderer entry starts a measurement span and the reviewed ++ * common epilogue closes it. Keep this callback independent from the ++ * vertical-camera dispatcher so either enhancement can be built and tested in ++ * isolation. */ ++struct DisruptorFarRenderingSite { ++ uint32_t pc; ++ uint32_t instruction; ++}; ++ ++static const DisruptorFarRenderingSite kDisruptorFarRenderingSites[] = { ++ /* Portal traversal diagnostics: recursion depth, unique-room completion, ++ * and the three candidate-distance values before their branch tests. */ ++ {0x8003A0F8u, 0x93B300C0u}, ++ {0x8003A390u, 0xA082001Fu}, ++ {0x8003A92Cu, 0x00005812u}, ++ {0x8003A964u, 0x00681021u}, ++ {0x8003AA74u, 0x00AC1021u}, ++ /* Far-distance loads from $gp + 0x4A0. */ ++ {0x8003A914u, 0x8F8C04A0u}, ++ {0x8003B8C8u, 0x8F8304A0u}, ++ {0x8003BCE0u, 0x8F8204A0u}, ++ {0x8003C168u, 0x8F8304A0u}, ++ {0x8003CD64u, 0x8F8304A0u}, ++ /* Object distance-decision results. */ ++ {0x8003B8D8u, 0x0072182Au}, ++ {0x8003BCECu, 0x0053102Au}, ++ {0x8003C178u, 0x0077182Au}, ++ {0x8003CD70u, 0x0077182Au}, ++ /* Fade-start loads from $gp + 0x494. */ ++ {0x8003B2CCu, 0x8F820494u}, ++ {0x8003BA30u, 0x8F820494u}, ++ {0x8003BE40u, 0x8F820494u}, ++ {0x8003C628u, 0x8F820494u}, ++ {0x8003C878u, 0x8F820494u}, ++ {0x8003D28Cu, 0x8F820494u}, ++ {0x8003FB0Cu, 0x8F840494u}, ++ /* Billboard post-pass CLUT-row selection. */ ++ {0x80043184u, 0x8F820494u}, ++ /* Main-renderer visible-list and packet boundaries. */ ++ {0x800410E0u, 0x93830310u}, ++ {0x800410F8u, 0x8F820680u}, ++ {0x800411D8u, 0x8F8301D4u}, ++ /* Common renderer epilogue. */ ++ {0x8004279Cu, 0x8FBF016Cu}, ++}; ++ ++static const DisruptorFarRenderingSite *disruptor_far_rendering_site( ++ uint32_t address) { ++ for (const DisruptorFarRenderingSite &site : ++ kDisruptorFarRenderingSites) { ++ if (site.pc == address) return &site; ++ } ++ return nullptr; ++} ++ ++/* Disruptor's world actors and pickups are CPU-built POLY_FT4 billboards. ++ * Their projected centre goes through the reviewed classic-wide X squash, but ++ * their retail pixel width does not. These are five final packet writes plus ++ * the deferred base-actor submission seam; the host callback tags only a ++ * completed, shape-validated packet for proportion repair. */ ++struct DisruptorBillboardAspectSite { ++ uint32_t pc; ++ uint32_t instruction; ++ uint8_t packet_gpr; ++}; ++ ++static const DisruptorBillboardAspectSite kDisruptorBillboardAspectSites[] = { ++ {0x8003BB88u, 0xA6030016u, 16u}, ++ {0x8003BFB0u, 0xA6030016u, 16u}, ++ {0x8003C848u, 0xA6030016u, 16u}, ++ {0x8003CAD4u, 0xA6020016u, 16u}, ++ {0x8003D488u, 0xA2060025u, 16u}, ++ {0x800433A0u, 0x02A02821u, 5u}, ++}; ++ ++static const DisruptorBillboardAspectSite *disruptor_billboard_aspect_site( ++ uint32_t address) { ++ for (const DisruptorBillboardAspectSite &site : ++ kDisruptorBillboardAspectSites) { ++ if (site.pc == address) return &site; ++ } ++ return nullptr; ++} ++ ++/* Disruptor's sky backdrop routine lays SPRT tiles over the opening it has ++ * already clamped to the 320-pixel frame. These four seams hand the host the ++ * left edge, the panorama scroll and the right edge before the tile loop reads ++ * them, so classic widescreen can ask for the columns a wider view needs. */ ++struct DisruptorBackdropTileSite { ++ uint32_t pc; ++ uint32_t instruction; ++}; ++ ++static const DisruptorBackdropTileSite kDisruptorBackdropTileSites[] = { ++ {0x8003B328u, 0x8FB80040u}, ++ {0x8003B338u, 0x00621821u}, ++ {0x8003B348u, 0x8FB80028u}, ++ {0x8003B5A8u, 0x8F8205B8u}, ++ {0x8003B638u, 0x8FB80040u}, ++}; ++ ++static const DisruptorBackdropTileSite kDisruptorSpriteDepthSites[] = { ++ {0x8003B98Cu, 0xAFA20010u}, ++ {0x8003BDA0u, 0xAFA20010u}, ++ {0x8003C4B0u, 0xAFA20010u}, ++ {0x8003D078u, 0xAFA20010u}, ++ {0x8003D1B4u, 0xA4379EA0u}, ++}; ++ ++static const DisruptorBackdropTileSite *disruptor_sprite_depth_site( ++ uint32_t address) { ++ for (const DisruptorBackdropTileSite &site : kDisruptorSpriteDepthSites) { ++ if (site.pc == address) return &site; ++ } ++ return nullptr; ++} ++ ++static const DisruptorBackdropTileSite kDisruptorIntroSkipSites[] = { ++ {0x800147B0u, 0x2A820003u}, ++ {0x80020A80u, 0x2A02005Eu}, ++ {0x80048678u, 0x30420940u}, ++}; ++ ++static const DisruptorBackdropTileSite *disruptor_intro_skip_site( ++ uint32_t address) { ++ for (const DisruptorBackdropTileSite &site : kDisruptorIntroSkipSites) { ++ if (site.pc == address) return &site; ++ } ++ return nullptr; ++} ++ ++static const DisruptorBackdropTileSite *disruptor_backdrop_tile_site( ++ uint32_t address) { ++ for (const DisruptorBackdropTileSite &site : kDisruptorBackdropTileSites) { ++ if (site.pc == address) return &site; ++ } ++ return nullptr; ++} ++ + static bool codegen_cycle_per_insn() { + // DEFAULT ON for the faithful-timing (cycle-audit) branch: each instruction + // charges its cost at its own site, so the running cycle count is correct +@@ -723,6 +1014,36 @@ + uint32_t opcode = (instr >> 26) & 0x3F; + uint32_t rs = get_rs(instr); + uint32_t rt = get_rt(instr); ++ ++ // Explicit manual-screen lower-edge branches. Disruptor-class portal and ++ // wall renderers project in CPU code and use zero-bound branches rather ++ // than the usual SLTI/BLTZ funnel. Move that zero edge to -x_margin while ++ // retaining the original branch sense. At margin 0 these expressions are ++ // exactly the vanilla predicates. ++ if (config_.ws_cull_lower_branch_sites.count(addr)) { ++ if (opcode == 0x01) { ++ const uint32_t regimm_op = (instr >> 16) & 0x1Fu; ++ if (regimm_op == 0x00u) ++ return fmt::format("(int32_t){} < -psx_ws_x_margin()" ++ " /* ws manual lower edge */", reg_name(rs)); ++ if (regimm_op == 0x01u) ++ return fmt::format("(int32_t){} >= -psx_ws_x_margin()" ++ " /* ws manual lower edge */", reg_name(rs)); ++ } ++ if (opcode == 0x06u || opcode == 0x16u) ++ return fmt::format("(int32_t){} <= -psx_ws_x_margin()" ++ " /* ws manual lower edge */", reg_name(rs)); ++ if (opcode == 0x07u || opcode == 0x17u) ++ return fmt::format("(int32_t){} > -psx_ws_x_margin()" ++ " /* ws manual lower edge */", reg_name(rs)); ++ if (!config_.overlay_mode) { ++ fmt::print(stderr, ++ "ERROR: [widescreen.cull] lower_branch site 0x{:08X} " ++ "is not bltz/bgez/blez/bgtz (0x{:08X})\n", ++ addr, instr); ++ std::exit(1); ++ } ++ } + + // REGIMM branches. R3000A hardware decodes EVERY rt value here, not just + // the four assembler mnemonics: the branch sense is rt bit 0 (0 = bltz, +@@ -784,6 +1105,78 @@ + + std::string code; + ++ const DisruptorVerticalCameraSite *vertical_camera_site = ++ disruptor_vertical_camera_site(addr); ++ if (vertical_camera_site && ++ instr != vertical_camera_site->instruction) { ++ if (!config_.overlay_mode) { ++ throw std::runtime_error(fmt::format( ++ "Disruptor vertical-camera site 0x{:08X} expected word " ++ "0x{:08X}, found 0x{:08X}", ++ addr, vertical_camera_site->instruction, instr)); ++ } ++ vertical_camera_site = nullptr; ++ } ++ ++ const DisruptorFarRenderingSite *far_rendering_site = ++ disruptor_far_rendering_site(addr); ++ if (far_rendering_site && instr != far_rendering_site->instruction) { ++ if (!config_.overlay_mode) { ++ throw std::runtime_error(fmt::format( ++ "Disruptor far-rendering site 0x{:08X} expected word " ++ "0x{:08X}, found 0x{:08X}", ++ addr, far_rendering_site->instruction, instr)); ++ } ++ far_rendering_site = nullptr; ++ } ++ ++ const DisruptorBillboardAspectSite *billboard_aspect_site = ++ disruptor_billboard_aspect_site(addr); ++ if (billboard_aspect_site && ++ instr != billboard_aspect_site->instruction) { ++ if (!config_.overlay_mode) { ++ throw std::runtime_error(fmt::format( ++ "Disruptor billboard-aspect site 0x{:08X} expected word " ++ "0x{:08X}, found 0x{:08X}", ++ addr, billboard_aspect_site->instruction, instr)); ++ } ++ billboard_aspect_site = nullptr; ++ } ++ ++ const DisruptorBackdropTileSite *backdrop_tile_site = ++ disruptor_backdrop_tile_site(addr); ++ if (backdrop_tile_site && instr != backdrop_tile_site->instruction) { ++ if (!config_.overlay_mode) { ++ throw std::runtime_error(fmt::format( ++ "Disruptor backdrop-tile site 0x{:08X} expected word " ++ "0x{:08X}, found 0x{:08X}", ++ addr, backdrop_tile_site->instruction, instr)); ++ } ++ backdrop_tile_site = nullptr; ++ } ++ const DisruptorBackdropTileSite *sprite_depth_site = ++ disruptor_sprite_depth_site(addr); ++ if (sprite_depth_site && instr != sprite_depth_site->instruction) { ++ if (!config_.overlay_mode) { ++ throw std::runtime_error(fmt::format( ++ "Disruptor sprite-depth site 0x{:08X} expected word " ++ "0x{:08X}, found 0x{:08X}", ++ addr, sprite_depth_site->instruction, instr)); ++ } ++ sprite_depth_site = nullptr; ++ } ++ const DisruptorBackdropTileSite *intro_skip_site = ++ disruptor_intro_skip_site(addr); ++ if (intro_skip_site && instr != intro_skip_site->instruction) { ++ if (!config_.overlay_mode) { ++ throw std::runtime_error(fmt::format( ++ "Disruptor intro-skip site 0x{:08X} expected word " ++ "0x{:08X}, found 0x{:08X}", ++ addr, intro_skip_site->instruction, instr)); ++ } ++ intro_skip_site = nullptr; ++ } ++ + // Full-word-guarded terrain-frustum half-angle constants. Scaling the + // tangent is the geometric counterpart of widening the horizontal + // projection; adding screen pixels directly to 12-bit angle units is not. +@@ -812,6 +1205,67 @@ + "{} = psx_ws_angle_widen({}u);" + " /* aspect-scaled terrain frustum half-angle */{}", + reg_name(dst), (uint32_t)vanilla, comment); ++ } ++ ++ // Full-word-guarded wrapping-yaw half-FOV offsets. Disruptor, for example, ++ // seeds portal traversal with yaw +/-32 in an eight-bit turn. Widen the ++ // signed offset geometrically while leaving the game's base yaw and wrap ++ // behavior untouched. ++ for (const auto& site : config_.ws_cull_yaw_cone_sites) { ++ if ((site.address & 0x1FFFFFFFu) != ++ (addr & 0x1FFFFFFFu)) continue; ++ if (instr != site.expected) { ++ if (config_.overlay_mode) continue; ++ fmt::print(stderr, ++ "ERROR: yaw cone expected 0x{:08X} at 0x{:08X}, " ++ "found 0x{:08X}\n", ++ site.expected, addr, instr); ++ std::exit(1); ++ } ++ if ((opcode != 0x08u && opcode != 0x09u) || ++ get_rs(instr) == 0u || site.full_turn < 16u) { ++ fmt::print(stderr, ++ "ERROR: yaw cone site 0x{:08X} is not a valid " ++ "ADDI/ADDIU rt,rs,offset (0x{:08X})\n", ++ addr, instr); ++ std::exit(1); ++ } ++ const uint32_t dst = get_rt(instr); ++ const uint32_t src = get_rs(instr); ++ const int32_t vanilla = (int16_t)(instr & 0xFFFFu); ++ return fmt::format( ++ "{} = {} + (uint32_t)psx_ws_yaw_cone_offset({}, {}u);" ++ " /* aspect-scaled wrapping yaw half-FOV */{}", ++ reg_name(dst), reg_name(src), vanilla, site.full_turn, comment); ++ } ++ ++ // Symmetric camera-horizontal side-plane rejects. Scaling the horizontal ++ // operand is the exact counterpart of widening the projection; vertical ++ // tests in the same functions remain completely vanilla. ++ for (const auto& site : config_.ws_cull_horizontal_slt_sites) { ++ if ((site.address & 0x1FFFFFFFu) != ++ (addr & 0x1FFFFFFFu)) continue; ++ if (instr != site.expected) { ++ if (config_.overlay_mode) continue; ++ fmt::print(stderr, ++ "ERROR: horizontal SLT expected 0x{:08X} at " ++ "0x{:08X}, found 0x{:08X}\n", ++ site.expected, addr, instr); ++ std::exit(1); ++ } ++ if (opcode != 0u || funct != 0x2Au || ++ ((instr >> 6) & 31u) != 0u) { ++ fmt::print(stderr, ++ "ERROR: horizontal SLT site 0x{:08X} is not signed " ++ "register-register SLT (0x{:08X})\n", ++ addr, instr); ++ std::exit(1); ++ } ++ return fmt::format( ++ "{} = psx_ws_horizontal_slt({}, {});" ++ " /* aspect-scaled camera-horizontal side plane */{}", ++ reg_name(get_rd(instr)), reg_name(get_rs(instr)), ++ reg_name(get_rt(instr)), comment); + } + + for (const auto& site : config_.ws_signed_x_bound_sites) { +@@ -971,6 +1425,76 @@ + // same address holds different code across scene variants, so a mismatch is + // expected — apply the transform only where the bytes match, else fall + // through to the vanilla translation (see CodeGenConfig::overlay_mode). ++ // Manual screen-window bound materialization. These are deliberately ++ // exact-site and instruction-shape guarded: a generic 320 constant can be ++ // a centre-relative divider, texture width, or packed span, and those ++ // meanings are not interchangeable. ++ const bool ws_lower_value = config_.ws_cull_lower_value_sites.count(addr); ++ const bool ws_upper_value = config_.ws_cull_upper_value_sites.count(addr); ++ if (ws_lower_value || ws_upper_value) { ++ const char* helper = ws_lower_value ++ ? "psx_ws_cull_lower_value" : "psx_ws_cull_upper_value"; ++ const char* tag = ws_lower_value ? "lower" : "upper"; ++ if ((opcode == 0x08u || opcode == 0x09u || opcode == 0x0Du) && ++ get_rs(instr) == 0u) { ++ const uint32_t dst = get_rt(instr); ++ const int32_t vanilla = opcode == 0x0Du ++ ? (int32_t)get_imm16_u(instr) : (int32_t)get_imm16(instr); ++ return fmt::format( ++ "{} = (uint32_t){}({});" ++ " /* ws manual {} bound value */{}", ++ reg_name(dst), helper, vanilla, tag, comment); ++ } ++ if (ws_lower_value && opcode == 0x00u && ++ (funct == 0x21u || funct == 0x25u) && ++ get_rs(instr) == 0u && get_rt(instr) == 0u) { ++ return fmt::format( ++ "{} = (uint32_t)psx_ws_cull_lower_value(0);" ++ " /* ws manual lower clamp */{}", ++ reg_name(get_rd(instr)), comment); ++ } ++ if (ws_lower_value && opcode == 0x2Bu && get_rt(instr) == 0u) { ++ const uint32_t base = get_rs(instr); ++ const int16_t offset = get_imm16(instr); ++ const std::string store_addr = offset == 0 ++ ? reg_name(base) ++ : fmt::format("{} + {}", reg_name(base), (int)offset); ++ return fmt::format( ++ "g_debug_last_store_pc = 0x{:08X}u; " ++ "psx_store_cycle_barrier(); cpu->write_word({}, " ++ "(uint32_t)psx_ws_cull_lower_value(0));" ++ " /* ws manual lower clamp store */{}", ++ addr, store_addr, comment); ++ } ++ if (!config_.overlay_mode) { ++ fmt::print(stderr, ++ "ERROR: [widescreen.cull] {}_value site 0x{:08X} " ++ "is not a supported constant/clamp form (0x{:08X})\n", ++ tag, addr, instr); ++ std::exit(1); ++ } ++ } ++ if (config_.ws_cull_span_value_sites.count(addr)) { ++ if ((opcode == 0x08u || opcode == 0x09u || opcode == 0x0Du) && ++ get_rs(instr) == 0u) { ++ const uint32_t dst = get_rt(instr); ++ const uint32_t vanilla = opcode == 0x0Du ++ ? (uint32_t)get_imm16_u(instr) ++ : (uint32_t)(int32_t)get_imm16(instr); ++ return fmt::format( ++ "{} = psx_ws_cull_span_value(0x{:08X}u);" ++ " /* ws manual packed [left,right] span */{}", ++ reg_name(dst), vanilla, comment); ++ } ++ if (!config_.overlay_mode) { ++ fmt::print(stderr, ++ "ERROR: [widescreen.cull] span_value site 0x{:08X} " ++ "is not ADDI/ADDIU/ORI rt,zero,imm (0x{:08X})\n", ++ addr, instr); ++ std::exit(1); ++ } ++ } ++ + if (config_.ws_cull_bias_sites.count(addr)) { + if (opcode == 0x08 || opcode == 0x09) { // addi / addiu + uint32_t rs = get_rs(instr), rt = get_rt(instr); +@@ -1217,11 +1741,15 @@ + } + // Overlay variant at the same address: leave nonmatching code unchanged. + } +- // Widescreen backdrop screenX squash ([widescreen.backdrop] x_sites). The +- // site is the `sh rt,off(base)` storing a parallax 2D backdrop layer's +- // final screen-X; squash the stored value around the screen centre so the +- // un-GTE'd backdrop tracks the 16:9 FOV (psx_ws_backdrop_x is identity at +- // 4:3). Instruction type is verified; a mismatch is a loud build error. ++ // Widescreen CPU-projected screen-X correction ++ // ([widescreen.backdrop] x_sites). Most sites are `sh rt,off(base)` stores ++ // from parallax layers. Some 3D engines also calculate their portal or ++ // billboard projection wholly on the CPU and materialize the final X with ++ // `addiu rt,rs,centre`; route those exact sites through the same classic ++ // projection squash as the GTE. This keeps the guest's original 0..W clip ++ // invariants intact instead of widening portal spans into negative values. ++ // Both helpers are identities at 4:3. Instruction shape is verified so a ++ // stale game address fails loudly rather than changing unrelated code. + if (config_.ws_backdrop_x_sites.count(addr)) { + if (opcode == 0x29) { // sh + uint32_t rs = get_rs(instr), rt = get_rt(instr); +@@ -1232,9 +1760,16 @@ + reg_name(rs), reg_name(rt), comment); + return store_pc + fmt::format("psx_store_cycle_barrier(); cpu->write_half({} + {}, (uint16_t)psx_ws_backdrop_x((int16_t){}));{}", + reg_name(rs), offset, reg_name(rt), comment); ++ } else if (opcode == 0x09) { // addiu rt,rs,screen-centre ++ uint32_t rs = get_rs(instr), rt = get_rt(instr); ++ int16_t imm = get_imm16(instr); ++ return fmt::format( ++ "{} = (uint32_t)psx_ws_project_x((int32_t)({} + (uint32_t){}));" ++ " /* ws CPU-projected screen-x */{}", ++ reg_name(rt), reg_name(rs), (int32_t)imm, comment); + } else if (!config_.overlay_mode) { + fmt::print(stderr, "ERROR: [widescreen.backdrop] x site 0x{:08X} is not " +- "sh (opcode 0x{:02X})\n", addr, opcode); ++ "sh or addiu (opcode 0x{:02X})\n", addr, opcode); + std::exit(1); + } + // overlay variant: addr is different code here — fall through to vanilla. +@@ -1486,11 +2021,26 @@ + const std::string gte_read = fmt::format( + "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_gte_read(cpu, {});\n#endif\n ", rt); + if (cop_op == 0x00) { // MFC2 - move from COP2 data ++ std::string value; + if (PSXRecompGTERegisters::data_read_needs_helper(static_cast(rd))) { +- code = gte_read + fmt::format("{} = gte_read_data(cpu, {}); /* mfc2 */", reg_name(rt), rd); ++ value = fmt::format("gte_read_data(cpu, {})", rd); + } else { +- code = gte_read + fmt::format("{} = cpu->gte_data[{}]; /* mfc2 */", reg_name(rt), rd); ++ value = fmt::format("cpu->gte_data[{}]", rd); + } ++ if (disruptor_precision_mfc2_pc(addr)) { ++ if (rt != 10u || rd != 14u || instr != 0x480A7000u) { ++ throw std::runtime_error(fmt::format( ++ "Disruptor precision MFC2 site 0x{:08X} no longer " ++ "matches MFC2 r10,SXY2 (word 0x{:08X})", ++ addr, instr)); ++ } ++ value = fmt::format( ++ "gte_precision_mfc2_pc_read(0x{:08X}u, " ++ "0x{:08X}u, {}, {})", ++ addr, instr, rd, value); ++ } ++ code = gte_read + fmt::format( ++ "{} = {}; /* mfc2 */", reg_name(rt), value); + } else if (cop_op == 0x02) { // CFC2 - move from COP2 control + if (PSXRecompGTERegisters::ctrl_read_needs_helper(static_cast(rd))) { + code = gte_read + fmt::format("{} = gte_read_ctrl(cpu, {}); /* cfc2 */", reg_name(rt), rd); +@@ -1521,7 +2071,35 @@ + case 0x20: code = translate_lb(instr); break; // lb + case 0x21: code = translate_lh(instr); break; // lh + case 0x22: code = translate_lwl(instr); break; // lwl +- case 0x23: code = translate_lw(instr); break; // lw ++ case 0x23: { // lw ++ const DisruptorPrecisionCopySite *site = ++ disruptor_precision_copy_site( ++ kDisruptorPrecisionCopyLoads, addr); ++ if (!site) { ++ code = translate_lw(instr); ++ break; ++ } ++ const uint32_t rs = get_rs(instr); ++ const uint32_t rt = get_rt(instr); ++ const int16_t offset = get_imm16(instr); ++ if (instr != site->instruction || rt != site->gpr) { ++ throw std::runtime_error(fmt::format( ++ "Disruptor precision-copy LW site 0x{:08X} no longer " ++ "matches word 0x{:08X}/r{} (found 0x{:08X})", ++ addr, site->instruction, site->gpr, instr)); ++ } ++ const std::string load_addr = offset == 0 ++ ? reg_name(rs) ++ : fmt::format("{} + {}", reg_name(rs), offset); ++ code = fmt::format( ++ "{{ uint32_t _precision_copy_addr = {}; " ++ "{} = psx_cyc_load_word(cpu, _precision_copy_addr, {}, " ++ "0x{:X}u); {} = gte_precision_copy_pc_read(" ++ "0x{:08X}u, 0x{:08X}u, {}, _precision_copy_addr, {}); }}", ++ load_addr, reg_name(rt), rt, 1u << rs, reg_name(rt), ++ addr, instr, rt, reg_name(rt)); ++ break; ++ } + case 0x24: code = translate_lbu(instr); break; // lbu + case 0x25: code = translate_lhu(instr); break; // lhu + case 0x26: code = translate_lwr(instr); break; // lwr +@@ -1533,7 +2111,69 @@ + case 0x28: code = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr) + translate_sb(instr); break; // sb + case 0x29: code = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr) + translate_sh(instr); break; // sh + case 0x2A: code = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr) + translate_swl(instr); break; // swl +- case 0x2B: code = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr) + translate_sw(instr); break; // sw ++ case 0x2B: { // sw ++ const DisruptorPrecisionCopySite *copy_site = ++ disruptor_precision_copy_site( ++ kDisruptorPrecisionCopyStores, addr); ++ if (copy_site) { ++ const uint32_t rs = get_rs(instr); ++ const uint32_t rt = get_rt(instr); ++ const int16_t offset = get_imm16(instr); ++ if (instr != copy_site->instruction || ++ rt != copy_site->gpr) { ++ throw std::runtime_error(fmt::format( ++ "Disruptor precision-copy SW site 0x{:08X} no " ++ "longer matches word 0x{:08X}/r{} (found " ++ "0x{:08X})", ++ addr, copy_site->instruction, ++ copy_site->gpr, instr)); ++ } ++ const std::string store_addr = offset == 0 ++ ? reg_name(rs) ++ : fmt::format("{} + {}", reg_name(rs), offset); ++ code = fmt::format( ++ "g_debug_last_store_pc = 0x{:08X}u; " ++ "{{ psx_store_cycle_barrier(); " ++ "uint32_t _precision_copy_addr = {}; " ++ "uint32_t _precision_copy_value = {}; " ++ "cpu->write_word(_precision_copy_addr, " ++ "_precision_copy_value); " ++ "gte_precision_copy_pc_word(0x{:08X}u, " ++ "0x{:08X}u, {}, _precision_copy_addr, " ++ "_precision_copy_value); }}", ++ addr, store_addr, reg_name(rt), addr, instr, rt); ++ break; ++ } ++ code = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr) + ++ translate_sw(instr); ++ if (disruptor_precision_store_pc(addr) || ++ disruptor_precision_scratch_store_pc(addr)) { ++ const uint32_t rs = get_rs(instr); ++ const uint32_t rt = get_rt(instr); ++ const int16_t offset = get_imm16(instr); ++ if (rs != 5u || rt != 10u || offset != 4 || ++ instr != 0xACAA0004u) { ++ throw std::runtime_error(fmt::format( ++ "Disruptor precision SW site 0x{:08X} no longer " ++ "matches SW r10,4(r5) (word 0x{:08X})", ++ addr, instr)); ++ } ++ const std::string store_addr = fmt::format( ++ "{} + {}", reg_name(rs), offset); ++ if (disruptor_precision_scratch_store_pc(addr)) { ++ code += fmt::format( ++ " gte_precision_scratch_store_pc_word(" ++ "0x{:08X}u, 0x{:08X}u, {}, {});", ++ addr, instr, store_addr, reg_name(rt)); ++ } else { ++ code += fmt::format( ++ " gte_precision_store_pc_word(0x{:08X}u, " ++ "0x{:08X}u, {}, {});", ++ addr, instr, store_addr, reg_name(rt)); ++ } ++ } ++ break; ++ } + case 0x2E: code = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr) + translate_swr(instr); break; // swr + case 0x2F: // CACHE - cache op (no-op for static recomp) + code = "/* cache (no-op: static recompilation has no I/D cache) */"; +@@ -1590,6 +2230,43 @@ + default: + code = fmt::format("/* TODO: opcode=0x{:02X} */", opcode); + } ++ } ++ ++ if (vertical_camera_site) { ++ code += fmt::format( ++ " disruptor_vertical_camera_instruction_hook(cpu, " ++ "0x{:08X}u, 0x{:08X}u, 1);", ++ addr, instr); ++ } ++ if (far_rendering_site) { ++ code += fmt::format( ++ "\n{}disruptor_far_rendering_instruction_hook(cpu, " ++ "0x{:08X}u, 0x{:08X}u, 1);", ++ config_.indent, addr, instr); ++ } ++ if (billboard_aspect_site) { ++ code += fmt::format( ++ "\n{}disruptor_billboard_aspect_instruction_hook(cpu, " ++ "0x{:08X}u, 0x{:08X}u, 1);", ++ config_.indent, addr, instr); ++ } ++ if (backdrop_tile_site) { ++ code += fmt::format( ++ "\n{}disruptor_backdrop_tiles_instruction_hook(cpu, " ++ "0x{:08X}u, 0x{:08X}u, 1);", ++ config_.indent, addr, instr); ++ } ++ if (sprite_depth_site) { ++ code += fmt::format( ++ "\n{}disruptor_sprite_depth_instruction_hook(cpu, " ++ "0x{:08X}u, 0x{:08X}u, 1);", ++ config_.indent, addr, instr); ++ } ++ if (intro_skip_site) { ++ code += fmt::format( ++ "\n{}disruptor_intro_skip_instruction_hook(cpu, " ++ "0x{:08X}u, 0x{:08X}u, 1);", ++ config_.indent, addr, instr); + } + + return config_.indent + code + comment; +@@ -2984,6 +3661,12 @@ + ss << "#endif\n"; + ss << "extern int psx_datashard_enter(CPUState* cpu, uint32_t key); /* data-shard replay/capture (data_shards.c) */\n"; + ss << "extern void psx_mod_function_entry(CPUState* cpu, uint32_t address); /* trusted opt-in game-mod hook */\n"; ++ ss << "extern void disruptor_vertical_camera_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned vertical-camera seam */\n"; ++ ss << "extern void disruptor_far_rendering_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned far-rendering seam */\n"; ++ ss << "extern void disruptor_billboard_aspect_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned world-billboard seam */\n"; ++ ss << "extern void disruptor_backdrop_tiles_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned backdrop-tile seam */\n"; ++ ss << "extern void disruptor_sprite_depth_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned sprite-depth seam */\n"; ++ ss << "extern void disruptor_intro_skip_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned intro-skip seam */\n"; + ss << "extern void psx_datashard_ret(CPUState* cpu); /* data-shard capture finalize */\n"; + ss << "extern int psx_vsync_query_hle_enter(CPUState* cpu, uint32_t func, uint32_t counter_addr, uint32_t gpustat_ptr_addr, uint32_t timer1_ptr_addr, uint32_t timer1_cache_addr); /* load_accel.c */\n"; + ss << "extern void psx_ws_sprite_tag(CPUState* cpu); /* widescreen prim tag (gpu.c) */\n"; +@@ -2993,6 +3676,9 @@ + ss << "extern int psx_ws_cull_sltiu(uint32_t sx, uint32_t imm); /* ws auto screen-x cull (gpu.c) */\n"; + ss << "extern int psx_ws_cull_slti(uint32_t sx, uint32_t imm); /* ws cull signed right edge (gpu.c) */\n"; + ss << "extern int psx_ws_cull_slti_lower(uint32_t sx, uint32_t imm); /* ws cull signed lower edge (gpu.c) */\n"; ++ ss << "extern int32_t psx_ws_cull_lower_value(int32_t vanilla); /* ws manual lower bound (gpu.c) */\n"; ++ ss << "extern int32_t psx_ws_cull_upper_value(int32_t vanilla); /* ws manual upper bound (gpu.c) */\n"; ++ ss << "extern uint32_t psx_ws_cull_span_value(uint32_t vanilla); /* ws packed [left,right] span (gpu.c) */\n"; + ss << "extern int psx_ws_cull_bltz(uint32_t v); /* ws cull signed left edge (gpu.c) */\n"; + ss << "extern int psx_ws_cull_vxrange(uint32_t x, uint32_t imm); /* ws masked-u16 X window */\n"; + ss << "extern int32_t psx_ws_depth_bound(int32_t imm); /* ws aspect-scaled far bound */\n"; +@@ -3001,7 +3687,10 @@ + ss << "extern uint32_t psx_ws_cull_keep_result(uint32_t vanilla, uint32_t forced); /* ws guarded keep compare (gpu.c) */\n"; + ss << "extern uint32_t psx_ws_aspect_cone_result(uint32_t site, uint32_t vanilla, uint32_t object, int32_t x, int32_t z, int32_t y); /* aspect-aware participation cone */\n"; + ss << "extern uint32_t psx_ws_angle_widen(uint32_t vanilla); /* aspect-scaled 12-bit terrain-frustum half-angle */\n"; ++ ss << "extern int32_t psx_ws_yaw_cone_offset(int32_t vanilla, uint32_t full_turn); /* aspect-scaled wrapping yaw half-FOV */\n"; ++ ss << "extern uint32_t psx_ws_horizontal_slt(uint32_t bound, uint32_t horizontal); /* aspect-scaled signed horizontal side plane */\n"; + ss << "extern int psx_ws_backdrop_x(int x); /* widescreen backdrop screenX squash (gpu.c) */\n"; ++ ss << "extern int psx_ws_project_x(int x); /* classic-wide CPU projection squash (gpu.c) */\n"; + ss << "extern int psx_ws_bg2d_cols(int base); /* ws 2D bg tile-loop widen: col count (gpu.c) */\n"; + ss << "extern int psx_ws_bg2d_startcol(int col, unsigned mask); /* ws 2D bg tile-loop widen: start tile col (gpu.c) */\n"; + ss << "extern int psx_ws_bg2d_startx(int x); /* ws 2D bg tile-loop widen: start screen-x (gpu.c) */\n"; +@@ -3013,6 +3702,11 @@ + ss << "extern int psx_game_option_store(uint32_t addr, int val); /* persisted OPTION restore-at-init (game_options.c) */\n"; + ss << "extern uint32_t psx_ws_backdrop_value(uint32_t orig, int is_end, int window_cols); /* ws backdrop preload (gpu.c) */\n"; + ss << "extern void gte_ws_set_suppress(int on); /* widescreen far-backdrop un-squash (gte.cpp) */\n"; ++ ss << "extern uint32_t gte_precision_mfc2_pc_read(uint32_t pc, uint32_t insn, uint8_t reg, uint32_t packed); /* reviewed MFC2 snapshot */\n"; ++ ss << "extern void gte_precision_store_pc_word(uint32_t pc, uint32_t insn, uint32_t addr, uint32_t packed); /* reviewed ordinary SW */\n"; ++ ss << "extern void gte_precision_scratch_store_pc_word(uint32_t pc, uint32_t insn, uint32_t addr, uint32_t packed); /* reviewed scratchpad SW */\n"; ++ ss << "extern uint32_t gte_precision_copy_pc_read(uint32_t pc, uint32_t insn, uint8_t gpr, uint32_t addr, uint32_t packed); /* reviewed packet-copy LW */\n"; ++ ss << "extern void gte_precision_copy_pc_word(uint32_t pc, uint32_t insn, uint8_t gpr, uint32_t addr, uint32_t packed); /* reviewed packet-copy SW */\n"; + ss << "extern uint32_t g_debug_last_store_pc; /* exact PC of the executing SW/SH/SB — wtrace/readtrace producer attribution (debug_server.c) */\n\n"; + } + diff --git a/framework/patches/recompiler/src/config_loader.cpp.patch b/framework/patches/recompiler/src/config_loader.cpp.patch new file mode 100644 index 0000000..2f04bc4 --- /dev/null +++ b/framework/patches/recompiler/src/config_loader.cpp.patch @@ -0,0 +1,495 @@ +diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp +index 246483fe90be378f8baf3e85671b692986823a8e..4fba08ff61a27a02f2f41e064aeba68d120e3f05 100644 +--- a/recompiler/src/config_loader.cpp ++++ b/recompiler/src/config_loader.cpp +@@ -3,13 +3,27 @@ + #include "config_loader.h" + + #include ++#include + #include ++#include ++#include + #include + #include ++#include ++#include + #include + #include + #include + ++#ifdef _WIN32 ++#ifndef NOMINMAX ++#define NOMINMAX ++#endif ++#include ++#else ++#include ++#endif ++ + #include "bios_rom_alias.h" + #include "fmt/format.h" + #include "ps1_exe_parser.h" +@@ -17,6 +31,14 @@ + // toml11 is header-only. + #define TOML11_USE_UNRELEASED_TOML_FEATURES + #include "toml.hpp" ++ ++#ifdef PSX_HAS_GAME_USER_SETTINGS ++namespace PSXRecompV4 { ++// Supplied by the game project: its own section of the user's settings file. ++void read_game_user_settings(const toml::value& doc, UserSettings& s); ++void write_game_user_settings(std::ostream& f, const UserSettings& s); ++} ++#endif + + namespace PSXRecompV4 { + +@@ -64,6 +86,10 @@ + h.words("cull_screen_x", c.ws_cull_screen_x_sites); + h.words("cull_slti", c.ws_cull_slti_sites); + h.words("cull_slti_lower", c.ws_cull_slti_lower_sites); ++ h.words("cull_lower_branch", c.ws_cull_lower_branch_sites); ++ h.words("cull_lower_value", c.ws_cull_lower_value_sites); ++ h.words("cull_upper_value", c.ws_cull_upper_value_sites); ++ h.words("cull_span_value", c.ws_cull_span_value_sites); + h.words("cull_bltz", c.ws_cull_bltz_sites); + h.words("cull_negsub", c.ws_cull_negsub_sites); + h.words("cull_vxrange", c.ws_cull_vxrange_sites); +@@ -120,6 +146,36 @@ + h.tag("cull_angle"); + h.u32((uint32_t)angle_sites.size()); + for (const auto& site : angle_sites) { ++ h.u32(site.address); ++ h.u32(site.expected); ++ } ++ ++ std::vector yaw_cone_sites = ++ c.ws_cull_yaw_cone_sites; ++ std::sort(yaw_cone_sites.begin(), yaw_cone_sites.end(), ++ [](const auto& a, const auto& b) { ++ if (a.address != b.address) return a.address < b.address; ++ if (a.expected != b.expected) return a.expected < b.expected; ++ return a.full_turn < b.full_turn; ++ }); ++ h.tag("cull_yaw_cone"); ++ h.u32((uint32_t)yaw_cone_sites.size()); ++ for (const auto& site : yaw_cone_sites) { ++ h.u32(site.address); ++ h.u32(site.expected); ++ h.u32(site.full_turn); ++ } ++ ++ std::vector horizontal_slt_sites = ++ c.ws_cull_horizontal_slt_sites; ++ std::sort(horizontal_slt_sites.begin(), horizontal_slt_sites.end(), ++ [](const auto& a, const auto& b) { ++ if (a.address != b.address) return a.address < b.address; ++ return a.expected < b.expected; ++ }); ++ h.tag("cull_horizontal_slt"); ++ h.u32((uint32_t)horizontal_slt_sites.size()); ++ for (const auto& site : horizontal_slt_sites) { + h.u32(site.address); + h.u32(site.expected); + } +@@ -496,9 +552,9 @@ + const toml::value& video = toml::find(cfg, "video"); + if (video.contains("supersampling")) { + const auto n = toml::find(video, "supersampling"); +- if (n < 1 || n > 4) { ++ if (n < 1 || n > 8) { + throw std::runtime_error(fmt::format( +- "[video] supersampling out of range (1..4): {}", n)); ++ "[video] supersampling out of range (1..8): {}", n)); + } + rt.video_supersampling = static_cast(n); + } +@@ -1264,6 +1320,41 @@ + for (const auto& a : arr) + mod_function_entry_funcs.push_back( + parse_hex(a, "recompiler.mod_function_entry_funcs")); ++ } ++ std::vector instruction_hooks; ++ if (recomp.contains("instruction_hook")) { ++ for (const auto& item : ++ toml::find(recomp, "instruction_hook")) { ++ InstructionHook hook; ++ hook.callback = toml::find(item, "callback"); ++ const bool named = !hook.callback.empty() && ++ !std::isdigit((unsigned char)hook.callback[0]) && ++ std::all_of(hook.callback.begin(), hook.callback.end(), ++ [](unsigned char c) { return std::isalnum(c) || c == '_'; }); ++ if (!named) ++ throw std::runtime_error(fmt::format( ++ "{}: [[recompiler.instruction_hook]] callback must be a " ++ "C identifier", config_path.string())); ++ for (const auto& pair : toml::find>>(item, "sites")) { ++ if (pair.size() != 2) ++ throw std::runtime_error(fmt::format( ++ "{}: [[recompiler.instruction_hook]] a site is an " ++ "address and a word", config_path.string())); ++ InstructionHookSite site; ++ site.address = parse_hex( ++ pair[0], "recompiler.instruction_hook.sites"); ++ site.expected = parse_hex( ++ pair[1], "recompiler.instruction_hook.sites"); ++ if ((site.address & 3u) != 0) ++ throw std::runtime_error(fmt::format( ++ "{}: instruction hook address 0x{:08X} is not " ++ "instruction-aligned", ++ config_path.string(), site.address)); ++ hook.sites.push_back(site); ++ } ++ instruction_hooks.push_back(std::move(hook)); ++ } + } + std::vector hot_funcs; + if (recomp.contains("hot_funcs")) { +@@ -1451,6 +1542,10 @@ + std::vector ws_cull_screen_x_sites; + std::vector ws_cull_slti_sites; + std::vector ws_cull_slti_lower_sites; ++ std::vector ws_cull_lower_branch_sites; ++ std::vector ws_cull_lower_value_sites; ++ std::vector ws_cull_upper_value_sites; ++ std::vector ws_cull_span_value_sites; + std::vector ws_cull_bltz_sites; + std::vector ws_cull_negsub_sites; + std::vector ws_cull_vxrange_sites; +@@ -1459,6 +1554,9 @@ + std::vector ws_cull_xclip_load_sites; + std::vector ws_cull_keep_sites; + std::vector ws_cull_angle_sites; ++ std::vector ws_cull_yaw_cone_sites; ++ std::vector ++ ws_cull_horizontal_slt_sites; + WidescreenAspectConeConfig ws_aspect_cone; + int ws_cull_guard_pixels = 0; + int ws_cull_activation_guard_pixels = 0; +@@ -1484,6 +1582,10 @@ + load_sites("screen_x_sites", ws_cull_screen_x_sites); + load_sites("slti_sites", ws_cull_slti_sites); + load_sites("slti_lower_sites", ws_cull_slti_lower_sites); ++ load_sites("lower_branch_sites", ws_cull_lower_branch_sites); ++ load_sites("lower_value_sites", ws_cull_lower_value_sites); ++ load_sites("upper_value_sites", ws_cull_upper_value_sites); ++ load_sites("span_value_sites", ws_cull_span_value_sites); + load_sites("bltz_sites", ws_cull_bltz_sites); + load_sites("negsub_sites", ws_cull_negsub_sites); + load_sites("vxrange_sites", ws_cull_vxrange_sites); +@@ -1552,6 +1654,85 @@ + "{}: duplicate cull-angle address 0x{:08X}", + config_path.string(), site.address)); + ws_cull_angle_sites.push_back(site); ++ } ++ } ++ if (cull.contains("yaw_cone")) { ++ std::set seen; ++ for (const auto& item : ++ toml::find(cull, "yaw_cone")) { ++ WidescreenYawConeSite site; ++ site.address = parse_hex( ++ toml::find(item, "address"), ++ "widescreen.cull.yaw_cone.address"); ++ site.expected = parse_hex( ++ toml::find(item, "expected"), ++ "widescreen.cull.yaw_cone.expected"); ++ const int64_t full_turn = ++ toml::find(item, "full_turn"); ++ if (full_turn < 16 || full_turn > 65536 || ++ (full_turn & 3) != 0) { ++ throw std::runtime_error(fmt::format( ++ "{}: [[widescreen.cull.yaw_cone]] full_turn " ++ "must be a multiple of four in [16, 65536]", ++ config_path.string())); ++ } ++ site.full_turn = (uint32_t)full_turn; ++ const uint32_t op = site.expected >> 26; ++ const uint32_t rs = (site.expected >> 21) & 31u; ++ const int32_t imm = ++ (int16_t)(site.expected & 0xFFFFu); ++ const int32_t magnitude = imm < 0 ? -imm : imm; ++ if ((op != 0x08u && op != 0x09u) || rs == 0u || ++ imm == 0 || ++ magnitude >= (int32_t)(site.full_turn / 4u)) { ++ throw std::runtime_error(fmt::format( ++ "{}: [[widescreen.cull.yaw_cone]] expected must " ++ "be ADDI/ADDIU rt,rs,+/-half_fov with rs != zero " ++ "and |half_fov| < full_turn/4", ++ config_path.string())); ++ } ++ if ((site.address & 3u) != 0) ++ throw std::runtime_error(fmt::format( ++ "{}: yaw-cone address 0x{:08X} is not " ++ "instruction-aligned", ++ config_path.string(), site.address)); ++ if (!seen.insert(site.address & 0x1FFFFFFFu).second) ++ throw std::runtime_error(fmt::format( ++ "{}: duplicate yaw-cone address 0x{:08X}", ++ config_path.string(), site.address)); ++ ws_cull_yaw_cone_sites.push_back(site); ++ } ++ } ++ if (cull.contains("horizontal_slt")) { ++ std::set seen; ++ for (const auto& item : ++ toml::find(cull, "horizontal_slt")) { ++ WidescreenHorizontalSltSite site; ++ site.address = parse_hex( ++ toml::find(item, "address"), ++ "widescreen.cull.horizontal_slt.address"); ++ site.expected = parse_hex( ++ toml::find(item, "expected"), ++ "widescreen.cull.horizontal_slt.expected"); ++ const uint32_t op = site.expected >> 26; ++ const uint32_t fn = site.expected & 0x3Fu; ++ const uint32_t shamt = (site.expected >> 6) & 31u; ++ if (op != 0u || fn != 0x2Au || shamt != 0u) { ++ throw std::runtime_error(fmt::format( ++ "{}: [[widescreen.cull.horizontal_slt]] expected " ++ "must be a signed register-register SLT", ++ config_path.string())); ++ } ++ if ((site.address & 3u) != 0) ++ throw std::runtime_error(fmt::format( ++ "{}: horizontal-SLT address 0x{:08X} is not " ++ "instruction-aligned", ++ config_path.string(), site.address)); ++ if (!seen.insert(site.address & 0x1FFFFFFFu).second) ++ throw std::runtime_error(fmt::format( ++ "{}: duplicate horizontal-SLT address 0x{:08X}", ++ config_path.string(), site.address)); ++ ws_cull_horizontal_slt_sites.push_back(site); + } + } + if (cull.contains("aspect_cone")) { +@@ -1886,6 +2067,7 @@ + /*ws_auto_ui_squash*/ ws_auto_ui_squash, + /*data_shard_funcs*/ data_shard_funcs, + /*mod_function_entry_funcs*/ mod_function_entry_funcs, ++ /*instruction_hooks*/ instruction_hooks, + /*hot_funcs*/ hot_funcs, + /*vsync_query_func*/ vsync_query_func, + /*vsync_counter_addr*/ vsync_counter_addr, +@@ -1901,6 +2083,10 @@ + /*ws_cull_screen_x_sites*/ ws_cull_screen_x_sites, + /*ws_cull_slti_sites*/ ws_cull_slti_sites, + /*ws_cull_slti_lower_sites*/ ws_cull_slti_lower_sites, ++ /*ws_cull_lower_branch_sites*/ ws_cull_lower_branch_sites, ++ /*ws_cull_lower_value_sites*/ ws_cull_lower_value_sites, ++ /*ws_cull_upper_value_sites*/ ws_cull_upper_value_sites, ++ /*ws_cull_span_value_sites*/ ws_cull_span_value_sites, + /*ws_cull_bltz_sites*/ ws_cull_bltz_sites, + /*ws_cull_negsub_sites*/ ws_cull_negsub_sites, + /*ws_cull_vxrange_sites*/ ws_cull_vxrange_sites, +@@ -1909,6 +2095,8 @@ + /*ws_cull_xclip_load_sites*/ ws_cull_xclip_load_sites, + /*ws_cull_keep_sites*/ ws_cull_keep_sites, + /*ws_cull_angle_sites*/ ws_cull_angle_sites, ++ /*ws_cull_yaw_cone_sites*/ ws_cull_yaw_cone_sites, ++ /*ws_cull_horizontal_slt_sites*/ ws_cull_horizontal_slt_sites, + /*ws_aspect_cone*/ ws_aspect_cone, + /*ws_cull_guard_pixels*/ ws_cull_guard_pixels, + /*ws_cull_activation_guard_pixels*/ ws_cull_activation_guard_pixels, +@@ -1995,7 +2183,7 @@ + return go; + } + +-// ---- UserSettings (settings.toml) — launcher-written override layer ---- ++// ---- UserSettings (settings.toml) — user-interface-written override layer ---- + + UserSettings load_user_settings(const fs::path& path) { + UserSettings s; +@@ -2004,7 +2192,10 @@ + + toml::value doc; + try { +- doc = toml::parse(path.string()); ++ // Opened by its wide name: path.string() is UTF-8 under MinGW, which the narrow open reads as the ANSI page. ++ std::ifstream file(path, std::ios::binary); ++ if (!file) throw std::runtime_error("settings.toml could not be opened"); ++ doc = toml::parse(file, "settings.toml"); + } catch (const std::exception&) { + // Malformed file: fall back to all-defaults rather than refuse to boot, + // but tell the caller so the user hears about it (and so the file can +@@ -2026,7 +2217,7 @@ + }); + if (v.contains("supersampling")) try_get([&]{ + const auto n = toml::find(v, "supersampling"); +- if (n >= 1 && n <= 4) { s.supersampling = (int)n; s.has_supersampling = true; } ++ if (n >= 1 && n <= 8) { s.supersampling = (int)n; s.has_supersampling = true; } + }); + if (v.contains("window_width")) try_get([&]{ + const auto n = toml::find(v, "window_width"); +@@ -2087,8 +2278,16 @@ + }); + if (v.contains("frame_interpolation_fps")) try_get([&]{ + s.frame_interpolation_fps = toml::find(v, "frame_interpolation_fps"); +- if (s.frame_interpolation_fps == 0 || s.frame_interpolation_fps >= 90) ++ if (s.frame_interpolation_fps == 0 || ++ (s.frame_interpolation_fps >= 30 && s.frame_interpolation_fps <= 1000)) + s.has_frame_interpolation_fps = true; ++ }); ++ if (v.contains("frame_interpolation_blend")) try_get([&]{ ++ const auto n = toml::find(v, "frame_interpolation_blend"); ++ if (n == 0 || n == 1 || n == 2) { ++ s.frame_interpolation_blend = static_cast(n); ++ s.has_frame_interpolation_blend = true; ++ } + }); + if (v.contains("aspect_ratio")) try_get([&]{ + const auto m = toml::find(v, "aspect_ratio"); +@@ -2106,6 +2305,17 @@ + const toml::value& a = toml::find(doc, "audio"); + if (a.contains("spu_hq")) try_get([&]{ + s.spu_hq = toml::find(a, "spu_hq"); s.has_spu_hq = true; ++ }); ++ if (a.contains("master_volume")) try_get([&]{ ++ const auto n = toml::find(a, "master_volume"); ++ if (n >= 0 && n <= 100) { ++ s.master_volume = static_cast(n); ++ s.has_master_volume = true; ++ } ++ }); ++ if (a.contains("muted")) try_get([&]{ ++ s.audio_muted = toml::find(a, "muted"); ++ s.has_audio_muted = true; + }); + } + if (doc.contains("launcher")) { +@@ -2230,15 +2440,70 @@ + if (n >= 0 && n <= 32767) { s.deadzone = (int)n; s.has_deadzone = true; } + }); + } ++#ifdef PSX_HAS_GAME_USER_SETTINGS ++ read_game_user_settings(doc, s); ++#endif + return s; + } ++ ++namespace { ++ ++fs::path user_settings_temp_path(const fs::path& destination) { ++ static std::atomic sequence{0}; ++#ifdef _WIN32 ++ const auto process_id = static_cast(::GetCurrentProcessId()); ++#else ++ const auto process_id = static_cast(::getpid()); ++#endif ++ const auto tick = static_cast( ++ std::chrono::steady_clock::now().time_since_epoch().count()); ++ ++ fs::path temporary = destination; ++ temporary += fmt::format(".tmp.{}.{}.{}", process_id, tick, ++ sequence.fetch_add(1, std::memory_order_relaxed)); ++ return temporary; ++} ++ ++bool atomic_replace_user_settings(const fs::path& temporary, ++ const fs::path& destination) { ++#ifdef _WIN32 ++ // A reader or antivirus scanner may briefly deny replacement after a save. ++ // Keep the old file intact and allow up to 500 ms for that handle to close. ++ for (unsigned attempt = 0; ; ++attempt) { ++ if (::MoveFileExW(temporary.c_str(), destination.c_str(), ++ MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) return true; ++ const DWORD error = ::GetLastError(); ++ const bool transient = error == ERROR_ACCESS_DENIED || ++ error == ERROR_SHARING_VIOLATION || ++ error == ERROR_LOCK_VIOLATION; ++ const DWORD attributes = ::GetFileAttributesW(destination.c_str()); ++ const bool blocked = attributes != INVALID_FILE_ATTRIBUTES && ++ (attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_READONLY)); ++ if (!transient || blocked || attempt == 20) { ++ ::SetLastError(error); ++ return false; ++ } ++ ::Sleep(25); ++ } ++#else ++ return std::rename(temporary.c_str(), destination.c_str()) == 0; ++#endif ++} ++ ++void remove_user_settings_temp(const fs::path& temporary) { ++ std::error_code ignored; ++ fs::remove(temporary, ignored); ++} ++ ++} // namespace + + bool save_user_settings(const fs::path& path, const UserSettings& s) { + std::error_code ec; + if (!path.parent_path().empty()) + fs::create_directories(path.parent_path(), ec); // best-effort + +- std::ofstream f(path, std::ios::trunc); ++ const fs::path temporary = user_settings_temp_path(path); ++ std::ofstream f(temporary, std::ios::trunc); + if (!f.is_open()) return false; + + // TOML strings use forward slashes so backslash escaping is never an issue. +@@ -2247,7 +2512,7 @@ + return str; + }; + +- f << "# psxrecomp user settings - written by the launcher. Safe to hand-edit.\n"; ++ f << "# psxrecomp user settings - written by settings UIs. Safe to hand-edit.\n"; + f << "# Overrides the bundled game.toml; the command line overrides this file.\n\n"; + + f << "[video]\n"; +@@ -2287,6 +2552,11 @@ + f << "frame_interpolation = " << (s.frame_interpolation ? "true" : "false") << "\n"; + if (s.has_frame_interpolation_fps) + f << "frame_interpolation_fps = " << s.frame_interpolation_fps << "\n"; ++ if (s.has_frame_interpolation_blend && ++ (s.frame_interpolation_blend == 0 || ++ s.frame_interpolation_blend == 1 || ++ s.frame_interpolation_blend == 2)) ++ f << "frame_interpolation_blend = " << s.frame_interpolation_blend << "\n"; + if (s.has_aspect_ratio) + f << "aspect_ratio = \"" << s.aspect_num << ":" << s.aspect_den << "\"\n"; + if (s.has_adaptive_view) +@@ -2294,6 +2564,10 @@ + f << "\n[audio]\n"; + if (s.has_spu_hq) + f << "spu_hq = " << (s.spu_hq ? "true" : "false") << "\n"; ++ if (s.has_master_volume) ++ f << "master_volume = " << s.master_volume << "\n"; ++ if (s.has_audio_muted) ++ f << "muted = " << (s.audio_muted ? "true" : "false") << "\n"; + if (s.has_skip_launcher) + f << "\n[launcher]\nskip_launcher = " << (s.skip_launcher ? "true" : "false") << "\n"; + if ((s.has_netplay_player_name && !s.netplay_player_name.empty()) || +@@ -2353,7 +2627,28 @@ + f << "extra_late = " << s.parappa_timing_extra_late << "\n"; + } + +- return f.good(); ++#ifdef PSX_HAS_GAME_USER_SETTINGS ++ write_game_user_settings(f, s); ++#endif ++ ++ f.flush(); ++ const bool write_ok = f.good(); ++ f.close(); ++ if (!write_ok || f.fail()) { ++ remove_user_settings_temp(temporary); ++ return false; ++ } ++ if (!atomic_replace_user_settings(temporary, path)) { ++#ifdef _WIN32 ++ const DWORD error = ::GetLastError(); ++#endif ++ remove_user_settings_temp(temporary); ++#ifdef _WIN32 ++ ::SetLastError(error); ++#endif ++ return false; ++ } ++ return true; + } + + } // namespace PSXRecompV4 diff --git a/framework/patches/recompiler/src/config_loader.h.patch b/framework/patches/recompiler/src/config_loader.h.patch new file mode 100644 index 0000000..2b28c70 --- /dev/null +++ b/framework/patches/recompiler/src/config_loader.h.patch @@ -0,0 +1,179 @@ +diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h +index 4761b920f677e251b60550fdaf44427bdcd1659b..f9cbb12226f8d7bfb4b67180cdfaa0e642f23309 100644 +--- a/recompiler/src/config_loader.h ++++ b/recompiler/src/config_loader.h +@@ -26,6 +26,9 @@ + + #include "bios_address_model.h" // BiosAddrCopy (BiosConfig::address_copies) + #include "recompiler_patch.h" ++#ifdef PSX_HAS_GAME_USER_SETTINGS ++#include "game_user_settings.h" // the game project's: PSXRecompV4::GameUserSettings, its own fields of the settings file ++#endif + + namespace PSXRecompV4 { + +@@ -71,6 +74,36 @@ + struct WidescreenAngleSite { + uint32_t address = 0; + uint32_t expected = 0; // guarded ADDI/ADDIU with rs == zero ++}; ++ ++// Aspect-scaled wrapping yaw offsets. Unlike WidescreenAngleSite, these are ++// ADDI/ADDIU instructions whose source register already contains the live ++// camera yaw. The signed immediate is a half-FOV in `full_turn` units (for ++// example +/-32 with full_turn=256). Only the offset is widened; the base yaw ++// and its natural integer wrapping remain owned by the game. ++// A call the RAM interpreter makes after each listed instruction. ++struct InstructionHookSite { ++ uint32_t address = 0; ++ uint32_t expected = 0; // the whole instruction word ++}; ++struct InstructionHook { ++ std::string callback; ++ std::vector sites; ++}; ++ ++struct WidescreenYawConeSite { ++ uint32_t address = 0; ++ uint32_t expected = 0; // guarded ADDI/ADDIU with rs != zero ++ uint32_t full_turn = 0; ++}; ++ ++// Exact signed SLT side-plane tests of the form `bound < horizontal`. The ++// runtime aspect-scales only the horizontal operand, preserving the original ++// comparison at 4:3 and widening a camera-space horizontal cone without ++// touching its corresponding vertical test. ++struct WidescreenHorizontalSltSite { ++ uint32_t address = 0; ++ uint32_t expected = 0; // guarded signed register-register SLT + }; + + // Aspect-aware horizontal participation cone. The exact compare sites are +@@ -309,7 +342,7 @@ + + // ---- [video] block — visual enhancement options ---- + // supersampling: internal-resolution SSAA factor (per axis). 1 = native +- // (default, behaves exactly as before). 2..4 render geometry/shading into ++ // (default, behaves exactly as before). 2..8 render geometry/shading into + // an N*-scaled mirror of VRAM and downsample on present — true ordered-grid + // supersampling + edge anti-aliasing. Cost scales ~N^2 in fill rate. + int video_supersampling = 1; +@@ -646,6 +679,9 @@ + // default, so projects that do not opt in emit no callback overhead. + std::vector mod_function_entry_funcs; + ++ // [[recompiler.instruction_hook]]. Not part of the codegen hash: a save state stays loadable. ++ std::vector instruction_hooks; ++ + // [recompiler] hot_funcs: guest addresses that get __attribute__((hot)) + // on their generated C bodies (profile/host locality; no guest semantics). + std::vector hot_funcs; +@@ -695,6 +731,20 @@ + // Explicit signed lower-bound sites (`slti rt, sx, -W`). The threshold is + // moved left by the live reveal margin. Empty by default; regen required. + std::vector ws_cull_slti_lower_sites; ++ // Manual screen-window code often expresses the left edge as a zero-bound ++ // branch (`bgez`, `bgtz`, `blez`, or `bltz`) rather than an SLTI. At these ++ // exact branch PCs, move that boundary from 0 to -x_margin while preserving ++ // the original branch sense and the exact 4:3 result. ++ std::vector ws_cull_lower_branch_sites; ++ // Exact constant/store sites which materialize the manual window edges. ++ // lower_value_sites accepts li-zero forms and `sw zero,off(base)` and emits ++ // vanilla-x_margin; upper_value_sites accepts li constants and emits ++ // vanilla+x_margin. span_value_sites accepts a packed signed-left/u16-right ++ // constant (high16,low16), widening both halves symmetrically. All are ++ // identity transforms when native-wide is inactive; regen required. ++ std::vector ws_cull_lower_value_sites; ++ std::vector ws_cull_upper_value_sites; ++ std::vector ws_cull_span_value_sites; + // [widescreen.cull] bltz_sites — explicit signed LEFT-edge widen sites + // (`bltz rs, reject` -> psx_ws_cull_bltz), the counterpart to slti_sites. + // detect_cull_bltz_sites only classifies left-edge bltz for functions +@@ -730,6 +780,10 @@ + std::vector ws_cull_keep_sites; + // Exact 12-bit angular half-extents used by terrain-cell frusta. + std::vector ws_cull_angle_sites; ++ // Signed wrapping yaw +/- half-FOV offsets used to seed portal traversal. ++ std::vector ws_cull_yaw_cone_sites; ++ // Signed camera-horizontal side-plane rejects used after traversal. ++ std::vector ws_cull_horizontal_slt_sites; + // Full-word-guarded model-participation cosine compares widened only in + // the camera-horizontal plane. Empty/default is completely inert. + WidescreenAspectConeConfig ws_aspect_cone; +@@ -944,17 +998,21 @@ + uint32_t ws_bg2d_packet_cap = 1000; + }; + +-// UserSettings — the launcher-written, user-editable override layer. ++// UserSettings — the launcher/in-game-UI-written, user-editable override layer. + // + // Lives in a `settings.toml` next to the runtime exe (NOT in the repo). It is + // layered on top of the bundled game.toml at startup: any field present here + // overrides the corresponding game.toml value, and the command line overrides +-// both. Absent fields fall through to game.toml. The launcher seeds this file ++// both. Absent fields fall through to game.toml. A settings UI seeds this file + // from game.toml defaults the first time it writes. + // + // Unlike game.toml, paths here are stored verbatim (the user picked them); they + // are NOT resolved against a project root. ++#ifdef PSX_HAS_GAME_USER_SETTINGS ++struct UserSettings : GameUserSettings { ++#else + struct UserSettings { ++#endif + // Set when the file existed but failed to parse as TOML: every field below + // is defaults and the caller must warn loudly — silently dropping a user's + // hand-edited settings looks like the settings "don't work", and a later +@@ -963,7 +1021,7 @@ + + // [video] + bool has_renderer = false; int renderer = 0; // 0=software,1=opengl +- bool has_supersampling = false; int supersampling = 1; // 1..4 ++ bool has_supersampling = false; int supersampling = 1; // 1..8 + // Window size: width in px; height is always width*3/4 (PSX 4:3). Applies to + // both the launcher and the emulator window so they boot at the same size. + bool has_window_width = false; int window_width = 1280; // -> 1280x960 +@@ -995,6 +1053,10 @@ + bool has_vsync = false; int vsync = 1; + bool has_frame_interpolation = false; bool frame_interpolation = false; + bool has_frame_interpolation_fps = false; int frame_interpolation_fps = 0; ++ // Frame-interpolation mode: 0 = legacy linear, 1 = legacy adaptive, ++ // 2 = geometry inter-frame redraw. A host built with ++ // PSX_FRAME_INTERPOLATION_GEOMETRY_DEFAULT migrates saved legacy modes to geometry. ++ bool has_frame_interpolation_blend = false; int frame_interpolation_blend = 0; + // [launcher] — when true, boot straight into the game and skip the GUI + // launcher window (mirrors snesrecomp's SkipLauncher). Overridable per-run: + // `--launcher` forces the GUI back on; `PSX_NO_LAUNCHER=1` forces it off. +@@ -1008,6 +1070,10 @@ + bool has_adaptive_view = false; bool adaptive_view = false; + // [audio] + bool has_spu_hq = false; bool spu_hq = false; ++ // Host-output controls. These scale the final mixed stereo stream and do ++ // not alter SPU registers, voice state, CD playback state or guest saves. ++ bool has_master_volume = false; int master_volume = 100; // 0..100 ++ bool has_audio_muted = false; bool audio_muted = false; + // [bios] / [disc] / [memcard] + bool has_bios_path = false; std::filesystem::path bios_path; + bool has_disc_path = false; std::filesystem::path disc_path; +@@ -1043,6 +1109,10 @@ + bool has_parappa_timing_mode = false; std::string parappa_timing_mode = "stock"; + bool has_parappa_timing_extra_early = false; int parappa_timing_extra_early = 0; + bool has_parappa_timing_extra_late = false; int parappa_timing_extra_late = 0; ++ ++ // Corrections of the renderer. A game project that offers them keeps them in its own section of the file. ++ bool has_geometry_correction = false; bool geometry_correction = false; ++ bool has_perspective_textures = false; bool perspective_textures = false; + }; + + // GameOptions — the game's OWN native OPTION-screen settings, declared in a +@@ -1084,7 +1154,9 @@ + // never throws. + UserSettings load_user_settings(const std::filesystem::path& path); + +-// Write settings.toml deterministically. Returns false on I/O failure. ++// Write settings.toml deterministically through a same-directory temporary ++// file and atomically replace the destination. Returns false on I/O failure; ++// the previous destination is left intact and the temporary file is removed. + bool save_user_settings(const std::filesystem::path& path, const UserSettings& s); + + // Locate the project root by walking upward from `config_path` until a diff --git a/framework/patches/recompiler/src/full_function_emitter.cpp.patch b/framework/patches/recompiler/src/full_function_emitter.cpp.patch new file mode 100644 index 0000000..1b1ac08 --- /dev/null +++ b/framework/patches/recompiler/src/full_function_emitter.cpp.patch @@ -0,0 +1,13 @@ +diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp +index 6d7f80bf721ec298f293ebd8cb39449f52865668..5c5cb38670df0dba8d65860eb134ebfed4e3dd3f 100644 +--- a/recompiler/src/full_function_emitter.cpp ++++ b/recompiler/src/full_function_emitter.cpp +@@ -1792,7 +1792,7 @@ + g_sym_prefix, kb_count); + out += kb; + out += "};\n"; +- out += fmt::format("static const uint32_t {}psx_bios_kernel_body_count = {}u;\n\n", ++ out += fmt::format("enum {{ {}psx_bios_kernel_body_count = {}u }};\n\n", + g_sym_prefix, kb_count); + } + diff --git a/framework/patches/recompiler/src/main_psx.cpp.patch b/framework/patches/recompiler/src/main_psx.cpp.patch new file mode 100644 index 0000000..61ede44 --- /dev/null +++ b/framework/patches/recompiler/src/main_psx.cpp.patch @@ -0,0 +1,151 @@ +diff --git a/recompiler/src/main_psx.cpp b/recompiler/src/main_psx.cpp +index 9979abe18ed351391f95e5009189c84ce7cbec7e..f79e0dcc50a18d4cdb7e1f5c9c1770ff8b78cde7 100644 +--- a/recompiler/src/main_psx.cpp ++++ b/recompiler/src/main_psx.cpp +@@ -169,6 +169,10 @@ + std::set ws_cull_screen_x; // [widescreen.cull] screen_x_sites + std::set ws_cull_slti; // [widescreen.cull] slti_sites + std::set ws_cull_slti_lower; // [widescreen.cull] slti_lower_sites ++ std::set ws_cull_lower_branch; // [widescreen.cull] lower_branch_sites ++ std::set ws_cull_lower_value; // [widescreen.cull] lower_value_sites ++ std::set ws_cull_upper_value; // [widescreen.cull] upper_value_sites ++ std::set ws_cull_span_value; // [widescreen.cull] span_value_sites + std::set ws_cull_bltz; // [widescreen.cull] bltz_sites + std::set ws_cull_negsub; // [widescreen.cull] negsub_sites + std::set ws_cull_vxrange; // [widescreen.cull] vxrange_sites +@@ -177,6 +181,10 @@ + std::set ws_cull_xclip_load; // [widescreen.cull] xclip_load_sites + std::vector ws_cull_keep; + std::vector ws_cull_angle; ++ std::vector ws_cull_yaw_cone; ++ std::vector instruction_hooks; ++ std::vector ++ ws_cull_horizontal_slt; + PSXRecompV4::WidescreenAspectConeConfig ws_aspect_cone; + int ws_cull_activation_guard_pixels = 0; + std::vector ws_cull_w_imms = { 0x140, 0x141 }; // [widescreen.cull] screen_w_imms +@@ -214,6 +222,7 @@ + ds_funcs.insert(cfg.data_shard_funcs.begin(), cfg.data_shard_funcs.end()); + mod_entry_funcs.insert(cfg.mod_function_entry_funcs.begin(), + cfg.mod_function_entry_funcs.end()); ++ instruction_hooks = cfg.instruction_hooks; + hot_funcs.insert(cfg.hot_funcs.begin(), cfg.hot_funcs.end()); + if (cfg.vsync_query_func) + vsync_query_hle_funcs[cfg.vsync_query_func] = { +@@ -226,6 +235,14 @@ + ws_cull_slti.insert(cfg.ws_cull_slti_sites.begin(), cfg.ws_cull_slti_sites.end()); + ws_cull_slti_lower.insert(cfg.ws_cull_slti_lower_sites.begin(), + cfg.ws_cull_slti_lower_sites.end()); ++ ws_cull_lower_branch.insert(cfg.ws_cull_lower_branch_sites.begin(), ++ cfg.ws_cull_lower_branch_sites.end()); ++ ws_cull_lower_value.insert(cfg.ws_cull_lower_value_sites.begin(), ++ cfg.ws_cull_lower_value_sites.end()); ++ ws_cull_upper_value.insert(cfg.ws_cull_upper_value_sites.begin(), ++ cfg.ws_cull_upper_value_sites.end()); ++ ws_cull_span_value.insert(cfg.ws_cull_span_value_sites.begin(), ++ cfg.ws_cull_span_value_sites.end()); + ws_cull_bltz.insert(cfg.ws_cull_bltz_sites.begin(), cfg.ws_cull_bltz_sites.end()); + ws_cull_negsub.insert(cfg.ws_cull_negsub_sites.begin(), cfg.ws_cull_negsub_sites.end()); + ws_cull_vxrange.insert(cfg.ws_cull_vxrange_sites.begin(), cfg.ws_cull_vxrange_sites.end()); +@@ -234,6 +251,8 @@ + ws_cull_xclip_load.insert(cfg.ws_cull_xclip_load_sites.begin(), cfg.ws_cull_xclip_load_sites.end()); + ws_cull_keep = cfg.ws_cull_keep_sites; + ws_cull_angle = cfg.ws_cull_angle_sites; ++ ws_cull_yaw_cone = cfg.ws_cull_yaw_cone_sites; ++ ws_cull_horizontal_slt = cfg.ws_cull_horizontal_slt_sites; + ws_aspect_cone = cfg.ws_aspect_cone; + ws_cull_activation_guard_pixels = + cfg.ws_cull_activation_guard_pixels; +@@ -307,6 +326,8 @@ + ws_tag_funcs.insert(wscfg.ws_sprite_tag_funcs.begin(), wscfg.ws_sprite_tag_funcs.end()); + mod_entry_funcs.insert(wscfg.mod_function_entry_funcs.begin(), + wscfg.mod_function_entry_funcs.end()); ++ if (instruction_hooks.empty()) ++ instruction_hooks = wscfg.instruction_hooks; + ws_cull_bias.insert(wscfg.ws_cull_bias_sites.begin(), wscfg.ws_cull_bias_sites.end()); + ws_cull_range.insert(wscfg.ws_cull_range_sites.begin(), wscfg.ws_cull_range_sites.end()); + ws_cull_a1.insert(wscfg.ws_cull_a1_sites.begin(), wscfg.ws_cull_a1_sites.end()); +@@ -314,6 +335,14 @@ + ws_cull_slti.insert(wscfg.ws_cull_slti_sites.begin(), wscfg.ws_cull_slti_sites.end()); + ws_cull_slti_lower.insert(wscfg.ws_cull_slti_lower_sites.begin(), + wscfg.ws_cull_slti_lower_sites.end()); ++ ws_cull_lower_branch.insert(wscfg.ws_cull_lower_branch_sites.begin(), ++ wscfg.ws_cull_lower_branch_sites.end()); ++ ws_cull_lower_value.insert(wscfg.ws_cull_lower_value_sites.begin(), ++ wscfg.ws_cull_lower_value_sites.end()); ++ ws_cull_upper_value.insert(wscfg.ws_cull_upper_value_sites.begin(), ++ wscfg.ws_cull_upper_value_sites.end()); ++ ws_cull_span_value.insert(wscfg.ws_cull_span_value_sites.begin(), ++ wscfg.ws_cull_span_value_sites.end()); + ws_cull_bltz.insert(wscfg.ws_cull_bltz_sites.begin(), wscfg.ws_cull_bltz_sites.end()); + ws_cull_negsub.insert(wscfg.ws_cull_negsub_sites.begin(), wscfg.ws_cull_negsub_sites.end()); + ws_cull_vxrange.insert(wscfg.ws_cull_vxrange_sites.begin(), wscfg.ws_cull_vxrange_sites.end()); +@@ -322,6 +351,11 @@ + ws_cull_xclip_load.insert(wscfg.ws_cull_xclip_load_sites.begin(), wscfg.ws_cull_xclip_load_sites.end()); + if (ws_cull_keep.empty()) ws_cull_keep = wscfg.ws_cull_keep_sites; + if (ws_cull_angle.empty()) ws_cull_angle = wscfg.ws_cull_angle_sites; ++ if (ws_cull_yaw_cone.empty()) ++ ws_cull_yaw_cone = wscfg.ws_cull_yaw_cone_sites; ++ if (ws_cull_horizontal_slt.empty()) ++ ws_cull_horizontal_slt = ++ wscfg.ws_cull_horizontal_slt_sites; + if (ws_aspect_cone.sites.empty()) + ws_aspect_cone = wscfg.ws_aspect_cone; + ws_cull_activation_guard_pixels = +@@ -1152,6 +1186,10 @@ + ws_cull_activation_guard_pixels; + codegen_config.ws_cull_slti_sites = ws_cull_slti; + codegen_config.ws_cull_slti_lower_sites = ws_cull_slti_lower; ++ codegen_config.ws_cull_lower_branch_sites = ws_cull_lower_branch; ++ codegen_config.ws_cull_lower_value_sites = ws_cull_lower_value; ++ codegen_config.ws_cull_upper_value_sites = ws_cull_upper_value; ++ codegen_config.ws_cull_span_value_sites = ws_cull_span_value; + codegen_config.ws_cull_bltz_sites = ws_cull_bltz; + codegen_config.ws_cull_negsub_sites = ws_cull_negsub; + codegen_config.ws_cull_vxrange_sites = ws_cull_vxrange; +@@ -1160,6 +1198,9 @@ + codegen_config.ws_cull_xclip_load_sites = ws_cull_xclip_load; + codegen_config.ws_cull_keep_sites = ws_cull_keep; + codegen_config.ws_cull_angle_sites = ws_cull_angle; ++ codegen_config.ws_cull_yaw_cone_sites = ws_cull_yaw_cone; ++ codegen_config.ws_cull_horizontal_slt_sites = ++ ws_cull_horizontal_slt; + codegen_config.ws_aspect_cone = ws_aspect_cone; + codegen_config.ws_cull_w_imms = ws_cull_w_imms; + codegen_config.ws_cull_h_imms = ws_cull_h_imms; +@@ -1584,6 +1625,35 @@ + ds << "#endif\n"; + } + ++ { ++ // [[recompiler.instruction_hook]]: the calls the RAM interpreter makes ++ // after an instruction, the ones native code has from the generator. ++ // Written with no hook too: the interpreter of a host project calls it. ++ std::map>> hooked; ++ for (const auto& hook : instruction_hooks) ++ for (const auto& site : hook.sites) ++ hooked[site.address].emplace_back(site.expected, hook.callback); ++ ds << "\n/* The game's hooks after an instruction that ran from RAM. */\n"; ++ for (const auto& hook : instruction_hooks) ++ ds << "extern void " << hook.callback ++ << "(CPUState* cpu, uint32_t address, uint32_t instruction, int phase);\n"; ++ ds << "void psx_game_instruction_ran(CPUState* cpu, uint32_t address, uint32_t instruction) {\n"; ++ if (hooked.empty()) ++ ds << " (void)cpu; (void)address; (void)instruction;\n}\n"; ++ else ++ ds << " switch (address) {\n"; ++ for (const auto& [address, calls] : hooked) { ++ ds << fmt::format(" case 0x{:08X}u:\n", address); ++ for (const auto& [expected, callback] : calls) ++ ds << fmt::format( ++ " if (instruction == 0x{:08X}u) {}(cpu, address, instruction, 1);\n", ++ expected, callback); ++ ds << " break;\n"; ++ } ++ if (!hooked.empty()) ++ ds << " default:\n break;\n }\n}\n"; ++ } ++ + std::ofstream dispatch_file(dispatch_filename); + if (dispatch_file.is_open()) { + dispatch_file << ds.str(); diff --git a/framework/patches/recompiler/tests/recompiler_patch_test.cpp.patch b/framework/patches/recompiler/tests/recompiler_patch_test.cpp.patch new file mode 100644 index 0000000..57eecf5 --- /dev/null +++ b/framework/patches/recompiler/tests/recompiler_patch_test.cpp.patch @@ -0,0 +1,209 @@ +diff --git a/recompiler/tests/recompiler_patch_test.cpp b/recompiler/tests/recompiler_patch_test.cpp +index 05f9cd45d963df9fd098b35522822c17191a739d..19125131038d312fe36111cd17e0b6b61bd297d5 100644 +--- a/recompiler/tests/recompiler_patch_test.cpp ++++ b/recompiler/tests/recompiler_patch_test.cpp +@@ -206,6 +206,32 @@ + std::vector{0x80012340u}, + "parser preserves negsub cull sites"); + ++ const auto manual_window = write_config(root, "manual-window", R"toml( ++[widescreen.cull] ++lower_branch_sites = ["0x80012340"] ++lower_value_sites = ["0x80012344"] ++upper_value_sites = ["0x80012348"] ++span_value_sites = ["0x8001234C"] ++)toml"); ++ const auto manual_window_config = ++ PSXRecompV4::load_game_config(manual_window); ++ check(manual_window_config.ws_cull_lower_branch_sites == ++ std::vector{0x80012340u} && ++ manual_window_config.ws_cull_lower_value_sites == ++ std::vector{0x80012344u} && ++ manual_window_config.ws_cull_upper_value_sites == ++ std::vector{0x80012348u} && ++ manual_window_config.ws_cull_span_value_sites == ++ std::vector{0x8001234Cu}, ++ "parser preserves manual screen-window site families"); ++ auto changed_manual_window_config = manual_window_config; ++ changed_manual_window_config.ws_cull_span_value_sites.push_back( ++ 0x80012350u); ++ check(PSXRecompV4::overlay_codegen_config_hash(manual_window_config) != ++ PSXRecompV4::overlay_codegen_config_hash( ++ changed_manual_window_config), ++ "manual screen-window sites change overlay cache identity"); ++ + const auto vxrange = write_config(root, "vxrange", R"toml( + [widescreen.cull] + vxrange_sites = ["0x80012340"] +@@ -324,6 +350,65 @@ + check_throws([&] { (void)PSXRecompV4::load_game_config(bad_angle); }, + "expected must be ADDI/ADDIU rt,zero,imm", + "parser rejects non-constant terrain-angle instructions"); ++ ++ const auto yaw_cone = write_config(root, "yaw-cone", R"toml( ++[[widescreen.cull.yaw_cone]] ++address = "0x80040FE8" ++expected = "0x24710020" ++full_turn = 256 ++ ++[[widescreen.cull.yaw_cone]] ++address = "0x80040FF0" ++expected = "0x2472FFE0" ++full_turn = 256 ++ ++[[widescreen.cull.horizontal_slt]] ++address = "0x8003B8EC" ++expected = "0x0067102A" ++)toml"); ++ const auto yaw_cone_config = PSXRecompV4::load_game_config(yaw_cone); ++ check(yaw_cone_config.ws_cull_yaw_cone_sites.size() == 2 && ++ yaw_cone_config.ws_cull_yaw_cone_sites[0].address == ++ 0x80040FE8u && ++ yaw_cone_config.ws_cull_yaw_cone_sites[0].expected == ++ 0x24710020u && ++ yaw_cone_config.ws_cull_yaw_cone_sites[0].full_turn == 256u && ++ yaw_cone_config.ws_cull_yaw_cone_sites[1].expected == ++ 0x2472FFE0u, ++ "parser preserves signed wrapping-yaw cone sites"); ++ check(yaw_cone_config.ws_cull_horizontal_slt_sites.size() == 1 && ++ yaw_cone_config.ws_cull_horizontal_slt_sites[0].address == ++ 0x8003B8ECu && ++ yaw_cone_config.ws_cull_horizontal_slt_sites[0].expected == ++ 0x0067102Au, ++ "parser preserves signed horizontal side-plane sites"); ++ auto changed_yaw_cone_config = yaw_cone_config; ++ changed_yaw_cone_config.ws_cull_yaw_cone_sites[0].full_turn = 512u; ++ check(PSXRecompV4::overlay_codegen_config_hash(yaw_cone_config) != ++ PSXRecompV4::overlay_codegen_config_hash( ++ changed_yaw_cone_config), ++ "yaw-cone metadata changes overlay cache identity"); ++ ++ const auto bad_yaw_cone = write_config(root, "yaw-cone-bad", R"toml( ++[[widescreen.cull.yaw_cone]] ++address = "0x80040FE8" ++expected = "0x24110020" ++full_turn = 256 ++)toml"); ++ check_throws([&] { (void)PSXRecompV4::load_game_config(bad_yaw_cone); }, ++ "rs != zero", ++ "parser rejects yaw-cone constants without a live base yaw"); ++ ++ const auto bad_horizontal_slt = write_config( ++ root, "horizontal-slt-bad", R"toml( ++[[widescreen.cull.horizontal_slt]] ++address = "0x8003B8EC" ++expected = "0x0067102B" ++)toml"); ++ check_throws( ++ [&] { (void)PSXRecompV4::load_game_config(bad_horizontal_slt); }, ++ "signed register-register SLT", ++ "parser rejects unsigned horizontal side-plane compares"); + + const auto aspect_cone = write_config(root, "aspect-cone", R"toml( + [widescreen.cull.aspect_cone] +@@ -622,6 +707,67 @@ + check(overlay_mismatch.find("ws cull negsub") == std::string::npos, + "overlay nonmatching negsub variant remains unchanged"); + ++ PSXRecomp::CodeGenConfig cpu_projection_config; ++ cpu_projection_config.ws_backdrop_x_sites.insert(0x80010000u); ++ const std::string cpu_projection = generate_first_instruction( ++ 0x248200A0u, {}, false, cpu_projection_config); // addiu v0,a0,160 ++ check(cpu_projection.find( ++ "psx_ws_project_x((int32_t)(cpu->gpr[4] + (uint32_t)160))") != ++ std::string::npos, ++ "codegen squashes exact CPU-projected screen-X sites"); ++ ++ const std::string cpu_projection_overlay_mismatch = ++ generate_first_instruction( ++ 0x288200A0u, {}, true, cpu_projection_config); // slti at same VA ++ check(cpu_projection_overlay_mismatch.find("psx_ws_project_x") == ++ std::string::npos, ++ "overlay nonmatching CPU-projection variant remains unchanged"); ++ ++ PSXRecomp::CodeGenConfig lower_branch_config; ++ lower_branch_config.ws_cull_lower_branch_sites.insert(0x80010000u); ++ const std::string lower_bgez = generate_first_instruction( ++ 0x04810002u, {}, false, lower_branch_config); // bgez a0,+2 ++ check(lower_bgez.find( ++ "(int32_t)cpu->gpr[4] >= -psx_ws_x_margin()") != ++ std::string::npos, ++ "codegen widens a manual bgez lower screen edge"); ++ const std::string lower_blez = generate_first_instruction( ++ 0x18800002u, {}, false, lower_branch_config); // blez a0,+2 ++ check(lower_blez.find( ++ "(int32_t)cpu->gpr[4] <= -psx_ws_x_margin()") != ++ std::string::npos, ++ "codegen preserves manual lower-edge branch sense"); ++ ++ PSXRecomp::CodeGenConfig lower_value_config; ++ lower_value_config.ws_cull_lower_value_sites.insert(0x80010000u); ++ const std::string lower_zero = generate_first_instruction( ++ 0x00001021u, {}, false, lower_value_config); // addu v0,zero,zero ++ check(lower_zero.find("psx_ws_cull_lower_value(0)") != ++ std::string::npos, ++ "codegen widens a register lower clamp"); ++ const std::string lower_store = generate_first_instruction( ++ 0xAFA0002Cu, {}, false, lower_value_config); // sw zero,44(sp) ++ check(lower_store.find("cpu->write_word(cpu->gpr[29] + 44, " ++ "(uint32_t)psx_ws_cull_lower_value(0))") != ++ std::string::npos, ++ "codegen widens a stored lower clamp"); ++ ++ PSXRecomp::CodeGenConfig upper_value_config; ++ upper_value_config.ws_cull_upper_value_sites.insert(0x80010000u); ++ const std::string upper_value = generate_first_instruction( ++ 0x34020140u, {}, false, upper_value_config); // ori v0,zero,320 ++ check(upper_value.find("psx_ws_cull_upper_value(320)") != ++ std::string::npos, ++ "codegen widens a manual upper bound constant"); ++ ++ PSXRecomp::CodeGenConfig span_value_config; ++ span_value_config.ws_cull_span_value_sites.insert(0x80010000u); ++ const std::string span_value = generate_first_instruction( ++ 0x34020140u, {}, false, span_value_config); // packed [0,320] ++ check(span_value.find("psx_ws_cull_span_value(0x00000140u)") != ++ std::string::npos, ++ "codegen widens both halves of a packed screen span"); ++ + PSXRecomp::CodeGenConfig vxrange_config; + vxrange_config.ws_cull_vxrange_sites.insert(0x80010000u); + const std::string vxrange = generate_first_instruction( +@@ -747,6 +893,37 @@ + check(angle_overlay_mismatch.find("psx_ws_angle_widen") == + std::string::npos, + "overlay full-word mismatch leaves terrain angle unchanged"); ++ ++ PSXRecomp::CodeGenConfig yaw_config; ++ yaw_config.ws_cull_yaw_cone_sites.push_back( ++ {0x80010000u, 0x24710020u, 256u}); ++ const std::string yaw = generate_first_instruction( ++ 0x24710020u, {}, false, yaw_config); ++ check(yaw.find("cpu->gpr[17] = cpu->gpr[3] + (uint32_t)" ++ "psx_ws_yaw_cone_offset(32, 256u)") != ++ std::string::npos, ++ "codegen emits aspect-scaled wrapping-yaw offset"); ++ const std::string yaw_overlay_mismatch = generate_first_instruction( ++ 0x2471001Fu, {}, true, yaw_config); ++ check(yaw_overlay_mismatch.find("psx_ws_yaw_cone_offset") == ++ std::string::npos, ++ "overlay full-word mismatch leaves yaw cone unchanged"); ++ ++ PSXRecomp::CodeGenConfig horizontal_slt_config; ++ horizontal_slt_config.ws_cull_horizontal_slt_sites.push_back( ++ {0x80010000u, 0x0067102Au}); ++ const std::string horizontal_slt = generate_first_instruction( ++ 0x0067102Au, {}, false, horizontal_slt_config); ++ check(horizontal_slt.find( ++ "cpu->gpr[2] = psx_ws_horizontal_slt(cpu->gpr[3], " ++ "cpu->gpr[7])") != std::string::npos, ++ "codegen emits signed camera-horizontal side-plane helper"); ++ const std::string horizontal_slt_overlay_mismatch = ++ generate_first_instruction( ++ 0x0067102Bu, {}, true, horizontal_slt_config); ++ check(horizontal_slt_overlay_mismatch.find("psx_ws_horizontal_slt") == ++ std::string::npos, ++ "overlay full-word mismatch leaves horizontal side plane unchanged"); + + PSXRecomp::CodeGenConfig aspect_config; + aspect_config.ws_aspect_cone.sites.push_back( diff --git a/framework/patches/recompiler/tests/test_reachable_discovery_codegen.py.patch b/framework/patches/recompiler/tests/test_reachable_discovery_codegen.py.patch new file mode 100644 index 0000000..5735eb1 --- /dev/null +++ b/framework/patches/recompiler/tests/test_reachable_discovery_codegen.py.patch @@ -0,0 +1,16 @@ +diff --git a/recompiler/tests/test_reachable_discovery_codegen.py b/recompiler/tests/test_reachable_discovery_codegen.py +index db87e2bea0f88e85d6930b3c687472c91f8e1784..26fd31f8575e5ec96dbcbe054a15fcc05a41b558 100644 +--- a/recompiler/tests/test_reachable_discovery_codegen.py ++++ b/recompiler/tests/test_reachable_discovery_codegen.py +@@ -90,6 +90,11 @@ + + failures = [] + with tempfile.TemporaryDirectory() as tmp: ++ # Keep find_project_root() anchored to this fixture. Some CI/sandbox ++ # hosts place a repository marker in the shared temp parent, which ++ # would otherwise make relative test.exe/seeds.txt paths resolve one ++ # directory too high and turn this into an environment-dependent test. ++ pathlib.Path(tmp, ".gitignore").write_text("\n", encoding="utf-8") + exe = os.path.join(tmp, "test.exe") + seeds = os.path.join(tmp, "seeds.txt") + config = os.path.join(tmp, "game.toml") diff --git a/framework/patches/runtime/CMakeLists.txt.patch b/framework/patches/runtime/CMakeLists.txt.patch new file mode 100644 index 0000000..d517dcb --- /dev/null +++ b/framework/patches/runtime/CMakeLists.txt.patch @@ -0,0 +1,23 @@ +diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt +index 6e6df7651c4a9240a4423d977052975f25d7c237..efeecd26a0422c4ee3347bf3113ef59697c9f2e7 100644 +--- a/runtime/CMakeLists.txt ++++ b/runtime/CMakeLists.txt +@@ -16,6 +16,18 @@ + DEFAULT_BIOS_PATH "${PSXRECOMP_ROOT}/bios/SCPH1001.BIN" + EXE_NAME "PSXRecomp" + ) ++if(WIN32) ++ target_compile_definitions( ++ psx-runtime PRIVATE "$<$:NOMINMAX>") ++endif() ++if(MSVC) ++ # audio_trace.c is the sole runtime source using C11 atomics. Keep the ++ # generated C in MSVC's extension-compatible C mode: its static tables use ++ # address constants that strict /std:c11 rejects. ++ set_property(SOURCE "${PSXRECOMP_ROOT}/runtime/src/audio_trace.c" ++ APPEND PROPERTY COMPILE_OPTIONS ++ "/std:c11" "/experimental:c11atomics") ++endif() + + if(BUILD_TESTING) + add_executable(launcher_device_roundtrip_test diff --git a/framework/patches/runtime/include/dirty_ram_interp.h.patch b/framework/patches/runtime/include/dirty_ram_interp.h.patch new file mode 100644 index 0000000..9ea8ea7 --- /dev/null +++ b/framework/patches/runtime/include/dirty_ram_interp.h.patch @@ -0,0 +1,24 @@ +diff --git a/runtime/include/dirty_ram_interp.h b/runtime/include/dirty_ram_interp.h +index 883f3eaf789ceca5ca406ba82b9d2a5c18eb4261..7f9b87c3215659923e4e5050ec343b61bbbb894b 100644 +--- a/runtime/include/dirty_ram_interp.h ++++ b/runtime/include/dirty_ram_interp.h +@@ -102,6 +102,19 @@ + int psx_kernel_bless_dispatchable(uint32_t phys); + void psx_kernel_bless_note_range(uint32_t phys, uint32_t len); + void psx_kernel_bless_stats(uint64_t out[6]); ++/* Detail for the most recent kernel-body validation failure. `count` is the ++ * cumulative number of failed body validations; out[] is entry, body_lo, ++ * body_hi, first_mismatch, live_byte, reference_byte. */ ++void psx_kernel_bless_mismatch_detail(uint64_t *count, uint32_t out[6]); ++ ++/* Opt-in, low-overhead interpreter hot-PC sampler used by ++ * PSX_RUNTIME_PERF_DIAG. It samples one retired instruction per `stride` and ++ * returns/resets the three hottest physical PCs since the previous take. */ ++#define DIRTY_RAM_PERF_HOT_COUNT 3u ++void dirty_ram_perf_hot_enable(int enabled); ++void dirty_ram_perf_take_hot(uint32_t pcs[DIRTY_RAM_PERF_HOT_COUNT], ++ uint32_t samples[DIRTY_RAM_PERF_HOT_COUNT], ++ uint32_t *stride); + + /* Capture/candidate window membership. Kernel window and overlay region are + * unconditional; main-EXE text [KERNEL_WINDOW_END, FLOOR) is included only diff --git a/framework/patches/runtime/include/gpu.h.patch b/framework/patches/runtime/include/gpu.h.patch new file mode 100644 index 0000000..25d98c3 --- /dev/null +++ b/framework/patches/runtime/include/gpu.h.patch @@ -0,0 +1,230 @@ +diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h +index 11b29fe121b12ffe7b453fde551c274d99425a1e..fad42ec530bb44fb305f34317c46958e1d126ace 100644 +--- a/runtime/include/gpu.h ++++ b/runtime/include/gpu.h +@@ -1,4 +1,4 @@ +-/* gpu.h — PS1 GPU hardware simulation (Phase 3). ++/* gpu.h — PS1 GPU hardware simulation (Phase 3). + * + * Implements GPUSTAT, GP0, GP1, and 1024x512 16-bit VRAM. + * No rendering to screen — just correct hardware state transitions. +@@ -116,6 +116,135 @@ + * env var, if set, overrides this. Default 0 (raw) is byte-identical. */ + void gpu_set_screen_kind(int kind); + ++/* Presentation-only high-precision geometry. The guest-visible GP0/GTE path ++ * remains integer; supported renderers mirror gameplay polygons into a ++ * separate display surface using retained 16.16 projection coordinates. */ ++void gpu_geometry_correction_set(int enabled); ++int gpu_geometry_correction_enabled(void); ++void gpu_geometry_camera_yaw_residual_set(double yaw_units); ++/* Display-relative projection horizon shared with the optional presentation ++ * yaw homography. Values are rounded to an integer pixel; non-finite or ++ * out-of-range values reset the horizon to the 240-line default of 120. */ ++void gpu_geometry_camera_projection_center_y_set(double center_y); ++int gpu_geometry_camera_projection_center_y_get(void); ++void gpu_geometry_correction_stats(uint64_t *world_triangles, ++ uint64_t *precise_triangles); ++ ++/* Independent, default-off perspective texture interpolation for exact world ++ * triangles on the presentation mirror. Canonical VRAM, sprites, rectangles, ++ * UI, and any polygon without strict projective depth remain affine. */ ++void gpu_texture_correction_set(int enabled); ++int gpu_texture_correction_enabled(void); ++uint32_t gpu_texture_correction_hits(void); ++ ++/* Versioned Test-13 diagnostics. Candidate/accepted primitive arrays count ++ * polygons; triangle and vertex totals make quad coverage unambiguous. The ++ * latest_live counters are the most recent presentation which was eligible to ++ * consume the independent corrected surface, while cumulative includes every ++ * geometry-correction candidate since the feature was enabled. */ ++#define GPU_GEOMETRY_DIAGNOSTICS_VERSION 1u ++#define GPU_GEOMETRY_REJECT_REASON_COUNT 15u ++#define GPU_GEOMETRY_PRIMITIVE_CLASS_COUNT 8u ++#define GPU_GEOMETRY_OPCODE_COUNT 32u ++#define GPU_GEOMETRY_DEPTH_BUCKET_COUNT 6u ++#define GPU_GEOMETRY_SCREEN_BUCKET_COUNT 10u ++#define GPU_GEOMETRY_CORRECTION_BUCKET_COUNT 5u ++ ++typedef enum GpuGeometryRejectReason { ++ GPU_GEOMETRY_REJECT_NO_COMMAND_SOURCE = 0, ++ GPU_GEOMETRY_REJECT_SOURCE_ADDRESS_OVERFLOW, ++ GPU_GEOMETRY_REJECT_TRACKING_DISABLED, ++ GPU_GEOMETRY_REJECT_SPECULATIVE_LOOKUP, ++ GPU_GEOMETRY_REJECT_SOURCE_NOT_RAM, ++ GPU_GEOMETRY_REJECT_SOURCE_UNALIGNED, ++ GPU_GEOMETRY_REJECT_STORE_MISS, ++ GPU_GEOMETRY_REJECT_STORE_COLLISION, ++ GPU_GEOMETRY_REJECT_PROJECTION_INVALID, ++ GPU_GEOMETRY_REJECT_PACKED_MISMATCH, ++ GPU_GEOMETRY_REJECT_ZERO_DEPTH, ++ GPU_GEOMETRY_REJECT_SATURATED, ++ GPU_GEOMETRY_REJECT_INTEGER_MISMATCH, ++ GPU_GEOMETRY_REJECT_ATOMIC_FALLBACK, ++ GPU_GEOMETRY_REJECT_RECTANGLE_FAST_PATH ++} GpuGeometryRejectReason; ++ ++typedef enum GpuGeometryPrimitiveClass { ++ GPU_GEOMETRY_PRIMITIVE_FLAT_TRI = 0, ++ GPU_GEOMETRY_PRIMITIVE_TEXTURED_FLAT_TRI, ++ GPU_GEOMETRY_PRIMITIVE_GOURAUD_TRI, ++ GPU_GEOMETRY_PRIMITIVE_TEXTURED_GOURAUD_TRI, ++ GPU_GEOMETRY_PRIMITIVE_FLAT_QUAD, ++ GPU_GEOMETRY_PRIMITIVE_TEXTURED_FLAT_QUAD, ++ GPU_GEOMETRY_PRIMITIVE_GOURAUD_QUAD, ++ GPU_GEOMETRY_PRIMITIVE_TEXTURED_GOURAUD_QUAD ++} GpuGeometryPrimitiveClass; ++ ++typedef struct GpuGeometryCorrectionCounters { ++ uint64_t triangle_candidates; ++ uint64_t triangle_accepted; ++ uint64_t polygon_candidates; ++ uint64_t polygon_accepted; ++ uint64_t vertex_candidates; ++ uint64_t vertex_accepted; ++ uint64_t vertex_rejections[GPU_GEOMETRY_REJECT_REASON_COUNT]; ++ uint64_t primitive_candidates[GPU_GEOMETRY_PRIMITIVE_CLASS_COUNT]; ++ uint64_t primitive_accepted[GPU_GEOMETRY_PRIMITIVE_CLASS_COUNT]; ++ /* Polygon opcode 0x20..0x3f, indexed by opcode - 0x20. */ ++ uint64_t opcode_candidates[GPU_GEOMETRY_OPCODE_COUNT]; ++ uint64_t opcode_accepted[GPU_GEOMETRY_OPCODE_COUNT]; ++ /* Accepted vertices: z <256,<1024,<4096,<16384,<32768,>=32768. */ ++ uint64_t depth_vertices[GPU_GEOMETRY_DEPTH_BUCKET_COUNT]; ++ /* Accepted vertices: 3x3 on-screen cells followed by off-screen. */ ++ uint64_t screen_vertices[GPU_GEOMETRY_SCREEN_BUCKET_COUNT]; ++ uint64_t correction_vertices; ++ uint64_t correction_abs_x16_sum; ++ uint64_t correction_abs_y16_sum; ++ uint64_t correction_magnitude16_sum; ++ uint64_t correction_magnitude16_max; ++ uint64_t correction_x16_squared_sum; ++ uint64_t correction_y16_squared_sum; ++ /* Set to one if any correction sum has saturated at UINT64_MAX. */ ++ uint64_t correction_accumulators_saturated; ++ /* max(|dx|,|dy|): zero, <.25px, <.5px, <.75px, <1px. */ ++ uint64_t correction_magnitude_buckets[ ++ GPU_GEOMETRY_CORRECTION_BUCKET_COUNT]; ++ uint64_t partial_polygon_rejections; ++ uint64_t partial_quad_rejections; ++} GpuGeometryCorrectionCounters; ++ ++typedef struct GpuGeometryCorrectionStats { ++ uint32_t version; ++ uint32_t size; ++ uint64_t presentation_sequence; ++ uint64_t corrected_presentations; ++ uint64_t latest_live_presentation; ++ uint32_t latest_live_valid; ++ uint32_t reserved; ++ uint64_t provenance_store_collisions; ++ uint64_t provenance_store_evictions; ++ uint64_t provenance_lookup_collisions; ++ GpuGeometryCorrectionCounters cumulative; ++ GpuGeometryCorrectionCounters latest_live; ++ /* Additive tail field: older size-bounded callers retain the version-1 ++ * prefix while current diagnostics expose rejected uncommitted SWC2s. */ ++ uint64_t provenance_store_uncommitted_rejections; ++ /* Reviewed MFC2 -> ordinary-SW routes. Attempts count committed-site ++ * observations; accepts entered exact provenance, while packed rejections ++ * identify clipped/repacked output that deliberately stayed canonical. */ ++ uint64_t provenance_registered_store_attempts; ++ uint64_t provenance_registered_store_accepts; ++ uint64_t provenance_registered_store_packed_rejections; ++ /* Reviewed projected-vertex-buffer -> GP0 packet copy routes. */ ++ uint64_t provenance_copy_load_attempts; ++ uint64_t provenance_copy_load_accepts; ++ uint64_t provenance_copy_store_attempts; ++ uint64_t provenance_copy_store_accepts; ++ uint64_t provenance_copy_store_packed_rejections; ++} GpuGeometryCorrectionStats; ++ ++void gpu_geometry_correction_stats_detailed( ++ GpuGeometryCorrectionStats *out, uint32_t out_size); ++ + /* Widescreen proportion correction (active only when aspect != 4:3 and the + * game's [widescreen] block opts in — see config_loader.h). Tagged + * character/billboard prims are re-squashed around their projected anchor; +@@ -126,6 +255,39 @@ + * (render the wider FOV into a wider frame, present 1:1; GTE not squashed). */ + void gpu_ws_configure(int aspect_num, int aspect_den, + uint32_t sprite_anchor_addr, int hud_sprt_squash, int mode); ++/* Add an audited world primitive to the existing classic-squash provenance ++ * table. This is host presentation metadata only; it never edits guest RAM. ++ * Calls are ignored outside an active classic-wide gameplay presentation. */ ++/* Camera depth of a CPU-projected POLY_FT4 sprite, for frame interpolation. */ ++void gpu_temporal_note_sprite(CPUState *cpu, uint32_t primitive_addr, ++ int32_t depth); ++/* The unrounded centre column of the sprite just noted and its distance from its row, 16.16. */ ++void gpu_temporal_place_sprite(uint32_t primitive_addr, int32_t centre_x16, int32_t offset_y16); ++/* The unrounded row the placed sprite is centred on and its unrounded sides, 16.16. A shadow lies on the floor. ++ * whole: the packet spans the size cut to whole pixels, not one less. */ ++void gpu_temporal_size_sprite(uint32_t primitive_addr, int32_t row16, int32_t width16, int32_t height16, ++ int shadow, int whole); ++/* Shadows as the game shapes them (0) or flattened the way the floor recedes (1). */ ++void gpu_set_shadow_shape(int improved); ++int gpu_shadow_shape(void); ++int gpu_sprite_placement_available(void); ++void gpu_ws_tag_primitive(CPUState *cpu, uint32_t primitive_addr, ++ int32_t anchor_x); ++/* Mark a completed, audited SPRT packet as a backdrop tile laid out edge to ++ * edge in widened coordinates: classic squash maps it about the display centre. */ ++void gpu_ws_tag_screen_tile(CPUState *cpu, uint32_t primitive_addr); ++/* Widened counterpart of a frame X for such a tile span. */ ++int32_t gpu_ws_widen_x(int32_t x, int round_up); ++/* Size of the widescreen HUD in percent, 50 to 100. 100 is the authored size. */ ++void gpu_ws_set_hud_scale(int percent); ++int gpu_ws_hud_scale(void); ++/* HUD widgets of a game that links its HUD to `layers` nodes just below its ordering table, whose address ++ * is the guest word at `table_pointer`: six numbers each, the box x0, y0, x1, y1 and the pivot x, y. Once ++ * such a layer has shown a widget, later rectangles inside its box belong to it too, until the game sets ++ * its next drawing area. The game has to set one every frame. */ ++void gpu_ws_set_hud_widgets(uint32_t table_pointer, int layers, const int32_t *boxes, int count); ++/* A game whose text printer draws a line as one picture `wide` across, the words from its left end. */ ++void gpu_ws_set_hud_text_strip(int wide); + /* [widescreen] full_2d: opt a pure-2D sprite game into the widescreen present + * path (treat every in-game frame as gameplay, since it never tags 3D prims). */ + void gpu_ws_set_full_2d(int on); +@@ -186,6 +348,13 @@ + * full-2D menu/title screen), so the squash is suppressed and content drawn + * pixel-native. The present path uses the same predicate to pillarbox. */ + int gpu_ws_present_native_43(void); ++/* The same for the frame on display, which a double-buffered game drew a frame earlier. */ ++int gpu_ws_displayed_native_43(void); ++/* The frame on display is a front-end screen shown wide: flat, so nothing is put in between its frames. */ ++int gpu_ws_displayed_menu(void); ++/* A game says what the frame it begins to build is, before it culls or projects for it: 1 a full-2D screen, 0 a world. */ ++void gpu_ws_tell_frame_kind(int flat); ++int gpu_ws_displayed_flat(void); + /* Per-side X cull-margin (screen/world units) emitted into the game's draw- + * cull immediates by the recompiler ([widescreen.cull]); 0 unless stretching. */ + int psx_ws_x_margin(void); +@@ -240,6 +409,8 @@ + int psx_ws_cull_keep_site(uint32_t pc, uint32_t instr, uint32_t vanilla, + uint32_t *out); + uint32_t psx_ws_angle_widen(uint32_t vanilla); ++int32_t psx_ws_yaw_cone_offset(int32_t vanilla, uint32_t full_turn); ++uint32_t psx_ws_horizontal_slt(uint32_t bound, uint32_t horizontal); + int psx_ws_angle_site(uint32_t pc, uint32_t instr, uint32_t *out); + uint32_t psx_ws_aspect_cone_result(uint32_t site, uint32_t vanilla, + uint32_t object, int32_t x, int32_t z, +@@ -263,6 +434,9 @@ + * widen for the paired `bltz maxSX` reject. Identity at 4:3. */ + int psx_ws_cull_slti(uint32_t sx, uint32_t imm); + int psx_ws_cull_slti_lower(uint32_t sx, uint32_t imm); ++int32_t psx_ws_cull_lower_value(int32_t vanilla); ++int32_t psx_ws_cull_upper_value(int32_t vanilla); ++uint32_t psx_ws_cull_span_value(uint32_t vanilla); + int psx_ws_cull_bltz(uint32_t v); + int psx_ws_cull_vxrange(uint32_t x, uint32_t imm); + /* True if a run of instruction words carries the screen-extent reject signature +@@ -318,6 +492,10 @@ + * final screenX store to squash it around the screen centre (identity at 4:3). + * Pulls far backdrop pieces in from past the 320px edge to cover the 16:9 FOV. */ + int psx_ws_backdrop_x(int x); ++/* CPU-calculated perspective X counterpart to the GTE widescreen squash. ++ * Exact game sites use this only for final projected screen coordinates. */ ++int psx_ws_project_x(int x); ++int32_t psx_ws_project_x16(int x, int32_t fraction16); + + /* Backdrop PRELOAD ([widescreen.cull] auto_backdrop). psx_ws_backdrop_preload() + * is nonzero only while native-wide widescreen is engaged (0 at 4:3 / boot / diff --git a/framework/patches/runtime/include/gpu_render.h.patch b/framework/patches/runtime/include/gpu_render.h.patch new file mode 100644 index 0000000..0b313bb --- /dev/null +++ b/framework/patches/runtime/include/gpu_render.h.patch @@ -0,0 +1,77 @@ +diff --git a/runtime/include/gpu_render.h b/runtime/include/gpu_render.h +index 4953270aaccd4c606f5320f949befcdc544261e6..6cb1779203723b8aa120fc1109cf32325b244a29 100644 +--- a/runtime/include/gpu_render.h ++++ b/runtime/include/gpu_render.h +@@ -38,6 +38,41 @@ + void gr_set_mask_bits(int set_bit, int check_bit); + void gr_set_texture_window(uint32_t raw); + void gr_set_color_modulation(int r, int g, int b, int raw_texture); ++ ++/* Optional presentation-only geometry correction. The authoritative PSX ++ * framebuffer is always rasterized from the integer GP0 coordinates. A ++ * backend may use the precise 16.16 coordinates and fractional camera yaw ++ * only while mirroring the primitive into a separate display surface. */ ++void gr_set_geometry_correction(int enabled); ++void gr_set_world_triangle(int world); ++void gr_set_precise_triangle(int enabled, ++ int32_t x0, int32_t y0, ++ int32_t x1, int32_t y1, ++ int32_t x2, int32_t y2); ++/* One-shot presentation positions of a triangle without exact provenance: where its pinned corners project. */ ++void gr_set_unpinned_triangle(int32_t x0, int32_t y0, int32_t x1, int32_t y1, int32_t x2, int32_t y2); ++/* One-shot reciprocal-depth metadata for the next textured triangle. Only a ++ * backend with a separate presentation surface should implement this; the ++ * authoritative PS1 framebuffer must retain affine texture interpolation. */ ++void gr_set_perspective_triangle(int enabled, ++ float q0, float q1, float q2); ++/* One-shot unnormalized GTE camera depth for temporal vertex identity. This ++ * metadata never reaches the shaders and is independent of perspective q, ++ * whose scale can differ between polygons. Zero means unknown depth. */ ++void gr_set_temporal_depth_triangle(int enabled, ++ float z0, float z1, float z2); ++/* One-shot camera depth of the same corners for the display mirror's own depth: a corner the game pinned has one here, ++ * where the temporal depth above says 0. Zero means unknown. */ ++void gr_set_surface_depth_triangle(float z0, float z1, float z2); ++/* One-shot GTE input vertices of the same three corners (zero = unknown). */ ++void gr_set_temporal_identity_triangle(uint64_t id0, uint64_t id1, uint64_t id2); ++/* The next textured rectangle is a world sprite at this camera depth (0 = none). */ ++void gr_set_temporal_sprite(float depth, const float sides[4]); ++/* center_y is relative to the active drawing band; a backend which stores ++ * absolute VRAM positions must combine it with its current draw offset. */ ++void gr_set_presentation_yaw(double yaw_units, double full_turn, ++ double center_x, double center_y, ++ double focal_x); + + /* Primitives */ + void gr_fill_rect(int x, int y, int w, int h, uint16_t color); +@@ -119,6 +154,22 @@ + void (*set_mask_bits)(int set_bit, int check_bit); + void (*set_texture_window)(uint32_t raw); + void (*set_color_modulation)(int r, int g, int b, int raw_texture); ++ void (*set_geometry_correction)(int enabled); ++ void (*set_world_triangle)(int world); ++ void (*set_precise_triangle)(int enabled, ++ int32_t x0, int32_t y0, ++ int32_t x1, int32_t y1, ++ int32_t x2, int32_t y2); ++ void (*set_perspective_triangle)(int enabled, ++ float q0, float q1, float q2); ++ void (*set_temporal_depth_triangle)(int enabled, ++ float z0, float z1, float z2); ++ void (*set_surface_depth_triangle)(float z0, float z1, float z2); ++ void (*set_temporal_identity_triangle)(uint64_t id0, uint64_t id1, uint64_t id2); ++ void (*set_temporal_sprite)(float depth, const float sides[4]); ++ void (*set_presentation_yaw)(double yaw_units, double full_turn, ++ double center_x, double center_y, ++ double focal_x); + void (*fill_rect)(int x, int y, int w, int h, uint16_t color); + void (*copy_rect)(int src_x, int src_y, int dst_x, int dst_y, int w, int h); + void (*draw_flat_triangle)(int x0, int y0, int x1, int y1, int x2, int y2, +@@ -176,6 +227,7 @@ + * pixel count, writes width/height to ow/oh. NULL if unsupported. */ + int (*wide_dump_full)(uint32_t *out, int cap_pixels, int *ow, int *oh, + int base_x); ++ void (*set_unpinned_triangle)(int32_t x0, int32_t y0, int32_t x1, int32_t y1, int32_t x2, int32_t y2); + } GpuRenderBackend; + + #ifdef __cplusplus diff --git a/framework/patches/runtime/include/gpu_sw_renderer.h.patch b/framework/patches/runtime/include/gpu_sw_renderer.h.patch new file mode 100644 index 0000000..384944a --- /dev/null +++ b/framework/patches/runtime/include/gpu_sw_renderer.h.patch @@ -0,0 +1,13 @@ +diff --git a/runtime/include/gpu_sw_renderer.h b/runtime/include/gpu_sw_renderer.h +index b8cea0e3a63da893772e2beccd97dc2ebec2559b..193ecbdf42fd1f0bd26d2ba36a0f8f17f59208d3 100644 +--- a/runtime/include/gpu_sw_renderer.h ++++ b/runtime/include/gpu_sw_renderer.h +@@ -8,7 +8,7 @@ + #endif + + /* Maximum internal-resolution supersampling factor (linear, per axis). */ +-#define SW_MAX_INTERNAL_SCALE 4 ++#define SW_MAX_INTERNAL_SCALE 8 + + /* Initialize software renderer */ + void sw_renderer_init(uint16_t* vram); diff --git a/framework/patches/runtime/include/mod_memory.h.patch b/framework/patches/runtime/include/mod_memory.h.patch new file mode 100644 index 0000000..dab1ee5 --- /dev/null +++ b/framework/patches/runtime/include/mod_memory.h.patch @@ -0,0 +1,16 @@ +diff --git a/runtime/include/mod_memory.h b/runtime/include/mod_memory.h +index 7406c39a683b0a7a9c8df8d80004c46cb2408d7c..c5a5c3f89c56ca8869bc7fb1fd18cfdc129fe1bb 100644 +--- a/runtime/include/mod_memory.h ++++ b/runtime/include/mod_memory.h +@@ -43,6 +43,11 @@ + + uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t size, uint32_t alignment); + uint32_t psx_mod_gpu_dma_resolve_address(uint32_t address); ++/* Snapshot payload is the allocated byte prefix; restoring its length also ++ * restores the allocation boundary needed by CPU and 24-bit GPU accesses. */ ++uint32_t psx_mod_gpu_dma_memory_bytes(void); ++const uint8_t *psx_mod_gpu_dma_memory_data(void); ++int psx_mod_gpu_dma_memory_restore(const uint8_t *data, uint32_t size); + + #ifdef __cplusplus + } diff --git a/framework/patches/runtime/include/overlay_dispatch_preamble.c.inc.patch b/framework/patches/runtime/include/overlay_dispatch_preamble.c.inc.patch new file mode 100644 index 0000000..b13270a --- /dev/null +++ b/framework/patches/runtime/include/overlay_dispatch_preamble.c.inc.patch @@ -0,0 +1,24 @@ +diff --git a/runtime/include/overlay_dispatch_preamble.c.inc b/runtime/include/overlay_dispatch_preamble.c.inc +index 6cd4b10e4d8525813984ce8a136803e71f6ecb5b..ea2e5c7cb9190c84d87f9ac1d984760aab04c015 100644 +--- a/runtime/include/overlay_dispatch_preamble.c.inc ++++ b/runtime/include/overlay_dispatch_preamble.c.inc +@@ -202,6 +202,19 @@ + int psx_ws_cull_slti(uint32_t sx, uint32_t imm) { + return ((int32_t)sx < (int32_t)imm + psx_ws_x_margin()) ? 1 : 0; + } ++int32_t psx_ws_cull_lower_value(int32_t vanilla) { ++ return vanilla - psx_ws_x_margin(); ++} ++int32_t psx_ws_cull_upper_value(int32_t vanilla) { ++ return vanilla + psx_ws_x_margin(); ++} ++uint32_t psx_ws_cull_span_value(uint32_t vanilla) { ++ int32_t margin = psx_ws_x_margin(); ++ int32_t left = (int32_t)(int16_t)(uint16_t)(vanilla >> 16); ++ uint32_t right = vanilla & 0xFFFFu; ++ return ((uint32_t)(uint16_t)(left - margin) << 16) | ++ (uint32_t)(uint16_t)((int32_t)right + margin); ++} + int psx_ws_cull_bltz(uint32_t v) { + return ((int32_t)v < -psx_ws_x_margin()) ? 1 : 0; + } diff --git a/framework/patches/runtime/include/psx_cyc.h.patch b/framework/patches/runtime/include/psx_cyc.h.patch new file mode 100644 index 0000000..af05140 --- /dev/null +++ b/framework/patches/runtime/include/psx_cyc.h.patch @@ -0,0 +1,30 @@ +diff --git a/runtime/include/psx_cyc.h b/runtime/include/psx_cyc.h +index a7e79f81c443083143768c02fcc5ea201a0fc8c7..30ef6723fcd47aa8c7d74ba0a802e22f4f6f747c 100644 +--- a/runtime/include/psx_cyc.h ++++ b/runtime/include/psx_cyc.h +@@ -66,11 +66,25 @@ + } + #endif + ++#if !defined(PSX_OVERLAY_DLL_BUILD) ++/* Enhancement-only guest CPU overclock: instruction cycles are charged at ++ * 1 / (1 << shift). Zero keeps the faithful cost. */ ++extern uint32_t g_psx_cyc_overclock_shift; ++extern uint32_t g_psx_cyc_overclock_carry; ++#endif ++ + static inline void psx_cyc_charge(uint32_t cycles) { + if (cycles == 0u) return; + #if defined(PSX_OVERLAY_DLL_BUILD) + psx_advance_cycles(cycles); + #else ++ if (g_psx_cyc_overclock_shift) { ++ const uint32_t total = g_psx_cyc_overclock_carry + cycles; ++ g_psx_cyc_overclock_carry = ++ total & ((1u << g_psx_cyc_overclock_shift) - 1u); ++ cycles = total >> g_psx_cyc_overclock_shift; ++ if (cycles == 0u) return; ++ } + #if defined(__GNUC__) || defined(__clang__) + if (__builtin_expect(g_ls_replay_active | g_event_step_conservative, 0)) { + #else diff --git a/framework/patches/runtime/include/ws_aspect_cone_math.h.patch b/framework/patches/runtime/include/ws_aspect_cone_math.h.patch new file mode 100644 index 0000000..ae936e8 --- /dev/null +++ b/framework/patches/runtime/include/ws_aspect_cone_math.h.patch @@ -0,0 +1,23 @@ +diff --git a/runtime/include/ws_aspect_cone_math.h b/runtime/include/ws_aspect_cone_math.h +index abb3adacf76f3836c11201dc7990ae9a6faba856..8355760ad00a0647b88ac750721cae03bb7c3bd1 100644 +--- a/runtime/include/ws_aspect_cone_math.h ++++ b/runtime/include/ws_aspect_cone_math.h +@@ -24,6 +24,18 @@ + * when extent_pixels is zero. Results stay below a 90-degree half-angle. */ + uint32_t psx_ws_widen_angle_q12(uint32_t vanilla, int extent_pixels); + ++/* Generic signed counterpart for games whose wrapping yaw uses a different ++ * unit system. `full_turn` is the number of integer units in 360 degrees. ++ * The sign is preserved and the magnitude is widened by the same 320-wide ++ * projection geometry. */ ++int32_t psx_ws_widen_angle_units(int32_t vanilla, uint32_t full_turn, ++ int extent_pixels); ++ ++/* Scale a signed camera-horizontal coordinate into the original 320-wide ++ * projection before an unchanged side-plane comparison. Identity at zero ++ * extent; symmetric nearest-integer rounding for negative values. */ ++int32_t psx_ws_scale_horizontal_320(int32_t value, int extent_pixels); ++ + #ifdef __cplusplus + } + #endif diff --git a/framework/patches/runtime/src/boot_state.c.patch b/framework/patches/runtime/src/boot_state.c.patch new file mode 100644 index 0000000..ec603ca --- /dev/null +++ b/framework/patches/runtime/src/boot_state.c.patch @@ -0,0 +1,81 @@ +diff --git a/runtime/src/boot_state.c b/runtime/src/boot_state.c +index b4acb4175df98cced308fbcc84e64d56a9b98c0c..7ba1437dbe6da8fdb8da74faa40b5e4bc4efaa5b 100644 +--- a/runtime/src/boot_state.c ++++ b/runtime/src/boot_state.c +@@ -1,4 +1,10 @@ + #include "boot_state.h" ++#include "mod_memory.h" ++ ++/* Optional v3 section; old snapshots predate enhancement packet allocations. */ ++#define BS_SEC_GPU_DMA_MEMORY 0x0Fu ++/* Optional: the instruction cost divider of an enhancement and its carry. An old snapshot resumes at the session's. */ ++#define BS_SEC_CPU_COST 0x10u + #include "overlay_api.h" /* PSX_OVERLAY_CODEGEN_HASH / _ABI_TAG / _CODEGEN_VER */ + #include "gpu_render.h" /* gr_vram_transfer_in / gr_vram_transfer_out */ + #include "psx_cycles.h" +@@ -22,6 +28,8 @@ + extern uint32_t i_stat; + extern uint32_t i_mask; + extern uint64_t psx_cycle_count; ++extern uint32_t g_psx_cyc_overclock_shift; ++extern uint32_t g_psx_cyc_overclock_carry; + extern void timers_get_snapshot(uint16_t counter[3], uint32_t mode[3], + uint16_t target[3], int32_t irq_line[3], + uint32_t frac[3]); +@@ -178,7 +186,7 @@ + h.codegen_hash = (uint32_t)PSX_OVERLAY_CODEGEN_HASH; + h.abi_tag = (int32_t)PSX_OVERLAY_ABI_TAG; + h.codegen_ver = (uint32_t)PSX_OVERLAY_CODEGEN_VER; +- h.section_count = 14; ++ h.section_count = 16; + + int ok = write_header_le(f, &h); + +@@ -242,6 +250,18 @@ + } + } + ++ if (ok) ok = write_section(f, BS_SEC_GPU_DMA_MEMORY, ++ psx_mod_gpu_dma_memory_data(), ++ psx_mod_gpu_dma_memory_bytes()); ++ if (ok) { ++ uint8_t cost[8]; ++ PstW w; ++ pst_w_init(&w, cost, sizeof cost); ++ ok = pst_w_u32(&w, g_psx_cyc_overclock_shift) && ++ pst_w_u32(&w, g_psx_cyc_overclock_carry) && ++ write_section(f, BS_SEC_CPU_COST, cost, sizeof cost); ++ } ++ + fclose(f); + if (!ok) + remove(path); +@@ -271,6 +291,19 @@ + if (!pst_r_u32(&r, &cpu->gte_ctrl[i])) return 0; + return 1; + } ++ case BS_SEC_GPU_DMA_MEMORY: ++ return psx_mod_gpu_dma_memory_restore(p, len); ++ case BS_SEC_CPU_COST: { ++ PstR r; ++ uint32_t shift, carry; ++ if (len != 8) return 0; ++ pst_r_init(&r, p, len); ++ /* A wait drops the divider and leaves the carry for the next frame, so the carry is not held to the divider. */ ++ if (!pst_r_u32(&r, &shift) || !pst_r_u32(&r, &carry) || shift > 31u || (carry >> 31) != 0u) return 0; ++ g_psx_cyc_overclock_shift = shift; ++ g_psx_cyc_overclock_carry = carry; ++ return 1; ++ } + case BS_SEC_RAM: + if (len != RAM_SIZE) return 0; + memcpy(memory_get_ram_ptr(), p, RAM_SIZE); +@@ -424,6 +457,8 @@ + + if (!ok || (seen & required) != required) + return 0; ++ if (!(seen & (1u << BS_SEC_GPU_DMA_MEMORY))) ++ psx_mod_gpu_dma_memory_restore(NULL, 0u); + return 1; + } + diff --git a/framework/patches/runtime/src/dirty_ram_interp.c.patch b/framework/patches/runtime/src/dirty_ram_interp.c.patch new file mode 100644 index 0000000..d0779db --- /dev/null +++ b/framework/patches/runtime/src/dirty_ram_interp.c.patch @@ -0,0 +1,177 @@ +diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c +index 0a324bcd9913b32b2bb2bcd7a673fd4aba39e4c4..d503d93efe57bb5e2d23c9cb4f7b224097ef9140 100644 +--- a/runtime/src/dirty_ram_interp.c ++++ b/runtime/src/dirty_ram_interp.c +@@ -24,6 +24,7 @@ + */ + + #include "dirty_ram_interp.h" ++#include "gte_precision.h" + #include "cpu_state.h" + #include "debug_server.h" + #include "interrupts.h" +@@ -34,6 +35,9 @@ + #include "ws_backdrop_detect.h" /* shared backdrop-window detector (auto_backdrop) */ + #include "lockstep.h" + #include "starvation_ring.h" ++#ifdef PSX_HAS_GAME_HOST ++#include "game_host.h" ++#endif + + #include + #include +@@ -45,6 +49,55 @@ + uint64_t g_dirty_ram_aborts = 0; + uint64_t g_dirty_ram_guard_yields = 0; + uint64_t g_dirty_ram_native_handoffs = 0; ++ ++/* One sample per 256 interpreted instructions identifies stable hot loops ++ * without turning production diagnostics into another per-insn profiler. The ++ * physical-PC direct table has no hash collisions and is touched only while ++ * PSX_RUNTIME_PERF_DIAG is enabled. */ ++#define DIRTY_RAM_PERF_SAMPLE_SHIFT 8u ++#define DIRTY_RAM_PERF_SAMPLE_STRIDE (1u << DIRTY_RAM_PERF_SAMPLE_SHIFT) ++static int s_dirty_ram_perf_hot_enabled = 0; ++static uint32_t s_dirty_ram_perf_hot_samples[DIRTY_RAM_EXEC_WORD_COUNT]; ++ ++void dirty_ram_perf_hot_enable(int enabled) { ++ s_dirty_ram_perf_hot_enabled = enabled ? 1 : 0; ++ memset(s_dirty_ram_perf_hot_samples, 0, ++ sizeof(s_dirty_ram_perf_hot_samples)); ++} ++ ++static inline void dirty_ram_perf_hot_record(uint32_t pc) { ++ if (!s_dirty_ram_perf_hot_enabled || ++ (g_dirty_ram_insns_run & (DIRTY_RAM_PERF_SAMPLE_STRIDE - 1u)) != 0u) ++ return; ++ uint32_t phys = pc & 0x1FFFFFFFu; ++ if (phys >= 2u * 1024u * 1024u || (phys & 3u) != 0u) return; ++ uint32_t *slot = &s_dirty_ram_perf_hot_samples[phys >> 2]; ++ if (*slot != UINT32_MAX) (*slot)++; ++} ++ ++void dirty_ram_perf_take_hot(uint32_t pcs[DIRTY_RAM_PERF_HOT_COUNT], ++ uint32_t samples[DIRTY_RAM_PERF_HOT_COUNT], ++ uint32_t *stride) { ++ uint32_t top_pc[DIRTY_RAM_PERF_HOT_COUNT] = {0}; ++ uint32_t top_n[DIRTY_RAM_PERF_HOT_COUNT] = {0}; ++ for (uint32_t word = 0; word < DIRTY_RAM_EXEC_WORD_COUNT; word++) { ++ uint32_t n = s_dirty_ram_perf_hot_samples[word]; ++ if (n == 0u || n <= top_n[DIRTY_RAM_PERF_HOT_COUNT - 1u]) continue; ++ uint32_t at = DIRTY_RAM_PERF_HOT_COUNT - 1u; ++ while (at > 0u && n > top_n[at - 1u]) { ++ top_n[at] = top_n[at - 1u]; ++ top_pc[at] = top_pc[at - 1u]; ++ at--; ++ } ++ top_n[at] = n; ++ top_pc[at] = word << 2; ++ } ++ memset(s_dirty_ram_perf_hot_samples, 0, ++ sizeof(s_dirty_ram_perf_hot_samples)); ++ if (pcs) memcpy(pcs, top_pc, sizeof(top_pc)); ++ if (samples) memcpy(samples, top_n, sizeof(top_n)); ++ if (stride) *stride = DIRTY_RAM_PERF_SAMPLE_STRIDE; ++} + /* Scheduling-contract pump telemetry (see dirty_ram_dispatch). Always-on: + * g_dirty_pump_max_gap_insns is the largest interpreted-insn gap ever seen + * between two interrupt pumps — must stay bounded (~4096 + one block) once the +@@ -1449,6 +1502,7 @@ + uint32_t dummy_next = 0; + (void)exec_one_fetched(cpu, pc, insn, &dummy_next); + g_dirty_ram_insns_run++; ++ dirty_ram_perf_hot_record(pc); + /* CYCLE MODEL: the delay-slot instruction is a real retired R3000A instruction + * and is charged its own per-instruction interlock INSIDE exec_one (top-of-fn + * §1+deps+DO_LDS, or psx_cyc_load_* for a load delay slot) — so a branch+slot +@@ -1516,6 +1570,10 @@ + const uint32_t ld_before = is_ld ? cpu->gpr[ld_rt] : 0u; + + const int rv = exec_one_fetched_inner(cpu, pc, insn, next_pc_out); ++ ++#ifdef PSX_HAS_GAME_HOST ++ psx_game_instruction_ran(cpu, pc, insn); ++#endif + + if (is_ld) { + const uint32_t loaded = cpu->gpr[ld_rt]; +@@ -2114,7 +2172,9 @@ + #ifdef PSX_ENABLE_BLOCK_CYCLES + psx_gte_read(cpu, rt); + #endif +- cpu->gpr[rt] = gte_read_data(cpu, (uint8_t)rd); ++ cpu->gpr[rt] = gte_precision_mfc2_pc_read( ++ pc, insn, (uint8_t)rd, ++ gte_read_data(cpu, (uint8_t)rd)); + cpu->gpr[0] = 0; + return 0; + } +@@ -2181,6 +2241,8 @@ + cpu->gpr[rt] = psx_ws_xclip_bound(psx_cyc_load_word(cpu, addr, rt, 1u << rs)); + else + cpu->gpr[rt] = psx_cyc_load_word(cpu, addr, rt, 1u << rs); ++ cpu->gpr[rt] = gte_precision_copy_pc_read( ++ pc, insn, (uint8_t)rt, addr, cpu->gpr[rt]); + cpu->gpr[0] = 0; + return 0; + } +@@ -2236,6 +2298,11 @@ + g_debug_last_store_pc = pc; + parappa_rhythm_log_store(cpu, pc, addr, cpu->gpr[rt], 4u); + cpu->write_word(addr, cpu->gpr[rt]); ++ gte_precision_scratch_store_pc_word( ++ pc, insn, addr, cpu->gpr[rt]); ++ gte_precision_copy_pc_word( ++ pc, insn, (uint8_t)rt, addr, cpu->gpr[rt]); ++ gte_precision_store_pc_word(pc, insn, addr, cpu->gpr[rt]); + return 0; + } + case 0x2E: { /* SWR */ +@@ -2509,6 +2576,7 @@ + cosim_exec_one_begin(); + int transferred = exec_one(cpu, pc, &next_pc); /* charges its own interlock */ + g_dirty_ram_insns_run++; ++ dirty_ram_perf_hot_record(pc); + #ifdef PSX_COSIM + if (!cosim_exec_one_did_hook()) { extern void cosim_instr(uint32_t); cosim_instr(pc); } + #endif +@@ -2981,6 +3049,7 @@ + OV_FPLOG_RET1(); + } + g_dirty_ram_insns_run++; ++ dirty_ram_perf_hot_record(pc); + insns_executed++; + /* COP0 software-interrupt latency: an MTC0 to CAUSE or SR that makes a + * software interrupt deliverable (CAUSE.IP0/IP1 & SR.IM0/IM1 & IEc) +@@ -3433,6 +3502,7 @@ + uint32_t sav_unsup_pc = g_unsupported_pc; + uint32_t sav_unsup_insn = g_unsupported_insn; + const char *sav_unsup_reason = g_unsupported_reason; ++ int gte_sandbox = gte_replay_side_effects_begin(); + g_ls_replay_active = 1; + g_ls_mode = 2; + uint32_t pc = rep.pc; +@@ -3456,6 +3526,7 @@ + } + g_ls_mode = 0; + g_ls_replay_active = 0; ++ if (gte_sandbox) gte_replay_side_effects_end(); + g_unsupported_seen = sav_unsup_seen; /* undo any replay-side decode-state leak */ + g_unsupported_pc = sav_unsup_pc; + g_unsupported_insn = sav_unsup_insn; +@@ -3550,6 +3621,7 @@ + const char *sav_unsup_reason = g_unsupported_reason; + uint32_t sav_store_pc = g_debug_last_store_pc; + uint32_t sav_func_addr = g_debug_current_func_addr; ++ int gte_sandbox = gte_replay_side_effects_begin(); + + g_ls_replay_active = 1; + g_ls_mode = 2; +@@ -3585,6 +3657,7 @@ + + g_ls_mode = 0; + g_ls_replay_active = 0; ++ if (gte_sandbox) gte_replay_side_effects_end(); + g_unsupported_seen = sav_unsup_seen; + g_unsupported_pc = sav_unsup_pc; + g_unsupported_insn = sav_unsup_insn; diff --git a/framework/patches/runtime/src/gpu.c.patch b/framework/patches/runtime/src/gpu.c.patch new file mode 100644 index 0000000..6068237 --- /dev/null +++ b/framework/patches/runtime/src/gpu.c.patch @@ -0,0 +1,2076 @@ +diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c +index 6d81477285f786cdd3658d14c424d4bfbab5d809..03a9bb50a3a0acf42c4593027554279e973793c3 100644 +--- a/runtime/src/gpu.c ++++ b/runtime/src/gpu.c +@@ -11,6 +11,7 @@ + */ + + #include "gpu.h" ++#include "gte_precision.h" + #include "mod_memory.h" + #include "gpu_primitive_reject.h" + #include "gpu_sw_renderer.h" +@@ -24,7 +25,19 @@ + #include "mod_runtime.h" + #include "ws_cull_detect.h" + #include "ws_aspect_cone_math.h" ++#include "gpu_pinned_corner.h" ++#include "gpu_ws_hud_scale.h" ++#include "gpu_ws_hud_widget.h" ++#include "gpu_ws_screen_tile.h" ++#include "gpu_ws_tag_match.h" ++#include "gpu_ws_frame_kind.h" ++#include "gpu_ws_menu.h" ++#include "gpu_temporal_sprite.h" + #include "ws_ui_group.h" ++#ifdef PSX_HAS_GAME_HOST ++#include "game_host.h" ++#endif ++#include + #include + #include + #include +@@ -104,12 +117,36 @@ + * in native-wide mode. */ + static int ws_mode = 0; + static int ws_cfg_num = 4, ws_cfg_den = 3; ++static int ws_geometry_correction = 0; ++static int s_texture_correction_enabled = 0; ++static uint32_t s_texture_correction_triangles = 0; ++static double s_geometry_camera_yaw_residual = 0.0; ++static int s_geometry_camera_projection_center_y = 120; ++static uint64_t ws_geometry_world_triangles = 0; ++static uint64_t ws_geometry_precise_triangles = 0; ++static GpuGeometryCorrectionCounters s_geometry_diag_cumulative; ++static GpuGeometryCorrectionCounters s_geometry_diag_live_current; ++static GpuGeometryCorrectionCounters s_geometry_diag_live_latest; ++static uint64_t s_geometry_diag_presentation_sequence; ++static uint64_t s_geometry_diag_corrected_presentations; ++static uint64_t s_geometry_diag_latest_live_presentation; ++static int s_geometry_diag_latest_live_valid; ++static int s_geometry_diag_live_current_touched; + static void ws_nw_sync_target(void); + + #define WS_TAG_BUCKETS 4096 /* power of two */ + #define WS_TAG_PROBES 8 + #define WS_FMV_HYSTERESIS 30 /* frames a colour MDEC decode pins 4:3 */ +-typedef struct { uint32_t key; uint32_t stamp; int32_t anchor_x; } WsTag; ++typedef struct { ++ uint32_t key; ++ uint32_t stamp; ++ int32_t anchor_x; ++ uint32_t signature; ++ uint8_t content_validated; ++ uint8_t placed, sized, shadow, whole; ++ int32_t centre_x, offset_y; /* 16.16: a sprite's unrounded centre, and how far above or below its row */ ++ int32_t row, width, height; /* 16.16: the unrounded row it is centred on and its unrounded sides */ ++} WsTag; + static WsTag ws_tags[WS_TAG_BUCKETS]; + static uint32_t ws_last_tag_stamp = (uint32_t)-1000; /* frame of newest tag */ + static uint32_t ws_last_3d_stamp = (uint32_t)-1000; /* frame of newest shaded prim (diagnostic) */ +@@ -177,6 +214,8 @@ + * intro-cutscene flicker). Only a genuine full-2D screen — no GTE projection for + * this many consecutive frames (save/options/memory-card) — reverts to 4:3. */ + #define WS_GTE_GAME_MODE_HYSTERESIS 45u ++static PsxWsFrameKinds ws_frame_kinds; ++static uint32_t display_area_x, display_area_y; + void gpu_ws_set_gte_game_mode(int on) { ws_gte_game_mode_cfg = on ? 1 : 0; } + void gpu_ws_set_gameplay_state_gate(uint32_t addr, + const uint32_t *values, int nvalues) { +@@ -240,6 +279,7 @@ + + void psx_ws_note_gte_project(int nverts) { + uint32_t f = (uint32_t)s_frame_count; ++ psx_ws_frame_kinds_project(&ws_frame_kinds, (uint32_t)nverts); + if (f != ws_gte_frame) { + if (f == ws_gte_frame + 1u) ws_gte_prev_verts = ws_gte_count; + else ws_gte_prev_verts = 0; +@@ -262,14 +302,22 @@ + if (env < 0) { const char *e = getenv("PSX_WS_FORCE_2D"); env = (e && e[0] == '1') ? 1 : 0; } + return ws_full_2d || env; + } +-static int ws_game_mode(void) { ++/* `displayed` asks about the frame on display, which a double-buffered game drew one frame before the one it is ++ * drawing now. A GTE-detected game's frames are told apart per buffer, and the grace below only covers the start. */ ++static int ws_game_mode_of(int displayed) { + int state_match = ws_gameplay_state_matches(); + if (state_match >= 0) return state_match; + if (ws_full_2d_mode()) return 1; +- if (ws_gte_game_mode_cfg && +- (uint32_t)s_frame_count - ws_last_gte_stamp <= WS_GTE_GAME_MODE_HYSTERESIS) return 1; ++ if (ws_gte_game_mode_cfg) { ++ const int flat = displayed ++ ? psx_ws_frame_kinds_displayed(&ws_frame_kinds, display_area_x, display_area_y) ++ : psx_ws_frame_kinds_drawing(&ws_frame_kinds, WS_GTE_GAME_MODE_MIN_VERTS); ++ if (flat >= 0) return !flat; ++ if ((uint32_t)s_frame_count - ws_last_gte_stamp <= WS_GTE_GAME_MODE_HYSTERESIS) return 1; ++ } + return (uint32_t)s_frame_count - ws_last_tag_stamp <= 2; + } ++static int ws_game_mode(void) { return ws_game_mode_of(0); } + + /* True when the current frame is presented at native 4:3 (NOT stretched), so + * ALL squash must be suppressed and the content rendered pixel-native: +@@ -304,9 +352,19 @@ + static uint32_t s_ws_fmv_frame_cache = 0xFFFFFFFFu; + static int s_ws_fmv_cached = 0; + +-int gpu_ws_present_native_43(void) { ++/* The front end's backdrop on a wide screen (gpu_ws_menu.h). Squash mode only: a wider frame of its own has nothing to put beside the picture. */ ++static int ws_menu_logo; /* the picture at (320,0) is the front end's, and its logo is kept at (512,0) */ ++static int ws_menu_halves; /* the halves of the backdrop drawn since the logo was last drawn */ ++static int ws_menu_wanted(void) { return ws_mode == 1 && ws_xnum != ws_xden; } ++static int ws_menu_frame(void) { return psx_ws_frame_kinds_menu(&ws_frame_kinds); } ++/* On a front-end frame everything but the backdrop keeps its shape on the stretched screen. Returns the rectangle's width. */ ++static int ws_menu_rect(int32_t *x0, int w) { return ws_menu_frame() ? psx_ws_menu_squash_rect(x0, w, ws_xnum, ws_xden) : w; } ++int gpu_ws_displayed_menu(void) { return psx_ws_frame_kinds_displayed_menu(&ws_frame_kinds, display_area_x, display_area_y); } ++ ++static int ws_native_43(int displayed) { + if (!ws_engaged()) return 0; +- if (!ws_game_mode()) return 1; /* full-2D screen */ ++ if (displayed ? gpu_ws_displayed_menu() : ws_menu_frame()) return 0; ++ if (!ws_game_mode_of(displayed)) return 1; /* full-2D screen */ + if (ws_2d_only_scene()) return 1; /* 2D-only gameplay scene */ + uint32_t f = (uint32_t)s_frame_count; + if (f != s_ws_fmv_frame_cache) { +@@ -315,6 +373,14 @@ + s_ws_fmv_cached = di.depth24 || mdec_recently_active(WS_FMV_HYSTERESIS); + } + return s_ws_fmv_cached; ++} ++int gpu_ws_present_native_43(void) { return ws_native_43(0); } ++int gpu_ws_displayed_native_43(void) { return ws_native_43(1); } ++void gpu_ws_tell_frame_kind(int flat) { psx_ws_frame_kinds_tell(&ws_frame_kinds, flat); } ++/* The frame on display is a full-2D one of a GTE-detected game and not the front end's: uploads and copies make it, and they do not reach the corrected mirror. */ ++int gpu_ws_displayed_flat(void) { ++ return ws_gte_game_mode_cfg && !gpu_ws_displayed_menu() && ++ psx_ws_frame_kinds_displayed(&ws_frame_kinds, display_area_x, display_area_y) == 1; + } + + /* Squash applies only when configured AND the frame is being stretched. */ +@@ -599,6 +665,38 @@ + if (widened > ws_angle_max_widened) + ws_angle_max_widened = widened; + return widened; ++} ++ ++/* A diagnostic launcher can isolate the two discovered frustum layers without ++ * rebuilding: "off" keeps both vanilla, "yaw" widens only portal traversal, ++ * and the default "full" also widens the later horizontal side planes. */ ++static int ws_frustum_mode(void) { ++ static int mode = -1; ++ if (mode >= 0) return mode; ++ const char *value = getenv("PSX_WS_FRUSTUM_MODE"); ++ if (value && (strcmp(value, "off") == 0 || strcmp(value, "0") == 0)) ++ mode = 0; ++ else if (value && (strcmp(value, "yaw") == 0 || strcmp(value, "1") == 0)) ++ mode = 1; ++ else ++ mode = 2; ++ printf("psxrecomp: widescreen frustum mode %s\n", ++ mode == 0 ? "off" : (mode == 1 ? "yaw-only" : "full")); ++ return mode; ++} ++ ++int32_t psx_ws_yaw_cone_offset(int32_t vanilla, uint32_t full_turn) { ++ if (ws_frustum_mode() == 0) return vanilla; ++ return psx_ws_widen_angle_units( ++ vanilla, full_turn, psx_ws_x_margin()); ++} ++ ++uint32_t psx_ws_horizontal_slt(uint32_t bound, uint32_t horizontal) { ++ if (ws_frustum_mode() < 2) ++ return (int32_t)bound < (int32_t)horizontal ? 1u : 0u; ++ const int32_t scaled_horizontal = psx_ws_scale_horizontal_320( ++ (int32_t)horizontal, psx_ws_x_margin()); ++ return (int32_t)bound < scaled_horizontal ? 1u : 0u; + } + + int psx_ws_angle_site(uint32_t pc, uint32_t instr, uint32_t *out) { +@@ -1303,6 +1401,23 @@ + return ((int32_t)sx < bound - psx_ws_x_margin()) ? 1 : 0; + } + ++/* Manual CPU-projected screen-window constants. These complement the compare ++ * helpers above for games which clamp and queue signed [left,right] spans in ++ * ordinary MIPS code. All three are exact identities when margin==0. */ ++int32_t psx_ws_cull_lower_value(int32_t vanilla) { ++ return vanilla - psx_ws_x_margin(); ++} ++int32_t psx_ws_cull_upper_value(int32_t vanilla) { ++ return vanilla + psx_ws_x_margin(); ++} ++uint32_t psx_ws_cull_span_value(uint32_t vanilla) { ++ int32_t margin = psx_ws_x_margin(); ++ int32_t left = (int32_t)(int16_t)(uint16_t)(vanilla >> 16); ++ uint32_t right = vanilla & 0xFFFFu; ++ return ((uint32_t)(uint16_t)(left - margin) << 16) | ++ (uint32_t)(uint16_t)((int32_t)right + margin); ++} ++ + /* Signed left-edge widen for the funnel's `bltz maxSX, reject`: reject only + * when the prim ends left of the REVEALED edge (maxSX < -margin). Returns the + * branch predicate. Identity at margin 0 (4:3). */ +@@ -1403,6 +1518,30 @@ + if (!ws_active()) return (int16_t)x; + int32_t cx = ws_disp_w() / 2; /* screen centre (=160 @ 320) */ + return ws_scale_about((int16_t)x, cx); ++} ++ ++/* Classic-wide counterpart for engines which calculate perspective screen X ++ * in ordinary CPU code instead of the GTE. Exact recompiler sites call this ++ * immediately after adding the projection centre. Squashing here keeps the ++ * game's original non-negative 0..display-width portal spans and clip logic ++ * valid; the classic present stretch restores native proportions afterward. ++ * Native-wide and 4:3 are exact identities. Use 64-bit intermediates because ++ * a near-plane projection may be far outside the eventual screen clamp. */ ++int psx_ws_project_x(int x) { ++ if (ws_mode != 1 || !ws_configured()) return x; ++ int64_t cx = (int64_t)ws_disp_w() / 2; ++ int64_t d = (int64_t)(int32_t)x - cx; ++ int64_t n = d * (int64_t)ws_xnum; ++ n += d >= 0 ? ws_xden / 2 : -ws_xden / 2; ++ return (int32_t)(cx + n / ws_xden); ++} ++ ++/* psx_ws_project_x for a point with a 16.16 fraction, without its rounding. */ ++int32_t psx_ws_project_x16(int x, int32_t fraction16) { ++ const int64_t exact = (int64_t)x * 65536 + fraction16; ++ if (ws_mode != 1 || !ws_configured()) return (int32_t)exact; ++ const int64_t cx = ((int64_t)ws_disp_w() / 2) * 65536; ++ return (int32_t)(cx + (exact - cx) * ws_xnum / ws_xden); + } + + /* Backdrop PRELOAD predicate + value substitution ([widescreen.cull] +@@ -1616,17 +1755,10 @@ + ws_nw_sync_target(); + } + +-/* Called from generated code at the entry of each [widescreen] +- * sprite_tag_funcs function: record prim pointer ($a0) -> projected anchor. +- * Gated on ws_configured (NOT ws_active): ws_active depends on game_mode which +- * THIS function sets, so gating it on ws_active would never let gameplay +- * start (the first character of a frame would be suppressed forever). */ +-void psx_ws_sprite_tag(CPUState* cpu) { +- if (!ws_engaged() || !ws_anchor_addr) return; +- uint32_t key = cpu->gpr[4] & 0x1FFFFCu; ++static void ws_tag_primitive(uint32_t primitive_addr, int32_t anchor_x, ++ uint32_t signature, int content_validated) { ++ uint32_t key = psx_mod_gpu_dma_resolve_address(primitive_addr); + if (!key) return; +- uint32_t sxy = cpu->read_word(ws_anchor_addr); +- int32_t ax = (int32_t)(int16_t)(sxy & 0xFFFFu); + uint32_t now = (uint32_t)s_frame_count; + uint32_t idx = (key >> 2) & (WS_TAG_BUCKETS - 1); + uint32_t victim = idx; +@@ -1638,8 +1770,169 @@ + } + ws_tags[victim].key = key; + ws_tags[victim].stamp = now; +- ws_tags[victim].anchor_x = ax; ++ ws_tags[victim].anchor_x = anchor_x; ++ ws_tags[victim].signature = signature; ++ ws_tags[victim].content_validated = content_validated != 0; + ws_last_tag_stamp = now; ++} ++ ++/* Explicit provenance seam for a title-specific, reviewed world-billboard ++ * builder. Unlike sprite_tag_funcs this does not depend on a fixed scratchpad ++ * anchor: the caller has already identified both the completed P_TAG packet and ++ * its projected centre. Restrict it to the classic squash path so a tag cannot ++ * affect native-wide backdrop classification or a native-4:3 presentation. */ ++void gpu_ws_tag_primitive(CPUState *cpu, uint32_t primitive_addr, ++ int32_t anchor_x) { ++ if (!ws_active() || !cpu || !cpu->read_word) return; ++ uint32_t words[9]; ++ for (int i = 0; i < 9; i++) ++ words[i] = cpu->read_word(primitive_addr + 4u + (uint32_t)i * 4u); ++ ws_tag_primitive(primitive_addr, anchor_x, ++ psx_ws_ft4_signature_words(words), 1); ++} ++ ++#define TEMPORAL_SPRITE_BUCKETS 4096 ++static WsTag temporal_sprites[TEMPORAL_SPRITE_BUCKETS]; ++ ++static WsTag *temporal_sprite_slot(uint32_t key, int claim) { ++ const uint32_t now = (uint32_t)s_frame_count; ++ const uint32_t first = (key >> 2) & (TEMPORAL_SPRITE_BUCKETS - 1); ++ WsTag *stale = NULL; ++ for (int i = 0; i < WS_TAG_PROBES; i++) { ++ WsTag *t = &temporal_sprites[(first + (uint32_t)i) & (TEMPORAL_SPRITE_BUCKETS - 1)]; ++ if (t->key == key) return t; ++ if (!stale && (t->key == 0 || now - t->stamp > 2)) stale = t; ++ } ++ return claim ? stale : NULL; ++} ++ ++void gpu_temporal_note_sprite(CPUState *cpu, uint32_t primitive_addr, ++ int32_t depth) { ++ if (!cpu || !cpu->read_word || depth <= 0) return; ++ const uint32_t key = psx_mod_gpu_dma_resolve_address(primitive_addr); ++ WsTag *t = key ? temporal_sprite_slot(key, 1) : NULL; ++ uint32_t words[9]; ++ if (!t) return; ++ for (int i = 0; i < 9; i++) ++ words[i] = cpu->read_word(primitive_addr + 4u + (uint32_t)i * 4u); ++ t->key = key; ++ t->stamp = (uint32_t)s_frame_count; ++ t->anchor_x = depth; ++ t->signature = psx_ws_ft4_signature_words(words); ++ t->content_validated = 1; ++ t->placed = t->sized = t->shadow = t->whole = 0; ++} ++ ++void gpu_temporal_place_sprite(uint32_t primitive_addr, int32_t centre_x16, int32_t offset_y16) { ++ const uint32_t key = psx_mod_gpu_dma_resolve_address(primitive_addr); ++ WsTag *t = key ? temporal_sprite_slot(key, 0) : NULL; ++ if (!t || t->stamp != (uint32_t)s_frame_count) return; ++ t->placed = 1; ++ t->centre_x = centre_x16; ++ t->offset_y = offset_y16; ++} ++ ++void gpu_temporal_size_sprite(uint32_t primitive_addr, int32_t row16, int32_t width16, int32_t height16, ++ int shadow, int whole) { ++ const uint32_t key = psx_mod_gpu_dma_resolve_address(primitive_addr); ++ WsTag *t = key ? temporal_sprite_slot(key, 0) : NULL; ++ if (!t || t->stamp != (uint32_t)s_frame_count || !t->placed || width16 <= 0 || height16 <= 0) return; ++ t->sized = 1; ++ t->shadow = shadow != 0; ++ t->whole = whole != 0; ++ t->row = row16; ++ t->width = width16; ++ t->height = height16; ++} ++ ++static int s_shadow_shape; ++void gpu_set_shadow_shape(int improved) { s_shadow_shape = improved != 0; } ++int gpu_shadow_shape(void) { return s_shadow_shape; } ++int gpu_sprite_placement_available(void) { ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++ return 1; ++#else ++ return 0; ++#endif ++} ++ ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++static const WsTag *temporal_sprite_tag(void) { ++ if (gp0_cmd_source_addr == 0xFFFFFFFFu) return NULL; ++ const uint32_t key = psx_mod_gpu_dma_resolve_address(gp0_cmd_source_addr - 4u); ++ const WsTag *t = key ? temporal_sprite_slot(key, 0) : NULL; ++ if (!t || psx_ws_tag_match_result((uint32_t)s_frame_count, t->stamp, 1, ++ t->signature, gp0_cmd_buf) != PSX_WS_TAG_MATCH) ++ return NULL; ++ return t; ++} ++ ++/* How far the presentation moves the left, top, right and bottom side of a sprite's rectangle (x, y, w, h on screen, ++ * raw_w and raw_h as the packet has them). Returns the sprite's camera depth, 0 without one. */ ++static float temporal_sprite_sides(int x, int y, int w, int h, int raw_w, int raw_h, int squashed, float sides[4]) { ++ const WsTag *t = temporal_sprite_tag(); ++ sides[0] = sides[1] = sides[2] = sides[3] = 0.0f; ++ if (!t) return 0.0f; ++ const GpuTemporalSprite sprite = gpu_temporal_sprite_from_fixed( ++ t->placed, t->sized, t->shadow, t->whole, t->centre_x, t->offset_y, t->row, t->width, t->height); ++ gpu_temporal_sprite_sides(&sprite, (float)x, (float)y, w, h, raw_w, raw_h, ++ squashed ? (float)ws_xnum / (float)ws_xden : 1.0f, gpu_shadow_shape(), sides); ++ return (float)t->anchor_x; ++} ++#endif ++ ++/* Backdrop tiles tile the frame exactly, so squashing each one around a HUD ++ * pivot pulls them apart and exposes the clear colour between them. */ ++static PsxWsScreenTiles ws_screen_tiles; ++ ++void gpu_ws_tag_screen_tile(CPUState *cpu, uint32_t primitive_addr) { ++ if (!ws_active() || !cpu || !cpu->read_word) return; ++ uint32_t words[PSX_WS_SCREEN_TILE_WORDS]; ++ for (uint32_t i = 0; i < PSX_WS_SCREEN_TILE_WORDS; i++) ++ words[i] = cpu->read_word(primitive_addr + 4u + i * 4u); ++ psx_ws_screen_tile_mark(&ws_screen_tiles, ++ psx_mod_gpu_dma_resolve_address(primitive_addr), ++ words, (uint32_t)s_frame_count); ++} ++ ++int32_t gpu_ws_widen_x(int32_t x, int round_up) { ++ if (!ws_active()) return x; ++ return psx_ws_widen_about(x, ws_disp_w() / 2, ws_xnum, ws_xden, round_up); ++} ++ ++/* A two-frame window was tried instead of one-shot marks and lost them on ++ * 4-VBlank frames. */ ++static int ws_screen_tile_take(void) { ++ if (gp0_cmd_source_addr == 0xFFFFFFFFu) return 0; ++ return psx_ws_screen_tile_take( ++ &ws_screen_tiles, ++ psx_mod_gpu_dma_resolve_address(gp0_cmd_source_addr - 4u), ++ gp0_cmd_buf, (uint32_t)s_frame_count); ++} ++ ++/* Called from generated code at the entry of each [widescreen] ++ * sprite_tag_funcs function: record prim pointer ($a0) -> projected anchor. ++ * Gated on ws_engaged (NOT ws_active): ws_active can depend on game_mode which ++ * this legacy tag path establishes for sprite-classified games. */ ++void psx_ws_sprite_tag(CPUState* cpu) { ++ if (!ws_engaged() || !ws_anchor_addr) return; ++ uint32_t sxy = cpu->read_word(ws_anchor_addr); ++ int32_t ax = (int32_t)(int16_t)(sxy & 0xFFFFu); ++ ws_tag_primitive(cpu->gpr[4], ax, 0, 0); ++} ++ ++static int ws_tag_matches_current(WsTag *tag, uint32_t now) { ++ const int result = psx_ws_tag_match_result( ++ now, tag->stamp, tag->content_validated, ++ tag->signature, gp0_cmd_buf); ++ if (result == PSX_WS_TAG_MATCH) return 1; ++ if (result != PSX_WS_TAG_CONTENT_MISMATCH) return 0; ++ ++ /* The packet pool reused this address. Invalidate immediately instead of ++ * letting a stale world-billboard tag flicker onto an unrelated primitive. */ ++ tag->key = 0; ++ tag->content_validated = 0; ++ return 0; + } + + /* Look up the executing GP0 command's prim in the tag table. The command's +@@ -1649,11 +1942,12 @@ + if (!ws_active() || gp0_cmd_source_addr == 0xFFFFFFFFu) return 0; + uint32_t now = (uint32_t)s_frame_count; + for (int variant = 0; variant < 2; variant++) { +- uint32_t key = (gp0_cmd_source_addr - (variant ? 0u : 4u)) & 0x1FFFFCu; ++ uint32_t key = psx_mod_gpu_dma_resolve_address( ++ gp0_cmd_source_addr - (variant ? 0u : 4u)); + uint32_t idx = (key >> 2) & (WS_TAG_BUCKETS - 1); + for (int i = 0; i < WS_TAG_PROBES; i++) { + WsTag *t = &ws_tags[(idx + i) & (WS_TAG_BUCKETS - 1)]; +- if (t->key == key && now - t->stamp <= 2) { ++ if (t->key == key && ws_tag_matches_current(t, now)) { + *out_ax = t->anchor_x; + return 1; + } +@@ -1786,11 +2080,12 @@ + if (!ws_engaged() || gp0_cmd_source_addr == 0xFFFFFFFFu) return 0; + uint32_t now = (uint32_t)s_frame_count; + for (int variant = 0; variant < 2; variant++) { +- uint32_t key = (gp0_cmd_source_addr - (variant ? 0u : 4u)) & 0x1FFFFCu; ++ uint32_t key = psx_mod_gpu_dma_resolve_address( ++ gp0_cmd_source_addr - (variant ? 0u : 4u)); + uint32_t idx = (key >> 2) & (WS_TAG_BUCKETS - 1); + for (int i = 0; i < WS_TAG_PROBES; i++) { + WsTag *t = &ws_tags[(idx + i) & (WS_TAG_BUCKETS - 1)]; +- if (t->key == key && now - t->stamp <= 2) return 1; ++ if (t->key == key && ws_tag_matches_current(t, now)) return 1; + } + } + return 0; +@@ -1820,6 +2115,12 @@ + GpuDisplayInfo di; + gpu_get_display_info(&di); + return di.height ? (int32_t)di.height : 240; ++} ++ ++/* An upload or a copy into a frame buffer has no drawing area to announce it. */ ++static void ws_frame_pictured(int x, int y, int w, int h) { ++ psx_ws_frame_kinds_pictured(&ws_frame_kinds, (uint32_t)x, (uint32_t)y, (uint32_t)w, (uint32_t)h, ++ (uint32_t)ws_disp_w(), (uint32_t)ws_disp_h()); + } + + /* Full-screen fades and environmental filters are authored as 320x240 TILEs. +@@ -1842,9 +2143,10 @@ + * the middle third to centre. A composite (counter box + digits) sits inside + * one zone, so its pieces share a pivot and stay aligned. (Full-2D menu + * screens never reach here — they get zero squash + 4:3 pillarbox instead.) */ ++static int32_t ws_hud_strip_wide(void); + static int32_t ws_hud_pivot(int32_t x, int32_t w) { + int32_t W = ws_disp_w(); +- int32_t cx = 2 * x + w; /* 2*centre, avoids losing the half */ ++ int32_t cx = psx_ws_hud_thirds_centre(x, w, ws_hud_strip_wide(), W); /* 2*centre, avoids losing the half */ + if (3 * cx < 2 * W) return 0; + if (3 * cx > 4 * W) return W; + return W / 2; +@@ -1861,7 +2163,7 @@ + if (!ws_auto_ui_squash || !ws_active() || + gp0_cmd_source_addr == 0xFFFFFFFFu) + return 0; +- uint32_t src = gp0_cmd_source_addr & 0x1FFFFCu; ++ uint32_t src = psx_mod_gpu_dma_resolve_address(gp0_cmd_source_addr); + for (uint32_t i = 0; i < ws_ui_prepass_count; i++) { + if (ws_ui_prepass[i].src_addr != src) continue; + if (out_anchor) *out_anchor = ws_ui_prepass[i].group.anchor; +@@ -1946,20 +2248,102 @@ + return 1; + } + ++static int ws_hud_scale_percent = PSX_WS_HUD_SCALE_MAX; ++void gpu_ws_set_hud_scale(int percent) { ++ ws_hud_scale_percent = psx_ws_hud_scale_clamp(percent); ++} ++int gpu_ws_hud_scale(void) { return ws_hud_scale_percent; } ++ ++/* The user's HUD size for an untagged screen-space SPRT. 0 leaves the ++ * stock horizontal squash to the caller. */ ++static int ws_hud_user_scale(int32_t *x, int32_t *y, int *w, int *h) { ++ int32_t scaled_w = *w, scaled_h = *h; ++ if (ws_hud_scale_percent >= PSX_WS_HUD_SCALE_MAX || ++ !psx_ws_hud_scale_rect_strip(x, y, &scaled_w, &scaled_h, ws_disp_w(), ws_disp_h(), ++ ws_xnum, ws_xden, ws_hud_scale_percent, ws_hud_strip_wide())) ++ return 0; ++ *w = (int)scaled_w; ++ *h = (int)scaled_h; ++ return 1; ++} ++ ++static PsxWsHudWidgets ws_hud; ++static uint32_t ws_hud_table_pointer; ++static int ws_hud_strip; /* how wide the game's text printer makes a line, 0 when the game has not said */ ++ ++void gpu_ws_set_hud_text_strip(int wide) { ws_hud_strip = wide > 0 ? wide : 0; } ++ ++/* A line of text is printed as a list of its own, outside the HUD layer: only there is a rectangle of that width a line. */ ++static int32_t ws_hud_strip_wide(void) { return ws_hud.in_hud_layer ? 0 : ws_hud_strip; } ++ ++void gpu_ws_set_hud_widgets(uint32_t table_pointer, int layers, const int32_t *boxes, int count) { ++ ws_hud_table_pointer = table_pointer; ++ memset(&ws_hud, 0, sizeof(ws_hud)); ++ ws_hud.layers = layers; ++ for (int i = 0; boxes && i < count && i < PSX_WS_HUD_WIDGET_MAX; ++i) { ++ const int32_t *box = boxes + 6 * i; ++ const PsxWsHudWidget widget = { box[0], box[1], box[2], box[3], box[4], box[5] }; ++ ws_hud.widgets[ws_hud.count++] = widget; ++ } ++} ++ ++/* The widget of an untagged widescreen primitive with these bounds. See psx_ws_hud_widgets_take. */ ++static const PsxWsHudWidget *ws_hud_widget(int32_t x0, int32_t y0, int32_t x1, int32_t y1, int rectangle) { ++ if (!ws_active() || psx_ws_prim_is_tagged()) return NULL; ++ return psx_ws_hud_widgets_take(&ws_hud, x0, y0, x1, y1, rectangle); ++} ++ ++/* A widget's rectangle in place. 0 for a rectangle of no widget. */ ++static int ws_hud_widget_rect(int32_t *x, int32_t *y, int32_t *w, int32_t *h) { ++ const PsxWsHudWidget *widget = ws_hud_widget(*x, *y, *x + *w, *y + *h, 1); ++ if (!widget) return 0; ++ psx_ws_hud_widget_rect(widget, x, y, w, h, ws_xnum, ws_xden, ws_hud_scale_percent); ++ return 1; ++} ++ ++/* A widget's axis-aligned quad, every corner through the same mapping. A quad joins in a HUD layer only. */ ++static void ws_hud_widget_quad(int32_t vx[4], int32_t vy[4]) { ++ int32_t min_x = vx[0], max_x = vx[0], min_y = vy[0], max_y = vy[0]; ++ if (!ws_hud.in_hud_layer || !ws_axis_aligned_quad(vx, vy)) return; ++ for (int i = 1; i < 4; i++) { ++ if (vx[i] < min_x) min_x = vx[i]; ++ if (vx[i] > max_x) max_x = vx[i]; ++ if (vy[i] < min_y) min_y = vy[i]; ++ if (vy[i] > max_y) max_y = vy[i]; ++ } ++ const PsxWsHudWidget *widget = ws_hud_widget(min_x, min_y, max_x, max_y, 0); ++ if (!widget) return; ++ for (int i = 0; i < 4; i++) ++ psx_ws_hud_widget_corner(widget, &vx[i], &vy[i], ws_xnum, ws_xden, ws_hud_scale_percent); ++} ++ + /* Shared transform for fixed-size textured sprites (8x8 / 16x16 / 1x1 dot): + * squash *x0 in place (around the tagged anchor, else the HUD pivot) and +- * return the squashed draw width, or 0 = no change. */ +-static int ws_sprt_fixed_transform(int32_t *x0, int32_t y0, int w) { ++ * return the squashed draw width, or 0 = no change. *out_h is the draw ++ * height when the user's HUD size changed it, else 0. */ ++static int ws_sprt_fixed_transform(int32_t *x0, int32_t *y0, int w, int *out_h) { ++ *out_h = 0; ++ if (ws_menu_frame()) return ws_menu_rect(x0, w); + if (!ws_active()) return 0; + int32_t ax; + if (ws_tagged_anchor(&ax)) { + *x0 = ws_scale_about(*x0, ax); + return (int)ws_scale_len(w); + } ++ int32_t widget_w = w, widget_h = w; ++ if (ws_hud_widget_rect(x0, y0, &widget_w, &widget_h)) { ++ *out_h = (int)widget_h; ++ return (int)widget_w; ++ } + int auto_w = w; +- if (ws_auto_ui_transform_rect(x0, y0, &auto_w, w)) ++ if (ws_auto_ui_transform_rect(x0, *y0, &auto_w, w)) + return auto_w; + if (ws_hud_sprt) { ++ int scaled_w = w, scaled_h = w; ++ if (ws_hud_user_scale(x0, y0, &scaled_w, &scaled_h)) { ++ *out_h = scaled_h; ++ return scaled_w; ++ } + *x0 = ws_scale_about(*x0, ws_hud_pivot(*x0, w)); + return (int)ws_scale_len(w); + } +@@ -2024,6 +2408,10 @@ + * the game's dedicated HUD packet arena reaches here, never world polygons. */ + static void ws_nw_hud_shift_vertices(int32_t *vx, int count) { + if (count <= 0) return; ++ if (ws_menu_frame()) { /* the front end's polygons and lines keep their shape like its text */ ++ psx_ws_menu_squash_corners(vx, count, ws_xnum, ws_xden); ++ return; ++ } + /* Sprite-tag titles: polygon/line prims are the GTE world and the tagged + * character billboards, never HUD — only the rect-family sites (which + * call ws_nw_hud_shift directly, with the untagged filter) re-anchor. */ +@@ -2103,6 +2491,7 @@ + * CPU-visible transfer remains ordered because GP0 accepts no next command + * until this payload is complete. Maximum PS1 transfer = full VRAM (1 MiB). */ + static uint16_t vram_write_pixels[1024 * 512]; ++static uint16_t ws_menu_clut_x = 0, ws_menu_clut_y = 480; /* the backdrop's palette, as its rectangles last named it */ + + /* Depth24 CPU→VRAM upload span (halfwords, exclusive end). See + * gpu_depth24_rgb_limit — declared early so gpu_reset_state can clear it. */ +@@ -2110,6 +2499,58 @@ + static int s_d24_present_hold = 0; /* vblanks to skip Swap after GP1(07h) */ + static uint32_t s_d24_prev_disp_h = 0; /* last GP1(07h) band height */ + static void depth24_note_upload(uint32_t x, uint32_t w); ++ ++/* The wide front end's logo and paint are in the renderer's video memory alone, the one the game reads and a save state holds. */ ++static uint16_t ws_menu_kept[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_LOGO_WIDE], ws_menu_under[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_LOGO_WIDE]; ++static uint16_t ws_menu_painted[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_WORDS], ws_menu_uploaded[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_WORDS]; ++static int ws_menu_fresh; /* the CPU copy at (320,0) is a whole picture with the logo, and no state was loaded since it came */ ++static int ws_menu_lent; /* the renderer holds the kept logo at (512,0) */ ++static int ws_menu_rows; /* and the painted rows in the picture */ ++static int ws_menu_due; /* the frame has drawn nothing yet: its first primitive says whether it is the front end's */ ++ ++static const uint16_t *ws_menu_palette(void) { return &vram[ws_menu_clut_y * 1024u + ws_menu_clut_x]; } ++ ++/* Gives a rectangle of the renderer's back word by word: what is still as it was put there becomes what it was before. */ ++static void ws_menu_return(int x, int y, int w, int h, const uint16_t *put, const uint16_t *before) { ++ static uint16_t now[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_LOGO_WIDE]; ++ gr_vram_transfer_out(x, y, w, h, now); ++ if (psx_ws_menu_return(now, w, w, h, put, before)) gr_vram_transfer_in(x, y, w, h, now); ++} ++ ++/* The front end is left, or the game is about to read or write there: it finds its own words. */ ++static void ws_menu_give_back(void) { ++ if (ws_menu_lent) ws_menu_return(PSX_WS_MENU_KEPT_X, 0, PSX_WS_MENU_LOGO_WIDE, PSX_WS_MENU_LOGO_ROWS, ws_menu_kept, ws_menu_under); ++ if (ws_menu_rows) ws_menu_return(PSX_WS_MENU_X, PSX_WS_MENU_LOGO_TOP, PSX_WS_MENU_WORDS, PSX_WS_MENU_LOGO_ROWS, ws_menu_painted, ws_menu_uploaded); ++ ws_menu_lent = ws_menu_rows = ws_menu_logo = 0; ++} ++ ++/* The game is about to read or write this rectangle of video memory, in words. What it writes there the CPU copy does not have. */ ++static void ws_menu_yield(int x, int y, int w, int h, int written) { ++ if (!psx_ws_menu_reaches(x, y, w, h)) return; ++ ws_menu_give_back(); ++ if (written) ws_menu_fresh = 0; ++} ++ ++/* The front end's picture is at (320,0): its logo goes to (512,0) and is painted over in the picture, both in the renderer's video memory. */ ++static void ws_menu_take_picture(void) { ++ static uint8_t mask[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_LOGO_WIDE]; ++ static uint16_t picture[PSX_WS_MENU_HIGH * PSX_WS_MENU_WORDS]; ++ const uint16_t *palette = ws_menu_palette(); ++ if (!ws_menu_fresh || !ws_menu_wanted()) { ++ ws_menu_give_back(); ++ return; ++ } ++ if (!ws_menu_lent) gr_vram_transfer_out(PSX_WS_MENU_KEPT_X, 0, PSX_WS_MENU_LOGO_WIDE, PSX_WS_MENU_LOGO_ROWS, ws_menu_under); ++ for (int row = 0; row < PSX_WS_MENU_HIGH; ++row) memcpy(&picture[row * PSX_WS_MENU_WORDS], &vram[row * 1024 + PSX_WS_MENU_X], PSX_WS_MENU_WORDS * sizeof(uint16_t)); ++ memcpy(ws_menu_uploaded, &picture[PSX_WS_MENU_LOGO_TOP * PSX_WS_MENU_WORDS], sizeof(ws_menu_uploaded)); ++ psx_ws_menu_logo_mask(picture, PSX_WS_MENU_WORDS, palette, mask); ++ psx_ws_menu_keep_logo(picture, PSX_WS_MENU_WORDS, palette, mask, ws_menu_kept); ++ gr_vram_transfer_in(PSX_WS_MENU_KEPT_X, 0, PSX_WS_MENU_LOGO_WIDE, PSX_WS_MENU_LOGO_ROWS, ws_menu_kept); ++ psx_ws_menu_paint(picture, PSX_WS_MENU_WORDS, mask); ++ memcpy(ws_menu_painted, &picture[PSX_WS_MENU_LOGO_TOP * PSX_WS_MENU_WORDS], sizeof(ws_menu_painted)); ++ gr_vram_transfer_in(PSX_WS_MENU_X, PSX_WS_MENU_LOGO_TOP, PSX_WS_MENU_WORDS, PSX_WS_MENU_LOGO_ROWS, ws_menu_painted); ++ ws_menu_logo = ws_menu_lent = ws_menu_rows = 1; ++} + + static void gp0_commit_cpu_to_vram(void) { + for (uint32_t row = 0; row < vram_write_h; row++) +@@ -2120,6 +2561,11 @@ + gr_vram_transfer_in(vram_write_x, vram_write_y, + vram_write_w, vram_write_h, vram_write_pixels); + depth24_note_upload(vram_write_x, vram_write_w); ++ if (psx_ws_menu_is_picture(vram_write_x, vram_write_y, vram_write_w, vram_write_h)) { ++ ws_menu_rows = 0; /* the renderer's rows are the game's again */ ++ ws_menu_fresh = psx_ws_menu_has_logo(&vram[PSX_WS_MENU_X], 1024, ws_menu_palette()); ++ ws_menu_take_picture(); ++ } + gp0_state = GP0_IDLE; + vram_write_remaining = 0; + text_xlate_vram_upload(vram_write_x, vram_write_y, +@@ -2221,11 +2667,186 @@ + * when native-wide is active and that buffer is a known display buffer; else + * disable mirroring for this draw. Called when the draw env changes. */ + static void ws_nw_sync_target(void) { +- if (!ws_native_wide_active()) { gr_wide_disable_target(); return; } +- gr_wide_configure((int)ws_disp_w() + ws_nw_extra(), ws_nw_offset()); ++ const int native_wide = ws_native_wide_active(); ++ /* The corrected presentation mirror is also required at authentic 4:3. ++ * main.cpp's game-entry gate deliberately considers that mode engaged even ++ * though this file's ws_engaged() means only "a non-identity wide mode". ++ * Gating visual-only here on ws_mode therefore left 4:3 correction without ++ * a surface and sent OpenGL into its CPU fallback at the wrong SSAA scale. */ ++ const int visual_only = ws_geometry_correction; ++ if (!native_wide && !visual_only) { ++ gr_wide_disable_target(); ++ return; ++ } ++ /* Geometry correction deliberately reuses the renderer's independent wide ++ * surface even in classic squash mode. With zero side offset it becomes a ++ * same-width presentation mirror: canonical VRAM remains integer-exact, ++ * while the mirror may consume subpixel vertices and fractional yaw. */ ++ gr_wide_configure(native_wide ? (int)ws_disp_w() + ws_nw_extra() ++ : (int)ws_disp_w(), ++ native_wide ? ws_nw_offset() : 0); + uint32_t base = draw_area_left; + if (ws_is_fb_base(base)) gr_wide_set_target((int)base); + else gr_wide_disable_target(); ++} ++ ++extern void gte_geometry_correction_set(int enabled); ++extern void gte_precision_tracking_set(int enabled); ++ ++static void geometry_diag_reset(void) { ++ memset(&s_geometry_diag_cumulative, 0, ++ sizeof(s_geometry_diag_cumulative)); ++ memset(&s_geometry_diag_live_current, 0, ++ sizeof(s_geometry_diag_live_current)); ++ memset(&s_geometry_diag_live_latest, 0, ++ sizeof(s_geometry_diag_live_latest)); ++ s_geometry_diag_presentation_sequence = 0; ++ s_geometry_diag_corrected_presentations = 0; ++ s_geometry_diag_latest_live_presentation = 0; ++ s_geometry_diag_latest_live_valid = 0; ++ s_geometry_diag_live_current_touched = 0; ++ gte_precision_diagnostics_reset(); ++} ++ ++static int geometry_diag_display_active(void) { ++ GpuDisplayInfo display; ++ gpu_get_display_info(&display); ++ return !display.disabled && display.width != 0u && display.height != 0u; ++} ++ ++/* Match main.cpp's corrected-present eligibility using state available at GP0 ++ * decode time. A known framebuffer target is additionally required because an ++ * offscreen texture draw cannot reach the independent presentation surface. */ ++static int geometry_diag_presentation_live(void) { ++ return geometry_diag_display_active() && ++ ws_geometry_correction && ++ !gpu_ws_present_native_43() && !(display_depth & 1u) && ++ gr_wide_supported() && ws_is_fb_base(draw_area_left); ++} ++ ++static void geometry_diag_finish_presentation(void) { ++ const int live = geometry_diag_display_active() && ++ (s_geometry_diag_live_current_touched || ++ geometry_diag_presentation_live()); ++ ++s_geometry_diag_presentation_sequence; ++ if (live) { ++ s_geometry_diag_live_latest = s_geometry_diag_live_current; ++ ++s_geometry_diag_corrected_presentations; ++ s_geometry_diag_latest_live_presentation = ++ s_geometry_diag_presentation_sequence; ++ s_geometry_diag_latest_live_valid = 1; ++ } ++ memset(&s_geometry_diag_live_current, 0, ++ sizeof(s_geometry_diag_live_current)); ++ s_geometry_diag_live_current_touched = 0; ++} ++ ++void gpu_geometry_correction_set(int enabled) { ++ ws_geometry_correction = enabled ? 1 : 0; ++ ws_geometry_world_triangles = 0; ++ ws_geometry_precise_triangles = 0; ++ geometry_diag_reset(); ++ gte_geometry_correction_set(ws_geometry_correction); ++ /* Exact committed packet-address provenance is preferred over the fallback ++ * packed-SXY cache whenever the game stores GTE results into its OT. */ ++ gte_precision_tracking_set(ws_geometry_correction || ++ s_texture_correction_enabled); ++ gr_set_geometry_correction(ws_geometry_correction); ++ gpu_geometry_camera_yaw_residual_set( ++ s_geometry_camera_yaw_residual); ++ ws_nw_sync_target(); ++} ++ ++void gpu_geometry_correction_stats(uint64_t *world_triangles, ++ uint64_t *precise_triangles) { ++ if (world_triangles) *world_triangles = ws_geometry_world_triangles; ++ if (precise_triangles) *precise_triangles = ws_geometry_precise_triangles; ++} ++ ++void gpu_geometry_correction_stats_detailed( ++ GpuGeometryCorrectionStats *out, uint32_t out_size) { ++ if (!out || out_size == 0u) return; ++ GtePrecisionDiagnostics provenance = { 0 }; ++ GpuGeometryCorrectionStats snapshot; ++ gte_precision_diagnostics(&provenance); ++ memset(&snapshot, 0, sizeof(snapshot)); ++ snapshot.version = GPU_GEOMETRY_DIAGNOSTICS_VERSION; ++ snapshot.size = (uint32_t)sizeof(snapshot); ++ snapshot.presentation_sequence = s_geometry_diag_presentation_sequence; ++ snapshot.corrected_presentations = s_geometry_diag_corrected_presentations; ++ snapshot.latest_live_presentation = s_geometry_diag_latest_live_presentation; ++ snapshot.latest_live_valid = (uint32_t)s_geometry_diag_latest_live_valid; ++ snapshot.provenance_store_collisions = provenance.store_collisions; ++ snapshot.provenance_store_evictions = provenance.store_evictions; ++ snapshot.provenance_lookup_collisions = provenance.lookup_collisions; ++ snapshot.cumulative = s_geometry_diag_cumulative; ++ snapshot.latest_live = s_geometry_diag_live_latest; ++ snapshot.provenance_store_uncommitted_rejections = ++ provenance.store_uncommitted_rejections; ++ snapshot.provenance_registered_store_attempts = ++ provenance.registered_store_attempts; ++ snapshot.provenance_registered_store_accepts = ++ provenance.registered_store_accepts; ++ snapshot.provenance_registered_store_packed_rejections = ++ provenance.registered_store_packed_rejections; ++ snapshot.provenance_copy_load_attempts = ++ provenance.copy_load_attempts; ++ snapshot.provenance_copy_load_accepts = ++ provenance.copy_load_accepts; ++ snapshot.provenance_copy_store_attempts = ++ provenance.copy_store_attempts; ++ snapshot.provenance_copy_store_accepts = ++ provenance.copy_store_accepts; ++ snapshot.provenance_copy_store_packed_rejections = ++ provenance.copy_store_packed_rejections; ++ { ++ const uint32_t copy_size = out_size < (uint32_t)sizeof(snapshot) ++ ? out_size : (uint32_t)sizeof(snapshot); ++ memcpy(out, &snapshot, copy_size); ++ } ++} ++ ++int gpu_geometry_correction_enabled(void) { ++ return ws_geometry_correction; ++} ++ ++static void geometry_camera_presentation_update(void) { ++ double center = (double)ws_disp_w() * 0.5; ++ /* Keep Y relative to the drawing band. The GL backend adds its current ++ * GP0(E5) draw offset after flushing the prior batch, so a framebuffer ++ * switch after VBlank cannot leave the next frame on the old horizon. */ ++ const double center_y = ++ (double)s_geometry_camera_projection_center_y; ++ double focal = center; ++ if (ws_mode == 1 && ws_xden != 0) ++ focal = focal * (double)ws_xnum / (double)ws_xden; ++ gr_set_presentation_yaw(ws_geometry_correction && ++ isfinite(s_geometry_camera_yaw_residual) ++ ? s_geometry_camera_yaw_residual : 0.0, ++ 256.0, center, center_y, focal); ++} ++ ++void gpu_geometry_camera_yaw_residual_set(double yaw_units) { ++ s_geometry_camera_yaw_residual = yaw_units; ++ geometry_camera_presentation_update(); ++} ++ ++void gpu_geometry_camera_projection_center_y_set(double center_y) { ++ int next = 120; ++ if (isfinite(center_y) && center_y >= 24.0 && center_y <= 216.0) ++ next = (int)floor(center_y + 0.5); ++ if (next == s_geometry_camera_projection_center_y) return; ++ ++ s_geometry_camera_projection_center_y = next; ++ /* Reissuing the current yaw lands renderer batches under the old horizon ++ * before the backend adopts the new one. This call is also intentional ++ * while correction is disabled: it stores the requested shared center but ++ * supplies an identity yaw to the presentation backend. */ ++ geometry_camera_presentation_update(); ++} ++ ++int gpu_geometry_camera_projection_center_y_get(void) { ++ return s_geometry_camera_projection_center_y; + } + + static void ws_clear_all_reveal_margins(void) { +@@ -2239,18 +2860,23 @@ + * stage art. A stale reveal tile is safer than deleting submitted content. */ + void gpu_ws_begin_linked_list(void) { + gp0_ot_rank = 0xFFFFu; ++ psx_ws_hud_widgets_list(&ws_hud, ws_hud.count && ws_active() ? psx_read_word(ws_hud_table_pointer) : 0u); + } + + void gpu_set_gp0_linked_list_node(uint32_t addr, uint32_t word_count) { + (void)addr; ++ psx_ws_frame_kinds_list(&ws_frame_kinds, 1); + if (word_count == 0) { + gp0_ot_rank = gp0_ot_rank == 0xFFFFu ? 0u + : (uint16_t)(gp0_ot_rank + 1u); ++ psx_ws_hud_widgets_node(&ws_hud, addr); + } + } + + void gpu_ws_end_linked_list(void) { ++ psx_ws_frame_kinds_list(&ws_frame_kinds, 0); + gp0_ot_rank = 0xFFFFu; ++ psx_ws_hud_widgets_list_end(&ws_hud); + } + + +@@ -2362,6 +2988,16 @@ + s_d24_upload_x1 = 0; + s_d24_present_hold = 0; + s_d24_prev_disp_h = 0; ++ ++ /* gr_init can retain backend-side draw state across GP1(00h). Keep the ++ * restored canonical clip and the framebuffer-relative yaw horizon in ++ * lockstep with the power-on GP0 defaults. */ ++ gr_set_draw_area((int)draw_area_left, (int)draw_area_top, ++ (int)draw_area_right, (int)draw_area_bottom); ++ gr_set_draw_offset(draw_offset_x, draw_offset_y); ++ s_geometry_camera_yaw_residual = 0.0; ++ s_geometry_camera_projection_center_y = 120; ++ geometry_camera_presentation_update(); + } + + void gpu_init(void) { +@@ -2538,6 +3174,9 @@ + } + g_doff_min_this = 0x7fffffff; g_doff_max_this = -0x7fffffff; g_doff_cnt_this = 0; + gpustat_poll_count = 0; ++ /* Latch the just-finished frame before the mouse/mod hook samples the ++ * diagnostics below. */ ++ geometry_diag_finish_presentation(); + /* Trusted package-selected plugins run on guest VBlank, independent of + * host presentation, pacing, turbo, or skipped frames. */ + mod_runtime_on_vblank(); +@@ -2757,73 +3396,522 @@ + *y = sign_extend((word >> 16) & 0x7FFu, 11); + } + +-extern int gte_geometry_correction_lookup(uint32_t packed, +- int32_t *x16, int32_t *y16); + extern int gte_geometry_correction_enabled(void); +-static int s_texture_correction_enabled = 0; + extern void gte_precision_tracking_set(int enabled); +-extern int gte_precision_load_word(uint32_t addr, uint32_t packed, +- int32_t *x16, int32_t *y16, uint16_t *z); + + void gpu_texture_correction_set(int enabled) { + s_texture_correction_enabled = enabled ? 1 : 0; +- gte_precision_tracking_set(enabled); ++ s_texture_correction_triangles = 0; ++ gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); ++ gte_precision_tracking_set(s_texture_correction_enabled || ++ ws_geometry_correction); ++} ++ ++int gpu_texture_correction_enabled(void) { ++ return s_texture_correction_enabled; + } + + uint32_t gpu_texture_correction_hits(void) { +- return sw_perspective_triangle_count(); +-} +- +-/* Match all three GP0 positions to recent GTE projections. Requiring a full +- * triangle match prevents screen-space HUD/sprites that happen to share one +- * coordinate from receiving world-geometry correction. The integer delta +- * folds in draw offsets and any widescreen adjustment already applied. */ +-static void prepare_precise_triangle(uint32_t w0, uint32_t w1, uint32_t w2, +- const int32_t vx[3], const int32_t vy[3]) { +- uint32_t words[3] = { w0, w1, w2 }; ++ return s_texture_correction_triangles; ++} ++ ++static int gp0_source_word_address(int word_index, uint32_t *out) { ++ const uint32_t delta = (uint32_t)word_index * 4u; ++ if (gp0_cmd_source_addr == 0xFFFFFFFFu || ++ gp0_cmd_source_addr > UINT32_MAX - delta) ++ return 0; ++ *out = gp0_cmd_source_addr + delta; ++ return 1; ++} ++ ++static int32_t fixed16_integer_floor(int32_t value) { ++ const int64_t wide = value; ++ if (wide >= 0) return (int32_t)(wide / 65536); ++ return (int32_t)(-(((-wide) + 65535) / 65536)); ++} ++ ++static uint64_t abs_i64_u64(int64_t value) { ++ return (uint64_t)(value < 0 ? -value : value); ++} ++ ++static uint32_t geometry_primitive_class(uint8_t opcode) { ++ const uint32_t quad = (opcode & 0x08u) ? 4u : 0u; ++ const uint32_t gouraud = (opcode & 0x10u) ? 2u : 0u; ++ const uint32_t textured = (opcode & 0x04u) ? 1u : 0u; ++ return quad | gouraud | textured; ++} ++ ++static GpuGeometryRejectReason geometry_reject_from_lookup( ++ GtePrecisionLookupResult result) { ++ switch (result) { ++ case GTE_PRECISION_LOOKUP_TRACKING_DISABLED: ++ return GPU_GEOMETRY_REJECT_TRACKING_DISABLED; ++ case GTE_PRECISION_LOOKUP_SPECULATIVE: ++ return GPU_GEOMETRY_REJECT_SPECULATIVE_LOOKUP; ++ case GTE_PRECISION_LOOKUP_SOURCE_NOT_RAM: ++ return GPU_GEOMETRY_REJECT_SOURCE_NOT_RAM; ++ case GTE_PRECISION_LOOKUP_SOURCE_UNALIGNED: ++ return GPU_GEOMETRY_REJECT_SOURCE_UNALIGNED; ++ case GTE_PRECISION_LOOKUP_STORE_MISS: ++ return GPU_GEOMETRY_REJECT_STORE_MISS; ++ case GTE_PRECISION_LOOKUP_STORE_COLLISION: ++ return GPU_GEOMETRY_REJECT_STORE_COLLISION; ++ case GTE_PRECISION_LOOKUP_PROJECTION_INVALID: ++ return GPU_GEOMETRY_REJECT_PROJECTION_INVALID; ++ case GTE_PRECISION_LOOKUP_PACKED_MISMATCH: ++ return GPU_GEOMETRY_REJECT_PACKED_MISMATCH; ++ case GTE_PRECISION_LOOKUP_ZERO_DEPTH: ++ return GPU_GEOMETRY_REJECT_ZERO_DEPTH; ++ case GTE_PRECISION_LOOKUP_SATURATED: ++ return GPU_GEOMETRY_REJECT_SATURATED; ++ case GTE_PRECISION_LOOKUP_PERSPECTIVE_INVALID: ++ return GPU_GEOMETRY_REJECT_PROJECTION_INVALID; ++ case GTE_PRECISION_LOOKUP_ACCEPTED: ++ case GTE_PRECISION_LOOKUP_RESULT_COUNT: ++ default: ++ return GPU_GEOMETRY_REJECT_PROJECTION_INVALID; ++ } ++} ++ ++static void geometry_diag_note_candidate( ++ GpuGeometryCorrectionCounters *stats, int count, int triangle_count, ++ uint32_t primitive_class, uint32_t opcode_index) { ++ if (!stats) return; ++ stats->triangle_candidates += (uint64_t)triangle_count; ++ ++stats->polygon_candidates; ++ stats->vertex_candidates += (uint64_t)count; ++ ++stats->primitive_candidates[primitive_class]; ++ ++stats->opcode_candidates[opcode_index]; ++} ++ ++static void geometry_diag_note_rejection( ++ GpuGeometryCorrectionCounters *stats, ++ GpuGeometryRejectReason reason, uint64_t count) { ++ if (stats) stats->vertex_rejections[(uint32_t)reason] += count; ++} ++ ++/* Axis-aligned textured quads use a rectangle renderer and never consume the ++ * per-triangle precision metadata. Keep them visible in the coverage ++ * denominator as an explicit, safe canonical fallback. */ ++static void geometry_diag_note_rectangle_fast_path(void) { ++ if (!gte_geometry_correction_enabled()) return; ++ const uint8_t opcode = (uint8_t)(gp0_cmd_buf[0] >> 24); ++ const uint32_t opcode_index = (uint32_t)(opcode - 0x20u) & 31u; ++ const uint32_t primitive_class = geometry_primitive_class(opcode); ++ GpuGeometryCorrectionCounters *live = NULL; ++ if (geometry_diag_presentation_live()) { ++ live = &s_geometry_diag_live_current; ++ s_geometry_diag_live_current_touched = 1; ++ } ++ geometry_diag_note_candidate(&s_geometry_diag_cumulative, 4, 2, ++ primitive_class, opcode_index); ++ geometry_diag_note_candidate(live, 4, 2, ++ primitive_class, opcode_index); ++ geometry_diag_note_rejection( ++ &s_geometry_diag_cumulative, ++ GPU_GEOMETRY_REJECT_RECTANGLE_FAST_PATH, 4); ++ geometry_diag_note_rejection( ++ live, GPU_GEOMETRY_REJECT_RECTANGLE_FAST_PATH, 4); ++} ++ ++static uint32_t geometry_depth_bucket(uint16_t z) { ++ if (z < 256u) return 0; ++ if (z < 1024u) return 1; ++ if (z < 4096u) return 2; ++ if (z < 16384u) return 3; ++ if (z < 32768u) return 4; ++ return 5; ++} ++ ++static uint32_t geometry_screen_bucket(int32_t x, int32_t y) { ++ const int32_t width = ws_disp_w(); ++ const int32_t height = ws_disp_h(); ++ if (x < 0 || y < 0 || x >= width || y >= height) ++ return 9; ++ int32_t column = width > 0 ? (x * 3) / width : 0; ++ int32_t row = height > 0 ? (y * 3) / height : 0; ++ if (column > 2) column = 2; ++ if (row > 2) row = 2; ++ return (uint32_t)(row * 3 + column); ++} ++ ++static uint32_t geometry_correction_bucket(uint64_t magnitude16) { ++ if (magnitude16 == 0) return 0; ++ if (magnitude16 < 16384u) return 1; ++ if (magnitude16 < 32768u) return 2; ++ if (magnitude16 < 49152u) return 3; ++ return 4; ++} ++ ++static void geometry_diag_saturating_add( ++ GpuGeometryCorrectionCounters *stats, ++ uint64_t *destination, uint64_t value) { ++ if (UINT64_MAX - *destination < value) { ++ *destination = UINT64_MAX; ++ stats->correction_accumulators_saturated = 1; ++ } else { ++ *destination += value; ++ } ++} ++ ++static void geometry_diag_note_accepted( ++ GpuGeometryCorrectionCounters *stats, int count, int triangle_count, ++ uint32_t primitive_class, uint32_t opcode_index, ++ const int32_t *precise_x, const int32_t *precise_y, ++ const int32_t *raw_x, const int32_t *raw_y, ++ const int32_t *screen_x, const int32_t *screen_y, ++ const uint16_t *z) { ++ if (!stats) return; ++ stats->triangle_accepted += (uint64_t)triangle_count; ++ ++stats->polygon_accepted; ++ stats->vertex_accepted += (uint64_t)count; ++ ++stats->primitive_accepted[primitive_class]; ++ ++stats->opcode_accepted[opcode_index]; ++ for (int i = 0; i < count; ++i) { ++ const int64_t dx = (int64_t)precise_x[i] - ++ (int64_t)raw_x[i] * 65536; ++ const int64_t dy = (int64_t)precise_y[i] - ++ (int64_t)raw_y[i] * 65536; ++ const uint64_t ax = abs_i64_u64(dx); ++ const uint64_t ay = abs_i64_u64(dy); ++ const uint64_t magnitude = ax > ay ? ax : ay; ++ ++stats->depth_vertices[geometry_depth_bucket(z[i])]; ++ ++stats->screen_vertices[ ++ geometry_screen_bucket(screen_x[i], screen_y[i])]; ++ ++stats->correction_vertices; ++ geometry_diag_saturating_add( ++ stats, &stats->correction_abs_x16_sum, ax); ++ geometry_diag_saturating_add( ++ stats, &stats->correction_abs_y16_sum, ay); ++ geometry_diag_saturating_add( ++ stats, &stats->correction_magnitude16_sum, magnitude); ++ if (magnitude > stats->correction_magnitude16_max) ++ stats->correction_magnitude16_max = magnitude; ++ geometry_diag_saturating_add( ++ stats, &stats->correction_x16_squared_sum, ax * ax); ++ geometry_diag_saturating_add( ++ stats, &stats->correction_y16_squared_sum, ay * ay); ++ ++stats->correction_magnitude_buckets[ ++ geometry_correction_bucket(magnitude)]; ++ } ++} ++ ++/* Resolve vertices only through the exact RAM address at which a reviewed GTE ++ * projection store committed that result. A rounded packed SXY value is not a ++ * unique identity: many ++ * unrelated 3D vertices can land on the same PS1 pixel. Test 11's packed-SXY ++ * fallback therefore paired polygons with unrelated projections and opened ++ * cracks between adjacent primitives. Missing provenance is deliberately a ++ * canonical-render fallback for the whole polygon. The integer delta folds in ++ * draw offsets and any widescreen adjustment already applied. */ ++/* Valid only for the polygon most recently resolved below. Both halves of a ++ * quad use their original indices; shader q remains a separate attribute. */ ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++static uint16_t s_precise_vertex_depth[4]; ++static uint16_t s_precise_vertex_surface[4]; /* the same with a pinned corner's depth kept: for the mirror's depth */ ++static uint64_t s_precise_vertex_identity[4]; ++static int s_precise_vertex_partial; ++#endif ++/* A polygon that falls back still draws the corners the game pinned where they project: its neighbours do. */ ++static PsxPinnedPlaces s_pinned_places; ++/* The polygon resolved last is the game's grown second copy of the one before it. */ ++static int s_precise_grown; ++#if defined(PSX_HAS_GAME_HOST) && !defined(PSX_DISABLE_FRAME_INTERPOLATION) ++/* A grown corner lies off its source corner, on the same plane: its depth is the plane's there, 1/z being linear on screen. */ ++static void grown_corner_depths(const int32_t *source_x, const int32_t *source_y, const int32_t *raw_x, const int32_t *raw_y, ++ const int *indices) { ++ static const int beside[4][2] = {{1, 2}, {0, 3}, {0, 3}, {1, 2}}; ++ uint16_t moved[4]; ++ for (int i = 0; i < 4; ++i) { ++ const int j = beside[i][0], k = beside[i][1]; ++ const double zi = s_precise_vertex_depth[i], zj = s_precise_vertex_depth[j], zk = s_precise_vertex_depth[k]; ++ const double ax = ((double)source_x[j] - source_x[i]) / 65536.0, ay = ((double)source_y[j] - source_y[i]) / 65536.0; ++ const double bx = ((double)source_x[k] - source_x[i]) / 65536.0, by = ((double)source_y[k] - source_y[i]) / 65536.0; ++ const double det = ax * by - bx * ay; ++ int32_t grown_x, grown_y; ++ moved[i] = s_precise_vertex_depth[i]; ++ if (zi <= 0.0 || zj <= 0.0 || zk <= 0.0 || (det < 1.0e-6 && det > -1.0e-6)) continue; ++ parse_vertex(gp0_cmd_buf[indices[i]], &grown_x, &grown_y); ++ { ++ const double ga = 1.0 / zj - 1.0 / zi, gb = 1.0 / zk - 1.0 / zi; ++ const double px = (double)grown_x - raw_x[i], py = (double)grown_y - raw_y[i]; ++ const double g = 1.0 / zi + ((ga * by - gb * ay) * px + (ax * gb - bx * ga) * py) / det; ++ const double z = g > 1.0 / 65535.0 ? 1.0 / g : 65535.0; ++ if (z >= 17.0) moved[i] = (uint16_t)(z + 0.5); /* 16 and nearer reads as no depth: the corner keeps its source's */ ++ } ++ } ++ memcpy(s_precise_vertex_depth, moved, sizeof(moved)); ++} ++#endif ++static int resolve_precise_vertices(const int *indices, int count, ++ int triangle_count, ++ const int32_t *vx, const int32_t *vy, ++ int32_t *fx, int32_t *fy) { ++ s_precise_grown = 0; ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++ memset(s_precise_vertex_depth, 0, sizeof(s_precise_vertex_depth)); ++ memset(s_precise_vertex_surface, 0, sizeof(s_precise_vertex_surface)); ++ memset(s_precise_vertex_identity, 0, sizeof(s_precise_vertex_identity)); ++ s_precise_vertex_partial = 0; ++#endif ++ s_pinned_places.unpinned = 0; ++ if (!gte_geometry_correction_enabled()) ++ return 0; ++ ++ const uint8_t opcode = (uint8_t)(gp0_cmd_buf[0] >> 24); ++ const uint32_t opcode_index = (uint32_t)(opcode - 0x20u) & 31u; ++ const uint32_t primitive_class = geometry_primitive_class(opcode); ++ GpuGeometryCorrectionCounters *live = NULL; ++ if (geometry_diag_presentation_live()) { ++ live = &s_geometry_diag_live_current; ++ s_geometry_diag_live_current_touched = 1; ++ } ++ geometry_diag_note_candidate(&s_geometry_diag_cumulative, count, ++ triangle_count, ++ primitive_class, opcode_index); ++ geometry_diag_note_candidate(live, count, triangle_count, ++ primitive_class, opcode_index); ++ ++ if (gp0_cmd_source_addr == 0xFFFFFFFFu) { ++ geometry_diag_note_rejection(&s_geometry_diag_cumulative, ++ GPU_GEOMETRY_REJECT_NO_COMMAND_SOURCE, ++ (uint64_t)count); ++ geometry_diag_note_rejection(live, ++ GPU_GEOMETRY_REJECT_NO_COMMAND_SOURCE, ++ (uint64_t)count); ++ return 0; ++ } ++ ++ int32_t precise_x[4], precise_y[4], raw_x[4], raw_y[4]; ++ uint16_t z[4]; ++ int resolved = 0; ++ int pinned[4] = {0}; ++ const int unpin = gte_precision_unpin_enabled(); ++ uint32_t grown_addr[4] = {0}, grown_word[4] = {0}; ++#ifdef PSX_HAS_GAME_HOST ++ /* The game's second, grown copy of a world quad stands on the first packet's corners. */ ++ const int grown = count == 4 && psx_game_grown_quad_sources( ++ gp0_cmd_source_addr, gp0_cmd_buf, grown_addr, grown_word); ++#else ++ const int grown = 0; ++#endif ++ ++ for (int i = 0; i < count; ++i) { ++ const uint32_t word = grown ? grown_word[i] : gp0_cmd_buf[indices[i]]; ++ uint32_t addr = grown_addr[i]; ++ GpuGeometryRejectReason reject; ++ int accepted = 0; ++ if (!grown && !gp0_source_word_address(indices[i], &addr)) { ++ reject = GPU_GEOMETRY_REJECT_SOURCE_ADDRESS_OVERFLOW; ++ } else { ++ const GtePrecisionLookupResult result = ++ gte_precision_load_word_ex(addr, word, ++ &precise_x[i], &precise_y[i], &z[i]); ++ if (result != GTE_PRECISION_LOOKUP_ACCEPTED) { ++ reject = geometry_reject_from_lookup(result); ++ } else { ++ parse_vertex(word, &raw_x[i], &raw_y[i]); ++ /* This test happens on the retained raw projection. Draw ++ * offsets and widescreen transforms in vx/vy must not hide a ++ * saturated or otherwise non-subpixel discrepancy. */ ++ /* A corner the game pinned to its guard band is drawn where it projects. */ ++ pinned[i] = gte_precision_word_clamped(addr, word); ++ if ((fixed16_integer_floor(precise_x[i]) != raw_x[i] || ++ fixed16_integer_floor(precise_y[i]) != raw_y[i]) && ++ !pinned[i]) { ++ reject = GPU_GEOMETRY_REJECT_INTEGER_MISMATCH; ++ } else { ++ accepted = 1; ++ ++resolved; ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++ s_precise_vertex_depth[i] = psx_pinned_corner_depth(unpin, pinned[i], z[i]); ++ s_precise_vertex_surface[i] = z[i]; ++#endif ++ } ++ } ++ } ++ if (!accepted) { ++ geometry_diag_note_rejection(&s_geometry_diag_cumulative, ++ reject, 1); ++ geometry_diag_note_rejection(live, reject, 1); ++ } ++ } ++ ++ if (resolved != count) { ++ if (resolved != 0) { ++ geometry_diag_note_rejection( ++ &s_geometry_diag_cumulative, ++ GPU_GEOMETRY_REJECT_ATOMIC_FALLBACK, (uint64_t)resolved); ++ geometry_diag_note_rejection( ++ live, GPU_GEOMETRY_REJECT_ATOMIC_FALLBACK, ++ (uint64_t)resolved); ++ ++s_geometry_diag_cumulative.partial_polygon_rejections; ++ if (live) ++live->partial_polygon_rejections; ++ if (count == 4) { ++ ++s_geometry_diag_cumulative.partial_quad_rejections; ++ if (live) ++live->partial_quad_rejections; ++ } ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++ s_precise_vertex_partial = 1; ++#endif ++ psx_pinned_places(count, unpin, pinned, precise_x, precise_y, raw_x, raw_y, vx, vy, &s_pinned_places); ++ } ++ return 0; ++ } ++ ++ geometry_diag_note_accepted(&s_geometry_diag_cumulative, count, ++ triangle_count, ++ primitive_class, opcode_index, ++ precise_x, precise_y, raw_x, raw_y, ++ raw_x, raw_y, z); ++ geometry_diag_note_accepted(live, count, triangle_count, ++ primitive_class, opcode_index, ++ precise_x, precise_y, raw_x, raw_y, ++ raw_x, raw_y, z); ++ for (int i = 0; i < count; ++i) { ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++ s_precise_vertex_depth[i] = psx_pinned_corner_depth(unpin, pinned[i], z[i]); ++ s_precise_vertex_surface[i] = z[i]; ++ { ++ uint32_t identity_addr; ++ /* A grown corner is off its model point: it gets the depth and no identity. */ ++ if (!grown && gp0_source_word_address(indices[i], &identity_addr)) ++ (void)gte_precision_load_identity(identity_addr, gp0_cmd_buf[indices[i]], ++ &s_precise_vertex_identity[i]); ++ } ++#endif ++ fx[i] = (int32_t)((int64_t)precise_x[i] + ++ (int64_t)(vx[i] - raw_x[i]) * 65536); ++ fy[i] = (int32_t)((int64_t)precise_y[i] + ++ (int64_t)(vy[i] - raw_y[i]) * 65536); ++ } ++ s_precise_grown = grown; ++#if defined(PSX_HAS_GAME_HOST) && !defined(PSX_DISABLE_FRAME_INTERPOLATION) ++ if (grown) { ++ grown_corner_depths(precise_x, precise_y, raw_x, raw_y, indices); ++ memcpy(s_precise_vertex_surface, s_precise_vertex_depth, sizeof(s_precise_vertex_surface)); ++ } ++#endif ++ return 1; ++} ++ ++/* Queue one triangle's one-shot presentation metadata. Fractional yaw uses the ++ * same exact-provenance gate as subpixel positions, so CPU-built 2D polygons ++ * and incomplete world packets remain completely canonical. */ ++static void queue_precise_triangle(int exact, ++ const int32_t *fx, const int32_t *fy, ++ int a, int b, int c) { ++ const int geometry_enabled = gte_geometry_correction_enabled(); ++ gr_set_world_triangle(0); ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++ gr_set_temporal_depth_triangle(0, 0.0f, 0.0f, 0.0f); ++ gr_set_temporal_identity_triangle(0u, 0u, 0u); ++ gr_set_surface_depth_triangle(0.0f, 0.0f, 0.0f); ++#endif ++ gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); ++ if (geometry_enabled) ++ws_geometry_world_triangles; ++ if (!geometry_enabled || !exact) { ++ gr_set_precise_triangle(0, 0,0, 0,0, 0,0); ++ if (geometry_enabled && s_pinned_places.unpinned) ++ gr_set_unpinned_triangle(s_pinned_places.x16[a], s_pinned_places.y16[a], s_pinned_places.x16[b], s_pinned_places.y16[b], ++ s_pinned_places.x16[c], s_pinned_places.y16[c]); ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++ /* The corners that did resolve keep their depth: an in-between frame moves them with the camera. */ ++ if (geometry_enabled && s_precise_vertex_partial) ++ gr_set_temporal_depth_triangle(2, ++ (float)s_precise_vertex_depth[a], ++ (float)s_precise_vertex_depth[b], ++ (float)s_precise_vertex_depth[c]); ++ if (geometry_enabled && s_precise_vertex_partial) ++ gr_set_surface_depth_triangle((float)s_precise_vertex_surface[a], (float)s_precise_vertex_surface[b], ++ (float)s_precise_vertex_surface[c]); ++#endif ++ return; ++ } ++ ++ws_geometry_precise_triangles; ++ gr_set_world_triangle(1); ++ if (s_precise_grown) gr_set_world_triangle(2); /* the game's grown second copy */ ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++ gr_set_temporal_depth_triangle(psx_pinned_depth_mode(s_precise_vertex_depth[a], s_precise_vertex_depth[b], s_precise_vertex_depth[c]), ++ (float)s_precise_vertex_depth[a], ++ (float)s_precise_vertex_depth[b], ++ (float)s_precise_vertex_depth[c]); ++ gr_set_temporal_identity_triangle(s_precise_vertex_identity[a], ++ s_precise_vertex_identity[b], ++ s_precise_vertex_identity[c]); ++ gr_set_surface_depth_triangle((float)s_precise_vertex_surface[a], (float)s_precise_vertex_surface[b], ++ (float)s_precise_vertex_surface[c]); ++#endif ++ gr_set_precise_triangle(1, ++ fx[a], fy[a], fx[b], fy[b], fx[c], fy[c]); ++} ++ ++static int prepare_precise_triangle(int i0, int i1, int i2, ++ const int32_t vx[3], const int32_t vy[3]) { ++ const int indices[3] = { i0, i1, i2 }; + int32_t fx[3], fy[3]; +- const int geometry_enabled = gte_geometry_correction_enabled(); +- sw_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); +- if (!geometry_enabled) { +- sw_set_precise_triangle(0, 0,0, 0,0, 0,0); ++ const int exact = resolve_precise_vertices( ++ indices, 3, 1, vx, vy, fx, fy); ++ queue_precise_triangle(exact, fx, fy, 0, 1, 2); ++ return exact; ++} ++ ++/* Resolve a GP0 quad as one unit before either of its raster triangles is ++ * submitted. This prevents a partially tracked quad from correcting only one ++ * side of its shared diagonal. */ ++static int prepare_precise_quad_vertices(const int indices[4], ++ int triangle_count, ++ const int32_t vx[4], ++ const int32_t vy[4], ++ int32_t fx[4], int32_t fy[4]) { ++ return resolve_precise_vertices( ++ indices, 4, triangle_count, vx, vy, fx, fy); ++} ++ ++/* Resolve projective depth as one polygon-wide transaction. Geometry's exact ++ * verdict is a prerequisite: this keeps both halves of a quad affine whenever ++ * any one of its four vertices falls back, and excludes CPU-built UI. */ ++static int resolve_texture_vertices(int exact, const int *indices, int count, ++ float *q) { ++ if (!exact || !s_texture_correction_enabled || ++ gp0_cmd_source_addr == 0xFFFFFFFFu || count < 3 || count > 4) ++ return 0; ++ uint16_t z[4]; ++ for (int i = 0; i < count; i++) { ++ uint32_t addr; ++ if (!gp0_source_word_address(indices[i], &addr) || ++ gte_precision_load_perspective_word_ex( ++ addr, gp0_cmd_buf[indices[i]], &z[i]) != ++ GTE_PRECISION_LOOKUP_ACCEPTED) ++ return 0; ++ } ++ for (int i = 0; i < count; ++i) q[i] = 1.0f / (float)z[i]; ++ float qmax = q[0]; ++ for (int i = 1; i < count; ++i) ++ if (q[i] > qmax) qmax = q[i]; ++ if (qmax <= 0.0f) return 0; ++ for (int i = 0; i < count; ++i) q[i] /= qmax; ++ return 1; ++} ++ ++static void queue_texture_triangle(int exact, const float *q, ++ int a, int b, int c) { ++ if (!exact) { ++ gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); + return; + } +- for (int i = 0; i < 3; i++) { +- int32_t raw_x, raw_y; +- parse_vertex(words[i], &raw_x, &raw_y); +- if (!gte_geometry_correction_lookup(words[i], &fx[i], &fy[i])) { +- sw_set_precise_triangle(0, 0,0, 0,0, 0,0); +- return; +- } +- fx[i] = (int32_t)((int64_t)fx[i] + +- (int64_t)(vx[i] - raw_x) * 65536); +- fy[i] = (int32_t)((int64_t)fy[i] + +- (int64_t)(vy[i] - raw_y) * 65536); +- } +- sw_set_precise_triangle(1, fx[0],fy[0], fx[1],fy[1], fx[2],fy[2]); +-} +- +-/* Enable perspective UVs only when every position word came from an exact +- * SWC2 projection store at that same DMA packet address. This preserves the +- * association through ordering-table reordering and rejects CPU-built UI. */ +-static void prepare_texture_triangle(int i0, int i1, int i2) { +- sw_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); +- if (!s_texture_correction_enabled || gp0_cmd_source_addr == 0xFFFFFFFFu) +- return; +- int indices[3] = { i0, i1, i2 }; +- uint16_t z[3]; +- for (int i = 0; i < 3; i++) { +- uint32_t addr = (gp0_cmd_source_addr + (uint32_t)indices[i] * 4u) & 0x1FFFFCu; +- if (!gte_precision_load_word(addr, gp0_cmd_buf[indices[i]], NULL, NULL, &z[i]) || +- z[i] == 0) +- return; +- } +- float q[3] = { 1.0f / (float)z[0], 1.0f / (float)z[1], 1.0f / (float)z[2] }; +- float qmax = q[0]; +- if (q[1] > qmax) qmax = q[1]; +- if (q[2] > qmax) qmax = q[2]; +- if (qmax <= 0.0f) return; +- sw_set_perspective_triangle(1, q[0] / qmax, q[1] / qmax, q[2] / qmax); ++ gr_set_perspective_triangle(1, q[a], q[b], q[c]); ++ if (s_texture_correction_triangles != UINT32_MAX) ++ ++s_texture_correction_triangles; ++} ++ ++/* Triangle wrapper; quads preflight all four vertices once and queue their two ++ * reciprocal-depth orderings explicitly below. */ ++static void prepare_texture_triangle(int exact, int i0, int i1, int i2) { ++ const int indices[3] = { i0, i1, i2 }; ++ float q[3]; ++ const int perspective = resolve_texture_vertices(exact, indices, 3, q); ++ queue_texture_triangle(perspective, q, 0, 1, 2); + } + + /* Write a single pixel to VRAM with draw area clipping and mask bit handling */ +@@ -3009,8 +4097,7 @@ + } + if (draw_area_out_bbox(vx, vy, 3)) return; + gr_set_semi_transparency(semi_trans, (int)semi_transparency); +- prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[2], gp0_cmd_buf[3], +- vx, vy); ++ prepare_precise_triangle(1, 2, 3, vx, vy); + gr_draw_flat_triangle(vx[0], vy[0], vx[1], vy[1], vx[2], vy[2], color); + } + +@@ -3046,6 +4133,7 @@ + gr_draw_flat_rect(x, y, w, h, color); + return; + } ++ ws_hud_widget_quad(vx, vy); + ws_nw_backdrop_stretch_quad(vx, vy); /* full-frame 2D backdrop stretch (no-op else) */ + ws_nw_hud_shift_vertices(vx, 4); + for (int i = 0; i < 4; i++) { +@@ -3054,18 +4142,17 @@ + } + if (draw_area_out_bbox(vx, vy, 4)) return; + gr_set_semi_transparency(semi_trans, (int)semi_transparency); ++ const int precise_indices[4] = { 1, 2, 3, 4 }; ++ int32_t precise_x[4], precise_y[4]; ++ const int precise_quad = prepare_precise_quad_vertices( ++ precise_indices, (!rej_a) + (!rej_b), ++ vx, vy, precise_x, precise_y); + if (!rej_a) { +- int32_t tx[3] = { vx[0], vx[1], vx[2] }; +- int32_t ty[3] = { vy[0], vy[1], vy[2] }; +- prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[2], +- gp0_cmd_buf[3], tx, ty); ++ queue_precise_triangle(precise_quad, precise_x, precise_y, 0, 1, 2); + gr_draw_flat_triangle(vx[0], vy[0], vx[1], vy[1], vx[2], vy[2], color); + } + if (!rej_b) { +- int32_t tx[3] = { vx[2], vx[1], vx[3] }; +- int32_t ty[3] = { vy[2], vy[1], vy[3] }; +- prepare_precise_triangle(gp0_cmd_buf[3], gp0_cmd_buf[2], +- gp0_cmd_buf[4], tx, ty); ++ queue_precise_triangle(precise_quad, precise_x, precise_y, 2, 1, 3); + gr_draw_flat_triangle(vx[2], vy[2], vx[1], vy[1], vx[3], vy[3], color); + } + } +@@ -3088,8 +4175,7 @@ + } + if (draw_area_out_bbox(vx, vy, 3)) return; + gr_set_semi_transparency(semi_trans, (int)semi_transparency); +- prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], gp0_cmd_buf[5], +- vx, vy); ++ prepare_precise_triangle(1, 3, 5, vx, vy); + gr_draw_gouraud_triangle(vx[0], vy[0], c[0], + vx[1], vy[1], c[1], + vx[2], vy[2], c[2]); +@@ -3131,20 +4217,19 @@ + } + } + gr_set_semi_transparency(semi_trans, (int)semi_transparency); ++ const int precise_indices[4] = { 1, 3, 5, 7 }; ++ int32_t precise_x[4], precise_y[4]; ++ const int precise_quad = prepare_precise_quad_vertices( ++ precise_indices, (!rej_a) + (!rej_b), ++ vx, vy, precise_x, precise_y); + if (!rej_a) { +- int32_t tx[3] = { vx[0], vx[1], vx[2] }; +- int32_t ty[3] = { vy[0], vy[1], vy[2] }; +- prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], +- gp0_cmd_buf[5], tx, ty); ++ queue_precise_triangle(precise_quad, precise_x, precise_y, 0, 1, 2); + gr_draw_gouraud_triangle(vx[0], vy[0], c[0], + vx[1], vy[1], c[1], + vx[2], vy[2], c[2]); + } + if (!rej_b) { +- int32_t tx[3] = { vx[2], vx[1], vx[3] }; +- int32_t ty[3] = { vy[2], vy[1], vy[3] }; +- prepare_precise_triangle(gp0_cmd_buf[5], gp0_cmd_buf[3], +- gp0_cmd_buf[7], tx, ty); ++ queue_precise_triangle(precise_quad, precise_x, precise_y, 2, 1, 3); + gr_draw_gouraud_triangle(vx[2], vy[2], c[2], + vx[1], vy[1], c[1], + vx[3], vy[3], c[3]); +@@ -3215,9 +4300,8 @@ + if (draw_area_out_bbox(vx, vy, 3)) return; + + setup_textured_draw(color24, semi_trans, raw_texture); +- prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], gp0_cmd_buf[5], +- vx, vy); +- prepare_texture_triangle(1, 3, 5); ++ const int precise = prepare_precise_triangle(1, 3, 5, vx, vy); ++ prepare_texture_triangle(precise, 1, 3, 5); + gr_draw_textured_triangle(vx[0], vy[0], u[0], v[0], + vx[1], vy[1], u[1], v[1], + vx[2], vy[2], u[2], v[2], +@@ -3252,11 +4336,16 @@ + + /* Widescreen: tagged billboard quads carry CPU-computed pixel offsets the + * GTE squash never saw — re-squash every X around the prim's anchor. */ ++ const int raw_w = abs(vx[1] - vx[0]) + 1, raw_h = abs(vy[2] - vy[0]) + 1; ++ int squashed = 0; + { + int32_t ws_ax; +- if (ws_tagged_anchor(&ws_ax)) ++ if (ws_tagged_anchor(&ws_ax)) { ++ squashed = 1; + for (int i = 0; i < 4; i++) vx[i] = ws_scale_about(vx[i], ws_ax); +- } ++ } ++ } ++ ws_hud_widget_quad(vx, vy); + ws_auto_ui_transform_quad(vx, vy); + ws_nw_backdrop_stretch_quad(vx, vy); /* full-frame 2D backdrop image stretch (no-op else) */ + ws_nw_hud_shift_vertices(vx, 4); +@@ -3282,6 +4371,13 @@ + int top_v = vy[0] < vy[2] ? v[0] : v[2]; + int bot_v = vy[0] < vy[2] ? v[2] : v[0]; + if (w > 0 && h > 0) { ++ geometry_diag_note_rectangle_fast_path(); ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++ float sides[4]; ++ const float sprite_depth = temporal_sprite_sides( ++ x - draw_offset_x, y - draw_offset_y, w, h, raw_w, raw_h, squashed, sides); ++ gr_set_temporal_sprite(sprite_depth, sides); ++#endif + if (right_u - left_u == w && bot_v - top_v == h) { + gr_draw_textured_rect(x, y, w, h, left_u, top_v, + clut_x, clut_y, tpage); +@@ -3294,23 +4390,25 @@ + } + } + ++ const int precise_indices[4] = { 1, 3, 5, 7 }; ++ int32_t precise_x[4], precise_y[4]; ++ const int precise_quad = prepare_precise_quad_vertices( ++ precise_indices, (!rej_a) + (!rej_b), ++ vx, vy, precise_x, precise_y); ++ float texture_q[4]; ++ const int perspective_quad = resolve_texture_vertices( ++ precise_quad, precise_indices, 4, texture_q); + if (!rej_a) { +- int32_t tx[3] = { vx[0], vx[1], vx[2] }; +- int32_t ty[3] = { vy[0], vy[1], vy[2] }; +- prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], +- gp0_cmd_buf[5], tx, ty); +- prepare_texture_triangle(1, 3, 5); ++ queue_precise_triangle(precise_quad, precise_x, precise_y, 0, 1, 2); ++ queue_texture_triangle(perspective_quad, texture_q, 0, 1, 2); + gr_draw_textured_triangle(vx[0], vy[0], u[0], v[0], + vx[1], vy[1], u[1], v[1], + vx[2], vy[2], u[2], v[2], + clut_x, clut_y, tpage); + } + if (!rej_b) { +- int32_t tx[3] = { vx[2], vx[1], vx[3] }; +- int32_t ty[3] = { vy[2], vy[1], vy[3] }; +- prepare_precise_triangle(gp0_cmd_buf[5], gp0_cmd_buf[3], +- gp0_cmd_buf[7], tx, ty); +- prepare_texture_triangle(5, 3, 7); ++ queue_precise_triangle(precise_quad, precise_x, precise_y, 2, 1, 3); ++ queue_texture_triangle(perspective_quad, texture_q, 2, 1, 3); + gr_draw_textured_triangle(vx[2], vy[2], u[2], v[2], + vx[1], vy[1], u[1], v[1], + vx[3], vy[3], u[3], v[3], +@@ -3351,9 +4449,8 @@ + if (draw_area_out_bbox(vx, vy, 3)) return; + + gr_set_semi_transparency(semi_trans, (int)semi_transparency); +- prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[4], gp0_cmd_buf[7], +- vx, vy); +- prepare_texture_triangle(1, 4, 7); ++ const int precise = prepare_precise_triangle(1, 4, 7, vx, vy); ++ prepare_texture_triangle(precise, 1, 4, 7); + gr_draw_shaded_textured_triangle(vx[0], vy[0], u[0], v[0], c[0], + vx[1], vy[1], u[1], v[1], c[1], + vx[2], vy[2], u[2], v[2], c[2], +@@ -3399,23 +4496,25 @@ + if (draw_area_out_bbox(vx, vy, 4)) return; + + gr_set_semi_transparency(semi_trans, (int)semi_transparency); ++ const int precise_indices[4] = { 1, 4, 7, 10 }; ++ int32_t precise_x[4], precise_y[4]; ++ const int precise_quad = prepare_precise_quad_vertices( ++ precise_indices, (!rej_a) + (!rej_b), ++ vx, vy, precise_x, precise_y); ++ float texture_q[4]; ++ const int perspective_quad = resolve_texture_vertices( ++ precise_quad, precise_indices, 4, texture_q); + if (!rej_a) { +- int32_t tx[3] = { vx[0], vx[1], vx[2] }; +- int32_t ty[3] = { vy[0], vy[1], vy[2] }; +- prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[4], +- gp0_cmd_buf[7], tx, ty); +- prepare_texture_triangle(1, 4, 7); ++ queue_precise_triangle(precise_quad, precise_x, precise_y, 0, 1, 2); ++ queue_texture_triangle(perspective_quad, texture_q, 0, 1, 2); + gr_draw_shaded_textured_triangle(vx[0], vy[0], u[0], v[0], c[0], + vx[1], vy[1], u[1], v[1], c[1], + vx[2], vy[2], u[2], v[2], c[2], + clut_x, clut_y, tpage, raw_texture); + } + if (!rej_b) { +- int32_t tx[3] = { vx[2], vx[1], vx[3] }; +- int32_t ty[3] = { vy[2], vy[1], vy[3] }; +- prepare_precise_triangle(gp0_cmd_buf[7], gp0_cmd_buf[4], +- gp0_cmd_buf[10], tx, ty); +- prepare_texture_triangle(7, 4, 10); ++ queue_precise_triangle(precise_quad, precise_x, precise_y, 2, 1, 3); ++ queue_texture_triangle(perspective_quad, texture_q, 2, 1, 3); + gr_draw_shaded_textured_triangle(vx[2], vy[2], u[2], v[2], c[2], + vx[1], vy[1], u[1], v[1], c[1], + vx[3], vy[3], u[3], v[3], c[3], +@@ -3477,6 +4576,7 @@ + if (w > 1023) w = 1023; + if (h > 511) h = 511; + ws_expand_fullscreen_rect(&x0, y0, &w, h); ++ w = ws_menu_rect(&x0, w); + x0 += ws_nw_hud_shift(x0, w); /* native-wide HUD corner re-anchor (no-op else) */ + x0 += draw_offset_x; y0 += draw_offset_y; + if (draw_area_out_rect(x0, y0, w, h)) return; +@@ -3504,35 +4604,76 @@ + /* Widescreen: tagged sprite parts squash around their projected anchor; + * untagged SPRTs are screen-space 2D (HUD/menus) and squash around the + * display centre. Texels keep full coverage via the scaled-rect path. */ +- int ws_w = 0; +- if (ws_active() && w > 0) { ++ int ws_w = 0, ws_h = 0; ++ const int menu_page = texpage_y != 0 || texpage_colors != 1 || clut_x > 1024 - 256 ? 0 : texpage_x == PSX_WS_MENU_X / 64 ? 1 : texpage_x == (PSX_WS_MENU_X + 128) / 64 ? 2 : 0; /* a palette that runs off its row is not the picture's */ ++ const int menu_shape = psx_ws_menu_backdrop_half(x0, y0, w, h) & menu_page; ++ if (ws_menu_due) { /* the front end's frame begins with its backdrop */ ++ ws_menu_due = 0; ++ if (!menu_shape) ws_menu_give_back(); ++ } ++ if (menu_shape) { ++ ws_menu_clut_x = clut_x; ++ ws_menu_clut_y = clut_y; ++ if (!ws_menu_logo) ws_menu_take_picture(); /* a save state or a read took it away, or the screen has become wide */ ++ } ++ const int menu_half = ws_menu_logo ? menu_shape : 0; ++ if (menu_half && ws_menu_wanted()) psx_ws_frame_kinds_menu_begin(&ws_frame_kinds); ++ if (ws_menu_frame()) { ++ if (!menu_half) ws_w = ws_menu_rect(&x0, w); /* the backdrop itself is stretched */ ++ } else if (ws_active() && w > 0) { + int32_t ws_ax; +- if (ws_tagged_anchor(&ws_ax)) { ++ if (ws_screen_tile_take()) { ++ /* Both edges go through one mapping, so neighbours keep touching. */ ++ const int32_t centre = ws_disp_w() / 2; ++ const int32_t right = ++ psx_ws_squash_about(x0 + w, centre, ws_xnum, ws_xden); ++ x0 = psx_ws_squash_about(x0, centre, ws_xnum, ws_xden); ++ if (right <= x0) return; ++ ws_w = (int)(right - x0); ++ } else if (ws_tagged_anchor(&ws_ax)) { + x0 = ws_scale_about(x0, ws_ax); + ws_w = (int)ws_scale_len(w); + } else { + int corrected_w = w; +- if (ws_auto_ui_transform_rect(&x0, y0, &corrected_w, h)) ++ int32_t widget_w = w, widget_h = h; ++ if (ws_hud_widget_rect(&x0, &y0, &widget_w, &widget_h)) { ++ ws_w = (int)widget_w; ++ ws_h = (int)widget_h; ++ } else if (ws_auto_ui_transform_rect(&x0, y0, &corrected_w, h)) + ws_w = corrected_w; + else if (ws_hud_sprt) { +- x0 = ws_scale_about(x0, ws_hud_pivot(x0, w)); +- ws_w = (int)ws_scale_len(w); ++ int scaled_w = w, scaled_h = h; ++ if (ws_hud_user_scale(&x0, &y0, &scaled_w, &scaled_h)) { ++ ws_w = scaled_w; ++ ws_h = scaled_h; ++ } else { ++ x0 = ws_scale_about(x0, ws_hud_pivot(x0, w)); ++ ws_w = (int)ws_scale_len(w); ++ } + } + } + } + x0 += ws_nw_hud_shift(x0, w); /* native-wide HUD corner re-anchor (no-op else) */ + + x0 += draw_offset_x; y0 += draw_offset_y; +- { +- int dw = (ws_w && ws_w != w) ? ws_w : w; +- if (draw_area_out_rect(x0, y0, dw, h)) return; +- } ++ const int dw = ws_w ? ws_w : w, dh = ws_h ? ws_h : h; ++ if (draw_area_out_rect(x0, y0, dw, dh)) return; + setup_textured_draw(color24, semi_trans, raw_texture); +- if (ws_w && ws_w != w) +- gr_draw_textured_rect_scaled(x0, y0, ws_w, h, u0, v0, u0 + w, v0 + h, ++ if (dw != w || dh != h) ++ gr_draw_textured_rect_scaled(x0, y0, dw, dh, u0, v0, u0 + w, v0 + h, + clut_x, clut_y, current_texpage()); + else + gr_draw_textured_rect(x0, y0, w, h, u0, v0, clut_x, clut_y, current_texpage()); ++ if (!menu_half) return; ++ if (ws_menu_halves & menu_half) ws_menu_halves = 0; ++ ws_menu_halves |= menu_half; ++ if (ws_menu_halves != 3) return; ++ ws_menu_halves = 0; ++ int32_t logo_left = PSX_WS_MENU_LOGO_LEFT, logo_right = PSX_WS_MENU_LOGO_RIGHT; /* where it was when the screen is not wide after all */ ++ if (ws_menu_frame()) psx_ws_menu_logo_span(PSX_WS_MENU_WIDE / 2, ws_xnum, ws_xden, &logo_left, &logo_right); ++ gr_draw_textured_rect_scaled(logo_left + draw_offset_x, PSX_WS_MENU_LOGO_TOP + draw_offset_y, logo_right - logo_left, PSX_WS_MENU_LOGO_ROWS, ++ 0, 0, PSX_WS_MENU_LOGO_WIDE, PSX_WS_MENU_LOGO_ROWS, ++ clut_x, clut_y, PSX_WS_MENU_KEPT_PAGE); + } + + /* Execute 1x1 dot (GP0 0x68-0x6B) */ +@@ -3541,6 +4682,7 @@ + uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); + int32_t x, y; + parse_vertex(gp0_cmd_buf[1], &x, &y); ++ (void)ws_menu_rect(&x, 1); + x += ws_nw_hud_shift(x, 1); + x += draw_offset_x; y += draw_offset_y; + if (draw_area_out_point(x, y)) return; +@@ -3555,13 +4697,12 @@ + int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; + int32_t x0, y0; + parse_vertex(gp0_cmd_buf[1], &x0, &y0); +- int ws_w = ws_sprt_fixed_transform(&x0, y0, 8); ++ int ws_h = 0; ++ int ws_w = ws_sprt_fixed_transform(&x0, &y0, 8, &ws_h); + x0 += ws_nw_hud_shift(x0, 8); /* native-wide HUD corner re-anchor (no-op else) */ + x0 += draw_offset_x; y0 += draw_offset_y; +- { +- int dw = (ws_w && ws_w != 8) ? ws_w : 8; +- if (draw_area_out_rect(x0, y0, dw, 8)) return; +- } ++ const int dw = ws_w ? ws_w : 8, dh = ws_h ? ws_h : 8; ++ if (draw_area_out_rect(x0, y0, dw, dh)) return; + int u0 = gp0_cmd_buf[2] & 0xFF; + int v0 = (gp0_cmd_buf[2] >> 8) & 0xFF; + uint16_t clut = (uint16_t)(gp0_cmd_buf[2] >> 16); +@@ -3569,8 +4710,8 @@ + uint16_t clut_y = (clut >> 6) & 0x1FF; + + setup_textured_draw(color24, semi_trans, raw_texture); +- if (ws_w && ws_w != 8) +- gr_draw_textured_rect_scaled(x0, y0, ws_w, 8, u0, v0, u0 + 8, v0 + 8, ++ if (dw != 8 || dh != 8) ++ gr_draw_textured_rect_scaled(x0, y0, dw, dh, u0, v0, u0 + 8, v0 + 8, + clut_x, clut_y, current_texpage()); + else + gr_draw_textured_rect(x0, y0, 8, 8, u0, v0, clut_x, clut_y, current_texpage()); +@@ -3582,11 +4723,12 @@ + uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); + int32_t x0, y0; + parse_vertex(gp0_cmd_buf[1], &x0, &y0); ++ const int w = ws_menu_rect(&x0, 8); + x0 += ws_nw_hud_shift(x0, 8); + x0 += draw_offset_x; y0 += draw_offset_y; +- if (draw_area_out_rect(x0, y0, 8, 8)) return; ++ if (draw_area_out_rect(x0, y0, w, 8)) return; + gr_set_semi_transparency(semi_trans, (int)semi_transparency); +- gr_draw_flat_rect(x0, y0, 8, 8, color); ++ gr_draw_flat_rect(x0, y0, w, 8, color); + } + + /* Execute 16x16 textured sprite (GP0 0x7C-0x7F) */ +@@ -3599,7 +4741,8 @@ + /* Never suppress MMX6 BG packets at a guessed finite-map boundary. The + * classifier cannot distinguish an authored layer entering the reveal from + * a stale ring slot; suppressing here caused the stage-start black flicker. */ +- int ws_w = ws_sprt_fixed_transform(&x0, y0, 16); ++ int ws_h = 0; ++ int ws_w = ws_sprt_fixed_transform(&x0, &y0, 16, &ws_h); + x0 += ws_nw_hud_shift(x0, 16); /* native-wide HUD corner re-anchor (no-op else) */ + x0 += draw_offset_x; y0 += draw_offset_y; + int u0 = gp0_cmd_buf[2] & 0xFF; +@@ -3608,13 +4751,11 @@ + uint16_t clut_x = (clut & 0x3F) * 16; + uint16_t clut_y = (clut >> 6) & 0x1FF; + +- { +- int dw = (ws_w && ws_w != 16) ? ws_w : 16; +- if (draw_area_out_rect(x0, y0, dw, 16)) return; +- } ++ const int dw = ws_w ? ws_w : 16, dh = ws_h ? ws_h : 16; ++ if (draw_area_out_rect(x0, y0, dw, dh)) return; + setup_textured_draw(color24, semi_trans, raw_texture); +- if (ws_w && ws_w != 16) +- gr_draw_textured_rect_scaled(x0, y0, ws_w, 16, u0, v0, u0 + 16, v0 + 16, ++ if (dw != 16 || dh != 16) ++ gr_draw_textured_rect_scaled(x0, y0, dw, dh, u0, v0, u0 + 16, v0 + 16, + clut_x, clut_y, current_texpage()); + else + gr_draw_textured_rect(x0, y0, 16, 16, u0, v0, clut_x, clut_y, current_texpage()); +@@ -3642,12 +4783,15 @@ + /* Fill ignores draw area, mask bits, and draw offset — writes directly to + * VRAM. Routed through the renderer so it also fills the hi-res + * supersampling mirror (no-op cost when supersampling is off). */ ++ ws_menu_yield((int)dst_x, (int)dst_y, (int)width, (int)height, 1); + gr_fill_rect((int)dst_x, (int)dst_y, (int)width, (int)height, color16); + +- /* Native-wide: when the game clears a display buffer, clear the full width +- * of that buffer's wide surface over the same rows — refreshing the centred +- * content region and keeping the revealed margins clean. */ +- if (ws_native_wide_active() && ws_is_fb_base(dst_x)) { ++ /* Keep every active presentation mirror in lockstep with the canonical ++ * framebuffer clear. The exact-geometry mirror is also used at authentic ++ * 4:3, where this file's ws_engaged() is deliberately false; gating it on ++ * wide mode left prior frames resident and produced persistent trails. */ ++ if ((ws_native_wide_active() || ws_geometry_correction) && ++ ws_is_fb_base(dst_x)) { + gr_wide_clear((int)dst_x, (int)dst_y, (int)height, color16); + } + } +@@ -3682,6 +4826,11 @@ + gr_set_texture_window(texture_window_value); + } + ++/* The game may draw anywhere in its drawing area. */ ++static void ws_menu_yield_drawing_area(void) { ++ ws_menu_yield((int)draw_area_left, (int)draw_area_top, (int)draw_area_right - (int)draw_area_left + 1, (int)draw_area_bottom - (int)draw_area_top + 1, 1); ++} ++ + static void gp0_exec_draw_area_tl(void) { + /* 0xE3 — Set Drawing Area Top-Left + * Bits 0-9: X +@@ -3691,7 +4840,12 @@ + draw_area_top = (param >> 10) & 0x3FF; + gr_set_draw_area((int)draw_area_left, (int)draw_area_top, + (int)draw_area_right, (int)draw_area_bottom); ++ psx_ws_hud_widgets_frame(&ws_hud); + ws_nw_sync_target(); /* back buffer (draw_area_left) → wide mirror surface */ ++ ws_menu_due = 1; ++ ws_menu_halves = 0; ++ ws_menu_yield_drawing_area(); ++ psx_ws_frame_kinds_area(&ws_frame_kinds, draw_area_left, draw_area_top, WS_GTE_GAME_MODE_MIN_VERTS); + } + + static void gp0_exec_draw_area_br(void) { +@@ -3703,6 +4857,7 @@ + draw_area_bottom = (param >> 10) & 0x3FF; + gr_set_draw_area((int)draw_area_left, (int)draw_area_top, + (int)draw_area_right, (int)draw_area_bottom); ++ ws_menu_yield_drawing_area(); + ws_nw_sync_target(); /* back buffer (draw_area_left) → wide mirror surface */ + } + +@@ -3799,7 +4954,10 @@ + /* 0 means max dimension */ + vram_write_w = (w == 0) ? 0x400 : (uint16_t)w; + vram_write_h = (h == 0) ? 0x200 : (uint16_t)h; +- ++ if (!psx_ws_menu_is_picture(vram_write_x, vram_write_y, vram_write_w, vram_write_h)) /* the whole picture is dealt with once it has arrived */ ++ ws_menu_yield(vram_write_x, vram_write_y, vram_write_w, vram_write_h, 1); /* before its words land */ ++ ++ ws_frame_pictured(vram_write_x, vram_write_y, vram_write_w, vram_write_h); + /* Record for debug */ + if (a0_history_count < A0_HISTORY_CAP) { + int slot = a0_history_count++; +@@ -3879,6 +5037,7 @@ + uint32_t h = (gp0_cmd_buf[2] >> 16) & 0x1FFu; + vram_read_w = (w == 0) ? 0x400 : (uint16_t)w; + vram_read_h = (h == 0) ? 0x200 : (uint16_t)h; ++ ws_menu_yield(vram_read_x, vram_read_y, vram_read_w, vram_read_h, 0); + + /* Record for debug */ + if (c0_history_count < C0_HISTORY_CAP) { +@@ -4047,7 +5206,7 @@ + item->group.x = min_x; + item->group.width = width; + item->group.anchor = 0; +- item->src_addr = source_addr & 0x1FFFFCu; ++ item->src_addr = psx_mod_gpu_dma_resolve_address(source_addr); + item->ot_rank = rank; + } + +@@ -4397,6 +5556,10 @@ + extern void ws_bg_phase_note(uint32_t op); + ws_bg_phase_note(opcode); /* native-wide 2D-backdrop stretch: background-phase latch */ + ++ if (ws_menu_due && opcode >= 0x20 && opcode <= 0x7F && (opcode & 0xFC) != 0x64) { /* a frame that begins with anything but a textured rectangle is not the front end's */ ++ ws_menu_due = 0; ++ ws_menu_give_back(); ++ } + /* Draw-census: capture every drawing primitive's first vertex + camera. */ + if (opcode >= 0x20 && opcode <= 0x7F) { + int32_t cvx, cvy; +@@ -4499,7 +5662,8 @@ + int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; + int32_t x0, y0; + parse_vertex(gp0_cmd_buf[1], &x0, &y0); +- (void)ws_sprt_fixed_transform(&x0, y0, 1); /* position only; 1px stays 1px */ ++ int dot_h; ++ (void)ws_sprt_fixed_transform(&x0, &y0, 1, &dot_h); /* position only; 1px stays 1px */ + x0 += draw_offset_x; y0 += draw_offset_y; + int u0 = gp0_cmd_buf[2] & 0xFF; + int v0 = (gp0_cmd_buf[2] >> 8) & 0xFF; +@@ -4522,9 +5686,10 @@ + uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); + int32_t x0, y0; + parse_vertex(gp0_cmd_buf[1], &x0, &y0); ++ const int w = ws_menu_rect(&x0, 16); + x0 += draw_offset_x; y0 += draw_offset_y; + gr_set_semi_transparency(semi_trans, (int)semi_transparency); +- gr_draw_flat_rect(x0, y0, 16, 16, color); ++ gr_draw_flat_rect(x0, y0, w, 16, color); + break; + } + case 0x7C: case 0x7D: case 0x7E: case 0x7F: +@@ -4549,7 +5714,10 @@ + int h = (gp0_cmd_buf[3] >> 16) & 0x1FF; + if (w == 0) w = 0x400; + if (h == 0) h = 0x200; ++ ws_menu_yield(src_x, src_y, w, h, 0); ++ ws_menu_yield(dst_x, dst_y, w, h, 1); + gr_copy_rect(src_x, src_y, dst_x, dst_y, w, h); ++ ws_frame_pictured(dst_x, dst_y, w, h); + break; + } + +@@ -4901,7 +6069,11 @@ + hres1 = val & 3; + vres = (val >> 2) & 1; + video_mode = (val >> 3) & 1; +- display_depth = (val >> 4) & 1; ++ const uint32_t depth = (val >> 4) & 1; ++ /* A 24-bit scanout reads the CPU copy of VRAM, and a GPU backend leaves that copy without what it drew. The game ++ * blacks out its movie's area with a rectangle a few VBlanks before it switches, so sync once, at the switch. */ ++ if (depth && !display_depth) (void)gr_vram_read(0, 0); ++ display_depth = depth; + vertical_interlace = (val >> 5) & 1; + /* GPUSTAT.13 holds the legacy constant 0 in progressive (see the vblank + * field flip); clear it on the switch so a title that toggles interlace +@@ -5087,11 +6259,16 @@ + } + void gpu_snapshot_write(uint8_t *p) { + PstW w; uint32_t n = gpu_snapshot_bytes(); ++ ws_menu_give_back(); /* a save state reads video memory from the renderer after this, and holds the game's alone */ + pst_w_init(&w, p, n); + (void)gpu_snap_emit(&w); + } + uint32_t gpu_cosim_snapshot_bytes(void) { return gpu_snapshot_bytes(); } +-void gpu_cosim_snapshot_write(uint8_t *p) { gpu_snapshot_write(p); } ++void gpu_cosim_snapshot_write(uint8_t *p) { /* a hash of the registers between blocks, not a save state: nothing is given back for it */ ++ PstW w; ++ pst_w_init(&w, p, gpu_snapshot_bytes()); ++ (void)gpu_snap_emit(&w); ++} + void gpu_cosim_dump(char *out, int cap) { + if (!out || cap <= 0) return; + char *p = out; +@@ -5136,10 +6313,14 @@ + if (len != gpu_snapshot_bytes()) return 0; + pst_r_init(&r, p, len); + if (!gpu_snap_parse(&r)) return 0; ++ ws_menu_lent = ws_menu_rows = ws_menu_logo = ws_menu_fresh = 0; /* the loaded video memory holds nothing of the wide menu's, and the CPU copy is not it */ + /* Sync renderer clip/scissor to restored GP0(E3/E4); vars alone leave GL +- * on a stale draw area after savestate load. */ ++ * on stale drawing state after savestate load. The offset also selects the ++ * correct framebuffer-relative horizon for optional full-Y yaw. */ + gr_set_draw_area((int)draw_area_left, (int)draw_area_top, + (int)draw_area_right, (int)draw_area_bottom); ++ gr_set_draw_offset(draw_offset_x, draw_offset_y); ++ geometry_camera_presentation_update(); + ws_nw_sync_target(); + return 1; + } diff --git a/framework/patches/runtime/src/gpu_gl_renderer.c.patch b/framework/patches/runtime/src/gpu_gl_renderer.c.patch new file mode 100644 index 0000000..df656f7 --- /dev/null +++ b/framework/patches/runtime/src/gpu_gl_renderer.c.patch @@ -0,0 +1,2640 @@ +diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c +index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4fbc378aee 100644 +--- a/runtime/src/gpu_gl_renderer.c ++++ b/runtime/src/gpu_gl_renderer.c +@@ -67,6 +67,9 @@ + #include "gpu_render.h" + #include "gpu_sw_renderer.h" + #include "gpu_gl_renderer.h" ++#include "gpu_turned_perspective.h" ++#include "gpu_world_depth.h" ++#include "host_ui.h" + #include "latency_ring.h" + + #include "psx_sdl.h" +@@ -76,6 +79,7 @@ + #include + #endif + #include ++#include + #include + #include + #include +@@ -121,7 +125,7 @@ + + #define VRAM_W 1024 + #define VRAM_H 512 +-#define GL_MAX_INTERNAL_SCALE 4 ++#define GL_MAX_INTERNAL_SCALE SW_MAX_INTERNAL_SCALE + + /* ---- Loaded modern-GL entry points ------------------------------------- */ + typedef GLuint (APIENTRY *PFN_glCreateShader)(GLenum); +@@ -245,6 +249,7 @@ + static int s_ws_ablate = 0; + static void flush_tex_batch(void); /* textured-prim batch — defined below, flushed from coherency points */ + static void flush_flat_batch(void); /* flat/gouraud GEO batch (MotK starfield 0x68 dots) */ ++static int resize_gpu_raster(int scale); /* live internal-resolution rebuild */ + static PFN_glGenRenderbuffers p_glGenRenderbuffers; + static PFN_glDeleteRenderbuffers p_glDeleteRenderbuffers; + static PFN_glBindRenderbuffer p_glBindRenderbuffer; +@@ -273,9 +278,11 @@ + LOAD(p_glVertexAttribPointer, "glVertexAttribPointer"); + LOAD(p_glEnableVertexAttribArray, "glEnableVertexAttribArray"); + LOAD(p_glBindFragDataLocationIndexed, "glBindFragDataLocationIndexed"); ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + LOAD(p_glFenceSync, "glFenceSync"); + LOAD(p_glWaitSync, "glWaitSync"); + LOAD(p_glDeleteSync, "glDeleteSync"); ++#endif + LOAD(p_glGenFramebuffers, "glGenFramebuffers"); LOAD(p_glBindFramebuffer, "glBindFramebuffer"); + LOAD(p_glDeleteFramebuffers, "glDeleteFramebuffers"); + LOAD(p_glFramebufferTexture2D, "glFramebufferTexture2D"); +@@ -312,6 +319,18 @@ + static int s_present_w = 0, s_present_h = 0; + static GLuint s_present_prog = 0, s_present_vao = 0; + static GLint s_present_uTex = -1, s_present_uUvRect = -1; ++/* Experimental ambient occlusion: the display mirror keeps the depth the painter's order leaves, the present pass shades by it. */ ++static int s_ssao = 0; ++/* What s_ssao is made of: the player's switch, and a layer shown in the picture's place (1 shading, 2 depth, 3 normals, 4 fade). */ ++static int s_ssao_on = 0, s_ssao_view = 0; ++static float s_ssao_radius = 160.0f, s_ssao_strength = 0.8f; ++static float s_ssao_tilt = 0.35f; /* the sine of the angle under which a joint between two faces is no corner: 20 degrees */ ++static GLuint s_ssao_prog = 0, s_ssao_mix_prog = 0; ++static GLint s_ssao_uDepth = -1, s_ssao_uTex = -1, s_ssao_uUvRect = -1, s_ssao_uTexel = -1, s_ssao_uProj = -1, s_ssao_uAo = -1, ++ s_ssao_uFade = -1; ++static float s_ssao_fade_from = 0.0f, s_ssao_fade_to = 0.0f; ++static GLint s_ssao_mix_uTex = -1, s_ssao_mix_uShade = -1, s_ssao_mix_uUvRect = -1, s_ssao_mix_uSpan = -1; ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + static GLuint s_interp_prog = 0, s_interp_tex[3]; + static GLsync s_interp_fence[3]; + static GLsync s_interp_draw_fence = NULL; +@@ -320,9 +339,11 @@ + static GLint s_interp_uBlendMode = -1; + static int s_interp_enabled = 0, s_interp_valid = 0; + static int s_interp_suspended = 0; ++static int s_interp_content_suspended = 0; ++static int s_interp_host_ui_suspended = 0; + static int s_interp_blend_mode = 0; + static int s_interp_prev = 0, s_interp_cur = 0; +-static int s_interp_w = 0, s_interp_h = 0, s_interp_linear = 0; ++static int s_interp_w = 0, s_interp_h = 0, s_interp_linear = 0, s_interp_scale = 1; + static int s_interp_force_4_3 = 0, s_interp_source_path = -1; + static uint64_t s_interp_start = 0, s_interp_duration = 1; + static uint64_t s_interp_last_capture = 0, s_interp_swaps = 0; +@@ -335,12 +356,62 @@ + static SDL_mutex *s_interp_mutex = NULL; + static SDL_atomic_t s_interp_thread_run; + static GLuint s_interp_thread_vao = 0; ++static void interp_reset_history_unlocked(void); + static void interp_reset_history(void); + static int interp_thread_main(void *opaque); + static int interp_present(void); +-static void interp_draw_quad(float alpha, int lx, int ly, int lw, int lh); ++static void shown_capture(int kind, float alpha); ++int gl_renderer_record_shown(const char *folder, int skip, int count, int width); ++static float interp_draw_quad(float alpha, int lx, int ly, int lw, int lh); ++static uint64_t s_temporal_raw_generation = 1; ++static void temporal_reset(void); ++static void temporal_release(void); ++static void temporal_begin(GLuint fbo, int y, int h, uint16_t color); ++static void temporal_invalidate_surface(GLuint fbo); ++static void temporal_draw(const float *vertices, const uint64_t *identities, int n, ++ int textured, GLenum mode, int semi, int mask, int filter, ++ const int *twin, int gate, int direct); ++#define temporal_note_raw_change() (++s_temporal_raw_generation) ++#else ++#define s_interp_enabled 0 ++#define interp_reset_history() ((void)0) ++#define temporal_note_raw_change() ((void)0) ++#define temporal_begin(...) ((void)0) ++#define temporal_invalidate_surface(...) ((void)0) ++#define temporal_draw(...) ((void)0) ++#define interp_capture(...) 0 ++#endif + + static int s_raster_ok = 0; /* full GPU pipeline available */ ++ ++/* Presentation-only geometry correction state. Precise vertices are queued by ++ * gpu.c immediately before a polygon draw. Every GL vertex carries the exact ++ * integer GP0 position separately from its optional precise position; the ++ * canonical pass always consumes the former, while only the independent ++ * display mirror may consume the latter and the fractional camera yaw. */ ++static int s_geometry_correction = 0; ++static int s_next_world = 0; ++static int s_next_precise = 0; ++static int32_t s_next_x16[3], s_next_y16[3]; ++static int s_next_unpinned = 0; ++static int32_t s_next_unpinned_x16[3], s_next_unpinned_y16[3]; ++static int s_next_perspective = 0; ++static float s_next_q[3] = { 0.0f, 0.0f, 0.0f }; ++static int s_next_temporal_depth = 0; ++static float s_next_z[3] = { 0.0f, 0.0f, 0.0f }; ++static float s_next_surface_z[3] = { 0.0f, 0.0f, 0.0f }; /* see gr_set_surface_depth_triangle */ ++static uint64_t s_next_identity[3] = { 0u, 0u, 0u }; ++#define TEMPORAL_SPRITE_IDENTITY UINT64_C(0x4000000000000000) ++#define TEMPORAL_PARTIAL_IDENTITY UINT64_C(0x2000000000000000) /* a world polygon with a corner off its projection */ ++static float s_next_sprite_depth = 0.0f, s_sprite_depth = 0.0f; ++static float s_next_sprite_sides[4], s_sprite_sides[4]; /* left, top, right, bottom */ ++static int s_sprite_x, s_sprite_y; ++static float s_yaw_sin = 0.0f, s_yaw_cos = 1.0f; ++static float s_yaw_center_x = 160.0f; ++static float s_yaw_center_y_relative = 120.0f; ++static float s_yaw_focal_x = 160.0f; ++static int s_geometry_full_yaw = 0; ++static int s_geometry_coverage_tint = 0; + + /* Authoritative VRAM: hr color texture + stencil (mask bit) FBO. */ + static GLuint s_hr_tex = 0, s_hr_fbo = 0, s_hr_rb = 0; +@@ -354,9 +425,10 @@ + /* Programs. */ + static GLuint s_geo_prog = 0, s_geo_vao = 0, s_geo_vbo = 0; + static GLuint s_tex_prog = 0, s_tex_vao = 0, s_tex_vbo = 0; +-/* Textured vertex: pos(2) uv(2) col(4) tpage(2) clut(2) depth(1) raw(1) limits(4) +- * — per-prim texture state in flat attributes so prims batch (see flush_tex_batch). */ +-#define TEXV 19 ++/* Vertex formats retain both canonical GP0 and presentation-only positions. ++ * Per-primitive texture state stays in flat attributes so prims can batch. */ ++#define GEOV 10 ++#define TEXV 24 + static GLuint s_blit_prog = 0, s_blit_vao = 0, s_blit_vbo = 0; + static GLuint s_pack_prog = 0, s_stencil_prog = 0, s_empty_vao = 0; + +@@ -379,6 +451,12 @@ + * prim each frame). 1.0 / 0.0 => no-op, so the canonical pass stays identical. */ + static GLint s_geo_uXscale = -1, s_geo_uXcenter = -1; + static GLint s_tex_uXscale = -1, s_tex_uXcenter = -1; ++/* Presentation-mirror-only subpixel/yaw uniforms. The canonical pass keeps ++ * u_visual=0 and reads the explicit GP0-position attribute. World/exact ++ * classification is carried per vertex so sprites and HUD rectangles remain ++ * fixed and the optional exact-coverage tint cannot affect canonical output. */ ++static GLint s_geo_uVisual = -1, s_geo_uCamera = -1, s_geo_uVisualOptions = -1; ++static GLint s_tex_uVisual = -1, s_tex_uCamera = -1, s_tex_uVisualOptions = -1; + /* Runtime controls (ws_backdrop_stretch debug command). */ + int g_ws_bd_stretch_on = 1; /* feature on (gated by native-wide + per-prim gate) */ + int g_ws_bd_stretch_pct = 0; /* 0 = auto (g_wide_w/native_w); else pct/100 */ +@@ -475,6 +553,7 @@ + #define WIDE_MAX_SURF 4 + static GLuint s_wide_tex[WIDE_MAX_SURF]; /* color tex per surface (0 = free) */ + static GLuint s_wide_fbo[WIDE_MAX_SURF]; /* FBO per surface */ ++static uint32_t s_wide_depth_shown[WIDE_MAX_SURF] = {~0u, ~0u, ~0u, ~0u}; /* rows shown since their depth was last cleared */ + static GLuint s_wide_rb[WIDE_MAX_SURF]; /* depth-stencil RB per surface (mask + * mirror, same as hr). PERF-CRITICAL: + * a stencil-less wide FBO made every +@@ -495,6 +574,95 @@ + /* X-translation (native px) from canonical VRAM space into the active wide + * surface: local_x = vram_x - base_x + OFFSET. Same as SW wide_dx(). */ + static inline int wide_dx(void) { return g_wide_off - g_wide_cur_base; } ++ ++/* A filler closes gaps between polygons of a textured batch. It is drawn before its batch, colour only: the batch's opaque ++ * fragments replace it and its blended ones blend over it. Over earlier batches it lands only where their depth is farther: ++ * the game's grown second copy of a near polygon (visual flag 4) by its own depth, and the outline of every exact world ++ * polygon (s_crack_fill native pixels wide, 0 for none) only where no depth was left at all. */ ++static float s_crack_fill = 0.5f, s_crack_width = 0.5f; ++static float s_line_width_max = 1.0f; /* the widest line this context draws */ ++static int s_mesh_view = 0; /* diagnostic: in-between frames draw their triangles' edges over the picture */ ++static int s_grown_under = 1; ++static int s_tb_fillers = 0; /* grown copies in the open batch */ ++static int s_draw_filler = 0; /* the batch being recorded for the in-between presenter holds grown copies */ ++/* What the mirror draws of a batch: 0 its polygons, between 0 and 1 its grown copies pushed back by that share, 2 the outlines, ++ * -1 all of it as it comes. */ ++static float s_fill_pass = 0.0f; ++#define GROWN_COPY_MARGIN 0.03f ++ ++/* Experimental: exact world polygons are drawn by depth, not by the game's order alone. A batch of them first writes the ++ * depth of its opaque fragments, then draws its colours in the game's order where they lie within s_world_margin of the ++ * nearest depth: polygons in one plane, whose depths differ by rounding, keep the game's order, and a polygon behind ++ * another by more is not drawn over it. Measured between a coarse wall quad and the pieces the game draws over it: up to ++ * 0.1 percent. The in-between presenter gives a departed face the same clearance (TEMP_ORDER_CLEARANCE). */ ++static int s_world_depth = 0; ++static float s_world_margin = 0.03f; ++static int s_tb_world = 0; /* the open batch holds only such polygons */ ++static int s_draw_world = 0; /* the batch being recorded for the in-between presenter is such a batch */ ++static GLint s_tex_uPull = -1; ++ ++static int mirror_depth(void) { return s_geometry_correction && (s_ssao || s_grown_under || s_crack_fill > 0.0f || s_world_depth); } ++ ++static void geometry_visual_uniforms(GLint visual, GLint camera, GLint options, ++ int enabled) { ++ const float on = enabled && s_geometry_correction ? 1.0f : 0.0f; ++ p_glUniform1f(visual, on); ++ p_glUniform4f(camera, s_yaw_sin, s_yaw_cos, ++ (float)g_wide_cur_base + s_yaw_center_x, ++ (float)s_off_y + s_yaw_center_y_relative); ++ p_glUniform4f(options, s_yaw_focal_x, ++ s_geometry_full_yaw ? 1.0f : 0.0f, ++ on && s_geometry_coverage_tint ? 0.55f : 0.0f, on > 0.0f ? s_fill_pass : 0.0f); ++} ++ ++/* Capture the one-shot metadata queued by gpu.c for the next polygon. `xs/ys` ++ * remain the exact GP0 coordinates used for canonical dirty tracking and ++ * guest-visible rasterization. The returned floats are consumed only by the ++ * presentation mirror when the shader's u_visual switch is set. The returned ++ * flags are bit 0 = world, bit 1 = exact precise provenance and bit 2 = the game's grown copy, a filler. */ ++static int take_visual_triangle(const int *xs, const int *ys, ++ float px[3], float py[3], float pq[3], ++ float pz[3], uint64_t pid[3]) { ++ const int world = s_geometry_correction && s_next_world; ++ const int exact = world && s_next_precise; ++ const int perspective = exact && s_next_perspective; ++ const int partial = !exact && s_next_temporal_depth == 2; ++ const int unpinned = !exact && s_geometry_correction && s_next_unpinned; ++ const int sprite = !world && s_geometry_correction && s_sprite_depth > 0.0f; ++ for (int i = 0; i < 3; ++i) { ++ if (exact) { ++ px[i] = (float)((double)s_next_x16[i] / 65536.0); ++ py[i] = (float)((double)s_next_y16[i] / 65536.0); ++ } else if (unpinned) { ++ px[i] = (float)((double)s_next_unpinned_x16[i] / 65536.0); ++ py[i] = (float)((double)s_next_unpinned_y16[i] / 65536.0); ++ } else { ++ px[i] = (float)xs[i] + (sprite ? s_sprite_sides[xs[i] == s_sprite_x ? 0 : 2] : 0.0f); ++ py[i] = (float)ys[i] + (sprite ? s_sprite_sides[ys[i] == s_sprite_y ? 1 : 3] : 0.0f); ++ } ++ if (pq) pq[i] = perspective ? s_next_q[i] : 0.0f; ++ if (pz) pz[i] = (exact && s_next_temporal_depth) || partial ? s_next_z[i] : s_sprite_depth; ++ /* A corner the presenter has no depth for, the mirror has: negative, so that only the mirror's shaders take it. */ ++ if (pz && (exact || partial) && !(pz[i] > 0.0f) && s_next_surface_z[i] > 0.0f) pz[i] = -s_next_surface_z[i]; ++ s_next_surface_z[i] = 0.0f; ++ if (pid) pid[i] = exact && s_next_temporal_depth ? s_next_identity[i] ++ : partial ? TEMPORAL_PARTIAL_IDENTITY ++ : s_sprite_depth > 0.0f ? TEMPORAL_SPRITE_IDENTITY : 0u; ++ s_next_identity[i] = 0u; ++ } ++ const int filler = s_grown_under && exact && s_next_world == 2; ++ s_next_world = 0; ++ s_next_precise = 0; ++ s_next_unpinned = 0; ++ s_next_perspective = 0; ++ s_next_temporal_depth = 0; ++ return (world || sprite || unpinned ? 1 : 0) | (exact ? 2 : 0) | (filler ? 4 : 0); ++} ++ ++static void turned_perspective(const float px[3], float pq[3], int visual_flags) { ++ if (visual_flags & 2) ++ psx_turned_perspective(px, pq, s_yaw_sin, s_yaw_cos, (float)g_wide_cur_base + s_yaw_center_x, s_yaw_focal_x); ++} + + /* ---- dirty-rect helpers ------------------------------------------------- */ + static void rect_clear(DirtyRect *r) { r->set = 0; } +@@ -742,6 +910,94 @@ + "#version 330\n" + "in vec2 v_uv; uniform sampler2D u_tex; out vec4 frag;\n" + "void main(){ frag = texture(u_tex, v_uv); }\n"; ++/* Scalable ambient obscurance over the mirror's depth, one value a screen pixel. A pixel without camera depth (HUD, backdrop, 2D) ++ * is neither shaded nor an occluder. Red is the light left, green a coarse code of the depth for the blur, 0 for none. */ ++static const char *SSAO_FS = ++ "#version 330\n" ++ "in vec2 v_uv; out vec4 frag;\n" ++ "uniform sampler2D u_depth, u_tex;\n" ++ "uniform vec4 u_span; /* one texel of the surface in x and y, the view asked for */\n" ++ "#define u_texel u_span.xy\n" ++ "uniform vec4 u_proj; /* focal x, focal y, centre x, centre y, in texels */\n" ++ "uniform vec4 u_ao; /* radius in depth units, strength, widest reach in texels, one native pixel in texels */\n" ++ "uniform vec4 u_fade; /* the depths between which the game fades the world into its far colour (0 0 for none), s_ssao_tilt */\n" ++ "float cam(vec2 uv){ float z = texture(u_depth, uv).r; return (z < 0.00002 || z > 0.99998) ? 0.0 : 16.0/(1.0-z); }\n" ++ "/* The depth of a surface to shade by: none where a blended fragment or one without a whole depth is marked. */\n" ++ "float surf(vec2 uv){\n" ++ " float mark = texelFetch(u_tex, clamp(ivec2(uv*vec2(textureSize(u_tex, 0))), ivec2(0), textureSize(u_tex, 0) - 1), 0).a;\n" ++ " return (mark > 0.2 && mark < 0.3) ? 0.0 : cam(uv); }\n" ++ "/* The same for a pixel's normal: a sprite beside it, or a sliver of a polygon drawn on the game's own pixels, is not its surface. */\n" ++ "float flat_surf(vec2 uv){\n" ++ " float mark = texelFetch(u_tex, clamp(ivec2(uv*vec2(textureSize(u_tex, 0))), ivec2(0), textureSize(u_tex, 0) - 1), 0).a;\n" ++ " return (mark > 0.2 && mark < 0.6) ? 0.0 : cam(uv); }\n" ++ "vec3 at(vec2 uv, float d){ return vec3((uv/u_texel - u_proj.zw)/u_proj.xy*d, d); }\n" ++ "vec3 edge(vec2 step, vec3 p, float d){\n" ++ " float a = flat_surf(v_uv - step), b = flat_surf(v_uv + step);\n" ++ " if (a == 0.0 && b == 0.0) return vec3(0.0);\n" ++ " return (b == 0.0 || (a != 0.0 && abs(a-d) < abs(b-d))) ? p - at(v_uv - step, a) : at(v_uv + step, b) - p; }\n" ++ "/* A hairline between two polygons shows what lies behind them, or a polygon an in-between frame holds in place under them:\n" ++ " * where the depths half a pixel or a pixel to both sides agree and this one does not, it is theirs. */\n" ++ "float mend(float d){\n" ++ " if (d == 0.0) return d;\n" ++ " for (int i = 0; i < 4; ++i) {\n" ++ " vec2 step = (i < 2 ? vec2(u_texel.x, 0.0) : vec2(0.0, u_texel.y))*u_ao.w*((i & 1) == 0 ? 0.5 : 1.0);\n" ++ " float a = flat_surf(v_uv - step), b = flat_surf(v_uv + step);\n" ++ " if (a != 0.0 && b != 0.0 && abs(a - b) < 0.03*min(a, b) + 1.0 && abs(d - 0.5*(a + b)) > 0.04*d + 4.0) return 0.5*(a + b);\n" ++ " }\n" ++ " return d; }\n" ++ "void main(){\n" ++ " float d = mend(cam(v_uv));\n" ++ " frag = vec4(1.0, 0.0, 0.0, 1.0);\n" ++ " if (d == 0.0) return;\n" ++ " frag.g = clamp(log2(d)/16.0, 1.0/255.0, 1.0);\n" ++ " float clear = u_fade.y > u_fade.x ? 1.0 - clamp((d - u_fade.x)/(u_fade.y - u_fade.x), 0.0, 1.0) : 1.0;\n" ++ " if (u_span.z > 4.5) { frag.rb = vec2(clear); return; }\n" ++ " if (clear <= 0.0) return;\n" ++ " if (u_span.z > 2.5 && u_span.z < 3.5) { frag.rb = vec2(fract(d/256.0), d/4096.0); return; }\n" ++ " vec3 p = at(v_uv, d);\n" ++ " vec3 n = cross(edge(vec2(0.0, u_ao.w*u_texel.y), p, d), edge(vec2(u_ao.w*u_texel.x, 0.0), p, d));\n" ++ " if (dot(n, n) < 1e-12) return;\n" ++ " n = normalize(n); if (dot(n, p) > 0.0) n = -n;\n" ++ " float mark = texelFetch(u_tex, ivec2(v_uv*vec2(textureSize(u_tex, 0))), 0).a;\n" ++ " if (mark > 0.2 && mark < 0.6 && u_span.z < 3.5) return; /* a sprite stands in the floor it is drawn over, and under a blended fragment the depth is not its own */\n" ++ " if (u_span.z > 3.5) { frag = vec4(n*0.5 + 0.5, 1.0); return; }\n" ++ " float reach = min(u_ao.x*u_proj.x/d, u_ao.z);\n" ++ " if (reach < u_ao.w) return;\n" ++ " float turn = 6.2831853*fract(52.9829189*fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));\n" ++ " float r2 = u_ao.x*u_ao.x, sum = 0.0;\n" ++ " for (int i = 0; i < 16; ++i) {\n" ++ " float f = (float(i)+0.5)/16.0, angle = turn + f*6.2831853*7.0;\n" ++ " vec2 uv = v_uv + vec2(cos(angle), sin(angle))*sqrt(f)*reach*u_texel;\n" ++ " float ds = surf(uv);\n" ++ " if (ds == 0.0) continue;\n" ++ " vec3 v = at(uv, ds) - p;\n" ++ " float vv = dot(v, v), fall = max(r2 - vv, 0.0);\n" ++ " sum += fall*fall*fall*max(((dot(v, n) - u_fade.z*sqrt(vv))/(1.0 - u_fade.z) - 0.012*u_ao.x - 0.0015*d)/(vv + 0.0001*r2), 0.0);\n" ++ " }\n" ++ " frag.r = mix(1.0, max(0.4, 1.0 - u_ao.y*5.0*u_ao.x*sum/(r2*r2*r2*16.0)), clear); }\n"; ++/* The frame with the shading of the pass above, averaged over the 4x4 pixels around that lie at the pixel's own depth. */ ++static const char *SSAO_MIX_FS = ++ "#version 330\n" ++ "in vec2 v_uv; out vec4 frag;\n" ++ "uniform sampler2D u_tex, u_shade;\n" ++ "uniform vec4 u_span; /* x, y of the picture's corner on screen, the view asked for */\n" ++ "void main(){\n" ++ " ivec2 at = ivec2(gl_FragCoord.xy - u_span.xy), most = textureSize(u_shade, 0) - 1;\n" ++ " vec4 here = texelFetch(u_shade, clamp(at, ivec2(0), most), 0);\n" ++ " vec3 c = texture(u_tex, v_uv).rgb;\n" ++ " if (u_span.z > 4.5) { frag = vec4(here.g == 0.0 ? vec3(0.0, 0.0, 0.25) : vec3(here.r), 1.0); return; }\n" ++ " if (u_span.z > 2.5) { frag = vec4(here.g == 0.0 ? vec3(0.0, 0.0, 0.25) : here.rgb, 1.0); return; }\n" ++ " frag = vec4(u_span.z > 1.5 ? vec3(0.0, 0.0, 0.25) : c, 1.0);\n" ++ " if (here.g == 0.0) return;\n" ++ " float lit = 0.0, count = 0.0;\n" ++ " for (int y = -2; y < 2; ++y) for (int x = -2; x < 2; ++x) {\n" ++ " vec4 near = texelFetch(u_shade, clamp(at + ivec2(x, y), ivec2(0), most), 0);\n" ++ " if (near.g == 0.0 || abs(near.g - here.g) > 2.5/255.0) continue;\n" ++ " lit += near.r; count += 1.0;\n" ++ " }\n" ++ " lit = count > 0.0 ? lit/count : here.r;\n" ++ " frag = vec4(u_span.z > 1.5 ? vec3(lit) : c*lit, 1.0); }\n"; ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + static const char *INTERP_FS = + "#version 330\n" + "in vec2 v_uv; uniform sampler2D u_prev; uniform sampler2D u_curr;\n" +@@ -757,6 +1013,7 @@ + " }\n" + " frag=mix(prev,curr,alpha);\n" + "}\n"; ++#endif + + /* Geometry: position in VRAM pixels (draw offset already applied by gpu.c), + * color rgb in 0..1, color a = mask bit (0/1). The clip transform is in +@@ -775,24 +1032,41 @@ + "#version 330\n" + "layout(location=0) in vec2 a_pos;\n" + "layout(location=1) in vec4 a_col;\n" ++ "layout(location=2) in float a_visual_flags;\n" ++ "layout(location=3) in vec2 a_visual_pos;\n" + "uniform float u_shift;\n" + "uniform float u_xoff; /* native-wide x translation (px); 0 canonical */\n" + "uniform float u_xhalf; /* x clip half-extent (px); 512 canonical */\n" + "uniform float u_xscale; /* native-wide 2D-backdrop x-stretch; 1 canonical */\n" + "uniform float u_xcenter;/* stretch centre in VRAM px; 0 canonical */\n" +- "noperspective out vec4 v_col;\n" ++ "uniform float u_visual; /* 1 only for the independent display mirror */\n" ++ "uniform vec4 u_camera; /* sin(yaw), cos(yaw), centre-x, centre-y */\n" ++ "uniform vec4 u_visual_options; /* focal-x, full-yaw, tint-strength, unused */\n" ++ "noperspective out vec4 v_col; noperspective out float v_tint;\n" + "void main(){ v_col = a_col;\n" +- " float xb = a_pos.x;\n" ++ " bool corrected = u_visual > 0.5 && a_visual_flags > 0.5;\n" ++ " vec2 p = corrected ? a_visual_pos : a_pos;\n" ++ " float xb = p.x, yb = p.y;\n" ++ " v_tint = (u_visual > 0.5 && a_visual_flags > 1.5) ? u_visual_options.z : 0.0;\n" ++ " if (corrected && abs(u_camera.x) > 0.0000001 && u_visual_options.x > 0.0) {\n" ++ " float ux = (xb-u_camera.z)/u_visual_options.x;\n" ++ " float den = u_camera.y-ux*u_camera.x;\n" ++ " if (abs(den) > 0.01) {\n" ++ " xb = u_camera.z + u_visual_options.x*((ux*u_camera.y+u_camera.x)/den);\n" ++ " if (u_visual_options.y > 0.5) yb = u_camera.w + (yb-u_camera.w)/den;\n" ++ " }\n" ++ " }\n" + " if (u_xscale < 0.0) {\n" + " float s = -u_xscale; float h = u_xhalf / s;\n" + " float l = u_xcenter - h, r = u_xcenter + h;\n" + " if (xb < l) xb = l + (xb-l)*s; else if (xb > r) xb = r + (xb-r)*s;\n" + " } else xb = (xb - u_xcenter)*u_xscale + u_xcenter;\n" +- " gl_Position = vec4((xb+u_shift+u_xoff)/u_xhalf - 1.0, (a_pos.y+u_shift)/256.0 - 1.0, 0.0, 1.0); }\n"; ++ " gl_Position = vec4((xb+u_shift+u_xoff)/u_xhalf - 1.0, (yb+u_shift)/256.0 - 1.0, 0.0, 1.0); }\n"; + static const char *GEO_FS = + "#version 330\n" +- "noperspective in vec4 v_col; out vec4 frag;\n" +- "void main(){ frag = v_col; }\n"; ++ "noperspective in vec4 v_col; noperspective in float v_tint; out vec4 frag;\n" ++ "void main(){ frag = v_col;\n" ++ " if (v_tint > 0.0) frag.rgb = mix(frag.rgb, vec3(1.0,0.15,0.85),v_tint); }\n"; + + /* Textured prims: sample raw 1555 VRAM (integer), CLUT decode per depth, + * texture window, optional bilinear, texel-0 discard, STP-split discard, +@@ -816,12 +1090,20 @@ + "layout(location=6) in float a_raw;\n" + "layout(location=7) in vec4 a_limits;\n" + "layout(location=8) in float a_semi;\n" ++ "layout(location=9) in float a_visual_flags;\n" ++ "layout(location=10) in vec2 a_visual_pos;\n" ++ "layout(location=11) in float a_q;\n" + "uniform float u_shift;\n" + "uniform float u_xoff; /* native-wide x translation (px); 0 canonical */\n" + "uniform float u_xhalf; /* x clip half-extent (px); 512 canonical */\n" + "uniform float u_xscale; /* native-wide 2D-backdrop x-stretch; 1 canonical */\n" + "uniform float u_xcenter;/* stretch centre in VRAM px; 0 canonical */\n" +- "noperspective out vec2 v_uv; noperspective out vec4 v_col;\n" ++ "uniform float u_visual; /* 1 only for the independent display mirror */\n" ++ "uniform vec4 u_camera; /* sin(yaw), cos(yaw), centre-x, centre-y */\n" ++ "uniform vec4 u_visual_options; /* focal-x, full-yaw, tint-strength, unused */\n" ++ "noperspective out vec2 v_uv; noperspective out vec3 v_uvq;\n" ++ "noperspective out vec4 v_col; flat out int v_perspective;\n" ++ "noperspective out float v_tint;\n" + "flat out ivec2 v_tpage; flat out ivec2 v_clut; flat out int v_depth;\n" + "flat out int v_raw; flat out ivec4 v_limits; flat out int v_semi;\n" + "void main(){ v_uv = a_uv; v_col = a_col;\n" +@@ -831,16 +1113,32 @@ + " v_limits = ivec4(floor(a_limits + 0.5));\n" + " /* u_shift: align GL's center-sample grid with the PS1 integer grid (see\n" + " * GEO_VS) so interpolated uv at a fragment equals the PS1 DDA value. */\n" +- " float xb = a_pos.x;\n" ++ " bool corrected = u_visual > 0.5 && a_visual_flags > 0.5;\n" ++ " vec2 p = corrected ? a_visual_pos : a_pos;\n" ++ " float xb = p.x, yb = p.y;\n" ++ " v_tint = (u_visual > 0.5 && a_visual_flags > 1.5) ? u_visual_options.z : 0.0;\n" ++ " if (corrected && abs(u_camera.x) > 0.0000001 && u_visual_options.x > 0.0) {\n" ++ " float ux = (xb-u_camera.z)/u_visual_options.x;\n" ++ " float den = u_camera.y-ux*u_camera.x;\n" ++ " if (abs(den) > 0.01) {\n" ++ " xb = u_camera.z + u_visual_options.x*((ux*u_camera.y+u_camera.x)/den);\n" ++ " if (u_visual_options.y > 0.5) yb = u_camera.w + (yb-u_camera.w)/den;\n" ++ " }\n" ++ " }\n" ++ " /* Under a fractional yaw a_q comes divided by the turn's denominator, or 0 for the whole triangle. */\n" ++ " float q = (u_visual > 0.5 && a_visual_flags > 1.5 && a_q > 0.0) ? a_q : 0.0;\n" ++ " v_uvq = vec3(a_uv*q, q); v_perspective = q > 0.0 ? 1 : 0;\n" + " if (u_xscale < 0.0) {\n" + " float s = -u_xscale; float h = u_xhalf / s;\n" + " float l = u_xcenter - h, r = u_xcenter + h;\n" + " if (xb < l) xb = l + (xb-l)*s; else if (xb > r) xb = r + (xb-r)*s;\n" + " } else xb = (xb - u_xcenter)*u_xscale + u_xcenter;\n" +- " gl_Position = vec4((xb+u_shift+u_xoff)/u_xhalf - 1.0, (a_pos.y+u_shift)/256.0 - 1.0, 0.0, 1.0); }\n"; ++ " gl_Position = vec4((xb+u_shift+u_xoff)/u_xhalf - 1.0, (yb+u_shift)/256.0 - 1.0, 0.0, 1.0); }\n"; + static const char *TEX_FS = + "#version 330\n" +- "noperspective in vec2 v_uv; noperspective in vec4 v_col;\n" ++ "noperspective in vec2 v_uv; noperspective in vec3 v_uvq;\n" ++ "noperspective in vec4 v_col; flat in int v_perspective;\n" ++ "noperspective in float v_tint;\n" + "out vec4 frag; out vec4 blend_factor;\n" + "flat in ivec2 v_tpage; /* texture page base, VRAM px */\n" + "flat in ivec2 v_clut; /* CLUT base, VRAM px */\n" +@@ -879,9 +1177,11 @@ + " return vec3(float(raw & 31), float((raw >> 5) & 31), float((raw >> 10) & 31)) / 31.0;\n" + "}\n" + "void main(){\n" ++ " vec2 sample_uv = v_uv;\n" ++ " if (v_perspective != 0 && v_uvq.z > 1.0e-12) sample_uv = v_uvq.xy/v_uvq.z;\n" + " int stp; vec3 rgb;\n" + " if (u_filter == 0) {\n" +- " int raw = fetch_texel(int(floor(v_uv.x)), int(floor(v_uv.y)));\n" ++ " int raw = fetch_texel(int(floor(sample_uv.x)), int(floor(sample_uv.y)));\n" + " if (raw == 0) discard;\n" + " rgb = col5(raw);\n" + " stp = (raw >> 15) & 1;\n" +@@ -892,8 +1192,8 @@ + " * its opacity with the colour renormalised — so prim edges and\n" + " * cutout borders keep their colour instead of dissolving into the\n" + " * transparent (black) neighbour and discarding whole edge columns. */\n" +- " int iu = int(floor(v_uv.x)), iv = int(floor(v_uv.y));\n" +- " float fx = v_uv.x - float(iu) - 0.5, fy = v_uv.y - float(iv) - 0.5;\n" ++ " int iu = int(floor(sample_uv.x)), iv = int(floor(sample_uv.y));\n" ++ " float fx = sample_uv.x - float(iu) - 0.5, fy = sample_uv.y - float(iv) - 0.5;\n" + " int sx = fx < 0.0 ? -1 : 1, sy = fy < 0.0 ? -1 : 1;\n" + " fx = abs(fx); fy = abs(fy);\n" + " int c00 = fetch_texel(iu, iv);\n" +@@ -914,6 +1214,7 @@ + " if (u_semipass == 1 && stp == 1) discard;\n" + " if (u_semipass == 2 && stp == 0) discard;\n" + " if (v_raw == 0) rgb = clamp(rgb * v_col.rgb * 2.0, 0.0, 1.0);\n" ++ " if (v_tint > 0.0) rgb = mix(rgb, vec3(1.0,0.15,0.85),v_tint);\n" + " float dst_factor = 0.0;\n" + " if (u_semimode == 4 && v_semi != 0 && stp != 0) {\n" + " dst_factor = v_semi == 1 ? 0.5 : 1.0;\n" +@@ -1006,6 +1307,70 @@ + fprintf(stdout, "psxrecomp: GL program link failed: %s\n", log); return 0; } + return p; + } ++/* The drawing programs' own copy of a vertex shader: on the mirror the camera depth of slot 9 or 23 becomes the fragment's depth, ++ * and v_bill says what is drawn: 0 off the mirror, 1 on it, 2 there with a depth that is not a world polygon's exact one ++ * (a sprite's one depth, or a clipped polygon's partial one: flags of 1, an exact polygon has 3). ++ * The source strings stay as they are: the in-between presenter makes its own copies of them. */ ++/* A copy of a shader with a line after its first one and another end. NULL when it does not end as expected. */ ++static char *shader_variant(const char *source, const char *declared, const char *end, const char *ending) { ++ const size_t length = strlen(source), tail = strlen(end), room = length + strlen(declared) + strlen(ending) + 8; ++ const char *body = strchr(source, '\n'); ++ char *out; ++ int written; ++ if (!body || length < tail || strcmp(source + length - tail, end)) return NULL; ++ out = (char *)malloc(room); ++ if (!out) return NULL; ++ written = snprintf(out, room, "%.*s%s", (int)(body + 1 - source), source, declared); ++ memcpy(out + written, body + 1, (size_t)(source + length - tail - (body + 1))); ++ strcpy(out + written + (source + length - tail - (body + 1)), ending); ++ return out; ++} ++ ++static char *depth_writing_shader(const char *source, int location) { ++ static const char flat[] = "0.0, 1.0); }\n"; ++ static const char deep[] = "u_visual_options.w >= 1.0 ? 0.99999 : ((u_visual > 0.5 && abs(a_camz) > 16.0) ? max(1.0 - 32.0*(1.0 + u_pull)/(abs(a_camz)*(1.0 + max(u_visual_options.w, 0.0))), -1.0) : 0.999999), 1.0);\n" ++ " bool grown = a_visual_flags > 5.5;\n" ++ " if (u_visual > 0.5 && (u_visual_options.w == 0.0 ? grown : u_visual_options.w >= 1.0 ? (grown || a_visual_flags < 1.5) : (u_visual_options.w > 0.0 && !grown)))\n" ++ " gl_Position = vec4(2.0, 2.0, 2.0, 1.0);\n" ++ " v_deep = abs(a_camz) > 16.0 ? 1.0 : -1000.0;\n" ++ " v_bill = u_visual > 0.5 ? ((abs(a_camz) > 16.0 && a_visual_flags < 1.5) ? 2.0 : 1.0) : 0.0; }\n"; ++ const size_t length = strlen(source), tail = sizeof(flat) - 1, room = length + sizeof(deep) + 160; ++ const char *body = strchr(source, '\n'); ++ char *out; ++ int written; ++ if (!body || length < tail || strcmp(source + length - tail, flat)) return NULL; ++ out = (char *)malloc(room); ++ if (!out) return NULL; ++ written = snprintf(out, room, "%.*slayout(location=%d) in float a_camz; flat out float v_bill; noperspective out float v_deep; uniform float u_pull;\n", (int)(body + 1 - source), source, location); ++ memcpy(out + written, body + 1, (size_t)(source + length - tail - (body + 1))); ++ strcpy(out + written + (source + length - tail - (body + 1)), deep); ++ return out; ++} ++ ++/* The textured program's own copy of its fragment shader. Pass 3 and 4 write depth alone, 4 for a batch whose STP texels are blended: ++ * a blended fragment leaves the depth under it. On the mirror, whose alpha nothing reads back, a blended fragment marks it 0.25 ++ * and an opaque one of a sprite 0.5. */ ++static char *sprite_marking_shader(const char *source) { ++ static const char end[] = "}\n"; ++ static const char marked[] = ++ " bool blended = stp != 0 && (u_semimode == 4 ? v_semi != 0 : (u_semipass == 2 || u_semipass == 4));\n" ++ " if (u_semipass == 4 && blended) discard;\n" ++ " if (v_bill > 0.5 && blended) frag.a = 0.25; else if (v_bill > 1.5) frag.a = 0.5;\n" ++ " if (v_bill > 0.5 && v_deep > -999.5 && v_deep < 0.999) frag.a = 0.25; /* some corner has no depth (it weighs -1000): its depth is no surface */\n" ++ "}\n"; ++ const size_t length = strlen(source), tail = sizeof(end) - 1, room = length + sizeof(marked) + 64; ++ const char *body = strchr(source, '\n'); ++ char *out; ++ int written; ++ if (!body || length < tail || strcmp(source + length - tail, end)) return NULL; ++ out = (char *)malloc(room); ++ if (!out) return NULL; ++ written = snprintf(out, room, "%.*sflat in float v_bill; noperspective in float v_deep;\n", (int)(body + 1 - source), source); ++ memcpy(out + written, body + 1, (size_t)(source + length - tail - (body + 1))); ++ strcpy(out + written + (source + length - tail - (body + 1)), marked); ++ return out; ++} ++ + static GLuint build_program(const char *vs, const char *fs) { + return build_program_ex(vs, fs, 0); + } +@@ -1025,6 +1390,14 @@ + * stored value is known to be 0 when the test passed) and a 0 + * via KEEP. */ + static void mask_stencil(int write_val) { ++ if (s_fill_pass > 0.0f) { /* a filler obeys the mask and leaves it as it is */ ++ if (!s_mask_check) { glDisable(GL_STENCIL_TEST); return; } ++ glEnable(GL_STENCIL_TEST); ++ glStencilMask(0x00); ++ glStencilFunc(GL_EQUAL, 0, 0x01); ++ glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP); ++ return; ++ } + glEnable(GL_STENCIL_TEST); + glStencilMask(0x01); + if (s_mask_check) { +@@ -1135,6 +1508,7 @@ + PSXGL_RED_INTEGER, GL_UNSIGNED_SHORT, + s_vram + (size_t)y * VRAM_W + x); + glPixelStorei(PSXGL_UNPACK_ROW_LENGTH, 0); ++ temporal_note_raw_change(); + if (diag) s_rt_up_diag[4] += SDL_GetPerformanceCounter() - t0; + } + +@@ -1223,6 +1597,7 @@ + int h = s_pack_dirty.y1 - s_pack_dirty.y0 + 1; + rect_clear(&s_pack_dirty); + coh_record(GL_COH_PACK, x, y, x + w - 1, y + h - 1); ++ temporal_note_raw_change(); + + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_raw_fbo); + glViewport(0, 0, VRAM_W, VRAM_H); +@@ -1327,9 +1702,43 @@ + * boundary (MMX6: draw area alternates y=0/y=240, both bands in ONE wide + * surface) bleed into the OTHER band's rows — presented one frame later as + * top/bottom edge flicker (16:9 GL only). */ ++/* Rows y..y+h-1 of a surface as bits, one for every 16 rows. */ ++static uint32_t wide_depth_rows(int y, int h) { ++ uint32_t rows = 0; ++ for (int block = y < 0 ? 0 : y / 16; block < 32 && block * 16 < y + h; block++) rows |= 1u << block; ++ return rows; ++} ++ ++/* The depth of a frame buffer is one frame's: drawing into rows that have been shown starts from none. The game's frame buffers ++ * share a surface, one above the other, so the rows on screen keep theirs. Polygons the game blends into its far colour write ++ * no depth: without this the last frame's would stay under them. */ ++static void wide_depth_shown(GLuint fbo, int y, int h) { ++ for (int i = 0; i < WIDE_MAX_SURF; i++) ++ if (s_wide_fbo[i] == fbo) s_wide_depth_shown[i] |= wide_depth_rows(y, h); ++} ++ ++static void wide_depth_begin(void) { ++ int sy = s_area_y1, sh = s_area_y2 - s_area_y1 + 1; ++ if (!mirror_depth()) return; ++ if (sy < 0) { sh += sy; sy = 0; } ++ if (sy + sh > VRAM_H) sh = VRAM_H - sy; ++ if (sh <= 0) return; ++ for (int i = 0; i < WIDE_MAX_SURF; i++) { ++ const uint32_t rows = wide_depth_rows(sy, sh); ++ if (s_wide_fbo[i] != g_wide_cur || !(s_wide_depth_shown[i] & rows)) continue; ++ s_wide_depth_shown[i] &= ~rows; ++ glEnable(GL_SCISSOR_TEST); ++ glScissor(0, sy * s_scale, g_wide_w * s_scale, sh * s_scale); ++ glDepthMask(GL_TRUE); ++ glClearDepth(1.0); ++ glClear(GL_DEPTH_BUFFER_BIT); ++ } ++} ++ + static void wide_target_begin(int dx, GLint uXoff, GLint uXhalf) { + if (s_ws_ablate != 3) /* ablate 3: no FBO rebind (draws land in hr — perf probe) */ + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, g_wide_cur); ++ wide_depth_begin(); + glViewport(0, 0, g_wide_w * s_scale, VRAM_H * s_scale); + glEnable(GL_SCISSOR_TEST); + { +@@ -1339,10 +1748,12 @@ + if (sh < 0) sh = 0; + glScissor(0, sy * s_scale, g_wide_w * s_scale, sh * s_scale); + } ++ if (mirror_depth()) { glEnable(GL_DEPTH_TEST); glDepthFunc(GL_ALWAYS); glDepthMask(GL_FALSE); } + p_glUniform1f(uXoff, (float)dx); + p_glUniform1f(uXhalf, (float)g_wide_w / 2.0f); + } + static void wide_target_end(GLint uXoff, GLint uXhalf) { ++ if (mirror_depth()) { glDepthMask(GL_TRUE); glDisable(GL_DEPTH_TEST); } + p_glUniform1f(uXoff, 0.0f); + p_glUniform1f(uXhalf, 512.0f); + if (s_ws_ablate != 3) +@@ -1397,13 +1808,13 @@ + /* True if [lo,hi] (canonical draw-x) lies strictly inside the 4:3 frame, so the + * prim adds nothing to either reveal margin and its mirror can be skipped. */ + static int mirror_x_center_only(int lo, int hi) { +- if (!s_wide_fast) return 0; ++ if (!s_wide_fast || s_geometry_correction) return 0; + int base = g_wide_cur_base, native_w = g_wide_w - 2 * g_wide_off; + if (native_w <= 0) return 0; + return (lo >= base) && (hi < base + native_w); + } + static int mirror_geo_center_only(const int *xs, int n) { +- if (!s_wide_fast) return 0; ++ if (!s_wide_fast || s_geometry_correction) return 0; + int lo = xs[0], hi = xs[0]; + for (int i = 1; i < n; i++) { if (xs[i] < lo) lo = xs[i]; if (xs[i] > hi) hi = xs[i]; } + return mirror_x_center_only(lo, hi); +@@ -1449,6 +1860,7 @@ + * flush_tex_batch never re-enters those helpers. */ + #define TEXBATCH_MAXV 8190 /* multiple of 3; ~2730 tris */ + static float s_tb[TEXBATCH_MAXV * TEXV]; ++static uint64_t s_tb_identity[TEXBATCH_MAXV]; + static int s_tb_n = 0; /* verts queued */ + static int s_tb_semi = -2; + static int s_tb_mask = 0, s_tb_filter = 0; +@@ -1506,7 +1918,7 @@ + if (s_tb_mask) mask_stencil(1); else glDisable(GL_STENCIL_TEST); + p_glUniform1i(s_uSemipass, 0); + glDrawArrays(GL_TRIANGLES, 0, nverts); +- if (!s_tb_mask) s_stencil_valid = 0; ++ if (!s_tb_mask && s_fill_pass <= 0.0f) s_stencil_valid = 0; + } else { + glDisable(GL_BLEND); /* Pass 1: STP=0 texels (opaque) */ + mask_stencil(s_tb_mask); +@@ -1540,7 +1952,7 @@ + /* Textured-batch variant of mirror_x_center_only: scan the queued verts' x + * (attr 0, stride TEXV). Defined here so s_tb / TEXV are in scope. */ + static int mirror_batch_center_only(int nverts) { +- if (!s_wide_fast || nverts <= 0) return 0; ++ if (!s_wide_fast || s_geometry_correction || nverts <= 0) return 0; + int lo = (int)s_tb[0], hi = (int)s_tb[0]; + for (int i = 1; i < nverts; i++) { + int x = (int)s_tb[i * TEXV]; +@@ -1549,15 +1961,74 @@ + return mirror_x_center_only(lo, hi); + } + ++static float filler_line_width(int scale) { ++ const float width = s_crack_fill * (float)scale; ++ return width < 1.0f ? 1.0f : width > s_line_width_max ? s_line_width_max : width; ++} ++ ++/* What a filler pass of the mirror leaves as it found it: the mask bit in alpha and stencil, and the depth. */ ++static void filler_pass_begin(float pass) { ++ s_fill_pass = pass; ++ geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 1); ++ glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_FALSE); ++ glDepthFunc(GL_LESS); ++ glDepthMask(GL_FALSE); ++} ++static void filler_pass_end(void) { ++ s_fill_pass = 0.0f; ++ geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 1); ++ glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); ++ glStencilMask(0x01); ++ glDepthFunc(GL_ALWAYS); ++} ++ ++/* Under a batch, the edges of its exact world polygons: exact corners open the hairline gaps that whole pixels closed. */ ++static void tex_batch_outline(int nverts, int semi) { ++ int exact = 0; ++ for (int i = 0; i < nverts && !exact; i += 3) exact = s_tb[i * TEXV + 19] > 1.5f; ++ if (!exact) return; ++ filler_pass_begin(2.0f); ++ glPolygonMode(GL_FRONT_AND_BACK, GL_LINE); ++ glLineWidth(filler_line_width(s_scale)); ++ glDisable(GL_BLEND); ++ mask_stencil(s_tb_mask); ++ p_glUniform1i(s_uSemimode, semi < 0 ? 0 : semi); ++ p_glUniform1i(s_uSemipass, semi < 0 ? 0 : 4); ++ glDrawArrays(GL_TRIANGLES, 0, nverts); ++ glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); ++ filler_pass_end(); ++} ++ ++/* A batch of exact world polygons by depth, see s_world_depth. */ ++static void tex_batch_by_depth(int nverts, int semi) { ++ glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); ++ glDisable(GL_BLEND); ++ glDisable(GL_STENCIL_TEST); ++ glDepthFunc(GL_LEQUAL); ++ glDepthMask(GL_TRUE); ++ p_glUniform1i(s_uSemimode, semi < 0 ? 0 : semi); ++ p_glUniform1i(s_uSemipass, semi < 0 ? 3 : 4); ++ glDrawArrays(GL_TRIANGLES, 0, nverts); ++ glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); ++ glDepthMask(GL_FALSE); ++ p_glUniform1f(s_tex_uPull, s_world_margin); ++ tex_batch_draw_passes(nverts, semi); ++ p_glUniform1f(s_tex_uPull, 0.0f); ++ glDepthFunc(GL_ALWAYS); ++} ++ + static void flush_tex_batch(void) { + if (s_tb_n == 0) return; + int nverts = s_tb_n, semi = s_tb_semi; ++ const int fillers = s_tb_fillers; ++ const int world = s_tb_world; + s_tb_n = 0; /* clear first: re-entrancy safe */ + double cw_t0 = cw_ms(); + s_cw_batches++; s_batch_total++; s_cw_flush_depth++; + + hr_begin(1); + p_glUseProgram(s_tex_prog); ++ geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 0); + p_glActiveTexture(PSXGL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, s_raw_tex); + p_glUniform1i(s_uVram, 0); +@@ -1580,9 +2051,34 @@ + s_bd_gate = s_tb_gate; /* this batch is uniform-gate (flushed on change) */ + gl_perf_mirror_begin(); + wide_target_begin(dx, s_tex_uXoff, s_tex_uXhalf); ++ geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 1); + wide_set_bd_scale(s_tex_uXscale, s_tex_uXcenter); +- if (s_ws_ablate != 2) tex_batch_draw_passes(nverts, semi); ++ s_draw_filler = mirror_depth() && fillers; ++ s_draw_world = mirror_depth() && world; ++ temporal_draw(s_tb, s_tb_identity, nverts, 1, GL_TRIANGLES, semi, s_tb_mask, ++ s_tb_filter, s_tb_twin, s_tb_gate, 0); ++ s_draw_filler = s_draw_world = 0; ++ if (s_ws_ablate != 2) { ++ if (mirror_depth() && s_crack_fill > 0.0f) tex_batch_outline(nverts, semi); ++ if (mirror_depth() && fillers) { /* in sequence each copy would break the batch: 135 batches a frame against 2 */ ++ filler_pass_begin(GROWN_COPY_MARGIN); ++ tex_batch_draw_passes(nverts, semi); ++ filler_pass_end(); ++ } ++ if (world && mirror_depth()) tex_batch_by_depth(nverts, semi); ++ else tex_batch_draw_passes(nverts, semi); ++ } ++ if (mirror_depth() && !world && s_ws_ablate != 2) { /* the depth of the opaque fragments alone */ ++ glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); ++ glDisable(GL_BLEND); ++ glDisable(GL_STENCIL_TEST); ++ glDepthMask(GL_TRUE); ++ p_glUniform1i(s_uSemipass, semi < 0 ? 3 : 4); ++ glDrawArrays(GL_TRIANGLES, 0, nverts); ++ glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); ++ } + wide_clear_bd_scale(s_tex_uXscale, s_tex_uXcenter); ++ geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 0); + wide_target_end(s_tex_uXoff, s_tex_uXhalf); + gl_perf_mirror_end(); + } +@@ -1594,16 +2090,17 @@ + * GP0(68h) 1x1 dots; each was two immediate gpu_triangle draws (BufferData + + * DrawArrays each). Coalesce opaque/semi-uniform tris into one draw. */ + #define FLATBATCH_MAXV 8190 /* multiple of 3 */ +-static float s_fb[FLATBATCH_MAXV * 6]; ++static float s_fb[FLATBATCH_MAXV * GEOV]; ++static uint64_t s_fb_identity[FLATBATCH_MAXV]; + static int s_fb_n = 0; + static int s_fb_semi = -2; + static int s_fb_mask = -1; + + static int mirror_flat_batch_center_only(int nverts) { +- if (!s_wide_fast || nverts <= 0) return 0; ++ if (!s_wide_fast || s_geometry_correction || nverts <= 0) return 0; + int lo = (int)s_fb[0], hi = (int)s_fb[0]; + for (int i = 1; i < nverts; i++) { +- int x = (int)s_fb[i * 6]; ++ int x = (int)s_fb[i * GEOV]; + if (x < lo) lo = x; if (x > hi) hi = x; + } + return mirror_x_center_only(lo, hi); +@@ -1618,9 +2115,10 @@ + if (semi >= 0) apply_psx_blend(semi); else glDisable(GL_BLEND); + mask_stencil(mask); + p_glUseProgram(s_geo_prog); ++ geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); + p_glBindVertexArray(s_geo_vao); + p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_geo_vbo); +- p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)(nverts * 6 * sizeof(float)), ++ p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)(nverts * GEOV * sizeof(float)), + s_fb, PSXGL_STREAM_DRAW); + glDrawArrays(GL_TRIANGLES, 0, nverts); + +@@ -1631,9 +2129,13 @@ + s_bd_gate = 0; + gl_perf_mirror_begin(); + wide_target_begin(dx, s_geo_uXoff, s_geo_uXhalf); ++ geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 1); + wide_set_bd_scale(s_geo_uXscale, s_geo_uXcenter); ++ temporal_draw(s_fb, s_fb_identity, nverts, 0, GL_TRIANGLES, semi, mask, 0, NULL, 0, 0); ++ if (mirror_depth()) glDepthMask(semi < 0 ? GL_TRUE : GL_FALSE); + if (s_ws_ablate != 2) glDrawArrays(GL_TRIANGLES, 0, nverts); + wide_clear_bd_scale(s_geo_uXscale, s_geo_uXcenter); ++ geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); + wide_target_end(s_geo_uXoff, s_geo_uXhalf); + gl_perf_mirror_end(); + } +@@ -1641,9 +2143,23 @@ + } + + /* Flat / gouraud triangles and lines share the GEO program. mode: GL_TRIANGLES +- * or GL_LINES; verts are (x, y, r, g, b, a) tuples with colors as 1555. */ ++ * or GL_LINES; vertices carry canonical x/y, color, visual flags, and separate ++ * presentation x/y. Bit 0 of the flags gates the fractional-yaw transform; ++ * bit 1 identifies exact provenance for the optional coverage tint. */ + static void gpu_geometry(GLenum mode, const int *xs, const int *ys, + const uint16_t *cs, int n, int semi) { ++ float px[3] = {0}, py[3] = {0}, pz[3] = {0}; ++ uint64_t pid[3] = {0}; ++ int visual_flags = 0; ++ if (mode == GL_TRIANGLES && n == 3) ++ visual_flags = take_visual_triangle(xs, ys, px, py, NULL, pz, pid) & 3; ++ else { ++ s_next_world = 0; ++ s_next_precise = 0; ++ s_next_unpinned = 0; ++ s_next_perspective = 0; ++ s_next_temporal_depth = 0; ++ } + flush_tex_batch(); /* flat prim: drain textured draws first (order + program) */ + flush_cpu_upload(); /* also drains flat batch if an upload was pending */ + mark_prim_dirty(xs, ys, n, 0 /* flat */); +@@ -1651,24 +2167,29 @@ + /* Lines stay immediate (rare); tris batch for MotK 0x68 starfields. */ + if (mode != GL_TRIANGLES || n < 3) { + flush_flat_batch(); +- float verts[3 * 6]; ++ float verts[3 * GEOV]; + float mask_a = s_mask_set ? 1.0f : 0.0f; + for (int i = 0; i < n; i++) { +- verts[i*6+0] = (float)xs[i]; +- verts[i*6+1] = (float)ys[i]; +- verts[i*6+2] = ((cs[i] & 0x1F) << 3) / 255.0f; +- verts[i*6+3] = (((cs[i] >> 5) & 0x1F) << 3) / 255.0f; +- verts[i*6+4] = (((cs[i] >> 10) & 0x1F) << 3) / 255.0f; +- verts[i*6+5] = mask_a; ++ verts[i*GEOV+0] = (float)xs[i]; ++ verts[i*GEOV+1] = (float)ys[i]; ++ verts[i*GEOV+2] = ((cs[i] & 0x1F) << 3) / 255.0f; ++ verts[i*GEOV+3] = (((cs[i] >> 5) & 0x1F) << 3) / 255.0f; ++ verts[i*GEOV+4] = (((cs[i] >> 10) & 0x1F) << 3) / 255.0f; ++ verts[i*GEOV+5] = mask_a; ++ verts[i*GEOV+6] = 0.0f; ++ verts[i*GEOV+7] = (float)xs[i]; ++ verts[i*GEOV+8] = (float)ys[i]; ++ verts[i*GEOV+9] = 0.0f; + } + hr_begin(1); + if (semi >= 0) apply_psx_blend(semi); else glDisable(GL_BLEND); + mask_stencil(s_mask_set); + if (mode == GL_LINES) glLineWidth((float)s_scale); + p_glUseProgram(s_geo_prog); ++ geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); + p_glBindVertexArray(s_geo_vao); + p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_geo_vbo); +- p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)(n * 6 * sizeof(float)), ++ p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)(n * GEOV * sizeof(float)), + verts, PSXGL_STREAM_DRAW); + glDrawArrays(mode, 0, n); + if (g_wide_cur && !s_wide_suppress && s_ws_ablate != 1 && +@@ -1677,9 +2198,13 @@ + s_bd_gate = bd_prim_gate(xs, n, 0); + gl_perf_mirror_begin(); + wide_target_begin(dx, s_geo_uXoff, s_geo_uXhalf); ++ geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 1); + wide_set_bd_scale(s_geo_uXscale, s_geo_uXcenter); ++ temporal_draw(verts, NULL, n, 0, mode, semi, s_mask_set, 0, NULL, s_bd_gate, 0); ++ if (s_ssao) glDepthMask(semi < 0 ? GL_TRUE : GL_FALSE); + if (s_ws_ablate != 2) glDrawArrays(mode, 0, n); + wide_clear_bd_scale(s_geo_uXscale, s_geo_uXcenter); ++ geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); + wide_target_end(s_geo_uXoff, s_geo_uXhalf); + gl_perf_mirror_end(); + } +@@ -1696,13 +2221,18 @@ + + float mask_a = s_mask_set ? 1.0f : 0.0f; + for (int i = 0; i < n; i++) { +- float *v = &s_fb[s_fb_n * 6]; ++ float *v = &s_fb[s_fb_n * GEOV]; + v[0] = (float)xs[i]; + v[1] = (float)ys[i]; + v[2] = ((cs[i] & 0x1F) << 3) / 255.0f; + v[3] = (((cs[i] >> 5) & 0x1F) << 3) / 255.0f; + v[4] = (((cs[i] >> 10) & 0x1F) << 3) / 255.0f; + v[5] = mask_a; ++ v[6] = (float)visual_flags; ++ v[7] = visual_flags ? px[i] : (float)xs[i]; ++ v[8] = visual_flags ? py[i] : (float)ys[i]; ++ v[9] = pz[i]; /* temporal identity only; no shader attribute */ ++ s_fb_identity[s_fb_n] = pid[i]; + s_fb_n++; + } + } +@@ -1733,6 +2263,10 @@ + const float *col, uint16_t texpage, + uint16_t clut_x, uint16_t clut_y, int rawtex, + int semi, const int *lim) { ++ float px[3], py[3], pq[3], pz[3]; ++ uint64_t pid[3]; ++ const int visual_flags = take_visual_triangle(xs, ys, px, py, pq, pz, pid); ++ turned_perspective(px, pq, visual_flags); + int lim_buf[4]; + int uv_buf[6]; + if (!lim) { +@@ -1762,6 +2296,8 @@ + flush_flat_batch(); /* painter order: flat GEO before textured */ + int twx = s_tw_mask_x, twy = s_tw_mask_y, tox = s_tw_off_x, toy = s_tw_off_y; + int gate = bd_prim_gate(xs, 3, 1); /* backdrop-stretch gate is also a batch key */ ++ const int world = psx_world_depth_class(s_world_depth, visual_flags, pz, s_world_margin); ++ if (s_tb_n > 0 && world != s_tb_world) flush_tex_batch(); + /* With mask checking off, opaque and mode-0 semi primitives use the + * same dual-source blend state. The per-vertex a_semi flag selects + * replace vs half-blend without breaking painter order. */ +@@ -1800,11 +2336,14 @@ + if (s_tb_n + 3 > TEXBATCH_MAXV) { s_batch_reason[6]++; flush_tex_batch(); } + if (s_tb_n == 0) { /* opening a batch: capture its keyed state */ + s_tb_semi = batch_semi; s_tb_mask = s_mask_set; s_tb_filter = s_tex_filter; s_tb_gate = gate; ++ s_tb_fillers = 0; ++ s_tb_world = world; + s_tb_twin[0] = twx; s_tb_twin[1] = twy; s_tb_twin[2] = tox; s_tb_twin[3] = toy; + } + float *vp = &s_tb[s_tb_n * TEXV]; + for (int i = 0; i < 3; i++, vp += TEXV) { +- vp[0] = (float)xs[i]; vp[1] = (float)ys[i]; ++ vp[0] = (float)xs[i]; ++ vp[1] = (float)ys[i]; + vp[2] = (float)us[i]; vp[3] = (float)vs[i]; + vp[4] = col[i*3+0]; vp[5] = col[i*3+1]; vp[6] = col[i*3+2]; vp[7] = 1.0f; + vp[8] = (float)base_x; vp[9] = (float)base_y; /* a_tpage */ +@@ -1813,7 +2352,14 @@ + vp[14] = (float)lim[0]; vp[15] = (float)lim[1]; /* a_limits */ + vp[16] = (float)lim[2]; vp[17] = (float)lim[3]; + vp[18] = semi >= 0 ? (float)(semi + 1) : 0.0f; /* a_semi code */ ++ vp[19] = (float)visual_flags; /* a_visual_flags */ ++ vp[20] = visual_flags ? px[i] : (float)xs[i]; /* a_visual_pos */ ++ vp[21] = visual_flags ? py[i] : (float)ys[i]; ++ vp[22] = pq[i]; /* a_q (0 = affine) */ ++ vp[23] = pz[i]; /* temporal identity only; no shader attribute */ ++ s_tb_identity[s_tb_n + i] = pid[i]; + } ++ if (visual_flags & 4) s_tb_fillers++; + s_tb_n += 3; + if (isolate) flush_tex_batch(); /* draw this semi prim alone, in submission order */ + } +@@ -1831,31 +2377,44 @@ + float b = (((c >> 10) & 0x1F) << 3) / 255.0f; + float a = s_mask_set ? 1.0f : 0.0f; + float fx0 = (float)wx, fy0 = (float)y, fx1 = (float)(wx + ww), fy1 = (float)(y + h); +- float verts[6 * 6] = { +- fx0,fy0,r,g,b,a, fx1,fy0,r,g,b,a, fx0,fy1,r,g,b,a, +- fx1,fy0,r,g,b,a, fx0,fy1,r,g,b,a, fx1,fy1,r,g,b,a, ++ float verts[6 * GEOV] = { ++ fx0,fy0,r,g,b,a,0,fx0,fy0,0, fx1,fy0,r,g,b,a,0,fx1,fy0,0, ++ fx0,fy1,r,g,b,a,0,fx0,fy1,0, fx1,fy0,r,g,b,a,0,fx1,fy0,0, ++ fx0,fy1,r,g,b,a,0,fx0,fy1,0, fx1,fy1,r,g,b,a,0,fx1,fy1,0, + }; + /* Wide target: positions already wide-space so u_xoff = 0; full-width + * scissor; u_xhalf = g_wide_w/2. */ + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, g_wide_cur); ++ wide_depth_begin(); + glViewport(0, 0, g_wide_w * s_scale, VRAM_H * s_scale); + glEnable(GL_SCISSOR_TEST); + glScissor(0, 0, g_wide_w * s_scale, VRAM_H * s_scale); /* full surface (rt_wide) */ + if (semi >= 0) apply_psx_blend(semi); else glDisable(GL_BLEND); + mask_stencil(s_mask_set); + p_glUseProgram(s_geo_prog); ++ geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); + p_glUniform1f(s_geo_uXoff, 0.0f); + p_glUniform1f(s_geo_uXhalf, (float)g_wide_w / 2.0f); + p_glBindVertexArray(s_geo_vao); + p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_geo_vbo); + p_glBufferData(PSXGL_ARRAY_BUFFER, sizeof verts, verts, PSXGL_STREAM_DRAW); ++ temporal_draw(verts, NULL, 6, 0, GL_TRIANGLES, semi, s_mask_set, 0, NULL, 0, 1); ++ if (mirror_depth()) { glEnable(GL_DEPTH_TEST); glDepthFunc(GL_ALWAYS); glDepthMask(semi < 0 ? GL_TRUE : GL_FALSE); } + glDrawArrays(GL_TRIANGLES, 0, 6); ++ if (mirror_depth()) { glDepthMask(GL_TRUE); glDisable(GL_DEPTH_TEST); } + p_glUniform1f(s_geo_uXoff, 0.0f); + p_glUniform1f(s_geo_uXhalf, 512.0f); + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hr_fbo); + } + + static void gpu_flat_rect(int x,int y,int w,int h,uint16_t c,int semi) { ++ /* Rectangles are 2D UI/sprite primitives. Never let one-shot metadata ++ * prepared for a rejected polygon leak into the first triangle here. */ ++ s_next_world = 0; ++ s_next_precise = 0; ++ s_next_unpinned = 0; ++ s_next_perspective = 0; ++ s_next_temporal_depth = 0; + if (w <= 0 || h <= 0) return; + /* Full-screen 2D overlay (pause gray-filter / load fade): a flat rect + * spanning the whole 4:3 framebuffer must cover the whole wide surface too, +@@ -1877,6 +2436,7 @@ + gpu_triangle(x, y, c, x+w, y, c, x, y+h, c, semi); + gpu_triangle(x+w, y, c, x, y+h, c, x+w, y+h, c, semi); + if (overlay) { ++ flush_flat_batch(); + s_wide_suppress = 0; + /* The two canonical triangles drew into the hr FBO already (each + * gpu_triangle ran its own hr_begin/hr_end). Re-open the bracket just +@@ -1895,7 +2455,19 @@ + static void gpu_textured_rect(int x,int y,int w,int h, + int u0,int v0,int u1,int v1, + uint16_t clut_x,uint16_t clut_y,uint16_t tp,int semi) { +- if (w <= 0 || h <= 0) return; ++ /* As above, a textured rectangle is never a GTE-projected world ++ * triangle, even though it is decomposed into two triangles below. */ ++ s_next_world = 0; ++ s_next_precise = 0; ++ s_next_unpinned = 0; ++ s_next_perspective = 0; ++ s_next_temporal_depth = 0; ++ s_sprite_depth = s_next_sprite_depth; ++ s_next_sprite_depth = 0.0f; ++ memcpy(s_sprite_sides, s_next_sprite_sides, sizeof(s_sprite_sides)); ++ memset(s_next_sprite_sides, 0, sizeof(s_next_sprite_sides)); ++ s_sprite_x = x; s_sprite_y = y; ++ if (w <= 0 || h <= 0) { s_sprite_depth = 0.0f; return; } + float mr=s_mod_r/255.0f, mg=s_mod_g/255.0f, mb=s_mod_b/255.0f; + float col[9]={mr,mg,mb, mr,mg,mb, mr,mg,mb}; + /* gpu.c routes axis-aligned MIRRORED quads (X/Y-flipped 2D sprites, +@@ -1911,6 +2483,7 @@ + int xs2[3]={x+w, x, x+w}, ys2[3]={y, y+h, y+h}; + int us2[3]={u1,u0,u1}, vs2[3]={v0,v1,v1}; + gpu_textured_triangle(xs2,ys2,us2,vs2,col,tp,clut_x,clut_y,s_mod_raw,semi,lim); ++ s_sprite_depth = 0.0f; + } + + /* GP0(02h) fill: writes color with bit15=0, ignoring draw area, mask and +@@ -2020,6 +2593,8 @@ + if (s < 1) s = 1; + if (s > GL_MAX_INTERNAL_SCALE) s = GL_MAX_INTERNAL_SCALE; + s_req_scale = s; ++ if (s_raster_ok && s != s_scale && !resize_gpu_raster(s)) ++ s_req_scale = s_scale; + sw_renderer_set_scale(1); + } + static int glb_scale(void) { return s_scale; } /* real internal SSAA scale (was a stub 1; the +@@ -2028,6 +2603,131 @@ + unaffected since it never reads gr_scale()) */ + static void glb_set_texture_filter(int b) { s_tex_filter = b ? 1 : 0; sw_set_texture_filter(b); } + static int glb_texture_filter(void) { return s_tex_filter; } ++ ++static void glb_set_geometry_correction(int enabled) { ++ if ((enabled ? 1 : 0) != s_geometry_correction) { ++ interp_reset_history(); ++ memset(s_wide_depth_shown, 0xFF, sizeof s_wide_depth_shown); ++ } ++ if (s_raster_ok) { flush_flat_batch(); flush_tex_batch(); } ++ s_geometry_correction = enabled ? 1 : 0; ++ if (!s_geometry_correction) { ++ s_next_world = 0; ++ s_next_precise = 0; ++ s_next_unpinned = 0; ++ s_next_perspective = 0; ++ s_next_temporal_depth = 0; ++ s_yaw_sin = 0.0f; ++ s_yaw_cos = 1.0f; ++ } ++} ++ ++static void glb_set_world_triangle(int world) { ++ if (!s_raster_ok) return; ++ s_next_world = world; /* 2: the game's grown second copy */ ++ if (!world) s_next_temporal_depth = 0; ++} ++ ++static void glb_set_precise_triangle(int enabled, ++ int32_t x0, int32_t y0, ++ int32_t x1, int32_t y1, ++ int32_t x2, int32_t y2) { ++ if (!s_raster_ok) { ++ sw_set_precise_triangle(enabled, x0, y0, x1, y1, x2, y2); ++ return; ++ } ++ s_next_precise = enabled ? 1 : 0; ++ if (!enabled) s_next_temporal_depth = 0; ++ s_next_x16[0] = x0; s_next_y16[0] = y0; ++ s_next_x16[1] = x1; s_next_y16[1] = y1; ++ s_next_x16[2] = x2; s_next_y16[2] = y2; ++} ++ ++static void glb_set_unpinned_triangle(int32_t x0, int32_t y0, int32_t x1, int32_t y1, int32_t x2, int32_t y2) { ++ if (!s_raster_ok) return; ++ s_next_unpinned = 1; ++ s_next_unpinned_x16[0] = x0; s_next_unpinned_y16[0] = y0; ++ s_next_unpinned_x16[1] = x1; s_next_unpinned_y16[1] = y1; ++ s_next_unpinned_x16[2] = x2; s_next_unpinned_y16[2] = y2; ++} ++ ++static void glb_set_perspective_triangle(int enabled, ++ float q0, float q1, float q2) { ++ /* Pre-context fallback draws canonical software VRAM, where perspective ++ * correction is deliberately forbidden. Drop rather than forward it. */ ++ s_next_perspective = 0; ++ if (!s_raster_ok || !enabled || ++ !isfinite(q0) || !isfinite(q1) || !isfinite(q2) || ++ q0 <= 0.0f || q1 <= 0.0f || q2 <= 0.0f) ++ return; ++ s_next_q[0] = q0; ++ s_next_q[1] = q1; ++ s_next_q[2] = q2; ++ s_next_perspective = 1; ++} ++ ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++static void glb_set_temporal_depth_triangle(int enabled, ++ float z0, float z1, float z2) { ++ s_next_temporal_depth = 0; ++ if (!s_raster_ok || !enabled || ++ !isfinite(z0) || !isfinite(z1) || !isfinite(z2)) ++ return; ++ /* enabled == 2: a polygon some of whose corners have no depth, which stay 0. */ ++ if (enabled == 2 ? z0 < 0.0f || z1 < 0.0f || z2 < 0.0f || z0 + z1 + z2 <= 0.0f ++ : z0 <= 0.0f || z1 <= 0.0f || z2 <= 0.0f) ++ return; ++ s_next_z[0] = z0; s_next_z[1] = z1; s_next_z[2] = z2; ++ s_next_temporal_depth = enabled == 2 ? 2 : 1; ++} ++static void glb_set_surface_depth_triangle(float z0, float z1, float z2) { ++ const int usable = s_raster_ok && isfinite(z0) && isfinite(z1) && isfinite(z2) && z0 >= 0.0f && z1 >= 0.0f && z2 >= 0.0f; ++ s_next_surface_z[0] = usable ? z0 : 0.0f; ++ s_next_surface_z[1] = usable ? z1 : 0.0f; ++ s_next_surface_z[2] = usable ? z2 : 0.0f; ++} ++static void glb_set_temporal_identity_triangle(uint64_t id0, uint64_t id1, uint64_t id2) { ++ s_next_identity[0] = id0; s_next_identity[1] = id1; s_next_identity[2] = id2; ++} ++static void glb_set_temporal_sprite(float depth, const float sides[4]) { ++ s_next_sprite_depth = s_raster_ok && isfinite(depth) && depth > 0.0f ? depth : 0.0f; ++ int placed = s_next_sprite_depth > 0.0f && sides != NULL; ++ for (int side = 0; placed && side < 4; ++side) placed = isfinite(sides[side]) && fabsf(sides[side]) < 64.0f; ++#ifndef PSX_NO_DEBUG_TOOLS ++ /* PSX_GL_SPRITE_WHOLE_PIXELS=1 puts sprites back on whole pixels, to measure against. */ ++ static int whole = -1; ++ if (whole < 0) { const char *asked = getenv("PSX_GL_SPRITE_WHOLE_PIXELS"); whole = asked && asked[0] == '1'; } ++ if (whole) placed = 0; ++#endif ++ for (int side = 0; side < 4; ++side) s_next_sprite_sides[side] = placed ? sides[side] : 0.0f; ++} ++#endif ++ ++static void glb_set_presentation_yaw(double yaw_units, double full_turn, ++ double center_x, double center_y, ++ double focal_x) { ++ /* A frame may still have batched polygons when the vblank mouse hook ++ * advances the residual. Land those with the OLD residual first so the ++ * presentation transform stays paired with the guest yaw that built them. */ ++ if (s_raster_ok) { flush_flat_batch(); flush_tex_batch(); } ++ if (!isfinite(yaw_units)) yaw_units = 0.0; ++ if (yaw_units > 4.0) yaw_units = 4.0; ++ if (yaw_units < -4.0) yaw_units = -4.0; ++ if (!isfinite(full_turn) || full_turn <= 0.0) { ++ s_yaw_sin = 0.0f; ++ s_yaw_cos = 1.0f; ++ } else { ++ const double radians = ++ yaw_units * (6.28318530717958647692 / full_turn); ++ s_yaw_sin = (float)sin(radians); ++ s_yaw_cos = (float)cos(radians); ++ } ++ s_yaw_center_x = isfinite(center_x) ? (float)center_x : 160.0f; ++ s_yaw_center_y_relative = isfinite(center_y) ++ ? (float)center_y : 120.0f; ++ s_yaw_focal_x = isfinite(focal_x) && focal_x > 1.0 ++ ? (float)focal_x : 160.0f; ++} + + static void glb_set_semi_transparency(int e, int m) { s_semi_en = e; s_semi_mode = m & 3; sw_set_semi_transparency(e, m); } + static void glb_set_mask_bits(int s, int c) { +@@ -2152,6 +2852,7 @@ + glStencilMask(0xFF); + glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + if (s_raw_fbo) { ++ temporal_note_raw_change(); + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_raw_fbo); + glViewport(0, 0, VRAM_W, VRAM_H); + glScissor(x0, y0, rw, rh); +@@ -2266,11 +2967,176 @@ + return 1; + } + ++static void delete_scale_target(GLuint *fbo, GLuint *tex, GLuint *rb) { ++ if (*fbo) { p_glDeleteFramebuffers(1, fbo); *fbo = 0; } ++ if (*tex) { glDeleteTextures(1, tex); *tex = 0; } ++ if (rb && *rb) { p_glDeleteRenderbuffers(1, rb); *rb = 0; } ++} ++ ++/* Rebuild only the scale-dependent GL targets while keeping the current VRAM ++ * image alive. All replacement targets are validated before any live handle ++ * changes; a failed allocation therefore leaves the old renderer untouched. ++ * Colour is nearest-blitted between equivalent logical grids, then stencil is ++ * reconstructed from the copied bit-15 alpha. Native-wide surfaces are ++ * migrated too, avoiding a black/stale transition when the game does not ++ * repaint every framebuffer pixel on the following frame. */ ++static int resize_gpu_raster(int scale) { ++ if (!s_raster_ok || scale == s_scale) return 1; ++ if (scale < 1 || scale > GL_MAX_INTERNAL_SCALE) return 0; ++ ++ flush_flat_batch(); ++ flush_tex_batch(); ++ flush_cpu_upload(); ++ ++ const int old_scale = s_scale; ++ const int new_hw = VRAM_W * scale; ++ const int new_hh = VRAM_H * scale; ++ GLuint new_hr_tex = 0, new_hr_fbo = 0, new_hr_rb = 0; ++ GLuint new_scratch_tex = 0, new_scratch_fbo = 0; ++ GLuint new_wide_tex[WIDE_MAX_SURF] = {0}; ++ GLuint new_wide_fbo[WIDE_MAX_SURF] = {0}; ++ GLuint new_wide_rb[WIDE_MAX_SURF] = {0}; ++ int current_wide_index = -1; ++ ++ new_hr_tex = make_tex(GL_RGBA8, new_hw, new_hh, ++ GL_RGBA, GL_UNSIGNED_BYTE); ++ new_scratch_tex = make_tex(GL_RGBA8, new_hw, new_hh, ++ GL_RGBA, GL_UNSIGNED_BYTE); ++ p_glGenRenderbuffers(1, &new_hr_rb); ++ p_glBindRenderbuffer(PSXGL_RENDERBUFFER, new_hr_rb); ++ p_glRenderbufferStorage(PSXGL_RENDERBUFFER, PSXGL_DEPTH24_STENCIL8, ++ new_hw, new_hh); ++ p_glBindRenderbuffer(PSXGL_RENDERBUFFER, 0); ++ if (!new_hr_tex || !new_scratch_tex || !new_hr_rb || ++ !make_fbo(&new_hr_fbo, new_hr_tex, new_hr_rb) || ++ !make_fbo(&new_scratch_fbo, new_scratch_tex, 0)) { ++ goto fail; ++ } ++ ++ for (int i = 0; i < WIDE_MAX_SURF; ++i) { ++ if (!s_wide_fbo[i]) continue; ++ if (g_wide_cur == s_wide_fbo[i]) current_wide_index = i; ++ const int wide_w = g_wide_w * scale; ++ new_wide_tex[i] = make_tex(GL_RGBA8, wide_w, new_hh, ++ GL_RGBA, GL_UNSIGNED_BYTE); ++ p_glGenRenderbuffers(1, &new_wide_rb[i]); ++ p_glBindRenderbuffer(PSXGL_RENDERBUFFER, new_wide_rb[i]); ++ p_glRenderbufferStorage(PSXGL_RENDERBUFFER, ++ PSXGL_DEPTH24_STENCIL8, wide_w, new_hh); ++ p_glBindRenderbuffer(PSXGL_RENDERBUFFER, 0); ++ if (!new_wide_tex[i] || !new_wide_rb[i] || ++ !make_fbo(&new_wide_fbo[i], new_wide_tex[i], ++ new_wide_rb[i])) { ++ goto fail; ++ } ++ } ++ ++ glDisable(GL_SCISSOR_TEST); ++ glDisable(GL_BLEND); ++ glDisable(GL_STENCIL_TEST); ++ glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); ++ p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo); ++ p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, new_hr_fbo); ++ p_glBlitFramebuffer(0, 0, VRAM_W * old_scale, VRAM_H * old_scale, ++ 0, 0, new_hw, new_hh, ++ GL_COLOR_BUFFER_BIT, GL_NEAREST); ++ for (int i = 0; i < WIDE_MAX_SURF; ++i) { ++ if (!new_wide_fbo[i]) continue; ++ p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_wide_fbo[i]); ++ p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, new_wide_fbo[i]); ++ p_glBlitFramebuffer(0, 0, g_wide_w * old_scale, ++ VRAM_H * old_scale, ++ 0, 0, g_wide_w * scale, new_hh, ++ GL_COLOR_BUFFER_BIT, GL_NEAREST); ++ } ++ ++ { ++ GLuint old_hr_tex = s_hr_tex, old_hr_fbo = s_hr_fbo, ++ old_hr_rb = s_hr_rb; ++ GLuint old_scratch_tex = s_scratch_tex, ++ old_scratch_fbo = s_scratch_fbo; ++ GLuint old_wide_tex[WIDE_MAX_SURF]; ++ GLuint old_wide_fbo[WIDE_MAX_SURF]; ++ GLuint old_wide_rb[WIDE_MAX_SURF]; ++ for (int i = 0; i < WIDE_MAX_SURF; ++i) { ++ old_wide_tex[i] = s_wide_tex[i]; ++ old_wide_fbo[i] = s_wide_fbo[i]; ++ old_wide_rb[i] = s_wide_rb[i]; ++ s_wide_tex[i] = new_wide_tex[i]; new_wide_tex[i] = 0; ++ s_wide_fbo[i] = new_wide_fbo[i]; new_wide_fbo[i] = 0; ++ s_wide_rb[i] = new_wide_rb[i]; new_wide_rb[i] = 0; ++ } ++ s_hr_tex = new_hr_tex; new_hr_tex = 0; ++ s_hr_fbo = new_hr_fbo; new_hr_fbo = 0; ++ s_hr_rb = new_hr_rb; new_hr_rb = 0; ++ s_scratch_tex = new_scratch_tex; new_scratch_tex = 0; ++ s_scratch_fbo = new_scratch_fbo; new_scratch_fbo = 0; ++ s_scale = scale; ++ s_req_scale = scale; ++ g_wide_cur = current_wide_index >= 0 ++ ? s_wide_fbo[current_wide_index] : 0; ++ ++ const float shift = 0.5f / (float)s_scale - 1.0f / 64.0f; ++ p_glUseProgram(s_geo_prog); ++ p_glUniform1f(p_glGetUniformLocation(s_geo_prog, "u_shift"), shift); ++ p_glUseProgram(s_tex_prog); ++ p_glUniform1f(p_glGetUniformLocation(s_tex_prog, "u_shift"), shift); ++ p_glUseProgram(s_blit_prog); ++ p_glUniform1f(p_glGetUniformLocation(s_blit_prog, "u_shift"), shift); ++ p_glUseProgram(0); ++ ++ rebuild_target_stencil(s_hr_fbo, new_hw, new_hh); ++ for (int i = 0; i < WIDE_MAX_SURF; ++i) { ++ if (s_wide_fbo[i]) ++ rebuild_target_stencil(s_wide_fbo[i], ++ g_wide_w * s_scale, new_hh); ++ } ++ s_stencil_valid = 1; ++ ++ delete_scale_target(&old_hr_fbo, &old_hr_tex, &old_hr_rb); ++ delete_scale_target(&old_scratch_fbo, &old_scratch_tex, NULL); ++ for (int i = 0; i < WIDE_MAX_SURF; ++i) ++ delete_scale_target(&old_wide_fbo[i], &old_wide_tex[i], ++ &old_wide_rb[i]); ++ } ++ ++ p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); ++ gl_renderer_invalidate_present(); ++ fprintf(stdout, "psxrecomp: live internal scale = %dx\n", s_scale); ++ return 1; ++ ++fail: ++ delete_scale_target(&new_hr_fbo, &new_hr_tex, &new_hr_rb); ++ delete_scale_target(&new_scratch_fbo, &new_scratch_tex, NULL); ++ for (int i = 0; i < WIDE_MAX_SURF; ++i) ++ delete_scale_target(&new_wide_fbo[i], &new_wide_tex[i], ++ &new_wide_rb[i]); ++ p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); ++ fprintf(stderr, ++ "psxrecomp: live internal-scale allocation failed; keeping %dx\n", ++ s_scale); ++ return 0; ++} ++ + static int init_gpu_raster(void) { + s_scale = s_req_scale; +- +- s_geo_prog = build_program(GEO_VS, GEO_FS); +- s_tex_prog = build_program_ex(TEX_VS, TEX_FS, 1); ++ { ++ const char *full_yaw = getenv("PSX_GEOMETRY_FULL_YAW"); ++ const char *coverage_tint = getenv("PSX_GEOMETRY_COVERAGE_TINT"); ++ s_geometry_full_yaw = full_yaw && full_yaw[0] && full_yaw[0] != '0'; ++ s_geometry_coverage_tint = coverage_tint && coverage_tint[0] && ++ coverage_tint[0] != '0'; ++ } ++ ++ { ++ char *geo_vs = depth_writing_shader(GEO_VS, 4), *tex_vs = depth_writing_shader(TEX_VS, 12); ++ char *tex_fs = tex_vs ? sprite_marking_shader(TEX_FS) : NULL; ++ s_geo_prog = build_program(geo_vs ? geo_vs : GEO_VS, GEO_FS); ++ s_tex_prog = build_program_ex(tex_fs ? tex_vs : TEX_VS, tex_fs ? tex_fs : TEX_FS, 1); ++ free(geo_vs); ++ free(tex_vs); ++ free(tex_fs); ++ } + s_blit_prog = build_program(BLIT_VS, BLIT_FS); + s_pack_prog = build_program(PACK_VS, PACK_FS); + s_stencil_prog = build_program(PACK_VS, STENCIL_FS); +@@ -2335,12 +3201,21 @@ + s_geo_uXcenter = p_glGetUniformLocation(s_geo_prog, "u_xcenter"); + s_tex_uXscale = p_glGetUniformLocation(s_tex_prog, "u_xscale"); + s_tex_uXcenter = p_glGetUniformLocation(s_tex_prog, "u_xcenter"); ++ s_geo_uVisual = p_glGetUniformLocation(s_geo_prog, "u_visual"); ++ s_geo_uCamera = p_glGetUniformLocation(s_geo_prog, "u_camera"); ++ s_geo_uVisualOptions = p_glGetUniformLocation(s_geo_prog, "u_visual_options"); ++ s_tex_uVisual = p_glGetUniformLocation(s_tex_prog, "u_visual"); ++ s_tex_uCamera = p_glGetUniformLocation(s_tex_prog, "u_camera"); ++ s_tex_uVisualOptions = p_glGetUniformLocation(s_tex_prog, "u_visual_options"); ++ s_tex_uPull = p_glGetUniformLocation(s_tex_prog, "u_pull"); + /* Default the new uniforms to the no-op (1.0 scale, 0 centre) -- GLSL would + * otherwise zero them, collapsing all x to 0. */ + p_glUseProgram(s_geo_prog); + p_glUniform1f(s_geo_uXscale, 1.0f); p_glUniform1f(s_geo_uXcenter, 0.0f); ++ geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); + p_glUseProgram(s_tex_prog); + p_glUniform1f(s_tex_uXscale, 1.0f); p_glUniform1f(s_tex_uXcenter, 0.0f); ++ geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 0); + + /* Sample-grid alignment shift: half an HR pixel, set once (S is fixed + * for the lifetime of the pipeline). Backed off by 1/64 native px so +@@ -2372,10 +3247,16 @@ + p_glBindVertexArray(s_geo_vao); + p_glGenBuffers(1, &s_geo_vbo); + p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_geo_vbo); +- p_glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void *)0); ++ p_glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, GEOV * sizeof(float), (void *)0); + p_glEnableVertexAttribArray(0); +- p_glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void *)(2 * sizeof(float))); ++ p_glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, GEOV * sizeof(float), (void *)(2 * sizeof(float))); + p_glEnableVertexAttribArray(1); ++ p_glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, GEOV * sizeof(float), (void *)(6 * sizeof(float))); ++ p_glEnableVertexAttribArray(2); ++ p_glVertexAttribPointer(3, 2, GL_FLOAT, GL_FALSE, GEOV * sizeof(float), (void *)(7 * sizeof(float))); ++ p_glEnableVertexAttribArray(3); ++ p_glVertexAttribPointer(4, 1, GL_FLOAT, GL_FALSE, GEOV * sizeof(float), (void *)(9 * sizeof(float))); ++ p_glEnableVertexAttribArray(4); + + p_glGenVertexArrays(1, &s_tex_vao); + p_glBindVertexArray(s_tex_vao); +@@ -2392,6 +3273,10 @@ + p_glVertexAttribPointer(6, 1, GL_FLOAT, GL_FALSE, st, (void*)(13*sizeof(float))); p_glEnableVertexAttribArray(6); /* raw */ + p_glVertexAttribPointer(7, 4, GL_FLOAT, GL_FALSE, st, (void*)(14*sizeof(float))); p_glEnableVertexAttribArray(7); /* limits */ + p_glVertexAttribPointer(8, 1, GL_FLOAT, GL_FALSE, st, (void*)(18*sizeof(float))); p_glEnableVertexAttribArray(8); /* semi */ ++ p_glVertexAttribPointer(9, 1, GL_FLOAT, GL_FALSE, st, (void*)(19*sizeof(float))); p_glEnableVertexAttribArray(9); /* visual flags */ ++ p_glVertexAttribPointer(10, 2, GL_FLOAT, GL_FALSE, st, (void*)(20*sizeof(float))); p_glEnableVertexAttribArray(10); /* visual pos */ ++ p_glVertexAttribPointer(11, 1, GL_FLOAT, GL_FALSE, st, (void*)(22*sizeof(float))); p_glEnableVertexAttribArray(11); /* reciprocal depth */ ++ p_glVertexAttribPointer(12, 1, GL_FLOAT, GL_FALSE, st, (void*)(23*sizeof(float))); p_glEnableVertexAttribArray(12); /* camera depth */ + } + + p_glGenVertexArrays(1, &s_blit_vao); +@@ -2433,6 +3318,12 @@ + gl_perf_init(); /* frame_perf GPU/CPU phase timing (no-op if queries absent) */ + fprintf(stdout, "psxrecomp: GL GPU pipeline ready (internal scale %dx, " + "mask-bit stencil, texture window, GPU copy/upload)\n", s_scale); ++ if (s_geometry_full_yaw || s_geometry_coverage_tint) { ++ fprintf(stdout, "psxrecomp: geometry diagnostics: full X/Y yaw %s, " ++ "exact-coverage tint %s (presentation mirror only)\n", ++ s_geometry_full_yaw ? "enabled" : "disabled", ++ s_geometry_coverage_tint ? "enabled" : "disabled"); ++ } + return 1; + } + +@@ -2467,11 +3358,60 @@ + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + s_present_prog = build_program(PRESENT_VS, PRESENT_FS); ++ s_ssao_prog = build_program(PRESENT_VS, SSAO_FS); ++ s_ssao_mix_prog = build_program(PRESENT_VS, SSAO_MIX_FS); ++ { ++ const char *fill = getenv("PSX_GL_CRACK_FILL"), *under = getenv("PSX_GL_GROWN_UNDER"); ++ GLfloat widths[2] = {1.0f, 1.0f}; ++ glGetFloatv(0x846E /* GL_ALIASED_LINE_WIDTH_RANGE */, widths); ++ while (glGetError() != GL_NO_ERROR) {} ++ glLineWidth(2.0f); /* a forward compatible context refuses any width but 1 */ ++ s_line_width_max = glGetError() == GL_NO_ERROR && widths[1] > 1.0f ? widths[1] : 1.0f; ++ glLineWidth(1.0f); ++ if (fill) s_crack_fill = s_crack_width = (float)atof(fill); ++ if (under) s_grown_under = atoi(under) != 0; ++ if (getenv("PSX_GL_WORLD_DEPTH")) s_world_depth = atoi(getenv("PSX_GL_WORLD_DEPTH")) != 0; ++ if (getenv("PSX_GL_SHOWN_DUMP")) ++ gl_renderer_record_shown(getenv("PSX_GL_SHOWN_DUMP"), ++ getenv("PSX_GL_SHOWN_DUMP_SKIP") ? atoi(getenv("PSX_GL_SHOWN_DUMP_SKIP")) : 0, ++ getenv("PSX_GL_SHOWN_DUMP_COUNT") ? atoi(getenv("PSX_GL_SHOWN_DUMP_COUNT")) : 240, ++ getenv("PSX_GL_SHOWN_DUMP_WIDTH") ? atoi(getenv("PSX_GL_SHOWN_DUMP_WIDTH")) : 640); ++ if (getenv("PSX_GL_WORLD_DEPTH_MARGIN") && atof(getenv("PSX_GL_WORLD_DEPTH_MARGIN")) > 0.0) ++ s_world_margin = (float)atof(getenv("PSX_GL_WORLD_DEPTH_MARGIN")); ++ } ++ if (s_ssao_prog && s_ssao_mix_prog) { ++ const char *on = getenv("PSX_GL_SSAO"), *radius = getenv("PSX_GL_SSAO_RADIUS"), *strength = getenv("PSX_GL_SSAO_STRENGTH"); ++ s_ssao_mix_uTex = p_glGetUniformLocation(s_ssao_mix_prog, "u_tex"); ++ s_ssao_mix_uShade = p_glGetUniformLocation(s_ssao_mix_prog, "u_shade"); ++ s_ssao_mix_uUvRect = p_glGetUniformLocation(s_ssao_mix_prog, "u_uv_rect"); ++ s_ssao_mix_uSpan = p_glGetUniformLocation(s_ssao_mix_prog, "u_span"); ++ s_ssao_uDepth = p_glGetUniformLocation(s_ssao_prog, "u_depth"); ++ s_ssao_uTex = p_glGetUniformLocation(s_ssao_prog, "u_tex"); ++ s_ssao_uFade = p_glGetUniformLocation(s_ssao_prog, "u_fade"); ++ s_ssao_uUvRect = p_glGetUniformLocation(s_ssao_prog, "u_uv_rect"); ++ s_ssao_uTexel = p_glGetUniformLocation(s_ssao_prog, "u_span"); ++ s_ssao_uProj = p_glGetUniformLocation(s_ssao_prog, "u_proj"); ++ s_ssao_uAo = p_glGetUniformLocation(s_ssao_prog, "u_ao"); ++ if (on) { ++ s_ssao = atoi(on); ++ s_ssao_on = s_ssao == 1; ++ s_ssao_view = s_ssao > 1 && s_ssao <= 5 ? s_ssao - 1 : 0; ++ } ++ if (radius && atof(radius) > 0.0) s_ssao_radius = (float)atof(radius); ++ if (strength && atof(strength) > 0.0) s_ssao_strength = (float)atof(strength); ++ if (getenv("PSX_GL_SSAO_TILT") && atof(getenv("PSX_GL_SSAO_TILT")) >= 0.0 && atof(getenv("PSX_GL_SSAO_TILT")) < 0.95) ++ s_ssao_tilt = (float)atof(getenv("PSX_GL_SSAO_TILT")); ++ } ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + s_interp_prog = build_program(PRESENT_VS, INTERP_FS); + if (s_present_prog && s_interp_prog) { ++#else ++ if (s_present_prog) { ++#endif + p_glGenVertexArrays(1, &s_present_vao); + s_present_uTex = p_glGetUniformLocation(s_present_prog, "u_tex"); + s_present_uUvRect = p_glGetUniformLocation(s_present_prog, "u_uv_rect"); ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + s_interp_uPrev = p_glGetUniformLocation(s_interp_prog, "u_prev"); + s_interp_uCurr = p_glGetUniformLocation(s_interp_prog, "u_curr"); + s_interp_uAlpha = p_glGetUniformLocation(s_interp_prog, "u_alpha"); +@@ -2484,6 +3424,7 @@ + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + } ++#endif + } else ok = 0; + } + if (ok) ok = init_gpu_raster(); +@@ -2496,20 +3437,28 @@ + return 1; + } + +-/* Set the GL swap interval (vsync mode): 1=vsync, 0=immediate, -1=adaptive. +- * Safe to call before or after context creation; applies live when a context +- * exists. Adaptive falls back to vsync if unsupported. */ +-void gl_renderer_set_swap_interval(int interval) { +- s_swap_interval = interval; +- if (s_ctx) { +- if (SDL_GL_SetSwapInterval(interval) != 0 && interval < 0) { +- SDL_GL_SetSwapInterval(1); +- s_swap_interval = 1; +- } +- } +-} +- ++static void apply_swap_interval(void) { ++ if (!s_ctx) return; ++ /* The interpolation thread owns host-refresh presentation and requires ++ * immediate swaps. Preserve the user's requested interval separately so ++ * disabling interpolation can restore it without a renderer restart. */ ++ int effective = s_interp_enabled ? 0 : s_swap_interval; ++ if (SDL_GL_SetSwapInterval(effective) != 0 && effective < 0) { ++ SDL_GL_SetSwapInterval(1); ++ s_swap_interval = 1; ++ } ++} ++ ++/* Set the requested GL swap interval (1=vsync, 0=immediate, -1=adaptive). ++ * Safe before or after context creation. While interpolation is active the ++ * request is remembered and physical swapping stays immediate. */ ++void gl_renderer_set_swap_interval(int interval) { ++ s_swap_interval = interval; ++ apply_swap_interval(); ++} ++ + void gl_renderer_shutdown(void) { ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + if (s_interp_thread) { + SDL_AtomicSet(&s_interp_thread_run, 0); + SDL_WaitThread(s_interp_thread, NULL); +@@ -2532,12 +3481,25 @@ + } + } + ensure_cpu(); ++ temporal_release(); + SDL_GL_DeleteContext(s_ctx); s_ctx = NULL; + } + if (s_interp_mutex) { + SDL_DestroyMutex(s_interp_mutex); + s_interp_mutex = NULL; + } ++ s_interp_enabled = 0; ++ s_interp_suspended = 0; ++ s_interp_content_suspended = 0; ++ s_interp_host_ui_suspended = 0; ++ interp_reset_history_unlocked(); ++#else ++ if (s_ctx) { ++ SDL_GL_MakeCurrent(s_win, s_ctx); ++ ensure_cpu(); ++ SDL_GL_DeleteContext(s_ctx); s_ctx = NULL; ++ } ++#endif + free(s_conv); s_conv = NULL; + s_raster_ok = 0; + /* New context regenerates s_present_tex empty; a stale size makes +@@ -2549,16 +3511,36 @@ + rect_clear(&s_d24_skip_fb); + } + ++/* Host UI callbacks are allowed to change live renderer settings. Those ++ * setters can flush queued geometry and consequently bind an internal FBO and ++ * its VRAM-sized viewport while ImGui is already building the current frame. ++ * Reassert the window target after the callback so the state cannot leak into ++ * this swap (or into ImGui's next-frame state backup). */ ++static void render_host_ui_for_swap(int drawable_w, int drawable_h) { ++ if (!psx_host_ui_gl_visible()) return; ++ psx_host_ui_render_gl(drawable_w, drawable_h); ++ p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); ++ glViewport(0, 0, drawable_w, drawable_h); ++} ++ + /* CPU-readout present (24-bit FMV frames and the PSX_GL_FORCE_CPU_PRESENT + * diagnostic): full-window clear, then a quad into the letterbox rect. + * force_4_3 pins the rect to native 4:3 regardless of the display aspect — + * FMVs are authored 4:3 and have no GTE squash to compensate a stretch, so + * widescreen presents them pillarboxed instead of distorted. */ ++/* Rows of the window above the screen (host_borderless.h): the picture goes in the rest, which starts at the bottom row as OpenGL counts. */ ++static int s_hidden_rows; ++void gl_renderer_set_hidden_rows(int rows) { s_hidden_rows = rows > 0 ? rows : 0; } ++static void gl_visible_size(int *w, int *h) { ++ SDL_GL_GetDrawableSize(s_win, w, h); ++ if (*h > s_hidden_rows) *h -= s_hidden_rows; ++} ++ + void gl_renderer_present(const uint32_t *pixels, int src_w, int src_h, int linear, + int force_4_3, int content_w) { + if (!s_ctx) return; + interp_reset_history(); +- int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); ++ int ww = 0, wh = 0; gl_visible_size(&ww, &wh); + glDisable(GL_SCISSOR_TEST); + glViewport(0, 0, ww, wh); + glClearColor(0.f,0.f,0.f,1.f); glClear(GL_COLOR_BUFFER_BIT); +@@ -2608,6 +3590,8 @@ + p_glBindVertexArray(s_present_vao); glDrawArrays(GL_TRIANGLES, 0, 3); + p_glBindVertexArray(0); p_glUseProgram(0); + pres_record(GL_PRES_CPU, 0, 0, src_w, src_h, lx, ly, lw, lh); ++ render_host_ui_for_swap(ww, wh); ++ shown_capture(0, 1.0f); + latency_ring_mark(LAT_SWAP_BEGIN); + SDL_GL_SwapWindow(s_win); + latency_ring_mark(LAT_SWAP_END); +@@ -2618,10 +3602,12 @@ + void gl_renderer_present_blank(void) { + if (!s_ctx) return; + interp_reset_history(); +- int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); ++ int ww = 0, wh = 0; gl_visible_size(&ww, &wh); + glDisable(GL_SCISSOR_TEST); + glViewport(0, 0, ww, wh); glClearColor(0.f,0.f,0.f,1.f); glClear(GL_COLOR_BUFFER_BIT); + pres_record(GL_PRES_BLANK, 0, 0, 0, 0, 0, 0, ww, wh); ++ render_host_ui_for_swap(ww, wh); ++ shown_capture(0, 1.0f); + latency_ring_mark(LAT_SWAP_BEGIN); + SDL_GL_SwapWindow(s_win); + latency_ring_mark(LAT_SWAP_END); +@@ -2749,6 +3735,7 @@ + * ------------------------------------------------------------------------- */ + + static void wide_free_all(void) { ++ interp_reset_history(); + for (int i = 0; i < WIDE_MAX_SURF; i++) { + if (s_wide_fbo[i]) { p_glDeleteFramebuffers(1, &s_wide_fbo[i]); s_wide_fbo[i] = 0; } + if (s_wide_tex[i]) { glDeleteTextures(1, &s_wide_tex[i]); s_wide_tex[i] = 0; } +@@ -2802,9 +3789,11 @@ + static void glb_wide_configure(int wide_w, int offset) { + if (!s_raster_ok) return; + double t0 = cw_ms(); s_cw_wide_cfgs++; ++ flush_flat_batch(); + flush_tex_batch(); /* a queued batch's wide mirror targets the CURRENT surfaces */ + if (wide_w <= 0) { wide_free_all(); g_wide_w = 0; g_wide_off = 0; s_cw_wide_ms += cw_ms() - t0; return; } + if (wide_w != g_wide_w) wide_free_all(); ++ if (offset != g_wide_off) interp_reset_history(); + g_wide_w = wide_w; + g_wide_off = offset; + s_cw_wide_ms += cw_ms() - t0; +@@ -2814,6 +3803,7 @@ + static void glb_wide_set_target(int base_x) { + if (!s_raster_ok) { g_wide_cur = 0; return; } + double t0 = cw_ms(); s_cw_wide_sets++; ++ flush_flat_batch(); + flush_tex_batch(); /* drain into the OLD target before switching */ + g_wide_cur = wide_fbo_for(base_x); + g_wide_cur_base = base_x; +@@ -2821,7 +3811,7 @@ + } + + /* Stop mirroring (offscreen draws that don't target a framebuffer). */ +-static void glb_wide_disable_target(void) { flush_tex_batch(); g_wide_cur = 0; } ++static void glb_wide_disable_target(void) { flush_flat_batch(); flush_tex_batch(); g_wide_cur = 0; } + + /* Mirror a framebuffer clear: fill the full wide width over [y, y+h) of the + * surface for base_x, so the revealed margins are clean. Mirrors sw_wide_clear: +@@ -2829,9 +3819,11 @@ + static void glb_wide_clear(int base_x, int y, int h, uint16_t color) { + if (!s_raster_ok || s_ws_ablate == 1) return; + double t0 = cw_ms(); s_cw_wide_clears++; ++ flush_flat_batch(); + flush_tex_batch(); + GLuint fbo = wide_fbo_for(base_x); + if (!fbo) { s_cw_wide_ms += cw_ms() - t0; return; } ++ temporal_begin(fbo, y, h, color); + gl_perf_mirror_begin(); + int H = VRAM_H * s_scale; + int y0 = y * s_scale, y1 = (y + h) * s_scale; +@@ -2849,6 +3841,13 @@ + glClearColor(r, g, b, a); + glClearStencil((color >> 15) & 1); /* stencil mirrors bit15, like the color alpha */ + glStencilMask(0xFF); ++ if (s_world_depth && mirror_depth()) { /* under the depth test a frame never shown must not leave its depth to the next */ ++ glDepthMask(GL_TRUE); ++ glClearDepth(1.0); ++ glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); ++ for (int i = 0; i < WIDE_MAX_SURF; i++) ++ if (s_wide_fbo[i] == fbo) s_wide_depth_shown[i] &= ~wide_depth_rows(y, h); ++ } else + glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + glDisable(GL_SCISSOR_TEST); + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); +@@ -2861,9 +3860,11 @@ + static void glb_wide_clear_margins(int base_x, int y, int h, uint16_t color, int sides) { + if (!s_raster_ok || s_ws_ablate == 1 || g_wide_off <= 0) return; + double t0 = cw_ms(); s_cw_wide_clears++; ++ flush_flat_batch(); + flush_tex_batch(); + GLuint fbo = wide_fbo_for(base_x); + if (!fbo) { s_cw_wide_ms += cw_ms() - t0; return; } ++ temporal_invalidate_surface(fbo); + gl_perf_mirror_begin(); + int H = VRAM_H * s_scale; + int W = g_wide_w * s_scale; +@@ -3236,10 +4237,153 @@ + } + + /* Native-wide mirror ablation (perf attribution): see s_ws_ablate. */ +-void gl_renderer_set_ws_ablate(int mode) { s_ws_ablate = (mode >= 0 && mode <= 3) ? mode : 0; } ++void gl_renderer_set_ws_ablate(int mode) { interp_reset_history(); s_ws_ablate = (mode >= 0 && mode <= 3) ? mode : 0; } + int gl_renderer_get_ws_ablate(void) { return s_ws_ablate; } + ++extern void gte_ws_x_squash(int32_t *num, int32_t *den); ++ ++/* What the shading draws through. Frame buffer objects belong to one GL context: the frame loop's and the in-between presenter's. */ ++typedef struct { ++ GLuint depth_fbo, depth, shade_fbo, shade; ++ int w, h, shade_w, shade_h; ++} SsaoTargets; ++static SsaoTargets s_ssao_main, s_ssao_thread; ++ ++static GLuint ssao_depth_texture(int w, int h) { ++ GLuint made = 0; ++ glGenTextures(1, &made); ++ glBindTexture(GL_TEXTURE_2D, made); ++ glTexImage2D(GL_TEXTURE_2D, 0, PSXGL_DEPTH24_STENCIL8, w, h, 0, 0x84F9 /* GL_DEPTH_STENCIL */, 0x84FA /* GL_UNSIGNED_INT_24_8 */, NULL); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); ++ return made; ++} ++ ++static int ssao_targets(SsaoTargets *t, int w, int h, int lw, int lh, int copies) { ++ if (copies && (!t->depth_fbo || t->w != w || t->h != h)) { ++ if (t->depth_fbo) p_glDeleteFramebuffers(1, &t->depth_fbo); ++ if (t->depth) glDeleteTextures(1, &t->depth); ++ t->depth_fbo = 0; ++ t->depth = ssao_depth_texture(w, h); ++ p_glGenFramebuffers(1, &t->depth_fbo); ++ p_glBindFramebuffer(PSXGL_FRAMEBUFFER, t->depth_fbo); ++ p_glFramebufferTexture2D(PSXGL_FRAMEBUFFER, PSXGL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, t->depth, 0); ++ glDrawBuffer(GL_NONE); ++ glReadBuffer(GL_NONE); ++ if (p_glCheckFramebufferStatus(PSXGL_FRAMEBUFFER) != PSXGL_FRAMEBUFFER_COMPLETE) return 0; ++ t->w = w; ++ t->h = h; ++ } ++ if (!t->shade_fbo || t->shade_w != lw || t->shade_h != lh) { ++ if (t->shade_fbo) p_glDeleteFramebuffers(1, &t->shade_fbo); ++ if (t->shade) glDeleteTextures(1, &t->shade); ++ t->shade_fbo = 0; ++ t->shade = make_tex(GL_RGBA8, lw, lh, GL_RGBA, GL_UNSIGNED_BYTE); ++ if (!t->shade || !make_fbo(&t->shade_fbo, t->shade, 0)) return 0; ++ t->shade_w = lw; ++ t->shade_h = lh; ++ } ++ return 1; ++} ++ ++/* Shades `color` by a depth of the same size and puts it on screen. The depth is `depth`, or with 0 is first copied from the ++ * frame buffer `from`. uv is the part shown. 0: nothing drawn, the caller presents plainly. */ ++static int ssao_draw(SsaoTargets *t, GLuint vao, GLuint from, GLuint depth, GLuint color, int w, int h, ++ float u0, float v0, float u1, float v1, int scale, int linear, int lx, int ly, int lw, int lh) { ++ int32_t num = 1, den = 1; ++ float focal_x, focal_y; ++ if (!s_ssao || !s_geometry_correction || !s_ssao_prog || !s_ssao_mix_prog || lw <= 0 || lh <= 0 || w <= 0 || h <= 0) return 0; ++ if (!ssao_targets(t, w, h, lw, lh, depth == 0)) { ++ fprintf(stdout, "psxrecomp: GL ambient occlusion has no targets, switched off\n"); ++ p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); ++ s_ssao = s_ssao_on = s_ssao_view = 0; ++ return 0; ++ } ++ glDisable(GL_SCISSOR_TEST); ++ if (!depth) { ++ p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, from); ++ p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, t->depth_fbo); ++ p_glBlitFramebuffer(0, 0, w, h, 0, 0, w, h, GL_DEPTH_BUFFER_BIT, GL_NEAREST); ++ depth = t->depth; ++ } ++ gte_ws_x_squash(&num, &den); ++ focal_x = s_yaw_focal_x * (float)scale; ++ focal_y = num > 0 && den > 0 ? focal_x * (float)den / (float)num : focal_x; ++ p_glBindFramebuffer(PSXGL_FRAMEBUFFER, t->shade_fbo); ++ glViewport(0, 0, lw, lh); ++ glDisable(GL_BLEND); ++ glDisable(GL_STENCIL_TEST); ++ glDisable(GL_DEPTH_TEST); ++ glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); ++ p_glActiveTexture(PSXGL_TEXTURE0 + 1); ++ glBindTexture(GL_TEXTURE_2D, color); ++ p_glActiveTexture(PSXGL_TEXTURE0); ++ glBindTexture(GL_TEXTURE_2D, depth); ++ p_glUseProgram(s_ssao_prog); ++ p_glUniform1i(s_ssao_uDepth, 0); ++ p_glUniform1i(s_ssao_uTex, 1); ++ p_glUniform4f(s_ssao_uUvRect, u0, v0, u1, v1); ++ p_glUniform4f(s_ssao_uTexel, 1.0f / (float)w, 1.0f / (float)h, (float)s_ssao, 0.0f); ++ p_glUniform4f(s_ssao_uProj, focal_x, focal_y, (u0 + u1) * 0.5f * (float)w, (v0 + v1) * 0.5f * (float)h); ++ p_glUniform4f(s_ssao_uAo, s_ssao_radius, s_ssao_strength, 24.0f * (float)scale, (float)scale); ++ p_glUniform4f(s_ssao_uFade, s_ssao_fade_from, s_ssao_fade_to, s_ssao_tilt, 0.0f); ++ p_glBindVertexArray(vao); ++ glDrawArrays(GL_TRIANGLES, 0, 3); ++ ++ p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); ++ glViewport(lx, ly, lw, lh); ++ p_glActiveTexture(PSXGL_TEXTURE0 + 1); ++ glBindTexture(GL_TEXTURE_2D, t->shade); ++ p_glActiveTexture(PSXGL_TEXTURE0); ++ glBindTexture(GL_TEXTURE_2D, color); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, linear ? GL_LINEAR : GL_NEAREST); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, linear ? GL_LINEAR : GL_NEAREST); ++ p_glUseProgram(s_ssao_mix_prog); ++ p_glUniform1i(s_ssao_mix_uTex, 0); ++ p_glUniform1i(s_ssao_mix_uShade, 1); ++ p_glUniform4f(s_ssao_mix_uUvRect, u0, v0, u1, v1); ++ p_glUniform4f(s_ssao_mix_uSpan, (float)lx, (float)ly, (float)s_ssao, 0.0f); ++ glDrawArrays(GL_TRIANGLES, 0, 3); ++ p_glBindVertexArray(0); ++ p_glUseProgram(0); ++ return 1; ++} ++ ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++/* The depth of each captured real frame, for the presenter's thread to shade it by. */ ++static GLuint s_interp_depth[3], s_interp_depth_fbo; ++static int s_interp_depth_w, s_interp_depth_h; ++static int s_temp_scene_depth; /* the replay is drawing its scene, whose opaque fragments alone leave depth while the shading is on */ ++ ++static void interp_capture_depth(GLuint fbo, int x, int y, int h, int pw, int ph, int dst) { ++ if (!s_ssao || !s_geometry_correction) return; ++ if (!s_interp_depth[0] || s_interp_depth_w != pw || s_interp_depth_h != ph) { ++ for (int i = 0; i < 3; i++) { ++ if (s_interp_depth[i]) glDeleteTextures(1, &s_interp_depth[i]); ++ s_interp_depth[i] = ssao_depth_texture(pw, ph); ++ } ++ s_interp_depth_w = pw; ++ s_interp_depth_h = ph; ++ } ++ if (!s_interp_depth_fbo) p_glGenFramebuffers(1, &s_interp_depth_fbo); ++ p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, s_interp_depth_fbo); ++ p_glFramebufferTexture2D(PSXGL_DRAW_FRAMEBUFFER, PSXGL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, s_interp_depth[dst], 0); ++ glDrawBuffer(GL_NONE); ++ glDisable(GL_SCISSOR_TEST); ++ p_glBlitFramebuffer(x * s_scale, y * s_scale, x * s_scale + pw, y * s_scale + ph, 0, 0, pw, ph, GL_DEPTH_BUFFER_BIT, GL_NEAREST); ++ p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); ++ for (int i = 0; i < WIDE_MAX_SURF; i++) ++ if (s_wide_fbo[i] == fbo) s_wide_depth_shown[i] |= wide_depth_rows(y, h); ++} ++#endif ++ ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++#include "gpu_gl_temporal.c.inc" ++ + static void interp_reset_history_unlocked(void) { ++ temporal_reset(); + s_interp_valid = 0; + s_interp_w = s_interp_h = 0; + s_interp_start = s_interp_last_capture = 0; +@@ -3257,9 +4401,10 @@ + int blend_mode) { + double effective_hz = target_hz < 0.0 + ? -1.0 +- : (target_hz >= 60.0 ? target_hz : host_hz); ++ : (target_hz >= 30.0 ? target_hz : host_hz); + int active = (enabled && +- (effective_hz < 0.0 || effective_hz >= 50.0)) ? 1 : 0; ++ (blend_mode != 2 || s_geometry_correction) && ++ (effective_hz < 0.0 || effective_hz >= 30.0)) ? 1 : 0; + const char *diag = getenv("PSX_GL_INTERP_DIAG"); + s_interp_diag = diag && diag[0] && diag[0] != '0'; + if (active && !s_interp_ctx && s_ctx) { +@@ -3273,6 +4418,7 @@ + * FMV temporarily suspends interpolation and the main context presents. */ + SDL_GL_SetSwapInterval(0); + if (s_interp_ctx && s_interp_mutex) { ++ SDL_AtomicSet(&s_temp_worker_ready, 0); + SDL_AtomicSet(&s_interp_thread_run, 1); + s_interp_thread = SDL_CreateThread(interp_thread_main, + "psx-gl-interp", NULL); +@@ -3284,31 +4430,52 @@ + active = 0; + } + } ++ if (active && s_interp_thread) { ++ while (!SDL_AtomicGet(&s_temp_worker_ready)) SDL_Delay(1); ++ if (blend_mode == 2 && SDL_AtomicGet(&s_temp_worker_ready) < 0) active = 0; ++ } + if (s_interp_mutex) SDL_LockMutex(s_interp_mutex); +- if (active != s_interp_enabled) interp_reset_history_unlocked(); ++ if (active != s_interp_enabled || blend_mode != s_interp_blend_mode) interp_reset_history_unlocked(); + s_interp_enabled = active; + s_interp_host_hz = host_hz; + s_interp_target_hz = active ? effective_hz : 0.0; +- s_interp_blend_mode = blend_mode == 1 ? 1 : 0; ++ s_interp_blend_mode = blend_mode == 2 ? 2 : blend_mode == 1 ? 1 : 0; + if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); ++ /* Enabling interpolation forces immediate swaps; disabling restores the ++ * last user-requested interval kept in s_swap_interval. */ ++ apply_swap_interval(); + if (active && effective_hz < 0.0) + fprintf(stdout, "psxrecomp: GL frame interpolation enabled: uncapped " +- "target on %.1f Hz display (%s blend)\n", host_hz, +- s_interp_blend_mode ? "motion-adaptive" : "linear"); ++ "target on %.1f Hz display (%s)\n", host_hz, ++ s_interp_blend_mode == 2 ? "geometry inter-frame" : s_interp_blend_mode ? "motion-adaptive" : "linear"); + else if (active) + fprintf(stdout, "psxrecomp: GL frame interpolation enabled: %.1f FPS " +- "target on %.1f Hz display (%s blend)\n", effective_hz, host_hz, +- s_interp_blend_mode ? "motion-adaptive" : "linear"); ++ "target on %.1f Hz display (%s)\n", effective_hz, host_hz, ++ s_interp_blend_mode == 2 ? "geometry inter-frame" : s_interp_blend_mode ? "motion-adaptive" : "linear"); + else + fprintf(stdout, "psxrecomp: GL frame interpolation disabled (host %.1f Hz)\n", host_hz); + } + +-void gl_renderer_set_interpolation_suspended(int suspended) { ++static void interp_set_suspended(int *reason, int suspended) { + suspended = suspended ? 1 : 0; + if (s_interp_mutex) SDL_LockMutex(s_interp_mutex); +- if (suspended != s_interp_suspended) interp_reset_history_unlocked(); +- s_interp_suspended = suspended; ++ const int was_suspended = s_interp_suspended; ++ *reason = suspended; ++ s_interp_suspended = ++ s_interp_content_suspended || s_interp_host_ui_suspended; ++ if (was_suspended != s_interp_suspended) interp_reset_history_unlocked(); + if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); ++} ++ ++void gl_renderer_set_interpolation_suspended(int suspended) { ++ interp_set_suspended(&s_interp_content_suspended, suspended); ++} ++ ++/* Host UI suspension is independent of content/FMV suspension. Keeping two ++ * reasons prevents closing a menu during an FMV from briefly waking the ++ * interpolation thread on the shared window. */ ++void gl_renderer_set_host_ui_suspended(int suspended) { ++ interp_set_suspended(&s_interp_host_ui_suspended, suspended); + } + + void gl_renderer_interpolation_diag(int *enabled, int *suspended, +@@ -3325,12 +4492,69 @@ + if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); + } + ++void gl_renderer_geometry_interpolation_diag(uint64_t out[6]) { ++ if (!out) return; ++ if (s_interp_mutex) SDL_LockMutex(s_interp_mutex); ++ out[0] = s_temp_published; ++ out[1] = s_temp_replays; ++ out[2] = s_temp_fallbacks; ++ out[3] = (uint64_t)s_temp_last_matched; ++ out[4] = (uint64_t)s_temp_last_world; ++ out[5] = (uint64_t)s_temp_last_ready; ++ if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); ++} ++ ++/* PSX_GL_INTERP_DIAG: spacing of presents in ms, and of the phase they were ++ * drawn at (captures plus the alpha drawn) in game frames per present. A step ++ * near 0 drew one phase twice, a step near 1 skipped most of an interval. ++ * Pictures are not compared: a replay that moves nothing still advances. */ ++typedef struct { double sum, squares, low, high; int count; } InterpSpread; ++static InterpSpread s_interp_gap, s_interp_step, s_interp_hold, s_interp_work; ++static InterpSpread s_interp_wake, s_interp_wait, s_interp_draw; ++static int s_interp_holds, s_interp_jumps, s_interp_late, s_interp_settle; ++static uint64_t s_interp_last_swap; ++static double s_interp_last_phase; ++static void interp_spread_add(InterpSpread *s, double value) { ++ if (!s->count || value < s->low) s->low = value; ++ if (!s->count || value > s->high) s->high = value; ++ s->sum += value; s->squares += value * value; s->count++; ++} ++static double interp_spread_deviation(const InterpSpread *s) { ++ const double mean = s->count ? s->sum / s->count : 0.0; ++ const double variance = s->count ? s->squares / s->count - mean * mean : 0.0; ++ return variance > 0.0 ? sqrt(variance) : 0.0; ++} ++ + /* Copy a stable display image out of the mutable VRAM/wide render target. + * Returns true once both previous and current images are available. */ + static int interp_capture(GLuint fbo, int x, int y, int w, int h, + int linear, int force_4_3, int source_path) { + if (!s_interp_enabled || s_interp_suspended || !fbo || w <= 0 || h <= 0) return 0; ++ uint64_t began = 0; ++ int under_lock = 0; ++#ifndef PSX_NO_DEBUG_TOOLS ++ { /* A/B: PSX_GL_TEMPORAL_MATCH_LOCKED=1 matches under the lock as before. */ ++ static int cached = -1; ++ if (cached < 0) { const char *e = getenv("PSX_GL_TEMPORAL_MATCH_LOCKED"); cached = e && e[0] == '1'; } ++ under_lock = cached; ++ } ++#endif ++ if (s_interp_blend_mode == 2 && !under_lock) { ++ began = SDL_GetPerformanceCounter(); ++ temporal_prepare(fbo, y, w, h); ++ if (s_interp_diag) ++ began = SDL_GetPerformanceCounter() - began; ++ } + SDL_LockMutex(s_interp_mutex); ++ const uint64_t locked = SDL_GetPerformanceCounter(); ++ if (s_interp_blend_mode == 2 && under_lock) { ++ began = SDL_GetPerformanceCounter(); ++ temporal_prepare(fbo, y, w, h); ++ began = SDL_GetPerformanceCounter() - began; ++ } ++ if (s_interp_diag && s_interp_blend_mode == 2) ++ interp_spread_add(&s_interp_work, (double)began * 1000.0 / ++ (double)SDL_GetPerformanceFrequency()); + /* The presentation context may still have a draw queued which samples one + * of the shared history textures. Order this context's next allocation or + * copy after that draw before recycling a texture. glWaitSync keeps the +@@ -3343,6 +4567,7 @@ + int pw = w * s_scale, ph = h * s_scale; + if (pw != s_interp_w || ph != s_interp_h || + source_path != s_interp_source_path || force_4_3 != s_interp_force_4_3) { ++ temporal_reset(); + s_interp_valid = 0; + s_interp_w = pw; s_interp_h = ph; + s_interp_prev = s_interp_cur = 0; +@@ -3365,6 +4590,22 @@ + glBindTexture(GL_TEXTURE_2D, s_interp_tex[dst]); + glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, + x * s_scale, y * s_scale, pw, ph); ++ wide_depth_shown(fbo, y, h); ++ if (s_interp_blend_mode == 2) interp_capture_depth(fbo, x, y, h, pw, ph, dst); ++ if (s_interp_blend_mode == 2) temporal_publish(fbo, y, h); ++#ifndef PSX_NO_DEBUG_TOOLS ++ if (s_interp_blend_mode == 2 && s_temp_curr.active) ++ temporal_dump_frame(x * s_scale, y * s_scale, pw, ph, -1.0f, ++ s_temp_curr.mesh_stats.departed_faces); ++ if (s_interp_blend_mode == 2 && temporal_edge_diag() && s_temp_curr.active) { ++ const int left = temporal_edge_gap((x + 1) * s_scale, y * s_scale, ph, s_temp_curr.clear); ++ const int right = temporal_edge_gap((x + w - 2) * s_scale, y * s_scale, ph, s_temp_curr.clear); ++ fprintf(stdout, "psxrecomp: edge real %llu %d %d %d\n", ++ (unsigned long long)s_temp_published, left, right, ph); ++ if ((left > right ? left : right) * 33 > ph) ++ temporal_dump_edge(x * s_scale, y * s_scale, pw, ph); ++ } ++#endif + s_interp_fence[dst] = p_glFenceSync(PSXGL_SYNC_GPU_COMMANDS_COMPLETE, 0); + glFlush(); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); +@@ -3388,21 +4629,41 @@ + s_interp_cur = dst; + s_interp_valid = 2; + } ++ s_interp_scale = s_scale; + s_interp_linear = linear; + s_interp_force_4_3 = force_4_3; + s_interp_source_path = source_path; + s_interp_captures++; + int ready = s_interp_valid >= 2; ++ if (s_interp_diag) ++ interp_spread_add(&s_interp_hold, (double)(SDL_GetPerformanceCounter() - locked) * 1000.0 / ++ (double)SDL_GetPerformanceFrequency()); + SDL_UnlockMutex(s_interp_mutex); + return ready; + } + +-static void interp_draw_quad(float alpha, int lx, int ly, int lw, int lh) { ++/* Returns the alpha of the picture it drew, which is 1 when the replay fell back. */ ++static float interp_draw_quad(float alpha, int lx, int ly, int lw, int lh) { ++ if (s_interp_blend_mode == 2) { ++ temporal_verify_endpoint(lx, ly, lw, lh); ++#ifndef PSX_NO_DEBUG_TOOLS ++ alpha = temporal_dump_alpha(alpha); ++#endif ++ if (alpha < 1.0f && temporal_present(alpha, lx, ly, lw, lh)) return alpha; ++ alpha = 1.0f; /* Unsafe/incomplete geometry: current frame, never a crossfade. */ ++ } + int prev = s_interp_prev, curr = s_interp_cur; + if (s_interp_fence[prev]) + p_glWaitSync(s_interp_fence[prev], 0, PSXGL_TIMEOUT_IGNORED); + if (s_interp_fence[curr]) + p_glWaitSync(s_interp_fence[curr], 0, PSXGL_TIMEOUT_IGNORED); ++ if (s_interp_blend_mode == 2 && alpha >= 1.0f && s_interp_depth[curr] && ++ s_interp_depth_w == s_interp_w && s_interp_depth_h == s_interp_h) { ++ const float ix = 0.5f * s_interp_scale / s_interp_w, iy = 0.5f * s_interp_scale / s_interp_h; ++ if (ssao_draw(&s_ssao_thread, s_interp_thread_vao, 0, s_interp_depth[curr], s_interp_tex[curr], s_interp_w, s_interp_h, ++ ix, iy, 1 - ix, 1 - iy, s_interp_scale, s_interp_linear, lx, ly, lw, lh)) ++ return alpha; ++ } + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); + glViewport(lx, ly, lw, lh); + p_glActiveTexture(PSXGL_TEXTURE0); +@@ -3418,12 +4679,135 @@ + p_glUniform1i(s_interp_uCurr, 1); + p_glUniform1f(s_interp_uAlpha, alpha); + p_glUniform1i(s_interp_uBlendMode, s_interp_blend_mode); +- p_glUniform4f(s_interp_uUvRect, 0.f, 0.f, 1.f, 1.f); ++ if (s_interp_blend_mode == 2) { ++ const float ix=0.5f*s_interp_scale/s_interp_w, iy=0.5f*s_interp_scale/s_interp_h; ++ p_glUniform4f(s_interp_uUvRect,ix,iy,1-ix,1-iy); ++ } else p_glUniform4f(s_interp_uUvRect, 0.f, 0.f, 1.f, 1.f); + p_glBindVertexArray(s_interp_thread_vao); + glDrawArrays(GL_TRIANGLES, 0, 3); + p_glBindVertexArray(0); + p_glUseProgram(0); + p_glActiveTexture(PSXGL_TEXTURE0); ++ return alpha; ++} ++ ++/* What reaches the screen, for a report: each swapped picture is read back at the asked width (the window's at most) with ++ * its time and kept in memory, then written as PPM files and a list when the count is reached. Returns 0 when refused. */ ++static char s_shown_dir[512]; ++static int s_shown_skip = 0, s_shown_left = 0, s_shown_count = 0, s_shown_w = 0, s_shown_h = 0, s_shown_width = 640; ++static uint8_t *s_shown_pixels = NULL; ++static struct ShownMeta { uint64_t tick; float alpha; int kind; int placed[7]; } *s_shown_meta = NULL; ++ ++int gl_renderer_record_shown(const char *folder, int skip, int count, int width) { ++ if (!folder || !folder[0] || strlen(folder) >= sizeof s_shown_dir || count <= 0 || count > 2000 || width < 160 || ++ width > 3840 || s_shown_left > 0) return 0; ++ snprintf(s_shown_dir, sizeof s_shown_dir, "%s", folder); ++ s_shown_width = width; ++ s_shown_skip = skip > 0 ? skip : 0; ++ s_shown_count = 0; ++ s_shown_w = s_shown_h = 0; ++ s_shown_left = count; ++ return 1; ++} ++int gl_renderer_recording_shown(void) { return s_shown_left > 0; } ++ ++static void shown_write(void) { ++ char path[640]; ++ FILE *list; ++ snprintf(path, sizeof path, "%s/shown.csv", s_shown_dir); ++ list = fopen(path, "wb"); ++ if (list) fprintf(list, "index,kind,alpha,microseconds,faces,camera_samples,camera_inliers,carried,held,conflicting,departed\n"); ++ for (int i = 0; i < s_shown_count; ++i) { ++ FILE *file; ++ snprintf(path, sizeof path, "%s/shown_%04d.ppm", s_shown_dir, i); ++ file = fopen(path, "wb"); ++ if (file) { ++ fprintf(file, "P6\n%d %d\n255\n", s_shown_w, s_shown_h); ++ for (int y = s_shown_h - 1; y >= 0; --y) ++ fwrite(s_shown_pixels + ((size_t)i * s_shown_h + y) * s_shown_w * 3, 1, (size_t)s_shown_w * 3, file); ++ fclose(file); ++ } ++ if (list) ++ fprintf(list, "%d,%s,%.3f,%.0f,%d,%d,%d,%d,%d,%d,%d\n", i, s_shown_meta[i].kind ? "between" : "frame", s_shown_meta[i].alpha, ++ (double)(s_shown_meta[i].tick - s_shown_meta[0].tick) * 1e6 / (double)SDL_GetPerformanceFrequency(), ++ s_shown_meta[i].placed[0], s_shown_meta[i].placed[1], s_shown_meta[i].placed[2], s_shown_meta[i].placed[3], ++ s_shown_meta[i].placed[4], s_shown_meta[i].placed[5], s_shown_meta[i].placed[6]); ++ } ++ if (list) fclose(list); ++ free(s_shown_pixels); ++ free(s_shown_meta); ++ s_shown_pixels = NULL; ++ s_shown_meta = NULL; ++} ++ ++/* Called with the finished picture in the back buffer, right before its swap. kind: 0 a game frame's own swap, 1 the ++ * in-between presenter's, whose alpha is the picture's place between the last two game frames (1 is the later one). */ ++static void shown_capture(int kind, float alpha) { ++ int ww = 0, wh = 0; ++ GLint bound = 0; ++ GLuint fbo = 0, texture = 0; ++ const GLboolean scissor = glIsEnabled(GL_SCISSOR_TEST); ++ if (s_shown_left <= 0) return; ++ if (s_shown_skip > 0) { --s_shown_skip; return; } ++ gl_visible_size(&ww, &wh); ++ if (ww <= 0 || wh <= 0) return; ++ if (!s_shown_pixels) { ++ s_shown_w = s_shown_width < ww ? s_shown_width : ww; ++ s_shown_h = s_shown_w * wh / ww > 0 ? s_shown_w * wh / ww : 1; ++ if (s_shown_h > 0) { ++ s_shown_pixels = (uint8_t *)malloc((size_t)s_shown_left * s_shown_w * s_shown_h * 3); ++ s_shown_meta = (struct ShownMeta *)malloc((size_t)s_shown_left * sizeof *s_shown_meta); ++ } ++ if (!s_shown_pixels || !s_shown_meta) { ++ free(s_shown_pixels); ++ free(s_shown_meta); ++ s_shown_pixels = NULL; ++ s_shown_meta = NULL; ++ s_shown_left = 0; ++ return; ++ } ++ } ++ glGetIntegerv(GL_TEXTURE_BINDING_2D, &bound); ++ glGenTextures(1, &texture); ++ glBindTexture(GL_TEXTURE_2D, texture); ++ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, s_shown_w, s_shown_h, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); ++ p_glGenFramebuffers(1, &fbo); ++ p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, fbo); ++ p_glFramebufferTexture2D(PSXGL_DRAW_FRAMEBUFFER, PSXGL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0); ++ p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); ++ glReadBuffer(GL_BACK); ++ glDisable(GL_SCISSOR_TEST); ++ p_glBlitFramebuffer(0, 0, ww, wh, 0, 0, s_shown_w, s_shown_h, GL_COLOR_BUFFER_BIT, GL_LINEAR); ++ p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, fbo); ++ glPixelStorei(GL_PACK_ALIGNMENT, 1); ++ glReadPixels(0, 0, s_shown_w, s_shown_h, GL_RGB, GL_UNSIGNED_BYTE, ++ s_shown_pixels + (size_t)s_shown_count * s_shown_w * s_shown_h * 3); ++ glPixelStorei(GL_PACK_ALIGNMENT, 4); ++ p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); ++ p_glDeleteFramebuffers(1, &fbo); ++ glBindTexture(GL_TEXTURE_2D, (GLuint)bound); ++ glDeleteTextures(1, &texture); ++ if (scissor) glEnable(GL_SCISSOR_TEST); ++ s_shown_meta[s_shown_count].tick = SDL_GetPerformanceCounter(); ++ s_shown_meta[s_shown_count].alpha = alpha; ++ s_shown_meta[s_shown_count].kind = kind; ++ memset(s_shown_meta[s_shown_count].placed, 0, sizeof s_shown_meta[s_shown_count].placed); ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++ if (kind) { ++ int *placed = s_shown_meta[s_shown_count].placed; ++ placed[0] = s_temp_shown_faces; ++ placed[1] = s_temp_shown_stats.camera_samples; ++ placed[2] = s_temp_shown_stats.camera_inliers; ++ placed[3] = s_temp_shown_stats.reprojected_vertices; ++ placed[4] = s_temp_shown_stats.held_vertices; ++ placed[5] = s_temp_shown_stats.conflicting_vertices; ++ placed[6] = s_temp_shown_stats.departed_faces; ++ } ++#endif ++ ++s_shown_count; ++ if (--s_shown_left == 0) shown_write(); + } + + static int interp_present(void) { +@@ -3438,7 +4822,7 @@ + if (a > 1.0) a = 1.0; + if (a < 0.0) a = 0.0; + +- int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); ++ int ww = 0, wh = 0; gl_visible_size(&ww, &wh); + int lx, ly, lw, lh; + if (s_interp_force_4_3) + letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); +@@ -3450,22 +4834,44 @@ + glClearColor(0.f, 0.f, 0.f, 1.f); + glClear(GL_COLOR_BUFFER_BIT); + } +- interp_draw_quad((float)a, lx, ly, lw, lh); ++ const float shown = interp_draw_quad((float)a, lx, ly, lw, lh); + if (s_interp_draw_fence) p_glDeleteSync(s_interp_draw_fence); + s_interp_draw_fence = p_glFenceSync(PSXGL_SYNC_GPU_COMMANDS_COMPLETE, 0); + glFlush(); + pres_record(GL_PRES_INTERP, 0, 0, s_interp_w, s_interp_h, + lx, ly, lw, lh); ++ shown_capture(1, shown); + SDL_GL_SwapWindow(s_win); + s_interp_swaps++; ++ if (s_interp_diag) { ++ const uint64_t swapped = SDL_GetPerformanceCounter(); ++ const double phase = (double)s_interp_captures + shown; ++ if (s_interp_settle > 0) --s_interp_settle; ++ else if (s_interp_last_swap) { ++ const double step = phase - s_interp_last_phase; ++ interp_spread_add(&s_interp_gap, (double)(swapped - s_interp_last_swap) * 1000.0 / ++ (double)SDL_GetPerformanceFrequency()); ++ interp_spread_add(&s_interp_step, step); ++ s_interp_late += (double)(swapped - s_interp_last_swap) * s_interp_target_hz > ++ 1.25 * (double)SDL_GetPerformanceFrequency(); ++ s_interp_holds += step < 0.05; ++ s_interp_jumps += step > 0.9; ++ } ++ s_interp_last_swap = swapped; ++ s_interp_last_phase = phase; ++ } + return 1; + } + + static int interp_thread_main(void *opaque) { + (void)opaque; +- if (SDL_GL_MakeCurrent(s_win, s_interp_ctx) != 0) return -1; ++ if (SDL_GL_MakeCurrent(s_win, s_interp_ctx) != 0) { ++ SDL_AtomicSet(&s_temp_worker_ready, -1); ++ return -1; ++ } + SDL_GL_SetSwapInterval(0); /* host-period scheduler owns cadence */ + p_glGenVertexArrays(1, &s_interp_thread_vao); ++ SDL_AtomicSet(&s_temp_worker_ready, temporal_worker_init() ? 1 : -1); + uint64_t freq = SDL_GetPerformanceFrequency(); + uint64_t deadline = SDL_GetPerformanceCounter(); + uint64_t diag_start = deadline, diag_swaps = 0, diag_captures = 0; +@@ -3477,7 +4883,7 @@ + int uncapped = hz < 0.0; + uint64_t now = SDL_GetPerformanceCounter(); + if (!uncapped) { +- if (hz < 50.0) hz = 60.0; ++ if (hz < 30.0) hz = 60.0; + uint64_t period = (uint64_t)((double)freq / hz); + if (!period) period = 1; + deadline += period; +@@ -3492,20 +4898,80 @@ + } + while (SDL_GetPerformanceCounter() < deadline) {} + now = SDL_GetPerformanceCounter(); ++ if (s_interp_diag && !s_interp_settle) ++ interp_spread_add(&s_interp_wake, (double)(now - deadline) * 1000.0 / (double)freq); + } else { + deadline = now; + } + + SDL_LockMutex(s_interp_mutex); + int presented = 0; ++ const uint64_t entered = SDL_GetPerformanceCounter(); + if (SDL_AtomicGet(&s_interp_thread_run) && s_interp_enabled) + presented = interp_present(); ++ if (s_interp_diag) { ++ interp_spread_add(&s_interp_wait, (double)(entered - now) * 1000.0 / (double)freq); ++ interp_spread_add(&s_interp_draw, (double)(SDL_GetPerformanceCounter() - entered) * 1000.0 / ++ (double)freq); ++ } + if (s_interp_diag && now - diag_start >= freq * 5u) { + double seconds = (double)(now - diag_start) / (double)freq; + fprintf(stdout, "psxrecomp: GL interpolation cadence: " + "%.2f captures/s, %.2f presents/s\n", + (double)(s_interp_captures - diag_captures) / seconds, + (double)(s_interp_swaps - diag_swaps) / seconds); ++ if (s_interp_gap.count) { ++ fprintf(stdout, "psxrecomp: GL interpolation pacing: present gap %.3f ms " ++ "(deviation %.3f, %.3f to %.3f), phase step %.3f frames " ++ "(deviation %.3f), %d repeated and %d skipped phases, %d late in %d presents\n", ++ s_interp_gap.sum / s_interp_gap.count, interp_spread_deviation(&s_interp_gap), ++ s_interp_gap.low, s_interp_gap.high, ++ s_interp_step.sum / s_interp_step.count, interp_spread_deviation(&s_interp_step), ++ s_interp_holds, s_interp_jumps, s_interp_late, s_interp_gap.count); ++ if (s_interp_hold.count) ++ fprintf(stdout, "psxrecomp: GL interpolation lock: a capture holds it %.3f ms " ++ "(up to %.3f), matching takes %.3f ms (up to %.3f)\n", ++ s_interp_hold.sum / s_interp_hold.count, s_interp_hold.high, ++ s_interp_work.count ? s_interp_work.sum / s_interp_work.count : 0.0, ++ s_interp_work.high); ++ fprintf(stdout, "psxrecomp: GL interpolation presenter: wakes %.3f ms late (up to %.3f), " ++ "waits %.3f ms for the lock (up to %.3f), draws and swaps in %.3f ms (up to %.3f)\n", ++ s_interp_wake.count ? s_interp_wake.sum / s_interp_wake.count : 0.0, s_interp_wake.high, ++ s_interp_wait.count ? s_interp_wait.sum / s_interp_wait.count : 0.0, s_interp_wait.high, ++ s_interp_draw.count ? s_interp_draw.sum / s_interp_draw.count : 0.0, s_interp_draw.high); ++ memset(&s_interp_wake, 0, sizeof(s_interp_wake)); ++ memset(&s_interp_wait, 0, sizeof(s_interp_wait)); ++ memset(&s_interp_draw, 0, sizeof(s_interp_draw)); ++ memset(&s_interp_hold, 0, sizeof(s_interp_hold)); ++ memset(&s_interp_work, 0, sizeof(s_interp_work)); ++ memset(&s_interp_gap, 0, sizeof(s_interp_gap)); ++ memset(&s_interp_step, 0, sizeof(s_interp_step)); ++ s_interp_holds = s_interp_jumps = s_interp_late = 0; ++ s_interp_settle = 8; /* the report itself delays the next presents */ ++ } ++ if (s_interp_blend_mode == 2) { ++ fprintf(stdout, "psxrecomp: GL geometry interpolation: %llu scenes, " ++ "%llu redraws, %llu fallback captures, %d/%d matched world triangles\n", ++ (unsigned long long)s_temp_published, (unsigned long long)s_temp_replays, ++ (unsigned long long)s_temp_fallbacks, s_temp_curr.matched, s_temp_curr.world); ++ fprintf(stdout, "psxrecomp: GL geometry mesh: %d moving triangles, " ++ "%d seeded / %d shared vertices, %d propagated corners, " ++ "%d conflicts, %d held vertices, %d anchored junctions, " ++ "camera %d of %d corners, %d reprojected vertices, %d departed faces, " ++ "%d welded corners, %d twin faces\n", ++ s_temp_curr.moving, s_temp_curr.mesh_stats.seeded_vertices, ++ s_temp_curr.mesh_stats.shared_vertices, ++ s_temp_curr.mesh_stats.propagated_vertices, ++ s_temp_curr.mesh_stats.conflicting_vertices, ++ s_temp_curr.mesh_stats.held_vertices, ++ s_temp_curr.mesh_stats.anchored_junctions, ++ s_temp_curr.mesh_stats.camera_inliers, ++ s_temp_curr.mesh_stats.camera_samples, ++ s_temp_curr.mesh_stats.reprojected_vertices, ++ s_temp_curr.mesh_stats.departed_faces, ++ s_temp_curr.mesh_stats.welded_corners, ++ s_temp_curr.mesh_stats.twin_faces); ++ } + fflush(stdout); + diag_start = now; + diag_captures = s_interp_captures; +@@ -3518,6 +4984,33 @@ + SDL_GL_MakeCurrent(s_win, NULL); + return 0; + } ++ ++#else ++/* Retain the framework ABI while the experimental presenter is compiled out. ++ * No history, capture, shared context, worker or diagnostic output is created. */ ++void gl_renderer_set_interpolation(int enabled, double host_hz, double target_hz, ++ int blend_mode) { ++ (void)enabled; (void)target_hz; (void)blend_mode; ++ fprintf(stdout, "psxrecomp: GL frame interpolation disabled (build; host %.1f Hz)\n", ++ host_hz); ++} ++void gl_renderer_set_interpolation_suspended(int suspended) { (void)suspended; } ++void gl_renderer_set_hidden_rows(int rows) { (void)rows; } ++void gl_renderer_set_host_ui_suspended(int suspended) { (void)suspended; } ++void gl_renderer_interpolation_diag(int *enabled, int *suspended, ++ int *history_frames, double *host_hz, ++ double *target_hz, uint64_t *swaps) { ++ if (enabled) *enabled = 0; ++ if (suspended) *suspended = 0; ++ if (history_frames) *history_frames = 0; ++ if (host_hz) *host_hz = 0.0; ++ if (target_hz) *target_hz = 0.0; ++ if (swaps) *swaps = 0; ++} ++void gl_renderer_geometry_interpolation_diag(uint64_t out[6]) { ++ if (out) memset(out, 0, 6 * sizeof(*out)); ++} ++#endif + + static void present_target_quad(GLuint tex, int tex_w, int tex_h, + int x, int y, int w, int h, int linear, +@@ -3553,13 +5046,14 @@ + s_last_present_path == GL_PRES_VRAM && + s_last_dx == disp_x && s_last_dy == disp_y && + s_last_dw == w && s_last_dh == h && +- !present_dirty_test(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1)) { ++ !present_dirty_test(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1) && ++ !psx_host_ui_gl_visible()) { + gl_perf_present_enter(); + gl_perf_present_exit(0); + return; + } + gl_perf_present_enter(); /* per-frame backdrop-phase reset + dbg snapshot live in here */ +- int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); ++ int ww = 0, wh = 0; gl_visible_size(&ww, &wh); + int lx, ly, lw, lh; + if (force_4_3) + letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); +@@ -3588,6 +5082,8 @@ + present_target_quad(s_hr_tex, VRAM_W, VRAM_H, + disp_x, disp_y, w, h, linear, lx, ly, lw, lh); + pres_record(GL_PRES_VRAM, disp_x, disp_y, w, h, lx, ly, lw, lh); ++ render_host_ui_for_swap(ww, wh); ++ shown_capture(0, 1.0f); + latency_ring_mark(LAT_SWAP_BEGIN); + SDL_GL_SwapWindow(s_win); + latency_ring_mark(LAT_SWAP_END); +@@ -3607,7 +5103,9 @@ + * x-translated by the reveal offset. No-op when s_wide_fast is off (then the + * mirror drew the full surface, as before). Shared by both present paths. */ + static void wide_blit_center(GLuint wide_fbo, int base_x, int disp_y, int disp_h) { +- if (!s_wide_fast || g_wide_w <= 0) return; ++ /* The correction surface is already a complete full mirror. Copying the ++ * canonical centre here would erase its subpixel/yaw-corrected gameplay. */ ++ if (!s_wide_fast || s_geometry_correction || g_wide_w <= 0) return; + int native_w = g_wide_w - 2 * g_wide_off; + if (native_w <= 0) return; + int S = s_scale; +@@ -3638,6 +5136,71 @@ + * disp_y+disp_h] region into the letterbox, V-flipped like present_vram (FBO y + * bottom-origin → window top). Returns 0 if there's no wide surface for base_x + * (caller falls back). disp_x is the displayed buffer base (the wide-surface key). */ ++/* The wide surface shaded by its own depth. 0: nothing drawn, the plain present follows. */ ++static int ssao_present(GLuint fbo, GLuint tex, int y, int h, int linear, int lx, int ly, int lw, int lh) { ++ if (!ssao_draw(&s_ssao_main, s_present_vao, fbo, 0, tex, g_wide_w * s_scale, VRAM_H * s_scale, ++ 0.5f / (float)g_wide_w, ((float)y + 0.5f) / (float)VRAM_H, ++ ((float)g_wide_w - 0.5f) / (float)g_wide_w, ((float)(y + h) - 0.5f) / (float)VRAM_H, ++ s_scale, linear, lx, ly, lw, lh)) ++ return 0; ++ for (int i = 0; i < WIDE_MAX_SURF; i++) ++ if (s_wide_fbo[i] == fbo) s_wide_depth_shown[i] |= wide_depth_rows(y, h); ++ return 1; ++} ++ ++void gl_renderer_set_mesh_view(int enabled) { s_mesh_view = enabled != 0; } ++int gl_renderer_mesh_view(void) { return s_mesh_view; } ++void gl_renderer_set_full_yaw(int enabled) { ++ if (s_raster_ok) { flush_flat_batch(); flush_tex_batch(); } ++ if ((enabled != 0) != s_geometry_full_yaw) interp_reset_history(); ++ s_geometry_full_yaw = enabled != 0; ++} ++void gl_renderer_set_gap_fill(int enabled) { ++ if (s_raster_ok) { flush_flat_batch(); flush_tex_batch(); } ++ const float width = enabled ? s_crack_width : 0.0f; ++ if ((enabled != 0) != s_grown_under || width != s_crack_fill) interp_reset_history(); ++ if (enabled && !mirror_depth()) memset(s_wide_depth_shown, 0xFF, sizeof s_wide_depth_shown); ++ s_grown_under = enabled != 0; ++ s_crack_fill = width; ++} ++int gl_renderer_gap_fill(void) { return s_grown_under || s_crack_fill > 0.0f; } ++ ++void gl_renderer_set_world_depth(int enabled) { ++ if (s_raster_ok) { flush_flat_batch(); flush_tex_batch(); } ++ if ((enabled != 0) != s_world_depth) interp_reset_history(); ++ if (enabled && !mirror_depth()) memset(s_wide_depth_shown, 0xFF, sizeof s_wide_depth_shown); ++ s_world_depth = enabled != 0; ++} ++int gl_renderer_world_depth(void) { return s_world_depth; } ++ ++static void ssao_mode(void) { ++ const int mode = s_ssao_view ? s_ssao_view + 1 : s_ssao_on; ++ if (mode && !s_ssao) memset(s_wide_depth_shown, 0xFF, sizeof s_wide_depth_shown); ++ s_ssao = mode; ++} ++void gl_renderer_set_ssao(int enabled) { ++ s_ssao_on = enabled != 0; ++ ssao_mode(); ++} ++void gl_renderer_set_ssao_view(int view) { ++ s_ssao_view = view >= 1 && view <= 4 ? view : 0; ++ ssao_mode(); ++} ++int gl_renderer_ssao_view(void) { return s_ssao_view; } ++float gl_renderer_ssao_tilt(void) { return s_ssao_tilt; } ++void gl_renderer_set_ssao_tilt(float tilt) { ++ if (tilt >= 0.0f && tilt < 0.95f) s_ssao_tilt = tilt; ++} ++void gl_renderer_set_ssao_shape(float radius, float strength) { ++ if (radius > 0.0f) s_ssao_radius = radius; ++ if (strength >= 0.0f) s_ssao_strength = strength; ++} ++float gl_renderer_ssao_radius(void) { return s_ssao_radius; } ++/* The camera depths between which the game fades its world out: the shading goes with it. */ ++void gl_renderer_set_ssao_fade(float from, float to) { s_ssao_fade_from = from; s_ssao_fade_to = to; } ++float gl_renderer_ssao_strength(void) { return s_ssao_strength; } ++int gl_renderer_ssao(void) { return s_ssao_on; } ++ + int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) { + if (!s_ctx || !s_raster_ok || g_wide_w <= 0) return 0; + GLuint fbo = 0, tex = 0; +@@ -3653,13 +5216,14 @@ + s_last_present_path == GL_PRES_WIDE && + s_last_dx == disp_x && s_last_dy == disp_y && + s_last_dw == g_wide_w && s_last_dh == disp_h && +- !present_dirty_test(0, disp_y, VRAM_W - 1, disp_y + disp_h - 1)) { ++ !present_dirty_test(0, disp_y, VRAM_W - 1, disp_y + disp_h - 1) && ++ !psx_host_ui_gl_visible()) { + gl_perf_present_enter(); + gl_perf_present_exit(1); + return 1; + } + gl_perf_present_enter(); +- int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); ++ int ww = 0, wh = 0; gl_visible_size(&ww, &wh); + int lx, ly, lw, lh; + letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); + wide_blit_center(fbo, disp_x, disp_y, disp_h); /* fast-path: authoritative centre */ +@@ -3681,9 +5245,13 @@ + s_last_dw = g_wide_w; s_last_dh = disp_h; + return 1; + } +- present_target_quad(tex, g_wide_w, VRAM_H, +- 0, disp_y, g_wide_w, disp_h, linear, lx, ly, lw, lh); ++ wide_depth_shown(fbo, disp_y, disp_h); ++ if (!ssao_present(fbo, tex, disp_y, disp_h, linear, lx, ly, lw, lh)) ++ present_target_quad(tex, g_wide_w, VRAM_H, ++ 0, disp_y, g_wide_w, disp_h, linear, lx, ly, lw, lh); + pres_record(GL_PRES_WIDE, disp_x, disp_y, g_wide_w, disp_h, lx, ly, lw, lh); ++ render_host_ui_for_swap(ww, wh); ++ shown_capture(0, 1.0f); + latency_ring_mark(LAT_SWAP_BEGIN); + SDL_GL_SwapWindow(s_win); + latency_ring_mark(LAT_SWAP_END); +@@ -3702,6 +5270,21 @@ + .set_texture_filter = glb_set_texture_filter, .texture_filter = glb_texture_filter, + .set_semi_transparency = glb_set_semi_transparency, .set_mask_bits = glb_set_mask_bits, + .set_texture_window = glb_set_texture_window, .set_color_modulation = glb_set_color_modulation, ++ .set_geometry_correction = glb_set_geometry_correction, ++ .set_world_triangle = glb_set_world_triangle, ++ .set_precise_triangle = glb_set_precise_triangle, ++ .set_unpinned_triangle = glb_set_unpinned_triangle, ++ .set_perspective_triangle = glb_set_perspective_triangle, ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++ .set_temporal_depth_triangle = glb_set_temporal_depth_triangle, ++ .set_surface_depth_triangle = glb_set_surface_depth_triangle, ++ .set_temporal_identity_triangle = glb_set_temporal_identity_triangle, ++ .set_temporal_sprite = glb_set_temporal_sprite, ++#else ++ .set_temporal_depth_triangle = NULL, ++ .set_temporal_identity_triangle = NULL, ++#endif ++ .set_presentation_yaw = glb_set_presentation_yaw, + .fill_rect = glb_fill_rect, .copy_rect = glb_copy_rect, + .draw_flat_triangle = glb_draw_flat_triangle, .draw_gouraud_triangle = glb_draw_gouraud_triangle, + .draw_textured_triangle = glb_draw_textured_triangle, diff --git a/framework/patches/runtime/src/gpu_render.c.patch b/framework/patches/runtime/src/gpu_render.c.patch new file mode 100644 index 0000000..28a9bd5 --- /dev/null +++ b/framework/patches/runtime/src/gpu_render.c.patch @@ -0,0 +1,68 @@ +diff --git a/runtime/src/gpu_render.c b/runtime/src/gpu_render.c +index cae379538501938c7236686556174d7d34a4fbe8..a6b96fe12d38080895e4a1bf398ca3322446b8be 100644 +--- a/runtime/src/gpu_render.c ++++ b/runtime/src/gpu_render.c +@@ -25,6 +25,13 @@ + .set_mask_bits = sw_set_mask_bits, + .set_texture_window = sw_set_texture_window, + .set_color_modulation = sw_set_color_modulation, ++ .set_geometry_correction = NULL, ++ .set_world_triangle = NULL, ++ .set_precise_triangle = sw_set_precise_triangle, ++ .set_perspective_triangle = NULL, ++ .set_temporal_depth_triangle = NULL, ++ .set_temporal_identity_triangle = NULL, ++ .set_presentation_yaw = NULL, + .fill_rect = sw_fill_rect, + .copy_rect = sw_copy_rect, + .draw_flat_triangle = sw_draw_flat_triangle, +@@ -101,6 +108,49 @@ + void gr_set_mask_bits(int s, int c) { g_b->set_mask_bits(s, c); } + void gr_set_texture_window(uint32_t raw) { g_b->set_texture_window(raw); } + void gr_set_color_modulation(int r, int g, int b, int raw) { g_b->set_color_modulation(r, g, b, raw); } ++void gr_set_geometry_correction(int enabled) { ++ if (g_b->set_geometry_correction) g_b->set_geometry_correction(enabled); ++} ++void gr_set_world_triangle(int world) { ++ if (g_b->set_world_triangle) g_b->set_world_triangle(world); ++} ++void gr_set_precise_triangle(int enabled, ++ int32_t x0, int32_t y0, ++ int32_t x1, int32_t y1, ++ int32_t x2, int32_t y2) { ++ if (g_b->set_precise_triangle) ++ g_b->set_precise_triangle(enabled, x0, y0, x1, y1, x2, y2); ++} ++void gr_set_unpinned_triangle(int32_t x0, int32_t y0, int32_t x1, int32_t y1, int32_t x2, int32_t y2) { ++ if (g_b->set_unpinned_triangle) g_b->set_unpinned_triangle(x0, y0, x1, y1, x2, y2); ++} ++void gr_set_perspective_triangle(int enabled, ++ float q0, float q1, float q2) { ++ if (g_b->set_perspective_triangle) ++ g_b->set_perspective_triangle(enabled, q0, q1, q2); ++} ++void gr_set_temporal_depth_triangle(int enabled, ++ float z0, float z1, float z2) { ++ if (g_b->set_temporal_depth_triangle) ++ g_b->set_temporal_depth_triangle(enabled, z0, z1, z2); ++} ++void gr_set_surface_depth_triangle(float z0, float z1, float z2) { ++ if (g_b->set_surface_depth_triangle) g_b->set_surface_depth_triangle(z0, z1, z2); ++} ++void gr_set_temporal_identity_triangle(uint64_t id0, uint64_t id1, uint64_t id2) { ++ if (g_b->set_temporal_identity_triangle) ++ g_b->set_temporal_identity_triangle(id0, id1, id2); ++} ++void gr_set_temporal_sprite(float depth, const float sides[4]) { ++ if (g_b->set_temporal_sprite) g_b->set_temporal_sprite(depth, sides); ++} ++void gr_set_presentation_yaw(double yaw_units, double full_turn, ++ double center_x, double center_y, ++ double focal_x) { ++ if (g_b->set_presentation_yaw) ++ g_b->set_presentation_yaw(yaw_units, full_turn, ++ center_x, center_y, focal_x); ++} + void gr_fill_rect(int x, int y, int w, int h, uint16_t c) { g_b->fill_rect(x, y, w, h, c); } + void gr_copy_rect(int sx, int sy, int dx, int dy, int w, int h) { g_b->copy_rect(sx, sy, dx, dy, w, h); } + void gr_draw_flat_triangle(int x0, int y0, int x1, int y1, int x2, int y2, uint16_t c) { diff --git a/framework/patches/runtime/src/gte.cpp.patch b/framework/patches/runtime/src/gte.cpp.patch new file mode 100644 index 0000000..553feb7 --- /dev/null +++ b/framework/patches/runtime/src/gte.cpp.patch @@ -0,0 +1,1159 @@ +diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp +index e7a1083a316b72fc30b9fa34038b773911bced51..163280ce64fa04e8c5a5f21e87af541c5daeaeb2 100644 +--- a/runtime/src/gte.cpp ++++ b/runtime/src/gte.cpp +@@ -1,5 +1,7 @@ + #include "gte.h" + #include "cpu_state.h" ++#include "gte_precision.h" ++#include "mod_memory.h" + #include + #include + #include +@@ -184,35 +186,75 @@ + // --------------------------------------------------------------------------- + static int32_t s_ws_xnum = 1, s_ws_xden = 1; + extern "C" int gpu_ws_present_native_43(void); /* gpu.c — suppress on 4:3 frames */ ++static bool ws_x_squashed() { return s_ws_xnum != s_ws_xden && !gpu_ws_present_native_43(); } ++/* The ratio RTPS scales a projected X by on this frame, 1/1 when it leaves X alone. */ ++extern "C" void gte_ws_x_squash(int32_t* num, int32_t* den) { ++ const bool squashed = ws_x_squashed(); ++ *num = squashed ? s_ws_xnum : 1; ++ *den = squashed ? s_ws_xden : 1; ++} + extern "C" void psx_ws_note_gte_project(int nverts); /* gpu.c — gte_game_mode stamp */ + static int s_gte_replay_sandbox = 0; + +-/* Optional visual-only geometry precision cache. The PS1-visible SXY FIFO +- * remains integer and fully faithful; this side cache retains the discarded +- * 16.16 projection fraction so the high-resolution software mirror can match +- * a later GP0 polygon and place its vertices between native pixels. */ +-#define GEOM_CACHE_SIZE 8192u +-struct GeomVertex { ++/* Geometry presentation is enabled here, but vertex recovery is deliberately ++ * owned by the exact address-keyed precision store below. Test 11 also kept a ++ * cache keyed only by rounded SXY; that key is ambiguous and is permanently ++ * disabled. Keep a generation counter solely for state/test ABI compatibility ++ * with checkpoints that predate the removal of that cache. */ ++static uint32_t s_geom_generation = 1; ++static int s_geom_enabled = 0; ++ ++/* The pinned framework's register test predates Test 12 and explicitly seeds ++ * the removed cache to verify generation wrap. Preserve that fixture only in ++ * the test binary; no production build contains or writes this storage. */ ++#ifdef PSX_GTE_REGISTER_TEST ++#define GEOM_TEST_CACHE_SIZE 8192u ++struct GeomTestVertex { + uint32_t packed; + int32_t x16, y16; + uint32_t generation; + }; +-static GeomVertex s_geom_cache[GEOM_CACHE_SIZE]; +-static uint32_t s_geom_generation = 1; +-static int s_geom_enabled = 0; +-static uint32_t s_geom_hits = 0; +- +-/* Exact GTE projection provenance for perspective texture correction. The +- * recompiler/interpreters call gte_precision_store after SWC2 writes an SXY +- * register into guest RAM. GPU DMA later supplies that exact RAM address, so +- * depth lookup follows the packet rather than guessing from rounded X/Y. */ ++static GeomTestVertex s_geom_test_cache[GEOM_TEST_CACHE_SIZE]; ++static uint32_t s_geom_test_hits = 0; ++ ++static inline uint32_t geom_test_hash(uint32_t packed) { ++ packed ^= packed >> 16; ++ return (packed * 2654435761u) & (GEOM_TEST_CACHE_SIZE - 1u); ++} ++#endif ++ ++/* Exact GTE projection provenance for perspective texture correction. SWC2 ++ * stores are observed directly; reviewed game-specific MFC2 + ordinary-SW ++ * routes use the same committed-word gate. GPU DMA later supplies that exact ++ * RAM address, so depth lookup follows the packet rather than guessing from ++ * rounded X/Y. */ + struct PreciseProjection { + uint32_t packed; + int32_t x16, y16; + uint16_t z; + uint8_t valid; ++ uint8_t saturated; ++ uint8_t perspective_valid; ++ uint8_t clamped; /* packed is what the game pinned the projection to */ ++ uint8_t unbounded; /* the GTE's divide or screen stopped short, and exact_x16 and exact_y16 hold what an exact divide gives */ ++ int32_t exact_x16, exact_y16; ++ uint64_t identity; + }; + static PreciseProjection s_precise_sxy[4] = {}; ++/* PSX_GEOMETRY_UNPIN=0 in a build with debug tools leaves pinned corners as the game put them, for comparison. */ ++static int s_precision_unpin = -1; ++static int precision_unpin() { ++ if (s_precision_unpin < 0) { ++ s_precision_unpin = 1; ++#ifndef PSX_NO_DEBUG_TOOLS ++ const char *value = getenv("PSX_GEOMETRY_UNPIN"); ++ if (value && value[0] == '0') s_precision_unpin = 0; ++#endif ++ } ++ return s_precision_unpin; ++} ++/* Half of what a 16.16 coordinate holds: an exact place stays under it with the screen centre in, the drawing offset comes later. */ ++constexpr int64_t kUnboundedLimit16 = INT64_C(1) << 30; + + #define PRECISION_STORE_SIZE 65536u + struct PrecisionStoreEntry { +@@ -223,9 +265,73 @@ + static PrecisionStoreEntry s_precision_store[PRECISION_STORE_SIZE]; + static uint32_t s_precision_generation = 1; + static int s_precision_tracking = 0; ++static GtePrecisionDiagnostics s_precision_diagnostics = {}; ++struct PrecisionWordCommitToken { ++ uint32_t physical; ++ uint8_t domain; ++ uint8_t pending; ++}; ++static PrecisionWordCommitToken s_precision_word_commit = {}; ++enum PrecisionWordDomain : uint8_t { ++ PRECISION_WORD_DOMAIN_NONE = 0, ++ PRECISION_WORD_DOMAIN_MAIN_RAM = 1, ++ PRECISION_WORD_DOMAIN_SCRATCHPAD = 2, ++ PRECISION_WORD_DOMAIN_GPU_DMA = 3, ++}; ++#define PRECISION_STORE_PC_ROUTE_MAX 32u ++struct PrecisionStorePcRoute { ++ uint32_t physical_pc; ++ uint32_t instruction; ++ uint8_t gte_reg; ++}; ++static PrecisionStorePcRoute ++ s_precision_store_pc_routes[PRECISION_STORE_PC_ROUTE_MAX] = {}; ++static uint32_t s_precision_store_pc_route_count = 0; ++#define PRECISION_SCRATCH_STORE_PC_ROUTE_MAX 8u ++struct PrecisionScratchStorePcRoute { ++ uint32_t physical_pc; ++ uint32_t instruction; ++ uint32_t scratch_first; ++ uint32_t scratch_stride; ++ uint32_t scratch_count; ++ uint8_t gte_reg; ++ uint8_t clamps; ++ int16_t low_x, high_x, low_y, high_y; ++}; ++static PrecisionScratchStorePcRoute ++ s_precision_scratch_store_pc_routes[ ++ PRECISION_SCRATCH_STORE_PC_ROUTE_MAX] = {}; ++static uint32_t s_precision_scratch_store_pc_route_count = 0; ++#define PRECISION_MFC2_PC_ROUTE_MAX 8u ++struct PrecisionMfc2PcRoute { ++ uint32_t physical_pc; ++ uint32_t instruction; ++ uint8_t gte_reg; ++}; ++static PrecisionMfc2PcRoute ++ s_precision_mfc2_pc_routes[PRECISION_MFC2_PC_ROUTE_MAX] = {}; ++static uint32_t s_precision_mfc2_pc_route_count = 0; ++static PreciseProjection s_precision_mfc2_capture[4] = {}; ++#define PRECISION_COPY_PC_ROUTE_MAX 32u ++struct PrecisionCopyPcRoute { ++ uint32_t load_physical_pc; ++ uint32_t load_instruction; ++ uint32_t store_physical_pc; ++ uint32_t store_instruction; ++ uint8_t gpr; ++ PreciseProjection capture; ++}; ++static PrecisionCopyPcRoute ++ s_precision_copy_pc_routes[PRECISION_COPY_PC_ROUTE_MAX] = {}; ++static uint32_t s_precision_copy_pc_route_count = 0; + static PreciseProjection s_speculative_saved_sxy[4]; + static uint32_t s_speculative_depth = 0; + static int s_speculative_timeline_invalidated = 0; ++ ++static void precision_copy_captures_clear(void) { ++ for (uint32_t i = 0; i < s_precision_copy_pc_route_count; ++i) ++ s_precision_copy_pc_routes[i].capture.valid = 0; ++} + + static void gte_precision_generation_advance(void) { + if (++s_precision_generation == 0) { +@@ -236,13 +342,20 @@ + + static void gte_geom_generation_advance(void) { + if (++s_geom_generation == 0) { +- std::memset(s_geom_cache, 0, sizeof(s_geom_cache)); ++#ifdef PSX_GTE_REGISTER_TEST ++ std::memset(s_geom_test_cache, 0, sizeof(s_geom_test_cache)); ++#endif + s_geom_generation = 1; + } + } + + extern "C" void gte_precision_timeline_invalidate(void) { + for (int i = 0; i < 4; ++i) s_precise_sxy[i].valid = 0; ++ std::memset(s_precision_mfc2_capture, 0, ++ sizeof(s_precision_mfc2_capture)); ++ precision_copy_captures_clear(); ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; + /* Raw machine-state restore is authoritative even if host polling reached + * it during a speculative validation pass. Defer the generation advance + * until the outer transaction ends so old provenance cannot be restored. */ +@@ -255,6 +368,8 @@ + } + + extern "C" void gte_precision_speculative_begin(void) { ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; + if (s_speculative_depth++ == 0) { + std::memcpy(s_speculative_saved_sxy, s_precise_sxy, + sizeof(s_precise_sxy)); +@@ -273,6 +388,8 @@ + std::memcpy(s_precise_sxy, s_speculative_saved_sxy, + sizeof(s_precise_sxy)); + } ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; + } + } + +@@ -283,64 +400,676 @@ + } + + static inline int precision_ram_address(uint32_t addr, uint32_t *physical) { ++ /* Match memory.c: KSEG2/KSEG3 are not translated through the ordinary ++ * 0x1fffffff mask. Apart from cache control, those accesses are unmapped ++ * and must never alias a low-RAM provenance entry. */ ++ if (addr >= 0xC0000000u) return 0; + uint32_t mapped = addr & 0x1FFFFFFFu; +- if (mapped >= 0x00800000u) return 0; ++ if (mapped >= 0x00800000u) { ++ if (mapped < PSX_MOD_GPU_DMA_APERTURE_BASE || ++ mapped > PSX_MOD_GPU_DMA_APERTURE_BASE + ++ PSX_MOD_GPU_DMA_APERTURE_SIZE - 4u) return 0; ++ *physical = mapped & ~3u; ++ return 1; ++ } + *physical = mapped & 0x1FFFFCu; + return 1; + } + +-extern "C" void gte_precision_tracking_set(int enabled) { +- s_precision_tracking = enabled ? 1 : 0; +- gte_precision_generation_advance(); +-} +- +-extern "C" void gte_precision_store_word(uint32_t addr, uint8_t reg) { +- if (s_speculative_depth != 0 || s_gte_replay_sandbox || !s_precision_tracking || +- reg < 12 || reg > 15) return; +- int index = reg == 15 ? 2 : (int)reg - 12; +- const PreciseProjection &projection = s_precise_sxy[index]; +- if (!projection.valid) return; +- uint32_t physical; +- if (!precision_ram_address(addr, &physical)) return; ++static inline int precision_scratch_address(uint32_t addr, ++ uint32_t *physical) { ++ /* Use the same KUSEG/KSEG0/KSEG1 translation as memory.c, while keeping ++ * KSEG2/KSEG3 from aliasing a reviewed scratchpad slot. */ ++ if (addr >= 0xC0000000u) return 0; ++ const uint32_t mapped = addr & 0x1FFFFFFFu; ++ if (mapped < 0x1F800000u || mapped > 0x1F8003FFu) return 0; ++ *physical = mapped & ~3u; ++ return 1; ++} ++ ++static inline int precision_copy_source_address(uint32_t addr, ++ uint32_t *physical) { ++ return precision_ram_address(addr, physical) || ++ precision_scratch_address(addr, physical); ++} ++ ++static inline int precision_projection_index(uint8_t reg) { ++ if (reg < 12 || reg > 15) return -1; ++ return reg == 15 ? 2 : (int)reg - 12; ++} ++ ++static void precision_store_projection( ++ uint32_t physical, const PreciseProjection &projection) { + PrecisionStoreEntry &entry = s_precision_store[precision_hash(physical)]; ++ if (entry.generation != 0 && entry.addr != physical) { ++ ++s_precision_diagnostics.store_collisions; ++ if (entry.generation == s_precision_generation) ++ ++s_precision_diagnostics.store_evictions; ++ } + entry.addr = physical; + entry.projection = projection; + entry.generation = s_precision_generation; + } + ++static const PrecisionStorePcRoute *precision_store_pc_route( ++ uint32_t store_pc, uint32_t instruction) { ++ const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; ++ for (uint32_t i = 0; i < s_precision_store_pc_route_count; ++i) { ++ if (s_precision_store_pc_routes[i].physical_pc == physical_pc && ++ s_precision_store_pc_routes[i].instruction == instruction) ++ return &s_precision_store_pc_routes[i]; ++ } ++ return nullptr; ++} ++ ++static const PrecisionScratchStorePcRoute *precision_scratch_store_pc_route( ++ uint32_t store_pc, uint32_t instruction) { ++ const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; ++ for (uint32_t i = 0; ++ i < s_precision_scratch_store_pc_route_count; ++i) { ++ const PrecisionScratchStorePcRoute &route = ++ s_precision_scratch_store_pc_routes[i]; ++ if (route.physical_pc == physical_pc && ++ route.instruction == instruction) ++ return &route; ++ } ++ return nullptr; ++} ++ ++static inline int precision_scratch_route_contains( ++ const PrecisionScratchStorePcRoute &route, uint32_t physical) { ++ if (physical < route.scratch_first) return 0; ++ const uint32_t delta = physical - route.scratch_first; ++ if ((delta % route.scratch_stride) != 0u) return 0; ++ return (delta / route.scratch_stride) < route.scratch_count; ++} ++ ++static const PrecisionMfc2PcRoute *precision_mfc2_pc_route( ++ uint32_t mfc2_pc, uint32_t instruction) { ++ const uint32_t physical_pc = mfc2_pc & 0x1FFFFFFFu; ++ for (uint32_t i = 0; i < s_precision_mfc2_pc_route_count; ++i) { ++ if (s_precision_mfc2_pc_routes[i].physical_pc == physical_pc && ++ s_precision_mfc2_pc_routes[i].instruction == instruction) ++ return &s_precision_mfc2_pc_routes[i]; ++ } ++ return nullptr; ++} ++ ++static PrecisionCopyPcRoute *precision_copy_load_pc_route( ++ uint32_t load_pc, uint32_t instruction, uint8_t gpr) { ++ const uint32_t physical_pc = load_pc & 0x1FFFFFFFu; ++ for (uint32_t i = 0; i < s_precision_copy_pc_route_count; ++i) { ++ PrecisionCopyPcRoute &route = s_precision_copy_pc_routes[i]; ++ if (route.load_physical_pc == physical_pc && ++ route.load_instruction == instruction && route.gpr == gpr) ++ return &route; ++ } ++ return nullptr; ++} ++ ++static PrecisionCopyPcRoute *precision_copy_store_pc_route( ++ uint32_t store_pc, uint32_t instruction, uint8_t gpr) { ++ const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; ++ for (uint32_t i = 0; i < s_precision_copy_pc_route_count; ++i) { ++ PrecisionCopyPcRoute &route = s_precision_copy_pc_routes[i]; ++ if (route.store_physical_pc == physical_pc && ++ route.store_instruction == instruction && route.gpr == gpr) ++ return &route; ++ } ++ return nullptr; ++} ++ ++extern "C" void gte_precision_word_write_begin(void) { ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; ++} ++ ++extern "C" void gte_precision_main_ram_word_committed(uint32_t physical) { ++ /* memory.c passes its already-folded main-RAM address only after all four ++ * bytes have been written. Do not turn an unaligned host write into an ++ * adjacent aligned provenance token. */ ++ if (physical >= 0x00200000u || (physical & 3u) != 0u) { ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; ++ return; ++ } ++ s_precision_word_commit.physical = physical; ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_MAIN_RAM; ++ s_precision_word_commit.pending = 1; ++} ++ ++/* Only memory.c arms this domain, after an allocated aperture word commits. ++ * A renderer lookup cannot alias this address with a folded main-RAM word. */ ++extern "C" void gte_precision_gpu_dma_word_committed(uint32_t physical) { ++ if (physical < PSX_MOD_GPU_DMA_APERTURE_BASE || ++ physical > PSX_MOD_GPU_DMA_APERTURE_BASE + ++ PSX_MOD_GPU_DMA_APERTURE_SIZE - 4u || (physical & 3u)) { ++ gte_precision_word_write_begin(); ++ return; ++ } ++ s_precision_word_commit.physical = physical; ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_GPU_DMA; ++ s_precision_word_commit.pending = 1; ++} ++ ++static int precision_memory_commit_matches(uint8_t domain, uint32_t physical) { ++ return domain == (physical < 0x00200000u ? ++ PRECISION_WORD_DOMAIN_MAIN_RAM : PRECISION_WORD_DOMAIN_GPU_DMA); ++} ++ ++extern "C" void gte_precision_scratch_word_committed(uint32_t physical) { ++ /* memory.c supplies an already-translated physical address after all four ++ * scratchpad bytes commit. Slot eligibility is checked independently by ++ * the reviewed post-write route. */ ++ if (physical < 0x1F800000u || physical > 0x1F8003FCu || ++ (physical & 3u) != 0u) { ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; ++ return; ++ } ++ s_precision_word_commit.physical = physical; ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_SCRATCHPAD; ++ s_precision_word_commit.pending = 1; ++} ++ ++extern "C" void gte_precision_tracking_set(int enabled) { ++ s_precision_tracking = enabled ? 1 : 0; ++ std::memset(s_precision_mfc2_capture, 0, ++ sizeof(s_precision_mfc2_capture)); ++ precision_copy_captures_clear(); ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; ++ gte_precision_generation_advance(); ++} ++ ++extern "C" void gte_precision_store_pc_routes_reset(void) { ++ s_precision_store_pc_route_count = 0; ++ s_precision_scratch_store_pc_route_count = 0; ++ s_precision_mfc2_pc_route_count = 0; ++ s_precision_copy_pc_route_count = 0; ++ std::memset(s_precision_store_pc_routes, 0, ++ sizeof(s_precision_store_pc_routes)); ++ std::memset(s_precision_scratch_store_pc_routes, 0, ++ sizeof(s_precision_scratch_store_pc_routes)); ++ std::memset(s_precision_mfc2_pc_routes, 0, ++ sizeof(s_precision_mfc2_pc_routes)); ++ std::memset(s_precision_copy_pc_routes, 0, ++ sizeof(s_precision_copy_pc_routes)); ++ std::memset(s_precision_mfc2_capture, 0, ++ sizeof(s_precision_mfc2_capture)); ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; ++} ++ ++extern "C" int gte_precision_mfc2_pc_route_add( ++ uint32_t mfc2_pc, uint32_t instruction, uint8_t gte_reg) { ++ if ((mfc2_pc & 3u) != 0u || precision_projection_index(gte_reg) < 0 || ++ ((instruction >> 26) & 0x3Fu) != 0x12u || ++ ((instruction >> 21) & 0x1Fu) != 0u || ++ ((instruction >> 11) & 0x1Fu) != gte_reg) ++ return 0; ++ const uint32_t physical_pc = mfc2_pc & 0x1FFFFFFFu; ++ for (uint32_t i = 0; i < s_precision_mfc2_pc_route_count; ++i) { ++ PrecisionMfc2PcRoute &route = s_precision_mfc2_pc_routes[i]; ++ if (route.physical_pc != physical_pc) continue; ++ return route.instruction == instruction && route.gte_reg == gte_reg; ++ } ++ if (s_precision_mfc2_pc_route_count >= PRECISION_MFC2_PC_ROUTE_MAX) ++ return 0; ++ PrecisionMfc2PcRoute &route = ++ s_precision_mfc2_pc_routes[s_precision_mfc2_pc_route_count++]; ++ route.physical_pc = physical_pc; ++ route.instruction = instruction; ++ route.gte_reg = gte_reg; ++ return 1; ++} ++ ++extern "C" int gte_precision_store_pc_route_add( ++ uint32_t store_pc, uint32_t instruction, uint8_t gte_reg) { ++ if ((store_pc & 3u) != 0u || precision_projection_index(gte_reg) < 0 || ++ ((instruction >> 26) & 0x3Fu) != 0x2Bu) ++ return 0; ++ const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; ++ for (uint32_t i = 0; i < s_precision_store_pc_route_count; ++i) { ++ PrecisionStorePcRoute &route = s_precision_store_pc_routes[i]; ++ if (route.physical_pc != physical_pc) continue; ++ return route.instruction == instruction && route.gte_reg == gte_reg; ++ } ++ if (s_precision_store_pc_route_count >= PRECISION_STORE_PC_ROUTE_MAX) ++ return 0; ++ PrecisionStorePcRoute &route = ++ s_precision_store_pc_routes[s_precision_store_pc_route_count++]; ++ route.physical_pc = physical_pc; ++ route.instruction = instruction; ++ route.gte_reg = gte_reg; ++ return 1; ++} ++ ++extern "C" int gte_precision_scratch_store_pc_route_add( ++ uint32_t store_pc, uint32_t instruction, uint8_t gte_reg, ++ uint32_t scratch_first, uint32_t scratch_stride, ++ uint32_t scratch_count) { ++ if ((store_pc & 3u) != 0u || precision_projection_index(gte_reg) < 0 || ++ ((instruction >> 26) & 0x3Fu) != 0x2Bu || ++ scratch_count == 0u || scratch_stride == 0u || ++ (scratch_first & 3u) != 0u || (scratch_stride & 3u) != 0u || ++ scratch_first < 0x1F800000u || scratch_first > 0x1F8003FCu) ++ return 0; ++ ++ /* Prove the final registered slot without overflow, and keep every slot ++ * wholly within the physical 1 KiB scratchpad. */ ++ const uint64_t scratch_last = ++ (uint64_t)scratch_first + ++ (uint64_t)scratch_stride * (uint64_t)(scratch_count - 1u); ++ if (scratch_last > 0x1F8003FCull) return 0; ++ ++ const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; ++ for (uint32_t i = 0; ++ i < s_precision_scratch_store_pc_route_count; ++i) { ++ PrecisionScratchStorePcRoute &route = ++ s_precision_scratch_store_pc_routes[i]; ++ if (route.physical_pc != physical_pc) continue; ++ return route.instruction == instruction && ++ route.gte_reg == gte_reg && ++ route.scratch_first == scratch_first && ++ route.scratch_stride == scratch_stride && ++ route.scratch_count == scratch_count; ++ } ++ if (s_precision_scratch_store_pc_route_count >= ++ PRECISION_SCRATCH_STORE_PC_ROUTE_MAX) ++ return 0; ++ PrecisionScratchStorePcRoute &route = ++ s_precision_scratch_store_pc_routes[ ++ s_precision_scratch_store_pc_route_count++]; ++ route.physical_pc = physical_pc; ++ route.instruction = instruction; ++ route.scratch_first = scratch_first; ++ route.scratch_stride = scratch_stride; ++ route.scratch_count = scratch_count; ++ route.gte_reg = gte_reg; ++ return 1; ++} ++ ++extern "C" int gte_precision_scratch_store_pc_route_clamp( ++ uint32_t store_pc, uint32_t instruction, ++ int16_t low_x, int16_t high_x, int16_t low_y, int16_t high_y) { ++ const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; ++ if (low_x > high_x || low_y > high_y) return 0; ++ for (uint32_t i = 0; i < s_precision_scratch_store_pc_route_count; ++i) { ++ PrecisionScratchStorePcRoute &route = s_precision_scratch_store_pc_routes[i]; ++ if (route.physical_pc != physical_pc || route.instruction != instruction) continue; ++ route.clamps = 1; ++ route.low_x = low_x; route.high_x = high_x; ++ route.low_y = low_y; route.high_y = high_y; ++ return 1; ++ } ++ return 0; ++} ++ ++static uint32_t precision_scratch_route_clamp( ++ const PrecisionScratchStorePcRoute &route, uint32_t packed) { ++ int32_t x = (int16_t)(packed & 0xFFFFu), y = (int16_t)(packed >> 16); ++ if (x < route.low_x) x = route.low_x; ++ if (x > route.high_x) x = route.high_x; ++ if (y < route.low_y) y = route.low_y; ++ if (y > route.high_y) y = route.high_y; ++ return ((uint32_t)x & 0xFFFFu) | ((uint32_t)y << 16); ++} ++ ++extern "C" int gte_precision_copy_pc_route_add( ++ uint32_t load_pc, uint32_t load_instruction, ++ uint32_t store_pc, uint32_t store_instruction, uint8_t gpr) { ++ if ((load_pc & 3u) != 0u || (store_pc & 3u) != 0u || gpr == 0u || ++ gpr >= 32u || ((load_instruction >> 26) & 0x3Fu) != 0x23u || ++ ((store_instruction >> 26) & 0x3Fu) != 0x2Bu || ++ ((load_instruction >> 16) & 0x1Fu) != gpr || ++ ((store_instruction >> 16) & 0x1Fu) != gpr) ++ return 0; ++ const uint32_t load_physical_pc = load_pc & 0x1FFFFFFFu; ++ const uint32_t store_physical_pc = store_pc & 0x1FFFFFFFu; ++ for (uint32_t i = 0; i < s_precision_copy_pc_route_count; ++i) { ++ PrecisionCopyPcRoute &route = s_precision_copy_pc_routes[i]; ++ if (route.load_physical_pc != load_physical_pc && ++ route.store_physical_pc != store_physical_pc) ++ continue; ++ return route.load_physical_pc == load_physical_pc && ++ route.load_instruction == load_instruction && ++ route.store_physical_pc == store_physical_pc && ++ route.store_instruction == store_instruction && ++ route.gpr == gpr; ++ } ++ if (s_precision_copy_pc_route_count >= PRECISION_COPY_PC_ROUTE_MAX) ++ return 0; ++ PrecisionCopyPcRoute &route = ++ s_precision_copy_pc_routes[s_precision_copy_pc_route_count++]; ++ route.load_physical_pc = load_physical_pc; ++ route.load_instruction = load_instruction; ++ route.store_physical_pc = store_physical_pc; ++ route.store_instruction = store_instruction; ++ route.gpr = gpr; ++ route.capture.valid = 0; ++ return 1; ++} ++ ++extern "C" uint32_t gte_precision_mfc2_pc_read( ++ uint32_t mfc2_pc, uint32_t instruction, uint8_t gte_reg, ++ uint32_t packed) { ++ const PrecisionMfc2PcRoute *route = ++ precision_mfc2_pc_route(mfc2_pc, instruction); ++ if (!route || route->gte_reg != gte_reg) ++ return packed; ++ if (s_speculative_depth != 0 || s_gte_replay_sandbox || ++ !s_precision_tracking) ++ return packed; ++ ++ const int index = precision_projection_index(gte_reg); ++ if (index < 0) return packed; ++ PreciseProjection &capture = s_precision_mfc2_capture[index]; ++ capture.valid = 0; ++ const PreciseProjection &projection = s_precise_sxy[index]; ++ if (projection.valid && projection.packed == packed) ++ capture = projection; ++ return packed; ++} ++ ++extern "C" uint32_t gte_precision_copy_pc_read( ++ uint32_t load_pc, uint32_t instruction, uint8_t gpr, ++ uint32_t addr, uint32_t packed) { ++ PrecisionCopyPcRoute *route = ++ precision_copy_load_pc_route(load_pc, instruction, gpr); ++ if (!route) return packed; ++ if (s_speculative_depth != 0 || s_gte_replay_sandbox || ++ !s_precision_tracking) ++ return packed; ++ ++s_precision_diagnostics.copy_load_attempts; ++ ++ /* Every authoritative execution replaces the prior capture, including a ++ * source miss. An older equal packed value must never survive this load. */ ++ route->capture.valid = 0; ++ if ((addr & 3u) != 0u) return packed; ++ uint32_t physical; ++ if (!precision_copy_source_address(addr, &physical)) return packed; ++ const PrecisionStoreEntry &entry = ++ s_precision_store[precision_hash(physical)]; ++ if (entry.generation != s_precision_generation || entry.addr != physical || ++ !entry.projection.valid || entry.projection.packed != packed) ++ return packed; ++ route->capture = entry.projection; ++ ++s_precision_diagnostics.copy_load_accepts; ++ return packed; ++} ++ ++extern "C" void gte_precision_store_word(uint32_t addr, uint8_t reg) { ++ /* Consume before every early return. Speculative validation, disabled ++ * tracking and ineligible GTE registers must not leave a real or replayed ++ * write token available to a later SWC2 callback. */ ++ const uint32_t committed_physical = s_precision_word_commit.physical; ++ const uint8_t committed_domain = s_precision_word_commit.domain; ++ const int committed = s_precision_word_commit.pending != 0; ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; ++ if (s_speculative_depth != 0 || s_gte_replay_sandbox || !s_precision_tracking || ++ reg < 12 || reg > 15 || (addr & 3u) != 0u) return; ++ uint32_t physical; ++ if (!precision_ram_address(addr, &physical)) return; ++ int commit_matches = committed && ++ precision_memory_commit_matches(committed_domain, physical) && ++ committed_physical == physical; ++#ifdef PSX_GTE_REGISTER_TEST ++ /* The pinned standalone register fixture links gte.cpp without memory.c ++ * and directly seeds then stores precision entries. Preserve that ++ * historical test seam only when no producer token exists; an explicitly ++ * armed mismatched token still exercises the production rejection. */ ++ if (!committed) commit_matches = 1; ++#endif ++ if (!commit_matches) { ++ ++s_precision_diagnostics.store_uncommitted_rejections; ++ return; ++ } ++ int index = precision_projection_index(reg); ++ const PreciseProjection &projection = s_precise_sxy[index]; ++ if (!projection.valid) return; ++ precision_store_projection(physical, projection); ++} ++ ++extern "C" void gte_precision_store_pc_word( ++ uint32_t store_pc, uint32_t instruction, uint32_t addr, ++ uint32_t packed) { ++ /* Most ordinary SWs are not reviewed projection stores and must leave the ++ * token available for an immediately-following SWC2 callback. */ ++ const PrecisionStorePcRoute *route = ++ precision_store_pc_route(store_pc, instruction); ++ if (!route) return; ++ ++ const uint32_t committed_physical = s_precision_word_commit.physical; ++ const uint8_t committed_domain = s_precision_word_commit.domain; ++ const int committed = s_precision_word_commit.pending != 0; ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; ++ ++ if (s_speculative_depth != 0 || s_gte_replay_sandbox || ++ !s_precision_tracking || (addr & 3u) != 0u) ++ return; ++ ++s_precision_diagnostics.registered_store_attempts; ++ uint32_t physical; ++ if (!precision_ram_address(addr, &physical)) return; ++ if (!committed || !precision_memory_commit_matches(committed_domain, physical) || ++ committed_physical != physical) { ++ ++s_precision_diagnostics.store_uncommitted_rejections; ++ return; ++ } ++ ++ const int index = precision_projection_index(route->gte_reg); ++ if (index < 0) return; ++ const PreciseProjection &projection = s_precision_mfc2_capture[index]; ++ if (!projection.valid) return; ++ if (projection.packed != packed) { ++ ++s_precision_diagnostics.registered_store_packed_rejections; ++ return; ++ } ++ precision_store_projection(physical, projection); ++ ++s_precision_diagnostics.registered_store_accepts; ++} ++ ++extern "C" void gte_precision_scratch_store_pc_word( ++ uint32_t store_pc, uint32_t instruction, ++ uint32_t addr, uint32_t packed) { ++ /* An unrelated SW callback must not steal the token from the matching ++ * reviewed helper that immediately follows it. */ ++ const PrecisionScratchStorePcRoute *route = ++ precision_scratch_store_pc_route(store_pc, instruction); ++ if (!route) return; ++ ++ const uint32_t committed_physical = s_precision_word_commit.physical; ++ const uint8_t committed_domain = s_precision_word_commit.domain; ++ const int committed = s_precision_word_commit.pending != 0; ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; ++ ++ if (s_speculative_depth != 0 || s_gte_replay_sandbox || ++ !s_precision_tracking || (addr & 3u) != 0u) ++ return; ++ ++s_precision_diagnostics.registered_store_attempts; ++ ++ uint32_t physical; ++ if (!precision_scratch_address(addr, &physical) || ++ !precision_scratch_route_contains(*route, physical)) ++ return; ++ if (!committed || ++ committed_domain != PRECISION_WORD_DOMAIN_SCRATCHPAD || ++ committed_physical != physical) { ++ ++s_precision_diagnostics.store_uncommitted_rejections; ++ return; ++ } ++ ++ const int index = precision_projection_index(route->gte_reg); ++ if (index < 0) return; ++ const PreciseProjection &projection = s_precision_mfc2_capture[index]; ++ if (!projection.valid) return; ++ if (projection.packed != packed) { ++ /* A corner the game pinned to its guard band keeps the projection it was pinned from. */ ++ if (!route->clamps || precision_scratch_route_clamp(*route, projection.packed) != packed) { ++ ++s_precision_diagnostics.registered_store_packed_rejections; ++ return; ++ } ++ PreciseProjection pinned = projection; ++ pinned.packed = packed; ++ pinned.clamped = 1; ++ precision_store_projection(physical, pinned); ++ ++s_precision_diagnostics.registered_store_accepts; ++ return; ++ } ++ precision_store_projection(physical, projection); ++ ++s_precision_diagnostics.registered_store_accepts; ++} ++ ++extern "C" void gte_precision_copy_pc_word( ++ uint32_t store_pc, uint32_t instruction, uint8_t gpr, ++ uint32_t addr, uint32_t packed) { ++ PrecisionCopyPcRoute *route = ++ precision_copy_store_pc_route(store_pc, instruction, gpr); ++ if (!route) return; ++ ++ const uint32_t committed_physical = s_precision_word_commit.physical; ++ const uint8_t committed_domain = s_precision_word_commit.domain; ++ const int committed = s_precision_word_commit.pending != 0; ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; ++ ++ /* Replay/speculative execution may consume only its transient memory token; ++ * it must neither replace nor erase an authoritative load capture. */ ++ if (s_speculative_depth != 0 || s_gte_replay_sandbox || ++ !s_precision_tracking) ++ return; ++ ++s_precision_diagnostics.copy_store_attempts; ++ ++ const PreciseProjection projection = route->capture; ++ route->capture.valid = 0; ++ if ((addr & 3u) != 0u) return; ++ uint32_t physical; ++ if (!precision_ram_address(addr, &physical)) return; ++ if (!committed || !precision_memory_commit_matches(committed_domain, physical) || ++ committed_physical != physical) { ++ ++s_precision_diagnostics.store_uncommitted_rejections; ++ return; ++ } ++ if (!projection.valid) return; ++ if (projection.packed != packed) { ++ ++s_precision_diagnostics.copy_store_packed_rejections; ++ return; ++ } ++ precision_store_projection(physical, projection); ++ ++s_precision_diagnostics.copy_store_accepts; ++} ++ + extern "C" void gte_precision_invalidate_word(uint32_t addr) { + if (s_speculative_depth != 0 || s_gte_replay_sandbox || !s_precision_tracking) return; + uint32_t physical; +- if (!precision_ram_address(addr, &physical)) return; ++ if (!precision_copy_source_address(addr, &physical)) return; + PrecisionStoreEntry &entry = s_precision_store[precision_hash(physical)]; + if (entry.generation == s_precision_generation && entry.addr == physical) + entry.generation = 0; + } + ++static GtePrecisionLookupResult precision_lookup_projection( ++ uint32_t addr, uint32_t packed, ++ const PreciseProjection **projection) { ++ if (projection) *projection = nullptr; ++ if (s_speculative_depth != 0) ++ return GTE_PRECISION_LOOKUP_SPECULATIVE; ++ if (!s_precision_tracking) ++ return GTE_PRECISION_LOOKUP_TRACKING_DISABLED; ++ if ((addr & 3u) != 0u) ++ return GTE_PRECISION_LOOKUP_SOURCE_UNALIGNED; ++ uint32_t physical; ++ if (!precision_ram_address(addr, &physical)) ++ return GTE_PRECISION_LOOKUP_SOURCE_NOT_RAM; ++ const PrecisionStoreEntry &entry = s_precision_store[precision_hash(physical)]; ++ if (entry.generation != s_precision_generation) ++ return GTE_PRECISION_LOOKUP_STORE_MISS; ++ if (entry.addr != physical) { ++ ++s_precision_diagnostics.lookup_collisions; ++ return GTE_PRECISION_LOOKUP_STORE_COLLISION; ++ } ++ if (!entry.projection.valid) ++ return GTE_PRECISION_LOOKUP_PROJECTION_INVALID; ++ if (entry.projection.packed != packed) ++ return GTE_PRECISION_LOOKUP_PACKED_MISMATCH; ++ if (entry.projection.z == 0) ++ return GTE_PRECISION_LOOKUP_ZERO_DEPTH; ++ /* A corner the GTE saturated and the game then pinned to its guard band is drawn where it projects. */ ++ if (entry.projection.saturated && !(entry.projection.clamped && entry.projection.unbounded && precision_unpin())) ++ return GTE_PRECISION_LOOKUP_SATURATED; ++ if (projection) *projection = &entry.projection; ++ return GTE_PRECISION_LOOKUP_ACCEPTED; ++} ++ ++extern "C" GtePrecisionLookupResult gte_precision_load_word_ex( ++ uint32_t addr, uint32_t packed, ++ int32_t *x16, int32_t *y16, uint16_t *z) { ++ const PreciseProjection *projection = nullptr; ++ const GtePrecisionLookupResult result = ++ precision_lookup_projection(addr, packed, &projection); ++ if (result != GTE_PRECISION_LOOKUP_ACCEPTED) return result; ++ /* Only a corner the game pinned leaves its packed pixel: it is drawn where an exact divide puts it. */ ++ const bool exact = projection->clamped && projection->unbounded && precision_unpin(); ++ if (x16) *x16 = exact ? projection->exact_x16 : projection->x16; ++ if (y16) *y16 = exact ? projection->exact_y16 : projection->y16; ++ if (z) *z = projection->z; ++ return GTE_PRECISION_LOOKUP_ACCEPTED; ++} ++ ++extern "C" GtePrecisionLookupResult gte_precision_load_perspective_word_ex( ++ uint32_t addr, uint32_t packed, uint16_t *z) { ++ const PreciseProjection *projection = nullptr; ++ const GtePrecisionLookupResult result = ++ precision_lookup_projection(addr, packed, &projection); ++ if (result != GTE_PRECISION_LOOKUP_ACCEPTED) return result; ++ if (!projection->perspective_valid) ++ return GTE_PRECISION_LOOKUP_PERSPECTIVE_INVALID; ++ if (z) *z = projection->z; ++ return GTE_PRECISION_LOOKUP_ACCEPTED; ++} ++ ++extern "C" GtePrecisionLookupResult gte_precision_load_identity( ++ uint32_t addr, uint32_t packed, uint64_t *identity) { ++ const PreciseProjection *projection = nullptr; ++ const GtePrecisionLookupResult result = ++ precision_lookup_projection(addr, packed, &projection); ++ if (identity) ++ *identity = result == GTE_PRECISION_LOOKUP_ACCEPTED ? projection->identity : 0u; ++ return result; ++} ++ ++extern "C" int gte_precision_unpin_enabled(void) { return precision_unpin(); } ++extern "C" void gte_precision_unpin_set(int enabled) { s_precision_unpin = enabled ? 1 : 0; } ++ ++extern "C" int gte_precision_word_clamped(uint32_t addr, uint32_t packed) { ++ const PreciseProjection *projection = nullptr; ++ return precision_lookup_projection(addr, packed, &projection) == GTE_PRECISION_LOOKUP_ACCEPTED && ++ projection->clamped; ++} ++ + extern "C" int gte_precision_load_word(uint32_t addr, uint32_t packed, + int32_t *x16, int32_t *y16, + uint16_t *z) { +- if (s_speculative_depth != 0 || !s_precision_tracking) return 0; +- uint32_t physical; +- if (!precision_ram_address(addr, &physical)) return 0; +- const PrecisionStoreEntry &entry = s_precision_store[precision_hash(physical)]; +- if (entry.generation != s_precision_generation || entry.addr != physical || +- !entry.projection.valid || entry.projection.packed != packed) +- return 0; +- if (x16) *x16 = entry.projection.x16; +- if (y16) *y16 = entry.projection.y16; +- if (z) *z = entry.projection.z; +- return entry.projection.z != 0; +-} +- +-static inline uint32_t geom_hash(uint32_t packed) { +- packed ^= packed >> 16; +- return (packed * 2654435761u) & (GEOM_CACHE_SIZE - 1u); ++ return gte_precision_load_word_ex(addr, packed, x16, y16, z) == ++ GTE_PRECISION_LOOKUP_ACCEPTED; ++} ++ ++extern "C" void gte_precision_diagnostics(GtePrecisionDiagnostics *out) { ++ if (out) *out = s_precision_diagnostics; ++} ++ ++extern "C" void gte_precision_diagnostics_reset(void) { ++ s_precision_diagnostics = {}; + } + + extern "C" void gte_geometry_correction_set(int enabled) { + s_geom_enabled = enabled ? 1 : 0; +- s_geom_hits = 0; ++#ifdef PSX_GTE_REGISTER_TEST ++ s_geom_test_hits = 0; ++#endif + gte_geom_generation_advance(); + } + +@@ -349,50 +1078,59 @@ + } + + extern "C" uint32_t gte_geometry_correction_hits(void) { +- return s_geom_hits; +-} +- ++#ifdef PSX_GTE_REGISTER_TEST ++ return s_geom_test_hits; ++#else ++ return 0; ++#endif ++} ++ ++/* Retained as a hard-disabled ABI stub for older generated/native test code. ++ * Geometry must never recover provenance from a rounded coordinate alone. */ + extern "C" int gte_geometry_correction_lookup(uint32_t packed, + int32_t *x16, int32_t *y16) { ++#ifdef PSX_GTE_REGISTER_TEST + if (s_speculative_depth != 0 || !s_geom_enabled) return 0; +- const GeomVertex &entry = s_geom_cache[geom_hash(packed)]; +- if (entry.generation != s_geom_generation || entry.packed != packed) return 0; ++ const GeomTestVertex &entry = s_geom_test_cache[geom_test_hash(packed)]; ++ if (entry.generation != s_geom_generation || entry.packed != packed) ++ return 0; + if (x16) *x16 = entry.x16; + if (y16) *y16 = entry.y16; +- s_geom_hits++; ++ s_geom_test_hits++; + return 1; +-} +- +-static inline void geom_note(uint32_t packed, int64_t x16, int64_t y16) { +- if (s_speculative_depth != 0 || s_gte_replay_sandbox || !s_geom_enabled) return; +- /* Saturated off-screen projections are unsuitable for subpixel recovery. */ +- int32_t x = (int16_t)(packed & 0xFFFFu); +- int32_t y = (int16_t)(packed >> 16); +- if (x <= -0x400 || x >= 0x3FF || y <= -0x400 || y >= 0x3FF) return; +- GeomVertex &entry = s_geom_cache[geom_hash(packed)]; +- entry.packed = packed; +- entry.x16 = (int32_t)x16; +- entry.y16 = (int32_t)y16; +- entry.generation = s_geom_generation; +-} +- +-// Per-draw suppression of the X-squash (8C far-backdrop). The far backdrop +-// (ocean/cloud/distant mountain) is a parallax layer that is conceptually at +-// infinity: squashing it toward centre leaves its geometry short of the +-// revealed 16:9 edges (blue void). Drawing it UN-squashed lets its 4:3-extent +-// fill the stretched frame (skybox treatment) while the near 3D world keeps the +-// wider FOV. The recompiler brackets the backdrop driver (FUN_8004db3c) with +-// gte_ws_set_suppress(1)/(0) ([widescreen] backdrop_unsquash_funcs). +-// The driver (FUN_8004db3c) actually draws a MIX: the far backdrop (which we +-// want un-squashed) AND nearer props (doors/mansion models) that must stay +-// squashed or they drift relative to the rest of the world. So suppression is +-// DEPTH-GATED: only vertices with SZ (projected Z) >= s_ws_far_threshold get +-// un-squashed while suppress is active; nearer props keep the squash. Threshold +-// tuned live via the ws_far_threshold TCP cmd; SZ stats below characterize the +-// driver's depth range so the split is set from data, not a guess. +-static int s_ws_suppress = 0; ++#else ++ (void)packed; ++ (void)x16; ++ (void)y16; ++ return 0; ++#endif ++} ++ ++// Per-scope diagnostic suppression of the classic widescreen X squash for ++// candidate finite far backdrops. The recompiler brackets each reviewed ++// [widescreen.backdrop].unsquash_funcs driver with ++// gte_ws_set_suppress(1)/(0). Those drivers may draw a mixture of backdrop and ++// ordinary room geometry, so the scope remains DEPTH-GATED: only vertices with ++// SZ >= s_ws_far_threshold retain their authored 4:3 projection before the ++// final wide stretch. The counter makes nested/recursive tagged renderers safe: ++// an inner return must not clear its caller's active scope. Live testing has ++// not linked this path to the skyline plane, so it must remain opt-in. ++static unsigned s_ws_suppress_depth = 0; ++static int s_ws_backdrop_repair_enabled = 0; + static int32_t s_ws_far_threshold = 900; /* SZ split: near props squashed, far backdrop un-squashed (8C, tunable via ws_far_threshold) */ +-extern "C" void gte_ws_set_suppress(int on) { s_ws_suppress = on ? 1 : 0; } ++extern "C" void gte_ws_set_suppress(int on) { ++ if (on) { ++ if (s_ws_suppress_depth != 0xFFFFFFFFu) ++s_ws_suppress_depth; ++ } else if (s_ws_suppress_depth != 0) { ++ --s_ws_suppress_depth; ++ } ++} ++extern "C" void gte_ws_set_backdrop_repair_enabled(int on) { ++ s_ws_backdrop_repair_enabled = on ? 1 : 0; ++} ++extern "C" int gte_ws_get_backdrop_repair_enabled(void) { ++ return s_ws_backdrop_repair_enabled; ++} + extern "C" void gte_ws_set_far_threshold(int t) { s_ws_far_threshold = t; } + extern "C" int gte_ws_get_far_threshold(void) { return (int)s_ws_far_threshold; } + /* SZ stats observed while suppress is active (one frame window, reset on read). */ +@@ -814,13 +1552,13 @@ + // this frame is being stretched — never on a 4:3-presented frame (FMV / + // full-2D screen), so content and present stay locked. + int64_t xterm = (int64_t)gte->IR1 * h_div_sz; +- bool do_squash = (s_ws_xnum != s_ws_xden) && !gpu_ws_present_native_43(); ++ bool do_squash = ws_x_squashed(); + const bool dome_call = ws_dome_call_matches(); + // Curved backdrops are authored to cover the original 4:3 projection. + // In classic widescreen, leave that projection intact and let the normal + // final-frame stretch cover the wide output instead of shrinking it first. + if (dome_call) do_squash = false; +- if (do_squash && s_ws_suppress) { ++ if (do_squash && s_ws_suppress_depth != 0) { + /* Depth-gated: un-squash only FAR geometry (the backdrop); keep near + * props squashed so they stay aligned. Record SZ stats for tuning. */ + int32_t sz = gte->SZ[3]; +@@ -829,7 +1567,7 @@ + if (sz > s_ws_sz_max) s_ws_sz_max = sz; + s_ws_sz_n++; + } +- if (sz >= s_ws_far_threshold) { ++ if (s_ws_backdrop_repair_enabled && sz >= s_ws_far_threshold) { + do_squash = false; + if (!s_gte_replay_sandbox) s_ws_sz_far++; + } +@@ -863,11 +1601,46 @@ + s_precise_sxy[2].x16 = (int32_t)sx16; + s_precise_sxy[2].y16 = (int32_t)sy16; + s_precise_sxy[2].z = gte->SZ[3]; +- s_precise_sxy[2].valid = gte->SZ[3] != 0; ++ s_precise_sxy[2].valid = 1; ++ s_precise_sxy[2].identity = UINT64_C(0x8000000000000000) | ++ static_cast(static_cast(V[0])) | ++ static_cast(static_cast(V[1])) << 16 | ++ static_cast(static_cast(V[2])) << 32; ++ s_precise_sxy[2].saturated = ++ static_cast(gte->SXY[2] & 0xFFFF) != sx || ++ static_cast((uint32_t)gte->SXY[2] >> 16) != sy; ++ /* The GTE's divide stops at twice the focal length and its screen at 1023: an exact divide does not. */ ++ const bool stopped = s_precise_sxy[2].saturated || (uint32_t)gte->SZ[3] * 2 <= (uint32_t)gte->H; ++ s_precise_sxy[2].unbounded = 0; ++ if (stopped && gte->SZ[3] != 0 && (do_squash || xterm == (int64_t)gte->IR1 * h_div_sz)) { ++ const int64_t exact = ((int64_t)gte->H << 16) / gte->SZ[3]; ++ int64_t exact_x = (int64_t)gte->IR1 * exact; ++ if (do_squash) exact_x = exact_x * s_ws_xnum / s_ws_xden; ++ int64_t exact_y = (int64_t)gte->IR2 * exact; ++ /* Too far for a 16.16 coordinate once the screen centre is added: nearer along the same ray from that centre. */ ++ const int64_t room_x = kUnboundedLimit16 - 1 - std::abs((int64_t)gte->OFX); ++ const int64_t room_y = kUnboundedLimit16 - 1 - std::abs((int64_t)gte->OFY); ++ double nearer = 1.0; ++ if (std::abs(exact_x) > room_x) nearer = (double)room_x / (double)std::abs(exact_x); ++ if (std::abs(exact_y) > room_y) nearer = std::min(nearer, (double)room_y / (double)std::abs(exact_y)); ++ if (room_x > 0 && room_y > 0) { ++ if (nearer < 1.0) { ++ exact_x = (int64_t)((double)exact_x * nearer); ++ exact_y = (int64_t)((double)exact_y * nearer); ++ } ++ s_precise_sxy[2].exact_x16 = (int32_t)(gte->OFX + exact_x); ++ s_precise_sxy[2].exact_y16 = (int32_t)(gte->OFY + exact_y); ++ s_precise_sxy[2].unbounded = 1; ++ } ++ } ++ /* Perspective textures need the original camera depth, not a value ++ * clamped into SZ3, and must reject the GTE's near divide-overflow ++ * region. Geometry can still use the retained X/Y when this bit is 0. */ ++ s_precise_sxy[2].perspective_valid = ++ gte->H != 0 && gte->MAC3 > 0 && gte->MAC3 <= 0xFFFF && ++ (uint32_t)gte->SZ[3] * 2u > (uint32_t)gte->H; + s_precise_sxy[3] = s_precise_sxy[2]; + } +- geom_note((uint32_t)gte->SXY[2], sx16, sy16); +- + // Step 5: Depth cueing (MAC0/IR0) — only for last vertex of RTPT or RTPS + if (setMac0) { + int64_t mac0 = (int64_t)gte->DQA * h_div_sz + gte->DQB; +@@ -1494,6 +2267,7 @@ + extern "C" int gte_replay_side_effects_begin(void) { + using namespace PSXRecomp::GTE; + if (s_gte_replay_sandbox) return 0; ++ gte_precision_word_write_begin(); + s_gte_replay_saved_caller_ra = s_gte_caller_ra; + s_gte_replay_sandbox = 1; + return 1; +@@ -1503,6 +2277,7 @@ + if (!s_gte_replay_sandbox) return; + s_gte_caller_ra = s_gte_replay_saved_caller_ra; + s_gte_replay_sandbox = 0; ++ gte_precision_word_write_begin(); + } + + static uint32_t gte_cpu_lzcr(uint32_t value); +@@ -2010,6 +2785,33 @@ + p.y16 = y16; + p.z = z; + p.valid = 1; ++ p.saturated = 0; ++ p.unbounded = 0; ++ p.perspective_valid = 1; ++} ++ ++extern "C" void gte_test_set_precise_exact(uint32_t index, int saturated, int unbounded, int32_t exact_x16, int32_t exact_y16) { ++ if (index >= 4) return; ++ auto &p = PSXRecomp::GTE::s_precise_sxy[index]; ++ p.saturated = saturated ? 1 : 0; ++ p.unbounded = unbounded ? 1 : 0; ++ p.exact_x16 = exact_x16; ++ p.exact_y16 = exact_y16; ++} ++ ++extern "C" void gte_test_get_precise_exact(uint32_t index, int *saturated, int *unbounded, int32_t *exact_x16, int32_t *exact_y16) { ++ if (index >= 4) return; ++ const auto &p = PSXRecomp::GTE::s_precise_sxy[index]; ++ if (saturated) *saturated = p.saturated; ++ if (unbounded) *unbounded = p.unbounded; ++ if (exact_x16) *exact_x16 = p.exact_x16; ++ if (exact_y16) *exact_y16 = p.exact_y16; ++} ++ ++extern "C" void gte_test_set_precise_perspective_valid(uint32_t index, ++ int valid) { ++ if (index >= 4) return; ++ PSXRecomp::GTE::s_precise_sxy[index].perspective_valid = valid ? 1 : 0; + } + + extern "C" void gte_test_get_precise_projection(uint32_t index, +@@ -2029,8 +2831,8 @@ + + extern "C" void gte_test_seed_geometry(uint32_t packed, int32_t x16, + int32_t y16) { +- auto &entry = PSXRecomp::GTE::s_geom_cache[ +- PSXRecomp::GTE::geom_hash(packed)]; ++ auto &entry = PSXRecomp::GTE::s_geom_test_cache[ ++ PSXRecomp::GTE::geom_test_hash(packed)]; + entry.packed = packed; + entry.x16 = x16; + entry.y16 = y16; diff --git a/framework/patches/runtime/src/interrupts.c.patch b/framework/patches/runtime/src/interrupts.c.patch new file mode 100644 index 0000000..0ee52f1 --- /dev/null +++ b/framework/patches/runtime/src/interrupts.c.patch @@ -0,0 +1,99 @@ +diff --git a/runtime/src/interrupts.c b/runtime/src/interrupts.c +index c267a87da5a361b8d34d15a00622279e231c314f..f105a564bdfa0c97aa46db56bda7acc5db2ba626 100644 +--- a/runtime/src/interrupts.c ++++ b/runtime/src/interrupts.c +@@ -37,6 +37,7 @@ + #include "lockstep.h" + #include "psx_cycles.h" + #include "psx_scheduler.h" ++#include "save_point.h" + #include + #include + #include +@@ -447,6 +448,10 @@ + * 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; + +@@ -731,6 +736,8 @@ + } + + 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) +@@ -749,11 +756,10 @@ + 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))) { +- if ((++s_fast_maintenance & 0x3FFFu) == 0) { +- extern void savestate_poll(CPUState* cpu, uint32_t resume_pc); ++ 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); +- debug_server_poll(); +- } ++ if (periodic) debug_server_poll(); + return; + } + } +@@ -795,11 +801,10 @@ + psx_idle_note_check(cpu, check_pc); + } + if ((i_stat & i_mask) == 0 && sw_pending == 0) { +- if ((++s_fast_maintenance & 0x3FFFu) == 0) { +- extern void savestate_poll(CPUState* cpu, uint32_t resume_pc); ++ 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); +- debug_server_poll(); +- } ++ if (periodic) debug_server_poll(); + return; + } + /* Idle skip advanced time and a device raised I_STAT — deliver below. */ +@@ -817,11 +822,10 @@ + s_last_interrupt_check_cycle = psx_get_cycle_count(); + g_ls_suppress_record++; + total_checks++; +- if ((total_checks & 0x3FFFu) == 0) { +- extern void savestate_poll(CPUState* cpu, uint32_t resume_pc); +- savestate_poll(cpu, check_pc); +- debug_server_poll(); +- } ++ 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; + } + +@@ -862,10 +866,8 @@ + * 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) { +- extern void savestate_poll(CPUState* cpu, uint32_t resume_pc); +- savestate_poll(cpu, s_last_interrupt_check_pc); +- } ++ 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 +@@ -1649,6 +1651,7 @@ + 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; + } diff --git a/framework/patches/runtime/src/iso_reader_c.cpp.patch b/framework/patches/runtime/src/iso_reader_c.cpp.patch new file mode 100644 index 0000000..cc31b27 --- /dev/null +++ b/framework/patches/runtime/src/iso_reader_c.cpp.patch @@ -0,0 +1,102 @@ +diff --git a/runtime/src/iso_reader_c.cpp b/runtime/src/iso_reader_c.cpp +index 13c0beb274a76941b174bd8083f89c92266cc403..a3fd420284513192ff70b4446bcb50e9b1adf02c 100644 +--- a/runtime/src/iso_reader_c.cpp ++++ b/runtime/src/iso_reader_c.cpp +@@ -4,9 +4,25 @@ + * Provides iso_open() / iso_read_sector() / iso_close() for use by cdrom.c + */ + ++#include "iso_overlay.h" + #include "iso_reader.h" + #include "mod_runtime.h" ++#include + #include ++#include ++#include ++ ++namespace { ++ ++const PsxIsoOverlay* g_overlay = nullptr; ++std::vector g_plain; ++void* g_game_disc = nullptr; ++ ++bool overlaid(void* handle) { ++ return g_overlay && std::find(g_plain.begin(), g_plain.end(), handle) == g_plain.end(); ++} ++ ++} // namespace + + extern "C" { + +@@ -16,14 +32,39 @@ + delete reader; + return nullptr; + } ++ g_game_disc = reader; + return reader; ++} ++ ++void iso_set_overlay(const PsxIsoOverlay* overlay) { g_overlay = overlay; } ++ ++void* iso_open_plain(const char* path) { ++ void* const game = g_game_disc; ++ void* handle = iso_open(path); ++ g_game_disc = game; ++ if (handle) g_plain.push_back(handle); ++ return handle; ++} ++ ++void* iso_game_disc(void) { return g_game_disc; } ++ ++int iso_read_raw_sector_plain(void* handle, uint32_t lba, uint8_t* buffer) { ++ return handle && static_cast(handle)->ReadRawSector(lba, buffer) ? 1 : 0; ++} ++ ++uint32_t iso_sector_count_plain(void* handle) { ++ return handle ? static_cast(handle)->GetSectorCount() : 0; + } + + int iso_read_sector(void* handle, uint32_t lba, uint8_t* buffer, int size) { + if (!handle) return 0; + auto* reader = static_cast(handle); + (void)size; /* ReadSector always reads 2048 bytes */ +- if (!reader->ReadSector(lba, buffer)) return 0; ++ uint8_t raw[2352]; ++ const int stood_in = overlaid(handle) && g_overlay->read ? g_overlay->read(handle, lba, raw) : 0; ++ if (stood_in < 0) return 0; ++ if (stood_in > 0) std::memcpy(buffer, raw + 24, 2048); ++ else if (!reader->ReadSector(lba, buffer)) return 0; + mod_runtime_patch_disc_sector(lba, 0, buffer, 2048); + return 1; + } +@@ -31,15 +72,17 @@ + int iso_read_raw_sector(void* handle, uint32_t lba, uint8_t* buffer, int size) { + if (!handle || size < 2352) return 0; + auto* reader = static_cast(handle); +- if (!reader->ReadRawSector(lba, buffer)) return 0; ++ const int stood_in = overlaid(handle) && g_overlay->read ? g_overlay->read(handle, lba, buffer) : 0; ++ if (stood_in < 0) return 0; ++ if (stood_in == 0 && !reader->ReadRawSector(lba, buffer)) return 0; + mod_runtime_patch_disc_sector(lba, 1, buffer, 2352); + return 1; + } + + uint32_t iso_sector_count(void* handle) { + if (!handle) return 0; +- auto* reader = static_cast(handle); +- return reader->GetSectorCount(); ++ const uint32_t stood_in = overlaid(handle) && g_overlay->sectors ? g_overlay->sectors(handle) : 0; ++ return stood_in ? stood_in : iso_sector_count_plain(handle); + } + + /* CD-track TOC accessors (multi-track / CD-DA support). track is 1-based. */ +@@ -65,6 +108,9 @@ + + void iso_close(void* handle) { + if (!handle) return; ++ if (overlaid(handle) && g_overlay->closed) g_overlay->closed(handle); ++ if (handle == g_game_disc) g_game_disc = nullptr; ++ g_plain.erase(std::remove(g_plain.begin(), g_plain.end(), handle), g_plain.end()); + auto* reader = static_cast(handle); + reader->Close(); + delete reader; diff --git a/framework/patches/runtime/src/main.cpp.patch b/framework/patches/runtime/src/main.cpp.patch new file mode 100644 index 0000000..8dd2fd4 --- /dev/null +++ b/framework/patches/runtime/src/main.cpp.patch @@ -0,0 +1,2128 @@ +diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp +index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e352b13313 100644 +--- a/runtime/src/main.cpp ++++ b/runtime/src/main.cpp +@@ -1,9 +1,16 @@ +-/* main.cpp — Phase 3 runtime entry point. ++/* main.cpp — Phase 3 runtime entry point. + * + * Loads BIOS ROM, initializes CPU state + SDL display, calls into + * the recompiled reset vector. BIOS drives execution; SDL presents + * VRAM at each vblank via callback from gpu_vblank_tick(). + */ ++ ++/* Keep the Windows min/max macros out of this C++ translation unit. SDL may ++ * include windows.h before the explicit platform includes below, so this must ++ * be defined before any runtime header is included. */ ++#if defined(_WIN32) && !defined(NOMINMAX) ++#define NOMINMAX ++#endif + + #include "cpu_state.h" + #include "psx_bios_image.h" /* the active BIOS's self-description */ +@@ -35,6 +42,11 @@ + #include "gpu_render.h" + #include "gpu_gl_renderer.h" + #include "gpu_vk_renderer.h" ++#include "host_borderless.h" ++#include "host_pick_slot.h" ++#include "host_ui.h" ++extern "C" void gl_renderer_geometry_interpolation_diag(uint64_t out[6]); ++#include "gte_precision.h" + #include "frame_pacing.h" + #include "latency_ring.h" + #include "sio.h" +@@ -59,6 +71,9 @@ + #include "game_options.h" + #include "mod_plugins.h" + #include "mod_runtime.h" ++#ifdef PSX_HAS_GAME_HOST ++#include "game_host.h" ++#endif + #include "dirty_ram_interp.h" + #include "crc32.h" + #include "disc_identity.h" +@@ -87,6 +102,9 @@ + #endif + #include + #include ++#include ++#include ++#include + #include + #include + #include +@@ -100,6 +118,11 @@ + #include + #include + ++/* Internal GL seam: host-menu suspension is a separate reason from FMV/ ++ * content suspension, so either owner can release independently. */ ++extern "C" void gl_renderer_set_host_ui_suspended(int suspended); ++extern "C" void gl_renderer_set_hidden_rows(int rows); ++ + #ifdef _WIN32 + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN +@@ -108,7 +131,7 @@ + #include + #include + #include +-#include ++#include + #else + #include + #include +@@ -131,6 +154,20 @@ + #ifndef DEFAULT_DEBUG_PORT + #error DEFAULT_DEBUG_PORT must be defined by the runtime target. + #endif ++ ++/* SDL_RenderSetVSync was added in SDL 2.0.18. Keep the explicitly supported ++ * SDL2 fallback buildable against older distro/toolchain copies; its OpenGL ++ * renderer can still release the swap interval through the public GL API. ++ * Use a project-local name because SDL3's psx_sdl.h already defines its own ++ * psx_sdl_render_set_vsync compatibility helper. */ ++static int sdl_render_set_vsync_compat(SDL_Renderer *renderer, int enabled) { ++#if defined(PSX_SDL3) || SDL_VERSION_ATLEAST(2, 0, 18) ++ return SDL_RenderSetVSync(renderer, enabled); ++#else ++ (void)renderer; ++ return SDL_GL_SetSwapInterval(enabled ? 1 : 0); ++#endif ++} + + extern "C" uint64_t gte_get_exec_count(void); + +@@ -164,11 +201,12 @@ + * every install must arm it — not just repo checkouts that happen to carry a + * loose EXE copy next to game.toml. Source order: + * 1. The local EXE file from game.toml (dev checkouts; recompiler input). +- * 2. The boot EXE extracted from the mounted disc image (end-user installs +- * — the disc is the same bytes the BIOS loads, i.e. the true reference). ++ * 2. The configured native range sliced from the boot EXE on the mounted ++ * disc (end-user installs may intentionally omit a derived code-only EXE). + * Registration passes ownership of the malloc'd buffer to memory.c. */ + static void arm_text_image_guard(const std::string &exe_path, + uint32_t load_address, ++ uint32_t text_size, + const std::string &disc_path) { + const uint32_t phys_lo = load_address & 0x1FFFFFFFu; + /* 1. Local EXE file (skip the 2048-byte PS-X EXE header). */ +@@ -177,9 +215,12 @@ + if (ef) { + std::streamsize sz = ef.tellg(); + if (sz > 2048) { +- uint32_t img_len = (uint32_t)(sz - 2048); +- uint8_t *img = (uint8_t *)std::malloc(img_len); +- if (img) { ++ uint32_t available = (uint32_t)(sz - 2048); ++ uint32_t img_len = text_size ? text_size : available; ++ if (img_len > available) img_len = 0; ++ uint8_t *img = img_len ++ ? (uint8_t *)std::malloc(img_len) : nullptr; ++ if (img && img_len) { + ef.seekg(2048, std::ios::beg); + if (ef.read((char *)img, img_len)) { + dirty_ram_register_text_image(phys_lo, img, img_len); +@@ -193,7 +234,10 @@ + } + } + } +- /* 2. Extract the boot EXE from the disc image. */ ++ /* 2. Extract the boot EXE from the disc image. Its declared load address ++ * can precede the code-only image consumed by the recompiler, so slice by ++ * the configured physical load and text size rather than blindly copying ++ * from payload byte zero. */ + if (!disc_path.empty()) { + PS1::ISOReader iso; + if (iso.Open(disc_path)) { +@@ -239,17 +283,38 @@ + uint8_t *file = (uint8_t *)std::malloc(ent.size); + if (file) { + size_t got = iso.ReadFile(boot_name, file, ent.size); +- if (got > 2048) { +- uint32_t img_len = (uint32_t)(got - 2048); +- uint8_t *img = (uint8_t *)std::malloc(img_len); +- if (img) { +- memcpy(img, file + 2048, img_len); ++ if (got > 2048 && got >= 0x20 && ++ std::memcmp(file, "PS-X EXE", 8) == 0) { ++ const auto read_le32 = [](const uint8_t *p) { ++ return (uint32_t)p[0] | ++ ((uint32_t)p[1] << 8) | ++ ((uint32_t)p[2] << 16) | ++ ((uint32_t)p[3] << 24); ++ }; ++ uint32_t disc_load = ++ read_le32(file + 0x18) & 0x1FFFFFFFu; ++ uint32_t declared_len = read_le32(file + 0x1C); ++ uint32_t payload_len = (uint32_t)(got - 2048); ++ if (declared_len < payload_len) payload_len = declared_len; ++ uint32_t payload_off = phys_lo >= disc_load ++ ? phys_lo - disc_load : UINT32_MAX; ++ uint32_t img_len = text_size ? text_size ++ : (payload_off <= payload_len ++ ? payload_len - payload_off : 0u); ++ if (payload_off > payload_len || ++ img_len > payload_len - payload_off) ++ img_len = 0; ++ uint8_t *img = img_len ++ ? (uint8_t *)std::malloc(img_len) : nullptr; ++ if (img && img_len) { ++ memcpy(img, file + 2048 + payload_off, img_len); + std::free(file); + dirty_ram_register_text_image(phys_lo, img, img_len); + std::fprintf(stdout, +- "psxrecomp: text image guard armed (0x%08X..0x%08X, disc %s)\n", ++ "psxrecomp: text image guard armed (0x%08X..0x%08X, " ++ "disc %s payload +0x%X)\n", + load_address, load_address + img_len, +- boot_name.c_str()); ++ boot_name.c_str(), payload_off); + return; + } + } +@@ -317,6 +382,10 @@ + * 640*scale x 512*scale. Allocated once the supersampling scale is known + * (sized for the native 640x512 when supersampling is off). */ + static uint32_t* sdl_pixel_buf = nullptr; ++/* Live OpenGL internal-scale changes may grow the CPU fallback/readback path. ++ * Never shrink this allocation during a session: if a GL target rebuild fails, ++ * the previous (larger) renderer scale must remain safe to present. */ ++static int sdl_pixel_buf_scale_capacity = 0; + + typedef void (*ModFrameHook)(void); + +@@ -337,8 +406,118 @@ + for (size_t i = 0; i < count; i++) hooks[i](); + } + ++/* Optional game-owned in-game UI. Keep the registration state here rather ++ * than in a renderer so input routing and renderer lifetime remain coordinated ++ * and a build with no provider has no extra source or link dependency. */ ++struct HostUiState { ++ PsxHostUiHooks hooks{}; ++ bool registered = false; ++ bool session_live = false; ++ SDL_Window *window = nullptr; ++ int backend = PSX_HOST_UI_BACKEND_NONE; ++}; ++ ++static HostUiState& host_ui_state() { ++ static HostUiState state; ++ return state; ++} ++ ++static std::string g_host_user_settings_path_utf8; ++ ++extern "C" const char *psx_host_user_settings_path(void) { ++ return g_host_user_settings_path_utf8.empty() ++ ? nullptr : g_host_user_settings_path_utf8.c_str(); ++} ++ ++extern "C" int psx_host_ui_register(const PsxHostUiHooks *hooks) { ++ if (!hooks || hooks->abi_version != PSX_HOST_UI_ABI_VERSION || ++ hooks->struct_size < sizeof(PsxHostUiHooks)) ++ return 0; ++ ++ HostUiState& state = host_ui_state(); ++ if (state.registered) return 0; ++ state.hooks = *hooks; ++ state.registered = true; ++ if (state.session_live && state.hooks.on_runtime_ready) ++ state.hooks.on_runtime_ready(state.hooks.userdata, state.window, ++ state.backend); ++ return 1; ++} ++ ++static void host_ui_runtime_ready(SDL_Window *window, int backend) { ++ HostUiState& state = host_ui_state(); ++ /* A lobby soft-return may create another game session in the same process. ++ * Close the prior provider lifetime defensively before opening a new one. */ ++ if (state.session_live && state.registered && ++ state.hooks.on_runtime_shutdown) ++ state.hooks.on_runtime_shutdown(state.hooks.userdata); ++ state.window = window; ++ state.backend = backend; ++ state.session_live = true; ++ if (state.registered && state.hooks.on_runtime_ready) ++ state.hooks.on_runtime_ready(state.hooks.userdata, window, backend); ++} ++ ++static void host_ui_runtime_shutdown() { ++ HostUiState& state = host_ui_state(); ++ if (!state.session_live) return; ++ if (state.registered && state.hooks.on_runtime_shutdown) ++ state.hooks.on_runtime_shutdown(state.hooks.userdata); ++ state.session_live = false; ++ state.window = nullptr; ++ state.backend = PSX_HOST_UI_BACKEND_NONE; ++} ++ ++extern "C" int psx_host_ui_handle_event(const SDL_Event *event) { ++ HostUiState& state = host_ui_state(); ++ if (!event || !state.session_live || !state.registered || ++ !state.hooks.on_sdl_event) ++ return 0; ++ return state.hooks.on_sdl_event(state.hooks.userdata, event) ? 1 : 0; ++} ++ ++extern "C" uint32_t psx_host_ui_capture_flags(void) { ++ HostUiState& state = host_ui_state(); ++ if (!state.session_live || !state.registered || !state.hooks.flags) ++ return 0; ++ return state.hooks.flags(state.hooks.userdata); ++} ++ ++static int (*g_fast_forward_query)(void) = nullptr; ++ ++extern "C" void psx_host_set_fast_forward_query(int (*query)(void)) { ++ g_fast_forward_query = query; ++} ++ ++extern "C" int psx_host_ui_game_input_captured(void) { ++ const uint32_t input = PSX_HOST_UI_CAPTURE_KEYBOARD | ++ PSX_HOST_UI_CAPTURE_MOUSE | ++ PSX_HOST_UI_CAPTURE_GAMEPAD; ++ return (psx_host_ui_capture_flags() & input) != 0; ++} ++ ++extern "C" int psx_host_ui_gl_visible(void) { ++ HostUiState& state = host_ui_state(); ++ return state.session_live && state.registered && ++ state.backend == PSX_HOST_UI_BACKEND_OPENGL && ++ state.hooks.render_gl && ++ (psx_host_ui_capture_flags() & PSX_HOST_UI_VISIBLE) != 0; ++} ++ ++extern "C" void psx_host_ui_render_gl(int drawable_width, ++ int drawable_height) { ++ HostUiState& state = host_ui_state(); ++ if (!state.session_live || !state.registered || ++ state.backend != PSX_HOST_UI_BACKEND_OPENGL || ++ !state.hooks.render_gl) ++ return; ++ state.hooks.render_gl(state.hooks.userdata, drawable_width, ++ drawable_height); ++} ++ + /* Presentation-only interpolation for software-rendered content that repeats + * each guest image for two vblanks. This never changes guest or audio timing. */ ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + static std::atomic g_smooth_60fps{0}; + + struct Smooth60State { +@@ -352,6 +531,7 @@ + }; + + static Smooth60State g_smooth_60_state; ++#endif + + /* Present-path session state — must reset on rematch (function-local statics + * survive soft-return and poison FMV/FPS after session_reboot). */ +@@ -379,12 +559,14 @@ + } + + static void smooth_60_reset(void) { ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + g_smooth_60_state.previous_source.clear(); + g_smooth_60_state.source_hash = 0; + g_smooth_60_state.width = 0; + g_smooth_60_state.height = 0; + g_smooth_60_state.have_source = false; + g_smooth_60_state.previous_was_duplicate = false; ++#endif + } + + static void present_session_reset(void) { +@@ -402,10 +584,18 @@ + smooth_60_reset(); + } + ++extern "C" int dirty_ram_text_image_registered(void); ++extern "C" int psx_game_text_native_ok(uint32_t addr); ++extern "C" void memory_keep_low_boot_scratch(void); ++static void take_loaded_state_for_started(void); ++ + /* Called from savestate_poll after a successful restore (before scheduler + * longjmp). Clears present latches and forces the next vblank to show the + * restored VRAM — including a blank if display was disabled in the snapshot. */ + extern "C" void psx_frontend_on_savestate_loaded(void) { ++#ifdef PSX_HAS_GAME_HOST ++ psx_game_state_loaded(); ++#endif + s_disabled_frame_presented = false; + s_force_present_after_load = true; + smooth_60_reset(); +@@ -419,8 +609,10 @@ + * tiles + force several swaps so the window actually updates. Safe no-op + * when the GL pipeline was never brought up. */ + gl_renderer_invalidate_present(); +-} +- ++ take_loaded_state_for_started(); ++} ++ ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + static uint64_t smooth_60_frame_hash(const uint32_t* pixels, size_t count) { + uint64_t hash = 1469598103934665603ull; + const size_t step = std::max(1, count / 4096u); +@@ -504,6 +696,13 @@ + extern "C" void psx_smooth_60fps_set(int enabled) { + g_smooth_60fps.store(enabled ? 1 : 0, std::memory_order_release); + } ++#else ++static void smooth_60_present(uint32_t* pixels, uint32_t width, uint32_t height, ++ bool eligible) { ++ (void)pixels; (void)width; (void)height; (void)eligible; ++} ++extern "C" void psx_smooth_60fps_set(int enabled) { (void)enabled; } ++#endif + #if defined(PSX_WEB) + extern "C" EMSCRIPTEN_KEEPALIVE void psx_web_set_smooth_60fps(int enabled) { + psx_smooth_60fps_set(enabled); +@@ -515,6 +714,8 @@ + static bool g_video_aa = true; /* linear present filtering */ + static int g_video_texfilter = 0; /* 0=nearest, 1=bilinear */ + static int g_video_renderer = PSXRecompV4::DEFAULT_VIDEO_RENDERER; ++static bool g_geometry_correction = false; /* visual-only subpixel mirror */ ++static bool g_texture_correction = false; /* visual-only perspective UVs */ + static int g_fullscreen = 0; /* tri-state: 0 windowed, 1 borderless (desktop) + * fullscreen, 2 exclusive fullscreen */ + static int g_video_screen = 0; /* 0=raw,1=crt,2=composite,3=trinitron */ +@@ -599,10 +800,17 @@ + static int g_video_vsync = 1; + static int g_frame_interpolation = 0; + static int g_frame_interpolation_fps = 0; ++#if defined(PSX_FRAME_INTERPOLATION_GEOMETRY_DEFAULT) ++static int g_frame_interpolation_blend = ++ PSX_HOST_FRAME_INTERPOLATION_GEOMETRY; ++static int g_frame_interpolation_blend_default = ++ PSX_HOST_FRAME_INTERPOLATION_GEOMETRY; ++#else + static int g_frame_interpolation_blend = + PSX_MOD_FRAME_INTERPOLATION_LINEAR; + static int g_frame_interpolation_blend_default = + PSX_MOD_FRAME_INTERPOLATION_LINEAR; ++#endif + static int g_mod_controller_mode_override[2] = { -1, -1 }; + static_assert((int)PSX_MOD_CONTROLLER_HYBRID == + (int)PSXRecompV4::PAD_MODE_HYBRID); +@@ -630,6 +838,111 @@ + return 0; /* windowed */ + } + ++/* SDL2 selects borderless vs exclusive through SDL_SetWindowFullscreen's ++ * flag. SDL3 moved that distinction into the window's optional fullscreen ++ * display mode: NULL means borderless desktop and a concrete display mode ++ * means exclusive. Keep that difference behind one helper so startup, the ++ * host UI, and the fullscreen hotkey all agree. */ ++static int psx_window_fullscreen_mode(SDL_Window *window) { ++ if (!window) return g_fullscreen; ++ const Uint32 flags = (Uint32)SDL_GetWindowFlags(window); ++ if ((flags & SDL_WINDOW_FULLSCREEN) == 0) return 0; ++#if defined(PSX_SDL3) ++ return SDL_GetWindowFullscreenMode(window) ? 2 : 1; ++#else ++ return ((flags & SDL_WINDOW_FULLSCREEN_DESKTOP) == ++ SDL_WINDOW_FULLSCREEN_DESKTOP) ? 1 : 2; ++#endif ++} ++ ++#if defined(_WIN32) && defined(PSX_SDL3) ++/* Keeps a borderless OpenGL window from covering its screen exactly (host_borderless.h). SDL fits it to the screen again when it is restored, so this is asked every frame. */ ++static void psx_borderless_keep_windowed(void) { ++ int hidden = 0; ++ if (sdl_window && g_video_renderer == 1 && psx_window_fullscreen_mode(sdl_window) == 1) { ++ const HWND handle = (HWND)SDL_GetPointerProperty(SDL_GetWindowProperties(sdl_window), SDL_PROP_WINDOW_WIN32_HWND_POINTER, nullptr); ++ MONITORINFO info = {}; ++ info.cbSize = sizeof(info); ++ RECT now = {}; ++ if (handle && GetMonitorInfoW(MonitorFromWindow(handle, MONITOR_DEFAULTTONEAREST), &info) && GetWindowRect(handle, &now)) { ++ const PsxHostRect screen = {info.rcMonitor.left, info.rcMonitor.top, info.rcMonitor.right, info.rcMonitor.bottom}; ++ PsxHostRect window = {now.left, now.top, now.right, now.bottom}, wanted = {}; ++ if (psx_borderless_wanted(window, screen, &wanted) && ++ SetWindowPos(handle, nullptr, wanted.left, wanted.top, wanted.right - wanted.left, wanted.bottom - wanted.top, ++ SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOOWNERZORDER)) { ++ window = wanted; ++ std::fprintf(stderr, "psxrecomp: borderless window kept one row taller than its screen\n"); ++ } ++ int pixels_wide = 0, pixels_high = 0; ++ SDL_GetWindowSizeInPixels(sdl_window, &pixels_wide, &pixels_high); ++ hidden = psx_borderless_hidden_rows(window, screen, pixels_high); ++ } ++ } ++ gl_renderer_set_hidden_rows(hidden); ++} ++#else ++static void psx_borderless_keep_windowed(void) {} ++#endif ++ ++static int psx_apply_window_fullscreen_mode(SDL_Window *window, int mode) { ++ if (!window || mode < 0 || mode > 2) return 0; ++#if defined(PSX_SDL3) ++ if (mode == 0) { ++ if (!SDL_SetWindowFullscreen(window, false)) return 0; ++ } else if (mode == 1) { ++ if (!SDL_SetWindowFullscreenMode(window, nullptr)) return 0; ++ if (!SDL_SetWindowFullscreen(window, true)) return 0; ++ } else { ++ const SDL_DisplayID display = SDL_GetDisplayForWindow(window); ++ const SDL_DisplayMode *desktop = ++ display ? SDL_GetDesktopDisplayMode(display) : nullptr; ++ if (!display || !desktop) return 0; ++ ++ int width = 0; ++ int height = 0; ++ if (!SDL_GetWindowSizeInPixels(window, &width, &height) || ++ width <= 0 || height <= 0) { ++ width = desktop->w; ++ height = desktop->h; ++ } ++ SDL_DisplayMode closest = {}; ++ if (!SDL_GetClosestFullscreenDisplayMode( ++ display, width, height, desktop->refresh_rate, true, ++ &closest)) { ++ return 0; ++ } ++ if (!SDL_SetWindowFullscreenMode(window, &closest)) return 0; ++ if (!SDL_SetWindowFullscreen(window, true)) return 0; ++ } ++ /* Fullscreen transitions may be asynchronous. Settle the request before ++ * reporting success so the live getter and menu cannot disagree. */ ++ if (!SDL_SyncWindow(window)) return 0; ++#else ++ if (SDL_SetWindowFullscreen( ++ window, psx_fullscreen_flag_for_mode(mode)) != 0) { ++ return 0; ++ } ++#endif ++ psx_borderless_keep_windowed(); /* at once: a present may come before the next vblank */ ++ return psx_window_fullscreen_mode(window) == mode ? 1 : 0; ++} ++ ++extern "C" int psx_host_video_get_fullscreen_mode(void) { ++ return sdl_window ? psx_window_fullscreen_mode(sdl_window) : g_fullscreen; ++} ++ ++extern "C" int psx_host_video_set_fullscreen_mode(int mode) { ++ if (mode < 0 || mode > 2) return 0; ++ if (sdl_window && !psx_apply_window_fullscreen_mode(sdl_window, mode)) { ++ std::fprintf(stderr, ++ "psxrecomp: failed to apply fullscreen mode %d: %s\n", ++ mode, SDL_GetError()); ++ return 0; ++ } ++ g_fullscreen = mode; ++ return 1; ++} ++ + /* FMV auto-skip detection hooks (cdrom.c / mdec.c). */ + extern "C" int cdrom_xa_stream_active(void); + extern "C" uint32_t mdec_get_decode_count(void); +@@ -655,12 +968,105 @@ + * in config_loader.h). */ + static int g_video_aspect_num = 4; + static int g_video_aspect_den = 3; ++/* Host-UI aspect requests may arrive while the current frame is being ++ * composed. Keep the renderer/GTE/GPU transition at the next presentation ++ * boundary instead of changing projection state halfway through a frame. ++ * Fixed requests are packed as numerator:denominator; the two reserved values ++ * select adaptive mode. Zero means no pending request. A single atomic makes ++ * the most recent fixed/adaptive selection win without a mixed transition. */ ++static std::atomic g_pending_video_aspect{0}; ++static constexpr uint32_t PENDING_VIDEO_ASPECT_ADAPTIVE_ON = 0xFFFFFFFFu; ++static constexpr uint32_t PENDING_VIDEO_ASPECT_ADAPTIVE_OFF = 0xFFFFFFFEu; + /* Resize-driven widescreen. The user's fixed aspect is still used to shape the + * initial window; after the game window exists these values follow its live + * aspect, clamped to 4:3..the widest mode offered by the title. */ + static bool g_ws_adaptive_view = false; + static int g_ws_adaptive_max_num = 16; + static int g_ws_adaptive_max_den = 9; ++ ++static uint32_t pack_display_aspect(int numerator, int denominator) { ++ return ((uint32_t)numerator << 16) | (uint32_t)denominator; ++} ++ ++static bool is_host_fixed_display_aspect(int numerator, int denominator) { ++ return (numerator == 4 && denominator == 3) || ++ (numerator == 16 && denominator == 9) || ++ (numerator == 21 && denominator == 9) || ++ (numerator == 32 && denominator == 9); ++} ++ ++extern "C" void psx_host_video_get_display_aspect( ++ int *numerator, int *denominator) { ++ /* Report an accepted pending selection immediately so a host UI does not ++ * visibly snap back during the one-frame handoff to the present thread. */ ++ uint32_t packed = g_pending_video_aspect.load(std::memory_order_acquire); ++ const bool fixed_pending = ++ packed != 0 && ++ packed != PENDING_VIDEO_ASPECT_ADAPTIVE_ON && ++ packed != PENDING_VIDEO_ASPECT_ADAPTIVE_OFF; ++ int num = fixed_pending ? (int)(packed >> 16) : g_video_aspect_num; ++ int den = fixed_pending ? (int)(packed & 0xFFFFu) : g_video_aspect_den; ++ if (numerator) *numerator = num; ++ if (denominator) *denominator = den; ++} ++ ++extern "C" int psx_host_video_set_display_aspect( ++ int numerator, int denominator) { ++ if (!is_host_fixed_display_aspect(numerator, denominator)) return 0; ++ g_pending_video_aspect.store( ++ pack_display_aspect(numerator, denominator), ++ std::memory_order_release); ++ return 1; ++} ++ ++extern "C" int psx_host_video_get_adaptive_view(void) { ++ uint32_t pending = ++ g_pending_video_aspect.load(std::memory_order_acquire); ++ if (pending == PENDING_VIDEO_ASPECT_ADAPTIVE_ON) return 1; ++ if (pending != 0) return 0; /* Explicit off or a pending fixed aspect. */ ++ return g_ws_adaptive_view ? 1 : 0; ++} ++ ++extern "C" int psx_host_video_set_adaptive_view(int enabled) { ++ if (enabled != 0 && enabled != 1) return 0; ++ g_pending_video_aspect.store( ++ enabled ? PENDING_VIDEO_ASPECT_ADAPTIVE_ON ++ : PENDING_VIDEO_ASPECT_ADAPTIVE_OFF, ++ std::memory_order_release); ++ return 1; ++} ++ ++extern "C" int psx_host_video_get_internal_scale(void) { ++ return g_video_scale; ++} ++ ++extern "C" int psx_host_video_set_internal_scale(int scale) { ++ if (scale < 1 || scale > SW_MAX_INTERNAL_SCALE) return 0; ++ /* The developer UI itself is OpenGL-backed. Software and Vulkan keep ++ * startup-only allocation rules until their presentation resources gain ++ * an equivalent transactional rebuild. */ ++ if (!host_ui_state().session_live || ++ host_ui_state().backend != PSX_HOST_UI_BACKEND_OPENGL) { ++ return 0; ++ } ++ if (scale == g_video_scale) return 1; ++ ++ if (scale > sdl_pixel_buf_scale_capacity) { ++ uint32_t *grown = static_cast(std::realloc( ++ sdl_pixel_buf, ++ (size_t)640 * scale * 512 * scale * sizeof(uint32_t))); ++ if (!grown) return 0; ++ sdl_pixel_buf = grown; ++ sdl_pixel_buf_scale_capacity = scale; ++ } ++ ++ gr_set_scale(scale); ++ const int applied = gr_scale(); ++ if (applied != scale) return 0; ++ g_video_scale = applied; ++ gl_renderer_invalidate_present(); ++ return 1; ++} + + extern "C" int psx_mod_set_fixed_display_aspect( + uint32_t numerator, uint32_t denominator) { +@@ -738,6 +1144,10 @@ + + extern "C" int psx_mod_set_frame_interpolation( + uint32_t frames_per_second) { ++#ifdef PSX_DISABLE_FRAME_INTERPOLATION ++ (void)frames_per_second; ++ return 0; ++#else + if (frames_per_second != 0 && + (frames_per_second < 60 || frames_per_second > 1000)) { + std::fprintf(stderr, +@@ -768,10 +1178,15 @@ + "interpolation; guest timing remains stock\n"); + } + return 1; ++#endif + } + + extern "C" int psx_mod_set_frame_interpolation_blend( + uint32_t blend_mode) { ++#ifdef PSX_DISABLE_FRAME_INTERPOLATION ++ (void)blend_mode; ++ return 0; ++#else + if (blend_mode != PSX_MOD_FRAME_INTERPOLATION_LINEAR && + blend_mode != PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE) { + std::fprintf(stderr, +@@ -784,6 +1199,7 @@ + blend_mode == PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE + ? "motion-adaptive clarity" : "linear crossfade"); + return 1; ++#endif + } + + extern "C" int psx_mod_set_auto_skip_fmv(int enabled) { +@@ -867,6 +1283,8 @@ + * 4:3, wider for wide aspects. Height is always 480*scale. Set at window + * creation alongside SDL_RenderSetLogicalSize. */ + static int g_logical_w = 640; ++static bool g_gl_active = false; /* GL context live -> GL present path */ ++static bool g_vk_active = false; /* Vulkan context live -> VK present path */ + + /* Clamp a requested window width to the primary display's usable area so an + * oversized choice (e.g. 1920 on a 1080p panel) still fits on screen. Keeps +@@ -886,6 +1304,103 @@ + static int aspect_gcd(int a, int b) { + while (b) { int t = a % b; a = b; b = t; } + return a > 0 ? a : 1; ++} ++ ++/* Resize only an ordinary window. Fullscreen owns the display mode/desktop ++ * extent; its compositor instead follows the logical/present aspect below. */ ++static void reshape_window_for_fixed_aspect(int num, int den) { ++ if (!sdl_window || ++ (SDL_GetWindowFlags(sdl_window) & SDL_WINDOW_FULLSCREEN) != 0) ++ return; ++ ++ int width = 0, height = 0; ++ SDL_GetWindowSize(sdl_window, &width, &height); ++ if (width <= 0) return; ++ height = width * den / num; ++ clamp_window_aspect(&width, &height, num, den); ++ SDL_SetWindowSize(sdl_window, width, height); ++} ++ ++/* One transition funnel for adaptive resizing and explicit host-UI choices. ++ * It runs only from sdl_vblank_present, after game hooks have completed, so a ++ * frame cannot mix old projection state with a new presentation shape. */ ++static void apply_live_display_aspect(int num, int den, ++ bool reshape_fixed_window) { ++ const bool changed = ++ num != g_video_aspect_num || den != g_video_aspect_den; ++ g_video_aspect_num = num; ++ g_video_aspect_den = den; ++ ++ gl_renderer_set_display_aspect(num, den); ++ if (sdl_renderer) { ++ g_logical_w = 480 * num * g_video_scale / den; ++ SDL_RenderSetLogicalSize(sdl_renderer, g_logical_w, ++ 480 * g_video_scale); ++ } ++ if (reshape_fixed_window) reshape_window_for_fixed_aspect(num, den); ++ ++ const bool wide = num * 3 != den * 4; ++ const bool game_started = fntrace_is_game_started() != 0; ++ if (game_started) { ++ g_ws_engaged = true; ++ const int mode = wide ? (g_ws_native_wide ? 2 : 1) : 0; ++ gte_set_display_aspect(mode == 1 ? num : 4, ++ mode == 1 ? den : 3); ++ gpu_ws_configure(num, den, g_ws_anchor_addr, ++ g_ws_hud_sprt ? 1 : 0, mode); ++ } else { ++ /* Preserve authentic 4:3 boot rendering even when a host UI selects a ++ * wide output before game entry. The normal engagement path will pick ++ * up the already-updated num:den once gameplay begins. */ ++ g_ws_engaged = !wide; ++ gte_set_display_aspect(4, 3); ++ gpu_ws_configure(4, 3, g_ws_anchor_addr, ++ g_ws_hud_sprt ? 1 : 0, 0); ++ } ++ ++ if (changed && g_gl_active) { ++ /* Do not crossfade or reuse a stale destination rectangle across an ++ * aspect transition. This also forces an otherwise-identical next ++ * frame through the GL present path. */ ++ gl_renderer_invalidate_present(); ++ } ++} ++ ++static void update_adaptive_widescreen(); ++ ++static void apply_pending_display_aspect() { ++ uint32_t packed = ++ g_pending_video_aspect.exchange(0, std::memory_order_acq_rel); ++ if (!packed) return; ++ ++ if (packed == PENDING_VIDEO_ASPECT_ADAPTIVE_ON) { ++ g_ws_adaptive_view = true; ++ /* The developer-facing match-window mode supports every intermediate ++ * window ratio, but never exposes more than the audited 32:9 view. */ ++ g_ws_adaptive_max_num = 32; ++ g_ws_adaptive_max_den = 9; ++ update_adaptive_widescreen(); ++ std::fprintf(stdout, ++ "psxrecomp: live adaptive display aspect enabled " ++ "(match window, maximum 32:9)\n"); ++ return; ++ } ++ if (packed == PENDING_VIDEO_ASPECT_ADAPTIVE_OFF) { ++ /* Leave the current effective aspect in place. A subsequent fixed ++ * selection can choose and reshape a canonical fixed output. */ ++ g_ws_adaptive_view = false; ++ std::fprintf(stdout, ++ "psxrecomp: live adaptive display aspect disabled\n"); ++ return; ++ } ++ ++ int num = (int)(packed >> 16); ++ int den = (int)(packed & 0xFFFFu); ++ g_ws_adaptive_view = false; ++ apply_live_display_aspect(num, den, true); ++ std::fprintf(stdout, ++ "psxrecomp: live fixed display aspect = %d:%d (adaptive view off)\n", ++ num, den); + } + + /* Follow the host window without feeding its absolute pixel size into guest +@@ -911,23 +1426,7 @@ + den /= divisor; + } + if (num == g_video_aspect_num && den == g_video_aspect_den) return; +- +- g_video_aspect_num = num; +- g_video_aspect_den = den; +- gl_renderer_set_display_aspect(num, den); +- if (sdl_renderer) { +- g_logical_w = 480 * num * g_video_scale / den; +- SDL_RenderSetLogicalSize(sdl_renderer, g_logical_w, 480 * g_video_scale); +- } +- +- if (g_ws_engaged) { +- const bool wide = num * 3 != den * 4; +- const int mode = wide ? (g_ws_native_wide ? 2 : 1) : 0; +- gte_set_display_aspect(mode == 1 ? num : 4, +- mode == 1 ? den : 3); +- gpu_ws_configure(num, den, g_ws_anchor_addr, +- g_ws_hud_sprt ? 1 : 0, mode); +- } ++ apply_live_display_aspect(num, den, false); + } + + /* SDL GL attributes are global inputs to the next context creation. Set the +@@ -955,8 +1454,107 @@ + } + extern "C" int psx_ws_get_native_wide(void) { return g_ws_native_wide; } + +-static bool g_gl_active = false; /* GL context live -> GL present path */ +-static bool g_vk_active = false; /* Vulkan context live -> VK present path */ ++extern "C" int psx_host_video_get_vsync(void) { ++ return g_video_vsync; ++} ++ ++extern "C" int psx_host_video_set_vsync(int mode) { ++ if (mode < -1 || mode > 1) return 0; ++ g_video_vsync = mode; ++#ifndef PSX_SDL_NO_RENDER ++ /* gl_renderer_set_swap_interval remembers this request but keeps the ++ * physical interval at zero while interpolation owns presentation. */ ++ if (g_gl_active) gl_renderer_set_swap_interval(mode); ++ if (g_vk_active) vk_renderer_set_present_mode(mode); ++#endif ++ latency_ring_set_present_mode(mode); ++ return 1; ++} ++ ++extern "C" void psx_host_video_get_interpolation(int *enabled, ++ int *target_fps, ++ int *blend_mode) { ++#ifdef PSX_DISABLE_FRAME_INTERPOLATION ++ if (enabled) *enabled = 0; ++ if (target_fps) *target_fps = 0; ++ if (blend_mode) *blend_mode = PSX_HOST_FRAME_INTERPOLATION_LINEAR; ++#else ++ int actual_enabled = g_frame_interpolation ? 1 : 0; ++ if (g_gl_active) ++ gl_renderer_interpolation_diag(&actual_enabled, nullptr, nullptr, ++ nullptr, nullptr, nullptr); ++ if (enabled) *enabled = actual_enabled; ++ if (target_fps) *target_fps = g_frame_interpolation_fps; ++ if (blend_mode) *blend_mode = g_frame_interpolation_blend; ++#endif ++} ++ ++extern "C" void psx_host_video_get_geometry_interpolation_stats( ++ uint64_t out[6]) { ++ if (!out) return; ++ for (int i = 0; i < 6; ++i) out[i] = 0; ++ if (g_gl_active) gl_renderer_geometry_interpolation_diag(out); ++} ++ ++extern "C" int psx_host_video_set_interpolation(int enabled, ++ int target_fps, ++ int blend_mode) { ++#ifdef PSX_DISABLE_FRAME_INTERPOLATION ++ (void)target_fps; (void)blend_mode; ++ /* A disable request is harmless; activation is unavailable in this build. */ ++ return enabled ? 0 : 1; ++#else ++ enabled = enabled ? 1 : 0; ++ if (target_fps != -1 && target_fps != 0 && ++ (target_fps < 30 || target_fps > 1000)) ++ return 0; ++ if (blend_mode != PSX_MOD_FRAME_INTERPOLATION_LINEAR && ++ blend_mode != PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE && ++ blend_mode != PSX_HOST_FRAME_INTERPOLATION_GEOMETRY) ++ return 0; ++ /* Interpolation is implemented only by the GL backend and renderer changes ++ * are restart-owned. Do not let an in-game control advertise success on a ++ * software/Vulkan session. */ ++ if (enabled && !g_gl_active) return 0; ++ if (enabled && blend_mode == PSX_HOST_FRAME_INTERPOLATION_GEOMETRY && ++ !gpu_geometry_correction_enabled()) ++ return 0; ++ ++ if (g_gl_active) { ++ int previous_actual = 0; ++ gl_renderer_interpolation_diag(&previous_actual, nullptr, nullptr, ++ nullptr, nullptr, nullptr); ++ gl_renderer_set_interpolation(enabled, g_host_refresh_hz, ++ (double)target_fps, blend_mode); ++ int actual = 0; ++ gl_renderer_interpolation_diag(&actual, nullptr, nullptr, ++ nullptr, nullptr, nullptr); ++ if (actual != enabled) { ++ /* Context/thread creation and display-rate validation live in the ++ * GL backend. Do not advertise a requested state which the ++ * renderer could not actually enter. Restore a previously-live ++ * interpolator when a target change was rejected. */ ++ if (actual != previous_actual) { ++ gl_renderer_set_interpolation( ++ previous_actual, g_host_refresh_hz, ++ (double)g_frame_interpolation_fps, ++ g_frame_interpolation_blend); ++ } ++ g_frame_interpolation = previous_actual; ++ gl_renderer_set_swap_interval(g_video_vsync); ++ return 0; ++ } ++ /* Re-assert the requested user interval. The GL backend defers it ++ * while interpolation is enabled and restores it on disable. */ ++ gl_renderer_set_swap_interval(g_video_vsync); ++ } ++ g_frame_interpolation = enabled; ++ g_frame_interpolation_fps = target_fps; ++ g_frame_interpolation_blend = blend_mode; ++ return 1; ++#endif ++} ++ + /* Present straight from the FBO (fast, no readback). Set PSX_GL_FORCE_CPU_PRESENT=1 + * to force the software readout path instead — a diagnostic/fallback that also + * keeps CPU VRAM current every frame (so screenshots reflect the screen). */ +@@ -994,6 +1592,16 @@ + static int g_turbo_load_release_frames = TURBO_LOADS_RELEASE_FRAMES; + static SDL_AudioDeviceID sdl_audio_device; + static int16_t sdl_audio_buf[2048 * 2]; ++static std::atomic g_audio_master_volume_percent{100}; ++static std::atomic g_audio_master_muted{0}; ++/* Legacy queued audio has two producer sites (ordinary pump and turbo-mute ++ * tail); both share the same applied gain so either path transitions smoothly. */ ++static float g_audio_legacy_applied_master_gain = -1.0f; ++/* Callback-owned; reset only after the audio device has stopped. */ ++static float g_audio_drc_applied_master_gain = -1.0f; ++ ++static void sdl_audio_apply_master_gain(int16_t* buf, int frames, ++ float* applied_gain); + + /* DRC bridge. Producer (sdl_audio_pump) runs on the main loop thread under + * SDL_LockAudioDevice; consumer (sdl_drc_callback) runs on the SDL audio +@@ -1026,10 +1634,36 @@ + int g_audio_host_rate = 44100; + } + ++extern "C" int psx_host_audio_get_master_volume(void) { ++ return g_audio_master_volume_percent.load(std::memory_order_relaxed); ++} ++ ++extern "C" int psx_host_audio_set_master_volume(int percent) { ++ if (percent < 0 || percent > 100) return 0; ++ g_audio_master_volume_percent.store(percent, std::memory_order_relaxed); ++ return 1; ++} ++ ++extern "C" int psx_host_audio_get_muted(void) { ++ return g_audio_master_muted.load(std::memory_order_relaxed); ++} ++ ++extern "C" int psx_host_audio_set_muted(int muted) { ++ if (muted != 0 && muted != 1) return 0; ++ g_audio_master_muted.store(muted, std::memory_order_relaxed); ++ return 1; ++} ++ + static void sdl_drc_callback(void* /*user*/, Uint8* stream, int len) { + if (!s_drc_ready) { std::memset(stream, 0, (size_t)len); return; } + int frames = len / (int)(2 * sizeof(int16_t)); /* stereo S16 */ + rab_pull(&s_drc, reinterpret_cast(stream), frames); ++ /* Apply user gain at the final pull boundary so the default bridge path ++ * responds on the next device callback instead of waiting for the DRC ++ * ring's buffered audio to drain. */ ++ sdl_audio_apply_master_gain( ++ reinterpret_cast(stream), frames, ++ &g_audio_drc_applied_master_gain); + /* T3 tap in bridge mode: the exact device-rate bytes the host consumes. + * This callback is the tap's single writer while the bridge is active. */ + audio_trace_pcm(AUDIO_TAP_HOST, reinterpret_cast(stream), +@@ -1174,22 +1808,40 @@ + return false; + } + #ifdef _WIN32 +- char path_buf[4096]; +- std::memset(path_buf, 0, sizeof(path_buf)); +- +- OPENFILENAMEA ofn; +- std::memset(&ofn, 0, sizeof(ofn)); +- ofn.lStructSize = sizeof(ofn); +- ofn.hwndOwner = NULL; +- ofn.lpstrFilter = filter; +- ofn.lpstrFile = path_buf; +- ofn.nMaxFile = (DWORD)sizeof(path_buf); +- ofn.lpstrTitle = title; +- ofn.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_HIDEREADONLY; +- +- if (!GetOpenFileNameA(&ofn)) return false; +- out = path_buf; +- return true; ++ /* IFileOpenDialog, not GetOpenFileName: that one moves the process's working directory into every folder the ++ * player opens (measured, OFN_NOCHANGEDIR or not), and the freeze heartbeat writes its files there. */ ++ const auto wide = [](const char* text) { ++ std::wstring out(static_cast(MultiByteToWideChar(CP_UTF8, 0, text, -1, nullptr, 0)), L'\0'); ++ MultiByteToWideChar(CP_UTF8, 0, text, -1, out.data(), static_cast(out.size())); ++ return out; ++ }; ++ std::vector texts; ++ for (const char* at = filter; at && *at; at += std::strlen(at) + 1) texts.push_back(wide(at)); ++ std::vector kinds; ++ for (size_t pair = 0; pair + 1 < texts.size(); pair += 2) kinds.push_back({texts[pair].c_str(), texts[pair + 1].c_str()}); ++ ++ const HRESULT initialised = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE); ++ IFileOpenDialog* dialog = nullptr; ++ bool chosen = false; ++ if (SUCCEEDED(CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&dialog)))) { ++ DWORD options = 0; ++ dialog->GetOptions(&options); ++ dialog->SetOptions(options | FOS_FILEMUSTEXIST | FOS_PATHMUSTEXIST | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR); ++ if (!kinds.empty()) dialog->SetFileTypes(static_cast(kinds.size()), kinds.data()); ++ dialog->SetTitle(wide(title).c_str()); ++ IShellItem* item = nullptr; ++ wchar_t* path = nullptr; ++ if (SUCCEEDED(dialog->Show(nullptr)) && SUCCEEDED(dialog->GetResult(&item)) && ++ SUCCEEDED(item->GetDisplayName(SIGDN_FILESYSPATH, &path))) { ++ out = path; ++ chosen = true; ++ } ++ CoTaskMemFree(path); ++ if (item) item->Release(); ++ dialog->Release(); ++ } ++ if (SUCCEEDED(initialised)) CoUninitialize(); ++ return chosen; + #else + (void)filter; + (void)out; +@@ -1205,6 +1857,46 @@ + title, cli_flag); + return false; + #endif ++} ++ ++/* The dialog runs on a thread of its own: on the game's thread it would stop the frames, and the freeze watchdog ++ * takes two seconds without a frame for a hang and writes a dump. */ ++/* Never destroyed: the dialog's thread may still be in the dialog when the program ends. */ ++static PsxHostPickSlot& s_disc_pick = *new PsxHostPickSlot; ++static std::thread& s_disc_worker = *new std::thread; ++ ++extern "C" int psx_host_pick_disc_image_begin(void) { ++#ifdef _WIN32 ++ if (g_headless || !s_disc_pick.open()) return 0; ++ try { ++ /* The worker before this one closed its pick as its last act, and no pick opens before that one is taken. */ ++ if (s_disc_worker.joinable()) s_disc_worker.join(); ++ s_disc_worker = std::thread([] { ++ std::string path; ++ try { ++ std::filesystem::path picked; ++ if (pick_runtime_file("Select a disc image", ++ "Disc image (*.cue;*.bin;*.chd;*.iso)\0*.cue;*.bin;*.chd;*.iso\0All files\0*.*\0", ++ picked, "--disc")) ++ path = picked.string(); ++ } catch (...) { ++ std::fprintf(stderr, "psxrecomp: the chosen disc image could not be taken, its path may have letters this system's code page lacks\n"); ++ path.clear(); ++ } ++ s_disc_pick.close(std::move(path)); ++ }); ++ } catch (const std::system_error&) { ++ s_disc_pick.abandon(); ++ return 0; ++ } ++ return 1; ++#else ++ return 0; ++#endif ++} ++ ++extern "C" int psx_host_pick_disc_image_poll(char* out, int size) { ++ return s_disc_pick.take(out, size); + } + + static std::string uppercase_ascii(std::string s) { +@@ -1590,6 +2282,9 @@ + } + + static void shutdown_runtime(void) { ++ /* UI teardown may destroy GL resources; it must run while the game window ++ * and main renderer context are still alive. The helper is idempotent. */ ++ host_ui_runtime_shutdown(); + /* (sljit removed 2026-07-15: overlay_compile_worker_stop joined the + * off-thread JIT worker here; the worker no longer exists.) */ + psx_netplay_shutdown(); +@@ -1607,6 +2302,8 @@ + psx_sdl_audio_close(sdl_audio_device); /* stops the pull callback */ + sdl_audio_device = 0; + } ++ g_audio_drc_applied_master_gain = -1.0f; ++ g_audio_legacy_applied_master_gain = -1.0f; + if (s_drc_ready) { rab_free(&s_drc); s_drc_ready = false; } + close_controller(); + debug_server_shutdown(); +@@ -1615,6 +2312,7 @@ + /* Tear down the game window/GL/audio after a lobby soft-return. Leaves SDL + * subsystems and the lobby WebSocket intact for the next launcher session. */ + static void teardown_game_session_keep_lobby(void) { ++ host_ui_runtime_shutdown(); + netplay_host_present_restore(); + psx_netplay_shutdown(); + memcard_flush_all(); +@@ -1623,6 +2321,8 @@ + psx_sdl_audio_close(sdl_audio_device); + sdl_audio_device = 0; + } ++ g_audio_drc_applied_master_gain = -1.0f; ++ g_audio_legacy_applied_master_gain = -1.0f; + if (s_drc_ready) { rab_free(&s_drc); s_drc_ready = false; } + close_controller(); + if (g_vk_active) { +@@ -1637,6 +2337,7 @@ + if (sdl_renderer) { SDL_DestroyRenderer(sdl_renderer); sdl_renderer = nullptr; } + if (sdl_window) { SDL_DestroyWindow(sdl_window); sdl_window = nullptr; } + if (sdl_pixel_buf) { std::free(sdl_pixel_buf); sdl_pixel_buf = nullptr; } ++ sdl_pixel_buf_scale_capacity = 0; + psx_lobby_set_ready(0); + psx_lobby_clear_launch_pending(); + psx_clear_return_to_lobby(); +@@ -1653,6 +2354,24 @@ + } + } + ++/* Smooth live master-volume changes over one produced/device block. The DRC ++ * path invokes this on its final audio callback; legacy queued audio invokes ++ * it immediately before queueing. Separate per-path applied gains are owned by ++ * their respective threads, while the requested controls are atomic. */ ++static void sdl_audio_apply_master_gain(int16_t* buf, int frames, ++ float* applied_gain) { ++ if (!buf || frames <= 0 || !applied_gain) return; ++ const int percent = ++ g_audio_master_volume_percent.load(std::memory_order_relaxed); ++ const bool muted = ++ g_audio_master_muted.load(std::memory_order_relaxed) != 0; ++ const float target_gain = muted ? 0.0f : (float)percent / 100.0f; ++ if (*applied_gain < 0.0f) *applied_gain = target_gain; ++ if (*applied_gain == 1.0f && target_gain == 1.0f) return; ++ sdl_audio_gain_ramp(buf, frames, *applied_gain, target_gain); ++ *applied_gain = target_gain; ++} ++ + /* Fade-in state: samples of rising ramp still to apply after an unmute. + * Consumed by sdl_audio_pump across however many pump calls it spans. + * MUST fit sdl_audio_buf (2048 frames): the fade-out tail renders this many +@@ -1661,7 +2380,7 @@ + static int sdl_audio_fadein_left = 0; + + static void sdl_audio_pump(bool discard_output = false) { +- if (!sdl_audio_device) return; ++ if (!sdl_audio_device) discard_output = true; /* a game reads the SPU back: it runs on with nothing to play it */ + + const uint32_t bytes_per_frame = sizeof(int16_t) * 2u; + const bool legacy = audio_legacy_mode(); +@@ -1751,6 +2470,8 @@ + sdl_audio_fadein_left -= ramp; + } + if (legacy) { ++ sdl_audio_apply_master_gain( ++ sdl_audio_buf, frames, &g_audio_legacy_applied_master_gain); + /* T3 tap: the exact post-fade bytes handed to the host audio queue. */ + audio_trace_pcm(AUDIO_TAP_HOST, sdl_audio_buf, frames); + psx_sdl_audio_queue(sdl_audio_device, sdl_audio_buf, +@@ -1883,6 +2604,7 @@ + + g_runtime_perf.frequency = SDL_GetPerformanceFrequency(); + if (!g_runtime_perf.frequency) g_runtime_perf.frequency = 1; ++ dirty_ram_perf_hot_enable(1); + if (const char *interval = std::getenv("PSX_RUNTIME_PERF_DIAG_MS")) { + char *tail = nullptr; + long requested = std::strtol(interval, &tail, 10); +@@ -2026,12 +2748,32 @@ + if (end >= start) *total += end - start; + } + ++extern "C" uint8_t *memory_get_ram_ptr(void); ++#ifdef PSX_HAS_OVERLAY_DISPATCH ++extern "C" void psx_overlay_static_get_stats(uint64_t *, uint64_t *, ++ uint64_t *, uint64_t *); ++extern "C" void overlay_loader_static_match_stats(uint64_t *, uint64_t *, ++ uint64_t *); ++#endif ++static uint32_t runtime_perf_live_word(uint32_t phys, uint32_t samples) { ++ if (samples == 0u || phys > 2u * 1024u * 1024u - 4u) return 0u; ++ const uint8_t *ram = memory_get_ram_ptr(); ++ if (!ram) return 0u; ++ return (uint32_t)ram[phys] | ++ ((uint32_t)ram[phys + 1u] << 8) | ++ ((uint32_t)ram[phys + 2u] << 16) | ++ ((uint32_t)ram[phys + 3u] << 24); ++} ++ + static void runtime_perf_diag_tick() { + static bool have_last = false; + static RuntimePerfSnapshot last; + static uint64_t last_spu = 0, last_underruns = 0, last_overflows = 0; + static uint64_t last_up[6] = {0}; + static uint64_t last_overlay_load_us = 0; ++#ifdef PSX_HAS_OVERLAY_DISPATCH ++ static uint64_t last_static[7] = {0}; ++#endif + if (!g_runtime_perf.enabled) return; + + uint64_t now = SDL_GetPerformanceCounter(); +@@ -2052,12 +2794,31 @@ + uint64_t overlay_load_us = 0, overlay_load_max_us = 0, overlay_load_last_us = 0; + overlay_loader_get_load_timing(&overlay_load_us, &overlay_load_max_us, + &overlay_load_last_us); ++ uint32_t dirty_hot_pc[DIRTY_RAM_PERF_HOT_COUNT] = {0}; ++ uint32_t dirty_hot_n[DIRTY_RAM_PERF_HOT_COUNT] = {0}; ++ uint32_t dirty_hot_stride = 0; ++ dirty_ram_perf_take_hot(dirty_hot_pc, dirty_hot_n, &dirty_hot_stride); ++ uint64_t kbless[6] = {0}; ++ uint64_t kbless_mismatch_count = 0; ++ uint32_t kbless_last[6] = {0}; ++ psx_kernel_bless_stats(kbless); ++ psx_kernel_bless_mismatch_detail(&kbless_mismatch_count, kbless_last); ++#ifdef PSX_HAS_OVERLAY_DISPATCH ++ uint64_t static_now[7] = {0}; ++ psx_overlay_static_get_stats(&static_now[0], &static_now[1], ++ &static_now[2], &static_now[3]); ++ overlay_loader_static_match_stats(&static_now[4], &static_now[5], ++ &static_now[6]); ++#endif + if (!have_last) { + last = current; + last_spu = audio.tap_frames[AUDIO_TAP_SPU_OUT]; + last_underruns = underruns; + last_overflows = overflows; + last_overlay_load_us = overlay_load_us; ++#ifdef PSX_HAS_OVERLAY_DISPATCH ++ for (int i = 0; i < 7; i++) last_static[i] = static_now[i]; ++#endif + for (int i = 0; i < 6; i++) last_up[i] = up[i]; + have_last = true; + return; +@@ -2072,6 +2833,14 @@ + "cpu=%.1f tex=%.1f draw=%.1f ms/s; " + "work guest=%.1f pacer=%.1f autocapture=%.1f provider_poll=%.1f ms/s, " + "dirty=%.0f insn/s %.0f dispatch/s; " ++ "dirty_hot=0x%08X:%08X/%u,0x%08X:%08X/%u," ++ "0x%08X:%08X/%u sample=1/%u; " ++ "kbless clean=%llu mismatch=%llu hits=%llu last=0x%08X " ++ "body=0x%08X-0x%08X diff=0x%08X:%02X/%02X; " ++#ifdef PSX_HAS_OVERLAY_DISPATCH ++ "static checks=+%llu hits=+%llu variant_miss=+%llu " ++ "address_miss=+%llu rehash=+%llu crc_miss=+%llu fast=+%llu; " ++#endif + "overlay native=+%llu interp=+%llu hot_native=0x%08X/+%llu " + "shadow=+%llu div=+%llu first_div=0x%08X " + "loads=+%u revalidations=+%u " +@@ -2096,6 +2865,26 @@ + runtime_perf_ticks_ms(current.provider_poll_ticks - last.provider_poll_ticks) / dt, + (double)(current.dirty_insns - last.dirty_insns) / dt, + (double)(current.dirty_dispatches - last.dirty_dispatches) / dt, ++ dirty_hot_pc[0], runtime_perf_live_word(dirty_hot_pc[0], dirty_hot_n[0]), ++ dirty_hot_n[0], ++ dirty_hot_pc[1], runtime_perf_live_word(dirty_hot_pc[1], dirty_hot_n[1]), ++ dirty_hot_n[1], ++ dirty_hot_pc[2], runtime_perf_live_word(dirty_hot_pc[2], dirty_hot_n[2]), ++ dirty_hot_n[2], dirty_hot_stride, ++ (unsigned long long)kbless[1], ++ (unsigned long long)kbless_mismatch_count, ++ (unsigned long long)kbless[3], ++ kbless_last[0], kbless_last[1], kbless_last[2], kbless_last[3], ++ kbless_last[4] & 0xFFu, kbless_last[5] & 0xFFu, ++#ifdef PSX_HAS_OVERLAY_DISPATCH ++ (unsigned long long)(static_now[0] - last_static[0]), ++ (unsigned long long)(static_now[1] - last_static[1]), ++ (unsigned long long)(static_now[2] - last_static[2]), ++ (unsigned long long)(static_now[3] - last_static[3]), ++ (unsigned long long)(static_now[4] - last_static[4]), ++ (unsigned long long)(static_now[5] - last_static[5]), ++ (unsigned long long)(static_now[6] - last_static[6]), ++#endif + (unsigned long long)(current.overlay_native - last.overlay_native), + (unsigned long long)(current.overlay_interp - last.overlay_interp), + current.overlay_hot_native_pc, +@@ -2118,11 +2907,13 @@ + last_underruns = underruns; + last_overflows = overflows; + last_overlay_load_us = overlay_load_us; ++#ifdef PSX_HAS_OVERLAY_DISPATCH ++ for (int i = 0; i < 7; i++) last_static[i] = static_now[i]; ++#endif + for (int i = 0; i < 6; i++) last_up[i] = up[i]; + } + + static void sdl_audio_update(int hard_mute_active, int turbo_sink_active) { +- if (!sdl_audio_device) return; + { /* Tag audio events with the vblank frame counter. */ + extern uint64_t s_frame_count; + audio_trace_note_frame((uint32_t)s_frame_count); +@@ -2145,11 +2936,14 @@ + spu_render(sdl_audio_buf, tail); + sdl_audio_gain_ramp(sdl_audio_buf, tail, g0, 0.0f); + audio_trace_event(AUDIO_EV_MUTE, (uint32_t)tail, 0); +- if (audio_legacy_mode()) { ++ if (sdl_audio_device && audio_legacy_mode()) { ++ sdl_audio_apply_master_gain( ++ sdl_audio_buf, tail, ++ &g_audio_legacy_applied_master_gain); + audio_trace_pcm(AUDIO_TAP_HOST, sdl_audio_buf, tail); + psx_sdl_audio_queue(sdl_audio_device, sdl_audio_buf, + (uint32_t)tail * sizeof(int16_t) * 2u); +- } else if (s_drc_ready) { ++ } else if (sdl_audio_device && s_drc_ready) { + psx_sdl_audio_lock(sdl_audio_device); + rab_push(&s_drc, sdl_audio_buf, tail); + psx_sdl_audio_unlock(sdl_audio_device); +@@ -2506,6 +3300,13 @@ + static void load_input_config(const char* argv0) { + set_default_controller_mapping(); + ++ /* Keyboard bindings are independent of the gamepad input.ini. Load them ++ * before the first-run input.ini creation branch: that branch returns ++ * after writing the default controller file, and previously left the ++ * compiled legacy keyboard defaults active for the entire first session ++ * even when a packaged keybinds.ini was already present. */ ++ psx_keybinds_init(argv0); ++ + namespace fs = std::filesystem; + fs::path config_path = exe_dir_from_argv(argv0) / "input.ini"; + std::error_code ec; +@@ -2557,11 +3358,6 @@ + for (auto& entry : controller_map) entry.sources.clear(); + } + +- /* Configurable KEYBOARD keybinds (keybinds.ini, next to the exe) — separate +- * from input.ini's gamepad map. Loads the user's map (or writes defaults on +- * first run). The launcher may have just edited+saved this file; we re-read +- * it here so the runtime always reflects the current bindings. */ +- psx_keybinds_init(argv0); + } + + static void close_player(PlayerInput& p) { +@@ -2691,12 +3487,26 @@ + } + } + ++/* The keyboard as a pad reads it: a game project may hold back the keys it is typing with. */ ++static const Uint8* pad_keys(void) { ++ int count = 0; ++ const Uint8* keys = SDL_GetKeyboardState(&count); ++#ifdef PSX_HAS_GAME_HOST ++ static Uint8 kept[512]; ++ if (!keys || count <= 0 || count > (int)sizeof(kept)) return keys; ++ psx_game_keys_for_pad(keys, kept, count); ++ return kept; ++#else ++ return keys; ++#endif ++} ++ + /* Keyboard -> PSX button word for `player` (1 or 2), via the configurable + * keybinds.ini map (psx_keybinds). Defaults reproduce the old hardcoded layout + * (arrows=d-pad, X/S/Z/A=Cross/Circle/Square/Triangle, Q/W/E/R=L1/R1/L2/R2, + * Return=Start, RShift=Select) plus T/Y=L3/R3 stick clicks. */ + static uint16_t pad_from_keyboard(int player) { +- const Uint8* keys = SDL_GetKeyboardState(NULL); ++ const Uint8* keys = pad_keys(); + return psx_keybinds_pad_word(keys, player); + } + +@@ -2770,7 +3580,7 @@ + /* Keyboard analog: the configurable left/right stick-direction binds + * (default = arrow keys on the LEFT stick; RIGHT stick unbound), so the + * old keyboard analog behaviour is preserved unless the user rebinds. */ +- const Uint8* keys = SDL_GetKeyboardState(NULL); ++ const Uint8* keys = pad_keys(); + psx_keybinds_sticks(keys, player, out); + return; + } +@@ -2839,7 +3649,7 @@ + } + } + if (p.kind == 1 || dev_any) { +- const Uint8* keys = SDL_GetKeyboardState(NULL); ++ const Uint8* keys = pad_keys(); + if (psx_keybinds_dpad_active(keys, player)) return true; + } + return false; +@@ -3028,7 +3838,7 @@ + * sticks onto the analog stick, so an analog-mode P1 steers from whatever + * is plugged in (P1 binds). */ + if (dev_here && eff_analog) { +- const Uint8* keys = SDL_GetKeyboardState(NULL); ++ const Uint8* keys = pad_keys(); + psx_keybinds_sticks(keys, 1, st); + dev_any_controller_sticks(st); + } +@@ -3095,6 +3905,16 @@ + * onto local_slot (host→sim P1, guest→sim P2). Never writes SIO. Exclusive + * capture — no keyboard-all / all-controllers merge — so peers hash-agree. */ + static void capture_local_human_pad(PsxNetPad* out) { ++ const uint32_t ui_flags = psx_host_ui_capture_flags(); ++ if (ui_flags & (PSX_HOST_UI_CAPTURE_KEYBOARD | ++ PSX_HOST_UI_CAPTURE_GAMEPAD)) { ++ out->buttons = 0xFFFFu; ++ out->lx = out->ly = out->rx = out->ry = 0x80u; ++ out->analog = 1; ++ out->connected = 1; ++ psx_netplay_normalize_pad(out); ++ return; ++ } + int idx = psx_netplay_input_player(); + if (idx < 0 || idx > 1) idx = 0; + if (!capture_pad_slot_exclusive(idx, out)) { +@@ -3228,11 +4048,12 @@ + if (!g_headless) { + SDL_Event ev; + while (SDL_PollEvent(&ev)) { ++ const int ui_consumed = psx_host_ui_handle_event(&ev); + if (ev.type == SDL_QUIT) { + netplay_soft_exit("sdl_window_close"); + if (psx_return_to_lobby_requested()) return; + } +- if (ev.type == SDL_KEYDOWN && ++ if (!ui_consumed && ev.type == SDL_KEYDOWN && + #if defined(PSX_SDL3) + ev.key.key == SDLK_ESCAPE) { + #else +@@ -3261,6 +4082,24 @@ + static void sample_pad_into_sio(int override) { + if (override >= 0) { + apply_input_override_to_sio(override); ++ return; ++ } ++ const uint32_t ui_flags = psx_host_ui_capture_flags(); ++ if (ui_flags & (PSX_HOST_UI_CAPTURE_KEYBOARD | ++ PSX_HOST_UI_CAPTURE_GAMEPAD)) { ++ /* Poll-based input bypasses SDL event consumption. Publish a fully ++ * neutral sample (buttons and sticks) while preserving each port's ++ * current digital/analog presentation mode. */ ++ for (int s = 0; s < 2; s++) { ++ PsxNetPad pad{}; ++ pad.buttons = 0xFFFFu; ++ pad.lx = pad.ly = pad.rx = pad.ry = 0x80u; ++ pad.analog = (g_players[s].mode == PSXRecompV4::PAD_MODE_ANALOG || ++ (g_players[s].mode == PSXRecompV4::PAD_MODE_HYBRID && ++ g_players[s].hybrid_analog)) ? 1u : 0u; ++ pad.connected = g_players[s].kind != 0 ? 1u : 0u; ++ apply_pad_slot_to_sio(s, pad); ++ } + return; + } + for (int s = 0; s < 2; s++) { +@@ -3482,8 +4321,51 @@ + } + } + ++static void engage_widescreen(void) { ++ g_ws_engaged = true; ++ const bool wide = g_video_aspect_num * 3 != g_video_aspect_den * 4; ++ int mode = wide ? (g_ws_native_wide ? 2 : 1) : 0; ++ /* Native-wide: GTE drawn un-squashed — feed it the 4:3 ratio ++ * (identity squash). Squash mode: feed the real wide aspect. */ ++ gte_set_display_aspect(mode == 1 ? g_video_aspect_num : 4, ++ mode == 1 ? g_video_aspect_den : 3); ++ gpu_ws_configure(g_video_aspect_num, g_video_aspect_den, ++ g_ws_anchor_addr, g_ws_hud_sprt ? 1 : 0, mode); ++} ++ ++/* Engage widescreen at game entry: BIOS boot stays authentic 4:3. */ ++static void engage_widescreen_at_game_entry(void) { ++ extern int fntrace_is_game_started(void); ++ if (!g_ws_engaged && fntrace_is_game_started()) engage_widescreen(); ++} ++ ++/* A state of a started game is no game entry. Told by the game's code in memory, as the entry latch tells it. */ ++static void take_loaded_state_for_started(void) { ++ uint32_t bios = 0, entry = 0; ++ savestate_get_integrity(&bios, &entry); ++ if (!dirty_ram_text_image_registered() || !psx_game_text_native_ok(entry)) return; ++ memory_keep_low_boot_scratch(); /* the latch of a fresh process would clear what the game has written there since */ ++ if (!g_ws_engaged) engage_widescreen(); /* at the next VBlank the frame the state draws first would be a 4:3 one */ ++} ++ + /* Called from gpu_vblank_tick() at each simulated vblank. */ + static void sdl_vblank_present(void) { ++ psx_borderless_keep_windowed(); ++ /* A host UI normally submits its request from the GL render callback near ++ * the end of this function. Apply on scope exit, after the old-aspect frame ++ * has been presented, so the next guest frame and its eventual present use ++ * one coherent aspect. The scope also covers every skipped/early-return ++ * vblank path. */ ++ struct PendingAspectBoundary { ++ ~PendingAspectBoundary() { ++ apply_pending_display_aspect(); ++ /* SDL resize events were consumed during this vblank. Commit ++ * their ratio only after the old-aspect frame has presented so ++ * projection, packet correction and destination sizing advance ++ * together for the next guest frame. */ ++ update_adaptive_widescreen(); ++ } ++ } pending_aspect_boundary; + #ifndef PSX_NO_DEBUG_TOOLS + debug_server_set_fmv_quiet(mdec_recently_active(2)); + /* Debug server: pause gate, poll commands, record frame, check watchpoints. */ +@@ -3582,6 +4464,7 @@ + /* Pump SDL events to prevent window freeze. */ + SDL_Event ev; + while (SDL_PollEvent(&ev)) { ++ const int ui_consumed = psx_host_ui_handle_event(&ev); + if (ev.type == SDL_QUIT) { + if (psx_netplay_active()) { + netplay_soft_exit("sdl_window_close"); +@@ -3603,18 +4486,34 @@ + close_controller(); + refresh_player_devices(); + } +- } else if (ev.type == SDL_KEYDOWN) { ++#ifdef PSX_HAS_GAME_HOST ++ } else if (ev.type == SDL_KEYUP) { ++#if defined(PSX_SDL3) ++ (void)psx_game_key((int)ev.key.scancode, (uint32_t)ev.key.key, 0, 1); ++#else ++ (void)psx_game_key((int)ev.key.keysym.scancode, (uint32_t)ev.key.keysym.sym, 0, 1); ++#endif ++#endif ++ } else if (!ui_consumed && ev.type == SDL_KEYDOWN) { + #if defined(PSX_SDL3) + const SDL_Keymod mod = ev.key.mod; + const SDL_Keycode key = ev.key.key; ++ const SDL_Scancode scancode = ev.key.scancode; + #else + const Uint16 mod = ev.key.keysym.mod; + const SDL_Keycode key = ev.key.keysym.sym; ++ const SDL_Scancode scancode = ev.key.keysym.scancode; + #endif + if (key == SDLK_ESCAPE && psx_netplay_active()) { + netplay_soft_exit("netplay_escape"); + return; + } ++#ifdef PSX_HAS_GAME_HOST ++ if (psx_game_key((int)scancode, (uint32_t)key, 1, !(mod & (KMOD_CTRL | KMOD_ALT | KMOD_GUI)))) ++ continue; /* the game took the key */ ++#else ++ (void)scancode; ++#endif + /* Save states: Shift+F1-F12 = save slot 0-11, F1-F12 = load. + * (F11 is a save slot per the user's spec, so fullscreen is + * Alt+Enter / Cmd+Ctrl+F only — no F11.) */ +@@ -3649,21 +4548,34 @@ + * detects "currently fullscreen, either mode". */ + else if ((key == SDLK_RETURN && (mod & KMOD_ALT)) || + (key == SDLK_f && (mod & (KMOD_GUI | KMOD_CTRL)))) { +- Uint32 is_fs = SDL_GetWindowFlags(sdl_window) & +- SDL_WINDOW_FULLSCREEN; +- if (is_fs) { +- SDL_SetWindowFullscreen(sdl_window, 0); +- } else { +- /* If the configured mode is "off", the hotkey still +- * needs a mode to switch INTO — default to borderless, +- * matching the historical (pre-tri-state) behaviour. */ +- Uint32 target = psx_fullscreen_flag_for_mode(g_fullscreen); +- if (target == 0) target = SDL_WINDOW_FULLSCREEN_DESKTOP; +- SDL_SetWindowFullscreen(sdl_window, target); ++ const int current = ++ psx_window_fullscreen_mode(sdl_window); ++ /* If the configured mode is "off", the hotkey still ++ * needs a mode to switch INTO — default to borderless, ++ * matching the historical (pre-tri-state) behaviour. ++ * Deliberately do not update g_fullscreen here: hotkey ++ * state is temporary and is not an implicit preference. */ ++ const int target = current ? 0 : ++ (g_fullscreen ? g_fullscreen : 1); ++ if (!psx_apply_window_fullscreen_mode( ++ sdl_window, target)) { ++ std::fprintf(stderr, ++ "psxrecomp: fullscreen hotkey failed: %s\n", ++ SDL_GetError()); + } + } + } + } ++ } ++ ++ if (g_gl_active) { ++ const uint32_t ui_flags = psx_host_ui_capture_flags(); ++ /* Visible UI must stay on the main GL context even if a provider omits ++ * the explicit suspension flag. The interpolation thread never calls ++ * the host render hook. */ ++ gl_renderer_set_host_ui_suspended( ++ (ui_flags & (PSX_HOST_UI_VISIBLE | ++ PSX_HOST_UI_SUSPEND_INTERPOLATION)) != 0); + } + + /* Sample each player's device and feed the matching SIO pad slot. +@@ -3697,6 +4609,9 @@ + } + } netplay_tail(override); + ++#ifdef PSX_HAS_GAME_HOST ++ if (!psx_netplay_active()) psx_game_before_input(); ++#endif + if (psx_netplay_active()) { + psx_netplay_finish_frame(); + } else if (g_headless) { +@@ -3704,6 +4619,9 @@ + } else { + sample_pad_into_sio(override); + } ++#ifdef PSX_HAS_GAME_HOST ++ if (!psx_netplay_active()) psx_game_after_input(); ++#endif + + /* Latency ring: open this present cycle's slot, stamping when input was + * sampled into SIO. Always-on; queried via the debug server "latency". */ +@@ -3789,16 +4707,21 @@ + } + } + ++ const bool module_fast_forward = g_fast_forward_query && ++ !psx_netplay_active() && g_fast_forward_query() != 0; ++ + #ifndef PSX_SDL_NO_AUDIO + /* Optional turbo host sink advances the canonical SPU on the exact guest + * sample budget but drops accelerated output before SDL. This is distinct + * from the old mute/freeze model: voice and CD state never pause. FMV skip + * retains the hard mute because it deliberately fast-forwards a movie. */ + sdl_audio_update(fmv_skip_active, +- turbo_loads_active && g_turbo_audio_sink_enabled); +-#endif +- +- if (g_headless) { ++ module_fast_forward || ++ (turbo_loads_active && g_turbo_audio_sink_enabled)); ++#endif ++ ++ if (g_headless) engage_widescreen_at_game_entry(); /* a run without a window is the same game */ ++ if (g_headless || module_fast_forward) { + netplay_tail.skip_pace(); + return; + } +@@ -3813,7 +4736,7 @@ + } + #endif + +- /* Turbo mode: while TAB is held, skip both VRAM->ARGB conversion and ++ /* Turbo mode: while ] is held, skip both VRAM->ARGB conversion and + * SDL_RenderPresent. The recompiled BIOS still advances simulated + * cycles every vblank, so the BIOS proceeds at whatever rate the host + * CPU sustains without graphics-driver vsync overhead. Present once +@@ -3822,7 +4745,8 @@ + const Uint8* keys = SDL_GetKeyboardState(NULL); + static int turbo_skip = 0; + const int TURBO_PRESENT_EVERY = 30; +- if (keys[SDL_SCANCODE_TAB]) { ++ if (!(psx_host_ui_capture_flags() & PSX_HOST_UI_CAPTURE_KEYBOARD) && ++ keys[SDL_SCANCODE_RIGHTBRACKET]) { + turbo_skip = (turbo_skip + 1) % TURBO_PRESENT_EVERY; + if (turbo_skip != 0) { + netplay_tail.skip_pace(); +@@ -3916,30 +4840,12 @@ + /* Mod hooks. Run after all normal input sampling. */ + mod_call_frame_hooks(); + +- /* Resize-driven mode updates the pending aspect during BIOS boot too, but +- * the actual wide compositor remains disengaged until game entry. */ +- update_adaptive_widescreen(); +- + /* Depth24 GP1(07h) retarget (MotK intro→crawl): keep the prior Swap for a + * few vblanks so stale trailing VRAM never flashes on the right edge. */ + if (gpu_depth24_present_hold_tick()) + return; + +- /* Engage widescreen at game entry: BIOS boot stays authentic 4:3. */ +- if (!g_ws_engaged) { +- extern int fntrace_is_game_started(void); +- if (fntrace_is_game_started()) { +- g_ws_engaged = true; +- const bool wide = g_video_aspect_num * 3 != g_video_aspect_den * 4; +- int mode = wide ? (g_ws_native_wide ? 2 : 1) : 0; +- /* Native-wide: GTE drawn un-squashed — feed it the 4:3 ratio +- * (identity squash). Squash mode: feed the real wide aspect. */ +- gte_set_display_aspect(mode == 1 ? g_video_aspect_num : 4, +- mode == 1 ? g_video_aspect_den : 3); +- gpu_ws_configure(g_video_aspect_num, g_video_aspect_den, +- g_ws_anchor_addr, g_ws_hud_sprt ? 1 : 0, mode); +- } +- } ++ engage_widescreen_at_game_entry(); + + /* ---- Display from our VRAM ---- */ + uint32_t w = 0, h = 0; +@@ -3963,7 +4869,8 @@ + /* After savestate load, always restage once even if we blanked + * earlier in the session — otherwise the window keeps a stale + * pre-load frame while vblanks (and FPS) keep advancing. */ +- if (!s_disabled_frame_presented || s_force_present_after_load) { ++ if (!s_disabled_frame_presented || s_force_present_after_load || ++ psx_host_ui_gl_visible()) { + s_disabled_frame_presented = true; + s_force_present_after_load = false; + if (g_gl_active) { +@@ -3988,7 +4895,7 @@ + * of truth, shared with the GTE/GPU squash so content and present stay + * locked: we squash IFF we stretch. depth24 always classifies as FMV + * even if the ws layer is not engaged yet (4:3 titles). */ +- fmv_frame = di.depth24 || !g_ws_engaged || gpu_ws_present_native_43() != 0; ++ fmv_frame = di.depth24 || !g_ws_engaged || gpu_ws_displayed_native_43() != 0; + /* MDEC movies are already decoded at their authored cadence and are + * CPU/upload heavy. High-refresh crossfades only contend with decoding + * and can starve audio, so present native-4:3/MDEC phases directly. +@@ -3996,19 +4903,25 @@ + * decode; the activity stamp also covers authentic 4:3 configurations. */ + if (g_gl_active) + gl_renderer_set_interpolation_suspended( +- fmv_frame || mdec_recently_active(2)); ++ fmv_frame || mdec_recently_active(2) || gpu_ws_displayed_menu() != 0); + + /* Canonical present width. Native-wide does NOT widen the canonical read + * (that bled across adjacent framebuffers); it composites into a separate + * wide surface and presents from there instead (see gpu wide compositor). */ + present_w = w; + +- /* Native-wide present: on a game frame, if the active backend has the +- * wide compositor, present the wider surface (canonical width + EXTRA) +- * from the displayed buffer's surface. FMV/menu frames stay 4:3. */ ++ /* A native-wide frame and the same-width high-precision experiment both ++ * present from an independent compositor surface. Native-wide adds ++ * reveal columns; classic squash mode keeps `present_w == w` and uses ++ * the second surface solely to isolate visual correction from VRAM. */ ++ const bool corrected_present = ++ (!fmv_frame && !di.depth24 && g_ws_engaged && ++ gpu_geometry_correction_enabled() && gr_wide_supported()); + bool wide_present = (!fmv_frame && !di.depth24 && g_ws_engaged && +- ws_native_wide_active() && gr_wide_supported()); +- if (wide_present) present_w = w + (uint32_t)ws_nw_extra(); ++ gr_wide_supported() && !gpu_ws_displayed_flat() && ++ (ws_native_wide_active() || corrected_present)); ++ if (wide_present && ws_native_wide_active()) ++ present_w = w + (uint32_t)ws_nw_extra(); + + /* Native-wide invariant: canonical (320-wide) content is NEVER + * stretched across the wide window — a game frame that cannot present +@@ -4045,6 +4958,22 @@ + if (gl_renderer_present_wide_fbo((int)di.display_x, (int)di.display_y, + (int)h, g_video_aa ? 1 : 0)) + return; ++ /* A correction/native-wide surface can be unavailable for its ++ * first frame (allocation failure, an as-yet unknown display ++ * base, or a live mode transition). The GL renderer's CPU ++ * mirror is intentionally native-scale even when the FBO uses ++ * SSAA, so falling through to the generic CPU-hires path would ++ * upload a 1x image as though it were Nx and show a tiny box. ++ * Present the authoritative canonical FBO for this frame; the ++ * compositor can retry on the next one. */ ++ pres_entry->path = PRES_PATH_CANONICAL; ++ pres_entry->wide_fellback = 1; ++ pres_entry->present_w = (uint16_t)w; ++ gl_renderer_present_vram((int)di.display_x, (int)di.display_y, ++ (int)w, (int)h, ++ g_video_aa ? 1 : 0, ++ nw_pin ? 1 : 0); ++ return; + } else { + gl_renderer_present_vram((int)di.display_x, (int)di.display_y, + (int)present_w, (int)h, g_video_aa ? 1 : 0, +@@ -4142,6 +5071,7 @@ + h * (uint32_t)active_scale, + !g_gl_active && !g_vk_active && !di.depth24 && !fmv_frame); + ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + /* Frame blending (CRT-persistence masker for 30fps double-buffered + * content). Some games (e.g. Crash Bash menus/characters) leave a + * dynamic object in only one of the two display buffers per 30fps cycle, +@@ -4183,6 +5113,7 @@ + } + } + } ++#endif + } + + /* Update only the active display rectangle. The backing texture is sized +@@ -4245,7 +5176,7 @@ + if (!g_present_vsync_disabled && present_ms > 250) { + g_present_slow_count++; + if (g_present_slow_count >= 3 && +- SDL_RenderSetVSync(sdl_renderer, 0) == 0) { ++ sdl_render_set_vsync_compat(sdl_renderer, 0) == 0) { + g_present_vsync_disabled = 1; + } + } +@@ -4867,9 +5798,11 @@ + #endif + + int main(int argc, char** argv) { +- /* Force line-buffered output so messages appear even if killed. */ +- std::setvbuf(stdout, nullptr, _IOLBF, 0); +- std::setvbuf(stderr, nullptr, _IOLBF, 0); ++ /* Keep startup diagnostics visible even if the process is killed. MSVC's ++ * CRT rejects a zero-sized _IOLBF buffer via its invalid-parameter handler, ++ * so use the portable zero-sized _IONBF form for both standard streams. */ ++ std::setvbuf(stdout, nullptr, _IONBF, 0); ++ std::setvbuf(stderr, nullptr, _IONBF, 0); + std::fprintf(stderr, "psxrecomp: main() entered\n"); + std::fflush(stderr); + +@@ -5047,7 +5980,11 @@ + bool ws_offered = true; /* game.toml [widescreen] offer; false hides the launcher toggle + clamps 4:3 */ + bool ws_ultrawide_offered = false; + bool ws_adaptive_view_supported = false; ++#ifdef PSX_DISABLE_FRAME_INTERPOLATION ++ bool frame_interpolation_offered = false; ++#else + bool frame_interpolation_offered = true; ++#endif + bool skip_fmv_offered = true; + bool turbo_loads_offered = true; + bool vulkan_offered = false; /* game.toml [video] offer_vulkan; developer opt-in for launcher visibility */ +@@ -5084,6 +6021,7 @@ + * path is resolved (arm_text_image_guard). */ + std::string text_guard_exe_path; + uint32_t text_guard_load_addr = 0; ++ uint32_t text_guard_text_size = 0; + + if (game_config_path) { + try { +@@ -5143,8 +6081,10 @@ + g_video_aspect_den = gc.runtime.video_aspect_den; + g_low_latency_input = gc.runtime.video_low_latency_input ? 1 : 0; + g_video_vsync = gc.runtime.video_vsync; ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + g_frame_interpolation = gc.runtime.video_frame_interpolation ? 1 : 0; + g_frame_interpolation_fps = gc.runtime.video_frame_interpolation_fps; ++#endif + g_fmv_skip_total_table = gc.runtime.video_fmv_skip_total_table; + g_fmv_skip_movie_id = gc.runtime.video_fmv_skip_movie_id; + if (gc.runtime.video_fmv_skip_end_total) +@@ -5308,8 +6248,10 @@ + ws_offered = gc.ws_offered; + ws_ultrawide_offered = gc.ws_ultrawide_offered; + ws_adaptive_view_supported = gc.ws_adaptive_view; ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + frame_interpolation_offered = + gc.runtime.video_offer_frame_interpolation; ++#endif + skip_fmv_offered = gc.runtime.video_offer_skip_fmv; + turbo_loads_offered = gc.runtime.offer_turbo_loads; + vulkan_offered = gc.vulkan_offered; +@@ -5363,6 +6305,7 @@ + * user's disc image instead. */ + text_guard_exe_path = gc.exe_path.string(); + text_guard_load_addr = gc.load_address; ++ text_guard_text_size = gc.text_size; + /* HLE-tier scheduler subsystem replacement default (env + * PSX_HLE_SCHEDULER still wins; latched at first dispatch). */ + psx_hle_scheduler_set_default(gc.runtime.hle_scheduler ? 1 : 0); +@@ -5567,6 +6510,10 @@ + } + } + ++#ifdef PSX_HAS_GAME_HOST ++ if (!psx_game_configure(game_id.data(), game_id.size())) return 1; ++#endif ++ + if (!game_name.empty()) s_picker_game_name = game_name; + + /* Layer the launcher-written settings.toml (next to the exe) over the +@@ -5580,6 +6527,9 @@ + { + std::filesystem::path settings_path = + exe_dir_from_argv(argv[0]) / "settings.toml"; ++ const auto settings_utf8 = settings_path.u8string(); ++ g_host_user_settings_path_utf8.assign(settings_utf8.begin(), ++ settings_utf8.end()); + #if defined(RECOMP_LAUNCHER) + g_lnch_settings_path = settings_path; + #endif +@@ -5640,6 +6590,10 @@ + * Config + env own it until a launcher toggle exists; if one is + * added, restore this line together with the UI. The seed write + * below still persists the effective value for inspection. */ ++#ifdef PSX_HAS_GAME_HOST ++ /* Before the drive first reads the game disc. */ ++ if (psx_game_settings_loaded(us)) fast_boot = true; ++#endif + if (us.has_bios_hle) bios_hle_requested = us.bios_hle; + if (us.has_fullscreen) g_fullscreen = us.fullscreen; + if (us.has_aspect_ratio) { +@@ -5648,6 +6602,10 @@ + } + if (us.has_adaptive_view) g_ws_adaptive_view = us.adaptive_view; + if (us.has_spu_hq) g_audio_spu_hq = us.spu_hq; ++ if (us.has_master_volume) ++ (void)psx_host_audio_set_master_volume(us.master_volume); ++ if (us.has_audio_muted) ++ (void)psx_host_audio_set_muted(us.audio_muted ? 1 : 0); + /* Bundled BIOS: ignore any persisted bios_path (same clamp-as-well- + * as-hide treatment as lock_mode / ws_offered below) so a stale + * settings.toml or launcher-less build can never point a bundled +@@ -5682,10 +6640,30 @@ + if (us.has_deadzone) resolved_deadzone = us.deadzone; + if (us.has_low_latency_input) g_low_latency_input = us.low_latency_input ? 1 : 0; + if (us.has_vsync) g_video_vsync = us.vsync; ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + if (us.has_frame_interpolation) + g_frame_interpolation = us.frame_interpolation ? 1 : 0; + if (us.has_frame_interpolation_fps) + g_frame_interpolation_fps = us.frame_interpolation_fps; ++#if defined(PSX_FRAME_INTERPOLATION_GEOMETRY_DEFAULT) ++ /* Migrate legacy image-blend preferences to geometry at startup. ++ * Explicit environment selection below can still opt into legacy ++ * modes for one-run comparisons. */ ++ g_frame_interpolation_blend_default = ++ PSX_HOST_FRAME_INTERPOLATION_GEOMETRY; ++ g_frame_interpolation_blend = g_frame_interpolation_blend_default; ++#else ++ if (us.has_frame_interpolation_blend) { ++ g_frame_interpolation_blend_default = ++ us.frame_interpolation_blend; ++ g_frame_interpolation_blend = us.frame_interpolation_blend; ++ } ++#endif ++#endif ++ if (us.has_geometry_correction) ++ g_geometry_correction = us.geometry_correction; ++ if (us.has_perspective_textures) ++ g_texture_correction = us.perspective_textures; + if (us.has_parappa_timing_mode || us.has_parappa_timing_extra_early || + us.has_parappa_timing_extra_late) { + parappa_apply_timing_setting( +@@ -5782,25 +6760,65 @@ + g_video_aspect_den = ws_offered ? 9 : 3; + } + if (!ws_adaptive_view_supported) g_ws_adaptive_view = false; +- g_ws_adaptive_max_num = ws_ultrawide_offered ? 21 : 16; ++ /* A title that explicitly opts into resize-driven presentation has ++ * accepted the runtime's full validated aspect range. Launcher-only ++ * ultrawide support remains capped at its historical 21:9 choice. */ ++ g_ws_adaptive_max_num = ws_adaptive_view_supported ++ ? 32 : (ws_ultrawide_offered ? 21 : 16); + g_ws_adaptive_max_den = 9; + +- /* Latency knobs: env overrides win over config (for A/B measurement). ++ /* Explicit process overrides win over saved/configured presentation. ++ * PSX_VIDEO_ASPECT=4:3|16:9|21:9|32:9; + * PSX_LOW_LATENCY_INPUT=0/1 ; PSX_VSYNC=1(vsync)/0(immediate)/-1(adaptive); + * PSX_FRAME_INTERPOLATION=0/1; PSX_FRAME_INTERPOLATION_FPS=0|60+; +- * PSX_FRAME_INTERPOLATION_BLEND=linear|adaptive. */ ++ * PSX_FRAME_INTERPOLATION_BLEND=geometry|linear|adaptive. */ ++ if (const char *e = std::getenv("PSX_VIDEO_ASPECT")) { ++ int numerator = 0; ++ int denominator = 0; ++ if (std::strcmp(e, "4:3") == 0) { ++ numerator = 4; denominator = 3; ++ } else if (std::strcmp(e, "16:9") == 0) { ++ numerator = 16; denominator = 9; ++ } else if (std::strcmp(e, "21:9") == 0) { ++ numerator = 21; denominator = 9; ++ } else if (std::strcmp(e, "32:9") == 0) { ++ numerator = 32; denominator = 9; ++ } ++ const bool parsed = numerator != 0; ++ const bool wide = numerator * 3 != denominator * 4; ++ const bool ultrawide = numerator * 9 > denominator * 16; ++ const bool offered = ++ !wide || (ws_offered && (!ultrawide || ws_ultrawide_offered)); ++ if (parsed && is_host_fixed_display_aspect(numerator, denominator) && ++ offered) { ++ g_video_aspect_num = numerator; ++ g_video_aspect_den = denominator; ++ g_ws_adaptive_view = false; ++ } else { ++ std::fprintf(stderr, ++ "psxrecomp: ignoring unsupported PSX_VIDEO_ASPECT=%s\n", e); ++ } ++ } + if (const char *e = std::getenv("PSX_LOW_LATENCY_INPUT")) g_low_latency_input = atoi(e) ? 1 : 0; + if (const char *e = std::getenv("PSX_VSYNC")) g_video_vsync = atoi(e); + if (const char *e = std::getenv("PSX_SMOOTH_60FPS")) + psx_smooth_60fps_set(atoi(e) ? 1 : 0); ++ if (const char *e = std::getenv("PSX_GEOMETRY_CORRECTION")) ++ g_geometry_correction = atoi(e) ? true : false; ++ if (const char *e = std::getenv("PSX_TEXTURE_CORRECTION")) ++ g_texture_correction = atoi(e) ? true : false; ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + if (const char *e = std::getenv("PSX_FRAME_INTERPOLATION")) + g_frame_interpolation = atoi(e) ? 1 : 0; + if (const char *e = std::getenv("PSX_FRAME_INTERPOLATION_FPS")) { + int fps = atoi(e); +- if (fps == 0 || fps >= 60) g_frame_interpolation_fps = fps; ++ if (fps == 0 || (fps >= 30 && fps <= 1000)) g_frame_interpolation_fps = fps; + } + if (const char *e = std::getenv("PSX_FRAME_INTERPOLATION_BLEND")) { +- if (strcmp(e, "adaptive") == 0 || strcmp(e, "clarity") == 0 || ++ if (strcmp(e, "geometry") == 0 || strcmp(e, "2") == 0) { ++ g_frame_interpolation_blend_default = ++ PSX_HOST_FRAME_INTERPOLATION_GEOMETRY; ++ } else if (strcmp(e, "adaptive") == 0 || strcmp(e, "clarity") == 0 || + strcmp(e, "1") == 0) { + g_frame_interpolation_blend_default = + PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE; +@@ -5810,6 +6828,11 @@ + } + g_frame_interpolation_blend = g_frame_interpolation_blend_default; + } ++ /* Geometry in-between frames need exact geometry, as the live switch insists. */ ++ if (g_frame_interpolation_blend == PSX_HOST_FRAME_INTERPOLATION_GEOMETRY && ++ !g_geometry_correction) ++ g_frame_interpolation = 0; ++#endif + { + const char *mode_env = std::getenv("PSX_PARAPPA_TIMING_MODE"); + const char *early_env = std::getenv("PSX_PARAPPA_TIMING_EXTRA_EARLY"); +@@ -5916,7 +6939,13 @@ + if (want_launcher) { + if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) == 0) { + int lr = 2; /* 0 = launch, 1 = quit, 2 = unavailable */ +- PSXRecompV4::UserSettings seed; ++ /* Merge launcher edits into the canonical file instead of ++ * constructing a fresh snapshot. Game-owned sections and settings ++ * not exposed by recomp-ui must survive a later launcher save. */ ++ PSXRecompV4::UserSettings seed = ++ PSXRecompV4::load_user_settings( ++ exe_dir_from_argv(argv[0]) / "settings.toml"); ++ if (seed.parse_error) seed = PSXRecompV4::UserSettings{}; + seed.renderer = g_video_renderer; seed.has_renderer = true; + seed.supersampling = g_video_scale; seed.has_supersampling = true; + seed.antialiasing = g_video_aa; seed.has_antialiasing = true; +@@ -6415,6 +7444,12 @@ + g_turbo_load_release_frames = TURBO_LOADS_RELEASE_FRAMES; + if (!turbo_loads_offered) + g_turbo_loads_enabled = 0; ++#ifdef PSX_DISABLE_FRAME_INTERPOLATION ++ /* Clamp each runtime session after configuration and launcher selection. ++ * Legacy settings and environment variables cannot revive this feature. */ ++ g_frame_interpolation = 0; ++ g_frame_interpolation_fps = 0; ++#endif + g_frame_interpolation_blend = g_frame_interpolation_blend_default; + mod_runtime_activate_plugins(); + if (g_mod_controller_mode_override[0] >= 0) +@@ -6527,6 +7562,22 @@ + gr_set_scale(g_video_scale); + g_video_scale = gr_scale(); /* reflect any clamp / alloc fallback */ + gr_set_texture_filter(g_video_texfilter); ++ gpu_geometry_correction_set(g_geometry_correction ? 1 : 0); ++ if (g_geometry_correction) ++ std::fprintf(stdout, ++ "psxrecomp: exact-provenance presentation geometry enabled " ++ "(ambiguous SXY fallback disabled; guest GP0/GTE coordinates preserved)\n"); ++ if (g_texture_correction && !g_geometry_correction) { ++ std::fprintf(stdout, ++ "psxrecomp: perspective texture correction requested but exact " ++ "geometry is disabled; keeping affine textures\n"); ++ g_texture_correction = false; ++ } ++ gpu_texture_correction_set(g_texture_correction ? 1 : 0); ++ if (g_texture_correction) ++ std::fprintf(stdout, ++ "psxrecomp: exact-depth perspective textures enabled on the " ++ "presentation mirror (canonical VRAM remains affine)\n"); + /* Display aspect. Identity at the default 4:3. The present letterbox uses + * this aspect; native-wide fills it with a genuinely wider frame (no + * stretch), squash mode stretches the 4:3 frame into it. */ +@@ -6633,6 +7684,7 @@ + * the local EXE file (dev checkouts) and the disc image (every install). */ + if (game_config_path) + arm_text_image_guard(text_guard_exe_path, text_guard_load_addr, ++ text_guard_text_size, + disc_path_str); + mod_runtime_enable_disc_patches(); + { +@@ -6691,6 +7743,7 @@ + + if (g_headless) { + std::fprintf(stdout, "psxrecomp: headless frontend enabled\n"); ++ host_ui_runtime_ready(nullptr, PSX_HOST_UI_BACKEND_NONE); /* a game module sets its session up there, saved settings included */ + } else { + /* ---- SDL init ---- */ + /* Scale quality governs SDL's logical-size -> window scaling. Linear when +@@ -6780,6 +7833,17 @@ + std::fprintf(stderr, "SDL_CreateWindow failed: %s\n", SDL_GetError()); + return 1; + } ++#if defined(PSX_SDL3) ++ /* SDL3's creation flag can request fullscreen but cannot select its ++ * exclusive display mode. Complete a configured exclusive startup now, ++ * before renderer/context creation, so mode 2 is not silently borderless. */ ++ if (g_fullscreen == 2 && ++ !psx_apply_window_fullscreen_mode(sdl_window, g_fullscreen)) { ++ std::fprintf(stderr, ++ "psxrecomp: failed to enter configured exclusive fullscreen: %s\n", ++ SDL_GetError()); ++ } ++#endif + + /* Sync-to-host-refresh: with SDL PRESENTVSYNC on, a fixed 59.94 Hz wall-clock + * pacer fights a 60.00 Hz panel — rendered frames slip onto an uneven vblank +@@ -6909,6 +7973,7 @@ + std::fprintf(stderr, "failed to allocate %dx staging buffer\n", g_video_scale); + return 1; + } ++ sdl_pixel_buf_scale_capacity = g_video_scale; + + if (!g_gl_active && !g_vk_active) { + sdl_texture = SDL_CreateTexture( +@@ -6924,6 +7989,12 @@ + SDL_SetTextureScaleMode(sdl_texture, + g_video_aa ? SDL_ScaleModeLinear : SDL_ScaleModeNearest); + } ++ ++ host_ui_runtime_ready( ++ sdl_window, ++ g_gl_active ? PSX_HOST_UI_BACKEND_OPENGL : ++ g_vk_active ? PSX_HOST_UI_BACKEND_VULKAN : ++ PSX_HOST_UI_BACKEND_SOFTWARE); + } + + /* Register vblank presentation callback. */ +@@ -7012,6 +8083,9 @@ + bios_hle_requested = (e[0] && e[0] != '0'); + if (const char* e = std::getenv("PSX_BIOS_HLE_KEEP_INTRO")) + bios_hle_keep_intro = (e[0] && e[0] != '0'); ++#ifdef PSX_HAS_GAME_HOST ++ if (psx_game_fast_boot_forced()) fast_boot = true; ++#endif + + PsxBiosHleRequest req; + req.bios_hle = bios_hle_requested ? 1 : 0; +@@ -7062,6 +8136,10 @@ + if (game_entry_pc != 0) { + savestate_configure(memcard_dir.string().c_str(), + memory_get_bios_checksum(), game_entry_pc); ++#ifdef PSX_HAS_GAME_HOST ++ /* Before any state can be asked for. */ ++ psx_game_states_configured(); ++#endif + /* Headless / agent load: PSX_LOAD_SLOT=N stages F1..F12 load (0..11) + * at the next safe block boundary after boot. */ + if (const char *ls = std::getenv("PSX_LOAD_SLOT")) { diff --git a/framework/patches/runtime/src/memory.c.patch b/framework/patches/runtime/src/memory.c.patch new file mode 100644 index 0000000..d123c3d --- /dev/null +++ b/framework/patches/runtime/src/memory.c.patch @@ -0,0 +1,225 @@ +diff --git a/runtime/src/memory.c b/runtime/src/memory.c +index d96d6d74422c5b80e3ae283d3a2d872686cab632..86e933d5c6a0e4426fc33ac30c09c68cc148754a 100644 +--- a/runtime/src/memory.c ++++ b/runtime/src/memory.c +@@ -14,6 +14,7 @@ + #include "dma.h" + #include "fntrace.h" + #include "gpu.h" ++#include "gte_precision.h" + #include "mdec.h" + #include "mod_memory.h" + #include "sio.h" +@@ -82,6 +83,16 @@ + return PSX_MOD_GPU_DMA_GUEST_BASE + start; + } + ++uint32_t psx_mod_gpu_dma_memory_bytes(void) { return mod_gpu_dma_memory_used; } ++const uint8_t *psx_mod_gpu_dma_memory_data(void) { return mod_gpu_dma_memory; } ++int psx_mod_gpu_dma_memory_restore(const uint8_t *data, uint32_t size) { ++ if (size > PSX_MOD_GPU_DMA_APERTURE_SIZE || (size && !data)) return 0; ++ if (size) memcpy(mod_gpu_dma_memory, data, size); ++ memset(mod_gpu_dma_memory + size, 0, PSX_MOD_GPU_DMA_APERTURE_SIZE - size); ++ mod_gpu_dma_memory_used = size; ++ return 1; ++} ++ + static int mod_gpu_dma_memory_offset(uint32_t phys, uint32_t width, + uint32_t *offset) { + return psx_mod_gpu_dma_aperture_offset_for( +@@ -115,7 +126,11 @@ + uint8_t *memory_get_ram_ptr(void) { return ram; } + uint8_t *memory_get_scratchpad_ptr(void) { return scratchpad; } + ++static int s_low_scratch_is_the_games; /* a loaded state of a started game: what is there is no longer the boot's */ ++void memory_keep_low_boot_scratch(void) { s_low_scratch_is_the_games = 1; } ++ + void memory_clear_low_boot_scratch(void) { ++ if (s_low_scratch_is_the_games) return; + memset(ram, 0, 0x10u); + } + +@@ -207,6 +222,7 @@ + static uint64_t kbless_verifies = 0; + static uint64_t kbless_mismatches = 0; + static uint64_t kbless_invalidations = 0; ++static uint32_t kbless_last_mismatch[6] = {0}; + + static int kbless_on(void) { + if (kbless_enabled < 0) { +@@ -264,6 +280,20 @@ + } + kbless_state[i] = KBLESS_MISMATCH; + kbless_mismatches++; ++ kbless_last_mismatch[0] = phys; ++ kbless_last_mismatch[1] = b->body_lo; ++ kbless_last_mismatch[2] = b->body_hi; ++ { ++ const uint8_t *live = ram + b->body_lo; ++ const uint8_t *ref = bios_rom + s_kb_rom_off + ++ (b->body_lo - s_kb_lo); ++ uint32_t len = b->body_hi - b->body_lo; ++ uint32_t off = 0; ++ while (off < len && live[off] == ref[off]) off++; ++ kbless_last_mismatch[3] = b->body_lo + off; ++ kbless_last_mismatch[4] = off < len ? live[off] : 0u; ++ kbless_last_mismatch[5] = off < len ? ref[off] : 0u; ++ } + return 0; + } + +@@ -322,6 +352,11 @@ + out[3] = kbless_native_hits; + out[4] = kbless_verifies; + out[5] = kbless_invalidations; ++} ++ ++void psx_kernel_bless_mismatch_detail(uint64_t *count, uint32_t out[6]) { ++ if (count) *count = kbless_mismatches; ++ if (out) memcpy(out, kbless_last_mismatch, sizeof(kbless_last_mismatch)); + } + + static inline void dirty_ram_mark_kernel_write(uint32_t phys) { +@@ -881,6 +916,8 @@ + uint32_t memory_get_bios_checksum(void) { return s_bios_checksum; } + + void memory_init(const char* bios_path) { ++ gte_precision_word_write_begin(); ++ s_low_scratch_is_the_games = 0; + memset(ram, 0, sizeof(ram)); + memset(scratchpad, 0, sizeof(scratchpad)); + /* Rematch re-enters without process exit — wipe sticky I/O regs that +@@ -1458,10 +1495,13 @@ + } + + static void psx_write_word_raw(uint32_t addr, uint32_t val); +-extern void gte_precision_invalidate_word(uint32_t addr); + void psx_write_word(uint32_t addr, uint32_t val) { + extern void (*g_overlay_flush_pending_cycles)(void); + if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles(); ++ /* The immediately following provenance callback may trust only a commit ++ * from this write attempt. Clear after the cycle flush because flushing ++ * can itself perform guest writes. */ ++ gte_precision_word_write_begin(); + if (g_ls_mode == 2) { ls_write_hook(addr, 4, val); return; } + if (g_ds_recording) { + if (g_dma_exec_depth > 0) ds_note_dma_write(); +@@ -1475,7 +1515,6 @@ + } + static void psx_write_word_raw(uint32_t addr, uint32_t val) { + g_guest_store_count++; +- gte_precision_invalidate_word(addr); + /* KSEG2 cache control — before physical translation. */ + if (addr == 0xFFFE0130u) { cache_ctrl = val; return; } + /* KSEG2 guard — see psx_read_word_raw. */ +@@ -1540,6 +1579,9 @@ + return; + } + } ++ /* All discard/suppression paths are now behind us. Only a write that ++ * actually changes main RAM may invalidate prior provenance. */ ++ gte_precision_invalidate_word(addr); + if (phys == D44_PHYS) d44_note(phys, read_ram_word(phys), val); + debug_server_trace_write_check(phys, read_ram_word(phys), val, 4); + parity_trace_note_write(phys, 4, effective_store_pc()); +@@ -1554,12 +1596,18 @@ + ram[phys + 1] = (uint8_t)(val >> 8); + ram[phys + 2] = (uint8_t)(val >> 16); + ram[phys + 3] = (uint8_t)(val >> 24); ++ gte_precision_main_ram_word_committed(phys); ++ /* The matching emitted/interpreted guest-store callback consumes this ++ * token after write_word returns. DMA, HLE and mod writes deliberately ++ * have no such callback, so a sticky debug PC cannot grant provenance. */ + return; + } + { + uint32_t off; + if (mod_gpu_dma_memory_offset(phys, 4u, &off)) { ++ gte_precision_invalidate_word(addr); + memcpy(mod_gpu_dma_memory + off, &val, sizeof(val)); ++ gte_precision_gpu_dma_word_committed(phys); + return; + } + } +@@ -1574,6 +1622,9 @@ + if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) return; + if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { + uint32_t off = phys - 0x1F800000u; ++ /* All discard/suppression paths are behind us. Invalidate any ++ * reviewed scratch provenance before the replacement bytes land. */ ++ gte_precision_invalidate_word(addr); + debug_server_trace_write_check(phys, + (uint32_t)scratchpad[off] + | ((uint32_t)scratchpad[off + 1] << 8) +@@ -1584,6 +1635,7 @@ + scratchpad[off + 1] = (uint8_t)(val >> 8); + scratchpad[off + 2] = (uint8_t)(val >> 16); + scratchpad[off + 3] = (uint8_t)(val >> 24); ++ gte_precision_scratch_word_committed(phys); + return; + } + if (phys >= 0x1F801000u && phys <= 0x1F803FFFu) { +@@ -1662,7 +1714,6 @@ + } + static void psx_write_half_raw(uint32_t addr, uint16_t val) { + g_guest_store_count++; +- gte_precision_invalidate_word(addr); + if (sr_ptr && (*sr_ptr & 0x10000u)) return; + + /* KSEG2 guard — see psx_read_word_raw. */ +@@ -1670,6 +1721,7 @@ + uint32_t phys = psx_phys_addr(addr); + + if (phys < RAM_SIZE) { ++ gte_precision_invalidate_word(addr); + debug_server_trace_write_check(phys, (uint32_t)read_ram_half(phys), (uint32_t)val, 2); + parity_trace_note_write(phys, 2, effective_store_pc()); + card_data_writes_check(phys, (uint32_t)val, 2); +@@ -1686,6 +1738,7 @@ + { + uint32_t off; + if (mod_gpu_dma_memory_offset(phys, 2u, &off)) { ++ gte_precision_invalidate_word(addr); + mod_gpu_dma_memory[off] = (uint8_t)val; + mod_gpu_dma_memory[off + 1u] = (uint8_t)(val >> 8); + return; +@@ -1702,6 +1755,7 @@ + if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) return; + if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { + uint32_t off = phys - 0x1F800000u; ++ gte_precision_invalidate_word(addr); + debug_server_trace_write_check(phys, + (uint32_t)scratchpad[off] | ((uint32_t)scratchpad[off + 1] << 8), + (uint32_t)val, 2); +@@ -1998,7 +2052,6 @@ + } + static void psx_write_byte_raw(uint32_t addr, uint8_t val) { + g_guest_store_count++; +- gte_precision_invalidate_word(addr); + if (sr_ptr && (*sr_ptr & 0x10000u)) return; + + /* KSEG2 guard — see psx_read_word_raw. */ +@@ -2006,6 +2059,7 @@ + uint32_t phys = psx_phys_addr(addr); + + if (phys < RAM_SIZE) { ++ gte_precision_invalidate_word(addr); + debug_server_trace_write_check(phys, (uint32_t)ram[phys], (uint32_t)val, 1); + parity_trace_note_write(phys, 1, effective_store_pc()); + card_data_writes_check(phys, (uint32_t)val, 1); +@@ -2021,6 +2075,7 @@ + { + uint32_t off; + if (mod_gpu_dma_memory_offset(phys, 1u, &off)) { ++ gte_precision_invalidate_word(addr); + mod_gpu_dma_memory[off] = val; + return; + } +@@ -2034,6 +2089,7 @@ + } + if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) return; + if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { ++ gte_precision_invalidate_word(addr); + debug_server_trace_write_check(phys, (uint32_t)scratchpad[phys - 0x1F800000u], + (uint32_t)val, 1); + scratchpad[phys - 0x1F800000u] = val; diff --git a/framework/patches/runtime/src/overlay_loader.c.patch b/framework/patches/runtime/src/overlay_loader.c.patch new file mode 100644 index 0000000..731dba9 --- /dev/null +++ b/framework/patches/runtime/src/overlay_loader.c.patch @@ -0,0 +1,13 @@ +diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c +index d5d1f381cea9019bcba1db168908f599ce6ce650..f05e6918965ea0ede9ddae3e2b6dabbb5b590909 100644 +--- a/runtime/src/overlay_loader.c ++++ b/runtime/src/overlay_loader.c +@@ -3415,7 +3415,7 @@ + return 0; + } + int lazy_loaded = 0; +-retry_candidates: ++retry_candidates: ; + int head = idx_head(phys); + int loaded_range_ci = -1; + int lazy_exact = 0; diff --git a/framework/patches/runtime/src/psx_cycles.c.patch b/framework/patches/runtime/src/psx_cycles.c.patch new file mode 100644 index 0000000..671f00d --- /dev/null +++ b/framework/patches/runtime/src/psx_cycles.c.patch @@ -0,0 +1,13 @@ +diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c +index 72cbe9b496991cbb84b10afb8813c5282c7dd0cf..dfd3b33f2d8383089645c67847200a7559cd784b 100644 +--- a/runtime/src/psx_cycles.c ++++ b/runtime/src/psx_cycles.c +@@ -20,6 +20,8 @@ + uint32_t g_psx_cyc_batch = 0; + uint32_t g_psx_cyc_batch_limit = 0; + int g_psx_cyc_bb_defer = 0; ++uint32_t g_psx_cyc_overclock_shift = 0; ++uint32_t g_psx_cyc_overclock_carry = 0; + static int s_cycle_replay_active = 0; + static uint64_t s_cycle_replay_live = 0; + diff --git a/framework/patches/runtime/src/ws_aspect_cone_math.c.patch b/framework/patches/runtime/src/ws_aspect_cone_math.c.patch new file mode 100644 index 0000000..0e01b45 --- /dev/null +++ b/framework/patches/runtime/src/ws_aspect_cone_math.c.patch @@ -0,0 +1,52 @@ +diff --git a/runtime/src/ws_aspect_cone_math.c b/runtime/src/ws_aspect_cone_math.c +index aca28999b02c32c1c7b16ad7c820472b7e9fd17b..a97019ed2c40bd20f69652731e7f2cfe1644c494 100644 +--- a/runtime/src/ws_aspect_cone_math.c ++++ b/runtime/src/ws_aspect_cone_math.c +@@ -2,19 +2,39 @@ + + #include + +-uint32_t psx_ws_widen_angle_q12(uint32_t vanilla, int extent_pixels) { +- if (extent_pixels <= 0) return vanilla; +- if (vanilla == 0) return 0; +- if (vanilla >= 1024u) return 1023u; ++int32_t psx_ws_widen_angle_units(int32_t vanilla, uint32_t full_turn, ++ int extent_pixels) { ++ if (extent_pixels <= 0 || vanilla == 0) return vanilla; ++ if (full_turn < 4u || (full_turn & 3u) != 0u) return vanilla; ++ ++ const int sign = vanilla < 0 ? -1 : 1; ++ int64_t magnitude = vanilla; ++ if (magnitude < 0) magnitude = -magnitude; ++ const int64_t maximum = (int64_t)full_turn / 4 - 1; ++ if (magnitude > maximum) magnitude = maximum; + + const double tau = 6.283185307179586476925286766559; +- const double angle = (double)vanilla * tau / 4096.0; ++ const double angle = (double)magnitude * tau / (double)full_turn; + const double scale = (160.0 + (double)extent_pixels) / 160.0; + const double widened = atan(tan(angle) * scale); +- long result = lround(widened * 4096.0 / tau); ++ long result = lround(widened * (double)full_turn / tau); + if (result < 1) result = 1; +- if (result > 1023) result = 1023; +- return (uint32_t)result; ++ if ((int64_t)result > maximum) result = (long)maximum; ++ return (int32_t)(sign * result); ++} ++ ++uint32_t psx_ws_widen_angle_q12(uint32_t vanilla, int extent_pixels) { ++ if (vanilla >= 1024u) vanilla = 1023u; ++ return (uint32_t)psx_ws_widen_angle_units( ++ (int32_t)vanilla, 4096u, extent_pixels); ++} ++ ++int32_t psx_ws_scale_horizontal_320(int32_t value, int extent_pixels) { ++ if (extent_pixels <= 0 || value == 0) return value; ++ const int64_t denominator = 160 + (int64_t)extent_pixels; ++ int64_t numerator = (int64_t)value * 160; ++ numerator += numerator >= 0 ? denominator / 2 : -denominator / 2; ++ return (int32_t)(numerator / denominator); + } + + int psx_ws_aspect_cone_contains(int32_t x, int32_t z, int32_t y, diff --git a/framework/patches/runtime/tests/test_ws_aspect_cone_math.c.patch b/framework/patches/runtime/tests/test_ws_aspect_cone_math.c.patch new file mode 100644 index 0000000..a062cfe --- /dev/null +++ b/framework/patches/runtime/tests/test_ws_aspect_cone_math.c.patch @@ -0,0 +1,23 @@ +diff --git a/runtime/tests/test_ws_aspect_cone_math.c b/runtime/tests/test_ws_aspect_cone_math.c +index 30ca90a49e62012c1115db3cd4675d0929f8a3e8..5575a2d22c88cd40f19fc8f69d0ccd53b53181a5 100644 +--- a/runtime/tests/test_ws_aspect_cone_math.c ++++ b/runtime/tests/test_ws_aspect_cone_math.c +@@ -64,5 +64,18 @@ + check(psx_ws_widen_angle_q12(1023, 256) <= 1023, + "terrain angle remains below a quarter turn"); + ++ check(psx_ws_widen_angle_units(32, 256, 0) == 32 && ++ psx_ws_widen_angle_units(-32, 256, 0) == -32, ++ "wrapping yaw offsets are exact at 4:3"); ++ check(psx_ws_widen_angle_units(32, 256, 53) == 38 && ++ psx_ws_widen_angle_units(-32, 256, 53) == -38, ++ "eight-bit yaw cone widens symmetrically for 16:9"); ++ check(psx_ws_scale_horizontal_320(213, 53) == 160 && ++ psx_ws_scale_horizontal_320(-213, 53) == -160, ++ "16:9 horizontal edges map to the original side plane"); ++ check(psx_ws_scale_horizontal_320(123, 0) == 123 && ++ psx_ws_scale_horizontal_320(-123, 0) == -123, ++ "horizontal side-plane scaling is exact at 4:3"); ++ + return failures ? 1 : 0; + } diff --git a/game-widescreen.toml b/game-widescreen.toml index d4ee225..38c715e 100644 --- a/game-widescreen.toml +++ b/game-widescreen.toml @@ -23,6 +23,153 @@ address = "0x80043E90" expected = "0x28420002" replacement = "0x28420001" +# Calls made after an instruction of the game that ran from RAM. A site is an address and the whole +# instruction word expected there. Native code gets the same calls from the generator's own lists, which +# a contract keeps equal to these. + +# Disruptor has no architectural pitch state. The project implements its +# opt-in vertical camera at a deliberately finite set of resident instruction +# sites: CPU-projected screen-Y centres, proven vertical participation tests, +# the two projectile-construction convergence points, and the common renderer +# epilogue. Every complete instruction word is pinned so a different retail +# revision fails generation instead of silently modifying unrelated code. +# +# The generated callback is appended after the vanilla instruction. At zero +# pitch it is an exact no-op; the runtime callback decodes the pinned word to +# identify the destination register or the required post-instruction action. +[[recompiler.instruction_hook]] +callback = "disruptor_vertical_camera_instruction_hook" +sites = [ + # CPU world projection centres (ORI rt,zero,120). + ["0x8003AD54", "0x34020078"], + ["0x8003ADE4", "0x34020078"], + ["0x8003B140", "0x34020078"], + ["0x8003B1EC", "0x34020078"], + ["0x8003B764", "0x34020078"], + ["0x8003B990", "0x34020078"], + ["0x8003BDA4", "0x34020078"], + ["0x8003C4B4", "0x34020078"], + ["0x8003C99C", "0x34020078"], + ["0x8003D07C", "0x34020078"], + ["0x8003D398", "0x34030078"], + ["0x8003F6B0", "0x34020078"], + # Proven camera-vertical object participation pairs. + ["0x8003B900", "0x0205102A"], + ["0x8003B90C", "0x0202102A"], + ["0x8003BD14", "0x0206102A"], + ["0x8003BD20", "0x0202102A"], + ["0x8003CDB8", "0x0065102A"], + ["0x8003CDC4", "0x0062102A"], + # Normal-weapon and psionic construction convergence loads. + ["0x8002E4B8", "0x92640022"], + ["0x8002EAF0", "0x92430020"], + # Common renderer epilogue: restore the neutral projection horizon. + ["0x8004279C", "0x8FBF016C"], +] + +# Disruptor's experimental far-rendering override is host-only: reviewed +# loads of the retail far/fade globals are substituted after the original LW, +# leaving guest RAM untouched. Additional reviewed portal, object-decision, +# and packet-boundary instructions feed read-only diagnostics through the same +# callback. The renderer entry starts a measurement span and the reviewed +# common epilogue closes it. Keep this callback independent from the +# vertical-camera dispatcher so either enhancement can be built and tested in +# isolation. +[[recompiler.instruction_hook]] +callback = "disruptor_far_rendering_instruction_hook" +sites = [ + # Portal traversal diagnostics: recursion depth, unique-room completion, + # and the three candidate-distance values before their branch tests. + ["0x8003A0F8", "0x93B300C0"], + ["0x8003A390", "0xA082001F"], + ["0x8003A92C", "0x00005812"], + ["0x8003A964", "0x00681021"], + ["0x8003AA74", "0x00AC1021"], + # Far-distance loads from $gp + 0x4A0. + ["0x8003A914", "0x8F8C04A0"], + ["0x8003B8C8", "0x8F8304A0"], + ["0x8003BCE0", "0x8F8204A0"], + ["0x8003C168", "0x8F8304A0"], + ["0x8003CD64", "0x8F8304A0"], + # Object distance-decision results. + ["0x8003B8D8", "0x0072182A"], + ["0x8003BCEC", "0x0053102A"], + ["0x8003C178", "0x0077182A"], + ["0x8003CD70", "0x0077182A"], + # Fade-start loads from $gp + 0x494. + ["0x8003B2CC", "0x8F820494"], + ["0x8003BA30", "0x8F820494"], + ["0x8003BE40", "0x8F820494"], + ["0x8003C628", "0x8F820494"], + ["0x8003C878", "0x8F820494"], + ["0x8003D28C", "0x8F820494"], + ["0x8003FB0C", "0x8F840494"], + # Billboard post-pass CLUT-row selection. + ["0x80043184", "0x8F820494"], + # Main-renderer visible-list and packet boundaries. + ["0x800410E0", "0x93830310"], + ["0x800410F8", "0x8F820680"], + ["0x800411D8", "0x8F8301D4"], + # Common renderer epilogue. + ["0x8004279C", "0x8FBF016C"], +] + +# Disruptor's world actors and pickups are CPU-built POLY_FT4 billboards. +# Their projected centre goes through the reviewed classic-wide X squash, but +# their retail pixel width does not. These are five final packet writes plus +# the deferred base-actor submission seam; the host callback tags only a +# completed, shape-validated packet for proportion repair. +[[recompiler.instruction_hook]] +callback = "disruptor_billboard_aspect_instruction_hook" +sites = [ + ["0x8003BB88", "0xA6030016"], + ["0x8003BFB0", "0xA6030016"], + ["0x8003C848", "0xA6030016"], + ["0x8003CAD4", "0xA6020016"], + ["0x8003D488", "0xA2060025"], + ["0x800433A0", "0x02A02821"], +] + +# Disruptor's sky backdrop routine lays SPRT tiles over the opening it has +# already clamped to the 320-pixel frame. These four seams hand the host the +# left edge, the panorama scroll and the right edge before the tile loop reads +# them, so classic widescreen can ask for the columns a wider view needs. +[[recompiler.instruction_hook]] +callback = "disruptor_backdrop_tiles_instruction_hook" +sites = [ + ["0x8003B328", "0x8FB80040"], + ["0x8003B338", "0x00621821"], + ["0x8003B348", "0x8FB80028"], + ["0x8003B5A8", "0x8F8205B8"], + ["0x8003B638", "0x8FB80040"], +] + +# Disruptor's sky backdrop routine lays SPRT tiles over the opening it has +# already clamped to the 320-pixel frame. These four seams hand the host the +# left edge, the panorama scroll and the right edge before the tile loop reads +# them, so classic widescreen can ask for the columns a wider view needs. +[[recompiler.instruction_hook]] +callback = "disruptor_sprite_depth_instruction_hook" +sites = [ + ["0x8003B98C", "0xAFA20010"], + ["0x8003BDA0", "0xAFA20010"], + ["0x8003C4B0", "0xAFA20010"], + ["0x8003D078", "0xAFA20010"], + ["0x8003D1B4", "0xA4379EA0"], +] + +# Disruptor's sky backdrop routine lays SPRT tiles over the opening it has +# already clamped to the 320-pixel frame. These four seams hand the host the +# left edge, the panorama scroll and the right edge before the tile loop reads +# them, so classic widescreen can ask for the columns a wider view needs. +[[recompiler.instruction_hook]] +callback = "disruptor_intro_skip_instruction_hook" +sites = [ + ["0x800147B0", "0x2A820003"], + ["0x80020A80", "0x2A02005E"], + ["0x80048678", "0x30420940"], +] + [runtime] debug_port = 4624 window_title = "Disruptor Recompiled" diff --git a/game.toml b/game.toml index e25a535..62f246b 100644 --- a/game.toml +++ b/game.toml @@ -26,6 +26,153 @@ address = "0x80043E90" expected = "0x28420002" replacement = "0x28420001" +# Calls made after an instruction of the game that ran from RAM. A site is an address and the whole +# instruction word expected there. Native code gets the same calls from the generator's own lists, which +# a contract keeps equal to these. + +# Disruptor has no architectural pitch state. The project implements its +# opt-in vertical camera at a deliberately finite set of resident instruction +# sites: CPU-projected screen-Y centres, proven vertical participation tests, +# the two projectile-construction convergence points, and the common renderer +# epilogue. Every complete instruction word is pinned so a different retail +# revision fails generation instead of silently modifying unrelated code. +# +# The generated callback is appended after the vanilla instruction. At zero +# pitch it is an exact no-op; the runtime callback decodes the pinned word to +# identify the destination register or the required post-instruction action. +[[recompiler.instruction_hook]] +callback = "disruptor_vertical_camera_instruction_hook" +sites = [ + # CPU world projection centres (ORI rt,zero,120). + ["0x8003AD54", "0x34020078"], + ["0x8003ADE4", "0x34020078"], + ["0x8003B140", "0x34020078"], + ["0x8003B1EC", "0x34020078"], + ["0x8003B764", "0x34020078"], + ["0x8003B990", "0x34020078"], + ["0x8003BDA4", "0x34020078"], + ["0x8003C4B4", "0x34020078"], + ["0x8003C99C", "0x34020078"], + ["0x8003D07C", "0x34020078"], + ["0x8003D398", "0x34030078"], + ["0x8003F6B0", "0x34020078"], + # Proven camera-vertical object participation pairs. + ["0x8003B900", "0x0205102A"], + ["0x8003B90C", "0x0202102A"], + ["0x8003BD14", "0x0206102A"], + ["0x8003BD20", "0x0202102A"], + ["0x8003CDB8", "0x0065102A"], + ["0x8003CDC4", "0x0062102A"], + # Normal-weapon and psionic construction convergence loads. + ["0x8002E4B8", "0x92640022"], + ["0x8002EAF0", "0x92430020"], + # Common renderer epilogue: restore the neutral projection horizon. + ["0x8004279C", "0x8FBF016C"], +] + +# Disruptor's experimental far-rendering override is host-only: reviewed +# loads of the retail far/fade globals are substituted after the original LW, +# leaving guest RAM untouched. Additional reviewed portal, object-decision, +# and packet-boundary instructions feed read-only diagnostics through the same +# callback. The renderer entry starts a measurement span and the reviewed +# common epilogue closes it. Keep this callback independent from the +# vertical-camera dispatcher so either enhancement can be built and tested in +# isolation. +[[recompiler.instruction_hook]] +callback = "disruptor_far_rendering_instruction_hook" +sites = [ + # Portal traversal diagnostics: recursion depth, unique-room completion, + # and the three candidate-distance values before their branch tests. + ["0x8003A0F8", "0x93B300C0"], + ["0x8003A390", "0xA082001F"], + ["0x8003A92C", "0x00005812"], + ["0x8003A964", "0x00681021"], + ["0x8003AA74", "0x00AC1021"], + # Far-distance loads from $gp + 0x4A0. + ["0x8003A914", "0x8F8C04A0"], + ["0x8003B8C8", "0x8F8304A0"], + ["0x8003BCE0", "0x8F8204A0"], + ["0x8003C168", "0x8F8304A0"], + ["0x8003CD64", "0x8F8304A0"], + # Object distance-decision results. + ["0x8003B8D8", "0x0072182A"], + ["0x8003BCEC", "0x0053102A"], + ["0x8003C178", "0x0077182A"], + ["0x8003CD70", "0x0077182A"], + # Fade-start loads from $gp + 0x494. + ["0x8003B2CC", "0x8F820494"], + ["0x8003BA30", "0x8F820494"], + ["0x8003BE40", "0x8F820494"], + ["0x8003C628", "0x8F820494"], + ["0x8003C878", "0x8F820494"], + ["0x8003D28C", "0x8F820494"], + ["0x8003FB0C", "0x8F840494"], + # Billboard post-pass CLUT-row selection. + ["0x80043184", "0x8F820494"], + # Main-renderer visible-list and packet boundaries. + ["0x800410E0", "0x93830310"], + ["0x800410F8", "0x8F820680"], + ["0x800411D8", "0x8F8301D4"], + # Common renderer epilogue. + ["0x8004279C", "0x8FBF016C"], +] + +# Disruptor's world actors and pickups are CPU-built POLY_FT4 billboards. +# Their projected centre goes through the reviewed classic-wide X squash, but +# their retail pixel width does not. These are five final packet writes plus +# the deferred base-actor submission seam; the host callback tags only a +# completed, shape-validated packet for proportion repair. +[[recompiler.instruction_hook]] +callback = "disruptor_billboard_aspect_instruction_hook" +sites = [ + ["0x8003BB88", "0xA6030016"], + ["0x8003BFB0", "0xA6030016"], + ["0x8003C848", "0xA6030016"], + ["0x8003CAD4", "0xA6020016"], + ["0x8003D488", "0xA2060025"], + ["0x800433A0", "0x02A02821"], +] + +# Disruptor's sky backdrop routine lays SPRT tiles over the opening it has +# already clamped to the 320-pixel frame. These four seams hand the host the +# left edge, the panorama scroll and the right edge before the tile loop reads +# them, so classic widescreen can ask for the columns a wider view needs. +[[recompiler.instruction_hook]] +callback = "disruptor_backdrop_tiles_instruction_hook" +sites = [ + ["0x8003B328", "0x8FB80040"], + ["0x8003B338", "0x00621821"], + ["0x8003B348", "0x8FB80028"], + ["0x8003B5A8", "0x8F8205B8"], + ["0x8003B638", "0x8FB80040"], +] + +# Disruptor's sky backdrop routine lays SPRT tiles over the opening it has +# already clamped to the 320-pixel frame. These four seams hand the host the +# left edge, the panorama scroll and the right edge before the tile loop reads +# them, so classic widescreen can ask for the columns a wider view needs. +[[recompiler.instruction_hook]] +callback = "disruptor_sprite_depth_instruction_hook" +sites = [ + ["0x8003B98C", "0xAFA20010"], + ["0x8003BDA0", "0xAFA20010"], + ["0x8003C4B0", "0xAFA20010"], + ["0x8003D078", "0xAFA20010"], + ["0x8003D1B4", "0xA4379EA0"], +] + +# Disruptor's sky backdrop routine lays SPRT tiles over the opening it has +# already clamped to the 320-pixel frame. These four seams hand the host the +# left edge, the panorama scroll and the right edge before the tile loop reads +# them, so classic widescreen can ask for the columns a wider view needs. +[[recompiler.instruction_hook]] +callback = "disruptor_intro_skip_instruction_hook" +sites = [ + ["0x800147B0", "0x2A820003"], + ["0x80020A80", "0x2A02005E"], + ["0x80048678", "0x30420940"], +] + [runtime] debug_port = 4624 window_title = "Disruptor Recompiled" 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/recompiler/include/code_generator.h b/psxrecomp-overlay/recompiler/include/code_generator.h deleted file mode 100644 index 8e6e02e..0000000 --- a/psxrecomp-overlay/recompiler/include/code_generator.h +++ /dev/null @@ -1,513 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include "ps1_exe_parser.h" -#include "function_analysis.h" -#include "control_flow.h" -#include "../src/annotations.hpp" -#include "../src/config_loader.h" - -namespace PSXRecompV4 { class BiosAddressModel; } - -namespace PSXRecomp { - -// CPU state structure (registers + memory access) -struct CPUState { - // General purpose registers (32 total) - uint32_t gpr[32]; // $0-$31 - uint32_t pc; // Program counter - uint32_t hi, lo; // Multiply/divide results - - // Memory access functions (to be implemented in runtime) - uint32_t (*read_word)(uint32_t addr); - void (*write_word)(uint32_t addr, uint32_t value); - uint16_t (*read_half)(uint32_t addr); - void (*write_half)(uint32_t addr, uint16_t value); - uint8_t (*read_byte)(uint32_t addr); - void (*write_byte)(uint32_t addr, uint8_t value); -}; - -// Code generation configuration -struct CodeGenConfig { - bool emit_comments; // Include MIPS instruction comments - bool emit_line_numbers; // Include original address as comments - bool optimize_zero_reg; // Optimize away $zero assignments - bool use_switch_for_blocks; // Use switch instead of goto labels - bool split_mid_function_targets; // Split branch targets into funcs for legacy main-EXE analysis - // True when compiling an overlay image (psxrecomp-game --overlay). Overlay - // regions hold different scene VARIANTS at the same addresses (the same addr - // is backdrop code in one scene, unrelated code/data in another), so an - // explicit per-address widescreen site (x_sites / cull_* sites) authored for - // one variant won't match the bytes in another. In overlay mode such a shape - // mismatch is EXPECTED (apply the transform only where the bytes match, skip - // it elsewhere) rather than a hard config error. - bool overlay_mode = false; - std::vector ws_signed_x_bound_sites; - std::string indent; // Indentation string (default: " ") - // Widescreen sprite-tag hooks ([widescreen] sprite_tag_funcs): functions - // that get a psx_ws_sprite_tag(cpu) callback emitted at entry, so the - // runtime can record the per-prim pointer ($a0) + projected anchor for - // proportion correction at GP0 submission. Empty = no hooks (default). - std::set ws_sprite_tag_funcs; - - // Data-shard hooks ([data_shards] funcs): memoized pure-function replay. - // Entry gets `if (psx_datashard_enter(cpu, KEY)) return;` (replay path), - // every jr-$ra return gets `psx_datashard_ret(cpu)` (capture finalize). - // See docs/DATA_SHARDS.md. Empty = no hooks (default). - std::set data_shard_funcs; - - // Trusted game-mod entry hooks ([recompiler] mod_function_entry_funcs). - // Only explicitly listed guest functions call the runtime dispatcher. - std::set mod_function_entry_funcs; - - // [recompiler] hot_funcs: emit __attribute__((hot)) on these guest - // addresses (MotK VLC leaves, etc.). Host locality hint only. - std::set hot_funcs; - - // [load_accel.vsync_query] verified PsyQ VSync functions whose mode=-1 - // path may bypass its unused GPUSTAT/Timer1 reads. The map value is the - // guest RAM VBlank counter returned by that query path. Empty = inert. - // value = {VBlank counter, GPUSTAT-pointer global, Timer1-pointer global, - // cached Timer1 global} - std::map> vsync_query_hle_funcs; - - // MMX6-class 2D tile-ring stage initializer. Emit a reveal-invalidation - // callback at entry so host-only wide pixels cannot survive a stage swap. - uint32_t ws_bg2d_init_func = 0; - - // Widescreen far-backdrop un-squash ([widescreen] backdrop_unsquash_funcs): - // functions whose body is bracketed by gte_ws_set_suppress(1)/(0) so the - // GTE X-squash is OFF for their (far-backdrop) draws — the backdrop fills - // the stretched 16:9 frame instead of leaving edge void (8C). Suppress is - // set at entry and cleared before every jr-$ra return. Empty = none. - std::set ws_backdrop_unsquash_funcs; - - // Widescreen cull-margin widening ([widescreen] cull_* sites). At these - // addresses the immediate is emitted with a runtime margin term - // psx_ws_x_margin() so the world-space draw cull widens with the aspect - // (0 at 4:3). bias = addiu (+margin); range = sltiu (+2*margin); a1 = a - // nop replaced with `a1 += margin`, or move rD,a1 replaced with - // `rD = a1 + margin`. See config_loader.h. Empty = default. - std::set ws_cull_bias_sites; - std::set ws_cull_range_sites; - std::set ws_cull_a1_sites; - std::set ws_cull_screen_x_sites; - // Extra lead for bias/range activation windows only. Render/terrain - // helpers continue to use psx_ws_x_margin() without this term. - int ws_cull_activation_guard_pixels = 0; - - // Explicit signed right-edge widen sites ([widescreen.cull] slti_sites): - // `slti rt, sx, W` emitted through psx_ws_cull_slti for funnel functions - // the auto-detector cannot qualify (X-only test, no height compare). - std::set ws_cull_slti_sites; - // Signed lower-bound counterpart: `slti rt,sx,-W` compares against - // `-W-x_margin`, preserving the original result at 4:3. - std::set ws_cull_slti_lower_sites; - // Manual screen-window counterparts for zero-bound branches, constant / - // zero-store clamps, right-edge constants, and packed [left,right] spans. - // Each configured site is instruction-shape checked by codegen. - std::set ws_cull_lower_branch_sites; - std::set ws_cull_lower_value_sites; - std::set ws_cull_upper_value_sites; - std::set ws_cull_span_value_sites; - - // `bltz rs, reject` emitted through psx_ws_cull_bltz — the explicit - // LEFT-edge counterpart to ws_cull_slti_sites ([widescreen.cull] - // bltz_sites), for X-only funnels the auto-detector cannot qualify. - std::set ws_cull_bltz_sites; - - // Explicit horizontal low-edge widen sites ([widescreen.cull] - // negsub_sites): `subu rd, zero, rs` becomes `-rs - x_margin`. - // Identity at 4:3; empty by default. - std::set ws_cull_negsub_sites; - - // Explicit masked-16-bit screen-X window sites ([widescreen.cull] - // vxrange_sites). The configured sltiu is routed through the shared - // runtime helper so native and interpreted overlays use identical math. - std::set ws_cull_vxrange_sites; - - // Explicit aspect-scaled far-bound sites ([widescreen.cull] - // depth_sites). Configured slti/sltiu instructions route through the - // runtime helper; empty by default. - std::set ws_cull_depth_sites; - - // Side frustum-plane normal-X load sites ([widescreen.cull] - // plane_nx_sites). The configured lw routes through the runtime helper - // (inverse-aspect scale while revealed, identity at 4:3). - std::set ws_cull_plane_nx_sites; - - // Per-primitive X-reject bound load sites ([widescreen.cull] - // xclip_load_sites). The configured lw routes through the runtime helper - // (INT32_MAX while revealed, vanilla at 4:3); empty by default. - std::set ws_cull_xclip_load_sites; - - // Exact, full-word-guarded comparison sites whose result is forced only - // while widescreen reveals extra world. 4:3 evaluates the original compare. - std::vector ws_cull_keep_sites; - - // Exact `addi[u] rt,zero,imm` 12-bit angular half-extents. The runtime - // scales tan(angle) by the current horizontal reveal factor. - std::vector ws_cull_angle_sites; - - // Exact `addi[u] rt,rs,+/-half_fov` wrapping-yaw sites. The runtime widens - // only the signed offset in the site's declared angular unit system. - std::vector ws_cull_yaw_cone_sites; - - // Exact signed `slt dst,bound,horizontal` side-plane comparisons. The - // horizontal operand is projection-scaled only while widescreen is live. - std::vector - ws_cull_horizontal_slt_sites; - - // Exact model-participation cosine compares that gain a camera-horizontal - // aspect envelope while preserving the vanilla vertical cone. - PSXRecompV4::WidescreenAspectConeConfig ws_aspect_cone; - - // Screen-extent signature immediates ([widescreen.cull] screen_w_imms / - // screen_h_imms) — per-game display-width-derived bounds. Defaults are the - // Tomba signature; Ape Escape uses 0x181 (+ 0xF1 height). - std::vector ws_cull_w_imms = { 0x140, 0x141 }; - std::vector ws_cull_h_imms = { 0xE0, 0xF1 }; - - // Widescreen automatic horizontal-FOV cull widening ([widescreen.cull] - // auto_screen_x). When true, any function carrying the GTE screen-extent - // reject signature — at least one `sltiu …,0x140/0x141` (right/width) AND - // one `sltiu …,0xE0/0xF1` (bottom/height) — has every width compare emitted - // with + 2*psx_ws_x_margin() (0 at 4:3). This routes the whole render-funnel - // (≈100 sites in Tomba) through one helper with no per-address list, and is - // generic to any PSX GTE title. The vertical (0xE0) bound is never touched. - bool ws_auto_screen_x_cull = false; - - // Widescreen automatic far-backdrop column PRELOAD ([widescreen.cull] - // auto_backdrop). When true, every scrolling-backdrop column-window generator - // is auto-detected by its invariant (see ws_backdrop_detect.h) and its window - // START/END finalize instructions are emitted through psx_ws_backdrop_value() - // so the whole finite tile row preloads in 16:9 (0 at 4:3 => byte-identical). - // Generators are overlay-resident, so this must reach the overlay compile. - bool ws_auto_backdrop_preload = false; - - // Widescreen backdrop screenX squash ([widescreen.backdrop] x_sites). Each - // address is a `sh rt,off(base)` storing a parallax 2D backdrop layer's - // final screen-X; emitted as write_half(base+off, psx_ws_backdrop_x(rt)) - // so the un-GTE'd backdrop is squashed around screen centre for the 16:9 - // FOV (identity at 4:3). Overlay-resident; see config_loader.h. Empty=default. - std::set ws_backdrop_x_sites; - - // Widescreen pure-2D background tile-loop widen ([widescreen.bg2d]). Three - // instruction addresses in a 2D game's per-layer BG renderer (MMX6's - // FUN_800270d0): the column-count load/compare, the start tile-column mask, - // and the start screen-x calculation (MMX6: li count + sra start; MMX4/MMX5: - // inline slti[u] count, sra or subu-zero start), rewritten through the gpu.c - // psx_ws_bg2d_* helpers so the loop draws the 16:9 reveal columns on both - // sides (identity at 4:3 / 512 hi-res). 0 = unset. Main-EXE addresses; - // verified by opcode at gen time. - uint32_t ws_bg2d_count_site = 0; - uint32_t ws_bg2d_startcol_site = 0; - uint32_t ws_bg2d_startx_site = 0; - uint32_t ws_bg2d_stream_left_site = 0; // tile-ring streamer left leading-edge addiu - uint32_t ws_bg2d_stream_right_site = 0; // tile-ring streamer right leading-edge addiu - uint32_t ws_bg2d_bufbase_site = 0; // driver addu: BG packet-buffer address (relocate) - uint32_t ws_bg2d_cap_site = 0; // renderer slti: per-frame BG tile cap (raise) - - // Persistent game-option init-store hooks ([persist_options] in - // game_options.toml). Each entry is the PC of a boot-init sb/sh that writes a - // config global's DEFAULT value; the store is rewritten to route the value - // through psx_game_option_store(addr,val), so a value persisted from a prior - // session overrides the default at initialization (issue #5). Identity when - // nothing is persisted, so a fresh install is byte-identical. Empty=default. - std::set persist_init_store_sites; - - CodeGenConfig() - : emit_comments(true) - , emit_line_numbers(true) - , optimize_zero_reg(true) - , use_switch_for_blocks(false) - , split_mid_function_targets(true) - , indent(" ") {} -}; - -// Result of code generation for a function -struct GeneratedFunction { - std::string function_name; // e.g., "func_80016940" - std::string signature; // e.g., "void func_80016940(CPUState* cpu)" - std::string body; // C code body - std::string full_code; // signature + body - int line_count; // Number of lines generated - // False for fail-closed data/untranslatable stubs whose only operation is - // psx_unknown_dispatch. They remain emitted for direct diagnostics, but - // must not advertise themselves as executable native game entries. - bool dispatchable = true; -}; - -class CodeGenerator { -public: - explicit CodeGenerator(const PS1Executable& exe, const CodeGenConfig& config = CodeGenConfig()); - - // Set the BIOS address model the game codegen folds shell/kernel RAM - // aliases through (jump tables inside relocated BIOS code windows). - // Resolved from the game's [recompiler] bios_config profile (default: - // the SCPH1001 profile) in main_psx.cpp; there is no built-in window. - // Must outlive code generation. - static void set_bios_address_model(const PSXRecompV4::BiosAddressModel* m); - - // Generate C code for a single function. - // fallthrough_name: if non-empty and the last block has no explicit control - // flow (ControlFlowType::None), emit a tail call to that function before '}'. - // This handles MIPS functions that fall through into the next function without - // an explicit jr/j instruction. - GeneratedFunction generate_function( - const Function& func, - const ControlFlowGraph& cfg, - const std::string& fallthrough_name = ""); - - // Generate C code for all functions - std::vector generate_all_functions( - const std::vector& functions, - const std::map& cfgs); - - // Generate a group of overlapping-alias entries that share one host - // function: ONE static body holding the host-range blocks (restricted to - // the union of blocks reachable from the entries) with an entry switch, - // plus a dispatchable wrapper per entry. Returns one GeneratedFunction per - // entry; the first carries the shared body. - std::vector generate_alias_group( - const std::vector& aliases, - const ControlFlowGraph& cfg, - const std::string& fallthrough_name); - - // Generate complete C file with all functions - std::string generate_file( - const std::vector& functions, - const std::map& cfgs); - - // The generated functions from the most recent generate_file() call - // (post mid-function-split pass). Consumed by main_psx to write the - // split full.c shards + shared decls header. Not populated until - // generate_file() has run. - const std::vector& last_gen_funcs() const { return last_gen_funcs_; } - - // Build the shared-declarations header shared by every full.c shard: - // the runtime extern prologue, the unaligned-access helpers (as static - // inline, since this header is included by multiple translation units), - // and forward declarations for every recompiled function plus every - // overlapping-alias shared body. Byte-for-byte the same declaration set - // the monolith emits inline, just hoisted into a header. - std::string build_shared_decls_header(const std::vector& gen_funcs) const; - - // Generate the per-function code-range manifest consumed by the overlay - // loader's per-entry validity hash (design §8). For each function, emits its - // compiled code byte-ranges (union of basic-block extents), coalesced; - // interleaved jump tables / constant pools fall in the gaps and are - // excluded — which is what makes the runtime hash stable across reloads. - // F one per function (virtual entry address) - // R one per coalesced code range (virtual addr) - std::string generate_ranges_manifest( - const std::vector& functions, - const std::map& cfgs); - - // Exact manifest for the final function/CFG set emitted by the most recent - // generate_file() call, including functions synthesized by its split pass. - const std::string& last_ranges_manifest() const { return last_ranges_manifest_; } - - // Set known functions for this compilation unit (for linking) - void set_known_functions(const std::set& functions) { - known_functions_ = functions; - } - - // Set annotation table (optional — no-op if not called) - void set_annotations(const AnnotationTable* at) { annotations_ = at; } - -private: - const PS1Executable& exe_; - CodeGenConfig config_; - std::set known_functions_; // Addresses of functions in this compilation unit - std::string last_ranges_manifest_; - std::set extra_labels_; // Mid-block addresses that need inline labels (jump table targets) - const AnnotationTable* annotations_ = nullptr; - - // Forward declarations for every psx_alias_body_XXXXXXXX() emitted by - // generate_alias_group() during the most recent generate_all_functions() - // pass. These bodies are externally linked (split-TU build) so every - // shard/header that calls into another shard's alias body needs the - // prototype. Cleared at the top of generate_all_functions(). - std::vector alias_body_decls_; - - // The functions emitted by the most recent generate_file() call, stashed - // for main_psx's split-shard writer. See last_gen_funcs(). - std::vector last_gen_funcs_; - - // Emit the runtime-hook extern prologue (debug_server_log_call_entry ... - // g_debug_last_store_pc). Shared verbatim between the monolith path - // (generate_file) and the split-shard shared header - // (build_shared_decls_header). - void emit_runtime_externs(std::ostream& ss) const; - - // Emit the LWL/LWR/SWL/SWR unaligned-access reference helpers. - // as_inline selects `static inline` (shared header, included by many - // TUs) vs `static` (monolith, single TU) linkage for the 4 helper - // function definitions; everything else is identical. - void emit_unaligned_helpers(std::ostream& ss, bool as_inline) const; - - // RECURSION_BUG.md §25 — continuation-passing call/return (the universal fix - // for the idle-freeze host-stack leak). When set (gen-time env PSX_CPS), - // guest calls (jal/jalr/jr-table/external) are emitted as TAIL-TRANSFERS - // (set $ra, cpu->pc = target, return) and each call's return point is - // registered as a dispatchable continuation; jr $ra publishes cpu->pc. - // The unified flat trampoline (psx_dispatch_impl) drives the whole - // call/return via the guest's own $ra/$sp — so the host stack can never - // mirror unbounded guest re-entrancy. The §8 leaking chain (func_8001A954 - // ...) lives in GAME code, so the game emitter MUST honor CPS too. - bool cps_enabled_ = false; - // Continuations (return points) collected during the CURRENT function's - // block translation; consumed by generate_function to emit the entry-switch. - std::vector cps_cur_continuations_; - // Global map: continuation address -> owning function entry, for the game - // dispatch table (psx_dispatch_game_compiled) to route a returned-to - // continuation into its function's entry-switch. - std::map cps_continuation_owner_; - - // Cycle accounting for legal entries that land inside an already-emitted - // block instead of at its leader. Block leaders charge the whole block; an - // entry-switch or jump-table target that bypasses the leader must charge the - // remaining instructions from the interior label to the end of that block. - uint32_t partial_block_cycle_count(uint32_t addr, const ControlFlowGraph& cfg) const; - std::string emit_mid_block_cycle_charge(uint32_t addr, const ControlFlowGraph& cfg, - const std::string& indent) const; - std::string emit_interrupt_check(uint32_t resume_pc, const std::string& indent) const; - std::string emit_interrupt_check_expr(const std::string& resume_pc_expr, - const std::string& indent) const; - -public: - bool cps_enabled() const { return cps_enabled_; } - // continuation_addr -> owning function entry (CPS dispatch routing). - const std::map& cps_continuations() const { - return cps_continuation_owner_; - } -private: - - // Set per-function by generate_function when config_.ws_auto_screen_x_cull is - // on AND the function carries the GTE screen-extent reject signature; read by - // translate_instruction to widen that function's width compares. See - // func_has_screen_extent_cull(). - bool ws_auto_cull_func_ = false; - // LEFT-edge funnel bltz addresses in the current function (auto_screen_x, - // signed min/max + center±halfwidth idioms — ws_cull_detect.h). Populated - // per function alongside ws_auto_cull_func_; generate_branch_condition - // emits a listed bltz through psx_ws_cull_bltz. Cleared per function. - std::set ws_cull_bltz_pcs_; - - // True iff the function's instruction stream contains both a screen-width - // reject (`sltiu …,0x140` or `…,0x141`) and a screen-height reject - // (`sltiu …,0xE0` or `…,0xF1`) — the uniform GTE per-vertex trivial-reject. - bool func_has_screen_extent_cull(const ControlFlowGraph& cfg) const; - - // Backdrop-preload rewrite sites for the CURRENT function ([widescreen.cull] - // auto_backdrop). Maps each detected window-bound instruction address to - // {kind (WS_BD_START / WS_BD_END), window_cols (|addiu offset|, the widen - // margin scale)}. Repopulated per function by detect_backdrop_windows() - // (gated on config_.ws_auto_backdrop_preload) and consulted by - // translate_instruction; cleared every function so it never leaks. - std::map> ws_backdrop_sites_; - - // Populate ws_backdrop_sites_ from the shared psx_ws_find_backdrop_windows() - // detector over this function's instruction words; returns the window count. - int detect_backdrop_windows(const ControlFlowGraph& cfg); - // Populate ws_cull_bltz_pcs_ (LEFT-edge funnel bltz addresses) for the - // current function; no-op unless ws_auto_cull_func_ is set. - int detect_cull_bltz_sites(const ControlFlowGraph& cfg); - - // Instruction translation - std::string translate_instruction(uint32_t addr, uint32_t instr); - - // Register name mapping - static std::string reg_name(int reg_num); - - // Basic block translation - std::string translate_basic_block( - const BasicBlock& block, - const ControlFlowGraph& cfg); - - // Detect jr jump tables across all blocks of a CFG. Fills extra_labels_ - // (mid-block table targets needing inline labels) and out_edges - // (jr block -> in-range rom targets), used for alias reachability. - void scan_jr_tables( - const ControlFlowGraph& cfg, - std::map>& out_edges); - - // Control flow translation - std::string generate_branch_condition(uint32_t instr, uint32_t addr); - std::string translate_branch(const ControlFlowInstr& cf, uint32_t fall_through); - std::string translate_jump(const ControlFlowInstr& cf); - - // Arithmetic instructions - std::string translate_add(uint32_t instr); - std::string translate_addu(uint32_t instr); - std::string translate_sub(uint32_t instr); - std::string translate_subu(uint32_t instr); - std::string translate_mult(uint32_t instr); - std::string translate_multu(uint32_t instr); - std::string translate_div(uint32_t instr); - std::string translate_divu(uint32_t instr); - std::string translate_mfhi(uint32_t instr); - std::string translate_mflo(uint32_t instr); - std::string translate_mthi(uint32_t instr); - std::string translate_mtlo(uint32_t instr); - - // Logical instructions - std::string translate_and(uint32_t instr); - std::string translate_or(uint32_t instr); - std::string translate_xor(uint32_t instr); - std::string translate_nor(uint32_t instr); - - // Shift instructions - std::string translate_sll(uint32_t instr); - std::string translate_srl(uint32_t instr); - std::string translate_sra(uint32_t instr); - std::string translate_sllv(uint32_t instr); - std::string translate_srlv(uint32_t instr); - std::string translate_srav(uint32_t instr); - - // Load/store instructions - std::string translate_lw(uint32_t instr); - std::string translate_sw(uint32_t instr); - std::string translate_lb(uint32_t instr); - std::string translate_lbu(uint32_t instr); - std::string translate_lh(uint32_t instr); - std::string translate_lhu(uint32_t instr); - std::string translate_lwl(uint32_t instr); - std::string translate_lwr(uint32_t instr); - std::string translate_swl(uint32_t instr); - std::string translate_swr(uint32_t instr); - std::string translate_sb(uint32_t instr); - std::string translate_sh(uint32_t instr); - - // Immediate instructions - std::string translate_addiu(uint32_t instr); - std::string translate_andi(uint32_t instr); - std::string translate_ori(uint32_t instr); - std::string translate_xori(uint32_t instr); - std::string translate_lui(uint32_t instr); - - // Set instructions - std::string translate_slt(uint32_t instr); - std::string translate_sltu(uint32_t instr); - std::string translate_slti(uint32_t instr); - std::string translate_sltiu(uint32_t instr); - - // Helper: Extract register fields - static uint32_t get_rs(uint32_t instr) { return (instr >> 21) & 0x1F; } - static uint32_t get_rt(uint32_t instr) { return (instr >> 16) & 0x1F; } - static uint32_t get_rd(uint32_t instr) { return (instr >> 11) & 0x1F; } - static uint32_t get_shamt(uint32_t instr) { return (instr >> 6) & 0x1F; } - static uint32_t get_funct(uint32_t instr) { return instr & 0x3F; } - static int16_t get_imm16(uint32_t instr) { return (int16_t)(instr & 0xFFFF); } - static uint16_t get_imm16_u(uint32_t instr) { return (uint16_t)(instr & 0xFFFF); } -}; - -} // namespace PSXRecomp diff --git a/psxrecomp-overlay/recompiler/src/code_generator.cpp b/psxrecomp-overlay/recompiler/src/code_generator.cpp deleted file mode 100644 index f54752e..0000000 --- a/psxrecomp-overlay/recompiler/src/code_generator.cpp +++ /dev/null @@ -1,4112 +0,0 @@ -#include "code_generator.h" -#include "control_flow.h" -#include "gte_register_classification.h" -#include "../src/bios_address_model.h" -// Shared widescreen backdrop-window detector (single source of truth across the -// recompiler and the interpreter). Self-contained C header; -// included via relative path to avoid an include-dir collision (recompiler and -// runtime both ship a gte.h). -#include "../../runtime/include/ws_backdrop_detect.h" -#include "../../runtime/include/ws_cull_detect.h" -#include "../../runtime/include/psx_instr_cost.h" /* single-source CPU cycle cost (shared with interp) */ -#include -#include -#include -#include -#include -#include - -namespace PSXRecomp { - -/* Disruptor's resident geometry builders read SXY2 with MFC2 and later use an - * ordinary SW. Keep these hooks deliberately narrower than the general code - * generator: the runtime also checks the game serial, PC and raw instruction. - * A shape mismatch at either source PC is a build error, not a silent widening - * of the provenance trust boundary. */ -static bool disruptor_precision_mfc2_pc(uint32_t address) { - return address == 0x8004633Cu || address == 0x8004795Cu; -} - -static bool disruptor_precision_store_pc(uint32_t address) { - switch (address) { - case 0x8004641Cu: - case 0x800464BCu: - case 0x80046524u: - case 0x80046540u: - case 0x80046594u: - case 0x800465D8u: - return true; - default: - return false; - } -} - -static bool disruptor_precision_scratch_store_pc(uint32_t address) { - return address == 0x80047A0Cu; -} - -struct DisruptorPrecisionCopySite { - uint32_t pc; - uint32_t instruction; - uint8_t gpr; -}; - -static const DisruptorPrecisionCopySite kDisruptorPrecisionCopyLoads[] = { - {0x80046C4Cu, 0x8D0C0004u, 12u}, - {0x80046C50u, 0x8D2D0004u, 13u}, - {0x80046C54u, 0x8D4E0004u, 14u}, - {0x80046C60u, 0x8D6F0004u, 15u}, - {0x80047A80u, 0x8CA80084u, 8u}, - {0x80047A84u, 0x8CA900A4u, 9u}, - {0x80047A88u, 0x8CAA00C4u, 10u}, - {0x80047A8Cu, 0x8CAB00BCu, 11u}, - {0x80047B40u, 0x8CA800A4u, 8u}, - {0x80047B44u, 0x8CA9008Cu, 9u}, - {0x80047B48u, 0x8CAA00ACu, 10u}, - {0x80047B4Cu, 0x8CAB00C4u, 11u}, - {0x80047C00u, 0x8CA800C4u, 8u}, - {0x80047C04u, 0x8CA900ACu, 9u}, - {0x80047C08u, 0x8CAA0094u, 10u}, - {0x80047C0Cu, 0x8CAB00B4u, 11u}, - {0x80047CC4u, 0x8CA800BCu, 8u}, - {0x80047CC8u, 0x8CA900C4u, 9u}, - {0x80047CCCu, 0x8CAA00B4u, 10u}, - {0x80047CD0u, 0x8CAB009Cu, 11u}, - {0x80047D6Cu, 0x8CA80084u, 8u}, - {0x80047D70u, 0x8CA9008Cu, 9u}, - {0x80047D74u, 0x8CAA0094u, 10u}, - {0x80047D78u, 0x8CAB009Cu, 11u}, -}; - -static const DisruptorPrecisionCopySite kDisruptorPrecisionCopyStores[] = { - {0x80046D04u, 0xAF2C0008u, 12u}, - {0x80046D08u, 0xAF2D0014u, 13u}, - {0x80046D10u, 0xAF2E002Cu, 14u}, - {0x80046D0Cu, 0xAF2F0020u, 15u}, - {0x80047A90u, 0xAF280008u, 8u}, - {0x80047A94u, 0xAF290014u, 9u}, - {0x80047A9Cu, 0xAF2A002Cu, 10u}, - {0x80047A98u, 0xAF2B0020u, 11u}, - {0x80047B50u, 0xAF280008u, 8u}, - {0x80047B54u, 0xAF290014u, 9u}, - {0x80047B5Cu, 0xAF2A002Cu, 10u}, - {0x80047B58u, 0xAF2B0020u, 11u}, - {0x80047C10u, 0xAF280008u, 8u}, - {0x80047C14u, 0xAF290014u, 9u}, - {0x80047C1Cu, 0xAF2A002Cu, 10u}, - {0x80047C18u, 0xAF2B0020u, 11u}, - {0x80047CD4u, 0xAF280008u, 8u}, - {0x80047CD8u, 0xAF290014u, 9u}, - {0x80047CE0u, 0xAF2A002Cu, 10u}, - {0x80047CDCu, 0xAF2B0020u, 11u}, - {0x80047D7Cu, 0xAF280008u, 8u}, - {0x80047D80u, 0xAF290014u, 9u}, - {0x80047D88u, 0xAF2A002Cu, 10u}, - {0x80047D84u, 0xAF2B0020u, 11u}, -}; - -template -static const DisruptorPrecisionCopySite *disruptor_precision_copy_site( - const DisruptorPrecisionCopySite (&sites)[N], uint32_t address) { - for (const DisruptorPrecisionCopySite &site : sites) { - if (site.pc == address) return &site; - } - return nullptr; -} - -/* Disruptor has no architectural pitch state. The project implements its - * opt-in vertical camera at a deliberately finite set of resident instruction - * sites: CPU-projected screen-Y centres, proven vertical participation tests, - * the two projectile-construction convergence points, and the common renderer - * epilogue. Every complete instruction word is pinned so a different retail - * revision fails generation instead of silently modifying unrelated code. - * - * The generated callback is appended after the vanilla instruction. At zero - * pitch it is an exact no-op; the runtime callback decodes the pinned word to - * identify the destination register or the required post-instruction action. */ -struct DisruptorVerticalCameraSite { - uint32_t pc; - uint32_t instruction; -}; - -static const DisruptorVerticalCameraSite kDisruptorVerticalCameraSites[] = { - /* CPU world projection centres (ORI rt,zero,120). */ - {0x8003AD54u, 0x34020078u}, - {0x8003ADE4u, 0x34020078u}, - {0x8003B140u, 0x34020078u}, - {0x8003B1ECu, 0x34020078u}, - {0x8003B764u, 0x34020078u}, - {0x8003B990u, 0x34020078u}, - {0x8003BDA4u, 0x34020078u}, - {0x8003C4B4u, 0x34020078u}, - {0x8003C99Cu, 0x34020078u}, - {0x8003D07Cu, 0x34020078u}, - {0x8003D398u, 0x34030078u}, - {0x8003F6B0u, 0x34020078u}, - /* Proven camera-vertical object participation pairs. */ - {0x8003B900u, 0x0205102Au}, - {0x8003B90Cu, 0x0202102Au}, - {0x8003BD14u, 0x0206102Au}, - {0x8003BD20u, 0x0202102Au}, - {0x8003CDB8u, 0x0065102Au}, - {0x8003CDC4u, 0x0062102Au}, - /* Normal-weapon and psionic construction convergence loads. */ - {0x8002E4B8u, 0x92640022u}, - {0x8002EAF0u, 0x92430020u}, - /* Common renderer epilogue: restore the neutral projection horizon. */ - {0x8004279Cu, 0x8FBF016Cu}, -}; - -static const DisruptorVerticalCameraSite *disruptor_vertical_camera_site( - uint32_t address) { - for (const DisruptorVerticalCameraSite &site : - kDisruptorVerticalCameraSites) { - if (site.pc == address) return &site; - } - return nullptr; -} - -/* Disruptor's experimental far-rendering override is host-only: reviewed - * loads of the retail far/fade globals are substituted after the original LW, - * leaving guest RAM untouched. Additional reviewed portal, object-decision, - * and packet-boundary instructions feed read-only diagnostics through the same - * callback. The renderer entry starts a measurement span and the reviewed - * common epilogue closes it. Keep this callback independent from the - * vertical-camera dispatcher so either enhancement can be built and tested in - * isolation. */ -struct DisruptorFarRenderingSite { - uint32_t pc; - uint32_t instruction; -}; - -static const DisruptorFarRenderingSite kDisruptorFarRenderingSites[] = { - /* Portal traversal diagnostics: recursion depth, unique-room completion, - * and the three candidate-distance values before their branch tests. */ - {0x8003A0F8u, 0x93B300C0u}, - {0x8003A390u, 0xA082001Fu}, - {0x8003A92Cu, 0x00005812u}, - {0x8003A964u, 0x00681021u}, - {0x8003AA74u, 0x00AC1021u}, - /* Far-distance loads from $gp + 0x4A0. */ - {0x8003A914u, 0x8F8C04A0u}, - {0x8003B8C8u, 0x8F8304A0u}, - {0x8003BCE0u, 0x8F8204A0u}, - {0x8003C168u, 0x8F8304A0u}, - {0x8003CD64u, 0x8F8304A0u}, - /* Object distance-decision results. */ - {0x8003B8D8u, 0x0072182Au}, - {0x8003BCECu, 0x0053102Au}, - {0x8003C178u, 0x0077182Au}, - {0x8003CD70u, 0x0077182Au}, - /* Fade-start loads from $gp + 0x494. */ - {0x8003B2CCu, 0x8F820494u}, - {0x8003BA30u, 0x8F820494u}, - {0x8003BE40u, 0x8F820494u}, - {0x8003C628u, 0x8F820494u}, - {0x8003C878u, 0x8F820494u}, - {0x8003D28Cu, 0x8F820494u}, - {0x8003FB0Cu, 0x8F840494u}, - /* Billboard post-pass CLUT-row selection. */ - {0x80043184u, 0x8F820494u}, - /* Main-renderer visible-list and packet boundaries. */ - {0x800410E0u, 0x93830310u}, - {0x800410F8u, 0x8F820680u}, - {0x800411D8u, 0x8F8301D4u}, - /* Common renderer epilogue. */ - {0x8004279Cu, 0x8FBF016Cu}, -}; - -static const DisruptorFarRenderingSite *disruptor_far_rendering_site( - uint32_t address) { - for (const DisruptorFarRenderingSite &site : - kDisruptorFarRenderingSites) { - if (site.pc == address) return &site; - } - return nullptr; -} - -/* Disruptor's world actors and pickups are CPU-built POLY_FT4 billboards. - * Their projected centre goes through the reviewed classic-wide X squash, but - * their retail pixel width does not. These are five final packet writes plus - * the deferred base-actor submission seam; the host callback tags only a - * completed, shape-validated packet for proportion repair. */ -struct DisruptorBillboardAspectSite { - uint32_t pc; - uint32_t instruction; - uint8_t packet_gpr; -}; - -static const DisruptorBillboardAspectSite kDisruptorBillboardAspectSites[] = { - {0x8003BB88u, 0xA6030016u, 16u}, - {0x8003BFB0u, 0xA6030016u, 16u}, - {0x8003C848u, 0xA6030016u, 16u}, - {0x8003CAD4u, 0xA6020016u, 16u}, - {0x8003D488u, 0xA2060025u, 16u}, - {0x800433A0u, 0x02A02821u, 5u}, -}; - -static const DisruptorBillboardAspectSite *disruptor_billboard_aspect_site( - uint32_t address) { - for (const DisruptorBillboardAspectSite &site : - kDisruptorBillboardAspectSites) { - if (site.pc == address) return &site; - } - return nullptr; -} - -/* Disruptor's sky backdrop routine lays SPRT tiles over the opening it has - * already clamped to the 320-pixel frame. These four seams hand the host the - * left edge, the panorama scroll and the right edge before the tile loop reads - * them, so classic widescreen can ask for the columns a wider view needs. */ -struct DisruptorBackdropTileSite { - uint32_t pc; - uint32_t instruction; -}; - -static const DisruptorBackdropTileSite kDisruptorBackdropTileSites[] = { - {0x8003B328u, 0x8FB80040u}, - {0x8003B338u, 0x00621821u}, - {0x8003B348u, 0x8FB80028u}, - {0x8003B5A8u, 0x8F8205B8u}, - {0x8003B638u, 0x8FB80040u}, -}; - -static const DisruptorBackdropTileSite kDisruptorSpriteDepthSites[] = { - {0x8003B98Cu, 0xAFA20010u}, - {0x8003BDA0u, 0xAFA20010u}, - {0x8003C4B0u, 0xAFA20010u}, - {0x8003D078u, 0xAFA20010u}, - {0x8003D1B4u, 0xA4379EA0u}, -}; - -static const DisruptorBackdropTileSite *disruptor_sprite_depth_site( - uint32_t address) { - for (const DisruptorBackdropTileSite &site : kDisruptorSpriteDepthSites) { - if (site.pc == address) return &site; - } - return nullptr; -} - -static const DisruptorBackdropTileSite kDisruptorIntroSkipSites[] = { - {0x800147B0u, 0x2A820003u}, - {0x80020A80u, 0x2A02005Eu}, - {0x80048678u, 0x30420940u}, -}; - -static const DisruptorBackdropTileSite *disruptor_intro_skip_site( - uint32_t address) { - for (const DisruptorBackdropTileSite &site : kDisruptorIntroSkipSites) { - if (site.pc == address) return &site; - } - return nullptr; -} - -static const DisruptorBackdropTileSite *disruptor_backdrop_tile_site( - uint32_t address) { - for (const DisruptorBackdropTileSite &site : kDisruptorBackdropTileSites) { - if (site.pc == address) return &site; - } - return nullptr; -} - -static bool codegen_cycle_per_insn() { - // DEFAULT ON for the faithful-timing (cycle-audit) branch: each instruction - // charges its cost at its own site, so the running cycle count is correct - // mid-block. This is REQUIRED for stateful timing — mult/div completion-stall - // (mflo/mfhi wait for muldiv_ts_done) and later GTE stalls — to absorb - // correctly, exactly like Beetle's per-instruction timestamp. Block-up-front - // charging loses the in-between timing and can only charge full latency. - // Set PSX_CODEGEN_CYCLE_PER_INSN=0 to force the old block-up-front mode. - const char* e = std::getenv("PSX_CODEGEN_CYCLE_PER_INSN"); - if (e != nullptr && e[0] != '\0') return e[0] != '0'; - return true; -} - -/* The BIOS address model of the profile this game is built against - * (main_psx.cpp resolves [recompiler] bios_config, default the SCPH1001 - * profile, and sets it before generation). A game artifact depends on a - * BIOS property here — jump tables inside relocated BIOS code windows — - * so the window comes from the profile, never from a constant. */ -static const ::PSXRecompV4::BiosAddressModel* g_game_bios_model = nullptr; - -void CodeGenerator::set_bios_address_model(const PSXRecompV4::BiosAddressModel* m) { - g_game_bios_model = m; -} - -/* - * Translate a runtime RAM address to an address that exe_.read_word() can - * resolve. - * - * Game EXEs are read directly at their load address. The BIOS copies code - * into RAM at boot (SCPH1001: the shell to 0x80030000); jump tables within - * such code store RAM-space target addresses, but at recompile time we can - * only read the ROM. This helper maps: - * - * active EXE load range -> active EXE load range - * BIOS-copy RAM windows -> ROM kseg1 (per the BIOS profile's model) - * - * Other addresses pass through unchanged. - */ -static uint32_t ram_to_rom(uint32_t addr, const PS1Executable& exe) { - uint32_t phys = addr & 0x1FFFFFFFu; - - /* - * Game EXEs can legitimately occupy the BIOS-copy RAM windows (the - * shell window especially), so prefer the active EXE's load range - * before applying any BIOS remap. - */ - uint32_t exe_phys = exe.load_address() & 0x1FFFFFFFu; - uint32_t exe_size = exe.code_size(); - if (exe_size != 0 && phys >= exe_phys && phys < exe_phys + exe_size) { - return exe.load_address() + (phys - exe_phys); - } - - if (!g_game_bios_model) { - throw std::runtime_error( - "CodeGenerator: no BIOS address model set " - "(CodeGenerator::set_bios_address_model must run before generation)"); - } - return g_game_bios_model->ram_alias_to_rom(addr); -} - -CodeGenerator::CodeGenerator(const PS1Executable& exe, const CodeGenConfig& config) - : exe_(exe), config_(config) { - // RECURSION_BUG.md §25 — continuation-passing call/return. Gen-time opt-in - // via PSX_CPS so legacy codegen stays byte-identical when unset. - // CPS is the DEFAULT (RECURSION_BUG.md §25). Opt out (legacy) with PSX_CPS=0. - { const char* e = std::getenv("PSX_CPS"); cps_enabled_ = (e == nullptr || e[0] != '0'); } -} - -uint32_t CodeGenerator::partial_block_cycle_count(uint32_t addr, - const ControlFlowGraph& cfg) const { - if (cfg.blocks.count(addr)) { - return 0; - } - - for (const auto& [block_addr, block] : cfg.blocks) { - (void)block_addr; - if (addr <= block.start_addr || addr > block.end_addr) { - continue; - } - if (((addr - block.start_addr) & 3u) != 0) { - continue; - } - return ((block.end_addr - addr) / 4u) + 1u; - } - - return 0; -} - -std::string CodeGenerator::emit_mid_block_cycle_charge(uint32_t addr, - const ControlFlowGraph& cfg, - const std::string& indent) const { - if (codegen_cycle_per_insn()) { - return ""; - } - - uint32_t cycles = partial_block_cycle_count(addr, cfg); - if (cycles == 0) { - return ""; - } - - std::stringstream ss; - ss << "#ifdef PSX_ENABLE_BLOCK_CYCLES\n"; - ss << indent << fmt::format("psx_advance_cycles({}u);\n", cycles); - ss << "#endif\n"; - return ss.str(); -} - -std::string CodeGenerator::emit_interrupt_check(uint32_t resume_pc, - const std::string& indent) const { - return std::string("#ifdef PSX_ENABLE_BLOCK_CYCLES\n") + indent + - "psx_cyc_bb_defer_flush();\n#endif\n" + indent + - fmt::format("psx_check_interrupts_at(cpu, 0x{:08X}u);\n", resume_pc); -} - -std::string CodeGenerator::emit_interrupt_check_expr(const std::string& resume_pc_expr, - const std::string& indent) const { - return std::string("#ifdef PSX_ENABLE_BLOCK_CYCLES\n") + indent + - "psx_cyc_bb_defer_flush();\n#endif\n" + indent + - fmt::format("psx_check_interrupts_at(cpu, {});\n", resume_pc_expr); -} - -std::string CodeGenerator::reg_name(int reg_num) { - if (reg_num >= 0 && reg_num < 32) { - return fmt::format("cpu->gpr[{}]", reg_num); - } - return fmt::format("cpu->gpr[{}]", reg_num); -} - -std::string CodeGenerator::translate_addiu(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t imm = get_imm16(instr); - - // Optimize: loading immediate into register (from $zero) - if (rs == 0) { - return fmt::format("{} = {};", reg_name(rt), imm); - } - - // Optimize: don't assign to $zero - if (config_.optimize_zero_reg && rt == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = {} + {};", reg_name(rt), reg_name(rs), imm); -} - -std::string CodeGenerator::translate_lui(uint32_t instr) { - uint32_t rt = get_rt(instr); - uint16_t imm = get_imm16_u(instr); - - if (config_.optimize_zero_reg && rt == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = 0x{:04X} << 16; /* 0x{:08X} */", - reg_name(rt), imm, ((uint32_t)imm) << 16); -} - -std::string CodeGenerator::translate_lw(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - std::string addr = (offset == 0) ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), offset); - uint32_t mask = 1u << rs; /* GPR_DEP rs (load: dest rt armed via LDWhich) */ - - if (config_.optimize_zero_reg && rt == 0) { - /* load to $zero: no GPR write, but the data access + R3000A interlock still run */ - return fmt::format("(void)psx_cyc_load_word(cpu, {}, 0, 0x{:X}u);", addr, mask); - } - return fmt::format("{} = psx_cyc_load_word(cpu, {}, {}, 0x{:X}u);", - reg_name(rt), addr, rt, mask); -} - -std::string CodeGenerator::translate_sw(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - - if (offset == 0) { - return fmt::format("psx_store_cycle_barrier(); cpu->write_word({}, {});", reg_name(rs), reg_name(rt)); - } else { - return fmt::format("psx_store_cycle_barrier(); cpu->write_word({} + {}, {});", - reg_name(rs), offset, reg_name(rt)); - } -} - -std::string CodeGenerator::translate_or(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - // Optimize: move instruction (or $rd, $rs, $zero) - if (rt == 0) { - return fmt::format("{} = {}; /* move */", reg_name(rd), reg_name(rs)); - } - if (rs == 0) { - return fmt::format("{} = {}; /* move */", reg_name(rd), reg_name(rt)); - } - - return fmt::format("{} = {} | {};", reg_name(rd), reg_name(rs), reg_name(rt)); -} - -std::string CodeGenerator::translate_and(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = {} & {};", reg_name(rd), reg_name(rs), reg_name(rt)); -} - -std::string CodeGenerator::translate_sll(uint32_t instr) { - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - uint32_t shamt = get_shamt(instr); - - // nop instruction (sll $zero, $zero, 0) - if (rd == 0 && rt == 0 && shamt == 0) { - return "/* nop */"; - } - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - if (shamt == 0) { - return fmt::format("{} = {}; /* no shift */", reg_name(rd), reg_name(rt)); - } - - return fmt::format("{} = {} << {};", reg_name(rd), reg_name(rt), shamt); -} - -std::string CodeGenerator::translate_srl(uint32_t instr) { - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - uint32_t shamt = get_shamt(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = (uint32_t){} >> {};", reg_name(rd), reg_name(rt), shamt); -} - -std::string CodeGenerator::translate_slt(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = ((int32_t){} < (int32_t){}) ? 1 : 0;", - reg_name(rd), reg_name(rs), reg_name(rt)); -} - -std::string CodeGenerator::translate_sltu(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = ({} < {}) ? 1 : 0;", - reg_name(rd), reg_name(rs), reg_name(rt)); -} - -std::string CodeGenerator::translate_addu(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - // Optimize: move instruction (addu $rd, $rs, $zero or addu $rd, $zero, $rt) - if (rt == 0) { - return fmt::format("{} = {}; /* move */", reg_name(rd), reg_name(rs)); - } - if (rs == 0) { - return fmt::format("{} = {}; /* move */", reg_name(rd), reg_name(rt)); - } - - return fmt::format("{} = {} + {};", reg_name(rd), reg_name(rs), reg_name(rt)); -} - -std::string CodeGenerator::translate_add(uint32_t instr) { - // add and addu are functionally identical in practice (trap on overflow not used) - return translate_addu(instr); -} - -std::string CodeGenerator::translate_subu(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = {} - {};", reg_name(rd), reg_name(rs), reg_name(rt)); -} - -std::string CodeGenerator::translate_sub(uint32_t instr) { - // sub and subu are functionally identical in practice (trap on overflow not used) - return translate_subu(instr); -} - -std::string CodeGenerator::translate_lb(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - std::string addr = (offset == 0) ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), offset); - uint32_t mask = 1u << rs; - - if (config_.optimize_zero_reg && rt == 0) { - return fmt::format("(void)psx_cyc_load_byte(cpu, {}, 0, 0x{:X}u);", addr, mask); - } - return fmt::format("{} = (int32_t)(int8_t)psx_cyc_load_byte(cpu, {}, {}, 0x{:X}u);", - reg_name(rt), addr, rt, mask); -} - -std::string CodeGenerator::translate_lbu(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - std::string addr = (offset == 0) ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), offset); - uint32_t mask = 1u << rs; - - if (config_.optimize_zero_reg && rt == 0) { - return fmt::format("(void)psx_cyc_load_byte(cpu, {}, 0, 0x{:X}u);", addr, mask); - } - return fmt::format("{} = psx_cyc_load_byte(cpu, {}, {}, 0x{:X}u);", - reg_name(rt), addr, rt, mask); -} - -std::string CodeGenerator::translate_lh(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - std::string addr = (offset == 0) ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), offset); - uint32_t mask = 1u << rs; - - if (config_.optimize_zero_reg && rt == 0) { - return fmt::format("(void)psx_cyc_load_half(cpu, {}, 0, 0x{:X}u);", addr, mask); - } - return fmt::format("{} = (int32_t)(int16_t)psx_cyc_load_half(cpu, {}, {}, 0x{:X}u);", - reg_name(rt), addr, rt, mask); -} - -std::string CodeGenerator::translate_lhu(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - std::string addr = (offset == 0) ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), offset); - uint32_t mask = 1u << rs; - - if (config_.optimize_zero_reg && rt == 0) { - return fmt::format("(void)psx_cyc_load_half(cpu, {}, 0, 0x{:X}u);", addr, mask); - } - return fmt::format("{} = psx_cyc_load_half(cpu, {}, {}, 0x{:X}u);", - reg_name(rt), addr, rt, mask); -} - -std::string CodeGenerator::translate_lwl(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - - if (config_.optimize_zero_reg && rt == 0) { - return "/* nop: load to $zero */"; - } - - // LWL: Load Word Left - merges high bytes from the aligned word into rt. - // psx_lwl runs the full R3000A load interlock on the aligned address (GPR_DEP rs, - // arm LDWhich=rt) and returns the merged value. - std::string addr = (offset == 0) ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), (int32_t)offset); - uint32_t mask = 1u << rs; - if (config_.optimize_zero_reg && rt == 0) { - return fmt::format("(void)psx_lwl(cpu, {}, {}, 0, 0x{:X}u);", addr, reg_name(rt), mask); - } - return fmt::format("{} = psx_lwl(cpu, {}, {}, {}, 0x{:X}u);", - reg_name(rt), addr, reg_name(rt), rt, mask); -} - -std::string CodeGenerator::translate_lwr(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - - if (config_.optimize_zero_reg && rt == 0) { - return "/* nop: load to $zero */"; - } - - // LWR: Load Word Right - merges low bytes from the aligned word into rt. - std::string addr = (offset == 0) ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), (int32_t)offset); - uint32_t mask = 1u << rs; - if (config_.optimize_zero_reg && rt == 0) { - return fmt::format("(void)psx_lwr(cpu, {}, {}, 0, 0x{:X}u);", addr, reg_name(rt), mask); - } - return fmt::format("{} = psx_lwr(cpu, {}, {}, {}, 0x{:X}u);", - reg_name(rt), addr, reg_name(rt), rt, mask); -} - -std::string CodeGenerator::translate_swl(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - - // SWL: Store Word Left - stores rt into memory using addr, MSB side - // Generate a helper call: psx_swl(cpu, addr, cpu->rt) - if (offset == 0) { - return fmt::format("psx_swl(cpu, {}, {});", reg_name(rs), reg_name(rt)); - } else { - return fmt::format("psx_swl(cpu, {} + {}, {});", - reg_name(rs), (int32_t)offset, reg_name(rt)); - } -} - -std::string CodeGenerator::translate_swr(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - - // SWR: Store Word Right - stores rt into memory using addr, LSB side - // Generate a helper call: psx_swr(cpu, addr, cpu->rt) - if (offset == 0) { - return fmt::format("psx_swr(cpu, {}, {});", reg_name(rs), reg_name(rt)); - } else { - return fmt::format("psx_swr(cpu, {} + {}, {});", - reg_name(rs), (int32_t)offset, reg_name(rt)); - } -} - -std::string CodeGenerator::translate_sb(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - - if (offset == 0) { - return fmt::format("psx_store_cycle_barrier(); cpu->write_byte({}, (uint8_t){});", reg_name(rs), reg_name(rt)); - } else { - return fmt::format("psx_store_cycle_barrier(); cpu->write_byte({} + {}, (uint8_t){});", - reg_name(rs), offset, reg_name(rt)); - } -} - -std::string CodeGenerator::translate_sh(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - - if (offset == 0) { - return fmt::format("psx_store_cycle_barrier(); cpu->write_half({}, (uint16_t){});", reg_name(rs), reg_name(rt)); - } else { - return fmt::format("psx_store_cycle_barrier(); cpu->write_half({} + {}, (uint16_t){});", - reg_name(rs), offset, reg_name(rt)); - } -} - -std::string CodeGenerator::translate_sra(uint32_t instr) { - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - uint32_t shamt = get_shamt(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = (int32_t){} >> {};", reg_name(rd), reg_name(rt), shamt); -} - -std::string CodeGenerator::translate_andi(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint16_t imm = get_imm16_u(instr); - - if (config_.optimize_zero_reg && rt == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = {} & 0x{:04X};", reg_name(rt), reg_name(rs), imm); -} - -std::string CodeGenerator::translate_ori(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint16_t imm = get_imm16_u(instr); - - if (config_.optimize_zero_reg && rt == 0) { - return "/* nop: write to $zero */"; - } - - // Optimize: loading immediate into register (from $zero) - if (rs == 0) { - return fmt::format("{} = 0x{:04X};", reg_name(rt), imm); - } - - return fmt::format("{} = {} | 0x{:04X};", reg_name(rt), reg_name(rs), imm); -} - -std::string CodeGenerator::translate_xori(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint16_t imm = get_imm16_u(instr); - - if (config_.optimize_zero_reg && rt == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = {} ^ 0x{:04X};", reg_name(rt), reg_name(rs), imm); -} - -std::string CodeGenerator::translate_xor(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = {} ^ {};", reg_name(rd), reg_name(rs), reg_name(rt)); -} - -std::string CodeGenerator::translate_nor(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - // Optimize: not instruction (nor $rd, $rs, $zero) - if (rt == 0) { - return fmt::format("{} = ~{}; /* not */", reg_name(rd), reg_name(rs)); - } - if (rs == 0) { - return fmt::format("{} = ~{}; /* not */", reg_name(rd), reg_name(rt)); - } - - return fmt::format("{} = ~({} | {});", reg_name(rd), reg_name(rs), reg_name(rt)); -} - -std::string CodeGenerator::translate_sllv(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = {} << ({} & 0x1F);", reg_name(rd), reg_name(rt), reg_name(rs)); -} - -std::string CodeGenerator::translate_srlv(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = (uint32_t){} >> ({} & 0x1F);", reg_name(rd), reg_name(rt), reg_name(rs)); -} - -std::string CodeGenerator::translate_srav(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = (int32_t){} >> ({} & 0x1F);", reg_name(rd), reg_name(rt), reg_name(rs)); -} - -std::string CodeGenerator::translate_slti(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t imm = get_imm16(instr); - - if (config_.optimize_zero_reg && rt == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = ((int32_t){} < {}) ? 1 : 0;", - reg_name(rt), reg_name(rs), imm); -} - -std::string CodeGenerator::translate_sltiu(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t imm = get_imm16(instr); // Sign-extended for comparison - - if (config_.optimize_zero_reg && rt == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = ({} < (uint32_t){}) ? 1 : 0;", - reg_name(rt), reg_name(rs), imm); -} - -std::string CodeGenerator::translate_mult(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - - return fmt::format("{{ int64_t result = (int64_t)(int32_t){} * (int64_t)(int32_t){}; cpu->lo = (uint32_t)result; cpu->hi = (uint32_t)(result >> 32); }}" - "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_muldiv_set(cpu, psx_mult_latency_s({}));\n#endif", - reg_name(rs), reg_name(rt), reg_name(rs)); -} - -std::string CodeGenerator::translate_multu(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - - return fmt::format("{{ uint64_t result = (uint64_t){} * (uint64_t){}; cpu->lo = (uint32_t)result; cpu->hi = (uint32_t)(result >> 32); }}" - "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_muldiv_set(cpu, psx_mult_latency_u({}));\n#endif", - reg_name(rs), reg_name(rt), reg_name(rs)); -} - -std::string CodeGenerator::translate_div(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - - // MIPS division: quotient in LO, remainder in HI - // Handle division by zero (MIPS behavior: result is unpredictable but doesn't trap) - return fmt::format("if ({} != 0) {{ cpu->lo = (uint32_t)((int32_t){} / (int32_t){}); cpu->hi = (uint32_t)((int32_t){} % (int32_t){}); }} else {{ cpu->lo = ((int32_t){} >= 0) ? 0xFFFFFFFF : 1; cpu->hi = (uint32_t){}; }}" - "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_muldiv_set(cpu, 37u);\n#endif", - reg_name(rt), reg_name(rs), reg_name(rt), reg_name(rs), reg_name(rt), reg_name(rs), reg_name(rs)); -} - -std::string CodeGenerator::translate_divu(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - - // MIPS division: quotient in LO, remainder in HI - // Handle division by zero (MIPS behavior: result is unpredictable but doesn't trap) - return fmt::format("if ({} != 0) {{ cpu->lo = {} / {}; cpu->hi = {} % {}; }} else {{ cpu->lo = 0xFFFFFFFF; cpu->hi = {}; }}" - "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_muldiv_set(cpu, 37u);\n#endif", - reg_name(rt), reg_name(rs), reg_name(rt), reg_name(rs), reg_name(rt), reg_name(rs)); -} - -std::string CodeGenerator::translate_mfhi(uint32_t instr) { - uint32_t rd = get_rd(instr); - - // Stall until the mult/div completion deadline (faithful R3000A) — happens - // even when rd==$zero (the read still stalls on HW). - const char* stall = "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_muldiv_stall(cpu);\n#endif"; - if (config_.optimize_zero_reg && rd == 0) { - return fmt::format("/* nop: write to $zero */{}", stall); - } - - return fmt::format("{} = cpu->hi;{}", reg_name(rd), stall); -} - -std::string CodeGenerator::translate_mflo(uint32_t instr) { - uint32_t rd = get_rd(instr); - - const char* stall = "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_muldiv_stall(cpu);\n#endif"; - if (config_.optimize_zero_reg && rd == 0) { - return fmt::format("/* nop: write to $zero */{}", stall); - } - - return fmt::format("{} = cpu->lo;{}", reg_name(rd), stall); -} - -std::string CodeGenerator::translate_mthi(uint32_t instr) { - uint32_t rs = get_rs(instr); - - return fmt::format("cpu->hi = {};", reg_name(rs)); -} - -std::string CodeGenerator::translate_mtlo(uint32_t instr) { - uint32_t rs = get_rs(instr); - - return fmt::format("cpu->lo = {};", reg_name(rs)); -} - -std::string CodeGenerator::generate_branch_condition(uint32_t instr, uint32_t addr) { - uint32_t opcode = (instr >> 26) & 0x3F; - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - - // Explicit manual-screen lower-edge branches. Disruptor-class portal and - // wall renderers project in CPU code and use zero-bound branches rather - // than the usual SLTI/BLTZ funnel. Move that zero edge to -x_margin while - // retaining the original branch sense. At margin 0 these expressions are - // exactly the vanilla predicates. - if (config_.ws_cull_lower_branch_sites.count(addr)) { - if (opcode == 0x01) { - const uint32_t regimm_op = (instr >> 16) & 0x1Fu; - if (regimm_op == 0x00u) - return fmt::format("(int32_t){} < -psx_ws_x_margin()" - " /* ws manual lower edge */", reg_name(rs)); - if (regimm_op == 0x01u) - return fmt::format("(int32_t){} >= -psx_ws_x_margin()" - " /* ws manual lower edge */", reg_name(rs)); - } - if (opcode == 0x06u || opcode == 0x16u) - return fmt::format("(int32_t){} <= -psx_ws_x_margin()" - " /* ws manual lower edge */", reg_name(rs)); - if (opcode == 0x07u || opcode == 0x17u) - return fmt::format("(int32_t){} > -psx_ws_x_margin()" - " /* ws manual lower edge */", reg_name(rs)); - if (!config_.overlay_mode) { - fmt::print(stderr, - "ERROR: [widescreen.cull] lower_branch site 0x{:08X} " - "is not bltz/bgez/blez/bgtz (0x{:08X})\n", - addr, instr); - std::exit(1); - } - } - - // REGIMM branches. R3000A hardware decodes EVERY rt value here, not just - // the four assembler mnemonics: the branch sense is rt bit 0 (0 = bltz, - // 1 = bgez) and the link register is written iff (rt & 0x1E) == 0x10 — - // Beetle cpu.cpp op_BCOND: result = (int32)(rs ^ (rt<<31)) < 0, - // link = ((rt & 0x1E) == 0x10). Undefined rt values appear when discovery - // sweeps data-as-code into a function; emitting the hardware decode keeps - // the regen alive AND matches the oracle if the word is ever executed. - if (opcode == 0x01) { - uint32_t regimm_op = (instr >> 16) & 0x1F; - if ((regimm_op & 0x01u) == 0x00u) { // bltz family (incl. bltzal + undefined mirrors) - // LEFT-edge funnel bltz: reject only past the revealed margin. - // Identity at 4:3 (margin 0). Two sources: (a) auto_screen_x's - // detect_cull_bltz_sites classification (ws_cull_bltz_pcs_), and - // (b) explicit [widescreen.cull] bltz_sites — the left-edge - // counterpart to slti_sites, for X-only funnels auto_screen_x can't - // qualify (whose bltz would otherwise never be widened). - if (regimm_op == 0x00 && - (ws_cull_bltz_pcs_.count(addr) || config_.ws_cull_bltz_sites.count(addr))) - return fmt::format("psx_ws_cull_bltz({}) /* ws cull (left edge) */", - reg_name(rs)); - return fmt::format("(int32_t){} < 0", reg_name(rs)); - } else { // bgez family (incl. bgezal + undefined mirrors) - return fmt::format("(int32_t){} >= 0", reg_name(rs)); - } - } - - // Standard branches - switch (opcode) { - case 0x04: // beq - case 0x14: // beql - return fmt::format("{} == {}", reg_name(rs), reg_name(rt)); - - case 0x05: // bne - case 0x15: // bnel - return fmt::format("{} != {}", reg_name(rs), reg_name(rt)); - - case 0x06: // blez - case 0x16: // blezl - return fmt::format("(int32_t){} <= 0", reg_name(rs)); - - case 0x07: // bgtz - case 0x17: // bgtzl - return fmt::format("(int32_t){} > 0", reg_name(rs)); - } - - return "0 /* unknown branch condition: defaults to not-taken */"; -} - -std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) { - uint32_t opcode = (instr >> 26) & 0x3F; - uint32_t funct = instr & 0x3F; - - // Instruction comment (if enabled) - std::string comment; - if (config_.emit_comments) { - comment = fmt::format(" /* 0x{:08X}: 0x{:08X} */", addr, instr); - } - - std::string code; - - const DisruptorVerticalCameraSite *vertical_camera_site = - disruptor_vertical_camera_site(addr); - if (vertical_camera_site && - instr != vertical_camera_site->instruction) { - if (!config_.overlay_mode) { - throw std::runtime_error(fmt::format( - "Disruptor vertical-camera site 0x{:08X} expected word " - "0x{:08X}, found 0x{:08X}", - addr, vertical_camera_site->instruction, instr)); - } - vertical_camera_site = nullptr; - } - - const DisruptorFarRenderingSite *far_rendering_site = - disruptor_far_rendering_site(addr); - if (far_rendering_site && instr != far_rendering_site->instruction) { - if (!config_.overlay_mode) { - throw std::runtime_error(fmt::format( - "Disruptor far-rendering site 0x{:08X} expected word " - "0x{:08X}, found 0x{:08X}", - addr, far_rendering_site->instruction, instr)); - } - far_rendering_site = nullptr; - } - - const DisruptorBillboardAspectSite *billboard_aspect_site = - disruptor_billboard_aspect_site(addr); - if (billboard_aspect_site && - instr != billboard_aspect_site->instruction) { - if (!config_.overlay_mode) { - throw std::runtime_error(fmt::format( - "Disruptor billboard-aspect site 0x{:08X} expected word " - "0x{:08X}, found 0x{:08X}", - addr, billboard_aspect_site->instruction, instr)); - } - billboard_aspect_site = nullptr; - } - - const DisruptorBackdropTileSite *backdrop_tile_site = - disruptor_backdrop_tile_site(addr); - if (backdrop_tile_site && instr != backdrop_tile_site->instruction) { - if (!config_.overlay_mode) { - throw std::runtime_error(fmt::format( - "Disruptor backdrop-tile site 0x{:08X} expected word " - "0x{:08X}, found 0x{:08X}", - addr, backdrop_tile_site->instruction, instr)); - } - backdrop_tile_site = nullptr; - } - const DisruptorBackdropTileSite *sprite_depth_site = - disruptor_sprite_depth_site(addr); - if (sprite_depth_site && instr != sprite_depth_site->instruction) { - if (!config_.overlay_mode) { - throw std::runtime_error(fmt::format( - "Disruptor sprite-depth site 0x{:08X} expected word " - "0x{:08X}, found 0x{:08X}", - addr, sprite_depth_site->instruction, instr)); - } - sprite_depth_site = nullptr; - } - const DisruptorBackdropTileSite *intro_skip_site = - disruptor_intro_skip_site(addr); - if (intro_skip_site && instr != intro_skip_site->instruction) { - if (!config_.overlay_mode) { - throw std::runtime_error(fmt::format( - "Disruptor intro-skip site 0x{:08X} expected word " - "0x{:08X}, found 0x{:08X}", - addr, intro_skip_site->instruction, instr)); - } - intro_skip_site = nullptr; - } - - // Full-word-guarded terrain-frustum half-angle constants. Scaling the - // tangent is the geometric counterpart of widening the horizontal - // projection; adding screen pixels directly to 12-bit angle units is not. - for (const auto& site : config_.ws_cull_angle_sites) { - if ((site.address & 0x1FFFFFFFu) != - (addr & 0x1FFFFFFFu)) continue; - if (instr != site.expected) { - if (config_.overlay_mode) continue; - fmt::print(stderr, - "ERROR: cull angle expected 0x{:08X} at 0x{:08X}, " - "found 0x{:08X}\n", - site.expected, addr, instr); - std::exit(1); - } - if ((opcode != 0x08u && opcode != 0x09u) || - get_rs(instr) != 0u) { - fmt::print(stderr, - "ERROR: cull angle site 0x{:08X} is not " - "ADDI/ADDIU rt,zero,imm (0x{:08X})\n", - addr, instr); - std::exit(1); - } - const uint32_t dst = get_rt(instr); - const int32_t vanilla = (int16_t)(instr & 0xFFFFu); - return fmt::format( - "{} = psx_ws_angle_widen({}u);" - " /* aspect-scaled terrain frustum half-angle */{}", - reg_name(dst), (uint32_t)vanilla, comment); - } - - // Full-word-guarded wrapping-yaw half-FOV offsets. Disruptor, for example, - // seeds portal traversal with yaw +/-32 in an eight-bit turn. Widen the - // signed offset geometrically while leaving the game's base yaw and wrap - // behavior untouched. - for (const auto& site : config_.ws_cull_yaw_cone_sites) { - if ((site.address & 0x1FFFFFFFu) != - (addr & 0x1FFFFFFFu)) continue; - if (instr != site.expected) { - if (config_.overlay_mode) continue; - fmt::print(stderr, - "ERROR: yaw cone expected 0x{:08X} at 0x{:08X}, " - "found 0x{:08X}\n", - site.expected, addr, instr); - std::exit(1); - } - if ((opcode != 0x08u && opcode != 0x09u) || - get_rs(instr) == 0u || site.full_turn < 16u) { - fmt::print(stderr, - "ERROR: yaw cone site 0x{:08X} is not a valid " - "ADDI/ADDIU rt,rs,offset (0x{:08X})\n", - addr, instr); - std::exit(1); - } - const uint32_t dst = get_rt(instr); - const uint32_t src = get_rs(instr); - const int32_t vanilla = (int16_t)(instr & 0xFFFFu); - return fmt::format( - "{} = {} + (uint32_t)psx_ws_yaw_cone_offset({}, {}u);" - " /* aspect-scaled wrapping yaw half-FOV */{}", - reg_name(dst), reg_name(src), vanilla, site.full_turn, comment); - } - - // Symmetric camera-horizontal side-plane rejects. Scaling the horizontal - // operand is the exact counterpart of widening the projection; vertical - // tests in the same functions remain completely vanilla. - for (const auto& site : config_.ws_cull_horizontal_slt_sites) { - if ((site.address & 0x1FFFFFFFu) != - (addr & 0x1FFFFFFFu)) continue; - if (instr != site.expected) { - if (config_.overlay_mode) continue; - fmt::print(stderr, - "ERROR: horizontal SLT expected 0x{:08X} at " - "0x{:08X}, found 0x{:08X}\n", - site.expected, addr, instr); - std::exit(1); - } - if (opcode != 0u || funct != 0x2Au || - ((instr >> 6) & 31u) != 0u) { - fmt::print(stderr, - "ERROR: horizontal SLT site 0x{:08X} is not signed " - "register-register SLT (0x{:08X})\n", - addr, instr); - std::exit(1); - } - return fmt::format( - "{} = psx_ws_horizontal_slt({}, {});" - " /* aspect-scaled camera-horizontal side plane */{}", - reg_name(get_rd(instr)), reg_name(get_rs(instr)), - reg_name(get_rt(instr)), comment); - } - - for (const auto& site : config_.ws_signed_x_bound_sites) { - if ((site.address & 0x1FFFFFFFu) != (addr & 0x1FFFFFFFu)) continue; - if (instr != site.expected) { - if (config_.overlay_mode) continue; - fmt::print(stderr, - "ERROR: signed_x_bound expected 0x{:08X} at 0x{:08X}, found 0x{:08X}\n", - site.expected, addr, instr); - std::exit(1); - } - const int32_t vanilla = (int32_t)((instr & 0xFFFFu) << 16); - return fmt::format("{} = (uint32_t)psx_ws_player_x_bound((int32_t)0x{:08X});" - " /* typed native-wide signed X bound */{}", - reg_name(get_rt(instr)), (uint32_t)vanilla, comment); - } - - // Full-word-guarded, camera-horizontal model-participation cones. The - // runtime helper preserves the original compare at 4:3 and every vanilla - // keep verdict, adding only the aspect-derived horizontal fringe. - for (const auto& site : config_.ws_aspect_cone.sites) { - if ((site.address & 0x1FFFFFFFu) != (addr & 0x1FFFFFFFu)) continue; - if (instr != site.expected) { - if (config_.overlay_mode) continue; - fmt::print(stderr, - "ERROR: aspect cone expected 0x{:08X} at 0x{:08X}, " - "found 0x{:08X}\n", - site.expected, addr, instr); - std::exit(1); - } - const bool is_slti = opcode == 0x0A; - const bool is_slt = - opcode == 0x00 && (instr & 0x3Fu) == 0x2Au && - ((instr >> 6) & 0x1Fu) == 0u; - if (!is_slti && !is_slt) { - fmt::print(stderr, - "ERROR: aspect cone site 0x{:08X} is not signed " - "SLTI/SLT " - "(0x{:08X})\n", addr, instr); - std::exit(1); - } - const uint32_t dst = is_slti ? get_rt(instr) : get_rd(instr); - const std::string vanilla = is_slti - ? fmt::format("((int32_t){} < (int32_t){}) ? 1u : 0u", - reg_name(get_rs(instr)), - (int32_t)(int16_t)(instr & 0xFFFFu)) - : fmt::format("((int32_t){} < (int32_t){}) ? 1u : 0u", - reg_name(get_rs(instr)), reg_name(get_rt(instr))); - const auto effective_reg = [](uint32_t site_reg, - uint32_t default_reg) { - return site_reg == 0xFFFFFFFFu ? default_reg : site_reg; - }; - return fmt::format( - "{} = psx_ws_aspect_cone_result(0x{:08X}u, ({}), {}, " - "(int32_t)(int16_t){}, (int32_t)(int16_t){}, " - "(int32_t)(int16_t){});" - " /* aspect-aware horizontal model participation */{}", - reg_name(dst), addr, vanilla, - reg_name(effective_reg( - site.object_reg, config_.ws_aspect_cone.object_reg)), - reg_name(effective_reg( - site.x_reg, config_.ws_aspect_cone.x_reg)), - reg_name(effective_reg( - site.z_reg, config_.ws_aspect_cone.z_reg)), - reg_name(effective_reg( - site.y_reg, config_.ws_aspect_cone.y_reg)), comment); - } - - // Full-word-guarded object/model participation compares. At 4:3 the - // helper returns the comparison's vanilla value; when widescreen reveals - // extra world it returns the configured keep verdict. Overlay variants - // with another instruction at the same VA are deliberately untouched. - for (const auto& site : config_.ws_cull_keep_sites) { - if ((site.address & 0x1FFFFFFFu) != (addr & 0x1FFFFFFFu)) continue; - if (instr != site.expected) { - if (config_.overlay_mode) continue; - fmt::print(stderr, - "ERROR: cull keep expected 0x{:08X} at 0x{:08X}, found 0x{:08X}\n", - site.expected, addr, instr); - std::exit(1); - } - uint32_t dst = 0; - std::string vanilla; - if (opcode == 0x0A) { // SLTI - dst = get_rt(instr); - vanilla = fmt::format("((int32_t){} < {}) ? 1u : 0u", - reg_name(get_rs(instr)), (int)get_imm16(instr)); - } else if (opcode == 0x0B) { // SLTIU (sign-extended immediate) - dst = get_rt(instr); - vanilla = fmt::format("({} < (uint32_t){}) ? 1u : 0u", - reg_name(get_rs(instr)), (int)get_imm16(instr)); - } else if (opcode == 0x00 && funct == 0x2A) { // SLT - dst = get_rd(instr); - vanilla = fmt::format("((int32_t){} < (int32_t){}) ? 1u : 0u", - reg_name(get_rs(instr)), reg_name(get_rt(instr))); - } else if (opcode == 0x00 && funct == 0x2B) { // SLTU - dst = get_rd(instr); - vanilla = fmt::format("({} < {}) ? 1u : 0u", - reg_name(get_rs(instr)), reg_name(get_rt(instr))); - } else { - fmt::print(stderr, - "ERROR: cull keep site 0x{:08X} is not a compare (0x{:08X})\n", - addr, instr); - std::exit(1); - } - return fmt::format( - "{} = psx_ws_cull_keep_result(({}), {}u);" - " /* ws maximal object/model participation */{}", - reg_name(dst), vanilla, site.result, comment); - } - - // Widescreen automatic far-backdrop column PRELOAD ([widescreen.cull] - // auto_backdrop). At a detected window's START/END finalize, route the value - // through psx_ws_backdrop_value(orig, is_end): identity at 4:3, but in - // native-wide it forces START->0 / END->0x100, so the generator's own low/ - // high clamps preload the whole finite tile row (filling the revealed 16:9 - // margin). ws_backdrop_sites_ is populated per function by - // detect_backdrop_windows() only when config_.ws_auto_backdrop_preload is - // set — inert and byte-identical when the feature is off. The instruction - // shape is verified; a mismatch is a loud build error (detector/codegen drift). - if (!ws_backdrop_sites_.empty()) { - auto bd = ws_backdrop_sites_.find(addr); - if (bd != ws_backdrop_sites_.end()) { - int is_end = (bd->second.first == WS_BD_END) ? 1 : 0; - int wcols = bd->second.second; - const char* tag = is_end ? "END" : "START"; - if (opcode == 0x00 && (funct == 0x21 || funct == 0x25)) { // addu/or (move) - uint32_t rd = get_rd(instr), rs = get_rs(instr), rt = get_rt(instr); - uint32_t src = (rt == 0) ? rs : rt; - return fmt::format("{} = psx_ws_backdrop_value({}, {}, {});" - " /* ws backdrop preload {} */{}", - reg_name(rd), reg_name(src), is_end, wcols, tag, comment); - } - if (opcode == 0x09 || opcode == 0x08) { // addiu / addi - uint32_t rs = get_rs(instr), rt = get_rt(instr); - int16_t imm = get_imm16(instr); - std::string orig = (rs == 0) - ? fmt::format("(uint32_t){}", (int)imm) - : fmt::format("(uint32_t)({} + {})", reg_name(rs), (int)imm); - return fmt::format("{} = psx_ws_backdrop_value({}, {}, {});" - " /* ws backdrop preload {} */{}", - reg_name(rt), orig, is_end, wcols, tag, comment); - } - fmt::print(stderr, "ERROR: [widescreen.cull] auto_backdrop site 0x{:08X} " - "is not addu/or/addiu (opcode 0x{:02X} funct 0x{:02X})\n", - addr, opcode, funct); - std::exit(1); - } - } - - // Widescreen cull-margin widening ([widescreen.cull] sites). Emit the - // window immediate with a runtime margin term psx_ws_x_margin() — 0 at - // 4:3/boot/menu/FMV, ~half-the-extra-width when stretching — so the world- - // space draw cull tracks the aspect and one build serves both. Each site's - // instruction type is verified; a mismatch is a loud build error in main-EXE - // mode (a bad address would silently mis-emit otherwise). In OVERLAY mode the - // same address holds different code across scene variants, so a mismatch is - // expected — apply the transform only where the bytes match, else fall - // through to the vanilla translation (see CodeGenConfig::overlay_mode). - // Manual screen-window bound materialization. These are deliberately - // exact-site and instruction-shape guarded: a generic 320 constant can be - // a centre-relative divider, texture width, or packed span, and those - // meanings are not interchangeable. - const bool ws_lower_value = config_.ws_cull_lower_value_sites.count(addr); - const bool ws_upper_value = config_.ws_cull_upper_value_sites.count(addr); - if (ws_lower_value || ws_upper_value) { - const char* helper = ws_lower_value - ? "psx_ws_cull_lower_value" : "psx_ws_cull_upper_value"; - const char* tag = ws_lower_value ? "lower" : "upper"; - if ((opcode == 0x08u || opcode == 0x09u || opcode == 0x0Du) && - get_rs(instr) == 0u) { - const uint32_t dst = get_rt(instr); - const int32_t vanilla = opcode == 0x0Du - ? (int32_t)get_imm16_u(instr) : (int32_t)get_imm16(instr); - return fmt::format( - "{} = (uint32_t){}({});" - " /* ws manual {} bound value */{}", - reg_name(dst), helper, vanilla, tag, comment); - } - if (ws_lower_value && opcode == 0x00u && - (funct == 0x21u || funct == 0x25u) && - get_rs(instr) == 0u && get_rt(instr) == 0u) { - return fmt::format( - "{} = (uint32_t)psx_ws_cull_lower_value(0);" - " /* ws manual lower clamp */{}", - reg_name(get_rd(instr)), comment); - } - if (ws_lower_value && opcode == 0x2Bu && get_rt(instr) == 0u) { - const uint32_t base = get_rs(instr); - const int16_t offset = get_imm16(instr); - const std::string store_addr = offset == 0 - ? reg_name(base) - : fmt::format("{} + {}", reg_name(base), (int)offset); - return fmt::format( - "g_debug_last_store_pc = 0x{:08X}u; " - "psx_store_cycle_barrier(); cpu->write_word({}, " - "(uint32_t)psx_ws_cull_lower_value(0));" - " /* ws manual lower clamp store */{}", - addr, store_addr, comment); - } - if (!config_.overlay_mode) { - fmt::print(stderr, - "ERROR: [widescreen.cull] {}_value site 0x{:08X} " - "is not a supported constant/clamp form (0x{:08X})\n", - tag, addr, instr); - std::exit(1); - } - } - if (config_.ws_cull_span_value_sites.count(addr)) { - if ((opcode == 0x08u || opcode == 0x09u || opcode == 0x0Du) && - get_rs(instr) == 0u) { - const uint32_t dst = get_rt(instr); - const uint32_t vanilla = opcode == 0x0Du - ? (uint32_t)get_imm16_u(instr) - : (uint32_t)(int32_t)get_imm16(instr); - return fmt::format( - "{} = psx_ws_cull_span_value(0x{:08X}u);" - " /* ws manual packed [left,right] span */{}", - reg_name(dst), vanilla, comment); - } - if (!config_.overlay_mode) { - fmt::print(stderr, - "ERROR: [widescreen.cull] span_value site 0x{:08X} " - "is not ADDI/ADDIU/ORI rt,zero,imm (0x{:08X})\n", - addr, instr); - std::exit(1); - } - } - - if (config_.ws_cull_bias_sites.count(addr)) { - if (opcode == 0x08 || opcode == 0x09) { // addi / addiu - uint32_t rs = get_rs(instr), rt = get_rt(instr); - int16_t imm = get_imm16(instr); - const std::string margin = - config_.ws_cull_activation_guard_pixels > 0 - ? fmt::format( - "(psx_ws_x_margin() > 0 ? psx_ws_x_margin() + {} : 0)", - config_.ws_cull_activation_guard_pixels) - : "psx_ws_x_margin()"; - return fmt::format("{} = {} + ((int32_t){} + {});{}", - reg_name(rt), reg_name(rs), imm, margin, - comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.cull] bias site 0x{:08X} is not " - "addi/addiu (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - // overlay variant: addr is different code here — fall through to vanilla. - } - if (config_.ws_cull_vxrange_sites.count(addr)) { - if (opcode == 0x0B) { // sltiu - uint32_t rs = get_rs(instr), rt = get_rt(instr); - uint16_t imm = get_imm16_u(instr); - return fmt::format("{} = psx_ws_cull_vxrange({}, {});" - " /* ws cull masked-u16 X window */{}", - reg_name(rt), reg_name(rs), (unsigned)imm, comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.cull] vxrange site 0x{:08X} is not " - "sltiu (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - // Overlay variant at the same address: leave nonmatching code unchanged. - } - if (config_.ws_cull_depth_sites.count(addr)) { - if (opcode == 0x0A) { // slti - uint32_t rs = get_rs(instr), rt = get_rt(instr); - int16_t imm = get_imm16(instr); - return fmt::format("{} = ((int32_t){} < psx_ws_depth_bound({})) ? 1 : 0;" - " /* ws cull depth */{}", - reg_name(rt), reg_name(rs), (int)imm, comment); - } - if (opcode == 0x0B) { // sltiu: MIPS sign-extends its immediate - uint32_t rs = get_rs(instr), rt = get_rt(instr); - int16_t imm = get_imm16(instr); - return fmt::format("{} = ((uint32_t){} < (uint32_t)psx_ws_depth_bound({})) ? 1 : 0;" - " /* ws cull depth unsigned */{}", - reg_name(rt), reg_name(rs), (int)imm, comment); - } - if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.cull] depth site 0x{:08X} is not " - "slti/sltiu (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - // Overlay variant at the same address: leave nonmatching code unchanged. - } - // Side frustum-plane normal-X load: `lw rt,off(rs)` reads the nx of a - // plane sign-tested per corner (dot = nx*px + nz*pz). The helper scales - // nx by the inverse aspect factor (4*den)/(3*num) while revealed, which - // widens the cone by exactly atan((3*num)/(4*den)*tan(theta)); identity - // at 4:3. - if (config_.ws_cull_plane_nx_sites.count(addr)) { - if (opcode == 0x23) { // lw - uint32_t rs = get_rs(instr), rt = get_rt(instr); - int16_t offset = get_imm16(instr); - std::string laddr = (offset == 0) - ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), (int)offset); - uint32_t mask = 1u << rs; - return fmt::format("{} = (uint32_t)psx_ws_plane_nx((int32_t)psx_cyc_load_word(cpu, {}, {}, 0x{:X}u));" - " /* ws cull plane nx load */{}", - reg_name(rt), laddr, rt, mask, comment); - } - if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.cull] plane_nx site 0x{:08X} is not " - "lw (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - // Overlay variant at the same address: leave nonmatching code unchanged. - } - - // Per-primitive X-reject bound load: `lw rt,off(rs)` reads the bound a - // masked-u16 screen X is sltu-compared against. While the margins are - // revealed the helper yields INT32_MAX (reject disabled; the wide-surface - // scissor clips the overflow); identity at 4:3. - if (config_.ws_cull_xclip_load_sites.count(addr)) { - if (opcode == 0x23) { // lw - uint32_t rs = get_rs(instr), rt = get_rt(instr); - int16_t offset = get_imm16(instr); - std::string laddr = (offset == 0) - ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), (int)offset); - uint32_t mask = 1u << rs; - return fmt::format("{} = psx_ws_xclip_bound(psx_cyc_load_word(cpu, {}, {}, 0x{:X}u));" - " /* ws cull xclip bound load */{}", - reg_name(rt), laddr, rt, mask, comment); - } - if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.cull] xclip_load site 0x{:08X} is not " - "lw (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - // Overlay variant at the same address: leave nonmatching code unchanged. - } - if (config_.ws_cull_range_sites.count(addr)) { - if (opcode == 0x0B) { // sltiu - uint32_t rs = get_rs(instr), rt = get_rt(instr); - int16_t imm = get_imm16(instr); - const std::string margin = - config_.ws_cull_activation_guard_pixels > 0 - ? fmt::format( - "(psx_ws_x_margin() > 0 ? psx_ws_x_margin() + {} : 0)", - config_.ws_cull_activation_guard_pixels) - : "psx_ws_x_margin()"; - return fmt::format( - "{} = ({} < (uint32_t)((int32_t){} + 2*{})) ? 1 : 0;{}", - reg_name(rt), reg_name(rs), imm, margin, comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.cull] range site 0x{:08X} is not " - "sltiu (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - // overlay variant: addr is different code here — fall through to vanilla. - } - if (config_.ws_cull_a1_sites.count(addr)) { - if (instr == 0x00000000u) { // must be a nop we can safely repurpose - // a1 = $5: widen the caller-supplied margin (param-margin classifiers). - return fmt::format("cpu->gpr[5] = cpu->gpr[5] + psx_ws_x_margin();" - " /* ws cull a1 bias */{}", comment); - } else if (opcode == 0x00 && get_funct(instr) == 0x21) { - // move rD,a1 (addu rD,a1,zero or addu rD,zero,a1): some Capcom - // classifiers copy the caller-supplied horizontal margin - // immediately, leaving no delay-slot nop to repurpose. Widen the - // copied value; a1 itself and the vertical margin stay unchanged. - uint32_t rs = get_rs(instr), rt = get_rt(instr), rd = get_rd(instr); - if ((rs == 5 && rt == 0) || (rs == 0 && rt == 5)) - return fmt::format("{} = cpu->gpr[5] + psx_ws_x_margin();" - " /* ws cull copied a1 bias */{}", - reg_name(rd), comment); - if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.cull] a1 site 0x{:08X} " - "is addu but not move rD,a1 (0x{:08X})\n", addr, instr); - std::exit(1); - } - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.cull] a1 site 0x{:08X} is not a " - "nop or move rD,a1 (0x{:08X})\n", addr, instr); - std::exit(1); - } - // overlay variant: addr is different code here — fall through to vanilla. - } - if (config_.ws_cull_screen_x_sites.count(addr)) { - if (opcode == 0x0B) { // sltiu rt,rs,imm - uint32_t rs = get_rs(instr), rt = get_rt(instr); - uint16_t imm = get_imm16_u(instr); - return fmt::format("{} = psx_ws_cull_sltiu({}, {});" - " /* ws explicit screen-x cull */{}", - reg_name(rt), reg_name(rs), imm, comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.cull] screen_x site 0x{:08X} " - "is not sltiu (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - // Overlay variant: the configured main-EXE PC holds different code. - } - // Widescreen automatic horizontal-FOV cull widening ([widescreen.cull] - // auto_screen_x). ws_auto_cull_func_ is set when this function carries the - // GTE screen-extent reject signature, so a per-vertex width compare here is - // `sltiu rt, SX, 0x140` (or inclusive 0x141). Emit it widened by - // +2*psx_ws_x_margin() (0 at 4:3 ⇒ byte-identical; = the wide-surface extra - // when 16:9), so geometry out to the wide frame edge is submitted rather - // than culled at the 320 boundary. Same shape as an explicit range_site but - // applied by signature — no per-address list. (Reached only when the addr is - // not already an explicit cull site, which returns above.) - if (ws_auto_cull_func_ && (opcode == 0x0B || opcode == 0x0A)) { // sltiu / slti - uint16_t uimm = get_imm16_u(instr); - if (psx_ws_cull_imm_in(uimm, config_.ws_cull_w_imms.data(), - (int)config_.ws_cull_w_imms.size())) { - uint32_t rs = get_rs(instr), rt = get_rt(instr); - if (opcode == 0x0B) { - // Route through the shared runtime helper psx_ws_cull_sltiu so the - // gcc emit and the interpreter both widen identically. - // It sign-extends SX and shifts by +margin (wide window - // -margin <= SX < imm+margin, both edges); at margin=0 it reduces - // bit-for-bit to the vanilla `SX write_half({}, (uint16_t)psx_ws_backdrop_x((int16_t){}));{}", - reg_name(rs), reg_name(rt), comment); - return store_pc + fmt::format("psx_store_cycle_barrier(); cpu->write_half({} + {}, (uint16_t)psx_ws_backdrop_x((int16_t){}));{}", - reg_name(rs), offset, reg_name(rt), comment); - } else if (opcode == 0x09) { // addiu rt,rs,screen-centre - uint32_t rs = get_rs(instr), rt = get_rt(instr); - int16_t imm = get_imm16(instr); - return fmt::format( - "{} = (uint32_t)psx_ws_project_x((int32_t)({} + (uint32_t){}));" - " /* ws CPU-projected screen-x */{}", - reg_name(rt), reg_name(rs), (int32_t)imm, comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.backdrop] x site 0x{:08X} is not " - "sh or addiu (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - // overlay variant: addr is different code here — fall through to vanilla. - } - - // Widescreen pure-2D background tile-loop widen ([widescreen.bg2d]). The - // per-layer BG renderer draws `count` 16px tile columns from a start column / - // start screen-x derived from the camera scroll. Rewrite those three values - // (via the gpu.c psx_ws_bg2d_* helpers — identity at 4:3 / 512 hi-res) so - // the loop draws the 16:9 reveal columns on both sides. Each site's opcode is - // verified; a mismatch is a loud build error (main-EXE addresses). - if (config_.ws_bg2d_count_site && addr == config_.ws_bg2d_count_site) { - if (opcode == 0x09 || opcode == 0x0D) { // addiu / ori (li rt,imm) - uint32_t rt = get_rt(instr); - uint16_t imm = get_imm16_u(instr); - return fmt::format("{} = (uint32_t)psx_ws_bg2d_cols({});{}", - reg_name(rt), (int)imm, comment); - } else if (opcode == 0x0A || opcode == 0x0B) { // slti/sltiu rt,rs,imm (loop bound compare) - uint32_t rs = get_rs(instr), rt = get_rt(instr); - uint16_t imm = get_imm16_u(instr); - if (opcode == 0x0A) // signed variant - return fmt::format("{} = ((int32_t){} < psx_ws_bg2d_cols({})) ? 1u : 0u;{}", - reg_name(rt), reg_name(rs), (int)imm, comment); - return fmt::format("{} = ({} < (uint32_t)psx_ws_bg2d_cols({})) ? 1u : 0u;{}", - reg_name(rt), reg_name(rs), (int)imm, comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.bg2d] count_site 0x{:08X} is not " - "addiu/ori/slti/sltiu (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - } - if (config_.ws_bg2d_startcol_site && addr == config_.ws_bg2d_startcol_site) { - if (opcode == 0x0C) { // andi rt,rs,imm (start tile-column mask) - uint32_t rs = get_rs(instr), rt = get_rt(instr); - uint16_t imm = get_imm16_u(instr); - return fmt::format("{} = (uint32_t)psx_ws_bg2d_startcol((int)({} & 0x{:X}u), 0x{:X}u);{}", - reg_name(rt), reg_name(rs), imm, imm, comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.bg2d] startcol_site 0x{:08X} is not " - "andi (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - } - if (config_.ws_bg2d_startx_site && addr == config_.ws_bg2d_startx_site) { - if (opcode == 0x00 && (instr & 0x3F) == 0x03) { // sra rd,rt,sa (start screen-x) - uint32_t rt = get_rt(instr), rd = get_rd(instr), sh = get_shamt(instr); - return fmt::format("{} = (uint32_t)psx_ws_bg2d_startx((int32_t){} >> {});{}", - reg_name(rd), reg_name(rt), sh, comment); - } else if (opcode == 0x00 && - (instr & 0x3F) == 0x23 && get_rs(instr) == 0) { - // subu rd,zero,rt (MMX4's start screen-x = -(scrollX & 15)) - uint32_t rt = get_rt(instr), rd = get_rd(instr); - return fmt::format("{} = (uint32_t)psx_ws_bg2d_startx(-(int32_t){});{}", - reg_name(rd), reg_name(rt), comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.bg2d] startx_site 0x{:08X} is not " - "sra or subu-zero (instr 0x{:08X})\n", addr, instr); - std::exit(1); - } - } - // Tile-ring streamer leading-edge widen: two addiu computing the left - // (scrollX-16) and right (scrollX+16) stream world-X; push them out by - // LEFT*16 so the ring is populated across the widened column window. - if (config_.ws_bg2d_stream_left_site && addr == config_.ws_bg2d_stream_left_site) { - if (opcode == 0x08 || opcode == 0x09) { // addi / addiu - uint32_t rs = get_rs(instr), rt = get_rt(instr); - int16_t imm = get_imm16(instr); - return fmt::format("{} = (uint32_t)psx_ws_bg2d_stream_left((int32_t){} + ({}));{}", - reg_name(rt), reg_name(rs), imm, comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.bg2d] stream_left_site 0x{:08X} is not " - "addi/addiu (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - } - if (config_.ws_bg2d_stream_right_site && addr == config_.ws_bg2d_stream_right_site) { - if (opcode == 0x08 || opcode == 0x09) { // addi / addiu - uint32_t rs = get_rs(instr), rt = get_rt(instr); - int16_t imm = get_imm16(instr); - return fmt::format("{} = (uint32_t)psx_ws_bg2d_stream_right((int32_t){} + ({}));{}", - reg_name(rt), reg_name(rs), imm, comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.bg2d] stream_right_site 0x{:08X} is not " - "addi/addiu (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - } - // BG packet-buffer RELOCATION (cure for the 1024-slot/1000-cap overflow that the - // widen causes in dense stages). bufbase_site = the driver addu computing the buffer - // address (base + bufidx*0x4000); cap_site = the renderer's per-frame tile-cap slti. - // Helpers relocate to a larger free buffer + raise the cap when the widen is active, - // identity otherwise (4:3 / other games byte-identical). - if (config_.ws_bg2d_bufbase_site && addr == config_.ws_bg2d_bufbase_site) { - if (opcode == 0x00 && (instr & 0x3F) == 0x21) { // addu rd,rs,rt (BG buffer address) - uint32_t rs = get_rs(instr), rt = get_rt(instr), rd = get_rd(instr); - return fmt::format("{} = (uint32_t)psx_ws_mmx6_bg_bufbase((int)({} + {}));{}", - reg_name(rd), reg_name(rs), reg_name(rt), comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.bg2d] bufbase_site 0x{:08X} is not " - "addu (instr 0x{:08X})\n", addr, instr); - std::exit(1); - } - } - if (config_.ws_bg2d_cap_site && addr == config_.ws_bg2d_cap_site) { - if (opcode == 0x0A) { // slti rt,rs,imm (BG per-frame tile cap compare) - uint32_t rs = get_rs(instr), rt = get_rt(instr); - return fmt::format("{} = (uint32_t)psx_ws_bg2d_undercap((int32_t){}, {});{}", - reg_name(rt), reg_name(rs), (int16_t)(instr & 0xFFFFu), comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.bg2d] cap_site 0x{:08X} is not " - "slti (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - } - - // Persistent game-option init store ([persist_options] in game_options.toml). - // The site is the boot-init sb/sh that writes a config global's DEFAULT value - // (Tomba MESSAGE / SOUND / VIBRATION / ADJUST SCREEN). Route the stored value - // through psx_game_option_store(addr, val): it returns a value persisted from a - // prior session for that address, else `val` unchanged — so the saved setting - // overrides the default exactly once, at initialization, and a fresh install - // (no saved file) is byte-identical. The in-OPTION write is a different - // instruction (option overlay), untouched, so the player can still change it. - // Verified sb/sh; a mismatch is a loud build error. - if (config_.persist_init_store_sites.count(addr)) { - uint32_t rs = get_rs(instr), rt = get_rt(instr); - int16_t offset = get_imm16(instr); - std::string aexpr = (offset != 0) - ? fmt::format("({} + {})", reg_name(rs), offset) - : std::string(reg_name(rs)); - std::string store_pc = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr); - if (opcode == 0x28) // sb - return store_pc + fmt::format("psx_store_cycle_barrier(); cpu->write_byte({0}, (uint8_t)psx_game_option_store({0}, (int){1}));{2}", - aexpr, reg_name(rt), comment); - if (opcode == 0x29) // sh - return store_pc + fmt::format("psx_store_cycle_barrier(); cpu->write_half({0}, (uint16_t)psx_game_option_store({0}, (int){1}));{2}", - aexpr, reg_name(rt), comment); - fmt::print(stderr, "ERROR: [persist_options] init site 0x{:08X} is not sb/sh " - "(opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - - // SPECIAL opcode (0x00) - if (opcode == 0x00) { - switch (funct) { - case 0x00: code = translate_sll(instr); break; // sll - case 0x02: code = translate_srl(instr); break; // srl - case 0x03: code = translate_sra(instr); break; // sra - case 0x04: code = translate_sllv(instr); break; // sllv - case 0x06: code = translate_srlv(instr); break; // srlv - case 0x07: code = translate_srav(instr); break; // srav - case 0x08: code = "return; /* jr $ra */"; break; // jr (assume return) - case 0x09: // jalr - { - uint32_t rs = get_rs(instr); - uint32_t rd = get_rd(instr); - code = fmt::format("{{ uint32_t _jt_{0:08X} = {1}; {2} = 0x{3:08X}; " - "cpu->pc = _jt_{0:08X}; }} /* jalr */", - addr, reg_name(rs), reg_name(rd), addr + 8); - } - break; - case 0x0C: // syscall - { - uint32_t syscall_code = (instr >> 6) & 0xFFFFF; - code = fmt::format("psx_syscall(cpu, {}); /* syscall {} */", syscall_code, syscall_code); - } - break; - case 0x0D: // break - { - uint32_t break_code = (instr >> 6) & 0xFFFFF; - code = fmt::format("/* break({}) — trap, no-op in recompiler */", break_code); - } - break; - case 0x10: code = translate_mfhi(instr); break; // mfhi - case 0x11: code = translate_mthi(instr); break; // mthi - case 0x12: code = translate_mflo(instr); break; // mflo - case 0x13: code = translate_mtlo(instr); break; // mtlo - case 0x18: code = translate_mult(instr); break; // mult - case 0x19: code = translate_multu(instr); break; // multu - case 0x1A: code = translate_div(instr); break; // div - case 0x1B: code = translate_divu(instr); break; // divu - case 0x20: code = translate_add(instr); break; // add - case 0x21: code = translate_addu(instr); break; // addu - case 0x22: code = translate_sub(instr); break; // sub - case 0x23: code = translate_subu(instr); break; // subu - case 0x24: code = translate_and(instr); break; // and - case 0x25: code = translate_or(instr); break; // or - case 0x26: code = translate_xor(instr); break; // xor - case 0x27: code = translate_nor(instr); break; // nor - case 0x2A: code = translate_slt(instr); break; // slt - case 0x2B: code = translate_sltu(instr); break; // sltu - default: - code = fmt::format("/* TODO: SPECIAL funct=0x{:02X} */", funct); - } - } - // Immediate and load/store instructions - else { - switch (opcode) { - case 0x08: code = translate_addiu(instr); break; // addi (same as addiu) - case 0x09: code = translate_addiu(instr); break; // addiu - case 0x0A: code = translate_slti(instr); break; // slti - case 0x0B: code = translate_sltiu(instr); break; // sltiu - case 0x0C: code = translate_andi(instr); break; // andi - case 0x0D: code = translate_ori(instr); break; // ori - case 0x0E: code = translate_xori(instr); break; // xori - case 0x0F: code = translate_lui(instr); break; // lui - case 0x10: // COP0 - { - uint32_t cop_op = (instr >> 21) & 0x1F; - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - if (cop_op == 0x00) { // MFC0 - move from COP0 - // MFC0 is a delayed load (Beetle: LDAbsorb=0, LDWhich=rt) — no - // give-back cycles, but it sets ReadFudge=rt so a load in the next - // slot gets no fudge. §1+DO_LDS ran in the block's psx_cyc_step. - code = fmt::format( - "{} = cpu->cop0[{}];" - "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n cpu->ld_absorb = 0u; cpu->ld_which_t = {}u;\n#endif" - " /* mfc0 (delayed load) */", - reg_name(rt), rd, rt); - } else if (cop_op == 0x04) { // MTC0 - move to COP0 - code = fmt::format("cpu->cop0[{}] = {}; /* mtc0 */", rd, reg_name(rt)); - } else if (cop_op == 0x10 && (instr & 0x3F) == 0x10) { // RFE - // Restore interrupt enable bits: shift bits 5:2 → 3:0. - // Fix B: arm the host exception-return escape if this RFE runs - // inside the synchronous exception handler (see psx_runtime.h). - code = "{ uint32_t sr = cpu->cop0[12]; " - "cpu->cop0[12] = (sr & 0xFFFFFFF0u) | ((sr >> 2) & 0x0Fu); } " - "psx_rfe_mark_escape(); /* rfe */"; - } else { - code = fmt::format("/* cop0: 0x{:08X} */", instr); - } - } - break; - case 0x12: // COP2 (GTE) - { - uint32_t cop_op = (instr >> 21) & 0x1F; - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - // Faithful GTE: any COP2 register access stalls to the pending - // command completion deadline (gte_execute arms it). Emitted - // before the access; cost-free unless an op is still in flight. - const char* gte_stall = - "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_gte_stall(cpu);\n#endif\n "; - // MFC2/CFC2 (GPR-dest reads): stall to the GTE deadline AND hand the - // stall amount to the next instruction(s) as a load-delay give-back - // (Beetle MFC2/CFC2: LDAbsorb=gte_ts_done-ts, LDWhich=rt). §1+DO_LDS - // ran in the block's psx_cyc_step (COP2 is non-load). - const std::string gte_read = fmt::format( - "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_gte_read(cpu, {});\n#endif\n ", rt); - if (cop_op == 0x00) { // MFC2 - move from COP2 data - std::string value; - if (PSXRecompGTERegisters::data_read_needs_helper(static_cast(rd))) { - value = fmt::format("gte_read_data(cpu, {})", rd); - } else { - value = fmt::format("cpu->gte_data[{}]", rd); - } - if (disruptor_precision_mfc2_pc(addr)) { - if (rt != 10u || rd != 14u || instr != 0x480A7000u) { - throw std::runtime_error(fmt::format( - "Disruptor precision MFC2 site 0x{:08X} no longer " - "matches MFC2 r10,SXY2 (word 0x{:08X})", - addr, instr)); - } - value = fmt::format( - "gte_precision_mfc2_pc_read(0x{:08X}u, " - "0x{:08X}u, {}, {})", - addr, instr, rd, value); - } - code = gte_read + fmt::format( - "{} = {}; /* mfc2 */", reg_name(rt), value); - } else if (cop_op == 0x02) { // CFC2 - move from COP2 control - if (PSXRecompGTERegisters::ctrl_read_needs_helper(static_cast(rd))) { - code = gte_read + fmt::format("{} = gte_read_ctrl(cpu, {}); /* cfc2 */", reg_name(rt), rd); - } else { - code = gte_read + fmt::format("{} = cpu->gte_ctrl[{}]; /* cfc2 */", reg_name(rt), rd); - } - } else if (cop_op == 0x04) { // MTC2 - move to COP2 data - if (PSXRecompGTERegisters::data_write_needs_helper(static_cast(rd))) { - code = gte_stall + fmt::format("gte_write_data(cpu, {}, {}); /* mtc2 */", rd, reg_name(rt)); - } else { - code = gte_stall + fmt::format("cpu->gte_data[{}] = {}; /* mtc2 */", rd, reg_name(rt)); - } - } else if (cop_op == 0x06) { // CTC2 - move to COP2 control - if (PSXRecompGTERegisters::ctrl_write_needs_helper(static_cast(rd))) { - code = gte_stall + fmt::format("gte_write_ctrl(cpu, {}, {}); /* ctc2 */", rd, reg_name(rt)); - } else { - code = gte_stall + fmt::format("cpu->gte_ctrl[{}] = {}; /* ctc2 */", rd, reg_name(rt)); - } - } else if ((cop_op & 0x10) != 0) { // GTE command (bit 25 set) - uint32_t gte_cmd = instr & 0x1FFFFFF; - // Route ALL GTE commands through gte_execute() for correct behavior - code = fmt::format("gte_execute(cpu, 0x{:07X}); /* gte cmd 0x{:02X} */", gte_cmd, gte_cmd & 0x3F); - } else { - code = fmt::format("/* cop2: 0x{:08X} */", instr); - } - } - break; - case 0x20: code = translate_lb(instr); break; // lb - case 0x21: code = translate_lh(instr); break; // lh - case 0x22: code = translate_lwl(instr); break; // lwl - case 0x23: { // lw - const DisruptorPrecisionCopySite *site = - disruptor_precision_copy_site( - kDisruptorPrecisionCopyLoads, addr); - if (!site) { - code = translate_lw(instr); - break; - } - const uint32_t rs = get_rs(instr); - const uint32_t rt = get_rt(instr); - const int16_t offset = get_imm16(instr); - if (instr != site->instruction || rt != site->gpr) { - throw std::runtime_error(fmt::format( - "Disruptor precision-copy LW site 0x{:08X} no longer " - "matches word 0x{:08X}/r{} (found 0x{:08X})", - addr, site->instruction, site->gpr, instr)); - } - const std::string load_addr = offset == 0 - ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), offset); - code = fmt::format( - "{{ uint32_t _precision_copy_addr = {}; " - "{} = psx_cyc_load_word(cpu, _precision_copy_addr, {}, " - "0x{:X}u); {} = gte_precision_copy_pc_read(" - "0x{:08X}u, 0x{:08X}u, {}, _precision_copy_addr, {}); }}", - load_addr, reg_name(rt), rt, 1u << rs, reg_name(rt), - addr, instr, rt, reg_name(rt)); - break; - } - case 0x24: code = translate_lbu(instr); break; // lbu - case 0x25: code = translate_lhu(instr); break; // lhu - case 0x26: code = translate_lwr(instr); break; // lwr - // Stores: stamp the EXACT executing store PC before the write so - // wtrace/readtrace producer attribution is correct for game code. - // (The BIOS emitter does the same in strict_translator.cpp; the game - // emitter previously left g_debug_last_store_pc holding a STALE pc — - // the last BIOS store — which mis-attributed game writes to BIOS.) - case 0x28: code = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr) + translate_sb(instr); break; // sb - case 0x29: code = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr) + translate_sh(instr); break; // sh - case 0x2A: code = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr) + translate_swl(instr); break; // swl - case 0x2B: { // sw - const DisruptorPrecisionCopySite *copy_site = - disruptor_precision_copy_site( - kDisruptorPrecisionCopyStores, addr); - if (copy_site) { - const uint32_t rs = get_rs(instr); - const uint32_t rt = get_rt(instr); - const int16_t offset = get_imm16(instr); - if (instr != copy_site->instruction || - rt != copy_site->gpr) { - throw std::runtime_error(fmt::format( - "Disruptor precision-copy SW site 0x{:08X} no " - "longer matches word 0x{:08X}/r{} (found " - "0x{:08X})", - addr, copy_site->instruction, - copy_site->gpr, instr)); - } - const std::string store_addr = offset == 0 - ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), offset); - code = fmt::format( - "g_debug_last_store_pc = 0x{:08X}u; " - "{{ psx_store_cycle_barrier(); " - "uint32_t _precision_copy_addr = {}; " - "uint32_t _precision_copy_value = {}; " - "cpu->write_word(_precision_copy_addr, " - "_precision_copy_value); " - "gte_precision_copy_pc_word(0x{:08X}u, " - "0x{:08X}u, {}, _precision_copy_addr, " - "_precision_copy_value); }}", - addr, store_addr, reg_name(rt), addr, instr, rt); - break; - } - code = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr) + - translate_sw(instr); - if (disruptor_precision_store_pc(addr) || - disruptor_precision_scratch_store_pc(addr)) { - const uint32_t rs = get_rs(instr); - const uint32_t rt = get_rt(instr); - const int16_t offset = get_imm16(instr); - if (rs != 5u || rt != 10u || offset != 4 || - instr != 0xACAA0004u) { - throw std::runtime_error(fmt::format( - "Disruptor precision SW site 0x{:08X} no longer " - "matches SW r10,4(r5) (word 0x{:08X})", - addr, instr)); - } - const std::string store_addr = fmt::format( - "{} + {}", reg_name(rs), offset); - if (disruptor_precision_scratch_store_pc(addr)) { - code += fmt::format( - " gte_precision_scratch_store_pc_word(" - "0x{:08X}u, 0x{:08X}u, {}, {});", - addr, instr, store_addr, reg_name(rt)); - } else { - code += fmt::format( - " gte_precision_store_pc_word(0x{:08X}u, " - "0x{:08X}u, {}, {});", - addr, instr, store_addr, reg_name(rt)); - } - } - break; - } - case 0x2E: code = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr) + translate_swr(instr); break; // swr - case 0x2F: // CACHE - cache op (no-op for static recomp) - code = "/* cache (no-op: static recompilation has no I/D cache) */"; - break; - case 0x32: // LWC2 - load word to COP2 - { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - // Faithful GTE: COP2 reg write stalls to the command deadline. - const char* gte_stall = - "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_gte_stall(cpu);\n#endif\n "; - // LWC2 load timing: §1+DO_LDS via the block's psx_cyc_step(cpu,0) - // (op 0x32 is non-load), the GTE deadline stall via psx_gte_stall, - // then psx_cyc_lwc2_read does the ReadMemory timing (completion +1, - // no LDWhich arm — the dest is a GTE register). - std::string addr = (offset == 0) - ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), offset); - bool special = PSXRecompGTERegisters::data_write_needs_helper(static_cast(rt)); - if (special) { - code = gte_stall + fmt::format("gte_write_data(cpu, {}, psx_cyc_lwc2_read(cpu, {})); /* lwc2 gte[{}] */", - rt, addr, rt); - } else { - code = gte_stall + fmt::format("cpu->gte_data[{}] = psx_cyc_lwc2_read(cpu, {}); /* lwc2 gte[{}], ({}) */", - rt, addr, rt, addr); - } - } - break; - case 0x3A: // SWC2 - store word from COP2 - { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - // Faithful GTE: COP2 reg read stalls to the command deadline. - const char* gte_stall = - "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_gte_stall(cpu);\n#endif\n "; - std::string value = PSXRecompGTERegisters::data_read_needs_helper(static_cast(rt)) - ? fmt::format("gte_read_data(cpu, {})", rt) - : fmt::format("cpu->gte_data[{}]", rt); - std::string swc2_store_pc = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr); - if (offset == 0) { - code = gte_stall + swc2_store_pc + fmt::format( - "psx_store_cycle_barrier(); cpu->write_word({}, {}); gte_precision_store_word({}, {}); /* swc2 gte[{}], ({}) */", - reg_name(rs), value, reg_name(rs), rt, rt, reg_name(rs)); - } else { - code = gte_stall + swc2_store_pc + fmt::format( - "psx_store_cycle_barrier(); cpu->write_word({} + {}, {}); gte_precision_store_word({} + {}, {}); /* swc2 gte[{}], {}({}) */", - reg_name(rs), offset, value, reg_name(rs), offset, rt, - rt, offset, reg_name(rs)); - } - } - break; - default: - code = fmt::format("/* TODO: opcode=0x{:02X} */", opcode); - } - } - - if (vertical_camera_site) { - code += fmt::format( - " disruptor_vertical_camera_instruction_hook(cpu, " - "0x{:08X}u, 0x{:08X}u, 1);", - addr, instr); - } - if (far_rendering_site) { - code += fmt::format( - "\n{}disruptor_far_rendering_instruction_hook(cpu, " - "0x{:08X}u, 0x{:08X}u, 1);", - config_.indent, addr, instr); - } - if (billboard_aspect_site) { - code += fmt::format( - "\n{}disruptor_billboard_aspect_instruction_hook(cpu, " - "0x{:08X}u, 0x{:08X}u, 1);", - config_.indent, addr, instr); - } - if (backdrop_tile_site) { - code += fmt::format( - "\n{}disruptor_backdrop_tiles_instruction_hook(cpu, " - "0x{:08X}u, 0x{:08X}u, 1);", - config_.indent, addr, instr); - } - if (sprite_depth_site) { - code += fmt::format( - "\n{}disruptor_sprite_depth_instruction_hook(cpu, " - "0x{:08X}u, 0x{:08X}u, 1);", - config_.indent, addr, instr); - } - if (intro_skip_site) { - code += fmt::format( - "\n{}disruptor_intro_skip_instruction_hook(cpu, " - "0x{:08X}u, 0x{:08X}u, 1);", - config_.indent, addr, instr); - } - - return config_.indent + code + comment; -} - -std::string CodeGenerator::translate_basic_block( - const BasicBlock& block, - const ControlFlowGraph& cfg) { - - std::stringstream ss; - - // Block label - ss << fmt::format("block_{:08X}:\n", block.start_addr); - - // Per-block-leader cycle observe (cyc_watch ruler — universal with the BIOS - // emitter). Sampled at the block leader BEFORE any cycle is charged, matching - // Beetle's before-instruction sample and the cycle_compare.py anchor - // semantics, so ANY block-leader PC is anchorable on both backends (not just - // function entries). Debug-only: prod (PSX_NO_DEBUG_TOOLS) emits nothing. - ss << "#ifndef PSX_NO_DEBUG_TOOLS\n"; - ss << config_.indent - << fmt::format("debug_server_cyc_observe(0x{:08X}u);\n", block.start_addr); - ss << "#endif\n"; - // First-divergence co-sim oracle (COSIM_ORACLE.md): lean per-block-leader hook, - // present only in the clean PSX_COSIM build (independent of the debug tools). - ss << "#ifdef PSX_COSIM\n"; - ss << config_.indent - << fmt::format("cosim_block(0x{:08X}u);\n", block.start_addr); - ss << "#endif\n"; - - // Cycle-budgeted precise event slicing (PRECISE_IRQ_SLICE.md). At the block - // leader — before any cycle is charged or the body runs — divert to the - // per-instruction interpreter when an interrupt could be taken inside this - // block, so the IRQ lands at its exact architectural instruction instead of - // the coarse block edge. psx_slice_block returns nonzero iff it sliced (it - // interpreted the block — and possibly more — and left cpu->pc at a - // dispatchable resume point), in which case this function returns so its - // compiled body does not re-execute the same instructions. - // side_effects=1 marks blocks that change interrupt visibility on the CPU - // (mtc0 Status/Cause, rfe), which can make a pending+masked IRQ deliverable - // mid-block with no external event deadline. - // Faithful cycle accounting (FAITHFUL_TIMING_PLAN.md P2): the exit branch/jump - // ALWAYS executes its delay-slot instruction (emitted as a clone before the - // transfer, below). When the delay slot lives INSIDE this block - // (block.exit_instr.address < end_addr) it is already part of - // instruction_count. But when the delay slot is a SEPARATE block leader (a - // branch target, or a split-function edge) the branch sits AT end_addr and the - // delay slot at end_addr+4 is outside [start,end]; instruction_count then - // EXCLUDES it, yet the clone still executes — so the block undercharges by one - // cycle vs the per-instruction interpreter on BOTH paths. (This is the measured - // -8 drift in the Tomba 2 logo init subtree: ~8 such sites on the boot path, - // native runs behind interp, the elapsed-Timer1 logo-delay loop exits early.) - // Charge the always-executed clone here so every dynamically executed - // instruction is charged exactly once. (R3000 is MIPS-I: no branch-likely, so - // the delay slot is unconditional.) cycle_per_insn mode already charges the - // clone per-instruction at its emit site, so this adjustment is block-mode only. - const bool exit_delay_slot_outside = - block.exit_instr.has_delay_slot && - block.exit_instr.type != ControlFlowType::None && - block.exit_instr.address == block.end_addr; - // Block cycle charge = sum of the single-source per-instruction cost - // (psx_instr_cost.h) over every instruction the block executes, INCLUDING - // the always-executed delay-slot clone when the exit branch sits at end_addr - // (delay slot outside [start,end]). The interpreter charges the same cost - // per instruction through the same function, so the two backends cannot - // disagree. Stage 1 cost is identity (1/insn) => this equals - // instruction_count(+1 outside delay) exactly => byte-identical regen; Stage 2 - // edits ONLY psx_instr_base_cycles and both backends update together. - uint32_t block_exec_cycles = 0; - for (uint32_t a = block.start_addr; a <= block.end_addr; a += 4) { - auto w = exe_.read_word(a); - if (!w.has_value()) break; - block_exec_cycles += psx_instr_base_cycles(*w); - } - if (exit_delay_slot_outside) { - auto dw = exe_.read_word(block.end_addr + 4u); // the delay-slot clone - if (dw.has_value()) block_exec_cycles += psx_instr_base_cycles(*dw); - } - - { - uint32_t slice_bcyc = block_exec_cycles; - if (slice_bcyc == 0) slice_bcyc = 1; - int slice_side_effects = 0; - for (uint32_t a = block.start_addr; a <= block.end_addr; a += 4) { - auto w = exe_.read_word(a); - if (!w.has_value()) break; - uint32_t insn = *w; - if ((insn >> 26) == 0x10u) { // COP0 - uint32_t rs = (insn >> 21) & 0x1Fu; - uint32_t co = (insn >> 25) & 0x1u; - uint32_t funct = insn & 0x3Fu; - if (rs == 0x04u) slice_side_effects = 1; // MTC0 (writes Status/Cause) - if (co && funct == 0x10u) slice_side_effects = 1; // RFE - } - } - ss << "#ifdef PSX_ENABLE_BLOCK_CYCLES\n"; - ss << config_.indent - << fmt::format("if (psx_slice_block(cpu, 0x{:08X}u, {}u, {})) return;\n", - block.start_addr, slice_bcyc, slice_side_effects); - ss << "#endif\n"; - } - - const bool cycle_per_insn = codegen_cycle_per_insn(); - // Per-instruction R3000A load-delay interlock (cycle_per_insn mode): §1 base + - // GPR_DEPRES + DO_LDS, emitted BEFORE the instruction body so §1 precedes any - // muldiv/GTE deadline stall the body applies (Beetle order). CPU loads (op - // 0x20-0x26) are SKIPPED here — psx_cyc_load_* runs their full interlock inside - // the body (and arms LDWhich=rt). The dep/res mask is a gen-time literal. This - // replaces the old flat per-instruction psx_advance_cycles(1u). - auto emit_pre_timing = [&](uint32_t in, const std::string& indent) { - uint32_t op = in >> 26; - if (op >= 0x20u && op <= 0x26u) return; // CPU load: interlock inside psx_cyc_load_* - ss << "#ifdef PSX_ENABLE_BLOCK_CYCLES\n"; - ss << indent << fmt::format("psx_cyc_step(cpu, 0x{:X}u);\n", - psx_cyc_dep_res_mask(in)); - ss << "#endif\n"; - }; - // I-cache FETCH cost (faithful R3000A), emitted BEFORE the per-instruction - // interlock/load — exactly like Beetle ReadInstruction precedes the base, and so a - // fetch MISS clears any pending load give-back before the next load arms one. Only - // emitted at cache-line LEADERS: a block leader / mid-block jump-table target (any - // address reachable other than by fall-through, i.e. a possibly-cold cache entry) OR - // a 16-byte-line start (addr&0xC==0, a sequential line crossing). Intra-line - // followers reached by fall-through are guaranteed hits — the leader's fetch - // refilled the line to its end — so they need no call (+0). Extra fetch points are - // harmless (a hit is +0); only UNDER-counting a cold entry would diverge, which the - // leader set prevents. The game runs at its KSEG0 load address, so `insn_addr` is - // already the runtime guest PC (matching the dirty-RAM interp's cpu->pc and Beetle). - auto emit_pre_icache = [&](uint32_t insn_addr, const std::string& indent) { - if (!(insn_addr == block.start_addr || (insn_addr & 0xCu) == 0 || - extra_labels_.count(insn_addr))) return; - ss << "#ifdef PSX_ENABLE_BLOCK_CYCLES\n"; - ss << indent << fmt::format("psx_icache_fetch(cpu, 0x{:08X}u);\n", insn_addr); - ss << "#endif\n"; - }; - auto emit_cosim_instr = [&](uint32_t insn_addr, const std::string& indent) { - ss << "#ifdef PSX_COSIM\n"; - ss << indent << fmt::format("cosim_instr(0x{:08X}u);\n", insn_addr); - ss << "#endif\n"; - }; - if (!cycle_per_insn && block_exec_cycles > 0) { - ss << "#ifdef PSX_ENABLE_BLOCK_CYCLES\n"; - ss << config_.indent << fmt::format("psx_advance_cycles({}u);\n", - block_exec_cycles); - ss << "#endif\n"; - } - - // Translate each instruction in the block - uint32_t addr = block.start_addr; - - // Determine if the exit instruction uses a delay slot pattern. - // Normal case: branch at end_addr-4, delay slot at end_addr. - // Split-function edge case: exit instruction IS at end_addr (delay slot - // is outside the function), so exit_branch_addr = end_addr not end_addr-4. - bool exit_has_delay = (block.exit_instr.has_delay_slot && - block.exit_instr.type != ControlFlowType::None); - bool delay_slot_in_block = (exit_has_delay && - block.exit_instr.address < block.end_addr); - bool exit_uses_delay_slot = delay_slot_in_block; - uint32_t exit_branch_addr; - if (delay_slot_in_block) { - exit_branch_addr = block.end_addr - 4; // branch at end-4, delay slot at end - } else if (exit_has_delay) { - exit_branch_addr = block.end_addr; // branch at end, delay slot outside - } else { - exit_branch_addr = block.end_addr; - } - - while (addr <= block.end_addr) { - auto instr_opt = exe_.read_word(addr); - if (!instr_opt.has_value()) { - break; - } - - uint32_t instr = *instr_opt; - - // Skip the delay slot at end_addr - it's emitted as part of the branch handling below - if (exit_uses_delay_slot && addr == block.end_addr) { - addr += 4; - break; - } - - // Don't emit control flow instructions here (handled separately) - bool is_cf = ControlFlowAnalyzer::is_control_flow(instr); - - // Emit inline label for mid-block jump table targets - if (addr != block.start_addr && extra_labels_.count(addr)) { - ss << fmt::format("block_{:08X}:;\n", addr); - } - - // Instruction-level annotation (skipped at function start — already shown above signature) - if (annotations_ && addr != cfg.function_start) { - const std::string& inote = annotations_->lookup(addr); - if (!inote.empty()) - ss << config_.indent << fmt::format("/* [NOTE] {} */\n", inote); - } - - if (!is_cf) { - if (cycle_per_insn) emit_pre_icache(addr, config_.indent); - if (cycle_per_insn) emit_pre_timing(instr, config_.indent); - ss << translate_instruction(addr, instr) << "\n"; - emit_cosim_instr(addr, config_.indent); - } else { - // Control flow is handled at block exit - if (addr == exit_branch_addr) { - std::string delay_saved_cond; // branch condition captured before delay - std::string delay_saved_target; // JR/JALR target captured before delay - - const uint32_t branch_opcode = - (block.exit_instr.instruction >> 26) & 0x3Fu; - if (branch_opcode >= 0x14u && branch_opcode <= 0x17u) { - /* Opcodes 0x14-0x17 (the MIPS-II branch-likely family) are - * RESERVED on the R3000A: real hardware raises the Reserved - * Instruction exception at this PC (Beetle cpu.cpp opcode - * table row 0x10: op_ILL). Discovery can sweep data-as-code - * into a function body; aborting the WHOLE regen here (the - * old throw) broke "every title regenerates at tip" over one - * bogus word, and emitting the word as a real branch would - * be unfaithful. Emit the architectural RI raise inline — - * ABI-neutral: the dispatch trampoline (and the CPS exit - * contract in overlay mode) re-dispatches cpu->pc after the - * return, exactly like any other guest control transfer. If - * the word is data (never executed) nothing happens; if the - * guest really reaches it, the kernel handler sees the same - * exception hardware would deliver. */ - fmt::print(" WARNING: reserved opcode 0x{:08X} at 0x{:08X} " - "— emitting guest RI exception raise\n", - block.exit_instr.instruction, addr); - ss << config_.indent - << fmt::format("{{ /* reserved opcode 0x{:08X}: architectural RI exception */\n", - block.exit_instr.instruction); - ss << config_.indent << " uint32_t psx_ri_sr = cpu->cop0[12];\n"; - ss << config_.indent - << fmt::format(" cpu->cop0[14] = 0x{:08X}u; /* EPC = faulting PC, BD=0 */\n", addr); - ss << config_.indent - << " cpu->cop0[13] = (cpu->cop0[13] & ~0x8000007Cu) | (10u << 2); /* Cause: RI */\n"; - ss << config_.indent - << " cpu->cop0[12] = (psx_ri_sr & ~0x3Fu) | ((psx_ri_sr & 0x0Fu) << 2); /* SR push */\n"; - ss << config_.indent - << " cpu->pc = (psx_ri_sr & 0x00400000u) ? 0xBFC00180u : 0x80000080u;\n"; - ss << config_.indent << " return; }\n"; - break; /* block ends at the raise; nothing after is reachable */ - } - - // Per-instruction interlock ORDER (Beetle): the branch's §1+deps+DO_LDS - // runs at the branch PC, THEN the delay slot's at PC+4. Emit the branch - // step FIRST (it is pure timing — does not touch GPR values, so it is - // safe before the branch-condition capture below). - if (cycle_per_insn) - emit_pre_icache(exit_branch_addr, config_.indent); - if (cycle_per_insn) - emit_pre_timing(block.exit_instr.instruction, config_.indent); - - // Register-indirect targets are resolved at the jump instruction. - // The delay slot may overwrite the source register, so all JR/JALR - // paths below consume this pre-delay snapshot. - if (block.exit_instr.type == ControlFlowType::Return || - block.exit_instr.type == ControlFlowType::JumpRegister || - block.exit_instr.type == ControlFlowType::JumpLinkReg) { - const uint32_t target_rs = get_rs(block.exit_instr.instruction); - delay_saved_target = fmt::format("_jt_{:08X}", addr); - ss << config_.indent - << fmt::format("uint32_t {} = {}; /* latch indirect target before delay slot */\n", - delay_saved_target, reg_name(target_rs)); - } - - if (block.exit_instr.type == ControlFlowType::JumpLink) { - ss << config_.indent - << fmt::format("cpu->gpr[31] = 0x{:08X}u; /* jal link before delay slot */\n", - addr + 8); - } else if (block.exit_instr.type == ControlFlowType::JumpLinkReg) { - uint32_t rd = get_rd(block.exit_instr.instruction); - if (rd != 0) { - ss << config_.indent - << fmt::format("{} = 0x{:08X}u; /* jalr link before delay slot */\n", - reg_name(rd), addr + 8); - } - } else if (block.exit_instr.type == ControlFlowType::Branch) { - uint32_t branch_instr = block.exit_instr.instruction; - uint32_t b_opcode = (branch_instr >> 26) & 0x3F; - uint32_t regimm_op = (branch_instr >> 16) & 0x1F; - if (b_opcode == 0x01 && (regimm_op == 0x10 || regimm_op == 0x11)) { - ss << config_.indent - << fmt::format("cpu->gpr[31] = 0x{:08X}u; /* branch-and-link before delay slot */\n", - addr + 8); - } - } - - // MIPS delay slot handling: emit delay slot instruction BEFORE branch/jump - if (block.exit_instr.has_delay_slot) { - uint32_t delay_slot_addr = addr + 4; - auto delay_instr_opt = exe_.read_word(delay_slot_addr); - - /* A native control transfer without its architectural delay - * slot is never a legal partial shard. In particular, a - * page-scoped overlay capture can end with the branch at - * ...FFC while the slot lives at ...000 in the next page. - * The old code silently skipped the unavailable word and - * still emitted the transfer, corrupting guest state while - * looking like a successful native call. Fail generation - * closed; the runtime interpreter remains authoritative - * until a capture containing the guard word is available. */ - if (!delay_instr_opt.has_value()) { - throw std::runtime_error(fmt::format( - "cannot emit control flow at 0x{:08X}: mandatory " - "delay slot 0x{:08X} is outside the input image", - addr, delay_slot_addr)); - } - { - uint32_t delay_instr = *delay_instr_opt; - - // For branch-likely variants, delay slot is conditional - if (block.exit_instr.is_likely) { - ss << config_.indent << "/* delay slot (likely) - conditional execution */\n"; - ss << config_.indent << "if (" << generate_branch_condition(block.exit_instr.instruction, block.exit_instr.address) << ") {\n"; - ss << config_.indent << translate_instruction(delay_slot_addr, delay_instr) << "\n"; - emit_cosim_instr(delay_slot_addr, config_.indent + config_.indent); - ss << config_.indent << "}\n"; - } else { - // Normal delay slot - always executes. - // In MIPS, the branch condition is evaluated at the branch - // instruction (before the delay slot). If the delay slot - // modifies a condition register we must save the result first. - if (block.exit_instr.type == ControlFlowType::Branch) { - std::string cond = generate_branch_condition(block.exit_instr.instruction, block.exit_instr.address); - delay_saved_cond = fmt::format("_bc_{:08X}", addr); - ss << config_.indent - << fmt::format("int {} = ({}); /* save branch cond before delay slot */\n", - delay_saved_cond, cond); - } - ss << config_.indent << "/* delay slot (always executes) */\n"; - // Delay slot's own fetch + §1+deps+DO_LDS, before its body (skipped if a load). - if (cycle_per_insn) emit_pre_icache(delay_slot_addr, config_.indent); - if (cycle_per_insn) emit_pre_timing(delay_instr, config_.indent); - ss << translate_instruction(delay_slot_addr, delay_instr) << "\n"; - emit_cosim_instr(delay_slot_addr, config_.indent); - } - } - } - - // Now emit the branch/jump - if (block.exit_instr.type == ControlFlowType::Branch) { - // Conditional branch - use pre-captured condition if available - std::string condition = delay_saved_cond.empty() - ? generate_branch_condition(block.exit_instr.instruction, block.exit_instr.address) - : delay_saved_cond; - if (block.successors.size() == 2) { - ss << config_.indent << "if (" << condition << ") {\n"; - ss << emit_interrupt_check(block.successors[0], config_.indent + config_.indent); - ss << config_.indent << config_.indent - << fmt::format("goto block_{:08X}; /* taken */\n", block.successors[0]); - ss << config_.indent << "} else {\n"; - ss << emit_interrupt_check(block.successors[1], config_.indent + config_.indent); - ss << config_.indent << config_.indent - << fmt::format("goto block_{:08X}; /* not taken */\n", block.successors[1]); - ss << config_.indent << "}\n"; - } else { - // Split-function: one or both branch targets are outside - // this function piece. Emit function calls for out-of-function - // targets to reconnect the split pieces. - uint32_t branch_target = block.exit_instr.target; - uint32_t fall_through_addr = block.exit_instr.address + 8; - bool target_in = cfg.blocks.count(branch_target) > 0; - bool fallthru_in = cfg.blocks.count(fall_through_addr) > 0; - - ss << config_.indent << "if (" << condition << ") {\n"; - if (target_in) { - ss << emit_interrupt_check(branch_target, config_.indent + config_.indent); - ss << config_.indent << config_.indent - << fmt::format("goto block_{:08X}; /* taken */\n", branch_target); - } else if (cps_enabled_) { - ss << emit_interrupt_check(branch_target, config_.indent + config_.indent); - ss << config_.indent << config_.indent - << fmt::format("cpu->pc = 0x{:08X}u; return; /* CPS taken: split */\n", branch_target); - } else if (known_functions_.count(branch_target)) { - ss << emit_interrupt_check(branch_target, config_.indent + config_.indent); - ss << config_.indent << config_.indent - << fmt::format("func_{:08X}(cpu); return; /* taken: split piece */\n", branch_target); - } else { - ss << emit_interrupt_check(branch_target, config_.indent + config_.indent); - ss << config_.indent << config_.indent - << fmt::format("call_by_address(cpu, 0x{:08X}u); return; /* taken: split (mid-func) */\n", branch_target); - } - ss << config_.indent << "} else {\n"; - if (fallthru_in) { - ss << emit_interrupt_check(fall_through_addr, config_.indent + config_.indent); - ss << config_.indent << config_.indent - << fmt::format("goto block_{:08X}; /* not taken */\n", fall_through_addr); - } else if (cps_enabled_) { - ss << emit_interrupt_check(fall_through_addr, config_.indent + config_.indent); - ss << config_.indent << config_.indent - << fmt::format("cpu->pc = 0x{:08X}u; return; /* CPS not taken: split */\n", fall_through_addr); - } else if (known_functions_.count(fall_through_addr)) { - ss << emit_interrupt_check(fall_through_addr, config_.indent + config_.indent); - ss << config_.indent << config_.indent - << fmt::format("func_{:08X}(cpu); return; /* not taken: split piece */\n", fall_through_addr); - } else { - ss << emit_interrupt_check(fall_through_addr, config_.indent + config_.indent); - ss << config_.indent << config_.indent - << fmt::format("call_by_address(cpu, 0x{:08X}u); return; /* not taken: split (mid-func) */\n", fall_through_addr); - } - ss << config_.indent << "}\n"; - } - } else if (block.exit_instr.type == ControlFlowType::Jump) { - // Unconditional jump - if (!block.successors.empty()) { - ss << emit_interrupt_check(block.successors[0], config_.indent); - ss << config_.indent - << fmt::format("goto block_{:08X}; /* j */\n", block.successors[0]); - } else if (cps_enabled_ && block.exit_instr.target != 0) { - // CPS: out-of-function jump target — tail-transfer (the - // flat trampoline dispatches the split piece / mid-func). - ss << emit_interrupt_check(block.exit_instr.target, config_.indent); - ss << config_.indent - << fmt::format("cpu->pc = 0x{:08X}u; return; /* CPS j: split */\n", - block.exit_instr.target); - } else if (block.exit_instr.target != 0 && known_functions_.count(block.exit_instr.target)) { - // Jump target is out-of-function and is a known function start - ss << emit_interrupt_check(block.exit_instr.target, config_.indent); - ss << config_.indent - << fmt::format("func_{:08X}(cpu); return; /* j to split piece */\n", - block.exit_instr.target); - } else if (block.exit_instr.target != 0) { - // Jump target is a mid-function address — dispatch dynamically - ss << emit_interrupt_check(block.exit_instr.target, config_.indent); - ss << config_.indent - << fmt::format("call_by_address(cpu, 0x{:08X}u); return; /* j to split (mid-func) */\n", - block.exit_instr.target); - } - } else if (block.exit_instr.type == ControlFlowType::Return) { - /* Return instruction. - * - * Detect "longjmp-return" pattern: if $ra was loaded from a - * non-$sp source anywhere in this function (e.g. RestoreState - * loads $ra from a save buffer via $a0), the jr $ra must set - * cpu->pc so the dispatch loop tail-calls to the restored - * address — the C `return` alone would go back to the wrong - * caller. Normal functions that save/restore $ra on the $sp - * stack get the plain `return;`. */ - bool ra_loaded_from_non_sp = false; - for (const auto& [baddr, blk] : cfg.blocks) { - for (uint32_t ia = blk.start_addr; ia < blk.end_addr; ia += 4) { - auto wopt = exe_.read_word(ia); - if (!wopt) continue; - uint32_t w = *wopt; - uint32_t i_op = (w >> 26) & 0x3F; - uint32_t i_rt = (w >> 16) & 0x1F; - uint32_t i_rs = (w >> 21) & 0x1F; - if (i_op == 0x23 && i_rt == 31 && i_rs == 4) { - /* lw $ra, offset($a0) — RestoreState/longjmp pattern */ - ra_loaded_from_non_sp = true; - break; - } - } - if (ra_loaded_from_non_sp) break; - } - if (config_.ws_backdrop_unsquash_funcs.count(cfg.function_start)) { - ss << config_.indent - << "gte_ws_set_suppress(0); /* widescreen: end far-backdrop un-squash (8C) */\n"; - } - if (config_.data_shard_funcs.count(cfg.function_start)) { - // Capture finalize: fires only when this is the armed - // function's own return (sp/ra checked runtime-side). - ss << config_.indent - << "psx_datashard_ret(cpu); /* data-shard: finalize capture */\n"; - } - if (ra_loaded_from_non_sp) { - ss << emit_interrupt_check_expr(delay_saved_target, config_.indent); - ss << config_.indent - << "cpu->pc = " << delay_saved_target << "; psx_restore_state_escape(); return;" - << " /* jr $ra — longjmp-return (ra loaded from non-sp) */\n"; - } else if (cps_enabled_) { - // CPS: publish $ra so the flat trampoline dispatches the - // caller's continuation (no host C-return to nest). - ss << emit_interrupt_check_expr(delay_saved_target, config_.indent); - ss << config_.indent - << "cpu->pc = " << delay_saved_target << "; return; /* CPS: jr $ra */\n"; - } else { - ss << emit_interrupt_check_expr(delay_saved_target, config_.indent); - ss << config_.indent << "return; /* jr $ra */\n"; - } - } else if (block.exit_instr.type == ControlFlowType::JumpRegister) { - // jr $rs — may be: BIOS call (jr $t2, t2=0xA0/0xB0/0xC0) - // or: jump table (jr $v0, v0 loaded from LW+ADDU+LUI) - uint32_t jr_rs = get_rs(block.exit_instr.instruction); - - // Detect MIPS jump-table pattern by scanning backwards from jr. - // Handles two common patterns: - // P1: lui $R,hi; addiu $R,$R,lo; addu $D,$idx,$R; lw $D,0($D) - // P2: lui $R,hi; addu $D,$R,$idx; lw $D,off($D) - // table_base = lui_val + addiu_val + lw_offset - ExactJumpTable exact_table; - bool have_exact_table = resolve_exact_bounded_jump_table( - exe_, cfg.function_start, cfg.function_end, - block.exit_instr.address, jr_rs, exact_table, - ram_to_rom, cfg.producer_lo, cfg.producer_hi); - uint32_t table_base = have_exact_table - ? exact_table.table_base : 0u; - uint32_t table_count = have_exact_table - ? exact_table.table_count : 0u; - // If we found a valid table, read entries and emit switch. - // table_base may be a RAM address (BIOS shell code copies - // ROM 0xBFC18000+ to RAM 0x80030000+). Translate to ROM - // so exe_.read_word() can read the data, and translate each - // entry so it matches cfg.blocks / extra_labels_ (ROM space). - bool emitted_switch = false; - if (table_base != 0 && table_count > 0) { - uint32_t rom_table_base = ram_to_rom(table_base, exe_); - std::set seen; - std::vector> targets; // {runtime_addr, rom_addr} - for (const auto& [runtime_addr, rom_addr] : - exact_table.targets) { - if (seen.insert(rom_addr).second && - (cfg.blocks.count(rom_addr) || extra_labels_.count(rom_addr))) { - targets.push_back({runtime_addr, rom_addr}); - } - } - if (!targets.empty()) { - ss << config_.indent << fmt::format("/* jump table 0x{:08X} (rom 0x{:08X}), {} entries */\n", - table_base, rom_table_base, table_count); - ss << config_.indent << fmt::format("switch ({}) {{\n", delay_saved_target); - for (auto& [rt, rom] : targets) { - if (partial_block_cycle_count(rom, cfg) != 0) { - ss << config_.indent << fmt::format(" case 0x{:08X}u:\n", rt); - ss << emit_interrupt_check(rom, config_.indent + " "); - ss << emit_mid_block_cycle_charge(rom, cfg, config_.indent + " "); - ss << config_.indent << fmt::format(" goto block_{:08X};\n", rom); - } else { - ss << config_.indent << fmt::format(" case 0x{:08X}u:\n", rt); - ss << emit_interrupt_check(rom, config_.indent + " "); - ss << config_.indent << fmt::format(" goto block_{:08X};\n", rom); - } - } - if (cps_enabled_) { - ss << config_.indent << " default:\n"; - ss << emit_interrupt_check_expr(delay_saved_target, config_.indent + " "); - ss << config_.indent << " cpu->pc = " << delay_saved_target - << "; return; /* CPS: jr table miss — tail-transfer */\n"; - } else { - ss << config_.indent << " default:\n"; - ss << emit_interrupt_check_expr(delay_saved_target, config_.indent + " "); - ss << config_.indent << " call_by_address(cpu, " << delay_saved_target << "); return;\n"; - } - ss << config_.indent << "}\n"; - emitted_switch = true; - } - } - if (!emitted_switch) { - if (cps_enabled_) { - // CPS: indirect jump / BIOS-call gate — tail-transfer - // to the target (the flat trampoline dispatches it). - ss << emit_interrupt_check_expr(delay_saved_target, config_.indent); - ss << config_.indent << fmt::format("cpu->pc = {}; return; /* CPS: jr {} */\n", - delay_saved_target, reg_name(jr_rs)); - } else { - // BIOS call or unrecognised indirect jump - ss << emit_interrupt_check_expr(delay_saved_target, config_.indent); - ss << config_.indent << fmt::format("call_by_address(cpu, {}); /* jr {} */\n", - delay_saved_target, reg_name(jr_rs)); - ss << config_.indent << "return;\n"; - } - } - } else if (block.exit_instr.type == ControlFlowType::JumpLink) { - uint32_t target = block.exit_instr.target; - uint32_t cont_addr = block.exit_instr.address + 8; - if (cps_enabled_) { - // CPS: tail-transfer to the callee. Set $ra to the - // return point and register it as a dispatchable - // continuation; the callee's jr $ra publishes cpu->pc = - // $ra and the flat trampoline dispatches back into this - // function's entry-switch. No host nesting. - // Only register a LOCAL switch case when the return PC - // lives in this piece (has a block label). When the - // delay/return was split out, successors is empty — the - // mid-function pre-pass promotes addr+8 to its own - // dispatch entry (do not emit goto block_). - if (!block.successors.empty()) { - cps_cur_continuations_.push_back(cont_addr); - } - ss << emit_interrupt_check(target, config_.indent); - ss << config_.indent - << fmt::format("cpu->pc = 0x{:08X}u; return; /* CPS jal -> 0x{:08X} */\n", - target, target); - } else { - // Function call (jal). The call contract (Bug D family) - // guards the continuation: it may only run if the guest - // actually returned here with the caller's $sp. - ss << config_.indent << "{ uint32_t _csp = cpu->gpr[29];\n"; - ss << emit_interrupt_check(target, config_.indent); - if (known_functions_.count(target) > 0) { - ss << config_.indent << fmt::format("func_{:08X}(cpu); /* jal */\n", target); - ss << config_.indent << fmt::format("if (psx_call_contract(cpu, 0x{:08X}u, _csp)) return; }}\n", addr + 8); - } else { - ss << config_.indent << fmt::format("call_by_address(cpu, 0x{:08X}u); /* external jal */\n", target); - /* psx_dispatch_call validated the (ra, sp) contract; - * only propagate an active bail unwind here. */ - ss << config_.indent << "if (g_psx_call_bail) return; (void)_csp; }\n"; - } - if (!block.successors.empty()) { - ss << config_.indent - << fmt::format("goto block_{:08X}; /* continue after call */\n", block.successors[0]); - } else { - // Split-function: JAL continuation is outside this function piece. - // Tail-call to the continuation piece (at exit_addr + 8, past delay slot). - if (known_functions_.count(cont_addr)) { - ss << config_.indent - << fmt::format("func_{:08X}(cpu); return; /* jal cont: split piece */\n", cont_addr); - } else { - ss << config_.indent - << fmt::format("call_by_address(cpu, 0x{:08X}u); return; /* jal cont: split */\n", cont_addr); - } - } - } - } else if (block.exit_instr.type == ControlFlowType::JumpLinkReg) { - // Register indirect call (jalr $rs, $rd) — call function at rs, return to rd - uint32_t rs = get_rs(block.exit_instr.instruction); - uint32_t cont_addr = block.exit_instr.address + 8; - - if (cps_enabled_) { - // The target and link were committed before the delay - // slot above. Register a local continuation only when - // the return PC is in this piece (see jal). - if (!block.successors.empty()) { - cps_cur_continuations_.push_back(cont_addr); - } - ss << emit_interrupt_check_expr(delay_saved_target, config_.indent); - ss << config_.indent << "cpu->pc = " << delay_saved_target - << "; return; /* CPS jalr */\n"; - } else { - // Dispatch the pre-delay latched indirect target. - // Dispatch indirect call — target is a runtime register value - ss << emit_interrupt_check_expr(delay_saved_target, config_.indent); - ss << config_.indent << fmt::format("call_by_address(cpu, {}); /* jalr {} */\n", - delay_saved_target, reg_name(rs)); - /* psx_dispatch_call validated the (ra, sp) contract; - * only propagate an active bail unwind here. */ - ss << config_.indent << "if (g_psx_call_bail) return;\n"; - - // Continue to successor block (instruction at return address) - if (!block.successors.empty()) { - ss << config_.indent - << fmt::format("goto block_{:08X}; /* continue after jalr */\n", - block.successors[0]); - } else { - // Split-function: JALR continuation is outside this function piece. - if (known_functions_.count(cont_addr)) { - ss << config_.indent - << fmt::format("func_{:08X}(cpu); return; /* jalr cont: split piece */\n", cont_addr); - } else { - ss << config_.indent - << fmt::format("call_by_address(cpu, 0x{:08X}u); return; /* jalr cont: split */\n", cont_addr); - } - } - } - } - } - } - - addr += 4; - } - - // If no explicit control flow, fall through to the next block. When a - // CFG split or mid-function seed split placed the next instruction behind - // a C label/function boundary, preserve the physical fall-through without - // consuming an interrupt/cooldown check at the artificial boundary. - if (block.exit_instr.type == ControlFlowType::None && !block.successors.empty()) { - ss << config_.indent << fmt::format("/* fall through to block_{:08X} */\n", - block.successors[0]); - } else if (block.exit_instr.type == ControlFlowType::None) { - uint32_t next_addr = block.end_addr + 4; - if (known_functions_.count(next_addr) > 0) { - ss << config_.indent - << fmt::format("func_{:08X}(cpu); return; /* fallthrough to split piece */\n", - next_addr); - } else { - ss << emit_interrupt_check(next_addr, config_.indent); - } - } - - return ss.str(); -} - -bool CodeGenerator::func_has_screen_extent_cull(const ControlFlowGraph& cfg) const { - // The GTE trivial-reject pairs a width compare (slti/sltiu, immediate from - // the per-game W set) with a height compare (immediate from the H set) in - // the same function. Presence of both is the signature of a screen-extent - // render funnel; a lone width compare elsewhere (rare) is left alone. - // Shared scan (ws_cull_detect.h) so every backend derives one verdict. - bool has_x = false, has_y = false; - for (uint32_t block_addr : cfg.block_order) { - const BasicBlock& block = cfg.blocks.at(block_addr); - for (uint32_t a = block.start_addr; a <= block.end_addr; a += 4) { - auto io = exe_.read_word(a); - if (!io.has_value()) continue; - uint32_t in = *io; - uint32_t op = in >> 26; - if (op != 0x0Au && op != 0x0Bu) continue; // not slti/sltiu - uint32_t im = in & 0xFFFFu; - if (psx_ws_cull_imm_in(im, config_.ws_cull_w_imms.data(), - (int)config_.ws_cull_w_imms.size())) has_x = true; - else if (psx_ws_cull_imm_in(im, config_.ws_cull_h_imms.data(), - (int)config_.ws_cull_h_imms.size())) has_y = true; - if (has_x && has_y) return true; - } - } - return false; -} - -int CodeGenerator::detect_cull_bltz_sites(const ControlFlowGraph& cfg) { - // Populate ws_cull_bltz_pcs_ with the function's LEFT-edge funnel bltz - // addresses (ws_cull_detect.h idioms 2/3). Only meaningful when the - // function already qualified (ws_auto_cull_func_). Build a contiguous word - // image over the function range (same approach as detect_backdrop_windows) - // so the structural classifier sees cross-block context. - ws_cull_bltz_pcs_.clear(); - if (!ws_auto_cull_func_) return 0; - uint32_t lo = cfg.function_start, hi = cfg.function_end; - if (hi <= lo || (hi - lo) > 0x40000u) return 0; // sanity bound - std::vector words; - words.reserve((hi - lo) / 4u); - for (uint32_t a = lo; a < hi; a += 4) { - auto io = exe_.read_word(a); - words.push_back(io.has_value() ? *io : 0u); - } - int n = (int)words.size(); - for (int i = 0; i < n; i++) - if (psx_ws_cull_bltz_here(words.data(), n, i, - config_.ws_cull_w_imms.data(), - (int)config_.ws_cull_w_imms.size())) - ws_cull_bltz_pcs_.insert(lo + (uint32_t)i * 4u); - return (int)ws_cull_bltz_pcs_.size(); -} - -int CodeGenerator::detect_backdrop_windows(const ControlFlowGraph& cfg) { - // Always clear so a stale map from the previous function can never leak. - ws_backdrop_sites_.clear(); - if (!config_.ws_auto_backdrop_preload) return 0; - - // Build a contiguous word image of the whole function range so the shared - // detector's absolute-PC math (base_pc + i*4) lands on real addresses; any - // non-code gap (jump table / constant pool) simply never matches the - // invariant and is harmless. base_pc = function_start. - uint32_t lo = cfg.function_start, hi = cfg.function_end; - if (hi <= lo || (hi - lo) > 0x40000u) return 0; // sanity bound - std::vector words; - words.reserve((hi - lo) / 4u); - for (uint32_t a = lo; a < hi; a += 4) { - auto io = exe_.read_word(a); - words.push_back(io.has_value() ? *io : 0u); - } - - WsBackdropSite sites[64]; - int ns = psx_ws_find_backdrop_windows(words.data(), (int)words.size(), lo, - sites, 64); - for (int i = 0; i < ns; i++) - ws_backdrop_sites_[sites[i].pc] = { sites[i].kind, sites[i].window_cols }; - return ns; -} - -GeneratedFunction CodeGenerator::generate_function( - const Function& func, - const ControlFlowGraph& cfg, - const std::string& fallthrough_name) { - - GeneratedFunction result; - result.function_name = func.name; - { - std::string sig; - if (config_.hot_funcs.count(func.start_addr)) { - sig += "#if defined(__GNUC__) || defined(__clang__)\n" - "__attribute__((hot))\n" - "#endif\n"; - } - sig += fmt::format("void {}(CPUState* cpu)", func.name); - result.signature = std::move(sig); - } - - // Widescreen auto cull (gated): detect the screen-extent reject signature so - // translate_instruction widens this function's width compares. Cleared for - // every function so it never leaks across functions. The LEFT-edge funnel - // bltz sites (signed idioms) are classified alongside. - ws_auto_cull_func_ = config_.ws_auto_screen_x_cull && - func_has_screen_extent_cull(cfg); - detect_cull_bltz_sites(cfg); - - // Widescreen backdrop preload (gated): detect each scrolling-backdrop column - // window so translate_instruction rewrites its START/END finalize. Cleared + - // repopulated per function so sites never leak across functions. - { - int bd_windows = detect_backdrop_windows(cfg); - if (bd_windows > 0) - fmt::print(" [ws backdrop] {} site(s) in {} (0x{:08X})\n", - bd_windows, func.name, func.start_addr); - } - - // Pre-scan: find jump table targets that land mid-block (not a block boundary). - // These need inline labels so goto statements in the switch can reach them. - // Must run BEFORE translating blocks (translate_basic_block reads extra_labels_). - extra_labels_.clear(); - std::map> jr_table_edges; - scan_jr_tables(cfg, jr_table_edges); - - // CPS (§25): collect this function's continuations (call return points) as - // blocks are translated, so the entry-switch below can route a dispatched - // continuation address into the right block. - cps_cur_continuations_.clear(); - - // Translate blocks into a temp buffer first: the CPS entry-switch is emitted - // at the top (before the entry hooks) and must know every continuation. - std::stringstream blocks_ss; - for (uint32_t block_addr : cfg.block_order) { - const BasicBlock& block = cfg.blocks.at(block_addr); - blocks_ss << "\n" << translate_basic_block(block, cfg); - } - - // FAITHFUL RE-ENTRY — every basic-block leader is a dispatchable continuation - // (MMX6 boot-wedge class B, resume PC 0x8005B07C). psx_check_interrupts_at is - // emitted at EVERY block leader, so ANY leader can be published as an async-RFE - // / interp-handoff interrupt-resume PC. The compiled top-level trampoline can - // only re-enter a function at a registered continuation; a leader that is only - // an interrupt-resume point (not a CPS jal-return) was NOT registered, so a - // mid-function resume PC missed dispatch and the top loop read pc=0 = - // "execution completed" abnormal exit. Game functions keep ALL guest state - // canonical in cpu->gpr[]/RAM (no cross-block C-locals), so entering at - // block_LEADER from a fresh call reconstructs identical state to normal flow. - // Register every leader (the jal/jalr call-returns pushed during translation - // above are a subset, deduped by `seen`/the owner map). Entry block runs from - // the top (cpu->pc==0) and is dispatched as a function entry, so skip it. - if (cps_enabled_) { - for (uint32_t block_addr : cfg.block_order) { - if (block_addr == func.start_addr) continue; - cps_cur_continuations_.push_back(block_addr); - } - } - - std::stringstream body_ss; - body_ss << "{\n"; - body_ss << "#if defined(PSX_ENABLE_BLOCK_CYCLES) && " - "(defined(__GNUC__) || defined(__clang__))\n" - << config_.indent - << "__attribute__((cleanup(psx_cyc_bb_defer_cleanup))) " - "int _psx_cyc_bb_guard = 1;\n" - << config_.indent << "psx_cyc_bb_defer_begin();\n" - << "#endif\n"; - - // CPS entry-switch: when the unified flat trampoline dispatches a - // continuation address (a callee published cpu->pc = $ra back to us), route - // into the owning block. Emitted BEFORE the entry hooks so a continuation - // re-entry bypasses debug_server_log_call_entry / widescreen entry hooks - // (those must run only on a true function entry, cpu->pc == 0). - // Overlay mode must emit the cpu->pc guard even when the continuation set - // is EMPTY: a range re-entry can still request an interior PC of a - // single-block function, and without the guard the body runs from its top - // regardless of the requested PC — the same corrupting-fall-through this - // switch's fail-closed default exists to prevent. - if (cps_enabled_ && (!cps_cur_continuations_.empty() || config_.overlay_mode)) { - std::set seen; - body_ss << config_.indent << "if (cpu->pc != 0u) {\n"; - body_ss << config_.indent << " uint32_t _cont = cpu->pc; cpu->pc = 0;\n"; - body_ss << config_.indent << " switch (_cont) {\n"; - for (uint32_t c : cps_cur_continuations_) { - if (!seen.insert(c).second) continue; - if (partial_block_cycle_count(c, cfg) != 0) { - body_ss << config_.indent << fmt::format(" case 0x{:08X}u:\n", c); - body_ss << emit_mid_block_cycle_charge(c, cfg, config_.indent + " "); - body_ss << config_.indent << fmt::format(" goto block_{:08X};\n", c); - } else { - body_ss << config_.indent - << fmt::format(" case 0x{:08X}u: goto block_{:08X};\n", c, c); - } - cps_continuation_owner_[c] = func.start_addr; - } - if (config_.overlay_mode) { - // Overlay functions: FAIL CLOSED on a foreign interior entry (the - // Tomba/Tomba2 native-overlay "blue screen" wedge). Entry at the true - // prologue (cpu->pc == start_addr) runs from the top. Any OTHER non-zero - // _cont is a foreign interior entry (a range-ownership mismatch routed a - // PC this function does not own into it). The old `default: break` ran - // the function FROM ITS TOP -- corrupting shared CPU/RAM state and - // wedging. Instead restore the requested PC and signal the overlay - // dispatch to route it to the sanctioned dirty-RAM interpreter - // (psx_native_bad_entry). NEVER run from the top. - if (seen.insert(func.start_addr).second) { - body_ss << config_.indent - << fmt::format(" case 0x{:08X}u: break; /* entry at prologue */\n", - func.start_addr); - } - body_ss << config_.indent - << fmt::format(" default: cpu->pc = _cont; " - "psx_native_bad_entry(cpu, 0x{:08X}u, _cont); return;\n", - func.start_addr); - } else { - // Static (BIOS/boot-EXE) functions keep the legacy fall-through: the - // generated static trampoline does not consume the bad-entry signal, - // and static ranges are discovered once with no overlay multi-variant - // ownership ambiguity. (The fail-closed default is overlay-scoped.) - body_ss << config_.indent << " default: break;\n"; - } - body_ss << config_.indent << " }\n"; - body_ss << config_.indent << "}\n"; - } - - body_ss << config_.indent - << fmt::format("debug_server_log_call_entry(0x{:08X}u);\n", - func.start_addr); - { - const auto vq = config_.vsync_query_hle_funcs.find(func.start_addr); - if (vq != config_.vsync_query_hle_funcs.end()) { - body_ss << config_.indent - << fmt::format( - "if (psx_vsync_query_hle_enter(cpu, 0x{:08X}u, 0x{:08X}u, " - "0x{:08X}u, 0x{:08X}u, 0x{:08X}u)) return;" - " /* load accel: cycle-faithful VSync(-1) query */\n", - func.start_addr, vq->second[0], vq->second[1], - vq->second[2], vq->second[3]); - } - } - if (config_.data_shard_funcs.count(func.start_addr)) { - // Data-shard hook (docs/DATA_SHARDS.md): on a verified shard hit the - // runtime applies the recorded effect, credits the recorded cycles, - // publishes pc=$ra and we return without executing the body. Otherwise - // it arms the capture recorder and the body runs natively. - body_ss << config_.indent - << fmt::format("if (psx_datashard_enter(cpu, 0x{:08X}u)) return;" - " /* data-shard: replay or arm capture */\n", - func.start_addr); - } - if (config_.mod_function_entry_funcs.count(func.start_addr)) { - body_ss << config_.indent - << fmt::format( - "psx_mod_function_entry(cpu, 0x{:08X}u);" - " /* trusted opt-in game-mod hook */\n", - func.start_addr); - } - if (config_.ws_sprite_tag_funcs.count(func.start_addr)) { - body_ss << config_.indent - << "psx_ws_sprite_tag(cpu); /* widescreen: record prim ($a0) + anchor */\n"; - } - if (config_.ws_bg2d_init_func == func.start_addr) { - body_ss << config_.indent - << "psx_ws_mmx6_bg_stage_init(); /* widescreen: invalidate stale reveal at stage BG init */\n"; - } - if (config_.ws_backdrop_unsquash_funcs.count(func.start_addr)) { - body_ss << config_.indent - << "gte_ws_set_suppress(1); /* widescreen: un-squash far backdrop (8C) */\n"; - } - - // Add function comment - if (config_.emit_comments) { - body_ss << config_.indent - << fmt::format("/* Address: 0x{:08X}, Size: {} bytes, Blocks: {} */\n", - func.start_addr, func.size, cfg.blocks.size()); - } - - body_ss << blocks_ss.str(); - - // Emit fallthrough call if the function ends without explicit control flow. - // A MIPS function truly falls through when: - // 1. It has NO jr/jr $ra block anywhere (no explicit return or indirect jump) - // 2. Its last basic block (by address) ends with no control flow (ControlFlowType::None) - // OR it ends with a branch/jump whose targets are all outside this function - // (indicating a split-function where the branch targets are in the next piece) - // Functions that have dead code after a jr $ra (e.g., padding) must NOT get a - // fallthrough call — condition 1 guards against that. - if (!fallthrough_name.empty() && !cfg.block_order.empty()) { - const BasicBlock& last_block = cfg.blocks.at(cfg.block_order.back()); - bool needs_fallthrough = false; - - if (last_block.exit_instr.type == ControlFlowType::None) { - needs_fallthrough = true; - } else if ((last_block.exit_instr.type == ControlFlowType::Branch || - last_block.exit_instr.type == ControlFlowType::Jump) && - last_block.successors.empty()) { - // Branch/jump with no in-function successors — likely a split-function bug. - // All targets are in the next function piece. Emit fallthrough as a safety net. - fmt::print(" WARNING: func_{:08X} has out-of-function {} at block_{:08X}, " - "emitting fallthrough to {}\n", - func.start_addr, - last_block.exit_instr.type == ControlFlowType::Branch ? "branch" : "jump", - last_block.start_addr, fallthrough_name); - needs_fallthrough = true; - } - - if (needs_fallthrough) { - // Emit fallthrough if the last block is reachable (has predecessors - // or is the entry block). Dead code after a return (e.g., padding - // nops) has no predecessors and should NOT get a fallthrough call. - const BasicBlock& last = cfg.blocks.at(cfg.block_order.back()); - bool is_reachable = last.is_entry || !last.predecessors.empty(); - if (is_reachable) { - body_ss << fmt::format(" {}(cpu); /* fallthrough to next function */\n", - fallthrough_name); - } - } - } - body_ss << " ; /* label compatibility: C requires a statement after the last label */\n"; - body_ss << "}\n"; - - result.body = body_ss.str(); - - // Prepend annotation comment if one exists for this function's address - std::string annotation_prefix; - if (annotations_) { - const std::string& note = annotations_->lookup(func.start_addr); - if (!note.empty()) - annotation_prefix = fmt::format("/* [NOTE] {} */\n", note); - } - - result.full_code = annotation_prefix + result.signature + "\n" + result.body; - result.line_count = std::count(result.full_code.begin(), result.full_code.end(), '\n'); - - return result; -} - -void CodeGenerator::scan_jr_tables( - const ControlFlowGraph& cfg, - std::map>& out_edges) { - - for (const auto& [baddr, blk] : cfg.blocks) { - if (blk.exit_instr.type != ControlFlowType::JumpRegister) continue; - uint32_t jr_r = get_rs(blk.exit_instr.instruction); - ExactJumpTable exact_table; - if (!resolve_exact_bounded_jump_table( - exe_, cfg.function_start, cfg.function_end, - blk.exit_instr.address, jr_r, exact_table, ram_to_rom, - cfg.producer_lo, cfg.producer_hi)) { - continue; - } - uint32_t tb = exact_table.table_base; - uint32_t tc = exact_table.table_count; - if (tb==0 || tc==0) continue; - for (const auto& target_pair : exact_table.targets) { - uint32_t t = target_pair.second; - if (t >= cfg.function_start && t < cfg.function_end) { - out_edges[baddr].push_back(t); - if (!cfg.blocks.count(t)) - extra_labels_.insert(t); - } - } - } -} - -std::vector CodeGenerator::generate_alias_group( - const std::vector& aliases, - const ControlFlowGraph& cfg, - const std::string& fallthrough_name) { - - std::vector results; - if (aliases.empty()) return results; - uint32_t host = aliases[0]->alias_walk_lo; - - // Widescreen auto cull (gated): set per-group so a stale value from the last - // generate_function() call can't leak into this body. (See generate_function.) - ws_auto_cull_func_ = config_.ws_auto_screen_x_cull && - func_has_screen_extent_cull(cfg); - detect_cull_bltz_sites(cfg); - - // Backdrop preload sites for this alias group's shared CFG (see - // generate_function). Set per-group so a stale map can't leak in. - { - int bd_windows = detect_backdrop_windows(cfg); - if (bd_windows > 0) - fmt::print(" [ws backdrop] {} site(s) in alias host 0x{:08X}\n", - bd_windows, host); - } - - // Jump-table edges + mid-block labels for this host's CFG. - extra_labels_.clear(); - std::map> jr_table_edges; - scan_jr_tables(cfg, jr_table_edges); - - // CPS (§25): collect this alias group's continuations during block - // translation (also prevents a stale list from leaking into the next - // generate_function call). - cps_cur_continuations_.clear(); - - // Union of blocks reachable from any alias entry. Edges are - // BasicBlock::successors plus jump-table targets (mapped to their - // containing block — table targets may be mid-block labels). - auto containing_block = [&](uint32_t a) -> uint32_t { - auto it = cfg.blocks.upper_bound(a); - if (it == cfg.blocks.begin()) return 0; - --it; - return (a >= it->second.start_addr && a <= it->second.end_addr) - ? it->first : 0; - }; - std::set live_blocks; - std::vector work; - for (const Function* a : aliases) { - uint32_t eb = containing_block(a->start_addr); - if (eb != 0) work.push_back(eb); - } - while (!work.empty()) { - uint32_t b = work.back(); - work.pop_back(); - if (!live_blocks.insert(b).second) continue; - const BasicBlock& blk = cfg.blocks.at(b); - for (uint32_t s : blk.successors) { - if (cfg.blocks.count(s)) work.push_back(s); - } - auto je = jr_table_edges.find(b); - if (je != jr_table_edges.end()) { - for (uint32_t t : je->second) { - uint32_t cb = containing_block(t); - if (cb != 0) work.push_back(cb); - } - } - } - - // Translate live blocks first (populates cps_cur_continuations_ for the CPS - // continuation switch below). - std::stringstream blocks_buf; - for (uint32_t block_addr : cfg.block_order) { - if (!live_blocks.count(block_addr)) continue; - const BasicBlock& block = cfg.blocks.at(block_addr); - blocks_buf << "\n" << translate_basic_block(block, cfg); - } - - // FAITHFUL RE-ENTRY — every live block leader is a dispatchable continuation - // (see generate_function for the full rationale: psx_check_interrupts_at is at - // every leader, so any leader can be an async-RFE/interp-handoff resume PC and - // must be re-enterable, not just CPS jal-returns). Alias-set blocks live behind - // an entry switch; the leaders re-enter via the continuation switch below. - // Owner = aliases[0]->start_addr (same as the call-return continuations). Skip - // the alias entry PCs (they are dispatched as function entries with cpu->pc==0). - if (cps_enabled_) { - std::set alias_entries; - for (const Function* a : aliases) alias_entries.insert(a->start_addr); - for (uint32_t block_addr : cfg.block_order) { - if (!live_blocks.count(block_addr)) continue; - if (alias_entries.count(block_addr)) continue; - cps_cur_continuations_.push_back(block_addr); - } - } - - // Shared body: host-range blocks (live subset) behind an entry switch. - std::stringstream body; - body << fmt::format("/* Overlapping-alias body for host func_{:08X}: {} entries */\n", - host, aliases.size()); - body << fmt::format("void psx_alias_body_{:08X}(CPUState* cpu, uint32_t entry)\n{{\n", - host); - alias_body_decls_.push_back(fmt::format("void psx_alias_body_{:08X}(CPUState* cpu, uint32_t entry)", host)); - // CPS (§25): a dispatched continuation (callee published cpu->pc = $ra) is - // routed into its block here, BEFORE the entry switch — cpu->pc overrides - // the `entry` arg. The dispatch routes a continuation by calling the first - // alias wrapper with cpu->pc set (entry is then ignored). Owner = - // aliases[0]->start_addr. - if (cps_enabled_ && (!cps_cur_continuations_.empty() || config_.overlay_mode)) { - std::set seen; - body << config_.indent << "if (cpu->pc != 0u) {\n"; - body << config_.indent << " uint32_t _cont = cpu->pc; cpu->pc = 0;\n"; - body << config_.indent << " switch (_cont) {\n"; - for (uint32_t c : cps_cur_continuations_) { - if (!seen.insert(c).second) continue; - body << config_.indent - << fmt::format(" case 0x{:08X}u: goto block_{:08X};\n", c, c); - cps_continuation_owner_[c] = aliases[0]->start_addr; - } - if (config_.overlay_mode) { - // Alias entry PCs are excluded from cps_cur_continuations_ (they - // dispatch as function entries with cpu->pc == 0), but a RANGE - // re-entry can still request one with cpu->pc set — route it to - // its block, exactly as the entry switch below would. - for (const Function* a : aliases) { - if (!seen.insert(a->start_addr).second) continue; - body << config_.indent - << fmt::format(" case 0x{:08X}u: goto block_{:08X};\n", - a->start_addr, a->start_addr); - } - // FAIL CLOSED on any other interior PC (same contract as - // generate_function's overlay entry switch, PR #46). The old - // `default: break` fell through to `switch (entry)` and ran the - // ALIAS'S OWN block for a PC it does not own — the Tomba 2 - // splash→title reload death: dispatch(0x80089788) resolved to the - // alias func_80089770, which re-ran the crt0 shim tail (reload - // $ra, call main-init) in an infinite loop, leaking 0x40 of guest - // stack per iteration until the frames overwrote the freshly - // loaded module code itself. Restore the requested PC and signal - // the overlay dispatch to route it to the dirty-RAM interpreter. - body << config_.indent - << fmt::format(" default: cpu->pc = _cont; " - "psx_native_bad_entry(cpu, 0x{:08X}u, _cont); return;\n", - aliases[0]->start_addr); - } else { - body << config_.indent << " default: break;\n"; - } - body << config_.indent << " }\n"; - body << config_.indent << "}\n"; - } - body << config_.indent << "switch (entry) {\n"; - for (const Function* a : aliases) { - body << config_.indent - << fmt::format(" case 0x{:08X}u: goto block_{:08X};\n", - a->start_addr, a->start_addr); - } - if (config_.overlay_mode) { - // A dispatched entry outside the alias set must not silently no-op: - // the loader would count it as executed. Fail closed to the interp. - body << config_.indent - << fmt::format(" default: cpu->pc = entry; " - "psx_native_bad_entry(cpu, 0x{:08X}u, entry); return;\n", - aliases[0]->start_addr); - } else { - body << config_.indent << " default: return;\n"; - } - body << config_.indent << "}\n"; - - body << blocks_buf.str(); - - // Host-end fallthrough (mirrors generate_function): only relevant when the - // host's final block is live and ends without explicit control flow. - if (!fallthrough_name.empty() && !cfg.block_order.empty() && - live_blocks.count(cfg.block_order.back())) { - const BasicBlock& last_block = cfg.blocks.at(cfg.block_order.back()); - bool needs_fallthrough = - (last_block.exit_instr.type == ControlFlowType::None) || - ((last_block.exit_instr.type == ControlFlowType::Branch || - last_block.exit_instr.type == ControlFlowType::Jump) && - last_block.successors.empty()); - if (needs_fallthrough) { - body << fmt::format(" {}(cpu); /* fallthrough to next function */\n", - fallthrough_name); - } - } - body << " ; /* label compatibility */\n"; - body << "}\n"; - - // One dispatchable wrapper per alias entry; the first carries the body. - for (size_t i = 0; i < aliases.size(); ++i) { - const Function* a = aliases[i]; - GeneratedFunction gf; - gf.function_name = a->name; - gf.signature = fmt::format("void {}(CPUState* cpu)", a->name); - gf.body = fmt::format( - "{{\n" - " debug_server_log_call_entry(0x{:08X}u);\n" - " psx_alias_body_{:08X}(cpu, 0x{:08X}u); /* alias entry into host func_{:08X} */\n" - "}}\n", - a->start_addr, host, a->start_addr, host); - gf.full_code = (i == 0 ? body.str() + "\n" : std::string()) + - gf.signature + "\n" + gf.body; - gf.line_count = std::count(gf.full_code.begin(), gf.full_code.end(), '\n'); - results.push_back(std::move(gf)); - } - return results; -} - -std::vector CodeGenerator::generate_all_functions( - const std::vector& functions, - const std::map& cfgs) { - - std::vector results; - results.reserve(functions.size()); - - // CPS (§25): rebuild the continuation->owner map for this full pass so the - // game dispatch table (psx_dispatch_game_compiled) reflects exactly the - // functions emitted here (the final generate_file pass is authoritative). - cps_continuation_owner_.clear(); - alias_body_decls_.clear(); - - fmt::print("\n=== C Code Generation ===\n\n"); - fmt::print("Generating C code for {} functions...\n", functions.size()); - - // Build address -> function name map for fallthrough detection. - // When a function ends without explicit control flow (ControlFlowType::None) - // and the very next address is another function's start, it's a MIPS - // fall-through: the recompiler must emit a tail call to the continuation. - std::map func_name_by_addr; - for (const Function& f : functions) { - func_name_by_addr[f.start_addr] = f.name; - } - - // Group overlapping-alias entries by host: each group is emitted as one - // shared static body plus per-entry wrappers (see generate_alias_group), - // not as per-alias duplicates of the host's blocks. - std::map> alias_groups; - for (const Function& f : functions) { - if (f.alias_walk_lo != 0) alias_groups[f.alias_walk_lo].push_back(&f); - } - std::set alias_groups_emitted; - - int total_lines = 0; - - for (const Function& func : functions) { - if (func.alias_walk_lo != 0) { - if (!alias_groups_emitted.insert(func.alias_walk_lo).second) continue; - const auto& group = alias_groups.at(func.alias_walk_lo); - if (cfgs.count(func.start_addr) == 0) continue; - const ControlFlowGraph& cfg = cfgs.at(func.start_addr); - - // Host-end fallthrough target (all group members share end_addr). - std::string fallthrough_name; - auto ft_it = func_name_by_addr.find(func.end_addr); - if (ft_it != func_name_by_addr.end()) fallthrough_name = ft_it->second; - - auto group_funcs = generate_alias_group(group, cfg, fallthrough_name); - for (auto& gf : group_funcs) { - total_lines += gf.line_count; - results.push_back(std::move(gf)); - } - continue; - } - if (func.is_data_section) { - GeneratedFunction stub; - stub.function_name = func.name; - stub.signature = fmt::format("void {}(CPUState* cpu)", func.name); - stub.body = fmt::format( - "{{\n psx_unknown_dispatch(cpu, 0x{:08X}u, 0x{:08X}u);\n}}\n", - func.start_addr, func.start_addr & 0x1FFFFFFFu); - stub.full_code = stub.signature + "\n" + stub.body; - stub.line_count = 4; - stub.dispatchable = false; - results.push_back(stub); - continue; - } - - if (cfgs.count(func.start_addr) == 0) { - continue; // Skip if no CFG - } - - const ControlFlowGraph& cfg = cfgs.at(func.start_addr); - - // Detect fallthrough: is there a function immediately after this one? - std::string fallthrough_name; - uint32_t next_addr = func.start_addr + func.size; - auto ft_it = func_name_by_addr.find(next_addr); - if (ft_it != func_name_by_addr.end()) { - fallthrough_name = ft_it->second; - } - - GeneratedFunction gen_func = generate_function(func, cfg, fallthrough_name); - - // PSX_EMIT_PROD (DEFAULT ON): a "function" whose body is overwhelmingly - // untranslatable (TODO-opcode) words is a data region that discovery's - // primary-opcode-only validity check let slip through as code (valid - // primary opcode, garbage sub-field). Re-emit it as the same compact, - // fail-closed data stub used for is_data_section — if it is ever - // dispatched (it should not be: real reachable code never hits a TODO) - // psx_unknown_dispatch fatals LOUDLY rather than silently no-op'ing. - // This is strictly more faithful than the baseline it replaces (which - // emitted a SILENT no-op for the same words). Smoke-validated on Tomba, - // Tomba2, Ape Escape, MMX6. Set PSX_EMIT_PROD=0 to restore the full-scan - // (oracle) emission. Proper productionization moves this judgment UPSTREAM - // into discovery's validity check (sub-field aware) in function_analysis. - static int s_emit_prod = -1; - if (s_emit_prod < 0) { - const char* e = std::getenv("PSX_EMIT_PROD"); - s_emit_prod = (e && *e == '0') ? 0 : 1; // default ON; =0 restores full-scan baseline - } - if (s_emit_prod) { - size_t todo = 0, pos = 0; - while ((pos = gen_func.body.find("TODO: opcode", pos)) != std::string::npos) { todo++; pos += 12; } - pos = 0; - while ((pos = gen_func.body.find("TODO: SPECIAL", pos)) != std::string::npos) { todo++; pos += 13; } - uint32_t instrs = func.size / 4u; - if (instrs > 0 && todo * 2u >= instrs) { // >= 50% untranslatable => data - GeneratedFunction data_stub; - data_stub.function_name = func.name; - data_stub.signature = fmt::format("void {}(CPUState* cpu)", func.name); - data_stub.body = fmt::format( - "{{\n psx_unknown_dispatch(cpu, 0x{:08X}u, 0x{:08X}u);\n}}\n", - func.start_addr, func.start_addr & 0x1FFFFFFFu); - data_stub.full_code = data_stub.signature + "\n" + data_stub.body; - data_stub.line_count = 4; - data_stub.dispatchable = false; - total_lines += data_stub.line_count; - results.push_back(std::move(data_stub)); - continue; - } - } - - total_lines += gen_func.line_count; - results.push_back(gen_func); - } - - fmt::print("✓ Generated {} functions\n", results.size()); - fmt::print("✓ Total C code: {} lines\n\n", total_lines); - - return results; -} - -void CodeGenerator::emit_runtime_externs(std::ostream& ss) const { - /* Release inlines debug_server_log_call_entry via debug_server.h - * (included from psx_runtime.h). Debug builds need the extern. */ - ss << "#ifndef PSX_NO_DEBUG_TOOLS\n"; - ss << "extern void debug_server_log_call_entry(uint32_t func_addr);\n"; - ss << "extern void debug_server_cyc_observe(uint32_t block_leader_phys);\n"; - ss << "#endif\n"; - ss << "#ifdef PSX_COSIM\n"; - ss << "extern void cosim_block(uint32_t block_leader_phys);\n"; - ss << "extern void cosim_instr(uint32_t pc);\n"; - ss << "#endif\n"; - ss << "extern int psx_datashard_enter(CPUState* cpu, uint32_t key); /* data-shard replay/capture (data_shards.c) */\n"; - ss << "extern void psx_mod_function_entry(CPUState* cpu, uint32_t address); /* trusted opt-in game-mod hook */\n"; - ss << "extern void disruptor_vertical_camera_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned vertical-camera seam */\n"; - ss << "extern void disruptor_far_rendering_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned far-rendering seam */\n"; - ss << "extern void disruptor_billboard_aspect_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned world-billboard seam */\n"; - ss << "extern void disruptor_backdrop_tiles_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned backdrop-tile seam */\n"; - ss << "extern void disruptor_sprite_depth_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned sprite-depth seam */\n"; - ss << "extern void disruptor_intro_skip_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned intro-skip seam */\n"; - ss << "extern void psx_datashard_ret(CPUState* cpu); /* data-shard capture finalize */\n"; - ss << "extern int psx_vsync_query_hle_enter(CPUState* cpu, uint32_t func, uint32_t counter_addr, uint32_t gpustat_ptr_addr, uint32_t timer1_ptr_addr, uint32_t timer1_cache_addr); /* load_accel.c */\n"; - ss << "extern void psx_ws_sprite_tag(CPUState* cpu); /* widescreen prim tag (gpu.c) */\n"; - ss << "extern void psx_ws_mmx6_bg_stage_init(void); /* ws 2D stage reveal invalidation (gpu.c) */\n"; - ss << "extern int psx_ws_x_margin(void); /* widescreen cull-margin term (gpu.c) */\n"; - ss << "extern int32_t psx_ws_player_x_bound(int32_t vanilla); /* typed gameplay X bound */\n"; - ss << "extern int psx_ws_cull_sltiu(uint32_t sx, uint32_t imm); /* ws auto screen-x cull (gpu.c) */\n"; - ss << "extern int psx_ws_cull_slti(uint32_t sx, uint32_t imm); /* ws cull signed right edge (gpu.c) */\n"; - ss << "extern int psx_ws_cull_slti_lower(uint32_t sx, uint32_t imm); /* ws cull signed lower edge (gpu.c) */\n"; - ss << "extern int32_t psx_ws_cull_lower_value(int32_t vanilla); /* ws manual lower bound (gpu.c) */\n"; - ss << "extern int32_t psx_ws_cull_upper_value(int32_t vanilla); /* ws manual upper bound (gpu.c) */\n"; - ss << "extern uint32_t psx_ws_cull_span_value(uint32_t vanilla); /* ws packed [left,right] span (gpu.c) */\n"; - ss << "extern int psx_ws_cull_bltz(uint32_t v); /* ws cull signed left edge (gpu.c) */\n"; - ss << "extern int psx_ws_cull_vxrange(uint32_t x, uint32_t imm); /* ws masked-u16 X window */\n"; - ss << "extern int32_t psx_ws_depth_bound(int32_t imm); /* ws aspect-scaled far bound */\n"; - ss << "extern int32_t psx_ws_plane_nx(int32_t nx); /* ws side-plane normal-X scale (gpu.c) */\n"; - ss << "extern uint32_t psx_ws_xclip_bound(uint32_t vanilla); /* ws per-prim X reject bound load (gpu.c) */\n"; - ss << "extern uint32_t psx_ws_cull_keep_result(uint32_t vanilla, uint32_t forced); /* ws guarded keep compare (gpu.c) */\n"; - ss << "extern uint32_t psx_ws_aspect_cone_result(uint32_t site, uint32_t vanilla, uint32_t object, int32_t x, int32_t z, int32_t y); /* aspect-aware participation cone */\n"; - ss << "extern uint32_t psx_ws_angle_widen(uint32_t vanilla); /* aspect-scaled 12-bit terrain-frustum half-angle */\n"; - ss << "extern int32_t psx_ws_yaw_cone_offset(int32_t vanilla, uint32_t full_turn); /* aspect-scaled wrapping yaw half-FOV */\n"; - ss << "extern uint32_t psx_ws_horizontal_slt(uint32_t bound, uint32_t horizontal); /* aspect-scaled signed horizontal side plane */\n"; - ss << "extern int psx_ws_backdrop_x(int x); /* widescreen backdrop screenX squash (gpu.c) */\n"; - ss << "extern int psx_ws_project_x(int x); /* classic-wide CPU projection squash (gpu.c) */\n"; - ss << "extern int psx_ws_bg2d_cols(int base); /* ws 2D bg tile-loop widen: col count (gpu.c) */\n"; - ss << "extern int psx_ws_bg2d_startcol(int col, unsigned mask); /* ws 2D bg tile-loop widen: start tile col (gpu.c) */\n"; - ss << "extern int psx_ws_bg2d_startx(int x); /* ws 2D bg tile-loop widen: start screen-x (gpu.c) */\n"; - ss << "extern int psx_ws_bg2d_stream_left(int x); /* ws 2D bg tile-ring streamer: left edge (gpu.c) */\n"; - ss << "extern int psx_ws_bg2d_stream_right(int x); /* ws 2D bg tile-ring streamer: right edge (gpu.c) */\n"; - ss << "extern int psx_ws_bg2d_undercap(int counter, int native_cap); /* ws 2D BG packet cap (gpu.c) */\n"; - ss << "extern int psx_ws_mmx6_bg_bufbase(int addr); /* ws 2D bg packet-buffer relocation (gpu.c) */\n"; - ss << "extern int psx_ws_mmx6_bg_undercap(int counter);/* ws 2D bg per-frame tile cap (gpu.c) */\n"; - ss << "extern int psx_game_option_store(uint32_t addr, int val); /* persisted OPTION restore-at-init (game_options.c) */\n"; - ss << "extern uint32_t psx_ws_backdrop_value(uint32_t orig, int is_end, int window_cols); /* ws backdrop preload (gpu.c) */\n"; - ss << "extern void gte_ws_set_suppress(int on); /* widescreen far-backdrop un-squash (gte.cpp) */\n"; - ss << "extern uint32_t gte_precision_mfc2_pc_read(uint32_t pc, uint32_t insn, uint8_t reg, uint32_t packed); /* reviewed MFC2 snapshot */\n"; - ss << "extern void gte_precision_store_pc_word(uint32_t pc, uint32_t insn, uint32_t addr, uint32_t packed); /* reviewed ordinary SW */\n"; - ss << "extern void gte_precision_scratch_store_pc_word(uint32_t pc, uint32_t insn, uint32_t addr, uint32_t packed); /* reviewed scratchpad SW */\n"; - ss << "extern uint32_t gte_precision_copy_pc_read(uint32_t pc, uint32_t insn, uint8_t gpr, uint32_t addr, uint32_t packed); /* reviewed packet-copy LW */\n"; - ss << "extern void gte_precision_copy_pc_word(uint32_t pc, uint32_t insn, uint8_t gpr, uint32_t addr, uint32_t packed); /* reviewed packet-copy SW */\n"; - ss << "extern uint32_t g_debug_last_store_pc; /* exact PC of the executing SW/SH/SB — wtrace/readtrace producer attribution (debug_server.c) */\n\n"; -} - -void CodeGenerator::emit_unaligned_helpers(std::ostream& ss, bool as_inline) const { - const char* kw = as_inline ? "static inline " : "static "; - - // Emit reference implementations for unaligned memory helpers. - // These implement the MIPS lwl/lwr/swl/swr semantics. - // The runtime may override these by setting the corresponding function pointers - // in CPUState, but these standalone functions provide correct reference behavior. - ss << "/* --- Unaligned memory access helper implementations --- */\n"; - ss << "\n"; - ss << "/* LWL/LWR/SWL/SWR implement little-endian R3000A unaligned word\n"; - ss << " * semantics. Keep these switch tables in sync with psx_interpreter.c\n"; - ss << " * and dirty_ram_interp.c.\n"; - ss << " */\n"; - ss << "\n"; - ss << "/* LWL: Load Word Left - merges high bytes from aligned word into rt.\n"; - ss << " * addr is the effective (possibly unaligned) address.\n"; - ss << " * rt_value is the current value of the destination register.\n"; - ss << " */\n"; - ss << kw << "uint32_t psx_lwl(CPUState* cpu, uint32_t addr, uint32_t rt_value, uint32_t rt, uint32_t reg_mask) {\n"; - ss << " uint32_t word = psx_cyc_load_word(cpu, addr & ~3u, rt, reg_mask); /* full load interlock */\n"; - ss << " switch (addr & 3u) {\n"; - ss << " case 0: return (rt_value & 0x00FFFFFFu) | (word << 24);\n"; - ss << " case 1: return (rt_value & 0x0000FFFFu) | (word << 16);\n"; - ss << " case 2: return (rt_value & 0x000000FFu) | (word << 8);\n"; - ss << " default: return word;\n"; - ss << " }\n"; - ss << "}\n"; - ss << "\n"; - ss << "/* LWR: Load Word Right - merges low bytes from aligned word into rt.\n"; - ss << " * addr is the effective (possibly unaligned) address.\n"; - ss << " * rt_value is the current value of the destination register.\n"; - ss << " */\n"; - ss << kw << "uint32_t psx_lwr(CPUState* cpu, uint32_t addr, uint32_t rt_value, uint32_t rt, uint32_t reg_mask) {\n"; - ss << " uint32_t word = psx_cyc_load_word(cpu, addr & ~3u, rt, reg_mask); /* full load interlock */\n"; - ss << " switch (addr & 3u) {\n"; - ss << " case 0: return word;\n"; - ss << " case 1: return (rt_value & 0xFF000000u) | (word >> 8);\n"; - ss << " case 2: return (rt_value & 0xFFFF0000u) | (word >> 16);\n"; - ss << " default: return (rt_value & 0xFFFFFF00u) | (word >> 24);\n"; - ss << " }\n"; - ss << "}\n"; - ss << "\n"; - ss << "/* SWL: Store Word Left - stores the high bytes of rt_value into memory.\n"; - ss << " * addr is the effective (possibly unaligned) address.\n"; - ss << " * rt_value is the value of the source register.\n"; - ss << " */\n"; - ss << kw << "void psx_swl(CPUState* cpu, uint32_t addr, uint32_t rt_value) {\n"; - ss << " psx_store_cycle_barrier();\n"; - ss << " uint32_t aligned_addr = addr & ~3u;\n"; - ss << " uint32_t word = cpu->read_word(aligned_addr);\n"; - ss << " switch (addr & 3u) {\n"; - ss << " case 0: word = (word & 0xFFFFFF00u) | (rt_value >> 24); break;\n"; - ss << " case 1: word = (word & 0xFFFF0000u) | (rt_value >> 16); break;\n"; - ss << " case 2: word = (word & 0xFF000000u) | (rt_value >> 8); break;\n"; - ss << " default: word = rt_value; break;\n"; - ss << " }\n"; - ss << " cpu->write_word(aligned_addr, word);\n"; - ss << "}\n"; - ss << "\n"; - ss << "/* SWR: Store Word Right - stores the low bytes of rt_value into memory.\n"; - ss << " * addr is the effective (possibly unaligned) address.\n"; - ss << " * rt_value is the value of the source register.\n"; - ss << " */\n"; - ss << kw << "void psx_swr(CPUState* cpu, uint32_t addr, uint32_t rt_value) {\n"; - ss << " psx_store_cycle_barrier();\n"; - ss << " uint32_t aligned_addr = addr & ~3u;\n"; - ss << " uint32_t word = cpu->read_word(aligned_addr);\n"; - ss << " switch (addr & 3u) {\n"; - ss << " case 0: word = rt_value; break;\n"; - ss << " case 1: word = (word & 0x000000FFu) | (rt_value << 8); break;\n"; - ss << " case 2: word = (word & 0x0000FFFFu) | (rt_value << 16); break;\n"; - ss << " default: word = (word & 0x00FFFFFFu) | (rt_value << 24); break;\n"; - ss << " }\n"; - ss << " cpu->write_word(aligned_addr, word);\n"; - ss << "}\n"; - ss << "\n"; -} - -std::string CodeGenerator::build_shared_decls_header(const std::vector& gen_funcs) const { - std::stringstream h; - h << "/* Generated by PSXRecomp - shared declarations for split full.c shards. DO NOT EDIT. */\n"; - h << "#pragma once\n"; - h << "#include \"psx_runtime.h\"\n\n"; - emit_runtime_externs(h); - emit_unaligned_helpers(h, /*as_inline=*/true); - if (!gen_funcs.empty()) { - h << "/* Forward declarations for all recompiled functions */\n"; - for (const auto& gf : gen_funcs) h << gf.signature << ";\n"; - for (const auto& decl : alias_body_decls_) h << decl << ";\n"; - h << "\n"; - } - return h.str(); -} - -std::string CodeGenerator::generate_file( - const std::vector& functions, - const std::map& cfgs) { - - std::stringstream ss; - - // File header - ss << "/* Generated by PSXRecomp - PlayStation 1 Static Recompiler */\n\n"; - - // Include the generic PSX runtime header. - // This provides CPUState, GTE/trap declarations, and call_by_address(). - ss << "#include \"psx_runtime.h\"\n\n"; - emit_runtime_externs(ss); - emit_unaligned_helpers(ss, /*as_inline=*/false); - - // Make mutable copies for potential function splitting - std::vector functions_mut(functions); - std::map cfgs_mut(cfgs); - - // Build initial set of known functions - std::set known_addrs; - for (const auto& func : functions_mut) { - known_addrs.insert(func.start_addr); - } - - // ---- Pre-pass: find mid-function branch/jump targets and split ---- - // Scan all CFGs for branch/jump targets that fall within a function's - // range but are NOT at any function start. Split containing functions - // at discovered targets. Iterate until convergence (no new mid-targets - // found), since splitting creates new pieces whose branches may target - // other mid-piece addresses. Each subsequent pass only scans CFGs that - // were rebuilt in the prior pass. - // - // Previously hard-capped at 3 iterations; Tomba's `func_800905DC` had - // 2 mid-func targets (0x800905E4, 0x80090600) that needed a 4th pass. - // The unconverged stragglers fell through to the - // `call_by_address(cpu, 0xX); return;` emit path (code_generator.cpp - // lines 970/981/998), which at runtime missed the dispatch table and - // hit `psx_unknown_dispatch`. Surfaced by Phase B2 audit. The 16-pass - // cap is a safety limit; in practice each pass strictly reduces the - // remaining target set so convergence is fast. - // - // Overlay exact mode disables this: overlay branch targets are basic-block - // labels, not callable function entries, so splitting them into standalone - // functions produces broken dispatch — the mid-function-seed failure mode. - if (config_.split_mid_function_targets) { - std::set cfgs_to_scan; // Which CFGs to scan (empty = all) - uint32_t exe_start = exe_.header.load_address; - uint32_t exe_end = exe_.end_address(); - int total_new = 0; - // Safety cap only — the loop must run to convergence. Unconverged - // targets emit `call_by_address(mid-func); return;` which misses the - // dispatch table at runtime. 16 proved too low once data-scan - // promotions and alias entries widened the function set. - const int MAX_PASSES = 256; - bool converged = false; - - for (int pass = 0; pass < MAX_PASSES; pass++) { - std::set mid_targets; - - // Scan either all CFGs (pass 0) or only rebuilt CFGs (pass 1+) - auto scan_cfg = [&](const ControlFlowGraph& cfg) { - for (const auto& [baddr, block] : cfg.blocks) { - auto check_target = [&](uint32_t target) { - if (target == 0) return; - if (cfg.blocks.count(target)) return; - if (known_addrs.count(target)) return; - if (target < exe_start || target >= exe_end) return; - if (target & 3) return; - mid_targets.insert(target); - }; - - if (block.exit_instr.type == ControlFlowType::Branch) { - check_target(block.exit_instr.target); - check_target(block.exit_instr.address + 8); - } else if (block.exit_instr.type == ControlFlowType::Jump) { - check_target(block.exit_instr.target); - } else if (block.exit_instr.type == ControlFlowType::JumpLink || - block.exit_instr.type == ControlFlowType::JumpLinkReg) { - // CPS jal/jalr publishes $ra = addr+8. When the delay slot - // was split into another piece, that return PC is often a - // mid-block address (not a function start / block leader) and - // was never registered — MotK 0x8001E7F0 fell to dirty_ram. - check_target(block.exit_instr.address + 8); - } - } - }; - - if (cfgs_to_scan.empty()) { - for (const auto& [cfg_addr, cfg] : cfgs_mut) scan_cfg(cfg); - } else { - for (uint32_t addr : cfgs_to_scan) { - auto it = cfgs_mut.find(addr); - if (it != cfgs_mut.end()) scan_cfg(it->second); - } - } - - if (mid_targets.empty()) { converged = true; break; } - - fmt::print("Pre-pass {}: {} mid-function branch targets\n", pass + 1, mid_targets.size()); - - // Sort functions for binary search. Alias entries overlap their - // host's range and must never be treated as the containing - // function of a mid-target (that would split/truncate the alias - // instead of the host) — exclude them from containment. - std::sort(functions_mut.begin(), functions_mut.end(), - [](const Function& a, const Function& b) { return a.start_addr < b.start_addr; }); - std::vector func_starts; - for (const auto& f : functions_mut) { - if (f.alias_walk_lo == 0) func_starts.push_back(f.start_addr); - } - - // Group targets by containing function. Some real compiler layouts - // branch past an early jr-ra return into the bytes before the next - // discovered function. Those targets are not "mid-function" by the - // return-scanner's range, but they are executable and must still be - // dispatchable CPS pieces. - std::map> splits_by_func; - std::set gap_targets; - for (uint32_t target : mid_targets) { - auto it = std::upper_bound(func_starts.begin(), func_starts.end(), target); - if (it == func_starts.begin()) { - gap_targets.insert(target); - continue; - } - --it; - auto fit = std::find_if(functions_mut.begin(), functions_mut.end(), - [&](const Function& f) { return f.start_addr == *it; }); - if (fit != functions_mut.end() && target > fit->start_addr && target < fit->end_addr) { - splits_by_func[*it].push_back(target); - } else { - gap_targets.insert(target); - } - } - - // Split each affected function - std::vector new_funcs; - std::set affected; - - for (uint32_t target : gap_targets) { - auto next_it = std::upper_bound(func_starts.begin(), func_starts.end(), target); - uint32_t gap_end = (next_it != func_starts.end()) ? *next_it : exe_end; - if (target >= gap_end) continue; - - Function nf; - nf.start_addr = target; - nf.end_addr = gap_end; - nf.size = nf.end_addr - nf.start_addr; - nf.name = fmt::format("func_{:08X}", nf.start_addr); - nf.has_prologue = false; - nf.has_epilogue = false; - nf.stack_frame_size = 0; - nf.is_data_section = false; - new_funcs.push_back(nf); - known_addrs.insert(nf.start_addr); - affected.insert(nf.start_addr); - } - - for (auto& [func_start, targets] : splits_by_func) { - std::sort(targets.begin(), targets.end()); - - for (auto& f : functions_mut) { - if (f.start_addr != func_start) continue; - if (targets.back() >= f.end_addr) break; - - uint32_t orig_end = f.end_addr; - bool orig_data = f.is_data_section; - - f.end_addr = targets[0]; - f.size = f.end_addr - f.start_addr; - affected.insert(f.start_addr); - - for (size_t ti = 0; ti < targets.size(); ti++) { - Function nf; - nf.start_addr = targets[ti]; - nf.end_addr = (ti + 1 < targets.size()) ? targets[ti + 1] : orig_end; - nf.size = nf.end_addr - nf.start_addr; - nf.name = fmt::format("func_{:08X}", nf.start_addr); - nf.has_prologue = false; - nf.has_epilogue = false; - nf.stack_frame_size = 0; - nf.is_data_section = orig_data; - new_funcs.push_back(nf); - known_addrs.insert(nf.start_addr); - affected.insert(nf.start_addr); - } - break; - } - } - - functions_mut.insert(functions_mut.end(), new_funcs.begin(), new_funcs.end()); - std::sort(functions_mut.begin(), functions_mut.end(), - [](const Function& a, const Function& b) { return a.start_addr < b.start_addr; }); - - // Rebuild CFGs for affected functions - ControlFlowAnalyzer cfg_analyzer(exe_); - cfgs_to_scan.clear(); - for (auto& f : functions_mut) { - if (affected.count(f.start_addr)) { - cfgs_mut[f.start_addr] = cfg_analyzer.analyze_function(f); - cfgs_to_scan.insert(f.start_addr); - } - } - - total_new += (int)new_funcs.size(); - fmt::print(" Created {} new entry points\n", new_funcs.size()); - } - - if (total_new > 0) { - fmt::print("Pre-pass total: {} new entry points, {} functions now\n", - total_new, functions_mut.size()); - } - if (!converged) { - fmt::print("WARNING: mid-function split pre-pass did NOT converge after {} passes; " - "remaining targets will dispatch-miss at runtime\n", MAX_PASSES); - } - } - - set_known_functions(known_addrs); - - // Generate all functions first (to collect names) - auto gen_funcs = generate_all_functions(functions_mut, cfgs_mut); - - // Forward declarations for all functions - if (!gen_funcs.empty()) { - ss << "/* Forward declarations */\n"; - for (const auto& gen_func : gen_funcs) { - ss << gen_func.signature << ";\n"; - } - for (const auto& decl : alias_body_decls_) ss << decl << ";\n"; - ss << "\n"; - } - - // Function definitions - for (const auto& gen_func : gen_funcs) { - ss << gen_func.full_code << "\n"; - } - - // Stash for the split-shard writer (main_psx); copy is intentional so the - // caller can consume it after generate_file() returns the monolith text. - last_gen_funcs_ = gen_funcs; - - // Preserve the exact post-split CFG inventory used by the emitted C. The - // dispatch generator consumes this so synthesized fallthrough functions - // receive the same byte-precise native-validity ranges as ordinary entries. - last_ranges_manifest_ = generate_ranges_manifest(functions_mut, cfgs_mut); - - return ss.str(); -} - -std::string CodeGenerator::generate_ranges_manifest( - const std::vector& functions, - const std::map& cfgs) { - - std::stringstream ss; - ss << "# psxrecomp overlay code-range manifest v1\n"; - ss << "# F one per function (virtual entry address)\n"; - ss << "# R one per coalesced code range (hex, virtual)\n"; - - for (const auto& func : functions) { - auto it = cfgs.find(func.start_addr); - if (it == cfgs.end()) continue; - const ControlFlowGraph& cfg = it->second; - - // Byte intervals [start_addr, end_addr+4) per block (end_addr is the - // last instruction, inclusive). - std::vector> iv; - for (const auto& [baddr, blk] : cfg.blocks) { - (void)baddr; - uint32_t lo = blk.start_addr; - uint32_t hi = blk.end_addr + 4u; - /* translate_basic_block clones an out-of-block delay slot when the - * control-flow instruction itself is the block's last word. That - * cloned instruction is compiled code too: include it in the exact - * validity range so its bytes participate in the candidate CRC and - * page-generation watch. Otherwise a later write to the slot could - * leave stale native semantics callable. */ - if (blk.exit_instr.has_delay_slot && - blk.exit_instr.type != ControlFlowType::None && - blk.exit_instr.address == blk.end_addr && hi <= UINT32_MAX - 4u) { - hi += 4u; - } - if (hi > lo) iv.emplace_back(lo, hi); - } - if (iv.empty()) continue; - - // Merge overlapping/adjacent intervals. A gap > 0 between blocks is - // non-code (e.g. a jump table) and is intentionally left out. - std::sort(iv.begin(), iv.end()); - std::vector> merged; - for (const auto& r : iv) { - if (!merged.empty() && r.first <= merged.back().second) { - if (r.second > merged.back().second) merged.back().second = r.second; - } else { - merged.push_back(r); - } - } - - ss << fmt::format("F {:08X}\n", func.start_addr); - for (const auto& r : merged) - ss << fmt::format("R {:08X} {:X}\n", r.first, r.second - r.first); - } - return ss.str(); -} - -} // namespace PSXRecomp diff --git a/psxrecomp-overlay/recompiler/src/config_loader.cpp b/psxrecomp-overlay/recompiler/src/config_loader.cpp deleted file mode 100644 index 5f99a33..0000000 --- a/psxrecomp-overlay/recompiler/src/config_loader.cpp +++ /dev/null @@ -1,2814 +0,0 @@ -// config_loader.cpp — see config_loader.h for the contract. - -#include "config_loader.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#ifdef _WIN32 -#ifndef NOMINMAX -#define NOMINMAX -#endif -#include -#else -#include -#endif - -#include "bios_rom_alias.h" -#include "fmt/format.h" -#include "ps1_exe_parser.h" - -// toml11 is header-only. -#define TOML11_USE_UNRELEASED_TOML_FEATURES -#include "toml.hpp" - -namespace PSXRecompV4 { - -namespace fs = std::filesystem; - -namespace { - -struct ConfigHash { - uint32_t value = 2166136261u; // FNV-1a 32 - - void byte(uint8_t v) { - value ^= v; - value *= 16777619u; - } - void u32(uint32_t v) { - byte((uint8_t)(v >> 0)); - byte((uint8_t)(v >> 8)); - byte((uint8_t)(v >> 16)); - byte((uint8_t)(v >> 24)); - } - void tag(const char *s) { - while (*s) byte((uint8_t)*s++); - byte(0); - } - void words(const char *name, std::vector values) { - tag(name); - std::sort(values.begin(), values.end()); - values.erase(std::unique(values.begin(), values.end()), values.end()); - u32((uint32_t)values.size()); - for (uint32_t value : values) u32(value); - } -}; - -} // namespace - -uint32_t overlay_codegen_config_hash(const GameConfig& c) { - ConfigHash h; - h.tag("psxrecomp-overlay-config-v1"); - - h.words("sprite_tag_funcs", c.ws_sprite_tag_funcs); - h.words("mod_function_entry_funcs", c.mod_function_entry_funcs); - h.words("cull_bias", c.ws_cull_bias_sites); - h.words("cull_range", c.ws_cull_range_sites); - h.words("cull_a1", c.ws_cull_a1_sites); - h.words("cull_screen_x", c.ws_cull_screen_x_sites); - h.words("cull_slti", c.ws_cull_slti_sites); - h.words("cull_slti_lower", c.ws_cull_slti_lower_sites); - h.words("cull_lower_branch", c.ws_cull_lower_branch_sites); - h.words("cull_lower_value", c.ws_cull_lower_value_sites); - h.words("cull_upper_value", c.ws_cull_upper_value_sites); - h.words("cull_span_value", c.ws_cull_span_value_sites); - h.words("cull_bltz", c.ws_cull_bltz_sites); - h.words("cull_negsub", c.ws_cull_negsub_sites); - h.words("cull_vxrange", c.ws_cull_vxrange_sites); - h.words("cull_depth", c.ws_cull_depth_sites); - h.words("cull_plane_nx", c.ws_cull_plane_nx_sites); - h.words("cull_xclip_load", c.ws_cull_xclip_load_sites); - h.words("cull_w_imms", c.ws_cull_w_imms); - h.words("cull_h_imms", c.ws_cull_h_imms); - h.words("backdrop_x", c.ws_backdrop_x_sites); - h.words("backdrop_unsquash", c.ws_backdrop_unsquash_funcs); - - h.tag("flags"); - h.u32(c.ws_auto_screen_x_cull ? 1u : 0u); - h.u32(c.ws_auto_backdrop_preload ? 1u : 0u); - h.u32(c.ws_bg2d_init_func); - h.u32((uint32_t)c.ws_cull_guard_pixels); - h.u32((uint32_t)c.ws_cull_activation_guard_pixels); - - std::vector signed_sites = - c.ws_signed_x_bound_sites; - std::sort(signed_sites.begin(), signed_sites.end(), - [](const auto& a, const auto& b) { - if (a.address != b.address) return a.address < b.address; - return a.expected < b.expected; - }); - h.tag("signed_x_bounds"); - h.u32((uint32_t)signed_sites.size()); - for (const auto& site : signed_sites) { - h.u32(site.address); - h.u32(site.expected); - } - - std::vector keep_sites = c.ws_cull_keep_sites; - std::sort(keep_sites.begin(), keep_sites.end(), - [](const auto& a, const auto& b) { - if (a.address != b.address) return a.address < b.address; - if (a.expected != b.expected) return a.expected < b.expected; - return a.result < b.result; - }); - h.tag("cull_keep"); - h.u32((uint32_t)keep_sites.size()); - for (const auto& site : keep_sites) { - h.u32(site.address); - h.u32(site.expected); - h.u32(site.result); - } - - std::vector angle_sites = c.ws_cull_angle_sites; - std::sort(angle_sites.begin(), angle_sites.end(), - [](const auto& a, const auto& b) { - if (a.address != b.address) return a.address < b.address; - return a.expected < b.expected; - }); - h.tag("cull_angle"); - h.u32((uint32_t)angle_sites.size()); - for (const auto& site : angle_sites) { - h.u32(site.address); - h.u32(site.expected); - } - - std::vector yaw_cone_sites = - c.ws_cull_yaw_cone_sites; - std::sort(yaw_cone_sites.begin(), yaw_cone_sites.end(), - [](const auto& a, const auto& b) { - if (a.address != b.address) return a.address < b.address; - if (a.expected != b.expected) return a.expected < b.expected; - return a.full_turn < b.full_turn; - }); - h.tag("cull_yaw_cone"); - h.u32((uint32_t)yaw_cone_sites.size()); - for (const auto& site : yaw_cone_sites) { - h.u32(site.address); - h.u32(site.expected); - h.u32(site.full_turn); - } - - std::vector horizontal_slt_sites = - c.ws_cull_horizontal_slt_sites; - std::sort(horizontal_slt_sites.begin(), horizontal_slt_sites.end(), - [](const auto& a, const auto& b) { - if (a.address != b.address) return a.address < b.address; - return a.expected < b.expected; - }); - h.tag("cull_horizontal_slt"); - h.u32((uint32_t)horizontal_slt_sites.size()); - for (const auto& site : horizontal_slt_sites) { - h.u32(site.address); - h.u32(site.expected); - } - - std::vector cone_sites = - c.ws_aspect_cone.sites; - std::sort(cone_sites.begin(), cone_sites.end(), - [](const auto& a, const auto& b) { - if (a.address != b.address) return a.address < b.address; - return a.expected < b.expected; - }); - h.tag("aspect_cone"); - h.u32((uint32_t)cone_sites.size()); - for (const auto& site : cone_sites) { - h.u32(site.address); - h.u32(site.expected); - h.u32(site.cosine_threshold); - h.u32(site.object_reg); - h.u32(site.x_reg); - h.u32(site.z_reg); - h.u32(site.y_reg); - h.u32(site.queue_guard ? 1u : 0u); - } - h.u32(c.ws_aspect_cone.forward_addr); - h.u32(c.ws_aspect_cone.object_type_offset); - h.u32(c.ws_aspect_cone.object_reg); - h.u32(c.ws_aspect_cone.x_reg); - h.u32(c.ws_aspect_cone.z_reg); - h.u32(c.ws_aspect_cone.y_reg); - h.u32(c.ws_aspect_cone.hysteresis_pixels); - h.u32(c.ws_aspect_cone.queue_reserve); - for (uint32_t value : c.ws_aspect_cone.queue_count_addrs) h.u32(value); - for (uint32_t value : c.ws_aspect_cone.queue_capacities) h.u32(value); - for (uint32_t value : c.ws_aspect_cone.queue_type_masks) h.u32(value); - - std::vector patches = c.recompiler_patches; - std::sort(patches.begin(), patches.end(), - [](const auto& a, const auto& b) { - const uint32_t aa = recompiler_patch_address_key(a.address); - const uint32_t ba = recompiler_patch_address_key(b.address); - if (aa != ba) return aa < ba; - if (a.expected != b.expected) return a.expected < b.expected; - return a.replacement < b.replacement; - }); - h.tag("instruction_patches"); - h.u32((uint32_t)patches.size()); - for (const auto& patch : patches) { - h.u32(recompiler_patch_address_key(patch.address)); - h.u32(patch.expected); - h.u32(patch.replacement); - } - return h.value; -} - -// Pad mode <-> string. Accepts "hybrid"/"analog"/"digital" (case-insensitive); -// returns `fallback` for anything unrecognised so a typo never silently flips -// the pad type. -int pad_mode_from_string(const std::string& s, int fallback) { - std::string l; - l.reserve(s.size()); - for (char c : s) l.push_back((char)std::tolower((unsigned char)c)); - if (l == "hybrid") return PAD_MODE_HYBRID; - if (l == "analog") return PAD_MODE_ANALOG; - if (l == "digital") return PAD_MODE_DIGITAL; - return fallback; -} - -const char* pad_mode_to_string(int mode) { - switch (mode) { - case PAD_MODE_ANALOG: return "analog"; - case PAD_MODE_DIGITAL: return "digital"; - default: return "hybrid"; - } -} - -// Parse a hex string ("0x...") to uint32_t. Throws on malformed input. -static uint32_t parse_hex(const std::string& s, const std::string& field) { - try { - return static_cast(std::stoul(s, nullptr, 16)); - } catch (const std::exception& ex) { - throw std::runtime_error( - fmt::format("config field '{}': expected hex string, got '{}' ({})", - field, s, ex.what())); - } -} - -// Parse a display aspect "W:H" string (e.g. "4:3", "16:9", "21:9"). Accepts -// only aspects between native 4:3 and 32:9 — narrower than 4:3 has no -// widescreen meaning and ultra-extreme values would squash the GTE X axis -// into unusability. Returns false on malformed input or out-of-range aspect. -static bool parse_aspect_ratio(const std::string& s, int* num, int* den) { - const size_t colon = s.find(':'); - if (colon == std::string::npos || colon == 0 || colon + 1 >= s.size()) - return false; - int n = 0, d = 0; - try { - size_t used = 0; - n = std::stoi(s.substr(0, colon), &used); - if (used != colon) return false; - const std::string rest = s.substr(colon + 1); - d = std::stoi(rest, &used); - if (used != rest.size()) return false; - } catch (const std::exception&) { - return false; - } - if (n <= 0 || d <= 0 || n > 99 || d > 99) return false; - if (3 * n < 4 * d) return false; // narrower than 4:3 - if (9 * n > 32 * d) return false; // wider than 32:9 - *num = n; *den = d; - return true; -} - -// Reject a config path that is not confined to the project directory. -// -// A game.toml is checked in and read on every host, so a path in it must mean -// the same thing everywhere; std::filesystem::path answers by the *host* -// grammar, so a Windows-authored escape passes on POSIX. Scan the raw bytes -// under both separator conventions instead. NUL is rejected outright because -// the runtime hands this value on as a C string — anything past a NUL would -// silently vanish between what is validated and what is used. -static void validate_project_relative(const std::string& value, - const char* field) { - if (value.find('\0') != std::string::npos) { - throw std::runtime_error( - fmt::format("{} must not contain a NUL byte", field)); - } - // Rooted ("/x", "\x"), UNC ("\\server\share"), drive-qualified ("C:/x", - // "C:\x") and drive-relative ("C:x") alike. - const bool rooted = !value.empty() && (value[0] == '/' || value[0] == '\\'); - const bool drive = value.size() >= 2 && value[1] == ':' && - std::isalpha(static_cast(value[0])); - if (rooted || drive) { - throw std::runtime_error( - fmt::format("{} must be project-relative", field)); - } - for (size_t start = 0;;) { - const size_t sep = value.find_first_of("/\\", start); - const size_t end = (sep == std::string::npos) ? value.size() : sep; - if (value.compare(start, end - start, "..") == 0) { - throw std::runtime_error( - fmt::format("{} must stay inside the project", field)); - } - if (sep == std::string::npos) break; - start = sep + 1; - } -} - -// Parse the optional [runtime] block. All fields optional; absent fields -// leave has_* = false on the returned struct. Paths are resolved relative -// to `root` (project root). -static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& root) { - RuntimeConfig rt; - // [localization] language = "en" (top-level; independent of [runtime]). - if (cfg.contains("localization")) { - const toml::value& loc = toml::find(cfg, "localization"); - if (loc.contains("language")) - rt.language = toml::find(loc, "language"); - // `default` is the launcher-facing alias for the pre-selected language. - if (loc.contains("default")) - rt.language = toml::find(loc, "default"); - // Optional launcher dropdown options: languages = [ {code, label}, ... ]. - if (loc.contains("languages") && toml::find(loc, "languages").is_array()) { - for (const auto& e : toml::find(loc, "languages").as_array()) { - if (!e.contains("code")) continue; - RuntimeConfig::LanguageOption lo; - lo.code = toml::find(e, "code"); - lo.label = e.contains("label") ? toml::find(e, "label") - : lo.code; - if (!lo.code.empty()) rt.languages.push_back(std::move(lo)); - } - } - } - if (!cfg.contains("runtime")) return rt; - const toml::value& runtime = toml::find(cfg, "runtime"); - if (runtime.contains("language")) // [runtime].language convenience alias - rt.language = toml::find(runtime, "language"); - - if (runtime.contains("debug_port")) { - const auto port = toml::find(runtime, "debug_port"); - if (port < 0 || port > 65535) { - throw std::runtime_error( - fmt::format("[runtime] debug_port out of range (0..65535): {}", port)); - } - rt.debug_port = static_cast(port); - rt.has_debug_port = true; - } - if (runtime.contains("window_title")) { - rt.window_title = toml::find(runtime, "window_title"); - rt.has_window_title = true; - } - if (runtime.contains("controller")) { - rt.controller = toml::find(runtime, "controller"); - for (char& c : rt.controller) { - c = static_cast(std::tolower(static_cast(c))); - } - if (rt.controller != "digital" && rt.controller != "dualshock") { - throw std::runtime_error( - fmt::format("[runtime] controller must be 'digital' or 'dualshock', got '{}'", - rt.controller)); - } - rt.has_controller = true; - } - if (runtime.contains("memcard_dir")) { - const auto rel = toml::find(runtime, "memcard_dir"); - rt.memcard_dir = fs::absolute(root / rel); - rt.has_memcard_dir = true; - } - if (runtime.contains("disc_speed")) { - rt.disc_speed = toml::find(runtime, "disc_speed"); - rt.has_disc_speed = true; - } - if (runtime.contains("instant_max_per_frame")) { - const auto n = toml::find(runtime, "instant_max_per_frame"); - if (n < 1 || n > 4096) { - throw std::runtime_error(fmt::format( - "[runtime] instant_max_per_frame out of range (1..4096): {}", n)); - } - rt.instant_max_per_frame = static_cast(n); - rt.has_instant_max_per_frame = true; - } - auto parse_warm_cd_route = [&](const toml::value& route, - const std::string& label) { - if (!route.contains("arm_lba") || !route.contains("lbas")) { - throw std::runtime_error( - label + " arm_lba and lbas are required"); - } - const auto arm = toml::find(route, "arm_lba"); - if (arm < 0 || arm > 0x7FFFFFFFll) { - throw std::runtime_error(fmt::format( - "{} arm_lba out of range: {}", label, arm)); - } - RuntimeConfig::WarmCdRoute parsed; - parsed.arm_lba = static_cast(arm); - const auto& lbas = toml::find>(route, "lbas"); - if (lbas.empty() || lbas.size() > 64) { - throw std::runtime_error( - label + " lbas must contain 1..64 entries"); - } - for (const auto lba : lbas) { - if (lba < 0 || lba > 0x7FFFFFFFll) { - throw std::runtime_error(fmt::format( - "{} LBA out of range: {}", label, lba)); - } - parsed.lbas.push_back(static_cast(lba)); - } - if (route.contains("instant_max_per_frame")) { - const auto n = toml::find(route, "instant_max_per_frame"); - if (n < 1 || n > 4096) { - throw std::runtime_error(fmt::format( - "{} instant_max_per_frame out of range (1..4096): {}", - label, n)); - } - parsed.instant_max_per_frame = static_cast(n); - } - for (const auto& existing : rt.warm_cd_routes) { - if (existing.arm_lba == parsed.arm_lba && - existing.lbas.front() == parsed.lbas.front()) { - throw std::runtime_error(fmt::format( - "{} duplicates route arm {} / first LBA {}", label, - parsed.arm_lba, parsed.lbas.front())); - } - } - rt.warm_cd_routes.push_back(std::move(parsed)); - }; - if (runtime.contains("warm_cd_route")) { - std::fprintf(stderr, - "psxrecomp: warning: [runtime.warm_cd_route] is deprecated; " - "use [[runtime.warm_cd_routes]]\n"); - parse_warm_cd_route(toml::find(runtime, "warm_cd_route"), - "[runtime.warm_cd_route]"); - } - if (runtime.contains("warm_cd_routes")) { - const auto& routes = toml::find(runtime, "warm_cd_routes"); - if (!routes.is_array() || routes.as_array().empty() || - routes.as_array().size() > 16) { - throw std::runtime_error( - "[[runtime.warm_cd_routes]] must contain 1..16 routes"); - } - size_t route_index = 0; - for (const auto& route : routes.as_array()) { - if (!route.is_table()) { - throw std::runtime_error( - "[[runtime.warm_cd_routes]] entries must be tables"); - } - parse_warm_cd_route(route, fmt::format( - "[[runtime.warm_cd_routes]] entry {}", route_index++)); - } - } - if (runtime.contains("fast_boot")) { - rt.fast_boot = toml::find(runtime, "fast_boot"); - } - if (runtime.contains("openbios")) { - rt.openbios = toml::find(runtime, "openbios"); - } - if (runtime.contains("bios_hle")) { - rt.bios_hle = toml::find(runtime, "bios_hle"); - } - if (runtime.contains("bios_hle_keep_intro")) { - rt.bios_hle_keep_intro = toml::find(runtime, "bios_hle_keep_intro"); - } - if (runtime.contains("hle_scheduler")) { - rt.hle_scheduler = toml::find(runtime, "hle_scheduler"); - } - if (runtime.contains("overlay_cache")) { - rt.overlay_cache = toml::find(runtime, "overlay_cache"); - } - if (runtime.contains("overlay_capture_history")) { - rt.overlay_capture_history = - toml::find(runtime, "overlay_capture_history"); - } - if (runtime.contains("overlay_capture_persist_dir")) { - rt.overlay_capture_persist_dir = - toml::find(runtime, "overlay_capture_persist_dir"); - validate_project_relative(rt.overlay_capture_persist_dir, - "runtime.overlay_capture_persist_dir"); - } - if (runtime.contains("turbo_loads")) { - rt.turbo_loads = toml::find(runtime, "turbo_loads"); - } - if (runtime.contains("offer_turbo_loads")) { - rt.offer_turbo_loads = - toml::find(runtime, "offer_turbo_loads"); - } - if (runtime.contains("turbo_audio_sink")) { - rt.turbo_audio_sink = toml::find(runtime, "turbo_audio_sink"); - } - if (runtime.contains("idle_skip")) { - rt.idle_skip = toml::find(runtime, "idle_skip"); - } - if (runtime.contains("overlay_autocompile_cmd")) { - rt.overlay_autocompile_cmd = - toml::find(runtime, "overlay_autocompile_cmd"); - rt.has_overlay_autocompile_cmd = !rt.overlay_autocompile_cmd.empty(); - } - if (runtime.contains("overlay_autocompile_cmd_tcc")) { - rt.overlay_autocompile_cmd_tcc = - toml::find(runtime, "overlay_autocompile_cmd_tcc"); - rt.has_overlay_autocompile_cmd_tcc = !rt.overlay_autocompile_cmd_tcc.empty(); - } - if (runtime.contains("overlay_backend")) { - rt.overlay_backend = toml::find(runtime, "overlay_backend"); - } - if (runtime.contains("overlay_native_block")) { - for (const auto& a : toml::find>(runtime, "overlay_native_block")) { - rt.overlay_native_block.push_back(parse_hex(a, "runtime.overlay_native_block")); - } - } - - if (cfg.contains("parappa_timing")) { - const toml::value& timing = toml::find(cfg, "parappa_timing"); - rt.has_parappa_timing = true; - if (timing.contains("mode")) { - rt.parappa_timing_mode = toml::find(timing, "mode"); - for (char& c : rt.parappa_timing_mode) - c = (char)std::tolower((unsigned char)c); - } - auto parse_window = [&](const char *key) -> int { - const auto n = toml::find(timing, key); - if (n < 0 || n > 60) { - throw std::runtime_error(fmt::format( - "[parappa_timing] {} out of range (0..60): {}", key, n)); - } - return (int)n; - }; - if (timing.contains("extra_early")) - rt.parappa_timing_extra_early = parse_window("extra_early"); - if (timing.contains("extra_late")) - rt.parappa_timing_extra_late = parse_window("extra_late"); - } - - // Optional [video] block — visual enhancement options. Kept on the same - // RuntimeConfig so main.cpp consumes them alongside the other knobs. - if (cfg.contains("video")) { - const toml::value& video = toml::find(cfg, "video"); - if (video.contains("supersampling")) { - const auto n = toml::find(video, "supersampling"); - if (n < 1 || n > 8) { - throw std::runtime_error(fmt::format( - "[video] supersampling out of range (1..8): {}", n)); - } - rt.video_supersampling = static_cast(n); - } - if (video.contains("antialiasing")) { - rt.video_antialiasing = toml::find(video, "antialiasing"); - } - if (video.contains("texture_filtering")) { - const auto mode = toml::find(video, "texture_filtering"); - if (mode == "nearest") rt.video_texture_filter = 0; - else if (mode == "bilinear") rt.video_texture_filter = 1; - else throw std::runtime_error(fmt::format( - "[video] texture_filtering must be \"nearest\" or \"bilinear\": {}", mode)); - } - if (video.contains("renderer")) { - const auto mode = toml::find(video, "renderer"); - if (mode == "software") rt.video_renderer = 0; - else if (mode == "opengl") rt.video_renderer = 1; - else if (mode == "vulkan") rt.video_renderer = 2; - else throw std::runtime_error(fmt::format( - "[video] renderer must be \"software\", \"opengl\" or \"vulkan\": {}", mode)); - } - if (video.contains("offer_vulkan")) { - rt.video_offer_vulkan = toml::find(video, "offer_vulkan"); - } - if (video.contains("crt_filter")) { - const auto mode = toml::find(video, "crt_filter"); - if (mode == "raw") rt.video_screen_kind = 0; - else if (mode == "crt") rt.video_screen_kind = 1; - else if (mode == "composite") rt.video_screen_kind = 2; - else if (mode == "trinitron") rt.video_screen_kind = 3; - else throw std::runtime_error(fmt::format( - "[video] crt_filter must be \"raw\"|\"crt\"|\"composite\"|\"trinitron\": {}", - mode)); - } - if (video.contains("auto_skip_fmv")) { - rt.video_auto_skip_fmv = toml::find(video, "auto_skip_fmv"); - } - if (video.contains("offer_skip_fmv")) { - rt.video_offer_skip_fmv = toml::find(video, "offer_skip_fmv"); - } - if (video.contains("fmv_skip_total_table")) { - rt.video_fmv_skip_total_table = - (uint32_t)toml::find(video, "fmv_skip_total_table"); - } - if (video.contains("fmv_skip_movie_id")) { - rt.video_fmv_skip_movie_id = - (uint32_t)toml::find(video, "fmv_skip_movie_id"); - } - if (video.contains("fmv_skip_end_total")) { - rt.video_fmv_skip_end_total = - (int)toml::find(video, "fmv_skip_end_total"); - } - if (video.contains("fmv_skip_no_xa")) { - rt.video_fmv_skip_no_xa = toml::find(video, "fmv_skip_no_xa"); - } - if (video.contains("fmv_skip_no_xa_hold")) { - rt.video_fmv_skip_no_xa_hold = - (int)toml::find(video, "fmv_skip_no_xa_hold"); - if (rt.video_fmv_skip_no_xa_hold < 4 || rt.video_fmv_skip_no_xa_hold > 3600) - throw std::runtime_error( - "[video] fmv_skip_no_xa_hold must be between 4 and 3600 vblanks"); - } - if (video.contains("low_latency_input")) { - rt.video_low_latency_input = toml::find(video, "low_latency_input"); - } - if (video.contains("vsync")) { - const auto mode = toml::find(video, "vsync"); - if (mode == "on" || mode == "vsync") rt.video_vsync = 1; - else if (mode == "off" || mode == "immediate") rt.video_vsync = 0; - else if (mode == "adaptive") rt.video_vsync = -1; - else throw std::runtime_error(fmt::format( - "[video] vsync must be \"on\"|\"off\"|\"immediate\"|\"adaptive\": {}", mode)); - } - if (video.contains("frame_interpolation")) { - rt.video_frame_interpolation = - toml::find(video, "frame_interpolation"); - } - if (video.contains("frame_interpolation_fps")) { - rt.video_frame_interpolation_fps = - toml::find(video, "frame_interpolation_fps"); - if (rt.video_frame_interpolation_fps != 0 && - rt.video_frame_interpolation_fps < 90) - throw std::runtime_error( - "[video] frame_interpolation_fps must be 0 (display) or >= 90"); - } - if (video.contains("offer_frame_interpolation")) { - rt.video_offer_frame_interpolation = - toml::find(video, "offer_frame_interpolation"); - } - if (video.contains("aspect_ratio")) { - const auto mode = toml::find(video, "aspect_ratio"); - int n = 0, d = 0; - if (!parse_aspect_ratio(mode, &n, &d)) - throw std::runtime_error(fmt::format( - "[video] aspect_ratio must be \"W:H\", no narrower than 4:3 " - "and no wider than 32:9 (e.g. \"4:3\", \"16:9\"): {}", mode)); - rt.video_aspect_num = n; - rt.video_aspect_den = d; - } - } - - // Optional [audio] block. - if (cfg.contains("audio")) { - const toml::value& audio = toml::find(cfg, "audio"); - if (audio.contains("buffer_ms")) { - const auto n = toml::find(audio, "buffer_ms"); - if (n < 30 || n > 500) { - throw std::runtime_error(fmt::format( - "[audio] buffer_ms out of range (30..500): {}", n)); - } - rt.audio_buffer_ms = static_cast(n); - } - if (audio.contains("spu_hq")) { - rt.audio_spu_hq = toml::find(audio, "spu_hq"); - } - } - - // Optional [controller] block — game-declared input defaults. - if (cfg.contains("controller")) { - const toml::value& ct = toml::find(cfg, "controller"); - // Legacy boolean form (true->analog, false->digital), read first so the - // new string `*_mode` keys win when both are present. - if (ct.contains("default_analog")) { - const int m = toml::find(ct, "default_analog") - ? PAD_MODE_ANALOG : PAD_MODE_DIGITAL; - rt.default_p1_mode = rt.default_p2_mode = m; - rt.has_default_mode = true; - } - if (ct.contains("p1_analog")) { - rt.default_p1_mode = toml::find(ct, "p1_analog") - ? PAD_MODE_ANALOG : PAD_MODE_DIGITAL; - rt.has_default_mode = true; - } - if (ct.contains("p2_analog")) { - rt.default_p2_mode = toml::find(ct, "p2_analog") - ? PAD_MODE_ANALOG : PAD_MODE_DIGITAL; - rt.has_default_mode = true; - } - // Preferred string form. - if (ct.contains("default_mode")) { - const int m = pad_mode_from_string( - toml::find(ct, "default_mode"), PAD_MODE_HYBRID); - rt.default_p1_mode = rt.default_p2_mode = m; - rt.has_default_mode = true; - } - if (ct.contains("p1_mode")) { - rt.default_p1_mode = pad_mode_from_string( - toml::find(ct, "p1_mode"), PAD_MODE_HYBRID); - rt.has_default_mode = true; - } - if (ct.contains("p2_mode")) { - rt.default_p2_mode = pad_mode_from_string( - toml::find(ct, "p2_mode"), PAD_MODE_HYBRID); - rt.has_default_mode = true; - } - if (ct.contains("allow_hybrid")) { - rt.controller_allow_hybrid = toml::find(ct, "allow_hybrid"); - } - if (ct.contains("lock_mode")) { - rt.controller_lock_mode = toml::find(ct, "lock_mode"); - } - if (ct.contains("lock_device")) { - rt.controller_lock_device = toml::find(ct, "lock_device"); - } - if (ct.contains("deadzone")) { - const auto n = toml::find(ct, "deadzone"); - if (n < 0 || n > 32767) - throw std::runtime_error(fmt::format( - "[controller] deadzone out of range (0..32767): {}", n)); - rt.deadzone = static_cast(n); - rt.has_deadzone = true; - } - if (ct.contains("anti_deadzone")) { - const auto n = toml::find(ct, "anti_deadzone"); - if (n < 0 || n > 32767) - throw std::runtime_error(fmt::format( - "[controller] anti_deadzone out of range (0..32767): {}", n)); - rt.anti_deadzone = static_cast(n); - rt.has_anti_deadzone = true; - } - } - - return rt; -} - -fs::path find_project_root(const fs::path& config_path) { - fs::path cur = fs::absolute(config_path).parent_path(); - const fs::path fallback = cur; - for (int i = 0; i < 8; ++i) { - for (const char* marker : { ".gitignore", ".git", "CMakeLists.txt" }) { - if (fs::exists(cur / marker)) { - return cur; - } - } - const fs::path parent = cur.parent_path(); - if (parent == cur) break; - cur = parent; - } - return fallback; -} - -// Derive the output filename stem from a rom basename. Mirrors the Python -// audit_config.py logic: strip a trailing .BIN/.EXE (case-insensitive) but -// preserve dotted names like "SCUS_942.36" unchanged. -static std::string derive_out_stem(const std::string& rom_basename) { - auto ends_with_ci = [](const std::string& s, const std::string& suffix) { - if (s.size() < suffix.size()) return false; - for (size_t i = 0; i < suffix.size(); ++i) { - char a = s[s.size() - suffix.size() + i]; - char b = suffix[i]; - if (std::tolower(static_cast(a)) != - std::tolower(static_cast(b))) return false; - } - return true; - }; - if (ends_with_ci(rom_basename, ".bin") || ends_with_ci(rom_basename, ".exe")) { - return rom_basename.substr(0, rom_basename.size() - 4); - } - return rom_basename; -} - -BiosConfig load_bios_config(const fs::path& config_path_in) { - const fs::path config_path = fs::absolute(config_path_in); - if (!fs::exists(config_path)) { - throw std::runtime_error( - fmt::format("config file not found: {}", config_path.string())); - } - - const fs::path root = find_project_root(config_path); - - toml::value cfg; - try { - cfg = toml::parse(config_path); - } catch (const toml::syntax_error& ex) { - throw std::runtime_error( - fmt::format("TOML syntax error in {}: {}", config_path.string(), ex.what())); - } - - // A BIOS profile describes an IMAGE — facts about bytes — never a runtime - // preference. This block used to be parsed into BiosConfig::runtime and - // read by nothing, which let bios/OpenBIOS.toml carry a `bios_hle = false` - // that looked authoritative and was inert. Fail loud instead of ignoring: - // a preference put here would never take effect, and believing it had is - // how the OpenBIOS boot-skip regression got explained away. - if (cfg.contains("runtime")) { - throw std::runtime_error(fmt::format( - "{}: a BIOS profile has no [runtime] block — it describes an image, " - "not a preference. Runtime options belong in game.toml / " - "settings.toml. (The BIOS-dependent HLE gating is expressed by " - "[recompiler.runtime_exports]: shell_entry_phys enables the boot-" - "skip, deliver_event_ret enables the kernel-call HLE tier.)", - config_path.string())); - } - - // [program] — bios.toml uses this; some legacy files use [game] - const toml::value* prog_ptr = nullptr; - if (cfg.contains("program")) { - prog_ptr = &toml::find(cfg, "program"); - } else if (cfg.contains("game")) { - prog_ptr = &toml::find(cfg, "game"); - } else { - throw std::runtime_error( - fmt::format("{}: missing [program] (or [game]) block", - config_path.string())); - } - const toml::value& prog = *prog_ptr; - - const std::string name = toml::find(prog, "name"); - const std::string id = prog.contains("id") - ? toml::find(prog, "id") - : std::string{}; - - // BIOS uses `rom`; game files use `exe`. Either is accepted here, but - // BIOS callers should pass a bios config that has `rom`. - std::string rom_field; - if (prog.contains("rom")) { - rom_field = toml::find(prog, "rom"); - } else if (prog.contains("exe")) { - rom_field = toml::find(prog, "exe"); - } else { - throw std::runtime_error( - fmt::format("{}: [program] missing 'rom' or 'exe' field", - config_path.string())); - } - // Tolerate either BIOS filename convention (bare "SCPH1001.BIN" vs - // region-qualified "US-PSX-SCPH1001.BIN") — see bios_rom_alias.h. A no-op - // for game [program].exe fields, which never match a BIOS model token. - const fs::path rom_path = resolve_bios_rom(fs::absolute(root / rom_field)); - - const uint32_t load_address = - parse_hex(toml::find(prog, "load_address"), "program.load_address"); - const uint32_t entry_pc = - prog.contains("entry_pc") - ? parse_hex(toml::find(prog, "entry_pc"), "program.entry_pc") - : load_address; - const uint32_t text_size = - parse_hex(toml::find(prog, "text_size"), "program.text_size"); - - // [program.image] — declared identity (optional) - std::string image_sha256; - bool image_redistributable = false; - if (prog.contains("image")) { - const toml::value& img = toml::find(prog, "image"); - if (img.contains("sha256")) - image_sha256 = toml::find(img, "sha256"); - if (img.contains("redistributable")) - image_redistributable = toml::find(img, "redistributable"); - } - - // [recompiler] - if (!cfg.contains("recompiler")) { - throw std::runtime_error( - fmt::format("{}: missing [recompiler] block", config_path.string())); - } - const toml::value& recomp = toml::find(cfg, "recompiler"); - - if (!recomp.contains("seeds")) { - throw std::runtime_error( - fmt::format("{}: [recompiler] missing 'seeds' field", config_path.string())); - } - const std::string seeds_field = toml::find(recomp, "seeds"); - const fs::path seeds_path = fs::absolute(root / seeds_field); - - const std::string out_dir_field = - recomp.contains("out_dir") - ? toml::find(recomp, "out_dir") - : std::string{"generated"}; - const fs::path out_dir = fs::absolute(root / out_dir_field); - - const bool strict = recomp.contains("strict") - ? toml::find(recomp, "strict") - : true; - - std::string out_stem; - if (recomp.contains("out_stem")) { - out_stem = toml::find(recomp, "out_stem"); - } else { - out_stem = derive_out_stem(fs::path(rom_field).filename().string()); - } - - // [[recompiler.bios_vectors]] — optional array of vector dispatch tables - std::vector bios_vectors; - if (recomp.contains("bios_vectors")) { - const auto& arr = recomp.at("bios_vectors").as_array(); - for (const auto& v : arr) { - BiosVectorTable bvt; - bvt.ram_addr = parse_hex( - toml::find(v, "ram_addr"), "bios_vectors.ram_addr"); - bvt.index_reg = static_cast( - toml::find(v, "index_reg")); - bvt.table_rom_addr = parse_hex( - toml::find(v, "table_rom_addr"), "bios_vectors.table_rom_addr"); - bvt.table_count = static_cast( - toml::find(v, "table_count")); - bvt.table_ram_addr = v.contains("table_ram_addr") - ? parse_hex(toml::find(v, "table_ram_addr"), - "bios_vectors.table_ram_addr") - : 0u; - bios_vectors.push_back(bvt); - } - } - - // [[recompiler.bios_aliases]] — fixed-target RAM trampolines - std::vector bios_aliases; - if (recomp.contains("bios_aliases")) { - const auto& arr = recomp.at("bios_aliases").as_array(); - for (const auto& v : arr) { - BiosAlias ba; - ba.ram_addr = parse_hex(toml::find(v, "ram_addr"), - "bios_aliases.ram_addr"); - ba.target_key = parse_hex(toml::find(v, "target_key"), - "bios_aliases.target_key"); - bios_aliases.push_back(ba); - } - } - - // [recompiler.address_model] — the BIOS's boot-time ROM->RAM code copies. - // Optional: absent (or an empty copy list) means the BIOS runs entirely - // from ROM and normalization degenerates to the KSEG mask. Semantic - // invariants (window overlap, bless uniqueness, alignment) are enforced - // in BiosAddressModel::from_config, not here. - std::vector address_copies; - if (recomp.contains("address_model")) { - const toml::value& am = toml::find(recomp, "address_model"); - if (am.contains("normalize_mask")) { - const uint32_t mask = parse_hex( - toml::find(am, "normalize_mask"), - "address_model.normalize_mask"); - // Only the KSEG strip is implemented; refuse anything else rather - // than silently mis-normalizing (RELOCATION_MANIFEST_FORMAT.md - // echo-and-check). - if (mask != 0x1FFFFFFFu) { - throw std::runtime_error(fmt::format( - "{}: address_model.normalize_mask must be 0x1FFFFFFF", - config_path.string())); - } - } - if (am.contains("copy")) { - for (const auto& v : am.at("copy").as_array()) { - BiosAddrCopy c; - c.name = toml::find(v, "name"); - c.rom_lo = parse_hex(toml::find(v, "rom_lo"), - "address_model.copy.rom_lo"); - c.rom_hi = parse_hex(toml::find(v, "rom_hi"), - "address_model.copy.rom_hi"); - c.ram_lo = parse_hex(toml::find(v, "ram_lo"), - "address_model.copy.ram_lo"); - c.runtime_base = parse_hex( - toml::find(v, "runtime_base"), - "address_model.copy.runtime_base"); - const std::string key = - toml::find(v, "dispatch_key"); - if (key == "ram") c.key_is_ram = true; - else if (key == "rom") c.key_is_ram = false; - else { - throw std::runtime_error(fmt::format( - "{}: address_model.copy '{}': dispatch_key must be " - "\"ram\" or \"rom\", got '{}'", - config_path.string(), c.name, key)); - } - if (v.contains("kernel_bless")) - c.kernel_bless = toml::find(v, "kernel_bless"); - address_copies.push_back(std::move(c)); - } - } - } - - // [[recompiler.install_slots]] — kernel-RAM PCs the BIOS overwrites with - // dispatch stubs at runtime. - std::vector install_slots; - if (recomp.contains("install_slots")) { - for (const auto& v : recomp.at("install_slots").as_array()) { - install_slots.push_back(parse_hex( - toml::find(v, "ram_addr"), - "install_slots.ram_addr")); - } - } - - // [recompiler.runtime_exports] — per-image HLE anchors couriered into the - // generated C. Absent keys stay 0 = structurally unavailable. - uint32_t shell_entry_phys = 0, deliver_event_ret = 0; - if (recomp.contains("runtime_exports")) { - const toml::value& rx = toml::find(recomp, "runtime_exports"); - if (rx.contains("shell_entry_phys")) - shell_entry_phys = parse_hex( - toml::find(rx, "shell_entry_phys"), - "runtime_exports.shell_entry_phys"); - if (rx.contains("deliver_event_ret")) - deliver_event_ret = parse_hex( - toml::find(rx, "deliver_event_ret"), - "runtime_exports.deliver_event_ret"); - } - - return BiosConfig{ - /*config_path*/ config_path, - /*project_root*/ root, - /*name*/ name, - /*id*/ id, - /*rom_path*/ rom_path, - /*load_address*/ load_address, - /*entry_pc*/ entry_pc, - /*text_size*/ text_size, - /*image_sha256*/ image_sha256, - /*image_redistributable*/ image_redistributable, - /*seeds_path*/ seeds_path, - /*out_dir*/ out_dir, - /*strict*/ strict, - /*out_stem*/ out_stem, - /*bios_vectors*/ std::move(bios_vectors), - /*bios_aliases*/ std::move(bios_aliases), - /*address_copies*/ std::move(address_copies), - /*install_slots*/ std::move(install_slots), - /*shell_entry_phys*/ shell_entry_phys, - /*deliver_event_ret*/ deliver_event_ret, - }; -} - -GameConfig load_game_config(const fs::path& config_path_in) { - const fs::path config_path = fs::absolute(config_path_in); - if (!fs::exists(config_path)) { - throw std::runtime_error( - fmt::format("game config not found: {}", config_path.string())); - } - const fs::path root = find_project_root(config_path); - - toml::value cfg; - try { - cfg = toml::parse(config_path); - } catch (const toml::syntax_error& ex) { - throw std::runtime_error( - fmt::format("TOML syntax error in {}: {}", config_path.string(), ex.what())); - } - - // [game] (preferred for game configs) or [program] - const toml::value* prog_ptr = nullptr; - if (cfg.contains("game")) { - prog_ptr = &toml::find(cfg, "game"); - } else if (cfg.contains("program")) { - prog_ptr = &toml::find(cfg, "program"); - } else { - throw std::runtime_error( - fmt::format("{}: missing [game] (or [program]) block", - config_path.string())); - } - const toml::value& game = *prog_ptr; - - const std::string name = toml::find(game, "name"); - const std::string id = game.contains("id") - ? toml::find(game, "id") - : std::string{}; - const std::string region = game.contains("region") - ? toml::find(game, "region") - : std::string{}; - const int players = game.contains("players") - ? (int)toml::find(game, "players") - : 1; - - std::string exe_field; - if (game.contains("exe")) { - exe_field = toml::find(game, "exe"); - } else if (game.contains("rom")) { - exe_field = toml::find(game, "rom"); - } else { - throw std::runtime_error( - fmt::format("{}: [game] missing 'exe' or 'rom' field", config_path.string())); - } - const fs::path exe_path = fs::absolute(root / exe_field); - - // Auto-detect EXE header values for any field not explicitly set in TOML. - // Parses the PS-X EXE header once and fills in load_address, entry_pc, - // text_size, and stack_base from the binary. If the EXE cannot be read - // (malformed header, file not found) and a required field is missing - // from TOML, the error surfaces when the EXE parse itself fails. - struct ExeHeaderAuto { - bool ok = false; - uint32_t load_address = 0; - uint32_t entry_pc = 0; - uint32_t text_size = 0; - uint32_t stack_base = 0; - }; - ExeHeaderAuto auto_hdr; - // We only parse the EXE header if at least one field is missing from TOML. - const bool need_auto = - !game.contains("load_address") || !game.contains("entry_pc") || - !game.contains("text_size") || !game.contains("stack_base"); - if (need_auto) { - std::string err; - if (auto exe = PSXRecomp::PS1ExeParser::parse_file(exe_path, err)) { - auto_hdr.ok = true; - auto_hdr.load_address = exe->load_address(); - auto_hdr.entry_pc = exe->entry_point(); - auto_hdr.text_size = exe->code_size(); - auto_hdr.stack_base = exe->header.stack_base; - // Round text_size up to next 4K page if it's not already - // page-aligned (the runtime's overlay region floor uses this - // as a guard, and the text segment in RAM is always fully - // page-reserved even if the EXE doesn't fill the last page). - if (auto_hdr.text_size & 0xFFF) { - auto_hdr.text_size = (auto_hdr.text_size + 0xFFF) & ~0xFFFu; - } - } - } - - const uint32_t load_address = - game.contains("load_address") - ? parse_hex(toml::find(game, "load_address"), "game.load_address") - : (auto_hdr.ok ? auto_hdr.load_address : []() -> uint32_t { - throw std::runtime_error( - "game.toml: missing 'load_address' in [game] and could not " - "auto-detect from EXE header"); - }()); - const uint32_t entry_pc = - game.contains("entry_pc") - ? parse_hex(toml::find(game, "entry_pc"), "game.entry_pc") - : (auto_hdr.ok ? auto_hdr.entry_pc : load_address); - const uint32_t text_size = - game.contains("text_size") - ? parse_hex(toml::find(game, "text_size"), "game.text_size") - : (auto_hdr.ok ? auto_hdr.text_size : []() -> uint32_t { - throw std::runtime_error( - "game.toml: missing 'text_size' in [game] and could not " - "auto-detect from EXE header"); - }()); - const uint32_t stack_base = - game.contains("stack_base") - ? parse_hex(toml::find(game, "stack_base"), "game.stack_base") - : (auto_hdr.ok && auto_hdr.stack_base != 0 - ? auto_hdr.stack_base - : 0x801FFFF0u); - - // Disc paths: accept either single `disc` or array `discs`. - std::vector discs; - if (game.contains("discs")) { - const auto& arr = toml::find>(game, "discs"); - for (const auto& d : arr) discs.push_back(fs::absolute(root / d)); - } else if (game.contains("disc")) { - const auto& d = toml::find(game, "disc"); - discs.push_back(fs::absolute(root / d)); - } - - // Optional expected disc identity (launcher verification badge). - bool has_disc_crc = false; - uint32_t disc_crc = 0; - std::string disc_sha1; - if (game.contains("disc_crc")) { - disc_crc = parse_hex(toml::find(game, "disc_crc"), "game.disc_crc"); - has_disc_crc = true; - } - if (game.contains("disc_sha1")) { - disc_sha1 = toml::find(game, "disc_sha1"); - // normalize to lowercase hex - for (char& c : disc_sha1) c = (char)std::tolower((unsigned char)c); - } - - // [recompiler] - if (!cfg.contains("recompiler")) { - throw std::runtime_error( - fmt::format("{}: missing [recompiler] block", config_path.string())); - } - const toml::value& recomp = toml::find(cfg, "recompiler"); - - if (!recomp.contains("seeds")) { - throw std::runtime_error( - fmt::format("{}: [recompiler] missing 'seeds' field", config_path.string())); - } - const fs::path seeds_path = - fs::absolute(root / toml::find(recomp, "seeds")); - - fs::path bios_thunks_path; - if (recomp.contains("bios_thunks")) { - bios_thunks_path = - fs::absolute(root / toml::find(recomp, "bios_thunks")); - } - - // [recompiler] bios_config — the BIOS profile this game is built - // against. Optional: main_psx falls back to the SCPH1001 profile - // (bios/SCPH1001.toml, in-repo or under the psxrecomp/ submodule). - fs::path bios_config_path; - if (recomp.contains("bios_config")) { - bios_config_path = - fs::absolute(root / toml::find(recomp, "bios_config")); - } - - const std::string out_dir_field = - recomp.contains("out_dir") - ? toml::find(recomp, "out_dir") - : std::string{"generated"}; - const fs::path out_dir = fs::absolute(root / out_dir_field); - - const bool strict = recomp.contains("strict") - ? toml::find(recomp, "strict") - : true; - - const std::string discovery = - recomp.contains("discovery") - ? toml::find(recomp, "discovery") - : std::string{"whole-image"}; - if (discovery != "whole-image" && discovery != "reachable") { - throw std::runtime_error(fmt::format( - "{}: [recompiler].discovery must be 'whole-image' or 'reachable'", - config_path.string())); - } - - std::string out_stem; - if (recomp.contains("out_stem")) { - out_stem = toml::find(recomp, "out_stem"); - } else { - out_stem = derive_out_stem(fs::path(exe_field).filename().string()); - } - - std::vector recompiler_patches; - if (recomp.contains("patch")) { - const auto& patches = toml::find(recomp, "patch"); - std::set seen_ids; - std::set seen_addresses; - for (const auto& item : patches) { - RecompilerPatch patch; - patch.id = toml::find(item, "id"); - patch.address = parse_hex(toml::find(item, "address"), - "recompiler.patch.address"); - patch.expected = parse_hex(toml::find(item, "expected"), - "recompiler.patch.expected"); - patch.replacement = parse_hex( - toml::find(item, "replacement"), - "recompiler.patch.replacement"); - if (item.contains("note")) { - patch.note = toml::find(item, "note"); - } - - if (patch.id.empty()) { - throw std::runtime_error( - "recompiler.patch.id must not be empty"); - } - if ((patch.address & 3u) != 0u) { - throw std::runtime_error(fmt::format( - "{}: recompiler patch '{}' address 0x{:08X} is not " - "instruction-aligned", - config_path.string(), patch.id, patch.address)); - } - if (!seen_ids.insert(patch.id).second) { - throw std::runtime_error(fmt::format( - "{}: duplicate [[recompiler.patch]] id '{}'", - config_path.string(), patch.id)); - } - const uint32_t address_key = - recompiler_patch_address_key(patch.address); - if (!seen_addresses.insert(address_key).second) { - throw std::runtime_error(fmt::format( - "{}: duplicate [[recompiler.patch]] physical address " - "0x{:08X} (KUSEG/KSEG aliases are one site)", - config_path.string(), address_key)); - } - recompiler_patches.push_back(std::move(patch)); - } - } - - // Optional [widescreen] block — per-game hooks for the widescreen hack. - std::vector ws_sprite_tag_funcs; - uint32_t ws_sprite_anchor_addr = 0; - bool ws_hud_sprt_squash = false; - bool ws_auto_ui_squash = false; - bool ws_full_2d = false; - bool ws_gte_game_mode = false; - uint32_t ws_gameplay_state_addr = 0; - std::vector ws_gameplay_state_values; - bool ws_native_wide = true; - bool ws_nw_hud_corners = false; - uint32_t ws_nw_left_hud_packet_lo = 0; - uint32_t ws_nw_left_hud_packet_hi = 0; - bool ws_nw_backdrop = false; - bool ws_clear_reveal = false; - bool ws_nw_flat_backdrop = false; - bool ws_nw_phase_backdrop = false; - bool ws_nw_textured_edges = false; - int ws_nw_textured_edge_scale = 0; - bool ws_nw_full_mirror = false; - std::vector ws_signed_x_bound_sites; - bool ws_offered = true; - bool vulkan_offered = false; - bool ws_adaptive_view = false; - bool ws_ultrawide_offered = false; - if (cfg.contains("video")) { - const toml::value& video = toml::find(cfg, "video"); - if (video.contains("offer_vulkan")) - vulkan_offered = toml::find(video, "offer_vulkan"); - } - // Optional [data_shards] block — memoized pure-function replay hooks. - std::vector data_shard_funcs; - if (cfg.contains("data_shards")) { - const toml::value& dsv = toml::find(cfg, "data_shards"); - if (dsv.contains("funcs")) { - const auto& arr = toml::find>(dsv, "funcs"); - for (const auto& a : arr) - data_shard_funcs.push_back(parse_hex(a, "data_shards.funcs")); - } - } - // Optional [recompiler] hot_funcs — __attribute__((hot)) on emitted C. - // Optional trusted, statically linked game-mod entry hooks. - std::vector mod_function_entry_funcs; - if (recomp.contains("mod_function_entry_funcs")) { - const auto& arr = toml::find>( - recomp, "mod_function_entry_funcs"); - for (const auto& a : arr) - mod_function_entry_funcs.push_back( - parse_hex(a, "recompiler.mod_function_entry_funcs")); - } - std::vector hot_funcs; - if (recomp.contains("hot_funcs")) { - const auto& arr = toml::find>(recomp, "hot_funcs"); - for (const auto& a : arr) - hot_funcs.push_back(parse_hex(a, "recompiler.hot_funcs")); - } - uint32_t vsync_query_func = 0; - uint32_t vsync_counter_addr = 0; - uint32_t vsync_gpustat_ptr_addr = 0; - uint32_t vsync_timer1_ptr_addr = 0; - uint32_t vsync_timer1_cache_addr = 0; - std::vector vsync_event_horizon_sites; - std::vector vsync_event_horizon_extra_sites; - bool vsync_event_horizon_any = false; - if (cfg.contains("load_accel")) { - const toml::value& lav = toml::find(cfg, "load_accel"); - if (lav.contains("vsync_query")) { - const toml::value& vq = toml::find(lav, "vsync_query"); - const bool has_func = vq.contains("func"); - const bool has_counter = vq.contains("counter_addr"); - const bool has_gpu_ptr = vq.contains("gpustat_ptr_addr"); - const bool has_timer_ptr = vq.contains("timer1_ptr_addr"); - const bool has_timer_cache = vq.contains("timer1_cache_addr"); - const int present = (int)has_func + (int)has_counter + (int)has_gpu_ptr + - (int)has_timer_ptr + (int)has_timer_cache; - if (present != 0 && present != 5) - throw std::runtime_error(fmt::format( - "{}: [load_accel.vsync_query] func, counter_addr, gpustat_ptr_addr, " - "timer1_ptr_addr, and timer1_cache_addr must be set together", - config_path.string())); - if (has_func) { - vsync_query_func = parse_hex( - toml::find(vq, "func"), - "load_accel.vsync_query.func"); - vsync_counter_addr = parse_hex( - toml::find(vq, "counter_addr"), - "load_accel.vsync_query.counter_addr"); - vsync_gpustat_ptr_addr = parse_hex( - toml::find(vq, "gpustat_ptr_addr"), - "load_accel.vsync_query.gpustat_ptr_addr"); - vsync_timer1_ptr_addr = parse_hex( - toml::find(vq, "timer1_ptr_addr"), - "load_accel.vsync_query.timer1_ptr_addr"); - vsync_timer1_cache_addr = parse_hex( - toml::find(vq, "timer1_cache_addr"), - "load_accel.vsync_query.timer1_cache_addr"); - if (vq.contains("event_horizon_sites")) { - const auto& arr = toml::find>( - vq, "event_horizon_sites"); - for (const auto& a : arr) - vsync_event_horizon_sites.push_back(parse_hex( - a, "load_accel.vsync_query.event_horizon_sites")); - } - if (vq.contains("event_horizon_extra_sites")) { - const auto& arr = toml::find>( - vq, "event_horizon_extra_sites"); - for (const auto& a : arr) - vsync_event_horizon_extra_sites.push_back(parse_hex( - a, "load_accel.vsync_query.event_horizon_extra_sites")); - } - if (vq.contains("event_horizon_any")) - vsync_event_horizon_any = toml::find(vq, "event_horizon_any"); - } - } - } - if (cfg.contains("widescreen")) { - const toml::value& ws = toml::find(cfg, "widescreen"); - if (ws.contains("sprite_tag_funcs")) { - const auto& arr = toml::find>(ws, "sprite_tag_funcs"); - for (const auto& a : arr) - ws_sprite_tag_funcs.push_back( - parse_hex(a, "widescreen.sprite_tag_funcs")); - } - if (ws.contains("sprite_anchor_addr")) { - ws_sprite_anchor_addr = parse_hex( - toml::find(ws, "sprite_anchor_addr"), - "widescreen.sprite_anchor_addr"); - } - if (!ws_sprite_tag_funcs.empty() && ws_sprite_anchor_addr == 0) - throw std::runtime_error(fmt::format( - "{}: [widescreen] sprite_tag_funcs requires sprite_anchor_addr", - config_path.string())); - if (ws.contains("hud_sprt_squash")) - ws_hud_sprt_squash = toml::find(ws, "hud_sprt_squash"); - if (ws.contains("auto_ui_squash")) - ws_auto_ui_squash = toml::find(ws, "auto_ui_squash"); - if (ws.contains("full_2d")) - ws_full_2d = toml::find(ws, "full_2d"); - if (ws.contains("gte_game_mode")) - ws_gte_game_mode = toml::find(ws, "gte_game_mode"); - const bool has_gameplay_state_addr = ws.contains("gameplay_state_addr"); - const bool has_gameplay_state_values = ws.contains("gameplay_state_values"); - if (has_gameplay_state_addr != has_gameplay_state_values) - throw std::runtime_error(fmt::format( - "{}: [widescreen] gameplay_state_addr and " - "gameplay_state_values must be set together", - config_path.string())); - if (has_gameplay_state_addr) { - ws_gameplay_state_addr = parse_hex( - toml::find(ws, "gameplay_state_addr"), - "widescreen.gameplay_state_addr"); - const auto& arr = toml::find>( - ws, "gameplay_state_values"); - for (const auto& value : arr) - ws_gameplay_state_values.push_back(parse_hex( - value, "widescreen.gameplay_state_values")); - if (ws_gameplay_state_values.empty()) - throw std::runtime_error(fmt::format( - "{}: [widescreen] gameplay_state_values must not be empty", - config_path.string())); - } - if (ws.contains("native_wide")) - ws_native_wide = toml::find(ws, "native_wide"); - if (ws.contains("nw_hud_corners")) - ws_nw_hud_corners = toml::find(ws, "nw_hud_corners"); - const bool has_nw_left_hud_lo = ws.contains("nw_left_hud_packet_lo"); - const bool has_nw_left_hud_hi = ws.contains("nw_left_hud_packet_hi"); - if (has_nw_left_hud_lo != has_nw_left_hud_hi) - throw std::runtime_error(fmt::format( - "{}: [widescreen] nw_left_hud_packet_lo and " - "nw_left_hud_packet_hi must be set together", - config_path.string())); - if (has_nw_left_hud_lo) { - ws_nw_left_hud_packet_lo = parse_hex( - toml::find(ws, "nw_left_hud_packet_lo"), - "widescreen.nw_left_hud_packet_lo"); - ws_nw_left_hud_packet_hi = parse_hex( - toml::find(ws, "nw_left_hud_packet_hi"), - "widescreen.nw_left_hud_packet_hi"); - if (ws_nw_left_hud_packet_lo >= ws_nw_left_hud_packet_hi) - throw std::runtime_error(fmt::format( - "{}: [widescreen] nw_left_hud_packet range is empty or reversed", - config_path.string())); - } - if (ws.contains("nw_backdrop")) - ws_nw_backdrop = toml::find(ws, "nw_backdrop"); - if (ws.contains("clear_reveal")) - ws_clear_reveal = toml::find(ws, "clear_reveal"); - if (ws.contains("nw_flat_backdrop")) - ws_nw_flat_backdrop = toml::find(ws, "nw_flat_backdrop"); - if (ws.contains("nw_phase_backdrop")) - ws_nw_phase_backdrop = toml::find(ws, "nw_phase_backdrop"); - if (ws.contains("nw_textured_edges")) - ws_nw_textured_edges = toml::find(ws, "nw_textured_edges"); - if (ws.contains("nw_textured_edge_scale")) { - ws_nw_textured_edge_scale = toml::find(ws, "nw_textured_edge_scale"); - if (ws_nw_textured_edge_scale != 0 && - (ws_nw_textured_edge_scale < 100 || ws_nw_textured_edge_scale > 400)) - throw std::runtime_error(fmt::format( - "{}: [widescreen] nw_textured_edge_scale must be 0 or in [100, 400]", - config_path.string())); - } - if (ws.contains("nw_full_mirror")) - ws_nw_full_mirror = toml::find(ws, "nw_full_mirror"); - if (ws.contains("signed_x_bound")) { - std::set seen; - for (const auto& item : toml::find(ws, "signed_x_bound")) { - WidescreenSignedBoundSite site; - site.address = parse_hex(toml::find(item, "address"), - "widescreen.signed_x_bound.address"); - site.expected = parse_hex(toml::find(item, "expected"), - "widescreen.signed_x_bound.expected"); - if ((site.expected >> 26) != 0x0Fu) - throw std::runtime_error(fmt::format( - "{}: [[widescreen.signed_x_bound]] expected must be LUI", - config_path.string())); - if (!seen.insert(site.address & 0x1FFFFFFFu).second) - throw std::runtime_error(fmt::format( - "{}: duplicate [[widescreen.signed_x_bound]] address 0x{:08X}", - config_path.string(), site.address)); - ws_signed_x_bound_sites.push_back(site); - } - } - if (ws.contains("offer")) - ws_offered = toml::find(ws, "offer"); - if (ws.contains("offer_ultrawide")) - ws_ultrawide_offered = toml::find(ws, "offer_ultrawide"); - if (ws.contains("adaptive_view")) - ws_adaptive_view = toml::find(ws, "adaptive_view"); - } - - // Optional [widescreen.cull] block — world-space draw-cull widening. - std::vector ws_cull_bias_sites, ws_cull_range_sites, ws_cull_a1_sites; - std::vector ws_cull_screen_x_sites; - std::vector ws_cull_slti_sites; - std::vector ws_cull_slti_lower_sites; - std::vector ws_cull_lower_branch_sites; - std::vector ws_cull_lower_value_sites; - std::vector ws_cull_upper_value_sites; - std::vector ws_cull_span_value_sites; - std::vector ws_cull_bltz_sites; - std::vector ws_cull_negsub_sites; - std::vector ws_cull_vxrange_sites; - std::vector ws_cull_depth_sites; - std::vector ws_cull_plane_nx_sites; - std::vector ws_cull_xclip_load_sites; - std::vector ws_cull_keep_sites; - std::vector ws_cull_angle_sites; - std::vector ws_cull_yaw_cone_sites; - std::vector - ws_cull_horizontal_slt_sites; - WidescreenAspectConeConfig ws_aspect_cone; - int ws_cull_guard_pixels = 0; - int ws_cull_activation_guard_pixels = 0; - // Cull-signature immediates (screen_w_imms / screen_h_imms). Defaults are - // the original Tomba signature (320-display: 0x140/0x141 + 0xE0/0xF1); a - // game with a different display width overrides them (Ape Escape: 0x181). - std::vector ws_cull_w_imms = { 0x140, 0x141 }; - std::vector ws_cull_h_imms = { 0xE0, 0xF1 }; - bool ws_auto_screen_x_cull = false; - bool ws_auto_backdrop_preload = false; - if (cfg.contains("widescreen")) { - const toml::value& ws = toml::find(cfg, "widescreen"); - if (ws.contains("cull")) { - const toml::value& cull = toml::find(ws, "cull"); - auto load_sites = [&](const char* key, std::vector& out) { - if (!cull.contains(key)) return; - for (const auto& a : toml::find>(cull, key)) - out.push_back(parse_hex(a, fmt::format("widescreen.cull.{}", key))); - }; - load_sites("bias_sites", ws_cull_bias_sites); - load_sites("range_sites", ws_cull_range_sites); - load_sites("a1_sites", ws_cull_a1_sites); - load_sites("screen_x_sites", ws_cull_screen_x_sites); - load_sites("slti_sites", ws_cull_slti_sites); - load_sites("slti_lower_sites", ws_cull_slti_lower_sites); - load_sites("lower_branch_sites", ws_cull_lower_branch_sites); - load_sites("lower_value_sites", ws_cull_lower_value_sites); - load_sites("upper_value_sites", ws_cull_upper_value_sites); - load_sites("span_value_sites", ws_cull_span_value_sites); - load_sites("bltz_sites", ws_cull_bltz_sites); - load_sites("negsub_sites", ws_cull_negsub_sites); - load_sites("vxrange_sites", ws_cull_vxrange_sites); - load_sites("depth_sites", ws_cull_depth_sites); - load_sites("plane_nx_sites", ws_cull_plane_nx_sites); - load_sites("xclip_load_sites", ws_cull_xclip_load_sites); - if (cull.contains("keep")) { - std::set seen; - for (const auto& item : toml::find(cull, "keep")) { - WidescreenCullKeepSite site; - site.address = parse_hex(toml::find(item, "address"), - "widescreen.cull.keep.address"); - site.expected = parse_hex(toml::find(item, "expected"), - "widescreen.cull.keep.expected"); - site.result = (uint32_t)toml::find(item, "result"); - const uint32_t op = site.expected >> 26; - const uint32_t fn = site.expected & 0x3Fu; - if (!((op == 0x0Au) || (op == 0x0Bu) || - (op == 0u && (fn == 0x2Au || fn == 0x2Bu)))) { - throw std::runtime_error(fmt::format( - "{}: [[widescreen.cull.keep]] expected must be " - "SLT/SLTU/SLTI/SLTIU", - config_path.string())); - } - if (site.result > 1u) { - throw std::runtime_error(fmt::format( - "{}: [[widescreen.cull.keep]] result must be 0 or 1", - config_path.string())); - } - if (!seen.insert(site.address & 0x1FFFFFFFu).second) { - throw std::runtime_error(fmt::format( - "{}: duplicate [[widescreen.cull.keep]] address 0x{:08X}", - config_path.string(), site.address)); - } - ws_cull_keep_sites.push_back(site); - } - } - if (cull.contains("angle")) { - std::set seen; - for (const auto& item : - toml::find(cull, "angle")) { - WidescreenAngleSite site; - site.address = parse_hex( - toml::find(item, "address"), - "widescreen.cull.angle.address"); - site.expected = parse_hex( - toml::find(item, "expected"), - "widescreen.cull.angle.expected"); - const uint32_t op = site.expected >> 26; - const uint32_t rs = (site.expected >> 21) & 31u; - const int32_t imm = (int16_t)(site.expected & 0xFFFFu); - if ((op != 0x08u && op != 0x09u) || rs != 0u || - imm <= 0 || imm >= 1024) { - throw std::runtime_error(fmt::format( - "{}: [[widescreen.cull.angle]] expected must be " - "ADDI/ADDIU rt,zero,imm with imm in [1, 1023]", - config_path.string())); - } - if ((site.address & 3u) != 0) - throw std::runtime_error(fmt::format( - "{}: cull-angle address 0x{:08X} is not " - "instruction-aligned", - config_path.string(), site.address)); - if (!seen.insert(site.address & 0x1FFFFFFFu).second) - throw std::runtime_error(fmt::format( - "{}: duplicate cull-angle address 0x{:08X}", - config_path.string(), site.address)); - ws_cull_angle_sites.push_back(site); - } - } - if (cull.contains("yaw_cone")) { - std::set seen; - for (const auto& item : - toml::find(cull, "yaw_cone")) { - WidescreenYawConeSite site; - site.address = parse_hex( - toml::find(item, "address"), - "widescreen.cull.yaw_cone.address"); - site.expected = parse_hex( - toml::find(item, "expected"), - "widescreen.cull.yaw_cone.expected"); - const int64_t full_turn = - toml::find(item, "full_turn"); - if (full_turn < 16 || full_turn > 65536 || - (full_turn & 3) != 0) { - throw std::runtime_error(fmt::format( - "{}: [[widescreen.cull.yaw_cone]] full_turn " - "must be a multiple of four in [16, 65536]", - config_path.string())); - } - site.full_turn = (uint32_t)full_turn; - const uint32_t op = site.expected >> 26; - const uint32_t rs = (site.expected >> 21) & 31u; - const int32_t imm = - (int16_t)(site.expected & 0xFFFFu); - const int32_t magnitude = imm < 0 ? -imm : imm; - if ((op != 0x08u && op != 0x09u) || rs == 0u || - imm == 0 || - magnitude >= (int32_t)(site.full_turn / 4u)) { - throw std::runtime_error(fmt::format( - "{}: [[widescreen.cull.yaw_cone]] expected must " - "be ADDI/ADDIU rt,rs,+/-half_fov with rs != zero " - "and |half_fov| < full_turn/4", - config_path.string())); - } - if ((site.address & 3u) != 0) - throw std::runtime_error(fmt::format( - "{}: yaw-cone address 0x{:08X} is not " - "instruction-aligned", - config_path.string(), site.address)); - if (!seen.insert(site.address & 0x1FFFFFFFu).second) - throw std::runtime_error(fmt::format( - "{}: duplicate yaw-cone address 0x{:08X}", - config_path.string(), site.address)); - ws_cull_yaw_cone_sites.push_back(site); - } - } - if (cull.contains("horizontal_slt")) { - std::set seen; - for (const auto& item : - toml::find(cull, "horizontal_slt")) { - WidescreenHorizontalSltSite site; - site.address = parse_hex( - toml::find(item, "address"), - "widescreen.cull.horizontal_slt.address"); - site.expected = parse_hex( - toml::find(item, "expected"), - "widescreen.cull.horizontal_slt.expected"); - const uint32_t op = site.expected >> 26; - const uint32_t fn = site.expected & 0x3Fu; - const uint32_t shamt = (site.expected >> 6) & 31u; - if (op != 0u || fn != 0x2Au || shamt != 0u) { - throw std::runtime_error(fmt::format( - "{}: [[widescreen.cull.horizontal_slt]] expected " - "must be a signed register-register SLT", - config_path.string())); - } - if ((site.address & 3u) != 0) - throw std::runtime_error(fmt::format( - "{}: horizontal-SLT address 0x{:08X} is not " - "instruction-aligned", - config_path.string(), site.address)); - if (!seen.insert(site.address & 0x1FFFFFFFu).second) - throw std::runtime_error(fmt::format( - "{}: duplicate horizontal-SLT address 0x{:08X}", - config_path.string(), site.address)); - ws_cull_horizontal_slt_sites.push_back(site); - } - } - if (cull.contains("aspect_cone")) { - const toml::value& cone = toml::find(cull, "aspect_cone"); - auto cone_hex = [&](const char *key) { - return parse_hex(toml::find(cone, key), - fmt::format("widescreen.cull.aspect_cone.{}", key)); - }; - ws_aspect_cone.forward_addr = cone_hex("forward_addr"); - const int object_type_offset = - toml::find(cone, "object_type_offset"); - const int object_reg = toml::find(cone, "object_reg"); - const int x_reg = toml::find(cone, "x_reg"); - const int z_reg = toml::find(cone, "z_reg"); - const int y_reg = toml::find(cone, "y_reg"); - const int hysteresis_pixels = - toml::find_or(cone, "hysteresis_pixels", 0); - const int queue_reserve = - toml::find_or(cone, "queue_reserve", 0); - if (object_type_offset < 0 || object_type_offset > 0xFFFF) { - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] " - "object_type_offset must be in [0, 65535]", - config_path.string())); - } - if (object_reg < 0 || object_reg > 31 || - x_reg < 0 || x_reg > 31 || - z_reg < 0 || z_reg > 31 || - y_reg < 0 || y_reg > 31) { - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] register indices " - "must be in [0, 31]", config_path.string())); - } - if (hysteresis_pixels < 0 || hysteresis_pixels > 256 || - queue_reserve < 0 || queue_reserve > 256) { - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] hysteresis_pixels " - "and queue_reserve must be in [0, 256]", - config_path.string())); - } - if ((ws_aspect_cone.forward_addr & 1u) != 0) { - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] forward_addr " - "must be halfword-aligned", config_path.string())); - } - ws_aspect_cone.object_type_offset = - (uint32_t)object_type_offset; - ws_aspect_cone.object_reg = (uint32_t)object_reg; - ws_aspect_cone.x_reg = (uint32_t)x_reg; - ws_aspect_cone.z_reg = (uint32_t)z_reg; - ws_aspect_cone.y_reg = (uint32_t)y_reg; - ws_aspect_cone.hysteresis_pixels = - (uint32_t)hysteresis_pixels; - ws_aspect_cone.queue_reserve = (uint32_t)queue_reserve; - auto load_cone_hex_array = - [&](const char *key, std::array& out) { - const auto values = - toml::find>(cone, key); - if (values.size() != out.size()) - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] {} must " - "contain exactly three values", - config_path.string(), key)); - for (size_t i = 0; i < out.size(); i++) - out[i] = parse_hex( - values[i], - fmt::format("widescreen.cull.aspect_cone.{}", - key)); - }; - auto load_cone_int_array = - [&](const char *key, std::array& out) { - const auto values = - toml::find>(cone, key); - if (values.size() != out.size()) - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] {} must " - "contain exactly three values", - config_path.string(), key)); - for (size_t i = 0; i < out.size(); i++) { - if (values[i] < 0) - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] {} " - "values must be non-negative", - config_path.string(), key)); - out[i] = (uint32_t)values[i]; - } - }; - load_cone_hex_array("queue_count_addrs", - ws_aspect_cone.queue_count_addrs); - load_cone_int_array("queue_capacities", - ws_aspect_cone.queue_capacities); - load_cone_hex_array("queue_type_masks", - ws_aspect_cone.queue_type_masks); - uint32_t used_type_mask = 0; - for (size_t i = 0; i < 3; i++) { - if ((ws_aspect_cone.queue_count_addrs[i] & 1u) != 0) - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] " - "queue_count_addrs values must be " - "halfword-aligned", config_path.string())); - if (ws_aspect_cone.queue_capacities[i] == 0 || - ws_aspect_cone.queue_capacities[i] > 0x7FFFu) - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] " - "queue_capacities values must be in [1, 32767]", - config_path.string())); - if (ws_aspect_cone.queue_reserve >= - ws_aspect_cone.queue_capacities[i]) - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] " - "queue_reserve must be smaller than every " - "queue capacity", config_path.string())); - if ((used_type_mask & - ws_aspect_cone.queue_type_masks[i]) != 0) - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] " - "queue_type_masks must not overlap", - config_path.string())); - used_type_mask |= ws_aspect_cone.queue_type_masks[i]; - } - if (!cone.contains("sites")) - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] requires at least " - "one [[...sites]] entry", config_path.string())); - std::set seen; - for (const auto& item : - toml::find(cone, "sites")) { - WidescreenAspectConeSite site; - site.address = parse_hex( - toml::find(item, "address"), - "widescreen.cull.aspect_cone.sites.address"); - site.expected = parse_hex( - toml::find(item, "expected"), - "widescreen.cull.aspect_cone.sites.expected"); - const uint32_t opcode = site.expected >> 26; - const bool is_slti = opcode == 0x0Au; - const bool is_slt = - opcode == 0u && - (site.expected & 0x3Fu) == 0x2Au && - ((site.expected >> 6) & 0x1Fu) == 0u; - if (!is_slti && !is_slt) - throw std::runtime_error(fmt::format( - "{}: [[widescreen.cull.aspect_cone.sites]] " - "expected must be signed SLTI or SLT", - config_path.string())); - const int threshold = toml::find_or( - item, "cosine_threshold", - is_slti ? (int)(site.expected & 0xFFFFu) : -1); - if (threshold < 1 || threshold > 1023) - throw std::runtime_error(fmt::format( - "{}: [[widescreen.cull.aspect_cone.sites]] " - "cosine_threshold must be in [1, 1023] " - "(and is required for SLT)", - config_path.string())); - site.cosine_threshold = (uint32_t)threshold; - auto site_reg = [&](const char *key, int fallback) { - const int value = item.contains(key) - ? toml::find(item, key) : fallback; - if (value < 0 || value > 31) - throw std::runtime_error(fmt::format( - "{}: [[widescreen.cull.aspect_cone.sites]] " - "{} must be in [0, 31]", - config_path.string(), key)); - return (uint32_t)value; - }; - site.object_reg = site_reg("object_reg", object_reg); - site.x_reg = site_reg("x_reg", x_reg); - site.z_reg = site_reg("z_reg", z_reg); - site.y_reg = site_reg("y_reg", y_reg); - site.queue_guard = - toml::find_or(item, "queue_guard", true); - if ((site.address & 3u) != 0) - throw std::runtime_error(fmt::format( - "{}: aspect-cone address 0x{:08X} is not " - "instruction-aligned", - config_path.string(), site.address)); - if (!seen.insert(site.address & 0x1FFFFFFFu).second) - throw std::runtime_error(fmt::format( - "{}: duplicate aspect-cone address 0x{:08X}", - config_path.string(), site.address)); - ws_aspect_cone.sites.push_back(site); - } - } - if (cull.contains("guard_pixels")) { - ws_cull_guard_pixels = toml::find(cull, "guard_pixels"); - if (ws_cull_guard_pixels < 0 || ws_cull_guard_pixels > 256) - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull] guard_pixels must be in [0, 256]", - config_path.string())); - } - if (cull.contains("activation_guard_pixels")) { - ws_cull_activation_guard_pixels = - toml::find(cull, "activation_guard_pixels"); - if (ws_cull_activation_guard_pixels < 0 || - ws_cull_activation_guard_pixels > 256) - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull] activation_guard_pixels must " - "be in [0, 256]", - config_path.string())); - } - if (cull.contains("screen_w_imms")) { - ws_cull_w_imms.clear(); - load_sites("screen_w_imms", ws_cull_w_imms); - } - if (cull.contains("screen_h_imms")) { - ws_cull_h_imms.clear(); - load_sites("screen_h_imms", ws_cull_h_imms); - } - if (cull.contains("auto_screen_x")) - ws_auto_screen_x_cull = toml::find(cull, "auto_screen_x"); - if (cull.contains("auto_backdrop")) - ws_auto_backdrop_preload = toml::find(cull, "auto_backdrop"); - } - } - - // Optional [widescreen.bg2d] block — pure-2D background tile-loop widen. - uint32_t ws_bg2d_count_site = 0, ws_bg2d_startcol_site = 0, ws_bg2d_startx_site = 0; - uint32_t ws_bg2d_stream_left_site = 0, ws_bg2d_stream_right_site = 0; - uint32_t ws_bg2d_bufbase_site = 0, ws_bg2d_cap_site = 0; - uint32_t ws_bg2d_layer_base = 0x800971F8u, ws_bg2d_ring_base = 0x800A21B8u; - uint32_t ws_bg2d_map_size_addr = 0x800CD338u, ws_bg2d_layer_stride_addr = 0x8008EC10u; - uint32_t ws_bg2d_ring_cols = 64, ws_bg2d_layer_count = 3; - uint32_t ws_bg2d_layer_struct_stride = 0x54; - uint32_t ws_bg2d_init_func = 0; - uint32_t ws_bg2d_packet_cap = 1000; - if (cfg.contains("widescreen")) { - const toml::value& ws = toml::find(cfg, "widescreen"); - if (ws.contains("bg2d")) { - const toml::value& bg = toml::find(ws, "bg2d"); - auto load1 = [&](const char* key) -> uint32_t { - return bg.contains(key) - ? parse_hex(toml::find(bg, key), - fmt::format("widescreen.bg2d.{}", key)) - : 0u; - }; - ws_bg2d_count_site = load1("count_site"); - ws_bg2d_startcol_site = load1("startcol_site"); - ws_bg2d_startx_site = load1("startx_site"); - ws_bg2d_stream_left_site = load1("stream_left_site"); - ws_bg2d_stream_right_site = load1("stream_right_site"); - ws_bg2d_bufbase_site = load1("bufbase_site"); - ws_bg2d_cap_site = load1("cap_site"); - if (bg.contains("layer_base")) - ws_bg2d_layer_base = load1("layer_base"); - if (bg.contains("ring_base")) - ws_bg2d_ring_base = load1("ring_base"); - if (bg.contains("map_size_addr")) - ws_bg2d_map_size_addr = load1("map_size_addr"); - if (bg.contains("layer_stride_addr")) - ws_bg2d_layer_stride_addr = load1("layer_stride_addr"); - auto load_positive = [&](const char* key, uint32_t current) -> uint32_t { - if (!bg.contains(key)) return current; - const int64_t value = toml::find(bg, key); - if (value <= 0 || value > UINT32_MAX) - throw std::runtime_error(fmt::format( - "widescreen.bg2d.{} must be a positive 32-bit integer", key)); - return static_cast(value); - }; - ws_bg2d_ring_cols = load_positive("ring_cols", ws_bg2d_ring_cols); - ws_bg2d_layer_count = load_positive("layer_count", ws_bg2d_layer_count); - ws_bg2d_layer_struct_stride = load_positive( - "layer_struct_stride", ws_bg2d_layer_struct_stride); - ws_bg2d_packet_cap = load_positive("packet_cap", ws_bg2d_packet_cap); - if ((ws_bg2d_ring_cols & (ws_bg2d_ring_cols - 1u)) != 0u) - throw std::runtime_error( - "widescreen.bg2d.ring_cols must be a power of two"); - ws_bg2d_init_func = load1("init_func"); - } - } - - // Optional [widescreen.backdrop] block — parallax 2D backdrop screenX squash - // (x_sites) + far-backdrop GTE un-squash (unsquash_funcs). - std::vector ws_backdrop_x_sites; - std::vector ws_backdrop_unsquash_funcs; - std::vector ws_dome_call_sites; - if (cfg.contains("widescreen")) { - const toml::value& ws = toml::find(cfg, "widescreen"); - if (ws.contains("backdrop")) { - const toml::value& bd = toml::find(ws, "backdrop"); - if (bd.contains("x_sites")) - for (const auto& a : toml::find>(bd, "x_sites")) - ws_backdrop_x_sites.push_back( - parse_hex(a, "widescreen.backdrop.x_sites")); - if (bd.contains("unsquash_funcs")) - for (const auto& a : toml::find>(bd, "unsquash_funcs")) - ws_backdrop_unsquash_funcs.push_back( - parse_hex(a, "widescreen.backdrop.unsquash_funcs")); - } - } - - // Optional [widescreen.dome] block: exact projection calls belonging to - // finite curved background meshes that retain authored 4:3 coverage. - if (cfg.contains("widescreen")) { - const toml::value& ws = toml::find(cfg, "widescreen"); - if (ws.contains("dome")) { - const toml::value& dome = toml::find(ws, "dome"); - if (dome.contains("call_sites")) - for (const auto& a : toml::find>(dome, "call_sites")) - ws_dome_call_sites.push_back( - parse_hex(a, "widescreen.dome.call_sites")); - } - } - - return GameConfig{ - /*config_path*/ config_path, - /*project_root*/ root, - /*name*/ name, - /*id*/ id, - /*region*/ region, - /*players*/ players, - /*exe_path*/ exe_path, - /*load_address*/ load_address, - /*entry_pc*/ entry_pc, - /*text_size*/ text_size, - /*stack_base*/ stack_base, - /*discs*/ discs, - /*has_disc_crc*/ has_disc_crc, - /*disc_crc*/ disc_crc, - /*disc_sha1*/ disc_sha1, - /*seeds_path*/ seeds_path, - /*bios_thunks_path*/ bios_thunks_path, - /*bios_config_path*/ bios_config_path, - /*out_dir*/ out_dir, - /*strict*/ strict, - /*discovery*/ discovery, - /*out_stem*/ out_stem, - /*recompiler_patches*/ recompiler_patches, - /*runtime*/ parse_runtime_block(cfg, root), - /*ws_sprite_tag_funcs*/ ws_sprite_tag_funcs, - /*ws_sprite_anchor_addr*/ ws_sprite_anchor_addr, - /*ws_hud_sprt_squash*/ ws_hud_sprt_squash, - /*ws_auto_ui_squash*/ ws_auto_ui_squash, - /*data_shard_funcs*/ data_shard_funcs, - /*mod_function_entry_funcs*/ mod_function_entry_funcs, - /*hot_funcs*/ hot_funcs, - /*vsync_query_func*/ vsync_query_func, - /*vsync_counter_addr*/ vsync_counter_addr, - /*vsync_gpustat_ptr_addr*/ vsync_gpustat_ptr_addr, - /*vsync_timer1_ptr_addr*/ vsync_timer1_ptr_addr, - /*vsync_timer1_cache_addr*/ vsync_timer1_cache_addr, - /*vsync_event_horizon_sites*/ vsync_event_horizon_sites, - /*vsync_event_horizon_extra_sites*/ vsync_event_horizon_extra_sites, - /*vsync_event_horizon_any*/ vsync_event_horizon_any, - /*ws_cull_bias_sites*/ ws_cull_bias_sites, - /*ws_cull_range_sites*/ ws_cull_range_sites, - /*ws_cull_a1_sites*/ ws_cull_a1_sites, - /*ws_cull_screen_x_sites*/ ws_cull_screen_x_sites, - /*ws_cull_slti_sites*/ ws_cull_slti_sites, - /*ws_cull_slti_lower_sites*/ ws_cull_slti_lower_sites, - /*ws_cull_lower_branch_sites*/ ws_cull_lower_branch_sites, - /*ws_cull_lower_value_sites*/ ws_cull_lower_value_sites, - /*ws_cull_upper_value_sites*/ ws_cull_upper_value_sites, - /*ws_cull_span_value_sites*/ ws_cull_span_value_sites, - /*ws_cull_bltz_sites*/ ws_cull_bltz_sites, - /*ws_cull_negsub_sites*/ ws_cull_negsub_sites, - /*ws_cull_vxrange_sites*/ ws_cull_vxrange_sites, - /*ws_cull_depth_sites*/ ws_cull_depth_sites, - /*ws_cull_plane_nx_sites*/ ws_cull_plane_nx_sites, - /*ws_cull_xclip_load_sites*/ ws_cull_xclip_load_sites, - /*ws_cull_keep_sites*/ ws_cull_keep_sites, - /*ws_cull_angle_sites*/ ws_cull_angle_sites, - /*ws_cull_yaw_cone_sites*/ ws_cull_yaw_cone_sites, - /*ws_cull_horizontal_slt_sites*/ ws_cull_horizontal_slt_sites, - /*ws_aspect_cone*/ ws_aspect_cone, - /*ws_cull_guard_pixels*/ ws_cull_guard_pixels, - /*ws_cull_activation_guard_pixels*/ ws_cull_activation_guard_pixels, - /*ws_cull_w_imms*/ ws_cull_w_imms, - /*ws_cull_h_imms*/ ws_cull_h_imms, - /*ws_backdrop_x_sites*/ ws_backdrop_x_sites, - /*ws_backdrop_unsquash_funcs*/ ws_backdrop_unsquash_funcs, - /*ws_dome_call_sites*/ ws_dome_call_sites, - /*ws_auto_screen_x_cull*/ ws_auto_screen_x_cull, - /*ws_auto_backdrop_preload*/ ws_auto_backdrop_preload, - /*ws_full_2d*/ ws_full_2d, - /*ws_gte_game_mode*/ ws_gte_game_mode, - /*ws_gameplay_state_addr*/ ws_gameplay_state_addr, - /*ws_gameplay_state_values*/ ws_gameplay_state_values, - /*ws_native_wide*/ ws_native_wide, - /*ws_nw_hud_corners*/ ws_nw_hud_corners, - /*ws_nw_left_hud_packet_lo*/ ws_nw_left_hud_packet_lo, - /*ws_nw_left_hud_packet_hi*/ ws_nw_left_hud_packet_hi, - /*ws_nw_backdrop*/ ws_nw_backdrop, - /*ws_clear_reveal*/ ws_clear_reveal, - /*ws_nw_flat_backdrop*/ ws_nw_flat_backdrop, - /*ws_nw_phase_backdrop*/ ws_nw_phase_backdrop, - /*ws_nw_textured_edges*/ ws_nw_textured_edges, - /*ws_nw_textured_edge_scale*/ ws_nw_textured_edge_scale, - /*ws_nw_full_mirror*/ ws_nw_full_mirror, - /*ws_signed_x_bound_sites*/ ws_signed_x_bound_sites, - /*ws_offered*/ ws_offered, - /*vulkan_offered*/ vulkan_offered, - /*ws_adaptive_view*/ ws_adaptive_view, - /*ws_ultrawide_offered*/ ws_ultrawide_offered, - /*ws_bg2d_count_site*/ ws_bg2d_count_site, - /*ws_bg2d_startcol_site*/ ws_bg2d_startcol_site, - /*ws_bg2d_startx_site*/ ws_bg2d_startx_site, - /*ws_bg2d_stream_left_site*/ ws_bg2d_stream_left_site, - /*ws_bg2d_stream_right_site*/ ws_bg2d_stream_right_site, - /*ws_bg2d_bufbase_site*/ ws_bg2d_bufbase_site, - /*ws_bg2d_cap_site*/ ws_bg2d_cap_site, - /*ws_bg2d_layer_base*/ ws_bg2d_layer_base, - /*ws_bg2d_ring_base*/ ws_bg2d_ring_base, - /*ws_bg2d_map_size_addr*/ ws_bg2d_map_size_addr, - /*ws_bg2d_layer_stride_addr*/ ws_bg2d_layer_stride_addr, - /*ws_bg2d_ring_cols*/ ws_bg2d_ring_cols, - /*ws_bg2d_layer_count*/ ws_bg2d_layer_count, - /*ws_bg2d_layer_struct_stride*/ ws_bg2d_layer_struct_stride, - /*ws_bg2d_init_func*/ ws_bg2d_init_func, - /*ws_bg2d_packet_cap*/ ws_bg2d_packet_cap, - }; -} - -// ---- GameOptions (game_options.toml) — the game's own native settings ---- - -GameOptions load_game_options(const fs::path& path) { - GameOptions go; - std::error_code ec; - if (path.empty() || !fs::exists(path, ec)) return go; - - const toml::value doc = toml::parse(path.string()); - if (!doc.contains("option")) return go; - const auto& arr = toml::find(doc, "option"); - for (const auto& item : arr) { - GameOption o; - o.name = toml::find(item, "name"); - o.addr = parse_hex(toml::find(item, "addr"), - fmt::format("game_options [[option]] '{}' addr", o.name)); - o.size = static_cast(toml::find(item, "size")); - if (o.size != 1 && o.size != 2) - throw std::runtime_error(fmt::format( - "game_options [[option]] '{}' size must be 1 or 2: {}", o.name, o.size)); - if (item.contains("init_store_pc")) - o.init_store_pc = parse_hex(toml::find(item, "init_store_pc"), - fmt::format("game_options '{}' init_store_pc", o.name)); - // Optional restore-time value range. `max` (with optional `min`, default 0) - // marks the field validated; a saved value outside [min,max] is dropped. - if (item.contains("max")) { - o.vmax = toml::find(item, "max"); - o.vmin = item.contains("min") ? toml::find(item, "min") : 0; - if (o.vmax < o.vmin) - throw std::runtime_error(fmt::format( - "game_options '{}' max ({}) < min ({})", o.name, o.vmax, o.vmin)); - o.has_range = true; - } - go.options.push_back(o); - } - return go; -} - -// ---- UserSettings (settings.toml) — user-interface-written override layer ---- - -UserSettings load_user_settings(const fs::path& path) { - UserSettings s; - std::error_code ec; - if (path.empty() || !fs::exists(path, ec)) return s; - - toml::value doc; - try { - // Opened by its wide name: path.string() is UTF-8 under MinGW, which the narrow open reads as the ANSI page. - std::ifstream file(path, std::ios::binary); - if (!file) throw std::runtime_error("settings.toml could not be opened"); - doc = toml::parse(file, "settings.toml"); - } catch (const std::exception&) { - // Malformed file: fall back to all-defaults rather than refuse to boot, - // but tell the caller so the user hears about it (and so the file can - // be preserved before any launcher save overwrites it with defaults). - s.parse_error = true; - return s; - } - - // Each field guarded independently so one bad value can't blank the rest. - auto try_get = [](auto&& fn) { try { fn(); } catch (const std::exception&) {} }; - - if (doc.contains("video")) { - const toml::value& v = toml::find(doc, "video"); - if (v.contains("renderer")) try_get([&]{ - const auto m = toml::find(v, "renderer"); - if (m == "software") { s.renderer = 0; s.has_renderer = true; } - else if (m == "opengl") { s.renderer = 1; s.has_renderer = true; } - else if (m == "vulkan") { s.renderer = 2; s.has_renderer = true; } - }); - if (v.contains("supersampling")) try_get([&]{ - const auto n = toml::find(v, "supersampling"); - if (n >= 1 && n <= 8) { s.supersampling = (int)n; s.has_supersampling = true; } - }); - if (v.contains("window_width")) try_get([&]{ - const auto n = toml::find(v, "window_width"); - if (n >= 640 && n <= 3840) { s.window_width = (int)n; s.has_window_width = true; } - }); - if (v.contains("antialiasing")) try_get([&]{ - s.antialiasing = toml::find(v, "antialiasing"); s.has_antialiasing = true; - }); - if (v.contains("texture_filtering")) try_get([&]{ - const auto m = toml::find(v, "texture_filtering"); - if (m == "nearest") { s.texture_filter = 0; s.has_texture_filter = true; } - else if (m == "bilinear") { s.texture_filter = 1; s.has_texture_filter = true; } - }); - if (v.contains("crt_filter")) try_get([&]{ - const auto m = toml::find(v, "crt_filter"); - if (m == "raw") { s.screen_kind = 0; s.has_screen_kind = true; } - else if (m == "crt") { s.screen_kind = 1; s.has_screen_kind = true; } - else if (m == "composite") { s.screen_kind = 2; s.has_screen_kind = true; } - else if (m == "trinitron") { s.screen_kind = 3; s.has_screen_kind = true; } - }); - if (v.contains("auto_skip_fmv")) try_get([&]{ - s.auto_skip_fmv = toml::find(v, "auto_skip_fmv"); s.has_auto_skip_fmv = true; - }); - if (v.contains("turbo_loads")) try_get([&]{ - s.turbo_loads = toml::find(v, "turbo_loads"); s.has_turbo_loads = true; - }); - if (v.contains("fast_boot")) try_get([&]{ - s.fast_boot = toml::find(v, "fast_boot"); s.has_fast_boot = true; - }); - if (v.contains("bios_hle")) try_get([&]{ - s.bios_hle = toml::find(v, "bios_hle"); s.has_bios_hle = true; - }); - if (v.contains("fullscreen")) try_get([&]{ - // Tri-state (0 off / 1 borderless / 2 exclusive). Back-compat: a - // settings.toml written before the tri-state migration stores this - // as a bool (true meant borderless desktop fullscreen). - try { - s.fullscreen = toml::find(v, "fullscreen"); - } catch (const std::exception&) { - s.fullscreen = toml::find(v, "fullscreen") ? 1 : 0; - } - if (s.fullscreen < 0 || s.fullscreen > 2) s.fullscreen = 0; - s.has_fullscreen = true; - }); - if (v.contains("low_latency_input")) try_get([&]{ - s.low_latency_input = toml::find(v, "low_latency_input"); - s.has_low_latency_input = true; - }); - if (v.contains("vsync")) try_get([&]{ - const auto m = toml::find(v, "vsync"); - if (m == "on" || m == "vsync") { s.vsync = 1; s.has_vsync = true; } - else if (m == "off" || m == "immediate"){ s.vsync = 0; s.has_vsync = true; } - else if (m == "adaptive") { s.vsync = -1; s.has_vsync = true; } - }); - if (v.contains("frame_interpolation")) try_get([&]{ - s.frame_interpolation = toml::find(v, "frame_interpolation"); - s.has_frame_interpolation = true; - }); - if (v.contains("frame_interpolation_fps")) try_get([&]{ - s.frame_interpolation_fps = toml::find(v, "frame_interpolation_fps"); - if (s.frame_interpolation_fps == 0 || - (s.frame_interpolation_fps >= 30 && s.frame_interpolation_fps <= 1000)) - s.has_frame_interpolation_fps = true; - }); - if (v.contains("frame_interpolation_blend")) try_get([&]{ - const auto n = toml::find(v, "frame_interpolation_blend"); - if (n == 0 || n == 1 || n == 2) { - s.frame_interpolation_blend = static_cast(n); - s.has_frame_interpolation_blend = true; - } - }); - if (v.contains("aspect_ratio")) try_get([&]{ - const auto m = toml::find(v, "aspect_ratio"); - int n = 0, d = 0; - if (parse_aspect_ratio(m, &n, &d)) { - s.aspect_num = n; s.aspect_den = d; s.has_aspect_ratio = true; - } - }); - if (v.contains("adaptive_view")) try_get([&]{ - s.adaptive_view = toml::find(v, "adaptive_view"); - s.has_adaptive_view = true; - }); - } - if (doc.contains("audio")) { - const toml::value& a = toml::find(doc, "audio"); - if (a.contains("spu_hq")) try_get([&]{ - s.spu_hq = toml::find(a, "spu_hq"); s.has_spu_hq = true; - }); - if (a.contains("master_volume")) try_get([&]{ - const auto n = toml::find(a, "master_volume"); - if (n >= 0 && n <= 100) { - s.master_volume = static_cast(n); - s.has_master_volume = true; - } - }); - if (a.contains("muted")) try_get([&]{ - s.audio_muted = toml::find(a, "muted"); - s.has_audio_muted = true; - }); - } - if (doc.contains("launcher")) { - const toml::value& l = toml::find(doc, "launcher"); - if (l.contains("skip_launcher")) try_get([&]{ - s.skip_launcher = toml::find(l, "skip_launcher"); s.has_skip_launcher = true; - }); - } - if (doc.contains("netplay")) { - const toml::value& n = toml::find(doc, "netplay"); - if (n.contains("player_name")) try_get([&]{ - const auto v = toml::find(n, "player_name"); - if (!v.empty()) { s.netplay_player_name = v; s.has_netplay_player_name = true; } - }); - if (n.contains("lobby_url")) try_get([&]{ - const auto v = toml::find(n, "lobby_url"); - if (!v.empty()) { s.netplay_lobby_url = v; s.has_netplay_lobby_url = true; } - }); - } - if (doc.contains("bios")) { - const toml::value& b = toml::find(doc, "bios"); - if (b.contains("path")) try_get([&]{ - const auto p = toml::find(b, "path"); - if (!p.empty()) { s.bios_path = fs::path(p); s.has_bios_path = true; } - }); - } - if (doc.contains("disc")) { - const toml::value& d = toml::find(doc, "disc"); - if (d.contains("path")) try_get([&]{ - const auto p = toml::find(d, "path"); - if (!p.empty()) { s.disc_path = fs::path(p); s.has_disc_path = true; } - }); - } - if (doc.contains("memcard")) { - const toml::value& m = toml::find(doc, "memcard"); - if (m.contains("dir")) try_get([&]{ - const auto p = toml::find(m, "dir"); - if (!p.empty()) { s.memcard_dir = fs::path(p); s.has_memcard_dir = true; } - }); - if (m.contains("card1")) try_get([&]{ - const auto p = toml::find(m, "card1"); - if (!p.empty()) { s.memcard1_path = fs::path(p); s.has_memcard1_path = true; } - }); - if (m.contains("card2")) try_get([&]{ - const auto p = toml::find(m, "card2"); - if (!p.empty()) { s.memcard2_path = fs::path(p); s.has_memcard2_path = true; } - }); - if (m.contains("enable1")) try_get([&]{ - s.memcard1_enabled = toml::find(m, "enable1"); s.has_memcard1_enabled = true; - }); - if (m.contains("enable2")) try_get([&]{ - s.memcard2_enabled = toml::find(m, "enable2"); s.has_memcard2_enabled = true; - }); - } - if (doc.contains("localization")) { - const toml::value& lc = toml::find(doc, "localization"); - if (lc.contains("language")) try_get([&]{ - const auto v = toml::find(lc, "language"); - if (!v.empty()) { s.language = v; s.has_language = true; } - }); - } - if (doc.contains("parappa_timing")) { - const toml::value& timing = toml::find(doc, "parappa_timing"); - if (timing.contains("mode")) try_get([&]{ - auto mode = toml::find(timing, "mode"); - for (char& c : mode) c = (char)std::tolower((unsigned char)c); - if (mode == "stock" || mode == "off" || mode == "medium" || - mode == "permissive" || mode == "easy" || mode == "custom") { - s.parappa_timing_mode = mode; - s.has_parappa_timing_mode = true; - } - }); - if (timing.contains("extra_early")) try_get([&]{ - const auto n = toml::find(timing, "extra_early"); - if (n >= 0 && n <= 60) { - s.parappa_timing_extra_early = (int)n; - s.has_parappa_timing_extra_early = true; - } - }); - if (timing.contains("extra_late")) try_get([&]{ - const auto n = toml::find(timing, "extra_late"); - if (n >= 0 && n <= 60) { - s.parappa_timing_extra_late = (int)n; - s.has_parappa_timing_extra_late = true; - } - }); - } - if (doc.contains("controller")) { - const toml::value& ct = toml::find(doc, "controller"); - if (ct.contains("p1_device")) try_get([&]{ - const auto v = toml::find(ct, "p1_device"); - if (!v.empty()) { s.p1_device = v; s.has_p1_device = true; } - }); - if (ct.contains("p2_device")) try_get([&]{ - const auto v = toml::find(ct, "p2_device"); - if (!v.empty()) { s.p2_device = v; s.has_p2_device = true; } - }); - // Legacy boolean form first (true->analog, false->digital); the new - // string `*_mode` keys override when present. - if (ct.contains("p1_analog")) try_get([&]{ - s.p1_mode = toml::find(ct, "p1_analog") - ? PAD_MODE_ANALOG : PAD_MODE_DIGITAL; - s.has_p1_mode = true; - }); - if (ct.contains("p2_analog")) try_get([&]{ - s.p2_mode = toml::find(ct, "p2_analog") - ? PAD_MODE_ANALOG : PAD_MODE_DIGITAL; - s.has_p2_mode = true; - }); - if (ct.contains("p1_mode")) try_get([&]{ - s.p1_mode = pad_mode_from_string( - toml::find(ct, "p1_mode"), PAD_MODE_HYBRID); - s.has_p1_mode = true; - }); - if (ct.contains("p2_mode")) try_get([&]{ - s.p2_mode = pad_mode_from_string( - toml::find(ct, "p2_mode"), PAD_MODE_HYBRID); - s.has_p2_mode = true; - }); - if (ct.contains("deadzone")) try_get([&]{ - const auto n = toml::find(ct, "deadzone"); - if (n >= 0 && n <= 32767) { s.deadzone = (int)n; s.has_deadzone = true; } - }); - } - if (doc.contains("disruptor")) { - const toml::value& d = toml::find(doc, "disruptor"); - if (d.contains("mouse_aim")) try_get([&]{ - s.mouse_aim = toml::find(d, "mouse_aim"); - s.has_mouse_aim = true; - }); - if (d.contains("modern_controls")) try_get([&]{ - s.modern_controls = toml::find(d, "modern_controls"); - s.has_modern_controls = true; - }); - if (d.contains("horizontal_sensitivity")) try_get([&]{ - double n = 0.0; - try { - n = toml::find(d, "horizontal_sensitivity"); - } catch (const std::exception&) { - n = static_cast( - toml::find(d, "horizontal_sensitivity")); - } - if (std::isfinite(n) && n >= 0.005 && n <= 2.0) { - s.horizontal_sensitivity = n; - s.has_horizontal_sensitivity = true; - } - }); - if (d.contains("invert_horizontal")) try_get([&]{ - s.invert_horizontal = toml::find(d, "invert_horizontal"); - s.has_invert_horizontal = true; - }); - if (d.contains("vertical_look")) try_get([&]{ - s.vertical_look = toml::find(d, "vertical_look"); - s.has_vertical_look = true; - }); - if (d.contains("vertical_sensitivity")) try_get([&]{ - double n = 0.0; - try { - n = toml::find(d, "vertical_sensitivity"); - } catch (const std::exception&) { - n = static_cast( - toml::find(d, "vertical_sensitivity")); - } - if (std::isfinite(n) && n >= 0.005 && n <= 2.0) { - s.vertical_sensitivity = n; - s.has_vertical_sensitivity = true; - } - }); - if (d.contains("invert_vertical")) try_get([&]{ - s.invert_vertical = toml::find(d, "invert_vertical"); - s.has_invert_vertical = true; - }); - if (d.contains("high_precision_camera")) try_get([&]{ - s.high_precision_camera = toml::find(d, "high_precision_camera"); - s.has_high_precision_camera = true; - }); - if (d.contains("geometry_correction")) try_get([&]{ - s.geometry_correction = toml::find(d, "geometry_correction"); - s.has_geometry_correction = true; - }); - if (d.contains("perspective_textures")) try_get([&]{ - s.perspective_textures = toml::find(d, "perspective_textures"); - s.has_perspective_textures = true; - }); - if (d.contains("frame_unlock")) try_get([&]{ - s.frame_unlock = toml::find(d, "frame_unlock"); - s.has_frame_unlock = true; - }); - if (d.contains("skip_intro")) try_get([&]{ - s.skip_intro = toml::find(d, "skip_intro"); - s.has_skip_intro = true; - }); - if (d.contains("language_disc")) try_get([&]{ - s.language_disc = toml::find(d, "language_disc"); - s.has_language_disc = true; - }); - if (d.contains("language_discs")) try_get([&]{ - s.language_discs = toml::find>(d, "language_discs"); - s.has_language_discs = true; - }); - if (d.contains("hud_scale")) try_get([&]{ - const int percent = toml::find(d, "hud_scale"); - if (percent >= 50 && percent <= 100) { - s.hud_scale = percent; - s.has_hud_scale = true; - } - }); - if (d.contains("improved_shadows")) try_get([&]{ - s.improved_shadows = toml::find(d, "improved_shadows"); - s.has_improved_shadows = true; - }); - if (d.contains("ambient_occlusion")) try_get([&]{ - s.ambient_occlusion = toml::find(d, "ambient_occlusion"); - s.has_ambient_occlusion = true; - }); - if (d.contains("occlusion_reach")) try_get([&]{ - const int reach = toml::find(d, "occlusion_reach"); - if (reach >= 50 && reach <= 800) { - s.occlusion_reach = reach; - s.has_occlusion_reach = true; - } - }); - if (d.contains("occlusion_strength")) try_get([&]{ - const int percent = toml::find(d, "occlusion_strength"); - if (percent >= 0 && percent <= 300) { - s.occlusion_strength = percent; - s.has_occlusion_strength = true; - } - }); - if (d.contains("gap_filling")) try_get([&]{ - s.gap_filling = toml::find(d, "gap_filling"); - s.has_gap_filling = true; - }); - if (d.contains("depth_sorted_world")) try_get([&]{ - s.depth_sorted_world = toml::find(d, "depth_sorted_world"); - s.has_depth_sorted_world = true; - }); - if (d.contains("diagnostic_view")) try_get([&]{ - const std::string view = toml::find(d, "diagnostic_view"); - for (int index = 0; index < kDisruptorDiagnosticViewCount; ++index) { - if (view == kDisruptorDiagnosticViews[index]) { - s.diagnostic_view = index; - s.has_diagnostic_view = true; - } - } - }); - } - return s; -} - -namespace { - -fs::path user_settings_temp_path(const fs::path& destination) { - static std::atomic sequence{0}; -#ifdef _WIN32 - const auto process_id = static_cast(::GetCurrentProcessId()); -#else - const auto process_id = static_cast(::getpid()); -#endif - const auto tick = static_cast( - std::chrono::steady_clock::now().time_since_epoch().count()); - - fs::path temporary = destination; - temporary += fmt::format(".tmp.{}.{}.{}", process_id, tick, - sequence.fetch_add(1, std::memory_order_relaxed)); - return temporary; -} - -bool atomic_replace_user_settings(const fs::path& temporary, - const fs::path& destination) { -#ifdef _WIN32 - // A reader or antivirus scanner may briefly deny replacement after a save. - // Keep the old file intact and allow up to 500 ms for that handle to close. - for (unsigned attempt = 0; ; ++attempt) { - if (::MoveFileExW(temporary.c_str(), destination.c_str(), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) return true; - const DWORD error = ::GetLastError(); - const bool transient = error == ERROR_ACCESS_DENIED || - error == ERROR_SHARING_VIOLATION || - error == ERROR_LOCK_VIOLATION; - const DWORD attributes = ::GetFileAttributesW(destination.c_str()); - const bool blocked = attributes != INVALID_FILE_ATTRIBUTES && - (attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_READONLY)); - if (!transient || blocked || attempt == 20) { - ::SetLastError(error); - return false; - } - ::Sleep(25); - } -#else - return std::rename(temporary.c_str(), destination.c_str()) == 0; -#endif -} - -void remove_user_settings_temp(const fs::path& temporary) { - std::error_code ignored; - fs::remove(temporary, ignored); -} - -} // namespace - -bool save_user_settings(const fs::path& path, const UserSettings& s) { - std::error_code ec; - if (!path.parent_path().empty()) - fs::create_directories(path.parent_path(), ec); // best-effort - - const fs::path temporary = user_settings_temp_path(path); - std::ofstream f(temporary, std::ios::trunc); - if (!f.is_open()) return false; - - // TOML strings use forward slashes so backslash escaping is never an issue. - auto fwd = [](const fs::path& p) { - std::string str = p.generic_string(); - return str; - }; - - // A TOML basic string: a quote, a backslash and a control character are escaped. - auto quoted = [](const std::string& text) { - std::string out = "\""; - for (const char letter : text) { - const unsigned code = static_cast(letter); - char escape[8]; - if (letter == '\\' || letter == '"') std::snprintf(escape, sizeof(escape), "\\%c", letter); - else if (code < 0x20 || code == 0x7F) std::snprintf(escape, sizeof(escape), "\\u%04X", code); - else std::snprintf(escape, sizeof(escape), "%c", letter); - out += escape; - } - return out + "\""; - }; - - f << "# psxrecomp user settings - written by settings UIs. Safe to hand-edit.\n"; - f << "# Overrides the bundled game.toml; the command line overrides this file.\n\n"; - - f << "[video]\n"; - if (s.has_renderer) - f << "renderer = \"" - << (s.renderer == 2 ? "vulkan" : s.renderer == 1 ? "opengl" : "software") - << "\"\n"; - if (s.has_supersampling) - f << "supersampling = " << s.supersampling << "\n"; - if (s.has_window_width) - f << "window_width = " << s.window_width << "\n"; - if (s.has_antialiasing) - f << "antialiasing = " << (s.antialiasing ? "true" : "false") << "\n"; - if (s.has_texture_filter) - f << "texture_filtering = \"" << (s.texture_filter ? "bilinear" : "nearest") << "\"\n"; - if (s.has_screen_kind) { - const char* k = s.screen_kind == 1 ? "crt" - : s.screen_kind == 2 ? "composite" - : s.screen_kind == 3 ? "trinitron" : "raw"; - f << "crt_filter = \"" << k << "\"\n"; - } - if (s.has_auto_skip_fmv) - f << "auto_skip_fmv = " << (s.auto_skip_fmv ? "true" : "false") << "\n"; - if (s.has_turbo_loads) - f << "turbo_loads = " << (s.turbo_loads ? "true" : "false") << "\n"; - if (s.has_fast_boot) - f << "fast_boot = " << (s.fast_boot ? "true" : "false") << "\n"; - if (s.has_bios_hle) - f << "bios_hle = " << (s.bios_hle ? "true" : "false") << "\n"; - if (s.has_fullscreen) - f << "fullscreen = " << s.fullscreen << "\n"; - if (s.has_low_latency_input) - f << "low_latency_input = " << (s.low_latency_input ? "true" : "false") << "\n"; - if (s.has_vsync) - f << "vsync = \"" << (s.vsync == 0 ? "immediate" : s.vsync < 0 ? "adaptive" : "on") << "\"\n"; - if (s.has_frame_interpolation) - f << "frame_interpolation = " << (s.frame_interpolation ? "true" : "false") << "\n"; - if (s.has_frame_interpolation_fps) - f << "frame_interpolation_fps = " << s.frame_interpolation_fps << "\n"; - if (s.has_frame_interpolation_blend && - (s.frame_interpolation_blend == 0 || - s.frame_interpolation_blend == 1 || - s.frame_interpolation_blend == 2)) - f << "frame_interpolation_blend = " << s.frame_interpolation_blend << "\n"; - if (s.has_aspect_ratio) - f << "aspect_ratio = \"" << s.aspect_num << ":" << s.aspect_den << "\"\n"; - if (s.has_adaptive_view) - f << "adaptive_view = " << (s.adaptive_view ? "true" : "false") << "\n"; - f << "\n[audio]\n"; - if (s.has_spu_hq) - f << "spu_hq = " << (s.spu_hq ? "true" : "false") << "\n"; - if (s.has_master_volume) - f << "master_volume = " << s.master_volume << "\n"; - if (s.has_audio_muted) - f << "muted = " << (s.audio_muted ? "true" : "false") << "\n"; - if (s.has_skip_launcher) - f << "\n[launcher]\nskip_launcher = " << (s.skip_launcher ? "true" : "false") << "\n"; - if ((s.has_netplay_player_name && !s.netplay_player_name.empty()) || - (s.has_netplay_lobby_url && !s.netplay_lobby_url.empty())) { - f << "\n[netplay]\n"; - if (s.has_netplay_player_name && !s.netplay_player_name.empty()) - f << "player_name = \"" << s.netplay_player_name << "\"\n"; - if (s.has_netplay_lobby_url && !s.netplay_lobby_url.empty()) - f << "lobby_url = \"" << s.netplay_lobby_url << "\"\n"; - } - if (s.has_bios_path) - f << "\n[bios]\npath = \"" << fwd(s.bios_path) << "\"\n"; - if (s.has_disc_path) - f << "\n[disc]\npath = \"" << fwd(s.disc_path) << "\"\n"; - if (s.has_memcard_dir || s.has_memcard1_path || s.has_memcard2_path || - s.has_memcard1_enabled || s.has_memcard2_enabled) { - f << "\n[memcard]\n"; - if (s.has_memcard_dir) - f << "dir = \"" << fwd(s.memcard_dir) << "\"\n"; - if (s.has_memcard1_path) - f << "card1 = \"" << fwd(s.memcard1_path) << "\"\n"; - if (s.has_memcard2_path) - f << "card2 = \"" << fwd(s.memcard2_path) << "\"\n"; - if (s.has_memcard1_enabled) - f << "enable1 = " << (s.memcard1_enabled ? "true" : "false") << "\n"; - if (s.has_memcard2_enabled) - f << "enable2 = " << (s.memcard2_enabled ? "true" : "false") << "\n"; - } - - if (s.has_p1_device || s.has_p2_device || s.has_p1_mode || s.has_p2_mode || - s.has_deadzone) { - f << "\n[controller]\n"; - if (s.has_p1_device) - f << "p1_device = \"" << s.p1_device << "\"\n"; - if (s.has_p1_mode) - f << "p1_mode = \"" << pad_mode_to_string(s.p1_mode) << "\"\n"; - if (s.has_p2_device) - f << "p2_device = \"" << s.p2_device << "\"\n"; - if (s.has_p2_mode) - f << "p2_mode = \"" << pad_mode_to_string(s.p2_mode) << "\"\n"; - if (s.has_deadzone) - f << "deadzone = " << s.deadzone << "\n"; - } - - if (s.has_language) { - f << "\n[localization]\n"; - f << "language = \"" << s.language << "\"\n"; - } - if (s.has_parappa_timing_mode || s.has_parappa_timing_extra_early || - s.has_parappa_timing_extra_late) { - f << "\n[parappa_timing]\n"; - if (s.has_parappa_timing_mode) - f << "mode = \"" << s.parappa_timing_mode << "\"\n"; - if (s.has_parappa_timing_extra_early) - f << "extra_early = " << s.parappa_timing_extra_early << "\n"; - if (s.has_parappa_timing_extra_late) - f << "extra_late = " << s.parappa_timing_extra_late << "\n"; - } - - if (s.has_mouse_aim || s.has_modern_controls || - s.has_horizontal_sensitivity || s.has_invert_horizontal || - s.has_vertical_look || s.has_vertical_sensitivity || - s.has_invert_vertical || - s.has_high_precision_camera || s.has_geometry_correction || - s.has_perspective_textures || s.has_frame_unlock || s.has_improved_shadows || - s.has_ambient_occlusion || s.has_occlusion_reach || s.has_occlusion_strength || - s.has_gap_filling || s.has_depth_sorted_world || s.has_diagnostic_view || - s.has_language_discs || - s.has_language_disc || s.has_skip_intro || - s.has_hud_scale) { - f << "\n[disruptor]\n"; - if (s.has_mouse_aim) - f << "mouse_aim = " << (s.mouse_aim ? "true" : "false") << "\n"; - if (s.has_modern_controls) - f << "modern_controls = " << (s.modern_controls ? "true" : "false") << "\n"; - if (s.has_horizontal_sensitivity && - std::isfinite(s.horizontal_sensitivity) && - s.horizontal_sensitivity >= 0.005 && - s.horizontal_sensitivity <= 2.0) { - f << "horizontal_sensitivity = " - << std::setprecision(std::numeric_limits::max_digits10) - << s.horizontal_sensitivity << "\n"; - } - if (s.has_invert_horizontal) - f << "invert_horizontal = " << (s.invert_horizontal ? "true" : "false") << "\n"; - if (s.has_vertical_look) - f << "vertical_look = " << (s.vertical_look ? "true" : "false") << "\n"; - if (s.has_vertical_sensitivity && - std::isfinite(s.vertical_sensitivity) && - s.vertical_sensitivity >= 0.005 && - s.vertical_sensitivity <= 2.0) { - f << "vertical_sensitivity = " - << std::setprecision(std::numeric_limits::max_digits10) - << s.vertical_sensitivity << "\n"; - } - if (s.has_invert_vertical) - f << "invert_vertical = " << (s.invert_vertical ? "true" : "false") << "\n"; - if (s.has_high_precision_camera) - f << "high_precision_camera = " << (s.high_precision_camera ? "true" : "false") << "\n"; - if (s.has_geometry_correction) - f << "geometry_correction = " << (s.geometry_correction ? "true" : "false") << "\n"; - if (s.has_perspective_textures) - f << "perspective_textures = " << (s.perspective_textures ? "true" : "false") << "\n"; - if (s.has_frame_unlock) - f << "frame_unlock = " << (s.frame_unlock ? "true" : "false") << "\n"; - if (s.has_skip_intro) - f << "skip_intro = " << (s.skip_intro ? "true" : "false") << "\n"; - if (s.has_language_disc) - f << "language_disc = " << quoted(s.language_disc) << "\n"; - if (s.has_language_discs) { - f << "language_discs = ["; - for (size_t index = 0; index < s.language_discs.size(); ++index) - f << (index ? ", " : "") << quoted(s.language_discs[index]); - f << "]\n"; - } - if (s.has_hud_scale && s.hud_scale >= 50 && s.hud_scale <= 100) - f << "hud_scale = " << s.hud_scale << "\n"; - if (s.has_improved_shadows) - f << "improved_shadows = " << (s.improved_shadows ? "true" : "false") << "\n"; - if (s.has_ambient_occlusion) - f << "ambient_occlusion = " << (s.ambient_occlusion ? "true" : "false") << "\n"; - if (s.has_occlusion_reach && s.occlusion_reach >= 50 && s.occlusion_reach <= 800) - f << "occlusion_reach = " << s.occlusion_reach << "\n"; - if (s.has_occlusion_strength && s.occlusion_strength >= 0 && s.occlusion_strength <= 300) - f << "occlusion_strength = " << s.occlusion_strength << "\n"; - if (s.has_gap_filling) - f << "gap_filling = " << (s.gap_filling ? "true" : "false") << "\n"; - if (s.has_depth_sorted_world) - f << "depth_sorted_world = " << (s.depth_sorted_world ? "true" : "false") << "\n"; - if (s.has_diagnostic_view && s.diagnostic_view >= 0 && s.diagnostic_view < kDisruptorDiagnosticViewCount) - f << "diagnostic_view = " << quoted(kDisruptorDiagnosticViews[s.diagnostic_view]) << "\n"; - } - - f.flush(); - const bool write_ok = f.good(); - f.close(); - if (!write_ok || f.fail()) { - remove_user_settings_temp(temporary); - return false; - } - if (!atomic_replace_user_settings(temporary, path)) { -#ifdef _WIN32 - const DWORD error = ::GetLastError(); -#endif - remove_user_settings_temp(temporary); -#ifdef _WIN32 - ::SetLastError(error); -#endif - return false; - } - return true; -} - -} // namespace PSXRecompV4 diff --git a/psxrecomp-overlay/recompiler/src/config_loader.h b/psxrecomp-overlay/recompiler/src/config_loader.h deleted file mode 100644 index dbe869f..0000000 --- a/psxrecomp-overlay/recompiler/src/config_loader.h +++ /dev/null @@ -1,1185 +0,0 @@ -// config_loader.h — shared TOML config loader for psxrecomp-{bios,game}. -// -// Mirrors the schema accepted by tools/audit_config.py (Python side). See -// docs/config_schema.md for the field reference. -// -// Two entry points: -// load_bios_config(path) reads bios/SCPH1001.toml — describes the BIOS -// load_game_config(path) reads /game.toml — describes a game EXE -// -// A runtime/process that needs both calls both; the BIOS one is the -// always-loaded base, the game one is layered on top (merge semantics are -// the caller's responsibility for now — recompiler tools consume one or -// the other independently). -// -// Paths inside the TOML are resolved relative to the detected project -// root (the nearest ancestor of the config file that has .gitignore, -// .git, or CMakeLists.txt). - -#pragma once - -#include -#include -#include -#include -#include - -#include "bios_address_model.h" // BiosAddrCopy (BiosConfig::address_copies) -#include "recompiler_patch.h" - -namespace PSXRecompV4 { - -// Pad input mode (per player). Replaces the old analog on/off boolean. -// hybrid — auto-switch DualShock(analog)/digital per the most-recent input: -// nudge the stick -> report DualShock (0x73, variable sticks); -// press the D-pad -> report a digital pad (0x41) so the game runs -// its OWN d-pad path at true digital sensitivity. Mirrors a -// DualShock's analog LED toggling on/off (and Tomba Special -// Edition's auto-detect). Default. -// analog — always present a DualShock/analog pad (id 0x73). The D-pad is -// folded onto the stick at full deflection so it still moves you. -// digital — always present a digital pad (id 0x41); sticks disabled. -enum PadMode { PAD_MODE_HYBRID = 0, PAD_MODE_ANALOG = 1, PAD_MODE_DIGITAL = 2 }; - -// Renderer IDs shared by game.toml/settings parsing and runtime startup. -// OpenGL is the default because the Windows software/SDL_Renderer path is slow -// enough to mask interpreter/AOT performance work and can present as a false -// 30 FPS regression. Software remains an explicit opt-in fallback. -inline constexpr int VIDEO_RENDERER_SOFTWARE = 0; -inline constexpr int VIDEO_RENDERER_OPENGL = 1; -inline constexpr int VIDEO_RENDERER_VULKAN = 2; -inline constexpr int DEFAULT_VIDEO_RENDERER = VIDEO_RENDERER_OPENGL; - -struct WidescreenSignedBoundSite { - uint32_t address = 0; - uint32_t expected = 0; // guarded LUI instruction -}; - -// One exact compare whose verdict is forced while a widescreen reveal is -// active. The full instruction word is part of the identity because overlay -// variants routinely place unrelated code at the same virtual address. -struct WidescreenCullKeepSite { - uint32_t address = 0; - uint32_t expected = 0; // guarded SLT/SLTU/SLTI/SLTIU instruction - uint32_t result = 0; // forced comparison result (0 or 1) -}; - -// Aspect-scaled 12-bit angular half-extent. These sites load a positive angle -// constant with `addi[u] rt,zero,imm`; the runtime widens tan(angle) by the -// live horizontal reveal factor. Full-word guards prevent overlay-address -// aliases from changing unrelated immediates. -struct WidescreenAngleSite { - uint32_t address = 0; - uint32_t expected = 0; // guarded ADDI/ADDIU with rs == zero -}; - -// Aspect-scaled wrapping yaw offsets. Unlike WidescreenAngleSite, these are -// ADDI/ADDIU instructions whose source register already contains the live -// camera yaw. The signed immediate is a half-FOV in `full_turn` units (for -// example +/-32 with full_turn=256). Only the offset is widened; the base yaw -// and its natural integer wrapping remain owned by the game. -struct WidescreenYawConeSite { - uint32_t address = 0; - uint32_t expected = 0; // guarded ADDI/ADDIU with rs != zero - uint32_t full_turn = 0; -}; - -// Exact signed SLT side-plane tests of the form `bound < horizontal`. The -// runtime aspect-scales only the horizontal operand, preserving the original -// comparison at 4:3 and widening a camera-space horizontal cone without -// touching its corresponding vertical test. -struct WidescreenHorizontalSltSite { - uint32_t address = 0; - uint32_t expected = 0; // guarded signed register-register SLT -}; - -// Aspect-aware horizontal participation cone. The exact compare sites are -// full-word guarded because overlay variants can reuse a virtual address for -// unrelated code. Registers are MIPS GPR indices captured at each comparison. -// Queue metadata is optional; when present it lets guard/hysteresis candidates -// leave configured headroom without changing the game's fixed capacities. -struct WidescreenAspectConeSite { - uint32_t address = 0; - uint32_t expected = 0; // guarded signed SLTI or SLT reject comparison - // Q10 cosine threshold. Zero derives the threshold from an SLTI - // immediate; register-register SLT sites must provide it explicitly. - uint32_t cosine_threshold = 0; - // Per-site register overrides. UINT32_MAX inherits the enclosing - // aspect-cone defaults. - uint32_t object_reg = 0xFFFFFFFFu; - uint32_t x_reg = 0xFFFFFFFFu; - uint32_t z_reg = 0xFFFFFFFFu; - uint32_t y_reg = 0xFFFFFFFFu; - // False for lower-level model/child predicates that do not append to the - // configured fixed-capacity queues. - bool queue_guard = true; -}; - -struct WidescreenAspectConeConfig { - std::vector sites; - uint32_t forward_addr = 0; // three signed Q12 halfwords: X,Z,Y - uint32_t object_type_offset = 0; - uint32_t object_reg = 0; - uint32_t x_reg = 0; - uint32_t z_reg = 0; - uint32_t y_reg = 0; - uint32_t hysteresis_pixels = 0; - uint32_t queue_reserve = 0; - std::array queue_count_addrs{}; - std::array queue_capacities{}; - std::array queue_type_masks{}; -}; -// Parse/format a pad mode. pad_mode_from_string accepts "hybrid"/"analog"/ -// "digital" (case-insensitive) and returns `fallback` for anything else. -int pad_mode_from_string(const std::string& s, int fallback); -const char* pad_mode_to_string(int mode); - -// [runtime] block — consumed by runtime/src/main.cpp. All fields optional; -// callers that need them check has_* flags or use the supplied defaults. -struct RuntimeConfig { - bool has_debug_port = false; - uint16_t debug_port = 0; - - // Localization / on-the-fly string translation (docs/STRING_TRANSLATION.md). - // language selects the translations/*.toml column; "jp"/"off"/"" disables - // APPLY (capture still runs). From [localization].language or [runtime]. - // language; env PSX_LANG overrides at runtime. Also the launcher default. - std::string language = "en"; - - // Optional launcher-facing language menu. When a game declares - // [localization].languages, the launcher shows a "Localization" dropdown of - // these {code,label} options (code feeds `language`; "off"/"jp"/"" = the - // untranslated native game). Empty => no dropdown (the general default). - struct LanguageOption { std::string code; std::string label; }; - std::vector languages; - - bool has_window_title = false; - std::string window_title; - - // controller: "digital" (default) | "dualshock" - bool has_controller = false; - std::string controller; - - bool has_memcard_dir = false; - std::filesystem::path memcard_dir; // absolute path (resolved against project root) - - // disc_speed: "1x" (default) | "2x" | "4x" | "instant" - // Controls how quickly CD-ROM timing delays fire. "instant" collapses all - // seek/read delays to 1 cycle — correct INT sequence, no artificial wait. - bool has_disc_speed = false; - std::string disc_speed; // raw string; main.cpp converts to divisor - - // instant_max_per_frame: per-frame sector-IRQ budget while disc_speed = - // "instant" (cdrom.c floors the per-sector period to VBLANK/N). Absent = - // cdrom.c built-in default. Runtime-tunable via the cdrom_instant_rate - // TCP command; the turbo-through-loads predicate drives the same knob. - bool has_instant_max_per_frame = false; - int instant_max_per_frame = 0; - - // Optional, game-specific warm-load route accelerators. A matching SetLoc - // at arm_lba followed by the exact ordered lbas sequence switches only - // those data reads to the bounded instant cadence. Any sequence mismatch - // fails closed to the configured disc_speed. The parser accepts the legacy - // [runtime.warm_cd_route] table and the reusable - // [[runtime.warm_cd_routes]] array-of-tables. - struct WarmCdRoute { - int arm_lba = -1; - std::vector lbas; - int instant_max_per_frame = 32; - }; - std::vector warm_cd_routes; - - // fast_boot: DEPRECATED alias for the BIOS boot shell-skip (see bios_hle - // below). The old mechanism (snapshot BIOS state at first handoff, restore - // on later launches) is gone; fast_boot=true now skips only the BIOS shell - // (the boot animation) via the HLE tier's one-shot shell intercept, with - // kernel init + game EXE load still executed by the real recompiled BIOS - // at host speed. It is boot-only: it never enables the kernel-call HLE - // tier, and it works on any BIOS image that exports a shell entry. - // Kept so existing game.toml/settings.toml keep working. - bool fast_boot = false; - - // bios_hle: High-Level Emulation tier for BIOS kernel services - // (CLAUDE.md §0 amendment 2026-07-02, the gbarecomp model). DEFAULT ON as of - // 2026-07-06 (user-directed player default: instant boot-skip for every - // game). Opt OUT with [runtime] bios_hle = false or env PSX_BIOS_HLE=0 to run - // pure LLE (the recompiled BIOS), which REMAINS the reference implementation - // and the oracle — this only flips the default, LLE is still fully linked and - // selectable. When on, implemented kernel services are computed in-runtime - // against the real guest kernel structures and every other call falls through - // to LLE. Implies the BIOS boot shell-skip unless bios_hle_keep_intro. - // PSX_BIOS_HLE / PSX_BIOS_HLE_KEEP_INTRO env override at launch. - // Runtime: runtime/src/bios_hle.c. - // - // The flag drives TWO axes with different per-image requirements, resolved - // in runtime/src/bios_hle_plan.c: - // * boot shell-skip — needs only the image's shell_entry_phys anchor and - // works under pure LLE, so it fires the SAME WAY on every linked BIOS - // (retail SCPH-1001 and the bundled OpenBIOS alike). "Skip the BIOS and - // go straight to the game" is one behaviour, not a per-BIOS lottery. - // * kernel-call HLE — needs the image's deliver_event_ret anchor; an - // image without it (OpenBIOS, until its B0 semantics are validated) - // refuses this axis and says so at startup, WITHOUT cancelling the - // boot-skip. Collapsing the two is exactly the bug fixed 2026-07-27. - // openbios: may this title run on the bundled, redistributable OpenBIOS? - // Default true — a player who chooses no BIOS gets OpenBIOS and never has - // to find one (docs/BIOS_SELECTION.md). Set false ONLY for a title with a - // verified OpenBIOS incompatibility; a retail image is then required and - // the player is prompted for it. - // - // Deliberately NOT overridable by a player's settings.toml: this records a - // developer's compatibility finding, not a preference. - bool openbios = true; - - bool bios_hle = true; - bool bios_hle_keep_intro = false; - - // hle_scheduler: the HLE tier's standing SUBSYSTEM REPLACEMENT for guest - // thread switching (deterministic TCB scheduler vs the legacy - // non-deterministic host-fiber bridge). Default ON under BOTH BIOS - // backends (CLAUDE.md §0 amendment 2026-06-29 carve-out); PSX_HLE_SCHEDULER - // env wins over this key. Runtime: traps.c psx_hle_scheduler_enabled(). - bool hle_scheduler = true; - - // overlay_cache: enable the overlay DLL cache + capture (Layer A). Off by - // default. When true the runtime scans cache// for precompiled - // overlay DLLs (loaded ahead of the dirty-RAM interpreter) and records - // overlay bytes to overlay_captures.json for offline compilation. - bool overlay_cache = false; - - // overlay_capture_history: opt-in durable capture history. The runtime - // keeps overlay_captures.json as an atomic latest snapshot for the live - // compiler and additionally appends every changed coherent snapshot to - // overlay_captures.addendum.jsonl beside the executable. A malformed tail - // from a hard kill cannot destroy earlier records. - bool overlay_capture_history = false; - - // overlay_capture_persist_dir: optional DEV-only, project-relative safe - // directory for one immutable JSON file per changed snapshot. Rooted, - // UNC, drive-qualified and drive-relative paths are rejected, as is any - // `..` component or an embedded NUL, under both `/` and `\` separators. - // Production normally leaves this unset and retains only the addendum - // beside the executable. - std::string overlay_capture_persist_dir; - - // turbo_loads: OPT-IN per game. While the game is loading (CD data - // stream active, XA/FMV excluded, post-BIOS-handoff only) the frontend - // skips wall-clock pacing so the guest runs at host speed — compressing - // load wall-time. Streaming titles (e.g. Crash) must leave this off. - bool turbo_loads = false; - - // offer_turbo_loads: expose the generic Turbo loads switch through - // recomp-ui Settings. Defaults true for compatibility. A game migrating - // load acceleration into its mod catalog sets this false; stale persisted - // Settings values are then ignored and a trusted activation plugin owns - // the launch policy. - bool offer_turbo_loads = true; - - // turbo_audio_sink: while turbo_loads is actively running unpaced, keep - // rendering the exact guest-time SPU sample budget (so voice/CD state - // advances) but discard those samples before the host playback queue. - // Opt-in while the experiment is under live audio QA. - bool turbo_audio_sink = false; - - // idle_skip: proof-gated fast-forward through repeated CPU polling - // loops with no stores/MMIO and stable register state. Guest time and - // device events still advance exactly. Opt-in per game; the idle_skip - // debug command and PSX_IDLE_SKIP environment variable support live A/B. - bool idle_skip = false; - - // overlay_autocompile_cmd: variant-capture automation (step 2.8). A - // shell command (run via cmd.exe /C, cwd = project root) that compiles - // overlay_captures.json into the cache — normally the project's - // compile_overlays.py invocation. When set (and overlay_cache is on), - // the runtime auto-captures on sustained capture-window interp pressure - // and spawns this command in the background; on success the loader - // rescans the cache and the new variant goes native in-session. - bool has_overlay_autocompile_cmd = false; - std::string overlay_autocompile_cmd; - - // overlay_autocompile_cmd_tcc: the SAME pipeline as overlay_autocompile_cmd - // but invoking the bundled, toolchain-free TinyCC compiler instead of gcc. - // The runtime spawns THIS command instead of the gcc one when the resolved - // backend is tcc (a machine without a gcc toolchain, or overlay_backend = - // "tcc"/"auto-no-gcc"). Lets gcc stay the dev/production path while tcc is - // the user fallback. When unset, a tcc-resolved backend has no compiler and - // overlay gaps fall to the interpreter. - bool has_overlay_autocompile_cmd_tcc = false; - std::string overlay_autocompile_cmd_tcc; - - // overlay_backend: overlay tier-selection (overlay_backend.h). - // "auto" (default, empty == auto) | "gcc" | "tcc" | "auto-no-gcc". ("sljit" - // was removed 2026-07-15; a stale value degrades to auto.) auto resolves to - // gcc when a gcc TOOLCHAIN is present (a dev / - // production box), else tcc (bundled, toolchain-free). "auto-no-gcc" forces - // the tcc branch even with gcc present (simulate a toolchain-less user box; - // gcc shards still load). The env var PSX_OVERLAY_BACKEND overrides at runtime. - std::string overlay_backend; - - // overlay_native_block: per-game overlay function entries that must stay on - // the dirty-RAM interpreter even when a matching native DLL exists. Intended - // for small timing-sensitive setup/task routines while the rest of the - // overlay runs native. - std::vector overlay_native_block; - - bool has_parappa_timing = false; - std::string parappa_timing_mode = "stock"; - int parappa_timing_extra_early = 0; - int parappa_timing_extra_late = 0; - - // ---- [video] block — visual enhancement options ---- - // supersampling: internal-resolution SSAA factor (per axis). 1 = native - // (default, behaves exactly as before). 2..8 render geometry/shading into - // an N*-scaled mirror of VRAM and downsample on present — true ordered-grid - // supersampling + edge anti-aliasing. Cost scales ~N^2 in fill rate. - int video_supersampling = 1; - - // antialiasing: when true the present path uses linear filtering when - // scaling the framebuffer to the window (smooths the supersample - // downscale and any window resize). false = nearest (sharp pixels). - // Defaults to true. - bool video_antialiasing = true; - - // texture_filtering: "nearest" (default, native PSX look) | "bilinear" - // (smooths textures and 2D backgrounds). Stored as 0/1. - int video_texture_filter = 0; - - // renderer: "software" | "opengl" (default) | "vulkan". Selects the - // rasterizer/present backend. The software rasterizer remains the explicit - // fallback. Stored as VIDEO_RENDERER_*. - int video_renderer = DEFAULT_VIDEO_RENDERER; - - // offer_vulkan: expose the experimental Vulkan renderer in the launcher. - // Defaults false even for Vulkan-enabled builds; developers must opt in per - // game once visuals are validated. - bool video_offer_vulkan = false; - - // low_latency_input: re-sample the pad after the wall-clock pacer (just - // before present) so the next CPU frame reads near-fresh input instead of - // input ~one frame stale. Default on. vsync: present/swap mode — - // 1=on (tear-free, default), 0=immediate (lowest display latency, may - // tear), -1=adaptive. The wall-clock pacer holds 59.94Hz regardless. - bool video_low_latency_input = true; - int video_vsync = 1; - bool video_frame_interpolation = false; - int video_frame_interpolation_fps = 0; // 0 = display refresh - // offer_frame_interpolation: expose the generic interpolation controls - // through recomp-ui Settings. Defaults true for compatibility. A game - // migrating interpolation into its mod catalog sets this false; stale - // persisted Settings values are then ignored and a trusted activation - // plugin owns the runtime switch. - bool video_offer_frame_interpolation = true; - - // crt_filter: present-time screen-colour model (verified-enhancement LUT). - // "raw" (default, byte-identical 5->8 passthrough) | "crt" | "composite" | - // "trinitron". Stored 0..3 to match ScreenKind in runtime/color_lut.h. The - // PSX_SCREEN env var overrides this at runtime (debug path). - int video_screen_kind = 0; - - // auto_skip_fmv: when true, full-motion videos (streaming XA audio + MDEC - // video) are skipped the instant they're detected — presentation + pacing are - // suppressed and audio muted for the duration, so an FMV ends in a fraction of - // a second with nothing shown, landing on the next screen with side effects - // intact. The skip is driven the GAME's own way (see fmv_skip_* below); with - // no per-game config it falls back to holding the skip button. - bool video_auto_skip_fmv = false; - - // offer_skip_fmv: expose auto_skip_fmv through recomp-ui Settings. - // Defaults true for compatibility. A game migrating the feature into its - // mod catalog sets this false; stale persisted Settings values are then - // ignored and a trusted activation plugin owns the runtime switch. - bool video_offer_skip_fmv = true; - - // fmv_skip_*: per-game FMV instant-skip via the game's own end-of-movie path. - // Some players (Tomba) end a movie when the streamed frame number reaches that - // movie's per-movie frame total minus a small offset. When auto_skip_fmv is on - // and fmv_skip_total_table is set, the runtime writes the CURRENT movie's total - // (at fmv_skip_total_table + movie_id*2, a u16 table indexed by the movie-id - // byte at fmv_skip_movie_id) down to fmv_skip_end_total, so the player tears the - // movie down on its next frame — a natural end that reaches EVERY movie (incl. - // ones whose caller never polls the skip button). Only the active movie's entry - // is touched. Addresses are game-specific (RE of the player loop); leave the - // table 0 to fall back to button injection. Tomba: table 0x80077728, - // movie_id 0x1F8001CD, end_total 3 (the player's "total - 3" offset). - uint32_t video_fmv_skip_total_table = 0; - uint32_t video_fmv_skip_movie_id = 0; - int video_fmv_skip_end_total = 0; // 0 => runtime default (3) - - // fmv_skip_no_xa: broaden FMV detection to MDEC-decode activity ALONE (no - // XA-stream requirement). Some movies are silent and fully preloaded into - // RAM (Tomba2's Whoopee Camp logo: no CD sectors, no XA during playback), - // so the default MDEC+XA detector never fires and neither skip mechanism is - // even attempted. With this on, such movies enter the same skip path: - // pacing/presents suppressed (fast-forward at host speed), audio muted, - // skip button held. The movie still executes every frame — presentation- - // side only, guest timeline untouched. Per-game opt-in because MDEC use - // outside movies (loading-screen stills) would briefly trigger it. - bool video_fmv_skip_no_xa = false; - // fmv_skip_no_xa_hold: presentation-side fast-forward latch, in guest - // vblanks, after the most recent silent MDEC decode. Silent preloaded - // logos can retain an authored post-decode wait; a title may opt into a - // longer latch so auto-skip covers that wait too. Default preserves the - // generic four-frame inter-decode hysteresis. - int video_fmv_skip_no_xa_hold = 4; - - // aspect_ratio: display aspect "W:H" (default "4:3" = native). A wider - // aspect (e.g. "16:9") enables the widescreen hack: the GTE squashes - // screen-X by (4*H)/(3*W) around the game's projection centre and the - // present path stretches the 4:3 frame to W:H — net effect is a wider - // field of view for GTE-projected geometry. Screen-space 2D (HUD, FMV, - // sprite widths) stretches; world geometry keeps correct proportions. - int video_aspect_num = 4; - int video_aspect_den = 3; - - // ---- [audio] block ---- - // buffer_ms: steady-state host playback cushion. The ecosystem default - // remains 180 ms because it survives long streamed-stage production gaps; - // games with smoother production cadence may opt into a lower value to - // reduce controller-to-sound latency. This is a game-developer setting, - // not a player preference. - int audio_buffer_ms = 180; - - // spu_hq: enable the SPU float-shadow re-render (Catmull-Rom resample, float - // headroom). Verified-enhancement, default OFF — spu_render output is - // byte-identical to the canon hardware mix when off. The PSX_AUDIO_SHADOW - // env var overrides this at runtime (debug path). - bool audio_spu_hq = false; - - // ---- [controller] block — game-declared input defaults ---- - // default_mode: the pad input mode this game ships with (see PadMode): - // "hybrid" (default) auto-switches DualShock/digital from the player's - // input, "analog" pins DualShock (0x73), "digital" pins a digital pad - // (0x41). A stick-capable title (e.g. Tomba) ships "hybrid" so the stick - // gives variable run speed yet the D-pad keeps its classic digital feel, - // with no launcher toggling. Per-install settings.toml [controller] - // p1_mode/p2_mode still override. `default_mode` sets both ports; - // `p1_mode`/`p2_mode` set one. Legacy `default_analog`/`p1_analog`/ - // `p2_analog` booleans are still accepted (true->analog, false->digital). - bool has_default_mode = false; - int default_p1_mode = PAD_MODE_HYBRID; - int default_p2_mode = PAD_MODE_HYBRID; - - // allow_hybrid: whether the launcher offers the "Hybrid" pad mode at all. - // Default true (Hybrid | Analog | D-Pad). A game that needs an explicit - // analog/digital choice (e.g. one that hard-requires a DualShock) can set - // [controller] allow_hybrid = false to drop Hybrid from the selector. - bool controller_allow_hybrid = true; - - // lock_mode: when true the launcher HIDES the whole pad-mode selector - // (Hybrid | Analog | D-Pad) and forces every port to default_p1_mode. For a - // game that supports exactly one pad type — e.g. Tomba 2, whose driver only - // works as a plain digital pad because the DualShock config-mode handshake - // is unhandled — so the player can't pick a broken mode. Supersedes - // allow_hybrid (which only hides the Hybrid segment). Default false. - bool controller_lock_mode = false; - - // lock_device: when true the launcher HIDES the Player 1/2 controller cards - // entirely (no device picker, no config) — the game's controller type is - // fixed and auto-bound. For a title that presents exactly one hardcoded pad - // type (e.g. Ape Escape = DualShock analog) where exposing a device/mode - // choice is pointless. Distinct from lock_mode (which only hides the pad-mode - // segment but keeps the device dropdown). Default false. - bool controller_lock_device = false; - - // deadzone: default analog-stick deadzone in raw SDL axis units (0..32767). - // Applied both to the stick->d-pad press threshold and the analog-axis centre - // dead-band. Absent => runtime default (12000). Overridden per-install by - // settings.toml [controller] deadzone and by input.ini. - bool has_deadzone = false; - int deadzone = 0; - - // anti_deadzone: minimum radial analog output after leaving deadzone, in - // raw SDL axis units (0..32767). This is a game-owned response setting used - // to compensate a title's own internal stick deadzone. Absent => 0. - bool has_anti_deadzone = false; - int anti_deadzone = 0; - -}; - -// One entry from [[recompiler.bios_vectors]]. -// Describes a BIOS vector dispatch stub (A0/B0/C0) that the BIOS installs -// into low RAM at boot. The recompiler reads the function pointer table from -// the ROM binary at build time and emits a static C switch handler so these -// addresses are resolved as binary-search hits at runtime rather than falling -// through to dirty_ram_interp. -struct BiosVectorTable { - uint32_t ram_addr; // RAM address of the installed stub (e.g. 0xA0) - int index_reg; // CPU register that holds the function index ($t1 = 9) - uint32_t table_rom_addr; // ROM virtual address of the function pointer table - uint32_t table_count; // number of entries to read from the table - // Runtime RAM address of the live function table (used as fallback for - // Shell-patched entries not present in ROM). 0 = no runtime fallback. - uint32_t table_ram_addr; -}; - -// One entry from [[recompiler.bios_aliases]]. -// A RAM address the BIOS installs a simple fixed-target trampoline at -// (e.g. the SIO handler at 0x0CF0 which just jalrs to 0x641C). Emitted as -// a one-liner wrapper in the dispatch table — no table lookup, no switch. -struct BiosAlias { - uint32_t ram_addr; // the installed stub address (e.g. 0x0CF0) - uint32_t target_key; // normalized dispatch key of the target (e.g. 0x641C) -}; - -struct BiosConfig { - std::filesystem::path config_path; // the toml file itself - std::filesystem::path project_root; // resolved via .gitignore/.git/CMakeLists.txt walk - - // [program] block - std::string name; // display name, e.g. "SCPH1001 BIOS" - std::string id; // canonical id, e.g. "SCPH-1001" - std::filesystem::path rom_path; // absolute path to BIOS ROM - uint32_t load_address; - uint32_t entry_pc; - uint32_t text_size; - - // [program.image] block (optional): declared image identity. When - // sha256 is present the recompiler REFUSES to emit from a ROM whose - // computed sha doesn't match — regenerating from the wrong image is a - // build defect, not a warning. redistributable=true marks a BIOS that - // ships WITH the game (OpenBIOS): the runtime then hides the whole - // BIOS-selection surface (couriered via psx_bios_image.image_bundled). - std::string image_sha256; // empty = unchecked - bool image_redistributable = false; - - // [recompiler] block - std::filesystem::path seeds_path; // absolute path to seeds JSON - std::filesystem::path out_dir; // absolute path to output dir - bool strict; // currently always true - std::string out_stem; // derived if not explicit - std::vector bios_vectors; // optional vector dispatch tables - std::vector bios_aliases; // optional fixed-target trampolines - - // [recompiler.address_model] block: the BIOS's boot-time ROM->RAM code - // copies, semantic validation and consumption in BiosAddressModel - // (bios_address_model.h). Empty = the BIOS runs entirely from ROM. - std::vector address_copies; - // [[recompiler.install_slots]]: kernel-RAM PCs the BIOS overwrites with - // dispatch stubs at runtime (see docs/dynamic_handler_install.md). - std::vector install_slots; - - // [recompiler.runtime_exports]: per-image anchors the emitter couriers - // into the generated C (psx_bios_image, runtime/include/psx_bios_image.h) - // for the runtime's HLE tier. 0 = this BIOS has no such anchor — the - // consumer treats the feature as structurally unavailable. - // shell_entry_phys gates the BIOS boot-skip and NOTHING else; it works - // under pure LLE, so every image that exports it skips the boot the same - // way. deliver_event_ret gates the kernel-call HLE tier, separately. The - // runtime decides the two axes in psx_bios_hle_plan() - // (runtime/include/bios_hle_plan.h) — read that header before touching - // either, it records why collapsing them broke OpenBIOS boot-skip. - uint32_t shell_entry_phys = 0; // BIOS boot-skip trigger (bios_hle.c) - uint32_t deliver_event_ret = 0; // $ra after the kernel DeliverEvent jalr - - // NOTE: a BIOS profile has NO [runtime] block. It describes an IMAGE — - // facts about bytes — never a preference; runtime options belong to - // game.toml/settings.toml, where the player and the title can both be - // heard. There used to be a RuntimeConfig here, parsed and never read by - // anything, and bios/OpenBIOS.toml carried a `bios_hle = false` in it that - // looked like the reason HLE was off on OpenBIOS. It was not (the real gate - // is the absent deliver_event_ret anchor), and reading it as one is how the - // boot-skip regression got rationalized instead of fixed. load_bios_config - // now REJECTS a [runtime] block rather than silently ignoring it. -}; - -struct GameConfig { - std::filesystem::path config_path; - std::filesystem::path project_root; - - // [game] block - std::string name; // e.g. "Tomba!" - std::string id; // e.g. "SCUS-94236" - // Optional region label shown by the launcher (e.g. "(USA)", "(Europe)", - // "(Japan)"). Empty = derive from the `id` serial prefix at the call site - // (SCUS/SLUS/LSP -> USA, SCES/SLES -> Europe, SCPS/SLPS/SLPM -> Japan); - // an unrecognized/empty serial yields no region badge. - std::string region; - // How many players the game supports (1 or 2). Defaults to 1; most PSX - // titles configs never set this explicitly. - int players = 1; - std::filesystem::path exe_path; // absolute path to PS-X EXE - uint32_t load_address; - uint32_t entry_pc; - uint32_t text_size; - uint32_t stack_base; // initial $sp - // disc paths (Phase D will properly support multi-disc; for now we - // accept either a single `disc = "..."` or `discs = [...]` and store - // the union here). - std::vector discs; - - // Optional expected disc identity, for the launcher's "Disc verified" badge. - // disc_crc: full-file CRC32 (IEEE) of the data track. disc_sha1: lowercase - // hex SHA-1. Either may be absent (has_disc_crc / disc_sha1.empty()). - bool has_disc_crc = false; - uint32_t disc_crc = 0; - std::string disc_sha1; - - // [recompiler] block - std::filesystem::path seeds_path; // absolute path to seeds (text or json) - std::filesystem::path bios_thunks_path; // optional; empty if not set - // [recompiler] bios_config — BIOS profile this game builds against - // (empty = main_psx resolves the SCPH1001 profile default). - std::filesystem::path bios_config_path; - std::filesystem::path out_dir; - bool strict; - std::string discovery; // "whole-image" (default) or "reachable" - std::string out_stem; // derived if not explicit - // Game-owned, exact MIPS word replacements. IDs and physical instruction - // addresses are unique within a config; see docs/config_schema.md. - std::vector recompiler_patches; - - // [runtime] block (optional) - RuntimeConfig runtime; - - // [widescreen] block (optional) — per-game knobs for the widescreen hack - // ([video] aspect_ratio != 4:3). All default to inert; a game with no - // [widescreen] block gets the plain GTE squash + stretched present only. - // - // sprite_tag_funcs: guest addresses of functions called once per - // character/billboard prim with the prim pointer in $a0 (the recompiler - // emits a psx_ws_sprite_tag(cpu) callback at their entry). Tagged prims - // get their X coords re-squashed around the prim's projected anchor at - // GP0 submission, undoing the present stretch so sprites keep correct - // proportions. - // sprite_anchor_addr: scratchpad address holding the prim's projected - // anchor SXY (written by the game's RTPS preamble) at tag time. - // hud_sprt_squash: center-squash every UNtagged textured-rect (SPRT) - // prim — pure screen-space 2D (HUD, menus) — so it presents at native - // proportions. Untextured TILEs (fades) are never touched. - std::vector ws_sprite_tag_funcs; - uint32_t ws_sprite_anchor_addr = 0; - bool ws_hud_sprt_squash = false; - // auto_ui_squash: proportion-correct textured screen-space primitives in - // the final ordering-table layer. Repeated glyph/icon rows share an anchor - // so centred text and edge counters cannot split at thirds boundaries. - bool ws_auto_ui_squash = false; - - // [data_shards] funcs: functions that get the memoized pure-function - // replay entry/return hooks (psx_datashard_enter/psx_datashard_ret). - // Enhancement-phase load-time work; see docs/DATA_SHARDS.md. Capture is - // self-proving (byte-verified read-set), so listing a function that turns - // out to be impure only costs a poisoned capture, never a wrong replay. - std::vector data_shard_funcs; - - // [recompiler] mod_function_entry_funcs: narrowly selected guest function - // entries that dispatch trusted, statically linked mod callbacks. Empty by - // default, so projects that do not opt in emit no callback overhead. - std::vector mod_function_entry_funcs; - - // [recompiler] hot_funcs: guest addresses that get __attribute__((hot)) - // on their generated C bodies (profile/host locality; no guest semantics). - std::vector hot_funcs; - - // [load_accel.vsync_query] opt-in for a byte-verified PsyQ VSync(mode) - // implementation. mode=-1 returns vsync_counter_addr while bypassing two - // unused MMIO reads; every other mode executes the original function. - uint32_t vsync_query_func = 0; - uint32_t vsync_counter_addr = 0; - uint32_t vsync_gpustat_ptr_addr = 0; - uint32_t vsync_timer1_ptr_addr = 0; - uint32_t vsync_timer1_cache_addr = 0; - // Return addresses of verified CD wait-loop VSync(-1) calls. At these - // sites only, the runtime may advance guest time to the next deliverable - // device event while a sustained CD load is active. - std::vector vsync_event_horizon_sites; - // Separately togglable second tier for additional verified loops, allowing - // per-feature A/B without disabling the accepted base site set. - std::vector vsync_event_horizon_extra_sites; - // When true, any VSync(-1) may event-horizon while CD load/read is busy - // (not only listed return PCs). For titles whose FMV polls many sites. - bool vsync_event_horizon_any = false; - - // Cull-margin widening. The game's per-object draw classifier compares - // (objX - camX + BIAS) against a RANGE derived from the 4:3 screen width; - // the GTE squash shows ~33% more world, so the fixed margin collapses and - // objects pop in/out near the wide-screen edges. We widen the window by - // emitting a runtime margin term psx_ws_x_margin() (0 at 4:3/boot/menu/FMV, - // ~the half-extra-width when stretching) into the relevant immediates: - // cull_bias_sites: an addiu rT,rS,imm → rT = rS + (imm + margin) - // cull_range_sites: an sltiu rT,rS,imm → rT = rS ws_cull_bias_sites; - std::vector ws_cull_range_sites; - std::vector ws_cull_a1_sites; - // Explicit `sltiu rt,sx,W` render rejects for cases where codegen function - // splitting separates the paired vertical test from auto_screen_x. - std::vector ws_cull_screen_x_sites; - - // [widescreen.cull] slti_sites — explicit signed right-edge widen sites - // (`slti rt, sx, W` → psx_ws_cull_slti) for funnel functions the - // auto-detector cannot qualify (e.g. an X-only test with no height compare - // in the same function — Ape Escape 0x8004AB64). Empty by default; regen. - std::vector ws_cull_slti_sites; - // Explicit signed lower-bound sites (`slti rt, sx, -W`). The threshold is - // moved left by the live reveal margin. Empty by default; regen required. - std::vector ws_cull_slti_lower_sites; - // Manual screen-window code often expresses the left edge as a zero-bound - // branch (`bgez`, `bgtz`, `blez`, or `bltz`) rather than an SLTI. At these - // exact branch PCs, move that boundary from 0 to -x_margin while preserving - // the original branch sense and the exact 4:3 result. - std::vector ws_cull_lower_branch_sites; - // Exact constant/store sites which materialize the manual window edges. - // lower_value_sites accepts li-zero forms and `sw zero,off(base)` and emits - // vanilla-x_margin; upper_value_sites accepts li constants and emits - // vanilla+x_margin. span_value_sites accepts a packed signed-left/u16-right - // constant (high16,low16), widening both halves symmetrically. All are - // identity transforms when native-wide is inactive; regen required. - std::vector ws_cull_lower_value_sites; - std::vector ws_cull_upper_value_sites; - std::vector ws_cull_span_value_sites; - // [widescreen.cull] bltz_sites — explicit signed LEFT-edge widen sites - // (`bltz rs, reject` -> psx_ws_cull_bltz), the counterpart to slti_sites. - // detect_cull_bltz_sites only classifies left-edge bltz for functions - // auto_screen_x qualified, so an X-only funnel wired through explicit - // slti_sites has no left-edge widen without this. Empty by default; - // identity at 4:3; regen required. - std::vector ws_cull_bltz_sites; - // Horizontal low-edge form `subu rd,zero,rs` -> `-rs-x_margin`. - // Empty by default; configured sites require regenerated native code. - std::vector ws_cull_negsub_sites; - // `sltiu rt,rs,imm` where rs is an ANDI-masked 16-bit screen X. - // Widens both edges in 16-bit space; empty by default; regen required. - std::vector ws_cull_vxrange_sites; - // Aspect-scaled slti/sltiu far-bound sites. Empty by default; use only for - // pure visibility gates. Configured sites require regenerated native code. - std::vector ws_cull_depth_sites; - // `lw rt,off(rs)` sites loading the X component of a side frustum-plane - // normal feeding a sign test (dot = nx*px + nz*pz). Scaled by the inverse - // aspect factor (4*den)/(3*num) while revealed, which widens the plane - // cone by exactly atan((3*num)/(4*den)*tan(theta)); identity at 4:3. - std::vector ws_cull_plane_nx_sites; - - // `lw rt,off(rs)` sites loading a per-primitive X-reject bound that is - // compared (sltu) against ANDI-masked u16 screen X. While the margins are - // revealed the load yields INT32_MAX (reject disabled; the wide-surface - // scissor clips the overflow and wrapped off-left coords pass); the - // vanilla loaded value at 4:3. Empty by default; regen required. - std::vector ws_cull_xclip_load_sites; - // Exact comparison sites whose result is forced only while widescreen - // reveals extra world. Used for proven object/model participation gates - // where maximal overdraw is preferable to range guessing. Each entry is - // guarded by the complete MIPS word; 4:3 executes the vanilla comparison. - std::vector ws_cull_keep_sites; - // Exact 12-bit angular half-extents used by terrain-cell frusta. - std::vector ws_cull_angle_sites; - // Signed wrapping yaw +/- half-FOV offsets used to seed portal traversal. - std::vector ws_cull_yaw_cone_sites; - // Signed camera-horizontal side-plane rejects used after traversal. - std::vector ws_cull_horizontal_slt_sites; - // Full-word-guarded model-participation cosine compares widened only in - // the camera-horizontal plane. Empty/default is completely inert. - WidescreenAspectConeConfig ws_aspect_cone; - // Extra per-side render/terrain participation beyond the visible edge. - int ws_cull_guard_pixels = 0; - // Additional per-side lead used only by the explicit bias_sites and - // range_sites world-space activation windows. This lets a game activate - // already-resident objects well before the visible edge without widening - // terrain producers or fixed-capacity render cones by the same amount. - int ws_cull_activation_guard_pixels = 0; - - // [widescreen.cull] screen_w_imms / screen_h_imms — the width/height - // immediates of the GTE screen-extent reject signature, per game (the - // display width varies: Tomba 320 → 0x140/0x141; Ape Escape 368 → 0x181). - // Consumed by the auto_screen_x detector + emit on every backend (the - // runtime mirrors get them via gpu_ws_set_cull_imms). Defaults preserve - // the original Tomba signature. - std::vector ws_cull_w_imms; - std::vector ws_cull_h_imms; - - // Backdrop screen-X squash ([widescreen.backdrop] x_sites). The parallax - // 2D backdrop layer (ocean/cloud/mountain/grass — overlay actor handlers) - // computes screenX = (worldX - camX) >> parallax in pure integer math and - // stores it to the object's screen-X field WITHOUT the GTE, so the GTE - // X-squash that gives 3D the wider 16:9 FOV never reaches it; far pieces - // sit past the 320px edge and are clipped (the edge "void"/pop-in). Each - // x_site is a `sh rt,off(base)` storing the FINAL screenX; we emit it as - // `write_half(base+off, psx_ws_backdrop_x(rt))` so the value is squashed - // around screen centre (identity at 4:3). These addresses live in OVERLAY - // code, so the overlay compile must see this config (--ws-config). A regen - // is required; empty by default. - std::vector ws_backdrop_x_sites; - - // [widescreen.backdrop] unsquash_funcs — far-backdrop driver functions whose - // body is bracketed with gte_ws_set_suppress(1)/(0) so the GTE X-squash is - // OFF for their (far, parallax) draws: the backdrop fills the stretched 16:9 - // frame instead of leaving edge void (8C). Main-EXE addresses; regen-class. - std::vector ws_backdrop_unsquash_funcs; - - // [widescreen.dome] call_sites: exact guest JAL addresses whose GTE - // projections belong to a finite curved backdrop mesh authored for 4:3. - std::vector ws_dome_call_sites; - - // [widescreen.cull] auto_screen_x — automatic horizontal-FOV cull widening. - // GTE-projected render funnels reject a primitive when ALL its vertices fall - // off the 4:3 frame: a per-vertex `sltiu vN, SX, 0x140` (right edge) paired - // with `sltiu vN, SY, 0xE0` (bottom edge) in the same function. When this is - // true the recompiler auto-detects that signature and emits every width - // compare (0x140 / inclusive 0x141) with + 2*psx_ws_x_margin(), so the - // wider 16:9 field of view is submitted instead of culled at 320 — no - // per-site address list needed. 0 at 4:3 ⇒ byte-identical. Off by default; - // a regen is required. (Vertical 0xE0 bound is left untouched.) - bool ws_auto_screen_x_cull = false; - - // [widescreen.cull] auto_backdrop — automatic far-backdrop column PRELOAD. - // Scrolling 2D backdrop layers (sky/ground/cloud/flower-field tile rows) - // generate only a camera-windowed ~4:3 range of tile columns, so the 16:9 - // revealed margin shows void until the camera moves. When true the recompiler - // auto-detects each column-window generator by its invariant (the /96 magic - // 0x66666667 dividing the 0x176 camera-X, the sra-by-5 divide tail, and the - // move/addiu loop-bound finalize — see ws_backdrop_detect.h) and rewrites the - // window START to 0 and END high via psx_ws_backdrop_value(); the generator's - // own low/high clamps then pin the loop to the whole finite row. Generators - // are overlay-resident, so the overlay compile must see this (--ws-config). - // 0 at 4:3 (native-wide inactive) => byte-identical. Off by default; regen. - bool ws_auto_backdrop_preload = false; - - // [widescreen] full_2d — pure-2D sprite game (e.g. Mega Man X6) that never - // emits the sprite_tag hook the 3D detector relies on. When true, every - // in-game frame is treated as "gameplay" so native-wide engages and the 2D - // scene is presented widescreen (the framework's normal full-2D screens - // pillarbox 4:3, which is wrong for a game that IS 2D end-to-end). Runtime- - // only (read at startup; no codegen impact) — no regen required. The wider - // field of view itself is supplied by [widescreen.cull]/engine hooks; this - // flag only opts the title into the 2D widescreen present path. Off by - // default; the env var PSX_WS_FORCE_2D=1 forces it on for testing. - bool ws_full_2d = false; - - // [widescreen] gte_game_mode — GTE-activity gameplay detector for a fully - // 3D title with no sprite-tag helper (e.g. Ape Escape). When true, a frame - // that projects enough vertices through RTPS/RTPT is stamped as gameplay - // (native-wide engages); genuine full-2D screens (save/options) still - // pillarbox 4:3. Runtime-only — no regen required. Off by default. - bool ws_gte_game_mode = false; - // Optional authoritative game-state gate for titles whose menus also - // render enough 3D geometry to fool gte_game_mode. When configured, - // native-wide is active only while the guest word matches one listed - // value. Runtime-only; both fields must be supplied together. - uint32_t ws_gameplay_state_addr = 0; - std::vector ws_gameplay_state_values; - - // [widescreen] native_wide — select the newer wide render-target path. - // Defaults on for compatibility. Titles can keep the original GTE-squash - // + stretched-present path when native-wide is not regression-free. - bool ws_native_wide = true; - - // [widescreen] nw_hud_corners — in native-wide, push outer-third screen- - // space HUD sprites out to the true wide-frame corners (they otherwise sit - // inset by the reveal offset). Runtime-only — no regen. Off by default. - bool ws_nw_hud_corners = false; - - // [widescreen] nw_left_hud_packet_lo / nw_left_hud_packet_hi — optional - // half-open physical-RAM source range for a specifically identified HUD - // pool. In native-wide, primitives from this pool anchor by screen third - // (left/right), without moving similarly placed scenery from other pools - // (essential for pure-2D games whose world is also screen-space prims). - // Both values must be present together. Runtime-only; no regen required. - uint32_t ws_nw_left_hud_packet_lo = 0; - uint32_t ws_nw_left_hud_packet_hi = 0; - - // [widescreen] nw_backdrop — in native-wide, stretch a screen-space quad - // that covers the whole 4:3 framebuffer (sky gradient / backdrop image) to - // fill the wide frame, so it stops pillarboxing at the reveal margins. - // Runtime-only — no regen. Off by default. - bool ws_nw_backdrop = false; - - // [widescreen] clear_reveal — opt a title into synthetic native-wide margin - // cleanup. A game-specific stage/map boundary can clear only proven-void - // sides while preserving the canonical 4:3 surface and guest VRAM. Runtime- - // only gate; any game-specific init hook remains regen-class. Off by default. - bool ws_clear_reveal = false; - - // [widescreen] nw_flat_backdrop — in the native-wide mirror only, stretch - // untextured flat primitives across the wider output. This is for games - // whose authored 4:3 sky/backdrop is emitted as flat polygons rather than - // a recognizable full-frame textured quad. Runtime-only; off by default. - bool ws_nw_flat_backdrop = false; - - // [widescreen] nw_phase_backdrop — stretch textured primitives emitted - // before the frame's first shaded 3D primitive. This isolates an authored - // 2D sky/backdrop phase from the later textured foreground. Runtime-only; - // off by default because draw ordering is title-specific. - bool ws_nw_phase_backdrop = false; - - // Expand only textured polygon vertices that already lie beyond the - // canonical 4:3 boundary. Useful for finite arena/background meshes while - // leaving actors, HUD, sprites, and centre geometry untouched. - bool ws_nw_textured_edges = false; - int ws_nw_textured_edge_scale = 0; // percent; 0 = aspect-derived - - // Render the complete wide mirror instead of splicing its centre from - // canonical VRAM. Required when edge-crossing polygon interpolation is - // transformed in the mirror. - bool ws_nw_full_mirror = false; - - // [[widescreen.signed_x_bound]] guarded LUI sites whose signed Q16 - // constants scale with the active native-wide field and remain identity in - // 4:3/menus/FMV. Shared by static codegen, overlay JIT, and interpreter. - std::vector ws_signed_x_bound_sites; - // [widescreen] offer — whether the launcher OFFERS its EXPERIMENTAL - // Widescreen toggle for this title. Default true. Set false while a - // title's widescreen is unported/unvalidated (e.g. MMX4 at bring-up): - // the toggle is hidden in the launcher UI AND the runtime clamps the - // display aspect to 4:3 after all config sources, so a stale persisted - // 16:9 in settings.toml can never engage the hack. Runtime-only (read at - // startup; no codegen impact) — no regen required. - bool ws_offered = true; - bool vulkan_offered = false; - - // [widescreen] adaptive_view — let the user opt into a live, resize-driven - // aspect instead of selecting only fixed 4:3/16:9/21:9 modes. The fixed - // aspect remains the initial window shape; the live view is clamped to the - // widest aspect this title offers. - bool ws_adaptive_view = false; - - // [widescreen] offer_ultrawide — expose a separate experimental 21:9 - // launcher choice for titles that have explicitly tested it. Default off; - // ordinary widescreen offer remains the independent 16:9 choice. - bool ws_ultrawide_offered = false; - - // [widescreen.bg2d] — pure-2D background tile-loop widen (e.g. MMX6's - // FUN_800270d0). Three instruction addresses in the per-layer BG renderer - // whose column count and loop start are rewritten so the loop draws the - // 16:9 reveal columns on both sides of the 320 view (see gpu.c - // psx_ws_bg2d_* helpers — identity at 4:3 / 512 hi-res mode). Regen-class. - // count_site: either the `li rt,21` column-count load (addiu/ori), or - // the loop-closing inline `slti[u] rt,index,21` bound - // compare (MMX4/MMX5). - // startcol_site: the `andi rt,rs,0x3f` start tile-column mask. - // startx_site: the `sra rd,rt,sa` or `subu rd,zero,rt` start screen-x. - // 0 = unset (feature off). Verified by opcode at gen time. - uint32_t ws_bg2d_count_site = 0; - uint32_t ws_bg2d_startcol_site = 0; - uint32_t ws_bg2d_startx_site = 0; - // stream_left_site / stream_right_site: the addiu instructions in the tile- - // RING STREAMER that compute the left (scrollX-16) and right (scrollX+16, - // before +width) leading-edge world-X. Pushed out by LEFT*16 so the ring is - // populated across the widened column window (else the extra columns show - // empty/stale tiles). 0 = unset. - uint32_t ws_bg2d_stream_left_site = 0; - uint32_t ws_bg2d_stream_right_site = 0; - // bufbase_site: the driver addu computing the BG packet-buffer address - // (base 0x800B91C0 + bufidx*0x4000); relocated to a larger free buffer when the - // widen is active. cap_site: the renderer's per-frame tile-cap slti (counter<1000); - // raised to match the bigger buffer. Together they cure the dense-stage overflow. - uint32_t ws_bg2d_bufbase_site = 0; - uint32_t ws_bg2d_cap_site = 0; - // Tile-ring layout used by the once-per-frame freshness refill. Defaults - // describe MMX6; sibling engines such as MMX5 override the shifted RAM - // addresses and smaller ring width in game.toml. - uint32_t ws_bg2d_layer_base = 0x800971F8u; - uint32_t ws_bg2d_ring_base = 0x800A21B8u; - uint32_t ws_bg2d_map_size_addr = 0x800CD338u; - uint32_t ws_bg2d_layer_stride_addr = 0x8008EC10u; - uint32_t ws_bg2d_ring_cols = 64; - uint32_t ws_bg2d_layer_count = 3; - uint32_t ws_bg2d_layer_struct_stride = 0x54; - // init_func: full tile-ring initializer called only when an independent - // layer's stage data is dirty. A callback at entry invalidates stale host - // reveal pixels once before the new stage background is submitted. - uint32_t ws_bg2d_init_func = 0; - uint32_t ws_bg2d_packet_cap = 1000; -}; - -// UserSettings — the launcher/in-game-UI-written, user-editable override layer. -// -// Lives in a `settings.toml` next to the runtime exe (NOT in the repo). It is -// layered on top of the bundled game.toml at startup: any field present here -// overrides the corresponding game.toml value, and the command line overrides -// both. Absent fields fall through to game.toml. A settings UI seeds this file -// from game.toml defaults the first time it writes. -// -// Unlike game.toml, paths here are stored verbatim (the user picked them); they -// are NOT resolved against a project root. -struct UserSettings { - // Set when the file existed but failed to parse as TOML: every field below - // is defaults and the caller must warn loudly — silently dropping a user's - // hand-edited settings looks like the settings "don't work", and a later - // launcher save would overwrite their file with defaults. - bool parse_error = false; - - // [video] - bool has_renderer = false; int renderer = 0; // 0=software,1=opengl - bool has_supersampling = false; int supersampling = 1; // 1..8 - // Window size: width in px; height is always width*3/4 (PSX 4:3). Applies to - // both the launcher and the emulator window so they boot at the same size. - bool has_window_width = false; int window_width = 1280; // -> 1280x960 - bool has_antialiasing = false; bool antialiasing = true; - bool has_texture_filter = false; int texture_filter = 0; // 0=nearest,1=bilinear - bool has_screen_kind = false; int screen_kind = 0; // 0..3 (ScreenKind) - bool has_auto_skip_fmv = false; bool auto_skip_fmv = false; // skip FMVs - // Turbo through in-game load screens: while the CD data stream is active, run - // the guest unpaced (host speed) to compress load wall-time. All guest timing - // (VBlanks/callbacks/sectors) is preserved and audio plays through. Default ON - // (the per-game game.toml value seeds the launcher toggle; user choice in - // settings.toml overrides it). - bool has_turbo_loads = false; bool turbo_loads = true; - bool has_fast_boot = false; bool fast_boot = false; - // HLE BIOS tier toggle (see RuntimeConfig::bios_hle). Overrides game.toml. - bool has_bios_hle = false; bool bios_hle = false; - // [video] fullscreen: universal tri-state (matches every recomp-ui console's - // launcher control). 0 = windowed (default), 1 = borderless desktop - // fullscreen, 2 = exclusive fullscreen (real display-mode change). The - // in-game Alt+Enter / Cmd+Ctrl+F hotkey toggles live between windowed and - // whichever of these is configured. - bool has_fullscreen = false; int fullscreen = 0; - // Low-latency present knobs. low_latency_input re-samples the pad after the - // wall-clock pacer (just before present) so the next CPU frame reads fresh - // input instead of input ~one frame stale (the dominant input->photon cost - // on a vsync-light box). vsync: 1=on (tear-free), 0=immediate (lowest - // display latency, may tear), -1=adaptive. - bool has_low_latency_input = false; bool low_latency_input = true; - bool has_vsync = false; int vsync = 1; - bool has_frame_interpolation = false; bool frame_interpolation = false; - bool has_frame_interpolation_fps = false; int frame_interpolation_fps = 0; - // Frame-interpolation mode: 0 = legacy linear, 1 = legacy adaptive, - // 2 = geometry inter-frame redraw. Activation remains session-only in - // Disruptor; its runtime migrates saved legacy modes to geometry. - bool has_frame_interpolation_blend = false; int frame_interpolation_blend = 0; - // [launcher] — when true, boot straight into the game and skip the GUI - // launcher window (mirrors snesrecomp's SkipLauncher). Overridable per-run: - // `--launcher` forces the GUI back on; `PSX_NO_LAUNCHER=1` forces it off. - bool has_skip_launcher = false; bool skip_launcher = false; - // [netplay] — display name for lobby browser / room. Prompted once on first - // Netplay Lobbies visit; Change Player Name updates it. - bool has_netplay_player_name = false; std::string netplay_player_name; - bool has_netplay_lobby_url = false; std::string netplay_lobby_url; - bool has_aspect_ratio = false; int aspect_num = 4; // display aspect W:H - int aspect_den = 3; // (4:3 = native) - bool has_adaptive_view = false; bool adaptive_view = false; - // [audio] - bool has_spu_hq = false; bool spu_hq = false; - // Host-output controls. These scale the final mixed stereo stream and do - // not alter SPU registers, voice state, CD playback state or guest saves. - bool has_master_volume = false; int master_volume = 100; // 0..100 - bool has_audio_muted = false; bool audio_muted = false; - // [bios] / [disc] / [memcard] - bool has_bios_path = false; std::filesystem::path bios_path; - bool has_disc_path = false; std::filesystem::path disc_path; - bool has_memcard_dir = false; std::filesystem::path memcard_dir; - // Per-slot memory-card overrides. An explicit card path overrides the - // /card.mcd default; the enable flag inserts/removes the card. - bool has_memcard1_path = false; std::filesystem::path memcard1_path; - bool has_memcard2_path = false; std::filesystem::path memcard2_path; - bool has_memcard1_enabled = false; bool memcard1_enabled = true; - bool has_memcard2_enabled = false; bool memcard2_enabled = true; - - // [controller] — per-player input device + pad type. device is one of: - // "none" — no pad in this port (port not connected) - // "keyboard" — driven by the keyboard map (input.ini) - // "" — an SDL game-controller GUID (SDL_JoystickGetGUIDString) - // p1_mode/p2_mode select the emulated pad behaviour (see PadMode): - // hybrid (default) / analog / digital. Defaults: P1 keyboard, P2 none. - bool has_p1_device = false; std::string p1_device = "keyboard"; - bool has_p2_device = false; std::string p2_device = "none"; - // Pad input mode per player (see PadMode): hybrid (default) / analog / - // digital. Persisted as p1_mode/p2_mode strings. Legacy p1_analog/p2_analog - // booleans are still read for back-compat (true->analog, false->digital). - bool has_p1_mode = false; int p1_mode = PAD_MODE_HYBRID; - bool has_p2_mode = false; int p2_mode = PAD_MODE_HYBRID; - // Analog-stick deadzone, raw SDL axis units (0..32767). The launcher edits - // this as 0-100% (raw = pct*32767/100), mirroring snesrecomp's GamepadDeadzone. - bool has_deadzone = false; int deadzone = 12000; - // Localization: the launcher's chosen language code (feeds RuntimeConfig - // .language / g_lang). "off"/"jp"/"" = untranslated native game. Persisted to - // settings.toml [localization].language. - bool has_language = false; std::string language = "en"; - - bool has_parappa_timing_mode = false; std::string parappa_timing_mode = "stock"; - bool has_parappa_timing_extra_early = false; int parappa_timing_extra_early = 0; - bool has_parappa_timing_extra_late = false; int parappa_timing_extra_late = 0; - - // [disruptor] — Disruptor-specific, live-safe enhancement settings. - // Kept optional so a missing key continues to inherit the launch-time - // default or an explicit per-run override. - bool has_mouse_aim = false; bool mouse_aim = false; - bool has_modern_controls = false; bool modern_controls = false; - bool has_horizontal_sensitivity = false; double horizontal_sensitivity = 0.1; - bool has_invert_horizontal = false; bool invert_horizontal = false; - bool has_vertical_look = false; bool vertical_look = false; - bool has_vertical_sensitivity = false; double vertical_sensitivity = 0.08; - bool has_invert_vertical = false; bool invert_vertical = false; - bool has_high_precision_camera = false; bool high_precision_camera = false; - bool has_geometry_correction = false; bool geometry_correction = false; - bool has_perspective_textures = false; bool perspective_textures = false; - bool has_frame_unlock = false; bool frame_unlock = false; - bool has_skip_intro = false; bool skip_intro = false; - bool has_language_disc = false; std::string language_disc; // image of another region's disc, "" for none - bool has_language_discs = false; std::vector language_discs; // the ones named before, for a launcher to offer again - bool has_hud_scale = false; int hud_scale = 100; - bool has_improved_shadows = false; bool improved_shadows = false; - bool has_ambient_occlusion = false; bool ambient_occlusion = false; - bool has_occlusion_reach = false; int occlusion_reach = 160; // camera depth units, 50..800 - bool has_occlusion_strength = false; int occlusion_strength = 80; // percent, 0..300 - bool has_gap_filling = false; bool gap_filling = true; - bool has_depth_sorted_world = false; bool depth_sorted_world = false; - // An index into kDisruptorDiagnosticViews. A session's own: at start only a capsule's replay takes it from the file. - bool has_diagnostic_view = false; int diagnostic_view = 0; -}; -inline constexpr const char* kDisruptorDiagnosticViews[] = {"off", "shading", "depth", "normals", "fade", "mesh"}; -inline constexpr int kDisruptorDiagnosticViewCount = 6; - -// GameOptions — the game's OWN native OPTION-screen settings, declared in a -// dedicated, self-contained `game_options.toml` next to game.toml. Kept entirely -// separate from GameConfig (recompiler/aftermarket config) and UserSettings -// (launcher overrides): these describe the GAME's settings, persisted across -// launches because some titles keep them only in a per-boot RAM global with no -// memory-card config block (issue #5). See game_options.{h,c} for the runtime. -struct GameOption { - std::string name; // key written to the saved-values state file - uint32_t addr = 0; // guest RAM address of the canonical config global - int size = 1; // 1 or 2 bytes - uint32_t init_store_pc = 0; // PC of the boot-init sb/sh that writes this - // global's default; the recompiler rewrites it - // to apply the persisted value (restore-at-init). - // Optional valid-value range for RESTORE-time validation. When has_range is - // true (game_options.toml declared `max`, and optionally `min`), a persisted - // value outside [vmin, vmax] is rejected at load — the game's own default is - // used instead — so a corrupt/stale options file can never inject an out-of- - // range value (e.g. an enum used as a jump-table index -> wild jump). Absent - // => no validation (e.g. signed screen offsets). Only the runtime consumes - // this; the recompiler ignores it (no codegen impact, no regen needed). - bool has_range = false; - int64_t vmin = 0; - int64_t vmax = 0; -}; -struct GameOptions { - std::vector options; // empty => feature off for this game -}; - -// Load game_options.toml ([[option]] array of {name, addr, size}). Returns an -// empty GameOptions if the file is missing/unreadable; throws on a malformed -// declared entry (bad addr / size) so a typo is surfaced, not silently dropped. -GameOptions load_game_options(const std::filesystem::path& path); - -// Load settings.toml. Returns an all-defaults (all has_*=false) struct if the -// file is missing or unreadable. Malformed values are skipped (best-effort: -// the launcher must still be able to start so the user can fix them), so this -// never throws. -UserSettings load_user_settings(const std::filesystem::path& path); - -// Write settings.toml deterministically through a same-directory temporary -// file and atomically replace the destination. Returns false on I/O failure; -// the previous destination is left intact and the temporary file is removed. -bool save_user_settings(const std::filesystem::path& path, const UserSettings& s); - -// Locate the project root by walking upward from `config_path` until a -// directory containing `.gitignore`, `.git`, or `CMakeLists.txt` is found. -// Throws std::runtime_error if not found within 8 levels. -std::filesystem::path find_project_root(const std::filesystem::path& config_path); - -// Load a BIOS config TOML. Throws std::runtime_error on schema violations. -BiosConfig load_bios_config(const std::filesystem::path& config_path); - -// Load a game config TOML. Throws std::runtime_error on schema violations. -GameConfig load_game_config(const std::filesystem::path& config_path); - -// Hash exactly the per-game settings that can change generated overlay code. -// The runtime and compile_overlays.py (via psxrecomp-game's -// --overlay-config-hash query) use this as part of the cache namespace, so a -// widescreen/patch config edit can never reuse shards emitted under the old -// rules. Runtime-only settings and comments deliberately do not participate. -uint32_t overlay_codegen_config_hash(const GameConfig& config); - -} // namespace PSXRecompV4 diff --git a/psxrecomp-overlay/recompiler/src/main_psx.cpp b/psxrecomp-overlay/recompiler/src/main_psx.cpp deleted file mode 100644 index b508c97..0000000 --- a/psxrecomp-overlay/recompiler/src/main_psx.cpp +++ /dev/null @@ -1,1636 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "ps1_exe_parser.h" -#include "gte.h" -#include "function_analysis.h" -#include "control_flow.h" -#include "code_generator.h" -#include "annotations.hpp" -#include "config_loader.h" -#include "rabbitizer.hpp" -#include "fmt/format.h" - -/* Baked emitter-source hash (recompiler/CMakeLists.txt custom command; same - * hash_codegen.cmake + canonical source list as the runtime's overlay cache - * tag). Exposed via --codegen-hash so compile_overlays.py can verify THIS - * binary was built from the same emitter sources as the cg tag it stamps — - * the stale-recompiler-binary guard. 0 when built without the bake (guard - * consumers treat that as "cannot verify" and fail loudly). */ -#if defined(__has_include) -# if __has_include("psxrecomp_baked_codegen_hash.h") -# include "psxrecomp_baked_codegen_hash.h" -# endif -#endif -#ifndef PSX_OVERLAY_CODEGEN_HASH -# define PSX_OVERLAY_CODEGEN_HASH 0u -#endif - -namespace { - -struct AliasEntry { uint32_t addr, host_start, host_end; }; - -// Materialize alias entries as overlapping functions, grouped by host. Each -// group shares one emitted body (entry switch + host-range blocks); every -// member lists its siblings so all group CFGs carry identical block leaders. -// Host functions are emitted unchanged — aliases never cap or truncate them. -void materialize_alias_groups(PSXRecomp::FunctionAnalysisResult& result, - const std::vector& alias_entries) { - std::map> by_host; - for (const auto& ae : alias_entries) { - by_host[ae.host_start].push_back(&ae); - } - for (const auto& [host_start, group] : by_host) { - uint32_t producer_lo = 0; - uint32_t producer_hi = 0; - for (const auto& function : result.functions) { - if (function.start_addr == host_start) { - producer_lo = function.producer_lo; - producer_hi = function.producer_hi; - break; - } - } - std::vector entries; - for (const AliasEntry* ae : group) entries.push_back(ae->addr); - for (const AliasEntry* ae : group) { - PSXRecomp::Function af; - af.start_addr = ae->addr; - af.end_addr = ae->host_end; - af.size = af.end_addr - af.start_addr; - af.has_prologue = false; - af.has_epilogue = false; - af.stack_frame_size = 0; - af.name = fmt::format("func_{:08X}", ae->addr); - af.is_data_section = false; - af.alias_walk_lo = ae->host_start; - af.alias_group_entries = entries; - af.producer_lo = producer_lo; - af.producer_hi = producer_hi; - result.functions.push_back(af); - } - } -} - -} // namespace - -int main(int argc, char** argv) { - const auto print_usage = [&]() { - fmt::print("Usage: {} --config \n", argv[0]); - fmt::print(" {} [--seeds ] [--out-dir ] [--strict] [--inspect]\n", argv[0]); - fmt::print("Example: {} SCUS_942.36 --seeds seeds/functions.txt --out-dir generated --strict\n", argv[0]); - }; - for (int i = 1; i < argc; ++i) { - const std::string arg = argv[i]; - if (arg == "-h" || arg == "--help") { - print_usage(); - return 0; - } - } - if (argc < 2) { - print_usage(); - return 0; - } - - /* --codegen-hash: print the baked emitter-source hash and exit. Handled - * before the banner so the output is machine-parseable (one hex line). - * compile_overlays.py compares this to the hash from --runtime-include and - * refuses to build shards with a stale recompiler binary. */ - for (int i = 1; i < argc; ++i) { - if (std::string(argv[i]) == "--codegen-hash") { - fmt::print("{:08x}\n", (unsigned)PSX_OVERLAY_CODEGEN_HASH); - return 0; - } - if (std::string(argv[i]) == "--overlay-config-hash" && i + 1 < argc) { - try { - const auto cfg = PSXRecompV4::load_game_config(argv[i + 1]); - fmt::print("{:08x}\n", - (unsigned)PSXRecompV4::overlay_codegen_config_hash(cfg)); - return 0; - } catch (const std::exception& e) { - fmt::print(stderr, "overlay config hash failed: {}\n", e.what()); - return 2; - } - } - } - - fmt::print("PSXRecomp - PlayStation 1 Static Recompiler\n"); - fmt::print("============================================\n\n"); - - // ── --config short-form ──────────────────────────────────── - // If --config is provided, all paths come from the TOML and other - // CLI flags are ignored. Positional form below stays for back-compat. - std::filesystem::path config_path; - // --ws-config : load ONLY the [widescreen] site lists from this TOML - // without treating it as the full game config. Used by compile_overlays.py - // so overlay compilation gets the widescreen emits (sprite-tag / cull / - // backdrop) whose addresses live in overlay code — the overlay path is - // positional (no --config), so this is the channel for those sites. - std::filesystem::path ws_config_path; - for (int i = 1; i < argc; ++i) { - std::string a = argv[i]; - if (a == "--config" && i + 1 < argc) { - config_path = argv[i + 1]; - break; - } - if (a.rfind("--config=", 0) == 0) { - config_path = a.substr(std::string("--config=").size()); - break; - } - } - for (int i = 1; i < argc; ++i) { - std::string a = argv[i]; - if (a == "--ws-config" && i + 1 < argc) { ws_config_path = argv[i + 1]; break; } - if (a.rfind("--ws-config=", 0) == 0) { - ws_config_path = a.substr(std::string("--ws-config=").size()); - break; - } - } - - std::filesystem::path exe_path; - std::string extra_funcs_storage; // lifetime anchor for the .c_str() below - const char* extra_funcs_path = nullptr; - bool inspect_mode = false; - bool overlay_mode = false; - bool reachable_discovery = false; - std::set ws_tag_funcs; // [widescreen] sprite_tag_funcs - std::set ds_funcs; // [data_shards] funcs - std::set mod_entry_funcs; // trusted game-mod entry hooks - std::set hot_funcs; // [recompiler] hot_funcs - std::map> vsync_query_hle_funcs; - std::set ws_cull_bias, ws_cull_range, ws_cull_a1; // [widescreen.cull] - std::set ws_cull_screen_x; // [widescreen.cull] screen_x_sites - std::set ws_cull_slti; // [widescreen.cull] slti_sites - std::set ws_cull_slti_lower; // [widescreen.cull] slti_lower_sites - std::set ws_cull_lower_branch; // [widescreen.cull] lower_branch_sites - std::set ws_cull_lower_value; // [widescreen.cull] lower_value_sites - std::set ws_cull_upper_value; // [widescreen.cull] upper_value_sites - std::set ws_cull_span_value; // [widescreen.cull] span_value_sites - std::set ws_cull_bltz; // [widescreen.cull] bltz_sites - std::set ws_cull_negsub; // [widescreen.cull] negsub_sites - std::set ws_cull_vxrange; // [widescreen.cull] vxrange_sites - std::set ws_cull_depth; // [widescreen.cull] depth_sites - std::set ws_cull_plane_nx; // [widescreen.cull] plane_nx_sites - std::set ws_cull_xclip_load; // [widescreen.cull] xclip_load_sites - std::vector ws_cull_keep; - std::vector ws_cull_angle; - std::vector ws_cull_yaw_cone; - std::vector - ws_cull_horizontal_slt; - PSXRecompV4::WidescreenAspectConeConfig ws_aspect_cone; - int ws_cull_activation_guard_pixels = 0; - std::vector ws_cull_w_imms = { 0x140, 0x141 }; // [widescreen.cull] screen_w_imms - std::vector ws_cull_h_imms = { 0xE0, 0xF1 }; // [widescreen.cull] screen_h_imms - std::set ws_backdrop_x; // [widescreen.backdrop] x_sites - std::set ws_backdrop_unsquash; // [widescreen.backdrop] unsquash_funcs - bool ws_auto_screen_x_cull = false; // [widescreen.cull] auto_screen_x - std::set persist_init_sites; // [persist_options] init-store hooks (game_options.toml) - std::vector instruction_patches; - std::vector ws_signed_x_bound_sites; - bool ws_auto_backdrop_preload = false; // [widescreen.cull] auto_backdrop - uint32_t ws_bg2d_count_site = 0, ws_bg2d_startcol_site = 0, - ws_bg2d_startx_site = 0, - ws_bg2d_stream_left_site = 0, - ws_bg2d_stream_right_site = 0, - ws_bg2d_bufbase_site = 0, - ws_bg2d_cap_site = 0, - ws_bg2d_init_func = 0; // [widescreen.bg2d] - std::filesystem::path out_dir = "generated"; - uint32_t configured_text_size = 0; - std::filesystem::path bios_profile_path; // [recompiler] bios_config - - if (!config_path.empty()) { - const auto cfg = PSXRecompV4::load_game_config(config_path); - exe_path = cfg.exe_path; - configured_text_size = cfg.text_size; - reachable_discovery = cfg.discovery == "reachable"; - extra_funcs_storage = cfg.seeds_path.string(); - extra_funcs_path = extra_funcs_storage.c_str(); - bios_profile_path = cfg.bios_config_path; - out_dir = cfg.out_dir; - instruction_patches = cfg.recompiler_patches; - ws_tag_funcs.insert(cfg.ws_sprite_tag_funcs.begin(), - cfg.ws_sprite_tag_funcs.end()); - ds_funcs.insert(cfg.data_shard_funcs.begin(), cfg.data_shard_funcs.end()); - mod_entry_funcs.insert(cfg.mod_function_entry_funcs.begin(), - cfg.mod_function_entry_funcs.end()); - hot_funcs.insert(cfg.hot_funcs.begin(), cfg.hot_funcs.end()); - if (cfg.vsync_query_func) - vsync_query_hle_funcs[cfg.vsync_query_func] = { - cfg.vsync_counter_addr, cfg.vsync_gpustat_ptr_addr, - cfg.vsync_timer1_ptr_addr, cfg.vsync_timer1_cache_addr }; - ws_cull_bias.insert(cfg.ws_cull_bias_sites.begin(), cfg.ws_cull_bias_sites.end()); - ws_cull_range.insert(cfg.ws_cull_range_sites.begin(), cfg.ws_cull_range_sites.end()); - ws_cull_a1.insert(cfg.ws_cull_a1_sites.begin(), cfg.ws_cull_a1_sites.end()); - ws_cull_screen_x.insert(cfg.ws_cull_screen_x_sites.begin(), cfg.ws_cull_screen_x_sites.end()); - ws_cull_slti.insert(cfg.ws_cull_slti_sites.begin(), cfg.ws_cull_slti_sites.end()); - ws_cull_slti_lower.insert(cfg.ws_cull_slti_lower_sites.begin(), - cfg.ws_cull_slti_lower_sites.end()); - ws_cull_lower_branch.insert(cfg.ws_cull_lower_branch_sites.begin(), - cfg.ws_cull_lower_branch_sites.end()); - ws_cull_lower_value.insert(cfg.ws_cull_lower_value_sites.begin(), - cfg.ws_cull_lower_value_sites.end()); - ws_cull_upper_value.insert(cfg.ws_cull_upper_value_sites.begin(), - cfg.ws_cull_upper_value_sites.end()); - ws_cull_span_value.insert(cfg.ws_cull_span_value_sites.begin(), - cfg.ws_cull_span_value_sites.end()); - ws_cull_bltz.insert(cfg.ws_cull_bltz_sites.begin(), cfg.ws_cull_bltz_sites.end()); - ws_cull_negsub.insert(cfg.ws_cull_negsub_sites.begin(), cfg.ws_cull_negsub_sites.end()); - ws_cull_vxrange.insert(cfg.ws_cull_vxrange_sites.begin(), cfg.ws_cull_vxrange_sites.end()); - ws_cull_depth.insert(cfg.ws_cull_depth_sites.begin(), cfg.ws_cull_depth_sites.end()); - ws_cull_plane_nx.insert(cfg.ws_cull_plane_nx_sites.begin(), cfg.ws_cull_plane_nx_sites.end()); - ws_cull_xclip_load.insert(cfg.ws_cull_xclip_load_sites.begin(), cfg.ws_cull_xclip_load_sites.end()); - ws_cull_keep = cfg.ws_cull_keep_sites; - ws_cull_angle = cfg.ws_cull_angle_sites; - ws_cull_yaw_cone = cfg.ws_cull_yaw_cone_sites; - ws_cull_horizontal_slt = cfg.ws_cull_horizontal_slt_sites; - ws_aspect_cone = cfg.ws_aspect_cone; - ws_cull_activation_guard_pixels = - cfg.ws_cull_activation_guard_pixels; - ws_cull_w_imms = cfg.ws_cull_w_imms; - ws_cull_h_imms = cfg.ws_cull_h_imms; - ws_backdrop_x.insert(cfg.ws_backdrop_x_sites.begin(), cfg.ws_backdrop_x_sites.end()); - ws_backdrop_unsquash.insert(cfg.ws_backdrop_unsquash_funcs.begin(), cfg.ws_backdrop_unsquash_funcs.end()); - ws_auto_screen_x_cull = ws_auto_screen_x_cull || cfg.ws_auto_screen_x_cull; - ws_auto_backdrop_preload = ws_auto_backdrop_preload || cfg.ws_auto_backdrop_preload; - if (cfg.ws_bg2d_count_site) ws_bg2d_count_site = cfg.ws_bg2d_count_site; - if (cfg.ws_bg2d_startcol_site) ws_bg2d_startcol_site = cfg.ws_bg2d_startcol_site; - if (cfg.ws_bg2d_startx_site) ws_bg2d_startx_site = cfg.ws_bg2d_startx_site; - if (cfg.ws_bg2d_stream_left_site) ws_bg2d_stream_left_site = cfg.ws_bg2d_stream_left_site; - if (cfg.ws_bg2d_stream_right_site) ws_bg2d_stream_right_site = cfg.ws_bg2d_stream_right_site; - if (cfg.ws_bg2d_bufbase_site) ws_bg2d_bufbase_site = cfg.ws_bg2d_bufbase_site; - if (cfg.ws_bg2d_cap_site) ws_bg2d_cap_site = cfg.ws_bg2d_cap_site; - if (cfg.ws_bg2d_init_func) ws_bg2d_init_func = cfg.ws_bg2d_init_func; - ws_signed_x_bound_sites = cfg.ws_signed_x_bound_sites; - // [persist_options] init-store hook sites live in a dedicated - // game_options.toml next to game.toml (the game's own native OPTION - // settings, kept separate from game.toml/settings.toml). Best-effort: - // a missing/bad file just means no persistence hooks this build. - try { - std::filesystem::path go_path = - std::filesystem::path(config_path).parent_path() / "game_options.toml"; - const auto goc = PSXRecompV4::load_game_options(go_path); - for (const auto& o : goc.options) - if (o.init_store_pc) persist_init_sites.insert(o.init_store_pc); - } catch (const std::exception& e) { - fmt::print(stderr, " game_options.toml ignored: {}\n", e.what()); - } - fmt::print("config: {}\n", config_path.string()); - fmt::print(" exe = {}\n", exe_path.string()); - fmt::print(" seeds = {}\n", extra_funcs_storage); - fmt::print(" out_dir = {}\n", out_dir.string()); - if (!ws_tag_funcs.empty()) - fmt::print(" ws_tag_funcs= {}\n", ws_tag_funcs.size()); - fmt::print("\n"); - } else { - exe_path = argv[1]; - for (int i = 2; i < argc; i++) { - std::string arg = argv[i]; - if ((arg == "--extra-funcs" || arg == "--seeds") && i + 1 < argc) { - extra_funcs_path = argv[++i]; - } else if (arg == "--out-dir" && i + 1 < argc) { - out_dir = argv[++i]; - } else if (arg == "--strict") { - /* The PS-X EXE path is fail-loud by default; accepted for parity - * with psxrecomp-bios and project scripts. */ - } else if (arg == "--inspect") { - inspect_mode = true; - } else if (arg == "--overlay") { - /* Overlay-compilation contract: this input is a runtime-captured - * overlay with execution evidence, so discovery is evidence-scoped - * (no whole-byte sweep) and branch/jump-table targets stay as - * in-parent labels. Set unconditionally by compile_overlays.py for - * every overlay input — never a human toggle. */ - overlay_mode = true; - } - } - } - - // --ws-config: merge the [widescreen] site lists from a side config WITHOUT - // adopting its exe/paths. The overlay path is positional (no --config), so - // this is how overlay compilation receives the widescreen emits whose - // addresses live in overlay code (backdrop screenX, and any sprite-tag / - // cull sites that resolve there). No-op when --config already provided the - // sites (same TOML) — std::set insert dedupes. - if (!ws_config_path.empty()) { - const auto wscfg = PSXRecompV4::load_game_config(ws_config_path); - ws_tag_funcs.insert(wscfg.ws_sprite_tag_funcs.begin(), wscfg.ws_sprite_tag_funcs.end()); - mod_entry_funcs.insert(wscfg.mod_function_entry_funcs.begin(), - wscfg.mod_function_entry_funcs.end()); - ws_cull_bias.insert(wscfg.ws_cull_bias_sites.begin(), wscfg.ws_cull_bias_sites.end()); - ws_cull_range.insert(wscfg.ws_cull_range_sites.begin(), wscfg.ws_cull_range_sites.end()); - ws_cull_a1.insert(wscfg.ws_cull_a1_sites.begin(), wscfg.ws_cull_a1_sites.end()); - ws_cull_screen_x.insert(wscfg.ws_cull_screen_x_sites.begin(), wscfg.ws_cull_screen_x_sites.end()); - ws_cull_slti.insert(wscfg.ws_cull_slti_sites.begin(), wscfg.ws_cull_slti_sites.end()); - ws_cull_slti_lower.insert(wscfg.ws_cull_slti_lower_sites.begin(), - wscfg.ws_cull_slti_lower_sites.end()); - ws_cull_lower_branch.insert(wscfg.ws_cull_lower_branch_sites.begin(), - wscfg.ws_cull_lower_branch_sites.end()); - ws_cull_lower_value.insert(wscfg.ws_cull_lower_value_sites.begin(), - wscfg.ws_cull_lower_value_sites.end()); - ws_cull_upper_value.insert(wscfg.ws_cull_upper_value_sites.begin(), - wscfg.ws_cull_upper_value_sites.end()); - ws_cull_span_value.insert(wscfg.ws_cull_span_value_sites.begin(), - wscfg.ws_cull_span_value_sites.end()); - ws_cull_bltz.insert(wscfg.ws_cull_bltz_sites.begin(), wscfg.ws_cull_bltz_sites.end()); - ws_cull_negsub.insert(wscfg.ws_cull_negsub_sites.begin(), wscfg.ws_cull_negsub_sites.end()); - ws_cull_vxrange.insert(wscfg.ws_cull_vxrange_sites.begin(), wscfg.ws_cull_vxrange_sites.end()); - ws_cull_depth.insert(wscfg.ws_cull_depth_sites.begin(), wscfg.ws_cull_depth_sites.end()); - ws_cull_plane_nx.insert(wscfg.ws_cull_plane_nx_sites.begin(), wscfg.ws_cull_plane_nx_sites.end()); - ws_cull_xclip_load.insert(wscfg.ws_cull_xclip_load_sites.begin(), wscfg.ws_cull_xclip_load_sites.end()); - if (ws_cull_keep.empty()) ws_cull_keep = wscfg.ws_cull_keep_sites; - if (ws_cull_angle.empty()) ws_cull_angle = wscfg.ws_cull_angle_sites; - if (ws_cull_yaw_cone.empty()) - ws_cull_yaw_cone = wscfg.ws_cull_yaw_cone_sites; - if (ws_cull_horizontal_slt.empty()) - ws_cull_horizontal_slt = - wscfg.ws_cull_horizontal_slt_sites; - if (ws_aspect_cone.sites.empty()) - ws_aspect_cone = wscfg.ws_aspect_cone; - ws_cull_activation_guard_pixels = - wscfg.ws_cull_activation_guard_pixels; - ws_cull_w_imms = wscfg.ws_cull_w_imms; - ws_cull_h_imms = wscfg.ws_cull_h_imms; - ws_backdrop_x.insert(wscfg.ws_backdrop_x_sites.begin(), wscfg.ws_backdrop_x_sites.end()); - ws_backdrop_unsquash.insert(wscfg.ws_backdrop_unsquash_funcs.begin(), wscfg.ws_backdrop_unsquash_funcs.end()); - ws_auto_screen_x_cull = ws_auto_screen_x_cull || wscfg.ws_auto_screen_x_cull; - ws_auto_backdrop_preload = ws_auto_backdrop_preload || wscfg.ws_auto_backdrop_preload; - PSXRecompV4::merge_recompiler_patches( - instruction_patches, wscfg.recompiler_patches); - ws_signed_x_bound_sites.insert(ws_signed_x_bound_sites.end(), - wscfg.ws_signed_x_bound_sites.begin(), - wscfg.ws_signed_x_bound_sites.end()); - if (wscfg.ws_bg2d_init_func) ws_bg2d_init_func = wscfg.ws_bg2d_init_func; - fmt::print("ws-config: {} (backdrop_x sites={}, unsquash funcs={})\n", - ws_config_path.string(), ws_backdrop_x.size(), ws_backdrop_unsquash.size()); - } - - /* Resolve the BIOS profile this game builds against and activate its - * address model for codegen (jump tables inside relocated BIOS code - * windows fold through it — see code_generator.cpp ram_to_rom). Order: - * explicit [recompiler] bios_config; else the SCPH1001 profile in-repo - * (framework checkout) or under the psxrecomp/ submodule (game repo). - * No profile -> fatal: there are no built-in windows. */ - static PSXRecompV4::BiosAddressModel s_bios_model; - { - std::filesystem::path prof = bios_profile_path; - /* An explicit bios_config that does not resolve is a different failure - * from having none at all — say which, or the message sends you looking - * for an unset key that is in fact set. */ - if (!prof.empty() && !std::filesystem::exists(prof)) { - fmt::print(stderr, - "psxrecomp-game: FATAL: [recompiler] bios_config = '{}' does " - "not exist (path is relative to the game project root)\n", - prof.string()); - return 1; - } - if (prof.empty()) { - if (std::filesystem::exists("bios/SCPH1001.toml")) { - prof = "bios/SCPH1001.toml"; /* framework checkout */ - } else { - /* Game repo vendoring the framework. Do NOT assume the - * directory is named "psxrecomp": ApeEscapeRecomp vendors it as - * "psxrecomp-v4", and hardcoding the name made regeneration - * fail outright there. Probe one level for any vendored - * framework, sorted so the pick is deterministic. */ - std::vector found; - std::error_code ec; - for (const auto& de : - std::filesystem::directory_iterator(".", ec)) { - if (ec) break; - if (!de.is_directory(ec) || ec) continue; - auto cand = de.path() / "bios" / "SCPH1001.toml"; - if (std::filesystem::exists(cand)) found.push_back(cand); - } - std::sort(found.begin(), found.end()); - if (!found.empty()) prof = found.front(); - } - } - if (prof.empty()) { - fmt::print(stderr, - "psxrecomp-game: FATAL: no BIOS profile found (set " - "[recompiler] bios_config in game.toml, or run from a root " - "containing bios/SCPH1001.toml or /bios/" - "SCPH1001.toml)\n"); - return 1; - } - const auto bios_cfg = PSXRecompV4::load_bios_config(prof); - s_bios_model = PSXRecompV4::BiosAddressModel::from_config(bios_cfg); - PSXRecomp::CodeGenerator::set_bios_address_model(&s_bios_model); - } - - // Parse the PS1-EXE file - std::string error_msg; - fmt::print("Parsing PS1-EXE: {}\n", exe_path.string()); - - auto exe = PSXRecomp::PS1ExeParser::parse_file(exe_path, error_msg); - - if (!exe.has_value()) { - fmt::print(stderr, "Failed to parse PS1-EXE: {}\n", error_msg); - return 1; - } - - // [game].text_size is the title-owned static-analysis bound. It may trim a - // verified data/padding tail, but it may never widen the parsed payload or - // exclude the executable entry. Runtime disc loading remains unchanged. - if (!config_path.empty()) { - const uint32_t parsed_size = exe->header.file_size; - std::string bound_error; - if (!PSXRecomp::apply_static_analysis_bound( - *exe, configured_text_size, bound_error)) { - fmt::print(stderr, "Invalid static analysis bound: {}\n", bound_error); - return 1; - } - if (exe->header.file_size < parsed_size) { - fmt::print("Static analysis bound: 0x{:X} bytes from game.text_size " - "(PS-X EXE header: 0x{:X})\n", - exe->header.file_size, parsed_size); - } - } - - // Patch the bounded image before discovery/CFG analysis so replacements of - // control-flow instructions cannot disagree with the generated graph. - PSXRecompV4::apply_recompiler_patches_to_executable( - *exe, instruction_patches, overlay_mode); - - fmt::print("✓ Successfully parsed PS1-EXE!\n\n"); - - // Print header information - fmt::print("PS1-EXE Header Info:\n"); - fmt::print(" Entry Point: 0x{:08X}\n", exe->header.initial_pc); - fmt::print(" Load Address: 0x{:08X}\n", exe->header.load_address); - fmt::print(" Code Size: {} bytes ({} KB)\n", - exe->header.file_size, exe->header.file_size / 1024); - fmt::print(" End Address: 0x{:08X}\n", exe->end_address()); - fmt::print(" Global Pointer: 0x{:08X}\n", exe->header.initial_gp); - fmt::print(" Stack Pointer: 0x{:08X}\n\n", exe->header.initial_sp); - - // Validate entry point - if (!exe->header.entry_in_range()) { - fmt::print(stderr, "⚠ Warning: Entry point 0x{:08X} is outside loaded code range!\n\n", - exe->header.initial_pc); - } - - if (inspect_mode) { - // Disassemble first 20 instructions from entry point - fmt::print("Disassembly at Entry Point (0x{:08X}):\n", exe->header.initial_pc); - fmt::print("---------------------------------------\n"); - - uint32_t current_addr = exe->header.initial_pc; - const int num_instructions = 20; - - for (int i = 0; i < num_instructions; i++) { - auto word_opt = exe->read_word(current_addr); - - if (!word_opt.has_value()) { - fmt::print(" [End of code or read error]\n"); - break; - } - - uint32_t instr_word = *word_opt; - - // Use Rabbitizer to disassemble the instruction - // Note: Rabbitizer expects big-endian, PS1 is little-endian, so the word is already in correct format - rabbitizer::InstructionCpu instr(instr_word, current_addr); - - // Get disassembly (parameter is flags, 0 = default) - std::string disasm = instr.disassemble(0); - - // Print address, hex, and disassembly - fmt::print(" {:08X}: {:08X} {}\n", current_addr, instr_word, disasm); - - current_addr += 4; - } - - fmt::print("\n"); - fmt::print("✓ Disassembly complete!\n"); - - // Instruction frequency analysis - fmt::print("\n"); - fmt::print("Analyzing instruction frequency...\n"); - - std::map instr_freq; - std::map opcode_freq; - std::map special_funct_freq; - - uint32_t total_instructions = 0; - uint32_t addr = exe->header.load_address; - while (addr < exe->end_address()) { - auto word_opt = exe->read_word(addr); - if (!word_opt.has_value()) break; - - uint32_t instr_word = *word_opt; - rabbitizer::InstructionCpu instr(instr_word, addr); - std::string mnemonic = instr.getOpcodeName(); - - instr_freq[mnemonic]++; - total_instructions++; - - // Track opcode distribution - uint32_t opcode = (instr_word >> 26) & 0x3F; - opcode_freq[opcode]++; - - // Track SPECIAL function codes - if (opcode == 0x00) { - uint32_t funct = instr_word & 0x3F; - special_funct_freq[funct]++; - } - - addr += 4; - } - - fmt::print("Total instructions: {}\n\n", total_instructions); - - // Sort by frequency and print top 20 - std::vector> sorted_freq(instr_freq.begin(), instr_freq.end()); - std::sort(sorted_freq.begin(), sorted_freq.end(), - [](const auto& a, const auto& b) { return a.second > b.second; }); - - fmt::print("=== Top 20 Most Common Instructions ===\n\n"); - fmt::print("{:4s} {:12s} {:10s} {:s}\n", "Rank", "Instruction", "Count", "Percentage"); - fmt::print("-----------------------------------------------------\n"); - - for (size_t i = 0; i < std::min(size_t(20), sorted_freq.size()); i++) { - const auto& [instr_name, count] = sorted_freq[i]; - double pct = 100.0 * count / total_instructions; - fmt::print("{:4d} {:12s} {:10d} {:5.2f}%\n", i + 1, instr_name, count, pct); - } - - fmt::print("\n"); - } - - // Function boundary detection - fmt::print("Performing function analysis...\n"); - - std::vector exact_entries; - exact_entries.push_back(exe->header.initial_pc); - - // Explicit entry points: functions called through pointers but not - // automatically detected by the heuristic-based function analysis. - // Pass game-specific entry points via --extra-funcs file. - - /* Load extra function addresses from a file (one hex address per line). - * Lines of the form `interior 0xXXXXXXXX` mark dispatch-proven interior - * addresses: alias candidates, never walk roots. `hosted_interior TARGET - * HOST` is a narrow cross-variant identity assertion: TARGET may alias only - * the explicitly rooted HOST, and only when TARGET is a syntactic block - * leader in HOST's current-byte range. The shard publisher additionally - * requires both emitted identities to match a previously CRC-validated host - * exactly before the result can reach the runtime. Lines of the form - * `dispatch_root 0xXXXXXXXX` mark classifier-proven dispatch roots - * WITHOUT a callable boundary (the kernel install-slot class, e.g. RAM - * 0xCF0): the static recompiler's install-slot hooks tail-dispatch into - * exactly these PCs, so they are real execution roots even though no - * prologue / preceding jr $ra exists. They are trusted walk roots and - * exempt from the overlay-mode boundary re-check below. Lines of the - * form `call_root 0xXXXXXXXX` carry the same mechanical trust for a - * statically proven direct or constant-register call/tail-call target. - * - * Seeds are accepted within the loaded image's own bounds — overlay mode - * wraps arbitrary regions (kernel RAM at 0x80000000, overlays at - * 0x800E7000, ...), so a hardcoded game-RAM window would silently drop - * valid seeds. */ - std::vector file_seeds; - std::vector interior_seeds; - std::map hosted_interior_seeds; - std::set hosted_interior_hosts; - constexpr size_t kHostedInteriorTargetCap = 4096; - constexpr size_t kHostedInteriorHostCap = 512; - std::set trusted_root_seeds; - std::set trusted_call_root_seeds; - std::vector> producer_ranges; - std::set cross_call_allow; - if (extra_funcs_path) { - std::ifstream ef(extra_funcs_path); - if (ef.is_open()) { - const uint32_t seed_lo = exe->header.load_address; - const uint32_t seed_hi = exe->end_address(); - std::string line; - while (std::getline(ef, line)) { - if (line.empty() || line[0] == '#') continue; - if (line.rfind("producer_range", 0) == 0) { - std::istringstream in(line); - std::string tag, lo_text, hi_text; - in >> tag >> lo_text >> hi_text; - uint32_t lo = static_cast( - std::strtoul(lo_text.c_str(), nullptr, 16)); - uint32_t hi = static_cast( - std::strtoul(hi_text.c_str(), nullptr, 16)); - if (lo_text.empty() || hi_text.empty() || - lo < seed_lo || lo >= hi || hi > seed_hi) { - fmt::print(stderr, "ERROR: invalid producer_range: {}\n", line); - return 1; - } - producer_ranges.emplace_back(lo, hi); - continue; - } - if (line.rfind("cross_call_allow", 0) == 0) { - const char* p = line.c_str() + 16; - uint32_t addr = static_cast( - std::strtoul(p, nullptr, 16)); - if (addr < seed_lo || addr >= seed_hi) { - fmt::print(stderr, "ERROR: invalid cross_call_allow: {}\n", line); - return 1; - } - cross_call_allow.insert(addr); - continue; - } - if (line.rfind("retained_alias", 0) == 0) { - fmt::print(" ignoring legacy retained_alias; old host " - "ranges are never reused: {}\n", line); - continue; - } - if (line.rfind("hosted_interior", 0) == 0) { - std::istringstream in(line); - std::string tag, target_text, host_text, trailing; - in >> tag >> target_text >> host_text >> trailing; - auto parse_hex_u32 = [](const std::string& text, - uint32_t& value) -> bool { - if (text.empty()) return false; - char* end = nullptr; - errno = 0; - unsigned long long parsed = - std::strtoull(text.c_str(), &end, 16); - if (errno != 0 || end == text.c_str() || *end != '\0' || - parsed > 0xFFFFFFFFull) return false; - value = static_cast(parsed); - return true; - }; - uint32_t target = 0, host = 0; - if (tag != "hosted_interior" || !trailing.empty() || - !parse_hex_u32(target_text, target) || - !parse_hex_u32(host_text, host) || - (target & 3u) != 0u || (host & 3u) != 0u || - target < seed_lo || target >= seed_hi || - host < seed_lo || host >= seed_hi || target == host) { - fmt::print(stderr, "ERROR: invalid hosted_interior: {}\n", line); - return 1; - } - auto prior = hosted_interior_seeds.find(target); - if (prior != hosted_interior_seeds.end() && - prior->second != host) { - fmt::print(stderr, "ERROR: conflicting hosted_interior " - "hosts for 0x{:08X}\n", target); - return 1; - } - hosted_interior_seeds.emplace(target, host); - hosted_interior_hosts.insert(host); - if (hosted_interior_seeds.size() > - kHostedInteriorTargetCap || - hosted_interior_hosts.size() > - kHostedInteriorHostCap) { - fmt::print(stderr, "ERROR: hosted_interior capacity " - "exceeded (targets {} / {}, hosts {} / {})\n", - hosted_interior_seeds.size(), - kHostedInteriorTargetCap, - hosted_interior_hosts.size(), - kHostedInteriorHostCap); - return 1; - } - continue; - } - bool interior = false; - bool trusted_root = false; - bool trusted_call_root = false; - const char* p = line.c_str(); - if (line.rfind("interior", 0) == 0) { - interior = true; - p += 8; - } else if (line.rfind("dispatch_root", 0) == 0) { - trusted_root = true; - p += 13; - } else if (line.rfind("call_root", 0) == 0) { - trusted_call_root = true; - p += 9; - } - uint32_t addr = (uint32_t)std::strtoul(p, nullptr, 16); - if (addr >= seed_lo && addr < seed_hi) { - if (interior) { - interior_seeds.push_back(addr); - } else { - if (trusted_root) trusted_root_seeds.insert(addr); - if (trusted_call_root) trusted_call_root_seeds.insert(addr); - file_seeds.push_back(addr); - exact_entries.push_back(addr); - } - } - } - std::sort(producer_ranges.begin(), producer_ranges.end()); - for (size_t i = 1; i < producer_ranges.size(); i++) { - if (producer_ranges[i - 1].second > producer_ranges[i].first) { - fmt::print(stderr, "ERROR: overlapping producer_range entries\n"); - return 1; - } - } - for (uint32_t addr : cross_call_allow) { - bool contained = false; - for (const auto& range : producer_ranges) { - if (addr >= range.first && addr < range.second) { - contained = true; - break; - } - } - if (!contained) { - fmt::print(stderr, - "ERROR: cross_call_allow 0x{:08X} is outside " - "producer ranges\n", addr); - return 1; - } - } - fmt::print("Loaded {} extra function addresses ({} interior, " - "{} hosted-interior, {} dispatch-root, {} call-root, {} producer ranges, " - "{} cross-call allows) from {}\n", - file_seeds.size() + interior_seeds.size() + - hosted_interior_seeds.size(), - interior_seeds.size(), hosted_interior_seeds.size(), - trusted_root_seeds.size(), - trusted_call_root_seeds.size(), - producer_ranges.size(), cross_call_allow.size(), - extra_funcs_path); - } else { - fmt::print("WARNING: Cannot open extra-funcs file: {}\n", extra_funcs_path); - } - } - - PSXRecomp::FunctionAnalysisResult analysis_result; - - if (overlay_mode || reachable_discovery) { - // ── Exact-entry mode: partition seeds into walk roots and interior - // alias candidates. `interior`-marked seeds (classifier-proven - // dispatch targets without a callable boundary) are NEVER walk roots — - // a root inside a host function hard-caps (truncates) it, the - // mid-function-seed softlock class. Unmarked seeds are re-verified - // against the same boundary rule as defense in depth. - const uint32_t exe_lo = exe->header.load_address; - auto read_w = [&](uint32_t a) -> uint32_t { - auto w = exe->read_word(a); - return w.has_value() ? *w : 0u; - }; - auto dense_local_pointer_table = [&](uint32_t a) -> bool { - for (uint32_t i = 0; i < 3u; i++) { - auto value = exe->read_word(a + i * 4u); - if (!value.has_value() || (*value & 3u) != 0u || - *value < exe_lo || *value >= exe->end_address()) { - return false; - } - } - return true; - }; - auto callable_boundary = [&](uint32_t a) -> bool { - uint32_t w = read_w(a); - if (!PSXRecomp::FunctionAnalyzer::is_valid_mips_word(w)) return false; - if (a == exe_lo) return true; - if (a >= exe_lo + 8 && read_w(a - 8) == 0x03E00008u) return true; - // Normal-mode discovery already skips alignment padding after a - // return when it infers a frameless function start. Preserve that - // evidence here. Requiring the next entry to be exactly jr-ra+8 - // demoted legitimate Psy-Q leaf routines separated by one to six - // padding NOPs to orphan interiors, so exact-entry analysis emitted - // no function at all. The delay slot (jr+4) may be non-NOP; only - // words after it and before the proposed entry must be padding. - bool padding_only = true; - for (uint32_t back = 12; back <= 32 && a >= exe_lo + back; - back += 4) { - if (read_w(a - back + 8) != 0u) padding_only = false; - if (!padding_only) break; - if (read_w(a - back) == 0x03E00008u) return true; - } - int32_t frame = 0; - return PSXRecomp::FunctionAnalyzer::is_prologue(w, frame) && - !(a >= exe_lo + 4 && - PSXRecomp::FunctionAnalyzer::is_branch_or_jump(read_w(a - 4))); - }; - - std::set roots; - std::set interior; - std::set hosted_targets; - for (const auto& seed : hosted_interior_seeds) - hosted_targets.insert(seed.first); - for (uint32_t a : exact_entries) { - if (hosted_targets.count(a)) { - // A fragment PS-X header needs one initial PC, but a trusted - // hosted interior must never become a walk root through it. - continue; - } else if (reachable_discovery && a == exe->header.initial_pc) { - // The PS-X EXE header is direct execution evidence for the - // main entry even when it uses a nonstandard prologue. - roots.insert(a); - } else if (trusted_root_seeds.count(a)) { - /* Classifier-proven dispatch root (install-slot class): the - * static code tail-dispatches into this PC, so it is a real - * execution root despite having no callable boundary. */ - fmt::print(" seed 0x{:08X} accepted as dispatch root " - "(install-slot class, no boundary evidence)\n", a); - roots.insert(a); - } else if (trusted_call_root_seeds.count(a) && - !dense_local_pointer_table(a)) { - fmt::print(" seed 0x{:08X} accepted as static call root " - "(direct/constant-register target)\n", a); - roots.insert(a); - } else if (callable_boundary(a)) { - roots.insert(a); - } else { - fmt::print(" seed 0x{:08X} fails the boundary check — " - "treating as interior alias candidate\n", a); - interior.insert(a); - } - } - for (uint32_t a : interior_seeds) interior.insert(a); - - auto containing = [&](uint32_t a) -> const PSXRecomp::Function* { - const PSXRecomp::Function* best = nullptr; - for (const auto& f : analysis_result.functions) { - if (a >= f.start_addr && a < f.end_addr) { best = &f; break; } - } - return best; - }; - - // Interior candidates attach only to functions the EVIDENCE-walked - // root set already owns. No backward-boundary guessing: a false - // prologue/jr-ra in embedded data mints a root that decodes data as - // code (audit-fatal). Orphans stay with the interpreter — it - // self-heals via recapture once real call evidence appears. - { - PSXRecomp::FunctionAnalyzer analyzer(*exe); - std::vector roots_vec(roots.begin(), roots.end()); - analysis_result = analyzer.analyze_exact_entries( - roots_vec, producer_ranges, cross_call_allow); - } - - std::vector alias_entries; - std::set alias_seen; - for (uint32_t a : interior) { - const PSXRecomp::Function* host = containing(a); - if (host && a == host->start_addr) continue; // became a real entry - if (host && analysis_result.exact_reachable_pcs.count(a)) { - if (alias_seen.insert(a).second) - alias_entries.push_back({a, host->start_addr, host->end_addr}); - } else { - fmt::print(" WARNING: interior seed 0x{:08X} has no host " - "function — left to the interpreter\n", a); - } - } - - // Cross-variant propagation never guesses a new walk. The caller names - // a current-byte host that was already CRC-valid in the cache, and the - // recompiler requires that exact root plus a syntactic block boundary - // in the host's current-byte range. This block test alone is NOT proof - // of reachability: the publisher must compare the emitted host+alias - // CRC/range identities with the preselected cached owner before use. - PSXRecomp::ControlFlowAnalyzer hosted_cfg_analyzer(*exe); - std::map hosted_cfgs; - for (const auto& seed : hosted_interior_seeds) { - uint32_t target = seed.first; - uint32_t requested_host = seed.second; - const PSXRecomp::Function* host = nullptr; - for (const auto& function : analysis_result.functions) { - if (function.start_addr == requested_host) { - host = &function; - break; - } - } - bool explicitly_rooted = roots.count(requested_host) != 0; - bool producer_ok = host && - (host->producer_lo == 0 || - (target >= host->producer_lo && target < host->producer_hi)); - bool block_ok = false; - if (host && producer_ok && target > host->start_addr && - target < host->end_addr) { - auto inserted = hosted_cfgs.emplace( - host->start_addr, PSXRecomp::ControlFlowGraph{}); - if (inserted.second) - inserted.first->second = hosted_cfg_analyzer.analyze_function(*host); - block_ok = inserted.first->second.blocks.count(target) != 0; - } - if (host && explicitly_rooted && producer_ok && block_ok) { - if (alias_seen.insert(target).second) - alias_entries.push_back( - {target, host->start_addr, host->end_addr}); - } else { - fmt::print(" WARNING: hosted interior 0x{:08X} rejected for " - "host 0x{:08X} (host={}, rooted={}, producer={}, block={})\n", - target, requested_host, host ? "yes" : "no", - explicitly_rooted ? "yes" : "no", - producer_ok ? "yes" : "no", block_ok ? "yes" : "no"); - } - } - - // Range-ownership completeness uses only analyzer-proven transfers from - // reachable instructions. The analyzer also revalidates each target's - // host against the final root partition and producer boundaries. - size_t proven_alias_added = 0; - for (const auto& absorbed : analysis_result.absorbed_entries) { - if (!alias_seen.insert(absorbed.addr).second) continue; - alias_entries.push_back( - {absorbed.addr, absorbed.host_start, absorbed.host_end}); - proven_alias_added++; - } - if (proven_alias_added) - fmt::print(" +{} analyzer-proven absorbed alias entries " - "(final range ownership)\n", proven_alias_added); - - materialize_alias_groups(analysis_result, alias_entries); - fmt::print("Exact-entry alias entries emitted: {}\n\n", alias_entries.size()); - } else { - // ── Iterative discovery: seed classification + static data-table scan ── - // - // Every candidate entry (explicit seed or scanned data pointer) is - // classified against the current function set: - // * matches a function start → already covered - // * INSIDE a function's range → ALIAS entry (overlapping - // emission; the host is never capped/truncated — forcing such an - // entry is the mid-function-seed softlock class) - // * in a gap between functions → forced entry (explicit - // seeds are trusted; scanned pointers additionally need boundary - // evidence: a jr $ra two slots earlier or a non-delay-slot - // prologue) - // - // The data scan walks every word of the image OUTSIDE code-function - // ranges (pointer tables live in data regions) and collects words that - // point at valid instructions inside code-function ranges. Promotions - // change function boundaries, so iterate to a fixed point. - std::vector alias_entries; - - std::set forced; - forced.insert(exe->header.initial_pc); - const uint32_t exe_lo = exe->header.load_address; - const uint32_t exe_hi = exe->end_address(); - auto read_w = [&](uint32_t a) -> uint32_t { - auto w = exe->read_word(a); - return w.has_value() ? *w : 0u; - }; - - const int kMaxDiscoveryIters = 6; - for (int iter = 0; iter < kMaxDiscoveryIters; ++iter) { - PSXRecomp::FunctionAnalyzer analyzer(*exe); - for (uint32_t a : forced) analyzer.add_forced_entry(a); - analysis_result = analyzer.analyze(); - - std::vector code_funcs; - for (const auto& f : analysis_result.functions) { - if (!f.is_data_section) code_funcs.push_back(&f); - } - auto containing = [&](uint32_t a) -> const PSXRecomp::Function* { - auto it = std::upper_bound(code_funcs.begin(), code_funcs.end(), a, - [](uint32_t v, const PSXRecomp::Function* f) { return v < f->start_addr; }); - if (it == code_funcs.begin()) return nullptr; - --it; - return (a >= (*it)->start_addr && a < (*it)->end_addr) ? *it : nullptr; - }; - - struct Candidate { uint32_t addr; bool trusted; bool table_evidence; }; - std::vector candidates; - for (uint32_t s : file_seeds) candidates.push_back({s, true, true}); - for (uint32_t s : interior_seeds) candidates.push_back({s, true, true}); - uint32_t scanned_words = 0; - auto is_text_ptr = [&](uint32_t v) { - return (v & 3u) == 0 && v >= exe_lo && v < exe_hi && containing(v) != nullptr; - }; - for (uint32_t p = exe_lo; p + 4 <= exe_hi; p += 4) { - if (containing(p)) continue; // instruction word, not a data word - uint32_t w = read_w(p); - if ((w & 3u) || w < exe_lo || w >= exe_hi) continue; - // Table evidence: an adjacent data word that is also a text - // pointer. Interior handler entries live in dense pointer - // tables; a lone pointer-shaped data word is too weak to mint - // an alias from (random data aliases into loose code ranges). - bool table_evidence = - (p >= exe_lo + 4 && is_text_ptr(read_w(p - 4))) || - (p + 8 <= exe_hi && is_text_ptr(read_w(p + 4))); - candidates.push_back({w, false, table_evidence}); - scanned_words++; - } - - alias_entries.clear(); - std::set alias_seen; - bool promoted_new = false; - for (const auto& [a, trusted, table_evidence] : candidates) { - if ((a & 3u) || a < exe_lo || a >= exe_hi) continue; - const PSXRecomp::Function* host = containing(a); - if (host && a == host->start_addr) continue; // already an entry - uint32_t w = read_w(a); - if (!PSXRecomp::FunctionAnalyzer::is_valid_mips_word(w)) continue; - if (host) { - if ((trusted || table_evidence) && alias_seen.insert(a).second) { - alias_entries.push_back({a, host->start_addr, host->end_addr}); - } - continue; - } - bool promote = trusted; - if (!promote) { - bool after_ret = (a >= exe_lo + 8) && (read_w(a - 8) == 0x03E00008u); - int32_t frame = 0; - bool prologue = - PSXRecomp::FunctionAnalyzer::is_prologue(w, frame) && - !(a >= exe_lo + 4 && - PSXRecomp::FunctionAnalyzer::is_branch_or_jump(read_w(a - 4))); - promote = after_ret || prologue; - } - if (promote && forced.insert(a).second) promoted_new = true; - } - - fmt::print("Discovery iteration {}: {} data pointers scanned, " - "{} forced entries, {} alias entries\n", - iter + 1, scanned_words, forced.size(), alias_entries.size()); - if (!promoted_new) break; - } - - materialize_alias_groups(analysis_result, alias_entries); - fmt::print("Alias entries emitted: {}\n\n", alias_entries.size()); - } - - // Print summary statistics - fmt::print("\n=== Function Analysis Summary ===\n\n"); - fmt::print("Total Functions: {}\n", analysis_result.functions.size()); - fmt::print("Code Coverage: {} KB analyzed\n", - (exe->end_address() - exe->header.load_address) / 1024); - - // Count functions with prologues/epilogues - int with_prologue = 0; - int with_epilogue = 0; - int with_both = 0; - - for (const auto& func : analysis_result.functions) { - if (func.has_prologue) with_prologue++; - if (func.has_epilogue) with_epilogue++; - if (func.has_prologue && func.has_epilogue) with_both++; - } - - fmt::print("With Prologue: {} ({:.1f}%)\n", with_prologue, - 100.0 * with_prologue / analysis_result.functions.size()); - fmt::print("With Epilogue: {} ({:.1f}%)\n", with_epilogue, - 100.0 * with_epilogue / analysis_result.functions.size()); - fmt::print("Complete: {} ({:.1f}%)\n\n", with_both, - 100.0 * with_both / analysis_result.functions.size()); - - // Print first 20 functions - fmt::print("First 20 Functions:\n"); - fmt::print("---------------------------------------\n"); - - for (size_t i = 0; i < std::min(size_t(20), analysis_result.functions.size()); i++) { - const auto& func = analysis_result.functions[i]; - fmt::print(" {:08X}-{:08X} {:5} bytes {}{}\n", - func.start_addr, - func.end_addr, - func.size, - func.name, - func.has_prologue ? fmt::format(" (frame={})", func.stack_frame_size) : ""); - } - - - fmt::print("\n... ({} more functions)\n\n", - analysis_result.functions.size() - std::min(size_t(20), analysis_result.functions.size())); - - - // Control flow analysis - PSXRecomp::ControlFlowAnalyzer cfg_analyzer(*exe); - auto all_cfgs = cfg_analyzer.analyze_all_functions(analysis_result.functions); - - // Print CFG summary statistics - fmt::print("=== Control Flow Summary ===\n\n"); - - int total_basic_blocks = 0; - int total_loops = 0; - int total_branches = 0; - - for (const auto& [func_addr, cfg] : all_cfgs) { - total_basic_blocks += static_cast(cfg.blocks.size()); - total_loops += cfg.loop_count; - - // Count branch instructions across all blocks - for (const auto& [block_addr, block] : cfg.blocks) { - if (block.exit_instr.type == PSXRecomp::ControlFlowType::Branch) { - total_branches++; - } - } - } - - fmt::print("Basic Blocks: {}\n", total_basic_blocks); - fmt::print("Loops Detected: {}\n", total_loops); - fmt::print("Branch Instructions: {}\n", total_branches); - fmt::print("Avg Blocks/Function: {:.1f}\n\n", - static_cast(total_basic_blocks) / all_cfgs.size()); - - // Show detailed CFG for first function (as an example) - if (!analysis_result.functions.empty() && !all_cfgs.empty()) { - const auto& first_func = analysis_result.functions[0]; - const auto& first_cfg = all_cfgs.at(first_func.start_addr); - - fmt::print("Example: CFG for {} (0x{:08X}):\n", first_func.name, first_func.start_addr); - fmt::print("---------------------------------------\n"); - fmt::print(" Blocks: {}\n", first_cfg.blocks.size()); - fmt::print(" Loops: {}\n", first_cfg.loop_count); - - if (first_cfg.blocks.size() > 1) { - fmt::print("\n Block Details:\n"); - int count = 0; - for (const auto& block_addr : first_cfg.block_order) { - const auto& block = first_cfg.blocks.at(block_addr); - fmt::print(" Block 0x{:08X}: {} instructions", block.start_addr, block.instruction_count); - - if (block.is_entry) fmt::print(" [ENTRY]"); - if (block.is_exit) fmt::print(" [EXIT]"); - if (block.is_loop_header) fmt::print(" [LOOP]"); - - fmt::print("\n"); - fmt::print(" Successors: {}", block.successors.size()); - if (!block.successors.empty()) { - fmt::print(" ("); - for (size_t i = 0; i < block.successors.size(); i++) { - fmt::print("0x{:08X}", block.successors[i]); - if (i + 1 < block.successors.size()) fmt::print(", "); - } - fmt::print(")"); - } - fmt::print("\n"); - - // Only show first 5 blocks for brevity - if (++count >= 5 && first_cfg.blocks.size() > 5) { - fmt::print(" ... ({} more blocks)\n", first_cfg.blocks.size() - 5); - break; - } - } - } - fmt::print("\n"); - } - - // C code generation - PSXRecomp::CodeGenConfig codegen_config; - codegen_config.emit_comments = true; - codegen_config.emit_line_numbers = true; - codegen_config.split_mid_function_targets = !overlay_mode; - codegen_config.overlay_mode = overlay_mode; - codegen_config.ws_signed_x_bound_sites = ws_signed_x_bound_sites; - codegen_config.ws_sprite_tag_funcs = ws_tag_funcs; - codegen_config.data_shard_funcs = ds_funcs; - codegen_config.mod_function_entry_funcs = mod_entry_funcs; - codegen_config.hot_funcs = hot_funcs; - codegen_config.vsync_query_hle_funcs = vsync_query_hle_funcs; - codegen_config.ws_bg2d_init_func = ws_bg2d_init_func; - codegen_config.ws_cull_bias_sites = ws_cull_bias; - codegen_config.ws_cull_range_sites = ws_cull_range; - codegen_config.ws_cull_a1_sites = ws_cull_a1; - codegen_config.ws_cull_screen_x_sites = ws_cull_screen_x; - codegen_config.ws_cull_activation_guard_pixels = - ws_cull_activation_guard_pixels; - codegen_config.ws_cull_slti_sites = ws_cull_slti; - codegen_config.ws_cull_slti_lower_sites = ws_cull_slti_lower; - codegen_config.ws_cull_lower_branch_sites = ws_cull_lower_branch; - codegen_config.ws_cull_lower_value_sites = ws_cull_lower_value; - codegen_config.ws_cull_upper_value_sites = ws_cull_upper_value; - codegen_config.ws_cull_span_value_sites = ws_cull_span_value; - codegen_config.ws_cull_bltz_sites = ws_cull_bltz; - codegen_config.ws_cull_negsub_sites = ws_cull_negsub; - codegen_config.ws_cull_vxrange_sites = ws_cull_vxrange; - codegen_config.ws_cull_depth_sites = ws_cull_depth; - codegen_config.ws_cull_plane_nx_sites = ws_cull_plane_nx; - codegen_config.ws_cull_xclip_load_sites = ws_cull_xclip_load; - codegen_config.ws_cull_keep_sites = ws_cull_keep; - codegen_config.ws_cull_angle_sites = ws_cull_angle; - codegen_config.ws_cull_yaw_cone_sites = ws_cull_yaw_cone; - codegen_config.ws_cull_horizontal_slt_sites = - ws_cull_horizontal_slt; - codegen_config.ws_aspect_cone = ws_aspect_cone; - codegen_config.ws_cull_w_imms = ws_cull_w_imms; - codegen_config.ws_cull_h_imms = ws_cull_h_imms; - codegen_config.ws_backdrop_x_sites = ws_backdrop_x; - codegen_config.ws_backdrop_unsquash_funcs = ws_backdrop_unsquash; - codegen_config.ws_auto_screen_x_cull = ws_auto_screen_x_cull; - codegen_config.persist_init_store_sites = persist_init_sites; - codegen_config.ws_auto_backdrop_preload = ws_auto_backdrop_preload; - codegen_config.ws_bg2d_count_site = ws_bg2d_count_site; - codegen_config.ws_bg2d_startcol_site = ws_bg2d_startcol_site; - codegen_config.ws_bg2d_startx_site = ws_bg2d_startx_site; - codegen_config.ws_bg2d_stream_left_site = ws_bg2d_stream_left_site; - codegen_config.ws_bg2d_stream_right_site = ws_bg2d_stream_right_site; - codegen_config.ws_bg2d_bufbase_site = ws_bg2d_bufbase_site; - codegen_config.ws_bg2d_cap_site = ws_bg2d_cap_site; - if (ws_bg2d_count_site || ws_bg2d_startcol_site || ws_bg2d_startx_site) - fmt::print(" ws_bg2d 2D-background widen = ON (count=0x{:08X} startcol=0x{:08X} startx=0x{:08X})\n", - ws_bg2d_count_site, ws_bg2d_startcol_site, ws_bg2d_startx_site); - if (ws_auto_screen_x_cull) - fmt::print(" ws_auto_screen_x_cull = ON (render-funnel FOV widening)\n"); - if (!persist_init_sites.empty()) - fmt::print(" persist_options = {} init-store hook(s)\n", persist_init_sites.size()); - if (ws_auto_backdrop_preload) - fmt::print(" ws_auto_backdrop_preload = ON (far-backdrop column preload)\n"); - - // Load per-game annotations: annotations/_annotations.csv - // Silently skipped if the file doesn't exist. - PSXRecomp::AnnotationTable annotations; - { - std::string stem = exe_path.filename().string(); - std::string ann_path = "annotations/" + stem + "_annotations.csv"; - if (annotations.load(ann_path.c_str())) - fmt::print("✓ Loaded {} annotations from {}\n\n", annotations.count(), ann_path); - else - fmt::print(" (No annotations file found at {})\n\n", ann_path); - } - - PSXRecomp::CodeGenerator codegen(*exe, codegen_config); - codegen.set_annotations(&annotations); - - // Generate code for first 5 functions (as examples) - fmt::print("=== C Code Generation Examples ===\n\n"); - fmt::print("Generating code for first 5 functions...\n\n"); - - std::vector sample_funcs; - for (size_t i = 0; i < std::min(size_t(5), analysis_result.functions.size()); i++) { - sample_funcs.push_back(analysis_result.functions[i]); - } - - auto generated = codegen.generate_all_functions(sample_funcs, all_cfgs); - - // Print first generated function as example - if (!generated.empty()) { - fmt::print("Example Generated Function:\n"); - fmt::print("---------------------------------------\n"); - fmt::print("{}\n", generated[0].full_code); - fmt::print("---------------------------------------\n\n"); - } - - // Generate full C file and save to the generated directory - // Use argv[2] as output path if provided, otherwise derive from input filename - // Use filename() not stem() because ".36" in SCUS_942.36 is part of the serial, not an extension - std::string exe_stem = exe_path.filename().string(); - std::filesystem::create_directories(out_dir); - std::filesystem::path output_filename = out_dir / (exe_stem + "_full.c"); - fmt::print("Generating complete C file: {}\n", output_filename.string()); - - std::string full_c_code = codegen.generate_file(analysis_result.functions, all_cfgs); - std::set dispatch_addrs; - { - const std::string marker = "void func_"; - size_t pos = 0; - while ((pos = full_c_code.find(marker, pos)) != std::string::npos) { - size_t hex_pos = pos + marker.size(); - if (hex_pos + 8 <= full_c_code.size() && - full_c_code.compare(hex_pos + 8, 14, "(CPUState* cpu") == 0) { - std::string hex = full_c_code.substr(hex_pos, 8); - dispatch_addrs.insert((uint32_t)std::strtoul(hex.c_str(), nullptr, 16)); - } - pos = hex_pos + 8; - } - } - // Data/untranslatable entries are emitted as fail-closed - // psx_unknown_dispatch stubs. Do not put those stubs in the callable game - // dispatch table: a runtime overlay can legitimately replace the same EXE - // address, and a tiny stub range (especially a single NOP) can - // coincidentally byte-match the live overlay continuation. Advertising the - // stub as native-safe then turns valid dynamic code into a false fail-fast. - for (const auto& gen_func : codegen.last_gen_funcs()) { - if (gen_func.dispatchable) continue; - const std::string prefix = "func_"; - if (gen_func.function_name.rfind(prefix, 0) != 0 || - gen_func.function_name.size() < prefix.size() + 8) { - continue; - } - const std::string hex = - gen_func.function_name.substr(prefix.size(), 8); - dispatch_addrs.erase( - (uint32_t)std::strtoul(hex.c_str(), nullptr, 16)); - } - if (dispatch_addrs.empty() && codegen.last_gen_funcs().empty()) { - for (const auto& func : analysis_result.functions) { - dispatch_addrs.insert(func.start_addr); - } - } - - // Split-TU output: instead of one monolithic _full.c, write a - // shared declarations header plus N shards bucketed by cumulative line - // count, so the game build can compile the shards in parallel. The - // in-memory full_c_code / dispatch_addrs scan above is unaffected — only - // the on-disk representation changes. Function bodies are byte-identical - // to what full_c_code would have contained; see - // CodeGenerator::last_gen_funcs() / build_shared_decls_header(). - if (overlay_mode) { - // Overlays are single small TUs consumed by compile_overlays.py, which - // reads _full.c directly. Emit the monolith; the split path below - // would delete _full.c and leave only shards, breaking overlay compile - // (no_output). Splitting a small overlay TU has no parallel-compile - // benefit anyway. - std::ofstream full_file(output_filename); - if (full_file.is_open()) { - full_file << full_c_code; - full_file.close(); - fmt::print("✓ Saved overlay monolith to {}\n", output_filename.string()); - } else { - fmt::print(stderr, "⚠ Failed to write overlay monolith {}\n\n", - output_filename.string()); - } - } else { - // 1. Remove stale outputs: the old monolith and any previously - // written shards (a shard count shrink must not leave orphans). - std::error_code rm_ec; - std::filesystem::remove(output_filename, rm_ec); - for (int stale = 0; stale < 256; stale++) { - std::filesystem::path stale_shard = - out_dir / fmt::format("{}_full_{:02d}.c", exe_stem, stale); - std::filesystem::remove(stale_shard, rm_ec); - } - - // 2. Shared declarations header. - std::filesystem::path decls_filename = out_dir / (exe_stem + "_decls.h"); - { - std::ofstream decls_file(decls_filename); - if (decls_file.is_open()) { - decls_file << codegen.build_shared_decls_header(codegen.last_gen_funcs()); - decls_file.close(); - fmt::print("✓ Saved shared decls header to {}\n", decls_filename.string()); - } else { - fmt::print(stderr, "⚠ Failed to write shared decls header\n\n"); - } - } - - // 3. Bucket generated functions into shards by cumulative line count. - const int SHARD_LINE_BUDGET = 40000; - const std::vector& gen_funcs_for_shards = codegen.last_gen_funcs(); - std::string decls_basename = exe_stem + "_decls.h"; - - int shard_index = 0; - int shard_lines = 0; - int total_shard_lines = 0; - std::stringstream shard_buf; - auto flush_shard = [&]() { - if (shard_buf.tellp() == std::streampos(0)) return; - std::filesystem::path shard_filename = - out_dir / fmt::format("{}_full_{:02d}.c", exe_stem, shard_index); - std::ofstream shard_file(shard_filename); - if (shard_file.is_open()) { - shard_file << fmt::format( - "/* Generated by PSXRecomp - full.c shard {}. DO NOT EDIT. */\n", - shard_index); - shard_file << fmt::format("#include \"{}\"\n\n", decls_basename); - shard_file << shard_buf.str(); - shard_file.close(); - fmt::print("✓ Saved shard {} ({} lines) to {}\n", - shard_index, shard_lines, shard_filename.string()); - } else { - fmt::print(stderr, "⚠ Failed to write shard {}\n\n", shard_index); - } - total_shard_lines += shard_lines; - shard_index++; - shard_lines = 0; - shard_buf.str(std::string()); - shard_buf.clear(); - }; - - for (const auto& gf : gen_funcs_for_shards) { - if (shard_lines > 0 && shard_lines + gf.line_count > SHARD_LINE_BUDGET) { - flush_shard(); - } - shard_buf << gf.full_code << "\n"; - shard_lines += gf.line_count; - } - flush_shard(); - - fmt::print("✓ Wrote {} shards ({} total lines) for {}\n\n", - shard_index, total_shard_lines, exe_stem); - } - - // Per-function code-range manifest (design §8): consumed by the overlay - // loader's per-entry validity hash. Emitted alongside _full.c for every - // build; only the overlay path actually loads it. - const std::string ranges_manifest = codegen.last_ranges_manifest().empty() - ? codegen.generate_ranges_manifest(analysis_result.functions, all_cfgs) - : codegen.last_ranges_manifest(); - { - std::filesystem::path ranges_filename = out_dir / (exe_stem + "_full.ranges"); - std::ofstream rf(ranges_filename); - if (rf.is_open()) { - rf << ranges_manifest; - rf.close(); - fmt::print("✓ Saved code-range manifest to {}\n\n", - ranges_filename.string()); - } - } - - // Generate dispatch table (tomba_dispatch.c) - // This maps PS1 addresses to compiled C functions so call_by_address() can - // dispatch dynamic jalr/jr calls to the right compiled function. - { - std::filesystem::path dispatch_filename = out_dir / (exe_stem + "_dispatch.c"); - fmt::print("Generating dispatch table: {}\n", dispatch_filename.string()); - - std::ostringstream ds; - ds << "/* Generated by PSXRecomp - dynamic dispatch table */\n"; - ds << "#include \"psx_runtime.h\"\n\n"; - ds << "extern void psx_check_interrupts_dispatch_entry(CPUState* cpu, uint32_t resume_pc);\n\n"; - ds << "extern int dirty_ram_text_native_ok_ranges_from(const uint32_t* lo_len_pairs, uint32_t count, uint32_t exec_pc);\n\n"; - - // Forward declarations - ds << "/* Forward declarations for all recompiled functions */\n"; - for (uint32_t addr : dispatch_addrs) { - ds << fmt::format("extern void func_{:08X}(CPUState* cpu);\n", addr); - } - ds << "\n"; - - // Dispatch function - uint32_t game_text_start = exe->load_address() & 0x1FFFFFFFu; - uint32_t game_text_end = game_text_start + exe->code_size(); - ds << "int psx_game_address_in_text(uint32_t addr) {\n"; - ds << " uint32_t phys = addr & 0x1FFFFFFFu;\n"; - ds << fmt::format(" return phys >= 0x{:08X}u && phys < 0x{:08X}u;\n", - game_text_start, game_text_end); - ds << "}\n\n"; - - // Materialize one sorted data table for dispatch and entry probes. A - // giant sparse switch is catastrophically slow in Debug/-O0 builds: - // GCC lowers X4's roughly 60K cases to a multi-megabyte linear compare - // chain. Binary search stays O(log N) at every optimizer level and - // avoids emitting the same case set twice. - struct DispatchRecord { - uint32_t addr; - uint32_t resume; - uint32_t owner; - uint32_t range_index; - uint32_t range_count; - }; - std::vector records; - records.reserve(dispatch_addrs.size() + - (codegen.cps_enabled() ? codegen.cps_continuations().size() : 0)); - for (uint32_t addr : dispatch_addrs) - records.push_back({addr, 0, addr, 0, 0}); - if (codegen.cps_enabled()) { - const auto& conts = codegen.cps_continuations(); - for (const auto& [cont, owner] : conts) { - if (dispatch_addrs.count(cont)) continue; - // A continuation owned by a fail-closed stub is no more - // executable than the stub entry itself. - if (!dispatch_addrs.count(owner)) continue; - records.push_back({cont, cont, owner, 0, 0}); - } - } - std::sort(records.begin(), records.end(), - [](const DispatchRecord& a, const DispatchRecord& b) { - return a.addr < b.addr; - }); - - // Attach the exact CFG instruction ranges from the manifest to every - // dispatch owner. Mid-function alias wrappers have no separate F record, - // so resolve them to the containing host range. Mutable data and - // jump-table gaps sharing a page remain deliberately excluded. - using CodeRange = std::pair; // virtual lo, byte len - std::map> function_ranges; - { - std::istringstream input(ranges_manifest); - std::string line; - uint32_t current_owner = 0; - while (std::getline(input, line)) { - std::istringstream fields(line); - char kind = 0; - fields >> kind; - if (kind == 'F') { - fields >> std::hex >> current_owner; - function_ranges[current_owner]; - } else if (kind == 'R' && current_owner != 0) { - uint32_t lo = 0, len = 0; - fields >> std::hex >> lo >> len; - if (len != 0) - function_ranges[current_owner].push_back({lo, len}); - } - } - } - std::vector flat_ranges; - std::map> owner_spans; - auto resolve_owner_ranges = [&](uint32_t owner) - -> const std::vector* { - auto exact = function_ranges.find(owner); - if (exact != function_ranges.end() && !exact->second.empty()) - return &exact->second; - for (const auto& [entry, ranges] : function_ranges) { - (void)entry; - for (const auto& [lo, len] : ranges) { - const uint64_t hi = (uint64_t)lo + len; - if (owner >= lo && (uint64_t)owner < hi) return &ranges; - } - } - return nullptr; - }; - for (auto& rec : records) { - auto cached = owner_spans.find(rec.owner); - if (cached == owner_spans.end()) { - const uint32_t first = (uint32_t)flat_ranges.size(); - const auto* ranges = resolve_owner_ranges(rec.owner); - if (ranges) - flat_ranges.insert(flat_ranges.end(), ranges->begin(), ranges->end()); - const uint32_t count = (uint32_t)flat_ranges.size() - first; - cached = owner_spans.emplace(rec.owner, - std::make_pair(first, count)).first; - } - rec.range_index = cached->second.first; - rec.range_count = cached->second.second; - } - - ds << "typedef struct { uint32_t lo; uint32_t len; } PsxGameCodeRange;\n"; - ds << "static const PsxGameCodeRange k_psx_game_code_ranges[] = {\n"; - if (flat_ranges.empty()) ds << " {0u, 0u},\n"; - for (const auto& [lo, len] : flat_ranges) - ds << fmt::format(" {{0x{:08X}u, 0x{:X}u}},\n", lo, len); - ds << "};\n\n"; - - ds << "typedef void (*PsxGameDispatchFn)(CPUState* cpu);\n"; - ds << "typedef struct {\n"; - ds << " uint32_t addr;\n"; - ds << " uint32_t resume_pc;\n"; - ds << " uint32_t range_index;\n"; - ds << " uint32_t range_count;\n"; - ds << " PsxGameDispatchFn fn;\n"; - ds << "} PsxGameDispatchEntry;\n\n"; - ds << "static const PsxGameDispatchEntry k_psx_game_dispatch[] = {\n"; - for (const auto& rec : records) { - ds << fmt::format(" {{0x{:08X}u, 0x{:08X}u, {}u, {}u, func_{:08X}}},\n", - rec.addr, rec.resume, rec.range_index, - rec.range_count, rec.owner); - } - ds << "};\n"; - ds << fmt::format("#define PSX_GAME_DISPATCH_COUNT {}u\n\n", records.size()); - ds << "static const PsxGameDispatchEntry* psx_game_find_entry(uint32_t addr) {\n"; - ds << " uint32_t lo = 0, hi = PSX_GAME_DISPATCH_COUNT;\n"; - ds << " while (lo < hi) {\n"; - ds << " uint32_t mid = lo + (hi - lo) / 2;\n"; - ds << " uint32_t key = k_psx_game_dispatch[mid].addr;\n"; - ds << " if (addr < key) hi = mid;\n"; - ds << " else if (addr > key) lo = mid + 1;\n"; - ds << " else return &k_psx_game_dispatch[mid];\n"; - ds << " }\n"; - ds << " return 0;\n"; - ds << "}\n\n"; - - ds << "/* Exact static-code validity for this entry's emitted CFG ranges. */\n"; - ds << "int psx_game_text_native_ok(uint32_t addr) {\n"; - ds << " const PsxGameDispatchEntry* entry = psx_game_find_entry(addr);\n"; - ds << " if (!entry || entry->range_count == 0) return 0;\n"; - ds << " return dirty_ram_text_native_ok_ranges_from(\n"; - ds << " &k_psx_game_code_ranges[entry->range_index].lo, entry->range_count, addr);\n"; - ds << "}\n\n"; - - ds << "/* Maps PS1 address to compiled game code. Returns 1 if dispatched, 0 if unknown. */\n"; - ds << "int psx_dispatch_game_compiled(CPUState* cpu, uint32_t addr) {\n"; - ds << " const PsxGameDispatchEntry* entry = psx_game_find_entry(addr);\n"; - ds << " if (!entry) return 0;\n"; - ds << " if (entry->range_count == 0 || !dirty_ram_text_native_ok_ranges_from(\n"; - ds << " &k_psx_game_code_ranges[entry->range_index].lo, entry->range_count, addr)) return 0;\n"; - ds << " psx_check_interrupts_dispatch_entry(cpu, addr);\n"; - if (codegen.cps_enabled()) - ds << " cpu->pc = entry->resume_pc;\n"; - ds << " /* load_accel: cycle-faithful VSync(-1) query (config-gated). */\n"; - ds << " {\n"; - ds << " extern int psx_vsync_query_hle_try(CPUState* cpu, uint32_t addr);\n"; - ds << " if (psx_vsync_query_hle_try(cpu, addr)) return 1;\n"; - ds << " }\n"; - ds << " entry->fn(cpu);\n"; - ds << " return 1;\n"; - ds << "}\n"; - - // Non-destructive companion to psx_dispatch_game_compiled: is `addr` a PC - // the top-level dispatch can re-enter (a compiled function entry, or a CPS - // continuation resume-point)? The precise event slicer (dirty_ram_interp.c) - // needs this to know when it may hand a resume PC back to the dispatcher: - // a MID-function clean-text PC is NOT re-enterable (the switch falls to - // `default: return 0` and the dirty path returns 0 -> top-level PC=0 exit), - // so the slicer must keep interpreting until cpu->pc lands on a true entry. - // Same precise address set as dispatch, without executing the entry. - ds << "\n/* 1 iff addr is a re-enterable compiled entry/continuation (no exec). */\n"; - ds << "int psx_game_is_function_entry(uint32_t addr) {\n"; - ds << " return psx_game_find_entry(addr) != 0;\n"; - ds << "}\n"; - - if (codegen.cps_enabled()) { - // RECURSION_BUG.md §25 — mark CPS mode at startup so runtime code that - // routes CPS continuations (overlay_loader.c) sees the contract. - // Static ctor: no clash with the BIOS dispatch's marker. - ds << "\n/* CPS runtime-mode marker (the overlay loader reads g_psx_cps_mode). */\n"; - ds << "static void psx_cps_mark_game(void) {\n"; - ds << " extern int g_psx_cps_mode; g_psx_cps_mode = 1;\n"; - ds << "}\n"; - // Run psx_cps_mark_game before main(). __attribute__((constructor)) is - // GCC/Clang-only; MSVC uses a static initializer pointer in .CRT$XCU. - ds << "#if defined(_MSC_VER)\n"; - ds << "#pragma section(\".CRT$XCU\", read)\n"; - ds << "__declspec(allocate(\".CRT$XCU\")) static void (*psx_cps_mark_game_ctor)(void) = psx_cps_mark_game;\n"; - ds << "#else\n"; - ds << "__attribute__((constructor)) static void psx_cps_mark_game_ctor(void) { psx_cps_mark_game(); }\n"; - ds << "#endif\n"; - } - - std::ofstream dispatch_file(dispatch_filename); - if (dispatch_file.is_open()) { - dispatch_file << ds.str(); - dispatch_file.close(); - fmt::print("✓ Dispatch table written ({} entries)\n\n", - dispatch_addrs.size()); - } else { - fmt::print(stderr, "⚠ Failed to write dispatch file\n\n"); - } - } - - return 0; -} diff --git a/psxrecomp-overlay/recompiler/tests/recompiler_patch_test.cpp b/psxrecomp-overlay/recompiler/tests/recompiler_patch_test.cpp deleted file mode 100644 index 1912513..0000000 --- a/psxrecomp-overlay/recompiler/tests/recompiler_patch_test.cpp +++ /dev/null @@ -1,1135 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include - -#include "code_generator.h" -#include "config_loader.h" -#include "control_flow.h" -#include "fmt/format.h" -#include "gte_register_classification.h" -#include "recompiler_patch.h" - -namespace fs = std::filesystem; -using PSXRecompV4::RecompilerPatch; - -namespace { - -int failures = 0; - -void check(bool condition, const std::string& name) { - if (condition) { - fmt::print("PASS {}\n", name); - } else { - fmt::print(stderr, "FAIL {}\n", name); - ++failures; - } -} - -template -void check_throws(Fn&& fn, const std::string& needle, - const std::string& name) { - try { - fn(); - check(false, name + " (did not throw)"); - } catch (const std::exception& e) { - check(std::string(e.what()).find(needle) != std::string::npos, - name + " (" + e.what() + ")"); - } -} - -void append_word(std::vector& bytes, uint32_t word) { - bytes.push_back(static_cast(word)); - bytes.push_back(static_cast(word >> 8)); - bytes.push_back(static_cast(word >> 16)); - bytes.push_back(static_cast(word >> 24)); -} - -void write_word(PSXRecomp::PS1Executable& exe, uint32_t address, - uint32_t word) { - const size_t offset = static_cast(address - exe.header.load_address); - exe.code_data[offset + 0] = static_cast(word); - exe.code_data[offset + 1] = static_cast(word >> 8); - exe.code_data[offset + 2] = static_cast(word >> 16); - exe.code_data[offset + 3] = static_cast(word >> 24); -} - -std::string generate_first_instruction(uint32_t first_word, - std::vector patches, - bool overlay_mode, - PSXRecomp::CodeGenConfig config = {}) { - constexpr uint32_t base = 0x80010000u; - PSXRecomp::PS1Executable exe{}; - exe.header.load_address = base; - exe.header.initial_pc = base; - exe.header.file_size = 20; - append_word(exe.code_data, first_word); - append_word(exe.code_data, 0x00000000u); // branch/nonbranch delay-slot nop - append_word(exe.code_data, 0x24030001u); // addiu v1, zero, 1 - append_word(exe.code_data, 0x03E00008u); // jr ra - append_word(exe.code_data, 0x00000000u); // return delay-slot nop - - PSXRecompV4::apply_recompiler_patches_to_executable( - exe, patches, overlay_mode); - - PSXRecomp::Function function{}; - function.start_addr = base; - function.end_addr = base + 20; - function.size = 20; - function.name = "patch_test"; - - PSXRecomp::ControlFlowAnalyzer analyzer(exe); - const auto cfg = analyzer.analyze_function(function); - config.overlay_mode = overlay_mode; - PSXRecomp::CodeGenerator generator(exe, config); - return generator.generate_function(function, cfg).full_code; -} - -std::string base_config() { - return R"toml([game] -name = "Patch Test" -id = "TEST-00000" -exe = "TEST.EXE" -load_address = "0x80010000" -entry_pc = "0x80010000" -text_size = "0x1000" -stack_base = "0x801FFFF0" - -[recompiler] -seeds = "seeds.txt" -out_dir = "generated" -)toml"; -} - -fs::path write_config(const fs::path& root, const std::string& suffix, - const std::string& patch_tables) { - const fs::path path = root / ("game-" + suffix + ".toml"); - std::ofstream file(path, std::ios::binary); - file << base_config() << patch_tables; - file.close(); - return path; -} - -void parser_tests(const fs::path& root) { - const auto valid = write_config(root, "valid", R"toml( -[[recompiler.patch]] -id = "gameplay-rate" -address = "0x80012340" -expected = "0x24020002" -replacement = "0x24020001" -note = "Test-only fixture" -)toml"); - const auto config = PSXRecompV4::load_game_config(valid); - check(config.recompiler_patches.size() == 1, - "parser accepts one guarded patch"); - check(config.recompiler_patches[0].id == "gameplay-rate" && - config.recompiler_patches[0].address == 0x80012340u && - config.recompiler_patches[0].expected == 0x24020002u && - config.recompiler_patches[0].replacement == 0x24020001u, - "parser preserves patch fields"); - - const auto audio_buffer = write_config(root, "audio-buffer", R"toml( -[runtime] - -[audio] -buffer_ms = 60 -)toml"); - const auto audio_config = PSXRecompV4::load_game_config(audio_buffer); - check(audio_config.runtime.audio_buffer_ms == 60, - "parser preserves per-game audio buffer target"); - - const auto bad_audio_buffer = write_config(root, "bad-audio-buffer", R"toml( -[runtime] - -[audio] -buffer_ms = 20 -)toml"); - check_throws( - [&] { (void)PSXRecompV4::load_game_config(bad_audio_buffer); }, - "[audio] buffer_ms out of range (30..500)", - "parser rejects unsafe audio buffer target"); - - const auto duplicate_address = write_config(root, "duplicate-address", R"toml( -[[recompiler.patch]] -id = "first" -address = "0x80012340" -expected = "0x24020002" -replacement = "0x24020001" - -[[recompiler.patch]] -id = "alias" -address = "0xA0012340" -expected = "0x24020002" -replacement = "0x24020001" -)toml"); - check_throws( - [&] { (void)PSXRecompV4::load_game_config(duplicate_address); }, - "physical address", "parser rejects duplicate aliased addresses"); - - const auto duplicate_id = write_config(root, "duplicate-id", R"toml( -[[recompiler.patch]] -id = "same-id" -address = "0x80012340" -expected = "0x24020002" -replacement = "0x24020001" - -[[recompiler.patch]] -id = "same-id" -address = "0x80012344" -expected = "0x24030002" -replacement = "0x24030001" -)toml"); - check_throws([&] { (void)PSXRecompV4::load_game_config(duplicate_id); }, - "duplicate [[recompiler.patch]] id", - "parser rejects duplicate IDs"); - - const auto unaligned = write_config(root, "unaligned", R"toml( -[[recompiler.patch]] -id = "unaligned" -address = "0x80012342" -expected = "0x24020002" -replacement = "0x24020001" -)toml"); - check_throws([&] { (void)PSXRecompV4::load_game_config(unaligned); }, - "instruction-aligned", "parser rejects unaligned sites"); - - const auto negsub = write_config(root, "negsub", R"toml( -[widescreen.cull] -negsub_sites = ["0x80012340"] -)toml"); - const auto negsub_config = PSXRecompV4::load_game_config(negsub); - check(negsub_config.ws_cull_negsub_sites == - std::vector{0x80012340u}, - "parser preserves negsub cull sites"); - - const auto manual_window = write_config(root, "manual-window", R"toml( -[widescreen.cull] -lower_branch_sites = ["0x80012340"] -lower_value_sites = ["0x80012344"] -upper_value_sites = ["0x80012348"] -span_value_sites = ["0x8001234C"] -)toml"); - const auto manual_window_config = - PSXRecompV4::load_game_config(manual_window); - check(manual_window_config.ws_cull_lower_branch_sites == - std::vector{0x80012340u} && - manual_window_config.ws_cull_lower_value_sites == - std::vector{0x80012344u} && - manual_window_config.ws_cull_upper_value_sites == - std::vector{0x80012348u} && - manual_window_config.ws_cull_span_value_sites == - std::vector{0x8001234Cu}, - "parser preserves manual screen-window site families"); - auto changed_manual_window_config = manual_window_config; - changed_manual_window_config.ws_cull_span_value_sites.push_back( - 0x80012350u); - check(PSXRecompV4::overlay_codegen_config_hash(manual_window_config) != - PSXRecompV4::overlay_codegen_config_hash( - changed_manual_window_config), - "manual screen-window sites change overlay cache identity"); - - const auto vxrange = write_config(root, "vxrange", R"toml( -[widescreen.cull] -vxrange_sites = ["0x80012340"] -)toml"); - const auto vxrange_config = PSXRecompV4::load_game_config(vxrange); - check(vxrange_config.ws_cull_vxrange_sites == - std::vector{0x80012340u}, - "parser preserves masked-u16 X-window sites"); - - const auto depth = write_config(root, "depth", R"toml( -[widescreen.cull] -depth_sites = ["0x80012340"] -)toml"); - const auto depth_config = PSXRecompV4::load_game_config(depth); - check(depth_config.ws_cull_depth_sites == - std::vector{0x80012340u}, - "parser preserves depth-bound sites"); - - const auto plane_nx = write_config(root, "plane-nx", R"toml( -[widescreen.cull] -plane_nx_sites = ["0x80012340"] -)toml"); - const auto plane_nx_config = PSXRecompV4::load_game_config(plane_nx); - check(plane_nx_config.ws_cull_plane_nx_sites == - std::vector{0x80012340u}, - "parser preserves side-plane nx load sites"); - - const auto xclip_load = write_config(root, "xclip-load", R"toml( -[widescreen.cull] -xclip_load_sites = ["0x80012340"] -)toml"); - const auto xclip_load_config = PSXRecompV4::load_game_config(xclip_load); - check(xclip_load_config.ws_cull_xclip_load_sites == - std::vector{0x80012340u}, - "parser preserves per-prim bound-load sites"); - - const auto keep = write_config(root, "cull-keep", R"toml( -[[widescreen.cull.keep]] -address = "0x8002B310" -expected = "0x28A21C01" -result = 1 - -[[widescreen.cull.keep]] -address = "0x8002B368" -expected = "0x0082202A" -result = 0 -)toml"); - const auto keep_config = PSXRecompV4::load_game_config(keep); - check(keep_config.ws_cull_keep_sites.size() == 2 && - keep_config.ws_cull_keep_sites[0].address == 0x8002B310u && - keep_config.ws_cull_keep_sites[0].expected == 0x28A21C01u && - keep_config.ws_cull_keep_sites[0].result == 1u && - keep_config.ws_cull_keep_sites[1].address == 0x8002B368u && - keep_config.ws_cull_keep_sites[1].expected == 0x0082202Au && - keep_config.ws_cull_keep_sites[1].result == 0u, - "parser preserves full-word-guarded maximal-participation sites"); - - const auto bad_keep = write_config(root, "cull-keep-bad", R"toml( -[[widescreen.cull.keep]] -address = "0x8002B310" -expected = "0x24820001" -result = 1 -)toml"); - check_throws([&] { (void)PSXRecompV4::load_game_config(bad_keep); }, - "expected must be SLT/SLTU/SLTI/SLTIU", - "parser rejects non-comparison maximal-participation sites"); - - const auto range = write_config(root, "range-cull", R"toml( -[widescreen.cull] -range_sites = ["0x80012340"] -activation_guard_pixels = 256 -)toml"); - const auto range_config = PSXRecompV4::load_game_config(range); - check(range_config.ws_cull_range_sites == - std::vector{0x80012340u} && - range_config.ws_cull_activation_guard_pixels == 256, - "parser preserves explicit range sites and activation guard"); - - const auto bad_activation_guard = - write_config(root, "range-cull-bad-activation-guard", R"toml( -[widescreen.cull] -activation_guard_pixels = 257 -)toml"); - check_throws( - [&] { - (void)PSXRecompV4::load_game_config(bad_activation_guard); - }, - "activation_guard_pixels must be in [0, 256]", - "parser rejects an oversized activation-only guard"); - - const auto angle = write_config(root, "angle-cull", R"toml( -[[widescreen.cull.angle]] -address = "0x8013F138" -expected = "0x24020155" -)toml"); - const auto angle_config = PSXRecompV4::load_game_config(angle); - check(angle_config.ws_cull_angle_sites.size() == 1 && - angle_config.ws_cull_angle_sites[0].address == - 0x8013F138u && - angle_config.ws_cull_angle_sites[0].expected == - 0x24020155u, - "parser preserves exact terrain-frustum angle sites"); - auto changed_angle_config = angle_config; - changed_angle_config.ws_cull_angle_sites[0].expected = - 0x24020156u; - check(PSXRecompV4::overlay_codegen_config_hash(angle_config) != - PSXRecompV4::overlay_codegen_config_hash( - changed_angle_config), - "terrain angle sites change overlay cache identity"); - - const auto bad_angle = write_config(root, "angle-cull-bad", R"toml( -[[widescreen.cull.angle]] -address = "0x8013F138" -expected = "0x24420155" -)toml"); - check_throws([&] { (void)PSXRecompV4::load_game_config(bad_angle); }, - "expected must be ADDI/ADDIU rt,zero,imm", - "parser rejects non-constant terrain-angle instructions"); - - const auto yaw_cone = write_config(root, "yaw-cone", R"toml( -[[widescreen.cull.yaw_cone]] -address = "0x80040FE8" -expected = "0x24710020" -full_turn = 256 - -[[widescreen.cull.yaw_cone]] -address = "0x80040FF0" -expected = "0x2472FFE0" -full_turn = 256 - -[[widescreen.cull.horizontal_slt]] -address = "0x8003B8EC" -expected = "0x0067102A" -)toml"); - const auto yaw_cone_config = PSXRecompV4::load_game_config(yaw_cone); - check(yaw_cone_config.ws_cull_yaw_cone_sites.size() == 2 && - yaw_cone_config.ws_cull_yaw_cone_sites[0].address == - 0x80040FE8u && - yaw_cone_config.ws_cull_yaw_cone_sites[0].expected == - 0x24710020u && - yaw_cone_config.ws_cull_yaw_cone_sites[0].full_turn == 256u && - yaw_cone_config.ws_cull_yaw_cone_sites[1].expected == - 0x2472FFE0u, - "parser preserves signed wrapping-yaw cone sites"); - check(yaw_cone_config.ws_cull_horizontal_slt_sites.size() == 1 && - yaw_cone_config.ws_cull_horizontal_slt_sites[0].address == - 0x8003B8ECu && - yaw_cone_config.ws_cull_horizontal_slt_sites[0].expected == - 0x0067102Au, - "parser preserves signed horizontal side-plane sites"); - auto changed_yaw_cone_config = yaw_cone_config; - changed_yaw_cone_config.ws_cull_yaw_cone_sites[0].full_turn = 512u; - check(PSXRecompV4::overlay_codegen_config_hash(yaw_cone_config) != - PSXRecompV4::overlay_codegen_config_hash( - changed_yaw_cone_config), - "yaw-cone metadata changes overlay cache identity"); - - const auto bad_yaw_cone = write_config(root, "yaw-cone-bad", R"toml( -[[widescreen.cull.yaw_cone]] -address = "0x80040FE8" -expected = "0x24110020" -full_turn = 256 -)toml"); - check_throws([&] { (void)PSXRecompV4::load_game_config(bad_yaw_cone); }, - "rs != zero", - "parser rejects yaw-cone constants without a live base yaw"); - - const auto bad_horizontal_slt = write_config( - root, "horizontal-slt-bad", R"toml( -[[widescreen.cull.horizontal_slt]] -address = "0x8003B8EC" -expected = "0x0067102B" -)toml"); - check_throws( - [&] { (void)PSXRecompV4::load_game_config(bad_horizontal_slt); }, - "signed register-register SLT", - "parser rejects unsigned horizontal side-plane compares"); - - const auto aspect_cone = write_config(root, "aspect-cone", R"toml( -[widescreen.cull.aspect_cone] -forward_addr = "0x1F8000E8" -object_type_offset = 12 -object_reg = 19 -x_reg = 16 -z_reg = 17 -y_reg = 18 -hysteresis_pixels = 24 -queue_reserve = 4 -queue_count_addrs = ["0x1F800144", "0x1F800150", "0x1F80015C"] -queue_capacities = [24, 40, 28] -queue_type_masks = ["0x00000204", "0x00000010", "0x00000020"] - -[[widescreen.cull.aspect_cone.sites]] -address = "0x80010000" -expected = "0x28620370" - -[[widescreen.cull.aspect_cone.sites]] -address = "0x80010004" -expected = "0x0082202A" -cosine_threshold = 856 -object_reg = 20 -x_reg = 19 -z_reg = 18 -y_reg = 17 -queue_guard = false -)toml"); - const auto aspect_config = - PSXRecompV4::load_game_config(aspect_cone); - check(aspect_config.ws_aspect_cone.sites.size() == 2 && - aspect_config.ws_aspect_cone.sites[0].address == - 0x80010000u && - aspect_config.ws_aspect_cone.sites[0].expected == - 0x28620370u && - aspect_config.ws_aspect_cone.sites[0].cosine_threshold == - 0x370u && - aspect_config.ws_aspect_cone.sites[1].expected == - 0x0082202Au && - aspect_config.ws_aspect_cone.sites[1].cosine_threshold == - 856u && - aspect_config.ws_aspect_cone.sites[1].object_reg == 20u && - aspect_config.ws_aspect_cone.sites[1].x_reg == 19u && - aspect_config.ws_aspect_cone.sites[1].z_reg == 18u && - aspect_config.ws_aspect_cone.sites[1].y_reg == 17u && - !aspect_config.ws_aspect_cone.sites[1].queue_guard && - aspect_config.ws_aspect_cone.forward_addr == - 0x1F8000E8u && - aspect_config.ws_aspect_cone.object_reg == 19u && - aspect_config.ws_aspect_cone.x_reg == 16u && - aspect_config.ws_aspect_cone.z_reg == 17u && - aspect_config.ws_aspect_cone.y_reg == 18u && - aspect_config.ws_aspect_cone.hysteresis_pixels == 24u && - aspect_config.ws_aspect_cone.queue_reserve == 4u && - aspect_config.ws_aspect_cone.queue_capacities == - std::array{24u, 40u, 28u}, - "parser preserves aspect-aware participation policy"); - - const auto aspect_hash = PSXRecompV4::overlay_codegen_config_hash( - aspect_config); - auto changed_aspect_config = aspect_config; - changed_aspect_config.ws_aspect_cone.hysteresis_pixels++; - check(aspect_hash != PSXRecompV4::overlay_codegen_config_hash( - changed_aspect_config), - "aspect participation policy changes overlay cache identity"); - changed_aspect_config = aspect_config; - changed_aspect_config.ws_cull_guard_pixels++; - check(aspect_hash != PSXRecompV4::overlay_codegen_config_hash( - changed_aspect_config), - "widescreen guard changes overlay cache identity"); - changed_aspect_config = aspect_config; - changed_aspect_config.ws_cull_activation_guard_pixels++; - check(aspect_hash != PSXRecompV4::overlay_codegen_config_hash( - changed_aspect_config), - "activation-only guard changes overlay cache identity"); - changed_aspect_config = aspect_config; - changed_aspect_config.ws_aspect_cone.sites[1].queue_guard = true; - check(aspect_hash != PSXRecompV4::overlay_codegen_config_hash( - changed_aspect_config), - "per-site aspect participation policy changes cache identity"); - - const auto bad_aspect_opcode = - write_config(root, "aspect-cone-bad-opcode", R"toml( -[widescreen.cull.aspect_cone] -forward_addr = "0x1F8000E8" -object_type_offset = 12 -object_reg = 19 -x_reg = 16 -z_reg = 17 -y_reg = 18 -queue_count_addrs = ["0x1F800144", "0x1F800150", "0x1F80015C"] -queue_capacities = [24, 40, 28] -queue_type_masks = ["0x00000204", "0x00000010", "0x00000020"] -[[widescreen.cull.aspect_cone.sites]] -address = "0x80010000" -expected = "0x2C620370" -)toml"); - check_throws( - [&] { - (void)PSXRecompV4::load_game_config(bad_aspect_opcode); - }, - "expected must be signed SLTI or SLT", - "parser rejects unsigned aspect-cone guards"); - - const auto bad_aspect_slt_threshold = - write_config(root, "aspect-cone-bad-slt-threshold", R"toml( -[widescreen.cull.aspect_cone] -forward_addr = "0x1F8000E8" -object_type_offset = 12 -object_reg = 19 -x_reg = 16 -z_reg = 17 -y_reg = 18 -queue_count_addrs = ["0x1F800144", "0x1F800150", "0x1F80015C"] -queue_capacities = [24, 40, 28] -queue_type_masks = ["0x00000204", "0x00000010", "0x00000020"] -[[widescreen.cull.aspect_cone.sites]] -address = "0x80010000" -expected = "0x0082202A" -)toml"); - check_throws( - [&] { - (void)PSXRecompV4::load_game_config( - bad_aspect_slt_threshold); - }, - "cosine_threshold must be in [1, 1023]", - "parser requires an explicit Q10 threshold for SLT cones"); - - const auto bad_aspect_arrays = - write_config(root, "aspect-cone-bad-arrays", R"toml( -[widescreen.cull.aspect_cone] -forward_addr = "0x1F8000E8" -object_type_offset = 12 -object_reg = 19 -x_reg = 16 -z_reg = 17 -y_reg = 18 -queue_count_addrs = ["0x1F800144", "0x1F800150"] -queue_capacities = [24, 40, 28] -queue_type_masks = ["0x00000204", "0x00000010", "0x00000020"] -[[widescreen.cull.aspect_cone.sites]] -address = "0x80010000" -expected = "0x28620370" -)toml"); - check_throws( - [&] { - (void)PSXRecompV4::load_game_config(bad_aspect_arrays); - }, - "must contain exactly three values", - "parser rejects incomplete aspect-cone queue metadata"); -} - -void capture_history_config_tests(const fs::path& root) { - const auto valid = write_config(root, "capture-history", R"toml( -[runtime] -overlay_cache = true -overlay_capture_history = true -overlay_capture_persist_dir = ".aot_capture_history/TEST-00000" -)toml"); - const auto cfg = PSXRecompV4::load_game_config(valid); - check(cfg.runtime.overlay_capture_history, - "parser enables durable overlay capture history"); - check(cfg.runtime.overlay_capture_persist_dir == - ".aot_capture_history/TEST-00000", - "parser preserves project-relative capture history directory"); - - const auto escaping = write_config(root, "capture-history-escape", R"toml( -[runtime] -overlay_capture_persist_dir = "../outside" -)toml"); - check_throws([&] { (void)PSXRecompV4::load_game_config(escaping); }, - "must stay inside the project", - "parser rejects escaping capture history directory"); - - const auto absolute = write_config(root, "capture-history-absolute", R"toml( -[runtime] -overlay_capture_persist_dir = "C:/outside" -)toml"); - check_throws([&] { (void)PSXRecompV4::load_game_config(absolute); }, - "must be project-relative", - "parser rejects absolute capture history directory"); - - // A game.toml travels between hosts, so each of these must be rejected on - // every host, not only on the one whose path grammar it is written in. - // TOML literal strings ('...') are used because a backslash is an escape - // inside a TOML basic string. Cases marked POSIX-visible are the ones a - // host-native std::filesystem check silently accepted under POSIX. - struct RejectedDir { - const char* value; // as written in game.toml - const char* needle; // expected message fragment - const char* name; - }; - static constexpr RejectedDir rejected[] = { - {"'/outside'", "must be project-relative", - "parser rejects rooted capture history directory"}, - {R"('\outside')", "must be project-relative", - "parser rejects backslash-rooted capture history directory"}, - {R"('C:\outside')", "must be project-relative", - "parser rejects backslash drive-qualified capture history directory"}, - {"'C:outside'", "must be project-relative", - "parser rejects drive-relative capture history directory"}, - {R"('\\server\share\outside')", "must be project-relative", - "parser rejects UNC capture history directory"}, - {R"('..\outside')", "must stay inside the project", - "parser rejects backslash-escaping capture history directory"}, - {R"('nested\..\..\outside')", "must stay inside the project", - "parser rejects nested backslash-escaping capture history directory"}, - {"'nested/..'", "must stay inside the project", - "parser rejects a trailing .. component"}, - // A basic string, not a literal one: TOML forbids a raw NUL byte but - // allows the \u0000 escape that produces it. The runtime passes this - // value on as a C string, so a NUL would truncate it after validation. - {R"("..\u0000keep")", "must not contain a NUL byte", - "parser rejects an embedded NUL escape"}, - }; - for (size_t i = 0; i < std::size(rejected); ++i) { - const auto& c = rejected[i]; - const auto cfg_path = write_config( - root, fmt::format("capture-history-rejected-{}", i), - fmt::format("[runtime]\noverlay_capture_persist_dir = {}\n", - c.value)); - check_throws([&] { (void)PSXRecompV4::load_game_config(cfg_path); }, - c.needle, c.name); - } - - // A dotfile is not a `..` component; the guard must not over-reject. - const auto dotted = write_config(root, "capture-history-dotted", R"toml( -[runtime] -overlay_capture_history = true -overlay_capture_persist_dir = ".aot_capture_history/..keep/TEST-00000" -)toml"); - const auto dotted_cfg = PSXRecompV4::load_game_config(dotted); - check(dotted_cfg.runtime.overlay_capture_persist_dir == - ".aot_capture_history/..keep/TEST-00000", - "parser accepts a capture history component that merely starts with dots"); -} - -void codegen_tests() { - constexpr uint32_t original = 0x24020002u; // addiu v0, zero, 2 - constexpr uint32_t replacement = 0x24020001u; // addiu v0, zero, 1 - const RecompilerPatch physical_alias{ - "gameplay-rate", 0x00010000u, original, replacement, ""}; - - const std::string applied = - generate_first_instruction(original, {physical_alias}, false); - check(applied.find("0x24020001") != std::string::npos && - applied.find("cpu->gpr[2] = 1;") != std::string::npos, - "codegen applies exact patch through physical alias"); - - check_throws( - [&] { (void)generate_first_instruction(0x24020003u, - {physical_alias}, false); }, - "wrong game revision or stale patch", - "main codegen fails on stale opcode guard"); - - const std::string overlay = - generate_first_instruction(0x24020003u, {physical_alias}, true); - check(overlay.find("0x24020003") != std::string::npos && - overlay.find("0x24020001") == std::string::npos, - "overlay nonmatching variant remains unchanged"); - - constexpr uint32_t beq_v0_zero = 0x10400002u; - constexpr uint32_t bne_v0_zero = 0x14400002u; - const RecompilerPatch branch_patch{ - "feature-branch", 0x80010000u, beq_v0_zero, bne_v0_zero, ""}; - const std::string branch = - generate_first_instruction(beq_v0_zero, {branch_patch}, false); - check(branch.find("cpu->gpr[2] != cpu->gpr[0]") != std::string::npos, - "patch is applied before control-flow analysis"); - - std::vector merged{physical_alias}; - PSXRecompV4::merge_recompiler_patches(merged, {physical_alias}); - check(merged.size() == 1, - "config merge deduplicates an identical patch"); - - const RecompilerPatch conflicting_id{ - "gameplay-rate", 0x00010004u, original, replacement, ""}; - check_throws( - [&] { - PSXRecompV4::merge_recompiler_patches(merged, {conflicting_id}); - }, - "conflicting recompiler patches", - "config merge rejects cross-config ID conflicts"); - - PSXRecomp::CodeGenConfig negsub_config; - negsub_config.ws_cull_negsub_sites.insert(0x80010000u); - const std::string negsub = generate_first_instruction( - 0x00041023u, {}, false, negsub_config); // subu v0,zero,a0 - check(negsub.find("cpu->gpr[2] = 0u - cpu->gpr[4] - " - "(uint32_t)psx_ws_x_margin()") != std::string::npos, - "codegen emits configured negsub horizontal low-edge widen"); - - const std::string overlay_mismatch = generate_first_instruction( - 0x00041021u, {}, true, negsub_config); // addu v0,zero,a0 - check(overlay_mismatch.find("ws cull negsub") == std::string::npos, - "overlay nonmatching negsub variant remains unchanged"); - - PSXRecomp::CodeGenConfig cpu_projection_config; - cpu_projection_config.ws_backdrop_x_sites.insert(0x80010000u); - const std::string cpu_projection = generate_first_instruction( - 0x248200A0u, {}, false, cpu_projection_config); // addiu v0,a0,160 - check(cpu_projection.find( - "psx_ws_project_x((int32_t)(cpu->gpr[4] + (uint32_t)160))") != - std::string::npos, - "codegen squashes exact CPU-projected screen-X sites"); - - const std::string cpu_projection_overlay_mismatch = - generate_first_instruction( - 0x288200A0u, {}, true, cpu_projection_config); // slti at same VA - check(cpu_projection_overlay_mismatch.find("psx_ws_project_x") == - std::string::npos, - "overlay nonmatching CPU-projection variant remains unchanged"); - - PSXRecomp::CodeGenConfig lower_branch_config; - lower_branch_config.ws_cull_lower_branch_sites.insert(0x80010000u); - const std::string lower_bgez = generate_first_instruction( - 0x04810002u, {}, false, lower_branch_config); // bgez a0,+2 - check(lower_bgez.find( - "(int32_t)cpu->gpr[4] >= -psx_ws_x_margin()") != - std::string::npos, - "codegen widens a manual bgez lower screen edge"); - const std::string lower_blez = generate_first_instruction( - 0x18800002u, {}, false, lower_branch_config); // blez a0,+2 - check(lower_blez.find( - "(int32_t)cpu->gpr[4] <= -psx_ws_x_margin()") != - std::string::npos, - "codegen preserves manual lower-edge branch sense"); - - PSXRecomp::CodeGenConfig lower_value_config; - lower_value_config.ws_cull_lower_value_sites.insert(0x80010000u); - const std::string lower_zero = generate_first_instruction( - 0x00001021u, {}, false, lower_value_config); // addu v0,zero,zero - check(lower_zero.find("psx_ws_cull_lower_value(0)") != - std::string::npos, - "codegen widens a register lower clamp"); - const std::string lower_store = generate_first_instruction( - 0xAFA0002Cu, {}, false, lower_value_config); // sw zero,44(sp) - check(lower_store.find("cpu->write_word(cpu->gpr[29] + 44, " - "(uint32_t)psx_ws_cull_lower_value(0))") != - std::string::npos, - "codegen widens a stored lower clamp"); - - PSXRecomp::CodeGenConfig upper_value_config; - upper_value_config.ws_cull_upper_value_sites.insert(0x80010000u); - const std::string upper_value = generate_first_instruction( - 0x34020140u, {}, false, upper_value_config); // ori v0,zero,320 - check(upper_value.find("psx_ws_cull_upper_value(320)") != - std::string::npos, - "codegen widens a manual upper bound constant"); - - PSXRecomp::CodeGenConfig span_value_config; - span_value_config.ws_cull_span_value_sites.insert(0x80010000u); - const std::string span_value = generate_first_instruction( - 0x34020140u, {}, false, span_value_config); // packed [0,320] - check(span_value.find("psx_ws_cull_span_value(0x00000140u)") != - std::string::npos, - "codegen widens both halves of a packed screen span"); - - PSXRecomp::CodeGenConfig vxrange_config; - vxrange_config.ws_cull_vxrange_sites.insert(0x80010000u); - const std::string vxrange = generate_first_instruction( - 0x2C820140u, {}, false, vxrange_config); // sltiu v0,a0,0x140 - check(vxrange.find("cpu->gpr[2] = psx_ws_cull_vxrange(cpu->gpr[4], 320)") != - std::string::npos, - "codegen routes masked-u16 X-window sites through shared helper"); - - const std::string vxrange_overlay_mismatch = generate_first_instruction( - 0x24820140u, {}, true, vxrange_config); // addiu v0,a0,0x140 - check(vxrange_overlay_mismatch.find("ws cull masked-u16") == std::string::npos, - "overlay nonmatching masked-u16 variant remains unchanged"); - - PSXRecomp::CodeGenConfig depth_config; - depth_config.ws_cull_depth_sites.insert(0x80010000u); - const std::string signed_depth = generate_first_instruction( - 0x28827FFFu, {}, false, depth_config); // slti v0,a0,0x7fff - check(signed_depth.find("psx_ws_depth_bound(32767)") != std::string::npos, - "codegen emits signed aspect-scaled depth bound"); - - const std::string unsigned_depth = generate_first_instruction( - 0x2C82FFFFu, {}, false, depth_config); // sltiu v0,a0,-1 - check(unsigned_depth.find("psx_ws_depth_bound(-1)") != std::string::npos, - "unsigned depth emit preserves MIPS immediate sign extension"); - - const std::string depth_overlay_mismatch = generate_first_instruction( - 0x24827FFFu, {}, true, depth_config); // addiu v0,a0,0x7fff - check(depth_overlay_mismatch.find("ws cull depth") == std::string::npos, - "overlay nonmatching depth variant remains unchanged"); - - PSXRecomp::CodeGenConfig range_config; - range_config.ws_cull_range_sites.insert(0x80010000u); - const std::string range = generate_first_instruction( - 0x2C8201C1u, {}, false, range_config); // sltiu v0,a0,0x1c1 - check(range.find("2*psx_ws_x_margin()") != std::string::npos, - "native range emit widens by both horizontal margins"); - - auto activation_range_config = range_config; - activation_range_config.ws_cull_activation_guard_pixels = 256; - const std::string activation_range = generate_first_instruction( - 0x2C8201C1u, {}, false, - activation_range_config); // sltiu v0,a0,0x1c1 - check(activation_range.find( - "2*(psx_ws_x_margin() > 0 ? psx_ws_x_margin() + 256 : 0)") != - std::string::npos, - "activation range gains an isolated resident-object lead"); - - PSXRecomp::CodeGenConfig activation_bias_config; - activation_bias_config.ws_cull_bias_sites.insert(0x80010000u); - activation_bias_config.ws_cull_activation_guard_pixels = 256; - const std::string activation_bias = generate_first_instruction( - 0x248200E6u, {}, false, - activation_bias_config); // addiu v0,a0,230 - check(activation_bias.find( - "(psx_ws_x_margin() > 0 ? psx_ws_x_margin() + 256 : 0)") != - std::string::npos, - "activation bias gains the same isolated resident-object lead"); - - PSXRecomp::CodeGenConfig plane_nx_config; - plane_nx_config.ws_cull_plane_nx_sites.insert(0x80010000u); - const std::string plane_nx = generate_first_instruction( - 0x8C84C828u, {}, false, plane_nx_config); // lw a0,-0x37d8(a0) - check(plane_nx.find("psx_ws_plane_nx((int32_t)psx_cyc_load_word") != - std::string::npos, - "codegen routes side-plane nx loads through shared helper"); - - const std::string plane_nx_overlay_mismatch = generate_first_instruction( - 0x9484C828u, {}, true, plane_nx_config); // lhu a0,-0x37d8(a0) - check(plane_nx_overlay_mismatch.find("ws cull plane nx") == std::string::npos, - "overlay nonmatching plane-nx variant remains unchanged"); - - PSXRecomp::CodeGenConfig xclip_config; - xclip_config.ws_cull_xclip_load_sites.insert(0x80010000u); - const std::string xclip = generate_first_instruction( - 0x8C6200F8u, {}, false, xclip_config); // lw v0,0xf8(v1) - check(xclip.find("psx_ws_xclip_bound(psx_cyc_load_word") != std::string::npos, - "codegen routes per-prim bound loads through shared helper"); - - const std::string xclip_overlay_mismatch = generate_first_instruction( - 0x246200F8u, {}, true, xclip_config); // addiu v0,v1,0xf8 - check(xclip_overlay_mismatch.find("ws cull xclip") == std::string::npos, - "overlay nonmatching xclip variant remains unchanged"); - - PSXRecomp::CodeGenConfig keep_config; - keep_config.ws_cull_keep_sites.push_back( - {0x80010000u, 0x28821C01u, 1u}); // slti v0,a0,0x1c01 - const std::string keep_true = generate_first_instruction( - 0x28821C01u, {}, false, keep_config); - check(keep_true.find("psx_ws_cull_keep_result") != std::string::npos && - keep_true.find(", 1u)") != std::string::npos, - "codegen emits guarded widescreen keep-true comparison"); - - keep_config.ws_cull_keep_sites[0] = - {0x80010000u, 0x0082202Au, 0u}; // slt a0,a0,v0 - const std::string keep_false = generate_first_instruction( - 0x0082202Au, {}, false, keep_config); - check(keep_false.find("psx_ws_cull_keep_result") != std::string::npos && - keep_false.find(", 0u)") != std::string::npos, - "codegen emits guarded widescreen keep-false comparison"); - - const std::string keep_overlay_mismatch = generate_first_instruction( - 0x0082202Bu, {}, true, keep_config); // sltu at same overlay VA - check(keep_overlay_mismatch.find("maximal object/model participation") == - std::string::npos, - "overlay full-word mismatch leaves keep site unchanged"); - - PSXRecomp::CodeGenConfig angle_config; - angle_config.ws_cull_angle_sites.push_back( - {0x80010000u, 0x24020155u}); - const std::string angle = generate_first_instruction( - 0x24020155u, {}, false, angle_config); - check(angle.find("psx_ws_angle_widen(341u)") != - std::string::npos, - "codegen emits exact terrain-frustum angle helper"); - auto activation_isolated_angle_config = angle_config; - activation_isolated_angle_config.ws_cull_activation_guard_pixels = 256; - const std::string activation_isolated_angle = generate_first_instruction( - 0x24020155u, {}, false, activation_isolated_angle_config); - check(activation_isolated_angle == angle, - "activation guard leaves terrain-frustum emission byte-identical"); - const std::string angle_overlay_mismatch = - generate_first_instruction(0x240201C7u, {}, true, angle_config); - check(angle_overlay_mismatch.find("psx_ws_angle_widen") == - std::string::npos, - "overlay full-word mismatch leaves terrain angle unchanged"); - - PSXRecomp::CodeGenConfig yaw_config; - yaw_config.ws_cull_yaw_cone_sites.push_back( - {0x80010000u, 0x24710020u, 256u}); - const std::string yaw = generate_first_instruction( - 0x24710020u, {}, false, yaw_config); - check(yaw.find("cpu->gpr[17] = cpu->gpr[3] + (uint32_t)" - "psx_ws_yaw_cone_offset(32, 256u)") != - std::string::npos, - "codegen emits aspect-scaled wrapping-yaw offset"); - const std::string yaw_overlay_mismatch = generate_first_instruction( - 0x2471001Fu, {}, true, yaw_config); - check(yaw_overlay_mismatch.find("psx_ws_yaw_cone_offset") == - std::string::npos, - "overlay full-word mismatch leaves yaw cone unchanged"); - - PSXRecomp::CodeGenConfig horizontal_slt_config; - horizontal_slt_config.ws_cull_horizontal_slt_sites.push_back( - {0x80010000u, 0x0067102Au}); - const std::string horizontal_slt = generate_first_instruction( - 0x0067102Au, {}, false, horizontal_slt_config); - check(horizontal_slt.find( - "cpu->gpr[2] = psx_ws_horizontal_slt(cpu->gpr[3], " - "cpu->gpr[7])") != std::string::npos, - "codegen emits signed camera-horizontal side-plane helper"); - const std::string horizontal_slt_overlay_mismatch = - generate_first_instruction( - 0x0067102Bu, {}, true, horizontal_slt_config); - check(horizontal_slt_overlay_mismatch.find("psx_ws_horizontal_slt") == - std::string::npos, - "overlay full-word mismatch leaves horizontal side plane unchanged"); - - PSXRecomp::CodeGenConfig aspect_config; - aspect_config.ws_aspect_cone.sites.push_back( - {0x80010000u, 0x28620370u}); // slti v0,v1,0x370 reject predicate - aspect_config.ws_aspect_cone.object_reg = 19u; - aspect_config.ws_aspect_cone.x_reg = 16u; - aspect_config.ws_aspect_cone.z_reg = 17u; - aspect_config.ws_aspect_cone.y_reg = 18u; - const std::string aspect = generate_first_instruction( - 0x28620370u, {}, false, aspect_config); - check(aspect.find( - "psx_ws_aspect_cone_result(0x80010000u") != - std::string::npos && - aspect.find("cpu->gpr[19]") != std::string::npos && - aspect.find("(int32_t)(int16_t)cpu->gpr[16]") != - std::string::npos && - aspect.find("(int32_t)(int16_t)cpu->gpr[17]") != - std::string::npos && - aspect.find("(int32_t)(int16_t)cpu->gpr[18]") != - std::string::npos, - "codegen emits full-word-guarded aspect-cone helper"); - - const std::string aspect_overlay_mismatch = - generate_first_instruction(0x28620358u, {}, true, aspect_config); - check(aspect_overlay_mismatch.find("psx_ws_aspect_cone_result") == - std::string::npos, - "overlay full-word mismatch leaves aspect-cone site unchanged"); - - PSXRecomp::CodeGenConfig part_config; - PSXRecompV4::WidescreenAspectConeSite part_site; - part_site.address = 0x80010000u; - part_site.expected = 0x0082202Au; // slt a0,a0,v0 - part_site.cosine_threshold = 856u; - part_site.object_reg = 20u; - part_site.x_reg = 19u; - part_site.z_reg = 18u; - part_site.y_reg = 17u; - part_site.queue_guard = false; - part_config.ws_aspect_cone.sites.push_back(part_site); - const std::string part = generate_first_instruction( - 0x0082202Au, {}, false, part_config); - check(part.find("cpu->gpr[4] = psx_ws_aspect_cone_result") != - std::string::npos && - part.find("cpu->gpr[20]") != std::string::npos && - part.find("(int32_t)(int16_t)cpu->gpr[19]") != - std::string::npos && - part.find("(int32_t)(int16_t)cpu->gpr[18]") != - std::string::npos && - part.find("(int32_t)(int16_t)cpu->gpr[17]") != - std::string::npos, - "codegen supports exact per-child SLT cone metadata"); -} - -void gte_codegen_classification_tests() { - bool all_ok = true; - for (uint8_t reg = 0; reg < 32; ++reg) { - const auto expect = [&](uint32_t word, const std::string& call, - bool needs_helper, const char *kind) { - const std::string code = generate_first_instruction(word, {}, false); - const bool has_helper = code.find(call) != std::string::npos; - if (has_helper != needs_helper) { - fmt::print(stderr, - "FAIL full-game GTE {} reg {} helper={} expected={}\n", - kind, reg, has_helper, needs_helper); - all_ok = false; - } - }; - - const uint32_t cop2 = 0x12u << 26; - expect(cop2 | (0x00u << 21) | (2u << 16) | (uint32_t(reg) << 11), - fmt::format("gte_read_data(cpu, {})", reg), - PSXRecompGTERegisters::data_read_needs_helper(reg), "MFC2"); - expect(cop2 | (0x02u << 21) | (2u << 16) | (uint32_t(reg) << 11), - fmt::format("gte_read_ctrl(cpu, {})", reg), - PSXRecompGTERegisters::ctrl_read_needs_helper(reg), "CFC2"); - expect(cop2 | (0x04u << 21) | (2u << 16) | (uint32_t(reg) << 11), - fmt::format("gte_write_data(cpu, {}", reg), - PSXRecompGTERegisters::data_write_needs_helper(reg), "MTC2"); - expect(cop2 | (0x06u << 21) | (2u << 16) | (uint32_t(reg) << 11), - fmt::format("gte_write_ctrl(cpu, {}", reg), - PSXRecompGTERegisters::ctrl_write_needs_helper(reg), "CTC2"); - expect((0x32u << 26) | (3u << 21) | (uint32_t(reg) << 16), - fmt::format("gte_write_data(cpu, {}", reg), - PSXRecompGTERegisters::data_write_needs_helper(reg), "LWC2"); - expect((0x3Au << 26) | (3u << 21) | (uint32_t(reg) << 16), - fmt::format("gte_read_data(cpu, {})", reg), - PSXRecompGTERegisters::data_read_needs_helper(reg), "SWC2"); - } - check(all_ok, "full-game GTE helper classification covers all registers"); -} - -void jump_table_producer_codegen_test() { - constexpr uint32_t base = 0x80010000u; - constexpr uint32_t entry = base + 0x500u; - PSXRecomp::PS1Executable exe{}; - exe.header.load_address = base; - exe.header.initial_pc = entry; - exe.header.file_size = 0x1000u; - exe.code_data.resize(0x1000u, 0u); - - write_word(exe, base + 0x500u, 0x3C088001u); // lui t0,0x8001 - write_word(exe, base + 0x504u, 0x25100A00u); // addiu s0,t0,0x0a00 - write_word(exe, base + 0x50Cu, 0x2C620003u); // sltiu v0,v1,3 - write_word(exe, base + 0x510u, 0x1040001Bu); // beq v0,zero,+0x580 - write_word(exe, base + 0x518u, 0x00031080u); // sll v0,v1,2 - write_word(exe, base + 0x51Cu, 0x00501021u); // addu v0,v0,s0 - write_word(exe, base + 0x520u, 0x8C420000u); // lw v0,0(v0) - write_word(exe, base + 0x528u, 0x00400008u); // jr v0 - const uint32_t cases[] = { - base + 0x540u, base + 0x550u, base + 0x560u}; - for (size_t i = 0; i < 3u; i++) { - write_word(exe, cases[i], 0x24020001u + static_cast(i)); - write_word(exe, cases[i] + 4u, 0x03E00008u); - write_word(exe, base + 0xA00u + static_cast(i * 4u), - cases[i]); - } - write_word(exe, base + 0x580u, 0x03E00008u); - write_word(exe, base + 0x590u, - 0x08000000u | ((entry >> 2) & 0x03FFFFFFu)); - - PSXRecomp::Function function{}; - function.start_addr = entry; - function.end_addr = base + 0x588u; - function.size = function.end_addr - function.start_addr; - function.name = "producer_switch"; - function.producer_lo = base + 0x400u; - function.producer_hi = base + 0x900u; // table belongs to adjacent producer - - PSXRecomp::ControlFlowAnalyzer analyzer(exe); - const auto bounded_cfg = analyzer.analyze_function(function); - PSXRecomp::CodeGenerator bounded_generator(exe); - const std::string bounded = - bounded_generator.generate_function(function, bounded_cfg).full_code; - check(bounded.find("/* jump table") == std::string::npos, - "codegen cannot resurrect an adjacent-producer jump table"); - - function.producer_lo = 0u; - function.producer_hi = 0u; - const auto single_image_cfg = analyzer.analyze_function(function); - PSXRecomp::CodeGenerator single_image_generator(exe); - const std::string single_image = single_image_generator - .generate_function(function, single_image_cfg).full_code; - check(single_image.find("/* jump table") != std::string::npos, - "codegen still emits the same table for a single owned image"); - - // Exercise the actual overlapping-alias emitter path. The alias begins at - // a later block which jumps back through the host's complete table setup, - // so an unbounded alias really would expose the adjacent producer bytes. - // Producer ownership must survive Function -> alias CFG -> shared body. - PSXRecomp::Function alias = function; - alias.start_addr = base + 0x590u; - alias.end_addr = base + 0x598u; - alias.size = alias.end_addr - alias.start_addr; - alias.name = "producer_switch_alias"; - alias.alias_walk_lo = entry; - alias.alias_group_entries = {alias.start_addr}; - alias.producer_lo = base + 0x400u; - alias.producer_hi = base + 0x900u; - - const auto alias_cfg = analyzer.analyze_function(alias); - check(alias_cfg.producer_lo == alias.producer_lo && - alias_cfg.producer_hi == alias.producer_hi, - "alias CFG retains its host producer bounds"); - PSXRecomp::CodeGenerator alias_generator(exe); - const auto alias_generated = alias_generator.generate_alias_group( - {&alias}, alias_cfg, ""); - check(!alias_generated.empty() && - alias_generated.front().full_code.find("/* jump table") == - std::string::npos, - "alias emitter cannot expose an adjacent producer jump table"); - - alias.producer_lo = 0u; - alias.producer_hi = 0u; - const auto unbounded_alias_cfg = analyzer.analyze_function(alias); - PSXRecomp::CodeGenerator unbounded_alias_generator(exe); - const auto unbounded_alias_generated = - unbounded_alias_generator.generate_alias_group( - {&alias}, unbounded_alias_cfg, ""); - check(!unbounded_alias_generated.empty() && - unbounded_alias_generated.front().full_code.find("/* jump table") != - std::string::npos, - "alias regression fixture reaches the table when ownership is absent"); -} - -} // namespace - -int main() { - const fs::path root = fs::temp_directory_path() / - fmt::format("psxrecomp-patch-test-{}", reinterpret_cast(&failures)); - fs::remove_all(root); - fs::create_directories(root); - - try { - parser_tests(root); - capture_history_config_tests(root); - codegen_tests(); - gte_codegen_classification_tests(); - jump_table_producer_codegen_test(); - } catch (const std::exception& e) { - fmt::print(stderr, "FAIL unexpected exception: {}\n", e.what()); - ++failures; - } - - std::error_code ec; - fs::remove_all(root, ec); - fmt::print("\nGuarded recompiler patches: {}\n", - failures == 0 ? "all tests passed" : "failures detected"); - return failures == 0 ? 0 : 1; -} diff --git a/psxrecomp-overlay/recompiler/tests/test_reachable_discovery_codegen.py b/psxrecomp-overlay/recompiler/tests/test_reachable_discovery_codegen.py deleted file mode 100644 index 26fd31f..0000000 --- a/psxrecomp-overlay/recompiler/tests/test_reachable_discovery_codegen.py +++ /dev/null @@ -1,205 +0,0 @@ -#!/usr/bin/env python3 -"""End-to-end config and codegen checks for reachable main-EXE discovery. - -Usage: python test_reachable_discovery_codegen.py \ - [--recompiler ] -""" -import argparse -import os -import pathlib -import struct -import subprocess -import sys -import tempfile - -LOAD = 0x80010000 - - -def jal(target): - return 0x0C000000 | ((target >> 2) & 0x03FFFFFF) - - -def put32(data, offset, value): - struct.pack_into("=50%-untranslatable body to a fail-closed data stub. - for i, word in enumerate(( - 0x27BDFFF0, - 0x00000001, 0x00000001, 0x00000001, 0x00000001, 0x00000001, - jal(LOAD + 0x100), 0, - 0x03E00008, 0x27BD0010)): - put32(text, 0x300 + i * 4, word) - return bytes(header + text) - - -def write_config(path, discovery="reachable", text_size="0x1000"): - discovery_line = (f'discovery = "{discovery}"\n' - if discovery is not None else "") - with open(path, "w", encoding="utf-8") as f: - f.write(f'''[game] -name = "Reachable discovery test" -exe = "test.exe" -load_address = "0x80010000" -entry_pc = "0x80010000" -text_size = "{text_size}" -stack_base = "0x801FFFF0" - -[recompiler] -seeds = "seeds.txt" -out_dir = "generated" -{discovery_line} -''') - - -def run(recompiler, config): - return subprocess.run( - [recompiler, "--config", config], capture_output=True, text=True) - - -def main(): - here = os.path.dirname(os.path.abspath(__file__)) - default_recompiler = os.path.normpath(os.path.join( - here, "..", "build", "psxrecomp-game.exe")) - parser = argparse.ArgumentParser() - parser.add_argument("--recompiler", default=default_recompiler) - args = parser.parse_args() - if not os.path.isfile(args.recompiler): - raise SystemExit(f"recompiler not found: {args.recompiler}") - - failures = [] - with tempfile.TemporaryDirectory() as tmp: - # Keep find_project_root() anchored to this fixture. Some CI/sandbox - # hosts place a repository marker in the shared temp parent, which - # would otherwise make relative test.exe/seeds.txt paths resolve one - # directory too high and turn this into an environment-dependent test. - pathlib.Path(tmp, ".gitignore").write_text("\n", encoding="utf-8") - exe = os.path.join(tmp, "test.exe") - seeds = os.path.join(tmp, "seeds.txt") - config = os.path.join(tmp, "game.toml") - with open(exe, "wb") as f: - f.write(make_psxexe()) - with open(seeds, "w", encoding="utf-8") as f: - # 0x80010300 is the deliberately untranslatable fixture above. It - # is retained as a fail-closed psx_unknown_dispatch stub for - # diagnostics, but must not be advertised as a callable native - # entry: runtime overlays can replace the same address and - # coincidentally share a short instruction such as NOP. - f.write("0x80010000\n0x80010300\n") - - write_config(config) - result = run(args.recompiler, config) - output = result.stdout + result.stderr - if result.returncode != 0: - failures.append(f"reachable recompile failed:\n{output}") - else: - full_paths = sorted(path for path in - pathlib.Path(tmp, "generated").glob("test.exe_full*.c") - if "_dispatch" not in path.name) - dispatch_path = os.path.join( - tmp, "generated", "test.exe_dispatch.c") - if not full_paths: - failures.append("reachable recompile emitted no _full*.c shards") - full = "" - else: - full = "\n".join(path.read_text(encoding="utf-8") - for path in full_paths) - with open(dispatch_path, encoding="utf-8") as f: - dispatch = f.read() - - if "void func_80010000" not in full: - failures.append("main EXE entry was not emitted") - if "void func_80010100" not in full: - failures.append("reachable direct-JAL target was not emitted") - if "void func_80010200" in full: - failures.append("unseen indirect callback was emitted") - if "void func_80011100" in full: - failures.append("target beyond analysis bound was emitted") - if "phys < 0x00011000u" not in dispatch: - failures.append("dispatch text range did not use verified bound") - if "dirty_ram_text_native_ok_ranges_from(" not in dispatch: - failures.append("exact static-range dispatch guard was lost") - if "uint32_t count, uint32_t exec_pc" not in dispatch: - failures.append("dispatch guard does not accept a continuation PC") - if dispatch.count("entry->range_count, addr)") != 2: - failures.append("dispatch guards do not validate from their resume PC") - if "psx_native_bad_entry(" in full: - failures.append("reachable main image incorrectly used overlay codegen") - if "=== Exact-Entry Function Analysis ===" not in output: - failures.append("reachable config did not select exact-entry analysis") - - # Omitting the key must retain the established whole-image default. - write_config(config, discovery=None, text_size="0x2000") - default_mode = run(args.recompiler, config) - default_output = default_mode.stdout + default_mode.stderr - if default_mode.returncode != 0 or "=== Function Boundary Detection ===" not in default_output: - failures.append("omitted discovery key did not preserve whole-image default") - else: - default_full_paths = sorted(path for path in - pathlib.Path(tmp, "generated").glob("test.exe_full*.c") - if "_dispatch" not in path.name) - default_full = "\n".join(path.read_text(encoding="utf-8") - for path in default_full_paths) - default_dispatch = pathlib.Path( - tmp, "generated", "test.exe_dispatch.c").read_text( - encoding="utf-8") - if "void func_80010300" not in default_full: - failures.append("forced data diagnostic stub was not emitted") - elif ("void func_80010300(CPUState* cpu)\n{\n" - " psx_unknown_dispatch(cpu, 0x80010300u, " - "0x00010300u);") not in default_full: - failures.append("forced data entry was not fail-closed") - if "{0x80010300u," in default_dispatch: - failures.append("fail-closed data stub leaked into game dispatch") - if "{0x80010320u," in default_dispatch: - failures.append("fail-closed stub continuation leaked into game dispatch") - - write_config(config, discovery="invent-functions") - invalid_mode = run(args.recompiler, config) - if invalid_mode.returncode == 0 or "must be 'whole-image' or 'reachable'" not in ( - invalid_mode.stdout + invalid_mode.stderr): - failures.append("invalid discovery mode did not fail closed") - - write_config(config, text_size="0x1800") - invalid_bound = run(args.recompiler, config) - if invalid_bound.returncode == 0 or "must be 4 KiB page-aligned" not in ( - invalid_bound.stdout + invalid_bound.stderr): - failures.append("invalid analysis bound did not fail closed") - - write_config(config, text_size="0x0") - zero_bound = run(args.recompiler, config) - if zero_bound.returncode == 0 or "game.text_size must be nonzero" not in ( - zero_bound.stdout + zero_bound.stderr): - failures.append("zero analysis bound did not fail closed") - - if failures: - for failure in failures: - print("FAIL:", failure) - return 1 - print("PASS: reachable parser, analysis, and exact static codegen behavior") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/psxrecomp-overlay/runtime/CMakeLists.txt b/psxrecomp-overlay/runtime/CMakeLists.txt deleted file mode 100644 index efeecd2..0000000 --- a/psxrecomp-overlay/runtime/CMakeLists.txt +++ /dev/null @@ -1,270 +0,0 @@ -cmake_minimum_required(VERSION 3.20) -project(psxrecomp-runtime C CXX) -include(CTest) - -set(CMAKE_C_STANDARD 99) -set(CMAKE_CXX_STANDARD 17) - -# ---- Native/oracle runtime targets ---- - -set(PSXRECOMP_ROOT "${CMAKE_SOURCE_DIR}/..") -include("${CMAKE_SOURCE_DIR}/runtime.cmake") - -psxrecomp_add_runtime_target(psx-runtime - DEBUG_PORT 4370 - WINDOW_TITLE "psxrecomp - BIOS" - DEFAULT_BIOS_PATH "${PSXRECOMP_ROOT}/bios/SCPH1001.BIN" - EXE_NAME "PSXRecomp" -) -if(WIN32) - target_compile_definitions( - psx-runtime PRIVATE "$<$:NOMINMAX>") -endif() -if(MSVC) - # audio_trace.c is the sole runtime source using C11 atomics. Keep the - # generated C in MSVC's extension-compatible C mode: its static tables use - # address constants that strict /std:c11 rejects. - set_property(SOURCE "${PSXRECOMP_ROOT}/runtime/src/audio_trace.c" - APPEND PROPERTY COMPILE_OPTIONS - "/std:c11" "/experimental:c11atomics") -endif() - -if(BUILD_TESTING) - add_executable(launcher_device_roundtrip_test - tests/test_launcher_device_roundtrip.cpp) - target_include_directories(launcher_device_roundtrip_test PRIVATE include) - add_test(NAME launcher_device_roundtrip_test - COMMAND launcher_device_roundtrip_test) - - add_executable(psx_cyc_batch_test tests/test_psx_cyc_batch.c) - target_include_directories(psx_cyc_batch_test PRIVATE include) - target_compile_definitions(psx_cyc_batch_test PRIVATE - PSX_ENABLE_BLOCK_CYCLES=1) - add_test(NAME psx_cyc_batch_test COMMAND psx_cyc_batch_test) - - add_executable(psx_icache_fastpath_test - tests/test_psx_icache_fastpath.c - src/psx_icache.c - ) - target_include_directories(psx_icache_fastpath_test PRIVATE include) - # PSX_OVERLAY_DLL_BUILD is the seam psx_cycles.h already provides for a TU - # that supplies its own psx_advance_cycles (overlay DLLs forward it through - # a callback shim). Without it the header inlines the production definition - # and this test's counting stub is a redefinition — the whole runtime test - # suite stopped building at "redefinition of 'psx_advance_cycles'", which - # also blocked every test registered after it. - target_compile_definitions(psx_icache_fastpath_test PRIVATE - PSX_ENABLE_BLOCK_CYCLES=1 - PSX_OVERLAY_DLL_BUILD=1) - add_test(NAME psx_icache_fastpath_test COMMAND psx_icache_fastpath_test) - - add_executable(gte_register_access_test - tests/test_gte_register_access.cpp - src/gte.cpp - ) - target_include_directories(gte_register_access_test PRIVATE include) - target_compile_definitions(gte_register_access_test PRIVATE - PSX_NO_DEBUG_TOOLS=1 - PSX_GTE_REGISTER_TEST=1 - PSX_ENABLE_BLOCK_CYCLES=1) - add_test(NAME gte_register_access_test COMMAND gte_register_access_test) - - add_executable(psx_instr_cost_test tests/test_psx_instr_cost.cpp) - target_include_directories(psx_instr_cost_test PRIVATE include) - add_test(NAME psx_instr_cost_test COMMAND psx_instr_cost_test) - - add_executable(ws_aspect_cone_math_test - tests/test_ws_aspect_cone_math.c - src/ws_aspect_cone_math.c) - target_include_directories(ws_aspect_cone_math_test PRIVATE include) - if(NOT MSVC) - target_link_libraries(ws_aspect_cone_math_test PRIVATE m) - endif() - add_test(NAME ws_aspect_cone_math_test COMMAND ws_aspect_cone_math_test) - - add_executable(ws_ui_group_test - tests/test_ws_ui_group.c - src/ws_ui_group.c) - target_include_directories(ws_ui_group_test PRIVATE include) - add_test(NAME ws_ui_group_test COMMAND ws_ui_group_test) - - add_executable(mod_gpu_dma_aperture_test - tests/test_mod_gpu_dma_aperture.c) - target_include_directories(mod_gpu_dma_aperture_test PRIVATE include) - add_test(NAME mod_gpu_dma_aperture_test - COMMAND mod_gpu_dma_aperture_test) - - add_executable(overlay_capture_retry_test - tests/test_overlay_capture_retry.cpp - src/overlay_capture.c) - target_include_directories(overlay_capture_retry_test PRIVATE - include ${PSX_SDL_INCLUDE_DIRS}) - target_compile_definitions(overlay_capture_retry_test PRIVATE - SDL_MAIN_HANDLED PSX_OVERLAY_CAPTURE_TEST=1 - $<$:PSX_SDL3=1>) - target_link_libraries(overlay_capture_retry_test PRIVATE ${PSX_SDL_LIBRARIES}) - add_test(NAME overlay_capture_retry_test COMMAND overlay_capture_retry_test) - - add_executable(overlay_path_canon_test - tests/test_overlay_path_canon.c - src/overlay_path_canon.c) - target_include_directories(overlay_path_canon_test PRIVATE include) - add_test(NAME overlay_path_canon_test COMMAND overlay_path_canon_test) - - # The player-facing "skip the BIOS" flag must resolve to the same boot - # behaviour on retail SCPH-1001 and on the bundled OpenBIOS, whose - # kernel-call HLE is (correctly) refused for want of a DeliverEvent anchor. - add_executable(bios_hle_plan_test - tests/test_bios_hle_plan.c - src/bios_hle_plan.c) - target_include_directories(bios_hle_plan_test PRIVATE include) - add_test(NAME bios_hle_plan_test COMMAND bios_hle_plan_test) - - # Either member of a disc dump (.cue or .bin) is a valid pick: both mount - # the same disc and identify off the same data track. - add_executable(disc_path_resolve_test - tests/test_disc_path_resolve.cpp - src/disc_path.cpp - src/cue_sheet.cpp - src/iso_reader.cpp) - target_include_directories(disc_path_resolve_test PRIVATE include) - target_link_libraries(disc_path_resolve_test PRIVATE chdr-static) - add_test(NAME disc_path_resolve_test COMMAND disc_path_resolve_test) - - # Both BIOS filename conventions (bare vs region-qualified) resolve, in - # both directions, so a config and a dump folder may disagree. - add_executable(bios_rom_alias_test tests/test_bios_rom_alias.cpp) - target_include_directories(bios_rom_alias_test PRIVATE - ${PSXRECOMP_ROOT}/recompiler/include) - add_test(NAME bios_rom_alias_test COMMAND bios_rom_alias_test) - - # Multi-file cue TOC mapping (data track + CD-DA in separate bins). - add_executable(iso_reader_cdda_test - tests/test_iso_reader_cdda.cpp - src/iso_reader.cpp - src/cue_sheet.cpp) - target_include_directories(iso_reader_cdda_test PRIVATE include) - target_link_libraries(iso_reader_cdda_test PRIVATE chdr-static) - add_test(NAME iso_reader_cdda_test COMMAND iso_reader_cdda_test) - - add_executable(mod_packages_test - tests/test_mod_packages.cpp - src/mod_packages.cpp - src/crc32.c - src/psx_sha256.c) - target_include_directories(mod_packages_test PRIVATE - include - ../recompiler/lib/toml11) - add_test(NAME mod_packages_test COMMAND mod_packages_test) - - add_executable(mod_runtime_test - tests/test_mod_runtime.cpp - src/mod_runtime.cpp - src/mod_packages.cpp - src/disc_path.cpp - src/cue_sheet.cpp - src/iso_reader.cpp - src/crc32.c - src/psx_sha256.c) - target_include_directories(mod_runtime_test PRIVATE - include - ../recompiler/lib/toml11) - target_link_libraries(mod_runtime_test PRIVATE chdr-static) - add_test(NAME mod_runtime_test COMMAND mod_runtime_test) - - if(WIN32) - add_executable(autocompile_publication_test - tests/test_autocompile_publication.c - src/autocompile.c) - target_include_directories(autocompile_publication_test PRIVATE include) - target_compile_definitions(autocompile_publication_test PRIVATE - PSX_AUTOCOMPILE_TEST=1) - add_test(NAME autocompile_publication_test - COMMAND autocompile_publication_test) - endif() -endif() - -if(NOT MSVC) - psxrecomp_add_runtime_target(psx-oracle - ORACLE - DEBUG_PORT 4371 - WINDOW_TITLE "psxrecomp - Oracle" - DEFAULT_BIOS_PATH "${PSXRECOMP_ROOT}/bios/SCPH1001.BIN" - ) -endif() - -# ---- psx-beetle target (Beetle PSX libretro standalone, MinGW) ---- -# -# Standalone binary that runs Beetle PSX (mednafen-psx libretro core) with -# a debug server exposing the SAME wire-protocol commands as psx-runtime, -# on a different port (4380). No recomp code linked. -# -# Requires beetle-psx built via: -# cd beetle-psx && make platform=mingw_x86_64 STATIC_LINKING=1 HAVE_LIGHTREC=0 - -set(BEETLE_LIB "${CMAKE_SOURCE_DIR}/../beetle-psx/libmednafen_psx.a") - -if(NOT MSVC AND EXISTS "${BEETLE_LIB}") - message(STATUS "psx-beetle: found ${BEETLE_LIB}") - - set(BEETLE_DIR "${CMAKE_SOURCE_DIR}/../beetle-psx") - set(LRC "${BEETLE_DIR}/libretro-common") - set(LIBRETRO_COMMON_SOURCES - ${LRC}/streams/file_stream.c - ${LRC}/streams/file_stream_transforms.c - ${LRC}/vfs/vfs_implementation.c - ${LRC}/file/file_path.c - ${LRC}/compat/compat_strl.c - ${LRC}/compat/fopen_utf8.c - ${LRC}/compat/compat_posix_string.c - ${LRC}/string/stdstring.c - ${LRC}/lists/string_list.c - ${LRC}/encodings/encoding_utf.c - ${LRC}/time/rtime.c - ${LRC}/rthreads/rthreads.c - ${LRC}/memmap/memalign.c - ${LRC}/hash/rhash.c - ) - - add_executable(psx-beetle - src/beetle_main.cpp - src/beetle_libretro.cpp - src/beetle_debug_server.c - src/frame_pacing.c - src/parity_trace.c - src/device_trace.c - src/audio_trace.c - ${LIBRETRO_COMMON_SOURCES} - ) - - target_include_directories(psx-beetle PRIVATE - include - ${PSX_SDL_INCLUDE_DIRS} - ${CMAKE_SOURCE_DIR}/../beetle-psx/libretro-common/include - ${CMAKE_SOURCE_DIR}/../beetle-psx - ) - - target_compile_definitions(psx-beetle PRIVATE - PSX_BEETLE_BUILD=1 - DEFAULT_DEBUG_PORT=4380 - $<$:PSX_SDL3=1> - ) - if(NOT PSX_DEBUG_TOOLS) - target_compile_definitions(psx-beetle PRIVATE PSX_NO_DEBUG_TOOLS=1) - endif() - - target_link_libraries(psx-beetle PRIVATE - "${BEETLE_LIB}" - ${PSX_SDL_LIBRARIES} - ) - - if(WIN32 OR MINGW) - target_link_libraries(psx-beetle PRIVATE ws2_32 -lpthread) - endif() - - if(MINGW) - target_link_options(psx-beetle PRIVATE -Wl,--stack,67108864) - endif() -elseif(NOT MSVC) - message(STATUS "psx-beetle: libmednafen_psx.a not found - skipping psx-beetle") -endif() diff --git a/psxrecomp-overlay/runtime/include/dirty_ram_interp.h b/psxrecomp-overlay/runtime/include/dirty_ram_interp.h deleted file mode 100644 index 7f9b87c..0000000 --- a/psxrecomp-overlay/runtime/include/dirty_ram_interp.h +++ /dev/null @@ -1,348 +0,0 @@ -/* dirty_ram_interp.h — interpret-on-dispatch for install-at-runtime RAM. - * - * See CLAUDE.md Rule 18 and docs/dynamic_handler_install.md for the full - * rationale. The PS1 BIOS dynamically writes 4-instruction dispatch stubs - * into kernel RAM (notably RAM 0xCF0 for the SIO data-byte handler). A - * static recompiler can't see those bytes at compile time, so a small MIPS - * interpreter here runs them at dispatch time on the same CPUState. - * - * Scope: this is NOT a fallback for code the recompiler failed to translate. - * It runs only against PCs in pages that have been written-to since boot. - * Static-recompiled code continues to handle ROM-resident code and game - * RAM. See docs/dynamic_handler_install.md for the inline note about a - * potential future migration to runtime JIT (Option B). - */ -#ifndef PSXRECOMP_DIRTY_RAM_INTERP_H -#define PSXRECOMP_DIRTY_RAM_INTERP_H - -#include -#include "cpu_state.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* Returns 1 if `addr` lies in a dirty kernel-RAM page and the interpreter - * ran a basic block at that PC. Returns 0 if `addr` is clean (caller must - * fall back to the static dispatch table). On return with 1, cpu->pc is - * either 0 (block ended on jr $ra style return) or the target of a tail - * jump that the dispatch trampoline should re-enter. - * - * `stop_addr` is the dispatch loop's return contract (psx_dispatch_call's - * return_addr; 0 for plain tail dispatches). The interpreter's overlay - * local-flow chaining MUST NOT run past it: when a transfer (or fall- - * through) reaches stop_addr, the interpreter exits with cpu->pc == - * stop_addr so the dispatch loop returns into the suspended native - * caller. Skipping this check lets the interp execute the native - * caller's tail itself; the suspended native host frame then resumes and - * double-executes that tail against a moved guest stack, restoring - * garbage callee-saved registers (the pig-throw blue-screen/MMIO-fatal - * corruption, 2026-06-10). */ -int dirty_ram_dispatch(CPUState* cpu, uint32_t addr, uint32_t stop_addr); - -/* Retire a deferred R3000A load-delay writeback (see dirty_ram_interp.c). The - * interpreter defers a load's destination-register write past the delay-slot - * instruction, as hardware does; call this anywhere control leaves the - * interpreter or an exception is delivered, so no register write is dropped. */ -void dirty_ram_ld_delay_flush(CPUState* cpu); - -/* Overlay-cache windows — the address ranges eligible for capture, offline - * recompilation, and per-entry-validated native execution (Rule 18 code that - * does not exist in any compile-time image): - * - * Kernel RAM [0x00000, 0x10000): the BIOS part-2 image relocated to RAM - * plus install-at-runtime stubs (e.g. the SIO data-byte stub at 0xCF0). - * Dirty-tracked per CPU store (dirty_ram_mark_kernel_write). - * Overlay region [OVERLAY_REGION_FLOOR, RAM_SIZE): game overlays loaded by - * CD DMA (dirty_ram_mark_executable_range). - * - * Main-EXE text [0x10000, OVERLAY_REGION_FLOOR): CLEAN pages are statically - * recompiled and never captured. But games that discard their boot/init code - * load gameplay overlays OVER this region (Tomba 2: hot gameplay code at - * 0x1DA88/0x24548/0x3116C, well below its 0x38800 floor). Post-baseline - * (dirty_ram_clear_image_baseline) a DIRTY page here is by definition live - * overlay code — the compiled image is stale for it — so dirty boot-text - * pages ARE a capture/candidate window (2026-07-06; before this they could - * only interpret, which dominated wall time at ~300ns/insn). - * - * The interpreter's LOCAL-FLOW gates (is_local_dirty_target / - * phys_is_overlay_flow_region) use DIRTY_RAM_KERNEL_WINDOW_END alone: only - * the kernel window stays per-block (it runs in exception context where the - * verified dispatch cadence is delicate). Native coverage for the kernel - * window comes from the overlay loader, not from interp chaining. */ -#define DIRTY_RAM_KERNEL_WINDOW_END 0x00010000u - -/* The overlay-region floor is the END of THIS game's statically-recompiled - * main-EXE text (phys). Everything at/above it is runtime-loaded overlay code, - * eligible for in-interpreter local-flow chaining and overlay-cache capture. - * - * It is a RUNTIME value (g_overlay_region_floor), NOT a compile-time constant, - * because it differs per game: Tomba 1's text ends at 0x98000 (0x10000+0x88000) - * but Tomba 2's boot EXE is only 0x28800 (text ends at 0x38800). A hardcoded - * 0x98000 misclassified Tomba 2's overlays (loaded at 0x85000+) as main-EXE - * text, forcing them onto the slow block-by-block dispatch path AND through the - * bail-prone non-local-call contract — the Whoopee-Camp splash freeze. main.cpp - * sets g_overlay_region_floor = (load_address + text_size) & 0x1FFFFFFF at game - * load; OVERLAY_REGION_FLOOR_DEFAULT applies for BIOS-only runs. */ -#define OVERLAY_REGION_FLOOR_DEFAULT 0x00098000u -extern uint32_t g_overlay_region_floor; -#define OVERLAY_REGION_FLOOR (g_overlay_region_floor) - -/* Test whether a given physical kernel-RAM address is in a page that was - * written-to since boot. Defined in memory.c. */ -int dirty_ram_is_dirty(uint32_t phys); - -/* Kernel-image bless (memory.c). A dispatch key in the relocated kernel - * window (RAM [0x500,0x8500), the BIOS's boot-time copy of ROM 0x1FC10000+) - * may run its statically-recompiled native function IFF the live RAM bytes - * of the code reachable from it (emitter-supplied body extent) byte-match - * the ROM source. Lazily verified, cached per entry, invalidated precisely - * on writes into the body. Runtime-patched bodies (pad/SIO install stubs) - * never verify and keep interpreting — faithful either way. */ -int psx_kernel_bless_dispatchable(uint32_t phys); -void psx_kernel_bless_note_range(uint32_t phys, uint32_t len); -void psx_kernel_bless_stats(uint64_t out[6]); -/* Detail for the most recent kernel-body validation failure. `count` is the - * cumulative number of failed body validations; out[] is entry, body_lo, - * body_hi, first_mismatch, live_byte, reference_byte. */ -void psx_kernel_bless_mismatch_detail(uint64_t *count, uint32_t out[6]); - -/* Opt-in, low-overhead interpreter hot-PC sampler used by - * PSX_RUNTIME_PERF_DIAG. It samples one retired instruction per `stride` and - * returns/resets the three hottest physical PCs since the previous take. */ -#define DIRTY_RAM_PERF_HOT_COUNT 3u -void dirty_ram_perf_hot_enable(int enabled); -void dirty_ram_perf_take_hot(uint32_t pcs[DIRTY_RAM_PERF_HOT_COUNT], - uint32_t samples[DIRTY_RAM_PERF_HOT_COUNT], - uint32_t *stride); - -/* Capture/candidate window membership. Kernel window and overlay region are - * unconditional; main-EXE text [KERNEL_WINDOW_END, FLOOR) is included only - * when the page is dirty — i.e. a gameplay overlay overwrote the boot text - * after the game-start baseline, so the bytes there are runtime overlay code - * (see the window model note above). Clean boot text never enters the window - * (it runs compiled; capturing it would violate the static-first design). */ -static inline int overlay_cache_window_contains(uint32_t phys) { - return phys < DIRTY_RAM_KERNEL_WINDOW_END - || phys >= OVERLAY_REGION_FLOOR - || dirty_ram_is_dirty(phys); -} -uint32_t dirty_ram_get_bitmap(void); -uint32_t dirty_ram_get_bitmap_word(uint32_t word_index); -uint32_t dirty_ram_get_bitmap_word_count(void); -void dirty_ram_mark_executable_range(uint32_t phys, uint32_t len); -void dirty_ram_register_text_image(uint32_t phys_lo, const uint8_t *bytes, - uint32_t len); -int dirty_ram_text_native_ok(uint32_t phys); -int dirty_ram_text_image_registered(void); -/* Bless an intentional runtime data patch (e.g. text_xlate string/glyph tables) - * into the text reference image so it is not mistaken for self-modifying code. */ -void dirty_ram_text_bless(uint32_t phys, const uint8_t *bytes, uint32_t len); -uint64_t dirty_ram_text_native_blocked(void); -uint32_t dirty_ram_text_diverged_pages(void); - -/* Counters for visibility / TCP debug. Increment in interpreter; expose - * via debug_server.c if helpful. */ -extern uint64_t g_dirty_ram_blocks_run; /* basic blocks interpreted */ -extern uint64_t g_dirty_ram_insns_run; /* instructions interpreted */ -extern uint64_t g_dirty_window_dispatches; /* interp dispatches inside a - * capture window (autocapture - * pressure signal, step 2.8) */ -extern uint64_t g_dirty_ram_aborts; /* unsupported-opcode aborts */ -extern uint64_t g_dirty_ram_guard_yields; /* long dirty loops yielded */ -extern uint64_t g_dirty_ram_unsupported_midblock; -extern uint32_t g_dirty_ram_last_unsupported_entry; -extern uint32_t g_dirty_ram_last_unsupported_entry_ra; -extern uint32_t g_dirty_ram_last_unsupported_entry_sp; -extern uint32_t g_dirty_ram_last_unsupported_insns; -extern uint32_t g_dirty_ram_last_unsupported_pc; -extern uint32_t g_dirty_ram_last_unsupported_insn; -extern const char *g_dirty_ram_last_unsupported_reason; - -/* Per-entry-PC counters. Aggregate counters above hide which install - * stubs actually fire — a single noisy spurious-dispatch site can mask - * a legitimate handler that never runs. This open-addressed table keys - * on the entry PC of each interpreted block. */ -/* 65536 -> 262144 (2026-07-03): Tomba2's Trolley attract demo interprets a - * working set past 64K distinct PCs; a full table degraded every lookup to a - * full-table scan (0.3 fps host burn in pc_table_get_or_insert_in) and - * silently truncated overlay-capture seed lists (MAX_CAPTURE_PCS aliases - * this). Probe length is bounded in pc_table_get_or_insert_in either way. */ -#define DIRTY_RAM_PC_TABLE_SIZE 262144 -typedef struct { - uint32_t pc; /* entry PC, 0 = empty slot */ - uint64_t hits; /* number of times dispatched here */ - uint64_t insns; /* total instructions executed across hits */ - uint64_t entry_hits; /* dispatches that arrived from NATIVE code (call / - * dispatch loop), not from the interpreter's own - * block-to-block chaining. `hits` conflates the two: - * below the local-flow floor every taken branch - * re-dispatches, so chain blocks dominate. entry_hits - * is the evidence stream for interior-alias seeds. */ -} DirtyRamPcEntry; -extern DirtyRamPcEntry g_dirty_ram_pc_table[DIRTY_RAM_PC_TABLE_SIZE]; -/* Every aligned main-RAM word is a possible instruction PC. Execution - * coverage only needs presence, not a hit count, so record it in a direct - * bitmap instead of probing a large hash table for every retired instruction. - * This covers all 524,288 RAM words (the old 262K-entry hash could saturate) - * and is also the execution-verified seed source used by overlay_capture. */ -#define DIRTY_RAM_EXEC_WORD_COUNT ((2u * 1024u * 1024u) / 4u) -#define DIRTY_RAM_EXEC_BITMAP_WORDS ((DIRTY_RAM_EXEC_WORD_COUNT + 31u) / 32u) -extern uint32_t g_dirty_ram_exec_pc_bitmap[DIRTY_RAM_EXEC_BITMAP_WORDS]; -/* One bit per 4 KiB page. RAM writes use this as a one-test stale-evidence - * guard; they clear that page's capture bits rather than serializing from the - * universal store hot path. */ -#define DIRTY_RAM_EXEC_PAGE_BITMAP_WORDS 16u -extern uint32_t g_dirty_ram_exec_page_bitmap[DIRTY_RAM_EXEC_PAGE_BITMAP_WORDS]; -/* Presence-only companion for interpreted block/dispatch entries. The richer - * counter table remains for telemetry, while capture can snapshot/reset this - * compact evidence independently at overlay-generation boundaries. */ -extern uint32_t g_dirty_ram_dispatch_pc_bitmap[DIRTY_RAM_EXEC_BITMAP_WORDS]; - -/* Block-entry ring buffer. Records every dispatch into dirty RAM with the - * caller's RA at entry, plus argument context — answers - * "who tried to JALR into this RAM stub, with what args". - * Always-on, eviction keeps memory bounded; callers query the window of - * interest (CLAUDE.md global rule on ring buffers). - * - * Limitation: this captures dispatches into RAM-resident code only. ROM - * recompiled-C → recompiled-C calls (direct C function calls in - * generated dispatch table) are NOT captured here. For BIOS shell - * investigation that's fine — shell code lives in RAM (0x800XXXXX). - * - * `ra` is cpu->gpr[31] at dispatch time. For normal JAL-style calls, - * (ra - 8) gives the caller's PC. For J/JR/tail-calls it's only a - * heuristic — treat ra as "ra_callsite_guess", not authoritative. */ -#ifdef PSX_NO_DEBUG_TOOLS -/* Keep ABI-visible one-element sentinels so crash/debug stubs still link, while - * Release does not reserve the diagnostic rings' ~227 MiB of address space. */ -#define DIRTY_RAM_BLOCK_LOG_CAP 1u -#else -#define DIRTY_RAM_BLOCK_LOG_CAP (1u << 22) /* 4M entries (~224 MiB at 56 B each). - * At ~580K dispatches/s during boot, this retains ~7s of history; at - * ~10K/s during modal idle, ~400s. Sized for retroactive press-window - * analysis without the prior 16K ring's 28-ms eviction problem. */ -#endif -typedef struct { - uint64_t seq; /* monotonic, unique per entry */ - uint32_t target; /* entry PC (RAM address) */ - uint32_t ra; /* cpu->gpr[31] at dispatch — caller's return target */ - uint32_t a0; /* cpu->gpr[4] at dispatch */ - uint32_t a1; /* cpu->gpr[5] at dispatch */ - uint32_t a2; /* cpu->gpr[6] at dispatch */ - uint32_t a3; /* cpu->gpr[7] at dispatch */ - uint32_t t0; /* cpu->gpr[8] at dispatch */ - uint32_t t1; /* cpu->gpr[9] at dispatch */ - uint32_t t2; /* cpu->gpr[10] at dispatch */ - uint32_t sp; /* cpu->gpr[29] at dispatch */ - uint32_t frame; /* s_frame_count at the time of dispatch */ -} DirtyRamBlockLogEntry; -extern DirtyRamBlockLogEntry g_dirty_ram_block_log[DIRTY_RAM_BLOCK_LOG_CAP]; -extern uint64_t g_dirty_ram_block_log_seq; - -#ifdef PSX_NO_DEBUG_TOOLS -#define DIRTY_RAM_FLOW_LOG_CAP 1u -#else -#define DIRTY_RAM_FLOW_LOG_CAP (1u << 16) -#endif -typedef struct { - uint64_t seq; - uint32_t pc; - uint32_t target; - uint32_t ra; - uint32_t a0; - uint32_t a1; - uint32_t a2; - uint32_t a3; - uint32_t sp; - uint32_t frame; -} DirtyRamFlowLogEntry; -extern DirtyRamFlowLogEntry g_dirty_ram_flow_log[DIRTY_RAM_FLOW_LOG_CAP]; -extern uint64_t g_dirty_ram_flow_log_seq; - -#ifdef PSX_NO_DEBUG_TOOLS -#define DIRTY_RAM_INSN_LOG_CAP 1u -#else -#define DIRTY_RAM_INSN_LOG_CAP (1u << 16) -#endif -typedef struct { - uint64_t seq; - uint32_t pc; - uint32_t insn; - uint32_t next_pc; - uint32_t target; - uint32_t before_s0; - uint32_t after_s0; - uint32_t sp; - uint32_t ra; - uint32_t v0; - uint32_t v1; - uint32_t a0; - uint32_t a1; - uint32_t a2; - uint32_t a3; - uint32_t t0; - uint32_t t1; - uint32_t t2; - /* Kernel scratch registers. $at is the assembler-temp that hand-written - * kernel asm uses to stash a base across a load-delay slot, and $k0/$k1 - * are the exception handlers' only free registers — i.e. exactly the state - * that matters when debugging BIOS exception/handler code, which is what - * this interpreter mostly runs. */ - uint32_t at; - uint32_t k0; - uint32_t k1; - uint32_t current_tcb; - uint32_t task_ptr; - uint32_t task_mode; - uint32_t task_submode; - uint32_t frame; - uint8_t transferred; - uint8_t pad[3]; -} DirtyRamInsnLogEntry; -extern DirtyRamInsnLogEntry g_dirty_ram_insn_log[DIRTY_RAM_INSN_LOG_CAP]; -extern uint64_t g_dirty_ram_insn_log_seq; - -/* PaRappa rhythm debug ring. Narrow, game-specific probe for the Stage 1 - * rhythm routine when it is running through the dirty interpreter. */ -#ifdef PSX_NO_DEBUG_TOOLS -#define PARAPPA_RHYTHM_EVENT_CAP 1u -#else -#define PARAPPA_RHYTHM_EVENT_CAP 4096u -#endif -typedef struct { - uint64_t seq; - uint32_t frame; - uint32_t pc; - uint32_t addr; - uint32_t value; - uint32_t width; - uint32_t obj; - uint32_t ra; - uint32_t a0; - uint32_t a1; - uint32_t a2; - uint32_t a3; - uint32_t s5; - uint32_t s6; - uint32_t g_800901bc; - uint32_t g_800901c0; - uint32_t g_800916d0; - uint32_t g_800916d8; - uint32_t g_800916da; - uint32_t g_800916dc; - uint32_t g_801d3040; -} ParappaRhythmEvent; -extern ParappaRhythmEvent g_parappa_rhythm_events[PARAPPA_RHYTHM_EVENT_CAP]; -extern uint64_t g_parappa_rhythm_event_seq; -void parappa_rhythm_events_reset(void); -void parappa_timing_window_reset(void); -extern int g_parappa_timing_mode; -extern int g_parappa_timing_extra_early; -extern int g_parappa_timing_extra_late; - -#ifdef __cplusplus -} -#endif - -#endif /* PSXRECOMP_DIRTY_RAM_INTERP_H */ diff --git a/psxrecomp-overlay/runtime/include/gpu.h b/psxrecomp-overlay/runtime/include/gpu.h deleted file mode 100644 index fad42ec..0000000 --- a/psxrecomp-overlay/runtime/include/gpu.h +++ /dev/null @@ -1,624 +0,0 @@ -/* gpu.h — PS1 GPU hardware simulation (Phase 3). - * - * Implements GPUSTAT, GP0, GP1, and 1024x512 16-bit VRAM. - * No rendering to screen — just correct hardware state transitions. - */ - -#ifndef PSXRECOMP_GPU_H -#define PSXRECOMP_GPU_H - -#include - -typedef struct CPUState CPUState; - -#ifdef __cplusplus -extern "C" { -#endif - -void gpu_init(void); -uint32_t gpu_read_gpustat(void); /* 0x1F801814 read */ -uint32_t gpu_read_gpuread(void); /* 0x1F801810 read */ -void gpu_write_gp0(uint32_t val); /* 0x1F801810 write */ -void gpu_write_gp1(uint32_t val); /* 0x1F801814 write */ -void gpu_set_gp0_source(uint32_t addr); /* diagnostic source for next GP0 word */ -/* Linked-list provenance for the next GP0 words. `word_count == 0` identifies - * an ordering-table link node; packet nodes retain the most recently visited - * OT rank. This lets diagnostics distinguish front-layer UI packets from - * depth-sorted world packets without guessing from primitive shape. */ -void gpu_set_gp0_linked_list_node(uint32_t addr, uint32_t word_count); -void gpu_vblank_tick(void); /* Toggle LCF, called at each simulated vblank */ - -/* Display presentation accessors (Phase 3). */ -const uint16_t* gpu_get_vram(void); /* Pointer to 1024x512 16-bit VRAM */ - -typedef struct { - uint32_t display_x, display_y; /* VRAM start of display area (GP1(05h)) */ - uint32_t width, height; /* Derived from display mode + ranges */ - int depth24; /* GP1(08h) display depth flag: RGB888 scanout */ - int disabled; /* GP1(03h) display disable flag */ -} GpuDisplayInfo; - -void gpu_get_display_info(GpuDisplayInfo* out); -/* GP1(08h) bit4 — 24-bit display. Renderers skip FBO upload queues while set: - * packed RGB888 lives in the CPU mirror; treating A0 rects as 1555 FBO uploads - * both wastes bandwidth and force-flushes when UP_RECTS_MAX is hit (MotK FMV). */ -int gpu_display_is_depth24(void); -void gpu_display_pixel_rgb(const GpuDisplayInfo* di, uint32_t x, uint32_t y, - uint8_t* r, uint8_t* g, uint8_t* b); -uint32_t gpu_display_pixel_argb(const GpuDisplayInfo* di, uint32_t x, uint32_t y); -/* Depth24: RGB columns covered by CPU→VRAM uploads since the last reset. - * Returns crtc_w when unknown / full coverage. Present uses this to blank a - * trailing margin without shrinking the CRTC-derived width globally. */ -uint32_t gpu_depth24_rgb_limit(uint32_t display_x, uint32_t crtc_w); -void gpu_depth24_upload_span_reset(void); -/* MotK intro cuts retarget GP1(07h). While hold > 0, present should skip - * Swap (keep prior frame) so stale trailing VRAM never flashes. One tick - * per vblank; returns non-zero while the hold is still active after tick. */ -int gpu_depth24_present_hold_tick(void); -/* GP1(06h)/GP1(07h)/GP1(08h) fields for debug (gpu_state). */ -void gpu_get_crtc_debug(uint32_t *x1, uint32_t *x2, uint32_t *y1, uint32_t *y2, - uint32_t *hres1_out, uint32_t *hres2_out); -uint64_t gpu_get_gp0_count(void); /* Total GP0 writes since init */ -void gpu_get_gp0_stats(uint64_t* nop, uint64_t* fill, uint64_t* draw, uint64_t* env, uint64_t* copy); - -typedef struct { - uint32_t left, top, right, bottom; - int32_t offset_x, offset_y; -} GpuDrawArea; -void gpu_get_draw_area(GpuDrawArea* out); -uint16_t gpu_vram_peek(int x, int y); - -/* Shaded quad vertex capture (Phase 4.5 debug). */ -typedef struct { - int32_t vx[4], vy[4]; - uint32_t color[4]; -} GpuSqCapEntry; -void gpu_arm_shaded_quad_capture(void); -int gpu_get_shaded_quad_capture(const GpuSqCapEntry** out); - -/* Per-frame GP0 command ring (always-on; query via debug server). - * Each entry records the GP0 command header + up to 6 payload words - * (longer commands like 0x3C shaded textured quad are truncated to 6). - * Stamped with the s_frame_count value at issue time so a debug - * client can pull all commands for any frame in the ring window. */ -#define GPU_GP0_RING_MAX_WORDS 12 -typedef struct { - uint32_t frame; - uint32_t seq; - uint32_t src_addr; /* RAM/MMIO source address of command header, if known */ - uint32_t pc; /* g_debug_last_store_pc when command completes */ - uint32_t func; /* g_debug_current_func_addr at issue (executing guest fn) */ - uint32_t ra; /* guest $ra at issue: direct caller of the leaf GP0 helper */ - uint8_t opcode; - uint8_t n_words; /* total command length; >MAX means truncated */ - uint16_t ot_rank; /* linked-list OT rank, 0xFFFF outside/unknown */ - uint32_t cmd[GPU_GP0_RING_MAX_WORDS]; - /* Builder attribution (populated only for VRAM->VRAM copies, op 0x80): - * bounded guest-stack unwind of validated return addresses, innermost - * first. Lets a caller skip the libgpu funnel band and name the game-level - * routine that assembled the copy. Zero for non-copy commands. */ - uint32_t bld[6]; - uint32_t csp; /* raw guest $sp at copy time (diagnostic) */ -} GpuGp0RingEntry; - -uint64_t gpu_gp0_ring_total(void); -uint32_t gpu_gp0_ring_capacity(void); -uint32_t gpu_gp0_ring_max_words(void); -int gpu_gp0_ring_dump_frame(uint32_t frame, GpuGp0RingEntry *out, int max_out); -void gpu_gp0_ring_frame_span(uint32_t *out_oldest, uint32_t *out_newest); - -/* Vblank presentation callback — called from gpu_vblank_tick(). */ -typedef void (*gpu_vblank_cb)(void); -void gpu_set_vblank_callback(gpu_vblank_cb cb); - -/* Present-time screen-colour model (see color_lut.h ScreenKind: 0=raw, - * 1=crt, 2=composite, 3=trinitron). Config/launcher-driven; the PSX_SCREEN - * env var, if set, overrides this. Default 0 (raw) is byte-identical. */ -void gpu_set_screen_kind(int kind); - -/* Presentation-only high-precision geometry. The guest-visible GP0/GTE path - * remains integer; supported renderers mirror gameplay polygons into a - * separate display surface using retained 16.16 projection coordinates. */ -void gpu_geometry_correction_set(int enabled); -int gpu_geometry_correction_enabled(void); -void gpu_geometry_camera_yaw_residual_set(double yaw_units); -/* Display-relative projection horizon shared with the optional presentation - * yaw homography. Values are rounded to an integer pixel; non-finite or - * out-of-range values reset the horizon to the 240-line default of 120. */ -void gpu_geometry_camera_projection_center_y_set(double center_y); -int gpu_geometry_camera_projection_center_y_get(void); -void gpu_geometry_correction_stats(uint64_t *world_triangles, - uint64_t *precise_triangles); - -/* Independent, default-off perspective texture interpolation for exact world - * triangles on the presentation mirror. Canonical VRAM, sprites, rectangles, - * UI, and any polygon without strict projective depth remain affine. */ -void gpu_texture_correction_set(int enabled); -int gpu_texture_correction_enabled(void); -uint32_t gpu_texture_correction_hits(void); - -/* Versioned Test-13 diagnostics. Candidate/accepted primitive arrays count - * polygons; triangle and vertex totals make quad coverage unambiguous. The - * latest_live counters are the most recent presentation which was eligible to - * consume the independent corrected surface, while cumulative includes every - * geometry-correction candidate since the feature was enabled. */ -#define GPU_GEOMETRY_DIAGNOSTICS_VERSION 1u -#define GPU_GEOMETRY_REJECT_REASON_COUNT 15u -#define GPU_GEOMETRY_PRIMITIVE_CLASS_COUNT 8u -#define GPU_GEOMETRY_OPCODE_COUNT 32u -#define GPU_GEOMETRY_DEPTH_BUCKET_COUNT 6u -#define GPU_GEOMETRY_SCREEN_BUCKET_COUNT 10u -#define GPU_GEOMETRY_CORRECTION_BUCKET_COUNT 5u - -typedef enum GpuGeometryRejectReason { - GPU_GEOMETRY_REJECT_NO_COMMAND_SOURCE = 0, - GPU_GEOMETRY_REJECT_SOURCE_ADDRESS_OVERFLOW, - GPU_GEOMETRY_REJECT_TRACKING_DISABLED, - GPU_GEOMETRY_REJECT_SPECULATIVE_LOOKUP, - GPU_GEOMETRY_REJECT_SOURCE_NOT_RAM, - GPU_GEOMETRY_REJECT_SOURCE_UNALIGNED, - GPU_GEOMETRY_REJECT_STORE_MISS, - GPU_GEOMETRY_REJECT_STORE_COLLISION, - GPU_GEOMETRY_REJECT_PROJECTION_INVALID, - GPU_GEOMETRY_REJECT_PACKED_MISMATCH, - GPU_GEOMETRY_REJECT_ZERO_DEPTH, - GPU_GEOMETRY_REJECT_SATURATED, - GPU_GEOMETRY_REJECT_INTEGER_MISMATCH, - GPU_GEOMETRY_REJECT_ATOMIC_FALLBACK, - GPU_GEOMETRY_REJECT_RECTANGLE_FAST_PATH -} GpuGeometryRejectReason; - -typedef enum GpuGeometryPrimitiveClass { - GPU_GEOMETRY_PRIMITIVE_FLAT_TRI = 0, - GPU_GEOMETRY_PRIMITIVE_TEXTURED_FLAT_TRI, - GPU_GEOMETRY_PRIMITIVE_GOURAUD_TRI, - GPU_GEOMETRY_PRIMITIVE_TEXTURED_GOURAUD_TRI, - GPU_GEOMETRY_PRIMITIVE_FLAT_QUAD, - GPU_GEOMETRY_PRIMITIVE_TEXTURED_FLAT_QUAD, - GPU_GEOMETRY_PRIMITIVE_GOURAUD_QUAD, - GPU_GEOMETRY_PRIMITIVE_TEXTURED_GOURAUD_QUAD -} GpuGeometryPrimitiveClass; - -typedef struct GpuGeometryCorrectionCounters { - uint64_t triangle_candidates; - uint64_t triangle_accepted; - uint64_t polygon_candidates; - uint64_t polygon_accepted; - uint64_t vertex_candidates; - uint64_t vertex_accepted; - uint64_t vertex_rejections[GPU_GEOMETRY_REJECT_REASON_COUNT]; - uint64_t primitive_candidates[GPU_GEOMETRY_PRIMITIVE_CLASS_COUNT]; - uint64_t primitive_accepted[GPU_GEOMETRY_PRIMITIVE_CLASS_COUNT]; - /* Polygon opcode 0x20..0x3f, indexed by opcode - 0x20. */ - uint64_t opcode_candidates[GPU_GEOMETRY_OPCODE_COUNT]; - uint64_t opcode_accepted[GPU_GEOMETRY_OPCODE_COUNT]; - /* Accepted vertices: z <256,<1024,<4096,<16384,<32768,>=32768. */ - uint64_t depth_vertices[GPU_GEOMETRY_DEPTH_BUCKET_COUNT]; - /* Accepted vertices: 3x3 on-screen cells followed by off-screen. */ - uint64_t screen_vertices[GPU_GEOMETRY_SCREEN_BUCKET_COUNT]; - uint64_t correction_vertices; - uint64_t correction_abs_x16_sum; - uint64_t correction_abs_y16_sum; - uint64_t correction_magnitude16_sum; - uint64_t correction_magnitude16_max; - uint64_t correction_x16_squared_sum; - uint64_t correction_y16_squared_sum; - /* Set to one if any correction sum has saturated at UINT64_MAX. */ - uint64_t correction_accumulators_saturated; - /* max(|dx|,|dy|): zero, <.25px, <.5px, <.75px, <1px. */ - uint64_t correction_magnitude_buckets[ - GPU_GEOMETRY_CORRECTION_BUCKET_COUNT]; - uint64_t partial_polygon_rejections; - uint64_t partial_quad_rejections; -} GpuGeometryCorrectionCounters; - -typedef struct GpuGeometryCorrectionStats { - uint32_t version; - uint32_t size; - uint64_t presentation_sequence; - uint64_t corrected_presentations; - uint64_t latest_live_presentation; - uint32_t latest_live_valid; - uint32_t reserved; - uint64_t provenance_store_collisions; - uint64_t provenance_store_evictions; - uint64_t provenance_lookup_collisions; - GpuGeometryCorrectionCounters cumulative; - GpuGeometryCorrectionCounters latest_live; - /* Additive tail field: older size-bounded callers retain the version-1 - * prefix while current diagnostics expose rejected uncommitted SWC2s. */ - uint64_t provenance_store_uncommitted_rejections; - /* Reviewed MFC2 -> ordinary-SW routes. Attempts count committed-site - * observations; accepts entered exact provenance, while packed rejections - * identify clipped/repacked output that deliberately stayed canonical. */ - uint64_t provenance_registered_store_attempts; - uint64_t provenance_registered_store_accepts; - uint64_t provenance_registered_store_packed_rejections; - /* Reviewed projected-vertex-buffer -> GP0 packet copy routes. */ - uint64_t provenance_copy_load_attempts; - uint64_t provenance_copy_load_accepts; - uint64_t provenance_copy_store_attempts; - uint64_t provenance_copy_store_accepts; - uint64_t provenance_copy_store_packed_rejections; -} GpuGeometryCorrectionStats; - -void gpu_geometry_correction_stats_detailed( - GpuGeometryCorrectionStats *out, uint32_t out_size); - -/* Widescreen proportion correction (active only when aspect != 4:3 and the - * game's [widescreen] block opts in — see config_loader.h). Tagged - * character/billboard prims are re-squashed around their projected anchor; - * untagged SPRT prims (screen-space HUD/menus) around the display centre. - * psx_ws_sprite_tag is the per-prim callback the recompiler emits at the - * entry of each [widescreen] sprite_tag_funcs function. */ -/* mode: 0 = off (4:3 identity), 1 = squash (legacy hack), 2 = native-wide - * (render the wider FOV into a wider frame, present 1:1; GTE not squashed). */ -void gpu_ws_configure(int aspect_num, int aspect_den, - uint32_t sprite_anchor_addr, int hud_sprt_squash, int mode); -/* Add an audited world primitive to the existing classic-squash provenance - * table. This is host presentation metadata only; it never edits guest RAM. - * Calls are ignored outside an active classic-wide gameplay presentation. */ -/* Camera depth of a CPU-projected POLY_FT4 sprite, for frame interpolation. */ -void gpu_temporal_note_sprite(CPUState *cpu, uint32_t primitive_addr, - int32_t depth); -/* The unrounded centre column of the sprite just noted and its distance from its row, 16.16. */ -void gpu_temporal_place_sprite(uint32_t primitive_addr, int32_t centre_x16, int32_t offset_y16); -/* The unrounded row the placed sprite is centred on and its unrounded sides, 16.16. A shadow lies on the floor. - * whole: the packet spans the size cut to whole pixels, not one less. */ -void gpu_temporal_size_sprite(uint32_t primitive_addr, int32_t row16, int32_t width16, int32_t height16, - int shadow, int whole); -/* Shadows as the game shapes them (0) or flattened the way the floor recedes (1). */ -void gpu_set_shadow_shape(int improved); -int gpu_shadow_shape(void); -int gpu_sprite_placement_available(void); -void gpu_ws_tag_primitive(CPUState *cpu, uint32_t primitive_addr, - int32_t anchor_x); -/* Mark a completed, audited SPRT packet as a backdrop tile laid out edge to - * edge in widened coordinates: classic squash maps it about the display centre. */ -void gpu_ws_tag_screen_tile(CPUState *cpu, uint32_t primitive_addr); -/* Widened counterpart of a frame X for such a tile span. */ -int32_t gpu_ws_widen_x(int32_t x, int round_up); -/* Size of the widescreen HUD in percent, 50 to 100. 100 is the authored size. */ -void gpu_ws_set_hud_scale(int percent); -int gpu_ws_hud_scale(void); -/* HUD widgets of a game that links its HUD to `layers` nodes just below its ordering table, whose address - * is the guest word at `table_pointer`: six numbers each, the box x0, y0, x1, y1 and the pivot x, y. Once - * such a layer has shown a widget, later rectangles inside its box belong to it too, until the game sets - * its next drawing area. The game has to set one every frame. */ -void gpu_ws_set_hud_widgets(uint32_t table_pointer, int layers, const int32_t *boxes, int count); -/* A game whose text printer draws a line as one picture `wide` across, the words from its left end. */ -void gpu_ws_set_hud_text_strip(int wide); -/* [widescreen] full_2d: opt a pure-2D sprite game into the widescreen present - * path (treat every in-game frame as gameplay, since it never tags 3D prims). */ -void gpu_ws_set_full_2d(int on); -void gpu_ws_set_auto_ui_squash(int on); -/* [widescreen.bg2d] Capcom 2D background tile-loop widen — hooked at the renderer's - * column-count / start-tile-col / start-screen-x instructions. Identity at 4:3 - * and in the engine's 512 hi-res mode. */ -void gpu_ws_bg2d_configure(uint32_t layer_base, uint32_t ring_base, - uint32_t map_size_addr, uint32_t layer_stride_addr, - uint32_t ring_cols, uint32_t layer_count, - uint32_t layer_struct_stride, uint32_t packet_cap); -int psx_ws_bg2d_cols(int base); -int psx_ws_bg2d_startcol(int col, unsigned mask); -int psx_ws_bg2d_startx(int x); -int psx_ws_bg2d_stream_left(int x); -int psx_ws_bg2d_stream_right(int x); -int psx_ws_bg2d_undercap(int counter, int native_cap); -/* MISNAMED, NOT MMX6-SPECIFIC, AND NOT SAFE TO DELETE (surveyed 2026-07-27). - * - * These read as "MMX6 compat shims" and the comment here used to say exactly - * that. They are not. They are the widescreen-2D entry points that the - * recompiler emits for ANY title with a [widescreen.bg2d] block, and live - * generated code in several non-MMX6 games links them today: - * - * psx_ws_mmx6_bg_cols/startcol/startx/stream_left/stream_right - * -> CrashBashRecomp, MegaManX5Recomp, TsumuRecomp (+ -release) - * psx_ws_mmx6_bg_undercap (below, in gpu.c) - * -> ApeEscapeRecomp, CrashBashRecomp, MegaManX4Recomp, - * MegaManX5Recomp, TsumuRecomp, Vigilante8PSXRecomp, THPS2 - * - * Each one forwards to its psx_ws_bg2d_* twin above, which is the real - * implementation. Only the NAME is historical: this began as MMX6 widescreen - * work and was generalised without renaming the emitted symbols. - * - * Deleting or renaming them breaks widescreen in every repo listed above, and - * a rename cannot be runtime-only: the names are emitted by - * recompiler/src/code_generator.cpp, which is in codegen_hash_sources.cmake, - * so changing it rolls the overlay cache tag and forces a regen + reshard of - * every title. That is the reason this is still called mmx6 in 2026. - * - * Verify before touching, don't trust this comment: grep the generated trees - * of the game repos for psx_ws_mmx6_bg_undercap and see who turns up. - */ -int psx_ws_mmx6_bg_cols(int base); -int psx_ws_mmx6_bg_startcol(int col); -int psx_ws_mmx6_bg_startx(int x); -int psx_ws_mmx6_bg_stream_left(int x); -int psx_ws_mmx6_bg_stream_right(int x); -struct CPUState; -void psx_ws_sprite_tag(struct CPUState* cpu); - -/* Native-wide (mode 2) on a game frame. ws_nw_extra() is the total width the - * frame grows by, in display pixels (the present path widens the display read - * by this; 0 when native-wide is inactive). */ -int ws_native_wide_active(void); -int ws_nw_extra(void); -/* True when the current frame must present at native 4:3 (FMV video or a - * full-2D menu/title screen), so the squash is suppressed and content drawn - * pixel-native. The present path uses the same predicate to pillarbox. */ -int gpu_ws_present_native_43(void); -/* The same for the frame on display, which a double-buffered game drew a frame earlier. */ -int gpu_ws_displayed_native_43(void); -/* The frame on display is a front-end screen shown wide: flat, so nothing is put in between its frames. */ -int gpu_ws_displayed_menu(void); -/* A game says what the frame it begins to build is, before it culls or projects for it: 1 a full-2D screen, 0 a world. */ -void gpu_ws_tell_frame_kind(int flat); -int gpu_ws_displayed_flat(void); -/* Per-side X cull-margin (screen/world units) emitted into the game's draw- - * cull immediates by the recompiler ([widescreen.cull]); 0 unless stretching. */ -int psx_ws_x_margin(void); -void gpu_ws_set_cull_guard_pixels(int pixels); -/* Bias/range activation-window margin. This may include an additional - * resident-object lead while render/terrain paths retain psx_ws_x_margin(). */ -int psx_ws_activation_margin(void); -void gpu_ws_set_activation_guard_pixels(int pixels); -void gpu_ws_set_explicit_cull_sites(const uint32_t *bias, int nbias, - const uint32_t *slti, int nslti, - const uint32_t *range, int nrange); -void gpu_ws_set_slti_lower_cull_sites(const uint32_t *sites, int nsites); -void gpu_ws_set_negsub_cull_sites(const uint32_t *sites, int nsites); -void gpu_ws_set_vxrange_cull_sites(const uint32_t *sites, int nsites); -void gpu_ws_set_depth_cull_sites(const uint32_t *sites, int nsites); -void gpu_ws_set_plane_nx_sites(const uint32_t *sites, int nsites); -void gpu_ws_set_xclip_load_sites(const uint32_t *sites, int nsites); -void gpu_ws_set_cull_keep_sites(const uint32_t *addresses, - const uint32_t *expected, - const uint32_t *results, int nsites); -void gpu_ws_set_angle_sites(const uint32_t *addresses, - const uint32_t *expected, int nsites); -void gpu_ws_set_aspect_cone(const uint32_t *addresses, - const uint32_t *expected, - const uint32_t *cosine_thresholds, - const uint32_t *object_regs, - const uint32_t *x_regs, - const uint32_t *z_regs, - const uint32_t *y_regs, - const uint32_t *queue_guards, - int nsites, - uint32_t forward_addr, - uint32_t object_type_offset, - uint32_t hysteresis_pixels, - uint32_t queue_reserve, - const uint32_t queue_count_addrs[3], - const uint32_t queue_capacities[3], - const uint32_t queue_type_masks[3]); -int psx_ws_is_cull_bias_site(uint32_t pc); -int psx_ws_is_cull_slti_site(uint32_t pc); -int psx_ws_is_cull_slti_lower_site(uint32_t pc); -int psx_ws_is_cull_negsub_site(uint32_t pc); -int psx_ws_is_cull_vxrange_site(uint32_t pc); -int psx_ws_is_cull_depth_site(uint32_t pc); -int32_t psx_ws_depth_bound(int32_t imm); -int psx_ws_is_cull_range_site(uint32_t pc); -int psx_ws_is_cull_plane_nx_site(uint32_t pc); -int32_t psx_ws_plane_nx(int32_t nx); -int psx_ws_is_cull_xclip_load_site(uint32_t pc); -uint32_t psx_ws_xclip_bound(uint32_t vanilla); -uint32_t psx_ws_cull_keep_result(uint32_t vanilla, uint32_t forced); -int psx_ws_cull_keep_site(uint32_t pc, uint32_t instr, uint32_t vanilla, - uint32_t *out); -uint32_t psx_ws_angle_widen(uint32_t vanilla); -int32_t psx_ws_yaw_cone_offset(int32_t vanilla, uint32_t full_turn); -uint32_t psx_ws_horizontal_slt(uint32_t bound, uint32_t horizontal); -int psx_ws_angle_site(uint32_t pc, uint32_t instr, uint32_t *out); -uint32_t psx_ws_aspect_cone_result(uint32_t site, uint32_t vanilla, - uint32_t object, int32_t x, int32_t z, - int32_t y); -int psx_ws_aspect_cone_site(CPUState *cpu, uint32_t pc, uint32_t instr, - uint32_t vanilla, uint32_t *out); -/* Scale a signed Q16 horizontal gameplay limit into the active native-wide - * game field. Identity at 4:3 / menus / FMV. */ -int32_t psx_ws_player_x_bound(int32_t vanilla); -void gpu_ws_set_signed_x_bound_sites(const uint32_t *addresses, - const uint32_t *expected, int count); -int psx_ws_is_signed_x_bound_site(uint32_t pc, uint32_t instr); - -/* Shared render-funnel screen-X cull widening ([widescreen.cull] auto_screen_x): - * the gcc emit and the interpreter both route a flagged - * `sltiu rt, sx, 0x140/0x141` through this one helper so every overlay execution - * path widens identically. Returns the sltiu verdict (1 = keep). 0 at 4:3. */ -int psx_ws_cull_sltiu(uint32_t sx, uint32_t imm); -/* Signed-funnel variants (min/max + center±halfwidth idioms — see - * ws_cull_detect.h): right-edge widen for `slti v, minSX, W` and left-edge - * widen for the paired `bltz maxSX` reject. Identity at 4:3. */ -int psx_ws_cull_slti(uint32_t sx, uint32_t imm); -int psx_ws_cull_slti_lower(uint32_t sx, uint32_t imm); -int32_t psx_ws_cull_lower_value(int32_t vanilla); -int32_t psx_ws_cull_upper_value(int32_t vanilla); -uint32_t psx_ws_cull_span_value(uint32_t vanilla); -int psx_ws_cull_bltz(uint32_t v); -int psx_ws_cull_vxrange(uint32_t x, uint32_t imm); -/* True if a run of instruction words carries the screen-extent reject signature - * (a width compare AND a height compare from the configured immediate sets). - * Used by the interp to gate the widening to real render funnels. */ -int psx_ws_func_has_screen_cull(const uint32_t *words, int n); -/* Classify words[idx] as an X left-edge reject bltz (runtime imm sets). */ -int psx_ws_cull_bltz_at(const uint32_t *words, int n, int idx); -/* Per-game cull signature immediates ([widescreen.cull] screen_w_imms / - * screen_h_imms); defaults 0x140/0x141 + 0xE0/0xF1. */ -void gpu_ws_set_cull_imms(const uint32_t *w, int nw, const uint32_t *h, int nh); -int psx_ws_is_cull_w_imm(uint32_t imm); -/* Per-game opt-in gates for the pattern-scanned interp widen hooks - * (auto_screen_x cull + auto_backdrop preload). Default OFF: a title that - * never opted in must never have its live code pattern-scanned and rewritten. */ -void gpu_ws_set_auto_hooks(int cull_on, int backdrop_on); -int psx_ws_auto_cull_on(void); -/* GTE-activity gameplay detector ([widescreen] gte_game_mode) for 3D titles - * with no sprite-tag helper: gte.cpp notes every RTPS/RTPT projection; a frame - * that projects enough vertices is stamped as gameplay. */ -void gpu_ws_set_gte_game_mode(int on); -void psx_ws_note_gte_project(int nverts); -/* Optional authoritative gameplay-state gate. When configured, it replaces - * heuristic gameplay classification for native-wide presentation. */ -void gpu_ws_set_gameplay_state_gate(uint32_t addr, - const uint32_t *values, int nvalues); -/* Native-wide HUD corner re-anchoring ([widescreen] nw_hud_corners): push - * outer-third screen-space HUD primitives out to the true wide-frame corners - * (they otherwise sit inset by the reveal). Runtime-only. Off by default. */ -void gpu_ws_set_nw_hud_corners(int on); -/* Targeted alternative for sprite-heavy 2D games: corner-anchor only primitives - * whose ordering-table packet lives in the configured half-open RAM range. */ -void gpu_ws_set_nw_left_hud_packet_range(uint32_t lo, uint32_t hi); -void gpu_ws_begin_linked_list(void); -void gpu_ws_end_linked_list(void); -void gpu_ws_prepass_linked_list(uint32_t start_addr); -/* Native-wide full-frame 2D backdrop stretch ([widescreen] nw_backdrop): - * stretch a screen-space quad that covers the whole 4:3 framebuffer (sky - * gradient / backdrop image) to fill the wide frame, so it no longer - * pillarboxes at the reveal margins. Runtime-only. Off by default. */ -void gpu_ws_set_nw_backdrop(int on); -/* Native-wide flat-polygon backdrop stretch ([widescreen] nw_flat_backdrop): - * stretch untextured primitives in the wide mirror without changing the - * canonical 4:3 framebuffer. Intended for flat-colour sky/water backdrops. */ -void gpu_ws_set_nw_flat_backdrop(int on); -int gpu_ws_nw_flat_backdrop_enabled(void); -/* Stretch the title-opted textured pre-3D backdrop phase in the wide mirror. */ -void gpu_ws_set_nw_phase_backdrop(int on); - -/* Backdrop screen-X correction ([widescreen.backdrop] x_sites). The parallax - * 2D backdrop layer computes screen-X without the GTE, so it misses the - * widescreen squash; the recompiler emits this on each backdrop handler's - * final screenX store to squash it around the screen centre (identity at 4:3). - * Pulls far backdrop pieces in from past the 320px edge to cover the 16:9 FOV. */ -int psx_ws_backdrop_x(int x); -/* CPU-calculated perspective X counterpart to the GTE widescreen squash. - * Exact game sites use this only for final projected screen coordinates. */ -int psx_ws_project_x(int x); -int32_t psx_ws_project_x16(int x, int32_t fraction16); - -/* Backdrop PRELOAD ([widescreen.cull] auto_backdrop). psx_ws_backdrop_preload() - * is nonzero only while native-wide widescreen is engaged (0 at 4:3 / boot / - * FMV / full-2D), so the rewrite is byte-identical when off. psx_ws_backdrop_value() - * is the one value substitution shared by the gcc emit and the - * interpreter for a detected window bound: it returns `orig` unless preload is - * engaged, in which case it forces the bound to preload the WHOLE finite tile - * row (START -> 0, END -> large sentinel pinned by the generator's high clamp to - * extent-1). Every column is submitted, so the widened 16:9 viewport never gaps; - * the generator's clamps + the byte-sized row extent keep it bounded. */ -int psx_ws_backdrop_preload(void); -uint32_t psx_ws_backdrop_value(uint32_t orig, int is_end, int window_cols); - -/* auto_backdrop diagnostic ring (always-on). The interpreter records every - * window rewrite (with the live extent / DL count / camera-X); `ws_backdrop_ring` - * dumps it. psx_ws_backdrop_ring_json() formats the recent entries into `buf` - * (returns bytes written, excluding the caller's JSON envelope). */ -void psx_ws_backdrop_ring_note(uint32_t pc, int kind, int wcols, uint32_t orig, - uint32_t finalv, int extent, int camx, int count, - uint32_t base, uint32_t dl); -int psx_ws_backdrop_ring_json(char *buf, int cap); - -/* Live-tunable backdrop widen amount (ws_backdrop_margin command): <0 whole-row, - * 0 off, >0 N-column widen. g_ws_bd_from_interp is the interp's one-shot - * "I will record the rich entry myself" flag (see gpu.c / dirty_ram_interp.c). */ -extern int g_ws_bd_margin; -extern int g_ws_bd_from_interp; - -/* Native-wide: nonzero if the GP0 prim currently being drawn is sprite-tagged - * (a character / Tomba / HUD element). The GL 2D-backdrop stretch uses this to - * EXCLUDE foreground sprites (only the untagged 2D backdrop is stretched). */ -int psx_ws_prim_is_tagged(void); - -/* Flower-field backdrop data-structure address range + a predicate matching the - * prim being drawn against it (gp0_cmd_source_addr ∈ [lo,hi]). The dirty-RAM - * interp sets lo/hi when the backdrop generator runs; the GL stretch gate uses - * psx_ws_prim_in_backdrop() to identify the flower-field tiles precisely. */ -extern uint32_t g_ws_backdrop_lo, g_ws_backdrop_hi; -int psx_ws_prim_in_backdrop(void); - -/* Backdrop store-site registry: the runtime registers the [widescreen.backdrop] - * x_sites here (from game.toml) so the dirty-RAM interpreter applies the same - * psx_ws_backdrop_x() squash at those `sh` PCs that the recompiler emits into - * native cache DLLs — overlay code frequently runs interpreted, where the emit - * can't reach. PCs are masked to the physical (KUSEG) range. */ -void psx_ws_set_backdrop_sites(const uint32_t* pcs, int n); -int psx_ws_is_backdrop_site(uint32_t pc); - -/* Live widescreen state for diagnostics (TCP gpu_state). All pointers - * optional. last_tag_frame/cur_frame let the caller see game_mode freshness. */ -typedef struct { - int configured; /* ws_xnum != ws_xden */ - int active; /* squash currently applied this frame */ - int game_mode; /* tagged char/billboard prim within 2 frames */ - int present_native_43; /* frame presents pillarboxed 4:3 (FMV/full-2D) */ - int x_margin; /* psx_ws_x_margin() right now */ - int activation_margin; /* psx_ws_activation_margin() right now */ - int xnum, xden; /* squash factor */ - int mode; /* 0 = off, 1 = squash, 2 = native-wide */ - int nw_extra; /* native-wide frame growth (display px), 0 if off */ - uint64_t cur_frame; - uint32_t last_tag_frame; /* frame of newest tagged prim */ - uint32_t last_3d_frame; /* frame of newest shaded prim (diagnostic) */ - uint32_t gte_verts; /* RTPS/RTPT verts in the last completed frame */ - uint32_t last_world3d_frame;/* newest SUSTAINED world-scale projection frame */ - uint32_t ovh_prims; /* overhanging polys in the last completed frame */ - uint32_t last_ovh_frame; /* newest SUSTAINED polygon-overhang frame (the - 2D-only-scene classifier's world signal) */ - int auto_ui_squash; /* final-OT grouped UI correction configured */ - int auto_ui_dense; /* current list classified as a dense menu */ - uint32_t auto_ui_ot_rank; /* highest populated UI rank in current list */ - uint64_t auto_ui_candidates; - uint64_t auto_ui_transforms; - uint64_t aspect_cone_calls; - uint64_t aspect_cone_43_identity; - uint64_t aspect_cone_vanilla_keep; - uint64_t aspect_cone_visible_keep; - uint64_t aspect_cone_guard_keep; - uint64_t aspect_cone_hysteresis_keep; - uint64_t aspect_cone_outside_reject; - uint64_t aspect_cone_queue_reject; - uint32_t aspect_cone_queue_highwater[3]; - uint64_t angle_calls; - uint64_t angle_43_identity; - uint32_t angle_max_vanilla; - uint32_t angle_max_widened; -} GpuWsDebug; -void gpu_ws_get_debug(GpuWsDebug* out); - -typedef struct { - uint32_t address; - uint64_t calls; - uint64_t identity_43; - uint64_t vanilla_keep; - uint64_t visible_keep; - uint64_t guard_keep; - uint64_t hysteresis_keep; - uint64_t outside_reject; - uint64_t queue_reject; -} GpuWsAspectConeSiteDebug; -/* Exact-site telemetry for separating actor-list and per-child participation - * predicates. Returns zero when address is not configured. */ -int gpu_ws_get_aspect_cone_site_debug( - uint32_t address, GpuWsAspectConeSiteDebug* out); - -/* Diagnostic: force psx_ws_x_margin() to return v (>=0) regardless of state, - * or -1 to restore the normal computed margin. For live cull-margin sweeps. */ -void gpu_ws_set_margin_override(int v); - -/* Native-wide HUD corner re-anchoring: allow TAGGED rect-family prims to - * shift too (live A/B via TCP ws_hud_mode; some HUD composites render - * through the tagged sprite funnel). */ -void gpu_ws_set_nw_hud_tag_rects(int on); - -/* Always-on draw-census ring: every drawn primitive records frame / source - * addr / camera / first-vertex screen pos, so object spawn/despawn and edge - * culls are observable in data. Dump frames [f0,f1] to a CSV file. */ -int gpu_ws_census_dump(uint32_t f0, uint32_t f1, const char *path); -void gpu_ws_census_set(int on); -uint64_t gpu_ws_census_seq(void); - -#ifdef __cplusplus -} -#endif - -#endif /* PSXRECOMP_GPU_H */ diff --git a/psxrecomp-overlay/runtime/include/gpu_render.h b/psxrecomp-overlay/runtime/include/gpu_render.h deleted file mode 100644 index 6cb1779..0000000 --- a/psxrecomp-overlay/runtime/include/gpu_render.h +++ /dev/null @@ -1,237 +0,0 @@ -#ifndef PSX_GPU_RENDER_H -#define PSX_GPU_RENDER_H - -/* Renderer facade. gpu.c (command processing) and main.cpp (present) call - * these gr_* entry points instead of a specific backend. The facade - * dispatches to a selected backend: the software rasterizer (gpu_sw_renderer.c, - * the default + fallback) or a hardware OpenGL backend (gpu_gl_renderer.c). - * - * The gr_* signatures mirror the software renderer's interface exactly, so a - * backend is just a table of the same functions. Select the backend with - * gr_set_backend() BEFORE gr_init(); gr_backend() reports the EFFECTIVE backend - * (a requested backend that fails to initialize falls back to software). */ - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -typedef enum { - GR_BACKEND_SOFTWARE = 0, - GR_BACKEND_OPENGL = 1, - GR_BACKEND_VULKAN = 2 -} GrBackend; - -void gr_set_backend(GrBackend backend); /* call before gr_init() */ -GrBackend gr_backend(void); /* effective backend after init */ - -/* Lifecycle / global state */ -void gr_init(uint16_t *vram); -void gr_set_scale(int scale); -int gr_scale(void); -void gr_set_texture_filter(int bilinear); -int gr_texture_filter(void); - -/* Per-primitive draw state */ -void gr_set_semi_transparency(int enabled, int mode); -void gr_set_mask_bits(int set_bit, int check_bit); -void gr_set_texture_window(uint32_t raw); -void gr_set_color_modulation(int r, int g, int b, int raw_texture); - -/* Optional presentation-only geometry correction. The authoritative PSX - * framebuffer is always rasterized from the integer GP0 coordinates. A - * backend may use the precise 16.16 coordinates and fractional camera yaw - * only while mirroring the primitive into a separate display surface. */ -void gr_set_geometry_correction(int enabled); -void gr_set_world_triangle(int world); -void gr_set_precise_triangle(int enabled, - int32_t x0, int32_t y0, - int32_t x1, int32_t y1, - int32_t x2, int32_t y2); -/* One-shot presentation positions of a triangle without exact provenance: where its pinned corners project. */ -void gr_set_unpinned_triangle(int32_t x0, int32_t y0, int32_t x1, int32_t y1, int32_t x2, int32_t y2); -/* One-shot reciprocal-depth metadata for the next textured triangle. Only a - * backend with a separate presentation surface should implement this; the - * authoritative PS1 framebuffer must retain affine texture interpolation. */ -void gr_set_perspective_triangle(int enabled, - float q0, float q1, float q2); -/* One-shot unnormalized GTE camera depth for temporal vertex identity. This - * metadata never reaches the shaders and is independent of perspective q, - * whose scale can differ between polygons. Zero means unknown depth. */ -void gr_set_temporal_depth_triangle(int enabled, - float z0, float z1, float z2); -/* One-shot camera depth of the same corners for the display mirror's own depth: a corner the game pinned has one here, - * where the temporal depth above says 0. Zero means unknown. */ -void gr_set_surface_depth_triangle(float z0, float z1, float z2); -/* One-shot GTE input vertices of the same three corners (zero = unknown). */ -void gr_set_temporal_identity_triangle(uint64_t id0, uint64_t id1, uint64_t id2); -/* The next textured rectangle is a world sprite at this camera depth (0 = none). */ -void gr_set_temporal_sprite(float depth, const float sides[4]); -/* center_y is relative to the active drawing band; a backend which stores - * absolute VRAM positions must combine it with its current draw offset. */ -void gr_set_presentation_yaw(double yaw_units, double full_turn, - double center_x, double center_y, - double focal_x); - -/* Primitives */ -void gr_fill_rect(int x, int y, int w, int h, uint16_t color); -void gr_copy_rect(int src_x, int src_y, int dst_x, int dst_y, int w, int h); -void gr_draw_flat_triangle(int x0, int y0, int x1, int y1, int x2, int y2, - uint16_t color); -void gr_draw_gouraud_triangle(int x0, int y0, uint16_t c0, - int x1, int y1, uint16_t c1, - int x2, int y2, uint16_t c2); -void gr_draw_textured_triangle(int x0, int y0, int u0, int v0, - int x1, int y1, int u1, int v1, - int x2, int y2, int u2, int v2, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage); -void gr_draw_shaded_textured_triangle(int x0, int y0, int u0, int v0, - uint32_t color0, - int x1, int y1, int u1, int v1, - uint32_t color1, - int x2, int y2, int u2, int v2, - uint32_t color2, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage, int raw_texture); -void gr_draw_flat_rect(int x, int y, int w, int h, uint16_t color); -void gr_draw_textured_rect(int x, int y, int w, int h, - int u, int v, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage); -void gr_draw_textured_rect_scaled(int x, int y, int w, int h, - int u0, int v0, int u1, int v1, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage); -void gr_draw_line(int x0, int y0, int x1, int y1, uint16_t color); -void gr_draw_shaded_line(int x0, int y0, uint16_t c0, - int x1, int y1, uint16_t c1); - -/* Display readout (present path) */ -int gr_render_display(uint32_t *out_pixels, int out_pitch, - int disp_x, int disp_y, int disp_w, int disp_h); -int gr_render_display_hires(uint32_t *out_pixels, int out_pitch, - int disp_x, int disp_y, int disp_w, int disp_h); - -/* VRAM transfers */ -void gr_vram_write(int x, int y, uint16_t pixel); -uint16_t gr_vram_read(int x, int y); -void gr_vram_transfer_in(int x, int y, int w, int h, const uint16_t *data); -void gr_vram_transfer_out(int x, int y, int w, int h, uint16_t *data); - -/* Draw area / offset */ -void gr_set_draw_area(int x1, int y1, int x2, int y2); -void gr_get_draw_area(int *x1, int *y1, int *x2, int *y2); -void gr_set_draw_offset(int x, int y); - -/* Native-wide compositor. gr_wide_supported() reports whether the active - * backend implements it; if not, the others are no-ops and the caller keeps - * the canonical present path. */ -int gr_wide_supported(void); -void gr_wide_configure(int wide_w, int offset); -void gr_wide_set_target(int base_x); -void gr_wide_disable_target(void); -void gr_wide_clear(int base_x, int y, int h, uint16_t color); -/* sides: bit 0 = left synthetic margin, bit 1 = right. */ -void gr_wide_clear_margins(int base_x, int y, int h, uint16_t color, int sides); -int gr_wide_dump_full(uint32_t *out, int cap_pixels, int *ow, int *oh, int base_x); -int gr_render_wide_display(uint32_t *out, int pitch, int base_x, - int disp_y, int disp_h); - -/* ---- Backend vtable ----------------------------------------------------- - * A backend supplies the same set of functions. gpu_gl_renderer.c (Phase 2) - * provides gl_backend_get(); until it does, requesting OpenGL falls back to - * software. Members mirror the gr_* / sw_* signatures 1:1. */ -typedef struct GpuRenderBackend { - const char *name; - void (*init)(uint16_t *vram); - void (*set_scale)(int scale); - int (*scale)(void); - void (*set_texture_filter)(int bilinear); - int (*texture_filter)(void); - void (*set_semi_transparency)(int enabled, int mode); - void (*set_mask_bits)(int set_bit, int check_bit); - void (*set_texture_window)(uint32_t raw); - void (*set_color_modulation)(int r, int g, int b, int raw_texture); - void (*set_geometry_correction)(int enabled); - void (*set_world_triangle)(int world); - void (*set_precise_triangle)(int enabled, - int32_t x0, int32_t y0, - int32_t x1, int32_t y1, - int32_t x2, int32_t y2); - void (*set_perspective_triangle)(int enabled, - float q0, float q1, float q2); - void (*set_temporal_depth_triangle)(int enabled, - float z0, float z1, float z2); - void (*set_surface_depth_triangle)(float z0, float z1, float z2); - void (*set_temporal_identity_triangle)(uint64_t id0, uint64_t id1, uint64_t id2); - void (*set_temporal_sprite)(float depth, const float sides[4]); - void (*set_presentation_yaw)(double yaw_units, double full_turn, - double center_x, double center_y, - double focal_x); - void (*fill_rect)(int x, int y, int w, int h, uint16_t color); - void (*copy_rect)(int src_x, int src_y, int dst_x, int dst_y, int w, int h); - void (*draw_flat_triangle)(int x0, int y0, int x1, int y1, int x2, int y2, - uint16_t color); - void (*draw_gouraud_triangle)(int x0, int y0, uint16_t c0, - int x1, int y1, uint16_t c1, - int x2, int y2, uint16_t c2); - void (*draw_textured_triangle)(int x0, int y0, int u0, int v0, - int x1, int y1, int u1, int v1, - int x2, int y2, int u2, int v2, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage); - void (*draw_shaded_textured_triangle)(int x0, int y0, int u0, int v0, - uint32_t color0, - int x1, int y1, int u1, int v1, - uint32_t color1, - int x2, int y2, int u2, int v2, - uint32_t color2, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage, int raw_texture); - void (*draw_flat_rect)(int x, int y, int w, int h, uint16_t color); - void (*draw_textured_rect)(int x, int y, int w, int h, int u, int v, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage); - void (*draw_textured_rect_scaled)(int x, int y, int w, int h, - int u0, int v0, int u1, int v1, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage); - void (*draw_line)(int x0, int y0, int x1, int y1, uint16_t color); - void (*draw_shaded_line)(int x0, int y0, uint16_t c0, - int x1, int y1, uint16_t c1); - int (*render_display)(uint32_t *out, int pitch, - int dx, int dy, int dw, int dh); - int (*render_display_hires)(uint32_t *out, int pitch, - int dx, int dy, int dw, int dh); - void (*vram_write)(int x, int y, uint16_t pixel); - uint16_t (*vram_read)(int x, int y); - void (*vram_transfer_in)(int x, int y, int w, int h, const uint16_t *data); - void (*vram_transfer_out)(int x, int y, int w, int h, uint16_t *data); - void (*set_draw_area)(int x1, int y1, int x2, int y2); - void (*get_draw_area)(int *x1, int *y1, int *x2, int *y2); - void (*set_draw_offset)(int x, int y); - /* Native-wide compositor (optional; NULL on backends without it — the - * facade then reports gr_wide_supported() == 0 and the caller keeps the - * canonical present). */ - void (*wide_configure)(int wide_w, int offset); - void (*wide_set_target)(int base_x); - void (*wide_disable_target)(void); - void (*wide_clear)(int base_x, int y, int h, uint16_t color); - void (*wide_clear_margins)(int base_x, int y, int h, uint16_t color, int sides); - int (*render_wide_display)(uint32_t *out, int pitch, int base_x, - int disp_y, int disp_h); - /* Dump the ENTIRE wide compositor surface for base_x (all double-buffer - * bands + both margins), g_wide_w x VRAM_H. Debug/inspection only; returns - * pixel count, writes width/height to ow/oh. NULL if unsupported. */ - int (*wide_dump_full)(uint32_t *out, int cap_pixels, int *ow, int *oh, - int base_x); - void (*set_unpinned_triangle)(int32_t x0, int32_t y0, int32_t x1, int32_t y1, int32_t x2, int32_t y2); -} GpuRenderBackend; - -#ifdef __cplusplus -} -#endif - -#endif /* PSX_GPU_RENDER_H */ diff --git a/psxrecomp-overlay/runtime/include/gpu_sw_renderer.h b/psxrecomp-overlay/runtime/include/gpu_sw_renderer.h deleted file mode 100644 index 193ecbd..0000000 --- a/psxrecomp-overlay/runtime/include/gpu_sw_renderer.h +++ /dev/null @@ -1,130 +0,0 @@ -#ifndef PSX_GPU_SW_RENDERER_H -#define PSX_GPU_SW_RENDERER_H - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/* Maximum internal-resolution supersampling factor (linear, per axis). */ -#define SW_MAX_INTERNAL_SCALE 8 - -/* Initialize software renderer */ -void sw_renderer_init(uint16_t* vram); - -/* Internal-resolution supersampling (SSAA). - * scale == 1 : disabled, renderer behaves exactly as native VRAM only. - * scale > 1 : maintain an S*-scaled mirror of VRAM; the display reads the - * mirror (sw_render_display_hires) and the present path - * downsamples to the window. Clamped to SW_MAX_INTERNAL_SCALE. - * Must be called once after sw_renderer_init, before drawing begins. */ -void sw_renderer_set_scale(int scale); -int sw_renderer_scale(void); - -/* Visual-only high-resolution vertex override for the next triangle. Values - * are signed 16.16 screen coordinates; native VRAM still uses the integer GP0 - * positions, while the supersampled mirror consumes these fractions. */ -void sw_set_precise_triangle(int enabled, - int32_t x0, int32_t y0, - int32_t x1, int32_t y1, - int32_t x2, int32_t y2); - -/* Perspective-correct UV override for the next triangle. q values are the - * homogeneous interpolation weights associated with each screen vertex. */ -void sw_set_perspective_triangle(int enabled, - float q0, float q1, float q2); -uint32_t sw_perspective_triangle_count(void); - -/* Texture filtering. 0 = nearest (native PSX look, default), 1 = bilinear - * (smooths textures/2D backgrounds; blends in RGB after the CLUT lookup, - * transparency-aware). Independent of supersampling. */ -void sw_set_texture_filter(int bilinear); -int sw_texture_filter(void); - -/* Draw state — must be set by gpu.c before each primitive */ -void sw_set_semi_transparency(int enabled, int mode); -void sw_set_mask_bits(int set_bit, int check_bit); -void sw_set_texture_window(uint32_t raw); -void sw_set_color_modulation(int r, int g, int b, int raw_texture); - -/* Drawing primitives — called from gpu.c command processing */ -void sw_fill_rect(int x, int y, int w, int h, uint16_t color); -void sw_copy_rect(int src_x, int src_y, int dst_x, int dst_y, int w, int h); -void sw_draw_flat_triangle(int x0, int y0, int x1, int y1, int x2, int y2, - uint16_t color); -void sw_draw_gouraud_triangle(int x0, int y0, uint16_t c0, - int x1, int y1, uint16_t c1, - int x2, int y2, uint16_t c2); -void sw_draw_textured_triangle(int x0, int y0, int u0, int v0, - int x1, int y1, int u1, int v1, - int x2, int y2, int u2, int v2, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage); -void sw_draw_shaded_textured_triangle(int x0, int y0, int u0, int v0, - uint32_t color0, - int x1, int y1, int u1, int v1, - uint32_t color1, - int x2, int y2, int u2, int v2, - uint32_t color2, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage, int raw_texture); -void sw_draw_flat_rect(int x, int y, int w, int h, uint16_t color); -void sw_draw_textured_rect(int x, int y, int w, int h, - int u, int v, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage); -void sw_draw_textured_rect_scaled(int x, int y, int w, int h, - int u0, int v0, int u1, int v1, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage); -void sw_draw_line(int x0, int y0, int x1, int y1, uint16_t color); -void sw_draw_shaded_line(int x0, int y0, uint16_t c0, - int x1, int y1, uint16_t c1); - -/* Copy display region to a 32-bit RGBA buffer for SDL presentation. - Returns number of pixels written. */ -int sw_render_display(uint32_t* out_pixels, int out_pitch, - int disp_x, int disp_y, int disp_w, int disp_h); - -/* Like sw_render_display, but reads the hi-res mirror and emits the region - at (disp_w*scale x disp_h*scale). Falls back to sw_render_display when - supersampling is disabled. Returns number of pixels written. */ -int sw_render_display_hires(uint32_t* out_pixels, int out_pitch, - int disp_x, int disp_y, int disp_w, int disp_h); - -/* VRAM transfer (CPU->VRAM, VRAM->CPU) */ -void sw_vram_write(int x, int y, uint16_t pixel); -uint16_t sw_vram_read(int x, int y); -void sw_vram_transfer_in(int x, int y, int w, int h, const uint16_t* data); -void sw_vram_transfer_out(int x, int y, int w, int h, uint16_t* data); - -/* Set draw area and draw offset */ -void sw_set_draw_area(int x1, int y1, int x2, int y2); -void sw_get_draw_area(int *x1, int *y1, int *x2, int *y2); -void sw_set_draw_offset(int x, int y); - -/* Native-wide compositor (see gpu_sw_renderer.c). Canonical VRAM stays faithful; - * framebuffer draws are mirrored into independent wide surfaces keyed by buffer - * base_x. wide_w<=0 disables. set_target selects/clears the mirror per back - * buffer; render_wide_display is the present source for the displayed buffer. */ -void sw_wide_configure(int wide_w, int offset); -void sw_wide_set_target(int base_x); -void sw_wide_disable_target(void); -void sw_wide_clear(int base_x, int y, int h, uint16_t color); -void sw_wide_clear_margins(int base_x, int y, int h, uint16_t color, int sides); -/* MMX6 native-wide reveal-column tile: rasterize a 16x16 textured tile into the - * wide surface ONLY (canonical VRAM + hi-res mirror untouched). See definition. */ -void sw_wide_emit_tile(int band_y, int x, int y, int u, int v, - uint16_t clut_x, uint16_t clut_y, uint16_t texpage, - uint32_t color24, int semi_trans, int semi_mode, int raw_texture); -int sw_render_wide_display(uint32_t* out_pixels, int out_pitch, int base_x, - int disp_y, int disp_h); -int sw_wide_dump_full(uint32_t* out, int cap_pixels, int* ow, int* oh, int base_x); -int sw_wide_width(void); - -#ifdef __cplusplus -} -#endif - -#endif /* PSX_GPU_SW_RENDERER_H */ diff --git a/psxrecomp-overlay/runtime/include/mod_memory.h b/psxrecomp-overlay/runtime/include/mod_memory.h deleted file mode 100644 index c5a5c3f..0000000 --- a/psxrecomp-overlay/runtime/include/mod_memory.h +++ /dev/null @@ -1,56 +0,0 @@ -#ifndef PSXRECOMP_MOD_MEMORY_H -#define PSXRECOMP_MOD_MEMORY_H - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/* - * GPU linked-list tags carry only 24 address bits. Trusted enhancements that - * need a larger primitive arena use this otherwise-unmapped 1 MiB aperture so - * their CPU pointers survive the tag truncation. Nothing is mapped until an - * enhancement explicitly allocates from it. - */ -#define PSX_MOD_GPU_DMA_APERTURE_BASE 0x00F00000u -#define PSX_MOD_GPU_DMA_APERTURE_SIZE (1u * 1024u * 1024u) -#define PSX_MOD_GPU_DMA_GUEST_BASE 0x80F00000u - -static inline int psx_mod_gpu_dma_aperture_offset_for( - uint32_t address, uint32_t width, uint32_t used, uint32_t *offset) { - uint32_t canonical = address & 0x00FFFFFFu; - uint32_t off; - if (canonical < PSX_MOD_GPU_DMA_APERTURE_BASE) return 0; - off = canonical - PSX_MOD_GPU_DMA_APERTURE_BASE; - if (off > used || width > used - off) return 0; - if (offset) *offset = off; - return 1; -} - -/* - * Preserve the retail DMAC's 2 MiB folding unless the address is inside the - * portion of the enhancement aperture that has actually been allocated. - */ -static inline uint32_t psx_mod_gpu_dma_resolve_address_for( - uint32_t address, uint32_t used) { - uint32_t canonical = address & 0x00FFFFFCu; - if (psx_mod_gpu_dma_aperture_offset_for( - canonical, 4u, used, (uint32_t *)0)) - return canonical; - return canonical & 0x001FFFFCu; -} - -uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t size, uint32_t alignment); -uint32_t psx_mod_gpu_dma_resolve_address(uint32_t address); -/* Snapshot payload is the allocated byte prefix; restoring its length also - * restores the allocation boundary needed by CPU and 24-bit GPU accesses. */ -uint32_t psx_mod_gpu_dma_memory_bytes(void); -const uint8_t *psx_mod_gpu_dma_memory_data(void); -int psx_mod_gpu_dma_memory_restore(const uint8_t *data, uint32_t size); - -#ifdef __cplusplus -} -#endif - -#endif diff --git a/psxrecomp-overlay/runtime/include/overlay_dispatch_preamble.c.inc b/psxrecomp-overlay/runtime/include/overlay_dispatch_preamble.c.inc deleted file mode 100644 index ea2e5c7..0000000 --- a/psxrecomp-overlay/runtime/include/overlay_dispatch_preamble.c.inc +++ /dev/null @@ -1,281 +0,0 @@ -#include "overlay_api.h" - -/* ---- Overlay dispatch shim (inserted by compile_overlays.py) ----------- */ -static OverlayCallbacks g_cbs; -static uint32_t s_pending_cycles; - -#ifdef _WIN32 -__declspec(dllexport) -#else -__attribute__((visibility("default"))) -#endif -void overlay_flush_cycles(void) { - uint32_t cycles = s_pending_cycles; - s_pending_cycles = 0; - if (cycles && g_cbs.advance_cycles) g_cbs.advance_cycles(cycles); -} - -/* Call-contract state (ABI v2): generated code reads/bumps the runtime's - * bail state through these pointers (cpu_state.h PSX_OVERLAY_DLL_BUILD - * branch). They start at local dummies so a pre-init call can't fault. */ -static int s_bail_dummy; -static uint64_t s_ctr_dummy0, s_ctr_dummy1; -int *g_psx_call_bail_p = &s_bail_dummy; -uint64_t *g_psx_bail_first_p = &s_ctr_dummy0; -uint64_t *g_psx_bail_resolved_p = &s_ctr_dummy1; - -#ifdef _WIN32 -__declspec(dllexport) -#else -__attribute__((visibility("default"))) -#endif -int overlay_abi(void) { return PSX_OVERLAY_ABI_VERSION | (PSX_OVERLAY_FLAVOR << 16); } - -#ifdef _WIN32 -__declspec(dllexport) -#else -__attribute__((visibility("default"))) -#endif -void overlay_init(const OverlayCallbacks *cbs) { - g_cbs = *cbs; - if (g_cbs.call_bail_flag) g_psx_call_bail_p = g_cbs.call_bail_flag; - if (g_cbs.bail_first) g_psx_bail_first_p = g_cbs.bail_first; - if (g_cbs.bail_resolved) g_psx_bail_resolved_p = g_cbs.bail_resolved; -} - -void psx_dispatch_call(CPUState *cpu, uint32_t addr, uint32_t ra) { - overlay_flush_cycles(); - (void)ra; - g_cbs.dispatch_call(cpu, addr, cpu->gpr[31]); -} -void psx_check_interrupts(CPUState *cpu) { - overlay_flush_cycles(); - g_cbs.check_interrupts(cpu); -} -void psx_check_interrupts_at(CPUState *cpu, uint32_t resume_pc) { - overlay_flush_cycles(); - if (g_cbs.check_interrupts_at) g_cbs.check_interrupts_at(cpu, resume_pc); - else g_cbs.check_interrupts(cpu); -} -void psx_advance_cycles(uint32_t cycles) { - if (cycles > UINT32_MAX - s_pending_cycles) overlay_flush_cycles(); - s_pending_cycles += cycles; -} -void gte_execute(CPUState *cpu, uint32_t cmd) { - overlay_flush_cycles(); - g_cbs.gte_execute(cpu, cmd); -} -int psx_syscall(CPUState *cpu, uint32_t code) { - overlay_flush_cycles(); - return g_cbs.psx_syscall(cpu, code); -} -void psx_native_bad_entry(CPUState *cpu, uint32_t owner, uint32_t pc) { - overlay_flush_cycles(); - if (g_cbs.psx_native_bad_entry) g_cbs.psx_native_bad_entry(cpu, owner, pc); -} -void psx_unknown_dispatch(CPUState *cpu, uint32_t addr, uint32_t phys) { - overlay_flush_cycles(); - g_cbs.psx_unknown_dispatch(cpu, addr, phys); -} -void debug_server_log_call_entry(uint32_t func_addr) { - if (g_cbs.log_call_entry) g_cbs.log_call_entry(func_addr); -} -void psx_restore_state_escape(void) { - overlay_flush_cycles(); - if (g_cbs.psx_restore_state_escape) g_cbs.psx_restore_state_escape(); -} -/* RFE escape marker (ABI v12): emitted at every `rfe`, including in - * overlay-resident exception-context code. Mutates the runtime's shared - * RFE-pending state, so it MUST forward (a local no-op would swallow - * exception returns). Unconditional — v12 hosts always supply it; the - * ABI gate rejects mixes that don't. */ -void psx_rfe_mark_escape(void) { - overlay_flush_cycles(); - g_cbs.rfe_mark_escape(); -} -/* Faithful-timing functions (ABI v9): overlay code built with PSX_ENABLE_BLOCK_CYCLES - * emits these; forward to the runtime's real impls so native overlays charge cycles on - * the SAME timeline as the interp/BIOS (a local copy would diverge). NULL-guarded so a - * v9 DLL stays safe on a host that predates a given callback. */ -uint32_t psx_cyc_load_word(CPUState *cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask) { - overlay_flush_cycles(); - return g_cbs.cyc_load_word ? g_cbs.cyc_load_word(cpu, addr, rt, reg_mask) : cpu->read_word(addr); -} -uint16_t psx_cyc_load_half(CPUState *cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask) { - overlay_flush_cycles(); - return g_cbs.cyc_load_half ? g_cbs.cyc_load_half(cpu, addr, rt, reg_mask) : cpu->read_half(addr); -} -uint8_t psx_cyc_load_byte(CPUState *cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask) { - overlay_flush_cycles(); - return g_cbs.cyc_load_byte ? g_cbs.cyc_load_byte(cpu, addr, rt, reg_mask) : cpu->read_byte(addr); -} -uint32_t psx_cyc_lwc2_read(CPUState *cpu, uint32_t addr) { - overlay_flush_cycles(); - return g_cbs.cyc_lwc2_read ? g_cbs.cyc_lwc2_read(cpu, addr) : cpu->read_word(addr); -} -void psx_icache_fetch(CPUState *cpu, uint32_t addr) { - overlay_flush_cycles(); - if (g_cbs.icache_fetch) g_cbs.icache_fetch(cpu, addr); -} -void psx_muldiv_set(CPUState *cpu, uint32_t latency) { - overlay_flush_cycles(); - if (g_cbs.muldiv_set) g_cbs.muldiv_set(cpu, latency); -} -void psx_muldiv_stall(CPUState *cpu) { - overlay_flush_cycles(); - if (g_cbs.muldiv_stall) g_cbs.muldiv_stall(cpu); -} -uint32_t psx_mult_latency_s(uint32_t rs) { - return g_cbs.mult_latency_s ? g_cbs.mult_latency_s(rs) : 0u; -} -uint32_t psx_mult_latency_u(uint32_t rs) { - return g_cbs.mult_latency_u ? g_cbs.mult_latency_u(rs) : 0u; -} -void psx_gte_stall(CPUState *cpu) { - overlay_flush_cycles(); - if (g_cbs.gte_stall) g_cbs.gte_stall(cpu); -} -void psx_gte_read(CPUState *cpu, uint32_t rt) { - overlay_flush_cycles(); - if (g_cbs.gte_read) g_cbs.gte_read(cpu, rt); -} -int psx_slice_block(CPUState *cpu, uint32_t block_addr, uint32_t bcyc, int side_effects) { - overlay_flush_cycles(); - return g_cbs.slice_block ? g_cbs.slice_block(cpu, block_addr, bcyc, side_effects) : 0; -} -/* GTE special-register accessors (ABI v10). The emitter routes masked, - * sign-extended, aliased/FIFO, read-only, precision-sensitive, flag/IR, and - * derived registers through these as DIRECT calls, so any - * GTE-heavy overlay (Tomba2 lava / Trolley attract demos) fails to link - * without them — undefined gte_read_ctrl was the signature that silently - * blocked all new overlay coverage. GTE state is SHARED runtime state; a - * local fallback would fork that timeline, so these forward - * unconditionally (ABI v10 hosts always supply them; the v10 gate rejects - * DLL/host mixes that don't). - * Proven bit-transparent registers retain direct CPUState-array fast paths; - * the shared emitter classification header is the source of truth. */ -uint32_t gte_read_data(CPUState *cpu, uint8_t reg) { - overlay_flush_cycles(); - return g_cbs.gte_read_data(cpu, reg); -} -uint32_t gte_read_ctrl(CPUState *cpu, uint8_t reg) { - overlay_flush_cycles(); - return g_cbs.gte_read_ctrl(cpu, reg); -} -void gte_write_data(CPUState *cpu, uint8_t reg, uint32_t value) { - overlay_flush_cycles(); - g_cbs.gte_write_data(cpu, reg, value); -} -void gte_write_ctrl(CPUState *cpu, uint8_t reg, uint32_t value) { - overlay_flush_cycles(); - g_cbs.gte_write_ctrl(cpu, reg, value); -} -/* g_debug_last_store_pc: a provenance breadcrumb the emitter writes before stores. - * Overlays are built PSX_NO_DEBUG_TOOLS (the runtime's only consumer is debug tooling), - * so a local definition satisfies the link; overlay-store provenance is simply not - * tracked in a no-debug-tools build (by design). */ -uint32_t g_debug_last_store_pc; -/* Widescreen hooks (ABI v3): forward to the runtime's live widescreen state. - * Identity fallback if the host predates the callbacks (NULL) — so a v3 DLL - * stays correct (4:3) on an older host. */ -int psx_ws_backdrop_x(int x) { - return g_cbs.ws_backdrop_x ? g_cbs.ws_backdrop_x(x) : (int)(short)x; -} -int psx_ws_x_margin(void) { - return g_cbs.ws_x_margin ? g_cbs.ws_x_margin() : 0; -} -/* Shared render-funnel screen-X cull widening ([widescreen.cull] auto_screen_x). - * The recompiler emits `rt = psx_ws_cull_sltiu(sx, imm)` at every width-compare - * cull site, INCLUDING in overlay-resident code, so the overlay DLL must define - * it or it fails to link (undefined reference) -> no native shard -> that overlay - * variant runs interpreted forever (the dwarf-village-style lag). Self-contained: - * depends only on psx_ws_x_margin() above. Identity at 4:3 (margin 0). MUST stay - * byte-for-byte identical to gpu.c psx_ws_cull_sltiu. */ -int psx_ws_cull_sltiu(uint32_t sx, uint32_t imm) { - int m = psx_ws_x_margin(); - return ((uint32_t)((int32_t)(int16_t)(uint16_t)sx + m) - < (uint32_t)((int32_t)imm + 2 * m)) ? 1 : 0; -} -/* Signed-funnel variants (min/max + center±halfwidth idioms). Same contract: - * self-contained, identity at margin 0, MUST stay byte-for-byte identical to - * the gpu.c implementations. */ -int psx_ws_cull_slti(uint32_t sx, uint32_t imm) { - return ((int32_t)sx < (int32_t)imm + psx_ws_x_margin()) ? 1 : 0; -} -int32_t psx_ws_cull_lower_value(int32_t vanilla) { - return vanilla - psx_ws_x_margin(); -} -int32_t psx_ws_cull_upper_value(int32_t vanilla) { - return vanilla + psx_ws_x_margin(); -} -uint32_t psx_ws_cull_span_value(uint32_t vanilla) { - int32_t margin = psx_ws_x_margin(); - int32_t left = (int32_t)(int16_t)(uint16_t)(vanilla >> 16); - uint32_t right = vanilla & 0xFFFFu; - return ((uint32_t)(uint16_t)(left - margin) << 16) | - (uint32_t)(uint16_t)((int32_t)right + margin); -} -int psx_ws_cull_bltz(uint32_t v) { - return ((int32_t)v < -psx_ws_x_margin()) ? 1 : 0; -} -int psx_ws_cull_vxrange(uint32_t x, uint32_t imm) { - int32_t margin = psx_ws_x_margin(); - uint32_t bound = (uint32_t)(int32_t)(int16_t)(uint16_t)imm; - return (((x + (uint32_t)margin) & 0xFFFFu) < - (bound + 2u * (uint32_t)margin)) ? 1 : 0; -} -void psx_ws_sprite_tag(CPUState *cpu) { - if (g_cbs.ws_sprite_tag) g_cbs.ws_sprite_tag(cpu); -} -/* Widescreen backdrop column-preload value (ABI v4). Identity fallback if the - * host predates the callback, so the DLL stays correct (4:3) on an older host. */ -uint32_t psx_ws_backdrop_value(uint32_t orig, int is_end, int window_cols) { - return g_cbs.ws_backdrop_value ? g_cbs.ws_backdrop_value(orig, is_end, window_cols) : orig; -} -/* Typed native-wide signed player-X bound (ABI v13). The emitter rewrites a - * configured [widescreen] signed_x_bound LUI site into - * psx_ws_player_x_bound(vanilla) (Einhander player bounds), including in - * overlay-resident code, so the DLL must define it or the shard fails to link - * (undefined reference — the rfe/gte_read_ctrl class). Computes against the - * host's live widescreen state; identity fallback (the vanilla constant, i.e. - * 4:3 behavior) if the host predates the callback. */ -int32_t psx_ws_player_x_bound(int32_t vanilla) { - return g_cbs.ws_player_x_bound ? g_cbs.ws_player_x_bound(vanilla) : vanilla; -} -int32_t psx_ws_depth_bound(int32_t imm) { - return g_cbs.ws_depth_bound ? g_cbs.ws_depth_bound(imm) : imm; -} -int32_t psx_ws_plane_nx(int32_t nx) { - return g_cbs.ws_plane_nx ? g_cbs.ws_plane_nx(nx) : nx; -} -uint32_t psx_ws_xclip_bound(uint32_t vanilla) { - return g_cbs.ws_xclip_bound ? g_cbs.ws_xclip_bound(vanilla) : vanilla; -} -uint32_t psx_ws_cull_keep_result(uint32_t vanilla, uint32_t forced) { - return g_cbs.ws_cull_keep_result - ? g_cbs.ws_cull_keep_result(vanilla, forced) - : vanilla; -} -uint32_t psx_ws_aspect_cone_result(uint32_t site, uint32_t vanilla, - uint32_t object, int32_t x, int32_t z, - int32_t y) { - return g_cbs.ws_aspect_cone_result - ? g_cbs.ws_aspect_cone_result(site, vanilla, object, x, z, y) - : vanilla; -} -uint32_t psx_ws_angle_widen(uint32_t vanilla) { - return g_cbs.ws_angle_widen - ? g_cbs.ws_angle_widen(vanilla) - : vanilla; -} -/* Sub-pixel GTE precision-store tracker (ABI v14). The emitter emits a direct - * gte_precision_store_word() call for every swc2 (GTE store-word), including in - * overlay-resident code, so the DLL must define it or the shard fails to link - * (undefined reference — the rfe/gte_read_ctrl class). Forwards to the host's - * stateful tracker (gte.cpp); no-op if the host predates the callback - * (precision tracking is a widescreen enhancement, off by default — 4:3 - * behavior is unaffected). */ -void gte_precision_store_word(uint32_t addr, uint8_t reg) { - if (g_cbs.gte_precision_store_word) g_cbs.gte_precision_store_word(addr, reg); -} -/* ----------------------------------------------------------------------- */ diff --git a/psxrecomp-overlay/runtime/include/psx_cyc.h b/psxrecomp-overlay/runtime/include/psx_cyc.h deleted file mode 100644 index 30ef672..0000000 --- a/psxrecomp-overlay/runtime/include/psx_cyc.h +++ /dev/null @@ -1,286 +0,0 @@ -/* psx_cyc.h — R3000A load-delay pipeline interlock (ReadAbsorb / ReadFudge / - * LDAbsorb / LDWhich). TIMING ONLY — the load VALUE delay is handled by the - * existing load-delay correctness path; this models only the CPU cycle cost. - * - * FAITHFUL_TIMING_PLAN.md / accuracy/load_readfudge_ldabsorb.md. Transcribed - * (facts, not code) from the in-tree Beetle oracle - * psxrecomp/beetle-psx/mednafen/psx/cpu.cpp (RunReal §1 base 795-798, GPR_DEPRES - * 702-705, DO_LDS 800, ReadMemory 364-451) and libretro.cpp (main-RAM read wait - * = +3, libretro.cpp:884). Every backend — the dirty-RAM interpreter and BOTH - * static emitters (code_generator.cpp, full_function_emitter.cpp/strict_translator.cpp) - * — accounts guest CPU cycles THROUGH THESE HELPERS so they can never disagree. - * - * Model (per retired instruction, in program order): - * §1 base : if(ReadAbsorb[ReadAbsorbWhich]) ReadAbsorb[ReadAbsorbWhich]--; - * else timestamp++; -- a pending load's give-back is - * consumed INSTEAD of the +1 base (pipeline write-back overlap). - * deps : for each GPR this opcode reads/writes, ReadAbsorb[reg]=0 (ends a - * give-back when the loaded value is used). ReadAbsorb[0] preserved. - * DO_LDS : commit the PREVIOUS instruction's pending load TIMING: - * ReadAbsorb[LDWhich]=LDAbsorb; ReadFudge=LDWhich; ...; LDWhich=0x20. - * load only : ReadMemory — clear the current give-back slot, charge the +2 fudge - * iff the predecessor committed no load (ReadFudge==0x20), charge the - * region wait (main RAM +3) + completion (+2 CPU / +1 LWC2); the - * (region+completion) becomes this load's LDAbsorb give-back, armed on - * LDWhich=rt for the NEXT instruction's DO_LDS to commit. - */ -#ifndef PSX_CYC_H -#define PSX_CYC_H - -#include -#include -#if defined(_MSC_VER) -#include /* MSVC intrinsics: _BitScanForward (no __builtin_ctz) */ -#endif -#include "cpu_state.h" /* CPUState (guard-safe: cpu_state.h includes us last) */ -#include "psx_cycles.h" /* inline psx_advance_cycles */ - -#ifdef __cplusplus -extern "C" { -#endif - -/* Load-charge batching (MotK VLC): cache the nearer of the next device - * deadline or a 64-cycle soft publication limit once per batch. Generated - * GCC/Clang blocks may defer to their IRQ edge; MMIO also flushes. Pipeline - * state still updates per instruction, and guest totals at barriers match. */ -enum { PSX_CYC_BATCH_SOFT = 64u }; - -#if defined(PSX_OVERLAY_DLL_BUILD) -/* The overlay shim already batches cycle charges until a host callback edge. */ -static inline void psx_cyc_bb_defer_begin(void) { } -static inline void psx_cyc_bb_defer_end(void) { } -static inline void psx_cyc_bb_defer_flush(void) { overlay_flush_cycles(); } -#else -static inline void psx_cyc_bb_defer_begin(void) { g_psx_cyc_bb_defer++; } -static inline void psx_cyc_bb_defer_end(void) { - if (g_psx_cyc_bb_defer > 0) g_psx_cyc_bb_defer--; - if (g_psx_cyc_bb_defer == 0) psx_cyc_batch_flush(); -} -static inline void psx_cyc_bb_defer_flush(void) { psx_cyc_batch_flush(); } -#endif - -#if defined(__GNUC__) || defined(__clang__) -static inline void psx_cyc_bb_defer_cleanup(int *guard) { - (void)guard; - psx_cyc_bb_defer_end(); -} -#endif - -#if !defined(PSX_OVERLAY_DLL_BUILD) -/* Enhancement-only guest CPU overclock: instruction cycles are charged at - * 1 / (1 << shift). Zero keeps the faithful cost. */ -extern uint32_t g_psx_cyc_overclock_shift; -extern uint32_t g_psx_cyc_overclock_carry; -#endif - -static inline void psx_cyc_charge(uint32_t cycles) { - if (cycles == 0u) return; -#if defined(PSX_OVERLAY_DLL_BUILD) - psx_advance_cycles(cycles); -#else - if (g_psx_cyc_overclock_shift) { - const uint32_t total = g_psx_cyc_overclock_carry + cycles; - g_psx_cyc_overclock_carry = - total & ((1u << g_psx_cyc_overclock_shift) - 1u); - cycles = total >> g_psx_cyc_overclock_shift; - if (cycles == 0u) return; - } -#if defined(__GNUC__) || defined(__clang__) - if (__builtin_expect(g_ls_replay_active | g_event_step_conservative, 0)) { -#else - if (g_ls_replay_active || g_event_step_conservative) { -#endif - psx_advance_cycles(cycles); - return; - } - if (psx_in_device_service) { - psx_cycle_count += (uint64_t)cycles; - return; - } -#if !defined(PSX_COSIM) - { - uint32_t prior = g_psx_cyc_batch; - uint32_t sum = prior + cycles; - if (sum >= prior) { /* no uint32 wrap */ - if (g_psx_cyc_bb_defer > 0) { - g_psx_cyc_batch = sum; - return; - } - if (prior == 0u) { - uint64_t room = 0u; - if (psx_next_service_cycle > psx_cycle_count) - room = psx_next_service_cycle - psx_cycle_count; - g_psx_cyc_batch_limit = room == 0u ? 1u : - (room < (uint64_t)PSX_CYC_BATCH_SOFT - ? (uint32_t)room : (uint32_t)PSX_CYC_BATCH_SOFT); - } - if (sum < g_psx_cyc_batch_limit) { - g_psx_cyc_batch = sum; - return; - } - } - } -#endif - psx_advance_cycles(cycles); /* publishes any pending batch first */ -#endif -} - -/* §1 base (Beetle cpu.cpp:795-798). */ -static inline void psx_cyc_base(CPUState* cpu) { - uint8_t w = cpu->read_absorb_which; - if (cpu->read_absorb[w]) cpu->read_absorb[w]--; - else psx_cyc_charge(1u); -} - -/* GPR_DEPRES (Beetle cpu.cpp:702-705): zero ReadAbsorb[n] for every source/dest - * GPR of this instruction, preserving ReadAbsorb[0] (skipping bit 0 == Beetle's - * save/restore of ReadAbsorb[0]). */ -static inline void psx_cyc_deps(CPUState* cpu, uint32_t reg_mask) { - reg_mask &= 0xFFFFFFFEu; /* never touch ReadAbsorb[0] */ - if (reg_mask && (reg_mask & (reg_mask - 1u)) == 0u) { -#if defined(_MSC_VER) - unsigned long n; - _BitScanForward(&n, reg_mask); - cpu->read_absorb[(unsigned)n] = 0u; -#else - cpu->read_absorb[(unsigned)__builtin_ctz(reg_mask)] = 0u; -#endif - return; - } - while (reg_mask) { -#if defined(_MSC_VER) - unsigned long _psx_ctz_idx; - _BitScanForward(&_psx_ctz_idx, reg_mask); - unsigned n = (unsigned)_psx_ctz_idx; -#else - unsigned n = (unsigned)__builtin_ctz(reg_mask); -#endif - cpu->read_absorb[n] = 0u; - reg_mask &= reg_mask - 1u; - } -} - -/* DO_LDS timing-commit (Beetle cpu.cpp:800). LDWhich==0x20 (no pending) writes the - * dummy slot read_absorb[32] and sets read_fudge=0x20 (=> next load gets +2 fudge). */ -static inline void psx_cyc_lds(CPUState* cpu) { - uint8_t lw = cpu->ld_which_t; - cpu->read_absorb[lw] = (uint8_t)cpu->ld_absorb; - cpu->read_fudge = lw; - cpu->read_absorb_which = (uint8_t)(cpu->read_absorb_which | (lw & 0x1Fu)); - cpu->ld_which_t = 0x20u; -} - -/* Full per-instruction interlock for a NON-CPU-load instruction (ALU, shift, - * branch, jump, store, COP control, LWC2/SWC2 pre-step, mult/div, mfhi/mflo, ...). - * reg_mask from psx_cyc_dep_res_mask(). MUST be emitted BEFORE the instruction - * body so §1 precedes any muldiv/GTE deadline stall in the body (Beetle order). */ -static inline void psx_cyc_step(CPUState* cpu, uint32_t reg_mask) { - psx_cyc_base(cpu); - psx_cyc_deps(cpu, reg_mask); - psx_cyc_lds(cpu); -} - -/* The GPR dep+res bitmask used by psx_cyc_step lives in psx_instr_cost.h - * (psx_cyc_dep_res_mask) — a standalone pure function shared by the emitters - * (gen-time literal) and the interpreter (runtime), with no CPUState dependency. */ - -/* Main-RAM base + load-delay gate (memory.c). Inlined load helpers use these - * so MotK VLC / decode hot paths avoid an out-of-line call per LW/LH. */ -extern uint8_t *g_psx_ram; -extern int g_psx_load_delay; -extern int g_ls_mode; -extern volatile int g_ds_recording; -int psx_load_delay_enabled(void); -uint32_t psx_cyc_load_word_slow(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask); -uint16_t psx_cyc_load_half_slow(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask); - -/* Overlay DLLs route timed loads through the host callback shim so they share - * runtime timing and memory state. Normal generated code keeps the main-RAM - * fast path inline. */ -#if defined(PSX_OVERLAY_DLL_BUILD) -uint32_t psx_cyc_load_word(CPUState* cpu, uint32_t addr, - uint32_t rt, uint32_t reg_mask); -uint16_t psx_cyc_load_half(CPUState* cpu, uint32_t addr, - uint32_t rt, uint32_t reg_mask); -#else -/* CPU data load value+timing. Full Beetle sequence for main RAM is inlined; - * MMIO / lockstep / data-shard fall through to *_slow in memory.c. */ -static inline uint32_t psx_cyc_load_word(CPUState* cpu, uint32_t addr, - uint32_t rt, uint32_t reg_mask) { -#ifdef PSX_ENABLE_BLOCK_CYCLES - uint32_t phys = addr & 0x1FFFFFFFu; - if (g_ls_mode == 0 && !g_ds_recording && phys < 0x00800000u) { - if (g_psx_load_delay < 0) (void)psx_load_delay_enabled(); - if (g_psx_load_delay) { - psx_cyc_base(cpu); - psx_cyc_deps(cpu, reg_mask); - if (cpu->ld_which_t == rt) cpu->ld_which_t = 0u; - psx_cyc_lds(cpu); - cpu->read_absorb[cpu->read_absorb_which] = 0u; - cpu->read_absorb_which = 0u; - uint32_t fudge = (uint32_t)((cpu->read_fudge >> 4) & 2u); - cpu->ld_absorb = 5u; /* main-RAM wait 3 + completion 2 */ - psx_cyc_charge(fudge + 5u); - cpu->ld_which_t = (uint8_t)rt; - } - uint32_t value; - memcpy(&value, g_psx_ram + (phys & 0x1FFFFFu), sizeof(value)); - return value; - } - return psx_cyc_load_word_slow(cpu, addr, rt, reg_mask); -#else - (void)cpu; (void)rt; (void)reg_mask; - extern uint32_t psx_read_word(uint32_t a); - return psx_read_word(addr); -#endif -} - -static inline uint16_t psx_cyc_load_half(CPUState* cpu, uint32_t addr, - uint32_t rt, uint32_t reg_mask) { -#ifdef PSX_ENABLE_BLOCK_CYCLES - uint32_t phys = addr & 0x1FFFFFFFu; - if (g_ls_mode == 0 && !g_ds_recording && phys < 0x00800000u) { - if (g_psx_load_delay < 0) (void)psx_load_delay_enabled(); - if (g_psx_load_delay) { - psx_cyc_base(cpu); - psx_cyc_deps(cpu, reg_mask); - if (cpu->ld_which_t == rt) cpu->ld_which_t = 0u; - psx_cyc_lds(cpu); - cpu->read_absorb[cpu->read_absorb_which] = 0u; - cpu->read_absorb_which = 0u; - uint32_t fudge = (uint32_t)((cpu->read_fudge >> 4) & 2u); - cpu->ld_absorb = 5u; - psx_cyc_charge(fudge + 5u); - cpu->ld_which_t = (uint8_t)rt; - } - uint16_t value; - memcpy(&value, g_psx_ram + (phys & 0x1FFFFFu), sizeof(value)); - return value; - } - return psx_cyc_load_half_slow(cpu, addr, rt, reg_mask); -#else - (void)cpu; (void)rt; (void)reg_mask; - extern uint16_t psx_read_half(uint32_t a); - return psx_read_half(addr); -#endif -} -#endif - -extern uint8_t psx_cyc_load_byte(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask); - -/* Charge the exact timing/pipeline effects of a 32-bit CPU load without - * invoking the memory handler. Enhancement HLE may use this only when static - * analysis proves the loaded value and read side effects are unobservable. */ -extern void psx_cyc_load_word_timing_only(CPUState* cpu, uint32_t addr, - uint32_t rt, uint32_t reg_mask); - -/* LWC2 (GTE load) ReadMemory timing only (completion +1, NO LDWhich arm — the dest - * is a GTE register). Call AFTER psx_cyc_step(cpu,0) (§1+DO_LDS) and psx_gte_stall. - * Returns the raw 32-bit value. */ -extern uint32_t psx_cyc_lwc2_read(CPUState* cpu, uint32_t addr); - -#ifdef __cplusplus -} -#endif - -#endif /* PSX_CYC_H */ diff --git a/psxrecomp-overlay/runtime/include/ws_aspect_cone_math.h b/psxrecomp-overlay/runtime/include/ws_aspect_cone_math.h deleted file mode 100644 index 8355760..0000000 --- a/psxrecomp-overlay/runtime/include/ws_aspect_cone_math.h +++ /dev/null @@ -1,41 +0,0 @@ -#pragma once - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/* Test whether a camera-relative vector is inside a rectangular view cone. - * - * Coordinates use Tomba 2's X,Z,Y ordering. The camera forward vector is Q12. - * `threshold` is the game's Q10 cosine threshold (the SLTI immediate after its - * normalized dot product). `extent_pixels` expands only the horizontal - * half-angle from the vanilla 160-pixel half-width; the vertical half-angle is - * unchanged. - */ -int psx_ws_aspect_cone_contains(int32_t x, int32_t z, int32_t y, - int32_t fx, int32_t fz, int32_t fy, - uint32_t threshold, int extent_pixels); - -/* Widen a positive PSX 12-bit angular half-extent by the same horizontal - * projection scale used by a 320-wide 4:3 viewport. `extent_pixels` is the - * extra per-side reveal (including any configured guard). Identity is exact - * when extent_pixels is zero. Results stay below a 90-degree half-angle. */ -uint32_t psx_ws_widen_angle_q12(uint32_t vanilla, int extent_pixels); - -/* Generic signed counterpart for games whose wrapping yaw uses a different - * unit system. `full_turn` is the number of integer units in 360 degrees. - * The sign is preserved and the magnitude is widened by the same 320-wide - * projection geometry. */ -int32_t psx_ws_widen_angle_units(int32_t vanilla, uint32_t full_turn, - int extent_pixels); - -/* Scale a signed camera-horizontal coordinate into the original 320-wide - * projection before an unchanged side-plane comparison. Identity at zero - * extent; symmetric nearest-integer rounding for negative values. */ -int32_t psx_ws_scale_horizontal_320(int32_t value, int extent_pixels); - -#ifdef __cplusplus -} -#endif diff --git a/psxrecomp-overlay/runtime/src/boot_state.c b/psxrecomp-overlay/runtime/src/boot_state.c deleted file mode 100644 index 7ba1437..0000000 --- a/psxrecomp-overlay/runtime/src/boot_state.c +++ /dev/null @@ -1,477 +0,0 @@ -#include "boot_state.h" -#include "mod_memory.h" - -/* Optional v3 section; old snapshots predate enhancement packet allocations. */ -#define BS_SEC_GPU_DMA_MEMORY 0x0Fu -/* Optional: the instruction cost divider of an enhancement and its carry. An old snapshot resumes at the session's. */ -#define BS_SEC_CPU_COST 0x10u -#include "overlay_api.h" /* PSX_OVERLAY_CODEGEN_HASH / _ABI_TAG / _CODEGEN_VER */ -#include "gpu_render.h" /* gr_vram_transfer_in / gr_vram_transfer_out */ -#include "psx_cycles.h" -#include "pst_wire.h" -#include -#include -#include - -#define RAM_SIZE (2u * 1024u * 1024u) -#define SPAD_SIZE (1024u) -#define VRAM_W 1024 -#define VRAM_H 512 -#define VRAM_SIZE ((uint32_t)(VRAM_W * VRAM_H * 2)) /* 1 MB, 16bpp */ - -/* ---- core accessors (existing runtime modules) ---- */ -extern uint8_t* memory_get_ram_ptr(void); -extern uint8_t* memory_get_scratchpad_ptr(void); -extern uint32_t dirty_ram_get_bitmap_word(uint32_t word_index); -extern uint32_t dirty_ram_get_bitmap_word_count(void); -extern void dirty_ram_set_bitmap_words(const uint32_t* words, uint32_t count); -extern uint32_t i_stat; -extern uint32_t i_mask; -extern uint64_t psx_cycle_count; -extern uint32_t g_psx_cyc_overclock_shift; -extern uint32_t g_psx_cyc_overclock_carry; -extern void timers_get_snapshot(uint16_t counter[3], uint32_t mode[3], - uint16_t target[3], int32_t irq_line[3], - uint32_t frac[3]); -extern void timers_set_snapshot(const uint16_t counter[3], const uint32_t mode[3], - const uint16_t target[3], const int32_t irq_line[3], - const uint32_t frac[3]); - -/* ---- per-subsystem complete-state accessors (defined in each module) ---- */ -extern uint32_t gpu_snapshot_bytes(void); -extern void gpu_snapshot_write(uint8_t* p); -extern int gpu_snapshot_read(const uint8_t* p, uint32_t len); -extern uint32_t spu_snapshot_bytes(void); -extern void spu_snapshot_write(uint8_t* p); -extern int spu_snapshot_read(const uint8_t* p, uint32_t len); -extern uint8_t* spu_get_ram_ptr(void); -extern uint32_t spu_get_ram_bytes(void); -extern uint32_t cdrom_snapshot_bytes(void); -extern void cdrom_snapshot_write(uint8_t* p); -extern int cdrom_snapshot_read(const uint8_t* p, uint32_t len); -extern uint32_t dma_snapshot_bytes(void); -extern void dma_snapshot_write(uint8_t* p); -extern int dma_snapshot_read(const uint8_t* p, uint32_t len); -extern uint32_t sio_snapshot_bytes(void); -extern void sio_snapshot_write(uint8_t* p); -extern int sio_snapshot_read(const uint8_t* p, uint32_t len); - -/* CPU regs wire: 32+3+32+32+32 LE u32 = 131 * 4 = 524 bytes (no padding). */ -#define CPU_REGS_WIRE_BYTES (524u) -/* Timer wire: 3*u16 + 3*u32 + 3*u16 + 3*i32 + 3*u32 = 48 bytes (no pad holes). */ -#define TIMER_REGS_WIRE_BYTES (48u) - -/* ---- deferred capture state (armed before first boot, fired at handoff) ---- */ -static char s_capture_path[512]; -static uint32_t s_capture_checksum; -static uint32_t s_capture_entry_pc; - -static int fwrite_all(FILE* f, const void* p, size_t n) { - return !n || fwrite(p, 1, n, f) == n; -} - -static int write_header_le(FILE* f, const BootStateHeader* h) { - uint8_t buf[BOOT_STATE_HEADER_WIRE_BYTES]; - PstW w; - pst_w_init(&w, buf, sizeof buf); - if (!pst_w_u32(&w, h->magic) || - !pst_w_u32(&w, h->version) || - !pst_w_u32(&w, h->bios_checksum) || - !pst_w_u32(&w, h->entry_pc) || - !pst_w_u32(&w, h->codegen_hash) || - !pst_w_i32(&w, h->abi_tag) || - !pst_w_u32(&w, h->codegen_ver) || - !pst_w_u32(&w, h->section_count) || - !pst_w_u32(&w, h->reserved) || - w.written != BOOT_STATE_HEADER_WIRE_BYTES) - return 0; - return fwrite_all(f, buf, sizeof buf); -} - -static int read_header_le(FILE* f, BootStateHeader* h) { - uint8_t buf[BOOT_STATE_HEADER_WIRE_BYTES]; - PstR r; - if (fread(buf, 1, sizeof buf, f) != sizeof buf) return 0; - pst_r_init(&r, buf, sizeof buf); - memset(h, 0, sizeof *h); - if (!pst_r_u32(&r, &h->magic) || - !pst_r_u32(&r, &h->version) || - !pst_r_u32(&r, &h->bios_checksum) || - !pst_r_u32(&r, &h->entry_pc) || - !pst_r_u32(&r, &h->codegen_hash) || - !pst_r_i32(&r, &h->abi_tag) || - !pst_r_u32(&r, &h->codegen_ver) || - !pst_r_u32(&r, &h->section_count) || - !pst_r_u32(&r, &h->reserved)) - return 0; - return 1; -} - -static int write_section(FILE* f, uint32_t tag, const void* data, uint64_t len) { - uint8_t hdr[16]; - PstW w; - pst_w_init(&w, hdr, sizeof hdr); - if (!pst_w_u32(&w, tag) || !pst_w_u32(&w, 0u) || !pst_w_u64(&w, len)) - return 0; - if (!fwrite_all(f, hdr, sizeof hdr)) return 0; - if (len && !fwrite_all(f, data, (size_t)len)) return 0; - return 1; -} - -static int write_module_section(FILE* f, uint32_t tag, - uint32_t (*bytes)(void), - void (*write)(uint8_t*)) { - uint32_t n = bytes(); - uint8_t* buf = (uint8_t*)malloc(n ? n : 1); - if (!buf) return 0; - write(buf); - int ok = write_section(f, tag, buf, n); - free(buf); - return ok; -} - -static int write_cpu_section(FILE* f, const CPUState* cpu) { - uint8_t buf[CPU_REGS_WIRE_BYTES]; - PstW w; - pst_w_init(&w, buf, sizeof buf); - for (int i = 0; i < 32; i++) - if (!pst_w_u32(&w, cpu->gpr[i])) return 0; - if (!pst_w_u32(&w, cpu->pc) || !pst_w_u32(&w, cpu->hi) || !pst_w_u32(&w, cpu->lo)) - return 0; - for (int i = 0; i < 32; i++) - if (!pst_w_u32(&w, cpu->cop0[i])) return 0; - for (int i = 0; i < 32; i++) - if (!pst_w_u32(&w, cpu->gte_data[i])) return 0; - for (int i = 0; i < 32; i++) - if (!pst_w_u32(&w, cpu->gte_ctrl[i])) return 0; - if (w.written != CPU_REGS_WIRE_BYTES) return 0; - return write_section(f, BS_SEC_CPU, buf, CPU_REGS_WIRE_BYTES); -} - -static int write_timer_section(FILE* f) { - uint16_t counter[3], target[3]; - uint32_t mode[3], frac[3]; - int32_t irq_line[3]; - uint8_t buf[TIMER_REGS_WIRE_BYTES]; - PstW w; - timers_get_snapshot(counter, mode, target, irq_line, frac); - pst_w_init(&w, buf, sizeof buf); - for (int i = 0; i < 3; i++) - if (!pst_w_u16(&w, counter[i])) return 0; - for (int i = 0; i < 3; i++) - if (!pst_w_u32(&w, mode[i])) return 0; - for (int i = 0; i < 3; i++) - if (!pst_w_u16(&w, target[i])) return 0; - for (int i = 0; i < 3; i++) - if (!pst_w_i32(&w, irq_line[i])) return 0; - for (int i = 0; i < 3; i++) - if (!pst_w_u32(&w, frac[i])) return 0; - if (w.written != TIMER_REGS_WIRE_BYTES) return 0; - return write_section(f, BS_SEC_TIMER, buf, TIMER_REGS_WIRE_BYTES); -} - -/* ============================ SAVE ============================ */ - -int boot_state_save(const CPUState* cpu, uint32_t bios_checksum, - uint32_t entry_pc, const char* path) { - FILE* f = fopen(path, "wb"); - if (!f) return 0; - - BootStateHeader h; - memset(&h, 0, sizeof h); - h.magic = BOOT_STATE_MAGIC; - h.version = BOOT_STATE_VERSION; - h.bios_checksum = bios_checksum; - h.entry_pc = entry_pc; - h.codegen_hash = (uint32_t)PSX_OVERLAY_CODEGEN_HASH; - h.abi_tag = (int32_t)PSX_OVERLAY_ABI_TAG; - h.codegen_ver = (uint32_t)PSX_OVERLAY_CODEGEN_VER; - h.section_count = 16; - - int ok = write_header_le(f, &h); - - if (ok) ok = write_cpu_section(f, cpu); - if (ok) ok = write_section(f, BS_SEC_RAM, memory_get_ram_ptr(), RAM_SIZE); - if (ok) ok = write_section(f, BS_SEC_SPAD, memory_get_scratchpad_ptr(), SPAD_SIZE); - if (ok) { - uint8_t irq[8]; - PstW w; - pst_w_init(&w, irq, sizeof irq); - ok = pst_w_u32(&w, i_stat) && pst_w_u32(&w, i_mask) && - write_section(f, BS_SEC_IRQ, irq, 8); - } - if (ok) ok = write_timer_section(f); - if (ok) { - uint8_t cyc[8]; - PstW w; - pst_w_init(&w, cyc, sizeof cyc); - /* Publish deferred load-charge batch before snapshotting the clock. */ - psx_cyc_batch_flush(); - ok = pst_w_u64(&w, psx_cycle_count) && - write_section(f, BS_SEC_CLOCK, cyc, 8); - } - if (ok) ok = write_module_section(f, BS_SEC_GPU, gpu_snapshot_bytes, gpu_snapshot_write); - if (ok) { - uint16_t* vbuf = (uint16_t*)malloc(VRAM_SIZE); - if (!vbuf) ok = 0; - else { - gr_vram_transfer_out(0, 0, VRAM_W, VRAM_H, vbuf); - /* VRAM is uint16 LE guest pixels — emit as LE u16 stream. */ - uint8_t* wire = (uint8_t*)malloc(VRAM_SIZE); - if (!wire) ok = 0; - else { - PstW w; - pst_w_init(&w, wire, VRAM_SIZE); - ok = pst_w_pod(&w, vbuf, VRAM_SIZE, 2) && - write_section(f, BS_SEC_VRAM, wire, VRAM_SIZE); - free(wire); - } - free(vbuf); - } - } - if (ok) ok = write_module_section(f, BS_SEC_SPU, spu_snapshot_bytes, spu_snapshot_write); - if (ok) ok = write_section(f, BS_SEC_SPURAM, spu_get_ram_ptr(), spu_get_ram_bytes()); - if (ok) ok = write_module_section(f, BS_SEC_CDROM, cdrom_snapshot_bytes, cdrom_snapshot_write); - if (ok) ok = write_module_section(f, BS_SEC_DMA, dma_snapshot_bytes, dma_snapshot_write); - if (ok) ok = write_module_section(f, BS_SEC_SIO, sio_snapshot_bytes, sio_snapshot_write); - if (ok) { - uint32_t wc = dirty_ram_get_bitmap_word_count(); - uint64_t nbytes = (uint64_t)wc * 4u; - uint8_t* db = (uint8_t*)malloc(nbytes ? (size_t)nbytes : 1); - if (!db) ok = 0; - else { - PstW w; - pst_w_init(&w, db, (size_t)nbytes); - ok = 1; - for (uint32_t i = 0; ok && i < wc; i++) - ok = pst_w_u32(&w, dirty_ram_get_bitmap_word(i)); - if (ok) ok = write_section(f, BS_SEC_DIRTY, db, nbytes); - free(db); - } - } - - if (ok) ok = write_section(f, BS_SEC_GPU_DMA_MEMORY, - psx_mod_gpu_dma_memory_data(), - psx_mod_gpu_dma_memory_bytes()); - if (ok) { - uint8_t cost[8]; - PstW w; - pst_w_init(&w, cost, sizeof cost); - ok = pst_w_u32(&w, g_psx_cyc_overclock_shift) && - pst_w_u32(&w, g_psx_cyc_overclock_carry) && - write_section(f, BS_SEC_CPU_COST, cost, sizeof cost); - } - - fclose(f); - if (!ok) - remove(path); - return ok; -} - -/* ============================ LOAD ============================ */ - -static int apply_section(uint32_t tag, const uint8_t* p, uint32_t len, - CPUState* cpu, uint32_t entry_pc) { - switch (tag) { - case BS_SEC_CPU: { - PstR r; - if (len != CPU_REGS_WIRE_BYTES) return 0; - pst_r_init(&r, p, len); - for (int i = 0; i < 32; i++) - if (!pst_r_u32(&r, &cpu->gpr[i])) return 0; - if (!pst_r_u32(&r, &cpu->pc) || !pst_r_u32(&r, &cpu->hi) || - !pst_r_u32(&r, &cpu->lo)) - return 0; - (void)entry_pc; - for (int i = 0; i < 32; i++) - if (!pst_r_u32(&r, &cpu->cop0[i])) return 0; - for (int i = 0; i < 32; i++) - if (!pst_r_u32(&r, &cpu->gte_data[i])) return 0; - for (int i = 0; i < 32; i++) - if (!pst_r_u32(&r, &cpu->gte_ctrl[i])) return 0; - return 1; - } - case BS_SEC_GPU_DMA_MEMORY: - return psx_mod_gpu_dma_memory_restore(p, len); - case BS_SEC_CPU_COST: { - PstR r; - uint32_t shift, carry; - if (len != 8) return 0; - pst_r_init(&r, p, len); - /* A wait drops the divider and leaves the carry for the next frame, so the carry is not held to the divider. */ - if (!pst_r_u32(&r, &shift) || !pst_r_u32(&r, &carry) || shift > 31u || (carry >> 31) != 0u) return 0; - g_psx_cyc_overclock_shift = shift; - g_psx_cyc_overclock_carry = carry; - return 1; - } - case BS_SEC_RAM: - if (len != RAM_SIZE) return 0; - memcpy(memory_get_ram_ptr(), p, RAM_SIZE); - { - extern void psx_kernel_bless_note_range(uint32_t phys, uint32_t l); - psx_kernel_bless_note_range(0, RAM_SIZE); - } - return 1; - case BS_SEC_SPAD: - if (len != SPAD_SIZE) return 0; - memcpy(memory_get_scratchpad_ptr(), p, SPAD_SIZE); - return 1; - case BS_SEC_IRQ: { - PstR r; - uint32_t st, mk; - if (len != 8) return 0; - pst_r_init(&r, p, len); - if (!pst_r_u32(&r, &st) || !pst_r_u32(&r, &mk)) return 0; - i_stat = st; - i_mask = mk; - return 1; - } - case BS_SEC_TIMER: { - uint16_t counter[3], target[3]; - uint32_t mode[3], frac[3]; - int32_t irq_line[3]; - PstR r; - if (len != TIMER_REGS_WIRE_BYTES) return 0; - pst_r_init(&r, p, len); - for (int i = 0; i < 3; i++) - if (!pst_r_u16(&r, &counter[i])) return 0; - for (int i = 0; i < 3; i++) - if (!pst_r_u32(&r, &mode[i])) return 0; - for (int i = 0; i < 3; i++) - if (!pst_r_u16(&r, &target[i])) return 0; - for (int i = 0; i < 3; i++) - if (!pst_r_i32(&r, &irq_line[i])) return 0; - for (int i = 0; i < 3; i++) - if (!pst_r_u32(&r, &frac[i])) return 0; - timers_set_snapshot(counter, mode, target, irq_line, frac); - return 1; - } - case BS_SEC_CLOCK: { - PstR r; - uint64_t cyc; - if (len != 8) return 0; - pst_r_init(&r, p, len); - if (!pst_r_u64(&r, &cyc)) return 0; - psx_cycle_count = cyc; - return 1; - } - case BS_SEC_GPU: - return gpu_snapshot_read(p, len); - case BS_SEC_VRAM: { - uint16_t* vbuf; - PstR r; - if (len != VRAM_SIZE) return 0; - vbuf = (uint16_t*)malloc(VRAM_SIZE); - if (!vbuf) return 0; - pst_r_init(&r, p, len); - if (!pst_r_pod(&r, vbuf, VRAM_SIZE, 2)) { - free(vbuf); - return 0; - } - gr_vram_transfer_in(0, 0, VRAM_W, VRAM_H, vbuf); - free(vbuf); - return 1; - } - case BS_SEC_SPU: - return spu_snapshot_read(p, len); - case BS_SEC_SPURAM: - if (len != spu_get_ram_bytes()) return 0; - memcpy(spu_get_ram_ptr(), p, len); - return 1; - case BS_SEC_CDROM: - return cdrom_snapshot_read(p, len); - case BS_SEC_DMA: - return dma_snapshot_read(p, len); - case BS_SEC_SIO: - return sio_snapshot_read(p, len); - case BS_SEC_DIRTY: { - uint32_t wc; - uint32_t* words; - PstR r; - if (len % 4u) return 0; - wc = len / 4u; - words = (uint32_t*)malloc(len ? len : 1); - if (!words) return 0; - pst_r_init(&r, p, len); - for (uint32_t i = 0; i < wc; i++) { - if (!pst_r_u32(&r, &words[i])) { - free(words); - return 0; - } - } - dirty_ram_set_bitmap_words(words, wc); - free(words); - return 1; - } - default: - return 0; - } -} - -int boot_state_load(const char* path, uint32_t bios_checksum, - uint32_t entry_pc, CPUState* cpu) { - FILE* f = fopen(path, "rb"); - if (!f) return 0; - - BootStateHeader h; - if (!read_header_le(f, &h)) { fclose(f); return 0; } - - if (h.magic != BOOT_STATE_MAGIC || - h.version != BOOT_STATE_VERSION || - h.bios_checksum != bios_checksum || - h.entry_pc != entry_pc || - h.codegen_hash != (uint32_t)PSX_OVERLAY_CODEGEN_HASH || - h.abi_tag != (int32_t)PSX_OVERLAY_ABI_TAG || - h.codegen_ver != (uint32_t)PSX_OVERLAY_CODEGEN_VER) { - fclose(f); - return 0; - } - - const uint32_t required = - (1u< 64u * 1024u * 1024u) { ok = 0; break; } - uint8_t* buf = (uint8_t*)malloc(len ? (size_t)len : 1); - if (!buf) { ok = 0; break; } - if (len && fread(buf, 1, (size_t)len, f) != (size_t)len) { free(buf); ok = 0; break; } - if (!apply_section(tag, buf, (uint32_t)len, cpu, entry_pc)) ok = 0; - else if (tag < 32) seen |= (1u << tag); - free(buf); - } - fclose(f); - - if (!ok || (seen & required) != required) - return 0; - if (!(seen & (1u << BS_SEC_GPU_DMA_MEMORY))) - psx_mod_gpu_dma_memory_restore(NULL, 0u); - return 1; -} - -void boot_state_set_capture(const char* path, uint32_t bios_checksum, - uint32_t entry_pc) { - strncpy(s_capture_path, path, sizeof(s_capture_path) - 1); - s_capture_path[sizeof(s_capture_path) - 1] = '\0'; - s_capture_checksum = bios_checksum; - s_capture_entry_pc = entry_pc; -} - -void boot_state_trigger_capture(const CPUState* cpu) { - if (!s_capture_path[0]) return; - boot_state_save(cpu, s_capture_checksum, s_capture_entry_pc, s_capture_path); - s_capture_path[0] = '\0'; -} diff --git a/psxrecomp-overlay/runtime/src/dirty_ram_interp.c b/psxrecomp-overlay/runtime/src/dirty_ram_interp.c deleted file mode 100644 index e988e67..0000000 --- a/psxrecomp-overlay/runtime/src/dirty_ram_interp.c +++ /dev/null @@ -1,4039 +0,0 @@ -/* dirty_ram_interp.c — small MIPS interpreter for install-at-runtime RAM. - * - * See CLAUDE.md Rule 18, docs/dynamic_handler_install.md, and the inline - * note in memory.c (search for "Option B") for the architectural rationale. - * - * Scope: only fires when psx_dispatch lands on a PC whose page has been - * written-to since boot. Runs one basic block (terminator: jr/jalr/j/jal - * or branch) and returns; dispatch trampoline re-enters for the next block. - * - * Strict policy: any opcode not implemented here aborts fatally. This - * surfaces unknown install patterns immediately so we expand the support - * set deliberately, never silently. - * - * Future option (Option B, see docs/dynamic_handler_install.md): JIT-compile - * dirty pages via the existing StrictTranslator instead of interpreting. - * Pros: single source of MIPS semantics shared with the build-time path, - * native-speed install stubs, generalizes to game JIT cases. Cons: gcc-at- - * runtime build dep, ~200 ms compile latency stall on first dispatch, file - * I/O on hot path, cache-invalidation complexity, Windows MinGW + dlopen - * friction. Today install stubs are cold-path glue (~4k instructions per - * directory-load); interpretation is sub-microsecond and the right fit. - * Revisit if measurement shows install-stub instructions becoming a - * meaningful fraction of total runtime work. - */ - -#include "dirty_ram_interp.h" -#include "gte_precision.h" -#include "cpu_state.h" -#include "debug_server.h" -#include "interrupts.h" -#include "psx_cycles.h" -#include "psx_icache.h" -#include "psx_instr_cost.h" /* psx_instr_base_cycles — single-source cycle cost */ -#include "gpu.h" /* psx_ws_is_backdrop_site / psx_ws_backdrop_x (interp hook) */ -#include "ws_backdrop_detect.h" /* shared backdrop-window detector (auto_backdrop) */ -#include "lockstep.h" -#include "starvation_ring.h" - -#include -#include -#include - -uint64_t g_dirty_ram_blocks_run = 0; -uint64_t g_dirty_ram_insns_run = 0; -uint64_t g_dirty_window_dispatches = 0; /* capture-window interp dispatches */ -uint64_t g_dirty_ram_aborts = 0; -uint64_t g_dirty_ram_guard_yields = 0; -uint64_t g_dirty_ram_native_handoffs = 0; - -/* One sample per 256 interpreted instructions identifies stable hot loops - * without turning production diagnostics into another per-insn profiler. The - * physical-PC direct table has no hash collisions and is touched only while - * PSX_RUNTIME_PERF_DIAG is enabled. */ -#define DIRTY_RAM_PERF_SAMPLE_SHIFT 8u -#define DIRTY_RAM_PERF_SAMPLE_STRIDE (1u << DIRTY_RAM_PERF_SAMPLE_SHIFT) -static int s_dirty_ram_perf_hot_enabled = 0; -static uint32_t s_dirty_ram_perf_hot_samples[DIRTY_RAM_EXEC_WORD_COUNT]; - -void dirty_ram_perf_hot_enable(int enabled) { - s_dirty_ram_perf_hot_enabled = enabled ? 1 : 0; - memset(s_dirty_ram_perf_hot_samples, 0, - sizeof(s_dirty_ram_perf_hot_samples)); -} - -static inline void dirty_ram_perf_hot_record(uint32_t pc) { - if (!s_dirty_ram_perf_hot_enabled || - (g_dirty_ram_insns_run & (DIRTY_RAM_PERF_SAMPLE_STRIDE - 1u)) != 0u) - return; - uint32_t phys = pc & 0x1FFFFFFFu; - if (phys >= 2u * 1024u * 1024u || (phys & 3u) != 0u) return; - uint32_t *slot = &s_dirty_ram_perf_hot_samples[phys >> 2]; - if (*slot != UINT32_MAX) (*slot)++; -} - -void dirty_ram_perf_take_hot(uint32_t pcs[DIRTY_RAM_PERF_HOT_COUNT], - uint32_t samples[DIRTY_RAM_PERF_HOT_COUNT], - uint32_t *stride) { - uint32_t top_pc[DIRTY_RAM_PERF_HOT_COUNT] = {0}; - uint32_t top_n[DIRTY_RAM_PERF_HOT_COUNT] = {0}; - for (uint32_t word = 0; word < DIRTY_RAM_EXEC_WORD_COUNT; word++) { - uint32_t n = s_dirty_ram_perf_hot_samples[word]; - if (n == 0u || n <= top_n[DIRTY_RAM_PERF_HOT_COUNT - 1u]) continue; - uint32_t at = DIRTY_RAM_PERF_HOT_COUNT - 1u; - while (at > 0u && n > top_n[at - 1u]) { - top_n[at] = top_n[at - 1u]; - top_pc[at] = top_pc[at - 1u]; - at--; - } - top_n[at] = n; - top_pc[at] = word << 2; - } - memset(s_dirty_ram_perf_hot_samples, 0, - sizeof(s_dirty_ram_perf_hot_samples)); - if (pcs) memcpy(pcs, top_pc, sizeof(top_pc)); - if (samples) memcpy(samples, top_n, sizeof(top_n)); - if (stride) *stride = DIRTY_RAM_PERF_SAMPLE_STRIDE; -} -/* Scheduling-contract pump telemetry (see dirty_ram_dispatch). Always-on: - * g_dirty_pump_max_gap_insns is the largest interpreted-insn gap ever seen - * between two interrupt pumps — must stay bounded (~4096 + one block) once the - * region-independent pump is in place; a runaway value means a dirty path is - * advancing cycles without surfacing to psx_check_interrupts (the softlock). */ -uint64_t g_dirty_pump_max_gap_insns = 0; -uint64_t g_dirty_pump_count = 0; -static uint64_t s_last_dirty_irq_pump_insns = 0; - -/* EPC de-overload signal (Tomba 2 frame-1997 fix). Set to the committed guest PC - * immediately around a dirty-pump psx_check_interrupts() call, 0 otherwise. When - * non-zero at exception entry, the interrupt is delivered at a dirty-interp safe point - * with a precise guest resume PC, so psx_check_interrupts sets COP0_EPC to the REAL PC - * (architectural RFE resume) instead of the host sentinel 0x80000048. The TCB then - * saves the real PC, so a game-installed handler that drives ReturnFromException - * itself (sync OR async) resumes correctly. Compiled-code block-boundary pumps leave - * this 0 -> the sentinel + host-GPR-restore + longjmp path (unchanged: T1/MMX6/Ape). */ -uint32_t g_dirty_safe_resume_pc = 0; - -/* Cycle-budgeted precise event slicing (PRECISE_IRQ_SLICE.md). Set while the - * block-leader slice guard is running a block through the per-instruction - * interpreter so the interrupt is taken at the EXACT architectural instruction - * (not the coarse block edge). While set, exec_one's jal/jalr short-circuit to a - * plain transfer (cpu->pc = target; return 1) so precise-mode steps INTO callees - * per-instruction instead of running them compiled — the "ignore native - * availability" invariant (ChatGPT-validated option b). Default 0 => every other - * path is byte-for-byte unchanged. */ -int g_precise_mode = 0; -/* #2 lockstep: set ONLY around the dirty-interp loop's per-instruction - * cyc_observe so the lockstep comparator can tell a real COMPILED block leader - * (g_ls_dirty_observe==0) from an interpreted per-instruction sample (==1). */ -int g_ls_dirty_observe = 0; -extern int g_ls_replay_active; /* defined in the lockstep section; used by exec_one's jal/jalr guard */ - -/* Interpreter-only production fast path. Do not put this in psx_cyc.h: - * generated overlay DLLs deliberately batch psx_advance_cycles() through a - * DLL-local accumulator and must never bind directly to runtime cycle state. */ -#if defined(PSX_NO_DEBUG_TOOLS) && !defined(PSX_COSIM) && !STARVATION_RING_ENABLED -extern uint64_t g_psx_cycle_fast_limit; -extern int g_event_step_conservative; - -static inline void interp_cyc_step(CPUState *cpu, uint32_t reg_mask) { - uint8_t w = cpu->read_absorb_which; - if (cpu->read_absorb[w]) { - cpu->read_absorb[w]--; - } else if (!g_ls_replay_active) { - uint64_t next = psx_cycle_count + 1u; - if (!g_event_step_conservative && g_psx_cycle_fast_limit != 0u && - next <= g_psx_cycle_fast_limit) { - psx_cycle_count = next; - } else { - psx_advance_cycles(1u); - } - } - psx_cyc_deps(cpu, reg_mask); - psx_cyc_lds(cpu); -} -#else -static inline void interp_cyc_step(CPUState *cpu, uint32_t reg_mask) { - psx_cyc_step(cpu, reg_mask); -} -#endif - -#ifdef PSX_COSIM -static int g_cosim_exec_one_hooked = 0; -static void cosim_exec_one_begin(void) { g_cosim_exec_one_hooked = 0; } -static int cosim_exec_one_did_hook(void) { return g_cosim_exec_one_hooked; } -static void cosim_exec_one_transfer_hook(uint32_t pc) { - extern void cosim_instr(uint32_t); - cosim_instr(pc); - g_cosim_exec_one_hooked = 1; -} -#else -static void cosim_exec_one_begin(void) {} -static int cosim_exec_one_did_hook(void) { return 0; } -static void cosim_exec_one_transfer_hook(uint32_t pc) { (void)pc; } -#endif -uint64_t g_slice_fired = 0; /* diagnostic: slices actually run */ -uint64_t g_slice_irq_taken = 0; /* diagnostic: IRQs taken inside precise-mode */ -/* First-divergence trace for the precise slice (PRECISE_IRQ_SLICE.md Task #4). */ -uint32_t g_slice_last_block = 0; /* block_addr the guard fired on */ -uint32_t g_slice_last_first_pc = 0; /* pc of the first interp instruction */ -uint32_t g_slice_last_first_insn= 0; /* raw word of that first instruction */ -uint32_t g_slice_last_committed = 0; /* committed PC handed back as resume/EPC */ -uint32_t g_slice_last_istat = 0; /* i_stat at the take */ -uint32_t g_slice_last_imask = 0; /* i_mask at the take */ -uint32_t g_slice_last_sr = 0; /* COP0 SR at the take */ -uint32_t g_slice_entry_deliverable = 0; /* was IRQ deliverable at slice entry? */ -uint32_t g_slice_exit_pc = 0; /* PC published by the last precise slice */ -uint32_t g_slice_exit_reason = 0; /* 1=safe, 2=unsupported, 3=bail, 4=guard */ -uint32_t g_slice_exit_iter = 0; /* loop iteration at exit */ -uint32_t g_slice_exit_dispatchable = 0; /* precise_pc_dispatchable(exit_pc) */ -uint32_t g_slice_exit_dirty = 0; /* dirty_ram_is_dirty(exit_pc phys) */ -uint32_t g_slice_exit_in_text = 0; /* psx_game_address_in_text(exit_pc) */ -uint32_t g_slice_exit_want = 0; /* want_exit value at exit */ - -/* Persistent async-RFE resume PC (Tomba 2 frame-1997 fix). psx_check_interrupts latches - * this from g_dirty_safe_resume_pc at each dirty-safe-point exception entry, so it holds - * the real guest PC of the most recent dirty-interp interruption. When a game-installed - * handler drives ReturnFromException ASYNCHRONOUSLY (sentinel RFE with in_exception==0), - * the sentinel gates resume the guest here instead of resolving to pc=0 (abnormal exit). - * Unlike g_dirty_safe_resume_pc (transient, scoped to the pump call) this persists across - * the gap between the interrupt and the game's later ReturnFromException. */ -uint32_t g_async_rfe_resume_pc = 0; -/* Diagnostics for the async-RFE fix: times the resume PC was latched at a dirty-safe - * exception entry, and times the sentinel gates actually redirected to it. */ -uint64_t g_async_rfe_set_count = 0; -uint64_t g_async_rfe_fire_count = 0; -/* Reach diagnostics: which gate the sentinel dispatch lands in, and what - * g_async_rfe_resume_pc held there. */ -uint64_t g_sentinel_reach_dirty = 0; -uint64_t g_sentinel_reach_traps = 0; -uint32_t g_sentinel_reach_async = 0; - -/* One-shot pc=0 producer tripwire (MMX6 boot-wedge investigation, Task #4 family). - * Latches the FIRST time dirty_ram_dispatch returns "handled" (r==1) but leaves - * cpu->pc == 0 — i.e. the dirty path produced the abnormal-exit null PC rather - * than psx_unknown_dispatch (which has its own fail-fast). Captures the dispatch - * addr + interrupt-return context so the exact producing path is identifiable - * without printf. Surfaced in freeze_check (pczero_*). */ -int g_cosim_dirty_pump_site = 0; /* PSX_COSIM diagnostic: current dirty IRQ pump site */ -int g_pczero_latched = 0; -uint32_t g_pczero_addr = 0; -uint32_t g_pczero_ra = 0; -uint32_t g_pczero_in_exc = 0; -uint32_t g_pczero_async_rfe = 0; -uint32_t g_pczero_dirty_safe = 0; -uint64_t g_pczero_count = 0; - -/* Mid-block unsupported-opcode counters. Bumped instead of fprintf-spamming - * stderr (CLAUDE.md §3). Read via dirty_ram_get_unsupported(). The "last_*" - * fields capture the most recent occurrence so a TCP query can see what - * opcode is missing without needing log scraping. */ -uint64_t g_dirty_ram_unsupported_midblock = 0; -uint32_t g_dirty_ram_last_unsupported_entry = 0; -uint32_t g_dirty_ram_last_unsupported_entry_ra = 0; -uint32_t g_dirty_ram_last_unsupported_entry_sp = 0; -uint32_t g_dirty_ram_last_unsupported_insns = 0; -uint32_t g_dirty_ram_last_unsupported_pc = 0; -uint32_t g_dirty_ram_last_unsupported_insn = 0; -const char *g_dirty_ram_last_unsupported_reason = NULL; - -DirtyRamPcEntry g_dirty_ram_pc_table[DIRTY_RAM_PC_TABLE_SIZE] = {0}; -uint32_t g_dirty_ram_exec_pc_bitmap[DIRTY_RAM_EXEC_BITMAP_WORDS] = {0}; -uint32_t g_dirty_ram_exec_page_bitmap[DIRTY_RAM_EXEC_PAGE_BITMAP_WORDS] = {0}; -uint32_t g_dirty_ram_dispatch_pc_bitmap[DIRTY_RAM_EXEC_BITMAP_WORDS] = {0}; - -DirtyRamBlockLogEntry g_dirty_ram_block_log[DIRTY_RAM_BLOCK_LOG_CAP] = {0}; -uint64_t g_dirty_ram_block_log_seq = 0; -DirtyRamFlowLogEntry g_dirty_ram_flow_log[DIRTY_RAM_FLOW_LOG_CAP] = {0}; -uint64_t g_dirty_ram_flow_log_seq = 0; -DirtyRamInsnLogEntry g_dirty_ram_insn_log[DIRTY_RAM_INSN_LOG_CAP] = {0}; -uint64_t g_dirty_ram_insn_log_seq = 0; -ParappaRhythmEvent g_parappa_rhythm_events[PARAPPA_RHYTHM_EVENT_CAP] = {0}; -uint64_t g_parappa_rhythm_event_seq = 0; -int g_parappa_timing_mode = 0; -int g_parappa_timing_extra_early = 0; -int g_parappa_timing_extra_late = 0; -static uint32_t s_parappa_recent_judge_obj = 0; -static uint32_t s_parappa_recent_judge_idx = 0; -static uint32_t s_parappa_recent_judge_mask = 0; -static uint32_t s_parappa_recent_judge_frame = 0; - -void parappa_timing_window_reset(void) { - s_parappa_recent_judge_obj = 0; - s_parappa_recent_judge_idx = 0; - s_parappa_recent_judge_mask = 0; - s_parappa_recent_judge_frame = 0; -} - -void parappa_rhythm_events_reset(void) { - g_parappa_rhythm_event_seq = 0; - memset(g_parappa_rhythm_events, 0, sizeof(g_parappa_rhythm_events)); - parappa_timing_window_reset(); -} - -/* Current frame counter, defined in debug_server.c. */ -extern uint64_t s_frame_count; - -/* Linear-probed insert/lookup keyed on entry PC. Probe length is HARD - * BOUNDED: a saturated table must degrade to "stop tracking" (NULL) at O(1) - * cost, never to an O(table) scan per lookup. Tomba2's Trolley attract demo - * overflowed the old unbounded probe (working set > table size) and burned - * ~99% of the host emu thread rescanning 64K slots per executed instruction - * (0.3 fps). 128 probes keeps misses vanishingly rare below ~85% fill with - * this hash; past that the tracking data was already degrading anyway. */ -static DirtyRamPcEntry *pc_table_get_or_insert_in(DirtyRamPcEntry *table, uint32_t pc) { - uint32_t h = (pc * 2654435761u) & (DIRTY_RAM_PC_TABLE_SIZE - 1); - for (uint32_t i = 0; i < 128u; i++) { - uint32_t idx = (h + i) & (DIRTY_RAM_PC_TABLE_SIZE - 1); - DirtyRamPcEntry *e = &table[idx]; - if (e->pc == pc) return e; - if (e->pc == 0) { e->pc = pc; return e; } - } - return NULL; -} - -static DirtyRamPcEntry *pc_table_get_or_insert(uint32_t pc) { - return pc_table_get_or_insert_in(g_dirty_ram_pc_table, pc); -} - -/* Record every PC the interpreter executes (not just block entries) so - * overlay_capture can report execution-verified seeds for the region. A PSX - * instruction is aligned, making this a single direct bitmap OR rather than a - * cache-unfriendly open-addressed lookup on every guest instruction. */ -static inline void exec_pc_table_record(uint32_t pc) { - uint32_t phys = pc & 0x1FFFFFFFu; - if (phys < 2u * 1024u * 1024u && (phys & 3u) == 0u) { - uint32_t word = phys >> 2; - uint32_t mask = 1u << (word & 31u); - uint32_t *slot = &g_dirty_ram_exec_pc_bitmap[word >> 5]; - if ((*slot & mask) == 0u) { - *slot |= mask; - uint32_t page = phys >> 12; - g_dirty_ram_exec_page_bitmap[page >> 5] |= 1u << (page & 31u); - } - } -} - -/* From debug_server.c — keep our outer-frame attribution coherent. */ -extern uint32_t g_debug_current_func_addr; -extern uint32_t g_debug_last_store_pc; - -static int parappa_rhythm_store_site(uint32_t pc) { - switch (pc & 0x1FFFFFFFu) { - case 0x001C7ACC: case 0x001C7B24: case 0x001C7B38: - case 0x001C7CAC: case 0x001C7CB0: case 0x001C7CC4: - case 0x001C7CE4: case 0x001C7CE8: case 0x001C7D04: - case 0x001C7D6C: case 0x001C7DD0: case 0x001C7DD8: - case 0x001C7DE0: case 0x001C7DEC: case 0x001C7E10: - case 0x001C7E58: case 0x001C7EB4: case 0x001C7ED0: - case 0x001C7ED8: case 0x001C7F20: case 0x001C7F34: - case 0x001C7F40: case 0x001C7F44: case 0x001C80F0: - case 0x001C8878: case 0x001C8894: case 0x001C88B0: - case 0x001C88B8: case 0x001C91D8: case 0x001C91F8: - return 1; - default: - return 0; - } -} - -static uint32_t parappa_read_u32(CPUState *cpu, uint32_t addr) { - return cpu->read_word(addr); -} - -static uint32_t parappa_read_i16(CPUState *cpu, uint32_t addr) { - return (uint32_t)(int32_t)(int16_t)cpu->read_half(addr); -} - -static ParappaRhythmEvent *parappa_rhythm_next_event(CPUState *cpu, - uint32_t pc) { - uint64_t s = g_parappa_rhythm_event_seq++; - ParappaRhythmEvent *e = - &g_parappa_rhythm_events[s & (PARAPPA_RHYTHM_EVENT_CAP - 1u)]; - e->seq = s; - e->frame = (uint32_t)s_frame_count; - e->pc = pc & 0x1FFFFFFFu; - e->obj = cpu->gpr[17]; - e->ra = cpu->gpr[31]; - e->s5 = cpu->gpr[21]; - e->s6 = cpu->gpr[22]; - e->g_800901bc = parappa_read_u32(cpu, 0x800901BCu); - e->g_800901c0 = parappa_read_u32(cpu, 0x800901C0u); - e->g_800916d0 = parappa_read_i16(cpu, 0x800916D0u); - e->g_800916d8 = parappa_read_i16(cpu, 0x800916D8u); - e->g_800916da = parappa_read_i16(cpu, 0x800916DAu); - e->g_800916dc = parappa_read_i16(cpu, 0x800916DCu); - e->g_801d3040 = parappa_read_u32(cpu, 0x801D3040u); - return e; -} - -static void parappa_rhythm_log_store(CPUState *cpu, uint32_t pc, - uint32_t addr, uint32_t value, - uint32_t width) { - if (!parappa_rhythm_store_site(pc)) return; - ParappaRhythmEvent *e = parappa_rhythm_next_event(cpu, pc); - e->addr = addr; - e->value = value; - e->width = width; - e->a0 = cpu->gpr[4]; - e->a1 = cpu->gpr[5]; - e->a2 = cpu->gpr[6]; - e->a3 = cpu->gpr[7]; -} - -static int parappa_rhythm_compare_site(uint32_t pc) { - switch (pc & 0x1FFFFFFFu) { - case 0x001C7D1C: - case 0x001C7D44: - return 1; - default: - return 0; - } -} - -static int32_t parappa_timing_adjust_threshold(uint32_t pc, int32_t rhs) { - (void)pc; - /* Score assist must not change note/action cadence. Keep this compare - * observational until a score-only judgement write is identified. */ - return rhs; -} - -static void parappa_rhythm_log_compare(CPUState *cpu, uint32_t pc, - uint32_t lhs, uint32_t raw_rhs, - uint32_t adjusted_rhs, - uint32_t vanilla, uint32_t kept) { - if (!parappa_rhythm_compare_site(pc)) return; - ParappaRhythmEvent *e = parappa_rhythm_next_event(cpu, pc); - e->addr = cpu->gpr[1]; - e->value = adjusted_rhs; - e->width = 0; - e->a0 = lhs; - e->a1 = raw_rhs; - e->a2 = vanilla; - e->a3 = kept; -} - -static void parappa_rhythm_log_final_window(CPUState *cpu, - uint32_t original_mask, - uint32_t result_mask, - uint32_t recent_mask, - uint32_t frame_delta, - int32_t idx_delta) { - ParappaRhythmEvent *e = parappa_rhythm_next_event(cpu, 0x001C7F1Cu); - e->addr = s_parappa_recent_judge_obj; - e->value = result_mask; - e->width = 0; - e->a0 = original_mask; - e->a1 = recent_mask; - e->a2 = frame_delta; - e->a3 = (uint32_t)idx_delta; -} - -static void parappa_timing_apply_final_window(CPUState *cpu, uint32_t pc) { - if ((pc & 0x1FFFFFFFu) != 0x001C7F1Cu) return; - if (g_parappa_timing_mode <= 0 || g_parappa_timing_extra_late <= 0) return; - - uint32_t mask = cpu->gpr[2]; - uint32_t obj = cpu->gpr[17]; - uint32_t idx = parappa_read_u32(cpu, 0x800901C0u); - uint32_t frame = (uint32_t)s_frame_count; - - if (mask != 0) { - s_parappa_recent_judge_obj = obj; - s_parappa_recent_judge_idx = idx; - s_parappa_recent_judge_mask = mask; - s_parappa_recent_judge_frame = frame; - parappa_rhythm_log_final_window(cpu, mask, mask, mask, 0, 0); - return; - } - - if (s_parappa_recent_judge_mask == 0 || s_parappa_recent_judge_obj != obj) { - parappa_rhythm_log_final_window(cpu, mask, mask, s_parappa_recent_judge_mask, 0, 0); - return; - } - - uint32_t frame_delta = frame - s_parappa_recent_judge_frame; - int32_t idx_delta = (int32_t)idx - (int32_t)s_parappa_recent_judge_idx; - parappa_rhythm_log_final_window(cpu, mask, mask, - s_parappa_recent_judge_mask, - frame_delta, idx_delta); -} - -/* Execution-mode tag for the event-timeline ring: 1 while a dirty_ram_dispatch - * call is on the stack. Cleared defensively at the psx_check_interrupts longjmp - * landing (interrupts.c) so the EPC-sentinel longjmp at dirty_ram_dispatch - * can't leave it stuck on. NATIVE_OVERLAY (inprogress!=0) takes precedence in - * the ring's mode resolution, so this only distinguishes INTERP from STATIC. */ -int g_dirty_interp_active = 0; -int dirty_ram_interp_is_active(void) { return g_dirty_interp_active; } - -/* Innermost execution phase, for the wall-time sampler (phase_profile). - * g_dirty_interp_active is NOT this: it stays 1 across a native overlay call - * made from the interpreter's jal/jalr contract (overlay_loader_call_native), - * so sampling it reads "inside the dispatch tree", not "interpreting". This - * variable is save/set/restored at EVERY backend boundary, so a sample reads - * the backend actually executing at that instant: - * 0 = host/other (SDL, GPU, top-level dispatch glue, idle) - * 1 = dirty-RAM interpreter (exec_one body / precise slice) - * 2 = native overlay shard (gcc/tcc DLL) - * 3 = compiled static text (game EXE / recompiled BIOS, incl. IRQ handler) - * 4 = GPU GP0 command processing (gpu.c — raster/batch/VRAM-transfer work) - * Longjmp contract mirrors g_dirty_interp_active: exception delivery saves it - * at entry and restores after its setjmp loop (interrupts.c), so a skipped - * inner restore self-heals at the next bracket. */ -int g_exec_phase = 0; -int psx_exec_phase(void) { return g_exec_phase; } - -/* TCP-armed instruction-window capture (native↔interp divergence drill). - * g_insn_gate_*: an extra runtime-settable PC range that the per-insn log - * records (on top of the hardwired kernel ranges). Freeze latch: on the Nth - * dispatch of candidate g_insn_freeze_addr the insn ring stops recording, so - * its tail preserves the window immediately BEFORE that dispatch — query the - * ring afterward at leisure (ring-first; no arm-then-hope). */ -/* ── R3000A load-delay VALUE semantics ────────────────────────────────────── - * On MIPS-I there is no load interlock: a load's target register is NOT - * visible to the instruction in the load-delay slot, which still sees the - * PREVIOUS value. The writeback lands one instruction later. - * - * We already modelled the load-delay *interlock timing* (psx_cyc_load_*), - * but wrote the destination register immediately — so the slot observed the - * loaded value. That silently diverged from BOTH real hardware and our own - * compiled backend (full_function_emitter defers the writeback past the - * successor for dependent pairs), and hand-written kernel asm depends on it. - * - * The concrete casualty: OpenBIOS's cardfasttrack.s stashes the pointer - * variable's BASE with `or $at,$k0,$zero` in the delay slot of - * `lw $k0,off($k0)`, then writes the advanced pointer back through $at. With - * an immediate writeback $at became the pointer itself, the write-back landed - * in unrelated memory, the pointer never advanced, and every byte of a - * 128-byte memory-card frame was stored to one address (cards read as - * permanently unformatted; the send side transmitted one byte 128x). - * - * Contract: on a same-register conflict the LOAD wins (it retires later), - * matching the compiled emitter. Pending state is flushed on interpreter exit - * and before exception delivery, where the pipeline would have drained. */ -static uint32_t s_ld_pend_rt = 0; -static uint32_t s_ld_pend_val = 0; -static uint32_t s_ld_pend_age = 0; /* 0 = armed; 1 = delay slot has run */ -static int s_ld_pend_armed = 0; - -/* Retire a deferred load writeback. Call wherever the interpreter stops - * stepping instructions (hand-off to compiled code, exception entry), since - * nothing downstream knows about the pending register write. */ -void dirty_ram_ld_delay_flush(CPUState *cpu) { - if (!s_ld_pend_armed) return; - s_ld_pend_armed = 0; - s_ld_pend_age = 0; - if (s_ld_pend_rt != 0u) cpu->gpr[s_ld_pend_rt] = s_ld_pend_val; - cpu->gpr[0] = 0; -} - -uint32_t g_insn_gate_lo = 0; /* extra always-log phys range [lo,hi) */ -uint32_t g_insn_gate_hi = 0; /* 0 = extra range disabled */ -uint32_t g_insn_freeze_addr = 0; /* candidate phys entry to watch */ -uint32_t g_insn_freeze_nth = 0; /* freeze before its Nth dispatch */ -uint32_t g_insn_freeze_count = 0; -int g_insn_log_frozen = 0; -/* Tomba2 wild-jump capture (Confirm-(b)): freeze the insn ring the instant an - * interpreted instruction's transfer TARGET (or next_pc) equals this watched - * value, so the offending jr/jalr is preserved as the ring's last entry with its - * full register snapshot. Set via the `insn_freeze_target` debug command (0 = - * disabled). Matches on full 32-bit value (wild PCs are not phys-normalizable). */ -uint32_t g_insn_freeze_on_target = 0; -/* Register snapshot captured at the instruction that hits g_insn_freeze_on_target - * (the offending jr/jalr). g_freeze_snap_valid latches 1; pc/insn identify the - * branch, gpr[] is the full guest register file at that moment (the source - * register still holds the wild target; scan for which reg == the target). */ -int g_freeze_snap_valid = 0; -uint32_t g_freeze_snap_pc = 0; -uint32_t g_freeze_snap_insn = 0; -uint32_t g_freeze_snap_tcb = 0; -uint32_t g_freeze_snap_gpr[32] = {0}; -/* ra-load watch (Confirm-(b)): capture the instruction that sets $ra to this - * value. 0 = disabled. */ -uint32_t g_ra_load_watch = 0; -/* Callee-smear tripwire (see the interp-loop probe): first $s3 change - * inside [lo,hi) that isn't the walk's own advance, with the call target. */ -uint32_t g_s3_smear_lo = 0, g_s3_smear_hi = 0; -/* Optional exact-encoding exclusion so a watched loop's OWN $s3 advance - * (e.g. an `addi s3,s3,8` list walk) doesn't trip the latch. 0 = none. */ -uint32_t g_s3_smear_excl = 0; -uint32_t g_s3_smear_pc = 0, g_s3_smear_insn = 0, g_s3_smear_old = 0, - g_s3_smear_new = 0, g_s3_smear_tgt = 0, g_s3_smear_frame = 0; -int g_s3_smear_valid = 0; -int g_ra_load_snap_valid = 0; -uint32_t g_ra_load_snap_pc = 0; -uint32_t g_ra_load_snap_insn = 0; -uint32_t g_ra_load_snap_before_ra = 0; -uint32_t g_ra_load_snap_srcaddr = 0; -uint32_t g_ra_load_snap_gpr[32] = {0}; - -/* Call-resolution ring (armed via `callret_watch lo=.. hi=..`). - * For every interp JALR whose call PC lies in [lo,hi), record which resolution - * tier ran the callee and the FULL host-side outcome: post-call guest state, - * bail/escape flags, and engine-attribution deltas (static-dispatch hits vs - * interp blocks across the call). This is the piece the s3 tripwire lacks: - * the tripwire names the callee that came back smeared; this ring names the - * RETURN PATH that let it come back. Zero-cost when disarmed. */ -uint32_t g_callret_lo = 0, g_callret_hi = 0; -/* MUST stay field-for-field identical to the local mirror `E` in - * debug_server.c handle_callret_watch() (which dumps this ring through an - * opaque extern; a divergence is silent garbage, not a compile error). */ -typedef struct { - uint64_t cycle; uint32_t frame; - uint32_t pc, target, sp_b, ra_b, s0_b, s3_b; - uint32_t path; /* CRES_* code, |0x100 if finish() escaped */ - uint32_t pc_a, ra_a, sp_a, s0_a, s3_a, v0_a; - uint32_t bail_a, rfe_a, esc_a, in_exc_a; - uint32_t dstatic, dblocks; /* engine deltas across the call */ - uint32_t dexc; /* exception entries across the call */ - uint32_t last_func_a; /* g_debug_current_func_addr after */ -} CallRetEnt; -#define CALLRET_CAP 64u -CallRetEnt g_callret_ring[CALLRET_CAP]; -uint64_t g_callret_seq = 0; -/* CRES path codes (JALR tiers, in consult order). */ -enum { CRES_PLAIN = 1, - CRES_EC_BAIL = 2, CRES_EC_PC = 3, CRES_EC_CONTRACT = 4, CRES_EC_RET = 5, - CRES_OVERRIDE = 6, - CRES_OV_BAIL = 7, CRES_OV_PC = 8, CRES_OV_CONTRACT = 9, CRES_OV_RET = 10, - CRES_NL_BAIL = 11, CRES_NL_PC = 12, CRES_NL_RET = 13, - CRES_PCCHAIN = 14 }; -extern uint64_t g_dispatch_static_hits; /* debug_server.c; bumped by generated dispatch */ -extern uint64_t psx_cycle_count; -static uint32_t callret_begin(CPUState *cpu, uint32_t pc, uint32_t target) { - if (!g_callret_lo || pc < g_callret_lo || pc >= g_callret_hi) - return 0xFFFFFFFFu; - uint32_t idx = (uint32_t)(g_callret_seq++ & (CALLRET_CAP - 1u)); - CallRetEnt *e = &g_callret_ring[idx]; - e->cycle = psx_cycle_count; e->frame = (uint32_t)s_frame_count; - e->pc = pc; e->target = target; - e->sp_b = cpu->gpr[29]; e->ra_b = cpu->gpr[31]; - e->s0_b = cpu->gpr[16]; e->s3_b = cpu->gpr[19]; - e->path = 0; - e->dstatic = (uint32_t)g_dispatch_static_hits; - e->dblocks = (uint32_t)g_dirty_ram_blocks_run; - { extern void psx_get_freeze_diag(uint64_t*,uint32_t*,int*,int*,uint64_t*,uint64_t*); - uint64_t exc = 0; psx_get_freeze_diag(NULL, NULL, NULL, NULL, &exc, NULL); - e->dexc = (uint32_t)exc; } - return idx; -} -static void callret_end(uint32_t idx, CPUState *cpu, uint32_t path) { - if (idx == 0xFFFFFFFFu) return; - CallRetEnt *e = &g_callret_ring[idx]; - e->path = path; - e->pc_a = cpu->pc; e->ra_a = cpu->gpr[31]; e->sp_a = cpu->gpr[29]; - e->s0_a = cpu->gpr[16]; e->s3_a = cpu->gpr[19]; e->v0_a = cpu->gpr[2]; - e->bail_a = (uint32_t)g_psx_call_bail; - { extern int g_rfe_escape_pending; extern int g_exc_escape_reason; - e->rfe_a = (uint32_t)g_rfe_escape_pending; - e->esc_a = (uint32_t)g_exc_escape_reason; } - e->in_exc_a = (uint32_t)psx_get_in_exception(); - e->dstatic = (uint32_t)g_dispatch_static_hits - e->dstatic; - e->dblocks = (uint32_t)g_dirty_ram_blocks_run - e->dblocks; - { extern void psx_get_freeze_diag(uint64_t*,uint32_t*,int*,int*,uint64_t*,uint64_t*); - uint64_t exc = 0; psx_get_freeze_diag(NULL, NULL, NULL, NULL, &exc, NULL); - e->dexc = (uint32_t)exc - e->dexc; } - e->last_func_a = g_debug_current_func_addr; -} - -/* Overlay-region floor (phys) = the loaded game's main-EXE text end. Defaults to - * the BIOS-only value; main.cpp pins it to (load_address + text_size) at game - * load. See dirty_ram_interp.h for the per-game rationale. */ -uint32_t g_overlay_region_floor = OVERLAY_REGION_FLOOR_DEFAULT; - -#ifdef PSX_HAS_GAME_DISPATCH -extern int psx_dispatch_game_compiled(CPUState* cpu, uint32_t addr); -extern int psx_game_address_in_text(uint32_t addr); -extern int psx_game_is_function_entry(uint32_t addr); /* non-destructive entry test */ -extern int psx_game_text_native_ok(uint32_t addr); -#endif -extern void psx_dispatch_call(CPUState* cpu, uint32_t addr, uint32_t return_addr); - -/* Forward decls from memory.c — used to read instruction bytes. */ -extern uint8_t *memory_get_ram_ptr(void); -extern void dirty_ram_mark_executable_range(uint32_t phys, uint32_t len); - -/* MIPS instruction field decoders. */ -static inline uint32_t op_field (uint32_t i) { return (i >> 26) & 0x3Fu; } -static inline uint32_t rs_field (uint32_t i) { return (i >> 21) & 0x1Fu; } -static inline uint32_t rt_field (uint32_t i) { return (i >> 16) & 0x1Fu; } -static inline uint32_t rd_field (uint32_t i) { return (i >> 11) & 0x1Fu; } -static inline uint32_t shamt_field (uint32_t i) { return (i >> 6) & 0x1Fu; } -static inline uint32_t funct_field (uint32_t i) { return i & 0x3Fu; } -static inline uint32_t imm16_field (uint32_t i) { return i & 0xFFFFu; } -static inline int32_t simm16_field(uint32_t i) { return (int32_t)(int16_t)imm16_field(i); } -static inline uint32_t target26 (uint32_t i) { return i & 0x03FFFFFFu; } - -/* Read a 32-bit instruction word from kernel RAM at the given physical addr. - * Caller has already verified the address is in dirty kernel RAM. */ -static inline uint32_t fetch_word(uint32_t phys) { - /* Main RAM is a process-lifetime static allocation. Cache its address so - * instruction fetch does not cross translation units for every guest op. */ - static const uint8_t *ram; - if (!ram) ram = memory_get_ram_ptr(); - return (uint32_t)ram[phys] - | ((uint32_t)ram[phys + 1] << 8) - | ((uint32_t)ram[phys + 2] << 16) - | ((uint32_t)ram[phys + 3] << 24); -} - -/* Widescreen render-funnel cull detection for the interpreter ([widescreen.cull] - * auto_screen_x). A `sltiu …,0x140/0x141` is widened ONLY when its enclosing - * render function also carries a `sltiu …,0xE0/0xF1` (the GTE per-vertex trivial- - * reject signature) — a lone 0x140 elsewhere stays vanilla. The interp has no - * function boundaries, so we scan a +/-512-byte window around the site (clamped - * to main RAM) via the shared psx_ws_func_has_screen_cull, cached per-PC so a - * hot render loop pays the scan once. Single-threaded interp => static buffers ok. */ -/* Per-PC site caches: FULL-PC tagged, sized for a whole overlay's working set - * (the old 256-slot direct map thrashed on any two hot PCs 1 KB apart — the - * ±512-byte rescan then ran per EXECUTED INSTRUCTION and collapsed native-wide - * 16:9 to ~12fps, entirely CPU-side). Entries carry the dirty-RAM code - * generation (memory.c g_dirty_ram_code_gen) so an overlay reload re-derives - * every classification instead of trusting a stale kind. */ -extern uint32_t g_dirty_ram_code_gen; -#define WS_SITE_CACHE_SLOTS 8192u /* 32 KB of code coverage per cache */ - -static int ws_cull_site(uint32_t pc) { - enum { WIN = 128 }; /* +/- 128 words = +/- 512 bytes */ - static struct { uint32_t pc; uint32_t gen; uint32_t word; int8_t flag; } cache[WS_SITE_CACHE_SLOTS]; - uint32_t slot = (pc >> 2) & (WS_SITE_CACHE_SLOTS - 1u); - uint32_t phys = pc & 0x1FFFFFFFu; - /* Entry valid iff pc + generation + the SITE'S OWN INSTRUCTION WORD all - * match. The word tag catches code replaced by plain CPU stores (loader - * memcpy), which never routes through the page-marking hooks the - * generation counter sees — a stale verdict here REWRITES a live GPR and - * corrupts the guest (wild-jump fatal). */ - if (cache[slot].pc == pc && cache[slot].gen == g_dirty_ram_code_gen && - cache[slot].word == fetch_word(phys)) - return cache[slot].flag; - uint32_t lo = (phys > (uint32_t)(WIN * 4)) ? phys - (uint32_t)(WIN * 4) : 0u; - uint32_t hi = phys + (uint32_t)(WIN * 4); - if (hi > 0x200000u) hi = 0x200000u; /* 2 MB main RAM */ - static uint32_t words[2 * WIN + 1]; - int n = 0; - for (uint32_t a = lo; a + 4u <= hi && n < (int)(2 * WIN + 1); a += 4u) - words[n++] = fetch_word(a); - int flag = psx_ws_func_has_screen_cull(words, n); - cache[slot].pc = pc; cache[slot].gen = g_dirty_ram_code_gen; - cache[slot].word = fetch_word(phys); cache[slot].flag = (int8_t)flag; - return flag; -} - -/* Widescreen LEFT-edge funnel-bltz classification (auto_screen_x, signed - * min/max + center±halfwidth idioms — ws_cull_detect.h). Same ±512-byte window - * qualification + per-PC cache discipline as ws_cull_site above; additionally - * classifies THIS bltz structurally (delay-slot width compare / addu-subu - * pair), so an unrelated bltz in a qualifying window stays vanilla. */ -static int ws_cull_bltz_site(uint32_t pc) { - enum { WIN = 128 }; /* +/- 128 words = +/- 512 bytes */ - static struct { uint32_t pc; uint32_t gen; uint32_t word; int8_t flag; } cache[WS_SITE_CACHE_SLOTS]; - uint32_t slot = (pc >> 2) & (WS_SITE_CACHE_SLOTS - 1u); - uint32_t phys = pc & 0x1FFFFFFFu; - if (cache[slot].pc == pc && cache[slot].gen == g_dirty_ram_code_gen && - cache[slot].word == fetch_word(phys)) - return cache[slot].flag; - uint32_t lo = (phys > (uint32_t)(WIN * 4)) ? phys - (uint32_t)(WIN * 4) : 0u; - uint32_t hi = phys + (uint32_t)(WIN * 4); - if (hi > 0x200000u) hi = 0x200000u; /* 2 MB main RAM */ - static uint32_t words[2 * WIN + 1]; - int n = 0; - for (uint32_t a = lo; a + 4u <= hi && n < (int)(2 * WIN + 1); a += 4u) - words[n++] = fetch_word(a); - int idx = (int)((phys - lo) / 4u); - int flag = psx_ws_func_has_screen_cull(words, n) && - psx_ws_cull_bltz_at(words, n, idx); - cache[slot].pc = pc; cache[slot].gen = g_dirty_ram_code_gen; - cache[slot].word = fetch_word(phys); cache[slot].flag = (int8_t)flag; - return flag; -} - -/* Widescreen far-backdrop column-PRELOAD site classification for the interpreter - * ([widescreen.cull] auto_backdrop). The scrolling-backdrop column-window - * generators run interpreted in the dev build, so the recompiler emit can't - * reach them; the interp re-derives the same START/END rewrite sites via the - * shared detector over a +/-512-byte window around the PC (physical space, so - * the detector's absolute-PC math matches the masked PC), cached per-PC so a hot - * generator pays the scan once. Returns WS_BD_NONE / WS_BD_START_ZERO / - * WS_BD_END_WIDEN. Single-threaded interp => static buffers are safe. */ -static int ws_backdrop_site_kind(uint32_t pc, int *out_cols) { - enum { WIN = 128 }; /* +/- 128 words = +/- 512 bytes */ - static struct { uint32_t pc; uint32_t gen; uint32_t word; int8_t kind; int16_t cols; } cache[WS_SITE_CACHE_SLOTS]; - uint32_t slot = (pc >> 2) & (WS_SITE_CACHE_SLOTS - 1u); - uint32_t phys = pc & 0x1FFFFFFFu; - /* pc + generation + site instruction word — see ws_cull_site: the word tag - * is what protects against CPU-store code reloads the generation counter - * cannot see. A stale KIND here is worse than a stale cull flag: it - * substitutes a GPR value and skips the real instruction. */ - if (cache[slot].pc == pc && cache[slot].gen == g_dirty_ram_code_gen && - cache[slot].word == fetch_word(phys)) { - if (out_cols) *out_cols = cache[slot].cols; - return cache[slot].kind; - } - uint32_t lo = (phys > (uint32_t)(WIN * 4)) ? phys - (uint32_t)(WIN * 4) : 0u; - uint32_t hi = phys + (uint32_t)(WIN * 4 + 4); - if (hi > 0x200000u) hi = 0x200000u; /* 2 MB main RAM */ - static uint32_t words[2 * WIN + 2]; - int n = 0; - for (uint32_t a = lo; a + 4u <= hi && n < (int)(2 * WIN + 2); a += 4u) - words[n++] = fetch_word(a); - int cols = 0; - int kind = psx_ws_backdrop_kind_at(words, n, lo, phys, &cols); /* all physical-space */ - cache[slot].pc = pc; cache[slot].gen = g_dirty_ram_code_gen; - cache[slot].word = fetch_word(phys); - cache[slot].kind = (int8_t)kind; cache[slot].cols = (int16_t)cols; - if (out_cols) *out_cols = cols; - return kind; -} - -static void dirty_ram_log_instruction(CPUState *cpu, uint32_t pc, uint32_t insn, - uint32_t before_s0, uint32_t next_pc, - uint32_t target, int transferred) { - if (g_insn_log_frozen) return; - /* Freeze-on-target (Confirm-(b)): checked BEFORE the gate filter so it fires - * for ANY interpreted instruction transferring to the watched wild PC, even - * if that jr/jalr sits outside the recorded gate range. We do NOT record this - * out-of-gate entry (the gate still governs what's stored); we just stop the - * ring so the in-gate window leading up to the wild jump is preserved. */ - if (g_insn_freeze_on_target != 0u && - (target == g_insn_freeze_on_target || next_pc == g_insn_freeze_on_target)) { - if (!g_freeze_snap_valid) { - g_freeze_snap_pc = pc; - g_freeze_snap_insn = insn; - uint32_t tcb_ptr = cpu->read_word(0x00000108u); - g_freeze_snap_tcb = tcb_ptr ? cpu->read_word(tcb_ptr) : 0; - for (int r = 0; r < 32; r++) g_freeze_snap_gpr[r] = cpu->gpr[r]; - g_freeze_snap_valid = 1; - } - g_insn_log_frozen = 1; - return; - } - uint32_t phys = pc & 0x1FFFFFFFu; - if (!((g_insn_gate_hi != 0u && phys >= g_insn_gate_lo && phys < g_insn_gate_hi) || - (phys >= 0x000E0000u && phys < 0x000F0000u) || - (phys >= 0x000000A0u && phys < 0x000000C0u) || - (phys >= 0x000005C0u && phys < 0x00000620u) || - (phys >= 0x00001E00u && phys < 0x00002000u))) { - return; - } - - uint32_t tcb_ptr_addr = cpu->read_word(0x00000108u); - uint32_t current_tcb = tcb_ptr_addr ? cpu->read_word(tcb_ptr_addr) : 0; - uint32_t task_ptr = cpu->read_word(0x1F8001D4u); - - uint64_t s = g_dirty_ram_insn_log_seq++; - DirtyRamInsnLogEntry *e = - &g_dirty_ram_insn_log[s & (DIRTY_RAM_INSN_LOG_CAP - 1u)]; - e->seq = s; - e->pc = pc; - e->insn = insn; - e->next_pc = next_pc; - e->target = target; - e->before_s0 = before_s0; - e->after_s0 = cpu->gpr[16]; - e->sp = cpu->gpr[29]; - e->ra = cpu->gpr[31]; - e->v0 = cpu->gpr[2]; - e->v1 = cpu->gpr[3]; - e->a0 = cpu->gpr[4]; - e->a1 = cpu->gpr[5]; - e->a2 = cpu->gpr[6]; - e->a3 = cpu->gpr[7]; - e->t0 = cpu->gpr[8]; - e->t1 = cpu->gpr[9]; - e->t2 = cpu->gpr[10]; - e->at = cpu->gpr[1]; - e->k0 = cpu->gpr[26]; - e->k1 = cpu->gpr[27]; - e->current_tcb = current_tcb; - e->task_ptr = task_ptr; - e->task_mode = task_ptr ? cpu->read_half(task_ptr + 72u) : 0; - e->task_submode = task_ptr ? cpu->read_half(task_ptr + 74u) : 0; - e->frame = (uint32_t)s_frame_count; - e->transferred = (uint8_t)(transferred ? 1u : 0u); -} - -/* Marker entries delimit NATIVE candidate executions in the insn ring (native - * code emits no per-insn entries; markers keep the two runs' timelines - * alignable). transferred: 200 = native entry, 201 = native exit. */ -void dirty_ram_log_marker(uint32_t addr, uint32_t tag, int kind) { - if (g_insn_log_frozen) return; - uint64_t s = g_dirty_ram_insn_log_seq++; - DirtyRamInsnLogEntry *e = - &g_dirty_ram_insn_log[s & (DIRTY_RAM_INSN_LOG_CAP - 1u)]; - memset(e, 0, sizeof *e); - e->seq = s; - e->pc = addr; - e->target = tag; - e->frame = (uint32_t)s_frame_count; - e->transferred = (uint8_t)(200 + kind); -} - -/* LWL/LWR are merge-only here; the timed aligned-word read is done by the caller via - * psx_cyc_load_word (Beetle reads the aligned word; GPR_DEP rs only, arms LDWhich=rt). */ -static inline uint32_t lwl_merge(uint32_t addr, uint32_t word, uint32_t rt_value) { - switch (addr & 3u) { - case 0: return (rt_value & 0x00FFFFFFu) | (word << 24); - case 1: return (rt_value & 0x0000FFFFu) | (word << 16); - case 2: return (rt_value & 0x000000FFu) | (word << 8); - default: return word; - } -} - -static inline uint32_t lwr_merge(uint32_t addr, uint32_t word, uint32_t rt_value) { - switch (addr & 3u) { - case 0: return word; - case 1: return (rt_value & 0xFF000000u) | (word >> 8); - case 2: return (rt_value & 0xFFFF0000u) | (word >> 16); - default: return (rt_value & 0xFFFFFF00u) | (word >> 24); - } -} - -static inline void interp_swl(CPUState *cpu, uint32_t addr, uint32_t value) { - uint32_t aligned = addr & ~3u; - uint32_t word = cpu->read_word(aligned); - switch (addr & 3u) { - case 0: word = (word & 0xFFFFFF00u) | (value >> 24); break; - case 1: word = (word & 0xFFFF0000u) | (value >> 16); break; - case 2: word = (word & 0xFF000000u) | (value >> 8); break; - default: word = value; break; - } - cpu->write_word(aligned, word); -} - -static inline void interp_swr(CPUState *cpu, uint32_t addr, uint32_t value) { - uint32_t aligned = addr & ~3u; - uint32_t word = cpu->read_word(aligned); - switch (addr & 3u) { - case 0: word = value; break; - case 1: word = (word & 0x000000FFu) | (value << 8); break; - case 2: word = (word & 0x0000FFFFu) | (value << 16); break; - default: word = (word & 0x00FFFFFFu) | (value << 24); break; - } - cpu->write_word(aligned, word); -} - -/* Soft-fail thread-local flag. When the interpreter encounters an opcode - * it doesn't implement, it sets this flag and returns instead of aborting, - * letting the caller (psx_dispatch via dirty_ram_dispatch) fall back to - * psx_unknown_dispatch — which has its own ad-hoc resolver for known - * trampoline patterns (jr-based vector dispatch, etc.). - * - * This is a deliberate retreat from "always pick the most complete option" - * for ONE narrow case: dispatch into pages that have been written-to but - * don't actually contain valid stub code at the dispatched PC (e.g. - * stale data, return-target addresses that point to non-code areas). The - * pre-existing psx_unknown_dispatch already handles those — we just need - * to let it. If a true install stub uses an opcode we don't have, this - * will silently route it to psx_unknown_dispatch, which will likely return - * a no-op cpu->pc=0. When that happens, we'll see "card protocol stalls" - * in measurement and add the missing opcode here. */ -static int g_unsupported_seen = 0; -static uint32_t g_unsupported_pc = 0; -static uint32_t g_unsupported_insn = 0; -static const char *g_unsupported_reason = NULL; - -static int abort_unsupported(uint32_t pc, uint32_t insn, const char *reason) { - g_dirty_ram_aborts++; - g_unsupported_seen = 1; - g_unsupported_pc = pc; - g_unsupported_insn = insn; - g_unsupported_reason = reason; - return 1; /* signal "control transferred" so the caller stops */ -} - -/* A dirty page that should run as a locally-chained overlay: anything ABOVE the - * kernel window. The kernel window [0, DIRTY_RAM_KERNEL_WINDOW_END) intentionally - * stays per-block (it runs in exception context, where interrupt-check cadence and - * EPC handling are delicate — see dirty_ram_interp.h window note; kernel native - * coverage comes from overlay_loader candidates, not interp chaining). - * - * NOTE this is the kernel-window end, NOT OVERLAY_REGION_FLOOR. The floor is the - * end of the boot-EXE text, and the original design assumed [0x10000, FLOOR) is - * immutable statically-recompiled text. That assumption is FALSE for games that - * discard their boot/init code and load gameplay overlays OVER the boot-text region - * (Tomba 2: a START.BIN-loader overlay at 0x8001Dxxx, well below its 0x38800 floor). - * Such a page is dirty (RAM != compiled image), so dispatching to it must interpret - * the live overlay and chain locally — exactly like the [FLOOR, RAM_SIZE) overlay - * region — NOT run the stale compiled image or route through the bail-prone - * non-local-call contract (the Whoopee-Camp splash wild-jr). dirty_ram_is_dirty() - * keeps clean boot text on the fast compiled path; only overwritten pages divert. */ -static inline int phys_is_overlay_flow_region(uint32_t phys) { - return phys >= DIRTY_RAM_KERNEL_WINDOW_END; -} - -static int is_local_dirty_target(uint32_t target) { - uint32_t phys = target & 0x1FFFFFFFu; - return phys_is_overlay_flow_region(phys) && dirty_ram_is_dirty(phys); -} - -/* Target the last interp run handed back to the dispatch loop (chained - * continuation). Consumed by the next dirty dispatch to tell apart - * external entries (from native code) from the interpreter's own - * block-to-block chaining. */ -static uint32_t g_dirty_interp_chain_target = 0; - -/* A dirty-RAM target only deserves interpretation if its first word decodes - * as a plausible MIPS instruction. A scatter-loaded overlay leaves data - * bytes in dirty pages; jumping into those and interpreting them as code is - * how the original cache build produced black/blue screens. When the target - * word doesn't look like code, fall back to the normal dispatch path. */ -static int dirty_ram_word_looks_decodable(uint32_t insn) { - if (insn == 0xFFFFFFFFu || insn == 0xFFFFFFFDu) return 0; - - uint32_t op = op_field(insn); - uint32_t fn = funct_field(insn); - uint32_t rt = rt_field(insn); - - if (op == 0x00u) { - switch (fn) { - case 0x00u: case 0x02u: case 0x03u: case 0x04u: - case 0x06u: case 0x07u: case 0x08u: case 0x09u: - case 0x0Cu: case 0x0Du: - case 0x10u: case 0x11u: case 0x12u: case 0x13u: - case 0x18u: case 0x19u: case 0x1Au: case 0x1Bu: - case 0x20u: case 0x21u: case 0x22u: case 0x23u: - case 0x24u: case 0x25u: case 0x26u: case 0x27u: - case 0x2Au: case 0x2Bu: - return 1; - default: - return 0; - } - } - if (op == 0x01u) { - return rt == 0x00u || rt == 0x01u || rt == 0x10u || rt == 0x11u; - } - - switch (op) { - case 0x02u: case 0x03u: case 0x04u: case 0x05u: - case 0x06u: case 0x07u: - case 0x08u: case 0x09u: case 0x0Au: case 0x0Bu: - case 0x0Cu: case 0x0Du: case 0x0Eu: case 0x0Fu: - case 0x10u: case 0x12u: - case 0x20u: case 0x21u: case 0x22u: case 0x23u: - case 0x24u: case 0x25u: case 0x26u: - case 0x28u: case 0x29u: case 0x2Au: case 0x2Bu: - case 0x2Eu: - case 0x30u: case 0x32u: case 0x38u: case 0x3Au: - return 1; - default: - return 0; - } -} - -static int dirty_ram_same_pc(uint32_t a, uint32_t b) { - return (((a ^ b) & 0x1FFFFFFFu) == 0); -} - -static int dirty_ram_pump_boundary(CPUState *cpu, uint32_t committed_pc, int site) { - uint32_t prev_safe = g_dirty_safe_resume_pc; - int prev_site = g_cosim_dirty_pump_site; - - s_last_dirty_irq_pump_insns = g_dirty_ram_insns_run; - if (!psx_interrupts_checked_at_current_cycle(committed_pc)) { - g_dirty_safe_resume_pc = committed_pc; - g_cosim_dirty_pump_site = site; - psx_check_interrupts(cpu); - g_dirty_safe_resume_pc = prev_safe; - g_cosim_dirty_pump_site = prev_site; - } - - if (g_psx_call_bail) return 1; - if (cpu->pc == 0u && committed_pc != 0u) { - cpu->pc = committed_pc; - g_async_rfe_fire_count++; - } - if (cpu->pc != 0u && !dirty_ram_same_pc(cpu->pc, committed_pc)) { - return 1; - } - - cpu->pc = committed_pc; - return 0; -} - -static int dirty_ram_finish_call_return(CPUState *cpu, uint32_t return_pc, - uint32_t *next_pc_out) { - uint32_t prev_pc = cpu->pc; - cpu->pc = return_pc; - if (dirty_ram_pump_boundary(cpu, return_pc, 5)) return 1; - cpu->pc = prev_pc; - *next_pc_out = return_pc; - return 0; -} - -/* Sub-outcome of the last dispatch_nonlocal_call, for the callret ring. */ -static uint32_t g_nl_exit_code = 0; -static int dispatch_nonlocal_call(CPUState *cpu, uint32_t target, - uint32_t return_pc, - uint32_t *next_pc_out) { - cpu->pc = 0; - { - int prev_phase = g_exec_phase; - g_exec_phase = 3; /* compiled route; dirty/native callees re-tag inside */ - psx_dispatch_call(cpu, target, return_pc); - g_exec_phase = prev_phase; - } - /* psx_dispatch_call validated the (return_pc, sp) contract; a bail - * unwind in progress surfaces with cpu->pc = the guest's true target. */ - if (g_psx_call_bail) { g_nl_exit_code = CRES_NL_BAIL; return 1; } - if (cpu->pc != 0) { g_nl_exit_code = CRES_NL_PC; return 1; } - g_nl_exit_code = CRES_NL_RET; - return dirty_ram_finish_call_return(cpu, return_pc, next_pc_out); -} - -/* ── Mixed interp<->compiled dispatch owner (host-stack recursion fix) ────── - * The dirty interpreter nests psx_dispatch_game_compiled when an interpreted - * block transfers into compiled code. A guest tail-dispatch loop that crosses the - * boundary (an interpreted overlay <-> the main-EXE per-frame loop func_8001A954) - * grows the HOST stack unboundedly while the GUEST stack stays flat — the long-run - * freeze (docs/RECURSION_BUG.md). The compiled side keeps tail-call loops flat via - * the psx_dispatch_impl trampoline + g_psx_call_bail unwind; the interpreter - * bypassed it by nesting directly. - * - * Fix: when an interp->compiled crossing would push the HOST stack past a safe - * watermark, publish the target as a wild-flow bail instead of nesting. The - * existing contract (compiled frames honor `if (g_psx_call_bail) return;`) unwinds - * it to the OUTERMOST psx_dispatch_impl, whose flatten path clears the bail and - * re-dispatches cpu->pc in its loop — so the mutual tail-recursion iterates with - * bounded native depth. Runtime-only (reuses the existing bail + flatten; no regen). - * - * The trigger is the ACTUAL host-stack usage (TEB StackBase - rsp), NOT a nesting - * counter. A counter LEAKS across the fiber exception path (psx_exception_longjmp - * never returns, so interp_enter_compiled's post-call decrement is skipped) and - * creeps up until it falsely trips on a benign call — observed as the FIRST dialogue - * freezing. Stack usage can't leak and is the real resource we protect: legitimate - * code (boot, dialogue, deep-but-bounded recursion) stays far below the watermark; - * only the runaway approaches it. Toggle off with PSX_MIXED_OWNER=0; watermark via - * PSX_MIXED_STACK_KB (default 700; fiber ~1MB, native-stack guard ~768KB used). */ -static int psx_mixed_owner_enabled(void) { - static int v = -1; - if (v < 0) { const char *e = getenv("PSX_MIXED_OWNER"); v = (e && e[0] == '0') ? 0 : 1; } - return v; -} - -#ifdef _WIN32 -#include /* __readgsqword — fiber TEB StackBase */ -static size_t interp_host_stack_used(void) { - char probe; - uintptr_t base = (uintptr_t)__readgsqword(0x08); /* TEB StackBase (high end) */ - uintptr_t sp = (uintptr_t)&probe; - return (base > sp) ? (size_t)(base - sp) : 0; -} -#else -static size_t interp_host_stack_used(void) { return 0; } -#endif - -static size_t psx_mixed_stack_watermark(void) { - static long v = -1; - if (v < 0) { const char *e = getenv("PSX_MIXED_STACK_KB"); - v = (e && *e) ? atol(e) : 700; if (v < 64) v = 64; } - return (size_t)v * 1024u; -} - -/* ── Flight recorder: per-frame count of the re-entry edge interp->0x8001A954 ── - * (RECURSION_BUG.md §15). The long-run freeze is a per-frame update that, at - * ~frame 50k, re-enters func_8001A954 (the per-frame loop head) unboundedly via - * an interpreted overlay. Record, per emulated frame, how many times that edge - * fires + the cycle counter, in a ring the crash report dumps. Answers: is the - * re-entry ORDINARY bounded per-frame behavior that stops TERMINATING at ~50k - * (count goes unbounded), or a brand-new edge — and whether cycles advance. - * Only the INTERP->compiled re-entry passes through here; the normal compiled - * per-frame entry to func_8001A954 does not, so this isolates the suspect edge. */ -extern uint64_t psx_cycle_count; -#define SITE_CAP 32u /* 3 sites x 256 overflowed crash_trace's 8KB sub-buffer; 32 frames of lead-up is plenty */ -typedef struct { uint32_t frame; uint32_t count; uint64_t cycle; } SiteEntry; -typedef struct { SiteEntry ring[SITE_CAP]; uint64_t seq; uint32_t cur; - uint32_t last_frame; uint32_t maxf; } SiteRec; -/* Per-frame dispatch-count recorders for the suspect loop sites (RECURSION_BUG.md - * §15). g_site_interp (interp->0x8001A954) was found ORDINARY (1/frame); the - * recursion re-dispatches the compiled loop via dirty_ram_dispatch's entry because - * the CD-DMA dirty bitmap is saturated. So record dirty_ram_dispatch(0x8001A954) - * = loop head and dirty_ram_dispatch(0x80046264) = loop tail. Bounded normally, - * spike at the freeze => "the per-frame loop stopped terminating." */ -static SiteRec g_site_interp = { .last_frame = 0xFFFFFFFFu }; /* interp -> 0x8001A954 */ -static SiteRec g_site_dd954 = { .last_frame = 0xFFFFFFFFu }; /* dirty_ram_dispatch(0x8001A954) */ -static SiteRec g_site_dd264 = { .last_frame = 0xFFFFFFFFu }; /* dirty_ram_dispatch(0x80046264) */ - -static void site_note(SiteRec *s) { - uint32_t f = (uint32_t)s_frame_count; - if (f != s->last_frame) { - if (s->last_frame != 0xFFFFFFFFu) { - SiteEntry *e = &s->ring[s->seq++ & (SITE_CAP - 1u)]; - e->frame = s->last_frame; e->count = s->cur; e->cycle = psx_cycle_count; - if (s->cur > s->maxf) s->maxf = s->cur; - } - s->cur = 0; s->last_frame = f; - } - s->cur++; -} - -static int site_json(char *out, int cap, const char *name, const SiteRec *s) { - int n = snprintf(out, cap, " \"%s\": {\"max_per_frame\":%u,\"cur_frame\":%u,\"cur_count\":%u,\"history\":[", - name, s->maxf, s->last_frame, s->cur); - uint64_t total = s->seq; - int avail = (total < SITE_CAP) ? (int)total : (int)SITE_CAP; - uint64_t start = total - (uint64_t)avail; - for (int i = 0; i < avail && n < cap - 96; i++) { - const SiteEntry *e = &s->ring[(start + (uint64_t)i) & (SITE_CAP - 1u)]; - n += snprintf(out + n, cap - n, "%s{\"f\":%u,\"n\":%u,\"cyc\":%llu}", - i ? "," : "", e->frame, e->count, (unsigned long long)e->cycle); - } - n += snprintf(out + n, cap - n, "]},\n"); - return n; -} - -/* Emit all three loop-site recorders as JSON (called by crash_trace). */ -int dirty_ram_re954_json(char *out, int cap) { - int n = 0; - n += site_json(out + n, cap - n, "reentry_interp_8001A954", &g_site_interp); - n += site_json(out + n, cap - n, "dispatch_8001A954", &g_site_dd954); - n += site_json(out + n, cap - n, "dispatch_80046264", &g_site_dd264); - return n; -} - -/* ── Boundary control-flow flight recorder (RECURSION_BUG.md §18) ────────────── - * The long-run freeze is a runaway interp↔compiled re-entry. To choose among the - * four shapes (gradual boundary leak / sudden same-frame spin / interpreter - * fabrication / real guest wait-loop) WITHOUT assuming any of them, record every - * interp→compiled crossing at BOTH nesting sites (interp_enter_compiled for guest - * JAL/JALR, and dirty_ram_dispatch_inner's psx_dispatch_game_compiled) plus the - * watched-set tail-transfers (J/JR to the loop addresses). Two always-on rings: - * - per-FRAME summary -> the time-series (does depth/crossings grow across - * frames, or explode in one?). Answers Q1/Q2/Q3. - * - per-CROSSING detail -> src_pc, decoded op, target, delay-slot insn, sp/ra, - * mixed depth, host-stack KB, I_STAT/I_MASK. Answers Q4. - * Trips EARLY (per-frame crossings or host-stack KB over a low threshold) so the - * detail ring still holds the ONSET (the transition frame's first crossings), not - * the 23k steady state that the terminal native-stack guard would capture. The - * dump routes through the normal crash report (xprobe_json), reusing the proven - * flush+halt+serve path. Watched set is flagged, but ALL crossings are recorded - * so the real driver address is not pre-assumed. NB: there is no due-cycle event - * scheduler in this build (psx_cycles.c), so "next scheduled event cycle" is N/A; - * cycles-advanced-per-frame + I_STAT/I_MASK cover event progress instead. */ -extern uint32_t i_stat; /* owned by memory.c */ -extern uint32_t i_mask; - -enum { XOP_JAL = 0, XOP_JALR = 1, XOP_JR = 2, XOP_J = 3, XOP_DD = 4, XOP_BR = 5, - XOP_RES = 6 /* watched-target call RESOLUTION: site = path code (XRES_*), - ds_insn = v0 after the path ran */ }; -enum { XSITE_INTERP = 0, XSITE_DD = 1 }; -/* XOP_RES path codes (in the `site` field). */ -enum { XRES_EC_BAIL = 2, XRES_EC_PC = 3, XRES_EC_CONTRACT = 4, XRES_EC_RET = 5, - XRES_OV_BAIL = 6, XRES_OV_PC = 7, XRES_OV_CONTRACT = 8, XRES_OV_RET = 9, - XRES_NONLOCAL = 10, XRES_PCCHAIN = 11, XRES_UNDECODABLE = 12 }; - -int g_mixed_depth = 0; /* best-effort interp→compiled nesting depth; reset per frame */ - -typedef struct { - uint32_t frame; uint64_t cycle; uint32_t src_pc; uint32_t target; - uint32_t ds_insn; uint32_t sp; uint32_t ra; uint32_t stk_kb; - uint32_t istat; uint32_t imask; uint16_t depth; uint8_t op; uint8_t site; -} XDetail; -typedef struct { - uint32_t frame; uint32_t crossings; uint32_t a954; int32_t depth_max; - uint32_t stk_max_kb; uint64_t cyc_advanced; -} XSum; - -#define XDET_CAP 16384u -#define XSUM_CAP 512u -static XDetail g_xdet[XDET_CAP]; -static XSum g_xsum[XSUM_CAP]; -static uint64_t g_xdet_seq = 0, g_xsum_seq = 0; - -/* per-frame accumulators */ -static uint32_t s_xf_frame = 0xFFFFFFFFu; -static uint32_t s_xf_crossings = 0, s_xf_a954 = 0; -static int32_t s_xf_depth_max = 0; -static uint32_t s_xf_stk_max_kb = 0; -static uint64_t s_xf_cyc_start = 0; - -/* first crossing where mixed depth became > 0 this run (Q1) */ -static uint32_t s_xf_first_depth_frame = 0xFFFFFFFFu; -static uint64_t s_xf_first_depth_cycle = 0; -static int s_xprobe_tripped = 0; - -/* Trip thresholds — DISABLED by default (0). Deep but LEGITIMATE boot stacks - * reach ~300KB host at crossing points (mixed-depth small) and would spuriously - * trip an absolute threshold. So arm at RUNTIME via the `xprobe_arm` TCP command - * once the game is idling and the normal per-frame baseline is known (idle stack - * is a flat ~5KB, so a threshold ~3x the idle crossing baseline / a stack KB well - * above 5KB then fires only on the runaway, with the onset still in the ring). - * warmup suppresses any trip before that frame regardless. Env overrides allow - * arming at launch (PSX_XPROBE_FRAME_TRIP / _STK_KB / _WARMUP). */ -int g_xprobe_frame_trip = 0; /* per-frame crossings; 0 = disabled */ -int g_xprobe_stk_kb = 0; /* host-stack KB; 0 = disabled */ -int g_xprobe_warmup = 0; /* no trip before this frame */ -static int g_xprobe_env_done = 0; -static void xprobe_env_init(void) { - if (g_xprobe_env_done) return; - g_xprobe_env_done = 1; - const char *a = getenv("PSX_XPROBE_FRAME_TRIP"); if (a && *a) g_xprobe_frame_trip = atoi(a); - const char *b = getenv("PSX_XPROBE_STK_KB"); if (b && *b) g_xprobe_stk_kb = atoi(b); - const char *c = getenv("PSX_XPROBE_WARMUP"); if (c && *c) g_xprobe_warmup = atoi(c); -} -void dirty_ram_xprobe_arm(int frame_trip, int stk_kb, int warmup) { - g_xprobe_frame_trip = frame_trip; g_xprobe_stk_kb = stk_kb; g_xprobe_warmup = warmup; - s_xprobe_tripped = 0; /* (re-)arm */ -} - -static void xprobe_flush_frame(void) { - if (s_xf_frame == 0xFFFFFFFFu) return; - XSum *s = &g_xsum[g_xsum_seq++ & (XSUM_CAP - 1u)]; - s->frame = s_xf_frame; s->crossings = s_xf_crossings; s->a954 = s_xf_a954; - s->depth_max = s_xf_depth_max; s->stk_max_kb = s_xf_stk_max_kb; - s->cyc_advanced = psx_cycle_count - s_xf_cyc_start; -} - -/* Record one boundary crossing. want_detail=1 for interp-site guest transfers - * (rich context), 0 for the dd-site (count + depth only). Runs the per-frame - * flush on frame change (leak-proof reset of g_mixed_depth) and the early trip. */ -static int g_xprobe_watch(uint32_t t) { - return t == 0x8001A954u || t == 0x80046264u || t == 0x8004630Cu || t == 0x8004DFA0u - /* MMX6 card-load firstfile flow (mmx6_card_load_regression_state): - * the mount 0x8001C1AC (works) vs firstfile flow 0x8001C4C0 (its body - * never runs) — record every call event + resolution for both. */ - || t == 0x8001C1ACu || t == 0x8001C4C0u; -} -static void xprobe_event(uint32_t src_pc, uint8_t op, uint8_t site, uint32_t target, - uint32_t ds_insn, uint32_t sp, uint32_t ra, int want_detail); -/* Watched-target call note for NON-interp call sites (overlay shard call-outs - * via dispatch_call). phase 0 = before the call (ds = a0), - * phase 1 = after it returned (ds = v0). Recorded as XOP_RES with - * site = 20+phase so the `watched` dump shows the shard-side call + result. */ -void dirty_ram_xprobe_call_note(CPUState *cpu, uint32_t target, uint32_t ra, uint8_t phase) { - if (!g_xprobe_watch(target)) return; - xprobe_event(ra, XOP_RES, (uint8_t)(20u + phase), target, - phase ? cpu->gpr[2] : cpu->gpr[4], cpu->gpr[29], cpu->gpr[31], 1); -} -static void xprobe_event(uint32_t src_pc, uint8_t op, uint8_t site, uint32_t target, - uint32_t ds_insn, uint32_t sp, uint32_t ra, int want_detail) { - uint32_t f = (uint32_t)s_frame_count; - if (f != s_xf_frame) { - xprobe_flush_frame(); - s_xf_frame = f; s_xf_crossings = 0; s_xf_a954 = 0; s_xf_depth_max = 0; - s_xf_stk_max_kb = 0; s_xf_cyc_start = psx_cycle_count; - g_mixed_depth = 0; /* shallow at frame top (proven ~5KB) — leak-proof reset */ - } - s_xf_crossings++; - if (target == 0x8001A954u) s_xf_a954++; - if (g_mixed_depth > s_xf_depth_max) s_xf_depth_max = g_mixed_depth; - if (g_mixed_depth > 0 && s_xf_first_depth_frame == 0xFFFFFFFFu) { - s_xf_first_depth_frame = f; s_xf_first_depth_cycle = psx_cycle_count; - } - uint32_t stk = (uint32_t)(interp_host_stack_used() >> 10); - if (stk > s_xf_stk_max_kb) s_xf_stk_max_kb = stk; - - if (want_detail || g_xprobe_watch(target)) { - XDetail *e = &g_xdet[g_xdet_seq++ & (XDET_CAP - 1u)]; - e->frame = f; e->cycle = psx_cycle_count; e->src_pc = src_pc; e->target = target; - e->ds_insn = ds_insn; e->sp = sp; e->ra = ra; e->stk_kb = stk; - e->istat = i_stat; e->imask = i_mask; - e->depth = (uint16_t)(g_mixed_depth > 0xFFFF ? 0xFFFF : g_mixed_depth); - e->op = op; e->site = site; - } - - xprobe_env_init(); - if (!s_xprobe_tripped && f >= (uint32_t)g_xprobe_warmup && - ((g_xprobe_frame_trip > 0 && (int)s_xf_crossings > g_xprobe_frame_trip) || - (g_xprobe_stk_kb > 0 && (int)stk > g_xprobe_stk_kb))) { - s_xprobe_tripped = 1; - extern void psx_fatal_halt(const char *reason); - psx_fatal_halt("xprobe: interp<->compiled boundary onset trip " - "(per-frame crossings/host-stack over armed threshold — see xprobe rings)"); - } -} - -/* Emit the flight-recorder rings as JSON (called by crash_trace). The detail dump - * is OLDEST-first so the transition (last normal frame -> first runaway crossings) - * is visible; the trip fires early enough that the onset is still in the ring. */ -int dirty_ram_xprobe_json(char *out, int cap) { - xprobe_flush_frame(); /* fold the in-progress frame into the summary */ - int n = snprintf(out, cap, - "{\n" - " \"tripped\": %d, \"trip_frame\": %u, \"mixed_depth_now\": %d,\n" - " \"first_depth_frame\": %u, \"first_depth_cycle\": %llu,\n" - " \"frame_trip\": %d, \"stk_kb_trip\": %d, \"warmup\": %d,\n", - s_xprobe_tripped, s_xf_frame, g_mixed_depth, - s_xf_first_depth_frame, (unsigned long long)s_xf_first_depth_cycle, - g_xprobe_frame_trip, g_xprobe_stk_kb, g_xprobe_warmup); - - /* watched-target events FIRST (sparse, the highest-value content): the WHOLE - * ring filtered to g_xprobe_watch targets + every XOP_RES resolution record. - * Emitted before summary/detail so the 56KB response cap can never truncate - * it (it truncated to [] when this section trailed the detail dump). */ - n += snprintf(out + n, cap - n, " \"watched\": ["); - { - static const char *opn2[] = {"JAL","JALR","JR","J","DD","BR","RES"}; - uint64_t total = g_xdet_seq; - uint32_t avail = total < XDET_CAP ? (uint32_t)total : XDET_CAP; - uint64_t base = total - avail; - int firstw = 1; - for (uint32_t i = 0; i < avail && n < cap - 240; i++) { - const XDetail *e = &g_xdet[(base + i) & (XDET_CAP - 1u)]; - if (e->op != XOP_RES && !g_xprobe_watch(e->target)) continue; - n += snprintf(out + n, cap - n, - "%s{\"f\":%u,\"cyc\":%llu,\"op\":\"%s\",\"site\":%u,\"src\":\"0x%08X\"," - "\"tgt\":\"0x%08X\",\"ds\":\"0x%08X\",\"sp\":\"0x%08X\",\"ra\":\"0x%08X\"," - "\"d\":%u,\"stk\":%u}", - firstw ? "" : ",", e->frame, (unsigned long long)e->cycle, - e->op < 7 ? opn2[e->op] : "?", e->site, e->src_pc, e->target, e->ds_insn, - e->sp, e->ra, e->depth, e->stk_kb); - firstw = 0; - } - } - n += snprintf(out + n, cap - n, "],\n"); - - /* per-frame summary (whole window in the ring) */ - n += snprintf(out + n, cap - n, " \"summary\": ["); - { - uint64_t total = g_xsum_seq; - uint32_t avail = total < XSUM_CAP ? (uint32_t)total : XSUM_CAP; - uint64_t start = total - avail; - for (uint32_t i = 0; i < avail && n < cap - 160; i++) { - const XSum *s = &g_xsum[(start + i) & (XSUM_CAP - 1u)]; - n += snprintf(out + n, cap - n, - "%s{\"f\":%u,\"cr\":%u,\"a954\":%u,\"dmax\":%d,\"stk\":%u,\"cyc\":%llu}", - i ? "," : "", s->frame, s->crossings, s->a954, s->depth_max, - s->stk_max_kb, (unsigned long long)s->cyc_advanced); - } - } - n += snprintf(out + n, cap - n, "],\n"); - - /* per-crossing detail, CENTERED on the focus frame (trip frame, or the current - * frame when live): dump from ~24 crossings before the focus frame's first - * crossing forward, so the transition (last normal frame -> runaway onset) is - * always captured regardless of ring fill. */ - n += snprintf(out + n, cap - n, " \"detail\": ["); - { - static const char *opn[] = {"JAL","JALR","JR","J","DD","BR"}; - uint64_t total = g_xdet_seq; - uint32_t avail = total < XDET_CAP ? (uint32_t)total : XDET_CAP; - uint64_t base = total - avail; /* oldest live entry */ - uint32_t focus = s_xf_frame; - uint32_t first = avail; /* first index of the focus frame */ - for (uint32_t i = 0; i < avail; i++) { - if (g_xdet[(base + i) & (XDET_CAP - 1u)].frame == focus) { first = i; break; } - } - uint32_t starto = (first == avail) ? (avail > 24u ? avail - 24u : 0u) - : (first > 24u ? first - 24u : 0u); - int firstdump = 1; - for (uint32_t i = starto; i < avail && n < cap - 240; i++) { - const XDetail *e = &g_xdet[(base + i) & (XDET_CAP - 1u)]; - n += snprintf(out + n, cap - n, - "%s{\"f\":%u,\"cyc\":%llu,\"op\":\"%s\",\"site\":%u,\"src\":\"0x%08X\"," - "\"tgt\":\"0x%08X\",\"ds\":\"0x%08X\",\"sp\":\"0x%08X\",\"ra\":\"0x%08X\"," - "\"d\":%u,\"stk\":%u,\"ist\":\"0x%X\",\"imk\":\"0x%X\"}", - firstdump ? "" : ",", e->frame, (unsigned long long)e->cycle, - e->op < 6 ? opn[e->op] : "?", e->site, e->src_pc, e->target, e->ds_insn, - e->sp, e->ra, e->depth, e->stk_kb, e->istat, e->imask); - firstdump = 0; - } - } - n += snprintf(out + n, cap - n, "]\n }"); - return n; -} - -/* Enter compiled code from the interpreter for a guest control transfer. Returns - * like psx_dispatch_game_compiled (1 = handled, OR a stack-watermark bail was - * surfaced; 0 = not a compiled target — caller falls through to overlay/dirty/ - * nonlocal paths). On surface: cpu->pc = target and g_psx_call_bail set. - * - * Only present when a game dispatch table is linked (PSX_HAS_GAME_DISPATCH). - * In the BIOS-only build psx_dispatch_game_compiled does not exist, and the - * two callers below are themselves gated behind the same macro. */ -#ifdef PSX_HAS_GAME_DISPATCH -static int interp_enter_compiled(CPUState *cpu, uint32_t target) { - if (target == 0x8001A954u) site_note(&g_site_interp); - /* Decline when the target page no longer matches the static game image. - * Returning 0 lets the JAL/JALR handler fall through to local-flow interp - * of the live RAM bytes instead of running stale compiled code. */ - if (!psx_game_text_native_ok(target)) return 0; - if (psx_mixed_owner_enabled() - && interp_host_stack_used() > psx_mixed_stack_watermark()) { - cpu->pc = target; - g_psx_call_bail = 1; - return 1; - } - g_mixed_depth++; - ls_func_enter(target, cpu); - int prev_phase = g_exec_phase; - g_exec_phase = 3; - int r = psx_dispatch_game_compiled(cpu, target); - g_exec_phase = prev_phase; - ls_func_exit(target, cpu, r); - g_mixed_depth--; - return r; -} -#endif /* PSX_HAS_GAME_DISPATCH */ - -/* Execute ONE instruction at *pc on the given CPU state. Returns: - * 0 = continue (advance pc by 4) - * 1 = control transferred OR unsupported opcode (caller checks - * g_unsupported_seen to distinguish). - * Branches encode their delay slot themselves before returning 1. */ -static int exec_one_fetched(CPUState *cpu, uint32_t pc, uint32_t insn, - uint32_t *next_pc_out); -static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) { - return exec_one_fetched(cpu, pc, fetch_word(pc & 0x1FFFFFFFu), next_pc_out); -} - -/* Forward: helper for delay-slot execution on jumps/branches. */ -static void exec_delay_slot(CPUState *cpu, uint32_t pc) { - /* Delay-slot instruction at pc must NOT be a control transfer. - * Recursively interpret as a single non-branching instruction. */ - uint32_t ds_phys = pc & 0x1FFFFFFFu; - uint32_t insn = fetch_word(ds_phys); - uint32_t opc = op_field(insn); - uint32_t fnt = funct_field(insn); - /* Reject branches/jumps in delay slots — undefined on R3000A and our - * static recompiler explicitly handles this case differently (the - * fall-through fix from 2026-04-21). In install stubs, delay slots - * are always nop or simple arithmetic. */ - if (opc == 0x02 /*j*/ || opc == 0x03 /*jal*/ || - opc == 0x04 /*beq*/ || opc == 0x05 /*bne*/ || - opc == 0x06 /*blez*/ || opc == 0x07 /*bgtz*/ || - opc == 0x01 /*regimm*/ || - (opc == 0x00 && (fnt == 0x08 /*jr*/ || fnt == 0x09 /*jalr*/))) { - (void)abort_unsupported(pc, insn, "control-transfer in delay slot"); - return; - } - uint32_t dummy_next = 0; - (void)exec_one_fetched(cpu, pc, insn, &dummy_next); - g_dirty_ram_insns_run++; - dirty_ram_perf_hot_record(pc); - /* CYCLE MODEL: the delay-slot instruction is a real retired R3000A instruction - * and is charged its own per-instruction interlock INSIDE exec_one (top-of-fn - * §1+deps+DO_LDS, or psx_cyc_load_* for a load delay slot) — so a branch+slot - * pair costs both, matching hardware. No separate charge here. */ -} - -/* Load-delay shim around the decoder (see dirty_ram_ld_delay_flush above). - * Wrapping here keeps every individual load case untouched: the inner decoder - * still writes gpr[rt] eagerly, and we roll that write back by one instruction - * so the delay-slot instruction observes the architecturally-correct value. */ -static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, - uint32_t *next_pc_out); - -#ifdef PSX_HAS_DISRUPTOR_VERTICAL_CAMERA -extern void disruptor_vertical_camera_instruction_hook( - CPUState *cpu, uint32_t address, uint32_t instruction, int phase); - -static int disruptor_vertical_camera_site(uint32_t pc, uint32_t insn) { - switch (pc) { - case 0x8003AD54u: case 0x8003ADE4u: case 0x8003B140u: - case 0x8003B1ECu: case 0x8003B764u: case 0x8003B990u: - case 0x8003BDA4u: case 0x8003C4B4u: case 0x8003C99Cu: - case 0x8003D07Cu: case 0x8003F6B0u: - return insn == 0x34020078u; - case 0x8003D398u: - return insn == 0x34030078u; - case 0x8003B900u: - return insn == 0x0205102Au; - case 0x8003B90Cu: - return insn == 0x0202102Au; - case 0x8003BD14u: - return insn == 0x0206102Au; - case 0x8003BD20u: - return insn == 0x0202102Au; - case 0x8003CDB8u: - return insn == 0x0065102Au; - case 0x8003CDC4u: - return insn == 0x0062102Au; - case 0x8002E4B8u: - return insn == 0x92640022u; - case 0x8002EAF0u: - return insn == 0x92430020u; - case 0x8004279Cu: - return insn == 0x8FBF016Cu; - default: - return 0; - } -} -#endif - -#ifdef PSX_HAS_DISRUPTOR_FAR_RENDERING -extern void disruptor_far_rendering_instruction_hook( - CPUState *cpu, uint32_t address, uint32_t instruction, int phase); - -static int disruptor_far_rendering_site(uint32_t pc, uint32_t insn) { - switch (pc) { - case 0x8003A0F8u: - return insn == 0x93B300C0u; - case 0x8003A390u: - return insn == 0xA082001Fu; - case 0x8003A92Cu: - return insn == 0x00005812u; - case 0x8003A964u: - return insn == 0x00681021u; - case 0x8003AA74u: - return insn == 0x00AC1021u; - case 0x8003A914u: - return insn == 0x8F8C04A0u; - case 0x8003B8C8u: case 0x8003C168u: case 0x8003CD64u: - return insn == 0x8F8304A0u; - case 0x8003BCE0u: - return insn == 0x8F8204A0u; - case 0x8003B8D8u: - return insn == 0x0072182Au; - case 0x8003BCECu: - return insn == 0x0053102Au; - case 0x8003C178u: case 0x8003CD70u: - return insn == 0x0077182Au; - case 0x8003B2CCu: case 0x8003BA30u: case 0x8003BE40u: - case 0x8003C628u: case 0x8003C878u: case 0x8003D28Cu: - case 0x80043184u: - return insn == 0x8F820494u; - case 0x8003FB0Cu: - return insn == 0x8F840494u; - case 0x800410E0u: - return insn == 0x93830310u; - case 0x800410F8u: - return insn == 0x8F820680u; - case 0x800411D8u: - return insn == 0x8F8301D4u; - case 0x8004279Cu: - return insn == 0x8FBF016Cu; - default: - return 0; - } -} -#endif - -#ifdef PSX_HAS_DISRUPTOR_BILLBOARD_ASPECT -extern void disruptor_billboard_aspect_instruction_hook( - CPUState *cpu, uint32_t address, uint32_t instruction, int phase); - -static int disruptor_billboard_aspect_site(uint32_t pc, uint32_t insn) { - switch (pc) { - case 0x8003BB88u: case 0x8003BFB0u: case 0x8003C848u: - return insn == 0xA6030016u; - case 0x8003CAD4u: - return insn == 0xA6020016u; - case 0x8003D488u: - return insn == 0xA2060025u; - case 0x800433A0u: - return insn == 0x02A02821u; - default: - return 0; - } -} -#endif - -#ifdef PSX_HAS_DISRUPTOR_INTRO_SKIP -extern void disruptor_intro_skip_instruction_hook( - CPUState *cpu, uint32_t address, uint32_t instruction, int phase); - -static int disruptor_intro_skip_site(uint32_t pc, uint32_t insn) { - return (pc == 0x800147B0u && insn == 0x2A820003u) || - (pc == 0x80020A80u && insn == 0x2A02005Eu) || - (pc == 0x80048678u && insn == 0x30420940u); -} -#endif - -#ifdef PSX_HAS_DISRUPTOR_SPRITE_DEPTH -extern void disruptor_sprite_depth_instruction_hook( - CPUState *cpu, uint32_t address, uint32_t instruction, int phase); - -static int disruptor_sprite_depth_site(uint32_t pc, uint32_t insn) { - switch (pc) { - case 0x8003B98Cu: case 0x8003BDA0u: case 0x8003C4B0u: case 0x8003D078u: - return insn == 0xAFA20010u; - case 0x8003D1B4u: - return insn == 0xA4379EA0u; - default: - return 0; - } -} -#endif - -#ifdef PSX_HAS_DISRUPTOR_BACKDROP_TILES -extern void disruptor_backdrop_tiles_instruction_hook( - CPUState *cpu, uint32_t address, uint32_t instruction, int phase); - -static int disruptor_backdrop_tile_site(uint32_t pc, uint32_t insn) { - switch (pc) { - case 0x8003B328u: case 0x8003B638u: - return insn == 0x8FB80040u; - case 0x8003B338u: - return insn == 0x00621821u; - case 0x8003B348u: - return insn == 0x8FB80028u; - case 0x8003B5A8u: - return insn == 0x8F8205B8u; - default: - return 0; - } -} -#endif - -static int exec_one_fetched(CPUState *cpu, uint32_t pc, uint32_t insn, - uint32_t *next_pc_out) { - /* A load's writeback becomes visible to the instruction AFTER its delay - * slot: load at N, hidden from N+1, visible from N+2. s_ld_pend_age tracks - * that: 0 = armed by the instruction just executed, 1 = the delay slot has - * now run, so retire it before executing anything else. - * - * Doing the retire HERE (rather than after the slot returns) is what makes - * branches correct: a branch's own delay slot is executed nested inside - * exec_one_fetched_inner, and re-enters this wrapper — by then age is 1, so - * the slot correctly observes the loaded value. */ - if (s_ld_pend_armed && s_ld_pend_age != 0u) { - s_ld_pend_armed = 0; - if (s_ld_pend_rt != 0u) cpu->gpr[s_ld_pend_rt] = s_ld_pend_val; - cpu->gpr[0] = 0; - } else if (s_ld_pend_armed) { - /* LWL/LWR exemption. `lwl rt,x` immediately followed by `lwr rt,y` is - * THE unaligned-load idiom every MIPS compiler emits, and MIPS-I - * explicitly permits the pair back-to-back: the second half merges with - * the first's result rather than the pre-load register. Hiding the - * pending write here would make the merge read a stale rt and silently - * corrupt every unaligned load (it wedged Tomba 2 at boot). Retire the - * write early so the partner sees it — this is forwarding, not a - * shortcut around the delay slot. */ - const uint32_t nx_op = op_field(insn); - const int is_lwlr = (nx_op == 0x22u || nx_op == 0x26u); - if (is_lwlr && rt_field(insn) == s_ld_pend_rt) { - s_ld_pend_armed = 0; - if (s_ld_pend_rt != 0u) cpu->gpr[s_ld_pend_rt] = s_ld_pend_val; - cpu->gpr[0] = 0; - } else { - s_ld_pend_age = 1u; /* this instruction IS the delay slot: stay hidden */ - } - } - - /* op 0x20..0x26 = LB/LH/LWL/LW/LBU/LHU/LWR. LWC2 (GTE, 0x32) targets a COP2 - * register, not a GPR, so it needs no deferral here. */ - const uint32_t ld_op = op_field(insn); - const uint32_t ld_rt = rt_field(insn); - const uint32_t pc_phys = pc & 0x1FFFFFFFu; - /* OpenBIOS executes cardfasttrack both from ROM and from its low-RAM - * kernel copy (for example 0x3554..0x36D4). Its hand-written dependent - * load requires the real R3000A value delay. Game-owned dirty RAM crosses - * mixed compiled/interpreted boundaries that do not carry this pending - * writeback yet, so preserve the historical eager-value contract there. */ - const int in_bios_rom = - pc_phys >= 0x1FC00000u && pc_phys < 0x1FC80000u; - const int in_bios_kernel_ram = pc_phys < 0x00010000u; - const int is_ld = (in_bios_rom || in_bios_kernel_ram) && - (ld_op >= 0x20u && ld_op <= 0x26u) && - (ld_rt != 0u); - const uint32_t ld_before = is_ld ? cpu->gpr[ld_rt] : 0u; - - const int rv = exec_one_fetched_inner(cpu, pc, insn, next_pc_out); - -#ifdef PSX_HAS_DISRUPTOR_VERTICAL_CAMERA - if (disruptor_vertical_camera_site(pc, insn)) { - disruptor_vertical_camera_instruction_hook(cpu, pc, insn, 1); - } -#endif - -#ifdef PSX_HAS_DISRUPTOR_FAR_RENDERING - if (disruptor_far_rendering_site(pc, insn)) { - disruptor_far_rendering_instruction_hook(cpu, pc, insn, 1); - } -#endif - -#ifdef PSX_HAS_DISRUPTOR_BILLBOARD_ASPECT - if (disruptor_billboard_aspect_site(pc, insn)) { - disruptor_billboard_aspect_instruction_hook(cpu, pc, insn, 1); - } -#endif - -#ifdef PSX_HAS_DISRUPTOR_INTRO_SKIP - if (disruptor_intro_skip_site(pc, insn)) { - disruptor_intro_skip_instruction_hook(cpu, pc, insn, 1); - } -#endif - -#ifdef PSX_HAS_DISRUPTOR_SPRITE_DEPTH - if (disruptor_sprite_depth_site(pc, insn)) { - disruptor_sprite_depth_instruction_hook(cpu, pc, insn, 1); - } -#endif - -#ifdef PSX_HAS_DISRUPTOR_BACKDROP_TILES - if (disruptor_backdrop_tile_site(pc, insn)) { - disruptor_backdrop_tiles_instruction_hook(cpu, pc, insn, 1); - } -#endif - - if (is_ld) { - const uint32_t loaded = cpu->gpr[ld_rt]; - if (loaded != ld_before) { - /* Back-to-back loads: retire the older writeback before reusing the - * slot, otherwise its register write would be dropped entirely. */ - if (s_ld_pend_armed && s_ld_pend_rt != ld_rt && s_ld_pend_rt != 0u) - cpu->gpr[s_ld_pend_rt] = s_ld_pend_val; - /* Undo the eager write; it becomes visible one instruction later. - * On a same-register conflict this naturally makes the LOAD win, - * matching what the compiled backend emits for a dependent pair. */ - cpu->gpr[ld_rt] = ld_before; - s_ld_pend_rt = ld_rt; - s_ld_pend_val = loaded; - s_ld_pend_age = 0u; - s_ld_pend_armed = 1; - cpu->gpr[0] = 0; - } - } - return rv; -} - -static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, - uint32_t *next_pc_out) { - exec_pc_table_record(pc); - uint32_t opc = op_field(insn); - uint32_t rs = rs_field(insn); - uint32_t rt = rt_field(insn); - uint32_t rd = rd_field(insn); - uint32_t sh = shamt_field(insn); - uint32_t fnt = funct_field(insn); - int32_t simm = simm16_field(insn); - uint32_t imm = imm16_field(insn); - - *next_pc_out = pc + 4; - -#ifdef PSX_ENABLE_BLOCK_CYCLES - /* Instruction FETCH cost (I-cache) — charged FIRST, before the §1 base, exactly - * like Beetle ReadInstruction precedes the per-instruction base (cpu.cpp). HIT=+0, - * KSEG1=+4, cached miss=+3+refill; a miss also clears the load give-back. */ - psx_icache_fetch_interp(cpu, pc); - - /* Per-instruction R3000A load-delay interlock (single-source: psx_cyc.h, shared - * with both static emitters). §1 base + GPR_DEPRES + DO_LDS run HERE, before the - * instruction body, so §1 precedes any muldiv/GTE deadline stall the body applies - * (Beetle order). CPU loads (op 0x20-0x26) are skipped here — psx_cyc_load_* runs - * their full interlock inside the body (and arms LDWhich=rt). This replaces the - * old flat per-instruction psx_advance_cycles(psx_instr_base_cycles). */ - if (!(opc >= 0x20u && opc <= 0x26u)) - interp_cyc_step(cpu, psx_cyc_dep_res_mask(insn)); -#endif - - /* Widescreen far-backdrop column PRELOAD (auto_backdrop). At a detected - * window START/END finalize, force the loop bound (START->0 / END->sentinel) - * so the generator's own clamps preload the WHOLE finite tile row into the - * revealed 16:9 margin. The site is a move/addiu that writes exactly one GPR - * and has no other effect, so substituting the value and advancing pc+4 is - * complete. Gated on the runtime predicate first => 4:3 pays nothing. Each - * rewrite is recorded to the always-on backdrop ring with the live extent - * (s7), camera-X (scratchpad 0x176) and DL count for `ws_backdrop_ring`. */ - /* Opcode pre-filter FIRST: only addu/or/addi/addiu can be a rewrite site - * (the detector below rewrites exactly one GPR-writing move/addiu). The old - * order ran the ±512-byte site scan for EVERY interpreted instruction; with - * the small direct-mapped cache thrashing, that was the mechanism of the - * native-wide 16:9 CPU collapse (Tomba2 attract 12fps, stack-sampled). */ - if (((opc == 0x00u && (fnt == 0x21u || fnt == 0x25u)) || - opc == 0x09u || opc == 0x08u) && - psx_ws_backdrop_preload()) { - int wcols = 0; - int bk = ws_backdrop_site_kind(pc, &wcols); - if (bk != WS_BD_NONE) { - uint32_t dest = (opc == 0x00u && (fnt == 0x21u || fnt == 0x25u)) ? rd - : (opc == 0x09u || opc == 0x08u) ? rt - : 0xFFFFFFFFu; - if (dest != 0xFFFFFFFFu) { - /* orig = the instruction's normal result, so the widen shifts the - * real camera-tracked bound (move: gpr[src]; addiu: gpr[rs]+simm). */ - uint32_t orig = (opc == 0x00u) - ? ((rt == 0u) ? cpu->gpr[rs] : cpu->gpr[rt]) - : (cpu->gpr[rs] + (uint32_t)simm); - /* Tell psx_ws_backdrop_value the interp will record the rich ring - * entry (it skips its own note when this flag is set). */ - g_ws_bd_from_interp = 1; - uint32_t finalv = psx_ws_backdrop_value(orig, bk == WS_BD_END, wcols); - cpu->gpr[dest] = finalv; - cpu->gpr[0] = 0; - /* Rich snapshot for the ring: s7 (gpr[23]) = byte tile-row extent; - * s0 (gpr[16]) = DL object, count byte at +3; camera-X = scratchpad - * halfword 0x176. */ - { - int extent = (int)(int16_t)cpu->gpr[23]; - int camx = (int)(int16_t)(cpu->read_word(0x1F800174u) >> 16); - uint32_t s0a = cpu->gpr[16] + 3u; - int count = (int)((cpu->read_word(s0a & ~3u) >> ((s0a & 3u) * 8u)) & 0xFFu); - /* base = s4 (gpr[20]) = backdrop data ptr (extent@+0, table@+4); - * dl = s0 (gpr[16]) = the ordering-table object. */ - uint32_t a1 = cpu->gpr[20]; - psx_ws_backdrop_ring_note(pc, bk, wcols, orig, finalv, extent, camx, - count, a1, cpu->gpr[16]); - /* Publish the backdrop structure's address range so the GL - * 2D-stretch gate can match tile prims by gp0_cmd_source_addr. - * Tiles live at a1 + table[col]; bound by the LAST table entry - * (table is ascending) + packet slack. */ - if (extent > 0 && extent <= 256) { - extern uint32_t g_ws_backdrop_lo, g_ws_backdrop_hi; - uint32_t tbl_last = cpu->read_word(a1 + 4u + (uint32_t)(extent - 1) * 4u); - g_ws_backdrop_lo = a1; - g_ws_backdrop_hi = a1 + tbl_last + 0x400u; - } - } - return 0; - } - } - } - - switch (opc) { - case 0x00: /* SPECIAL */ - switch (fnt) { - case 0x00: /* SLL rd, rt, sh (also nop when all fields are 0) */ - cpu->gpr[rd] = cpu->gpr[rt] << sh; - cpu->gpr[0] = 0; - return 0; - case 0x02: /* SRL */ - cpu->gpr[rd] = cpu->gpr[rt] >> sh; - cpu->gpr[0] = 0; - return 0; - case 0x03: /* SRA */ - cpu->gpr[rd] = (uint32_t)((int32_t)cpu->gpr[rt] >> sh); - cpu->gpr[0] = 0; - return 0; - case 0x04: /* SLLV */ - cpu->gpr[rd] = cpu->gpr[rt] << (cpu->gpr[rs] & 31); - cpu->gpr[0] = 0; - return 0; - case 0x06: /* SRLV */ - cpu->gpr[rd] = cpu->gpr[rt] >> (cpu->gpr[rs] & 31); - cpu->gpr[0] = 0; - return 0; - case 0x07: /* SRAV */ - cpu->gpr[rd] = (uint32_t)((int32_t)cpu->gpr[rt] >> (cpu->gpr[rs] & 31)); - cpu->gpr[0] = 0; - return 0; - case 0x08: { /* JR rs */ - uint32_t target = cpu->gpr[rs]; - exec_delay_slot(cpu, pc + 4); - cosim_exec_one_transfer_hook(pc + 4); - /* crossing (if target is compiled) is counted at the block-loop - * tail-transfer site (interp_enter_compiled, §18) — not here, to - * avoid double-counting J/JR-to-compiled. */ - cpu->pc = target; - return 1; - } - case 0x09: { /* JALR rd, rs */ - uint32_t target = cpu->gpr[rs]; - uint32_t return_pc = pc + 8; - cpu->gpr[rd ? rd : 31] = return_pc; - cpu->gpr[0] = 0; - exec_delay_slot(cpu, pc + 4); - cosim_exec_one_transfer_hook(pc + 4); - uint32_t site_sp = cpu->gpr[29]; /* call contract: sp at the call */ -#ifndef PSX_NO_DEBUG_TOOLS - xprobe_event(pc, XOP_JALR, XSITE_INTERP, target, - fetch_word((pc + 4) & 0x1FFFFFFFu), site_sp, cpu->gpr[31], 1); -#endif - uint32_t _cr = callret_begin(cpu, pc, target); /* call-resolution ring */ -#define CRET(code, rv) do { callret_end(_cr, cpu, (code)); return (rv); } while (0) - if (g_precise_mode || g_ls_replay_active) { cpu->pc = target; CRET(CRES_PLAIN, 1); } /* slice / lockstep-replay: plain transfer, never execute the callee */ -#ifdef PSX_HAS_GAME_DISPATCH - cpu->pc = 0; - if (interp_enter_compiled(cpu, target)) { - if (g_psx_call_bail) CRET(CRES_EC_BAIL, 1); /* wild unwind: cpu->pc = true target */ - if (cpu->pc != 0) CRET(CRES_EC_PC, 1); - if (rd == 0 || rd == 31) { - if (psx_call_contract(cpu, return_pc, site_sp)) CRET(CRES_EC_CONTRACT, 1); - } - { int _r = dirty_ram_finish_call_return(cpu, return_pc, next_pc_out); - CRET(CRES_EC_RET | (_r ? 0x100u : 0u), _r); } - } -#endif - /* Native overlay candidates get the SAME call contract as - * statically-compiled callees: run as a unit, resume at - * return_pc. A bare pc-chain here loses the return obligation - * when the callee runs natively (C return, pc==0) — the dispatch - * loop unwinds past the suspended caller, leaking its frame. */ - { - extern int overlay_loader_call_native(CPUState *cpu, uint32_t addr); - cpu->pc = 0; - if (overlay_loader_call_native(cpu, target)) { - if (g_psx_call_bail) CRET(CRES_OV_BAIL, 1); - if (cpu->pc != 0) CRET(CRES_OV_PC, 1); - if (rd == 0 || rd == 31) { - if (psx_call_contract(cpu, return_pc, site_sp)) CRET(CRES_OV_CONTRACT, 1); - } - { int _r = dirty_ram_finish_call_return(cpu, return_pc, next_pc_out); - CRET(CRES_OV_RET | (_r ? 0x100u : 0u), _r); } - } - } - if (!is_local_dirty_target(target)) { - int _r = dispatch_nonlocal_call(cpu, target, return_pc, next_pc_out); - CRET(g_nl_exit_code | ((_r && g_nl_exit_code == CRES_NL_RET) ? 0x100u : 0u), _r); - } - cpu->pc = target; - CRET(CRES_PCCHAIN, 1); -#undef CRET - } - case 0x0C: /* SYSCALL */ - cpu->pc = pc; - psx_syscall(cpu, (insn >> 6) & 0xFFFFFu); - return (cpu->pc != 0); - case 0x0D: /* BREAK */ - psx_break(cpu, (insn >> 6) & 0xFFFFFu, pc); - return 1; - case 0x0F: /* SYNC */ - return 0; - case 0x10: /* MFHI */ -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_muldiv_stall(cpu); /* stall to mult/div completion (faithful) */ -#endif - cpu->gpr[rd] = cpu->hi; - cpu->gpr[0] = 0; - return 0; - case 0x11: /* MTHI */ - cpu->hi = cpu->gpr[rs]; - return 0; - case 0x12: /* MFLO */ -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_muldiv_stall(cpu); /* stall to mult/div completion (faithful) */ -#endif - cpu->gpr[rd] = cpu->lo; - cpu->gpr[0] = 0; - return 0; - case 0x13: /* MTLO */ - cpu->lo = cpu->gpr[rs]; - return 0; - case 0x18: { /* MULT */ - int64_t r = (int64_t)(int32_t)cpu->gpr[rs] * (int64_t)(int32_t)cpu->gpr[rt]; - cpu->lo = (uint32_t)r; - cpu->hi = (uint32_t)((uint64_t)r >> 32); -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_muldiv_set(cpu, psx_mult_latency_s(cpu->gpr[rs])); /* completion deadline */ -#endif - return 0; - } - case 0x19: { /* MULTU */ - uint64_t r = (uint64_t)cpu->gpr[rs] * (uint64_t)cpu->gpr[rt]; - cpu->lo = (uint32_t)r; - cpu->hi = (uint32_t)(r >> 32); -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_muldiv_set(cpu, psx_mult_latency_u(cpu->gpr[rs])); /* completion deadline */ -#endif - return 0; - } - case 0x1A: { /* DIV */ - int32_t a = (int32_t)cpu->gpr[rs]; - int32_t b = (int32_t)cpu->gpr[rt]; - if (b == 0) { - cpu->lo = (a < 0) ? 1u : 0xFFFFFFFFu; - cpu->hi = (uint32_t)a; - } else if ((uint32_t)a == 0x80000000u && b == -1) { - cpu->lo = 0x80000000u; - cpu->hi = 0; - } else { - cpu->lo = (uint32_t)(a / b); - cpu->hi = (uint32_t)(a % b); - } -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_muldiv_set(cpu, 37u); /* DIV completion deadline (fixed) */ -#endif - return 0; - } - case 0x1B: /* DIVU */ - if (cpu->gpr[rt] == 0) { - cpu->lo = 0xFFFFFFFFu; - cpu->hi = cpu->gpr[rs]; - } else { - cpu->lo = cpu->gpr[rs] / cpu->gpr[rt]; - cpu->hi = cpu->gpr[rs] % cpu->gpr[rt]; - } -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_muldiv_set(cpu, 37u); /* DIVU completion deadline (fixed) */ -#endif - return 0; - case 0x20: /* ADD - overflow traps are delegated if they occur. */ - case 0x21: /* ADDU rd, rs, rt */ - cpu->gpr[rd] = cpu->gpr[rs] + cpu->gpr[rt]; - cpu->gpr[0] = 0; - return 0; - case 0x22: /* SUB - overflow traps are delegated if they occur. */ - case 0x23: /* SUBU */ - if (rs == 0 && psx_ws_is_cull_negsub_site(pc)) - cpu->gpr[rd] = 0u - cpu->gpr[rt] - (uint32_t)psx_ws_x_margin(); - else - cpu->gpr[rd] = cpu->gpr[rs] - cpu->gpr[rt]; - cpu->gpr[0] = 0; - return 0; - case 0x24: /* AND */ - cpu->gpr[rd] = cpu->gpr[rs] & cpu->gpr[rt]; - cpu->gpr[0] = 0; - return 0; - case 0x25: /* OR */ - cpu->gpr[rd] = cpu->gpr[rs] | cpu->gpr[rt]; - cpu->gpr[0] = 0; - return 0; - case 0x26: /* XOR */ - cpu->gpr[rd] = cpu->gpr[rs] ^ cpu->gpr[rt]; - cpu->gpr[0] = 0; - return 0; - case 0x27: /* NOR */ - cpu->gpr[rd] = ~(cpu->gpr[rs] | cpu->gpr[rt]); - cpu->gpr[0] = 0; - return 0; - case 0x2A: /* SLT */ - { - int32_t lhs = (int32_t)cpu->gpr[rs]; - int32_t rhs = (int32_t)cpu->gpr[rt]; - int32_t adjusted_rhs = parappa_timing_adjust_threshold(pc, rhs); - uint32_t vanilla = - (lhs < rhs) ? 1u : 0u; - uint32_t kept = vanilla; - if (adjusted_rhs != rhs) - kept = (lhs < adjusted_rhs) ? 1u : 0u; - if (!psx_ws_aspect_cone_site(cpu, pc, insn, vanilla, &kept)) - (void)psx_ws_cull_keep_site(pc, insn, vanilla, &kept); - parappa_rhythm_log_compare(cpu, pc, (uint32_t)lhs, - (uint32_t)rhs, - (uint32_t)adjusted_rhs, - vanilla, kept); - cpu->gpr[rd] = kept; - cpu->gpr[0] = 0; - return 0; - } - case 0x2B: /* SLTU */ - { - uint32_t vanilla = (cpu->gpr[rs] < cpu->gpr[rt]) ? 1u : 0u; - uint32_t kept = vanilla; - (void)psx_ws_cull_keep_site(pc, insn, vanilla, &kept); - cpu->gpr[rd] = kept; - cpu->gpr[0] = 0; - return 0; - } - default: - return abort_unsupported(pc, insn, "SPECIAL funct"); - } - break; - - case 0x02: { /* J target */ - uint32_t target = ((pc + 4) & 0xF0000000u) | (target26(insn) << 2); - exec_delay_slot(cpu, pc + 4); - cosim_exec_one_transfer_hook(pc + 4); - /* crossing counted at the block-loop tail-transfer site (§18). */ - cpu->pc = target; - return 1; - } - case 0x03: { /* JAL target */ - uint32_t target = ((pc + 4) & 0xF0000000u) | (target26(insn) << 2); - uint32_t return_pc = pc + 8; - cpu->gpr[31] = return_pc; - exec_delay_slot(cpu, pc + 4); - cosim_exec_one_transfer_hook(pc + 4); - uint32_t site_sp = cpu->gpr[29]; /* call contract: sp at the call */ -#ifndef PSX_NO_DEBUG_TOOLS - int xw = g_xprobe_watch(target); /* record RESOLUTION path for watched targets */ - xprobe_event(pc, XOP_JAL, XSITE_INTERP, target, - fetch_word((pc + 4) & 0x1FFFFFFFu), site_sp, cpu->gpr[31], 1); -#define XRES(code) do { if (xw) xprobe_event(pc, XOP_RES, (uint8_t)(code), target, \ - cpu->gpr[2], cpu->gpr[29], cpu->gpr[31], 1); } while (0) -#else -#define XRES(code) do { (void)(code); } while (0) -#endif - if (g_precise_mode || g_ls_replay_active) { cpu->pc = target; return 1; } /* slice / lockstep-replay: plain transfer, never execute the callee */ -#ifdef PSX_HAS_GAME_DISPATCH - cpu->pc = 0; - if (interp_enter_compiled(cpu, target)) { - if (g_psx_call_bail) { XRES(XRES_EC_BAIL); return 1; } /* wild unwind: cpu->pc = true target */ - if (cpu->pc != 0) { XRES(XRES_EC_PC); return 1; } - if (psx_call_contract(cpu, return_pc, site_sp)) { XRES(XRES_EC_CONTRACT); return 1; } - XRES(XRES_EC_RET); - return dirty_ram_finish_call_return(cpu, return_pc, next_pc_out); - } -#endif - /* Native overlay candidates get the SAME call contract as statically- - * compiled callees: run as a unit, resume at return_pc. A bare - * pc-chain here loses the return obligation when the callee runs - * natively (C return, pc==0) — the dispatch loop unwinds past the - * suspended caller, leaking its frame (dwarf->overworld root cause: - * F=0x800338A8's epilogue skipped, sp leaked 0x18 per entity). */ - { - extern int overlay_loader_call_native(CPUState *cpu, uint32_t addr); - cpu->pc = 0; - if (overlay_loader_call_native(cpu, target)) { - if (g_psx_call_bail) { XRES(XRES_OV_BAIL); return 1; } - if (cpu->pc != 0) { XRES(XRES_OV_PC); return 1; } - if (psx_call_contract(cpu, return_pc, site_sp)) { XRES(XRES_OV_CONTRACT); return 1; } - XRES(XRES_OV_RET); - return dirty_ram_finish_call_return(cpu, return_pc, next_pc_out); - } - } - if (!is_local_dirty_target(target)) { - XRES(XRES_NONLOCAL); - return dispatch_nonlocal_call(cpu, target, return_pc, next_pc_out); - } - if (!dirty_ram_word_looks_decodable(fetch_word(target & 0x1FFFFFFFu))) { - XRES(XRES_UNDECODABLE); - return dispatch_nonlocal_call(cpu, target, return_pc, next_pc_out); - } - XRES(XRES_PCCHAIN); - cpu->pc = target; - return 1; -#undef XRES - } - case 0x04: { /* BEQ rs, rt, simm */ - parappa_timing_apply_final_window(cpu, pc); - int taken = (cpu->gpr[rs] == cpu->gpr[rt]); - exec_delay_slot(cpu, pc + 4); - cosim_exec_one_transfer_hook(pc + 4); - cpu->pc = taken ? (pc + 4 + (simm << 2)) : (pc + 8); - return 1; - } - case 0x05: { /* BNE */ - int taken = (cpu->gpr[rs] != cpu->gpr[rt]); - exec_delay_slot(cpu, pc + 4); - cosim_exec_one_transfer_hook(pc + 4); - cpu->pc = taken ? (pc + 4 + (simm << 2)) : (pc + 8); - return 1; - } - case 0x06: { /* BLEZ */ - int taken = ((int32_t)cpu->gpr[rs] <= 0); - exec_delay_slot(cpu, pc + 4); - cosim_exec_one_transfer_hook(pc + 4); - cpu->pc = taken ? (pc + 4 + (simm << 2)) : (pc + 8); - return 1; - } - case 0x07: { /* BGTZ */ - int taken = ((int32_t)cpu->gpr[rs] > 0); - exec_delay_slot(cpu, pc + 4); - cosim_exec_one_transfer_hook(pc + 4); - cpu->pc = taken ? (pc + 4 + (simm << 2)) : (pc + 8); - return 1; - } - case 0x01: { /* REGIMM: BLTZ/BGEZ/BLTZAL/BGEZAL by rt field */ - int taken; - switch (rt) { - case 0x00: /* BLTZ */ - /* Widescreen render-funnel LEFT-edge widen (auto_screen_x): a - * classified funnel bltz rejects only past the revealed margin. - * Identity at 4:3 (margin 0). Gated per-game, cheap cached scan. */ - if (psx_ws_auto_cull_on() && ws_cull_bltz_site(pc)) - taken = psx_ws_cull_bltz(cpu->gpr[rs]); - else - taken = ((int32_t)cpu->gpr[rs] < 0); - break; - case 0x01: /* BGEZ */ taken = ((int32_t)cpu->gpr[rs] >= 0); break; - case 0x10: /* BLTZAL */ taken = ((int32_t)cpu->gpr[rs] < 0); - cpu->gpr[31] = pc + 8; break; - case 0x11: /* BGEZAL */ taken = ((int32_t)cpu->gpr[rs] >= 0); - cpu->gpr[31] = pc + 8; break; - default: return abort_unsupported(pc, insn, "REGIMM rt"); - } - exec_delay_slot(cpu, pc + 4); - cosim_exec_one_transfer_hook(pc + 4); - cpu->pc = taken ? (pc + 4 + (simm << 2)) : (pc + 8); - return 1; - } - case 0x08: /* ADDI rt, rs, simm — same as ADDIU, sans overflow trap (we don't model traps here) */ - { - uint32_t widened = 0; - if (psx_ws_angle_site(pc, insn, &widened)) - cpu->gpr[rt] = widened; - else - cpu->gpr[rt] = cpu->gpr[rs] + (uint32_t)simm - + (psx_ws_is_cull_bias_site(pc) - ? (uint32_t)psx_ws_activation_margin() : 0u); - cpu->gpr[0] = 0; - return 0; - } - case 0x09: /* ADDIU rt, rs, simm */ - { - uint32_t widened = 0; - if (psx_ws_angle_site(pc, insn, &widened)) - cpu->gpr[rt] = widened; - else - cpu->gpr[rt] = cpu->gpr[rs] + (uint32_t)simm - + (psx_ws_is_cull_bias_site(pc) - ? (uint32_t)psx_ws_activation_margin() : 0u); - cpu->gpr[0] = 0; - return 0; - } - case 0x0A: /* SLTI */ - { - uint32_t vanilla = ((int32_t)cpu->gpr[rs] < simm) ? 1u : 0u; - uint32_t kept = vanilla; - if (psx_ws_aspect_cone_site(cpu, pc, insn, vanilla, &kept)) - cpu->gpr[rt] = kept; - else if (psx_ws_cull_keep_site(pc, insn, vanilla, &kept)) - cpu->gpr[rt] = kept; - else if (psx_ws_is_cull_depth_site(pc)) - cpu->gpr[rt] = ((int32_t)cpu->gpr[rs] < psx_ws_depth_bound(simm)) ? 1u : 0u; - /* Widescreen render-funnel RIGHT-edge widen (auto_screen_x) for the - * signed min/max funnel idiom (`slti v, minSX, W`) — the paired left - * edge is the bltz above. Identity at 4:3 (margin 0). */ - else if (psx_ws_is_cull_slti_lower_site(pc)) - cpu->gpr[rt] = (uint32_t)psx_ws_cull_slti_lower( - cpu->gpr[rs], imm); - else if (psx_ws_is_cull_slti_site(pc) || - (psx_ws_auto_cull_on() && psx_ws_is_cull_w_imm(imm) && ws_cull_site(pc))) - cpu->gpr[rt] = (uint32_t)psx_ws_cull_slti(cpu->gpr[rs], imm); - else - cpu->gpr[rt] = ((int32_t)cpu->gpr[rs] < simm) ? 1u : 0u; - cpu->gpr[0] = 0; - return 0; - } - case 0x0B: /* SLTIU */ - { - uint32_t vanilla = (cpu->gpr[rs] < (uint32_t)simm) ? 1u : 0u; - uint32_t kept = vanilla; - /* Widescreen render-funnel cull widening (auto_screen_x): apply the - * shared helper for a flagged render-cull site — it is byte-identical - * to the vanilla compare at 4:3 (margin 0) and widens at 16:9, so the one - * code path serves both aspects (no widescreen-specific caching). */ - if (psx_ws_cull_keep_site(pc, insn, vanilla, &kept)) - cpu->gpr[rt] = kept; - else if (psx_ws_is_cull_depth_site(pc)) - cpu->gpr[rt] = (cpu->gpr[rs] < - (uint32_t)psx_ws_depth_bound(simm)) ? 1u : 0u; - else if (psx_ws_is_cull_vxrange_site(pc)) - cpu->gpr[rt] = (uint32_t)psx_ws_cull_vxrange(cpu->gpr[rs], imm); - else if (psx_ws_is_cull_range_site(pc)) { - /* Explicit world-space classifier widen. The native emitter uses - * the same bound transform for configured range_sites. */ - cpu->gpr[rt] = (cpu->gpr[rs] < - ((uint32_t)simm + 2u * - (uint32_t)psx_ws_activation_margin())) ? 1u : 0u; - } - else if (psx_ws_auto_cull_on() && psx_ws_is_cull_w_imm(imm) && ws_cull_site(pc)) - cpu->gpr[rt] = (uint32_t)psx_ws_cull_sltiu(cpu->gpr[rs], imm); - else - cpu->gpr[rt] = (cpu->gpr[rs] < (uint32_t)simm) ? 1u : 0u; - cpu->gpr[0] = 0; - return 0; - } - case 0x0C: /* ANDI */ - cpu->gpr[rt] = cpu->gpr[rs] & imm; - cpu->gpr[0] = 0; - return 0; - case 0x0D: /* ORI */ - cpu->gpr[rt] = cpu->gpr[rs] | imm; - cpu->gpr[0] = 0; - return 0; - case 0x0E: /* XORI */ - cpu->gpr[rt] = cpu->gpr[rs] ^ imm; - cpu->gpr[0] = 0; - return 0; - case 0x0F: /* LUI rt, imm */ - if (psx_ws_is_signed_x_bound_site(pc, insn)) - cpu->gpr[rt] = (uint32_t)psx_ws_player_x_bound((int32_t)(imm << 16)); - else - cpu->gpr[rt] = imm << 16; - cpu->gpr[0] = 0; - return 0; - case 0x10: { /* COP0 */ - uint32_t cop_op = rs; - if (cop_op == 0x00) { /* MFC0 — delayed load (Beetle: LDAbsorb=0, LDWhich=rt) */ -#ifdef PSX_ENABLE_BLOCK_CYCLES - cpu->ld_absorb = 0u; - cpu->ld_which_t = (uint8_t)rt; -#endif - cpu->gpr[rt] = cpu->cop0[rd]; - cpu->gpr[0] = 0; - return 0; - } - if (cop_op == 0x04) { /* MTC0 */ - cpu->cop0[rd] = cpu->gpr[rt]; - return 0; - } - if (cop_op == 0x10 && fnt == 0x10) { /* RFE */ - uint32_t sr = cpu->cop0[12]; - cpu->cop0[12] = (sr & 0xFFFFFFF0u) | ((sr >> 2) & 0x0Fu); - /* Backend contract parity (overlay_api.h v12; the emitter calls - * this after every recompiled rfe): inside the synchronous - * handler window this arms the host escape taken at the next - * committed transfer. Without it an interpreted handler whose - * post-RFE EPC lands in dirty RAM keeps interpreting flat inside - * psx_check_interrupts' window — in_exception never clears, the - * cycle-paced VBlank authority is vetoed forever, and the guest - * livelocks in the kernel idle loop (dbg-build boot wedge). */ - psx_rfe_mark_escape(); - return 0; - } - return abort_unsupported(pc, insn, "COP0 op"); - } - case 0x12: { /* COP2 / GTE */ - uint32_t cop_op = rs; - /* Faithful GTE: any COP2 register access stalls to the pending command - * completion deadline (gte_execute armed it via psx_gte_set). */ - if (cop_op == 0x00) { /* MFC2 — read: stall + give-back (Beetle) */ -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_gte_read(cpu, rt); -#endif - cpu->gpr[rt] = gte_precision_mfc2_pc_read( - pc, insn, (uint8_t)rd, - gte_read_data(cpu, (uint8_t)rd)); - cpu->gpr[0] = 0; - return 0; - } - if (cop_op == 0x02) { /* CFC2 — read: stall + give-back (Beetle) */ -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_gte_read(cpu, rt); -#endif - cpu->gpr[rt] = gte_read_ctrl(cpu, (uint8_t)rd); - cpu->gpr[0] = 0; - return 0; - } - if (cop_op == 0x04) { /* MTC2 */ -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_gte_stall(cpu); -#endif - gte_write_data(cpu, (uint8_t)rd, cpu->gpr[rt]); - return 0; - } - if (cop_op == 0x06) { /* CTC2 */ -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_gte_stall(cpu); -#endif - gte_write_ctrl(cpu, (uint8_t)rd, cpu->gpr[rt]); - return 0; - } - if (cop_op & 0x10) { - gte_execute(cpu, insn & 0x1FFFFFFu); - return 0; - } - return abort_unsupported(pc, insn, "COP2 op"); - } - /* CPU loads: the full per-instruction R3000A interlock (§1 + GPR_DEPRES(rs) + - * DO_LDS + ReadMemory timing + arm LDWhich=rt) lives in psx_cyc_load_*; exec_one's - * top-of-function step is skipped for op 0x20-0x26 so it is not double-charged. - * #ifndef PSX_ENABLE_BLOCK_CYCLES these still read the value via the uncharged - * accessor (psx_cyc_load_* falls back to a plain read when cycles are off). */ - case 0x20: { /* LB rt, simm(rs) */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - cpu->gpr[rt] = (uint32_t)(int32_t)(int8_t)psx_cyc_load_byte(cpu, addr, rt, 1u << rs); - cpu->gpr[0] = 0; - return 0; - } - case 0x21: { /* LH */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - cpu->gpr[rt] = (uint32_t)(int32_t)(int16_t)psx_cyc_load_half(cpu, addr, rt, 1u << rs); - cpu->gpr[0] = 0; - return 0; - } - case 0x22: { /* LWL */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - uint32_t word = psx_cyc_load_word(cpu, addr & ~3u, rt, 1u << rs); - cpu->gpr[rt] = lwl_merge(addr, word, cpu->gpr[rt]); - cpu->gpr[0] = 0; - return 0; - } - case 0x23: { /* LW */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - if (psx_ws_is_cull_plane_nx_site(pc)) - /* Side-plane normal-X: inverse-aspect scale while revealed. */ - cpu->gpr[rt] = (uint32_t)psx_ws_plane_nx( - (int32_t)psx_cyc_load_word(cpu, addr, rt, 1u << rs)); - else if (psx_ws_is_cull_xclip_load_site(pc)) - /* Per-prim X-reject bound: INT32_MAX while revealed (gpu.c). */ - cpu->gpr[rt] = psx_ws_xclip_bound(psx_cyc_load_word(cpu, addr, rt, 1u << rs)); - else - cpu->gpr[rt] = psx_cyc_load_word(cpu, addr, rt, 1u << rs); - cpu->gpr[rt] = gte_precision_copy_pc_read( - pc, insn, (uint8_t)rt, addr, cpu->gpr[rt]); - cpu->gpr[0] = 0; - return 0; - } - case 0x24: { /* LBU */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - cpu->gpr[rt] = (uint32_t)psx_cyc_load_byte(cpu, addr, rt, 1u << rs); - cpu->gpr[0] = 0; - return 0; - } - case 0x25: { /* LHU */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - cpu->gpr[rt] = (uint32_t)psx_cyc_load_half(cpu, addr, rt, 1u << rs); - cpu->gpr[0] = 0; - return 0; - } - case 0x26: { /* LWR */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - uint32_t word = psx_cyc_load_word(cpu, addr & ~3u, rt, 1u << rs); - cpu->gpr[rt] = lwr_merge(addr, word, cpu->gpr[rt]); - cpu->gpr[0] = 0; - return 0; - } - case 0x28: { /* SB */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - g_debug_last_store_pc = pc; - parappa_rhythm_log_store(cpu, pc, addr, cpu->gpr[rt] & 0xFFu, 1u); - cpu->write_byte(addr, (uint8_t)cpu->gpr[rt]); - return 0; - } - case 0x29: { /* SH */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - uint16_t val = (uint16_t)cpu->gpr[rt]; - /* Widescreen backdrop screenX squash on the interpreter path: mirrors - * the recompiler emit at [widescreen.backdrop] x_sites. Overlay code - * (the parallax backdrop handlers) runs interpreted when no cache DLL - * is loaded, so without this the squash never happens. Identity at 4:3 - * (psx_ws_backdrop_x gates on ws_active). */ - if (psx_ws_is_backdrop_site(pc)) - val = (uint16_t)psx_ws_backdrop_x((int16_t)val); - g_debug_last_store_pc = pc; - parappa_rhythm_log_store(cpu, pc, addr, (uint32_t)val, 2u); - cpu->write_half(addr, val); - return 0; - } - case 0x2A: { /* SWL */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - g_debug_last_store_pc = pc; - interp_swl(cpu, addr, cpu->gpr[rt]); - return 0; - } - case 0x2B: { /* SW */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - g_debug_last_store_pc = pc; - parappa_rhythm_log_store(cpu, pc, addr, cpu->gpr[rt], 4u); - cpu->write_word(addr, cpu->gpr[rt]); - gte_precision_scratch_store_pc_word( - pc, insn, addr, cpu->gpr[rt]); - gte_precision_copy_pc_word( - pc, insn, (uint8_t)rt, addr, cpu->gpr[rt]); - gte_precision_store_pc_word(pc, insn, addr, cpu->gpr[rt]); - return 0; - } - case 0x2E: { /* SWR */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - g_debug_last_store_pc = pc; - interp_swr(cpu, addr, cpu->gpr[rt]); - return 0; - } - case 0x32: { /* LWC2 — §1+DO_LDS charged by exec_one's top step (mask 0) */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_gte_stall(cpu); /* COP2 reg write stalls to GTE completion */ -#endif - gte_write_data(cpu, (uint8_t)rt, psx_cyc_lwc2_read(cpu, addr)); - return 0; - } - case 0x3A: { /* SWC2 */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_gte_stall(cpu); /* COP2 reg read stalls to GTE completion */ -#endif - g_debug_last_store_pc = pc; - cpu->write_word(addr, gte_read_data(cpu, (uint8_t)rt)); - gte_precision_store_word(addr, (uint8_t)rt); - return 0; - } - default: - return abort_unsupported(pc, insn, "primary opcode"); - } - return 0; -} - -static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_addr); - -/* Public entry point. Caller (psx_dispatch) has translated `addr` to a - * KSEG-stripped form already in some cases, so accept any address and - * mask. Returns 1 if interpretation handled the basic block; 0 if the - * caller should fall back (e.g. unsupported opcode at the entry, page - * not dirty). - * - * Thin wrapper that brackets the body with g_dirty_interp_active so the - * event-timeline ring can tag events as INTERP vs STATIC. The EPC-sentinel - * branch inside can longjmp out (not restoring here); psx_check_interrupts - * clears the flag at its longjmp landing to cover that case. */ -/* Always-on runaway-recursion guard. The host C stack mirrors the guest call - * graph (each guest call = a nested psx_dispatch_call -> dirty_ram_dispatch C - * frame), so an unbounded self-recursion — e.g. a bogus jump-table handler that - * re-enters its own entity loop (issue #1 seesaw crash: v0=0x8001DFF8) — silently - * overflows the host stack with no diagnostic (raw STATUS_STACK_OVERFLOW). Trip - * well below overflow: dump full state (crash report carries dispatch_depth + the - * dirty_block cycle that NAMES the recursing PC) and halt-and-serve, so the next - * reproduction (whoever hits it) is captured cleanly + live-inspectable instead of - * a bare SEH. Normal guest nesting is shallow (<~64); the default 256 sits far - * above that and below overflow (~500+ frames on a 1 MB stack). Override via - * PSX_RECURSION_LIMIT. One-shot. NOT a fix — the upstream bogus-pointer divergence - * still needs the oracle; this converts a hard crash into a catchable, diagnosed - * bail (Rule 15: make the failure observable). */ -int dirty_ram_dispatch(CPUState* cpu, uint32_t addr, uint32_t stop_addr) { - extern int g_psx_dispatch_depth; - extern void psx_fatal_halt(const char *reason); -#ifndef PSX_NO_DEBUG_TOOLS - /* A0/B0/C0 kernel-vector stubs are runtime-written, so calls to them - * land HERE, not in the static dispatcher — which meant the bioscall - * ring (debug_server_trace_dispatch) never saw a single vector call on - * a BIOS whose stubs stay dirty (OpenBIOS bring-up: ring total 0 while - * the guest hammered B0). Feed the same observer from this path. */ - { - uint32_t vphys = addr & 0x1FFFFFFFu; - if (vphys == 0xA0u || vphys == 0xB0u || vphys == 0xC0u) { - extern void debug_server_trace_dispatch(uint32_t func_addr); - debug_server_trace_dispatch(vphys); - } - } -#endif - static int s_rec_guard = 0; - static int s_rec_limit = 0; - if (s_rec_limit == 0) { - const char *e = getenv("PSX_RECURSION_LIMIT"); - s_rec_limit = (e && *e) ? atoi(e) : 256; - if (s_rec_limit < 32) s_rec_limit = 32; - } - if (!s_rec_guard && g_psx_dispatch_depth > s_rec_limit) { - s_rec_guard = 1; /* one-shot: first trip wins the report */ - psx_fatal_halt("dispatch recursion guard tripped — runaway self-call " - "(see dispatch_depth + dirty_block cycle for the recursing PC)"); - } - if (addr == 0x8001A954u) site_note(&g_site_dd954); /* loop head re-dispatch */ - else if (addr == 0x80046264u) site_note(&g_site_dd264); /* loop tail re-dispatch */ - int prev = g_dirty_interp_active; - g_dirty_interp_active = 1; - int prev_phase = g_exec_phase; - g_exec_phase = 1; - int r = dirty_ram_dispatch_inner(cpu, addr, stop_addr); - - /* pc=0 producer tripwire: the dirty path reported "handled" yet published a - * null PC — the abnormal-exit source we're hunting (MMX6 boot wedge). Latch - * full context the first time; count every occurrence. */ - if (r == 1 && cpu->pc == 0u) { - extern uint32_t g_async_rfe_resume_pc, g_dirty_safe_resume_pc; - g_pczero_count++; - if (!g_pczero_latched) { - g_pczero_latched = 1; - g_pczero_addr = addr; - g_pczero_ra = cpu->gpr[31]; - g_pczero_in_exc = (uint32_t)psx_get_in_exception(); - g_pczero_async_rfe = g_async_rfe_resume_pc; - g_pczero_dirty_safe = g_dirty_safe_resume_pc; - } - } - - /* Dirty-RAM scheduling contract (Tomba 2 Whoopee-Camp softlock fix): - * ANY path that advanced guest cycles must periodically expose the CPU to - * the interrupt/event pump, regardless of RAM region. The local-dirty-flow - * fast path (overlay region, phys >= OVERLAY_REGION_FLOOR) already pumps - * psx_check_interrupts() every 4096 insns internally — but a LOW-RAM kernel - * dirty loop (e.g. 0x2CA8 / 0xE10 / 0xB0 polling for VBlank) takes the - * per-block-return path instead and re-dispatches through the CPS chain - * WITHOUT ever surfacing to the top-level interrupt check. Result: VBlank is - * never raised, i_stat stays 0, the wait-loop deadlocks (frame frozen, - * total_checks frozen, dirty_ram_insns -> billions). OVERLAY_REGION_FLOOR may - * decide HOW dirty code is dispatched; it must never decide whether time - * exists. So pump here on a region-independent insn budget. This is a clean - * poll boundary: the inner committed cpu->pc, the executed instruction and - * its delay slot are fully retired, and psx_check_interrupts() clears - * g_dirty_interp_active at its longjmp landing (same contract as the inner - * local-flow pump), so a deliver-via-longjmp here is safe. */ - if (r == 1) { - uint64_t now = g_dirty_ram_insns_run; - uint64_t gap = now - s_last_dirty_irq_pump_insns; - if (gap > g_dirty_pump_max_gap_insns) g_dirty_pump_max_gap_insns = gap; - if (gap >= 4096u) { - s_last_dirty_irq_pump_insns = now; - g_dirty_pump_count++; - uint32_t committed = cpu->pc; /* block already retired; this is next PC */ - int prev_site = g_cosim_dirty_pump_site; - g_cosim_dirty_pump_site = 6; - g_dirty_safe_resume_pc = committed; - psx_check_interrupts(cpu); /* may exception-enter / longjmp */ - /* Frame-1997 fix: a game-driven ReturnFromException longjmp'd through the - * handler and left cpu->pc=0. Here (outer pump, the block already returned) - * that null PC would propagate up the trampoline to the OUTERMOST dispatch - * and be read as a clean "execution completed" exit. Restore the committed - * guest PC so the trampoline re-dispatches and the game resumes. (The - * local-flow internal pump resumes via its own loop, so this only matters at - * block-return pumps.) */ - if (cpu->pc == 0u && committed != 0u) { - cpu->pc = committed; - g_async_rfe_fire_count++; - } - g_dirty_safe_resume_pc = 0; - g_cosim_dirty_pump_site = prev_site; - } - } - - g_dirty_interp_active = prev; - g_exec_phase = prev_phase; - return r; -} - -/* ===== Cycle-budgeted precise event slicing (PRECISE_IRQ_SLICE.md) ===== */ - -/* Is a hardware interrupt deliverable to the guest at this exact instruction - * boundary? Mirrors the gate in psx_check_interrupts: a pending+unmasked I_STAT - * bit, COP0 IEc + IM2 set, and not already inside the exception handler. */ -static int precise_irq_deliverable(CPUState *cpu) { - extern uint32_t i_stat; - uint32_t sr = cpu->cop0[12]; - /* COP0 software interrupts (CAUSE.IP0/IP1 & SR.IM0/IM1): guest-raised via - * mtc0 to CAUSE, independent of the INTC line (see psx_check_interrupts). */ - uint32_t sw_pending = cpu->cop0[13] & sr & 0x0300u; - if ((i_stat & i_mask) == 0 && sw_pending == 0) return 0; - if (psx_get_in_exception()) return 0; - if (!(sr & 0x1u)) return 0; /* IEc: interrupts globally enabled */ - /* INTC needs IM2; a pending software interrupt is deliverable without it. */ - if (!(sr & (1u << 10)) && sw_pending == 0) return 0; - return 1; -} - -/* Is `pc` a point the TOP-LEVEL dispatcher can re-enter? Clean (compiled) game - * text is only re-enterable at a FUNCTION ENTRY: psx_dispatch_game_compiled is a - * switch over entries and returns 0 for any mid-function PC; the dirty path then - * sees a non-dirty page and also returns 0, which the top dispatch reads as PC=0 - * ("execution completed" — the deterministic precise-slice boot exit, root-caused - * to a mid-function clean-text resume PC, PRECISE_IRQ_SLICE.md Task #4). Every - * other PC class — BIOS ROM, dirty RAM (overlays / install-at-runtime stubs), - * overlay candidates, the A0/B0/C0 kernel call vectors, scratchpad — is handled by - * psx_dispatch / the dirty interp / the overlay loader, so it is dispatchable. - * Therefore the precise slicer must never hand control back at a mid-function - * clean-text PC: it keeps interpreting (exec_one handles arbitrary mid-function - * flow) until cpu->pc satisfies this predicate. */ -static int precise_pc_dispatchable(uint32_t pc) { -#ifdef PSX_HAS_GAME_DISPATCH - uint32_t phys = pc & 0x1FFFFFFFu; - if (psx_game_address_in_text(pc) && !dirty_ram_is_dirty(phys)) - return psx_game_is_function_entry(pc); -#endif - (void)pc; - return 1; -} - -/* Run the guest per-instruction from cpu->pc through the interpreter, taking any - * deliverable interrupt at the EXACT instruction boundary, until a safe resume - * point. g_precise_mode makes exec_one step INTO jal/jalr callees per-instruction - * (ignore native availability), so the slice is owned by the event deadline, not - * the function boundary. ALL exits land on a control-transfer target — every - * branch/jump target is dispatchable (compiled blocks end with cpu->pc=target; - * return), whereas an arbitrary mid-block PC is not — so after taking a mid-block - * IRQ we keep interpreting to the next transfer before handing back to compiled. - * `bcyc` = originating block's cycle budget; `deadline_entry` = entered because an - * event is due within bcyc (vs a side-effect-only block, which runs one block). */ -static void psx_run_precise(CPUState *cpu, uint32_t bcyc, int deadline_entry) { - int prev_precise = g_precise_mode; - int prev_active = g_dirty_interp_active; - int prev_phase = g_exec_phase; - g_precise_mode = 1; - g_dirty_interp_active = 1; - g_exec_phase = 1; - - uint32_t pc = cpu->pc; - g_slice_last_block = pc; - g_slice_last_first_pc = pc; - g_slice_last_first_insn = fetch_word(pc & 0x1FFFFFFFu); - g_slice_exit_pc = pc; - g_slice_exit_reason = 0; - g_slice_exit_iter = 0; - g_slice_exit_dispatchable = 0; - g_slice_exit_dirty = dirty_ram_is_dirty(pc & 0x1FFFFFFFu) ? 1u : 0u; -#ifdef PSX_HAS_GAME_DISPATCH - g_slice_exit_in_text = psx_game_address_in_text(pc) ? 1u : 0u; -#else - g_slice_exit_in_text = 0; -#endif - g_slice_exit_want = 0; - int irq_taken = 0; /* one take per slice (avoid re-taking an unacked IRQ) */ - enum { MAX_PRECISE_INSNS = 200000 }; - for (int i = 0; i < MAX_PRECISE_INSNS; i++) { - if (!irq_taken && precise_irq_deliverable(cpu)) { - uint32_t committed = pc; - extern uint32_t i_stat; - g_slice_last_committed = committed; - g_slice_last_istat = i_stat; - g_slice_last_imask = i_mask; - g_slice_last_sr = cpu->cop0[12]; - cpu->pc = committed; - int prev_site = g_cosim_dirty_pump_site; - g_cosim_dirty_pump_site = 7; - g_dirty_safe_resume_pc = committed; - s_last_dirty_irq_pump_insns = g_dirty_ram_insns_run; - psx_check_interrupts(cpu); - if (cpu->pc == 0u && committed != 0u) { - cpu->pc = committed; - g_async_rfe_fire_count++; - } - g_dirty_safe_resume_pc = 0; - g_cosim_dirty_pump_site = prev_site; - g_slice_irq_taken++; - irq_taken = 1; - pc = cpu->pc ? cpu->pc : committed; - cpu->pc = pc; - if (precise_pc_dispatchable(cpu->pc)) { - g_slice_exit_reason = 1; - g_slice_exit_iter = (uint32_t)i; - g_slice_exit_want = 1; - break; - } - continue; - } - - uint32_t next_pc = 0; - g_unsupported_seen = 0; - cosim_exec_one_begin(); - int transferred = exec_one(cpu, pc, &next_pc); /* charges its own interlock */ - g_dirty_ram_insns_run++; - dirty_ram_perf_hot_record(pc); -#ifdef PSX_COSIM - if (!cosim_exec_one_did_hook()) { extern void cosim_instr(uint32_t); cosim_instr(pc); } -#endif - if (g_unsupported_seen) { - /* Valid compiled code always decodes; if not, hand the committed PC to - * the dispatcher rather than abort the slice. */ - g_unsupported_seen = 0; - pc = transferred ? cpu->pc : next_pc; - g_slice_exit_reason = 2; - g_slice_exit_iter = (uint32_t)i; - g_slice_exit_want = 0; - break; - } - uint32_t committed = transferred ? cpu->pc : next_pc; - - /* `want_exit`: the slice has done its job and would like to hand back. But - * it may ONLY return control at a dispatchable PC (precise_pc_dispatchable) - * — a mid-function clean-text resume PC is unhandleable by the top dispatch - * (root cause of the deterministic boot PC=0). When we want to exit but the - * committed PC is mid-function clean text, keep interpreting (exec_one runs - * arbitrary mid-function flow) until control lands on a real entry / leaves - * clean game text. */ - int want_exit = 0; - - /* Exact take-point: an interrupt deliverable on THIS instruction's cycle is - * taken before the next instruction retires — once per slice. Re-checking - * after a take re-fires an IRQ the handler has not acked yet (an - * 8-takes-in-11-insns storm), so gate on !irq_taken. */ - if (!irq_taken && precise_irq_deliverable(cpu)) { - extern uint32_t i_stat; - g_slice_last_committed = committed; - g_slice_last_istat = i_stat; - g_slice_last_imask = i_mask; - g_slice_last_sr = cpu->cop0[12]; - cpu->pc = committed; - int prev_site = g_cosim_dirty_pump_site; - g_cosim_dirty_pump_site = 7; - g_dirty_safe_resume_pc = committed; /* real EPC for exception entry */ - s_last_dirty_irq_pump_insns = g_dirty_ram_insns_run; - psx_check_interrupts(cpu); /* takes it; runs handler; restores GPRs */ - g_dirty_safe_resume_pc = 0; - g_cosim_dirty_pump_site = prev_site; - g_slice_irq_taken++; - irq_taken = 1; - committed = cpu->pc ? cpu->pc : committed; - want_exit = 1; - } - - pc = committed; - cpu->pc = committed; - - if (g_psx_call_bail) { - g_slice_exit_reason = 3; - g_slice_exit_iter = (uint32_t)i; - g_slice_exit_want = want_exit ? 1u : 0u; - break; /* wild unwind: hand cpu->pc to the dispatcher */ - } - - if (irq_taken) { - want_exit = 1; /* after the take, leave as soon as it is safe */ - } else if (transferred) { - want_exit = 1; - /* else still imminent — keep slicing across this boundary */ - } - - /* Hand back ONLY at a dispatchable PC. Otherwise (mid-function clean text) - * keep interpreting until one is reached. */ - if (want_exit && precise_pc_dispatchable(cpu->pc)) { - g_slice_exit_reason = 1; - g_slice_exit_iter = (uint32_t)i; - g_slice_exit_want = 1; - break; - } - } - if (g_slice_exit_reason == 0) { - g_slice_exit_reason = 4; - g_slice_exit_iter = MAX_PRECISE_INSNS; - } - - cpu->pc = pc; - g_slice_exit_pc = cpu->pc; - g_slice_exit_dispatchable = precise_pc_dispatchable(cpu->pc) ? 1u : 0u; - g_slice_exit_dirty = dirty_ram_is_dirty(cpu->pc & 0x1FFFFFFFu) ? 1u : 0u; -#ifdef PSX_HAS_GAME_DISPATCH - g_slice_exit_in_text = psx_game_address_in_text(cpu->pc) ? 1u : 0u; -#else - g_slice_exit_in_text = 0; -#endif - g_precise_mode = prev_precise; - g_dirty_interp_active = prev_active; - g_exec_phase = prev_phase; -} - -/* Precise-slice gate (PARKED default OFF). Hot callers use the cpu_state.h - * inline which returns 0 when this is 0 — no out-of-line call. Opt in with - * PSX_PRECISE_SLICE=1 (same binary A/B). */ -int g_psx_precise_slice = 0; - -void psx_precise_slice_init_from_env(void) { - const char *e = getenv("PSX_PRECISE_SLICE"); - g_psx_precise_slice = (e && e[0] == '1') ? 1 : 0; -} - -/* Block-leader slice guard impl. Emitted code calls the inline wrapper - * psx_slice_block(); overlay CPS callbacks point here directly. - * Returns 1 if it sliced (ran the block — and possibly more — through the precise - * interpreter and left cpu->pc at a dispatchable resume point; the caller MUST - * `return` so its compiled body does not re-execute the same instructions). - * Returns 0 if the whole block is provably safe to run as fast compiled C. */ -int psx_slice_block_impl(CPUState *cpu, uint32_t block_addr, uint32_t bcyc, int side_effects) { - /* PARKED (PRECISE_IRQ_SLICE.md): precise take-point slicing is a later - * correctness upgrade, NOT the current FMV blocker (that is the -8 cycle - * drift / faithful per-instruction cycle model — see CLAUDE.md Rule -1). */ - if (!g_psx_precise_slice) return 0; - - /* No nested slicing: a handler dispatched from inside precise-mode, and any - * block executed while in_exception, run compiled (interrupts are gated during - * exception handling anyway). Keeps re-entrancy structurally impossible. */ - if (g_precise_mode || psx_get_in_exception()) return 0; - - static int s_slice_always = -1; - static int s_slice_margin = -1; - if (s_slice_always < 0) { - const char *e = getenv("PSX_PRECISE_ALWAYS"); - s_slice_always = (e && e[0] == '1') ? 1 : 0; - } - if (s_slice_margin < 0) { - const char *e = getenv("PSX_PRECISE_MARGIN"); - s_slice_margin = (e && *e) ? atoi(e) : 0; - if (s_slice_margin < 0) s_slice_margin = 0; - } - - uint32_t deadline = cycles_to_next_event(); - uint32_t budget = bcyc + (uint32_t)s_slice_margin; - if (budget < bcyc) budget = 0xFFFFFFFFu; - int entry_deliverable = precise_irq_deliverable(cpu); - int has_deadline = s_slice_always || entry_deliverable || (deadline <= budget); - if (!has_deadline && !side_effects) return 0; /* fast path: no event in this block */ - - g_slice_entry_deliverable = (uint32_t)entry_deliverable; - g_slice_fired++; - cpu->pc = block_addr; - psx_run_precise(cpu, bcyc, has_deadline); - return 1; -} - -static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_addr) { - uint32_t phys = addr & 0x1FFFFFFFu; - int clean_game_text_miss = 0; - - if (addr == 0x80000048u) { - g_sentinel_reach_dirty++; - if (!psx_get_in_exception()) g_sentinel_reach_traps++; /* reuse: in_exc==0 dirty reaches */ - g_sentinel_reach_async = g_async_rfe_resume_pc; - cpu->pc = 0; - if (psx_get_in_exception()) { - psx_exception_longjmp(); /* does not return */ - } - /* Async ReturnFromException (Tomba 2 frame-1997 fix): game-installed handler - * RFE'd outside our exception window. Resume at the latched real guest PC - * instead of returning pc=0 (which the top dispatch reads as a clean exit). */ - if (g_async_rfe_resume_pc != 0u) { - g_async_rfe_fire_count++; - cpu->pc = g_async_rfe_resume_pc; - return 1; - } - return 1; - } - -#ifdef PSX_HAS_GAME_DISPATCH -#ifndef PSX_NO_DEBUG_TOOLS - xprobe_event(cpu->gpr[31], XOP_DD, XSITE_DD, addr, 0u, cpu->gpr[29], cpu->gpr[31], 0); -#endif - /* Run the statically-compiled game function only while the target is still - * native-safe. Dirty overlay pages and pages whose text bytes diverged from - * the original EXE image fall through to interpret the live RAM bytes. */ - if (psx_game_text_native_ok(addr)) { - g_mixed_depth++; - { - ls_func_enter(addr, cpu); - int prev_phase = g_exec_phase; - g_exec_phase = 3; - int _gc = psx_dispatch_game_compiled(cpu, addr); - g_exec_phase = prev_phase; - ls_func_exit(addr, cpu, _gc); - g_mixed_depth--; - if (_gc) return 1; - } - clean_game_text_miss = psx_game_address_in_text(addr) ? 1 : 0; - } else if (psx_game_address_in_text(addr)) { - /* RAM at a game-text address diverged from the static EXE image - * (runtime-relocated / overlaid / self-modified code the compiled - * static function no longer reflects). The live RAM is the truth: - * fall through to INTERPRET it here rather than bail (line below) to - * the shell shadow (normalize() -> shell ROM). Crash Bash relocates a - * code page onto 0x30000 (inside the BIOS shell window); without this - * its 0x30FF4 call diverged (native_ok=0) yet was not dirty, so the - * interpreter bailed and normalize() shadowed it to dead shell ROM -> - * unknown-dispatch abort. Marking it a clean game-text miss lets the - * dirty interpreter execute the real RAM bytes. */ - clean_game_text_miss = 1; - } -#endif - - /* B-2: statically-compiled overlay functions (generated/overlays_static.c). - * Inert unless a game provides an overlays_static.c at build time. */ -#ifdef PSX_HAS_OVERLAY_DISPATCH - { - extern int psx_overlay_dispatch(CPUState *cpu, uint32_t addr); - if (psx_overlay_dispatch(cpu, addr)) return 1; - } -#endif - - /* A-1: dynamically-loaded overlay DLL functions, checked before the - * interpreter. No-op (returns 0) until overlay_loader_init() runs, which - * only happens when the overlay cache is enabled in config. */ - /* §5-E native↔interp fingerprint: capture entry register state for a - * candidate overlay function, so native and interpreted runs can be diffed - * by sequence. Additive only — no control-flow change. */ - extern int overlay_loader_is_candidate(uint32_t phys); - extern int overlay_fp_enabled(void); - extern void overlay_regs_snap(uint32_t out[34], const CPUState *cpu); - extern void overlay_fp_log(uint32_t addr, const uint32_t *in_regs, - const CPUState *cpu, int native); - int _ovfp = overlay_fp_enabled() && - overlay_cache_window_contains(phys) && - overlay_loader_is_candidate(phys); - uint32_t _in_regs[34]; - if (_ovfp) { - overlay_regs_snap(_in_regs, cpu); - /* One-shot insn-ring freeze BEFORE the Nth dispatch of the watched - * candidate — the ring tail then holds the pre-divergence window. */ - if (g_insn_freeze_addr && phys == g_insn_freeze_addr && - ++g_insn_freeze_count == g_insn_freeze_nth) - g_insn_log_frozen = 1; - } - - { - extern int overlay_loader_dispatch(CPUState *cpu, uint32_t addr); - if (overlay_loader_dispatch(cpu, addr)) { - if (_ovfp) overlay_fp_log(addr, _in_regs, cpu, 1); - return 1; - } - } -/* Every exit retires any deferred load writeback: once we hand control back to - * compiled code (or the dispatch loop) nothing downstream knows a register - * write is still owed, and the pipeline would have drained by then anyway. */ -#define OV_FPLOG_RET1() do { dirty_ram_ld_delay_flush(cpu); if (_ovfp) overlay_fp_log(addr, _in_regs, cpu, 0); return 1; } while (0) - - if (!dirty_ram_is_dirty(phys) && !clean_game_text_miss) { - /* Bulk host transfers can populate post-EXE executable RAM without - * passing through the write hooks that mark dirty pages. A real - * control transfer to a decodable word above the configured boot-EXE - * text end is enough evidence to admit that word to the interpreter. - * Data and invalid targets still fail closed. */ - if (phys < (2u * 1024u * 1024u) && - phys >= g_overlay_region_floor && - dirty_ram_word_looks_decodable(fetch_word(phys))) { - dirty_ram_mark_executable_range(phys, 4u); - } else { - return 0; - } - } - - /* Interp-pressure signal for variant-capture automation (step 2.8): - * counts dispatches the interpreter actually handles inside a capture - * window. The autocapture tick reads-and-resets this to decide whether - * an unseen region variant is being interp-executed right now. */ - if (overlay_cache_window_contains(phys)) g_dirty_window_dispatches++; - - /* Reset soft-fail state at block entry. */ - g_unsupported_seen = 0; - /* Overlay flow above the kernel window — kernel window stays per-block (see - * is_local_dirty_target / phys_is_overlay_flow_region). Includes boot-text - * pages overwritten by a runtime overlay (Tomba 2), not just [FLOOR, RAM). */ - int allow_local_dirty_flow = phys_is_overlay_flow_region(phys); - - /* Per-PC entry counter (visible via dirty_ram_stats). */ - DirtyRamPcEntry *pc_entry = pc_table_get_or_insert(phys); - if (pc_entry) pc_entry->hits++; - { - uint32_t word = phys >> 2; - g_dirty_ram_dispatch_pc_bitmap[word >> 5] |= 1u << (word & 31u); - } - - /* External-entry attribution: when the previous interp run exited by - * handing a target back to the dispatch loop, the very next dirty - * dispatch at that target is the SAME logical execution continuing — - * not a new entry. Anything else arrived from native code (a call or - * a fresh dispatch) and is real interior-entry evidence for alias - * seeding. */ - if (pc_entry && addr != g_dirty_interp_chain_target) pc_entry->entry_hits++; - g_dirty_interp_chain_target = 0; - - /* Block-entry ring buffer — answers "who tried to JALR into this RAM - * stub" by capturing cpu->gpr[31] (the caller's RA) at dispatch time. - * Always-on; eviction keeps memory bounded. Honors the capture freeze. */ -#ifndef PSX_NO_DEBUG_TOOLS - if (!g_insn_log_frozen) { - uint64_t s = g_dirty_ram_block_log_seq++; - DirtyRamBlockLogEntry *e = - &g_dirty_ram_block_log[s & (DIRTY_RAM_BLOCK_LOG_CAP - 1u)]; - e->seq = s; - e->target = addr; - e->ra = cpu->gpr[31]; - e->a0 = cpu->gpr[4]; - e->a1 = cpu->gpr[5]; - e->a2 = cpu->gpr[6]; - e->a3 = cpu->gpr[7]; - e->t0 = cpu->gpr[8]; - e->t1 = cpu->gpr[9]; - e->t2 = cpu->gpr[10]; - e->sp = cpu->gpr[29]; - e->frame = (uint32_t)s_frame_count; - } -#endif - -#ifndef PSX_NO_DEBUG_TOOLS - if (debug_server_dirty_break_maybe_pause(addr, cpu)) { - debug_server_wait_if_paused(); - } -#endif - - /* Run dirty code locally until it returns to compiled/non-dirty code. - * Runtime-loaded overlays are larger than BIOS install stubs, so stopping - * at every local branch burns the dispatch loop. */ - enum { MAX_INSNS_PER_DISPATCH = 1000000 }; - uint32_t pc = addr; - uint32_t current_page = phys >> 12; - int current_page_dirty = dirty_ram_is_dirty(phys); - int insns_executed = 0; -#ifndef PSX_NO_DEBUG_TOOLS - extern void debug_server_cyc_observe(uint32_t block_leader_phys); -#endif - /* Async-interrupt latency fix. A guest wait loop that lives ENTIRELY in - * dirty RAM (e.g. libcd's CdSync spin, as in Kula World) re-enters this - * interpreter once per short (~7-insn) block and exits before the - * per-invocation `(insns_executed & 0xFFF)` gate below can fire — so a - * pending, IEc+IM2-enabled interrupt is never taken and the loop spins for - * seconds while the CD IRQ that would set its wait-flag is never serviced. - * Poll on a GLOBAL invocation counter so short-block loops still yield to - * interrupts (this is the interpreter analogue of a block-leader poll in - * static code). psx_check_interrupts runs the handler and returns with - * registers restored, so continuing the loop afterward is safe. */ - { - static uint32_t s_interp_entry_poll = 0; - static int s_entry_poll_enabled = -1; /* DIAGNOSTIC toggle (590c236 x kind-30 escape regression hunt) */ - if (s_entry_poll_enabled < 0) { - const char* e = getenv("PSX_DIRTY_ENTRY_POLL"); - s_entry_poll_enabled = (e && e[0] == '0') ? 0 : 1; - } - if (s_entry_poll_enabled && (++s_interp_entry_poll & 0x3Fu) == 0) { - cpu->pc = pc; - psx_check_interrupts(cpu); - } - } - for (int i = 0; i < MAX_INSNS_PER_DISPATCH; i++) { - uint32_t next_pc = 0; -#ifndef PSX_NO_DEBUG_TOOLS - /* Interp-path cycle ruler: make every interpreted PC anchorable by - * cyc_watch (parity with the compiled emitter's block-leader observe). - * Early-returns when cyc_watch is disarmed → ~free in normal runs. */ - g_ls_dirty_observe = 1; - debug_server_cyc_observe(pc & 0x1FFFFFFFu); - g_ls_dirty_observe = 0; -#endif -#ifdef PSX_COSIM - { extern void cosim_block(uint32_t); cosim_block(pc); } -#endif - uint32_t insn = fetch_word(pc & 0x1FFFFFFFu); -#ifndef PSX_NO_DEBUG_TOOLS - uint32_t before_s0 = cpu->gpr[16]; - uint32_t before_ra = cpu->gpr[31]; - /* Callee-smear tripwire: latch the first instruction in the - * watched pc window whose execution changes $s3 (gpr19) — for a jalr - * this spans the ENTIRE nested native callee, naming the callee that - * returned with a clobbered callee-saved register. Armed via the - * s3_smear_watch TCP command; zero-cost when disarmed. */ - extern uint32_t g_s3_smear_lo, g_s3_smear_hi; - extern uint32_t g_s3_smear_pc, g_s3_smear_insn, g_s3_smear_old, - g_s3_smear_new, g_s3_smear_tgt, g_s3_smear_frame; - extern int g_s3_smear_valid; - uint32_t before_s3 = cpu->gpr[19]; -#endif - cosim_exec_one_begin(); - int transferred = exec_one_fetched(cpu, pc, insn, &next_pc); -#ifndef PSX_NO_DEBUG_TOOLS - if (g_s3_smear_lo && !g_s3_smear_valid && - pc >= g_s3_smear_lo && pc < g_s3_smear_hi && - cpu->gpr[19] != before_s3 && - (g_s3_smear_excl == 0u || insn != g_s3_smear_excl)) { - g_s3_smear_valid = 1; - g_s3_smear_pc = pc; - g_s3_smear_insn = insn; - g_s3_smear_old = before_s3; - g_s3_smear_new = cpu->gpr[19]; - /* for jr/jalr the smearing callee = rs at the call site */ - g_s3_smear_tgt = cpu->gpr[(insn >> 21) & 0x1Fu]; - g_s3_smear_frame = (uint32_t)s_frame_count; - extern int g_insn_log_frozen; - g_insn_log_frozen = 1; /* freeze the insn ring at the smear */ - } - /* $ra->1 corruption tripwire (confirm-first probe): did THIS overlay - * instruction clobber $ra to 1? Latches once, cheap after. */ - if (cpu->gpr[31] == 1u && before_ra != 1u) { - extern void psx_ra_tripwire(CPUState *, uint32_t, uint32_t, uint32_t); - psx_ra_tripwire(cpu, before_ra, pc, 0u /*INTERP*/); - } - /* Tomba2 ra-corruption pin (Confirm-(b)): capture the EXACT instruction - * that loads/sets $ra to the watched wild value (0x49422E54). For a - * `lw ra, off(sp)` this records the loading pc/insn + sp + the source - * stack address so we can see whether sp is wrong or the filename string - * was written onto the saved-ra slot. Latches g_ra_load_snap_valid. */ - if (g_ra_load_watch != 0u && cpu->gpr[31] == g_ra_load_watch && - before_ra != g_ra_load_watch && !g_ra_load_snap_valid) { - g_ra_load_snap_valid = 1; - g_ra_load_snap_pc = pc; - g_ra_load_snap_insn = insn; - g_ra_load_snap_before_ra = before_ra; - for (int r = 0; r < 32; r++) g_ra_load_snap_gpr[r] = cpu->gpr[r]; - /* If it's a load (lw/lbu/...), decode base+imm to record the source addr. */ - uint32_t op = insn >> 26; - if (op >= 0x20u && op <= 0x25u) { /* lb/lh/lwl/lw/lbu/lhu */ - uint32_t base = cpu->gpr[(insn >> 21) & 0x1Fu]; - int16_t imm = (int16_t)(insn & 0xFFFFu); - g_ra_load_snap_srcaddr = base + (int32_t)imm; - } else { - g_ra_load_snap_srcaddr = 0; - } - } -#endif - /* Per-instruction cycle cost (R3000A load-delay interlock) is charged - * INSIDE exec_one (top-of-fn §1+deps+DO_LDS, or psx_cyc_load_* for loads). */ -#ifndef PSX_NO_DEBUG_TOOLS - dirty_ram_log_instruction(cpu, pc, insn, before_s0, next_pc, - transferred ? cpu->pc : next_pc, - transferred); -#endif -#ifdef PSX_COSIM - if (!cosim_exec_one_did_hook()) { extern void cosim_instr(uint32_t); cosim_instr(pc); } -#endif - if (g_unsupported_seen) { - if (insns_executed == 0) { - /* Couldn't decode the first instruction. Most likely - * dispatch landed in a dirty page that's not actually - * code (stale data, return-target into save area, etc.). - * Hand off to psx_unknown_dispatch which has its own - * pattern-matching trampoline resolver. */ - return 0; - } - /* Made some progress, then hit unknown. Treat as a no-op - * return like psx_unknown_dispatch does for unrecognized - * targets — set cpu->pc=0 so the trampoline exits cleanly. - * If this turns out to be load-bearing, measurement will - * surface it as a card-protocol stall and we can add the - * missing opcode. - * - * No fprintf — read the last-* globals via TCP if needed - * (CLAUDE.md §3). Synchronous stderr at the rate this fires - * starves the dispatch loop and the debug-server poll. */ - g_dirty_ram_unsupported_midblock++; - g_dirty_ram_last_unsupported_entry = addr; - g_dirty_ram_last_unsupported_entry_ra = cpu->gpr[31]; - g_dirty_ram_last_unsupported_entry_sp = cpu->gpr[29]; - g_dirty_ram_last_unsupported_insns = (uint32_t)insns_executed; - g_dirty_ram_last_unsupported_pc = g_unsupported_pc; - g_dirty_ram_last_unsupported_insn = g_unsupported_insn; - g_dirty_ram_last_unsupported_reason = g_unsupported_reason; - cpu->pc = 0; - OV_FPLOG_RET1(); - } - g_dirty_ram_insns_run++; - dirty_ram_perf_hot_record(pc); - insns_executed++; - /* COP0 software-interrupt latency: an MTC0 to CAUSE or SR that makes a - * software interrupt deliverable (CAUSE.IP0/IP1 & SR.IM0/IM1 & IEc) - * must be taken IMMEDIATELY — real hardware vectors within the next - * instruction. Waiting for the next block boundary opens a window in - * which another hardware IRQ can re-enter the guest's (single-slot, - * legitimately non-reentrant) dispatcher and destroy its saved SR: - * Jackie Chan Stuntmaster's stage-1 handler does exactly - * `mtc0 CAUSE,0x100; mtc0 SR,0x101` and relies on the instant sw-int - * to reach its stage-2 before anything else runs. Same nested-delivery - * machinery as the entry poll above; EPC = the committed next pc. */ - if ((insn & 0xFFE00000u) == 0x40800000u) { /* MTC0 */ - uint32_t sw_rd = (insn >> 11) & 0x1Fu; - if ((sw_rd == 12u || sw_rd == 13u) && - (cpu->cop0[13] & cpu->cop0[12] & 0x0300u) != 0u && - (cpu->cop0[12] & 0x1u) != 0u) { - extern uint32_t g_dirty_safe_resume_pc; - uint32_t saved_resume = g_dirty_safe_resume_pc; - cpu->pc = next_pc ? next_pc : pc + 4u; - g_dirty_safe_resume_pc = cpu->pc; - /* Retire an owed load writeback before vectoring: the R3000A - * pipeline drains on exception entry, and the handler must not - * observe a stale destination register. */ - dirty_ram_ld_delay_flush(cpu); - psx_check_interrupts(cpu); - g_dirty_safe_resume_pc = saved_resume; - } - } - if (transferred) { - if (g_psx_call_bail) { - /* A bail unwind began inside a surfaced call: stop the interp - * run and hand the wild target (cpu->pc) up to the dispatch - * loop's bail handling. */ - g_dirty_ram_blocks_run++; - if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; - OV_FPLOG_RET1(); - } - /* Trampoline contract parity (full_function_emitter emits this - * check after every compiled function return): the transfer that - * just committed may be the jr whose delay-slot RFE armed the - * host escape — cpu->pc now holds the real EPC, so take the - * escape BEFORE local dirty flow can continue interpreting the - * resumed code inside the handler's host window. Same-fiber: this - * longjmps to the psx_check_interrupts frame that dispatched the - * handler (its epilogue restores in_exception and it returns with - * cpu->pc = EPC through the existing pump-boundary redirect). - * Foreign fiber: declines, and the normal surfacing below applies. */ - if (g_rfe_escape_pending) - psx_rfe_escape_check(cpu); - uint32_t target = cpu->pc; - if (target != 0u && dirty_ram_pump_boundary(cpu, target, 1)) { - g_dirty_ram_blocks_run++; - if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; - g_dirty_interp_chain_target = cpu->pc; - OV_FPLOG_RET1(); - } - target = cpu->pc; -#ifdef PSX_HAS_GAME_DISPATCH - if (target != 0) { - /* §20 FIX — the long-run idle freeze. A guest TAIL-transfer (j/jr/ - * branch) from an interpreted overlay into compiled code used to NEST - * a fresh psx_dispatch_game_compiled trampoline here. That nest never - * unwound for the per-frame render chain, leaking ~one host call chain - * (~1.17 KB) PER FRAME -> the 64 MB guest stack overflowed after ~40k - * idle frames -> the native-stack guard tripped (RECURSION_BUG.md - * §19/§20; confirmed by the ce_profile linear climb). - * - * A tail-transfer carries NO return obligation, so the correct action - * is to SURFACE the target (leave it in cpu->pc) and return; the OUTER - * psx_dispatch_impl trampoline re-dispatches cpu->pc FLAT - * (full_function_emitter.cpp:1230), keeping the host stack bounded - * across frames. Dirty-overlay targets still take the local-dirty-flow - * just below (kept flat in-interpreter); compiled / static / unknown - * targets fall through to the surface return (case 3). Real guest - * CALLS (jal/jalr) are unaffected — they nest inline in exec_one and - * return normally. (The §14 watermark surfaced via g_psx_call_bail, - * which is wild-return semantics and wedged; a plain tail surface does - * not touch the bail.) */ -#ifndef PSX_NO_DEBUG_TOOLS - xprobe_event(pc, XOP_BR, XSITE_INTERP, target, insn, - cpu->gpr[29], cpu->gpr[31], 1); -#endif - cpu->pc = target; /* surfaced; trampoline re-dispatches flat */ - } -#endif - uint32_t target_phys = target & 0x1FFFFFFFu; - if (allow_local_dirty_flow && target != 0 && - target != stop_addr && - phys_is_overlay_flow_region(target_phys) && - dirty_ram_is_dirty(target_phys)) { - /* A runtime overlay may start executing while its final code - * bytes are still being installed. Entry-time native validation - * must reject that partial image, but local dirty flow used to - * remain here for up to one million instructions after the bytes - * became an exact cached match. Tomba 2's MDEC path turns that - * one early miss into an entire FMV interpreted at ~23 fps. - * - * Re-check exact cached entries at safe guest transfer - * boundaries. The loader re-hashes generation-changed code - * before executing it, so incomplete/self-modified bytes stay on - * this authoritative interpreter path. */ - if (overlay_loader_is_candidate(target_phys)) { - extern int overlay_loader_dispatch(CPUState *cpu, uint32_t addr); - if (overlay_loader_dispatch(cpu, target)) { - g_dirty_ram_native_handoffs++; - g_dirty_ram_blocks_run++; - if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; - OV_FPLOG_RET1(); - } - } -#ifdef PSX_HAS_GAME_DISPATCH - /* A patched prologue can force entry through the interpreter, - * while the remaining static ranges at a later continuation - * are still safe to run as compiled code. */ - if (clean_game_text_miss && interp_enter_compiled(cpu, target)) { - g_dirty_ram_native_handoffs++; - g_dirty_ram_blocks_run++; - if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; - g_dirty_interp_chain_target = cpu->pc; - OV_FPLOG_RET1(); - } -#endif - /* Capture freeze gates ONLY the ring write — never flow. */ -#ifndef PSX_NO_DEBUG_TOOLS - if (!g_insn_log_frozen) { - uint64_t s = g_dirty_ram_flow_log_seq++; - DirtyRamFlowLogEntry *e = - &g_dirty_ram_flow_log[s & (DIRTY_RAM_FLOW_LOG_CAP - 1u)]; - e->seq = s; - e->pc = pc; - e->target = target; - e->ra = cpu->gpr[31]; - e->a0 = cpu->gpr[4]; - e->a1 = cpu->gpr[5]; - e->a2 = cpu->gpr[6]; - e->a3 = cpu->gpr[7]; - e->sp = cpu->gpr[29]; - e->frame = (uint32_t)s_frame_count; - } -#endif - pc = target; - current_page = target_phys >> 12; - current_page_dirty = 1; /* is_local_dirty_target proved it */ - if ((insns_executed & 0xFFF) == 0) { - debug_server_poll(); - debug_server_wait_if_paused(); - } - continue; - } - g_dirty_ram_blocks_run++; - if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; - g_dirty_interp_chain_target = cpu->pc; - OV_FPLOG_RET1(); - } - pc = next_pc; -#ifdef PSX_HAS_GAME_DISPATCH - /* Guest call returns advance without transferred set. Re-check the - * resume PC so a patched entry can hand its unchanged tail back to - * compiled code without adding probes to ordinary dirty overlay runs. */ - if (clean_game_text_miss && interp_enter_compiled(cpu, pc)) { - g_dirty_ram_native_handoffs++; - g_dirty_ram_blocks_run++; - if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; - g_dirty_interp_chain_target = cpu->pc; - OV_FPLOG_RET1(); - } -#endif - /* Straight-line flow reaching the dispatch return contract — exit - * so the loop returns into the suspended native caller (same - * hazard as a transfer to stop_addr). */ - if (stop_addr != 0 && pc == stop_addr) { - cpu->pc = pc; - if (dirty_ram_pump_boundary(cpu, pc, 2)) { - g_dirty_ram_blocks_run++; - if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; - g_dirty_interp_chain_target = cpu->pc; - OV_FPLOG_RET1(); - } - g_dirty_ram_blocks_run++; - if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; - g_dirty_interp_chain_target = pc; - OV_FPLOG_RET1(); - } - /* Straight-line code that left the dirty page — hand back to - * static dispatch by setting cpu->pc and returning. */ - uint32_t next_phys = pc & 0x1FFFFFFFu; - uint32_t next_page = next_phys >> 12; - if ((!current_page_dirty || next_page != current_page) && - !dirty_ram_is_dirty(next_phys)) { - cpu->pc = pc; - if (dirty_ram_pump_boundary(cpu, pc, 3)) { - g_dirty_ram_blocks_run++; - if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; - g_dirty_interp_chain_target = cpu->pc; - OV_FPLOG_RET1(); - } - g_dirty_ram_blocks_run++; - if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; - g_dirty_interp_chain_target = pc; - OV_FPLOG_RET1(); - } - if (next_page != current_page) current_page_dirty = 1; - current_page = next_page; - } - g_dirty_ram_last_unsupported_pc = pc; - g_dirty_ram_last_unsupported_insn = fetch_word(pc & 0x1FFFFFFFu); - g_dirty_ram_last_unsupported_reason = "instruction guard"; - g_dirty_ram_guard_yields++; - g_dirty_ram_blocks_run++; - cpu->pc = pc; - if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; - g_dirty_interp_chain_target = pc; - { - uint64_t gap = g_dirty_ram_insns_run - s_last_dirty_irq_pump_insns; - if (gap >= 4096u) { - uint32_t committed = cpu->pc; - int prev_site = g_cosim_dirty_pump_site; - g_cosim_dirty_pump_site = 4; - g_dirty_safe_resume_pc = committed; - s_last_dirty_irq_pump_insns = g_dirty_ram_insns_run; - dirty_ram_ld_delay_flush(cpu); /* pipeline drains on exception entry */ - psx_check_interrupts(cpu); - /* Frame-1997 fix (see outer pump): restore the committed PC if a game RFE - * longjmp left cpu->pc=0, so the trampoline re-dispatches instead of exiting. */ - if (cpu->pc == 0u && committed != 0u) { - cpu->pc = committed; - g_async_rfe_fire_count++; - } - g_dirty_safe_resume_pc = 0; - g_cosim_dirty_pump_site = prev_site; - } - } - OV_FPLOG_RET1(); -} -#undef OV_FPLOG_RET1 - -/* ============================================================================ - * #2 — Lockstep "unit-test interp" comparator. See lockstep.h. - * Compiled-first + read-trace replay, per basic block, window-gated. - * ==========================================================================*/ -#include "lockstep.h" - -int g_ls_mode = 0; -int g_ls_replay_active = 0; -int g_ls_suppress_record = 0; - -static uint32_t s_ls_frame_lo = 0, s_ls_frame_hi = 0; /* hi==0 => disabled */ - -typedef struct { uint8_t is_write, size; uint32_t addr, val; } ls_op_t; -enum { LS_TRACE_CAP = 65536 }; -static ls_op_t s_ls_trace[LS_TRACE_CAP]; -static int s_ls_trace_n = 0, s_ls_trace_idx = 0; -static int s_ls_overflow = 0, s_ls_mismatch = 0; -static int s_ls_replay_done = 0; /* trace exhausted: stop replay (benign count mismatch) */ -static uint32_t s_ls_cur_pc = 0; /* pc of the instruction being replayed */ -static int s_ls_shadow_owner = 0; /* 0 none, 1 record, 2 replay */ -static int s_ls_shadow_saw_exception = 0; -static CPUState s_ls_R0; /* register snapshot at block entry */ -static uint32_t s_ls_block = 0; -static int s_ls_prev_valid = 0; - -/* mismatch detail captured by the hooks (read by ls_replay_and_compare) */ -static int s_ls_m_kind = 0; /* 1 read-addr 2 write-addr 3 write-val 4 trace-exhausted */ -static uint32_t s_ls_m_pc = 0, s_ls_m_addr = 0, s_ls_m_exp = 0, s_ls_m_act = 0; - -static struct { - int found; - int kind; /* see ls_kind below */ - uint32_t frame, block, pc, addr; - uint32_t exp, act; /* exp = interp(correct), act = compiled(actual) */ - int detail; /* reg index for REG kind, else 0 */ - uint64_t blocks_checked; -} s_ls_div = {0}; -/* kinds: 1 REG 2 HI 3 LO 4 WRITE-VAL 5 READ-ADDR 6 WRITE-ADDR 7 TRACE-EXHAUSTED - * 8 COMPILED-EXTRA-OPS 9 PATH-CAP */ -/* Post-mortem: the recorded op-trace of the diverging block + the replay's - * op index where it diverged, so the desync can be read off directly. */ -static ls_op_t s_ls_div_trace[48]; -static int s_ls_div_trace_n = 0, s_ls_div_idx = 0; - -static int s_ls_record_only = 0; /* diagnostic: record but skip inline replay (perturbation test) */ - -static uint32_t s_lsf_frame_lo = 0, s_lsf_frame_hi = 0; /* hi==0 => disabled */ -static int s_lsf_record_only = 0; -static int s_lsf_active = 0; -static int s_lsf_recording = 0; -static int s_lsf_saw_irq = 0; -static CPUState s_lsf_R0; -static uint32_t s_lsf_entry = 0, s_lsf_dispatch_entry = 0, s_lsf_frame = 0; - -static struct { - int found; - int kind; /* see lsf_kind names in ls_get_func_json */ - uint32_t frame, entry, pc, addr; - uint32_t exp, act; - int detail; - uint64_t segments_checked, skipped_irq, skipped_overflow; - uint64_t skipped_unhandled, skipped_conflict, skipped_disabled; - uint32_t trace_ops, replay_ops, steps; -} s_lsf_div = {0}; - -static ls_op_t s_lsf_div_trace[48]; -static int s_lsf_div_trace_n = 0, s_lsf_div_idx = 0; - -void ls_set_window(uint32_t lo, uint32_t hi) { s_ls_frame_lo = lo; s_ls_frame_hi = hi; } -void ls_set_record_only(int on) { s_ls_record_only = on; } -void ls_func_set_window(uint32_t lo, uint32_t hi) { s_lsf_frame_lo = lo; s_lsf_frame_hi = hi; } -void ls_func_set_record_only(int on) { s_lsf_record_only = on; } - -void ls_note_exception_entry(void) { - if (s_lsf_active) s_lsf_saw_irq = 1; - if (s_ls_shadow_owner == 1) s_ls_shadow_saw_exception = 1; -} - -int ls_shadow_record_begin(void) { - if (s_ls_shadow_owner || g_ls_mode != 0 || g_ls_replay_active || - s_lsf_active || s_ls_prev_valid) - return 0; - s_ls_trace_n = 0; - s_ls_trace_idx = 0; - s_ls_overflow = 0; - s_ls_mismatch = 0; - s_ls_m_kind = 0; - s_ls_replay_done = 0; - s_ls_shadow_saw_exception = 0; - s_ls_shadow_owner = 1; - g_ls_mode = 1; - return 1; -} - -int ls_shadow_record_end(uint32_t *ops, int *saw_exception) { - if (s_ls_shadow_owner != 1) return 0; - g_ls_mode = 0; - s_ls_shadow_owner = 0; - if (ops) *ops = (uint32_t)s_ls_trace_n; - if (saw_exception) *saw_exception = s_ls_shadow_saw_exception; - return !s_ls_overflow; -} - -int ls_shadow_replay_begin(void) { - if (s_ls_shadow_owner || g_ls_mode != 0 || g_ls_replay_active || - s_ls_overflow) - return 0; - s_ls_trace_idx = 0; - s_ls_mismatch = 0; - s_ls_m_kind = 0; - s_ls_replay_done = 0; - s_ls_shadow_owner = 2; - g_ls_replay_active = 1; - g_ls_mode = 2; - return 1; -} - -int ls_shadow_replay_end(uint32_t *ops, int *mismatch_kind, - uint32_t *pc, uint32_t *addr, - uint32_t *expected, uint32_t *actual) { - if (s_ls_shadow_owner != 2) return 0; - int complete = !s_ls_mismatch && !s_ls_replay_done && - s_ls_trace_idx == s_ls_trace_n; - g_ls_mode = 0; - g_ls_replay_active = 0; - s_ls_shadow_owner = 0; - if (ops) *ops = (uint32_t)s_ls_trace_idx; - if (mismatch_kind) *mismatch_kind = s_ls_m_kind; - if (pc) *pc = s_ls_m_pc; - if (addr) *addr = s_ls_m_addr; - if (expected) *expected = s_ls_m_exp; - if (actual) *actual = s_ls_m_act; - return complete; -} - -void ls_shadow_abort(void) { - g_ls_mode = 0; - g_ls_replay_active = 0; - s_ls_shadow_owner = 0; - s_ls_shadow_saw_exception = 0; -} - -void ls_suppress_begin(void) { - g_ls_suppress_record++; -} - -void ls_suppress_end(void) { - if (g_ls_suppress_record > 0) g_ls_suppress_record--; -} - -uint32_t ls_read_hook(uint32_t addr, int size, uint32_t real_val) { - if (g_ls_mode == 2) { /* replay: serve from trace */ - if (s_ls_trace_idx >= s_ls_trace_n) { s_ls_replay_done = 1; return 0; } /* count mismatch: benign block-boundary artifact */ - ls_op_t *o = &s_ls_trace[s_ls_trace_idx]; - if (o->is_write || o->size != (uint8_t)size || o->addr != addr) { - if (!s_ls_mismatch) { s_ls_mismatch = 1; s_ls_m_kind = 1; s_ls_m_pc = s_ls_cur_pc; - s_ls_m_addr = addr; s_ls_m_exp = addr; s_ls_m_act = o->addr; } - return 0; - } - s_ls_trace_idx++; - return o->val; - } - if (g_ls_mode == 1) { /* record */ - if (s_ls_trace_n < LS_TRACE_CAP) { - ls_op_t *o = &s_ls_trace[s_ls_trace_n++]; - o->is_write = 0; o->size = (uint8_t)size; o->addr = addr; o->val = real_val; - } else s_ls_overflow = 1; - } - return real_val; -} - -void ls_write_hook(uint32_t addr, int size, uint32_t val) { - if (g_ls_mode == 2) { /* replay: verify against trace */ - if (s_ls_trace_idx >= s_ls_trace_n) { s_ls_replay_done = 1; return; } /* count mismatch: benign block-boundary artifact */ - ls_op_t *o = &s_ls_trace[s_ls_trace_idx]; - if (!o->is_write || o->size != (uint8_t)size || o->addr != addr) { - if (!s_ls_mismatch) { s_ls_mismatch = 1; s_ls_m_kind = 2; s_ls_m_pc = s_ls_cur_pc; - s_ls_m_addr = addr; s_ls_m_exp = addr; s_ls_m_act = o->addr; } - return; - } - if (o->val != val) { /* compiled wrote a different value than interp */ - if (!s_ls_mismatch) { s_ls_mismatch = 1; s_ls_m_kind = 3; s_ls_m_pc = s_ls_cur_pc; - s_ls_m_addr = addr; s_ls_m_exp = val; s_ls_m_act = o->val; } - } - s_ls_trace_idx++; - return; - } - if (g_ls_mode == 1) { /* record */ - if (s_ls_trace_n < LS_TRACE_CAP) { - ls_op_t *o = &s_ls_trace[s_ls_trace_n++]; - o->is_write = 1; o->size = (uint8_t)size; o->addr = addr; o->val = val; - } else s_ls_overflow = 1; - } -} - -static void ls_latch(int kind, uint32_t frame, uint32_t pc, uint32_t addr, - uint32_t exp, uint32_t act, int detail) { - if (s_ls_div.found) return; - s_ls_div.found = 1; s_ls_div.kind = kind; s_ls_div.frame = frame; - s_ls_div.block = s_ls_block; s_ls_div.pc = pc; s_ls_div.addr = addr; - s_ls_div.exp = exp; s_ls_div.act = act; s_ls_div.detail = detail; -} - -/* Re-interpret the just-recorded block from the entry snapshot, comparing the - * interp result to native (cpu == post-block native state). */ -static void ls_replay_and_compare(CPUState *cpu, uint32_t frame, uint32_t target_phys) { - CPUState rep = s_ls_R0; /* registers from block entry (pc is stale in compiled) */ - rep.pc = s_ls_block | 0x80000000u; /* start at the recorded block's leader (KSEG0) */ - s_ls_trace_idx = 0; s_ls_mismatch = 0; s_ls_m_kind = 0; s_ls_replay_done = 0; - /* The replay's exec_one writes process-global decode state on any - * instruction it can't handle; snapshot+restore so the replay can never - * divert the REAL dispatch (which reacts to g_unsupported_seen). */ - int sav_unsup_seen = g_unsupported_seen; - uint32_t sav_unsup_pc = g_unsupported_pc; - uint32_t sav_unsup_insn = g_unsupported_insn; - const char *sav_unsup_reason = g_unsupported_reason; - int gte_sandbox = gte_replay_side_effects_begin(); - g_ls_replay_active = 1; - g_ls_mode = 2; - uint32_t pc = rep.pc; - int cap = 512, steps = 0; - for (;;) { - /* Stop at the next block leader (fall-through block, or a branch's - * target). steps>0 so a self-looping block runs one iteration. */ - if (steps > 0 && (pc & 0x1FFFFFFFu) == target_phys) break; - if (cap-- <= 0 || s_ls_mismatch || s_ls_replay_done) break; - uint32_t next_pc = 0; - s_ls_cur_pc = pc; - int transferred = exec_one(&rep, pc, &next_pc); - pc = transferred ? rep.pc : next_pc; - steps++; - /* Also stop at the block TERMINATOR (branch/jump/call) — do NOT follow - * it. A call would run the callee (kernel/syscall/another fn) which the - * recording dispatched separately or whose ops trail in the trace; - * following it desyncs. Compare only the block's own in-line memory ops, - * checked op-by-op against the trace as we go. */ - if (transferred) break; - } - g_ls_mode = 0; - g_ls_replay_active = 0; - if (gte_sandbox) gte_replay_side_effects_end(); - g_unsupported_seen = sav_unsup_seen; /* undo any replay-side decode-state leak */ - g_unsupported_pc = sav_unsup_pc; - g_unsupported_insn = sav_unsup_insn; - g_unsupported_reason = sav_unsup_reason; - s_ls_div.blocks_checked++; - - /* Divergence = an in-block memory-op mismatch (the interp replay read a - * different address, or the compiled block wrote a different value, at the - * same op index given identical entry state). Leftover trace ops past the - * terminator are the callee's (recording spanned a call) and are ignored; - * registers are not compared (boundary-sensitive across calls). A wrong - * register value still surfaces here later, as a wrong store value / load - * address, when the bad value is used. */ - if (s_ls_mismatch) { - int n = s_ls_trace_n; if (n > 48) n = 48; - for (int i = 0; i < n; i++) s_ls_div_trace[i] = s_ls_trace[i]; - s_ls_div_trace_n = n; - s_ls_div_idx = s_ls_trace_idx; - int k = (s_ls_m_kind == 1) ? 5 : (s_ls_m_kind == 2) ? 6 : (s_ls_m_kind == 3) ? 4 : 7; - ls_latch(k, frame, s_ls_m_pc, s_ls_m_addr, s_ls_m_exp, s_ls_m_act, 0); - return; - } -} - -static int ls_same_phys(uint32_t a, uint32_t b) { - return (((a ^ b) & 0x1FFFFFFFu) == 0); -} - -static void lsf_copy_div_trace(void) { - int n = s_ls_trace_n; - if (n > 48) n = 48; - for (int i = 0; i < n; i++) s_lsf_div_trace[i] = s_ls_trace[i]; - s_lsf_div_trace_n = n; - s_lsf_div_idx = s_ls_trace_idx; -} - -static void lsf_latch(int kind, uint32_t frame, uint32_t entry, uint32_t pc, - uint32_t addr, uint32_t exp, uint32_t act, int detail, - uint32_t steps) { - if (s_lsf_div.found) return; - s_lsf_div.found = 1; - s_lsf_div.kind = kind; - s_lsf_div.frame = frame; - s_lsf_div.entry = entry; - s_lsf_div.pc = pc; - s_lsf_div.addr = addr; - s_lsf_div.exp = exp; - s_lsf_div.act = act; - s_lsf_div.detail = detail; - s_lsf_div.trace_ops = (uint32_t)s_ls_trace_n; - s_lsf_div.replay_ops = (uint32_t)s_ls_trace_idx; - s_lsf_div.steps = steps; - lsf_copy_div_trace(); -} - -static void lsf_compare_regs(CPUState *rep, CPUState *nat, uint32_t frame, - uint32_t entry, uint32_t steps) { - if (!ls_same_phys(rep->pc, nat->pc)) { - lsf_latch(4, frame, entry, rep->pc, 0, rep->pc, nat->pc, 0, steps); - return; - } - for (int i = 0; i < 32; i++) { - if (rep->gpr[i] != nat->gpr[i]) { - lsf_latch(1, frame, entry, rep->pc, 0, rep->gpr[i], nat->gpr[i], i, steps); - return; - } - } - if (rep->hi != nat->hi) { - lsf_latch(2, frame, entry, rep->pc, 0, rep->hi, nat->hi, 0, steps); - return; - } - if (rep->lo != nat->lo) { - lsf_latch(3, frame, entry, rep->pc, 0, rep->lo, nat->lo, 0, steps); - return; - } -} - -static void lsf_replay_and_compare(CPUState *native_post, uint32_t frame, - uint32_t entry_pc) { - CPUState rep = s_lsf_R0; - uint32_t target = native_post->pc; - rep.pc = entry_pc; - - s_ls_trace_idx = 0; - s_ls_mismatch = 0; - s_ls_m_kind = 0; - s_ls_replay_done = 0; - - int sav_unsup_seen = g_unsupported_seen; - uint32_t sav_unsup_pc = g_unsupported_pc; - uint32_t sav_unsup_insn = g_unsupported_insn; - const char *sav_unsup_reason = g_unsupported_reason; - uint32_t sav_store_pc = g_debug_last_store_pc; - uint32_t sav_func_addr = g_debug_current_func_addr; - int gte_sandbox = gte_replay_side_effects_begin(); - - g_ls_replay_active = 1; - g_ls_mode = 2; - - uint32_t pc = entry_pc; - uint32_t steps = 0; - int done = 0; - enum { MAX_FUNC_REPLAY_INSNS = 250000 }; - for (;;) { - if (steps > 0 && target != 0 && ls_same_phys(pc, target) && - s_ls_trace_idx == s_ls_trace_n) { - done = 1; - break; - } - if (steps >= MAX_FUNC_REPLAY_INSNS) { - lsf_latch(10, frame, entry_pc, pc, 0, target, pc, 0, steps); - break; - } - if (s_ls_mismatch || s_ls_replay_done) break; - - uint32_t next_pc = 0; - s_ls_cur_pc = pc; - g_unsupported_seen = 0; - int transferred = exec_one(&rep, pc, &next_pc); - if (g_unsupported_seen) { - lsf_latch(11, frame, entry_pc, pc, 0, 0, g_unsupported_insn, 0, steps); - break; - } - pc = transferred ? rep.pc : next_pc; - rep.pc = pc; - steps++; - } - - g_ls_mode = 0; - g_ls_replay_active = 0; - if (gte_sandbox) gte_replay_side_effects_end(); - g_unsupported_seen = sav_unsup_seen; - g_unsupported_pc = sav_unsup_pc; - g_unsupported_insn = sav_unsup_insn; - g_unsupported_reason = sav_unsup_reason; - g_debug_last_store_pc = sav_store_pc; - g_debug_current_func_addr = sav_func_addr; - - s_lsf_div.segments_checked++; - - if (s_ls_mismatch) { - int k = (s_ls_m_kind == 1) ? 6 : (s_ls_m_kind == 2) ? 7 : - (s_ls_m_kind == 3) ? 5 : 8; - lsf_latch(k, frame, entry_pc, s_ls_m_pc, s_ls_m_addr, - s_ls_m_exp, s_ls_m_act, 0, steps); - return; - } - if (s_ls_replay_done) { - lsf_latch(8, frame, entry_pc, s_ls_cur_pc, 0, (uint32_t)s_ls_trace_n, - (uint32_t)s_ls_trace_idx, 0, steps); - return; - } - if (!done) { - lsf_latch(10, frame, entry_pc, pc, 0, target, pc, 0, steps); - return; - } - if (s_ls_trace_idx != s_ls_trace_n) { - lsf_latch(9, frame, entry_pc, pc, 0, (uint32_t)s_ls_trace_n, - (uint32_t)s_ls_trace_idx, 0, steps); - return; - } - - lsf_compare_regs(&rep, native_post, frame, entry_pc, steps); -} - -void ls_func_enter(uint32_t entry_pc, CPUState *cpu) { - if (s_lsf_frame_hi == 0 || s_lsf_div.found || !cpu) return; - if (g_ls_replay_active || g_ls_mode != 0 || s_lsf_active) { - s_lsf_div.skipped_conflict++; - return; - } - if (psx_get_in_exception()) { - s_lsf_div.skipped_irq++; - return; - } - uint32_t frame = (uint32_t)s_frame_count; - if (frame < s_lsf_frame_lo || frame > s_lsf_frame_hi) { - s_lsf_div.skipped_disabled++; - return; - } - uint32_t phys = entry_pc & 0x1FFFFFFFu; - if (phys < 0x00010000u || phys >= 0x00200000u) return; - - s_lsf_dispatch_entry = entry_pc; - s_lsf_entry = entry_pc; - s_lsf_frame = frame; - s_lsf_saw_irq = 0; - s_lsf_active = 1; - s_lsf_recording = 0; -} - -void ls_func_exit(uint32_t entry_pc, CPUState *cpu, int handled) { - if (!s_lsf_active) return; - if (s_lsf_dispatch_entry != entry_pc) { - g_ls_mode = 0; - s_lsf_active = 0; - s_lsf_recording = 0; - s_lsf_div.skipped_conflict++; - return; - } - g_ls_mode = 0; - s_lsf_active = 0; - if (!s_lsf_recording) { - s_lsf_div.skipped_unhandled++; - return; - } - s_lsf_recording = 0; - - if (!handled || !cpu) { - s_lsf_div.skipped_unhandled++; - return; - } - if (s_lsf_saw_irq) { - s_lsf_div.skipped_irq++; - return; - } - if (s_ls_overflow) { - s_lsf_div.skipped_overflow++; - return; - } - if (s_lsf_record_only) { - s_lsf_div.segments_checked++; - return; - } - lsf_replay_and_compare(cpu, s_lsf_frame, s_lsf_entry); -} - -void ls_at_leader(uint32_t leader_phys, CPUState *cpu) { - if (s_lsf_active) { - if (!s_lsf_recording && cpu && !s_lsf_div.found && g_ls_mode == 0) { - s_lsf_R0 = *cpu; - s_lsf_entry = leader_phys | 0x80000000u; - s_ls_trace_n = 0; - s_ls_trace_idx = 0; - s_ls_overflow = 0; - s_ls_mismatch = 0; - s_ls_m_kind = 0; - s_lsf_recording = 1; - g_ls_mode = 1; - } - return; /* function-scope recording owns g_ls_mode */ - } - if (s_ls_frame_hi == 0 || s_ls_div.found || !cpu) return; - if (g_ls_mode == 2) return; /* re-entrancy guard (shouldn't happen) */ - uint32_t frame = (uint32_t)s_frame_count; - int in_win = (frame >= s_ls_frame_lo && frame <= s_ls_frame_hi); - if (!in_win) { if (g_ls_mode == 1) { g_ls_mode = 0; s_ls_prev_valid = 0; } return; } - - if (s_ls_prev_valid && g_ls_mode == 1) { /* finalize previous game block */ - g_ls_mode = 0; - if (!s_ls_record_only) { /* record_only: skip inline replay (perturbation test) */ - ls_replay_and_compare(cpu, frame, leader_phys); /* this leader = where prev block went */ - if (s_ls_div.found) { s_ls_prev_valid = 0; return; } - } else { - s_ls_div.blocks_checked++; /* count recorded blocks */ - } - s_ls_prev_valid = 0; - } - /* Only START recording for a genuinely COMPILED game-text block: - * - !g_ls_dirty_observe: the dirty-RAM interp loop also calls cyc_observe - * (per-instruction, for the cycle ruler); skip those — they're already - * interpreted (clean game text dispatched FROM the dirty path still runs - * compiled, so we can't use g_dirty_interp_active here). - * - [0x10000, 0x200000): game EXE text in main RAM (above the low-RAM - * kernel/relocated-BIOS area, which isn't the regression locus). */ - if (!g_ls_dirty_observe && leader_phys >= 0x00010000u && leader_phys < 0x00200000u) { - s_ls_R0 = *cpu; - s_ls_block = leader_phys; - s_ls_trace_n = 0; s_ls_trace_idx = 0; s_ls_overflow = 0; s_ls_mismatch = 0; - s_ls_prev_valid = 1; - g_ls_mode = 1; - } -} - -int ls_get_diverge_json(char *buf, int buflen) { - static const char *kn[] = {"none","reg","hi","lo","write-val","read-addr", - "write-addr","trace-exhausted","compiled-extra-ops","path-cap"}; - int k = (s_ls_div.kind >= 0 && s_ls_div.kind <= 9) ? s_ls_div.kind : 0; - int p = snprintf(buf, buflen, - "{\"found\":%d,\"kind\":\"%s\",\"frame\":%u,\"block\":\"0x%08X\",\"pc\":\"0x%08X\"," - "\"addr\":\"0x%08X\",\"interp_expected\":\"0x%08X\",\"compiled_actual\":\"0x%08X\"," - "\"reg\":%d,\"blocks_checked\":%llu,\"window\":[%u,%u],\"div_idx\":%d,\"trace\":[", - s_ls_div.found, kn[k], s_ls_div.frame, s_ls_div.block, s_ls_div.pc, - s_ls_div.addr, s_ls_div.exp, s_ls_div.act, s_ls_div.detail, - (unsigned long long)s_ls_div.blocks_checked, s_ls_frame_lo, s_ls_frame_hi, - s_ls_div_idx); - for (int i = 0; i < s_ls_div_trace_n && p < buflen - 48; i++) { - p += snprintf(buf + p, buflen - p, "%s\"%c%d:%08X=%08X\"", - i ? "," : "", s_ls_div_trace[i].is_write ? 'W' : 'R', - s_ls_div_trace[i].size, s_ls_div_trace[i].addr, s_ls_div_trace[i].val); - } - p += snprintf(buf + p, buflen - p, "]}"); - return p; -} - -int ls_get_func_json(char *buf, int buflen) { - static const char *kn[] = { - "none","gpr","hi","lo","pc","write-val","read-addr","write-addr", - "trace-exhausted","trace-leftover","path-cap","unsupported" - }; - int k = (s_lsf_div.kind >= 0 && s_lsf_div.kind <= 11) ? s_lsf_div.kind : 0; - int p = snprintf(buf, buflen, - "{\"found\":%d,\"kind\":\"%s\",\"frame\":%u,\"entry\":\"0x%08X\"," - "\"pc\":\"0x%08X\",\"addr\":\"0x%08X\"," - "\"interp_expected\":\"0x%08X\",\"compiled_actual\":\"0x%08X\"," - "\"reg\":%d,\"segments_checked\":%llu,\"skipped_irq\":%llu," - "\"skipped_overflow\":%llu,\"skipped_unhandled\":%llu," - "\"skipped_conflict\":%llu,\"skipped_disabled\":%llu," - "\"window\":[%u,%u],\"trace_ops\":%u,\"replay_ops\":%u," - "\"steps\":%u,\"div_idx\":%d,\"trace\":[", - s_lsf_div.found, kn[k], s_lsf_div.frame, s_lsf_div.entry, - s_lsf_div.pc, s_lsf_div.addr, s_lsf_div.exp, s_lsf_div.act, - s_lsf_div.detail, (unsigned long long)s_lsf_div.segments_checked, - (unsigned long long)s_lsf_div.skipped_irq, - (unsigned long long)s_lsf_div.skipped_overflow, - (unsigned long long)s_lsf_div.skipped_unhandled, - (unsigned long long)s_lsf_div.skipped_conflict, - (unsigned long long)s_lsf_div.skipped_disabled, - s_lsf_frame_lo, s_lsf_frame_hi, s_lsf_div.trace_ops, - s_lsf_div.replay_ops, s_lsf_div.steps, s_lsf_div_idx); - for (int i = 0; i < s_lsf_div_trace_n && p < buflen - 48; i++) { - p += snprintf(buf + p, buflen - p, "%s\"%c%d:%08X=%08X\"", - i ? "," : "", s_lsf_div_trace[i].is_write ? 'W' : 'R', - s_lsf_div_trace[i].size, s_lsf_div_trace[i].addr, - s_lsf_div_trace[i].val); - } - p += snprintf(buf + p, buflen - p, "]}"); - return p; -} diff --git a/psxrecomp-overlay/runtime/src/gpu.c b/psxrecomp-overlay/runtime/src/gpu.c deleted file mode 100644 index 2bd3df7..0000000 --- a/psxrecomp-overlay/runtime/src/gpu.c +++ /dev/null @@ -1,6336 +0,0 @@ -/* gpu.c — PS1 GPU hardware simulation (Phase 3, Step 1). - * - * Implements: - * - GPUSTAT register with correct bit semantics - * - GP1 commands 00h-08h (reset, display config) - * - VRAM storage (1024x512 x 16-bit) - * - GP0 command write — ABORTS (not yet implemented) - * - GPUREAD — returns last latched value - * - * Reference: nocash PSX specs, DuckStation src/core/gpu.cpp - */ - -#include "gpu.h" -#include "gte_precision.h" -#include "mod_memory.h" -#include "gpu_primitive_reject.h" -#include "gpu_sw_renderer.h" -#include "gpu_render.h" -#include "text_xlate.h" -#include "crash_trace.h" -#include "debug_server.h" -#include "cpu_state.h" -#include "event_ring.h" -#include "color_lut.h" -#include "mod_runtime.h" -#include "ws_cull_detect.h" -#include "ws_aspect_cone_math.h" -#include "gpu_pinned_corner.h" -#include "gpu_ws_hud_scale.h" -#include "gpu_ws_hud_widget.h" -#include "gpu_ws_screen_tile.h" -#include "gpu_ws_tag_match.h" -#include "gpu_ws_frame_kind.h" -#include "gpu_ws_menu.h" -#include "gpu_temporal_sprite.h" -#include "ws_ui_group.h" -#include -#include -#include -#include -#include - -extern uint16_t psx_read_half(uint32_t addr); -extern uint8_t psx_read_byte(uint32_t addr); -extern uint32_t psx_read_word(uint32_t addr); - -/* ---- VRAM ---- */ -static uint16_t vram[1024 * 512]; - -/* ---- GP0 state machine ---- */ - -typedef enum { - GP0_IDLE, - GP0_COLLECTING, - GP0_VRAM_WRITE, - GP0_POLYLINE_MONO, /* collecting mono polyline vertices */ - GP0_POLYLINE_SHADED /* collecting shaded polyline color+vertex pairs */ -} Gp0State; - -static Gp0State gp0_state; -static uint64_t gp0_write_count; -static uint64_t gp0_nop_count, gp0_fill_count, gp0_draw_count, gp0_env_count, gp0_copy_count; -static uint32_t gp0_cmd_buf[16]; /* max fixed-length command is 12 words */ -static int gp0_words_collected; -static int gp0_words_needed; -static uint32_t gp0_next_source_addr = 0xFFFFFFFFu; -static uint32_t gp0_cmd_source_addr = 0xFFFFFFFFu; -static uint16_t gp0_ot_rank = 0xFFFFu; - -/* ---- Widescreen proportion correction -------------------------------------- - * Active only when [video] aspect_ratio != 4:3 AND the game's [widescreen] - * block opts in (see config_loader.h). Two mechanisms, both driven from the - * game.toml so nothing here is game-specific: - * - * 1. Tagged character/billboard prims. The recompiler emits a - * psx_ws_sprite_tag(cpu) call at the entry of each configured - * sprite-tag function ($a0 = prim pointer, scratchpad holds the prim's - * GTE-projected anchor). The game computes the prim's screen-pixel - * offsets/widths itself — which our GTE X-squash never sees — so tagged - * prims get every X re-squashed around their own anchor at execution - * time, restoring correct proportions on the stretched present. - * - * 2. Untagged textured rects (SPRT family). These never went through the - * GTE at all (HUD, menus — pure screen space), so they're squashed - * around the display centre, presenting at native proportions. - * Untextured TILEs are never touched: full-screen fades/flashes must - * keep covering the whole frame. - * - * The tag table is keyed by the prim's guest address (the DMA linked-list - * walk reports each word's source address) and stamped with the frame - * counter, so stale entries age out without an explicit clear. */ -static int32_t ws_xnum = 1, ws_xden = 1; /* X squash factor; 1/1 = off */ -static uint32_t ws_anchor_addr = 0; /* scratchpad addr of anchor SXY */ -static int ws_hud_sprt = 0; /* edge-anchor untagged HUD SPRTs */ -static int ws_auto_ui_squash; -static int ws_auto_ui_dense; -static uint64_t ws_auto_ui_candidate_count; -static uint64_t ws_auto_ui_transform_count; -#define WS_UI_PREPASS_MAX 2048u -typedef struct { - WsUiGroupItem group; - uint32_t src_addr; - uint16_t ot_rank; -} WsUiPrepassItem; -static WsUiPrepassItem ws_ui_prepass[WS_UI_PREPASS_MAX]; -static uint32_t ws_ui_prepass_count; -static uint16_t ws_ui_prepass_rank = 0xFFFFu; - -/* Wide-aspect mode: 0 = off (4:3 identity), 1 = squash (legacy hack — compress - * a wider FOV into the 320 frame, present stretched), 2 = native-wide (render - * the wider FOV into an actually wider frame and present 1:1; the GTE is NOT - * squashed — the frame is widened via draw_offset/draw_area and the display - * read). ws_cfg_num/den hold the configured aspect that drives the wide extent - * in native-wide mode. */ -static int ws_mode = 0; -static int ws_cfg_num = 4, ws_cfg_den = 3; -static int ws_geometry_correction = 0; -static int s_texture_correction_enabled = 0; -static uint32_t s_texture_correction_triangles = 0; -static double s_geometry_camera_yaw_residual = 0.0; -static int s_geometry_camera_projection_center_y = 120; -static uint64_t ws_geometry_world_triangles = 0; -static uint64_t ws_geometry_precise_triangles = 0; -static GpuGeometryCorrectionCounters s_geometry_diag_cumulative; -static GpuGeometryCorrectionCounters s_geometry_diag_live_current; -static GpuGeometryCorrectionCounters s_geometry_diag_live_latest; -static uint64_t s_geometry_diag_presentation_sequence; -static uint64_t s_geometry_diag_corrected_presentations; -static uint64_t s_geometry_diag_latest_live_presentation; -static int s_geometry_diag_latest_live_valid; -static int s_geometry_diag_live_current_touched; -static void ws_nw_sync_target(void); - -#define WS_TAG_BUCKETS 4096 /* power of two */ -#define WS_TAG_PROBES 8 -#define WS_FMV_HYSTERESIS 30 /* frames a colour MDEC decode pins 4:3 */ -typedef struct { - uint32_t key; - uint32_t stamp; - int32_t anchor_x; - uint32_t signature; - uint8_t content_validated; - uint8_t placed, sized, shadow, whole; - int32_t centre_x, offset_y; /* 16.16: a sprite's unrounded centre, and how far above or below its row */ - int32_t row, width, height; /* 16.16: the unrounded row it is centred on and its unrounded sides */ -} WsTag; -static WsTag ws_tags[WS_TAG_BUCKETS]; -static uint32_t ws_last_tag_stamp = (uint32_t)-1000; /* frame of newest tag */ -static uint32_t ws_last_3d_stamp = (uint32_t)-1000; /* frame of newest shaded prim (diagnostic) */ -extern uint64_t s_frame_count; /* defined in debug_server.c */ -extern int mdec_recently_active(uint32_t within_frames); /* mdec.c */ - -static int ws_configured(void) { return ws_xnum != ws_xden; } - -/* Any wide mode engaged (squash or native-wide). Drives the FMV/menu 4:3 - * pillarbox + the game-vs-2D detector, which both modes share. */ -static int ws_engaged(void) { return ws_mode != 0; } - -/* Forward decls: defined later but used by psx_ws_backdrop_x above them. */ -static int32_t ws_scale_about(int32_t x, int32_t ax); -static int32_t ws_disp_w(void); -static void ws_clear_all_reveal_margins(void); - -/* Gameplay vs full-2D screen. Character/billboard prims tag (psx_ws_sprite_tag) - * within the last couple of frames => the actor render funnel is running => - * gameplay. A menu/title/save screen draws no tagged prims. The 2-frame window - * makes this frame-stable regardless of per-prim ordering within a frame. */ -/* Full-2D widescreen ([widescreen] full_2d). A pure 2D sprite game (e.g. MMX6) - * never emits the sprite-tag hook the 3D gameplay detector keys on, so every - * in-game frame would be misclassified as a "full-2D menu" and pillarboxed 4:3. - * When this is set, treat every in-game frame as gameplay so native-wide engages - * and the 2D scene presents widescreen. Set once from game.toml at widescreen- - * engage (gpu_ws_set_full_2d); PSX_WS_FORCE_2D=1 forces it on for testing. */ -static int ws_full_2d = 0; -void gpu_ws_set_full_2d(int on) { ws_full_2d = on ? 1 : 0; } -void gpu_ws_set_auto_ui_squash(int on) { - ws_auto_ui_squash = on ? 1 : 0; - ws_ui_prepass_count = 0; - ws_ui_prepass_rank = 0xFFFFu; - ws_auto_ui_dense = 0; - ws_auto_ui_candidate_count = 0; - ws_auto_ui_transform_count = 0; -} -static int ws_clear_reveal = 0; -static int g_mmx6_void_sides = 0; -static uint32_t g_mmx6_void_generation = 1; -void gpu_ws_set_clear_reveal(int on) { ws_clear_reveal = on ? 1 : 0; } - -/* GTE-activity gameplay detector ([widescreen] gte_game_mode) — the generic - * 3D-title analog of the sprite-tag stamp. A fully-3D game (e.g. Ape Escape) - * has no per-prim tag helper to hook, but every gameplay frame projects a - * meaningful number of vertices through RTPS/RTPT, while a full-2D screen - * (save/options/memory-card) projects none (or a token few). gte.cpp notes - * every projection here; when a frame accumulates >= the threshold the frame - * is stamped as gameplay, with the same 2-frame hysteresis the tag path uses. - * Off unless the game opts in, so existing titles are untouched. */ -static int ws_gte_game_mode_cfg = 0; -static uint32_t ws_gte_frame = (uint32_t)-1; -static uint32_t ws_gte_count = 0; -static uint32_t ws_last_gte_stamp = (uint32_t)-1000; -#define WS_GAMEPLAY_STATE_VALUES_MAX 16 -static uint32_t ws_gameplay_state_addr = 0; -static uint32_t ws_gameplay_state_values[WS_GAMEPLAY_STATE_VALUES_MAX]; -static int ws_gameplay_state_value_count = 0; -/* Any frame that projects a handful of vertices is "3D" (a low threshold so a - * sparse close-up cutscene frame still counts — the flicker was frames dipping - * below a high 16-vert bar and pillarboxing for a frame or two). */ -#define WS_GTE_GAME_MODE_MIN_VERTS 3u -/* STICKY: stay in native-wide for ~0.75s after the last 3D frame, so brief - * low-poly frames in a real-time 3D cutscene never flip to a 4:3 pillarbox (the - * intro-cutscene flicker). Only a genuine full-2D screen — no GTE projection for - * this many consecutive frames (save/options/memory-card) — reverts to 4:3. */ -#define WS_GTE_GAME_MODE_HYSTERESIS 45u -static PsxWsFrameKinds ws_frame_kinds; -static uint32_t display_area_x, display_area_y; -void gpu_ws_set_gte_game_mode(int on) { ws_gte_game_mode_cfg = on ? 1 : 0; } -void gpu_ws_set_gameplay_state_gate(uint32_t addr, - const uint32_t *values, int nvalues) { - if (nvalues < 0) nvalues = 0; - if (nvalues > WS_GAMEPLAY_STATE_VALUES_MAX) - nvalues = WS_GAMEPLAY_STATE_VALUES_MAX; - ws_gameplay_state_addr = addr; - ws_gameplay_state_value_count = nvalues; - for (int i = 0; i < nvalues; i++) - ws_gameplay_state_values[i] = values[i]; -} - -static int ws_gameplay_state_matches(void) { - if (!ws_gameplay_state_addr || ws_gameplay_state_value_count == 0) - return -1; - uint32_t state = psx_read_word(ws_gameplay_state_addr); - for (int i = 0; i < ws_gameplay_state_value_count; i++) - if (state == ws_gameplay_state_values[i]) return 1; - return 0; -} - -/* World-scale 3D signal for the 2D-only-scene classifier (sprite-tag titles). - * Shaded-prim presence proved to be a FALSE world signal: task-clear / - * new-task title cards are gouraud-shaded screen-space tiles animated over - * many consecutive frames, which defeated both the shaded test and its - * sustained guard (room re-stretched whenever a card played). Projection - * volume can't be fooled that way: a real 3D world projects hundreds of - * verts per frame through RTPS/RTPT, a room interior projects only its - * character anchors (a handful), and screen-space overlays project ZERO. - * Sustained rule as elsewhere: 2+ consecutive frames over threshold, so an - * isolated projection burst can't flip a 2D scene wide. */ -#define WS_WORLD3D_MIN_VERTS 48u -static uint32_t ws_gte_prev_verts = 0; /* final count of last completed frame */ -static uint32_t ws_last_world3d_stamp = (uint32_t)-1000; -static uint32_t ws_sust_world3d_stamp = (uint32_t)-1000; - -/* Natural-overhang world signal — the classifier's actual input. GTE volume - * above proved to be a SECOND false world signal: the task-found/task-clear - * jingle projects world-scale vert counts inside a room (stamped world3d at - * ≥48 while the room idles at 4). What can't be faked is CONTENT: a real 3D - * scene always submits polygons whose raw vertex X extent crosses outside - * the canonical display window (that overhang is the very content native- - * wide reveals — the working outdoor reveal proves it exists), while rooms, - * dialogs, HUDs and jingle popups compose everything inside it. Polygons - * only (sprites/rects slide in from edges routinely — the task icon enters - * from the right edge), ≥3 prims per frame, sustained 2+ consecutive - * frames. Counted at gp0_execute_command via prim_sx_extent (raw SX, - * pre-draw_offset, so our native-wide offset injection can't feed back). */ -/* Depth qualifier: only polys crossing DEEP past the canonical edge count. - * Census-measured (SCUS-94236): outdoor terrain = 55/frame at >=24px (depths - * to 128px); the task-jingle sparkles poke <=20px and its sliding icon is a - * single prim at 34px — so >=24px depth + >=4 prims excludes every observed - * screen-edge effect with ~14x headroom to the real world's count. */ -#define WS_OVERHANG_DEEP_PX 24 -#define WS_OVERHANG_MIN_PRIMS 4u -static uint32_t ws_ovh_frame = (uint32_t)-1; -static uint32_t ws_ovh_count = 0; -static uint32_t ws_ovh_prev = 0; /* final count of last completed frame */ -static uint32_t ws_last_ovh_stamp = (uint32_t)-1000; -static uint32_t ws_sust_ovh_stamp = (uint32_t)-1000; - -void psx_ws_note_gte_project(int nverts) { - uint32_t f = (uint32_t)s_frame_count; - psx_ws_frame_kinds_project(&ws_frame_kinds, (uint32_t)nverts); - if (f != ws_gte_frame) { - if (f == ws_gte_frame + 1u) ws_gte_prev_verts = ws_gte_count; - else ws_gte_prev_verts = 0; - ws_gte_frame = f; ws_gte_count = 0; - } - ws_gte_count += (uint32_t)nverts; - if (ws_gte_game_mode_cfg && ws_gte_count >= WS_GTE_GAME_MODE_MIN_VERTS) - ws_last_gte_stamp = f; - if (ws_gte_count >= WS_WORLD3D_MIN_VERTS && ws_last_world3d_stamp != f) { - if (f == ws_last_world3d_stamp + 1u) ws_sust_world3d_stamp = f; - ws_last_world3d_stamp = f; - } -} - -/* Full-2D tile-engine mode (MMX6): config [widescreen] full_2d, or the PSX_WS_FORCE_2D - * test override. Distinct from ws_game_mode (which also fires on the 3D sprite-tag path, - * e.g. Tomba) — only true full-2D games get the BG tile-budget reveal cap. */ -static int ws_full_2d_mode(void) { - static int env = -1; - if (env < 0) { const char *e = getenv("PSX_WS_FORCE_2D"); env = (e && e[0] == '1') ? 1 : 0; } - return ws_full_2d || env; -} -/* `displayed` asks about the frame on display, which a double-buffered game drew one frame before the one it is - * drawing now. A GTE-detected game's frames are told apart per buffer, and the grace below only covers the start. */ -static int ws_game_mode_of(int displayed) { - int state_match = ws_gameplay_state_matches(); - if (state_match >= 0) return state_match; - if (ws_full_2d_mode()) return 1; - if (ws_gte_game_mode_cfg) { - const int flat = displayed - ? psx_ws_frame_kinds_displayed(&ws_frame_kinds, display_area_x, display_area_y) - : psx_ws_frame_kinds_drawing(&ws_frame_kinds, WS_GTE_GAME_MODE_MIN_VERTS); - if (flat >= 0) return !flat; - if ((uint32_t)s_frame_count - ws_last_gte_stamp <= WS_GTE_GAME_MODE_HYSTERESIS) return 1; - } - return (uint32_t)s_frame_count - ws_last_tag_stamp <= 2; -} -static int ws_game_mode(void) { return ws_game_mode_of(0); } - -/* True when the current frame is presented at native 4:3 (NOT stretched), so - * ALL squash must be suppressed and the content rendered pixel-native: - * - FMV video (24-bit, or streamed 15-bit colour MDEC), and - * - full-2D screens (menus/title/save — no character billboards this frame). - * Coupling squash to this exact predicate keeps content and present in lock- - * step: we squash IFF we stretch. Per-prim center-squash mangles composite 2D - * UI (dialog boxes built from tiled cap/middle pieces), so such screens get - * zero squash + a 4:3 pillarbox instead. The FMV check is cached per frame; - * game_mode is a cheap live check. */ -/* 2D-only gameplay scenes (sprite-tag titles). A room interior or a sky-only - * fall is gameplay-classified (the character prims tag every frame) but has - * no world-scale GTE projection — there is no 3D world and nothing beyond - * the canonical frame to reveal, so wide presents can only stretch flat art - * (the backdrop stretch fires wholesale because the background phase never - * ends). Present those scenes native 4:3 instead: the canonical buffer is - * always faithful. The signal is natural polygon OVERHANG past the canonical - * window (ws_sust_ovh_stamp) — content that would actually be revealed. - * Shaded-prim presence and GTE projection volume both proved to be false - * world signals (title-card letter tiles; the task-jingle's GTE effect) — - * see the overhang block for the full lineage. Scoped to the tag-classified - * (2.5D) mechanism: full-2D titles (MMX6) are 2D-only by definition and - * genuinely reveal more, and GTE-detector titles (Ape) already classify 2D - * screens by projection count. The hysteresis rides out 1-2 frame gaps; a - * real scene change crosses it in ~0.1 s. */ -#define WS_2D_SCENE_HYSTERESIS 6u -static int ws_2d_only_scene(void) { - if (ws_full_2d_mode() || ws_gte_game_mode_cfg) return 0; - return (uint32_t)s_frame_count - ws_sust_ovh_stamp > WS_2D_SCENE_HYSTERESIS; -} - -static uint32_t s_ws_fmv_frame_cache = 0xFFFFFFFFu; -static int s_ws_fmv_cached = 0; - -/* The front end's backdrop on a wide screen (gpu_ws_menu.h). Squash mode only: a wider frame of its own has nothing to put beside the picture. */ -static int ws_menu_logo; /* the picture at (320,0) is the front end's, and its logo is kept at (512,0) */ -static int ws_menu_halves; /* the halves of the backdrop drawn since the logo was last drawn */ -static int ws_menu_wanted(void) { return ws_mode == 1 && ws_xnum != ws_xden; } -static int ws_menu_frame(void) { return psx_ws_frame_kinds_menu(&ws_frame_kinds); } -/* On a front-end frame everything but the backdrop keeps its shape on the stretched screen. Returns the rectangle's width. */ -static int ws_menu_rect(int32_t *x0, int w) { return ws_menu_frame() ? psx_ws_menu_squash_rect(x0, w, ws_xnum, ws_xden) : w; } -int gpu_ws_displayed_menu(void) { return psx_ws_frame_kinds_displayed_menu(&ws_frame_kinds, display_area_x, display_area_y); } - -static int ws_native_43(int displayed) { - if (!ws_engaged()) return 0; - if (displayed ? gpu_ws_displayed_menu() : ws_menu_frame()) return 0; - if (!ws_game_mode_of(displayed)) return 1; /* full-2D screen */ - if (ws_2d_only_scene()) return 1; /* 2D-only gameplay scene */ - uint32_t f = (uint32_t)s_frame_count; - if (f != s_ws_fmv_frame_cache) { - s_ws_fmv_frame_cache = f; - GpuDisplayInfo di; gpu_get_display_info(&di); - s_ws_fmv_cached = di.depth24 || mdec_recently_active(WS_FMV_HYSTERESIS); - } - return s_ws_fmv_cached; -} -int gpu_ws_present_native_43(void) { return ws_native_43(0); } -int gpu_ws_displayed_native_43(void) { return ws_native_43(1); } -void gpu_ws_tell_frame_kind(int flat) { psx_ws_frame_kinds_tell(&ws_frame_kinds, flat); } -/* The frame on display is a full-2D one of a GTE-detected game and not the front end's: uploads and copies make it, and they do not reach the corrected mirror. */ -int gpu_ws_displayed_flat(void) { - return ws_gte_game_mode_cfg && !gpu_ws_displayed_menu() && - psx_ws_frame_kinds_displayed(&ws_frame_kinds, display_area_x, display_area_y) == 1; -} - -/* Squash applies only when configured AND the frame is being stretched. */ -static int ws_active(void) { return ws_configured() && !gpu_ws_present_native_43(); } - -/* ----- Native-wide (mode 2) ------------------------------------------------ - * Render the wider FOV into an actually wider frame instead of squashing it. - * Engaged on game frames only (FMV/menu frames pillarbox 4:3 like squash). The - * frame grows symmetrically: each side by OFFSET display-pixels, total by - * EXTRA = 2*OFFSET. OFFSET = round(W*(3*num-4*den)/(8*den)) where W is the live - * display width — derived from the aspect so it generalises past 16:9 and - * tracks display-mode changes; EXTRA is forced even so OFFSET is integral - * (e.g. W=320 @ 16:9 -> OFFSET=53, EXTRA=106, frame 320 -> 426). The shift is - * applied via the GPU draw_offset (so 3D and 2D move together and stay - * aligned), the draw-area widens by EXTRA so the shifted content is not clipped - * on the right, and the present widens the display read by EXTRA. 0 when - * native-wide is inactive (4:3 / boot / squash mode / FMV / full-2D). */ -int ws_native_wide_active(void) { - return ws_mode == 2 && !gpu_ws_present_native_43(); -} -/* Cull/spawn setup often runs while a scene is loading, before the first GTE - * frame can classify it as gameplay. Keep that pre-render setup aware of the - * configured native-wide viewport; presentation itself remains gated by - * ws_native_wide_active(). */ -static int ws_native_wide_configured(void) { - return ws_mode == 2 && ws_cfg_num * 3 > ws_cfg_den * 4; -} -static int ws_nw_configured_offset(void) { - if (!ws_native_wide_configured()) return 0; - int numr = 3 * ws_cfg_num - 4 * ws_cfg_den; - int w = (int)ws_disp_w(); - return (w * numr + 4 * ws_cfg_den) / (8 * ws_cfg_den); -} -static int ws_nw_offset(void) { - if (!ws_native_wide_active()) return 0; - return ws_nw_configured_offset(); -} -int ws_nw_extra(void) { return 2 * ws_nw_offset(); } - -/* Per-side X cull margin in screen/world units (the game's draw classifier - * works in objX-camX where 1 unit ~= 1 native-4:3 screen pixel). The squash - * shows a half-view of 160/s pixels (s = squash factor = ws_xnum/ws_xden), so - * the visible edge moves out by 160*(1/s - 1) = 160*(xden-xnum)/xnum. Widening - * the cull window by this restores the original off-screen margin at the new - * edge. 0 whenever squash is inactive (4:3/boot/menu/FMV) → original cull. */ -/* Diagnostic override (8C): when >= 0, psx_ws_x_margin() returns this value - * unconditionally so a probe can sweep the cull margin live (0 = force 4:3 - * cull while still stretching; large = over-draw) at a fixed camera position. - * -1 = normal computed margin. */ -static int ws_margin_override = -1; -static int ws_cull_guard_pixels = 0; -static int ws_activation_guard_pixels = 0; -void gpu_ws_set_margin_override(int v) { ws_margin_override = v; } -void gpu_ws_set_cull_guard_pixels(int pixels) { - if (pixels < 0) pixels = 0; - if (pixels > 256) pixels = 256; - ws_cull_guard_pixels = pixels; -} -void gpu_ws_set_activation_guard_pixels(int pixels) { - if (pixels < 0) pixels = 0; - if (pixels > 256) pixels = 256; - ws_activation_guard_pixels = pixels; -} - -#define WS_EXPLICIT_CULL_SITES_MAX 64 -static uint32_t ws_explicit_bias_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static uint32_t ws_explicit_slti_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static uint32_t ws_explicit_range_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static int ws_explicit_bias_n = 0; -static int ws_explicit_slti_n = 0; -static int ws_explicit_range_n = 0; -void gpu_ws_set_explicit_cull_sites(const uint32_t *bias, int nbias, - const uint32_t *slti, int nslti, - const uint32_t *range, int nrange) { - if (nbias < 0) nbias = 0; - if (nslti < 0) nslti = 0; - if (nrange < 0) nrange = 0; - if (nbias > WS_EXPLICIT_CULL_SITES_MAX) nbias = WS_EXPLICIT_CULL_SITES_MAX; - if (nslti > WS_EXPLICIT_CULL_SITES_MAX) nslti = WS_EXPLICIT_CULL_SITES_MAX; - if (nrange > WS_EXPLICIT_CULL_SITES_MAX) nrange = WS_EXPLICIT_CULL_SITES_MAX; - ws_explicit_bias_n = nbias; - ws_explicit_slti_n = nslti; - ws_explicit_range_n = nrange; - for (int i = 0; i < nbias; i++) ws_explicit_bias_sites[i] = bias[i] & 0x1FFFFFFFu; - for (int i = 0; i < nslti; i++) ws_explicit_slti_sites[i] = slti[i] & 0x1FFFFFFFu; - for (int i = 0; i < nrange; i++) ws_explicit_range_sites[i] = range[i] & 0x1FFFFFFFu; -} -static int ws_explicit_site(const uint32_t *sites, int n, uint32_t pc) { - uint32_t p = pc & 0x1FFFFFFFu; - for (int i = 0; i < n; i++) if (sites[i] == p) return 1; - return 0; -} -int psx_ws_is_cull_bias_site(uint32_t pc) { - return ws_explicit_site(ws_explicit_bias_sites, ws_explicit_bias_n, pc); -} -int psx_ws_is_cull_slti_site(uint32_t pc) { - return ws_explicit_site(ws_explicit_slti_sites, ws_explicit_slti_n, pc); -} -static uint32_t ws_explicit_slti_lower_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static int ws_explicit_slti_lower_n = 0; -void gpu_ws_set_slti_lower_cull_sites(const uint32_t *sites, int nsites) { - if (nsites < 0) nsites = 0; - if (nsites > WS_EXPLICIT_CULL_SITES_MAX) - nsites = WS_EXPLICIT_CULL_SITES_MAX; - ws_explicit_slti_lower_n = nsites; - for (int i = 0; i < nsites; i++) - ws_explicit_slti_lower_sites[i] = sites[i] & 0x1FFFFFFFu; -} -int psx_ws_is_cull_slti_lower_site(uint32_t pc) { - return ws_explicit_site(ws_explicit_slti_lower_sites, - ws_explicit_slti_lower_n, pc); -} -static uint32_t ws_explicit_negsub_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static int ws_explicit_negsub_n = 0; -void gpu_ws_set_negsub_cull_sites(const uint32_t *sites, int nsites) { - if (nsites < 0) nsites = 0; - if (nsites > WS_EXPLICIT_CULL_SITES_MAX) nsites = WS_EXPLICIT_CULL_SITES_MAX; - ws_explicit_negsub_n = nsites; - for (int i = 0; i < nsites; i++) - ws_explicit_negsub_sites[i] = sites[i] & 0x1FFFFFFFu; -} -int psx_ws_is_cull_negsub_site(uint32_t pc) { - return ws_explicit_site(ws_explicit_negsub_sites, ws_explicit_negsub_n, pc); -} -static uint32_t ws_explicit_vxrange_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static int ws_explicit_vxrange_n = 0; -void gpu_ws_set_vxrange_cull_sites(const uint32_t *sites, int nsites) { - if (nsites < 0) nsites = 0; - if (nsites > WS_EXPLICIT_CULL_SITES_MAX) nsites = WS_EXPLICIT_CULL_SITES_MAX; - ws_explicit_vxrange_n = nsites; - for (int i = 0; i < nsites; i++) - ws_explicit_vxrange_sites[i] = sites[i] & 0x1FFFFFFFu; -} -int psx_ws_is_cull_vxrange_site(uint32_t pc) { - return ws_explicit_site(ws_explicit_vxrange_sites, ws_explicit_vxrange_n, pc); -} -static uint32_t ws_explicit_depth_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static int ws_explicit_depth_n = 0; -void gpu_ws_set_depth_cull_sites(const uint32_t *sites, int nsites) { - if (nsites < 0) nsites = 0; - if (nsites > WS_EXPLICIT_CULL_SITES_MAX) nsites = WS_EXPLICIT_CULL_SITES_MAX; - ws_explicit_depth_n = nsites; - for (int i = 0; i < nsites; i++) - ws_explicit_depth_sites[i] = sites[i] & 0x1FFFFFFFu; -} -int psx_ws_is_cull_depth_site(uint32_t pc) { - return ws_explicit_site(ws_explicit_depth_sites, ws_explicit_depth_n, pc); -} -int32_t psx_ws_depth_bound(int32_t imm) { - if (psx_ws_x_margin() <= 0) return imm; - int64_t numerator = (int64_t)imm * 3 * ws_cfg_num; - int64_t denominator = 4 * ws_cfg_den; - if (denominator <= 0) return imm; - int64_t result = numerator >= 0 - ? (numerator + denominator / 2) / denominator - : -((-numerator + denominator / 2) / denominator); - return (int32_t)result; -} -int psx_ws_is_cull_range_site(uint32_t pc) { - return ws_explicit_site(ws_explicit_range_sites, ws_explicit_range_n, pc); -} -static uint32_t ws_explicit_plane_nx_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static int ws_explicit_plane_nx_n = 0; -void gpu_ws_set_plane_nx_sites(const uint32_t *sites, int nsites) { - if (nsites < 0) nsites = 0; - if (nsites > WS_EXPLICIT_CULL_SITES_MAX) nsites = WS_EXPLICIT_CULL_SITES_MAX; - ws_explicit_plane_nx_n = nsites; - for (int i = 0; i < nsites; i++) - ws_explicit_plane_nx_sites[i] = sites[i] & 0x1FFFFFFFu; -} -int psx_ws_is_cull_plane_nx_site(uint32_t pc) { - return ws_explicit_site(ws_explicit_plane_nx_sites, ws_explicit_plane_nx_n, pc); -} -/* Side frustum-plane normal-X scale ([widescreen.cull] plane_nx_sites). The - * plane half-angle goes as atan(nz/nx), so widening the cone by the aspect - * ratio means scaling nx by the INVERSE factor (4*den)/(3*num): - * atan(nz/(nx*4*den/(3*num))) == atan((3*num)/(4*den)*tan(theta)). Identity - * at 4:3 (margin 0). */ -int32_t psx_ws_plane_nx(int32_t nx) { - if (psx_ws_x_margin() <= 0) return nx; - int64_t numerator = (int64_t)nx * 4 * ws_cfg_den; - int64_t denominator = 3 * ws_cfg_num; - if (denominator <= 0) return nx; - int64_t result = numerator >= 0 - ? (numerator + denominator / 2) / denominator - : -((-numerator + denominator / 2) / denominator); - return (int32_t)result; -} - -static uint32_t ws_explicit_xclip_load_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static int ws_explicit_xclip_load_n = 0; -void gpu_ws_set_xclip_load_sites(const uint32_t *sites, int nsites) { - if (nsites < 0) nsites = 0; - if (nsites > WS_EXPLICIT_CULL_SITES_MAX) nsites = WS_EXPLICIT_CULL_SITES_MAX; - ws_explicit_xclip_load_n = nsites; - for (int i = 0; i < nsites; i++) - ws_explicit_xclip_load_sites[i] = sites[i] & 0x1FFFFFFFu; -} -int psx_ws_is_cull_xclip_load_site(uint32_t pc) { - return ws_explicit_site(ws_explicit_xclip_load_sites, ws_explicit_xclip_load_n, pc); -} -/* Per-primitive X-reject bound ([widescreen.cull] xclip_load_sites). While - * the margins are revealed the reject is disabled (INT32_MAX passes every - * ANDI-masked u16 screen X, including wrapped off-left coords at 655xx); the - * wide-surface scissor clips the overflow. Vanilla loaded value at 4:3. */ -uint32_t psx_ws_xclip_bound(uint32_t vanilla) { - return psx_ws_x_margin() > 0 ? 0x7FFFFFFFu : vanilla; -} - -typedef struct { - uint32_t address; - uint32_t expected; - uint32_t result; -} WsCullKeepSite; -static WsCullKeepSite ws_cull_keep_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static int ws_cull_keep_n = 0; -void gpu_ws_set_cull_keep_sites(const uint32_t *addresses, - const uint32_t *expected, - const uint32_t *results, int nsites) { - if (nsites < 0) nsites = 0; - if (nsites > WS_EXPLICIT_CULL_SITES_MAX) nsites = WS_EXPLICIT_CULL_SITES_MAX; - ws_cull_keep_n = nsites; - for (int i = 0; i < nsites; i++) { - ws_cull_keep_sites[i].address = addresses[i] & 0x1FFFFFFFu; - ws_cull_keep_sites[i].expected = expected[i]; - ws_cull_keep_sites[i].result = results[i] ? 1u : 0u; - } -} -uint32_t psx_ws_cull_keep_result(uint32_t vanilla, uint32_t forced) { - return psx_ws_x_margin() > 0 ? (forced ? 1u : 0u) : vanilla; -} -int psx_ws_cull_keep_site(uint32_t pc, uint32_t instr, uint32_t vanilla, - uint32_t *out) { - const uint32_t phys = pc & 0x1FFFFFFFu; - for (int i = 0; i < ws_cull_keep_n; i++) { - const WsCullKeepSite *site = &ws_cull_keep_sites[i]; - if (site->address != phys || site->expected != instr) continue; - if (out) *out = psx_ws_cull_keep_result(vanilla, site->result); - return 1; - } - return 0; -} - -typedef struct { - uint32_t address; - uint32_t expected; -} WsAngleSite; -static WsAngleSite ws_angle_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static int ws_angle_n = 0; -static uint64_t ws_angle_calls = 0; -static uint64_t ws_angle_43_identity = 0; -static uint32_t ws_angle_max_vanilla = 0; -static uint32_t ws_angle_max_widened = 0; - -void gpu_ws_set_angle_sites(const uint32_t *addresses, - const uint32_t *expected, int nsites) { - if (nsites < 0) nsites = 0; - if (nsites > WS_EXPLICIT_CULL_SITES_MAX) - nsites = WS_EXPLICIT_CULL_SITES_MAX; - ws_angle_n = nsites; - for (int i = 0; i < nsites; i++) { - ws_angle_sites[i].address = addresses[i] & 0x1FFFFFFFu; - ws_angle_sites[i].expected = expected[i]; - } - ws_angle_calls = 0; - ws_angle_43_identity = 0; - ws_angle_max_vanilla = 0; - ws_angle_max_widened = 0; -} - -uint32_t psx_ws_angle_widen(uint32_t vanilla) { - ws_angle_calls++; - const int margin = psx_ws_x_margin(); - if (margin <= 0) { - ws_angle_43_identity++; - return vanilla; - } - const uint32_t widened = - psx_ws_widen_angle_q12(vanilla, margin); - if (vanilla > ws_angle_max_vanilla) - ws_angle_max_vanilla = vanilla; - if (widened > ws_angle_max_widened) - ws_angle_max_widened = widened; - return widened; -} - -/* A diagnostic launcher can isolate the two discovered frustum layers without - * rebuilding: "off" keeps both vanilla, "yaw" widens only portal traversal, - * and the default "full" also widens the later horizontal side planes. */ -static int ws_frustum_mode(void) { - static int mode = -1; - if (mode >= 0) return mode; - const char *value = getenv("PSX_WS_FRUSTUM_MODE"); - if (value && (strcmp(value, "off") == 0 || strcmp(value, "0") == 0)) - mode = 0; - else if (value && (strcmp(value, "yaw") == 0 || strcmp(value, "1") == 0)) - mode = 1; - else - mode = 2; - printf("psxrecomp: widescreen frustum mode %s\n", - mode == 0 ? "off" : (mode == 1 ? "yaw-only" : "full")); - return mode; -} - -int32_t psx_ws_yaw_cone_offset(int32_t vanilla, uint32_t full_turn) { - if (ws_frustum_mode() == 0) return vanilla; - return psx_ws_widen_angle_units( - vanilla, full_turn, psx_ws_x_margin()); -} - -uint32_t psx_ws_horizontal_slt(uint32_t bound, uint32_t horizontal) { - if (ws_frustum_mode() < 2) - return (int32_t)bound < (int32_t)horizontal ? 1u : 0u; - const int32_t scaled_horizontal = psx_ws_scale_horizontal_320( - (int32_t)horizontal, psx_ws_x_margin()); - return (int32_t)bound < scaled_horizontal ? 1u : 0u; -} - -int psx_ws_angle_site(uint32_t pc, uint32_t instr, uint32_t *out) { - const uint32_t phys = pc & 0x1FFFFFFFu; - for (int i = 0; i < ws_angle_n; i++) { - const WsAngleSite *site = &ws_angle_sites[i]; - if (site->address != phys || site->expected != instr) continue; - if (out) - *out = psx_ws_angle_widen( - (uint32_t)(int32_t)(int16_t)(instr & 0xFFFFu)); - return 1; - } - return 0; -} - -typedef struct { - uint32_t address; - uint32_t expected; - uint32_t cosine_threshold; - uint8_t object_reg; - uint8_t x_reg; - uint8_t z_reg; - uint8_t y_reg; - uint8_t queue_guard; - uint64_t calls; - uint64_t identity_43; - uint64_t vanilla_keep; - uint64_t visible_keep; - uint64_t guard_keep; - uint64_t hysteresis_keep; - uint64_t outside_reject; - uint64_t queue_reject; -} WsAspectConeSite; - -typedef struct { - uint32_t site; - uint32_t object; - uint32_t last_seen_frame; - uint8_t active; -} WsAspectConeState; - -#define WS_ASPECT_CONE_STATE_CAP 512 -static WsAspectConeSite ws_aspect_cone_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static int ws_aspect_cone_n = 0; -static uint32_t ws_aspect_cone_forward_addr = 0; -static uint32_t ws_aspect_cone_object_type_offset = 0; -static uint32_t ws_aspect_cone_hysteresis_pixels = 0; -static uint32_t ws_aspect_cone_queue_reserve = 0; -static uint32_t ws_aspect_cone_queue_count_addrs[3]; -static uint32_t ws_aspect_cone_queue_capacities[3]; -static uint32_t ws_aspect_cone_queue_type_masks[3]; -static WsAspectConeState ws_aspect_cone_state[WS_ASPECT_CONE_STATE_CAP]; -static int ws_aspect_cone_wide_latched = 0; -static uint64_t ws_aspect_cone_calls = 0; -static uint64_t ws_aspect_cone_43_identity = 0; -static uint64_t ws_aspect_cone_vanilla_keep = 0; -static uint64_t ws_aspect_cone_visible_keep = 0; -static uint64_t ws_aspect_cone_guard_keep = 0; -static uint64_t ws_aspect_cone_hysteresis_keep = 0; -static uint64_t ws_aspect_cone_outside_reject = 0; -static uint64_t ws_aspect_cone_queue_reject = 0; -static uint32_t ws_aspect_cone_queue_highwater[3]; - -void gpu_ws_set_aspect_cone(const uint32_t *addresses, - const uint32_t *expected, - const uint32_t *cosine_thresholds, - const uint32_t *object_regs, - const uint32_t *x_regs, - const uint32_t *z_regs, - const uint32_t *y_regs, - const uint32_t *queue_guards, - int nsites, - uint32_t forward_addr, - uint32_t object_type_offset, - uint32_t hysteresis_pixels, - uint32_t queue_reserve, - const uint32_t queue_count_addrs[3], - const uint32_t queue_capacities[3], - const uint32_t queue_type_masks[3]) { - if (nsites < 0) nsites = 0; - if (nsites > WS_EXPLICIT_CULL_SITES_MAX) - nsites = WS_EXPLICIT_CULL_SITES_MAX; - ws_aspect_cone_n = nsites; - memset(ws_aspect_cone_sites, 0, sizeof(ws_aspect_cone_sites)); - for (int i = 0; i < nsites; i++) { - ws_aspect_cone_sites[i].address = addresses[i] & 0x1FFFFFFFu; - ws_aspect_cone_sites[i].expected = expected[i]; - ws_aspect_cone_sites[i].cosine_threshold = - cosine_thresholds ? cosine_thresholds[i] : 0; - ws_aspect_cone_sites[i].object_reg = - (uint8_t)(object_regs ? object_regs[i] & 31u : 0u); - ws_aspect_cone_sites[i].x_reg = - (uint8_t)(x_regs ? x_regs[i] & 31u : 0u); - ws_aspect_cone_sites[i].z_reg = - (uint8_t)(z_regs ? z_regs[i] & 31u : 0u); - ws_aspect_cone_sites[i].y_reg = - (uint8_t)(y_regs ? y_regs[i] & 31u : 0u); - ws_aspect_cone_sites[i].queue_guard = - (uint8_t)(!queue_guards || queue_guards[i] != 0); - } - ws_aspect_cone_forward_addr = forward_addr; - ws_aspect_cone_object_type_offset = object_type_offset; - ws_aspect_cone_hysteresis_pixels = hysteresis_pixels; - ws_aspect_cone_queue_reserve = queue_reserve; - for (int i = 0; i < 3; i++) { - ws_aspect_cone_queue_count_addrs[i] = - queue_count_addrs ? queue_count_addrs[i] : 0; - ws_aspect_cone_queue_capacities[i] = - queue_capacities ? queue_capacities[i] : 0; - ws_aspect_cone_queue_type_masks[i] = - queue_type_masks ? queue_type_masks[i] : 0; - } - memset(ws_aspect_cone_state, 0, sizeof(ws_aspect_cone_state)); - ws_aspect_cone_wide_latched = 0; - ws_aspect_cone_calls = 0; - ws_aspect_cone_43_identity = 0; - ws_aspect_cone_vanilla_keep = 0; - ws_aspect_cone_visible_keep = 0; - ws_aspect_cone_guard_keep = 0; - ws_aspect_cone_hysteresis_keep = 0; - ws_aspect_cone_outside_reject = 0; - ws_aspect_cone_queue_reject = 0; - memset(ws_aspect_cone_queue_highwater, 0, - sizeof(ws_aspect_cone_queue_highwater)); -} - -static WsAspectConeSite *ws_aspect_cone_find(uint32_t address, - uint32_t expected) { - const uint32_t phys = address & 0x1FFFFFFFu; - for (int i = 0; i < ws_aspect_cone_n; i++) { - WsAspectConeSite *site = &ws_aspect_cone_sites[i]; - if (site->address == phys && - (expected == 0 || site->expected == expected)) - return site; - } - return NULL; -} - -static WsAspectConeState *ws_aspect_cone_object_state(uint32_t site, - uint32_t object) { - const uint32_t now = (uint32_t)s_frame_count; - uint32_t slot = (((object >> 2) * 2654435761u) ^ - ((site >> 2) * 2246822519u)) & - (WS_ASPECT_CONE_STATE_CAP - 1u); - WsAspectConeState *oldest = NULL; - for (uint32_t probe = 0; probe < 8; probe++) { - WsAspectConeState *entry = - &ws_aspect_cone_state[(slot + probe) & - (WS_ASPECT_CONE_STATE_CAP - 1u)]; - if (entry->site == site && entry->object == object) { - entry->last_seen_frame = now; - return entry; - } - if (entry->object == 0 || now - entry->last_seen_frame > 120u) { - entry->site = site; - entry->object = object; - entry->last_seen_frame = now; - entry->active = 0; - return entry; - } - if (!oldest || - entry->last_seen_frame < oldest->last_seen_frame) - oldest = entry; - } - oldest->site = site; - oldest->object = object; - oldest->last_seen_frame = now; - oldest->active = 0; - return oldest; -} - -static int ws_aspect_cone_queue_has_guard_room(uint32_t object) { - if (object == 0) return 0; - const uint8_t type = - psx_read_byte(object + ws_aspect_cone_object_type_offset); - if (type >= 32) return 1; - const uint32_t bit = 1u << type; - for (int i = 0; i < 3; i++) { - if ((ws_aspect_cone_queue_type_masks[i] & bit) == 0) continue; - const uint32_t cap = ws_aspect_cone_queue_capacities[i]; - const uint32_t reserve = ws_aspect_cone_queue_reserve; - if (cap == 0 || ws_aspect_cone_queue_count_addrs[i] == 0) - return 1; - const uint32_t count = - psx_read_half(ws_aspect_cone_queue_count_addrs[i]); - return count < ((cap > reserve) ? cap - reserve : 0u); - } - return 1; -} - -static void ws_aspect_cone_sample_queues(void) { - /* The game clears each queue at frame start and appends as candidates are - * accepted. Sample throughout the producer loop so the high-water mark - * sees the final occupancy rather than only the first zero count. */ - for (int i = 0; i < 3; i++) { - if (ws_aspect_cone_queue_count_addrs[i] == 0) continue; - const uint32_t count = - psx_read_half(ws_aspect_cone_queue_count_addrs[i]); - if (count > ws_aspect_cone_queue_highwater[i]) - ws_aspect_cone_queue_highwater[i] = count; - } -} - -uint32_t psx_ws_aspect_cone_result(uint32_t site_address, uint32_t vanilla, - uint32_t object, int32_t x, int32_t z, - int32_t y) { - WsAspectConeSite *site = ws_aspect_cone_find(site_address, 0); - if (!site) return vanilla; - - ws_aspect_cone_calls++; - site->calls++; - const int total_margin = psx_ws_x_margin(); - if (total_margin <= 0) { - ws_aspect_cone_43_identity++; - site->identity_43++; - if (ws_aspect_cone_wide_latched) { - memset(ws_aspect_cone_state, 0, sizeof(ws_aspect_cone_state)); - ws_aspect_cone_wide_latched = 0; - } - return vanilla; - } - ws_aspect_cone_wide_latched = 1; - ws_aspect_cone_sample_queues(); - - WsAspectConeState *state = - ws_aspect_cone_object_state(site->address, object); - /* The guarded Tomba sites are signed reject predicates: zero falls - * through to model participation, while one branches to rejection. */ - if (!vanilla) { - state->active = 1; - ws_aspect_cone_vanilla_keep++; - site->vanilla_keep++; - return 0; - } - - const int visible_margin = - (total_margin > ws_cull_guard_pixels) - ? total_margin - ws_cull_guard_pixels : 0; - const int32_t fx = - (int16_t)psx_read_half(ws_aspect_cone_forward_addr + 0u); - const int32_t fz = - (int16_t)psx_read_half(ws_aspect_cone_forward_addr + 2u); - const int32_t fy = - (int16_t)psx_read_half(ws_aspect_cone_forward_addr + 4u); - const uint32_t threshold = site->cosine_threshold; - - const int in_visible = - psx_ws_aspect_cone_contains(x, z, y, fx, fz, fy, threshold, - visible_margin); - const int in_guard = - !in_visible && psx_ws_aspect_cone_contains( - x, z, y, fx, fz, fy, threshold, total_margin); - const int in_hysteresis = - !in_visible && !in_guard && state->active && - psx_ws_aspect_cone_contains( - x, z, y, fx, fz, fy, threshold, - total_margin + (int)ws_aspect_cone_hysteresis_pixels); - - if (!in_visible && !in_guard && !in_hysteresis) { - state->active = 0; - ws_aspect_cone_outside_reject++; - site->outside_reject++; - return 1; - } - if (!in_visible && site->queue_guard && - !ws_aspect_cone_queue_has_guard_room(object)) { - state->active = 0; - ws_aspect_cone_queue_reject++; - site->queue_reject++; - return 1; - } - state->active = 1; - if (in_visible) { - ws_aspect_cone_visible_keep++; - site->visible_keep++; - } else if (in_guard) { - ws_aspect_cone_guard_keep++; - site->guard_keep++; - } else { - ws_aspect_cone_hysteresis_keep++; - site->hysteresis_keep++; - } - return 0; -} - -int psx_ws_aspect_cone_site(CPUState *cpu, uint32_t pc, uint32_t instr, - uint32_t vanilla, uint32_t *out) { - const WsAspectConeSite *site = ws_aspect_cone_find(pc, instr); - if (!site) return 0; - if (out) { - *out = psx_ws_aspect_cone_result( - pc, vanilla, cpu->gpr[site->object_reg], - (int32_t)(int16_t)cpu->gpr[site->x_reg], - (int32_t)(int16_t)cpu->gpr[site->z_reg], - (int32_t)(int16_t)cpu->gpr[site->y_reg]); - } - return 1; -} - -int psx_ws_x_margin(void) { - if (ws_margin_override >= 0) return ws_margin_override; - /* Native-wide: widen the world-space draw cull by the per-side reveal - * (== the centering OFFSET in screen px) so the game SUBMITS the geometry - * that previously fell outside the 4:3 cull window; the wide compositor then - * rasterizes it into the revealed margins. Same recompiler emit sites as the - * squash path ([widescreen.cull]); 0 at 4:3 so the cull stays byte-identical. */ - /* Unlike rendering/presentation, do not wait for game-mode detection here. - * Tomba 2 builds its terrain-cell and actor spawn lists during scene load; - * returning zero until the first 3D frame permanently bakes a 4:3 frustum - * into those lists. */ - if (ws_native_wide_configured()) - return ws_nw_configured_offset() + ws_cull_guard_pixels; - if (!ws_active()) return 0; - return (160 * (ws_xden - ws_xnum) + ws_xnum / 2) / ws_xnum - + ws_cull_guard_pixels; -} - -int psx_ws_activation_margin(void) { - const int margin = psx_ws_x_margin(); - return margin > 0 ? margin + ws_activation_guard_pixels : 0; -} - -int32_t psx_ws_player_x_bound(int32_t vanilla) -{ - const int margin = psx_ws_x_margin(); - if (margin <= 0) return vanilla; - return (int32_t)(((int64_t)vanilla * (320 + 2 * margin)) / 320); -} - -#define WS_SIGNED_BOUND_MAX 64 -static uint32_t ws_signed_bound_addr[WS_SIGNED_BOUND_MAX]; -static uint32_t ws_signed_bound_expected[WS_SIGNED_BOUND_MAX]; -static int ws_signed_bound_count = 0; -void gpu_ws_set_signed_x_bound_sites(const uint32_t *addresses, - const uint32_t *expected, int count) { - if (count < 0) count = 0; - if (count > WS_SIGNED_BOUND_MAX) count = WS_SIGNED_BOUND_MAX; - ws_signed_bound_count = count; - for (int i = 0; i < count; i++) { - ws_signed_bound_addr[i] = addresses[i] & 0x1FFFFFFFu; - ws_signed_bound_expected[i] = expected[i]; - } -} -int psx_ws_is_signed_x_bound_site(uint32_t pc, uint32_t instr) { - const uint32_t phys = pc & 0x1FFFFFFFu; - for (int i = 0; i < ws_signed_bound_count; i++) - if (ws_signed_bound_addr[i] == phys && ws_signed_bound_expected[i] == instr) - return 1; - return 0; -} - -/* ---- Capcom 2D background tile-loop widen ([widescreen.bg2d]) -------------- - * Mega Man X5/X6 use a pure-2D sprite engine that renders only a 4:3 (320px) field - * of view — there is no overscan to "reveal." Its per-layer background renderer - * (FUN_800270d0) draws `count` 16px tile columns × 16 rows from a start tile - * column / start screen-x derived from the camera scroll. To produce a TRUE - * wider FOV we widen that loop so it draws extra columns on BOTH sides of the - * 320 view, filling the 16:9 reveal margins with real adjacent stage. The three - * helpers below are hooked at the renderer's count / start-col / start-x - * instructions by the recompiler (gen-time). They are IDENTITY unless native- - * wide is engaged on a ~320 screen, so 4:3 (and the engine's own 512 hi-res - * mode, which already draws 33 columns) stay byte-identical. - * - * LEFT = per-side reveal in whole 16px tile columns = ceil(nw_offset / 16). The - * loop start moves LEFT columns earlier (tile col -LEFT in the 64-col ring, - * screen-x -LEFT*16) and the count grows by 2*LEFT, so existing columns keep - * their screen positions (stay aligned with objects/player) while new columns - * are prepended left and appended right. The 64-column tile ring (mask 0x3f) is - * far wider than the visible window, so the prepended columns fetch the correct - * already-streamed adjacent tiles. */ -static uint32_t g_bg2d_layer_base = 0x800971F8u; -static uint32_t g_bg2d_ring_base = 0x800A21B8u; -static uint32_t g_bg2d_map_size_addr = 0x800CD338u; -static uint32_t g_bg2d_layer_stride_addr = 0x8008EC10u; -static uint32_t g_bg2d_ring_cols = 64; -static uint32_t g_bg2d_layer_count = 3; -static uint32_t g_bg2d_layer_struct_stride = 0x54; -static uint32_t g_bg2d_packet_cap = 1000; -static int g_bg2d_native_cols = 21; - -void gpu_ws_bg2d_configure(uint32_t layer_base, uint32_t ring_base, - uint32_t map_size_addr, uint32_t layer_stride_addr, - uint32_t ring_cols, uint32_t layer_count, - uint32_t layer_struct_stride, uint32_t packet_cap) { - g_bg2d_layer_base = layer_base; - g_bg2d_ring_base = ring_base; - g_bg2d_map_size_addr = map_size_addr; - g_bg2d_layer_stride_addr = layer_stride_addr; - g_bg2d_ring_cols = ring_cols; - g_bg2d_layer_count = layer_count; - g_bg2d_layer_struct_stride = layer_struct_stride; - g_bg2d_packet_cap = packet_cap; -} - -static int ws_bg2d_left_cols(void) { - if (!ws_native_wide_active()) return 0; - /* Only the ~320 gameplay mode; the engine's 512 hi-res mode (title) draws - * its own 33 columns and centres itself — never double-shift it. */ - if (ws_disp_w() > 384) return 0; - int off = ws_nw_offset(); /* per-side reveal in screen px */ - if (off <= 0) return 0; - return (off + 15) / 16; /* ceil to whole tile columns */ -} -/* Column count: base + both-side reveal. */ -static void mmx6_bg_refill_tick(void); /* defined below (ring-freshness fix) */ -int psx_ws_bg2d_cols(int base) { - g_bg2d_native_cols = base; - mmx6_bg_refill_tick(); - return base + 2 * ws_bg2d_left_cols(); -} -/* Start tile column: refill before the first column is consumed, then begin LEFT - * earlier in the ring. The count hook retains the same tick for layouts that load - * their loop bound before calculating the starting column. */ -int psx_ws_bg2d_startcol(int col, unsigned mask) { - mmx6_bg_refill_tick(); - return (col - ws_bg2d_left_cols()) & (int)mask; -} -/* Start screen-x: LEFT*16 further left. */ -int psx_ws_bg2d_startx(int x) { return x - ws_bg2d_left_cols() * 16; } - -/* Tile-RING STREAMER widen (same [widescreen.bg2d], FUN_800273e4). The renderer - * above now draws LEFT extra columns each side, but the engine's 64-column tile - * ring is only refreshed at its leading edge: the streamer re-streams ONE column - * per side per frame at world-X scrollX-16 (left) and scrollX+16+width (right), - * so the columns the widened renderer reaches are never populated (they show - * empty=black or stale=old tiles, the "stale margins"). Push the streamed left/ - * right edge out by LEFT*16 so the ring stays valid across the widened window as - * the camera scrolls (the incremental streamer fills each newly-leading column). - * Identity (no extra streaming) at 4:3 / 512 hi-res, so the ring is byte-identical - * there. The 64-col ring has ample slack (visible ~21 cols) for ±LEFT more. */ -int psx_ws_bg2d_stream_left(int x) { return x - ws_bg2d_left_cols() * 16; } -int psx_ws_bg2d_stream_right(int x) { return x + ws_bg2d_left_cols() * 16; } -int psx_ws_bg2d_undercap(int counter, int native_cap) { - int cap = ws_bg2d_left_cols() > 0 ? (int)g_bg2d_packet_cap : native_cap; - return counter < cap; -} - -/* NOT MMX6-only, and NOT dead -- see the survey note on the declarations in - * runtime/include/gpu.h. Live generated code in CrashBash, MegaManX5 and Tsumu - * calls these, and seven repos call psx_ws_mmx6_bg_undercap below. The mmx6 in - * the name is historical; the recompiler emits these names for any title with - * a [widescreen.bg2d] block. Renaming means touching code_generator.cpp, which - * rolls the overlay cache tag for every game. */ -int psx_ws_mmx6_bg_cols(int base) { return psx_ws_bg2d_cols(base); } -int psx_ws_mmx6_bg_startcol(int col) { return psx_ws_bg2d_startcol(col, 0x3fu); } -int psx_ws_mmx6_bg_startx(int x) { return psx_ws_bg2d_startx(x); } -int psx_ws_mmx6_bg_stream_left(int x) { return psx_ws_bg2d_stream_left(x); } -int psx_ws_mmx6_bg_stream_right(int x) { return psx_ws_bg2d_stream_right(x); } - -/* Called at entry to MMX6's full tile-ring initializer (FUN_800269F4). The - * independent layers invoke it only when stage/background data is dirty; gate - * by frame because up to three layers initialize together. This is the exact - * point where pixels retained from the old stage cease to be meaningful. */ -void psx_ws_mmx6_bg_stage_init(void) { - static uint32_t last_frame = 0xFFFFFFFFu; - uint32_t frame = (uint32_t)s_frame_count; - if (!ws_clear_reveal || ws_mode != 2 || frame == last_frame) return; - last_frame = frame; - g_mmx6_void_generation++; - ws_clear_all_reveal_margins(); -} - -/* Retired packet-buffer relocation hooks. These symbols remain part of the - * generated-code ABI; their bodies preserve the committed guest-widen path. */ -int psx_ws_mmx6_bg_bufbase(int addr) { - return addr; -} - -int psx_ws_mmx6_bg_undercap(int counter) { - return psx_ws_bg2d_undercap(counter, 1000); -} - -/* ===== MMX6 BG tile-ring freshness fix ([widescreen.bg2d]) ================== * - * The widened renderer (FUN_800270d0) draws LEFT extra tile columns on each side of - * the 320 view, but the engine's streamer (FUN_800273e4 -> FUN_800274a0) only - * refreshes ONE column per side per frame at the leading edge. On scene entry or - * fast horizontal scroll the inner reveal columns therefore hold STALE tiles from a - * previous scroll position (different scrollY) -> real-but-misplaced tiles spilling - * up-and-left (the "staircase"). 4:3 is unaffected (it draws only the 21 columns the - * engine keeps streamed). Fix: before the renderer draws, re-stream EVERY tile column - * of the widened window for all 3 layers, so the render window and the ring-refresh - * window are the same window. This is a faithful clone of FUN_800274a0's inner - * metatile->ring fill, MINUS the scrollX wrap-bookkeeping writes (those drive the - * engine's own incremental streamer; perturbing them would desync its leading edge). - * Off-map columns fill tile 0 (the renderer skips them) = the engine's own void; at a - * horizontal map-loop seam (engine early-return) we skip the column. Gated on - * native-wide + ~320 mode, so 4:3 and the 512 hi-res title stay byte-identical. */ -extern void psx_write_half(uint32_t addr, uint16_t val); - -static void bg2d_clear_column(int layer, int ringcol, int worldY) { - int rows = 0x12; - if (worldY < 0) { worldY = 0; rows = 0x11; } - int wrapY = worldY - (((worldY < 0) ? worldY + 0x1ff : worldY) >> 9) * 0x200; - int ringrow = ((wrapY < 0) ? wrapY + 0xf : wrapY) >> 4; - uint32_t ringbase = g_bg2d_ring_base - + (uint32_t)layer * (g_bg2d_ring_cols * 64u); - for (int i = 0; i < rows; i++) { - uint32_t cell = ringbase - + (uint32_t)((ringcol & (int)(g_bg2d_ring_cols - 1u)) * 2) - + (uint32_t)((ringrow & 0x1f) * (int)(g_bg2d_ring_cols * 2u)); - psx_write_half(cell, 0); - ringrow = (ringrow + 1) & 0x1f; - } -} - -/* Faithful clone of FUN_800274a0's inner fill: stream one tile column at guest pixel - * (worldX, worldY) of `layer` into the 64x32 ring. Compares mirror the engine's sltu - * (UNSIGNED). When write!=0 the tiles are written to the ring; when write==0 they are - * compared against the live ring (validation), counting *cmp_total / *cmp_bad. Returns - * 1 if the column was streamed, 0 if skipped (map-loop seam early-return). */ -static int bg2d_fill_column(int layer, int worldX, int worldY, int scrollX, int write, - int *cmp_total, int *cmp_bad) { - /* The guest column streamer returns immediately for negative world X. Do - * not wrap it into the far edge of the map, which produces repeated blocks - * in the left reveal near a level boundary. */ - if (worldX < 0) return 0; - uint32_t lbase = g_bg2d_layer_base + (uint32_t)layer * g_bg2d_layer_struct_stride; - int rows = 0x12; - if (worldY < 0) { worldY = 0; rows = 0x11; } - int32_t metaCol = ((worldX < 0) ? worldX + 0xff : worldX) >> 8; /* t1 */ - int32_t metaRow = ((worldY < 0) ? worldY + 0xff : worldY) >> 8; /* t4 */ - int worldPeriod = (int)g_bg2d_ring_cols * 16; - int worldShift = 0; - for (int n = worldPeriod; n > 1; n >>= 1) worldShift++; - int wrapX = worldX - - (((worldX < 0) ? worldX + worldPeriod - 1 : worldX) >> worldShift) - * worldPeriod; - int colsh = ((wrapX < 0) ? wrapX + 0xf : wrapX) >> 4; /* t7 = wrapX>>4 */ - int ringcol = colsh & (int)(g_bg2d_ring_cols - 1u); - int tcolInMeta = colsh & 0xf; /* s0 = t7 & 0xf */ - int wrapY = worldY - (((worldY < 0) ? worldY + 0x1ff : worldY) >> 9) * 0x200; - int ringrow = ((wrapY < 0) ? wrapY + 0xf : wrapY) >> 4; /* a2 */ - int trowInMeta = ringrow & 0xf; /* t3 */ - - int mapLeft = psx_read_byte(lbase + 0x4d); - int mapRight = psx_read_byte(lbase + 0x4e); - /* C integer division toward zero makes worldX -255..-1 resolve to metatile - * column 0 below. The native 4:3 streamer never requests those coordinates, - * but the widened left window does at a stage starting on mapLeft. Treat - * them as finite-map void before the engine's loop/wrap bookkeeping. */ - if (worldX < mapLeft * 0x100) - return 2; - if ((uint32_t)metaCol < (uint32_t)mapLeft) { - metaCol = metaCol + 1 + (mapRight - mapLeft); - if (scrollX <= (mapLeft - 1) * 0x100) { - /* The native renderer never sees this column, so the engine leaves - * its ring slot alone. Report finite-map void to the DMA-start - * cleanup without mutating ring slots canonical draws can alias. */ - return 2; /* finite-map void, not merely a normal streamed column */ - } - } - if ((uint32_t)mapRight < (uint32_t)metaCol) { - metaCol = (metaCol - 1 - mapRight) + mapLeft; - if ((mapRight + 1) * 0x100 <= scrollX) { - return 2; - } - } - int mapW = psx_read_byte(g_bg2d_map_size_addr); - int mapH = psx_read_byte(g_bg2d_map_size_addr + 1u); - uint32_t mapBase = psx_read_word(0x1F800004u); - uint32_t metaBase = psx_read_word(0x1F800008u); - if (mapBase == 0 || metaBase == 0 || mapW <= 0 || mapH <= 0) return 0; - int layerStride = (uint16_t)psx_read_half(g_bg2d_layer_stride_addr); - - int metaIdx = 0; - if ((uint32_t)metaCol < (uint32_t)mapW && (uint32_t)metaRow < (uint32_t)mapH) - metaIdx = psx_read_byte(mapBase + (uint32_t)(layer * layerStride + mapW * metaRow + metaCol)); - - uint32_t ringbase = g_bg2d_ring_base - + (uint32_t)layer * (g_bg2d_ring_cols * 64u); - for (int i = 0; i < rows; i++) { - uint32_t cell = ringbase + (uint32_t)(ringcol * 2) - + (uint32_t)((ringrow & 0x1f) * (int)(g_bg2d_ring_cols * 2u)); - uint16_t tile = psx_read_half(metaBase + (uint32_t)metaIdx * 0x200u - + (uint32_t)(trowInMeta * 0x20) + (uint32_t)(tcolInMeta * 2)); - if (write) { - psx_write_half(cell, tile); - } else if (cmp_total) { - (*cmp_total)++; - if (psx_read_half(cell) != tile && cmp_bad) (*cmp_bad)++; - } - ringrow = (ringrow + 1) & 0x1f; - if (++trowInMeta == 0x10) { - trowInMeta = 0; - metaRow++; - metaIdx = 0; - if ((uint32_t)metaCol < (uint32_t)mapW && (uint32_t)metaRow < (uint32_t)mapH) - metaIdx = psx_read_byte(mapBase + (uint32_t)(layer * layerStride + mapW * metaRow + metaCol)); - } - } - return 1; -} - -static int g_mmx6_freshfix = 1; /* re-stream the widened window each frame (the fix) */ -static long g_mmx6_refill_cols = 0; /* columns streamed in the last refill (diag) */ -static uint32_t g_mmx6_refill_frame = 0xFFFFFFFFu; /* frame of the last refill (once/frame) */ -void gpu_ws_mmx6_set_freshfix(int on) { g_mmx6_freshfix = on ? 1 : 0; } -int gpu_ws_mmx6_freshfix_get(void) { return g_mmx6_freshfix; } -long gpu_ws_mmx6_refill_cols(void) { return g_mmx6_refill_cols; } - -/* Re-stream every tile column of the widened window for every BG layer. Independent - * layers use their own scroll; parent-linked layers use the same combined scroll - * that the renderer uses to index their ring. The guest's incremental streamer - * skips linked layers because their native 4:3 window is prefilled, but the newly - * revealed columns still need to be populated here. worldY = scrollY - 0x10 mirrors - * the driver. ci spans the renderer's widened span [-LEFT, 20+LEFT]. - * No-op at 4:3 / 512 hi-res (left==0). Idempotent over the native columns the engine - * already streamed correctly, so re-streaming them is harmless. */ -void psx_ws_mmx6_bg_refill_all(void) { - if (!g_mmx6_freshfix) return; - if (!ws_native_wide_active() || ws_disp_w() > 384) return; - int left = ws_bg2d_left_cols(); - if (left <= 0) return; - g_mmx6_refill_cols = 0; - for (uint32_t layer = 0; layer < g_bg2d_layer_count; layer++) { - uint32_t lbase = g_bg2d_layer_base + layer * g_bg2d_layer_struct_stride; - int sx = (int16_t)psx_read_half(lbase + 0xa); - int sy = (int16_t)psx_read_half(lbase + 0xe); - int8_t parent = (int8_t)psx_read_byte(lbase + 0x52); - if (parent >= 0 && (uint32_t)(uint8_t)parent < g_bg2d_layer_count) { - uint32_t pbase = g_bg2d_layer_base - + (uint32_t)(uint8_t)parent * g_bg2d_layer_struct_stride; - sx += (int16_t)psx_read_half(pbase + 0xa); - sy += (int16_t)psx_read_half(pbase + 0xe); - } - int sxr = sx; - if (sxr < 0) sxr += 0xf; - int start_col = sxr >> 4; - for (int ci = -left; ci < g_bg2d_native_cols + left; ci++) { - int world_x = sx + ci * 16; - if (world_x < 0) { - bg2d_clear_column((int)layer, start_col + ci, sy - 0x10); - continue; - } - if (bg2d_fill_column((int)layer, world_x, sy - 0x10, - sx, 1, NULL, NULL)) - g_mmx6_refill_cols++; - } - } -} - -/* Trigger the once-per-frame widened-window refill. Both renderer setup hooks call - * this so differing instruction order between games still refreshes the ring before - * the first widened column is consumed. */ -static void mmx6_bg_refill_tick(void) { - uint32_t f = (uint32_t)s_frame_count; - if (f == g_mmx6_refill_frame) return; - g_mmx6_refill_frame = f; - psx_ws_mmx6_bg_refill_all(); -} - -/* Validation harness (debug mmx6_freshfix {validate:1}): for the NATIVE 21 columns — - * which the engine streams correctly — run the clone in compare-only mode against the - * live ring. Proves the clone is byte-exact before the refill (which overwrites the - * ring) is trusted. Returns total cells compared; *bad = mismatches. */ -int gpu_ws_mmx6_validate(int *bad_out) { - int total = 0, bad = 0; - for (uint32_t layer = 0; layer < g_bg2d_layer_count; layer++) { - uint32_t lbase = g_bg2d_layer_base + layer * g_bg2d_layer_struct_stride; - int sx = (int16_t)psx_read_half(lbase + 0xa); - int sy = (int16_t)psx_read_half(lbase + 0xe); - int8_t parent = (int8_t)psx_read_byte(lbase + 0x52); - if (parent >= 0 && (uint32_t)(uint8_t)parent < g_bg2d_layer_count) { - uint32_t pbase = g_bg2d_layer_base - + (uint32_t)(uint8_t)parent * g_bg2d_layer_struct_stride; - sx += (int16_t)psx_read_half(pbase + 0xa); - sy += (int16_t)psx_read_half(pbase + 0xe); - } - for (int ci = 0; ci < g_bg2d_native_cols; ci++) - bg2d_fill_column((int)layer, sx + ci * 16, sy - 0x10, - sx, 0, &total, &bad); - } - if (bad_out) *bad_out = bad; - return total; -} - -/* Shared render-funnel screen-X cull widening ([widescreen.cull] auto_screen_x), - * called identically by the gcc emit and the interpreter so every - * overlay execution path widens the same way. sx = the GTE screen-X as loaded by - * the guest's lhu (low 16 bits significant); imm = the original bound (0x140 / - * 0x141). Returns the sltiu verdict (1 = on-screen/keep). Sign-extends sx and - * shifts by +margin so BOTH 16:9 margins pass; at 4:3 margin==0 so it reduces - * bit-for-bit to the vanilla `(uint16)sx < imm`. */ -int psx_ws_cull_sltiu(uint32_t sx, uint32_t imm) { - int m = psx_ws_x_margin(); - return ((uint32_t)((int32_t)(int16_t)(uint16_t)sx + m) - < (uint32_t)((int32_t)imm + 2 * m)) ? 1 : 0; -} - -/* Signed right-edge widen for the min/max funnel idiom (`slti v, minSX, W`): - * the paired LEFT edge is a separate bltz (psx_ws_cull_bltz below), so this - * bound moves out by ONE margin only. Operand is an already sign-extended / - * computed 32-bit screen X. Identity at margin 0 (4:3). */ -int psx_ws_cull_slti(uint32_t sx, uint32_t imm) { - return ((int32_t)sx < (int32_t)imm + psx_ws_x_margin()) ? 1 : 0; -} - -/* Signed fixed lower-bound widen (`slti v, x, -W`): move the reject edge left - * by one live reveal margin. The encoded immediate must be sign-extended. */ -int psx_ws_cull_slti_lower(uint32_t sx, uint32_t imm) { - int32_t bound = (int32_t)(int16_t)(uint16_t)imm; - return ((int32_t)sx < bound - psx_ws_x_margin()) ? 1 : 0; -} - -/* Manual CPU-projected screen-window constants. These complement the compare - * helpers above for games which clamp and queue signed [left,right] spans in - * ordinary MIPS code. All three are exact identities when margin==0. */ -int32_t psx_ws_cull_lower_value(int32_t vanilla) { - return vanilla - psx_ws_x_margin(); -} -int32_t psx_ws_cull_upper_value(int32_t vanilla) { - return vanilla + psx_ws_x_margin(); -} -uint32_t psx_ws_cull_span_value(uint32_t vanilla) { - int32_t margin = psx_ws_x_margin(); - int32_t left = (int32_t)(int16_t)(uint16_t)(vanilla >> 16); - uint32_t right = vanilla & 0xFFFFu; - return ((uint32_t)(uint16_t)(left - margin) << 16) | - (uint32_t)(uint16_t)((int32_t)right + margin); -} - -/* Signed left-edge widen for the funnel's `bltz maxSX, reject`: reject only - * when the prim ends left of the REVEALED edge (maxSX < -margin). Returns the - * branch predicate. Identity at margin 0 (4:3). */ -int psx_ws_cull_bltz(uint32_t v) { - return ((int32_t)v < -psx_ws_x_margin()) ? 1 : 0; -} -int psx_ws_cull_vxrange(uint32_t x, uint32_t imm) { - int32_t margin = psx_ws_x_margin(); - uint32_t bound = (uint32_t)(int32_t)(int16_t)(uint16_t)imm; - return (((x + (uint32_t)margin) & 0xFFFFu) < - (bound + 2u * (uint32_t)margin)) ? 1 : 0; -} - -/* ---- Cull signature configuration ([widescreen.cull] screen_w_imms / - * screen_h_imms). The width/height immediates are per-game (Tomba: 0x140/0x141 - * + 0xE0/0xF1 on a 320 display; Ape Escape: 0x181 + 0xF1 on 368). Defaults - * keep the original Tomba signature so existing configs are unchanged. The - * sets are consulted by the shared detector on every backend (interp; - * the recompiler reads the same config at gen time). */ -static uint32_t ws_cull_w_imms[8] = { 0x140, 0x141 }; -static int ws_cull_w_n = 2; -static uint32_t ws_cull_h_imms[8] = { 0xE0, 0xF1 }; -static int ws_cull_h_n = 2; -void gpu_ws_set_cull_imms(const uint32_t *w, int nw, const uint32_t *h, int nh) { - if (w && nw > 0) { - if (nw > 8) nw = 8; - for (int i = 0; i < nw; i++) ws_cull_w_imms[i] = w[i]; - ws_cull_w_n = nw; - } - if (h && nh > 0) { - if (nh > 8) nh = 8; - for (int i = 0; i < nh; i++) ws_cull_h_imms[i] = h[i]; - ws_cull_h_n = nh; - } -} -int psx_ws_is_cull_w_imm(uint32_t imm) { - return psx_ws_cull_imm_in(imm, ws_cull_w_imms, ws_cull_w_n); -} - -/* ---- Runtime gates for the pattern-scanned widescreen hooks. The interp - * derives widen sites by scanning live code; that derivation must honor - * the SAME per-game [widescreen.cull] opt-ins the recompiler emit does. These - * default OFF: a title that never opted in must never have its code - * pattern-scanned and rewritten (an ungated backdrop false positive rewrites a - * live GPR = wild-jump fatal — the exact class this gate closes). Set from - * game.toml at startup (main.cpp). */ -static int ws_auto_cull_on_cfg = 0; -static int ws_auto_backdrop_on_cfg = 0; -void gpu_ws_set_auto_hooks(int cull_on, int backdrop_on) { - ws_auto_cull_on_cfg = cull_on ? 1 : 0; - ws_auto_backdrop_on_cfg = backdrop_on ? 1 : 0; -} -int psx_ws_auto_cull_on(void) { return ws_auto_cull_on_cfg; } - -/* Detect the GTE screen-extent trivial-reject signature in a run of - * instruction words: at least one width compare AND one height compare - * (slti or sltiu, immediates from the configured sets). Lets the - * interpreter gate the cull-widening to real render funnels (a lone width - * compare elsewhere must stay vanilla). Same shared scan the recompiler's - * func_has_screen_extent_cull uses (ws_cull_detect.h). */ -int psx_ws_func_has_screen_cull(const uint32_t *words, int n) { - return psx_ws_cull_scan(words, n, ws_cull_w_imms, ws_cull_w_n, - ws_cull_h_imms, ws_cull_h_n); -} - -/* Classify words[idx] as an X left-edge reject bltz (shared structural - * detector, runtime imm sets). Caller qualifies the window first. */ -int psx_ws_cull_bltz_at(const uint32_t *words, int n, int idx) { - return psx_ws_cull_bltz_here(words, n, idx, ws_cull_w_imms, ws_cull_w_n); -} - -/* Widescreen backdrop screen-X correction ([widescreen.backdrop] x_sites). - * The parallax 2D backdrop layer (ocean/cloud/mountain/grass — overlay actor - * handlers e.g. 0x801216BC) computes its screen-X in pure integer math - * (screenX = (worldX - camX) >> parallax) and NEVER goes through the GTE, so - * the GTE X-squash (gte_set_display_aspect) that gives 3D the wider 16:9 FOV - * does not touch it. The recompiler emits this on each handler's final - * screenX store so the backdrop is squashed by the SAME factor around the - * screen centre: a far piece whose 4:3 screenX sat past the 320px edge (and - * was GPU-clipped → the blue void / half-rectangles at the edges) is pulled - * in to cover the revealed FOV. Identity at 4:3 / boot / FMV / full-2D (the - * exact ws_active() predicate the GTE squash uses), so one build serves both. - * In native-wide mode there is nothing to squash (the 4:3 frame is presented - * with side reveal instead), so the same sites are STRETCHED about the screen - * centre by (disp_w + nw_extra) / disp_w: a 4:3-authored backdrop then covers - * the widened frame instead of leaving unpainted margins (Xenogears battle - * mountain panels — pre-calculated POLY_FT4 screen coords stored by main-EXE - * `sh` sites, wtrace-evidenced). - * x is the int16 screenX the handler was about to store. */ -int psx_ws_backdrop_x(int x) { - if (ws_native_wide_active()) { - int32_t W = (int32_t)ws_disp_w(); - int32_t extra = ws_nw_extra(); - int32_t cx = W / 2; - int32_t d = (int16_t)x - cx; - return (int)(cx + (d * (W + extra) + (d >= 0 ? W / 2 : -W / 2)) / W); - } - if (!ws_active()) return (int16_t)x; - int32_t cx = ws_disp_w() / 2; /* screen centre (=160 @ 320) */ - return ws_scale_about((int16_t)x, cx); -} - -/* Classic-wide counterpart for engines which calculate perspective screen X - * in ordinary CPU code instead of the GTE. Exact recompiler sites call this - * immediately after adding the projection centre. Squashing here keeps the - * game's original non-negative 0..display-width portal spans and clip logic - * valid; the classic present stretch restores native proportions afterward. - * Native-wide and 4:3 are exact identities. Use 64-bit intermediates because - * a near-plane projection may be far outside the eventual screen clamp. */ -int psx_ws_project_x(int x) { - if (ws_mode != 1 || !ws_configured()) return x; - int64_t cx = (int64_t)ws_disp_w() / 2; - int64_t d = (int64_t)(int32_t)x - cx; - int64_t n = d * (int64_t)ws_xnum; - n += d >= 0 ? ws_xden / 2 : -ws_xden / 2; - return (int32_t)(cx + n / ws_xden); -} - -/* psx_ws_project_x for a point with a 16.16 fraction, without its rounding. */ -int32_t psx_ws_project_x16(int x, int32_t fraction16) { - const int64_t exact = (int64_t)x * 65536 + fraction16; - if (ws_mode != 1 || !ws_configured()) return (int32_t)exact; - const int64_t cx = ((int64_t)ws_disp_w() / 2) * 65536; - return (int32_t)(cx + (exact - cx) * ws_xnum / ws_xden); -} - -/* Backdrop PRELOAD predicate + value substitution ([widescreen.cull] - * auto_backdrop). The recompiler/interp detect each scrolling-backdrop - * column-window generator (see ws_backdrop_detect.h) and route its window START - * and END bounds through psx_ws_backdrop_value(). When native-wide is engaged we - * WIDEN the camera-tracked window by the 16:9 reveal: the START (left) bound - * moves left by `margin`, the END (right) bound moves right by `margin`, where - * margin is the per-side reveal in COLUMNS. The window of window_cols columns - * spans (at least) the full display width, so reveal_cols = window_cols * - * reveal_px / display_px = window_cols * nw_offset / disp_w (+slack). This draws - * ONLY the now-visible columns (no whole-row overdraw, no fixed cap), and the - * generator's own low/high clamps still bound it at the level edges. Gated on - * native-wide only (NOT squash: that path uses psx_ws_backdrop_x()). Returns - * `orig` unchanged at 4:3 / squash / boot / FMV, so 4:3 stays byte-identical. */ -int psx_ws_backdrop_preload(void) { - return ws_auto_backdrop_on_cfg && ws_native_wide_active(); -} - -/* Live-tunable widen amount (set via the `ws_backdrop_margin` debug command): - * <0 => WHOLE-ROW preload: START->0, END->extent-1 (max generous) - * 0 => identity: no widening, native-wide still on (A/B the effect) - * >0 => widen the camera-tracked window by N columns each side (bounded) - * Default is a safe-but-generous bounded value; dialed live while debugging so - * the widen strategy can be tuned without a rebuild. */ -int g_ws_bd_margin = 0; /* column preload proven irrelevant to the void; default off */ -/* Set to 1 by the interpreter around its psx_ws_backdrop_value call so the value - * function does NOT also ring-note: the interp records a richer entry (live - * extent / camera / DL count). Native cache-DLL calls leave it 0, so the value - * function notes them itself -- that is how the ring sees native execution. */ -int g_ws_bd_from_interp = 0; - -uint32_t psx_ws_backdrop_value(uint32_t orig, int is_end, int window_cols) { - if (!psx_ws_backdrop_preload()) return orig; /* 4:3 -> byte-identical */ - if (window_cols < 0) window_cols = -window_cols; - int m = g_ws_bd_margin; - int from_interp = g_ws_bd_from_interp; - g_ws_bd_from_interp = 0; /* consume one-shot flag */ - uint32_t finalv; - if (m < 0) { - /* Whole-row: the generator's shared clamps (START<0 -> 0; END>=extent -> - * extent-1) pin the loop to [0, extent-1]. Bounded because `extent` is a - * byte and the DL count is a byte (row <= 255 cols). */ - enum { WS_BD_END_SENTINEL = 0x7FFF }; - finalv = is_end ? (uint32_t)WS_BD_END_SENTINEL : 0u; - } else if (m == 0) { - finalv = orig; /* effect off (still recorded) */ - } else { - finalv = is_end ? (orig + (uint32_t)m) : (orig - (uint32_t)m); - } - /* Single chokepoint for every path (interp hook, native cache-DLL via the - * overlay callback). The interp records its own richer entry, so skip - * here when it set the flag; native-DLL calls (flag 0) are recorded here with - * pc/extent/camera = 0 -- which is how the ring sees native execution. */ - if (!from_interp) - psx_ws_backdrop_ring_note(0u, is_end ? 2 /*WS_BD_END*/ : 1 /*WS_BD_START*/, - window_cols, orig, finalv, 0, 0, 0, 0u, 0u); - return finalv; -} - -/* ---- auto_backdrop diagnostic ring (always-on; see BACKDROP_PRELOAD.md) ----- - * Every backdrop-window rewrite (interp or native) is recorded here so the live - * tile-row extent, the camera-tracked bounds, and exactly WHICH of a scene's - * windows fire can be read back via the `ws_backdrop_ring` debug command -- - * query the ring for the window of interest, never pause/step. The recorder is - * fed by the interpreter (which has the live CPU state: s7=extent, the DL count, - * and the scratchpad camera-X) at the rewrite site. */ -typedef struct { - uint32_t frame, pc, orig, finalv, base, dl; - int kind, wcols, extent, camx, count; -} WsBdRingEnt; -#define WS_BD_RING_CAP 512 -static WsBdRingEnt s_bd_ring[WS_BD_RING_CAP]; -static uint32_t s_bd_ring_seq = 0; - -void psx_ws_backdrop_ring_note(uint32_t pc, int kind, int wcols, uint32_t orig, - uint32_t finalv, int extent, int camx, int count, - uint32_t base, uint32_t dl) { - WsBdRingEnt *e = &s_bd_ring[s_bd_ring_seq & (WS_BD_RING_CAP - 1)]; - e->frame = (uint32_t)s_frame_count; e->pc = pc; e->kind = kind; e->wcols = wcols; - e->orig = orig; e->finalv = finalv; e->extent = extent; e->camx = camx; e->count = count; - e->base = base; e->dl = dl; - s_bd_ring_seq++; -} - -int psx_ws_backdrop_ring_json(char *buf, int cap) { - int n = (int)(s_bd_ring_seq < WS_BD_RING_CAP ? s_bd_ring_seq : WS_BD_RING_CAP); - uint32_t start = s_bd_ring_seq - (uint32_t)n; - int off = snprintf(buf, (size_t)cap, "\"seq\":%u,\"n\":%d,\"ents\":[", s_bd_ring_seq, n); - for (int i = 0; i < n && off < cap - 200; i++) { - WsBdRingEnt *e = &s_bd_ring[(start + (uint32_t)i) & (WS_BD_RING_CAP - 1)]; - off += snprintf(buf + off, (size_t)(cap - off), - "%s{\"f\":%u,\"pc\":\"%08x\",\"k\":%d,\"wc\":%d,\"o\":%d,\"fin\":%d,\"ext\":%d,\"cam\":%d,\"cnt\":%d,\"base\":\"%08x\",\"dl\":\"%08x\"}", - i ? "," : "", e->frame, e->pc, e->kind, e->wcols, - (int)(int16_t)e->orig, (int)(int16_t)e->finalv, e->extent, e->camx, e->count, - e->base, e->dl); - } - off += snprintf(buf + off, (size_t)(cap - off), "]"); - return off; -} - -/* Backdrop store-site registry. The [widescreen.backdrop] x_sites are emitted - * into native cache-DLL code by the recompiler, but overlay code very often - * runs INTERPRETED (no DLL loaded), where the emit can't reach. So the runtime - * also registers the same site PCs here and the dirty-RAM interpreter applies - * psx_ws_backdrop_x() at those `sh` PCs — same transform, both paths. Tiny set; - * a linear scan per matching SH is negligible. */ -#define WS_BACKDROP_SITES_MAX 16 -static uint32_t ws_backdrop_sites[WS_BACKDROP_SITES_MAX]; -static int ws_backdrop_site_n = 0; - -void psx_ws_set_backdrop_sites(const uint32_t* pcs, int n) { - ws_backdrop_site_n = 0; - if (!pcs) return; - for (int i = 0; i < n && ws_backdrop_site_n < WS_BACKDROP_SITES_MAX; i++) - ws_backdrop_sites[ws_backdrop_site_n++] = pcs[i] & 0x1FFFFFFFu; -} - -int psx_ws_is_backdrop_site(uint32_t pc) { - if (!ws_backdrop_site_n) return 0; - pc &= 0x1FFFFFFFu; - for (int i = 0; i < ws_backdrop_site_n; i++) - if (ws_backdrop_sites[i] == pc) return 1; - return 0; -} - -/* Snapshot live widescreen state for the TCP gpu_state diagnostic. Mirrors - * the predicates above so a probe can see, mid-gameplay, whether the squash - * and the cull-margin are actually engaged (8C). */ -void gpu_ws_get_debug(GpuWsDebug* out) { - if (!out) return; - out->configured = ws_configured(); - out->active = ws_active(); - out->game_mode = ws_game_mode(); - out->present_native_43 = gpu_ws_present_native_43(); - out->x_margin = psx_ws_x_margin(); - out->activation_margin = psx_ws_activation_margin(); - out->xnum = ws_xnum; - out->xden = ws_xden; - out->mode = ws_mode; - out->nw_extra = ws_nw_extra(); - out->cur_frame = s_frame_count; - out->last_tag_frame = ws_last_tag_stamp; - out->last_3d_frame = ws_last_3d_stamp; - out->gte_verts = ws_gte_prev_verts; - out->last_world3d_frame = ws_sust_world3d_stamp; - out->ovh_prims = ws_ovh_prev; - out->last_ovh_frame = ws_sust_ovh_stamp; - out->auto_ui_squash = ws_auto_ui_squash; - out->auto_ui_dense = ws_auto_ui_dense; - out->auto_ui_ot_rank = - ws_ui_prepass_rank != 0xFFFFu ? ws_ui_prepass_rank : UINT32_MAX; - out->auto_ui_candidates = ws_auto_ui_candidate_count; - out->auto_ui_transforms = ws_auto_ui_transform_count; - out->aspect_cone_calls = ws_aspect_cone_calls; - out->aspect_cone_43_identity = ws_aspect_cone_43_identity; - out->aspect_cone_vanilla_keep = ws_aspect_cone_vanilla_keep; - out->aspect_cone_visible_keep = ws_aspect_cone_visible_keep; - out->aspect_cone_guard_keep = ws_aspect_cone_guard_keep; - out->aspect_cone_hysteresis_keep = - ws_aspect_cone_hysteresis_keep; - out->aspect_cone_outside_reject = - ws_aspect_cone_outside_reject; - out->aspect_cone_queue_reject = ws_aspect_cone_queue_reject; - for (int i = 0; i < 3; i++) - out->aspect_cone_queue_highwater[i] = - ws_aspect_cone_queue_highwater[i]; - out->angle_calls = ws_angle_calls; - out->angle_43_identity = ws_angle_43_identity; - out->angle_max_vanilla = ws_angle_max_vanilla; - out->angle_max_widened = ws_angle_max_widened; -} - -int gpu_ws_get_aspect_cone_site_debug( - uint32_t address, GpuWsAspectConeSiteDebug* out) { - WsAspectConeSite *site = ws_aspect_cone_find(address, 0); - if (!site || !out) return 0; - out->address = site->address | 0x80000000u; - out->calls = site->calls; - out->identity_43 = site->identity_43; - out->vanilla_keep = site->vanilla_keep; - out->visible_keep = site->visible_keep; - out->guard_keep = site->guard_keep; - out->hysteresis_keep = site->hysteresis_keep; - out->outside_reject = site->outside_reject; - out->queue_reject = site->queue_reject; - return 1; -} - -void gpu_ws_configure(int aspect_num, int aspect_den, - uint32_t sprite_anchor_addr, int hud_sprt_squash, int mode) { - ws_cfg_num = aspect_num > 0 ? aspect_num : 4; - ws_cfg_den = aspect_den > 0 ? aspect_den : 3; - ws_mode = mode; - if (mode == 1) { - /* Squash factor = (4*den)/(3*num) — the same factor the GTE applies. */ - int32_t n = 4 * ws_cfg_den, d = 3 * ws_cfg_num; - int32_t a = n, b = d; - while (b) { int32_t t = a % b; a = b; b = t; } - ws_xnum = n / a; - ws_xden = d / a; - } else { - /* Native-wide (2) and off (0): the GTE is NOT squashed. */ - ws_xnum = ws_xden = 1; - } - ws_anchor_addr = sprite_anchor_addr; - ws_hud_sprt = hud_sprt_squash; - /* Adaptive view can change mode/extent without the guest reissuing E3/E4 - * immediately. Keep the active mirror target and its scissor in lockstep - * with the new live aspect instead of waiting for another draw-env packet. */ - ws_nw_sync_target(); -} - -static void ws_tag_primitive(uint32_t primitive_addr, int32_t anchor_x, - uint32_t signature, int content_validated) { - uint32_t key = psx_mod_gpu_dma_resolve_address(primitive_addr); - if (!key) return; - uint32_t now = (uint32_t)s_frame_count; - uint32_t idx = (key >> 2) & (WS_TAG_BUCKETS - 1); - uint32_t victim = idx; - for (int i = 0; i < WS_TAG_PROBES; i++) { - uint32_t j = (idx + i) & (WS_TAG_BUCKETS - 1); - WsTag *t = &ws_tags[j]; - if (t->key == key || t->key == 0) { victim = j; break; } - if (now - t->stamp > 2) victim = j; /* stale — reusable */ - } - ws_tags[victim].key = key; - ws_tags[victim].stamp = now; - ws_tags[victim].anchor_x = anchor_x; - ws_tags[victim].signature = signature; - ws_tags[victim].content_validated = content_validated != 0; - ws_last_tag_stamp = now; -} - -/* Explicit provenance seam for a title-specific, reviewed world-billboard - * builder. Unlike sprite_tag_funcs this does not depend on a fixed scratchpad - * anchor: the caller has already identified both the completed P_TAG packet and - * its projected centre. Restrict it to the classic squash path so a tag cannot - * affect native-wide backdrop classification or a native-4:3 presentation. */ -void gpu_ws_tag_primitive(CPUState *cpu, uint32_t primitive_addr, - int32_t anchor_x) { - if (!ws_active() || !cpu || !cpu->read_word) return; - uint32_t words[9]; - for (int i = 0; i < 9; i++) - words[i] = cpu->read_word(primitive_addr + 4u + (uint32_t)i * 4u); - ws_tag_primitive(primitive_addr, anchor_x, - psx_ws_ft4_signature_words(words), 1); -} - -#define TEMPORAL_SPRITE_BUCKETS 4096 -static WsTag temporal_sprites[TEMPORAL_SPRITE_BUCKETS]; - -static WsTag *temporal_sprite_slot(uint32_t key, int claim) { - const uint32_t now = (uint32_t)s_frame_count; - const uint32_t first = (key >> 2) & (TEMPORAL_SPRITE_BUCKETS - 1); - WsTag *stale = NULL; - for (int i = 0; i < WS_TAG_PROBES; i++) { - WsTag *t = &temporal_sprites[(first + (uint32_t)i) & (TEMPORAL_SPRITE_BUCKETS - 1)]; - if (t->key == key) return t; - if (!stale && (t->key == 0 || now - t->stamp > 2)) stale = t; - } - return claim ? stale : NULL; -} - -void gpu_temporal_note_sprite(CPUState *cpu, uint32_t primitive_addr, - int32_t depth) { - if (!cpu || !cpu->read_word || depth <= 0) return; - const uint32_t key = psx_mod_gpu_dma_resolve_address(primitive_addr); - WsTag *t = key ? temporal_sprite_slot(key, 1) : NULL; - uint32_t words[9]; - if (!t) return; - for (int i = 0; i < 9; i++) - words[i] = cpu->read_word(primitive_addr + 4u + (uint32_t)i * 4u); - t->key = key; - t->stamp = (uint32_t)s_frame_count; - t->anchor_x = depth; - t->signature = psx_ws_ft4_signature_words(words); - t->content_validated = 1; - t->placed = t->sized = t->shadow = t->whole = 0; -} - -void gpu_temporal_place_sprite(uint32_t primitive_addr, int32_t centre_x16, int32_t offset_y16) { - const uint32_t key = psx_mod_gpu_dma_resolve_address(primitive_addr); - WsTag *t = key ? temporal_sprite_slot(key, 0) : NULL; - if (!t || t->stamp != (uint32_t)s_frame_count) return; - t->placed = 1; - t->centre_x = centre_x16; - t->offset_y = offset_y16; -} - -void gpu_temporal_size_sprite(uint32_t primitive_addr, int32_t row16, int32_t width16, int32_t height16, - int shadow, int whole) { - const uint32_t key = psx_mod_gpu_dma_resolve_address(primitive_addr); - WsTag *t = key ? temporal_sprite_slot(key, 0) : NULL; - if (!t || t->stamp != (uint32_t)s_frame_count || !t->placed || width16 <= 0 || height16 <= 0) return; - t->sized = 1; - t->shadow = shadow != 0; - t->whole = whole != 0; - t->row = row16; - t->width = width16; - t->height = height16; -} - -static int s_shadow_shape = -1; /* not chosen yet: PSX_DISRUPTOR_IMPROVED_SHADOWS=1 of the launcher decides */ -void gpu_set_shadow_shape(int improved) { s_shadow_shape = improved != 0; } -int gpu_shadow_shape(void) { - if (s_shadow_shape < 0) { - const char *asked = getenv("PSX_DISRUPTOR_IMPROVED_SHADOWS"); - s_shadow_shape = asked && asked[0] == '1'; - } - return s_shadow_shape; -} -int gpu_sprite_placement_available(void) { -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - return 1; -#else - return 0; -#endif -} - -#ifndef PSX_DISABLE_FRAME_INTERPOLATION -static const WsTag *temporal_sprite_tag(void) { - if (gp0_cmd_source_addr == 0xFFFFFFFFu) return NULL; - const uint32_t key = psx_mod_gpu_dma_resolve_address(gp0_cmd_source_addr - 4u); - const WsTag *t = key ? temporal_sprite_slot(key, 0) : NULL; - if (!t || psx_ws_tag_match_result((uint32_t)s_frame_count, t->stamp, 1, - t->signature, gp0_cmd_buf) != PSX_WS_TAG_MATCH) - return NULL; - return t; -} - -/* How far the presentation moves the left, top, right and bottom side of a sprite's rectangle (x, y, w, h on screen, - * raw_w and raw_h as the packet has them). Returns the sprite's camera depth, 0 without one. */ -static float temporal_sprite_sides(int x, int y, int w, int h, int raw_w, int raw_h, int squashed, float sides[4]) { - const WsTag *t = temporal_sprite_tag(); - sides[0] = sides[1] = sides[2] = sides[3] = 0.0f; - if (!t) return 0.0f; - const GpuTemporalSprite sprite = gpu_temporal_sprite_from_fixed( - t->placed, t->sized, t->shadow, t->whole, t->centre_x, t->offset_y, t->row, t->width, t->height); - gpu_temporal_sprite_sides(&sprite, (float)x, (float)y, w, h, raw_w, raw_h, - squashed ? (float)ws_xnum / (float)ws_xden : 1.0f, gpu_shadow_shape(), sides); - return (float)t->anchor_x; -} -#endif - -/* Backdrop tiles tile the frame exactly, so squashing each one around a HUD - * pivot pulls them apart and exposes the clear colour between them. */ -static PsxWsScreenTiles ws_screen_tiles; - -void gpu_ws_tag_screen_tile(CPUState *cpu, uint32_t primitive_addr) { - if (!ws_active() || !cpu || !cpu->read_word) return; - uint32_t words[PSX_WS_SCREEN_TILE_WORDS]; - for (uint32_t i = 0; i < PSX_WS_SCREEN_TILE_WORDS; i++) - words[i] = cpu->read_word(primitive_addr + 4u + i * 4u); - psx_ws_screen_tile_mark(&ws_screen_tiles, - psx_mod_gpu_dma_resolve_address(primitive_addr), - words, (uint32_t)s_frame_count); -} - -int32_t gpu_ws_widen_x(int32_t x, int round_up) { - if (!ws_active()) return x; - return psx_ws_widen_about(x, ws_disp_w() / 2, ws_xnum, ws_xden, round_up); -} - -/* A two-frame window was tried instead of one-shot marks and lost them on - * 4-VBlank frames. */ -static int ws_screen_tile_take(void) { - if (gp0_cmd_source_addr == 0xFFFFFFFFu) return 0; - return psx_ws_screen_tile_take( - &ws_screen_tiles, - psx_mod_gpu_dma_resolve_address(gp0_cmd_source_addr - 4u), - gp0_cmd_buf, (uint32_t)s_frame_count); -} - -/* Called from generated code at the entry of each [widescreen] - * sprite_tag_funcs function: record prim pointer ($a0) -> projected anchor. - * Gated on ws_engaged (NOT ws_active): ws_active can depend on game_mode which - * this legacy tag path establishes for sprite-classified games. */ -void psx_ws_sprite_tag(CPUState* cpu) { - if (!ws_engaged() || !ws_anchor_addr) return; - uint32_t sxy = cpu->read_word(ws_anchor_addr); - int32_t ax = (int32_t)(int16_t)(sxy & 0xFFFFu); - ws_tag_primitive(cpu->gpr[4], ax, 0, 0); -} - -static int ws_tag_matches_current(WsTag *tag, uint32_t now) { - const int result = psx_ws_tag_match_result( - now, tag->stamp, tag->content_validated, - tag->signature, gp0_cmd_buf); - if (result == PSX_WS_TAG_MATCH) return 1; - if (result != PSX_WS_TAG_CONTENT_MISMATCH) return 0; - - /* The packet pool reused this address. Invalidate immediately instead of - * letting a stale world-billboard tag flicker onto an unrelated primitive. */ - tag->key = 0; - tag->content_validated = 0; - return 0; -} - -/* Look up the executing GP0 command's prim in the tag table. The command's - * first word lives at prim+4 (the PsyQ P_TAG header precedes it), but accept - * a direct hit too in case a tag site passes the colour-word address. */ -static int ws_tagged_anchor(int32_t *out_ax) { - if (!ws_active() || gp0_cmd_source_addr == 0xFFFFFFFFu) return 0; - uint32_t now = (uint32_t)s_frame_count; - for (int variant = 0; variant < 2; variant++) { - uint32_t key = psx_mod_gpu_dma_resolve_address( - gp0_cmd_source_addr - (variant ? 0u : 4u)); - uint32_t idx = (key >> 2) & (WS_TAG_BUCKETS - 1); - for (int i = 0; i < WS_TAG_PROBES; i++) { - WsTag *t = &ws_tags[(idx + i) & (WS_TAG_BUCKETS - 1)]; - if (t->key == key && ws_tag_matches_current(t, now)) { - *out_ax = t->anchor_x; - return 1; - } - } - } - return 0; -} - -/* Native-wide-compatible "is the current GP0 prim sprite-tagged?" — same ws_tags - * lookup as ws_tagged_anchor but gated on ws_engaged() (squash OR native-wide) - * instead of ws_active() (squash only). Used by the GL native-wide 2D-backdrop - * stretch to EXCLUDE foreground sprites: characters / Tomba / HUD are tagged by - * psx_ws_sprite_tag (their prim ptr -> ws_tags), the 2D backdrop tiles are not. - * Reads the live gp0_cmd_source_addr (the source addr of the prim being drawn). */ -/* Flower-field backdrop data-structure address range — set by the dirty-RAM - * interpreter when the backdrop generator (overlay 0x80116808) runs; its tile - * packets live in [lo, hi]. The GL native-wide 2D-stretch gate matches the prim - * being drawn (gp0_cmd_source_addr) against this to PRECISELY identify the - * flower-field tiles — the 3D rock/foreground is untagged AND has narrow prims, - * so tag/narrow heuristics alone mis-stretch and tear it. */ -uint32_t g_ws_backdrop_lo = 0, g_ws_backdrop_hi = 0; -static int ws_nw_phase_backdrop = 0; -void gpu_ws_set_nw_phase_backdrop(int on) { ws_nw_phase_backdrop = on ? 1 : 0; } -static int ws_nw_textured_edges = 0; -int g_ws_tex_edge_pct = 0; -void gpu_ws_set_nw_textured_edges(int on, int scale_pct) { - ws_nw_textured_edges = on ? 1 : 0; - g_ws_tex_edge_pct = scale_pct; -} -/* diag: per-frame min/max of the prim source addrs the GL gate sees */ -uint32_t g_bdg_src_lo = 0xFFFFFFFFu, g_bdg_src_hi = 0; -static uint32_t bdg_src_frame = 0xFFFFFFFFu; - -/* ---- prim<->pixel correlation debug gate (ws_dbg_stretch command) ------------ - * To identify WHICH drawn prims constitute the native-wide 2D-backdrop void layer - * (vs Tomba/HUD/3D), this lets a probe stretch an exact SELECTABLE prim set and - * screenshot the result. When g_dbg_mode != 0 the GL stretch gate uses this - * instead of the flower-struct range. Modes (buffer/frame-independent except 1): - * 1 OT-range : gp0_cmd_source_addr in [g_dbg_lo,g_dbg_hi] - * 2 clut : textured prim whose clut == g_dbg_clut - * 3 tpage : textured prim whose tpage == (g_dbg_clut & 0x1FF) - * 4 tagged : prim is sprite-tagged (psx_ws_prim_is_tagged) - * 5 untag-tex : textured AND not sprite-tagged - * 6 all-tex : every textured prim (sanity: should fill void + distort 3D) - * 7 all : every primitive (coarse correlation sanity check) - * 8 all-flat : every untextured primitive - * Counters (per-frame, snapshotted by the present hook) report how many prims - * matched and how many of those were sprite-tagged. */ -int g_dbg_mode = 0; -uint32_t g_dbg_lo = 0, g_dbg_hi = 0; -uint16_t g_dbg_clut = 0; -int g_dbg_match_n = 0, g_dbg_match_tagged = 0; /* snapshot (reported) */ -static int s_dbg_match_n = 0, s_dbg_match_tagged = 0; /* accumulate per frame */ -int psx_ws_prim_is_tagged(void); /* defined below */ - -static int dbg_gate_match(void) { - uint32_t op = (gp0_cmd_buf[0] >> 24) & 0xFFu; - uint16_t clut = (uint16_t)(gp0_cmd_buf[2] >> 16); - uint32_t a = gp0_cmd_source_addr & 0x1FFFFFFFu; - int textured = ((op >= 0x20 && op <= 0x3F) && (op & 0x04)) || (op >= 0x64 && op <= 0x67); - int tagged = psx_ws_prim_is_tagged(); - int m = 0; - switch (g_dbg_mode) { - case 1: m = (g_dbg_hi > g_dbg_lo && a >= g_dbg_lo && a <= g_dbg_hi); break; - case 2: m = (textured && clut == g_dbg_clut); break; - case 3: m = (textured && ((uint16_t)(gp0_cmd_buf[4] >> 16) & 0x1FF) == (g_dbg_clut & 0x1FF)); break; - case 4: m = tagged; break; - case 5: m = (textured && !tagged); break; - case 6: m = textured; break; - case 7: m = 1; break; - case 8: m = !textured; break; - default: m = 0; break; - } - if (m) { s_dbg_match_n++; if (tagged) s_dbg_match_tagged++; } - return m; -} - -/* Called by the present hook each frame to publish the dbg match counters. */ -void psx_ws_dbg_gate_frame_snapshot(void) { - g_dbg_match_n = s_dbg_match_n; g_dbg_match_tagged = s_dbg_match_tagged; - s_dbg_match_n = 0; s_dbg_match_tagged = 0; -} - -/* Background-phase latch for the native-wide 2D-backdrop stretch. The far 2D - * backdrop (a sprite-tagged tile grid: sky gradient + flower-ball field) draws - * FIRST each frame, before the GTE 3D world. Once a shaded/gouraud prim (the 3D - * world's signature -- the backdrop is sprites/flat) is drawn, the backdrop phase - * is over: later TAGGED prims (HUD, characters) must NOT be stretched. Latched - * per-frame at GP0 decode (ws_bg_phase_note, sees every prim in draw order). */ -static uint32_t s_bg_phase_frame = 0xFFFFFFFFu; -static int s_bg_phase_over = 0; -void ws_bg_phase_note(uint32_t op) { - uint32_t f = (uint32_t)s_frame_count; - if (f != s_bg_phase_frame) { s_bg_phase_frame = f; s_bg_phase_over = 0; } - if (op >= 0x30u && op <= 0x3Fu) { - s_bg_phase_over = 1; /* shaded = 3D world (within-frame draw order) */ - ws_last_3d_stamp = f; /* diagnostic only — see ws_2d_only_scene() for - why shaded prims are NOT the scene classifier */ - } -} -static int ws_bg_phase_over(void) { - uint32_t f = (uint32_t)s_frame_count; - if (f != s_bg_phase_frame) { s_bg_phase_frame = f; s_bg_phase_over = 0; } - return s_bg_phase_over; -} - -int psx_ws_prim_in_backdrop(void) { - if (gp0_cmd_source_addr != 0xFFFFFFFFu) { - uint32_t f = (uint32_t)s_frame_count; - if (f != bdg_src_frame) { bdg_src_frame = f; g_bdg_src_lo = 0xFFFFFFFFu; g_bdg_src_hi = 0; } - if (gp0_cmd_source_addr < g_bdg_src_lo) g_bdg_src_lo = gp0_cmd_source_addr; - if (gp0_cmd_source_addr > g_bdg_src_hi) g_bdg_src_hi = gp0_cmd_source_addr; - } - if (g_dbg_mode != 0) return dbg_gate_match(); /* correlation override */ - if (ws_nw_textured_edges) { - uint32_t op = (gp0_cmd_buf[0] >> 24) & 0xFFu; - if (op >= 0x20u && op <= 0x3Fu && (op & 0x04u)) - return 2; /* GL gate: expand only vertices beyond canonical edges */ - } - if (ws_nw_phase_backdrop && !ws_bg_phase_over()) return 1; - /* Real gate: stretch the 2D backdrop = sprite-tagged prims drawn in the - * background phase (before the 3D world). Fills the native-wide void for both - * the flower grid and the sky band; HUD/characters (tagged but post-3D) and the - * GTE world (untagged) are excluded. */ - if (ws_bg_phase_over()) return 0; - return psx_ws_prim_is_tagged(); -} - -int psx_ws_prim_is_tagged(void) { - if (!ws_engaged() || gp0_cmd_source_addr == 0xFFFFFFFFu) return 0; - uint32_t now = (uint32_t)s_frame_count; - for (int variant = 0; variant < 2; variant++) { - uint32_t key = psx_mod_gpu_dma_resolve_address( - gp0_cmd_source_addr - (variant ? 0u : 4u)); - uint32_t idx = (key >> 2) & (WS_TAG_BUCKETS - 1); - for (int i = 0; i < WS_TAG_PROBES; i++) { - WsTag *t = &ws_tags[(idx + i) & (WS_TAG_BUCKETS - 1)]; - if (t->key == key && ws_tag_matches_current(t, now)) return 1; - } - } - return 0; -} - -/* Squash x around pivot ax by ws_xnum/ws_xden, round to nearest. */ -static int32_t ws_scale_about(int32_t x, int32_t ax) { - int32_t d = x - ax; - int32_t s = (d * ws_xnum + (d >= 0 ? ws_xden / 2 : -ws_xden / 2)) / ws_xden; - return ax + s; -} - -/* Squash a width; never below 1px so nothing vanishes. */ -static int32_t ws_scale_len(int32_t w) { - int32_t s = (w * ws_xnum + ws_xden / 2) / ws_xden; - return s < 1 ? 1 : s; -} - -/* Horizontal display width in drawing space (e.g. 320). */ -static int32_t ws_disp_w(void) { - GpuDisplayInfo di; - gpu_get_display_info(&di); - return di.width ? (int32_t)di.width : 320; -} - -static int32_t ws_disp_h(void) { - GpuDisplayInfo di; - gpu_get_display_info(&di); - return di.height ? (int32_t)di.height : 240; -} - -/* An upload or a copy into a frame buffer has no drawing area to announce it. */ -static void ws_frame_pictured(int x, int y, int w, int h) { - psx_ws_frame_kinds_pictured(&ws_frame_kinds, (uint32_t)x, (uint32_t)y, (uint32_t)w, (uint32_t)h, - (uint32_t)ws_disp_w(), (uint32_t)ws_disp_h()); -} - -/* Full-screen fades and environmental filters are authored as 320x240 TILEs. - * In native-wide mode, grow only primitives that cover the complete native - * display; ordinary world-space rectangles remain untouched. */ -static void ws_expand_fullscreen_rect(int32_t *x, int32_t y, int *w, int h) { - if (!ws_native_wide_active()) return; - int W = (int)ws_disp_w(), H = (int)ws_disp_h(); - if (*x <= 0 && *x + *w >= W && y <= 0 && y + h >= H) { - int off = ws_nw_offset(); - *x -= off; - *w += 2 * off; - } -} - -/* In-game HUD pivot for an untagged screen-space SPRT spanning [x, x+w). - * Only reached during gameplay (ws_active true). HUD elements are sparse and - * corner-anchored, so pivot by thirds: outer-third elements anchor to their - * screen edge (keeping the wide-screen corner position at native proportions), - * the middle third to centre. A composite (counter box + digits) sits inside - * one zone, so its pieces share a pivot and stay aligned. (Full-2D menu - * screens never reach here — they get zero squash + 4:3 pillarbox instead.) */ -static int32_t ws_hud_strip_wide(void); -static int32_t ws_hud_pivot(int32_t x, int32_t w) { - int32_t W = ws_disp_w(); - int32_t cx = psx_ws_hud_thirds_centre(x, w, ws_hud_strip_wide(), W); /* 2*centre, avoids losing the half */ - if (3 * cx < 2 * W) return 0; - if (3 * cx > 4 * W) return W; - return W / 2; -} - -/* Automatic UI correction is deliberately tied to draw provenance, not to - * primitive size or "does this look 2D?" guesses. Ape's ordering table submits - * HUD/font/icon packets in the final layer, after every depth-sorted world - * bucket. A read-only prepass finds that final rank and complete spatial groups - * before the list streams through GP0. This excludes CPU-built characters (the - * source of the old squashed-Spike regression) even when their packets are - * axis-aligned, and gives animated glyphs a shared anchor on their first frame. */ -static int ws_auto_ui_anchor(int32_t *out_anchor) { - if (!ws_auto_ui_squash || !ws_active() || - gp0_cmd_source_addr == 0xFFFFFFFFu) - return 0; - uint32_t src = psx_mod_gpu_dma_resolve_address(gp0_cmd_source_addr); - for (uint32_t i = 0; i < ws_ui_prepass_count; i++) { - if (ws_ui_prepass[i].src_addr != src) continue; - if (out_anchor) *out_anchor = ws_ui_prepass[i].group.anchor; - ws_auto_ui_candidate_count++; - return 1; - } - return 0; -} - -static uint32_t ws_auto_ui_group_key_words(const uint32_t *words, - uint32_t op, - int32_t y, int32_t h) { - uint32_t clut = words[2] >> 16; - uint32_t tpage = 0; - if (op >= 0x20u && op <= 0x3Fu && (op & 0x04u)) { - int tp_index = (op & 0x10u) ? 5 : 4; - tpage = (words[tp_index] >> 16) & 0x1FFu; - } - int32_t centre_y = y + h / 2; - uint32_t band = (uint32_t)(centre_y < 0 ? 0 : centre_y / 24) & 0x1Fu; - uint32_t family = op < 0x60u ? 1u : 2u; - uint32_t key = (clut * 2654435761u) ^ (tpage << 11) ^ - (band << 3) ^ family; - return key ? key : 1u; -} - -static int ws_axis_aligned_quad(const int32_t vx[4], const int32_t vy[4]) { - int32_t min_x = vx[0], max_x = vx[0], min_y = vy[0], max_y = vy[0]; - for (int i = 1; i < 4; i++) { - if (vx[i] < min_x) min_x = vx[i]; - if (vx[i] > max_x) max_x = vx[i]; - if (vy[i] < min_y) min_y = vy[i]; - if (vy[i] > max_y) max_y = vy[i]; - } - unsigned corners = 0; - for (int i = 0; i < 4; i++) { - if (vx[i] == min_x && vy[i] == min_y) corners |= 1u; - else if (vx[i] == max_x && vy[i] == min_y) corners |= 2u; - else if (vx[i] == min_x && vy[i] == max_y) corners |= 4u; - else if (vx[i] == max_x && vy[i] == max_y) corners |= 8u; - else return 0; - } - return corners == 15u; -} - -static int ws_auto_ui_transform_quad(int32_t vx[4], const int32_t vy[4]) { - if (psx_ws_prim_is_tagged() || !ws_axis_aligned_quad(vx, vy)) - return 0; - - int32_t min_x = vx[0], max_x = vx[0], min_y = vy[0], max_y = vy[0]; - for (int i = 1; i < 4; i++) { - if (vx[i] < min_x) min_x = vx[i]; - if (vx[i] > max_x) max_x = vx[i]; - if (vy[i] < min_y) min_y = vy[i]; - if (vy[i] > max_y) max_y = vy[i]; - } - int32_t width = max_x - min_x, height = max_y - min_y; - int32_t W = ws_disp_w(), H = ws_disp_h(); - if ((min_x <= 0 && max_x >= W && min_y <= 0 && max_y >= H) || - (width > W / 2 && height > H / 4)) - return 0; - - int32_t anchor; - if (!ws_auto_ui_anchor(&anchor)) return 0; - for (int i = 0; i < 4; i++) vx[i] = ws_scale_about(vx[i], anchor); - ws_auto_ui_transform_count++; - return 1; -} - -static int ws_auto_ui_transform_rect(int32_t *x, int32_t y, int *w, int h) { - if (!x || !w || *w <= 0 || psx_ws_prim_is_tagged()) - return 0; - int32_t W = ws_disp_w(), H = ws_disp_h(); - if ((*x <= 0 && *x + *w >= W && y <= 0 && y + h >= H) || - (*w > W / 2 && h > H / 4)) - return 0; - int32_t anchor; - if (!ws_auto_ui_anchor(&anchor)) return 0; - *x = ws_scale_about(*x, anchor); - *w = (int)ws_scale_len(*w); - ws_auto_ui_transform_count++; - return 1; -} - -static int ws_hud_scale_percent = PSX_WS_HUD_SCALE_MAX; -void gpu_ws_set_hud_scale(int percent) { - ws_hud_scale_percent = psx_ws_hud_scale_clamp(percent); -} -int gpu_ws_hud_scale(void) { return ws_hud_scale_percent; } - -/* The user's HUD size for an untagged screen-space SPRT. 0 leaves the - * stock horizontal squash to the caller. */ -static int ws_hud_user_scale(int32_t *x, int32_t *y, int *w, int *h) { - int32_t scaled_w = *w, scaled_h = *h; - if (ws_hud_scale_percent >= PSX_WS_HUD_SCALE_MAX || - !psx_ws_hud_scale_rect_strip(x, y, &scaled_w, &scaled_h, ws_disp_w(), ws_disp_h(), - ws_xnum, ws_xden, ws_hud_scale_percent, ws_hud_strip_wide())) - return 0; - *w = (int)scaled_w; - *h = (int)scaled_h; - return 1; -} - -static PsxWsHudWidgets ws_hud; -static uint32_t ws_hud_table_pointer; -static int ws_hud_strip; /* how wide the game's text printer makes a line, 0 when the game has not said */ - -void gpu_ws_set_hud_text_strip(int wide) { ws_hud_strip = wide > 0 ? wide : 0; } - -/* A line of text is printed as a list of its own, outside the HUD layer: only there is a rectangle of that width a line. */ -static int32_t ws_hud_strip_wide(void) { return ws_hud.in_hud_layer ? 0 : ws_hud_strip; } - -void gpu_ws_set_hud_widgets(uint32_t table_pointer, int layers, const int32_t *boxes, int count) { - ws_hud_table_pointer = table_pointer; - memset(&ws_hud, 0, sizeof(ws_hud)); - ws_hud.layers = layers; - for (int i = 0; boxes && i < count && i < PSX_WS_HUD_WIDGET_MAX; ++i) { - const int32_t *box = boxes + 6 * i; - const PsxWsHudWidget widget = { box[0], box[1], box[2], box[3], box[4], box[5] }; - ws_hud.widgets[ws_hud.count++] = widget; - } -} - -/* The widget of an untagged widescreen primitive with these bounds. See psx_ws_hud_widgets_take. */ -static const PsxWsHudWidget *ws_hud_widget(int32_t x0, int32_t y0, int32_t x1, int32_t y1, int rectangle) { - if (!ws_active() || psx_ws_prim_is_tagged()) return NULL; - return psx_ws_hud_widgets_take(&ws_hud, x0, y0, x1, y1, rectangle); -} - -/* A widget's rectangle in place. 0 for a rectangle of no widget. */ -static int ws_hud_widget_rect(int32_t *x, int32_t *y, int32_t *w, int32_t *h) { - const PsxWsHudWidget *widget = ws_hud_widget(*x, *y, *x + *w, *y + *h, 1); - if (!widget) return 0; - psx_ws_hud_widget_rect(widget, x, y, w, h, ws_xnum, ws_xden, ws_hud_scale_percent); - return 1; -} - -/* A widget's axis-aligned quad, every corner through the same mapping. A quad joins in a HUD layer only. */ -static void ws_hud_widget_quad(int32_t vx[4], int32_t vy[4]) { - int32_t min_x = vx[0], max_x = vx[0], min_y = vy[0], max_y = vy[0]; - if (!ws_hud.in_hud_layer || !ws_axis_aligned_quad(vx, vy)) return; - for (int i = 1; i < 4; i++) { - if (vx[i] < min_x) min_x = vx[i]; - if (vx[i] > max_x) max_x = vx[i]; - if (vy[i] < min_y) min_y = vy[i]; - if (vy[i] > max_y) max_y = vy[i]; - } - const PsxWsHudWidget *widget = ws_hud_widget(min_x, min_y, max_x, max_y, 0); - if (!widget) return; - for (int i = 0; i < 4; i++) - psx_ws_hud_widget_corner(widget, &vx[i], &vy[i], ws_xnum, ws_xden, ws_hud_scale_percent); -} - -/* Shared transform for fixed-size textured sprites (8x8 / 16x16 / 1x1 dot): - * squash *x0 in place (around the tagged anchor, else the HUD pivot) and - * return the squashed draw width, or 0 = no change. *out_h is the draw - * height when the user's HUD size changed it, else 0. */ -static int ws_sprt_fixed_transform(int32_t *x0, int32_t *y0, int w, int *out_h) { - *out_h = 0; - if (ws_menu_frame()) return ws_menu_rect(x0, w); - if (!ws_active()) return 0; - int32_t ax; - if (ws_tagged_anchor(&ax)) { - *x0 = ws_scale_about(*x0, ax); - return (int)ws_scale_len(w); - } - int32_t widget_w = w, widget_h = w; - if (ws_hud_widget_rect(x0, y0, &widget_w, &widget_h)) { - *out_h = (int)widget_h; - return (int)widget_w; - } - int auto_w = w; - if (ws_auto_ui_transform_rect(x0, *y0, &auto_w, w)) - return auto_w; - if (ws_hud_sprt) { - int scaled_w = w, scaled_h = w; - if (ws_hud_user_scale(x0, y0, &scaled_w, &scaled_h)) { - *out_h = scaled_h; - return scaled_w; - } - *x0 = ws_scale_about(*x0, ws_hud_pivot(*x0, w)); - return (int)ws_scale_len(w); - } - return 0; -} - -/* ---- Native-wide HUD corner re-anchoring ([widescreen] nw_hud_corners) ------ - * In native-wide the whole frame is composited into a wider surface centred by - * ws_nw_offset() per side (the reveal). Screen-space 2D HUD (drawn with fixed - * rect/sprite GP0 commands, never through the GTE — a 3D title's world is all - * polygons) therefore lands inset from the true wide edges by exactly the - * reveal. This pushes an outer-third HUD primitive the rest of the way to its - * wide corner with an additive thirds shift: left third −offset, right third - * +offset, middle unchanged (matches the squash-path ws_hud_pivot geometry, but - * as a translate since native-wide does not squash). Composite pieces in one - * zone share a shift and stay aligned. A configured command-source range lets - * pure-2D games opt in only their HUD packet arena, leaving world primitives - * untouched. Identity unless native-wide is engaged AND the game opts in, so - * 4:3 and non-opted titles are byte-identical. - * Returns the signed x delta to add to the prim's x before draw_offset. */ -static int ws_nw_hud_corners = 0; -static int ws_nw_hud_tag_rects = 0; /* rects shift even when tagged (A/B) */ -static uint32_t ws_nw_left_hud_packet_lo = 0; -static uint32_t ws_nw_left_hud_packet_hi = 0; -void gpu_ws_set_nw_hud_corners(int on) { ws_nw_hud_corners = on ? 1 : 0; } -void gpu_ws_set_nw_hud_tag_rects(int on) { ws_nw_hud_tag_rects = on ? 1 : 0; } -void gpu_ws_set_nw_left_hud_packet_range(uint32_t lo, uint32_t hi) { - ws_nw_left_hud_packet_lo = lo & 0x1FFFFFFFu; - ws_nw_left_hud_packet_hi = hi & 0x1FFFFFFFu; -} -static int ws_nw_left_hud_packet(void) { - if (!ws_native_wide_active() || gp0_cmd_source_addr == 0xFFFFFFFFu || - ws_nw_left_hud_packet_hi <= ws_nw_left_hud_packet_lo) - return 0; - uint32_t a = gp0_cmd_source_addr & 0x1FFFFFFFu; - int matched = a >= ws_nw_left_hud_packet_lo && a < ws_nw_left_hud_packet_hi; - return matched; -} -static int32_t ws_nw_hud_shift(int32_t x, int32_t w) { - if (!ws_native_wide_active()) return 0; - int32_t off = ws_nw_offset(); - if (off <= 0) return 0; - if (!ws_nw_left_hud_packet() && !ws_nw_hud_corners) return 0; - /* Sprite-tag titles (anchor configured): HUD ≡ UNTAGGED rect-family prims - * — the same discriminator the squash path's hud_sprt_squash used. Tagged - * prims are character billboards positioned from their GTE anchor; they - * must never re-anchor. (Poly/line sites are excluded wholesale for tag - * titles in ws_nw_hud_shift_vertices — their polys are the world.) - * ws_nw_hud_tag_rects (TCP ws_hud_mode) lifts the exclusion for rects, - * for live A/B: some HUD composites (Tomba's AP counter) render through - * the tagged sprite funnel and stay inset without it. */ - if (ws_anchor_addr && !ws_nw_hud_tag_rects && psx_ws_prim_is_tagged()) return 0; - int32_t W = ws_disp_w(); - int32_t cx = 2 * x + w; /* 2*centre, avoids losing the half */ - if (3 * cx < 2 * W) return -off; /* left third -> pull to left edge */ - if (3 * cx > 4 * W) return off; /* right third -> push to right edge */ - return 0; /* middle third -> stay centred */ -} - -/* Re-anchor every X coordinate of a HUD polygon as one rigid composite. The - * command-source filter above is what makes this safe for pure-2D titles: only - * the game's dedicated HUD packet arena reaches here, never world polygons. */ -static void ws_nw_hud_shift_vertices(int32_t *vx, int count) { - if (count <= 0) return; - if (ws_menu_frame()) { /* the front end's polygons and lines keep their shape like its text */ - psx_ws_menu_squash_corners(vx, count, ws_xnum, ws_xden); - return; - } - /* Sprite-tag titles: polygon/line prims are the GTE world and the tagged - * character billboards, never HUD — only the rect-family sites (which - * call ws_nw_hud_shift directly, with the untagged filter) re-anchor. */ - if (ws_anchor_addr) return; - int32_t lo = vx[0], hi = vx[0]; - for (int i = 1; i < count; i++) { - if (vx[i] < lo) lo = vx[i]; - if (vx[i] > hi) hi = vx[i]; - } - int32_t d = ws_nw_hud_shift(lo, hi - lo); - if (d) for (int i = 0; i < count; i++) vx[i] += d; -} - -/* ---- Native-wide full-frame 2D backdrop stretch ([widescreen] nw_backdrop) --- - * A screen-space background plane (sky gradient / backdrop image) is drawn as an - * axis-aligned quad covering the whole 4:3 framebuffer [0,W]x[0,H]. It is NOT - * GTE-projected, so native-wide leaves it at its 4:3 span, composited centred = - * black bars in the revealed side margins (the pillarboxed sky). Detect exactly - * that shape — a 4-vertex quad whose corners form a rectangle spanning ~the full - * display width from ~the left edge — and stretch its X vertices about the - * display centre by the wide ratio so it fills the wider frame; the texture/UV - * (or gradient) simply stretches horizontally (invisible on a sky). GTE-drawn - * world quads are perspective-distorted (not axis-aligned) and partial-width, so - * they never match. Identity unless native-wide is engaged AND the game opts in. - * Transforms vx[0..3] IN PLACE (pre-draw_offset); returns 1 if it applied. */ -static int ws_nw_backdrop = 0; -void gpu_ws_set_nw_backdrop(int on) { ws_nw_backdrop = on ? 1 : 0; } -static int ws_nw_flat_backdrop = 0; -void gpu_ws_set_nw_flat_backdrop(int on) { ws_nw_flat_backdrop = on ? 1 : 0; } -int gpu_ws_nw_flat_backdrop_enabled(void) { return ws_nw_flat_backdrop; } -static int ws_nw_backdrop_stretch_quad(int32_t *vx, const int32_t *vy) { - if (!ws_nw_backdrop || !ws_native_wide_active()) return 0; - int32_t extra = ws_nw_extra(); - if (extra <= 0) return 0; - int32_t W = ws_disp_w(); - const int32_t EDGE = 24; /* slack for "touches the frame edge" */ - int32_t minx = vx[0], maxx = vx[0], miny = vy[0], maxy = vy[0]; - for (int i = 1; i < 4; i++) { - if (vx[i] < minx) minx = vx[i]; - if (vx[i] > maxx) maxx = vx[i]; - if (vy[i] < miny) miny = vy[i]; - if (vy[i] > maxy) maxy = vy[i]; - } - /* Must span the full display width and a real vertical extent. */ - if (minx > EDGE || maxx < W - EDGE || (maxy - miny) < 64) return 0; - /* Axis-aligned: every vertex X sits at either the min or the max edge, and - * every Y at the top or bottom edge (a true screen-space rectangle). */ - for (int i = 0; i < 4; i++) { - int xe = (vx[i] - minx <= EDGE) || (maxx - vx[i] <= EDGE); - int ye = (vy[i] - miny <= EDGE) || (maxy - vy[i] <= EDGE); - if (!xe || !ye) return 0; - } - /* Stretch X about the display centre by (W+extra)/W so [0,W] -> [-off, W+off], - * which the wide compositor (+off) maps onto the full [0, W+extra] surface. */ - int32_t cx = W / 2; - for (int i = 0; i < 4; i++) { - int32_t d = vx[i] - cx; - vx[i] = cx + (d * (W + extra) + (d >= 0 ? W / 2 : -W / 2)) / W; - } - return 1; -} - -/* Polyline state */ -static uint16_t polyline_color; /* mono polyline: current color */ -static int32_t polyline_prev_x, polyline_prev_y; /* previous vertex */ -static uint16_t polyline_prev_c; /* shaded polyline: previous color */ -static int polyline_semi_trans; /* semi-transparency flag from command word */ -static int polyline_has_prev; /* have we seen at least one vertex? */ - -/* VRAM write transfer state (CPU→VRAM, command 0xA0) */ -static uint16_t vram_write_x, vram_write_y; /* start coords */ -static uint16_t vram_write_w, vram_write_h; /* dimensions */ -static uint16_t vram_write_col, vram_write_row; /* current offset */ -static uint32_t vram_write_remaining; /* words remaining */ -/* Stage one complete GP0(A0) transfer so renderer backends receive one bulk - * rectangle instead of hundreds of thousands of single-pixel callbacks. The - * CPU-visible transfer remains ordered because GP0 accepts no next command - * until this payload is complete. Maximum PS1 transfer = full VRAM (1 MiB). */ -static uint16_t vram_write_pixels[1024 * 512]; -static uint16_t ws_menu_clut_x = 0, ws_menu_clut_y = 480; /* the backdrop's palette, as its rectangles last named it */ - -/* Depth24 CPU→VRAM upload span (halfwords, exclusive end). See - * gpu_depth24_rgb_limit — declared early so gpu_reset_state can clear it. */ -static uint32_t s_d24_upload_x1 = 0; -static int s_d24_present_hold = 0; /* vblanks to skip Swap after GP1(07h) */ -static uint32_t s_d24_prev_disp_h = 0; /* last GP1(07h) band height */ -static void depth24_note_upload(uint32_t x, uint32_t w); - -/* The wide front end's logo and paint are in the renderer's video memory alone, the one the game reads and a save state holds. */ -static uint16_t ws_menu_kept[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_LOGO_WIDE], ws_menu_under[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_LOGO_WIDE]; -static uint16_t ws_menu_painted[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_WORDS], ws_menu_uploaded[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_WORDS]; -static int ws_menu_fresh; /* the CPU copy at (320,0) is a whole picture with the logo, and no state was loaded since it came */ -static int ws_menu_lent; /* the renderer holds the kept logo at (512,0) */ -static int ws_menu_rows; /* and the painted rows in the picture */ -static int ws_menu_due; /* the frame has drawn nothing yet: its first primitive says whether it is the front end's */ - -static const uint16_t *ws_menu_palette(void) { return &vram[ws_menu_clut_y * 1024u + ws_menu_clut_x]; } - -/* Gives a rectangle of the renderer's back word by word: what is still as it was put there becomes what it was before. */ -static void ws_menu_return(int x, int y, int w, int h, const uint16_t *put, const uint16_t *before) { - static uint16_t now[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_LOGO_WIDE]; - gr_vram_transfer_out(x, y, w, h, now); - if (psx_ws_menu_return(now, w, w, h, put, before)) gr_vram_transfer_in(x, y, w, h, now); -} - -/* The front end is left, or the game is about to read or write there: it finds its own words. */ -static void ws_menu_give_back(void) { - if (ws_menu_lent) ws_menu_return(PSX_WS_MENU_KEPT_X, 0, PSX_WS_MENU_LOGO_WIDE, PSX_WS_MENU_LOGO_ROWS, ws_menu_kept, ws_menu_under); - if (ws_menu_rows) ws_menu_return(PSX_WS_MENU_X, PSX_WS_MENU_LOGO_TOP, PSX_WS_MENU_WORDS, PSX_WS_MENU_LOGO_ROWS, ws_menu_painted, ws_menu_uploaded); - ws_menu_lent = ws_menu_rows = ws_menu_logo = 0; -} - -/* The game is about to read or write this rectangle of video memory, in words. What it writes there the CPU copy does not have. */ -static void ws_menu_yield(int x, int y, int w, int h, int written) { - if (!psx_ws_menu_reaches(x, y, w, h)) return; - ws_menu_give_back(); - if (written) ws_menu_fresh = 0; -} - -/* The front end's picture is at (320,0): its logo goes to (512,0) and is painted over in the picture, both in the renderer's video memory. */ -static void ws_menu_take_picture(void) { - static uint8_t mask[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_LOGO_WIDE]; - static uint16_t picture[PSX_WS_MENU_HIGH * PSX_WS_MENU_WORDS]; - const uint16_t *palette = ws_menu_palette(); - if (!ws_menu_fresh || !ws_menu_wanted()) { - ws_menu_give_back(); - return; - } - if (!ws_menu_lent) gr_vram_transfer_out(PSX_WS_MENU_KEPT_X, 0, PSX_WS_MENU_LOGO_WIDE, PSX_WS_MENU_LOGO_ROWS, ws_menu_under); - for (int row = 0; row < PSX_WS_MENU_HIGH; ++row) memcpy(&picture[row * PSX_WS_MENU_WORDS], &vram[row * 1024 + PSX_WS_MENU_X], PSX_WS_MENU_WORDS * sizeof(uint16_t)); - memcpy(ws_menu_uploaded, &picture[PSX_WS_MENU_LOGO_TOP * PSX_WS_MENU_WORDS], sizeof(ws_menu_uploaded)); - psx_ws_menu_logo_mask(picture, PSX_WS_MENU_WORDS, palette, mask); - psx_ws_menu_keep_logo(picture, PSX_WS_MENU_WORDS, palette, mask, ws_menu_kept); - gr_vram_transfer_in(PSX_WS_MENU_KEPT_X, 0, PSX_WS_MENU_LOGO_WIDE, PSX_WS_MENU_LOGO_ROWS, ws_menu_kept); - psx_ws_menu_paint(picture, PSX_WS_MENU_WORDS, mask); - memcpy(ws_menu_painted, &picture[PSX_WS_MENU_LOGO_TOP * PSX_WS_MENU_WORDS], sizeof(ws_menu_painted)); - gr_vram_transfer_in(PSX_WS_MENU_X, PSX_WS_MENU_LOGO_TOP, PSX_WS_MENU_WORDS, PSX_WS_MENU_LOGO_ROWS, ws_menu_painted); - ws_menu_logo = ws_menu_lent = ws_menu_rows = 1; -} - -static void gp0_commit_cpu_to_vram(void) { - for (uint32_t row = 0; row < vram_write_h; row++) - for (uint32_t col = 0; col < vram_write_w; col++) - vram_write_pixels[row * vram_write_w + col] = - vram[((vram_write_y + row) & 511u) * 1024u + - ((vram_write_x + col) & 1023u)]; - gr_vram_transfer_in(vram_write_x, vram_write_y, - vram_write_w, vram_write_h, vram_write_pixels); - depth24_note_upload(vram_write_x, vram_write_w); - if (psx_ws_menu_is_picture(vram_write_x, vram_write_y, vram_write_w, vram_write_h)) { - ws_menu_rows = 0; /* the renderer's rows are the game's again */ - ws_menu_fresh = psx_ws_menu_has_logo(&vram[PSX_WS_MENU_X], 1024, ws_menu_palette()); - ws_menu_take_picture(); - } - gp0_state = GP0_IDLE; - vram_write_remaining = 0; - text_xlate_vram_upload(vram_write_x, vram_write_y, - vram_write_w, vram_write_h); -} - -/* VRAM read transfer state (VRAM→CPU, command 0xC0) */ -static int vram_read_active; -static uint16_t vram_read_x, vram_read_y; -static uint16_t vram_read_w, vram_read_h; -static uint16_t vram_read_col, vram_read_row; - -/* ---- GPU internal state ---- */ - -/* Texture page / draw mode (set by GP0(E1h), reflected in GPUSTAT bits 0-10) */ -static uint32_t texpage_x; /* bits 0-3: texture page X base (N*64) */ -static uint32_t texpage_y; /* bit 4: texture page Y base (0 or 256) */ -static uint32_t semi_transparency; /* bits 5-6 */ -static uint32_t texpage_colors; /* bits 7-8: 4bit/8bit/15bit */ -static uint32_t dither_enabled; /* bit 9 */ -static uint32_t draw_to_display; /* bit 10: drawing to display area allowed */ -static uint32_t set_mask_bit; /* bit 11 */ -static uint32_t check_mask_bit; /* bit 12 */ -static uint32_t interlace_field; /* bit 13 */ -static uint32_t reverse_flag; /* bit 14 */ -static uint32_t texture_disable; /* bit 15 */ - -/* Draw area (set by GP0(E3h)/GP0(E4h)) */ -static uint32_t draw_area_left, draw_area_top; -static uint32_t draw_area_right, draw_area_bottom; - -/* Draw offset (set by GP0(E5h)) */ -static int32_t draw_offset_x, draw_offset_y; -/* Instrumentation: per-vblank range/count of GP0(E5) draw-offset-Y sets. If a - * single frame sets offsets in BOTH the top (y<128) and bottom (y>=128) buffer - * bands, the game is drawing different parts of the scene into different display - * buffers in the same frame (candidate root of a character-in-one-buffer - * strobe). Snapshotted each vblank into the *_last copies, exposed via debug. */ -int32_t g_doff_min_this = 0x7fffffff, g_doff_max_this = -0x7fffffff; -uint32_t g_doff_cnt_this = 0; -int32_t g_doff_min_last = 0, g_doff_max_last = 0; -uint32_t g_doff_cnt_last = 0; - -/* Texture window raw value (set by GP0(E2h), readback via GP1(10h)) */ -static uint32_t texture_window_value; - -/* Display mode (set by GP1(08h), reflected in GPUSTAT bits 16-22) */ -static uint32_t hres2; /* bit 16: horizontal resolution 2 (368 mode) */ -static uint32_t hres1; /* bits 17-18: horizontal resolution 1 */ -static uint32_t vres; /* bit 19: vertical resolution (0=240, 1=480) */ -static uint32_t video_mode; /* bit 20: 0=NTSC, 1=PAL */ -static uint32_t display_depth; /* bit 21: 0=15bit, 1=24bit */ -static uint32_t vertical_interlace; /* bit 22 */ - -/* Display enable (set by GP1(03h)) */ -static uint32_t display_disabled; /* bit 23: 0=enabled, 1=disabled */ - -/* IRQ1 (set by GP0(1Fh), acked by GP1(02h)) */ -static uint32_t irq1_flag; /* bit 24 */ - -/* DMA direction (set by GP1(04h)) */ -static uint32_t dma_direction; /* bits 29-30 in GPUSTAT */ - -/* LCF (even/odd line in interlace, toggles per vblank) */ -static uint32_t lcf; /* bit 31 */ - -/* I_STAT — defined in memory.c; declared early so gpu_read_gpustat can - * raise the VBLANK bit when a synthetic vblank fires from a tight - * BIOS-shell GPUSTAT poll loop, without going through the - * vblank-callback path (which would call SDL_Delay/Sleep). */ -extern uint32_t i_stat; -/* Central IRQ-raise choke point (interrupts.c) — also records the device ring. */ -extern void psx_irq_raise(uint32_t bit, uint32_t detail); - -/* Display area start (GP1(05h)) */ -static uint32_t display_area_x; -static uint32_t display_area_y; - -/* ----- Native-wide compositor driving (see runtime/src/gpu_sw_renderer.c) ---- - * The renderer keeps a separate wide surface per framebuffer; we tell it which - * back buffer each draw targets and present from the displayed buffer's surface. - * A "framebuffer base" is a VRAM x-origin the display has scanned out from — we - * learn that set from GP1(05h) so the mirror only engages for real display - * buffers (not offscreen texture builds). Generic: no per-game constants. */ -#define WS_FB_BASES 4 -static uint32_t ws_fb_base[WS_FB_BASES]; -static int ws_fb_n = 0; -static void ws_note_display_base(uint32_t bx) { - for (int i = 0; i < ws_fb_n; i++) if (ws_fb_base[i] == bx) return; - if (ws_fb_n < WS_FB_BASES) { ws_fb_base[ws_fb_n++] = bx; return; } - for (int i = 1; i < WS_FB_BASES; i++) ws_fb_base[i - 1] = ws_fb_base[i]; - ws_fb_base[WS_FB_BASES - 1] = bx; -} -static int ws_is_fb_base(uint32_t bx) { - for (int i = 0; i < ws_fb_n; i++) if (ws_fb_base[i] == bx) return 1; - return 0; -} -/* Point the renderer's wide mirror at the current back buffer (draw_area_left) - * when native-wide is active and that buffer is a known display buffer; else - * disable mirroring for this draw. Called when the draw env changes. */ -static void ws_nw_sync_target(void) { - const int native_wide = ws_native_wide_active(); - /* The corrected presentation mirror is also required at authentic 4:3. - * main.cpp's game-entry gate deliberately considers that mode engaged even - * though this file's ws_engaged() means only "a non-identity wide mode". - * Gating visual-only here on ws_mode therefore left 4:3 correction without - * a surface and sent OpenGL into its CPU fallback at the wrong SSAA scale. */ - const int visual_only = ws_geometry_correction; - if (!native_wide && !visual_only) { - gr_wide_disable_target(); - return; - } - /* Geometry correction deliberately reuses the renderer's independent wide - * surface even in classic squash mode. With zero side offset it becomes a - * same-width presentation mirror: canonical VRAM remains integer-exact, - * while the mirror may consume subpixel vertices and fractional yaw. */ - gr_wide_configure(native_wide ? (int)ws_disp_w() + ws_nw_extra() - : (int)ws_disp_w(), - native_wide ? ws_nw_offset() : 0); - uint32_t base = draw_area_left; - if (ws_is_fb_base(base)) gr_wide_set_target((int)base); - else gr_wide_disable_target(); -} - -extern void gte_geometry_correction_set(int enabled); -extern void gte_precision_tracking_set(int enabled); - -static void geometry_diag_reset(void) { - memset(&s_geometry_diag_cumulative, 0, - sizeof(s_geometry_diag_cumulative)); - memset(&s_geometry_diag_live_current, 0, - sizeof(s_geometry_diag_live_current)); - memset(&s_geometry_diag_live_latest, 0, - sizeof(s_geometry_diag_live_latest)); - s_geometry_diag_presentation_sequence = 0; - s_geometry_diag_corrected_presentations = 0; - s_geometry_diag_latest_live_presentation = 0; - s_geometry_diag_latest_live_valid = 0; - s_geometry_diag_live_current_touched = 0; - gte_precision_diagnostics_reset(); -} - -static int geometry_diag_display_active(void) { - GpuDisplayInfo display; - gpu_get_display_info(&display); - return !display.disabled && display.width != 0u && display.height != 0u; -} - -/* Match main.cpp's corrected-present eligibility using state available at GP0 - * decode time. A known framebuffer target is additionally required because an - * offscreen texture draw cannot reach the independent presentation surface. */ -static int geometry_diag_presentation_live(void) { - return geometry_diag_display_active() && - ws_geometry_correction && - !gpu_ws_present_native_43() && !(display_depth & 1u) && - gr_wide_supported() && ws_is_fb_base(draw_area_left); -} - -static void geometry_diag_finish_presentation(void) { - const int live = geometry_diag_display_active() && - (s_geometry_diag_live_current_touched || - geometry_diag_presentation_live()); - ++s_geometry_diag_presentation_sequence; - if (live) { - s_geometry_diag_live_latest = s_geometry_diag_live_current; - ++s_geometry_diag_corrected_presentations; - s_geometry_diag_latest_live_presentation = - s_geometry_diag_presentation_sequence; - s_geometry_diag_latest_live_valid = 1; - } - memset(&s_geometry_diag_live_current, 0, - sizeof(s_geometry_diag_live_current)); - s_geometry_diag_live_current_touched = 0; -} - -void gpu_geometry_correction_set(int enabled) { - ws_geometry_correction = enabled ? 1 : 0; - ws_geometry_world_triangles = 0; - ws_geometry_precise_triangles = 0; - geometry_diag_reset(); - gte_geometry_correction_set(ws_geometry_correction); - /* Exact committed packet-address provenance is preferred over the fallback - * packed-SXY cache whenever the game stores GTE results into its OT. */ - gte_precision_tracking_set(ws_geometry_correction || - s_texture_correction_enabled); - gr_set_geometry_correction(ws_geometry_correction); - gpu_geometry_camera_yaw_residual_set( - s_geometry_camera_yaw_residual); - ws_nw_sync_target(); -} - -void gpu_geometry_correction_stats(uint64_t *world_triangles, - uint64_t *precise_triangles) { - if (world_triangles) *world_triangles = ws_geometry_world_triangles; - if (precise_triangles) *precise_triangles = ws_geometry_precise_triangles; -} - -void gpu_geometry_correction_stats_detailed( - GpuGeometryCorrectionStats *out, uint32_t out_size) { - if (!out || out_size == 0u) return; - GtePrecisionDiagnostics provenance = { 0 }; - GpuGeometryCorrectionStats snapshot; - gte_precision_diagnostics(&provenance); - memset(&snapshot, 0, sizeof(snapshot)); - snapshot.version = GPU_GEOMETRY_DIAGNOSTICS_VERSION; - snapshot.size = (uint32_t)sizeof(snapshot); - snapshot.presentation_sequence = s_geometry_diag_presentation_sequence; - snapshot.corrected_presentations = s_geometry_diag_corrected_presentations; - snapshot.latest_live_presentation = s_geometry_diag_latest_live_presentation; - snapshot.latest_live_valid = (uint32_t)s_geometry_diag_latest_live_valid; - snapshot.provenance_store_collisions = provenance.store_collisions; - snapshot.provenance_store_evictions = provenance.store_evictions; - snapshot.provenance_lookup_collisions = provenance.lookup_collisions; - snapshot.cumulative = s_geometry_diag_cumulative; - snapshot.latest_live = s_geometry_diag_live_latest; - snapshot.provenance_store_uncommitted_rejections = - provenance.store_uncommitted_rejections; - snapshot.provenance_registered_store_attempts = - provenance.registered_store_attempts; - snapshot.provenance_registered_store_accepts = - provenance.registered_store_accepts; - snapshot.provenance_registered_store_packed_rejections = - provenance.registered_store_packed_rejections; - snapshot.provenance_copy_load_attempts = - provenance.copy_load_attempts; - snapshot.provenance_copy_load_accepts = - provenance.copy_load_accepts; - snapshot.provenance_copy_store_attempts = - provenance.copy_store_attempts; - snapshot.provenance_copy_store_accepts = - provenance.copy_store_accepts; - snapshot.provenance_copy_store_packed_rejections = - provenance.copy_store_packed_rejections; - { - const uint32_t copy_size = out_size < (uint32_t)sizeof(snapshot) - ? out_size : (uint32_t)sizeof(snapshot); - memcpy(out, &snapshot, copy_size); - } -} - -int gpu_geometry_correction_enabled(void) { - return ws_geometry_correction; -} - -static void geometry_camera_presentation_update(void) { - double center = (double)ws_disp_w() * 0.5; - /* Keep Y relative to the drawing band. The GL backend adds its current - * GP0(E5) draw offset after flushing the prior batch, so a framebuffer - * switch after VBlank cannot leave the next frame on the old horizon. */ - const double center_y = - (double)s_geometry_camera_projection_center_y; - double focal = center; - if (ws_mode == 1 && ws_xden != 0) - focal = focal * (double)ws_xnum / (double)ws_xden; - gr_set_presentation_yaw(ws_geometry_correction && - isfinite(s_geometry_camera_yaw_residual) - ? s_geometry_camera_yaw_residual : 0.0, - 256.0, center, center_y, focal); -} - -void gpu_geometry_camera_yaw_residual_set(double yaw_units) { - s_geometry_camera_yaw_residual = yaw_units; - geometry_camera_presentation_update(); -} - -void gpu_geometry_camera_projection_center_y_set(double center_y) { - int next = 120; - if (isfinite(center_y) && center_y >= 24.0 && center_y <= 216.0) - next = (int)floor(center_y + 0.5); - if (next == s_geometry_camera_projection_center_y) return; - - s_geometry_camera_projection_center_y = next; - /* Reissuing the current yaw lands renderer batches under the old horizon - * before the backend adopts the new one. This call is also intentional - * while correction is disabled: it stores the requested shared center but - * supplies an identity yaw to the presentation backend. */ - geometry_camera_presentation_update(); -} - -int gpu_geometry_camera_projection_center_y_get(void) { - return s_geometry_camera_projection_center_y; -} - -static void ws_clear_all_reveal_margins(void) { - for (int i = 0; i < ws_fb_n; i++) - gr_wide_clear_margins((int)ws_fb_base[i], 0, 512, 0, 3); -} - -/* Stage-init already clears both synthetic margins once. Do not keep clearing - * a guessed finite-map side here: MMX6's authored layers enter the reveal at - * different times, so the side guess produced a moving black trim over valid - * stage art. A stale reveal tile is safer than deleting submitted content. */ -void gpu_ws_begin_linked_list(void) { - gp0_ot_rank = 0xFFFFu; - psx_ws_hud_widgets_list(&ws_hud, ws_hud.count && ws_active() ? psx_read_word(ws_hud_table_pointer) : 0u); -} - -void gpu_set_gp0_linked_list_node(uint32_t addr, uint32_t word_count) { - (void)addr; - psx_ws_frame_kinds_list(&ws_frame_kinds, 1); - if (word_count == 0) { - gp0_ot_rank = gp0_ot_rank == 0xFFFFu ? 0u - : (uint16_t)(gp0_ot_rank + 1u); - psx_ws_hud_widgets_node(&ws_hud, addr); - } -} - -void gpu_ws_end_linked_list(void) { - psx_ws_frame_kinds_list(&ws_frame_kinds, 0); - gp0_ot_rank = 0xFFFFu; - psx_ws_hud_widgets_list_end(&ws_hud); -} - - -/* Horizontal display range (GP1(06h)) */ -static uint32_t h_display_x1; -static uint32_t h_display_x2; - -/* Vertical display range (GP1(07h)) */ -static uint32_t v_display_y1; -static uint32_t v_display_y2; - -/* GPUREAD latch (GP1(10h) get-info result, or VRAM read data) */ -static uint32_t gpuread_latch; - -/* C0 (VRAM→CPU) capture slot — forward declaration for gpu_read_gpuread */ -#define C0_HISTORY_CAP_FWD 32 -static struct C0HistEntry { - uint16_t x, y, w, h; - uint32_t func_addr, sp_val, s1_val; - uint32_t first_words[4]; - int read_count; -} c0_history_fwd[C0_HISTORY_CAP_FWD]; -static int c0_history_count_fwd = 0; -static int c0_capture_slot_fwd = -1; - -/* Vblank presentation callback */ -static gpu_vblank_cb vblank_callback; - -/* Shaded quad vertex capture (Phase 4.5 debug) — forward declarations - * so gpu_vblank_tick can reference sq_cap_armed. */ -#define SQ_CAP_MAX 32 -static GpuSqCapEntry sq_cap_buf[SQ_CAP_MAX]; -static int sq_cap_count; -static int sq_cap_armed; - -/* GPUSTAT poll counter: in v4, recompiled code runs as native C without - * per-instruction stepping. The BIOS contains tight VSYNC wait loops that - * poll GPUSTAT bit 31 (LCF) waiting for it to toggle. LCF only changes - * via gpu_vblank_tick(), which normally fires from psx_check_interrupts() - * at dispatch boundaries. A polling loop within a single recompiled - * function would spin forever. - * - * Fix: count GPUSTAT reads and trigger a vblank when the BIOS has polled - * enough times, approximating the real hardware timing where the field - * flips every ~33,868 GPU clocks (one NTSC frame). */ -#define GPUSTAT_POLL_VBLANK_THRESHOLD 1000 -static uint32_t gpustat_poll_count; -uint64_t g_pollhack_vblank_count = 0; /* instrumentation: poll-fallback VBlank IRQ count */ - -/* ---- Initialization ---- */ - -static void gpu_reset_state(int clear_vram) { - if (clear_vram) { - memset(vram, 0, sizeof(vram)); - } - gr_init(vram); - - /* Reset GP0 state machine */ - gp0_state = GP0_IDLE; - gp0_words_collected = 0; - gp0_words_needed = 0; - vram_write_remaining = 0; - vram_read_active = 0; - - /* Reset all state to power-on defaults */ - texpage_x = 0; - texpage_y = 0; - semi_transparency = 0; - texpage_colors = 0; - dither_enabled = 0; - draw_to_display = 0; - set_mask_bit = 0; - check_mask_bit = 0; - interlace_field = 0; - reverse_flag = 0; - texture_disable = 0; - - draw_area_left = 0; - draw_area_top = 0; - draw_area_right = 0; - draw_area_bottom = 0; - draw_offset_x = 0; - draw_offset_y = 0; - texture_window_value = 0; - - hres2 = 0; - hres1 = 0; - vres = 0; - video_mode = 0; - display_depth = 0; - vertical_interlace = 0; - - display_disabled = 1; /* display is OFF after reset */ - irq1_flag = 0; - dma_direction = 0; - lcf = 0; - - display_area_x = 0; - display_area_y = 0; - h_display_x1 = 0x200; - h_display_x2 = 0xC00; - v_display_y1 = 0x010; - v_display_y2 = 0x100; - - gpuread_latch = 0; - gpustat_poll_count = 0; - s_ws_fmv_frame_cache = 0xFFFFFFFFu; - s_ws_fmv_cached = 0; - s_d24_upload_x1 = 0; - s_d24_present_hold = 0; - s_d24_prev_disp_h = 0; - - /* gr_init can retain backend-side draw state across GP1(00h). Keep the - * restored canonical clip and the framebuffer-relative yaw horizon in - * lockstep with the power-on GP0 defaults. */ - gr_set_draw_area((int)draw_area_left, (int)draw_area_top, - (int)draw_area_right, (int)draw_area_bottom); - gr_set_draw_offset(draw_offset_x, draw_offset_y); - s_geometry_camera_yaw_residual = 0.0; - s_geometry_camera_projection_center_y = 120; - geometry_camera_presentation_update(); -} - -void gpu_init(void) { - gpu_reset_state(1); -} - -/* ---- GPUSTAT read (0x1F801814) ---- */ - -uint32_t gpu_read_gpustat(void) { - /* Advance vblank when polled enough times from within a single function. - * This handles BIOS VSYNC wait loops that poll LCF in tight loops. - * - * IMPORTANT: only update emulation state here (LCF + I_STAT). Do NOT - * fire the vblank callback — that calls sdl_vblank_present, which - * runs SDL_RenderPresent and SDL_Delay (Sleep). Entering Sleep from - * an MMIO-read code path means recompiled MIPS code spends real wall - * time inside Sleep — wrong context, hard to reason about, and - * accumulates host stack frames inside the recompiled call tree. - * The callback fires from the proper VBLANK trigger in - * psx_check_interrupts (cycle-paced). */ - gpustat_poll_count++; - if (gpustat_poll_count >= GPUSTAT_POLL_VBLANK_THRESHOLD) { - gpustat_poll_count = 0; - /* The recompiled BIOS + game carry per-block interrupt checks - * (psx_check_interrupts_at) and per-block cycle charging, so tight - * LCF/VSync poll loops DO advance guest cycles and let the ONE - * cycle-paced VBlank authority (interrupts.c) fire on schedule. The old - * "spin forever" premise that justified raising VBlank+LCF from a raw - * GPUSTAT read-count is stale — and it injected ~38 fake VBlanks/s - * (measured on Crash Bash), delivering ~96/s to the game instead of 60, - * over-advancing the game's VSync frame counter and jittering animation - * (character strobe). Firing is now OFF by default; PSX_POLLHACK_VBLANK=1 - * restores the legacy behavior for A/B. */ - static int poll_fire = -1; - if (poll_fire < 0) { - const char* e = getenv("PSX_POLLHACK_VBLANK"); - poll_fire = (e && e[0] == '1') ? 1 : 0; - } - if (poll_fire) { - g_pollhack_vblank_count++; /* count only when actually firing */ - lcf ^= 1; - psx_irq_raise(0, 0); /* IRQ_VBLANK (GPUSTAT-poll fallback path) */ - /* DEQUEUE: VBlank fired via the GPUSTAT-poll fallback path (distinct - * from the cycle-paced VBlank in psx_check_interrupts). */ - event_ring_record_aux(EV_DEQ, (uint8_t)SRC_VBLANK, 0xFFFFFFFFu); - } - } - - uint32_t stat = 0; - - stat |= (texpage_x & 0xF); - stat |= (texpage_y & 1) << 4; - stat |= (semi_transparency & 3) << 5; - stat |= (texpage_colors & 3) << 7; - stat |= (dither_enabled & 1) << 9; - stat |= (draw_to_display & 1) << 10; - stat |= (set_mask_bit & 1) << 11; - stat |= (check_mask_bit & 1) << 12; - stat |= (interlace_field & 1) << 13; - stat |= (reverse_flag & 1) << 14; - stat |= (texture_disable & 1) << 15; - stat |= (hres2 & 1) << 16; - stat |= (hres1 & 3) << 17; - stat |= (vres & 1) << 19; - stat |= (video_mode & 1) << 20; - stat |= (display_depth & 1) << 21; - stat |= (vertical_interlace & 1) << 22; - stat |= (display_disabled & 1) << 23; - stat |= (irq1_flag & 1) << 24; - - /* Bit 25: DMA request — depends on DMA direction. - * Direction 0: always 0 - * Direction 1: FIFO not full (always 1 for now — we process instantly) - * Direction 2: same as bit 28 (ready to receive DMA block) - * Direction 3: same as bit 27 (ready to send VRAM to CPU) - */ - switch (dma_direction) { - case 0: break; /* bit 25 = 0 */ - case 1: stat |= (1u << 25); break; - case 2: stat |= (1u << 25); break; /* mirrors ready-to-receive */ - case 3: stat |= (1u << 25); break; /* mirrors ready-to-send */ - } - - /* Bit 26: ready to receive cmd word — 1 when not busy */ - stat |= (1u << 26); - - /* Bit 27: ready to send VRAM to CPU — 1 when VRAM read is active */ - if (vram_read_active) - stat |= (1u << 27); - - /* Bit 28: ready to receive DMA block — 1 when not busy */ - stat |= (1u << 28); - - /* Bits 29-30: DMA direction */ - stat |= (dma_direction & 3) << 29; - - /* Bit 31: LCF — drawing even/odd lines in interlace mode */ - stat |= (lcf & 1) << 31; - - return stat; -} - -/* ---- GPUREAD (0x1F801810 read) ---- */ - -uint32_t gpu_read_gpuread(void) { - if (!vram_read_active) - return gpuread_latch; - - /* Read two 16-bit pixels from VRAM and pack into one 32-bit word. - * Routed through the renderer facade: under the GL backend the GPU-side - * framebuffer is authoritative and must be synced down before the CPU - * array is read (no-op cost on the software backend). */ - uint32_t value = 0; - for (int i = 0; i < 2; i++) { - uint16_t rx = (vram_read_x + vram_read_col) % 1024; - uint16_t ry = (vram_read_y + vram_read_row) % 512; - value |= (uint32_t)gr_vram_read((int)rx, (int)ry) << (i * 16); - - if (++vram_read_col == vram_read_w) { - vram_read_col = 0; - if (++vram_read_row == vram_read_h) { - /* Transfer complete */ - vram_read_active = 0; - break; - } - } - } - - /* Capture first words for C0 debug */ - if (c0_capture_slot_fwd >= 0 && c0_capture_slot_fwd < C0_HISTORY_CAP_FWD) { - int rc = c0_history_fwd[c0_capture_slot_fwd].read_count++; - if (rc < 4) - c0_history_fwd[c0_capture_slot_fwd].first_words[rc] = value; - if (!vram_read_active) - c0_capture_slot_fwd = -1; /* transfer complete */ - } - - gpuread_latch = value; - return value; -} - -/* ---- GP0 command helpers ---- */ - -/* Convert 24-bit RGB888 to 16-bit RGB555 (PS1 VRAM format) */ -static uint16_t rgb888_to_rgb555(uint32_t color24) { - uint32_t r = (color24 >> 0) & 0xFF; - uint32_t g = (color24 >> 8) & 0xFF; - uint32_t b = (color24 >> 16) & 0xFF; - return (uint16_t)((r >> 3) | ((g >> 3) << 5) | ((b >> 3) << 10)); -} - -/* i_stat extern declared earlier in this file (above gpu_read_gpustat). */ - -void gpu_vblank_tick(void) { - lcf ^= 1; - /* GPUSTAT.13 (interlace FIELD): on real hardware this alternates per - * field while GP1(08h) vertical interlace is on, in antiphase with the - * even/odd-lines bit 31 during active display. Modeled here at vblank - * granularity: field = !LCF while interlaced, pinned to the legacy 0 in - * progressive (GP1(08h) clears it) so titles see identical GPUSTAT. - * First consumer - * is OpenBIOS's shell waitVSync, which polls for the alternating - * (bit31,bit13) = (1,0)/(0,1) pattern on real hardware — under - * PCSX-Redux it never runs this path (pcsx_present() short-circuits to - * the vblank-IRQ wait, and Redux holds bit13 constant at 1), so this - * poll first became reachable in this runtime. */ - if (vertical_interlace) - interlace_field = (lcf ^ 1) & 1; - /* snapshot per-frame draw-offset-Y range for the strobe instrumentation */ - if (g_doff_cnt_this) { - g_doff_min_last = g_doff_min_this; - g_doff_max_last = g_doff_max_this; - g_doff_cnt_last = g_doff_cnt_this; - } - g_doff_min_this = 0x7fffffff; g_doff_max_this = -0x7fffffff; g_doff_cnt_this = 0; - gpustat_poll_count = 0; - /* Latch the just-finished frame before the mouse/mod hook samples the - * diagnostics below. */ - geometry_diag_finish_presentation(); - /* Trusted package-selected plugins run on guest VBlank, independent of - * host presentation, pacing, turbo, or skipped frames. */ - mod_runtime_on_vblank(); - psx_irq_raise(0, 0); /* IRQ_VBLANK (gpu_vblank_tick) */ - if (vblank_callback) vblank_callback(); -} - -const uint16_t* gpu_get_vram(void) { - return vram; -} - -static uint8_t gpu_vram_byte(uint32_t byte_x, uint32_t y) { - uint16_t hw = vram[((y & 511u) * 1024u) + ((byte_x >> 1) & 1023u)]; - return (byte_x & 1u) ? (uint8_t)(hw >> 8) : (uint8_t)hw; -} - -/* Depth24: note/query/reset the CPU→VRAM upload span tracked above. Used to - * hide trailing RGB columns when a movie blit doesn't fill the full CRTC - * width — MotK's Star Wars crawl leaves ~8px of stale VRAM on the right. - * Only FB-class A0s (w >= 256 halfwords) grow the span; texture uploads must - * not collapse it. During present-hold, ignore updates entirely. */ -static void depth24_note_upload(uint32_t x, uint32_t w) { - if (!(display_depth & 1u) || w < 256u) return; - if (s_d24_present_hold > 0) return; - uint32_t x1 = x + w; - if (x1 > 1024u) x1 = 1024u; - if (x1 > s_d24_upload_x1) s_d24_upload_x1 = x1; -} - -uint32_t gpu_depth24_rgb_limit(uint32_t display_x, uint32_t crtc_w) { - if (!(display_depth & 1u) || s_d24_upload_x1 == 0u || crtc_w == 0u) - return crtc_w; - uint32_t dx = display_x & 1023u; - if (s_d24_upload_x1 <= dx) return crtc_w; - uint32_t hw = s_d24_upload_x1 - dx; - uint32_t rgb = (hw * 2u) / 3u; - if (rgb == 0u || rgb >= crtc_w) return crtc_w; - return rgb; -} - -void gpu_depth24_upload_span_reset(void) { - s_d24_upload_x1 = 0; -} - -int gpu_depth24_present_hold_tick(void) { - if (s_d24_present_hold <= 0) return 0; - s_d24_present_hold--; - return 1; -} - -/* ---- Present-time screen-colour LUT (verified-enhancement, opt-in) ------- - * - * PRESENT-TIME ONLY. This sits on the 15-bit-scanout -> RGB888 conversion that - * feeds the SDL/GL present path. It never touches VRAM and never runs on the - * depth24 (FMV) scanout (see gpu_display_pixel_rgb). It defaults to SCREEN_RAW - * (the original exact 5->3-replicated expansion below), so with the feature off - * the conversion — and therefore every hashed/oracle-diffed frame — is - * byte-identical to upstream. Opt in via PSX_SCREEN={crt,composite,trinitron}; - * any other value (or unset) keeps the raw path. - * - * Built lazily on first use because env is read once and the GPU has no - * settings plumb yet. The raw fast-path below is preserved verbatim so the - * default never even consults the LUT. */ -static ColorLut* s_screen_lut = NULL; -static int s_screen_lut_init = 0; -static int s_screen_kind_cfg = SCREEN_RAW; /* config/launcher-set; env overrides */ - -void gpu_set_screen_kind(int kind) { - if (kind < SCREEN_RAW || kind > SCREEN_TRINITRON) kind = SCREEN_RAW; - if (kind == s_screen_kind_cfg) return; - s_screen_kind_cfg = kind; - s_screen_lut_init = 0; /* rebuild on next scanout */ -} - -static void screen_lut_ensure(void) { - if (s_screen_lut_init) return; - s_screen_lut_init = 1; - if (s_screen_lut) { color_lut_destroy(s_screen_lut); s_screen_lut = NULL; } - /* Precedence: PSX_SCREEN env (debug override) wins if set+valid; otherwise - * the config/launcher value. Default (no env, raw config) = passthrough. */ - ScreenKind kind = (ScreenKind)s_screen_kind_cfg; - const char* name = getenv("PSX_SCREEN"); - ScreenKind envk; - if (name && screen_kind_from_name(name, &envk)) kind = envk; - if (kind == SCREEN_RAW) { - s_screen_lut = NULL; /* raw fast-path; passthrough */ - return; - } - ColorSettings s; - s.screen = kind; - s.darken = -1.0; /* per-screen default */ - s.target = DISPLAY_SRGB; - s_screen_lut = color_lut_create(&s); - if (s_screen_lut && color_lut_is_passthrough(s_screen_lut)) { - color_lut_destroy(s_screen_lut); - s_screen_lut = NULL; - } -} - -static void gpu_rgb555_to_rgb888(uint16_t c, uint8_t* r, uint8_t* g, uint8_t* b) { - screen_lut_ensure(); - if (s_screen_lut) { - color_lut_map555(s_screen_lut, c, r, g, b); - return; - } - /* Default raw path — byte-identical to upstream. */ - *r = (uint8_t)((c & 0x1Fu) << 3); - *g = (uint8_t)(((c >> 5) & 0x1Fu) << 3); - *b = (uint8_t)(((c >> 10) & 0x1Fu) << 3); -} - -void gpu_display_pixel_rgb(const GpuDisplayInfo* di, uint32_t x, uint32_t y, - uint8_t* r, uint8_t* g, uint8_t* b) { - if (di->depth24) { - uint32_t byte_x = ((di->display_x & 1023u) * 2u) + x * 3u; - uint32_t vy = (di->display_y + y) & 511u; - /* No horizontal wrap for 24-bit DAC scanout: bytes past the 2048-byte - * VRAM row are blank on hardware / accurate emulators, not sheared - * from X=0. Wrapping here produced a flickering right-edge strip. */ - if (byte_x + 2u >= 2048u) { - *r = *g = *b = 0; - return; - } - *r = gpu_vram_byte(byte_x + 0u, vy); - *g = gpu_vram_byte(byte_x + 1u, vy); - *b = gpu_vram_byte(byte_x + 2u, vy); - return; - } - - uint32_t vx = (di->display_x + x) & 1023u; - uint32_t vy = (di->display_y + y) & 511u; - gpu_rgb555_to_rgb888(vram[vy * 1024u + vx], r, g, b); -} - -uint32_t gpu_display_pixel_argb(const GpuDisplayInfo* di, uint32_t x, uint32_t y) { - uint8_t r, g, b; - gpu_display_pixel_rgb(di, x, y, &r, &g, &b); - return 0xFF000000u | ((uint32_t)r << 16) | ((uint32_t)g << 8) | (uint32_t)b; -} - -int gpu_display_is_depth24(void) { - return (int)(display_depth & 1u); -} - -void gpu_get_display_info(GpuDisplayInfo* out) { - out->display_x = display_area_x; - out->display_y = display_area_y; - out->depth24 = (int)(display_depth & 1u); - out->disabled = (int)display_disabled; - - /* Dot-clock divider from GP1(08h) hres (psx-spx / DuckStation). */ - uint32_t cycles; - uint32_t mode_w; - if (hres2) { - cycles = 7; - mode_w = 368; - } else { - static const uint32_t cyc_lut[4] = { 10, 8, 5, 4 }; - static const uint32_t hres_lut[4] = { 256, 320, 512, 640 }; - cycles = cyc_lut[hres1 & 3]; - mode_w = hres_lut[hres1 & 3]; - } - - /* Visible pixel width from GP1(06h): (((X2-X1)/cycles)+2) & ~3 (psx-spx). - * Fall back to the mode width when the range is unset/degenerate. */ - uint32_t dots = (h_display_x2 > h_display_x1) ? (h_display_x2 - h_display_x1) : 0; - uint32_t w = mode_w; - if (dots > 0u && cycles > 0u) { - w = ((dots / cycles) + 2u) & ~3u; - if (w == 0u) w = 4u; - } - - uint32_t h = (v_display_y2 > v_display_y1) ? (v_display_y2 - v_display_y1) : 240; - if (vres) h *= 2; /* 480i */ - - /* 24-bit scanout uses the same CRTC pixel width as 15-bit (DuckStation / - * Beetle: coordinates stay 16-bit-based; W RGB occupies W*3/2 halfwords). - * MotK FMV: GP1(06h) yields 512; the logo is centered in that RGB line. - * A blanket (W*2)/3 (512→341) left-shifts the frame and clips the right - * of the video — do not reintroduce it. Right-edge junk is a separate - * present/filter issue, not a reason to shrink CRTC width. */ - - /* Clamp to sane maximums */ - if (w > 640) w = 640; - if (h > 512) h = 512; - - out->width = w; - out->height = h; -} - -/* Debug accessors for GP1 display-range / mode (TCP gpu_state). */ -void gpu_get_crtc_debug(uint32_t *x1, uint32_t *x2, uint32_t *y1, uint32_t *y2, - uint32_t *hres1_out, uint32_t *hres2_out) { - if (x1) *x1 = h_display_x1; - if (x2) *x2 = h_display_x2; - if (y1) *y1 = v_display_y1; - if (y2) *y2 = v_display_y2; - if (hres1_out) *hres1_out = hres1; - if (hres2_out) *hres2_out = hres2; -} - -void gpu_set_vblank_callback(gpu_vblank_cb cb) { - vblank_callback = cb; -} - -/* Sign-extend an N-bit value to int32_t */ -static int32_t sign_extend(uint32_t val, int bits) { - uint32_t sign_bit = 1u << (bits - 1); - return (int32_t)((val ^ sign_bit) - sign_bit); -} - -/* ---- Polygon rasterizer ---- */ - -/* Parse a vertex position word: signed 11-bit X and Y */ -static void parse_vertex(uint32_t word, int32_t* x, int32_t* y) { - *x = sign_extend(word & 0x7FFu, 11); - *y = sign_extend((word >> 16) & 0x7FFu, 11); -} - -extern int gte_geometry_correction_enabled(void); -extern void gte_precision_tracking_set(int enabled); - -void gpu_texture_correction_set(int enabled) { - s_texture_correction_enabled = enabled ? 1 : 0; - s_texture_correction_triangles = 0; - gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); - gte_precision_tracking_set(s_texture_correction_enabled || - ws_geometry_correction); -} - -int gpu_texture_correction_enabled(void) { - return s_texture_correction_enabled; -} - -uint32_t gpu_texture_correction_hits(void) { - return s_texture_correction_triangles; -} - -static int gp0_source_word_address(int word_index, uint32_t *out) { - const uint32_t delta = (uint32_t)word_index * 4u; - if (gp0_cmd_source_addr == 0xFFFFFFFFu || - gp0_cmd_source_addr > UINT32_MAX - delta) - return 0; - *out = gp0_cmd_source_addr + delta; - return 1; -} - -static int32_t fixed16_integer_floor(int32_t value) { - const int64_t wide = value; - if (wide >= 0) return (int32_t)(wide / 65536); - return (int32_t)(-(((-wide) + 65535) / 65536)); -} - -static uint64_t abs_i64_u64(int64_t value) { - return (uint64_t)(value < 0 ? -value : value); -} - -static uint32_t geometry_primitive_class(uint8_t opcode) { - const uint32_t quad = (opcode & 0x08u) ? 4u : 0u; - const uint32_t gouraud = (opcode & 0x10u) ? 2u : 0u; - const uint32_t textured = (opcode & 0x04u) ? 1u : 0u; - return quad | gouraud | textured; -} - -static GpuGeometryRejectReason geometry_reject_from_lookup( - GtePrecisionLookupResult result) { - switch (result) { - case GTE_PRECISION_LOOKUP_TRACKING_DISABLED: - return GPU_GEOMETRY_REJECT_TRACKING_DISABLED; - case GTE_PRECISION_LOOKUP_SPECULATIVE: - return GPU_GEOMETRY_REJECT_SPECULATIVE_LOOKUP; - case GTE_PRECISION_LOOKUP_SOURCE_NOT_RAM: - return GPU_GEOMETRY_REJECT_SOURCE_NOT_RAM; - case GTE_PRECISION_LOOKUP_SOURCE_UNALIGNED: - return GPU_GEOMETRY_REJECT_SOURCE_UNALIGNED; - case GTE_PRECISION_LOOKUP_STORE_MISS: - return GPU_GEOMETRY_REJECT_STORE_MISS; - case GTE_PRECISION_LOOKUP_STORE_COLLISION: - return GPU_GEOMETRY_REJECT_STORE_COLLISION; - case GTE_PRECISION_LOOKUP_PROJECTION_INVALID: - return GPU_GEOMETRY_REJECT_PROJECTION_INVALID; - case GTE_PRECISION_LOOKUP_PACKED_MISMATCH: - return GPU_GEOMETRY_REJECT_PACKED_MISMATCH; - case GTE_PRECISION_LOOKUP_ZERO_DEPTH: - return GPU_GEOMETRY_REJECT_ZERO_DEPTH; - case GTE_PRECISION_LOOKUP_SATURATED: - return GPU_GEOMETRY_REJECT_SATURATED; - case GTE_PRECISION_LOOKUP_PERSPECTIVE_INVALID: - return GPU_GEOMETRY_REJECT_PROJECTION_INVALID; - case GTE_PRECISION_LOOKUP_ACCEPTED: - case GTE_PRECISION_LOOKUP_RESULT_COUNT: - default: - return GPU_GEOMETRY_REJECT_PROJECTION_INVALID; - } -} - -static void geometry_diag_note_candidate( - GpuGeometryCorrectionCounters *stats, int count, int triangle_count, - uint32_t primitive_class, uint32_t opcode_index) { - if (!stats) return; - stats->triangle_candidates += (uint64_t)triangle_count; - ++stats->polygon_candidates; - stats->vertex_candidates += (uint64_t)count; - ++stats->primitive_candidates[primitive_class]; - ++stats->opcode_candidates[opcode_index]; -} - -static void geometry_diag_note_rejection( - GpuGeometryCorrectionCounters *stats, - GpuGeometryRejectReason reason, uint64_t count) { - if (stats) stats->vertex_rejections[(uint32_t)reason] += count; -} - -/* Axis-aligned textured quads use a rectangle renderer and never consume the - * per-triangle precision metadata. Keep them visible in the coverage - * denominator as an explicit, safe canonical fallback. */ -static void geometry_diag_note_rectangle_fast_path(void) { - if (!gte_geometry_correction_enabled()) return; - const uint8_t opcode = (uint8_t)(gp0_cmd_buf[0] >> 24); - const uint32_t opcode_index = (uint32_t)(opcode - 0x20u) & 31u; - const uint32_t primitive_class = geometry_primitive_class(opcode); - GpuGeometryCorrectionCounters *live = NULL; - if (geometry_diag_presentation_live()) { - live = &s_geometry_diag_live_current; - s_geometry_diag_live_current_touched = 1; - } - geometry_diag_note_candidate(&s_geometry_diag_cumulative, 4, 2, - primitive_class, opcode_index); - geometry_diag_note_candidate(live, 4, 2, - primitive_class, opcode_index); - geometry_diag_note_rejection( - &s_geometry_diag_cumulative, - GPU_GEOMETRY_REJECT_RECTANGLE_FAST_PATH, 4); - geometry_diag_note_rejection( - live, GPU_GEOMETRY_REJECT_RECTANGLE_FAST_PATH, 4); -} - -static uint32_t geometry_depth_bucket(uint16_t z) { - if (z < 256u) return 0; - if (z < 1024u) return 1; - if (z < 4096u) return 2; - if (z < 16384u) return 3; - if (z < 32768u) return 4; - return 5; -} - -static uint32_t geometry_screen_bucket(int32_t x, int32_t y) { - const int32_t width = ws_disp_w(); - const int32_t height = ws_disp_h(); - if (x < 0 || y < 0 || x >= width || y >= height) - return 9; - int32_t column = width > 0 ? (x * 3) / width : 0; - int32_t row = height > 0 ? (y * 3) / height : 0; - if (column > 2) column = 2; - if (row > 2) row = 2; - return (uint32_t)(row * 3 + column); -} - -static uint32_t geometry_correction_bucket(uint64_t magnitude16) { - if (magnitude16 == 0) return 0; - if (magnitude16 < 16384u) return 1; - if (magnitude16 < 32768u) return 2; - if (magnitude16 < 49152u) return 3; - return 4; -} - -static void geometry_diag_saturating_add( - GpuGeometryCorrectionCounters *stats, - uint64_t *destination, uint64_t value) { - if (UINT64_MAX - *destination < value) { - *destination = UINT64_MAX; - stats->correction_accumulators_saturated = 1; - } else { - *destination += value; - } -} - -static void geometry_diag_note_accepted( - GpuGeometryCorrectionCounters *stats, int count, int triangle_count, - uint32_t primitive_class, uint32_t opcode_index, - const int32_t *precise_x, const int32_t *precise_y, - const int32_t *raw_x, const int32_t *raw_y, - const int32_t *screen_x, const int32_t *screen_y, - const uint16_t *z) { - if (!stats) return; - stats->triangle_accepted += (uint64_t)triangle_count; - ++stats->polygon_accepted; - stats->vertex_accepted += (uint64_t)count; - ++stats->primitive_accepted[primitive_class]; - ++stats->opcode_accepted[opcode_index]; - for (int i = 0; i < count; ++i) { - const int64_t dx = (int64_t)precise_x[i] - - (int64_t)raw_x[i] * 65536; - const int64_t dy = (int64_t)precise_y[i] - - (int64_t)raw_y[i] * 65536; - const uint64_t ax = abs_i64_u64(dx); - const uint64_t ay = abs_i64_u64(dy); - const uint64_t magnitude = ax > ay ? ax : ay; - ++stats->depth_vertices[geometry_depth_bucket(z[i])]; - ++stats->screen_vertices[ - geometry_screen_bucket(screen_x[i], screen_y[i])]; - ++stats->correction_vertices; - geometry_diag_saturating_add( - stats, &stats->correction_abs_x16_sum, ax); - geometry_diag_saturating_add( - stats, &stats->correction_abs_y16_sum, ay); - geometry_diag_saturating_add( - stats, &stats->correction_magnitude16_sum, magnitude); - if (magnitude > stats->correction_magnitude16_max) - stats->correction_magnitude16_max = magnitude; - geometry_diag_saturating_add( - stats, &stats->correction_x16_squared_sum, ax * ax); - geometry_diag_saturating_add( - stats, &stats->correction_y16_squared_sum, ay * ay); - ++stats->correction_magnitude_buckets[ - geometry_correction_bucket(magnitude)]; - } -} - -/* Resolve vertices only through the exact RAM address at which a reviewed GTE - * projection store committed that result. A rounded packed SXY value is not a - * unique identity: many - * unrelated 3D vertices can land on the same PS1 pixel. Test 11's packed-SXY - * fallback therefore paired polygons with unrelated projections and opened - * cracks between adjacent primitives. Missing provenance is deliberately a - * canonical-render fallback for the whole polygon. The integer delta folds in - * draw offsets and any widescreen adjustment already applied. */ -/* Valid only for the polygon most recently resolved below. Both halves of a - * quad use their original indices; shader q remains a separate attribute. */ -#ifndef PSX_DISABLE_FRAME_INTERPOLATION -static uint16_t s_precise_vertex_depth[4]; -static uint16_t s_precise_vertex_surface[4]; /* the same with a pinned corner's depth kept: for the mirror's depth */ -static uint64_t s_precise_vertex_identity[4]; -static int s_precise_vertex_partial; -#endif -/* A polygon that falls back still draws the corners the game pinned where they project: its neighbours do. */ -static PsxPinnedPlaces s_pinned_places; -/* The polygon resolved last is the game's grown second copy of the one before it. */ -static int s_precise_grown; -#if defined(PSX_HAS_DISRUPTOR_GROWN_QUAD) && !defined(PSX_DISABLE_FRAME_INTERPOLATION) -/* A grown corner lies off its source corner, on the same plane: its depth is the plane's there, 1/z being linear on screen. */ -static void grown_corner_depths(const int32_t *source_x, const int32_t *source_y, const int32_t *raw_x, const int32_t *raw_y, - const int *indices) { - static const int beside[4][2] = {{1, 2}, {0, 3}, {0, 3}, {1, 2}}; - uint16_t moved[4]; - for (int i = 0; i < 4; ++i) { - const int j = beside[i][0], k = beside[i][1]; - const double zi = s_precise_vertex_depth[i], zj = s_precise_vertex_depth[j], zk = s_precise_vertex_depth[k]; - const double ax = ((double)source_x[j] - source_x[i]) / 65536.0, ay = ((double)source_y[j] - source_y[i]) / 65536.0; - const double bx = ((double)source_x[k] - source_x[i]) / 65536.0, by = ((double)source_y[k] - source_y[i]) / 65536.0; - const double det = ax * by - bx * ay; - int32_t grown_x, grown_y; - moved[i] = s_precise_vertex_depth[i]; - if (zi <= 0.0 || zj <= 0.0 || zk <= 0.0 || (det < 1.0e-6 && det > -1.0e-6)) continue; - parse_vertex(gp0_cmd_buf[indices[i]], &grown_x, &grown_y); - { - const double ga = 1.0 / zj - 1.0 / zi, gb = 1.0 / zk - 1.0 / zi; - const double px = (double)grown_x - raw_x[i], py = (double)grown_y - raw_y[i]; - const double g = 1.0 / zi + ((ga * by - gb * ay) * px + (ax * gb - bx * ga) * py) / det; - const double z = g > 1.0 / 65535.0 ? 1.0 / g : 65535.0; - if (z >= 17.0) moved[i] = (uint16_t)(z + 0.5); /* 16 and nearer reads as no depth: the corner keeps its source's */ - } - } - memcpy(s_precise_vertex_depth, moved, sizeof(moved)); -} -#endif -#ifdef PSX_HAS_DISRUPTOR_GROWN_QUAD -extern int disruptor_grown_quad_sources( - uint32_t command_address, const uint32_t *packet, - uint32_t source_address[4], uint32_t source_word[4]); -#endif -static int resolve_precise_vertices(const int *indices, int count, - int triangle_count, - const int32_t *vx, const int32_t *vy, - int32_t *fx, int32_t *fy) { - s_precise_grown = 0; -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - memset(s_precise_vertex_depth, 0, sizeof(s_precise_vertex_depth)); - memset(s_precise_vertex_surface, 0, sizeof(s_precise_vertex_surface)); - memset(s_precise_vertex_identity, 0, sizeof(s_precise_vertex_identity)); - s_precise_vertex_partial = 0; -#endif - s_pinned_places.unpinned = 0; - if (!gte_geometry_correction_enabled()) - return 0; - - const uint8_t opcode = (uint8_t)(gp0_cmd_buf[0] >> 24); - const uint32_t opcode_index = (uint32_t)(opcode - 0x20u) & 31u; - const uint32_t primitive_class = geometry_primitive_class(opcode); - GpuGeometryCorrectionCounters *live = NULL; - if (geometry_diag_presentation_live()) { - live = &s_geometry_diag_live_current; - s_geometry_diag_live_current_touched = 1; - } - geometry_diag_note_candidate(&s_geometry_diag_cumulative, count, - triangle_count, - primitive_class, opcode_index); - geometry_diag_note_candidate(live, count, triangle_count, - primitive_class, opcode_index); - - if (gp0_cmd_source_addr == 0xFFFFFFFFu) { - geometry_diag_note_rejection(&s_geometry_diag_cumulative, - GPU_GEOMETRY_REJECT_NO_COMMAND_SOURCE, - (uint64_t)count); - geometry_diag_note_rejection(live, - GPU_GEOMETRY_REJECT_NO_COMMAND_SOURCE, - (uint64_t)count); - return 0; - } - - int32_t precise_x[4], precise_y[4], raw_x[4], raw_y[4]; - uint16_t z[4]; - int resolved = 0; - int pinned[4] = {0}; - const int unpin = gte_precision_unpin_enabled(); - uint32_t grown_addr[4] = {0}, grown_word[4] = {0}; -#ifdef PSX_HAS_DISRUPTOR_GROWN_QUAD - /* Disruptor's second, grown copy of a world quad stands on the first packet's corners. */ - const int grown = count == 4 && disruptor_grown_quad_sources( - gp0_cmd_source_addr, gp0_cmd_buf, grown_addr, grown_word); -#else - const int grown = 0; -#endif - - for (int i = 0; i < count; ++i) { - const uint32_t word = grown ? grown_word[i] : gp0_cmd_buf[indices[i]]; - uint32_t addr = grown_addr[i]; - GpuGeometryRejectReason reject; - int accepted = 0; - if (!grown && !gp0_source_word_address(indices[i], &addr)) { - reject = GPU_GEOMETRY_REJECT_SOURCE_ADDRESS_OVERFLOW; - } else { - const GtePrecisionLookupResult result = - gte_precision_load_word_ex(addr, word, - &precise_x[i], &precise_y[i], &z[i]); - if (result != GTE_PRECISION_LOOKUP_ACCEPTED) { - reject = geometry_reject_from_lookup(result); - } else { - parse_vertex(word, &raw_x[i], &raw_y[i]); - /* This test happens on the retained raw projection. Draw - * offsets and widescreen transforms in vx/vy must not hide a - * saturated or otherwise non-subpixel discrepancy. */ - /* A corner the game pinned to its guard band is drawn where it projects. */ - pinned[i] = gte_precision_word_clamped(addr, word); - if ((fixed16_integer_floor(precise_x[i]) != raw_x[i] || - fixed16_integer_floor(precise_y[i]) != raw_y[i]) && - !pinned[i]) { - reject = GPU_GEOMETRY_REJECT_INTEGER_MISMATCH; - } else { - accepted = 1; - ++resolved; -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - s_precise_vertex_depth[i] = psx_pinned_corner_depth(unpin, pinned[i], z[i]); - s_precise_vertex_surface[i] = z[i]; -#endif - } - } - } - if (!accepted) { - geometry_diag_note_rejection(&s_geometry_diag_cumulative, - reject, 1); - geometry_diag_note_rejection(live, reject, 1); - } - } - - if (resolved != count) { - if (resolved != 0) { - geometry_diag_note_rejection( - &s_geometry_diag_cumulative, - GPU_GEOMETRY_REJECT_ATOMIC_FALLBACK, (uint64_t)resolved); - geometry_diag_note_rejection( - live, GPU_GEOMETRY_REJECT_ATOMIC_FALLBACK, - (uint64_t)resolved); - ++s_geometry_diag_cumulative.partial_polygon_rejections; - if (live) ++live->partial_polygon_rejections; - if (count == 4) { - ++s_geometry_diag_cumulative.partial_quad_rejections; - if (live) ++live->partial_quad_rejections; - } -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - s_precise_vertex_partial = 1; -#endif - psx_pinned_places(count, unpin, pinned, precise_x, precise_y, raw_x, raw_y, vx, vy, &s_pinned_places); - } - return 0; - } - - geometry_diag_note_accepted(&s_geometry_diag_cumulative, count, - triangle_count, - primitive_class, opcode_index, - precise_x, precise_y, raw_x, raw_y, - raw_x, raw_y, z); - geometry_diag_note_accepted(live, count, triangle_count, - primitive_class, opcode_index, - precise_x, precise_y, raw_x, raw_y, - raw_x, raw_y, z); - for (int i = 0; i < count; ++i) { -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - s_precise_vertex_depth[i] = psx_pinned_corner_depth(unpin, pinned[i], z[i]); - s_precise_vertex_surface[i] = z[i]; - { - uint32_t identity_addr; - /* A grown corner is off its model point: it gets the depth and no identity. */ - if (!grown && gp0_source_word_address(indices[i], &identity_addr)) - (void)gte_precision_load_identity(identity_addr, gp0_cmd_buf[indices[i]], - &s_precise_vertex_identity[i]); - } -#endif - fx[i] = (int32_t)((int64_t)precise_x[i] + - (int64_t)(vx[i] - raw_x[i]) * 65536); - fy[i] = (int32_t)((int64_t)precise_y[i] + - (int64_t)(vy[i] - raw_y[i]) * 65536); - } - s_precise_grown = grown; -#if defined(PSX_HAS_DISRUPTOR_GROWN_QUAD) && !defined(PSX_DISABLE_FRAME_INTERPOLATION) - if (grown) { - grown_corner_depths(precise_x, precise_y, raw_x, raw_y, indices); - memcpy(s_precise_vertex_surface, s_precise_vertex_depth, sizeof(s_precise_vertex_surface)); - } -#endif - return 1; -} - -/* Queue one triangle's one-shot presentation metadata. Fractional yaw uses the - * same exact-provenance gate as subpixel positions, so CPU-built 2D polygons - * and incomplete world packets remain completely canonical. */ -static void queue_precise_triangle(int exact, - const int32_t *fx, const int32_t *fy, - int a, int b, int c) { - const int geometry_enabled = gte_geometry_correction_enabled(); - gr_set_world_triangle(0); -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - gr_set_temporal_depth_triangle(0, 0.0f, 0.0f, 0.0f); - gr_set_temporal_identity_triangle(0u, 0u, 0u); - gr_set_surface_depth_triangle(0.0f, 0.0f, 0.0f); -#endif - gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); - if (geometry_enabled) ++ws_geometry_world_triangles; - if (!geometry_enabled || !exact) { - gr_set_precise_triangle(0, 0,0, 0,0, 0,0); - if (geometry_enabled && s_pinned_places.unpinned) - gr_set_unpinned_triangle(s_pinned_places.x16[a], s_pinned_places.y16[a], s_pinned_places.x16[b], s_pinned_places.y16[b], - s_pinned_places.x16[c], s_pinned_places.y16[c]); -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - /* The corners that did resolve keep their depth: an in-between frame moves them with the camera. */ - if (geometry_enabled && s_precise_vertex_partial) - gr_set_temporal_depth_triangle(2, - (float)s_precise_vertex_depth[a], - (float)s_precise_vertex_depth[b], - (float)s_precise_vertex_depth[c]); - if (geometry_enabled && s_precise_vertex_partial) - gr_set_surface_depth_triangle((float)s_precise_vertex_surface[a], (float)s_precise_vertex_surface[b], - (float)s_precise_vertex_surface[c]); -#endif - return; - } - ++ws_geometry_precise_triangles; - gr_set_world_triangle(1); - if (s_precise_grown) gr_set_world_triangle(2); /* the game's grown second copy */ -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - gr_set_temporal_depth_triangle(psx_pinned_depth_mode(s_precise_vertex_depth[a], s_precise_vertex_depth[b], s_precise_vertex_depth[c]), - (float)s_precise_vertex_depth[a], - (float)s_precise_vertex_depth[b], - (float)s_precise_vertex_depth[c]); - gr_set_temporal_identity_triangle(s_precise_vertex_identity[a], - s_precise_vertex_identity[b], - s_precise_vertex_identity[c]); - gr_set_surface_depth_triangle((float)s_precise_vertex_surface[a], (float)s_precise_vertex_surface[b], - (float)s_precise_vertex_surface[c]); -#endif - gr_set_precise_triangle(1, - fx[a], fy[a], fx[b], fy[b], fx[c], fy[c]); -} - -static int prepare_precise_triangle(int i0, int i1, int i2, - const int32_t vx[3], const int32_t vy[3]) { - const int indices[3] = { i0, i1, i2 }; - int32_t fx[3], fy[3]; - const int exact = resolve_precise_vertices( - indices, 3, 1, vx, vy, fx, fy); - queue_precise_triangle(exact, fx, fy, 0, 1, 2); - return exact; -} - -/* Resolve a GP0 quad as one unit before either of its raster triangles is - * submitted. This prevents a partially tracked quad from correcting only one - * side of its shared diagonal. */ -static int prepare_precise_quad_vertices(const int indices[4], - int triangle_count, - const int32_t vx[4], - const int32_t vy[4], - int32_t fx[4], int32_t fy[4]) { - return resolve_precise_vertices( - indices, 4, triangle_count, vx, vy, fx, fy); -} - -/* Resolve projective depth as one polygon-wide transaction. Geometry's exact - * verdict is a prerequisite: this keeps both halves of a quad affine whenever - * any one of its four vertices falls back, and excludes CPU-built UI. */ -static int resolve_texture_vertices(int exact, const int *indices, int count, - float *q) { - if (!exact || !s_texture_correction_enabled || - gp0_cmd_source_addr == 0xFFFFFFFFu || count < 3 || count > 4) - return 0; - uint16_t z[4]; - for (int i = 0; i < count; i++) { - uint32_t addr; - if (!gp0_source_word_address(indices[i], &addr) || - gte_precision_load_perspective_word_ex( - addr, gp0_cmd_buf[indices[i]], &z[i]) != - GTE_PRECISION_LOOKUP_ACCEPTED) - return 0; - } - for (int i = 0; i < count; ++i) q[i] = 1.0f / (float)z[i]; - float qmax = q[0]; - for (int i = 1; i < count; ++i) - if (q[i] > qmax) qmax = q[i]; - if (qmax <= 0.0f) return 0; - for (int i = 0; i < count; ++i) q[i] /= qmax; - return 1; -} - -static void queue_texture_triangle(int exact, const float *q, - int a, int b, int c) { - if (!exact) { - gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); - return; - } - gr_set_perspective_triangle(1, q[a], q[b], q[c]); - if (s_texture_correction_triangles != UINT32_MAX) - ++s_texture_correction_triangles; -} - -/* Triangle wrapper; quads preflight all four vertices once and queue their two - * reciprocal-depth orderings explicitly below. */ -static void prepare_texture_triangle(int exact, int i0, int i1, int i2) { - const int indices[3] = { i0, i1, i2 }; - float q[3]; - const int perspective = resolve_texture_vertices(exact, indices, 3, q); - queue_texture_triangle(perspective, q, 0, 1, 2); -} - -/* Write a single pixel to VRAM with draw area clipping and mask bit handling */ -static void raster_pixel(int32_t x, int32_t y, uint16_t color) { - if (x < (int32_t)draw_area_left || x > (int32_t)draw_area_right) return; - if (y < (int32_t)draw_area_top || y > (int32_t)draw_area_bottom) return; - uint32_t vx = (uint32_t)x & 1023u; - uint32_t vy = (uint32_t)y & 511u; - uint32_t idx = vy * 1024 + vx; - if (check_mask_bit && (vram[idx] & 0x8000u)) return; - vram[idx] = color | (set_mask_bit ? 0x8000u : 0u); -} - -/* Inclusive draw-area reject (same predicate as raster_pixel / hardware clip). - * - * MotK inter-movie / title / char-select OT drains often set GP0(E3/E4) to - * (0,0)-(0,0) then submit thousands of 1x1 dots and shaded quads. Real GPU - * clips those for free; our GL path was building two triangles per clipped - * prim (gpu_share ~0.9, host FPS ~5–10). Skip the host rasterizer when the - * post-offset bbox cannot touch the draw area. Side effects that must still - * run (texpage latch, oversize reject) happen before these checks. - * - * Native-wide is intentionally different in X: the mirror renderer translates - * canonical framebuffer coordinates by the live reveal offset and scissors to - * the FULL wide surface, not GP0(E3/E4)'s 4:3 X range. Commit 31015ce originally - * compared every primitive only with the guest draw area here, rejecting the - * margin geometry before the mirror renderer could see it. Match the mirror's - * exact X extent while its framebuffer target is active; Y remains the guest - * draw area because native-wide does not extend vertically. At 4:3, in FMV/ - * menus, in squash mode, and for offscreen texture targets, margin is zero and - * this remains the original fast reject byte-for-byte. - * - * OpokXeno independently identified the same host-side regression on Xenogears - * and contributed the original generalized fix in psxrecomp PR #73: - * https://github.com/mstan/psxrecomp/pull/73 - * Keep that credit with this guarded framebuffer-target variant. */ -static inline int32_t draw_area_wide_x_margin(void) { - if (!ws_native_wide_active() || !ws_is_fb_base(draw_area_left)) return 0; - return (int32_t)ws_nw_offset(); -} - -static inline void draw_area_host_x_bounds(int32_t *left, int32_t *right) { - int32_t margin = draw_area_wide_x_margin(); - *left = (int32_t)draw_area_left; - *right = (int32_t)draw_area_right; - if (margin > 0) { - /* Use the union of the guest draw area and the widescreen mirror. - * Wider staging areas may share the framebuffer X origin; clamping them - * to the mirror width would drop valid canonical VRAM writes. */ - int32_t wide_left = (int32_t)draw_area_left - margin; - int32_t wide_right = (int32_t)draw_area_left + (int32_t)ws_disp_w() + margin - 1; - if (wide_left < *left) *left = wide_left; - if (wide_right > *right) *right = wide_right; - } -} - -static inline int draw_area_out_point(int32_t x, int32_t y) { - int32_t left, right; - draw_area_host_x_bounds(&left, &right); - return x < left || x > right - || y < (int32_t)draw_area_top || y > (int32_t)draw_area_bottom; -} - -static inline int draw_area_out_bbox(const int32_t *vx, const int32_t *vy, int n) { - int32_t minx = vx[0], maxx = vx[0], miny = vy[0], maxy = vy[0]; - for (int i = 1; i < n; i++) { - if (vx[i] < minx) minx = vx[i]; - if (vx[i] > maxx) maxx = vx[i]; - if (vy[i] < miny) miny = vy[i]; - if (vy[i] > maxy) maxy = vy[i]; - } - int32_t left, right; - draw_area_host_x_bounds(&left, &right); - return maxx < left || minx > right - || maxy < (int32_t)draw_area_top || miny > (int32_t)draw_area_bottom; -} - -static inline int draw_area_out_rect(int32_t x, int32_t y, int w, int h) { - if (w <= 0 || h <= 0) return 1; - int32_t left, right; - draw_area_host_x_bounds(&left, &right); - return (x + w - 1) < left - || x > right - || (y + h - 1) < (int32_t)draw_area_top - || y > (int32_t)draw_area_bottom; -} - -/* Rasterize a flat-shaded triangle using DDA scanline fill. - * Vertices are in screen coordinates (draw offset already applied). */ -static void raster_triangle(int32_t x0, int32_t y0, - int32_t x1, int32_t y1, - int32_t x2, int32_t y2, - uint16_t color) -{ - /* Sort vertices by Y (ascending). */ - int32_t tx, ty; - if (y0 > y1) { tx=x0; ty=y0; x0=x1; y0=y1; x1=tx; y1=ty; } - if (y1 > y2) { tx=x1; ty=y1; x1=x2; y1=y2; x2=tx; y2=ty; } - if (y0 > y1) { tx=x0; ty=y0; x0=x1; y0=y1; x1=tx; y1=ty; } - - /* Reject degenerate (zero-height) or oversized triangles. */ - if (y0 == y2) return; - if ((x2 - x0) > 1023 || (x0 - x2) > 1023) return; - if ((y2 - y0) > 511) return; - - /* 64-bit fixed-point DDA (32.32) matching PS1 hardware behavior. - * Reference: DuckStation gpu_sw_rasterizer.inl makefp_xy / makestep_xy */ - #define FP_ONE (1LL << 32) - #define MAKE_FP(v) (((int64_t)(v) << 32) + (FP_ONE - (1 << 11))) - #define MAKE_STEP(dx, dy) \ - ((((int64_t)(dx) << 32) + ((dx) < 0 ? -((dy)-1) : (((dx) > 0) ? ((dy)-1) : 0))) / (dy)) - #define UNFP(fp) ((int32_t)((uint64_t)(fp) >> 32)) - - /* Upper half: y0 to y1 */ - if (y1 > y0) { - int32_t dy_long = y2 - y0; - int32_t dy_short = y1 - y0; - int64_t base = MAKE_FP(x0); - int64_t step_long = MAKE_STEP(x2 - x0, dy_long); - int64_t step_short = MAKE_STEP(x1 - x0, dy_short); - /* Determine which edge is left vs right */ - int64_t lx, rx, ls, rs; - if (step_long < step_short) { - lx = base; ls = step_long; rx = base; rs = step_short; - } else { - lx = base; ls = step_short; rx = base; rs = step_long; - } - for (int32_t y = y0; y < y1; y++) { - int32_t xl = UNFP(lx); - int32_t xr = UNFP(rx); - for (int32_t x = xl; x < xr; x++) - raster_pixel(x, y, color); - lx += ls; rx += rs; - } - } - - /* Lower half: y1 to y2 */ - if (y2 > y1) { - int32_t dy_long = y2 - y0; - int32_t dy_short = y2 - y1; - /* Long edge continues from y0 to y2 */ - int64_t step_long = MAKE_STEP(x2 - x0, dy_long); - int64_t long_at_y1 = MAKE_FP(x0) + step_long * (y1 - y0); - int64_t short_start = MAKE_FP(x1); - int64_t step_short = MAKE_STEP(x2 - x1, dy_short); - /* Determine left/right from the upper half's orientation */ - int64_t lx, rx, ls, rs; - int64_t full_step_short_upper = (y1 > y0) ? MAKE_STEP(x1 - x0, y1 - y0) : 0; - if (step_long < full_step_short_upper || - (y1 == y0 && long_at_y1 <= short_start)) { - lx = long_at_y1; ls = step_long; rx = short_start; rs = step_short; - } else { - lx = short_start; ls = step_short; rx = long_at_y1; rs = step_long; - } - for (int32_t y = y1; y < y2; y++) { - int32_t xl = UNFP(lx); - int32_t xr = UNFP(rx); - for (int32_t x = xl; x < xr; x++) - raster_pixel(x, y, color); - lx += ls; rx += rs; - } - } - - #undef FP_ONE - #undef MAKE_FP - #undef MAKE_STEP - #undef UNFP -} - -/* Execute mono triangle (GP0 0x20-0x23) */ -static void gp0_exec_mono_tri(void) { - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); - int32_t vx[3], vy[3]; - for (int i = 0; i < 3; i++) { - parse_vertex(gp0_cmd_buf[1 + i], &vx[i], &vy[i]); - } - if (psx_gpu_triangle_oversize(vx, vy, 0, 1, 2)) return; - ws_nw_hud_shift_vertices(vx, 3); - for (int i = 0; i < 3; i++) { - vx[i] += draw_offset_x; - vy[i] += draw_offset_y; - } - if (draw_area_out_bbox(vx, vy, 3)) return; - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - prepare_precise_triangle(1, 2, 3, vx, vy); - gr_draw_flat_triangle(vx[0], vy[0], vx[1], vy[1], vx[2], vy[2], color); -} - -/* Execute mono quad (GP0 0x28-0x2B) — two triangles: (0,1,2) and (2,1,3) */ -static void gp0_exec_mono_quad(void) { - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); - int32_t vx[4], vy[4]; - for (int i = 0; i < 4; i++) - parse_vertex(gp0_cmd_buf[1 + i], &vx[i], &vy[i]); - int rej_a = psx_gpu_triangle_oversize(vx, vy, 0, 1, 2); - int rej_b = psx_gpu_triangle_oversize(vx, vy, 2, 1, 3); - if (rej_a && rej_b) return; - - /* Full-screen filters are commonly encoded as an axis-aligned quad. Drawing - * a semi-transparent quad as two independent triangles blends their shared - * diagonal twice; render the equivalent rectangle once to avoid that seam. - * The full-screen helper also grows the native 320-wide filter across the - * sidecar surface in native-wide mode. Ordinary world quads are unchanged. */ - if (vx[0] == vx[2] && vx[1] == vx[3] && - vy[0] == vy[1] && vy[2] == vy[3] && - vx[1] > vx[0] && vy[2] > vy[0] && - vx[0] <= 0 && vx[1] >= ws_disp_w() && - vy[0] <= 0 && vy[2] >= ws_disp_h()) { - int32_t x = vx[0]; - int32_t y = vy[0]; - int w = (int)(vx[1] - vx[0]); - int h = (int)(vy[2] - vy[0]); - ws_expand_fullscreen_rect(&x, y, &w, h); - x += draw_offset_x; - y += draw_offset_y; - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - gr_draw_flat_rect(x, y, w, h, color); - return; - } - ws_hud_widget_quad(vx, vy); - ws_nw_backdrop_stretch_quad(vx, vy); /* full-frame 2D backdrop stretch (no-op else) */ - ws_nw_hud_shift_vertices(vx, 4); - for (int i = 0; i < 4; i++) { - vx[i] += draw_offset_x; - vy[i] += draw_offset_y; - } - if (draw_area_out_bbox(vx, vy, 4)) return; - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - const int precise_indices[4] = { 1, 2, 3, 4 }; - int32_t precise_x[4], precise_y[4]; - const int precise_quad = prepare_precise_quad_vertices( - precise_indices, (!rej_a) + (!rej_b), - vx, vy, precise_x, precise_y); - if (!rej_a) { - queue_precise_triangle(precise_quad, precise_x, precise_y, 0, 1, 2); - gr_draw_flat_triangle(vx[0], vy[0], vx[1], vy[1], vx[2], vy[2], color); - } - if (!rej_b) { - queue_precise_triangle(precise_quad, precise_x, precise_y, 2, 1, 3); - gr_draw_flat_triangle(vx[2], vy[2], vx[1], vy[1], vx[3], vy[3], color); - } -} - -/* Execute shaded triangle (GP0 0x30-0x33) — Gouraud shaded */ -static void gp0_exec_shaded_tri(void) { - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - int32_t vx[3], vy[3]; - uint16_t c[3]; - /* Layout: C0, V0, C1, V1, C2, V2 */ - for (int i = 0; i < 3; i++) { - c[i] = rgb888_to_rgb555(gp0_cmd_buf[i * 2] & 0xFFFFFFu); - parse_vertex(gp0_cmd_buf[1 + i * 2], &vx[i], &vy[i]); - } - if (psx_gpu_triangle_oversize(vx, vy, 0, 1, 2)) return; - ws_nw_hud_shift_vertices(vx, 3); - for (int i = 0; i < 3; i++) { - vx[i] += draw_offset_x; - vy[i] += draw_offset_y; - } - if (draw_area_out_bbox(vx, vy, 3)) return; - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - prepare_precise_triangle(1, 3, 5, vx, vy); - gr_draw_gouraud_triangle(vx[0], vy[0], c[0], - vx[1], vy[1], c[1], - vx[2], vy[2], c[2]); -} - -void gpu_arm_shaded_quad_capture(void) { sq_cap_armed = 1; sq_cap_count = 0; } -int gpu_get_shaded_quad_capture(const GpuSqCapEntry** out) { - sq_cap_armed = 0; /* disarm on read */ - *out = sq_cap_buf; - return sq_cap_count; -} - -/* Execute shaded quad (GP0 0x38-0x3B) */ -static void gp0_exec_shaded_quad(void) { - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - int32_t vx[4], vy[4]; - uint16_t c[4]; - /* Layout: C0, V0, C1, V1, C2, V2, C3, V3 */ - for (int i = 0; i < 4; i++) { - c[i] = rgb888_to_rgb555(gp0_cmd_buf[i * 2] & 0xFFFFFFu); - parse_vertex(gp0_cmd_buf[1 + i * 2], &vx[i], &vy[i]); - } - int rej_a = psx_gpu_triangle_oversize(vx, vy, 0, 1, 2); - int rej_b = psx_gpu_triangle_oversize(vx, vy, 2, 1, 3); - if (rej_a && rej_b) return; - ws_nw_backdrop_stretch_quad(vx, vy); /* full-frame 2D backdrop stretch (sky gradient; no-op else) */ - ws_nw_hud_shift_vertices(vx, 4); - for (int i = 0; i < 4; i++) { - vx[i] += draw_offset_x; - vy[i] += draw_offset_y; - } - if (draw_area_out_bbox(vx, vy, 4)) return; - /* Capture vertex data when armed. */ - if (sq_cap_armed && sq_cap_count < SQ_CAP_MAX) { - GpuSqCapEntry* e = &sq_cap_buf[sq_cap_count++]; - for (int i = 0; i < 4; i++) { - e->vx[i] = vx[i]; e->vy[i] = vy[i]; - e->color[i] = gp0_cmd_buf[i * 2] & 0xFFFFFFu; - } - } - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - const int precise_indices[4] = { 1, 3, 5, 7 }; - int32_t precise_x[4], precise_y[4]; - const int precise_quad = prepare_precise_quad_vertices( - precise_indices, (!rej_a) + (!rej_b), - vx, vy, precise_x, precise_y); - if (!rej_a) { - queue_precise_triangle(precise_quad, precise_x, precise_y, 0, 1, 2); - gr_draw_gouraud_triangle(vx[0], vy[0], c[0], - vx[1], vy[1], c[1], - vx[2], vy[2], c[2]); - } - if (!rej_b) { - queue_precise_triangle(precise_quad, precise_x, precise_y, 2, 1, 3); - gr_draw_gouraud_triangle(vx[2], vy[2], c[2], - vx[1], vy[1], c[1], - vx[3], vy[3], c[3]); - } -} - -/* Helper: build texpage word from GPU state for SW renderer. - * Format: bits 0-3 = X base, bit 4 = Y base, bits 5-6 = semi-trans, bits 7-8 = color depth */ -static uint16_t current_texpage(void) { - return (uint16_t)(texpage_x | (texpage_y << 4) | - (semi_transparency << 5) | (texpage_colors << 7)); -} - -/* Hardware: the texpage attribute word carried inside every textured polygon - * (GP0 0x24-0x3F with the texture bit) is copied into the GPU draw-mode state - * (GPUSTAT bits 0-8) exactly like GP0(E1) bits 0-8 — the poly's own word, not - * the last E1, decides its semi-transparency mode, and later rectangle/sprite - * prims (which carry no texpage word) consume the state the poly left behind. - * Beetle: SetTPage(CB[4 + ((cc>>4)&1)] >> 16) on every textured poly. - * Bits 9-10 (dither / draw-to-display) exist only in E1; bit 11 (texture - * disable) only latches when GP1(09h) allowed it, which polys can't grant. */ -static void set_tpage_from_poly(uint16_t tpage_word) { - texpage_x = tpage_word & 0xF; - texpage_y = (tpage_word >> 4) & 1; - semi_transparency = (tpage_word >> 5) & 3; - texpage_colors = (tpage_word >> 7) & 3; -} - -/* Helper: set up SW renderer state before a textured draw. - * semi_trans: whether the primitive has semi-transparency enabled (from opcode bit) - * raw_texture: 1 if this is a raw-texture opcode (bit 0 of GP0 opcode set) */ -static void setup_textured_draw(uint32_t color24, int semi_trans, int raw_texture) { - uint32_t r = (color24 >> 0) & 0xFF; - uint32_t g = (color24 >> 8) & 0xFF; - uint32_t b = (color24 >> 16) & 0xFF; - gr_set_color_modulation((int)r, (int)g, (int)b, raw_texture); - gr_set_semi_transparency(semi_trans, (int)semi_transparency); -} - -/* Execute textured triangle (GP0 0x24-0x27) */ -static void gp0_exec_textured_tri(void) { - uint32_t color24 = gp0_cmd_buf[0] & 0xFFFFFFu; - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; - int32_t vx[3], vy[3]; - int u[3], v[3]; - /* Layout: color+v0, texcoord0+clut, v1, texcoord1+tpage, v2, texcoord2 */ - parse_vertex(gp0_cmd_buf[1], &vx[0], &vy[0]); - parse_vertex(gp0_cmd_buf[3], &vx[1], &vy[1]); - parse_vertex(gp0_cmd_buf[5], &vx[2], &vy[2]); - u[0] = gp0_cmd_buf[2] & 0xFF; v[0] = (gp0_cmd_buf[2] >> 8) & 0xFF; - u[1] = gp0_cmd_buf[4] & 0xFF; v[1] = (gp0_cmd_buf[4] >> 8) & 0xFF; - u[2] = gp0_cmd_buf[6] & 0xFF; v[2] = (gp0_cmd_buf[6] >> 8) & 0xFF; - uint16_t clut = (uint16_t)(gp0_cmd_buf[2] >> 16); - uint16_t clut_x = (clut & 0x3F) * 16; - uint16_t clut_y = (clut >> 6) & 0x1FF; - /* Texpage from word 4 bits 16-31 */ - uint16_t tpage_word = (uint16_t)(gp0_cmd_buf[4] >> 16); - uint16_t tpage = tpage_word & 0x1FF; - set_tpage_from_poly(tpage_word); /* latches even for size-rejected polys */ - if (psx_gpu_triangle_oversize(vx, vy, 0, 1, 2)) return; - - ws_nw_hud_shift_vertices(vx, 3); - for (int i = 0; i < 3; i++) { - vx[i] += draw_offset_x; - vy[i] += draw_offset_y; - } - if (draw_area_out_bbox(vx, vy, 3)) return; - - setup_textured_draw(color24, semi_trans, raw_texture); - const int precise = prepare_precise_triangle(1, 3, 5, vx, vy); - prepare_texture_triangle(precise, 1, 3, 5); - gr_draw_textured_triangle(vx[0], vy[0], u[0], v[0], - vx[1], vy[1], u[1], v[1], - vx[2], vy[2], u[2], v[2], - clut_x, clut_y, tpage); -} - -/* Execute textured quad (GP0 0x2C-0x2F) */ -static void gp0_exec_textured_quad(void) { - uint32_t color24 = gp0_cmd_buf[0] & 0xFFFFFFu; - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; - int32_t vx[4], vy[4]; - int u[4], v[4]; - /* Layout: color+v0, tc0+clut, v1, tc1+tpage, v2, tc2, v3, tc3 */ - parse_vertex(gp0_cmd_buf[1], &vx[0], &vy[0]); - parse_vertex(gp0_cmd_buf[3], &vx[1], &vy[1]); - parse_vertex(gp0_cmd_buf[5], &vx[2], &vy[2]); - parse_vertex(gp0_cmd_buf[7], &vx[3], &vy[3]); - u[0] = gp0_cmd_buf[2] & 0xFF; v[0] = (gp0_cmd_buf[2] >> 8) & 0xFF; - u[1] = gp0_cmd_buf[4] & 0xFF; v[1] = (gp0_cmd_buf[4] >> 8) & 0xFF; - u[2] = gp0_cmd_buf[6] & 0xFF; v[2] = (gp0_cmd_buf[6] >> 8) & 0xFF; - u[3] = gp0_cmd_buf[8] & 0xFF; v[3] = (gp0_cmd_buf[8] >> 8) & 0xFF; - uint16_t clut = (uint16_t)(gp0_cmd_buf[2] >> 16); - uint16_t clut_x = (clut & 0x3F) * 16; - uint16_t clut_y = (clut >> 6) & 0x1FF; - uint16_t tpage_word = (uint16_t)(gp0_cmd_buf[4] >> 16); - uint16_t tpage = tpage_word & 0x1FF; - set_tpage_from_poly(tpage_word); /* latches even for size-rejected polys */ - int rej_a = psx_gpu_triangle_oversize(vx, vy, 0, 1, 2); - int rej_b = psx_gpu_triangle_oversize(vx, vy, 2, 1, 3); - if (rej_a && rej_b) return; - - /* Widescreen: tagged billboard quads carry CPU-computed pixel offsets the - * GTE squash never saw — re-squash every X around the prim's anchor. */ - const int raw_w = abs(vx[1] - vx[0]) + 1, raw_h = abs(vy[2] - vy[0]) + 1; - int squashed = 0; - { - int32_t ws_ax; - if (ws_tagged_anchor(&ws_ax)) { - squashed = 1; - for (int i = 0; i < 4; i++) vx[i] = ws_scale_about(vx[i], ws_ax); - } - } - ws_hud_widget_quad(vx, vy); - ws_auto_ui_transform_quad(vx, vy); - ws_nw_backdrop_stretch_quad(vx, vy); /* full-frame 2D backdrop image stretch (no-op else) */ - ws_nw_hud_shift_vertices(vx, 4); - - for (int i = 0; i < 4; i++) { - vx[i] += draw_offset_x; - vy[i] += draw_offset_y; - } - if (draw_area_out_bbox(vx, vy, 4)) return; - - setup_textured_draw(color24, semi_trans, raw_texture); - - if (vy[0] == vy[1] && vy[2] == vy[3] && - vx[0] == vx[2] && vx[1] == vx[3] && - u[0] == u[2] && u[1] == u[3] && - v[0] == v[1] && v[2] == v[3]) { - int x = vx[0] < vx[1] ? vx[0] : vx[1]; - int y = vy[0] < vy[2] ? vy[0] : vy[2]; - int w = vx[0] < vx[1] ? vx[1] - vx[0] : vx[0] - vx[1]; - int h = vy[0] < vy[2] ? vy[2] - vy[0] : vy[0] - vy[2]; - int left_u = vx[0] < vx[1] ? u[0] : u[1]; - int right_u = vx[0] < vx[1] ? u[1] : u[0]; - int top_v = vy[0] < vy[2] ? v[0] : v[2]; - int bot_v = vy[0] < vy[2] ? v[2] : v[0]; - if (w > 0 && h > 0) { - geometry_diag_note_rectangle_fast_path(); -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - float sides[4]; - const float sprite_depth = temporal_sprite_sides( - x - draw_offset_x, y - draw_offset_y, w, h, raw_w, raw_h, squashed, sides); - gr_set_temporal_sprite(sprite_depth, sides); -#endif - if (right_u - left_u == w && bot_v - top_v == h) { - gr_draw_textured_rect(x, y, w, h, left_u, top_v, - clut_x, clut_y, tpage); - return; - } - gr_draw_textured_rect_scaled(x, y, w, h, left_u, top_v, - right_u, bot_v, - clut_x, clut_y, tpage); - return; - } - } - - const int precise_indices[4] = { 1, 3, 5, 7 }; - int32_t precise_x[4], precise_y[4]; - const int precise_quad = prepare_precise_quad_vertices( - precise_indices, (!rej_a) + (!rej_b), - vx, vy, precise_x, precise_y); - float texture_q[4]; - const int perspective_quad = resolve_texture_vertices( - precise_quad, precise_indices, 4, texture_q); - if (!rej_a) { - queue_precise_triangle(precise_quad, precise_x, precise_y, 0, 1, 2); - queue_texture_triangle(perspective_quad, texture_q, 0, 1, 2); - gr_draw_textured_triangle(vx[0], vy[0], u[0], v[0], - vx[1], vy[1], u[1], v[1], - vx[2], vy[2], u[2], v[2], - clut_x, clut_y, tpage); - } - if (!rej_b) { - queue_precise_triangle(precise_quad, precise_x, precise_y, 2, 1, 3); - queue_texture_triangle(perspective_quad, texture_q, 2, 1, 3); - gr_draw_textured_triangle(vx[2], vy[2], u[2], v[2], - vx[1], vy[1], u[1], v[1], - vx[3], vy[3], u[3], v[3], - clut_x, clut_y, tpage); - } -} - -/* Execute shaded textured triangle (GP0 0x34-0x37) */ -static void gp0_exec_shaded_textured_tri(void) { - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; - int32_t vx[3], vy[3]; - int u[3], v[3]; - uint32_t c[3]; - /* Layout: C0, V0, TC0+clut, C1, V1, TC1+tpage, C2, V2, TC2 */ - c[0] = gp0_cmd_buf[0] & 0xFFFFFFu; - c[1] = gp0_cmd_buf[3] & 0xFFFFFFu; - c[2] = gp0_cmd_buf[6] & 0xFFFFFFu; - parse_vertex(gp0_cmd_buf[1], &vx[0], &vy[0]); - parse_vertex(gp0_cmd_buf[4], &vx[1], &vy[1]); - parse_vertex(gp0_cmd_buf[7], &vx[2], &vy[2]); - u[0] = gp0_cmd_buf[2] & 0xFF; v[0] = (gp0_cmd_buf[2] >> 8) & 0xFF; - u[1] = gp0_cmd_buf[5] & 0xFF; v[1] = (gp0_cmd_buf[5] >> 8) & 0xFF; - u[2] = gp0_cmd_buf[8] & 0xFF; v[2] = (gp0_cmd_buf[8] >> 8) & 0xFF; - uint16_t clut = (uint16_t)(gp0_cmd_buf[2] >> 16); - uint16_t clut_x = (clut & 0x3F) * 16; - uint16_t clut_y = (clut >> 6) & 0x1FF; - uint16_t tpage_word = (uint16_t)(gp0_cmd_buf[5] >> 16); - uint16_t tpage = tpage_word & 0x1FF; - set_tpage_from_poly(tpage_word); /* latches even for size-rejected polys */ - if (psx_gpu_triangle_oversize(vx, vy, 0, 1, 2)) return; - - ws_nw_hud_shift_vertices(vx, 3); - for (int i = 0; i < 3; i++) { - vx[i] += draw_offset_x; - vy[i] += draw_offset_y; - } - if (draw_area_out_bbox(vx, vy, 3)) return; - - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - const int precise = prepare_precise_triangle(1, 4, 7, vx, vy); - prepare_texture_triangle(precise, 1, 4, 7); - gr_draw_shaded_textured_triangle(vx[0], vy[0], u[0], v[0], c[0], - vx[1], vy[1], u[1], v[1], c[1], - vx[2], vy[2], u[2], v[2], c[2], - clut_x, clut_y, tpage, raw_texture); -} - -/* Execute shaded textured quad (GP0 0x3C-0x3F) */ -static void gp0_exec_shaded_textured_quad(void) { - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; - int32_t vx[4], vy[4]; - int u[4], v[4]; - uint32_t c[4]; - /* Layout: C0, V0, TC0+clut, C1, V1, TC1+tpage, C2, V2, TC2, C3, V3, TC3 */ - c[0] = gp0_cmd_buf[0] & 0xFFFFFFu; - c[1] = gp0_cmd_buf[3] & 0xFFFFFFu; - c[2] = gp0_cmd_buf[6] & 0xFFFFFFu; - c[3] = gp0_cmd_buf[9] & 0xFFFFFFu; - parse_vertex(gp0_cmd_buf[1], &vx[0], &vy[0]); - parse_vertex(gp0_cmd_buf[4], &vx[1], &vy[1]); - parse_vertex(gp0_cmd_buf[7], &vx[2], &vy[2]); - parse_vertex(gp0_cmd_buf[10], &vx[3], &vy[3]); - u[0] = gp0_cmd_buf[2] & 0xFF; v[0] = (gp0_cmd_buf[2] >> 8) & 0xFF; - u[1] = gp0_cmd_buf[5] & 0xFF; v[1] = (gp0_cmd_buf[5] >> 8) & 0xFF; - u[2] = gp0_cmd_buf[8] & 0xFF; v[2] = (gp0_cmd_buf[8] >> 8) & 0xFF; - u[3] = gp0_cmd_buf[11] & 0xFF; v[3] = (gp0_cmd_buf[11] >> 8) & 0xFF; - uint16_t clut = (uint16_t)(gp0_cmd_buf[2] >> 16); - uint16_t clut_x = (clut & 0x3F) * 16; - uint16_t clut_y = (clut >> 6) & 0x1FF; - uint16_t tpage_word = (uint16_t)(gp0_cmd_buf[5] >> 16); - uint16_t tpage = tpage_word & 0x1FF; - set_tpage_from_poly(tpage_word); /* latches even for size-rejected polys */ - int rej_a = psx_gpu_triangle_oversize(vx, vy, 0, 1, 2); - int rej_b = psx_gpu_triangle_oversize(vx, vy, 2, 1, 3); - if (rej_a && rej_b) return; - - ws_auto_ui_transform_quad(vx, vy); - ws_nw_hud_shift_vertices(vx, 4); - for (int i = 0; i < 4; i++) { - vx[i] += draw_offset_x; - vy[i] += draw_offset_y; - } - if (draw_area_out_bbox(vx, vy, 4)) return; - - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - const int precise_indices[4] = { 1, 4, 7, 10 }; - int32_t precise_x[4], precise_y[4]; - const int precise_quad = prepare_precise_quad_vertices( - precise_indices, (!rej_a) + (!rej_b), - vx, vy, precise_x, precise_y); - float texture_q[4]; - const int perspective_quad = resolve_texture_vertices( - precise_quad, precise_indices, 4, texture_q); - if (!rej_a) { - queue_precise_triangle(precise_quad, precise_x, precise_y, 0, 1, 2); - queue_texture_triangle(perspective_quad, texture_q, 0, 1, 2); - gr_draw_shaded_textured_triangle(vx[0], vy[0], u[0], v[0], c[0], - vx[1], vy[1], u[1], v[1], c[1], - vx[2], vy[2], u[2], v[2], c[2], - clut_x, clut_y, tpage, raw_texture); - } - if (!rej_b) { - queue_precise_triangle(precise_quad, precise_x, precise_y, 2, 1, 3); - queue_texture_triangle(perspective_quad, texture_q, 2, 1, 3); - gr_draw_shaded_textured_triangle(vx[2], vy[2], u[2], v[2], c[2], - vx[1], vy[1], u[1], v[1], c[1], - vx[3], vy[3], u[3], v[3], c[3], - clut_x, clut_y, tpage, raw_texture); - } -} - -/* Execute mono line (GP0 0x40-0x47) — Bresenham */ -static void gp0_exec_mono_line(void) { - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); - int32_t x0, y0, x1, y1; - parse_vertex(gp0_cmd_buf[1], &x0, &y0); - parse_vertex(gp0_cmd_buf[2], &x1, &y1); - if (psx_gpu_line_oversize(x0, y0, x1, y1)) return; - int32_t vx[2] = { x0, x1 }; - ws_nw_hud_shift_vertices(vx, 2); - x0 = vx[0]; x1 = vx[1]; - x0 += draw_offset_x; y0 += draw_offset_y; - x1 += draw_offset_x; y1 += draw_offset_y; - { - int32_t lx[2] = { x0, x1 }, ly[2] = { y0, y1 }; - if (draw_area_out_bbox(lx, ly, 2)) return; - } - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - gr_draw_line(x0, y0, x1, y1, color); -} - -/* Execute shaded line (GP0 0x50-0x57) */ -static void gp0_exec_shaded_line(void) { - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - uint16_t c0 = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); - uint16_t c1 = rgb888_to_rgb555(gp0_cmd_buf[2] & 0xFFFFFFu); - int32_t x0, y0, x1, y1; - parse_vertex(gp0_cmd_buf[1], &x0, &y0); - parse_vertex(gp0_cmd_buf[3], &x1, &y1); - if (psx_gpu_line_oversize(x0, y0, x1, y1)) return; - int32_t vx[2] = { x0, x1 }; - ws_nw_hud_shift_vertices(vx, 2); - x0 = vx[0]; x1 = vx[1]; - x0 += draw_offset_x; y0 += draw_offset_y; - x1 += draw_offset_x; y1 += draw_offset_y; - { - int32_t lx[2] = { x0, x1 }, ly[2] = { y0, y1 }; - if (draw_area_out_bbox(lx, ly, 2)) return; - } - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - gr_draw_shaded_line(x0, y0, c0, x1, y1, c1); -} - -/* Execute mono rectangle (GP0 0x60-0x63) */ -static void gp0_exec_mono_rect(void) { - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); - int32_t x0, y0; - parse_vertex(gp0_cmd_buf[1], &x0, &y0); - int w = gp0_cmd_buf[2] & 0xFFFFu; - int h = (gp0_cmd_buf[2] >> 16) & 0xFFFFu; - if (w > 1023) w = 1023; - if (h > 511) h = 511; - ws_expand_fullscreen_rect(&x0, y0, &w, h); - w = ws_menu_rect(&x0, w); - x0 += ws_nw_hud_shift(x0, w); /* native-wide HUD corner re-anchor (no-op else) */ - x0 += draw_offset_x; y0 += draw_offset_y; - if (draw_area_out_rect(x0, y0, w, h)) return; - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - gr_draw_flat_rect(x0, y0, w, h, color); -} - -/* Execute textured rectangle (GP0 0x64-0x67) */ -static void gp0_exec_textured_rect(void) { - uint32_t color24 = gp0_cmd_buf[0] & 0xFFFFFFu; - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; - int32_t x0, y0; - parse_vertex(gp0_cmd_buf[1], &x0, &y0); - int u0 = gp0_cmd_buf[2] & 0xFF; - int v0 = (gp0_cmd_buf[2] >> 8) & 0xFF; - uint16_t clut = (uint16_t)(gp0_cmd_buf[2] >> 16); - uint16_t clut_x = (clut & 0x3F) * 16; - uint16_t clut_y = (clut >> 6) & 0x1FF; - int w = gp0_cmd_buf[3] & 0x3FF; - int h = (gp0_cmd_buf[3] >> 16) & 0x1FF; - if (w > 1023) w = 1023; - if (h > 511) h = 511; - - /* Widescreen: tagged sprite parts squash around their projected anchor; - * untagged SPRTs are screen-space 2D (HUD/menus) and squash around the - * display centre. Texels keep full coverage via the scaled-rect path. */ - int ws_w = 0, ws_h = 0; - const int menu_page = texpage_y != 0 || texpage_colors != 1 || clut_x > 1024 - 256 ? 0 : texpage_x == PSX_WS_MENU_X / 64 ? 1 : texpage_x == (PSX_WS_MENU_X + 128) / 64 ? 2 : 0; /* a palette that runs off its row is not the picture's */ - const int menu_shape = psx_ws_menu_backdrop_half(x0, y0, w, h) & menu_page; - if (ws_menu_due) { /* the front end's frame begins with its backdrop */ - ws_menu_due = 0; - if (!menu_shape) ws_menu_give_back(); - } - if (menu_shape) { - ws_menu_clut_x = clut_x; - ws_menu_clut_y = clut_y; - if (!ws_menu_logo) ws_menu_take_picture(); /* a save state or a read took it away, or the screen has become wide */ - } - const int menu_half = ws_menu_logo ? menu_shape : 0; - if (menu_half && ws_menu_wanted()) psx_ws_frame_kinds_menu_begin(&ws_frame_kinds); - if (ws_menu_frame()) { - if (!menu_half) ws_w = ws_menu_rect(&x0, w); /* the backdrop itself is stretched */ - } else if (ws_active() && w > 0) { - int32_t ws_ax; - if (ws_screen_tile_take()) { - /* Both edges go through one mapping, so neighbours keep touching. */ - const int32_t centre = ws_disp_w() / 2; - const int32_t right = - psx_ws_squash_about(x0 + w, centre, ws_xnum, ws_xden); - x0 = psx_ws_squash_about(x0, centre, ws_xnum, ws_xden); - if (right <= x0) return; - ws_w = (int)(right - x0); - } else if (ws_tagged_anchor(&ws_ax)) { - x0 = ws_scale_about(x0, ws_ax); - ws_w = (int)ws_scale_len(w); - } else { - int corrected_w = w; - int32_t widget_w = w, widget_h = h; - if (ws_hud_widget_rect(&x0, &y0, &widget_w, &widget_h)) { - ws_w = (int)widget_w; - ws_h = (int)widget_h; - } else if (ws_auto_ui_transform_rect(&x0, y0, &corrected_w, h)) - ws_w = corrected_w; - else if (ws_hud_sprt) { - int scaled_w = w, scaled_h = h; - if (ws_hud_user_scale(&x0, &y0, &scaled_w, &scaled_h)) { - ws_w = scaled_w; - ws_h = scaled_h; - } else { - x0 = ws_scale_about(x0, ws_hud_pivot(x0, w)); - ws_w = (int)ws_scale_len(w); - } - } - } - } - x0 += ws_nw_hud_shift(x0, w); /* native-wide HUD corner re-anchor (no-op else) */ - - x0 += draw_offset_x; y0 += draw_offset_y; - const int dw = ws_w ? ws_w : w, dh = ws_h ? ws_h : h; - if (draw_area_out_rect(x0, y0, dw, dh)) return; - setup_textured_draw(color24, semi_trans, raw_texture); - if (dw != w || dh != h) - gr_draw_textured_rect_scaled(x0, y0, dw, dh, u0, v0, u0 + w, v0 + h, - clut_x, clut_y, current_texpage()); - else - gr_draw_textured_rect(x0, y0, w, h, u0, v0, clut_x, clut_y, current_texpage()); - if (!menu_half) return; - if (ws_menu_halves & menu_half) ws_menu_halves = 0; - ws_menu_halves |= menu_half; - if (ws_menu_halves != 3) return; - ws_menu_halves = 0; - int32_t logo_left = PSX_WS_MENU_LOGO_LEFT, logo_right = PSX_WS_MENU_LOGO_RIGHT; /* where it was when the screen is not wide after all */ - if (ws_menu_frame()) psx_ws_menu_logo_span(PSX_WS_MENU_WIDE / 2, ws_xnum, ws_xden, &logo_left, &logo_right); - gr_draw_textured_rect_scaled(logo_left + draw_offset_x, PSX_WS_MENU_LOGO_TOP + draw_offset_y, logo_right - logo_left, PSX_WS_MENU_LOGO_ROWS, - 0, 0, PSX_WS_MENU_LOGO_WIDE, PSX_WS_MENU_LOGO_ROWS, - clut_x, clut_y, PSX_WS_MENU_KEPT_PAGE); -} - -/* Execute 1x1 dot (GP0 0x68-0x6B) */ -static void gp0_exec_mono_dot(void) { - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); - int32_t x, y; - parse_vertex(gp0_cmd_buf[1], &x, &y); - (void)ws_menu_rect(&x, 1); - x += ws_nw_hud_shift(x, 1); - x += draw_offset_x; y += draw_offset_y; - if (draw_area_out_point(x, y)) return; - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - gr_draw_flat_rect(x, y, 1, 1, color); -} - -/* Execute 8x8 textured sprite (GP0 0x74-0x77) */ -static void gp0_exec_textured_8x8(void) { - uint32_t color24 = gp0_cmd_buf[0] & 0xFFFFFFu; - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; - int32_t x0, y0; - parse_vertex(gp0_cmd_buf[1], &x0, &y0); - int ws_h = 0; - int ws_w = ws_sprt_fixed_transform(&x0, &y0, 8, &ws_h); - x0 += ws_nw_hud_shift(x0, 8); /* native-wide HUD corner re-anchor (no-op else) */ - x0 += draw_offset_x; y0 += draw_offset_y; - const int dw = ws_w ? ws_w : 8, dh = ws_h ? ws_h : 8; - if (draw_area_out_rect(x0, y0, dw, dh)) return; - int u0 = gp0_cmd_buf[2] & 0xFF; - int v0 = (gp0_cmd_buf[2] >> 8) & 0xFF; - uint16_t clut = (uint16_t)(gp0_cmd_buf[2] >> 16); - uint16_t clut_x = (clut & 0x3F) * 16; - uint16_t clut_y = (clut >> 6) & 0x1FF; - - setup_textured_draw(color24, semi_trans, raw_texture); - if (dw != 8 || dh != 8) - gr_draw_textured_rect_scaled(x0, y0, dw, dh, u0, v0, u0 + 8, v0 + 8, - clut_x, clut_y, current_texpage()); - else - gr_draw_textured_rect(x0, y0, 8, 8, u0, v0, clut_x, clut_y, current_texpage()); -} - -/* Execute 8x8 sprite (GP0 0x70-0x73) */ -static void gp0_exec_mono_8x8(void) { - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); - int32_t x0, y0; - parse_vertex(gp0_cmd_buf[1], &x0, &y0); - const int w = ws_menu_rect(&x0, 8); - x0 += ws_nw_hud_shift(x0, 8); - x0 += draw_offset_x; y0 += draw_offset_y; - if (draw_area_out_rect(x0, y0, w, 8)) return; - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - gr_draw_flat_rect(x0, y0, w, 8, color); -} - -/* Execute 16x16 textured sprite (GP0 0x7C-0x7F) */ -static void gp0_exec_textured_16x16(void) { - uint32_t color24 = gp0_cmd_buf[0] & 0xFFFFFFu; - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; - int32_t x0, y0; - parse_vertex(gp0_cmd_buf[1], &x0, &y0); - /* Never suppress MMX6 BG packets at a guessed finite-map boundary. The - * classifier cannot distinguish an authored layer entering the reveal from - * a stale ring slot; suppressing here caused the stage-start black flicker. */ - int ws_h = 0; - int ws_w = ws_sprt_fixed_transform(&x0, &y0, 16, &ws_h); - x0 += ws_nw_hud_shift(x0, 16); /* native-wide HUD corner re-anchor (no-op else) */ - x0 += draw_offset_x; y0 += draw_offset_y; - int u0 = gp0_cmd_buf[2] & 0xFF; - int v0 = (gp0_cmd_buf[2] >> 8) & 0xFF; - uint16_t clut = (uint16_t)(gp0_cmd_buf[2] >> 16); - uint16_t clut_x = (clut & 0x3F) * 16; - uint16_t clut_y = (clut >> 6) & 0x1FF; - - const int dw = ws_w ? ws_w : 16, dh = ws_h ? ws_h : 16; - if (draw_area_out_rect(x0, y0, dw, dh)) return; - setup_textured_draw(color24, semi_trans, raw_texture); - if (dw != 16 || dh != 16) - gr_draw_textured_rect_scaled(x0, y0, dw, dh, u0, v0, u0 + 16, v0 + 16, - clut_x, clut_y, current_texpage()); - else - gr_draw_textured_rect(x0, y0, 16, 16, u0, v0, clut_x, clut_y, current_texpage()); -} - -/* ---- GP0 command execution ---- */ - -static void gp0_exec_nop(void) { - /* 0x00 / 0x01 — nothing to do */ -} - -static void gp0_exec_fill_rect(void) { - /* 0x02 — Fill Rectangle in VRAM - * Word 0: 0x02BBGGRR (color) - * Word 1: YstartXstart (X bits 0-9 aligned to 16, Y bits 16-24) - * Word 2: YsizXsiz (W bits 0-9 rounded up to 16, H bits 16-24) */ - uint32_t color24 = gp0_cmd_buf[0] & 0x00FFFFFF; - uint16_t color16 = rgb888_to_rgb555(color24); - - uint32_t dst_x = gp0_cmd_buf[1] & 0x3F0u; /* X masked to 16-pixel alignment */ - uint32_t dst_y = (gp0_cmd_buf[1] >> 16) & 0x1FFu; - uint32_t width = ((gp0_cmd_buf[2] & 0x3FFu) + 0xFu) & ~0xFu; /* round up to 16 */ - uint32_t height = (gp0_cmd_buf[2] >> 16) & 0x1FFu; - - /* Fill ignores draw area, mask bits, and draw offset — writes directly to - * VRAM. Routed through the renderer so it also fills the hi-res - * supersampling mirror (no-op cost when supersampling is off). */ - ws_menu_yield((int)dst_x, (int)dst_y, (int)width, (int)height, 1); - gr_fill_rect((int)dst_x, (int)dst_y, (int)width, (int)height, color16); - - /* Keep every active presentation mirror in lockstep with the canonical - * framebuffer clear. The exact-geometry mirror is also used at authentic - * 4:3, where this file's ws_engaged() is deliberately false; gating it on - * wide mode left prior frames resident and produced persistent trails. */ - if ((ws_native_wide_active() || ws_geometry_correction) && - ws_is_fb_base(dst_x)) { - gr_wide_clear((int)dst_x, (int)dst_y, (int)height, color16); - } -} - -static void gp0_exec_draw_mode(void) { - /* 0xE1 — Draw Mode / Texpage - * Bits 0-3: texpage X base - * Bit 4: texpage Y base - * Bits 5-6: semi-transparency mode - * Bits 7-8: texture color mode - * Bit 9: dither - * Bit 10: draw to display area - * Bit 11: texture disable (when allowed by GP1) */ - uint32_t param = gp0_cmd_buf[0] & 0x00FFFFFFu; - texpage_x = param & 0xF; - texpage_y = (param >> 4) & 1; - semi_transparency = (param >> 5) & 3; - texpage_colors = (param >> 7) & 3; - dither_enabled = (param >> 9) & 1; - draw_to_display = (param >> 10) & 1; - texture_disable = (param >> 11) & 1; - /* Sync semi-transparency to SW renderer — actual blending is per-primitive */ -} - -static void gp0_exec_texture_window(void) { - /* 0xE2 — Texture Window - * Bits 0-4: mask X (in 8-pixel steps) - * Bits 5-9: mask Y - * Bits 10-14: offset X - * Bits 15-19: offset Y */ - texture_window_value = gp0_cmd_buf[0] & 0x000FFFFFu; - gr_set_texture_window(texture_window_value); -} - -/* The game may draw anywhere in its drawing area. */ -static void ws_menu_yield_drawing_area(void) { - ws_menu_yield((int)draw_area_left, (int)draw_area_top, (int)draw_area_right - (int)draw_area_left + 1, (int)draw_area_bottom - (int)draw_area_top + 1, 1); -} - -static void gp0_exec_draw_area_tl(void) { - /* 0xE3 — Set Drawing Area Top-Left - * Bits 0-9: X - * Bits 10-19: Y */ - uint32_t param = gp0_cmd_buf[0] & 0x00FFFFFFu; - draw_area_left = param & 0x3FF; - draw_area_top = (param >> 10) & 0x3FF; - gr_set_draw_area((int)draw_area_left, (int)draw_area_top, - (int)draw_area_right, (int)draw_area_bottom); - psx_ws_hud_widgets_frame(&ws_hud); - ws_nw_sync_target(); /* back buffer (draw_area_left) → wide mirror surface */ - ws_menu_due = 1; - ws_menu_halves = 0; - ws_menu_yield_drawing_area(); - psx_ws_frame_kinds_area(&ws_frame_kinds, draw_area_left, draw_area_top, WS_GTE_GAME_MODE_MIN_VERTS); -} - -static void gp0_exec_draw_area_br(void) { - /* 0xE4 — Set Drawing Area Bottom-Right - * Bits 0-9: X - * Bits 10-19: Y */ - uint32_t param = gp0_cmd_buf[0] & 0x00FFFFFFu; - draw_area_right = param & 0x3FF; - draw_area_bottom = (param >> 10) & 0x3FF; - gr_set_draw_area((int)draw_area_left, (int)draw_area_top, - (int)draw_area_right, (int)draw_area_bottom); - ws_menu_yield_drawing_area(); - ws_nw_sync_target(); /* back buffer (draw_area_left) → wide mirror surface */ -} - -static void gp0_exec_draw_offset(void) { - /* 0xE5 — Set Drawing Offset - * Bits 0-10: X (signed 11-bit) - * Bits 11-21: Y (signed 11-bit) */ - uint32_t param = gp0_cmd_buf[0] & 0x00FFFFFFu; - draw_offset_x = sign_extend(param & 0x7FFu, 11); - draw_offset_y = sign_extend((param >> 11) & 0x7FFu, 11); - if (draw_offset_y < g_doff_min_this) g_doff_min_this = draw_offset_y; - if (draw_offset_y > g_doff_max_this) g_doff_max_this = draw_offset_y; - g_doff_cnt_this++; - /* Native-wide mirrors framebuffer draws into a separate wide surface (canonical - * VRAM stays faithful); the renderer needs the live draw offset to translate - * into surface-local coords, so keep gr_set_draw_offset current. */ - gr_set_draw_offset(draw_offset_x, draw_offset_y); -} - -static void gp0_exec_mask_bit(void) { - /* 0xE6 — Mask Bit Setting - * Bit 0: set mask bit when drawing (force bit 15 of pixels) - * Bit 1: check mask bit (don't draw to pixels with bit 15 set) */ - uint32_t param = gp0_cmd_buf[0] & 0x00FFFFFFu; - set_mask_bit = param & 1; - check_mask_bit = (param >> 1) & 1; - gr_set_mask_bits((int)set_mask_bit, (int)check_mask_bit); -} - -/* A0 upload history for debug inspection */ -#define A0_HISTORY_CAP 128 -typedef struct { - uint16_t x, y, w, h; - uint32_t first_words[4]; - int word_count; - uint32_t func_addr; - uint32_t sp_val; /* CPU $sp at time of A0 header */ - uint32_t ra_val; /* CPU $ra register at time of A0 header */ - uint32_t s1_val; /* CPU $s1 = LoadImage RECT ptr (x/y/w/h) in FUN_80069dfc */ - uint32_t s2_val; /* CPU $s2 = TRUE source pixel ptr (`move s2,a1` at entry) */ - uint32_t a0_val; /* CPU $a0 at capture (RECT arg; may be clobbered) */ - uint32_t a1_val; /* CPU $a1 at capture (source arg; may be clobbered) */ - uint32_t frame_stamp; /* s_frame_count at the upload — for load-vs-upload ordering */ - uint32_t stack[10]; /* first 10 words from sp (sp+32=saved $s1, sp+36=saved $ra) */ -} A0HistEntry; -static A0HistEntry a0_history[A0_HISTORY_CAP]; -static int a0_history_count = 0; -static int a0_capture_slot = -1; /* currently capturing data words for this slot */ - -extern uint32_t psx_read_word(uint32_t addr); - -int gpu_get_a0_history(int index, int *x, int *y, int *w, int *h, - uint32_t *fw0, uint32_t *fw1, int *wcount) { - if (index < 0 || index >= a0_history_count) return 0; - *x = a0_history[index].x; *y = a0_history[index].y; - *w = a0_history[index].w; *h = a0_history[index].h; - *fw0 = a0_history[index].first_words[0]; - *fw1 = a0_history[index].first_words[1]; - *wcount = a0_history[index].word_count; - return 1; -} -int gpu_get_a0_count(void) { return a0_history_count; } -int gpu_get_a0_extra(int index, uint32_t *func, uint32_t *sp, uint32_t *ra, - uint32_t *s1, uint32_t *stack10) { - if (index < 0 || index >= a0_history_count) return 0; - *func = a0_history[index].func_addr; - *sp = a0_history[index].sp_val; - *ra = a0_history[index].ra_val; - *s1 = a0_history[index].s1_val; - memcpy(stack10, a0_history[index].stack, 10 * sizeof(uint32_t)); - return 1; -} -/* True source pointer + arg regs + frame for the LoadImage upload (s2 = source). */ -int gpu_get_a0_src(int index, uint32_t *s2, uint32_t *a0, uint32_t *a1, uint32_t *frame) { - if (index < 0 || index >= a0_history_count) return 0; - *s2 = a0_history[index].s2_val; - *a0 = a0_history[index].a0_val; - *a1 = a0_history[index].a1_val; - *frame = a0_history[index].frame_stamp; - return 1; -} - -static void gp0_exec_cpu_to_vram(void) { - /* 0xA0 — CPU→VRAM Copy (header: 3 words) - * Word 0: command - * Word 1: destination coords (X bits 0-9, Y bits 16-24) - * Word 2: dimensions (W bits 0-9, H bits 16-24) - * Followed by pixel data words */ - vram_write_x = gp0_cmd_buf[1] & 0x3FFu; - vram_write_y = (gp0_cmd_buf[1] >> 16) & 0x1FFu; - - uint32_t w = gp0_cmd_buf[2] & 0x3FFu; - uint32_t h = (gp0_cmd_buf[2] >> 16) & 0x1FFu; - /* 0 means max dimension */ - vram_write_w = (w == 0) ? 0x400 : (uint16_t)w; - vram_write_h = (h == 0) ? 0x200 : (uint16_t)h; - if (!psx_ws_menu_is_picture(vram_write_x, vram_write_y, vram_write_w, vram_write_h)) /* the whole picture is dealt with once it has arrived */ - ws_menu_yield(vram_write_x, vram_write_y, vram_write_w, vram_write_h, 1); /* before its words land */ - - ws_frame_pictured(vram_write_x, vram_write_y, vram_write_w, vram_write_h); - /* Record for debug */ - if (a0_history_count < A0_HISTORY_CAP) { - int slot = a0_history_count++; - a0_history[slot].x = vram_write_x; - a0_history[slot].y = vram_write_y; - a0_history[slot].w = vram_write_w; - a0_history[slot].h = vram_write_h; - a0_history[slot].word_count = 0; - a0_history[slot].func_addr = g_debug_current_func_addr; - memset(a0_history[slot].first_words, 0, sizeof(a0_history[slot].first_words)); - /* Capture CPU context for caller tracing. - * func_1FC38524 (shell LoadImage) uses $s1 as source data pointer. */ - a0_history[slot].sp_val = 0; - a0_history[slot].ra_val = 0; - a0_history[slot].s1_val = 0; - a0_history[slot].s2_val = 0; - a0_history[slot].a0_val = 0; - a0_history[slot].a1_val = 0; - a0_history[slot].frame_stamp = (uint32_t)s_frame_count; - memset(a0_history[slot].stack, 0, sizeof(a0_history[slot].stack)); - if (debug_cpu_ptr) { - uint32_t sp = debug_cpu_ptr->gpr[29]; - a0_history[slot].sp_val = sp; - a0_history[slot].ra_val = debug_cpu_ptr->gpr[31]; - a0_history[slot].s1_val = debug_cpu_ptr->gpr[17]; /* $s1 */ - a0_history[slot].s2_val = debug_cpu_ptr->gpr[18]; /* $s2 = source ptr */ - a0_history[slot].a0_val = debug_cpu_ptr->gpr[4]; /* $a0 */ - a0_history[slot].a1_val = debug_cpu_ptr->gpr[5]; /* $a1 */ - uint32_t sp_phys = sp & 0x1FFFFFu; - for (int si = 0; si < 10 && sp_phys + (si + 1) * 4 <= 0x200000u; si++) - a0_history[slot].stack[si] = psx_read_word(sp + si * 4); - } - a0_capture_slot = slot; - } - - vram_write_col = 0; - vram_write_row = 0; - - uint32_t num_pixels = (uint32_t)vram_write_w * (uint32_t)vram_write_h; - vram_write_remaining = (num_pixels + 1) / 2; - - if (vram_write_remaining > 0) - gp0_state = GP0_VRAM_WRITE; -} - -/* C0 (VRAM→CPU) history — uses c0_history_fwd declared at top of file */ -#define C0_HISTORY_CAP C0_HISTORY_CAP_FWD -#define c0_history c0_history_fwd -#define c0_history_count c0_history_count_fwd -#define c0_capture_slot c0_capture_slot_fwd - -int gpu_get_c0_count(void) { return c0_history_count; } -int gpu_get_c0_history(int index, int *x, int *y, int *w, int *h, - uint32_t *func, uint32_t *sp, uint32_t *s1, - uint32_t *fw0, uint32_t *fw1, int *rcount) { - if (index < 0 || index >= c0_history_count) return 0; - *x = c0_history[index].x; *y = c0_history[index].y; - *w = c0_history[index].w; *h = c0_history[index].h; - *func = c0_history[index].func_addr; - *sp = c0_history[index].sp_val; - *s1 = c0_history[index].s1_val; - *fw0 = c0_history[index].first_words[0]; - *fw1 = c0_history[index].first_words[1]; - *rcount = c0_history[index].read_count; - return 1; -} - -static void gp0_exec_vram_to_cpu(void) { - /* 0xC0 — VRAM→CPU Copy (3 words) - * Word 1: source coords - * Word 2: dimensions - * After this, data is read via GPUREAD */ - vram_read_x = gp0_cmd_buf[1] & 0x3FFu; - vram_read_y = (gp0_cmd_buf[1] >> 16) & 0x1FFu; - - uint32_t w = gp0_cmd_buf[2] & 0x3FFu; - uint32_t h = (gp0_cmd_buf[2] >> 16) & 0x1FFu; - vram_read_w = (w == 0) ? 0x400 : (uint16_t)w; - vram_read_h = (h == 0) ? 0x200 : (uint16_t)h; - ws_menu_yield(vram_read_x, vram_read_y, vram_read_w, vram_read_h, 0); - - /* Record for debug */ - if (c0_history_count < C0_HISTORY_CAP) { - int slot = c0_history_count++; - c0_history[slot].x = vram_read_x; - c0_history[slot].y = vram_read_y; - c0_history[slot].w = vram_read_w; - c0_history[slot].h = vram_read_h; - c0_history[slot].func_addr = g_debug_current_func_addr; - c0_history[slot].sp_val = debug_cpu_ptr ? debug_cpu_ptr->gpr[29] : 0; - c0_history[slot].s1_val = debug_cpu_ptr ? debug_cpu_ptr->gpr[17] : 0; - memset(c0_history[slot].first_words, 0, sizeof(c0_history[slot].first_words)); - c0_history[slot].read_count = 0; - c0_capture_slot = slot; - } - - vram_read_col = 0; - vram_read_row = 0; - vram_read_active = 1; -} - -/* Determine how many words a GP0 command requires (header only, not counting - * variable-length data for 0xA0). Returns -1 for polylines (terminated by - * sentinel). Returns 0 for unknown commands (will be fatal). */ -static int gp0_command_word_count(uint8_t opcode) { - switch (opcode) { - /* NOP / control */ - case 0x00: return 1; - case 0x01: return 1; /* clear cache */ - case 0x02: return 3; /* fill rect */ - case 0x1F: return 1; /* IRQ request */ - - /* Drawing commands — polygons */ - case 0x20: case 0x21: case 0x22: case 0x23: return 4; /* mono tri */ - case 0x24: case 0x25: case 0x26: case 0x27: return 7; /* textured tri */ - case 0x28: case 0x29: case 0x2A: case 0x2B: return 5; /* mono quad */ - case 0x2C: case 0x2D: case 0x2E: case 0x2F: return 9; /* textured quad */ - case 0x30: case 0x31: case 0x32: case 0x33: return 6; /* shaded tri */ - case 0x34: case 0x35: case 0x36: case 0x37: return 9; /* shaded textured tri */ - case 0x38: case 0x39: case 0x3A: case 0x3B: return 8; /* shaded quad */ - case 0x3C: case 0x3D: case 0x3E: case 0x3F: return 12; /* shaded textured quad */ - - /* Lines */ - case 0x40: case 0x41: case 0x42: case 0x43: - case 0x44: case 0x45: case 0x46: case 0x47: return 3; /* mono line */ - case 0x48: case 0x49: case 0x4A: case 0x4B: - case 0x4C: case 0x4D: case 0x4E: case 0x4F: return -1; /* mono polyline */ - case 0x50: case 0x51: case 0x52: case 0x53: - case 0x54: case 0x55: case 0x56: case 0x57: return 4; /* shaded line */ - case 0x58: case 0x59: case 0x5A: case 0x5B: - case 0x5C: case 0x5D: case 0x5E: case 0x5F: return -1; /* shaded polyline */ - - /* Rectangles */ - case 0x60: case 0x61: case 0x62: case 0x63: return 3; /* variable rect */ - case 0x64: case 0x65: case 0x66: case 0x67: return 4; /* variable textured rect */ - case 0x68: case 0x69: case 0x6A: case 0x6B: return 2; /* 1x1 dot */ - case 0x6C: case 0x6D: case 0x6E: case 0x6F: return 3; /* 1x1 textured */ - case 0x70: case 0x71: case 0x72: case 0x73: return 2; /* 8x8 rect */ - case 0x74: case 0x75: case 0x76: case 0x77: return 3; /* 8x8 textured */ - case 0x78: case 0x79: case 0x7A: case 0x7B: return 2; /* 16x16 rect */ - case 0x7C: case 0x7D: case 0x7E: case 0x7F: return 3; /* 16x16 textured */ - - /* VRAM copy / transfer */ - case 0x80: case 0x81: case 0x82: case 0x83: - case 0x84: case 0x85: case 0x86: case 0x87: - case 0x88: case 0x89: case 0x8A: case 0x8B: - case 0x8C: case 0x8D: case 0x8E: case 0x8F: - case 0x90: case 0x91: case 0x92: case 0x93: - case 0x94: case 0x95: case 0x96: case 0x97: - case 0x98: case 0x99: case 0x9A: case 0x9B: - case 0x9C: case 0x9D: case 0x9E: case 0x9F: return 4; /* VRAM→VRAM */ - - case 0xA0: case 0xA1: case 0xA2: case 0xA3: - case 0xA4: case 0xA5: case 0xA6: case 0xA7: - case 0xA8: case 0xA9: case 0xAA: case 0xAB: - case 0xAC: case 0xAD: case 0xAE: case 0xAF: - case 0xB0: case 0xB1: case 0xB2: case 0xB3: - case 0xB4: case 0xB5: case 0xB6: case 0xB7: - case 0xB8: case 0xB9: case 0xBA: case 0xBB: - case 0xBC: case 0xBD: case 0xBE: case 0xBF: return 3; /* CPU→VRAM (header) */ - - case 0xC0: case 0xC1: case 0xC2: case 0xC3: - case 0xC4: case 0xC5: case 0xC6: case 0xC7: - case 0xC8: case 0xC9: case 0xCA: case 0xCB: - case 0xCC: case 0xCD: case 0xCE: case 0xCF: - case 0xD0: case 0xD1: case 0xD2: case 0xD3: - case 0xD4: case 0xD5: case 0xD6: case 0xD7: - case 0xD8: case 0xD9: case 0xDA: case 0xDB: - case 0xDC: case 0xDD: case 0xDE: case 0xDF: return 3; /* VRAM→CPU (header) */ - - /* Environment */ - case 0xE0: return 1; /* NOP */ - case 0xE1: return 1; /* draw mode */ - case 0xE2: return 1; /* texture window */ - case 0xE3: return 1; /* draw area TL */ - case 0xE4: return 1; /* draw area BR */ - case 0xE5: return 1; /* draw offset */ - case 0xE6: return 1; /* mask bits */ - - default: - /* 0x03-0x1E, 0xE7-0xEF, 0xFF: NOP (1 word) per DuckStation */ - if ((opcode >= 0x03 && opcode <= 0x1E) || - (opcode >= 0xE7 && opcode <= 0xEF) || - opcode == 0xFF) { - return 1; - } - return 0; /* unknown */ - } -} - -static void ws_ui_prepass_add(const uint32_t *words, uint32_t source_addr, - uint16_t rank) { - if (ws_ui_prepass_count >= WS_UI_PREPASS_MAX || rank == 0xFFFFu) - return; - uint32_t op = words[0] >> 24; - int32_t min_x, max_x, min_y, max_y; - - if (op >= 0x20u && op <= 0x3Fu && (op & 0x04u) && - (op & 0x08u)) { - int indices[4]; - if (op & 0x10u) { - indices[0] = 1; indices[1] = 4; - indices[2] = 7; indices[3] = 10; - } else { - indices[0] = 1; indices[1] = 3; - indices[2] = 5; indices[3] = 7; - } - int32_t vx[4], vy[4]; - for (int i = 0; i < 4; i++) - parse_vertex(words[indices[i]], &vx[i], &vy[i]); - if (!ws_axis_aligned_quad(vx, vy)) return; - min_x = max_x = vx[0]; min_y = max_y = vy[0]; - for (int i = 1; i < 4; i++) { - if (vx[i] < min_x) min_x = vx[i]; - if (vx[i] > max_x) max_x = vx[i]; - if (vy[i] < min_y) min_y = vy[i]; - if (vy[i] > max_y) max_y = vy[i]; - } - } else if ((op >= 0x64u && op <= 0x67u) || - (op >= 0x74u && op <= 0x77u) || - (op >= 0x7Cu && op <= 0x7Fu)) { - parse_vertex(words[1], &min_x, &min_y); - int32_t width, height; - if (op >= 0x64u && op <= 0x67u) { - width = (int32_t)(words[3] & 0x3FFu); - height = (int32_t)((words[3] >> 16) & 0x1FFu); - } else { - width = height = op >= 0x7Cu ? 16 : 8; - } - if (width <= 0 || height <= 0) return; - max_x = min_x + width; - max_y = min_y + height; - } else { - return; - } - - int32_t width = max_x - min_x, height = max_y - min_y; - int32_t W = ws_disp_w(), H = ws_disp_h(); - if ((min_x <= 0 && max_x >= W && min_y <= 0 && max_y >= H) || - (width > W / 2 && height > H / 4)) - return; - - WsUiPrepassItem *item = &ws_ui_prepass[ws_ui_prepass_count++]; - item->group.key = - ws_auto_ui_group_key_words(words, op, min_y, height); - item->group.x = min_x; - item->group.width = width; - item->group.anchor = 0; - item->src_addr = psx_mod_gpu_dma_resolve_address(source_addr); - item->ot_rank = rank; -} - -void gpu_ws_prepass_linked_list(uint32_t start_addr) { - ws_ui_prepass_count = 0; - ws_ui_prepass_rank = 0xFFFFu; - ws_auto_ui_dense = 0; - if (!ws_auto_ui_squash || !ws_active()) return; - - uint32_t addr = psx_mod_gpu_dma_resolve_address(start_addr); - uint32_t safety = 0; - uint16_t rank = 0xFFFFu; - const uint32_t max_nodes = 0x40000u; - - for (;;) { - if (safety++ > max_nodes) { - ws_ui_prepass_count = 0; - return; - } - uint32_t header = psx_read_word(addr); - uint32_t num_words = (header >> 24) & 0xFFu; - if (num_words == 0) { - rank = rank == 0xFFFFu ? 0u : (uint16_t)(rank + 1u); - } else if (rank != 0xFFFFu) { - uint32_t word_addr = - psx_mod_gpu_dma_resolve_address(addr + 4u); - uint32_t offset = 0; - while (offset < num_words) { - uint32_t first = psx_read_word( - psx_mod_gpu_dma_resolve_address( - word_addr + offset * 4u)); - uint8_t op = (uint8_t)(first >> 24); - int count = gp0_command_word_count(op); - if (count <= 0 || offset + (uint32_t)count > num_words) - break; - /* CPU->VRAM data follows its 3-word header and is not a command - * stream. Such transfers are not UI draws; stop this node. */ - if (op >= 0xA0u && op <= 0xBFu) break; - uint32_t words[12] = {0}; - for (int i = 0; i < count && i < 12; i++) { - words[i] = psx_read_word( - psx_mod_gpu_dma_resolve_address( - word_addr + (offset + (uint32_t)i) * 4u)); - } - ws_ui_prepass_add(words, - psx_mod_gpu_dma_resolve_address( - word_addr + offset * 4u), rank); - offset += (uint32_t)count; - } - } - - uint32_t next = header & 0xFFFFFFu; - if (next == 0xFFFFFFu) break; - addr = psx_mod_gpu_dma_resolve_address(next); - } - if (ws_ui_prepass_count == 0) { - ws_ui_prepass_count = 0; - return; - } - - /* Empty ordering-table buckets can trail the actual frontmost layer. - * Selecting the last empty bucket made the memory-card glyph layer (rank - * 4095 followed by an empty rank 4096) disappear from the correction - * pass. Pick the highest rank that contains an eligible UI primitive. */ - uint16_t max_rank = ws_ui_prepass[0].ot_rank; - for (uint32_t i = 1; i < ws_ui_prepass_count; i++) { - if (ws_ui_prepass[i].ot_rank > max_rank) - max_rank = ws_ui_prepass[i].ot_rank; - } - ws_ui_prepass_rank = max_rank; - - uint32_t out = 0; - for (uint32_t i = 0; i < ws_ui_prepass_count; i++) { - if (ws_ui_prepass[i].ot_rank == max_rank) - ws_ui_prepass[out++] = ws_ui_prepass[i]; - } - ws_ui_prepass_count = out; - ws_auto_ui_dense = ws_ui_prepass_count >= 32u; - if (ws_ui_prepass_count == 0) return; - - WsUiGroupItem groups[WS_UI_PREPASS_MAX]; - for (uint32_t i = 0; i < ws_ui_prepass_count; i++) - groups[i] = ws_ui_prepass[i].group; - ws_ui_group_assign(groups, ws_ui_prepass_count, ws_disp_w(), - ws_auto_ui_dense); - for (uint32_t i = 0; i < ws_ui_prepass_count; i++) - ws_ui_prepass[i].group.anchor = groups[i].anchor; -} - -/* Per-opcode execution counters (exposed via gpu_get_opcode_stats) */ -static uint32_t gp0_opcode_count[256]; - -uint32_t gpu_get_opcode_count(uint8_t op) { return gp0_opcode_count[op]; } - -/* ---- Per-frame GP0 command ring (always-on, queried via debug server) ---- */ -/* We record every GP0 command (header + up to 6 payload words) with the - * frame number it was issued in. Per CLAUDE.md ring-buffer rule: capture - * is continuous and observers query a window of interest later, not arm- - * then-record. ~34 MB at 1M entries. Polyline / long commands get the - * first 6 payload words; that's enough for the header + first vertex pair - * + first uv/color word for diagnosing per-primitive state. */ - -extern uint64_t s_frame_count; /* defined in debug_server.c */ -extern uint32_t g_debug_last_store_pc; /* defined in debug_server.c */ -extern uint32_t g_debug_current_func_addr; /* defined in debug_server.c */ -uint32_t debug_guest_ra(void); /* accessor in debug_server.c (guest $ra) */ -uint32_t debug_guest_sp(void); /* accessor in debug_server.c (guest $sp) */ -extern uint8_t *memory_get_ram_ptr(void); /* raw 2MB main-RAM base (no lockstep) */ - -/* Bounded guest-stack unwind for VRAM-copy builder attribution. Scans the live - * stack for words that are valid return addresses (main-RAM code range, and the - * instruction at ret-8 is a jal/jalr), innermost first. A consumer skips the - * libgpu funnel band to name the game-level routine that issued the copy. Pure - * reads against the raw RAM array — no lockstep/observer pollution. */ -uint32_t g_gp0_last_copy_sp = 0; /* diag: raw guest $sp at last op-0x80 copy */ -static void gp0_capture_builder_chain(uint32_t out[6]) { - for (int i = 0; i < 6; i++) out[i] = 0; - uint8_t *ram = memory_get_ram_ptr(); - if (!ram) return; - const uint32_t RMASK = 0x001FFFFFu; /* 2 MB, mirror-folded */ - uint32_t rawsp = debug_guest_sp(); - g_gp0_last_copy_sp = rawsp; - if ((rawsp & 0x1FFFFFFFu) >= 0x00800000u) return; /* not in RAM/mirror */ - uint32_t sp = rawsp & RMASK & ~3u; /* fold to 2MB, word-align */ - /* Two-pass: prefer words that are genuine return addresses (call at ret-8), - * but if guest code validation yields none, fall back to any code-range - * stack word so the chain is never silently empty. */ - int found = 0; - for (int pass = 0; pass < 2 && found == 0; pass++) { - for (uint32_t off = 0; off + 4 <= 0x400u && found < 6; off += 4) { - uint32_t w; memcpy(&w, ram + ((sp + off) & RMASK), 4); - if ((w & 3u) || w < 0x80010000u || w >= 0x80120000u) continue; - if (pass == 0) { - uint32_t ins; memcpy(&ins, ram + ((w - 8u) & RMASK), 4); - uint32_t op = ins >> 26; - int is_call = (op == 3u) || (op == 0u && (ins & 0x3Fu) == 9u); - if (!is_call) continue; - } - out[found++] = w; - } - } -} - -#define GP0_RING_CAP (1u << 20) /* 1 048 576 entries */ -/* GpuGp0RingEntry + GPU_GP0_RING_MAX_WORDS are public types in gpu.h. */ - -static GpuGp0RingEntry *gp0_ring = NULL; /* lazy-alloc on first record */ -static uint64_t gp0_ring_seq = 0; /* total commands recorded */ -static uint32_t gp0_ring_head = 0; /* next write slot */ - -static void gp0_ring_record(const uint32_t *words, int n) { - if (!gp0_ring) { - gp0_ring = (GpuGp0RingEntry *)calloc(GP0_RING_CAP, sizeof(*gp0_ring)); - if (!gp0_ring) return; /* OOM — capture disabled, no other effect */ - } - GpuGp0RingEntry *e = &gp0_ring[gp0_ring_head]; - e->frame = (uint32_t)s_frame_count; - e->seq = (uint32_t)gp0_ring_seq; - e->src_addr = gp0_cmd_source_addr; - e->pc = g_debug_last_store_pc; - e->func = g_debug_current_func_addr; - e->ra = debug_guest_ra(); - e->opcode = (uint8_t)((words[0] >> 24) & 0xFF); - e->n_words = (uint8_t)(n > 255 ? 255 : (n < 0 ? 1 : n)); - e->ot_rank = gp0_ot_rank; - int copy_n = e->n_words > GPU_GP0_RING_MAX_WORDS ? GPU_GP0_RING_MAX_WORDS : e->n_words; - for (int i = 0; i < copy_n; i++) e->cmd[i] = words[i]; - for (int i = copy_n; i < GPU_GP0_RING_MAX_WORDS; i++) e->cmd[i] = 0; - if (e->opcode == 0x80) { gp0_capture_builder_chain(e->bld); e->csp = g_gp0_last_copy_sp; } - else { for (int i = 0; i < 6; i++) e->bld[i] = 0; e->csp = 0; } - gp0_ring_head = (gp0_ring_head + 1) % GP0_RING_CAP; - gp0_ring_seq++; -} - -/* Public accessors for debug_server.c */ -uint64_t gpu_gp0_ring_total(void) { return gp0_ring_seq; } -uint32_t gpu_gp0_ring_capacity(void) { return GP0_RING_CAP; } -uint32_t gpu_gp0_ring_max_words(void){ return GPU_GP0_RING_MAX_WORDS; } - -void gpu_set_gp0_source(uint32_t addr) { - gp0_next_source_addr = addr; -} - -/* Fill `out[0..max_out-1]` with entries from the requested frame; returns - * count. Walks from oldest in-buffer to newest so iteration order matches - * draw order within a frame. */ -int gpu_gp0_ring_dump_frame(uint32_t frame, GpuGp0RingEntry *out, int max_out) { - if (!gp0_ring || max_out <= 0) return 0; - uint32_t avail = (gp0_ring_seq < GP0_RING_CAP) - ? (uint32_t)gp0_ring_seq : GP0_RING_CAP; - uint32_t start = (gp0_ring_seq < GP0_RING_CAP) ? 0 : gp0_ring_head; - int n_out = 0; - for (uint32_t i = 0; i < avail && n_out < max_out; i++) { - uint32_t idx = (start + i) % GP0_RING_CAP; - if (gp0_ring[idx].frame == frame) { - out[n_out++] = gp0_ring[idx]; - } - } - return n_out; -} - -/* Report the frame range currently held in the ring (oldest..newest). */ -void gpu_gp0_ring_frame_span(uint32_t *out_oldest, uint32_t *out_newest) { - if (out_oldest) *out_oldest = 0; - if (out_newest) *out_newest = 0; - if (!gp0_ring || gp0_ring_seq == 0) return; - uint32_t avail = (gp0_ring_seq < GP0_RING_CAP) - ? (uint32_t)gp0_ring_seq : GP0_RING_CAP; - uint32_t start = (gp0_ring_seq < GP0_RING_CAP) ? 0 : gp0_ring_head; - uint32_t newest_idx = (start + avail - 1) % GP0_RING_CAP; - if (out_oldest) *out_oldest = gp0_ring[start].frame; - if (out_newest) *out_newest = gp0_ring[newest_idx].frame; -} - -/* ---- Draw census ring (ALWAYS-ON) ----------------------------------------- - * Every drawn primitive (GP0 opcode 0x20-0x7F) records: frame, the prim's DMA - * source address, the live camera (scratchpad camX/camY), and the prim's first - * vertex in DRAWING space (pre draw_offset). Purpose: see object spawn/despawn - * and edge-cull in DATA, not screenshots. When a background object despawns, - * its prim simply stops appearing in the census at a specific camX while its - * last recorded screen-x shows how far on-screen it still was — i.e. the - * effective (4:3-sized) despawn margin, which the 16:9 view exceeds. A prim - * present in the census but absent on screen would instead indict the renderer - * gate. Query via TCP `ws_census` → CSV file (large dumps mustn't ride TCP). */ -#define WS_CENSUS_CAP (1u << 21) /* 2,097,152 entries * 24B = 48 MiB */ -typedef struct { - uint32_t frame; - uint32_t src_addr; - int16_t cam_x, cam_y; - int16_t x, y; /* first vertex (screen / pre-draw_offset) */ - int16_t xmin, xmax; /* screen-X extent across the prim's position verts */ - int16_t base_x; /* back-buffer origin (draw_area_left) at draw time */ - uint8_t opcode; - uint8_t tagged; /* psx_ws_prim_is_tagged() at draw time */ -} WsCensusEntry; -static WsCensusEntry *ws_census = NULL; -static uint64_t ws_census_seq = 0; -static int ws_census_on = 1; /* always-on; toggle via ws_census */ -extern uint16_t psx_read_half(uint32_t addr); - -/* Screen-X extent (raw vertex coords = SX, pre-draw_offset) across a prim's - * position vertices. Polygons (0x20-0x3F) decoded exactly via the packet stride - * (pos word = 1 + i*(1 + gouraud + textured)); rects/sprites/lines fall back to - * the first vertex. Lets the census show how far right each path reaches and - * whether anything is drawn past the 4:3 edge (native-wide margin diagnosis). */ -static void prim_sx_extent(uint8_t op, int32_t *xmin, int32_t *xmax) { - int32_t lo = 0x7FFF, hi = -0x8000; - if (op >= 0x20 && op <= 0x3F) { - int nv = (op & 0x08) ? 4 : 3; - int stride = 1 + ((op & 0x10) ? 1 : 0) + ((op & 0x04) ? 1 : 0); - for (int i = 0; i < nv; i++) { - int32_t vx, vy; - parse_vertex(gp0_cmd_buf[1 + i * stride], &vx, &vy); - if (vx < lo) lo = vx; - if (vx > hi) hi = vx; - } - } else { - int32_t vx, vy; - parse_vertex(gp0_cmd_buf[1], &vx, &vy); - lo = hi = vx; - } - *xmin = lo; *xmax = hi; -} - -/* Natural-overhang noter (see the ws_ovh_* block up top for the rationale). - * Called for every draw prim; counts POLYGONS whose raw SX extent crosses - * outside [0, disp_w] by more than the jitter margin, and stamps the frame - * (with the 2-consecutive-frames sustained rule) when enough do. */ -static void ws_note_overhang(uint8_t op) { - if (op < 0x20 || op > 0x3F) return; /* polygons only — the world's prims */ - /* Sprite-funnel (tagged) prims slide in from off-screen routinely — - * title-card letter tiles are tagged poly quads entering from the edges - * and stamped a sustained overhang inside the hut (observed at frame - * 20245). The WORLD funnel (Cluster-A RTPT terrain) never tags, so - * untagged overhang is the uncorrupted "revealable content" signal. - * Untagged-only also leaves non-tag titles unchanged (is_tagged == 0). */ - if (psx_ws_prim_is_tagged()) return; - int32_t xmn, xmx; - prim_sx_extent(op, &xmn, &xmx); - int32_t W = ws_disp_w(); - if (xmn >= -WS_OVERHANG_DEEP_PX && xmx <= W + WS_OVERHANG_DEEP_PX) return; - uint32_t f = (uint32_t)s_frame_count; - if (f != ws_ovh_frame) { - ws_ovh_prev = (f == ws_ovh_frame + 1u) ? ws_ovh_count : 0; - ws_ovh_frame = f; ws_ovh_count = 0; - } - ws_ovh_count++; - if (ws_ovh_count >= WS_OVERHANG_MIN_PRIMS && ws_last_ovh_stamp != f) { - if (f == ws_last_ovh_stamp + 1u) ws_sust_ovh_stamp = f; - ws_last_ovh_stamp = f; - } -} - -static void ws_census_record(uint8_t opcode, int32_t x, int32_t y) { - if (!ws_census_on) return; - if (!ws_census) { - ws_census = (WsCensusEntry *)calloc(WS_CENSUS_CAP, sizeof(WsCensusEntry)); - if (!ws_census) { ws_census_on = 0; return; } - } - int32_t xmn, xmx; - prim_sx_extent(opcode, &xmn, &xmx); - WsCensusEntry *e = &ws_census[ws_census_seq & (WS_CENSUS_CAP - 1)]; - e->frame = (uint32_t)s_frame_count; - e->src_addr = gp0_cmd_source_addr; - e->cam_x = (int16_t)psx_read_half(0x1F800176); - e->cam_y = (int16_t)psx_read_half(0x1F800186); - e->x = (int16_t)x; - e->y = (int16_t)y; - e->xmin = (int16_t)xmn; - e->xmax = (int16_t)xmx; - e->base_x = (int16_t)draw_area_left; - e->opcode = opcode; - e->tagged = (uint8_t)psx_ws_prim_is_tagged(); - ws_census_seq++; -} - -/* Dump census rows for frames [f0,f1] to a CSV file. Returns row count. */ -int gpu_ws_census_dump(uint32_t f0, uint32_t f1, const char *path) { - if (!ws_census) return 0; - FILE *fp = fopen(path, "w"); - if (!fp) return -1; - fprintf(fp, "frame,src_addr,cam_x,cam_y,x,y,xmin,xmax,base_x,opcode,tagged\n"); - uint64_t total = ws_census_seq; - uint64_t avail = total < WS_CENSUS_CAP ? total : WS_CENSUS_CAP; - uint64_t start = total - avail; - int n = 0; - for (uint64_t s = start; s < total; s++) { - WsCensusEntry *e = &ws_census[s & (WS_CENSUS_CAP - 1)]; - if (e->frame < f0 || e->frame > f1) continue; - fprintf(fp, "%u,0x%08X,%d,%d,%d,%d,%d,%d,%d,0x%02X,%u\n", - e->frame, e->src_addr, e->cam_x, e->cam_y, e->x, e->y, - e->xmin, e->xmax, e->base_x, e->opcode, e->tagged); - n++; - } - fclose(fp); - return n; -} - -void gpu_ws_census_set(int on) { ws_census_on = on ? 1 : 0; } -uint64_t gpu_ws_census_seq(void) { return ws_census_seq; } - -/* Execute a fully-collected GP0 command */ -static void gp0_execute_command(void) { - uint8_t opcode = (gp0_cmd_buf[0] >> 24) & 0xFF; - gp0_opcode_count[opcode]++; - gp0_ring_record(gp0_cmd_buf, gp0_words_needed); - extern void ws_bg_phase_note(uint32_t op); - ws_bg_phase_note(opcode); /* native-wide 2D-backdrop stretch: background-phase latch */ - - if (ws_menu_due && opcode >= 0x20 && opcode <= 0x7F && (opcode & 0xFC) != 0x64) { /* a frame that begins with anything but a textured rectangle is not the front end's */ - ws_menu_due = 0; - ws_menu_give_back(); - } - /* Draw-census: capture every drawing primitive's first vertex + camera. */ - if (opcode >= 0x20 && opcode <= 0x7F) { - int32_t cvx, cvy; - parse_vertex(gp0_cmd_buf[1], &cvx, &cvy); - ws_census_record(opcode, cvx, cvy); - ws_note_overhang(opcode); /* 2D-only-scene classifier world signal */ - } - - /* Categorize for diagnostics */ - if (opcode <= 0x01) gp0_nop_count++; - else if (opcode == 0x02) gp0_fill_count++; - else if (opcode >= 0x20 && opcode <= 0x7F) gp0_draw_count++; - else if (opcode >= 0x80 && opcode <= 0xDF) gp0_copy_count++; - else if (opcode >= 0xE1 && opcode <= 0xE6) gp0_env_count++; - - switch (opcode) { - case 0x00: - case 0x01: - gp0_exec_nop(); - break; - - case 0x02: - gp0_exec_fill_rect(); - break; - - case 0xE1: - gp0_exec_draw_mode(); - break; - - case 0xE2: - gp0_exec_texture_window(); - break; - - case 0xE3: - gp0_exec_draw_area_tl(); - break; - - case 0xE4: - gp0_exec_draw_area_br(); - break; - - case 0xE5: - gp0_exec_draw_offset(); - break; - - case 0xE6: - gp0_exec_mask_bit(); - break; - - /* Drawing commands — polygons */ - case 0x20: case 0x21: case 0x22: case 0x23: - gp0_exec_mono_tri(); - break; - case 0x24: case 0x25: case 0x26: case 0x27: - gp0_exec_textured_tri(); - break; - case 0x28: case 0x29: case 0x2A: case 0x2B: - gp0_exec_mono_quad(); - break; - case 0x2C: case 0x2D: case 0x2E: case 0x2F: - gp0_exec_textured_quad(); - break; - case 0x30: case 0x31: case 0x32: case 0x33: - gp0_exec_shaded_tri(); - break; - case 0x34: case 0x35: case 0x36: case 0x37: - gp0_exec_shaded_textured_tri(); - break; - case 0x38: case 0x39: case 0x3A: case 0x3B: - gp0_exec_shaded_quad(); - break; - case 0x3C: case 0x3D: case 0x3E: case 0x3F: - gp0_exec_shaded_textured_quad(); - break; - - /* Lines */ - case 0x40: case 0x41: case 0x42: case 0x43: - case 0x44: case 0x45: case 0x46: case 0x47: - gp0_exec_mono_line(); - break; - case 0x50: case 0x51: case 0x52: case 0x53: - case 0x54: case 0x55: case 0x56: case 0x57: - gp0_exec_shaded_line(); - break; - - /* Rectangles */ - case 0x60: case 0x61: case 0x62: case 0x63: - gp0_exec_mono_rect(); - break; - case 0x64: case 0x65: case 0x66: case 0x67: - gp0_exec_textured_rect(); - break; - case 0x68: case 0x69: case 0x6A: case 0x6B: - gp0_exec_mono_dot(); - break; - case 0x6C: case 0x6D: case 0x6E: case 0x6F: { - /* 1x1 textured dot: cmd, vertex, texcoord+clut (no size word) */ - uint32_t color24 = gp0_cmd_buf[0] & 0xFFFFFFu; - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; - int32_t x0, y0; - parse_vertex(gp0_cmd_buf[1], &x0, &y0); - int dot_h; - (void)ws_sprt_fixed_transform(&x0, &y0, 1, &dot_h); /* position only; 1px stays 1px */ - x0 += draw_offset_x; y0 += draw_offset_y; - int u0 = gp0_cmd_buf[2] & 0xFF; - int v0 = (gp0_cmd_buf[2] >> 8) & 0xFF; - uint16_t clut = (uint16_t)(gp0_cmd_buf[2] >> 16); - uint16_t clut_x = (clut & 0x3F) * 16; - uint16_t clut_y = (clut >> 6) & 0x1FF; - setup_textured_draw(color24, semi_trans, raw_texture); - gr_draw_textured_rect(x0, y0, 1, 1, u0, v0, clut_x, clut_y, current_texpage()); - break; - } - case 0x70: case 0x71: case 0x72: case 0x73: - gp0_exec_mono_8x8(); - break; - case 0x74: case 0x75: case 0x76: case 0x77: - gp0_exec_textured_8x8(); - break; - case 0x78: case 0x79: case 0x7A: case 0x7B: { - /* 16x16 mono sprite */ - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); - int32_t x0, y0; - parse_vertex(gp0_cmd_buf[1], &x0, &y0); - const int w = ws_menu_rect(&x0, 16); - x0 += draw_offset_x; y0 += draw_offset_y; - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - gr_draw_flat_rect(x0, y0, w, 16, color); - break; - } - case 0x7C: case 0x7D: case 0x7E: case 0x7F: - gp0_exec_textured_16x16(); - break; - - /* VRAM→VRAM copy */ - case 0x80: case 0x81: case 0x82: case 0x83: - case 0x84: case 0x85: case 0x86: case 0x87: - case 0x88: case 0x89: case 0x8A: case 0x8B: - case 0x8C: case 0x8D: case 0x8E: case 0x8F: - case 0x90: case 0x91: case 0x92: case 0x93: - case 0x94: case 0x95: case 0x96: case 0x97: - case 0x98: case 0x99: case 0x9A: case 0x9B: - case 0x9C: case 0x9D: case 0x9E: case 0x9F: { - /* Word 1: source coords, Word 2: dest coords, Word 3: dimensions */ - int src_x = gp0_cmd_buf[1] & 0x3FF; - int src_y = (gp0_cmd_buf[1] >> 16) & 0x1FF; - int dst_x = gp0_cmd_buf[2] & 0x3FF; - int dst_y = (gp0_cmd_buf[2] >> 16) & 0x1FF; - int w = gp0_cmd_buf[3] & 0x3FF; - int h = (gp0_cmd_buf[3] >> 16) & 0x1FF; - if (w == 0) w = 0x400; - if (h == 0) h = 0x200; - ws_menu_yield(src_x, src_y, w, h, 0); - ws_menu_yield(dst_x, dst_y, w, h, 1); - gr_copy_rect(src_x, src_y, dst_x, dst_y, w, h); - ws_frame_pictured(dst_x, dst_y, w, h); - break; - } - - case 0xA0: case 0xA1: case 0xA2: case 0xA3: - case 0xA4: case 0xA5: case 0xA6: case 0xA7: - case 0xA8: case 0xA9: case 0xAA: case 0xAB: - case 0xAC: case 0xAD: case 0xAE: case 0xAF: - case 0xB0: case 0xB1: case 0xB2: case 0xB3: - case 0xB4: case 0xB5: case 0xB6: case 0xB7: - case 0xB8: case 0xB9: case 0xBA: case 0xBB: - case 0xBC: case 0xBD: case 0xBE: case 0xBF: - gp0_exec_cpu_to_vram(); - break; - - case 0xC0: case 0xC1: case 0xC2: case 0xC3: - case 0xC4: case 0xC5: case 0xC6: case 0xC7: - case 0xC8: case 0xC9: case 0xCA: case 0xCB: - case 0xCC: case 0xCD: case 0xCE: case 0xCF: - case 0xD0: case 0xD1: case 0xD2: case 0xD3: - case 0xD4: case 0xD5: case 0xD6: case 0xD7: - case 0xD8: case 0xD9: case 0xDA: case 0xDB: - case 0xDC: case 0xDD: case 0xDE: case 0xDF: - gp0_exec_vram_to_cpu(); - break; - - default: - /* NOP range: 0x03-0x1E, 0xE0, 0xE7-0xEF, 0xFF */ - if ((opcode >= 0x03 && opcode <= 0x1E) || - opcode == 0xE0 || - (opcode >= 0xE7 && opcode <= 0xEF) || - opcode == 0xFF) { - break; /* NOP — silently consume */ - } - - /* Any other command (drawing, VRAM-to-VRAM, etc.) is not yet - * implemented. Fatal halt so we know exactly what's needed next, - * with all rings queryable post-mortem. */ - { - static char reason[96]; - snprintf(reason, sizeof(reason), - "GPU GP0 unimplemented command 0x%02X (word 0x%08X)", - opcode, gp0_cmd_buf[0]); - psx_fatal_halt(reason); - } - } -} - -/* ---- GP0 write (0x1F801810 write) — command state machine ---- */ - -uint64_t gpu_get_gp0_count(void) { return gp0_write_count; } - -void gpu_get_gp0_stats(uint64_t* nop, uint64_t* fill, uint64_t* draw, uint64_t* env, uint64_t* copy) { - *nop = gp0_nop_count; *fill = gp0_fill_count; - *draw = gp0_draw_count; *env = gp0_env_count; *copy = gp0_copy_count; -} - -void gpu_get_draw_area(GpuDrawArea* out) { - out->left = draw_area_left; - out->top = draw_area_top; - out->right = draw_area_right; - out->bottom = draw_area_bottom; - out->offset_x = draw_offset_x; - out->offset_y = draw_offset_y; -} - -uint16_t gpu_vram_peek(int x, int y) { - if (x < 0 || x >= 1024 || y < 0 || y >= 512) return 0; - /* Through the facade so the GL backend syncs its FBO down first. */ - return gr_vram_read(x, y); -} - -static void gpu_write_gp0_body(uint32_t val) { - gp0_write_count++; - - /* State: consuming pixel data for CPU→VRAM transfer */ - if (gp0_state == GP0_VRAM_WRITE) { - /* Capture first few data words for debug */ - if (a0_capture_slot >= 0 && a0_capture_slot < A0_HISTORY_CAP) { - int wc = a0_history[a0_capture_slot].word_count++; - if (wc < 4) - a0_history[a0_capture_slot].first_words[wc] = val; - } - /* Each word contains two 16-bit pixels (low halfword first) */ - for (int i = 0; i < 2; i++) { - uint16_t pixel = (uint16_t)(val >> (i * 16)); - uint16_t wx = (vram_write_x + vram_write_col) % 1024; - uint16_t wy = (vram_write_y + vram_write_row) % 512; - - /* Respect mask bit settings. The check reads through the facade: - * under the GL backend GPU-drawn mask bits live in the FBO, not - * the CPU array (one sync per burst; free when check is off). */ - if (check_mask_bit && (gr_vram_read((int)wx, (int)wy) & 0x8000)) { - goto next_pixel; - } - - if (set_mask_bit) - pixel |= 0x8000; - - /* CPU VRAM remains immediately authoritative, preserving mid-DMA - * reads/savestates. The renderer mirror is committed in bulk when - * the GP0 payload completes. */ - vram[(uint32_t)wy * 1024u + wx] = pixel; - - next_pixel: - if (++vram_write_col == vram_write_w) { - vram_write_col = 0; - if (++vram_write_row == vram_write_h) { - /* Transfer complete */ - gp0_commit_cpu_to_vram(); - return; - } - } - } - - if (--vram_write_remaining == 0) { - gp0_commit_cpu_to_vram(); - } - - return; - } - - /* State: mono polyline — each word is a vertex (or terminator) */ - if (gp0_state == GP0_POLYLINE_MONO) { - if ((val & 0xF000F000u) == 0x50005000u) { - /* Terminator: hardware ends a polyline ONLY when the masked word - * matches 0x50005000 (the 0x55555555 terminator) — Beetle - * gpu.cpp:1030, psx-spx. The old `(val & 0xF000F000) != 0` test - * also fired on any NEGATIVE vertex coordinate (Y=0xFFxx) and, at - * shaded color positions, on any color component >= 0x10 in the - * G byte — ending the polyline early and re-parsing its remaining - * words as new GP0 commands. That de-phased the whole command - * stream: garbage prims all over the Tomba2 attract (texture - * garble) and eventually a legit texcoord word 0xFE65FE58 parsed - * in IDLE state -> "GP0 unknown command 0xFE" fatal (village). */ - gp0_state = GP0_IDLE; - return; - } - int32_t x, y; - parse_vertex(val, &x, &y); - x += draw_offset_x; y += draw_offset_y; - if (polyline_has_prev && - !psx_gpu_line_oversize(polyline_prev_x, polyline_prev_y, x, y)) { - gr_draw_line(polyline_prev_x, polyline_prev_y, x, y, polyline_color); - } - polyline_prev_x = x; polyline_prev_y = y; - polyline_has_prev = 1; - return; - } - - /* State: shaded polyline — alternating color, vertex words */ - if (gp0_state == GP0_POLYLINE_SHADED) { - /* The terminator can arrive in either the color or vertex position. - * Check it before interpreting the alternating shaded-polyline stream; - * otherwise a vertex-position terminator is consumed as coordinates and - * de-phases all following GP0 commands. */ - if ((val & 0xF000F000u) == 0x50005000u) { - gp0_state = GP0_IDLE; - return; - } - /* Even words (after cmd) are colors, odd words are vertices. - * Sequence: [cmd+C0] [V0] [C1] [V1] [C2] [V2] ... - * polyline_has_prev tracks: 0=need V0, 1=need C_next, 2=need V_next */ - if (!polyline_has_prev) { - /* First vertex */ - int32_t x, y; - parse_vertex(val, &x, &y); - x += draw_offset_x; y += draw_offset_y; - polyline_prev_x = x; polyline_prev_y = y; - polyline_prev_c = polyline_color; - polyline_has_prev = 1; - return; - } - if (polyline_has_prev == 1) { - /* Expecting color word. */ - polyline_color = rgb888_to_rgb555(val & 0xFFFFFFu); - polyline_has_prev = 2; - return; - } - /* polyline_has_prev == 2: vertex word */ - { - int32_t x, y; - parse_vertex(val, &x, &y); - x += draw_offset_x; y += draw_offset_y; - if (!psx_gpu_line_oversize(polyline_prev_x, polyline_prev_y, x, y)) - gr_draw_shaded_line(polyline_prev_x, polyline_prev_y, - polyline_prev_c, x, y, polyline_color); - polyline_prev_x = x; polyline_prev_y = y; - polyline_prev_c = polyline_color; - polyline_has_prev = 1; - } - return; - } - - /* State: collecting words for a multi-word command */ - if (gp0_state == GP0_COLLECTING) { - gp0_cmd_buf[gp0_words_collected++] = val; - if (gp0_words_collected >= gp0_words_needed) { - gp0_state = GP0_IDLE; - gp0_execute_command(); - } - return; - } - - /* State: IDLE — this is the first word of a new command */ - uint8_t opcode = (val >> 24) & 0xFF; - int word_count = gp0_command_word_count(opcode); - - if (word_count == 0) { - /* Unknown command — fatal halt with rings queryable post-mortem */ - static char reason[96]; - snprintf(reason, sizeof(reason), - "GPU GP0 unknown command 0x%02X (word 0x%08X)", opcode, val); - psx_fatal_halt(reason); - } - - if (word_count < 0) { - /* Variable-length polyline command. - * Mono (0x48-0x4F): [cmd+color] [v0] [v1] ... [terminator] - * Shaded(0x58-0x5F): [cmd+C0] [v0] [C1] [v1] ... [terminator] - * Terminator: (word & 0xF000F000) == 0x50005000 (0x55555555). */ - int shaded = (opcode & 0x10) != 0; /* 0x58+ = shaded, 0x48+ = mono */ - polyline_semi_trans = (val >> 25) & 1; - polyline_color = rgb888_to_rgb555(val & 0xFFFFFFu); - polyline_prev_c = polyline_color; - polyline_has_prev = 0; - gr_set_semi_transparency(polyline_semi_trans, (int)semi_transparency); - gp0_state = shaded ? GP0_POLYLINE_SHADED : GP0_POLYLINE_MONO; - gp0_draw_count++; - /* Record polyline header (variable-length body not captured; - * just enough so per-frame stream shows the polyline existed). */ - gp0_opcode_count[opcode]++; - uint32_t hdr_only[1] = { val }; - gp0_ring_record(hdr_only, 1); - return; - } - - gp0_cmd_buf[0] = val; - gp0_cmd_source_addr = gp0_next_source_addr; - - if (word_count == 1) { - gp0_words_collected = 1; - gp0_words_needed = 1; - gp0_execute_command(); - } else { - gp0_state = GP0_COLLECTING; - gp0_words_collected = 1; - gp0_words_needed = word_count; - } -} - -/* Wall-time sampler bracket (phase_profile): tag GP0 command processing — - * rasterization / batching / VRAM transfer work on the emu thread — as its - * own phase so it is separable from the guest code that issued the write. - * Covers both the MMIO store chokepoint and DMA channel-2 feeds. */ -void gpu_write_gp0(uint32_t val) { - extern int g_exec_phase; - int prev_phase = g_exec_phase; - g_exec_phase = 4; - gpu_write_gp0_body(val); - g_exec_phase = prev_phase; -} - -/* ---- GP1 write (0x1F801814 write) ---- */ - -static void gp1_reset(void) { - /* GP1(00h): Reset GPU — clears FIFO/control state and disables display. - * VRAM contents survive a GPU reset on real hardware; only power-on init - * clears our backing store. */ - gpu_reset_state(0); -} - -static void gp1_reset_command_buffer(void) { - /* GP1(01h): Reset command buffer — clears FIFO, aborts current command */ - gp0_state = GP0_IDLE; - gp0_words_collected = 0; - gp0_words_needed = 0; - vram_write_remaining = 0; -} - -static void gp1_ack_irq1(void) { - /* GP1(02h): Acknowledge IRQ1 */ - irq1_flag = 0; -} - -static void gp1_display_enable(uint32_t val) { - /* GP1(03h): Display enable — bit 0: 0=on, 1=off */ - uint32_t next_disabled = val & 1; - /* Native-wide reveal pixels have no guest-VRAM backing, so they cannot be - * reconstructed from the canonical framebuffer after a scene change. Treat - * the guest's ON->OFF transition as invalidation of those synthetic strips. - * This is deliberately transition-scoped: MMX6 retains valid background - * pixels across ordinary frames, so per-frame clearing pillarboxes gameplay. */ - if (ws_clear_reveal && next_disabled && !display_disabled && ws_mode == 2) { - ws_clear_all_reveal_margins(); - } - display_disabled = next_disabled; -} - -static void gp1_dma_direction(uint32_t val) { - /* GP1(04h): DMA direction — bits 0-1 */ - dma_direction = val & 3; -} - -static void gp1_display_area_start(uint32_t val) { - /* GP1(05h): Start of display area in VRAM - * bits 0-9: X (in halfwords, 0-1023) - * bits 10-18: Y (0-511) */ - display_area_x = val & 0x3FF; - display_area_y = (val >> 10) & 0x1FF; - ws_note_display_base(display_area_x); /* learn the display buffer set (native-wide) */ -} - -static void gp1_h_display_range(uint32_t val) { - /* GP1(06h): Horizontal display range - * bits 0-11: X1 - * bits 12-23: X2 */ - h_display_x1 = val & 0xFFF; - h_display_x2 = (val >> 12) & 0xFFF; -} - -static void gp1_v_display_range(uint32_t val) { - /* GP1(07h): Vertical display range - * bits 0-9: Y1 - * bits 10-19: Y2 */ - uint32_t y1 = val & 0x3FF; - uint32_t y2 = (val >> 10) & 0x3FF; - uint32_t h = (y2 > y1) ? (y2 - y1) : 0u; - /* MotK intro→crawl retargets the band while staying in depth24. Stale - * trailing RGB from the prior movie would flash for a frame or two — - * reset the upload span and hold present (skip Swap) for 3 vblanks. */ - if ((display_depth & 1u) && s_d24_prev_disp_h != 0u && h != s_d24_prev_disp_h) { - s_d24_upload_x1 = 0; - s_d24_present_hold = 3; - } - v_display_y1 = y1; - v_display_y2 = y2; - if (h != 0u) s_d24_prev_disp_h = h; -} - -static void gp1_display_mode(uint32_t val) { - /* GP1(08h): Display mode - * bit 0-1: horizontal resolution 1 (0=256, 1=320, 2=512, 3=640) - * bit 2: vertical resolution (0=240, 1=480) - * bit 3: video mode (0=NTSC, 1=PAL) - * bit 4: display area color depth (0=15bit, 1=24bit) - * bit 5: vertical interlace (0=off, 1=on) - * bit 6: horizontal resolution 2 (0=normal, 1=368) - * bit 7: "reverseflag" */ - hres1 = val & 3; - vres = (val >> 2) & 1; - video_mode = (val >> 3) & 1; - const uint32_t depth = (val >> 4) & 1; - /* A 24-bit scanout reads the CPU copy of VRAM, and a GPU backend leaves that copy without what it drew. Disruptor - * blacks out its movie's area with a rectangle a few VBlanks before it switches, so sync once, at the switch. */ - if (depth && !display_depth) (void)gr_vram_read(0, 0); - display_depth = depth; - vertical_interlace = (val >> 5) & 1; - /* GPUSTAT.13 holds the legacy constant 0 in progressive (see the vblank - * field flip); clear it on the switch so a title that toggles interlace - * on and back off doesn't leave the field bit latched at 1. */ - if (!vertical_interlace) - interlace_field = 0; - hres2 = (val >> 6) & 1; - reverse_flag = (val >> 7) & 1; -} - -static void gp1_get_info(uint32_t val) { - /* GP1(10h): Get GPU info — writes result to GPUREAD latch. - * Mednafen-psx masks the subcommand to 4 bits (val & 0x0F) and - * services cases 2..5, 7, 8. Tomba's ResetGraph() uses param 7 to - * read the GPU version (must be 2) to pick its video-mode path — - * the wrong value here lands the game on a no-draw branch. */ - uint32_t which = val & 0x0F; - switch (which) { - case 2: /* texture window */ - gpuread_latch = texture_window_value; - break; - case 3: /* draw area top-left */ - gpuread_latch = draw_area_left | (draw_area_top << 10); - break; - case 4: /* draw area bottom-right */ - gpuread_latch = draw_area_right | (draw_area_bottom << 10); - break; - case 5: /* draw offset */ - gpuread_latch = ((uint32_t)draw_offset_x & 0x7FFu) | - (((uint32_t)draw_offset_y & 0x7FFu) << 11); - break; - case 7: /* GPU version (real-hw + mednafen return 2) */ - gpuread_latch = 2; - break; - case 8: /* unknown info index, real hw / mednafen return 0 */ - gpuread_latch = 0; - break; - default: - /* N=0,1,6,9..15: leave latch unchanged */ - break; - } -} - -void gpu_write_gp1(uint32_t val) { - uint32_t cmd = (val >> 24) & 0x3F; - - switch (cmd) { - case 0x00: gp1_reset(); break; - case 0x01: gp1_reset_command_buffer(); break; - case 0x02: gp1_ack_irq1(); break; - case 0x03: gp1_display_enable(val); break; - case 0x04: gp1_dma_direction(val); break; - case 0x05: gp1_display_area_start(val); break; - case 0x06: gp1_h_display_range(val); break; - case 0x07: gp1_v_display_range(val); break; - case 0x08: gp1_display_mode(val); break; - case 0x10: case 0x11: case 0x12: case 0x13: - case 0x14: case 0x15: case 0x16: case 0x17: - case 0x18: case 0x19: case 0x1A: case 0x1B: - case 0x1C: case 0x1D: case 0x1E: case 0x1F: - gp1_get_info(val); break; - default: { - static char reason[96]; - snprintf(reason, sizeof(reason), - "GPU GP1 unknown command 0x%02X (word 0x%08X)", cmd, val); - psx_fatal_halt(reason); - } - } -} - -/* ---- boot snapshot: complete GPU register state (see boot_state.h) ---- */ -#define GPU_SNAP_FIELDS(X) \ - X(texpage_x) X(texpage_y) X(semi_transparency) X(texpage_colors) \ - X(dither_enabled) X(draw_to_display) X(texture_disable) X(texture_window_value) \ - X(set_mask_bit) X(check_mask_bit) \ - X(interlace_field) X(reverse_flag) \ - X(draw_area_left) X(draw_area_top) X(draw_area_right) X(draw_area_bottom) \ - X(draw_offset_x) X(draw_offset_y) \ - X(hres1) X(hres2) X(vres) X(video_mode) X(display_depth) X(vertical_interlace) \ - X(display_disabled) X(irq1_flag) X(dma_direction) X(lcf) \ - X(display_area_x) X(display_area_y) \ - X(h_display_x1) X(h_display_x2) X(v_display_y1) X(v_display_y2) \ - X(gpuread_latch) \ - X(gp0_state) \ - X(gp0_cmd_buf) X(gp0_words_collected) X(gp0_words_needed) \ - X(gp0_next_source_addr) X(gp0_cmd_source_addr) \ - X(polyline_color) X(polyline_prev_x) X(polyline_prev_y) X(polyline_prev_c) \ - X(polyline_semi_trans) X(polyline_has_prev) \ - X(vram_write_x) X(vram_write_y) X(vram_write_w) X(vram_write_h) \ - X(vram_write_col) X(vram_write_row) X(vram_write_remaining) \ - X(vram_read_active) X(vram_read_x) X(vram_read_y) X(vram_read_w) X(vram_read_h) \ - X(vram_read_col) X(vram_read_row) -#define GPU_COSIM_SNAP_FIELDS(X) \ - X(texpage_x) X(texpage_y) X(semi_transparency) X(texpage_colors) \ - X(dither_enabled) X(draw_to_display) X(texture_disable) X(texture_window_value) \ - X(set_mask_bit) X(check_mask_bit) \ - X(interlace_field) X(reverse_flag) \ - X(draw_area_left) X(draw_area_top) X(draw_area_right) X(draw_area_bottom) \ - X(draw_offset_x) X(draw_offset_y) \ - X(hres1) X(hres2) X(vres) X(video_mode) X(display_depth) X(vertical_interlace) \ - X(display_disabled) X(irq1_flag) X(dma_direction) X(lcf) \ - X(display_area_x) X(display_area_y) \ - X(h_display_x1) X(h_display_x2) X(v_display_y1) X(v_display_y2) \ - X(gpuread_latch) \ - X(gp0_state) \ - X(gp0_cmd_buf) X(gp0_words_collected) X(gp0_words_needed) \ - X(polyline_color) X(polyline_prev_x) X(polyline_prev_y) X(polyline_prev_c) \ - X(polyline_semi_trans) X(polyline_has_prev) \ - X(vram_write_x) X(vram_write_y) X(vram_write_w) X(vram_write_h) \ - X(vram_write_col) X(vram_write_row) X(vram_write_remaining) \ - X(vram_read_active) X(vram_read_x) X(vram_read_y) X(vram_read_w) X(vram_read_h) \ - X(vram_read_col) X(vram_read_row) -#include "pst_wire.h" - -/* GPU snap fields are scalars / u32 arrays — emit as LE u32/i32 (no struct pad). */ -static int gpu_snap_emit(PstW *w) { -#define WU(f) do { if (!pst_w_u32(w, (uint32_t)(f))) return 0; } while (0) -#define WI(f) do { if (!pst_w_i32(w, (int32_t)(f))) return 0; } while (0) -#define WH(f) do { if (!pst_w_u16(w, (uint16_t)(f))) return 0; } while (0) - WU(texpage_x); WU(texpage_y); WU(semi_transparency); WU(texpage_colors); - WU(dither_enabled); WU(draw_to_display); WU(texture_disable); WU(texture_window_value); - WU(set_mask_bit); WU(check_mask_bit); - WU(interlace_field); WU(reverse_flag); - WU(draw_area_left); WU(draw_area_top); WU(draw_area_right); WU(draw_area_bottom); - WI(draw_offset_x); WI(draw_offset_y); - WU(hres1); WU(hres2); WU(vres); WU(video_mode); WU(display_depth); WU(vertical_interlace); - WU(display_disabled); WU(irq1_flag); WU(dma_direction); WU(lcf); - WU(display_area_x); WU(display_area_y); - WU(h_display_x1); WU(h_display_x2); WU(v_display_y1); WU(v_display_y2); - WU(gpuread_latch); - WU((uint32_t)gp0_state); - for (int i = 0; i < 16; i++) WU(gp0_cmd_buf[i]); - WI(gp0_words_collected); WI(gp0_words_needed); - WU(gp0_next_source_addr); WU(gp0_cmd_source_addr); - WH(polyline_color); WI(polyline_prev_x); WI(polyline_prev_y); WH(polyline_prev_c); - WI(polyline_semi_trans); WI(polyline_has_prev); - WH(vram_write_x); WH(vram_write_y); WH(vram_write_w); WH(vram_write_h); - WH(vram_write_col); WH(vram_write_row); WU(vram_write_remaining); - WI(vram_read_active); WH(vram_read_x); WH(vram_read_y); WH(vram_read_w); WH(vram_read_h); - WH(vram_read_col); WH(vram_read_row); -#undef WU -#undef WI -#undef WH - return 1; -} -static int gpu_snap_parse(PstR *r) { - uint32_t u; int32_t i; uint16_t h; -#define RU(f) do { if (!pst_r_u32(r, &u)) return 0; (f) = u; } while (0) -#define RI(f) do { if (!pst_r_i32(r, &i)) return 0; (f) = i; } while (0) -#define RH(f) do { if (!pst_r_u16(r, &h)) return 0; (f) = h; } while (0) - RU(texpage_x); RU(texpage_y); RU(semi_transparency); RU(texpage_colors); - RU(dither_enabled); RU(draw_to_display); RU(texture_disable); RU(texture_window_value); - RU(set_mask_bit); RU(check_mask_bit); - RU(interlace_field); RU(reverse_flag); - RU(draw_area_left); RU(draw_area_top); RU(draw_area_right); RU(draw_area_bottom); - RI(draw_offset_x); RI(draw_offset_y); - RU(hres1); RU(hres2); RU(vres); RU(video_mode); RU(display_depth); RU(vertical_interlace); - RU(display_disabled); RU(irq1_flag); RU(dma_direction); RU(lcf); - RU(display_area_x); RU(display_area_y); - RU(h_display_x1); RU(h_display_x2); RU(v_display_y1); RU(v_display_y2); - RU(gpuread_latch); - if (!pst_r_u32(r, &u)) return 0; - gp0_state = (Gp0State)u; - for (int k = 0; k < 16; k++) RU(gp0_cmd_buf[k]); - RI(gp0_words_collected); RI(gp0_words_needed); - RU(gp0_next_source_addr); RU(gp0_cmd_source_addr); - RH(polyline_color); RI(polyline_prev_x); RI(polyline_prev_y); RH(polyline_prev_c); - RI(polyline_semi_trans); RI(polyline_has_prev); - RH(vram_write_x); RH(vram_write_y); RH(vram_write_w); RH(vram_write_h); - RH(vram_write_col); RH(vram_write_row); RU(vram_write_remaining); - RI(vram_read_active); RH(vram_read_x); RH(vram_read_y); RH(vram_read_w); RH(vram_read_h); - RH(vram_read_col); RH(vram_read_row); -#undef RU -#undef RI -#undef RH - return 1; -} - -uint32_t gpu_snapshot_bytes(void) { - PstW w; pst_w_init(&w, NULL, 0); - (void)gpu_snap_emit(&w); - return (uint32_t)w.written; -} -void gpu_snapshot_write(uint8_t *p) { - PstW w; uint32_t n = gpu_snapshot_bytes(); - ws_menu_give_back(); /* a save state reads video memory from the renderer after this, and holds the game's alone */ - pst_w_init(&w, p, n); - (void)gpu_snap_emit(&w); -} -uint32_t gpu_cosim_snapshot_bytes(void) { return gpu_snapshot_bytes(); } -void gpu_cosim_snapshot_write(uint8_t *p) { /* a hash of the registers between blocks, not a save state: nothing is given back for it */ - PstW w; - pst_w_init(&w, p, gpu_snapshot_bytes()); - (void)gpu_snap_emit(&w); -} -void gpu_cosim_dump(char *out, int cap) { - if (!out || cap <= 0) return; - char *p = out; - size_t rem = (size_t)cap; -#define APPEND(fmt, ...) do { \ - if (rem <= 1) break; \ - int w = snprintf(p, rem, fmt, __VA_ARGS__); \ - if (w < 0) { out[0] = 0; return; } \ - if ((size_t)w >= rem) { p += rem - 1; rem = 1; } \ - else { p += (size_t)w; rem -= (size_t)w; } \ - } while (0) -#define X(f) APPEND(" %s %llx", #f, (unsigned long long)(uint32_t)(f)); - X(texpage_x) X(texpage_y) X(semi_transparency) X(texpage_colors) - X(dither_enabled) X(draw_to_display) X(texture_disable) X(texture_window_value) - X(set_mask_bit) X(check_mask_bit) - X(interlace_field) X(reverse_flag) - X(draw_area_left) X(draw_area_top) X(draw_area_right) X(draw_area_bottom) - X(draw_offset_x) X(draw_offset_y) - X(hres1) X(hres2) X(vres) X(video_mode) X(display_depth) X(vertical_interlace) - X(display_disabled) X(irq1_flag) X(dma_direction) X(lcf) - X(display_area_x) X(display_area_y) - X(h_display_x1) X(h_display_x2) X(v_display_y1) X(v_display_y2) - X(gpuread_latch) - X(gp0_state) - for (int i = 0; i < 16; i++) { - APPEND(" gp0_cmd_buf%d %08x", i, gp0_cmd_buf[i]); - } - X(gp0_words_collected) X(gp0_words_needed) - X(gp0_next_source_addr) X(gp0_cmd_source_addr) - X(polyline_color) X(polyline_prev_x) X(polyline_prev_y) X(polyline_prev_c) - X(polyline_semi_trans) X(polyline_has_prev) - X(vram_write_x) X(vram_write_y) X(vram_write_w) X(vram_write_h) - X(vram_write_col) X(vram_write_row) X(vram_write_remaining) - X(vram_read_active) X(vram_read_x) X(vram_read_y) X(vram_read_w) X(vram_read_h) - X(vram_read_col) X(vram_read_row) -#undef X - APPEND("%s", "\n"); -#undef APPEND -} -int gpu_snapshot_read(const uint8_t *p, uint32_t len) { - PstR r; - if (len != gpu_snapshot_bytes()) return 0; - pst_r_init(&r, p, len); - if (!gpu_snap_parse(&r)) return 0; - ws_menu_lent = ws_menu_rows = ws_menu_logo = ws_menu_fresh = 0; /* the loaded video memory holds nothing of the wide menu's, and the CPU copy is not it */ - /* Sync renderer clip/scissor to restored GP0(E3/E4); vars alone leave GL - * on stale drawing state after savestate load. The offset also selects the - * correct framebuffer-relative horizon for optional full-Y yaw. */ - gr_set_draw_area((int)draw_area_left, (int)draw_area_top, - (int)draw_area_right, (int)draw_area_bottom); - gr_set_draw_offset(draw_offset_x, draw_offset_y); - geometry_camera_presentation_update(); - ws_nw_sync_target(); - return 1; -} -uint16_t* gpu_get_vram_ptr(void){ return vram; } -uint32_t gpu_get_vram_bytes(void){ return (uint32_t)sizeof(vram); } diff --git a/psxrecomp-overlay/runtime/src/gpu_gl_renderer.c b/psxrecomp-overlay/runtime/src/gpu_gl_renderer.c deleted file mode 100644 index de176b9..0000000 --- a/psxrecomp-overlay/runtime/src/gpu_gl_renderer.c +++ /dev/null @@ -1,5309 +0,0 @@ -/* gpu_gl_renderer.c — hardware OpenGL renderer backend. - * - * ARCHITECTURE (v2 — GPU-authoritative VRAM) - * ------------------------------------------- - * The FBO color texture (`s_hr_tex`, RGBA8, 1024*S x 512*S where S is the - * internal-resolution scale from [video] supersampling) is the single - * authoritative copy of VRAM. EVERY mutation goes through the GPU: - * - * - polys / rects / lines -> rasterized into the hr FBO - * - GP0(02h) fills -> scissored glClear (color + stencil) - * - VRAM->VRAM copies -> hr FBO -> scratch texture blit, then a - * masked quad draw back into the hr FBO - * - CPU->VRAM transfers -> written to the CPU mirror immediately and - * accumulated into a pending-upload rect; the - * rect is flushed (staging texture + quad into - * the hr FBO, plus a direct R16UI subimage) - * before the next GPU op, preserving op order - * - * PS1 MASK BIT (bit15). The FBO alpha channel carries bit15 exactly - * (1.0 = set), and a stencil buffer mirrors it (stencil bit0 == bit15): - * - "set mask" -> fragment alpha + stencil write value - * - "check mask"-> stencil test (pass iff stored == 0). The stencil write - * value is coupled to the test reference in GL, so when checking we use - * GL_INVERT on pass to write a 1 (stored is known 0 when the test - * passes) and GL_KEEP to write a 0. - * - textured prims (and copies/uploads, whose pixels carry per-texel STP - * bits) are drawn in TWO passes split by the STP bit via discard, so - * each pass writes a single known stencil value. The same split already - * existed for semi-transparent texture blending. - * - blending uses glBlendFuncSeparate so the alpha (mask) channel is - * always REPLACED by the source fragment's mask bit, never blended. - * - * TEXTURE SAMPLING / RENDER-TO-TEXTURE. Textured prims sample a native-res - * R16UI mirror (`s_raw_tex`) holding raw 1555 VRAM values (CLUT decode + - * texture window + optional bilinear in the fragment shader). GPU draws - * mark a native-coords dirty union; before any textured draw whose texture - * page or CLUT intersects the union, a PACK pass re-encodes the dirty - * region of the hr FBO into the raw mirror (point-sampled at native - * coords). CPU->VRAM uploads update the raw mirror directly. So content - * rendered by the GPU is immediately valid as a texture source. - * - * CPU READBACKS (VRAM->CPU transfers, GPUREAD, screenshots, 24-bit FMV - * display) flush uploads + pack, then glReadPixels the raw mirror straight - * into the CPU VRAM array (raw 1555, no conversion loop). - * - * PRESENT is deterministic: 15-bit frames always blit the display region - * from the hr FBO into a 4:3 letterboxed rect (single path — no more - * frame-to-frame alternation between FBO and CPU presents). 24-bit (FMV) - * frames sync to CPU and use the quad-present path, also letterboxed. - * PSX_GL_FORCE_CPU_PRESENT=1 (read by main.cpp) forces the CPU path as a - * diagnostic. - * - * Known divergences from the software rasterizer (accepted, documented): - * - GL triangle/line coverage rules differ from the PS1 DDA by ±1px on - * edges; lines use GL_LINES (width S) instead of Bresenham. - * - No dithering (the software path doesn't dither either). - * - Gouraud interpolation happens at 8-bit precision instead of 5-bit - * (smoother gradients; readback re-quantizes to 5-bit). - * - VRAM-wrapping draws are clamped, except GP0(02h) fills which split - * into wrapped segments. Wrapping copies/draws are unused by real SDKs. - * - * Init is all-or-nothing: if any shader/FBO fails, gl_renderer_init_context - * returns 0 and the runtime falls back to the pure software renderer — no - * half-GL hybrid. */ - -#include "gpu.h" -#include "gpu_render.h" -#include "gpu_sw_renderer.h" -#include "gpu_gl_renderer.h" -#include "gpu_turned_perspective.h" -#include "gpu_world_depth.h" -#include "host_ui.h" -#include "latency_ring.h" - -#include "psx_sdl.h" -#if defined(PSX_SDL3) -#include -#else -#include -#endif -#include -#include -#include -#include -#include - -#ifndef GL_BGRA -#define GL_BGRA 0x80E1 -#endif -#ifndef GL_CLAMP_TO_EDGE -#define GL_CLAMP_TO_EDGE 0x812F -#endif -#ifndef GL_RGBA8 -#define GL_RGBA8 0x8058 -#endif -/* GL 1.4+ enums absent from MinGW's GL 1.1 headers. */ -#define PSXGL_FRAGMENT_SHADER 0x8B30 -#define PSXGL_VERTEX_SHADER 0x8B31 -#define PSXGL_COMPILE_STATUS 0x8B81 -#define PSXGL_LINK_STATUS 0x8B82 -#define PSXGL_TEXTURE0 0x84C0 -#define PSXGL_ARRAY_BUFFER 0x8892 -#define PSXGL_STREAM_DRAW 0x88E0 -#define PSXGL_FRAMEBUFFER 0x8D40 -#define PSXGL_READ_FRAMEBUFFER 0x8CA8 -#define PSXGL_DRAW_FRAMEBUFFER 0x8CA9 -#define PSXGL_COLOR_ATTACHMENT0 0x8CE0 -#define PSXGL_DEPTH_STENCIL_ATTACHMENT 0x821A -#define PSXGL_FRAMEBUFFER_COMPLETE 0x8CD5 -#define PSXGL_RENDERBUFFER 0x8D41 -#define PSXGL_DEPTH24_STENCIL8 0x88F0 -#define PSXGL_R16UI 0x8234 -#define PSXGL_RED_INTEGER 0x8D94 -#define PSXGL_FUNC_ADD 0x8006 -#define PSXGL_FUNC_REVERSE_SUBTRACT 0x800B -#define PSXGL_CONSTANT_ALPHA 0x8003 -#define PSXGL_UNPACK_ROW_LENGTH 0x0CF2 -#define PSXGL_SRC1_ALPHA 0x8589 -#define PSXGL_SYNC_GPU_COMMANDS_COMPLETE 0x9117 -#define PSXGL_TIMEOUT_IGNORED 0xFFFFFFFFFFFFFFFFull - -#ifndef APIENTRY -#define APIENTRY -#endif - -#define VRAM_W 1024 -#define VRAM_H 512 -#define GL_MAX_INTERNAL_SCALE SW_MAX_INTERNAL_SCALE - -/* ---- Loaded modern-GL entry points ------------------------------------- */ -typedef GLuint (APIENTRY *PFN_glCreateShader)(GLenum); -typedef void (APIENTRY *PFN_glShaderSource)(GLuint, GLsizei, const char *const *, const GLint *); -typedef void (APIENTRY *PFN_glCompileShader)(GLuint); -typedef void (APIENTRY *PFN_glGetShaderiv)(GLuint, GLenum, GLint *); -typedef void (APIENTRY *PFN_glGetShaderInfoLog)(GLuint, GLsizei, GLsizei *, char *); -typedef void (APIENTRY *PFN_glDeleteShader)(GLuint); -typedef GLuint (APIENTRY *PFN_glCreateProgram)(void); -typedef void (APIENTRY *PFN_glAttachShader)(GLuint, GLuint); -typedef void (APIENTRY *PFN_glLinkProgram)(GLuint); -typedef void (APIENTRY *PFN_glGetProgramiv)(GLuint, GLenum, GLint *); -typedef void (APIENTRY *PFN_glGetProgramInfoLog)(GLuint, GLsizei, GLsizei *, char *); -typedef void (APIENTRY *PFN_glUseProgram)(GLuint); -typedef GLint (APIENTRY *PFN_glGetUniformLocation)(GLuint, const char *); -typedef void (APIENTRY *PFN_glUniform1i)(GLint, GLint); -typedef void (APIENTRY *PFN_glUniform1f)(GLint, GLfloat); -typedef void (APIENTRY *PFN_glUniform2i)(GLint, GLint, GLint); -typedef void (APIENTRY *PFN_glUniform4i)(GLint, GLint, GLint, GLint, GLint); -typedef void (APIENTRY *PFN_glUniform4f)(GLint, GLfloat, GLfloat, GLfloat, GLfloat); -typedef void (APIENTRY *PFN_glBlendColor)(GLfloat, GLfloat, GLfloat, GLfloat); -typedef void (APIENTRY *PFN_glBlendFuncSeparate)(GLenum, GLenum, GLenum, GLenum); -typedef void (APIENTRY *PFN_glBlendEquationSeparate)(GLenum, GLenum); -typedef void (APIENTRY *PFN_glGenVertexArrays)(GLsizei, GLuint *); -typedef void (APIENTRY *PFN_glBindVertexArray)(GLuint); -typedef void (APIENTRY *PFN_glActiveTexture)(GLenum); -typedef void (APIENTRY *PFN_glGenBuffers)(GLsizei, GLuint *); -typedef void (APIENTRY *PFN_glBindBuffer)(GLenum, GLuint); -typedef void (APIENTRY *PFN_glBufferData)(GLenum, ptrdiff_t, const void *, GLenum); -typedef void (APIENTRY *PFN_glVertexAttribPointer)(GLuint, GLint, GLenum, GLboolean, GLsizei, const void *); -typedef void (APIENTRY *PFN_glEnableVertexAttribArray)(GLuint); -typedef void (APIENTRY *PFN_glBindFragDataLocationIndexed)(GLuint, GLuint, GLuint, const char *); -typedef GLsync (APIENTRY *PFN_glFenceSync)(GLenum, GLbitfield); -typedef void (APIENTRY *PFN_glWaitSync)(GLsync, GLbitfield, GLuint64); -typedef void (APIENTRY *PFN_glDeleteSync)(GLsync); -typedef void (APIENTRY *PFN_glGenFramebuffers)(GLsizei, GLuint *); -typedef void (APIENTRY *PFN_glDeleteFramebuffers)(GLsizei, const GLuint *); -typedef void (APIENTRY *PFN_glBindFramebuffer)(GLenum, GLuint); -typedef void (APIENTRY *PFN_glFramebufferTexture2D)(GLenum, GLenum, GLenum, GLuint, GLint); -typedef GLenum (APIENTRY *PFN_glCheckFramebufferStatus)(GLenum); -typedef void (APIENTRY *PFN_glBlitFramebuffer)(GLint,GLint,GLint,GLint,GLint,GLint,GLint,GLint,GLbitfield,GLenum); -typedef void (APIENTRY *PFN_glGenRenderbuffers)(GLsizei, GLuint *); -typedef void (APIENTRY *PFN_glDeleteRenderbuffers)(GLsizei, const GLuint *); -typedef void (APIENTRY *PFN_glBindRenderbuffer)(GLenum, GLuint); -typedef void (APIENTRY *PFN_glRenderbufferStorage)(GLenum, GLenum, GLsizei, GLsizei); -typedef void (APIENTRY *PFN_glFramebufferRenderbuffer)(GLenum, GLenum, GLenum, GLuint); -/* GPU timer queries (ARB_timer_query / core GL 3.3) — frame_perf instrumentation. */ -typedef void (APIENTRY *PFN_glGenQueries)(GLsizei, GLuint *); -typedef void (APIENTRY *PFN_glDeleteQueries)(GLsizei, const GLuint *); -typedef void (APIENTRY *PFN_glBeginQuery)(GLenum, GLuint); -typedef void (APIENTRY *PFN_glEndQuery)(GLenum); -typedef void (APIENTRY *PFN_glGetQueryObjectui64v)(GLuint, GLenum, GLuint64 *); -typedef void (APIENTRY *PFN_glGetQueryObjectiv)(GLuint, GLenum, GLint *); -typedef void (APIENTRY *PFN_glQueryCounter)(GLuint, GLenum); -#ifndef GL_TIME_ELAPSED -#define GL_TIME_ELAPSED 0x88BF -#endif -#ifndef GL_TIMESTAMP -#define GL_TIMESTAMP 0x8E28 -#endif -#ifndef GL_QUERY_RESULT -#define GL_QUERY_RESULT 0x8866 -#endif -#ifndef GL_QUERY_RESULT_AVAILABLE -#define GL_QUERY_RESULT_AVAILABLE 0x8867 -#endif - -static PFN_glCreateShader p_glCreateShader; -static PFN_glShaderSource p_glShaderSource; -static PFN_glCompileShader p_glCompileShader; -static PFN_glGetShaderiv p_glGetShaderiv; -static PFN_glGetShaderInfoLog p_glGetShaderInfoLog; -static PFN_glDeleteShader p_glDeleteShader; -static PFN_glCreateProgram p_glCreateProgram; -static PFN_glAttachShader p_glAttachShader; -static PFN_glLinkProgram p_glLinkProgram; -static PFN_glGetProgramiv p_glGetProgramiv; -static PFN_glGetProgramInfoLog p_glGetProgramInfoLog; -static PFN_glUseProgram p_glUseProgram; -static PFN_glGetUniformLocation p_glGetUniformLocation; -static PFN_glUniform1i p_glUniform1i; -static PFN_glUniform1f p_glUniform1f; -static PFN_glUniform2i p_glUniform2i; -static PFN_glUniform4i p_glUniform4i; -static PFN_glUniform4f p_glUniform4f; -static PFN_glBlendColor p_glBlendColor; -static PFN_glBlendFuncSeparate p_glBlendFuncSeparate; -static PFN_glBlendEquationSeparate p_glBlendEquationSeparate; -static PFN_glGenVertexArrays p_glGenVertexArrays; -static PFN_glBindVertexArray p_glBindVertexArray; -static PFN_glActiveTexture p_glActiveTexture; -static PFN_glGenBuffers p_glGenBuffers; -static PFN_glBindBuffer p_glBindBuffer; -static PFN_glBufferData p_glBufferData; -static PFN_glVertexAttribPointer p_glVertexAttribPointer; -static PFN_glEnableVertexAttribArray p_glEnableVertexAttribArray; -static PFN_glBindFragDataLocationIndexed p_glBindFragDataLocationIndexed; -static PFN_glFenceSync p_glFenceSync; -static PFN_glWaitSync p_glWaitSync; -static PFN_glDeleteSync p_glDeleteSync; -static PFN_glGenFramebuffers p_glGenFramebuffers; -static PFN_glDeleteFramebuffers p_glDeleteFramebuffers; -static PFN_glBindFramebuffer p_glBindFramebuffer; -static PFN_glFramebufferTexture2D p_glFramebufferTexture2D; -static PFN_glCheckFramebufferStatus p_glCheckFramebufferStatus; -static PFN_glBlitFramebuffer p_glBlitFramebuffer; -static PFN_glGenQueries p_glGenQueries; -static PFN_glDeleteQueries p_glDeleteQueries; -static PFN_glBeginQuery p_glBeginQuery; -static PFN_glEndQuery p_glEndQuery; -static PFN_glGetQueryObjectui64v p_glGetQueryObjectui64v; -static PFN_glGetQueryObjectiv p_glGetQueryObjectiv; -static PFN_glQueryCounter p_glQueryCounter; -static void gl_perf_init(void); /* frame_perf — defined below, called from init_gpu_raster */ -static void gl_perf_mirror_begin(void); /* frame_perf: GPU-time bracket around ONE native-wide */ -static void gl_perf_mirror_end(void); /* mirror pass (timestamp pair; splits scene canonical/mirror) */ -/* Native-wide mirror ABLATION (perf attribution, debug cmd gl_ws_ablate): - * 0 = normal, 1 = skip the whole mirror pass, 2 = full mirror state churn but no - * draw calls, 3 = mirror draws land in the hr FBO (no per-pass FBO rebind; wide - * margins go stale + hr gets garbage — perf probe only). */ -static int s_ws_ablate = 0; -static void flush_tex_batch(void); /* textured-prim batch — defined below, flushed from coherency points */ -static void flush_flat_batch(void); /* flat/gouraud GEO batch (MotK starfield 0x68 dots) */ -static int resize_gpu_raster(int scale); /* live internal-resolution rebuild */ -static PFN_glGenRenderbuffers p_glGenRenderbuffers; -static PFN_glDeleteRenderbuffers p_glDeleteRenderbuffers; -static PFN_glBindRenderbuffer p_glBindRenderbuffer; -static PFN_glRenderbufferStorage p_glRenderbufferStorage; -static PFN_glFramebufferRenderbuffer p_glFramebufferRenderbuffer; - -static int load_modern_gl(void) { - int ok = 1; -#define LOAD(p, n) do { p = (void *)SDL_GL_GetProcAddress(n); if (!p) ok = 0; } while (0) - LOAD(p_glCreateShader, "glCreateShader"); LOAD(p_glShaderSource, "glShaderSource"); - LOAD(p_glCompileShader, "glCompileShader"); LOAD(p_glGetShaderiv, "glGetShaderiv"); - LOAD(p_glGetShaderInfoLog, "glGetShaderInfoLog"); LOAD(p_glDeleteShader, "glDeleteShader"); - LOAD(p_glCreateProgram, "glCreateProgram"); LOAD(p_glAttachShader, "glAttachShader"); - LOAD(p_glLinkProgram, "glLinkProgram"); LOAD(p_glGetProgramiv, "glGetProgramiv"); - LOAD(p_glGetProgramInfoLog, "glGetProgramInfoLog"); LOAD(p_glUseProgram, "glUseProgram"); - LOAD(p_glGetUniformLocation, "glGetUniformLocation"); LOAD(p_glUniform1i, "glUniform1i"); - LOAD(p_glUniform1f, "glUniform1f"); - LOAD(p_glUniform2i, "glUniform2i"); LOAD(p_glUniform4i, "glUniform4i"); - LOAD(p_glUniform4f, "glUniform4f"); - LOAD(p_glBlendColor, "glBlendColor"); - LOAD(p_glBlendFuncSeparate, "glBlendFuncSeparate"); - LOAD(p_glBlendEquationSeparate, "glBlendEquationSeparate"); - LOAD(p_glGenVertexArrays, "glGenVertexArrays"); LOAD(p_glBindVertexArray, "glBindVertexArray"); - LOAD(p_glActiveTexture, "glActiveTexture"); LOAD(p_glGenBuffers, "glGenBuffers"); - LOAD(p_glBindBuffer, "glBindBuffer"); LOAD(p_glBufferData, "glBufferData"); - LOAD(p_glVertexAttribPointer, "glVertexAttribPointer"); - LOAD(p_glEnableVertexAttribArray, "glEnableVertexAttribArray"); - LOAD(p_glBindFragDataLocationIndexed, "glBindFragDataLocationIndexed"); -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - LOAD(p_glFenceSync, "glFenceSync"); - LOAD(p_glWaitSync, "glWaitSync"); - LOAD(p_glDeleteSync, "glDeleteSync"); -#endif - LOAD(p_glGenFramebuffers, "glGenFramebuffers"); LOAD(p_glBindFramebuffer, "glBindFramebuffer"); - LOAD(p_glDeleteFramebuffers, "glDeleteFramebuffers"); - LOAD(p_glFramebufferTexture2D, "glFramebufferTexture2D"); - LOAD(p_glCheckFramebufferStatus, "glCheckFramebufferStatus"); - LOAD(p_glBlitFramebuffer, "glBlitFramebuffer"); - LOAD(p_glGenRenderbuffers, "glGenRenderbuffers"); - LOAD(p_glDeleteRenderbuffers, "glDeleteRenderbuffers"); - LOAD(p_glBindRenderbuffer, "glBindRenderbuffer"); - LOAD(p_glRenderbufferStorage, "glRenderbufferStorage"); - LOAD(p_glFramebufferRenderbuffer, "glFramebufferRenderbuffer"); - /* GPU timer queries — optional (frame_perf). Don't fail the renderer if - * absent; gl_perf just stays disabled. */ - p_glGenQueries = (void *)SDL_GL_GetProcAddress("glGenQueries"); - p_glDeleteQueries = (void *)SDL_GL_GetProcAddress("glDeleteQueries"); - p_glBeginQuery = (void *)SDL_GL_GetProcAddress("glBeginQuery"); - p_glEndQuery = (void *)SDL_GL_GetProcAddress("glEndQuery"); - p_glGetQueryObjectui64v = (void *)SDL_GL_GetProcAddress("glGetQueryObjectui64v"); - p_glGetQueryObjectiv = (void *)SDL_GL_GetProcAddress("glGetQueryObjectiv"); - p_glQueryCounter = (void *)SDL_GL_GetProcAddress("glQueryCounter"); -#undef LOAD - return ok; -} - -/* ---- state ------------------------------------------------------------- */ -static SDL_Window *s_win = NULL; -static SDL_GLContext s_ctx = NULL; -static uint16_t *s_vram = NULL; /* CPU VRAM array (gpu.c's storage) */ -static int s_swap_interval = 1; /* SDL_GL swap interval (vsync mode) */ - -static int s_scale = 1; /* internal-res scale (hr FBO) */ -static int s_req_scale = 1; /* requested before context init */ - -static GLuint s_present_tex = 0; /* CPU-readout present path (24bpp) */ -static int s_present_w = 0, s_present_h = 0; -static GLuint s_present_prog = 0, s_present_vao = 0; -static GLint s_present_uTex = -1, s_present_uUvRect = -1; -/* Experimental ambient occlusion: the display mirror keeps the depth the painter's order leaves, the present pass shades by it. */ -static int s_ssao = 0; -/* What s_ssao is made of: the player's switch, and a layer shown in the picture's place (1 shading, 2 depth, 3 normals, 4 fade). */ -static int s_ssao_on = 0, s_ssao_view = 0; -static float s_ssao_radius = 160.0f, s_ssao_strength = 0.8f; -static float s_ssao_tilt = 0.35f; /* the sine of the angle under which a joint between two faces is no corner: 20 degrees */ -static GLuint s_ssao_prog = 0, s_ssao_mix_prog = 0; -static GLint s_ssao_uDepth = -1, s_ssao_uTex = -1, s_ssao_uUvRect = -1, s_ssao_uTexel = -1, s_ssao_uProj = -1, s_ssao_uAo = -1, - s_ssao_uFade = -1; -static float s_ssao_fade_from = 0.0f, s_ssao_fade_to = 0.0f; -static GLint s_ssao_mix_uTex = -1, s_ssao_mix_uShade = -1, s_ssao_mix_uUvRect = -1, s_ssao_mix_uSpan = -1; -#ifndef PSX_DISABLE_FRAME_INTERPOLATION -static GLuint s_interp_prog = 0, s_interp_tex[3]; -static GLsync s_interp_fence[3]; -static GLsync s_interp_draw_fence = NULL; -static GLint s_interp_uPrev = -1, s_interp_uCurr = -1; -static GLint s_interp_uAlpha = -1, s_interp_uUvRect = -1; -static GLint s_interp_uBlendMode = -1; -static int s_interp_enabled = 0, s_interp_valid = 0; -static int s_interp_suspended = 0; -static int s_interp_content_suspended = 0; -static int s_interp_host_ui_suspended = 0; -static int s_interp_blend_mode = 0; -static int s_interp_prev = 0, s_interp_cur = 0; -static int s_interp_w = 0, s_interp_h = 0, s_interp_linear = 0, s_interp_scale = 1; -static int s_interp_force_4_3 = 0, s_interp_source_path = -1; -static uint64_t s_interp_start = 0, s_interp_duration = 1; -static uint64_t s_interp_last_capture = 0, s_interp_swaps = 0; -static uint64_t s_interp_captures = 0; -static int s_interp_diag = 0; -static double s_interp_host_hz = 0.0; -static double s_interp_target_hz = 0.0; -static SDL_GLContext s_interp_ctx = NULL; -static SDL_Thread *s_interp_thread = NULL; -static SDL_mutex *s_interp_mutex = NULL; -static SDL_atomic_t s_interp_thread_run; -static GLuint s_interp_thread_vao = 0; -static void interp_reset_history_unlocked(void); -static void interp_reset_history(void); -static int interp_thread_main(void *opaque); -static int interp_present(void); -static void shown_capture(int kind, float alpha); -int gl_renderer_record_shown(const char *folder, int skip, int count, int width); -static float interp_draw_quad(float alpha, int lx, int ly, int lw, int lh); -static uint64_t s_temporal_raw_generation = 1; -static void temporal_reset(void); -static void temporal_release(void); -static void temporal_begin(GLuint fbo, int y, int h, uint16_t color); -static void temporal_invalidate_surface(GLuint fbo); -static void temporal_draw(const float *vertices, const uint64_t *identities, int n, - int textured, GLenum mode, int semi, int mask, int filter, - const int *twin, int gate, int direct); -#define temporal_note_raw_change() (++s_temporal_raw_generation) -#else -#define s_interp_enabled 0 -#define interp_reset_history() ((void)0) -#define temporal_note_raw_change() ((void)0) -#define temporal_begin(...) ((void)0) -#define temporal_invalidate_surface(...) ((void)0) -#define temporal_draw(...) ((void)0) -#define interp_capture(...) 0 -#endif - -static int s_raster_ok = 0; /* full GPU pipeline available */ - -/* Presentation-only geometry correction state. Precise vertices are queued by - * gpu.c immediately before a polygon draw. Every GL vertex carries the exact - * integer GP0 position separately from its optional precise position; the - * canonical pass always consumes the former, while only the independent - * display mirror may consume the latter and the fractional camera yaw. */ -static int s_geometry_correction = 0; -static int s_next_world = 0; -static int s_next_precise = 0; -static int32_t s_next_x16[3], s_next_y16[3]; -static int s_next_unpinned = 0; -static int32_t s_next_unpinned_x16[3], s_next_unpinned_y16[3]; -static int s_next_perspective = 0; -static float s_next_q[3] = { 0.0f, 0.0f, 0.0f }; -static int s_next_temporal_depth = 0; -static float s_next_z[3] = { 0.0f, 0.0f, 0.0f }; -static float s_next_surface_z[3] = { 0.0f, 0.0f, 0.0f }; /* see gr_set_surface_depth_triangle */ -static uint64_t s_next_identity[3] = { 0u, 0u, 0u }; -#define TEMPORAL_SPRITE_IDENTITY UINT64_C(0x4000000000000000) -#define TEMPORAL_PARTIAL_IDENTITY UINT64_C(0x2000000000000000) /* a world polygon with a corner off its projection */ -static float s_next_sprite_depth = 0.0f, s_sprite_depth = 0.0f; -static float s_next_sprite_sides[4], s_sprite_sides[4]; /* left, top, right, bottom */ -static int s_sprite_x, s_sprite_y; -static float s_yaw_sin = 0.0f, s_yaw_cos = 1.0f; -static float s_yaw_center_x = 160.0f; -static float s_yaw_center_y_relative = 120.0f; -static float s_yaw_focal_x = 160.0f; -static int s_geometry_full_yaw = 0; -static int s_geometry_coverage_tint = 0; - -/* Authoritative VRAM: hr color texture + stencil (mask bit) FBO. */ -static GLuint s_hr_tex = 0, s_hr_fbo = 0, s_hr_rb = 0; -/* Native raw-1555 sampling mirror + readback source. */ -static GLuint s_raw_tex = 0, s_raw_fbo = 0; -/* CPU->VRAM upload staging (native RGBA8). */ -static GLuint s_up_tex = 0; -/* copy_rect staging (hr-sized RGBA8). */ -static GLuint s_scratch_tex = 0, s_scratch_fbo = 0; - -/* Programs. */ -static GLuint s_geo_prog = 0, s_geo_vao = 0, s_geo_vbo = 0; -static GLuint s_tex_prog = 0, s_tex_vao = 0, s_tex_vbo = 0; -/* Vertex formats retain both canonical GP0 and presentation-only positions. - * Per-primitive texture state stays in flat attributes so prims can batch. */ -#define GEOV 10 -#define TEXV 24 -static GLuint s_blit_prog = 0, s_blit_vao = 0, s_blit_vbo = 0; -static GLuint s_pack_prog = 0, s_stencil_prog = 0, s_empty_vao = 0; - -/* TEX program uniforms. */ -static GLint s_uVram = -1, s_uTpage = -1, s_uClut = -1, s_uDepth = -1; -static GLint s_uRaw = -1, s_uSemipass = -1, s_uSemimode = -1; -static GLint s_uTwin = -1, s_uMaskset = -1, s_uFilter = -1; -static GLint s_uLimits = -1; -/* Native-wide x-projection uniforms (per program). u_xoff = x translation in - * native px (0 canonical), u_xhalf = x clip half-extent in native px (512 - * canonical). When wide is off these stay 0 / 512 so the canonical pass is - * bit-identical to the pre-native-wide projection. */ -static GLint s_geo_uXoff = -1, s_geo_uXhalf = -1; -static GLint s_tex_uXoff = -1, s_tex_uXhalf = -1; -/* Native-wide 2D-backdrop x-stretch uniforms. The far 2D backdrop layer is a - * ~4:3-width set of static prims scrolled by the draw offset; native-wide widens - * 3D via the GTE but never these, so they leave a void in the 16:9 margins. The - * wide mirror scales them about screen centre (u_xscale, u_xcenter) to fill it. - * Applied only to "backdrop-phase" prims (drawn before the first clearly-wide - * prim each frame). 1.0 / 0.0 => no-op, so the canonical pass stays identical. */ -static GLint s_geo_uXscale = -1, s_geo_uXcenter = -1; -static GLint s_tex_uXscale = -1, s_tex_uXcenter = -1; -/* Presentation-mirror-only subpixel/yaw uniforms. The canonical pass keeps - * u_visual=0 and reads the explicit GP0-position attribute. World/exact - * classification is carried per vertex so sprites and HUD rectangles remain - * fixed and the optional exact-coverage tint cannot affect canonical output. */ -static GLint s_geo_uVisual = -1, s_geo_uCamera = -1, s_geo_uVisualOptions = -1; -static GLint s_tex_uVisual = -1, s_tex_uCamera = -1, s_tex_uVisualOptions = -1; -/* Runtime controls (ws_backdrop_stretch debug command). */ -int g_ws_bd_stretch_on = 1; /* feature on (gated by native-wide + per-prim gate) */ -int g_ws_bd_stretch_pct = 0; /* 0 = auto (g_wide_w/native_w); else pct/100 */ -int g_ws_bd_phase_thresh = 24; /* "narrow" margin (px): a prim overhanging the 4:3 - * region by more than this is treated as already- - * widened GTE geometry and NOT stretched */ -int g_ws_bd_phase_mode = 1; /* (retained for the debug command; unused since the - * gate is now per-prim !tagged && narrow) */ -/* Per-prim 2D-backdrop gate (replaces the old draw-order phase): a prim is - * stretched iff native-wide + feature on + NOT sprite-tagged (foreground chars/ - * HUD are tagged) + NARROW (the GTE far-parallax/3D extend into the margins). - * s_bd_gate is what wide_set_bd_scale reads; s_tb_gate is the open textured - * batch's gate (batch flushes when a prim's gate differs, so a batch is uniform). */ -static int s_bd_gate = 0; -static int s_tb_gate = 0; -/* ws_backdrop_stretch diagnostics: per-frame snapshot reported by the command. */ -int g_bdg_applied = 0, g_bdg_prims = 0, g_bdg_clearx = -999999; -int g_bdg_cur = 0, g_bdg_base = 0, g_bdg_w = 0, g_bdg_off = 0; -static int s_bdg_applied = 0, s_bdg_prims = 0, s_bdg_clearx = -999999; -/* per-prim draw-order trace (ws_backdrop_trace): x-extent + textured flag, in - * draw order, for the last frame -- so we can SEE where the background sits. */ -typedef struct { short x0, x1, y0, y1; unsigned char tex; } PrimRec; -#define PTRACE_CAP 200 -static PrimRec s_ptrace[PTRACE_CAP]; static int s_ptrace_n = 0; -PrimRec g_ptrace[PTRACE_CAP]; int g_ptrace_n = 0; /* snapshot (extern) */ -/* BLIT program uniforms. */ -static GLint s_uBlitSrc = -1, s_uBlitPass = -1, s_uBlitMaskset = -1; -static GLint s_uBlitSrcDiv = -1, s_uBlitSrcOff = -1; -/* PACK program uniforms. */ -static GLint s_uPackHr = -1, s_uPackScale = -1; -static GLint s_uStencilSrc = -1; - -static uint32_t *s_conv = NULL; /* RGBA8 staging for uploads */ -static int s_gpu_dirty = 0; /* CPU VRAM array may be stale */ - -/* Dirty-rect unions, native VRAM coords, inclusive bounds. */ -typedef struct { int x0, y0, x1, y1, set; } DirtyRect; -static DirtyRect s_pack_dirty; /* hr FBO content not in raw mirror */ - -/* CPU writes not yet in the FBO — an EXACT rect list, NOT a single union. - * - * THE FLICKER CLASS BUG (MMX6 GL black-frame flicker, ISSUES.md #7): the old - * single-union s_up_pending merged DISJOINT uploads (e.g. a sprite column at - * x>=320 and a tile row at y>=480) into one bounding box that covered the - * framebuffers in between; flush_cpu_upload then painted that whole box from - * the CPU VRAM array — which is STALE under GL (the FBO is authoritative, - * ensure_cpu only syncs on demand) — stomping freshly-rendered framebuffer - * content with stale (typically black) pixels for 1-2 presents until the game - * redrew each buffer. Fix: track the exact uploaded rects and flush each one; - * only pixels the CPU actually wrote are ever painted. Merging is allowed only - * when it adds NO uncovered pixels (containment / same-band extension). On - * overflow the pending set is flushed and the new rect starts a fresh list — - * order-preserving and still batched for the common poke patterns. - * (Pre-context, s_raster_ok == 0, the CPU array is fully authoritative — the - * software rasterizer mirrors every draw — so union-merging is harmless and - * used as the overflow strategy there.) */ -#define UP_RECTS_MAX 16 -static DirtyRect s_up_rects[UP_RECTS_MAX]; -static int s_up_nrects = 0; -static uint64_t s_rt_up_diag[6]; - -static int runtime_upload_diag_enabled(void) { - static int enabled = -1; - if (enabled < 0) { - const char *e = getenv("PSX_RUNTIME_PERF_DIAG"); - enabled = e && e[0] && e[0] != '0'; - } - return enabled; -} - -void gl_renderer_runtime_diag(uint64_t out[6]) { - for (int i = 0; i < 6; i++) out[i] = s_rt_up_diag[i]; -} - -/* Draw state mirrored from the vtable set_* calls. */ -static int s_off_x = 0, s_off_y = 0; -static int s_area_x1 = 0, s_area_y1 = 0, s_area_x2 = VRAM_W - 1, s_area_y2 = VRAM_H - 1; -static int s_semi_en = 0, s_semi_mode = 0; -static int s_mod_r = 128, s_mod_g = 128, s_mod_b = 128, s_mod_raw = 0; -static int s_mask_set = 0, s_mask_check = 0; -static int s_tw_mask_x = 0, s_tw_mask_y = 0, s_tw_off_x = 0, s_tw_off_y = 0; -static int s_tex_filter = 0; -/* Opaque textured draws carry the exact mask bit in FBO alpha. Keeping the - * duplicate stencil copy current is deferred until mask checking is requested. */ -static int s_stencil_valid = 1; - -/* ---- native-wide compositor (mirrors gpu_sw_renderer.c g_wide_*) ---------- - * Canonical VRAM (the hr FBO) stays 100% faithful. Native-wide lives in - * SEPARATE wide FBOs keyed by framebuffer base_x; each framebuffer-targeting - * primitive is mirrored into the active wide surface at local x = vram_x - - * base_x + OFFSET. Present reads the displayed buffer's wide surface. Textures - * always sample canonical VRAM (s_raw_tex). 4:3 / non-opted games never call - * wide_configure, so g_wide_cur stays 0 and the wide pass never runs. */ -#define WIDE_MAX_SURF 4 -static GLuint s_wide_tex[WIDE_MAX_SURF]; /* color tex per surface (0 = free) */ -static GLuint s_wide_fbo[WIDE_MAX_SURF]; /* FBO per surface */ -static uint32_t s_wide_depth_shown[WIDE_MAX_SURF] = {~0u, ~0u, ~0u, ~0u}; /* rows shown since their depth was last cleared */ -static GLuint s_wide_rb[WIDE_MAX_SURF]; /* depth-stencil RB per surface (mask - * mirror, same as hr). PERF-CRITICAL: - * a stencil-less wide FBO made every - * stencil-enabled mirror draw cost - * ~0.6ms of driver work (Tomba2 16:9 - * collapsed to 12fps); with the RB - * attached the pass costs ~2us. */ -static int s_wide_base[WIDE_MAX_SURF]; /* base_x per surface (-1 = free) */ -static int g_wide_w = 0; /* wide width (native px); 0 = disabled */ -static int g_wide_off = 0; /* centering OFFSET (native px) */ -static GLuint g_wide_cur = 0; /* active mirror FBO (0 = no mirror) */ -static int g_wide_cur_base = 0; /* base_x of g_wide_cur */ -/* Set by gpu_flat_rect for the full-screen-overlay case so the generic - * gpu_geometry wide mirror is skipped and the flat path emits its own - * full-wide-width pass instead (mirrors sw_draw_flat_rect). */ -static int s_wide_suppress = 0; - -/* X-translation (native px) from canonical VRAM space into the active wide - * surface: local_x = vram_x - base_x + OFFSET. Same as SW wide_dx(). */ -static inline int wide_dx(void) { return g_wide_off - g_wide_cur_base; } - -/* A filler closes gaps between polygons of a textured batch. It is drawn before its batch, colour only: the batch's opaque - * fragments replace it and its blended ones blend over it. Over earlier batches it lands only where their depth is farther: - * the game's grown second copy of a near polygon (visual flag 4) by its own depth, and the outline of every exact world - * polygon (s_crack_fill native pixels wide, 0 for none) only where no depth was left at all. */ -static float s_crack_fill = 0.5f, s_crack_width = 0.5f; -static float s_line_width_max = 1.0f; /* the widest line this context draws */ -static int s_mesh_view = 0; /* diagnostic: in-between frames draw their triangles' edges over the picture */ -static int s_grown_under = 1; -static int s_tb_fillers = 0; /* grown copies in the open batch */ -static int s_draw_filler = 0; /* the batch being recorded for the in-between presenter holds grown copies */ -/* What the mirror draws of a batch: 0 its polygons, between 0 and 1 its grown copies pushed back by that share, 2 the outlines, - * -1 all of it as it comes. */ -static float s_fill_pass = 0.0f; -#define GROWN_COPY_MARGIN 0.03f - -/* Experimental: exact world polygons are drawn by depth, not by the game's order alone. A batch of them first writes the - * depth of its opaque fragments, then draws its colours in the game's order where they lie within s_world_margin of the - * nearest depth: polygons in one plane, whose depths differ by rounding, keep the game's order, and a polygon behind - * another by more is not drawn over it. Measured between a coarse wall quad and the pieces the game draws over it: up to - * 0.1 percent. The in-between presenter gives a departed face the same clearance (TEMP_ORDER_CLEARANCE). */ -static int s_world_depth = 0; -static float s_world_margin = 0.03f; -static int s_tb_world = 0; /* the open batch holds only such polygons */ -static int s_draw_world = 0; /* the batch being recorded for the in-between presenter is such a batch */ -static GLint s_tex_uPull = -1; - -static int mirror_depth(void) { return s_geometry_correction && (s_ssao || s_grown_under || s_crack_fill > 0.0f || s_world_depth); } - -static void geometry_visual_uniforms(GLint visual, GLint camera, GLint options, - int enabled) { - const float on = enabled && s_geometry_correction ? 1.0f : 0.0f; - p_glUniform1f(visual, on); - p_glUniform4f(camera, s_yaw_sin, s_yaw_cos, - (float)g_wide_cur_base + s_yaw_center_x, - (float)s_off_y + s_yaw_center_y_relative); - p_glUniform4f(options, s_yaw_focal_x, - s_geometry_full_yaw ? 1.0f : 0.0f, - on && s_geometry_coverage_tint ? 0.55f : 0.0f, on > 0.0f ? s_fill_pass : 0.0f); -} - -/* Capture the one-shot metadata queued by gpu.c for the next polygon. `xs/ys` - * remain the exact GP0 coordinates used for canonical dirty tracking and - * guest-visible rasterization. The returned floats are consumed only by the - * presentation mirror when the shader's u_visual switch is set. The returned - * flags are bit 0 = world, bit 1 = exact precise provenance and bit 2 = the game's grown copy, a filler. */ -static int take_visual_triangle(const int *xs, const int *ys, - float px[3], float py[3], float pq[3], - float pz[3], uint64_t pid[3]) { - const int world = s_geometry_correction && s_next_world; - const int exact = world && s_next_precise; - const int perspective = exact && s_next_perspective; - const int partial = !exact && s_next_temporal_depth == 2; - const int unpinned = !exact && s_geometry_correction && s_next_unpinned; - const int sprite = !world && s_geometry_correction && s_sprite_depth > 0.0f; - for (int i = 0; i < 3; ++i) { - if (exact) { - px[i] = (float)((double)s_next_x16[i] / 65536.0); - py[i] = (float)((double)s_next_y16[i] / 65536.0); - } else if (unpinned) { - px[i] = (float)((double)s_next_unpinned_x16[i] / 65536.0); - py[i] = (float)((double)s_next_unpinned_y16[i] / 65536.0); - } else { - px[i] = (float)xs[i] + (sprite ? s_sprite_sides[xs[i] == s_sprite_x ? 0 : 2] : 0.0f); - py[i] = (float)ys[i] + (sprite ? s_sprite_sides[ys[i] == s_sprite_y ? 1 : 3] : 0.0f); - } - if (pq) pq[i] = perspective ? s_next_q[i] : 0.0f; - if (pz) pz[i] = (exact && s_next_temporal_depth) || partial ? s_next_z[i] : s_sprite_depth; - /* A corner the presenter has no depth for, the mirror has: negative, so that only the mirror's shaders take it. */ - if (pz && (exact || partial) && !(pz[i] > 0.0f) && s_next_surface_z[i] > 0.0f) pz[i] = -s_next_surface_z[i]; - s_next_surface_z[i] = 0.0f; - if (pid) pid[i] = exact && s_next_temporal_depth ? s_next_identity[i] - : partial ? TEMPORAL_PARTIAL_IDENTITY - : s_sprite_depth > 0.0f ? TEMPORAL_SPRITE_IDENTITY : 0u; - s_next_identity[i] = 0u; - } - const int filler = s_grown_under && exact && s_next_world == 2; - s_next_world = 0; - s_next_precise = 0; - s_next_unpinned = 0; - s_next_perspective = 0; - s_next_temporal_depth = 0; - return (world || sprite || unpinned ? 1 : 0) | (exact ? 2 : 0) | (filler ? 4 : 0); -} - -static void turned_perspective(const float px[3], float pq[3], int visual_flags) { - if (visual_flags & 2) - psx_turned_perspective(px, pq, s_yaw_sin, s_yaw_cos, (float)g_wide_cur_base + s_yaw_center_x, s_yaw_focal_x); -} - -/* ---- dirty-rect helpers ------------------------------------------------- */ -static void rect_clear(DirtyRect *r) { r->set = 0; } -static void rect_add(DirtyRect *r, int x0, int y0, int x1, int y1) { - if (x0 < 0) x0 = 0; if (y0 < 0) y0 = 0; - if (x1 > VRAM_W - 1) x1 = VRAM_W - 1; - if (y1 > VRAM_H - 1) y1 = VRAM_H - 1; - if (x0 > x1 || y0 > y1) return; - if (!r->set) { r->x0 = x0; r->y0 = y0; r->x1 = x1; r->y1 = y1; r->set = 1; return; } - if (x0 < r->x0) r->x0 = x0; - if (y0 < r->y0) r->y0 = y0; - if (x1 > r->x1) r->x1 = x1; - if (y1 > r->y1) r->y1 = y1; -} -static int rect_intersects(const DirtyRect *r, int x0, int y0, int x1, int y1) { - if (!r->set) return 0; - return !(x1 < r->x0 || x0 > r->x1 || y1 < r->y0 || y0 > r->y1); -} - -/* ---- exact pending-upload rect list (see s_up_rects comment) ------------- */ -static void flush_cpu_upload(void); /* fwd: overflow flushes then re-adds */ - -/* Add an uploaded rect. Merges ONLY when the merge adds no uncovered pixels: - * containment either way, or an extension within the same row-band / column- - * band (equal y-range with touching/overlapping x-ranges, or equal x-range - * with touching/overlapping y-ranges — the row-scan / column-scan poke - * patterns). Never unions disjoint rects post-init (that was the flicker bug). */ -static void up_add(int x0, int y0, int x1, int y1) { - if (x0 < 0) x0 = 0; if (y0 < 0) y0 = 0; - if (x1 > VRAM_W - 1) x1 = VRAM_W - 1; - if (y1 > VRAM_H - 1) y1 = VRAM_H - 1; - if (x0 > x1 || y0 > y1) return; - for (int i = 0; i < s_up_nrects; i++) { - DirtyRect *r = &s_up_rects[i]; - if (x0 >= r->x0 && x1 <= r->x1 && y0 >= r->y0 && y1 <= r->y1) - return; /* contained */ - if (x0 <= r->x0 && x1 >= r->x1 && y0 <= r->y0 && y1 >= r->y1) { - r->x0 = x0; r->y0 = y0; r->x1 = x1; r->y1 = y1; /* contains */ - return; - } - if (y0 == r->y0 && y1 == r->y1 && - x0 <= r->x1 + 1 && x1 >= r->x0 - 1) { /* same row-band extension */ - if (x0 < r->x0) r->x0 = x0; - if (x1 > r->x1) r->x1 = x1; - return; - } - if (x0 == r->x0 && x1 == r->x1 && - y0 <= r->y1 + 1 && y1 >= r->y0 - 1) { /* same column-band extension */ - if (y0 < r->y0) r->y0 = y0; - if (y1 > r->y1) r->y1 = y1; - return; - } - } - if (s_up_nrects >= UP_RECTS_MAX) { - if (s_raster_ok) { - flush_cpu_upload(); /* order-preserving: land the old ones */ - } else { - /* Pre-context: the CPU array is fully authoritative (software - * rasterizer mirrors every op), so a union is harmless. */ - DirtyRect *r = &s_up_rects[0]; - for (int i = 1; i < s_up_nrects; i++) { - if (s_up_rects[i].x0 < r->x0) r->x0 = s_up_rects[i].x0; - if (s_up_rects[i].y0 < r->y0) r->y0 = s_up_rects[i].y0; - if (s_up_rects[i].x1 > r->x1) r->x1 = s_up_rects[i].x1; - if (s_up_rects[i].y1 > r->y1) r->y1 = s_up_rects[i].y1; - } - if (x0 < r->x0) r->x0 = x0; - if (y0 < r->y0) r->y0 = y0; - if (x1 > r->x1) r->x1 = x1; - if (y1 > r->y1) r->y1 = y1; - s_up_nrects = 1; - return; - } - } - DirtyRect *r = &s_up_rects[s_up_nrects++]; - r->x0 = x0; r->y0 = y0; r->x1 = x1; r->y1 = y1; r->set = 1; -} - -/* Add a GP0(A0) transfer's exact touched region. The software reference wraps - * per pixel (px = (x+col) & 1023, py = (y+row) & 511), so a wrapping transfer - * touches up to four exact rects — NOT all of VRAM (the old "wrapped: take - * all" full-VRAM union painted stale CPU content over the framebuffers). */ -static void up_add_transfer(int x, int y, int w, int h) { - x &= VRAM_W - 1; y &= VRAM_H - 1; - if (w > VRAM_W) w = VRAM_W; - if (h > VRAM_H) h = VRAM_H; - if (w <= 0 || h <= 0) return; - int w1 = w, w2 = 0, h1 = h, h2 = 0; - if (x + w > VRAM_W) { w1 = VRAM_W - x; w2 = w - w1; } - if (y + h > VRAM_H) { h1 = VRAM_H - y; h2 = h - h1; } - up_add(x, y, x + w1 - 1, y + h1 - 1); - if (w2) up_add(0, y, w2 - 1, y + h1 - 1); - if (h2) up_add(x, 0, x + w1 - 1, h2 - 1); - if (w2 && h2) up_add(0, 0, w2 - 1, h2 - 1); -} - -/* ---- coherency event ring (always-on, debug server "gl_coh_ring") -------- */ -/* Every coherency-relevant operation — upload flushes, fills, copies, draw - * bboxes, packs, full readbacks, presents, and probe perturbations — is - * recorded with its rect and frame number. Per CLAUDE.md ring-buffer rule: - * capture is continuous, observers query a window after the fact. Trigger - * attribution convention: an op that flushes internally (fill/copy/draw/ - * present/peek) records its own event AFTER the FLUSH event it caused, so - * the event following a FLUSH names the trigger. 16 B * 64 Ki = 1 MB. */ -extern uint64_t s_frame_count; /* defined in debug_server.c */ - -#define GL_COH_RING_CAP (1u << 16) -static GlCohEvent s_coh_ring[GL_COH_RING_CAP]; -static uint64_t s_coh_seq = 0; - -/* 16x16 native-pixel tiles changed since their last on-screen present. This - * lets a double-buffered 30 Hz game avoid swapping the unchanged front buffer - * on the intervening 60 Hz vblank without guessing from game identity. */ -#define PRES_TILE 16 -#define PRES_ROWS (VRAM_H / PRES_TILE) -static uint64_t s_present_dirty[PRES_ROWS]; -static int s_last_present_path = -1; -static int s_last_dx, s_last_dy, s_last_dw, s_last_dh; -/* After savestate restore: keep swapping for a few presents even when the - * display rect is byte-identical to the last swap. Double/triple-buffered - * windows otherwise can leave a stale back buffer on screen while vblanks - * (and FPS) keep advancing — especially on a 2nd+ load of the same slot. */ -static int s_force_present_remaining = 0; - -static void present_dirty_rect(int x0, int y0, int x1, int y1, int set) { - if (x0 < 0) x0 = 0; if (y0 < 0) y0 = 0; - if (x1 >= VRAM_W) x1 = VRAM_W - 1; if (y1 >= VRAM_H) y1 = VRAM_H - 1; - if (x0 > x1 || y0 > y1) return; - int tx0 = x0 / PRES_TILE, tx1 = x1 / PRES_TILE; - uint64_t mask = (~0ull << tx0) & (~0ull >> (63 - tx1)); - for (int ty = y0 / PRES_TILE; ty <= y1 / PRES_TILE; ty++) { - if (set) s_present_dirty[ty] |= mask; else s_present_dirty[ty] &= ~mask; - } -} - -static int present_dirty_test(int x0, int y0, int x1, int y1) { - if (x0 < 0) x0 = 0; if (y0 < 0) y0 = 0; - if (x1 >= VRAM_W) x1 = VRAM_W - 1; if (y1 >= VRAM_H) y1 = VRAM_H - 1; - if (x0 > x1 || y0 > y1) return 0; - int tx0 = x0 / PRES_TILE, tx1 = x1 / PRES_TILE; - uint64_t mask = (~0ull << tx0) & (~0ull >> (63 - tx1)); - for (int ty = y0 / PRES_TILE; ty <= y1 / PRES_TILE; ty++) - if (s_present_dirty[ty] & mask) return 1; - return 0; -} - -static void present_force_consumed(void) { - if (s_force_present_remaining > 0) - s_force_present_remaining--; -} - -static void coh_record(int kind, int x0, int y0, int x1, int y1) { - GlCohEvent *e = &s_coh_ring[s_coh_seq % GL_COH_RING_CAP]; - e->frame = (uint32_t)s_frame_count; - e->kind = (uint8_t)kind; - e->x0 = (int16_t)x0; e->y0 = (int16_t)y0; - e->x1 = (int16_t)x1; e->y1 = (int16_t)y1; - s_coh_seq++; - if (kind == GL_COH_FLUSH || kind == GL_COH_FILL || - kind == GL_COH_COPY || kind == GL_COH_DRAW) - present_dirty_rect(x0, y0, x1, y1, 1); -} - -uint64_t gl_renderer_coh_total(void) { return s_coh_seq; } -int gl_renderer_coh_get(uint64_t seq, GlCohEvent *out) { - if (seq >= s_coh_seq) return 0; - if (s_coh_seq - seq > GL_COH_RING_CAP) return 0; /* evicted */ - *out = s_coh_ring[seq % GL_COH_RING_CAP]; - return 1; -} - -/* ---- present ring (always-on, debug server "present_ring") --------------- */ -/* Records every SwapWindow (vram/wide/cpu/blank/interpolated) and its - * source + letterbox rects. PSX_GL_PRESENT_PROBE=1 additionally drains - * glGetError and samples one backbuffer pixel before the swap; that synchronous - * diagnostic is intentionally opt-in. Observers query a window after the fact. */ -#define GL_PRES_RING_CAP 4096 -static GlPresEvent s_pres_ring[GL_PRES_RING_CAP]; -static uint64_t s_pres_seq = 0; - -static void pres_record(int path, int dx, int dy, int w, int h, - int lx, int ly, int lw, int lh) { - /* The ring metadata stays always-on, but pixel probing must not: each - * glReadPixels synchronously drains queued GPU work. Two probes per frame - * were enough to make Tomba 2 miss its frame budget. */ - static int probe_pixels = -1; - if (probe_pixels < 0) { - const char *cfg = getenv("PSX_GL_PRESENT_PROBE"); - probe_pixels = (cfg && cfg[0] == '1') ? 1 : 0; - } - GlPresEvent *e = &s_pres_ring[s_pres_seq % GL_PRES_RING_CAP]; - e->frame = (uint32_t)s_frame_count; - e->t_ms = (uint32_t)SDL_GetTicks(); - e->path = (uint8_t)path; - e->glerr = probe_pixels ? (uint16_t)glGetError() : 0; - e->dx = (int16_t)dx; e->dy = (int16_t)dy; - e->w = (int16_t)w; e->h = (int16_t)h; - e->lx = (int16_t)lx; e->ly = (int16_t)ly; - e->lw = (int16_t)lw; e->lh = (int16_t)lh; - /* Backbuffer sample at the letterbox centre (GL bottom-origin; the rects - * we pass in are already bottom-origin GL window coords). */ - uint8_t px[3] = { 0, 0, 0 }; - if (probe_pixels && lw > 0 && lh > 0) { - glReadBuffer(GL_BACK); - glPixelStorei(GL_PACK_ALIGNMENT, 1); - glReadPixels(lx + lw / 2, ly + lh / 2, 1, 1, GL_RGB, GL_UNSIGNED_BYTE, px); - glPixelStorei(GL_PACK_ALIGNMENT, 4); - } - e->px_r = px[0]; e->px_g = px[1]; e->px_b = px[2]; - /* Blit-source sample: the hr FBO pixel at the display-rect centre. Splits - * "FBO content was black" from "the blit malfunctioned". */ - uint8_t sp[3] = { 0, 0, 0 }; - e->src_valid = 0; - if (probe_pixels && (path == GL_PRES_VRAM) && w > 0 && h > 0 && s_hr_fbo) { - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo); - glPixelStorei(GL_PACK_ALIGNMENT, 1); - glReadPixels((dx + w / 2) * s_scale, (dy + h / 2) * s_scale, - 1, 1, GL_RGB, GL_UNSIGNED_BYTE, sp); - glPixelStorei(GL_PACK_ALIGNMENT, 4); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - e->src_valid = 1; - } - e->src_r = sp[0]; e->src_g = sp[1]; e->src_b = sp[2]; - s_pres_seq++; -} - -uint64_t gl_renderer_pres_total(void) { return s_pres_seq; } -int gl_renderer_pres_get(uint64_t seq, GlPresEvent *out) { - if (seq >= s_pres_seq) return 0; - if (s_pres_seq - seq > GL_PRES_RING_CAP) return 0; /* evicted */ - *out = s_pres_ring[seq % GL_PRES_RING_CAP]; - return 1; -} - -/* ---- shaders ------------------------------------------------------------ */ -static const char *PRESENT_VS = - "#version 330\n" - "out vec2 v_uv;\n" - "uniform vec4 u_uv_rect;\n" - "void main(){ vec2 p = vec2((gl_VertexID<<1)&2, gl_VertexID&2);\n" - " v_uv = vec2(mix(u_uv_rect.x,u_uv_rect.z,p.x),\n" - " mix(u_uv_rect.y,u_uv_rect.w,1.0-p.y));\n" - " gl_Position = vec4(p*2.0-1.0,0.0,1.0); }\n"; -static const char *PRESENT_FS = - "#version 330\n" - "in vec2 v_uv; uniform sampler2D u_tex; out vec4 frag;\n" - "void main(){ frag = texture(u_tex, v_uv); }\n"; -/* Scalable ambient obscurance over the mirror's depth, one value a screen pixel. A pixel without camera depth (HUD, backdrop, 2D) - * is neither shaded nor an occluder. Red is the light left, green a coarse code of the depth for the blur, 0 for none. */ -static const char *SSAO_FS = - "#version 330\n" - "in vec2 v_uv; out vec4 frag;\n" - "uniform sampler2D u_depth, u_tex;\n" - "uniform vec4 u_span; /* one texel of the surface in x and y, the view asked for */\n" - "#define u_texel u_span.xy\n" - "uniform vec4 u_proj; /* focal x, focal y, centre x, centre y, in texels */\n" - "uniform vec4 u_ao; /* radius in depth units, strength, widest reach in texels, one native pixel in texels */\n" - "uniform vec4 u_fade; /* the depths between which the game fades the world into its far colour (0 0 for none), s_ssao_tilt */\n" - "float cam(vec2 uv){ float z = texture(u_depth, uv).r; return (z < 0.00002 || z > 0.99998) ? 0.0 : 16.0/(1.0-z); }\n" - "/* The depth of a surface to shade by: none where a blended fragment or one without a whole depth is marked. */\n" - "float surf(vec2 uv){\n" - " float mark = texelFetch(u_tex, clamp(ivec2(uv*vec2(textureSize(u_tex, 0))), ivec2(0), textureSize(u_tex, 0) - 1), 0).a;\n" - " return (mark > 0.2 && mark < 0.3) ? 0.0 : cam(uv); }\n" - "/* The same for a pixel's normal: a sprite beside it, or a sliver of a polygon drawn on the game's own pixels, is not its surface. */\n" - "float flat_surf(vec2 uv){\n" - " float mark = texelFetch(u_tex, clamp(ivec2(uv*vec2(textureSize(u_tex, 0))), ivec2(0), textureSize(u_tex, 0) - 1), 0).a;\n" - " return (mark > 0.2 && mark < 0.6) ? 0.0 : cam(uv); }\n" - "vec3 at(vec2 uv, float d){ return vec3((uv/u_texel - u_proj.zw)/u_proj.xy*d, d); }\n" - "vec3 edge(vec2 step, vec3 p, float d){\n" - " float a = flat_surf(v_uv - step), b = flat_surf(v_uv + step);\n" - " if (a == 0.0 && b == 0.0) return vec3(0.0);\n" - " return (b == 0.0 || (a != 0.0 && abs(a-d) < abs(b-d))) ? p - at(v_uv - step, a) : at(v_uv + step, b) - p; }\n" - "/* A hairline between two polygons shows what lies behind them, or a polygon an in-between frame holds in place under them:\n" - " * where the depths half a pixel or a pixel to both sides agree and this one does not, it is theirs. */\n" - "float mend(float d){\n" - " if (d == 0.0) return d;\n" - " for (int i = 0; i < 4; ++i) {\n" - " vec2 step = (i < 2 ? vec2(u_texel.x, 0.0) : vec2(0.0, u_texel.y))*u_ao.w*((i & 1) == 0 ? 0.5 : 1.0);\n" - " float a = flat_surf(v_uv - step), b = flat_surf(v_uv + step);\n" - " if (a != 0.0 && b != 0.0 && abs(a - b) < 0.03*min(a, b) + 1.0 && abs(d - 0.5*(a + b)) > 0.04*d + 4.0) return 0.5*(a + b);\n" - " }\n" - " return d; }\n" - "void main(){\n" - " float d = mend(cam(v_uv));\n" - " frag = vec4(1.0, 0.0, 0.0, 1.0);\n" - " if (d == 0.0) return;\n" - " frag.g = clamp(log2(d)/16.0, 1.0/255.0, 1.0);\n" - " float clear = u_fade.y > u_fade.x ? 1.0 - clamp((d - u_fade.x)/(u_fade.y - u_fade.x), 0.0, 1.0) : 1.0;\n" - " if (u_span.z > 4.5) { frag.rb = vec2(clear); return; }\n" - " if (clear <= 0.0) return;\n" - " if (u_span.z > 2.5 && u_span.z < 3.5) { frag.rb = vec2(fract(d/256.0), d/4096.0); return; }\n" - " vec3 p = at(v_uv, d);\n" - " vec3 n = cross(edge(vec2(0.0, u_ao.w*u_texel.y), p, d), edge(vec2(u_ao.w*u_texel.x, 0.0), p, d));\n" - " if (dot(n, n) < 1e-12) return;\n" - " n = normalize(n); if (dot(n, p) > 0.0) n = -n;\n" - " float mark = texelFetch(u_tex, ivec2(v_uv*vec2(textureSize(u_tex, 0))), 0).a;\n" - " if (mark > 0.2 && mark < 0.6 && u_span.z < 3.5) return; /* a sprite stands in the floor it is drawn over, and under a blended fragment the depth is not its own */\n" - " if (u_span.z > 3.5) { frag = vec4(n*0.5 + 0.5, 1.0); return; }\n" - " float reach = min(u_ao.x*u_proj.x/d, u_ao.z);\n" - " if (reach < u_ao.w) return;\n" - " float turn = 6.2831853*fract(52.9829189*fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));\n" - " float r2 = u_ao.x*u_ao.x, sum = 0.0;\n" - " for (int i = 0; i < 16; ++i) {\n" - " float f = (float(i)+0.5)/16.0, angle = turn + f*6.2831853*7.0;\n" - " vec2 uv = v_uv + vec2(cos(angle), sin(angle))*sqrt(f)*reach*u_texel;\n" - " float ds = surf(uv);\n" - " if (ds == 0.0) continue;\n" - " vec3 v = at(uv, ds) - p;\n" - " float vv = dot(v, v), fall = max(r2 - vv, 0.0);\n" - " sum += fall*fall*fall*max(((dot(v, n) - u_fade.z*sqrt(vv))/(1.0 - u_fade.z) - 0.012*u_ao.x - 0.0015*d)/(vv + 0.0001*r2), 0.0);\n" - " }\n" - " frag.r = mix(1.0, max(0.4, 1.0 - u_ao.y*5.0*u_ao.x*sum/(r2*r2*r2*16.0)), clear); }\n"; -/* The frame with the shading of the pass above, averaged over the 4x4 pixels around that lie at the pixel's own depth. */ -static const char *SSAO_MIX_FS = - "#version 330\n" - "in vec2 v_uv; out vec4 frag;\n" - "uniform sampler2D u_tex, u_shade;\n" - "uniform vec4 u_span; /* x, y of the picture's corner on screen, the view asked for */\n" - "void main(){\n" - " ivec2 at = ivec2(gl_FragCoord.xy - u_span.xy), most = textureSize(u_shade, 0) - 1;\n" - " vec4 here = texelFetch(u_shade, clamp(at, ivec2(0), most), 0);\n" - " vec3 c = texture(u_tex, v_uv).rgb;\n" - " if (u_span.z > 4.5) { frag = vec4(here.g == 0.0 ? vec3(0.0, 0.0, 0.25) : vec3(here.r), 1.0); return; }\n" - " if (u_span.z > 2.5) { frag = vec4(here.g == 0.0 ? vec3(0.0, 0.0, 0.25) : here.rgb, 1.0); return; }\n" - " frag = vec4(u_span.z > 1.5 ? vec3(0.0, 0.0, 0.25) : c, 1.0);\n" - " if (here.g == 0.0) return;\n" - " float lit = 0.0, count = 0.0;\n" - " for (int y = -2; y < 2; ++y) for (int x = -2; x < 2; ++x) {\n" - " vec4 near = texelFetch(u_shade, clamp(at + ivec2(x, y), ivec2(0), most), 0);\n" - " if (near.g == 0.0 || abs(near.g - here.g) > 2.5/255.0) continue;\n" - " lit += near.r; count += 1.0;\n" - " }\n" - " lit = count > 0.0 ? lit/count : here.r;\n" - " frag = vec4(u_span.z > 1.5 ? vec3(lit) : c*lit, 1.0); }\n"; -#ifndef PSX_DISABLE_FRAME_INTERPOLATION -static const char *INTERP_FS = - "#version 330\n" - "in vec2 v_uv; uniform sampler2D u_prev; uniform sampler2D u_curr;\n" - "uniform float u_alpha; uniform int u_blend_mode; out vec4 frag;\n" - "void main(){\n" - " vec4 prev=texture(u_prev,v_uv), curr=texture(u_curr,v_uv);\n" - " float alpha=u_alpha;\n" - " if(u_blend_mode==1){\n" - " vec3 d=abs(prev.rgb-curr.rgb);\n" - " float change=max(max(d.r,d.g),d.b);\n" - " float safe_blend=1.0-smoothstep(0.08,0.20,change);\n" - " alpha=mix(step(0.5,u_alpha),u_alpha,safe_blend);\n" - " }\n" - " frag=mix(prev,curr,alpha);\n" - "}\n"; -#endif - -/* Geometry: position in VRAM pixels (draw offset already applied by gpu.c), - * color rgb in 0..1, color a = mask bit (0/1). The clip transform is in - * native VRAM space; the viewport at S* the size scales rasterization. - * - * ALL drawn prims shift positions by u_shift = half an HR pixel (0.5/S in - * native units): GL samples coverage/attributes at pixel CENTERS, the PS1 - * DDA at INTEGER coords. The shift aligns GL's sample grid with the PS1 - * grid — without it, any texture mapping with slope != 1 (scaled sprites, - * squished menu fonts) samples one texel off per row/column (striped - * glyphs, seam lines). Half an HR pixel (not half a native pixel!) keeps - * rect coverage exactly [x*S, (x+w)*S) at every scale AND makes the - * top-left subpixel of each S*S block sample the exact PS1 value (which is - * what the PACK pass reads back). */ -static const char *GEO_VS = - "#version 330\n" - "layout(location=0) in vec2 a_pos;\n" - "layout(location=1) in vec4 a_col;\n" - "layout(location=2) in float a_visual_flags;\n" - "layout(location=3) in vec2 a_visual_pos;\n" - "uniform float u_shift;\n" - "uniform float u_xoff; /* native-wide x translation (px); 0 canonical */\n" - "uniform float u_xhalf; /* x clip half-extent (px); 512 canonical */\n" - "uniform float u_xscale; /* native-wide 2D-backdrop x-stretch; 1 canonical */\n" - "uniform float u_xcenter;/* stretch centre in VRAM px; 0 canonical */\n" - "uniform float u_visual; /* 1 only for the independent display mirror */\n" - "uniform vec4 u_camera; /* sin(yaw), cos(yaw), centre-x, centre-y */\n" - "uniform vec4 u_visual_options; /* focal-x, full-yaw, tint-strength, unused */\n" - "noperspective out vec4 v_col; noperspective out float v_tint;\n" - "void main(){ v_col = a_col;\n" - " bool corrected = u_visual > 0.5 && a_visual_flags > 0.5;\n" - " vec2 p = corrected ? a_visual_pos : a_pos;\n" - " float xb = p.x, yb = p.y;\n" - " v_tint = (u_visual > 0.5 && a_visual_flags > 1.5) ? u_visual_options.z : 0.0;\n" - " if (corrected && abs(u_camera.x) > 0.0000001 && u_visual_options.x > 0.0) {\n" - " float ux = (xb-u_camera.z)/u_visual_options.x;\n" - " float den = u_camera.y-ux*u_camera.x;\n" - " if (abs(den) > 0.01) {\n" - " xb = u_camera.z + u_visual_options.x*((ux*u_camera.y+u_camera.x)/den);\n" - " if (u_visual_options.y > 0.5) yb = u_camera.w + (yb-u_camera.w)/den;\n" - " }\n" - " }\n" - " if (u_xscale < 0.0) {\n" - " float s = -u_xscale; float h = u_xhalf / s;\n" - " float l = u_xcenter - h, r = u_xcenter + h;\n" - " if (xb < l) xb = l + (xb-l)*s; else if (xb > r) xb = r + (xb-r)*s;\n" - " } else xb = (xb - u_xcenter)*u_xscale + u_xcenter;\n" - " gl_Position = vec4((xb+u_shift+u_xoff)/u_xhalf - 1.0, (yb+u_shift)/256.0 - 1.0, 0.0, 1.0); }\n"; -static const char *GEO_FS = - "#version 330\n" - "noperspective in vec4 v_col; noperspective in float v_tint; out vec4 frag;\n" - "void main(){ frag = v_col;\n" - " if (v_tint > 0.0) frag.rgb = mix(frag.rgb, vec3(1.0,0.15,0.85),v_tint); }\n"; - -/* Textured prims: sample raw 1555 VRAM (integer), CLUT decode per depth, - * texture window, optional bilinear, texel-0 discard, STP-split discard, - * PS1 *2-around-0x80 modulation. Output alpha = bit15 of the written pixel. - * Texel coords use floor() to match the software rasterizer's truncation - * (rounding shifted sampling +1 texel half the time: smeared text). */ -/* Textured program. Per-prim texture state (texpage, clut, depth, raw, uv - * limits) is carried in FLAT vertex attributes — constant across a prim's - * vertices — instead of uniforms, so consecutive textured prims with the same - * blend/mask/texture-window state batch into one draw (see flush_tex_batch). - * The remaining uniforms (u_twin/u_maskset/u_filter/u_semipass) are the batch - * keys + per-pass state. */ -static const char *TEX_VS = - "#version 330\n" - "layout(location=0) in vec2 a_pos;\n" - "layout(location=1) in vec2 a_uv;\n" - "layout(location=2) in vec4 a_col;\n" - "layout(location=3) in vec2 a_tpage;\n" - "layout(location=4) in vec2 a_clut;\n" - "layout(location=5) in float a_depth;\n" - "layout(location=6) in float a_raw;\n" - "layout(location=7) in vec4 a_limits;\n" - "layout(location=8) in float a_semi;\n" - "layout(location=9) in float a_visual_flags;\n" - "layout(location=10) in vec2 a_visual_pos;\n" - "layout(location=11) in float a_q;\n" - "uniform float u_shift;\n" - "uniform float u_xoff; /* native-wide x translation (px); 0 canonical */\n" - "uniform float u_xhalf; /* x clip half-extent (px); 512 canonical */\n" - "uniform float u_xscale; /* native-wide 2D-backdrop x-stretch; 1 canonical */\n" - "uniform float u_xcenter;/* stretch centre in VRAM px; 0 canonical */\n" - "uniform float u_visual; /* 1 only for the independent display mirror */\n" - "uniform vec4 u_camera; /* sin(yaw), cos(yaw), centre-x, centre-y */\n" - "uniform vec4 u_visual_options; /* focal-x, full-yaw, tint-strength, unused */\n" - "noperspective out vec2 v_uv; noperspective out vec3 v_uvq;\n" - "noperspective out vec4 v_col; flat out int v_perspective;\n" - "noperspective out float v_tint;\n" - "flat out ivec2 v_tpage; flat out ivec2 v_clut; flat out int v_depth;\n" - "flat out int v_raw; flat out ivec4 v_limits; flat out int v_semi;\n" - "void main(){ v_uv = a_uv; v_col = a_col;\n" - " v_tpage = ivec2(a_tpage + 0.5); v_clut = ivec2(a_clut + 0.5);\n" - " v_depth = int(a_depth + 0.5); v_raw = int(a_raw + 0.5);\n" - " v_semi = int(a_semi + 0.5);\n" - " v_limits = ivec4(floor(a_limits + 0.5));\n" - " /* u_shift: align GL's center-sample grid with the PS1 integer grid (see\n" - " * GEO_VS) so interpolated uv at a fragment equals the PS1 DDA value. */\n" - " bool corrected = u_visual > 0.5 && a_visual_flags > 0.5;\n" - " vec2 p = corrected ? a_visual_pos : a_pos;\n" - " float xb = p.x, yb = p.y;\n" - " v_tint = (u_visual > 0.5 && a_visual_flags > 1.5) ? u_visual_options.z : 0.0;\n" - " if (corrected && abs(u_camera.x) > 0.0000001 && u_visual_options.x > 0.0) {\n" - " float ux = (xb-u_camera.z)/u_visual_options.x;\n" - " float den = u_camera.y-ux*u_camera.x;\n" - " if (abs(den) > 0.01) {\n" - " xb = u_camera.z + u_visual_options.x*((ux*u_camera.y+u_camera.x)/den);\n" - " if (u_visual_options.y > 0.5) yb = u_camera.w + (yb-u_camera.w)/den;\n" - " }\n" - " }\n" - " /* Under a fractional yaw a_q comes divided by the turn's denominator, or 0 for the whole triangle. */\n" - " float q = (u_visual > 0.5 && a_visual_flags > 1.5 && a_q > 0.0) ? a_q : 0.0;\n" - " v_uvq = vec3(a_uv*q, q); v_perspective = q > 0.0 ? 1 : 0;\n" - " if (u_xscale < 0.0) {\n" - " float s = -u_xscale; float h = u_xhalf / s;\n" - " float l = u_xcenter - h, r = u_xcenter + h;\n" - " if (xb < l) xb = l + (xb-l)*s; else if (xb > r) xb = r + (xb-r)*s;\n" - " } else xb = (xb - u_xcenter)*u_xscale + u_xcenter;\n" - " gl_Position = vec4((xb+u_shift+u_xoff)/u_xhalf - 1.0, (yb+u_shift)/256.0 - 1.0, 0.0, 1.0); }\n"; -static const char *TEX_FS = - "#version 330\n" - "noperspective in vec2 v_uv; noperspective in vec3 v_uvq;\n" - "noperspective in vec4 v_col; flat in int v_perspective;\n" - "noperspective in float v_tint;\n" - "out vec4 frag; out vec4 blend_factor;\n" - "flat in ivec2 v_tpage; /* texture page base, VRAM px */\n" - "flat in ivec2 v_clut; /* CLUT base, VRAM px */\n" - "flat in int v_depth; /* 0=4bit 1=8bit 2=15bit */\n" - "flat in int v_raw; /* 1 = no color modulation */\n" - "flat in ivec4 v_limits; /* prim uv sampling bounds (inclusive, post-wrap) */\n" - "flat in int v_semi; /* GP0 command has semi-transparency enabled */\n" - "uniform usampler2D u_vram;\n" - "uniform int u_semipass; /* 0=all texels, 1=STP=0 only, 2=STP=1 only */\n" - "uniform int u_semimode; /* PS1 blend mode; drives dual-source factors */\n" - "uniform ivec4 u_twin; /* texture window: mask_x, mask_y, off_x, off_y */\n" - "uniform int u_maskset; /* GP0(E6h) set-mask: OR bit15 into output */\n" - "uniform int u_filter; /* 1 = bilinear */\n" - "int vram_at(int x, int y){\n" - " return int(texelFetch(u_vram, ivec2(x & 1023, y & 511), 0).r);\n" - "}\n" - "int fetch_texel(int u, int v){\n" - " u &= 255; v &= 255;\n" - " if ((u_twin.x | u_twin.y) != 0) {\n" - " u = (u & ~(u_twin.x * 8)) | ((u_twin.z & u_twin.x) * 8);\n" - " v = (v & ~(u_twin.y * 8)) | ((u_twin.w & u_twin.y) * 8);\n" - " } else {\n" - " u = clamp(u, v_limits.x, v_limits.z);\n" - " v = clamp(v, v_limits.y, v_limits.w);\n" - " }\n" - " if (v_depth == 0) {\n" - " int px = vram_at(v_tpage.x + (u >> 2), v_tpage.y + v);\n" - " return vram_at(v_clut.x + ((px >> ((u & 3) * 4)) & 0xF), v_clut.y);\n" - " } else if (v_depth == 1) {\n" - " int px = vram_at(v_tpage.x + (u >> 1), v_tpage.y + v);\n" - " return vram_at(v_clut.x + ((px >> ((u & 1) * 8)) & 0xFF), v_clut.y);\n" - " }\n" - " return vram_at(v_tpage.x + u, v_tpage.y + v);\n" - "}\n" - "vec3 col5(int raw){\n" - " return vec3(float(raw & 31), float((raw >> 5) & 31), float((raw >> 10) & 31)) / 31.0;\n" - "}\n" - "void main(){\n" - " vec2 sample_uv = v_uv;\n" - " if (v_perspective != 0 && v_uvq.z > 1.0e-12) sample_uv = v_uvq.xy/v_uvq.z;\n" - " int stp; vec3 rgb;\n" - " if (u_filter == 0) {\n" - " int raw = fetch_texel(int(floor(sample_uv.x)), int(floor(sample_uv.y)));\n" - " if (raw == 0) discard;\n" - " rgb = col5(raw);\n" - " stp = (raw >> 15) & 1;\n" - " } else {\n" - " /* Bilinear, Beetle-PSX formulation: the NEAREST texel is the base\n" - " * (cutout + STP authority), the neighbours lie toward the sub-texel\n" - " * offset and clamp to u_limits, and each texel's weight is gated by\n" - " * its opacity with the colour renormalised — so prim edges and\n" - " * cutout borders keep their colour instead of dissolving into the\n" - " * transparent (black) neighbour and discarding whole edge columns. */\n" - " int iu = int(floor(sample_uv.x)), iv = int(floor(sample_uv.y));\n" - " float fx = sample_uv.x - float(iu) - 0.5, fy = sample_uv.y - float(iv) - 0.5;\n" - " int sx = fx < 0.0 ? -1 : 1, sy = fy < 0.0 ? -1 : 1;\n" - " fx = abs(fx); fy = abs(fy);\n" - " int c00 = fetch_texel(iu, iv);\n" - " int c10 = fetch_texel(iu + sx, iv);\n" - " int c01 = fetch_texel(iu, iv + sy);\n" - " int c11 = fetch_texel(iu + sx, iv + sy);\n" - " float w00 = (c00 == 0 ? 0.0 : 1.0) * (1.0 - fx) * (1.0 - fy);\n" - " float w10 = (c10 == 0 ? 0.0 : 1.0) * fx * (1.0 - fy);\n" - " float w01 = (c01 == 0 ? 0.0 : 1.0) * (1.0 - fx) * fy;\n" - " float w11 = (c11 == 0 ? 0.0 : 1.0) * fx * fy;\n" - " float opac = w00 + w10 + w01 + w11;\n" - " if (opac < 0.5) discard;\n" - " rgb = (col5(c00)*w00 + col5(c10)*w10 + col5(c01)*w01 + col5(c11)*w11) / opac;\n" - " float stpf = (float((c00 >> 15) & 1) * w00 + float((c10 >> 15) & 1) * w10\n" - " + float((c01 >> 15) & 1) * w01 + float((c11 >> 15) & 1) * w11) / opac;\n" - " stp = stpf >= 0.5 ? 1 : 0;\n" - " }\n" - " if (u_semipass == 1 && stp == 1) discard;\n" - " if (u_semipass == 2 && stp == 0) discard;\n" - " if (v_raw == 0) rgb = clamp(rgb * v_col.rgb * 2.0, 0.0, 1.0);\n" - " if (v_tint > 0.0) rgb = mix(rgb, vec3(1.0,0.15,0.85),v_tint);\n" - " float dst_factor = 0.0;\n" - " if (u_semimode == 4 && v_semi != 0 && stp != 0) {\n" - " dst_factor = v_semi == 1 ? 0.5 : 1.0;\n" - " if (v_semi == 1) rgb *= 0.5; else if (v_semi == 4) rgb *= 0.25;\n" - " }\n" - " frag = vec4(rgb, (stp == 1 || u_maskset == 1) ? 1.0 : 0.0);\n" - " blend_factor = vec4(0.0, 0.0, 0.0, dst_factor);\n" - "}\n"; - -/* Quad blit: used for CPU->VRAM upload flushes and VRAM->VRAM copies. - * Samples an RGBA8 source (alpha = bit15), splits by STP for the stencil - * write, optionally ORs set-mask into the output alpha. */ -static const char *BLIT_VS = - "#version 330\n" - "layout(location=0) in vec2 a_pos; /* native VRAM px */\n" - "uniform float u_shift;\n" - "void main(){\n" - " gl_Position = vec4((a_pos.x+u_shift)/512.0 - 1.0, (a_pos.y+u_shift)/256.0 - 1.0, 0.0, 1.0); }\n"; -static const char *BLIT_FS = - "#version 330\n" - "out vec4 frag;\n" - "uniform sampler2D u_src;\n" - "uniform int u_stp_pass; /* 0=all, 1=bit15=0 only, 2=bit15=1 only */\n" - "uniform int u_maskset;\n" - "uniform int u_src_div; /* fragcoord -> src texel divisor (S for native-res\n" - " sources, 1 for hr-res sources) */\n" - "uniform ivec2 u_src_off; /* added after the divide, in src texel units */\n" - "void main(){\n" - " /* Exact integer source fetch — no normalized-uv edge precision. */\n" - " ivec2 p = ivec2(gl_FragCoord.xy);\n" - " vec4 c = texelFetch(u_src, p / u_src_div + u_src_off, 0);\n" - " bool stp = c.a >= 0.5;\n" - " if (u_stp_pass == 1 && stp) discard;\n" - " if (u_stp_pass == 2 && !stp) discard;\n" - " frag = vec4(c.rgb, (stp || u_maskset != 0) ? 1.0 : 0.0);\n" - "}\n"; - -/* Pack: re-encode the hr FBO into the native R16UI raw mirror. Runs over a - * native-res viewport with scissor = dirty rect; each native pixel takes the - * top-left sample of its S*S block (the sample at the exact native coord). */ -static const char *PACK_VS = - "#version 330\n" - "void main(){ vec2 p = vec2((gl_VertexID<<1)&2, gl_VertexID&2);\n" - " gl_Position = vec4(p*2.0-1.0, 0.0, 1.0); }\n"; -static const char *PACK_FS = - "#version 330\n" - "uniform sampler2D u_hr;\n" - "uniform int u_scale;\n" - "out uint o_pix;\n" - "void main(){\n" - " ivec2 p = ivec2(gl_FragCoord.xy);\n" - " vec4 c = texelFetch(u_hr, p * u_scale, 0);\n" - " uint r = uint(c.r * 255.0 + 0.5) >> 3;\n" - " uint g = uint(c.g * 255.0 + 0.5) >> 3;\n" - " uint b = uint(c.b * 255.0 + 0.5) >> 3;\n" - " o_pix = r | (g << 5) | (b << 10) | (c.a >= 0.5 ? 0x8000u : 0u);\n" - "}\n"; - -/* Rebuild stencil bit 0 from a copied RGBA target's alpha. Color writes are - * disabled while this shader runs; only alpha>=0.5 fragments replace stencil. */ -static const char *STENCIL_FS = - "#version 330\n" - "uniform sampler2D u_src; out vec4 frag;\n" - "void main(){ vec4 c=texelFetch(u_src,ivec2(gl_FragCoord.xy),0);\n" - " if(c.a<0.5) discard; frag=vec4(0.0); }\n"; - -static GLuint compile_shader(GLenum type, const char *src) { - GLuint s = p_glCreateShader(type); - p_glShaderSource(s, 1, &src, NULL); - p_glCompileShader(s); - GLint ok = 0; p_glGetShaderiv(s, PSXGL_COMPILE_STATUS, &ok); - if (!ok) { char log[1024]; log[0]=0; p_glGetShaderInfoLog(s, sizeof log, NULL, log); - fprintf(stdout, "psxrecomp: GL shader compile failed: %s\n", log); - p_glDeleteShader(s); return 0; } - return s; -} -static GLuint build_program_ex(const char *vs, const char *fs, int dual_source) { - GLuint v = compile_shader(PSXGL_VERTEX_SHADER, vs), f = compile_shader(PSXGL_FRAGMENT_SHADER, fs); - if (!v || !f) return 0; - GLuint p = p_glCreateProgram(); - p_glAttachShader(p, v); p_glAttachShader(p, f); - if (dual_source) { - p_glBindFragDataLocationIndexed(p, 0, 0, "frag"); - p_glBindFragDataLocationIndexed(p, 0, 1, "blend_factor"); - } - p_glLinkProgram(p); - p_glDeleteShader(v); p_glDeleteShader(f); - GLint ok = 0; p_glGetProgramiv(p, PSXGL_LINK_STATUS, &ok); - if (!ok) { char log[1024]; log[0]=0; p_glGetProgramInfoLog(p, sizeof log, NULL, log); - fprintf(stdout, "psxrecomp: GL program link failed: %s\n", log); return 0; } - return p; -} -/* The drawing programs' own copy of a vertex shader: on the mirror the camera depth of slot 9 or 23 becomes the fragment's depth, - * and v_bill says what is drawn: 0 off the mirror, 1 on it, 2 there with a depth that is not a world polygon's exact one - * (a sprite's one depth, or a clipped polygon's partial one: flags of 1, an exact polygon has 3). - * The source strings stay as they are: the in-between presenter makes its own copies of them. */ -/* A copy of a shader with a line after its first one and another end. NULL when it does not end as expected. */ -static char *shader_variant(const char *source, const char *declared, const char *end, const char *ending) { - const size_t length = strlen(source), tail = strlen(end), room = length + strlen(declared) + strlen(ending) + 8; - const char *body = strchr(source, '\n'); - char *out; - int written; - if (!body || length < tail || strcmp(source + length - tail, end)) return NULL; - out = (char *)malloc(room); - if (!out) return NULL; - written = snprintf(out, room, "%.*s%s", (int)(body + 1 - source), source, declared); - memcpy(out + written, body + 1, (size_t)(source + length - tail - (body + 1))); - strcpy(out + written + (source + length - tail - (body + 1)), ending); - return out; -} - -static char *depth_writing_shader(const char *source, int location) { - static const char flat[] = "0.0, 1.0); }\n"; - static const char deep[] = "u_visual_options.w >= 1.0 ? 0.99999 : ((u_visual > 0.5 && abs(a_camz) > 16.0) ? max(1.0 - 32.0*(1.0 + u_pull)/(abs(a_camz)*(1.0 + max(u_visual_options.w, 0.0))), -1.0) : 0.999999), 1.0);\n" - " bool grown = a_visual_flags > 5.5;\n" - " if (u_visual > 0.5 && (u_visual_options.w == 0.0 ? grown : u_visual_options.w >= 1.0 ? (grown || a_visual_flags < 1.5) : (u_visual_options.w > 0.0 && !grown)))\n" - " gl_Position = vec4(2.0, 2.0, 2.0, 1.0);\n" - " v_deep = abs(a_camz) > 16.0 ? 1.0 : -1000.0;\n" - " v_bill = u_visual > 0.5 ? ((abs(a_camz) > 16.0 && a_visual_flags < 1.5) ? 2.0 : 1.0) : 0.0; }\n"; - const size_t length = strlen(source), tail = sizeof(flat) - 1, room = length + sizeof(deep) + 160; - const char *body = strchr(source, '\n'); - char *out; - int written; - if (!body || length < tail || strcmp(source + length - tail, flat)) return NULL; - out = (char *)malloc(room); - if (!out) return NULL; - written = snprintf(out, room, "%.*slayout(location=%d) in float a_camz; flat out float v_bill; noperspective out float v_deep; uniform float u_pull;\n", (int)(body + 1 - source), source, location); - memcpy(out + written, body + 1, (size_t)(source + length - tail - (body + 1))); - strcpy(out + written + (source + length - tail - (body + 1)), deep); - return out; -} - -/* The textured program's own copy of its fragment shader. Pass 3 and 4 write depth alone, 4 for a batch whose STP texels are blended: - * a blended fragment leaves the depth under it. On the mirror, whose alpha nothing reads back, a blended fragment marks it 0.25 - * and an opaque one of a sprite 0.5. */ -static char *sprite_marking_shader(const char *source) { - static const char end[] = "}\n"; - static const char marked[] = - " bool blended = stp != 0 && (u_semimode == 4 ? v_semi != 0 : (u_semipass == 2 || u_semipass == 4));\n" - " if (u_semipass == 4 && blended) discard;\n" - " if (v_bill > 0.5 && blended) frag.a = 0.25; else if (v_bill > 1.5) frag.a = 0.5;\n" - " if (v_bill > 0.5 && v_deep > -999.5 && v_deep < 0.999) frag.a = 0.25; /* some corner has no depth (it weighs -1000): its depth is no surface */\n" - "}\n"; - const size_t length = strlen(source), tail = sizeof(end) - 1, room = length + sizeof(marked) + 64; - const char *body = strchr(source, '\n'); - char *out; - int written; - if (!body || length < tail || strcmp(source + length - tail, end)) return NULL; - out = (char *)malloc(room); - if (!out) return NULL; - written = snprintf(out, room, "%.*sflat in float v_bill; noperspective in float v_deep;\n", (int)(body + 1 - source), source); - memcpy(out + written, body + 1, (size_t)(source + length - tail - (body + 1))); - strcpy(out + written + (source + length - tail - (body + 1)), marked); - return out; -} - -static GLuint build_program(const char *vs, const char *fs) { - return build_program_ex(vs, fs, 0); -} - -/* ---- pixel conversion (PS1 1555: bit15=mask, B[14:10] G[9:5] R[4:0]) ---- */ -static inline uint32_t conv_1555_to_rgba8(uint16_t p) { - uint32_t r = (p & 0x1F) << 3, g = ((p >> 5) & 0x1F) << 3, b = ((p >> 10) & 0x1F) << 3; - uint32_t a = (p >> 15) & 1 ? 0xFF : 0; - return r | (g << 8) | (b << 16) | (a << 24); /* RGBA8 little-endian */ -} - -/* ---- mask-bit stencil --------------------------------------------------- * - * Stencil bit0 mirrors bit15 of every pixel. - * check off: test ALWAYS, op REPLACE with ref = write value. - * check on: test EQUAL 0 (pass iff dest unmasked). GL couples the REPLACE - * value to the test reference, so write a 1 via INVERT (the - * stored value is known to be 0 when the test passed) and a 0 - * via KEEP. */ -static void mask_stencil(int write_val) { - if (s_fill_pass > 0.0f) { /* a filler obeys the mask and leaves it as it is */ - if (!s_mask_check) { glDisable(GL_STENCIL_TEST); return; } - glEnable(GL_STENCIL_TEST); - glStencilMask(0x00); - glStencilFunc(GL_EQUAL, 0, 0x01); - glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP); - return; - } - glEnable(GL_STENCIL_TEST); - glStencilMask(0x01); - if (s_mask_check) { - glStencilFunc(GL_EQUAL, 0, 0x01); - glStencilOp(GL_KEEP, GL_KEEP, write_val ? GL_INVERT : GL_KEEP); - } else { - glStencilFunc(GL_ALWAYS, write_val ? 1 : 0, 0x01); - glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE); - } -} -/* Like mask_stencil but never checks (uploads: gpu.c already applied mask). */ -static void plain_stencil(int write_val) { - glEnable(GL_STENCIL_TEST); - glStencilMask(0x01); - glStencilFunc(GL_ALWAYS, write_val ? 1 : 0, 0x01); - glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE); -} - -/* PS1 semi-transparency as fixed-function blending, RGB only — the alpha - * channel (mask bit) is always replaced by the source fragment's alpha. - * 0: B/2 + F/2 1: B + F 2: B - F 3: B + F/4 */ -static void apply_psx_blend(int mode) { - glEnable(GL_BLEND); - p_glBlendEquationSeparate((mode & 3) == 2 ? PSXGL_FUNC_REVERSE_SUBTRACT - : PSXGL_FUNC_ADD, - PSXGL_FUNC_ADD); - switch (mode & 3) { - case 0: - p_glBlendColor(0.5f, 0.5f, 0.5f, 0.5f); - p_glBlendFuncSeparate(PSXGL_CONSTANT_ALPHA, PSXGL_CONSTANT_ALPHA, GL_ONE, GL_ZERO); - break; - case 1: - case 2: - p_glBlendFuncSeparate(GL_ONE, GL_ONE, GL_ONE, GL_ZERO); - break; - case 3: - p_glBlendColor(0.25f, 0.25f, 0.25f, 0.25f); - p_glBlendFuncSeparate(PSXGL_CONSTANT_ALPHA, GL_ONE, GL_ONE, GL_ZERO); - break; - } -} - -/* ---- hr FBO render-state bracket ---------------------------------------- */ -static void hr_begin(int clip_to_draw_area) { - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hr_fbo); - glViewport(0, 0, VRAM_W * s_scale, VRAM_H * s_scale); - glEnable(GL_SCISSOR_TEST); - if (clip_to_draw_area) { - int sw = s_area_x2 - s_area_x1 + 1, sh = s_area_y2 - s_area_y1 + 1; - if (sw < 0) sw = 0; if (sh < 0) sh = 0; - glScissor(s_area_x1 * s_scale, s_area_y1 * s_scale, - sw * s_scale, sh * s_scale); - } -} -static void hr_end(void) { - glDisable(GL_BLEND); - glDisable(GL_STENCIL_TEST); - glDisable(GL_SCISSOR_TEST); - p_glBindVertexArray(0); - p_glUseProgram(0); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); -} - -/* ---- coherency: CPU -> GPU upload flush --------------------------------- */ -/* CPU-side VRAM writes (GP0 A0 transfers, DMA, single pixel pokes) land in - * the CPU array immediately and accumulate s_up_rects. Flushing before the - * next GPU op (or readback/present) preserves PS1 command order. */ -static void flush_cpu_upload(void) { - if (!s_raster_ok || s_up_nrects == 0) return; - const int diag = runtime_upload_diag_enabled(); - if (diag) { s_rt_up_diag[0]++; s_rt_up_diag[1] += (uint64_t)s_up_nrects; } - flush_flat_batch(); /* queued flat GEO before upload mutates VRAM */ - flush_tex_batch(); /* queued textured draws before this upload writes VRAM */ - /* Snapshot + clear first (re-entrancy safe; up_add overflow calls back in). */ - DirtyRect rects[UP_RECTS_MAX]; - int nrects = s_up_nrects; - memcpy(rects, s_up_rects, (size_t)nrects * sizeof(DirtyRect)); - s_up_nrects = 0; - - /* Stage every rect's CPU data first (texture uploads outside the FBO - * bracket), then draw all the quads in one bracket. Only the exact - * uploaded rects are painted — never the union bounding box (stale-CPU - * flicker class bug, see s_up_rects). */ - for (int i = 0; i < nrects; i++) { - int x = rects[i].x0, y = rects[i].y0; - int w = rects[i].x1 - rects[i].x0 + 1; - int h = rects[i].y1 - rects[i].y0 + 1; - if (diag) s_rt_up_diag[2] += (uint64_t)w * (uint64_t)h; - coh_record(GL_COH_FLUSH, x, y, x + w - 1, y + h - 1); - - /* RGBA8 staging for the hr quad draw. */ - uint64_t t0 = diag ? SDL_GetPerformanceCounter() : 0; - for (int row = 0; row < h; row++) { - const uint16_t *src = s_vram + (size_t)(y + row) * VRAM_W + x; - uint32_t *dst = s_conv + (size_t)row * w; - for (int col = 0; col < w; col++) dst[col] = conv_1555_to_rgba8(src[col]); - } - if (diag) s_rt_up_diag[3] += SDL_GetPerformanceCounter() - t0; - t0 = diag ? SDL_GetPerformanceCounter() : 0; - glBindTexture(GL_TEXTURE_2D, s_up_tex); - glTexSubImage2D(GL_TEXTURE_2D, 0, x, y, w, h, GL_RGBA, GL_UNSIGNED_BYTE, s_conv); - - /* Raw mirror takes the CPU data directly — current for this rect, so no - * pack is needed for uploaded content. */ - glBindTexture(GL_TEXTURE_2D, s_raw_tex); - glPixelStorei(PSXGL_UNPACK_ROW_LENGTH, VRAM_W); - glTexSubImage2D(GL_TEXTURE_2D, 0, x, y, w, h, - PSXGL_RED_INTEGER, GL_UNSIGNED_SHORT, - s_vram + (size_t)y * VRAM_W + x); - glPixelStorei(PSXGL_UNPACK_ROW_LENGTH, 0); - temporal_note_raw_change(); - if (diag) s_rt_up_diag[4] += SDL_GetPerformanceCounter() - t0; - } - - /* Quads into the hr FBO; two passes split by bit15 so the stencil mirror - * stays exact. gpu.c applied mask set/check per pixel already — no check - * here, the data is final. up_tex is VRAM-aligned: src texel = frag/S. */ - uint64_t draw_t0 = diag ? SDL_GetPerformanceCounter() : 0; - hr_begin(0); - glDisable(GL_BLEND); - p_glUseProgram(s_blit_prog); - p_glActiveTexture(PSXGL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, s_up_tex); - p_glUniform1i(s_uBlitSrc, 0); - p_glUniform1i(s_uBlitMaskset, 0); - p_glUniform1i(s_uBlitSrcDiv, s_scale); - p_glUniform2i(s_uBlitSrcOff, 0, 0); - p_glBindVertexArray(s_blit_vao); - p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_blit_vbo); - for (int i = 0; i < nrects; i++) { - int x = rects[i].x0, y = rects[i].y0; - int w = rects[i].x1 - rects[i].x0 + 1; - int h = rects[i].y1 - rects[i].y0 + 1; - glScissor(x * s_scale, y * s_scale, w * s_scale, h * s_scale); - float fx0 = (float)x, fy0 = (float)y, fx1 = (float)(x + w), fy1 = (float)(y + h); - float verts[6 * 2] = { - fx0, fy0, fx1, fy0, fx0, fy1, - fx1, fy0, fx0, fy1, fx1, fy1, - }; - p_glBufferData(PSXGL_ARRAY_BUFFER, sizeof verts, verts, PSXGL_STREAM_DRAW); - plain_stencil(0); p_glUniform1i(s_uBlitPass, 1); glDrawArrays(GL_TRIANGLES, 0, 6); - plain_stencil(1); p_glUniform1i(s_uBlitPass, 2); glDrawArrays(GL_TRIANGLES, 0, 6); - } - hr_end(); - if (diag) s_rt_up_diag[5] += SDL_GetPerformanceCounter() - draw_t0; -} - -/* Recreate one target's stencil mask from its authoritative alpha channel. - * Sampling an attached render target is undefined, so copy color to the shared - * scratch texture first. Wide targets never exceed the 1024-pixel VRAM width. */ -static void rebuild_target_stencil(GLuint target_fbo, int target_w, int target_h) { - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, target_fbo); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, s_scratch_fbo); - p_glBlitFramebuffer(0, 0, target_w, target_h, 0, 0, target_w, target_h, - GL_COLOR_BUFFER_BIT, GL_NEAREST); - - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, target_fbo); - glViewport(0, 0, target_w, target_h); - glDisable(GL_SCISSOR_TEST); - glDisable(GL_BLEND); - glEnable(GL_STENCIL_TEST); - glStencilMask(0x01); - glClearStencil(0); - glClear(GL_STENCIL_BUFFER_BIT); - glStencilFunc(GL_ALWAYS, 1, 0x01); - glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE); - glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); - p_glUseProgram(s_stencil_prog); - p_glActiveTexture(PSXGL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, s_scratch_tex); - p_glUniform1i(s_uStencilSrc, 0); - p_glBindVertexArray(s_empty_vao); - glDrawArrays(GL_TRIANGLES, 0, 3); - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - glDisable(GL_STENCIL_TEST); - p_glBindVertexArray(0); - p_glUseProgram(0); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); -} - -static void rebuild_mask_stencils(void) { - if (s_stencil_valid || !s_raster_ok) return; - int hw = VRAM_W * s_scale, hh = VRAM_H * s_scale; - rebuild_target_stencil(s_hr_fbo, hw, hh); - for (int i = 0; i < WIDE_MAX_SURF; i++) { - if (s_wide_fbo[i]) - rebuild_target_stencil(s_wide_fbo[i], g_wide_w * s_scale, hh); - } - s_stencil_valid = 1; -} - -/* ---- coherency: hr FBO -> raw mirror (pack) ------------------------------ */ -static void pack_flush(void) { - if (!s_raster_ok || !s_pack_dirty.set) return; - int x = s_pack_dirty.x0, y = s_pack_dirty.y0; - int w = s_pack_dirty.x1 - s_pack_dirty.x0 + 1; - int h = s_pack_dirty.y1 - s_pack_dirty.y0 + 1; - rect_clear(&s_pack_dirty); - coh_record(GL_COH_PACK, x, y, x + w - 1, y + h - 1); - temporal_note_raw_change(); - - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_raw_fbo); - glViewport(0, 0, VRAM_W, VRAM_H); - glEnable(GL_SCISSOR_TEST); - glScissor(x, y, w, h); - glDisable(GL_BLEND); - glDisable(GL_STENCIL_TEST); - p_glUseProgram(s_pack_prog); - p_glActiveTexture(PSXGL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, s_hr_tex); - p_glUniform1i(s_uPackHr, 0); - p_glUniform1i(s_uPackScale, s_scale); - p_glBindVertexArray(s_empty_vao); - glDrawArrays(GL_TRIANGLES, 0, 3); - p_glBindVertexArray(0); - p_glUseProgram(0); - glDisable(GL_SCISSOR_TEST); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); -} - -/* Make sure the raw mirror is current for a textured draw that samples the - * given texture page / CLUT. */ -static void flush_pack_if_sampling(int tpage_x, int tpage_y, int depth, - int clut_x, int clut_y) { - if (!s_pack_dirty.set) return; - int page_w = depth == 0 ? 64 : depth == 1 ? 128 : 256; /* VRAM columns */ - if (rect_intersects(&s_pack_dirty, tpage_x, tpage_y, - tpage_x + page_w - 1, tpage_y + 255)) { - flush_flat_batch(); - flush_tex_batch(); /* queued draws are part of s_pack_dirty — realise them before packing */ - pack_flush(); return; - } - if (depth <= 1) { - int n = depth == 0 ? 16 : 256; - if (rect_intersects(&s_pack_dirty, clut_x, clut_y, clut_x + n - 1, clut_y)) { - flush_flat_batch(); - flush_tex_batch(); - pack_flush(); - } - } -} - -/* ---- coherency: GPU -> CPU readback -------------------------------------- */ -static void ensure_cpu(void) { - if (!s_raster_ok || !s_gpu_dirty) return; - flush_flat_batch(); - flush_tex_batch(); /* realise queued textured draws before reading the FBO back */ - flush_cpu_upload(); - pack_flush(); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_raw_fbo); - glReadPixels(0, 0, VRAM_W, VRAM_H, PSXGL_RED_INTEGER, GL_UNSIGNED_SHORT, s_vram); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - s_gpu_dirty = 0; - coh_record(GL_COH_ENSURE, 0, 0, VRAM_W - 1, VRAM_H - 1); -} - -/* ---- GPU primitives ------------------------------------------------------ */ - -static uint64_t s_scene_prims = 0; /* frame_perf: scene primitives submitted (pre double-draw) */ -static uint64_t s_scene_prims_tex = 0; /* frame_perf: of which textured (vs flat geometry) */ -static void flush_tex_batch(void); /* fwd: drained at the backdrop-phase boundary below */ -static void mark_prim_dirty(const int *xs, const int *ys, int n, int textured) { - s_scene_prims++; - int x0 = xs[0], x1 = xs[0], y0 = ys[0], y1 = ys[0]; - for (int i = 1; i < n; i++) { - if (xs[i] < x0) x0 = xs[i]; if (xs[i] > x1) x1 = xs[i]; - if (ys[i] < y0) y0 = ys[i]; if (ys[i] > y1) y1 = ys[i]; - } - s_bdg_prims++; /* dbg: prims seen this frame (gate is now per-prim, see bd_prim_gate) */ - if (s_ptrace_n < PTRACE_CAP) { - PrimRec *p = &s_ptrace[s_ptrace_n++]; - p->x0 = (short)x0; p->x1 = (short)x1; p->y0 = (short)y0; p->y1 = (short)y1; - p->tex = (unsigned char)textured; - } - if (x0 < s_area_x1) x0 = s_area_x1; - if (y0 < s_area_y1) y0 = s_area_y1; - if (x1 > s_area_x2) x1 = s_area_x2; - if (y1 > s_area_y2) y1 = s_area_y2; - rect_add(&s_pack_dirty, x0, y0, x1, y1); - s_gpu_dirty = 1; - coh_record(GL_COH_DRAW, x0, y0, x1, y1); -} - -/* ---- native-wide mirror pass plumbing ----------------------------------- * - * Re-issue an already-set-up draw (program/VAO/VBO/blend/stencil bound) into - * the active wide surface. The geometry positions are identical on the host - * side; the x translation (wide_dx) and the wider clip are applied entirely in - * the vertex shader via u_xoff / u_xhalf, so the SAME glDrawArrays produces the - * shifted copy. wide_target_begin binds the wide FBO + viewport + scissor and - * sets the projection uniforms; wide_target_end restores u_xoff=0/u_xhalf=512 - * on the active program so the next canonical pass is bit-identical. The - * caller stays inside hr_begin/hr_end; hr_end unbinds and the next hr_begin - * resets the viewport, so no viewport restore is needed mid-function. - * - * The scissor X is the FULL wide surface (NOT the draw area): the SW reference - * (rt_wide()) deliberately lets the shifted geometry fill the revealed 16:9 - * margins that lie OUTSIDE the game's 4:3 draw-area x-range; scissoring X to - * the (translated) draw area would crop exactly the margin content native-wide - * exists to reveal. The scissor Y stays clamped to the DRAW AREA, exactly like - * rt_wide() (t.cy1/cy2 = g_clip_y1/y2): native-wide only widens X. A full-height - * Y scissor let draws that canonically clip at a vertical double-buffer band - * boundary (MMX6: draw area alternates y=0/y=240, both bands in ONE wide - * surface) bleed into the OTHER band's rows — presented one frame later as - * top/bottom edge flicker (16:9 GL only). */ -/* Rows y..y+h-1 of a surface as bits, one for every 16 rows. */ -static uint32_t wide_depth_rows(int y, int h) { - uint32_t rows = 0; - for (int block = y < 0 ? 0 : y / 16; block < 32 && block * 16 < y + h; block++) rows |= 1u << block; - return rows; -} - -/* The depth of a frame buffer is one frame's: drawing into rows that have been shown starts from none. The game's frame buffers - * share a surface, one above the other, so the rows on screen keep theirs. Polygons the game blends into its far colour write - * no depth: without this the last frame's would stay under them. */ -static void wide_depth_shown(GLuint fbo, int y, int h) { - for (int i = 0; i < WIDE_MAX_SURF; i++) - if (s_wide_fbo[i] == fbo) s_wide_depth_shown[i] |= wide_depth_rows(y, h); -} - -static void wide_depth_begin(void) { - int sy = s_area_y1, sh = s_area_y2 - s_area_y1 + 1; - if (!mirror_depth()) return; - if (sy < 0) { sh += sy; sy = 0; } - if (sy + sh > VRAM_H) sh = VRAM_H - sy; - if (sh <= 0) return; - for (int i = 0; i < WIDE_MAX_SURF; i++) { - const uint32_t rows = wide_depth_rows(sy, sh); - if (s_wide_fbo[i] != g_wide_cur || !(s_wide_depth_shown[i] & rows)) continue; - s_wide_depth_shown[i] &= ~rows; - glEnable(GL_SCISSOR_TEST); - glScissor(0, sy * s_scale, g_wide_w * s_scale, sh * s_scale); - glDepthMask(GL_TRUE); - glClearDepth(1.0); - glClear(GL_DEPTH_BUFFER_BIT); - } -} - -static void wide_target_begin(int dx, GLint uXoff, GLint uXhalf) { - if (s_ws_ablate != 3) /* ablate 3: no FBO rebind (draws land in hr — perf probe) */ - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, g_wide_cur); - wide_depth_begin(); - glViewport(0, 0, g_wide_w * s_scale, VRAM_H * s_scale); - glEnable(GL_SCISSOR_TEST); - { - int sy = s_area_y1, sh = s_area_y2 - s_area_y1 + 1; - if (sy < 0) { sh += sy; sy = 0; } - if (sy + sh > VRAM_H) sh = VRAM_H - sy; - if (sh < 0) sh = 0; - glScissor(0, sy * s_scale, g_wide_w * s_scale, sh * s_scale); - } - if (mirror_depth()) { glEnable(GL_DEPTH_TEST); glDepthFunc(GL_ALWAYS); glDepthMask(GL_FALSE); } - p_glUniform1f(uXoff, (float)dx); - p_glUniform1f(uXhalf, (float)g_wide_w / 2.0f); -} -static void wide_target_end(GLint uXoff, GLint uXhalf) { - if (mirror_depth()) { glDepthMask(GL_TRUE); glDisable(GL_DEPTH_TEST); } - p_glUniform1f(uXoff, 0.0f); - p_glUniform1f(uXhalf, 512.0f); - if (s_ws_ablate != 3) - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hr_fbo); -} - -extern int psx_ws_prim_is_tagged(void); /* gpu.c: is the current GP0 prim sprite-tagged? */ -extern int psx_ws_prim_in_backdrop(void); /* gpu.c: is its source addr in the flower-field struct? */ -extern int gpu_ws_nw_flat_backdrop_enabled(void); /* gpu.c: per-title flat backdrop opt-in */ - -/* Per-prim gate: stretch this prim iff native-wide + feature on AND the prim's - * source address is inside the flower-field backdrop data structure (precise — - * excludes the 3D rock/foreground, which is untagged AND has narrow prims so the - * earlier tag/narrow heuristic tore it). mode!=0 falls back to the old - * tag+narrow heuristic for A/B. */ -static int bd_prim_gate(const int *xs, int n, int textured) { - if (g_wide_w <= 0 || !g_ws_bd_stretch_on) return 0; - /* Some games draw their authored 4:3 sky/water as flat-colour polygons. - * Stretch those only in the native-wide mirror: the canonical framebuffer - * remains byte-for-byte 4:3, while the flat backdrop reaches the reveal - * margins. Opt-in because flat foreground geometry is title-dependent. */ - if (!textured && gpu_ws_nw_flat_backdrop_enabled()) return 1; - if (g_ws_bd_phase_mode != 0) return psx_ws_prim_in_backdrop(); /* default: precise address gate */ - /* mode 0: legacy tag+narrow heuristic (kept for comparison) */ - int native_w = g_wide_w - 2 * g_wide_off; - if (native_w <= 0) return 0; - if (psx_ws_prim_is_tagged()) return 0; - int base = g_wide_cur_base, lo = xs[0], hi = xs[0]; - for (int i = 1; i < n; i++) { if (xs[i] < lo) lo = xs[i]; if (xs[i] > hi) hi = xs[i]; } - if (lo < base - g_ws_bd_phase_thresh) return 0; /* into left margin -> GTE-wide */ - if (hi > base + native_w + g_ws_bd_phase_thresh) return 0; /* into right margin -> GTE-wide */ - return 1; -} - -/* ---- native-wide FAST path (skip redundant center mirror) ----------------- * - * The wide surface's CENTRE columns [g_wide_off, g_wide_off+native_w) are, by - * construction, identical to the canonical 4:3 framebuffer. So instead of - * re-rasterizing every primitive into the wide surface (the "mirror" pass — the - * dominant native-wide GPU cost, ~2x scene fill), we copy the canonical centre - * into the wide surface once at present (blit_wide_center_from_canonical), and - * the per-prim mirror only needs to produce the reveal MARGINS. Any prim/batch - * whose x-range is fully inside the 4:3 frame contributes nothing to the margins, - * so its mirror is skipped entirely. Correctness does not depend on the skip - * being precise: the centre is authoritatively overwritten by the blit, so the - * ONLY requirement is that a margin-reaching prim is NOT skipped — hence the - * conservative strict-inside test. 4:3 never runs any of this (g_wide_cur == 0). - * Toggle via gl_wide_fast for A/B; default ON. */ -static int s_wide_fast = 1; -void gl_renderer_set_wide_fast(int on) { s_wide_fast = on ? 1 : 0; } -int gl_renderer_get_wide_fast(void) { return s_wide_fast; } -static void wide_blit_center(GLuint wide_fbo, int base_x, int disp_y, int disp_h); /* def below */ -/* True if [lo,hi] (canonical draw-x) lies strictly inside the 4:3 frame, so the - * prim adds nothing to either reveal margin and its mirror can be skipped. */ -static int mirror_x_center_only(int lo, int hi) { - if (!s_wide_fast || s_geometry_correction) return 0; - int base = g_wide_cur_base, native_w = g_wide_w - 2 * g_wide_off; - if (native_w <= 0) return 0; - return (lo >= base) && (hi < base + native_w); -} -static int mirror_geo_center_only(const int *xs, int n) { - if (!s_wide_fast || s_geometry_correction) return 0; - int lo = xs[0], hi = xs[0]; - for (int i = 1; i < n; i++) { if (xs[i] < lo) lo = xs[i]; if (xs[i] > hi) hi = xs[i]; } - return mirror_x_center_only(lo, hi); -} -/* mirror_batch_center_only (textured-batch variant) is defined after s_tb below. */ - -/* Set / clear the 2D-backdrop x-stretch for a wide-mirror draw, per the current - * gate (s_bd_gate, set by the caller from bd_prim_gate / the batch gate). */ -static void wide_set_bd_scale(GLint uScale, GLint uCenter) { - extern int g_ws_tex_edge_pct; - float scale = 1.0f, center = 0.0f; - if (s_bd_gate && g_ws_bd_stretch_on && g_wide_w > 0) { - int native_w = g_wide_w - 2 * g_wide_off; - if (native_w > 0) { - scale = g_ws_bd_stretch_pct > 0 ? (float)g_ws_bd_stretch_pct / 100.0f - : (float)g_wide_w / (float)native_w; - if (s_bd_gate == 2) { - scale = g_ws_tex_edge_pct > 0 - ? (float)g_ws_tex_edge_pct / 100.0f : scale; - scale = -scale; /* shader: expand only beyond canonical edges */ - } - center = (float)g_wide_cur_base + (float)native_w / 2.0f; - } - } - if (scale != 1.0f) s_bdg_applied++; - p_glUniform1f(uScale, scale); - p_glUniform1f(uCenter, center); -} -static void wide_clear_bd_scale(GLint uScale, GLint uCenter) { - p_glUniform1f(uScale, 1.0f); - p_glUniform1f(uCenter, 0.0f); -} - -/* ---- textured-prim batching -------------------------------------------- * - * Consecutive textured prims sharing blend/mask/texwindow/filter coalesce into - * one draw. Per-prim texture state (texpage/clut/depth/raw/uv-limits) rides in - * the vertex (TEXV flat attributes), so only `semi` (blend) and the global - * mask/twin/filter are batch keys. flush_tex_batch() draws the queued verts; it - * is called before any op that reads VRAM, writes it outside the batch, or - * changes batch state (see callers: flush_cpu_upload, flush_pack_if_sampling, - * every non-textured glb_ wrapper, and the present path). Drawing reads only the - * already-coherent texture (per-prim coherency was ensured at append time), so - * flush_tex_batch never re-enters those helpers. */ -#define TEXBATCH_MAXV 8190 /* multiple of 3; ~2730 tris */ -static float s_tb[TEXBATCH_MAXV * TEXV]; -static uint64_t s_tb_identity[TEXBATCH_MAXV]; -static int s_tb_n = 0; /* verts queued */ -static int s_tb_semi = -2; -static int s_tb_mask = 0, s_tb_filter = 0; -static int s_tb_twin[4] = {0, 0, 0, 0}; -static uint64_t s_batch_total = 0, s_batch_reason[7]; - -void gl_renderer_batch_diag(uint64_t out[8]) { - out[0] = s_batch_total; - for (int i = 0; i < 7; i++) out[i + 1] = s_batch_reason[i]; -} - -/* Draw the queued textured batch with correct PSX mask-bit handling AND correct - * painter's order. The mask bit lives in both the colour-attachment alpha - * (frag.a) and the stencil; STP=1 texels must always set it. - * - * OPAQUE batch (semi < 0): the STP bit does NOT gate COLOUR (every texel is - * opaque), so colour is drawn in ONE ordered pass over all texels — frag.a still - * carries the per-texel mask bit, so the alpha mask is correct — followed by a - * COLOUR-MASKED pass that only fixes the STENCIL for STP=1 texels. The old code - * split colour into STP=0 (pass 1) then STP=1 (pass 2) across the WHOLE batch, - * which let a behind prim's STP=1 texels overwrite a front prim's STP=0 colour - * (the Tomba character drew behind an AP-block's letters / a save post on GL - * only). Same draw count, order preserved, mask preserved. - * - * SEMI batch (semi >= 0): genuine two-pass — STP=0 texels opaque, STP=1 texels - * blended per the PSX mode. Cross-prim order is kept by isolating semi prims to - * one per batch (see gpu_textured_triangle), so this batch holds a single prim - * whose two passes do not self-overlap. */ -static void tex_batch_draw_passes(int nverts, int semi) { - p_glUniform1i(s_uSemimode, semi < 0 ? 0 : semi); - if (semi < 0) { - glDisable(GL_BLEND); - mask_stencil(s_tb_mask); - p_glUniform1i(s_uSemipass, 0); /* all texels, one ordered colour pass */ - glDrawArrays(GL_TRIANGLES, 0, nverts); - if (s_mask_check) { - glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); /* stencil-only fixup */ - mask_stencil(1); - p_glUniform1i(s_uSemipass, 2); /* STP=1 texels set the mask bit */ - glDrawArrays(GL_TRIANGLES, 0, nverts); - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - } else if (!s_tb_mask) { - /* Alpha is already exact; defer its duplicate stencil encoding - * until a later GP0(E6h) actually enables destination masking. */ - s_stencil_valid = 0; - } - } else if (!s_mask_check && semi == 4) { - /* Modes 0/1/3 can select opaque-vs-semi behavior per fragment with - * dual-source factors, so the whole painter-ordered batch is one draw. - * Mode 2 needs a different blend equation and stays on the conservative - * isolated path below. */ - glEnable(GL_BLEND); - p_glBlendEquationSeparate(PSXGL_FUNC_ADD, PSXGL_FUNC_ADD); - p_glBlendFuncSeparate(GL_ONE, PSXGL_SRC1_ALPHA, GL_ONE, GL_ZERO); - if (s_tb_mask) mask_stencil(1); else glDisable(GL_STENCIL_TEST); - p_glUniform1i(s_uSemipass, 0); - glDrawArrays(GL_TRIANGLES, 0, nverts); - if (!s_tb_mask && s_fill_pass <= 0.0f) s_stencil_valid = 0; - } else { - glDisable(GL_BLEND); /* Pass 1: STP=0 texels (opaque) */ - mask_stencil(s_tb_mask); - p_glUniform1i(s_uSemipass, 1); - glDrawArrays(GL_TRIANGLES, 0, nverts); - apply_psx_blend(semi); /* Pass 2: STP=1 texels (blended) */ - mask_stencil(1); - p_glUniform1i(s_uSemipass, 2); - glDrawArrays(GL_TRIANGLES, 0, nverts); - } -} - -/* ---- frame_perf CPU attribution (native-wide wedge hunt) ----------------- * - * Per-frame CPU wall time spent inside the GL submission paths (driver CPU - * cost surfaces INSIDE our gl* calls) + counters for the wide plumbing, so a - * CPU-bound wide frame (emu_cpu >> scene_gpu) can be attributed without a - * sampling profiler. Reset at present enter; reported by frame_perf. */ -static double cw_ms(void) { - static double freq = 0.0; - if (freq == 0.0) { - uint64_t f = SDL_GetPerformanceFrequency(); - freq = f ? (double)f : 1.0; - } - return (double)SDL_GetPerformanceCounter() * 1000.0 / freq; -} -static double s_cw_flush_ms = 0.0; /* CPU wall inside flush_tex_batch */ -static double s_cw_wide_ms = 0.0; /* CPU wall inside glb_wide_* entry points */ -static int s_cw_batches = 0, s_cw_wide_sets = 0, s_cw_wide_cfgs = 0, - s_cw_wide_clears = 0, s_cw_fbo_creates = 0, s_cw_flush_depth = 0; - -/* Textured-batch variant of mirror_x_center_only: scan the queued verts' x - * (attr 0, stride TEXV). Defined here so s_tb / TEXV are in scope. */ -static int mirror_batch_center_only(int nverts) { - if (!s_wide_fast || s_geometry_correction || nverts <= 0) return 0; - int lo = (int)s_tb[0], hi = (int)s_tb[0]; - for (int i = 1; i < nverts; i++) { - int x = (int)s_tb[i * TEXV]; - if (x < lo) lo = x; if (x > hi) hi = x; - } - return mirror_x_center_only(lo, hi); -} - -static float filler_line_width(int scale) { - const float width = s_crack_fill * (float)scale; - return width < 1.0f ? 1.0f : width > s_line_width_max ? s_line_width_max : width; -} - -/* What a filler pass of the mirror leaves as it found it: the mask bit in alpha and stencil, and the depth. */ -static void filler_pass_begin(float pass) { - s_fill_pass = pass; - geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 1); - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_FALSE); - glDepthFunc(GL_LESS); - glDepthMask(GL_FALSE); -} -static void filler_pass_end(void) { - s_fill_pass = 0.0f; - geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 1); - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - glStencilMask(0x01); - glDepthFunc(GL_ALWAYS); -} - -/* Under a batch, the edges of its exact world polygons: exact corners open the hairline gaps that whole pixels closed. */ -static void tex_batch_outline(int nverts, int semi) { - int exact = 0; - for (int i = 0; i < nverts && !exact; i += 3) exact = s_tb[i * TEXV + 19] > 1.5f; - if (!exact) return; - filler_pass_begin(2.0f); - glPolygonMode(GL_FRONT_AND_BACK, GL_LINE); - glLineWidth(filler_line_width(s_scale)); - glDisable(GL_BLEND); - mask_stencil(s_tb_mask); - p_glUniform1i(s_uSemimode, semi < 0 ? 0 : semi); - p_glUniform1i(s_uSemipass, semi < 0 ? 0 : 4); - glDrawArrays(GL_TRIANGLES, 0, nverts); - glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); - filler_pass_end(); -} - -/* A batch of exact world polygons by depth, see s_world_depth. */ -static void tex_batch_by_depth(int nverts, int semi) { - glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); - glDisable(GL_BLEND); - glDisable(GL_STENCIL_TEST); - glDepthFunc(GL_LEQUAL); - glDepthMask(GL_TRUE); - p_glUniform1i(s_uSemimode, semi < 0 ? 0 : semi); - p_glUniform1i(s_uSemipass, semi < 0 ? 3 : 4); - glDrawArrays(GL_TRIANGLES, 0, nverts); - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - glDepthMask(GL_FALSE); - p_glUniform1f(s_tex_uPull, s_world_margin); - tex_batch_draw_passes(nverts, semi); - p_glUniform1f(s_tex_uPull, 0.0f); - glDepthFunc(GL_ALWAYS); -} - -static void flush_tex_batch(void) { - if (s_tb_n == 0) return; - int nverts = s_tb_n, semi = s_tb_semi; - const int fillers = s_tb_fillers; - const int world = s_tb_world; - s_tb_n = 0; /* clear first: re-entrancy safe */ - double cw_t0 = cw_ms(); - s_cw_batches++; s_batch_total++; s_cw_flush_depth++; - - hr_begin(1); - p_glUseProgram(s_tex_prog); - geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 0); - p_glActiveTexture(PSXGL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, s_raw_tex); - p_glUniform1i(s_uVram, 0); - p_glUniform4i(s_uTwin, s_tb_twin[0], s_tb_twin[1], s_tb_twin[2], s_tb_twin[3]); - p_glUniform1i(s_uMaskset, s_tb_mask); - p_glUniform1i(s_uFilter, s_tb_filter); - p_glBindVertexArray(s_tex_vao); - p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_tex_vbo); - p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)(nverts * TEXV * sizeof(float)), s_tb, PSXGL_STREAM_DRAW); - - tex_batch_draw_passes(nverts, semi); - - /* Native-wide mirror — skipped for a batch fully inside the 4:3 frame (its - * centre content comes from the present-time canonical blit; nothing to add - * to the margins). A backdrop-stretched batch (s_tb_gate) widens past the - * frame, so it is never treated as centre-only. */ - if (g_wide_cur && s_ws_ablate != 1 && - !(s_tb_gate == 0 && mirror_batch_center_only(nverts))) { /* native-wide mirror */ - int dx = wide_dx(); - s_bd_gate = s_tb_gate; /* this batch is uniform-gate (flushed on change) */ - gl_perf_mirror_begin(); - wide_target_begin(dx, s_tex_uXoff, s_tex_uXhalf); - geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 1); - wide_set_bd_scale(s_tex_uXscale, s_tex_uXcenter); - s_draw_filler = mirror_depth() && fillers; - s_draw_world = mirror_depth() && world; - temporal_draw(s_tb, s_tb_identity, nverts, 1, GL_TRIANGLES, semi, s_tb_mask, - s_tb_filter, s_tb_twin, s_tb_gate, 0); - s_draw_filler = s_draw_world = 0; - if (s_ws_ablate != 2) { - if (mirror_depth() && s_crack_fill > 0.0f) tex_batch_outline(nverts, semi); - if (mirror_depth() && fillers) { /* in sequence each copy would break the batch: 135 batches a frame against 2 */ - filler_pass_begin(GROWN_COPY_MARGIN); - tex_batch_draw_passes(nverts, semi); - filler_pass_end(); - } - if (world && mirror_depth()) tex_batch_by_depth(nverts, semi); - else tex_batch_draw_passes(nverts, semi); - } - if (mirror_depth() && !world && s_ws_ablate != 2) { /* the depth of the opaque fragments alone */ - glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); - glDisable(GL_BLEND); - glDisable(GL_STENCIL_TEST); - glDepthMask(GL_TRUE); - p_glUniform1i(s_uSemipass, semi < 0 ? 3 : 4); - glDrawArrays(GL_TRIANGLES, 0, nverts); - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - } - wide_clear_bd_scale(s_tex_uXscale, s_tex_uXcenter); - geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 0); - wide_target_end(s_tex_uXoff, s_tex_uXhalf); - gl_perf_mirror_end(); - } - hr_end(); - if (--s_cw_flush_depth == 0) s_cw_flush_ms += cw_ms() - cw_t0; -} - -/* Flat / gouraud GEO batch — MotK title/char-select starfields issue ~30k/s - * GP0(68h) 1x1 dots; each was two immediate gpu_triangle draws (BufferData + - * DrawArrays each). Coalesce opaque/semi-uniform tris into one draw. */ -#define FLATBATCH_MAXV 8190 /* multiple of 3 */ -static float s_fb[FLATBATCH_MAXV * GEOV]; -static uint64_t s_fb_identity[FLATBATCH_MAXV]; -static int s_fb_n = 0; -static int s_fb_semi = -2; -static int s_fb_mask = -1; - -static int mirror_flat_batch_center_only(int nverts) { - if (!s_wide_fast || s_geometry_correction || nverts <= 0) return 0; - int lo = (int)s_fb[0], hi = (int)s_fb[0]; - for (int i = 1; i < nverts; i++) { - int x = (int)s_fb[i * GEOV]; - if (x < lo) lo = x; if (x > hi) hi = x; - } - return mirror_x_center_only(lo, hi); -} - -static void flush_flat_batch(void) { - if (s_fb_n == 0) return; - int nverts = s_fb_n, semi = s_fb_semi, mask = s_fb_mask; - s_fb_n = 0; - - hr_begin(1); - if (semi >= 0) apply_psx_blend(semi); else glDisable(GL_BLEND); - mask_stencil(mask); - p_glUseProgram(s_geo_prog); - geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); - p_glBindVertexArray(s_geo_vao); - p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_geo_vbo); - p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)(nverts * GEOV * sizeof(float)), - s_fb, PSXGL_STREAM_DRAW); - glDrawArrays(GL_TRIANGLES, 0, nverts); - - if (g_wide_cur && !s_wide_suppress && s_ws_ablate != 1 && - !(!g_ws_bd_stretch_on && mirror_flat_batch_center_only(nverts))) { - int dx = wide_dx(); - /* Batch may span many prims; use stretch gate off (flat dots/UI). */ - s_bd_gate = 0; - gl_perf_mirror_begin(); - wide_target_begin(dx, s_geo_uXoff, s_geo_uXhalf); - geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 1); - wide_set_bd_scale(s_geo_uXscale, s_geo_uXcenter); - temporal_draw(s_fb, s_fb_identity, nverts, 0, GL_TRIANGLES, semi, mask, 0, NULL, 0, 0); - if (mirror_depth()) glDepthMask(semi < 0 ? GL_TRUE : GL_FALSE); - if (s_ws_ablate != 2) glDrawArrays(GL_TRIANGLES, 0, nverts); - wide_clear_bd_scale(s_geo_uXscale, s_geo_uXcenter); - geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); - wide_target_end(s_geo_uXoff, s_geo_uXhalf); - gl_perf_mirror_end(); - } - hr_end(); -} - -/* Flat / gouraud triangles and lines share the GEO program. mode: GL_TRIANGLES - * or GL_LINES; vertices carry canonical x/y, color, visual flags, and separate - * presentation x/y. Bit 0 of the flags gates the fractional-yaw transform; - * bit 1 identifies exact provenance for the optional coverage tint. */ -static void gpu_geometry(GLenum mode, const int *xs, const int *ys, - const uint16_t *cs, int n, int semi) { - float px[3] = {0}, py[3] = {0}, pz[3] = {0}; - uint64_t pid[3] = {0}; - int visual_flags = 0; - if (mode == GL_TRIANGLES && n == 3) - visual_flags = take_visual_triangle(xs, ys, px, py, NULL, pz, pid) & 3; - else { - s_next_world = 0; - s_next_precise = 0; - s_next_unpinned = 0; - s_next_perspective = 0; - s_next_temporal_depth = 0; - } - flush_tex_batch(); /* flat prim: drain textured draws first (order + program) */ - flush_cpu_upload(); /* also drains flat batch if an upload was pending */ - mark_prim_dirty(xs, ys, n, 0 /* flat */); - - /* Lines stay immediate (rare); tris batch for MotK 0x68 starfields. */ - if (mode != GL_TRIANGLES || n < 3) { - flush_flat_batch(); - float verts[3 * GEOV]; - float mask_a = s_mask_set ? 1.0f : 0.0f; - for (int i = 0; i < n; i++) { - verts[i*GEOV+0] = (float)xs[i]; - verts[i*GEOV+1] = (float)ys[i]; - verts[i*GEOV+2] = ((cs[i] & 0x1F) << 3) / 255.0f; - verts[i*GEOV+3] = (((cs[i] >> 5) & 0x1F) << 3) / 255.0f; - verts[i*GEOV+4] = (((cs[i] >> 10) & 0x1F) << 3) / 255.0f; - verts[i*GEOV+5] = mask_a; - verts[i*GEOV+6] = 0.0f; - verts[i*GEOV+7] = (float)xs[i]; - verts[i*GEOV+8] = (float)ys[i]; - verts[i*GEOV+9] = 0.0f; - } - hr_begin(1); - if (semi >= 0) apply_psx_blend(semi); else glDisable(GL_BLEND); - mask_stencil(s_mask_set); - if (mode == GL_LINES) glLineWidth((float)s_scale); - p_glUseProgram(s_geo_prog); - geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); - p_glBindVertexArray(s_geo_vao); - p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_geo_vbo); - p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)(n * GEOV * sizeof(float)), - verts, PSXGL_STREAM_DRAW); - glDrawArrays(mode, 0, n); - if (g_wide_cur && !s_wide_suppress && s_ws_ablate != 1 && - !(!g_ws_bd_stretch_on && mirror_geo_center_only(xs, n))) { - int dx = wide_dx(); - s_bd_gate = bd_prim_gate(xs, n, 0); - gl_perf_mirror_begin(); - wide_target_begin(dx, s_geo_uXoff, s_geo_uXhalf); - geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 1); - wide_set_bd_scale(s_geo_uXscale, s_geo_uXcenter); - temporal_draw(verts, NULL, n, 0, mode, semi, s_mask_set, 0, NULL, s_bd_gate, 0); - if (s_ssao) glDepthMask(semi < 0 ? GL_TRUE : GL_FALSE); - if (s_ws_ablate != 2) glDrawArrays(mode, 0, n); - wide_clear_bd_scale(s_geo_uXscale, s_geo_uXcenter); - geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); - wide_target_end(s_geo_uXoff, s_geo_uXhalf); - gl_perf_mirror_end(); - } - hr_end(); - return; - } - - if (s_fb_n > 0 && (s_fb_semi != semi || s_fb_mask != (int)s_mask_set)) - flush_flat_batch(); - if (s_fb_n + n > FLATBATCH_MAXV) - flush_flat_batch(); - s_fb_semi = semi; - s_fb_mask = (int)s_mask_set; - - float mask_a = s_mask_set ? 1.0f : 0.0f; - for (int i = 0; i < n; i++) { - float *v = &s_fb[s_fb_n * GEOV]; - v[0] = (float)xs[i]; - v[1] = (float)ys[i]; - v[2] = ((cs[i] & 0x1F) << 3) / 255.0f; - v[3] = (((cs[i] >> 5) & 0x1F) << 3) / 255.0f; - v[4] = (((cs[i] >> 10) & 0x1F) << 3) / 255.0f; - v[5] = mask_a; - v[6] = (float)visual_flags; - v[7] = visual_flags ? px[i] : (float)xs[i]; - v[8] = visual_flags ? py[i] : (float)ys[i]; - v[9] = pz[i]; /* temporal identity only; no shader attribute */ - s_fb_identity[s_fb_n] = pid[i]; - s_fb_n++; - } -} - -static void gpu_triangle(int x0,int y0,uint16_t c0, int x1,int y1,uint16_t c1, - int x2,int y2,uint16_t c2, int semi) { - int xs[3] = {x0, x1, x2}, ys[3] = {y0, y1, y2}; - uint16_t cs[3] = {c0, c1, c2}; - gpu_geometry(GL_TRIANGLES, xs, ys, cs, 3, semi); -} - -static void gpu_line(int x0,int y0,uint16_t c0,int x1,int y1,uint16_t c1,int semi) { - int xs[2] = {x0, x1}, ys[2] = {y0, y1}; - uint16_t cs[2] = {c0, c1}; - gpu_geometry(GL_LINES, xs, ys, cs, 2, semi); -} - -/* Shared PS1 uv-sampling model (limits + mirrored-2D compensation) — one - * implementation for GL/VK/SW, see gpu_uv.h. */ -#include "gpu_uv.h" - -/* Textured triangle. Always two passes split by the per-texel STP bit so the - * stencil (mask) write value is constant within each pass; the semi pass is - * also where PS1 blending applies. lim = uv sampling bounds (see - * tri_uv_limits); NULL computes them from the vertices. */ -static void gpu_textured_triangle(const int *xs, const int *ys, - const int *us, const int *vs, - const float *col, uint16_t texpage, - uint16_t clut_x, uint16_t clut_y, int rawtex, - int semi, const int *lim) { - float px[3], py[3], pq[3], pz[3]; - uint64_t pid[3]; - const int visual_flags = take_visual_triangle(xs, ys, px, py, pq, pz, pid); - turned_perspective(px, pq, visual_flags); - int lim_buf[4]; - int uv_buf[6]; - if (!lim) { - /* Poly path: exact sampled bounds from the ORIGINAL uvs, then the - * center-sampling mirror compensation (rect prims arrive with their - * own precomputed lim and pre-bumped uvs). */ - int *mu = uv_buf, *mv = uv_buf + 3; - for (int i = 0; i < 3; i++) { mu[i] = us[i]; mv[i] = vs[i]; } - psx_uv_tri_limits(xs, ys, mu, mv, lim_buf); - psx_uv_tri_mirror_offset(xs, ys, mu, mv); - us = mu; vs = mv; - lim = lim_buf; - } - s_scene_prims_tex++; - int base_x = (texpage & 0xF) * 64; - int base_y = ((texpage >> 4) & 1) * 256; - int depth = (texpage >> 7) & 3; if (depth > 2) depth = 2; - - flush_cpu_upload(); /* if a CPU->VRAM upload is pending it flushes the batch first */ - flush_pack_if_sampling(base_x, base_y, depth, clut_x, clut_y); /* flushes batch iff it must pack */ - mark_prim_dirty(xs, ys, 3, 1 /* textured */); - - /* Append to the textured batch. Flush first if this prim's blend/mask/twin/ - * filter differ from the open batch, or the buffer is full. Per-prim texture - * state goes in the vertex; only these keys force a new draw. */ - { - flush_flat_batch(); /* painter order: flat GEO before textured */ - int twx = s_tw_mask_x, twy = s_tw_mask_y, tox = s_tw_off_x, toy = s_tw_off_y; - int gate = bd_prim_gate(xs, 3, 1); /* backdrop-stretch gate is also a batch key */ - const int world = psx_world_depth_class(s_world_depth, visual_flags, pz, s_world_margin); - if (s_tb_n > 0 && world != s_tb_world) flush_tex_batch(); - /* With mask checking off, opaque and mode-0 semi primitives use the - * same dual-source blend state. The per-vertex a_semi flag selects - * replace vs half-blend without breaking painter order. */ - int batch_semi = (!s_mask_check && semi != 2) ? 4 : semi; - /* STP draw-ORDER correctness. flush_tex_batch draws a batch in two passes - * over the WHOLE batch (pass 1 = every prim's STP=0/opaque texels, pass 2 - * = every prim's STP=1/semi texels with the PSX blend). For overlapping - * prims that share a batch, a BEHIND prim's semi texels (pass 2) then - * paint OVER a FRONT prim's opaque texels (pass 1) — a painter's-order - * violation that only exists on GL (Tomba: the character drew behind the - * AP-block letters / a save post on GL, correct on software). So a - * semi-transparent prim must NOT coalesce with its neighbours: drain the - * open batch, draw this prim alone (its own STP=0+STP=1 passes, which do - * not self-overlap → composited fully before the next prim, exactly like - * the software renderer), and let opaque prims keep batching. Opaque - * content (terrain/foliage — the batching perf win) is untouched; the cost - * is one draw per semi prim, which are sparse. (A future single-pass - * optimization for modes 0/1/3 via GL_ONE,GL_SRC_ALPHA with a per-fragment - * destination factor in alpha could re-batch semi prims — see memory - * tomba_sprite_zorder_bug; mode 2 subtractive still needs isolation.) */ - int isolate = (semi >= 0 && (semi == 2 || s_mask_check)); - int reason = -1; - if (s_tb_n > 0) { - if (isolate) reason = 0; - else if (batch_semi != s_tb_semi) reason = 1; - else if (s_mask_set != s_tb_mask) reason = 2; - else if (s_tex_filter != s_tb_filter) reason = 3; - else if (gate != s_tb_gate) reason = 4; - else if (twx != s_tb_twin[0] || twy != s_tb_twin[1] || - tox != s_tb_twin[2] || toy != s_tb_twin[3]) reason = 5; - } - if (reason >= 0) { - s_batch_reason[reason]++; - flush_tex_batch(); - } - if (s_tb_n + 3 > TEXBATCH_MAXV) { s_batch_reason[6]++; flush_tex_batch(); } - if (s_tb_n == 0) { /* opening a batch: capture its keyed state */ - s_tb_semi = batch_semi; s_tb_mask = s_mask_set; s_tb_filter = s_tex_filter; s_tb_gate = gate; - s_tb_fillers = 0; - s_tb_world = world; - s_tb_twin[0] = twx; s_tb_twin[1] = twy; s_tb_twin[2] = tox; s_tb_twin[3] = toy; - } - float *vp = &s_tb[s_tb_n * TEXV]; - for (int i = 0; i < 3; i++, vp += TEXV) { - vp[0] = (float)xs[i]; - vp[1] = (float)ys[i]; - vp[2] = (float)us[i]; vp[3] = (float)vs[i]; - vp[4] = col[i*3+0]; vp[5] = col[i*3+1]; vp[6] = col[i*3+2]; vp[7] = 1.0f; - vp[8] = (float)base_x; vp[9] = (float)base_y; /* a_tpage */ - vp[10] = (float)clut_x; vp[11] = (float)clut_y; /* a_clut */ - vp[12] = (float)depth; vp[13] = (float)rawtex; /* a_depth, a_raw */ - vp[14] = (float)lim[0]; vp[15] = (float)lim[1]; /* a_limits */ - vp[16] = (float)lim[2]; vp[17] = (float)lim[3]; - vp[18] = semi >= 0 ? (float)(semi + 1) : 0.0f; /* a_semi code */ - vp[19] = (float)visual_flags; /* a_visual_flags */ - vp[20] = visual_flags ? px[i] : (float)xs[i]; /* a_visual_pos */ - vp[21] = visual_flags ? py[i] : (float)ys[i]; - vp[22] = pq[i]; /* a_q (0 = affine) */ - vp[23] = pz[i]; /* temporal identity only; no shader attribute */ - s_tb_identity[s_tb_n + i] = pid[i]; - } - if (visual_flags & 4) s_tb_fillers++; - s_tb_n += 3; - if (isolate) flush_tex_batch(); /* draw this semi prim alone, in submission order */ - } -} - -/* Draw a flat-colored rect (GEO program) DIRECTLY into the active wide surface - * at wide-space coords [wx, wx+ww) × [y, y+h). Used only by the full-screen- - * overlay path; positions are already in wide space so u_xoff stays 0. Mirrors - * raster_flat_rect(&wt, ...) in sw_draw_flat_rect. Caller stays inside - * hr_begin/hr_end. */ -static void wide_flat_rect_direct(int wx, int y, int ww, int h, uint16_t c, int semi) { - if (ww <= 0 || h <= 0) return; - float r = ((c & 0x1F) << 3) / 255.0f; - float g = (((c >> 5) & 0x1F) << 3) / 255.0f; - float b = (((c >> 10) & 0x1F) << 3) / 255.0f; - float a = s_mask_set ? 1.0f : 0.0f; - float fx0 = (float)wx, fy0 = (float)y, fx1 = (float)(wx + ww), fy1 = (float)(y + h); - float verts[6 * GEOV] = { - fx0,fy0,r,g,b,a,0,fx0,fy0,0, fx1,fy0,r,g,b,a,0,fx1,fy0,0, - fx0,fy1,r,g,b,a,0,fx0,fy1,0, fx1,fy0,r,g,b,a,0,fx1,fy0,0, - fx0,fy1,r,g,b,a,0,fx0,fy1,0, fx1,fy1,r,g,b,a,0,fx1,fy1,0, - }; - /* Wide target: positions already wide-space so u_xoff = 0; full-width - * scissor; u_xhalf = g_wide_w/2. */ - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, g_wide_cur); - wide_depth_begin(); - glViewport(0, 0, g_wide_w * s_scale, VRAM_H * s_scale); - glEnable(GL_SCISSOR_TEST); - glScissor(0, 0, g_wide_w * s_scale, VRAM_H * s_scale); /* full surface (rt_wide) */ - if (semi >= 0) apply_psx_blend(semi); else glDisable(GL_BLEND); - mask_stencil(s_mask_set); - p_glUseProgram(s_geo_prog); - geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); - p_glUniform1f(s_geo_uXoff, 0.0f); - p_glUniform1f(s_geo_uXhalf, (float)g_wide_w / 2.0f); - p_glBindVertexArray(s_geo_vao); - p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_geo_vbo); - p_glBufferData(PSXGL_ARRAY_BUFFER, sizeof verts, verts, PSXGL_STREAM_DRAW); - temporal_draw(verts, NULL, 6, 0, GL_TRIANGLES, semi, s_mask_set, 0, NULL, 0, 1); - if (mirror_depth()) { glEnable(GL_DEPTH_TEST); glDepthFunc(GL_ALWAYS); glDepthMask(semi < 0 ? GL_TRUE : GL_FALSE); } - glDrawArrays(GL_TRIANGLES, 0, 6); - if (mirror_depth()) { glDepthMask(GL_TRUE); glDisable(GL_DEPTH_TEST); } - p_glUniform1f(s_geo_uXoff, 0.0f); - p_glUniform1f(s_geo_uXhalf, 512.0f); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hr_fbo); -} - -static void gpu_flat_rect(int x,int y,int w,int h,uint16_t c,int semi) { - /* Rectangles are 2D UI/sprite primitives. Never let one-shot metadata - * prepared for a rejected polygon leak into the first triangle here. */ - s_next_world = 0; - s_next_precise = 0; - s_next_unpinned = 0; - s_next_perspective = 0; - s_next_temporal_depth = 0; - if (w <= 0 || h <= 0) return; - /* Full-screen 2D overlay (pause gray-filter / load fade): a flat rect - * spanning the whole 4:3 framebuffer must cover the whole wide surface too, - * else the revealed 16:9 margins are left undimmed/unfaded. Same detection - * as sw_draw_flat_rect: native_w = the 4:3 framebuffer width (g_wide_w less - * the per-side reveal on both sides); the rect's native screen-X span - * (x - base) must cover [0, native_w). When it does, the two canonical - * triangles are drawn WITHOUT the per-triangle wide mirror (suppressed) and - * a single full-width rect is drawn into the wide surface instead; every - * other rect mirrors 1:1 via the generic gpu_geometry path. Only runs in - * native-wide (g_wide_cur != 0), so 4:3 is unaffected. */ - int overlay = 0; - if (g_wide_cur) { - int native_w = g_wide_w - 2 * g_wide_off; - int lx = x - g_wide_cur_base, rx = x + w - g_wide_cur_base; - overlay = (native_w > 0 && lx <= 0 && rx >= native_w); - } - if (overlay) s_wide_suppress = 1; - gpu_triangle(x, y, c, x+w, y, c, x, y+h, c, semi); - gpu_triangle(x+w, y, c, x, y+h, c, x+w, y+h, c, semi); - if (overlay) { - flush_flat_batch(); - s_wide_suppress = 0; - /* The two canonical triangles drew into the hr FBO already (each - * gpu_triangle ran its own hr_begin/hr_end). Re-open the bracket just - * for the full-width wide pass so blend/scissor/program state is - * clean. */ - if (s_ws_ablate != 1) { - hr_begin(0); - gl_perf_mirror_begin(); - wide_flat_rect_direct(0, y, g_wide_w, h, c, semi); - gl_perf_mirror_end(); - hr_end(); - } - } -} - -static void gpu_textured_rect(int x,int y,int w,int h, - int u0,int v0,int u1,int v1, - uint16_t clut_x,uint16_t clut_y,uint16_t tp,int semi) { - /* As above, a textured rectangle is never a GTE-projected world - * triangle, even though it is decomposed into two triangles below. */ - s_next_world = 0; - s_next_precise = 0; - s_next_unpinned = 0; - s_next_perspective = 0; - s_next_temporal_depth = 0; - s_sprite_depth = s_next_sprite_depth; - s_next_sprite_depth = 0.0f; - memcpy(s_sprite_sides, s_next_sprite_sides, sizeof(s_sprite_sides)); - memset(s_next_sprite_sides, 0, sizeof(s_next_sprite_sides)); - s_sprite_x = x; s_sprite_y = y; - if (w <= 0 || h <= 0) { s_sprite_depth = 0.0f; return; } - float mr=s_mod_r/255.0f, mg=s_mod_g/255.0f, mb=s_mod_b/255.0f; - float col[9]={mr,mg,mb, mr,mg,mb, mr,mg,mb}; - /* gpu.c routes axis-aligned MIRRORED quads (X/Y-flipped 2D sprites, - * e.g. right-facing MMX entities) through THIS path as scaled rects - * with u0>u1 / v0>v1 — they never reach the poly path. Exact bounds - * from the original corners, then the mirror bump (see gpu_uv.h). */ - int lim[4]; - psx_uv_rect_limits(u0, v0, u1, v1, lim); - psx_uv_rect_mirror_offset(&u0, &v0, &u1, &v1); - int xs1[3]={x, x+w, x}, ys1[3]={y, y, y+h}; - int us1[3]={u0,u1,u0}, vs1[3]={v0,v0,v1}; - gpu_textured_triangle(xs1,ys1,us1,vs1,col,tp,clut_x,clut_y,s_mod_raw,semi,lim); - int xs2[3]={x+w, x, x+w}, ys2[3]={y, y+h, y+h}; - int us2[3]={u1,u0,u1}, vs2[3]={v0,v1,v1}; - gpu_textured_triangle(xs2,ys2,us2,vs2,col,tp,clut_x,clut_y,s_mod_raw,semi,lim); - s_sprite_depth = 0.0f; -} - -/* GP0(02h) fill: writes color with bit15=0, ignoring draw area, mask and - * offset; coordinates wrap. A scissored clear (color + stencil) per wrapped - * segment is exactly this. */ -static void fill_segment(int x, int y, int w, int h, float r, float g, float b) { - if (w <= 0 || h <= 0) return; - glScissor(x * s_scale, y * s_scale, w * s_scale, h * s_scale); - glClearColor(r, g, b, 0.0f); - glClearStencil(0); - glStencilMask(0xFF); - glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); - rect_add(&s_pack_dirty, x, y, x + w - 1, y + h - 1); -} - -static void gpu_fill(int x,int y,int w,int h,uint16_t c) { - if (w <= 0 || h <= 0) return; - flush_flat_batch(); - flush_tex_batch(); - flush_cpu_upload(); - float r=(c&0x1F)/31.0f, g=((c>>5)&0x1F)/31.0f, b=((c>>10)&0x1F)/31.0f; - x &= VRAM_W - 1; y &= VRAM_H - 1; - if (w > VRAM_W) w = VRAM_W; - if (h > VRAM_H) h = VRAM_H; - int w1 = w, w2 = 0, h1 = h, h2 = 0; - if (x + w > VRAM_W) { w1 = VRAM_W - x; w2 = w - w1; } - if (y + h > VRAM_H) { h1 = VRAM_H - y; h2 = h - h1; } - - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hr_fbo); - glViewport(0, 0, VRAM_W * s_scale, VRAM_H * s_scale); - glEnable(GL_SCISSOR_TEST); - fill_segment(x, y, w1, h1, r, g, b); - if (w2) fill_segment(0, y, w2, h1, r, g, b); - if (h2) fill_segment(x, 0, w1, h2, r, g, b); - if (w2 && h2) fill_segment(0, 0, w2, h2, r, g, b); - glDisable(GL_SCISSOR_TEST); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - s_gpu_dirty = 1; - coh_record(GL_COH_FILL, x, y, x + w - 1, y + h - 1); -} - -/* VRAM->VRAM copy: blit the source region to the scratch texture (resolves - * overlap), then draw it back at the destination through the BLIT program so - * mask set/check and the stencil mirror apply, exactly like sw_copy_rect. */ -static void gpu_copy_rect(int sx,int sy,int dx,int dy,int w,int h) { - if (w <= 0 || h <= 0) return; - flush_flat_batch(); - flush_tex_batch(); - flush_cpu_upload(); - /* Clamp to bounds (the software path wraps; wrapping copies are unused - * in practice — see the file header). */ - if (sx < 0) sx = 0; if (sy < 0) sy = 0; - if (dx < 0) dx = 0; if (dy < 0) dy = 0; - if (sx + w > VRAM_W) w = VRAM_W - sx; - if (dx + w > VRAM_W) w = VRAM_W - dx; - if (sy + h > VRAM_H) h = VRAM_H - sy; - if (dy + h > VRAM_H) h = VRAM_H - dy; - if (w <= 0 || h <= 0) return; - - int S = s_scale; - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, s_scratch_fbo); - glDisable(GL_SCISSOR_TEST); - p_glBlitFramebuffer(sx*S, sy*S, (sx+w)*S, (sy+h)*S, - sx*S, sy*S, (sx+w)*S, (sy+h)*S, - GL_COLOR_BUFFER_BIT, GL_NEAREST); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); - - hr_begin(0); /* copies ignore the draw area */ - glScissor(dx*S, dy*S, w*S, h*S); - glDisable(GL_BLEND); - p_glUseProgram(s_blit_prog); - p_glActiveTexture(PSXGL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, s_scratch_tex); - p_glUniform1i(s_uBlitSrc, 0); - p_glUniform1i(s_uBlitMaskset, s_mask_set); - /* Scratch holds the source at its own hr coords: texel = frag + (src-dst)*S. */ - p_glUniform1i(s_uBlitSrcDiv, 1); - p_glUniform2i(s_uBlitSrcOff, (sx - dx) * S, (sy - dy) * S); - float fx0 = (float)dx, fy0 = (float)dy, fx1 = (float)(dx + w), fy1 = (float)(dy + h); - float verts[6 * 2] = { - fx0, fy0, fx1, fy0, fx0, fy1, - fx1, fy0, fx0, fy1, fx1, fy1, - }; - p_glBindVertexArray(s_blit_vao); - p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_blit_vbo); - p_glBufferData(PSXGL_ARRAY_BUFFER, sizeof verts, verts, PSXGL_STREAM_DRAW); - /* Two passes split by source bit15 so stencil tracks the copied mask. */ - mask_stencil(s_mask_set); p_glUniform1i(s_uBlitPass, 1); glDrawArrays(GL_TRIANGLES, 0, 6); - mask_stencil(1); p_glUniform1i(s_uBlitPass, 2); glDrawArrays(GL_TRIANGLES, 0, 6); - hr_end(); - - rect_add(&s_pack_dirty, dx, dy, dx + w - 1, dy + h - 1); - s_gpu_dirty = 1; - coh_record(GL_COH_COPY_SRC, sx, sy, sx + w - 1, sy + h - 1); - coh_record(GL_COH_COPY, dx, dy, dx + w - 1, dy + h - 1); -} - -/* ---- backend vtable wrappers ------------------------------------------- */ -static void glb_init(uint16_t *vram) { s_vram = vram; sw_renderer_init(vram); } - -/* Under GL the internal-resolution scale lives in the hr FBO; the CPU-side - * (software mirror, readbacks, screenshots) stays native, so the reported - * scale is 1 and the software hi-res mirror stays off. */ -static void glb_set_scale(int s) { - if (s < 1) s = 1; - if (s > GL_MAX_INTERNAL_SCALE) s = GL_MAX_INTERNAL_SCALE; - s_req_scale = s; - if (s_raster_ok && s != s_scale && !resize_gpu_raster(s)) - s_req_scale = s_scale; - sw_renderer_set_scale(1); -} -static int glb_scale(void) { return s_scale; } /* real internal SSAA scale (was a stub 1; the - native-wide CPU present path + gr_scale() callers - need the true scale — the FBO-direct present is - unaffected since it never reads gr_scale()) */ -static void glb_set_texture_filter(int b) { s_tex_filter = b ? 1 : 0; sw_set_texture_filter(b); } -static int glb_texture_filter(void) { return s_tex_filter; } - -static void glb_set_geometry_correction(int enabled) { - if ((enabled ? 1 : 0) != s_geometry_correction) { - interp_reset_history(); - memset(s_wide_depth_shown, 0xFF, sizeof s_wide_depth_shown); - } - if (s_raster_ok) { flush_flat_batch(); flush_tex_batch(); } - s_geometry_correction = enabled ? 1 : 0; - if (!s_geometry_correction) { - s_next_world = 0; - s_next_precise = 0; - s_next_unpinned = 0; - s_next_perspective = 0; - s_next_temporal_depth = 0; - s_yaw_sin = 0.0f; - s_yaw_cos = 1.0f; - } -} - -static void glb_set_world_triangle(int world) { - if (!s_raster_ok) return; - s_next_world = world; /* 2: the game's grown second copy */ - if (!world) s_next_temporal_depth = 0; -} - -static void glb_set_precise_triangle(int enabled, - int32_t x0, int32_t y0, - int32_t x1, int32_t y1, - int32_t x2, int32_t y2) { - if (!s_raster_ok) { - sw_set_precise_triangle(enabled, x0, y0, x1, y1, x2, y2); - return; - } - s_next_precise = enabled ? 1 : 0; - if (!enabled) s_next_temporal_depth = 0; - s_next_x16[0] = x0; s_next_y16[0] = y0; - s_next_x16[1] = x1; s_next_y16[1] = y1; - s_next_x16[2] = x2; s_next_y16[2] = y2; -} - -static void glb_set_unpinned_triangle(int32_t x0, int32_t y0, int32_t x1, int32_t y1, int32_t x2, int32_t y2) { - if (!s_raster_ok) return; - s_next_unpinned = 1; - s_next_unpinned_x16[0] = x0; s_next_unpinned_y16[0] = y0; - s_next_unpinned_x16[1] = x1; s_next_unpinned_y16[1] = y1; - s_next_unpinned_x16[2] = x2; s_next_unpinned_y16[2] = y2; -} - -static void glb_set_perspective_triangle(int enabled, - float q0, float q1, float q2) { - /* Pre-context fallback draws canonical software VRAM, where perspective - * correction is deliberately forbidden. Drop rather than forward it. */ - s_next_perspective = 0; - if (!s_raster_ok || !enabled || - !isfinite(q0) || !isfinite(q1) || !isfinite(q2) || - q0 <= 0.0f || q1 <= 0.0f || q2 <= 0.0f) - return; - s_next_q[0] = q0; - s_next_q[1] = q1; - s_next_q[2] = q2; - s_next_perspective = 1; -} - -#ifndef PSX_DISABLE_FRAME_INTERPOLATION -static void glb_set_temporal_depth_triangle(int enabled, - float z0, float z1, float z2) { - s_next_temporal_depth = 0; - if (!s_raster_ok || !enabled || - !isfinite(z0) || !isfinite(z1) || !isfinite(z2)) - return; - /* enabled == 2: a polygon some of whose corners have no depth, which stay 0. */ - if (enabled == 2 ? z0 < 0.0f || z1 < 0.0f || z2 < 0.0f || z0 + z1 + z2 <= 0.0f - : z0 <= 0.0f || z1 <= 0.0f || z2 <= 0.0f) - return; - s_next_z[0] = z0; s_next_z[1] = z1; s_next_z[2] = z2; - s_next_temporal_depth = enabled == 2 ? 2 : 1; -} -static void glb_set_surface_depth_triangle(float z0, float z1, float z2) { - const int usable = s_raster_ok && isfinite(z0) && isfinite(z1) && isfinite(z2) && z0 >= 0.0f && z1 >= 0.0f && z2 >= 0.0f; - s_next_surface_z[0] = usable ? z0 : 0.0f; - s_next_surface_z[1] = usable ? z1 : 0.0f; - s_next_surface_z[2] = usable ? z2 : 0.0f; -} -static void glb_set_temporal_identity_triangle(uint64_t id0, uint64_t id1, uint64_t id2) { - s_next_identity[0] = id0; s_next_identity[1] = id1; s_next_identity[2] = id2; -} -static void glb_set_temporal_sprite(float depth, const float sides[4]) { - s_next_sprite_depth = s_raster_ok && isfinite(depth) && depth > 0.0f ? depth : 0.0f; - int placed = s_next_sprite_depth > 0.0f && sides != NULL; - for (int side = 0; placed && side < 4; ++side) placed = isfinite(sides[side]) && fabsf(sides[side]) < 64.0f; -#ifndef PSX_NO_DEBUG_TOOLS - /* PSX_GL_SPRITE_WHOLE_PIXELS=1 puts sprites back on whole pixels, to measure against. */ - static int whole = -1; - if (whole < 0) { const char *asked = getenv("PSX_GL_SPRITE_WHOLE_PIXELS"); whole = asked && asked[0] == '1'; } - if (whole) placed = 0; -#endif - for (int side = 0; side < 4; ++side) s_next_sprite_sides[side] = placed ? sides[side] : 0.0f; -} -#endif - -static void glb_set_presentation_yaw(double yaw_units, double full_turn, - double center_x, double center_y, - double focal_x) { - /* A frame may still have batched polygons when the vblank mouse hook - * advances the residual. Land those with the OLD residual first so the - * presentation transform stays paired with the guest yaw that built them. */ - if (s_raster_ok) { flush_flat_batch(); flush_tex_batch(); } - if (!isfinite(yaw_units)) yaw_units = 0.0; - if (yaw_units > 4.0) yaw_units = 4.0; - if (yaw_units < -4.0) yaw_units = -4.0; - if (!isfinite(full_turn) || full_turn <= 0.0) { - s_yaw_sin = 0.0f; - s_yaw_cos = 1.0f; - } else { - const double radians = - yaw_units * (6.28318530717958647692 / full_turn); - s_yaw_sin = (float)sin(radians); - s_yaw_cos = (float)cos(radians); - } - s_yaw_center_x = isfinite(center_x) ? (float)center_x : 160.0f; - s_yaw_center_y_relative = isfinite(center_y) - ? (float)center_y : 120.0f; - s_yaw_focal_x = isfinite(focal_x) && focal_x > 1.0 - ? (float)focal_x : 160.0f; -} - -static void glb_set_semi_transparency(int e, int m) { s_semi_en = e; s_semi_mode = m & 3; sw_set_semi_transparency(e, m); } -static void glb_set_mask_bits(int s, int c) { - int next_check = c ? 1 : 0; - if (next_check && !s_mask_check) { - /* Land all alpha-authoritative work before deriving stencil from it. */ - flush_tex_batch(); - flush_cpu_upload(); - rebuild_mask_stencils(); - } - s_mask_set = s ? 1 : 0; - s_mask_check = next_check; - sw_set_mask_bits(s, c); -} -static void glb_set_texture_window(uint32_t r) { - s_tw_mask_x = (int)(r & 0x1F); - s_tw_mask_y = (int)((r >> 5) & 0x1F); - s_tw_off_x = (int)((r >> 10) & 0x1F); - s_tw_off_y = (int)((r >> 15) & 0x1F); - sw_set_texture_window(r); -} -static void glb_set_color_modulation(int r,int g,int b,int raw) { s_mod_r=r; s_mod_g=g; s_mod_b=b; s_mod_raw=raw; sw_set_color_modulation(r,g,b,raw); } -static void glb_set_draw_area(int x1,int y1,int x2,int y2) { flush_flat_batch(); flush_tex_batch(); s_area_x1=x1; s_area_y1=y1; s_area_x2=x2; s_area_y2=y2; sw_set_draw_area(x1,y1,x2,y2); } -static void glb_get_draw_area(int *x1,int *y1,int *x2,int *y2) { sw_get_draw_area(x1,y1,x2,y2); } -static void glb_set_draw_offset(int x,int y) { flush_flat_batch(); flush_tex_batch(); s_off_x=x; s_off_y=y; sw_set_draw_offset(x,y); } - -/* Pre-context draws (s_raster_ok == 0) fall back to the software rasterizer - * over CPU VRAM; the initial full-VRAM upload at context init folds them in. - * Post-init, the GPU pipeline is all-or-nothing — no per-prim fallback. */ -static void glb_draw_flat_triangle(int x0,int y0,int x1,int y1,int x2,int y2,uint16_t col) { - if (!s_raster_ok) { sw_draw_flat_triangle(x0,y0,x1,y1,x2,y2,col); return; } - gpu_triangle(x0,y0,col, x1,y1,col, x2,y2,col, s_semi_en?s_semi_mode:-1); -} -static void glb_draw_gouraud_triangle(int x0,int y0,uint16_t c0,int x1,int y1,uint16_t c1,int x2,int y2,uint16_t c2) { - if (!s_raster_ok) { sw_draw_gouraud_triangle(x0,y0,c0,x1,y1,c1,x2,y2,c2); return; } - gpu_triangle(x0,y0,c0, x1,y1,c1, x2,y2,c2, s_semi_en?s_semi_mode:-1); -} -static void glb_fill_rect(int x,int y,int w,int h,uint16_t c){ - if (!s_raster_ok) { sw_fill_rect(x,y,w,h,c); return; } - gpu_fill(x,y,w,h,c); -} -static void glb_copy_rect(int sx,int sy,int dx,int dy,int w,int h){ - if (!s_raster_ok) { sw_copy_rect(sx,sy,dx,dy,w,h); return; } - gpu_copy_rect(sx,sy,dx,dy,w,h); -} -static void glb_draw_textured_triangle(int x0,int y0,int u0,int v0,int x1,int y1,int u1,int v1,int x2,int y2,int u2,int v2,uint16_t cx,uint16_t cy,uint16_t tp){ - if (!s_raster_ok) { sw_draw_textured_triangle(x0,y0,u0,v0,x1,y1,u1,v1,x2,y2,u2,v2,cx,cy,tp); return; } - int xs[3]={x0,x1,x2}, ys[3]={y0,y1,y2}, us[3]={u0,u1,u2}, vs[3]={v0,v1,v2}; - float mr=s_mod_r/255.0f, mg=s_mod_g/255.0f, mb=s_mod_b/255.0f; - float col[9]={mr,mg,mb, mr,mg,mb, mr,mg,mb}; - gpu_textured_triangle(xs,ys,us,vs,col,tp,cx,cy,s_mod_raw, s_semi_en?s_semi_mode:-1, NULL); -} -static void glb_draw_shaded_textured_triangle(int x0,int y0,int u0,int v0,uint32_t c0,int x1,int y1,int u1,int v1,uint32_t c1,int x2,int y2,int u2,int v2,uint32_t c2,uint16_t cx,uint16_t cy,uint16_t tp,int raw){ - if (!s_raster_ok) { sw_draw_shaded_textured_triangle(x0,y0,u0,v0,c0,x1,y1,u1,v1,c1,x2,y2,u2,v2,c2,cx,cy,tp,raw); return; } - int xs[3]={x0,x1,x2}, ys[3]={y0,y1,y2}, us[3]={u0,u1,u2}, vs[3]={v0,v1,v2}; - uint32_t cc[3]={c0,c1,c2}; float col[9]; - for (int i=0;i<3;i++){ col[i*3+0]=(cc[i]&0xFF)/255.0f; col[i*3+1]=((cc[i]>>8)&0xFF)/255.0f; col[i*3+2]=((cc[i]>>16)&0xFF)/255.0f; } - gpu_textured_triangle(xs,ys,us,vs,col,tp,cx,cy,raw, s_semi_en?s_semi_mode:-1, NULL); -} -static void glb_draw_flat_rect(int x,int y,int w,int h,uint16_t c){ - if (!s_raster_ok) { sw_draw_flat_rect(x,y,w,h,c); return; } - gpu_flat_rect(x,y,w,h,c, s_semi_en?s_semi_mode:-1); -} -static void glb_draw_textured_rect(int x,int y,int w,int h,int u,int v,uint16_t cx,uint16_t cy,uint16_t tp){ - if (!s_raster_ok) { sw_draw_textured_rect(x,y,w,h,u,v,cx,cy,tp); return; } - gpu_textured_rect(x,y,w,h, u,v, u+w,v+h, cx,cy,tp, s_semi_en?s_semi_mode:-1); -} -static void glb_draw_textured_rect_scaled(int x,int y,int w,int h,int u0,int v0,int u1,int v1,uint16_t cx,uint16_t cy,uint16_t tp){ - if (!s_raster_ok) { sw_draw_textured_rect_scaled(x,y,w,h,u0,v0,u1,v1,cx,cy,tp); return; } - gpu_textured_rect(x,y,w,h, u0,v0, u1,v1, cx,cy,tp, s_semi_en?s_semi_mode:-1); -} -static void glb_draw_line(int x0,int y0,int x1,int y1,uint16_t c){ - if (!s_raster_ok) { sw_draw_line(x0,y0,x1,y1,c); return; } - gpu_line(x0,y0,c, x1,y1,c, s_semi_en?s_semi_mode:-1); -} -static void glb_draw_shaded_line(int x0,int y0,uint16_t c0,int x1,int y1,uint16_t c1){ - if (!s_raster_ok) { sw_draw_shaded_line(x0,y0,c0,x1,y1,c1); return; } - gpu_line(x0,y0,c0, x1,y1,c1, s_semi_en?s_semi_mode:-1); -} -static int glb_render_display(uint32_t *o,int p,int dx,int dy,int dw,int dh){ ensure_cpu(); return sw_render_display(o,p,dx,dy,dw,dh); } -static int glb_render_display_hires(uint32_t *o,int p,int dx,int dy,int dw,int dh){ ensure_cpu(); return sw_render_display_hires(o,p,dx,dy,dw,dh); } -/* While GP1 depth24 is on, packed RGB888 lives in the CPU mirror and is - * presented via gl_renderer_present — never as 1555 FBO texels. Queuing those - * MDEC A0 rects hits UP_RECTS_MAX (16) and force-flushes mid-movie (MotK intro - * ~50→~30 FPS). Skip ONLY framebuffer-sized transfers (RGB888); still upload - * smaller texture A0s so post-FMV menus keep VRAM pages coherent. - * On leave: clear the skipped FB union in the FBO — do NOT restage CPU RGB888 - * as 1555 (that painted MotK title rainbow/static). */ -static int s_depth24_skip_up = 0; -static DirtyRect s_d24_skip_fb; /* union of skipped MDEC FB rects (VRAM halfwords) */ - -static int depth24_is_fb_transfer(int w, int h) { - if (!gpu_display_is_depth24() || w <= 0 || h <= 0) return 0; - GpuDisplayInfo di; - gpu_get_display_info(&di); - int fb_w = (int)((di.width * 3u + 1u) / 2u); /* RGB W → halfwords */ - int fb_h = (int)di.height; - if (fb_w < 8) fb_w = 8; - if (fb_h < 1) fb_h = 1; - /* MotK: 768×128 class blits; allow slack. Reject small texture pages. */ - if (h >= fb_h - 8 && h <= fb_h + 16 && w >= (fb_w * 3) / 4) return 1; - if ((int64_t)w * (int64_t)h >= ((int64_t)fb_w * fb_h) / 2) return 1; - return 0; -} - -static void depth24_clear_skipped_fb(void) { - if (!s_raster_ok || !s_d24_skip_fb.set) return; - flush_flat_batch(); - flush_tex_batch(); - int x0 = s_d24_skip_fb.x0, y0 = s_d24_skip_fb.y0; - int x1 = s_d24_skip_fb.x1, y1 = s_d24_skip_fb.y1; - int rw = x1 - x0 + 1, rh = y1 - y0 + 1; - int S = s_scale; - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hr_fbo); - glDisable(GL_BLEND); - glDisable(GL_STENCIL_TEST); - glEnable(GL_SCISSOR_TEST); - glViewport(0, 0, VRAM_W * S, VRAM_H * S); - glScissor(x0 * S, y0 * S, rw * S, rh * S); - glClearColor(0.f, 0.f, 0.f, 0.f); - glClearStencil(0); - glStencilMask(0xFF); - glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); - if (s_raw_fbo) { - temporal_note_raw_change(); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_raw_fbo); - glViewport(0, 0, VRAM_W, VRAM_H); - glScissor(x0, y0, rw, rh); - glClear(GL_COLOR_BUFFER_BIT); - } - glDisable(GL_SCISSOR_TEST); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - rect_add(&s_pack_dirty, x0, y0, x1, y1); - present_dirty_rect(x0, y0, x1, y1, 1); - rect_clear(&s_d24_skip_fb); -} - -static void depth24_upload_policy(void) { - int d24 = gpu_display_is_depth24(); - if (d24 && !s_depth24_skip_up) { - s_up_nrects = 0; - rect_clear(&s_d24_skip_fb); - } else if (!d24 && s_depth24_skip_up) { - s_up_nrects = 0; - depth24_clear_skipped_fb(); - gpu_depth24_upload_span_reset(); - } - s_depth24_skip_up = d24; -} - -static void glb_vram_write(int x,int y,uint16_t px){ - sw_vram_write(x,y,px); - depth24_upload_policy(); - /* Point pokes are never MDEC frames — always stage to FBO. */ - up_add(x & (VRAM_W-1), y & (VRAM_H-1), x & (VRAM_W-1), y & (VRAM_H-1)); -} -static uint16_t glb_vram_read(int x,int y){ ensure_cpu(); return sw_vram_read(x,y); } -static void glb_vram_transfer_in(int x,int y,int w,int h,const uint16_t *d){ - sw_vram_transfer_in(x,y,w,h,d); - depth24_upload_policy(); - if (s_depth24_skip_up && depth24_is_fb_transfer(w, h)) { - int x0 = x & (VRAM_W - 1), y0 = y & (VRAM_H - 1); - rect_add(&s_d24_skip_fb, x0, y0, x0 + w - 1, y0 + h - 1); - coh_record(GL_COH_UPLOAD, x, y, x+w-1, y+h-1); - return; - } - up_add_transfer(x, y, w, h); /* exact touched rects, incl. per-pixel wrap */ - coh_record(GL_COH_UPLOAD, x, y, x+w-1, y+h-1); -} -static void glb_vram_transfer_out(int x,int y,int w,int h,uint16_t *d){ ensure_cpu(); sw_vram_transfer_out(x,y,w,h,d); } - -/* ---- context init / present -------------------------------------------- */ -static void upload_present_tex(const uint32_t *pixels, int w, int h, int linear) { - glBindTexture(GL_TEXTURE_2D, s_present_tex); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, linear ? GL_LINEAR : GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, linear ? GL_LINEAR : GL_NEAREST); - /* Re-assert clamp every upload: a stale REPEAT wrap samples past the - * right edge into garbage (MotK FMV right-strip flicker). */ - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - if (w != s_present_w || h != s_present_h) { - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_BGRA, GL_UNSIGNED_BYTE, pixels); - s_present_w = w; s_present_h = h; - } else { - glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, w, h, GL_BGRA, GL_UNSIGNED_BYTE, pixels); - } -} - -/* Display aspect for the present letterbox. Default 4:3 (native). When a wide - * aspect is configured the 4:3 frame is stretched into it — paired with the - * GTE X-squash (gte_set_display_aspect) this nets a wider field of view. */ -static int s_aspect_num = 4, s_aspect_den = 3; - -void gl_renderer_set_display_aspect(int num, int den) { - if (num <= 0 || den <= 0) { num = 4; den = 3; } - s_aspect_num = num; s_aspect_den = den; -} - -/* Letterbox: largest num:den rect centered in the drawable. */ -static void letterbox_rect_aspect(int ww, int wh, int num, int den, - int *x, int *y, int *w, int *h) { - int dw = ww, dh = (ww * den) / num; - if (dh > wh) { dh = wh; dw = (wh * num) / den; } - *x = (ww - dw) / 2; - *y = (wh - dh) / 2; - *w = dw; *h = dh; -} -static void letterbox_rect(int ww, int wh, int *x, int *y, int *w, int *h) { - letterbox_rect_aspect(ww, wh, s_aspect_num, s_aspect_den, x, y, w, h); -} - -static GLuint make_tex(GLenum internal, int w, int h, GLenum fmt, GLenum type) { - GLuint t = 0; - glGenTextures(1, &t); - glBindTexture(GL_TEXTURE_2D, t); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glTexImage2D(GL_TEXTURE_2D, 0, (GLint)internal, w, h, 0, fmt, type, NULL); - return t; -} - -static int make_fbo(GLuint *out_fbo, GLuint color_tex, GLuint stencil_rb) { - p_glGenFramebuffers(1, out_fbo); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, *out_fbo); - p_glFramebufferTexture2D(PSXGL_FRAMEBUFFER, PSXGL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, color_tex, 0); - if (stencil_rb) - p_glFramebufferRenderbuffer(PSXGL_FRAMEBUFFER, PSXGL_DEPTH_STENCIL_ATTACHMENT, - PSXGL_RENDERBUFFER, stencil_rb); - GLenum st = p_glCheckFramebufferStatus(PSXGL_FRAMEBUFFER); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - if (st != PSXGL_FRAMEBUFFER_COMPLETE) { - fprintf(stdout, "psxrecomp: GL FBO incomplete (0x%X)\n", st); - return 0; - } - return 1; -} - -static void delete_scale_target(GLuint *fbo, GLuint *tex, GLuint *rb) { - if (*fbo) { p_glDeleteFramebuffers(1, fbo); *fbo = 0; } - if (*tex) { glDeleteTextures(1, tex); *tex = 0; } - if (rb && *rb) { p_glDeleteRenderbuffers(1, rb); *rb = 0; } -} - -/* Rebuild only the scale-dependent GL targets while keeping the current VRAM - * image alive. All replacement targets are validated before any live handle - * changes; a failed allocation therefore leaves the old renderer untouched. - * Colour is nearest-blitted between equivalent logical grids, then stencil is - * reconstructed from the copied bit-15 alpha. Native-wide surfaces are - * migrated too, avoiding a black/stale transition when the game does not - * repaint every framebuffer pixel on the following frame. */ -static int resize_gpu_raster(int scale) { - if (!s_raster_ok || scale == s_scale) return 1; - if (scale < 1 || scale > GL_MAX_INTERNAL_SCALE) return 0; - - flush_flat_batch(); - flush_tex_batch(); - flush_cpu_upload(); - - const int old_scale = s_scale; - const int new_hw = VRAM_W * scale; - const int new_hh = VRAM_H * scale; - GLuint new_hr_tex = 0, new_hr_fbo = 0, new_hr_rb = 0; - GLuint new_scratch_tex = 0, new_scratch_fbo = 0; - GLuint new_wide_tex[WIDE_MAX_SURF] = {0}; - GLuint new_wide_fbo[WIDE_MAX_SURF] = {0}; - GLuint new_wide_rb[WIDE_MAX_SURF] = {0}; - int current_wide_index = -1; - - new_hr_tex = make_tex(GL_RGBA8, new_hw, new_hh, - GL_RGBA, GL_UNSIGNED_BYTE); - new_scratch_tex = make_tex(GL_RGBA8, new_hw, new_hh, - GL_RGBA, GL_UNSIGNED_BYTE); - p_glGenRenderbuffers(1, &new_hr_rb); - p_glBindRenderbuffer(PSXGL_RENDERBUFFER, new_hr_rb); - p_glRenderbufferStorage(PSXGL_RENDERBUFFER, PSXGL_DEPTH24_STENCIL8, - new_hw, new_hh); - p_glBindRenderbuffer(PSXGL_RENDERBUFFER, 0); - if (!new_hr_tex || !new_scratch_tex || !new_hr_rb || - !make_fbo(&new_hr_fbo, new_hr_tex, new_hr_rb) || - !make_fbo(&new_scratch_fbo, new_scratch_tex, 0)) { - goto fail; - } - - for (int i = 0; i < WIDE_MAX_SURF; ++i) { - if (!s_wide_fbo[i]) continue; - if (g_wide_cur == s_wide_fbo[i]) current_wide_index = i; - const int wide_w = g_wide_w * scale; - new_wide_tex[i] = make_tex(GL_RGBA8, wide_w, new_hh, - GL_RGBA, GL_UNSIGNED_BYTE); - p_glGenRenderbuffers(1, &new_wide_rb[i]); - p_glBindRenderbuffer(PSXGL_RENDERBUFFER, new_wide_rb[i]); - p_glRenderbufferStorage(PSXGL_RENDERBUFFER, - PSXGL_DEPTH24_STENCIL8, wide_w, new_hh); - p_glBindRenderbuffer(PSXGL_RENDERBUFFER, 0); - if (!new_wide_tex[i] || !new_wide_rb[i] || - !make_fbo(&new_wide_fbo[i], new_wide_tex[i], - new_wide_rb[i])) { - goto fail; - } - } - - glDisable(GL_SCISSOR_TEST); - glDisable(GL_BLEND); - glDisable(GL_STENCIL_TEST); - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, new_hr_fbo); - p_glBlitFramebuffer(0, 0, VRAM_W * old_scale, VRAM_H * old_scale, - 0, 0, new_hw, new_hh, - GL_COLOR_BUFFER_BIT, GL_NEAREST); - for (int i = 0; i < WIDE_MAX_SURF; ++i) { - if (!new_wide_fbo[i]) continue; - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_wide_fbo[i]); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, new_wide_fbo[i]); - p_glBlitFramebuffer(0, 0, g_wide_w * old_scale, - VRAM_H * old_scale, - 0, 0, g_wide_w * scale, new_hh, - GL_COLOR_BUFFER_BIT, GL_NEAREST); - } - - { - GLuint old_hr_tex = s_hr_tex, old_hr_fbo = s_hr_fbo, - old_hr_rb = s_hr_rb; - GLuint old_scratch_tex = s_scratch_tex, - old_scratch_fbo = s_scratch_fbo; - GLuint old_wide_tex[WIDE_MAX_SURF]; - GLuint old_wide_fbo[WIDE_MAX_SURF]; - GLuint old_wide_rb[WIDE_MAX_SURF]; - for (int i = 0; i < WIDE_MAX_SURF; ++i) { - old_wide_tex[i] = s_wide_tex[i]; - old_wide_fbo[i] = s_wide_fbo[i]; - old_wide_rb[i] = s_wide_rb[i]; - s_wide_tex[i] = new_wide_tex[i]; new_wide_tex[i] = 0; - s_wide_fbo[i] = new_wide_fbo[i]; new_wide_fbo[i] = 0; - s_wide_rb[i] = new_wide_rb[i]; new_wide_rb[i] = 0; - } - s_hr_tex = new_hr_tex; new_hr_tex = 0; - s_hr_fbo = new_hr_fbo; new_hr_fbo = 0; - s_hr_rb = new_hr_rb; new_hr_rb = 0; - s_scratch_tex = new_scratch_tex; new_scratch_tex = 0; - s_scratch_fbo = new_scratch_fbo; new_scratch_fbo = 0; - s_scale = scale; - s_req_scale = scale; - g_wide_cur = current_wide_index >= 0 - ? s_wide_fbo[current_wide_index] : 0; - - const float shift = 0.5f / (float)s_scale - 1.0f / 64.0f; - p_glUseProgram(s_geo_prog); - p_glUniform1f(p_glGetUniformLocation(s_geo_prog, "u_shift"), shift); - p_glUseProgram(s_tex_prog); - p_glUniform1f(p_glGetUniformLocation(s_tex_prog, "u_shift"), shift); - p_glUseProgram(s_blit_prog); - p_glUniform1f(p_glGetUniformLocation(s_blit_prog, "u_shift"), shift); - p_glUseProgram(0); - - rebuild_target_stencil(s_hr_fbo, new_hw, new_hh); - for (int i = 0; i < WIDE_MAX_SURF; ++i) { - if (s_wide_fbo[i]) - rebuild_target_stencil(s_wide_fbo[i], - g_wide_w * s_scale, new_hh); - } - s_stencil_valid = 1; - - delete_scale_target(&old_hr_fbo, &old_hr_tex, &old_hr_rb); - delete_scale_target(&old_scratch_fbo, &old_scratch_tex, NULL); - for (int i = 0; i < WIDE_MAX_SURF; ++i) - delete_scale_target(&old_wide_fbo[i], &old_wide_tex[i], - &old_wide_rb[i]); - } - - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - gl_renderer_invalidate_present(); - fprintf(stdout, "psxrecomp: live internal scale = %dx\n", s_scale); - return 1; - -fail: - delete_scale_target(&new_hr_fbo, &new_hr_tex, &new_hr_rb); - delete_scale_target(&new_scratch_fbo, &new_scratch_tex, NULL); - for (int i = 0; i < WIDE_MAX_SURF; ++i) - delete_scale_target(&new_wide_fbo[i], &new_wide_tex[i], - &new_wide_rb[i]); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - fprintf(stderr, - "psxrecomp: live internal-scale allocation failed; keeping %dx\n", - s_scale); - return 0; -} - -static int init_gpu_raster(void) { - s_scale = s_req_scale; - { - const char *full_yaw = getenv("PSX_GEOMETRY_FULL_YAW"); - const char *coverage_tint = getenv("PSX_GEOMETRY_COVERAGE_TINT"); - s_geometry_full_yaw = full_yaw && full_yaw[0] && full_yaw[0] != '0'; - s_geometry_coverage_tint = coverage_tint && coverage_tint[0] && - coverage_tint[0] != '0'; - } - - { - char *geo_vs = depth_writing_shader(GEO_VS, 4), *tex_vs = depth_writing_shader(TEX_VS, 12); - char *tex_fs = tex_vs ? sprite_marking_shader(TEX_FS) : NULL; - s_geo_prog = build_program(geo_vs ? geo_vs : GEO_VS, GEO_FS); - s_tex_prog = build_program_ex(tex_fs ? tex_vs : TEX_VS, tex_fs ? tex_fs : TEX_FS, 1); - free(geo_vs); - free(tex_vs); - free(tex_fs); - } - s_blit_prog = build_program(BLIT_VS, BLIT_FS); - s_pack_prog = build_program(PACK_VS, PACK_FS); - s_stencil_prog = build_program(PACK_VS, STENCIL_FS); - if (!s_geo_prog || !s_tex_prog || !s_blit_prog || !s_pack_prog || !s_stencil_prog) return 0; - - s_conv = (uint32_t *)malloc((size_t)VRAM_W * VRAM_H * sizeof(uint32_t)); - if (!s_conv) return 0; - - int hw = VRAM_W * s_scale, hh = VRAM_H * s_scale; - s_hr_tex = make_tex(GL_RGBA8, hw, hh, GL_RGBA, GL_UNSIGNED_BYTE); - s_scratch_tex = make_tex(GL_RGBA8, hw, hh, GL_RGBA, GL_UNSIGNED_BYTE); - s_up_tex = make_tex(GL_RGBA8, VRAM_W, VRAM_H, GL_RGBA, GL_UNSIGNED_BYTE); - s_raw_tex = make_tex(PSXGL_R16UI, VRAM_W, VRAM_H, PSXGL_RED_INTEGER, GL_UNSIGNED_SHORT); - /* Force the driver's first texture-upload allocation while the renderer is - * initializing. NVIDIA otherwise defers it until the first MDEC frame, - * producing a measured ~33 ms glTexSubImage hitch and an audible underrun. */ - { - const uint32_t zero_rgba = 0; - const uint16_t zero_raw = 0; - glBindTexture(GL_TEXTURE_2D, s_up_tex); - glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 1, 1, - GL_RGBA, GL_UNSIGNED_BYTE, &zero_rgba); - glBindTexture(GL_TEXTURE_2D, s_raw_tex); - glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 1, 1, - PSXGL_RED_INTEGER, GL_UNSIGNED_SHORT, &zero_raw); - glFinish(); - } - - p_glGenRenderbuffers(1, &s_hr_rb); - p_glBindRenderbuffer(PSXGL_RENDERBUFFER, s_hr_rb); - p_glRenderbufferStorage(PSXGL_RENDERBUFFER, PSXGL_DEPTH24_STENCIL8, hw, hh); - p_glBindRenderbuffer(PSXGL_RENDERBUFFER, 0); - - if (!make_fbo(&s_hr_fbo, s_hr_tex, s_hr_rb)) return 0; - if (!make_fbo(&s_raw_fbo, s_raw_tex, 0)) return 0; - if (!make_fbo(&s_scratch_fbo, s_scratch_tex, 0)) return 0; - - s_uVram = p_glGetUniformLocation(s_tex_prog, "u_vram"); - s_uTpage = p_glGetUniformLocation(s_tex_prog, "u_tpage"); - s_uClut = p_glGetUniformLocation(s_tex_prog, "u_clut"); - s_uDepth = p_glGetUniformLocation(s_tex_prog, "u_depth"); - s_uRaw = p_glGetUniformLocation(s_tex_prog, "u_raw"); - s_uSemipass = p_glGetUniformLocation(s_tex_prog, "u_semipass"); - s_uSemimode = p_glGetUniformLocation(s_tex_prog, "u_semimode"); - s_uTwin = p_glGetUniformLocation(s_tex_prog, "u_twin"); - s_uMaskset = p_glGetUniformLocation(s_tex_prog, "u_maskset"); - s_uFilter = p_glGetUniformLocation(s_tex_prog, "u_filter"); - s_uLimits = p_glGetUniformLocation(s_tex_prog, "u_limits"); - s_uBlitSrc = p_glGetUniformLocation(s_blit_prog, "u_src"); - s_uBlitPass = p_glGetUniformLocation(s_blit_prog, "u_stp_pass"); - s_uBlitMaskset = p_glGetUniformLocation(s_blit_prog, "u_maskset"); - s_uBlitSrcDiv = p_glGetUniformLocation(s_blit_prog, "u_src_div"); - s_uBlitSrcOff = p_glGetUniformLocation(s_blit_prog, "u_src_off"); - s_uPackHr = p_glGetUniformLocation(s_pack_prog, "u_hr"); - s_uPackScale = p_glGetUniformLocation(s_pack_prog, "u_scale"); - s_uStencilSrc = p_glGetUniformLocation(s_stencil_prog, "u_src"); - s_geo_uXoff = p_glGetUniformLocation(s_geo_prog, "u_xoff"); - s_geo_uXhalf = p_glGetUniformLocation(s_geo_prog, "u_xhalf"); - s_tex_uXoff = p_glGetUniformLocation(s_tex_prog, "u_xoff"); - s_tex_uXhalf = p_glGetUniformLocation(s_tex_prog, "u_xhalf"); - s_geo_uXscale = p_glGetUniformLocation(s_geo_prog, "u_xscale"); - s_geo_uXcenter = p_glGetUniformLocation(s_geo_prog, "u_xcenter"); - s_tex_uXscale = p_glGetUniformLocation(s_tex_prog, "u_xscale"); - s_tex_uXcenter = p_glGetUniformLocation(s_tex_prog, "u_xcenter"); - s_geo_uVisual = p_glGetUniformLocation(s_geo_prog, "u_visual"); - s_geo_uCamera = p_glGetUniformLocation(s_geo_prog, "u_camera"); - s_geo_uVisualOptions = p_glGetUniformLocation(s_geo_prog, "u_visual_options"); - s_tex_uVisual = p_glGetUniformLocation(s_tex_prog, "u_visual"); - s_tex_uCamera = p_glGetUniformLocation(s_tex_prog, "u_camera"); - s_tex_uVisualOptions = p_glGetUniformLocation(s_tex_prog, "u_visual_options"); - s_tex_uPull = p_glGetUniformLocation(s_tex_prog, "u_pull"); - /* Default the new uniforms to the no-op (1.0 scale, 0 centre) -- GLSL would - * otherwise zero them, collapsing all x to 0. */ - p_glUseProgram(s_geo_prog); - p_glUniform1f(s_geo_uXscale, 1.0f); p_glUniform1f(s_geo_uXcenter, 0.0f); - geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); - p_glUseProgram(s_tex_prog); - p_glUniform1f(s_tex_uXscale, 1.0f); p_glUniform1f(s_tex_uXcenter, 0.0f); - geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 0); - - /* Sample-grid alignment shift: half an HR pixel, set once (S is fixed - * for the lifetime of the pipeline). Backed off by 1/64 native px so - * primitive edges never land EXACTLY on sample centers — that float tie - * dropped 1px columns at quad seams (e.g. the 256px texture-page seam in - * Tomba's title background). The 1/64 bias keeps floor(uv) on the exact - * PS1 texel for |uv slope| < 64; mirrored (negative-slope) mappings can - * be off by one texel at exact-integer uv — accepted. */ - { - float shift = 0.5f / (float)s_scale - 1.0f / 64.0f; - p_glUseProgram(s_geo_prog); - p_glUniform1f(p_glGetUniformLocation(s_geo_prog, "u_shift"), shift); - /* Native-wide projection defaults: x translation 0, clip half-extent - * 512 — so the GEO_VS x term reduces to (x+u_shift)/512-1, bit-identical - * to the pre-native-wide projection. The wide passes set these, then - * restore these defaults. */ - p_glUniform1f(s_geo_uXoff, 0.0f); - p_glUniform1f(s_geo_uXhalf, 512.0f); - p_glUseProgram(s_tex_prog); - p_glUniform1f(p_glGetUniformLocation(s_tex_prog, "u_shift"), shift); - p_glUniform1f(s_tex_uXoff, 0.0f); - p_glUniform1f(s_tex_uXhalf, 512.0f); - p_glUseProgram(s_blit_prog); - p_glUniform1f(p_glGetUniformLocation(s_blit_prog, "u_shift"), shift); - p_glUseProgram(0); - } - - p_glGenVertexArrays(1, &s_geo_vao); - p_glBindVertexArray(s_geo_vao); - p_glGenBuffers(1, &s_geo_vbo); - p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_geo_vbo); - p_glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, GEOV * sizeof(float), (void *)0); - p_glEnableVertexAttribArray(0); - p_glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, GEOV * sizeof(float), (void *)(2 * sizeof(float))); - p_glEnableVertexAttribArray(1); - p_glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, GEOV * sizeof(float), (void *)(6 * sizeof(float))); - p_glEnableVertexAttribArray(2); - p_glVertexAttribPointer(3, 2, GL_FLOAT, GL_FALSE, GEOV * sizeof(float), (void *)(7 * sizeof(float))); - p_glEnableVertexAttribArray(3); - p_glVertexAttribPointer(4, 1, GL_FLOAT, GL_FALSE, GEOV * sizeof(float), (void *)(9 * sizeof(float))); - p_glEnableVertexAttribArray(4); - - p_glGenVertexArrays(1, &s_tex_vao); - p_glBindVertexArray(s_tex_vao); - p_glGenBuffers(1, &s_tex_vbo); - p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_tex_vbo); - { - GLsizei st = TEXV * sizeof(float); - p_glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, st, (void*)0); p_glEnableVertexAttribArray(0); /* pos */ - p_glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, st, (void*)(2*sizeof(float))); p_glEnableVertexAttribArray(1); /* uv */ - p_glVertexAttribPointer(2, 4, GL_FLOAT, GL_FALSE, st, (void*)(4*sizeof(float))); p_glEnableVertexAttribArray(2); /* col */ - p_glVertexAttribPointer(3, 2, GL_FLOAT, GL_FALSE, st, (void*)(8*sizeof(float))); p_glEnableVertexAttribArray(3); /* tpage */ - p_glVertexAttribPointer(4, 2, GL_FLOAT, GL_FALSE, st, (void*)(10*sizeof(float))); p_glEnableVertexAttribArray(4); /* clut */ - p_glVertexAttribPointer(5, 1, GL_FLOAT, GL_FALSE, st, (void*)(12*sizeof(float))); p_glEnableVertexAttribArray(5); /* depth */ - p_glVertexAttribPointer(6, 1, GL_FLOAT, GL_FALSE, st, (void*)(13*sizeof(float))); p_glEnableVertexAttribArray(6); /* raw */ - p_glVertexAttribPointer(7, 4, GL_FLOAT, GL_FALSE, st, (void*)(14*sizeof(float))); p_glEnableVertexAttribArray(7); /* limits */ - p_glVertexAttribPointer(8, 1, GL_FLOAT, GL_FALSE, st, (void*)(18*sizeof(float))); p_glEnableVertexAttribArray(8); /* semi */ - p_glVertexAttribPointer(9, 1, GL_FLOAT, GL_FALSE, st, (void*)(19*sizeof(float))); p_glEnableVertexAttribArray(9); /* visual flags */ - p_glVertexAttribPointer(10, 2, GL_FLOAT, GL_FALSE, st, (void*)(20*sizeof(float))); p_glEnableVertexAttribArray(10); /* visual pos */ - p_glVertexAttribPointer(11, 1, GL_FLOAT, GL_FALSE, st, (void*)(22*sizeof(float))); p_glEnableVertexAttribArray(11); /* reciprocal depth */ - p_glVertexAttribPointer(12, 1, GL_FLOAT, GL_FALSE, st, (void*)(23*sizeof(float))); p_glEnableVertexAttribArray(12); /* camera depth */ - } - - p_glGenVertexArrays(1, &s_blit_vao); - p_glBindVertexArray(s_blit_vao); - p_glGenBuffers(1, &s_blit_vbo); - p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_blit_vbo); - p_glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2*sizeof(float), (void*)0); - p_glEnableVertexAttribArray(0); - - p_glGenVertexArrays(1, &s_empty_vao); - p_glBindVertexArray(0); - - /* Clear the authoritative surface (color + stencil) and queue a full - * upload of whatever the CPU VRAM already holds (pre-context software - * draws, BIOS logo state, ...). */ - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hr_fbo); - glDisable(GL_SCISSOR_TEST); - glClearColor(0, 0, 0, 0); - glClearStencil(0); - glStencilMask(0xFF); - glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - rect_clear(&s_pack_dirty); - s_up_nrects = 0; - up_add(0, 0, VRAM_W - 1, VRAM_H - 1); - s_gpu_dirty = 0; - s_stencil_valid = 1; - for (int i = 0; i < PRES_ROWS; i++) s_present_dirty[i] = ~0ull; - s_last_present_path = -1; - - /* Native-wide compositor surfaces start unallocated (lazily created when a - * widescreen game calls wide_configure + wide_set_target). */ - for (int i = 0; i < WIDE_MAX_SURF; i++) { - s_wide_tex[i] = 0; s_wide_fbo[i] = 0; s_wide_base[i] = -1; - } - g_wide_w = 0; g_wide_off = 0; g_wide_cur = 0; g_wide_cur_base = 0; - - s_raster_ok = 1; - gl_perf_init(); /* frame_perf GPU/CPU phase timing (no-op if queries absent) */ - fprintf(stdout, "psxrecomp: GL GPU pipeline ready (internal scale %dx, " - "mask-bit stencil, texture window, GPU copy/upload)\n", s_scale); - if (s_geometry_full_yaw || s_geometry_coverage_tint) { - fprintf(stdout, "psxrecomp: geometry diagnostics: full X/Y yaw %s, " - "exact-coverage tint %s (presentation mirror only)\n", - s_geometry_full_yaw ? "enabled" : "disabled", - s_geometry_coverage_tint ? "enabled" : "disabled"); - } - return 1; -} - -int gl_renderer_init_context(SDL_Window *win) { - s_win = win; - s_present_w = 0; - s_present_h = 0; - SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); - SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8); - s_ctx = SDL_GL_CreateContext(win); - if (!s_ctx) { fprintf(stdout, "psxrecomp: GL context creation failed (%s)\n", SDL_GetError()); return 0; } - if (SDL_GL_MakeCurrent(win, s_ctx) != 0) { fprintf(stdout, "psxrecomp: MakeCurrent failed (%s)\n", SDL_GetError()); SDL_GL_DeleteContext(s_ctx); s_ctx=NULL; return 0; } - /* Swap interval: 1=vsync (tear-free, default), 0=immediate (lowest display - * latency, may tear; our wall-clock pacer still holds 59.94Hz), -1=adaptive. - * Adaptive falls back to vsync if the driver rejects it. */ - if (SDL_GL_SetSwapInterval(s_swap_interval) != 0 && s_swap_interval < 0) { - SDL_GL_SetSwapInterval(1); - s_swap_interval = 1; - } - glDisable(GL_DEPTH_TEST); glDisable(GL_CULL_FACE); - const char *ver = (const char *)glGetString(GL_VERSION); - fprintf(stdout, "psxrecomp: OpenGL context created (%s)\n", ver ? ver : "?"); - - /* All-or-nothing: any missing entry point / failed shader / bad FBO means - * the whole GL renderer is unavailable and the runtime stays on the pure - * software path — no half-GL hybrid (that mixed mode is what produced - * the alternating-present menu jitter). */ - int ok = load_modern_gl(); - if (ok) { - glGenTextures(1, &s_present_tex); - glBindTexture(GL_TEXTURE_2D, s_present_tex); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - s_present_prog = build_program(PRESENT_VS, PRESENT_FS); - s_ssao_prog = build_program(PRESENT_VS, SSAO_FS); - s_ssao_mix_prog = build_program(PRESENT_VS, SSAO_MIX_FS); - { - const char *fill = getenv("PSX_GL_CRACK_FILL"), *under = getenv("PSX_GL_GROWN_UNDER"); - GLfloat widths[2] = {1.0f, 1.0f}; - glGetFloatv(0x846E /* GL_ALIASED_LINE_WIDTH_RANGE */, widths); - while (glGetError() != GL_NO_ERROR) {} - glLineWidth(2.0f); /* a forward compatible context refuses any width but 1 */ - s_line_width_max = glGetError() == GL_NO_ERROR && widths[1] > 1.0f ? widths[1] : 1.0f; - glLineWidth(1.0f); - if (fill) s_crack_fill = s_crack_width = (float)atof(fill); - if (under) s_grown_under = atoi(under) != 0; - if (getenv("PSX_GL_WORLD_DEPTH")) s_world_depth = atoi(getenv("PSX_GL_WORLD_DEPTH")) != 0; - if (getenv("PSX_GL_SHOWN_DUMP")) - gl_renderer_record_shown(getenv("PSX_GL_SHOWN_DUMP"), - getenv("PSX_GL_SHOWN_DUMP_SKIP") ? atoi(getenv("PSX_GL_SHOWN_DUMP_SKIP")) : 0, - getenv("PSX_GL_SHOWN_DUMP_COUNT") ? atoi(getenv("PSX_GL_SHOWN_DUMP_COUNT")) : 240, - getenv("PSX_GL_SHOWN_DUMP_WIDTH") ? atoi(getenv("PSX_GL_SHOWN_DUMP_WIDTH")) : 640); - if (getenv("PSX_GL_WORLD_DEPTH_MARGIN") && atof(getenv("PSX_GL_WORLD_DEPTH_MARGIN")) > 0.0) - s_world_margin = (float)atof(getenv("PSX_GL_WORLD_DEPTH_MARGIN")); - } - if (s_ssao_prog && s_ssao_mix_prog) { - const char *on = getenv("PSX_GL_SSAO"), *radius = getenv("PSX_GL_SSAO_RADIUS"), *strength = getenv("PSX_GL_SSAO_STRENGTH"); - s_ssao_mix_uTex = p_glGetUniformLocation(s_ssao_mix_prog, "u_tex"); - s_ssao_mix_uShade = p_glGetUniformLocation(s_ssao_mix_prog, "u_shade"); - s_ssao_mix_uUvRect = p_glGetUniformLocation(s_ssao_mix_prog, "u_uv_rect"); - s_ssao_mix_uSpan = p_glGetUniformLocation(s_ssao_mix_prog, "u_span"); - s_ssao_uDepth = p_glGetUniformLocation(s_ssao_prog, "u_depth"); - s_ssao_uTex = p_glGetUniformLocation(s_ssao_prog, "u_tex"); - s_ssao_uFade = p_glGetUniformLocation(s_ssao_prog, "u_fade"); - s_ssao_uUvRect = p_glGetUniformLocation(s_ssao_prog, "u_uv_rect"); - s_ssao_uTexel = p_glGetUniformLocation(s_ssao_prog, "u_span"); - s_ssao_uProj = p_glGetUniformLocation(s_ssao_prog, "u_proj"); - s_ssao_uAo = p_glGetUniformLocation(s_ssao_prog, "u_ao"); - if (on) { - s_ssao = atoi(on); - s_ssao_on = s_ssao == 1; - s_ssao_view = s_ssao > 1 && s_ssao <= 5 ? s_ssao - 1 : 0; - } - if (radius && atof(radius) > 0.0) s_ssao_radius = (float)atof(radius); - if (strength && atof(strength) > 0.0) s_ssao_strength = (float)atof(strength); - if (getenv("PSX_GL_SSAO_TILT") && atof(getenv("PSX_GL_SSAO_TILT")) >= 0.0 && atof(getenv("PSX_GL_SSAO_TILT")) < 0.95) - s_ssao_tilt = (float)atof(getenv("PSX_GL_SSAO_TILT")); - } -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - s_interp_prog = build_program(PRESENT_VS, INTERP_FS); - if (s_present_prog && s_interp_prog) { -#else - if (s_present_prog) { -#endif - p_glGenVertexArrays(1, &s_present_vao); - s_present_uTex = p_glGetUniformLocation(s_present_prog, "u_tex"); - s_present_uUvRect = p_glGetUniformLocation(s_present_prog, "u_uv_rect"); -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - s_interp_uPrev = p_glGetUniformLocation(s_interp_prog, "u_prev"); - s_interp_uCurr = p_glGetUniformLocation(s_interp_prog, "u_curr"); - s_interp_uAlpha = p_glGetUniformLocation(s_interp_prog, "u_alpha"); - s_interp_uUvRect = p_glGetUniformLocation(s_interp_prog, "u_uv_rect"); - s_interp_uBlendMode = - p_glGetUniformLocation(s_interp_prog, "u_blend_mode"); - glGenTextures(3, s_interp_tex); - for (int i = 0; i < 3; i++) { - glBindTexture(GL_TEXTURE_2D, s_interp_tex[i]); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - } -#endif - } else ok = 0; - } - if (ok) ok = init_gpu_raster(); - if (!ok) { - fprintf(stdout, "psxrecomp: GL pipeline init failed — falling back to software renderer\n"); - SDL_GL_DeleteContext(s_ctx); s_ctx = NULL; - s_raster_ok = 0; - return 0; - } - return 1; -} - -static void apply_swap_interval(void) { - if (!s_ctx) return; - /* The interpolation thread owns host-refresh presentation and requires - * immediate swaps. Preserve the user's requested interval separately so - * disabling interpolation can restore it without a renderer restart. */ - int effective = s_interp_enabled ? 0 : s_swap_interval; - if (SDL_GL_SetSwapInterval(effective) != 0 && effective < 0) { - SDL_GL_SetSwapInterval(1); - s_swap_interval = 1; - } -} - -/* Set the requested GL swap interval (1=vsync, 0=immediate, -1=adaptive). - * Safe before or after context creation. While interpolation is active the - * request is remembered and physical swapping stays immediate. */ -void gl_renderer_set_swap_interval(int interval) { - s_swap_interval = interval; - apply_swap_interval(); -} - -void gl_renderer_shutdown(void) { -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - if (s_interp_thread) { - SDL_AtomicSet(&s_interp_thread_run, 0); - SDL_WaitThread(s_interp_thread, NULL); - s_interp_thread = NULL; - } - if (s_interp_ctx) { - SDL_GL_DeleteContext(s_interp_ctx); - s_interp_ctx = NULL; - } - if (s_ctx) { - SDL_GL_MakeCurrent(s_win, s_ctx); - if (s_interp_draw_fence) { - p_glDeleteSync(s_interp_draw_fence); - s_interp_draw_fence = NULL; - } - for (int i = 0; i < 3; i++) { - if (s_interp_fence[i]) { - p_glDeleteSync(s_interp_fence[i]); - s_interp_fence[i] = NULL; - } - } - ensure_cpu(); - temporal_release(); - SDL_GL_DeleteContext(s_ctx); s_ctx = NULL; - } - if (s_interp_mutex) { - SDL_DestroyMutex(s_interp_mutex); - s_interp_mutex = NULL; - } - s_interp_enabled = 0; - s_interp_suspended = 0; - s_interp_content_suspended = 0; - s_interp_host_ui_suspended = 0; - interp_reset_history_unlocked(); -#else - if (s_ctx) { - SDL_GL_MakeCurrent(s_win, s_ctx); - ensure_cpu(); - SDL_GL_DeleteContext(s_ctx); s_ctx = NULL; - } -#endif - free(s_conv); s_conv = NULL; - s_raster_ok = 0; - /* New context regenerates s_present_tex empty; a stale size makes - * upload_present_tex take glTexSubImage2D into an unallocated texture - * (rematch 24-bit FMV → black picture, audio still runs). */ - s_present_w = 0; - s_present_h = 0; - s_depth24_skip_up = 0; - rect_clear(&s_d24_skip_fb); -} - -/* Host UI callbacks are allowed to change live renderer settings. Those - * setters can flush queued geometry and consequently bind an internal FBO and - * its VRAM-sized viewport while ImGui is already building the current frame. - * Reassert the window target after the callback so the state cannot leak into - * this swap (or into ImGui's next-frame state backup). */ -static void render_host_ui_for_swap(int drawable_w, int drawable_h) { - if (!psx_host_ui_gl_visible()) return; - psx_host_ui_render_gl(drawable_w, drawable_h); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - glViewport(0, 0, drawable_w, drawable_h); -} - -/* CPU-readout present (24-bit FMV frames and the PSX_GL_FORCE_CPU_PRESENT - * diagnostic): full-window clear, then a quad into the letterbox rect. - * force_4_3 pins the rect to native 4:3 regardless of the display aspect — - * FMVs are authored 4:3 and have no GTE squash to compensate a stretch, so - * widescreen presents them pillarboxed instead of distorted. */ -/* Rows of the window above the screen (host_borderless.h): the picture goes in the rest, which starts at the bottom row as OpenGL counts. */ -static int s_hidden_rows; -void gl_renderer_set_hidden_rows(int rows) { s_hidden_rows = rows > 0 ? rows : 0; } -static void gl_visible_size(int *w, int *h) { - SDL_GL_GetDrawableSize(s_win, w, h); - if (*h > s_hidden_rows) *h -= s_hidden_rows; -} - -void gl_renderer_present(const uint32_t *pixels, int src_w, int src_h, int linear, - int force_4_3, int content_w) { - if (!s_ctx) return; - interp_reset_history(); - int ww = 0, wh = 0; gl_visible_size(&ww, &wh); - glDisable(GL_SCISSOR_TEST); - glViewport(0, 0, ww, wh); - glClearColor(0.f,0.f,0.f,1.f); glClear(GL_COLOR_BUFFER_BIT); - int lx, ly, lw, lh; - if (force_4_3) - letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); - else - letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); - /* Short GP1(07h) bands (MotK FMV is 128 lines) only fill a fraction of - * NTSC active height on hardware. Stretching them to the full letterbox - * doubles vertical scale vs horizontal and makes the frame look too wide - * with the right edge clipped. Letterbox within the present rect instead. - * Apply whenever the source is short — not only when force_4_3 — so a - * misclassified FMV frame still keeps correct pixel aspect. */ - /* Genuinely windowed video bands only (<80% of the 240-line field, e.g. - * MotK's 128-line FMV). A game's native short display mode (216/224) - * fills the rect as on hardware. */ - if (src_h > 0 && src_h < 192) { - int content_h = (lh * src_h) / 240; - if (content_h < 1) content_h = 1; - ly += (lh - content_h) / 2; - lh = content_h; - } - /* Optional trailing-column crop (depth24 margin): shrink the draw width - * left-aligned so cleared black remains on the right — never stretch. */ - float uv_x1 = 1.f; - int crop = (content_w > 0 && content_w < src_w && src_w > 0); - if (crop) { - uv_x1 = (float)content_w / (float)src_w; - lw = (lw * content_w) / src_w; - if (lw < 1) lw = 1; - } - glViewport(lx, ly, lw, lh); - p_glActiveTexture(PSXGL_TEXTURE0); - upload_present_tex(pixels, src_w, src_h, linear); - p_glUseProgram(s_present_prog); p_glUniform1i(s_present_uTex, 0); - if (crop) { - p_glUniform4f(s_present_uUvRect, 0.f, 0.f, uv_x1, 1.f); - } else if (!linear && src_w > 0 && src_h > 0) { - /* Nearest: half-texel UV inset so UV=1.0 never grazes past the last - * column into undefined border samples on some drivers. */ - float u0 = 0.5f / (float)src_w, v0 = 0.5f / (float)src_h; - p_glUniform4f(s_present_uUvRect, u0, v0, 1.f - u0, 1.f - v0); - } else { - p_glUniform4f(s_present_uUvRect, 0.f, 0.f, 1.f, 1.f); - } - p_glBindVertexArray(s_present_vao); glDrawArrays(GL_TRIANGLES, 0, 3); - p_glBindVertexArray(0); p_glUseProgram(0); - pres_record(GL_PRES_CPU, 0, 0, src_w, src_h, lx, ly, lw, lh); - render_host_ui_for_swap(ww, wh); - shown_capture(0, 1.0f); - latency_ring_mark(LAT_SWAP_BEGIN); - SDL_GL_SwapWindow(s_win); - latency_ring_mark(LAT_SWAP_END); - present_force_consumed(); - s_last_present_path = GL_PRES_CPU; -} - -void gl_renderer_present_blank(void) { - if (!s_ctx) return; - interp_reset_history(); - int ww = 0, wh = 0; gl_visible_size(&ww, &wh); - glDisable(GL_SCISSOR_TEST); - glViewport(0, 0, ww, wh); glClearColor(0.f,0.f,0.f,1.f); glClear(GL_COLOR_BUFFER_BIT); - pres_record(GL_PRES_BLANK, 0, 0, 0, 0, 0, 0, ww, wh); - render_host_ui_for_swap(ww, wh); - shown_capture(0, 1.0f); - latency_ring_mark(LAT_SWAP_BEGIN); - SDL_GL_SwapWindow(s_win); - latency_ring_mark(LAT_SWAP_END); - present_force_consumed(); - s_last_present_path = GL_PRES_BLANK; -} - -/* Sync the authoritative FBO down into CPU VRAM (no-op when current). - * Screenshots / debug server. Not for 24-bit FMV scanout (see flush). */ -void gl_renderer_sync_cpu(void) { - ensure_cpu(); -} - -void gl_renderer_invalidate_present(void) { - for (int i = 0; i < PRES_ROWS; i++) s_present_dirty[i] = ~0ull; - s_last_present_path = -1; - s_force_present_remaining = 8; - interp_reset_history(); -} - -void gl_renderer_flush_cpu_uploads(void) { - if (!s_raster_ok) return; - flush_flat_batch(); - flush_tex_batch(); - flush_cpu_upload(); -} - -/* Diagnostic (debug server "gl_fbo_peek"): read a rect of the GPU-side - * authoritative VRAM (via the pack pass + raw mirror) WITHOUT writing CPU - * VRAM — lets a probe diff FBO truth against CPU truth. Returns 0 when the - * GL pipeline is inactive (software backend). */ -int gl_renderer_fbo_peek(int x, int y, int w, int h, uint16_t *out) { - if (!s_raster_ok || !s_ctx) return 0; - if (x < 0 || y < 0 || w < 1 || h < 1 || - x + w > VRAM_W || y + h > VRAM_H) return 0; - flush_cpu_upload(); - rect_add(&s_pack_dirty, x, y, x + w - 1, y + h - 1); - pack_flush(); - coh_record(GL_COH_PEEK, x, y, x + w - 1, y + h - 1); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_raw_fbo); - glPixelStorei(GL_PACK_ALIGNMENT, 2); - glReadPixels(x, y, w, h, PSXGL_RED_INTEGER, GL_UNSIGNED_SHORT, out); - glPixelStorei(GL_PACK_ALIGNMENT, 4); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - return 1; -} - -/* Diagnostic (debug server "gl_vram_diff"): full-VRAM comparison of the - * GPU-side truth (FBO via pack) against the CPU array, WITHOUT writing - * either. Reports mismatch count + bounding box + a few sample coords. - * Divergence is expected where the GPU is legitimately ahead (gpu_dirty); - * at upload-only scenes the two must match exactly. */ -int gl_renderer_vram_diff(uint32_t *count, int bbox[4], - int samples[8][2], uint16_t samples_px[8][2]) { - if (!s_raster_ok || !s_ctx) return 0; - uint16_t *tmp = (uint16_t *)malloc((size_t)VRAM_W * VRAM_H * 2); - if (!tmp) return 0; - flush_cpu_upload(); - /* Force a full pack: the diff must read FBO truth even where the - * raw-mirror invariant (raw == FBO outside s_pack_dirty) is broken — - * a broken invariant is exactly what this tool hunts. */ - rect_add(&s_pack_dirty, 0, 0, VRAM_W - 1, VRAM_H - 1); - pack_flush(); - coh_record(GL_COH_DIFF, 0, 0, VRAM_W - 1, VRAM_H - 1); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_raw_fbo); - glReadPixels(0, 0, VRAM_W, VRAM_H, PSXGL_RED_INTEGER, GL_UNSIGNED_SHORT, tmp); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - uint32_t n = 0; - int x0 = VRAM_W, y0 = VRAM_H, x1 = -1, y1 = -1, ns = 0; - for (int y = 0; y < VRAM_H; y++) { - for (int x = 0; x < VRAM_W; x++) { - uint16_t f = tmp[y * VRAM_W + x], c = s_vram[y * VRAM_W + x]; - if (f == c) continue; - n++; - if (x < x0) x0 = x; if (x > x1) x1 = x; - if (y < y0) y0 = y; if (y > y1) y1 = y; - if (ns < 8 && (n % 977) == 1) { /* spread samples */ - samples[ns][0] = x; samples[ns][1] = y; - samples_px[ns][0] = f; samples_px[ns][1] = c; - ns++; - } - } - } - free(tmp); - *count = n; - bbox[0] = x0; bbox[1] = y0; bbox[2] = x1; bbox[3] = y1; - return 1 + ns; /* >=1 means valid; ns = samples filled */ -} - -/* Diagnostic state for the debug server: coherency flags + dirty rects. */ -void gl_renderer_diag(int *gpu_dirty, int pending[5], int pack[5]) { - if (gpu_dirty) *gpu_dirty = s_gpu_dirty; - if (pending) { - /* [0] = pending rect count; [1..4] = union bbox (diagnostic only — - * the flush itself paints the exact rects, never this union). */ - pending[0] = s_up_nrects; - pending[1] = pending[2] = pending[3] = pending[4] = 0; - for (int i = 0; i < s_up_nrects; i++) { - if (i == 0) { - pending[1] = s_up_rects[i].x0; pending[2] = s_up_rects[i].y0; - pending[3] = s_up_rects[i].x1; pending[4] = s_up_rects[i].y1; - } else { - if (s_up_rects[i].x0 < pending[1]) pending[1] = s_up_rects[i].x0; - if (s_up_rects[i].y0 < pending[2]) pending[2] = s_up_rects[i].y0; - if (s_up_rects[i].x1 > pending[3]) pending[3] = s_up_rects[i].x1; - if (s_up_rects[i].y1 > pending[4]) pending[4] = s_up_rects[i].y1; - } - } - } - if (pack) { - pack[0] = s_pack_dirty.set; - pack[1] = s_pack_dirty.x0; pack[2] = s_pack_dirty.y0; - pack[3] = s_pack_dirty.x1; pack[4] = s_pack_dirty.y1; - } -} - -/* ------------------------------------------------------------------------- * - * Native-wide compositor (GL). Mirrors gpu_sw_renderer.c's wide functions: - * canonical VRAM (the hr FBO) is untouched; framebuffer draws are also mirrored - * into per-base_x wide FBOs (see the draw funcs' wide passes). Present reads the - * displayed buffer's wide FBO via glReadPixels into the CPU present buffer, then - * the existing CPU present path uploads/letterboxes it (Option B: reuse the CPU - * present path). Self-gates on the GL pipeline being live; never runs for - * 4:3 / non-opted games (gpu.c never calls wide_configure for those). - * ------------------------------------------------------------------------- */ - -static void wide_free_all(void) { - interp_reset_history(); - for (int i = 0; i < WIDE_MAX_SURF; i++) { - if (s_wide_fbo[i]) { p_glDeleteFramebuffers(1, &s_wide_fbo[i]); s_wide_fbo[i] = 0; } - if (s_wide_tex[i]) { glDeleteTextures(1, &s_wide_tex[i]); s_wide_tex[i] = 0; } - if (s_wide_rb[i]) { p_glDeleteRenderbuffers(1, &s_wide_rb[i]); s_wide_rb[i] = 0; } - s_wide_base[i] = -1; - } - g_wide_cur = 0; -} - -/* Lazily allocate (or find) the wide FBO+tex for base_x. Returns the FBO id, or - * 0 on failure / more distinct buffers than WIDE_MAX_SURF. */ -static GLuint wide_fbo_for(int base_x) { - if (g_wide_w <= 0) return 0; - for (int i = 0; i < WIDE_MAX_SURF; i++) - if (s_wide_fbo[i] && s_wide_base[i] == base_x) return s_wide_fbo[i]; - for (int i = 0; i < WIDE_MAX_SURF; i++) { - if (!s_wide_fbo[i]) { - int w = g_wide_w * s_scale, h = VRAM_H * s_scale; - s_cw_fbo_creates++; - s_wide_tex[i] = make_tex(GL_RGBA8, w, h, GL_RGBA, GL_UNSIGNED_BYTE); - /* Depth-stencil RB, same as the hr FBO: the stencil carries the - * PSX mask-bit mirror for the wide surface, and (the hard lesson) - * a stencil-less FBO turns every stencil-enabled mirror draw into - * ~0.6ms of driver-side work — the 16:9 GL perf collapse. */ - p_glGenRenderbuffers(1, &s_wide_rb[i]); - p_glBindRenderbuffer(PSXGL_RENDERBUFFER, s_wide_rb[i]); - p_glRenderbufferStorage(PSXGL_RENDERBUFFER, PSXGL_DEPTH24_STENCIL8, w, h); - if (!make_fbo(&s_wide_fbo[i], s_wide_tex[i], s_wide_rb[i])) { - glDeleteTextures(1, &s_wide_tex[i]); s_wide_tex[i] = 0; - p_glDeleteRenderbuffers(1, &s_wide_rb[i]); s_wide_rb[i] = 0; - return 0; - } - /* Clear to black so unwritten margins are clean (not stale). */ - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_wide_fbo[i]); - glDisable(GL_SCISSOR_TEST); - glClearColor(0, 0, 0, 0); - glClearStencil(0); - glStencilMask(0xFF); - glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - s_wide_base[i] = base_x; - return s_wide_fbo[i]; - } - } - return 0; /* more distinct buffers than WIDE_MAX_SURF — shouldn't happen */ -} - -/* Enable native-wide with a wide width + centering offset (native px), or - * disable (wide_w <= 0). Re-allocates if the width changed. Mirrors - * sw_wide_configure. */ -static void glb_wide_configure(int wide_w, int offset) { - if (!s_raster_ok) return; - double t0 = cw_ms(); s_cw_wide_cfgs++; - flush_flat_batch(); - flush_tex_batch(); /* a queued batch's wide mirror targets the CURRENT surfaces */ - if (wide_w <= 0) { wide_free_all(); g_wide_w = 0; g_wide_off = 0; s_cw_wide_ms += cw_ms() - t0; return; } - if (wide_w != g_wide_w) wide_free_all(); - if (offset != g_wide_off) interp_reset_history(); - g_wide_w = wide_w; - g_wide_off = offset; - s_cw_wide_ms += cw_ms() - t0; -} - -/* Select the wide surface to mirror into for the back buffer at base_x. */ -static void glb_wide_set_target(int base_x) { - if (!s_raster_ok) { g_wide_cur = 0; return; } - double t0 = cw_ms(); s_cw_wide_sets++; - flush_flat_batch(); - flush_tex_batch(); /* drain into the OLD target before switching */ - g_wide_cur = wide_fbo_for(base_x); - g_wide_cur_base = base_x; - s_cw_wide_ms += cw_ms() - t0; -} - -/* Stop mirroring (offscreen draws that don't target a framebuffer). */ -static void glb_wide_disable_target(void) { flush_flat_batch(); flush_tex_batch(); g_wide_cur = 0; } - -/* Mirror a framebuffer clear: fill the full wide width over [y, y+h) of the - * surface for base_x, so the revealed margins are clean. Mirrors sw_wide_clear: - * a scissored glClear with the 1555 color converted to RGBA8 (alpha = bit15). */ -static void glb_wide_clear(int base_x, int y, int h, uint16_t color) { - if (!s_raster_ok || s_ws_ablate == 1) return; - double t0 = cw_ms(); s_cw_wide_clears++; - flush_flat_batch(); - flush_tex_batch(); - GLuint fbo = wide_fbo_for(base_x); - if (!fbo) { s_cw_wide_ms += cw_ms() - t0; return; } - temporal_begin(fbo, y, h, color); - gl_perf_mirror_begin(); - int H = VRAM_H * s_scale; - int y0 = y * s_scale, y1 = (y + h) * s_scale; - if (y0 < 0) y0 = 0; - if (y1 > H) y1 = H; - if (y1 <= y0) return; - float r = (color & 0x1F) / 31.0f; - float g = ((color >> 5) & 0x1F) / 31.0f; - float b = ((color >> 10) & 0x1F) / 31.0f; - float a = (color >> 15) & 1 ? 1.0f : 0.0f; - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, fbo); - glViewport(0, 0, g_wide_w * s_scale, H); - glEnable(GL_SCISSOR_TEST); - glScissor(0, y0, g_wide_w * s_scale, y1 - y0); - glClearColor(r, g, b, a); - glClearStencil((color >> 15) & 1); /* stencil mirrors bit15, like the color alpha */ - glStencilMask(0xFF); - if (s_world_depth && mirror_depth()) { /* under the depth test a frame never shown must not leave its depth to the next */ - glDepthMask(GL_TRUE); - glClearDepth(1.0); - glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); - for (int i = 0; i < WIDE_MAX_SURF; i++) - if (s_wide_fbo[i] == fbo) s_wide_depth_shown[i] &= ~wide_depth_rows(y, h); - } else - glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); - glDisable(GL_SCISSOR_TEST); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - gl_perf_mirror_end(); - s_cw_wide_ms += cw_ms() - t0; -} - -/* Clear only the two synthetic reveal strips, preserving the centred canonical - * framebuffer. This is an opt-in transition cleanup driven by gpu.c. */ -static void glb_wide_clear_margins(int base_x, int y, int h, uint16_t color, int sides) { - if (!s_raster_ok || s_ws_ablate == 1 || g_wide_off <= 0) return; - double t0 = cw_ms(); s_cw_wide_clears++; - flush_flat_batch(); - flush_tex_batch(); - GLuint fbo = wide_fbo_for(base_x); - if (!fbo) { s_cw_wide_ms += cw_ms() - t0; return; } - temporal_invalidate_surface(fbo); - gl_perf_mirror_begin(); - int H = VRAM_H * s_scale; - int W = g_wide_w * s_scale; - int margin = g_wide_off * s_scale; - int y0 = y * s_scale, y1 = (y + h) * s_scale; - if (y0 < 0) y0 = 0; - if (y1 > H) y1 = H; - if (y1 <= y0 || margin * 2 >= W) { - gl_perf_mirror_end(); - s_cw_wide_ms += cw_ms() - t0; - return; - } - float r = (color & 0x1F) / 31.0f; - float g = ((color >> 5) & 0x1F) / 31.0f; - float b = ((color >> 10) & 0x1F) / 31.0f; - float a = (color >> 15) & 1 ? 1.0f : 0.0f; - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, fbo); - glViewport(0, 0, W, H); - glEnable(GL_SCISSOR_TEST); - glClearColor(r, g, b, a); - glClearStencil((color >> 15) & 1); - glStencilMask(0xFF); - if (sides & 1) { - glScissor(0, y0, margin, y1 - y0); - glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); - } - if (sides & 2) { - glScissor(W - margin, y0, margin, y1 - y0); - glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); - } - glDisable(GL_SCISSOR_TEST); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - gl_perf_mirror_end(); - s_cw_wide_ms += cw_ms() - t0; -} - -/* Present source: read the wide FBO for the displayed buffer (base_x) into the - * CPU present buffer as ARGB8888, byte-identical to sw_render_wide_display's - * output so the shared CPU present path consumes it the same way. Output is - * (g_wide_w*scale) wide × (disp_h*scale) tall. Returns pixel count (>0), or 0 - * if no surface exists for base_x (caller falls back to the canonical present). - * - * PIXEL FORMAT: glReadPixels(GL_BGRA, GL_UNSIGNED_BYTE) yields, per pixel, the - * little-endian uint32 0xAARRGGBB == ARGB8888 — exactly what rgb555_to_argb - * produces and what upload_present_tex feeds to glTexImage2D(GL_BGRA,...). The - * SW path forces alpha to 0xFF; we OR it in to match (present ignores alpha, but - * we keep the two paths bit-identical). GL's read origin is bottom-left, so the - * block is read bottom-to-top and copied into `out` reversed (out row 0 = the - * display's top scanline, as the SW path and the PRESENT_VS V-flip expect). */ -static int glb_render_wide_display(uint32_t *out, int pitch, int base_x, - int disp_y, int disp_h) { - if (!s_raster_ok || !s_ctx || g_wide_w <= 0) return 0; - GLuint fbo = 0; - for (int i = 0; i < WIDE_MAX_SURF; i++) - if (s_wide_fbo[i] && s_wide_base[i] == base_x) { fbo = s_wide_fbo[i]; break; } - if (!fbo) return 0; - - /* Fold any pending CPU->VRAM uploads into the canonical FBO first (uploads - * are never mirrored to wide, but draws after them are; keep op order) and - * make sure all wide-FBO draws have completed before the readback. */ - flush_tex_batch(); - flush_cpu_upload(); - wide_blit_center(fbo, base_x, disp_y, disp_h); /* fast-path: authoritative centre before readback */ - glFinish(); - - int W = g_wide_w * s_scale; - int H = VRAM_H * s_scale; - int out_h = disp_h * s_scale; - int ry0 = disp_y * s_scale; - if (ry0 < 0) ry0 = 0; - if (ry0 + out_h > H) out_h = H - ry0; - if (out_h <= 0) return 0; - - uint32_t *tmp = (uint32_t *)malloc((size_t)W * out_h * sizeof(uint32_t)); - if (!tmp) return 0; - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, fbo); - glPixelStorei(GL_PACK_ALIGNMENT, 4); - glReadPixels(0, ry0, W, out_h, GL_BGRA, GL_UNSIGNED_BYTE, tmp); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - - /* Orientation: the wide FBO stores PS1 y inverted (geo shader maps vram_y=0 - * to NDC y=-1 = FBO BOTTOM), and glReadPixels reads bottom-up — the two - * inversions CANCEL, so glReadPixels row 0 already = PS1 top scanline. Copy - * straight (NO flip) so `out` is top-down, matching sw_render_wide_display - * (which the shared CPU present path + its PRESENT_VS V-flip expect). Force - * alpha = 0xFF (match SW). [An earlier reversal here made the frame - * upside-down.] */ - int count = 0; - for (int row = 0; row < out_h; row++) { - const uint32_t *src = tmp + (size_t)row * W; - uint32_t *dst = (uint32_t *)((uint8_t *)out + (size_t)row * pitch); - for (int col = 0; col < W; col++) { dst[col] = src[col] | 0xFF000000u; count++; } - } - free(tmp); - return count; -} - -/* Dump the ENTIRE wide compositor surface for base_x (all double-buffer bands + - * both reveal margins), g_wide_w x VRAM_H at scale. Debug/inspection tool (TCP - * wide_full) — the GL analog of sw_wide_dump_full, so native-wide can be - * inspected without touching the game window. Runs the same authoritative - * centre blit first so the dump matches what present shows. Top-down, alpha=FF - * (matches sw_wide_dump_full / render_wide_display orientation). */ -static int glb_wide_dump_full(uint32_t *out, int cap_pixels, int *ow, int *oh, - int base_x) { - if (!s_raster_ok || !s_ctx || g_wide_w <= 0) return 0; - GLuint fbo = 0; - for (int i = 0; i < WIDE_MAX_SURF; i++) - if (s_wide_fbo[i] && s_wide_base[i] == base_x) { fbo = s_wide_fbo[i]; break; } - if (!fbo) return 0; - flush_tex_batch(); - flush_cpu_upload(); - wide_blit_center(fbo, base_x, 0, VRAM_H); /* authoritative centre (full height) */ - glFinish(); - int W = g_wide_w * s_scale; - int H = VRAM_H * s_scale; - if (cap_pixels > 0 && (long)W * H > cap_pixels) { H = cap_pixels / W; if (H <= 0) return 0; } - uint32_t *tmp = (uint32_t *)malloc((size_t)W * H * sizeof(uint32_t)); - if (!tmp) return 0; - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, fbo); - glPixelStorei(GL_PACK_ALIGNMENT, 4); - glReadPixels(0, 0, W, H, GL_BGRA, GL_UNSIGNED_BYTE, tmp); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - /* Same orientation reasoning as glb_render_wide_display: glReadPixels row 0 = - * PS1 top scanline, so copy straight (top-down). */ - int count = 0; - for (int i = 0; i < W * H; i++) { out[i] = tmp[i] | 0xFF000000u; count++; } - free(tmp); - if (ow) *ow = W; - if (oh) *oh = H; - return count; -} - -/* THE present path for 15-bit frames: blit the display region from the - * authoritative hr FBO into a letterboxed rect. Deterministic — runs - * every 15-bit frame regardless of what mix of ops produced it. - * force_4_3 pins to native 4:3 (15-bit MDEC FMV frames on a wide aspect). */ -/* ===================== frame_perf: per-frame GPU/CPU phase timing ============ - * Developer builds use two GL_TIME_ELAPSED queries per frame to bracket (a) the - * scene draws (all GP0 raster issued between two presents) and (b) the present - * clear+blit, giving TRUE GPU time per phase independent of CPU/GPU overlap - * (glFinish would only catch the non-overlapped tail and mislead). CPU wall time - * (present-to-present total, and the present call) comes from SDL perf counters. - * Results are read back GLPERF_NBUF frames late (no pipeline stall) into a ring - * the debug server's frame_perf command aggregates. Release builds compile out - * the debug server, so they also leave this instrumentation disabled: a native- - * wide frame can otherwise issue hundreds of unused mirror timestamp queries. - * One question this answers in a diagnostics build: - * where does a 16:9 frame go vs 4:3 — scene fill, wide composite, or CPU. */ -#define GLPERF_NBUF 4 -#define GLPERF_RING 256 -typedef struct { - double total_ms; /* present-entry to present-entry (full frame) */ - double present_wall_ms; /* CPU wall time inside the present call */ - double scene_gpu_ms; /* GPU: all scene draws this frame */ - double present_gpu_ms; /* GPU: the present clear+blit */ - double mirror_gpu_ms; /* GPU: of scene_gpu, the native-wide mirror passes */ - double prims; /* scene primitives submitted this frame */ - double mirror_passes; /* mirror passes this frame (measured + overflow) */ - double cw_flush_ms; /* CPU wall inside flush_tex_batch this frame */ - double cw_wide_ms; /* CPU wall inside glb_wide_* this frame */ - double batches; /* flush_tex_batch draws this frame */ - double wide_sets; /* glb_wide_set_target calls this frame */ - double fbo_creates; /* wide FBO+tex creations this frame */ - int wide; /* 1 = native-wide (16:9) present, 0 = 4:3 */ - uint64_t frame; -} GlPerfSample; - -/* Native-wide mirror-pass GPU attribution: the scene TIME_ELAPSED query spans - * the whole frame (queries of one target cannot nest), so each mirror pass is - * bracketed with a GL_TIMESTAMP pair instead (glQueryCounter does not conflict - * with an active TIME_ELAPSED query). Pairs are pooled per buffered frame and - * summed at readback, splitting scene_gpu into canonical vs mirror cost. */ -#define GLPERF_MIRQ 1024 /* measured mirror passes per frame */ -static GLuint s_mq_q[GLPERF_NBUF][GLPERF_MIRQ * 2]; -static int s_mq_n[GLPERF_NBUF]; /* pairs recorded this frame */ -static int s_mq_over[GLPERF_NBUF]; /* passes beyond the pool (counted, untimed) */ -static int s_mq_open = 0; /* begin issued, end pending */ -static int s_mq_ok = 0; /* glQueryCounter available */ - -static int s_pf_on = 0; -static GLuint s_pf_scene_q[GLPERF_NBUF]; -static GLuint s_pf_present_q[GLPERF_NBUF]; -static int s_pf_b = 0; /* buffer for the CURRENT frame */ -static int s_pf_scene_active = 0; -static uint64_t s_pf_count = 0; -static uint64_t s_pf_freq = 1; -static uint64_t s_pf_last_enter = 0; -static uint64_t s_pf_enter = 0; -static double s_pf_total_pending = 0.0; -static double s_pf_buf_total[GLPERF_NBUF]; -static double s_pf_buf_pwall[GLPERF_NBUF]; -static double s_pf_buf_prims[GLPERF_NBUF]; -static int s_pf_buf_wide[GLPERF_NBUF]; -static uint64_t s_pf_buf_frame[GLPERF_NBUF]; -static uint64_t s_pf_prims_last = 0; -static double s_pf_prims_pending = 0.0; -static double s_pf_cw_pending[5]; /* flush_ms, wide_ms, batches, wide_sets, fbo_creates */ -static double s_pf_buf_cw[GLPERF_NBUF][5]; -static GlPerfSample s_pf_ring[GLPERF_RING]; -static uint64_t s_pf_ring_seq = 0; - -static void gl_perf_init(void) { -#ifdef PSX_NO_DEBUG_TOOLS - return; -#else - /* Timer queries are intentionally available in diagnostics builds, but a - * driver may serialize command submission while collecting them. Keep an - * escape hatch so frame cadence can be A/B tested without rebuilding or - * losing the rest of the debug server telemetry. */ - { - const char *enabled = getenv("PSX_GL_PERF"); - if (enabled && enabled[0] == '0') return; - } - if (!p_glGenQueries || !p_glBeginQuery || !p_glEndQuery || !p_glGetQueryObjectui64v) return; - p_glGenQueries(GLPERF_NBUF, s_pf_scene_q); - p_glGenQueries(GLPERF_NBUF, s_pf_present_q); - s_mq_ok = (p_glQueryCounter != NULL); - if (s_mq_ok) - for (int i = 0; i < GLPERF_NBUF; i++) { - p_glGenQueries(GLPERF_MIRQ * 2, s_mq_q[i]); - s_mq_n[i] = 0; s_mq_over[i] = 0; - } - s_mq_open = 0; - s_pf_freq = SDL_GetPerformanceFrequency(); - if (!s_pf_freq) s_pf_freq = 1; - s_pf_b = 0; s_pf_scene_active = 0; s_pf_count = 0; s_pf_ring_seq = 0; - s_pf_last_enter = 0; - s_pf_on = 1; -#endif -} - -/* Bracket ONE native-wide mirror pass (called from the wide-mirror draw sites). - * Timestamp pairs, not TIME_ELAPSED — see the pool comment above. */ -static void gl_perf_mirror_begin(void) { - if (!s_pf_on || !s_mq_ok) return; - int b = s_pf_b; - if (s_mq_n[b] >= GLPERF_MIRQ) { s_mq_over[b]++; return; } - p_glQueryCounter(s_mq_q[b][s_mq_n[b] * 2], GL_TIMESTAMP); - s_mq_open = 1; -} -static void gl_perf_mirror_end(void) { - if (!s_pf_on || !s_mq_ok || !s_mq_open) return; - int b = s_pf_b; - p_glQueryCounter(s_mq_q[b][s_mq_n[b] * 2 + 1], GL_TIMESTAMP); - s_mq_n[b]++; - s_mq_open = 0; -} - -/* Top of present (after flush_cpu_upload, before clear/blit). */ -static void gl_perf_present_enter(void) { - /* Per-frame boundary for the 2D-backdrop stretch — runs from BOTH present - * paths (4:3 present_vram AND native-wide present_wide_fbo), before the - * perf-on gate so native-wide frames reset too. Snapshot this frame's - * backdrop-stretch diagnostics, then reset the per-frame counters. (The gate - * is per-prim now — no draw-order phase to reset.) Final batch already flushed - * by the caller. */ - g_bdg_applied = s_bdg_applied; g_bdg_prims = s_bdg_prims; g_bdg_clearx = s_bdg_clearx; - g_bdg_cur = (g_wide_cur != 0); g_bdg_base = g_wide_cur_base; g_bdg_w = g_wide_w; g_bdg_off = g_wide_off; - s_bdg_applied = 0; s_bdg_prims = 0; s_bdg_clearx = -999999; - { extern void psx_ws_dbg_gate_frame_snapshot(void); psx_ws_dbg_gate_frame_snapshot(); } - if (!s_pf_on) return; - uint64_t now = SDL_GetPerformanceCounter(); - s_pf_enter = now; - s_pf_total_pending = s_pf_last_enter - ? (double)(now - s_pf_last_enter) * 1000.0 / (double)s_pf_freq : 0.0; - s_pf_last_enter = now; - s_pf_prims_pending = (double)(s_scene_prims - s_pf_prims_last); /* prims drawn this frame */ - s_pf_prims_last = s_scene_prims; - s_pf_cw_pending[0] = s_cw_flush_ms; s_pf_cw_pending[1] = s_cw_wide_ms; - s_pf_cw_pending[2] = (double)s_cw_batches; - s_pf_cw_pending[3] = (double)s_cw_wide_sets; - s_pf_cw_pending[4] = (double)s_cw_fbo_creates; - s_cw_flush_ms = 0.0; s_cw_wide_ms = 0.0; - s_cw_batches = 0; s_cw_wide_sets = 0; s_cw_wide_cfgs = 0; - s_cw_wide_clears = 0; s_cw_fbo_creates = 0; - if (s_pf_scene_active) { p_glEndQuery(GL_TIME_ELAPSED); s_pf_scene_active = 0; } /* end frame b's scene draws */ - p_glBeginQuery(GL_TIME_ELAPSED, s_pf_present_q[s_pf_b]); /* time frame b's present */ -} - -/* End of present (after SwapWindow). wide = native-wide path. */ -static void gl_perf_present_exit(int wide) { - if (!s_pf_on) return; - uint64_t now = SDL_GetPerformanceCounter(); - p_glEndQuery(GL_TIME_ELAPSED); /* end present_q[b] */ - s_pf_buf_total[s_pf_b] = s_pf_total_pending; - s_pf_buf_pwall[s_pf_b] = (double)(now - s_pf_enter) * 1000.0 / (double)s_pf_freq; - s_pf_buf_prims[s_pf_b] = s_pf_prims_pending; - for (int ci = 0; ci < 5; ci++) s_pf_buf_cw[s_pf_b][ci] = s_pf_cw_pending[ci]; - s_pf_buf_wide[s_pf_b] = wide; - s_pf_buf_frame[s_pf_b] = s_pf_count; - int rd = (s_pf_b + 1) % GLPERF_NBUF; /* oldest buffer (frame count+1-NBUF), now done */ - if (s_pf_count >= (uint64_t)GLPERF_NBUF) { - GLuint64 sc = 0, pr = 0; - p_glGetQueryObjectui64v(s_pf_scene_q[rd], GL_QUERY_RESULT, &sc); - p_glGetQueryObjectui64v(s_pf_present_q[rd], GL_QUERY_RESULT, &pr); - double mir = 0.0; - for (int i = 0; i < s_mq_n[rd]; i++) { /* sum that frame's mirror pairs */ - GLuint64 t0 = 0, t1 = 0; - p_glGetQueryObjectui64v(s_mq_q[rd][i * 2], GL_QUERY_RESULT, &t0); - p_glGetQueryObjectui64v(s_mq_q[rd][i * 2 + 1], GL_QUERY_RESULT, &t1); - if (t1 > t0) mir += (double)(t1 - t0); - } - GlPerfSample *s = &s_pf_ring[s_pf_ring_seq % GLPERF_RING]; - s->total_ms = s_pf_buf_total[rd]; - s->present_wall_ms = s_pf_buf_pwall[rd]; - s->scene_gpu_ms = (double)sc / 1.0e6; - s->present_gpu_ms = (double)pr / 1.0e6; - s->mirror_gpu_ms = mir / 1.0e6; - s->prims = s_pf_buf_prims[rd]; - s->mirror_passes = (double)(s_mq_n[rd] + s_mq_over[rd]); - s->cw_flush_ms = s_pf_buf_cw[rd][0]; - s->cw_wide_ms = s_pf_buf_cw[rd][1]; - s->batches = s_pf_buf_cw[rd][2]; - s->wide_sets = s_pf_buf_cw[rd][3]; - s->fbo_creates = s_pf_buf_cw[rd][4]; - s->wide = s_pf_buf_wide[rd]; - s->frame = s_pf_buf_frame[rd]; - s_pf_ring_seq++; - } - s_mq_n[rd] = 0; s_mq_over[rd] = 0; /* rd becomes the next frame's buffer */ - s_pf_count++; - s_pf_b = rd; /* reuse oldest for next frame */ - p_glBeginQuery(GL_TIME_ELAPSED, s_pf_scene_q[s_pf_b]); /* open next frame's scene draws */ - s_pf_scene_active = 1; -} - -/* Aggregate the ring for the debug server. wide_filter: -1 all, 0 = 4:3, 1 = wide. - * out[0]=count, [1]=total_avg, [2]=total_max, [3]=emu_cpu_avg (total-present_wall), - * [4]=present_wall_avg, [5]=scene_gpu_avg, [6]=scene_gpu_max, [7]=present_gpu_avg, - * [8]=present_gpu_max. Returns the sample count. */ -/* Cumulative textured fraction of scene prims (decides flat vs textured batching - * priority). out_tex_frac = textured/total since boot; returns total prim count. */ -uint64_t gl_renderer_perf_prim_split(double *out_tex_frac) { - if (out_tex_frac) *out_tex_frac = s_scene_prims ? (double)s_scene_prims_tex / (double)s_scene_prims : 0.0; - return s_scene_prims; -} - -int gl_renderer_perf_aggregate(int wide_filter, double out[18]) { - for (int i = 0; i < 18; i++) out[i] = 0.0; - if (!s_pf_on) return 0; - int navail = (int)(s_pf_ring_seq < (uint64_t)GLPERF_RING ? s_pf_ring_seq : GLPERF_RING); - uint64_t start = s_pf_ring_seq - (uint64_t)navail; - int n = 0; - for (int i = 0; i < navail; i++) { - const GlPerfSample *s = &s_pf_ring[(start + i) % GLPERF_RING]; - if (wide_filter >= 0 && s->wide != wide_filter) continue; - double emu = s->total_ms - s->present_wall_ms; if (emu < 0) emu = 0; - out[1] += s->total_ms; if (s->total_ms > out[2]) out[2] = s->total_ms; - out[3] += emu; - out[4] += s->present_wall_ms; - out[5] += s->scene_gpu_ms; if (s->scene_gpu_ms > out[6]) out[6] = s->scene_gpu_ms; - out[7] += s->present_gpu_ms; if (s->present_gpu_ms > out[8]) out[8] = s->present_gpu_ms; - out[9] += s->prims; - out[10] += s->mirror_gpu_ms; if (s->mirror_gpu_ms > out[11]) out[11] = s->mirror_gpu_ms; - out[12] += s->mirror_passes; - out[13] += s->cw_flush_ms; - out[14] += s->cw_wide_ms; - out[15] += s->batches; - out[16] += s->wide_sets; - out[17] += s->fbo_creates; - n++; - } - if (n) { - out[1]/=n; out[3]/=n; out[4]/=n; out[5]/=n; out[7]/=n; out[9]/=n; out[10]/=n; out[12]/=n; - out[13]/=n; out[14]/=n; out[15]/=n; out[16]/=n; out[17]/=n; - } - out[0] = (double)n; - return n; -} - -/* Native-wide mirror ablation (perf attribution): see s_ws_ablate. */ -void gl_renderer_set_ws_ablate(int mode) { interp_reset_history(); s_ws_ablate = (mode >= 0 && mode <= 3) ? mode : 0; } -int gl_renderer_get_ws_ablate(void) { return s_ws_ablate; } - -extern void gte_ws_x_squash(int32_t *num, int32_t *den); - -/* What the shading draws through. Frame buffer objects belong to one GL context: the frame loop's and the in-between presenter's. */ -typedef struct { - GLuint depth_fbo, depth, shade_fbo, shade; - int w, h, shade_w, shade_h; -} SsaoTargets; -static SsaoTargets s_ssao_main, s_ssao_thread; - -static GLuint ssao_depth_texture(int w, int h) { - GLuint made = 0; - glGenTextures(1, &made); - glBindTexture(GL_TEXTURE_2D, made); - glTexImage2D(GL_TEXTURE_2D, 0, PSXGL_DEPTH24_STENCIL8, w, h, 0, 0x84F9 /* GL_DEPTH_STENCIL */, 0x84FA /* GL_UNSIGNED_INT_24_8 */, NULL); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - return made; -} - -static int ssao_targets(SsaoTargets *t, int w, int h, int lw, int lh, int copies) { - if (copies && (!t->depth_fbo || t->w != w || t->h != h)) { - if (t->depth_fbo) p_glDeleteFramebuffers(1, &t->depth_fbo); - if (t->depth) glDeleteTextures(1, &t->depth); - t->depth_fbo = 0; - t->depth = ssao_depth_texture(w, h); - p_glGenFramebuffers(1, &t->depth_fbo); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, t->depth_fbo); - p_glFramebufferTexture2D(PSXGL_FRAMEBUFFER, PSXGL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, t->depth, 0); - glDrawBuffer(GL_NONE); - glReadBuffer(GL_NONE); - if (p_glCheckFramebufferStatus(PSXGL_FRAMEBUFFER) != PSXGL_FRAMEBUFFER_COMPLETE) return 0; - t->w = w; - t->h = h; - } - if (!t->shade_fbo || t->shade_w != lw || t->shade_h != lh) { - if (t->shade_fbo) p_glDeleteFramebuffers(1, &t->shade_fbo); - if (t->shade) glDeleteTextures(1, &t->shade); - t->shade_fbo = 0; - t->shade = make_tex(GL_RGBA8, lw, lh, GL_RGBA, GL_UNSIGNED_BYTE); - if (!t->shade || !make_fbo(&t->shade_fbo, t->shade, 0)) return 0; - t->shade_w = lw; - t->shade_h = lh; - } - return 1; -} - -/* Shades `color` by a depth of the same size and puts it on screen. The depth is `depth`, or with 0 is first copied from the - * frame buffer `from`. uv is the part shown. 0: nothing drawn, the caller presents plainly. */ -static int ssao_draw(SsaoTargets *t, GLuint vao, GLuint from, GLuint depth, GLuint color, int w, int h, - float u0, float v0, float u1, float v1, int scale, int linear, int lx, int ly, int lw, int lh) { - int32_t num = 1, den = 1; - float focal_x, focal_y; - if (!s_ssao || !s_geometry_correction || !s_ssao_prog || !s_ssao_mix_prog || lw <= 0 || lh <= 0 || w <= 0 || h <= 0) return 0; - if (!ssao_targets(t, w, h, lw, lh, depth == 0)) { - fprintf(stdout, "psxrecomp: GL ambient occlusion has no targets, switched off\n"); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - s_ssao = s_ssao_on = s_ssao_view = 0; - return 0; - } - glDisable(GL_SCISSOR_TEST); - if (!depth) { - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, from); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, t->depth_fbo); - p_glBlitFramebuffer(0, 0, w, h, 0, 0, w, h, GL_DEPTH_BUFFER_BIT, GL_NEAREST); - depth = t->depth; - } - gte_ws_x_squash(&num, &den); - focal_x = s_yaw_focal_x * (float)scale; - focal_y = num > 0 && den > 0 ? focal_x * (float)den / (float)num : focal_x; - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, t->shade_fbo); - glViewport(0, 0, lw, lh); - glDisable(GL_BLEND); - glDisable(GL_STENCIL_TEST); - glDisable(GL_DEPTH_TEST); - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - p_glActiveTexture(PSXGL_TEXTURE0 + 1); - glBindTexture(GL_TEXTURE_2D, color); - p_glActiveTexture(PSXGL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, depth); - p_glUseProgram(s_ssao_prog); - p_glUniform1i(s_ssao_uDepth, 0); - p_glUniform1i(s_ssao_uTex, 1); - p_glUniform4f(s_ssao_uUvRect, u0, v0, u1, v1); - p_glUniform4f(s_ssao_uTexel, 1.0f / (float)w, 1.0f / (float)h, (float)s_ssao, 0.0f); - p_glUniform4f(s_ssao_uProj, focal_x, focal_y, (u0 + u1) * 0.5f * (float)w, (v0 + v1) * 0.5f * (float)h); - p_glUniform4f(s_ssao_uAo, s_ssao_radius, s_ssao_strength, 24.0f * (float)scale, (float)scale); - p_glUniform4f(s_ssao_uFade, s_ssao_fade_from, s_ssao_fade_to, s_ssao_tilt, 0.0f); - p_glBindVertexArray(vao); - glDrawArrays(GL_TRIANGLES, 0, 3); - - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - glViewport(lx, ly, lw, lh); - p_glActiveTexture(PSXGL_TEXTURE0 + 1); - glBindTexture(GL_TEXTURE_2D, t->shade); - p_glActiveTexture(PSXGL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, color); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, linear ? GL_LINEAR : GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, linear ? GL_LINEAR : GL_NEAREST); - p_glUseProgram(s_ssao_mix_prog); - p_glUniform1i(s_ssao_mix_uTex, 0); - p_glUniform1i(s_ssao_mix_uShade, 1); - p_glUniform4f(s_ssao_mix_uUvRect, u0, v0, u1, v1); - p_glUniform4f(s_ssao_mix_uSpan, (float)lx, (float)ly, (float)s_ssao, 0.0f); - glDrawArrays(GL_TRIANGLES, 0, 3); - p_glBindVertexArray(0); - p_glUseProgram(0); - return 1; -} - -#ifndef PSX_DISABLE_FRAME_INTERPOLATION -/* The depth of each captured real frame, for the presenter's thread to shade it by. */ -static GLuint s_interp_depth[3], s_interp_depth_fbo; -static int s_interp_depth_w, s_interp_depth_h; -static int s_temp_scene_depth; /* the replay is drawing its scene, whose opaque fragments alone leave depth while the shading is on */ - -static void interp_capture_depth(GLuint fbo, int x, int y, int h, int pw, int ph, int dst) { - if (!s_ssao || !s_geometry_correction) return; - if (!s_interp_depth[0] || s_interp_depth_w != pw || s_interp_depth_h != ph) { - for (int i = 0; i < 3; i++) { - if (s_interp_depth[i]) glDeleteTextures(1, &s_interp_depth[i]); - s_interp_depth[i] = ssao_depth_texture(pw, ph); - } - s_interp_depth_w = pw; - s_interp_depth_h = ph; - } - if (!s_interp_depth_fbo) p_glGenFramebuffers(1, &s_interp_depth_fbo); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, s_interp_depth_fbo); - p_glFramebufferTexture2D(PSXGL_DRAW_FRAMEBUFFER, PSXGL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, s_interp_depth[dst], 0); - glDrawBuffer(GL_NONE); - glDisable(GL_SCISSOR_TEST); - p_glBlitFramebuffer(x * s_scale, y * s_scale, x * s_scale + pw, y * s_scale + ph, 0, 0, pw, ph, GL_DEPTH_BUFFER_BIT, GL_NEAREST); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); - for (int i = 0; i < WIDE_MAX_SURF; i++) - if (s_wide_fbo[i] == fbo) s_wide_depth_shown[i] |= wide_depth_rows(y, h); -} -#endif - -#ifndef PSX_DISABLE_FRAME_INTERPOLATION -#include "gpu_gl_temporal.c.inc" - -static void interp_reset_history_unlocked(void) { - temporal_reset(); - s_interp_valid = 0; - s_interp_w = s_interp_h = 0; - s_interp_start = s_interp_last_capture = 0; - s_interp_duration = 1; - s_interp_source_path = -1; -} - -static void interp_reset_history(void) { - if (s_interp_mutex) SDL_LockMutex(s_interp_mutex); - interp_reset_history_unlocked(); - if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); -} - -void gl_renderer_set_interpolation(int enabled, double host_hz, double target_hz, - int blend_mode) { - double effective_hz = target_hz < 0.0 - ? -1.0 - : (target_hz >= 30.0 ? target_hz : host_hz); - int active = (enabled && - (blend_mode != 2 || s_geometry_correction) && - (effective_hz < 0.0 || effective_hz >= 30.0)) ? 1 : 0; - const char *diag = getenv("PSX_GL_INTERP_DIAG"); - s_interp_diag = diag && diag[0] && diag[0] != '0'; - if (active && !s_interp_ctx && s_ctx) { - if (!s_interp_mutex) s_interp_mutex = SDL_CreateMutex(); - SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 1); - s_interp_ctx = SDL_GL_CreateContext(s_win); - SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 0); - SDL_GL_MakeCurrent(s_win, s_ctx); - /* The wall-clock pacer and interpolation scheduler own cadence. Keeping - * driver vsync on the main context adds a 6-11 ms block whenever an - * FMV temporarily suspends interpolation and the main context presents. */ - SDL_GL_SetSwapInterval(0); - if (s_interp_ctx && s_interp_mutex) { - SDL_AtomicSet(&s_temp_worker_ready, 0); - SDL_AtomicSet(&s_interp_thread_run, 1); - s_interp_thread = SDL_CreateThread(interp_thread_main, - "psx-gl-interp", NULL); - } - if (!s_interp_thread) { - SDL_AtomicSet(&s_interp_thread_run, 0); - if (s_interp_ctx) SDL_GL_DeleteContext(s_interp_ctx); - s_interp_ctx = NULL; - active = 0; - } - } - if (active && s_interp_thread) { - while (!SDL_AtomicGet(&s_temp_worker_ready)) SDL_Delay(1); - if (blend_mode == 2 && SDL_AtomicGet(&s_temp_worker_ready) < 0) active = 0; - } - if (s_interp_mutex) SDL_LockMutex(s_interp_mutex); - if (active != s_interp_enabled || blend_mode != s_interp_blend_mode) interp_reset_history_unlocked(); - s_interp_enabled = active; - s_interp_host_hz = host_hz; - s_interp_target_hz = active ? effective_hz : 0.0; - s_interp_blend_mode = blend_mode == 2 ? 2 : blend_mode == 1 ? 1 : 0; - if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); - /* Enabling interpolation forces immediate swaps; disabling restores the - * last user-requested interval kept in s_swap_interval. */ - apply_swap_interval(); - if (active && effective_hz < 0.0) - fprintf(stdout, "psxrecomp: GL frame interpolation enabled: uncapped " - "target on %.1f Hz display (%s)\n", host_hz, - s_interp_blend_mode == 2 ? "geometry inter-frame" : s_interp_blend_mode ? "motion-adaptive" : "linear"); - else if (active) - fprintf(stdout, "psxrecomp: GL frame interpolation enabled: %.1f FPS " - "target on %.1f Hz display (%s)\n", effective_hz, host_hz, - s_interp_blend_mode == 2 ? "geometry inter-frame" : s_interp_blend_mode ? "motion-adaptive" : "linear"); - else - fprintf(stdout, "psxrecomp: GL frame interpolation disabled (host %.1f Hz)\n", host_hz); -} - -static void interp_set_suspended(int *reason, int suspended) { - suspended = suspended ? 1 : 0; - if (s_interp_mutex) SDL_LockMutex(s_interp_mutex); - const int was_suspended = s_interp_suspended; - *reason = suspended; - s_interp_suspended = - s_interp_content_suspended || s_interp_host_ui_suspended; - if (was_suspended != s_interp_suspended) interp_reset_history_unlocked(); - if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); -} - -void gl_renderer_set_interpolation_suspended(int suspended) { - interp_set_suspended(&s_interp_content_suspended, suspended); -} - -/* Host UI suspension is independent of content/FMV suspension. Keeping two - * reasons prevents closing a menu during an FMV from briefly waking the - * interpolation thread on the shared window. */ -void gl_renderer_set_host_ui_suspended(int suspended) { - interp_set_suspended(&s_interp_host_ui_suspended, suspended); -} - -void gl_renderer_interpolation_diag(int *enabled, int *suspended, - int *history_frames, - double *host_hz, double *target_hz, - uint64_t *swaps) { - if (s_interp_mutex) SDL_LockMutex(s_interp_mutex); - if (enabled) *enabled = s_interp_enabled; - if (suspended) *suspended = s_interp_suspended; - if (history_frames) *history_frames = s_interp_valid; - if (host_hz) *host_hz = s_interp_host_hz; - if (target_hz) *target_hz = s_interp_target_hz; - if (swaps) *swaps = s_interp_swaps; - if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); -} - -void gl_renderer_geometry_interpolation_diag(uint64_t out[6]) { - if (!out) return; - if (s_interp_mutex) SDL_LockMutex(s_interp_mutex); - out[0] = s_temp_published; - out[1] = s_temp_replays; - out[2] = s_temp_fallbacks; - out[3] = (uint64_t)s_temp_last_matched; - out[4] = (uint64_t)s_temp_last_world; - out[5] = (uint64_t)s_temp_last_ready; - if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); -} - -/* PSX_GL_INTERP_DIAG: spacing of presents in ms, and of the phase they were - * drawn at (captures plus the alpha drawn) in game frames per present. A step - * near 0 drew one phase twice, a step near 1 skipped most of an interval. - * Pictures are not compared: a replay that moves nothing still advances. */ -typedef struct { double sum, squares, low, high; int count; } InterpSpread; -static InterpSpread s_interp_gap, s_interp_step, s_interp_hold, s_interp_work; -static InterpSpread s_interp_wake, s_interp_wait, s_interp_draw; -static int s_interp_holds, s_interp_jumps, s_interp_late, s_interp_settle; -static uint64_t s_interp_last_swap; -static double s_interp_last_phase; -static void interp_spread_add(InterpSpread *s, double value) { - if (!s->count || value < s->low) s->low = value; - if (!s->count || value > s->high) s->high = value; - s->sum += value; s->squares += value * value; s->count++; -} -static double interp_spread_deviation(const InterpSpread *s) { - const double mean = s->count ? s->sum / s->count : 0.0; - const double variance = s->count ? s->squares / s->count - mean * mean : 0.0; - return variance > 0.0 ? sqrt(variance) : 0.0; -} - -/* Copy a stable display image out of the mutable VRAM/wide render target. - * Returns true once both previous and current images are available. */ -static int interp_capture(GLuint fbo, int x, int y, int w, int h, - int linear, int force_4_3, int source_path) { - if (!s_interp_enabled || s_interp_suspended || !fbo || w <= 0 || h <= 0) return 0; - uint64_t began = 0; - int under_lock = 0; -#ifndef PSX_NO_DEBUG_TOOLS - { /* A/B: PSX_GL_TEMPORAL_MATCH_LOCKED=1 matches under the lock as before. */ - static int cached = -1; - if (cached < 0) { const char *e = getenv("PSX_GL_TEMPORAL_MATCH_LOCKED"); cached = e && e[0] == '1'; } - under_lock = cached; - } -#endif - if (s_interp_blend_mode == 2 && !under_lock) { - began = SDL_GetPerformanceCounter(); - temporal_prepare(fbo, y, w, h); - if (s_interp_diag) - began = SDL_GetPerformanceCounter() - began; - } - SDL_LockMutex(s_interp_mutex); - const uint64_t locked = SDL_GetPerformanceCounter(); - if (s_interp_blend_mode == 2 && under_lock) { - began = SDL_GetPerformanceCounter(); - temporal_prepare(fbo, y, w, h); - began = SDL_GetPerformanceCounter() - began; - } - if (s_interp_diag && s_interp_blend_mode == 2) - interp_spread_add(&s_interp_work, (double)began * 1000.0 / - (double)SDL_GetPerformanceFrequency()); - /* The presentation context may still have a draw queued which samples one - * of the shared history textures. Order this context's next allocation or - * copy after that draw before recycling a texture. glWaitSync keeps the - * dependency on the GPU; unlike glFinish it does not stall the guest CPU. */ - if (s_interp_draw_fence) { - p_glWaitSync(s_interp_draw_fence, 0, PSXGL_TIMEOUT_IGNORED); - p_glDeleteSync(s_interp_draw_fence); - s_interp_draw_fence = NULL; - } - int pw = w * s_scale, ph = h * s_scale; - if (pw != s_interp_w || ph != s_interp_h || - source_path != s_interp_source_path || force_4_3 != s_interp_force_4_3) { - temporal_reset(); - s_interp_valid = 0; - s_interp_w = pw; s_interp_h = ph; - s_interp_prev = s_interp_cur = 0; - for (int i = 0; i < 3; i++) { - glBindTexture(GL_TEXTURE_2D, s_interp_tex[i]); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, pw, ph, 0, - GL_RGBA, GL_UNSIGNED_BYTE, NULL); - } - } - - int dst = 0; - if (s_interp_valid == 1) dst = s_interp_cur == 0 ? 1 : 0; - else if (s_interp_valid >= 2) dst = s_interp_prev; - if (s_interp_fence[dst]) { - p_glDeleteSync(s_interp_fence[dst]); - s_interp_fence[dst] = NULL; - } - - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, fbo); - glBindTexture(GL_TEXTURE_2D, s_interp_tex[dst]); - glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, - x * s_scale, y * s_scale, pw, ph); - wide_depth_shown(fbo, y, h); - if (s_interp_blend_mode == 2) interp_capture_depth(fbo, x, y, h, pw, ph, dst); - if (s_interp_blend_mode == 2) temporal_publish(fbo, y, h); -#ifndef PSX_NO_DEBUG_TOOLS - if (s_interp_blend_mode == 2 && s_temp_curr.active) - temporal_dump_frame(x * s_scale, y * s_scale, pw, ph, -1.0f, - s_temp_curr.mesh_stats.departed_faces); - if (s_interp_blend_mode == 2 && temporal_edge_diag() && s_temp_curr.active) { - const int left = temporal_edge_gap((x + 1) * s_scale, y * s_scale, ph, s_temp_curr.clear); - const int right = temporal_edge_gap((x + w - 2) * s_scale, y * s_scale, ph, s_temp_curr.clear); - fprintf(stdout, "psxrecomp: edge real %llu %d %d %d\n", - (unsigned long long)s_temp_published, left, right, ph); - if ((left > right ? left : right) * 33 > ph) - temporal_dump_edge(x * s_scale, y * s_scale, pw, ph); - } -#endif - s_interp_fence[dst] = p_glFenceSync(PSXGL_SYNC_GPU_COMMANDS_COMPLETE, 0); - glFlush(); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - - uint64_t now = SDL_GetPerformanceCounter(); - uint64_t freq = SDL_GetPerformanceFrequency(); - if (s_interp_valid > 0 && s_interp_last_capture && now > s_interp_last_capture) { - uint64_t d = now - s_interp_last_capture; - uint64_t lo = freq / 240u, hi = freq / 10u; - if (d < lo) d = lo; - if (d > hi) d = hi; - s_interp_duration = d; - } - s_interp_last_capture = now; - s_interp_start = now; - if (s_interp_valid == 0) { - s_interp_cur = dst; - s_interp_valid = 1; - } else { - s_interp_prev = s_interp_cur; - s_interp_cur = dst; - s_interp_valid = 2; - } - s_interp_scale = s_scale; - s_interp_linear = linear; - s_interp_force_4_3 = force_4_3; - s_interp_source_path = source_path; - s_interp_captures++; - int ready = s_interp_valid >= 2; - if (s_interp_diag) - interp_spread_add(&s_interp_hold, (double)(SDL_GetPerformanceCounter() - locked) * 1000.0 / - (double)SDL_GetPerformanceFrequency()); - SDL_UnlockMutex(s_interp_mutex); - return ready; -} - -/* Returns the alpha of the picture it drew, which is 1 when the replay fell back. */ -static float interp_draw_quad(float alpha, int lx, int ly, int lw, int lh) { - if (s_interp_blend_mode == 2) { - temporal_verify_endpoint(lx, ly, lw, lh); -#ifndef PSX_NO_DEBUG_TOOLS - alpha = temporal_dump_alpha(alpha); -#endif - if (alpha < 1.0f && temporal_present(alpha, lx, ly, lw, lh)) return alpha; - alpha = 1.0f; /* Unsafe/incomplete geometry: current frame, never a crossfade. */ - } - int prev = s_interp_prev, curr = s_interp_cur; - if (s_interp_fence[prev]) - p_glWaitSync(s_interp_fence[prev], 0, PSXGL_TIMEOUT_IGNORED); - if (s_interp_fence[curr]) - p_glWaitSync(s_interp_fence[curr], 0, PSXGL_TIMEOUT_IGNORED); - if (s_interp_blend_mode == 2 && alpha >= 1.0f && s_interp_depth[curr] && - s_interp_depth_w == s_interp_w && s_interp_depth_h == s_interp_h) { - const float ix = 0.5f * s_interp_scale / s_interp_w, iy = 0.5f * s_interp_scale / s_interp_h; - if (ssao_draw(&s_ssao_thread, s_interp_thread_vao, 0, s_interp_depth[curr], s_interp_tex[curr], s_interp_w, s_interp_h, - ix, iy, 1 - ix, 1 - iy, s_interp_scale, s_interp_linear, lx, ly, lw, lh)) - return alpha; - } - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - glViewport(lx, ly, lw, lh); - p_glActiveTexture(PSXGL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, s_interp_tex[prev]); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, s_interp_linear ? GL_LINEAR : GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, s_interp_linear ? GL_LINEAR : GL_NEAREST); - p_glActiveTexture(PSXGL_TEXTURE0 + 1); - glBindTexture(GL_TEXTURE_2D, s_interp_tex[curr]); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, s_interp_linear ? GL_LINEAR : GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, s_interp_linear ? GL_LINEAR : GL_NEAREST); - p_glUseProgram(s_interp_prog); - p_glUniform1i(s_interp_uPrev, 0); - p_glUniform1i(s_interp_uCurr, 1); - p_glUniform1f(s_interp_uAlpha, alpha); - p_glUniform1i(s_interp_uBlendMode, s_interp_blend_mode); - if (s_interp_blend_mode == 2) { - const float ix=0.5f*s_interp_scale/s_interp_w, iy=0.5f*s_interp_scale/s_interp_h; - p_glUniform4f(s_interp_uUvRect,ix,iy,1-ix,1-iy); - } else p_glUniform4f(s_interp_uUvRect, 0.f, 0.f, 1.f, 1.f); - p_glBindVertexArray(s_interp_thread_vao); - glDrawArrays(GL_TRIANGLES, 0, 3); - p_glBindVertexArray(0); - p_glUseProgram(0); - p_glActiveTexture(PSXGL_TEXTURE0); - return alpha; -} - -/* What reaches the screen, for a report: each swapped picture is read back at the asked width (the window's at most) with - * its time and kept in memory, then written as PPM files and a list when the count is reached. Returns 0 when refused. */ -static char s_shown_dir[512]; -static int s_shown_skip = 0, s_shown_left = 0, s_shown_count = 0, s_shown_w = 0, s_shown_h = 0, s_shown_width = 640; -static uint8_t *s_shown_pixels = NULL; -static struct ShownMeta { uint64_t tick; float alpha; int kind; int placed[7]; } *s_shown_meta = NULL; - -int gl_renderer_record_shown(const char *folder, int skip, int count, int width) { - if (!folder || !folder[0] || strlen(folder) >= sizeof s_shown_dir || count <= 0 || count > 2000 || width < 160 || - width > 3840 || s_shown_left > 0) return 0; - snprintf(s_shown_dir, sizeof s_shown_dir, "%s", folder); - s_shown_width = width; - s_shown_skip = skip > 0 ? skip : 0; - s_shown_count = 0; - s_shown_w = s_shown_h = 0; - s_shown_left = count; - return 1; -} -int gl_renderer_recording_shown(void) { return s_shown_left > 0; } - -static void shown_write(void) { - char path[640]; - FILE *list; - snprintf(path, sizeof path, "%s/shown.csv", s_shown_dir); - list = fopen(path, "wb"); - if (list) fprintf(list, "index,kind,alpha,microseconds,faces,camera_samples,camera_inliers,carried,held,conflicting,departed\n"); - for (int i = 0; i < s_shown_count; ++i) { - FILE *file; - snprintf(path, sizeof path, "%s/shown_%04d.ppm", s_shown_dir, i); - file = fopen(path, "wb"); - if (file) { - fprintf(file, "P6\n%d %d\n255\n", s_shown_w, s_shown_h); - for (int y = s_shown_h - 1; y >= 0; --y) - fwrite(s_shown_pixels + ((size_t)i * s_shown_h + y) * s_shown_w * 3, 1, (size_t)s_shown_w * 3, file); - fclose(file); - } - if (list) - fprintf(list, "%d,%s,%.3f,%.0f,%d,%d,%d,%d,%d,%d,%d\n", i, s_shown_meta[i].kind ? "between" : "frame", s_shown_meta[i].alpha, - (double)(s_shown_meta[i].tick - s_shown_meta[0].tick) * 1e6 / (double)SDL_GetPerformanceFrequency(), - s_shown_meta[i].placed[0], s_shown_meta[i].placed[1], s_shown_meta[i].placed[2], s_shown_meta[i].placed[3], - s_shown_meta[i].placed[4], s_shown_meta[i].placed[5], s_shown_meta[i].placed[6]); - } - if (list) fclose(list); - free(s_shown_pixels); - free(s_shown_meta); - s_shown_pixels = NULL; - s_shown_meta = NULL; -} - -/* Called with the finished picture in the back buffer, right before its swap. kind: 0 a game frame's own swap, 1 the - * in-between presenter's, whose alpha is the picture's place between the last two game frames (1 is the later one). */ -static void shown_capture(int kind, float alpha) { - int ww = 0, wh = 0; - GLint bound = 0; - GLuint fbo = 0, texture = 0; - const GLboolean scissor = glIsEnabled(GL_SCISSOR_TEST); - if (s_shown_left <= 0) return; - if (s_shown_skip > 0) { --s_shown_skip; return; } - gl_visible_size(&ww, &wh); - if (ww <= 0 || wh <= 0) return; - if (!s_shown_pixels) { - s_shown_w = s_shown_width < ww ? s_shown_width : ww; - s_shown_h = s_shown_w * wh / ww > 0 ? s_shown_w * wh / ww : 1; - if (s_shown_h > 0) { - s_shown_pixels = (uint8_t *)malloc((size_t)s_shown_left * s_shown_w * s_shown_h * 3); - s_shown_meta = (struct ShownMeta *)malloc((size_t)s_shown_left * sizeof *s_shown_meta); - } - if (!s_shown_pixels || !s_shown_meta) { - free(s_shown_pixels); - free(s_shown_meta); - s_shown_pixels = NULL; - s_shown_meta = NULL; - s_shown_left = 0; - return; - } - } - glGetIntegerv(GL_TEXTURE_BINDING_2D, &bound); - glGenTextures(1, &texture); - glBindTexture(GL_TEXTURE_2D, texture); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, s_shown_w, s_shown_h, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - p_glGenFramebuffers(1, &fbo); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, fbo); - p_glFramebufferTexture2D(PSXGL_DRAW_FRAMEBUFFER, PSXGL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - glReadBuffer(GL_BACK); - glDisable(GL_SCISSOR_TEST); - p_glBlitFramebuffer(0, 0, ww, wh, 0, 0, s_shown_w, s_shown_h, GL_COLOR_BUFFER_BIT, GL_LINEAR); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, fbo); - glPixelStorei(GL_PACK_ALIGNMENT, 1); - glReadPixels(0, 0, s_shown_w, s_shown_h, GL_RGB, GL_UNSIGNED_BYTE, - s_shown_pixels + (size_t)s_shown_count * s_shown_w * s_shown_h * 3); - glPixelStorei(GL_PACK_ALIGNMENT, 4); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - p_glDeleteFramebuffers(1, &fbo); - glBindTexture(GL_TEXTURE_2D, (GLuint)bound); - glDeleteTextures(1, &texture); - if (scissor) glEnable(GL_SCISSOR_TEST); - s_shown_meta[s_shown_count].tick = SDL_GetPerformanceCounter(); - s_shown_meta[s_shown_count].alpha = alpha; - s_shown_meta[s_shown_count].kind = kind; - memset(s_shown_meta[s_shown_count].placed, 0, sizeof s_shown_meta[s_shown_count].placed); -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - if (kind) { - int *placed = s_shown_meta[s_shown_count].placed; - placed[0] = s_temp_shown_faces; - placed[1] = s_temp_shown_stats.camera_samples; - placed[2] = s_temp_shown_stats.camera_inliers; - placed[3] = s_temp_shown_stats.reprojected_vertices; - placed[4] = s_temp_shown_stats.held_vertices; - placed[5] = s_temp_shown_stats.conflicting_vertices; - placed[6] = s_temp_shown_stats.departed_faces; - } -#endif - ++s_shown_count; - if (--s_shown_left == 0) shown_write(); -} - -static int interp_present(void) { - if (!s_ctx || !s_interp_enabled || s_interp_suspended || s_interp_valid < 2) return 0; - uint64_t now = SDL_GetPerformanceCounter(); - if (now <= s_interp_start || !s_interp_duration) return 0; - double a = (double)(now - s_interp_start) / (double)s_interp_duration; - /* Keep swapping at the host cadence after the blend completes. Holding - * alpha at one is visually identical to leaving the current image on the - * front buffer, but avoids an irregular 2/3-swap pattern on 120/144/165 Hz - * displays while the next 59.94 Hz guest frame is being produced. */ - if (a > 1.0) a = 1.0; - if (a < 0.0) a = 0.0; - - int ww = 0, wh = 0; gl_visible_size(&ww, &wh); - int lx, ly, lw, lh; - if (s_interp_force_4_3) - letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); - else - letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); - glDisable(GL_SCISSOR_TEST); - glViewport(0, 0, ww, wh); - if (lx != 0 || ly != 0 || lw != ww || lh != wh) { - glClearColor(0.f, 0.f, 0.f, 1.f); - glClear(GL_COLOR_BUFFER_BIT); - } - const float shown = interp_draw_quad((float)a, lx, ly, lw, lh); - if (s_interp_draw_fence) p_glDeleteSync(s_interp_draw_fence); - s_interp_draw_fence = p_glFenceSync(PSXGL_SYNC_GPU_COMMANDS_COMPLETE, 0); - glFlush(); - pres_record(GL_PRES_INTERP, 0, 0, s_interp_w, s_interp_h, - lx, ly, lw, lh); - shown_capture(1, shown); - SDL_GL_SwapWindow(s_win); - s_interp_swaps++; - if (s_interp_diag) { - const uint64_t swapped = SDL_GetPerformanceCounter(); - const double phase = (double)s_interp_captures + shown; - if (s_interp_settle > 0) --s_interp_settle; - else if (s_interp_last_swap) { - const double step = phase - s_interp_last_phase; - interp_spread_add(&s_interp_gap, (double)(swapped - s_interp_last_swap) * 1000.0 / - (double)SDL_GetPerformanceFrequency()); - interp_spread_add(&s_interp_step, step); - s_interp_late += (double)(swapped - s_interp_last_swap) * s_interp_target_hz > - 1.25 * (double)SDL_GetPerformanceFrequency(); - s_interp_holds += step < 0.05; - s_interp_jumps += step > 0.9; - } - s_interp_last_swap = swapped; - s_interp_last_phase = phase; - } - return 1; -} - -static int interp_thread_main(void *opaque) { - (void)opaque; - if (SDL_GL_MakeCurrent(s_win, s_interp_ctx) != 0) { - SDL_AtomicSet(&s_temp_worker_ready, -1); - return -1; - } - SDL_GL_SetSwapInterval(0); /* host-period scheduler owns cadence */ - p_glGenVertexArrays(1, &s_interp_thread_vao); - SDL_AtomicSet(&s_temp_worker_ready, temporal_worker_init() ? 1 : -1); - uint64_t freq = SDL_GetPerformanceFrequency(); - uint64_t deadline = SDL_GetPerformanceCounter(); - uint64_t diag_start = deadline, diag_swaps = 0, diag_captures = 0; - - while (SDL_AtomicGet(&s_interp_thread_run)) { - SDL_LockMutex(s_interp_mutex); - double hz = s_interp_target_hz; - SDL_UnlockMutex(s_interp_mutex); - int uncapped = hz < 0.0; - uint64_t now = SDL_GetPerformanceCounter(); - if (!uncapped) { - if (hz < 30.0) hz = 60.0; - uint64_t period = (uint64_t)((double)freq / hz); - if (!period) period = 1; - deadline += period; - if (now > deadline + period * 4u) deadline = now + period; - for (;;) { - now = SDL_GetPerformanceCounter(); - if (now >= deadline) break; - uint64_t remain = deadline - now; - uint32_t ms = (uint32_t)((remain * 1000u) / - (freq ? freq : 1u)); - if (ms > 1) SDL_Delay(ms - 1); - } - while (SDL_GetPerformanceCounter() < deadline) {} - now = SDL_GetPerformanceCounter(); - if (s_interp_diag && !s_interp_settle) - interp_spread_add(&s_interp_wake, (double)(now - deadline) * 1000.0 / (double)freq); - } else { - deadline = now; - } - - SDL_LockMutex(s_interp_mutex); - int presented = 0; - const uint64_t entered = SDL_GetPerformanceCounter(); - if (SDL_AtomicGet(&s_interp_thread_run) && s_interp_enabled) - presented = interp_present(); - if (s_interp_diag) { - interp_spread_add(&s_interp_wait, (double)(entered - now) * 1000.0 / (double)freq); - interp_spread_add(&s_interp_draw, (double)(SDL_GetPerformanceCounter() - entered) * 1000.0 / - (double)freq); - } - if (s_interp_diag && now - diag_start >= freq * 5u) { - double seconds = (double)(now - diag_start) / (double)freq; - fprintf(stdout, "psxrecomp: GL interpolation cadence: " - "%.2f captures/s, %.2f presents/s\n", - (double)(s_interp_captures - diag_captures) / seconds, - (double)(s_interp_swaps - diag_swaps) / seconds); - if (s_interp_gap.count) { - fprintf(stdout, "psxrecomp: GL interpolation pacing: present gap %.3f ms " - "(deviation %.3f, %.3f to %.3f), phase step %.3f frames " - "(deviation %.3f), %d repeated and %d skipped phases, %d late in %d presents\n", - s_interp_gap.sum / s_interp_gap.count, interp_spread_deviation(&s_interp_gap), - s_interp_gap.low, s_interp_gap.high, - s_interp_step.sum / s_interp_step.count, interp_spread_deviation(&s_interp_step), - s_interp_holds, s_interp_jumps, s_interp_late, s_interp_gap.count); - if (s_interp_hold.count) - fprintf(stdout, "psxrecomp: GL interpolation lock: a capture holds it %.3f ms " - "(up to %.3f), matching takes %.3f ms (up to %.3f)\n", - s_interp_hold.sum / s_interp_hold.count, s_interp_hold.high, - s_interp_work.count ? s_interp_work.sum / s_interp_work.count : 0.0, - s_interp_work.high); - fprintf(stdout, "psxrecomp: GL interpolation presenter: wakes %.3f ms late (up to %.3f), " - "waits %.3f ms for the lock (up to %.3f), draws and swaps in %.3f ms (up to %.3f)\n", - s_interp_wake.count ? s_interp_wake.sum / s_interp_wake.count : 0.0, s_interp_wake.high, - s_interp_wait.count ? s_interp_wait.sum / s_interp_wait.count : 0.0, s_interp_wait.high, - s_interp_draw.count ? s_interp_draw.sum / s_interp_draw.count : 0.0, s_interp_draw.high); - memset(&s_interp_wake, 0, sizeof(s_interp_wake)); - memset(&s_interp_wait, 0, sizeof(s_interp_wait)); - memset(&s_interp_draw, 0, sizeof(s_interp_draw)); - memset(&s_interp_hold, 0, sizeof(s_interp_hold)); - memset(&s_interp_work, 0, sizeof(s_interp_work)); - memset(&s_interp_gap, 0, sizeof(s_interp_gap)); - memset(&s_interp_step, 0, sizeof(s_interp_step)); - s_interp_holds = s_interp_jumps = s_interp_late = 0; - s_interp_settle = 8; /* the report itself delays the next presents */ - } - if (s_interp_blend_mode == 2) { - fprintf(stdout, "psxrecomp: GL geometry interpolation: %llu scenes, " - "%llu redraws, %llu fallback captures, %d/%d matched world triangles\n", - (unsigned long long)s_temp_published, (unsigned long long)s_temp_replays, - (unsigned long long)s_temp_fallbacks, s_temp_curr.matched, s_temp_curr.world); - fprintf(stdout, "psxrecomp: GL geometry mesh: %d moving triangles, " - "%d seeded / %d shared vertices, %d propagated corners, " - "%d conflicts, %d held vertices, %d anchored junctions, " - "camera %d of %d corners, %d reprojected vertices, %d departed faces, " - "%d welded corners, %d twin faces\n", - s_temp_curr.moving, s_temp_curr.mesh_stats.seeded_vertices, - s_temp_curr.mesh_stats.shared_vertices, - s_temp_curr.mesh_stats.propagated_vertices, - s_temp_curr.mesh_stats.conflicting_vertices, - s_temp_curr.mesh_stats.held_vertices, - s_temp_curr.mesh_stats.anchored_junctions, - s_temp_curr.mesh_stats.camera_inliers, - s_temp_curr.mesh_stats.camera_samples, - s_temp_curr.mesh_stats.reprojected_vertices, - s_temp_curr.mesh_stats.departed_faces, - s_temp_curr.mesh_stats.welded_corners, - s_temp_curr.mesh_stats.twin_faces); - } - fflush(stdout); - diag_start = now; - diag_captures = s_interp_captures; - diag_swaps = s_interp_swaps; - } - SDL_UnlockMutex(s_interp_mutex); - if (uncapped && !presented) SDL_Delay(1); - } - p_glBindVertexArray(0); - SDL_GL_MakeCurrent(s_win, NULL); - return 0; -} - -#else -/* Retain the framework ABI while the experimental presenter is compiled out. - * No history, capture, shared context, worker or diagnostic output is created. */ -void gl_renderer_set_interpolation(int enabled, double host_hz, double target_hz, - int blend_mode) { - (void)enabled; (void)target_hz; (void)blend_mode; - fprintf(stdout, "psxrecomp: GL frame interpolation disabled (build; host %.1f Hz)\n", - host_hz); -} -void gl_renderer_set_interpolation_suspended(int suspended) { (void)suspended; } -void gl_renderer_set_hidden_rows(int rows) { (void)rows; } -void gl_renderer_set_host_ui_suspended(int suspended) { (void)suspended; } -void gl_renderer_interpolation_diag(int *enabled, int *suspended, - int *history_frames, double *host_hz, - double *target_hz, uint64_t *swaps) { - if (enabled) *enabled = 0; - if (suspended) *suspended = 0; - if (history_frames) *history_frames = 0; - if (host_hz) *host_hz = 0.0; - if (target_hz) *target_hz = 0.0; - if (swaps) *swaps = 0; -} -void gl_renderer_geometry_interpolation_diag(uint64_t out[6]) { - if (out) memset(out, 0, 6 * sizeof(*out)); -} -#endif - -static void present_target_quad(GLuint tex, int tex_w, int tex_h, - int x, int y, int w, int h, int linear, - int lx, int ly, int lw, int lh) { - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - glViewport(lx, ly, lw, lh); - p_glActiveTexture(PSXGL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, tex); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, linear ? GL_LINEAR : GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, linear ? GL_LINEAR : GL_NEAREST); - p_glUseProgram(s_present_prog); - p_glUniform1i(s_present_uTex, 0); - /* Half-texel inset: with GL_LINEAR, corner-mapped UVs make the outermost - * dest pixels blend the border texel with VRAM outside the content rect - * (visible edge stripe with AA on). Center-mapped UVs keep edge samples - * inside the rect; interior sampling is unchanged. */ - p_glUniform4f(s_present_uUvRect, - ((float)x + 0.5f) / (float)tex_w, ((float)y + 0.5f) / (float)tex_h, - ((float)(x + w) - 0.5f) / (float)tex_w, ((float)(y + h) - 0.5f) / (float)tex_h); - p_glBindVertexArray(s_present_vao); - glDrawArrays(GL_TRIANGLES, 0, 3); - p_glBindVertexArray(0); - p_glUseProgram(0); -} - -void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, - int force_4_3) { - if (!s_ctx || !s_raster_ok) return; - flush_flat_batch(); - flush_tex_batch(); - flush_cpu_upload(); - if (s_force_present_remaining <= 0 && - s_last_present_path == GL_PRES_VRAM && - s_last_dx == disp_x && s_last_dy == disp_y && - s_last_dw == w && s_last_dh == h && - !present_dirty_test(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1) && - !psx_host_ui_gl_visible()) { - gl_perf_present_enter(); - gl_perf_present_exit(0); - return; - } - gl_perf_present_enter(); /* per-frame backdrop-phase reset + dbg snapshot live in here */ - int ww = 0, wh = 0; gl_visible_size(&ww, &wh); - int lx, ly, lw, lh; - if (force_4_3) - letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); - else - letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); - /* No short-band adjustment on the 15-bit FBO path: a game's native short - * display mode (e.g. 216/224-line menus) must fill the rect as before. - * The FMV band fix lives in the depth24/CPU present paths only. */ - - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); - glDisable(GL_SCISSOR_TEST); - glViewport(0, 0, ww, wh); - if (lx != 0 || ly != 0 || lw != ww || lh != wh) { - glClearColor(0.f,0.f,0.f,1.f); glClear(GL_COLOR_BUFFER_BIT); - } - int interp_pair = interp_capture(s_hr_fbo, disp_x, disp_y, w, h, - linear, force_4_3, GL_PRES_VRAM); - if (interp_pair) { - gl_perf_present_exit(0); - present_dirty_rect(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1, 0); - present_force_consumed(); - s_last_present_path = GL_PRES_VRAM; - s_last_dx = disp_x; s_last_dy = disp_y; s_last_dw = w; s_last_dh = h; - return; - } - present_target_quad(s_hr_tex, VRAM_W, VRAM_H, - disp_x, disp_y, w, h, linear, lx, ly, lw, lh); - pres_record(GL_PRES_VRAM, disp_x, disp_y, w, h, lx, ly, lw, lh); - render_host_ui_for_swap(ww, wh); - shown_capture(0, 1.0f); - latency_ring_mark(LAT_SWAP_BEGIN); - SDL_GL_SwapWindow(s_win); - latency_ring_mark(LAT_SWAP_END); - gl_perf_present_exit(0); - present_dirty_rect(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1, 0); - present_force_consumed(); - s_last_present_path = GL_PRES_VRAM; - s_last_dx = disp_x; s_last_dy = disp_y; s_last_dw = w; s_last_dh = h; - coh_record(GL_COH_PRESENT, disp_x, disp_y, disp_x + w - 1, disp_y + h - 1); -} - -/* Native-wide fast path: authoritatively copy the canonical 4:3 framebuffer - * into the wide surface's CENTRE columns [g_wide_off, g_wide_off+native_w) for - * the displayed Y band, so the per-prim mirror could skip every centre-only - * prim (the dominant native-wide GPU saving). The reveal margins were already - * produced by the mirror; this leaves them untouched. One FBO->FBO blit, - * x-translated by the reveal offset. No-op when s_wide_fast is off (then the - * mirror drew the full surface, as before). Shared by both present paths. */ -static void wide_blit_center(GLuint wide_fbo, int base_x, int disp_y, int disp_h) { - /* The correction surface is already a complete full mirror. Copying the - * canonical centre here would erase its subpixel/yaw-corrected gameplay. */ - if (!s_wide_fast || s_geometry_correction || g_wide_w <= 0) return; - int native_w = g_wide_w - 2 * g_wide_off; - if (native_w <= 0) return; - int S = s_scale; - (void)disp_y; (void)disp_h; - /* Copy the canonical framebuffer column into the wide surface CENTRE over the - * FULL VRAM height, not just the current display band [disp_y, disp_y+disp_h]. - * The wide surface holds BOTH vertical double-buffer bands (Ape flips - * display_y 0<->256), and the game's draw area / display band can differ per - * scene (the cityscape intro exposed rows outside disp_h). Copying the whole - * column makes the wide CENTRE bit-identical to what the full mirror would - * have drawn there for every band, so nothing the present reads is ever left - * black. The margins (x outside the centre) are untouched. */ - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, wide_fbo); - glDisable(GL_SCISSOR_TEST); - p_glBlitFramebuffer(base_x * S, 0, - (base_x + native_w) * S, VRAM_H * S, - g_wide_off * S, 0, - (g_wide_off + native_w) * S, VRAM_H * S, - GL_COLOR_BUFFER_BIT, GL_NEAREST); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); -} - -/* GPU-direct native-wide present: blit the displayed buffer's wide FBO straight - * to the window (no glReadPixels / glFinish CPU round-trip). The wide surface is - * g_wide_w wide × VRAM_H tall (at scale S); present its [0,g_wide_w] × [disp_y, - * disp_y+disp_h] region into the letterbox, V-flipped like present_vram (FBO y - * bottom-origin → window top). Returns 0 if there's no wide surface for base_x - * (caller falls back). disp_x is the displayed buffer base (the wide-surface key). */ -/* The wide surface shaded by its own depth. 0: nothing drawn, the plain present follows. */ -static int ssao_present(GLuint fbo, GLuint tex, int y, int h, int linear, int lx, int ly, int lw, int lh) { - if (!ssao_draw(&s_ssao_main, s_present_vao, fbo, 0, tex, g_wide_w * s_scale, VRAM_H * s_scale, - 0.5f / (float)g_wide_w, ((float)y + 0.5f) / (float)VRAM_H, - ((float)g_wide_w - 0.5f) / (float)g_wide_w, ((float)(y + h) - 0.5f) / (float)VRAM_H, - s_scale, linear, lx, ly, lw, lh)) - return 0; - for (int i = 0; i < WIDE_MAX_SURF; i++) - if (s_wide_fbo[i] == fbo) s_wide_depth_shown[i] |= wide_depth_rows(y, h); - return 1; -} - -void gl_renderer_set_mesh_view(int enabled) { s_mesh_view = enabled != 0; } -int gl_renderer_mesh_view(void) { return s_mesh_view; } -void gl_renderer_set_full_yaw(int enabled) { - if (s_raster_ok) { flush_flat_batch(); flush_tex_batch(); } - if ((enabled != 0) != s_geometry_full_yaw) interp_reset_history(); - s_geometry_full_yaw = enabled != 0; -} -void gl_renderer_set_gap_fill(int enabled) { - if (s_raster_ok) { flush_flat_batch(); flush_tex_batch(); } - const float width = enabled ? s_crack_width : 0.0f; - if ((enabled != 0) != s_grown_under || width != s_crack_fill) interp_reset_history(); - if (enabled && !mirror_depth()) memset(s_wide_depth_shown, 0xFF, sizeof s_wide_depth_shown); - s_grown_under = enabled != 0; - s_crack_fill = width; -} -int gl_renderer_gap_fill(void) { return s_grown_under || s_crack_fill > 0.0f; } - -void gl_renderer_set_world_depth(int enabled) { - if (s_raster_ok) { flush_flat_batch(); flush_tex_batch(); } - if ((enabled != 0) != s_world_depth) interp_reset_history(); - if (enabled && !mirror_depth()) memset(s_wide_depth_shown, 0xFF, sizeof s_wide_depth_shown); - s_world_depth = enabled != 0; -} -int gl_renderer_world_depth(void) { return s_world_depth; } - -static void ssao_mode(void) { - const int mode = s_ssao_view ? s_ssao_view + 1 : s_ssao_on; - if (mode && !s_ssao) memset(s_wide_depth_shown, 0xFF, sizeof s_wide_depth_shown); - s_ssao = mode; -} -void gl_renderer_set_ssao(int enabled) { - s_ssao_on = enabled != 0; - ssao_mode(); -} -void gl_renderer_set_ssao_view(int view) { - s_ssao_view = view >= 1 && view <= 4 ? view : 0; - ssao_mode(); -} -int gl_renderer_ssao_view(void) { return s_ssao_view; } -float gl_renderer_ssao_tilt(void) { return s_ssao_tilt; } -void gl_renderer_set_ssao_tilt(float tilt) { - if (tilt >= 0.0f && tilt < 0.95f) s_ssao_tilt = tilt; -} -void gl_renderer_set_ssao_shape(float radius, float strength) { - if (radius > 0.0f) s_ssao_radius = radius; - if (strength >= 0.0f) s_ssao_strength = strength; -} -float gl_renderer_ssao_radius(void) { return s_ssao_radius; } -/* The camera depths between which the game fades its world out: the shading goes with it. */ -void gl_renderer_set_ssao_fade(float from, float to) { s_ssao_fade_from = from; s_ssao_fade_to = to; } -float gl_renderer_ssao_strength(void) { return s_ssao_strength; } -int gl_renderer_ssao(void) { return s_ssao_on; } - -int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) { - if (!s_ctx || !s_raster_ok || g_wide_w <= 0) return 0; - GLuint fbo = 0, tex = 0; - for (int i = 0; i < WIDE_MAX_SURF; i++) - if (s_wide_fbo[i] && s_wide_base[i] == disp_x) { - fbo = s_wide_fbo[i]; tex = s_wide_tex[i]; break; - } - if (!fbo) return 0; - flush_flat_batch(); - flush_tex_batch(); - flush_cpu_upload(); - if (s_force_present_remaining <= 0 && - s_last_present_path == GL_PRES_WIDE && - s_last_dx == disp_x && s_last_dy == disp_y && - s_last_dw == g_wide_w && s_last_dh == disp_h && - !present_dirty_test(0, disp_y, VRAM_W - 1, disp_y + disp_h - 1) && - !psx_host_ui_gl_visible()) { - gl_perf_present_enter(); - gl_perf_present_exit(1); - return 1; - } - gl_perf_present_enter(); - int ww = 0, wh = 0; gl_visible_size(&ww, &wh); - int lx, ly, lw, lh; - letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); - wide_blit_center(fbo, disp_x, disp_y, disp_h); /* fast-path: authoritative centre */ - - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); - glDisable(GL_SCISSOR_TEST); - glViewport(0, 0, ww, wh); - if (lx != 0 || ly != 0 || lw != ww || lh != wh) { - glClearColor(0.f, 0.f, 0.f, 1.f); glClear(GL_COLOR_BUFFER_BIT); - } - int interp_pair = interp_capture(fbo, 0, disp_y, g_wide_w, disp_h, - linear, 0, GL_PRES_WIDE); - if (interp_pair) { - gl_perf_present_exit(1); - present_dirty_rect(0, disp_y, VRAM_W - 1, disp_y + disp_h - 1, 0); - present_force_consumed(); - s_last_present_path = GL_PRES_WIDE; - s_last_dx = disp_x; s_last_dy = disp_y; - s_last_dw = g_wide_w; s_last_dh = disp_h; - return 1; - } - wide_depth_shown(fbo, disp_y, disp_h); - if (!ssao_present(fbo, tex, disp_y, disp_h, linear, lx, ly, lw, lh)) - present_target_quad(tex, g_wide_w, VRAM_H, - 0, disp_y, g_wide_w, disp_h, linear, lx, ly, lw, lh); - pres_record(GL_PRES_WIDE, disp_x, disp_y, g_wide_w, disp_h, lx, ly, lw, lh); - render_host_ui_for_swap(ww, wh); - shown_capture(0, 1.0f); - latency_ring_mark(LAT_SWAP_BEGIN); - SDL_GL_SwapWindow(s_win); - latency_ring_mark(LAT_SWAP_END); - gl_perf_present_exit(1); - present_dirty_rect(0, disp_y, VRAM_W - 1, disp_y + disp_h - 1, 0); - present_force_consumed(); - s_last_present_path = GL_PRES_WIDE; - s_last_dx = disp_x; s_last_dy = disp_y; s_last_dw = g_wide_w; s_last_dh = disp_h; - coh_record(GL_COH_PRESENT, 0, disp_y, g_wide_w - 1, disp_y + disp_h - 1); - return 1; -} - -static const GpuRenderBackend GL_BACKEND = { - .name = "opengl", - .init = glb_init, .set_scale = glb_set_scale, .scale = glb_scale, - .set_texture_filter = glb_set_texture_filter, .texture_filter = glb_texture_filter, - .set_semi_transparency = glb_set_semi_transparency, .set_mask_bits = glb_set_mask_bits, - .set_texture_window = glb_set_texture_window, .set_color_modulation = glb_set_color_modulation, - .set_geometry_correction = glb_set_geometry_correction, - .set_world_triangle = glb_set_world_triangle, - .set_precise_triangle = glb_set_precise_triangle, - .set_unpinned_triangle = glb_set_unpinned_triangle, - .set_perspective_triangle = glb_set_perspective_triangle, -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - .set_temporal_depth_triangle = glb_set_temporal_depth_triangle, - .set_surface_depth_triangle = glb_set_surface_depth_triangle, - .set_temporal_identity_triangle = glb_set_temporal_identity_triangle, - .set_temporal_sprite = glb_set_temporal_sprite, -#else - .set_temporal_depth_triangle = NULL, - .set_temporal_identity_triangle = NULL, -#endif - .set_presentation_yaw = glb_set_presentation_yaw, - .fill_rect = glb_fill_rect, .copy_rect = glb_copy_rect, - .draw_flat_triangle = glb_draw_flat_triangle, .draw_gouraud_triangle = glb_draw_gouraud_triangle, - .draw_textured_triangle = glb_draw_textured_triangle, - .draw_shaded_textured_triangle = glb_draw_shaded_textured_triangle, - .draw_flat_rect = glb_draw_flat_rect, .draw_textured_rect = glb_draw_textured_rect, - .draw_textured_rect_scaled = glb_draw_textured_rect_scaled, - .draw_line = glb_draw_line, .draw_shaded_line = glb_draw_shaded_line, - .render_display = glb_render_display, .render_display_hires = glb_render_display_hires, - .vram_write = glb_vram_write, .vram_read = glb_vram_read, - .vram_transfer_in = glb_vram_transfer_in, .vram_transfer_out = glb_vram_transfer_out, - .set_draw_area = glb_set_draw_area, .get_draw_area = glb_get_draw_area, - .set_draw_offset = glb_set_draw_offset, - .wide_configure = glb_wide_configure, - .wide_set_target = glb_wide_set_target, - .wide_disable_target = glb_wide_disable_target, - .wide_clear = glb_wide_clear, - .wide_clear_margins = glb_wide_clear_margins, - .render_wide_display = glb_render_wide_display, - .wide_dump_full = glb_wide_dump_full, -}; - -const GpuRenderBackend *gl_backend_get(void) { return &GL_BACKEND; } diff --git a/psxrecomp-overlay/runtime/src/gpu_render.c b/psxrecomp-overlay/runtime/src/gpu_render.c deleted file mode 100644 index a6b96fe..0000000 --- a/psxrecomp-overlay/runtime/src/gpu_render.c +++ /dev/null @@ -1,232 +0,0 @@ -/* gpu_render.c — renderer facade / backend dispatch. - * - * gpu.c and main.cpp call the gr_* functions; this file forwards each to the - * selected backend's vtable. The software rasterizer is the default and the - * fallback. The OpenGL backend (gpu_gl_renderer.c) is selected via - * gr_set_backend(GR_BACKEND_OPENGL) and supplies its table through - * gl_backend_get(); if that returns NULL (GL unavailable / init failed) we - * stay on software so a misconfigured [video] renderer never bricks boot. - * - * The software backend table points straight at the existing sw_* functions, - * so the software path is byte-for-byte unchanged. */ - -#include "gpu_render.h" -#include "gpu_sw_renderer.h" -#include - -static const GpuRenderBackend SW_BACKEND = { - .name = "software", - .init = sw_renderer_init, - .set_scale = sw_renderer_set_scale, - .scale = sw_renderer_scale, - .set_texture_filter = sw_set_texture_filter, - .texture_filter = sw_texture_filter, - .set_semi_transparency = sw_set_semi_transparency, - .set_mask_bits = sw_set_mask_bits, - .set_texture_window = sw_set_texture_window, - .set_color_modulation = sw_set_color_modulation, - .set_geometry_correction = NULL, - .set_world_triangle = NULL, - .set_precise_triangle = sw_set_precise_triangle, - .set_perspective_triangle = NULL, - .set_temporal_depth_triangle = NULL, - .set_temporal_identity_triangle = NULL, - .set_presentation_yaw = NULL, - .fill_rect = sw_fill_rect, - .copy_rect = sw_copy_rect, - .draw_flat_triangle = sw_draw_flat_triangle, - .draw_gouraud_triangle = sw_draw_gouraud_triangle, - .draw_textured_triangle = sw_draw_textured_triangle, - .draw_shaded_textured_triangle = sw_draw_shaded_textured_triangle, - .draw_flat_rect = sw_draw_flat_rect, - .draw_textured_rect = sw_draw_textured_rect, - .draw_textured_rect_scaled = sw_draw_textured_rect_scaled, - .draw_line = sw_draw_line, - .draw_shaded_line = sw_draw_shaded_line, - .render_display = sw_render_display, - .render_display_hires = sw_render_display_hires, - .vram_write = sw_vram_write, - .vram_read = sw_vram_read, - .vram_transfer_in = sw_vram_transfer_in, - .vram_transfer_out = sw_vram_transfer_out, - .set_draw_area = sw_set_draw_area, - .get_draw_area = sw_get_draw_area, - .set_draw_offset = sw_set_draw_offset, - .wide_configure = sw_wide_configure, - .wide_set_target = sw_wide_set_target, - .wide_disable_target = sw_wide_disable_target, - .wide_clear = sw_wide_clear, - .wide_clear_margins = sw_wide_clear_margins, - .render_wide_display = sw_render_wide_display, - .wide_dump_full = sw_wide_dump_full, -}; - -/* Supplied by gpu_gl_renderer.c; returns NULL until the GL backend is ready. */ -extern const GpuRenderBackend *gl_backend_get(void); -/* Supplied by gpu_vk_renderer.c; returns NULL until the Vulkan backend is - * ready (init succeeded). When Vulkan isn't compiled in it returns NULL. */ -extern const GpuRenderBackend *vk_backend_get(void); - -static const GpuRenderBackend *g_b = &SW_BACKEND; -static GrBackend g_effective = GR_BACKEND_SOFTWARE; - -void gr_set_backend(GrBackend backend) { - if (backend == GR_BACKEND_OPENGL) { - const GpuRenderBackend *gl = gl_backend_get(); - if (gl) { - g_b = gl; - g_effective = GR_BACKEND_OPENGL; - fprintf(stdout, "psxrecomp: renderer = opengl (%s)\n", gl->name); - return; - } - fprintf(stdout, "psxrecomp: renderer = opengl requested but unavailable " - "— falling back to software\n"); - } else if (backend == GR_BACKEND_VULKAN) { - const GpuRenderBackend *vk = vk_backend_get(); - if (vk) { - g_b = vk; - g_effective = GR_BACKEND_VULKAN; - fprintf(stdout, "psxrecomp: renderer = vulkan (%s)\n", vk->name); - return; - } - fprintf(stdout, "psxrecomp: renderer = vulkan requested but unavailable " - "— falling back to software\n"); - } - g_b = &SW_BACKEND; - g_effective = GR_BACKEND_SOFTWARE; -} - -GrBackend gr_backend(void) { return g_effective; } - -/* ---- Dispatch wrappers (one line each; forward to the active backend) ---- */ -void gr_init(uint16_t *vram) { g_b->init(vram); } -void gr_set_scale(int scale) { g_b->set_scale(scale); } -int gr_scale(void) { return g_b->scale(); } -void gr_set_texture_filter(int bilinear) { g_b->set_texture_filter(bilinear); } -int gr_texture_filter(void) { return g_b->texture_filter(); } -void gr_set_semi_transparency(int e, int m) { g_b->set_semi_transparency(e, m); } -void gr_set_mask_bits(int s, int c) { g_b->set_mask_bits(s, c); } -void gr_set_texture_window(uint32_t raw) { g_b->set_texture_window(raw); } -void gr_set_color_modulation(int r, int g, int b, int raw) { g_b->set_color_modulation(r, g, b, raw); } -void gr_set_geometry_correction(int enabled) { - if (g_b->set_geometry_correction) g_b->set_geometry_correction(enabled); -} -void gr_set_world_triangle(int world) { - if (g_b->set_world_triangle) g_b->set_world_triangle(world); -} -void gr_set_precise_triangle(int enabled, - int32_t x0, int32_t y0, - int32_t x1, int32_t y1, - int32_t x2, int32_t y2) { - if (g_b->set_precise_triangle) - g_b->set_precise_triangle(enabled, x0, y0, x1, y1, x2, y2); -} -void gr_set_unpinned_triangle(int32_t x0, int32_t y0, int32_t x1, int32_t y1, int32_t x2, int32_t y2) { - if (g_b->set_unpinned_triangle) g_b->set_unpinned_triangle(x0, y0, x1, y1, x2, y2); -} -void gr_set_perspective_triangle(int enabled, - float q0, float q1, float q2) { - if (g_b->set_perspective_triangle) - g_b->set_perspective_triangle(enabled, q0, q1, q2); -} -void gr_set_temporal_depth_triangle(int enabled, - float z0, float z1, float z2) { - if (g_b->set_temporal_depth_triangle) - g_b->set_temporal_depth_triangle(enabled, z0, z1, z2); -} -void gr_set_surface_depth_triangle(float z0, float z1, float z2) { - if (g_b->set_surface_depth_triangle) g_b->set_surface_depth_triangle(z0, z1, z2); -} -void gr_set_temporal_identity_triangle(uint64_t id0, uint64_t id1, uint64_t id2) { - if (g_b->set_temporal_identity_triangle) - g_b->set_temporal_identity_triangle(id0, id1, id2); -} -void gr_set_temporal_sprite(float depth, const float sides[4]) { - if (g_b->set_temporal_sprite) g_b->set_temporal_sprite(depth, sides); -} -void gr_set_presentation_yaw(double yaw_units, double full_turn, - double center_x, double center_y, - double focal_x) { - if (g_b->set_presentation_yaw) - g_b->set_presentation_yaw(yaw_units, full_turn, - center_x, center_y, focal_x); -} -void gr_fill_rect(int x, int y, int w, int h, uint16_t c) { g_b->fill_rect(x, y, w, h, c); } -void gr_copy_rect(int sx, int sy, int dx, int dy, int w, int h) { g_b->copy_rect(sx, sy, dx, dy, w, h); } -void gr_draw_flat_triangle(int x0, int y0, int x1, int y1, int x2, int y2, uint16_t c) { - g_b->draw_flat_triangle(x0, y0, x1, y1, x2, y2, c); -} -void gr_draw_gouraud_triangle(int x0, int y0, uint16_t c0, int x1, int y1, uint16_t c1, - int x2, int y2, uint16_t c2) { - g_b->draw_gouraud_triangle(x0, y0, c0, x1, y1, c1, x2, y2, c2); -} -void gr_draw_textured_triangle(int x0, int y0, int u0, int v0, int x1, int y1, int u1, int v1, - int x2, int y2, int u2, int v2, - uint16_t clut_x, uint16_t clut_y, uint16_t texpage) { - g_b->draw_textured_triangle(x0, y0, u0, v0, x1, y1, u1, v1, x2, y2, u2, v2, - clut_x, clut_y, texpage); -} -void gr_draw_shaded_textured_triangle(int x0, int y0, int u0, int v0, uint32_t c0, - int x1, int y1, int u1, int v1, uint32_t c1, - int x2, int y2, int u2, int v2, uint32_t c2, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage, int raw) { - g_b->draw_shaded_textured_triangle(x0, y0, u0, v0, c0, x1, y1, u1, v1, c1, - x2, y2, u2, v2, c2, clut_x, clut_y, texpage, raw); -} -void gr_draw_flat_rect(int x, int y, int w, int h, uint16_t c) { g_b->draw_flat_rect(x, y, w, h, c); } -void gr_draw_textured_rect(int x, int y, int w, int h, int u, int v, - uint16_t clut_x, uint16_t clut_y, uint16_t texpage) { - g_b->draw_textured_rect(x, y, w, h, u, v, clut_x, clut_y, texpage); -} -void gr_draw_textured_rect_scaled(int x, int y, int w, int h, int u0, int v0, int u1, int v1, - uint16_t clut_x, uint16_t clut_y, uint16_t texpage) { - g_b->draw_textured_rect_scaled(x, y, w, h, u0, v0, u1, v1, clut_x, clut_y, texpage); -} -void gr_draw_line(int x0, int y0, int x1, int y1, uint16_t c) { g_b->draw_line(x0, y0, x1, y1, c); } -void gr_draw_shaded_line(int x0, int y0, uint16_t c0, int x1, int y1, uint16_t c1) { - g_b->draw_shaded_line(x0, y0, c0, x1, y1, c1); -} -int gr_render_display(uint32_t *o, int p, int dx, int dy, int dw, int dh) { - return g_b->render_display(o, p, dx, dy, dw, dh); -} -int gr_render_display_hires(uint32_t *o, int p, int dx, int dy, int dw, int dh) { - return g_b->render_display_hires(o, p, dx, dy, dw, dh); -} -void gr_vram_write(int x, int y, uint16_t pixel) { g_b->vram_write(x, y, pixel); } -uint16_t gr_vram_read(int x, int y) { return g_b->vram_read(x, y); } -void gr_vram_transfer_in(int x, int y, int w, int h, const uint16_t *d) { g_b->vram_transfer_in(x, y, w, h, d); } -void gr_vram_transfer_out(int x, int y, int w, int h, uint16_t *d) { g_b->vram_transfer_out(x, y, w, h, d); } -void gr_set_draw_area(int x1, int y1, int x2, int y2){ g_b->set_draw_area(x1, y1, x2, y2); } -void gr_get_draw_area(int *x1, int *y1, int *x2, int *y2) { g_b->get_draw_area(x1, y1, x2, y2); } -void gr_set_draw_offset(int x, int y) { g_b->set_draw_offset(x, y); } - -/* Native-wide compositor — present only on backends that supply it. */ -int gr_wide_supported(void) { return g_b->render_wide_display != 0; } -void gr_wide_configure(int wide_w, int offset) { - if (g_b->wide_configure) g_b->wide_configure(wide_w, offset); -} -void gr_wide_set_target(int base_x) { - if (g_b->wide_set_target) g_b->wide_set_target(base_x); -} -void gr_wide_disable_target(void) { - if (g_b->wide_disable_target) g_b->wide_disable_target(); -} -void gr_wide_clear(int base_x, int y, int h, uint16_t color) { - if (g_b->wide_clear) g_b->wide_clear(base_x, y, h, color); -} -void gr_wide_clear_margins(int base_x, int y, int h, uint16_t color, int sides) { - if (g_b->wide_clear_margins) - g_b->wide_clear_margins(base_x, y, h, color, sides); -} -int gr_render_wide_display(uint32_t *out, int pitch, int base_x, - int disp_y, int disp_h) { - if (g_b->render_wide_display) - return g_b->render_wide_display(out, pitch, base_x, disp_y, disp_h); - return 0; -} -int gr_wide_dump_full(uint32_t *out, int cap_pixels, int *ow, int *oh, int base_x) { - if (g_b->wide_dump_full) - return g_b->wide_dump_full(out, cap_pixels, ow, oh, base_x); - return 0; -} diff --git a/psxrecomp-overlay/runtime/src/gte.cpp b/psxrecomp-overlay/runtime/src/gte.cpp deleted file mode 100644 index 163280c..0000000 --- a/psxrecomp-overlay/runtime/src/gte.cpp +++ /dev/null @@ -1,2841 +0,0 @@ -#include "gte.h" -#include "cpu_state.h" -#include "gte_precision.h" -#include "mod_memory.h" -#include -#include -#include -#include - -namespace PSXRecomp { -namespace GTE { - -// --------------------------------------------------------------------------- -// Common helpers -// --------------------------------------------------------------------------- - -// Perspective division: H / SZ3, scaled, saturated to 17 bits. -// -// The real PS1 GTE does NOT compute an exact H*0x20000/SZ3. It uses an -// Unsigned Newton-Raphson (UNR) reciprocal approximation driven by a 257-entry -// seed table (documented in PSX-SPX "GTE Division Inaccuracy"; identical to the -// mednafen/Beetle oracle in beetle-psx/mednafen/psx/gte.cpp). Exact division -// diverges from hardware by +/-1 (occasionally up to a few units) on ~25% of -// inputs, which is enough to flip games' distance/intensity threshold branches -// (e.g. Ape Escape's additive-glow CLUT semi-transparency bit). This is the -// faithful hardware algorithm, shared by every RTPS/RTPT caller. -static uint8_t s_gte_div_table[0x101]; -static bool s_gte_div_table_init = false; -static void gte_init_div_table() { - for (uint32_t divisor = 0x8000; divisor < 0x10000; divisor += 0x80) { - uint32_t xa = 512; - for (unsigned i = 1; i < 5; i++) - xa = (xa * (1024u * 512u - ((divisor >> 7) * xa))) >> 18; - s_gte_div_table[(divisor >> 7) & 0xFF] = - (uint8_t)(((xa + 1) >> 1) - 0x101); - } - s_gte_div_table[0x100] = s_gte_div_table[0xFF]; - s_gte_div_table_init = true; -} -static int32_t gte_calc_recip(uint16_t divisor) { - int32_t x = 0x101 + s_gte_div_table[(((divisor & 0x7FFF) + 0x40) >> 7)]; - int32_t tmp = (((int32_t)divisor * -x) + 0x80) >> 8; - int32_t tmp2 = ((x * (131072 + tmp)) + 0x80) >> 8; - return tmp2; -} -// count leading zeros of a 16-bit value (0 -> 16) -static inline unsigned gte_clz16(uint16_t v) { - unsigned n = 0; - for (int b = 15; b >= 0; --b) { if (v & (1u << b)) break; ++n; } - return n; -} -static int32_t gte_divide(uint16_t H, uint16_t SZ3, uint32_t& FLAG) { - if (!s_gte_div_table_init) gte_init_div_table(); - // Hardware: overflow flag + saturate when 2*SZ3 <= H (includes SZ3 == 0). - if ((uint32_t)SZ3 * 2 <= (uint32_t)H) { - FLAG |= FLAG_DIV_OVF; - return 0x1FFFF; - } - unsigned shift_bias = gte_clz16(SZ3); - uint32_t dividend = (uint32_t)H << shift_bias; - uint32_t divisor = (uint32_t)SZ3 << shift_bias; - uint32_t result = (uint32_t)(((uint64_t)dividend * - (uint32_t)gte_calc_recip((uint16_t)(divisor | 0x8000)) - + 32768) >> 16); - if (result > 0x1FFFF) result = 0x1FFFF; // 17-bit saturate (no flag; matches hw path) - return (int32_t)result; -} - -// lm (bit 10) selects the lower clamp bound of every IR write in the -// lighting/depth-cue ops: 0 → -0x8000, 1 → 0. sf (bit 19) selects the -// accumulator shift. Hardware takes BOTH from the instruction word; these -// helpers used to hardcode lm=1, which zeroed every negative component — -// Crash Bash lerps signed VERTICES through INTPL (lm=0), so its characters -// collapsed onto their anchor point on every tween frame (menu strobe bug). -static inline bool gte_instr_lm(uint32_t instr) { return (instr & (1u << 10)) != 0; } -static inline int gte_instr_sf(uint32_t instr) { return (instr & (1u << 19)) ? 12 : 0; } - -// Transform a vertex by Light matrix → IR1/IR2/IR3 (step 1 of lighting) -static void light_transform(GTEState* gte, int16_t* V, uint32_t instr) { - const bool lm = gte_instr_lm(instr); - int64_t mac1 = ((int64_t)gte->L[0][0] * V[0] + - (int64_t)gte->L[0][1] * V[1] + - (int64_t)gte->L[0][2] * V[2]) >> 12; - int64_t mac2 = ((int64_t)gte->L[1][0] * V[0] + - (int64_t)gte->L[1][1] * V[1] + - (int64_t)gte->L[1][2] * V[2]) >> 12; - int64_t mac3 = ((int64_t)gte->L[2][0] * V[0] + - (int64_t)gte->L[2][1] * V[1] + - (int64_t)gte->L[2][2] * V[2]) >> 12; - gte->MAC1 = static_cast(mac1); - gte->MAC2 = static_cast(mac2); - gte->MAC3 = static_cast(mac3); - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, lm); -} - -// Apply light color matrix + background color to IR → IR (step 2 of lighting) -static void light_color(GTEState* gte, uint32_t instr) { - const bool lm = gte_instr_lm(instr); - int64_t mac1 = (int64_t)gte->BK[0] * 4096 + - (int64_t)gte->LC[0][0] * gte->IR1 + - (int64_t)gte->LC[0][1] * gte->IR2 + - (int64_t)gte->LC[0][2] * gte->IR3; - int64_t mac2 = (int64_t)gte->BK[1] * 4096 + - (int64_t)gte->LC[1][0] * gte->IR1 + - (int64_t)gte->LC[1][1] * gte->IR2 + - (int64_t)gte->LC[1][2] * gte->IR3; - int64_t mac3 = (int64_t)gte->BK[2] * 4096 + - (int64_t)gte->LC[2][0] * gte->IR1 + - (int64_t)gte->LC[2][1] * gte->IR2 + - (int64_t)gte->LC[2][2] * gte->IR3; - gte->MAC1 = static_cast(mac1 >> 12); - gte->MAC2 = static_cast(mac2 >> 12); - gte->MAC3 = static_cast(mac3 >> 12); - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, lm); -} - -// Multiply IR by RGBC color and push to RGB FIFO (step 3 of color output) -static void color_output(GTEState* gte, uint32_t instr) { - const bool lm = gte_instr_lm(instr); - uint8_t r0 = (gte->RGBC >> 0) & 0xFF; - uint8_t g0 = (gte->RGBC >> 8) & 0xFF; - uint8_t b0 = (gte->RGBC >> 16) & 0xFF; - int64_t mac1 = (int64_t)r0 * gte->IR1 * 16; - int64_t mac2 = (int64_t)g0 * gte->IR2 * 16; - int64_t mac3 = (int64_t)b0 * gte->IR3 * 16; - gte->MAC1 = static_cast(mac1 >> 12); - gte->MAC2 = static_cast(mac2 >> 12); - gte->MAC3 = static_cast(mac3 >> 12); - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, lm); - gte->push_rgb( - gte->saturate_color(gte->MAC1 >> 4, 0), - gte->saturate_color(gte->MAC2 >> 4, 1), - gte->saturate_color(gte->MAC3 >> 4, 2)); -} - -// Depth cue / interpolate current IR toward the far color using IR0 -// (common tail of DPCS/DPCT/DPCL/INTPL/NCDS/NCDT/CDP). Hardware: -// base = IR << 12 -// step = lim(((FC << 12) - base) >> (sf*12)) ; ±0x8000 clamp, lm FORCED off -// MAC = (base + IR0 * step) >> (sf*12) -// IR = lim(MAC, lm) ; lm from the instruction -static void depth_cue_from_ir(GTEState* gte, uint32_t instr) { - const int shift = gte_instr_sf(instr); - const bool lm = gte_instr_lm(instr); - int64_t base1 = (int64_t)gte->IR1 * 4096; - int64_t base2 = (int64_t)gte->IR2 * 4096; - int64_t base3 = (int64_t)gte->IR3 * 4096; - int16_t step1 = gte->saturate_ir( - (int32_t)((((int64_t)gte->FC[0] * 4096 - base1) >> shift)), 1, false); - int16_t step2 = gte->saturate_ir( - (int32_t)((((int64_t)gte->FC[1] * 4096 - base2) >> shift)), 2, false); - int16_t step3 = gte->saturate_ir( - (int32_t)((((int64_t)gte->FC[2] * 4096 - base3) >> shift)), 3, false); - int64_t mac1 = (base1 + (int64_t)gte->IR0 * step1) >> shift; - int64_t mac2 = (base2 + (int64_t)gte->IR0 * step2) >> shift; - int64_t mac3 = (base3 + (int64_t)gte->IR0 * step3) >> shift; - gte->check_mac_overflow(mac1, 1); - gte->check_mac_overflow(mac2, 2); - gte->check_mac_overflow(mac3, 3); - gte->MAC1 = static_cast(mac1); - gte->MAC2 = static_cast(mac2); - gte->MAC3 = static_cast(mac3); - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, lm); -} - -// --------------------------------------------------------------------------- -// Widescreen X-squash (verified-enhancement, default off = identity). -// -// For a display aspect wider than the native 4:3, screen-space X is scaled by -// (4*den)/(3*num) around the projection centre OFX — e.g. 3/4 for 16:9 — and -// the present path stretches the 4:3 frame to the wide aspect, netting a wider -// horizontal field of view (the DuckStation/Beetle "widescreen hack", but -// applied in our GTE library so every RTPS/RTPT caller — generated code, -// interpreter, overlay DLLs — sees it). Only the IR1*h/sz term is scaled, NOT -// OFX, so the squash is centred on the game's own projection centre and -// games' post-projection screen-bounds culls (which read SXY back from us) -// stay aligned with the visible frame. -// --------------------------------------------------------------------------- -static int32_t s_ws_xnum = 1, s_ws_xden = 1; -extern "C" int gpu_ws_present_native_43(void); /* gpu.c — suppress on 4:3 frames */ -static bool ws_x_squashed() { return s_ws_xnum != s_ws_xden && !gpu_ws_present_native_43(); } -/* The ratio RTPS scales a projected X by on this frame, 1/1 when it leaves X alone. */ -extern "C" void gte_ws_x_squash(int32_t* num, int32_t* den) { - const bool squashed = ws_x_squashed(); - *num = squashed ? s_ws_xnum : 1; - *den = squashed ? s_ws_xden : 1; -} -extern "C" void psx_ws_note_gte_project(int nverts); /* gpu.c — gte_game_mode stamp */ -static int s_gte_replay_sandbox = 0; - -/* Geometry presentation is enabled here, but vertex recovery is deliberately - * owned by the exact address-keyed precision store below. Test 11 also kept a - * cache keyed only by rounded SXY; that key is ambiguous and is permanently - * disabled. Keep a generation counter solely for state/test ABI compatibility - * with checkpoints that predate the removal of that cache. */ -static uint32_t s_geom_generation = 1; -static int s_geom_enabled = 0; - -/* The pinned framework's register test predates Test 12 and explicitly seeds - * the removed cache to verify generation wrap. Preserve that fixture only in - * the test binary; no production build contains or writes this storage. */ -#ifdef PSX_GTE_REGISTER_TEST -#define GEOM_TEST_CACHE_SIZE 8192u -struct GeomTestVertex { - uint32_t packed; - int32_t x16, y16; - uint32_t generation; -}; -static GeomTestVertex s_geom_test_cache[GEOM_TEST_CACHE_SIZE]; -static uint32_t s_geom_test_hits = 0; - -static inline uint32_t geom_test_hash(uint32_t packed) { - packed ^= packed >> 16; - return (packed * 2654435761u) & (GEOM_TEST_CACHE_SIZE - 1u); -} -#endif - -/* Exact GTE projection provenance for perspective texture correction. SWC2 - * stores are observed directly; reviewed game-specific MFC2 + ordinary-SW - * routes use the same committed-word gate. GPU DMA later supplies that exact - * RAM address, so depth lookup follows the packet rather than guessing from - * rounded X/Y. */ -struct PreciseProjection { - uint32_t packed; - int32_t x16, y16; - uint16_t z; - uint8_t valid; - uint8_t saturated; - uint8_t perspective_valid; - uint8_t clamped; /* packed is what the game pinned the projection to */ - uint8_t unbounded; /* the GTE's divide or screen stopped short, and exact_x16 and exact_y16 hold what an exact divide gives */ - int32_t exact_x16, exact_y16; - uint64_t identity; -}; -static PreciseProjection s_precise_sxy[4] = {}; -/* PSX_GEOMETRY_UNPIN=0 in a build with debug tools leaves pinned corners as the game put them, for comparison. */ -static int s_precision_unpin = -1; -static int precision_unpin() { - if (s_precision_unpin < 0) { - s_precision_unpin = 1; -#ifndef PSX_NO_DEBUG_TOOLS - const char *value = getenv("PSX_GEOMETRY_UNPIN"); - if (value && value[0] == '0') s_precision_unpin = 0; -#endif - } - return s_precision_unpin; -} -/* Half of what a 16.16 coordinate holds: an exact place stays under it with the screen centre in, the drawing offset comes later. */ -constexpr int64_t kUnboundedLimit16 = INT64_C(1) << 30; - -#define PRECISION_STORE_SIZE 65536u -struct PrecisionStoreEntry { - uint32_t addr; - PreciseProjection projection; - uint32_t generation; -}; -static PrecisionStoreEntry s_precision_store[PRECISION_STORE_SIZE]; -static uint32_t s_precision_generation = 1; -static int s_precision_tracking = 0; -static GtePrecisionDiagnostics s_precision_diagnostics = {}; -struct PrecisionWordCommitToken { - uint32_t physical; - uint8_t domain; - uint8_t pending; -}; -static PrecisionWordCommitToken s_precision_word_commit = {}; -enum PrecisionWordDomain : uint8_t { - PRECISION_WORD_DOMAIN_NONE = 0, - PRECISION_WORD_DOMAIN_MAIN_RAM = 1, - PRECISION_WORD_DOMAIN_SCRATCHPAD = 2, - PRECISION_WORD_DOMAIN_GPU_DMA = 3, -}; -#define PRECISION_STORE_PC_ROUTE_MAX 32u -struct PrecisionStorePcRoute { - uint32_t physical_pc; - uint32_t instruction; - uint8_t gte_reg; -}; -static PrecisionStorePcRoute - s_precision_store_pc_routes[PRECISION_STORE_PC_ROUTE_MAX] = {}; -static uint32_t s_precision_store_pc_route_count = 0; -#define PRECISION_SCRATCH_STORE_PC_ROUTE_MAX 8u -struct PrecisionScratchStorePcRoute { - uint32_t physical_pc; - uint32_t instruction; - uint32_t scratch_first; - uint32_t scratch_stride; - uint32_t scratch_count; - uint8_t gte_reg; - uint8_t clamps; - int16_t low_x, high_x, low_y, high_y; -}; -static PrecisionScratchStorePcRoute - s_precision_scratch_store_pc_routes[ - PRECISION_SCRATCH_STORE_PC_ROUTE_MAX] = {}; -static uint32_t s_precision_scratch_store_pc_route_count = 0; -#define PRECISION_MFC2_PC_ROUTE_MAX 8u -struct PrecisionMfc2PcRoute { - uint32_t physical_pc; - uint32_t instruction; - uint8_t gte_reg; -}; -static PrecisionMfc2PcRoute - s_precision_mfc2_pc_routes[PRECISION_MFC2_PC_ROUTE_MAX] = {}; -static uint32_t s_precision_mfc2_pc_route_count = 0; -static PreciseProjection s_precision_mfc2_capture[4] = {}; -#define PRECISION_COPY_PC_ROUTE_MAX 32u -struct PrecisionCopyPcRoute { - uint32_t load_physical_pc; - uint32_t load_instruction; - uint32_t store_physical_pc; - uint32_t store_instruction; - uint8_t gpr; - PreciseProjection capture; -}; -static PrecisionCopyPcRoute - s_precision_copy_pc_routes[PRECISION_COPY_PC_ROUTE_MAX] = {}; -static uint32_t s_precision_copy_pc_route_count = 0; -static PreciseProjection s_speculative_saved_sxy[4]; -static uint32_t s_speculative_depth = 0; -static int s_speculative_timeline_invalidated = 0; - -static void precision_copy_captures_clear(void) { - for (uint32_t i = 0; i < s_precision_copy_pc_route_count; ++i) - s_precision_copy_pc_routes[i].capture.valid = 0; -} - -static void gte_precision_generation_advance(void) { - if (++s_precision_generation == 0) { - std::memset(s_precision_store, 0, sizeof(s_precision_store)); - s_precision_generation = 1; - } -} - -static void gte_geom_generation_advance(void) { - if (++s_geom_generation == 0) { -#ifdef PSX_GTE_REGISTER_TEST - std::memset(s_geom_test_cache, 0, sizeof(s_geom_test_cache)); -#endif - s_geom_generation = 1; - } -} - -extern "C" void gte_precision_timeline_invalidate(void) { - for (int i = 0; i < 4; ++i) s_precise_sxy[i].valid = 0; - std::memset(s_precision_mfc2_capture, 0, - sizeof(s_precision_mfc2_capture)); - precision_copy_captures_clear(); - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; - /* Raw machine-state restore is authoritative even if host polling reached - * it during a speculative validation pass. Defer the generation advance - * until the outer transaction ends so old provenance cannot be restored. */ - if (s_speculative_depth != 0) { - s_speculative_timeline_invalidated = 1; - return; - } - gte_precision_generation_advance(); - gte_geom_generation_advance(); -} - -extern "C" void gte_precision_speculative_begin(void) { - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; - if (s_speculative_depth++ == 0) { - std::memcpy(s_speculative_saved_sxy, s_precise_sxy, - sizeof(s_precise_sxy)); - s_speculative_timeline_invalidated = 0; - } -} - -extern "C" void gte_precision_speculative_end(void) { - if (s_speculative_depth == 0) return; - if (--s_speculative_depth == 0) { - if (s_speculative_timeline_invalidated) { - gte_precision_generation_advance(); - gte_geom_generation_advance(); - s_speculative_timeline_invalidated = 0; - } else { - std::memcpy(s_precise_sxy, s_speculative_saved_sxy, - sizeof(s_precise_sxy)); - } - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; - } -} - -static inline uint32_t precision_hash(uint32_t addr) { - addr >>= 2; - addr ^= addr >> 11; - return (addr * 2654435761u) & (PRECISION_STORE_SIZE - 1u); -} - -static inline int precision_ram_address(uint32_t addr, uint32_t *physical) { - /* Match memory.c: KSEG2/KSEG3 are not translated through the ordinary - * 0x1fffffff mask. Apart from cache control, those accesses are unmapped - * and must never alias a low-RAM provenance entry. */ - if (addr >= 0xC0000000u) return 0; - uint32_t mapped = addr & 0x1FFFFFFFu; - if (mapped >= 0x00800000u) { - if (mapped < PSX_MOD_GPU_DMA_APERTURE_BASE || - mapped > PSX_MOD_GPU_DMA_APERTURE_BASE + - PSX_MOD_GPU_DMA_APERTURE_SIZE - 4u) return 0; - *physical = mapped & ~3u; - return 1; - } - *physical = mapped & 0x1FFFFCu; - return 1; -} - -static inline int precision_scratch_address(uint32_t addr, - uint32_t *physical) { - /* Use the same KUSEG/KSEG0/KSEG1 translation as memory.c, while keeping - * KSEG2/KSEG3 from aliasing a reviewed scratchpad slot. */ - if (addr >= 0xC0000000u) return 0; - const uint32_t mapped = addr & 0x1FFFFFFFu; - if (mapped < 0x1F800000u || mapped > 0x1F8003FFu) return 0; - *physical = mapped & ~3u; - return 1; -} - -static inline int precision_copy_source_address(uint32_t addr, - uint32_t *physical) { - return precision_ram_address(addr, physical) || - precision_scratch_address(addr, physical); -} - -static inline int precision_projection_index(uint8_t reg) { - if (reg < 12 || reg > 15) return -1; - return reg == 15 ? 2 : (int)reg - 12; -} - -static void precision_store_projection( - uint32_t physical, const PreciseProjection &projection) { - PrecisionStoreEntry &entry = s_precision_store[precision_hash(physical)]; - if (entry.generation != 0 && entry.addr != physical) { - ++s_precision_diagnostics.store_collisions; - if (entry.generation == s_precision_generation) - ++s_precision_diagnostics.store_evictions; - } - entry.addr = physical; - entry.projection = projection; - entry.generation = s_precision_generation; -} - -static const PrecisionStorePcRoute *precision_store_pc_route( - uint32_t store_pc, uint32_t instruction) { - const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; - for (uint32_t i = 0; i < s_precision_store_pc_route_count; ++i) { - if (s_precision_store_pc_routes[i].physical_pc == physical_pc && - s_precision_store_pc_routes[i].instruction == instruction) - return &s_precision_store_pc_routes[i]; - } - return nullptr; -} - -static const PrecisionScratchStorePcRoute *precision_scratch_store_pc_route( - uint32_t store_pc, uint32_t instruction) { - const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; - for (uint32_t i = 0; - i < s_precision_scratch_store_pc_route_count; ++i) { - const PrecisionScratchStorePcRoute &route = - s_precision_scratch_store_pc_routes[i]; - if (route.physical_pc == physical_pc && - route.instruction == instruction) - return &route; - } - return nullptr; -} - -static inline int precision_scratch_route_contains( - const PrecisionScratchStorePcRoute &route, uint32_t physical) { - if (physical < route.scratch_first) return 0; - const uint32_t delta = physical - route.scratch_first; - if ((delta % route.scratch_stride) != 0u) return 0; - return (delta / route.scratch_stride) < route.scratch_count; -} - -static const PrecisionMfc2PcRoute *precision_mfc2_pc_route( - uint32_t mfc2_pc, uint32_t instruction) { - const uint32_t physical_pc = mfc2_pc & 0x1FFFFFFFu; - for (uint32_t i = 0; i < s_precision_mfc2_pc_route_count; ++i) { - if (s_precision_mfc2_pc_routes[i].physical_pc == physical_pc && - s_precision_mfc2_pc_routes[i].instruction == instruction) - return &s_precision_mfc2_pc_routes[i]; - } - return nullptr; -} - -static PrecisionCopyPcRoute *precision_copy_load_pc_route( - uint32_t load_pc, uint32_t instruction, uint8_t gpr) { - const uint32_t physical_pc = load_pc & 0x1FFFFFFFu; - for (uint32_t i = 0; i < s_precision_copy_pc_route_count; ++i) { - PrecisionCopyPcRoute &route = s_precision_copy_pc_routes[i]; - if (route.load_physical_pc == physical_pc && - route.load_instruction == instruction && route.gpr == gpr) - return &route; - } - return nullptr; -} - -static PrecisionCopyPcRoute *precision_copy_store_pc_route( - uint32_t store_pc, uint32_t instruction, uint8_t gpr) { - const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; - for (uint32_t i = 0; i < s_precision_copy_pc_route_count; ++i) { - PrecisionCopyPcRoute &route = s_precision_copy_pc_routes[i]; - if (route.store_physical_pc == physical_pc && - route.store_instruction == instruction && route.gpr == gpr) - return &route; - } - return nullptr; -} - -extern "C" void gte_precision_word_write_begin(void) { - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; -} - -extern "C" void gte_precision_main_ram_word_committed(uint32_t physical) { - /* memory.c passes its already-folded main-RAM address only after all four - * bytes have been written. Do not turn an unaligned host write into an - * adjacent aligned provenance token. */ - if (physical >= 0x00200000u || (physical & 3u) != 0u) { - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; - return; - } - s_precision_word_commit.physical = physical; - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_MAIN_RAM; - s_precision_word_commit.pending = 1; -} - -/* Only memory.c arms this domain, after an allocated aperture word commits. - * A renderer lookup cannot alias this address with a folded main-RAM word. */ -extern "C" void gte_precision_gpu_dma_word_committed(uint32_t physical) { - if (physical < PSX_MOD_GPU_DMA_APERTURE_BASE || - physical > PSX_MOD_GPU_DMA_APERTURE_BASE + - PSX_MOD_GPU_DMA_APERTURE_SIZE - 4u || (physical & 3u)) { - gte_precision_word_write_begin(); - return; - } - s_precision_word_commit.physical = physical; - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_GPU_DMA; - s_precision_word_commit.pending = 1; -} - -static int precision_memory_commit_matches(uint8_t domain, uint32_t physical) { - return domain == (physical < 0x00200000u ? - PRECISION_WORD_DOMAIN_MAIN_RAM : PRECISION_WORD_DOMAIN_GPU_DMA); -} - -extern "C" void gte_precision_scratch_word_committed(uint32_t physical) { - /* memory.c supplies an already-translated physical address after all four - * scratchpad bytes commit. Slot eligibility is checked independently by - * the reviewed post-write route. */ - if (physical < 0x1F800000u || physical > 0x1F8003FCu || - (physical & 3u) != 0u) { - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; - return; - } - s_precision_word_commit.physical = physical; - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_SCRATCHPAD; - s_precision_word_commit.pending = 1; -} - -extern "C" void gte_precision_tracking_set(int enabled) { - s_precision_tracking = enabled ? 1 : 0; - std::memset(s_precision_mfc2_capture, 0, - sizeof(s_precision_mfc2_capture)); - precision_copy_captures_clear(); - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; - gte_precision_generation_advance(); -} - -extern "C" void gte_precision_store_pc_routes_reset(void) { - s_precision_store_pc_route_count = 0; - s_precision_scratch_store_pc_route_count = 0; - s_precision_mfc2_pc_route_count = 0; - s_precision_copy_pc_route_count = 0; - std::memset(s_precision_store_pc_routes, 0, - sizeof(s_precision_store_pc_routes)); - std::memset(s_precision_scratch_store_pc_routes, 0, - sizeof(s_precision_scratch_store_pc_routes)); - std::memset(s_precision_mfc2_pc_routes, 0, - sizeof(s_precision_mfc2_pc_routes)); - std::memset(s_precision_copy_pc_routes, 0, - sizeof(s_precision_copy_pc_routes)); - std::memset(s_precision_mfc2_capture, 0, - sizeof(s_precision_mfc2_capture)); - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; -} - -extern "C" int gte_precision_mfc2_pc_route_add( - uint32_t mfc2_pc, uint32_t instruction, uint8_t gte_reg) { - if ((mfc2_pc & 3u) != 0u || precision_projection_index(gte_reg) < 0 || - ((instruction >> 26) & 0x3Fu) != 0x12u || - ((instruction >> 21) & 0x1Fu) != 0u || - ((instruction >> 11) & 0x1Fu) != gte_reg) - return 0; - const uint32_t physical_pc = mfc2_pc & 0x1FFFFFFFu; - for (uint32_t i = 0; i < s_precision_mfc2_pc_route_count; ++i) { - PrecisionMfc2PcRoute &route = s_precision_mfc2_pc_routes[i]; - if (route.physical_pc != physical_pc) continue; - return route.instruction == instruction && route.gte_reg == gte_reg; - } - if (s_precision_mfc2_pc_route_count >= PRECISION_MFC2_PC_ROUTE_MAX) - return 0; - PrecisionMfc2PcRoute &route = - s_precision_mfc2_pc_routes[s_precision_mfc2_pc_route_count++]; - route.physical_pc = physical_pc; - route.instruction = instruction; - route.gte_reg = gte_reg; - return 1; -} - -extern "C" int gte_precision_store_pc_route_add( - uint32_t store_pc, uint32_t instruction, uint8_t gte_reg) { - if ((store_pc & 3u) != 0u || precision_projection_index(gte_reg) < 0 || - ((instruction >> 26) & 0x3Fu) != 0x2Bu) - return 0; - const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; - for (uint32_t i = 0; i < s_precision_store_pc_route_count; ++i) { - PrecisionStorePcRoute &route = s_precision_store_pc_routes[i]; - if (route.physical_pc != physical_pc) continue; - return route.instruction == instruction && route.gte_reg == gte_reg; - } - if (s_precision_store_pc_route_count >= PRECISION_STORE_PC_ROUTE_MAX) - return 0; - PrecisionStorePcRoute &route = - s_precision_store_pc_routes[s_precision_store_pc_route_count++]; - route.physical_pc = physical_pc; - route.instruction = instruction; - route.gte_reg = gte_reg; - return 1; -} - -extern "C" int gte_precision_scratch_store_pc_route_add( - uint32_t store_pc, uint32_t instruction, uint8_t gte_reg, - uint32_t scratch_first, uint32_t scratch_stride, - uint32_t scratch_count) { - if ((store_pc & 3u) != 0u || precision_projection_index(gte_reg) < 0 || - ((instruction >> 26) & 0x3Fu) != 0x2Bu || - scratch_count == 0u || scratch_stride == 0u || - (scratch_first & 3u) != 0u || (scratch_stride & 3u) != 0u || - scratch_first < 0x1F800000u || scratch_first > 0x1F8003FCu) - return 0; - - /* Prove the final registered slot without overflow, and keep every slot - * wholly within the physical 1 KiB scratchpad. */ - const uint64_t scratch_last = - (uint64_t)scratch_first + - (uint64_t)scratch_stride * (uint64_t)(scratch_count - 1u); - if (scratch_last > 0x1F8003FCull) return 0; - - const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; - for (uint32_t i = 0; - i < s_precision_scratch_store_pc_route_count; ++i) { - PrecisionScratchStorePcRoute &route = - s_precision_scratch_store_pc_routes[i]; - if (route.physical_pc != physical_pc) continue; - return route.instruction == instruction && - route.gte_reg == gte_reg && - route.scratch_first == scratch_first && - route.scratch_stride == scratch_stride && - route.scratch_count == scratch_count; - } - if (s_precision_scratch_store_pc_route_count >= - PRECISION_SCRATCH_STORE_PC_ROUTE_MAX) - return 0; - PrecisionScratchStorePcRoute &route = - s_precision_scratch_store_pc_routes[ - s_precision_scratch_store_pc_route_count++]; - route.physical_pc = physical_pc; - route.instruction = instruction; - route.scratch_first = scratch_first; - route.scratch_stride = scratch_stride; - route.scratch_count = scratch_count; - route.gte_reg = gte_reg; - return 1; -} - -extern "C" int gte_precision_scratch_store_pc_route_clamp( - uint32_t store_pc, uint32_t instruction, - int16_t low_x, int16_t high_x, int16_t low_y, int16_t high_y) { - const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; - if (low_x > high_x || low_y > high_y) return 0; - for (uint32_t i = 0; i < s_precision_scratch_store_pc_route_count; ++i) { - PrecisionScratchStorePcRoute &route = s_precision_scratch_store_pc_routes[i]; - if (route.physical_pc != physical_pc || route.instruction != instruction) continue; - route.clamps = 1; - route.low_x = low_x; route.high_x = high_x; - route.low_y = low_y; route.high_y = high_y; - return 1; - } - return 0; -} - -static uint32_t precision_scratch_route_clamp( - const PrecisionScratchStorePcRoute &route, uint32_t packed) { - int32_t x = (int16_t)(packed & 0xFFFFu), y = (int16_t)(packed >> 16); - if (x < route.low_x) x = route.low_x; - if (x > route.high_x) x = route.high_x; - if (y < route.low_y) y = route.low_y; - if (y > route.high_y) y = route.high_y; - return ((uint32_t)x & 0xFFFFu) | ((uint32_t)y << 16); -} - -extern "C" int gte_precision_copy_pc_route_add( - uint32_t load_pc, uint32_t load_instruction, - uint32_t store_pc, uint32_t store_instruction, uint8_t gpr) { - if ((load_pc & 3u) != 0u || (store_pc & 3u) != 0u || gpr == 0u || - gpr >= 32u || ((load_instruction >> 26) & 0x3Fu) != 0x23u || - ((store_instruction >> 26) & 0x3Fu) != 0x2Bu || - ((load_instruction >> 16) & 0x1Fu) != gpr || - ((store_instruction >> 16) & 0x1Fu) != gpr) - return 0; - const uint32_t load_physical_pc = load_pc & 0x1FFFFFFFu; - const uint32_t store_physical_pc = store_pc & 0x1FFFFFFFu; - for (uint32_t i = 0; i < s_precision_copy_pc_route_count; ++i) { - PrecisionCopyPcRoute &route = s_precision_copy_pc_routes[i]; - if (route.load_physical_pc != load_physical_pc && - route.store_physical_pc != store_physical_pc) - continue; - return route.load_physical_pc == load_physical_pc && - route.load_instruction == load_instruction && - route.store_physical_pc == store_physical_pc && - route.store_instruction == store_instruction && - route.gpr == gpr; - } - if (s_precision_copy_pc_route_count >= PRECISION_COPY_PC_ROUTE_MAX) - return 0; - PrecisionCopyPcRoute &route = - s_precision_copy_pc_routes[s_precision_copy_pc_route_count++]; - route.load_physical_pc = load_physical_pc; - route.load_instruction = load_instruction; - route.store_physical_pc = store_physical_pc; - route.store_instruction = store_instruction; - route.gpr = gpr; - route.capture.valid = 0; - return 1; -} - -extern "C" uint32_t gte_precision_mfc2_pc_read( - uint32_t mfc2_pc, uint32_t instruction, uint8_t gte_reg, - uint32_t packed) { - const PrecisionMfc2PcRoute *route = - precision_mfc2_pc_route(mfc2_pc, instruction); - if (!route || route->gte_reg != gte_reg) - return packed; - if (s_speculative_depth != 0 || s_gte_replay_sandbox || - !s_precision_tracking) - return packed; - - const int index = precision_projection_index(gte_reg); - if (index < 0) return packed; - PreciseProjection &capture = s_precision_mfc2_capture[index]; - capture.valid = 0; - const PreciseProjection &projection = s_precise_sxy[index]; - if (projection.valid && projection.packed == packed) - capture = projection; - return packed; -} - -extern "C" uint32_t gte_precision_copy_pc_read( - uint32_t load_pc, uint32_t instruction, uint8_t gpr, - uint32_t addr, uint32_t packed) { - PrecisionCopyPcRoute *route = - precision_copy_load_pc_route(load_pc, instruction, gpr); - if (!route) return packed; - if (s_speculative_depth != 0 || s_gte_replay_sandbox || - !s_precision_tracking) - return packed; - ++s_precision_diagnostics.copy_load_attempts; - - /* Every authoritative execution replaces the prior capture, including a - * source miss. An older equal packed value must never survive this load. */ - route->capture.valid = 0; - if ((addr & 3u) != 0u) return packed; - uint32_t physical; - if (!precision_copy_source_address(addr, &physical)) return packed; - const PrecisionStoreEntry &entry = - s_precision_store[precision_hash(physical)]; - if (entry.generation != s_precision_generation || entry.addr != physical || - !entry.projection.valid || entry.projection.packed != packed) - return packed; - route->capture = entry.projection; - ++s_precision_diagnostics.copy_load_accepts; - return packed; -} - -extern "C" void gte_precision_store_word(uint32_t addr, uint8_t reg) { - /* Consume before every early return. Speculative validation, disabled - * tracking and ineligible GTE registers must not leave a real or replayed - * write token available to a later SWC2 callback. */ - const uint32_t committed_physical = s_precision_word_commit.physical; - const uint8_t committed_domain = s_precision_word_commit.domain; - const int committed = s_precision_word_commit.pending != 0; - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; - if (s_speculative_depth != 0 || s_gte_replay_sandbox || !s_precision_tracking || - reg < 12 || reg > 15 || (addr & 3u) != 0u) return; - uint32_t physical; - if (!precision_ram_address(addr, &physical)) return; - int commit_matches = committed && - precision_memory_commit_matches(committed_domain, physical) && - committed_physical == physical; -#ifdef PSX_GTE_REGISTER_TEST - /* The pinned standalone register fixture links gte.cpp without memory.c - * and directly seeds then stores precision entries. Preserve that - * historical test seam only when no producer token exists; an explicitly - * armed mismatched token still exercises the production rejection. */ - if (!committed) commit_matches = 1; -#endif - if (!commit_matches) { - ++s_precision_diagnostics.store_uncommitted_rejections; - return; - } - int index = precision_projection_index(reg); - const PreciseProjection &projection = s_precise_sxy[index]; - if (!projection.valid) return; - precision_store_projection(physical, projection); -} - -extern "C" void gte_precision_store_pc_word( - uint32_t store_pc, uint32_t instruction, uint32_t addr, - uint32_t packed) { - /* Most ordinary SWs are not reviewed projection stores and must leave the - * token available for an immediately-following SWC2 callback. */ - const PrecisionStorePcRoute *route = - precision_store_pc_route(store_pc, instruction); - if (!route) return; - - const uint32_t committed_physical = s_precision_word_commit.physical; - const uint8_t committed_domain = s_precision_word_commit.domain; - const int committed = s_precision_word_commit.pending != 0; - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; - - if (s_speculative_depth != 0 || s_gte_replay_sandbox || - !s_precision_tracking || (addr & 3u) != 0u) - return; - ++s_precision_diagnostics.registered_store_attempts; - uint32_t physical; - if (!precision_ram_address(addr, &physical)) return; - if (!committed || !precision_memory_commit_matches(committed_domain, physical) || - committed_physical != physical) { - ++s_precision_diagnostics.store_uncommitted_rejections; - return; - } - - const int index = precision_projection_index(route->gte_reg); - if (index < 0) return; - const PreciseProjection &projection = s_precision_mfc2_capture[index]; - if (!projection.valid) return; - if (projection.packed != packed) { - ++s_precision_diagnostics.registered_store_packed_rejections; - return; - } - precision_store_projection(physical, projection); - ++s_precision_diagnostics.registered_store_accepts; -} - -extern "C" void gte_precision_scratch_store_pc_word( - uint32_t store_pc, uint32_t instruction, - uint32_t addr, uint32_t packed) { - /* An unrelated SW callback must not steal the token from the matching - * reviewed helper that immediately follows it. */ - const PrecisionScratchStorePcRoute *route = - precision_scratch_store_pc_route(store_pc, instruction); - if (!route) return; - - const uint32_t committed_physical = s_precision_word_commit.physical; - const uint8_t committed_domain = s_precision_word_commit.domain; - const int committed = s_precision_word_commit.pending != 0; - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; - - if (s_speculative_depth != 0 || s_gte_replay_sandbox || - !s_precision_tracking || (addr & 3u) != 0u) - return; - ++s_precision_diagnostics.registered_store_attempts; - - uint32_t physical; - if (!precision_scratch_address(addr, &physical) || - !precision_scratch_route_contains(*route, physical)) - return; - if (!committed || - committed_domain != PRECISION_WORD_DOMAIN_SCRATCHPAD || - committed_physical != physical) { - ++s_precision_diagnostics.store_uncommitted_rejections; - return; - } - - const int index = precision_projection_index(route->gte_reg); - if (index < 0) return; - const PreciseProjection &projection = s_precision_mfc2_capture[index]; - if (!projection.valid) return; - if (projection.packed != packed) { - /* A corner the game pinned to its guard band keeps the projection it was pinned from. */ - if (!route->clamps || precision_scratch_route_clamp(*route, projection.packed) != packed) { - ++s_precision_diagnostics.registered_store_packed_rejections; - return; - } - PreciseProjection pinned = projection; - pinned.packed = packed; - pinned.clamped = 1; - precision_store_projection(physical, pinned); - ++s_precision_diagnostics.registered_store_accepts; - return; - } - precision_store_projection(physical, projection); - ++s_precision_diagnostics.registered_store_accepts; -} - -extern "C" void gte_precision_copy_pc_word( - uint32_t store_pc, uint32_t instruction, uint8_t gpr, - uint32_t addr, uint32_t packed) { - PrecisionCopyPcRoute *route = - precision_copy_store_pc_route(store_pc, instruction, gpr); - if (!route) return; - - const uint32_t committed_physical = s_precision_word_commit.physical; - const uint8_t committed_domain = s_precision_word_commit.domain; - const int committed = s_precision_word_commit.pending != 0; - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; - - /* Replay/speculative execution may consume only its transient memory token; - * it must neither replace nor erase an authoritative load capture. */ - if (s_speculative_depth != 0 || s_gte_replay_sandbox || - !s_precision_tracking) - return; - ++s_precision_diagnostics.copy_store_attempts; - - const PreciseProjection projection = route->capture; - route->capture.valid = 0; - if ((addr & 3u) != 0u) return; - uint32_t physical; - if (!precision_ram_address(addr, &physical)) return; - if (!committed || !precision_memory_commit_matches(committed_domain, physical) || - committed_physical != physical) { - ++s_precision_diagnostics.store_uncommitted_rejections; - return; - } - if (!projection.valid) return; - if (projection.packed != packed) { - ++s_precision_diagnostics.copy_store_packed_rejections; - return; - } - precision_store_projection(physical, projection); - ++s_precision_diagnostics.copy_store_accepts; -} - -extern "C" void gte_precision_invalidate_word(uint32_t addr) { - if (s_speculative_depth != 0 || s_gte_replay_sandbox || !s_precision_tracking) return; - uint32_t physical; - if (!precision_copy_source_address(addr, &physical)) return; - PrecisionStoreEntry &entry = s_precision_store[precision_hash(physical)]; - if (entry.generation == s_precision_generation && entry.addr == physical) - entry.generation = 0; -} - -static GtePrecisionLookupResult precision_lookup_projection( - uint32_t addr, uint32_t packed, - const PreciseProjection **projection) { - if (projection) *projection = nullptr; - if (s_speculative_depth != 0) - return GTE_PRECISION_LOOKUP_SPECULATIVE; - if (!s_precision_tracking) - return GTE_PRECISION_LOOKUP_TRACKING_DISABLED; - if ((addr & 3u) != 0u) - return GTE_PRECISION_LOOKUP_SOURCE_UNALIGNED; - uint32_t physical; - if (!precision_ram_address(addr, &physical)) - return GTE_PRECISION_LOOKUP_SOURCE_NOT_RAM; - const PrecisionStoreEntry &entry = s_precision_store[precision_hash(physical)]; - if (entry.generation != s_precision_generation) - return GTE_PRECISION_LOOKUP_STORE_MISS; - if (entry.addr != physical) { - ++s_precision_diagnostics.lookup_collisions; - return GTE_PRECISION_LOOKUP_STORE_COLLISION; - } - if (!entry.projection.valid) - return GTE_PRECISION_LOOKUP_PROJECTION_INVALID; - if (entry.projection.packed != packed) - return GTE_PRECISION_LOOKUP_PACKED_MISMATCH; - if (entry.projection.z == 0) - return GTE_PRECISION_LOOKUP_ZERO_DEPTH; - /* A corner the GTE saturated and the game then pinned to its guard band is drawn where it projects. */ - if (entry.projection.saturated && !(entry.projection.clamped && entry.projection.unbounded && precision_unpin())) - return GTE_PRECISION_LOOKUP_SATURATED; - if (projection) *projection = &entry.projection; - return GTE_PRECISION_LOOKUP_ACCEPTED; -} - -extern "C" GtePrecisionLookupResult gte_precision_load_word_ex( - uint32_t addr, uint32_t packed, - int32_t *x16, int32_t *y16, uint16_t *z) { - const PreciseProjection *projection = nullptr; - const GtePrecisionLookupResult result = - precision_lookup_projection(addr, packed, &projection); - if (result != GTE_PRECISION_LOOKUP_ACCEPTED) return result; - /* Only a corner the game pinned leaves its packed pixel: it is drawn where an exact divide puts it. */ - const bool exact = projection->clamped && projection->unbounded && precision_unpin(); - if (x16) *x16 = exact ? projection->exact_x16 : projection->x16; - if (y16) *y16 = exact ? projection->exact_y16 : projection->y16; - if (z) *z = projection->z; - return GTE_PRECISION_LOOKUP_ACCEPTED; -} - -extern "C" GtePrecisionLookupResult gte_precision_load_perspective_word_ex( - uint32_t addr, uint32_t packed, uint16_t *z) { - const PreciseProjection *projection = nullptr; - const GtePrecisionLookupResult result = - precision_lookup_projection(addr, packed, &projection); - if (result != GTE_PRECISION_LOOKUP_ACCEPTED) return result; - if (!projection->perspective_valid) - return GTE_PRECISION_LOOKUP_PERSPECTIVE_INVALID; - if (z) *z = projection->z; - return GTE_PRECISION_LOOKUP_ACCEPTED; -} - -extern "C" GtePrecisionLookupResult gte_precision_load_identity( - uint32_t addr, uint32_t packed, uint64_t *identity) { - const PreciseProjection *projection = nullptr; - const GtePrecisionLookupResult result = - precision_lookup_projection(addr, packed, &projection); - if (identity) - *identity = result == GTE_PRECISION_LOOKUP_ACCEPTED ? projection->identity : 0u; - return result; -} - -extern "C" int gte_precision_unpin_enabled(void) { return precision_unpin(); } -extern "C" void gte_precision_unpin_set(int enabled) { s_precision_unpin = enabled ? 1 : 0; } - -extern "C" int gte_precision_word_clamped(uint32_t addr, uint32_t packed) { - const PreciseProjection *projection = nullptr; - return precision_lookup_projection(addr, packed, &projection) == GTE_PRECISION_LOOKUP_ACCEPTED && - projection->clamped; -} - -extern "C" int gte_precision_load_word(uint32_t addr, uint32_t packed, - int32_t *x16, int32_t *y16, - uint16_t *z) { - return gte_precision_load_word_ex(addr, packed, x16, y16, z) == - GTE_PRECISION_LOOKUP_ACCEPTED; -} - -extern "C" void gte_precision_diagnostics(GtePrecisionDiagnostics *out) { - if (out) *out = s_precision_diagnostics; -} - -extern "C" void gte_precision_diagnostics_reset(void) { - s_precision_diagnostics = {}; -} - -extern "C" void gte_geometry_correction_set(int enabled) { - s_geom_enabled = enabled ? 1 : 0; -#ifdef PSX_GTE_REGISTER_TEST - s_geom_test_hits = 0; -#endif - gte_geom_generation_advance(); -} - -extern "C" int gte_geometry_correction_enabled(void) { - return s_geom_enabled; -} - -extern "C" uint32_t gte_geometry_correction_hits(void) { -#ifdef PSX_GTE_REGISTER_TEST - return s_geom_test_hits; -#else - return 0; -#endif -} - -/* Retained as a hard-disabled ABI stub for older generated/native test code. - * Geometry must never recover provenance from a rounded coordinate alone. */ -extern "C" int gte_geometry_correction_lookup(uint32_t packed, - int32_t *x16, int32_t *y16) { -#ifdef PSX_GTE_REGISTER_TEST - if (s_speculative_depth != 0 || !s_geom_enabled) return 0; - const GeomTestVertex &entry = s_geom_test_cache[geom_test_hash(packed)]; - if (entry.generation != s_geom_generation || entry.packed != packed) - return 0; - if (x16) *x16 = entry.x16; - if (y16) *y16 = entry.y16; - s_geom_test_hits++; - return 1; -#else - (void)packed; - (void)x16; - (void)y16; - return 0; -#endif -} - -// Per-scope diagnostic suppression of the classic widescreen X squash for -// candidate finite far backdrops. The recompiler brackets each reviewed -// [widescreen.backdrop].unsquash_funcs driver with -// gte_ws_set_suppress(1)/(0). Those drivers may draw a mixture of backdrop and -// ordinary room geometry, so the scope remains DEPTH-GATED: only vertices with -// SZ >= s_ws_far_threshold retain their authored 4:3 projection before the -// final wide stretch. The counter makes nested/recursive tagged renderers safe: -// an inner return must not clear its caller's active scope. Live testing has -// not linked this path to the skyline plane, so it must remain opt-in. -static unsigned s_ws_suppress_depth = 0; -static int s_ws_backdrop_repair_enabled = 0; -static int32_t s_ws_far_threshold = 900; /* SZ split: near props squashed, far backdrop un-squashed (8C, tunable via ws_far_threshold) */ -extern "C" void gte_ws_set_suppress(int on) { - if (on) { - if (s_ws_suppress_depth != 0xFFFFFFFFu) ++s_ws_suppress_depth; - } else if (s_ws_suppress_depth != 0) { - --s_ws_suppress_depth; - } -} -extern "C" void gte_ws_set_backdrop_repair_enabled(int on) { - s_ws_backdrop_repair_enabled = on ? 1 : 0; -} -extern "C" int gte_ws_get_backdrop_repair_enabled(void) { - return s_ws_backdrop_repair_enabled; -} -extern "C" void gte_ws_set_far_threshold(int t) { s_ws_far_threshold = t; } -extern "C" int gte_ws_get_far_threshold(void) { return (int)s_ws_far_threshold; } -/* SZ stats observed while suppress is active (one frame window, reset on read). */ -static int32_t s_ws_sz_min = 0x7FFFFFFF, s_ws_sz_max = 0; -static uint32_t s_ws_sz_n = 0, s_ws_sz_far = 0; -extern "C" void gte_ws_get_sz_stats(int* mn, int* mx, unsigned* n, unsigned* far_n) { - if (mn) *mn = (s_ws_sz_n ? s_ws_sz_min : 0); - if (mx) *mx = s_ws_sz_max; - if (n) *n = s_ws_sz_n; - if (far_n) *far_n = s_ws_sz_far; - s_ws_sz_min = 0x7FFFFFFF; s_ws_sz_max = 0; s_ws_sz_n = 0; s_ws_sz_far = 0; -} - -// ---- Native-wide sky-DOME expand ------------------------------------------ -// In native-wide (mode 2) the GTE is fed identity (no squash) so the world -// fills the wider FOV via the cull widening. But a sky DOME is a FINITE mesh -// authored to fill 4:3 — its curved edge falls short of the wider frame corners -// (black corners). Fix: scale the FAR-depth vertices' projected X OUTWARD from -// the projection centre (OFX) by the frame-widening ratio (3*num)/(4*den) — the -// inverse of the squash — so the dome grows to cover the wider FOV. DEPTH-GATED -// (SZ >= s_ws_far_threshold) so only the farthest layer (the sky) expands while -// nearer world geometry stays put. On + ratio set from main.cpp when native- -// wide engages; threshold tuned live via ws_far_threshold. Off => identity. -static int s_ws_dome_on = 0; -static int32_t s_ws_dome_num = 1, s_ws_dome_den = 1; // expand = num/den (>1) -extern "C" void gte_ws_set_dome_expand(int on, int aspect_num, int aspect_den) { - s_ws_dome_on = on ? 1 : 0; - // widen ratio = wide_w / disp_w = (aspect/(4:3)) = (3*num)/(4*den). - int32_t n = 3 * aspect_num, d = 4 * aspect_den; - if (n <= 0 || d <= 0 || n <= d) { s_ws_dome_num = s_ws_dome_den = 1; return; } - s_ws_dome_num = n; s_ws_dome_den = d; -} - -// ---- Dome-locate probe ----------------------------------------------------- -// Tally which guest function (g_debug_current_func_addr, set at dispatch) -// projects FAR (SZ >= threshold) vertices, so the sky-dome draw function can be -// identified as the top far-vertex emitter — the target for the per-function -// dome-expand bracket (the clean alternative to the scene-dependent depth gate). -extern "C" { extern uint32_t g_debug_current_func_addr; } -static uint32_t s_gte_caller_ra = 0; /* guest ra at gte_execute = return into the GAME fn that issued the projection (not the libgte leaf) */ -#define WS_DOME_CALL_SITES_MAX 32 -static uint32_t s_ws_dome_call_sites[WS_DOME_CALL_SITES_MAX]; -static int s_ws_dome_call_sites_n = 0; -extern "C" void gte_ws_configure_dome_sites(const uint32_t* sites, int count) { - if (count < 0) count = 0; - if (count > WS_DOME_CALL_SITES_MAX) count = WS_DOME_CALL_SITES_MAX; - s_ws_dome_call_sites_n = count; - for (int i = 0; i < count; i++) - s_ws_dome_call_sites[i] = sites[i] & 0x1FFFFFFFu; -} -static inline bool ws_dome_call_matches(void) { - // JAL writes pc+8 to RA; config records the call instruction itself. - const uint32_t call_pc = (s_gte_caller_ra - 8u) & 0x1FFFFFFFu; - for (int i = 0; i < s_ws_dome_call_sites_n; i++) - if (s_ws_dome_call_sites[i] == call_pc) return true; - return false; -} -#define DOME_PROBE_SLOTS 48 -static struct { uint32_t func; uint32_t count; int32_t max_sz; } s_dome_probe[DOME_PROBE_SLOTS]; -static int s_dome_probe_on = 0; -static int32_t s_dome_probe_thr = 4000; -extern "C" void gte_dome_probe(int on, int thr) { - s_dome_probe_on = on ? 1 : 0; - if (thr > 0) s_dome_probe_thr = thr; - if (on) for (int i = 0; i < DOME_PROBE_SLOTS; i++) { s_dome_probe[i].func = 0; s_dome_probe[i].count = 0; s_dome_probe[i].max_sz = 0; } -} -static inline void dome_probe_note(int32_t sz) { - if (s_gte_replay_sandbox) return; - if (!s_dome_probe_on || sz < s_dome_probe_thr) return; - /* Tally the guest RA (the GAME fn that jal'd to libgte for this projection), - * NOT g_debug_current_func_addr (which is the libgte leaf 0x80000F40). */ - uint32_t f = s_gte_caller_ra; - int slot = -1, empty = -1; - for (int i = 0; i < DOME_PROBE_SLOTS; i++) { - if (s_dome_probe[i].count && s_dome_probe[i].func == f) { slot = i; break; } - if (empty < 0 && s_dome_probe[i].count == 0) empty = i; - } - if (slot < 0) slot = empty; - if (slot < 0) return; - s_dome_probe[slot].func = f; - s_dome_probe[slot].count++; - if (sz > s_dome_probe[slot].max_sz) s_dome_probe[slot].max_sz = sz; -} -extern "C" int gte_dome_probe_dump(uint32_t* funcs, uint32_t* counts, int32_t* maxsz, int cap) { - int n = 0; - for (int i = 0; i < DOME_PROBE_SLOTS && n < cap; i++) - if (s_dome_probe[i].count) { funcs[n] = s_dome_probe[i].func; counts[n] = s_dome_probe[i].count; maxsz[n] = s_dome_probe[i].max_sz; n++; } - return n; -} - -// --------------------------------------------------------------------------- -// GTE projection ring (ALWAYS-ON) — records each RTPS/RTPT's inputs (vertex, -// rotation matrix, translation, projection config) and outputs (screen XY / Z / -// FLAG). A post-hoc probe queries this to find flattened/degenerate character -// projections and decide whether the fault is in the INPUTS supplied by the -// recompiled game code (bad matrix/vertex) or the GTE math itself. -// --------------------------------------------------------------------------- -// NOTE: this block is already inside namespace PSXRecomp::GTE (opened line 8), -// so types are referred to unqualified. -extern "C" { extern uint64_t s_frame_count; } -struct GteRtpRec { - uint32_t seq, frame, caller_ra, cmd; - int16_t V0[3], V1[3], V2[3]; - int16_t RT[9]; - int32_t TR[3]; - uint16_t H; int32_t OFX, OFY; - int32_t SXY0, SXY1, SXY2; - uint16_t SZ1, SZ2, SZ3; - uint32_t FLAG; -}; -#define GTE_RTP_RING_CAP (1u << 18) /* 256K raw entries (~a few seconds) */ -static GteRtpRec* s_gte_rtp_ring = nullptr; -static uint64_t s_gte_rtp_seq = 0; - -/* Per-frame aggregate stats (retained ring over recent distinct frames). Lets a - * probe see the alternating flat/normal render pattern without shipping 84k - * projections/sec over JSON. nsat = projections whose newest output vertex - * saturated off-screen; nflat = RTP whose last-3 output verts are collinear - * (area 0) = a flattened triangle. */ -#define GTE_FSTAT_CAP 512 -struct GteFStat { uint32_t frame, nproj, nsat, nflat, nintpl, nintpl_tiny; }; -static GteFStat s_gte_fstat[GTE_FSTAT_CAP]; -static uint32_t s_gte_fstat_head = 0; /* index of most-recent frame slot */ -static int s_gte_fstat_valid = 0; - -/* Degenerate-latch: full records of flat/saturated projections, retained (small - * ring) so intermittent flat frames are caught regardless of raw-ring roll. */ -#define GTE_LATCH_CAP (1u << 13) /* 8192 */ -static GteRtpRec* s_gte_latch = nullptr; -static uint64_t s_gte_latch_seq = 0; - -static inline int gte_sxx(int32_t p){ int v=p&0xFFFF; return v>=0x8000? v-0x10000:v; } -static inline int gte_syy(int32_t p){ int v=(p>>16)&0xFFFF; return v>=0x8000? v-0x10000:v; } - -static void gte_rtp_record(const GTEState* g, uint32_t cmd) { - if (s_gte_replay_sandbox) return; - if (!s_gte_rtp_ring) { - s_gte_rtp_ring = (GteRtpRec*)calloc(GTE_RTP_RING_CAP, sizeof(GteRtpRec)); - if (!s_gte_rtp_ring) return; - } - GteRtpRec* e = &s_gte_rtp_ring[s_gte_rtp_seq % GTE_RTP_RING_CAP]; - e->seq = (uint32_t)s_gte_rtp_seq; - e->frame = (uint32_t)s_frame_count; - e->caller_ra = s_gte_caller_ra; - e->cmd = cmd; - for (int i = 0; i < 3; i++) { e->V0[i]=g->V0[i]; e->V1[i]=g->V1[i]; e->V2[i]=g->V2[i]; } - for (int r = 0; r < 3; r++) for (int c = 0; c < 3; c++) e->RT[r*3+c]=g->RT[r][c]; - for (int i = 0; i < 3; i++) e->TR[i]=g->TR[i]; - e->H=g->H; e->OFX=g->OFX; e->OFY=g->OFY; - e->SXY0=g->SXY[0]; e->SXY1=g->SXY[1]; e->SXY2=g->SXY[2]; - e->SZ1=g->SZ[1]; e->SZ2=g->SZ[2]; e->SZ3=g->SZ[3]; - e->FLAG=g->FLAG; - s_gte_rtp_seq++; - - /* classify */ - int x0=gte_sxx(e->SXY0),y0=gte_syy(e->SXY0); - int x1=gte_sxx(e->SXY1),y1=gte_syy(e->SXY1); - int x2=gte_sxx(e->SXY2),y2=gte_syy(e->SXY2); - int sat = (x2<=-1023||x2>=1023||y2<=-1023||y2>=1023) ? 1 : 0; - long area2 = (long)(x1-x0)*(y2-y0) - (long)(x2-x0)*(y1-y0); - int flat = (area2==0) ? 1 : 0; /* three output verts exactly collinear */ - - /* per-frame stats */ - uint32_t f=(uint32_t)s_frame_count; - if (!s_gte_fstat_valid || s_gte_fstat[s_gte_fstat_head].frame != f) { - s_gte_fstat_head = s_gte_fstat_valid ? (s_gte_fstat_head+1)%GTE_FSTAT_CAP : 0; - s_gte_fstat_valid = 1; - s_gte_fstat[s_gte_fstat_head].frame=f; - s_gte_fstat[s_gte_fstat_head].nproj=0; - s_gte_fstat[s_gte_fstat_head].nsat=0; - s_gte_fstat[s_gte_fstat_head].nflat=0; - s_gte_fstat[s_gte_fstat_head].nintpl=0; - s_gte_fstat[s_gte_fstat_head].nintpl_tiny=0; - } - s_gte_fstat[s_gte_fstat_head].nproj++; - s_gte_fstat[s_gte_fstat_head].nsat += sat; - s_gte_fstat[s_gte_fstat_head].nflat += flat; - - /* latch degenerate (saturated) projections — the "flat/absent" character */ - if (sat) { - if (!s_gte_latch) { - s_gte_latch = (GteRtpRec*)calloc(GTE_LATCH_CAP, sizeof(GteRtpRec)); - if (!s_gte_latch) return; - } - s_gte_latch[s_gte_latch_seq % GTE_LATCH_CAP] = *e; - s_gte_latch_seq++; - } -} - -extern "C" uint64_t gte_rtp_ring_total(void) { return s_gte_rtp_seq; } -extern "C" uint64_t gte_latch_total(void) { return s_gte_latch_seq; } - -// --------------------------------------------------------------------------- -// GTE INTPL ring (ALWAYS-ON) — records each INTPL (0x11) vertex-lerp: inputs -// (IR0 blend, IR1-3 pose-A vertex, FC pose-B vertex) snapshotted BEFORE the op -// and outputs (MAC1-3 / IR1-3 / FLAG) after. Crash Bash tweens character -// vertex animation through INTPL; this ring decides whether collapsed tween -// output comes from already-tiny inputs (upstream CPU/game-state divergence) -// or from the INTPL math itself. -// --------------------------------------------------------------------------- -struct GteIntplRec { - uint32_t seq, frame, caller_ra; - int16_t ir0, in1, in2, in3; - int32_t fc[3]; - int32_t mac[3]; - int16_t out1, out2, out3; - uint32_t flag; -}; -#define GTE_INTPL_RING_CAP (1u << 17) /* 128K entries (~4s at menu rates) */ -static GteIntplRec* s_gte_intpl_ring = nullptr; -static uint64_t s_gte_intpl_seq = 0; - -static void gte_intpl_record(const GTEState* g, - const int16_t pre_ir[4], const int32_t pre_fc[3]) { - if (s_gte_replay_sandbox) return; - if (!s_gte_intpl_ring) { - s_gte_intpl_ring = (GteIntplRec*)calloc(GTE_INTPL_RING_CAP, sizeof(GteIntplRec)); - if (!s_gte_intpl_ring) return; - } - GteIntplRec* e = &s_gte_intpl_ring[s_gte_intpl_seq % GTE_INTPL_RING_CAP]; - e->seq = (uint32_t)s_gte_intpl_seq; - e->frame = (uint32_t)s_frame_count; - e->caller_ra = s_gte_caller_ra; - e->ir0 = pre_ir[0]; e->in1 = pre_ir[1]; e->in2 = pre_ir[2]; e->in3 = pre_ir[3]; - for (int i = 0; i < 3; i++) e->fc[i] = pre_fc[i]; - e->mac[0]=g->MAC1; e->mac[1]=g->MAC2; e->mac[2]=g->MAC3; - e->out1=g->IR1; e->out2=g->IR2; e->out3=g->IR3; - e->flag=g->FLAG; - s_gte_intpl_seq++; - - /* per-frame stats (shares the fstat ring; nintpl_tiny = all outputs small - * while at least one input was not — the collapse signature) */ - uint32_t f=(uint32_t)s_frame_count; - if (!s_gte_fstat_valid || s_gte_fstat[s_gte_fstat_head].frame != f) { - s_gte_fstat_head = s_gte_fstat_valid ? (s_gte_fstat_head+1)%GTE_FSTAT_CAP : 0; - s_gte_fstat_valid = 1; - s_gte_fstat[s_gte_fstat_head].frame=f; - s_gte_fstat[s_gte_fstat_head].nproj=0; - s_gte_fstat[s_gte_fstat_head].nsat=0; - s_gte_fstat[s_gte_fstat_head].nflat=0; - s_gte_fstat[s_gte_fstat_head].nintpl=0; - s_gte_fstat[s_gte_fstat_head].nintpl_tiny=0; - } - s_gte_fstat[s_gte_fstat_head].nintpl++; - { - int out_tiny = (e->out1>-48 && e->out1<48 && e->out2>-48 && e->out2<48 - && e->out3>-48 && e->out3<48); - int in_big = (e->in1<=-48 || e->in1>=48 || e->in2<=-48 || e->in2>=48 - || e->in3<=-48 || e->in3>=48 - || e->fc[0]<=-48 || e->fc[0]>=48 - || e->fc[1]<=-48 || e->fc[1]>=48 - || e->fc[2]<=-48 || e->fc[2]>=48); - if (out_tiny && in_big) s_gte_fstat[s_gte_fstat_head].nintpl_tiny++; - } -} - -extern "C" uint64_t gte_intpl_ring_total(void) { return s_gte_intpl_seq; } - -/* Dump INTPL ring entries as JSON array body. offset skips the first N - * MATCHING entries (after frame filtering), so a probe can page through a - * whole frame regardless of where it sits in the ring. */ -extern "C" int gte_intpl_ring_dump_json(char* out, int outsz, int max_count, - int newest_first, long frame_filter, - int offset) { - if (!s_gte_intpl_ring || outsz < 64) { if (out && outsz) out[0]=0; return 0; } - uint64_t total = s_gte_intpl_seq; - if (total == 0) { out[0]=0; return 0; } - uint64_t oldest = total > GTE_INTPL_RING_CAP ? total - GTE_INTPL_RING_CAP : 0; - int pos = 0, emitted = 0, matched = 0; - for (uint64_t k = 0; k < (total - oldest) && emitted < max_count; k++) { - uint64_t seq = newest_first ? (total - 1 - k) : (oldest + k); - if (seq < oldest || seq >= total) break; - const GteIntplRec* e = &s_gte_intpl_ring[seq % GTE_INTPL_RING_CAP]; - if (frame_filter >= 0 && e->frame != (uint32_t)frame_filter) continue; - if (matched++ < offset) continue; - if (pos > outsz - 400) break; - pos += snprintf(out+pos, outsz-pos, - "%s{\"seq\":%u,\"frame\":%u,\"ra\":\"0x%08X\"," - "\"ir0\":%d,\"in\":[%d,%d,%d],\"fc\":[%d,%d,%d]," - "\"mac\":[%d,%d,%d],\"out\":[%d,%d,%d],\"flag\":\"0x%08X\"}", - emitted?",":"", e->seq, e->frame, e->caller_ra, - e->ir0, e->in1, e->in2, e->in3, e->fc[0], e->fc[1], e->fc[2], - e->mac[0], e->mac[1], e->mac[2], e->out1, e->out2, e->out3, - e->flag); - emitted++; - } - out[pos] = 0; - return emitted; -} - -/* Emit per-frame stats (newest first) as JSON array body. */ -extern "C" int gte_fstat_dump_json(char* out, int outsz, int max_frames) { - if (!s_gte_fstat_valid) { if(out&&outsz) out[0]=0; return 0; } - int pos=0, emitted=0; - for (int k=0; knproj==0 && !(k==0)) continue; - if (pos>outsz-96) break; - pos+=snprintf(out+pos,outsz-pos, - "%s{\"frame\":%u,\"nproj\":%u,\"nsat\":%u,\"nflat\":%u," - "\"nintpl\":%u,\"nintpl_tiny\":%u}", - emitted?",":"", s->frame,s->nproj,s->nsat,s->nflat, - s->nintpl,s->nintpl_tiny); - emitted++; - } - out[pos]=0; return emitted; -} - -/* Dump latched degenerate projections (newest first). */ -extern "C" int gte_latch_dump_json(char* out, int outsz, int max_count) { - if (!s_gte_latch || s_gte_latch_seq==0){ if(out&&outsz) out[0]=0; return 0; } - uint64_t total=s_gte_latch_seq; - uint64_t oldest = total>GTE_LATCH_CAP ? total-GTE_LATCH_CAP : 0; - int pos=0, emitted=0; - for (uint64_t k=0; k<(total-oldest) && emittedoutsz-700) break; - pos+=snprintf(out+pos,outsz-pos, - "%s{\"frame\":%u,\"ra\":\"0x%08X\",\"cmd\":\"0x%08X\"," - "\"RT\":[%d,%d,%d,%d,%d,%d,%d,%d,%d],\"TR\":[%d,%d,%d],\"H\":%u," - "\"V0\":[%d,%d,%d],\"V1\":[%d,%d,%d],\"V2\":[%d,%d,%d]," - "\"S0\":[%d,%d],\"S1\":[%d,%d],\"S2\":[%d,%d],\"SZ\":[%u,%u,%u],\"FLAG\":\"0x%08X\"}", - emitted?",":"", e->frame, e->caller_ra, e->cmd, - e->RT[0],e->RT[1],e->RT[2],e->RT[3],e->RT[4],e->RT[5],e->RT[6],e->RT[7],e->RT[8], - e->TR[0],e->TR[1],e->TR[2],(unsigned)e->H, - e->V0[0],e->V0[1],e->V0[2], e->V1[0],e->V1[1],e->V1[2], e->V2[0],e->V2[1],e->V2[2], - gte_sxx(e->SXY0),gte_syy(e->SXY0),gte_sxx(e->SXY1),gte_syy(e->SXY1), - gte_sxx(e->SXY2),gte_syy(e->SXY2),(unsigned)e->SZ1,(unsigned)e->SZ2,(unsigned)e->SZ3,e->FLAG); - emitted++; - } - out[pos]=0; return emitted; -} - -/* Format up to max_count recent entries as a JSON array body (no outer braces). - * If frame_filter >= 0, only entries with that frame are emitted. Returns count. */ -extern "C" int gte_rtp_ring_dump_json(char* out, int outsz, int max_count, - int newest_first, long frame_filter) { - if (!s_gte_rtp_ring || outsz < 64) { if (out && outsz) out[0]=0; return 0; } - uint64_t total = s_gte_rtp_seq; - if (total == 0) { out[0]=0; return 0; } - uint64_t oldest = total > GTE_RTP_RING_CAP ? total - GTE_RTP_RING_CAP : 0; - int pos = 0, emitted = 0; - for (uint64_t k = 0; k < (total - oldest) && emitted < max_count; k++) { - uint64_t seq = newest_first ? (total - 1 - k) : (oldest + k); - if (seq < oldest || seq >= total) break; - const GteRtpRec* e = &s_gte_rtp_ring[seq % GTE_RTP_RING_CAP]; - if (frame_filter >= 0 && e->frame != (uint32_t)frame_filter) continue; - auto sxx = [](int32_t p){ int v=p&0xFFFF; return v>=0x8000? v-0x10000:v; }; - auto syy = [](int32_t p){ int v=(p>>16)&0xFFFF; return v>=0x8000? v-0x10000:v; }; - if (pos > outsz - 700) break; - pos += snprintf(out+pos, outsz-pos, - "%s{\"seq\":%u,\"frame\":%u,\"ra\":\"0x%08X\",\"cmd\":\"0x%08X\"," - "\"V0\":[%d,%d,%d],\"V1\":[%d,%d,%d],\"V2\":[%d,%d,%d]," - "\"RT\":[%d,%d,%d,%d,%d,%d,%d,%d,%d],\"TR\":[%d,%d,%d]," - "\"H\":%u,\"OFX\":%d,\"OFY\":%d," - "\"S0\":[%d,%d],\"S1\":[%d,%d],\"S2\":[%d,%d]," - "\"SZ\":[%u,%u,%u],\"FLAG\":\"0x%08X\"}", - emitted?",":"", e->seq, e->frame, e->caller_ra, e->cmd, - e->V0[0],e->V0[1],e->V0[2], e->V1[0],e->V1[1],e->V1[2], e->V2[0],e->V2[1],e->V2[2], - e->RT[0],e->RT[1],e->RT[2],e->RT[3],e->RT[4],e->RT[5],e->RT[6],e->RT[7],e->RT[8], - e->TR[0],e->TR[1],e->TR[2], (unsigned)e->H, e->OFX, e->OFY, - sxx(e->SXY0),syy(e->SXY0), sxx(e->SXY1),syy(e->SXY1), sxx(e->SXY2),syy(e->SXY2), - (unsigned)e->SZ1,(unsigned)e->SZ2,(unsigned)e->SZ3, e->FLAG); - emitted++; - } - out[pos] = 0; - return emitted; -} - -extern "C" void gte_set_display_aspect(int num, int den) { - if (num <= 0 || den <= 0) { s_ws_xnum = s_ws_xden = 1; return; } - // squash = (4/3) / (num/den) = (4*den) / (3*num); identity for 4:3. - int32_t n = 4 * den, d = 3 * num; - int32_t a = n, b = d; - while (b) { int32_t t = a % b; a = b; b = t; } // gcd - s_ws_xnum = n / a; - s_ws_xden = d / a; -} - -// --------------------------------------------------------------------------- -// RTPS — Perspective Transformation (internal, operates on given vertex V) -// --------------------------------------------------------------------------- -void gte_rtps_internal(GTEState* gte, int16_t* V, bool setMac0) { - // Step 1: Matrix multiplication + translation - int64_t mac1 = (int64_t)gte->TR[0] * 4096 + - (int64_t)gte->RT[0][0] * V[0] + - (int64_t)gte->RT[0][1] * V[1] + - (int64_t)gte->RT[0][2] * V[2]; - int64_t mac2 = (int64_t)gte->TR[1] * 4096 + - (int64_t)gte->RT[1][0] * V[0] + - (int64_t)gte->RT[1][1] * V[1] + - (int64_t)gte->RT[1][2] * V[2]; - int64_t mac3 = (int64_t)gte->TR[2] * 4096 + - (int64_t)gte->RT[2][0] * V[0] + - (int64_t)gte->RT[2][1] * V[1] + - (int64_t)gte->RT[2][2] * V[2]; - - gte->check_mac_overflow(mac1 >> 12, 1); - gte->check_mac_overflow(mac2 >> 12, 2); - gte->check_mac_overflow(mac3 >> 12, 3); - gte->MAC1 = static_cast(mac1 >> 12); - gte->MAC2 = static_cast(mac2 >> 12); - gte->MAC3 = static_cast(mac3 >> 12); - - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, false); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, false); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, false); - - // Step 2: Push SZ FIFO - gte->push_sz(gte->MAC3); - - // Step 3: Perspective division - int32_t h_div_sz = gte_divide(gte->H, gte->SZ[3], gte->FLAG); - - // Step 4: Project to screen coordinates. Squash X only when configured AND - // this frame is being stretched — never on a 4:3-presented frame (FMV / - // full-2D screen), so content and present stay locked. - int64_t xterm = (int64_t)gte->IR1 * h_div_sz; - bool do_squash = ws_x_squashed(); - const bool dome_call = ws_dome_call_matches(); - // Curved backdrops are authored to cover the original 4:3 projection. - // In classic widescreen, leave that projection intact and let the normal - // final-frame stretch cover the wide output instead of shrinking it first. - if (dome_call) do_squash = false; - if (do_squash && s_ws_suppress_depth != 0) { - /* Depth-gated: un-squash only FAR geometry (the backdrop); keep near - * props squashed so they stay aligned. Record SZ stats for tuning. */ - int32_t sz = gte->SZ[3]; - if (!s_gte_replay_sandbox) { - if (sz < s_ws_sz_min) s_ws_sz_min = sz; - if (sz > s_ws_sz_max) s_ws_sz_max = sz; - s_ws_sz_n++; - } - if (s_ws_backdrop_repair_enabled && sz >= s_ws_far_threshold) { - do_squash = false; - if (!s_gte_replay_sandbox) s_ws_sz_far++; - } - } - if (do_squash) - xterm = xterm * s_ws_xnum / s_ws_xden; - // Native-wide dome expansion remains a diagnostic-only depth probe. - else if (s_ws_dome_on && s_ws_dome_num != s_ws_dome_den && - !gpu_ws_present_native_43()) { - int32_t sz = gte->SZ[3]; - if (!s_gte_replay_sandbox) { - if (sz < s_ws_sz_min) s_ws_sz_min = sz; - if (sz > s_ws_sz_max) s_ws_sz_max = sz; - s_ws_sz_n++; - } - if (sz >= s_ws_far_threshold) { - xterm = xterm * s_ws_dome_num / s_ws_dome_den; - if (!s_gte_replay_sandbox) s_ws_sz_far++; - } - } - dome_probe_note(gte->SZ[3]); /* locate the dome draw fn (far-vertex tally) */ - int64_t sx16 = gte->OFX + xterm; - int64_t sy16 = gte->OFY + (int64_t)gte->IR2 * h_div_sz; - int64_t sx = sx16 >> 16; - int64_t sy = sy16 >> 16; - gte->push_sxy(sx, sy); - if (!s_gte_replay_sandbox) { - s_precise_sxy[0] = s_precise_sxy[1]; - s_precise_sxy[1] = s_precise_sxy[2]; - s_precise_sxy[2].packed = (uint32_t)gte->SXY[2]; - s_precise_sxy[2].x16 = (int32_t)sx16; - s_precise_sxy[2].y16 = (int32_t)sy16; - s_precise_sxy[2].z = gte->SZ[3]; - s_precise_sxy[2].valid = 1; - s_precise_sxy[2].identity = UINT64_C(0x8000000000000000) | - static_cast(static_cast(V[0])) | - static_cast(static_cast(V[1])) << 16 | - static_cast(static_cast(V[2])) << 32; - s_precise_sxy[2].saturated = - static_cast(gte->SXY[2] & 0xFFFF) != sx || - static_cast((uint32_t)gte->SXY[2] >> 16) != sy; - /* The GTE's divide stops at twice the focal length and its screen at 1023: an exact divide does not. */ - const bool stopped = s_precise_sxy[2].saturated || (uint32_t)gte->SZ[3] * 2 <= (uint32_t)gte->H; - s_precise_sxy[2].unbounded = 0; - if (stopped && gte->SZ[3] != 0 && (do_squash || xterm == (int64_t)gte->IR1 * h_div_sz)) { - const int64_t exact = ((int64_t)gte->H << 16) / gte->SZ[3]; - int64_t exact_x = (int64_t)gte->IR1 * exact; - if (do_squash) exact_x = exact_x * s_ws_xnum / s_ws_xden; - int64_t exact_y = (int64_t)gte->IR2 * exact; - /* Too far for a 16.16 coordinate once the screen centre is added: nearer along the same ray from that centre. */ - const int64_t room_x = kUnboundedLimit16 - 1 - std::abs((int64_t)gte->OFX); - const int64_t room_y = kUnboundedLimit16 - 1 - std::abs((int64_t)gte->OFY); - double nearer = 1.0; - if (std::abs(exact_x) > room_x) nearer = (double)room_x / (double)std::abs(exact_x); - if (std::abs(exact_y) > room_y) nearer = std::min(nearer, (double)room_y / (double)std::abs(exact_y)); - if (room_x > 0 && room_y > 0) { - if (nearer < 1.0) { - exact_x = (int64_t)((double)exact_x * nearer); - exact_y = (int64_t)((double)exact_y * nearer); - } - s_precise_sxy[2].exact_x16 = (int32_t)(gte->OFX + exact_x); - s_precise_sxy[2].exact_y16 = (int32_t)(gte->OFY + exact_y); - s_precise_sxy[2].unbounded = 1; - } - } - /* Perspective textures need the original camera depth, not a value - * clamped into SZ3, and must reject the GTE's near divide-overflow - * region. Geometry can still use the retained X/Y when this bit is 0. */ - s_precise_sxy[2].perspective_valid = - gte->H != 0 && gte->MAC3 > 0 && gte->MAC3 <= 0xFFFF && - (uint32_t)gte->SZ[3] * 2u > (uint32_t)gte->H; - s_precise_sxy[3] = s_precise_sxy[2]; - } - // Step 5: Depth cueing (MAC0/IR0) — only for last vertex of RTPT or RTPS - if (setMac0) { - int64_t mac0 = (int64_t)gte->DQA * h_div_sz + gte->DQB; - gte->check_mac0_overflow(mac0); - gte->MAC0 = static_cast(mac0); - gte->IR0 = gte->saturate_ir0(static_cast(mac0 >> 12)); - } -} - -// RTPS (0x01) — single vertex, always sets MAC0 -void gte_rtps(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - gte_rtps_internal(gte, gte->V0, true); - gte->set_error_flag(); -} - -// RTPT (0x30) — triple vertex, only last sets MAC0 -void gte_rtpt(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - gte_rtps_internal(gte, gte->V0, false); - gte_rtps_internal(gte, gte->V1, false); - gte_rtps_internal(gte, gte->V2, true); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// NCLIP (0x06) — Normal Clipping (2D cross product for backface culling) -// --------------------------------------------------------------------------- -void gte_nclip(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - int16_t sx0 = static_cast(gte->SXY[0] & 0xFFFF); - int16_t sy0 = static_cast(gte->SXY[0] >> 16); - int16_t sx1 = static_cast(gte->SXY[1] & 0xFFFF); - int16_t sy1 = static_cast(gte->SXY[1] >> 16); - int16_t sx2 = static_cast(gte->SXY[2] & 0xFFFF); - int16_t sy2 = static_cast(gte->SXY[2] >> 16); - int64_t mac0 = (int64_t)sx0 * (sy1 - sy2) + - (int64_t)sx1 * (sy2 - sy0) + - (int64_t)sx2 * (sy0 - sy1); - gte->check_mac0_overflow(mac0); - gte->MAC0 = static_cast(mac0); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// AVSZ3 (0x2D) — Average Z (3 points) -// --------------------------------------------------------------------------- -void gte_avsz3(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - int64_t mac0 = (int64_t)gte->ZSF3 * (gte->SZ[1] + gte->SZ[2] + gte->SZ[3]); - gte->check_mac0_overflow(mac0); - gte->MAC0 = static_cast(mac0 >> 12); - gte->OTZ = gte->saturate_sz(gte->MAC0); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// AVSZ4 (0x2E) — Average Z (4 points) -// --------------------------------------------------------------------------- -void gte_avsz4(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - int64_t mac0 = (int64_t)gte->ZSF4 * (gte->SZ[0] + gte->SZ[1] + gte->SZ[2] + gte->SZ[3]); - gte->check_mac0_overflow(mac0); - gte->MAC0 = static_cast(mac0 >> 12); - gte->OTZ = gte->saturate_sz(gte->MAC0); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// NCCS (0x1B) — Normal Color Color Single -// --------------------------------------------------------------------------- -void gte_nccs_internal(GTEState* gte, int16_t* V, uint32_t instr) { - light_transform(gte, V, instr); - light_color(gte, instr); - color_output(gte, instr); -} - -void gte_nccs(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - gte_nccs_internal(gte, gte->V0, instr); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// NCCT (0x3F) — Normal Color Color Triple -// --------------------------------------------------------------------------- -void gte_ncct(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - gte_nccs_internal(gte, gte->V0, instr); - gte_nccs_internal(gte, gte->V1, instr); - gte_nccs_internal(gte, gte->V2, instr); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// NCDS (0x13) — Normal Color Depth Cue Single -// --------------------------------------------------------------------------- -void gte_ncds_internal(GTEState* gte, int16_t* V, uint32_t instr) { - light_transform(gte, V, instr); - light_color(gte, instr); - depth_cue_from_ir(gte, instr); - color_output(gte, instr); -} - -void gte_ncds(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - gte_ncds_internal(gte, gte->V0, instr); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// NCDT (0x16) — Normal Color Depth Cue Triple -// --------------------------------------------------------------------------- -void gte_ncdt(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - gte_ncds_internal(gte, gte->V0, instr); - gte_ncds_internal(gte, gte->V1, instr); - gte_ncds_internal(gte, gte->V2, instr); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// NCS (0x1E) — Normal Color Single (no vertex color multiply) -// --------------------------------------------------------------------------- -void gte_ncs_internal(GTEState* gte, int16_t* V, uint32_t instr) { - light_transform(gte, V, instr); - light_color(gte, instr); - // Output directly (no RGBC multiply) - gte->push_rgb( - gte->saturate_color(gte->MAC1 >> 4, 0), - gte->saturate_color(gte->MAC2 >> 4, 1), - gte->saturate_color(gte->MAC3 >> 4, 2)); -} - -void gte_ncs(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - gte_ncs_internal(gte, gte->V0, instr); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// NCT (0x20) — Normal Color Triple -// --------------------------------------------------------------------------- -void gte_nct(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - gte_ncs_internal(gte, gte->V0, instr); - gte_ncs_internal(gte, gte->V1, instr); - gte_ncs_internal(gte, gte->V2, instr); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// DPCS (0x10) — Depth Cueing Single (from RGBC color) -// --------------------------------------------------------------------------- -void gte_dpcs(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - uint8_t r = (gte->RGBC >> 0) & 0xFF; - uint8_t g = (gte->RGBC >> 8) & 0xFF; - uint8_t b = (gte->RGBC >> 16) & 0xFF; - // MAC = RGBC << 4 - gte->IR1 = gte->saturate_ir(r << 4, 1, false); - gte->IR2 = gte->saturate_ir(g << 4, 2, false); - gte->IR3 = gte->saturate_ir(b << 4, 3, false); - // Interpolate toward far color using IR0 - depth_cue_from_ir(gte, instr); - // Output - gte->push_rgb( - gte->saturate_color(gte->MAC1 >> 4, 0), - gte->saturate_color(gte->MAC2 >> 4, 1), - gte->saturate_color(gte->MAC3 >> 4, 2)); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// DPCT (0x2A) — Depth Cueing Triple (from RGB FIFO entries) -// --------------------------------------------------------------------------- -void gte_dpct(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - for (int i = 0; i < 3; i++) { - uint8_t r = (gte->RGB[0] >> 0) & 0xFF; - uint8_t g = (gte->RGB[0] >> 8) & 0xFF; - uint8_t b = (gte->RGB[0] >> 16) & 0xFF; - gte->IR1 = gte->saturate_ir(r << 4, 1, false); - gte->IR2 = gte->saturate_ir(g << 4, 2, false); - gte->IR3 = gte->saturate_ir(b << 4, 3, false); - depth_cue_from_ir(gte, instr); - gte->push_rgb( - gte->saturate_color(gte->MAC1 >> 4, 0), - gte->saturate_color(gte->MAC2 >> 4, 1), - gte->saturate_color(gte->MAC3 >> 4, 2)); - } - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// DPCL (0x29) — Depth Cueing Light (from IR, uses existing lighting result) -// --------------------------------------------------------------------------- -void gte_dpcl(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - // IR already contains lighting result, multiply by RGBC - uint8_t r = (gte->RGBC >> 0) & 0xFF; - uint8_t g = (gte->RGBC >> 8) & 0xFF; - uint8_t b = (gte->RGBC >> 16) & 0xFF; - gte->MAC1 = (r * gte->IR1) >> 8; - gte->MAC2 = (g * gte->IR2) >> 8; - gte->MAC3 = (b * gte->IR3) >> 8; - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, gte_instr_lm(instr)); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, gte_instr_lm(instr)); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, gte_instr_lm(instr)); - // Depth cue toward far color - depth_cue_from_ir(gte, instr); - gte->push_rgb( - gte->saturate_color(gte->MAC1 >> 4, 0), - gte->saturate_color(gte->MAC2 >> 4, 1), - gte->saturate_color(gte->MAC3 >> 4, 2)); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// INTPL (0x11) — Interpolation (IR toward far color using IR0) -// --------------------------------------------------------------------------- -void gte_intpl(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - depth_cue_from_ir(gte, instr); - gte->push_rgb( - gte->saturate_color(gte->MAC1 >> 4, 0), - gte->saturate_color(gte->MAC2 >> 4, 1), - gte->saturate_color(gte->MAC3 >> 4, 2)); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// CDP (0x14) — Color Depth Cue (light color + depth cue, no normal transform) -// --------------------------------------------------------------------------- -void gte_cdp(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - // IR1/IR2/IR3 already set (from previous NCS or similar) - light_color(gte, instr); - depth_cue_from_ir(gte, instr); - color_output(gte, instr); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// CC (0x1C) — Color Color (light color + vertex color, no depth cue) -// --------------------------------------------------------------------------- -void gte_cc(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - light_color(gte, instr); - color_output(gte, instr); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// MVMVA (0x12) — Matrix Vector Multiply Add -// Highly configurable: matrix, vector, and translation selected by bits -// --------------------------------------------------------------------------- -void gte_mvmva(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - uint32_t mx = (instr >> 17) & 3; // Matrix: 0=RT, 1=Light, 2=LightColor, 3=reserved - uint32_t vv = (instr >> 15) & 3; // Vector: 0=V0, 1=V1, 2=V2, 3=IR - uint32_t tv = (instr >> 13) & 3; // Translation: 0=TR, 1=BK, 2=FC/bugged, 3=none - int sf = (instr >> 19) & 1; // Shift: 0=no shift, 1=shift right 12 - - // Select matrix - int16_t M[3][3]; - switch (mx) { - case 0: std::memcpy(M, gte->RT, sizeof(M)); break; - case 1: std::memcpy(M, gte->L, sizeof(M)); break; - case 2: std::memcpy(M, gte->LC, sizeof(M)); break; - default: std::memset(M, 0, sizeof(M)); break; // Garbage on real HW - } - - // Select vector - int16_t V[3]; - switch (vv) { - case 0: V[0] = gte->V0[0]; V[1] = gte->V0[1]; V[2] = gte->V0[2]; break; - case 1: V[0] = gte->V1[0]; V[1] = gte->V1[1]; V[2] = gte->V1[2]; break; - case 2: V[0] = gte->V2[0]; V[1] = gte->V2[1]; V[2] = gte->V2[2]; break; - case 3: V[0] = gte->IR1; V[1] = gte->IR2; V[2] = gte->IR3; break; - } - - // Select translation vector - int64_t T[3]; - switch (tv) { - case 0: T[0] = (int64_t)gte->TR[0] * 4096; T[1] = (int64_t)gte->TR[1] * 4096; T[2] = (int64_t)gte->TR[2] * 4096; break; - case 1: T[0] = (int64_t)gte->BK[0] * 4096; T[1] = (int64_t)gte->BK[1] * 4096; T[2] = (int64_t)gte->BK[2] * 4096; break; - case 2: T[0] = (int64_t)gte->FC[0] * 4096; T[1] = (int64_t)gte->FC[1] * 4096; T[2] = (int64_t)gte->FC[2] * 4096; break; - case 3: T[0] = 0; T[1] = 0; T[2] = 0; break; - } - - // Multiply - int64_t mac1 = T[0] + (int64_t)M[0][0] * V[0] + (int64_t)M[0][1] * V[1] + (int64_t)M[0][2] * V[2]; - int64_t mac2 = T[1] + (int64_t)M[1][0] * V[0] + (int64_t)M[1][1] * V[1] + (int64_t)M[1][2] * V[2]; - int64_t mac3 = T[2] + (int64_t)M[2][0] * V[0] + (int64_t)M[2][1] * V[1] + (int64_t)M[2][2] * V[2]; - - if (sf) { - mac1 >>= 12; mac2 >>= 12; mac3 >>= 12; - } - - gte->check_mac_overflow(mac1, 1); - gte->check_mac_overflow(mac2, 2); - gte->check_mac_overflow(mac3, 3); - - gte->MAC1 = static_cast(mac1); - gte->MAC2 = static_cast(mac2); - gte->MAC3 = static_cast(mac3); - - bool lm = (instr >> 10) & 1; - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, lm); - - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// OP (0x0C) — Outer Product of two vectors (cross product) -// --------------------------------------------------------------------------- -void gte_op(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - int sf = (instr >> 19) & 1; - // D1=RT[0][0], D2=RT[1][1], D3=RT[2][2] (rotation matrix diagonal) - int16_t d1 = gte->RT[0][0]; - int16_t d2 = gte->RT[1][1]; - int16_t d3 = gte->RT[2][2]; - - int64_t mac1 = (int64_t)d2 * gte->IR3 - (int64_t)d3 * gte->IR2; - int64_t mac2 = (int64_t)d3 * gte->IR1 - (int64_t)d1 * gte->IR3; - int64_t mac3 = (int64_t)d1 * gte->IR2 - (int64_t)d2 * gte->IR1; - - if (sf) { mac1 >>= 12; mac2 >>= 12; mac3 >>= 12; } - - gte->MAC1 = static_cast(mac1); - gte->MAC2 = static_cast(mac2); - gte->MAC3 = static_cast(mac3); - - bool lm = (instr >> 10) & 1; - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, lm); - - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// SQR (0x28) — Square of IR vector -// --------------------------------------------------------------------------- -void gte_sqr(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - int sf = (instr >> 19) & 1; - - int64_t mac1 = (int64_t)gte->IR1 * gte->IR1; - int64_t mac2 = (int64_t)gte->IR2 * gte->IR2; - int64_t mac3 = (int64_t)gte->IR3 * gte->IR3; - - if (sf) { mac1 >>= 12; mac2 >>= 12; mac3 >>= 12; } - - gte->MAC1 = static_cast(mac1); - gte->MAC2 = static_cast(mac2); - gte->MAC3 = static_cast(mac3); - - bool lm = (instr >> 10) & 1; - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, lm); - - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// GPF (0x3D) — General Purpose Interpolation (IR0 * IR) -// --------------------------------------------------------------------------- -void gte_gpf(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - int sf = (instr >> 19) & 1; - - int64_t mac1 = (int64_t)gte->IR0 * gte->IR1; - int64_t mac2 = (int64_t)gte->IR0 * gte->IR2; - int64_t mac3 = (int64_t)gte->IR0 * gte->IR3; - - if (sf) { mac1 >>= 12; mac2 >>= 12; mac3 >>= 12; } - - gte->MAC1 = static_cast(mac1); - gte->MAC2 = static_cast(mac2); - gte->MAC3 = static_cast(mac3); - - bool lm = (instr >> 10) & 1; - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, lm); - - gte->push_rgb( - gte->saturate_color(gte->MAC1 >> 4, 0), - gte->saturate_color(gte->MAC2 >> 4, 1), - gte->saturate_color(gte->MAC3 >> 4, 2)); - - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// GPL (0x3E) — General Purpose Interpolation (MAC + IR0 * IR) -// --------------------------------------------------------------------------- -void gte_gpl(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - int sf = (instr >> 19) & 1; - - int64_t mac1 = (sf ? (int64_t)gte->MAC1 * 4096 : (int64_t)gte->MAC1) + (int64_t)gte->IR0 * gte->IR1; - int64_t mac2 = (sf ? (int64_t)gte->MAC2 * 4096 : (int64_t)gte->MAC2) + (int64_t)gte->IR0 * gte->IR2; - int64_t mac3 = (sf ? (int64_t)gte->MAC3 * 4096 : (int64_t)gte->MAC3) + (int64_t)gte->IR0 * gte->IR3; - - if (sf) { mac1 >>= 12; mac2 >>= 12; mac3 >>= 12; } - - gte->MAC1 = static_cast(mac1); - gte->MAC2 = static_cast(mac2); - gte->MAC3 = static_cast(mac3); - - bool lm = (instr >> 10) & 1; - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, lm); - - gte->push_rgb( - gte->saturate_color(gte->MAC1 >> 4, 0), - gte->saturate_color(gte->MAC2 >> 4, 1), - gte->saturate_color(gte->MAC3 >> 4, 2)); - - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// MTC2 — Move To Coprocessor 2 (write GTE data register) -// --------------------------------------------------------------------------- -void gte_mtc2(GTEState* gte, uint8_t reg, uint32_t value) { - switch (reg) { - case 0: gte->V0[0] = value & 0xFFFF; gte->V0[1] = value >> 16; break; - case 1: gte->V0[2] = value & 0xFFFF; break; - case 2: gte->V1[0] = value & 0xFFFF; gte->V1[1] = value >> 16; break; - case 3: gte->V1[2] = value & 0xFFFF; break; - case 4: gte->V2[0] = value & 0xFFFF; gte->V2[1] = value >> 16; break; - case 5: gte->V2[2] = value & 0xFFFF; break; - case 6: gte->RGBC = value; break; - case 7: gte->OTZ = value & 0xFFFF; break; - case 8: gte->IR0 = static_cast(value & 0xFFFF); break; - case 9: gte->IR1 = static_cast(value & 0xFFFF); break; - case 10: gte->IR2 = static_cast(value & 0xFFFF); break; - case 11: gte->IR3 = static_cast(value & 0xFFFF); break; - case 12: gte->SXY[0] = value; break; - case 13: gte->SXY[1] = value; break; - case 14: gte->SXY[2] = value; gte->SXY[3] = value; break; - case 15: // SXYP — push to FIFO - gte->SXY[0] = gte->SXY[1]; gte->SXY[1] = gte->SXY[2]; - gte->SXY[2] = value; gte->SXY[3] = value; break; - case 16: gte->SZ[0] = value & 0xFFFF; break; - case 17: gte->SZ[1] = value & 0xFFFF; break; - case 18: gte->SZ[2] = value & 0xFFFF; break; - case 19: gte->SZ[3] = value & 0xFFFF; break; - case 20: gte->RGB[0] = value; break; - case 21: gte->RGB[1] = value; break; - case 22: gte->RGB[2] = value; break; - case 23: break; // RES1 reserved - case 24: gte->MAC0 = value; break; - case 25: gte->MAC1 = value; break; - case 26: gte->MAC2 = value; break; - case 27: gte->MAC3 = value; break; - case 28: // IRGB — packed 5-bit RGB → IR1/IR2/IR3 - gte->IR1 = (value & 0x1F) << 7; - gte->IR2 = ((value >> 5) & 0x1F) << 7; - gte->IR3 = ((value >> 10) & 0x1F) << 7; - break; - case 29: break; // ORGB read-only - case 30: // LZCS — set source and compute LZCR - gte->LZCS = static_cast(value); - { - uint32_t v = value; - if ((int32_t)value < 0) v = ~v; - int cnt = 0; - if (v == 0) { cnt = 32; } - else { while (!(v & 0x80000000u)) { v <<= 1; cnt++; } } - gte->LZCR = cnt; - } - break; - case 31: break; // LZCR read-only - default: break; - } -} - -// --------------------------------------------------------------------------- -// MFC2 — Move From Coprocessor 2 (read GTE data register) -// --------------------------------------------------------------------------- -uint32_t gte_mfc2(GTEState* gte, uint8_t reg) { - switch (reg) { - case 0: return (static_cast(gte->V0[1]) << 16) | static_cast(gte->V0[0]); - case 1: return static_cast(gte->V0[2]); - case 2: return (static_cast(gte->V1[1]) << 16) | static_cast(gte->V1[0]); - case 3: return static_cast(gte->V1[2]); - case 4: return (static_cast(gte->V2[1]) << 16) | static_cast(gte->V2[0]); - case 5: return static_cast(gte->V2[2]); - case 6: return gte->RGBC; - case 7: return gte->OTZ; - case 8: return static_cast(gte->IR0); // sign-extend - case 9: return static_cast(gte->IR1); - case 10: return static_cast(gte->IR2); - case 11: return static_cast(gte->IR3); - case 12: return gte->SXY[0]; - case 13: return gte->SXY[1]; - case 14: return gte->SXY[2]; - case 15: return gte->SXY[3]; - case 16: return gte->SZ[0]; - case 17: return gte->SZ[1]; - case 18: return gte->SZ[2]; - case 19: return gte->SZ[3]; - case 20: return gte->RGB[0]; - case 21: return gte->RGB[1]; - case 22: return gte->RGB[2]; - case 23: return 0; // RES1 - case 24: return gte->MAC0; - case 25: return gte->MAC1; - case 26: return gte->MAC2; - case 27: return gte->MAC3; - case 28: case 29: { // IRGB/ORGB - uint32_t r = std::clamp((int)(gte->IR1 >> 7), 0, 0x1F); - uint32_t g = std::clamp((int)(gte->IR2 >> 7), 0, 0x1F); - uint32_t b = std::clamp((int)(gte->IR3 >> 7), 0, 0x1F); - return (b << 10) | (g << 5) | r; - } - case 30: return gte->LZCS; - case 31: return gte->LZCR; - default: return 0; - } -} - -// --------------------------------------------------------------------------- -// CTC2 — Move To Coprocessor 2 Control (write GTE control register) -// --------------------------------------------------------------------------- -void gte_ctc2(GTEState* gte, uint8_t reg, uint32_t value) { - switch (reg) { - case 0: gte->RT[0][0] = value & 0xFFFF; gte->RT[0][1] = value >> 16; break; - case 1: gte->RT[0][2] = value & 0xFFFF; gte->RT[1][0] = value >> 16; break; - case 2: gte->RT[1][1] = value & 0xFFFF; gte->RT[1][2] = value >> 16; break; - case 3: gte->RT[2][0] = value & 0xFFFF; gte->RT[2][1] = value >> 16; break; - case 4: gte->RT[2][2] = value & 0xFFFF; break; - case 5: gte->TR[0] = value; break; - case 6: gte->TR[1] = value; break; - case 7: gte->TR[2] = value; break; - case 8: gte->L[0][0] = value & 0xFFFF; gte->L[0][1] = value >> 16; break; - case 9: gte->L[0][2] = value & 0xFFFF; gte->L[1][0] = value >> 16; break; - case 10: gte->L[1][1] = value & 0xFFFF; gte->L[1][2] = value >> 16; break; - case 11: gte->L[2][0] = value & 0xFFFF; gte->L[2][1] = value >> 16; break; - case 12: gte->L[2][2] = value & 0xFFFF; break; - case 13: gte->BK[0] = value; break; - case 14: gte->BK[1] = value; break; - case 15: gte->BK[2] = value; break; - case 16: gte->LC[0][0] = value & 0xFFFF; gte->LC[0][1] = value >> 16; break; - case 17: gte->LC[0][2] = value & 0xFFFF; gte->LC[1][0] = value >> 16; break; - case 18: gte->LC[1][1] = value & 0xFFFF; gte->LC[1][2] = value >> 16; break; - case 19: gte->LC[2][0] = value & 0xFFFF; gte->LC[2][1] = value >> 16; break; - case 20: gte->LC[2][2] = value & 0xFFFF; break; - case 21: gte->FC[0] = value; break; - case 22: gte->FC[1] = value; break; - case 23: gte->FC[2] = value; break; - case 24: gte->OFX = value; break; - case 25: gte->OFY = value; break; - case 26: gte->H = value & 0xFFFF; break; - case 27: gte->DQA = static_cast(value & 0xFFFF); break; - case 28: gte->DQB = value; break; - case 29: gte->ZSF3 = static_cast(value & 0xFFFF); break; - case 30: gte->ZSF4 = static_cast(value & 0xFFFF); break; - case 31: gte->FLAG = value & 0x7FFFF000u; break; - default: break; - } -} - -// --------------------------------------------------------------------------- -// CFC2 — Move From Coprocessor 2 Control (read GTE control register) -// --------------------------------------------------------------------------- -uint32_t gte_cfc2(GTEState* gte, uint8_t reg) { - switch (reg) { - case 0: return (static_cast(gte->RT[0][1]) << 16) | static_cast(gte->RT[0][0]); - case 1: return (static_cast(gte->RT[1][0]) << 16) | static_cast(gte->RT[0][2]); - case 2: return (static_cast(gte->RT[1][2]) << 16) | static_cast(gte->RT[1][1]); - case 3: return (static_cast(gte->RT[2][1]) << 16) | static_cast(gte->RT[2][0]); - case 4: return static_cast(gte->RT[2][2]); - case 5: return gte->TR[0]; - case 6: return gte->TR[1]; - case 7: return gte->TR[2]; - case 8: return (static_cast(gte->L[0][1]) << 16) | static_cast(gte->L[0][0]); - case 9: return (static_cast(gte->L[1][0]) << 16) | static_cast(gte->L[0][2]); - case 10: return (static_cast(gte->L[1][2]) << 16) | static_cast(gte->L[1][1]); - case 11: return (static_cast(gte->L[2][1]) << 16) | static_cast(gte->L[2][0]); - case 12: return static_cast(gte->L[2][2]); - case 13: return gte->BK[0]; - case 14: return gte->BK[1]; - case 15: return gte->BK[2]; - case 16: return (static_cast(gte->LC[0][1]) << 16) | static_cast(gte->LC[0][0]); - case 17: return (static_cast(gte->LC[1][0]) << 16) | static_cast(gte->LC[0][2]); - case 18: return (static_cast(gte->LC[1][2]) << 16) | static_cast(gte->LC[1][1]); - case 19: return (static_cast(gte->LC[2][1]) << 16) | static_cast(gte->LC[2][0]); - case 20: return static_cast(gte->LC[2][2]); - case 21: return gte->FC[0]; - case 22: return gte->FC[1]; - case 23: return gte->FC[2]; - case 24: return gte->OFX; - case 25: return gte->OFY; - case 26: return gte->H; - case 27: return static_cast(gte->DQA); // sign-extend - case 28: return gte->DQB; - case 29: return static_cast(gte->ZSF3); - case 30: return static_cast(gte->ZSF4); - case 31: return gte->FLAG; - default: return 0; - } -} - -} // namespace GTE -} // namespace PSXRecomp - -// --------------------------------------------------------------------------- -// gte_execute — C-linkage bridge called from generated code -// --------------------------------------------------------------------------- -static uint64_t s_gte_exec_count = 0; -extern "C" uint64_t gte_get_exec_count(void) { return s_gte_exec_count; } -static uint32_t s_gte_replay_saved_caller_ra = 0; -extern "C" int gte_replay_side_effects_begin(void) { - using namespace PSXRecomp::GTE; - if (s_gte_replay_sandbox) return 0; - gte_precision_word_write_begin(); - s_gte_replay_saved_caller_ra = s_gte_caller_ra; - s_gte_replay_sandbox = 1; - return 1; -} -extern "C" void gte_replay_side_effects_end(void) { - using namespace PSXRecomp::GTE; - if (!s_gte_replay_sandbox) return; - s_gte_caller_ra = s_gte_replay_saved_caller_ra; - s_gte_replay_sandbox = 0; - gte_precision_word_write_begin(); -} - -static uint32_t gte_cpu_lzcr(uint32_t value); - -static inline int16_t gte_unpack_s16(uint32_t value) { - return static_cast(value & 0xFFFFu); -} - -static inline uint32_t gte_pack_s16_pair(int16_t lo, int16_t hi) { - return static_cast(static_cast(lo)) | - (static_cast(static_cast(hi)) << 16); -} - -/* Exact full marshaling for GTE commands. Keep this explicit: GTEState has - * mixed-width fields and padding, while CPUState exposes architectural 32-bit - * register words. Whole-struct copies would be layout/endian dependent. */ -static void gte_import_cpu_state(PSXRecomp::GTE::GTEState* gte, - const CPUState* cpu) { - const uint32_t* d = cpu->gte_data; - const uint32_t* c = cpu->gte_ctrl; - - gte->V0[0] = gte_unpack_s16(d[0]); - gte->V0[1] = gte_unpack_s16(d[0] >> 16); - gte->V0[2] = gte_unpack_s16(d[1]); - gte->V1[0] = gte_unpack_s16(d[2]); - gte->V1[1] = gte_unpack_s16(d[2] >> 16); - gte->V1[2] = gte_unpack_s16(d[3]); - gte->V2[0] = gte_unpack_s16(d[4]); - gte->V2[1] = gte_unpack_s16(d[4] >> 16); - gte->V2[2] = gte_unpack_s16(d[5]); - gte->RGBC = d[6]; - gte->OTZ = static_cast(d[7]); - gte->IR0 = gte_unpack_s16(d[8]); - gte->IR1 = gte_unpack_s16(d[9]); - gte->IR2 = gte_unpack_s16(d[10]); - gte->IR3 = gte_unpack_s16(d[11]); - gte->SXY[0] = static_cast(d[12]); - gte->SXY[1] = static_cast(d[13]); - gte->SXY[2] = static_cast(d[14]); - gte->SXY[3] = static_cast(d[14]); - for (int i = 0; i < 4; ++i) gte->SZ[i] = static_cast(d[16 + i]); - for (int i = 0; i < 3; ++i) gte->RGB[i] = d[20 + i]; - gte->MAC0 = static_cast(d[24]); - gte->MAC1 = static_cast(d[25]); - gte->MAC2 = static_cast(d[26]); - gte->MAC3 = static_cast(d[27]); - /* Skip IRGB (28): importing it would quantize and overwrite IR1..3. */ - gte->LZCS = static_cast(d[30]); - gte->LZCR = static_cast(gte_cpu_lzcr(d[30])); - - gte->RT[0][0] = gte_unpack_s16(c[0]); - gte->RT[0][1] = gte_unpack_s16(c[0] >> 16); - gte->RT[0][2] = gte_unpack_s16(c[1]); - gte->RT[1][0] = gte_unpack_s16(c[1] >> 16); - gte->RT[1][1] = gte_unpack_s16(c[2]); - gte->RT[1][2] = gte_unpack_s16(c[2] >> 16); - gte->RT[2][0] = gte_unpack_s16(c[3]); - gte->RT[2][1] = gte_unpack_s16(c[3] >> 16); - gte->RT[2][2] = gte_unpack_s16(c[4]); - for (int i = 0; i < 3; ++i) gte->TR[i] = static_cast(c[5 + i]); - - gte->L[0][0] = gte_unpack_s16(c[8]); - gte->L[0][1] = gte_unpack_s16(c[8] >> 16); - gte->L[0][2] = gte_unpack_s16(c[9]); - gte->L[1][0] = gte_unpack_s16(c[9] >> 16); - gte->L[1][1] = gte_unpack_s16(c[10]); - gte->L[1][2] = gte_unpack_s16(c[10] >> 16); - gte->L[2][0] = gte_unpack_s16(c[11]); - gte->L[2][1] = gte_unpack_s16(c[11] >> 16); - gte->L[2][2] = gte_unpack_s16(c[12]); - for (int i = 0; i < 3; ++i) gte->BK[i] = static_cast(c[13 + i]); - - gte->LC[0][0] = gte_unpack_s16(c[16]); - gte->LC[0][1] = gte_unpack_s16(c[16] >> 16); - gte->LC[0][2] = gte_unpack_s16(c[17]); - gte->LC[1][0] = gte_unpack_s16(c[17] >> 16); - gte->LC[1][1] = gte_unpack_s16(c[18]); - gte->LC[1][2] = gte_unpack_s16(c[18] >> 16); - gte->LC[2][0] = gte_unpack_s16(c[19]); - gte->LC[2][1] = gte_unpack_s16(c[19] >> 16); - gte->LC[2][2] = gte_unpack_s16(c[20]); - for (int i = 0; i < 3; ++i) gte->FC[i] = static_cast(c[21 + i]); - gte->OFX = static_cast(c[24]); - gte->OFY = static_cast(c[25]); - gte->H = static_cast(c[26]); - gte->DQA = gte_unpack_s16(c[27]); - gte->DQB = static_cast(c[28]); - gte->ZSF3 = gte_unpack_s16(c[29]); - gte->ZSF4 = gte_unpack_s16(c[30]); - gte->FLAG = c[31] & 0x7FFFF000u; -} - -static uint32_t gte_state_pack_irgb(const PSXRecomp::GTE::GTEState* gte) { - const uint32_t r = static_cast(std::clamp( - static_cast(gte->IR1 >> 7), 0, 0x1F)); - const uint32_t g = static_cast(std::clamp( - static_cast(gte->IR2 >> 7), 0, 0x1F)); - const uint32_t b = static_cast(std::clamp( - static_cast(gte->IR3 >> 7), 0, 0x1F)); - return (b << 10) | (g << 5) | r; -} - -static void gte_export_cpu_state(CPUState* cpu, - const PSXRecomp::GTE::GTEState* gte) { - uint32_t* d = cpu->gte_data; - uint32_t* c = cpu->gte_ctrl; - - d[0] = gte_pack_s16_pair(gte->V0[0], gte->V0[1]); - d[1] = static_cast(gte->V0[2]); - d[2] = gte_pack_s16_pair(gte->V1[0], gte->V1[1]); - d[3] = static_cast(gte->V1[2]); - d[4] = gte_pack_s16_pair(gte->V2[0], gte->V2[1]); - d[5] = static_cast(gte->V2[2]); - d[6] = gte->RGBC; - d[7] = gte->OTZ; - d[8] = static_cast(static_cast(gte->IR0)); - d[9] = static_cast(static_cast(gte->IR1)); - d[10] = static_cast(static_cast(gte->IR2)); - d[11] = static_cast(static_cast(gte->IR3)); - d[12] = static_cast(gte->SXY[0]); - d[13] = static_cast(gte->SXY[1]); - d[14] = static_cast(gte->SXY[2]); - d[15] = static_cast(gte->SXY[3]); - for (int i = 0; i < 4; ++i) d[16 + i] = gte->SZ[i]; - for (int i = 0; i < 3; ++i) d[20 + i] = gte->RGB[i]; - d[23] = 0; - d[24] = static_cast(gte->MAC0); - d[25] = static_cast(gte->MAC1); - d[26] = static_cast(gte->MAC2); - d[27] = static_cast(gte->MAC3); - d[28] = d[29] = gte_state_pack_irgb(gte); - d[30] = static_cast(gte->LZCS); - d[31] = static_cast(gte->LZCR); - - c[0] = gte_pack_s16_pair(gte->RT[0][0], gte->RT[0][1]); - c[1] = gte_pack_s16_pair(gte->RT[0][2], gte->RT[1][0]); - c[2] = gte_pack_s16_pair(gte->RT[1][1], gte->RT[1][2]); - c[3] = gte_pack_s16_pair(gte->RT[2][0], gte->RT[2][1]); - c[4] = static_cast(gte->RT[2][2]); - for (int i = 0; i < 3; ++i) c[5 + i] = static_cast(gte->TR[i]); - c[8] = gte_pack_s16_pair(gte->L[0][0], gte->L[0][1]); - c[9] = gte_pack_s16_pair(gte->L[0][2], gte->L[1][0]); - c[10] = gte_pack_s16_pair(gte->L[1][1], gte->L[1][2]); - c[11] = gte_pack_s16_pair(gte->L[2][0], gte->L[2][1]); - c[12] = static_cast(gte->L[2][2]); - for (int i = 0; i < 3; ++i) c[13 + i] = static_cast(gte->BK[i]); - c[16] = gte_pack_s16_pair(gte->LC[0][0], gte->LC[0][1]); - c[17] = gte_pack_s16_pair(gte->LC[0][2], gte->LC[1][0]); - c[18] = gte_pack_s16_pair(gte->LC[1][1], gte->LC[1][2]); - c[19] = gte_pack_s16_pair(gte->LC[2][0], gte->LC[2][1]); - c[20] = static_cast(gte->LC[2][2]); - for (int i = 0; i < 3; ++i) c[21 + i] = static_cast(gte->FC[i]); - c[24] = static_cast(gte->OFX); - c[25] = static_cast(gte->OFY); - c[26] = gte->H; - c[27] = static_cast(static_cast(gte->DQA)); - c[28] = static_cast(gte->DQB); - c[29] = static_cast(static_cast(gte->ZSF3)); - c[30] = static_cast(static_cast(gte->ZSF4)); - c[31] = gte->FLAG; -} - -static void gte_run_command(PSXRecomp::GTE::GTEState* gte, uint32_t cmd) { - using namespace PSXRecomp::GTE; - switch (cmd & 0x3Fu) { - case 0x01: gte_rtps(gte, cmd); break; - case 0x06: gte_nclip(gte, cmd); break; - case 0x0C: gte_op(gte, cmd); break; - case 0x10: gte_dpcs(gte, cmd); break; - case 0x11: gte_intpl(gte, cmd); break; - case 0x12: gte_mvmva(gte, cmd); break; - case 0x13: gte_ncds(gte, cmd); break; - case 0x14: gte_cdp(gte, cmd); break; - case 0x16: gte_ncdt(gte, cmd); break; - case 0x1B: gte_nccs(gte, cmd); break; - case 0x1C: gte_cc(gte, cmd); break; - case 0x1E: gte_ncs(gte, cmd); break; - case 0x20: gte_nct(gte, cmd); break; - case 0x28: gte_sqr(gte, cmd); break; - case 0x29: gte_dpcl(gte, cmd); break; - case 0x2A: gte_dpct(gte, cmd); break; - case 0x2D: gte_avsz3(gte, cmd); break; - case 0x2E: gte_avsz4(gte, cmd); break; - case 0x30: gte_rtpt(gte, cmd); break; - case 0x3D: gte_gpf(gte, cmd); break; - case 0x3E: gte_gpl(gte, cmd); break; - case 0x3F: gte_ncct(gte, cmd); break; - default: std::exit(1); - } -} - -extern "C" void gte_execute(CPUState* cpu, uint32_t cmd) { - using namespace PSXRecomp::GTE; -#ifndef PSX_NO_DEBUG_TOOLS - if (!s_gte_replay_sandbox) s_gte_exec_count++; - s_gte_caller_ra = cpu->gpr[31]; /* dome-locate probe: game fn that issued this projection */ -#endif - - GTEState gte; - gte_import_cpu_state(>e, cpu); - - uint8_t func = cmd & 0x3F; - /* GTE-activity gameplay detector ([widescreen] gte_game_mode): note every - * perspective projection so gpu.c can stamp real 3D frames as gameplay - * (no-op unless the game opts in — early-out on the config flag). */ - if (!s_gte_replay_sandbox && (func == 0x01 || func == 0x30)) - psx_ws_note_gte_project(func == 0x30 ? 3 : 1); -#ifndef PSX_NO_DEBUG_TOOLS - /* INTPL ring: snapshot inputs before the op mutates IR (outputs recorded - * after the switch). Debug tooling only; production does no probe copies. */ - int16_t intpl_pre_ir[4] = {0,0,0,0}; - int32_t intpl_pre_fc[3] = {0,0,0}; - if (func == 0x11) { - intpl_pre_ir[0]=gte.IR0; intpl_pre_ir[1]=gte.IR1; - intpl_pre_ir[2]=gte.IR2; intpl_pre_ir[3]=gte.IR3; - intpl_pre_fc[0]=gte.FC[0]; intpl_pre_fc[1]=gte.FC[1]; intpl_pre_fc[2]=gte.FC[2]; - } -#endif - gte_run_command(>e, cmd); - -#ifndef PSX_NO_DEBUG_TOOLS - if (func == 0x01 || func == 0x30) gte_rtp_record(>e, cmd); - if (func == 0x11) gte_intpl_record(>e, intpl_pre_ir, intpl_pre_fc); -#endif - - gte_export_cpu_state(cpu, >e); - -#ifdef PSX_ENABLE_BLOCK_CYCLES - /* Faithful GTE command completion-stall: arm the per-command deadline - * (serializing back-to-back ops). Any later COP2 register access stalls to - * it. Single shared site for BOTH backends (compiled + dirty interp both - * route GTE commands through gte_execute). */ - psx_gte_set(cpu, psx_gte_cmd_latency(cmd)); -#endif -} - -#ifdef PSX_GTE_REGISTER_TEST -/* Preserved pre-optimization bridge: test oracle for full command marshaling. */ -extern "C" void gte_test_execute_reference(CPUState* cpu, uint32_t cmd) { - using namespace PSXRecomp::GTE; - GTEState gte; - for (uint8_t reg = 0; reg < 32; ++reg) { - if (reg == 15 || reg == 28) continue; - gte_mtc2(>e, reg, cpu->gte_data[reg]); - } - for (uint8_t reg = 0; reg < 32; ++reg) - gte_ctc2(>e, reg, cpu->gte_ctrl[reg]); - switch (cmd & 0x3Fu) { - case 0x01: gte_rtps(>e, cmd); break; - case 0x06: gte_nclip(>e, cmd); break; - case 0x0C: gte_op(>e, cmd); break; - case 0x10: gte_dpcs(>e, cmd); break; - case 0x11: gte_intpl(>e, cmd); break; - case 0x12: gte_mvmva(>e, cmd); break; - case 0x13: gte_ncds(>e, cmd); break; - case 0x14: gte_cdp(>e, cmd); break; - case 0x16: gte_ncdt(>e, cmd); break; - case 0x1B: gte_nccs(>e, cmd); break; - case 0x1C: gte_cc(>e, cmd); break; - case 0x1E: gte_ncs(>e, cmd); break; - case 0x20: gte_nct(>e, cmd); break; - case 0x28: gte_sqr(>e, cmd); break; - case 0x29: gte_dpcl(>e, cmd); break; - case 0x2A: gte_dpct(>e, cmd); break; - case 0x2D: gte_avsz3(>e, cmd); break; - case 0x2E: gte_avsz4(>e, cmd); break; - case 0x30: gte_rtpt(>e, cmd); break; - case 0x3D: gte_gpf(>e, cmd); break; - case 0x3E: gte_gpl(>e, cmd); break; - case 0x3F: gte_ncct(>e, cmd); break; - default: std::exit(1); - } - for (uint8_t reg = 0; reg < 32; ++reg) - cpu->gte_data[reg] = gte_mfc2(>e, reg); - for (uint8_t reg = 0; reg < 32; ++reg) - cpu->gte_ctrl[reg] = gte_cfc2(>e, reg); -} -#endif - -/* C-callable wrappers for GTE register transfers. - * - * CPUState's arrays are the canonical GTE backing store between commands. - * The old wrappers rebuilt a full GTEState by replaying 62 register writes - * for every read, and rebuilt plus exported 64 registers for every write. - * Transfers are common in vertex loops, so that exact-but-accidental work was - * a severe interpreter-only tax. These switches implement the same guest - * register semantics in O(1), without changing the command implementation. */ - -static uint32_t gte_cpu_lzcr(uint32_t value) { - uint32_t bits = (value & 0x80000000u) ? ~value : value; - if (bits == 0) return 32; - uint32_t count = 0; - while ((bits & 0x80000000u) == 0) { - bits <<= 1; - ++count; - } - return count; -} - -static uint32_t gte_cpu_irgb_component(uint32_t value) { - const int32_t ir = static_cast(value & 0xFFFFu); - if (ir <= 0) return 0; - const uint32_t scaled = static_cast(ir) >> 7; - return scaled > 0x1Fu ? 0x1Fu : scaled; -} - -static uint32_t gte_cpu_pack_irgb(const CPUState* cpu) { - const uint32_t r = gte_cpu_irgb_component(cpu->gte_data[9]); - const uint32_t g = gte_cpu_irgb_component(cpu->gte_data[10]); - const uint32_t b = gte_cpu_irgb_component(cpu->gte_data[11]); - return (b << 10) | (g << 5) | r; -} - -static uint32_t gte_sign_extend_16(uint32_t value) { - return static_cast(static_cast( - static_cast(value & 0xFFFFu))); -} - -/* Normalize only the guest-visible backing words. This deliberately does not - * invalidate host precision caches: it is also the compatibility boundary for - * committed AOT C emitted before all masked/aliased writes used helpers. */ -static void gte_cpu_canonicalize_backing(CPUState* cpu) { - static const uint8_t data_u16[] = {1, 3, 5, 7, 16, 17, 18, 19}; - static const uint8_t data_s16[] = {8, 9, 10, 11}; - static const uint8_t ctrl_u16[] = {4, 12, 20, 26}; - static const uint8_t ctrl_s16[] = {27, 29, 30}; - - for (uint8_t reg : data_u16) cpu->gte_data[reg] &= 0xFFFFu; - for (uint8_t reg : data_s16) - cpu->gte_data[reg] = gte_sign_extend_16(cpu->gte_data[reg]); - cpu->gte_data[15] = cpu->gte_data[14]; - cpu->gte_data[23] = 0; - cpu->gte_data[28] = cpu->gte_data[29] = gte_cpu_pack_irgb(cpu); - cpu->gte_data[31] = gte_cpu_lzcr(cpu->gte_data[30]); - - for (uint8_t reg : ctrl_u16) cpu->gte_ctrl[reg] &= 0xFFFFu; - for (uint8_t reg : ctrl_s16) - cpu->gte_ctrl[reg] = gte_sign_extend_16(cpu->gte_ctrl[reg]); -} - -extern "C" uint32_t gte_read_data(CPUState* cpu, uint8_t reg) { - if (reg >= 32) return 0; - switch (reg) { - case 1: case 3: case 5: case 7: - case 16: case 17: case 18: case 19: - return cpu->gte_data[reg] & 0xFFFFu; - case 8: case 9: case 10: case 11: - return gte_sign_extend_16(cpu->gte_data[reg]); - case 15: - return cpu->gte_data[14]; - case 23: - return 0; - case 28: case 29: - return gte_cpu_pack_irgb(cpu); - case 31: - return gte_cpu_lzcr(cpu->gte_data[30]); - default: - return cpu->gte_data[reg]; - } -} - -extern "C" uint32_t gte_read_ctrl(CPUState* cpu, uint8_t reg) { - if (reg >= 32) return 0; - switch (reg) { - case 4: case 12: case 20: case 26: - return cpu->gte_ctrl[reg] & 0xFFFFu; - case 27: case 29: case 30: - return gte_sign_extend_16(cpu->gte_ctrl[reg]); - default: - return cpu->gte_ctrl[reg]; - } -} - -extern "C" void gte_write_data(CPUState* cpu, uint8_t reg, uint32_t val) { - if (reg >= 32) return; - gte_cpu_canonicalize_backing(cpu); - switch (reg) { - case 1: case 3: case 5: case 7: - case 16: case 17: case 18: case 19: - cpu->gte_data[reg] = val & 0xFFFFu; - break; - case 8: case 9: case 10: case 11: - cpu->gte_data[reg] = gte_sign_extend_16(val); - if (reg >= 9) { - const uint32_t packed = gte_cpu_pack_irgb(cpu); - cpu->gte_data[28] = packed; - cpu->gte_data[29] = packed; - } - break; - case 12: case 13: - cpu->gte_data[reg] = val; - if (!PSXRecomp::GTE::s_gte_replay_sandbox) - for (int i = 0; i < 4; ++i) PSXRecomp::GTE::s_precise_sxy[i].valid = 0; - break; - case 14: - cpu->gte_data[14] = val; - cpu->gte_data[15] = val; - if (!PSXRecomp::GTE::s_gte_replay_sandbox) - for (int i = 0; i < 4; ++i) PSXRecomp::GTE::s_precise_sxy[i].valid = 0; - break; - case 15: - cpu->gte_data[12] = cpu->gte_data[13]; - cpu->gte_data[13] = cpu->gte_data[14]; - cpu->gte_data[14] = val; - cpu->gte_data[15] = val; - if (!PSXRecomp::GTE::s_gte_replay_sandbox) - for (int i = 0; i < 4; ++i) PSXRecomp::GTE::s_precise_sxy[i].valid = 0; - break; - case 23: - cpu->gte_data[23] = 0; - break; - case 28: { - cpu->gte_data[9] = (val & 0x1Fu) << 7; - cpu->gte_data[10] = ((val >> 5) & 0x1Fu) << 7; - cpu->gte_data[11] = ((val >> 10) & 0x1Fu) << 7; - const uint32_t packed = val & 0x7FFFu; - cpu->gte_data[28] = packed; - cpu->gte_data[29] = packed; - break; - } - case 29: { - const uint32_t packed = gte_cpu_pack_irgb(cpu); - cpu->gte_data[28] = packed; - cpu->gte_data[29] = packed; - break; - } - case 30: - cpu->gte_data[30] = val; - cpu->gte_data[31] = gte_cpu_lzcr(val); - break; - case 31: - cpu->gte_data[31] = gte_cpu_lzcr(cpu->gte_data[30]); - break; - default: - cpu->gte_data[reg] = val; - break; - } -} - -extern "C" void gte_write_ctrl(CPUState* cpu, uint8_t reg, uint32_t val) { - if (reg >= 32) return; - gte_cpu_canonicalize_backing(cpu); - switch (reg) { - case 4: case 12: case 20: case 26: - cpu->gte_ctrl[reg] = val & 0xFFFFu; - break; - case 27: case 29: case 30: - cpu->gte_ctrl[reg] = gte_sign_extend_16(val); - break; - case 31: - cpu->gte_ctrl[31] = val & 0x7FFFF000u; - break; - default: - cpu->gte_ctrl[reg] = val; - break; - } -} - -extern "C" void gte_canonicalize_cpu_state(CPUState* cpu) { - gte_cpu_canonicalize_backing(cpu); - /* ctrl[31] is emulator state, not a guest CTC2 write. Preserve the - * command-computed summary bit 31 exactly. */ - /* Raw restore replaced RAM and SXY0..2. Drop every host-only provenance - * cache from the previous timeline, including address-keyed packet depth - * and geometry correction entries. */ - gte_precision_timeline_invalidate(); -} - -#ifdef PSX_GTE_REGISTER_TEST -extern "C" void gte_test_set_precise_valid_mask(uint32_t mask) { - for (uint32_t i = 0; i < 4; ++i) - PSXRecomp::GTE::s_precise_sxy[i].valid = (mask >> i) & 1u; -} - -extern "C" uint32_t gte_test_get_precise_valid_mask(void) { - uint32_t mask = 0; - for (uint32_t i = 0; i < 4; ++i) - mask |= (PSXRecomp::GTE::s_precise_sxy[i].valid ? 1u : 0u) << i; - return mask; -} - -extern "C" void gte_test_set_timeline_generations(uint32_t precision, - uint32_t geometry) { - PSXRecomp::GTE::s_precision_generation = precision; - PSXRecomp::GTE::s_geom_generation = geometry; -} - -extern "C" uint32_t gte_test_get_precision_generation(void) { - return PSXRecomp::GTE::s_precision_generation; -} - -extern "C" uint32_t gte_test_get_geometry_generation(void) { - return PSXRecomp::GTE::s_geom_generation; -} - -extern "C" void gte_test_seed_precise_projection(uint32_t index, - uint32_t packed, - int32_t x16, - int32_t y16, - uint16_t z) { - if (index >= 4) return; - auto &p = PSXRecomp::GTE::s_precise_sxy[index]; - p.packed = packed; - p.x16 = x16; - p.y16 = y16; - p.z = z; - p.valid = 1; - p.saturated = 0; - p.unbounded = 0; - p.perspective_valid = 1; -} - -extern "C" void gte_test_set_precise_exact(uint32_t index, int saturated, int unbounded, int32_t exact_x16, int32_t exact_y16) { - if (index >= 4) return; - auto &p = PSXRecomp::GTE::s_precise_sxy[index]; - p.saturated = saturated ? 1 : 0; - p.unbounded = unbounded ? 1 : 0; - p.exact_x16 = exact_x16; - p.exact_y16 = exact_y16; -} - -extern "C" void gte_test_get_precise_exact(uint32_t index, int *saturated, int *unbounded, int32_t *exact_x16, int32_t *exact_y16) { - if (index >= 4) return; - const auto &p = PSXRecomp::GTE::s_precise_sxy[index]; - if (saturated) *saturated = p.saturated; - if (unbounded) *unbounded = p.unbounded; - if (exact_x16) *exact_x16 = p.exact_x16; - if (exact_y16) *exact_y16 = p.exact_y16; -} - -extern "C" void gte_test_set_precise_perspective_valid(uint32_t index, - int valid) { - if (index >= 4) return; - PSXRecomp::GTE::s_precise_sxy[index].perspective_valid = valid ? 1 : 0; -} - -extern "C" void gte_test_get_precise_projection(uint32_t index, - uint32_t* packed, - int32_t* x16, - int32_t* y16, - uint16_t* z, - uint8_t* valid) { - if (index >= 4) return; - const auto &p = PSXRecomp::GTE::s_precise_sxy[index]; - if (packed) *packed = p.packed; - if (x16) *x16 = p.x16; - if (y16) *y16 = p.y16; - if (z) *z = p.z; - if (valid) *valid = p.valid; -} - -extern "C" void gte_test_seed_geometry(uint32_t packed, int32_t x16, - int32_t y16) { - auto &entry = PSXRecomp::GTE::s_geom_test_cache[ - PSXRecomp::GTE::geom_test_hash(packed)]; - entry.packed = packed; - entry.x16 = x16; - entry.y16 = y16; - entry.generation = PSXRecomp::GTE::s_geom_generation; -} -#endif diff --git a/psxrecomp-overlay/runtime/src/interrupts.c b/psxrecomp-overlay/runtime/src/interrupts.c deleted file mode 100644 index f105a56..0000000 --- a/psxrecomp-overlay/runtime/src/interrupts.c +++ /dev/null @@ -1,1669 +0,0 @@ -/* - * 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/psxrecomp-overlay/runtime/src/iso_reader_c.cpp b/psxrecomp-overlay/runtime/src/iso_reader_c.cpp deleted file mode 100644 index a3fd420..0000000 --- a/psxrecomp-overlay/runtime/src/iso_reader_c.cpp +++ /dev/null @@ -1,119 +0,0 @@ -/* - * iso_reader_c.cpp — C wrapper for the C++ ISOReader class - * - * Provides iso_open() / iso_read_sector() / iso_close() for use by cdrom.c - */ - -#include "iso_overlay.h" -#include "iso_reader.h" -#include "mod_runtime.h" -#include -#include -#include -#include - -namespace { - -const PsxIsoOverlay* g_overlay = nullptr; -std::vector g_plain; -void* g_game_disc = nullptr; - -bool overlaid(void* handle) { - return g_overlay && std::find(g_plain.begin(), g_plain.end(), handle) == g_plain.end(); -} - -} // namespace - -extern "C" { - -void* iso_open(const char* path) { - auto* reader = new PS1::ISOReader(); - if (!reader->Open(path)) { - delete reader; - return nullptr; - } - g_game_disc = reader; - return reader; -} - -void iso_set_overlay(const PsxIsoOverlay* overlay) { g_overlay = overlay; } - -void* iso_open_plain(const char* path) { - void* const game = g_game_disc; - void* handle = iso_open(path); - g_game_disc = game; - if (handle) g_plain.push_back(handle); - return handle; -} - -void* iso_game_disc(void) { return g_game_disc; } - -int iso_read_raw_sector_plain(void* handle, uint32_t lba, uint8_t* buffer) { - return handle && static_cast(handle)->ReadRawSector(lba, buffer) ? 1 : 0; -} - -uint32_t iso_sector_count_plain(void* handle) { - return handle ? static_cast(handle)->GetSectorCount() : 0; -} - -int iso_read_sector(void* handle, uint32_t lba, uint8_t* buffer, int size) { - if (!handle) return 0; - auto* reader = static_cast(handle); - (void)size; /* ReadSector always reads 2048 bytes */ - uint8_t raw[2352]; - const int stood_in = overlaid(handle) && g_overlay->read ? g_overlay->read(handle, lba, raw) : 0; - if (stood_in < 0) return 0; - if (stood_in > 0) std::memcpy(buffer, raw + 24, 2048); - else if (!reader->ReadSector(lba, buffer)) return 0; - mod_runtime_patch_disc_sector(lba, 0, buffer, 2048); - return 1; -} - -int iso_read_raw_sector(void* handle, uint32_t lba, uint8_t* buffer, int size) { - if (!handle || size < 2352) return 0; - auto* reader = static_cast(handle); - const int stood_in = overlaid(handle) && g_overlay->read ? g_overlay->read(handle, lba, buffer) : 0; - if (stood_in < 0) return 0; - if (stood_in == 0 && !reader->ReadRawSector(lba, buffer)) return 0; - mod_runtime_patch_disc_sector(lba, 1, buffer, 2352); - return 1; -} - -uint32_t iso_sector_count(void* handle) { - if (!handle) return 0; - const uint32_t stood_in = overlaid(handle) && g_overlay->sectors ? g_overlay->sectors(handle) : 0; - return stood_in ? stood_in : iso_sector_count_plain(handle); -} - -/* CD-track TOC accessors (multi-track / CD-DA support). track is 1-based. */ -int iso_track_count(void* handle) { - if (!handle) return 1; - return static_cast(handle)->TrackCount(); -} - -uint32_t iso_track_start_lba(void* handle, int track) { - if (!handle) return 0; - return static_cast(handle)->TrackStartLBA(track); -} - -uint32_t iso_track_pregap_lba(void* handle, int track) { - auto* reader = static_cast(handle); - return reader ? reader->TrackPregapLBA(track) : 0; -} - -int iso_track_is_audio(void* handle, int track) { - if (!handle) return 0; - return static_cast(handle)->TrackIsAudio(track) ? 1 : 0; -} - -void iso_close(void* handle) { - if (!handle) return; - if (overlaid(handle) && g_overlay->closed) g_overlay->closed(handle); - if (handle == g_game_disc) g_game_disc = nullptr; - g_plain.erase(std::remove(g_plain.begin(), g_plain.end(), handle), g_plain.end()); - auto* reader = static_cast(handle); - reader->Close(); - delete reader; -} - -} /* extern "C" */ diff --git a/psxrecomp-overlay/runtime/src/main.cpp b/psxrecomp-overlay/runtime/src/main.cpp deleted file mode 100644 index 975e230..0000000 --- a/psxrecomp-overlay/runtime/src/main.cpp +++ /dev/null @@ -1,8526 +0,0 @@ -/* main.cpp — Phase 3 runtime entry point. - * - * Loads BIOS ROM, initializes CPU state + SDL display, calls into - * the recompiled reset vector. BIOS drives execution; SDL presents - * VRAM at each vblank via callback from gpu_vblank_tick(). - */ - -/* Keep the Windows min/max macros out of this C++ translation unit. SDL may - * include windows.h before the explicit platform includes below, so this must - * be defined before any runtime header is included. */ -#if defined(_WIN32) && !defined(NOMINMAX) -#define NOMINMAX -#endif - -#include "cpu_state.h" -#include "psx_bios_image.h" /* the active BIOS's self-description */ -#include "psx_bios_backend.h" /* routing to whichever BIOS was selected */ -#include "psx_scheduler.h" /* psx_scheduler_run — deterministic TCB scheduler */ -#include "parity_trace.h" /* general two-process control-flow parity ring */ -#include "device_trace.h" /* general two-process device-event cycle ring */ -#include "psx_interpreter.h" -#include "cdrom.h" -#include "fntrace.h" -#include "text_xlate.h" -#include "boot_state.h" -#include "bios_hle.h" -#include "bios_hle_plan.h" -#include "psx_cycles.h" -#include "starvation_ring.h" -#include "load_accel.h" -#include "savestate.h" -#include "overlay_capture.h" -#include "overlay_loader.h" -#include "autocompile.h" -#include "code_provider.h" -extern "C" void psx_event_step_conservative_env_init(void); -#include "overlay_backend.h" -#include "gpu.h" -#include "present_ring.h" -#include "load_transition_ring.h" -#include "gpu_sw_renderer.h" -#include "gpu_render.h" -#include "gpu_gl_renderer.h" -#include "gpu_vk_renderer.h" -#include "host_borderless.h" -#include "host_pick_slot.h" -#include "host_ui.h" -extern "C" void gl_renderer_geometry_interpolation_diag(uint64_t out[6]); -#include "gte_precision.h" -#include "frame_pacing.h" -#include "latency_ring.h" -#include "sio.h" -#include "psx_netplay.h" -#include "psx_lobby_client.h" -#include "spu.h" -#include "audio_trace.h" -#include "spu_shadow.h" - -/* Shared clock-domain bridge: band-limited polyphase resampler + P-only DRC. - * The SPU renders at a fixed 44100/60 per wall-clock frame while the host - * consumes on its own crystal; with no resampling/DRC the queue slowly drifts - * to underrun (silence gaps). The bridge resamples ~1:1 with a <=+/-0.5% ratio - * trim to hold the ring near target -- no gaps. See recomp_audio_drc.h. */ -#define RECOMP_AUDIO_DRC_IMPL -#include "recomp_audio_drc.h" -#include "memcard.h" -#include "debug_server.h" -#include "crash_trace.h" -#include "freeze_heartbeat.h" -#include "config_loader.h" -#include "game_options.h" -#include "mod_plugins.h" -#include "mod_runtime.h" -#ifdef PSX_HAS_DISRUPTOR_CAPSULE -extern "C" void disruptor_capsule_before_input(void); -extern "C" void disruptor_capsule_after_input(void); -extern "C" void disruptor_capsule_state_loaded(void); -#endif -#ifdef PSX_HAS_DISRUPTOR_FAR_RENDERING -extern "C" void disruptor_far_rendering_abandon_metrics(void); -#ifdef PSX_HAS_DISRUPTOR_LANGUAGE -extern "C" void disruptor_language_set_disc(const char* path); -extern "C" void disruptor_language_settle(void); -#endif -#endif -#ifdef PSX_HAS_DISRUPTOR_NAME_ENTRY -extern "C" int disruptor_name_entry_key(int scancode, uint32_t keycode, int down, int plain); -#endif -#include "dirty_ram_interp.h" -#include "crc32.h" -#include "disc_identity.h" -#include "disc_path.h" -#include "bios_rom_alias.h" -#include "iso_reader.h" /* text-image guard: extract the boot EXE from the disc */ -#include "launcher_device.h" /* recomp-ui controller-source round-trip */ -#include "psx_keybinds.h" /* configurable keyboard->DualShock keybinds (keybinds.ini) */ -#include "psx_stick.h" /* radial SDL-stick -> DualShock response transform */ -#if defined(RECOMP_LAUNCHER) -#include "recomp_launcher.h" /* shared recomp-ui Dear ImGui launcher */ -#include "launcher_profile.h" /* per-system variant profile (theme/caps bundle) */ -#endif -#include "psx_sdl.h" -#if defined(PSX_SDL3) -/* - * SDL_main.h is a single-header implementation in SDL3. Keep it in the one - * translation unit that defines main(); including it through psx_sdl.h makes - * every SDL-using source emit WinMain under MinGW. - */ -#include -#endif -#include "psx_sdl_audio.h" -#if defined(PSX_WEB) -#include -#endif -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -/* Internal GL seam: host-menu suspension is a separate reason from FMV/ - * content suspension, so either owner can release independently. */ -extern "C" void gl_renderer_set_host_ui_suspended(int suspended); -extern "C" void gl_renderer_set_hidden_rows(int rows); - -#ifdef _WIN32 -#ifndef WIN32_LEAN_AND_MEAN -#define WIN32_LEAN_AND_MEAN -#endif -#include -#include -#include -#include -#include -#else -#include -#include -#include -#include -#include -#include -#include -#endif - -#ifndef PSX_DEFAULT_BIOS_PATH -#define PSX_DEFAULT_BIOS_PATH "bios/SCPH1001.BIN" -#endif -#ifndef PSX_DEFAULT_GAME_CONFIG_PATH -#define PSX_DEFAULT_GAME_CONFIG_PATH "" -#endif -#ifndef PSX_WINDOW_TITLE -#define PSX_WINDOW_TITLE "psxrecomp" -#endif -#ifndef DEFAULT_DEBUG_PORT -#error DEFAULT_DEBUG_PORT must be defined by the runtime target. -#endif - -/* SDL_RenderSetVSync was added in SDL 2.0.18. Keep the explicitly supported - * SDL2 fallback buildable against older distro/toolchain copies; its OpenGL - * renderer can still release the swap interval through the public GL API. - * Use a project-local name because SDL3's psx_sdl.h already defines its own - * psx_sdl_render_set_vsync compatibility helper. */ -static int disruptor_sdl_render_set_vsync_compat(SDL_Renderer *renderer, int enabled) { -#if defined(PSX_SDL3) || SDL_VERSION_ATLEAST(2, 0, 18) - return SDL_RenderSetVSync(renderer, enabled); -#else - (void)renderer; - return SDL_GL_SetSwapInterval(enabled ? 1 : 0); -#endif -} - -extern "C" uint64_t gte_get_exec_count(void); - -/* Disruptor does not use SWC2 for its resident world-projection loops. These - * two reviewed MFC2 sites snapshot SXY2. Six ordinary SW sites publish into - * main RAM and one publishes into nine exact scratchpad slots, each only after - * an exact instruction and committed-word check. Twenty-four reviewed LW/SW - * pairs then carry those proven words into GP0 packets. Keeping every route - * behind the game serial prevents another title at the same PCs from - * inheriting it. */ -static int configure_disruptor_precision_store_routes( - const std::string& game_id) { - gte_precision_store_pc_routes_reset(); - if (game_id != "SLUS-00224") return 1; - static const uint32_t mfc2_pcs[] = { - 0x8004633Cu, - 0x8004795Cu, - }; - static const uint32_t store_pcs[] = { - 0x8004641Cu, - 0x800464BCu, - 0x80046524u, - 0x80046540u, - 0x80046594u, - 0x800465D8u, - }; - struct PrecisionCopyRoute { - uint32_t load_pc; - uint32_t load_instruction; - uint32_t store_pc; - uint32_t store_instruction; - uint8_t gpr; - }; - static const PrecisionCopyRoute copy_routes[] = { - {0x80046C4Cu, 0x8D0C0004u, 0x80046D04u, 0xAF2C0008u, 12u}, - {0x80046C50u, 0x8D2D0004u, 0x80046D08u, 0xAF2D0014u, 13u}, - {0x80046C54u, 0x8D4E0004u, 0x80046D10u, 0xAF2E002Cu, 14u}, - {0x80046C60u, 0x8D6F0004u, 0x80046D0Cu, 0xAF2F0020u, 15u}, - {0x80047A80u, 0x8CA80084u, 0x80047A90u, 0xAF280008u, 8u}, - {0x80047A84u, 0x8CA900A4u, 0x80047A94u, 0xAF290014u, 9u}, - {0x80047A88u, 0x8CAA00C4u, 0x80047A9Cu, 0xAF2A002Cu, 10u}, - {0x80047A8Cu, 0x8CAB00BCu, 0x80047A98u, 0xAF2B0020u, 11u}, - {0x80047B40u, 0x8CA800A4u, 0x80047B50u, 0xAF280008u, 8u}, - {0x80047B44u, 0x8CA9008Cu, 0x80047B54u, 0xAF290014u, 9u}, - {0x80047B48u, 0x8CAA00ACu, 0x80047B5Cu, 0xAF2A002Cu, 10u}, - {0x80047B4Cu, 0x8CAB00C4u, 0x80047B58u, 0xAF2B0020u, 11u}, - {0x80047C00u, 0x8CA800C4u, 0x80047C10u, 0xAF280008u, 8u}, - {0x80047C04u, 0x8CA900ACu, 0x80047C14u, 0xAF290014u, 9u}, - {0x80047C08u, 0x8CAA0094u, 0x80047C1Cu, 0xAF2A002Cu, 10u}, - {0x80047C0Cu, 0x8CAB00B4u, 0x80047C18u, 0xAF2B0020u, 11u}, - {0x80047CC4u, 0x8CA800BCu, 0x80047CD4u, 0xAF280008u, 8u}, - {0x80047CC8u, 0x8CA900C4u, 0x80047CD8u, 0xAF290014u, 9u}, - {0x80047CCCu, 0x8CAA00B4u, 0x80047CE0u, 0xAF2A002Cu, 10u}, - {0x80047CD0u, 0x8CAB009Cu, 0x80047CDCu, 0xAF2B0020u, 11u}, - {0x80047D6Cu, 0x8CA80084u, 0x80047D7Cu, 0xAF280008u, 8u}, - {0x80047D70u, 0x8CA9008Cu, 0x80047D80u, 0xAF290014u, 9u}, - {0x80047D74u, 0x8CAA0094u, 0x80047D88u, 0xAF2A002Cu, 10u}, - {0x80047D78u, 0x8CAB009Cu, 0x80047D84u, 0xAF2B0020u, 11u}, - }; - for (uint32_t pc : mfc2_pcs) { - if (!gte_precision_mfc2_pc_route_add( - pc, 0x480A7000u, 14u)) { - std::fprintf(stderr, - "psxrecomp: failed to register Disruptor SXY2 MFC2 PC " - "0x%08X\n", pc); - return 0; - } - } - for (uint32_t pc : store_pcs) { - if (!gte_precision_store_pc_route_add( - pc, 0xACAA0004u, 14u)) { - std::fprintf(stderr, - "psxrecomp: failed to register Disruptor SXY2 store PC " - "0x%08X\n", pc); - return 0; - } - } - if (!gte_precision_scratch_store_pc_route_add( - 0x80047A0Cu, 0xACAA0004u, 14u, - 0x1F800084u, 8u, 9u)) { - std::fprintf(stderr, - "psxrecomp: failed to register Disruptor scratchpad SXY2 " - "store route 0x80047A0C\n"); - return 0; - } - /* 0x80047980..0x800479E0 pin x to -256..576 and y to -256..496 before that store. */ - if (!gte_precision_scratch_store_pc_route_clamp( - 0x80047A0Cu, 0xACAA0004u, -0x100, 0x240, -0x100, 0x1F0)) { - std::fprintf(stderr, - "psxrecomp: failed to register the guard band of Disruptor's " - "scratchpad SXY2 store route 0x80047A0C\n"); - return 0; - } - for (const PrecisionCopyRoute& route : copy_routes) { - if (!gte_precision_copy_pc_route_add( - route.load_pc, route.load_instruction, - route.store_pc, route.store_instruction, route.gpr)) { - std::fprintf(stderr, - "psxrecomp: failed to register Disruptor projection-copy " - "route 0x%08X -> 0x%08X\n", - route.load_pc, route.store_pc); - return 0; - } - } - std::fprintf(stdout, - "psxrecomp: registered %zu MFC2 captures, %zu main-RAM projection " - "stores, 1 scratchpad projection store and %zu packet-copy routes\n", - sizeof(mfc2_pcs) / sizeof(mfc2_pcs[0]), - sizeof(store_pcs) / sizeof(store_pcs[0]), - sizeof(copy_routes) / sizeof(copy_routes[0])); - return 1; -} - -/* Cross-language globals defined in C translation units. Declared extern "C" at - * file scope so MSVC gives them C linkage (matching the C definitions); without - * this MSVC name-mangles the C++ references and they fail to link. GCC/Clang do - * not mangle namespace-scope variables, so this is a no-op there. The existing - * block-scope `extern` redeclarations inside functions inherit this C linkage. */ -extern "C" { - extern uint64_t psx_cycle_count; - extern uint64_t s_frame_count; - extern uint32_t g_overlay_region_floor; - extern int g_psx_cps_mode; - extern uint64_t g_slice_fired, g_slice_irq_taken, g_dirty_ram_insns_run; - extern uint64_t g_dirty_window_dispatches; - extern uint32_t g_slice_exit_pc, g_slice_exit_reason, g_slice_exit_iter; - extern uint32_t g_slice_exit_dispatchable, g_slice_exit_dirty, g_slice_exit_in_text, g_slice_exit_want; -} - -/* memory.c */ -extern "C" void memory_init(const char* bios_path); -extern "C" void memory_set_sr_ptr(const uint32_t *p); -extern "C" uint32_t memory_get_bios_checksum(void); -extern "C" void dirty_ram_register_text_image(uint32_t phys_lo, - const uint8_t *bytes, - uint32_t len); - -/* Arm the dirty-RAM text-image guard with the boot EXE bytes. The guard is - * load-bearing: dispatch native-safety (dirty_ram_text_native_ok) and the - * fntrace alternate game-start latch both key off the registered image, so - * every install must arm it — not just repo checkouts that happen to carry a - * loose EXE copy next to game.toml. Source order: - * 1. The local EXE file from game.toml (dev checkouts; recompiler input). - * 2. The configured native range sliced from the boot EXE on the mounted - * disc (end-user installs may intentionally omit a derived code-only EXE). - * Registration passes ownership of the malloc'd buffer to memory.c. */ -static void arm_text_image_guard(const std::string &exe_path, - uint32_t load_address, - uint32_t text_size, - const std::string &disc_path) { - const uint32_t phys_lo = load_address & 0x1FFFFFFFu; - /* 1. Local EXE file (skip the 2048-byte PS-X EXE header). */ - if (!exe_path.empty()) { - std::ifstream ef(exe_path, std::ios::binary | std::ios::ate); - if (ef) { - std::streamsize sz = ef.tellg(); - if (sz > 2048) { - uint32_t available = (uint32_t)(sz - 2048); - uint32_t img_len = text_size ? text_size : available; - if (img_len > available) img_len = 0; - uint8_t *img = img_len - ? (uint8_t *)std::malloc(img_len) : nullptr; - if (img && img_len) { - ef.seekg(2048, std::ios::beg); - if (ef.read((char *)img, img_len)) { - dirty_ram_register_text_image(phys_lo, img, img_len); - std::fprintf(stdout, - "psxrecomp: text image guard armed (0x%08X..0x%08X, local EXE)\n", - load_address, load_address + img_len); - return; - } - std::free(img); - } - } - } - } - /* 2. Extract the boot EXE from the disc image. Its declared load address - * can precede the code-only image consumed by the recompiler, so slice by - * the configured physical load and text size rather than blindly copying - * from payload byte zero. */ - if (!disc_path.empty()) { - PS1::ISOReader iso; - if (iso.Open(disc_path)) { - /* The boot filename: basename of the game.toml exe field (it names - * the same file the recompiler consumed, which came off this disc); - * fall back to the SYSTEM.CNF BOOT token for configs whose local - * name differs from the disc name. */ - std::string boot_name; - if (!exe_path.empty()) { - const size_t slash = exe_path.find_last_of("/\\"); - boot_name = (slash == std::string::npos) - ? exe_path : exe_path.substr(slash + 1); - } - PS1::ISOFileEntry ent; - if (boot_name.empty() || !iso.FindFile(boot_name, ent)) { - /* SYSTEM.CNF: `BOOT = cdrom:\SCUS_944.23;1` */ - uint8_t cnf[2048] = {0}; - size_t n = iso.ReadFile("SYSTEM.CNF", cnf, sizeof(cnf) - 1); - if (n > 0) { - std::string text((const char *)cnf, n); - std::string lower = text; - for (char &c : lower) c = (char)std::tolower((unsigned char)c); - const size_t key = lower.find("cdrom:"); - if (key != std::string::npos) { - size_t j = key + 6; - while (j < text.size() && (text[j] == '\\' || text[j] == '/')) j++; - std::string tok; - while (j < text.size()) { - char c = text[j]; - if (c == ';' || c == '\r' || c == '\n' || c == ' ' || - c == '\t' || c == '\0') break; - tok += c; j++; - if (tok.size() > 64) break; - } - const size_t s2 = tok.find_last_of("\\/"); - if (s2 != std::string::npos) tok = tok.substr(s2 + 1); - if (!tok.empty() && iso.FindFile(tok, ent)) boot_name = tok; - } - } - } - if (!boot_name.empty() && iso.FindFile(boot_name, ent) && - ent.size > 2048) { - uint8_t *file = (uint8_t *)std::malloc(ent.size); - if (file) { - size_t got = iso.ReadFile(boot_name, file, ent.size); - if (got > 2048 && got >= 0x20 && - std::memcmp(file, "PS-X EXE", 8) == 0) { - const auto read_le32 = [](const uint8_t *p) { - return (uint32_t)p[0] | - ((uint32_t)p[1] << 8) | - ((uint32_t)p[2] << 16) | - ((uint32_t)p[3] << 24); - }; - uint32_t disc_load = - read_le32(file + 0x18) & 0x1FFFFFFFu; - uint32_t declared_len = read_le32(file + 0x1C); - uint32_t payload_len = (uint32_t)(got - 2048); - if (declared_len < payload_len) payload_len = declared_len; - uint32_t payload_off = phys_lo >= disc_load - ? phys_lo - disc_load : UINT32_MAX; - uint32_t img_len = text_size ? text_size - : (payload_off <= payload_len - ? payload_len - payload_off : 0u); - if (payload_off > payload_len || - img_len > payload_len - payload_off) - img_len = 0; - uint8_t *img = img_len - ? (uint8_t *)std::malloc(img_len) : nullptr; - if (img && img_len) { - memcpy(img, file + 2048 + payload_off, img_len); - std::free(file); - dirty_ram_register_text_image(phys_lo, img, img_len); - std::fprintf(stdout, - "psxrecomp: text image guard armed (0x%08X..0x%08X, " - "disc %s payload +0x%X)\n", - load_address, load_address + img_len, - boot_name.c_str(), payload_off); - return; - } - } - std::free(file); - } - } - } - } - std::fprintf(stdout, - "psxrecomp: WARNING: text image guard NOT armed (no local EXE, no disc " - "boot EXE) — native text dispatch will be conservative\n"); -} - -/* dma.c */ -extern "C" void dma_init(void); - -/* mdec.c */ -extern "C" void mdec_init(void); -extern "C" int mdec_recently_active(uint32_t within_frames); - -/* timers.c */ -extern "C" void timers_init(void); - -/* interrupts.c */ -extern "C" void interrupts_init(void); -#ifdef PSX_COSIM -extern "C" void cosim_init(void); /* first-divergence oracle server (cosim.c) */ -#endif -extern "C" uint32_t psx_read_word(uint32_t addr); -extern "C" void psx_write_word(uint32_t addr, uint32_t val); -extern "C" uint16_t psx_read_half(uint32_t addr); -extern "C" void psx_write_half(uint32_t addr, uint16_t val); -extern "C" uint8_t psx_read_byte(uint32_t addr); -extern "C" void psx_write_byte(uint32_t addr, uint8_t val); -/* Guest-side data-read wrappers: same as psx_read_* but charge PS1 main-RAM - * read wait states (R3000A has no D-cache). Wired to cpu->read_* below so the - * timing applies to recompiled + interpreted guest loads, not debug/device reads. */ -extern "C" uint32_t psx_guest_read_word(uint32_t addr); -extern "C" uint16_t psx_guest_read_half(uint32_t addr); -extern "C" uint8_t psx_guest_read_byte(uint32_t addr); - -/* ---- SDL state ---- */ -extern "C" { -SDL_Window* sdl_window = nullptr; -} -static SDL_Renderer* sdl_renderer; -static SDL_Texture* sdl_texture; -/* Per-player input device routing (PSX ports 1 & 2). Seeded from the - * [controller] settings the launcher writes; the runtime opens the matching - * SDL controller (or uses the keyboard) and feeds each PSX pad slot. */ -struct PlayerInput { - int kind = 0; /* 0=none, 1=keyboard, 2=controller */ - char guid[40] = {0}; /* SDL joystick GUID string when kind==controller */ - /* Pad input mode (PSXRecompV4::PadMode): 0=hybrid (default), 1=analog, - * 2=digital. hybrid_analog is the per-frame auto-switch latch used only in - * hybrid mode: true => currently presenting DualShock (stick was the last - * input), false => currently presenting a digital pad (D-pad was last). */ - int mode = PSXRecompV4::PAD_MODE_HYBRID; - bool hybrid_analog = false; - SDL_GameController* handle = nullptr; - SDL_JoystickID instance = -1; -}; -static PlayerInput g_players[2]; -/* ARGB8888 staging buffer. Sized for the active internal resolution: - * 640*scale x 512*scale. Allocated once the supersampling scale is known - * (sized for the native 640x512 when supersampling is off). */ -static uint32_t* sdl_pixel_buf = nullptr; -/* Live OpenGL internal-scale changes may grow the CPU fallback/readback path. - * Never shrink this allocation during a session: if a GL target rebuild fails, - * the previous (larger) renderer scale must remain safe to present. */ -static int sdl_pixel_buf_scale_capacity = 0; - -typedef void (*ModFrameHook)(void); - -static std::vector& mod_frame_hooks() { - static std::vector hooks; - return hooks; -} - -/* Statically linked mod code can register per-frame callbacks here. Hooks run - * after normal input sampling and before presentation/widescreen frame work. */ -extern "C" void mod_register_frame_hook(ModFrameHook hook) { - if (hook) mod_frame_hooks().push_back(hook); -} - -static void mod_call_frame_hooks() { - auto& hooks = mod_frame_hooks(); - const size_t count = hooks.size(); - for (size_t i = 0; i < count; i++) hooks[i](); -} - -/* Optional game-owned in-game UI. Keep the registration state here rather - * than in a renderer so input routing and renderer lifetime remain coordinated - * and a build with no provider has no extra source or link dependency. */ -struct HostUiState { - PsxHostUiHooks hooks{}; - bool registered = false; - bool session_live = false; - SDL_Window *window = nullptr; - int backend = PSX_HOST_UI_BACKEND_NONE; -}; - -static HostUiState& host_ui_state() { - static HostUiState state; - return state; -} - -static std::string g_host_user_settings_path_utf8; - -extern "C" const char *psx_host_user_settings_path(void) { - return g_host_user_settings_path_utf8.empty() - ? nullptr : g_host_user_settings_path_utf8.c_str(); -} - -extern "C" int psx_host_ui_register(const PsxHostUiHooks *hooks) { - if (!hooks || hooks->abi_version != PSX_HOST_UI_ABI_VERSION || - hooks->struct_size < sizeof(PsxHostUiHooks)) - return 0; - - HostUiState& state = host_ui_state(); - if (state.registered) return 0; - state.hooks = *hooks; - state.registered = true; - if (state.session_live && state.hooks.on_runtime_ready) - state.hooks.on_runtime_ready(state.hooks.userdata, state.window, - state.backend); - return 1; -} - -static void host_ui_runtime_ready(SDL_Window *window, int backend) { - HostUiState& state = host_ui_state(); - /* A lobby soft-return may create another game session in the same process. - * Close the prior provider lifetime defensively before opening a new one. */ - if (state.session_live && state.registered && - state.hooks.on_runtime_shutdown) - state.hooks.on_runtime_shutdown(state.hooks.userdata); - state.window = window; - state.backend = backend; - state.session_live = true; - if (state.registered && state.hooks.on_runtime_ready) - state.hooks.on_runtime_ready(state.hooks.userdata, window, backend); -} - -static void host_ui_runtime_shutdown() { - HostUiState& state = host_ui_state(); - if (!state.session_live) return; - if (state.registered && state.hooks.on_runtime_shutdown) - state.hooks.on_runtime_shutdown(state.hooks.userdata); - state.session_live = false; - state.window = nullptr; - state.backend = PSX_HOST_UI_BACKEND_NONE; -} - -extern "C" int psx_host_ui_handle_event(const SDL_Event *event) { - HostUiState& state = host_ui_state(); - if (!event || !state.session_live || !state.registered || - !state.hooks.on_sdl_event) - return 0; - return state.hooks.on_sdl_event(state.hooks.userdata, event) ? 1 : 0; -} - -extern "C" uint32_t psx_host_ui_capture_flags(void) { - HostUiState& state = host_ui_state(); - if (!state.session_live || !state.registered || !state.hooks.flags) - return 0; - return state.hooks.flags(state.hooks.userdata); -} - -static int (*g_fast_forward_query)(void) = nullptr; - -extern "C" void psx_host_set_fast_forward_query(int (*query)(void)) { - g_fast_forward_query = query; -} - -extern "C" int psx_host_ui_game_input_captured(void) { - const uint32_t input = PSX_HOST_UI_CAPTURE_KEYBOARD | - PSX_HOST_UI_CAPTURE_MOUSE | - PSX_HOST_UI_CAPTURE_GAMEPAD; - return (psx_host_ui_capture_flags() & input) != 0; -} - -extern "C" int psx_host_ui_gl_visible(void) { - HostUiState& state = host_ui_state(); - return state.session_live && state.registered && - state.backend == PSX_HOST_UI_BACKEND_OPENGL && - state.hooks.render_gl && - (psx_host_ui_capture_flags() & PSX_HOST_UI_VISIBLE) != 0; -} - -extern "C" void psx_host_ui_render_gl(int drawable_width, - int drawable_height) { - HostUiState& state = host_ui_state(); - if (!state.session_live || !state.registered || - state.backend != PSX_HOST_UI_BACKEND_OPENGL || - !state.hooks.render_gl) - return; - state.hooks.render_gl(state.hooks.userdata, drawable_width, - drawable_height); -} - -/* Presentation-only interpolation for software-rendered content that repeats - * each guest image for two vblanks. This never changes guest or audio timing. */ -#ifndef PSX_DISABLE_FRAME_INTERPOLATION -static std::atomic g_smooth_60fps{0}; - -struct Smooth60State { - std::vector previous_source; - uint64_t source_hash = 0; - uint32_t width = 0; - uint32_t height = 0; - bool have_source = false; - bool previous_was_duplicate = false; - bool announced = false; -}; - -static Smooth60State g_smooth_60_state; -#endif - -/* Present-path session state — must reset on rematch (function-local statics - * survive soft-return and poison FMV/FPS after session_reboot). */ -static bool s_disabled_frame_presented = false; -static bool s_force_present_after_load = false; -static Uint64 s_fps_last_time = 0; -static uint64_t s_fps_last_frame = 0; -static std::string s_fps_base_title; -static FramePacer s_frame_pacer = { 0 }; -static int s_turbo_present_skip = 0; -static int s_fmv_skip_present_skip = 0; -/* Netplay + depth24: present every other vblank (admit still every tick). */ -static int s_netplay_depth24_present_skip = 0; -static uint32_t s_fmv_skip_last_mdec = 0; -static int s_fmv_skip_hold = 0; - -/* Lightweight title/stderr FPS telemetry is developer-facing and opt-in. - * Keep release window titles clean unless explicitly requested. */ -static bool fps_telemetry_enabled() { - static const bool enabled = [] { - const char* value = std::getenv("PSX_FPS_TELEMETRY"); - return value && value[0] && value[0] != '0'; - }(); - return enabled; -} - -static void smooth_60_reset(void) { -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - g_smooth_60_state.previous_source.clear(); - g_smooth_60_state.source_hash = 0; - g_smooth_60_state.width = 0; - g_smooth_60_state.height = 0; - g_smooth_60_state.have_source = false; - g_smooth_60_state.previous_was_duplicate = false; -#endif -} - -static void present_session_reset(void) { - s_disabled_frame_presented = false; - s_force_present_after_load = false; - s_fps_last_time = 0; - s_fps_last_frame = 0; - s_fps_base_title.clear(); - s_frame_pacer = FramePacer{ 0 }; - s_turbo_present_skip = 0; - s_fmv_skip_present_skip = 0; - s_netplay_depth24_present_skip = 0; - s_fmv_skip_last_mdec = 0; - s_fmv_skip_hold = 0; - smooth_60_reset(); -} - -extern "C" int dirty_ram_text_image_registered(void); -extern "C" int psx_game_text_native_ok(uint32_t addr); -extern "C" void memory_keep_low_boot_scratch(void); -static void take_loaded_state_for_started(void); - -/* Called from savestate_poll after a successful restore (before scheduler - * longjmp). Clears present latches and forces the next vblank to show the - * restored VRAM — including a blank if display was disabled in the snapshot. */ -extern "C" void psx_frontend_on_savestate_loaded(void) { -#ifdef PSX_HAS_DISRUPTOR_CAPSULE - disruptor_capsule_state_loaded(); -#endif -#ifdef PSX_HAS_DISRUPTOR_FAR_RENDERING - disruptor_far_rendering_abandon_metrics(); -#endif - s_disabled_frame_presented = false; - s_force_present_after_load = true; - smooth_60_reset(); - /* Re-anchor wall pacing + FPS baseline: admit may have blocked for seconds - * in the load barrier with next_deadline in the past. */ - s_frame_pacer = FramePacer{ 0 }; - s_fps_last_time = 0; - s_fps_last_frame = 0; - /* GL present-dirty early-out can skip SwapWindow when the restored frame - * matches the last swap (typical on 2nd+ load of the same slot). Invalidate - * tiles + force several swaps so the window actually updates. Safe no-op - * when the GL pipeline was never brought up. */ - gl_renderer_invalidate_present(); - take_loaded_state_for_started(); -} - -#ifndef PSX_DISABLE_FRAME_INTERPOLATION -static uint64_t smooth_60_frame_hash(const uint32_t* pixels, size_t count) { - uint64_t hash = 1469598103934665603ull; - const size_t step = std::max(1, count / 4096u); - for (size_t i = 0; i < count; i += step) { - hash ^= pixels[i]; - hash *= 1099511628211ull; - } - hash ^= count; - return hash * 1099511628211ull; -} - -static bool smooth_60_scene_cut(const uint32_t* current, - const uint32_t* previous, - size_t count) { - const size_t step = std::max(1, count / 4096u); - uint64_t difference = 0; - size_t samples = 0; - size_t large_changes = 0; - for (size_t i = 0; i < count; i += step) { - const uint32_t a = current[i]; - const uint32_t b = previous[i]; - const unsigned dr = (unsigned)std::abs((int)((a >> 16) & 0xFFu) - - (int)((b >> 16) & 0xFFu)); - const unsigned dg = (unsigned)std::abs((int)((a >> 8) & 0xFFu) - - (int)((b >> 8) & 0xFFu)); - const unsigned db = (unsigned)std::abs((int)(a & 0xFFu) - - (int)(b & 0xFFu)); - const unsigned pixel_difference = dr + dg + db; - difference += pixel_difference; - if (pixel_difference > 320u) large_changes++; - samples++; - } - return samples && large_changes * 4u > samples * 3u && - difference > (uint64_t)samples * 360u; -} - -static void smooth_60_present(uint32_t* pixels, uint32_t width, uint32_t height, - bool eligible) { - if (!eligible || !pixels || !g_smooth_60fps.load(std::memory_order_acquire)) { - if (g_smooth_60_state.have_source) smooth_60_reset(); - return; - } - Smooth60State& state = g_smooth_60_state; - const size_t count = (size_t)width * height; - if (!count) { smooth_60_reset(); return; } - const uint64_t hash = smooth_60_frame_hash(pixels, count); - if (!state.have_source || state.width != width || state.height != height) { - state.previous_source.assign(pixels, pixels + count); - state.source_hash = hash; - state.width = width; - state.height = height; - state.have_source = true; - state.previous_was_duplicate = false; - return; - } - const bool duplicate = hash == state.source_hash && - std::memcmp(pixels, state.previous_source.data(), count * sizeof(uint32_t)) == 0; - if (duplicate) { state.previous_was_duplicate = true; return; } - const bool interpolate = state.previous_was_duplicate && - !smooth_60_scene_cut(pixels, state.previous_source.data(), count); - state.previous_was_duplicate = false; - state.source_hash = hash; - if (!interpolate) { - std::copy(pixels, pixels + count, state.previous_source.begin()); - return; - } - if (!state.announced) { - std::fprintf(stdout, - "psxrecomp: 60 FPS smoothing active (guest timing preserved)\n"); - state.announced = true; - } - for (size_t i = 0; i < count; i++) { - const uint32_t current = pixels[i]; - const uint32_t previous = state.previous_source[i]; - state.previous_source[i] = current; - pixels[i] = 0xFF000000u | - (((current & 0x00FEFEFEu) >> 1) + ((previous & 0x00FEFEFEu) >> 1)); - } -} - -extern "C" void psx_smooth_60fps_set(int enabled) { - g_smooth_60fps.store(enabled ? 1 : 0, std::memory_order_release); -} -#else -static void smooth_60_present(uint32_t* pixels, uint32_t width, uint32_t height, - bool eligible) { - (void)pixels; (void)width; (void)height; (void)eligible; -} -extern "C" void psx_smooth_60fps_set(int enabled) { (void)enabled; } -#endif -#if defined(PSX_WEB) -extern "C" EMSCRIPTEN_KEEPALIVE void psx_web_set_smooth_60fps(int enabled) { - psx_smooth_60fps_set(enabled); -} -#endif - -/* [video] options, resolved from the game config (defaults: native + AA). */ -static int g_video_scale = 1; /* internal-resolution SSAA factor */ -static bool g_video_aa = true; /* linear present filtering */ -static int g_video_texfilter = 0; /* 0=nearest, 1=bilinear */ -static int g_video_renderer = PSXRecompV4::DEFAULT_VIDEO_RENDERER; -static bool g_geometry_correction = false; /* visual-only subpixel mirror */ -static bool g_texture_correction = false; /* visual-only perspective UVs */ -static int g_fullscreen = 0; /* tri-state: 0 windowed, 1 borderless (desktop) - * fullscreen, 2 exclusive fullscreen */ -static int g_video_screen = 0; /* 0=raw,1=crt,2=composite,3=trinitron */ -static int g_video_win_w = 1280; /* window width (height follows aspect) */ -static bool g_audio_spu_hq = false; /* SPU float-shadow (env overrides) */ -static int g_auto_skip_fmv = 0; /* skip FMVs the instant they're detected */ -static int g_headless = 0; /* debug/CI frontend: no SDL window/audio */ - -static void parappa_apply_timing_setting(const std::string& mode_in, - int extra_early_override, - int extra_late_override, - const char *source) { - std::string mode = mode_in; - for (char& c : mode) c = (char)std::tolower((unsigned char)c); - - int next_mode = 0; - int next_early = 0; - int next_late = 0; - if (mode == "stock" || mode == "off" || mode.empty()) { - next_mode = 0; - } else if (mode == "medium") { - next_mode = 1; - next_early = 2; - next_late = 2; - } else if (mode == "permissive") { - next_mode = 2; - next_early = 6; - next_late = 6; - } else if (mode == "easy") { - next_mode = 4; - next_early = 10; - next_late = 10; - } else if (mode == "custom") { - next_mode = 3; - } else { - std::fprintf(stderr, - "psxrecomp: ignoring unknown PaRappa timing mode '%s' from %s\n", - mode_in.c_str(), source ? source : "config"); - return; - } - - if (extra_early_override >= 0) next_early = std::min(extra_early_override, 60); - if (extra_late_override >= 0) next_late = std::min(extra_late_override, 60); - if ((extra_early_override >= 0 || extra_late_override >= 0) && next_mode == 0) - next_mode = 3; - - g_parappa_timing_mode = next_mode; - g_parappa_timing_extra_early = next_early; - g_parappa_timing_extra_late = next_late; - parappa_timing_window_reset(); - std::fprintf(stdout, - "psxrecomp: PaRappa timing mode %s (early=%d late=%d, source=%s)\n", - next_mode == 1 ? "medium" : next_mode == 2 ? "permissive" : - next_mode == 3 ? "custom" : next_mode == 4 ? "easy" : "stock", - g_parappa_timing_extra_early, g_parappa_timing_extra_late, - source ? source : "config"); -} -/* FMV instant-skip via the game's OWN end-of-movie path. Tomba's MDEC player - * (FUN_8001efe8) tears a movie down when the streamed frame number reaches that - * movie's per-movie total minus 3; writing the current movie's total down to - * g_fmv_skip_end_total makes the player end it on the next frame — a natural end - * that reaches EVERY movie. g_fmv_skip_total_table = base of the per-movie u16 - * total table; g_fmv_skip_movie_id = guest addr of the current-movie-id byte. - * 0 table => fall back to START injection. See [video] fmv_skip_* in game.toml. */ -static uint32_t g_fmv_skip_total_table = 0; -static uint32_t g_fmv_skip_movie_id = 0; -static int g_fmv_skip_end_total = 3; -/* fmv_skip_no_xa: detect FMVs on MDEC activity alone (silent RAM-preloaded - * movies never stream XA, so the default MDEC+XA detector misses them — - * Tomba2's Whoopee Camp logo). Presentation-side fast-forward only. */ -static int g_fmv_skip_no_xa = 0; -static int g_fmv_skip_no_xa_hold = 4; -/* Low-latency present options (see [video] in config_loader). Measured on a - * 60Hz box the dominant input->photon cost is NOT vsync at the swap (that - * blocks ~tens of us) but that input is sampled ~one pacer-wait (~13.6ms) - * before the frame the CPU then renders from it. g_low_latency_input re-samples - * the pad AFTER the wall-clock pacer (just before present) so the next CPU frame - * reads near-fresh input. g_video_vsync controls the GL swap interval - * (1=vsync/tear-free, 0=immediate/lowest display latency+tearing, -1=adaptive); - * it trims the display-side scanout latency the CPU-side ring can't see. */ -static int g_low_latency_input = 1; -static int g_video_vsync = 1; -static int g_frame_interpolation = 0; -static int g_frame_interpolation_fps = 0; -#if defined(DISRUPTOR_DEV_MENU) -static int g_frame_interpolation_blend = - PSX_HOST_FRAME_INTERPOLATION_GEOMETRY; -static int g_frame_interpolation_blend_default = - PSX_HOST_FRAME_INTERPOLATION_GEOMETRY; -#else -static int g_frame_interpolation_blend = - PSX_MOD_FRAME_INTERPOLATION_LINEAR; -static int g_frame_interpolation_blend_default = - PSX_MOD_FRAME_INTERPOLATION_LINEAR; -#endif -static int g_mod_controller_mode_override[2] = { -1, -1 }; -static_assert((int)PSX_MOD_CONTROLLER_HYBRID == - (int)PSXRecompV4::PAD_MODE_HYBRID); -static_assert((int)PSX_MOD_CONTROLLER_ANALOG == - (int)PSXRecompV4::PAD_MODE_ANALOG); -static_assert((int)PSX_MOD_CONTROLLER_DIGITAL == - (int)PSXRecompV4::PAD_MODE_DIGITAL); -static double g_host_refresh_hz = 0.0; -static constexpr double PSX_FRAME_PERIOD_MS = 1000.0 / 59.94; -static double g_frame_period_ms = PSX_FRAME_PERIOD_MS; -static bool g_mod_native_vblank_rate = false; -static uint32_t g_mod_native_vblank_fps = 0; -/* Activation-time request. -1 means no enabled mod owns load acceleration. */ -static int g_mod_load_wall_multiplier = -1; -static int g_mod_load_release_frames = -1; -static int g_mod_disc_speed_divisor = -1; -static int g_mod_disc_instant_rate = -1; - -/* Map the configured tri-state fullscreen mode (g_fullscreen) to the SDL - * window-fullscreen flag: used both to open the window in that mode and to - * pick the hotkey's fullscreen target. */ -static Uint32 psx_fullscreen_flag_for_mode(int mode) { - if (mode == 2) return SDL_WINDOW_FULLSCREEN; /* exclusive */ - if (mode == 1) return SDL_WINDOW_FULLSCREEN_DESKTOP; /* borderless */ - return 0; /* windowed */ -} - -/* SDL2 selects borderless vs exclusive through SDL_SetWindowFullscreen's - * flag. SDL3 moved that distinction into the window's optional fullscreen - * display mode: NULL means borderless desktop and a concrete display mode - * means exclusive. Keep that difference behind one helper so startup, the - * host UI, and the fullscreen hotkey all agree. */ -static int psx_window_fullscreen_mode(SDL_Window *window) { - if (!window) return g_fullscreen; - const Uint32 flags = (Uint32)SDL_GetWindowFlags(window); - if ((flags & SDL_WINDOW_FULLSCREEN) == 0) return 0; -#if defined(PSX_SDL3) - return SDL_GetWindowFullscreenMode(window) ? 2 : 1; -#else - return ((flags & SDL_WINDOW_FULLSCREEN_DESKTOP) == - SDL_WINDOW_FULLSCREEN_DESKTOP) ? 1 : 2; -#endif -} - -#if defined(_WIN32) && defined(PSX_SDL3) -/* Keeps a borderless OpenGL window from covering its screen exactly (host_borderless.h). SDL fits it to the screen again when it is restored, so this is asked every frame. */ -static void psx_borderless_keep_windowed(void) { - int hidden = 0; - if (sdl_window && g_video_renderer == 1 && psx_window_fullscreen_mode(sdl_window) == 1) { - const HWND handle = (HWND)SDL_GetPointerProperty(SDL_GetWindowProperties(sdl_window), SDL_PROP_WINDOW_WIN32_HWND_POINTER, nullptr); - MONITORINFO info = {}; - info.cbSize = sizeof(info); - RECT now = {}; - if (handle && GetMonitorInfoW(MonitorFromWindow(handle, MONITOR_DEFAULTTONEAREST), &info) && GetWindowRect(handle, &now)) { - const PsxHostRect screen = {info.rcMonitor.left, info.rcMonitor.top, info.rcMonitor.right, info.rcMonitor.bottom}; - PsxHostRect window = {now.left, now.top, now.right, now.bottom}, wanted = {}; - if (psx_borderless_wanted(window, screen, &wanted) && - SetWindowPos(handle, nullptr, wanted.left, wanted.top, wanted.right - wanted.left, wanted.bottom - wanted.top, - SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOOWNERZORDER)) { - window = wanted; - std::fprintf(stderr, "psxrecomp: borderless window kept one row taller than its screen\n"); - } - int pixels_wide = 0, pixels_high = 0; - SDL_GetWindowSizeInPixels(sdl_window, &pixels_wide, &pixels_high); - hidden = psx_borderless_hidden_rows(window, screen, pixels_high); - } - } - gl_renderer_set_hidden_rows(hidden); -} -#else -static void psx_borderless_keep_windowed(void) {} -#endif - -static int psx_apply_window_fullscreen_mode(SDL_Window *window, int mode) { - if (!window || mode < 0 || mode > 2) return 0; -#if defined(PSX_SDL3) - if (mode == 0) { - if (!SDL_SetWindowFullscreen(window, false)) return 0; - } else if (mode == 1) { - if (!SDL_SetWindowFullscreenMode(window, nullptr)) return 0; - if (!SDL_SetWindowFullscreen(window, true)) return 0; - } else { - const SDL_DisplayID display = SDL_GetDisplayForWindow(window); - const SDL_DisplayMode *desktop = - display ? SDL_GetDesktopDisplayMode(display) : nullptr; - if (!display || !desktop) return 0; - - int width = 0; - int height = 0; - if (!SDL_GetWindowSizeInPixels(window, &width, &height) || - width <= 0 || height <= 0) { - width = desktop->w; - height = desktop->h; - } - SDL_DisplayMode closest = {}; - if (!SDL_GetClosestFullscreenDisplayMode( - display, width, height, desktop->refresh_rate, true, - &closest)) { - return 0; - } - if (!SDL_SetWindowFullscreenMode(window, &closest)) return 0; - if (!SDL_SetWindowFullscreen(window, true)) return 0; - } - /* Fullscreen transitions may be asynchronous. Settle the request before - * reporting success so the live getter and menu cannot disagree. */ - if (!SDL_SyncWindow(window)) return 0; -#else - if (SDL_SetWindowFullscreen( - window, psx_fullscreen_flag_for_mode(mode)) != 0) { - return 0; - } -#endif - psx_borderless_keep_windowed(); /* at once: a present may come before the next vblank */ - return psx_window_fullscreen_mode(window) == mode ? 1 : 0; -} - -extern "C" int psx_host_video_get_fullscreen_mode(void) { - return sdl_window ? psx_window_fullscreen_mode(sdl_window) : g_fullscreen; -} - -extern "C" int psx_host_video_set_fullscreen_mode(int mode) { - if (mode < 0 || mode > 2) return 0; - if (sdl_window && !psx_apply_window_fullscreen_mode(sdl_window, mode)) { - std::fprintf(stderr, - "psxrecomp: failed to apply fullscreen mode %d: %s\n", - mode, SDL_GetError()); - return 0; - } - g_fullscreen = mode; - return 1; -} - -/* FMV auto-skip detection hooks (cdrom.c / mdec.c). */ -extern "C" int cdrom_xa_stream_active(void); -extern "C" uint32_t mdec_get_decode_count(void); - -/* Debug observability: expose the RESOLVED FMV-skip + config state so the TCP - * debug server can report the runtime truth (which game.toml path actually loaded, - * and what auto_skip_fmv / the frame-count-table params resolved to) instead of us - * inferring it from the .toml on disk. Set once in main() after the config path is - * resolved. */ -const char *g_active_config_path = nullptr; -extern "C" void debug_get_fmv_config(int *auto_skip, uint32_t *total_table, - uint32_t *movie_id, int *no_xa_hold, - const char **cfg_path) { - if (auto_skip) *auto_skip = g_auto_skip_fmv; - if (total_table) *total_table = g_fmv_skip_total_table; - if (movie_id) *movie_id = g_fmv_skip_movie_id; - if (no_xa_hold) *no_xa_hold = g_fmv_skip_no_xa_hold; - if (cfg_path) *cfg_path = g_active_config_path ? g_active_config_path : "(null)"; -} - -/* Display aspect W:H (default 4:3 = native). Wider aspects enable the - * widescreen hack: GTE X-squash + stretched present (see [video] aspect_ratio - * in config_loader.h). */ -static int g_video_aspect_num = 4; -static int g_video_aspect_den = 3; -/* Host-UI aspect requests may arrive while the current frame is being - * composed. Keep the renderer/GTE/GPU transition at the next presentation - * boundary instead of changing projection state halfway through a frame. - * Fixed requests are packed as numerator:denominator; the two reserved values - * select adaptive mode. Zero means no pending request. A single atomic makes - * the most recent fixed/adaptive selection win without a mixed transition. */ -static std::atomic g_pending_video_aspect{0}; -static constexpr uint32_t PENDING_VIDEO_ASPECT_ADAPTIVE_ON = 0xFFFFFFFFu; -static constexpr uint32_t PENDING_VIDEO_ASPECT_ADAPTIVE_OFF = 0xFFFFFFFEu; -/* Resize-driven widescreen. The user's fixed aspect is still used to shape the - * initial window; after the game window exists these values follow its live - * aspect, clamped to 4:3..the widest mode offered by the title. */ -static bool g_ws_adaptive_view = false; -static int g_ws_adaptive_max_num = 16; -static int g_ws_adaptive_max_den = 9; - -static uint32_t pack_display_aspect(int numerator, int denominator) { - return ((uint32_t)numerator << 16) | (uint32_t)denominator; -} - -static bool is_host_fixed_display_aspect(int numerator, int denominator) { - return (numerator == 4 && denominator == 3) || - (numerator == 16 && denominator == 9) || - (numerator == 21 && denominator == 9) || - (numerator == 32 && denominator == 9); -} - -extern "C" void psx_host_video_get_display_aspect( - int *numerator, int *denominator) { - /* Report an accepted pending selection immediately so a host UI does not - * visibly snap back during the one-frame handoff to the present thread. */ - uint32_t packed = g_pending_video_aspect.load(std::memory_order_acquire); - const bool fixed_pending = - packed != 0 && - packed != PENDING_VIDEO_ASPECT_ADAPTIVE_ON && - packed != PENDING_VIDEO_ASPECT_ADAPTIVE_OFF; - int num = fixed_pending ? (int)(packed >> 16) : g_video_aspect_num; - int den = fixed_pending ? (int)(packed & 0xFFFFu) : g_video_aspect_den; - if (numerator) *numerator = num; - if (denominator) *denominator = den; -} - -extern "C" int psx_host_video_set_display_aspect( - int numerator, int denominator) { - if (!is_host_fixed_display_aspect(numerator, denominator)) return 0; - g_pending_video_aspect.store( - pack_display_aspect(numerator, denominator), - std::memory_order_release); - return 1; -} - -extern "C" int psx_host_video_get_adaptive_view(void) { - uint32_t pending = - g_pending_video_aspect.load(std::memory_order_acquire); - if (pending == PENDING_VIDEO_ASPECT_ADAPTIVE_ON) return 1; - if (pending != 0) return 0; /* Explicit off or a pending fixed aspect. */ - return g_ws_adaptive_view ? 1 : 0; -} - -extern "C" int psx_host_video_set_adaptive_view(int enabled) { - if (enabled != 0 && enabled != 1) return 0; - g_pending_video_aspect.store( - enabled ? PENDING_VIDEO_ASPECT_ADAPTIVE_ON - : PENDING_VIDEO_ASPECT_ADAPTIVE_OFF, - std::memory_order_release); - return 1; -} - -extern "C" int psx_host_video_get_internal_scale(void) { - return g_video_scale; -} - -extern "C" int psx_host_video_set_internal_scale(int scale) { - if (scale < 1 || scale > SW_MAX_INTERNAL_SCALE) return 0; - /* The developer UI itself is OpenGL-backed. Software and Vulkan keep - * startup-only allocation rules until their presentation resources gain - * an equivalent transactional rebuild. */ - if (!host_ui_state().session_live || - host_ui_state().backend != PSX_HOST_UI_BACKEND_OPENGL) { - return 0; - } - if (scale == g_video_scale) return 1; - - if (scale > sdl_pixel_buf_scale_capacity) { - uint32_t *grown = static_cast(std::realloc( - sdl_pixel_buf, - (size_t)640 * scale * 512 * scale * sizeof(uint32_t))); - if (!grown) return 0; - sdl_pixel_buf = grown; - sdl_pixel_buf_scale_capacity = scale; - } - - gr_set_scale(scale); - const int applied = gr_scale(); - if (applied != scale) return 0; - g_video_scale = applied; - gl_renderer_invalidate_present(); - return 1; -} - -extern "C" int psx_mod_set_fixed_display_aspect( - uint32_t numerator, uint32_t denominator) { - if (numerator == 0 || denominator == 0 || - numerator > 99 || denominator > 99 || - 3u * numerator < 4u * denominator || - 9u * numerator > 32u * denominator) { - std::fprintf(stderr, - "psxrecomp: mod rejected invalid display aspect %u:%u\n", - (unsigned)numerator, (unsigned)denominator); - return 0; - } - g_video_aspect_num = (int)numerator; - g_video_aspect_den = (int)denominator; - g_ws_adaptive_view = false; - std::fprintf(stdout, "psxrecomp: mod selected fixed display aspect %u:%u\n", - (unsigned)numerator, (unsigned)denominator); - return 1; -} - -extern "C" int psx_mod_set_adaptive_display_aspect( - uint32_t max_numerator, uint32_t max_denominator) { - if (max_numerator == 0 || max_denominator == 0 || - max_numerator > 99 || max_denominator > 99 || - 3u * max_numerator < 4u * max_denominator || - 9u * max_numerator > 32u * max_denominator) { - std::fprintf(stderr, - "psxrecomp: mod rejected invalid adaptive display aspect %u:%u\n", - (unsigned)max_numerator, (unsigned)max_denominator); - return 0; - } - g_ws_adaptive_view = true; - g_ws_adaptive_max_num = (int)max_numerator; - g_ws_adaptive_max_den = (int)max_denominator; - std::fprintf(stdout, - "psxrecomp: mod selected adaptive display aspect " - "(initial %d:%d, range 4:3 through %u:%u)\n", - g_video_aspect_num, g_video_aspect_den, - (unsigned)max_numerator, (unsigned)max_denominator); - return 1; -} - -extern "C" int psx_mod_set_native_vblank_rate( - uint32_t frames_per_second) { - if (frames_per_second != 0 && - (frames_per_second < 60 || frames_per_second > 1000)) { - std::fprintf(stderr, - "psxrecomp: mod rejected invalid native VBlank rate %u FPS\n", - (unsigned)frames_per_second); - return 0; - } - g_mod_native_vblank_rate = true; - g_mod_native_vblank_fps = frames_per_second; - g_frame_period_ms = frames_per_second - ? 1000.0 / (double)frames_per_second - : 0.0; - /* - * Above the physical panel rate (and in uncapped mode), swap-interval - * blocking would silently replace the requested guest cadence with the - * display cadence. The frame pacer owns fixed-rate timing here. - */ - if (frames_per_second == 0 || frames_per_second > 60) - g_video_vsync = 0; - if (frames_per_second) { - std::fprintf(stdout, - "psxrecomp: mod selected native guest VBlank pacing at %u FPS " - "(%.4f ms/frame)\n", - (unsigned)frames_per_second, g_frame_period_ms); - } else { - std::fprintf(stdout, - "psxrecomp: mod selected uncapped native guest VBlank pacing\n"); - } - return 1; -} - -extern "C" int psx_mod_set_frame_interpolation( - uint32_t frames_per_second) { -#ifdef PSX_DISABLE_FRAME_INTERPOLATION - (void)frames_per_second; - return 0; -#else - if (frames_per_second != 0 && - (frames_per_second < 60 || frames_per_second > 1000)) { - std::fprintf(stderr, - "psxrecomp: mod rejected invalid interpolated frame rate %u FPS\n", - (unsigned)frames_per_second); - return 0; - } - - /* - * Interpolation is a presentation feature of the OpenGL renderer. Mods are - * activated before the game window and renderer are created, so selecting - * it here is deterministic and does not require a live backend switch. - */ - g_video_renderer = 1; - g_video_vsync = 0; - g_frame_interpolation = 1; - g_frame_interpolation_fps = - frames_per_second ? (int)frames_per_second : 0; - - if (frames_per_second) { - std::fprintf(stdout, - "psxrecomp: mod selected presentation-only interpolation at " - "%u FPS; guest timing remains stock\n", - (unsigned)frames_per_second); - } else { - std::fprintf(stdout, - "psxrecomp: mod selected display-refresh presentation-only " - "interpolation; guest timing remains stock\n"); - } - return 1; -#endif -} - -extern "C" int psx_mod_set_frame_interpolation_blend( - uint32_t blend_mode) { -#ifdef PSX_DISABLE_FRAME_INTERPOLATION - (void)blend_mode; - return 0; -#else - if (blend_mode != PSX_MOD_FRAME_INTERPOLATION_LINEAR && - blend_mode != PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE) { - std::fprintf(stderr, - "psxrecomp: mod rejected invalid frame-interpolation blend %u\n", - (unsigned)blend_mode); - return 0; - } - g_frame_interpolation_blend = (int)blend_mode; - std::fprintf(stdout, "psxrecomp: frame-interpolation blend = %s\n", - blend_mode == PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE - ? "motion-adaptive clarity" : "linear crossfade"); - return 1; -#endif -} - -extern "C" int psx_mod_set_auto_skip_fmv(int enabled) { - if (enabled != 0 && enabled != 1) { - std::fprintf(stderr, - "psxrecomp: mod rejected invalid auto-skip-FMV value %d\n", - enabled); - return 0; - } - g_auto_skip_fmv = enabled; - std::fprintf(stdout, "psxrecomp: mod %s automatic FMV skipping\n", - enabled ? "enabled" : "disabled"); - return 1; -} - -extern "C" int psx_mod_set_load_acceleration( - uint32_t wall_clock_multiplier, uint32_t release_frames) { - if ((wall_clock_multiplier != 0 && - (wall_clock_multiplier < 2 || wall_clock_multiplier > 16)) || - release_frames > 60) { - std::fprintf(stderr, - "psxrecomp: mod rejected load acceleration " - "(multiplier %u, release frames %u)\n", - (unsigned)wall_clock_multiplier, (unsigned)release_frames); - return 0; - } - g_mod_load_wall_multiplier = (int)wall_clock_multiplier; - g_mod_load_release_frames = (int)release_frames; - return 1; -} - -extern "C" int psx_mod_set_disc_speed( - uint32_t divisor, uint32_t instant_max_per_frame) { - if ((divisor != 0 && divisor != 2 && divisor != 4) || - (divisor == 0 && - (instant_max_per_frame < 1 || instant_max_per_frame > 256))) { - std::fprintf(stderr, - "psxrecomp: mod rejected disc speed " - "(divisor %u, instant budget %u)\n", - (unsigned)divisor, (unsigned)instant_max_per_frame); - return 0; - } - g_mod_disc_speed_divisor = (int)divisor; - g_mod_disc_instant_rate = - divisor == 0 ? (int)instant_max_per_frame : -1; - return 1; -} - -extern "C" int psx_mod_set_controller_mode_override( - uint32_t player, uint32_t controller_mode) { - if (player >= 2 || - controller_mode > (uint32_t)PSXRecompV4::PAD_MODE_DIGITAL) { - std::fprintf(stderr, - "psxrecomp: mod rejected controller-mode override " - "(player %u, mode %u)\n", - (unsigned)player, (unsigned)controller_mode); - return 0; - } - g_mod_controller_mode_override[player] = (int)controller_mode; - return 1; -} - -/* [widescreen] per-game hooks (see config_loader.h): anchor scratch addr for - * tagged sprite prims + HUD SPRT center-squash. Inert at 0/false. */ -static uint32_t g_ws_anchor_addr = 0; -static bool g_ws_hud_sprt = false; -/* Runtime-only transition cleanup; kept out of gpu.h because generated game - * units include that ABI header and do not need this frontend-only setter. */ -extern "C" void gpu_ws_set_clear_reveal(int on); -extern "C" void gpu_ws_set_nw_textured_edges(int on, int scale_pct); -extern "C" void gpu_ws_set_signed_x_bound_sites(const uint32_t*, const uint32_t*, int); -/* Widescreen engages at game entry (fntrace_is_game_started): the BIOS boot - * — Sony logo, PS logo, shell — presents authentic 4:3 with no GTE squash. - * Starts true when the configured aspect is already 4:3 (nothing to engage). */ -static bool g_ws_engaged = true; -/* Wide-aspect strategy: native-wide (render the wider FOV into a wider frame, - * present 1:1 — the GTE is NOT squashed) vs. the legacy squash hack. Default - * native-wide; toggle live via the ws_nw TCP command for A/B comparison. */ -static int g_ws_native_wide = 1; -/* Logical present width for the SDL_Renderer (software) path; 640*scale at - * 4:3, wider for wide aspects. Height is always 480*scale. Set at window - * creation alongside SDL_RenderSetLogicalSize. */ -static int g_logical_w = 640; -static bool g_gl_active = false; /* GL context live -> GL present path */ -static bool g_vk_active = false; /* Vulkan context live -> VK present path */ - -/* Clamp a requested window width to the primary display's usable area so an - * oversized choice (e.g. 1920 on a 1080p panel) still fits on screen. Keeps - * the given aspect: height = width*den/num. */ -static void clamp_window_aspect(int* w, int* h, int num, int den) { - int width = *w; - if (width < 640) width = 640; - SDL_Rect bounds; - if (SDL_GetDisplayUsableBounds(0, &bounds) == 0 && bounds.w > 0 && bounds.h > 0) { - if (width > bounds.w) width = bounds.w; - if (width * den / num > bounds.h) width = bounds.h * num / den; - } - *w = width; - *h = width * den / num; -} - -static int aspect_gcd(int a, int b) { - while (b) { int t = a % b; a = b; b = t; } - return a > 0 ? a : 1; -} - -/* Resize only an ordinary window. Fullscreen owns the display mode/desktop - * extent; its compositor instead follows the logical/present aspect below. */ -static void reshape_window_for_fixed_aspect(int num, int den) { - if (!sdl_window || - (SDL_GetWindowFlags(sdl_window) & SDL_WINDOW_FULLSCREEN) != 0) - return; - - int width = 0, height = 0; - SDL_GetWindowSize(sdl_window, &width, &height); - if (width <= 0) return; - height = width * den / num; - clamp_window_aspect(&width, &height, num, den); - SDL_SetWindowSize(sdl_window, width, height); -} - -/* One transition funnel for adaptive resizing and explicit host-UI choices. - * It runs only from sdl_vblank_present, after game hooks have completed, so a - * frame cannot mix old projection state with a new presentation shape. */ -static void apply_live_display_aspect(int num, int den, - bool reshape_fixed_window) { - const bool changed = - num != g_video_aspect_num || den != g_video_aspect_den; - g_video_aspect_num = num; - g_video_aspect_den = den; - - gl_renderer_set_display_aspect(num, den); - if (sdl_renderer) { - g_logical_w = 480 * num * g_video_scale / den; - SDL_RenderSetLogicalSize(sdl_renderer, g_logical_w, - 480 * g_video_scale); - } - if (reshape_fixed_window) reshape_window_for_fixed_aspect(num, den); - - const bool wide = num * 3 != den * 4; - const bool game_started = fntrace_is_game_started() != 0; - if (game_started) { - g_ws_engaged = true; - const int mode = wide ? (g_ws_native_wide ? 2 : 1) : 0; - gte_set_display_aspect(mode == 1 ? num : 4, - mode == 1 ? den : 3); - gpu_ws_configure(num, den, g_ws_anchor_addr, - g_ws_hud_sprt ? 1 : 0, mode); - } else { - /* Preserve authentic 4:3 boot rendering even when a host UI selects a - * wide output before game entry. The normal engagement path will pick - * up the already-updated num:den once gameplay begins. */ - g_ws_engaged = !wide; - gte_set_display_aspect(4, 3); - gpu_ws_configure(4, 3, g_ws_anchor_addr, - g_ws_hud_sprt ? 1 : 0, 0); - } - - if (changed && g_gl_active) { - /* Do not crossfade or reuse a stale destination rectangle across an - * aspect transition. This also forces an otherwise-identical next - * frame through the GL present path. */ - gl_renderer_invalidate_present(); - } -} - -static void update_adaptive_widescreen(); - -static void apply_pending_display_aspect() { - uint32_t packed = - g_pending_video_aspect.exchange(0, std::memory_order_acq_rel); - if (!packed) return; - - if (packed == PENDING_VIDEO_ASPECT_ADAPTIVE_ON) { - g_ws_adaptive_view = true; - /* The developer-facing match-window mode supports every intermediate - * window ratio, but never exposes more than the audited 32:9 view. */ - g_ws_adaptive_max_num = 32; - g_ws_adaptive_max_den = 9; - update_adaptive_widescreen(); - std::fprintf(stdout, - "psxrecomp: live adaptive display aspect enabled " - "(match window, maximum 32:9)\n"); - return; - } - if (packed == PENDING_VIDEO_ASPECT_ADAPTIVE_OFF) { - /* Leave the current effective aspect in place. A subsequent fixed - * selection can choose and reshape a canonical fixed output. */ - g_ws_adaptive_view = false; - std::fprintf(stdout, - "psxrecomp: live adaptive display aspect disabled\n"); - return; - } - - int num = (int)(packed >> 16); - int den = (int)(packed & 0xFFFFu); - g_ws_adaptive_view = false; - apply_live_display_aspect(num, den, true); - std::fprintf(stdout, - "psxrecomp: live fixed display aspect = %d:%d (adaptive view off)\n", - num, den); -} - -/* Follow the host window without feeding its absolute pixel size into guest - * rendering. Only the ratio matters: gpu_ws_configure derives the PSX-native - * sidecar width from it, just as the fixed 16:9/21:9 modes do. */ -static void update_adaptive_widescreen() { - if (!g_ws_adaptive_view || !sdl_window) return; - - int width = 0, height = 0; - SDL_GetWindowSize(sdl_window, &width, &height); - if (width <= 0 || height <= 0) return; - - int num = width, den = height; - if ((int64_t)width * 3 <= (int64_t)height * 4) { - num = 4; den = 3; - } else if ((int64_t)width * g_ws_adaptive_max_den >= - (int64_t)height * g_ws_adaptive_max_num) { - num = g_ws_adaptive_max_num; - den = g_ws_adaptive_max_den; - } else { - int divisor = aspect_gcd(num, den); - num /= divisor; - den /= divisor; - } - if (num == g_video_aspect_num && den == g_video_aspect_den) return; - apply_live_display_aspect(num, den, false); -} - -/* SDL GL attributes are global inputs to the next context creation. Set the - * runtime requirements immediately before every GL window, because launcher - * teardown resets them and macOS otherwise supplies a legacy 2.1 context. */ -static void configure_core_gl_context_attributes() { - SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); - SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); - SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); - SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); - SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8); -} - -/* Live native-wide vs squash toggle (ws_nw TCP command) for A/B comparison. - * Re-engages with the chosen mode in place if widescreen is already running. */ -extern "C" void psx_ws_set_native_wide(int on) { - g_ws_native_wide = on ? 1 : 0; - if (g_ws_engaged && g_video_aspect_num * 3 != g_video_aspect_den * 4) { - int mode = g_ws_native_wide ? 2 : 1; - gte_set_display_aspect(mode == 1 ? g_video_aspect_num : 4, - mode == 1 ? g_video_aspect_den : 3); - gpu_ws_configure(g_video_aspect_num, g_video_aspect_den, - g_ws_anchor_addr, g_ws_hud_sprt ? 1 : 0, mode); - } -} -extern "C" int psx_ws_get_native_wide(void) { return g_ws_native_wide; } - -extern "C" int psx_host_video_get_vsync(void) { - return g_video_vsync; -} - -extern "C" int psx_host_video_set_vsync(int mode) { - if (mode < -1 || mode > 1) return 0; - g_video_vsync = mode; -#ifndef PSX_SDL_NO_RENDER - /* gl_renderer_set_swap_interval remembers this request but keeps the - * physical interval at zero while interpolation owns presentation. */ - if (g_gl_active) gl_renderer_set_swap_interval(mode); - if (g_vk_active) vk_renderer_set_present_mode(mode); -#endif - latency_ring_set_present_mode(mode); - return 1; -} - -extern "C" void psx_host_video_get_interpolation(int *enabled, - int *target_fps, - int *blend_mode) { -#ifdef PSX_DISABLE_FRAME_INTERPOLATION - if (enabled) *enabled = 0; - if (target_fps) *target_fps = 0; - if (blend_mode) *blend_mode = PSX_HOST_FRAME_INTERPOLATION_LINEAR; -#else - int actual_enabled = g_frame_interpolation ? 1 : 0; - if (g_gl_active) - gl_renderer_interpolation_diag(&actual_enabled, nullptr, nullptr, - nullptr, nullptr, nullptr); - if (enabled) *enabled = actual_enabled; - if (target_fps) *target_fps = g_frame_interpolation_fps; - if (blend_mode) *blend_mode = g_frame_interpolation_blend; -#endif -} - -extern "C" void psx_host_video_get_geometry_interpolation_stats( - uint64_t out[6]) { - if (!out) return; - for (int i = 0; i < 6; ++i) out[i] = 0; - if (g_gl_active) gl_renderer_geometry_interpolation_diag(out); -} - -extern "C" int psx_host_video_set_interpolation(int enabled, - int target_fps, - int blend_mode) { -#ifdef PSX_DISABLE_FRAME_INTERPOLATION - (void)target_fps; (void)blend_mode; - /* A disable request is harmless; activation is unavailable in this build. */ - return enabled ? 0 : 1; -#else - enabled = enabled ? 1 : 0; - if (target_fps != -1 && target_fps != 0 && - (target_fps < 30 || target_fps > 1000)) - return 0; - if (blend_mode != PSX_MOD_FRAME_INTERPOLATION_LINEAR && - blend_mode != PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE && - blend_mode != PSX_HOST_FRAME_INTERPOLATION_GEOMETRY) - return 0; - /* Interpolation is implemented only by the GL backend and renderer changes - * are restart-owned. Do not let an in-game control advertise success on a - * software/Vulkan session. */ - if (enabled && !g_gl_active) return 0; - if (enabled && blend_mode == PSX_HOST_FRAME_INTERPOLATION_GEOMETRY && - !gpu_geometry_correction_enabled()) - return 0; - - if (g_gl_active) { - int previous_actual = 0; - gl_renderer_interpolation_diag(&previous_actual, nullptr, nullptr, - nullptr, nullptr, nullptr); - gl_renderer_set_interpolation(enabled, g_host_refresh_hz, - (double)target_fps, blend_mode); - int actual = 0; - gl_renderer_interpolation_diag(&actual, nullptr, nullptr, - nullptr, nullptr, nullptr); - if (actual != enabled) { - /* Context/thread creation and display-rate validation live in the - * GL backend. Do not advertise a requested state which the - * renderer could not actually enter. Restore a previously-live - * interpolator when a target change was rejected. */ - if (actual != previous_actual) { - gl_renderer_set_interpolation( - previous_actual, g_host_refresh_hz, - (double)g_frame_interpolation_fps, - g_frame_interpolation_blend); - } - g_frame_interpolation = previous_actual; - gl_renderer_set_swap_interval(g_video_vsync); - return 0; - } - /* Re-assert the requested user interval. The GL backend defers it - * while interpolation is enabled and restores it on disable. */ - gl_renderer_set_swap_interval(g_video_vsync); - } - g_frame_interpolation = enabled; - g_frame_interpolation_fps = target_fps; - g_frame_interpolation_blend = blend_mode; - return 1; -#endif -} - -/* Present straight from the FBO (fast, no readback). Set PSX_GL_FORCE_CPU_PRESENT=1 - * to force the software readout path instead — a diagnostic/fallback that also - * keeps CPU VRAM current every frame (so screenshots reflect the screen). */ -static int g_gl_fbo_present = 1; - -/* Vsync self-heal state (see SDL_RenderPresent wrapper in - * sdl_vblank_present). C linkage: freeze_heartbeat.c includes both in - * the freeze dump so a slow-frames wedge can be attributed to driver - * present backpressure from the dump alone. */ -extern "C" { -uint32_t g_present_slow_count = 0; /* presents that blocked >250ms */ -int g_present_vsync_disabled = 0; /* 1 once self-heal tripped */ -} - -/* Turbo-through-loads (step 4). C linkage: debug_server.c reads/toggles the - * enable and reports the frame counter. Enabled by game.toml [runtime] - * turbo_loads (opt-in per game) or the turbo_loads TCP command. */ -extern "C" { -int g_turbo_loads_enabled = 0; -uint64_t g_turbo_loads_frames = 0; /* vblanks run unpaced (observability) */ -int g_turbo_audio_sink_enabled = 0; -int g_turbo_audio_sink_active = 0; -uint64_t g_turbo_audio_sink_frames = 0; /* guest SPU frames advanced, not queued */ -} -/* The CD predicate already excludes XA and holds across ordinary inter-file - * gaps. A short engage debounce rejects a one-frame controller blip without - * leaving a visible authentic-paced prefix on every real load. Once engaged, - * a symmetric release debounce rides through tiny late-sector/IRQ gaps. This - * changes host presentation/pacing only; guest cycles and input sampling keep - * advancing normally. */ -#define TURBO_LOADS_ENGAGE_FRAMES 4 -#define TURBO_LOADS_RELEASE_FRAMES 6 -/* Zero multiplier retains the historical uncapped turbo behavior. */ -static int g_turbo_load_wall_multiplier = 0; -static int g_turbo_load_release_frames = TURBO_LOADS_RELEASE_FRAMES; -static SDL_AudioDeviceID sdl_audio_device; -static int16_t sdl_audio_buf[2048 * 2]; -static std::atomic g_audio_master_volume_percent{100}; -static std::atomic g_audio_master_muted{0}; -/* Legacy queued audio has two producer sites (ordinary pump and turbo-mute - * tail); both share the same applied gain so either path transitions smoothly. */ -static float g_audio_legacy_applied_master_gain = -1.0f; -/* Callback-owned; reset only after the audio device has stopped. */ -static float g_audio_drc_applied_master_gain = -1.0f; - -static void sdl_audio_apply_master_gain(int16_t* buf, int frames, - float* applied_gain); - -/* DRC bridge. Producer (sdl_audio_pump) runs on the main loop thread under - * SDL_LockAudioDevice; consumer (sdl_drc_callback) runs on the SDL audio - * thread. */ -static rab_bridge s_drc; -static bool s_drc_ready = false; -/* Per-game bridge fill target. 180 ms remains the compatibility default; - * [audio].buffer_ms may lower it for titles whose production cadence has been - * validated with less reserve. */ -static double g_audio_buffer_ms = 180.0; - -/* Observability + A/B. PSXRECOMP_AUDIO_LEGACY=1 keeps the historical push model - * (SDL_QueueAudio, no bridge) so the underrun baseline can be measured against - * the bridge from a single build; default (unset) = bridge/pull model. Output - * health is surfaced through the audio_stats TCP command (no stderr probe). */ -static bool audio_legacy_mode(void) { - static int v = -1; - if (v < 0) { const char* e = getenv("PSXRECOMP_AUDIO_LEGACY"); v = (e && *e && *e != '0') ? 1 : 0; } - return v != 0; -} -/* Legacy-mode underrun counter: incremented when the SDL queue is found empty - * at pump time (the device was silence-filling = an audible gap). */ -static uint64_t g_legacy_underruns = 0; -/* Set at unmute so the pump resyncs its bridge-underrun baseline past the - * intentional mute-drain instead of reporting it as gaps. */ -int g_audio_unmute_resync = 0; -/* Actual device rate the host opened at (bridge mode may differ from 44100; - * the T3 tap ring runs at this rate and its WAV dump must say so). */ -extern "C" { -int g_audio_host_rate = 44100; -} - -extern "C" int psx_host_audio_get_master_volume(void) { - return g_audio_master_volume_percent.load(std::memory_order_relaxed); -} - -extern "C" int psx_host_audio_set_master_volume(int percent) { - if (percent < 0 || percent > 100) return 0; - g_audio_master_volume_percent.store(percent, std::memory_order_relaxed); - return 1; -} - -extern "C" int psx_host_audio_get_muted(void) { - return g_audio_master_muted.load(std::memory_order_relaxed); -} - -extern "C" int psx_host_audio_set_muted(int muted) { - if (muted != 0 && muted != 1) return 0; - g_audio_master_muted.store(muted, std::memory_order_relaxed); - return 1; -} - -static void sdl_drc_callback(void* /*user*/, Uint8* stream, int len) { - if (!s_drc_ready) { std::memset(stream, 0, (size_t)len); return; } - int frames = len / (int)(2 * sizeof(int16_t)); /* stereo S16 */ - rab_pull(&s_drc, reinterpret_cast(stream), frames); - /* Apply user gain at the final pull boundary so the default bridge path - * responds on the next device callback instead of waiting for the DRC - * ring's buffered audio to drain. */ - sdl_audio_apply_master_gain( - reinterpret_cast(stream), frames, - &g_audio_drc_applied_master_gain); - /* T3 tap in bridge mode: the exact device-rate bytes the host consumes. - * This callback is the tap's single writer while the bridge is active. */ - audio_trace_pcm(AUDIO_TAP_HOST, reinterpret_cast(stream), - frames); -} - -static std::filesystem::path find_upward(std::filesystem::path start, - const std::filesystem::path& marker) { - std::error_code ec; - start = std::filesystem::absolute(start, ec); - if (ec) start = std::filesystem::current_path(); - if (!std::filesystem::is_directory(start, ec)) start = start.parent_path(); - - for (;;) { - if (std::filesystem::exists(start / marker, ec)) return start; - if (!start.has_parent_path() || start.parent_path() == start) break; - start = start.parent_path(); - } - return {}; -} - -// The directory the running executable lives in — the SINGLE anchor for every -// runtime file (game.toml, disc, BIOS, settings.toml, caches, memcards, sidecar -// .cfg). Deliberately NEVER the current working directory: a game must launch -// identically whether started from its own folder, a shortcut, a shell in some -// other directory, or a debugger. On Windows GetModuleFileNameW is the -// authoritative source (independent of argv[0], which can be relative/bare and -// would otherwise get resolved against cwd by fs::absolute). $APPIMAGE and -// argv[0] are non-Windows / fallback sources only. -static std::filesystem::path exe_dir_from_argv(const char* argv0) { - namespace fs = std::filesystem; - std::error_code ec; - fs::path exe_dir; -#ifdef _WIN32 - // Authoritative: the real image path, regardless of how we were invoked. - { - wchar_t buf[MAX_PATH * 4]; - DWORD n = GetModuleFileNameW(NULL, buf, (DWORD)(sizeof(buf) / sizeof(buf[0]))); - if (n > 0 && n < (DWORD)(sizeof(buf) / sizeof(buf[0]))) { - exe_dir = fs::path(std::wstring(buf, buf + n)).parent_path(); - } - } -#endif - // Inside an AppImage the binary lives in a read-only mount and argv[0] can - // be a bare name or symlink (launched via PATH / a .desktop file). $APPIMAGE - // is the .AppImage's own path, so settings.toml anchors next to it. - if (exe_dir.empty()) { - if (const char* appimg = std::getenv("APPIMAGE"); appimg && appimg[0]) { - exe_dir = fs::absolute(appimg, ec).parent_path(); - if (ec) exe_dir.clear(); - } - } - if (exe_dir.empty() && argv0 && argv0[0]) { - exe_dir = fs::absolute(argv0, ec).parent_path(); - if (ec) exe_dir.clear(); - } - // Last-ditch only (should be unreachable on a normal launch); a bare "." so - // we never silently resolve against an unrelated cwd deeper in the tree. - if (exe_dir.empty()) exe_dir = fs::path("."); - return exe_dir; -} - -static std::filesystem::path resolve_existing_runtime_path(const char* requested, - const char* argv0) { - namespace fs = std::filesystem; - if (!requested || !requested[0]) return {}; - - std::error_code ec; - fs::path p(requested); - if (fs::exists(p, ec)) return fs::absolute(p, ec); - if (p.is_absolute()) return {}; - - // Anchor exclusively on the exe directory — never cwd (see exe_dir_from_argv). - const fs::path root = exe_dir_from_argv(argv0); - fs::path direct = root / p; - if (fs::exists(direct, ec)) return fs::absolute(direct, ec); - fs::path found = find_upward(root, p); - if (!found.empty()) return fs::absolute(found / p, ec); - return {}; -} - -static std::filesystem::path sidecar_cfg_path(const char* argv0, const char* filename) { - return exe_dir_from_argv(argv0) / filename; -} - -static std::filesystem::path read_cached_path(const char* argv0, const char* filename) { - std::ifstream f(sidecar_cfg_path(argv0, filename)); - if (!f.is_open()) return {}; - std::string line; - std::getline(f, line); - while (!line.empty() && (line.back() == '\n' || line.back() == '\r')) { - line.pop_back(); - } - return line.empty() ? std::filesystem::path{} : std::filesystem::path(line); -} - -static void write_cached_path(const char* argv0, const char* filename, - const std::filesystem::path& path) { - std::ofstream f(sidecar_cfg_path(argv0, filename), std::ios::trunc); - if (f.is_open()) f << path.string() << "\n"; -} - -static void launcher_warning(const char* title, const std::string& msg) { - std::fprintf(stderr, "%s: %s\n", title, msg.c_str()); -#ifdef _WIN32 - // Headless (--headless / PSX_HEADLESS): NEVER pop a blocking modal — it would - // hang an unattended/CI/scripted run forever waiting for a click. - if (!g_headless) MessageBoxA(NULL, msg.c_str(), title, MB_OK | MB_ICONWARNING); -#endif -} - -static void launcher_info(const char* title, const std::string& msg) { - std::fprintf(stderr, "%s: %s\n", title, msg.c_str()); -#ifdef _WIN32 - if (!g_headless) MessageBoxA(NULL, msg.c_str(), title, MB_OK | MB_ICONINFORMATION); -#endif -} - -/* Game display name for picker dialogs ("Tomba!"); set after the game - * config loads, before any interactive file resolution. */ -static std::string s_picker_game_name = "PSXRecomp"; - -/* BIOS selection state (docs/BIOS_SELECTION.md). s_openbios_allowed is the - * game's [runtime] openbios; s_bundled_bios_rel is where the shipped - * redistributable image lives relative to the executable. */ -static bool s_openbios_allowed = true; -#ifdef PSX_BUNDLED_BIOS_PATH -static std::string s_bundled_bios_rel = PSX_BUNDLED_BIOS_PATH; -#else -static std::string s_bundled_bios_rel = "bios/openbios.bin"; -#endif - -static bool pick_runtime_file(const char* title, const char* filter, - std::filesystem::path& out, const char* cli_flag) { - // Headless: never open an interactive file dialog (it blocks). Fail the - // resolve so boot aborts cleanly with the stderr message the caller printed. - if (g_headless) { - std::fprintf(stderr, - "psxrecomp: headless — cannot prompt for '%s'.\n" - " Supply it on the command line: %s (or set it in game.toml).\n", - title, cli_flag); - return false; - } -#ifdef _WIN32 - /* IFileOpenDialog, not GetOpenFileName: that one moves the process's working directory into every folder the - * player opens (measured, OFN_NOCHANGEDIR or not), and the freeze heartbeat writes its files there. */ - const auto wide = [](const char* text) { - std::wstring out(static_cast(MultiByteToWideChar(CP_UTF8, 0, text, -1, nullptr, 0)), L'\0'); - MultiByteToWideChar(CP_UTF8, 0, text, -1, out.data(), static_cast(out.size())); - return out; - }; - std::vector texts; - for (const char* at = filter; at && *at; at += std::strlen(at) + 1) texts.push_back(wide(at)); - std::vector kinds; - for (size_t pair = 0; pair + 1 < texts.size(); pair += 2) kinds.push_back({texts[pair].c_str(), texts[pair + 1].c_str()}); - - const HRESULT initialised = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE); - IFileOpenDialog* dialog = nullptr; - bool chosen = false; - if (SUCCEEDED(CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&dialog)))) { - DWORD options = 0; - dialog->GetOptions(&options); - dialog->SetOptions(options | FOS_FILEMUSTEXIST | FOS_PATHMUSTEXIST | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR); - if (!kinds.empty()) dialog->SetFileTypes(static_cast(kinds.size()), kinds.data()); - dialog->SetTitle(wide(title).c_str()); - IShellItem* item = nullptr; - wchar_t* path = nullptr; - if (SUCCEEDED(dialog->Show(nullptr)) && SUCCEEDED(dialog->GetResult(&item)) && - SUCCEEDED(item->GetDisplayName(SIGDN_FILESYSPATH, &path))) { - out = path; - chosen = true; - } - CoTaskMemFree(path); - if (item) item->Release(); - dialog->Release(); - } - if (SUCCEEDED(initialised)) CoUninitialize(); - return chosen; -#else - (void)filter; - (void)out; - // No native GUI file picker on this platform (macOS/Linux): the file must be - // named on the command line. Tell the user the exact flag + a full example - // instead of a dead-end "requires a command-line path". - std::fprintf(stderr, - "psxrecomp: no graphical file picker on this platform — '%s'\n" - " must be supplied on the command line: %s \n" - " Example: ./ --game game.toml " - "--bios /path/to/SCPH1001.BIN --disc /path/to/game.cue\n" - " (or set the path in game.toml). See the game's README, \"Building From Source\".\n", - title, cli_flag); - return false; -#endif -} - -/* The dialog runs on a thread of its own: on the game's thread it would stop the frames, and the freeze watchdog - * takes two seconds without a frame for a hang and writes a dump. */ -/* Never destroyed: the dialog's thread may still be in the dialog when the program ends. */ -static PsxHostPickSlot& s_disc_pick = *new PsxHostPickSlot; -static std::thread& s_disc_worker = *new std::thread; - -extern "C" int psx_host_pick_disc_image_begin(void) { -#ifdef _WIN32 - if (g_headless || !s_disc_pick.open()) return 0; - try { - /* The worker before this one closed its pick as its last act, and no pick opens before that one is taken. */ - if (s_disc_worker.joinable()) s_disc_worker.join(); - s_disc_worker = std::thread([] { - std::string path; - try { - std::filesystem::path picked; - if (pick_runtime_file("Select a disc image", - "Disc image (*.cue;*.bin;*.chd;*.iso)\0*.cue;*.bin;*.chd;*.iso\0All files\0*.*\0", - picked, "--disc")) - path = picked.string(); - } catch (...) { - std::fprintf(stderr, "psxrecomp: the chosen disc image could not be taken, its path may have letters this system's code page lacks\n"); - path.clear(); - } - s_disc_pick.close(std::move(path)); - }); - } catch (const std::system_error&) { - s_disc_pick.abandon(); - return 0; - } - return 1; -#else - return 0; -#endif -} - -extern "C" int psx_host_pick_disc_image_poll(char* out, int size) { - return s_disc_pick.take(out, size); -} - -static std::string uppercase_ascii(std::string s) { - for (char& c : s) { - c = (char)std::toupper((unsigned char)c); - } - return s; -} - -// Region display label for the launcher, derived from the game-id serial -// prefix (e.g. "SCUS-94423" -> "(USA)"). Used only when game.toml [game] -// leaves `region` unset. Unknown/empty serials yield "" (no badge) rather -// than guessing. -static std::string region_label_from_serial(const std::string& serial) { - if (serial.size() < 3) return std::string(); - const std::string up = uppercase_ascii(serial); - const std::string p4 = up.substr(0, std::min(4, up.size())); - const std::string p3 = up.substr(0, 3); - if (p4 == "SCUS" || p4 == "SLUS" || p3 == "LSP") return "(USA)"; - if (p4 == "SCES" || p4 == "SLES") return "(Europe)"; - if (p4 == "SCPS" || p4 == "SLPS" || p4 == "SLPM") return "(Japan)"; - return std::string(); -} - -static bool read_at(std::ifstream& f, uint64_t offset, uint8_t* out, size_t len) { - f.clear(); - f.seekg((std::streamoff)offset, std::ios::beg); - if (!f.good()) return false; - f.read(reinterpret_cast(out), (std::streamsize)len); - return f.gcount() == (std::streamsize)len; -} - -struct DiscValidation { - bool opened = false; - bool has_header = false; - bool id_matches = false; - std::string detail; -}; - -static DiscValidation validate_disc_image(const std::filesystem::path& selected_path, - const std::string& game_id) { - // Delegate to the shared disc-identity module so the launch-time check and - // the launcher badge can never drift apart. No CRC here — the launch check - // only cares about openability / header / serial; the launcher does the - // (slower) CRC pass when an expected CRC is configured. - const PSXRecompV4::DiscIdentity id = - PSXRecompV4::identify_disc(selected_path, game_id, /*expected_crc*/0, - /*has_expected_crc*/false, /*compute_crc*/false); - DiscValidation v; - v.opened = id.opened; - v.has_header = id.has_header; - v.id_matches = game_id.empty() ? true : id.serial_matches; - v.detail = id.detail; - if (id.opened && id.has_header && !v.id_matches && v.detail.empty()) { - v.detail = "The disc header is readable, but it does not contain the expected game ID " + - uppercase_ascii(game_id) + " in the early disc metadata."; - } - return v; -} - -static bool validate_disc_for_launch(const std::filesystem::path& path, - const std::string& game_id) { - const DiscValidation v = validate_disc_image(path, game_id); - if (!v.opened) { - launcher_warning("Disc Image Not Found", v.detail + "\n\nSelected path:\n" + path.string()); - return false; - } - if (!v.has_header || !v.id_matches) { - launcher_warning("Disc Image Warning", - v.detail + "\n\nThis may be the wrong game or a corrupt image. The runtime will try to run it anyway."); - } - return true; -} - -// Canonicalize whichever member of a dump the user pointed us at. Picking the -// .cue and picking the .bin must both mount and both verify — see -// runtime/include/disc_path.h for the preference rules. This used to swap a -// .cue for its same-named .bin unconditionally, which silently discarded the -// cue's table of contents (and with it every CD-DA track and pregap) on -// single-file multi-track dumps. -// Pure: no dialog here. Every caller feeds the result to -// validate_disc_for_launch(), whose detail text already carries the resolver's -// note (identify_disc surfaces it), so a broken dump is reported exactly once -// instead of popping a modal on every boot. -static std::filesystem::path normalize_disc_path_for_launch(const std::filesystem::path& path) { - return PSXRecompV4::resolve_disc_path(path).mount; -} - -/* Which compiled-in backend, if any, was recompiled from THIS file? - * - * The chosen file is only ever DATA: the code that executes is the statically - * recompiled C of one specific image, so running it against another image's - * bytes is a guaranteed wild jump. Identity therefore decides WHICH backend - * may run, not merely whether to warn. Null = no linked image matches. - */ -static const PsxBiosBackend* bios_backend_for_file(const std::filesystem::path& path, - uint32_t* out_crc, - uint64_t* out_size) { - std::ifstream f(path, std::ios::binary | std::ios::ate); - if (!f.is_open()) return nullptr; - const std::streamoff size = f.tellg(); - if (out_size) *out_size = (uint64_t)size; - if (size <= 0) return nullptr; - std::vector data((size_t)size); - if (!read_at(f, 0, data.data(), data.size())) return nullptr; - const uint32_t crc = crc32_compute(data.data(), data.size()); - if (out_crc) *out_crc = crc; - for (uint32_t i = 0; i < psx_bios_registry_count; i++) { - const PsxBiosBackend* b = psx_bios_registry[i]; - if (!b || !b->image) continue; - if ((uint64_t)size == (uint64_t)b->image->image_size && - crc == b->image->image_crc32) - return b; - } - return nullptr; -} - -/* What a player may supply, for mismatch and picker copy. The bundled image is - * excluded: it is never something to go and find. */ -static std::string bios_accepted_images() { - std::string s; - for (uint32_t i = 0; i < psx_bios_registry_count; i++) { - const PsxBiosBackend* b = psx_bios_registry[i]; - if (!b || !b->image || b->image->image_bundled) continue; - if (!s.empty()) s += ", "; - s += b->image->image_id; - s += " (" + std::to_string(b->image->image_size / 1024u) + " KB)"; - } - return s.empty() ? std::string("(this build ships its own BIOS)") : s; -} - -/* Identity-gate a player-chosen BIOS and activate its backend on success. */ -static bool validate_bios_for_launch(const std::filesystem::path& path) { - uint32_t crc = 0; uint64_t size = 0; - const PsxBiosBackend* b = bios_backend_for_file(path, &crc, &size); - if (b) return psx_bios_activate(b) != 0; - - char buf[512]; - std::snprintf(buf, sizeof(buf), - "That BIOS (%llu bytes, CRC32 %08X) is not an image this build was " - "compiled from.\n\nThis build accepts: %s\n\nThe compiled-in code " - "would execute against mismatched data and crash.", - (unsigned long long)size, crc, bios_accepted_images().c_str()); - launcher_warning("BIOS Mismatch", buf); - return false; -} - -static std::filesystem::path resolve_bios_path(const char* requested, const char* argv0); - -/* Resolve which BIOS image file to load, and activate its backend. - * - * Policy (docs/BIOS_SELECTION.md): - * no explicit player choice -> bundled OpenBIOS, silently - * explicit choice, matching -> that image - * explicit choice, mismatched-> explained; falls back to OpenBIOS if allowed - * openbios disabled for this title -> a retail image is required - * - * "Explicit" means --bios or a remembered launcher/settings pick. Finding a - * file on disk deliberately does NOT count: discovery used to adopt whatever - * happened to sit near the executable, so two players with the same build - * could end up on different BIOSes. - */ -static std::filesystem::path resolve_bios_for_runtime(const char* requested, - const char* argv0, - bool requested_is_explicit) { - const bool openbios_allowed = s_openbios_allowed; - const PsxBiosBackend* bundled = psx_bios_bundled(); - - /* 1. An explicit choice: --bios, else a remembered pick. */ - std::filesystem::path chosen; - if (requested_is_explicit && requested && requested[0]) { - /* --bios, or a BIOS named in settings.toml. NOT the compile-time - * default: that is a build constant, not something the player asked - * for, and treating it as a choice made a stray file near the exe - * produce a "BIOS Mismatch" dialog before the bundled image was even - * considered. */ - chosen = resolve_bios_path(requested, argv0); - } else { - std::filesystem::path cached = read_cached_path(argv0, "bios.cfg"); - if (!cached.empty() && std::filesystem::exists(cached)) chosen = cached; - } - if (!chosen.empty() && std::filesystem::exists(chosen)) { - if (validate_bios_for_launch(chosen)) return chosen; /* activates it */ - /* Mismatched. With OpenBIOS available this is recoverable, so say so - * and carry on rather than dead-ending on a file they chose once. */ - if (!(openbios_allowed && bundled)) return {}; - } - - /* 2. No usable explicit choice -> bundled OpenBIOS, if this title allows it. */ - if (openbios_allowed && bundled) { - std::filesystem::path img = resolve_bios_path( - s_bundled_bios_rel.empty() ? nullptr : s_bundled_bios_rel.c_str(), argv0); - if (!img.empty() && std::filesystem::exists(img) && - bios_backend_for_file(img, nullptr, nullptr) == bundled && - psx_bios_activate(bundled)) { - return img; - } - launcher_warning("Bundled BIOS Missing", - std::string("This build ships its own BIOS (") + - bundled->image->image_id + "), but the bundled image is missing " - "or does not match.\n\nExpected next to the executable:\n" + - (s_bundled_bios_rel.empty() ? "(default path)" : s_bundled_bios_rel) + - "\n\nReinstall or rebuild."); - return {}; - } - - /* 3. This title requires a retail BIOS: ask for one. */ - const std::string accepted = bios_accepted_images(); - launcher_info((s_picker_game_name + " — PlayStation BIOS needed").c_str(), - s_picker_game_name + " requires a PlayStation BIOS.\n\n" - "Step 1 of 2 — PlayStation BIOS\n\n" - "In the next window, select your PlayStation BIOS dump. This build " - "requires the exact image it was compiled from: " + accepted + ". " - "You must dump it from your own console or otherwise legally obtain " - "it.\n\n(This is NOT the game disc — that is asked for next.)"); - std::string bios_title = - s_picker_game_name + " — Step 1 of 2: select PlayStation BIOS (" + accepted + ")"; - for (;;) { - std::filesystem::path picked; - if (!pick_runtime_file( - bios_title.c_str(), - "PlayStation BIOS (*.bin)\0*.bin\0All Files (*.*)\0*.*\0", - picked, "--bios")) { - return {}; - } - if (validate_bios_for_launch(picked)) { - write_cached_path(argv0, "bios.cfg", picked); - return picked; - } - } -} - -static std::filesystem::path resolve_disc_for_runtime(const std::filesystem::path& config_disc, - const char* disc_override, - const std::string& game_id, - const char* argv0) { - if (disc_override && disc_override[0]) { - std::filesystem::path p = normalize_disc_path_for_launch(disc_override); - return validate_disc_for_launch(p, game_id) ? p : std::filesystem::path{}; - } - - // A relative disc path in game.toml resolves against the exe dir (never cwd); - // resolve_existing_runtime_path also walks up from there (build/ -> game dir), - // so an out-of-tree build layout still finds the bundled disc. - if (!config_disc.empty()) { - std::filesystem::path cd = config_disc; - if (cd.is_relative()) { - std::filesystem::path r = resolve_existing_runtime_path(config_disc.string().c_str(), argv0); - if (!r.empty()) cd = r; - } - cd = normalize_disc_path_for_launch(cd); - if (std::filesystem::exists(cd) && validate_disc_for_launch(cd, game_id)) { - return cd; - } - } - - std::filesystem::path cached = read_cached_path(argv0, "disc.cfg"); - if (!cached.empty()) { - cached = normalize_disc_path_for_launch(cached); - } - if (!cached.empty() && std::filesystem::exists(cached) && - validate_disc_for_launch(cached, game_id)) { - return cached; - } - - launcher_info((s_picker_game_name + " — game disc image needed").c_str(), - "Step 2 of 2 — game disc image\n\n" - "In the next window, select your " + s_picker_game_name + - (game_id.empty() ? std::string() : " (" + game_id + ")") + - " disc image ripped from your own disc.\n\n" - "Accepted formats: .cue (preferred, with its .bin next to it), " - ".bin, .iso, or .chd.\n\n" - "(This is NOT the BIOS — the BIOS was already chosen.)"); - std::string disc_title = - s_picker_game_name + " — Step 2 of 2: select " + s_picker_game_name + - " disc image (.cue / .bin / .iso / .chd)"; - for (;;) { - std::filesystem::path picked; - if (!pick_runtime_file( - disc_title.c_str(), - "PS1 Disc Images (*.cue;*.bin;*.iso;*.chd)\0*.cue;*.bin;*.iso;*.chd\0All Files (*.*)\0*.*\0", - picked, "--disc")) { - return {}; - } - picked = normalize_disc_path_for_launch(picked); - if (validate_disc_for_launch(picked, game_id)) { - write_cached_path(argv0, "disc.cfg", picked); - return picked; - } - } -} - -static std::filesystem::path resolve_bios_path(const char* requested, const char* argv0) { - namespace fs = std::filesystem; - std::error_code ec; - fs::path p(requested); - if (fs::exists(p, ec)) { - fs::path abs = fs::absolute(p, ec); - return ec ? p : abs; - } - // Either BIOS filename convention is acceptable: a dump folder holding - // "US-PSX-SCPH1001.BIN" satisfies a request for "SCPH1001.BIN" and vice - // versa (see recompiler/include/bios_rom_alias.h). - if (fs::path aliased = PSXRecompV4::resolve_bios_rom(p); aliased != p) { - fs::path abs = fs::absolute(aliased, ec); - return ec ? aliased : abs; - } - if (p.is_absolute()) return p; - - // Anchor on the exe directory — never cwd (see exe_dir_from_argv). - fs::path found = find_upward(exe_dir_from_argv(argv0), p); - if (!found.empty()) return found / p; - // Same walk, accepting the other naming convention at each rung: the - // literal name is absent but a region-qualified sibling may be present. - for (fs::path dir = fs::absolute(exe_dir_from_argv(argv0), ec); - !dir.empty(); dir = dir.parent_path()) { - const fs::path aliased = PSXRecompV4::resolve_bios_rom(dir / p); - if (aliased != dir / p && fs::exists(aliased, ec)) return aliased; - if (!dir.has_parent_path() || dir.parent_path() == dir) break; - } - - // Dev-checkout rung: game projects keep the framework at - // /psxrecomp-v4 (junction/worktree), so a relative default like - // "bios/SCPH1001.BIN" lives under that prefix rather than at the game root. - // A user install has no psxrecomp-v4 directory, so this rung cannot - // resolve a build-machine BIOS there — it falls through to bios.cfg or the - // interactive picker. - const fs::path dev_marker = fs::path("psxrecomp-v4") / p; - found = find_upward(exe_dir_from_argv(argv0), dev_marker); - if (!found.empty()) return found / dev_marker; - return p; -} - -// Fallback memcard directory used when no game config (or its [runtime] -// block) specifies one: the executable's directory (authoritative, never cwd — -// see exe_dir_from_argv), so saves always live next to the binary. -static std::filesystem::path default_memcard_dir(const char* argv0) { - return exe_dir_from_argv(argv0); -} - -static void close_controller(void); -static void shutdown_runtime(void); - -/* Set when this process started netplay from the lobby room — soft-exit returns - * there instead of killing the process. */ -static int g_netplay_from_lobby = 0; -static int g_netplay_vsync_forced_off = 0; - -/* Host-only: lockstep already couples peers. Driver vsync on top of the - * wall-clock pacer double-blocks the vblank callback (present is before the - * guest resumes), which shows up as MotK FMV ~30–40 FPS in netplay vs ~50+ - * offline. Force immediate swaps for the session; restore on soft-exit. */ -static void netplay_host_present_uncap(void) { -#ifndef PSX_SDL_NO_RENDER - if (g_gl_active) gl_renderer_set_swap_interval(0); - if (g_vk_active) vk_renderer_set_present_mode(0); -#endif - g_netplay_vsync_forced_off = 1; -} - -static void netplay_host_present_restore(void) { - if (!g_netplay_vsync_forced_off) return; -#ifndef PSX_SDL_NO_RENDER - if (g_gl_active) gl_renderer_set_swap_interval(g_video_vsync); - if (g_vk_active) vk_renderer_set_present_mode(g_video_vsync); -#endif - g_netplay_vsync_forced_off = 0; -} - -static void netplay_soft_exit(const char *origin) { - psx_crash_trace_set_exit_origin(origin); - netplay_host_present_restore(); - psx_netplay_shutdown(); /* sends BYE so the peer soft-exits too */ - if (g_netplay_from_lobby) { - std::printf("psxrecomp: netplay ended (%s) — returning to lobby\n", - origin ? origin : "?"); - std::fflush(stdout); - psx_request_return_to_lobby(); - return; - } - shutdown_runtime(); - std::exit(0); -} - -static void shutdown_runtime(void) { - /* UI teardown may destroy GL resources; it must run while the game window - * and main renderer context are still alive. The helper is idempotent. */ - host_ui_runtime_shutdown(); - /* (sljit removed 2026-07-15: overlay_compile_worker_stop joined the - * off-thread JIT worker here; the worker no longer exists.) */ - psx_netplay_shutdown(); - memcard_flush_all(); - /* Stop and join the active external compiler before capture/debug teardown. - * Otherwise closing the window can leave cmd/python/gcc running against - * cache and capture files while the main thread performs synchronous - * shutdown work, making the window appear frozen until that tree exits. */ - autocompile_shutdown(); - overlay_autocapture_shutdown(); - overlay_capture_wait_pending(); - overlay_capture_write_json(); - if (sdl_audio_device) { - psx_sdl_audio_clear(sdl_audio_device); - psx_sdl_audio_close(sdl_audio_device); /* stops the pull callback */ - sdl_audio_device = 0; - } - g_audio_drc_applied_master_gain = -1.0f; - g_audio_legacy_applied_master_gain = -1.0f; - if (s_drc_ready) { rab_free(&s_drc); s_drc_ready = false; } - close_controller(); - debug_server_shutdown(); -} - -/* Tear down the game window/GL/audio after a lobby soft-return. Leaves SDL - * subsystems and the lobby WebSocket intact for the next launcher session. */ -static void teardown_game_session_keep_lobby(void) { - host_ui_runtime_shutdown(); - netplay_host_present_restore(); - psx_netplay_shutdown(); - memcard_flush_all(); - if (sdl_audio_device) { - psx_sdl_audio_clear(sdl_audio_device); - psx_sdl_audio_close(sdl_audio_device); - sdl_audio_device = 0; - } - g_audio_drc_applied_master_gain = -1.0f; - g_audio_legacy_applied_master_gain = -1.0f; - if (s_drc_ready) { rab_free(&s_drc); s_drc_ready = false; } - close_controller(); - if (g_vk_active) { - vk_renderer_shutdown(); - g_vk_active = false; - } - if (g_gl_active) { - gl_renderer_shutdown(); - g_gl_active = false; - } - if (sdl_texture) { SDL_DestroyTexture(sdl_texture); sdl_texture = nullptr; } - if (sdl_renderer) { SDL_DestroyRenderer(sdl_renderer); sdl_renderer = nullptr; } - if (sdl_window) { SDL_DestroyWindow(sdl_window); sdl_window = nullptr; } - if (sdl_pixel_buf) { std::free(sdl_pixel_buf); sdl_pixel_buf = nullptr; } - sdl_pixel_buf_scale_capacity = 0; - psx_lobby_set_ready(0); - psx_lobby_clear_launch_pending(); - psx_clear_return_to_lobby(); -} - -/* Linear gain ramp g0 -> g1 across a block of interleaved stereo frames. */ -static void sdl_audio_gain_ramp(int16_t* buf, int frames, float g0, float g1) { - if (frames <= 0) return; - const float step = (g1 - g0) / (float)frames; - float g = g0; - for (int f = 0; f < frames; f++, g += step) { - buf[f * 2 + 0] = (int16_t)((float)buf[f * 2 + 0] * g); - buf[f * 2 + 1] = (int16_t)((float)buf[f * 2 + 1] * g); - } -} - -/* Smooth live master-volume changes over one produced/device block. The DRC - * path invokes this on its final audio callback; legacy queued audio invokes - * it immediately before queueing. Separate per-path applied gains are owned by - * their respective threads, while the requested controls are atomic. */ -static void sdl_audio_apply_master_gain(int16_t* buf, int frames, - float* applied_gain) { - if (!buf || frames <= 0 || !applied_gain) return; - const int percent = - g_audio_master_volume_percent.load(std::memory_order_relaxed); - const bool muted = - g_audio_master_muted.load(std::memory_order_relaxed) != 0; - const float target_gain = muted ? 0.0f : (float)percent / 100.0f; - if (*applied_gain < 0.0f) *applied_gain = target_gain; - if (*applied_gain == 1.0f && target_gain == 1.0f) return; - sdl_audio_gain_ramp(buf, frames, *applied_gain, target_gain); - *applied_gain = target_gain; -} - -/* Fade-in state: samples of rising ramp still to apply after an unmute. - * Consumed by sdl_audio_pump across however many pump calls it spans. - * MUST fit sdl_audio_buf (2048 frames): the fade-out tail renders this many - * frames in one spu_render call. 1764 frames = 40 ms. */ -static const int sdl_audio_fade_samples = 44100 * 40 / 1000; /* 40 ms */ -static int sdl_audio_fadein_left = 0; - -static void sdl_audio_pump(bool discard_output = false) { - if (!sdl_audio_device) discard_output = true; /* a game reads the SPU back: it runs on with nothing to play it */ - - const uint32_t bytes_per_frame = sizeof(int16_t) * 2u; - const bool legacy = audio_legacy_mode(); - static int had_audio = 0; - uint32_t queued = 0; /* RENDER event b: bytes (legacy) / fill ms (bridge) */ - if (discard_output) { - /* Host-only sink: skip all queue/bridge interaction, but continue to - * the guest-cycle sample budget and spu_render below. */ - } else if (legacy) { - /* Historical push model + baseline measurement: a drained (==0) queue - * means the device was silence-filling since the last pump = a gap. - * Only meaningful after the first audio has been queued. */ - const uint32_t max_queue_bytes = 44100u * bytes_per_frame / 5u; - queued = psx_sdl_audio_queued_size(sdl_audio_device); - if (queued == 0 && had_audio) { - g_legacy_underruns++; - audio_trace_event(AUDIO_EV_UNDERRUN, 0, 0); - } - if (queued > max_queue_bytes) { - audio_trace_event(AUDIO_EV_PUMP_SKIP, queued, 0); - return; - } - } else { - /* No host-queue backpressure check: the bridge's ring + DRC hold the - * fill near target, so we always render this frame and push it. */ - if (!s_drc_ready) return; - /* Surface bridge underruns (counted on the SDL audio thread) into the - * event ring from this thread — the event ring is single-writer. - * Across a turbo mute the ring intentionally runs dry (the pump stops - * while the callback keeps pulling); those dry pulls are the mute, - * not gaps — resync past them instead of reporting them. */ - rab_stats st; - rab_get_stats(&s_drc, &st); - static uint64_t prev_underruns = 0; - extern int g_audio_unmute_resync; - if (g_audio_unmute_resync) { - prev_underruns = st.underrun_events; - g_audio_unmute_resync = 0; - } else if (st.underrun_events > prev_underruns) { - audio_trace_event(AUDIO_EV_UNDERRUN, - (uint32_t)(st.underrun_events - prev_underruns), 1); - prev_underruns = st.underrun_events; - } - queued = (uint32_t)st.last_fill_ms; - } - - /* Faithful sample budget: the SPU is clocked by the GUEST, not by host - * presents. 33.8688 MHz / 44100 Hz = exactly 768 guest cycles per output - * frame, so production tracks guest time precisely — including the real - * NTSC 59.94 Hz vblank — instead of assuming 60.00 Hz per present, which - * built in a systematic -0.1% production deficit (measured: 43950/s - * produced vs 44100/s consumed = recurring ring underruns no +/-0.5% - * DRC trim could absorb during jitter spikes). */ - extern uint64_t psx_cycle_count; - static uint64_t last_cycles = 0; - static uint64_t cycle_carry = 0; - const uint64_t now_cycles = psx_cycle_count; - if (last_cycles == 0) last_cycles = now_cycles; - uint64_t delta = (now_cycles - last_cycles) + cycle_carry; - last_cycles = now_cycles; - int frames = (int)(delta / 768u); - cycle_carry = delta % 768u; - if (frames <= 0) return; - if (frames > 2048) { - /* A burst beyond one buffer (e.g. right after an unmute or a long - * stall): render one full buffer and DROP the remainder of the debt — - * same semantic as the mute model (voice positions freeze across the - * gap) rather than time-compressing a backlog into garble. */ - frames = 2048; - cycle_carry = 0; - } - - audio_trace_event(AUDIO_EV_RENDER, (uint32_t)frames, queued); - spu_render(sdl_audio_buf, frames); - if (discard_output) { - g_turbo_audio_sink_frames += (uint64_t)frames; - audio_trace_event(AUDIO_EV_SINK_DROP, (uint32_t)frames, 0); - return; - } - if (sdl_audio_fadein_left > 0) { - const float g0 = 1.0f - (float)sdl_audio_fadein_left - / (float)sdl_audio_fade_samples; - int ramp = sdl_audio_fadein_left < frames ? sdl_audio_fadein_left : frames; - const float g1 = 1.0f - (float)(sdl_audio_fadein_left - ramp) - / (float)sdl_audio_fade_samples; - sdl_audio_gain_ramp(sdl_audio_buf, ramp, g0, g1); - sdl_audio_fadein_left -= ramp; - } - if (legacy) { - sdl_audio_apply_master_gain( - sdl_audio_buf, frames, &g_audio_legacy_applied_master_gain); - /* T3 tap: the exact post-fade bytes handed to the host audio queue. */ - audio_trace_pcm(AUDIO_TAP_HOST, sdl_audio_buf, frames); - psx_sdl_audio_queue(sdl_audio_device, sdl_audio_buf, - (uint32_t)frames * bytes_per_frame); - had_audio = 1; - } else { - /* Hand to the bridge (band-limited resample + DRC) instead of - * SDL_QueueAudio. Lock guards the SPSC ring against the pull callback. - * The T3 tap moves to sdl_drc_callback: what the device actually - * receives is the bridge's device-rate output, not this buffer. */ - psx_sdl_audio_lock(sdl_audio_device); - rab_push(&s_drc, sdl_audio_buf, frames); - psx_sdl_audio_unlock(sdl_audio_device); - } -} - -/* Audio gating across turbo-loads transitions. - * - * The mute model stays: during turbo the guest runs at host speed, so - * rendered SPU audio is time-compressed garble — we stop pumping, the queue - * drains, voice positions freeze, and music resumes in place afterward. - * What changes is the EDGES: - * - entering turbo: render one short tail of the current voice state, - * ramp it to silence, and queue it — the drain ends in a fade instead - * of a hard cut; - * - leaving turbo: hold the mute for a short hangover first (loads often - * re-trigger within a few frames; without the debounce the mute would - * flicker audibly), then resume pumping with a rising ramp applied - * across the first ~50 ms of samples (sdl_audio_pump above). */ -/* Bridge/legacy output health, surfaced through the audio_stats TCP command - * (debug_server.c) — no stderr probe; rule 3. */ -extern "C" int psx_audio_out_stats(double *fill_ms, double *target_ms, - uint64_t *underruns, - uint64_t *overflow_drops, double *correction, - int *legacy, int *host_rate) -{ - *legacy = audio_legacy_mode() ? 1 : 0; - *host_rate = g_audio_host_rate; - *target_ms = g_audio_buffer_ms; - if (*legacy || !s_drc_ready) { - *fill_ms = sdl_audio_device - ? (double)psx_sdl_audio_queued_size(sdl_audio_device) - / (44100.0 * 4.0) * 1000.0 - : 0.0; - *underruns = g_legacy_underruns; - *overflow_drops = 0; - *correction = 0.0; - return sdl_audio_device != 0; - } - rab_stats st; - rab_get_stats(&s_drc, &st); - *fill_ms = st.last_fill_ms; - *underruns = st.underrun_events; - *overflow_drops = st.overflow_drops; - *correction = st.last_correction; - return 1; -} - -/* Lightweight production-safe cadence probe. Unlike the TCP debug build this - * adds no per-block or per-instruction recording. All counter/timer work below - * is opt-in via PSX_RUNTIME_PERF_DIAG; the normal Release path pays only the - * existing once-per-vblank disabled checks. */ -struct RuntimePerfSnapshot { - uint64_t counter = 0; - uint64_t frame = 0; - uint64_t guest_work_ticks = 0; - uint64_t pacer_ticks = 0; - uint64_t autocapture_ticks = 0; - uint64_t provider_poll_ticks = 0; - uint64_t dirty_insns = 0; - uint64_t dirty_dispatches = 0; - uint32_t overlay_loads = 0; - uint32_t overlay_invalidations = 0; - uint32_t overlay_unregistered = 0; - uint64_t overlay_native = 0; - uint64_t overlay_interp = 0; - uint64_t overlay_stale = 0; - uint32_t overlay_revalidations = 0; - uint32_t overlay_hot_native_pc = 0; - uint64_t overlay_hot_native_calls = 0; - uint64_t overlay_shadow_calls = 0; - uint64_t overlay_shadow_divergences = 0; - uint32_t overlay_first_divergence_pc = 0; - uint32_t capture_triggers = 0; - uint64_t capture_last_dispatch_delta = 0; - int capture_overlays = 0; -}; - -struct RuntimePerfState { - bool initialized = false; - bool enabled = false; - uint32_t interval_ms = 5000; - uint64_t frequency = 0; - uint64_t last_frame_exit_counter = 0; - uint64_t guest_work_ticks = 0; - uint64_t pacer_ticks = 0; - uint64_t autocapture_ticks = 0; - uint64_t provider_poll_ticks = 0; - bool bench_configured = false; - bool bench_started = false; - bool bench_reported = false; - uint64_t bench_start_frame = 0; - uint64_t bench_end_frame = 0; - RuntimePerfSnapshot bench_start; -}; - -static RuntimePerfState g_runtime_perf; - -static bool runtime_perf_parse_window(const char *text, uint64_t *start, - uint64_t *end) { - if (!text || !text[0]) return false; - char *middle = nullptr; - unsigned long long first = std::strtoull(text, &middle, 10); - if (middle == text || *middle != ':') return false; - char *tail = nullptr; - unsigned long long last = std::strtoull(middle + 1, &tail, 10); - if (tail == middle + 1 || *tail != '\0' || first == 0 || last <= first) - return false; - *start = (uint64_t)first; - *end = (uint64_t)last; - return true; -} - -static void runtime_perf_init() { - if (g_runtime_perf.initialized) return; - g_runtime_perf.initialized = true; - const char *enabled = std::getenv("PSX_RUNTIME_PERF_DIAG"); - g_runtime_perf.enabled = enabled && enabled[0] && enabled[0] != '0'; - if (!g_runtime_perf.enabled) return; - - g_runtime_perf.frequency = SDL_GetPerformanceFrequency(); - if (!g_runtime_perf.frequency) g_runtime_perf.frequency = 1; - dirty_ram_perf_hot_enable(1); - if (const char *interval = std::getenv("PSX_RUNTIME_PERF_DIAG_MS")) { - char *tail = nullptr; - long requested = std::strtol(interval, &tail, 10); - if (tail != interval && *tail == '\0') { - if (requested < 250) requested = 250; - if (requested > 600000) requested = 600000; - g_runtime_perf.interval_ms = (uint32_t)requested; - } else { - std::fprintf(stderr, - "psxrecomp: ignoring invalid PSX_RUNTIME_PERF_DIAG_MS='%s'\n", - interval); - } - } - if (const char *window = std::getenv("PSX_BENCH_WINDOW")) { - if (runtime_perf_parse_window(window, - &g_runtime_perf.bench_start_frame, - &g_runtime_perf.bench_end_frame)) { - g_runtime_perf.bench_configured = true; - } else { - std::fprintf(stderr, - "psxrecomp: ignoring invalid PSX_BENCH_WINDOW='%s' " - "(expected start:end, start >= 1, end > start)\n", window); - } - } - std::fprintf(stdout, "psxrecomp: runtime perf diagnostics enabled: interval=%u ms", - g_runtime_perf.interval_ms); - if (g_runtime_perf.bench_configured) - std::fprintf(stdout, ", bench=%llu:%llu", - (unsigned long long)g_runtime_perf.bench_start_frame, - (unsigned long long)g_runtime_perf.bench_end_frame); - std::fprintf(stdout, "\n"); - std::fflush(stdout); -} - -static RuntimePerfSnapshot runtime_perf_snapshot(uint64_t now) { - RuntimePerfSnapshot s; - s.counter = now; - s.frame = s_frame_count; - s.guest_work_ticks = g_runtime_perf.guest_work_ticks; - s.pacer_ticks = g_runtime_perf.pacer_ticks; - s.autocapture_ticks = g_runtime_perf.autocapture_ticks; - s.provider_poll_ticks = g_runtime_perf.provider_poll_ticks; - s.dirty_insns = g_dirty_ram_insns_run; - s.dirty_dispatches = g_dirty_window_dispatches; - overlay_loader_get_counters(&s.overlay_loads, &s.overlay_invalidations, - &s.overlay_unregistered, &s.overlay_native, - &s.overlay_interp, &s.overlay_stale, - nullptr, nullptr, nullptr, nullptr, - &s.overlay_revalidations); - overlay_loader_take_hot_native(&s.overlay_hot_native_pc, - &s.overlay_hot_native_calls); - overlay_loader_get_shadow_summary(&s.overlay_shadow_calls, - &s.overlay_shadow_divergences, - &s.overlay_first_divergence_pc); - int capture_enabled = 0; - overlay_autocapture_get_status(&capture_enabled, &s.capture_triggers, - &s.capture_last_dispatch_delta); - s.capture_overlays = overlay_capture_count(); - return s; -} - -static double runtime_perf_ticks_ms(uint64_t ticks) { - return (double)ticks * 1000.0 / (double)g_runtime_perf.frequency; -} - -static void runtime_perf_bench_tick(uint64_t now) { - if (!g_runtime_perf.bench_configured || g_runtime_perf.bench_reported) return; - if (!g_runtime_perf.bench_started) { - if (s_frame_count == g_runtime_perf.bench_start_frame) { - g_runtime_perf.bench_start = runtime_perf_snapshot(now); - g_runtime_perf.bench_started = true; - } - return; - } - if (s_frame_count != g_runtime_perf.bench_end_frame) return; - - RuntimePerfSnapshot end = runtime_perf_snapshot(now); - const RuntimePerfSnapshot &start = g_runtime_perf.bench_start; - std::fprintf(stdout, - "[BENCH] window=%llu:%llu frames=%llu wall_ms=%.3f " - "guest_work_ms=%.3f pacer_ms=%.3f autocapture_main_ms=%.3f " - "provider_poll_ms=%.3f " - "dirty_insns=+%llu dirty_dispatches=+%llu " - "overlay_native=+%llu overlay_interp=+%llu overlay_loads=+%u " - "overlay_invalidations=+%u overlay_unregistered=+%u " - "overlay_stale=+%llu overlay_revalidations=+%u " - "capture_triggers=+%u capture_overlays=+%d " - "capture_last_dispatch_delta=%llu\n", - (unsigned long long)start.frame, (unsigned long long)end.frame, - (unsigned long long)(end.frame - start.frame), - runtime_perf_ticks_ms(end.counter - start.counter), - runtime_perf_ticks_ms(end.guest_work_ticks - start.guest_work_ticks), - runtime_perf_ticks_ms(end.pacer_ticks - start.pacer_ticks), - runtime_perf_ticks_ms(end.autocapture_ticks - start.autocapture_ticks), - runtime_perf_ticks_ms(end.provider_poll_ticks - start.provider_poll_ticks), - (unsigned long long)(end.dirty_insns - start.dirty_insns), - (unsigned long long)(end.dirty_dispatches - start.dirty_dispatches), - (unsigned long long)(end.overlay_native - start.overlay_native), - (unsigned long long)(end.overlay_interp - start.overlay_interp), - end.overlay_loads - start.overlay_loads, - end.overlay_invalidations - start.overlay_invalidations, - end.overlay_unregistered - start.overlay_unregistered, - (unsigned long long)(end.overlay_stale - start.overlay_stale), - end.overlay_revalidations - start.overlay_revalidations, - end.capture_triggers - start.capture_triggers, - end.capture_overlays - start.capture_overlays, - (unsigned long long)end.capture_last_dispatch_delta); - std::fflush(stdout); - g_runtime_perf.bench_reported = true; -} - -static void runtime_perf_frame_begin() { - runtime_perf_init(); - if (!g_runtime_perf.enabled) return; - uint64_t now = SDL_GetPerformanceCounter(); - if (g_runtime_perf.last_frame_exit_counter && - now >= g_runtime_perf.last_frame_exit_counter) { - g_runtime_perf.guest_work_ticks += - now - g_runtime_perf.last_frame_exit_counter; - } - runtime_perf_bench_tick(now); -} - -static void runtime_perf_frame_end() { - if (!g_runtime_perf.enabled) return; - g_runtime_perf.last_frame_exit_counter = SDL_GetPerformanceCounter(); -} - -class RuntimePerfFrameScope { -public: - ~RuntimePerfFrameScope() { runtime_perf_frame_end(); } -}; - -static uint64_t runtime_perf_section_begin() { - return g_runtime_perf.enabled ? SDL_GetPerformanceCounter() : 0; -} - -static void runtime_perf_section_end(uint64_t start, uint64_t *total) { - if (!start) return; - uint64_t end = SDL_GetPerformanceCounter(); - if (end >= start) *total += end - start; -} - -extern "C" uint8_t *memory_get_ram_ptr(void); -#ifdef PSX_HAS_OVERLAY_DISPATCH -extern "C" void psx_overlay_static_get_stats(uint64_t *, uint64_t *, - uint64_t *, uint64_t *); -extern "C" void overlay_loader_static_match_stats(uint64_t *, uint64_t *, - uint64_t *); -#endif -static uint32_t runtime_perf_live_word(uint32_t phys, uint32_t samples) { - if (samples == 0u || phys > 2u * 1024u * 1024u - 4u) return 0u; - const uint8_t *ram = memory_get_ram_ptr(); - if (!ram) return 0u; - return (uint32_t)ram[phys] | - ((uint32_t)ram[phys + 1u] << 8) | - ((uint32_t)ram[phys + 2u] << 16) | - ((uint32_t)ram[phys + 3u] << 24); -} - -static void runtime_perf_diag_tick() { - static bool have_last = false; - static RuntimePerfSnapshot last; - static uint64_t last_spu = 0, last_underruns = 0, last_overflows = 0; - static uint64_t last_up[6] = {0}; - static uint64_t last_overlay_load_us = 0; -#ifdef PSX_HAS_OVERLAY_DISPATCH - static uint64_t last_static[7] = {0}; -#endif - if (!g_runtime_perf.enabled) return; - - uint64_t now = SDL_GetPerformanceCounter(); - const uint64_t interval_ticks = - g_runtime_perf.frequency * (uint64_t)g_runtime_perf.interval_ms / 1000u; - if (have_last && now - last.counter < interval_ticks) return; - - RuntimePerfSnapshot current = runtime_perf_snapshot(now); - AudioTraceStats audio; - audio_trace_get_stats(&audio); - double fill_ms = 0.0, target_ms = 0.0, correction = 0.0; - uint64_t underruns = 0, overflows = 0; - int legacy = 0, host_rate = 0; - psx_audio_out_stats(&fill_ms, &target_ms, &underruns, &overflows, - &correction, &legacy, &host_rate); - uint64_t up[6] = {0}; - gl_renderer_runtime_diag(up); - uint64_t overlay_load_us = 0, overlay_load_max_us = 0, overlay_load_last_us = 0; - overlay_loader_get_load_timing(&overlay_load_us, &overlay_load_max_us, - &overlay_load_last_us); - uint32_t dirty_hot_pc[DIRTY_RAM_PERF_HOT_COUNT] = {0}; - uint32_t dirty_hot_n[DIRTY_RAM_PERF_HOT_COUNT] = {0}; - uint32_t dirty_hot_stride = 0; - dirty_ram_perf_take_hot(dirty_hot_pc, dirty_hot_n, &dirty_hot_stride); - uint64_t kbless[6] = {0}; - uint64_t kbless_mismatch_count = 0; - uint32_t kbless_last[6] = {0}; - psx_kernel_bless_stats(kbless); - psx_kernel_bless_mismatch_detail(&kbless_mismatch_count, kbless_last); -#ifdef PSX_HAS_OVERLAY_DISPATCH - uint64_t static_now[7] = {0}; - psx_overlay_static_get_stats(&static_now[0], &static_now[1], - &static_now[2], &static_now[3]); - overlay_loader_static_match_stats(&static_now[4], &static_now[5], - &static_now[6]); -#endif - if (!have_last) { - last = current; - last_spu = audio.tap_frames[AUDIO_TAP_SPU_OUT]; - last_underruns = underruns; - last_overflows = overflows; - last_overlay_load_us = overlay_load_us; -#ifdef PSX_HAS_OVERLAY_DISPATCH - for (int i = 0; i < 7; i++) last_static[i] = static_now[i]; -#endif - for (int i = 0; i < 6; i++) last_up[i] = up[i]; - have_last = true; - return; - } - - const double dt = (double)(current.counter - last.counter) / - (double)g_runtime_perf.frequency; - std::fprintf(stdout, - "psxrecomp: runtime cadence: guest=%.2f Hz, spu=%.1f Hz, " - "audio_fill=%.1f/%.1f ms, underruns=+%llu, overflows=+%llu, corr=%+.5f; " - "GL upload=%.1f calls/s %.1f rect/s %.2f Mpix/s, " - "cpu=%.1f tex=%.1f draw=%.1f ms/s; " - "work guest=%.1f pacer=%.1f autocapture=%.1f provider_poll=%.1f ms/s, " - "dirty=%.0f insn/s %.0f dispatch/s; " - "dirty_hot=0x%08X:%08X/%u,0x%08X:%08X/%u," - "0x%08X:%08X/%u sample=1/%u; " - "kbless clean=%llu mismatch=%llu hits=%llu last=0x%08X " - "body=0x%08X-0x%08X diff=0x%08X:%02X/%02X; " -#ifdef PSX_HAS_OVERLAY_DISPATCH - "static checks=+%llu hits=+%llu variant_miss=+%llu " - "address_miss=+%llu rehash=+%llu crc_miss=+%llu fast=+%llu; " -#endif - "overlay native=+%llu interp=+%llu hot_native=0x%08X/+%llu " - "shadow=+%llu div=+%llu first_div=0x%08X " - "loads=+%u revalidations=+%u " - "load_wall=%.1f ms max=%.1f last=%.1f ms; " - "capture triggers=+%u overlays=+%d last_dispatch_delta=%llu\n", - (double)(current.frame - last.frame) / dt, - (double)(audio.tap_frames[AUDIO_TAP_SPU_OUT] - last_spu) / dt, - fill_ms, target_ms, (unsigned long long)(underruns - last_underruns), - (unsigned long long)(overflows - last_overflows), correction, - (double)(up[0] - last_up[0]) / dt, - (double)(up[1] - last_up[1]) / dt, - (double)(up[2] - last_up[2]) / dt / 1.0e6, - (double)(up[3] - last_up[3]) * 1000.0 / - (double)g_runtime_perf.frequency / dt, - (double)(up[4] - last_up[4]) * 1000.0 / - (double)g_runtime_perf.frequency / dt, - (double)(up[5] - last_up[5]) * 1000.0 / - (double)g_runtime_perf.frequency / dt, - runtime_perf_ticks_ms(current.guest_work_ticks - last.guest_work_ticks) / dt, - runtime_perf_ticks_ms(current.pacer_ticks - last.pacer_ticks) / dt, - runtime_perf_ticks_ms(current.autocapture_ticks - last.autocapture_ticks) / dt, - runtime_perf_ticks_ms(current.provider_poll_ticks - last.provider_poll_ticks) / dt, - (double)(current.dirty_insns - last.dirty_insns) / dt, - (double)(current.dirty_dispatches - last.dirty_dispatches) / dt, - dirty_hot_pc[0], runtime_perf_live_word(dirty_hot_pc[0], dirty_hot_n[0]), - dirty_hot_n[0], - dirty_hot_pc[1], runtime_perf_live_word(dirty_hot_pc[1], dirty_hot_n[1]), - dirty_hot_n[1], - dirty_hot_pc[2], runtime_perf_live_word(dirty_hot_pc[2], dirty_hot_n[2]), - dirty_hot_n[2], dirty_hot_stride, - (unsigned long long)kbless[1], - (unsigned long long)kbless_mismatch_count, - (unsigned long long)kbless[3], - kbless_last[0], kbless_last[1], kbless_last[2], kbless_last[3], - kbless_last[4] & 0xFFu, kbless_last[5] & 0xFFu, -#ifdef PSX_HAS_OVERLAY_DISPATCH - (unsigned long long)(static_now[0] - last_static[0]), - (unsigned long long)(static_now[1] - last_static[1]), - (unsigned long long)(static_now[2] - last_static[2]), - (unsigned long long)(static_now[3] - last_static[3]), - (unsigned long long)(static_now[4] - last_static[4]), - (unsigned long long)(static_now[5] - last_static[5]), - (unsigned long long)(static_now[6] - last_static[6]), -#endif - (unsigned long long)(current.overlay_native - last.overlay_native), - (unsigned long long)(current.overlay_interp - last.overlay_interp), - current.overlay_hot_native_pc, - (unsigned long long)current.overlay_hot_native_calls, - (unsigned long long)(current.overlay_shadow_calls - last.overlay_shadow_calls), - (unsigned long long)(current.overlay_shadow_divergences - - last.overlay_shadow_divergences), - current.overlay_first_divergence_pc, - current.overlay_loads - last.overlay_loads, - current.overlay_revalidations - last.overlay_revalidations, - (double)(overlay_load_us - last_overlay_load_us) / 1000.0, - (double)overlay_load_max_us / 1000.0, - (double)overlay_load_last_us / 1000.0, - current.capture_triggers - last.capture_triggers, - current.capture_overlays - last.capture_overlays, - (unsigned long long)current.capture_last_dispatch_delta); - std::fflush(stdout); - last = current; - last_spu = audio.tap_frames[AUDIO_TAP_SPU_OUT]; - last_underruns = underruns; - last_overflows = overflows; - last_overlay_load_us = overlay_load_us; -#ifdef PSX_HAS_OVERLAY_DISPATCH - for (int i = 0; i < 7; i++) last_static[i] = static_now[i]; -#endif - for (int i = 0; i < 6; i++) last_up[i] = up[i]; -} - -static void sdl_audio_update(int hard_mute_active, int turbo_sink_active) { - { /* Tag audio events with the vblank frame counter. */ - extern uint64_t s_frame_count; - audio_trace_note_frame((uint32_t)s_frame_count); - } - const int HANGOVER_FRAMES = 8; /* ~133 ms at 60 fps */ - static int muted = 0; - static int hangover = 0; - static int sink_was_active = 0; - - if (hard_mute_active) { - if (!muted) { - int tail = sdl_audio_fade_samples; - const int buf_cap = (int)(sizeof(sdl_audio_buf) / (2 * sizeof(int16_t))); - if (tail > buf_cap) tail = buf_cap; - /* An unmute ramp may still be in progress; start the down-ramp - * from its current gain so the edge stays continuous. */ - const float g0 = 1.0f - (float)sdl_audio_fadein_left - / (float)sdl_audio_fade_samples; - sdl_audio_fadein_left = 0; - spu_render(sdl_audio_buf, tail); - sdl_audio_gain_ramp(sdl_audio_buf, tail, g0, 0.0f); - audio_trace_event(AUDIO_EV_MUTE, (uint32_t)tail, 0); - if (sdl_audio_device && audio_legacy_mode()) { - sdl_audio_apply_master_gain( - sdl_audio_buf, tail, - &g_audio_legacy_applied_master_gain); - audio_trace_pcm(AUDIO_TAP_HOST, sdl_audio_buf, tail); - psx_sdl_audio_queue(sdl_audio_device, sdl_audio_buf, - (uint32_t)tail * sizeof(int16_t) * 2u); - } else if (sdl_audio_device && s_drc_ready) { - psx_sdl_audio_lock(sdl_audio_device); - rab_push(&s_drc, sdl_audio_buf, tail); - psx_sdl_audio_unlock(sdl_audio_device); - } - muted = 1; - } - hangover = HANGOVER_FRAMES; - return; - } - if (muted) { - if (hangover > 0) { hangover--; return; } - muted = 0; - sdl_audio_fadein_left = sdl_audio_fade_samples; - audio_trace_event(AUDIO_EV_UNMUTE, (uint32_t)sdl_audio_fadein_left, 0); - extern int g_audio_unmute_resync; - g_audio_unmute_resync = 1; - } - if (turbo_sink_active) { - if (!sink_was_active) { - sink_was_active = 1; - audio_trace_event(AUDIO_EV_MUTE, 0, 2); /* b=2: discard-only sink */ - } - g_turbo_audio_sink_active = 1; - sdl_audio_pump(true); - return; - } - g_turbo_audio_sink_active = 0; - if (sink_was_active) { - sink_was_active = 0; - sdl_audio_fadein_left = sdl_audio_fade_samples; - audio_trace_event(AUDIO_EV_UNMUTE, - (uint32_t)sdl_audio_fadein_left, 2); - g_audio_unmute_resync = 1; - } - sdl_audio_pump(false); -} - -/* PS1 digital pad button bits (active-low: 0=pressed, 1=released). - * Bit 0 = SELECT, Bit 1 = L3, Bit 2 = R3, Bit 3 = START, - * Bit 4 = UP, Bit 5 = RIGHT, Bit 6 = DOWN, Bit 7 = LEFT, - * Bit 8 = L2, Bit 9 = R2, Bit 10 = L1, Bit 11 = R1, - * Bit 12 = TRIANGLE, Bit 13 = CIRCLE, Bit 14 = CROSS, Bit 15 = SQUARE. - * L3/R3 (stick clicks) exist on a DualShock only; the wire reports them like - * Beetle's dualshock.cpp does — straight from the button word, no mode mask. */ -#define PAD_SELECT (1 << 0) -#define PAD_L3 (1 << 1) -#define PAD_R3 (1 << 2) -#define PAD_START (1 << 3) -#define PAD_UP (1 << 4) -#define PAD_RIGHT (1 << 5) -#define PAD_DOWN (1 << 6) -#define PAD_LEFT (1 << 7) -#define PAD_L2 (1 << 8) -#define PAD_R2 (1 << 9) -#define PAD_L1 (1 << 10) -#define PAD_R1 (1 << 11) -#define PAD_TRIANGLE (1 << 12) -#define PAD_CIRCLE (1 << 13) -#define PAD_CROSS (1 << 14) -#define PAD_SQUARE (1 << 15) - -struct ControllerSource { - enum class Kind { - None, - Button, - AxisPositive, - AxisNegative, - }; - - Kind kind = Kind::None; - int id = -1; -}; - -struct PsxButtonMap { - uint16_t bit; - const char* ini_name; - std::vector sources; -}; - -static int controller_device_index = 0; -static int controller_deadzone = 12000; -static int controller_anti_deadzone = 0; -static std::array controller_map = {{ - { PAD_UP, "up", {} }, - { PAD_DOWN, "down", {} }, - { PAD_LEFT, "left", {} }, - { PAD_RIGHT, "right", {} }, - { PAD_CROSS, "cross", {} }, - { PAD_CIRCLE, "circle", {} }, - { PAD_SQUARE, "square", {} }, - { PAD_TRIANGLE, "triangle", {} }, - { PAD_L1, "l1", {} }, - { PAD_R1, "r1", {} }, - { PAD_L2, "l2", {} }, - { PAD_R2, "r2", {} }, - { PAD_L3, "l3", {} }, - { PAD_R3, "r3", {} }, - { PAD_START, "start", {} }, - { PAD_SELECT, "select", {} }, -}}; - -static std::string trim_copy(const std::string& s) { - size_t first = 0; - while (first < s.size() && std::isspace((unsigned char)s[first])) first++; - size_t last = s.size(); - while (last > first && std::isspace((unsigned char)s[last - 1])) last--; - return s.substr(first, last - first); -} - -/* Resolve the durable additive overlay-capture store. Explicit process env is - * authoritative. For local development, parse only the two known capture keys - * from a gitignored root .psxrecomp.env (or .env); do not import unrelated - * variables/secrets into the game process. Relative paths are root-relative. */ -static std::filesystem::path resolve_overlay_capture_path( - const std::filesystem::path& project_root, - const std::filesystem::path& exe_dir, - const std::string& game_id) { - std::string direct; - std::string store_root; - std::filesystem::path value_base = project_root; - if (const char* e = std::getenv("PSX_OVERLAY_CAPTURES")) direct = e; - if (const char* e = std::getenv("PSX_OVERLAY_CAPTURE_ROOT")) store_root = e; - - if (direct.empty() && store_root.empty()) { - std::vector configs = { - project_root / ".psxrecomp.env", project_root / ".env" - }; - /* Framework developers commonly build a game whose config root is a - * sibling repository. Walk upward from the executable as well so the - * framework worktree's private config remains the durable authority. */ - std::filesystem::path cur = exe_dir; - for (int i = 0; i < 8; ++i) { - configs.push_back(cur / ".psxrecomp.env"); - const auto parent = cur.parent_path(); - if (parent == cur) break; - cur = parent; - } - for (const auto& config : configs) { - std::ifstream in(config); - if (!in) continue; - std::string line; - while (std::getline(in, line)) { - line = trim_copy(line); - if (line.empty() || line[0] == '#') continue; - const size_t eq = line.find('='); - if (eq == std::string::npos) continue; - std::string key = trim_copy(line.substr(0, eq)); - std::string value = trim_copy(line.substr(eq + 1)); - if (value.size() >= 2 && - ((value.front() == '"' && value.back() == '"') || - (value.front() == '\'' && value.back() == '\''))) - value = value.substr(1, value.size() - 2); - if (key == "PSX_OVERLAY_CAPTURES") { - direct = value; - value_base = config.parent_path(); - } else if (key == "PSX_OVERLAY_CAPTURE_ROOT") { - store_root = value; - value_base = config.parent_path(); - } - } - if (!direct.empty() || !store_root.empty()) break; - } - } - - auto root_relative = [&](const std::string& raw) { - std::filesystem::path p(raw); - return p.is_absolute() ? p : value_base / p; - }; - std::filesystem::path result; - if (!direct.empty()) result = root_relative(direct); - else if (!store_root.empty()) - result = root_relative(store_root) / game_id / "overlay_captures.json"; - else result = exe_dir / "overlay_captures.json"; - - std::error_code ec; - std::filesystem::create_directories(result.parent_path(), ec); - if (ec) { - std::fprintf(stderr, "psxrecomp: cannot create overlay capture store %s: %s\n", - result.parent_path().string().c_str(), ec.message().c_str()); - return exe_dir / "overlay_captures.json"; - } - return result.lexically_normal(); -} - -static std::string lower_copy(std::string s) { - std::transform(s.begin(), s.end(), s.begin(), - [](unsigned char c) { return (char)std::tolower(c); }); - return s; -} - -static bool parse_bool_value(const std::string& value, bool fallback) { - std::string v = lower_copy(trim_copy(value)); - if (v == "1" || v == "true" || v == "yes" || v == "on") return true; - if (v == "0" || v == "false" || v == "no" || v == "off") return false; - return fallback; -} - -static ControllerSource parse_controller_source(const std::string& raw) { - std::string s = lower_copy(trim_copy(raw)); - ControllerSource out; - if (s.empty() || s == "none" || s == "disabled") return out; - - if (s == "a") { out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_A; return out; } - if (s == "b") { out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_B; return out; } - if (s == "x") { out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_X; return out; } - if (s == "y") { out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_Y; return out; } - if (s == "back" || s == "view" || s == "select") { - out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_BACK; return out; - } - if (s == "start" || s == "menu") { - out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_START; return out; - } - if (s == "guide") { out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_GUIDE; return out; } - if (s == "leftstick") { out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_LEFTSTICK; return out; } - if (s == "rightstick") { out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_RIGHTSTICK; return out; } - if (s == "leftshoulder" || s == "lb" || s == "l1") { - out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_LEFTSHOULDER; return out; - } - if (s == "rightshoulder" || s == "rb" || s == "r1") { - out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_RIGHTSHOULDER; return out; - } - if (s == "dpup" || s == "dpadup") { - out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_DPAD_UP; return out; - } - if (s == "dpdown" || s == "dpaddown") { - out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_DPAD_DOWN; return out; - } - if (s == "dpleft" || s == "dpadleft") { - out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_DPAD_LEFT; return out; - } - if (s == "dpright" || s == "dpadright") { - out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_DPAD_RIGHT; return out; - } - if (s == "lefttrigger" || s == "lt" || s == "l2") { - out.kind = ControllerSource::Kind::AxisPositive; out.id = SDL_CONTROLLER_AXIS_TRIGGERLEFT; return out; - } - if (s == "righttrigger" || s == "rt" || s == "r2") { - out.kind = ControllerSource::Kind::AxisPositive; out.id = SDL_CONTROLLER_AXIS_TRIGGERRIGHT; return out; - } - if (s == "leftx+" || s == "lsright") { - out.kind = ControllerSource::Kind::AxisPositive; out.id = SDL_CONTROLLER_AXIS_LEFTX; return out; - } - if (s == "leftx-" || s == "lsleft") { - out.kind = ControllerSource::Kind::AxisNegative; out.id = SDL_CONTROLLER_AXIS_LEFTX; return out; - } - if (s == "lefty+" || s == "lsdown") { - out.kind = ControllerSource::Kind::AxisPositive; out.id = SDL_CONTROLLER_AXIS_LEFTY; return out; - } - if (s == "lefty-" || s == "lsup") { - out.kind = ControllerSource::Kind::AxisNegative; out.id = SDL_CONTROLLER_AXIS_LEFTY; return out; - } - if (s == "rightx+" || s == "rsright") { - out.kind = ControllerSource::Kind::AxisPositive; out.id = SDL_CONTROLLER_AXIS_RIGHTX; return out; - } - if (s == "rightx-" || s == "rsleft") { - out.kind = ControllerSource::Kind::AxisNegative; out.id = SDL_CONTROLLER_AXIS_RIGHTX; return out; - } - if (s == "righty+" || s == "rsdown") { - out.kind = ControllerSource::Kind::AxisPositive; out.id = SDL_CONTROLLER_AXIS_RIGHTY; return out; - } - if (s == "righty-" || s == "rsup") { - out.kind = ControllerSource::Kind::AxisNegative; out.id = SDL_CONTROLLER_AXIS_RIGHTY; return out; - } - - SDL_GameControllerButton button = SDL_GameControllerGetButtonFromString(s.c_str()); - if (button != SDL_CONTROLLER_BUTTON_INVALID) { - out.kind = ControllerSource::Kind::Button; - out.id = button; - return out; - } - SDL_GameControllerAxis axis = SDL_GameControllerGetAxisFromString(s.c_str()); - if (axis != SDL_CONTROLLER_AXIS_INVALID) { - out.kind = ControllerSource::Kind::AxisPositive; - out.id = axis; - return out; - } - return out; -} - -static std::vector parse_source_list(const std::string& value) { - std::vector sources; - std::stringstream ss(value); - std::string item; - while (std::getline(ss, item, ',')) { - ControllerSource source = parse_controller_source(item); - if (source.kind != ControllerSource::Kind::None) { - sources.push_back(source); - } - } - return sources; -} - -static void set_default_controller_mapping(void) { - for (auto& entry : controller_map) entry.sources.clear(); - - auto set_sources = [](const char* name, const char* sources) { - for (auto& entry : controller_map) { - if (std::strcmp(entry.ini_name, name) == 0) { - entry.sources = parse_source_list(sources); - return; - } - } - }; - - set_sources("up", "dpup,lefty-"); - set_sources("down", "dpdown,lefty+"); - set_sources("left", "dpleft,leftx-"); - set_sources("right", "dpright,leftx+"); - set_sources("cross", "a"); - set_sources("circle", "b"); - set_sources("square", "x"); - set_sources("triangle", "y"); - set_sources("l1", "leftshoulder"); - set_sources("r1", "rightshoulder"); - set_sources("l2", "lefttrigger"); - set_sources("r2", "righttrigger"); - set_sources("l3", "leftstick"); - set_sources("r3", "rightstick"); - set_sources("start", "start"); - set_sources("select", "back"); -} - -static std::string default_input_ini_text(void) { - return - "; PSXRecomp input mapping. PSX buttons are active when any listed source is pressed.\n" - "; Sources use SDL/Xbox names: a,b,x,y,back,start,leftshoulder,rightshoulder,\n" - "; lefttrigger,righttrigger,leftstick,rightstick (stick clicks -> L3/R3),\n" - "; dpup,dpdown,dpleft,dpright,leftx-/leftx+/lefty-/lefty+.\n" - "\n" - "[controller]\n" - "enabled = true\n" - "device = 0\n" - "deadzone = 12000\n" - "\n" - "[mapping]\n" - "up = dpup,lefty-\n" - "down = dpdown,lefty+\n" - "left = dpleft,leftx-\n" - "right = dpright,leftx+\n" - "cross = a\n" - "circle = b\n" - "square = x\n" - "triangle = y\n" - "l1 = leftshoulder\n" - "r1 = rightshoulder\n" - "l2 = lefttrigger\n" - "r2 = righttrigger\n" - "l3 = leftstick\n" - "r3 = rightstick\n" - "start = start\n" - "select = back\n"; -} - -static void load_input_config(const char* argv0) { - set_default_controller_mapping(); - - /* Keyboard bindings are independent of the gamepad input.ini. Load them - * before the first-run input.ini creation branch: that branch returns - * after writing the default controller file, and previously left the - * compiled legacy keyboard defaults active for the entire first session - * even when a packaged keybinds.ini was already present. */ - psx_keybinds_init(argv0); - - namespace fs = std::filesystem; - fs::path config_path = exe_dir_from_argv(argv0) / "input.ini"; - std::error_code ec; - if (!fs::exists(config_path, ec)) { - std::ofstream out(config_path, std::ios::binary); - if (out) out << default_input_ini_text(); - return; - } - - std::ifstream in(config_path); - if (!in) return; - - bool controller_enabled = true; - std::string section; - std::string line; - while (std::getline(in, line)) { - size_t comment = line.find_first_of(";#"); - if (comment != std::string::npos) line.resize(comment); - line = trim_copy(line); - if (line.empty()) continue; - if (line.front() == '[' && line.back() == ']') { - section = lower_copy(trim_copy(line.substr(1, line.size() - 2))); - continue; - } - size_t eq = line.find('='); - if (eq == std::string::npos) continue; - - std::string key = lower_copy(trim_copy(line.substr(0, eq))); - std::string value = trim_copy(line.substr(eq + 1)); - if (section == "controller") { - if (key == "enabled") { - controller_enabled = parse_bool_value(value, controller_enabled); - } else if (key == "device") { - controller_device_index = std::max(0, std::atoi(value.c_str())); - } else if (key == "deadzone") { - controller_deadzone = std::max(0, std::min(32767, std::atoi(value.c_str()))); - } - } else if (section == "mapping") { - for (auto& entry : controller_map) { - if (key == entry.ini_name) { - entry.sources = parse_source_list(value); - break; - } - } - } - } - - if (!controller_enabled) { - for (auto& entry : controller_map) entry.sources.clear(); - } - -} - -static void close_player(PlayerInput& p) { - if (p.handle) { - SDL_GameControllerClose(p.handle); - p.handle = nullptr; - p.instance = -1; - } -} - -static void close_controller(void) { - close_player(g_players[0]); - close_player(g_players[1]); -} - -/* Open the SDL controller whose GUID matches p.guid. If no exact GUID match - * exists (e.g. a different physical unit of the same model, or Steam's virtual - * pad at an unpredictable slot), fall back to the first controller not already - * claimed by the other player. */ -static void open_player(PlayerInput& p, const PlayerInput& other) { - if (p.kind != 2 || p.handle) return; - - int chosen = -1, fallback = -1; - const int joysticks = SDL_NumJoysticks(); - for (int i = 0; i < joysticks; i++) { - if (!SDL_IsGameController(i)) continue; - SDL_JoystickGUID g = SDL_JoystickGetDeviceGUID(i); - char buf[40] = {0}; - SDL_JoystickGetGUIDString(g, buf, sizeof(buf)); - /* Skip a device already opened by the other player. */ - SDL_JoystickID inst = SDL_JoystickGetDeviceInstanceID(i); - if (other.handle && other.instance == inst) continue; - if (p.guid[0] && std::strcmp(buf, p.guid) == 0) { chosen = i; break; } - if (fallback < 0) fallback = i; - } - if (chosen < 0) chosen = fallback; - if (chosen < 0) return; - - p.handle = SDL_GameControllerOpen(chosen); - if (p.handle) { - SDL_Joystick* joy = SDL_GameControllerGetJoystick(p.handle); - p.instance = joy ? SDL_JoystickInstanceID(joy) : -1; - const char* name = SDL_GameControllerName(p.handle); - std::fprintf(stdout, "psxrecomp runtime: opened controller for slot: %s\n", - name ? name : "(unnamed)"); - } -} - -/* The pad type a mode reports before any input has been sampled (boot / - * hotplug). analog pins DualShock; digital and hybrid both start as a digital - * pad (hybrid only flips to DualShock once the player nudges the stick). */ -static int pad_mode_boot_analog(int mode) { - /* Hybrid boots ANALOG-on (matches a DualShock powered up with the analog LED - * lit): the seamless auto-switch then drops to digital only if the player - * reaches for the d-pad. Pinned-analog also boots analog; digital boots off. */ - return (mode == PSXRecompV4::PAD_MODE_ANALOG || - mode == PSXRecompV4::PAD_MODE_HYBRID) ? 1 : 0; -} - -/* Open/close SDL handles so they match g_players, and (re)assert each slot's - * PSX connection + pad type. Safe to call repeatedly (hotplug, boot). - * While delay-sync netplay is active, SIO connection/type are owned by - * psx_netplay (session slots stay plugged); only refresh host SDL handles. */ -static void refresh_player_devices(void) { - const int netplay = psx_netplay_active(); - for (int s = 0; s < 2; s++) { - PlayerInput& p = g_players[s]; - if (p.kind != 2) close_player(p); /* keyboard/none: no handle */ - else open_player(p, g_players[s ^ 1]); - if (netplay) continue; - sio_set_pad_connected(s, p.kind != 0 ? 1 : 0); - sio_set_pad_analog(s, pad_mode_boot_analog(p.mode), 0x80, 0x80, 0x80, 0x80); - /* DIGITAL mode == a plain digital controller that ignores the DualShock - * config-mode commands (real SCPH-1080 behaviour); ANALOG/HYBRID == a - * config-capable DualShock. A digital pad that wrongly answered 0x43 - * sent Tomba 2's pad driver down the config path -> phantom 0x00 reads. */ - sio_set_pad_config_capable(s, p.mode != PSXRecompV4::PAD_MODE_DIGITAL); - } -} - -/* Parse a [controller] device string into a player slot: - * "none" -> no pad; "keyboard" -> keyboard map; otherwise an SDL GUID. */ -static void set_player_device(PlayerInput& p, const std::string& dev, int mode) { - p.mode = mode; - /* Hybrid starts in ANALOG (analog LED on); the auto-switch drops to digital - * only when the player uses the d-pad. */ - p.hybrid_analog = true; - p.guid[0] = '\0'; - std::string d = lower_copy(trim_copy(dev)); - if (d.empty() || d == "none") { p.kind = 0; } - else if (d == "keyboard") { p.kind = 1; } - else if (d == "auto" || d == "gamepad" || d == "controller") { - /* First available SDL game controller (guid empty -> open_player falls - * back to the first connected pad). Lets a user default to "my - * controller" without pinning a specific GUID. */ - p.kind = 2; /* p.guid already cleared above */ - } - else { - p.kind = 2; - std::snprintf(p.guid, sizeof(p.guid), "%s", trim_copy(dev).c_str()); - } -} - -/* Stick→button (Digital D-Pad fold): per-axis deadzone. A strong left/right - * push with a few units of Y drift must NOT fire Up/Down (jump/crouch). Pure - * radial+sign did that — once mag cleared the circle, every non-zero axis - * component became a D-Pad bit. Analog stick bytes still use radial rescale - * in axes_to_pad_pair; this path is digital buttons only. Triggers share the - * same per-axis threshold. */ -static bool controller_source_pressed_h(SDL_GameController* h, const ControllerSource& source) { - if (!h) return false; - - switch (source.kind) { - case ControllerSource::Kind::Button: - return SDL_GameControllerGetButton( - h, (SDL_GameControllerButton)source.id) != 0; - case ControllerSource::Kind::AxisPositive: - case ControllerSource::Kind::AxisNegative: { - const int16_t v = SDL_GameControllerGetAxis(h, (SDL_GameControllerAxis)source.id); - if (source.kind == ControllerSource::Kind::AxisPositive) - return v > controller_deadzone; - return v < -controller_deadzone; - } - case ControllerSource::Kind::None: - default: - return false; - } -} - -/* Keyboard -> PSX button word for `player` (1 or 2), via the configurable - * keybinds.ini map (psx_keybinds). Defaults reproduce the old hardcoded layout - * (arrows=d-pad, X/S/Z/A=Cross/Circle/Square/Triangle, Q/W/E/R=L1/R1/L2/R2, - * Return=Start, RShift=Select) plus T/Y=L3/R3 stick clicks. */ -static uint16_t pad_from_keyboard(int player) { - const Uint8* keys = SDL_GetKeyboardState(NULL); - return psx_keybinds_pad_word(keys, player); -} - -/* A left/right ANALOG-STICK axis source (LEFTX/LEFTY/RIGHTX/RIGHTY), as opposed - * to a trigger axis (L2/R2) or a button. The default map folds the left stick - * onto the D-pad bits (up=dpup,lefty- … right=dpright,leftx+) so the stick works - * as a D-pad for DIGITAL games. In ANALOG mode that fold is WRONG: the stick - * already drives the analog axes, and on a real DualShock the stick and D-pad - * are independent — so a dual-analog game that uses the D-pad as its own control - * (e.g. Ape Escape's camera rotate) would see phantom D-pad presses from every - * stick movement, and constant rotation from centre drift. controller_pad_buttons - * suppresses these sources when the pad presents as analog. Trigger axes and - * button sources are never suppressed. */ -static bool source_is_stick_axis(const ControllerSource& s) { - if (s.kind != ControllerSource::Kind::AxisPositive && - s.kind != ControllerSource::Kind::AxisNegative) return false; - return s.id == SDL_CONTROLLER_AXIS_LEFTX || s.id == SDL_CONTROLLER_AXIS_LEFTY || - s.id == SDL_CONTROLLER_AXIS_RIGHTX || s.id == SDL_CONTROLLER_AXIS_RIGHTY; -} - -/* Build the active-low PSX button word from a controller's input.ini map. When - * suppress_stick_axes is set (the pad is presenting as analog this frame), the - * left/right analog-stick axes do NOT contribute button bits — see - * source_is_stick_axis above. Digital mode passes false, so the stick still - * folds onto the D-pad (its only outlet there). */ -static uint16_t controller_pad_buttons(SDL_GameController* h, bool suppress_stick_axes) { - uint16_t buttons = 0xFFFF; /* all released */ - if (!h) return buttons; - for (const auto& entry : controller_map) { - for (const auto& source : entry.sources) { - if (suppress_stick_axes && source_is_stick_axis(source)) continue; - if (controller_source_pressed_h(h, source)) { - buttons &= (uint16_t)~entry.bit; - break; - } - } - } - return buttons; -} - -static void axes_to_pad_pair(int16_t vx, int16_t vy, uint8_t* obx, uint8_t* oby) { - psx_stick_to_dualshock(vx, vy, - controller_deadzone, controller_anti_deadzone, - obx, oby); -} - -/* Buttons for a player's selected device (0xFFFF = none pressed). `player` is - * 1 or 2 — selects which keybinds.ini section drives a keyboard port. */ -static uint16_t pad_buttons_for(const PlayerInput& p, int player, bool suppress_stick_axes) { - if (p.kind == 1) return pad_from_keyboard(player); - if (p.kind == 2) return controller_pad_buttons(p.handle, suppress_stick_axes); - return 0xFFFF; -} - -/* Analog stick bytes (lx,ly,rx,ry) for a player; centred if no live source. - * - * The host left stick maps to the LEFT analog axes (variable). When fold_dpad - * is set, the physical D-pad (and, for a keyboard player, the arrow keys) is - * ALSO folded onto the left axes at full deflection, so an analog-mode game - * whose movement reads only the stick magnitude (e.g. Tomba) still responds to - * the D-pad/keyboard — the faithful "seamless" behaviour: the stick gives - * variable speed, the D-pad gives full speed, both at once. Only the PHYSICAL - * D-pad is folded in (not the stick->d-pad button mapping), so the stick keeps - * its true variable magnitude. fold_dpad is false in HYBRID mode (there the - * D-pad instead flips the pad to digital, so the game runs its own d-pad path) - * and true in pinned-ANALOG mode. The keyboard branch always folds the arrows - * — they are that player's only stick source. */ -static void pad_sticks_for(const PlayerInput& p, int player, uint8_t out[4], bool fold_dpad) { - out[0] = out[1] = out[2] = out[3] = 0x80; - if (p.kind == 1) { - /* Keyboard analog: the configurable left/right stick-direction binds - * (default = arrow keys on the LEFT stick; RIGHT stick unbound), so the - * old keyboard analog behaviour is preserved unless the user rebinds. */ - const Uint8* keys = SDL_GetKeyboardState(NULL); - psx_keybinds_sticks(keys, player, out); - return; - } - if (p.kind == 2 && p.handle) { - axes_to_pad_pair(SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_LEFTX), - SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_LEFTY), - &out[0], &out[1]); - axes_to_pad_pair(SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_RIGHTX), - SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_RIGHTY), - &out[2], &out[3]); - if (fold_dpad) { - if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_LEFT)) out[0] = 0x00; - if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_RIGHT)) out[0] = 0xFF; - if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_UP)) out[1] = 0x00; - if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_DOWN)) out[1] = 0xFF; - } - } -} - -/* HYBRID-mode auto-switch detectors (mirror the DualShock analog LED toggling). - * hybrid_stick_active: the LEFT stick is outside the radial deadzone — the - * player has reached for analog. hybrid_dpad_active: any D-pad direction (or, - * for the keyboard, an arrow key) is held — the player wants classic digital. - * The keyboard has no analog stick, so a keyboard player stays digital. */ -static bool controller_stick_active(SDL_GameController* handle) { - if (!handle) return false; - const double lx = - SDL_GameControllerGetAxis(handle, SDL_CONTROLLER_AXIS_LEFTX); - const double ly = - SDL_GameControllerGetAxis(handle, SDL_CONTROLLER_AXIS_LEFTY); - return std::sqrt(lx * lx + ly * ly) > (double)controller_deadzone; -} -static bool controller_dpad_active(SDL_GameController* handle) { - return handle && - (SDL_GameControllerGetButton(handle, SDL_CONTROLLER_BUTTON_DPAD_LEFT) || - SDL_GameControllerGetButton(handle, SDL_CONTROLLER_BUTTON_DPAD_RIGHT) || - SDL_GameControllerGetButton(handle, SDL_CONTROLLER_BUTTON_DPAD_UP) || - SDL_GameControllerGetButton(handle, SDL_CONTROLLER_BUTTON_DPAD_DOWN)); -} -static bool hybrid_stick_active(const PlayerInput& p, bool dev_any) { - if (p.kind == 2 && controller_stick_active(p.handle)) return true; - if (dev_any) { - const int n = SDL_NumJoysticks(); - for (int i = 0; i < n; i++) { - if (!SDL_IsGameController(i)) continue; - const SDL_JoystickID inst = SDL_JoystickGetDeviceInstanceID(i); - SDL_GameController* handle = - SDL_GameControllerFromInstanceID(inst); - if (!handle) handle = SDL_GameControllerOpen(i); - if (controller_stick_active(handle)) return true; - } - } - return false; -} -static bool hybrid_dpad_active(const PlayerInput& p, int player, bool dev_any) { - if (p.kind == 2 && controller_dpad_active(p.handle)) return true; - if (dev_any) { - const int n = SDL_NumJoysticks(); - for (int i = 0; i < n; i++) { - if (!SDL_IsGameController(i)) continue; - const SDL_JoystickID inst = SDL_JoystickGetDeviceInstanceID(i); - SDL_GameController* handle = - SDL_GameControllerFromInstanceID(inst); - if (!handle) handle = SDL_GameControllerOpen(i); - if (controller_dpad_active(handle)) return true; - } - } - if (p.kind == 1 || dev_any) { - const Uint8* keys = SDL_GetKeyboardState(NULL); - if (psx_keybinds_dpad_active(keys, player)) return true; - } - return false; -} - -/* Sample each player's live device state into the matching SIO pad slot. - * override >= 0 forces port-1 buttons (debug-server input injection). Called - * once at cycle start (covers the turbo/FMV-skip paths that present nothing) - * and, when g_low_latency_input is set, AGAIN after the pacer wait so the next - * CPU frame reads near-fresh input instead of input ~one frame stale. - * - * HYBRID mode auto-switches DualShock<->digital from the most-recent input - * (stick -> analog, D-pad -> digital) so the game runs its own analog or - * digital input path exactly as on hardware (mirrors the inline block this - * helper was extracted from for the low-latency re-sample). */ -/* Dev input mode: while enabled, Player 1 is driven by the keyboard AND EVERY - * connected game controller, all merged together (active-low AND) onto the P1 pad - * word — so a tester can navigate P1 from whatever is plugged in (keyboard, the - * launcher-assigned pad, or any other controller) with no reconfiguration. The P1 - * pad TYPE is left unchanged: a launcher-assigned analog DualShock still presents - * as analog (so the game's analog input path / SIO handshake cadence is preserved - * exactly), and merged sources only contribute button/stick STATE, never a type - * downgrade. This diagnostic convenience must never alter normal player input: - * strict single-device-per-port routing is the default, and PSX_DEV_INPUT must - * be explicitly set to 1/true/yes/on to enable the merge. */ -static bool dev_any_input_enabled() { - static int cached = -1; - if (cached < 0) { - const char* e = std::getenv("PSX_DEV_INPUT"); - const std::string value = lower_copy(trim_copy(e ? e : "")); - cached = (value == "1" || value == "true" || - value == "yes" || value == "on") ? 1 : 0; - } - return cached != 0; -} - -/* Merge (active-low AND) the button words of every connected game controller. - * Handles are resolved from SDL's already-open set when possible and opened once - * on first sight otherwise (kept open for the process lifetime; if a slot later - * closes a shared device this self-heals by reopening next frame). This does NOT - * disturb per-slot routing — SDL returns the same handle for an already-open - * device, so reads are shared and harmless. */ -static uint16_t dev_all_controllers_buttons(bool suppress_stick_axes) { - uint16_t btn = 0xFFFF; - const int n = SDL_NumJoysticks(); - for (int i = 0; i < n; i++) { - if (!SDL_IsGameController(i)) continue; - SDL_JoystickID inst = SDL_JoystickGetDeviceInstanceID(i); - SDL_GameController* h = SDL_GameControllerFromInstanceID(inst); - if (!h) h = SDL_GameControllerOpen(i); /* open once; SDL keeps it */ - if (h) btn &= controller_pad_buttons(h, suppress_stick_axes); - } - return btn; -} - -/* Fold the first live (non-neutral) left/right stick from ANY connected - * controller into st[] (lx,ly,rx,ry). Lets an analog-mode P1 steer from - * whatever pad is plugged in, not only the launcher-assigned one. Only axes - * that read past the deadzone override; a neutral pad leaves st[] untouched. */ -static void dev_any_controller_sticks(uint8_t st[4]) { - const int n = SDL_NumJoysticks(); - for (int i = 0; i < n; i++) { - if (!SDL_IsGameController(i)) continue; - SDL_JoystickID inst = SDL_JoystickGetDeviceInstanceID(i); - SDL_GameController* h = SDL_GameControllerFromInstanceID(inst); - if (!h) h = SDL_GameControllerOpen(i); - if (!h) continue; - uint8_t lx, ly, rx, ry; - axes_to_pad_pair(SDL_GameControllerGetAxis(h, SDL_CONTROLLER_AXIS_LEFTX), - SDL_GameControllerGetAxis(h, SDL_CONTROLLER_AXIS_LEFTY), &lx, &ly); - axes_to_pad_pair(SDL_GameControllerGetAxis(h, SDL_CONTROLLER_AXIS_RIGHTX), - SDL_GameControllerGetAxis(h, SDL_CONTROLLER_AXIS_RIGHTY), &rx, &ry); - if (lx != 0x80 || ly != 0x80) { st[0] = lx; st[1] = ly; } - if (rx != 0x80 || ry != 0x80) { st[2] = rx; st[3] = ry; } - } -} - -/* Debug-server input injection: drive the SAME pad model as a physical - * device. The old path set only the button word and returned, which left the - * pad type/sticks at whatever the real device last was — a hybrid P1 stayed - * analog, so injected d-pad bits never moved games that read the stick in - * analog mode (menus reacted, walking didn't). Injected d-pad reads as d-pad - * activity (hybrid drops to digital), an injected stick override reads as - * stick activity (hybrid rises to analog), and the resolved type goes through - * the same coherent request channel as real sampling — never slammed mid- - * handshake (the v0.5.0 phantom-input lesson). */ -static void apply_input_override_to_sio(int override_word) { - PlayerInput& p = g_players[0]; - const uint16_t w = (uint16_t)override_word; - sio_set_pad_state_slot(0, w); - - uint8_t st[4] = { 0x80, 0x80, 0x80, 0x80 }; - int axes = 0; -#ifndef PSX_NO_DEBUG_TOOLS - axes = debug_server_get_axis_override(st); -#endif - const bool stick_live = axes != 0 && (st[0] != 0x80 || st[1] != 0x80 || - st[2] != 0x80 || st[3] != 0x80); - const bool dpad_live = ((uint16_t)~w & 0x00F0u) != 0; /* up/right/down/left */ - - /* Debug injection follows the game's resolved mode even when P1 has no - * assigned device. This keeps dev-any-input from manufacturing Hybrid for - * a title that deliberately exposes only Analog / D-Pad. */ - const int mode = p.mode; - - int eff_analog; - if (mode == (int)PSXRecompV4::PAD_MODE_DIGITAL) { - eff_analog = 0; - } else if (mode == (int)PSXRecompV4::PAD_MODE_ANALOG) { - eff_analog = 1; - } else { /* HYBRID: most-recent input source wins, like the real sampler */ - if (stick_live) p.hybrid_analog = true; - else if (dpad_live) p.hybrid_analog = false; - eff_analog = p.hybrid_analog ? 1 : 0; - } - if (!eff_analog) { st[0] = st[1] = st[2] = st[3] = 0x80; } - sio_set_pad_sticks(0, st[0], st[1], st[2], st[3]); - sio_request_pad_type(0, eff_analog); -} - -/* Capture one SIO slot's host pad into a netplay/local blob. Returns 1 if a - * device (or dev-any P1) is present; 0 leaves *out as released/disconnected. */ -static int capture_pad_slot(int s, PsxNetPad* out) { - if (!out) return 0; - out->buttons = 0xFFFFu; - out->lx = out->ly = out->rx = out->ry = 0x80u; - out->analog = 0; - out->connected = 0; - - PlayerInput& p = g_players[s]; - const int player = s + 1; /* keybinds.ini section (1|2) */ - /* Dev input: P1 is driven by the keyboard AND every connected controller, - * so a tester can navigate from whatever is plugged in (P2 keeps strict - * per-port routing). */ - const bool dev_here = (dev_any_input_enabled() && s == 0); - if (p.kind == 0 && !dev_here) return 0; /* no device in this port */ - - /* Resolve the pad type this frame FIRST — the effective analog/digital - * state gates how the left stick is read for BOTH the button word and the - * analog axes below. An assigned device keeps its configured mode (a - * launcher-selected analog DualShock stays analog, so its input path / SIO - * handshake cadence is preserved exactly). A P1 with no assigned device - * keeps the game's resolved mode while dev-any-input merges the keyboard and - * all connected controllers. This is important for games that deliberately - * do not support Hybrid. */ - const int mode = p.mode; - int eff_analog; - if (mode == PSXRecompV4::PAD_MODE_DIGITAL) { - eff_analog = 0; - } else if (mode == PSXRecompV4::PAD_MODE_ANALOG) { - eff_analog = 1; - } else { /* HYBRID */ - if (hybrid_stick_active(p, dev_here)) p.hybrid_analog = true; - else if (hybrid_dpad_active(p, player, dev_here)) p.hybrid_analog = false; - eff_analog = p.hybrid_analog ? 1 : 0; - } - - /* Buttons: merge the assigned device with the keyboard (PSX pad word is - * active-low, so AND combines "pressed on either source"). In dev mode P1 - * also folds in the keyboard binds and EVERY connected controller. When the - * pad presents as ANALOG this frame (eff_analog), the left/right analog- - * stick axes are suppressed as button sources so the stick drives ONLY the - * analog axes — the D-pad bits then come solely from the physical D-pad, - * exactly as on a real DualShock. This is what stops a dual-analog game's - * D-pad control (Ape Escape's camera rotate) from being spun by stick - * movement or centre drift. Digital mode keeps the stick->D-pad fold. */ - const bool suppress_stick = (eff_analog != 0); - uint16_t btn = (p.kind != 0) ? pad_buttons_for(p, player, suppress_stick) - : (uint16_t)0xFFFF; - if (dev_here) { - btn &= pad_from_keyboard(1); /* keyboard P1 binds */ - btn &= dev_all_controllers_buttons(suppress_stick); /* any plugged-in pad */ - } - - /* Analog axes. Pinned-ANALOG folds the physical D-pad onto the left axes - * (fold_dpad) so the D-pad still moves stick-only games; HYBRID feeds the - * raw stick when currently analog (no fold — the D-pad drives its own - * digital path there); DIGITAL leaves the axes centred. */ - uint8_t st[4] = { 0x80, 0x80, 0x80, 0x80 }; - if (mode == PSXRecompV4::PAD_MODE_ANALOG) { - pad_sticks_for(p, player, st, /*fold_dpad=*/true); - } else if (eff_analog) { /* HYBRID, currently presenting analog */ - pad_sticks_for(p, player, st, /*fold_dpad=*/false); - } - /* Dev mode: fold the keyboard's stick binds AND any connected controller's - * sticks onto the analog stick, so an analog-mode P1 steers from whatever - * is plugged in (P1 binds). */ - if (dev_here && eff_analog) { - const Uint8* keys = SDL_GetKeyboardState(NULL); - psx_keybinds_sticks(keys, 1, st); - dev_any_controller_sticks(st); - } - - out->buttons = btn; - out->lx = st[0]; out->ly = st[1]; out->rx = st[2]; out->ry = st[3]; - out->analog = eff_analog ? 1u : 0u; - out->connected = 1; - return 1; -} - -/* Netplay-only capture: assigned PlayerInput for this slot only. Never merges - * keyboard-all / all-controllers (dev_any_input). Same pad-mode / stick rules - * as capture_pad_slot otherwise. */ -static int capture_pad_slot_exclusive(int s, PsxNetPad* out) { - if (!out) return 0; - out->buttons = 0xFFFFu; - out->lx = out->ly = out->rx = out->ry = 0x80u; - out->analog = 0; - out->connected = 0; - - PlayerInput& p = g_players[s]; - const int player = s + 1; /* keybinds.ini section (1|2) */ - const bool dev_here = false; - if (p.kind == 0) return 0; /* no device in this port */ - - int mode = p.mode; - int eff_analog; - if (mode == PSXRecompV4::PAD_MODE_DIGITAL) { - eff_analog = 0; - } else if (mode == PSXRecompV4::PAD_MODE_ANALOG) { - eff_analog = 1; - } else { /* HYBRID */ - if (hybrid_stick_active(p, dev_here)) p.hybrid_analog = true; - else if (hybrid_dpad_active(p, player, dev_here)) p.hybrid_analog = false; - eff_analog = p.hybrid_analog ? 1 : 0; - } - - const bool suppress_stick = (eff_analog != 0); - uint16_t btn = pad_buttons_for(p, player, suppress_stick); - - uint8_t st[4] = { 0x80, 0x80, 0x80, 0x80 }; - if (mode == PSXRecompV4::PAD_MODE_ANALOG) { - pad_sticks_for(p, player, st, /*fold_dpad=*/true); - } else if (eff_analog) { /* HYBRID, currently presenting analog */ - pad_sticks_for(p, player, st, /*fold_dpad=*/false); - } - - out->buttons = btn; - out->lx = st[0]; out->ly = st[1]; out->rx = st[2]; out->ry = st[3]; - out->analog = eff_analog ? 1u : 0u; - out->connected = 1; - return 1; -} - -static void apply_pad_slot_to_sio(int s, const PsxNetPad& pad) { - sio_set_pad_state_slot(s, pad.buttons); - sio_set_pad_sticks(s, pad.lx, pad.ly, pad.rx, pad.ry); - sio_request_pad_type(s, pad.analog ? 1 : 0); -} - -/* Local human pad for delay-sync: sample the host PlayerInput selected for - * this peer (see --net-input-player / auto), then recomp-net maps that blob - * onto local_slot (host→sim P1, guest→sim P2). Never writes SIO. Exclusive - * capture — no keyboard-all / all-controllers merge — so peers hash-agree. */ -static void capture_local_human_pad(PsxNetPad* out) { - const uint32_t ui_flags = psx_host_ui_capture_flags(); - if (ui_flags & (PSX_HOST_UI_CAPTURE_KEYBOARD | - PSX_HOST_UI_CAPTURE_GAMEPAD)) { - out->buttons = 0xFFFFu; - out->lx = out->ly = out->rx = out->ry = 0x80u; - out->analog = 1; - out->connected = 1; - psx_netplay_normalize_pad(out); - return; - } - int idx = psx_netplay_input_player(); - if (idx < 0 || idx > 1) idx = 0; - if (!capture_pad_slot_exclusive(idx, out)) { - /* Fallback: if auto picked empty P2, try P1 (two-machine guest). */ - if (idx != 0 && capture_pad_slot_exclusive(0, out)) { - out->connected = 1; - psx_netplay_normalize_pad(out); - return; - } - out->buttons = 0xFFFFu; - out->lx = out->ly = out->rx = out->ry = 0x80u; - out->analog = 1; - } - out->connected = 1; - psx_netplay_normalize_pad(out); -} - -/* Build a netplay pad blob from a debug-server override without writing SIO. */ -static void capture_override_pad(int override_word, PsxNetPad* out) { - PlayerInput& p = g_players[0]; - const uint16_t w = (uint16_t)override_word; - uint8_t st[4] = { 0x80, 0x80, 0x80, 0x80 }; - int axes = 0; -#ifndef PSX_NO_DEBUG_TOOLS - axes = debug_server_get_axis_override(st); -#endif - const bool stick_live = axes != 0 && (st[0] != 0x80 || st[1] != 0x80 || - st[2] != 0x80 || st[3] != 0x80); - const bool dpad_live = ((uint16_t)~w & 0x00F0u) != 0; - - const int mode = p.mode; - - int eff_analog; - if (mode == (int)PSXRecompV4::PAD_MODE_DIGITAL) { - eff_analog = 0; - } else if (mode == (int)PSXRecompV4::PAD_MODE_ANALOG) { - eff_analog = 1; - } else { - if (stick_live) p.hybrid_analog = true; - else if (dpad_live) p.hybrid_analog = false; - eff_analog = p.hybrid_analog ? 1 : 0; - } - if (!eff_analog) { st[0] = st[1] = st[2] = st[3] = 0x80; } - - out->buttons = w; - out->lx = st[0]; out->ly = st[1]; out->rx = st[2]; out->ry = st[3]; - out->analog = eff_analog ? 1u : 0u; - out->connected = 1; -} - -/* Opt-in netplay barrier split: guest quantum vs admit wait (PSX_NETPLAY_TIMING=1). - * Printed on the existing [FPS] line — no separate log files. */ -static int s_np_timing_enabled = -1; -static uint64_t s_np_admit_ticks = 0; -static uint64_t s_np_guest_ticks = 0; -static uint64_t s_np_last_admit_end = 0; -static uint64_t s_np_timing_frames = 0; - -static int netplay_timing_on(void) { - if (s_np_timing_enabled < 0) { - const char *e = std::getenv("PSX_NETPLAY_TIMING"); - s_np_timing_enabled = (e && e[0] && e[0] != '0') ? 1 : 0; - } - return s_np_timing_enabled; -} - -/* Stage local pad + poll admit until published. Parks the guest fiber when - * called from the vblank callback (or before scheduler entry). Latches one - * sample per sim tick; stalls on INPUT_CONFIRM desync. */ -static void netplay_barrier_admit(int override) { - if (!psx_netplay_active()) return; - static int desync_logged = 0; - const uint64_t admit_t0 = - netplay_timing_on() ? SDL_GetPerformanceCounter() : 0; - /* Guest quantum = time since previous admit returned (fiber ran). */ - if (admit_t0 && s_np_last_admit_end && admit_t0 >= s_np_last_admit_end) { - s_np_guest_ticks += admit_t0 - s_np_last_admit_end; - s_np_timing_frames++; - } - for (;;) { - uint32_t dt = 0, lh = 0, rh = 0; - /* Load apply/ready suppresses INPUT (and can sit silent for seconds on - * a hash-match .pst). timeout=0 still honors BYE (peer_gone) but does - * not treat rx silence as disconnect — that was kicking both peers to - * the lobby mid-load. */ - if (psx_netplay_peer_disconnected( - psx_netplay_in_load_barrier() ? 0u : 1500u)) { - netplay_soft_exit("netplay_peer_disconnect"); - if (psx_return_to_lobby_requested()) return; - } - psx_lobby_pump(); - if (psx_netplay_input_desync(&dt, &lh, &rh)) { - if (!desync_logged) { - std::printf("psxrecomp: netplay INPUT desync tick=%u local=%08x remote=%08x — stalled\n", - (unsigned)dt, (unsigned)lh, (unsigned)rh); - std::fflush(stdout); - desync_logged = 1; - } - SDL_Delay(16); -#ifndef PSX_NO_DEBUG_TOOLS - debug_server_poll(); -#endif - continue; - } - if (psx_netplay_needs_local_sample()) { - PsxNetPad local{}; - if (override >= 0 && !g_headless) { - capture_override_pad(override, &local); - } else if (g_headless) { - local.buttons = 0xFFFFu; - local.lx = local.ly = local.rx = local.ry = 0x80u; - local.analog = 1; - local.connected = 1; - } else { - capture_local_human_pad(&local); - } - psx_netplay_stage_local(&local); - } - if (psx_netplay_poll_admit()) { - desync_logged = 0; - if (admit_t0) { - const uint64_t t1 = SDL_GetPerformanceCounter(); - if (t1 >= admit_t0) s_np_admit_ticks += t1 - admit_t0; - s_np_last_admit_end = t1; - } - return; - } -#ifndef PSX_NO_DEBUG_TOOLS - debug_server_poll(); -#endif - if (!g_headless) { - SDL_Event ev; - while (SDL_PollEvent(&ev)) { - const int ui_consumed = psx_host_ui_handle_event(&ev); - if (ev.type == SDL_QUIT) { - netplay_soft_exit("sdl_window_close"); - if (psx_return_to_lobby_requested()) return; - } - if (!ui_consumed && ev.type == SDL_KEYDOWN && -#if defined(PSX_SDL3) - ev.key.key == SDLK_ESCAPE) { -#else - ev.key.keysym.sym == SDLK_ESCAPE) { -#endif - netplay_soft_exit("netplay_barrier_escape"); - if (psx_return_to_lobby_requested()) return; - } - if (ev.type == SDL_CONTROLLERDEVICEADDED || - ev.type == SDL_CONTROLLERDEVICEREMOVED) { - refresh_player_devices(); - } - } - SDL_GameControllerUpdate(); - } - if (psx_return_to_lobby_requested()) return; - /* Wake on peer UDP (or 1ms timeout). SDL_Delay(1) under dual FMV load - * often stretches multi-ms and cut MotK netplay intro ~59→~36; Delay(0) - * busy-spins and can starve the peer (tick-0 hang). */ - psx_netplay_wait_recv(1); - /* Admit barriers (save/load sync) can last seconds without guest cycles. */ - starvation_watchdog_heartbeat(); - } -} - -static void sample_pad_into_sio(int override) { - if (override >= 0) { - apply_input_override_to_sio(override); - return; - } - const uint32_t ui_flags = psx_host_ui_capture_flags(); - if (ui_flags & (PSX_HOST_UI_CAPTURE_KEYBOARD | - PSX_HOST_UI_CAPTURE_GAMEPAD)) { - /* Poll-based input bypasses SDL event consumption. Publish a fully - * neutral sample (buttons and sticks) while preserving each port's - * current digital/analog presentation mode. */ - for (int s = 0; s < 2; s++) { - PsxNetPad pad{}; - pad.buttons = 0xFFFFu; - pad.lx = pad.ly = pad.rx = pad.ry = 0x80u; - pad.analog = (g_players[s].mode == PSXRecompV4::PAD_MODE_ANALOG || - (g_players[s].mode == PSXRecompV4::PAD_MODE_HYBRID && - g_players[s].hybrid_analog)) ? 1u : 0u; - pad.connected = g_players[s].kind != 0 ? 1u : 0u; - apply_pad_slot_to_sio(s, pad); - } - return; - } - for (int s = 0; s < 2; s++) { - PsxNetPad pad; - if (!capture_pad_slot(s, &pad)) continue; /* no device in this port */ - /* Push sticks every frame; request the pad type (digital/analog) through - * the coherent channel so a hybrid stick<->d-pad flip is applied only at - * an idle, non-config bus boundary (never mid-poll / mid-handshake). This - * is the fix for the v0.5.0 phantom-input regression: slamming the type - * each frame raced Tomba's DualShock config handshake -> garbage button - * reads. eff_analog still reflects this frame's mode (digital / analog / - * hybrid auto-switch). */ - apply_pad_slot_to_sio(s, pad); - } -} - -static void sample_headless_pad_into_sio(int override) { - if (override >= 0) { - apply_input_override_to_sio(override); - return; - } -#ifdef PSX_COSIM - /* Input-driven cosim (EXPERIMENTAL): the coordinator sets a HELD pad state via the - * `setpad` TCP command; the headless sampler applies it every frame so the same - * button input is fed to BOTH lockstep instances at the same guest cycle. If the - * two instances desync under input, this path is proven nondeterministic and gets - * stubbed out (see cosim.c). Default 0xFFFF = all released (PSX pad is active-low). */ - { extern volatile int g_cosim_pad_hold[2]; - sio_set_pad_state_slot(0, (uint16_t)g_cosim_pad_hold[0]); - sio_set_pad_state_slot(1, (uint16_t)g_cosim_pad_hold[1]); - return; } -#endif - sio_set_pad_state_slot(0, 0xFFFFu); - sio_set_pad_state_slot(1, 0xFFFFu); -} - -/* PSX native vblank cadence: NTSC ≈ 59.94 Hz. Wall-clock target keeps - * audio sample generation (735 samples/vblank * 60 = 44100/sec) matched - * to the SDL audio device drain rate, eliminating queue overflow drops - * and underruns. Uncapped, the host runs the simulation at whatever - * speed it can — typically several × realtime — and audio glitches. - * - * The wall-clock pacer target; nudged to the host display refresh at - * window-creation time (sync-to-host-refresh) so the pacer and SDL PRESENTVSYNC - * do not fight — a fixed 59.94 pacer against a 60.00 Hz panel makes rendered - * frames land on an uneven vblank count (a 2/3/1 beat) that reads as - * moving-object judder/flicker. See g_frame_period_ms. */ -/* Live pacer period (ms). Defaults to the PSX rate; set to the host refresh - * period when the panel is within ~2% of 60 Hz so 30fps content pads evenly to - * two host refreshes. Left at the PSX rate on non-~60Hz panels to avoid running - * the sim at the wrong speed. */ - -/* ── Host-stack-usage profile (RECURSION_BUG.md §17) ────────────────────────── - * The decisive instrument for the long-run freeze. The guest call graph mirrors - * onto the 1MB guest fiber stack (traps.c), and the native-stack guard trips at - * ~768KB used. The open question is the SHAPE of host-stack usage over frames: - * - LINEAR climb from gameplay start => a per-frame interp<->compiled boundary - * leak: every game tick leaks one un-unwound host call chain (~13 frames), - * overflowing the fiber after ~50k frames. 50k is a CAPACITY limit, not a - * trigger. (Model A — predicted.) - * - FLAT, then a cliff at one frame => a within-one-frame runaway re-entry at - * a real ~frame-50k trigger. (Model W — the old §15 premise.) - * (TEB StackBase - rsp), sampled on the guest fiber each vblank, IS the leaked - * depth. Sampled in sdl_vblank_present — a gpu_vblank_tick callback off - * psx_check_interrupts, i.e. ON the guest fiber, every vblank, in both builds. - * Decimated 1-per-STRIDE frames so the ring spans the whole run; "now" carries - * the exact latest sample. ALWAYS-ON: read live via the `stack_profile` TCP - * command while the game is still responsive (no need to reach the overflow), - * and dumped in the crash report. Query the ring; never arm-and-capture. */ -#if defined(_WIN32) -#include /* __readgsqword — fiber TEB StackBase */ -static size_t host_stack_used(void) { - char probe; - uintptr_t base = (uintptr_t)__readgsqword(0x08); /* TEB StackBase (high) */ - uintptr_t sp = (uintptr_t)&probe; - return (base > sp) ? (size_t)(base - sp) : 0; -} -#else -static size_t host_stack_used(void) { return 0; } -#endif - -#define STACK_PROFILE_CAP 512u -#define STACK_PROFILE_STRIDE 128u /* 512 * 128 = 65536 frames of coverage */ -typedef struct { uint32_t frame; uint32_t used_kb; } StackSample; -static StackSample g_stack_prof[STACK_PROFILE_CAP]; -static uint32_t g_stack_prof_seq = 0; -static uint32_t g_stack_used_now_kb = 0; -static uint32_t g_stack_frame_now = 0; -static uint32_t g_stack_used_max_kb = 0; - -static void stack_profile_sample(void) { - extern uint64_t s_frame_count; - uint32_t f = (uint32_t)s_frame_count; - uint32_t kb = (uint32_t)(host_stack_used() >> 10); - g_stack_frame_now = f; - g_stack_used_now_kb = kb; - if (kb > g_stack_used_max_kb) g_stack_used_max_kb = kb; - if ((f % STACK_PROFILE_STRIDE) == 0) { - StackSample *e = &g_stack_prof[g_stack_prof_seq++ & (STACK_PROFILE_CAP - 1u)]; - e->frame = f; e->used_kb = kb; - } -} - -/* JSON dump of the profile (callable from C: crash report + TCP command). */ -extern "C" int stack_profile_json(char *out, int cap) { - uint32_t total = g_stack_prof_seq; - uint32_t avail = total < STACK_PROFILE_CAP ? total : STACK_PROFILE_CAP; - uint32_t start = total - avail; - int n = snprintf(out, cap, - "{\"now\":{\"f\":%u,\"kb\":%u},\"max_kb\":%u,\"stride\":%u,\"count\":%u,\"samples\":[", - g_stack_frame_now, g_stack_used_now_kb, g_stack_used_max_kb, - STACK_PROFILE_STRIDE, avail); - for (uint32_t i = 0; i < avail && n < cap - 48; i++) { - StackSample *e = &g_stack_prof[(start + i) & (STACK_PROFILE_CAP - 1u)]; - n += snprintf(out + n, cap - n, "%s{\"f\":%u,\"kb\":%u}", - i ? "," : "", e->frame, e->used_kb); - } - n += snprintf(out + n, cap - n, "]}"); - return n; -} - -/* ---- Present-classification ring (see present_ring.h) ----------------- - * Always-on record of how each present was classified, for every backend. - * A native-wide present that silently falls back to the canonical width is - * indistinguishable on screen from a deliberate stretch, so the decision - * has to live in a queryable ring rather than be re-derived after the fact. */ -#define PRES_RING_CAP 4096u /* power of two; ~68 s at 60 Hz */ -static PresRingEntry s_pres_ring[PRES_RING_CAP]; -static uint64_t s_pres_ring_total = 0; - -extern "C" uint64_t present_ring_total(void) { return s_pres_ring_total; } -extern "C" int present_ring_get(uint64_t seq, PresRingEntry* out) { - if (seq >= s_pres_ring_total) return 0; - if (s_pres_ring_total - seq > PRES_RING_CAP) return 0; - *out = s_pres_ring[seq & (PRES_RING_CAP - 1u)]; - return 1; -} - -/* Commit this present's classification. Returns the stored entry so the - * software wide-fallback (the only post-decision mutation) can amend it. */ -static PresRingEntry* present_ring_commit(uint8_t path, uint16_t disp_w, - uint16_t disp_h, uint16_t present_w) { - GpuWsDebug ws; - gpu_ws_get_debug(&ws); - PresRingEntry* e = &s_pres_ring[s_pres_ring_total & (PRES_RING_CAP - 1u)]; - s_pres_ring_total++; - e->frame = (uint32_t)ws.cur_frame; - e->disp_w = disp_w; - e->disp_h = disp_h; - e->present_w = present_w; - e->nw_extra = (uint16_t)(ws.nw_extra < 0 ? 0 : ws.nw_extra); - e->path = path; - e->wide_fellback = 0; - e->game_mode = (uint8_t)ws.game_mode; - e->native_43 = (uint8_t)ws.present_native_43; - int64_t d = (int64_t)ws.cur_frame - (int64_t)ws.last_tag_frame; - e->tag_delta = (d > INT32_MAX || d < INT32_MIN) ? INT32_MAX : (int32_t)d; - e->gte_verts = (uint16_t)(ws.gte_verts > 0xFFFF ? 0xFFFF : ws.gte_verts); - e->ovh_prims = (uint16_t)(ws.ovh_prims > 0xFFFF ? 0xFFFF : ws.ovh_prims); - return e; -} - -/* ---- Load-transition ring --------------------------------------------- - * State edges only, rather than one record per frame. This makes the exact - * CD-read -> bridged-load -> turbo -> paced transitions cheap and queryable - * without changing any runtime behavior. */ -#define LOAD_TRANSITION_CAP 512u -static LoadTransitionEntry s_load_transitions[LOAD_TRANSITION_CAP]; -static uint64_t s_load_transition_total = 0; - -extern "C" uint64_t load_transition_total(void) { - return s_load_transition_total; -} -extern "C" int load_transition_get(uint64_t seq, LoadTransitionEntry *out) { - if (seq >= s_load_transition_total) return 0; - if (s_load_transition_total - seq > LOAD_TRANSITION_CAP) return 0; - *out = s_load_transitions[seq & (LOAD_TRANSITION_CAP - 1u)]; - return 1; -} - -static void load_transition_note(int read_active, int load_active, - int turbo_active, int load_run) { - static int prev_read = -1, prev_load = -1, prev_turbo = -1; - if (read_active == prev_read && load_active == prev_load && - turbo_active == prev_turbo) return; - extern uint64_t s_frame_count; - LoadTransitionEntry *e = - &s_load_transitions[s_load_transition_total & (LOAD_TRANSITION_CAP - 1u)]; - s_load_transition_total++; - e->frame = (uint32_t)s_frame_count; - e->host_ms = (uint32_t)SDL_GetTicks(); - e->load_run = (uint16_t)(load_run > 0xFFFF ? 0xFFFF : load_run); - e->read_active = (uint8_t)(read_active != 0); - e->load_active = (uint8_t)(load_active != 0); - e->turbo_active = (uint8_t)(turbo_active != 0); - prev_read = read_active; - prev_load = load_active; - prev_turbo = turbo_active; -} - -/* Depth24 FMV: trailing RGB columns can be stale while CRTC width stays full - * (MotK 512). Present width is never shrunk — cropping the GL/SDL rect left a - * flickering black pillar when upload coverage varied. - * - * Always black-fill the last 8 RGB cols. Also blank beyond the tracked FB A0 - * span when it falls short of the CRTC (movie cut / partial cover). Do NOT - * replicate the last good column — that smeared MotK's starfield into an - * 8-wide streak. */ -static void depth24_fix_trailing_margin(uint32_t *buf, uint32_t w, uint32_t h, - uint32_t display_x) { - if (!buf || w < 8u || h == 0u) return; - uint32_t start = w - 8u; - uint32_t lim = gpu_depth24_rgb_limit(display_x, w); - if (lim > 0u && lim < w && lim < start) - start = lim; - for (uint32_t y = 0; y < h; y++) { - for (uint32_t x = start; x < w; x++) - buf[y * w + x] = 0xFF000000u; - } -} - -static void engage_widescreen(void) { - g_ws_engaged = true; - const bool wide = g_video_aspect_num * 3 != g_video_aspect_den * 4; - int mode = wide ? (g_ws_native_wide ? 2 : 1) : 0; - /* Native-wide: GTE drawn un-squashed — feed it the 4:3 ratio - * (identity squash). Squash mode: feed the real wide aspect. */ - gte_set_display_aspect(mode == 1 ? g_video_aspect_num : 4, - mode == 1 ? g_video_aspect_den : 3); - gpu_ws_configure(g_video_aspect_num, g_video_aspect_den, - g_ws_anchor_addr, g_ws_hud_sprt ? 1 : 0, mode); -} - -/* Engage widescreen at game entry: BIOS boot stays authentic 4:3. */ -static void engage_widescreen_at_game_entry(void) { - extern int fntrace_is_game_started(void); - if (!g_ws_engaged && fntrace_is_game_started()) engage_widescreen(); -} - -/* A state of a started game is no game entry. Told by the game's code in memory, as the entry latch tells it. */ -static void take_loaded_state_for_started(void) { - uint32_t bios = 0, entry = 0; - savestate_get_integrity(&bios, &entry); - if (!dirty_ram_text_image_registered() || !psx_game_text_native_ok(entry)) return; - memory_keep_low_boot_scratch(); /* the latch of a fresh process would clear what the game has written there since */ - if (!g_ws_engaged) engage_widescreen(); /* at the next VBlank the frame the state draws first would be a 4:3 one */ -} - -/* Called from gpu_vblank_tick() at each simulated vblank. */ -static void sdl_vblank_present(void) { - psx_borderless_keep_windowed(); - /* A host UI normally submits its request from the GL render callback near - * the end of this function. Apply on scope exit, after the old-aspect frame - * has been presented, so the next guest frame and its eventual present use - * one coherent aspect. The scope also covers every skipped/early-return - * vblank path. */ - struct PendingAspectBoundary { - ~PendingAspectBoundary() { - apply_pending_display_aspect(); - /* SDL resize events were consumed during this vblank. Commit - * their ratio only after the old-aspect frame has presented so - * projection, packet correction and destination sizing advance - * together for the next guest frame. */ - update_adaptive_widescreen(); - } - } pending_aspect_boundary; -#ifndef PSX_NO_DEBUG_TOOLS - debug_server_set_fmv_quiet(mdec_recently_active(2)); - /* Debug server: pause gate, poll commands, record frame, check watchpoints. */ - debug_server_wait_if_paused(); - debug_server_poll(); - debug_server_record_frame(); - debug_server_check_watchpoints(); - - /* Check debug server input override. */ - int override = debug_server_get_input_override(); -#else - /* Production: skip debug server. Still need to advance frame counter - * locally so anything else that reads it continues to work. */ - extern uint64_t s_frame_count; - s_frame_count++; - int override = -1; -#endif - - runtime_perf_frame_begin(); - RuntimePerfFrameScope runtime_perf_frame_scope; - runtime_perf_diag_tick(); - - /* Lightweight frontend telemetry from PR #13. Count simulated vblanks rather - * than presents so turbo and skipped-frame modes still report game speed. - * Skip during netplay post-load barrier — admit is stalled and the window - * is not updating, so a climbing FPS line is misleading. */ - if (fps_telemetry_enabled() && !psx_netplay_in_load_barrier()) { - extern uint64_t s_frame_count; - const Uint64 now = SDL_GetPerformanceCounter(); - const Uint64 frequency = SDL_GetPerformanceFrequency(); - if (!s_fps_last_time) { - s_fps_last_time = now; - s_fps_last_frame = s_frame_count; - if (sdl_window && s_fps_base_title.empty()) { - const char *title = SDL_GetWindowTitle(sdl_window); - if (title) s_fps_base_title = title; - } - } else if (frequency && now - s_fps_last_time >= frequency) { - const double seconds = (double)(now - s_fps_last_time) / (double)frequency; - const double fps = (double)(s_frame_count - s_fps_last_frame) / seconds; - const double speed = fps / 59.94; - if (!g_headless && sdl_window) { - char title[256]; - snprintf(title, sizeof(title), "%s [%.0f fps %.2fx]", - s_fps_base_title.c_str(), fps, speed); - SDL_SetWindowTitle(sdl_window, title); - } - if (netplay_timing_on() && s_np_timing_frames > 0) { - const double invf = 1000.0 / (double)frequency; - const double admit_ms = - (double)s_np_admit_ticks * invf / (double)s_np_timing_frames; - const double guest_ms = - (double)s_np_guest_ticks * invf / (double)s_np_timing_frames; - std::fprintf(stderr, - "[FPS] game: %.1f fps (%.2fx) | frames: %llu | " - "guest=%.2f ms/f admit=%.2f ms/f (n=%llu)\n", - fps, speed, (unsigned long long)s_frame_count, - guest_ms, admit_ms, (unsigned long long)s_np_timing_frames); - s_np_admit_ticks = 0; - s_np_guest_ticks = 0; - s_np_timing_frames = 0; - } else { - std::fprintf(stderr, "[FPS] game: %.1f fps (%.2fx) | frames: %llu\n", - fps, speed, (unsigned long long)s_frame_count); - } - std::fflush(stderr); - s_fps_last_time = now; - s_fps_last_frame = s_frame_count; - } - } - - /* Host-stack-usage profile sample — frame counter is now current, and we are - * on the guest fiber (see §17 block above). BEFORE the turbo/fast-boot early - * returns so the curve is captured even when presents are skipped. */ - stack_profile_sample(); - - /* Step 2.8 automation ticks — BEFORE the turbo/fast-boot early returns - * below, so capture detection and compile pickup keep running while the - * frontend is skipping presents. Both are cheap and emu-thread-only. */ - { - uint64_t perf_start = runtime_perf_section_begin(); - overlay_autocapture_tick(); - runtime_perf_section_end(perf_start, - &g_runtime_perf.autocapture_ticks); - } - { /* Apply a finished batch compile via the active code provider (gcc: - * cache rescan on done). */ - uint64_t perf_start = runtime_perf_section_begin(); - const CodeProvider *cp = code_provider_active(); - if (cp->poll_main) cp->poll_main(); - runtime_perf_section_end(perf_start, - &g_runtime_perf.provider_poll_ticks); - } - - if (!g_headless) { - /* Pump SDL events to prevent window freeze. */ - SDL_Event ev; - while (SDL_PollEvent(&ev)) { - const int ui_consumed = psx_host_ui_handle_event(&ev); - if (ev.type == SDL_QUIT) { - if (psx_netplay_active()) { - netplay_soft_exit("sdl_window_close"); - return; - } - psx_crash_trace_set_exit_origin("sdl_window_close"); - shutdown_runtime(); - std::exit(0); - } else if (ev.type == SDL_CONTROLLERDEVICEADDED) { - refresh_player_devices(); - } else if (ev.type == SDL_CONTROLLERDEVICEREMOVED) { -#if defined(PSX_SDL3) - if (ev.gdevice.which == g_players[0].instance || - ev.gdevice.which == g_players[1].instance) { -#else - if (ev.cdevice.which == g_players[0].instance || - ev.cdevice.which == g_players[1].instance) { -#endif - close_controller(); - refresh_player_devices(); - } -#ifdef PSX_HAS_DISRUPTOR_NAME_ENTRY - } else if (ev.type == SDL_KEYUP) { -#if defined(PSX_SDL3) - (void)disruptor_name_entry_key((int)ev.key.scancode, (uint32_t)ev.key.key, 0, 1); -#else - (void)disruptor_name_entry_key((int)ev.key.keysym.scancode, (uint32_t)ev.key.keysym.sym, 0, 1); -#endif -#endif - } else if (!ui_consumed && ev.type == SDL_KEYDOWN) { -#if defined(PSX_SDL3) - const SDL_Keymod mod = ev.key.mod; - const SDL_Keycode key = ev.key.key; - const SDL_Scancode scancode = ev.key.scancode; -#else - const Uint16 mod = ev.key.keysym.mod; - const SDL_Keycode key = ev.key.keysym.sym; - const SDL_Scancode scancode = ev.key.keysym.scancode; -#endif - if (key == SDLK_ESCAPE && psx_netplay_active()) { - netplay_soft_exit("netplay_escape"); - return; - } -#ifdef PSX_HAS_DISRUPTOR_NAME_ENTRY - if (disruptor_name_entry_key((int)scancode, (uint32_t)key, 1, !(mod & (KMOD_CTRL | KMOD_ALT | KMOD_GUI)))) - continue; /* the key is a letter of a save's name */ -#else - (void)scancode; -#endif - /* Save states: Shift+F1-F12 = save slot 0-11, F1-F12 = load. - * (F11 is a save slot per the user's spec, so fullscreen is - * Alt+Enter / Cmd+Ctrl+F only — no F11.) */ - if (key >= SDLK_F1 && key <= SDLK_F12) { - int slot = (int)(key - SDLK_F1); /* 0..11 */ - if (psx_netplay_active()) { - /* Match host only — guest F-keys must not initiate. */ - if (!psx_netplay_is_host()) { - /* ignore */ - } else if (mod & KMOD_SHIFT) { - (void)psx_netplay_request_save(slot); - } else { - (void)psx_netplay_request_load(slot); - } - } else if (mod & KMOD_SHIFT) { - savestate_request_save(slot); - } else { - savestate_request_load(slot); - } - } - else if (key == SDLK_c && (mod & KMOD_CTRL)) { - std::fprintf(stdout, "[DEBUG] Forzando reinserción de CD...\n"); - debug_force_cd_reinsert(); - } - /* Fullscreen toggle: Alt+Enter or Cmd/Ctrl+F. Toggles between - * windowed and the CONFIGURED tri-state mode (g_fullscreen: 1 - * borderless desktop fullscreen keeping the desktop resolution - * and letterboxing the image, or 2 exclusive fullscreen — a - * real display-mode change). SDL_WINDOW_FULLSCREEN's bit is - * set in both SDL_WINDOW_FULLSCREEN and - * SDL_WINDOW_FULLSCREEN_DESKTOP, so testing just that bit - * detects "currently fullscreen, either mode". */ - else if ((key == SDLK_RETURN && (mod & KMOD_ALT)) || - (key == SDLK_f && (mod & (KMOD_GUI | KMOD_CTRL)))) { - const int current = - psx_window_fullscreen_mode(sdl_window); - /* If the configured mode is "off", the hotkey still - * needs a mode to switch INTO — default to borderless, - * matching the historical (pre-tri-state) behaviour. - * Deliberately do not update g_fullscreen here: hotkey - * state is temporary and is not an implicit preference. */ - const int target = current ? 0 : - (g_fullscreen ? g_fullscreen : 1); - if (!psx_apply_window_fullscreen_mode( - sdl_window, target)) { - std::fprintf(stderr, - "psxrecomp: fullscreen hotkey failed: %s\n", - SDL_GetError()); - } - } - } - } - } - - if (g_gl_active) { - const uint32_t ui_flags = psx_host_ui_capture_flags(); - /* Visible UI must stay on the main GL context even if a provider omits - * the explicit suspension flag. The interpolation thread never calls - * the host render hook. */ - gl_renderer_set_host_ui_suspended( - (ui_flags & (PSX_HOST_UI_VISIBLE | - PSX_HOST_UI_SUSPEND_INTERPOLATION)) != 0); - } - - /* Sample each player's device and feed the matching SIO pad slot. - * Debug server input override (when active) drives port 1 only. With - * g_low_latency_input this early sample is re-done after the pacer wait - * (below) for the interactive present path; it still covers the turbo / - * FMV-skip paths that early-return before pacing. - * - * Delay-sync netplay host FPS (MotK FMV): finish → present → admit/pace. - * Local Swap overlaps the peer's guest quantum; admit still runs every - * tick via the RAII tail (including early returns that skip present). - * Offline keeps pace-before-present. Barrier wait stays UDP poll — do - * not pair this order with SDL_Delay(0) busy-spin (tick-0 hang). */ - struct NetplayVblankTail { - int override_ = -1; - bool active_ = false; - bool skip_pace_ = false; - explicit NetplayVblankTail(int ov) - : override_(ov), active_(psx_netplay_active() != 0) {} - void skip_pace() { skip_pace_ = true; } - ~NetplayVblankTail() { - if (!active_) return; - if (psx_return_to_lobby_requested()) return; - netplay_barrier_admit(override_); - if (skip_pace_ || psx_return_to_lobby_requested()) return; - uint64_t perf_start = runtime_perf_section_begin(); - if (g_frame_period_ms > 0.0) - frame_pacer_wait(&s_frame_pacer, g_frame_period_ms); - runtime_perf_section_end(perf_start, &g_runtime_perf.pacer_ticks); - latency_ring_mark(LAT_PACED); - } - } netplay_tail(override); - -#ifdef PSX_HAS_DISRUPTOR_CAPSULE - if (!psx_netplay_active()) disruptor_capsule_before_input(); -#endif - if (psx_netplay_active()) { - psx_netplay_finish_frame(); - } else if (g_headless) { - sample_headless_pad_into_sio(override); - } else { - sample_pad_into_sio(override); - } -#ifdef PSX_HAS_DISRUPTOR_CAPSULE - if (!psx_netplay_active()) disruptor_capsule_after_input(); -#endif - - /* Latency ring: open this present cycle's slot, stamping when input was - * sampled into SIO. Always-on; queried via the debug server "latency". */ - latency_ring_frame_begin(); - - /* Turbo-active test shared by the pacing/present gate below. */ - int turbo_loads_active = 0; - extern int fntrace_is_game_started(void); - int logical_load_active = fntrace_is_game_started() && cdrom_load_in_progress(); - int load_run_value = 0; - static int load_run = 0; - static int release_run = 0; - if (g_turbo_loads_enabled && !psx_netplay_active()) { - /* Require a short sustained predicate on entry, then retain turbo over - * a short false gap after it has engaged. Netplay keeps wall pacing. */ - if (logical_load_active) { - if (load_run < (1 << 20)) load_run++; - if (load_run >= TURBO_LOADS_ENGAGE_FRAMES || release_run > 0) { - turbo_loads_active = 1; - release_run = g_turbo_load_release_frames; - } - } else { - load_run = 0; - if (release_run > 0) { - turbo_loads_active = 1; - release_run--; - } - } - load_run_value = load_run; - } else { - load_run = 0; - release_run = 0; - } - /* HLE boot-skip window: from reset until the game entry PC first - * dispatches, run unpaced so the (shell-skipped) BIOS kernel init + - * SYSTEM.CNF + game EXE load compress to host speed. This replaces the - * old fast_boot snapshot restore; all guest timing is authentic. */ - if (psx_bios_hle_boot_turbo_active()) - turbo_loads_active = 1; - - load_transition_note(cdrom_data_read_active(), logical_load_active, - turbo_loads_active, load_run_value); - - /* FMV auto-skip ([video] auto_skip_fmv). A streaming FMV is XA audio + MDEC - * video together. Detect "MDEC produced a frame since the last vblank AND XA - * is streaming"; a short hold rides out brief inter-frame gaps. The present/ - * pacing safety-net + audio mute below hide the one or two transition frames. - * - * End the movie the GAME's own way, chosen per game: - * - fmv_skip_total_table set (Tomba): write the CURRENT movie's per-movie - * frame-total down to fmv_skip_end_total, so the player's MDEC loop - * (FUN_8001efe8: teardown when streamed frame# >= total - 3) terminates on - * its next frame — a NATURAL end that reaches EVERY movie, including ones - * whose caller never polls the skip button. Only the active movie's table - * entry is touched (via the movie-id byte), so nothing else is disturbed. - * - no table configured (generic): hold START so a movie whose handler polls - * the pad aborts itself (can't reach unskippable movies). */ - int fmv_skip_active = 0; - if (g_auto_skip_fmv) { - uint32_t mc = mdec_get_decode_count(); - int mdec_decoding = (mc != s_fmv_skip_last_mdec); - s_fmv_skip_last_mdec = mc; - int xa = cdrom_xa_stream_active(); - /* fmv_skip_no_xa: silent RAM-preloaded movies (no XA stream) never - * satisfy the MDEC+XA detector; with the per-game opt-in, MDEC - * activity alone qualifies. */ - int detected = mdec_decoding && (xa || g_fmv_skip_no_xa); - if (detected) s_fmv_skip_hold = (!xa && g_fmv_skip_no_xa) - ? g_fmv_skip_no_xa_hold : 4; - else if (s_fmv_skip_hold > 0) s_fmv_skip_hold--; - fmv_skip_active = (s_fmv_skip_hold > 0) && (xa || g_fmv_skip_no_xa); - if (fmv_skip_active) { - if (g_fmv_skip_total_table) { - /* End the active movie via its own frame-count teardown. */ - uint8_t mid = psx_read_byte(g_fmv_skip_movie_id); - psx_write_half(g_fmv_skip_total_table + (uint32_t)mid * 2u, - (uint16_t)g_fmv_skip_end_total); - } else { - /* Generic fallback: hold START (PSX pad word is active-low; START - * is bit 3) so the game's FMV handler aborts the movie itself. */ - sio_set_pad_state_slot(0, (uint16_t)~(1u << 3)); - } - } - } - - const bool module_fast_forward = g_fast_forward_query && - !psx_netplay_active() && g_fast_forward_query() != 0; - -#ifndef PSX_SDL_NO_AUDIO - /* Optional turbo host sink advances the canonical SPU on the exact guest - * sample budget but drops accelerated output before SDL. This is distinct - * from the old mute/freeze model: voice and CD state never pause. FMV skip - * retains the hard mute because it deliberately fast-forwards a movie. */ - sdl_audio_update(fmv_skip_active, - module_fast_forward || - (turbo_loads_active && g_turbo_audio_sink_enabled)); -#endif - - if (g_headless) engage_widescreen_at_game_entry(); /* a run without a window is the same game */ - if (g_headless || module_fast_forward) { - netplay_tail.skip_pace(); - return; - } - - /* TCP turbo is for automated validation and trace capture. It keeps the - * simulation advancing and the debug server polling, but removes frontend - * presentation and wall-clock pacing. */ -#ifndef PSX_NO_DEBUG_TOOLS - if (debug_server_turbo_enabled()) { - netplay_tail.skip_pace(); - return; - } -#endif - - /* Turbo mode: while ] is held, skip both VRAM->ARGB conversion and - * SDL_RenderPresent. The recompiled BIOS still advances simulated - * cycles every vblank, so the BIOS proceeds at whatever rate the host - * CPU sustains without graphics-driver vsync overhead. Present once - * every TURBO_PRESENT_EVERY frames so the user sees visual progress. */ - { - const Uint8* keys = SDL_GetKeyboardState(NULL); - static int turbo_skip = 0; - const int TURBO_PRESENT_EVERY = 30; - if (!(psx_host_ui_capture_flags() & PSX_HOST_UI_CAPTURE_KEYBOARD) && - keys[SDL_SCANCODE_RIGHTBRACKET]) { - turbo_skip = (turbo_skip + 1) % TURBO_PRESENT_EVERY; - if (turbo_skip != 0) { - netplay_tail.skip_pace(); - return; /* skip render this frame */ - } - } else { - turbo_skip = 0; - } - } - - /* Turbo-through-loads (step 4, OPT-IN via game.toml [runtime] - * turbo_loads): while the game is loading — CD data stream active, - * XA/FMV excluded, post-BIOS-handoff only — skip wall-clock pacing and - * most presents so the guest runs at host speed. Step-3 measurement - * showed loads are paced by the game's own per-sector processing at - * real time (2.2-4.8 sectors/frame against a 32-256 IRQ budget), so - * host-speed execution is the lever that compresses load wall-time. - * Presents 1-in-30 so visual progress stays visible. */ - bool turbo_load_paced = false; - if (turbo_loads_active) { - g_turbo_loads_frames++; - /* A mod may request a bounded wall-clock multiplier instead of the - * legacy unpaced host-speed path. Pace every simulated guest frame at - * the divided period; the presentation skip below remains independent - * so rendering cost does not distort the selected multiplier. */ - if (g_turbo_load_wall_multiplier >= 2 && - g_frame_period_ms > 0.0) { - uint64_t perf_start = runtime_perf_section_begin(); - frame_pacer_wait( - &s_frame_pacer, - g_frame_period_ms / (double)g_turbo_load_wall_multiplier); - runtime_perf_section_end( - perf_start, &g_runtime_perf.pacer_ticks); - latency_ring_mark(LAT_PACED); - turbo_load_paced = true; - } - const int TL_PRESENT_EVERY = 30; - s_turbo_present_skip = (s_turbo_present_skip + 1) % TL_PRESENT_EVERY; - if (s_turbo_present_skip != 0) { - netplay_tail.skip_pace(); - return; - } - } - - /* FMV auto-skip: run uncapped (no wall-clock pacing) and suppress nearly all - * presents so the fast-forwarded movie is invisible. Present 1-in-30 only so - * the window keeps pumping and never looks hung during the brief skip. */ - if (fmv_skip_active) { - const int FMV_PRESENT_EVERY = 30; - s_fmv_skip_present_skip = (s_fmv_skip_present_skip + 1) % FMV_PRESENT_EVERY; - if (s_fmv_skip_present_skip != 0) { - netplay_tail.skip_pace(); - return; - } - } - - /* Netplay FMV: skip present every other depth24 vblank (admit still runs - * in the RAII tail). Present-first frame, then alternate. */ - if (psx_netplay_active() && gpu_display_is_depth24()) { - if (s_netplay_depth24_present_skip) { - s_netplay_depth24_present_skip = 0; - netplay_tail.skip_pace(); - return; - } - s_netplay_depth24_present_skip = 1; - } else { - s_netplay_depth24_present_skip = 0; - } - - /* Offline wall-clock pacing before present. Netplay paces in NetplayVblankTail - * AFTER present so Swap overlaps the peer's guest quantum. */ - if (!psx_netplay_active()) { - uint64_t perf_start = runtime_perf_section_begin(); - if (!turbo_load_paced && g_frame_period_ms > 0.0) - frame_pacer_wait(&s_frame_pacer, g_frame_period_ms); - runtime_perf_section_end(perf_start, &g_runtime_perf.pacer_ticks); - latency_ring_mark(LAT_PACED); - - /* Low-latency input: the early sample above is now ~one pacer-wait old. - * Refresh the device state and re-sample right before present so the next - * CPU frame reads near-fresh input (the dominant input->photon cost on a - * vsync-light box). Re-stamp the ring's input mark to measure from here. */ - if (g_low_latency_input) { - SDL_GameControllerUpdate(); /* refresh pad state after the wait */ - SDL_PumpEvents(); /* refresh keyboard state */ - sample_pad_into_sio(override); - latency_ring_restamp_input(); - } - } - - /* Mod hooks. Run after all normal input sampling. */ - mod_call_frame_hooks(); - - /* Depth24 GP1(07h) retarget (MotK intro→crawl): keep the prior Swap for a - * few vblanks so stale trailing VRAM never flashes on the right edge. */ - if (gpu_depth24_present_hold_tick()) - return; - - engage_widescreen_at_game_entry(); - - /* ---- Display from our VRAM ---- */ - uint32_t w = 0, h = 0; - uint32_t present_w = 0; /* display width actually presented (w + native-wide EXTRA) */ - int active_scale = 1; /* hi-res mirror used only for 15-bit display */ - bool fmv_frame = false; /* FMV/boot — present pillarboxed 4:3 in widescreen */ - bool pin_43 = false; /* pillarbox this present (FMV, or a native-wide - game frame that could not present wide) */ - bool depth24_frame = false; - if (s_force_present_after_load && g_gl_active) - gl_renderer_flush_cpu_uploads(); - { - GpuDisplayInfo di; - gpu_get_display_info(&di); - depth24_frame = di.depth24 != 0; - if (di.disabled || di.width == 0 || di.height == 0) { - smooth_60_present(nullptr, 0, 0, false); - present_ring_commit(PRES_PATH_BLANK, (uint16_t)di.width, - (uint16_t)di.height, 0); -#ifndef PSX_SDL_NO_RENDER - /* After savestate load, always restage once even if we blanked - * earlier in the session — otherwise the window keeps a stale - * pre-load frame while vblanks (and FPS) keep advancing. */ - if (!s_disabled_frame_presented || s_force_present_after_load || - psx_host_ui_gl_visible()) { - s_disabled_frame_presented = true; - s_force_present_after_load = false; - if (g_gl_active) { - gl_renderer_present_blank(); - } else if (g_vk_active) { - vk_renderer_present_blank(); - } else { - SDL_SetRenderDrawColor(sdl_renderer, 0, 0, 0, 255); - SDL_RenderClear(sdl_renderer); - SDL_RenderPresent(sdl_renderer); - } - } -#endif - return; - } - s_disabled_frame_presented = false; - s_force_present_after_load = false; - w = di.width; h = di.height; - /* 4:3-pinned frames: the pre-game BIOS boot, plus (once engaged) every - * frame the widescreen layer presents native — FMV video and full-2D - * menu/title screens. gpu_ws_present_native_43() is the single source - * of truth, shared with the GTE/GPU squash so content and present stay - * locked: we squash IFF we stretch. depth24 always classifies as FMV - * even if the ws layer is not engaged yet (4:3 titles). */ - fmv_frame = di.depth24 || !g_ws_engaged || gpu_ws_displayed_native_43() != 0; - /* MDEC movies are already decoded at their authored cadence and are - * CPU/upload heavy. High-refresh crossfades only contend with decoding - * and can starve audio, so present native-4:3/MDEC phases directly. - * The classification catches the transition frame before the first - * decode; the activity stamp also covers authentic 4:3 configurations. */ - if (g_gl_active) - gl_renderer_set_interpolation_suspended( - fmv_frame || mdec_recently_active(2) || gpu_ws_displayed_menu() != 0); - - /* Canonical present width. Native-wide does NOT widen the canonical read - * (that bled across adjacent framebuffers); it composites into a separate - * wide surface and presents from there instead (see gpu wide compositor). */ - present_w = w; - - /* A native-wide frame and the same-width high-precision experiment both - * present from an independent compositor surface. Native-wide adds - * reveal columns; classic squash mode keeps `present_w == w` and uses - * the second surface solely to isolate visual correction from VRAM. */ - const bool corrected_present = - (!fmv_frame && !di.depth24 && g_ws_engaged && - gpu_geometry_correction_enabled() && gr_wide_supported()); - bool wide_present = (!fmv_frame && !di.depth24 && g_ws_engaged && - gr_wide_supported() && !gpu_ws_displayed_flat() && - (ws_native_wide_active() || corrected_present)); - if (wide_present && ws_native_wide_active()) - present_w = w + (uint32_t)ws_nw_extra(); - - /* Native-wide invariant: canonical (320-wide) content is NEVER - * stretched across the wide window — a game frame that cannot present - * wide (compositor unsupported, or the surface fallback below) - * pillarboxes 4:3 like FMV/menus instead. Only squash mode (1) may - * stretch: its canonical content is pre-squashed FOR the stretch. */ - const bool nw_pin = g_ws_engaged && g_ws_native_wide; - - /* Ring the classification now that it's final (only the software/CPU - * wide path below can still fall back — it amends this entry). A - * CANONICAL game frame on a widescreen window IS the stretch. */ - PresRingEntry* pres_entry = present_ring_commit( - fmv_frame ? PRES_PATH_NATIVE_43 - : (wide_present ? PRES_PATH_WIDE : PRES_PATH_CANONICAL), - (uint16_t)w, (uint16_t)h, (uint16_t)present_w); - - /* OpenGL: 15-bit frames ALWAYS present straight from the authoritative - * VRAM FBO — one deterministic path (the old per-frame FBO-vs-CPU - * alternation caused the menu seam/jitter). 24-bit (FMV) frames and - * the PSX_GL_FORCE_CPU_PRESENT diagnostic use the CPU readout below. - * depth24: do NOT sync_cpu (FBO readback can clobber packed RGB888) and - * do NOT flush_cpu_uploads (MDEC already wrote the CPU mirror; forcing - * FBO uploads every frame cut MotK intro from ~50 to ~30 FPS). */ -#ifndef PSX_SDL_NO_RENDER - if (g_gl_active && g_gl_fbo_present && !di.depth24) { - if (wide_present) { - /* GPU-direct native-wide present: blit the displayed buffer's - * wide FBO straight to the window (GPU-side, like the canonical - * present_vram). This avoids the glFinish + glReadPixels CPU - * round-trip the old path did EVERY frame — that readback was the - * GL-only slowdown (SW's wide path is pure CPU, no GPU sync, so - * SW stayed smooth). Falls through to the CPU readout path only if - * the wide surface for this buffer doesn't exist yet. */ - if (gl_renderer_present_wide_fbo((int)di.display_x, (int)di.display_y, - (int)h, g_video_aa ? 1 : 0)) - return; - /* A correction/native-wide surface can be unavailable for its - * first frame (allocation failure, an as-yet unknown display - * base, or a live mode transition). The GL renderer's CPU - * mirror is intentionally native-scale even when the FBO uses - * SSAA, so falling through to the generic CPU-hires path would - * upload a 1x image as though it were Nx and show a tiny box. - * Present the authoritative canonical FBO for this frame; the - * compositor can retry on the next one. */ - pres_entry->path = PRES_PATH_CANONICAL; - pres_entry->wide_fellback = 1; - pres_entry->present_w = (uint16_t)w; - gl_renderer_present_vram((int)di.display_x, (int)di.display_y, - (int)w, (int)h, - g_video_aa ? 1 : 0, - nw_pin ? 1 : 0); - return; - } else { - gl_renderer_present_vram((int)di.display_x, (int)di.display_y, - (int)present_w, (int)h, g_video_aa ? 1 : 0, - (fmv_frame || nw_pin) ? 1 : 0); - return; - } - } - if (g_gl_active && !di.depth24) gl_renderer_sync_cpu(); - /* Vulkan owns every frame: 15-bit frames present straight from the GPU - * VRAM image (deterministic blit, no readback), mirroring the GL path; - * 24-bit (FMV) frames go through the CPU present (Phase 3). The Vulkan - * window has no SDL_Renderer, so we must never fall through below. */ - if (g_vk_active) { - if (di.depth24) { - /* 24-bit (FMV): packed RGB lives in the CPU mirror — do NOT - * sync_cpu (FBO readback clobbers RGB888). */ - for (uint32_t y = 0; y < h; y++) - for (uint32_t x = 0; x < present_w; x++) - sdl_pixel_buf[y * present_w + x] = - gpu_display_pixel_argb(&di, x, y); - /* Trailing margin: blank junk cols inside the full-width - * buffer — never shrink present width. */ - depth24_fix_trailing_margin(sdl_pixel_buf, present_w, h, - di.display_x); - vk_renderer_present_cpu(sdl_pixel_buf, (int)present_w, (int)h, - 0 /* nearest */, fmv_frame ? 1 : 0); - } else if (wide_present && - vk_renderer_present_wide((int)di.display_x, (int)di.display_y, - (int)h, g_video_aa ? 1 : 0)) { - /* presented wide */ - } else { - vk_renderer_present_vram((int)di.display_x, (int)di.display_y, - (int)present_w, (int)h, g_video_aa ? 1 : 0, - (fmv_frame || nw_pin) ? 1 : 0); - } - return; - } -#endif - - /* The hi-res mirror is a 15-bit copy of VRAM; 24-bit display (FMV) - * reads packed bytes the mirror can't represent, so fall back to the - * native path for those frames (the present filter still upscales). */ - active_scale = (g_video_scale > 1 && !di.depth24) ? g_video_scale : 1; - - if (wide_present) { - /* The wide compositor surface is at the renderer's REAL internal - * scale, which gr_render_wide_display uses for its readback pitch. - * Use gr_scale() (== that internal scale) rather than g_video_scale: - * on the GL backend g_video_scale can be stale (1) while the renderer - * supersamples at 2, and a mismatch makes the present read the wide - * buffer at the wrong width (a magnified top-left slice). gr_scale() - * is guaranteed to match the readback. Falls back to the canonical - * hires path if the displayed buffer has no surface yet. */ - int s = gr_scale(); - int sw = (int)present_w * s; - int n = gr_render_wide_display(sdl_pixel_buf, (int)(sw * sizeof(uint32_t)), - (int)di.display_x, (int)di.display_y, (int)h); - if (n > 0) { - active_scale = s; - } else { - wide_present = false; - present_w = w; - pres_entry->path = PRES_PATH_CANONICAL; - pres_entry->wide_fellback = 1; - pres_entry->present_w = (uint16_t)present_w; - } - } - /* depth24 (MotK crawl is 128 lines): pin short bands for letterbox even - * when fmv_frame is false on a plain 4:3 window (ws layer not engaged). */ - pin_43 = fmv_frame || di.depth24 || (nw_pin && !wide_present); - if (!wide_present) { - if (active_scale > 1) { - int sw = (int)present_w * active_scale; - gr_render_display_hires(sdl_pixel_buf, (int)(sw * sizeof(uint32_t)), - (int)di.display_x, (int)di.display_y, - (int)present_w, (int)h); - } else { - for (uint32_t y = 0; y < h; y++) { - for (uint32_t x = 0; x < present_w; x++) { - sdl_pixel_buf[y * present_w + x] = gpu_display_pixel_argb(&di, x, y); - } - } - } - } - - /* Depth24 trailing margin: MotK CRTC is 512 RGB but trailing cols - * can be stale. Fix pixels in-place at full present_w — never crop the - * GL/SDL draw width (that caused a flickering black pillar). */ - if (di.depth24 && active_scale == 1 && !wide_present) - depth24_fix_trailing_margin(sdl_pixel_buf, present_w, h, - di.display_x); - - smooth_60_present(sdl_pixel_buf, - present_w * (uint32_t)active_scale, - h * (uint32_t)active_scale, - !g_gl_active && !g_vk_active && !di.depth24 && !fmv_frame); - -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - /* Frame blending (CRT-persistence masker for 30fps double-buffered - * content). Some games (e.g. Crash Bash menus/characters) leave a - * dynamic object in only one of the two display buffers per 30fps cycle, - * so it strobes on/off as the display alternates buffers — the static - * background, present in both, stays put. Real CRTs hid this via phosphor - * persistence; accurate emulators either time it away (cycle accuracy) or - * offer a "blend frames" option that averages the last two presented - * frames. This is the latter: presentation only, game logic untouched. - * Guarded to the plain software present path (no GL/VK/wide/hires/FMV) so - * the buffer is a simple present_w*h ARGB grid. PSX_FRAME_BLEND=0 off. */ - { - static int blend_cfg = -1; /* -1 unresolved, 0 off, 1 on */ - if (blend_cfg < 0) { - const char* e = getenv("PSX_FRAME_BLEND"); - blend_cfg = (e && e[0] == '1') ? 1 : 0; /* default off (masker only) */ - } - const bool simple_sw = !g_gl_active && !g_vk_active && !wide_present && - !di.depth24 && active_scale == 1; - if (blend_cfg && simple_sw && - !g_smooth_60fps.load(std::memory_order_acquire)) { - static uint32_t prev_buf[640 * 512]; - static uint32_t prev_px = 0; - const uint32_t npx = present_w * h; - if (npx <= (uint32_t)(640 * 512)) { - if (prev_px == npx) { - for (uint32_t i = 0; i < npx; i++) { - const uint32_t a = sdl_pixel_buf[i]; - const uint32_t b = prev_buf[i]; - prev_buf[i] = a; /* store this frame (pre-blend) */ - sdl_pixel_buf[i] = - 0xFF000000u | - (((a & 0xFEFEFEu) >> 1) + ((b & 0xFEFEFEu) >> 1)); - } - } else { - /* Resolution changed / first frame: seed, no blend. */ - for (uint32_t i = 0; i < npx; i++) prev_buf[i] = sdl_pixel_buf[i]; - prev_px = npx; - } - } - } - } -#endif - } - - /* Update only the active display rectangle. The backing texture is sized - * 640x512 (times the supersampling factor), while games can switch to - * smaller modes such as 320x224 for FMV; presenting the full texture would - * leave the active image stuck in the upper-left portion of the window. */ -#ifndef PSX_SDL_NO_RENDER - int src_w = (int)present_w * active_scale; - int src_h = (int)h * active_scale; - if (g_gl_active) { - /* OpenGL present: upload the active display rect and draw a full-screen - * quad. SDL_GL_SwapWindow handles vsync; the wall-clock pacer above - * still owns timing. 24-bit (FMV) frames pin to native 4:3. */ - /* FMV: nearest present — linear filtering fringes the right edge of - * low-res 24-bit scanouts into adjacent (often garbage) texels. */ - gl_renderer_present(sdl_pixel_buf, src_w, src_h, - (g_video_aa && !depth24_frame) ? 1 : 0, - pin_43 ? 1 : 0, 0 /* full width */); - } else { - SDL_Rect src = { 0, 0, src_w, src_h }; - SDL_UpdateTexture(sdl_texture, &src, sdl_pixel_buf, - (int)(src_w * sizeof(uint32_t))); - - /* FMV (24-bit) frames are authored 4:3 with no GTE squash to compensate - * the widescreen stretch — pillarbox them at native 4:3 instead. Same for - * native-wide game frames that could not present wide (pin_43): canonical - * content is never stretched across the wide window. */ - int dst_w = pin_43 ? 640 * g_video_scale : g_logical_w; - int dst_h = 480 * g_video_scale; - SDL_Rect dst = { (g_logical_w - dst_w) / 2, 0, dst_w, dst_h }; - /* Match GL: short display bands letterbox inside the 4:3 rect — FMV only - * (depth24), and only genuinely windowed bands (<80% of the field; a - * native short display mode like 216/224 fills the rect). */ - if (pin_43 && depth24_frame && h > 0 && h < 192) { - int content_h = (dst_h * (int)h) / 240; - if (content_h < 1) content_h = 1; - dst.y = (dst_h - content_h) / 2; - dst.h = content_h; - } - SDL_RenderClear(sdl_renderer); - SDL_RenderCopy(sdl_renderer, sdl_texture, &src, &dst); - - /* Vsync self-heal. The renderer is created with PRESENTVSYNC for - * tear-free output, but the wall-clock pacer above already holds - * 59.94 Hz, so driver vsync is redundant for timing. Under some - * driver states (observed: NVIDIA GL with the swap queue wedged) - * SwapBuffers blocks ~1.5 s per present, dragging the whole - * emulation to ~0.7 fps for minutes (freeze dump 1781045865: - * 8/8 main-thread samples inside wglSwapBuffers). If presents - * block pathologically several times in a row, drop driver vsync - * for the rest of the session; our own pacing keeps the rate. */ - { - latency_ring_mark(LAT_SWAP_BEGIN); - const Uint64 t0 = SDL_GetPerformanceCounter(); - SDL_RenderPresent(sdl_renderer); - const Uint64 t1 = SDL_GetPerformanceCounter(); - latency_ring_mark(LAT_SWAP_END); - const Uint64 freq = SDL_GetPerformanceFrequency(); - const Uint64 present_ms = (t1 >= t0 && freq) ? ((t1 - t0) * 1000u) / freq : 0; - if (!g_present_vsync_disabled && present_ms > 250) { - g_present_slow_count++; - if (g_present_slow_count >= 3 && - disruptor_sdl_render_set_vsync_compat(sdl_renderer, 0) == 0) { - g_present_vsync_disabled = 1; - } - } - } - } -#endif -} - -#if defined(RECOMP_LAUNCHER) -// Host verification/inspection callbacks for the shared recomp-ui launcher. -// The launcher re-runs these on every disc/memory-card change so the "Disc -// verified" verdict + memcard block grids reflect the real images. The expected -// serial/CRC are passed via these file-scope statics (set just before -// recomp_launcher_run_window) because the C-ABI callback can't capture locals. -namespace { - std::string g_lnch_expected_serial; - uint32_t g_lnch_expected_crc = 0; - bool g_lnch_has_crc = false; - - int ae_disc_verify(const char* disc_path, RecompLauncherCDiscVerify* out) { - if (!disc_path || !disc_path[0] || !out) return 0; - PSXRecompV4::DiscIdentity id = PSXRecompV4::identify_disc( - disc_path, g_lnch_expected_serial, g_lnch_expected_crc, - g_lnch_has_crc, /*compute_crc*/ g_lnch_has_crc); - const std::string& serial = !id.detected_serial.empty() - ? id.detected_serial : g_lnch_expected_serial; - std::snprintf(out->serial, sizeof(out->serial), "%s", serial.c_str()); - std::snprintf(out->region, sizeof(out->region), "%s", id.region.c_str()); - out->iso_ok = id.has_header ? 1 : 0; - // Verdict shown by the launcher. - if (!id.opened || !id.has_header) out->verdict = 3; // bad - else if (id.expected_serial_given && !id.serial_matches) out->verdict = 3; // wrong disc - else if (id.expected_crc_given && id.crc_computed && !id.crc_matches) out->verdict = 2; // warn - else out->verdict = 1; // ok - return 1; - } - - int ae_memcard_inspect(const char* card_path, RecompLauncherCMemcard* out) { - if (!card_path || !card_path[0] || !out) return 0; - MemcardSummary s; - if (memcard_summary_path(card_path, &s) != 0) return 0; - out->valid = s.valid; - out->used_blocks = s.used_blocks; - for (int i = 0; i < 15; ++i) out->block_used[i] = s.block_used[i]; - return 1; - } - - std::string g_lnch_netplay_game_name; - std::filesystem::path g_lnch_settings_path; - std::string g_lnch_lobby_url; - RecompLauncherCNetplayLaunch g_lnch_pending_direct_launch{}; - bool g_lnch_hosting_lan = false; - bool g_lnch_joined_lan = false; - std::string g_lnch_lan_endpoint; - std::string g_lnch_lan_guest_bind; - - struct AeLanLobbyState { - std::string name; - std::string game; - std::string endpoint; - std::string host_name; - std::string joiner_name; - std::string password; - bool started = false; - int host_slot = 0; - }; - - std::filesystem::path ae_np_lan_file() { - return std::filesystem::current_path() / "netplay_lan_lobby.txt"; - } - - bool ae_np_read_lan_state(AeLanLobbyState* state) { - if (!state) return false; - std::ifstream f(ae_np_lan_file()); - if (!f) return false; - std::string started, host_slot; - std::getline(f, state->name); - std::getline(f, state->game); - std::getline(f, state->endpoint); - std::getline(f, state->host_name); - std::getline(f, state->joiner_name); - std::getline(f, started); - std::getline(f, host_slot); - std::getline(f, state->password); - state->started = started == "1"; - state->host_slot = host_slot == "1" ? 1 : 0; - return !state->endpoint.empty(); - } - - bool ae_np_write_lan_state(const AeLanLobbyState& state) { - std::ofstream f(ae_np_lan_file(), std::ios::trunc); - if (!f) return false; - f << state.name << "\n" - << state.game << "\n" - << state.endpoint << "\n" - << state.host_name << "\n" - << state.joiner_name << "\n" - << (state.started ? "1" : "0") << "\n" - << state.host_slot << "\n" - << state.password << "\n"; - return (bool)f; - } - - void ae_np_write_lan_lobby(const char* name, const char* endpoint, - const char* password) { - AeLanLobbyState state; - state.name = name && name[0] ? name : "LAN Lobby"; - state.game = g_lnch_netplay_game_name.empty() ? "PSX" : g_lnch_netplay_game_name; - state.endpoint = endpoint && endpoint[0] ? endpoint : "127.0.0.1:7777"; - state.host_name = psx_lobby_display_name(); - if (state.host_name.empty()) state.host_name = "Host"; - state.password = password ? password : ""; - ae_np_write_lan_state(state); - g_lnch_hosting_lan = true; - g_lnch_joined_lan = false; - g_lnch_lan_endpoint = state.endpoint; - g_lnch_lan_guest_bind.clear(); - } - - int ae_np_read_lan_lobby(RecompLauncherCNetplayLobby* out) { - if (!out) return 0; - AeLanLobbyState state; - if (!ae_np_read_lan_state(&state)) return 0; - std::snprintf(out->lobby_id, sizeof(out->lobby_id), "lan:%s", state.endpoint.c_str()); - std::snprintf(out->name, sizeof(out->name), "LAN - %s", - state.name.empty() ? "Lobby" : state.name.c_str()); - std::snprintf(out->game_name, sizeof(out->game_name), "%s", - state.game.empty() ? "PSX" : state.game.c_str()); - out->player_count = state.joiner_name.empty() ? 1 : 2; - out->max_slots = 2; - out->has_password = state.password.empty() ? 0 : 1; - return 1; - } - - PsxLobbyMatchCaps ae_netplay_caps_from_settings(const RecompLauncherCSettings* s) { - PsxLobbyMatchCaps caps{}; - caps.valid = 1; - switch (s ? s->aspect_index : 0) { - case 2: caps.aspect_num = 21; caps.aspect_den = 9; break; - case 1: caps.aspect_num = 16; caps.aspect_den = 9; break; - default: caps.aspect_num = 4; caps.aspect_den = 3; break; - } - caps.turbo_loads = s ? (s->turbo_loads != 0) : 0; - caps.auto_skip_fmv = s ? (s->auto_skip_fmv != 0) : 0; - caps.input_delay = 2; - return caps; - } - - const char* ae_np_default_url(void*) { - return g_lnch_lobby_url.empty() ? psx_lobby_default_url() : g_lnch_lobby_url.c_str(); - } - - void ae_np_save_identity(const char* player_name, const char* lobby_url) { - if (g_lnch_settings_path.empty()) return; - PSXRecompV4::UserSettings settings = - PSXRecompV4::load_user_settings(g_lnch_settings_path); - if (settings.parse_error) return; - if (player_name && player_name[0]) { - settings.netplay_player_name = player_name; - settings.has_netplay_player_name = true; - } - if (lobby_url && lobby_url[0]) { - settings.netplay_lobby_url = lobby_url; - settings.has_netplay_lobby_url = true; - } - PSXRecompV4::save_user_settings(g_lnch_settings_path, settings); - } - - void ae_np_set_lobby_url(void*, const char* url) { - g_lnch_lobby_url = url && url[0] ? url : psx_lobby_default_url(); - ae_np_save_identity(nullptr, g_lnch_lobby_url.c_str()); - } - - int ae_np_connect(void*) { - psx_lobby_set_game_identity(g_lnch_netplay_game_name.c_str(), PSX_GAME_VERSION); - return psx_lobby_connect(ae_np_default_url(nullptr)); - } - - int ae_np_connected(void*) { - return psx_lobby_connected(); - } - - void ae_np_pump(void*) { - psx_lobby_pump(); - } - - void ae_np_set_player_name(void*, const char* name) { - psx_lobby_set_display_name(name && name[0] ? name : "Player"); - if (name && name[0]) ae_np_save_identity(name, nullptr); - } - - const char* ae_np_player_name(void*) { - return psx_lobby_display_name(); - } - - void ae_np_request_list(void*) { - psx_lobby_request_list(); - } - - int ae_np_list_count(void*) { - RecompLauncherCNetplayLobby lan{}; - return psx_lobby_list_count() + (ae_np_read_lan_lobby(&lan) ? 1 : 0); - } - - int ae_np_list_get(void*, int index, RecompLauncherCNetplayLobby* out) { - if (!out) return 0; - const int remote_count = psx_lobby_list_count(); - if (index >= remote_count) - return ae_np_read_lan_lobby(out); - PsxLobbyRow row{}; - if (!psx_lobby_list_get(index, &row)) return 0; - std::snprintf(out->lobby_id, sizeof(out->lobby_id), "%s", row.lobby_id); - std::snprintf(out->name, sizeof(out->name), "%s", row.name); - std::snprintf(out->game_name, sizeof(out->game_name), "%s", row.game_name); - std::snprintf(out->game_version, sizeof(out->game_version), "%s", row.game_version); - out->player_count = row.player_count; - out->max_slots = row.max_slots; - out->has_password = row.has_password; - return 1; - } - - /* Collect non-loopback IPv4 addresses for Host Lobby IP dropdown. */ - static void ae_np_collect_local_addresses( - std::vector* out) { - if (!out) return; - out->clear(); - auto push = [&](const char* ip, const char* label) { - if (!ip || !ip[0]) return; - if (std::strcmp(ip, "0.0.0.0") == 0 || - std::strcmp(ip, "127.0.0.1") == 0) - return; - for (const auto& existing : *out) { - if (std::strcmp(existing.address, ip) == 0) return; - } - RecompLauncherCNetplayLocalAddress entry{}; - std::snprintf(entry.address, sizeof(entry.address), "%s", ip); - if (label && label[0]) - std::snprintf(entry.label, sizeof(entry.label), "%s", label); - out->push_back(entry); - }; -#ifdef _WIN32 - WSADATA wsa; - WSAStartup(MAKEWORD(2, 2), &wsa); - ULONG flags = GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | - GAA_FLAG_SKIP_DNS_SERVER; - ULONG buf_len = 16 * 1024; - std::vector buf(buf_len); - IP_ADAPTER_ADDRESSES* addrs = - reinterpret_cast(buf.data()); - ULONG rc = GetAdaptersAddresses(AF_INET, flags, nullptr, addrs, &buf_len); - if (rc == ERROR_BUFFER_OVERFLOW) { - buf.resize(buf_len); - addrs = reinterpret_cast(buf.data()); - rc = GetAdaptersAddresses(AF_INET, flags, nullptr, addrs, &buf_len); - } - if (rc != NO_ERROR) return; - for (IP_ADAPTER_ADDRESSES* a = addrs; a; a = a->Next) { - if (a->OperStatus != IfOperStatusUp) continue; - if (a->IfType == IF_TYPE_SOFTWARE_LOOPBACK) continue; - char label[64] = {}; - if (a->FriendlyName) { - WideCharToMultiByte(CP_UTF8, 0, a->FriendlyName, -1, label, - (int)sizeof(label), nullptr, nullptr); - } - for (IP_ADAPTER_UNICAST_ADDRESS* u = a->FirstUnicastAddress; u; - u = u->Next) { - if (!u->Address.lpSockaddr || - u->Address.lpSockaddr->sa_family != AF_INET) - continue; - auto* sin = reinterpret_cast(u->Address.lpSockaddr); - char ip[64] = {}; - if (!inet_ntop(AF_INET, &sin->sin_addr, ip, sizeof(ip))) continue; - push(ip, label); - } - } -#else - struct ifaddrs* ifa = nullptr; - if (getifaddrs(&ifa) != 0 || !ifa) return; - for (struct ifaddrs* i = ifa; i; i = i->ifa_next) { - if (!i->ifa_addr || i->ifa_addr->sa_family != AF_INET) continue; - if (!(i->ifa_flags & IFF_UP) || (i->ifa_flags & IFF_LOOPBACK)) continue; - auto* sin = reinterpret_cast(i->ifa_addr); - char ip[64] = {}; - if (!inet_ntop(AF_INET, &sin->sin_addr, ip, sizeof(ip))) continue; - push(ip, i->ifa_name ? i->ifa_name : ""); - } - freeifaddrs(ifa); -#endif - } - - int ae_np_local_address_get(void*, int index, - RecompLauncherCNetplayLocalAddress* out) { - if (!out || index < 0) return 0; - std::memset(out, 0, sizeof(*out)); - std::vector addrs; - ae_np_collect_local_addresses(&addrs); - if (index >= (int)addrs.size()) return 0; - *out = addrs[(size_t)index]; - return out->address[0] ? 1 : 0; - } - - int ae_np_local_ip(void*, char* out, size_t out_len) { - if (!out || out_len == 0) return 0; - std::vector addrs; - ae_np_collect_local_addresses(&addrs); - if (addrs.empty()) { - std::snprintf(out, out_len, "Unavailable"); - return 0; - } - std::snprintf(out, out_len, "%s", addrs[0].address); - return 1; - } - - int ae_np_external_ip(void*, char* out, size_t out_len) { - if (!out || out_len == 0) return 0; -#ifdef _WIN32 - WSADATA wsa; - WSAStartup(MAKEWORD(2, 2), &wsa); - addrinfo hints{}; - hints.ai_family = AF_INET; - hints.ai_socktype = SOCK_STREAM; - addrinfo* res = nullptr; - if (getaddrinfo("api.ipify.org", "80", &hints, &res) != 0 || !res) - return 0; - SOCKET s = socket(res->ai_family, res->ai_socktype, res->ai_protocol); - if (s == INVALID_SOCKET) { - freeaddrinfo(res); - return 0; - } - int ok = 0; - if (connect(s, res->ai_addr, (int)res->ai_addrlen) != SOCKET_ERROR) { - const char req[] = - "GET / HTTP/1.1\r\n" - "Host: api.ipify.org\r\n" - "Connection: close\r\n\r\n"; - (void)send(s, req, (int)strlen(req), 0); - char resp[1024]; - int n = recv(s, resp, sizeof(resp) - 1, 0); - if (n > 0) { - resp[n] = '\0'; - char* body = strstr(resp, "\r\n\r\n"); - body = body ? body + 4 : resp; - char ip[64] = {}; - int j = 0; - for (int i = 0; body[i] && j < (int)sizeof(ip) - 1; ++i) { - if ((body[i] >= '0' && body[i] <= '9') || body[i] == '.') - ip[j++] = body[i]; - else if (j > 0) - break; - } - if (j > 0) { - std::snprintf(out, out_len, "%s", ip); - ok = 1; - } - } - } - closesocket(s); - freeaddrinfo(res); - return ok; -#else - std::snprintf(out, out_len, "Unavailable"); - return 0; -#endif - } - - /* host_endpoint is in/out (capacity >= 64). recomp-ui applies UDP port - * policy before calling. lan_only: local registry only; else lobby server. */ - int ae_np_create(void*, const char* lobby_name, char* host_endpoint, - const char* password, - const RecompLauncherCSettings* settings, - int lan_only, int max_slots) { - /* The restored two-peer PSX transport does not consume the launcher's - * newer seat-count hint yet. The game profile still caps PSX sessions - * to the supported player count. */ - (void)max_slots; - PsxLobbyMatchCaps caps = ae_netplay_caps_from_settings(settings); - char endpoint[96]; - if (host_endpoint && host_endpoint[0]) - std::snprintf(endpoint, sizeof(endpoint), "%s", host_endpoint); - else - std::snprintf(endpoint, sizeof(endpoint), "0.0.0.0:7777"); - - if (lan_only) { - if (psx_lobby_in_lobby()) - (void)psx_lobby_leave(); - ae_np_write_lan_lobby(lobby_name, endpoint, password); - if (host_endpoint) - std::snprintf(host_endpoint, 96, "%s", endpoint); - return 0; - } - - if (g_lnch_hosting_lan) { - std::error_code ec; - std::filesystem::remove(ae_np_lan_file(), ec); - g_lnch_hosting_lan = false; - } - g_lnch_joined_lan = false; - g_lnch_lan_endpoint.clear(); - g_lnch_lan_guest_bind.clear(); - if (host_endpoint) - std::snprintf(host_endpoint, 96, "%s", endpoint); - return psx_lobby_create(lobby_name && lobby_name[0] ? lobby_name : "Netplay Lobby", - g_lnch_netplay_game_name.c_str(), PSX_GAME_VERSION, - password ? password : "", endpoint, &caps); - } - - /* guest_bind is in/out (capacity >= 64). recomp-ui fills a real UDP port - * (prefer 7778); never advertise :0 to the lobby. */ - int ae_np_join(void*, const char* lobby_id, const char* password, - char* guest_bind) { - char bind_buf[64]; - const char* bind = guest_bind; - const char* colon = (bind && bind[0]) ? std::strrchr(bind, ':') : nullptr; - const unsigned port = (colon && colon[1]) - ? static_cast(std::strtoul(colon + 1, nullptr, 10)) : 0u; - if (!bind || !bind[0] || port == 0u) { - std::snprintf(bind_buf, sizeof(bind_buf), "0.0.0.0:7778"); - bind = bind_buf; - if (guest_bind) - std::snprintf(guest_bind, 64, "%s", bind_buf); - } - if (lobby_id && strncmp(lobby_id, "lan:", 4) == 0) { - AeLanLobbyState state; - if (!ae_np_read_lan_state(&state) || !state.joiner_name.empty()) return -1; - if (state.password != (password ? password : "")) return -2; - state.joiner_name = psx_lobby_display_name(); - if (state.joiner_name.empty()) state.joiner_name = "Player"; - state.started = false; - if (!ae_np_write_lan_state(state)) return -1; - g_lnch_hosting_lan = false; - g_lnch_joined_lan = true; - g_lnch_lan_endpoint = state.endpoint; - g_lnch_lan_guest_bind = bind; - return 0; - } - return psx_lobby_join(lobby_id, password ? password : "", bind); - } - - int ae_np_leave(void*) { - if (g_lnch_hosting_lan) { - std::error_code ec; - std::filesystem::remove(ae_np_lan_file(), ec); - g_lnch_hosting_lan = false; - } else if (g_lnch_joined_lan) { - AeLanLobbyState state; - if (ae_np_read_lan_state(&state)) { - state.joiner_name.clear(); - state.started = false; - ae_np_write_lan_state(state); - } - } - g_lnch_joined_lan = false; - g_lnch_lan_endpoint.clear(); - g_lnch_lan_guest_bind.clear(); - g_lnch_pending_direct_launch = {}; - return psx_lobby_leave(); - } - int ae_np_in_lobby(void*) { - return g_lnch_hosting_lan || g_lnch_joined_lan || psx_lobby_in_lobby(); - } - int ae_np_is_host(void*) { - if (g_lnch_hosting_lan || g_lnch_joined_lan) return g_lnch_hosting_lan ? 1 : 0; - return psx_lobby_is_host(); - } - int ae_np_member_count(void*) { - if (g_lnch_hosting_lan || g_lnch_joined_lan) return 2; - return psx_lobby_member_count(); - } - - int ae_np_member_get(void*, int index, RecompLauncherCNetplayMember* out) { - if (!out) return 0; - if (g_lnch_hosting_lan || g_lnch_joined_lan) { - if (index < 0 || index > 1) return 0; - AeLanLobbyState state; - if (!ae_np_read_lan_state(&state)) return 0; - const bool host = index == 0; - out->slot = host ? state.host_slot : 1 - state.host_slot; - const std::string& name = host ? state.host_name : state.joiner_name; - std::snprintf(out->display_name, sizeof(out->display_name), "%s", name.c_str()); - out->ready = !name.empty(); - out->is_host = host ? 1 : 0; - return 1; - } - PsxLobbyMember mem{}; - if (!psx_lobby_member_get(index, &mem)) return 0; - out->slot = mem.slot; - std::snprintf(out->display_name, sizeof(out->display_name), "%s", mem.display_name); - out->ready = mem.ready; - out->is_host = mem.slot == 0; - return 1; - } - - int ae_np_move_member(void*, int from_slot, int to_slot) { - if (!g_lnch_hosting_lan || from_slot < 0 || from_slot > 1 || - to_slot < 0 || to_slot > 1 || from_slot == to_slot) return -1; - AeLanLobbyState state; - if (!ae_np_read_lan_state(&state)) return -1; - state.host_slot = 1 - state.host_slot; - state.started = false; - return ae_np_write_lan_state(state) ? 0 : -1; - } - - int ae_np_local_ready(void*) { return psx_lobby_local_ready(); } - int ae_np_all_ready(void*) { return psx_lobby_all_ready(); } - int ae_np_set_ready(void*, int ready) { return psx_lobby_set_ready(ready); } - - int ae_np_request_start(void*, const RecompLauncherCSettings* settings) { - if (g_lnch_hosting_lan) { - AeLanLobbyState state; - if (!ae_np_read_lan_state(&state) || state.joiner_name.empty()) return -1; - state.started = true; - return ae_np_write_lan_state(state) ? 0 : -1; - } - PsxLobbyMatchCaps caps = ae_netplay_caps_from_settings(settings); - return psx_lobby_request_start(&caps); - } - - int ae_np_launch_pending(void*) { - if ((g_lnch_hosting_lan || g_lnch_joined_lan) && - !g_lnch_pending_direct_launch.enabled) { - AeLanLobbyState state; - if (ae_np_read_lan_state(&state) && state.started) { - g_lnch_pending_direct_launch = {}; - g_lnch_pending_direct_launch.enabled = 1; - g_lnch_pending_direct_launch.local_slot = g_lnch_hosting_lan - ? state.host_slot : 1 - state.host_slot; - g_lnch_pending_direct_launch.input_player = 0; - g_lnch_pending_direct_launch.session_id = 1; - g_lnch_pending_direct_launch.input_delay = 2; - if (g_lnch_hosting_lan) { - const size_t colon = state.endpoint.rfind(':'); - const char* port = colon == std::string::npos - ? "7777" : state.endpoint.c_str() + colon + 1; - std::snprintf(g_lnch_pending_direct_launch.bind_hostport, - sizeof(g_lnch_pending_direct_launch.bind_hostport), - "0.0.0.0:%s", port); - } else { - std::snprintf(g_lnch_pending_direct_launch.bind_hostport, - sizeof(g_lnch_pending_direct_launch.bind_hostport), "%s", - g_lnch_lan_guest_bind.empty() - ? "0.0.0.0:0" : g_lnch_lan_guest_bind.c_str()); - std::snprintf(g_lnch_pending_direct_launch.peer_hostport, - sizeof(g_lnch_pending_direct_launch.peer_hostport), "%s", - state.endpoint.c_str()); - } - } - } - return g_lnch_pending_direct_launch.enabled || psx_lobby_launch_pending(); - } - void ae_np_clear_launch_pending(void*) { - g_lnch_pending_direct_launch = {}; - psx_lobby_clear_launch_pending(); - } - - int ae_np_fill_launch(void*, RecompLauncherCNetplayLaunch* out) { - if (!out) return 0; - if (g_lnch_pending_direct_launch.enabled) { - *out = g_lnch_pending_direct_launch; - return 1; - } - const PsxLobbyJoinInfo* ji = psx_lobby_join_info(); - if (!ji || !ji->ok) return 0; - const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); - out->enabled = 1; - out->local_slot = ji->local_slot; - out->input_player = 0; - std::snprintf(out->bind_hostport, sizeof(out->bind_hostport), "%s", ji->bind_hostport); - std::snprintf(out->peer_hostport, sizeof(out->peer_hostport), "%s", ji->peer_hostport); - out->session_id = ji->session_id; - out->input_delay = (caps && caps->valid) ? caps->input_delay : 2; - return 1; - } - - const char* ae_np_last_error(void*) { - const PsxLobbyJoinInfo* ji = psx_lobby_join_info(); - return (ji && ji->last_error[0]) ? ji->last_error : nullptr; - } - - void ae_np_clear_last_error(void*) { - PsxLobbyJoinInfo* ji = const_cast(psx_lobby_join_info()); - if (ji) ji->last_error[0] = '\0'; - } - - RecompLauncherCNetplayCallbacks g_lnch_netplay_callbacks = { - nullptr, - ae_np_default_url, - ae_np_set_lobby_url, - ae_np_connect, - ae_np_connected, - ae_np_pump, - ae_np_set_player_name, - ae_np_player_name, - ae_np_request_list, - ae_np_list_count, - ae_np_list_get, - ae_np_local_ip, - ae_np_external_ip, - ae_np_create, - ae_np_join, - ae_np_leave, - ae_np_in_lobby, - ae_np_is_host, - ae_np_member_count, - ae_np_member_get, - ae_np_move_member, - ae_np_local_ready, - ae_np_all_ready, - ae_np_set_ready, - ae_np_request_start, - ae_np_launch_pending, - ae_np_clear_launch_pending, - ae_np_fill_launch, - ae_np_local_address_get, - nullptr, /* kick_member — optional */ - ae_np_last_error, - ae_np_clear_last_error, - }; -} // namespace -#endif - -int main(int argc, char** argv) { - /* Keep startup diagnostics visible even if the process is killed. MSVC's - * CRT rejects a zero-sized _IOLBF buffer via its invalid-parameter handler, - * so use the portable zero-sized _IONBF form for both standard streams. */ - std::setvbuf(stdout, nullptr, _IONBF, 0); - std::setvbuf(stderr, nullptr, _IONBF, 0); - std::fprintf(stderr, "psxrecomp: main() entered\n"); - std::fflush(stderr); - - /* Install crash handlers early so they catch issues during init too. - * Writes psx_last_run_report.json on signal/SEH/atexit/fail-fast. */ - psx_crash_trace_install_handlers(); - - const char* bios_path = PSX_DEFAULT_BIOS_PATH; - const char* game_config_path = nullptr; - const char* disc_override_path = nullptr; - bool bios_from_cli = false; /* CLI --bios/positional wins over settings.toml */ - /* Did the PLAYER choose this BIOS (CLI or settings), as opposed to it - * being the compile-time default? Only a real choice overrides the - * bundled OpenBIOS — see docs/BIOS_SELECTION.md. */ - bool bios_explicit = false; - /* Launcher overrides (mirrors snesrecomp): --launcher forces the GUI back on - * even when [launcher] skip_launcher = true is set; --no-launcher (and the - * PSX_NO_LAUNCHER env) forces it off. --launcher wins if both are given. */ - bool force_launcher = false; - bool force_no_launcher = false; - /* CLI overrides for running several instances side by side (soak fleet). - * These win over any game-config value and, crucially, work for the BIOS - * (which has no [game]-block config schema, so debug_port/renderer can't be - * supplied via --game). -1 = "not set on the CLI". */ - int cli_debug_port = -1; - int cli_renderer = -1; /* 0=software 1=opengl 2=vulkan */ - const char* cli_window_title = nullptr; /* label windows in a fleet */ - const char* cli_memcard_dir = nullptr; /* isolate writable state in a fleet */ - PsxNetplayConfig net_cfg; - psx_netplay_config_defaults(&net_cfg); - psx_netplay_apply_env(&net_cfg); /* CLI flags below win over env */ - /* Parse args. - * --bios override the compile-time BIOS path - * --game load a game config (single source of truth for - * disc / memcard / window title / debug port) - * --disc override the game config disc path - * --debug-port override the TCP debug-server port (multi-instance) - * --memcard-dir override card/save/options state (multi-instance) - * --renderer override the renderer: software|opengl|vulkan - * --launcher force the GUI launcher (overrides skip_launcher) - * --no-launcher skip the GUI launcher (boot straight in) - * --headless skip SDL window/audio; use TCP screenshots/state - * --netplay enable delay-sync LAN (also PSX_NETPLAY=1) - * --net-slot N local player slot (0|1) - * --net-input-player N host device to sample (0=P1, 1=P2; default auto) - * --net-bind H:P local UDP bind (default 0.0.0.0:7777) - * --net-peer H:P peer host:port - * --net-delay N input delay in sim ticks (default 2) - * --net-session-id N must match peer (default 1) - * deprecated alias for --bios - * No --game-root flag: that remains config-driven. */ - for (int i = 1; i < argc; i++) { - if (std::strcmp(argv[i], "--bios") == 0 && i + 1 < argc) { - bios_path = argv[++i]; - bios_from_cli = true; - bios_explicit = true; - } else if (std::strcmp(argv[i], "--game") == 0 && i + 1 < argc) { - game_config_path = argv[++i]; - } else if (std::strcmp(argv[i], "--disc") == 0 && i + 1 < argc) { - disc_override_path = argv[++i]; - } else if (std::strcmp(argv[i], "--debug-port") == 0 && i + 1 < argc) { - cli_debug_port = std::atoi(argv[++i]); - } else if (std::strcmp(argv[i], "--memcard-dir") == 0 && i + 1 < argc) { - cli_memcard_dir = argv[++i]; - } else if (std::strcmp(argv[i], "--renderer") == 0 && i + 1 < argc) { - const char* r = argv[++i]; - if (std::strcmp(r, "software") == 0) cli_renderer = 0; - else if (std::strcmp(r, "opengl") == 0) cli_renderer = 1; - else if (std::strcmp(r, "vulkan") == 0) cli_renderer = 2; - } else if (std::strcmp(argv[i], "--window-title") == 0 && i + 1 < argc) { - cli_window_title = argv[++i]; - } else if (std::strcmp(argv[i], "--launcher") == 0) { - force_launcher = true; - } else if (std::strcmp(argv[i], "--no-launcher") == 0) { - force_no_launcher = true; - } else if (std::strcmp(argv[i], "--headless") == 0) { - g_headless = 1; - force_no_launcher = true; - } else if (std::strcmp(argv[i], "--netplay") == 0) { - net_cfg.enabled = 1; - } else if (std::strcmp(argv[i], "--net-slot") == 0 && i + 1 < argc) { - net_cfg.enabled = 1; - net_cfg.local_slot = std::atoi(argv[++i]); - } else if (std::strcmp(argv[i], "--net-input-player") == 0 && i + 1 < argc) { - net_cfg.enabled = 1; - net_cfg.input_player = std::atoi(argv[++i]); - } else if (std::strcmp(argv[i], "--net-bind") == 0 && i + 1 < argc) { - net_cfg.enabled = 1; - std::snprintf(net_cfg.bind_hostport, sizeof(net_cfg.bind_hostport), "%s", argv[++i]); - } else if (std::strcmp(argv[i], "--net-peer") == 0 && i + 1 < argc) { - net_cfg.enabled = 1; - std::snprintf(net_cfg.peer_hostport, sizeof(net_cfg.peer_hostport), "%s", argv[++i]); - } else if (std::strcmp(argv[i], "--net-delay") == 0 && i + 1 < argc) { - net_cfg.enabled = 1; - net_cfg.input_delay = std::atoi(argv[++i]); - } else if (std::strcmp(argv[i], "--net-session-id") == 0 && i + 1 < argc) { - net_cfg.enabled = 1; - net_cfg.session_id = (uint32_t)std::strtoul(argv[++i], nullptr, 10); - } else if (argv[i][0] != '-') { - /* The positional BIOS alias predates the named CLI. Consume it at - * most once. In particular, never let a stray split argument - * replace an explicit --bios path (for example when a Windows - * launcher fails to quote a multi-word --window-title value). */ - if (!bios_from_cli) { - bios_path = argv[i]; - bios_from_cli = true; - bios_explicit = true; - bios_explicit = true; - } else { - std::fprintf(stderr, - "psxrecomp: ignoring unexpected positional argument after BIOS selection: %s\n", - argv[i]); - } - } - } - if (const char *e = std::getenv("PSX_HEADLESS")) { - if (e[0] && e[0] != '0') { - g_headless = 1; - force_no_launcher = true; - } - } - - std::string default_game_config_storage; - if (!game_config_path) { - std::filesystem::path default_game_config = - resolve_existing_runtime_path(PSX_DEFAULT_GAME_CONFIG_PATH, argv[0]); - if (!default_game_config.empty()) { - default_game_config_storage = default_game_config.string(); - game_config_path = default_game_config_storage.c_str(); - } - } else if (!std::filesystem::path(game_config_path).is_absolute()) { - // An explicit --game with a relative path must ALSO anchor on the exe - // dir, never cwd — otherwise the disc / memcard_dir / game_options.toml - // that resolve against this file's parent silently point at cwd. Resolve - // it the same way the default path is resolved. - std::filesystem::path resolved = - resolve_existing_runtime_path(game_config_path, argv[0]); - if (!resolved.empty()) { - default_game_config_storage = resolved.string(); - game_config_path = default_game_config_storage.c_str(); - } - } - /* Record the resolved config path for the TCP fmv_state report (observability: - * proves which game.toml actually loaded — CLI --game vs the baked default). */ - g_active_config_path = game_config_path; - - std::filesystem::path memcard_dir; - std::filesystem::path memcard1_path; /* explicit slot-1 .mcd (empty => dir/card1.mcd) */ - std::filesystem::path memcard2_path; /* explicit slot-2 .mcd (empty => dir/card2.mcd) */ - bool memcard1_enabled = true; - bool memcard2_enabled = true; - /* [controller] device routing (defaults: P1 keyboard/digital, P2 none). */ - /* Dev builds default Player 1 to the first connected controller ("auto"). - * PSX_DEV_INPUT=1 additionally merges every other controller and the - * keyboard for diagnostic convenience; normal routing stays exclusive. - * Release keeps "keyboard" as its pre-launch default (the launcher assigns - * the selected physical device). */ -#if defined(PSX_DEBUG_TOOLS) - std::string p1_device = "auto"; -#else - std::string p1_device = "keyboard"; -#endif - std::string p2_device = "none"; - int p1_mode = PSXRecompV4::PAD_MODE_HYBRID; - int p2_mode = PSXRecompV4::PAD_MODE_HYBRID; - bool ctrl_allow_hybrid = true; /* game.toml [controller] allow_hybrid; false hides Hybrid in the launcher */ - bool ctrl_lock_mode = false; /* game.toml [controller] lock_mode; true hides the whole pad-mode selector */ - bool ctrl_lock_device = false; /* game.toml [controller] lock_device; true hides the Player controller cards entirely */ - /* The game-DECLARED port modes, captured at game.toml load and immune to the - * settings.toml overrides below. Under lock_mode these are the only valid - * modes (the game supports exactly one pad type), so they are what the - * runtime clamps to and what the launcher locks its selector to. */ - int ctrl_locked_p1_mode = PSXRecompV4::PAD_MODE_HYBRID; - int ctrl_locked_p2_mode = PSXRecompV4::PAD_MODE_HYBRID; - bool ws_offered = true; /* game.toml [widescreen] offer; false hides the launcher toggle + clamps 4:3 */ - bool ws_ultrawide_offered = false; - bool ws_adaptive_view_supported = false; -#ifdef PSX_DISABLE_FRAME_INTERPOLATION - bool frame_interpolation_offered = false; -#else - bool frame_interpolation_offered = true; -#endif - bool skip_fmv_offered = true; - bool turbo_loads_offered = true; - bool vulkan_offered = false; /* game.toml [video] offer_vulkan; developer opt-in for launcher visibility */ - int resolved_deadzone = -1; /* <0 => keep input.ini/runtime default (12000) */ - /* Localization: the effective language (game.toml default -> settings.toml -> - * launcher choice), applied to the translation layer AFTER the launcher runs. - * lang_menu_options drives the launcher's "Localization" dropdown (empty => - * no dropdown; only games that declare [localization].languages get one). */ - std::string resolved_language = "en"; - std::vector lang_menu_options; - std::filesystem::path resolved_disc; - std::string window_title = PSX_WINDOW_TITLE; - uint16_t debug_port = (uint16_t)DEFAULT_DEBUG_PORT; - std::string game_name; - std::string game_id; - std::string game_region; /* game.toml [game] region; empty => derive from game_id serial */ - int game_players = 1; /* game.toml [game] players; launcher hides the P2 row when 1 */ - bool game_has_disc_crc = false; - uint32_t game_disc_crc = 0; - std::string disc_speed; /* "1x" | "2x" | "4x" | "instant" */ - int instant_rate = 0; /* 0 = cdrom.c built-in default */ - std::vector warm_cd_routes; - uint32_t game_entry_pc = 0; - bool fast_boot = false; /* DEPRECATED alias: HLE boot-skip only */ - /* REQUESTED intent, never the granted result: "run the HLE tier" as asked - * for by game.toml / settings.toml / a netplay seed / env. What each of the - * tier's two axes actually gets is derived per BIOS image by - * psx_bios_hle_plan() at bring-up and must NOT be written back here — the - * session_reboot path re-enters that block, and folding a refusal back into - * the request would make a rematch differ from the first launch. */ - bool bios_hle_requested = false; /* [runtime] bios_hle (bios_hle.c) */ - bool bios_hle_keep_intro = false; - /* Text-image guard source, captured at config load; armed after the disc - * path is resolved (arm_text_image_guard). */ - std::string text_guard_exe_path; - uint32_t text_guard_load_addr = 0; - uint32_t text_guard_text_size = 0; - - if (game_config_path) { - try { - const auto gc = PSXRecompV4::load_game_config(game_config_path); - game_name = gc.name; - game_id = gc.id; - game_region = gc.region; - game_players = gc.players; - game_has_disc_crc = gc.has_disc_crc; - game_disc_crc = gc.disc_crc; - if (!gc.discs.empty()) resolved_disc = gc.discs.front(); - if (gc.runtime.has_memcard_dir) memcard_dir = gc.runtime.memcard_dir; - if (gc.runtime.has_window_title) window_title = gc.runtime.window_title; - if (gc.runtime.has_debug_port) debug_port = gc.runtime.debug_port; - /* On-the-fly string translation / localization (framework feature — - * text_xlate.cpp): load translations/ *.toml under the project root - * and select the language. Capture inventory is always-on; APPLY is - * gated by language + table presence. See docs/STRING_TRANSLATION.md. */ - text_xlate_init(gc.project_root.string().c_str(), - gc.runtime.language.c_str()); - resolved_language = gc.runtime.language; /* launcher/settings may override */ - lang_menu_options = gc.runtime.languages; /* launcher localization dropdown */ - if (gc.runtime.has_disc_speed) disc_speed = gc.runtime.disc_speed; - if (gc.runtime.has_instant_max_per_frame) - instant_rate = gc.runtime.instant_max_per_frame; - warm_cd_routes = gc.runtime.warm_cd_routes; - if (gc.runtime.turbo_loads) { - g_turbo_loads_enabled = 1; - std::fprintf(stdout, "psxrecomp: turbo_loads enabled (opt-in)\n"); - } - if (gc.runtime.turbo_audio_sink) { - g_turbo_audio_sink_enabled = 1; - std::fprintf(stdout, - "psxrecomp: turbo_audio_sink enabled (opt-in)\n"); - } - { - extern int g_idle_skip_enabled; - const char *idle_env = std::getenv("PSX_IDLE_SKIP"); - g_idle_skip_enabled = idle_env - ? (idle_env[0] == '1' ? 1 : 0) - : (gc.runtime.idle_skip ? 1 : 0); - std::fprintf(stdout, "psxrecomp: idle_skip %s%s\n", - g_idle_skip_enabled ? "enabled" : "disabled", - idle_env ? " (environment override)" : ""); - psx_precise_slice_init_from_env(); - } - for (uint32_t site : gc.vsync_event_horizon_sites) - psx_vsync_query_hle_add_event_horizon_site(site); - for (uint32_t site : gc.vsync_event_horizon_extra_sites) - psx_vsync_query_hle_add_extra_event_horizon_site(site); - g_video_scale = gc.runtime.video_supersampling; - g_video_aa = gc.runtime.video_antialiasing; - g_video_texfilter = gc.runtime.video_texture_filter; - g_video_renderer = gc.runtime.video_renderer; - g_video_screen = gc.runtime.video_screen_kind; - g_video_aspect_num = gc.runtime.video_aspect_num; - g_video_aspect_den = gc.runtime.video_aspect_den; - g_low_latency_input = gc.runtime.video_low_latency_input ? 1 : 0; - g_video_vsync = gc.runtime.video_vsync; -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - g_frame_interpolation = gc.runtime.video_frame_interpolation ? 1 : 0; - g_frame_interpolation_fps = gc.runtime.video_frame_interpolation_fps; -#endif - g_fmv_skip_total_table = gc.runtime.video_fmv_skip_total_table; - g_fmv_skip_movie_id = gc.runtime.video_fmv_skip_movie_id; - if (gc.runtime.video_fmv_skip_end_total) - g_fmv_skip_end_total = gc.runtime.video_fmv_skip_end_total; - g_fmv_skip_no_xa = gc.runtime.video_fmv_skip_no_xa ? 1 : 0; - g_fmv_skip_no_xa_hold = gc.runtime.video_fmv_skip_no_xa_hold; - g_ws_anchor_addr = gc.ws_sprite_anchor_addr; - g_ws_hud_sprt = gc.ws_hud_sprt_squash; - gpu_ws_set_auto_ui_squash(gc.ws_auto_ui_squash ? 1 : 0); - /* [widescreen] full_2d — opt a pure-2D sprite game (MMX6) into the - * widescreen present path. Applied to the GPU layer up front so the - * ws engage at game entry classifies every frame as gameplay. */ - gpu_ws_set_full_2d(gc.ws_full_2d ? 1 : 0); - /* [widescreen.bg2d] engine tile-ring layout for the freshness - * refill shared by the MMX5/MMX6 2D background hook. */ - gpu_ws_bg2d_configure(gc.ws_bg2d_layer_base, - gc.ws_bg2d_ring_base, - gc.ws_bg2d_map_size_addr, - gc.ws_bg2d_layer_stride_addr, - gc.ws_bg2d_ring_cols, - gc.ws_bg2d_layer_count, - gc.ws_bg2d_layer_struct_stride, - gc.ws_bg2d_packet_cap); - /* [widescreen] gte_game_mode — 3D-title gameplay detector (Ape). */ - gpu_ws_set_gte_game_mode(gc.ws_gte_game_mode ? 1 : 0); - gpu_ws_set_gameplay_state_gate( - gc.ws_gameplay_state_addr, - gc.ws_gameplay_state_values.data(), - (int)gc.ws_gameplay_state_values.size()); - /* Keep titles with known native-wide regressions on the original - * projection-squash + stretched-present widescreen path. */ - g_ws_native_wide = gc.ws_native_wide ? 1 : 0; - /* [widescreen] nw_hud_corners — push HUD to the true wide corners. */ - gpu_ws_set_nw_hud_corners(gc.ws_nw_hud_corners ? 1 : 0); - /* Targeted left-HUD packet range — avoids shifting 2D scenery. */ - gpu_ws_set_nw_left_hud_packet_range(gc.ws_nw_left_hud_packet_lo, - gc.ws_nw_left_hud_packet_hi); - /* [widescreen] nw_backdrop — stretch full-frame 2D sky backdrop. */ - gpu_ws_set_nw_backdrop(gc.ws_nw_backdrop ? 1 : 0); - /* [widescreen] nw_flat_backdrop — stretch flat sky/backdrop prims - * in the native-wide mirror, preserving the canonical 4:3 image. */ - gpu_ws_set_nw_flat_backdrop(gc.ws_nw_flat_backdrop ? 1 : 0); - /* [widescreen] nw_phase_backdrop — stretch only the textured - * backdrop phase emitted before shaded 3D foreground geometry. */ - gpu_ws_set_nw_phase_backdrop(gc.ws_nw_phase_backdrop ? 1 : 0); - gpu_ws_set_nw_textured_edges(gc.ws_nw_textured_edges ? 1 : 0, - gc.ws_nw_textured_edge_scale); - gl_renderer_set_wide_fast(gc.ws_nw_full_mirror ? 0 : 1); - { - std::vector addresses, expected; - addresses.reserve(gc.ws_signed_x_bound_sites.size()); - expected.reserve(gc.ws_signed_x_bound_sites.size()); - for (const auto& site : gc.ws_signed_x_bound_sites) { - addresses.push_back(site.address); - expected.push_back(site.expected); - } - gpu_ws_set_signed_x_bound_sites(addresses.data(), expected.data(), - (int)addresses.size()); - } - /* [widescreen] clear_reveal — enable opted-in scene/map-boundary - * cleanup of synthetic native-wide margins. */ - gpu_ws_set_clear_reveal(gc.ws_clear_reveal ? 1 : 0); - gpu_ws_set_cull_guard_pixels(gc.ws_cull_guard_pixels); - gpu_ws_set_activation_guard_pixels( - gc.ws_cull_activation_guard_pixels); - gpu_ws_set_explicit_cull_sites( - gc.ws_cull_bias_sites.data(), (int)gc.ws_cull_bias_sites.size(), - gc.ws_cull_slti_sites.data(), (int)gc.ws_cull_slti_sites.size(), - gc.ws_cull_range_sites.data(), (int)gc.ws_cull_range_sites.size()); - gpu_ws_set_slti_lower_cull_sites( - gc.ws_cull_slti_lower_sites.data(), - (int)gc.ws_cull_slti_lower_sites.size()); - gpu_ws_set_negsub_cull_sites( - gc.ws_cull_negsub_sites.data(), (int)gc.ws_cull_negsub_sites.size()); - gpu_ws_set_vxrange_cull_sites( - gc.ws_cull_vxrange_sites.data(), (int)gc.ws_cull_vxrange_sites.size()); - gpu_ws_set_depth_cull_sites( - gc.ws_cull_depth_sites.data(), (int)gc.ws_cull_depth_sites.size()); - gpu_ws_set_plane_nx_sites( - gc.ws_cull_plane_nx_sites.data(), (int)gc.ws_cull_plane_nx_sites.size()); - gpu_ws_set_xclip_load_sites( - gc.ws_cull_xclip_load_sites.data(), (int)gc.ws_cull_xclip_load_sites.size()); - { - std::vector addresses, expected, results; - addresses.reserve(gc.ws_cull_keep_sites.size()); - expected.reserve(gc.ws_cull_keep_sites.size()); - results.reserve(gc.ws_cull_keep_sites.size()); - for (const auto& site : gc.ws_cull_keep_sites) { - addresses.push_back(site.address); - expected.push_back(site.expected); - results.push_back(site.result); - } - gpu_ws_set_cull_keep_sites( - addresses.data(), expected.data(), results.data(), - (int)addresses.size()); - } - { - std::vector addresses, expected; - addresses.reserve(gc.ws_cull_angle_sites.size()); - expected.reserve(gc.ws_cull_angle_sites.size()); - for (const auto& site : gc.ws_cull_angle_sites) { - addresses.push_back(site.address); - expected.push_back(site.expected); - } - gpu_ws_set_angle_sites( - addresses.data(), expected.data(), (int)addresses.size()); - } - { - std::vector addresses, expected, thresholds; - std::vector object_regs, x_regs, z_regs, y_regs; - std::vector queue_guards; - addresses.reserve(gc.ws_aspect_cone.sites.size()); - expected.reserve(gc.ws_aspect_cone.sites.size()); - thresholds.reserve(gc.ws_aspect_cone.sites.size()); - object_regs.reserve(gc.ws_aspect_cone.sites.size()); - x_regs.reserve(gc.ws_aspect_cone.sites.size()); - z_regs.reserve(gc.ws_aspect_cone.sites.size()); - y_regs.reserve(gc.ws_aspect_cone.sites.size()); - queue_guards.reserve(gc.ws_aspect_cone.sites.size()); - const auto effective_reg = [](uint32_t site_reg, - uint32_t default_reg) { - return site_reg == 0xFFFFFFFFu - ? default_reg : site_reg; - }; - for (const auto& site : gc.ws_aspect_cone.sites) { - addresses.push_back(site.address); - expected.push_back(site.expected); - thresholds.push_back(site.cosine_threshold); - object_regs.push_back(effective_reg( - site.object_reg, gc.ws_aspect_cone.object_reg)); - x_regs.push_back(effective_reg( - site.x_reg, gc.ws_aspect_cone.x_reg)); - z_regs.push_back(effective_reg( - site.z_reg, gc.ws_aspect_cone.z_reg)); - y_regs.push_back(effective_reg( - site.y_reg, gc.ws_aspect_cone.y_reg)); - queue_guards.push_back(site.queue_guard ? 1u : 0u); - } - gpu_ws_set_aspect_cone( - addresses.data(), expected.data(), thresholds.data(), - object_regs.data(), x_regs.data(), z_regs.data(), - y_regs.data(), queue_guards.data(), (int)addresses.size(), - gc.ws_aspect_cone.forward_addr, - gc.ws_aspect_cone.object_type_offset, - gc.ws_aspect_cone.hysteresis_pixels, - gc.ws_aspect_cone.queue_reserve, - gc.ws_aspect_cone.queue_count_addrs.data(), - gc.ws_aspect_cone.queue_capacities.data(), - gc.ws_aspect_cone.queue_type_masks.data()); - } - gte_ws_configure_dome_sites( - gc.ws_dome_call_sites.data(), (int)gc.ws_dome_call_sites.size()); - /* [widescreen.cull] per-game gates + signature immediates for the - * pattern-scanned interp widen hooks. A title that never opted in - * must never have its live code scanned and rewritten. */ - gpu_ws_set_auto_hooks(gc.ws_auto_screen_x_cull ? 1 : 0, - gc.ws_auto_backdrop_preload ? 1 : 0); - if (!gc.ws_cull_w_imms.empty() || !gc.ws_cull_h_imms.empty()) - gpu_ws_set_cull_imms(gc.ws_cull_w_imms.data(), (int)gc.ws_cull_w_imms.size(), - gc.ws_cull_h_imms.data(), (int)gc.ws_cull_h_imms.size()); - ws_offered = gc.ws_offered; - ws_ultrawide_offered = gc.ws_ultrawide_offered; - ws_adaptive_view_supported = gc.ws_adaptive_view; -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - frame_interpolation_offered = - gc.runtime.video_offer_frame_interpolation; -#endif - skip_fmv_offered = gc.runtime.video_offer_skip_fmv; - turbo_loads_offered = gc.runtime.offer_turbo_loads; - vulkan_offered = gc.vulkan_offered; - /* Register the [widescreen.backdrop] store PCs so the dirty-RAM - * interpreter applies the backdrop screenX squash on the interp - * path (overlay backdrop handlers run interpreted when no cache - * DLL is loaded — the recompiler emit only covers native). */ - if (!gc.ws_backdrop_x_sites.empty()) - psx_ws_set_backdrop_sites(gc.ws_backdrop_x_sites.data(), - (int)gc.ws_backdrop_x_sites.size()); - g_audio_spu_hq = gc.runtime.audio_spu_hq; - g_audio_buffer_ms = (double)gc.runtime.audio_buffer_ms; - g_auto_skip_fmv = gc.runtime.video_auto_skip_fmv ? 1 : 0; - if (gc.runtime.has_parappa_timing) { - parappa_apply_timing_setting( - gc.runtime.parappa_timing_mode, - gc.runtime.parappa_timing_extra_early, - gc.runtime.parappa_timing_extra_late, - "game.toml"); - } - /* [controller] game-declared input defaults (settings.toml/launcher - * still override below). */ - if (gc.runtime.has_default_mode) { - p1_mode = gc.runtime.default_p1_mode; - p2_mode = gc.runtime.default_p2_mode; - } - ctrl_locked_p1_mode = gc.runtime.default_p1_mode; - ctrl_locked_p2_mode = gc.runtime.default_p2_mode; - ctrl_allow_hybrid = gc.runtime.controller_allow_hybrid; - ctrl_lock_mode = gc.runtime.controller_lock_mode; - ctrl_lock_device = gc.runtime.controller_lock_device; - if (gc.runtime.has_deadzone) resolved_deadzone = gc.runtime.deadzone; - if (gc.runtime.has_anti_deadzone) - controller_anti_deadzone = gc.runtime.anti_deadzone; - { const char *e = std::getenv("PSX_GL_FORCE_CPU_PRESENT"); - if (e && e[0] && e[0] != '0') g_gl_fbo_present = 0; } - game_entry_pc = gc.entry_pc; - fast_boot = gc.runtime.fast_boot; - bios_hle_requested = gc.runtime.bios_hle; - bios_hle_keep_intro = gc.runtime.bios_hle_keep_intro; - /* Developer compatibility finding, applied before BIOS selection. - * Not exposed to settings.toml on purpose — see BIOS_SELECTION.md. */ - s_openbios_allowed = gc.runtime.openbios; - /* Let the dispatch layer distinguish "dirty because text was - * loaded" from "diverged because runtime wrote different code over - * the original EXE image". Packed/self-modifying games can rewrite - * their own text; those pages must execute from live RAM, not stale - * static native code. Arming is DEFERRED until the disc path is - * resolved (arm_text_image_guard below): release installs have no - * local EXE file, so the guard extracts the boot EXE from the - * user's disc image instead. */ - text_guard_exe_path = gc.exe_path.string(); - text_guard_load_addr = gc.load_address; - text_guard_text_size = gc.text_size; - /* HLE-tier scheduler subsystem replacement default (env - * PSX_HLE_SCHEDULER still wins; latched at first dispatch). */ - psx_hle_scheduler_set_default(gc.runtime.hle_scheduler ? 1 : 0); - /* Pin the overlay-region floor to THIS game's main-EXE text end so - * runtime-loaded overlays (which load just above it) are dispatched - * via in-interpreter local-flow chaining, NOT the slow block-by-block - * + bail-prone non-local-call path. Hardcoding the floor to Tomba 1's - * text end (0x98000) wedged Tomba 2 (text ends 0x38800, overlays at - * 0x85000+) at the Whoopee-Camp splash. See dirty_ram_interp.h. */ - { - extern uint32_t g_overlay_region_floor; - uint32_t text_end = (gc.load_address + gc.text_size) & 0x1FFFFFFFu; - if (text_end > 0x00010000u /* DIRTY_RAM_KERNEL_WINDOW_END */) - g_overlay_region_floor = text_end; - std::fprintf(stdout, - "psxrecomp: overlay_region_floor = 0x%05X (game text end)\n", - g_overlay_region_floor); - } - /* Overlay DLL cache (Layer A). Off unless enabled in [runtime]; - * when on, capture overlay bytes and scan cache// for - * precompiled overlay DLLs. */ - if (gc.runtime.overlay_cache) { - std::filesystem::path exe_dir = exe_dir_from_argv(argv[0]); - std::string cache_dir = (exe_dir / "cache").string(); - std::filesystem::path captures_path = - resolve_overlay_capture_path(gc.project_root, exe_dir, game_id); - if (!overlay_capture_set_path(captures_path.string().c_str())) { - captures_path = exe_dir / "overlay_captures.json"; - if (!overlay_capture_set_path(captures_path.string().c_str())) - throw std::runtime_error("overlay capture path exceeds runtime limit"); - } - overlay_capture_set_enabled(1); - std::fprintf(stdout, - "psxrecomp: additive overlay capture store = %s (+ .d history)\n", - captures_path.string().c_str()); - std::string capture_persist_dir; - if (gc.runtime.overlay_capture_history && - !gc.runtime.overlay_capture_persist_dir.empty()) { - std::filesystem::path persist = - gc.project_root / gc.runtime.overlay_capture_persist_dir; - std::error_code persist_ec; - std::filesystem::create_directories(persist, persist_ec); - if (persist_ec) { - std::fprintf(stderr, - "psxrecomp: cannot create overlay capture history %s: %s\n", - persist.string().c_str(), persist_ec.message().c_str()); - } else { - capture_persist_dir = persist.string(); - } - } - overlay_capture_configure_history( - gc.runtime.overlay_capture_history ? 1 : 0, - capture_persist_dir.empty() ? nullptr : - capture_persist_dir.c_str(), - game_id.c_str()); - const uint32_t overlay_config_hash = - PSXRecompV4::overlay_codegen_config_hash(gc); - overlay_loader_init(cache_dir.c_str(), game_id.c_str(), - overlay_config_hash); - std::fprintf(stdout, - "psxrecomp: overlay codegen config hash = %08x\n", - (unsigned)overlay_config_hash); - for (uint32_t addr : gc.runtime.overlay_native_block) { - overlay_loader_native_block_add(addr); - } - if (!gc.runtime.overlay_native_block.empty()) { - std::fprintf(stdout, - "psxrecomp: overlay native blocklist seeded with %zu entr%s\n", - gc.runtime.overlay_native_block.size(), - gc.runtime.overlay_native_block.size() == 1 ? "y" : "ies"); - } - /* Scoped pre-DMA journaling and the shutdown snapshot remain - * active whenever the cache is on, including toolchain-less - * production machines. The periodic pressure trigger exists to - * feed a live compiler; leave it off when no provider command - * exists, otherwise it rewrites manifests every cooldown while - * being unable to reduce interpreter residency. */ - overlay_autocapture_set_enabled(0); - /* Resolve the overlay tier first so we wire the RIGHT compiler's - * autocompile command. gcc is "available" only when a gcc cmd is - * configured AND a gcc toolchain is actually reachable (a real - * dev/production box). auto => gcc if so, else tcc; auto-no-gcc => - * tcc even with gcc present (simulate a toolchain-less user box). - * env PSX_OVERLAY_BACKEND overrides. Tiers: static > gcc > tcc > - * interp. */ - const char *cfg_backend = gc.runtime.overlay_backend.empty() - ? nullptr : gc.runtime.overlay_backend.c_str(); - int gcc_avail = gc.runtime.has_overlay_autocompile_cmd - && autocompile_toolchain_available(); - OverlayBackend eff = overlay_backend_resolve(cfg_backend, gcc_avail); - /* gcc and tcc run the IDENTICAL recompiler->C->DLL->load pipeline; - * only the compiler binary differs. Wire the autocompile spawn with - * the command for the resolved tier (tcc cmd for the tcc tier, gcc - * cmd otherwise). gcc shards already on disk still LOAD either way - * (the loader is compiler-blind), so a tcc box uses shipped gcc - * shards first and fills the rest with tcc. */ - std::string built_tcc_cmd; /* runtime-constructed bundled tcc cmd */ - std::string env_ac_cmd; - const std::string *ac_cmd = nullptr; - if (eff == OVERLAY_BACKEND_TCC) { - if (gc.runtime.has_overlay_autocompile_cmd_tcc) { - ac_cmd = &gc.runtime.overlay_autocompile_cmd_tcc; /* explicit override (dev) */ - } else { - /* PRODUCTION: construct the tcc autocompile cmd from the - * self-contained toolchain bundled beside the exe - * (/overlay_toolchain/ = embedded python + tcc + - * recompiler + compile_overlays.py + runtime headers). No - * system python or gcc required. */ - extern int g_psx_cps_mode; - std::filesystem::path xd = exe_dir_from_argv(argv[0]); - std::filesystem::path tk = xd / "overlay_toolchain"; - std::filesystem::path py = tk / "python" / "python.exe"; - if (std::filesystem::exists(py)) { - auto cmd_quote = [](const std::string& s) { - return std::string("\"") + s + "\""; - }; - built_tcc_cmd = - cmd_quote(py.string()) + " " + - cmd_quote((tk / "compile_overlays.py").string()) + - " --captures " + cmd_quote(captures_path.string()) + - " --game-toml " + cmd_quote(std::string( - game_config_path ? game_config_path : "game.toml")) + - " --recompiler " + cmd_quote((tk / "psxrecomp-game.exe").string()) + - " --runtime-include " + cmd_quote((tk / "include").string()) + - " --out-dir " + cmd_quote((xd / "cache").string()) + - (g_psx_cps_mode ? " --cps" : "") + - " --compiler tcc --tcc " + - cmd_quote((tk / "tcc" / "tcc.exe").string()); - ac_cmd = &built_tcc_cmd; - std::fprintf(stdout, - "psxrecomp: tcc tier using bundled toolchain (%s)\n", - tk.string().c_str()); - } else { - std::fprintf(stdout, - "psxrecomp: tcc tier active but no bundled toolchain at %s " - "(overlay gaps -> interpreter)\n", tk.string().c_str()); - } - } - } else { - if (gc.runtime.has_overlay_autocompile_cmd) - ac_cmd = &gc.runtime.overlay_autocompile_cmd; - } - /* A developer may run a game config from one checkout against a - * runtime/recompiler built in another worktree. Let the launch - * pin the producer command to that exact worktree so the baked - * codegen hash, additive-capture reader, and runtime headers - * cannot silently drift through a game-repo junction. */ - if (const char *e = std::getenv("PSX_OVERLAY_AUTOCOMPILE_CMD")) { - if (e[0]) { - env_ac_cmd = e; - ac_cmd = &env_ac_cmd; - std::fprintf(stdout, - "psxrecomp: overlay autocompile command overridden by environment\n"); - } - } - if (const char *e = std::getenv("PSX_OVERLAY_AUTOCOMPILE_OFF")) { - if (e[0] && e[0] != '0') { - ac_cmd = nullptr; - std::fprintf(stdout, - "psxrecomp: overlay autocompile disabled by environment\n"); - } - } - if (ac_cmd) { - /* Pin the compile's WRITE cache + READ captures to the SAME - * canonical locations the loader uses (cache_dir = /cache, - * /overlay_captures.json — set above). The framework owns - * the cache location; no game.toml --out-dir/--captures can make - * the write drift from the read. Single source of truth, all - * games, dev or prod. */ - autocompile_set_cache_paths(cache_dir.c_str(), - captures_path.string().c_str()); - std::string ac_cwd = gc.project_root.string(); - if (const char *e = std::getenv("PSX_OVERLAY_AUTOCOMPILE_CWD")) { - if (e[0]) ac_cwd = e; - } - autocompile_configure(ac_cmd->c_str(), ac_cwd.c_str()); - overlay_autocapture_set_enabled(1); - /* cwd is printed because every relative path in - * overlay_autocompile_cmd resolves against it, and it is - * derived from game.toml's detected project root. Staging a - * game.toml somewhere that is not the real project root - * therefore repoints the whole compile command, and the only - * symptom is that nothing ever goes native and frame times - * are 10-30x worse. Printing the root makes that a one-line - * diagnosis instead of a TCP query against - * autocompile_status. */ - std::fprintf(stdout, - "psxrecomp: overlay autocompile enabled (%s); cache=%s; captures=%s; cwd=%s\n", - overlay_backend_name(eff), cache_dir.c_str(), - captures_path.string().c_str(), ac_cwd.c_str()); - } - code_provider_init(cfg_backend, gcc_avail); - /* (sljit removed 2026-07-15: overlay_loader_apply_live_policy was - * called here once the backend resolved.) */ - } - std::fprintf(stdout, "psxrecomp: loaded game config %s (%s, %s)\n", - game_config_path, game_name.c_str(), game_id.c_str()); - } catch (const std::exception& ex) { - std::fprintf(stderr, "psxrecomp: failed to load --game %s: %s\n", - game_config_path, ex.what()); - return 1; - } - } - - if (!configure_disruptor_precision_store_routes(game_id)) return 1; - - if (!game_name.empty()) s_picker_game_name = game_name; - - /* Layer the launcher-written settings.toml (next to the exe) over the - * bundled game.toml. Any field present there overrides the config value; - * the command line (--bios/--disc) still wins over the file. Absent => - * fall through to game.toml. The file is the launcher's persistence. */ - bool skip_launcher_setting = false; /* [launcher] skip_launcher from settings.toml */ - std::string settings_bios_storage; /* must outlive resolve_bios_for_runtime */ - std::string netplay_player_name; /* [netplay] player_name from settings.toml */ - bool has_netplay_player_name = false; - { - std::filesystem::path settings_path = - exe_dir_from_argv(argv[0]) / "settings.toml"; - const auto settings_utf8 = settings_path.u8string(); - g_host_user_settings_path_utf8.assign(settings_utf8.begin(), - settings_utf8.end()); -#if defined(RECOMP_LAUNCHER) - g_lnch_settings_path = settings_path; -#endif - const PSXRecompV4::UserSettings us = - PSXRecompV4::load_user_settings(settings_path); - if (us.parse_error) { - /* The file exists but is not valid TOML: every setting in it (the - * user's renderer choice, BIOS/disc paths, ...) is being ignored, - * and any later launcher save would overwrite it with defaults. - * Preserve their file and say so loudly instead of both failing - * silently (GH Tomba2Recomp#1 triage: a stray character disabled a - * user's whole settings file with no indication anywhere). */ - std::error_code rn_ec; - std::filesystem::path bad = settings_path; - bad += ".bad"; - std::filesystem::remove(bad, rn_ec); - rn_ec.clear(); - std::filesystem::rename(settings_path, bad, rn_ec); - launcher_warning("Settings file ignored", - "settings.toml has a TOML syntax error, so ALL settings in it were " - "ignored this run (renderer, BIOS/disc paths, everything).\n\n" + - (rn_ec ? "The broken file was left at:\n" + settings_path.string() - : "The broken file was preserved as:\n" + bad.string()) + - "\n\nFix the syntax error and restore the file, or set your options " - "again from the launcher (a fresh settings.toml will be written)."); - } - if (us.has_skip_launcher) skip_launcher_setting = us.skip_launcher; - if (us.has_renderer) { - if (us.renderer == 2 && !vulkan_offered) { - g_video_renderer = 1; - std::fprintf(stdout, - "psxrecomp: settings requested Vulkan, but this game does not " - "offer Vulkan in the launcher; using OpenGL.\n"); - } else { - g_video_renderer = us.renderer; - } - } - if (us.has_netplay_player_name && !us.netplay_player_name.empty()) { - netplay_player_name = us.netplay_player_name; - has_netplay_player_name = true; - } -#if defined(RECOMP_LAUNCHER) - if (us.has_netplay_lobby_url && !us.netplay_lobby_url.empty()) - g_lnch_lobby_url = us.netplay_lobby_url; -#endif - if (us.has_supersampling) g_video_scale = us.supersampling; - if (us.has_window_width) g_video_win_w = us.window_width; - if (us.has_antialiasing) g_video_aa = us.antialiasing; - if (us.has_texture_filter) g_video_texfilter = us.texture_filter; - if (us.has_screen_kind) g_video_screen = us.screen_kind; - if (us.has_auto_skip_fmv) g_auto_skip_fmv = us.auto_skip_fmv ? 1 : 0; - if (us.has_turbo_loads) g_turbo_loads_enabled = us.turbo_loads ? 1 : 0; - /* fast_boot is deliberately NOT restored from settings.toml: the - * launcher exposes no control for it, so the persisted row can only - * echo whatever game.toml said on some EARLIER run — a write-only - * latch that silently overrode a later config change (flipping - * game.toml fast_boot=false still skipped the boot animation). - * Config + env own it until a launcher toggle exists; if one is - * added, restore this line together with the UI. The seed write - * below still persists the effective value for inspection. */ -#ifdef PSX_HAS_DISRUPTOR_INTRO_SKIP - /* Disruptor's intro switch has a control and covers the BIOS animation too. */ - if (us.has_skip_intro && us.skip_intro && - !std::getenv("PSX_DISRUPTOR_SKIP_INTRO")) - fast_boot = true; -#endif -#ifdef PSX_HAS_DISRUPTOR_LANGUAGE - /* Named before the drive first reads the game disc. */ - if (us.has_language_disc && !std::getenv("PSX_DISRUPTOR_LANGUAGE_DISC")) - disruptor_language_set_disc(us.language_disc.c_str()); -#endif - if (us.has_bios_hle) bios_hle_requested = us.bios_hle; - if (us.has_fullscreen) g_fullscreen = us.fullscreen; - if (us.has_aspect_ratio) { - g_video_aspect_num = us.aspect_num; - g_video_aspect_den = us.aspect_den; - } - if (us.has_adaptive_view) g_ws_adaptive_view = us.adaptive_view; - if (us.has_spu_hq) g_audio_spu_hq = us.spu_hq; - if (us.has_master_volume) - (void)psx_host_audio_set_master_volume(us.master_volume); - if (us.has_audio_muted) - (void)psx_host_audio_set_muted(us.audio_muted ? 1 : 0); - /* Bundled BIOS: ignore any persisted bios_path (same clamp-as-well- - * as-hide treatment as lock_mode / ws_offered below) so a stale - * settings.toml or launcher-less build can never point a bundled - * build at a foreign image. The identity gate is the backstop. */ - /* A non-empty BIOS in settings.toml is the player's choice, made in - * the launcher's SYSTEM row, and is honoured on every build — that row - * is the only way to opt into a retail image now that bundled builds - * no longer prompt. An EMPTY value is equally meaningful: it is the - * launcher's Clear, i.e. "go back to the bundled BIOS", so it must NOT - * be adopted as a path. - * - * The identity gate is what protects a bundled build from a stale or - * foreign path here — previously this was guarded by refusing to read - * the setting at all, which would now silently ignore the player. */ - if (us.has_bios_path && !bios_from_cli && !us.bios_path.empty()) { - settings_bios_storage = us.bios_path.string(); - bios_path = settings_bios_storage.c_str(); - bios_explicit = true; - } - if (us.has_disc_path && !disc_override_path) - resolved_disc = normalize_disc_path_for_launch(us.disc_path); - if (us.has_memcard_dir) memcard_dir = us.memcard_dir; - if (us.has_memcard1_path) memcard1_path = us.memcard1_path; - if (us.has_memcard2_path) memcard2_path = us.memcard2_path; - if (us.has_memcard1_enabled) memcard1_enabled = us.memcard1_enabled; - if (us.has_memcard2_enabled) memcard2_enabled = us.memcard2_enabled; - if (us.has_language) resolved_language = us.language; - if (us.has_p1_device) p1_device = us.p1_device; - if (us.has_p2_device) p2_device = us.p2_device; - if (us.has_p1_mode) p1_mode = us.p1_mode; - if (us.has_p2_mode) p2_mode = us.p2_mode; - if (us.has_deadzone) resolved_deadzone = us.deadzone; - if (us.has_low_latency_input) g_low_latency_input = us.low_latency_input ? 1 : 0; - if (us.has_vsync) g_video_vsync = us.vsync; -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - if (us.has_frame_interpolation) - g_frame_interpolation = us.frame_interpolation ? 1 : 0; - if (us.has_frame_interpolation_fps) - g_frame_interpolation_fps = us.frame_interpolation_fps; -#if defined(DISRUPTOR_DEV_MENU) - /* Migrate legacy image-blend preferences to geometry at startup. - * Explicit environment selection below can still opt into legacy - * modes for one-run comparisons. */ - g_frame_interpolation_blend_default = - PSX_HOST_FRAME_INTERPOLATION_GEOMETRY; - g_frame_interpolation_blend = g_frame_interpolation_blend_default; -#else - if (us.has_frame_interpolation_blend) { - g_frame_interpolation_blend_default = - us.frame_interpolation_blend; - g_frame_interpolation_blend = us.frame_interpolation_blend; - } -#endif -#endif - if (us.has_geometry_correction) - g_geometry_correction = us.geometry_correction; - if (us.has_perspective_textures) - g_texture_correction = us.perspective_textures; - if (us.has_parappa_timing_mode || us.has_parappa_timing_extra_early || - us.has_parappa_timing_extra_late) { - parappa_apply_timing_setting( - us.has_parappa_timing_mode ? us.parappa_timing_mode : "custom", - us.has_parappa_timing_extra_early ? us.parappa_timing_extra_early : -1, - us.has_parappa_timing_extra_late ? us.parappa_timing_extra_late : -1, - "settings.toml"); - } - } - - /* The launcher must inspect the same explicit disc override that the - * runtime will validate and mount. Applying --disc only in the later - * resolve_disc_for_runtime() pass left the launcher showing game.toml's - * path, which may not exist in a build/worktree even though the CLI path - * is valid. */ - if (disc_override_path && disc_override_path[0]) - resolved_disc = normalize_disc_path_for_launch(disc_override_path); - - /* lock_mode: the game supports exactly ONE pad type (e.g. X4 / Tomba 2 are - * digital-only — X4's pre-DualShock libpad silently discards input from a - * pad answering id 0x73). The launcher hides its selector for such games, - * but that alone left two holes: (a) launcher-less builds still honoured a - * settings.toml p1_mode/p2_mode, and (b) a settings.toml persisted BEFORE - * the game declared lock_mode fed the stale mode back as the launcher's - * locked_mode. Clamp to the game-declared modes here, after every - * config/settings source has been applied, so a locked game can never boot - * a pad type it doesn't support. */ - if (ctrl_lock_mode) { - p1_mode = ctrl_locked_p1_mode; - p2_mode = ctrl_locked_p2_mode; - } - /* allow_hybrid=false removes Hybrid from the game's supported controller - * modes. Clamp an old persisted Hybrid value here as well as hiding it in - * recomp-ui, so launcher-less builds cannot revive an unsupported mode. - * Prefer each port's game-declared default; malformed/legacy configs that - * also default to Hybrid fall back to Analog, matching recomp-ui. */ - if (!ctrl_allow_hybrid) { - const int fallback_p1 = - ctrl_locked_p1_mode == PSXRecompV4::PAD_MODE_HYBRID - ? PSXRecompV4::PAD_MODE_ANALOG : ctrl_locked_p1_mode; - const int fallback_p2 = - ctrl_locked_p2_mode == PSXRecompV4::PAD_MODE_HYBRID - ? PSXRecompV4::PAD_MODE_ANALOG : ctrl_locked_p2_mode; - if (p1_mode == PSXRecompV4::PAD_MODE_HYBRID) p1_mode = fallback_p1; - if (p2_mode == PSXRecompV4::PAD_MODE_HYBRID) p2_mode = fallback_p2; - } - - /* A game may migrate Skip FMVs from generic Settings into its mod catalog. - * Clamp stale settings before seeding recomp-ui; an enabled activation - * plugin applies the feature after the final mod-plan commit. */ - if (!skip_fmv_offered && g_auto_skip_fmv) { - std::fprintf(stdout, - "psxrecomp: Skip FMVs is mod-owned for this title; " - "ignoring the legacy Settings value\n"); - g_auto_skip_fmv = 0; - } - /* A game may likewise move load acceleration into its mod catalog. The - * activation plugin runs after final plan commit, so clear both the - * game.toml default and stale Settings value before the launcher is seeded. */ - if (!turbo_loads_offered && g_turbo_loads_enabled) { - std::fprintf(stdout, - "psxrecomp: Turbo loads is mod-owned for this title; " - "ignoring the legacy Settings value\n"); - g_turbo_loads_enabled = 0; - } - /* Treat presentation interpolation the same way when a title moves it to - * Mods. Both the boolean and target rate are cleared so launcher-less - * starts cannot revive an old generic Settings selection. */ - if (!frame_interpolation_offered && - (g_frame_interpolation || g_frame_interpolation_fps)) { - std::fprintf(stdout, - "psxrecomp: Frame interpolation is mod-owned for this title; " - "ignoring the legacy Settings value\n"); - g_frame_interpolation = 0; - g_frame_interpolation_fps = 0; - } - - /* [widescreen] offer=false: this title's widescreen is unported/unvalidated, - * so the launcher hides its toggle — and, same completeness treatment as - * lock_mode above, the runtime clamps the display aspect to native 4:3 here - * so a stale persisted 16:9 in settings.toml can't engage the hack in - * launcher-less builds either. */ - if (!ws_offered && (g_video_aspect_num != 4 || g_video_aspect_den != 3)) { - std::fprintf(stdout, "psxrecomp: widescreen not offered for this title; " - "clamping display aspect %d:%d -> 4:3\n", - g_video_aspect_num, g_video_aspect_den); - g_video_aspect_num = 4; - g_video_aspect_den = 3; - } - if (!ws_ultrawide_offered && g_video_aspect_num * 9 == g_video_aspect_den * 21) { - std::fprintf(stdout, "psxrecomp: 21:9 is not offered for this title; clamping to %s\n", - ws_offered ? "16:9" : "4:3"); - g_video_aspect_num = ws_offered ? 16 : 4; - g_video_aspect_den = ws_offered ? 9 : 3; - } - if (!ws_adaptive_view_supported) g_ws_adaptive_view = false; - /* A title that explicitly opts into resize-driven presentation has - * accepted the runtime's full validated aspect range. Launcher-only - * ultrawide support remains capped at its historical 21:9 choice. */ - g_ws_adaptive_max_num = ws_adaptive_view_supported - ? 32 : (ws_ultrawide_offered ? 21 : 16); - g_ws_adaptive_max_den = 9; - - /* Explicit process overrides win over saved/configured presentation. - * PSX_VIDEO_ASPECT=4:3|16:9|21:9|32:9; - * PSX_LOW_LATENCY_INPUT=0/1 ; PSX_VSYNC=1(vsync)/0(immediate)/-1(adaptive); - * PSX_FRAME_INTERPOLATION=0/1; PSX_FRAME_INTERPOLATION_FPS=0|60+; - * PSX_FRAME_INTERPOLATION_BLEND=geometry|linear|adaptive. */ - if (const char *e = std::getenv("PSX_VIDEO_ASPECT")) { - int numerator = 0; - int denominator = 0; - if (std::strcmp(e, "4:3") == 0) { - numerator = 4; denominator = 3; - } else if (std::strcmp(e, "16:9") == 0) { - numerator = 16; denominator = 9; - } else if (std::strcmp(e, "21:9") == 0) { - numerator = 21; denominator = 9; - } else if (std::strcmp(e, "32:9") == 0) { - numerator = 32; denominator = 9; - } - const bool parsed = numerator != 0; - const bool wide = numerator * 3 != denominator * 4; - const bool ultrawide = numerator * 9 > denominator * 16; - const bool offered = - !wide || (ws_offered && (!ultrawide || ws_ultrawide_offered)); - if (parsed && is_host_fixed_display_aspect(numerator, denominator) && - offered) { - g_video_aspect_num = numerator; - g_video_aspect_den = denominator; - g_ws_adaptive_view = false; - } else { - std::fprintf(stderr, - "psxrecomp: ignoring unsupported PSX_VIDEO_ASPECT=%s\n", e); - } - } - if (const char *e = std::getenv("PSX_LOW_LATENCY_INPUT")) g_low_latency_input = atoi(e) ? 1 : 0; - if (const char *e = std::getenv("PSX_VSYNC")) g_video_vsync = atoi(e); - if (const char *e = std::getenv("PSX_SMOOTH_60FPS")) - psx_smooth_60fps_set(atoi(e) ? 1 : 0); - if (const char *e = std::getenv("PSX_GEOMETRY_CORRECTION")) - g_geometry_correction = atoi(e) ? true : false; - if (const char *e = std::getenv("PSX_TEXTURE_CORRECTION")) - g_texture_correction = atoi(e) ? true : false; -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - if (const char *e = std::getenv("PSX_FRAME_INTERPOLATION")) - g_frame_interpolation = atoi(e) ? 1 : 0; - if (const char *e = std::getenv("PSX_FRAME_INTERPOLATION_FPS")) { - int fps = atoi(e); - if (fps == 0 || (fps >= 30 && fps <= 1000)) g_frame_interpolation_fps = fps; - } - if (const char *e = std::getenv("PSX_FRAME_INTERPOLATION_BLEND")) { - if (strcmp(e, "geometry") == 0 || strcmp(e, "2") == 0) { - g_frame_interpolation_blend_default = - PSX_HOST_FRAME_INTERPOLATION_GEOMETRY; - } else if (strcmp(e, "adaptive") == 0 || strcmp(e, "clarity") == 0 || - strcmp(e, "1") == 0) { - g_frame_interpolation_blend_default = - PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE; - } else if (strcmp(e, "linear") == 0 || strcmp(e, "0") == 0) { - g_frame_interpolation_blend_default = - PSX_MOD_FRAME_INTERPOLATION_LINEAR; - } - g_frame_interpolation_blend = g_frame_interpolation_blend_default; - } - /* Geometry in-between frames need exact geometry, as the live switch insists. */ - if (g_frame_interpolation_blend == PSX_HOST_FRAME_INTERPOLATION_GEOMETRY && - !g_geometry_correction) - g_frame_interpolation = 0; -#endif - { - const char *mode_env = std::getenv("PSX_PARAPPA_TIMING_MODE"); - const char *early_env = std::getenv("PSX_PARAPPA_TIMING_EXTRA_EARLY"); - const char *late_env = std::getenv("PSX_PARAPPA_TIMING_EXTRA_LATE"); - if ((mode_env && mode_env[0]) || early_env || late_env) { - parappa_apply_timing_setting( - (mode_env && mode_env[0]) ? std::string(mode_env) : std::string("custom"), - early_env ? atoi(early_env) : -1, - late_env ? atoi(late_env) : -1, - "environment"); - } - } - - /* Apply writable-state isolation before game-options and launcher setup, - * not with the later renderer/port overrides. Explicit slot paths from a - * shared settings.toml must not escape the isolated directory. */ - if (cli_memcard_dir) { - memcard_dir = std::filesystem::path(cli_memcard_dir); - if (memcard_dir.is_relative()) - memcard_dir = exe_dir_from_argv(argv[0]) / memcard_dir; - memcard_dir = memcard_dir.lexically_normal(); - memcard1_path.clear(); - memcard2_path.clear(); - std::error_code memcard_ec; - std::filesystem::create_directories(memcard_dir, memcard_ec); - if (memcard_ec) { - std::fprintf(stderr, - "psxrecomp: cannot create --memcard-dir %s: %s\n", - memcard_dir.string().c_str(), memcard_ec.message().c_str()); - return 1; - } - std::fprintf(stdout, "psxrecomp: CLI writable-state directory = %s\n", - memcard_dir.string().c_str()); - } - - /* Resolve the effective memory-card directory now (before the launcher) so - * the launcher can introspect the real card files. The same default is used - * by the runtime below. */ - if (memcard_dir.empty()) memcard_dir = default_memcard_dir(argv[0]); - - /* The game's OWN native OPTION settings (game_options.toml, next to - * game.toml) — persisted across launches, kept separate from game.toml - * (recomp config) and settings.toml (launcher). Values are saved to - * /.options. Best-effort: a malformed file disables - * the feature, it never blocks boot. */ - if (game_config_path) { - try { - std::filesystem::path go_path = - std::filesystem::path(game_config_path).parent_path() / "game_options.toml"; - const PSXRecompV4::GameOptions go = PSXRecompV4::load_game_options(go_path); - if (!go.options.empty()) { - std::vector go_addrs; - std::vector go_sizes; - std::vector go_names; - std::vector go_vmins; - std::vector go_vmaxs; - for (const auto& o : go.options) { - go_addrs.push_back(o.addr); - go_sizes.push_back((uint8_t)o.size); - go_names.push_back(o.name.c_str()); - /* Declared range validates the persisted value at restore; no - * range => full int32 span (accept anything that fits). */ - go_vmins.push_back(o.has_range ? (int32_t)o.vmin : (int32_t)0x80000000); - go_vmaxs.push_back(o.has_range ? (int32_t)o.vmax : (int32_t)0x7FFFFFFF); - } - std::string go_state = (memcard_dir / - ((game_id.empty() ? std::string("game") : game_id) + ".options")).string(); - game_options_configure(go_state.c_str(), go_addrs.data(), go_sizes.data(), - go_names.data(), go_vmins.data(), go_vmaxs.data(), - (int)go_addrs.size()); - std::fprintf(stdout, - "psxrecomp: game options persistence armed (%d field(s)) -> %s\n", - (int)go_addrs.size(), go_state.c_str()); - } - } catch (const std::exception& ex) { - std::fprintf(stderr, "psxrecomp: game_options.toml ignored: %s\n", ex.what()); - } - } - - { - std::string mod_error; - if (!PSXRecompV4::mod_runtime_initialize( - exe_dir_from_argv(argv[0]) / "mods", game_id, - game_entry_pc, text_guard_exe_path, &mod_error)) { - std::fprintf(stderr, "psxrecomp: mods unavailable: %s\n", - mod_error.c_str()); - } - } - -#if defined(RECOMP_LAUNCHER) - /* Integrated recomp-ui launcher: shown in its own GL window before the emulator - * boots. Seeded with the effective settings (game.toml ∪ settings.toml); - * on LAUNCH the user's choices are persisted to settings.toml and applied. - * The launcher window/context is fully torn down before the emulator's own - * window is created, so the emulator boot path below is untouched. - * - * Skip the GUI (boot straight in) when ANY of: PSX_NO_LAUNCHER=1 env, - * --no-launcher, or the persisted [launcher] skip_launcher setting — unless - * --launcher forces it back on (mirrors snesrecomp's SkipLauncher / --launcher). - * This removes the dismiss-the-launcher round-trip for scripted/debug runs. */ - const bool want_launcher = - force_launcher || - (!std::getenv("PSX_NO_LAUNCHER") && !force_no_launcher && !skip_launcher_setting); - if (want_launcher) { - if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) == 0) { - int lr = 2; /* 0 = launch, 1 = quit, 2 = unavailable */ - /* Merge launcher edits into the canonical file instead of - * constructing a fresh snapshot. Game-owned sections (including - * [disruptor]) and settings not exposed by recomp-ui must survive - * a later launcher save. */ - PSXRecompV4::UserSettings seed = - PSXRecompV4::load_user_settings( - exe_dir_from_argv(argv[0]) / "settings.toml"); - if (seed.parse_error) seed = PSXRecompV4::UserSettings{}; - seed.renderer = g_video_renderer; seed.has_renderer = true; - seed.supersampling = g_video_scale; seed.has_supersampling = true; - seed.antialiasing = g_video_aa; seed.has_antialiasing = true; - seed.texture_filter = g_video_texfilter; seed.has_texture_filter = true; - seed.screen_kind = g_video_screen; seed.has_screen_kind = true; - seed.auto_skip_fmv = (g_auto_skip_fmv != 0); - seed.has_auto_skip_fmv = skip_fmv_offered; - seed.turbo_loads = (g_turbo_loads_enabled != 0); - seed.has_turbo_loads = turbo_loads_offered; - seed.fast_boot = fast_boot; seed.has_fast_boot = true; - seed.bios_hle = bios_hle_requested; seed.has_bios_hle = true; - seed.fullscreen = g_fullscreen; seed.has_fullscreen = true; - seed.frame_interpolation = (g_frame_interpolation != 0); - seed.has_frame_interpolation = frame_interpolation_offered; - seed.frame_interpolation_fps = g_frame_interpolation_fps; - seed.has_frame_interpolation_fps = frame_interpolation_offered; - seed.aspect_num = g_video_aspect_num; - seed.aspect_den = g_video_aspect_den; - seed.has_aspect_ratio = ws_offered; - seed.adaptive_view = g_ws_adaptive_view; - seed.has_adaptive_view = ws_offered; - seed.spu_hq = g_audio_spu_hq; seed.has_spu_hq = true; - seed.skip_launcher = skip_launcher_setting; seed.has_skip_launcher = true; - if (has_netplay_player_name) { - seed.netplay_player_name = netplay_player_name; - seed.has_netplay_player_name = true; - } - if (!g_lnch_lobby_url.empty()) { - seed.netplay_lobby_url = g_lnch_lobby_url; - seed.has_netplay_lobby_url = true; - } - if (bios_path && bios_path[0]) { seed.bios_path = bios_path; seed.has_bios_path = true; } - if (!resolved_disc.empty()) { seed.disc_path = resolved_disc; seed.has_disc_path = true; } - seed.memcard_dir = memcard_dir; seed.has_memcard_dir = true; - seed.memcard1_enabled = memcard1_enabled; seed.has_memcard1_enabled = true; - seed.memcard2_enabled = memcard2_enabled; seed.has_memcard2_enabled = true; - if (!memcard1_path.empty()) { seed.memcard1_path = memcard1_path; seed.has_memcard1_path = true; } - if (!memcard2_path.empty()) { seed.memcard2_path = memcard2_path; seed.has_memcard2_path = true; } - seed.language = resolved_language; seed.has_language = true; - seed.p1_device = p1_device; seed.has_p1_device = true; - seed.p2_device = p2_device; seed.has_p2_device = true; - seed.p1_mode = p1_mode; seed.has_p1_mode = true; - seed.p2_mode = p2_mode; seed.has_p2_mode = true; - seed.deadzone = resolved_deadzone >= 0 ? resolved_deadzone : 12000; - seed.has_deadzone = true; - seed.window_width = g_video_win_w; seed.has_window_width = true; - seed.parappa_timing_mode = - g_parappa_timing_mode == 1 ? "medium" : - g_parappa_timing_mode == 2 ? "permissive" : - g_parappa_timing_mode == 3 ? "custom" : - g_parappa_timing_mode == 4 ? "easy" : "stock"; - seed.has_parappa_timing_mode = true; - seed.parappa_timing_extra_early = g_parappa_timing_extra_early; - seed.has_parappa_timing_extra_early = true; - seed.parappa_timing_extra_late = g_parappa_timing_extra_late; - seed.has_parappa_timing_extra_late = true; - - /* recomp-ui creates + owns its SDL/GL window internally, so there - * is no launcher window/context to manage here. */ - std::string assets_dir_str = exe_dir_from_argv(argv[0]).string(); - std::string rui_initial_disc = resolved_disc.string(); - std::string rui_title = (game_name.empty() ? std::string("PSX") : game_name) - + " - Launcher"; - - RecompLauncherCSettings ls{}; - ls.output_method = 2; /* OpenGL */ - ls.window_scale = std::max(1, std::min(4, g_video_win_w / 320)); - ls.fullscreen = seed.fullscreen; - ls.ignore_aspect = 0; - ls.linear_filter = (seed.texture_filter != 0) ? 1 : 0; - ls.widescreen = (seed.aspect_num == 16 && seed.aspect_den == 9) ? 1 : 0; - ls.widescreen_hud = ls.widescreen; - ls.enable_audio = 1; - ls.audio_freq = 44100; - ls.volume = 100; - ls.player_src[0] = PSXRecompV4::launcher_source_from_device(p1_device); - ls.player_src[1] = PSXRecompV4::launcher_source_from_device(p2_device); -#if defined(RECOMP_LAUNCHER_HAS_PLAYER_GAMEPAD_GUID) - if (ls.player_src[0] == 2) { - std::snprintf(ls.player_gamepad_guid[0], - sizeof(ls.player_gamepad_guid[0]), "%s", - p1_device.c_str()); - } - if (ls.player_src[1] == 2) { - std::snprintf(ls.player_gamepad_guid[1], - sizeof(ls.player_gamepad_guid[1]), "%s", - p2_device.c_str()); - } -#endif - { - int rui_deadzone_pct = seed.deadzone * 100 / 32767; - ls.deadzone[0] = rui_deadzone_pct; - ls.deadzone[1] = rui_deadzone_pct; - } - ls.skip_launcher = seed.skip_launcher ? 1 : 0; - ls.msu1_enabled = 0; - ls.msu1_dir[0] = '\0'; - std::snprintf(ls.netplay_player_name, sizeof(ls.netplay_player_name), "%s", - has_netplay_player_name ? netplay_player_name.c_str() : ""); - ls.pad_mode[0] = seed.p1_mode; - ls.pad_mode[1] = seed.p2_mode; - /* Adaptive is part of the aspect vocabulary, not a second toggle. - * It follows 21:9 when ultrawide is offered, otherwise 16:9. */ - const int adaptive_aspect_index = ws_ultrawide_offered ? 3 : 2; - ls.aspect_index = seed.adaptive_view ? adaptive_aspect_index : - (seed.aspect_num * 9 == seed.aspect_den * 21) ? 2 : - (seed.aspect_num == 16 && seed.aspect_den == 9) ? 1 : 0; - ls.adaptive_view = seed.adaptive_view ? 1 : 0; - - /* ---- deeper PSX-style settings (capability-gated via launcher_profile - * below). Sourced 1:1 from PSXRecompV4::UserSettings (config_loader.h). */ - ls.window_width = seed.window_width; - ls.renderer = seed.renderer; - if (ls.renderer < 0 || ls.renderer > (vulkan_offered ? 2 : 1)) - ls.renderer = 1; - ls.supersampling = seed.supersampling; - ls.antialiasing = seed.antialiasing ? 1 : 0; - ls.texture_filter = seed.texture_filter; - ls.screen_kind = seed.screen_kind; - ls.frame_interp = seed.frame_interpolation ? 1 : 0; - ls.frame_interp_fps = seed.frame_interpolation_fps; - ls.spu_hq = seed.spu_hq ? 1 : 0; - ls.auto_skip_fmv = seed.auto_skip_fmv ? 1 : 0; - ls.turbo_loads = seed.turbo_loads ? 1 : 0; - /* Localization: index of resolved_language within lang_menu_options - * (match by code; games with no [runtime].languages list leave - * lang_menu_options empty and this stays 0 / unused). */ - ls.language_index = 0; - for (size_t li = 0; li < lang_menu_options.size(); li++) { - if (lang_menu_options[li].code == resolved_language) { - ls.language_index = (int)li; - break; - } - } - { - std::string bios_str = seed.bios_path.string(); - std::snprintf(ls.bios_path, sizeof(ls.bios_path), "%s", bios_str.c_str()); - } - - /* Memory-card slots: point each slot at the REAL card the runtime - * will use — an explicit settings.toml path if set, else the default - * /cardN.mcd the runtime derives — so the launcher's - * block grids reflect the actual on-disk saves (memcard_inspect). */ - { - std::string mc1 = seed.has_memcard1_path ? seed.memcard1_path.string() - : (memcard_dir / "card1.mcd").string(); - std::string mc2 = seed.has_memcard2_path ? seed.memcard2_path.string() - : (memcard_dir / "card2.mcd").string(); - std::snprintf(ls.memcard_path[0], sizeof(ls.memcard_path[0]), "%s", mc1.c_str()); - std::snprintf(ls.memcard_path[1], sizeof(ls.memcard_path[1]), "%s", mc2.c_str()); - } - ls.memcard_enabled[0] = seed.memcard1_enabled ? 1 : 0; - ls.memcard_enabled[1] = seed.memcard2_enabled ? 1 : 0; - - /* Region badge: game.toml [game] region wins verbatim; otherwise derive - * from the game_id serial prefix (SCUS/SLUS/LSP -> USA, SCES/SLES -> - * Europe, SCPS/SLPS/SLPM -> Japan). Empty/unknown -> no badge (gi.region - * left null; the launcher model treats that as "" and hides the row). */ - const std::string rui_region = - !game_region.empty() ? game_region : region_label_from_serial(game_id); - - /* Localization: one C-string array pointing at lang_menu_options' - * own storage (that vector outlives this call, it's a main()-scope - * local), so no separate copy is needed. Empty when the game declares - * no [runtime].languages -> Localization stays hidden (num_languages=0). */ - std::vector rui_lang_labels; - rui_lang_labels.reserve(lang_menu_options.size()); - for (const auto& lo : lang_menu_options) rui_lang_labels.push_back(lo.label.c_str()); - - RecompLauncherCGameInfo gi{}; - static const char* const kPsxRendererLabels[] = { - "Software", - "OpenGL (Recommended)", - "Vulkan" - }; - static const char* const kAdaptiveAspectLabels[] = { - "4:3 (Native)", - "16:9 (Widescreen)", - "21:9 (Ultrawide)", - "Adaptive" - }; - static const char* const kAdaptiveAspectLabelsNoUltrawide[] = { - "4:3 (Native)", - "16:9 (Widescreen)", - "Adaptive" - }; - /* System identity + full PS1 settings-surface capability set (theme, - * platform label, rom_noun, pad-mode support, aspect mask base, and - * all has_* deep-settings flags) — one profile call keeps them from - * drifting apart across PSX titles. Per-game specifics below override - * only what the profile can't know. */ - launcher_profile_apply("psx", &gi); - /* Show the SYSTEM module's BIOS row whenever this build can - * actually use a player-supplied image — i.e. some linked backend - * is not the bundled one. Bundled builds used to hide the row - * entirely, which under the current model would strand the player: - * with OpenBIOS AND a retail image linked, choosing retail (and - * clearing back to bundled) is exactly what the row is for. - * Asks the registry rather than psx_bios_image, which is not - * populated until a backend is activated. */ - gi.has_bios = psx_bios_has_selectable(); - gi.name = game_name.empty() ? nullptr : game_name.c_str(); - gi.region = rui_region.empty() ? nullptr : rui_region.c_str(); - gi.has_expected_crc = 0; /* the launcher's simple file-CRC doesn't fit - PSX multi-track discs — skip verification */ - gi.num_known_sha256 = 0; - gi.widescreen_supported = ws_offered ? 1 : 0; - gi.num_players = game_players; - gi.msu1_supported = 0; - gi.sram_path = nullptr; /* PSX uses memory cards, not SRAM -> hide SAVES */ - /* Pad-mode + aspect capabilities sourced from game.toml - * [controller]/[widescreen] via GameConfig. PSX always has pad modes. */ - gi.pad_mode_selectable = ctrl_lock_mode ? 0 : 1; - gi.allow_hybrid = ctrl_allow_hybrid ? 1 : 0; - gi.locked_pad_mode = p1_mode; /* force the game's declared mode (default_mode) */ - gi.lock_device = ctrl_lock_device ? 1 : 0; - /* No aspect capability means recomp-ui omits View mode entirely. - * Games still using the legacy Settings path advertise Native plus - * their offered wide modes; games migrating widescreen into Mods - * set [widescreen] offer=false and let a plugin own activation. */ - gi.aspect_mask = ws_offered - ? (0x1 | 0x2 | (ws_ultrawide_offered ? 0x4 : 0)) - : 0; - gi.has_frame_interp = frame_interpolation_offered ? 1 : 0; - if (ws_offered && ws_adaptive_view_supported) { - gi.aspect_labels = ws_ultrawide_offered - ? kAdaptiveAspectLabels : kAdaptiveAspectLabelsNoUltrawide; - gi.num_aspect_labels = ws_ultrawide_offered ? 4 : 3; - gi.aspect_experimental = 1; - } - gi.renderer_labels = kPsxRendererLabels; - gi.num_renderers = vulkan_offered ? 3 : 2; - gi.has_skip_fmv = skip_fmv_offered ? 1 : 0; - gi.has_turbo_loads = turbo_loads_offered ? 1 : 0; - /* Localization menu: shown only when the game declares languages. */ - if (!rui_lang_labels.empty()) { - gi.language_labels = rui_lang_labels.data(); - gi.num_languages = (int)rui_lang_labels.size(); - } - - /* Real disc-verify + memcard-inspect (identify_disc / memcard_summary_path - * via the ae_* callbacks). The launcher re-runs these whenever the - * user changes the disc or a memory card, so the verdict + block - * grids reflect the actual images. - * The expected serial/CRC ride on file-scope statics (the C-ABI - * callback can't capture these locals). */ - g_lnch_expected_serial = game_id; - g_lnch_expected_crc = game_disc_crc; - g_lnch_has_crc = game_has_disc_crc; - gi.disc_verify = ae_disc_verify; - gi.memcard_inspect = ae_memcard_inspect; - gi.mods = PSXRecompV4::mod_runtime_launcher_provider(); -#if defined(PSX_HAS_RECOMP_NET) && defined(PSX_HAS_LOBBY_CLIENT) - g_lnch_netplay_game_name = game_name.empty() ? "PSX" : game_name; - gi.netplay_supported = (game_players == 2) ? 1 : 0; - gi.netplay = &g_lnch_netplay_callbacks; -#endif - - char rui_out_disc[1024] = {0}; - int rui_rc = recomp_launcher_run_window( - rui_title.c_str(), &ls, &gi, assets_dir_str.c_str(), - rui_initial_disc.c_str(), rui_out_disc, sizeof(rui_out_disc)); - - lr = rui_rc; - - if (lr == 0) { - seed.netplay_player_name = ls.netplay_player_name; - seed.has_netplay_player_name = true; - if (rui_out_disc[0]) { - seed.disc_path = rui_out_disc; - seed.has_disc_path = true; - } - seed.fullscreen = ls.fullscreen; seed.has_fullscreen = true; - seed.skip_launcher = ls.skip_launcher != 0; seed.has_skip_launcher = true; - /* Adaptive preserves the previous fixed aspect as the initial - * window shape. Selecting a fixed entry turns adaptation off. */ - if (ws_offered) { - if (ws_adaptive_view_supported && - ls.aspect_index == adaptive_aspect_index) { - seed.adaptive_view = true; - } else { - seed.adaptive_view = false; - switch (ls.aspect_index) { - case 2: seed.aspect_num = 21; seed.aspect_den = 9; break; - case 1: seed.aspect_num = 16; seed.aspect_den = 9; break; - default: seed.aspect_num = 4; seed.aspect_den = 3; break; - } - } - seed.has_aspect_ratio = true; - seed.has_adaptive_view = true; - } else { - /* A mod-owned widescreen path starts from an unpersisted - * 4:3 baseline. Its activation plugin selects the actual - * fixed/adaptive aspect after the launcher commits Mods. */ - seed.adaptive_view = false; - seed.aspect_num = 4; - seed.aspect_den = 3; - seed.has_aspect_ratio = false; - seed.has_adaptive_view = false; - } - /* has_texture_filter is on (PSX profile) so the launcher edits - * ls.texture_filter directly (0=nearest,1=bilinear); ls.linear_filter - * is the legacy fallback field for consoles without the cap and is - * left unused here. */ - seed.texture_filter = ls.texture_filter ? 1 : 0; seed.has_texture_filter = true; - std::string p1_launcher_device = p1_device; - std::string p2_launcher_device = p2_device; -#if defined(RECOMP_LAUNCHER_HAS_PLAYER_GAMEPAD_GUID) - if (ls.player_gamepad_guid[0][0]) - p1_launcher_device = ls.player_gamepad_guid[0]; - if (ls.player_gamepad_guid[1][0]) - p2_launcher_device = ls.player_gamepad_guid[1]; -#endif - p1_device = PSXRecompV4::launcher_device_from_source( - ls.player_src[0], p1_launcher_device); - p2_device = PSXRecompV4::launcher_device_from_source( - ls.player_src[1], p2_launcher_device); - seed.p1_device = p1_device; seed.has_p1_device = true; - seed.p2_device = p2_device; seed.has_p2_device = true; - seed.deadzone = ls.deadzone[0] * 32767 / 100; seed.has_deadzone = true; - seed.p1_mode = ls.pad_mode[0]; seed.has_p1_mode = true; - seed.p2_mode = ls.pad_mode[1]; seed.has_p2_mode = true; - seed.parappa_timing_mode = - g_parappa_timing_mode == 1 ? "medium" : - g_parappa_timing_mode == 2 ? "permissive" : - g_parappa_timing_mode == 3 ? "custom" : - g_parappa_timing_mode == 4 ? "easy" : "stock"; - seed.has_parappa_timing_mode = true; - seed.parappa_timing_extra_early = g_parappa_timing_extra_early; - seed.has_parappa_timing_extra_early = true; - seed.parappa_timing_extra_late = g_parappa_timing_extra_late; - seed.has_parappa_timing_extra_late = true; - - /* ---- deeper PSX-style settings write-back (mirrors the seed - * fields above), all gated on by the "psx" launcher_profile caps. */ - seed.window_width = ls.window_width; seed.has_window_width = true; - seed.renderer = ls.renderer; seed.has_renderer = true; - seed.supersampling = ls.supersampling; seed.has_supersampling = true; - seed.antialiasing = ls.antialiasing != 0; seed.has_antialiasing = true; - seed.screen_kind = ls.screen_kind; seed.has_screen_kind = true; - seed.frame_interpolation = ls.frame_interp != 0; - seed.has_frame_interpolation = frame_interpolation_offered; - seed.frame_interpolation_fps = ls.frame_interp_fps; - seed.has_frame_interpolation_fps = frame_interpolation_offered; - seed.spu_hq = ls.spu_hq != 0; seed.has_spu_hq = true; - seed.auto_skip_fmv = ls.auto_skip_fmv != 0; - seed.has_auto_skip_fmv = skip_fmv_offered; - seed.turbo_loads = ls.turbo_loads != 0; - seed.has_turbo_loads = turbo_loads_offered; - /* Bundled-BIOS builds ignore any launcher-supplied path (the - * picker is hidden, but a stale settings file could still - * carry one) — resolve_bios_for_runtime would reject it and - * the identity gate makes it meaningless. */ - if (ls.bios_path[0] && !psx_bios_image.image_bundled) { - seed.bios_path = ls.bios_path; - seed.has_bios_path = true; - } - /* Memory-card slots: enable flags + any Browse/New paths. */ - seed.memcard1_enabled = ls.memcard_enabled[0] != 0; seed.has_memcard1_enabled = true; - seed.memcard2_enabled = ls.memcard_enabled[1] != 0; seed.has_memcard2_enabled = true; - if (ls.memcard_path[0][0]) { seed.memcard1_path = ls.memcard_path[0]; seed.has_memcard1_path = true; } - if (ls.memcard_path[1][0]) { seed.memcard2_path = ls.memcard_path[1]; seed.has_memcard2_path = true; } - - /* Localization: persist the chosen language code (only meaningful - * when the game declared a menu; otherwise leave seed.language - * untouched. */ - if (!lang_menu_options.empty() && ls.language_index >= 0 && - ls.language_index < (int)lang_menu_options.size()) { - seed.language = lang_menu_options[ls.language_index].code; - seed.has_language = true; - } -#if defined(PSX_HAS_LOBBY_CLIENT) - if (ls.netplay_launch.enabled) { - const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); - if (caps && caps->valid) { - seed.aspect_num = caps->aspect_num ? caps->aspect_num : seed.aspect_num; - seed.aspect_den = caps->aspect_den ? caps->aspect_den : seed.aspect_den; - seed.has_aspect_ratio = true; - if (turbo_loads_offered) { - seed.turbo_loads = caps->turbo_loads != 0; - seed.has_turbo_loads = true; - } - if (skip_fmv_offered) { - seed.auto_skip_fmv = caps->auto_skip_fmv != 0; - seed.has_auto_skip_fmv = true; - } - if (caps->language[0]) { - seed.language = caps->language; - seed.has_language = true; - } - } - } -#endif - } - - if (lr == 1) { - std::fprintf(stdout, "psxrecomp: launcher closed; exiting.\n"); - return 0; - } - if (lr == 0) { - if (ls.netplay_launch.enabled) { - net_cfg.enabled = 1; - net_cfg.local_slot = ls.netplay_launch.local_slot; - net_cfg.input_player = ls.netplay_launch.input_player; - net_cfg.session_id = ls.netplay_launch.session_id; - net_cfg.input_delay = ls.netplay_launch.input_delay; - std::snprintf(net_cfg.bind_hostport, sizeof(net_cfg.bind_hostport), "%s", - ls.netplay_launch.bind_hostport); - std::snprintf(net_cfg.peer_hostport, sizeof(net_cfg.peer_hostport), "%s", - ls.netplay_launch.peer_hostport); - g_netplay_from_lobby = 1; - std::fprintf(stdout, - "psxrecomp: launcher netplay slot=%d bind=%s peer=%s session=%u\n", - net_cfg.local_slot, net_cfg.bind_hostport, net_cfg.peer_hostport, - (unsigned)net_cfg.session_id); - std::fflush(stdout); - } else { - g_netplay_from_lobby = 0; - } - g_video_renderer = seed.renderer; - g_video_scale = seed.supersampling; - g_video_aa = seed.antialiasing; - g_video_texfilter = seed.texture_filter; - g_video_screen = seed.screen_kind; - g_auto_skip_fmv = skip_fmv_offered && seed.auto_skip_fmv ? 1 : 0; - g_turbo_loads_enabled = - turbo_loads_offered && seed.turbo_loads ? 1 : 0; - fast_boot = seed.fast_boot; - bios_hle_requested = seed.bios_hle; - g_fullscreen = seed.fullscreen; - g_frame_interpolation = - frame_interpolation_offered && seed.frame_interpolation ? 1 : 0; - g_frame_interpolation_fps = - frame_interpolation_offered - ? seed.frame_interpolation_fps : 0; - g_video_aspect_num = ws_offered ? seed.aspect_num : 4; - g_video_aspect_den = ws_offered ? seed.aspect_den : 3; - g_ws_adaptive_view = ws_offered && seed.adaptive_view; - g_audio_spu_hq = seed.spu_hq; - skip_launcher_setting = seed.skip_launcher; - if (seed.has_bios_path) { - settings_bios_storage = seed.bios_path.string(); - bios_path = settings_bios_storage.c_str(); - } - if (seed.has_disc_path) { - seed.disc_path = normalize_disc_path_for_launch(seed.disc_path); - resolved_disc = seed.disc_path; - } - memcard1_enabled = seed.memcard1_enabled; - memcard2_enabled = seed.memcard2_enabled; - if (seed.has_memcard1_path) memcard1_path = seed.memcard1_path; - if (seed.has_memcard2_path) memcard2_path = seed.memcard2_path; - if (seed.has_language) resolved_language = seed.language; - p1_device = seed.p1_device; p2_device = seed.p2_device; - p1_mode = seed.p1_mode; p2_mode = seed.p2_mode; - if (seed.has_deadzone) resolved_deadzone = seed.deadzone; - g_video_win_w = seed.window_width; - /* Persist the user's choices next to the exe. */ - PSXRecompV4::save_user_settings( - exe_dir_from_argv(argv[0]) / "settings.toml", seed); - } - } - } -#endif - - /* Resolve the actual stock image before mod resolution. In particular, - * --disc is a late CLI override and must participate in target hashing; - * the launcher already stores its imported stock path in resolved_disc. */ - if (game_config_path || disc_override_path || !resolved_disc.empty()) { - resolved_disc = - resolve_disc_for_runtime(resolved_disc, disc_override_path, game_id, argv[0]); - if (game_config_path && resolved_disc.empty()) { - std::fprintf(stderr, "psxrecomp: no disc image selected; exiting.\n"); - return 1; - } - } - - { - std::string mod_error; - if (!PSXRecompV4::mod_runtime_commit(resolved_disc, &mod_error)) { - std::fprintf(stderr, "psxrecomp: cannot launch with selected mods: %s\n", - mod_error.c_str()); - return 1; - } - } - /* Activation callbacks are re-run after every launcher session. Clear - * game-owned controller overrides first so disabling a package cannot - * leave its prior mode latched across a soft return to the launcher. */ - g_mod_controller_mode_override[0] = -1; - g_mod_controller_mode_override[1] = -1; - g_mod_load_wall_multiplier = -1; - g_mod_load_release_frames = -1; - g_mod_disc_speed_divisor = -1; - g_mod_disc_instant_rate = -1; - g_turbo_load_wall_multiplier = 0; - g_turbo_load_release_frames = TURBO_LOADS_RELEASE_FRAMES; - if (!turbo_loads_offered) - g_turbo_loads_enabled = 0; -#ifdef PSX_DISABLE_FRAME_INTERPOLATION - /* Clamp each runtime session after configuration and launcher selection. - * Legacy settings and environment variables cannot revive this feature. */ - g_frame_interpolation = 0; - g_frame_interpolation_fps = 0; -#endif - g_frame_interpolation_blend = g_frame_interpolation_blend_default; - mod_runtime_activate_plugins(); - if (g_mod_controller_mode_override[0] >= 0) - p1_mode = g_mod_controller_mode_override[0]; - if (g_mod_controller_mode_override[1] >= 0) - p2_mode = g_mod_controller_mode_override[1]; - if (g_mod_load_wall_multiplier >= 0) { - g_turbo_loads_enabled = 1; - g_turbo_load_wall_multiplier = g_mod_load_wall_multiplier; - g_turbo_load_release_frames = g_mod_load_release_frames; - if (g_turbo_load_wall_multiplier) { - std::fprintf(stdout, - "psxrecomp: mod selected %dx load acceleration " - "(%d release frames)\n", - g_turbo_load_wall_multiplier, g_turbo_load_release_frames); - } else { - std::fprintf(stdout, - "psxrecomp: mod selected uncapped load acceleration " - "(%d release frames)\n", - g_turbo_load_release_frames); - } - } - - /* Re-apply the resolved language to the translation layer. text_xlate_init - * (at config load) only saw the game.toml default; this folds in the - * settings.toml override and the launcher's choice. No-op when unchanged. */ - text_xlate_set_language(resolved_language.c_str()); - - /* CLI overrides win over config — applied last, before backend/port init. - * Enables a soak fleet: several instances on distinct ports + renderers, - * including BIOS instances that have no [game]-block config. */ - if (cli_debug_port >= 0) debug_port = (uint16_t)cli_debug_port; - if (cli_renderer >= 0) g_video_renderer = cli_renderer; - if (cli_window_title) window_title = cli_window_title; - if (cli_memcard_dir) { - memcard_dir = std::filesystem::path(cli_memcard_dir); - if (memcard_dir.is_relative()) - memcard_dir = exe_dir_from_argv(argv[0]) / memcard_dir; - memcard_dir = memcard_dir.lexically_normal(); - memcard1_path.clear(); - memcard2_path.clear(); - } - - std::filesystem::path resolved_bios = - resolve_bios_for_runtime(bios_path, argv[0], bios_explicit); - if (resolved_bios.empty()) { - std::fprintf(stderr, "psxrecomp: no BIOS selected; exiting.\n"); - return 1; - } - /* memcard_dir was resolved to its default before the launcher (above). */ - - std::string bios_path_str = resolved_bios.string(); - std::string memcard_dir_str = memcard_dir.string(); - const std::filesystem::path& mod_disc = - PSXRecompV4::mod_runtime_effective_disc_path(); - std::string disc_path_str = - (mod_disc.empty() ? resolved_disc : mod_disc).string(); - if (!mod_disc.empty()) { - std::fprintf(stdout, - "psxrecomp: stock disc remains %s; mounting private mod cache %s\n", - resolved_disc.string().c_str(), mod_disc.string().c_str()); - } - -session_reboot: - /* Rematch after lobby soft-return re-enters here with updated net_cfg. */ - static int s_emu_session = 0; - const bool rematch_session = (++s_emu_session > 1); - std::fprintf(stdout, "psxrecomp runtime: loading BIOS from %s%s\n", - bios_path_str.c_str(), - rematch_session ? " (rematch)" : ""); - /* Soft-exit longjmps out of device service with a leftover guest clock; - * rematch must start from cycle 0 or vblanks never fire again. */ - psx_cycles_reset_for_boot(); - starvation_ring_reset(); - present_session_reset(); - { - extern uint64_t s_frame_count; - extern uint32_t g_debug_current_func_addr; - extern uint32_t g_debug_last_store_pc; - s_frame_count = 0; - g_debug_current_func_addr = 0; - g_debug_last_store_pc = 0; - } - memory_init(bios_path_str.c_str()); -#ifndef PSX_HAVE_VULKAN - /* Vulkan was not compiled in (PSX_ENABLE_VULKAN=OFF or no SDK found). - * Refuse a vulkan request from ANY source (config / CLI / launcher seed) - * and fall back to OpenGL, so the window is never created with - * SDL_WINDOW_VULKAN against an inert backend stub. */ - if (g_video_renderer == 2) { - std::fprintf(stdout, "psxrecomp: Vulkan renderer is not available in this build " - "(PSX_ENABLE_VULKAN=OFF); using OpenGL instead.\n"); - g_video_renderer = 1; - } -#endif - /* Select the renderer backend BEFORE gpu_init() (which runs gr_init -> - * the backend's init on the VRAM buffer). Software is the default and the - * fallback; an unavailable OpenGL backend reverts to software. */ - gr_set_backend(g_video_renderer == 2 ? GR_BACKEND_VULKAN : - g_video_renderer == 1 ? GR_BACKEND_OPENGL : GR_BACKEND_SOFTWARE); - std::fprintf(stdout, "psxrecomp: renderer backend requested: %s\n", - g_video_renderer == 2 ? "vulkan" : - g_video_renderer == 1 ? "opengl" : "software"); - gpu_init(); - /* Internal-resolution supersampling (SSAA). Must follow gpu_init (which - * runs sw_renderer_init). scale==1 is a no-op; >1 allocates the hi-res - * VRAM mirror. */ - if (g_video_scale < 1) g_video_scale = 1; - if (g_video_scale > SW_MAX_INTERNAL_SCALE) g_video_scale = SW_MAX_INTERNAL_SCALE; - gr_set_scale(g_video_scale); - g_video_scale = gr_scale(); /* reflect any clamp / alloc fallback */ - gr_set_texture_filter(g_video_texfilter); - gpu_geometry_correction_set(g_geometry_correction ? 1 : 0); - if (g_geometry_correction) - std::fprintf(stdout, - "psxrecomp: exact-provenance presentation geometry enabled " - "(ambiguous SXY fallback disabled; guest GP0/GTE coordinates preserved)\n"); - if (g_texture_correction && !g_geometry_correction) { - std::fprintf(stdout, - "psxrecomp: perspective texture correction requested but exact " - "geometry is disabled; keeping affine textures\n"); - g_texture_correction = false; - } - gpu_texture_correction_set(g_texture_correction ? 1 : 0); - if (g_texture_correction) - std::fprintf(stdout, - "psxrecomp: exact-depth perspective textures enabled on the " - "presentation mirror (canonical VRAM remains affine)\n"); - /* Display aspect. Identity at the default 4:3. The present letterbox uses - * this aspect; native-wide fills it with a genuinely wider frame (no - * stretch), squash mode stretches the 4:3 frame into it. */ - gl_renderer_set_display_aspect(g_video_aspect_num, g_video_aspect_den); - if (g_video_aspect_num * 3 != g_video_aspect_den * 4 || g_ws_adaptive_view) { - /* Hold widescreen off through the BIOS boot (authentic 4:3 logos); - * the per-frame present path engages it at game entry. */ - g_ws_engaged = false; - std::fprintf(stdout, - "psxrecomp: widescreen %d:%d (%s%s%s%s; engages at game entry)\n", - g_video_aspect_num, g_video_aspect_den, - g_ws_native_wide ? "native-wide, present 1:1" - : "GTE X-squash + stretched present", - g_ws_anchor_addr ? " + sprite tags" : "", - g_ws_hud_sprt ? " + HUD squash" : "", - g_ws_adaptive_view ? " + adaptive view" : ""); - } - /* Present-time screen-colour model (verified-enhancement LUT). Default raw - * is byte-identical; PSX_SCREEN env overrides this at scanout. */ - gpu_set_screen_kind(g_video_screen); - if (g_video_scale > 1 || g_video_texfilter) - std::fprintf(stdout, - "psxrecomp: supersampling %dx (antialiasing %s, texture filter %s)\n", - g_video_scale, g_video_aa ? "on" : "off", - g_video_texfilter ? "bilinear" : "nearest"); - if (g_video_screen != 0) - std::fprintf(stdout, "psxrecomp: screen-colour model %s\n", - g_video_screen == 1 ? "crt" : g_video_screen == 2 ? "composite" - : "trinitron"); - dma_init(); - mdec_init(); - timers_init(); - interrupts_init(); - sio_init(); - psx_event_step_conservative_env_init(); - /* Seed per-player device routing from the resolved [controller] config. - * SDL controller handles are opened later (after SDL_Init); here we only - * set the PSX-visible connection + pad type so the BIOS sees the right - * ports during early boot. */ - set_player_device(g_players[0], p1_device, p1_mode); - set_player_device(g_players[1], p2_device, p2_mode); - for (int s = 0; s < 2; s++) { - /* Dev-any-input keeps P1 connected even with no assigned controller so the - * keyboard / any plugged-in controller can drive port 1 standalone. */ - const bool dev_p1 = (dev_any_input_enabled() && s == 0); - sio_set_pad_connected(s, (g_players[s].kind != 0 || dev_p1) ? 1 : 0); - sio_set_pad_analog(s, pad_mode_boot_analog(g_players[s].mode), 0x80, 0x80, 0x80, 0x80); - } - /* SPU float-shadow gate must be set before spu_init() (which runs - * spu_shadow_reset()). Default OFF; PSX_AUDIO_SHADOW env overrides. */ - spu_shadow_set_enabled(g_audio_spu_hq ? 1 : 0); - if (g_audio_spu_hq) - std::fprintf(stdout, "psxrecomp: SPU float-shadow enabled (verified-enhancement)\n"); - spu_init(); - cdrom_init(disc_path_str.empty() ? NULL : disc_path_str.c_str()); - - /* A disc was requested but nothing mounted. cdrom_init() is non-fatal here - * (BIOS-only targets run with an empty drive on purpose), so without this - * check the game would boot into an empty drive and render NOTHING -- the - * "black screen on a .cue that works as a .bin" symptom. The earlier - * validate_disc_for_launch() pass cannot catch it: identify_disc() reads - * the data track FILE directly, so a cue whose sheet the mounting reader - * rejects still shows a green "Disc verified" badge. Report the actual - * failure instead of leaving the player staring at black. */ - if (!disc_path_str.empty() && !cdrom_has_disc()) { - const PSXRecompV4::DiscPathResolution r = - PSXRecompV4::resolve_disc_path(disc_path_str); - std::string detail = - "The disc image was found and verified, but the CD-ROM drive could " - "not mount it, so the game would boot with an empty drive.\n\n" - "Selected:\n" + disc_path_str; - if (r.mount != r.picked) detail += "\nMounted as:\n" + r.mount.string(); - if (!r.note.empty()) detail += "\n\n" + r.note; - detail += "\n\nIf this is a .cue, check that every FILE line it names " - "exists next to it; selecting the .bin directly also works."; - launcher_warning("Disc Could Not Be Mounted", detail); - return 1; - } - for (const auto& route : warm_cd_routes) { - cdrom_register_warm_route(route.arm_lba, route.lbas.data(), - (int)route.lbas.size(), - route.instant_max_per_frame); - std::fprintf(stdout, - "psxrecomp: warm CD route armed at LBA %d (%zu entries, " - "%d sectors/frame)\n", - route.arm_lba, route.lbas.size(), - route.instant_max_per_frame); - } - if (!disc_path_str.empty()) { - /* GetID must report the inserted disc's license region (the BIOS CD - * driver revalidates it mid-game). Derive it from the disc's boot - * serial via the same disc_identity module the launch check uses. */ - const auto ident = PSXRecompV4::identify_disc( - disc_path_str, /*expected_serial*/"", /*expected_crc*/0, - /*has_expected_crc*/false, /*compute_crc*/false); - if (ident.region == "PAL") cdrom_set_disc_scex("SCEE"); - else if (ident.region == "NTSC-J") cdrom_set_disc_scex("SCEI"); - else if (ident.region == "NTSC-U") cdrom_set_disc_scex("SCEA"); - if (!ident.region.empty()) - std::fprintf(stdout, "psxrecomp: disc region %s (serial %s)\n", - ident.region.c_str(), ident.detected_serial.c_str()); - } - /* Arm the text-image guard now that both possible sources are resolved: - * the local EXE file (dev checkouts) and the disc image (every install). */ - if (game_config_path) - arm_text_image_guard(text_guard_exe_path, text_guard_load_addr, - text_guard_text_size, - disc_path_str); - mod_runtime_enable_disc_patches(); - { - int divisor = 1; /* default: authentic 1x timing */ - if (disc_speed == "instant") divisor = 0; - else if (disc_speed == "4x") divisor = 4; - else if (disc_speed == "2x") divisor = 2; - if (g_mod_disc_speed_divisor >= 0) - divisor = g_mod_disc_speed_divisor; - /* Store for post-BIOS application; boot always runs at 1x so the - * BIOS disc-init sequence sees correct timing. */ - cdrom_set_game_speed(divisor); - if (g_mod_disc_instant_rate > 0) - cdrom_set_instant_rate(g_mod_disc_instant_rate); - else if (instant_rate > 0) - cdrom_set_instant_rate(instant_rate); - if (divisor != 1) - std::fprintf(stdout, "psxrecomp: disc speed divisor=%d (applied post-BIOS, " - "instant budget %d/frame)\n", - divisor, cdrom_get_instant_rate()); - } - { - std::string mc1 = memcard1_path.string(); - std::string mc2 = memcard2_path.string(); - const MemcardSlotConfig slots[2] = { - { mc1.empty() ? nullptr : mc1.c_str(), memcard1_enabled ? 1 : 0 }, - { mc2.empty() ? nullptr : mc2.c_str(), memcard2_enabled ? 1 : 0 }, - }; - memcard_init_slots(memcard_dir_str.c_str(), slots); - } - if (!rematch_session) { - std::atexit(memcard_flush_all); - /* Persist the game's native OPTION settings on any exit path (belt-and- - * suspenders; save-on-change already persists them live). No-op if not yet - * armed, so it can't overwrite the saved file with boot defaults. */ - std::atexit(game_options_save_now); -#ifndef PSX_NO_DEBUG_TOOLS - debug_server_init(debug_port); -#else - (void)debug_port; -#endif -#ifdef PSX_COSIM - cosim_init(); /* first-divergence oracle server */ -#endif - /* Heartbeat always on — see freeze_heartbeat.c rationale. */ - freeze_heartbeat_start("psx-runtime"); - } else { -#ifndef PSX_NO_DEBUG_TOOLS - (void)debug_port; -#endif - } - /* Register game entry_pc for post-BIOS disc speed switch. Fires once when - * the BIOS hands control to the game EXE — not on the BIOS shell. */ - if (game_entry_pc != 0) - fntrace_set_game_range(game_entry_pc, 0); - - if (g_headless) { - std::fprintf(stdout, "psxrecomp: headless frontend enabled\n"); - host_ui_runtime_ready(nullptr, PSX_HOST_UI_BACKEND_NONE); /* a game module sets its session up there, saved settings included */ - } else { - /* ---- SDL init ---- */ - /* Scale quality governs SDL's logical-size -> window scaling. Linear when - * antialiasing is on so the (super)sampled frame stays smooth when the - * window is resized; nearest preserves crisp pixels otherwise. */ - SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, g_video_aa ? "1" : "0"); - SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); - /* Prefer SDL's own HIDAPI driver over platform-native so Steam's virtual - * Xbox controller (injected by Steam Input / Remote Play) is enumerated - * as a game controller rather than a raw HID device. */ - SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI, "1"); - SDL_SetHint(SDL_HINT_JOYSTICK_RAWINPUT, "0"); - /* ...but HIDAPI's Xbox sub-driver is OFF by default on Windows (Xbox pads are - * normally RAWINPUT/XInput there). With RAWINPUT disabled above, a PHYSICAL - * Xbox One/Series controller would be claimed by nobody -> not a GameController - * -> zero input (PS5 DualSense works regardless: its HIDAPI driver is on by - * default). Enable the HIDAPI Xbox driver so HIDAPI handles Xbox pads too. */ - SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_XBOX, "1"); - if (!SDL_WasInit(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER)) { - if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) != 0) { - std::fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError()); - return 1; - } - } - load_input_config(argv[0]); - /* The launcher / settings.toml / game.toml deadzone (when set) is the - * user-facing authority; apply it over the input.ini value here, after - * load_input_config has read input.ini. */ - if (resolved_deadzone >= 0) - controller_deadzone = std::max(0, std::min(32767, resolved_deadzone)); - refresh_player_devices(); /* open SDL handles to match the player config */ -#ifndef PSX_SDL_NO_AUDIO - audio_trace_init(); - if (SDL_InitSubSystem(SDL_INIT_AUDIO) == 0) { - PsxSdlAudioSpec want = {}; - PsxSdlAudioSpec have = {}; - want.freq = 44100; - want.format = AUDIO_S16SYS; - want.channels = 2; - want.samples = 1024; - const bool legacy = audio_legacy_mode(); - want.allow_frequency_change = legacy ? 0 : 1; - if (!legacy) - want.callback = sdl_drc_callback; /* pull model: bridge resamples + DRC */ - sdl_audio_device = psx_sdl_audio_open(&want, &have); - if (sdl_audio_device) { - if (!legacy) { - rab_config cfg; rab_config_defaults(&cfg); - cfg.channels = 2; - cfg.source_rate = 44100.0; /* SPU render rate */ - cfg.host_rate = (double)have.freq; /* actual device rate */ - cfg.target_ms = g_audio_buffer_ms; /* per-game cushion */ - if (rab_init(&s_drc, &cfg) == 0) s_drc_ready = true; - } - g_audio_host_rate = have.freq; - audio_trace_set_tap_rate(AUDIO_TAP_HOST, (uint32_t)have.freq); - (void)psx_sdl_audio_resume(sdl_audio_device); - } - } -#endif - - Uint32 win_flags = SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE; - if (g_video_renderer == 1) { - configure_core_gl_context_attributes(); - win_flags |= SDL_WINDOW_OPENGL; - } - if (g_video_renderer == 2) win_flags |= SDL_WINDOW_VULKAN; - /* Fullscreen on launch (launcher's tri-state Fullscreen control): 1 = - * borderless desktop fullscreen (keeps the desktop resolution, letterboxes - * the image), 2 = exclusive fullscreen (real display-mode change), 0 = - * windowed. Matches the in-game Alt+Enter / Cmd+Ctrl+F hotkey behaviour. */ - win_flags |= psx_fullscreen_flag_for_mode(g_fullscreen); - /* Open at the user-chosen window size (default 1280 wide) instead of the - * old hardcoded 640x480, so the game doesn't boot into a tiny window. The - * height follows the configured display aspect (4:3 native, wider for the - * widescreen hack); the present path letterboxes to the same aspect, so - * the image scales to fill the larger window with no further distortion. */ - int game_w = g_video_win_w, game_h = 0; - clamp_window_aspect(&game_w, &game_h, g_video_aspect_num, g_video_aspect_den); - sdl_window = SDL_CreateWindow( - window_title.c_str(), - SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, - game_w, game_h, - win_flags - ); - if (!sdl_window) { - std::fprintf(stderr, "SDL_CreateWindow failed: %s\n", SDL_GetError()); - return 1; - } -#if defined(PSX_SDL3) - /* SDL3's creation flag can request fullscreen but cannot select its - * exclusive display mode. Complete a configured exclusive startup now, - * before renderer/context creation, so mode 2 is not silently borderless. */ - if (g_fullscreen == 2 && - !psx_apply_window_fullscreen_mode(sdl_window, g_fullscreen)) { - std::fprintf(stderr, - "psxrecomp: failed to enter configured exclusive fullscreen: %s\n", - SDL_GetError()); - } -#endif - - /* Sync-to-host-refresh: with SDL PRESENTVSYNC on, a fixed 59.94 Hz wall-clock - * pacer fights a 60.00 Hz panel — rendered frames slip onto an uneven vblank - * count (2/3/1 beat) that reads as moving-object judder. If the panel is - * within ~2% of 60 Hz, nudge the pacer to the exact panel period so the pacer - * and vsync agree and 30fps content pads to a steady 2 refreshes each. - * Non-~60Hz panels keep the PSX rate (vsync then governs; wrong-speed sim is - * worse than a benign slow beat). */ - { - SDL_DisplayMode dm; - int disp_idx = SDL_GetWindowDisplayIndex(sdl_window); - if (disp_idx >= 0 && SDL_GetCurrentDisplayMode(disp_idx, &dm) == 0 && dm.refresh_rate > 0) { - double host_hz = (double)dm.refresh_rate; - g_host_refresh_hz = host_hz; - if (g_mod_native_vblank_rate) { - if (g_mod_native_vblank_fps) { - std::printf("psxrecomp: native VBlank mod owns pacing at " - "%u FPS; ignoring %.2f Hz panel sync\n", - (unsigned)g_mod_native_vblank_fps, - host_hz); - } else { - std::printf("psxrecomp: native VBlank mod owns uncapped " - "pacing; ignoring %.2f Hz panel sync\n", - host_hz); - } - } else if (host_hz >= 58.8 && host_hz <= 61.2) { - g_frame_period_ms = 1000.0 / host_hz; - std::printf("psxrecomp: sync-to-host-refresh: pacing to %.2f Hz panel " - "(%.4f ms/frame)\n", host_hz, g_frame_period_ms); - } else { - std::printf("psxrecomp: host panel %.2f Hz not ~60 Hz; keeping PSX " - "59.94 Hz pacing\n", host_hz); - } - } - } - - /* OpenGL backend: create the GL context now. On failure, relabel the - * facade back to software (rasterization already runs through software in - * this phase) and fall through to the SDL_Renderer present path below. */ - if (g_video_renderer == 1) { - gl_renderer_set_swap_interval(g_video_vsync); /* applied at context init */ - g_gl_active = (gl_renderer_init_context(sdl_window) != 0); - if (!g_gl_active) gr_set_backend(GR_BACKEND_SOFTWARE); - /* The GL backend establishes its real internal scale HERE (raster init), - * which is AFTER the earlier `g_video_scale = gr_scale()` sync (that ran - * before this and so saw the default scale 1). Re-sync now so the staging - * buffer below is sized for the true scale and the native-wide present - * (which uses gr_scale()) matches it — otherwise sdl_pixel_buf is - * undersized and the wide readback overflows it. */ - g_video_scale = gr_scale(); - gl_renderer_set_interpolation(g_frame_interpolation, g_host_refresh_hz, - (double)g_frame_interpolation_fps, - g_frame_interpolation_blend); - } - /* Vulkan backend: create the instance/device/swapchain on the - * SDL_WINDOW_VULKAN window. On failure, fall back to software (vkb_init - * already initialized the software renderer on the shared VRAM array). */ - if (g_video_renderer == 2) { - vk_renderer_set_present_mode(g_video_vsync); - g_vk_active = (vk_renderer_init_context(sdl_window) != 0); - if (!g_vk_active) gr_set_backend(GR_BACKEND_SOFTWARE); - g_video_scale = gr_scale(); - } - latency_ring_set_backend(g_vk_active ? "vulkan" : g_gl_active ? "opengl" : "software"); - latency_ring_set_present_mode(g_video_vsync); - /* Title bar shows the clean game title (set at window creation); the active - * renderer is reported via the debug server / config, not appended here. */ - - /* Force OpenGL renderer. - * - * History (TombaRecomp/ISSUES.md #6): - * 1. Originally SDL_RENDERER_ACCELERATED with fallback to software. - * Froze "Not Responding" after extended uptime — was thought to - * be GPU-driver-side hangs. - * 2. Switched to SDL_RENDERER_SOFTWARE only. Still froze. Software - * renderer goes through Windows GDI; the GDI path hangs the SDL - * main thread under heavy emulation load. - * 3. Bisection: NO_AUDIO+NO_RENDER ran indefinitely (~7+ min, 40k+ - * frames) but the game never progressed past BIOS boot because - * it depends on the renderer being present. NO_AUDIO alone with - * software renderer froze at frame 3084 — same as full debug - * build. So audio is innocent; software renderer (GDI path) is - * the culprit. - * 4. SDL_HINT_RENDER_DRIVER=opengl + SDL_RENDERER_ACCELERATED. - * Ran indefinitely past every prior freeze point. OpenGL driver - * uses a different presentation path that doesn't hit the GDI - * hang. This is now the default. - * - * Note: the freeze became prevalent only after the FMV-speed fix - * (commit b486c13) raised cycle throughput. Before that, the slower - * MDEC/DMA workload was below whatever GDI threshold trips the bug. */ - /* The OpenGL force above is a Windows-only workaround for the GDI - * presentation hang; macOS/Linux have no GDI path, so let SDL choose its - * native backend (Metal on Apple Silicon). PRESENTVSYNC removes tearing; - * fall back progressively if a driver can't provide vsync/accel. */ - if (!g_gl_active && !g_vk_active) { -#ifdef _WIN32 - SDL_SetHint(SDL_HINT_RENDER_DRIVER, "opengl"); -#endif - /* Vsync off (g_video_vsync==0) drops PRESENTVSYNC for lowest display - * latency; the wall-clock pacer still holds 59.94Hz (may tear). */ - Uint32 rflags = SDL_RENDERER_ACCELERATED | - (g_video_vsync != 0 ? SDL_RENDERER_PRESENTVSYNC : 0u); - sdl_renderer = SDL_CreateRenderer(sdl_window, -1, rflags); - if (!sdl_renderer) - sdl_renderer = SDL_CreateRenderer(sdl_window, -1, SDL_RENDERER_ACCELERATED); - if (!sdl_renderer) { - std::fprintf(stderr, "SDL_CreateRenderer failed: %s\n", SDL_GetError()); - return 1; - } - - /* Present in a logical space of the configured aspect (640x480 at native - * 4:3, wider at widescreen aspects; scaled by the supersampling factor so - * the full internal resolution reaches a large/fullscreen window; SDL - * still scales and letterboxes to the real output). Identity in the - * default window when supersampling is off, so native rendering is - * unchanged. */ - g_logical_w = 480 * g_video_aspect_num * g_video_scale / g_video_aspect_den; - SDL_RenderSetLogicalSize(sdl_renderer, g_logical_w, 480 * g_video_scale); - } - - /* Staging buffer + backing texture are sized for the internal resolution - * (640x512 native, times the supersampling factor). */ - sdl_pixel_buf = (uint32_t*)std::malloc( - (size_t)640 * g_video_scale * 512 * g_video_scale * sizeof(uint32_t)); - if (!sdl_pixel_buf) { - std::fprintf(stderr, "failed to allocate %dx staging buffer\n", g_video_scale); - return 1; - } - sdl_pixel_buf_scale_capacity = g_video_scale; - - if (!g_gl_active && !g_vk_active) { - sdl_texture = SDL_CreateTexture( - sdl_renderer, - SDL_PIXELFORMAT_ARGB8888, - SDL_TEXTUREACCESS_STREAMING, - 640 * g_video_scale, 512 * g_video_scale - ); - if (!sdl_texture) { - std::fprintf(stderr, "SDL_CreateTexture failed: %s\n", SDL_GetError()); - return 1; - } - SDL_SetTextureScaleMode(sdl_texture, - g_video_aa ? SDL_ScaleModeLinear : SDL_ScaleModeNearest); - } - - host_ui_runtime_ready( - sdl_window, - g_gl_active ? PSX_HOST_UI_BACKEND_OPENGL : - g_vk_active ? PSX_HOST_UI_BACKEND_VULKAN : - PSX_HOST_UI_BACKEND_SOFTWARE); - } - - /* Register vblank presentation callback. */ - gpu_set_vblank_callback(sdl_vblank_present); - - /* Delay-sync LAN (recomp-net). Menu/lobby UI is later work — CLI/env only. - * Must start after SDL so local pad capture has devices; before the guest - * fiber so the first vblanks already pump HELLO/START. */ - if (net_cfg.enabled) { - /* Transport role and gameplay slot are independent. An empty peer - * means this process listens for the first peer, even when the lobby - * owner was reordered into gameplay slot 1 (Player 2). */ - if (!net_cfg.bind_hostport[0]) { - std::fprintf(stderr, - "psxrecomp: netplay refused — empty bind " - "(bind='%s' peer='%s' session=%u)\n", - net_cfg.bind_hostport, net_cfg.peer_hostport, - (unsigned)net_cfg.session_id); - return 1; - } - /* Resolve which host PlayerInput feeds this peer's net sample. - * Auto: prefer g_players[local_slot] when assigned (same-PC: host - * C40 on P1 + guest keyboard on P2); else player 0 (two-machine). */ - if (net_cfg.input_player != 0 && net_cfg.input_player != 1) { - const int prefer = (net_cfg.local_slot == 1) ? 1 : 0; - if (prefer == 1 && g_players[1].kind != 0) - net_cfg.input_player = 1; - else - net_cfg.input_player = 0; - } - const int nrc = psx_netplay_start(&net_cfg); - if (nrc != 0) { - std::fprintf(stderr, - "psxrecomp: netplay start failed (%d) — built without recomp-net, " - "or bind/peer invalid (slot=%d bind=%s peer=%s)\n", - nrc, net_cfg.local_slot, net_cfg.bind_hostport, net_cfg.peer_hostport); - return 1; - } - std::printf("psxrecomp: netplay LAN slot=%d input_player=%d delay=%d " - "bind=%s peer=%s session=%u\n", - net_cfg.local_slot, net_cfg.input_player, net_cfg.input_delay, - net_cfg.bind_hostport, net_cfg.peer_hostport, - (unsigned)net_cfg.session_id); - } - - /* Initialize CPU state. */ - CPUState cpu; - std::memset(&cpu, 0, sizeof(cpu)); - /* R3000A load-delay interlock init (Beetle: BACKED_LDWhich=0x20 = no pending - * load; ReadFudge=0 so the first load gets no fudge). Rest is correctly 0. */ - cpu.ld_which_t = 0x20; - psx_icache_reset(); /* all I-cache lines cold at reset */ - - /* Wire memory function pointers. Guest data loads now route their CPU cycle - * cost through the faithful load-delay interlock (psx_cyc_load_* in memory.c), - * so these VALUE accessors are the UNCHARGED psx_read_* — the data-access - * wait-state is charged once, inside the interlock, never here. */ - cpu.read_word = psx_read_word; - cpu.write_word = psx_write_word; - cpu.read_half = psx_read_half; - cpu.write_half = psx_write_half; - cpu.read_byte = psx_read_byte; - cpu.write_byte = psx_write_byte; - /* (sljit removed 2026-07-15: overlay_sljit_init_helpers wired the JIT-shard - * host-helper table into cpu here; no shards consume it now.) */ - - /* BIOS backend select (CLAUDE.md §0 amendment 2026-07-02): LLE (the - * recompiled BIOS — default, reference implementation, oracle) vs the - * opt-in HLE tier (implemented kernel services computed in-runtime, LLE - * fallback for everything else). Boot always starts at the real reset - * vector; with the HLE boot-skip on, the tier's dispatch hook intercepts - * the shell entry one-shot (the boot animation is skipped; kernel init + - * game EXE load still run in the real recompiled BIOS, unpaced) — this - * REPLACES the old fast_boot snapshot restore, and fast_boot=true now - * aliases the boot-skip alone. Env overrides: PSX_BIOS_HLE / - * PSX_BIOS_HLE_KEEP_INTRO ('0' = off, anything else = on). - * - * The two axes are decided in ONE pure place — psx_bios_hle_plan(), - * runtime/src/bios_hle_plan.c — because they have DIFFERENT per-image - * requirements and conflating them broke boot-skip on OpenBIOS: call-HLE - * needs deliver_event_ret, the boot-skip needs only shell_entry_phys, so - * refusing the former must not silently cancel the latter. See that - * header for the full rationale and the bug it fixes. */ - { - if (const char* e = std::getenv("PSX_BIOS_HLE")) - bios_hle_requested = (e[0] && e[0] != '0'); - if (const char* e = std::getenv("PSX_BIOS_HLE_KEEP_INTRO")) - bios_hle_keep_intro = (e[0] && e[0] != '0'); -#ifdef PSX_HAS_DISRUPTOR_INTRO_SKIP - if (const char* e = std::getenv("PSX_DISRUPTOR_SKIP_INTRO")) - if (e[0] && e[0] != '0') fast_boot = true; -#endif - - PsxBiosHleRequest req; - req.bios_hle = bios_hle_requested ? 1 : 0; - req.keep_intro = bios_hle_keep_intro ? 1 : 0; - req.fast_boot = fast_boot ? 1 : 0; - req.have_deliver_event_ret = (psx_bios_image.deliver_event_ret != 0); - req.have_shell_entry = (psx_bios_image.shell_entry_phys != 0); - req.have_game_entry = (game_entry_pc != 0); - const PsxBiosHlePlan plan = psx_bios_hle_plan(req); - - /* Call-HLE is a per-image capability, not just a preference: its kernel - * service semantics were validated against the SCPH1001 kernel, and an - * image that exports no DeliverEvent anchor (psx_bios_image - * deliver_event_ret == 0, e.g. OpenBIOS until validated) declares that - * axis STRUCTURALLY UNAVAILABLE. Config/env cannot turn it on for such - * a BIOS — servicing B0 events with mismatched semantics wedges the - * guest in event waits (OpenBIOS bring-up: the shell menu hung in a - * CD-event wait under HLE, booted the game under LLE). */ - if (plan.call_hle_denied) { - std::fprintf(stdout, - "psxrecomp: bios_hle kernel-call tier unavailable on %s (no " - "DeliverEvent anchor); kernel calls stay LLE\n", - psx_bios_image.image_id); - } - /* Boot-skip refusal is only possible on an image with no shell entry at - * all. Say so rather than quietly playing the intro. */ - if (plan.boot_skip_denied) { - std::fprintf(stdout, - "psxrecomp: BIOS boot-skip unavailable on %s (no shell entry " - "anchor); playing the real intro\n", - psx_bios_image.image_id); - } - psx_bios_hle_configure(plan.call_hle, plan.boot_skip); - std::fprintf(stdout, "psxrecomp: bios_backend=%s bios_boot=%s\n", - psx_bios_hle_backend_name(), - psx_bios_hle_boot_skip_enabled() - ? "skip to game (shell skipped)" - : "real intro"); - } - - /* R3000A reset state. */ - cpu.pc = 0xBFC00000u; - cpu.cop0[12] = 0x00400000u; /* SR: BEV=1 (boot exception vectors) */ - - /* User save states (F1-F12 / Shift+F1-F12): slots live in the per-game - * memcard/save dir, keyed by entry_pc + guarded by the boot_state integrity - * key (codegen hash/abi/ver) so a stale/foreign state can never load. */ - if (game_entry_pc != 0) { - savestate_configure(memcard_dir.string().c_str(), - memory_get_bios_checksum(), game_entry_pc); -#ifdef PSX_HAS_DISRUPTOR_LANGUAGE - /* Before any state can be asked for: a language disc's states have a folder of their own. */ - disruptor_language_settle(); -#endif - /* Headless / agent load: PSX_LOAD_SLOT=N stages F1..F12 load (0..11) - * at the next safe block boundary after boot. */ - if (const char *ls = std::getenv("PSX_LOAD_SLOT")) { - int slot = atoi(ls); - if (slot >= 0 && slot < 12) { - if (psx_netplay_active()) { - std::fprintf(stdout, - "psxrecomp: PSX_LOAD_SLOT=%d ignored (use host F1–F12 after netplay starts)\n", - slot); - } else if (savestate_request_load(slot)) { - std::fprintf(stdout, "psxrecomp: PSX_LOAD_SLOT=%d staged\n", slot); - } - } - } - } - /* Guest netplay: sandbox .pst/.mcd under saves/netplay/ after personal - * roots are known (must follow savestate_configure + memcard_init). */ - psx_netplay_bind_guest_saves(); - - /* Let memory subsystem see SR for cache-isolation checks. */ - memory_set_sr_ptr(&cpu.cop0[12]); - - /* Wire debug server to CPU state for register queries. */ - debug_server_set_cpu(&cpu); - - /* Execute. */ - std::fprintf(stdout, "psxrecomp runtime: executing from PC=0x%08X\n", cpu.pc); - -#if defined(PSX_ORACLE_BUILD) - std::fprintf(stdout, "psxrecomp ORACLE: interpreter mode (port %d)\n", DEFAULT_DEBUG_PORT); - interp_init(&cpu); - interp_trace_enable(1); - - /* Breakpoints focused on VSync diagnostics. - * Only break on the VSync incrementer — count how many times it's hit. */ - interp_break_add(0x8005A5BCu); /* VSync counter incrementer — KSEG0 */ - - std::fprintf(stderr, "ORACLE: running with BP at 0x8005A5BC (VSync incrementer)...\n"); - std::fflush(stderr); - - uint64_t total_executed = 0; - const uint64_t max_total = 100000000ULL; /* 100M instructions */ - uint32_t vsync_hits = 0; - for (;;) { - uint32_t ran = interp_step(&cpu, 1000000); - total_executed += ran; - if (interp_hit_breakpoint()) { - vsync_hits++; - if (vsync_hits <= 3 || (vsync_hits % 100 == 0)) { - std::fprintf(stderr, "ORACLE: VSync hit #%u at %llu instructions, ra=0x%08X, gte_exec=%llu\n", - vsync_hits, (unsigned long long)total_executed, cpu.gpr[31], - (unsigned long long)gte_get_exec_count()); - /* Dump last 20 trace entries for first 3 hits. */ - if (vsync_hits <= 3) { - uint64_t tseq = interp_trace_count(); - uint32_t tavail = (tseq < 1048576ULL) ? (uint32_t)tseq : 1048576u; - uint32_t tstart = (tavail > 20) ? tavail - 20 : 0; - for (uint32_t ti = tstart; ti < tavail; ti++) { - const InterpTraceEntry* e = interp_trace_get(ti); - if (e) std::fprintf(stderr, " [%llu] PC=0x%08X insn=0x%08X ra=0x%08X v0=0x%08X\n", - (unsigned long long)e->seq, e->pc, e->insn, - e->gpr[31], e->gpr[2]); - } - } - std::fflush(stderr); - } - /* Step past the breakpoint: temporarily remove, step 1, re-add. */ - uint32_t bp_pc = cpu.pc; - interp_break_remove(bp_pc); - interp_step(&cpu, 1); - total_executed++; - interp_break_add(bp_pc); - } - if (ran == 0) { - std::fprintf(stderr, "ORACLE: halted at PC=0x%08X after %llu total instructions\n", - cpu.pc, (unsigned long long)total_executed); - break; - } - if (total_executed >= max_total) { - std::fprintf(stderr, "ORACLE: reached %llu instructions, stopping. PC=0x%08X\n", - (unsigned long long)total_executed, cpu.pc); - /* Dump last 30 trace entries (ring-relative). */ - uint64_t tseq2 = interp_trace_count(); - uint32_t tavail2 = (tseq2 < 1048576ULL) ? (uint32_t)tseq2 : 1048576u; - uint32_t tstart2 = (tavail2 > 30) ? tavail2 - 30 : 0; - for (uint32_t ti = tstart2; ti < tavail2; ti++) { - const InterpTraceEntry* e = interp_trace_get(ti); - if (e) std::fprintf(stderr, " [%llu] PC=0x%08X insn=0x%08X ra=0x%08X\n", - (unsigned long long)e->seq, e->pc, e->insn, e->gpr[31]); - } - std::fflush(stderr); - break; - } - /* Poll debug server. */ - debug_server_poll(); - } - /* Read VSync counter from RAM. */ - uint32_t vsync_counter = cpu.read_word(0x80079D9Cu); - uint32_t init_flag_48 = cpu.read_word(0x80079D48u); - uint32_t init_flag_4C = cpu.read_word(0x80079D4Cu); - std::fprintf(stderr, "ORACLE: VSync counter = 0x%08X (%u), init_flag@48 = 0x%08X, init_flag@4C = 0x%08X\n", - vsync_counter, vsync_counter, init_flag_48, init_flag_4C); - std::fprintf(stderr, "ORACLE: VSync incrementer hit count = %u\n", vsync_hits); - std::fflush(stderr); -#else - /* Deterministic TCB-scheduler trampoline (carve-out): wraps the top-level - * dispatch so a cooperative thread switch unwinds here and re-dispatches the - * target thread, instead of the old per-frame host-fiber recreate. Returns - * only on the abnormal top-level pc==0 exit; the diagnostic dump below runs - * exactly as it did for the bare psx_dispatch. (HLE_SCHEDULER_CARVEOUT_PLAN.md) - * - * Hidden toggle: PSX_HLE_SCHEDULER=0 reverts to the legacy LLE host-fiber - * bridge (default 1 = HLE). The trampoline is transparent in LLE mode (the - * fiber path never longjmps to it). */ - std::fprintf(stdout, "psxrecomp: thread scheduler = %s (PSX_HLE_SCHEDULER)\n", - psx_hle_scheduler_enabled() ? "HLE (deterministic TCB)" - : "LLE (host fibers)"); - std::fflush(stdout); - - /* General control-flow parity trace (parity_trace.h). Armed from boot via - * PSX_PARITY_TRACE=1 so the pre-divergence window is always covered (never - * arm-then-time). Watched TCB / freeze trigger / watch words are env-tunable; - * defaults target the MMX6 cutscene→gameplay wedge (thread1 0xA000E35C, - * trigger dispatch 0x800CD3F8, watch the handshake flag + state struct). */ - if (const char* pt = std::getenv("PSX_PARITY_TRACE"); pt && pt[0] && pt[0] != '0') { - auto envhex = [](const char* k, uint32_t dflt) -> uint32_t { - const char* v = std::getenv(k); - return (v && v[0]) ? (uint32_t)std::strtoul(v, nullptr, 0) : dflt; - }; - uint32_t tcb = envhex("PSX_PARITY_TCB", 0xA000E35Cu); - uint32_t trigger = envhex("PSX_PARITY_TRIGGER", 0x800CD3F8u); - uint32_t watch[PARITY_WATCH_MAX] = { - 0x801FEB78u, /* thread1 stack saved-ra slot */ - 0x8006D9ACu, /* handshake flag (setter target) */ - 0x800CD3F8u, /* cutscene state struct: outer/sub */ - 0x800CD3FCu, /* countdown */ - 0x800CD404u, /* done flag region */ - 0x800200F4u, /* func_8002000C resume point */ - }; - int wc = PARITY_WATCH_MAX; - if (const char* wl = std::getenv("PSX_PARITY_WATCH"); wl && wl[0]) { - wc = 0; std::string s(wl); size_t p = 0; - while (p < s.size() && wc < PARITY_WATCH_MAX) { - size_t c = s.find(',', p); - std::string tok = s.substr(p, c == std::string::npos ? c : c - p); - if (!tok.empty()) watch[wc++] = (uint32_t)std::strtoul(tok.c_str(), nullptr, 0); - if (c == std::string::npos) break; p = c + 1; - } - } - parity_trace_config(tcb, trigger, 0x88u, 0x00u, watch, wc); - parity_trace_arm(1); - std::fprintf(stdout, "psxrecomp: parity trace ARMED tcb=0x%08X trigger=0x%08X (%d watch)\n", - tcb, trigger, wc); - std::fflush(stdout); - } - - /* Device-event cycle ring (device_trace.h): armed from boot under the same - * PSX_PARITY_TRACE gate, or PSX_DEVTRACE=1 standalone. Captures every - * CD/DMA/timer/VBlank/SIO/SPU IRQ raise with its guest cycle for the - * cross-process device-timing diff (tools/devtrace_diff.py). */ - { - const char* pt = std::getenv("PSX_PARITY_TRACE"); - const char* dt = std::getenv("PSX_DEVTRACE"); - if ((pt && pt[0] && pt[0] != '0') || (dt && dt[0] && dt[0] != '0')) { - device_trace_arm(1); - std::fprintf(stdout, "psxrecomp: device-event trace ARMED\n"); - std::fflush(stdout); - } - } - - /* Delay-sync: do not free-run boot while HELLO/START is in flight. - * Park until tick 0 pads are published, then enter the guest. */ - if (psx_netplay_active()) { - std::printf("psxrecomp: netplay waiting for peer START + tick-0 admit…\n"); - std::fflush(stdout); - netplay_barrier_admit(-1); - if (psx_return_to_lobby_requested()) - goto soft_return_lobby; - netplay_host_present_uncap(); - std::printf("psxrecomp: netplay lockstep armed (sim_tick=%u, vsync off)\n", - (unsigned)psx_netplay_sim_tick()); - std::fflush(stdout); - } - - psx_scheduler_run(&cpu); - if (psx_return_to_lobby_requested() && g_netplay_from_lobby) - goto soft_return_lobby; -#endif - - /* If we reach here, all execution completed without MMIO abort. - * During normal operation the guest runs an infinite main loop and the - * top-level psx_dispatch NEVER returns. Reaching this point means the - * outermost trampoline loop saw cpu->pc == 0 (some jr/tail-transfer - * published a null PC) — an abnormal boot exit. Dump the always-on - * fntrace ring tail (last dispatch chain) to a JSON artifact so we can - * see exactly which targets led to the null PC. (CLAUDE.md ring-buffer - * model: consume the always-on ring after the fact, not arm-and-time.) */ - { - FILE* tf = std::fopen("psx_cps_exit_trace.json", "wb"); - if (tf) { - std::fprintf(tf, "{\n \"final_pc\": \"0x%08X\",\n \"final_ra\": \"0x%08X\",\n" - " \"final_sp\": \"0x%08X\",\n \"fntrace_seq\": %llu,\n \"tail\": [\n", - cpu.pc, cpu.gpr[31], cpu.gpr[29], - (unsigned long long)g_fntrace_seq); - uint64_t seq = g_fntrace_seq; - uint32_t n = seq < 128u ? (uint32_t)seq : 128u; - for (uint32_t i = 0; i < n; i++) { - uint64_t idx = seq - n + i; - const FntraceEntry* e = &g_fntrace_ring[idx % FNTRACE_RING_CAP]; - std::fprintf(tf, - " {\"seq\":%llu,\"frame\":%u,\"target\":\"0x%08X\",\"ra\":\"0x%08X\"," - "\"sp\":\"0x%08X\",\"a0\":\"0x%08X\",\"a1\":\"0x%08X\"}%s\n", - (unsigned long long)idx, e->frame, e->target, e->ra, e->sp, - e->a0, e->a1, (i + 1 < n) ? "," : ""); - } - std::fprintf(tf, " ],\n \"stack\": [\n"); - uint32_t sbase = (cpu.gpr[29] - 0x40u) & ~3u; - for (int w = 0; w < 40; w++) { - uint32_t a = sbase + (uint32_t)w * 4u; - uint32_t v = cpu.read_word(a); - std::fprintf(tf, " {\"addr\":\"0x%08X\",\"val\":\"0x%08X\"}%s\n", - a, v, (w < 39) ? "," : ""); - } - std::fprintf(tf, " ]\n}\n"); - std::fclose(tf); - } - } - - /* Diagnostic: the guest published a null PC at the top level (abnormal). With - * PSX_EXIT_HALT set, halt-and-serve here instead of shutting down so the - * still-loaded overlays + full guest state are live-inspectable over TCP. */ - { const char *e = std::getenv("PSX_EXIT_HALT"); - if (e && e[0] && e[0] != '0') { - extern void psx_fatal_halt(const char *reason); - psx_fatal_halt("top-level dispatch returned PC=0 (abnormal boot exit — inspect live)"); - } - } - - std::fprintf(stdout, "psxrecomp runtime: execution completed, PC=0x%08X\n", cpu.pc); - { extern uint64_t g_slice_fired, g_slice_irq_taken, g_dirty_ram_insns_run; - extern uint32_t g_slice_exit_pc, g_slice_exit_reason, g_slice_exit_iter; - extern uint32_t g_slice_exit_dispatchable, g_slice_exit_dirty, g_slice_exit_in_text, g_slice_exit_want; - std::fprintf(stdout, "psxrecomp runtime: [slice diag] slice_fired=%llu slice_irq_taken=%llu dirty_insns=%llu exit_pc=0x%08X reason=%u iter=%u dispatchable=%u dirty=%u in_text=%u want=%u\n", - (unsigned long long)g_slice_fired, (unsigned long long)g_slice_irq_taken, - (unsigned long long)g_dirty_ram_insns_run, g_slice_exit_pc, g_slice_exit_reason, - g_slice_exit_iter, g_slice_exit_dispatchable, g_slice_exit_dirty, - g_slice_exit_in_text, g_slice_exit_want); } - - shutdown_runtime(); - if (g_gl_active) gl_renderer_shutdown(); - if (g_vk_active) vk_renderer_shutdown(); - SDL_DestroyTexture(sdl_texture); /* NULL-safe in GL mode */ - SDL_DestroyRenderer(sdl_renderer); /* NULL-safe in GL mode */ - SDL_DestroyWindow(sdl_window); - SDL_Quit(); - - return 0; - -soft_return_lobby: - /* Netplay soft-exit: tear down the match window. Lobby resume will be - * restored through recomp-ui. */ - teardown_game_session_keep_lobby(); - psx_lobby_disconnect(); - SDL_Quit(); - return 0; -} diff --git a/psxrecomp-overlay/runtime/src/memory.c b/psxrecomp-overlay/runtime/src/memory.c deleted file mode 100644 index 86e933d..0000000 --- a/psxrecomp-overlay/runtime/src/memory.c +++ /dev/null @@ -1,2106 +0,0 @@ -/* memory.c — Phase 2 PS1 memory system. - * - * Physical address routing: - * 0x00000000..0x001FFFFF — 2 MB RAM - * 0x1F800000..0x1F8003FF — 1 KB scratchpad - * 0x1F801000..0x1F803FFF — MMIO (fatal abort) - * 0x1FC00000..0x1FC7FFFF — 512 KB BIOS ROM (read-only) - * Everything else — fatal abort (unmapped) - */ - -#include "cpu_state.h" -#include "cdrom.h" -#include "crash_trace.h" -#include "dma.h" -#include "fntrace.h" -#include "gpu.h" -#include "gte_precision.h" -#include "mdec.h" -#include "mod_memory.h" -#include "sio.h" -#include "spu.h" -#include "timers.h" -#include "lockstep.h" -#include "data_shards.h" -#include "dirty_ram_interp.h" -#include "psx_cycles.h" -#include "starvation_ring.h" -#include -#include -#include -#include - -#define RAM_SIZE (2 * 1024 * 1024) -#define SCRATCHPAD_SIZE 1024 -#define BIOS_ROM_SIZE (512 * 1024) -#define MOD_MEMORY_BASE 0x1F000000u -#define MOD_MEMORY_SIZE (1u * 1024u * 1024u) - -static uint8_t ram[RAM_SIZE]; -static uint8_t scratchpad[SCRATCHPAD_SIZE]; -static uint8_t bios_rom[BIOS_ROM_SIZE]; -static uint8_t mod_memory[MOD_MEMORY_SIZE]; -static uint32_t mod_memory_used; -static uint8_t mod_gpu_dma_memory[PSX_MOD_GPU_DMA_APERTURE_SIZE]; -static uint32_t mod_gpu_dma_memory_used; - -/* - * Trusted mods may opt into host-backed guest memory in Expansion 1. Before - * the first allocation this entire region retains hardware open-bus behavior. - */ -uint32_t psx_mod_memory_alloc(uint32_t size, uint32_t alignment) { - uint32_t start; - if (size == 0u) return 0u; - if (alignment == 0u) alignment = 1u; - if ((alignment & (alignment - 1u)) != 0u || alignment > 4096u) return 0u; - start = (mod_memory_used + alignment - 1u) & ~(alignment - 1u); - if (start > MOD_MEMORY_SIZE || size > MOD_MEMORY_SIZE - start) return 0u; - memset(mod_memory + start, 0, size); - mod_memory_used = start + size; - return 0x9F000000u + start; -} - -static int mod_memory_offset(uint32_t phys, uint32_t width, uint32_t *offset) { - uint32_t off; - if (phys < MOD_MEMORY_BASE) return 0; - off = phys - MOD_MEMORY_BASE; - if (off > mod_memory_used || width > mod_memory_used - off) return 0; - if (offset) *offset = off; - return 1; -} - -uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t size, uint32_t alignment) { - uint32_t start; - if (size == 0u) return 0u; - if (alignment == 0u) alignment = 1u; - if ((alignment & (alignment - 1u)) != 0u || alignment > 4096u) return 0u; - start = (mod_gpu_dma_memory_used + alignment - 1u) & ~(alignment - 1u); - if (start > PSX_MOD_GPU_DMA_APERTURE_SIZE || - size > PSX_MOD_GPU_DMA_APERTURE_SIZE - start) - return 0u; - memset(mod_gpu_dma_memory + start, 0, size); - mod_gpu_dma_memory_used = start + size; - return PSX_MOD_GPU_DMA_GUEST_BASE + start; -} - -uint32_t psx_mod_gpu_dma_memory_bytes(void) { return mod_gpu_dma_memory_used; } -const uint8_t *psx_mod_gpu_dma_memory_data(void) { return mod_gpu_dma_memory; } -int psx_mod_gpu_dma_memory_restore(const uint8_t *data, uint32_t size) { - if (size > PSX_MOD_GPU_DMA_APERTURE_SIZE || (size && !data)) return 0; - if (size) memcpy(mod_gpu_dma_memory, data, size); - memset(mod_gpu_dma_memory + size, 0, PSX_MOD_GPU_DMA_APERTURE_SIZE - size); - mod_gpu_dma_memory_used = size; - return 1; -} - -static int mod_gpu_dma_memory_offset(uint32_t phys, uint32_t width, - uint32_t *offset) { - return psx_mod_gpu_dma_aperture_offset_for( - phys, width, mod_gpu_dma_memory_used, offset); -} - -uint32_t psx_mod_gpu_dma_resolve_address(uint32_t address) { - return psx_mod_gpu_dma_resolve_address_for( - address, mod_gpu_dma_memory_used); -} - -/* Exposed for inlined main-RAM load helpers in psx_cyc.h (VLC/decode hot path). */ -uint8_t *g_psx_ram = ram; -/* PSX_LOAD_DELAY gate (default on). −1 = unread; 0/1 after first resolve. */ -int g_psx_load_delay = -1; - -/* Physical address translation for guest accesses. The 2 MB main RAM is - * mirrored 4x across the first 8 MB of each segment (mem-ctrl RAM_SIZE - * register, default 0x0B88) and games rely on it — Kula World's crt0 - * parks $sp in the 4th mirror (0x807FFFF8). Fold the mirrors here so the - * RAM bounds checks below see canonical offsets; without this, mirror - * writes fell into the open-bus no-op and mirror reads returned 0 (the - * guest's stack silently vanished and $ra came back as 0). */ -static inline uint32_t psx_phys_addr(uint32_t addr) { - uint32_t phys = addr & 0x1FFFFFFFu; - if (phys < 0x00800000u) phys &= (uint32_t)(RAM_SIZE - 1); - return phys; -} - -/* Expose RAM pointer for oracle comparison (find_first_divergence). */ -uint8_t *memory_get_ram_ptr(void) { return ram; } -uint8_t *memory_get_scratchpad_ptr(void) { return scratchpad; } - -static int s_low_scratch_is_the_games; /* a loaded state of a started game: what is there is no longer the boot's */ -void memory_keep_low_boot_scratch(void) { s_low_scratch_is_the_games = 1; } - -void memory_clear_low_boot_scratch(void) { - if (s_low_scratch_is_the_games) return; - memset(ram, 0, 0x10u); -} - -/* ---- Dirty-page tracking for install-at-runtime code (CLAUDE.md Rule 18) ---- - * - * The PS1 BIOS writes 4-instruction dispatch stubs into kernel RAM at runtime - * (e.g. RAM 0xCF0 for the SIO data-byte handler). A static recompiler can't - * see those instructions because they don't exist at compile time. We track - * which RAM pages have been written-to since boot and route any psx_dispatch - * landing in such a page through a small MIPS interpreter (dirty_ram_interp.c). - * - * Granularity: 4 KB pages. Ordinary CPU writes only mark the kernel-code - * region (RAM 0..0xFFFF) where BIOS install stubs live. CD-ROM DMA can also - * mark arbitrary RAM ranges as executable candidates, which covers game - * overlays loaded from disc without treating every data write as code. - * - * Future option (Option B, see docs/dynamic_handler_install.md): when a page - * goes dirty, JIT-compile its bytes via StrictTranslator instead of running - * an interpreter. Pros: one source of MIPS semantics, hot install stubs run - * as native compiled C. Cons: gcc-at-runtime build dep, ~200 ms compile latency - * stall on first dispatch, file I/O on hot path, cache-invalidation complexity, - * Windows MinGW + dlopen friction. Revisit only if install stubs become a - * measurable hot path; today they're cold-path glue (~4k instructions per - * directory-load is sub-microsecond to interpret). */ -#define DIRTY_RAM_KERNEL_TRACK_BYTES 0x10000u -#define DIRTY_RAM_PAGE_SHIFT 12 /* 4 KB pages */ -#define DIRTY_RAM_PAGE_COUNT (RAM_SIZE >> DIRTY_RAM_PAGE_SHIFT) -#define DIRTY_RAM_BITMAP_WORDS ((DIRTY_RAM_PAGE_COUNT + 31u) / 32u) -static uint32_t dirty_ram_bitmap[DIRTY_RAM_BITMAP_WORDS]; - -/* Monotonic generation for RAM-resident CODE changes (kernel install-stub - * writes + DMA/EXE loads that mark executable ranges). Consumers that cache - * per-PC classifications of RAM instructions (the interp's widescreen - * cull/backdrop site caches) compare against this and re-derive after any - * code change — a cached kind must never survive an overlay reload. */ -uint32_t g_dirty_ram_code_gen = 1; - -static inline void dirty_ram_mark_page(uint32_t phys) { - if (phys >= RAM_SIZE) return; - uint32_t page = phys >> DIRTY_RAM_PAGE_SHIFT; - uint32_t bit = 1u << (page & 31u); - /* Generation bumps on the clean->dirty TRANSITION only: kernel-window data - * writes land here on every guest store, and bumping per write would - * invalidate the per-PC site caches continuously. Overlay (re)loads always - * bump via dirty_ram_mark_executable_range. */ - if (!(dirty_ram_bitmap[page >> 5] & bit)) g_dirty_ram_code_gen++; - dirty_ram_bitmap[page >> 5] |= bit; -} - -/* ---- Kernel-image bless ------------------------------------------------- - * Kernel Part 2 (RAM [0x500,0x8500)) is the BIOS's boot-time copy of ROM - * [0x1FC10000,0x1FC18000); the statically-recompiled kernel functions in the - * generated dispatch table were compiled FROM those ROM bytes. Those pages - * are permanently dirty (TCB saves, event tables, install stubs share the - * window), which diverted EVERY kernel dispatch to the interpreter — the - * dominant cost of event-heavy scenes (a Tomba village frame samples ~97% - * in kernel PCs) and CD-load drains. - * - * A dispatch key may run its static native function IFF the live RAM bytes - * of everything that function can execute (its body extent, emitter-supplied - * via psx_bios_kernel_bodies[]) still byte-match the ROM source. Per-entry, - * lazily verified, cached; a guest write landing INSIDE an entry's body - * drops that entry back to unverified (next dispatch re-compares). Entries - * whose bodies were runtime-patched (the BIOS installs pad/SIO handlers into - * ROM-zero gaps of the pad driver) simply never verify and stay on the - * faithful interpreter path — their behavior is unchanged by design. This is - * the runtime half of the relocation-manifest contract - * (docs/RELOCATION_MANIFEST_FORMAT.md: "runtime verifies against live RAM - * before dispatching the AOT function"). */ -/* The window/offset constants come from the LINKED recompiled BIOS itself - * (psx_bios_image, emitted into _dispatch.c from the BIOS profile) — - * they cannot disagree with the code they describe. Snapshotted into - * file-statics on the lazy init latch so the two hot paths (dispatch query, - * guest-store invalidation) keep a two-load range test instead of reading - * through the extern each time. A BIOS with no bless window exports all - * zeros: the span-0 range test then rejects every address. */ -#include "psx_bios_image.h" - -static uint32_t s_kb_lo = 0, s_kb_span = 0, s_kb_rom_off = 0; - -#define KBLESS_UNKNOWN 0u -#define KBLESS_CLEAN 1u -#define KBLESS_MISMATCH 2u -#define KBLESS_MAX_ENTRIES 4096u -static uint8_t kbless_state[KBLESS_MAX_ENTRIES]; /* parallel to bodies[] */ -static int kbless_enabled = -1; /* env PSX_KERNEL_BLESS=0 disables */ -/* Always-on counters (TCP kernel_bless). */ -static uint64_t kbless_native_hits = 0; -static uint64_t kbless_verifies = 0; -static uint64_t kbless_mismatches = 0; -static uint64_t kbless_invalidations = 0; -static uint32_t kbless_last_mismatch[6] = {0}; - -static int kbless_on(void) { - if (kbless_enabled < 0) { - const char* e = getenv("PSX_KERNEL_BLESS"); - kbless_enabled = (e && e[0] == '0') ? 0 : 1; - if (psx_bios_kernel_body_count > KBLESS_MAX_ENTRIES) kbless_enabled = 0; - s_kb_lo = psx_bios_image.kbless_ram_lo; - s_kb_span = psx_bios_image.kbless_ram_hi - psx_bios_image.kbless_ram_lo; - s_kb_rom_off = psx_bios_image.kbless_rom_off; - if (s_kb_span == 0) kbless_enabled = 0; /* BIOS with no bless window */ - /* The emitted constants must agree with each other and the ROM - * array: a window whose ROM source exceeds the image is a build - * defect, not a runtime condition. */ - if (s_kb_span > 0 && - (uint64_t)s_kb_rom_off + s_kb_span > (uint64_t)BIOS_ROM_SIZE) { - fprintf(stderr, "FATAL: psx_bios_image kbless window [0x%X,+0x%X) " - "exceeds the %u-byte ROM\n", s_kb_rom_off, s_kb_span, - (unsigned)BIOS_ROM_SIZE); - exit(1); - } - } - return kbless_enabled; -} - -/* Binary search the (key-sorted) body table. -1 if absent. */ -static int kbless_find(uint32_t phys) { - uint32_t lo = 0, hi = psx_bios_kernel_body_count; - while (lo < hi) { - uint32_t mid = (lo + hi) >> 1; - uint32_t k = psx_bios_kernel_bodies[mid].key; - if (k == phys) return (int)mid; - if (k < phys) lo = mid + 1; else hi = mid; - } - return -1; -} - -/* Dispatch-time query: may `phys` run its static native function? - * Verifies lazily; every failure mode falls back to the interpreter. */ -int psx_kernel_bless_dispatchable(uint32_t phys) { - if (!kbless_on()) return 0; - if (phys - s_kb_lo >= s_kb_span) return 0; - int i = kbless_find(phys); - if (i < 0) return 0; - uint8_t st = kbless_state[i]; - if (st == KBLESS_CLEAN) { kbless_native_hits++; return 1; } - if (st == KBLESS_MISMATCH) return 0; - const PsxKernelBody* b = &psx_bios_kernel_bodies[i]; - kbless_verifies++; - if (memcmp(ram + b->body_lo, - bios_rom + s_kb_rom_off + (b->body_lo - s_kb_lo), - b->body_hi - b->body_lo) == 0) { - kbless_state[i] = KBLESS_CLEAN; - kbless_native_hits++; - return 1; - } - kbless_state[i] = KBLESS_MISMATCH; - kbless_mismatches++; - kbless_last_mismatch[0] = phys; - kbless_last_mismatch[1] = b->body_lo; - kbless_last_mismatch[2] = b->body_hi; - { - const uint8_t *live = ram + b->body_lo; - const uint8_t *ref = bios_rom + s_kb_rom_off + - (b->body_lo - s_kb_lo); - uint32_t len = b->body_hi - b->body_lo; - uint32_t off = 0; - while (off < len && live[off] == ref[off]) off++; - kbless_last_mismatch[3] = b->body_lo + off; - kbless_last_mismatch[4] = off < len ? live[off] : 0u; - kbless_last_mismatch[5] = off < len ? ref[off] : 0u; - } - return 0; -} - -/* A write landed in the kernel window: any entry whose body contains the - * written byte must re-verify before its next native dispatch. Kernel DATA - * writes (TCB saves, event tables — the frequent case) lie outside every - * body and fall through the loop without invalidating anything; genuine - * code-window writes (install-time, patches) are rare. */ -static void kbless_note_write(uint32_t phys) { - if (kbless_enabled <= 0) return; /* also pre-init: nothing verified yet */ - if (phys - s_kb_lo >= s_kb_span) return; - uint32_t n = psx_bios_kernel_body_count; - if (n > KBLESS_MAX_ENTRIES) return; - for (uint32_t i = 0; i < n; i++) { - /* No early exit: a continuation key can sort ABOVE the written - * address while its parent body contains it (bodies overlap). n is - * a few hundred; this path fires only on kernel-window writes. */ - const PsxKernelBody* b = &psx_bios_kernel_bodies[i]; - if (phys >= b->body_lo && phys < b->body_hi && - kbless_state[i] != KBLESS_UNKNOWN) { - kbless_state[i] = KBLESS_UNKNOWN; - kbless_invalidations++; - } - } -} - -/* Range write (DMA / EXE load / savestate restore) overlapping the window: - * bulk, rare events — reset every entry rather than per-byte scanning. */ -void psx_kernel_bless_note_range(uint32_t phys, uint32_t len) { - if (kbless_enabled <= 0) return; /* also pre-init: nothing verified yet */ - if (len == 0) return; - uint32_t end = phys + len; - if (end < phys) end = 0xFFFFFFFFu; - if (end <= s_kb_lo || phys >= s_kb_lo + s_kb_span) return; - uint32_t n = psx_bios_kernel_body_count; - if (n > KBLESS_MAX_ENTRIES) return; - for (uint32_t i = 0; i < n; i++) { - if (kbless_state[i] != KBLESS_UNKNOWN) { - kbless_state[i] = KBLESS_UNKNOWN; - kbless_invalidations++; - } - } -} - -void psx_kernel_bless_stats(uint64_t out[6]) { - uint32_t n = psx_bios_kernel_body_count; - uint32_t clean = 0, mism = 0; - if (n > KBLESS_MAX_ENTRIES) n = KBLESS_MAX_ENTRIES; - for (uint32_t i = 0; i < n; i++) { - if (kbless_state[i] == KBLESS_CLEAN) clean++; - if (kbless_state[i] == KBLESS_MISMATCH) mism++; - } - out[0] = psx_bios_kernel_body_count; - out[1] = clean; - out[2] = mism; - out[3] = kbless_native_hits; - out[4] = kbless_verifies; - out[5] = kbless_invalidations; -} - -void psx_kernel_bless_mismatch_detail(uint64_t *count, uint32_t out[6]) { - if (count) *count = kbless_mismatches; - if (out) memcpy(out, kbless_last_mismatch, sizeof(kbless_last_mismatch)); -} - -static inline void dirty_ram_mark_kernel_write(uint32_t phys) { - if (phys >= DIRTY_RAM_KERNEL_TRACK_BYTES) return; - dirty_ram_mark_page(phys); - kbless_note_write(phys); -} - -int dirty_ram_is_dirty(uint32_t phys); - -/* Establish the clean compiled-image baseline for the game-EXE text region. - * Called ONCE when the game entry is first reached (fntrace game-start): by then - * the BIOS has fully loaded the boot EXE into [0x10000, FLOOR) — which IS the - * compiled image — but no gameplay overlay has run yet. The EXE load (CD DMA via - * dirty_ram_mark_executable_range) marks the whole text dirty as a FALSE POSITIVE - * (RAM == compiled image); clearing it here means dirty_ram_is_dirty() afterwards - * is true ONLY for pages a later overlay actually overwrote. The dispatch can then - * trust a clean text page to run its compiled function and divert only truly- - * overlaid pages to the interpreter (Tomba 2 loads a loader overlay over - * 0x8001Dxxx). The kernel window [0,0x10000) (BIOS install stubs) and the overlay - * region [FLOOR, RAM) are left untouched. */ -extern uint32_t g_overlay_region_floor; -void dirty_ram_clear_image_baseline(void) { - uint32_t floor = g_overlay_region_floor; - if (floor <= DIRTY_RAM_KERNEL_TRACK_BYTES) return; - if (floor > RAM_SIZE) floor = RAM_SIZE; - uint32_t first_page = DIRTY_RAM_KERNEL_TRACK_BYTES >> DIRTY_RAM_PAGE_SHIFT; - uint32_t last_page = (floor - 1u) >> DIRTY_RAM_PAGE_SHIFT; - for (uint32_t page = first_page; page <= last_page; page++) - dirty_ram_bitmap[page >> 5] &= ~(1u << (page & 31u)); -} - -/* Text-image divergence guard. - * - * dirty_ram_is_dirty() is intentionally coarse: it says a page was touched by - * runtime code loading. For deciding whether the original static game dispatch - * is still safe, a dirty page is not enough information: data in a code page can - * be written without changing the instructions at a target, while packed games - * can also rewrite their own text with completely different instructions. - * - * main.cpp registers the boot EXE bytes as the reference image. Writes inside - * that range mark pages whose bytes differ from the reference. At dispatch time, - * a modified page is still allowed to use the static native entry if the code - * bytes at the target match the original image; otherwise it is sticky-diverged - * and must execute from live RAM through the dirty interpreter. */ -/* Mutable: the runtime owns this heap buffer (main.cpp mallocs the EXE image and - * never frees it). Intentional data patches by the translation layer are blessed - * into it via dirty_ram_text_bless so they are not mistaken for self-modifying - * code — see dirty_ram_text_native_ok / dirty_ram_text_bless. */ -static uint8_t *text_ref_image = NULL; -static uint32_t text_ref_lo = 0, text_ref_hi = 0; -static uint32_t text_modified_bitmap[DIRTY_RAM_BITMAP_WORDS]; -static uint32_t text_diverged_bitmap[DIRTY_RAM_BITMAP_WORDS]; -static uint64_t g_text_native_blocked = 0; -static uint32_t g_text_diverged_pages = 0; -static uint64_t g_text_exact_mismatches = 0; -static uint32_t g_text_exact_last_range_lo = 0; -static uint32_t g_text_exact_last_range_len = 0; -static uint32_t g_text_exact_last_mismatch = 0; -static uint32_t g_text_exact_last_live = 0; -static uint32_t g_text_exact_last_ref = 0; - -void dirty_ram_register_text_image(uint32_t phys_lo, const uint8_t *bytes, - uint32_t len) { - if (!bytes || len == 0 || phys_lo >= RAM_SIZE) return; - if (len > RAM_SIZE - phys_lo) len = RAM_SIZE - phys_lo; - text_ref_image = (uint8_t *)bytes; /* runtime-owned mutable heap buffer */ - text_ref_lo = phys_lo; - text_ref_hi = phys_lo + len; - memset(text_modified_bitmap, 0, sizeof(text_modified_bitmap)); - memset(text_diverged_bitmap, 0, sizeof(text_diverged_bitmap)); - g_text_native_blocked = 0; - g_text_diverged_pages = 0; - g_text_exact_mismatches = 0; - g_text_exact_last_range_lo = 0; - g_text_exact_last_range_len = 0; - g_text_exact_last_mismatch = 0; - g_text_exact_last_live = 0; - g_text_exact_last_ref = 0; -} - -int dirty_ram_text_image_registered(void) { return text_ref_image != NULL; } - -static inline void text_guard_note_write(uint32_t phys, uint32_t val, int size) { - if (!text_ref_image) return; - if (phys < text_ref_lo || phys + (uint32_t)size > text_ref_hi) return; - const uint8_t *ref = text_ref_image + (phys - text_ref_lo); - uint8_t buf[4] = { (uint8_t)val, (uint8_t)(val >> 8), - (uint8_t)(val >> 16), (uint8_t)(val >> 24) }; - if (memcmp(ref, buf, (size_t)size) != 0) { - uint32_t page = phys >> DIRTY_RAM_PAGE_SHIFT; - text_modified_bitmap[page >> 5] |= (1u << (page & 31u)); - } -} - -int dirty_ram_text_native_ok(uint32_t phys) { - if (!text_ref_image || phys < text_ref_lo || phys >= text_ref_hi) - return !dirty_ram_is_dirty(phys); - - uint32_t page = phys >> DIRTY_RAM_PAGE_SHIFT; - uint32_t bit = 1u << (page & 31u); - if (text_diverged_bitmap[page >> 5] & bit) { - g_text_native_blocked++; - return 0; - } - /* Fast path: a page never touched by a guarded write AND not runtime-dirty - * still holds the pristine compiled image — native is safe with no compare. */ - if (!(text_modified_bitmap[page >> 5] & bit) && !dirty_ram_is_dirty(phys)) - return 1; - - /* Flagged (a guarded write that differed, an overlay-dirty page, or an - * intentional runtime data patch): the compiled native code is valid IFF the - * bytes at the entry still match the reference image. Decide by the ACTUAL - * bytes, not the page-dirty heuristic — so a data-only change in a page that - * also holds code (e.g. a translation string table blessed into the ref via - * dirty_ram_text_bless) does not needlessly block a still-valid function and - * route it to psx_unknown_dispatch. A genuine code overwrite still diverges. */ - uint32_t n = 256; - if (n > text_ref_hi - phys) n = text_ref_hi - phys; - if (memcmp(ram + phys, text_ref_image + (phys - text_ref_lo), n) == 0) - return 1; - - text_diverged_bitmap[page >> 5] |= bit; - g_text_diverged_pages++; - g_text_native_blocked++; - return 0; -} - -/* Validate the exact instruction ranges emitted for a static game function. - * Each pair is {virtual/physical lo, byte len}; non-code gaps and mutable data - * on the same page are intentionally absent. Unlike the legacy 256-byte probe, - * a mismatch never poisons an unrelated 4 KB page forever: every decision is - * made from the live bytes the native body will actually execute. - * - * exec_pc is the dispatch/resume address. Ranges that end at or before that PC - * are skipped, and a range that straddles it is clipped to [exec_pc, end). A - * runtime patch of a function prologue must not block a compiled continuation - * that never fetches the patched bytes. */ -int dirty_ram_text_native_ok_ranges_from(const uint32_t *lo_len_pairs, - uint32_t count, - uint32_t exec_pc) { - if (!text_ref_image || !lo_len_pairs || count == 0) return 0; - uint32_t at = exec_pc & 0x1FFFFFFFu; - int any = 0; - for (uint32_t i = 0; i < count; i++) { - uint32_t phys = lo_len_pairs[i * 2u] & 0x1FFFFFFFu; - uint32_t len = lo_len_pairs[i * 2u + 1u]; - if (len == 0 || phys < text_ref_lo || phys >= text_ref_hi || - len > text_ref_hi - phys) { - g_text_native_blocked++; - return 0; - } - if (phys + len <= at) continue; - if (phys < at) { - len -= at - phys; - phys = at; - } - any = 1; - if (memcmp(ram + phys, text_ref_image + (phys - text_ref_lo), len) != 0) { - uint32_t off = 0; - const uint8_t *live = ram + phys; - const uint8_t *ref = text_ref_image + (phys - text_ref_lo); - while (off < len && live[off] == ref[off]) off++; - g_text_exact_mismatches++; - g_text_exact_last_range_lo = phys; - g_text_exact_last_range_len = len; - g_text_exact_last_mismatch = phys + off; - g_text_exact_last_live = off < len ? live[off] : 0; - g_text_exact_last_ref = off < len ? ref[off] : 0; - /* Do not sticky-poison the page. A continuation on the same page - * may still match its clipped ranges. */ - g_text_native_blocked++; - return 0; - } - } - if (!any) { - g_text_native_blocked++; - return 0; - } - return 1; -} - -/* Preserve the generated-code ABI used by existing game projects. */ -int dirty_ram_text_native_ok_ranges(const uint32_t *lo_len_pairs, - uint32_t count) { - return dirty_ram_text_native_ok_ranges_from(lo_len_pairs, count, 0u); -} - -void dirty_ram_text_exact_mismatch_stats(uint64_t *count, - uint32_t out[5]) { - if (count) *count = g_text_exact_mismatches; - if (!out) return; - out[0] = g_text_exact_last_range_lo; - out[1] = g_text_exact_last_range_len; - out[2] = g_text_exact_last_mismatch; - out[3] = g_text_exact_last_live; - out[4] = g_text_exact_last_ref; -} - -/* Bless an INTENTIONAL data patch into the reference image so the text-divergence - * guard does not mistake it for self-modifying code. The runtime's own - * translation layer (text_xlate) patches string/glyph tables that share 4 KB - * pages — and the guard's 256-byte compare window — with real compiled functions. - * Without this, patching a data table diverges the page and blocks native - * dispatch of every function in it (a real function fatally routes to - * psx_unknown_dispatch). By updating text_ref_image to match the patched bytes, - * the compare passes for the intentional change while a genuine game write of - * DIFFERENT bytes still diverges and is still caught. Clears the sticky diverged - * bit for affected pages so the guard re-evaluates against the updated reference. - * Byte-for-byte identical writes are a no-op. */ -void dirty_ram_text_bless(uint32_t phys, const uint8_t *bytes, uint32_t len) { - if (!text_ref_image || !bytes || len == 0) return; - if (phys >= text_ref_hi || phys + len <= text_ref_lo) return; /* out of range */ - uint32_t lo = phys < text_ref_lo ? text_ref_lo : phys; - uint32_t hi = phys + len > text_ref_hi ? text_ref_hi : phys + len; - if (hi <= lo) return; - uint8_t *ref = text_ref_image + (lo - text_ref_lo); - const uint8_t *src = bytes + (lo - phys); - if (memcmp(ref, src, hi - lo) == 0) return; /* already in sync */ - memcpy(ref, src, hi - lo); - /* Re-open the affected pages: clear the sticky diverged bit so the next - * dispatch re-runs the compare against the now-updated reference. */ - uint32_t first_page = lo >> DIRTY_RAM_PAGE_SHIFT; - uint32_t last_page = (hi - 1u) >> DIRTY_RAM_PAGE_SHIFT; - for (uint32_t page = first_page; page <= last_page; page++) - text_diverged_bitmap[page >> 5] &= ~(1u << (page & 31u)); -} - -uint64_t dirty_ram_text_native_blocked(void) { return g_text_native_blocked; } -uint32_t dirty_ram_text_diverged_pages(void) { return g_text_diverged_pages; } -uint32_t dirty_ram_text_modified_bitmap_word(uint32_t word_index) { - if (word_index >= DIRTY_RAM_BITMAP_WORDS) return 0; - return text_modified_bitmap[word_index]; -} -uint32_t dirty_ram_text_diverged_bitmap_word(uint32_t word_index) { - if (word_index >= DIRTY_RAM_BITMAP_WORDS) return 0; - return text_diverged_bitmap[word_index]; -} - -void dirty_ram_mark_executable_range(uint32_t phys, uint32_t len) { - if (len == 0 || phys >= RAM_SIZE) return; - psx_kernel_bless_note_range(phys, len); - uint32_t end = phys + len - 1u; - if (end >= RAM_SIZE || end < phys) end = RAM_SIZE - 1u; - - uint32_t first_page = phys >> DIRTY_RAM_PAGE_SHIFT; - uint32_t last_page = end >> DIRTY_RAM_PAGE_SHIFT; - for (uint32_t page = first_page; page <= last_page; page++) { - dirty_ram_bitmap[page >> 5] |= (1u << (page & 31u)); - } - g_dirty_ram_code_gen++; -} - -/* Force-interp mode (tooling): PSX_FORCE_INTERP=1 makes ALL RAM above the kernel - * window report dirty, so every dispatch into game/overlay text routes to the - * dirty-RAM interpreter (the SAME path overlays take) instead of the compiled - * image. Interp-path Δ-ruler enabler: lets the cyctest isolation loops be measured - * native-INTERP vs Beetle. Consulted by every routing site that already calls - * dirty_ram_is_dirty (top dispatch, dirty_ram_dispatch_inner gates, the - * psx_dispatch_game_compiled gates) — no emitter/dispatch change needed. */ -static int dirty_ram_force_interp(void) { - static int s = -1; - if (s < 0) { const char* e = getenv("PSX_FORCE_INTERP"); s = (e && e[0] && e[0] != '0'); } - return s; -} - -/* DIAGNOSTIC ONLY (class-B reproduction, NOT a fix — Rule -1): PSX_SHELLWIN_INTERP=1 - * reports the BIOS shell-copy relocated RAM window [0x30000, 0x5AFFF] as dirty, so - * the compiled dispatch (full_function_emitter.cpp:1380) routes those addresses - * through the recovering dirty-RAM interp instead of normalize()->shell ROM. This - * peels the class-A shell-window pc=0 wedge (func_1FC42090) so the *general* - * class-B compiled exception-return pc=0 (in [0x5B000, 0x8F000)) can surface and be - * captured. Must be reverted before any merge; it is a probe, not the fix. */ -static int dirty_ram_shellwin_interp(void) { - static int s = -1; - if (s < 0) { -#ifdef PSX_SHELLWIN_INTERP_DEFAULT - s = 1; -#else - const char* e = getenv("PSX_SHELLWIN_INTERP"); - s = (e && e[0] && e[0] != '0'); -#endif - } - return s; -} - -int dirty_ram_is_dirty(uint32_t phys) { - if (phys >= RAM_SIZE) return 0; - if (dirty_ram_force_interp() && phys >= DIRTY_RAM_KERNEL_TRACK_BYTES) return 1; - if (dirty_ram_shellwin_interp() && phys >= 0x00030000u && phys <= 0x0005AFFFu) return 1; - /* Experimental fallback for overlays copied into their final location by - * ordinary guest CPU stores rather than CD DMA. Dispatch above the static - * image floor is treated as dynamic and validated by the interpreter. */ - if (phys >= g_overlay_region_floor) return 1; - uint32_t page = phys >> DIRTY_RAM_PAGE_SHIFT; - return (dirty_ram_bitmap[page >> 5] >> (page & 31u)) & 1u; -} - -uint32_t dirty_ram_get_bitmap(void) { return dirty_ram_bitmap[0]; } - -uint32_t dirty_ram_get_bitmap_word(uint32_t word_index) { - if (word_index >= DIRTY_RAM_BITMAP_WORDS) return 0; - return dirty_ram_bitmap[word_index]; -} - -uint32_t dirty_ram_get_bitmap_word_count(void) { - return DIRTY_RAM_BITMAP_WORDS; -} - -void dirty_ram_set_bitmap_words(const uint32_t* words, uint32_t count) { - if (count > DIRTY_RAM_BITMAP_WORDS) count = DIRTY_RAM_BITMAP_WORDS; - for (uint32_t i = 0; i < count; i++) - dirty_ram_bitmap[i] = words[i]; -} - -/* ---- Inc3: watched overlay pages + per-page generation counters --------- - * Pages covered by a registered overlay function's code range. The store path - * (the single, audited RAM-write chokepoint — all CPU + DMA stores funnel - * here) tests the watch bitmap and, on a hit, bumps that page's generation - * counter. It does NOT eagerly invalidate (Inc1-D did): validity is now decided - * lazily, per compiled entry, at dispatch time (overlay_loader.c §8). The - * generation counter lets the loader cheaply detect "did any page covering this - * entry's code change since I last validated it?" without hashing on the store - * path. Monotonic: gen only increases, so a sum over an entry's pages is a - * perfect change detector (no aliasing). - * - * The bitmap is almost always empty, so the per-store cost on the common path - * is a single bitmap lookup. - */ -static uint32_t overlay_watch_bitmap[DIRTY_RAM_BITMAP_WORDS]; -static uint32_t overlay_page_gen[DIRTY_RAM_PAGE_COUNT]; - -void overlay_watch_set_range(uint32_t phys, uint32_t len) { - if (len == 0 || phys >= RAM_SIZE) return; - uint32_t end = phys + len - 1u; - if (end >= RAM_SIZE || end < phys) end = RAM_SIZE - 1u; - uint32_t fp = phys >> DIRTY_RAM_PAGE_SHIFT; - uint32_t lp = end >> DIRTY_RAM_PAGE_SHIFT; - for (uint32_t pg = fp; pg <= lp; pg++) - overlay_watch_bitmap[pg >> 5] |= (1u << (pg & 31u)); -} - -void overlay_watch_clear_range(uint32_t phys, uint32_t len) { - if (len == 0 || phys >= RAM_SIZE) return; - uint32_t end = phys + len - 1u; - if (end >= RAM_SIZE || end < phys) end = RAM_SIZE - 1u; - uint32_t fp = phys >> DIRTY_RAM_PAGE_SHIFT; - uint32_t lp = end >> DIRTY_RAM_PAGE_SHIFT; - for (uint32_t pg = fp; pg <= lp; pg++) - overlay_watch_bitmap[pg >> 5] &= ~(1u << (pg & 31u)); -} - -/* Sum of generation counters over the pages spanning [phys, phys+len). The - * loader stores this at validation time and compares on dispatch; any change - * means a watched page in the range was written. */ -uint32_t overlay_watch_pagegen_sum(uint32_t phys, uint32_t len) { - if (len == 0 || phys >= RAM_SIZE) return 0; - uint32_t end = phys + len - 1u; - if (end >= RAM_SIZE || end < phys) end = RAM_SIZE - 1u; - uint32_t fp = phys >> DIRTY_RAM_PAGE_SHIFT; - uint32_t lp = end >> DIRTY_RAM_PAGE_SHIFT; - uint32_t sum = 0; - for (uint32_t pg = fp; pg <= lp; pg++) sum += overlay_page_gen[pg]; - return sum; -} - -static inline void overlay_watch_note_write(uint32_t phys, uint32_t size) { - uint32_t pg = phys >> DIRTY_RAM_PAGE_SHIFT; - if (pg >= DIRTY_RAM_PAGE_COUNT) return; - /* Never attach pre-write PC evidence to post-write bytes, including for - * completely unknown/self-modifying code. This is deliberately a compact - * page clear, not a capture: serializing snapshots from this universal - * guest-store hook caused unbounded queues and multi-second stalls. CD DMA - * and periodic coherent capture remain the durable variant boundaries. */ - if ((g_dirty_ram_exec_page_bitmap[pg >> 5] >> (pg & 31u)) & 1u) { - uint32_t bitmap_word = pg * (4096u / 4u / 32u); - memset(&g_dirty_ram_exec_pc_bitmap[bitmap_word], 0, - (4096u / 4u / 32u) * sizeof(uint32_t)); - memset(&g_dirty_ram_dispatch_pc_bitmap[bitmap_word], 0, - (4096u / 4u / 32u) * sizeof(uint32_t)); - g_dirty_ram_exec_page_bitmap[pg >> 5] &= ~(1u << (pg & 31u)); - } - if ((overlay_watch_bitmap[pg >> 5] >> (pg & 31u)) & 1u) { - overlay_page_gen[pg]++; - /* Also invalidate generation-aware negative overlay lookups: bytes in - * a manifested code page may now match a previously absent variant. - * Keep this separate from g_dirty_ram_code_gen: interpreter widescreen - * classifiers use that epoch and must not churn on every watched-page - * data write. */ - extern void overlay_loader_note_code_write(void); - overlay_loader_note_code_write(); - /* Self-modification of a currently-executing native entry cannot be - * recovered lazily (the next dispatch is too late) — the loader - * blacklists that entry. Everything else is handled at dispatch. */ - extern void overlay_loader_active_write_check(uint32_t phys, uint32_t size); - overlay_loader_active_write_check(phys, size); - } -} - -/* Memory control registers: 0x1F801000..0x1F80103F (16 words) + 0x1F801060 (RAM size). - * Includes expansion base/size, COM_DELAY, SPU_DELAY, CDROM_DELAY etc. */ -static uint32_t mem_ctrl[16]; /* indices 0..15 → addresses 0x1F801000..0x1F80103C */ -static uint32_t ram_size_reg; /* 0x1F801060 */ - -/* KSEG2 no-op access telemetry (see the guards in the accessors). */ -uint64_t g_kseg2_ignored_reads; -uint64_t g_kseg2_ignored_writes; - -/* Cache control register (KSEG2: 0xFFFE0130). */ -static uint32_t cache_ctrl; - -/* Pointer to cpu->cop0[12] (SR). Set once at init. - * Used by write functions to check the IsC (Isolate Cache) bit. - * When IsC is set, RAM/scratchpad writes are silently dropped — the - * real R3000A sends them to the data cache only. */ -static const uint32_t *sr_ptr; - -/* Interrupt controller — non-static so hardware subsystems can set I_STAT bits. */ -uint32_t i_stat; /* 0x1F801070 — interrupt status (AND-acknowledge semantics) */ -uint32_t i_mask; /* 0x1F801074 — interrupt enable mask */ - -/* Shadow-diff device-access detector. run_shadow_diff arms g_shadow_mmio_watch - * around its single authoritative (interpreter) probe pass; ANY MMIO touch bumps - * g_shadow_mmio_hits, so the harness can detect a device-touching function and - * SKIP the validation (native) pass — device I/O must never be double-executed - * (one spurious card/SIO/DMA write corrupts hardware state). Zero cost when not - * watching (adds 0). */ -int g_shadow_mmio_watch = 0; -uint64_t g_shadow_mmio_hits = 0; -/* Always-on MMIO access counter (reads AND writes of the device page). The - * idle-loop cycle skip (psx_cycles.c) requires "no MMIO touched since the - * last interrupt check" before fast-forwarding time: an MMIO read can return - * time-varying or side-effecting values (timer counters, CD response FIFO), - * so any touch disqualifies the window. One increment per access. */ -uint64_t g_mmio_access_count = 0; -#define SHADOW_NOTE_MMIO() do { g_mmio_access_count++; \ - g_shadow_mmio_hits += (uint64_t)g_shadow_mmio_watch; } while (0) - -/* Always-on guest store counter, bumped at the top of every raw store - * (RAM, scratchpad, MMIO, even discarded KSEG2/IsC stores — over-counting - * is conservative). Second gate of the idle-loop cycle skip: a window with - * ANY store is not a pure poll loop. CPU stores and in-flight DMA writes - * both funnel through the psx_write_*_raw chokepoints. */ -uint64_t g_guest_store_count = 0; - -/* ---- Card protocol trace: tracks I_MASK bit 7 transitions ---- */ -#define IMASK_TRACE_CAP 4096 -typedef struct { - uint32_t old_mask; - uint32_t new_mask; - uint32_t caller; /* g_debug_current_func_addr */ - uint32_t store_pc; /* g_debug_last_store_pc — exact PC of the SW/SH */ - uint8_t width; /* 8, 16, or 32 */ - uint8_t bit7_set; /* 1 if this write SET bit 7 */ - uint8_t bit7_clear; /* 1 if this write CLEARED bit 7 */ - uint8_t in_exc; -} ImaskTraceEntry; -static ImaskTraceEntry imask_trace[IMASK_TRACE_CAP]; -static int imask_trace_idx = 0; -static int imask_trace_count = 0; -static int imask_bit7_set_count = 0; -static int imask_bit7_clear_count = 0; - -static void imask_trace_record(uint32_t old_val, uint32_t new_val, uint8_t width) { - extern uint32_t g_debug_current_func_addr; - extern uint32_t g_debug_last_store_pc; - extern int psx_get_in_exception(void); - ImaskTraceEntry *e = &imask_trace[imask_trace_idx]; - e->old_mask = old_val; - e->new_mask = new_val; - e->caller = g_debug_current_func_addr; - e->store_pc = g_debug_last_store_pc; - e->width = width; - e->bit7_set = (!(old_val & 0x80) && (new_val & 0x80)) ? 1 : 0; - e->bit7_clear = ((old_val & 0x80) && !(new_val & 0x80)) ? 1 : 0; - e->in_exc = (uint8_t)psx_get_in_exception(); - if (e->bit7_set) imask_bit7_set_count++; - if (e->bit7_clear) imask_bit7_clear_count++; - imask_trace_idx = (imask_trace_idx + 1) % IMASK_TRACE_CAP; - imask_trace_count++; -} - -/* VBLANK-ack telemetry (Tomba 2 exception-reentry-storm diagnosis): counts how - * many times an I_STAT write clears the VBLANK bit (the handler's ack). Read in - * the freeze heartbeat against g_vblank_raise/deliver counts. */ -uint64_t g_vblank_ack_count = 0; - -static void interrupt_write_stat_masked(uint32_t val, uint32_t mask) { - uint32_t ack_mask = mask & 0x7FFu; - uint32_t before = i_stat; - i_stat = (i_stat & ~ack_mask) | (i_stat & val & ack_mask); - if ((before & 1u) && !(i_stat & 1u)) g_vblank_ack_count++; /* VBLANK bit 1->0 */ -} - -static void interrupt_write_mask_masked(uint32_t val, uint32_t mask, uint8_t width) { - uint32_t old = i_mask; - i_mask = ((i_mask & ~mask) | (val & mask)) & 0x7FFu; - imask_trace_record(old, i_mask, width); -} - -/* Getters for debug server */ -int memory_get_imask_bit7_set_count(void) { return imask_bit7_set_count; } -int memory_get_imask_bit7_clear_count(void) { return imask_bit7_clear_count; } -const ImaskTraceEntry *memory_get_imask_trace(int *idx_out, int *count_out) { - if (idx_out) *idx_out = imask_trace_idx; - if (count_out) *count_out = imask_trace_count; - return imask_trace; -} - -/* Tier 1 write-trace hooks (implemented in debug_server.c). */ -extern void debug_server_trace_write_check(uint32_t phys, uint32_t old_val, - uint32_t new_val, uint8_t width); -extern void debug_server_trace_mmio_write(uint32_t addr, uint32_t val, uint8_t width); -extern void debug_server_trace_mmio_read(uint32_t addr, uint32_t val, uint8_t width); -/* Targeted main-RAM read watch (debug_server.c). Flag gates the hot read path. */ -extern int g_ram_read_watch_active; -extern void debug_server_trace_ram_read_watch(uint32_t phys, uint32_t val); -extern void debug_server_trace_entryint_write(uint32_t phys, uint32_t old_val, - uint32_t new_val, uint8_t width); -extern CPUState *debug_cpu_ptr; -extern uint32_t g_debug_last_store_pc; - -/* Parity last-writer provenance (parity_trace.c): note every main-RAM write so - * the watch-word last-writer table tracks the exact producing store. No-op - * unless the parity ring is armed. */ -extern void parity_trace_note_write(uint32_t addr, uint32_t width, uint32_t writer_pc); - -/* Effective writer PC for provenance: during a DMA transfer the last CPU-store - * PC is stale/unrelated, so attribute DMA-sourced RAM writes to the PC that - * kicked the DMA (dma.c). Matches the wtrace recorder's DMA attribution. */ -extern int g_dma_exec_depth; -extern uint32_t g_dma_initiator_pc; -static inline uint32_t effective_store_pc(void) { - return (g_dma_exec_depth > 0 && g_dma_initiator_pc) ? g_dma_initiator_pc - : g_debug_last_store_pc; -} - -/* Card-byte destination capture (Phase 3 audit). Always-on. */ -extern int card_data_writes_check(uint32_t phys, uint32_t value, uint8_t width); - -static inline uint32_t read_ram_word(uint32_t phys) { - return (uint32_t)ram[phys] - | ((uint32_t)ram[phys + 1] << 8) - | ((uint32_t)ram[phys + 2] << 16) - | ((uint32_t)ram[phys + 3] << 24); -} -static inline uint16_t read_ram_half(uint32_t phys) { - return (uint16_t)ram[phys] | ((uint16_t)ram[phys + 1] << 8); -} - -/* SPU registers are now handled by spu.c */ - -void memory_set_sr_ptr(const uint32_t *p) { sr_ptr = p; } -uint32_t memory_get_sr(void) { return sr_ptr ? *sr_ptr : 0; } - -static uint32_t s_bios_checksum = 0; -uint32_t memory_get_bios_checksum(void) { return s_bios_checksum; } - -void memory_init(const char* bios_path) { - gte_precision_word_write_begin(); - s_low_scratch_is_the_games = 0; - memset(ram, 0, sizeof(ram)); - memset(scratchpad, 0, sizeof(scratchpad)); - /* Rematch re-enters without process exit — wipe sticky I/O regs that - * live outside device *_init (I_STAT/I_MASK cleared in interrupts_init). */ - memset(mem_ctrl, 0, sizeof(mem_ctrl)); - ram_size_reg = 0; - i_stat = 0; - i_mask = 0; - - FILE* f = fopen(bios_path, "rb"); - if (!f) { - fprintf(stderr, "FATAL: cannot open BIOS file: %s\n", bios_path); - exit(1); - } - size_t n = fread(bios_rom, 1, BIOS_ROM_SIZE, f); - fclose(f); - if (n != BIOS_ROM_SIZE) { - fprintf(stderr, "FATAL: BIOS file %s is %zu bytes (expected %d)\n", - bios_path, n, BIOS_ROM_SIZE); - exit(1); - } - s_bios_checksum = 0; - for (uint32_t i = 0; i < BIOS_ROM_SIZE / 4; i++) - s_bios_checksum += ((const uint32_t*)bios_rom)[i]; -} - -/* Unmapped-register access INSIDE the I/O window (0x1F801000..0x1F803FFF): - * real hardware open-buses these (reads return garbage, writes vanish; no - * fault) and games genuinely hit them — Tomba2's late attract sweeps a wild - * byte loop across the whole window (bzero/read over a 0xDF80xxxx pointer). - * Beetle returns 0 / ignores. Match it: count + (already ring-traced by the - * callers' mmio trace hooks) + open-bus. Genuinely unknown-DEVICE reads are - * still observable via the always-on MMIO rings and these counters — probes - * query the rings, per the ring-buffer doctrine. mmio_fatal is retired. */ -uint64_t g_io_openbus_reads; -uint64_t g_io_openbus_writes; - -static void mmio_fatal(uint32_t vaddr, uint32_t phys, const char* op) { - static char reason[96]; - snprintf(reason, sizeof(reason), "MMIO %s @ 0x%08X (phys 0x%08X)", op, vaddr, phys); - fprintf(stderr, "%s\n", reason); - fflush(stderr); - FILE* cf = fopen("psx_crash.txt", "w"); - if (cf) { fprintf(cf, "%s\n", reason); fclose(cf); } - psx_fatal_halt(reason); -} - -static void mmio_unimplemented(uint32_t addr, const char* op) { - static char reason[96]; - snprintf(reason, sizeof(reason), "UNIMPLEMENTED MMIO %s @ 0x%08X", op, addr); - fprintf(stderr, "%s\n", reason); - fflush(stderr); - /* Also write to a crash file for capture when stderr is lost. */ - FILE* cf = fopen("psx_crash.txt", "w"); - if (cf) { fprintf(cf, "%s\n", reason); fclose(cf); } - psx_fatal_halt(reason); -} - -static void unmapped_fatal(uint32_t vaddr, uint32_t phys, const char* op) { - /* On real PS1 hardware, reads from unmapped addresses return open bus - * (typically the last value on the data bus, or 0xFFFFFFFF). - * The BIOS intentionally probes unmapped regions (RAM size detection, - * expansion hardware detection). Fatal abort would prevent normal boot. */ - (void)vaddr; (void)phys; (void)op; -} - -/* --- MMIO read/write helpers --- */ - -static uint32_t mmio_read32_impl(uint32_t addr) { - SHADOW_NOTE_MMIO(); - /* Memory control: 0x1F801000..0x1F801020 */ - if (addr >= 0x1F801000u && addr <= 0x1F80103Cu) { - return mem_ctrl[(addr - 0x1F801000u) >> 2]; - } - /* SIO: 0x1F801040..0x1F80105F */ - if (addr >= 0x1F801040u && addr <= 0x1F80105Fu) { - return sio_read(addr); - } - /* RAM size: 0x1F801060 */ - if (addr == 0x1F801060u) { - return ram_size_reg; - } - /* Interrupts: 0x1F801070, 0x1F801074 */ - if (addr == 0x1F801070u) { sio_tick(0); return i_stat; } - if (addr == 0x1F801074u) return i_mask; - /* DMA: 0x1F801080..0x1F8010FF */ - if (addr >= 0x1F801080u && addr <= 0x1F8010FFu) { - return dma_read(addr); - } - /* Timers: 0x1F801100..0x1F80112F */ - if (addr >= 0x1F801100u && addr <= 0x1F80112Fu) { - return timers_read(addr); - } - /* CDROM: 0x1F801800..0x1F801803 */ - if (addr >= 0x1F801800u && addr <= 0x1F801803u) { - return cdrom_read(addr); - } - /* GPU: 0x1F801810 (GPUREAD), 0x1F801814 (GPUSTAT) */ - if (addr == 0x1F801810u) return gpu_read_gpuread(); - if (addr == 0x1F801814u) return gpu_read_gpustat(); - /* MDEC: 0x1F801820, 0x1F801824 */ - if (addr == 0x1F801820u || addr == 0x1F801824u) return mdec_read(addr); - /* SPU: 0x1F801C00..0x1F801FFF */ - if (addr >= 0x1F801C00u && addr <= 0x1F801FFFu) { - return spu_read(addr); - } - /* Expansion 2 / POST: 0x1F802000..0x1F802FFF */ - if (addr >= 0x1F802000u && addr <= 0x1F802FFFu) { - return 0; - } - { /* open-bus (Beetle parity) */ g_io_openbus_reads++; return 0;; } - return 0; -} - -/* Thin wrapper: record the loaded value into the MMIO-READ trace ring AFTER the - * single (side-effecting) read. Callers use mmio_read32; the body is _impl, so - * the device read executes exactly once. */ -static uint32_t mmio_read32(uint32_t addr) { - psx_devices_mmio_sync(); - uint32_t v = mmio_read32_impl(addr); - debug_server_trace_mmio_read(addr, v, 4); - return v; -} - -static void mmio_write32(uint32_t addr, uint32_t val) { - psx_devices_mmio_sync(); - SHADOW_NOTE_MMIO(); - debug_server_trace_mmio_write(addr, val, 4); - /* Memory control: 0x1F801000..0x1F801020 */ - if (addr >= 0x1F801000u && addr <= 0x1F80103Cu) { - mem_ctrl[(addr - 0x1F801000u) >> 2] = val; - return; - } - /* SIO: 0x1F801040..0x1F80105F */ - if (addr >= 0x1F801040u && addr <= 0x1F80105Fu) { - sio_write(addr, val); - return; - } - /* RAM size: 0x1F801060 */ - if (addr == 0x1F801060u) { - ram_size_reg = val; - return; - } - /* Interrupts: 0x1F801070, 0x1F801074 */ - if (addr == 0x1F801070u) { interrupt_write_stat_masked(val, 0xFFFFFFFFu); return; } - if (addr == 0x1F801074u) { interrupt_write_mask_masked(val, 0xFFFFFFFFu, 32); return; } - /* DMA: 0x1F801080..0x1F8010FF */ - if (addr >= 0x1F801080u && addr <= 0x1F8010FFu) { - dma_write(addr, val); - return; - } - /* Timers: 0x1F801100..0x1F80112F */ - if (addr >= 0x1F801100u && addr <= 0x1F80112Fu) { - timers_write(addr, val); - return; - } - /* CDROM: 0x1F801800..0x1F801803 */ - if (addr >= 0x1F801800u && addr <= 0x1F801803u) { - cdrom_write(addr, val); - return; - } - /* GPU GP0: 0x1F801810, GP1: 0x1F801814 */ - if (addr == 0x1F801810u) { - uint32_t src = addr; - if (g_debug_last_store_pc == 0xBFC38B1Cu && debug_cpu_ptr) { - src = (debug_cpu_ptr->gpr[4] - 4u) & 0x1FFFFCu; - } - gpu_set_gp0_source(src); - gpu_write_gp0(val); - return; - } - if (addr == 0x1F801814u) { gpu_write_gp1(val); return; } - /* MDEC: 0x1F801820, 0x1F801824 */ - if (addr == 0x1F801820u || addr == 0x1F801824u) { mdec_write(addr, val); return; } - /* SPU: 0x1F801C00..0x1F801FFF */ - if (addr >= 0x1F801C00u && addr <= 0x1F801FFFu) { - spu_write(addr, val); - return; - } - /* Expansion 2 / POST: 0x1F802000..0x1F802FFF */ - if (addr >= 0x1F802000u && addr <= 0x1F802FFFu) { - return; /* POST port — ignore */ - } - { /* open-bus (Beetle parity) */ g_io_openbus_writes++; return;; } -} - -static uint16_t mmio_read16_impl(uint32_t addr) { - SHADOW_NOTE_MMIO(); - /* Memory control: 0x1F801000..0x1F80103C — halfword lane of the 32-bit - * register (games touch EXP1/EXP2 config with sub-word accesses; Tomba2's - * late attract byte-writes 0x1F801000). */ - if (addr >= 0x1F801000u && addr <= 0x1F80103Fu) { - uint32_t v = mem_ctrl[(addr - 0x1F801000u) >> 2]; - return (uint16_t)(v >> (8u * (addr & 2u))); - } - if (addr >= 0x1F801060u && addr <= 0x1F801063u) { - return (uint16_t)(ram_size_reg >> (8u * (addr & 2u))); - } - /* SIO: 0x1F801040..0x1F80105F */ - if (addr >= 0x1F801040u && addr <= 0x1F80105Fu) { - return (uint16_t)sio_read(addr); - } - /* Interrupts */ - if (addr >= 0x1F801070u && addr <= 0x1F801072u) { - sio_tick(0); - uint32_t shift = (addr & 2u) ? 16u : 0u; - return (uint16_t)(i_stat >> shift); - } - if (addr >= 0x1F801074u && addr <= 0x1F801076u) { - uint32_t shift = (addr & 2u) ? 16u : 0u; - return (uint16_t)(i_mask >> shift); - } - /* Timers: 0x1F801100..0x1F80112F */ - if (addr >= 0x1F801100u && addr <= 0x1F80112Fu) { - return (uint16_t)timers_read(addr); - } - /* DMA: 0x1F801080..0x1F8010FF */ - if (addr >= 0x1F801080u && addr <= 0x1F8010FFu) { - uint32_t val = dma_read(addr & ~3u); - return (addr & 2) ? (uint16_t)(val >> 16) : (uint16_t)val; - } - /* MDEC: 0x1F801820..0x1F801827 */ - if (addr >= 0x1F801820u && addr <= 0x1F801827u) { - uint32_t val = mdec_read(addr & ~3u); - return (addr & 2) ? (uint16_t)(val >> 16) : (uint16_t)val; - } - /* SPU: 0x1F801C00..0x1F801FFF */ - if (addr >= 0x1F801C00u && addr <= 0x1F801FFFu) { - return (uint16_t)spu_read(addr); - } - { /* open-bus (Beetle parity) */ g_io_openbus_reads++; return 0;; } - return 0; -} - -static uint16_t mmio_read16(uint32_t addr) { - psx_devices_mmio_sync(); - uint16_t v = mmio_read16_impl(addr); - debug_server_trace_mmio_read(addr, (uint32_t)v, 2); - return v; -} - -static void mmio_write16(uint32_t addr, uint16_t val) { - psx_devices_mmio_sync(); - SHADOW_NOTE_MMIO(); - debug_server_trace_mmio_write(addr, (uint32_t)val, 2); - /* Memory control: 0x1F801000..0x1F80103C — halfword lane RMW. */ - if (addr >= 0x1F801000u && addr <= 0x1F80103Fu) { - uint32_t idx = (addr - 0x1F801000u) >> 2; - uint32_t shift = 8u * (addr & 2u); - mem_ctrl[idx] = (mem_ctrl[idx] & ~(0xFFFFu << shift)) - | ((uint32_t)val << shift); - return; - } - /* RAM size register: 0x1F801060 — halfword lane RMW. */ - if (addr >= 0x1F801060u && addr <= 0x1F801063u) { - uint32_t shift = 8u * (addr & 2u); - ram_size_reg = (ram_size_reg & ~(0xFFFFu << shift)) - | ((uint32_t)val << shift); - return; - } - /* SIO: 0x1F801040..0x1F80105F */ - if (addr >= 0x1F801040u && addr <= 0x1F80105Fu) { - sio_write(addr, val); - return; - } - /* Interrupts */ - if (addr >= 0x1F801070u && addr <= 0x1F801072u) { - uint32_t shift = (addr & 2u) ? 16u : 0u; - interrupt_write_stat_masked((uint32_t)val << shift, 0xFFFFu << shift); - return; - } - if (addr >= 0x1F801074u && addr <= 0x1F801076u) { - uint32_t shift = (addr & 2u) ? 16u : 0u; - interrupt_write_mask_masked((uint32_t)val << shift, 0xFFFFu << shift, 16); - return; - } - /* Timers: 0x1F801100..0x1F80112F */ - if (addr >= 0x1F801100u && addr <= 0x1F80112Fu) { - timers_write(addr, val); - return; - } - /* DMA: 0x1F801080..0x1F8010FF */ - if (addr >= 0x1F801080u && addr <= 0x1F8010FFu) { - uint32_t aligned = addr & ~3u; - uint32_t shift = (addr & 2) ? 16u : 0u; - uint32_t mask = 0xFFFFu << shift; - dma_write_masked(aligned, (uint32_t)val << shift, mask); - return; - } - /* MDEC: 0x1F801820..0x1F801827 */ - if (addr >= 0x1F801820u && addr <= 0x1F801827u) { - uint32_t aligned = addr & ~3u; - uint32_t cur = mdec_read(aligned); - if (addr & 2) - cur = (cur & 0x0000FFFFu) | ((uint32_t)val << 16); - else - cur = (cur & 0xFFFF0000u) | (uint32_t)val; - mdec_write(aligned, cur); - return; - } - /* SPU: 0x1F801C00..0x1F801FFF */ - if (addr >= 0x1F801C00u && addr <= 0x1F801FFFu) { - spu_write(addr, val); - return; - } - { /* open-bus (Beetle parity) */ g_io_openbus_writes++; return;; } -} - -static uint8_t mmio_read8_impl(uint32_t addr) { - SHADOW_NOTE_MMIO(); - /* Memory control: 0x1F801000..0x1F80103C — byte lane of the 32-bit reg. */ - if (addr >= 0x1F801000u && addr <= 0x1F80103Fu) { - uint32_t v = mem_ctrl[(addr - 0x1F801000u) >> 2]; - return (uint8_t)(v >> (8u * (addr & 3u))); - } - if (addr >= 0x1F801060u && addr <= 0x1F801063u) { - return (uint8_t)(ram_size_reg >> (8u * (addr & 3u))); - } - /* Interrupts: 0x1F801070..0x1F801077 (I_STAT, I_MASK) */ - if (addr >= 0x1F801070u && addr <= 0x1F801077u) { - if (addr < 0x1F801074u) sio_tick(0); - uint32_t val = (addr < 0x1F801074u) ? i_stat : i_mask; - return (uint8_t)(val >> (8 * (addr & 3))); - } - /* SIO: 0x1F801040..0x1F80104F */ - if (addr >= 0x1F801040u && addr <= 0x1F80104Fu) { - return (uint8_t)sio_read(addr & ~3u); - } - /* DMA: 0x1F801080..0x1F8010FF — byte reads return the corresponding - * byte of the 32-bit register. The BIOS shell reads DICR (0x1F8010F4) - * as individual bytes during interrupt handling. */ - if (addr >= 0x1F801080u && addr <= 0x1F8010FFu) { - uint32_t aligned = addr & ~3u; - uint32_t val = dma_read(aligned); - return (uint8_t)(val >> (8 * (addr & 3))); - } - /* MDEC: 0x1F801820..0x1F801827 */ - if (addr >= 0x1F801820u && addr <= 0x1F801827u) { - uint32_t val = mdec_read(addr & ~3u); - return (uint8_t)(val >> (8 * (addr & 3))); - } - /* CDROM: 0x1F801800..0x1F801803 */ - if (addr >= 0x1F801800u && addr <= 0x1F801803u) { - return (uint8_t)cdrom_read(addr); - } - /* Expansion 2 / POST: 0x1F802000..0x1F802FFF */ - if (addr >= 0x1F802000u && addr <= 0x1F802FFFu) { - return 0; - } - { /* open-bus (Beetle parity) */ g_io_openbus_reads++; return 0;; } - return 0; -} - -static uint8_t mmio_read8(uint32_t addr) { - psx_devices_mmio_sync(); - uint8_t v = mmio_read8_impl(addr); - debug_server_trace_mmio_read(addr, (uint32_t)v, 1); - return v; -} - -static void mmio_write8(uint32_t addr, uint8_t val) { - psx_devices_mmio_sync(); - SHADOW_NOTE_MMIO(); - debug_server_trace_mmio_write(addr, (uint32_t)val, 1); - /* Memory control: 0x1F801000..0x1F80103C — byte lane RMW. Tomba2's late - * attract byte-writes the EXP1 base register at 0x1F801000; only the - * 32-bit path covered the block and the byte path fatal'd. */ - if (addr >= 0x1F801000u && addr <= 0x1F80103Fu) { - uint32_t idx = (addr - 0x1F801000u) >> 2; - uint32_t shift = 8u * (addr & 3u); - mem_ctrl[idx] = (mem_ctrl[idx] & ~(0xFFu << shift)) - | ((uint32_t)val << shift); - return; - } - /* RAM size register: 0x1F801060 — byte lane RMW (Tomba2 late attract). */ - if (addr >= 0x1F801060u && addr <= 0x1F801063u) { - uint32_t shift = 8u * (addr & 3u); - ram_size_reg = (ram_size_reg & ~(0xFFu << shift)) - | ((uint32_t)val << shift); - return; - } - /* Interrupts: partial stores affect only the addressed byte lane. */ - if (addr >= 0x1F801070u && addr <= 0x1F801073u) { - uint32_t shift = 8u * (addr & 3u); - interrupt_write_stat_masked((uint32_t)val << shift, 0xFFu << shift); - return; - } - if (addr >= 0x1F801074u && addr <= 0x1F801077u) { - uint32_t shift = 8u * (addr & 3u); - interrupt_write_mask_masked((uint32_t)val << shift, 0xFFu << shift, 8); - return; - } - /* SIO: 0x1F801040..0x1F80105F */ - if (addr >= 0x1F801040u && addr <= 0x1F80105Fu) { - sio_write(addr & ~3u, (uint32_t)val); - return; - } - /* DMA: 0x1F801080..0x1F8010FF — byte writes update the corresponding - * byte of the 32-bit register. Needed for DICR byte-level access. */ - if (addr >= 0x1F801080u && addr <= 0x1F8010FFu) { - uint32_t aligned = addr & ~3u; - uint32_t shift = 8 * (addr & 3); - uint32_t mask = 0xFFu << shift; - dma_write_masked(aligned, (uint32_t)val << shift, mask); - return; - } - /* Timers: 0x1F801100..0x1F80112F — byte writes update the addressed byte - * lane of the 32-bit register. Byte stores to timer registers are valid - * hardware accesses; mmio_write16/32 already route here via timers_write, so - * write8 must too (otherwise a guest `sb` to a timer fails loud). */ - if (addr >= 0x1F801100u && addr <= 0x1F80112Fu) { - uint32_t aligned = addr & ~3u; - uint32_t cur = timers_read(aligned); - uint32_t shift = 8 * (addr & 3); - cur = (cur & ~(0xFFu << shift)) | ((uint32_t)val << shift); - timers_write(aligned, cur); - return; - } - /* MDEC: 0x1F801820..0x1F801827 */ - if (addr >= 0x1F801820u && addr <= 0x1F801827u) { - uint32_t aligned = addr & ~3u; - uint32_t cur = mdec_read(aligned); - uint32_t shift = 8 * (addr & 3); - cur = (cur & ~(0xFFu << shift)) | ((uint32_t)val << shift); - mdec_write(aligned, cur); - return; - } - /* CDROM: 0x1F801800..0x1F801803 */ - if (addr >= 0x1F801800u && addr <= 0x1F801803u) { - cdrom_write(addr, val); - return; - } - /* Expansion 2 / POST: 0x1F802000..0x1F802FFF */ - if (addr >= 0x1F802000u && addr <= 0x1F802FFFu) { - return; - } - { /* open-bus (Beetle parity) */ g_io_openbus_writes++; return;; } -} - -/* --- Read functions --- */ - -/* lockstep: 1 while a guest-direct memory op's REAL body runs, so nested ops - * (device-emulation reads triggered by an MMIO write, etc.) are not recorded — - * the shadow replay verifies writes without performing them, so it never sees - * those device-internal accesses. Interrupt-handler ops are excluded too - * (psx_get_in_exception): the replay runs only the block, never the handler. */ -static int s_ls_op_active = 0; -extern int g_ls_suppress_record; -extern int g_dma_exec_depth; -extern int psx_get_in_exception(void); -static uint32_t psx_read_word_raw(uint32_t addr); -/* data-shard capture feed (data_shards.c): record guest reads/writes while a - * capture window is armed. Same exclusions as the lockstep hooks: never in - * exception context (ISR work replays live), never under DMA (DMA writes - * poison the window via ds_note_dma_write instead). */ -uint32_t psx_read_word(uint32_t addr) { - if (g_ls_mode == 2) return ls_read_hook(addr, 4, 0u); - if (g_ds_recording && g_dma_exec_depth == 0 && !psx_get_in_exception()) - ds_note_read(addr, 4); - if (g_ls_mode != 1 || s_ls_op_active || g_ls_suppress_record || g_dma_exec_depth > 0) return psx_read_word_raw(addr); - s_ls_op_active = 1; - uint32_t v = psx_read_word_raw(addr); - s_ls_op_active = 0; - if (!psx_get_in_exception()) ls_read_hook(addr, 4, v); - return v; -} -/* Physical address of a CPU/DMA main-RAM access. Fold KUSEG/KSEG0/KSEG1 first - * (0x1FFFFFFF), then fold the 2nd-4th main-RAM mirrors: real hardware mirrors the - * 2 MB DRAM across the WHOLE 0..0x7FFFFF physical window (Beetle libretro.cpp:874 - * `A < 0x00800000` routes to main RAM; psx-spx "2048K RAM ... mirrored 4x"). A game - * may legitimately place its stack at the top of that window — Tsumu Light computes - * sp = (ramtop-8)|0x80000000 with its ramtop constant 0x00800000, giving sp=0x807FFFF8 - * (top of the 4th mirror). Without this fold those accesses miss DRAM (`< RAM_SIZE` - * fails) and hit open bus: stores drop, loads return 0, so the first saved return - * address reads back 0 and `jr ra` derails to PC=0. Identity for phys < RAM_SIZE, so - * normal-range RAM is byte-identical; MMIO/scratchpad/BIOS (>= 0x800000) untouched. */ -static uint32_t psx_read_word_raw(uint32_t addr) { - /* KSEG2 cache control — before physical translation. */ - if (addr == 0xFFFE0130u) return cache_ctrl; - /* KSEG2 (0xC0000000+): only cache control (0xFFFE0130, above where - * applicable) exists there. Real hardware maps NOTHING else — Beetle - * (cpu.cpp addr_mask[6..7]=0xFFFFFFFF) leaves KSEG2 addresses unmasked - * so they fall to unmapped space and the access is a no-op. Our flat - * 0x1FFFFFFF masking routed KSEG2 garbage onto LIVE registers: Tomba2's - * attract runs a BIOS bzero over a wild 0xDF80xxxx pointer, which zeroed - * the SIO/memctrl I/O block byte-by-byte and then hit the unmapped-MMIO - * fatal (frame 9705). Ignore writes, read as 0, count for telemetry. */ - if (addr >= 0xC0000000u) { g_kseg2_ignored_reads++; return 0; } - - uint32_t phys = psx_phys_addr(addr); - - if (phys < RAM_SIZE) { - /* Host LE load — same bytes as the shift-or form; VLC/decode hot paths - * issue millions of aligned main-RAM LWs per second. */ - uint32_t v; - memcpy(&v, &ram[phys], sizeof(v)); - /* Targeted main-RAM read watch (debug). Flag is 0 in normal runs, so the - * hot read path pays only a predictable branch. */ - if (g_ram_read_watch_active) debug_server_trace_ram_read_watch(phys, v); - return v; - } - { - uint32_t off; - if (mod_gpu_dma_memory_offset(phys, 4u, &off)) { - uint32_t v; - memcpy(&v, mod_gpu_dma_memory + off, sizeof(v)); - return v; - } - } - /* Expansion 1: 0x1F000000..0x1F7FFFFF — no device, open bus */ - { - uint32_t off; - if (mod_memory_offset(phys, 4u, &off)) { - uint32_t v; - memcpy(&v, mod_memory + off, sizeof(v)); - return v; - } - } - if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) { - return 0xFFFFFFFFu; - } - if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { - uint32_t off = phys - 0x1F800000u; - return (uint32_t)scratchpad[off] - | ((uint32_t)scratchpad[off + 1] << 8) - | ((uint32_t)scratchpad[off + 2] << 16) - | ((uint32_t)scratchpad[off + 3] << 24); - } - if (phys >= 0x1F801000u && phys <= 0x1F803FFFu) { - return mmio_read32(phys); - } - if (phys >= 0x1FC00000u && phys <= 0x1FC7FFFFu) { - uint32_t off = phys - 0x1FC00000u; - return (uint32_t)bios_rom[off] - | ((uint32_t)bios_rom[off + 1] << 8) - | ((uint32_t)bios_rom[off + 2] << 16) - | ((uint32_t)bios_rom[off + 3] << 24); - } - unmapped_fatal(addr, phys, "READ"); - return 0; -} - -/* ---- VSync callback-pointer provenance probe (MMX6 boot wedge) ------------- - * The kernel VSync/RCnt callback-block pointer at phys 0x79D44 (KSEG0 0x80079D44) - * is corrupted to 0x016F0110 at frame ~1188 by a write whose g_debug_last_store_pc - * is STALE (so it is not a compiled/dirty-interp store — a DMA or runtime mem-op - * with a wrong destination). This always-on ring records EVERY write to that word - * at the unified RAM-write chokepoint, tagging CPU-vs-DMA via the dma.c exec flags, - * so the real corrupting writer (and, if DMA, its channel + madr) is captured even - * though the per-instruction store-PC tracker can't see it. Dump via `d44_ring`. */ -#define D44_PHYS 0x00079D44u -#define D44_RING_CAP 32u -typedef struct { - uint64_t seq; - uint32_t val, old, store_pc; - int32_t dma_depth, dma_ch; - uint32_t dma_madr, dma_bcr, frame; -} D44Entry; -D44Entry g_d44_ring[D44_RING_CAP]; -uint64_t g_d44_seq = 0; -extern uint32_t g_debug_last_store_pc; -extern int g_dma_exec_depth; /* >0 while a DMA is moving data (dma.c) */ -extern int g_dma_cur_ch; /* channel of the in-flight DMA, else -1 */ -extern uint32_t g_dma_cur_madr; /* current MADR of the in-flight DMA */ -extern uint32_t g_dma_cur_bcr; /* BCR of the in-flight DMA */ -extern uint64_t s_frame_count; -static inline void d44_note(uint32_t phys, uint32_t old, uint32_t val) { - if (phys != D44_PHYS) return; - uint64_t i = g_d44_seq++; - D44Entry *e = &g_d44_ring[i & (D44_RING_CAP - 1u)]; - e->seq = i; e->val = val; e->old = old; e->store_pc = g_debug_last_store_pc; - e->dma_depth = g_dma_exec_depth; e->dma_ch = g_dma_cur_ch; - e->dma_madr = g_dma_cur_madr; e->dma_bcr = g_dma_cur_bcr; - e->frame = (uint32_t)s_frame_count; -} - -static void psx_write_word_raw(uint32_t addr, uint32_t val); -void psx_write_word(uint32_t addr, uint32_t val) { - extern void (*g_overlay_flush_pending_cycles)(void); - if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles(); - /* The immediately following provenance callback may trust only a commit - * from this write attempt. Clear after the cycle flush because flushing - * can itself perform guest writes. */ - gte_precision_word_write_begin(); - if (g_ls_mode == 2) { ls_write_hook(addr, 4, val); return; } - if (g_ds_recording) { - if (g_dma_exec_depth > 0) ds_note_dma_write(); - else if (!psx_get_in_exception()) ds_note_write(addr, 4); - } - if (g_ls_mode != 1 || s_ls_op_active || g_ls_suppress_record || g_dma_exec_depth > 0) { psx_write_word_raw(addr, val); return; } - if (!psx_get_in_exception()) ls_write_hook(addr, 4, val); - s_ls_op_active = 1; - psx_write_word_raw(addr, val); - s_ls_op_active = 0; -} -static void psx_write_word_raw(uint32_t addr, uint32_t val) { - g_guest_store_count++; - /* KSEG2 cache control — before physical translation. */ - if (addr == 0xFFFE0130u) { cache_ctrl = val; return; } - /* KSEG2 guard — see psx_read_word_raw. */ - if (addr >= 0xC0000000u) { g_kseg2_ignored_writes++; return; } - - /* IsC (Isolate Cache): when set, writes go to D-cache only. - * We have no cache model, so silently discard RAM/scratchpad writes. */ - if (sr_ptr && (*sr_ptr & 0x10000u)) return; - - uint32_t phys = psx_phys_addr(addr); - - /* The generated BIOS mirrors its exception trampoline to 0x80000000 during - * boot. On hardware this mirror copy is not visible in RAM; only the real - * exception vector at 0x80000080 is. Tomba 2 later passes buffer=0 to the - * BIOS card write routine, so stale mirror bytes at 0 corrupt the sector 63 - * management write and leave the load menu stuck checking the card. */ - if (fntrace_is_game_started() && - phys < 0x10u && g_debug_last_store_pc == 0xBFC10A00u) return; - - /* BIOS helpers use RAM address zero as a tiny delay-loop scratch between - * device-register polls. Treat these two dummy stores as non-visible; real - * hardware / Beetle preserve Tomba 2's sector-63 card-management payload - * when it passes buffer=0 to _card_write. */ - if (fntrace_is_game_started() && phys == 0u) { - switch (g_debug_last_store_pc) { - case 0xBFC04E90u: - case 0xBFC04EF0u: - case 0xBFC05164u: - case 0xBFC0D634u: - case 0xBFC3EEB4u: - case 0xBFC405E4u: - case 0xBFC40788u: - case 0xBFC41C50u: - case 0x80012434u: - case 0x800125ACu: - return; - default: - break; - } - } - - if (phys < RAM_SIZE) { - /* Tomba 2 load-game card check: the BIOS card-manager cleanup helper - * at kernel RAM 0x1C5C scans MARK events and re-arms any READY event - * matching F0000011/{4,8000,100,200,2000}. In the recomp timing path it - * can run after the card completion DeliverEvent but before the game's - * TestEvent poll, consuming the public card event and leaving the UI - * stuck at "Checking MEMORY CARD...". Let the real TestEvent consume - * public MARK card events instead; keep this narrowly keyed to the - * helper's status store and the EvCB layout. */ - if (fntrace_is_game_started() && - g_debug_last_store_pc == 0xBFC117E4u && - val == 0x2000u && - phys >= 4u && (phys + 8u) < RAM_SIZE && - read_ram_word(phys) == 0x4000u && - read_ram_word(phys - 4u) == 0xF0000011u && - read_ram_word(phys + 8u) == 0x2000u) { - uint32_t spec = read_ram_word(phys + 4u); - if (spec == 0x00000004u || spec == 0x00008000u || - spec == 0x00000100u || spec == 0x00000200u || - spec == 0x00002000u) { - return; - } - } - /* All discard/suppression paths are now behind us. Only a write that - * actually changes main RAM may invalidate prior provenance. */ - gte_precision_invalidate_word(addr); - if (phys == D44_PHYS) d44_note(phys, read_ram_word(phys), val); - debug_server_trace_write_check(phys, read_ram_word(phys), val, 4); - parity_trace_note_write(phys, 4, effective_store_pc()); - card_data_writes_check(phys, val, 4); - dirty_ram_mark_kernel_write(phys); - text_guard_note_write(phys, val, 4); - overlay_watch_note_write(phys, 4); -#ifdef PSX_COSIM - { extern void cosim_note_ram_write(uint32_t,uint32_t); cosim_note_ram_write(phys, 4); } -#endif - ram[phys] = (uint8_t)(val); - ram[phys + 1] = (uint8_t)(val >> 8); - ram[phys + 2] = (uint8_t)(val >> 16); - ram[phys + 3] = (uint8_t)(val >> 24); - gte_precision_main_ram_word_committed(phys); - /* The matching emitted/interpreted guest-store callback consumes this - * token after write_word returns. DMA, HLE and mod writes deliberately - * have no such callback, so a sticky debug PC cannot grant provenance. */ - return; - } - { - uint32_t off; - if (mod_gpu_dma_memory_offset(phys, 4u, &off)) { - gte_precision_invalidate_word(addr); - memcpy(mod_gpu_dma_memory + off, &val, sizeof(val)); - gte_precision_gpu_dma_word_committed(phys); - return; - } - } - /* Expansion 1: 0x1F000000..0x1F7FFFFF — ignore writes */ - { - uint32_t off; - if (mod_memory_offset(phys, 4u, &off)) { - memcpy(mod_memory + off, &val, sizeof(val)); - return; - } - } - if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) return; - if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { - uint32_t off = phys - 0x1F800000u; - /* All discard/suppression paths are behind us. Invalidate any - * reviewed scratch provenance before the replacement bytes land. */ - gte_precision_invalidate_word(addr); - debug_server_trace_write_check(phys, - (uint32_t)scratchpad[off] - | ((uint32_t)scratchpad[off + 1] << 8) - | ((uint32_t)scratchpad[off + 2] << 16) - | ((uint32_t)scratchpad[off + 3] << 24), - val, 4); - scratchpad[off] = (uint8_t)(val); - scratchpad[off + 1] = (uint8_t)(val >> 8); - scratchpad[off + 2] = (uint8_t)(val >> 16); - scratchpad[off + 3] = (uint8_t)(val >> 24); - gte_precision_scratch_word_committed(phys); - return; - } - if (phys >= 0x1F801000u && phys <= 0x1F803FFFu) { - mmio_write32(phys, val); - return; - } - if (phys >= 0x1FC00000u && phys <= 0x1FC7FFFFu) { - /* ROM: silently ignore writes */ - return; - } - unmapped_fatal(addr, phys, "WRITE"); -} - -static uint16_t psx_read_half_raw(uint32_t addr); -uint16_t psx_read_half(uint32_t addr) { - if (g_ls_mode == 2) return (uint16_t)ls_read_hook(addr, 2, 0u); - if (g_ds_recording && g_dma_exec_depth == 0 && !psx_get_in_exception()) - ds_note_read(addr, 2); - if (g_ls_mode != 1 || s_ls_op_active || g_ls_suppress_record || g_dma_exec_depth > 0) return psx_read_half_raw(addr); - s_ls_op_active = 1; - uint16_t v = psx_read_half_raw(addr); - s_ls_op_active = 0; - if (!psx_get_in_exception()) ls_read_hook(addr, 2, v); - return v; -} -static uint16_t psx_read_half_raw(uint32_t addr) { - /* KSEG2 guard — see psx_read_word_raw. */ - if (addr >= 0xC0000000u) { g_kseg2_ignored_reads++; return 0; } - uint32_t phys = psx_phys_addr(addr); - - if (phys < RAM_SIZE) { - return (uint16_t)ram[phys] | ((uint16_t)ram[phys + 1] << 8); - } - { - uint32_t off; - if (mod_gpu_dma_memory_offset(phys, 2u, &off)) - return (uint16_t)mod_gpu_dma_memory[off] | - ((uint16_t)mod_gpu_dma_memory[off + 1u] << 8); - } - { - uint32_t off; - if (mod_memory_offset(phys, 2u, &off)) - return (uint16_t)mod_memory[off] | - ((uint16_t)mod_memory[off + 1u] << 8); - } - if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) return 0xFFFFu; - if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { - uint32_t off = phys - 0x1F800000u; - return (uint16_t)scratchpad[off] | ((uint16_t)scratchpad[off + 1] << 8); - } - if (phys >= 0x1F801000u && phys <= 0x1F803FFFu) { - return mmio_read16(phys); - } - if (phys >= 0x1FC00000u && phys <= 0x1FC7FFFFu) { - uint32_t off = phys - 0x1FC00000u; - return (uint16_t)bios_rom[off] | ((uint16_t)bios_rom[off + 1] << 8); - } - unmapped_fatal(addr, phys, "READ"); - return 0; -} - -static void psx_write_half_raw(uint32_t addr, uint16_t val); -void psx_write_half(uint32_t addr, uint16_t val) { - extern void (*g_overlay_flush_pending_cycles)(void); - if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles(); - if (g_ls_mode == 2) { ls_write_hook(addr, 2, val); return; } - if (g_ds_recording) { - if (g_dma_exec_depth > 0) ds_note_dma_write(); - else if (!psx_get_in_exception()) ds_note_write(addr, 2); - } - if (g_ls_mode != 1 || s_ls_op_active || g_ls_suppress_record || g_dma_exec_depth > 0) { psx_write_half_raw(addr, val); return; } - if (!psx_get_in_exception()) ls_write_hook(addr, 2, val); - s_ls_op_active = 1; - psx_write_half_raw(addr, val); - s_ls_op_active = 0; -} -static void psx_write_half_raw(uint32_t addr, uint16_t val) { - g_guest_store_count++; - if (sr_ptr && (*sr_ptr & 0x10000u)) return; - - /* KSEG2 guard — see psx_read_word_raw. */ - if (addr >= 0xC0000000u) { g_kseg2_ignored_writes++; return; } - uint32_t phys = psx_phys_addr(addr); - - if (phys < RAM_SIZE) { - gte_precision_invalidate_word(addr); - debug_server_trace_write_check(phys, (uint32_t)read_ram_half(phys), (uint32_t)val, 2); - parity_trace_note_write(phys, 2, effective_store_pc()); - card_data_writes_check(phys, (uint32_t)val, 2); - dirty_ram_mark_kernel_write(phys); - text_guard_note_write(phys, (uint32_t)val, 2); - overlay_watch_note_write(phys, 2); -#ifdef PSX_COSIM - { extern void cosim_note_ram_write(uint32_t,uint32_t); cosim_note_ram_write(phys, 2); } -#endif - ram[phys] = (uint8_t)(val); - ram[phys + 1] = (uint8_t)(val >> 8); - return; - } - { - uint32_t off; - if (mod_gpu_dma_memory_offset(phys, 2u, &off)) { - gte_precision_invalidate_word(addr); - mod_gpu_dma_memory[off] = (uint8_t)val; - mod_gpu_dma_memory[off + 1u] = (uint8_t)(val >> 8); - return; - } - } - { - uint32_t off; - if (mod_memory_offset(phys, 2u, &off)) { - mod_memory[off] = (uint8_t)val; - mod_memory[off + 1u] = (uint8_t)(val >> 8); - return; - } - } - if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) return; - if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { - uint32_t off = phys - 0x1F800000u; - gte_precision_invalidate_word(addr); - debug_server_trace_write_check(phys, - (uint32_t)scratchpad[off] | ((uint32_t)scratchpad[off + 1] << 8), - (uint32_t)val, 2); - scratchpad[off] = (uint8_t)(val); - scratchpad[off + 1] = (uint8_t)(val >> 8); - return; - } - if (phys >= 0x1F801000u && phys <= 0x1F803FFFu) { - mmio_write16(phys, val); - return; - } - if (phys >= 0x1FC00000u && phys <= 0x1FC7FFFFu) { - return; /* ROM: ignore */ - } - unmapped_fatal(addr, phys, "WRITE"); -} - -static uint8_t psx_read_byte_raw(uint32_t addr); -uint8_t psx_read_byte(uint32_t addr) { - if (g_ls_mode == 2) return (uint8_t)ls_read_hook(addr, 1, 0u); - if (g_ls_mode != 1 || s_ls_op_active || g_ls_suppress_record || g_dma_exec_depth > 0) return psx_read_byte_raw(addr); - s_ls_op_active = 1; - uint8_t v = psx_read_byte_raw(addr); - s_ls_op_active = 0; - if (!psx_get_in_exception()) ls_read_hook(addr, 1, v); - return v; -} -static uint8_t psx_read_byte_raw(uint32_t addr) { - /* KSEG2 guard — see psx_read_word_raw. */ - if (addr >= 0xC0000000u) { g_kseg2_ignored_reads++; return 0; } - uint32_t phys = psx_phys_addr(addr); - - if (phys < RAM_SIZE) { - return ram[phys]; - } - { - uint32_t off; - if (mod_gpu_dma_memory_offset(phys, 1u, &off)) - return mod_gpu_dma_memory[off]; - } - { - uint32_t off; - if (mod_memory_offset(phys, 1u, &off)) return mod_memory[off]; - } - if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) return 0xFFu; - if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { - return scratchpad[phys - 0x1F800000u]; - } - if (phys >= 0x1F801000u && phys <= 0x1F803FFFu) { - return mmio_read8(phys); - } - if (phys >= 0x1FC00000u && phys <= 0x1FC7FFFFu) { - return bios_rom[phys - 0x1FC00000u]; - } - unmapped_fatal(addr, phys, "READ"); - return 0; -} - -/* ---- CPU guest-side data loads: faithful R3000A load-delay pipeline interlock ---- - * The R3000A has no usable D-cache (it is repurposed as the 1 KB scratchpad), so a - * CPU data load from main DRAM stalls the pipeline. Beetle (cpu.cpp ReadMemory, - * 364-451) models this as: a +2 "fudge" iff the predecessor committed no load, the - * region wait (main RAM = +3, libretro.cpp:884), and a completion cost (+2 CPU / - * +1 LWC2); the (region+completion) becomes a per-register LDAbsorb "give-back" that - * following instructions consume instead of their own +1 base (pipeline write-back - * overlap). The §1 base + GPR_DEPRES + DO_LDS that bracket this run in psx_cyc.h. - * - * These functions own the WHOLE per-instruction interlock for a CPU load (they call - * psx_cyc_base/deps/lds), so the emitters/interp invoke them in place of the prior - * cpu->read_* call and emit NO separate psx_cyc_step for the load. They return the - * raw value via the UNCHARGED psx_read_* (cpu->read_* is now uncharged too — the - * data-access cost is charged exactly once, here). Keyed on the runtime effective - * physical address (KUSEG/KSEG0/KSEG1 alias the same DRAM). - * - * DMACycleSteal residual: Beetle adds the (dynamic) DMACycleSteal to EVERY read - * (libretro.cpp:868-869). That is non-zero only while a DMA channel is actively - * stealing the bus; modeling it needs the live steal count threaded out of the DMA - * controller, and it can't be isolated by a static ruler. It remains an unmodeled - * dynamic axis; the per-region device waits below are the static, validatable piece. */ - -/* Runtime-only production cycle charge for data-load timing. Overlay DLLs - * flush their local pending-cycle accumulator before entering these host - * helpers, so this stays on the host side of that ABI boundary. */ -#if defined(PSX_NO_DEBUG_TOOLS) && !defined(PSX_COSIM) && !STARVATION_RING_ENABLED -extern uint64_t g_psx_cycle_fast_limit; -extern int g_event_step_conservative; -extern int g_ls_replay_active; -static inline void psx_load_charge_cycles(uint32_t cycles) { - if (g_ls_replay_active || cycles == 0u) return; - uint64_t next = psx_cycle_count + (uint64_t)cycles; - if (!g_event_step_conservative && g_psx_cycle_fast_limit != 0u && - next >= psx_cycle_count && next <= g_psx_cycle_fast_limit) { - psx_cycle_count = next; - return; - } - psx_advance_cycles(cycles); -} -#else -static inline void psx_load_charge_cycles(uint32_t cycles) { - psx_advance_cycles(cycles); -} -#endif - -/* Beetle MemRW device-region READ wait (libretro.cpp:859-1131), the device-dependent - * part of a load's access cost (added to the timestamp before the +completion). `size` - * is the access width in bytes (1/2/4) — the SPU and CDC waits are width-dependent. - * Reads only; writes are posted (~free) in MemRW. phys is the masked physical address. */ -static inline uint32_t psx_mmio_read_wait(uint32_t phys, uint32_t size) { - /* Main RAM, mirrored across phys 0..0x7FFFFF (libretro.cpp:874 `A < 0x00800000`). */ - if (phys < 0x00800000u) return 3u; - /* BIOS ROM (905) and Expansion 1 / PIO (1134): no extra device wait. */ - if (phys >= 0x1FC00000u && phys <= 0x1FC7FFFFu) return 0u; - if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) return 0u; - /* Hardware MMIO window 0x1F801000..0x1F802FFF (921). */ - if (phys >= 0x1F801000u && phys <= 0x1F802FFFu) { - /* Bisect gate (PSX_MMIO_WAIT=0): disable the device-region read waits - * (the 9ae534d feature) to test whether they move the MMX6 cutscene - * ordering. Read once. */ - static int s_mw = -1; - if (s_mw < 0) { const char* e = getenv("PSX_MMIO_WAIT"); s_mw = (e && e[0] == '0') ? 0 : 1; } - if (!s_mw) return 0u; - if (phys >= 0x1F801C00u && phys <= 0x1F801FFFu) /* SPU (929) */ - return (size == 4u) ? 36u : 16u; - if (phys >= 0x1F801800u && phys <= 0x1F80180Fu) /* CDC (979) */ - return 6u * size; - if (phys >= 0x1F801810u && phys <= 0x1F801817u) return 1u; /* GPU (994) */ - if (phys >= 0x1F801820u && phys <= 0x1F801827u) return 1u; /* MDEC (1007) */ - if (phys >= 0x1F801000u && phys <= 0x1F801023u) return 1u; /* SysControl (1020) */ - if (phys >= 0x1F801040u && phys <= 0x1F80104Fu) return 1u; /* FrontIO/pad (1043) */ - if (phys >= 0x1F801050u && phys <= 0x1F80105Fu) return 1u; /* SIO (1055) */ - if (phys >= 0x1F801070u && phys <= 0x1F801077u) return 1u; /* IRQ (1094) */ - if (phys >= 0x1F801080u && phys <= 0x1F8010FFu) return 1u; /* DMA (1106) */ - if (phys >= 0x1F801100u && phys <= 0x1F80113Fu) return 1u; /* Timers (1119) */ - return 0u; /* unmatched MMIO: Beetle adds no device wait */ - } - return 0u; /* unknown / open-bus region */ -} - -/* Beetle ReadMemory data-access timing (cpu.cpp:369-448), after §1/deps/DO_LDS. - * compl_cost = 2 (CPU load) / 1 (LWC2); arm_rt = GPR to arm as pending load, or - * 0x20 = none (LWC2, dest is a GTE reg). size = access width in bytes (1/2/4). */ -static inline void psx_cyc_readmem(CPUState* cpu, uint32_t phys, uint32_t size, - uint32_t compl_cost, uint32_t arm_rt) { - /* ReadMemory start (369-370): clear the current give-back slot. */ - cpu->read_absorb[cpu->read_absorb_which] = 0u; - cpu->read_absorb_which = 0u; - /* Scratchpad (the D-cache): no wait, no give-back (414-422). */ - if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { - cpu->ld_absorb = 0u; - cpu->ld_which_t = (uint8_t)arm_rt; - return; - } - /* fudge (424): +2 iff the predecessor committed no load (read_fudge==0x20). - * Combined with region+completion into one advance — deadline catch-up - * replays exact event boundaries, so splitting the charge is only host cost. */ - uint32_t region = psx_mmio_read_wait(phys, size); /* device-region wait */ - uint32_t cost = region + compl_cost; /* LDAbsorb = region + completion */ - uint32_t fudge = (uint32_t)((cpu->read_fudge >> 4) & 2u); - cpu->ld_absorb = cost; - psx_advance_cycles(fudge + cost); - cpu->ld_which_t = (uint8_t)arm_rt; - /* PROOF GATE (PSX_POLL_PROOF=N, default 0/off): a FLAT, non-absorbed extra N - * cycles per main-RAM data read — replicates the historical "+6 cyc/main-RAM - * read" fix (mmx6_memcard_invalid_rootcause) that lengthened MMX6's card - * busy-poll so it outlasts the VBlank-paced async card op. Unlike the - * region+completion above (which arms ld_absorb and gets given back in a - * tight loop), this is pure added cost. If setting this makes the MMX6 save - * load, the card regression is confirmed as poll-vs-op timing. TEMPORARY — - * the faithful fix models the uncached data-read cost properly. */ - if (phys < 0x00800000u) { - static int s_pp = -1; - if (s_pp < 0) { const char* e = getenv("PSX_POLL_PROOF"); s_pp = (e && e[0]) ? atoi(e) : 0; } - if (s_pp > 0) psx_load_charge_cycles((uint32_t)s_pp); - } -} - -/* Resolve PSX_LOAD_DELAY once (shared with inlined psx_cyc.h helpers). */ -int psx_load_delay_enabled(void) { - if (g_psx_load_delay < 0) { - const char* e = getenv("PSX_LOAD_DELAY"); - g_psx_load_delay = (e && e[0] == '0') ? 0 : 1; - } - return g_psx_load_delay; -} - -/* The interlock half of a load (§1+deps+(cancel)+DO_LDS+ReadMemory). Gated on - * PSX_ENABLE_BLOCK_CYCLES so the Beetle-oracle build (cycles off) does a plain read. */ -static inline void psx_cyc_load_timing(CPUState* cpu, uint32_t addr, uint32_t size, - uint32_t rt, uint32_t reg_mask) { -#ifdef PSX_ENABLE_BLOCK_CYCLES - /* Bisect gate (PSX_LOAD_DELAY=0): disable the R3000A load-delay interlock - * timing (the d8c4a8e/fade560/d597797 feature) to test whether it moves the - * MMX6 cutscene ordering. Read once; default on. */ - if (!psx_load_delay_enabled()) { - (void)addr; (void)size; (void)rt; (void)reg_mask; - return; - } - psx_cyc_base(cpu); - psx_cyc_deps(cpu, reg_mask); - if (cpu->ld_which_t == rt) cpu->ld_which_t = 0u; /* cancel pending load to same dest */ - psx_cyc_lds(cpu); - psx_cyc_readmem(cpu, addr & 0x1FFFFFFFu, size, 2u, rt); -#else - (void)cpu; (void)addr; (void)size; (void)rt; (void)reg_mask; -#endif -} - -/* Production value-read fast path for the overwhelmingly common main-RAM - * load. Timing has already run before this helper is consulted, so a device - * deadline (and any DMA it services) remains ordered before the value read. - * - * Keep this host-local: generated overlay DLLs flush their pending cycles - * before entering psx_cyc_load_* and must not depend on runtime globals. Every - * mode with observable read-side instrumentation falls back to psx_read_*. - * The physical-range test is done before the 2 MiB mirror fold so MMIO, BIOS, - * scratchpad, KSEG2/cache-control and open-bus behavior remain canonical. */ -#if defined(PSX_NO_DEBUG_TOOLS) && !defined(PSX_COSIM) -static inline int psx_cyc_main_ram_fast_addr(uint32_t addr, uint32_t width, - uint32_t *phys_out) { - if (g_ls_mode != 0 || g_ls_replay_active || g_ds_recording || - g_ram_read_watch_active || - g_dma_exec_depth > 0 || addr >= 0xC0000000u) - return 0; - uint32_t phys = addr & 0x1FFFFFFFu; - if (phys >= 0x00800000u) return 0; - phys &= (uint32_t)(RAM_SIZE - 1); - /* Aligned guest loads cannot cross this boundary, but fail closed for a - * malformed/unaligned caller instead of introducing a host OOB read. */ - if (phys > (uint32_t)RAM_SIZE - width) return 0; - *phys_out = phys; - return 1; -} -#endif - -/* Slow paths for the inlined helpers in psx_cyc.h (MMIO / lockstep / shards). */ -uint32_t psx_cyc_load_word_slow(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask) { - psx_cyc_load_timing(cpu, addr, 4u, rt, reg_mask); - return psx_read_word(addr); -} -uint16_t psx_cyc_load_half_slow(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask) { - psx_cyc_load_timing(cpu, addr, 2u, rt, reg_mask); - return psx_read_half(addr); -} -void psx_cyc_load_word_timing_only(CPUState* cpu, uint32_t addr, - uint32_t rt, uint32_t reg_mask) { - psx_cyc_load_timing(cpu, addr, 4u, rt, reg_mask); -} -uint8_t psx_cyc_load_byte(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask) { - psx_cyc_load_timing(cpu, addr, 1u, rt, reg_mask); -#if defined(PSX_NO_DEBUG_TOOLS) && !defined(PSX_COSIM) - uint32_t phys; - if (psx_cyc_main_ram_fast_addr(addr, 1u, &phys)) return ram[phys]; -#endif - return psx_read_byte(addr); -} - -/* LWC2 (GTE load): §1/DO_LDS done by psx_cyc_step(cpu,0); the GTE deadline stall by - * psx_gte_stall — both emitted before this call. 32-bit access, completion +1, no - * LDWhich arm. */ -uint32_t psx_cyc_lwc2_read(CPUState* cpu, uint32_t addr) { -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_cyc_readmem(cpu, addr & 0x1FFFFFFFu, 4u, 1u, 0x20u); -#else - (void)cpu; -#endif -#if defined(PSX_NO_DEBUG_TOOLS) && !defined(PSX_COSIM) - uint32_t phys; - if (psx_cyc_main_ram_fast_addr(addr, 4u, &phys)) { - return (uint32_t)ram[phys] - | ((uint32_t)ram[phys + 1] << 8) - | ((uint32_t)ram[phys + 2] << 16) - | ((uint32_t)ram[phys + 3] << 24); - } -#endif - return psx_read_word(addr); -} - -/* Deprecated uncharged passthroughs (the +4 flat wait-state model is gone; load - * timing now lives in psx_cyc_load_*). Kept so any stray reference stays valid. */ -uint32_t psx_guest_read_word(uint32_t addr) { return psx_read_word(addr); } -uint16_t psx_guest_read_half(uint32_t addr) { return psx_read_half(addr); } -uint8_t psx_guest_read_byte(uint32_t addr) { return psx_read_byte(addr); } - -static void psx_write_byte_raw(uint32_t addr, uint8_t val); -void psx_write_byte(uint32_t addr, uint8_t val) { - extern void (*g_overlay_flush_pending_cycles)(void); - if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles(); - if (g_ls_mode == 2) { ls_write_hook(addr, 1, val); return; } - if (g_ls_mode != 1 || s_ls_op_active || g_ls_suppress_record || g_dma_exec_depth > 0) { psx_write_byte_raw(addr, val); return; } - if (!psx_get_in_exception()) ls_write_hook(addr, 1, val); - s_ls_op_active = 1; - psx_write_byte_raw(addr, val); - s_ls_op_active = 0; -} -static void psx_write_byte_raw(uint32_t addr, uint8_t val) { - g_guest_store_count++; - if (sr_ptr && (*sr_ptr & 0x10000u)) return; - - /* KSEG2 guard — see psx_read_word_raw. */ - if (addr >= 0xC0000000u) { g_kseg2_ignored_writes++; return; } - uint32_t phys = psx_phys_addr(addr); - - if (phys < RAM_SIZE) { - gte_precision_invalidate_word(addr); - debug_server_trace_write_check(phys, (uint32_t)ram[phys], (uint32_t)val, 1); - parity_trace_note_write(phys, 1, effective_store_pc()); - card_data_writes_check(phys, (uint32_t)val, 1); - dirty_ram_mark_kernel_write(phys); - text_guard_note_write(phys, (uint32_t)val, 1); - overlay_watch_note_write(phys, 1); -#ifdef PSX_COSIM - { extern void cosim_note_ram_write(uint32_t,uint32_t); cosim_note_ram_write(phys, 1); } -#endif - ram[phys] = val; - return; - } - { - uint32_t off; - if (mod_gpu_dma_memory_offset(phys, 1u, &off)) { - gte_precision_invalidate_word(addr); - mod_gpu_dma_memory[off] = val; - return; - } - } - { - uint32_t off; - if (mod_memory_offset(phys, 1u, &off)) { - mod_memory[off] = val; - return; - } - } - if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) return; - if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { - gte_precision_invalidate_word(addr); - debug_server_trace_write_check(phys, (uint32_t)scratchpad[phys - 0x1F800000u], - (uint32_t)val, 1); - scratchpad[phys - 0x1F800000u] = val; - return; - } - if (phys >= 0x1F801000u && phys <= 0x1F803FFFu) { - mmio_write8(phys, val); - return; - } - if (phys >= 0x1FC00000u && phys <= 0x1FC7FFFFu) { - return; /* ROM: ignore */ - } - unmapped_fatal(addr, phys, "WRITE"); -} diff --git a/psxrecomp-overlay/runtime/src/overlay_loader.c b/psxrecomp-overlay/runtime/src/overlay_loader.c deleted file mode 100644 index f05e691..0000000 --- a/psxrecomp-overlay/runtime/src/overlay_loader.c +++ /dev/null @@ -1,4715 +0,0 @@ -#include "overlay_loader.h" -#include "overlay_api.h" -#include "overlay_path_canon.h" -#include "code_provider.h" -#include "overlay_backend.h" -#include "crc32.h" -#include "dirty_ram_interp.h" -#include "interrupts.h" -#include "debug_server.h" -#include "psx_cycles.h" -#include "lockstep.h" -#include "overlay_posix.h" -#include -#include -#include -#include -#include -#include - -extern void overlay_watch_set_range(uint32_t phys, uint32_t len); - -#ifdef _WIN32 -# define WIN32_LEAN_AND_MEAN -# include -#else -# include -#endif - -#ifdef _WIN32 -# define OVERLAY_SHARED_EXT ".dll" -# define OVERLAY_SHARED_EXT_LEN 4 -#else -# define OVERLAY_SHARED_EXT ".so" -# define OVERLAY_SHARED_EXT_LEN 3 -#endif - -/* ============================================================================ - * Inc3 — Per-entry validity + multi-candidate dispatch (design doc §8) - * - * Validity is tracked per *compiled entry*, not per dirty region. Each entry - * carries its tight code byte-ranges (from the recompiler's per-function walk, - * emitted as a {base}_{crc}.ranges manifest beside the DLL), a content hash of - * those ranges, and a page-generation snapshot. A write to a watched page bumps - * that page's generation (memory.c); dispatch lazily re-hashes an entry only - * when the generation over its ranges changed. This: - * - kills the false-invalidation churn of the old [fn_lo,fn_hi) region flag - * (data interleaved between functions no longer nukes the whole region), and - * - makes reload-on-return gradual and automatic — each entry flips back to - * native the moment its own code bytes reappear (hash matches), with no - * region-wide atomic threshold. - * - * PC -> candidate list: different overlays can reuse the same RAM address - * (Tomba loads village and overworld both at 0x800E7xxx). A single-entry table - * would let the later DLL clobber the earlier candidate, making reload-on-return - * impossible. So each PC maps to a chain of candidates; dispatch picks the one - * whose code hash matches live RAM. - * - * §8.3 RESOLVED: jump tables compile to `switch (live register)` with a - * call_by_address default, so table contents are an optimization, not a - * correctness dependency. The dependency set is the entry's code bytes only. - * ==========================================================================*/ - -typedef void (*OverlayFn)(CPUState *); -typedef void (*OverlayFlushFn)(void); - -#define MAX_CODE_RANGES 16 /* code ranges per function (coalesced; usually 1) */ - -enum { ENTRY_VALID = 0, ENTRY_INVALID = 1, ENTRY_BLACKLIST = 2 }; - -typedef struct { - uint32_t addr; /* phys entry address */ - OverlayFn fn; - uint32_t range_lo[MAX_CODE_RANGES]; /* phys code-range starts */ - uint32_t range_len[MAX_CODE_RANGES]; - int nranges; - uint32_t crc_code; /* hash of code ranges at registration*/ - uint32_t val_gen; /* pagegen sum when last (in)validated*/ - int state; /* ENTRY_VALID/INVALID/BLACKLIST */ - int dll; /* source DLL index */ - uint8_t tier; /* gcc=2, tcc=1, unknown=0 */ - int next; /* next candidate at same addr, -1 end*/ - uint32_t diff_passes; /* clean same-state diffs (verify budget)*/ - int device_touch; /* 1 = touches MMIO; never run its shard, - * always interp (shadow diff can't safely - * double-execute device I/O). */ -#ifndef PSX_NO_DEBUG_TOOLS - uint32_t native_rank; /* first-seen identity rank for bisection */ -#endif -} Candidate; - -/* Same-state differential verify budget: diff a candidate this many times with - * 0 divergence, then trust it (stop diffing — run it normally). Bounds the - * differential's cost to ~(distinct functions × budget) instead of every - * dispatch forever, making a validation playtest playable; a DIVERGING - * candidate never reaches the budget, so it stays diff-gated (interp result - * kept) and never executes native live. */ -#define OVERLAY_DIFF_BUDGET 32u - -/* MMX6's clean play-free cache now carries 17,506 on-disk manifest identities - * across observed byte variants, above the old 16,384 lifetime ceiling. Lazy - * loading means this is a potential long-session population, not proof that one - * observed run loaded all of them; every encountered variant does remain loaded. - * Keep enough headroom that coverage cannot silently become interpreter-only. */ -/* Executable loader tests need a tiny table to prove alias-at-cap behavior. - * Keep the override private to this translation unit: changing overlay_api.h - * would roll the production shard codegen hash and its publisher contract. */ -#ifdef PSX_OVERLAY_TEST_CANDIDATE_CAP -#define CAND_CAP PSX_OVERLAY_TEST_CANDIDATE_CAP -#else -#define CAND_CAP PSX_OVERLAY_CANDIDATE_CAP -#endif -static Candidate s_cand[CAND_CAP]; -static int s_cand_n = 0; - -/* CPS continuation lookup. A full candidate-table scan at every tail transfer - * scales catastrophically once a warmed cache contains hundreds of variant - * DLLs. Index candidates by the 4 KiB RAM pages touched by their code ranges; - * a continuation then examines only candidates that could contain its PC. */ -#define RANGE_PAGE_COUNT (2u * 1024u * 1024u / 4096u) -#define RANGE_LINK_CAP (CAND_CAP * 8) -typedef struct { int cand, next; } RangeLink; -static int s_range_page_head[RANGE_PAGE_COUNT]; -static int s_range_page_tail[RANGE_PAGE_COUNT]; -static RangeLink s_range_links[RANGE_LINK_CAP]; -static int s_range_link_n = 0; -static int s_range_index_overflow = 0; - -/* Cache the selected owner of hot CPS continuation PCs. The cached candidate - * is still generation/CRC validated on every lookup; this only avoids walking - * hundreds of historical, currently-invalid owners in a reused region. */ -#define RANGE_PC_CACHE_CAP 16384u -#define RANGE_PC_CACHE_MASK (RANGE_PC_CACHE_CAP - 1u) -typedef struct { uint32_t phys, generation; int cand; } RangePcCache; -static RangePcCache s_range_pc_cache[RANGE_PC_CACHE_CAP]; -static uint32_t s_range_candidate_generation = 1u; - -static void range_index_add_candidate(int ci) { - Candidate *c = &s_cand[ci]; - uint8_t seen[RANGE_PAGE_COUNT / 8] = {0}; - for (int r = 0; r < c->nranges; r++) { - uint32_t lo = c->range_lo[r]; - uint32_t hi = lo + c->range_len[r] - 1u; - uint32_t p0 = lo >> 12, p1 = hi >> 12; - if (p0 >= RANGE_PAGE_COUNT) continue; - if (p1 >= RANGE_PAGE_COUNT) p1 = RANGE_PAGE_COUNT - 1u; - for (uint32_t p = p0; p <= p1; p++) { - uint8_t bit = (uint8_t)(1u << (p & 7u)); - if (seen[p >> 3] & bit) continue; - seen[p >> 3] |= bit; - if (s_range_link_n >= RANGE_LINK_CAP) { - /* Never leave lookup correctness dependent on a partial index. - * overlay_find_by_range() falls back to the original ordered - * candidate scan if a future title exceeds this sizing guess. */ - s_range_index_overflow = 1; - return; - } - int li = s_range_link_n++; - s_range_links[li].cand = ci; - s_range_links[li].next = -1; - if (s_range_page_head[p] < 0) s_range_page_head[p] = li; - else s_range_links[s_range_page_tail[p]].next = li; - s_range_page_tail[p] = li; - } - } -} - -/* RECURSION_BUG.md §25 — 1 when the build is continuation-passing (set by a - * constructor in the generated CPS dispatch). The overlay dispatch reads it to - * emit/route the CPS tail-transfer contract. Defined here (before - * overlay_loader_dispatch) so both can see it. 0 = legacy unit-model. */ -int g_psx_cps_mode = 0; - -/* Open-addressed index: phys entry addr -> head candidate index (-1 sentinel - * stored as chain terminator on each Candidate). addr 0 = empty slot. */ -#define IDX_CAP (CAND_CAP * 2u) -#define IDX_MASK (IDX_CAP - 1u) -typedef struct { uint32_t addr; int head; } IdxSlot; -static IdxSlot s_idx[IDX_CAP]; -/* Presence-only exact-entry index for the interpreter's common local-transfer - * negative check. Full byte/bundle validation remains in dispatch. */ -static uint32_t s_exact_entry_bitmap[DIRTY_RAM_EXEC_BITMAP_WORDS]; - -static void exact_entry_set(uint32_t phys) { - phys &= 0x1FFFFFFFu; - if (phys < 2u * 1024u * 1024u && (phys & 3u) == 0u) { - uint32_t word = phys >> 2; - s_exact_entry_bitmap[word >> 5] |= 1u << (word & 31u); - } -} - -static int exact_entry_has(uint32_t phys) { - phys &= 0x1FFFFFFFu; - if (phys >= 2u * 1024u * 1024u || (phys & 3u) != 0u) return 0; - uint32_t word = phys >> 2; - return (s_exact_entry_bitmap[word >> 5] >> (word & 31u)) & 1u; -} - -static int idx_head(uint32_t phys) { - uint32_t h = (phys * 2654435761u) & IDX_MASK; - for (uint32_t i = 0; i < IDX_CAP; i++) { - uint32_t k = (h + i) & IDX_MASK; - if (s_idx[k].addr == 0) return -1; - if (s_idx[k].addr == phys) return s_idx[k].head; - } - return -1; -} -static void idx_set_head(uint32_t phys, int head) { - uint32_t h = (phys * 2654435761u) & IDX_MASK; - for (uint32_t i = 0; i < IDX_CAP; i++) { - uint32_t k = (h + i) & IDX_MASK; - if (s_idx[k].addr == 0 || s_idx[k].addr == phys) { - s_idx[k].addr = phys; - s_idx[k].head = head; - return; - } - } -} - -/* Active native-entry stack (self-modification detection, §8.5). */ -static int s_active_stack[64]; -static int s_active_depth = 0; - -/* ---- Observability (recomp-debug: measure, don't eyeball) -------------- */ -/* Always-on ring of native overlay calls, so the FIRST divergence / last - * function executed before a corruption can be read back from the window of - * interest — never "arm a trace then hope". s_native_inprogress holds the entry - * currently executing (nonzero at dump => a native fn was entered and never - * returned: a freeze INSIDE native code, the strongest single suspect). */ -#define NRING_CAP 16384 -typedef struct { uint32_t addr; uint32_t crc; uint32_t frame; uint64_t seq; int returned; } NRingEnt; -static NRingEnt s_nring[NRING_CAP]; -static uint32_t s_nring_pos = 0; -static uint64_t s_nring_seq = 0; -static uint32_t s_native_inprogress = 0; /* addr of fn currently in native, 0 = none */ -/* Sampler accessor (phase_profile hot-function histogram): the innermost native - * candidate's registered entry. Read off-thread; a torn read is harmless. */ -uint32_t overlay_loader_native_inprogress(void) { return s_native_inprogress; } -static uint64_t s_native_calls_total = 0; -extern uint64_t s_frame_count; - -/* Runtime A/B: when 0, candidates are still hashed/validated and recorded, but - * NEVER executed native — execution falls to the interpreter. Lets us prove - * whether native EXECUTION is the cause without a rebuild or losing candidate - * visibility. Default on. */ -static int s_native_exec = 1; -static uint64_t s_would_run_native = 0; /* matched but skipped (exec off) */ - -void overlay_loader_set_native_exec(int on) { s_native_exec = on ? 1 : 0; } -int overlay_loader_get_native_exec(void) { return s_native_exec; } -/* Fail-closed native entry guard (root-cause fix for the Tomba/Tomba2 native- - * overlay "blue screen" wedge). A native overlay function's generated CPS entry- - * switch calls psx_native_bad_entry when it is entered at a PC that is NOT one of - * its legal entries (true prologue or a known continuation) -- e.g. when range - * ownership wrongly resolved a foreign interior PC to this function. Instead of - * the old `default: break` (which fell through and ran the function from its - * TOP, corrupting shared CPU/RAM state -- the wedge), the function records the - * illegal entry and returns WITHOUT executing; overlay_loader_dispatch then - * routes the requested PC to the sanctioned dirty-RAM interpreter (the bytes at - * that PC run correctly). NOT a stub/HLE -- the code still runs, via interp. */ -int g_native_bad_entry = 0; /* set by psx_native_bad_entry, consumed by dispatch */ -static uint32_t s_bad_entry_owner = 0; /* the function unit that rejected the PC */ -static uint32_t s_bad_entry_pc = 0; /* the illegal entry PC */ -static uint64_t s_bad_entry_count = 0; -void psx_native_bad_entry(CPUState *cpu, uint32_t owner, uint32_t pc) { - (void)cpu; - g_native_bad_entry = 1; - s_bad_entry_owner = owner; - s_bad_entry_pc = pc; - s_bad_entry_count++; -} -void overlay_loader_bad_entry_stats(uint32_t *owner, uint32_t *pc, uint64_t *count) { - if (owner) *owner = s_bad_entry_owner; - if (pc) *pc = s_bad_entry_pc; - if (count) *count = s_bad_entry_count; -} - -/* Per-function native-disable (bisection). Functions whose ENTRY phys address is - * listed here are forced through the sanctioned dirty-RAM interpreter instead of - * their native shard — exactly as if s_native_exec were off, but for that one - * function only. This is a DIAGNOSTIC localization knob (not skip/stub/HLE): the - * function still runs, just via the interpreter. Used to binary-search which - * compiled overlay function's native execution causes a native-vs-interp - * divergence. Keyed by phys (addr & 0x1FFFFFFF) so KSEG bits don't matter. */ -#define NATIVE_BLOCK_CAP 64 -static uint32_t s_native_block[NATIVE_BLOCK_CAP]; -static int s_native_block_n = 0; -static uint64_t s_native_block_hits = 0; -static int overlay_native_blocked(uint32_t addr) { - if (s_native_block_n == 0) return 0; - uint32_t p = addr & 0x1FFFFFFFu; - for (int i = 0; i < s_native_block_n; i++) - if (s_native_block[i] == p) { s_native_block_hits++; return 1; } - return 0; -} -int overlay_loader_native_block_add(uint32_t addr) { - uint32_t p = addr & 0x1FFFFFFFu; - for (int i = 0; i < s_native_block_n; i++) if (s_native_block[i] == p) return s_native_block_n; - if (s_native_block_n >= NATIVE_BLOCK_CAP) return -1; - s_native_block[s_native_block_n++] = p; - return s_native_block_n; -} -void overlay_loader_native_block_clear(void) { s_native_block_n = 0; s_native_block_hits = 0; } -int overlay_loader_native_block_list(uint32_t *out, int cap) { - int n = s_native_block_n < cap ? s_native_block_n : cap; - for (int i = 0; i < n; i++) out[i] = s_native_block[i]; - return s_native_block_n; -} -uint64_t overlay_loader_native_block_hits(void) { return s_native_block_hits; } - -/* CPS interior-continuation dispatch probe (diagnostic, always-on when armed). - * Records, for a watched interior PC, what the CPS continuation re-entry path - * (overlay_find_by_range + validate) decides: chosen candidate entry, range - * count, crc match, and outcome. Used to confirm the wrong-candidate / escape - * hypothesis for the Tomba 2 FMV freeze (pc=0x50B30). */ -static uint32_t s_cps_probe_pc = 0; /* phys, 0 = disarmed */ -static uint64_t s_cps_probe_count = 0; -static uint32_t s_cps_probe_found = 0; /* chosen c->addr (0 if none) */ -static int s_cps_probe_ci = -2; /* overlay_find_by_range result */ -static int s_cps_probe_nrange = -1; -static int s_cps_probe_matched = -1; /* crc match */ -static int s_cps_probe_outcome = -1; /* 0=find<0,1=crc-miss->interp,2=native,3=device,4=blocked */ -static int s_cps_probe_ncand_inrange = 0; /* how many candidates' range contains the PC */ -void overlay_loader_cps_probe_set(uint32_t pc) { - s_cps_probe_pc = pc & 0x1FFFFFFFu; s_cps_probe_count = 0; - s_cps_probe_found = 0; s_cps_probe_ci = -2; s_cps_probe_nrange = -1; - s_cps_probe_matched = -1; s_cps_probe_outcome = -1; s_cps_probe_ncand_inrange = 0; -} -void overlay_loader_cps_probe_get(uint32_t *pc, uint64_t *cnt, uint32_t *found, - int *ci, int *nrange, int *matched, int *outcome, - int *ncand) { - if (pc) *pc = s_cps_probe_pc; if (cnt) *cnt = s_cps_probe_count; - if (found) *found = s_cps_probe_found; if (ci) *ci = s_cps_probe_ci; - if (nrange) *nrange = s_cps_probe_nrange; if (matched) *matched = s_cps_probe_matched; - if (outcome) *outcome = s_cps_probe_outcome; if (ncand) *ncand = s_cps_probe_ncand_inrange; -} -/* count how many live candidates have a range containing phys (multi-variant check) */ -static int overlay_count_by_range(uint32_t phys) { - int n = 0; - for (int i = 0; i < s_cand_n; i++) { - const Candidate *c = &s_cand[i]; - if (c->state == ENTRY_BLACKLIST) continue; - for (int r = 0; r < c->nranges; r++) - if (phys >= c->range_lo[r] && phys < c->range_lo[r] + c->range_len[r]) { n++; break; } - } - return n; -} -/* Address of the native overlay function currently executing (0 if none). - * Used by the event-timeline ring to tag an event's execution mode. */ -uint32_t overlay_loader_get_inprogress(void) { return s_native_inprogress; } - -/* Same-state differential — defined fully below; declared here for dispatch. */ -static int s_diff_mode = 0; -/* Optional diagnostic filter: when non-zero, shadow-diff only this candidate - * entry while every other validated candidate follows its normal live route. */ -static uint32_t s_diff_addr = 0; -static int s_in_shadow = 0; -/* Candidate whose shadow NATIVE pass is currently executing (NULL outside it). - * Set ONLY around run_shadow_diff's native pass — never during the interp pass, - * which must stay pure interp. Lets the CPS continuation re-entry path below - * run THIS candidate's own interiors natively inside its shadow run (so the - * diff exercises the whole function, not just the first CPS segment), while - * every other dispatch inside the shadow stays on the interpreter. */ -static const void *s_shadow_cand = NULL; -/* Longjmp-escape hardening. run_shadow_diff saves the pre-shadow values of - * s_native_exec / s_suppress_irq here (in addition to its locals) so that - * overlay_loader_shadow_escape_fixup() can restore them if an exception - * longjmp ever unwinds through a live shadow frame (should be impossible — - * the dispatch gate refuses to start a shadow while in an exception dispatch — - * but an escape must fail LOUD and self-heal, not silently kill the - * instrument). s_shadow_epoch is the exception-setjmp epoch at shadow start: - * a longjmp targeting a frame with epoch <= s_shadow_epoch escapes the shadow; - * a larger epoch is an exception armed INSIDE the shadow (contained). */ -static int s_shadow_saved_native_exec = 0; -static int s_shadow_saved_supp = 0; -static int s_shadow_saved_mmio_watch = 0; -static int s_shadow_saved_exec_phase = 0; -static int s_shadow_scheduler_bail = 0; -static int s_shadow_saved_call_bail = 0; -static uint32_t s_shadow_saved_debug_func = 0; -static uint32_t s_shadow_saved_irq_calls = 0; -static uint64_t s_shadow_saved_irq_supp = 0; -static uint64_t s_shadow_epoch = 0; -static uint32_t s_shadow_escapes = 0; -static uint32_t s_shadow_escapes_native = 0; -/* (sljit removed 2026-07-15: the deprecated in-process JIT tier and its - * live-execution mode are gone; overlay gaps fall to the interpreter.) */ -static void run_shadow_diff(CPUState *cpu, Candidate *c, uint32_t addr); - -/* A real interpreter-pass ChangeThread is authoritative and escapes before a - * native validation pass starts. Therefore a scheduler switch attempted while - * s_shadow_cand is armed is necessarily native-only divergence. Traps calls - * this before mutating TCB/RAM or longjmping; force the speculative call to - * unwind normally so run_shadow_diff can restore the interpreter snapshot. */ -int overlay_loader_shadow_native_thread_switch_bail(void) { - if (!s_in_shadow || s_shadow_cand == NULL) return 0; - s_shadow_scheduler_bail = 1; - g_psx_call_bail = 1; - return 1; -} - -static int cand_range_contains(const Candidate *c, uint32_t phys) { - for (int r = 0; r < c->nranges; r++) - if (phys >= c->range_lo[r] && phys < c->range_lo[r] + c->range_len[r]) - return 1; - return 0; -} - -static int cand_ranges_overlap(const Candidate *a, const Candidate *b) { - for (int ar = 0; ar < a->nranges; ar++) { - uint32_t alo = a->range_lo[ar], ahi = alo + a->range_len[ar]; - for (int br = 0; br < b->nranges; br++) { - uint32_t blo = b->range_lo[br], bhi = blo + b->range_len[br]; - if (alo < bhi && blo < ahi) return 1; - } - } - return 0; -} - -/* An explicit entry filter owns its whole code envelope, including separately - * seeded continuation candidates. This prevents an IRQ resume at a mid-function - * PC from bypassing the diagnostic quarantine and running native live. The scan - * is intentionally diagnostic-only (s_diff_addr != 0). */ -static int cand_selected_for_diff(const Candidate *c) { - if (!s_diff_addr) return 1; - uint32_t target = s_diff_addr & 0x1FFFFFFFu; - if (c->addr == target || cand_range_contains(c, target)) return 1; - for (int i = 0; i < s_cand_n; i++) { - const Candidate *owner = &s_cand[i]; - if (owner->addr == target && cand_ranges_overlap(c, owner)) return 1; - } - return 0; -} - -/* ---- Counters (surfaced via overlay_loader_status) --------------------- */ -static int s_ndlls = 0; /* DLLs LoadLibrary'd */ -static uint64_t s_load_total_us = 0; -static uint64_t s_load_max_us = 0; -static uint64_t s_load_last_us = 0; -static int s_valid_count = 0; /* candidates currently VALID */ -static uint64_t s_disp_native = 0; -static uint64_t s_disp_interp = 0; -/* Small diagnostic-only native-owner sampler. A logical softlock can continue - * presenting at 60 Hz while executing a tiny bad native loop, so FPS alone is - * not correctness evidence. This is enabled only with the existing runtime - * perf environment switch and is drained once per telemetry interval. */ -#define NATIVE_HOT_CAP 256u -typedef struct { uint32_t pc; uint64_t calls; } NativeHot; -static NativeHot s_native_hot[NATIVE_HOT_CAP]; -static int s_native_hot_enabled = 0; - -#ifndef PSX_NO_DEBUG_TOOLS -/* Correctness localization without speculative/double guest execution. When - * PSX_NATIVE_RANK_LIMIT is present, each concrete loaded candidate is assigned - * a first-seen rank. Candidates through the limit execute normally; later ones - * fall back to the authoritative interpreter. Binary-searching the limit finds - * the first candidate identity required for a deterministic softlock while - * preserving exactly one guest execution path per run. Debug builds only. */ -static uint32_t s_native_rank_limit = UINT32_MAX; -static uint32_t s_native_rank_next = 0; -static uint64_t s_native_rank_blocked = 0; -#endif - -static void native_hot_note(uint32_t pc) { - if (!s_native_hot_enabled) return; - uint32_t slot = ((pc >> 2) * 2654435761u) & (NATIVE_HOT_CAP - 1u); - NativeHot *h = &s_native_hot[slot]; - if (h->pc != pc) { h->pc = pc; h->calls = 0; } - h->calls++; -} -static uint64_t s_stale_blocked = 0; /* dispatches skipped (candidate !valid)*/ -static uint32_t s_invalidations = 0; /* VALID -> INVALID transitions */ -static uint32_t s_revalidations = 0; /* INVALID -> VALID (reload-on-return) */ -static uint32_t s_rehashes = 0; /* code-range hashes computed */ -static uint32_t s_rehash_miss = 0; /* hashes that didn't match crc_code */ -static uint64_t s_gen_fastpath = 0; /* dispatches that skipped the crc32 */ - /* via the unchanged page-generation */ - /* fast path (overlay-cache v2 P2) */ -static uint64_t s_diffgate_interp = 0; /* CPS interior re-entries sent to the */ - /* interp because their candidate is */ - /* still inside the diff verify budget */ -static uint32_t s_last_crc = 0; -static uint32_t s_no_manifest = 0; /* exports skipped (no manifest range)*/ -static uint64_t s_cand_overflow = 0; /* registrations dropped at CAND_CAP */ -static uint64_t s_pair_aliases = 0; /* validated complete pairs deduped */ -static uint32_t s_selfmod = 0; /* entries blacklisted (self-mod) */ -static uint32_t s_last_write_pc = 0; -static uint32_t s_last_write_addr = 0; -static uint32_t s_last_write_size = 0; - -/* ABI v11 cycle batching. Each DLL owns a small pending-cycle accumulator. - * While its code is active, memory stores ask this export to commit cycles - * before the guest-visible write; block/device boundaries flush through the - * DLL glue directly. */ -static OverlayFlushFn s_dll_flush[4096]; -OverlayFlushFn g_overlay_flush_pending_cycles = NULL; - -static OverlayFlushFn overlay_flush_enter(const Candidate *c) { - OverlayFlushFn prev = g_overlay_flush_pending_cycles; - if (prev) prev(); - g_overlay_flush_pending_cycles = - (c->dll >= 0 && c->dll < (int)(sizeof(s_dll_flush) / sizeof(s_dll_flush[0]))) - ? s_dll_flush[c->dll] : NULL; - return prev; -} - -static void overlay_flush_leave(OverlayFlushFn prev) { - if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles(); - g_overlay_flush_pending_cycles = prev; -} - -extern uint8_t *memory_get_ram_ptr(void); -extern uint32_t overlay_watch_pagegen_sum(uint32_t phys, uint32_t len); - -/* ---- Per-candidate hash / generation over its code ranges -------------- */ - -static uint32_t cand_crc(const Candidate *c) { - const uint8_t *ram = memory_get_ram_ptr(); - uint32_t crc = 0xFFFFFFFFu; - for (int i = 0; i < c->nranges; i++) - crc = crc32_update(crc, ram + c->range_lo[i], c->range_len[i]); - return crc ^ 0xFFFFFFFFu; -} - -static uint32_t cand_gensum(const Candidate *c) { - uint32_t s = 0; - for (int i = 0; i < c->nranges; i++) - s += overlay_watch_pagegen_sum(c->range_lo[i], c->range_len[i]); - return s; -} - -#ifdef PSX_HAS_OVERLAY_DISPATCH -/* Static-overlay validation uses the same exact-code-range contract as the - * dynamic DLL loader. Generated code passes immutable {phys_lo, len} pairs and - * the CRC of the bytes it was compiled from. A page-generation cache keeps the - * hot path O(number of ranges), without re-hashing unchanged code each call. */ -#define STATIC_MATCH_CACHE_CAP 4096u -typedef struct { - const uint32_t *ranges; - uint32_t count; - uint32_t expected_crc; - uint32_t gen_sum; - int matches; -} StaticMatchCache; - -static StaticMatchCache s_static_match_cache[STATIC_MATCH_CACHE_CAP]; -static uint64_t s_static_match_rehashes = 0; -static uint64_t s_static_match_crc_misses = 0; -static uint64_t s_static_match_gen_fastpath = 0; - -int psx_overlay_static_code_matches(const uint32_t *lo_len_pairs, - uint32_t count, - uint32_t expected_crc) { - const uint8_t *ram = memory_get_ram_ptr(); - if (!ram || !lo_len_pairs || count == 0u || count > 4096u) { - s_static_match_crc_misses++; - return 0; - } - - uint32_t gen_sum = 0; - for (uint32_t i = 0; i < count; i++) { - uint32_t lo = lo_len_pairs[i * 2u] & 0x1FFFFFFFu; - uint32_t len = lo_len_pairs[i * 2u + 1u]; - if (len == 0u || lo >= 2u * 1024u * 1024u || - len > 2u * 1024u * 1024u - lo) { - s_static_match_crc_misses++; - return 0; - } - gen_sum += overlay_watch_pagegen_sum(lo, len); - } - - uintptr_t raw = (uintptr_t)lo_len_pairs; - uint32_t slot = (uint32_t)(((raw >> 4) ^ (raw >> 19) ^ expected_crc ^ - (count * 0x9E3779B9u)) & - (STATIC_MATCH_CACHE_CAP - 1u)); - StaticMatchCache *entry = NULL; - for (uint32_t probe = 0; probe < STATIC_MATCH_CACHE_CAP; probe++) { - StaticMatchCache *candidate = - &s_static_match_cache[(slot + probe) & (STATIC_MATCH_CACHE_CAP - 1u)]; - if (!candidate->ranges || - (candidate->ranges == lo_len_pairs && - candidate->count == count && - candidate->expected_crc == expected_crc)) { - entry = candidate; - break; - } - } - - if (entry && entry->ranges && entry->gen_sum == gen_sum) { - s_static_match_gen_fastpath++; - return entry->matches; - } - - uint32_t crc = 0xFFFFFFFFu; - for (uint32_t i = 0; i < count; i++) { - uint32_t lo = lo_len_pairs[i * 2u] & 0x1FFFFFFFu; - uint32_t len = lo_len_pairs[i * 2u + 1u]; - crc = crc32_update(crc, ram + lo, len); - } - crc ^= 0xFFFFFFFFu; - int matches = (crc == expected_crc) ? 1 : 0; - s_static_match_rehashes++; - if (!matches) s_static_match_crc_misses++; - - if (entry) { - entry->ranges = lo_len_pairs; - entry->count = count; - entry->expected_crc = expected_crc; - entry->gen_sum = gen_sum; - entry->matches = matches; - } - return matches; -} - -void overlay_loader_static_match_stats(uint64_t *rehashes, - uint64_t *crc_misses, - uint64_t *gen_fastpath) { - if (rehashes) *rehashes = s_static_match_rehashes; - if (crc_misses) *crc_misses = s_static_match_crc_misses; - if (gen_fastpath) *gen_fastpath = s_static_match_gen_fastpath; -} -#endif - -/* ---- Per-DLL code-range manifest --------------------------------------- */ -/* Strict v2 line format emitted by tools/compile_overlays.py beside each DLL: - * P optional DLL/manifest publication identity - * F start a function (entry = virtual export addr) - * R a code byte-range (virtual addr, byte length) - * The recompiler's per-function instruction walk yields exactly the executed - * code bytes — interleaved jump tables / constant pools are excluded, which is - * what makes the hash stable across reloads. */ -typedef struct { - uint32_t entry; - uint32_t crc; /* expected hash of the compiled-from code bytes */ - int has_crc; /* parser validity bit; authoritative CRC is required */ - uint32_t lo[MAX_CODE_RANGES]; - uint32_t len[MAX_CODE_RANGES]; - int n; -} ManFn; - -#define OVERLAY_RAM_SIZE (2u * 1024u * 1024u) -#define MANIFEST_LINE_MAX 128u -#define MANIFEST_PHYSICAL_LINE_MAX 159u -#define MANIFEST_PROVENANCE_PREFIX "# psxrecomp overlay provenance " -enum { - MANIFEST_PROVENANCE_AMBIGUOUS = -1, - MANIFEST_PROVENANCE_AUTHORITY = 0, - MANIFEST_PROVENANCE_HOSTED = 1, - MANIFEST_PROVENANCE_ORPHAN = 2 -}; - -static int man_structurally_valid(const ManFn *m) { - if (!m || !m->has_crc || m->n < 1 || m->n > MAX_CODE_RANGES) - return 0; - if ((m->entry & 0xFFE00000u) != 0x80000000u) return 0; - uint32_t entry = m->entry & 0x1FFFFFFFu; - if ((entry & 3u) != 0u || entry >= OVERLAY_RAM_SIZE) return 0; - int entry_covered = 0; - for (int r = 0; r < m->n; r++) { - if ((m->lo[r] & 0xFFE00000u) != 0x80000000u) return 0; - uint32_t lo = m->lo[r] & 0x1FFFFFFFu; - uint32_t len = m->len[r]; - if ((lo & 3u) != 0u || (len & 3u) != 0u || len < 4u || - lo >= OVERLAY_RAM_SIZE || len > OVERLAY_RAM_SIZE - lo) - return 0; - if (entry >= lo && entry - lo <= len - 4u) entry_covered = 1; - for (int p = 0; p < r; p++) { - uint32_t prev_lo = m->lo[p] & 0x1FFFFFFFu; - uint32_t prev_hi = prev_lo + m->len[p]; - uint32_t hi = lo + len; - if (lo < prev_hi && prev_lo < hi) return 0; - } - } - return entry_covered; -} - -static int manifest_is_space(unsigned char c) { - return c == ' ' || c == '\t' || c == '\r' || c == '\v' || c == '\f'; -} - -static const char *manifest_skip_space(const char *p) { - while (*p && manifest_is_space((unsigned char)*p)) p++; - return p; -} - -static int manifest_hex_field(const char **cursor, int max_digits, - uint64_t *out) { - const char *p = manifest_skip_space(*cursor); - uint64_t value = 0; - int digits = 0; - while (isxdigit((unsigned char)*p)) { - int nibble; - if (*p >= '0' && *p <= '9') nibble = *p - '0'; - else if (*p >= 'a' && *p <= 'f') nibble = *p - 'a' + 10; - else nibble = *p - 'A' + 10; - if (++digits > max_digits) return 0; - value = (value << 4) | (uint64_t)nibble; - p++; - } - if (digits == 0 || (*p && !manifest_is_space((unsigned char)*p))) return 0; - *cursor = p; - *out = value; - return 1; -} - -static int manifest_record_end(const char *cursor) { - return *manifest_skip_space(cursor) == '\0'; -} - -static ManFn *parse_manifest(const char *path, int *out_n, - uint64_t *out_pair_id, - int *out_has_pair_id, - int *out_provenance) { - *out_n = 0; - if (out_pair_id) *out_pair_id = 0; - if (out_has_pair_id) *out_has_pair_id = 0; - if (out_provenance) *out_provenance = MANIFEST_PROVENANCE_AUTHORITY; - FILE *f = fopen(path, "rb"); - if (!f) return NULL; - int cap = 1024, n = 0; - int invalid = 0, pair_seen = 0, provenance_seen = 0; - int provenance = MANIFEST_PROVENANCE_AUTHORITY; - ManFn *arr = (ManFn *)malloc(sizeof(ManFn) * cap); - if (!arr) { fclose(f); return NULL; } - char line[MANIFEST_PHYSICAL_LINE_MAX + 1u]; - ManFn *cur = NULL; - for (;;) { - size_t line_len = 0; - int ch = 0, too_long = 0; - while ((ch = fgetc(f)) != '\n' && ch != EOF) { - if (line_len + 1 < sizeof(line)) line[line_len++] = (char)ch; - else too_long = 1; - } - if (ch == EOF && line_len == 0 && !too_long) break; - line[line_len] = '\0'; - size_t semantic_len = line_len; - if (semantic_len > 0 && line[semantic_len - 1] == '\r') - semantic_len--; - size_t provenance_prefix_len = strlen(MANIFEST_PROVENANCE_PREFIX); - if (semantic_len >= provenance_prefix_len && - memcmp(line, MANIFEST_PROVENANCE_PREFIX, - provenance_prefix_len) == 0) { - const char *value = line + provenance_prefix_len; - size_t value_len = semantic_len - provenance_prefix_len; - int parsed_provenance = MANIFEST_PROVENANCE_AMBIGUOUS; - if (value_len == strlen("hosted-v1") && - memcmp(value, "hosted-v1", value_len) == 0) - parsed_provenance = MANIFEST_PROVENANCE_HOSTED; - else if (value_len == strlen("orphan-v1") && - memcmp(value, "orphan-v1", value_len) == 0) - parsed_provenance = MANIFEST_PROVENANCE_ORPHAN; - provenance_seen++; - provenance = provenance_seen == 1 - ? parsed_provenance : MANIFEST_PROVENANCE_AMBIGUOUS; - } - const char *record = manifest_skip_space(line); - int recognized = ((*record == 'P' || *record == 'F' || *record == 'R') && - (record[1] == '\0' || - manifest_is_space((unsigned char)record[1]))); - int embedded_nul = memchr(line, '\0', line_len) != NULL; - int non_ascii = 0; - for (size_t i = 0; i < line_len; i++) - if ((unsigned char)line[i] >= 0x80u) { non_ascii = 1; break; } - size_t record_width = 0; - while (record_width < line_len && line[record_width] != '\r') - record_width++; - if (too_long || embedded_nul || non_ascii || - record_width > MANIFEST_LINE_MAX) { - invalid = 1; - continue; - } - if (!recognized) continue; - if (*record == 'P') { - const char *cursor = record + 1; - uint64_t parsed = 0; - if (manifest_hex_field(&cursor, 16, &parsed) && - manifest_record_end(cursor)) { - if (pair_seen) invalid = 1; - pair_seen = 1; - if (out_pair_id) *out_pair_id = parsed; - if (out_has_pair_id) *out_has_pair_id = 1; - } else invalid = 1; - } else if (*record == 'F') { - const char *cursor = record + 1; - uint64_t parsed_e = 0, parsed_crc = 0; - if (manifest_hex_field(&cursor, 8, &parsed_e) && - manifest_hex_field(&cursor, 8, &parsed_crc) && - manifest_record_end(cursor)) { - if (n >= cap) { - cap *= 2; - ManFn *na = (ManFn *)realloc(arr, sizeof(ManFn) * cap); - if (!na) { invalid = 1; break; } - arr = na; - } - cur = &arr[n++]; - cur->entry = ((uint32_t)parsed_e & 0x1FFFFFFFu) | 0x80000000u; - cur->crc = (uint32_t)parsed_crc; - cur->has_crc = 1; - cur->n = 0; - } else invalid = 1; - } else if (*record == 'R' && cur) { - const char *cursor = record + 1; - uint64_t parsed_lo = 0, parsed_len = 0; - if (manifest_hex_field(&cursor, 8, &parsed_lo) && - manifest_hex_field(&cursor, 8, &parsed_len) && - manifest_record_end(cursor) && - cur->n < MAX_CODE_RANGES && parsed_len > 0) { - cur->lo[cur->n] = (uint32_t)parsed_lo; - cur->len[cur->n] = (uint32_t)parsed_len; - cur->n++; - } else invalid = 1; - } - } - if (!invalid && (n == 0 || !cur || !man_structurally_valid(cur))) invalid = 1; - fclose(f); - for (int i = 0; i < n; i++) { - uint32_t entry = arr[i].entry & 0x1FFFFFFFu; - int contains_entry = 0; - if (!arr[i].has_crc || arr[i].n <= 0) invalid = 1; - for (int r = 0; r < arr[i].n; r++) { - uint32_t lo = arr[i].lo[r] & 0x1FFFFFFFu; - uint64_t hi = (uint64_t)lo + arr[i].len[r]; - if (lo >= OVERLAY_RAM_SIZE || - arr[i].len[r] > OVERLAY_RAM_SIZE - lo) - invalid = 1; - if ((uint64_t)entry >= lo && (uint64_t)entry < hi) - contains_entry = 1; - } - if (!contains_entry) invalid = 1; - for (int j = 0; j < i; j++) - if ((arr[j].entry & 0x1FFFFFFFu) == entry) invalid = 1; - } - if (invalid || n == 0) { - free(arr); - return NULL; - } - if (out_provenance) *out_provenance = provenance; - *out_n = n; - return arr; -} - -static ManFn *man_find(ManFn *arr, int n, uint32_t entry) { - for (int i = 0; i < n; i++) - if (arr[i].entry == entry) return &arr[i]; - return NULL; -} - -/* 1 = supported R3000A control transfer with a delay slot, 0 = ordinary - * instruction, -1 = reserved/unsupported branch encoding that native shards - * must never claim. */ -static int mips_control_kind(uint32_t instr) { - uint32_t op = instr >> 26; - if (op == 0u) { - uint32_t funct = instr & 0x3Fu; - return funct == 0x08u || funct == 0x09u; /* JR / JALR */ - } - if (op == 0x01u) { /* REGIMM */ - uint32_t rt = (instr >> 16) & 0x1Fu; - return (rt == 0x00u || rt == 0x01u || - rt == 0x10u || rt == 0x11u) ? 1 : -1; - } - if (op == 0x02u || op == 0x03u || - (op >= 0x04u && op <= 0x07u)) /* J/JAL/branches */ - return 1; - if (op >= 0x14u && op <= 0x17u) return -1; /* branch-likely: reserved */ - if (op >= 0x10u && op <= 0x13u && - ((instr >> 21) & 0x1Fu) == 0x08u) return -1; /* unsupported BCz* */ - return 0; -} - -static int ranges_contain_word(const uint32_t *lo_list, - const uint32_t *len_list, int n, - uint32_t phys) { - if ((phys & 3u) != 0u || phys > (2u * 1024u * 1024u) - 4u) return 0; - for (int r = 0; r < n; r++) { - uint32_t lo = lo_list[r] & 0x1FFFFFFFu; - uint32_t len = len_list[r]; - if (len >= 4u && phys >= lo && phys - lo <= len - 4u) return 1; - } - return 0; -} - -/* Defense in depth for caches generated before guarded capture/codegen. Called - * only after the range CRC matches. Scan every hashed instruction so a missing - * slot at any range edge, an unsupported branch form, or a nested transfer in a - * delay slot rejects the whole native identity. */ -static int ranges_delay_slots_hashed(const uint32_t *lo_list, - const uint32_t *len_list, int n) { - const uint8_t *ram = memory_get_ram_ptr(); - const uint32_t ram_size = 2u * 1024u * 1024u; - if (!ram || n < 1 || n > MAX_CODE_RANGES) return 0; - for (int r = 0; r < n; r++) { - uint32_t lo = lo_list[r] & 0x1FFFFFFFu; - uint32_t len = len_list[r]; - if ((lo & 3u) != 0u || (len & 3u) != 0u || len < 4u || - lo >= ram_size || len > ram_size - lo) - return 0; - for (uint32_t pc = lo; pc < lo + len; pc += 4u) { - uint32_t instr = (uint32_t)ram[pc] | - ((uint32_t)ram[pc + 1u] << 8) | - ((uint32_t)ram[pc + 2u] << 16) | - ((uint32_t)ram[pc + 3u] << 24); - int kind = mips_control_kind(instr); - if (kind < 0) return 0; - if (kind > 0) { - uint32_t slot = pc + 4u; - if (!ranges_contain_word(lo_list, len_list, n, slot)) return 0; - uint32_t slot_instr = (uint32_t)ram[slot] | - ((uint32_t)ram[slot + 1u] << 8) | - ((uint32_t)ram[slot + 2u] << 16) | - ((uint32_t)ram[slot + 3u] << 24); - if (mips_control_kind(slot_instr) != 0) return 0; - } - } - } - return 1; -} - -/* Complete-pair deduplication. A generated P record binds the DLL source, - * manifest provenance, and normalized F/R records. The pair id alone is not - * enough for aliasing: retain the canonical parsed manifest and require exact - * semantic equality as collision defense. Compiler tiers remain separate so - * a gcc image never silently stands in for a tcc image (or vice versa). - * - * Only a fully registered pair enters this table. Its DLL handle, function - * pointers, and cycle-flush callback remain the sole owners for process life; - * a later identical physical cache pair is preflighted, closed, and recorded - * as a satisfied path without consuming Candidate slots. */ -#define LOADED_PAIR_CAP 4096 -#define LOAD_PAIR_ALIAS (-1) -typedef struct { - uint64_t pair_id; - ManFn *man; - int man_n; - int tier; - int provenance; -} LoadedPair; -static LoadedPair s_loaded_pairs[LOADED_PAIR_CAP]; -static int s_loaded_pair_n = 0; - -static int manifest_pair_equal(const ManFn *a, int a_n, - const ManFn *b, int b_n) { - if (!a || !b || a_n != b_n) return 0; - for (int i = 0; i < a_n; i++) { - if (a[i].entry != b[i].entry || a[i].crc != b[i].crc || - a[i].has_crc != b[i].has_crc || a[i].n != b[i].n) - return 0; - for (int r = 0; r < a[i].n; r++) - if ((a[i].lo[r] & 0x1FFFFFFFu) != - (b[i].lo[r] & 0x1FFFFFFFu) || - a[i].len[r] != b[i].len[r]) - return 0; - } - return 1; -} - -static int man_delay_slots_hashed(const ManFn *m) { - return ranges_delay_slots_hashed(m->lo, m->len, m->n); -} - -static int cand_delay_slots_hashed(const Candidate *c) { - return ranges_delay_slots_hashed(c->range_lo, c->range_len, c->nranges); -} - -static int loaded_pair_find(int tier, int provenance, uint64_t pair_id, - const ManFn *man, int man_n) { - if (provenance == MANIFEST_PROVENANCE_AMBIGUOUS) return -1; - for (int i = 0; i < s_loaded_pair_n; i++) { - const LoadedPair *p = &s_loaded_pairs[i]; - if (p->tier == tier && p->provenance == provenance && - p->pair_id == pair_id && - manifest_pair_equal(p->man, p->man_n, man, man_n)) - return i; - } - return -1; -} - -static void loaded_pair_commit(int tier, int provenance, uint64_t pair_id, - const ManFn *man, int man_n) { - if (!man || man_n <= 0 || - provenance == MANIFEST_PROVENANCE_AMBIGUOUS || - s_loaded_pair_n >= LOADED_PAIR_CAP || - loaded_pair_find(tier, provenance, pair_id, man, man_n) >= 0) - return; - ManFn *copy = (ManFn *)malloc(sizeof(*copy) * (size_t)man_n); - if (!copy) return; /* Safe fallback: later twins register independently. */ - memcpy(copy, man, sizeof(*copy) * (size_t)man_n); - LoadedPair *p = &s_loaded_pairs[s_loaded_pair_n++]; - p->pair_id = pair_id; - p->man = copy; - p->man_n = man_n; - p->tier = tier; - p->provenance = provenance; -} - -/* ---- Candidate registration -------------------------------------------- */ - -static void loader_log(const char *fmt, ...); /* defined below */ -static int cand_register(uint32_t phys, OverlayFn fn, const ManFn *m, int dll, - int tier) { - if (s_cand_n >= CAND_CAP) { - s_cand_overflow++; - return 0; - } - if (!man_structurally_valid(m)) return 0; - int idx = s_cand_n++; - Candidate *c = &s_cand[idx]; - c->addr = phys; - c->fn = fn; - c->dll = dll; - c->tier = (uint8_t)tier; - c->state = ENTRY_VALID; - c->nranges = 0; - for (int i = 0; i < m->n && c->nranges < MAX_CODE_RANGES; i++) { - c->range_lo[c->nranges] = m->lo[i] & 0x1FFFFFFFu; - c->range_len[c->nranges] = m->len[i]; - c->nranges++; - } - /* Watch the code pages so future writes bump their generation. */ - extern void overlay_watch_set_range(uint32_t phys, uint32_t len); - for (int i = 0; i < c->nranges; i++) - overlay_watch_set_range(c->range_lo[i], c->range_len[i]); - - /* crc_code is the AUTHORITATIVE hash of the bytes the recompiler compiled - * from (supplied by the manifest) — NOT a sample of live RAM at this instant - * (registration is a single transient moment; the overlay may not be fully - * present yet, and other overlays sharing the merged DLL are not present at - * all). A candidate is callable iff live RAM matches this compiled-from hash, - * which makes validity timing-independent and reload-on-return work. */ - c->crc_code = m->crc; - c->val_gen = cand_gensum(c); - c->state = (cand_crc(c) == c->crc_code && cand_delay_slots_hashed(c)) - ? ENTRY_VALID : ENTRY_INVALID; - /* Keep higher-priority compiler tiers first without discarding additive - * artifacts. New same-tier repairs precede older ones; lower-tier TCC - * remains available if every GCC candidate fails live-byte validation. */ - int head = idx_head(phys); - if (head < 0 || s_cand[head].tier <= c->tier) { - c->next = head; - idx_set_head(phys, idx); - } else { - int prev = head; - while (s_cand[prev].next >= 0 && - s_cand[s_cand[prev].next].tier > c->tier) - prev = s_cand[prev].next; - c->next = s_cand[prev].next; - s_cand[prev].next = idx; - } - exact_entry_set(phys); - range_index_add_candidate(idx); - s_range_candidate_generation++; - if (s_range_candidate_generation == 0u) s_range_candidate_generation = 1u; - if (c->state == ENTRY_VALID) s_valid_count++; - /* (sljit removed 2026-07-15: the gcc-DLL-obsoletes-older-sljit-shard block - * lived here. With no sljit shards ever registered (dll == -1), there is - * nothing to obsolete; the normal content-keyed chain dispatch stands.) */ - return 1; -} - -/* (sljit removed 2026-07-15: register_sljit_candidate, the JIT-on-miss memo, - * the async compile-worker glue, the master tier gate, and try_sljit_region - * lived here. Overlay misses now fall to the interpreter.) */ - -/* ---- Global state ------------------------------------------------------ */ - -static char s_cache_dir[512]; -static char s_game_id[64]; -static uint32_t s_config_hash; -static int s_active = 0; - -/* Canonical (filesystem-resolved) form of s_cache_dir, computed lazily on the - * first live publication — the root is guaranteed to exist by then (the - * compiler just wrote a DLL into it). cache_path_has_root() is sound ONLY on - * canonical inputs: raw pipe text can smuggle `..` segments or a junction - * past a lexical prefix compare. */ -static char s_cache_root_canon[768]; -static int s_cache_root_canon_ok = 0; - -/* Rule 3: no stderr logging. Most recent loader event recorded here and - * surfaced through overlay_loader_status. */ -static char s_last_msg[256] = {0}; - -static void loader_log(const char *fmt, ...) { - va_list ap; - va_start(ap, fmt); - vsnprintf(s_last_msg, sizeof(s_last_msg), fmt, ap); - va_end(ap); -} - -const char *overlay_loader_last_msg(void) { return s_last_msg; } - -/* ---- Cache index: region_start -> dll path ----------------------------- */ - -/* 256 -> 4096 (2026-07-03): Tomba2's cache crossed 256 DLLs and the index - * TRUNCATED SILENTLY — every entry past the cap was invisible to the loader - * (region ran interpreted forever, no diagnostic). Found by the ABI-sweep - * negative test. scan_one_cache_dir now shouts if even 4096 is hit. */ -#define CACHE_IDX_CAP 4096 -enum { CACHE_TIER_UNKNOWN = 0, CACHE_TIER_TCC = 1, CACHE_TIER_GCC = 2 }; -typedef struct { - uint32_t region_start; - uint32_t logical_crc; - uint64_t mtime; - int func_count; - int indexed_func_count; - uint8_t tier; - uint8_t manifest_ok; - uint8_t load_failed; - int resident; - int capacity_suppressed; - char path[768]; -} CacheEntry; -static CacheEntry s_cache_idx[CACHE_IDX_CAP]; -static int s_cache_idx_count = 0; -static int s_capacity_warm_dropped = 0; -static void overlay_image_warm_cancel(int ci); -static void overlay_image_warm_release(int ci); -static void overlay_image_warm_drop_all(void); - -static int cache_entry_suppress_for_shortfall(int ci, int needed) { - if (ci < 0 || ci >= s_cache_idx_count || needed <= 0) return 0; - CacheEntry *e = &s_cache_idx[ci]; - if (e->capacity_suppressed) return 1; - if (needed <= CAND_CAP - s_cand_n) return 0; - if (s_cand_n >= CAND_CAP && !s_capacity_warm_dropped) { - /* No further DLL can ever register in this process. Release every - * speculative image-map reference, including bundles never dispatched. */ - overlay_image_warm_drop_all(); - s_capacity_warm_dropped = 1; - } else { - /* Smaller bundles may still fit. Release only this impossible image - * and leave warming enabled for candidates within the remaining cap. */ - overlay_image_warm_cancel(ci); - overlay_image_warm_release(ci); - } - e->capacity_suppressed = 1; - s_cand_overflow += (uint64_t)needed; - loader_log("*** CANDIDATE TABLE SHORTFALL (%d): suppressing %s (%d " - "manifest candidates, %d slots remain); native coverage is " - "falling back to the interpreter", CAND_CAP, e->path, needed, - CAND_CAP - s_cand_n); - return 1; -} - -/* Candidates are process-lifetime objects, so reaching CAND_CAP is permanent. - * Suppress each not-yet-loaded bundle once instead of repeatedly mapping and - * unmapping it on every dispatch miss. The durable overflow count includes the - * manifest identities that this short-circuit prevents from registering. */ -static int cache_entry_suppress_at_capacity(int ci) { - if (ci < 0 || ci >= s_cache_idx_count) return 0; - CacheEntry *e = &s_cache_idx[ci]; - if (e->capacity_suppressed) return 1; - if (s_cand_n < CAND_CAP) return 0; - int dropped = e->indexed_func_count > 0 - ? e->indexed_func_count : e->func_count; - if (dropped == 0) dropped = 1; - return cache_entry_suppress_for_shortfall(ci, dropped); -} - -static int path_component_eq(const char *path, const char *wanted) { - size_t wanted_len = strlen(wanted); - const char *p = path; - while (*p) { - while (*p == '/' || *p == '\\') p++; - const char *start = p; - while (*p && *p != '/' && *p != '\\') p++; - size_t len = (size_t)(p - start); - if (len == wanted_len) { - size_t i = 0; - for (; i < len; i++) { - unsigned char a = (unsigned char)start[i]; - unsigned char b = (unsigned char)wanted[i]; - if (tolower(a) != tolower(b)) break; - } - if (i == len) return 1; - } - } - return 0; -} - -static int cache_tier_from_path(const char *path) { - if (path_component_eq(path, "gcc")) return CACHE_TIER_GCC; - if (path_component_eq(path, "tcc")) return CACHE_TIER_TCC; - return CACHE_TIER_UNKNOWN; -} - -static int cache_name_is_immutable(const char *name) { - if (!name) return 0; - size_t len = strlen(name); - /* Current publishers commit a recoverable region/CRC pair. Retain support - * for additive artifact-suffixed pairs produced by earlier cache tools. */ - if (len == 17u + OVERLAY_SHARED_EXT_LEN) return name[8] == '_'; - return len == 26u + OVERLAY_SHARED_EXT_LEN && - name[8] == '_' && name[17] == '_'; -} - -static int cache_path_char_equal(unsigned char a, unsigned char b) { -#ifdef _WIN32 - if ((a == '/' || a == '\\') && (b == '/' || b == '\\')) return 1; - return tolower(a) == tolower(b); -#else - return a == b; -#endif -} - -static int cache_path_equal(const char *a, const char *b) { - if (!a || !b) return 0; - while (*a && *b) { - if (!cache_path_char_equal((unsigned char)*a, (unsigned char)*b)) - return 0; - a++; - b++; - } - return *a == '\0' && *b == '\0'; -} - -static int cache_path_has_root(const char *path, const char *root) { - if (!path || !root) return 0; - size_t root_len = strlen(root); - while (root_len > 0u && - (root[root_len - 1u] == '/' || root[root_len - 1u] == '\\')) - root_len--; - for (size_t i = 0; i < root_len; i++) { - if (!path[i] || - !cache_path_char_equal((unsigned char)path[i], - (unsigned char)root[i])) - return 0; - } - return path[root_len] == '/' || path[root_len] == '\\'; -} - -#ifdef _WIN32 -/* Image-only, bounded mapping for small recovery/fragment DLLs. Windows can - * spend ~100 ms in its loader/security path on the first LoadLibrary, enough to - * drain audio even though guest execution is otherwise at full speed. Map the - * dynamic-text-base fragments on a worker before streamed code first uses them. - * The emulation thread still performs overlay_init, export registration, and - * live-byte validation. - * - * This deliberately is NOT a boot-time preload: Tomba's mature vault contains - * hundreds of historical fragments and retaining all of them consumed ~1.8 GB. - * Only one cache region's small fragments are mapped. */ -typedef struct { - volatile LONG state; /* 0 idle, 1 mapping, 2 ready, 3 cancelled, -1 failed */ - HMODULE handle; -} ImageWarm; -typedef struct { - int cache_idx; - char path[768]; -} ImageWarmJob; -typedef struct { - int count; - ImageWarmJob jobs[1]; -} ImageWarmBatch; -static ImageWarm s_image_warm[CACHE_IDX_CAP]; -volatile LONG g_overlay_image_warm_loaded = 0; -volatile LONG g_overlay_image_warm_pending = 0; -static int s_image_warm_enabled = 1; -static ULONGLONG s_image_warm_not_before_ms = 0; - -static DWORD WINAPI overlay_image_warm_main(LPVOID unused) { - ImageWarmBatch *batch = (ImageWarmBatch *)unused; - ULONGLONG now = GetTickCount64(); - if (now < s_image_warm_not_before_ms) - Sleep((DWORD)(s_image_warm_not_before_ms - now)); - for (int i = 0; i < batch->count; i++) { - ImageWarmJob *job = &batch->jobs[i]; - int ci = job->cache_idx; - if (InterlockedCompareExchange(&s_image_warm[ci].state, 0, 0) != 1) { - InterlockedDecrement(&g_overlay_image_warm_pending); - continue; - } - HMODULE h = LoadLibraryA(job->path); - s_image_warm[ci].handle = h; - if (InterlockedCompareExchange(&s_image_warm[ci].state, - h ? 2 : -1, 1) == 1) { - if (h) InterlockedIncrement(&g_overlay_image_warm_loaded); - } else { - /* The emulation thread needed this image before the batch reached - * it and cancelled the speculative reference. */ - s_image_warm[ci].handle = NULL; - if (h) FreeLibrary(h); - InterlockedExchange(&s_image_warm[ci].state, 0); - } - InterlockedDecrement(&g_overlay_image_warm_pending); - } - free(batch); - return 0; -} - -static void overlay_image_warm_init(void) { - const char *e = getenv("PSX_OVERLAY_IMAGE_WARM"); - if (e && (e[0] == '0' || e[0] == 'n' || e[0] == 'N')) - s_image_warm_enabled = 0; - /* Leave BIOS/game initialization alone, but finish before the first - * streamed replacement of the dynamic text base. */ - s_image_warm_not_before_ms = GetTickCount64() + 5000u; -} - -static void overlay_image_warm_queue(const int *indices, int count) { - /* Candidate storage is process-lifetime. Once full, no warmed image can - * ever be registered, including a queue attempted later during init. */ - if (!s_image_warm_enabled || s_capacity_warm_dropped || - s_cand_n >= CAND_CAP || count <= 0) return; - size_t bytes = sizeof(ImageWarmBatch) + - (size_t)(count - 1) * sizeof(ImageWarmJob); - ImageWarmBatch *batch = (ImageWarmBatch *)malloc(bytes); - if (!batch) return; - batch->count = 0; - for (int i = 0; i < count; i++) { - int ci = indices[i]; - if (ci < 0 || ci >= s_cache_idx_count) continue; - if (s_cache_idx[ci].capacity_suppressed) continue; - if (InterlockedCompareExchange(&s_image_warm[ci].state, 1, 0) != 0) - continue; - ImageWarmJob *job = &batch->jobs[batch->count++]; - job->cache_idx = ci; - snprintf(job->path, sizeof(job->path), "%s", s_cache_idx[ci].path); - InterlockedIncrement(&g_overlay_image_warm_pending); - } - if (batch->count == 0) { free(batch); return; } - HANDLE h = CreateThread(NULL, 0, overlay_image_warm_main, batch, 0, NULL); - if (!h) { - for (int i = 0; i < batch->count; i++) { - InterlockedDecrement(&g_overlay_image_warm_pending); - InterlockedExchange(&s_image_warm[batch->jobs[i].cache_idx].state, 0); - } - free(batch); - return; - } - SetThreadPriority(h, THREAD_PRIORITY_BELOW_NORMAL); - CloseHandle(h); -} - -static void overlay_image_warm_cancel(int ci) { - if (ci < 0 || ci >= CACHE_IDX_CAP) return; - InterlockedCompareExchange(&s_image_warm[ci].state, 3, 1); -} - -static void overlay_image_warm_release(int ci) { - if (ci < 0 || ci >= CACHE_IDX_CAP) return; - if (InterlockedCompareExchange(&s_image_warm[ci].state, 0, 0) != 2) return; - HMODULE h = s_image_warm[ci].handle; - s_image_warm[ci].handle = NULL; - InterlockedExchange(&s_image_warm[ci].state, 0); - if (h) FreeLibrary(h); /* load_overlay_dll retained its own reference */ -} -static void overlay_image_warm_drop_all(void) { - for (int ci = 0; ci < s_cache_idx_count; ci++) { - overlay_image_warm_cancel(ci); - overlay_image_warm_release(ci); - } -} -#else -long g_overlay_image_warm_loaded = 0; -long g_overlay_image_warm_pending = 0; -static void overlay_image_warm_init(void) {} -static void overlay_image_warm_queue(const int *indices, int count) - { (void)indices; (void)count; } -static void overlay_image_warm_cancel(int ci) { (void)ci; } -static void overlay_image_warm_release(int ci) { (void)ci; } -static void overlay_image_warm_drop_all(void) {} -#endif - -static void overlay_image_warm_seed_boot_text(void); - -/* Manifest-only cache index. Parsing the small .ranges sidecars up front lets - * a dispatch hash the live code bytes and LoadLibrary only the exact matching - * variant. This is the scalable alternative to trial-loading hundreds of DLLs - * that share one reused RAM region. */ -#define LAZY_MAN_CAP (CAND_CAP * 2) -#define LAZY_ENTRY_CAP (CAND_CAP * 2u) -#define LAZY_ENTRY_MASK (LAZY_ENTRY_CAP - 1u) -#define LAZY_RANGE_LINK_CAP (LAZY_MAN_CAP * 8) -typedef struct { - int cache_idx; - ManFn fn; - uint32_t val_gen; - uint8_t state; /* 0xFF unknown, else ENTRY_VALID/INVALID */ - int next_entry; - int next_bundle; -} LazyMan; -static LazyMan s_lazy_man[LAZY_MAN_CAP]; -static int s_lazy_man_n = 0; -static int s_lazy_man_overflow = 0; -static int s_lazy_entry_head[LAZY_ENTRY_CAP]; -static int s_lazy_entry_tail[LAZY_ENTRY_CAP]; -static int s_lazy_bundle_head[CACHE_IDX_CAP]; -static int s_lazy_page_head[RANGE_PAGE_COUNT]; -static int s_lazy_page_tail[RANGE_PAGE_COUNT]; -static RangeLink s_lazy_range_links[LAZY_RANGE_LINK_CAP]; -static int s_lazy_range_link_n = 0; - -/* A direct-mapped negative cache for the final "no live native owner" result. - * Hot uncovered PCs otherwise repeat manifest/range-chain discovery on every - * branch into the interpreter. The entry is valid only for the current RAM - * code generation and loader generation: executable writes, DLL publication, - * and cache rescans all make old misses disappear immediately. */ -#define LAZY_MISS_CACHE_CAP 16384u -#define LAZY_MISS_CACHE_MASK (LAZY_MISS_CACHE_CAP - 1u) -typedef struct { - uint32_t phys; - uint32_t code_gen; - uint32_t watched_code_gen; - uint32_t loader_gen; -} LazyMissEntry; -static LazyMissEntry s_lazy_miss_cache[LAZY_MISS_CACHE_CAP]; -static uint32_t s_lazy_loader_gen = 1; -static uint32_t s_lazy_watched_code_gen = 1; -extern uint32_t g_dirty_ram_code_gen; - -static void lazy_miss_invalidate_loader(void) { - if (++s_lazy_loader_gen == 0) { - memset(s_lazy_miss_cache, 0, sizeof(s_lazy_miss_cache)); - s_lazy_loader_gen = 1; - } -} - -static int lazy_miss_cached(uint32_t phys) { - LazyMissEntry *e = &s_lazy_miss_cache[(phys >> 2) & LAZY_MISS_CACHE_MASK]; - return e->phys == phys && e->code_gen == g_dirty_ram_code_gen && - e->watched_code_gen == s_lazy_watched_code_gen && - e->loader_gen == s_lazy_loader_gen; -} - -static void lazy_miss_record(uint32_t phys) { - LazyMissEntry *e = &s_lazy_miss_cache[(phys >> 2) & LAZY_MISS_CACHE_MASK]; - e->phys = phys; - e->code_gen = g_dirty_ram_code_gen; - e->watched_code_gen = s_lazy_watched_code_gen; - e->loader_gen = s_lazy_loader_gen; -} - -void overlay_loader_note_code_write(void) { - if (++s_lazy_watched_code_gen == 0) { - memset(s_lazy_miss_cache, 0, sizeof(s_lazy_miss_cache)); - s_lazy_watched_code_gen = 1; - } -} - -static void rebuild_lazy_manifest_index(void) { - memset(s_exact_entry_bitmap, 0, sizeof(s_exact_entry_bitmap)); - s_lazy_man_n = 0; - s_lazy_man_overflow = 0; - s_lazy_range_link_n = 0; - for (uint32_t i = 0; i < LAZY_ENTRY_CAP; i++) { - s_lazy_entry_head[i] = -1; - s_lazy_entry_tail[i] = -1; - } - for (int i = 0; i < CACHE_IDX_CAP; i++) s_lazy_bundle_head[i] = -1; - for (uint32_t i = 0; i < RANGE_PAGE_COUNT; i++) { - s_lazy_page_head[i] = -1; - s_lazy_page_tail[i] = -1; - } - for (int ci = 0; ci < s_cache_idx_count; ci++) { - s_cache_idx[ci].func_count = 0; - s_cache_idx[ci].indexed_func_count = 0; - s_cache_idx[ci].manifest_ok = 0; - char path[800]; - snprintf(path, sizeof(path), "%s", s_cache_idx[ci].path); - size_t n = strlen(path); - if (n < OVERLAY_SHARED_EXT_LEN || - strcmp(path + n - OVERLAY_SHARED_EXT_LEN, OVERLAY_SHARED_EXT) != 0) - continue; - snprintf(path + n - OVERLAY_SHARED_EXT_LEN, - sizeof(path) - (n - OVERLAY_SHARED_EXT_LEN), ".ranges"); - int man_n = 0; - ManFn *man = parse_manifest(path, &man_n, NULL, NULL, NULL); - if (!man) continue; - s_cache_idx[ci].manifest_ok = 1; - s_cache_idx[ci].func_count = man_n; - for (int mi = 0; mi < man_n; mi++) { - if (!man[mi].has_crc || man[mi].n <= 0) continue; - uint32_t entry = man[mi].entry & 0x1FFFFFFFu; - exact_entry_set(entry); - uint32_t bucket = (entry * 2654435761u) & LAZY_ENTRY_MASK; - - if (s_lazy_man_n >= LAZY_MAN_CAP) { - s_lazy_man_overflow = 1; - break; - } - int li = s_lazy_man_n++; - LazyMan *lm = &s_lazy_man[li]; - lm->cache_idx = ci; - lm->fn = man[mi]; - lm->val_gen = 0; - lm->state = 0xFFu; - lm->next_bundle = s_lazy_bundle_head[ci]; - s_lazy_bundle_head[ci] = li; - s_cache_idx[ci].indexed_func_count++; - /* Exact-entry chains historically insert at the head, so later - * cache entries win. Keep that order distinct from range ownership - * below, which is oldest-first. */ - lm->next_entry = s_lazy_entry_head[bucket]; - s_lazy_entry_head[bucket] = li; - if (s_lazy_entry_tail[bucket] < 0) s_lazy_entry_tail[bucket] = li; - - uint8_t seen[RANGE_PAGE_COUNT / 8] = {0}; - for (int r = 0; r < lm->fn.n; r++) { - uint32_t lo = lm->fn.lo[r] & 0x1FFFFFFFu; - uint32_t hi = lo + lm->fn.len[r] - 1u; - /* Unloaded manifests must participate in the same page - * generations as registered DLL candidates. Otherwise a CPU - * copy can turn INVALID bytes into a match while both the - * LazyMan state and final-miss cache remain stale forever. */ - overlay_watch_set_range(lo, lm->fn.len[r]); - uint32_t p0 = lo >> 12, p1 = hi >> 12; - if (p0 >= RANGE_PAGE_COUNT) continue; - if (p1 >= RANGE_PAGE_COUNT) p1 = RANGE_PAGE_COUNT - 1u; - for (uint32_t p = p0; p <= p1; p++) { - uint8_t bit = (uint8_t)(1u << (p & 7u)); - if (seen[p >> 3] & bit) continue; - seen[p >> 3] |= bit; - if (s_lazy_range_link_n >= LAZY_RANGE_LINK_CAP) { - s_lazy_man_overflow = 1; - break; - } - int ri = s_lazy_range_link_n++; - s_lazy_range_links[ri].cand = li; - s_lazy_range_links[ri].next = -1; - if (s_lazy_page_head[p] < 0) s_lazy_page_head[p] = ri; - else s_lazy_range_links[s_lazy_page_tail[p]].next = ri; - s_lazy_page_tail[p] = ri; - } - if (s_lazy_man_overflow) break; - } - if (s_lazy_man_overflow) break; - } - free(man); - if (s_lazy_man_overflow) break; - } - for (int i = 0; i < s_cand_n; i++) exact_entry_set(s_cand[i].addr); -} - -static int cache_idx_has_path(const char *path) { - for (int i = 0; i < s_cache_idx_count; i++) - if (cache_path_equal(s_cache_idx[i].path, path)) return 1; - return 0; -} - -/* Cache discovery is idempotent only by full path. Logical identities may have - * multiple immutable artifacts; validation and tier-aware selection happen - * after every artifact is indexed. */ -static int cache_filename_equal(const char *a, const char *b) { -#ifdef _WIN32 - return _stricmp(a, b) == 0; -#else - return strcmp(a, b) == 0; -#endif -} - -/* A BIOS-resident shard is generated from an exact-BIOS-hash manifest and has - * a synthetic page envelope rather than a runtime dirty-run envelope. Its - * region filename therefore cannot participate in the ordinary region-start - * lookup. compile_overlays.py publishes this explicit sidecar only for those - * shards. Loading it early merely registers candidates: every dispatch still - * passes the ordinary per-function live-byte CRC guard, so a different BIOS or - * boot-time patch fails closed to the interpreter. */ -#define BIOS_RESIDENT_MARKER_SCHEMA "psxrecomp bios resident shard v1" -static int cache_path_is_bios_resident(const char *dll_path) { - char path[800]; - snprintf(path, sizeof(path), "%s", dll_path); - size_t n = strlen(path); - if (n < OVERLAY_SHARED_EXT_LEN || - strcmp(path + n - OVERLAY_SHARED_EXT_LEN, OVERLAY_SHARED_EXT) != 0) - return 0; - snprintf(path + n - OVERLAY_SHARED_EXT_LEN, - sizeof(path) - (n - OVERLAY_SHARED_EXT_LEN), ".resident"); - FILE *f = fopen(path, "rb"); - if (!f) return 0; - char buf[256] = {0}; - size_t got = fread(buf, 1, sizeof(buf) - 1, f); - fclose(f); - buf[got] = '\0'; - return strstr(buf, BIOS_RESIDENT_MARKER_SCHEMA) != NULL; -} - -static void refresh_bios_resident_flags(void) { - for (int i = 0; i < s_cache_idx_count; i++) - s_cache_idx[i].resident = - cache_path_is_bios_resident(s_cache_idx[i].path); -} - -/* Canonical cache arch-abi tag (caches are namespaced per backend AND per - * target so a Windows-x64 gcc DLL and, later, a same-OS arm64 build for the - * same fragment never comingle). compile_overlays.py - * computes the IDENTICAL string from platform.system()/machine(); keep the two - * mappings in lockstep ("-": win|linux|macos + x64|arm64|x86). */ -#if defined(_WIN32) -# define PSX_OL_OS "win" -#elif defined(__APPLE__) -# define PSX_OL_OS "macos" -#else -# define PSX_OL_OS "linux" -#endif -#if defined(__aarch64__) || defined(_M_ARM64) -# define PSX_OL_ARCH "arm64" -#elif defined(__x86_64__) || defined(_M_X64) -# define PSX_OL_ARCH "x64" -#elif defined(__i386__) || defined(_M_IX86) -# define PSX_OL_ARCH "x86" -#else -# define PSX_OL_ARCH "unknown" -#endif -#define PSX_OVERLAY_ARCH_ABI PSX_OL_OS "-" PSX_OL_ARCH - -const char *overlay_loader_arch_abi(void) { return PSX_OVERLAY_ARCH_ABI; } - -#ifndef _WIN32 -static int add_posix_cache_file(const PsxOverlayCacheFile *file, void *opaque) { - int tier = opaque ? *(const int *)opaque : CACHE_TIER_UNKNOWN; - /* Legacy 8_8 pairs were published by a replace protocol that could leave a - * new manifest beside an old DLL. Keep them on disk for Python seed - * migration, but never let them suppress or execute instead of a bound - * immutable 8_8_8 repair. */ - if (!cache_name_is_immutable(file->name)) return 0; - if (s_cache_idx_count >= CACHE_IDX_CAP) { - loader_log("*** CACHE INDEX FULL (%d): further DLLs near %s are being " - "IGNORED — their regions will run interpreted. Raise " - "CACHE_IDX_CAP.", CACHE_IDX_CAP, file->path); - return 1; - } - if (cache_idx_has_path(file->path)) return 0; - CacheEntry *e = &s_cache_idx[s_cache_idx_count++]; - e->region_start = file->region_start; - e->logical_crc = file->content_crc; - e->mtime = file->mtime; - e->func_count = 0; - e->indexed_func_count = 0; - e->tier = (uint8_t)tier; - e->manifest_ok = 0; - e->load_failed = 0; - e->resident = cache_path_is_bios_resident(file->path); - e->capacity_suppressed = 0; - snprintf(e->path, sizeof(e->path), "%s", file->path); - return 0; -} -#endif - -/* Scan one directory for _.dll cache entries into the index. - * `dir` is a full directory path. Idempotent (skips already-indexed paths). */ -static void scan_one_cache_dir(const char *dir, int tier) { -#ifdef _WIN32 - char pattern[768]; - snprintf(pattern, sizeof(pattern), "%s/*_*.dll", dir); - WIN32_FIND_DATAA fd; - HANDLE h = FindFirstFileA(pattern, &fd); - if (h == INVALID_HANDLE_VALUE) return; - do { - uint32_t addr = 0, crc = 0; - if (!psx_overlay_cache_name_parse(fd.cFileName, &addr, &crc)) continue; - if (!cache_name_is_immutable(fd.cFileName)) continue; - (void)crc; - if (s_cache_idx_count >= CACHE_IDX_CAP) { - /* Never-again (the silent-256 truncation): overflowing the index - * means real native coverage is being IGNORED. Shout once. */ - loader_log("*** CACHE INDEX FULL (%d): further DLLs in %s are being " - "IGNORED — their regions will run interpreted. Raise " - "CACHE_IDX_CAP.", CACHE_IDX_CAP, dir); - break; - } - char full[768]; - snprintf(full, sizeof(full), "%s/%s", dir, fd.cFileName); - /* Same full path means a rescan duplicate. Same logical name with a - * different artifact suffix is an additive repair and is retained. */ - if (cache_idx_has_path(full)) continue; - CacheEntry *e = &s_cache_idx[s_cache_idx_count++]; - e->region_start = addr; - e->logical_crc = crc; - e->mtime = ((uint64_t)fd.ftLastWriteTime.dwHighDateTime << 32) | - (uint64_t)fd.ftLastWriteTime.dwLowDateTime; - e->func_count = 0; - e->indexed_func_count = 0; - e->tier = (uint8_t)tier; - e->manifest_ok = 0; - e->load_failed = 0; - e->capacity_suppressed = 0; - snprintf(e->path, sizeof(e->path), "%s", full); - e->resident = cache_path_is_bios_resident(full); - } while (FindNextFileA(h, &fd)); - FindClose(h); -#else - psx_overlay_posix_scan_cache_dir(dir, add_posix_cache_file, &tier); -#endif -} - -/* Scan the namespaced gcc cache: gcc//cg/. The codegen - * version segment means a build with new emitter output reads a FRESH directory - * and never picks up a stale DLL (old versions coexist on disk, no migration). - * (Pre-1.0: no legacy fallback — older flat / unversioned caches are simply - * ignored and regenerated.) */ -/* Hardening (never-again for the silent cg-tag read≠write drift): when the loader - * finds ZERO shards under its OWN cg-tag, check whether a SIBLING cg<...> folder in - * the same tier holds shards. If so, the autocompile wrote to a different codegen - * hash than this build reads — every overlay will silently fall to the interpreter - * ("why is it slow"). This is USUALLY a mismatched overlay_autocompile_cmd - * --recompiler / --runtime-include (e.g. a cross-build: runtime from one framework - * checkout, autocompile pointed at another). Shout it once, loudly, with both tags, - * so it can never again be diagnosed as generic slowness. */ -static void warn_on_cgtag_mismatch(const char *tier) { -#ifdef _WIN32 - char base[768], pattern[900]; - snprintf(base, sizeof base, "%s/%s/%s/%s", - s_cache_dir, s_game_id, tier, PSX_OVERLAY_ARCH_ABI); - char expect[64]; - snprintf(expect, sizeof expect, "cg%d_%08x_gc%08x", - PSX_OVERLAY_CODEGEN_VER, (unsigned)PSX_OVERLAY_CODEGEN_HASH, - (unsigned)s_config_hash); - snprintf(pattern, sizeof pattern, "%s/cg*", base); - WIN32_FIND_DATAA fd; - HANDLE h = FindFirstFileA(pattern, &fd); - if (h == INVALID_HANDLE_VALUE) return; - do { - if (!(fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) continue; - if (strcmp(fd.cFileName, expect) == 0) continue; /* our own tag */ - char dllpat[900]; WIN32_FIND_DATAA fd2; - snprintf(dllpat, sizeof dllpat, "%s/%s/*_*.dll", base, fd.cFileName); - HANDLE h2 = FindFirstFileA(dllpat, &fd2); - if (h2 != INVALID_HANDLE_VALUE) { /* sibling tag HAS shards */ - FindClose(h2); - loader_log("*** OVERLAY CACHE HASH MISMATCH: this build reads %s/%s but " - "shards exist under %s/%s. The autocompile is writing to a " - "DIFFERENT codegen/config hash than this runtime reads -> ALL overlays " - "run INTERPRETED (slow). Fix overlay_autocompile_cmd's " - "--recompiler/--runtime-include to match THIS build's framework.", - tier, expect, tier, fd.cFileName); - } - } while (FindNextFileA(h, &fd)); - FindClose(h); -#else - char base[768], expect[64], found[256]; - snprintf(base, sizeof base, "%s/%s/%s/%s", - s_cache_dir, s_game_id, tier, PSX_OVERLAY_ARCH_ABI); - snprintf(expect, sizeof expect, "cg%d_%08x_gc%08x", - PSX_OVERLAY_CODEGEN_VER, (unsigned)PSX_OVERLAY_CODEGEN_HASH, - (unsigned)s_config_hash); - if (psx_overlay_posix_find_other_cache_tag(base, expect, found, - sizeof(found))) { - loader_log("*** OVERLAY CACHE HASH MISMATCH: this build reads %s/%s but " - "shards exist under %s/%s. The autocompile is writing to a " - "DIFFERENT codegen/config hash than this runtime reads -> ALL overlays " - "run INTERPRETED (slow). Fix overlay_autocompile_cmd's " - "--recompiler/--runtime-include to match THIS build's framework.", - tier, expect, tier, found); - } -#endif -} - -static void cache_idx_remove_path(const char *path) { - for (int i = 0; i < s_cache_idx_count; i++) { - if (cache_path_equal(s_cache_idx[i].path, path)) { - s_cache_idx[i] = s_cache_idx[--s_cache_idx_count]; - return; - } - } -} - -#ifndef _WIN32 -typedef struct PosixAbiSweep { - int purged; - int kept; -} PosixAbiSweep; - -static int posix_abi_sweep_file(const PsxOverlayCacheFile *file, void *opaque) { - PosixAbiSweep *sweep = (PosixAbiSweep *)opaque; - if (!cache_name_is_immutable(file->name)) return 0; - char error[256] = {0}; - void *handle = psx_overlay_posix_library_open(file->path, error, sizeof(error)); - int abi = 0; - if (handle) { - typedef int (*AbiFn)(void); - AbiFn abi_fn = (AbiFn)psx_overlay_posix_library_symbol(handle, "overlay_abi"); - abi = abi_fn ? abi_fn() : 0; - psx_overlay_posix_library_close(handle); - } - if (handle && abi == PSX_OVERLAY_ABI_TAG) { - sweep->kept++; - return 0; - } - - if (handle) - loader_log("ABI preflight rejecting %s: dll=0x%X runtime=0x%X", - file->path, abi, PSX_OVERLAY_ABI_TAG); - else - loader_log("ABI preflight rejecting unloadable %s: %s", - file->path, error); - /* Do not delete by canonical pathname after inspecting a loaded handle: - * an atomic publisher may already have replaced that path with a new file. */ - cache_idx_remove_path(file->path); - sweep->purged++; - return 0; -} -#endif - -/* ---- ABI pre-flight sweep (batch purge) ---------------------------------- - * A contract-ABI bump (e.g. v9 -> v10) invalidates EVERY cached DLL at once. - * The lazy path handled that per-dispatch: try_load_region -> LoadLibrary -> - * ABI reject -> DeleteFile -> retry next dispatch — thousands of file ops on - * the EMULATION thread (which owns the window pump). On a large cache this - * presented as a "(Not Responding)" black window for minutes (MMX6, 136-DLL - * cache, the v10 migration, 2026-07-03 regression gate). - * - * Instead, sweep at scan time: load each indexed DLL, check overlay_abi, and - * drop mismatches from the in-memory index. Never delete by canonical path - * after loading: a concurrent atomic publisher may have replaced that name. A marker - * file (.abi_.ok) per cache dir records a completed sweep, so healthy - * caches skip the sweep entirely on later boots — steady-state cost is one - * stat per dir. Autocompile only ever writes current-ABI DLLs, so the marker - * stays truthful; the per-load ABI gate in load_overlay_dll remains as - * defense in depth. */ -static void abi_preflight_sweep(const char *dir) { -#ifdef _WIN32 - char marker[900]; - snprintf(marker, sizeof marker, "%s/.abi_%08x.ok", dir, (unsigned)PSX_OVERLAY_ABI_TAG); - if (GetFileAttributesA(marker) != INVALID_FILE_ATTRIBUTES) return; /* swept */ - - /* Enumerate the DIRECTORY, not the index: the index is capacity-bounded - * and dedup-filtered, so sweeping it alone can leave stale files behind - * while the marker claims a complete sweep (found by the negative test — - * the planted v6 DLL survived behind the old 256-entry index cap). */ - int purged = 0, kept = 0; - char pattern[900]; - snprintf(pattern, sizeof pattern, "%s/*_*.dll", dir); - WIN32_FIND_DATAA fd; - HANDLE hf = FindFirstFileA(pattern, &fd); - if (hf != INVALID_HANDLE_VALUE) { - do { - if (!cache_name_is_immutable(fd.cFileName)) continue; - char full[900]; - snprintf(full, sizeof full, "%s/%s", dir, fd.cFileName); - HMODULE h = LoadLibraryA(full); - if (h) { - typedef int (*AbiFn)(void); - AbiFn abi_fn = (AbiFn)GetProcAddress(h, "overlay_abi"); - int abi = abi_fn ? abi_fn() : 0; - FreeLibrary(h); - if (abi == PSX_OVERLAY_ABI_TAG) { kept++; continue; } - } - /* Drop only the index entry. Deleting `full` here can delete a new - * shard atomically swapped in after LoadLibrary returned the old - * handle. The compiler owns on-disk replacement. */ - purged++; - for (int i = 0; i < s_cache_idx_count; i++) { - if (cache_path_equal(s_cache_idx[i].path, full)) { - s_cache_idx[i] = s_cache_idx[--s_cache_idx_count]; - break; - } - } - } while (FindNextFileA(hf, &fd)); - FindClose(hf); - } - if (purged) - loader_log("abi preflight: rejected %d stale DLL(s), kept %d in %s", - purged, kept, dir); - /* Rejected files remain until the compiler replaces them, so do not create - * a marker that would skip their in-memory rejection next boot. */ - if (!purged) { - HANDLE m = CreateFileA(marker, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, - FILE_ATTRIBUTE_NORMAL, NULL); - if (m != INVALID_HANDLE_VALUE) CloseHandle(m); - } -#else - char marker[900]; - snprintf(marker, sizeof marker, "%s/.abi_%08x.ok", dir, - (unsigned)PSX_OVERLAY_ABI_TAG); - if (access(marker, F_OK) == 0) return; - - PosixAbiSweep sweep = {0}; - psx_overlay_posix_scan_cache_dir(dir, posix_abi_sweep_file, &sweep); - if (sweep.purged) - loader_log("abi preflight: rejected %d stale DLL(s), kept %d in %s", - sweep.purged, sweep.kept, dir); - if (!sweep.purged) { - FILE *m = fopen(marker, "wb"); - if (m) fclose(m); - } -#endif -} - -static void scan_cache_dir(void) { - char dir[768]; - /* Index both tiers and every immutable artifact. Runtime selection prefers - * usable GCC over TCC; an invalid GCC artifact cannot suppress a valid TCC - * fallback merely because its filename was enumerated first. */ - snprintf(dir, sizeof(dir), "%s/%s/gcc/%s/cg%d_%08x_gc%08x", - s_cache_dir, s_game_id, PSX_OVERLAY_ARCH_ABI, PSX_OVERLAY_CODEGEN_VER, - (unsigned)PSX_OVERLAY_CODEGEN_HASH, (unsigned)s_config_hash); - scan_one_cache_dir(dir, CACHE_TIER_GCC); - abi_preflight_sweep(dir); - snprintf(dir, sizeof(dir), "%s/%s/tcc/%s/cg%d_%08x_gc%08x", - s_cache_dir, s_game_id, PSX_OVERLAY_ARCH_ABI, PSX_OVERLAY_CODEGEN_VER, - (unsigned)PSX_OVERLAY_CODEGEN_HASH, (unsigned)s_config_hash); - scan_one_cache_dir(dir, CACHE_TIER_TCC); - abi_preflight_sweep(dir); - - refresh_bios_resident_flags(); - rebuild_lazy_manifest_index(); - - /* Never-again guard: if we loaded NOTHING but wrong-hash shards exist, shout. */ - if (s_cache_idx_count == 0) { - warn_on_cgtag_mismatch("gcc"); - warn_on_cgtag_mismatch("tcc"); - } - - /* Opt-in startup/rescan inventory from PR #13. Current master deliberately - * keeps routine loader events off stderr, so retain that policy unless the - * operator explicitly requests the diagnostic. */ - const char *inventory = getenv("PSX_OVERLAY_CACHE_INVENTORY"); - if (inventory && *inventory && *inventory != '0') { - fprintf(stderr, "overlay cache scan: %d shared library file(s) indexed " - "[arch=%s]\n", s_cache_idx_count, PSX_OVERLAY_ARCH_ABI); - for (int i = 0; i < s_cache_idx_count; i++) { - const char *base = strrchr(s_cache_idx[i].path, '/'); - base = base ? base + 1 : s_cache_idx[i].path; - fprintf(stderr, " [%d] %s (region=0x%08X, functions=%d%s)\n", - i, base, s_cache_idx[i].region_start, - s_cache_idx[i].indexed_func_count, - s_cache_idx[i].resident ? ", BIOS-resident" : ""); - } - fflush(stderr); - } -} - -/* (sljit removed 2026-07-15: the persisted sljit shard cache — blob header, - * persist/publish writers, on-disk reload scan, and their debug accessors — - * lived here. No shards are produced or reloaded anymore.) */ - -static uint32_t lazy_man_crc(const ManFn *m); /* defined with lazy matching */ - -/* True if the cache holds a usable DLL for this region/logical image CRC. - * Filename identity alone is insufficient: a structurally valid but stale or - * unsafe manifest must not suppress additive recapture of its own repair. */ -int overlay_loader_has_cached_crc(uint32_t region_start, uint32_t crc) { - for (int i = 0; i < s_cache_idx_count; i++) { - if (s_cache_idx[i].manifest_ok && - !s_cache_idx[i].load_failed && - s_cache_idx[i].region_start == region_start && - s_cache_idx[i].logical_crc == crc && - s_cache_idx[i].func_count > 0 && - s_cache_idx[i].indexed_func_count == s_cache_idx[i].func_count) { - int seen = 0, usable = 1; - for (int li = s_lazy_bundle_head[i]; li >= 0; - li = s_lazy_man[li].next_bundle) { - seen++; - if (lazy_man_crc(&s_lazy_man[li].fn) != s_lazy_man[li].fn.crc || - !man_delay_slots_hashed(&s_lazy_man[li].fn)) { - usable = 0; - break; - } - } - if (usable && seen == s_cache_idx[i].func_count) return 1; - } - } - return 0; -} - -/* ---- Runtime callbacks wired into overlay DLLs via overlay_init() ------ */ - -extern void psx_dispatch_call(CPUState *cpu, uint32_t addr, uint32_t ra); -extern void psx_check_interrupts(CPUState *cpu); -extern void psx_check_interrupts_at(CPUState *cpu, uint32_t resume_pc); -extern int psx_interrupt_delivery_needed(const CPUState *cpu); -extern void gte_execute(CPUState *cpu, uint32_t cmd); -extern int psx_syscall(CPUState *cpu, uint32_t code); -extern void psx_unknown_dispatch(CPUState *cpu, uint32_t addr, uint32_t phys); -extern void debug_server_log_call_entry_fn(uint32_t func_addr); - -static OverlayCallbacks s_callbacks; - -/* Timing-hypothesis probe: native overlay code calls psx_check_interrupts at - * EVERY block (up to ~100x/function), whereas the dirty-RAM interpreter checks - * only ~every 4096 instructions + at function exit. That cadence gap can deliver - * an interrupt at a different point in native vs interp -> divergence with no - * mistranslation. When s_suppress_irq is set we drop native's per-block checks - * (cadence ~ interp). If the blue screen vanishes, the cause is interrupt timing. - * Two modes: full suppress, or rate-limit (call the real check every Nth time). - * - * NOTE: this probe rests on the cross-game finding that overlay code never - * installs its own IRQ/DMA/callback handlers (PsyQ convention — all timing- - * critical handlers live in resident static code; holds for every sampled - * title). That makes interrupt-check *cadence* the only native-vs-interp - * difference. If a future title violates the convention (an overlay installs a - * handler), this probe is no longer sufficient and a discriminator is needed. */ -static int s_suppress_irq = 0; -static uint32_t s_irq_ratelimit = 0; /* 0 = full suppress; N = every Nth call */ -static uint32_t s_irq_callcount = 0; -static uint64_t s_irq_suppressed = 0; -static uint32_t s_irq_budget_cycles = 0; /* 0 = off; otherwise guest cycles/check */ -static uint64_t s_irq_last_check_cycle = UINT64_MAX; -static int s_irq_suppress_cdrom_only = 0; -static int s_irq_post_dispatch_pump = 0; -static int s_irq_defer_cdrom = 0; - -extern uint32_t i_stat; -extern uint32_t i_mask; - -/* Nested call-unit depth (Ape memcard native<->interp resume-desync fix). - * - * When the dirty interpreter (or a shard) issues a guest jal/jalr to a callee - * run as a UNIT — an overlay-native shard (overlay_loader_call_native) OR a - * non-local dirty/kernel routine (dispatch_nonlocal_call -> psx_dispatch_call) — - * that callee must be ATOMIC w.r.t. the cooperative thread switch, exactly like - * statically-compiled code (psx_dispatch_impl checks interrupts ONLY at the - * outermost dispatch return; a nested callee never interrupts before its caller - * runs the post-call continuation). Both the overlay CI wrappers AND the dirty - * per-transfer IRQ pumps, however, checked interrupts at EVERY block/transfer - * regardless of nesting; an IRQ + cooperative ChangeThread landing inside such a - * nested unit suspended the interrupted thread with an INCONSISTENT snapshot - * (resume PC at the caller's post-call point, sp still mid-callee) -> a leaked - * stack frame that compounded across cooperative cycles into a smeared jumptable - * index (the Ape "Checking MEMORY CARD" softlock/fatal). While this depth is >0 - * both backends defer the IRQ check; the callee runs to completion, then the - * enclosing top-level dirty pump / outermost dispatch return delivers the IRQ at - * a consistent (pc, sp) boundary. The TOP-LEVEL flow (depth 0) still pumps IRQs, - * so the consumer's poll loop and long overlay game-loops keep their - * responsiveness. Incremented only when the A/B toggle is on (below). */ -int g_call_unit_depth = 0; - -/* A/B toggle for the nested-unit IRQ deferral (PSX_OVERLAY_UNIT_DEFER, default - * ON). Gates the depth INCREMENTS (so =0 leaves g_call_unit_depth at 0 and - * nothing defers — the pre-fix behavior), letting us attribute a behavior change - * to this fix without a rebuild. */ -int overlay_unit_defer_enabled(void) { - static int cached = -1; - if (cached < 0) { - const char *e = getenv("PSX_OVERLAY_UNIT_DEFER"); - cached = (e && (e[0] == '0' || e[0] == 'n' || e[0] == 'N')) ? 0 : 1; - } - return cached; -} - -void overlay_loader_set_irq_suppress(int mode, uint32_t ratelimit) { - s_suppress_irq = mode ? 1 : 0; - s_irq_ratelimit = ratelimit; - /* Reset counters on every (re)arm so an A/B run reports a clean, isolated - * suppressed-count for that arming rather than a cumulative total. */ - s_irq_callcount = 0; - s_irq_suppressed = 0; -} -void overlay_loader_get_irq_suppress(int *mode, uint32_t *rl, uint64_t *supp) { - if (mode) *mode = s_suppress_irq; - if (rl) *rl = s_irq_ratelimit; - if (supp) *supp = s_irq_suppressed; -} - -static int overlay_irq_suppressed_now(void) { - /* Differential replay (and its authoritative interpreter pass) is atomic. - * Never let a previously armed rate-limit punch a real IRQ into a shadow. */ - if (s_in_shadow) { s_irq_suppressed++; return 1; } - if (s_suppress_irq) { - if (s_irq_ratelimit == 0) { s_irq_suppressed++; return 1; } - if ((++s_irq_callcount % s_irq_ratelimit) != 0) { s_irq_suppressed++; return 1; } - } - if (s_irq_suppress_cdrom_only) { - uint32_t pending = i_stat & i_mask; - if (pending == (1u << IRQ_CDROM)) { - s_irq_suppressed++; - return 1; - } - } - if (s_irq_budget_cycles != 0) { - uint64_t now = psx_get_cycle_count(); - if (s_irq_last_check_cycle != UINT64_MAX && - now - s_irq_last_check_cycle < (uint64_t)s_irq_budget_cycles) { - s_irq_suppressed++; - return 1; - } - s_irq_last_check_cycle = now; - } - return 0; -} - -static void overlay_ci_wrapper(CPUState *cpu) { - /* Defer while inside a nested call unit — a callee must not interrupt - * mid-call (static-call atomicity). See g_call_unit_depth. */ - if (g_call_unit_depth > 0) return; - if (overlay_irq_suppressed_now()) return; - /* psx_advance_cycles() has already raised every device edge due at this - * block. Avoid entering the full scheduler/diagnostic path when COP0 could - * not take the IRQ anyway. FMV polling loops can execute this edge millions - * of times while an INTC bit is pending but IEc is deliberately clear. */ - if ((i_stat & i_mask) == 0) return; - if ((cpu->cop0[12] & ((1u << 10) | 1u)) != ((1u << 10) | 1u)) return; - if (!psx_interrupt_delivery_needed(cpu)) return; - if (s_irq_defer_cdrom && (i_stat & (1u << IRQ_CDROM))) { - uint32_t saved_cd = i_stat & (1u << IRQ_CDROM); - i_stat &= ~(1u << IRQ_CDROM); - psx_check_interrupts(cpu); - i_stat |= saved_cd; - return; - } - psx_check_interrupts(cpu); -} - -static int overlay_idle_note_is_internal_or_return(const CPUState *cpu, - uint32_t resume_pc) { - uint32_t phys = resume_pc & 0x1FFFFFFFu; - if ((resume_pc & 0x1FFFFFFFu) == (cpu->gpr[31] & 0x1FFFFFFFu)) return 1; - if (s_active_depth <= 0) return 0; - Candidate *c = &s_cand[s_active_stack[s_active_depth - 1]]; - for (int r = 0; r < c->nranges; r++) { - uint32_t lo = c->range_lo[r]; - if (phys >= lo && phys < lo + c->range_len[r]) return 1; - } - return 0; -} - -static void overlay_ci_at_wrapper(CPUState *cpu, uint32_t resume_pc) { - /* Defer while inside a nested call unit (see g_call_unit_depth): suspending - * here would save resume_pc at the callee's block leader while the enclosing - * dirty caller expects an atomic unit — the resume-desync bug. */ - if (g_call_unit_depth > 0) return; - if (overlay_irq_suppressed_now()) return; - if ((i_stat & i_mask) == 0) return; - if ((cpu->cop0[12] & ((1u << 10) | 1u)) != ((1u << 10) | 1u)) return; - if (!psx_interrupt_delivery_needed(cpu)) return; - extern int g_idle_note_suppress; - int suppress_idle_note = overlay_idle_note_is_internal_or_return(cpu, resume_pc); - if (suppress_idle_note) g_idle_note_suppress++; - if (s_irq_defer_cdrom && (i_stat & (1u << IRQ_CDROM))) { - uint32_t saved_cd = i_stat & (1u << IRQ_CDROM); - i_stat &= ~(1u << IRQ_CDROM); - psx_check_interrupts_at(cpu, resume_pc); - i_stat |= saved_cd; - if (suppress_idle_note) g_idle_note_suppress--; - return; - } - psx_check_interrupts_at(cpu, resume_pc); - if (suppress_idle_note) g_idle_note_suppress--; -} - -static int overlay_irq_budget_blocks_now(void) { - if (s_irq_budget_cycles == 0) return 0; - uint64_t now = psx_get_cycle_count(); - if (s_irq_last_check_cycle != UINT64_MAX && - now - s_irq_last_check_cycle < (uint64_t)s_irq_budget_cycles) { - s_irq_suppressed++; - return 1; - } - s_irq_last_check_cycle = now; - return 0; -} - -static void overlay_post_dispatch_irq_pump(CPUState *cpu) { - if (!s_irq_post_dispatch_pump) return; - /* Never deliver an IRQ inside a shadow run: the handler would ack device - * state (I_STAT) that survives the sandbox while the guest state it ran on - * is rolled back — a lost interrupt. Shadow runs are IRQ-suppressed by - * design (s_suppress_irq in run_shadow_diff); this pump must match. */ - if (s_in_shadow) return; - if (overlay_irq_budget_blocks_now()) return; - if (cpu->pc != 0u) psx_check_interrupts_at(cpu, cpu->pc); - else psx_check_interrupts(cpu); -} - -/* Probe wrapper (mmx6_card_load_regression_state): record shard-side calls to - * xprobe-watched targets (before + after) so overlay-DLL call-outs are visible - * in the xprobe `watched` dump the way interp JAL/JALR sites already are. */ -extern void dirty_ram_xprobe_call_note(CPUState *cpu, uint32_t target, uint32_t ra, uint8_t phase); -extern int g_exec_phase; /* wall-time sampler phase (dirty_ram_interp.c) */ -static void overlay_dispatch_call_probed(CPUState *cpu, uint32_t addr, uint32_t ra) { - dirty_ram_xprobe_call_note(cpu, addr, ra, 0); - int prev_phase = g_exec_phase; - g_exec_phase = 3; /* compiled route; dirty/native callees re-tag inside */ - psx_dispatch_call(cpu, addr, ra); - g_exec_phase = prev_phase; - dirty_ram_xprobe_call_note(cpu, addr, ra, 1); -} - -static void init_callbacks(void) { - extern void psx_restore_state_escape(void); - extern void psx_rfe_mark_escape(void); - s_callbacks.dispatch_call = overlay_dispatch_call_probed; - s_callbacks.check_interrupts = overlay_ci_wrapper; - s_callbacks.check_interrupts_at = overlay_ci_at_wrapper; - /* Address of the header inline → out-of-line copy in this TU (host side). */ - s_callbacks.advance_cycles = psx_advance_cycles; - s_callbacks.gte_execute = gte_execute; - s_callbacks.psx_syscall = psx_syscall; - s_callbacks.psx_native_bad_entry = psx_native_bad_entry; - s_callbacks.psx_unknown_dispatch = psx_unknown_dispatch; -#ifdef PSX_NO_DEBUG_TOOLS - /* Generated DLLs NULL-check this callback. Avoid a production call/return - * at every guest function entry when the callee would immediately no-op. */ - s_callbacks.log_call_entry = NULL; -#else - s_callbacks.log_call_entry = debug_server_log_call_entry_fn; -#endif - s_callbacks.psx_restore_state_escape = psx_restore_state_escape; - /* Return-from-exception mark (ABI v12): overlay `rfe` ops forward here. */ - s_callbacks.rfe_mark_escape = psx_rfe_mark_escape; - /* Call-contract state (ABI v2): DLL code shares the runtime's bail - * flag and counters through these pointers. */ - s_callbacks.call_bail_flag = &g_psx_call_bail; - s_callbacks.bail_first = &g_psx_bail_first; - s_callbacks.bail_resolved = &g_psx_bail_resolved; - /* Widescreen hooks (ABI v3): overlay-emitted psx_ws_* calls forward to - * the runtime's live widescreen state (gpu.c). */ - { - extern int psx_ws_backdrop_x(int x); - extern int psx_ws_x_margin(void); - extern void psx_ws_sprite_tag(CPUState *cpu); - extern uint32_t psx_ws_backdrop_value(uint32_t orig, int is_end, int window_cols); /* ABI v4 */ - s_callbacks.ws_backdrop_x = psx_ws_backdrop_x; - s_callbacks.ws_x_margin = psx_ws_x_margin; - s_callbacks.ws_sprite_tag = psx_ws_sprite_tag; - s_callbacks.ws_backdrop_value = psx_ws_backdrop_value; - } - /* Faithful-timing functions (ABI v9): overlay code built with - * PSX_ENABLE_BLOCK_CYCLES emits these; forward to the runtime's real impls so - * native overlays charge cycles on the SAME timeline as the interp/BIOS. */ - { - /* Word/half fast paths are static inline in psx_cyc.h; overlays get the - * out-of-line slow entry points (same guest timing, MMIO-safe). */ - extern uint32_t psx_cyc_load_word_slow(CPUState*, uint32_t, uint32_t, uint32_t); - extern uint16_t psx_cyc_load_half_slow(CPUState*, uint32_t, uint32_t, uint32_t); - extern uint8_t psx_cyc_load_byte(CPUState*, uint32_t, uint32_t, uint32_t); - extern uint32_t psx_cyc_lwc2_read(CPUState*, uint32_t); - extern void psx_icache_fetch_fn(CPUState*, uint32_t); - extern void psx_muldiv_set(CPUState*, uint32_t); - extern void psx_muldiv_stall(CPUState*); - extern uint32_t psx_mult_latency_s(uint32_t); - extern uint32_t psx_mult_latency_u(uint32_t); - extern void psx_gte_stall(CPUState*); - extern void psx_gte_read(CPUState*, uint32_t); - extern int psx_slice_block_impl(CPUState*, uint32_t, uint32_t, int); - s_callbacks.cyc_load_word = psx_cyc_load_word_slow; - s_callbacks.cyc_load_half = psx_cyc_load_half_slow; - s_callbacks.cyc_load_byte = psx_cyc_load_byte; - s_callbacks.cyc_lwc2_read = psx_cyc_lwc2_read; - s_callbacks.icache_fetch = psx_icache_fetch_fn; - s_callbacks.muldiv_set = psx_muldiv_set; - s_callbacks.muldiv_stall = psx_muldiv_stall; - s_callbacks.mult_latency_s = psx_mult_latency_s; - s_callbacks.mult_latency_u = psx_mult_latency_u; - s_callbacks.gte_stall = psx_gte_stall; - s_callbacks.gte_read = psx_gte_read; - s_callbacks.slice_block = psx_slice_block_impl; - /* ABI v10: GTE special-register accessors — the emitter emits direct - * calls for flag/IR/derived GTE regs (mfc2/cfc2/mtc2/ctc2); a GTE-heavy - * overlay DLL cannot link without these forwarded. */ - { - extern uint32_t gte_read_data(CPUState*, uint8_t); - extern uint32_t gte_read_ctrl(CPUState*, uint8_t); - extern void gte_write_data(CPUState*, uint8_t, uint32_t); - extern void gte_write_ctrl(CPUState*, uint8_t, uint32_t); - s_callbacks.gte_read_data = gte_read_data; - s_callbacks.gte_read_ctrl = gte_read_ctrl; - s_callbacks.gte_write_data = gte_write_data; - s_callbacks.gte_write_ctrl = gte_write_ctrl; - } - /* ABI v12: rfe escape marker — emitted at every `rfe`, including in - * overlay-resident exception-context code; mutates the runtime's - * shared RFE-pending state so it must forward. */ - { - extern void psx_rfe_mark_escape(void); - s_callbacks.rfe_mark_escape = psx_rfe_mark_escape; - } - /* ABI v13: typed native-wide signed player-X bound — the emitter - * rewrites configured signed_x_bound LUI sites (Einhander player - * bounds) into psx_ws_player_x_bound() calls, including in - * overlay-resident code; the clamp reads the host's live widescreen - * state (gpu.c) so the DLL must forward. */ - { - extern int32_t psx_ws_player_x_bound(int32_t vanilla); - s_callbacks.ws_player_x_bound = psx_ws_player_x_bound; - } - /* ABI v14: GTE precision-store tracker — the emitter emits a direct - * gte_precision_store_word() call for every swc2 (GTE store-word), - * including in overlay-resident code; it mutates the host's stateful - * sub-pixel projection cache (gte.cpp) so the DLL must forward. */ - { - extern void gte_precision_store_word(uint32_t addr, uint8_t reg); - s_callbacks.gte_precision_store_word = gte_precision_store_word; - } - { - extern int32_t psx_ws_depth_bound(int32_t imm); - s_callbacks.ws_depth_bound = psx_ws_depth_bound; - } - { - extern int32_t psx_ws_plane_nx(int32_t nx); - s_callbacks.ws_plane_nx = psx_ws_plane_nx; - } - { - extern uint32_t psx_ws_xclip_bound(uint32_t vanilla); - s_callbacks.ws_xclip_bound = psx_ws_xclip_bound; - } - { - extern uint32_t psx_ws_cull_keep_result(uint32_t vanilla, - uint32_t forced); - s_callbacks.ws_cull_keep_result = psx_ws_cull_keep_result; - } - { - extern uint32_t psx_ws_aspect_cone_result( - uint32_t site, uint32_t vanilla, uint32_t object, - int32_t x, int32_t z, int32_t y); - s_callbacks.ws_aspect_cone_result = - psx_ws_aspect_cone_result; - } - { - extern uint32_t psx_ws_angle_widen(uint32_t vanilla); - s_callbacks.ws_angle_widen = psx_ws_angle_widen; - } - } -} - -/* ---- DLL loading and export enumeration -------------------------------- */ - -#ifdef _WIN32 -typedef HMODULE OverlayLibraryHandle; -#else -typedef void *OverlayLibraryHandle; -#endif - -struct OverlayPreparedImage { - OverlayLibraryHandle handle; - char path[768]; -}; - -#ifdef _WIN32 -static int load_overlay_dll(const char *dll_path, ManFn *man, int man_n, int dll, - OverlayLibraryHandle prepared, - uint64_t manifest_pair_id, - int manifest_has_pair_id, - int manifest_provenance, int tier) { - HMODULE h = prepared ? prepared : LoadLibraryA(dll_path); - if (!h) { - loader_log("LoadLibrary(%s) failed: %lu", dll_path, GetLastError()); - return 0; - } - /* ABI gate: reject any DLL whose contract ABI doesn't match this runtime - * (pre-versioning DLLs lack the export entirely). The compiler, not this - * loaded handle, owns safe on-disk replacement. */ - typedef int (*AbiFn)(void); - AbiFn abi_fn = (AbiFn)GetProcAddress(h, "overlay_abi"); - int abi = abi_fn ? abi_fn() : 0; - /* Tag = ABI version (low 16) | codegen flavor (high 16). Mismatch on either - * (wrong ABI, or a different-flavor cache e.g. widescreen vs base) is - * rejected so autocompile can replace it for THIS build. */ - if (abi != PSX_OVERLAY_ABI_TAG) { - loader_log("ABI/flavor mismatch in %s: dll=0x%X runtime=0x%X — rejecting " - "without pathname deletion", dll_path, abi, - PSX_OVERLAY_ABI_TAG); - FreeLibrary(h); - return 0; - } - typedef uint64_t (*PairIdFn)(void); - PairIdFn pair_fn = (PairIdFn)GetProcAddress(h, "overlay_pair_id"); - int dll_has_pair_id = pair_fn != NULL; - uint64_t dll_pair_id = pair_fn ? pair_fn() : 0; - if (dll_has_pair_id != manifest_has_pair_id || - (dll_has_pair_id && dll_pair_id != manifest_pair_id)) { - loader_log("DLL/manifest pair mismatch in %s -- rejecting without " - "deleting (publication may be in progress)", dll_path); - FreeLibrary(h); - return 0; - } - typedef void (*InitFn)(const OverlayCallbacks *); - InitFn init_fn = (InitFn)GetProcAddress(h, "overlay_init"); - if (!init_fn) { - loader_log("no overlay_init in %s", dll_path); - FreeLibrary(h); - return 0; - } - /* Match the offline pair validator atomically: every manifest F must have - * its exact callable export before any Candidate slot is consumed. */ - for (int i = 0; i < man_n; i++) { - char name[32]; - snprintf(name, sizeof(name), "func_%08X", man[i].entry); - if (!GetProcAddress(h, name)) { - loader_log("manifest/export mismatch in %s: missing %s -- " - "rejecting whole DLL", dll_path, name); - FreeLibrary(h); - return 0; - } - } - OverlayFlushFn flush_fn = - (OverlayFlushFn)GetProcAddress(h, "overlay_flush_cycles"); - if (!flush_fn) { - loader_log("no ABI-v11 cycle flush export in %s", dll_path); - FreeLibrary(h); - return 0; - } - - /* Validate the whole physical pair before treating it as an alias. The - * canonical pair already owns its initialized handle and callbacks, so the - * redundant image must never run overlay_init or publish function pointers. */ - if (manifest_has_pair_id && loaded_pair_find( - tier, manifest_provenance, manifest_pair_id, man, man_n) >= 0) { - s_pair_aliases++; - loader_log("validated duplicate overlay pair in %s -- reusing canonical " - "candidate bundle", dll_path); - FreeLibrary(h); - return LOAD_PAIR_ALIAS; - } - if (dll < 0 || dll >= (int)(sizeof(s_dll_flush) / sizeof(s_dll_flush[0]))) { - loader_log("overlay DLL owner table full for %s", dll_path); - FreeLibrary(h); - return 0; - } - - /* Candidate publication is all-or-nothing for pair dedup authority. The - * offline publisher normally makes this impossible; fail closed if an - * externally modified cache would leave only part of a bundle resident. */ - if (man_n > CAND_CAP - s_cand_n) { - s_cand_overflow += (uint64_t)man_n; - loader_log("*** CANDIDATE TABLE SHORTFALL (%d): rejecting complete %s " - "pair (%d manifest candidates); native coverage is falling " - "back to the interpreter", CAND_CAP, dll_path, man_n); - FreeLibrary(h); - return 0; - } - - BYTE *base = (BYTE *)h; - IMAGE_DOS_HEADER *dos = (IMAGE_DOS_HEADER *)base; - IMAGE_NT_HEADERS *nt = (IMAGE_NT_HEADERS *)(base + dos->e_lfanew); - IMAGE_DATA_DIRECTORY *exp_dd = - &nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]; - if (!exp_dd->VirtualAddress) { - loader_log("no export dir in %s", dll_path); - FreeLibrary(h); - return 0; - } - init_fn(&s_callbacks); - s_dll_flush[dll] = flush_fn; - IMAGE_EXPORT_DIRECTORY *exp = - (IMAGE_EXPORT_DIRECTORY *)(base + exp_dd->VirtualAddress); - DWORD *names = (DWORD *)(base + exp->AddressOfNames); - WORD *ordinals = (WORD *)(base + exp->AddressOfNameOrdinals); - DWORD *funcs = (DWORD *)(base + exp->AddressOfFunctions); - - int registered = 0; - uint64_t overflow_before = s_cand_overflow; - for (DWORD i = 0; i < exp->NumberOfNames; i++) { - const char *name = (const char *)(base + names[i]); - if (strncmp(name, "func_", 5) != 0) continue; - if (strlen(name) != 13) continue; - uint32_t addr = (uint32_t)strtoul(name + 5, NULL, 16); - if (addr == 0) continue; - - WORD ord = ordinals[i]; - OverlayFn fn = (OverlayFn)(base + funcs[ord]); - - /* Precision-first (§1): only register a candidate when the recompiler - * gave us its code ranges. A function with no manifest entry is left to - * the interpreter rather than registered with a guessed extent. */ - ManFn *m = man_find(man, man_n, addr); - if (!m || m->n == 0) { s_no_manifest++; continue; } - registered += cand_register(addr & 0x1FFFFFFFu, fn, m, dll, tier); - } - if (registered == man_n && manifest_has_pair_id) - loaded_pair_commit(tier, manifest_provenance, - manifest_pair_id, man, man_n); - if (s_cand_overflow != overflow_before) - loader_log("*** CANDIDATE TABLE FULL (%d): loaded %s -> %d registered, " - "%llu dropped; native coverage is falling back to the " - "interpreter. Raise CAND_CAP.", CAND_CAP, dll_path, - registered, - (unsigned long long)(s_cand_overflow - overflow_before)); - else - loader_log("loaded %s -> %d candidates (%u no-manifest)", - dll_path, registered, s_no_manifest); - if (registered == 0) { - /* load_one_dll deliberately leaves a zero-candidate path retryable. Do - * not leak a LoadLibrary reference on every retry after CAND_CAP is - * exhausted (or when every export lacks a usable manifest record). */ - s_dll_flush[dll] = NULL; - FreeLibrary(h); - } - return registered; -} -#else -static int load_overlay_dll(const char *dll_path, ManFn *man, int man_n, int dll, - OverlayLibraryHandle prepared, - uint64_t manifest_pair_id, - int manifest_has_pair_id, - int manifest_provenance, int tier) { - char error[256] = {0}; - void *h = prepared ? prepared - : psx_overlay_posix_library_open(dll_path, error, - sizeof(error)); - if (!h) { loader_log("dlopen(%s) failed: %s", dll_path, error); return 0; } - /* ABI gate (see the _WIN32 branch). */ - typedef int (*AbiFn)(void); - AbiFn abi_fn = (AbiFn)psx_overlay_posix_library_symbol(h, "overlay_abi"); - int abi = abi_fn ? abi_fn() : 0; - if (abi != PSX_OVERLAY_ABI_TAG) { - loader_log("ABI/flavor mismatch in %s: dll=0x%X runtime=0x%X — rejecting " - "without pathname deletion", dll_path, abi, - PSX_OVERLAY_ABI_TAG); - psx_overlay_posix_library_close(h); - return 0; - } - typedef uint64_t (*PairIdFn)(void); - PairIdFn pair_fn = (PairIdFn)psx_overlay_posix_library_symbol( - h, "overlay_pair_id"); - int dll_has_pair_id = pair_fn != NULL; - uint64_t dll_pair_id = pair_fn ? pair_fn() : 0; - if (dll_has_pair_id != manifest_has_pair_id || - (dll_has_pair_id && dll_pair_id != manifest_pair_id)) { - loader_log("DLL/manifest pair mismatch in %s -- rejecting without " - "deleting (publication may be in progress)", dll_path); - psx_overlay_posix_library_close(h); - return 0; - } - typedef void (*InitFn)(const OverlayCallbacks *); - InitFn init_fn = (InitFn)psx_overlay_posix_library_symbol(h, "overlay_init"); - if (!init_fn) { - loader_log("no overlay_init in %s", dll_path); - psx_overlay_posix_library_close(h); - return 0; - } - for (int i = 0; i < man_n; i++) { - if (!psx_overlay_posix_library_entry(h, man[i].entry)) { - loader_log("manifest/export mismatch in %s: missing func_%08X -- " - "rejecting whole DLL", dll_path, man[i].entry); - psx_overlay_posix_library_close(h); - return 0; - } - } - OverlayFlushFn flush_fn = (OverlayFlushFn)psx_overlay_posix_library_symbol( - h, "overlay_flush_cycles"); - if (!flush_fn) { - loader_log("no ABI-v11 cycle flush export in %s", dll_path); - psx_overlay_posix_library_close(h); - return 0; - } - if (manifest_has_pair_id && loaded_pair_find( - tier, manifest_provenance, manifest_pair_id, man, man_n) >= 0) { - s_pair_aliases++; - loader_log("validated duplicate overlay pair in %s -- reusing canonical " - "candidate bundle", dll_path); - psx_overlay_posix_library_close(h); - return LOAD_PAIR_ALIAS; - } - if (dll < 0 || dll >= (int)(sizeof(s_dll_flush) / sizeof(s_dll_flush[0]))) { - loader_log("overlay DLL owner table full for %s", dll_path); - psx_overlay_posix_library_close(h); - return 0; - } - if (man_n > CAND_CAP - s_cand_n) { - s_cand_overflow += (uint64_t)man_n; - loader_log("*** CANDIDATE TABLE SHORTFALL (%d): rejecting complete %s " - "pair (%d manifest candidates); native coverage is falling " - "back to the interpreter", CAND_CAP, dll_path, man_n); - psx_overlay_posix_library_close(h); - return 0; - } - init_fn(&s_callbacks); - s_dll_flush[dll] = flush_fn; - - /* ELF/Mach-O do not expose a portable export-table walker. The range - * manifest is already the loader's authority, so resolve only its exact - * callable entries. Missing symbols stay interpreted rather than acquiring - * a guessed extent. */ - int registered = 0; - uint64_t overflow_before = s_cand_overflow; - for (int i = 0; i < man_n; i++) { - ManFn *m = &man[i]; - if (m->entry == 0 || m->n == 0) continue; - OverlayFn fn = (OverlayFn)psx_overlay_posix_library_entry(h, m->entry); - if (!fn) continue; - registered += cand_register(m->entry & 0x1FFFFFFFu, fn, m, dll, tier); - } - if (registered == man_n && manifest_has_pair_id) - loaded_pair_commit(tier, manifest_provenance, - manifest_pair_id, man, man_n); - if (s_cand_overflow != overflow_before) - loader_log("*** CANDIDATE TABLE FULL (%d): loaded %s -> %d registered, " - "%llu dropped; native coverage is falling back to the " - "interpreter. Raise CAND_CAP.", CAND_CAP, dll_path, - registered, - (unsigned long long)(s_cand_overflow - overflow_before)); - else - loader_log("loaded %s -> %d manifest candidates", dll_path, registered); - if (registered == 0) { - s_dll_flush[dll] = NULL; - psx_overlay_posix_library_close(h); - } - /* A successful handle intentionally stays open for process lifetime: the - * registered function and flush pointers refer into it. Rescans are - * idempotent through s_loaded_paths and never acquire a second reference. */ - return registered; -} -#endif - -/* ---- Public API -------------------------------------------------------- */ - -static int dll_already_loaded(const char *path); -static int load_one_dll(const char *dll_path, OverlayLibraryHandle prepared); - -static int load_bios_resident_shards(void) { - int bundles = 0, functions = 0; - for (int i = 0; i < s_cache_idx_count; i++) { - CacheEntry *e = &s_cache_idx[i]; - if (!e->resident || e->func_count <= 0 || - e->indexed_func_count != e->func_count || - e->capacity_suppressed || dll_already_loaded(e->path)) - continue; - int loaded = load_one_dll(e->path, NULL); - if (loaded > 0) { bundles++; functions += loaded; } - } - if (bundles > 0) - loader_log("preloaded %d exact-guarded BIOS resident shard(s), %d candidates", - bundles, functions); - return functions; -} - -void overlay_loader_init(const char *cache_dir, const char *game_id, - uint32_t config_hash) { - { - const char *perf = getenv("PSX_RUNTIME_PERF_DIAG"); - s_native_hot_enabled = perf && perf[0] && perf[0] != '0'; - } - for (uint32_t p = 0; p < RANGE_PAGE_COUNT; p++) { - s_range_page_head[p] = -1; - s_range_page_tail[p] = -1; - } - s_range_link_n = 0; - s_range_index_overflow = 0; - for (uint32_t i = 0; i < RANGE_PC_CACHE_CAP; i++) - s_range_pc_cache[i].cand = -1; - strncpy(s_cache_dir, cache_dir, sizeof(s_cache_dir) - 1); - strncpy(s_game_id, game_id, sizeof(s_game_id) - 1); - s_config_hash = config_hash; - s_cache_root_canon_ok = 0; /* re-resolve for the (re)assigned root */ - /* data shards persist under the same unified cache root (data_shards.c) */ - { extern void ds_init(const char*, const char*); ds_init(cache_dir, game_id); } - init_callbacks(); - scan_cache_dir(); - load_bios_resident_shards(); - overlay_image_warm_init(); - overlay_image_warm_seed_boot_text(); - /* (sljit removed 2026-07-15: the persisted-shard reload + off-thread compile - * worker were started here. Overlay misses fall to the interpreter.) */ - /* Pre-seed native-block bisection list from PSX_NATIVE_BLOCK (comma/space - * separated hex addrs). Lets us route init-time overlay functions through the - * sanctioned dirty-RAM interpreter from the FIRST boot instruction — the - * runtime overlay_native_block cmd can only be set post-boot, too late for - * once-only init functions. Same diagnostic knob, just seedable at launch. */ - { - const char *nb = getenv("PSX_NATIVE_BLOCK"); - if (nb && *nb) { - const char *p = nb; - while (*p) { - while (*p == ' ' || *p == ',' || *p == '\t') p++; - if (!*p) break; - uint32_t a = (uint32_t)strtoul(p, NULL, 0); - if (a) overlay_loader_native_block_add(a); - while (*p && *p != ' ' && *p != ',' && *p != '\t') p++; - } - loader_log("PSX_NATIVE_BLOCK seeded %d native-block entr%s from '%s'", - s_native_block_n, s_native_block_n == 1 ? "y" : "ies", nb); - } - } -#ifndef PSX_NO_DEBUG_TOOLS - { - const char *limit = getenv("PSX_NATIVE_RANK_LIMIT"); - if (limit && *limit) { - unsigned long long n = strtoull(limit, NULL, 0); - s_native_rank_limit = n >= UINT32_MAX ? UINT32_MAX - 1u : (uint32_t)n; - s_native_rank_next = 0; - s_native_rank_blocked = 0; - fprintf(stdout, - "psxrecomp: PSX_NATIVE_RANK_LIMIT=%u: " - "first-seen candidate bisection ON\n", - s_native_rank_limit); - } - } -#endif - /* Boot-time full-interp override (diagnostic): PSX_OVERLAY_NATIVE_OFF=1 forces - * native overlay execution off from the first instruction, so a pristine - * interpreter reference can be captured without racing post-boot cmds. Same - * effect as the overlay_native_off cmd, but seeded at launch. */ - { - const char *no = getenv("PSX_OVERLAY_NATIVE_OFF"); - if (no && *no && *no != '0') { - s_native_exec = 0; - loader_log("PSX_OVERLAY_NATIVE_OFF set: native overlay exec OFF from boot"); - } - } - { - const char *sup = getenv("PSX_OVERLAY_IRQ_SUPPRESS"); - const char *rl = getenv("PSX_OVERLAY_IRQ_RATELIMIT"); - if (sup && *sup && *sup != '0') { - overlay_loader_set_irq_suppress(1, 0); - loader_log("PSX_OVERLAY_IRQ_SUPPRESS set: native overlay IRQ checks suppressed from boot"); - } else if (rl && *rl) { - uint32_t n = (uint32_t)strtoul(rl, NULL, 0); - if (n == 0) n = 1; - overlay_loader_set_irq_suppress(1, n); - loader_log("PSX_OVERLAY_IRQ_RATELIMIT set: native overlay IRQ checks every %u call(s)", n); - } - } - { - const char *budget = getenv("PSX_OVERLAY_IRQ_BUDGET"); - if (budget && *budget) { - uint32_t n = (uint32_t)strtoul(budget, NULL, 0); - s_irq_budget_cycles = n; - s_irq_last_check_cycle = UINT64_MAX; - loader_log("PSX_OVERLAY_IRQ_BUDGET set: native overlay IRQ checks every %u guest cycle(s)", n); - } - } - { - const char *no_cd = getenv("PSX_OVERLAY_IRQ_NO_CDROM"); - if (no_cd && *no_cd && *no_cd != '0') { - s_irq_suppress_cdrom_only = 1; - loader_log("PSX_OVERLAY_IRQ_NO_CDROM set: native overlay CDROM-only IRQ checks suppressed"); - } - } - { - const char *defer_cd = getenv("PSX_OVERLAY_IRQ_DEFER_CDROM"); - if (defer_cd && *defer_cd && *defer_cd != '0') { - s_irq_defer_cdrom = 1; - loader_log("PSX_OVERLAY_IRQ_DEFER_CDROM set: native overlay CDROM IRQ delivery deferred"); - } - } - { - const char *post = getenv("PSX_OVERLAY_IRQ_POST_PUMP"); - if (post && *post && *post != '0') { - s_irq_post_dispatch_pump = 1; - s_irq_last_check_cycle = UINT64_MAX; - loader_log("PSX_OVERLAY_IRQ_POST_PUMP set: native overlay dispatch-return IRQ pump enabled"); - } - } - { - const char *diff = getenv("PSX_OVERLAY_DIFF"); - if (diff && *diff && *diff != '0') { - s_diff_mode = 1; - loader_log("PSX_OVERLAY_DIFF set: native/interp shadow diff ON from boot"); - } - const char *diff_addr = getenv("PSX_OVERLAY_DIFF_ADDR"); - if (diff_addr && *diff_addr) { - s_diff_addr = (uint32_t)strtoul(diff_addr, NULL, 0); - loader_log("PSX_OVERLAY_DIFF_ADDR set: shadow diff restricted to 0x%08X", - s_diff_addr); - } - } - s_active = 1; -} - -/* (sljit removed 2026-07-15: overlay_loader_apply_live_policy resolved the - * sljit live-execution policy once the Tier-2 backend was known. With the tier - * gone there is no live policy to apply; the interpreter is the overlay-miss - * floor unconditionally.) */ - -void overlay_loader_check_cache(uint32_t load_addr, uint32_t size, - const uint8_t *bytes) { - /* DLL loading is deferred to the first dispatch miss (try_load_region). */ - (void)load_addr; (void)size; (void)bytes; -} - -/* ---- Lazy region cache check (first dispatch miss for a region) -------- */ - -/* 64 -> 256 (2026-07-06): the dirty boot-text window adds per-variant regions - * below FLOOR; a full memo silently stops caching new region checks. */ -#define MAX_CHECKED 256 -static uint32_t s_checked[MAX_CHECKED]; -static int s_nchecked = 0; -static int s_last_file_found = 0; - -static int already_checked(uint32_t region_start) { - for (int i = 0; i < s_nchecked; i++) - if (s_checked[i] == region_start) return 1; - return 0; -} -static void mark_checked(uint32_t region_start) { - if (s_nchecked < MAX_CHECKED) s_checked[s_nchecked++] = region_start; -} - -/* Re-scan the cache dir for DLLs compiled after init (step 2.8 autocompile) - * and clear the checked-regions memo so the next dispatch into a window - * region reconsiders the cache. Already-loaded DLLs stay loaded; - * dll_already_loaded() makes the re-walk idempotent. */ -void overlay_loader_rescan(void) { - if (!s_active) return; - scan_cache_dir(); - load_bios_resident_shards(); - s_nchecked = 0; - lazy_miss_invalidate_loader(); -} - -/* Loaded-DLL set — the cache is ADDITIVE: a memory slot reused by several - * overlays (Tomba's village and overworld both at 0x800E7xxx) has one cached - * DLL per distinct overlay (keyed by content crc in the filename). We load - * ALL of them; each contributes its functions as separate candidates, and - * per-entry validity (the live-RAM hash) decides which candidate is callable - * at any moment. Nothing is ever clobbered — discoveries accumulate. */ -/* The warmed vault and runtime-discovered variants can exceed the old 512-DLL - * ceiling; once dll_already_loaded() lost track, a rescan could double-register - * candidates. Match the cache-index capacity used by the loader. */ -#define MAX_LOADED_DLLS 4096 -static char s_loaded_paths[MAX_LOADED_DLLS][768]; -static int s_nloaded_paths = 0; - -#ifndef PSX_NO_DEBUG_TOOLS -static int native_rank_allows(Candidate *c, uint32_t pc) { - if (s_native_rank_limit == UINT32_MAX) return 1; - if (c->native_rank == 0) { - c->native_rank = ++s_native_rank_next; - const char *path = (c->dll >= 0 && c->dll < s_nloaded_paths) - ? s_loaded_paths[c->dll] : ""; - fprintf(stdout, - "psxrecomp: native-rank rank=%u candidate=%d owner=0x%08X " - "pc=0x%08X crc=0x%08X dll=%s frame=%llu cycle=%llu allow=%d\n", - c->native_rank, (int)(c - s_cand), c->addr, pc, - c->crc_code, path, (unsigned long long)s_frame_count, - (unsigned long long)psx_get_cycle_count(), - c->native_rank <= s_native_rank_limit); - } - if (c->native_rank <= s_native_rank_limit) return 1; - s_native_rank_blocked++; - return 0; -} -#endif - -static int dll_already_loaded(const char *path) { - for (int i = 0; i < s_nloaded_paths; i++) - if (cache_path_equal(s_loaded_paths[i], path)) return 1; - return 0; -} - -static void overlay_library_close(OverlayLibraryHandle handle) { - if (!handle) return; -#ifdef _WIN32 - FreeLibrary(handle); -#else - psx_overlay_posix_library_close(handle); -#endif -} - -/* Consumes prepared on every path. A successful registration intentionally - * retains that reference for process lifetime, matching the ordinary loader. */ -static int load_one_dll(const char *dll_path, - OverlayLibraryHandle prepared) { - /* Fail CLOSED at the tracking cap, BEFORE registering anything: past this - * point dll_already_loaded() would lose track of the DLL and a later - * rescan could double-register its candidates (stale-chain execution). - * Refusing keeps the region interpreted -- same contract as CACHE_IDX_CAP. */ - if (s_nloaded_paths >= MAX_LOADED_DLLS) { - loader_log("*** LOADED-DLL TABLE FULL (%d): refusing %s -- its region " - "stays interpreted. Raise MAX_LOADED_DLLS.", - MAX_LOADED_DLLS, dll_path); - overlay_library_close(prepared); - return 0; - } - int cache_idx = -1; - int tier = cache_tier_from_path(dll_path); - for (int ci = 0; ci < s_cache_idx_count; ci++) { - if (cache_path_equal(s_cache_idx[ci].path, dll_path)) { - cache_idx = ci; - tier = s_cache_idx[ci].tier; - break; - } - } -#ifdef _WIN32 - LARGE_INTEGER q0, q1, qf; - QueryPerformanceCounter(&q0); -#endif - /* Sibling code-range manifest: {base}_{crc}.ranges next to the DLL. */ - char ranges_path[800]; - snprintf(ranges_path, sizeof(ranges_path), "%s", dll_path); - size_t plen = strlen(ranges_path); - if (plen >= OVERLAY_SHARED_EXT_LEN && - strcmp(ranges_path + plen - OVERLAY_SHARED_EXT_LEN, OVERLAY_SHARED_EXT) == 0) - snprintf(ranges_path + plen - OVERLAY_SHARED_EXT_LEN, - sizeof(ranges_path) - (plen - OVERLAY_SHARED_EXT_LEN), ".ranges"); - - int man_n = 0; - uint64_t manifest_pair_id = 0; - int manifest_has_pair_id = 0; - int manifest_provenance = MANIFEST_PROVENANCE_AUTHORITY; - ManFn *man = parse_manifest(ranges_path, &man_n, - &manifest_pair_id, - &manifest_has_pair_id, - &manifest_provenance); - if (!man || man_n == 0) { - loader_log("no/empty manifest %s — DLL left to interpreter", ranges_path); - free(man); - overlay_library_close(prepared); - return 0; - } - /* A complete known pair remains a zero-slot operation even when no new - * bundle of this size can fit. Parse and compare before durably suppressing - * the cache entry; otherwise safe aliases become unreachable at the cap. */ - int known_alias = manifest_has_pair_id && loaded_pair_find( - tier, manifest_provenance, manifest_pair_id, man, man_n) >= 0; - if (cache_idx >= 0 && !known_alias) { - int suppressed = s_cand_n >= CAND_CAP - ? cache_entry_suppress_at_capacity(cache_idx) - : cache_entry_suppress_for_shortfall(cache_idx, man_n); - if (suppressed) { - free(man); - overlay_library_close(prepared); - return 0; - } - } - int registered = load_overlay_dll(dll_path, man, man_n, s_ndlls, prepared, - manifest_pair_id, - manifest_has_pair_id, - manifest_provenance, tier); -#ifdef _WIN32 - QueryPerformanceCounter(&q1); - QueryPerformanceFrequency(&qf); - uint64_t elapsed_us = qf.QuadPart > 0 - ? (uint64_t)((q1.QuadPart - q0.QuadPart) * 1000000LL / qf.QuadPart) : 0; - s_load_last_us = elapsed_us; - s_load_total_us += elapsed_us; - if (elapsed_us > s_load_max_us) s_load_max_us = elapsed_us; -#endif - free(man); - if (registered == 0) return 0; - - /* The DLL may publish other PCs that were previously negative-cached. */ - lazy_miss_invalidate_loader(); - - /* Unconditional: the fail-closed gate at entry guarantees room, so the - * tracking table can never silently lose a loaded DLL again. */ - strncpy(s_loaded_paths[s_nloaded_paths], dll_path, 767); - s_loaded_paths[s_nloaded_paths][767] = '\0'; - s_nloaded_paths++; - if (registered > 0) s_ndlls++; - return registered; -} - -static int load_one_dll_prepared(const char *dll_path, - OverlayLibraryHandle prepared) { - /* load_one_dll performs lazy_miss_invalidate_loader(); keep this wrapper - * name for the prepared-publication contract. */ - return load_one_dll(dll_path, prepared); -} - -static int published_root_canonical(void) { - if (s_cache_root_canon_ok) return 1; - if (!overlay_path_canonicalize(s_cache_dir, s_cache_root_canon, - sizeof(s_cache_root_canon))) { - /* Not cached as a failure: the root may simply not exist yet; the - * next publication retries. Rejecting until it resolves is the - * fail-closed direction. */ - loader_log("publication rejected: cache root %s does not " - "canonicalize", s_cache_dir); - return 0; - } - return s_cache_root_canon_ok = 1; -} - -/* Validates dll_path and writes its CANONICAL form into canon; every later - * consumer (LoadLibrary, .ranges derivation, loaded-path tracking) uses the - * canonical string, never the raw pipe text. */ -static int published_path_valid(const char *dll_path, char *canon, - size_t canon_cap) { - if (!s_active || !dll_path || !dll_path[0]) return 0; - /* The compiler command is trusted local code, but keep its native-library - * output confined to the cache root the framework injected — resolved - * through the filesystem on BOTH sides, exactly as the OS loader will - * resolve it, so `..`/short-name/reparse forms cannot slip a lexical - * prefix check. */ - if (!published_root_canonical()) return 0; - if (!overlay_path_canonicalize(dll_path, canon, canon_cap)) return 0; - if (!cache_path_has_root(canon, s_cache_root_canon)) return 0; - const char *base = strrchr(canon, '/'); - const char *win_base = strrchr(canon, '\\'); - if (!base || (win_base && win_base > base)) base = win_base; - base = base ? base + 1 : canon; - uint32_t addr = 0, crc = 0; - if (!psx_overlay_cache_name_parse(base, &addr, &crc)) return 0; - if (!cache_name_is_immutable(base)) return 0; - (void)addr; - (void)crc; - return 1; -} - -OverlayPreparedImage *overlay_loader_prepare_published(const char *dll_path) { - char canon[768]; - if (!published_path_valid(dll_path, canon, sizeof(canon))) return NULL; - OverlayPreparedImage *image = - (OverlayPreparedImage *)calloc(1, sizeof(*image)); - if (!image) return NULL; -#ifdef _WIN32 - /* Guard handle: GENERIC_READ with only READ shared blocks rename/replace/ - * delete of the validated file until LoadLibrary has mapped it, and the - * canonical identity is re-derived from the object actually held — so a - * swap between validation and mapping cannot survive the re-check. - * (Anything already writable inside the cache root itself is outside this - * boundary by design; see the threat-model note above cand_register.) */ - HANDLE guard = CreateFileA(canon, GENERIC_READ, FILE_SHARE_READ, NULL, - OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); - if (guard == INVALID_HANDLE_VALUE) { - free(image); - return NULL; - } - if (!overlay_path_canonicalize_handle(guard, canon, sizeof(canon)) || - !cache_path_has_root(canon, s_cache_root_canon)) { - CloseHandle(guard); - free(image); - return NULL; - } - snprintf(image->path, sizeof(image->path), "%s", canon); - image->handle = LoadLibraryA(canon); - CloseHandle(guard); -#else - snprintf(image->path, sizeof(image->path), "%s", canon); - char error[256] = {0}; - image->handle = psx_overlay_posix_library_open(canon, error, - sizeof(error)); -#endif - if (!image->handle) { - free(image); - return NULL; - } - return image; -} - -int overlay_loader_commit_published(OverlayPreparedImage *image) { - if (!image) return 0; - OverlayLibraryHandle handle = image->handle; - image->handle = NULL; - int loaded = 0; - char canon[768]; - /* image->path is already canonical (prepare stored the handle-derived - * form); re-validating is an idempotent freshness check. */ - if (published_path_valid(image->path, canon, sizeof(canon)) && - !dll_already_loaded(canon)) - loaded = load_one_dll_prepared(canon, handle); - else - overlay_library_close(handle); - free(image); - return loaded; -} - -void overlay_loader_discard_prepared(OverlayPreparedImage *image) { - if (!image) return; - overlay_library_close(image->handle); - image->handle = NULL; - free(image); -} - -static int lazy_man_contains(const ManFn *m, uint32_t phys) { - if ((m->entry & 0x1FFFFFFFu) == phys) return 1; - for (int r = 0; r < m->n; r++) { - uint32_t lo = m->lo[r] & 0x1FFFFFFFu; - if (phys >= lo && phys < lo + m->len[r]) return 1; - } - return 0; -} - -static uint32_t lazy_man_crc(const ManFn *m) { - const uint8_t *ram = memory_get_ram_ptr(); - uint32_t crc = 0xFFFFFFFFu; - for (int r = 0; r < m->n; r++) - crc = crc32_update(crc, ram + (m->lo[r] & 0x1FFFFFFFu), m->len[r]); - return crc ^ 0xFFFFFFFFu; -} - -static uint32_t lazy_man_gensum(const ManFn *m) { - uint32_t sum = 0; - for (int r = 0; r < m->n; r++) - sum += overlay_watch_pagegen_sum(m->lo[r] & 0x1FFFFFFFu, m->len[r]); - return sum; -} - -static int lazy_man_matches(LazyMan *lm) { - if (!man_structurally_valid(&lm->fn)) { - lm->state = ENTRY_INVALID; - return 0; - } - uint32_t gen = lazy_man_gensum(&lm->fn); - if (lm->state == ENTRY_VALID && lm->val_gen == gen) return 1; - if (lm->state == ENTRY_INVALID && lm->val_gen == gen) return 0; - uint32_t live = lazy_man_crc(&lm->fn); - lm->val_gen = gen; - s_last_crc = live; - lm->state = (live == lm->fn.crc && man_delay_slots_hashed(&lm->fn)) - ? ENTRY_VALID : ENTRY_INVALID; - return lm->state == ENTRY_VALID; -} - -/* A CPS DLL can directly own internal tail targets, so one matching entry does - * not prove the rest of that captured snapshot is current. Validate every - * manifested function before preferring a broad bundle. This permits one - * coherent snapshot to replace many synchronous incremental LoadLibrary calls - * at a stage transition without substituting a stale CPS owner. */ -static int lazy_bundle_matches(int ci) { - if (ci < 0 || ci >= s_cache_idx_count || ci >= CACHE_IDX_CAP) return 0; - if (s_cache_idx[ci].func_count <= 0 || - s_cache_idx[ci].indexed_func_count != s_cache_idx[ci].func_count) - return 0; - int seen = 0; - for (int li = s_lazy_bundle_head[ci]; li >= 0; - li = s_lazy_man[li].next_bundle) { - seen++; - if (!lazy_man_matches(&s_lazy_man[li])) return 0; - } - return seen == s_cache_idx[ci].func_count; -} - -static int lazy_candidate_preferred(int li, int current) { - if (current < 0) return 1; - const CacheEntry *a = &s_cache_idx[s_lazy_man[li].cache_idx]; - const CacheEntry *b = &s_cache_idx[s_lazy_man[current].cache_idx]; - if (a->tier != b->tier) return a->tier > b->tier; - if (a->mtime != b->mtime) return a->mtime > b->mtime; - return strcmp(a->path, b->path) > 0; -} - -/* Prefer GCC over TCC even when the TCC option happens to be a broader bundle. - * Within one tier, a fully coherent bundle remains stronger than a partial - * region-local function match. */ -static int lazy_choose_complete_or_fallback(int complete, int fallback) { - if (complete < 0) return fallback; - if (fallback < 0) return complete; - const CacheEntry *a = &s_cache_idx[s_lazy_man[complete].cache_idx]; - const CacheEntry *b = &s_cache_idx[s_lazy_man[fallback].cache_idx]; - if (a->tier != b->tier) return a->tier > b->tier ? complete : fallback; - return complete; -} - -/* A streamed variant is not byte-coherent until immediately before its first - * call, too late to hide Windows' first image-map cost. Map every small bundle - * for this ONE base after boot. Tomba's mature vault has 141 here versus 712 in - * the rejected whole-cache preload. Mapping never registers a candidate; the - * normal manifest/live-byte gate still decides whether it can execute. */ -static void overlay_image_warm_seed_boot_text(void) { - int indices[CACHE_IDX_CAP]; - int count = 0; - for (int ci = 0; ci < s_cache_idx_count; ci++) { - if (s_cache_idx[ci].region_start != DIRTY_RAM_KERNEL_WINDOW_END || - s_cache_idx[ci].capacity_suppressed || - s_cache_idx[ci].func_count <= 0 || - s_cache_idx[ci].func_count > 8) - continue; - indices[count++] = ci; - } - overlay_image_warm_queue(indices, count); -} - -static int lazy_is_loadable(int li, uint32_t region_start, uint32_t phys, - int require_region_start) { - if (li < 0 || li >= s_lazy_man_n) return 0; - LazyMan *lm = &s_lazy_man[li]; - int ci = lm->cache_idx; - if (ci < 0 || ci >= s_cache_idx_count) return 0; - return (!require_region_start || - s_cache_idx[ci].region_start == region_start) && - !s_cache_idx[ci].load_failed && - !s_cache_idx[ci].capacity_suppressed && - !dll_already_loaded(s_cache_idx[ci].path) && - lazy_man_contains(&lm->fn, phys) && lazy_man_matches(lm); -} - -static int lazy_better_bundle(int li, int best) { - (void)li; - /* Candidate order is semantic for continuation-passing bundles: two DLLs - * can contain a byte-identical entry while resolving its internal tail - * targets against different region snapshots. Preserve the manifest index's - * established order and publish only its first live match. Comparing bundle - * size or timestamp can silently substitute an incompatible owner. */ - return best < 0; -} - -static int lazy_load_selected(int li) { - if (li < 0 || li >= s_lazy_man_n) return 0; - int ci = s_lazy_man[li].cache_idx; - if (ci < 0 || ci >= s_cache_idx_count || ci >= CACHE_IDX_CAP || - s_cache_idx[ci].load_failed || - s_cache_idx[ci].capacity_suppressed || - dll_already_loaded(s_cache_idx[ci].path) || - s_cache_idx[ci].func_count <= 0) return 0; - /* If proactive warming has not reached this fragment yet, prefer the - * historical synchronous path over running a potentially hot function in - * the interpreter. The worker drops its speculative reference safely. */ - overlay_image_warm_cancel(ci); - s_last_file_found = 1; - int loaded = load_one_dll(s_cache_idx[ci].path, NULL) > 0; - if (!loaded) s_cache_idx[ci].load_failed = 1; - overlay_image_warm_release(ci); - return loaded; -} - -static int try_load_region(uint32_t phys) { - extern uint32_t dirty_ram_get_bitmap_word(uint32_t word_index); - - uint32_t page_sz = 4096u; - - /* Walk back over the contiguous dirty run to recover region_start — cache - * DLLs are keyed by this start address in their filename. - * - * CRITICAL: the capture clamps each region to its WINDOW — kernel - * [0, KERNEL_WINDOW_END), dirty boot-text [KERNEL_WINDOW_END, FLOOR), or - * overlay [FLOOR, end) — so a DLL's region_start is the first dirty page - * AT OR ABOVE the window floor. The walkback must apply the SAME clamp, - * chosen by PAGE (the capture windows are page-granular and FLOOR need - * not be page-aligned): an overlay-region walk stopping anywhere below - * FLOOR's page, or a boot-text walk crossing into the kernel window, - * yields a region_start that NO DLL filename matches — the overlay's - * DLLs never load and its functions interpret forever. */ - uint32_t kern_pg = DIRTY_RAM_KERNEL_WINDOW_END / page_sz; - uint32_t ovl_pg = OVERLAY_REGION_FLOOR / page_sz; - uint32_t pg = phys / page_sz; - uint32_t floor_pg = (pg < kern_pg) ? 0u - : (pg < ovl_pg) ? kern_pg - : ovl_pg; - while (pg > floor_pg) { - uint32_t pp = pg - 1; - if (!((dirty_ram_get_bitmap_word(pp >> 5) >> (pp & 31u)) & 1u)) break; - pg = pp; - } - uint32_t region_start = pg * page_sz; - - if (!already_checked(region_start)) mark_checked(region_start); - - int select_attempts = 0; -retry_artifact: - ; - /* Prefer the broadest fully coherent snapshot. If none has every function - * live, retain the historical first matching-function fallback. */ - int best = -1; - int fallback = -1; - uint32_t bucket = (phys * 2654435761u) & LAZY_ENTRY_MASK; - for (int li = s_lazy_entry_head[bucket]; li >= 0; - li = s_lazy_man[li].next_entry) { - if ((s_lazy_man[li].fn.entry & 0x1FFFFFFFu) != phys) continue; - /* This function is reached only for a real runtime dispatch at phys - * (external entry or CPS continuation). The manifest supplies exact - * code identity, and cross-region recovery below additionally requires - * every directly-owned function in the CPS bundle to match. The dirty - * bitmap is sticky across streamed - * variants, so walkback can recover an older adjacent run's base - * (e.g. 0x106000 instead of an exact live 0x108000 shard). Rejecting - * the exact entry on that heuristic strands valid DLLs forever. */ - if (!lazy_is_loadable(li, region_start, phys, 0)) continue; - int ci = s_lazy_man[li].cache_idx; - /* Cross-region recovery is safe only for a fully coherent CPS bundle. - * A partial exact-function match can have snapshot-specific internal - * tails, so retain it as fallback only when the heuristic base agrees. */ - if (s_cache_idx[ci].region_start == region_start && - lazy_candidate_preferred(li, fallback)) - fallback = li; - /* Entry chains are newest-first/semantic. Preserve their established - * order instead of substituting a larger historical bundle. */ - if (lazy_bundle_matches(ci) && lazy_candidate_preferred(li, best)) - best = li; - } - - uint32_t page = phys >> 12; - /* An exact live manifest is sufficient and semantically stronger than an - * enclosing range. Scanning every range link after finding one revalidated - * hundreds of broad historical bundles at streamed-stage transitions. */ - if (fallback < 0 && page < RANGE_PAGE_COUNT) { - for (int ri = s_lazy_page_head[page]; ri >= 0; - ri = s_lazy_range_links[ri].next) { - int li = s_lazy_range_links[ri].cand; - if ((s_lazy_man[li].fn.entry & 0x1FFFFFFFu) == phys) continue; - /* Non-exact range ownership remains region-narrowed: unlike an - * exact manifested entry, an interior PC alone is not a unique - * identity for a reused-address CPS bundle. */ - if (!lazy_is_loadable(li, region_start, phys, 1)) continue; - if (lazy_candidate_preferred(li, fallback)) fallback = li; - int ci = s_lazy_man[li].cache_idx; - if (lazy_bundle_matches(ci) && lazy_candidate_preferred(li, best)) - best = li; - } - } - int selected = lazy_choose_complete_or_fallback(best, fallback); - if (selected < 0) return 0; - if (lazy_load_selected(selected)) return 1; - /* An unloadable/corrupt GCC artifact must not suppress an older immutable - * repair or the TCC fallback. lazy_load_selected marks the failed cache - * entry, so a bounded re-selection chooses the next live candidate. */ - if (++select_attempts < s_cache_idx_count) goto retry_artifact; - return 0; -} - -/* O(1)-bucket discriminator between a real manifested function entry and an - * interior CPS continuation. The latter must use its already-loaded range owner - * first (the Whoopee 0x107624 fix); the former must be allowed to publish its - * exact DLL even when a broader conservative owner contains the same PC. */ -static int lazy_has_exact_entry(uint32_t phys) { - /* Same overlay-off hazard as overlay_find_by_range: the lazy entry index - * (s_lazy_entry_head) is -1-initialized only when overlay_cache is enabled; - * with overlay off it is zero-initialized and the link traversal below - * spins forever. Fail closed (no lazy entry) when the loader is inactive. */ - if (!s_active) return 0; - uint32_t bucket = (phys * 2654435761u) & LAZY_ENTRY_MASK; - for (int li = s_lazy_entry_head[bucket]; li >= 0; - li = s_lazy_man[li].next_entry) { - if ((s_lazy_man[li].fn.entry & 0x1FFFFFFFu) == phys) - return 1; - } - return 0; -} - -/* ---- Dispatch ---------------------------------------------------------- */ - -/* CPS (§25): find a non-blacklisted candidate whose code range CONTAINS phys — - * i.e. phys is a continuation / return point INSIDE an overlay function, not its - * registered entry. Under continuation-passing, an overlay function tail- - * transfers after each block, so a callee returns to a mid-function address that - * idx_head() (entry-keyed) misses; we re-enter the owning function with - * cpu->pc = that address so its entry-switch routes to the right block. Returns - * the candidate index, or -1. */ -static int range_candidate_matches(int i, uint32_t phys) { - Candidate *c = &s_cand[i]; - if (c->state == ENTRY_BLACKLIST) return 0; - int contains = 0; - uint32_t first_range_lo = UINT32_MAX; - for (int r = 0; r < c->nranges; r++) { - uint32_t lo = c->range_lo[r]; - if (lo < first_range_lo) first_range_lo = lo; - if (phys >= lo && phys < lo + c->range_len[r]) contains = 1; - } - /* Interior hosted aliases intentionally share their canonical host's full - * CRC/range identity, but their generated wrapper may contain only the - * blocks reachable from the alias. They are valid exact dispatch entries, - * never authoritative owners for an arbitrary CPS continuation elsewhere - * in the host range. Prefer the rooted host; if it is unavailable, fail - * closed to the interpreter instead of restarting through an alias. */ - if (!contains || c->addr != first_range_lo) return 0; - - /* A reused address can have several range-owning variants. Select the - * one whose compiled code bytes match live RAM instead of returning the - * first range hit and letting one stale variant mask every later match. */ - uint32_t gen = cand_gensum(c); - if (c->state == ENTRY_VALID && gen == c->val_gen) { - s_gen_fastpath++; - return 1; - } - if (c->state == ENTRY_INVALID && gen == c->val_gen) - return 0; /* known mismatch, no watched write */ - uint32_t live = cand_crc(c); - s_rehashes++; - s_last_crc = live; - c->val_gen = gen; - if (live == c->crc_code && cand_delay_slots_hashed(c)) { - if (c->state != ENTRY_VALID) { - c->state = ENTRY_VALID; - s_valid_count++; - } - return 1; - } - s_rehash_miss++; - if (c->state == ENTRY_VALID) { - c->state = ENTRY_INVALID; - s_invalidations++; - if (s_valid_count > 0) s_valid_count--; - } else { - c->state = ENTRY_INVALID; - } - s_stale_blocked++; - return 0; -} - -static int range_candidate_preferred(int candidate, int current) { - if (current < 0) return 1; - if (s_cand[candidate].tier != s_cand[current].tier) - return s_cand[candidate].tier > s_cand[current].tier; - return candidate > current; /* newest same-tier repair */ -} - -static int overlay_find_by_range(uint32_t phys) { - /* Definitive guard for the overlay-off case: the range page index - * (s_range_page_head) is only initialized to -1 by overlay_loader_init, - * which runs solely when overlay_cache is enabled. With overlay off it is - * zero-initialized (0 = a valid index, not the -1 sentinel), so the link - * traversal below never terminates. Fail closed (no candidate) when the - * loader is inactive — there are no candidates to find anyway. */ - if (!s_active) return -1; - uint32_t page = phys >> 12; - if (page >= RANGE_PAGE_COUNT) return -1; - - RangePcCache *pc = &s_range_pc_cache[ - (phys * 2654435761u) & RANGE_PC_CACHE_MASK]; - if (pc->generation == s_range_candidate_generation && - pc->cand >= 0 && pc->phys == phys && - range_candidate_matches(pc->cand, phys)) - return pc->cand; - - int best = -1; - if (s_range_index_overflow) { - for (int i = 0; i < s_cand_n; i++) - if (range_candidate_matches(i, phys) && - range_candidate_preferred(i, best)) - best = i; - } else { - for (int li = s_range_page_head[page]; li >= 0; - li = s_range_links[li].next) { - int i = s_range_links[li].cand; - if (range_candidate_matches(i, phys) && - range_candidate_preferred(i, best)) - best = i; - } - } - pc->phys = phys; - pc->generation = s_range_candidate_generation; - pc->cand = best; - return best; -} - -int overlay_loader_dispatch(CPUState *cpu, uint32_t addr) { - uint32_t phys = addr & 0x1FFFFFFFu; - /* Overlay dispatch is a no-op when the overlay loader is inactive - * (overlay_cache=false): there are no candidates to match, so this must - * return 0 (dispatch to interp). This guard is also a HARD SAFETY: - * overlay_loader_init() — the ONLY place the range index s_range_page_head[] - * is initialized to -1 (empty) — runs only when overlay_cache is enabled - * (main.cpp). With overlay off, that array stays zero-initialized (0 is a - * VALID index, not the -1 sentinel), so the CPS continuation range lookup - * below (gated on g_psx_cps_mode, independent of s_active) would traverse a - * garbage index: s_range_page_head[page]=0 -> s_range_links[0].next=0 -> the - * loop never terminates (infinite spin in range_candidate_matches). Every - * overlay-off + CPS game hit this and wedged at boot before any game code ran - * (found via Ape Escape, the only overlay-off title). Fail closed here. */ - if (!s_active) return 0; - if (overlay_cache_window_contains(phys) && lazy_miss_cached(phys)) { - s_disp_interp++; - return 0; - } - int lazy_loaded = 0; -retry_candidates: ; - int head = idx_head(phys); - int loaded_range_ci = -1; - int lazy_exact = 0; - int exact_needs_load = 0; - if (head < 0 && s_active && overlay_cache_window_contains(phys)) { - lazy_exact = head < 0 && lazy_has_exact_entry(phys); - /* A CPS continuation is normally not a registered function ENTRY. Its - * already-loaded range owner must be checked before lazy discovery: - * try_load_region otherwise scans the 12K+ manifest index and may load - * another duplicate bundle on every hot continuation edge. Whoopee's - * 0x80107624 path paid that cost thousands of times per frame. Exact - * manifested entries are different: a broader range owner must not mask - * their valid cached DLL (Tomba FMV's 0x80106424/0x80106688 case). */ - if (head < 0 && g_psx_cps_mode) - loaded_range_ci = overlay_find_by_range(phys); - exact_needs_load = lazy_exact && - (loaded_range_ci < 0 || s_cand[loaded_range_ci].device_touch); - if (head < 0 && (loaded_range_ci < 0 || exact_needs_load) && - !lazy_loaded && try_load_region(phys)) { - lazy_loaded = 1; - goto retry_candidates; - } - /* (sljit removed 2026-07-15: the JIT-on-miss gap-fill — off-thread - * enqueue or synchronous compile — ran here. Misses fall to interp.) */ - } - - /* CPS (§25) continuation re-entry: phys is not an overlay ENTRY, but it may - * be a return point inside an already-registered overlay function (the - * caller's continuation after a tail-transferred call). Re-enter that - * function natively with cpu->pc = addr so its entry-switch routes to the - * continuation block — without this the continuation falls to the interp and - * native coverage stops at the first call. gcc-DLL candidates are the trusted - * tier (diff-validated at their entry; the continuation is the same DLL code), - * so they run native directly here; device-touch funcs still go to interp. */ - if (head < 0 && g_psx_cps_mode) { - int ci = loaded_range_ci >= 0 ? loaded_range_ci - : overlay_find_by_range(phys); - int _probe = (s_cps_probe_pc && phys == s_cps_probe_pc); - if (_probe) { - s_cps_probe_count++; - s_cps_probe_ci = ci; - s_cps_probe_ncand_inrange = overlay_count_by_range(phys); - s_cps_probe_found = (ci >= 0) ? s_cand[ci].addr : 0; - s_cps_probe_nrange = (ci >= 0) ? s_cand[ci].nranges : -1; - s_cps_probe_matched = -1; - s_cps_probe_outcome = (ci < 0) ? 0 : -1; - } - if (ci >= 0) { - Candidate *c = &s_cand[ci]; - /* Generation-gated validation (overlay-cache v2 P2) — same contract - * as the main chain: skip the crc32 when no watched code page changed - * since this entry was last confirmed VALID. */ - uint32_t gen = cand_gensum(c); - int matched; - if (c->state == ENTRY_VALID && gen == c->val_gen) { - matched = 1; - s_gen_fastpath++; - } else { - uint32_t live = cand_crc(c); - s_rehashes++; - s_last_crc = live; - c->val_gen = gen; - matched = (live == c->crc_code && cand_delay_slots_hashed(c)); - } - if (_probe) s_cps_probe_matched = matched; - if (matched) { - if (c->state != ENTRY_VALID) { c->state = ENTRY_VALID; s_valid_count++; } - if (c->device_touch) { if (_probe) s_cps_probe_outcome = 3; s_disp_interp++; return 0; } - /* Diff instrument — same contract as the entry chain's want_diff - * gate below. A continuation re-entry must NOT run native blind - * while its candidate is still inside the verify budget: CPS - * interiors dominate dispatch, so without this gate ~all native - * execution bypassed the diff harness (shadow_calls=1 over 51K - * frames while gen_fastpath took ~600M hits). Interiors are - * validated transitively: the entry-level shadow diff runs the - * WHOLE function natively (in_own_shadow below), so until the - * candidate passes its budget its interiors take the interpreter - * (the authority). Inside the candidate's OWN shadow native pass - * interiors DO run native — that IS the diff exercising the - * continuation blocks; the blocklist is not consulted there, - * matching the entry gate (which shadow-diffs blocked candidates - * too — the blocklist blocks LIVE native, not the sandbox). */ - int in_own_shadow = (s_in_shadow && (const void *)c == s_shadow_cand); - if (!in_own_shadow) { - int want_diff = s_diff_mode && cand_selected_for_diff(c); - if (want_diff && addr < 0x10000u) want_diff = 0; - if (want_diff && (s_diff_addr || c->diff_passes < OVERLAY_DIFF_BUDGET)) { - if (_probe) s_cps_probe_outcome = 5; - s_diffgate_interp++; - s_disp_interp++; - return 0; - } - if (!s_native_exec || overlay_native_blocked(c->addr) || overlay_native_blocked(addr)) - { if (_probe) s_cps_probe_outcome = 4; s_would_run_native++; s_disp_interp++; return 0; } -#ifndef PSX_NO_DEBUG_TOOLS - if (!native_rank_allows(c, addr)) - { if (_probe) s_cps_probe_outcome = 7; s_would_run_native++; s_disp_interp++; return 0; } -#endif - } - if (_probe) s_cps_probe_outcome = 2; - /* Native-call ring: ALWAYS-ON (production too). Each entry names - * the requested PC and the candidate CRC that claimed it — the - * only record that can attribute a wrong-variant native run on - * a production binary (Tomba 2 splash reload loop). */ - uint32_t slot = s_nring_pos++ & (NRING_CAP - 1u); - s_nring[slot].addr = addr; - s_nring[slot].crc = c->crc_code; - s_nring[slot].frame = (uint32_t)s_frame_count; - s_nring[slot].seq = ++s_nring_seq; - s_nring[slot].returned = 0; - uint32_t prev_inprogress = s_native_inprogress; - s_native_inprogress = c->addr; - s_native_calls_total++; - native_hot_note(c->addr); - if (s_active_depth < (int)(sizeof(s_active_stack) / sizeof(s_active_stack[0]))) - s_active_stack[s_active_depth++] = ci; - s_disp_native++; - cpu->pc = addr; /* route the func's entry-switch to the block */ - { - int prev_phase = g_exec_phase; - OverlayFlushFn prev_flush = overlay_flush_enter(c); - g_exec_phase = 2; - c->fn(cpu); - overlay_flush_leave(prev_flush); - g_exec_phase = prev_phase; - } - overlay_post_dispatch_irq_pump(cpu); - if (s_active_depth > 0) s_active_depth--; - s_nring[slot].returned = 1; - s_native_inprogress = prev_inprogress; - if (g_native_bad_entry) { /* foreign interior entry: fail closed to interp */ - g_native_bad_entry = 0; - s_disp_native--; s_disp_interp++; - return 0; /* cpu->pc was restored to the requested PC */ - } - return 1; - } - if (_probe) s_cps_probe_outcome = 1; - /* stale code bytes: fall through to the interpreter */ - } - } - - for (int i = head; i >= 0; i = s_cand[i].next) { - Candidate *c = &s_cand[i]; - if (c->state == ENTRY_BLACKLIST) continue; - - /* Generation-gated validation (overlay-cache v2 P2). The ONLY way this - * entry's compiled code bytes can change is a write to one of its watched - * code pages — and every CPU/DMA store funnels through the single store - * chokepoint, which bumps overlay_page_gen for watched pages (memory.c). - * So if the page-generation sum is unchanged since we last confirmed this - * entry VALID, the bytes are PROVABLY unchanged and we run native without - * re-hashing. We fall back to the full crc32 only when the generation - * moved (or the entry isn't known-valid) — which also covers - * reload-on-return: a write-away-then-back bumps the gen, forcing a - * re-hash that re-confirms the byte-exact match before any native call. - * This removes the per-dispatch crc32 of the whole function body from the - * hot path for stable code (the warm-cache common case). Correctness is - * identical to the old unconditional hash; only redundant hashing is cut. */ - uint32_t gen = cand_gensum(c); - int matched; - if (c->state == ENTRY_VALID && gen == c->val_gen) { - matched = 1; /* no watched write since validation */ - s_gen_fastpath++; - } else if (c->state == ENTRY_INVALID && gen == c->val_gen) { - /* This exact byte identity already failed after the most recent - * watched write. Re-hashing it on every dispatch makes reused - * variant chains scale with cache history instead of live code. */ - continue; - } else { - uint32_t live = cand_crc(c); - s_rehashes++; - s_last_crc = live; - c->val_gen = gen; - matched = (live == c->crc_code && cand_delay_slots_hashed(c)); - } - if (matched) { - if (c->state != ENTRY_VALID) { - c->state = ENTRY_VALID; - s_revalidations++; /* reload-on-return */ - s_valid_count++; - } - /* Device-touching functions never run their shard: the shadow diff - * can't safely double-execute MMIO/SIO/DMA to validate them, so they - * always fall to the interpreter (the authoritative single path). */ - if (c->device_touch) { s_disp_interp++; return 0; } - /* Same-state differential: run native+interp from identical state, - * compare, keep the interp result. Takes precedence over the A/B - * toggle. Verify-budget: once a candidate has passed cleanly enough - * times it's trusted and falls through to normal execution, so the - * diff cost stays bounded (a diverging candidate never reaches the - * budget — it keeps being diff-gated and never runs native live). - * gcc/DLL candidates are the trusted tier (validated at dev time) and - * run native directly; they are diffed only in explicit dev diff mode - * (PSX_OVERLAY_DIFF / overlay_diff cmd). */ - int want_diff = s_diff_mode && cand_selected_for_diff(c); - /* Kernel-window candidates (call gates 0xA0/0xB0/0xC0 and the RAM - * kernel) are NOT shadow-diffable: the gates tail-jump via RAM - * tables into kernel SERVICES whose behavior depends on scheduler/ - * event state and can block (TestEvent/WaitEvent) — the two passes - * legitimately run different instruction paths and every call - * "diverges" (observed: 2016/2017 calls, all 0xB0, flooding the - * ring). The diff harness validates OVERLAY shard codegen against - * the interp oracle; kernel gates stay on their normal route. */ - if (want_diff && addr < 0x10000u) want_diff = 0; - if (want_diff && !s_in_shadow && - (s_diff_addr || c->diff_passes < OVERLAY_DIFF_BUDGET)) { - /* NEVER start a shadow inside an exception dispatch: the guest's - * ReturnFromException longjmps to the setjmp frame BELOW us - * (psx_check_interrupts), unwinding run_shadow_diff WITHOUT its - * epilogue — s_in_shadow/s_native_exec/s_suppress_irq stay stuck - * and the diff instrument is dead for the rest of the run - * (observed: shadow_calls=1 over 51K frames). Exceptions ENTERED - * during a shadow are contained — their setjmp is armed inside - * the shadow frame — so gating the START is structurally - * sufficient (every psx_exception_longjmp site is guarded by - * psx_get_in_exception()). The candidate still must not run - * native unvalidated: route to the interpreter (the authority); - * it gets diffed at its next non-exception dispatch. */ - extern int psx_get_in_exception(void); - if (psx_get_in_exception()) { - s_diffgate_interp++; - s_disp_interp++; - return 0; - } - run_shadow_diff(cpu, c, addr); - return 1; - } - - /* A/B: prove whether native EXECUTION is the cause. When off, the - * candidate matched (byte-exact) but we DON'T run native — interp - * handles it. The per-function blocklist forces the same interp - * routing for one function only (bisection localization). */ - if (!s_native_exec || overlay_native_blocked(c->addr)) - { s_would_run_native++; s_disp_interp++; return 0; } -#ifndef PSX_NO_DEBUG_TOOLS - if (!native_rank_allows(c, addr)) - { s_would_run_native++; s_disp_interp++; return 0; } -#endif - - /* Record into the always-on ring BEFORE the call; mark in-progress - * so a freeze inside this fn is visible at dump time. */ - /* Native-call ring: ALWAYS-ON (see the range-chain site). */ - uint32_t slot = s_nring_pos++ & (NRING_CAP - 1u); - s_nring[slot].addr = c->addr; - s_nring[slot].crc = c->crc_code; - s_nring[slot].frame = (uint32_t)s_frame_count; - s_nring[slot].seq = ++s_nring_seq; - s_nring[slot].returned = 0; - uint32_t prev_inprogress = s_native_inprogress; - s_native_inprogress = c->addr; - s_native_calls_total++; - native_hot_note(c->addr); - - if (s_active_depth < (int)(sizeof(s_active_stack) / sizeof(s_active_stack[0]))) - s_active_stack[s_active_depth++] = i; - s_disp_native++; - /* Delimit this native execution in the interp insn ring (native code - * emits no per-insn entries; markers keep the timeline alignable). */ -#ifndef PSX_NO_DEBUG_TOOLS - extern void dirty_ram_log_marker(uint32_t addr, uint32_t tag, int kind); - uint32_t mtag = (uint32_t)s_nring[slot].seq; /* stable across nesting */ - dirty_ram_log_marker(c->addr, mtag, 0); -#endif - { - int prev_phase = g_exec_phase; - OverlayFlushFn prev_flush = overlay_flush_enter(c); - g_exec_phase = 2; - c->fn(cpu); - overlay_flush_leave(prev_flush); - g_exec_phase = prev_phase; - } - overlay_post_dispatch_irq_pump(cpu); -#ifndef PSX_NO_DEBUG_TOOLS - dirty_ram_log_marker(c->addr, mtag, 1); -#endif - if (s_active_depth > 0) s_active_depth--; - - s_nring[slot].returned = 1; - s_native_inprogress = prev_inprogress; /* restore (nested calls) */ - if (g_native_bad_entry) { /* foreign interior entry: fail closed to interp */ - g_native_bad_entry = 0; - s_disp_native--; s_disp_interp++; - return 0; /* cpu->pc was restored to the requested PC */ - } - return 1; - } else { - s_rehash_miss++; - if (c->state == ENTRY_VALID) { - c->state = ENTRY_INVALID; - s_invalidations++; - if (s_valid_count > 0) s_valid_count--; - } else { - c->state = ENTRY_INVALID; - } - s_stale_blocked++; - } - } - - /* Publish at most one cached DLL and retry this same dispatch once. This - * preserves additive variant coverage without turning one guest transition - * into an unbounded synchronous LoadLibrary loop. */ - if (!lazy_loaded && s_active && overlay_cache_window_contains(phys) && - try_load_region(phys)) { - lazy_loaded = 1; - goto retry_candidates; - } - - if (overlay_cache_window_contains(phys)) lazy_miss_record(phys); - s_disp_interp++; - return 0; -} - -/* ---- Self-modification of an actively-executing entry (§8.5) ------------ */ -/* Lazy re-hash on the NEXT dispatch is too late if a native function modifies - * its own code and continues executing the modified bytes within the same - * activation (native runs the originally-compiled semantics). We can't recover - * the current activation, so we permanently demote that entry to interp. Called - * from memory.c only when the written page is watched. */ -void overlay_loader_active_write_check(uint32_t phys, uint32_t size) { - extern uint32_t g_debug_last_store_pc; - uint32_t p = phys & 0x1FFFFFFFu; - for (int d = 0; d < s_active_depth; d++) { - Candidate *c = &s_cand[s_active_stack[d]]; - for (int i = 0; i < c->nranges; i++) { - uint32_t lo = c->range_lo[i]; - uint32_t hi = lo + c->range_len[i]; - if (p < hi && p + size > lo) { - if (c->state != ENTRY_BLACKLIST) { - c->state = ENTRY_BLACKLIST; - s_selfmod++; - if (s_valid_count > 0) s_valid_count--; - s_last_write_pc = g_debug_last_store_pc; - s_last_write_addr = phys; - s_last_write_size = size; - loader_log("blacklist self-mod entry 0x%08X (write 0x%08X)", - c->addr, phys); - } - break; - } - } - } -} - -/* ---- Status getters (signatures preserved for debug_server.c) ---------- */ - -void overlay_loader_get_counters(uint32_t *loads, uint32_t *invalidations, - uint32_t *unregistered, - uint64_t *disp_native, uint64_t *disp_interp, - uint64_t *stale_blocked, - uint32_t *last_write_pc, - uint32_t *last_write_addr, - uint32_t *last_write_size, - int *regions, uint32_t *revalidations) { - if (loads) *loads = (uint32_t)s_ndlls; - if (invalidations) *invalidations = s_invalidations; - if (unregistered) *unregistered = s_no_manifest; - if (disp_native) *disp_native = s_disp_native; - if (disp_interp) *disp_interp = s_disp_interp; - if (stale_blocked) *stale_blocked = s_stale_blocked; - if (last_write_pc) *last_write_pc = s_last_write_pc; - if (last_write_addr) *last_write_addr = s_last_write_addr; - if (last_write_size) *last_write_size = s_last_write_size; - if (regions) *regions = s_ndlls; - if (revalidations) *revalidations = s_revalidations; -} - -void overlay_loader_get_load_timing(uint64_t *total_us, uint64_t *max_us, - uint64_t *last_us) { - if (total_us) *total_us = s_load_total_us; - if (max_us) *max_us = s_load_max_us; - if (last_us) *last_us = s_load_last_us; -} - -void overlay_loader_take_hot_native(uint32_t *pc, uint64_t *calls) { - uint32_t best_pc = 0; - uint64_t best_calls = 0; - if (s_native_hot_enabled) { - for (uint32_t i = 0; i < NATIVE_HOT_CAP; i++) { - if (s_native_hot[i].calls > best_calls) { - best_pc = s_native_hot[i].pc; - best_calls = s_native_hot[i].calls; - } - s_native_hot[i].calls = 0; - } - } - if (pc) *pc = best_pc; - if (calls) *calls = best_calls; -} - -/* Reload diagnostics. Repurposed for the per-entry model: - * r0_valid -> candidates currently VALID - * r0_writes... -> entries blacklisted (self-mod) - * r0_fn_lo -> total candidates registered - * r0_fn_hi -> DLLs loaded - * r0_crc_live -> last computed code-range crc - * reval_attempts -> code-range hashes computed - * reval_crc_miss -> hashes that did not match - * last_reval_crc -> last computed crc */ -void overlay_loader_get_reload_debug(int *r0_valid, uint32_t *r0_writes, - uint32_t *r0_fn_lo, uint32_t *r0_fn_hi, - uint32_t *r0_crc_live, - uint32_t *reval_attempts, - uint32_t *reval_crc_miss, - uint32_t *last_reval_crc) { - if (r0_valid) *r0_valid = s_valid_count; - if (r0_writes) *r0_writes = s_selfmod; - if (r0_fn_lo) *r0_fn_lo = (uint32_t)s_cand_n; - if (r0_fn_hi) *r0_fn_hi = (uint32_t)s_ndlls; - if (r0_crc_live) *r0_crc_live = s_last_crc; - if (reval_attempts) *reval_attempts = s_rehashes; - if (reval_crc_miss) *reval_crc_miss = s_rehash_miss; - if (last_reval_crc) *last_reval_crc = s_last_crc; -} - -int overlay_loader_registered_count(void) { return s_valid_count; } - -/* Dispatches that ran native via the unchanged-page-generation fast path, - * i.e. skipped the per-dispatch code-range crc32 (overlay-cache v2 P2). High - * values relative to reval_attempts mean the warm-cache hot path is cheap. */ -uint64_t overlay_loader_gen_fastpath(void) { return s_gen_fastpath; } -int overlay_loader_range_link_count(void) { return s_range_link_n; } -int overlay_loader_range_index_overflow(void) { return s_range_index_overflow; } -int overlay_loader_lazy_manifest_count(void) { return s_lazy_man_n; } -int overlay_loader_lazy_manifest_overflow(void) { return s_lazy_man_overflow; } -uint64_t overlay_loader_candidate_overflow(void) { return s_cand_overflow; } -uint64_t overlay_loader_pair_aliases(void) { return s_pair_aliases; } - -/* (sljit removed 2026-07-15: overlay_loader_sljit_obsoleted and the - * overlay_loader_sljit_probe one-shot JIT helper lived here.) */ - -/* ---- Native↔interp execution fingerprint differential (§5-E) ----------- */ -/* For each CANDIDATE function execution we record the FULL register file at - * entry and exit (plus the guest cycle), tagged native vs interp. Run once - * native-OFF (all candidates interpreted = oracle) and once native-ON; diff by - * sequence — the first entry whose in-state differs names the exact register - * AND value where the two trajectories part ways, and the cycle field - * quantifies native↔interp cycle-accounting skew over the aligned prefix. - * Logging is purely additive (no control-flow change), driven from the single - * dirty_ram_dispatch chokepoint. */ -typedef struct { - uint64_t seq; - uint64_t cycle; /* guest cycle at exit (log time) */ - uint32_t addr; - uint32_t in_crc, out_crc; - int native; - uint32_t in_regs[34]; /* r0..r31, hi, lo at entry */ - uint32_t out_regs[34]; /* r0..r31, hi, lo at exit */ -} FpEnt; -#define FP_CAP (1u << 16) /* ~19 MB with full reg files; ~65K executions */ -static FpEnt s_fp[FP_CAP]; -static uint64_t s_fp_seq = 0; - -int overlay_loader_is_candidate(uint32_t phys) { - if (!s_active) return 0; - return exact_entry_has(phys); -} - -int overlay_fp_enabled(void) { - static int enabled = -1; - if (enabled < 0) { - const char *e = getenv("PSX_OVERLAY_FP_LOG"); - enabled = (e && e[0] && e[0] != '0') ? 1 : 0; - } - return enabled; -} - -/* ---- Same-state native↔interp differential (confident measurement) ------ */ -/* At a matched dispatch: snapshot CPU+RAM, run native (discard), restore, run - * interpreter (KEEP — game stays correct), compare under IDENTICAL input state. - * Eliminates manual-nav desync. Interrupts suppressed during both shadow runs - * so the comparison isolates COMPUTATION (and is longjmp-safe). A divergence - * here = a real codegen bug (function + exact register/RAM). Zero divergence = - * computation is correct and the fault is timing/interrupt-ordering. */ -#define SHADOW_RAM_SIZE (2u * 1024u * 1024u) -#define SHADOW_SPAD_SIZE 1024u -static uint8_t s_ram0[SHADOW_RAM_SIZE], s_ramN[SHADOW_RAM_SIZE], s_ramI[SHADOW_RAM_SIZE]; -static uint8_t s_spad0[SHADOW_SPAD_SIZE], s_spadI[SHADOW_SPAD_SIZE]; -static uint64_t s_shadow_skipped_dev = 0; /* unsafe/incomplete traces skipped */ -/* s_diff_mode / s_in_shadow declared above (before dispatch). */ - -typedef struct { - uint64_t seq; uint32_t addr; - int kind; /* architectural/trace difference class */ - int index; /* register/array index, or -1 */ - uint64_t cycle_native, cycle_interp; - uint32_t trace_ops, replay_ops; - int trace_kind; - uint32_t trace_pc, trace_addr, trace_expected, trace_actual; - int reg; /* first differing gpr index, -1 none */ - uint32_t reg_native, reg_interp; - int hi_diff, lo_diff; - int64_t ram_off; /* first differing RAM byte, -1 none */ - uint32_t ram_native, ram_interp; /* the differing word */ -} ShadowDiv; -#define SDIV_CAP 512 -static ShadowDiv s_sdiv[SDIV_CAP]; -static int s_sdiv_n = 0; -static uint64_t s_shadow_calls = 0; -static uint64_t s_shadow_divs = 0; - -/* One-shot full-state capture of the FIRST divergence: complete native and - * interp register files so the diverging path can be localized. */ -static int s_detail_captured = 0; -static uint32_t s_detail_addr = 0; -static uint32_t s_detail_nat_gpr[32], s_detail_int_gpr[32]; -static uint32_t s_detail_nat_hi, s_detail_nat_lo, s_detail_int_hi, s_detail_int_lo; - -void overlay_loader_get_shadow_summary(uint64_t *calls, uint64_t *divergences, - uint32_t *first_divergence_pc) { - if (calls) *calls = s_shadow_calls; - if (divergences) *divergences = s_shadow_divs; - if (first_divergence_pc) - *first_divergence_pc = s_detail_captured ? s_detail_addr : 0; -} - -void overlay_loader_set_diff_mode(int on) { s_diff_mode = on ? 1 : 0; } - -static void run_shadow_diff_legacy(CPUState *cpu, Candidate *c, uint32_t addr) { - extern uint8_t *memory_get_ram_ptr(void); - extern uint8_t *memory_get_scratchpad_ptr(void); - extern int dirty_ram_dispatch(CPUState *cpu, uint32_t addr, uint32_t stop_addr); - extern void psx_dispatch_call(CPUState *cpu, uint32_t addr, uint32_t return_addr); - extern int g_shadow_mmio_watch; /* memory.c — device-access detector */ - extern uint64_t g_shadow_mmio_hits; - uint8_t *ram = memory_get_ram_ptr(); - uint8_t *spad = memory_get_scratchpad_ptr(); - - extern uint64_t psx_exception_setjmp_epoch(void); - s_in_shadow = 1; - s_shadow_epoch = psx_exception_setjmp_epoch(); - int saved_supp = s_suppress_irq; - s_suppress_irq = 1; /* isolate computation; longjmp-safe */ - s_shadow_saved_supp = saved_supp; /* escape-fixup mirror (see decl) */ - s_shadow_saved_mmio_watch = g_shadow_mmio_watch; - s_shadow_saved_exec_phase = g_exec_phase; - s_shadow_scheduler_bail = 0; - /* Validate ONE function at a time: nested OVERLAY calls run via the - * INTERPRETER on BOTH passes (s_native_exec=0). Otherwise the native pass - * dispatches each callee as its own native shard while the interp pass runs - * it interp, so the diff compares the whole call TREE and a callee that bails - * or diverges is misattributed to this candidate (e.g. a contract bail that - * skips THIS function's epilogue -> $sp off by the frame size). Per-function - * isolation proves exactly this shard's codegen vs the interp oracle; whole- - * tree soundness then follows by induction. */ - int sv = s_native_exec; - s_native_exec = 0; - s_shadow_saved_native_exec = sv; /* escape-fixup mirror (see decl) */ - - CPUState cpu0 = *cpu; - memcpy(s_ram0, ram, SHADOW_RAM_SIZE); - memcpy(s_spad0, spad, SHADOW_SPAD_SIZE); - - /* PASS 1 — INTERPRETER, the authoritative single execution, with the device - * detector armed. Running interp FIRST (not native) guarantees device I/O - * happens AT MOST ONCE and only via the trusted path: if this function (or a - * callee) touches ANY MMIO we abandon the native pass entirely — device I/O - * must never be double-executed (one spurious card/SIO/DMA write corrupts - * hardware state and wedges the guest, e.g. the save-load crash). */ - uint64_t mmio0 = g_shadow_mmio_hits; - g_shadow_mmio_watch++; - dirty_ram_dispatch(cpu, addr, cpu->gpr[31]); - g_shadow_mmio_watch--; - s_shadow_calls++; - - if (g_shadow_mmio_hits != mmio0) { - /* Device-touching: keep the interp result live (already in *cpu/ram/spad), - * mark the candidate so it ALWAYS runs via the interpreter (never its - * shard, never re-diffed). Device functions stay on the interpreter — - * safe by construction, no double I/O. */ - c->device_touch = 1; - s_shadow_skipped_dev++; - g_psx_call_bail = 0; - s_native_exec = sv; - s_suppress_irq = saved_supp; - s_in_shadow = 0; - return; - } - - /* Device-free: preserve the interp result, then run the NATIVE shard from the - * same input and compare. No device I/O on either pass (proven clean above). */ - CPUState cpuI = *cpu; - memcpy(s_ramI, ram, SHADOW_RAM_SIZE); - memcpy(s_spadI, spad, SHADOW_SPAD_SIZE); - - *cpu = cpu0; - memcpy(ram, s_ram0, SHADOW_RAM_SIZE); - memcpy(spad, s_spad0, SHADOW_SPAD_SIZE); - uint32_t stop_ra = cpu->gpr[31]; /* entry $ra = the function's return point */ - /* Arm the own-interior native route for the NATIVE pass only (see - * s_shadow_cand decl): the candidate's CPS continuation re-entries run - * native so the diff exercises every block of the function, not just the - * first segment (nested CALLS still run interp on both passes — - * s_native_exec=0 above). Never armed during the interp pass, which must - * stay pure interp. */ - s_shadow_cand = c; - /* Preserve the authoritative interpreter's projection provenance while - * making every host-only cache read miss and every cache write a no-op for - * the speculative native pass. */ - gte_precision_speculative_begin(); - { - int prev_phase = g_exec_phase; - OverlayFlushFn prev_flush = overlay_flush_enter(c); - g_exec_phase = 2; - c->fn(cpu); - overlay_flush_leave(prev_flush); - g_exec_phase = prev_phase; - } - /* CPS shards exit with cpu->pc set to the next tail target. Chain through - * the normal dispatcher to the original caller return, with s_native_exec=0 - * above so nested overlay calls still run through the interpreter on both - * passes. dirty_ram_dispatch alone cannot follow clean/static BIOS targets - * and creates false shadow divergences for tail-transfer-heavy functions. */ - { - int guard = 0; - while (cpu->pc != 0 && !g_psx_call_bail && guard++ < 8192) { - uint32_t tv = cpu->pc; - if ((tv & 0x1FFFFFFFu) == (stop_ra & 0x1FFFFFFFu)) break; /* returned */ - cpu->pc = 0; - int prev_phase = g_exec_phase; - g_exec_phase = 3; /* compiled route; dirty/native callees re-tag inside */ - psx_dispatch_call(cpu, tv, stop_ra); - g_exec_phase = prev_phase; - } - } - s_shadow_cand = NULL; - gte_precision_speculative_end(); - CPUState cpuN = *cpu; - memcpy(s_ramN, ram, SHADOW_RAM_SIZE); - int scheduler_bail = s_shadow_scheduler_bail; - - /* Compare native (cpuN/s_ramN) vs interp (cpuI/s_ramI) under identical input. */ - int reg = -1; - for (int r = 1; r < 32; r++) if (cpuN.gpr[r] != cpuI.gpr[r]) { reg = r; break; } - int hidiff = (cpuN.hi != cpuI.hi), lodiff = (cpuN.lo != cpuI.lo); - int64_t ramoff = -1; - if (memcmp(s_ramN, s_ramI, SHADOW_RAM_SIZE) != 0) { - for (uint32_t a = 0; a < SHADOW_RAM_SIZE; a++) - if (s_ramN[a] != s_ramI[a]) { ramoff = (int64_t)a; break; } - } - if (!scheduler_bail && reg < 0 && !hidiff && !lodiff && ramoff < 0) { - /* Clean pass: credit the verify budget. */ - if (c->diff_passes < OVERLAY_DIFF_BUDGET) c->diff_passes++; - } else { - /* Divergence: reset the budget. Promotion to live requires N CONSECUTIVE - * clean passes (the spec's "0 divergences over the budget"), so an - * intermittently-wrong shard can never accumulate enough lucky passes to be - * trusted — it stays diff-gated (interp result kept) and never runs live. */ - c->diff_passes = 0; - s_shadow_divs++; - if (scheduler_bail && c->state != ENTRY_BLACKLIST) { - /* The interpreter pass completed without switching threads, so a - * native-only scheduler attempt proves control-flow divergence. - * Permanently keep this shard off the live path. */ - c->state = ENTRY_BLACKLIST; - if (s_valid_count > 0) s_valid_count--; - loader_log("blacklist shadow scheduler divergence 0x%08X", c->addr); - } - if (!s_detail_captured) { - s_detail_captured = 1; - s_detail_addr = c->addr; - for (int r = 0; r < 32; r++) { - s_detail_nat_gpr[r] = cpuN.gpr[r]; - s_detail_int_gpr[r] = cpuI.gpr[r]; - } - s_detail_nat_hi = cpuN.hi; s_detail_nat_lo = cpuN.lo; - s_detail_int_hi = cpuI.hi; s_detail_int_lo = cpuI.lo; - } - /* Ring-flood guard: one persistently-diverging site must not evict - * every other site's records (512x 0xB0 drowned the 0xF514 hunt). - * Cap records per address; the divergence COUNTER still increments. */ - int addr_recs = 0; - for (int i = 0; i < s_sdiv_n; i++) - if (s_sdiv[i].addr == c->addr && ++addr_recs >= 16) break; - if (s_sdiv_n < SDIV_CAP && addr_recs < 16) { - ShadowDiv *d = &s_sdiv[s_sdiv_n++]; - d->seq = s_shadow_calls; d->addr = c->addr; - d->reg = reg; - d->reg_native = (reg >= 0) ? cpuN.gpr[reg] : 0; - d->reg_interp = (reg >= 0) ? cpuI.gpr[reg] : 0; - d->hi_diff = hidiff; d->lo_diff = lodiff; - d->ram_off = ramoff; - if (ramoff >= 0) { - uint32_t a = (uint32_t)ramoff & ~3u; - d->ram_native = *(uint32_t *)&s_ramN[a]; - d->ram_interp = *(uint32_t *)&s_ramI[a]; - } - } - } - /* Restore the interp result as the authoritative live state (native discarded). - * A bail raised by the shadow run must never leak into live execution (a - * spurious in-progress unwind wedges the guest). */ - *cpu = cpuI; - memcpy(ram, s_ramI, SHADOW_RAM_SIZE); - memcpy(spad, s_spadI, SHADOW_SPAD_SIZE); - g_psx_call_bail = 0; - s_native_exec = sv; - s_suppress_irq = saved_supp; - s_in_shadow = 0; -} - -enum { - SHDIFF_NONE = 0, SHDIFF_PC, SHDIFF_GPR, SHDIFF_HI, SHDIFF_LO, - SHDIFF_COP0, SHDIFF_GTE_DATA, SHDIFF_GTE_CTRL, - SHDIFF_MULDIV_TS, SHDIFF_GTE_TS, SHDIFF_READ_ABSORB, - SHDIFF_READ_WHICH, SHDIFF_READ_FUDGE, SHDIFF_LD_WHICH, - SHDIFF_LD_ABSORB, SHDIFF_CYCLE, SHDIFF_TRACE, SHDIFF_SETUP, - SHDIFF_SCHED -}; - -static int shadow_cpu_diff(const CPUState *native, const CPUState *interp, - int *index, uint32_t *nv, uint32_t *iv) { -#define SHCMP_SCALAR(kind_, field_) do { \ - if (native->field_ != interp->field_) { \ - *index = -1; *nv = (uint32_t)native->field_; *iv = (uint32_t)interp->field_; \ - return (kind_); \ - } \ -} while (0) -#define SHCMP_ARRAY(kind_, field_, count_) do { \ - for (int _i = 0; _i < (count_); _i++) { \ - if (native->field_[_i] != interp->field_[_i]) { \ - *index = _i; *nv = (uint32_t)native->field_[_i]; \ - *iv = (uint32_t)interp->field_[_i]; return (kind_); \ - } \ - } \ -} while (0) - if (native->pc != interp->pc) { - *index = -1; *nv = native->pc; *iv = interp->pc; return SHDIFF_PC; - } - SHCMP_ARRAY(SHDIFF_GPR, gpr, 32); - SHCMP_SCALAR(SHDIFF_HI, hi); - SHCMP_SCALAR(SHDIFF_LO, lo); - SHCMP_ARRAY(SHDIFF_COP0, cop0, 32); - SHCMP_ARRAY(SHDIFF_GTE_DATA, gte_data, 32); - SHCMP_ARRAY(SHDIFF_GTE_CTRL, gte_ctrl, 32); - SHCMP_SCALAR(SHDIFF_MULDIV_TS, muldiv_ts_done); - SHCMP_SCALAR(SHDIFF_GTE_TS, gte_ts_done); - SHCMP_ARRAY(SHDIFF_READ_ABSORB, read_absorb, 33); - SHCMP_SCALAR(SHDIFF_READ_WHICH, read_absorb_which); - SHCMP_SCALAR(SHDIFF_READ_FUDGE, read_fudge); - SHCMP_SCALAR(SHDIFF_LD_WHICH, ld_which_t); - SHCMP_SCALAR(SHDIFF_LD_ABSORB, ld_absorb); -#undef SHCMP_ARRAY -#undef SHCMP_SCALAR - *index = -1; *nv = 0; *iv = 0; - return SHDIFF_NONE; -} - -/* Authoritative interpreter RECORD + side-effect-free native REPLAY. - * Memory reads are replayed and writes verified/no-op, the replay clock advances - * without servicing devices, and GTE visual/diagnostic sidecars are sandboxed. - * Thus RAM/MMIO/devices mutate exactly once while the comparison covers the full - * CPU architectural and timing state plus the ordered memory-operation trace. */ -static void run_shadow_diff(CPUState *cpu, Candidate *c, uint32_t addr) { - extern int dirty_ram_dispatch(CPUState *cpu, uint32_t addr, uint32_t stop_addr); - extern void psx_dispatch_call(CPUState *cpu, uint32_t addr, uint32_t return_addr); - extern int gte_replay_side_effects_begin(void); - extern void gte_replay_side_effects_end(void); - extern uint32_t g_debug_current_func_addr; - extern uint64_t psx_exception_setjmp_epoch(void); - extern int g_shadow_mmio_watch; - - s_in_shadow = 1; - s_shadow_epoch = psx_exception_setjmp_epoch(); - int saved_supp = s_suppress_irq; - int saved_native = s_native_exec; - s_suppress_irq = 1; - s_native_exec = 0; - s_shadow_saved_supp = saved_supp; - s_shadow_saved_native_exec = saved_native; - s_shadow_saved_mmio_watch = g_shadow_mmio_watch; - s_shadow_saved_exec_phase = g_exec_phase; - s_shadow_saved_call_bail = g_psx_call_bail; - s_shadow_saved_debug_func = g_debug_current_func_addr; - s_shadow_saved_irq_calls = s_irq_callcount; - s_shadow_saved_irq_supp = s_irq_suppressed; - s_shadow_scheduler_bail = 0; - - CPUState cpu0 = *cpu; - uint64_t cycle0 = psx_get_cycle_count(); - uint32_t stop_ra = cpu0.gpr[31]; - uint32_t record_ops = 0, replay_ops = 0; - int saw_exception = 0; - int icache_record_ok = psx_icache_shadow_record_begin(); - int record_owned = 0; - int record_ok = icache_record_ok && ls_shadow_record_begin(); - if (record_ok) { - record_owned = 1; - dirty_ram_dispatch(cpu, addr, stop_ra); - record_ok = ls_shadow_record_end(&record_ops, &saw_exception); - record_owned = 0; - } else { - dirty_ram_dispatch(cpu, addr, stop_ra); - } - s_shadow_calls++; - - CPUState cpuI = *cpu; - uint64_t cycleI = psx_get_cycle_count(); - if (!record_ok || saw_exception) { - s_shadow_skipped_dev++; - if (record_owned) ls_shadow_abort(); - psx_icache_shadow_abort(); - s_native_exec = saved_native; - s_suppress_irq = saved_supp; - s_in_shadow = 0; - return; - } - - CPUState cpuN = cpu0; - int trace_kind = 0; - uint32_t trace_pc = 0, trace_addr = 0, trace_expected = 0, trace_actual = 0; - int replay_owned = 0; - int replay_ok = ls_shadow_replay_begin(); - if (replay_ok) replay_owned = 1; - int icache_ok = replay_ok && psx_icache_shadow_replay_begin(); - int clock_ok = icache_ok && psx_cycle_replay_begin(cycle0); - int gte_ok = clock_ok && gte_replay_side_effects_begin(); - uint32_t saved_debug_func = g_debug_current_func_addr; - uint32_t saved_irq_callcount = s_irq_callcount; - uint64_t saved_irq_suppressed = s_irq_suppressed; - int saved_bail = g_psx_call_bail; - - if (gte_ok) { - s_shadow_cand = c; - { - int prev_phase = g_exec_phase; - OverlayFlushFn prev_flush = overlay_flush_enter(c); - g_exec_phase = 2; - c->fn(&cpuN); - overlay_flush_leave(prev_flush); - g_exec_phase = prev_phase; - } - { - int guard = 0; - while (cpuN.pc != 0 && !g_psx_call_bail && guard++ < 8192) { - uint32_t tv = cpuN.pc; - if ((tv & 0x1FFFFFFFu) == (stop_ra & 0x1FFFFFFFu)) break; - cpuN.pc = 0; - int prev_phase = g_exec_phase; - g_exec_phase = 3; - psx_dispatch_call(&cpuN, tv, stop_ra); - g_exec_phase = prev_phase; - } - } - s_shadow_cand = NULL; - } - int scheduler_bail = s_shadow_scheduler_bail; - - if (gte_ok) gte_replay_side_effects_end(); - uint64_t cycleN = clock_ok ? psx_cycle_replay_end() : cycle0; - psx_icache_shadow_replay_end(); - if (replay_ok) { - replay_ok = ls_shadow_replay_end(&replay_ops, &trace_kind, &trace_pc, - &trace_addr, &trace_expected, &trace_actual); - replay_owned = 0; - } else if (replay_owned) { - ls_shadow_abort(); - replay_owned = 0; - } - psx_icache_shadow_abort(); - g_debug_current_func_addr = saved_debug_func; - s_irq_callcount = saved_irq_callcount; - s_irq_suppressed = saved_irq_suppressed; - g_psx_call_bail = saved_bail; - - int index = -1; - uint32_t native_value = 0, interp_value = 0; - int kind = (!gte_ok || !replay_ok) ? SHDIFF_TRACE : - shadow_cpu_diff(&cpuN, &cpuI, &index, &native_value, &interp_value); - if (kind == SHDIFF_NONE && cycleN - cycle0 != cycleI - cycle0) { - kind = SHDIFF_CYCLE; - native_value = (uint32_t)(cycleN - cycle0); - interp_value = (uint32_t)(cycleI - cycle0); - } - if (kind == SHDIFF_NONE && scheduler_bail) kind = SHDIFF_SCHED; - - if (kind == SHDIFF_NONE) { - if (c->diff_passes < OVERLAY_DIFF_BUDGET) c->diff_passes++; - } else { - c->diff_passes = 0; - c->device_touch = 1; /* fail closed: all later calls stay interpreted */ - s_shadow_divs++; - if (scheduler_bail && c->state != ENTRY_BLACKLIST) { - /* The interpreter pass completed without switching threads, so a - * native-only scheduler attempt proves control-flow divergence. - * Permanently keep this shard off the live path. */ - c->state = ENTRY_BLACKLIST; - if (s_valid_count > 0) s_valid_count--; - loader_log("blacklist shadow scheduler divergence 0x%08X", c->addr); - } - if (!s_detail_captured) { - s_detail_captured = 1; - s_detail_addr = c->addr; - memcpy(s_detail_nat_gpr, cpuN.gpr, sizeof s_detail_nat_gpr); - memcpy(s_detail_int_gpr, cpuI.gpr, sizeof s_detail_int_gpr); - s_detail_nat_hi = cpuN.hi; s_detail_nat_lo = cpuN.lo; - s_detail_int_hi = cpuI.hi; s_detail_int_lo = cpuI.lo; - } - int addr_recs = 0; - for (int i = 0; i < s_sdiv_n; i++) - if (s_sdiv[i].addr == c->addr && ++addr_recs >= 16) break; - if (s_sdiv_n < SDIV_CAP && addr_recs < 16) { - ShadowDiv *d = &s_sdiv[s_sdiv_n++]; - memset(d, 0, sizeof *d); - d->seq = s_shadow_calls; d->addr = c->addr; - d->kind = kind; d->index = index; - d->cycle_native = cycleN - cycle0; - d->cycle_interp = cycleI - cycle0; - d->trace_ops = record_ops; d->replay_ops = replay_ops; - d->trace_kind = trace_kind; d->trace_pc = trace_pc; - d->trace_addr = trace_addr; d->trace_expected = trace_expected; - d->trace_actual = trace_actual; - d->reg = kind == SHDIFF_GPR ? index : -1; - d->reg_native = native_value; d->reg_interp = interp_value; - d->hi_diff = kind == SHDIFF_HI; d->lo_diff = kind == SHDIFF_LO; - d->ram_off = -1; - } - } - - s_native_exec = saved_native; - s_suppress_irq = saved_supp; - s_in_shadow = 0; -} - -/* A nonlocal scheduler bail during the speculative native replay pass (see - * overlay_loader_shadow_native_thread_switch_bail) means the interpreter pass - * — authoritative and already complete by the time the native replay runs — - * did NOT itself switch threads at this point, so a native-only switch attempt - * proves the native shard's control flow diverges. This is stronger than an - * ordinary register/RAM mismatch: permanently blacklist the shard rather than - * merely resetting its verify budget, mirroring the legacy comparator's - * handling of the same signal. */ -static void shadow_escape_cleanup(void) { - extern int g_shadow_mmio_watch; - if (s_shadow_cand != NULL) { - Candidate *escaped = (Candidate *)s_shadow_cand; - escaped->diff_passes = 0; - escaped->device_touch = 1; /* native escape is unverified: fail closed */ - } - { extern void gte_replay_side_effects_end(void); gte_replay_side_effects_end(); } - (void)psx_cycle_replay_end(); - psx_icache_shadow_abort(); - ls_shadow_abort(); - s_shadow_escapes++; - if (s_shadow_cand != NULL) { - s_shadow_escapes_native++; - gte_precision_speculative_end(); - } - /* The interpreter pass arms the MMIO detector around dirty dispatch. A - * nonlocal scheduler/exception escape can skip the matching decrement. */ - g_shadow_mmio_watch = s_shadow_saved_mmio_watch; - s_in_shadow = 0; - s_shadow_cand = NULL; - s_native_exec = s_shadow_saved_native_exec; - s_suppress_irq = s_shadow_saved_supp; - g_exec_phase = s_shadow_saved_exec_phase; - g_psx_call_bail = s_shadow_saved_call_bail; - { extern uint32_t g_debug_current_func_addr; - g_debug_current_func_addr = s_shadow_saved_debug_func; } - s_irq_callcount = s_shadow_saved_irq_calls; - s_irq_suppressed = s_shadow_saved_irq_supp; -} - -static void shadow_escape_flush_cycles(void) { - /* A host unwind can skip a candidate call's normal epilogue. Commit any - * cycles accumulated before the callback and clear the active DLL store - * hook so later static/interpreted writes cannot address stale context. */ - if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles(); - g_overlay_flush_pending_cycles = NULL; -} - -/* Called by deferred_exception_longjmp() (interrupts.c) before it unwinds. - * If the longjmp target frame predates a live shadow run (target_epoch <= - * s_shadow_epoch), the unwind blows through run_shadow_diff and its epilogue - * never runs — restore the shadow-scoped globals here and count the escape. - * The dispatch gate (no shadow start while in an exception dispatch) makes - * this structurally unreachable; if the counters ever move, that invariant is - * broken and must be investigated. An escape during the NATIVE pass - * (s_shadow_cand set) additionally means speculative native state leaked into - * the live timeline — counted separately; it must stay 0. A longjmp to a - * frame armed AFTER shadow start (target_epoch > s_shadow_epoch) is an - * exception contained inside the shadow: it lands inside the shadow frame and - * the shadow continues — the flags must NOT be touched. */ -void overlay_loader_shadow_escape_fixup(uint64_t target_epoch) { - shadow_escape_flush_cycles(); - if (!s_in_shadow) return; - if (target_epoch > s_shadow_epoch) return; /* contained: leave armed */ - shadow_escape_cleanup(); -} - -/* The deterministic HLE scheduler uses a different jmp_buf and therefore has - * no exception-setjmp epoch. Its landing calls this after every structured - * escape; a live shadow frame was necessarily abandoned in full. */ -void overlay_loader_shadow_scheduler_escape_fixup(void) { - shadow_escape_flush_cycles(); - if (s_in_shadow) shadow_escape_cleanup(); -} - -int overlay_loader_dump_shadow_detail(char *out, int cap) { - int n = 0; - n += snprintf(out + n, cap - n, - "{\"captured\":%d,\"addr\":\"0x%08X\",\"regs\":[", s_detail_captured, s_detail_addr); - static const char *rn[32] = {"zero","at","v0","v1","a0","a1","a2","a3", - "t0","t1","t2","t3","t4","t5","t6","t7","s0","s1","s2","s3","s4","s5", - "s6","s7","t8","t9","k0","k1","gp","sp","fp","ra"}; - int first = 1; - for (int r = 0; r < 32; r++) { - if (s_detail_nat_gpr[r] == s_detail_int_gpr[r]) continue; - n += snprintf(out + n, cap - n, - "%s{\"r\":%d,\"name\":\"%s\",\"native\":\"0x%08X\",\"interp\":\"0x%08X\"}", - first ? "" : ",", r, rn[r], s_detail_nat_gpr[r], s_detail_int_gpr[r]); - first = 0; - } - n += snprintf(out + n, cap - n, "],\"hi\":{\"native\":\"0x%08X\",\"interp\":\"0x%08X\"}," - "\"lo\":{\"native\":\"0x%08X\",\"interp\":\"0x%08X\"}}", - s_detail_nat_hi, s_detail_int_hi, s_detail_nat_lo, s_detail_int_lo); - return n; -} - -int overlay_loader_dump_shadow(char *out, int cap) { - int n = 0; - n += snprintf(out + n, cap - n, - "{\"diff_mode\":%d,\"shadow_calls\":%llu,\"divergences\":%llu," - "\"skipped_device\":%llu,\"interior_gated\":%llu," - "\"in_shadow\":%d,\"native_exec\":%d," - "\"escapes\":%u,\"escapes_native\":%u,\"records\":[", - s_diff_mode, (unsigned long long)s_shadow_calls, - (unsigned long long)s_shadow_divs, - (unsigned long long)s_shadow_skipped_dev, - (unsigned long long)s_diffgate_interp, - s_in_shadow, s_native_exec, - s_shadow_escapes, s_shadow_escapes_native); - for (int i = 0; i < s_sdiv_n && n < cap - 200; i++) { - ShadowDiv *d = &s_sdiv[i]; - n += snprintf(out + n, cap - n, - "%s{\"seq\":%llu,\"addr\":\"0x%08X\",\"kind\":%d,\"index\":%d,\"reg\":%d," - "\"reg_native\":\"0x%08X\",\"reg_interp\":\"0x%08X\"," - "\"hi\":%d,\"lo\":%d,\"ram_off\":%lld," - "\"ram_native\":\"0x%08X\",\"ram_interp\":\"0x%08X\"," - "\"cycle_native\":%llu,\"cycle_interp\":%llu," - "\"trace_ops\":%u,\"replay_ops\":%u,\"trace_kind\":%d," - "\"trace_pc\":\"0x%08X\",\"trace_addr\":\"0x%08X\"," - "\"trace_expected\":\"0x%08X\",\"trace_actual\":\"0x%08X\"}", - i ? "," : "", (unsigned long long)d->seq, d->addr, - d->kind, d->index, d->reg, - d->reg_native, d->reg_interp, d->hi_diff, d->lo_diff, - (long long)d->ram_off, d->ram_native, d->ram_interp, - (unsigned long long)d->cycle_native, - (unsigned long long)d->cycle_interp, - d->trace_ops, d->replay_ops, d->trace_kind, - d->trace_pc, d->trace_addr, d->trace_expected, d->trace_actual); - } - n += snprintf(out + n, cap - n, "]}"); - return n; -} - -/* Fingerprint over the general registers (r1..r31) + hi/lo. r0 excluded - * (always 0). pc excluded (the return target is trivially equal at exit). */ -uint32_t overlay_regs_crc(const CPUState *cpu) { - uint32_t crc = 0xFFFFFFFFu; - crc = crc32_update(crc, (const uint8_t *)&cpu->gpr[1], sizeof(uint32_t) * 31); - crc = crc32_update(crc, (const uint8_t *)&cpu->hi, sizeof(uint32_t)); - crc = crc32_update(crc, (const uint8_t *)&cpu->lo, sizeof(uint32_t)); - return crc ^ 0xFFFFFFFFu; -} - -/* Snapshot r0..r31 + hi/lo into a 34-word buffer (entry-state capture). */ -void overlay_regs_snap(uint32_t out[34], const CPUState *cpu) { - memcpy(out, cpu->gpr, sizeof(uint32_t) * 32); - out[32] = cpu->hi; - out[33] = cpu->lo; -} - -/* CRC over words 1..33 (r0 excluded — always 0), matching overlay_regs_crc. */ -static uint32_t regs34_crc(const uint32_t *r) { - uint32_t crc = 0xFFFFFFFFu; - crc = crc32_update(crc, (const uint8_t *)&r[1], sizeof(uint32_t) * 33); - return crc ^ 0xFFFFFFFFu; -} - -void overlay_fp_log(uint32_t addr, const uint32_t *in_regs, - const CPUState *cpu, int native) { - extern uint64_t psx_get_cycle_count(void); - uint64_t s = s_fp_seq++; - FpEnt *e = &s_fp[s & (FP_CAP - 1u)]; - e->seq = s; e->cycle = psx_get_cycle_count(); - e->addr = addr & 0x1FFFFFFFu; e->native = native; - memcpy(e->in_regs, in_regs, sizeof(e->in_regs)); - overlay_regs_snap(e->out_regs, cpu); - e->in_crc = regs34_crc(e->in_regs); - e->out_crc = regs34_crc(e->out_regs); -} - -/* Execute `addr` natively if a validated overlay candidate exists, keeping the - * §5-E fingerprint record (same as the dirty_ram_dispatch chokepoint). Called - * from the dirty-RAM interpreter's jal/jalr handlers so native overlay callees - * get the SAME call contract as statically-compiled callees: execute as a - * unit, then the interpreter resumes at the call's return address. Without - * this, the call surfaces to the dispatch loop as a bare pc value; the native - * callee's C-style return (pc==0) then unwinds the loop past the suspended - * caller continuation — the caller's epilogue never runs and its stack frame - * leaks (root cause of the dwarf->overworld native blue screen). - * Returns 1 iff a native candidate ran. */ -int overlay_loader_call_native(CPUState *cpu, uint32_t addr) { - if (!s_active || !s_native_exec) - return 0; /* inactive/interp mode: keep the legacy inline path */ - uint32_t phys = addr & 0x1FFFFFFFu; - if (idx_head(phys) < 0 && !lazy_has_exact_entry(phys)) - return 0; /* neither a registered nor an exact cached entry */ - uint32_t in_regs[34]; - int fp = overlay_fp_enabled(); - if (fp) overlay_regs_snap(in_regs, cpu); - /* Run the callee as an atomic UNIT: IRQ delivery is deferred inside it (both - * the overlay CI wrappers and the dirty IRQ pumps check g_call_unit_depth) so - * a cooperative ChangeThread cannot suspend the interrupted thread mid-callee - * with an inconsistent (resume_pc, sp) snapshot. Restore (not just decrement) - * so a bail/longjmp-out unwinds the depth correctly; the scheduler landing - * also resets it to 0 as a backstop. Gated by the A/B toggle. */ - int prev_unit_depth = g_call_unit_depth; - if (overlay_unit_defer_enabled()) g_call_unit_depth = prev_unit_depth + 1; - int ran = overlay_loader_dispatch(cpu, addr); - g_call_unit_depth = prev_unit_depth; - if (!ran) return 0; - if (fp) overlay_fp_log(addr, in_regs, cpu, 1); - return 1; -} - -/* (sljit removed 2026-07-15: the JIT-shard host helpers psx_sljit_call / - * psx_sljit_cop2 / psx_sljit_memx lived here. They were called only by sljit- - * emitted shards via the cpu->sljit_helpers table, which is no longer wired.) */ - -/* Write the whole fingerprint log to a file (no TCP size limit). Returns the - * number of entries written, or -1 on open failure. */ -int overlay_loader_write_fp_file(const char *path) { - FILE *f = fopen(path, "w"); - if (!f) return -1; - uint64_t total = s_fp_seq; - uint64_t start = (total > FP_CAP) ? (total - FP_CAP) : 0; - fputc('[', f); - int first = 1, count = 0; - for (uint64_t s = start; s < total; s++) { - FpEnt *e = &s_fp[s & (FP_CAP - 1u)]; - fprintf(f, - "%s{\"seq\":%llu,\"cycle\":%llu,\"addr\":\"0x%08X\",\"in\":\"0x%08X\"," - "\"out\":\"0x%08X\",\"native\":%d,\"in_regs\":[", - first ? "" : ",\n", (unsigned long long)e->seq, - (unsigned long long)e->cycle, e->addr, - e->in_crc, e->out_crc, e->native); - for (int r = 0; r < 34; r++) - fprintf(f, "%s\"0x%08X\"", r ? "," : "", e->in_regs[r]); - fputs("],\"out_regs\":[", f); - for (int r = 0; r < 34; r++) - fprintf(f, "%s\"0x%08X\"", r ? "," : "", e->out_regs[r]); - fputs("]}", f); - first = 0; count++; - } - fputs("]\n", f); - fclose(f); - return count; -} - -/* Dump the native-call ring (most-recent first) + the in-progress entry. The - * in_progress field being nonzero means a native function was entered and never - * returned — a freeze INSIDE native code, pointing straight at the suspect. */ -int overlay_loader_dump_native_ring(char *out, int cap) { - int n = 0; - n += snprintf(out + n, cap - n, - "{\"native_exec\":%d,\"calls_total\":%llu,\"would_run\":%llu," - "\"in_progress\":\"0x%08X\",\"recent\":[", - s_native_exec, (unsigned long long)s_native_calls_total, - (unsigned long long)s_would_run_native, s_native_inprogress); - /* Walk backward from the most recent entries kept in the diagnostic ring. */ - int shown = 0; - for (uint32_t k = 0; k < NRING_CAP && n < cap - 140; k++) { - uint32_t idx = (s_nring_pos - 1u - k) & (NRING_CAP - 1u); - if (s_nring[idx].seq == 0) break; - n += snprintf(out + n, cap - n, - "%s{\"addr\":\"0x%08X\",\"crc\":\"0x%08X\",\"frame\":%u,\"seq\":%llu,\"returned\":%d}", - shown ? "," : "", s_nring[idx].addr, s_nring[idx].crc, s_nring[idx].frame, - (unsigned long long)s_nring[idx].seq, s_nring[idx].returned); - shown++; - } - n += snprintf(out + n, cap - n, "]}"); - return n; -} - -/* Diagnostic: dump every candidate with its stored vs live hash and generation - * state, so reload behaviour can be inspected directly (Rule 3 — visibility via - * the debug server, not logs). Writes a JSON array into `out`; returns bytes - * written. */ -int overlay_loader_dump_candidates(char *out, int cap) { - int n = 0; - n += snprintf(out + n, cap - n, "["); - for (int i = 0; i < s_cand_n && n < cap - 160; i++) { - Candidate *c = &s_cand[i]; - uint32_t live = cand_crc(c); - uint32_t sum = cand_gensum(c); - n += snprintf(out + n, cap - n, - "%s{\"addr\":\"0x%08X\",\"state\":%d,\"nranges\":%d," - "\"crc\":\"0x%08X\",\"live\":\"0x%08X\",\"match\":%d," - "\"val_gen\":%u,\"gen\":%u,\"dll\":%d,\"diff_passes\":%u}", - i ? "," : "", c->addr, c->state, c->nranges, - c->crc_code, live, (live == c->crc_code) ? 1 : 0, - c->val_gen, sum, c->dll, c->diff_passes); - } - n += snprintf(out + n, cap - n, "]"); - return n; -} - -/* Focused form for live miss diagnosis. The full candidate table can exceed the - * debug command's response buffer once a game has accumulated many variants; - * filtering by entry keeps every candidate at a reused PC visible. */ -int overlay_loader_dump_candidates_at(uint32_t addr, char *out, int cap) { - uint32_t phys = addr & 0x1FFFFFFFu; - int n = 0; - int first = 1; - n += snprintf(out + n, cap - n, "["); - for (int i = 0; i < s_cand_n && n < cap - 180; i++) { - Candidate *c = &s_cand[i]; - if (c->addr != phys) continue; - uint32_t live = cand_crc(c); - uint32_t sum = cand_gensum(c); - n += snprintf(out + n, cap - n, - "%s{\"index\":%d,\"addr\":\"0x%08X\",\"state\":%d,\"nranges\":%d," - "\"crc\":\"0x%08X\",\"live\":\"0x%08X\",\"match\":%d," - "\"val_gen\":%u,\"gen\":%u,\"dll\":%d,\"diff_passes\":%u," - "\"device_touch\":%d}", - first ? "" : ",", i, c->addr, c->state, c->nranges, - c->crc_code, live, (live == c->crc_code) ? 1 : 0, - c->val_gen, sum, c->dll, c->diff_passes, c->device_touch); - first = 0; - } - n += snprintf(out + n, cap - n, "]"); - return n; -} - -/* Focused view of manifest-only candidates that have not been loaded yet. */ -int overlay_loader_dump_lazy_at(uint32_t addr, char *out, int cap) { - uint32_t phys = addr & 0x1FFFFFFFu; - uint32_t pg = phys >> 12; - uint32_t kern_pg = DIRTY_RAM_KERNEL_WINDOW_END >> 12; - uint32_t ovl_pg = OVERLAY_REGION_FLOOR >> 12; - uint32_t floor_pg = pg < kern_pg ? 0u : (pg < ovl_pg ? kern_pg : ovl_pg); - extern uint32_t dirty_ram_get_bitmap_word(uint32_t word_index); - while (pg > floor_pg) { - uint32_t pp = pg - 1; - if (!((dirty_ram_get_bitmap_word(pp >> 5) >> (pp & 31u)) & 1u)) break; - pg = pp; - } - uint32_t recovered = pg << 12; - uint32_t bucket = (phys * 2654435761u) & LAZY_ENTRY_MASK; - int n = snprintf(out, cap, "{\"recovered_region\":\"0x%08X\",\"entries\":[", - recovered); - int first = 1; - for (int li = s_lazy_entry_head[bucket]; li >= 0 && n < cap - 320; - li = s_lazy_man[li].next_entry) { - LazyMan *lm = &s_lazy_man[li]; - if ((lm->fn.entry & 0x1FFFFFFFu) != phys) continue; - int ci = lm->cache_idx; - const char *base = strrchr(s_cache_idx[ci].path, '/'); - base = base ? base + 1 : s_cache_idx[ci].path; - uint32_t live = lazy_man_crc(&lm->fn); - n += snprintf(out + n, cap - n, - "%s{\"li\":%d,\"ci\":%d,\"region\":\"0x%08X\"," - "\"file\":\"%s\",\"funcs\":%d,\"indexed\":%d," - "\"loaded\":%d,\"crc\":\"0x%08X\",\"live\":\"0x%08X\"," - "\"match\":%d,\"contains\":%d}", - first ? "" : ",", li, ci, s_cache_idx[ci].region_start, - base, s_cache_idx[ci].func_count, s_cache_idx[ci].indexed_func_count, - dll_already_loaded(s_cache_idx[ci].path), lm->fn.crc, live, - live == lm->fn.crc, lazy_man_contains(&lm->fn, phys)); - first = 0; - } - n += snprintf(out + n, cap - n, "]}"); - return n; -} - -void overlay_loader_get_status(int *active, int *registered, - int *regions_checked, - char *cache_dir_out, int cache_dir_len, - char *game_id_out, int game_id_len, - uint32_t *checked_out, int checked_max, - int *checked_written, - uint32_t *last_crc_out, int *last_file_found_out) { - if (active) *active = s_active; - if (registered) *registered = s_valid_count; - if (regions_checked) *regions_checked = s_nchecked; - if (cache_dir_out) strncpy(cache_dir_out, s_cache_dir, (size_t)cache_dir_len - 1); - if (game_id_out) strncpy(game_id_out, s_game_id, (size_t)game_id_len - 1); - if (checked_out && checked_written) { - int n = s_nchecked < checked_max ? s_nchecked : checked_max; - for (int i = 0; i < n; i++) checked_out[i] = s_checked[i]; - *checked_written = n; - } - if (last_crc_out) *last_crc_out = s_last_crc; - if (last_file_found_out) *last_file_found_out = s_last_file_found; -} diff --git a/psxrecomp-overlay/runtime/src/psx_cycles.c b/psxrecomp-overlay/runtime/src/psx_cycles.c deleted file mode 100644 index dfd3b33..0000000 --- a/psxrecomp-overlay/runtime/src/psx_cycles.c +++ /dev/null @@ -1,684 +0,0 @@ -/* psx_cycles.c — PSX guest CPU cycle clock. */ - -#include "psx_cycles.h" -#include "cpu_state.h" -#include -#if defined(_MSC_VER) -#include /* MSVC intrinsics: _BitScanReverse (no __builtin_clz) */ -#endif -#include "cdrom.h" -#include "dma.h" -#include "interrupts.h" -#include "sio.h" -#include "starvation_ring.h" -#include "timers.h" -#ifdef PSX_COSIM -#include "cosim_state.h" -#endif - -uint64_t psx_cycle_count = 0; -uint32_t g_psx_cyc_batch = 0; -uint32_t g_psx_cyc_batch_limit = 0; -int g_psx_cyc_bb_defer = 0; -uint32_t g_psx_cyc_overclock_shift = 0; -uint32_t g_psx_cyc_overclock_carry = 0; -static int s_cycle_replay_active = 0; -static uint64_t s_cycle_replay_live = 0; - -int psx_cycle_replay_begin(uint64_t start_cycle) { - extern int g_ls_replay_active; - if (!g_ls_replay_active || s_cycle_replay_active) return 0; - s_cycle_replay_live = psx_cycle_count; - psx_cycle_count = start_cycle; - s_cycle_replay_active = 1; - return 1; -} - -uint64_t psx_cycle_replay_end(void) { - uint64_t replay_cycle = psx_cycle_count; - if (s_cycle_replay_active) { - psx_cycle_count = s_cycle_replay_live; - s_cycle_replay_active = 0; - } - return replay_cycle; -} - -/* Throttle watchdog check to once per ~64K cycles (header hot path). */ -uint32_t psx_watchdog_throttle = 0; -uint32_t psx_pc_sample_throttle = 0; - -/* Conservative event-granularity diagnostic (set via debug cmd - * overlay_native_event_granularity). Normally psx_advance_cycles charges a - * whole basic block's cycles in ONE step, so every device advances N cycles at - * once and any events that came due at sub-block cycles all fire together, in - * the fixed device order below (sio,cdrom,dma,timers,interrupts) — NOT in true - * due-cycle order. The dirty-RAM interpreter avoids this only because it calls - * us with N=1 per instruction. When this flag is set, a batched (N>1) advance - * is split into N single-cycle steps, so device events fire at their true - * due-cycle in order — i.e. native execution gets the same event timeline the - * interpreter produces. Diagnostic: if the village->overworld blue screen - * clears with this on, the root cause is per-block event-ordering, and the - * real fix is a due-cycle event scheduler (run-to-next-event), not this. */ -int g_event_step_conservative = 0; - -/* Env opt-in (PSX_EVENT_STEP_CONSERVATIVE=1) so the diagnostic is reachable in - * production builds too, where the TCP setter doesn't exist — the determinism - * A/B ("do the fast-limit and conservative paths present the same guest event - * timeline?") needs the production binary to run BOTH ways. */ -void psx_event_step_conservative_env_init(void) { - const char *e = getenv("PSX_EVENT_STEP_CONSERVATIVE"); - if (e && e[0] == '1') g_event_step_conservative = 1; -} - -static void advance_devices(uint32_t c) { - psx_cycle_count += (uint64_t)c; - sio_advance(c); - cdrom_advance(c); - dma_advance(c); - timers_advance(c); - interrupts_advance_cycles(c); -} - -/* ===== Event-deadline device servicing (production fast path) ================= - * - * The faithful core charges guest cycles PER INSTRUCTION (psx_cyc.h §1), and the - * legacy path below walked EVERY device on EVERY charge — ~10 host calls per - * guest instruction, ~300M calls/s: the whole prod build pegged a core inside - * psx_advance_cycles (gdb-sampled 14/14, leaves = timers/cdrom/dma/interrupts). - * - * FAITHFUL_TIMING_PLAN.md's target architecture is the fix: cycles accumulate - * into the ONE global counter cheaply; devices are only SERVICED when the next - * event deadline is reached (cycles_to_next_event(): min over vblank / timer / - * cdrom / dma / sio IRQ distances), or when guest code touches device MMIO - * (memory.c calls psx_devices_mmio_sync() so reads see current state and writes - * that re-arm a device force a deadline recompute). - * - * Guest-visible semantics are IDENTICAL: servicing rewinds psx_cycle_count to - * the devices' synced position and re-plays the gap through the SAME - * vblank-bounded chunk loop the legacy path used, so device code observes the - * exact same cycle values it always did, and no event can fire late because the - * deadline is by construction <= the earliest device event (hard-capped at - * DEADLINE_HARD_CAP for IRQ-masked device progress + frame pacing). - * - * The exact per-charge path is kept verbatim for PSX_COSIM builds (oracle - * determinism / checkpoint alignment) and for the g_event_step_conservative - * diagnostic toggle. */ -#define PSX_DEADLINE_HARD_CAP 16384u - -static uint64_t s_devices_synced_cycle = 0; /* devices are advanced up to here */ -/* Exported for the inlined psx_advance_cycles / psx_cyc_charge hot path. */ -uint64_t psx_next_service_cycle = 0; /* absolute; 0 = dirty, recompute */ -int psx_in_device_service = 0; /* re-entrancy guard */ -#define s_next_service_cycle psx_next_service_cycle -#define s_in_device_service psx_in_device_service -static uint64_t s_next_watchdog = 0; -static uint64_t s_next_pc_sample = 0; - -/* Absolute inclusive limit for dirty_ram_interp.c's exact one-cycle path. - * Zero means that the next charge must visit psx_advance_cycles(). */ -uint64_t g_psx_cycle_fast_limit = 0; - -/* Distance to the nearest INTERNAL device event, mask-blind (i_mask = - * all-unmasked). This is the chunking bound for catch-up: the sio/cdrom/dma - * *_advance() functions fire at most ONE event boundary per call (their - * legacy caller stepped 1 cycle at a time), so every catch-up chunk must land - * exactly ON the nearest event so chained sequences (SIO byte -> ack -> next - * byte; CD sector trains) replay event-by-event, and so events armed while - * their IRQ is masked in I_MASK still process on time (games poll I_STAT). */ -static uint32_t devices_cycles_to_next_internal_event(void) { - uint32_t best = interrupts_cycles_to_vblank(); /* frame pacing always */ - uint32_t t = timers_cycles_to_irq(0xFFFFFFFFu); if (t < best) best = t; - uint32_t c = cdrom_cycles_to_irq(0xFFFFFFFFu); if (c < best) best = c; - uint32_t d = dma_cycles_to_internal_event(); if (d < best) best = d; - uint32_t s = sio_cycles_to_irq(0xFFFFFFFFu); if (s < best) best = s; - if (best == 0) best = 1; /* due/overdue: process within one cycle */ - return best; -} - -/* Wait-loop observation boundary. Device catch-up remains mask-blind and - * replays every intermediate timer/DMA/SIO transition, but a CPU loop with no - * stores or MMIO cannot observe a masked IRQ until it becomes deliverable. - * Stopping at every masked timer target turned a safe VBlank/CD wait skip into - * tens of thousands of host-side re-detections per second. VBlank remains an - * unconditional ceiling so frame pacing is never crossed in one jump. */ -static uint32_t devices_cycles_to_next_idle_event(void) { - extern uint32_t i_mask; - uint32_t best = interrupts_cycles_to_vblank(); - 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_deliverable_irq(i_mask); if (d < best) best = d; - uint32_t s = sio_cycles_to_irq(i_mask); if (s < best) best = s; - if (best == 0) best = 1; - return best; -} - -static void psx_devices_recompute_deadline(void) { - uint32_t next = devices_cycles_to_next_internal_event(); - if (next > PSX_DEADLINE_HARD_CAP) next = PSX_DEADLINE_HARD_CAP; - psx_next_service_cycle = psx_cycle_count + (uint64_t)next; -} - -void psx_devices_service_to_now(void) { - if (s_in_device_service) return; /* device code charged cycles: absorb */ - g_psx_cycle_fast_limit = 0; - s_in_device_service = 1; - uint64_t target = psx_cycle_count; - if (s_devices_synced_cycle < target) { - /* Rewind and re-play the gap in event-bounded chunks so device - * callbacks see the same incremental psx_cycle_count they always did - * and no chunk ever skips OVER a device event boundary. */ - psx_cycle_count = s_devices_synced_cycle; - interrupts_service_scheduled_events(); - while (psx_cycle_count < target) { - uint32_t remaining = (uint32_t)(target - psx_cycle_count); - uint32_t step = remaining; - uint32_t to_ev = devices_cycles_to_next_internal_event(); - if (to_ev > 0 && to_ev < step) step = to_ev; - if (step == 0) step = 1; - /* Preserve interval causality at an advertised event boundary. - * Devices are advanced in a fixed dependency order (CD before DMA, - * MDEC input before output). Passing the whole D-cycle interval to - * each device lets a state created by an earlier device at cycle D - * be treated by a later device as though it existed for all D - * cycles. Advance the quiet prefix first, then the boundary cycle: - * this is exactly the conservative timeline with O(events), not - * O(cycles), host calls. */ - if (step > 1u && to_ev == step) { - advance_devices(step - 1u); - step = 1u; - } - advance_devices(step); - interrupts_service_scheduled_events(); - } - s_devices_synced_cycle = target; - } - psx_devices_recompute_deadline(); - psx_in_device_service = 0; - - /* Sparse host maintenance (was on every psx_advance_cycles). HARD_CAP - * guarantees we land here at least every 16K guest cycles. */ - if (target >= s_next_watchdog) { - s_next_watchdog = target + 65536ull; - psx_cycles_watchdog_fire(); - } - if (target >= s_next_pc_sample) { - s_next_pc_sample = target + 1048576ull; - psx_cycles_pc_sample_fire(); - } -} - -/* memory.c hook: called at the top of every device-MMIO read/write wrapper. - * Catch devices up so the handler sees current state, then recompute the - * deadline immediately (a write may re-arm a shorter event). Previously this - * set psx_next_service_cycle=0 ("dirty"), forcing the *next* per-instruction - * advance through service_to_now even when already synced — MotK FMV pays - * that on every GPU/CD/MDEC MMIO touch. Recompute here instead. */ -void psx_devices_mmio_sync(void) { - psx_cyc_batch_flush(); - if (s_devices_synced_cycle != psx_cycle_count) { - psx_devices_service_to_now(); - } else { - psx_devices_recompute_deadline(); - } -} - -/* Exact per-charge path (legacy semantics). Used by PSX_COSIM builds and the - * g_event_step_conservative diagnostic; also keeps the deadline-path state - * coherent so the two can interleave (the runtime toggle flips mid-run). */ -static void psx_advance_cycles_exact(uint32_t cycles) { -#ifdef PSX_COSIM - /* First-divergence oracle: the guest-cycle counter is the ONLY clock both backends - * share identically, so it is the alignment point for the full-state hash. */ - cosim_tick(); -#endif - interrupts_service_scheduled_events(); - while (cycles > 0) { - uint32_t step = cycles; - if (g_event_step_conservative && step > 1u) { - step = 1u; - } else { - uint32_t to_vblank = interrupts_cycles_to_vblank(); - if (to_vblank > 0 && to_vblank < step) step = to_vblank; -#ifdef PSX_COSIM - uint32_t to_cp = cosim_cycles_to_next_checkpoint(); - if (to_cp > 0 && to_cp < step) step = to_cp; -#endif - } - if (step == 0) step = cycles; - advance_devices(step); - cycles -= step; - interrupts_service_scheduled_events(); -#ifdef PSX_COSIM - cosim_tick(); -#endif - } - s_devices_synced_cycle = psx_cycle_count; - psx_next_service_cycle = 0; -} - -void psx_cycles_watchdog_fire(void) { - starvation_watchdog_check(); -} - -void psx_cycles_pc_sample_fire(void) { - starvation_ring_pc_sample(); -} - -/* Slow path for the inlined psx_advance_cycles (COSIM / lockstep / conservative). */ -void psx_advance_cycles_slow(uint32_t cycles) { - { extern int g_ls_replay_active; - if (g_ls_replay_active) { - /* Replay owns a private-in-time view of the global clock. Advance it - * so GTE/muldiv deadlines and memory wait states see the same time as - * the authoritative pass, but never service devices or observers. */ - if (s_cycle_replay_active) psx_cycle_count += (uint64_t)cycles; - return; - } - } - if (cycles == 0) return; -#ifdef PSX_COSIM - psx_advance_cycles_exact(cycles); -#else - if (g_event_step_conservative) { - psx_advance_cycles_exact(cycles); - } else if (psx_in_device_service) { - psx_cycle_count += (uint64_t)cycles; - } else { - psx_cycle_count += (uint64_t)cycles; - if (psx_next_service_cycle == 0 || psx_cycle_count >= psx_next_service_cycle) { - psx_devices_service_to_now(); - } - } -#endif -#if STARVATION_RING_ENABLED - psx_watchdog_throttle += cycles; - if (psx_watchdog_throttle >= 65536u) { - psx_watchdog_throttle = 0; - starvation_watchdog_check(); - } - psx_pc_sample_throttle += cycles; - if (psx_pc_sample_throttle >= 1048576u) { - psx_pc_sample_throttle = 0; - psx_cycles_pc_sample_fire(); - } -#endif -} - -uint64_t psx_get_cycle_count(void) { - return psx_cycle_count + (uint64_t)g_psx_cyc_batch; -} - -/* ===== Idle-loop cycle skip (wait-loop elision, 2026-07-06) ================== - * - * A pure poll loop (Tomba2 main loop: `do {} while (vbl_count < target)`) - * burns its full guest-cycle budget at real emulation cost even though every - * iteration is a provable no-op: no stores, no MMIO, and register state at - * the loop's interrupt-check boundary identical each time. Nothing such a - * loop can observe changes except via device servicing, which happens at - * deterministic cycle deadlines. So time is fast-forwarded to the next - * INTERNAL device event (mask-blind min over vblank pacing / timers / cdrom / - * dma / sio — the full set of async guest-visible mutators) in whole loop - * quanta, bit-exact vs executing the iterations: - * - real execution takes a deliverable IRQ at the first check boundary at - * or after the event cycle; the skip lands on exactly that boundary - * (k = ceil(dist / quantum) whole iterations). - * - device RAM writes (DMA) replay at their exact cycles inside - * psx_advance_cycles' deadline-servicing path either way. - * - if the event does not release the loop, the detector re-arms and - * skips to the next internal event. - * - * Detection is evidence-based; ALL gates must hold across IDLE_STREAK_MIN - * consecutive interrupt checks at the SAME resume PC with a STABLE quantum: - * 1. zero guest stores (g_guest_store_count unchanged — covers RAM, - * scratchpad, MMIO and DMA writes; memory.c chokepoints) - * 2. zero MMIO accesses (g_mmio_access_count unchanged — an MMIO read can - * be side-effecting or time-varying, strictly disqualifying. I_STAT- - * polling kernel loops therefore never skip; whitelisting side-effect- - * free I_STAT reads is a possible later refinement.) - * 3. identical GPR/hi/lo fingerprint at the check boundary — rules out - * progressing pure-load loops (strlen/memcmp-style scans) whose exit - * depends on register progress, not time. (COP0/GTE-held loop state is - * not fingerprinted; no realistic poll loop keys its exit on those.) - * - * Excluded contexts: exception delivery, bail unwinds, precise slicing, - * lockstep record/replay, and PSX_COSIM builds entirely (the cosim oracle - * compares per-instruction streams; elided iterations would false-diverge). - * - * Opt in: game config, PSX_IDLE_SKIP=1 (env), or the `idle_skip` TCP command. - * The always-on counters below are the observability surface. */ - -enum { - IDLE_STREAK_MIN = 4, /* stable same-PC windows before a skip */ - IDLE_QUANTUM_MAX = 32768, /* interp pump gap fits; junk deltas don't */ - IDLE_SKIP_MAX_CYCLES = 1200000 /* defensive cap (> one vblank interval) */ -}; - -int g_idle_skip_enabled = -1; /* -1 = read PSX_IDLE_SKIP once; TCP-togglable */ -uint64_t g_idle_skip_count = 0; /* skips performed */ -uint64_t g_idle_skip_cycles = 0; /* guest cycles fast-forwarded */ -uint32_t g_idle_skip_last_pc = 0; /* loop PC of the last skip */ -uint32_t g_idle_skip_last_quantum = 0; -/* Overlay CPS code reports several interrupt-safe PCs per logical loop - * iteration. The loader suppresses internal/return observations so they do not - * erase evidence collected at the repeated external poll boundary; interrupt - * delivery still runs normally. */ -int g_idle_note_suppress = 0; - -static uint32_t s_idle_pc = 0; -static uint32_t s_idle_quantum = 0; -static uint32_t s_idle_streak = 0; -static int s_idle_have_snap = 0; /* GPR snapshot valid for s_idle_pc */ -static uint32_t s_idle_gpr[32]; -static uint32_t s_idle_hi = 0, s_idle_lo = 0; -static int s_idle_progress_reg = -2; /* -2 unknown, -1 stable, 1..31 countdown */ -static int32_t s_idle_progress_delta = 0; -static uint64_t s_idle_last_cycle = 0; -static uint64_t s_idle_last_stores = 0; -static uint64_t s_idle_last_mmio = 0; - -static void idle_snapshot_regs(const CPUState *cpu) { - for (int i = 1; i < 32; i++) s_idle_gpr[i] = cpu->gpr[i]; - s_idle_hi = cpu->hi; - s_idle_lo = cpu->lo; -} - -static int idle_skip_on(void) { - if (g_idle_skip_enabled < 0) { - /* No game config reached this process (for example a BIOS-only - * runtime). Keep the enhancement inert unless the environment opts in; - * normal game launches set g_idle_skip_enabled from RuntimeConfig. */ - const char *e = getenv("PSX_IDLE_SKIP"); - g_idle_skip_enabled = e ? (e[0] == '1') : 0; - } - return g_idle_skip_enabled; -} - -int psx_idle_skip_is_enabled(void) { return idle_skip_on(); } - -void psx_idle_note_check(CPUState *cpu, uint32_t check_pc) { -#ifdef PSX_COSIM - (void)cpu; (void)check_pc; - return; -#else - extern int g_psx_call_bail, g_precise_mode, g_ls_mode, g_ls_replay_active; - extern int psx_get_in_exception(void); - extern uint64_t g_guest_store_count, g_mmio_access_count; - - /* A speculative replay must not clear or train the authoritative live idle - * detector. Its clock/device view is transactional and cannot be skipped. */ - if (g_ls_replay_active) return; - if (!idle_skip_on() || g_idle_note_suppress) return; - if (check_pc == 0 || psx_get_in_exception() || g_psx_call_bail || - g_precise_mode || g_ls_mode != 0) { - s_idle_pc = 0; - s_idle_streak = 0; - s_idle_have_snap = 0; - return; - } - - uint64_t cyc = psx_cycle_count; - if (check_pc == s_idle_pc && s_idle_have_snap && - g_guest_store_count == s_idle_last_stores && - g_mmio_access_count == s_idle_last_mmio) { - int changed = -1, changed_count = 0; - for (int i = 1; i < 32; i++) { - if (cpu->gpr[i] != s_idle_gpr[i]) { - changed = i; - changed_count++; - if (changed_count > 1) break; - } - } - if (cpu->hi != s_idle_hi || cpu->lo != s_idle_lo) changed_count = 2; - int32_t progress_delta = changed_count == 1 - ? (int32_t)(cpu->gpr[changed] - s_idle_gpr[changed]) : 0; - /* Besides the original invariant-register loop, accept exactly one - * monotonically decrementing timeout register. Skipped iterations apply - * the same decrements and stop before zero so the real exit branch still - * executes. No stores/MMIO are allowed, so there is no hidden work. */ - int progress_reg = changed_count == 0 ? -1 - : (changed_count == 1 && progress_delta == -1 ? changed : -2); - uint32_t quantum = (uint32_t)(cyc - s_idle_last_cycle); - if (progress_reg != -2 && quantum > 0 && quantum <= IDLE_QUANTUM_MAX) { - if (quantum == s_idle_quantum && - progress_reg == s_idle_progress_reg && - progress_delta == s_idle_progress_delta) { - if (s_idle_streak < 1000000u) s_idle_streak++; - } else { - s_idle_quantum = quantum; - s_idle_progress_reg = progress_reg; - s_idle_progress_delta = progress_delta; - s_idle_streak = 1; - } - } else { - s_idle_streak = 0; - s_idle_quantum = 0; - s_idle_progress_reg = -2; - s_idle_progress_delta = 0; - } - idle_snapshot_regs(cpu); - } else if (check_pc == s_idle_pc && - g_guest_store_count == s_idle_last_stores && - g_mmio_access_count == s_idle_last_mmio) { - /* Second consecutive observation of this PC with no stores/MMIO — - * take the baseline snapshot; the next hit can compare. Defers the - * 31-GPR copy away from branchy VLC/decode edges that change resume - * PC every check (pure host cost, never skipped). */ - idle_snapshot_regs(cpu); - s_idle_have_snap = 1; - s_idle_streak = 0; - s_idle_quantum = 0; - s_idle_progress_reg = -2; - s_idle_progress_delta = 0; - } else { - s_idle_pc = check_pc; - s_idle_streak = 0; - s_idle_quantum = 0; - s_idle_progress_reg = -2; - s_idle_progress_delta = 0; - s_idle_have_snap = 0; - } - s_idle_last_cycle = cyc; - s_idle_last_stores = g_guest_store_count; - s_idle_last_mmio = g_mmio_access_count; - - if (s_idle_streak < IDLE_STREAK_MIN) return; - uint32_t q = s_idle_quantum; - uint32_t dist = devices_cycles_to_next_idle_event(); - if (dist <= q) return; /* one real iteration reaches it */ - uint32_t k = (dist + q - 1u) / q; /* first check boundary >= event */ - if (s_idle_progress_reg > 0) { - uint32_t value = cpu->gpr[s_idle_progress_reg]; - uint32_t max_k = value > 1u ? value - 1u : 0u; - if (k > max_k) k = max_k; - if (k == 0) return; - } - uint64_t skip = (uint64_t)k * q; - if (skip > IDLE_SKIP_MAX_CYCLES) return; - - if (s_idle_progress_reg > 0) - cpu->gpr[s_idle_progress_reg] -= k; - - g_idle_skip_count++; - g_idle_skip_cycles += skip; - g_idle_skip_last_pc = check_pc; - g_idle_skip_last_quantum = q; - psx_advance_cycles((uint32_t)skip); /* replays device events exactly */ - - /* Device servicing may have written RAM / raised I_STAT — the caller - * (psx_check_interrupts) evaluates deliverability right after this with - * the post-skip state. Re-detect from scratch either way. */ - s_idle_streak = 0; - idle_snapshot_regs(cpu); - s_idle_have_snap = 1; - s_idle_last_cycle = psx_cycle_count; - s_idle_last_stores = g_guest_store_count; - s_idle_last_mmio = g_mmio_access_count; -#endif -} - -/* Save-state restore: psx_cycle_count was just overwritten from the snapshot, so - * the deadline-model bookkeeping (synced position + next deadline) is stale and - * would try to replay a bogus gap. Re-anchor devices at the restored cycle and - * force a fresh deadline on the next charge. */ -void psx_cycles_resync_after_restore(void) { - g_psx_cyc_batch = 0; - g_psx_cyc_batch_limit = 0; - g_psx_cyc_bb_defer = 0; - s_devices_synced_cycle = psx_cycle_count; - psx_next_service_cycle = 0; /* recompute on next charge */ - psx_in_device_service = 0; -} - -void psx_cycles_reset_for_boot(void) { - g_psx_cyc_batch = 0; - g_psx_cyc_batch_limit = 0; - g_psx_cyc_bb_defer = 0; - psx_cycle_count = 0; - s_devices_synced_cycle = 0; - s_next_service_cycle = 0; - s_in_device_service = 0; - g_psx_cycle_fast_limit = 0; - s_next_watchdog = 0; - s_next_pc_sample = 0; -} - -/* ---- Mult/div completion-stall timing (faithful R3000A; Beetle muldiv_ts_done) ---- - * - * MULT/MULTU/DIV/DIVU don't stall at the op; they set a completion DEADLINE. - * A later MFLO/MFHI that reads HI/LO before the deadline STALLS (advances guest - * cycles) until it. Instructions executed in between absorb the latency — so the - * stall is (deadline - now), not a flat charge (this is why div+2filler+mflo costs - * the same as div+mflo: the fillers ran during the latency window). REQUIRES - * per-instruction cycle charging (PSX_CODEGEN_CYCLE_PER_INSN / the interp), so - * `now` is the true cycle position at the op — block-up-front charging breaks it. - * - * Latencies transcribed from Beetle cpu.cpp: DIV/DIVU = 37 (fixed). MULT/MULTU = - * MULT_Tab24 indexed by the leading-zero count of the (sign-folded, for signed) - * first operand | 0x400 — i.e. 14 for small magnitudes (<12 significant bits), - * 10 for medium, 7 for large. The | 0x400 caps the index at 21 (never l==0). */ - -static const uint8_t PSX_MULT_TAB24[24] = { - /* i<12: 7+4+3=14 */ 14,14,14,14,14,14,14,14,14,14,14,14, - /* 12<=i<21: 7+3=10 */ 10,10,10,10,10,10,10,10,10, - /* i>=21: 7 */ 7,7,7 -}; - -static inline uint32_t psx_clz32(uint32_t v) { - /* v is never 0 here (callers OR in 0x400). */ -#if defined(_MSC_VER) - unsigned long _idx; - _BitScanReverse(&_idx, v); /* index of highest set bit */ - return (uint32_t)(31u - _idx); -#else - return (uint32_t)__builtin_clz(v); -#endif -} - -uint32_t psx_mult_latency_s(uint32_t rs) { /* MULT (signed): sign-fold magnitude */ - return PSX_MULT_TAB24[psx_clz32((rs ^ (uint32_t)((int32_t)rs >> 31)) | 0x400u)]; -} -uint32_t psx_mult_latency_u(uint32_t rs) { /* MULTU (unsigned) */ - return PSX_MULT_TAB24[psx_clz32(rs | 0x400u)]; -} - -/* DIV/DIVU latency is the fixed constant 37 — emitted directly at the op site. */ - -void psx_muldiv_set(CPUState* cpu, uint32_t latency) { - cpu->muldiv_ts_done = psx_cycle_count + (uint64_t)latency; -} - -void psx_muldiv_stall(CPUState* cpu) { - /* MFLO/MFHI stall to the mult/div completion deadline (Beetle cpu.cpp:1723-1736). - * While stalling it CONSUMES a pending load-delay give-back (read_absorb) — each - * stalled cycle decrements read_absorb[read_absorb_which] — so cycles that would - * have been "free" for following instructions are spent here instead. Plus the - * off-by-one shortcut: a deadline exactly one cycle out just retracts (no stall). - * (No-load code has read_absorb==0, so this reduces to a plain advance.) */ - if (cpu->muldiv_ts_done > psx_cycle_count) { - if (cpu->muldiv_ts_done == psx_cycle_count + 1u) { - cpu->muldiv_ts_done--; /* off-by-one: retract the deadline, no advance */ - return; - } - uint32_t stall = (uint32_t)(cpu->muldiv_ts_done - psx_cycle_count); - uint8_t w = cpu->read_absorb_which; /* fixed during the stall */ - uint32_t give = cpu->read_absorb[w]; - cpu->read_absorb[w] = (uint8_t)(give > stall ? give - stall : 0u); /* consume */ - psx_advance_cycles(stall); - } -} - -/* MFC2/CFC2 (GTE register read → GPR): stall to the GTE command completion deadline - * AND hand the stall amount to the next instruction(s) as a load-delay give-back - * (Beetle cpu.cpp:1332-1341: LDAbsorb = gte_ts_done - timestamp, LDWhich = rt). The - * §1+DO_LDS that bracket this ran in the instruction's psx_cyc_step (COP2 is non-load). - * MTC2/CTC2 (writes) use psx_gte_stall (stall only, no give-back). */ -void psx_gte_read(CPUState* cpu, uint32_t rt) { - if (cpu->gte_ts_done > psx_cycle_count) { - uint32_t stall = (uint32_t)(cpu->gte_ts_done - psx_cycle_count); - cpu->ld_absorb = stall; - psx_advance_cycles(stall); - } else { - cpu->ld_absorb = 0u; - } - cpu->ld_which_t = (uint8_t)rt; -} - -/* ---- GTE (COP2) per-command completion-stall timing ---- - * - * Faithful R3000A/GTE model (Beetle cpu.cpp:1410-1412 + gte.cpp GTE_Instruction - * return(ret-1)). Each GTE command takes `cost` cycles (gte.cpp per-op returns, - * verified from source); the COP2 instruction's own +1 base is charged - * separately by per-instruction charging, so the *added* deadline latency is - * cost-1. A later COP2 register access (MFC2/CFC2/MTC2/CTC2/LWC2/SWC2) stalls - * until the deadline. Back-to-back commands serialize: psx_gte_set stalls to the - * prior deadline before arming the next (Beetle stalls timestamp to gte_ts_done - * at the command site before computing the new gte_ts_done). - * - * Cost table = (cost-1), indexed by the 6-bit GTE command (instr & 0x3F). - * cost values transcribed + verified from beetle-psx/mednafen/psx/gte.cpp op - * returns: RTPS15 RTPT23 MVMVA8 SQR5 OP6 AVSZ3/4=5 NCLIP8 NCDS19 NCDT44 NCCS17 - * NCCT39 NCS14 NCT30 CC11 CDP13 DPCS8 DPCT17 DCPL8 INTPL8 GPF5 GPL5. Unknown/ - * undefined commands = 1 cycle (Beetle default ret=1) -> 0 added. */ -static const uint8_t PSX_GTE_LAT_M1[64] = { - [0x00] = 14, [0x01] = 14, /* RTPS */ - [0x06] = 7, /* NCLIP */ - [0x0C] = 5, /* OP */ - [0x10] = 7, /* DPCS */ - [0x11] = 7, /* INTPL */ - [0x12] = 7, /* MVMVA */ - [0x13] = 18, /* NCDS */ - [0x14] = 12, /* CDP */ - [0x16] = 43, /* NCDT */ - [0x1A] = 7, /* DCPL (alt of 0x29) */ - [0x1B] = 16, /* NCCS */ - [0x1C] = 10, /* CC */ - [0x1E] = 13, /* NCS */ - [0x20] = 29, /* NCT */ - [0x28] = 4, /* SQR */ - [0x29] = 7, /* DCPL */ - [0x2A] = 16, /* DPCT */ - [0x2D] = 4, /* AVSZ3 */ - [0x2E] = 4, /* AVSZ4 */ - [0x30] = 22, /* RTPT */ - [0x3D] = 4, /* GPF */ - [0x3E] = 4, /* GPL */ - [0x3F] = 38, /* NCCT */ -}; - -uint32_t psx_gte_cmd_latency(uint32_t cmd) { - return (uint32_t)PSX_GTE_LAT_M1[cmd & 0x3Fu]; -} - -void psx_gte_set(CPUState* cpu, uint32_t latency) { - /* Back-to-back GTE ops serialize: finish the prior op first. */ - if (cpu->gte_ts_done > psx_cycle_count) { - psx_advance_cycles((uint32_t)(cpu->gte_ts_done - psx_cycle_count)); - } - cpu->gte_ts_done = psx_cycle_count + (uint64_t)latency; -} - -void psx_gte_stall(CPUState* cpu) { - if (cpu->gte_ts_done > psx_cycle_count) { - psx_advance_cycles((uint32_t)(cpu->gte_ts_done - psx_cycle_count)); - } -} diff --git a/psxrecomp-overlay/runtime/src/ws_aspect_cone_math.c b/psxrecomp-overlay/runtime/src/ws_aspect_cone_math.c deleted file mode 100644 index a97019e..0000000 --- a/psxrecomp-overlay/runtime/src/ws_aspect_cone_math.c +++ /dev/null @@ -1,70 +0,0 @@ -#include "ws_aspect_cone_math.h" - -#include - -int32_t psx_ws_widen_angle_units(int32_t vanilla, uint32_t full_turn, - int extent_pixels) { - if (extent_pixels <= 0 || vanilla == 0) return vanilla; - if (full_turn < 4u || (full_turn & 3u) != 0u) return vanilla; - - const int sign = vanilla < 0 ? -1 : 1; - int64_t magnitude = vanilla; - if (magnitude < 0) magnitude = -magnitude; - const int64_t maximum = (int64_t)full_turn / 4 - 1; - if (magnitude > maximum) magnitude = maximum; - - const double tau = 6.283185307179586476925286766559; - const double angle = (double)magnitude * tau / (double)full_turn; - const double scale = (160.0 + (double)extent_pixels) / 160.0; - const double widened = atan(tan(angle) * scale); - long result = lround(widened * (double)full_turn / tau); - if (result < 1) result = 1; - if ((int64_t)result > maximum) result = (long)maximum; - return (int32_t)(sign * result); -} - -uint32_t psx_ws_widen_angle_q12(uint32_t vanilla, int extent_pixels) { - if (vanilla >= 1024u) vanilla = 1023u; - return (uint32_t)psx_ws_widen_angle_units( - (int32_t)vanilla, 4096u, extent_pixels); -} - -int32_t psx_ws_scale_horizontal_320(int32_t value, int extent_pixels) { - if (extent_pixels <= 0 || value == 0) return value; - const int64_t denominator = 160 + (int64_t)extent_pixels; - int64_t numerator = (int64_t)value * 160; - numerator += numerator >= 0 ? denominator / 2 : -denominator / 2; - return (int32_t)(numerator / denominator); -} - -int psx_ws_aspect_cone_contains(int32_t x, int32_t z, int32_t y, - int32_t fx, int32_t fz, int32_t fy, - uint32_t threshold, int extent_pixels) { - if (threshold == 0 || threshold >= 1024 || extent_pixels < 0) - return 0; - - const double fhd = hypot((double)fx, (double)fz); - if (fhd < 1.0) return 0; - - /* Camera-local coordinates. The Q12 scale cancels from the comparisons. - * The right vector is horizontal in world space; the derived up vector is - * perpendicular to both right and the pitched camera-forward vector. */ - const double forward = - ((double)fx * x + (double)fz * z + (double)fy * y) / 4096.0; - if (forward <= 0.0) return 0; - const double horizontal = - ((double)fz * x - (double)fx * z) / fhd; - const double vertical_num = - (double)fx * fy * x - - fhd * fhd * y + - (double)fz * fy * z; - const double vertical = vertical_num / (fhd * 4096.0); - - const double cosine = (double)threshold / 1024.0; - const double tangent = - sqrt((1.0 - cosine * cosine) / (cosine * cosine)); - if (fabs(vertical) > forward * tangent) return 0; - - const double aspect_scale = (160.0 + extent_pixels) / 160.0; - return fabs(horizontal) <= forward * tangent * aspect_scale; -} diff --git a/psxrecomp-overlay/runtime/tests/test_ws_aspect_cone_math.c b/psxrecomp-overlay/runtime/tests/test_ws_aspect_cone_math.c deleted file mode 100644 index 5575a2d..0000000 --- a/psxrecomp-overlay/runtime/tests/test_ws_aspect_cone_math.c +++ /dev/null @@ -1,81 +0,0 @@ -#include "ws_aspect_cone_math.h" - -#include - -static int failures; - -static void check(int condition, const char *name) { - if (condition) { - printf("PASS %s\n", name); - } else { - fprintf(stderr, "FAIL %s\n", name); - failures++; - } -} - -int main(void) { - /* Camera looking along +X, with an approximately 30-degree vanilla cone. */ - const int32_t q12 = 4096; - const uint32_t cos_q10 = 880; - - check(psx_ws_aspect_cone_contains( - 1000, 500, 0, q12, 0, 0, cos_q10, 0), - "vanilla horizontal interior is accepted"); - check(!psx_ws_aspect_cone_contains( - 1000, 650, 0, q12, 0, 0, cos_q10, 0), - "vanilla horizontal exterior is rejected"); - check(psx_ws_aspect_cone_contains( - 1000, 650, 0, q12, 0, 0, cos_q10, 80), - "wide extent expands horizontal acceptance"); - check(!psx_ws_aspect_cone_contains( - 1000, 0, 650, q12, 0, 0, cos_q10, 80), - "wide extent preserves the vertical boundary"); - check(!psx_ws_aspect_cone_contains( - -1000, 0, 0, q12, 0, 0, cos_q10, 160), - "points behind the camera are rejected"); - - /* A 45-degree pitched forward vector still produces orthogonal forward, - * horizontal, and vertical tests. */ - check(psx_ws_aspect_cone_contains( - 1000, 0, 1000, 2896, 0, 2896, cos_q10, 0), - "pitched camera accepts a point on its forward axis"); - check(!psx_ws_aspect_cone_contains( - 1000, 0, -1000, 2896, 0, 2896, cos_q10, 160), - "horizontal expansion does not bypass pitched vertical rejection"); - - check(!psx_ws_aspect_cone_contains( - 1000, 0, 0, q12, 0, 0, 0, 0), - "invalid zero threshold is inert"); - check(!psx_ws_aspect_cone_contains( - 1000, 0, 0, 0, 0, q12, cos_q10, 0), - "degenerate horizontal camera basis is inert"); - - check(psx_ws_widen_angle_q12(341, 0) == 341, - "terrain angle is exact at 4:3"); - check(psx_ws_widen_angle_q12(341, 136) == 533, - "terrain angle follows 21:9 plus guard geometry"); - check(psx_ws_widen_angle_q12(455, 136) == 651, - "wider vanilla terrain angle scales geometrically"); - check(psx_ws_widen_angle_q12(300, 136) == 484, - "lava terrain angle uses the same projection geometry"); - check(psx_ws_widen_angle_q12(318, 80) < - psx_ws_widen_angle_q12(318, 136), - "terrain angle tracks dynamic horizontal extent"); - check(psx_ws_widen_angle_q12(1023, 256) <= 1023, - "terrain angle remains below a quarter turn"); - - check(psx_ws_widen_angle_units(32, 256, 0) == 32 && - psx_ws_widen_angle_units(-32, 256, 0) == -32, - "wrapping yaw offsets are exact at 4:3"); - check(psx_ws_widen_angle_units(32, 256, 53) == 38 && - psx_ws_widen_angle_units(-32, 256, 53) == -38, - "eight-bit yaw cone widens symmetrically for 16:9"); - check(psx_ws_scale_horizontal_320(213, 53) == 160 && - psx_ws_scale_horizontal_320(-213, 53) == -160, - "16:9 horizontal edges map to the original side plane"); - check(psx_ws_scale_horizontal_320(123, 0) == 123 && - psx_ws_scale_horizontal_320(-123, 0) == -123, - "horizontal side-plane scaling is exact at 4:3"); - - return failures ? 1 : 0; -} 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_dev_menu.cpp b/src/disruptor_dev_menu.cpp deleted file mode 100644 index 6a4ffa5..0000000 --- a/src/disruptor_dev_menu.cpp +++ /dev/null @@ -1,1610 +0,0 @@ -/* - * Host-side in-game settings menu for Disruptor. - * - * This overlay is deliberately outside the emulated PlayStation. It draws - * after the completed host presentation. Normal settings never write guest - * VRAM/RAM; the Cheats tab calls only the narrow, version-pinned Disruptor - * 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 "config_loader.h" -#include "gpu.h" -#include "host_ui.h" -#include "psx_keybinds.h" -#include "psx_netplay.h" - -#include "imgui.h" -#include "imgui_impl_opengl3.h" -#if defined(PSX_SDL3) -#include "imgui_impl_sdl3.h" -#else -#include "imgui_impl_sdl2.h" -#endif - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -extern "C" void gte_ws_set_far_threshold(int threshold); -extern "C" void gte_ws_set_backdrop_repair_enabled(int enabled); -extern "C" void gl_renderer_set_world_depth(int enabled); -extern "C" int gl_renderer_world_depth(void); -extern "C" void gl_renderer_set_full_yaw(int enabled); -extern "C" void gl_renderer_set_mesh_view(int enabled); -extern "C" int gl_renderer_mesh_view(void); -extern "C" int gl_renderer_record_shown(const char *folder, int skip, int count, int width); -extern "C" int gl_renderer_recording_shown(void); -extern "C" void gl_renderer_set_gap_fill(int enabled); -extern "C" int gl_renderer_gap_fill(void); -extern "C" void gl_renderer_set_ssao(int enabled); -extern "C" void gl_renderer_set_ssao_view(int view); -extern "C" int gl_renderer_ssao_view(void); -extern "C" int gl_renderer_ssao(void); -extern "C" void gl_renderer_set_ssao_shape(float radius, float strength); -extern "C" float gl_renderer_ssao_radius(void); -extern "C" float gl_renderer_ssao_strength(void); - -namespace { - -struct DevMenuState { - SDL_Window *window = nullptr; - int backend = PSX_HOST_UI_BACKEND_NONE; - bool imgui_ready = false; - bool open = false; - bool restore_mouse_capture = false; - bool aspect_apply_failed = false; - bool internal_scale_apply_failed = false; - bool fullscreen_apply_failed = false; - bool base_keybinds_valid = false; - bool record_shown = false; // armed in the menu, started when it closes - std::string shown_note; - int selected_wide_aspect = 0; - std::string cheat_status; - std::array base_keybinds{}; -}; - -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, -}; - -struct PreferenceState { - fs::path path; - PSXRecompV4::UserSettings pending; - uint32_t dirty = 0; - bool loaded = false; - bool save_failed = false; - std::string status; -}; - -PreferenceState g_preferences; -std::string g_language_disc; - -std::string utf8(const fs::path &path) { - const std::u8string text = path.u8string(); - return {text.begin(), text.end()}; -} - -bool env_override_present(const char *name) { - return name && std::getenv(name) != nullptr; -} - -static constexpr std::array kModernKeybinds = { - SDL_SCANCODE_W, SDL_SCANCODE_S, - SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, - SDL_SCANCODE_RETURN, SDL_SCANCODE_SPACE, - SDL_SCANCODE_F, SDL_SCANCODE_E, - SDL_SCANCODE_Q, SDL_SCANCODE_R, - SDL_SCANCODE_A, SDL_SCANCODE_D, - SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, - SDL_SCANCODE_P, SDL_SCANCODE_TAB, - SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, - SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, - SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, - SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, -}; - -static constexpr std::array kOriginalKeybinds = { - SDL_SCANCODE_UP, SDL_SCANCODE_DOWN, - SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, - SDL_SCANCODE_X, SDL_SCANCODE_S, - SDL_SCANCODE_Z, SDL_SCANCODE_A, - SDL_SCANCODE_Q, SDL_SCANCODE_W, - SDL_SCANCODE_E, SDL_SCANCODE_R, - SDL_SCANCODE_T, SDL_SCANCODE_Y, - SDL_SCANCODE_RETURN, SDL_SCANCODE_RSHIFT, - SDL_SCANCODE_UP, SDL_SCANCODE_DOWN, - SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, - SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, - SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, -}; - -void capture_base_keybinds() { - if (g_menu.base_keybinds_valid) return; - bool is_packaged_modern_preset = true; - for (int i = 0; i < PSX_KB_COUNT; ++i) { - g_menu.base_keybinds[static_cast(i)] = - psx_keybinds_get_button(1, i); - if (g_menu.base_keybinds[static_cast(i)] != - kModernKeybinds[static_cast(i)]) - is_packaged_modern_preset = false; - } - /* run.ps1/run.sh install the packaged modern map for an explicit launch - * flag. Treat that exact map as an overlay, not the restore baseline, so - * turning Modern controls off live really returns to the packaged classic - * mapping. Any custom/non-exact map is preserved verbatim instead. */ - if (is_packaged_modern_preset) - g_menu.base_keybinds = kOriginalKeybinds; - g_menu.base_keybinds_valid = true; -} - -void apply_modern_keybinds(bool enabled) { - capture_base_keybinds(); - if (!enabled) { - for (int i = 0; i < PSX_KB_COUNT; ++i) - psx_keybinds_set_button( - 1, i, g_menu.base_keybinds[static_cast(i)]); - return; - } - - for (int i = 0; i < PSX_KB_COUNT; ++i) - psx_keybinds_set_button( - 1, i, kModernKeybinds[static_cast(i)]); -} - -void set_modern_controls_live(bool enabled) { - apply_modern_keybinds(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_modern_controls(bool value) { - g_preferences.pending.has_modern_controls = true; - g_preferences.pending.modern_controls = value; - g_preferences.dirty |= PREF_MODERN_CONTROLS; -} - -void mark_sensitivity(double value) { - g_preferences.pending.has_horizontal_sensitivity = true; - g_preferences.pending.horizontal_sensitivity = value; - g_preferences.dirty |= PREF_SENSITIVITY; -} - -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; -} - -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; -} - -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; -} - -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; -} - -void mark_geometry(bool value) { - g_preferences.pending.has_geometry_correction = true; - g_preferences.pending.geometry_correction = value; - g_preferences.dirty |= PREF_GEOMETRY; -} - -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_vsync(int value) { - g_preferences.pending.has_vsync = true; - g_preferences.pending.vsync = value; - g_preferences.dirty |= PREF_VSYNC; -} - -void mark_aspect(int numerator, int denominator, bool adaptive) { - g_preferences.pending.has_aspect_ratio = true; - g_preferences.pending.aspect_num = numerator; - g_preferences.pending.aspect_den = denominator; - /* Keep the adaptive flag and its fixed 32:9 cap in the same atomic - * 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; -} - -void mark_supersampling(int value) { - g_preferences.pending.has_supersampling = true; - g_preferences.pending.supersampling = value; - g_preferences.dirty |= PREF_SUPERSAMPLING; -} - -void mark_fullscreen(int value) { - g_preferences.pending.has_fullscreen = true; - g_preferences.pending.fullscreen = value; - g_preferences.dirty |= PREF_FULLSCREEN; -} - -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_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_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_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; -} - -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_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_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_occlusion(bool value) { - g_preferences.pending.has_ambient_occlusion = true; - g_preferences.pending.ambient_occlusion = value; - g_preferences.dirty |= PREF_OCCLUSION; -} - -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; -} - -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_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; -} - -// 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) { - gl_renderer_set_ssao_view(view >= 1 && view <= 4 ? view : 0); - gl_renderer_set_mesh_view(view == 5 ? 1 : 0); -} - -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; -} - -// 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. -void apply_smooth_turn(bool enabled) { - disruptor_high_precision_camera_set_enabled(enabled ? 1 : 0); - 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_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_enabled(bool value) { - g_preferences.pending.has_frame_interpolation = true; - g_preferences.pending.frame_interpolation = value; - g_preferences.dirty |= PREF_PRESENT_ENABLED; -} - -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) { - 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) { - 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.path.empty()) { - g_preferences.save_failed = true; - g_preferences.status = "Runtime settings path is unavailable."; - return false; - } - - PSXRecompV4::UserSettings settings = - PSXRecompV4::load_user_settings(g_preferences.path); - if (settings.parse_error) { - g_preferences.save_failed = true; - g_preferences.status = - "settings.toml is invalid; it was left untouched."; - return false; - } - merge_dirty_preferences(settings); - if (!PSXRecompV4::save_user_settings(g_preferences.path, settings)) { - g_preferences.save_failed = true; - g_preferences.status = - "Could not save settings; the previous file is intact."; - return false; - } - - g_preferences.dirty = 0; - g_preferences.save_failed = false; - g_preferences.status = "Settings saved."; - std::fprintf(stdout, "disruptor: saved in-game settings to %s\n", - utf8(g_preferences.path).c_str()); - return true; -} - -void apply_saved_preferences(const PSXRecompV4::UserSettings &settings) { - /* Force the legacy INI + environment fallback to load before the saved - * layer is applied. Presence (including an explicit zero) makes an - * environment value authoritative for this launch. */ - (void)disruptor_mouse_horizontal_sensitivity(); - (void)disruptor_mouse_invert_horizontal(); - - if (settings.has_mouse_aim && - !env_override_present("PSX_DISRUPTOR_MOUSE_AIM")) - disruptor_mouse_aim_set_enabled(settings.mouse_aim ? 1 : 0); - if (settings.has_modern_controls && - !env_override_present("PSX_DISRUPTOR_MODERN_CONTROLS")) - set_modern_controls_live(settings.modern_controls); - if (settings.has_horizontal_sensitivity && - !env_override_present("PSX_DISRUPTOR_MOUSE_SENSITIVITY")) - (void)disruptor_mouse_set_horizontal_sensitivity( - settings.horizontal_sensitivity); - if (settings.has_invert_horizontal && - !env_override_present("PSX_DISRUPTOR_MOUSE_INVERT_X")) - disruptor_mouse_set_invert_horizontal( - settings.invert_horizontal ? 1 : 0); - if (settings.has_vertical_look && - !env_override_present("PSX_DISRUPTOR_VERTICAL_LOOK")) - disruptor_mouse_set_vertical_look_enabled( - settings.vertical_look ? 1 : 0); - if (settings.has_vertical_sensitivity && - !env_override_present("PSX_DISRUPTOR_MOUSE_SENSITIVITY_Y")) - (void)disruptor_mouse_set_vertical_sensitivity( - settings.vertical_sensitivity); - if (settings.has_invert_vertical && - !env_override_present("PSX_DISRUPTOR_MOUSE_INVERT_Y")) - disruptor_mouse_set_invert_vertical( - settings.invert_vertical ? 1 : 0); - - /* An inherited explicit environment override can enable Modern controls - * without a run-script switch (and therefore without its preset copy). - * Always reconcile the live mode with the in-memory keyboard overlay so - * S/A do not retain conflicting classic face-button bindings. */ - apply_modern_keybinds(disruptor_modern_controls_enabled() != 0); - - /* main.cpp applies these fields before the environment layer. Repeat the - * live application here so a host-UI soft session also gets the saved - * state, while still respecting explicit per-launch overrides. */ - if (settings.has_geometry_correction && - !env_override_present("PSX_GEOMETRY_CORRECTION")) - gpu_geometry_correction_set(settings.geometry_correction ? 1 : 0); - if (settings.has_perspective_textures && - !env_override_present("PSX_TEXTURE_CORRECTION")) { - gpu_texture_correction_set( - settings.perspective_textures && - gpu_geometry_correction_enabled() - ? 1 : 0); - } - if (settings.has_supersampling) - (void)psx_host_video_set_internal_scale(settings.supersampling); - if (settings.has_fullscreen) - g_menu.fullscreen_apply_failed = - psx_host_video_set_fullscreen_mode(settings.fullscreen) == 0; - if (settings.has_master_volume) - (void)psx_host_audio_set_master_volume(settings.master_volume); - if (settings.has_audio_muted) - (void)psx_host_audio_set_muted(settings.audio_muted ? 1 : 0); - if (settings.has_hud_scale) gpu_ws_set_hud_scale(settings.hud_scale); - if (settings.has_frame_unlock && - !env_override_present("PSX_DISRUPTOR_FRAME_UNLOCK")) - disruptor_frame_rate_set_unlocked(settings.frame_unlock ? 1 : 0); - if (settings.has_skip_intro && - !env_override_present("PSX_DISRUPTOR_SKIP_INTRO")) - disruptor_intro_skip_set_enabled(settings.skip_intro ? 1 : 0); - if (settings.has_language_disc) g_language_disc = settings.language_disc; - if (settings.has_improved_shadows && !env_override_present("PSX_DISRUPTOR_IMPROVED_SHADOWS")) - gpu_set_shadow_shape(settings.improved_shadows ? 1 : 0); - if (!env_override_present("PSX_GL_SSAO")) { - if (settings.has_ambient_occlusion) gl_renderer_set_ssao(settings.ambient_occlusion ? 1 : 0); - /* A layer is a session's own. Only a capsule's replay starts in the one its recording was made in. */ - apply_diagnostic_view(settings.has_diagnostic_view && env_override_present("PSX_DISRUPTOR_CAPSULE_REPLAY") - ? settings.diagnostic_view : 0); - } - gl_renderer_set_ssao_shape( - settings.has_occlusion_reach && !env_override_present("PSX_GL_SSAO_RADIUS") - ? static_cast(settings.occlusion_reach) : -1.0f, - settings.has_occlusion_strength && !env_override_present("PSX_GL_SSAO_STRENGTH") - ? settings.occlusion_strength / 100.0f : -1.0f); - 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); - if (settings.has_depth_sorted_world && !env_override_present("PSX_GL_WORLD_DEPTH")) - gl_renderer_set_world_depth(settings.depth_sorted_world ? 1 : 0); - // The session's start switched it off after the mouse module read its variable, so the variable is applied here too. - 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); -} - -void apply_pending_preferences() { - const auto &pending = g_preferences.pending; - if (g_preferences.dirty & PREF_MOUSE_AIM) - disruptor_mouse_aim_set_enabled(pending.mouse_aim ? 1 : 0); - if (g_preferences.dirty & PREF_MODERN_CONTROLS) - set_modern_controls_live(pending.modern_controls); - if (g_preferences.dirty & PREF_SENSITIVITY) - (void)disruptor_mouse_set_horizontal_sensitivity( - pending.horizontal_sensitivity); - if (g_preferences.dirty & PREF_INVERT_X) - disruptor_mouse_set_invert_horizontal( - pending.invert_horizontal ? 1 : 0); - if (g_preferences.dirty & PREF_VERTICAL_LOOK) - disruptor_mouse_set_vertical_look_enabled( - pending.vertical_look ? 1 : 0); - if (g_preferences.dirty & PREF_VERTICAL_SENS) - (void)disruptor_mouse_set_vertical_sensitivity( - pending.vertical_sensitivity); - if (g_preferences.dirty & PREF_INVERT_Y) - disruptor_mouse_set_invert_vertical( - pending.invert_vertical ? 1 : 0); - if (g_preferences.dirty & PREF_GEOMETRY) - gpu_geometry_correction_set(pending.geometry_correction ? 1 : 0); - if (g_preferences.dirty & PREF_TEXTURES) - gpu_texture_correction_set( - pending.perspective_textures && - gpu_geometry_correction_enabled() - ? 1 : 0); - if (g_preferences.dirty & PREF_VSYNC) - (void)psx_host_video_set_vsync(pending.vsync); - if (g_preferences.dirty & PREF_ASPECT) { - 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) - (void)psx_host_video_set_internal_scale(pending.supersampling); - if (g_preferences.dirty & PREF_FULLSCREEN) - g_menu.fullscreen_apply_failed = - psx_host_video_set_fullscreen_mode(pending.fullscreen) == 0; - if (g_preferences.dirty & PREF_MASTER_VOLUME) - (void)psx_host_audio_set_master_volume(pending.master_volume); - if (g_preferences.dirty & PREF_AUDIO_MUTED) - (void)psx_host_audio_set_muted(pending.audio_muted ? 1 : 0); - if (g_preferences.dirty & PREF_HUD_SCALE) - gpu_ws_set_hud_scale(pending.hud_scale); - if (g_preferences.dirty & PREF_FRAME_UNLOCK) - disruptor_frame_rate_set_unlocked(pending.frame_unlock ? 1 : 0); - if (g_preferences.dirty & PREF_SKIP_INTRO) - disruptor_intro_skip_set_enabled(pending.skip_intro ? 1 : 0); - if (g_preferences.dirty & PREF_SHADOW_SHAPE) - gpu_set_shadow_shape(pending.improved_shadows ? 1 : 0); - if (g_preferences.dirty & PREF_OCCLUSION) - gl_renderer_set_ssao(pending.ambient_occlusion ? 1 : 0); - if (g_preferences.dirty & PREF_OCCLUSION_SHAPE) - gl_renderer_set_ssao_shape(static_cast(pending.occlusion_reach), pending.occlusion_strength / 100.0f); - if (g_preferences.dirty & PREF_GAP_FILLING) - gl_renderer_set_gap_fill(pending.gap_filling ? 1 : 0); - if (g_preferences.dirty & PREF_WORLD_DEPTH) - gl_renderer_set_world_depth(pending.depth_sorted_world ? 1 : 0); - if (g_preferences.dirty & PREF_DIAGNOSTIC_VIEW) - apply_diagnostic_view(pending.diagnostic_view); - if (g_preferences.dirty & PREF_SMOOTH_TURN) - apply_smooth_turn(pending.high_precision_camera); -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - if (g_preferences.dirty & PREF_PRESENT_ENABLED) - (void)disruptor_present_rate_enable(pending.frame_interpolation ? 1 : 0); - if (g_preferences.dirty & PREF_PRESENT_RATE) - (void)disruptor_present_rate_apply(pending.frame_interpolation_fps); -#endif -} - -void load_preferences_for_session() { - const char *path = psx_host_user_settings_path(); - if (!path || !path[0]) { - g_preferences.loaded = false; - g_preferences.status = "Runtime settings path is unavailable."; - return; - } - const std::string_view text(path); - g_preferences.path = fs::path(std::u8string(text.begin(), text.end())); - const PSXRecompV4::UserSettings settings = - PSXRecompV4::load_user_settings(g_preferences.path); - if (settings.parse_error) { - g_preferences.loaded = false; - g_preferences.status = - "settings.toml is invalid; saved menu values were not applied."; - return; - } - capture_base_keybinds(); - apply_saved_preferences(settings); - apply_pending_preferences(); - /* A capsule recorded later copies the file: a layer left there by the last session must not go into it. */ - if (settings.has_diagnostic_view && settings.diagnostic_view != 0 && !env_override_present("PSX_DISRUPTOR_CAPSULE_REPLAY")) { - mark_diagnostic_view(0); - (void)flush_preferences(); - } - g_preferences.loaded = true; - if (!g_preferences.save_failed) - g_preferences.status = "Settings loaded."; -} - -bool imgui_sdl_init(SDL_Window *window) { -#if defined(PSX_SDL3) - return ImGui_ImplSDL3_InitForOpenGL(window, SDL_GL_GetCurrentContext()); -#else - return ImGui_ImplSDL2_InitForOpenGL(window, SDL_GL_GetCurrentContext()); -#endif -} - -void imgui_sdl_shutdown() { -#if defined(PSX_SDL3) - ImGui_ImplSDL3_Shutdown(); -#else - ImGui_ImplSDL2_Shutdown(); -#endif -} - -void imgui_sdl_new_frame() { -#if defined(PSX_SDL3) - ImGui_ImplSDL3_NewFrame(); -#else - ImGui_ImplSDL2_NewFrame(); -#endif -} - -void imgui_sdl_process_event(const SDL_Event *event) { -#if defined(PSX_SDL3) - (void)ImGui_ImplSDL3_ProcessEvent(event); -#else - (void)ImGui_ImplSDL2_ProcessEvent(event); -#endif -} - -void apply_disruptor_style() { - ImGui::StyleColorsDark(); - ImGuiStyle &style = ImGui::GetStyle(); - style.WindowRounding = 5.0f; - style.ChildRounding = 4.0f; - style.FrameRounding = 3.0f; - style.PopupRounding = 4.0f; - style.ScrollbarRounding = 4.0f; - style.GrabRounding = 3.0f; - - const ImVec4 green(0.20f, 0.78f, 0.34f, 1.00f); - const ImVec4 green_hover(0.28f, 0.90f, 0.43f, 1.00f); - const ImVec4 green_active(0.14f, 0.62f, 0.27f, 1.00f); - style.Colors[ImGuiCol_CheckMark] = green_hover; - style.Colors[ImGuiCol_SliderGrab] = green; - style.Colors[ImGuiCol_SliderGrabActive] = green_hover; - style.Colors[ImGuiCol_Header] = ImVec4(0.10f, 0.38f, 0.18f, 1.00f); - style.Colors[ImGuiCol_HeaderHovered] = green_active; - style.Colors[ImGuiCol_HeaderActive] = green; - style.Colors[ImGuiCol_Button] = ImVec4(0.10f, 0.34f, 0.17f, 1.00f); - style.Colors[ImGuiCol_ButtonHovered] = green_active; - style.Colors[ImGuiCol_ButtonActive] = green; -} - -bool initialize_imgui() { - if (g_menu.imgui_ready) return true; - if (!g_menu.window || g_menu.backend != PSX_HOST_UI_BACKEND_OPENGL) - return false; - - IMGUI_CHECKVERSION(); - ImGui::CreateContext(); - ImGuiIO &io = ImGui::GetIO(); - io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; - /* Persist only the reviewed gameplay/presentation fields below. ImGui's - * ambient layout file is deliberately disabled: it is relative-path, - * independently written, and outside our atomic settings contract. */ - io.IniFilename = nullptr; - apply_disruptor_style(); - - if (!imgui_sdl_init(g_menu.window)) { - ImGui::DestroyContext(); - std::fprintf(stderr, - "disruptor: developer menu SDL backend initialization failed\n"); - return false; - } - if (!ImGui_ImplOpenGL3_Init("#version 330 core")) { - imgui_sdl_shutdown(); - ImGui::DestroyContext(); - std::fprintf(stderr, - "disruptor: developer menu OpenGL initialization failed\n"); - return false; - } - - g_menu.imgui_ready = true; - std::fprintf(stdout, - "disruptor: in-game settings menu ready (press ` to open)\n"); - return true; -} - -// A second after the menu closes, two seconds of what is swapped to the screen go to shown// beside settings.toml. -void start_shown_recording() { - g_menu.record_shown = false; - const auto now = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); - const fs::path folder = g_preferences.path.parent_path() / "shown" / std::to_string(now); - std::error_code failed; - fs::create_directories(folder, failed); - if (failed) { - g_menu.shown_note = "The folder for the pictures could not be made."; - return; - } - g_menu.shown_note = gl_renderer_record_shown(utf8(folder).c_str(), 120, 240, 960) ? "Last recording: " + utf8(folder) - : "The recording could not start."; -} - -void set_menu_open(bool open) { - if (open == g_menu.open) return; - if (open) { - g_menu.restore_mouse_capture = disruptor_mouse_captured() != 0; - if (g_menu.restore_mouse_capture) - (void)disruptor_mouse_set_captured(0); - g_menu.open = true; - } else { - g_menu.open = false; - (void)flush_preferences(); - if (g_menu.record_shown) start_shown_recording(); - if (g_menu.restore_mouse_capture) - (void)disruptor_mouse_set_captured(1); - g_menu.restore_mouse_capture = false; - } -} - -bool scancode_event(const SDL_Event *event, SDL_Scancode scancode) { - if (!event || event->type != SDL_KEYDOWN || event->key.repeat) return false; -#if defined(PSX_SDL3) - return event->key.scancode == scancode; -#else - return event->key.keysym.scancode == scancode; -#endif -} - -void draw_status_badge(const char *label, const ImVec4 &colour) { - ImGui::SameLine(); - ImGui::TextColored(colour, "[%s]", label); -} - -void draw_controls_tab() { - bool mouse_aim = disruptor_mouse_aim_enabled() != 0; - if (ImGui::Checkbox("Horizontal 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)) { - set_modern_controls_live(modern); - mark_modern_controls(modern); - } - draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - - float sensitivity = - static_cast(disruptor_mouse_horizontal_sensitivity()); - if (ImGui::SliderFloat("Horizontal sensitivity", &sensitivity, - 0.005f, 2.0f, "%.3f", - ImGuiSliderFlags_Logarithmic)) { - 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)) { - 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; - bool smooth = disruptor_high_precision_camera_enabled() != 0; - if (!turn_applies) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Smooth mouse turning", &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)) { - disruptor_mouse_set_vertical_look_enabled(vertical_look ? 1 : 0); - mark_vertical_look(vertical_look); - } - draw_status_badge("EXPERIMENTAL", ImVec4(1.0f, 0.73f, 0.25f, 1.0f)); - - float vertical_sensitivity = - static_cast(disruptor_mouse_vertical_sensitivity()); - if (ImGui::SliderFloat("Vertical sensitivity", &vertical_sensitivity, - 0.005f, 2.0f, "%.3f", - ImGuiSliderFlags_Logarithmic)) { - 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)) { - disruptor_mouse_set_invert_vertical(invert_y ? 1 : 0); - mark_invert_y(invert_y); - } - - const double pitch_units = disruptor_mouse_vertical_pitch(); - if (pitch_units == 0.0) ImGui::BeginDisabled(); - if (ImGui::Button("Recenter vertical view")) - disruptor_mouse_recenter_vertical(); - if (pitch_units == 0.0) ImGui::EndDisabled(); - ImGui::SameLine(); - ImGui::Text("Requested pitch: %+.2f deg", pitch_units * (360.0 / 256.0)); - ImGui::TextDisabled( - "Range: +/-30.94 degrees. The original game has no pitch state."); - ImGui::Separator(); - ImGui::TextWrapped( - "The menu releases relative mouse capture while open and restores the " - "previous state when closed. Gameplay input is neutralised while you " - "interact with the menu."); - ImGui::Text("Capture before opening: %s", - g_menu.restore_mouse_capture ? "yes" : "no"); -} - -void apply_geometry_enabled(bool enabled) { - if (!enabled && gpu_texture_correction_enabled()) { - gpu_texture_correction_set(0); - mark_textures(false); - } -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - if (!enabled) { - (void)disruptor_present_rate_enable(0); - mark_present_enabled(false); - } -#endif - gpu_geometry_correction_set(enabled ? 1 : 0); - mark_geometry(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; - - int numerator = 4; - int denominator = 3; - psx_host_video_get_display_aspect(&numerator, &denominator); - const bool adaptive = psx_host_video_get_adaptive_view() != 0; - bool widescreen = adaptive || numerator * 3 != denominator * 4; - if (adaptive) { - g_menu.selected_wide_aspect = kAdaptiveAspectIndex; - } else if (widescreen) { - for (int index = 0; index < kFixedWideAspectCount; ++index) { - if (numerator * kAspectDenominators[index] == - denominator * kAspectNumerators[index]) { - g_menu.selected_wide_aspect = index; - break; - } - } - } - g_menu.selected_wide_aspect = std::clamp( - g_menu.selected_wide_aspect, 0, kAspectCount - 1); - - ImGui::SeparatorText("Display aspect"); - if (ImGui::Checkbox("Widescreen", &widescreen)) { - if (widescreen && - g_menu.selected_wide_aspect == kAdaptiveAspectIndex) { - g_menu.aspect_apply_failed = - psx_host_video_set_adaptive_view(1) == 0; - if (!g_menu.aspect_apply_failed) - mark_aspect(32, 9, true); - } else { - const int requested_num = widescreen - ? kAspectNumerators[g_menu.selected_wide_aspect] : 4; - const int requested_den = widescreen - ? kAspectDenominators[g_menu.selected_wide_aspect] : 3; - g_menu.aspect_apply_failed = - psx_host_video_set_display_aspect( - requested_num, requested_den) == 0; - if (!g_menu.aspect_apply_failed) - mark_aspect(requested_num, requested_den, false); - } - } - 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)) { - g_menu.selected_wide_aspect = selected; - if (widescreen) { - if (selected == kAdaptiveAspectIndex) { - g_menu.aspect_apply_failed = - psx_host_video_set_adaptive_view(1) == 0; - if (!g_menu.aspect_apply_failed) - mark_aspect(32, 9, true); - } else { - const int requested_num = kAspectNumerators[selected]; - const int requested_den = kAspectDenominators[selected]; - g_menu.aspect_apply_failed = - psx_host_video_set_display_aspect( - requested_num, requested_den) == 0; - if (!g_menu.aspect_apply_failed) - mark_aspect(requested_num, requested_den, false); - } - } - } - if (g_menu.aspect_apply_failed) { - ImGui::TextColored( - ImVec4(1.0f, 0.35f, 0.25f, 1.0f), - "The runtime rejected the requested display aspect."); - } - ImGui::TextDisabled( - "4:3 disables widescreen. Fixed 16:9, 21:9 and 32:9 choices or " - "live window matching (capped at 32:9) apply after the current " - "frame and are saved."); - - if (gpu_sprite_placement_available()) { - int shadow_shape = gpu_shadow_shape(); - ImGui::SetNextItemWidth(260.0f); - if (ImGui::Combo("Shadows", &shadow_shape, "Vanilla\0Improved\0")) { - 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)) { - 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)) { - const int requested = selected + 1; - g_menu.internal_scale_apply_failed = - psx_host_video_set_internal_scale(requested) == 0; - if (!g_menu.internal_scale_apply_failed) - mark_supersampling(requested); - } - draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - if (g_menu.internal_scale_apply_failed) { - ImGui::TextColored( - ImVec4(1.0f, 0.35f, 0.25f, 1.0f), - "The renderer could not allocate that internal resolution."); - } - ImGui::TextDisabled( - "Supersamples geometry and shading before presentation. GPU fill and " - "memory cost grow roughly with the square of this value; changes " - "apply immediately and are saved."); -} - -void draw_enhancements_tab() { - draw_aspect_controls(); - draw_internal_resolution_controls(); - - bool geometry = gpu_geometry_correction_enabled() != 0; - if (ImGui::Checkbox("Exact-provenance geometry", &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)) { - gpu_texture_correction_set(textures ? 1 : 0); - mark_textures(textures); - } - if (!geometry) ImGui::EndDisabled(); - draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - if (!geometry) - 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)) { - gl_renderer_set_gap_fill(gaps ? 1 : 0); - mark_gap_filling(gaps); - } - if (!geometry) 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)) { - gl_renderer_set_world_depth(by_depth ? 1 : 0); - mark_world_depth(by_depth); - } - if (!geometry) 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)) { - 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)) { - disruptor_frame_rate_set_unlocked(unlocked ? 1 : 0); - mark_frame_unlock(unlocked); - } - draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - DisruptorFrameRateWindow window{}; - if (disruptor_frame_rate_last_window(&window) && window.vblanks != 0u) { - ImGui::TextDisabled( - "Game frames: %.1f per second. Frame work: %.2f VBlank average, " - "%.2f peak, %u of %u frames over one VBlank.", - 59.94 * window.frames / window.vblanks, - window.average_work_permille / 1000.0, - window.peak_work_permille / 1000.0, - window.late_frames, window.frames); - } - -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - int present_enabled = 0; - if (disruptor_present_rate_switch(geometry ? 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); -#endif - - ImGui::SeparatorText("Startup"); - bool skip_intro = disruptor_intro_skip_enabled() != 0; - if (ImGui::Checkbox("Skip the logos and the title movie", &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) { - switch (result) { - case DISRUPTOR_CHEAT_OK: - return success; - case DISRUPTOR_CHEAT_GAME_NOT_READY: - return "Enter live gameplay before using this cheat."; - case DISRUPTOR_CHEAT_NETPLAY_BLOCKED: - return "Cheats are disabled during netplay."; - case DISRUPTOR_CHEAT_UNVERIFIED_STATE: - return "The game state did not match the verified USA executable; no changes were made."; - default: - return "The cheat request was rejected."; - } -} - -void draw_experimental_tab() { - ImGui::TextDisabled( - "Unfinished features of the OpenGL renderer. All of them need geometry " - "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)) { - gl_renderer_set_ssao(occlusion ? 1 : 0); - mark_occlusion(occlusion); - } - if (!geometry) 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(); - ImGui::SetNextItemWidth(260.0f); - const bool reach_moved = ImGui::SliderInt("Occlusion reach", &reach, 50, 800, "%d", ImGuiSliderFlags_AlwaysClamp); - ImGui::SetNextItemWidth(260.0f); - const bool strength_moved = - ImGui::SliderInt("Occlusion strength", &strength, 0, 300, "%d%%", ImGuiSliderFlags_AlwaysClamp); - if (!geometry) 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() { - ImGui::TextDisabled("Tools for a report of a fault in the picture."); - ImGui::Spacing(); - - if (ImGui::Button("Record the screen") && !gl_renderer_recording_shown()) g_menu.record_shown = true; - draw_status_badge("SESSION", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled( - "For a report: two seconds of the pictures that reach the screen, with " - "their times. It starts a second after this menu closes."); - if (g_menu.record_shown) ImGui::TextDisabled("Armed. Close the menu and do what you want to show."); - else if (!g_menu.shown_note.empty()) ImGui::TextDisabled("%s", g_menu.shown_note.c_str()); - - ImGui::Separator(); - int view = gl_renderer_mesh_view() ? 5 : gl_renderer_ssao_view(); - ImGui::SetNextItemWidth(260.0f); - if (ImGui::Combo("Diagnostic view", &view, "Off\0Shading alone\0Depth\0Surface normals\0Fog fade\0Mesh\0")) { - apply_diagnostic_view(view); - mark_diagnostic_view(view); - } - draw_status_badge("SESSION", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled( - "Shows a layer of the renderer in the picture's place, for reports. A " - "replay capsule recorded with one on replays in it. Off again at the " - "next start."); -} - -void draw_cheats_tab() { - const bool netplay = disruptor_cheats_netplay_blocked() != 0; - const bool gameplay_ready = disruptor_cheats_gameplay_ready() != 0; - - ImGui::TextWrapped( - "These are version-pinned gameplay cheats for testing and casual " - "play. They are never saved as port settings and start off on every " - "launch."); - if (netplay) { - ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.25f, 1.0f), - "Cheats are unavailable while netplay is active."); - } else if (!gameplay_ready) { - ImGui::TextDisabled("Enter live gameplay to enable cheats."); - } - - ImGui::SeparatorText("Player"); - bool god_mode = disruptor_cheats_god_mode_enabled() != 0; - const bool disable_god_control = !god_mode && (netplay || !gameplay_ready); - if (disable_god_control) ImGui::BeginDisabled(); - if (ImGui::Checkbox("God mode", &god_mode)) { - const int result = disruptor_cheats_set_god_mode(god_mode ? 1 : 0); - g_menu.cheat_status = cheat_result_message( - result, god_mode ? "God mode enabled for this session." - : "God mode disabled."); - } - if (disable_god_control) ImGui::EndDisabled(); - draw_status_badge("SESSION", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled( - "Neutralises player damage at the game's central damage routine. " - "A light hit cue remains, but saved health is not altered and the " - "game is not marked as cheated."); - - ImGui::SeparatorText("Inventory"); - if (netplay || !gameplay_ready) ImGui::BeginDisabled(); - if (ImGui::Button("Grant all weapons + psionics")) - ImGui::OpenPopup("Confirm All Weapons"); - if (netplay || !gameplay_ready) ImGui::EndDisabled(); - ImGui::TextWrapped( - "This reproduces the retail All Weapons cheat: all weapons and " - "psionics are unlocked, ammunition and psionic energy are refilled, " - "and the current game is marked as cheated."); - ImGui::TextColored(ImVec4(1.0f, 0.73f, 0.25f, 1.0f), - "Important: the cheated marker affects the ending and is carried into " - "memory-card/password saves."); - - bool popup_open = true; - if (ImGui::BeginPopupModal("Confirm All Weapons", &popup_open, - ImGuiWindowFlags_AlwaysAutoResize)) { - ImGui::TextWrapped( - "Grant every weapon and psionic, refill their resources, and " - "mark this game (including later saves) as cheated?"); - if (ImGui::Button("Grant and mark cheated")) { - const int result = disruptor_cheats_grant_all_weapons(); - g_menu.cheat_status = cheat_result_message( - result, "All weapons, psionics and resources granted. Game marked as cheated."); - ImGui::CloseCurrentPopup(); - } - ImGui::SameLine(); - if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); - ImGui::EndPopup(); - } - - if (!g_menu.cheat_status.empty()) { - ImGui::Separator(); - ImGui::TextWrapped("%s", g_menu.cheat_status.c_str()); - } -} - -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)) { - g_menu.fullscreen_apply_failed = - psx_host_video_set_fullscreen_mode(mode) == 0; - if (!g_menu.fullscreen_apply_failed) - mark_fullscreen(mode); - } - draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - if (g_menu.fullscreen_apply_failed) { - ImGui::TextColored( - ImVec4(1.0f, 0.35f, 0.25f, 1.0f), - "The runtime rejected the requested display mode."); - } - ImGui::TextDisabled( - "Menu selections apply immediately and are saved. Alt+Enter remains " - "a transient window/fullscreen toggle and does not overwrite the " - "saved mode."); -} - -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) && - 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) && - psx_host_audio_set_muted(muted ? 1 : 0)) { - mark_audio_muted(muted); - } - ImGui::TextDisabled( - "Host-side output only: game audio state and saves are unchanged. " - "Unmuting restores the selected volume."); -} - -void draw_system_tab() { - draw_fullscreen_controls(); - draw_audio_controls(); - ImGui::TextWrapped( - "Live menu choices are merged into the runtime's user-owned " - "settings.toml. Writes use an atomic replacement, so a failed save " - "leaves the previous file intact. Explicit launch flags still win " - "for that run."); - ImGui::SeparatorText("Settings persistence"); - ImGui::TextWrapped("Path: %s", - g_preferences.path.empty() - ? "unavailable" : utf8(g_preferences.path).c_str()); - if (g_preferences.save_failed) - ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.25f, 1.0f), - "Not saved"); - 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")) - (void)flush_preferences(); - ImGui::TextDisabled( - "Current vertical pitch, menu layout, " - "and mouse capture are not saved. Cheat controls start " - "off each launch."); - ImGui::SeparatorText("Restart required"); - ImGui::BulletText("Renderer backend"); - ImGui::BulletText("Audio backend and buffer configuration"); - ImGui::BulletText("BIOS, disc and memory-card wiring"); - ImGui::Separator(); - if (ImGui::Button("Close menu")) set_menu_open(false); -} - -void draw_language_tab() { - ImGui::TextWrapped("Disc of another region: %s", g_language_disc.empty() ? "none, the game is in English" : g_language_disc.c_str()); - if (ImGui::Button("Choose a disc image")) (void)psx_host_pick_disc_image_begin(); - ImGui::SameLine(); - if (ImGui::Button("English") && !g_language_disc.empty()) mark_language_disc(std::string()); - ImGui::SameLine(); - if (ImGui::Button("Restart now") && !psx_netplay_active() && flush_preferences() && disruptor_restart_arm()) { - SDL_Event quit{}; - quit.type = SDL_QUIT; - SDL_PushEvent(&quit); - } - ImGui::TextDisabled("Movies, speech and words come from that disc at the next start. The French, German and Japanese discs are known."); - ImGui::TextDisabled("The Japanese disc gives movies, speech and hints: its menus are in English on the disc itself."); -} - -void draw_menu() { - char picked[4096]; - if (psx_host_pick_disc_image_poll(picked, static_cast(sizeof picked)) == 1) mark_language_disc(picked); - bool keep_open = g_menu.open; - ImGui::SetNextWindowSize(ImVec2(760.0f, 560.0f), - ImGuiCond_FirstUseEver); - if (ImGui::Begin("Disruptor Settings", &keep_open, - ImGuiWindowFlags_NoCollapse)) { - ImGui::PushTextWrapPos(0.0f); // a long note wraps at the window's edge, where it used to be cut off - ImGui::TextDisabled("Press ` to toggle; Escape closes the menu."); - ImGui::TextColored(ImVec4(1.0f, 0.73f, 0.25f, 1.0f), - "The game is not paused while this menu is open."); - - if (ImGui::BeginTabBar("DisruptorDevTabs")) { - if (ImGui::BeginTabItem("Controls")) { - draw_controls_tab(); - ImGui::EndTabItem(); - } - if (ImGui::BeginTabItem("Enhancements")) { - draw_enhancements_tab(); - ImGui::EndTabItem(); - } - if (ImGui::BeginTabItem("Experimental")) { - draw_experimental_tab(); - ImGui::EndTabItem(); - } - if (ImGui::BeginTabItem("Language")) { - draw_language_tab(); - ImGui::EndTabItem(); - } - if (ImGui::BeginTabItem("System")) { - draw_system_tab(); - ImGui::EndTabItem(); - } - if (ImGui::BeginTabItem("Debug")) { - draw_debug_tab(); - ImGui::EndTabItem(); - } - if (ImGui::BeginTabItem("Cheats")) { - draw_cheats_tab(); - ImGui::EndTabItem(); - } - ImGui::EndTabBar(); - } - ImGui::PopTextWrapPos(); - } - ImGui::End(); - if (!keep_open && g_menu.open) set_menu_open(false); -} - -void on_runtime_ready(void *, SDL_Window *window, int backend) { - disruptor_cheats_reset_session(); - disruptor_far_rendering_reset_session(); - disruptor_billboard_aspect_set_site_mask( - disruptor_billboard_aspect_all_site_mask()); - disruptor_mouse_recenter_vertical(); - disruptor_high_precision_camera_set_enabled(0); - gte_ws_set_far_threshold(900); - gte_ws_set_backdrop_repair_enabled(0); - g_menu.window = window; - g_menu.backend = backend; - load_preferences_for_session(); - if (backend == PSX_HOST_UI_BACKEND_OPENGL) (void)initialize_imgui(); -} - -void on_runtime_shutdown(void *) { - disruptor_cheats_reset_session(); - disruptor_far_rendering_reset_session(); - disruptor_billboard_aspect_set_site_mask( - disruptor_billboard_aspect_all_site_mask()); - disruptor_mouse_recenter_vertical(); - disruptor_high_precision_camera_set_enabled(0); - gte_ws_set_far_threshold(900); - gte_ws_set_backdrop_repair_enabled(0); - (void)flush_preferences(); - if (disruptor_mouse_captured()) - (void)disruptor_mouse_set_captured(0); - if (g_menu.base_keybinds_valid) - apply_modern_keybinds(false); - g_menu.open = false; - g_menu.restore_mouse_capture = false; - if (g_menu.imgui_ready) { - ImGui_ImplOpenGL3_Shutdown(); - imgui_sdl_shutdown(); - ImGui::DestroyContext(); - } - g_menu = DevMenuState{}; -} - -int on_sdl_event(void *, const SDL_Event *event) { - const bool toggle = scancode_event(event, SDL_SCANCODE_GRAVE); - const bool close = - g_menu.open && scancode_event(event, SDL_SCANCODE_ESCAPE); - if (toggle || close) { - set_menu_open(toggle ? !g_menu.open : false); - return 1; - } - if (!g_menu.open && scancode_event(event, SDL_SCANCODE_ESCAPE)) - disruptor_intro_skip_request(); - if (!g_menu.open && scancode_event(event, SDL_SCANCODE_F12) && (SDL_GetModState() & KMOD_CTRL) != 0) { - disruptor_capsule_toggle(); - return 1; - } - if (!g_menu.open || !g_menu.imgui_ready) return 0; - imgui_sdl_process_event(event); - return 1; -} - -uint32_t current_flags(void *) { - if (!g_menu.open || !g_menu.imgui_ready) return disruptor_capsule_notice() ? PSX_HOST_UI_VISIBLE : 0u; - return PSX_HOST_UI_CAPTURE_KEYBOARD | - PSX_HOST_UI_CAPTURE_MOUSE | - PSX_HOST_UI_CAPTURE_GAMEPAD | - PSX_HOST_UI_VISIBLE; -} - -/* Drawn without the SDL backend's frame: that one reads and captures the mouse, which is the game's while the menu is closed. */ -void render_notice(const char *word, int width, int height) { - ImGui_ImplOpenGL3_NewFrame(); - ImGuiIO &io = ImGui::GetIO(); - io.DisplaySize = ImVec2(static_cast(width), static_cast(height)); - io.DisplayFramebufferScale = ImVec2(1.0f, 1.0f); - io.DeltaTime = 1.0f / 60.0f; - ImGui::NewFrame(); - ImGui::SetNextWindowPos(ImVec2(16.0f, 16.0f)); - ImGui::Begin("##capsule", nullptr, - ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings | - ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoFocusOnAppearing); - ImGui::SetWindowFontScale(std::max(1.0f, static_cast(height) / 360.0f)); - ImGui::TextColored(ImVec4(1.0f, 0.25f, 0.2f, 1.0f), "%s", word); - ImGui::End(); - ImGui::Render(); - ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); -} - -void render_gl(void *, int width, int height) { - if (const char *word = g_menu.open ? nullptr : disruptor_capsule_notice()) { - if (initialize_imgui()) render_notice(word, width, height); - return; - } - if (!g_menu.open || !initialize_imgui()) return; - ImGui_ImplOpenGL3_NewFrame(); - imgui_sdl_new_frame(); - ImGui::NewFrame(); - draw_menu(); - ImGui::Render(); - ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); -} - -struct MenuRegistration { - MenuRegistration() { - PsxHostUiHooks hooks{}; - hooks.abi_version = PSX_HOST_UI_ABI_VERSION; - hooks.struct_size = static_cast(sizeof(hooks)); - hooks.on_runtime_ready = on_runtime_ready; - hooks.on_runtime_shutdown = on_runtime_shutdown; - hooks.on_sdl_event = on_sdl_event; - hooks.flags = current_flags; - hooks.render_gl = render_gl; - if (!psx_host_ui_register(&hooks)) { - std::fprintf(stderr, - "disruptor: settings menu registration failed\n"); - } - } -}; - -MenuRegistration g_registration; - -} // namespace 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/game/host.cpp b/src/game/host.cpp new file mode 100644 index 0000000..94da2ad --- /dev/null +++ b/src/game/host.cpp @@ -0,0 +1,63 @@ +#include "game_host.h" + +#include "config_loader.h" +#include "game/language/language.h" +#include "game/render/far_rendering.h" +#include "game/render/precision_routes.h" +#include "game/session/capsule.h" +#include "gpu.h" + +#include +#include +#include + +extern "C" int disruptor_name_entry_key(int scancode, uint32_t keycode, int down, int plain); +extern "C" void disruptor_name_entry_keys_for_pad(const uint8_t *keys, uint8_t *kept, int count); +extern "C" void disruptor_name_entry_state_loaded(void); +extern "C" int disruptor_grown_quad_sources(uint32_t command_address, const uint32_t *packet, uint32_t source_address[4], + uint32_t source_word[4]); + +bool psx_game_settings_loaded(const PSXRecompV4::UserSettings &settings) { + if (settings.has_language_disc && !std::getenv("PSX_DISRUPTOR_LANGUAGE_DISC")) + disruptor_language_set_disc(settings.language_disc.c_str()); + return settings.has_skip_intro && settings.skip_intro && !std::getenv("PSX_DISRUPTOR_SKIP_INTRO"); +} + +extern "C" { + +int psx_game_configure(const char *game_id, size_t length) { + if (const char *asked = std::getenv("PSX_DISRUPTOR_IMPROVED_SHADOWS")) gpu_set_shadow_shape(asked[0] == '1'); + return disruptor_precision_routes_configure(game_id, length); +} + +int psx_game_fast_boot_forced(void) { + const char *forced = std::getenv("PSX_DISRUPTOR_SKIP_INTRO"); + return forced && forced[0] && forced[0] != '0'; +} + +void psx_game_states_configured(void) { disruptor_language_settle(); } + +void psx_game_state_loaded(void) { + disruptor_capsule_state_loaded(); + disruptor_name_entry_state_loaded(); + disruptor_far_rendering_abandon_metrics(); +} + +int psx_game_key(int scancode, uint32_t keycode, int down, int plain) { + return disruptor_name_entry_key(scancode, keycode, down, plain); +} + +int psx_game_grown_quad_sources(uint32_t command_address, const uint32_t *packet, uint32_t source_address[4], + uint32_t source_word[4]) { + return disruptor_grown_quad_sources(command_address, packet, source_address, source_word); +} + +void psx_game_keys_for_pad(const uint8_t *keys, uint8_t *kept, int count) { + disruptor_name_entry_keys_for_pad(keys, kept, count); +} + +void psx_game_before_input(void) { disruptor_capsule_before_input(); } + +void psx_game_after_input(void) { disruptor_capsule_after_input(); } + +} // extern "C" 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 98% rename from src/disruptor_mouse_aim.cpp rename to src/game/input/mouse_aim.cpp index 290cc73..fda5dab 100644 --- a/src/disruptor_mouse_aim.cpp +++ b/src/game/input/mouse_aim.cpp @@ -18,12 +18,13 @@ */ #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/runtime/include/gpu.h" #include +#include #include #include #include @@ -45,6 +46,7 @@ extern "C" void gpu_geometry_camera_yaw_residual_set(double yaw_units); extern "C" SDL_Window *sdl_window; extern "C" int psx_host_ui_game_input_captured(void); extern "C" int psx_netplay_active(void); +extern "C" void disruptor_name_entry_keys_for_pad(const uint8_t *keys, uint8_t *kept, int count); namespace { @@ -342,6 +344,12 @@ bool key_down(const Uint8 *keys, int scancode) { void apply_modern_controls(const Uint8 *keys, uint32_t mouse_buttons) { if (!g_mouse.modern_controls_enabled) return; + std::array pad_keys{}; /* SDL's scancode count, in 2 and 3 alike */ + if (keys) { + disruptor_name_entry_keys_for_pad(keys, pad_keys.data(), static_cast(pad_keys.size())); + keys = pad_keys.data(); + } + uint16_t press = 0; if (key_down(keys, SDL_SCANCODE_W) || key_down(keys, SDL_SCANCODE_UP)) press |= kPadUp; 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 60% rename from src/disruptor_name_entry.cpp rename to src/game/input/name_entry.cpp index dced01e..308413e 100644 --- a/src/disruptor_name_entry.cpp +++ b/src/game/input/name_entry.cpp @@ -6,6 +6,9 @@ * with 8 for DONE, the row a word at gp + 0x33C. Keys the host hands over are put there on the way into the * routine, and that frame's presses are dropped: a letter key is a pad button as well. Enter only moves the * cursor to DONE and stays a press: the game's own confirm then goes on to ACCEPT NAME. + * + * The pad is sampled from the keyboard's state, which shows a key up to a frame before its event comes, so on the + * name rows a key that types is also kept out of that state for the pad, until it is let go. */ #include "cpu_state.h" @@ -19,6 +22,8 @@ #include #include +extern "C" uint64_t psx_get_cycle_count(void); + namespace { constexpr uint32_t kSaveScreen = 0x8001C484u; @@ -29,11 +34,12 @@ constexpr uint32_t kLettersRow = 5u, kLastNameRow = 7u; constexpr uint32_t kLength = 8u; /* the cursor past the last letter stands on DONE */ constexpr uint8_t kDigits = 26u, kBlank = 36u; constexpr unsigned kLeftScreen = 8u; /* VBlanks without the routine: the menu runs it every frame */ +constexpr uint64_t kVBlankCycles = 564480u; /* the framework's VBLANK_CYCLES */ constexpr unsigned kLetGo = 120u; /* VBlanks without a key event: a held key repeats many times in that */ /* SDL scancodes, the same numbers in SDL 2 and SDL 3. */ constexpr int kKeyA = 4, kKeyZ = 29, kKey1 = 30, kKey9 = 38, kKey0 = 39, kBackspace = 42, kSpace = 44, kDelete = 76; -constexpr int kPad1 = 89, kPad9 = 97, kPad0 = 98, kEnter = 40, kPadEnter = 88; +constexpr int kPad1 = 89, kPad9 = 97, kPad0 = 98, kEnter = 40, kPadEnter = 88, kSlash = 56, kSecondBackslash = 100; enum class Act : uint8_t { Put, Back, Clear, Done }; @@ -45,8 +51,10 @@ struct Typed { std::array g_typed{}; std::size_t g_count = 0; std::bitset<512> g_held; +std::bitset<512> g_withheld; /* keys the pad does not see: a typing key found down on the name rows, until it is up */ std::size_t g_text = 0; /* how many of the typed are text: Enter is not */ -unsigned g_away = kLeftScreen, g_quiet = 0; +uint64_t g_entered = 0, g_keyed = 0; /* the game's clock at the routine's last entry and at the last key event */ +bool g_entered_once = false; bool g_on = false, g_letters = false; /* at the routine's last entry: the name rows took text, and the row was the letters' */ /* A key as the layout names it when that is a Latin letter, a digit or a space. A letter key the layout gives a @@ -65,6 +73,29 @@ bool g_on = false, g_letters = false; /* at the routine's last entry: the name return std::nullopt; } +/* A key of the typing block, which some layout types with: letters, digits and the signs beside them, space, the + * keypad's digits, both erasers. Enter, Tab and Escape are not of it. */ +[[nodiscard]] constexpr bool in_typing_block(int scancode) { + return (scancode >= kKeyA && scancode <= kKey0) || (scancode >= kSpace && scancode <= kSlash) || scancode == kSecondBackslash || + (scancode >= kPad1 && scancode <= kPad0) || scancode == kBackspace || scancode == kDelete; +} + +[[nodiscard]] bool run_in_step() { return g_ls_mode || g_ls_replay_active || psx_netplay_active() != 0; } + +/* The screen was left: the routine has not run for kLeftScreen VBlanks of the game's own clock. A VBlank plugin + * cannot count them: the framework calls one for a mod package only, never for this game. */ +[[nodiscard]] bool left_screen() { + return !g_entered_once || psx_get_cycle_count() - g_entered >= kLeftScreen * kVBlankCycles; +} + +/* Whether the name rows are up now, for the pad. The row is read as it is, not as the routine last found it: the + * routine itself moves it, and a key can go down between that and its next entry. */ +[[nodiscard]] bool taking_text() { + if (left_screen() || run_in_step()) return false; + const uint32_t row = psx_mod_read_word(kRow); + return row >= kLettersRow && row <= kLastNameRow; +} + void forget() { g_count = 0; g_text = 0; @@ -73,14 +104,17 @@ void forget() { void save_screen_entry(CPUState *cpu, uint32_t address) { if (!cpu || address != kSaveScreen) return; + if (left_screen()) forget(); + g_entered = psx_get_cycle_count(); + g_entered_once = true; const uint32_t row = psx_mod_read_word(kRow); - g_away = 0; g_on = row >= kLettersRow && row <= kLastNameRow && !g_ls_mode && !g_ls_replay_active && !psx_netplay_active(); g_letters = row == kLettersRow; if (!g_on) { forget(); return; } + if (g_held.any() && g_entered - g_keyed >= kLetGo * kVBlankCycles) g_held.reset(); if (g_count == 0 && g_held.none()) return; if (g_text != 0 || g_held.any()) cpu->gpr[4] = 0; uint32_t cursor = psx_mod_read_word(kCursor); @@ -107,34 +141,56 @@ void save_screen_entry(CPUState *cpu, uint32_t address) { psx_mod_write_word(kRow, kLettersRow); } -void vblank() { - if (g_away < kLeftScreen && ++g_away == kLeftScreen) { - g_on = g_letters = false; - forget(); - } - if (g_held.any() && ++g_quiet >= kLetGo) g_held.reset(); -} - PSX_MOD_CONSTRUCTOR(register_disruptor_name_entry) { psx_mod_register_function_entry_plugin("disruptor.name_entry", kSaveScreen, save_screen_entry); - psx_mod_register_vblank_plugin("disruptor.name_entry.vblank", vblank); } } // namespace +/* The keyboard's state as the pad may read it: `count` keys copied to `kept`, the withheld ones as up. A key held + * while the name rows are left stays withheld, or the next screen would take it for a fresh press. A run in step + * with another gets the keyboard whole. */ +extern "C" void disruptor_name_entry_keys_for_pad(const uint8_t *keys, uint8_t *kept, int count) { + const bool taking = taking_text(); + if (run_in_step()) g_withheld.reset(); + for (int scancode = 0; scancode < count; ++scancode) { + const auto key = static_cast(scancode); + if (key >= g_withheld.size()) { + kept[scancode] = keys[scancode]; + continue; + } + if (keys[scancode] == 0) g_withheld.reset(key); + else if (taking && in_typing_block(scancode)) g_withheld.set(key); + kept[scancode] = g_withheld.test(key) ? uint8_t{0} : keys[scancode]; + } +} + +/* A save state was loaded: what was typed, and where the screen stood, belong to the time before it. The clock + * cannot tell: a state's clock may lie anywhere beside the one it replaces. */ +extern "C" void disruptor_name_entry_state_loaded(void) { + g_on = g_letters = g_entered_once = false; + forget(); +} + /* A key went down or up. `plain` is 0 with Ctrl, Alt or the system key held. Returns 1 when the key was taken for the name. */ extern "C" int disruptor_name_entry_key(int scancode, uint32_t keycode, int down, int plain) { + if (left_screen()) { + g_on = g_letters = false; + forget(); + } if (scancode == kEnter || scancode == kPadEnter) { if (down && plain && g_letters && g_count < g_typed.size()) g_typed[g_count++] = Typed{Act::Done, kBlank}; return 0; } const std::optional typed = typed_of(scancode, keycode); - if (!typed || scancode < 0 || static_cast(scancode) >= g_held.size()) return 0; - g_quiet = 0; + if (!typed || !in_typing_block(scancode)) return 0; /* text only from keys the pad never sees on these rows */ + g_keyed = psx_get_cycle_count(); if (!down) { g_held.reset(static_cast(scancode)); return 0; } + /* By the rows as the routine last found them, not as they are: the pad shows a key before its event comes, and a + * key that confirmed the row before can have brought these rows up by now. */ if (!g_on || !plain) return 0; g_held.set(static_cast(scancode)); if (g_count < g_typed.size()) { 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/game/render/precision_routes.cpp b/src/game/render/precision_routes.cpp new file mode 100644 index 0000000..3358ff9 --- /dev/null +++ b/src/game/render/precision_routes.cpp @@ -0,0 +1,117 @@ +#include "game/render/precision_routes.h" + +#include "gte_precision.h" + +#include +#include +#include +#include + +/* Disruptor does not use SWC2 for its resident world-projection loops. These + * two reviewed MFC2 sites snapshot SXY2. Six ordinary SW sites publish into + * main RAM and one publishes into nine exact scratchpad slots, each only after + * an exact instruction and committed-word check. Twenty-four reviewed LW/SW + * pairs then carry those proven words into GP0 packets. Keeping every route + * behind the game serial prevents another title at the same PCs from + * inheriting it. */ +int disruptor_precision_routes_configure(const char *game_id, size_t length) { + gte_precision_store_pc_routes_reset(); + if (std::string_view(game_id, length) != "SLUS-00224") return 1; + static const uint32_t mfc2_pcs[] = { + 0x8004633Cu, + 0x8004795Cu, + }; + static const uint32_t store_pcs[] = { + 0x8004641Cu, + 0x800464BCu, + 0x80046524u, + 0x80046540u, + 0x80046594u, + 0x800465D8u, + }; + struct PrecisionCopyRoute { + uint32_t load_pc; + uint32_t load_instruction; + uint32_t store_pc; + uint32_t store_instruction; + uint8_t gpr; + }; + static const PrecisionCopyRoute copy_routes[] = { + {0x80046C4Cu, 0x8D0C0004u, 0x80046D04u, 0xAF2C0008u, 12u}, + {0x80046C50u, 0x8D2D0004u, 0x80046D08u, 0xAF2D0014u, 13u}, + {0x80046C54u, 0x8D4E0004u, 0x80046D10u, 0xAF2E002Cu, 14u}, + {0x80046C60u, 0x8D6F0004u, 0x80046D0Cu, 0xAF2F0020u, 15u}, + {0x80047A80u, 0x8CA80084u, 0x80047A90u, 0xAF280008u, 8u}, + {0x80047A84u, 0x8CA900A4u, 0x80047A94u, 0xAF290014u, 9u}, + {0x80047A88u, 0x8CAA00C4u, 0x80047A9Cu, 0xAF2A002Cu, 10u}, + {0x80047A8Cu, 0x8CAB00BCu, 0x80047A98u, 0xAF2B0020u, 11u}, + {0x80047B40u, 0x8CA800A4u, 0x80047B50u, 0xAF280008u, 8u}, + {0x80047B44u, 0x8CA9008Cu, 0x80047B54u, 0xAF290014u, 9u}, + {0x80047B48u, 0x8CAA00ACu, 0x80047B5Cu, 0xAF2A002Cu, 10u}, + {0x80047B4Cu, 0x8CAB00C4u, 0x80047B58u, 0xAF2B0020u, 11u}, + {0x80047C00u, 0x8CA800C4u, 0x80047C10u, 0xAF280008u, 8u}, + {0x80047C04u, 0x8CA900ACu, 0x80047C14u, 0xAF290014u, 9u}, + {0x80047C08u, 0x8CAA0094u, 0x80047C1Cu, 0xAF2A002Cu, 10u}, + {0x80047C0Cu, 0x8CAB00B4u, 0x80047C18u, 0xAF2B0020u, 11u}, + {0x80047CC4u, 0x8CA800BCu, 0x80047CD4u, 0xAF280008u, 8u}, + {0x80047CC8u, 0x8CA900C4u, 0x80047CD8u, 0xAF290014u, 9u}, + {0x80047CCCu, 0x8CAA00B4u, 0x80047CE0u, 0xAF2A002Cu, 10u}, + {0x80047CD0u, 0x8CAB009Cu, 0x80047CDCu, 0xAF2B0020u, 11u}, + {0x80047D6Cu, 0x8CA80084u, 0x80047D7Cu, 0xAF280008u, 8u}, + {0x80047D70u, 0x8CA9008Cu, 0x80047D80u, 0xAF290014u, 9u}, + {0x80047D74u, 0x8CAA0094u, 0x80047D88u, 0xAF2A002Cu, 10u}, + {0x80047D78u, 0x8CAB009Cu, 0x80047D84u, 0xAF2B0020u, 11u}, + }; + for (uint32_t pc : mfc2_pcs) { + if (!gte_precision_mfc2_pc_route_add( + pc, 0x480A7000u, 14u)) { + std::fprintf(stderr, + "psxrecomp: failed to register Disruptor SXY2 MFC2 PC " + "0x%08X\n", pc); + return 0; + } + } + for (uint32_t pc : store_pcs) { + if (!gte_precision_store_pc_route_add( + pc, 0xACAA0004u, 14u)) { + std::fprintf(stderr, + "psxrecomp: failed to register Disruptor SXY2 store PC " + "0x%08X\n", pc); + return 0; + } + } + if (!gte_precision_scratch_store_pc_route_add( + 0x80047A0Cu, 0xACAA0004u, 14u, + 0x1F800084u, 8u, 9u)) { + std::fprintf(stderr, + "psxrecomp: failed to register Disruptor scratchpad SXY2 " + "store route 0x80047A0C\n"); + return 0; + } + /* 0x80047980..0x800479E0 pin x to -256..576 and y to -256..496 before that store. */ + if (!gte_precision_scratch_store_pc_route_clamp( + 0x80047A0Cu, 0xACAA0004u, -0x100, 0x240, -0x100, 0x1F0)) { + std::fprintf(stderr, + "psxrecomp: failed to register the guard band of Disruptor's " + "scratchpad SXY2 store route 0x80047A0C\n"); + return 0; + } + for (const PrecisionCopyRoute& route : copy_routes) { + if (!gte_precision_copy_pc_route_add( + route.load_pc, route.load_instruction, + route.store_pc, route.store_instruction, route.gpr)) { + std::fprintf(stderr, + "psxrecomp: failed to register Disruptor projection-copy " + "route 0x%08X -> 0x%08X\n", + route.load_pc, route.store_pc); + return 0; + } + } + std::fprintf(stdout, + "psxrecomp: registered %zu MFC2 captures, %zu main-RAM projection " + "stores, 1 scratchpad projection store and %zu packet-copy routes\n", + sizeof(mfc2_pcs) / sizeof(mfc2_pcs[0]), + sizeof(store_pcs) / sizeof(store_pcs[0]), + sizeof(copy_routes) / sizeof(copy_routes[0])); + return 1; +} diff --git a/src/game/render/precision_routes.h b/src/game/render/precision_routes.h new file mode 100644 index 0000000..ba7531d --- /dev/null +++ b/src/game/render/precision_routes.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Registers the game's projection routes for the disc with this serial of `length` characters. 0 when a route was refused. */ +int disruptor_precision_routes_configure(const char *game_id, size_t length); + +#ifdef __cplusplus +} +#endif 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/game_user_settings.h b/src/game/settings/game_user_settings.h new file mode 100644 index 0000000..29b0d0a --- /dev/null +++ b/src/game/settings/game_user_settings.h @@ -0,0 +1,38 @@ +#ifndef DISRUPTOR_GAME_USER_SETTINGS_H +#define DISRUPTOR_GAME_USER_SETTINGS_H + +#include +#include + +namespace PSXRecompV4 { + +/// The game's fields of the [disruptor] section of settings.toml, which the framework's UserSettings derives from. +struct GameUserSettings { + bool has_mouse_aim = false; bool mouse_aim = false; + bool has_modern_controls = false; bool modern_controls = false; + bool has_horizontal_sensitivity = false; double horizontal_sensitivity = 0.1; + bool has_invert_horizontal = false; bool invert_horizontal = false; + bool has_vertical_look = false; bool vertical_look = false; + bool has_vertical_sensitivity = false; double vertical_sensitivity = 0.08; + bool has_invert_vertical = false; bool invert_vertical = false; + bool has_high_precision_camera = false; bool high_precision_camera = false; + bool has_frame_unlock = false; bool frame_unlock = false; + bool has_skip_intro = false; bool skip_intro = false; + bool has_language_disc = false; std::string language_disc; // image of another region's disc, "" for none + bool has_language_discs = false; std::vector language_discs; // the ones named before, for a launcher to offer again + bool has_hud_scale = false; int hud_scale = 100; + bool has_improved_shadows = false; bool improved_shadows = false; + bool has_ambient_occlusion = false; bool ambient_occlusion = false; + bool has_occlusion_reach = false; int occlusion_reach = 160; // camera depth units, 50..800 + bool has_occlusion_strength = false; int occlusion_strength = 80; // percent, 0..300 + bool has_gap_filling = false; bool gap_filling = true; + bool has_depth_sorted_world = false; bool depth_sorted_world = false; + // An index into kDisruptorDiagnosticViews. A session's own: at start only a capsule's replay takes it from the file. + bool has_diagnostic_view = false; int diagnostic_view = 0; +}; +inline constexpr const char* kDisruptorDiagnosticViews[] = {"off", "shading", "depth", "normals", "fade", "mesh"}; +inline constexpr int kDisruptorDiagnosticViewCount = 6; + +} // namespace PSXRecompV4 + +#endif 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/game/settings/saved.cpp b/src/game/settings/saved.cpp new file mode 100644 index 0000000..c829994 --- /dev/null +++ b/src/game/settings/saved.cpp @@ -0,0 +1,236 @@ +#include "config_loader.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Defined as the framework's reader defines it: toml11's inline code must be one in a program. +#define TOML11_USE_UNRELEASED_TOML_FEATURES +#include "toml.hpp" + +namespace PSXRecompV4 { + +void read_game_user_settings(const toml::value& doc, UserSettings& s) { + auto try_get = [](auto&& fn) { try { fn(); } catch (const std::exception&) {} }; + if (doc.contains("disruptor")) { + const toml::value& d = toml::find(doc, "disruptor"); + if (d.contains("mouse_aim")) try_get([&]{ + s.mouse_aim = toml::find(d, "mouse_aim"); + s.has_mouse_aim = true; + }); + if (d.contains("modern_controls")) try_get([&]{ + s.modern_controls = toml::find(d, "modern_controls"); + s.has_modern_controls = true; + }); + if (d.contains("horizontal_sensitivity")) try_get([&]{ + double n = 0.0; + try { + n = toml::find(d, "horizontal_sensitivity"); + } catch (const std::exception&) { + n = static_cast( + toml::find(d, "horizontal_sensitivity")); + } + if (std::isfinite(n) && n >= 0.005 && n <= 2.0) { + s.horizontal_sensitivity = n; + s.has_horizontal_sensitivity = true; + } + }); + if (d.contains("invert_horizontal")) try_get([&]{ + s.invert_horizontal = toml::find(d, "invert_horizontal"); + s.has_invert_horizontal = true; + }); + if (d.contains("vertical_look")) try_get([&]{ + s.vertical_look = toml::find(d, "vertical_look"); + s.has_vertical_look = true; + }); + if (d.contains("vertical_sensitivity")) try_get([&]{ + double n = 0.0; + try { + n = toml::find(d, "vertical_sensitivity"); + } catch (const std::exception&) { + n = static_cast( + toml::find(d, "vertical_sensitivity")); + } + if (std::isfinite(n) && n >= 0.005 && n <= 2.0) { + s.vertical_sensitivity = n; + s.has_vertical_sensitivity = true; + } + }); + if (d.contains("invert_vertical")) try_get([&]{ + s.invert_vertical = toml::find(d, "invert_vertical"); + s.has_invert_vertical = true; + }); + if (d.contains("high_precision_camera")) try_get([&]{ + s.high_precision_camera = toml::find(d, "high_precision_camera"); + s.has_high_precision_camera = true; + }); + if (d.contains("geometry_correction")) try_get([&]{ + s.geometry_correction = toml::find(d, "geometry_correction"); + s.has_geometry_correction = true; + }); + if (d.contains("perspective_textures")) try_get([&]{ + s.perspective_textures = toml::find(d, "perspective_textures"); + s.has_perspective_textures = true; + }); + if (d.contains("frame_unlock")) try_get([&]{ + s.frame_unlock = toml::find(d, "frame_unlock"); + s.has_frame_unlock = true; + }); + if (d.contains("skip_intro")) try_get([&]{ + s.skip_intro = toml::find(d, "skip_intro"); + s.has_skip_intro = true; + }); + if (d.contains("language_disc")) try_get([&]{ + s.language_disc = toml::find(d, "language_disc"); + s.has_language_disc = true; + }); + if (d.contains("language_discs")) try_get([&]{ + s.language_discs = toml::find>(d, "language_discs"); + s.has_language_discs = true; + }); + if (d.contains("hud_scale")) try_get([&]{ + const int percent = toml::find(d, "hud_scale"); + if (percent >= 50 && percent <= 100) { + s.hud_scale = percent; + s.has_hud_scale = true; + } + }); + if (d.contains("improved_shadows")) try_get([&]{ + s.improved_shadows = toml::find(d, "improved_shadows"); + s.has_improved_shadows = true; + }); + if (d.contains("ambient_occlusion")) try_get([&]{ + s.ambient_occlusion = toml::find(d, "ambient_occlusion"); + s.has_ambient_occlusion = true; + }); + if (d.contains("occlusion_reach")) try_get([&]{ + const int reach = toml::find(d, "occlusion_reach"); + if (reach >= 50 && reach <= 800) { + s.occlusion_reach = reach; + s.has_occlusion_reach = true; + } + }); + if (d.contains("occlusion_strength")) try_get([&]{ + const int percent = toml::find(d, "occlusion_strength"); + if (percent >= 0 && percent <= 300) { + s.occlusion_strength = percent; + s.has_occlusion_strength = true; + } + }); + if (d.contains("gap_filling")) try_get([&]{ + s.gap_filling = toml::find(d, "gap_filling"); + s.has_gap_filling = true; + }); + if (d.contains("depth_sorted_world")) try_get([&]{ + s.depth_sorted_world = toml::find(d, "depth_sorted_world"); + s.has_depth_sorted_world = true; + }); + if (d.contains("diagnostic_view")) try_get([&]{ + const std::string view = toml::find(d, "diagnostic_view"); + for (int index = 0; index < kDisruptorDiagnosticViewCount; ++index) { + if (view == kDisruptorDiagnosticViews[index]) { + s.diagnostic_view = index; + s.has_diagnostic_view = true; + } + } + }); + } +} + +void write_game_user_settings(std::ostream& f, const UserSettings& s) { + // A TOML basic string: a quote, a backslash and a control character are escaped. + auto quoted = [](const std::string& text) { + std::string out = "\""; + for (const char letter : text) { + const unsigned code = static_cast(letter); + char escape[8]; + if (letter == '\\' || letter == '"') std::snprintf(escape, sizeof(escape), "\\%c", letter); + else if (code < 0x20 || code == 0x7F) std::snprintf(escape, sizeof(escape), "\\u%04X", code); + else std::snprintf(escape, sizeof(escape), "%c", letter); + out += escape; + } + return out + "\""; + }; + + if (s.has_mouse_aim || s.has_modern_controls || + s.has_horizontal_sensitivity || s.has_invert_horizontal || + s.has_vertical_look || s.has_vertical_sensitivity || + s.has_invert_vertical || + s.has_high_precision_camera || s.has_geometry_correction || + s.has_perspective_textures || s.has_frame_unlock || s.has_improved_shadows || + s.has_ambient_occlusion || s.has_occlusion_reach || s.has_occlusion_strength || + s.has_gap_filling || s.has_depth_sorted_world || s.has_diagnostic_view || + s.has_language_discs || + s.has_language_disc || s.has_skip_intro || + s.has_hud_scale) { + f << "\n[disruptor]\n"; + if (s.has_mouse_aim) + f << "mouse_aim = " << (s.mouse_aim ? "true" : "false") << "\n"; + if (s.has_modern_controls) + f << "modern_controls = " << (s.modern_controls ? "true" : "false") << "\n"; + if (s.has_horizontal_sensitivity && + std::isfinite(s.horizontal_sensitivity) && + s.horizontal_sensitivity >= 0.005 && + s.horizontal_sensitivity <= 2.0) { + f << "horizontal_sensitivity = " + << std::setprecision(std::numeric_limits::max_digits10) + << s.horizontal_sensitivity << "\n"; + } + if (s.has_invert_horizontal) + f << "invert_horizontal = " << (s.invert_horizontal ? "true" : "false") << "\n"; + if (s.has_vertical_look) + f << "vertical_look = " << (s.vertical_look ? "true" : "false") << "\n"; + if (s.has_vertical_sensitivity && + std::isfinite(s.vertical_sensitivity) && + s.vertical_sensitivity >= 0.005 && + s.vertical_sensitivity <= 2.0) { + f << "vertical_sensitivity = " + << std::setprecision(std::numeric_limits::max_digits10) + << s.vertical_sensitivity << "\n"; + } + if (s.has_invert_vertical) + f << "invert_vertical = " << (s.invert_vertical ? "true" : "false") << "\n"; + if (s.has_high_precision_camera) + f << "high_precision_camera = " << (s.high_precision_camera ? "true" : "false") << "\n"; + if (s.has_geometry_correction) + f << "geometry_correction = " << (s.geometry_correction ? "true" : "false") << "\n"; + if (s.has_perspective_textures) + f << "perspective_textures = " << (s.perspective_textures ? "true" : "false") << "\n"; + if (s.has_frame_unlock) + f << "frame_unlock = " << (s.frame_unlock ? "true" : "false") << "\n"; + if (s.has_skip_intro) + f << "skip_intro = " << (s.skip_intro ? "true" : "false") << "\n"; + if (s.has_language_disc) + f << "language_disc = " << quoted(s.language_disc) << "\n"; + if (s.has_language_discs) { + f << "language_discs = ["; + for (size_t index = 0; index < s.language_discs.size(); ++index) + f << (index ? ", " : "") << quoted(s.language_discs[index]); + f << "]\n"; + } + if (s.has_hud_scale && s.hud_scale >= 50 && s.hud_scale <= 100) + f << "hud_scale = " << s.hud_scale << "\n"; + if (s.has_improved_shadows) + f << "improved_shadows = " << (s.improved_shadows ? "true" : "false") << "\n"; + if (s.has_ambient_occlusion) + f << "ambient_occlusion = " << (s.ambient_occlusion ? "true" : "false") << "\n"; + if (s.has_occlusion_reach && s.occlusion_reach >= 50 && s.occlusion_reach <= 800) + f << "occlusion_reach = " << s.occlusion_reach << "\n"; + if (s.has_occlusion_strength && s.occlusion_strength >= 0 && s.occlusion_strength <= 300) + f << "occlusion_strength = " << s.occlusion_strength << "\n"; + if (s.has_gap_filling) + f << "gap_filling = " << (s.gap_filling ? "true" : "false") << "\n"; + if (s.has_depth_sorted_world) + f << "depth_sorted_world = " << (s.depth_sorted_world ? "true" : "false") << "\n"; + if (s.has_diagnostic_view && s.diagnostic_view >= 0 && s.diagnostic_view < kDisruptorDiagnosticViewCount) + f << "diagnostic_view = " << quoted(kDisruptorDiagnosticViews[s.diagnostic_view]) << "\n"; + } +} + +} // namespace PSXRecompV4 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/game/ui/menu/menu.cpp b/src/game/ui/menu/menu.cpp new file mode 100644 index 0000000..0e86d90 --- /dev/null +++ b/src/game/ui/menu/menu.cpp @@ -0,0 +1,315 @@ +#include "game/ui/menu/menu.h" + +namespace disruptor::menu { + +DevMenuState g_menu; + +namespace { + +bool imgui_sdl_init(SDL_Window *window) { +#if defined(PSX_SDL3) + return ImGui_ImplSDL3_InitForOpenGL(window, SDL_GL_GetCurrentContext()); +#else + return ImGui_ImplSDL2_InitForOpenGL(window, SDL_GL_GetCurrentContext()); +#endif +} + +void imgui_sdl_shutdown() { +#if defined(PSX_SDL3) + ImGui_ImplSDL3_Shutdown(); +#else + ImGui_ImplSDL2_Shutdown(); +#endif +} + +void imgui_sdl_new_frame() { +#if defined(PSX_SDL3) + ImGui_ImplSDL3_NewFrame(); +#else + ImGui_ImplSDL2_NewFrame(); +#endif +} + +void imgui_sdl_process_event(const SDL_Event *event) { +#if defined(PSX_SDL3) + (void)ImGui_ImplSDL3_ProcessEvent(event); +#else + (void)ImGui_ImplSDL2_ProcessEvent(event); +#endif +} + +void apply_disruptor_style() { + ImGui::StyleColorsDark(); + ImGuiStyle &style = ImGui::GetStyle(); + style.WindowRounding = 5.0f; + style.ChildRounding = 4.0f; + style.FrameRounding = 3.0f; + style.PopupRounding = 4.0f; + style.ScrollbarRounding = 4.0f; + style.GrabRounding = 3.0f; + + const ImVec4 green(0.20f, 0.78f, 0.34f, 1.00f); + const ImVec4 green_hover(0.28f, 0.90f, 0.43f, 1.00f); + const ImVec4 green_active(0.14f, 0.62f, 0.27f, 1.00f); + style.Colors[ImGuiCol_CheckMark] = green_hover; + style.Colors[ImGuiCol_SliderGrab] = green; + style.Colors[ImGuiCol_SliderGrabActive] = green_hover; + style.Colors[ImGuiCol_Header] = ImVec4(0.10f, 0.38f, 0.18f, 1.00f); + style.Colors[ImGuiCol_HeaderHovered] = green_active; + style.Colors[ImGuiCol_HeaderActive] = green; + style.Colors[ImGuiCol_Button] = ImVec4(0.10f, 0.34f, 0.17f, 1.00f); + style.Colors[ImGuiCol_ButtonHovered] = green_active; + style.Colors[ImGuiCol_ButtonActive] = green; +} + +bool initialize_imgui() { + if (g_menu.imgui_ready) return true; + if (!g_menu.window || g_menu.backend != PSX_HOST_UI_BACKEND_OPENGL) + return false; + + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImGuiIO &io = ImGui::GetIO(); + io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; + /* Persist only the reviewed gameplay/presentation fields below. ImGui's + * ambient layout file is deliberately disabled: it is relative-path, + * independently written, and outside our atomic settings contract. */ + io.IniFilename = nullptr; + apply_disruptor_style(); + + if (!imgui_sdl_init(g_menu.window)) { + ImGui::DestroyContext(); + std::fprintf(stderr, + "disruptor: developer menu SDL backend initialization failed\n"); + return false; + } + if (!ImGui_ImplOpenGL3_Init("#version 330 core")) { + imgui_sdl_shutdown(); + ImGui::DestroyContext(); + std::fprintf(stderr, + "disruptor: developer menu OpenGL initialization failed\n"); + return false; + } + + g_menu.imgui_ready = true; + std::fprintf(stdout, + "disruptor: in-game settings menu ready (press ` to open)\n"); + return true; +} + +// A second after the menu closes, two seconds of what is swapped to the screen go to shown// beside settings.toml. +void start_shown_recording() { + g_menu.record_shown = false; + const auto now = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + const fs::path folder = g_preferences.path.parent_path() / "shown" / std::to_string(now); + std::error_code failed; + fs::create_directories(folder, failed); + if (failed) { + g_menu.shown_note = "The folder for the pictures could not be made."; + return; + } + g_menu.shown_note = gl_renderer_record_shown(utf8(folder).c_str(), 120, 240, 960) ? "Last recording: " + utf8(folder) + : "The recording could not start."; +} + +} // namespace + +void set_menu_open(bool open) { + if (open == g_menu.open) return; + if (open) { + g_menu.restore_mouse_capture = disruptor_mouse_captured() != 0; + if (g_menu.restore_mouse_capture) + (void)disruptor_mouse_set_captured(0); + g_menu.open = true; + } else { + g_menu.open = false; + (void)flush_preferences(); + if (g_menu.record_shown) start_shown_recording(); + if (g_menu.restore_mouse_capture) + (void)disruptor_mouse_set_captured(1); + g_menu.restore_mouse_capture = false; + } +} + +namespace { + +bool scancode_event(const SDL_Event *event, SDL_Scancode scancode) { + if (!event || event->type != SDL_KEYDOWN || event->key.repeat) return false; +#if defined(PSX_SDL3) + return event->key.scancode == scancode; +#else + return event->key.keysym.scancode == scancode; +#endif +} + +void draw_menu() { + char picked[4096]; + if (psx_host_pick_disc_image_poll(picked, static_cast(sizeof picked)) == 1) mark_language_disc(picked); + bool keep_open = g_menu.open; + ImGui::SetNextWindowSize(ImVec2(760.0f, 560.0f), + ImGuiCond_FirstUseEver); + if (ImGui::Begin("Disruptor Settings", &keep_open, + ImGuiWindowFlags_NoCollapse)) { + ImGui::PushTextWrapPos(0.0f); // a long note wraps at the window's edge, where it used to be cut off + ImGui::TextDisabled("Press ` to toggle; Escape closes the menu."); + ImGui::TextColored(ImVec4(1.0f, 0.73f, 0.25f, 1.0f), + "The game is not paused while this menu is open."); + + if (ImGui::BeginTabBar("DisruptorDevTabs")) { + if (ImGui::BeginTabItem("Controls")) { + draw_controls_tab(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Enhancements")) { + draw_enhancements_tab(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Experimental")) { + draw_experimental_tab(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Language")) { + draw_language_tab(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("System")) { + draw_system_tab(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Debug")) { + draw_debug_tab(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Cheats")) { + draw_cheats_tab(); + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } + ImGui::PopTextWrapPos(); + } + ImGui::End(); + if (!keep_open && g_menu.open) set_menu_open(false); +} + +void on_runtime_ready(void *, SDL_Window *window, int backend) { + disruptor_cheats_reset_session(); + disruptor_far_rendering_reset_session(); + disruptor_billboard_aspect_set_site_mask( + disruptor_billboard_aspect_all_site_mask()); + disruptor_mouse_recenter_vertical(); + disruptor_high_precision_camera_set_enabled(0); + gte_ws_set_far_threshold(900); + gte_ws_set_backdrop_repair_enabled(0); + g_menu.window = window; + g_menu.backend = backend; + load_preferences_for_session(); + if (backend == PSX_HOST_UI_BACKEND_OPENGL) (void)initialize_imgui(); +} + +void on_runtime_shutdown(void *) { + disruptor_cheats_reset_session(); + disruptor_far_rendering_reset_session(); + disruptor_billboard_aspect_set_site_mask( + disruptor_billboard_aspect_all_site_mask()); + disruptor_mouse_recenter_vertical(); + disruptor_high_precision_camera_set_enabled(0); + gte_ws_set_far_threshold(900); + gte_ws_set_backdrop_repair_enabled(0); + (void)flush_preferences(); + if (disruptor_mouse_captured()) + (void)disruptor_mouse_set_captured(0); + if (g_menu.base_keybinds_valid) + apply_modern_keybinds(false); + g_menu.open = false; + g_menu.restore_mouse_capture = false; + if (g_menu.imgui_ready) { + ImGui_ImplOpenGL3_Shutdown(); + imgui_sdl_shutdown(); + ImGui::DestroyContext(); + } + g_menu = DevMenuState{}; +} + +int on_sdl_event(void *, const SDL_Event *event) { + const bool toggle = scancode_event(event, SDL_SCANCODE_GRAVE); + const bool close = + g_menu.open && scancode_event(event, SDL_SCANCODE_ESCAPE); + if (toggle || close) { + set_menu_open(toggle ? !g_menu.open : false); + return 1; + } + if (!g_menu.open && scancode_event(event, SDL_SCANCODE_ESCAPE)) + disruptor_intro_skip_request(); + if (!g_menu.open && scancode_event(event, SDL_SCANCODE_F12) && (SDL_GetModState() & KMOD_CTRL) != 0) { + disruptor_capsule_toggle(); + return 1; + } + if (!g_menu.open || !g_menu.imgui_ready) return 0; + imgui_sdl_process_event(event); + return 1; +} + +uint32_t current_flags(void *) { + if (!g_menu.open || !g_menu.imgui_ready) return disruptor_capsule_notice() ? PSX_HOST_UI_VISIBLE : 0u; + return PSX_HOST_UI_CAPTURE_KEYBOARD | + PSX_HOST_UI_CAPTURE_MOUSE | + PSX_HOST_UI_CAPTURE_GAMEPAD | + PSX_HOST_UI_VISIBLE; +} + +/* Drawn without the SDL backend's frame: that one reads and captures the mouse, which is the game's while the menu is closed. */ +void render_notice(const char *word, int width, int height) { + ImGui_ImplOpenGL3_NewFrame(); + ImGuiIO &io = ImGui::GetIO(); + io.DisplaySize = ImVec2(static_cast(width), static_cast(height)); + io.DisplayFramebufferScale = ImVec2(1.0f, 1.0f); + io.DeltaTime = 1.0f / 60.0f; + ImGui::NewFrame(); + ImGui::SetNextWindowPos(ImVec2(16.0f, 16.0f)); + ImGui::Begin("##capsule", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoFocusOnAppearing); + ImGui::SetWindowFontScale(std::max(1.0f, static_cast(height) / 360.0f)); + ImGui::TextColored(ImVec4(1.0f, 0.25f, 0.2f, 1.0f), "%s", word); + ImGui::End(); + ImGui::Render(); + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); +} + +void render_gl(void *, int width, int height) { + if (const char *word = g_menu.open ? nullptr : disruptor_capsule_notice()) { + if (initialize_imgui()) render_notice(word, width, height); + return; + } + if (!g_menu.open || !initialize_imgui()) return; + ImGui_ImplOpenGL3_NewFrame(); + imgui_sdl_new_frame(); + ImGui::NewFrame(); + draw_menu(); + ImGui::Render(); + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); +} + +struct MenuRegistration { + MenuRegistration() { + PsxHostUiHooks hooks{}; + hooks.abi_version = PSX_HOST_UI_ABI_VERSION; + hooks.struct_size = static_cast(sizeof(hooks)); + hooks.on_runtime_ready = on_runtime_ready; + hooks.on_runtime_shutdown = on_runtime_shutdown; + hooks.on_sdl_event = on_sdl_event; + hooks.flags = current_flags; + hooks.render_gl = render_gl; + if (!psx_host_ui_register(&hooks)) { + std::fprintf(stderr, + "disruptor: settings menu registration failed\n"); + } + } +}; + +MenuRegistration g_registration; + +} // namespace + +} // namespace disruptor::menu diff --git a/src/game/ui/menu/menu.h b/src/game/ui/menu/menu.h new file mode 100644 index 0000000..9263823 --- /dev/null +++ b/src/game/ui/menu/menu.h @@ -0,0 +1,174 @@ +/* + * Host-side in-game settings menu for Disruptor. + * + * This overlay is deliberately outside the emulated PlayStation. It draws + * after the completed host presentation. Normal settings never write guest + * VRAM/RAM; the Cheats tab calls only the narrow, version-pinned Disruptor + * gameplay APIs whose save consequences are stated in the UI. + */ + +#ifndef DISRUPTOR_UI_MENU_H +#define DISRUPTOR_UI_MENU_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" +#include "psx_keybinds.h" +#include "psx_netplay.h" + +#include "imgui.h" +#include "imgui_impl_opengl3.h" +#if defined(PSX_SDL3) +#include "imgui_impl_sdl3.h" +#else +#include "imgui_impl_sdl2.h" +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" void gte_ws_set_far_threshold(int threshold); +extern "C" void gte_ws_set_backdrop_repair_enabled(int enabled); +extern "C" void gl_renderer_set_world_depth(int enabled); +extern "C" int gl_renderer_world_depth(void); +extern "C" void gl_renderer_set_full_yaw(int enabled); +extern "C" void gl_renderer_set_mesh_view(int enabled); +extern "C" int gl_renderer_mesh_view(void); +extern "C" int gl_renderer_record_shown(const char *folder, int skip, int count, int width); +extern "C" int gl_renderer_recording_shown(void); +extern "C" void gl_renderer_set_gap_fill(int enabled); +extern "C" int gl_renderer_gap_fill(void); +extern "C" void gl_renderer_set_ssao(int enabled); +extern "C" void gl_renderer_set_ssao_view(int view); +extern "C" int gl_renderer_ssao_view(void); +extern "C" int gl_renderer_ssao(void); +extern "C" void gl_renderer_set_ssao_shape(float radius, float strength); +extern "C" float gl_renderer_ssao_radius(void); +extern "C" float gl_renderer_ssao_strength(void); + +namespace disruptor::menu { + +struct DevMenuState { + SDL_Window *window = nullptr; + int backend = PSX_HOST_UI_BACKEND_NONE; + bool imgui_ready = false; + bool open = false; + bool restore_mouse_capture = false; + bool aspect_apply_failed = false; + bool internal_scale_apply_failed = false; + bool fullscreen_apply_failed = false; + bool base_keybinds_valid = false; + bool record_shown = false; // armed in the menu, started when it closes + std::string shown_note; + int selected_wide_aspect = 0; + std::string cheat_status; + std::array base_keybinds{}; +}; + +extern DevMenuState g_menu; + +namespace fs = std::filesystem; +using disruptor::settings::find_option; +using disruptor::settings::Option; +using disruptor::settings::Unit; + +struct PreferenceState { + fs::path path; + PSXRecompV4::UserSettings pending; + 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; + } +}; + +extern PreferenceState g_preferences; +extern std::string g_language_disc; + +std::string utf8(const fs::path &path); +void apply_modern_keybinds(bool enabled); +void set_modern_controls_live(bool enabled); +void mark(const char *key, int value); +void mark_mouse_aim(bool value); +void mark_modern_controls(bool value); +void mark_sensitivity(double value); +void mark_invert_x(bool value); +void mark_vertical_look(bool value); +void mark_vertical_sensitivity(double value); +void mark_invert_y(bool value); +void mark_geometry(bool value); +void mark_textures(bool value); +void mark_vsync(int value); +void mark_aspect(int numerator, int denominator, bool adaptive); +void mark_supersampling(int value); +void mark_fullscreen(int value); +void mark_master_volume(int value); +void mark_audio_muted(bool value); +void mark_skip_intro(bool value); +void mark_language_disc(const std::string &path); +void mark_hud_scale(int value); +void mark_frame_unlock(bool value); +void mark_shadow_shape(bool improved); +void mark_occlusion(bool value); +void mark_occlusion_shape(int reach, int strength); +void mark_gap_filling(bool value); +void mark_world_depth(bool value); +void apply_diagnostic_view(int view); +void mark_diagnostic_view(int value); +void apply_smooth_turn(bool enabled); +void mark_smooth_turn(bool value); +void mark_present_rate(int value); +void mark_present_enabled(bool value); +bool flush_preferences(); +void load_preferences_for_session(); + +bool applies(const char *key); +bool option_toggle(const char *key, bool *value); +bool option_slider(const char *key, int *value); +bool option_slider(const char *key, float *value); +bool option_list(const char *key, int *index, size_t skipped = 0); +void draw_status_badge(const char *label, const ImVec4 &colour); + +void draw_controls_tab(); +void draw_enhancements_tab(); +void draw_experimental_tab(); +void draw_debug_tab(); +void draw_cheats_tab(); +void draw_system_tab(); +void draw_language_tab(); + +void set_menu_open(bool open); + +} // namespace disruptor::menu + +#endif diff --git a/src/game/ui/menu/preferences.cpp b/src/game/ui/menu/preferences.cpp new file mode 100644 index 0000000..912e453 --- /dev/null +++ b/src/game/ui/menu/preferences.cpp @@ -0,0 +1,456 @@ +#include "game/ui/menu/menu.h" + +namespace disruptor::menu { + +PreferenceState g_preferences; + +std::string g_language_disc; + +std::string utf8(const fs::path &path) { + const std::u8string text = path.u8string(); + return {text.begin(), text.end()}; +} + +namespace { + +bool env_override_present(const char *name) { + return name && std::getenv(name) != nullptr; +} + +static constexpr std::array kModernKeybinds = { + SDL_SCANCODE_W, SDL_SCANCODE_S, + SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, + SDL_SCANCODE_RETURN, SDL_SCANCODE_SPACE, + SDL_SCANCODE_F, SDL_SCANCODE_E, + SDL_SCANCODE_Q, SDL_SCANCODE_R, + SDL_SCANCODE_A, SDL_SCANCODE_D, + SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, + SDL_SCANCODE_P, SDL_SCANCODE_TAB, + SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, + SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, + SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, + SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, +}; + +static constexpr std::array kOriginalKeybinds = { + SDL_SCANCODE_UP, SDL_SCANCODE_DOWN, + SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, + SDL_SCANCODE_X, SDL_SCANCODE_S, + SDL_SCANCODE_Z, SDL_SCANCODE_A, + SDL_SCANCODE_Q, SDL_SCANCODE_W, + SDL_SCANCODE_E, SDL_SCANCODE_R, + SDL_SCANCODE_T, SDL_SCANCODE_Y, + SDL_SCANCODE_RETURN, SDL_SCANCODE_RSHIFT, + SDL_SCANCODE_UP, SDL_SCANCODE_DOWN, + SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, + SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, + SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, +}; + +void capture_base_keybinds() { + if (g_menu.base_keybinds_valid) return; + bool is_packaged_modern_preset = true; + for (int i = 0; i < PSX_KB_COUNT; ++i) { + g_menu.base_keybinds[static_cast(i)] = + psx_keybinds_get_button(1, i); + if (g_menu.base_keybinds[static_cast(i)] != + kModernKeybinds[static_cast(i)]) + is_packaged_modern_preset = false; + } + /* run.ps1/run.sh install the packaged modern map for an explicit launch + * flag. Treat that exact map as an overlay, not the restore baseline, so + * turning Modern controls off live really returns to the packaged classic + * mapping. Any custom/non-exact map is preserved verbatim instead. */ + if (is_packaged_modern_preset) + g_menu.base_keybinds = kOriginalKeybinds; + g_menu.base_keybinds_valid = true; +} + +} // namespace + +void apply_modern_keybinds(bool enabled) { + capture_base_keybinds(); + if (!enabled) { + for (int i = 0; i < PSX_KB_COUNT; ++i) + psx_keybinds_set_button( + 1, i, g_menu.base_keybinds[static_cast(i)]); + return; + } + + for (int i = 0; i < PSX_KB_COUNT; ++i) + psx_keybinds_set_button( + 1, i, kModernKeybinds[static_cast(i)]); +} + +void set_modern_controls_live(bool enabled) { + apply_modern_keybinds(enabled); + disruptor_modern_controls_set_enabled(enabled ? 1 : 0); +} + +void mark(const char *key, int value) { + const Option *option = find_option(key); + option->set(g_preferences.pending, value); + g_preferences.changed.insert(option); +} + +namespace { + +bool changed(const char *key) { + return g_preferences.changed.contains(find_option(key)); +} + +} // namespace + +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_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; + g_preferences.pending.aspect_den = denominator; + /* Keep the adaptive flag and its fixed 32:9 cap in the same atomic + * settings update as the fallback/initial aspect. */ + g_preferences.pending.has_adaptive_view = true; + g_preferences.pending.adaptive_view = adaptive; + g_preferences.changed.insert(find_option("video.aspect_ratio")); +} + +void mark_supersampling(int value) { mark("video.supersampling", value - 1); } + +void mark_fullscreen(int value) { mark("video.fullscreen", value); } + +void mark_master_volume(int value) { mark("audio.master_volume", value); } + +void mark_audio_muted(bool value) { mark("audio.muted", value); } + +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.language_disc_changed = true; +} + +void mark_hud_scale(int value) { mark("disruptor.hud_scale", value); } + +void mark_frame_unlock(bool value) { mark("disruptor.frame_unlock", value); } + +void mark_shadow_shape(bool improved) { mark("disruptor.improved_shadows", improved); } + +void mark_occlusion(bool value) { mark("disruptor.ambient_occlusion", value); } + +void mark_occlusion_shape(int reach, int strength) { + mark("disruptor.occlusion_reach", reach); + mark("disruptor.occlusion_strength", strength); +} + +void mark_gap_filling(bool value) { mark("disruptor.gap_filling", value); } + +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) { + gl_renderer_set_ssao_view(view >= 1 && view <= 4 ? view : 0); + gl_renderer_set_mesh_view(view == 5 ? 1 : 0); +} + +void mark_diagnostic_view(int value) { + g_preferences.pending.has_diagnostic_view = true; + g_preferences.pending.diagnostic_view = value; + 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. +void apply_smooth_turn(bool enabled) { + disruptor_high_precision_camera_set_enabled(enabled ? 1 : 0); + if (!env_override_present("PSX_GEOMETRY_FULL_YAW")) gl_renderer_set_full_yaw(enabled ? 1 : 0); +} + +void mark_smooth_turn(bool value) { mark("disruptor.high_precision_camera", value); } + +void mark_present_rate(int value) { mark("video.frame_interpolation_fps", value); } + +void mark_present_enabled(bool value) { mark("video.frame_interpolation", value); } + +namespace { + +void merge_dirty_preferences(PSXRecompV4::UserSettings &settings) { + const auto &pending = g_preferences.pending; + 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.diagnostic_view_changed) { + settings.has_diagnostic_view = true; + settings.diagnostic_view = pending.diagnostic_view; + } +} + +} // namespace + +bool flush_preferences() { + if (!g_preferences.dirty()) return true; + if (g_preferences.path.empty()) { + g_preferences.save_failed = true; + g_preferences.status = "Runtime settings path is unavailable."; + return false; + } + + PSXRecompV4::UserSettings settings = + PSXRecompV4::load_user_settings(g_preferences.path); + if (settings.parse_error) { + g_preferences.save_failed = true; + g_preferences.status = + "settings.toml is invalid; it was left untouched."; + return false; + } + merge_dirty_preferences(settings); + if (!PSXRecompV4::save_user_settings(g_preferences.path, settings)) { + g_preferences.save_failed = true; + g_preferences.status = + "Could not save settings; the previous file is intact."; + return false; + } + + g_preferences.clean(); + g_preferences.save_failed = false; + g_preferences.status = "Settings saved."; + std::fprintf(stdout, "disruptor: saved in-game settings to %s\n", + utf8(g_preferences.path).c_str()); + return true; +} + +namespace { + +void apply_saved_preferences(const PSXRecompV4::UserSettings &settings) { + /* Force the legacy INI + environment fallback to load before the saved + * layer is applied. Presence (including an explicit zero) makes an + * environment value authoritative for this launch. */ + (void)disruptor_mouse_horizontal_sensitivity(); + (void)disruptor_mouse_invert_horizontal(); + + if (settings.has_mouse_aim && + !env_override_present("PSX_DISRUPTOR_MOUSE_AIM")) + disruptor_mouse_aim_set_enabled(settings.mouse_aim ? 1 : 0); + if (settings.has_modern_controls && + !env_override_present("PSX_DISRUPTOR_MODERN_CONTROLS")) + set_modern_controls_live(settings.modern_controls); + if (settings.has_horizontal_sensitivity && + !env_override_present("PSX_DISRUPTOR_MOUSE_SENSITIVITY")) + (void)disruptor_mouse_set_horizontal_sensitivity( + settings.horizontal_sensitivity); + if (settings.has_invert_horizontal && + !env_override_present("PSX_DISRUPTOR_MOUSE_INVERT_X")) + disruptor_mouse_set_invert_horizontal( + settings.invert_horizontal ? 1 : 0); + if (settings.has_vertical_look && + !env_override_present("PSX_DISRUPTOR_VERTICAL_LOOK")) + disruptor_mouse_set_vertical_look_enabled( + settings.vertical_look ? 1 : 0); + if (settings.has_vertical_sensitivity && + !env_override_present("PSX_DISRUPTOR_MOUSE_SENSITIVITY_Y")) + (void)disruptor_mouse_set_vertical_sensitivity( + settings.vertical_sensitivity); + if (settings.has_invert_vertical && + !env_override_present("PSX_DISRUPTOR_MOUSE_INVERT_Y")) + disruptor_mouse_set_invert_vertical( + settings.invert_vertical ? 1 : 0); + + /* An inherited explicit environment override can enable Modern controls + * without a run-script switch (and therefore without its preset copy). + * Always reconcile the live mode with the in-memory keyboard overlay so + * S/A do not retain conflicting classic face-button bindings. */ + apply_modern_keybinds(disruptor_modern_controls_enabled() != 0); + + /* main.cpp applies these fields before the environment layer. Repeat the + * live application here so a host-UI soft session also gets the saved + * state, while still respecting explicit per-launch overrides. */ + if (settings.has_geometry_correction && + !env_override_present("PSX_GEOMETRY_CORRECTION")) + gpu_geometry_correction_set(settings.geometry_correction ? 1 : 0); + if (settings.has_perspective_textures && + !env_override_present("PSX_TEXTURE_CORRECTION")) { + gpu_texture_correction_set( + settings.perspective_textures && + gpu_geometry_correction_enabled() + ? 1 : 0); + } + if (settings.has_supersampling) + (void)psx_host_video_set_internal_scale(settings.supersampling); + if (settings.has_fullscreen) + g_menu.fullscreen_apply_failed = + psx_host_video_set_fullscreen_mode(settings.fullscreen) == 0; + if (settings.has_master_volume) + (void)psx_host_audio_set_master_volume(settings.master_volume); + if (settings.has_audio_muted) + (void)psx_host_audio_set_muted(settings.audio_muted ? 1 : 0); + if (settings.has_hud_scale) gpu_ws_set_hud_scale(settings.hud_scale); + if (settings.has_frame_unlock && + !env_override_present("PSX_DISRUPTOR_FRAME_UNLOCK")) + disruptor_frame_rate_set_unlocked(settings.frame_unlock ? 1 : 0); + if (settings.has_skip_intro && + !env_override_present("PSX_DISRUPTOR_SKIP_INTRO")) + disruptor_intro_skip_set_enabled(settings.skip_intro ? 1 : 0); + if (settings.has_language_disc) g_language_disc = settings.language_disc; + if (settings.has_improved_shadows && !env_override_present("PSX_DISRUPTOR_IMPROVED_SHADOWS")) + gpu_set_shadow_shape(settings.improved_shadows ? 1 : 0); + if (!env_override_present("PSX_GL_SSAO")) { + if (settings.has_ambient_occlusion) gl_renderer_set_ssao(settings.ambient_occlusion ? 1 : 0); + /* A layer is a session's own. Only a capsule's replay starts in the one its recording was made in. */ + apply_diagnostic_view(settings.has_diagnostic_view && env_override_present("PSX_DISRUPTOR_CAPSULE_REPLAY") + ? settings.diagnostic_view : 0); + } + gl_renderer_set_ssao_shape( + settings.has_occlusion_reach && !env_override_present("PSX_GL_SSAO_RADIUS") + ? static_cast(settings.occlusion_reach) : -1.0f, + settings.has_occlusion_strength && !env_override_present("PSX_GL_SSAO_STRENGTH") + ? settings.occlusion_strength / 100.0f : -1.0f); + 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); + if (settings.has_depth_sorted_world && !env_override_present("PSX_GL_WORLD_DEPTH")) + gl_renderer_set_world_depth(settings.depth_sorted_world ? 1 : 0); + // The session's start switched it off after the mouse module read its variable, so the variable is applied here too. + 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); +} + +void apply_pending_preferences() { + const auto &pending = g_preferences.pending; + if (changed("disruptor.mouse_aim")) + disruptor_mouse_aim_set_enabled(pending.mouse_aim ? 1 : 0); + if (changed("disruptor.modern_controls")) + set_modern_controls_live(pending.modern_controls); + if (changed("disruptor.horizontal_sensitivity")) + (void)disruptor_mouse_set_horizontal_sensitivity( + pending.horizontal_sensitivity); + if (changed("disruptor.invert_horizontal")) + disruptor_mouse_set_invert_horizontal( + pending.invert_horizontal ? 1 : 0); + if (changed("disruptor.vertical_look")) + disruptor_mouse_set_vertical_look_enabled( + pending.vertical_look ? 1 : 0); + if (changed("disruptor.vertical_sensitivity")) + (void)disruptor_mouse_set_vertical_sensitivity( + pending.vertical_sensitivity); + if (changed("disruptor.invert_vertical")) + disruptor_mouse_set_invert_vertical( + pending.invert_vertical ? 1 : 0); + if (changed("disruptor.geometry_correction")) + gpu_geometry_correction_set(pending.geometry_correction ? 1 : 0); + if (changed("disruptor.perspective_textures")) + gpu_texture_correction_set( + pending.perspective_textures && + gpu_geometry_correction_enabled() + ? 1 : 0); + if (changed("video.vsync")) + (void)psx_host_video_set_vsync(pending.vsync); + 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 (changed("video.supersampling")) + (void)psx_host_video_set_internal_scale(pending.supersampling); + if (changed("video.fullscreen")) + g_menu.fullscreen_apply_failed = + psx_host_video_set_fullscreen_mode(pending.fullscreen) == 0; + if (changed("audio.master_volume")) + (void)psx_host_audio_set_master_volume(pending.master_volume); + if (changed("audio.muted")) + (void)psx_host_audio_set_muted(pending.audio_muted ? 1 : 0); + if (changed("disruptor.hud_scale")) + gpu_ws_set_hud_scale(pending.hud_scale); + if (changed("disruptor.frame_unlock")) + disruptor_frame_rate_set_unlocked(pending.frame_unlock ? 1 : 0); + if (changed("disruptor.skip_intro")) + disruptor_intro_skip_set_enabled(pending.skip_intro ? 1 : 0); + if (changed("disruptor.improved_shadows")) + gpu_set_shadow_shape(pending.improved_shadows ? 1 : 0); + if (changed("disruptor.ambient_occlusion")) + gl_renderer_set_ssao(pending.ambient_occlusion ? 1 : 0); + 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 (changed("disruptor.gap_filling")) + gl_renderer_set_gap_fill(pending.gap_filling ? 1 : 0); + if (changed("disruptor.depth_sorted_world")) + gl_renderer_set_world_depth(pending.depth_sorted_world ? 1 : 0); + if (g_preferences.diagnostic_view_changed) + apply_diagnostic_view(pending.diagnostic_view); + if (changed("disruptor.high_precision_camera")) + apply_smooth_turn(pending.high_precision_camera); +#ifndef PSX_DISABLE_FRAME_INTERPOLATION + if (changed("video.frame_interpolation")) + (void)disruptor_present_rate_enable(pending.frame_interpolation ? 1 : 0); + if (changed("video.frame_interpolation_fps")) + (void)disruptor_present_rate_apply(pending.frame_interpolation_fps); +#endif +} + +} // namespace + +void load_preferences_for_session() { + const char *path = psx_host_user_settings_path(); + if (!path || !path[0]) { + g_preferences.loaded = false; + g_preferences.status = "Runtime settings path is unavailable."; + return; + } + const std::string_view text(path); + g_preferences.path = fs::path(std::u8string(text.begin(), text.end())); + const PSXRecompV4::UserSettings settings = + PSXRecompV4::load_user_settings(g_preferences.path); + if (settings.parse_error) { + g_preferences.loaded = false; + g_preferences.status = + "settings.toml is invalid; saved menu values were not applied."; + return; + } + capture_base_keybinds(); + apply_saved_preferences(settings); + apply_pending_preferences(); + /* A capsule recorded later copies the file: a layer left there by the last session must not go into it. */ + if (settings.has_diagnostic_view && settings.diagnostic_view != 0 && !env_override_present("PSX_DISRUPTOR_CAPSULE_REPLAY")) { + mark_diagnostic_view(0); + (void)flush_preferences(); + } + g_preferences.loaded = true; + if (!g_preferences.save_failed) + g_preferences.status = "Settings loaded."; +} + +} // namespace disruptor::menu diff --git a/src/game/ui/menu/rows.cpp b/src/game/ui/menu/rows.cpp new file mode 100644 index 0000000..146d831 --- /dev/null +++ b/src/game/ui/menu/rows.cpp @@ -0,0 +1,62 @@ +#include "game/ui/menu/menu.h" + +namespace disruptor::menu { + +namespace { + +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; +} + +} // namespace + +bool applies(const char *key) { + return disruptor::settings::applies(*find_option(key), prerequisite_on); +} + +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; +} + +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; +} + +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; +} + +bool option_list(const char *key, int *index, size_t skipped) { + 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 draw_status_badge(const char *label, const ImVec4 &colour) { + ImGui::SameLine(); + ImGui::TextColored(colour, "[%s]", label); +} + +} // namespace disruptor::menu diff --git a/src/game/ui/menu/tab_cheats.cpp b/src/game/ui/menu/tab_cheats.cpp new file mode 100644 index 0000000..cbaf04d --- /dev/null +++ b/src/game/ui/menu/tab_cheats.cpp @@ -0,0 +1,92 @@ +#include "game/ui/menu/menu.h" + +namespace disruptor::menu { + +namespace { + +const char *cheat_result_message(int result, const char *success) { + switch (result) { + case DISRUPTOR_CHEAT_OK: + return success; + case DISRUPTOR_CHEAT_GAME_NOT_READY: + return "Enter live gameplay before using this cheat."; + case DISRUPTOR_CHEAT_NETPLAY_BLOCKED: + return "Cheats are disabled during netplay."; + case DISRUPTOR_CHEAT_UNVERIFIED_STATE: + return "The game state did not match the verified USA executable; no changes were made."; + default: + return "The cheat request was rejected."; + } +} + +} // namespace + +void draw_cheats_tab() { + const bool netplay = disruptor_cheats_netplay_blocked() != 0; + const bool gameplay_ready = disruptor_cheats_gameplay_ready() != 0; + + ImGui::TextWrapped( + "These are version-pinned gameplay cheats for testing and casual " + "play. They are never saved as port settings and start off on every " + "launch."); + if (netplay) { + ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.25f, 1.0f), + "Cheats are unavailable while netplay is active."); + } else if (!gameplay_ready) { + ImGui::TextDisabled("Enter live gameplay to enable cheats."); + } + + ImGui::SeparatorText("Player"); + bool god_mode = disruptor_cheats_god_mode_enabled() != 0; + const bool disable_god_control = !god_mode && (netplay || !gameplay_ready); + if (disable_god_control) ImGui::BeginDisabled(); + if (ImGui::Checkbox("God mode", &god_mode)) { + const int result = disruptor_cheats_set_god_mode(god_mode ? 1 : 0); + g_menu.cheat_status = cheat_result_message( + result, god_mode ? "God mode enabled for this session." + : "God mode disabled."); + } + if (disable_god_control) ImGui::EndDisabled(); + draw_status_badge("SESSION", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + ImGui::TextDisabled( + "Neutralises player damage at the game's central damage routine. " + "A light hit cue remains, but saved health is not altered and the " + "game is not marked as cheated."); + + ImGui::SeparatorText("Inventory"); + if (netplay || !gameplay_ready) ImGui::BeginDisabled(); + if (ImGui::Button("Grant all weapons + psionics")) + ImGui::OpenPopup("Confirm All Weapons"); + if (netplay || !gameplay_ready) ImGui::EndDisabled(); + ImGui::TextWrapped( + "This reproduces the retail All Weapons cheat: all weapons and " + "psionics are unlocked, ammunition and psionic energy are refilled, " + "and the current game is marked as cheated."); + ImGui::TextColored(ImVec4(1.0f, 0.73f, 0.25f, 1.0f), + "Important: the cheated marker affects the ending and is carried into " + "memory-card/password saves."); + + bool popup_open = true; + if (ImGui::BeginPopupModal("Confirm All Weapons", &popup_open, + ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::TextWrapped( + "Grant every weapon and psionic, refill their resources, and " + "mark this game (including later saves) as cheated?"); + if (ImGui::Button("Grant and mark cheated")) { + const int result = disruptor_cheats_grant_all_weapons(); + g_menu.cheat_status = cheat_result_message( + result, "All weapons, psionics and resources granted. Game marked as cheated."); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } + + if (!g_menu.cheat_status.empty()) { + ImGui::Separator(); + ImGui::TextWrapped("%s", g_menu.cheat_status.c_str()); + } +} + +} // namespace disruptor::menu diff --git a/src/game/ui/menu/tab_controls.cpp b/src/game/ui/menu/tab_controls.cpp new file mode 100644 index 0000000..6525e9c --- /dev/null +++ b/src/game/ui/menu/tab_controls.cpp @@ -0,0 +1,84 @@ +#include "game/ui/menu/menu.h" + +namespace disruptor::menu { + +void draw_controls_tab() { + bool mouse_aim = disruptor_mouse_aim_enabled() != 0; + 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 (option_toggle("disruptor.modern_controls", &modern)) { + set_modern_controls_live(modern); + mark_modern_controls(modern); + } + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + + float sensitivity = + static_cast(disruptor_mouse_horizontal_sensitivity()); + 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 (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 = applies("disruptor.high_precision_camera"); + bool smooth = disruptor_high_precision_camera_enabled() != 0; + if (!turn_applies) ImGui::BeginDisabled(); + 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::SeparatorText("Vertical look"); + bool vertical_look = disruptor_mouse_vertical_look_enabled() != 0; + if (option_toggle("disruptor.vertical_look", &vertical_look)) { + disruptor_mouse_set_vertical_look_enabled(vertical_look ? 1 : 0); + mark_vertical_look(vertical_look); + } + draw_status_badge("EXPERIMENTAL", ImVec4(1.0f, 0.73f, 0.25f, 1.0f)); + + float vertical_sensitivity = + static_cast(disruptor_mouse_vertical_sensitivity()); + 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 (option_toggle("disruptor.invert_vertical", &invert_y)) { + disruptor_mouse_set_invert_vertical(invert_y ? 1 : 0); + mark_invert_y(invert_y); + } + + const double pitch_units = disruptor_mouse_vertical_pitch(); + if (pitch_units == 0.0) ImGui::BeginDisabled(); + if (ImGui::Button("Recenter vertical view")) + disruptor_mouse_recenter_vertical(); + if (pitch_units == 0.0) ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::Text("Requested pitch: %+.2f deg", pitch_units * (360.0 / 256.0)); + ImGui::TextDisabled( + "Range: +/-30.94 degrees. The original game has no pitch state."); + ImGui::Separator(); + ImGui::TextWrapped( + "The menu releases relative mouse capture while open and restores the " + "previous state when closed. Gameplay input is neutralised while you " + "interact with the menu."); + ImGui::Text("Capture before opening: %s", + g_menu.restore_mouse_capture ? "yes" : "no"); +} + +} // namespace disruptor::menu diff --git a/src/game/ui/menu/tab_debug.cpp b/src/game/ui/menu/tab_debug.cpp new file mode 100644 index 0000000..e11febc --- /dev/null +++ b/src/game/ui/menu/tab_debug.cpp @@ -0,0 +1,31 @@ +#include "game/ui/menu/menu.h" + +namespace disruptor::menu { + +void draw_debug_tab() { + ImGui::TextDisabled("Tools for a report of a fault in the picture."); + ImGui::Spacing(); + + if (ImGui::Button("Record the screen") && !gl_renderer_recording_shown()) g_menu.record_shown = true; + draw_status_badge("SESSION", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + ImGui::TextDisabled( + "For a report: two seconds of the pictures that reach the screen, with " + "their times. It starts a second after this menu closes."); + if (g_menu.record_shown) ImGui::TextDisabled("Armed. Close the menu and do what you want to show."); + else if (!g_menu.shown_note.empty()) ImGui::TextDisabled("%s", g_menu.shown_note.c_str()); + + ImGui::Separator(); + int view = gl_renderer_mesh_view() ? 5 : gl_renderer_ssao_view(); + ImGui::SetNextItemWidth(260.0f); + if (ImGui::Combo("Diagnostic view", &view, "Off\0Shading alone\0Depth\0Surface normals\0Fog fade\0Mesh\0")) { + apply_diagnostic_view(view); + mark_diagnostic_view(view); + } + draw_status_badge("SESSION", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + ImGui::TextDisabled( + "Shows a layer of the renderer in the picture's place, for reports. A " + "replay capsule recorded with one on replays in it. Off again at the " + "next start."); +} + +} // namespace disruptor::menu diff --git a/src/game/ui/menu/tab_enhancements.cpp b/src/game/ui/menu/tab_enhancements.cpp new file mode 100644 index 0000000..aa194da --- /dev/null +++ b/src/game/ui/menu/tab_enhancements.cpp @@ -0,0 +1,236 @@ +#include "game/ui/menu/menu.h" + +namespace disruptor::menu { + +namespace { + +void apply_geometry_enabled(bool enabled) { + if (!enabled && gpu_texture_correction_enabled()) { + gpu_texture_correction_set(0); + mark_textures(false); + } +#ifndef PSX_DISABLE_FRAME_INTERPOLATION + if (!enabled) { + (void)disruptor_present_rate_enable(0); + mark_present_enabled(false); + } +#endif + gpu_geometry_correction_set(enabled ? 1 : 0); + mark_geometry(enabled); +} + +void draw_aspect_controls() { + static constexpr int kAspectNumerators[] = {16, 21, 32, 32}; + static constexpr int kAspectDenominators[] = {9, 9, 9, 9}; + static constexpr int kFixedWideAspectCount = 3; + static constexpr int kAdaptiveAspectIndex = 3; + static constexpr int kAspectCount = 4; + + int numerator = 4; + int denominator = 3; + psx_host_video_get_display_aspect(&numerator, &denominator); + const bool adaptive = psx_host_video_get_adaptive_view() != 0; + bool widescreen = adaptive || numerator * 3 != denominator * 4; + if (adaptive) { + g_menu.selected_wide_aspect = kAdaptiveAspectIndex; + } else if (widescreen) { + for (int index = 0; index < kFixedWideAspectCount; ++index) { + if (numerator * kAspectDenominators[index] == + denominator * kAspectNumerators[index]) { + g_menu.selected_wide_aspect = index; + break; + } + } + } + g_menu.selected_wide_aspect = std::clamp( + g_menu.selected_wide_aspect, 0, kAspectCount - 1); + + ImGui::SeparatorText("Display aspect"); + if (ImGui::Checkbox("Widescreen", &widescreen)) { + if (widescreen && + g_menu.selected_wide_aspect == kAdaptiveAspectIndex) { + g_menu.aspect_apply_failed = + psx_host_video_set_adaptive_view(1) == 0; + if (!g_menu.aspect_apply_failed) + mark_aspect(32, 9, true); + } else { + const int requested_num = widescreen + ? kAspectNumerators[g_menu.selected_wide_aspect] : 4; + const int requested_den = widescreen + ? kAspectDenominators[g_menu.selected_wide_aspect] : 3; + g_menu.aspect_apply_failed = + psx_host_video_set_display_aspect( + requested_num, requested_den) == 0; + if (!g_menu.aspect_apply_failed) + mark_aspect(requested_num, requested_den, false); + } + } + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + + int selected = g_menu.selected_wide_aspect; + if (option_list("video.aspect_ratio", &selected, 1)) { + g_menu.selected_wide_aspect = selected; + if (widescreen) { + if (selected == kAdaptiveAspectIndex) { + g_menu.aspect_apply_failed = + psx_host_video_set_adaptive_view(1) == 0; + if (!g_menu.aspect_apply_failed) + mark_aspect(32, 9, true); + } else { + const int requested_num = kAspectNumerators[selected]; + const int requested_den = kAspectDenominators[selected]; + g_menu.aspect_apply_failed = + psx_host_video_set_display_aspect( + requested_num, requested_den) == 0; + if (!g_menu.aspect_apply_failed) + mark_aspect(requested_num, requested_den, false); + } + } + } + if (g_menu.aspect_apply_failed) { + ImGui::TextColored( + ImVec4(1.0f, 0.35f, 0.25f, 1.0f), + "The runtime rejected the requested display aspect."); + } + ImGui::TextDisabled( + "4:3 disables widescreen. Fixed 16:9, 21:9 and 32:9 choices or " + "live window matching (capped at 32:9) apply after the current " + "frame and are saved."); + + if (gpu_sprite_placement_available()) { + int shadow_shape = gpu_shadow_shape(); + ImGui::SetNextItemWidth(260.0f); + 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)); + } + + int hud_scale = gpu_ws_hud_scale(); + ImGui::SetNextItemWidth(260.0f); + 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)); +} + +void draw_internal_resolution_controls() { + static constexpr int kScaleCount = 8; + int selected = std::clamp( + psx_host_video_get_internal_scale(), 1, kScaleCount) - 1; + + ImGui::SeparatorText("Rendering resolution"); + 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; + if (!g_menu.internal_scale_apply_failed) + mark_supersampling(requested); + } + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + if (g_menu.internal_scale_apply_failed) { + ImGui::TextColored( + ImVec4(1.0f, 0.35f, 0.25f, 1.0f), + "The renderer could not allocate that internal resolution."); + } + ImGui::TextDisabled( + "Supersamples geometry and shading before presentation. GPU fill and " + "memory cost grow roughly with the square of this value; changes " + "apply immediately and are saved."); +} + +} // namespace + +void draw_enhancements_tab() { + draw_aspect_controls(); + draw_internal_resolution_controls(); + + bool geometry = gpu_geometry_correction_enabled() != 0; + 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; + 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 (!textures_apply) ImGui::EndDisabled(); + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + if (!textures_apply) + ImGui::TextDisabled("Enable exact geometry before perspective textures."); + + bool gaps = gl_renderer_gap_fill() != 0; + 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 (!gaps_apply) ImGui::EndDisabled(); + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + + bool by_depth = gl_renderer_world_depth() != 0; + 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 (!depth_applies) ImGui::EndDisabled(); + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + + ImGui::SeparatorText("Synchronisation"); + int vsync = psx_host_video_get_vsync(); + int vsync_index = std::clamp(vsync + 1, 0, 2); + 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 (option_toggle("disruptor.frame_unlock", &unlocked)) { + disruptor_frame_rate_set_unlocked(unlocked ? 1 : 0); + mark_frame_unlock(unlocked); + } + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + DisruptorFrameRateWindow window{}; + if (disruptor_frame_rate_last_window(&window) && window.vblanks != 0u) { + ImGui::TextDisabled( + "Game frames: %.1f per second. Frame work: %.2f VBlank average, " + "%.2f peak, %u of %u frames over one VBlank.", + 59.94 * window.frames / window.vblanks, + window.average_work_permille / 1000.0, + window.peak_work_permille / 1000.0, + window.late_frames, window.frames); + } + +#ifndef PSX_DISABLE_FRAME_INTERPOLATION + int present_enabled = 0; + 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( + 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 (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)); +} + +} // namespace disruptor::menu diff --git a/src/game/ui/menu/tab_experimental.cpp b/src/game/ui/menu/tab_experimental.cpp new file mode 100644 index 0000000..5a101f0 --- /dev/null +++ b/src/game/ui/menu/tab_experimental.cpp @@ -0,0 +1,37 @@ +#include "game/ui/menu/menu.h" + +namespace disruptor::menu { + +void draw_experimental_tab() { + ImGui::TextDisabled( + "Unfinished features of the OpenGL renderer. All of them need geometry " + "correction on."); + ImGui::Spacing(); + + 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 (!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)); + 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); + ImGui::SetNextItemWidth(260.0f); + const bool strength_moved = + 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); + } +} + +} // namespace disruptor::menu diff --git a/src/game/ui/menu/tab_language.cpp b/src/game/ui/menu/tab_language.cpp new file mode 100644 index 0000000..5d428bd --- /dev/null +++ b/src/game/ui/menu/tab_language.cpp @@ -0,0 +1,20 @@ +#include "game/ui/menu/menu.h" + +namespace disruptor::menu { + +void draw_language_tab() { + ImGui::TextWrapped("Disc of another region: %s", g_language_disc.empty() ? "none, the game is in English" : g_language_disc.c_str()); + if (ImGui::Button("Choose a disc image")) (void)psx_host_pick_disc_image_begin(); + ImGui::SameLine(); + if (ImGui::Button("English") && !g_language_disc.empty()) mark_language_disc(std::string()); + ImGui::SameLine(); + if (ImGui::Button("Restart now") && !psx_netplay_active() && flush_preferences() && disruptor_restart_arm()) { + SDL_Event quit{}; + quit.type = SDL_QUIT; + SDL_PushEvent(&quit); + } + ImGui::TextDisabled("Movies, speech and words come from that disc at the next start. The French, German and Japanese discs are known."); + ImGui::TextDisabled("The Japanese disc gives movies, speech and hints: its menus are in English on the disc itself."); +} + +} // namespace disruptor::menu diff --git a/src/game/ui/menu/tab_system.cpp b/src/game/ui/menu/tab_system.cpp new file mode 100644 index 0000000..e2f0036 --- /dev/null +++ b/src/game/ui/menu/tab_system.cpp @@ -0,0 +1,87 @@ +#include "game/ui/menu/menu.h" + +namespace disruptor::menu { + +namespace { + +void draw_fullscreen_controls() { + int mode = std::clamp(psx_host_video_get_fullscreen_mode(), 0, 2); + + ImGui::SeparatorText("Window presentation"); + 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) + mark_fullscreen(mode); + } + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + if (g_menu.fullscreen_apply_failed) { + ImGui::TextColored( + ImVec4(1.0f, 0.35f, 0.25f, 1.0f), + "The runtime rejected the requested display mode."); + } + ImGui::TextDisabled( + "Menu selections apply immediately and are saved. Alt+Enter remains " + "a transient window/fullscreen toggle and does not overwrite the " + "saved mode."); +} + +void draw_audio_controls() { + ImGui::SeparatorText("Audio output"); + int volume = psx_host_audio_get_master_volume(); + ImGui::SetNextItemWidth(260.0f); + 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 (option_toggle("audio.muted", &muted) && + psx_host_audio_set_muted(muted ? 1 : 0)) { + mark_audio_muted(muted); + } + ImGui::TextDisabled( + "Host-side output only: game audio state and saves are unchanged. " + "Unmuting restores the selected volume."); +} + +} // namespace + +void draw_system_tab() { + draw_fullscreen_controls(); + draw_audio_controls(); + ImGui::TextWrapped( + "Live menu choices are merged into the runtime's user-owned " + "settings.toml. Writes use an atomic replacement, so a failed save " + "leaves the previous file intact. Explicit launch flags still win " + "for that run."); + ImGui::SeparatorText("Settings persistence"); + ImGui::TextWrapped("Path: %s", + g_preferences.path.empty() + ? "unavailable" : utf8(g_preferences.path).c_str()); + if (g_preferences.save_failed) + ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.25f, 1.0f), + "Not saved"); + 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")) + (void)flush_preferences(); + ImGui::TextDisabled( + "Current vertical pitch, menu layout, " + "and mouse capture are not saved. Cheat controls start " + "off each launch."); + ImGui::SeparatorText("Restart required"); + ImGui::BulletText("Renderer backend"); + ImGui::BulletText("Audio backend and buffer configuration"); + ImGui::BulletText("BIOS, disc and memory-card wiring"); + ImGui::Separator(); + if (ImGui::Button("Close menu")) set_menu_open(false); +} + +} // namespace disruptor::menu diff --git a/src/launcher/CMakeLists.txt b/src/launcher/CMakeLists.txt index 10696dc..2005277 100644 --- a/src/launcher/CMakeLists.txt +++ b/src/launcher/CMakeLists.txt @@ -8,7 +8,8 @@ target_link_libraries(disruptor-disc-import PUBLIC bcrypt) # settings.toml goes through the game's own reader and writer. add_library(disruptor-launcher-config STATIC "${PSXRECOMP_ROOT}/recompiler/src/config_loader.cpp" - "${PSXRECOMP_ROOT}/recompiler/src/ps1_exe_parser.cpp") + "${PSXRECOMP_ROOT}/recompiler/src/ps1_exe_parser.cpp" + "${PROJECT_SOURCE_DIR}/src/game/settings/saved.cpp") target_compile_features(disruptor-launcher-config PUBLIC cxx_std_20) target_include_directories(disruptor-launcher-config PUBLIC "${PSXRECOMP_ROOT}/recompiler/src" @@ -24,7 +25,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 +60,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 +83,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/contracts/menu_sources.py b/tests/contracts/menu_sources.py new file mode 100644 index 0000000..85fa1ed --- /dev/null +++ b/tests/contracts/menu_sources.py @@ -0,0 +1,17 @@ +"""The in-game menu's sources as one text: the shared header, then each source in the order the build lists it.""" + +from pathlib import Path + +FOLDER = "src/game/ui/menu" + + +def menu_sources(root: Path) -> list[str]: + """Returns the menu's files as paths from the repository root.""" + build = (root / "CMakeLists.txt").read_text(encoding="utf-8") + listed = [line.strip().strip('")').rsplit("}/", 1)[1] for line in build.splitlines() if f"/{FOLDER}/" in line] + return [f"{FOLDER}/menu.h", *listed] + + +def menu_text(root: Path) -> str: + """Returns the text of all of them, joined.""" + return "".join((root / name).read_text(encoding="utf-8") for name in menu_sources(root)) diff --git a/tests/test_ambient_occlusion_contract.py b/tests/contracts/test_ambient_occlusion_contract.py similarity index 60% rename from tests/test_ambient_occlusion_contract.py rename to tests/contracts/test_ambient_occlusion_contract.py index c5374f7..226c1a2 100644 --- a/tests/test_ambient_occlusion_contract.py +++ b/tests/contracts/test_ambient_occlusion_contract.py @@ -3,7 +3,9 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +from menu_sources import menu_text + +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -21,13 +23,25 @@ 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") +renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") +menu = menu_text(ROOT) launcher = read("src/launcher/launcher_settings.cpp") -loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") -settings = read("psxrecomp-overlay/recompiler/src/config_loader.h") +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("src/game/settings/saved.cpp") +settings = read("src/game/settings/game_user_settings.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 +81,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 +135,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 +164,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 85% rename from tests/test_backdrop_tiles_contract.py rename to tests/contracts/test_backdrop_tiles_contract.py index b60ad84..8251778 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,33 +53,29 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_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") -interpreter = read("psxrecomp-overlay/runtime/src/dirty_ram_interp.c") +source = read("src/game/render/backdrop_tiles.cpp") +gpu = read("psxrecomp/runtime/src/gpu.c") +gpu_header = read("psxrecomp/runtime/include/gpu.h") +codegen = read("psxrecomp/recompiler/src/code_generator.cpp") +configs = read("game.toml") + read("game-widescreen.toml") 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( - "PSX_HAS_DISRUPTOR_BACKDROP_TILES=1" in cmake, - "the Disruptor target must opt into the dirty-interpreter backdrop seams", -) -require( - "+runtime/include/gpu_ws_screen_tile.h" in read("PSXRECOMP_OVERLAY_FILES.txt").splitlines(), + "+runtime/include/gpu_ws_screen_tile.h" in read("framework/FILES.txt").splitlines(), "the tile header must be applied to the framework checkout", ) -for text, name in ((source, "host hook"), (codegen, "code generator"), (interpreter, "dirty interpreter"), (audit, "codegen audit")): +for text, name in ((source, "host hook"), (codegen, "code generator"), (configs, "game configuration"), (audit, "codegen audit")): upper = text.upper() require( all(f"0X{address:08X}" in upper and f"0X{word:08X}" in upper for address, word in TILE_SITES.items()), @@ -87,7 +83,7 @@ def require(condition: bool, message: str) -> None: ) require( "disruptor_backdrop_tiles_instruction_hook(cpu, " in codegen - and "disruptor_backdrop_tiles_instruction_hook(cpu, pc, insn, 1);" in interpreter, + and configs.count('callback = "disruptor_backdrop_tiles_instruction_hook"\n') == 2, "compiled and interpreted code must reach the same host hook", ) require( diff --git a/tests/test_billboard_aspect_contract.py b/tests/contracts/test_billboard_aspect_contract.py similarity index 79% rename from tests/test_billboard_aspect_contract.py rename to tests/contracts/test_billboard_aspect_contract.py index 7d37aee..6b8922a 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,13 +19,13 @@ def require(condition: bool, message: str) -> None: raise SystemExit(1) -source = read("src/disruptor_billboard_aspect.cpp") -header = read("src/disruptor_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") -gpu_h = read("psxrecomp-overlay/runtime/include/gpu.h") -tag_match_h = read("psxrecomp-overlay/runtime/include/gpu_ws_tag_match.h") +source = read("src/game/render/billboard_aspect.cpp") +header = read("src/game/render/billboard_aspect.h") +codegen = read("psxrecomp/recompiler/src/code_generator.cpp") +configs = read("game.toml") + read("game-widescreen.toml") +gpu = read("psxrecomp/runtime/src/gpu.c") +gpu_h = read("psxrecomp/runtime/include/gpu.h") +tag_match_h = read("psxrecomp/runtime/include/gpu_ws_tag_match.h") audit = read("tools/audit_codegen.py") cmake = read("CMakeLists.txt") @@ -59,19 +59,17 @@ def require(condition: bool, message: str) -> None: require(f"0x{pc:08X}u" in source_table and f"0x{word:08X}u" in source_table, f"host hook table is missing 0x{pc:08X}") - require(f"0x{pc:08X}u" in dirty and f"0x{word:08X}u" in dirty, - f"dirty interpreter is missing 0x{pc:08X}") + require(configs.count(f'["0x{pc:08X}", "0x{word:08X}"],') == 2, + f"code that runs from RAM is not hooked at 0x{pc:08X}") require("disruptor_billboard_aspect_instruction_hook(cpu" in codegen and '"\\n{}disruptor_billboard_aspect_instruction_hook(cpu, "' in codegen, "compiled hooks must be separate post-instruction statements") -require("#ifdef PSX_HAS_DISRUPTOR_BILLBOARD_ASPECT" in dirty and - "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, - "runtime source, dirty seam, and focused behavior test must be enabled") +require(configs.count('callback = "disruptor_billboard_aspect_instruction_hook"\n') == 2, + "both game configurations must list the hook for code that runs from RAM") +require("src/game/render/billboard_aspect.cpp" in cmake and + "tests/unit/disruptor_billboard_aspect_test.cpp" in cmake, + "runtime source and focused behavior test must be enabled") require("gpu_ws_tag_primitive" in gpu_h and "void gpu_ws_tag_primitive" in gpu and diff --git a/tests/test_borderless_contract.py b/tests/contracts/test_borderless_contract.py similarity index 87% rename from tests/test_borderless_contract.py rename to tests/contracts/test_borderless_contract.py index 0be89d4..475122e 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: @@ -15,8 +15,8 @@ def require(condition: bool, message: str) -> None: raise AssertionError(message) -host = read("psxrecomp-overlay/runtime/src/main.cpp") -renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") +host = read("psxrecomp/runtime/src/main.cpp") +renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") cmake = read("CMakeLists.txt") keep = host[ @@ -59,9 +59,10 @@ def require(condition: bool, message: str) -> None: "every present of the OpenGL renderer fits its picture to the rows that are on the screen", ) 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, + "+runtime/include/host_borderless.h" in read("framework/FILES.txt").splitlines() + 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 81% rename from tests/test_capsule_contract.py rename to tests/contracts/test_capsule_contract.py index 696a2d6..9b965cc 100644 --- a/tests/test_capsule_contract.py +++ b/tests/contracts/test_capsule_contract.py @@ -3,7 +3,9 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +from menu_sources import menu_text + +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -15,15 +17,16 @@ def require(condition: bool, message: str) -> None: raise AssertionError(message) -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") +host = read("psxrecomp/runtime/src/main.cpp") +game_side = read("src/game/host.cpp") +capsule = read("src/game/session/capsule.cpp") +mouse = read("src/game/input/mouse_aim.cpp") +menu = menu_text(ROOT) cmake = read("CMakeLists.txt") require( - "#ifdef PSX_HAS_DISRUPTOR_CAPSULE\n" - " if (!psx_netplay_active()) disruptor_capsule_before_input();\n" + "#ifdef PSX_HAS_GAME_HOST\n" + " if (!psx_netplay_active()) psx_game_before_input();\n" "#endif\n" " if (psx_netplay_active()) {\n" " psx_netplay_finish_frame();\n" @@ -32,14 +35,19 @@ def require(condition: bool, message: str) -> None: " } else {\n" " sample_pad_into_sio(override);\n" " }\n" - "#ifdef PSX_HAS_DISRUPTOR_CAPSULE\n" - " if (!psx_netplay_active()) disruptor_capsule_after_input();\n" + "#ifdef PSX_HAS_GAME_HOST\n" + " if (!psx_netplay_active()) psx_game_after_input();\n" "#endif\n" in host - and host.count("disruptor_capsule_before_input();") == host.count("disruptor_capsule_after_input();") == 1, + and host.count("psx_game_before_input();") == host.count("psx_game_after_input();") == 1 + and "void psx_game_before_input(void) { disruptor_capsule_before_input(); }\n" in game_side + and "void psx_game_after_input(void) { disruptor_capsule_after_input(); }\n" in game_side + and game_side.count("disruptor_capsule_before_input();") == game_side.count("disruptor_capsule_after_input();") == 1 + and game_side.count("disruptor_capsule_state_loaded();") == 1 + and "disruptor_capsule_" not in host, "the capsule is called once on either side of the pad sampling of every VBlank, and never in a network game", ) -sampling = host.index(" if (!psx_netplay_active()) disruptor_capsule_after_input();") +sampling = host.index(" if (!psx_netplay_active()) psx_game_after_input();") require( sampling < host.index(" if (g_headless || module_fast_forward) {") and sampling < host.index(" mod_call_frame_hooks();"), @@ -47,9 +55,10 @@ def require(condition: bool, message: str) -> None: ) require( 'extern "C" void psx_frontend_on_savestate_loaded(void) {\n' - "#ifdef PSX_HAS_DISRUPTOR_CAPSULE\n" - " disruptor_capsule_state_loaded();\n" - "#endif\n" in host, + "#ifdef PSX_HAS_GAME_HOST\n" + " psx_game_state_loaded();\n" + "#endif\n" in host + and "void psx_game_state_loaded(void) {\n disruptor_capsule_state_loaded();\n" in game_side, "the capsule must hear of a loaded state before the game runs on from it", ) require( @@ -128,12 +137,13 @@ 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 - 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, + ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/capsule.cpp"\n' in cmake + and " PSX_HAS_GAME_HOST=1" in cmake + and ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/host.cpp"\n' 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 88% rename from tests/test_debug_tab_contract.py rename to tests/contracts/test_debug_tab_contract.py index 4f138a0..4a7863a 100644 --- a/tests/test_debug_tab_contract.py +++ b/tests/contracts/test_debug_tab_contract.py @@ -3,7 +3,9 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +from menu_sources import menu_text + +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -21,12 +23,12 @@ 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") -loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") -settings = read("psxrecomp-overlay/recompiler/src/config_loader.h") +renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") +menu = menu_text(ROOT) +loader = read("src/game/settings/saved.cpp") +settings = read("src/game/settings/game_user_settings.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 +63,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 +74,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 +90,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 88% rename from tests/test_depth24_sync_contract.py rename to tests/contracts/test_depth24_sync_contract.py index 3f40ac4..97e387c 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: @@ -35,13 +35,10 @@ def enclosing(source: str, block: str) -> list[str]: cmake = read("CMakeLists.txt") -gpu = read("psxrecomp-overlay/runtime/src/gpu.c") -renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") +gpu = read("psxrecomp/runtime/src/gpu.c") +renderer = read("psxrecomp/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 63% rename from tests/test_dev_menu_contract.py rename to tests/contracts/test_dev_menu_contract.py index 92d22df..a6d0471 100644 --- a/tests/test_dev_menu_contract.py +++ b/tests/contracts/test_dev_menu_contract.py @@ -6,8 +6,10 @@ import re from pathlib import Path +from menu_sources import menu_sources, menu_text + +ROOT = Path(__file__).resolve().parents[2] -ROOT = Path(__file__).resolve().parents[1] def read(path: str) -> str: @@ -53,34 +55,72 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: cmake = read("CMakeLists.txt") -manifest = read("PSXRECOMP_OVERLAY_FILES.txt") -host_ui = read("psxrecomp-overlay/runtime/include/host_ui.h") -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") -config_h = read("psxrecomp-overlay/recompiler/src/config_loader.h") -config_cpp = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") +manifest = read("framework/FILES.txt") +host_ui = read("psxrecomp/runtime/include/host_ui.h") +main_cpp = read("psxrecomp/runtime/src/main.cpp") +gl = read("psxrecomp/runtime/src/gpu_gl_renderer.c") +gte = read("psxrecomp/runtime/src/gte.cpp") +menu = menu_text(ROOT) +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/recompiler/src/config_loader.h") + read("src/game/settings/game_user_settings.h") +config_cpp = read("psxrecomp/recompiler/src/config_loader.cpp") + read("src/game/settings/saved.cpp") run_ps1 = read("run.ps1") run_sh = read("run.sh") game_toml = read("game.toml") game_wide_toml = read("game-widescreen.toml") -require("option(DISRUPTOR_DEV_MENU" in cmake, - "developer menu must remain independently build-selectable") +require("option(DISRUPTOR_DEV_MENU" in cmake and + "if(DISRUPTOR_DEV_MENU)\n target_compile_definitions(psx-runtime PRIVATE PSX_FRAME_INTERPOLATION_GEOMETRY_DEFAULT=1)\n" in cmake and + read("psxrecomp/runtime/src/main.cpp").count("#if defined(PSX_FRAME_INTERPOLATION_GEOMETRY_DEFAULT)\n") == 2 and + "DISRUPTOR_DEV_MENU" not in read("psxrecomp/runtime/src/main.cpp"), + "developer menu must remain independently build-selectable, and with it in-between frames start in the geometry mode") require("v1.92.6.tar.gz" in cmake and "5b17c01f69545bde732b14936d89ce0f508adb83e8b56fa82448371845172bc3" in cmake.lower(), "Dear ImGui source must remain versioned and integrity-pinned") -require("src/disruptor_dev_menu.cpp" in cmake and +menu_files = menu_sources(ROOT) +require([name.rsplit("/", 1)[1] for name in menu_files] == [ + "menu.h", "preferences.cpp", "rows.cpp", "tab_controls.cpp", "tab_enhancements.cpp", + "tab_experimental.cpp", "tab_debug.cpp", "tab_cheats.cpp", "tab_system.cpp", "tab_language.cpp", + "menu.cpp"] and + sorted(menu_files) == sorted( + found.relative_to(ROOT).as_posix() for found in (ROOT / "src/game/ui/menu").iterdir()) 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, + "every file of the menu's folder must be built, and linked only into the game runtime") +for name in menu_files[1:]: + source = read(name) + require(source.startswith('#include "game/ui/menu/menu.h"\n\nnamespace disruptor::menu {\n') and + source.endswith("} // namespace disruptor::menu\n") and + source.count("namespace disruptor::menu {") == 1 and "#include" not in source[10:], + f"{name} must hold its definitions in the menu's namespace, behind the menu's one header") +for tab in ("controls", "enhancements", "experimental", "debug", "cheats", "system", "language"): + source = read(f"src/game/ui/menu/tab_{tab}.cpp") + require(f"\nvoid draw_{tab}_tab() {{\n" in source and source.count("_tab() {") == 1 and + f"void draw_{tab}_tab();\n" in read("src/game/ui/menu/menu.h") and + f"draw_{tab}_tab();" in function_body(read("src/game/ui/menu/menu.cpp"), "draw_menu"), + f"the {tab} tab is drawn by its own file, and the menu draws it") +require("PreferenceState g_preferences;\n" in read("src/game/ui/menu/preferences.cpp") and + "DevMenuState g_menu;\n" in read("src/game/ui/menu/menu.cpp") and + "MenuRegistration g_registration;\n" in read("src/game/ui/menu/menu.cpp") and + "bool option_toggle(const char *key, bool *value) {\n" in read("src/game/ui/menu/rows.cpp"), + "the staged settings, the menu's own state and its registration, and the rows each have one file") +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") @@ -205,7 +245,7 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: require(token in scale_setter, f"live internal-scale setter is missing {token}") -sw_header = read("psxrecomp-overlay/runtime/include/gpu_sw_renderer.h") +sw_header = read("psxrecomp/runtime/include/gpu_sw_renderer.h") require("#define SW_MAX_INTERNAL_SCALE 8" in sw_header and "#define GL_MAX_INTERNAL_SCALE SW_MAX_INTERNAL_SCALE" in gl, "renderers must share the 8x internal scale limit") @@ -450,9 +490,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 +556,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 +570,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 +579,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 +596,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 +608,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 +628,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 +642,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 +704,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 +761,189 @@ 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/menu/preferences.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) {\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 "bool option_list(const char *key, int *index, size_t skipped = 0);\n" 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" + "} // namespace\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 82% rename from tests/test_far_rendering_contract.py rename to tests/contracts/test_far_rendering_contract.py index 821ab31..7e6195d 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,21 +19,20 @@ def require(condition: bool, message: str) -> None: raise SystemExit(1) -source = read("src/disruptor_far_rendering.cpp") -header = read("src/disruptor_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") +source = read("src/game/render/far_rendering.cpp") +header = read("src/game/render/far_rendering.h") +codegen = read("psxrecomp/recompiler/src/code_generator.cpp") +main = read("psxrecomp/runtime/src/main.cpp") audit = read("tools/audit_codegen.py") 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") +require(configs.count('callback = "disruptor_far_rendering_instruction_hook"\n') == 2, + "both game configurations must list the far hooks for code that runs from RAM") # The far renderer shares the already-generated renderer-entry fan-out. A # second config entry would emit a duplicate guest callback. @@ -116,22 +115,18 @@ def require(condition: bool, message: str) -> None: "config_.indent, addr, instr" in codegen, "compiled AFTER hooks must begin a separately indented statement line") -require("#ifdef PSX_HAS_DISRUPTOR_FAR_RENDERING" in dirty and - "disruptor_far_rendering_instruction_hook(cpu, pc, insn, 1)" in dirty, - "dirty interpreter parity must be project-gated and opcode-pinned") +listed_start = configs.index('callback = "disruptor_far_rendering_instruction_hook"\n') +listed_table = configs[listed_start:configs.index("[[recompiler.instruction_hook]]", listed_start)] for pc, instruction in expected_sites.items(): - require(f"0x{pc:08X}u" in dirty and f"0x{instruction:08X}u" in dirty, - f"dirty interpreter is missing reviewed site 0x{pc:08X}") -require("disruptor_far_rendering_instruction_hook(cpu, pc, insn, 1)" in dirty and - '"0x{:08X}u, 0x{:08X}u, 1);"' in codegen, + require(f'["0x{pc:08X}", "0x{instruction:08X}"],' in listed_table, + f"code that runs from RAM is not hooked at reviewed site 0x{pc:08X}") +require('"0x{:08X}u, 0x{:08X}u, 1);"' in codegen, "all reviewed diagnostic sites must remain AFTER-instruction") -dirty_table_start = dirty.index("static int disruptor_far_rendering_site") -dirty_table = dirty[dirty_table_start:dirty.index("#endif", dirty_table_start)] audit_table_start = audit.index("FAR_RENDERING_SITES") audit_table = audit[audit_table_start:audit.index("# Tests 4-6", audit_table_start)] for pc in excluded_sites: token = f"0x{pc:08X}" - require(token not in codegen_table and token not in dirty_table and + require(token not in codegen_table and token not in listed_table and token not in audit_table and token not in source_table, f"excluded far-rendering site leaked into hook tables: {token}") require("FAR_RENDERING_SITES" in audit and @@ -172,8 +167,8 @@ def require(condition: bool, message: str) -> None: require("disruptor_far_rendering_abandon_metrics" in header and "disruptor_far_rendering_abandon_metrics" in source, "the module must expose savestate-abandon cleanup") -require("#ifdef PSX_HAS_DISRUPTOR_FAR_RENDERING" in main and - "disruptor_far_rendering_abandon_metrics();" in main, +require("#ifdef PSX_HAS_GAME_HOST\n psx_game_state_loaded();\n#endif\n" in main and + " disruptor_far_rendering_abandon_metrics();\n}\n" in read("src/game/host.cpp"), "successful savestate loads must abandon host measurement spans") for token in ( diff --git a/tests/test_frame_kind_contract.py b/tests/contracts/test_frame_kind_contract.py similarity index 88% rename from tests/test_frame_kind_contract.py rename to tests/contracts/test_frame_kind_contract.py index 0965026..7a90aee 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: @@ -35,10 +35,10 @@ def enclosing(source: str, block: str) -> list[str]: cmake = read("CMakeLists.txt") -manifest = read("PSXRECOMP_OVERLAY_FILES.txt") -gpu = read("psxrecomp-overlay/runtime/src/gpu.c") -header = read("psxrecomp-overlay/runtime/include/gpu.h") -runtime = read("psxrecomp-overlay/runtime/src/main.cpp") +manifest = read("framework/FILES.txt") +gpu = read("psxrecomp/runtime/src/gpu.c") +header = read("psxrecomp/runtime/include/gpu.h") +runtime = read("psxrecomp/runtime/src/main.cpp") require( "+runtime/include/gpu_ws_frame_kind.h" in manifest.splitlines(), @@ -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 71% rename from tests/test_frame_rate_contract.py rename to tests/contracts/test_frame_rate_contract.py index c74cd30..d517028 100644 --- a/tests/test_frame_rate_contract.py +++ b/tests/contracts/test_frame_rate_contract.py @@ -6,7 +6,9 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +from menu_sources import menu_text + +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -43,21 +45,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") -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") +source = read("src/game/frame/frame_rate.cpp") +header = read("src/game/frame/frame_rate.h") +menu = menu_text(ROOT) +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("framework/FILES.txt") +cycle_header = read("psxrecomp/runtime/include/psx_cyc.h") +cycle_source = read("psxrecomp/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 +135,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( @@ -136,7 +152,7 @@ def require(condition: bool, message: str) -> None: " disruptor_frame_rate_set_unlocked(settings.frame_unlock ? 1 : 0);" in menu, "a saved switch must be applied at start unless the environment overrides it", ) -settings_loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") +settings_loader = read("src/game/settings/saved.cpp") require( 's.frame_unlock = toml::find(d, "frame_unlock");' in settings_loader and 'f << "frame_unlock = " << (s.frame_unlock ? "true" : "false")' in settings_loader @@ -161,12 +177,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") -renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") -host = read("psxrecomp-overlay/runtime/src/main.cpp") +present_rate = read("src/game/frame/present_rate.h") +present_control = read("src/game/frame/present_rate.cpp") +renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") +host = read("psxrecomp/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 +190,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 +238,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 +274,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( @@ -263,7 +293,8 @@ def require(condition: bool, message: str) -> None: "the presenter must run at any rate from 30 FPS", ) require( - "(s.frame_interpolation_fps >= 30 && s.frame_interpolation_fps <= 1000))" in settings_loader, + "(s.frame_interpolation_fps >= 30 && s.frame_interpolation_fps <= 1000))" + in read("psxrecomp/recompiler/src/config_loader.cpp"), "settings.toml must keep an in-between frame rate from 30 FPS", ) diff --git a/tests/contracts/test_framework_tool.py b/tests/contracts/test_framework_tool.py new file mode 100644 index 0000000..740af33 --- /dev/null +++ b/tests/contracts/test_framework_tool.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Test of tools/framework.py on a small framework made here: apply, refresh, check and what each refuses.""" + +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TOOL = ROOT / "tools" / "framework.py" +PLAIN = b"int a;\nint b;\nint c;\nint d;\nint e;\n" +MIXED = b"one\r\ntwo\r\nthree\nfo\rur\r\nfive\r\n" +PLAIN_EDITED = b"int a;\nint b2;\nint c;\nint d;\nint e;\nint f;\n" +MIXED_EDITED = b"one\r\ntwo\r\nours\nfo\rur\r\nfive\r\n" +ADDED = b"#pragma once\n" +LISTED = "runtime/src/plain.c\nruntime/src/mixed.c\n+runtime/include/added.h\n" + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def git(folder: Path, *arguments: str) -> str: + identity = ["-c", "user.name=test", "-c", "user.email=test@example.invalid", "-c", "core.autocrlf=false"] + done = subprocess.run(["git", "-C", str(folder), *identity, *arguments], capture_output=True, text=True, check=True) + return done.stdout.strip() + + +def tool(project: Path, checkout: Path, *arguments: str) -> subprocess.CompletedProcess[str]: + command = [sys.executable, "-B", str(TOOL), *arguments, "--project", str(project), "--framework", str(checkout)] + return subprocess.run(command, capture_output=True, text=True, check=False) + + +def main() -> int: + with tempfile.TemporaryDirectory() as folder: + checkout, project = Path(folder) / "checkout", Path(folder) / "project" + (checkout / "runtime" / "src").mkdir(parents=True) + (checkout / "runtime" / "include").mkdir() + (checkout / "runtime" / "runtime.cmake").write_bytes(b"# fixture\n") + plain, mixed = checkout / "runtime" / "src" / "plain.c", checkout / "runtime" / "src" / "mixed.c" + added = checkout / "runtime" / "include" / "added.h" + plain.write_bytes(PLAIN) + mixed.write_bytes(MIXED) + git(checkout, "init", "--quiet") + git(checkout, "add", "--all") + git(checkout, "commit", "--quiet", "--message", "pinned") + (project / "framework").mkdir(parents=True) + pinned = git(checkout, "rev-parse", "HEAD") + "\n" + (project / "framework" / "PIN").write_text(pinned, encoding="utf-8") + manifest = project / "framework" / "FILES.txt" + manifest.write_text("# a comment\n" + LISTED, encoding="utf-8") + + plain.write_bytes(PLAIN_EDITED) + mixed.write_bytes(MIXED_EDITED) + added.write_bytes(ADDED) + saved = tool(project, checkout, "refresh") + patch = project / "framework" / "patches" / "runtime" / "src" / "mixed.c.patch" + require( + saved.returncode == 0 + and (project / "framework" / "files" / "runtime" / "include" / "added.h").read_bytes() == ADDED + and b"\n two\r\n-three\n+ours\n fo\rur\r\n five\r\n" in patch.read_bytes() + and tool(project, checkout, "check").returncode == 0, + "an edit made in the checkout is saved as a patch that keeps each line's own ending", + ) + + plain.write_bytes(PLAIN) + mixed.write_bytes(MIXED) + require( + git(checkout, "apply", "--check", str(patch)) == "" + and git(checkout, "apply", "--check", str(patch.with_name("plain.c.patch"))) == "", + "a saved patch is one that git applies to the pinned file", + ) + added.unlink() + require(tool(project, checkout, "check").returncode != 0, "a checkout with pinned files is not in step") + applied = tool(project, checkout, "apply") + require( + applied.returncode == 0 + and plain.read_bytes() == PLAIN_EDITED + and mixed.read_bytes() == MIXED_EDITED + and added.read_bytes() == ADDED + and tool(project, checkout, "check").returncode == 0, + "apply gives the saved state back byte for byte", + ) + written = [path.stat().st_mtime_ns for path in (plain, mixed, added)] + require( + tool(project, checkout, "apply").returncode == 0 + and written == [path.stat().st_mtime_ns for path in (plain, mixed, added)], + "apply leaves alone a file that already holds what it should, so a build does not compile it again", + ) + + plain.write_bytes(PLAIN_EDITED + b"int unsaved;\n") + refused = tool(project, checkout, "apply") + require( + refused.returncode != 0 + and "runtime/src/plain.c" in refused.stderr + and plain.read_bytes().endswith(b"int unsaved;\n") + and tool(project, checkout, "check").returncode != 0, + "apply does not overwrite an edit that refresh has not saved, and check reports it", + ) + require( + tool(project, checkout, "apply", "--discard").returncode == 0 and plain.read_bytes() == PLAIN_EDITED, + "apply --discard drops such an edit", + ) + + stray = project / "framework" / "patches" / "runtime" / "src" / "stray.c.patch" + stray.write_bytes(patch.read_bytes()) + require( + tool(project, checkout, "check").returncode != 0 and tool(project, checkout, "apply").returncode != 0, + "a patch that FILES.txt does not list is refused", + ) + stray.unlink() + + whole = patch.read_bytes() + patch.write_bytes(whole.replace(b"+ours\n", b"+theirs\n")) + mixed.write_bytes(MIXED) + altered = tool(project, checkout, "apply") + require( + altered.returncode != 0 + and "did not give the content it names" in altered.stderr + and mixed.read_bytes() == MIXED, + "a patch whose text was altered is not applied", + ) + patch.write_bytes(whole.replace(b" two\r\n", b" two\n")) + unmatched = tool(project, checkout, "apply") + require( + unmatched.returncode != 0 and "does not match at line 2" in unmatched.stderr, + "a hunk that does not match the pinned file is refused, and the line is named", + ) + patch.write_bytes(whole) + require(tool(project, checkout, "apply").returncode == 0, "the fixture is whole again") + + marker = checkout / "runtime" / "runtime.cmake" + marker.write_bytes(b"# changed\n") + unlisted = tool(project, checkout, "check") + require( + unlisted.returncode != 0 and "not in FILES.txt: runtime/runtime.cmake" in unlisted.stderr, + "a framework file changed in the checkout and not listed is reported", + ) + marker.write_bytes(b"# fixture\n") + require(tool(project, checkout, "check").returncode == 0, "the fixture is in step again") + + for lines, said in ( + (LISTED.replace("+runtime/include/added.h", "runtime/include/added.h"), "is not in the framework"), + (LISTED.replace("runtime/src/plain.c", "+runtime/src/plain.c"), "is marked '+' but the framework has it"), + (LISTED + "../outside.c\n", "unsafe path"), + (LISTED + "+..\\outside.h\n", "unsafe path"), + (LISTED + "+C:/outside.h\n", "unsafe path"), + (LISTED + "runtime/src/plain.c\n", "lists a file twice"), + ("# nothing\n", "lists no file"), + ): + manifest.write_text(lines, encoding="utf-8") + refused = tool(project, checkout, "check") + require(refused.returncode != 0 and said in refused.stderr, f"a list is refused when it {said}") + manifest.write_text(LISTED, encoding="utf-8") + + plain.write_bytes(PLAIN) + require( + tool(project, checkout, "refresh").returncode != 0, + "refresh refuses a listed file that is the pinned one again", + ) + + plain.write_bytes(b"int other;\n") + git(checkout, "commit", "--quiet", "--all", "--message", "moved") + (project / "framework" / "PIN").write_text(git(checkout, "rev-parse", "HEAD") + "\n", encoding="utf-8") + moved = tool(project, checkout, "check") + require( + moved.returncode != 0 and "made against other content" in moved.stderr, + "a patch is not applied to a pinned file it was not made against", + ) + (project / "framework" / "PIN").write_text(pinned, encoding="utf-8") + elsewhere = tool(project, checkout, "check") + require( + elsewhere.returncode != 0 and "not at the pinned" in elsewhere.stderr, + "a checkout that is at another revision than the pinned one is reported", + ) + + listed = [line for line in (ROOT / "framework" / "FILES.txt").read_text(encoding="utf-8").splitlines()] + entries = [line.strip() for line in listed if line.strip() and not line.startswith("#")] + patches = { + path.relative_to(ROOT / "framework" / "patches").as_posix() + for path in (ROOT / "framework" / "patches").rglob("*") + if path.is_file() + } + files = { + path.relative_to(ROOT / "framework" / "files").as_posix() + for path in (ROOT / "framework" / "files").rglob("*") + if path.is_file() + } + require( + len(entries) == 52 + and patches == {f"{line}.patch" for line in entries if not line.startswith("+")} + and files == {line[1:] for line in entries if line.startswith("+")} + and len(patches) == 32 + and len(files) == 20 + and "framework/patches/** -text\n" in (ROOT / ".gitattributes").read_text(encoding="utf-8"), + "the project lists 52 changed files, with a patch for each of 32 and 20 added whole, and stores patches byte for byte", + ) + for script, call in ( + ("build.ps1", "python (Join-Path $Root 'tools/framework.py') apply --framework $Framework"), + ("build.sh", 'python3 "$ROOT/tools/framework.py" apply --framework "$FRAMEWORK"'), + ("build.ps1", 'python -c "import sys; sys.exit(sys.version_info < (3, 10))"'), + ("build.sh", "python3 -c 'import sys; sys.exit(sys.version_info < (3, 10))' || {"), + ("CMakeLists.txt", " find_package(Python3 3.10 REQUIRED COMPONENTS Interpreter)\n"), + ): + require( + call in (ROOT / script).read_text(encoding="utf-8"), + f"{script} applies the framework changes with a Python the tool runs on", + ) + source = TOOL.read_text(encoding="utf-8") + require( + ' if self._git("cat-file", "-e", f"{pin}^0").returncode != 0:\n' in source and "^{" not in source, + "the tool asks git for the pinned commit without braces, which an MSYS git expands when a native Python starts it", + ) + + print("framework tool test: PASS") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_gap_filling_contract.py b/tests/contracts/test_gap_filling_contract.py similarity index 78% rename from tests/test_gap_filling_contract.py rename to tests/contracts/test_gap_filling_contract.py index 4a29417..372f41c 100644 --- a/tests/test_gap_filling_contract.py +++ b/tests/contracts/test_gap_filling_contract.py @@ -3,7 +3,9 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +from menu_sources import menu_text + +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -21,12 +23,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") +renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") +menu = menu_text(ROOT) launcher = read("src/launcher/launcher_settings.cpp") -loader = read("psxrecomp-overlay/recompiler/src/config_loader.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("src/game/settings/saved.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 +144,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", ) @@ -173,13 +181,16 @@ def between(source: str, opening: str, closing: str) -> str: f << "gap_filling = " << (s.gap_filling ? "true" : "false") << "\\n"; """ in loader and "s.has_gap_filling" in between(loader, " if (s.has_mouse_aim", "{\n") - and " bool has_gap_filling = false; bool gap_filling = true;\n" in read("psxrecomp-overlay/recompiler/src/config_loader.h"), + and " bool has_gap_filling = false; bool gap_filling = true;\n" in read("src/game/settings/game_user_settings.h"), "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 95% rename from tests/test_geometry_provenance_contract.py rename to tests/contracts/test_geometry_provenance_contract.py index a61795c..bfd8b7a 100644 --- a/tests/test_geometry_provenance_contract.py +++ b/tests/contracts/test_geometry_provenance_contract.py @@ -8,20 +8,20 @@ import sys -ROOT = pathlib.Path(__file__).resolve().parents[1] -GPU = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "gpu.c" -GTE = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "gte.cpp" -MEMORY = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "memory.c" +ROOT = pathlib.Path(__file__).resolve().parents[2] +GPU = ROOT / "psxrecomp" / "runtime" / "src" / "gpu.c" +GTE = ROOT / "psxrecomp" / "runtime" / "src" / "gte.cpp" +MEMORY = ROOT / "psxrecomp" / "runtime" / "src" / "memory.c" DIRTY_INTERP = ( - ROOT / "psxrecomp-overlay" / "runtime" / "src" / "dirty_ram_interp.c" + ROOT / "psxrecomp" / "runtime" / "src" / "dirty_ram_interp.c" ) GTE_HEADER = ( - ROOT / "psxrecomp-overlay" / "runtime" / "include" / "gte_precision.h" + ROOT / "psxrecomp" / "runtime" / "include" / "gte_precision.h" ) -MOUSE = ROOT / "src" / "disruptor_mouse_aim.cpp" -MAIN = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "main.cpp" +MOUSE = ROOT / "src" / "game" / "input" / "mouse_aim.cpp" +MAIN = ROOT / "psxrecomp" / "runtime" / "src" / "main.cpp" CODEGEN = ( - ROOT / "psxrecomp-overlay" / "recompiler" / "src" / "code_generator.cpp" + ROOT / "psxrecomp" / "recompiler" / "src" / "code_generator.cpp" ) GAME_CODE = ROOT / "input" / "SLUS_002.24.code" @@ -374,8 +374,15 @@ def main() -> int: f"{replay_name} must sandbox replayed GTE projections") route_config = function_body( - main_source, "configure_disruptor_precision_store_routes") - require('game_id != "SLUS-00224"' in route_config, + (ROOT / "src/game/render/precision_routes.cpp").read_text(encoding="utf-8"), + "disruptor_precision_routes_configure") + require("#ifdef PSX_HAS_GAME_HOST\n if (!psx_game_configure(game_id.data(), game_id.size())) return 1;\n#endif\n" in main_source and + " return disruptor_precision_routes_configure(game_id, length);\n}\n" in + function_body((ROOT / "src/game/host.cpp").read_text(encoding="utf-8"), "psx_game_configure") + "}\n" and + "int psx_game_configure(const char *game_id, size_t length) {\n" in + (ROOT / "src/game/host.cpp").read_text(encoding="utf-8"), + "the host must ask the game for its routes once the serial is known, and stop when one is refused") + require('std::string_view(game_id, length) != "SLUS-00224"' in route_config, "reviewed resident routes must be gated to Disruptor's serial") mfc2_array = re.search( r"mfc2_pcs\[\]\s*=\s*\{(.*?)\};", route_config, re.S) diff --git a/tests/test_grown_quad_contract.py b/tests/contracts/test_grown_quad_contract.py similarity index 72% rename from tests/test_grown_quad_contract.py rename to tests/contracts/test_grown_quad_contract.py index e6a9051..1a27623 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,16 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_grown_quad.cpp") -gpu = read("psxrecomp-overlay/runtime/src/gpu.c") +source = read("src/game/render/grown_quad.cpp") +gpu = read("psxrecomp/runtime/src/gpu.c") +host = read("src/game/host.cpp") require( - '"${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_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, + '"${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/grown_quad.cpp"' in cmake + and " PSX_HAS_GAME_HOST=1)\n" in cmake + and " return disruptor_grown_quad_sources(command_address, packet, source_address, source_word);\n" in host + 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( @@ -57,8 +59,19 @@ def require(condition: bool, message: str) -> None: "recognising a grown quad must not write guest memory", ) require( - " const int grown = count == 4 && disruptor_grown_quad_sources(\n" - " gp0_cmd_source_addr, gp0_cmd_buf, grown_addr, grown_word);\n" in gpu + "#ifdef PSX_HAS_GAME_HOST\n" + " /* The game's second, grown copy of a world quad stands on the first packet's corners. */\n" + " const int grown = count == 4 && psx_game_grown_quad_sources(\n" + " gp0_cmd_source_addr, gp0_cmd_buf, grown_addr, grown_word);\n" + "#else\n" + " const int grown = 0;\n" + "#endif\n" in gpu + and "#if defined(PSX_HAS_GAME_HOST) && !defined(PSX_DISABLE_FRAME_INTERPOLATION)\n" + " if (grown) {\n" + " grown_corner_depths(precise_x, precise_y, raw_x, raw_y, indices);\n" in gpu + and "#if defined(PSX_HAS_GAME_HOST) && !defined(PSX_DISABLE_FRAME_INTERPOLATION)\n" + "/* A grown corner lies off its source corner, on the same plane" in gpu + and "disruptor" not in gpu.lower() and " const uint32_t word = grown ? grown_word[i] : gp0_cmd_buf[indices[i]];\n" " uint32_t addr = grown_addr[i];\n" in gpu and " if (!grown && !gp0_source_word_address(indices[i], &addr)) {\n" in gpu, diff --git a/tests/test_hud_scale_contract.py b/tests/contracts/test_hud_scale_contract.py similarity index 77% rename from tests/test_hud_scale_contract.py rename to tests/contracts/test_hud_scale_contract.py index 7722d05..71609bc 100644 --- a/tests/test_hud_scale_contract.py +++ b/tests/contracts/test_hud_scale_contract.py @@ -3,7 +3,9 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +from menu_sources import menu_text + +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -16,19 +18,31 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -manifest = read("PSXRECOMP_OVERLAY_FILES.txt") -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") -settings_loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") +manifest = read("framework/FILES.txt") +scale_header = read("psxrecomp/runtime/include/gpu_ws_hud_scale.h") +gpu = read("psxrecomp/runtime/src/gpu.c") +header = read("psxrecomp/runtime/include/gpu.h") +menu = menu_text(ROOT) +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("src/game/settings/saved.cpp") require( "+runtime/include/gpu_ws_hud_scale.h" in manifest.splitlines(), "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 +111,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 90% rename from tests/test_hud_widget_contract.py rename to tests/contracts/test_hud_widget_contract.py index 74a7776..1b8c594 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: @@ -21,21 +21,19 @@ def body(source: str, signature: str) -> str: cmake = read("CMakeLists.txt") -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") +manifest = read("framework/FILES.txt") +gpu = read("psxrecomp/runtime/src/gpu.c") +header = read("psxrecomp/runtime/include/gpu.h") +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/contracts/test_instruction_hooks_contract.py b/tests/contracts/test_instruction_hooks_contract.py new file mode 100644 index 0000000..0669a4a --- /dev/null +++ b/tests/contracts/test_instruction_hooks_contract.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Source contract for the game's hooks after an instruction: one list in the settings, and who reads it.""" + +import re +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + +GENERATOR_TABLES = { + "disruptor_vertical_camera_instruction_hook": "kDisruptorVerticalCameraSites", + "disruptor_far_rendering_instruction_hook": "kDisruptorFarRenderingSites", + "disruptor_billboard_aspect_instruction_hook": "kDisruptorBillboardAspectSites", + "disruptor_backdrop_tiles_instruction_hook": "kDisruptorBackdropTileSites", + "disruptor_sprite_depth_instruction_hook": "kDisruptorSpriteDepthSites", + "disruptor_intro_skip_instruction_hook": "kDisruptorIntroSkipSites", +} +SITES = 66 +SHARED = {0x8004279C: ["disruptor_vertical_camera_instruction_hook", "disruptor_far_rendering_instruction_hook"]} + + +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 listed_hooks(path: str) -> dict[str, dict[int, int]]: + """The hooks of a game configuration, by callback, in the order they are written.""" + hooks: dict[str, dict[int, int]] = {} + for hook in tomllib.loads(read(path))["recompiler"]["instruction_hook"]: + require(set(hook) == {"callback", "sites"}, f"{path}: a hook is a callback and its sites") + sites = {int(address, 16): int(word, 16) for address, word in hook["sites"]} + require( + len(sites) == len(hook["sites"]) and hook["callback"] not in hooks, + f"{path}: {hook['callback']} is listed twice, or one of its addresses is", + ) + hooks[hook["callback"]] = sites + return hooks + + +def generator_table(generator: str, table: str) -> dict[int, int]: + """A site table of the framework's code generator.""" + rows = re.search(r"static const \w+ " + table + r"\[\] = \{\n(.*?)\n\};\n", generator, re.DOTALL) + if rows is None: + raise AssertionError(f"the generator has no table {table}") + found = re.findall(r"\{0x([0-9A-F]{8})u, 0x([0-9A-F]{8})u(?:, \d+u)?\},", rows.group(1)) + sites = {int(address, 16): int(word, 16) for address, word in found} + require(len(sites) == len(found), f"{table} lists an address twice") + return sites + + +generator = read("psxrecomp/recompiler/src/code_generator.cpp") +interpreter = read("psxrecomp/runtime/src/dirty_ram_interp.c") +emitter = read("psxrecomp/recompiler/src/main_psx.cpp") +settings = read("psxrecomp/recompiler/src/config_loader.cpp") +host = read("psxrecomp/runtime/include/game_host.h") +cmake = read("CMakeLists.txt") + +hooks = listed_hooks("game.toml") +require( + hooks == listed_hooks("game-widescreen.toml") and list(hooks) == list(listed_hooks("game-widescreen.toml")), + "both game configurations must list the same hooks in the same order", +) +require( + list(hooks) == list(GENERATOR_TABLES) and sum(len(sites) for sites in hooks.values()) == SITES, + "the hooks are the six the generator knows, in the order it writes them, at 66 sites", +) +for callback, table in GENERATOR_TABLES.items(): + require( + hooks[callback] == generator_table(generator, table), + f"{callback}: native code and code that runs from RAM must be hooked at the same instructions", + ) +shared = { + address: [callback for callback, sites in hooks.items() if address in sites] + for address in {address for sites in hooks.values() for address in sites} +} +require( + {address: callbacks for address, callbacks in shared.items() if len(callbacks) > 1} == SHARED, + "one instruction has two hooks, and the camera's runs first as it does in native code", +) +require( + "instruction_hooks" not in generator + and "instruction_hooks" not in read("psxrecomp/recompiler/include/code_generator.h"), + "the generator's sources make the key of a save state: its own site tables stay until the pin is raised", +) +changed = [ + line.removeprefix("+").strip() + for line in read("framework/FILES.txt").splitlines() + if line.strip() and not line.startswith("#") +] +naming = sorted(path for path in changed if b"disruptor" in (ROOT / "psxrecomp" / path).read_bytes().lower()) +require( + naming == ["recompiler/src/code_generator.cpp"], + f"of the framework files the project changes only the code generator may name the game: {naming}", +) +require( + "disruptor" not in interpreter.lower() + and interpreter.count("psx_game_instruction_ran(") == 1 + and " const int rv = exec_one_fetched_inner(cpu, pc, insn, next_pc_out);\n" + "\n" + "#ifdef PSX_HAS_GAME_HOST\n" + " psx_game_instruction_ran(cpu, pc, insn);\n" + "#endif\n" + in interpreter + and '#ifdef PSX_HAS_GAME_HOST\n#include "game_host.h"\n#endif\n' in interpreter + and "void psx_game_instruction_ran(CPUState *cpu, uint32_t address, uint32_t instruction);\n" in host + and " PSX_HAS_GAME_HOST=1" in cmake, + "the interpreter names no game: it makes one call after an instruction, and the generated code knows the sites", +) +require( + " instruction_hooks = cfg.instruction_hooks;\n" in emitter + and " if (instruction_hooks.empty())\n instruction_hooks = wscfg.instruction_hooks;\n" in emitter + and " hooked[site.address].emplace_back(site.expected, hook.callback);\n" in emitter + and " if (hooked.empty())\n" + ' ds << " (void)cpu; (void)address; (void)instruction;\\n}\\n";\n' + in emitter + and " if (!instruction_hooks.empty()) {\n" not in emitter + and ' ds << "void psx_game_instruction_ran(CPUState* cpu, uint32_t address, uint32_t instruction) {\\n";\n' + in emitter + and ' " if (instruction == 0x{:08X}u) {}(cpu, address, instruction, 1);\\n",\n' + in emitter, + "the dispatch file must carry the interpreter's calls: by address, the whole word checked, after the instruction, " + "and the function itself even when no hook is listed", +) +hashed = settings[settings.index("uint32_t overlay_codegen_config_hash(") : settings.index("// Pad mode <-> string.")] +require( + "instruction_hook" not in hashed + and ' for (const auto& item :\n toml::find(recomp, "instruction_hook")) {\n' + in settings + and " if ((site.address & 3u) != 0)\n" in settings + and " /*instruction_hooks*/ instruction_hooks,\n" in settings, + "the settings reader takes the list, refuses an address between instructions, and keeps it out of the state's key", +) +require( + " disruptor_contract(instruction_hooks)\n" in cmake, + "this contract must remain registered", +) + +print("Disruptor instruction hook source contract: PASS") diff --git a/tests/test_intro_skip_contract.py b/tests/contracts/test_intro_skip_contract.py similarity index 77% rename from tests/test_intro_skip_contract.py rename to tests/contracts/test_intro_skip_contract.py index 71af171..582efc5 100644 --- a/tests/test_intro_skip_contract.py +++ b/tests/contracts/test_intro_skip_contract.py @@ -5,7 +5,9 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +from menu_sources import menu_text + +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -45,24 +47,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") -generator = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") -interpreter = read("psxrecomp-overlay/runtime/src/dirty_ram_interp.c") +source = read("src/game/session/intro_skip.cpp") +header = read("src/game/session/intro_skip.h") +menu = menu_text(ROOT) +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/recompiler/src/code_generator.cpp") +configs = read("game.toml") + read("game-widescreen.toml") audit = read("tools/audit_codegen.py") -settings_loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") +settings_loader = read("src/game/settings/saved.cpp") require( - "src/disruptor_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, - "intro-skip source, its interpreter switch and both tests must remain registered", + "src/game/session/intro_skip.cpp" in cmake + and configs.count('callback = "disruptor_intro_skip_instruction_hook"\n') == 2 + and " disruptor_contract(intro_skip RETAIL)\n" in cmake + and " disruptor_unit_test(disruptor_intro_skip " in cmake, + "intro-skip source, its hook for code that runs from RAM 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(): @@ -71,8 +85,8 @@ def require(condition: bool, message: str) -> None: f"the generator must pin the intro-skip seam at 0x{address:08X}", ) require( - f"(pc == 0x{address:08X}u && insn == 0x{word:08X}u)" in interpreter, - f"the dirty-RAM interpreter must pin the intro-skip seam at 0x{address:08X}", + configs.count(f'["0x{address:08X}", "0x{word:08X}"],') == 2, + f"code that runs from RAM must be hooked at the intro-skip seam 0x{address:08X}", ) require(f"0x{address:08X}: 0x{word:08X}," in audit, f"the codegen audit must expect the seam at 0x{address:08X}") require(f"0x{address:08X}u" in source and f"0x{word:08X}u" in source, f"the hook must check 0x{address:08X}") @@ -116,8 +130,8 @@ def require(condition: bool, message: str) -> None: "disruptor_intro_skip_fast_forward"): require(token in header and token in source, f"intro-skip API is missing {token}") -host_header = read("psxrecomp-overlay/runtime/include/host_ui.h") -host = read("psxrecomp-overlay/runtime/src/main.cpp") +host_header = read("psxrecomp/runtime/include/host_ui.h") +host = read("psxrecomp/runtime/src/main.cpp") require( 'extern "C" int disruptor_intro_skip_fast_forward(void) {\n' " if (g_boot_over || !psx_mod_game_started()) return 0;\n" @@ -161,13 +175,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( @@ -183,15 +199,14 @@ def require(condition: bool, message: str) -> None: "settings.toml must read and write [disruptor] skip_intro", ) -runtime_main = read("psxrecomp-overlay/runtime/src/main.cpp") +runtime_main = read("psxrecomp/runtime/src/main.cpp") require( - " if (us.has_skip_intro && us.skip_intro &&\n" - ' !std::getenv("PSX_DISRUPTOR_SKIP_INTRO"))\n' - " fast_boot = true;" - in runtime_main - and ' if (const char* e = std::getenv("PSX_DISRUPTOR_SKIP_INTRO"))\n' - " if (e[0] && e[0] != '0') fast_boot = true;" - in runtime_main, + " if (psx_game_settings_loaded(us)) fast_boot = true;\n" in runtime_main + and " if (psx_game_fast_boot_forced()) fast_boot = true;\n" in runtime_main + and ' return settings.has_skip_intro && settings.skip_intro && !std::getenv("PSX_DISRUPTOR_SKIP_INTRO");\n' + in read("src/game/host.cpp") + and ' const char *forced = std::getenv("PSX_DISRUPTOR_SKIP_INTRO");\n' + " return forced && forced[0] && forced[0] != '0';\n" in read("src/game/host.cpp"), "the saved switch, or the environment in its place, must also skip the BIOS animation", ) 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 91% rename from tests/test_language_disc_contract.py rename to tests/contracts/test_language_disc_contract.py index bc7dd88..9b5d15d 100644 --- a/tests/test_language_disc_contract.py +++ b/tests/contracts/test_language_disc_contract.py @@ -5,7 +5,9 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +from menu_sources import menu_text + +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -64,29 +66,28 @@ 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") -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") -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") -reader = read("psxrecomp-overlay/runtime/src/iso_reader_c.cpp") +overlay_files = read("framework/FILES.txt") +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/runtime/include/iso_overlay.h") +host = read("psxrecomp/runtime/src/main.cpp") +host_header = read("psxrecomp/runtime/include/host_ui.h") +pick_slot = read("psxrecomp/runtime/include/host_pick_slot.h") +restart = read("src/game/session/restart.cpp") +settings_header = read("src/game/settings/game_user_settings.h") +settings_loader = read("src/game/settings/saved.cpp") +menu = menu_text(ROOT) +reader = read("psxrecomp/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( @@ -297,11 +298,12 @@ def require(condition: bool, message: str) -> None: " if (void *game = iso_game_disc(); game && !g_path.empty()) (void)laid_out(game);" in source and " savestate_configure(memcard_dir.string().c_str(),\n" " memory_get_bios_checksum(), game_entry_pc);\n" - "#ifdef PSX_HAS_DISRUPTOR_LANGUAGE\n" - " /* Before any state can be asked for: a language disc's states have a folder of their own. */\n" - " disruptor_language_settle();\n" + "#ifdef PSX_HAS_GAME_HOST\n" + " /* Before any state can be asked for. */\n" + " psx_game_states_configured();\n" "#endif" in host - and host.index(" cdrom_init(disc_path_str.empty()") < host.index(" disruptor_language_settle();") + and "void psx_game_states_configured(void) { disruptor_language_settle(); }\n" in read("src/game/host.cpp") + and host.index(" cdrom_init(disc_path_str.empty()") < host.index(" psx_game_states_configured();") < host.index(' if (const char *ls = std::getenv("PSX_LOAD_SLOT")) {') and " language_ = language->name;\n tall_hints_ = language->hints == Hints::kTall && hints != 0;\n return true;" in disc and ' tall_hints_ = false;\n language_ = "";' in disc, @@ -318,12 +320,11 @@ def require(condition: bool, message: str) -> None: "settings.toml must read and write [disruptor] language_disc whole: backslashes, quotes and control characters", ) require( - "PSX_HAS_DISRUPTOR_LANGUAGE=1" in cmake - and '#ifdef PSX_HAS_DISRUPTOR_LANGUAGE\nextern "C" void disruptor_language_set_disc(const char* path);\n' - 'extern "C" void disruptor_language_settle(void);\n#endif' in host - and ' if (us.has_language_disc && !std::getenv("PSX_DISRUPTOR_LANGUAGE_DISC"))\n' - " disruptor_language_set_disc(us.language_disc.c_str());" in host - and host.index("disruptor_language_set_disc(us.language_disc.c_str());") < host.index("if (us.has_bios_hle) bios_hle_requested = us.bios_hle;"), + "PSX_HAS_GAME_HOST=1" in cmake + and ' if (settings.has_language_disc && !std::getenv("PSX_DISRUPTOR_LANGUAGE_DISC"))\n' + " disruptor_language_set_disc(settings.language_disc.c_str());" in read("src/game/host.cpp") + and " if (psx_game_settings_loaded(us)) fast_boot = true;\n" in host + and host.index("if (psx_game_settings_loaded(us)) fast_boot = true;") < host.index("if (us.has_bios_hle) bios_hle_requested = us.bios_hle;"), "the saved language disc must be named while the settings are read, before anything boots, unless the environment names one", ) require( @@ -343,7 +344,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 +354,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 +363,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 61% rename from tests/test_launcher_contract.py rename to tests/contracts/test_launcher_contract.py index a797db2..fbdb419 100644 --- a/tests/test_launcher_contract.py +++ b/tests/contracts/test_launcher_contract.py @@ -4,7 +4,9 @@ import re from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +from menu_sources import menu_text + +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,19 +40,69 @@ 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") +loader = read("psxrecomp/recompiler/src/config_loader.cpp") +game_section = read("src/game/settings/saved.cpp") +menu = menu_text(ROOT) + 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(") :] +require( + "#ifdef PSX_HAS_GAME_USER_SETTINGS\n write_game_user_settings(f, s);\n#endif\n\n f.flush();\n" in writer + and "#ifdef PSX_HAS_GAME_USER_SETTINGS\n read_game_user_settings(doc, s);\n#endif\n return s;\n" in loader + and "disruptor" not in loader.lower() + and ' if (doc.contains("disruptor")) {\n const toml::value& d = toml::find(doc, "disruptor");\n' in game_section + and ' f << "\\n[disruptor]\\n";\n' in game_section, + "the framework's writer and reader hand the game's section to the game, last, and name no game", +) +settings_struct = read("psxrecomp/recompiler/src/config_loader.h") +require( + "#ifdef PSX_HAS_GAME_USER_SETTINGS\nstruct UserSettings : GameUserSettings {\n#else\nstruct UserSettings {\n#endif\n" + in settings_struct + and '#ifdef PSX_HAS_GAME_USER_SETTINGS\n#include "game_user_settings.h"' in settings_struct + and "disruptor" not in settings_struct.lower() + and "struct GameUserSettings {\n" in read("src/game/settings/game_user_settings.h") + and "void read_game_user_settings(const toml::value& doc, UserSettings& s) {\n" in game_section + and "void write_game_user_settings(std::ostream& f, const UserSettings& s) {\n" in game_section + and "#define TOML11_USE_UNRELEASED_TOML_FEATURES\n" in game_section + and "#define TOML11_USE_UNRELEASED_TOML_FEATURES\n" in loader, + "the settings struct takes the game's fields from the game, which reads and writes its own section " + "with the reader's own build of the TOML library", +) +require( + cmake.index('include_directories("${CMAKE_CURRENT_SOURCE_DIR}/src/game/settings")\n') + < cmake.index("add_compile_definitions(PSX_HAS_GAME_USER_SETTINGS=1)\n") + < cmake.index("psxrecomp_add_runtime_target(psx-runtime") + < cmake.index(" add_subdirectory(src/launcher)") + and cmake.count('"${CMAKE_CURRENT_SOURCE_DIR}/src/game/settings/saved.cpp"\n') == 2 + and ' "${PROJECT_SOURCE_DIR}/src/game/settings/saved.cpp")\n' in launcher_cmake + and "PSX_HAS_GAME_USER_SETTINGS" not in launcher_cmake, + "every target of the build sees the same settings struct, and each that builds the reader builds the game's section", +) +writer += game_section[game_section.index("void write_game_user_settings(") :] saved, section = set(), "" for header, key in re.findall(r'f << "(?:\\n)?\[(\w+)\]\\n|f << "(\w+)\s*= ', writer): section = header or section @@ -65,18 +117,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 @@ -84,14 +133,14 @@ def plain_values(text: str) -> dict[str, str]: "the launcher must start from the game's mouse sensitivity", ) require( - initial["video.vsync"] == 2 and "static int g_video_vsync = 1;" in read("psxrecomp-overlay/runtime/src/main.cpp") + initial["video.vsync"] == 2 and "static int g_video_vsync = 1;" in read("psxrecomp/runtime/src/main.cpp") and initial["disruptor.hud_scale"] == 100 and initial["audio.master_volume"] == 100, "the launcher must start from the runtime's own defaults", ) 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 +194,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 93% rename from tests/test_loaded_state_contract.py rename to tests/contracts/test_loaded_state_contract.py index 103b84f..45bf4fd 100644 --- a/tests/test_loaded_state_contract.py +++ b/tests/contracts/test_loaded_state_contract.py @@ -7,7 +7,9 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +from menu_sources import menu_text + +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -19,13 +21,13 @@ def require(condition: bool, message: str) -> None: raise AssertionError(message) -host = read("psxrecomp-overlay/runtime/src/main.cpp") -memory = read("psxrecomp-overlay/runtime/src/memory.c") -state = read("psxrecomp-overlay/runtime/src/boot_state.c") -cycles = read("psxrecomp-overlay/runtime/include/psx_cyc.h") +host = read("psxrecomp/runtime/src/main.cpp") +memory = read("psxrecomp/runtime/src/memory.c") +state = read("psxrecomp/runtime/src/boot_state.c") +cycles = read("psxrecomp/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 = menu_text(ROOT) +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 94% rename from tests/test_menu_wide_contract.py rename to tests/contracts/test_menu_wide_contract.py index fc55076..71133a1 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: @@ -15,10 +15,10 @@ def require(condition: bool, message: str) -> None: raise AssertionError(message) -gpu = read("psxrecomp-overlay/runtime/src/gpu.c") -host = read("psxrecomp-overlay/runtime/src/main.cpp") -kinds = read("psxrecomp-overlay/runtime/include/gpu_ws_frame_kind.h") -state = read("psxrecomp-overlay/runtime/src/boot_state.c") +gpu = read("psxrecomp/runtime/src/gpu.c") +host = read("psxrecomp/runtime/src/main.cpp") +kinds = read("psxrecomp/runtime/include/gpu_ws_frame_kind.h") +state = read("psxrecomp/runtime/src/boot_state.c") cmake = read("CMakeLists.txt") require( @@ -189,13 +189,13 @@ def require(condition: bool, message: str) -> None: ) require( " fmv_frame || mdec_recently_active(2) || gpu_ws_displayed_menu() != 0);" in host - and "int gpu_ws_displayed_menu(void);" in read("psxrecomp-overlay/runtime/include/gpu.h"), + and "int gpu_ws_displayed_menu(void);" in read("psxrecomp/runtime/include/gpu.h"), "nothing is put in between the frames of a front-end screen", ) 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, + "+runtime/include/gpu_ws_menu.h" in read("framework/FILES.txt").splitlines() + 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 53% rename from tests/test_name_entry_contract.py rename to tests/contracts/test_name_entry_contract.py index c8f7a76..8ac5287 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,16 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -module = read("src/disruptor_name_entry.cpp") -host = read("psxrecomp-overlay/runtime/src/main.cpp") +module = read("src/game/input/name_entry.cpp") +host = read("psxrecomp/runtime/src/main.cpp") +game_side = read("src/game/host.cpp") require( - ' "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_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, + ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/name_entry.cpp"\n' in cmake + and " PSX_HAS_GAME_HOST=1" in cmake + and ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/host.cpp"\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( @@ -118,9 +118,8 @@ def require(condition: bool, message: str) -> None: and "constexpr uint32_t kLength = 8u;" in module and "constexpr uint8_t kDigits = 26u, kBlank = 36u;" in module and ' psx_mod_register_function_entry_plugin("disruptor.name_entry", kSaveScreen, save_screen_entry);\n' - in module - and ' psx_mod_register_vblank_plugin("disruptor.name_entry.vblank", vblank);\n' in module, - "the module must name the routine, the row, the cursor and the name as the game has them, and hook the routine and the VBlank", + in module, + "the module must name the routine, the row, the cursor and the name as the game has them, and hook the routine", ) require( " g_on = row >= kLettersRow && row <= kLastNameRow && !g_ls_mode && !g_ls_replay_active && !psx_netplay_active();\n" @@ -131,35 +130,109 @@ def require(condition: bool, message: str) -> None: and module.count("psx_mod_write_") == 3 and " psx_mod_write_byte(kName + cursor, typed.code);\n" in module and " psx_mod_write_word(kCursor, cursor);\n psx_mod_write_word(kRow, kLettersRow);\n" in module - and " if (!g_on || !plain) return 0;\n" in module, - "keys are text only on the name rows and outside lockstep and net games, the frame's presses are the one register changed, " + and " if (!g_on || !plain) return 0;\n" in module + and " if (!typed || !in_typing_block(scancode)) return 0;" in module + and " if (left_screen() || run_in_step()) return false;\n" + " const uint32_t row = psx_mod_read_word(kRow);\n" + " return row >= kLettersRow && row <= kLastNameRow;\n" in module + and "[[nodiscard]] bool run_in_step() { return g_ls_mode || g_ls_replay_active || psx_netplay_active() != 0; }\n" in module, + "keys are text only from the typing block, on the name rows as the routine last found them, and outside " + "lockstep and net games, " + "the frame's presses are the one register changed, " "and the name, the cursor and the row the only memory written", ) require( - "#ifdef PSX_HAS_DISRUPTOR_NAME_ENTRY\n" - 'extern "C" int disruptor_name_entry_key(int scancode, uint32_t keycode, int down, int plain);\n#endif\n' + '#ifdef PSX_HAS_GAME_HOST\n#include "game_host.h"\n#endif\n' in host - and "#ifdef PSX_HAS_DISRUPTOR_NAME_ENTRY\n } else if (ev.type == SDL_KEYUP) {\n#if defined(PSX_SDL3)\n" - " (void)disruptor_name_entry_key((int)ev.key.scancode, (uint32_t)ev.key.key, 0, 1);\n#else\n" - " (void)disruptor_name_entry_key((int)ev.key.keysym.scancode, (uint32_t)ev.key.keysym.sym, 0, 1);\n" + and "int psx_game_key(int scancode, uint32_t keycode, int down, int plain) {\n" + " return disruptor_name_entry_key(scancode, keycode, down, plain);\n}\n" in game_side + and "#ifdef PSX_HAS_GAME_HOST\n } else if (ev.type == SDL_KEYUP) {\n#if defined(PSX_SDL3)\n" + " (void)psx_game_key((int)ev.key.scancode, (uint32_t)ev.key.key, 0, 1);\n#else\n" + " (void)psx_game_key((int)ev.key.keysym.scancode, (uint32_t)ev.key.keysym.sym, 0, 1);\n" "#endif\n#endif\n } else if (!ui_consumed && ev.type == SDL_KEYDOWN) {\n" in host - and "#ifdef PSX_HAS_DISRUPTOR_NAME_ENTRY\n" - " if (disruptor_name_entry_key((int)scancode, (uint32_t)key, 1, !(mod & (KMOD_CTRL | KMOD_ALT | KMOD_GUI))))\n" - " continue; /* the key is a letter of a save's name */\n" + and "#ifdef PSX_HAS_GAME_HOST\n" + " if (psx_game_key((int)scancode, (uint32_t)key, 1, !(mod & (KMOD_CTRL | KMOD_ALT | KMOD_GUI))))\n" + " continue; /* the game took the key */\n" in host - and host.count("disruptor_name_entry_key(") == 4, + and host.count("psx_game_key(") == 3, "the host must hand every key it did not use itself to the module, a release whoever used the press, and leave a taken key alone", ) keydown = host.index(" } else if (!ui_consumed && ev.type == SDL_KEYDOWN) {\n") require( keydown - < host.index("if (disruptor_name_entry_key((int)scancode") + < host.index("if (psx_game_key((int)scancode") < host.index("if (key >= SDLK_F1 && key <= SDLK_F12) {", keydown) and host.index('netplay_soft_exit("netplay_escape");', keydown) - < host.index("if (disruptor_name_entry_key((int)scancode"), + < host.index("if (psx_game_key((int)scancode"), "a taken key must be taken before the host's own keys are looked at, and after a net game's Escape", ) +sources = [found for found in (ROOT / "src").rglob("*") if found.suffix in {".cpp", ".h"}] +require( + " return !g_entered_once || psx_get_cycle_count() - g_entered >= kLeftScreen * kVBlankCycles;\n" in module + and " if (left_screen()) forget();\n g_entered = psx_get_cycle_count();\n g_entered_once = true;\n" in module + and " if (left_screen()) {\n g_on = g_letters = false;\n forget();\n }\n" in module + and " if (g_held.any() && g_entered - g_keyed >= kLetGo * kVBlankCycles) g_held.reset();\n" in module + and " g_keyed = psx_get_cycle_count();\n" in module + and "constexpr uint64_t kVBlankCycles = 564480u;" in module + and "#define VBLANK_CYCLES 564480u" in read("psxrecomp/runtime/src/interrupts.c") + and "constexpr unsigned kLeftScreen = 8u;" in module + and "constexpr unsigned kLetGo = 120u;" in module + and 'extern "C" void disruptor_name_entry_state_loaded(void) {\n' + " g_on = g_letters = g_entered_once = false;\n forget();\n}\n" in module + and " disruptor_capsule_state_loaded();\n disruptor_name_entry_state_loaded();\n" in game_side, + "the screen counts as left, and a held key as let go, by the game's own clock in the framework's VBlanks, " + "and a loaded save state, whose clock may lie anywhere, resets it all", +) +require( + " for (const ModResolution::Plugin& plugin : s.plan.plugins)\n mod_invoke_vblank_plugin(plugin.id);\n" + in read("psxrecomp/runtime/src/mod_runtime.cpp") + and not [ + found.name + for found in sources + if "psx_mod_register_vblank_plugin" in found.read_text(encoding="utf-8") + or "psx_mod_register_activation_plugin" in found.read_text(encoding="utf-8") + ], + "the framework calls a VBlank or an activation plugin for a mod package only, never for the game itself: " + "no source of the game may count on one", +) +controls = read("src/game/input/mouse_aim.cpp") +require( + "static const Uint8* pad_keys(void) {\n" + " int count = 0;\n" + " const Uint8* keys = SDL_GetKeyboardState(&count);\n" + "#ifdef PSX_HAS_GAME_HOST\n" + " static Uint8 kept[512];\n" + " if (!keys || count <= 0 || count > (int)sizeof(kept)) return keys;\n" + " psx_game_keys_for_pad(keys, kept, count);\n" + " return kept;\n" in host + and host.count("const Uint8* keys = pad_keys();\n") == 4 + and all( + host.count(f"{reader}(keys, ") == uses + for reader, uses in (("psx_keybinds_pad_word", 1), ("psx_keybinds_sticks", 2), ("psx_keybinds_dpad_active", 1)) + ) + and "void psx_game_keys_for_pad(const uint8_t *keys, uint8_t *kept, int count) {\n" + " disruptor_name_entry_keys_for_pad(keys, kept, count);\n}\n" in game_side, + "every reading of the keyboard for a pad must go through the game, which holds back the keys it is typing with", +) +require( + " disruptor_name_entry_keys_for_pad(keys, pad_keys.data(), static_cast(pad_keys.size()));\n" + " keys = pad_keys.data();\n" in controls + and controls.index("disruptor_name_entry_keys_for_pad(keys, pad_keys.data()") + < controls.index(" if (key_down(keys, SDL_SCANCODE_W)") + and " const bool taking = taking_text();\n if (run_in_step()) g_withheld.reset();\n" in module + and " if (keys[scancode] == 0) g_withheld.reset(key);\n" + " else if (taking && in_typing_block(scancode)) g_withheld.set(key);\n" + " kept[scancode] = g_withheld.test(key) ? uint8_t{0} : keys[scancode];\n" in module + and module.count("g_withheld.reset(") == 2 + and " return (scancode >= kKeyA && scancode <= kKey0) || (scancode >= kSpace && scancode <= kSlash) || " + "scancode == kSecondBackslash ||\n" + " (scancode >= kPad1 && scancode <= kPad0) || scancode == kBackspace || scancode == kDelete;\n" in module + and "kEnter = 40, kPadEnter = 88, kSlash = 56, kSecondBackslash = 100;\n" in module, + "on the name rows a key of the typing block is no button, in the modern controls as well, from the moment the " + "keyboard shows it down, which can be a frame before its event, until it is let go and not merely until the rows " + "are left, and a run in step with another gets the keyboard whole", +) image_path = ROOT / "input" / "SLUS_002.24.code" if image_path.exists(): 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..59d6078 100644 --- a/tests/test_perspective_texture_contract.py +++ b/tests/contracts/test_perspective_texture_contract.py @@ -13,8 +13,8 @@ from math import isclose -ROOT = pathlib.Path(__file__).resolve().parents[1] -OVERLAY = ROOT / "psxrecomp-overlay" / "runtime" +ROOT = pathlib.Path(__file__).resolve().parents[2] +OVERLAY = ROOT / "psxrecomp" / "runtime" MAIN = OVERLAY / "src" / "main.cpp" GPU = OVERLAY / "src" / "gpu.c" GL = OVERLAY / "src" / "gpu_gl_renderer.c" diff --git a/tests/test_phase_a_contract.py b/tests/contracts/test_phase_a_contract.py similarity index 94% rename from tests/test_phase_a_contract.py rename to tests/contracts/test_phase_a_contract.py index e9411e4..fd08f02 100644 --- a/tests/test_phase_a_contract.py +++ b/tests/contracts/test_phase_a_contract.py @@ -8,16 +8,16 @@ import sys -ROOT = pathlib.Path(__file__).resolve().parents[1] -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" -GPU_HEADER = ROOT / "psxrecomp-overlay" / "runtime" / "include" / "gpu.h" +ROOT = pathlib.Path(__file__).resolve().parents[2] +GPU = ROOT / "psxrecomp" / "runtime" / "src" / "gpu.c" +GTE = ROOT / "psxrecomp" / "runtime" / "src" / "gte.cpp" +GL = ROOT / "psxrecomp" / "runtime" / "src" / "gpu_gl_renderer.c" +GPU_HEADER = ROOT / "psxrecomp" / "runtime" / "include" / "gpu.h" GTE_HEADER = ( - ROOT / "psxrecomp-overlay" / "runtime" / "include" / "gte_precision.h" + ROOT / "psxrecomp" / "runtime" / "include" / "gte_precision.h" ) -MOUSE = ROOT / "src" / "disruptor_mouse_aim.cpp" -OVERLAY_LIST = ROOT / "PSXRECOMP_OVERLAY_FILES.txt" +MOUSE = ROOT / "src" / "game" / "input" / "mouse_aim.cpp" +OVERLAY_LIST = ROOT / "framework/FILES.txt" def function_body(source: str, name: str) -> str: diff --git a/tests/test_pinned_corner_contract.py b/tests/contracts/test_pinned_corner_contract.py similarity index 89% rename from tests/test_pinned_corner_contract.py rename to tests/contracts/test_pinned_corner_contract.py index e4b6dcd..f2ba9fb 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: @@ -41,26 +41,17 @@ def enclosing(source: str, block: str) -> list[str]: cmake = read("CMakeLists.txt") -overlay = read("PSXRECOMP_OVERLAY_FILES.txt") -gte = read("psxrecomp-overlay/runtime/src/gte.cpp") -gpu = read("psxrecomp-overlay/runtime/src/gpu.c") -render_header = read("psxrecomp-overlay/runtime/include/gpu_render.h") -render = read("psxrecomp-overlay/runtime/src/gpu_render.c") -renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") -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" -) +overlay = read("framework/FILES.txt") +gte = read("psxrecomp/runtime/src/gte.cpp") +gpu = read("psxrecomp/runtime/src/gpu.c") +render_header = read("psxrecomp/runtime/include/gpu_render.h") +render = read("psxrecomp/runtime/src/gpu_render.c") +renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") +temporal = read("psxrecomp/runtime/src/gpu_gl_temporal.c.inc") +mesh = read("psxrecomp/runtime/include/gpu_temporal_mesh.h") + +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/test_save_point_contract.py b/tests/contracts/test_save_point_contract.py similarity index 92% rename from tests/test_save_point_contract.py rename to tests/contracts/test_save_point_contract.py index aa87912..f9f8dc4 100644 --- a/tests/test_save_point_contract.py +++ b/tests/contracts/test_save_point_contract.py @@ -8,7 +8,7 @@ import re from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] POLL = "savestate_poll(cpu, s_compiled_interrupt_resume_pc);" @@ -32,13 +32,13 @@ def polls_are_the_four(source: str) -> bool: 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") +manifest = read("framework/FILES.txt").splitlines() +interrupts = read("psxrecomp/runtime/src/interrupts.c") +rule = read("psxrecomp/runtime/include/save_point.h") require( - " add_test(NAME disruptor_save_point_contract\n" in cmake - and " add_test(NAME disruptor_save_point\n COMMAND disruptor-save-point-test)\n" in cmake, + " 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( diff --git a/tests/test_smooth_turn_contract.py b/tests/contracts/test_smooth_turn_contract.py similarity index 66% rename from tests/test_smooth_turn_contract.py rename to tests/contracts/test_smooth_turn_contract.py index 0f37728..ba24fb2 100644 --- a/tests/test_smooth_turn_contract.py +++ b/tests/contracts/test_smooth_turn_contract.py @@ -3,7 +3,9 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +from menu_sources import menu_text + +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -21,19 +23,31 @@ def between(source: str, opening: str, closing: str) -> str: cmake = read("CMakeLists.txt") -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") +overlay = read("framework/FILES.txt") +renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") +gpu = read("psxrecomp/runtime/src/gpu.c") +mouse = read("src/game/input/mouse_aim.cpp") +menu = menu_text(ROOT) 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 +88,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 +108,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..1da26b8 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") -gte = read("psxrecomp-overlay/runtime/src/gte.cpp") +source = read("src/game/render/sprite_depth.cpp") +gte = read("psxrecomp/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/contracts/test_state_key_contract.py b/tests/contracts/test_state_key_contract.py new file mode 100644 index 0000000..079a7cc --- /dev/null +++ b/tests/contracts/test_state_key_contract.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Source contract for the key a save state carries: the generator's sources it is made of. + +The key is pinned twice: over the files' bytes as the pin and the project's patches give them, and with CRLF read as +LF, which is how CMake reads a file on Windows before it hashes. +""" + +import hashlib +import importlib.util +import re +import sys +from pathlib import Path, PurePosixPath + +ROOT = Path(__file__).resolve().parents[2] +KEY_OF_BYTES = "2d1942e0" +KEY_OF_TEXT = "2cece446" + +specification = importlib.util.spec_from_file_location("framework_tool", ROOT / "tools" / "framework.py") +if specification is None or specification.loader is None: + raise AssertionError("tools/framework.py cannot be loaded") +tool = importlib.util.module_from_spec(specification) +sys.modules[specification.name] = tool +specification.loader.exec_module(tool) + + +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 as_text(content: bytes) -> bytes: + return content.replace(b"\r\n", b"\n") + + +def key(content: bytes) -> str: + return hashlib.sha256(content).hexdigest()[:8] + + +listing = read("psxrecomp/runtime/codegen_hash_sources.cmake") +sources = re.findall(r"^\s*\$\{PSXRECOMP_CODEGEN_HASH_ROOT\}/(\S+?)\)?$", listing, re.MULTILINE) +require(len(sources) == 19, f"the key is made of 19 sources, the framework lists {len(sources)}") + +project = tool.Project(ROOT) +checkout = tool.Checkout(ROOT / "psxrecomp", project.pin()) +changed = {str(change.path): change for change in project.changes()} +stored, working = b"", b"" +for source in sources: + change = changed.get(source, tool.Change(PurePosixPath(source), False)) + pinned = checkout.pinned(change) + if pinned is None: + raise AssertionError(f"the pinned framework has no {source}") + stored += tool.apply_patch(pinned, project.record(change).read_bytes()) if source in changed else pinned + working += (ROOT / "psxrecomp" / source).read_bytes() +require( + key(stored) == KEY_OF_BYTES and key(as_text(stored)) == KEY_OF_TEXT, + f"the generator's sources give the keys {key(stored)} and {key(as_text(stored))}, not {KEY_OF_BYTES} and " + f"{KEY_OF_TEXT}: every save state and capsule made so far would be refused. Change them only with the pin, " + "and restate the keys here", +) +require( + as_text(working) == as_text(stored), + "the checkout's generator sources are not the pinned ones with the project's patches", +) +hashing = read("psxrecomp/runtime/hash_codegen.cmake") +require( + 'string(SHA256 _h "${_cat}")\nstring(SUBSTRING "${_h}" 0 8 _h8)\n' in hashing + and " h.codegen_hash != (uint32_t)PSX_OVERLAY_CODEGEN_HASH ||\n" + in read("psxrecomp/runtime/src/boot_state.c"), + "the key is the first eight digits of the sources' hash, and a state of another key is refused", +) +require(" disruptor_contract(state_key)\n" in read("CMakeLists.txt"), "this contract must remain registered") + +print("Disruptor save state key source contract: PASS") diff --git a/tests/test_temporal_presenter_contract.py b/tests/contracts/test_temporal_presenter_contract.py similarity index 88% rename from tests/test_temporal_presenter_contract.py rename to tests/contracts/test_temporal_presenter_contract.py index 975af03..27ec92e 100644 --- a/tests/test_temporal_presenter_contract.py +++ b/tests/contracts/test_temporal_presenter_contract.py @@ -4,7 +4,9 @@ import re from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +from menu_sources import menu_text + +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -36,14 +38,18 @@ def shader(source: str, name: str) -> str: cmake = read("CMakeLists.txt") -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") +temporal = read("psxrecomp/runtime/src/gpu_gl_temporal.c.inc") +renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") +gpu = read("psxrecomp/runtime/src/gpu.c") +billboards = read("src/game/render/billboard_aspect.cpp") require( - "disruptor_temporal_presenter_contract" in cmake and "PSX_HAS_DISRUPTOR_SPRITE_DEPTH=1" in cmake, - "the presenter contract and the sprite-depth interpreter switch must remain registered", + " disruptor_contract(temporal_presenter)\n" in cmake + and (read("game.toml") + read("game-widescreen.toml")).count( + 'callback = "disruptor_sprite_depth_instruction_hook"\n' + ) + == 2, + "the presenter contract and the sprite depth hook for code that runs from RAM must remain registered", ) underlay = function_body(temporal, "static void temporal_underlay(float alpha,int covered)") @@ -219,9 +225,30 @@ def shader(source: str, name: str) -> str: and quad.index("const int raw_w = abs(vx[1] - vx[0]) + 1, raw_h = abs(vy[2] - vy[0]) + 1;") < quad.index("if (ws_tagged_anchor(&ws_ax)) {"), "the depth and the sides must be announced right before the rectangle they belong to, from the packet's size before the squash", ) +require( + "static int s_shadow_shape;\n" + "void gpu_set_shadow_shape(int improved) { s_shadow_shape = improved != 0; }\n" + "int gpu_shadow_shape(void) { return s_shadow_shape; }\n" in gpu + and "int psx_game_configure(const char *game_id, size_t length) {\n" + ' if (const char *asked = std::getenv("PSX_DISRUPTOR_IMPROVED_SHADOWS")) gpu_set_shadow_shape(asked[0] == \'1\');\n' + in read("src/game/host.cpp") + and ' if (settings.has_improved_shadows && !env_override_present("PSX_DISRUPTOR_IMPROVED_SHADOWS"))\n' + " gpu_set_shadow_shape(settings.improved_shadows ? 1 : 0);\n" in menu_text(ROOT), + "the renderer holds the shadow switch and reads no environment: the game sets it at the start, " + "and a saved setting gives way to the environment", +) +host_loop = read("psxrecomp/runtime/src/main.cpp") +require( + host_loop.count("psx_game_configure(") == host_loop.count(" gpu_init();\n") == 1 + and host_loop.index("int main(int argc, char** argv) {") + < host_loop.index("psx_game_configure(") + < host_loop.index(" gpu_init();\n") + < host_loop.index(" sdl_window = SDL_CreateWindow("), + "the game is configured before the GPU starts and a window opens: nothing can ask for the shadow switch sooner", +) sides = function_body(gpu, "static float temporal_sprite_sides(int x, int y, int w, int h, int raw_w, int raw_h, int squashed, float sides[4])") sizer = function_body(gpu, "void gpu_temporal_size_sprite(uint32_t primitive_addr, int32_t row16, int32_t width16, int32_t height16,") -sprite_header = read("psxrecomp-overlay/runtime/include/gpu_temporal_sprite.h") +sprite_header = read("psxrecomp/runtime/include/gpu_temporal_sprite.h") require( " if (!t) return 0.0f;" in sides and " const GpuTemporalSprite sprite = gpu_temporal_sprite_from_fixed(\n t->placed, t->sized, t->shadow, t->whole, t->centre_x, t->offset_y, t->row, t->width, t->height);" in sides @@ -229,14 +256,14 @@ 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 "+runtime/include/gpu_temporal_sprite.h" in read("PSXRECOMP_OVERLAY_FILES.txt") + and "tests/unit/gpu_temporal_sprite_test.cpp" in read("CMakeLists.txt") + and "+runtime/include/gpu_temporal_sprite.h" in read("framework/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, "the renderer must hand a sprite's own numbers, the squash and the shadow setting to the tested side arithmetic", ) -gte = read("psxrecomp-overlay/runtime/src/gte.cpp") -runtime_main = read("psxrecomp-overlay/runtime/src/main.cpp") +gte = read("psxrecomp/runtime/src/gte.cpp") +runtime_main = read("psxrecomp/runtime/src/main.cpp") audit = read("tools/audit_codegen.py") scratch_store = function_body(gte, "extern \"C\" void gte_precision_scratch_store_pc_word(") band = function_body(gte, "static uint32_t precision_scratch_route_clamp(") @@ -245,7 +272,7 @@ def shader(source: str, name: str) -> str: and "pinned.packed = packed;\n pinned.clamped = 1;\n precision_store_projection(physical, pinned);" in scratch_store and "if (x < route.low_x) x = route.low_x;" in band and "if (x > route.high_x) x = route.high_x;" in band and "if (y < route.low_y) y = route.low_y;" in band and "if (y > route.high_y) y = route.high_y;" in band - and "0x80047A0Cu, 0xACAA0004u, -0x100, 0x240, -0x100, 0x1F0)) {" in runtime_main + and "0x80047A0Cu, 0xACAA0004u, -0x100, 0x240, -0x100, 0x1F0)) {" in read("src/game/render/precision_routes.cpp") and '"cpu->gpr[11] = -256; /* 0x8004798C: 0x200BFF00 */", "cpu->gpr[11] = 576; /* 0x800479A8: 0x200B0240 */",' in audit and '"cpu->gpr[12] = -256; /* 0x800479C4: 0x200CFF00 */", "cpu->gpr[12] = 496; /* 0x800479E0: 0x200C01F0 */"):' in audit, "a corner the game pins to its guard band must keep its projection only when the stored word is that projection pinned to the retail band", diff --git a/tests/contracts/test_test_registration_contract.py b/tests/contracts/test_test_registration_contract.py new file mode 100644 index 0000000..665dbf6 --- /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_tool.py": "disruptor_framework_tool", +} +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 3.10 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 79% rename from tests/test_vertical_camera_contract.py rename to tests/contracts/test_vertical_camera_contract.py index 4695e11..777490b 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,12 +19,11 @@ 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") -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") +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/recompiler/src/code_generator.cpp") +gpu = read("psxrecomp/runtime/src/gpu.c") cmake = read("CMakeLists.txt") configs = read("game.toml") + read("game-widescreen.toml") @@ -63,11 +62,12 @@ def require(condition: bool, message: str) -> None: "addr, instr);" not in codegen_table, "generated sites must call the guarded camera dispatcher") -require("#ifdef PSX_HAS_DISRUPTOR_VERTICAL_CAMERA" in dirty and - "disruptor_vertical_camera_instruction_hook(cpu, pc, insn, 1)" in dirty, - "dirty interpreter parity must be project-gated and post-instruction") -require("PSX_HAS_DISRUPTOR_VERTICAL_CAMERA=1" in cmake, - "the Disruptor target must opt into dirty-interpreter camera hooks") +listed_start = configs.index('callback = "disruptor_vertical_camera_instruction_hook"\n') +listed_table = configs[listed_start:configs.index("[[recompiler.instruction_hook]]", listed_start)] +require(configs.count('callback = "disruptor_vertical_camera_instruction_hook"\n') == 2 and + all(f'["0x{pc:08X}", "0x{word:08X}"],' in listed_table + for pc, word in expected_sites.items()), + "both game configurations must list the camera hooks for code that runs from RAM") require("g_ls_mode != 0 || g_ls_replay_active != 0" in camera, "record and replay halves of the lockstep comparator must be neutral") @@ -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 73% rename from tests/test_world_depth_contract.py rename to tests/contracts/test_world_depth_contract.py index 9934c50..e82ae0c 100644 --- a/tests/test_world_depth_contract.py +++ b/tests/contracts/test_world_depth_contract.py @@ -1,10 +1,11 @@ #!/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] +from menu_sources import menu_text + +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -22,20 +23,31 @@ def between(source: str, opening: str, closing: str) -> str: cmake = read("CMakeLists.txt") -overlay = read("PSXRECOMP_OVERLAY_FILES.txt") -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") +overlay = read("framework/FILES.txt") +header = read("psxrecomp/runtime/include/gpu_world_depth.h") +renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") +presenter = read("psxrecomp/runtime/src/gpu_gl_temporal.c.inc") +menu = menu_text(ROOT) launcher = read("src/launcher/launcher_settings.cpp") -loader = read("psxrecomp-overlay/recompiler/src/config_loader.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("src/game/settings/saved.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 +123,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", ) @@ -157,13 +162,16 @@ def between(source: str, opening: str, closing: str) -> str: """ in loader and "s.has_depth_sorted_world" in between(loader, " if (s.has_mouse_aim", "{\n") and " bool has_depth_sorted_world = false; bool depth_sorted_world = false;\n" - in read("psxrecomp-overlay/recompiler/src/config_loader.h"), + in read("src/game/settings/game_user_settings.h"), "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/test_framework_overlay_new_file.py b/tests/test_framework_overlay_new_file.py deleted file mode 100644 index 6617bab..0000000 --- a/tests/test_framework_overlay_new_file.py +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env python3 -"""Verify that reviewed overlays may add files inside known framework dirs.""" - -from __future__ import annotations - -import pathlib -import subprocess -import sys -import tempfile - - -ROOT = pathlib.Path(__file__).resolve().parents[1] -APPLIER = ROOT / "tools" / "apply_framework_overlay.py" -PROJECT_OVERLAY = ROOT / "psxrecomp-overlay" -PROJECT_MANIFEST = ROOT / "PSXRECOMP_OVERLAY_FILES.txt" - - -def run_applier( - framework: pathlib.Path, - overlay: pathlib.Path, - manifest: pathlib.Path, -) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [ - sys.executable, - str(APPLIER), - "--framework", - str(framework), - "--overlay", - str(overlay), - "--manifest", - str(manifest), - ], - check=False, - capture_output=True, - text=True, - ) - - -def main() -> int: - with tempfile.TemporaryDirectory() as temp_dir: - temp = pathlib.Path(temp_dir) - framework = temp / "framework" - overlay = temp / "overlay" - include_dir = framework / "runtime" / "include" - overlay_include_dir = overlay / "runtime" / "include" - include_dir.mkdir(parents=True) - overlay_include_dir.mkdir(parents=True) - (framework / "runtime" / "runtime.cmake").write_text( - "# fixture\n", encoding="utf-8" - ) - expected = "#pragma once\n" - (overlay_include_dir / "new_header.h").write_text( - expected, encoding="utf-8" - ) - manifest = temp / "manifest.txt" - manifest.write_text( - "+runtime/include/new_header.h\n", encoding="utf-8" - ) - - result = run_applier(framework, overlay, manifest) - if result.returncode != 0: - print(result.stdout, end="") - print(result.stderr, end="", file=sys.stderr) - return 1 - installed = include_dir / "new_header.h" - if installed.read_text(encoding="utf-8") != expected: - print("new manifest-listed overlay file was not copied", file=sys.stderr) - return 1 - - unexpected = overlay_include_dir / "unexpected.h" - unexpected.write_text(expected, encoding="utf-8") - manifest.write_text( - "runtime/include/unexpected.h\n", encoding="utf-8" - ) - rejected = run_applier(framework, overlay, manifest) - if rejected.returncode == 0 or (include_dir / "unexpected.h").exists(): - print("unmarked new overlay file was not rejected", file=sys.stderr) - return 1 - - # Exercise the real reviewed manifest as well. Existing entries get - # placeholder destinations; '+' entries deliberately start absent. - real_framework = temp / "real-framework" - (real_framework / "runtime").mkdir(parents=True) - (real_framework / "runtime" / "runtime.cmake").write_text( - "# fixture\n", encoding="utf-8" - ) - for raw_line in PROJECT_MANIFEST.read_text( - encoding="utf-8" - ).splitlines(): - entry = raw_line.strip() - if not entry or entry.startswith("#"): - continue - allow_new = entry.startswith("+") - relative = pathlib.Path(entry[1:] if allow_new else entry) - destination = real_framework / relative - destination.parent.mkdir(parents=True, exist_ok=True) - if not allow_new: - destination.write_text("fixture\n", encoding="utf-8") - - real_result = run_applier( - real_framework, PROJECT_OVERLAY, PROJECT_MANIFEST - ) - if real_result.returncode != 0: - print(real_result.stdout, end="") - print(real_result.stderr, end="", file=sys.stderr) - return 1 - installed_shared_header = ( - real_framework / "runtime" / "include" / "gte_precision.h" - ) - if not installed_shared_header.is_file(): - print("real manifest did not install gte_precision.h", file=sys.stderr) - return 1 - - print("framework overlay new-file test: PASS") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) 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..efe22ae 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/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 97% rename from tests/disruptor_mouse_aim_test.cpp rename to tests/unit/disruptor_mouse_aim_test.cpp index c37d6b2..a6e60a9 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/runtime/include/gpu.h" #include #include @@ -18,6 +18,7 @@ namespace { SDL_Window g_window; std::array g_ram{}; std::array g_keys{}; +std::array g_typed_with{}; /* keys the name entry holds back from the pad */ void (*g_registered_hook)(void) = nullptr; std::uint32_t g_noted_at = 0; std::uint8_t g_noted = 0; @@ -62,6 +63,10 @@ void disruptor_capsule_note_byte(std::uint32_t address, std::uint8_t value) { ++g_notes; } +void disruptor_name_entry_keys_for_pad(const std::uint8_t *keys, std::uint8_t *kept, int count) { + for (int key = 0; key < count; ++key) kept[key] = g_typed_with[static_cast(key)] ? std::uint8_t{0} : keys[key]; +} + int psx_host_ui_game_input_captured(void) { return g_host_ui_captured; } @@ -184,7 +189,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 { @@ -203,6 +208,7 @@ void reset_state() { g_mouse = MouseAimState{}; g_ram.fill(0); g_keys.fill(0); + g_typed_with.fill(false); g_buttons = 0; g_pad_buttons = 0xFFFFu; g_relative_x = 0.0f; @@ -710,6 +716,18 @@ void test_modern_keyboard_mapping_and_merge() { expect_pressed(kPadLeft, "Left arrow retains original turn/menu behavior"); expect_pressed(kPadRight, "Right arrow retains original turn/menu behavior"); expect_pressed(kPadSelect, "Tab maps to the automap"); + + reset_state(); + g_mouse.modern_controls_enabled = true; + g_typed_with[SDL_SCANCODE_E] = true; + g_keys[SDL_SCANCODE_E] = 1; + g_keys[SDL_SCANCODE_F] = 1; + g_keys[SDL_SCANCODE_RETURN] = 1; + apply_modern_controls(g_keys.data(), 0); + expect_released(kPadTriangle, "a key a name is being typed with is no button"); + expect_pressed(kPadSquare, "the keys beside it still are"); + expect_pressed(kPadCross, "and so is Return"); + expect(g_keys[SDL_SCANCODE_E] == 1, "the keyboard's own state is left as it was"); } void test_modern_mouse_buttons_require_capture() { diff --git a/tests/disruptor_name_entry_test.cpp b/tests/unit/disruptor_name_entry_test.cpp similarity index 66% rename from tests/disruptor_name_entry_test.cpp rename to tests/unit/disruptor_name_entry_test.cpp index eb75d51..f1c94f0 100644 --- a/tests/disruptor_name_entry_test.cpp +++ b/tests/unit/disruptor_name_entry_test.cpp @@ -1,6 +1,8 @@ #include "cpu_state.h" #include "mod_plugins.h" +#include +#include #include #include #include @@ -16,7 +18,7 @@ constexpr char kTable[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 "; /* the game' std::map g_memory; std::uint32_t g_entry_address = 0; PSXModFunctionEntryCallback g_entry = nullptr; -PSXModVBlankCallback g_tick = nullptr; +std::uint64_t g_cycles = 1000; /* the game's clock */ int g_netplay = 0; int g_strays = 0; int g_failures = 0; @@ -27,6 +29,9 @@ void expect(bool condition, const char *message) { std::cerr << "FAIL: " << message << '\n'; } +/* A VBlank of the game passes. */ +void g_tick() { g_cycles += 564480u; } + bool ours(std::uint32_t address, std::uint32_t bytes) { return (address == kTestRow && bytes == 4u) || (address == kTestCursor && bytes == 4u) || (address >= kTestName && address < kTestName + 8u && bytes == 1u); @@ -56,13 +61,10 @@ int psx_mod_register_function_entry_plugin(const char *, std::uint32_t address, g_entry = callback; return 1; } -int psx_mod_register_vblank_plugin(const char *, PSXModVBlankCallback callback) { - g_tick = callback; - return 1; -} +std::uint64_t psx_get_cycle_count(void) { return g_cycles; } } -#include "../src/disruptor_name_entry.cpp" +#include "../../src/game/input/name_entry.cpp" namespace { @@ -107,7 +109,7 @@ void fresh(std::uint32_t at_row, std::uint32_t at_cursor, const std::string &tex } void test_keys_become_the_name() { - expect(g_entry_address == 0x8001C484u && g_entry && g_tick, "the module must hook the save screen's routine and the VBlank"); + expect(g_entry_address == 0x8001C484u && g_entry, "the module must hook the save screen's routine"); fresh(5, 0); expect(strike(4, 'a') && name() == "A " && cursor() == 1u && row() == 5u, "a letter goes where the cursor is and the cursor moves on"); expect(strike(5, 'B') && strike(38, '9') && strike(39, '0') && strike(44, 0xA0u) && strike(29, 'z'), @@ -268,6 +270,136 @@ void test_a_letter_key_is_no_button_that_frame() { expect(frame() == 0u && name() == "AAAAAAAA" && cursor() == 8u, "more keys than a frame can hold are dropped, not written past the name"); } +/* Whether the pad sees this key when it and the others named are down. */ +bool reaches_pad(int scancode, std::initializer_list others = {}) { + std::array keys{}, kept{}; + keys[static_cast(scancode)] = 1; + for (const int other : others) keys[static_cast(other)] = 1; + disruptor_name_entry_keys_for_pad(keys.data(), kept.data(), static_cast(keys.size())); + for (std::size_t key = 0; key < keys.size(); ++key) expect(kept[key] == 0 || keys[key] != 0, "the pad is never shown a key that is up"); + return kept[static_cast(scancode)] != 0; +} + +void let_go() { + const std::array keys{}; + std::array kept{}; + disruptor_name_entry_keys_for_pad(keys.data(), kept.data(), static_cast(keys.size())); +} + +void test_a_typing_key_stays_off_the_pad() { + fresh(0, 0); + expect(reaches_pad(8) && reaches_pad(42) && reaches_pad(44) && reaches_pad(30) && reaches_pad(51), "off the name rows every key is the pad's"); + let_go(); + + fresh(5, 0); + expect(!reaches_pad(8), "on the name rows a letter key is no button, and that before its event has come"); + expect(frame() == kPressed && name() == " ", "which leaves the pad's own presses alone and types nothing by itself"); + expect(!reaches_pad(4) && !reaches_pad(29) && !reaches_pad(30) && !reaches_pad(39) && !reaches_pad(89) && !reaches_pad(98), + "nor is any letter, a key of the digit row or a digit of the keypad"); + expect(!reaches_pad(44) && !reaches_pad(42) && !reaches_pad(76), "nor the space bar or either eraser"); + expect(!reaches_pad(45) && !reaches_pad(51) && !reaches_pad(56) && !reaches_pad(100), + "nor a sign's key: a French board has M on one, a Dvorak board most of its letters"); + expect(reaches_pad(40) && reaches_pad(88) && reaches_pad(82) && reaches_pad(81) && reaches_pad(43) && reaches_pad(41) && reaches_pad(58) && + reaches_pad(225), + "Enter, the arrows, Tab, Escape, a function key and Shift stay buttons"); + expect(reaches_pad(82, {8}) && !reaches_pad(8, {82}), "one key held back does not hold back another"); + let_go(); + for (std::uint32_t at = 6; at <= 7; ++at) { + fresh(at, 8); + expect(!reaches_pad(8), "the rows under the letters hold a letter key back as well"); + let_go(); + } + + fresh(0, 0); + screen(5, 0); + expect(!reaches_pad(8), "a key that goes down between the routine's putting the rows up and its next entry is held back already"); + expect(disruptor_name_entry_key(8, 'e', 1, 1) == 0, "and is no text yet"); + expect(frame() == kPressed && name() == " ", "so it does nothing at all"); + disruptor_name_entry_key(8, 'e', 0, 1); + let_go(); + + fresh(4, 0); + expect(reaches_pad(9), "on the row before, F is a button and can confirm it"); + screen(5, 0); + expect(disruptor_name_entry_key(9, 'f', 1, 1) == 0, "its event, which comes after the pad showed it, is not typed into the name it brought up"); + expect(!reaches_pad(9), "and the key, still down, is a button no more"); + expect(frame() == kPressed && name() == " ", "the name stays empty"); + disruptor_name_entry_key(9, 'f', 0, 1); + let_go(); + + fresh(5, 0); + screen(0, 0); + expect(reaches_pad(9), "a key that goes down once the routine has left the rows is a button"); + disruptor_name_entry_key(9, 'f', 1, 1); + expect(frame() == kPressed && name() == " ", "and what its event queued is dropped at the routine's next entry"); + disruptor_name_entry_key(9, 'f', 0, 1); + let_go(); + fresh(5, 0); + for (int tick = 0; tick < 8; ++tick) g_tick(); + expect(reaches_pad(9) && disruptor_name_entry_key(9, 'f', 1, 1) == 0, "and so is one that comes when the routine has stopped running"); + disruptor_name_entry_key(9, 'f', 0, 1); + let_go(); + + fresh(5, 0); + expect(!reaches_pad(8), "a key held on the name rows"); + screen(0, 0); + expect(!reaches_pad(8), "stays held back when the rows are left: the next screen must not take it for a fresh press"); + let_go(); + expect(reaches_pad(8), "and is a button again once it has been let go"); + let_go(); + + fresh(5, 0); + expect(disruptor_name_entry_key(99, 'x', 1, 1) == 0 && reaches_pad(99) && frame() == kPressed && name() == " ", + "a key outside the typing block types nothing whatever a layout calls it, and stays a button"); + disruptor_name_entry_key(99, 'x', 0, 1); + let_go(); + + fresh(5, 0); + expect(!reaches_pad(8), "a key held back on the name rows"); + g_netplay = 1; + expect(reaches_pad(8), "is given to a network game at once: nothing is typed there"); + g_netplay = 0; + let_go(); + fresh(5, 0); + expect(!reaches_pad(8), "and one held back"); + g_ls_mode = 1; + expect(reaches_pad(8), "to a lockstep run"); + g_ls_mode = 0; + let_go(); + + fresh(5, 0); + expect(disruptor_name_entry_key(4, 'a', 1, 1) == 1 && !reaches_pad(4), "a letter queued and its key held back"); + disruptor_name_entry_state_loaded(); + expect(disruptor_name_entry_key(5, 'b', 1, 1) == 0, "a save state loaded: no key is text until the routine runs again"); + expect(reaches_pad(9), "a key that goes down now is a button, though the clock has not moved"); + expect(frame() == kPressed && name() == " ", "and what was typed before the load is not put into the name it restored"); + disruptor_name_entry_key(4, 'a', 0, 1); + disruptor_name_entry_key(5, 'b', 0, 1); + let_go(); + + int typed_and_pressed = 0, typing = 0; + for (int scancode = 0; scancode < 512; ++scancode) { + for (const std::uint32_t keycode : {std::uint32_t{'a'}, std::uint32_t{'Z'}, std::uint32_t{'7'}, std::uint32_t{' '}, 0xE9u, 0x444u, 0x40000059u}) { + fresh(5, 0); + const bool on_pad = reaches_pad(scancode); + const bool text = disruptor_name_entry_key(scancode, keycode, 1, 1) == 1; + disruptor_name_entry_key(scancode, keycode, 0, 1); + let_go(); + typing += text; + typed_and_pressed += text && on_pad; + } + } + expect(typing > 300 && typed_and_pressed == 0, "no key of the board, whatever a layout calls it, both types on the name rows and presses a button"); + + fresh(5, 0); + std::array wide{}, kept{}; + wide[8] = wide[599] = 1; + kept[599] = 7; + disruptor_name_entry_keys_for_pad(wide.data(), kept.data(), 600); + expect(kept[8] == 0 && kept[599] == 1, "a key past the last one known is passed on as it is"); + let_go(); +} + } // namespace int main() { @@ -276,6 +408,7 @@ int main() { test_enter_ends_the_typing(); test_only_on_the_name_rows(); test_a_letter_key_is_no_button_that_frame(); + test_a_typing_key_stays_off_the_pad(); expect(g_strays == 0, "nothing but the row, the cursor and the name's eight codes may be read or written"); if (g_failures) return 1; std::cout << "Disruptor name entry tests passed\n"; 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 98% rename from tests/host_borderless_test.cpp rename to tests/unit/host_borderless_test.cpp index 181ecc3..485b5c0 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/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..79e5ef1 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/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/save_point_test.cpp b/tests/unit/save_point_test.cpp similarity index 100% rename from tests/save_point_test.cpp rename to tests/unit/save_point_test.cpp 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/apply_framework_overlay.py b/tools/apply_framework_overlay.py deleted file mode 100755 index c353d77..0000000 --- a/tools/apply_framework_overlay.py +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env python3 -"""Apply this project's reviewed source overlay to a pinned PSXRecomp tree.""" - -from __future__ import annotations - -import argparse -import filecmp -import shutil -import sys -from pathlib import Path, PurePosixPath - - -PROJECT_ROOT = Path(__file__).resolve().parents[1] - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument( - "--framework", - type=Path, - required=True, - help="path to the pinned PSXRecomp checkout", - ) - parser.add_argument( - "--overlay", - type=Path, - default=PROJECT_ROOT / "psxrecomp-overlay", - help="overlay root (default: this repository's psxrecomp-overlay)", - ) - parser.add_argument( - "--manifest", - type=Path, - default=PROJECT_ROOT / "PSXRECOMP_OVERLAY_FILES.txt", - help="reviewed overlay-file manifest", - ) - args = parser.parse_args() - - framework = args.framework.resolve() - overlay = args.overlay.resolve() - manifest = args.manifest.resolve() - if not overlay.is_dir(): - parser.error(f"framework overlay does not exist: {overlay}") - if not manifest.is_file(): - parser.error(f"framework overlay manifest does not exist: {manifest}") - if not (framework / "runtime" / "runtime.cmake").is_file(): - parser.error(f"not a PSXRecomp checkout: {framework}") - - relative_paths: list[Path] = [] - new_relative_paths: set[Path] = set() - for line_number, raw_line in enumerate( - manifest.read_text(encoding="utf-8").splitlines(), start=1 - ): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - allow_new = line.startswith("+") - if allow_new: - line = line[1:].strip() - if not line: - parser.error( - f"empty new-file entry in {manifest} at line {line_number}" - ) - posix_path = PurePosixPath(line) - if posix_path.is_absolute() or ".." in posix_path.parts: - parser.error( - f"unsafe path in {manifest} at line {line_number}: {line}" - ) - relative = Path(*posix_path.parts) - relative_paths.append(relative) - if allow_new: - new_relative_paths.add(relative) - - if not relative_paths: - parser.error(f"framework overlay manifest is empty: {manifest}") - if len(relative_paths) != len(set(relative_paths)): - parser.error(f"framework overlay manifest contains duplicates: {manifest}") - - sources = [overlay / relative for relative in relative_paths] - absent_sources = [ - relative - for source, relative in zip(sources, relative_paths) - if not source.is_file() - ] - if absent_sources: - print("Reviewed framework overlay files are missing:", file=sys.stderr) - for path in absent_sources: - print(f" missing {path}", file=sys.stderr) - return 1 - - destinations = [framework / relative for relative in relative_paths] - missing_existing = [ - relative - for destination, relative in zip(destinations, relative_paths) - if relative not in new_relative_paths and not destination.is_file() - ] - invalid_new = [ - relative - for destination, relative in zip(destinations, relative_paths) - if relative in new_relative_paths - and (not destination.parent.is_dir() - or (destination.exists() and not destination.is_file())) - ] - if missing_existing or invalid_new: - print( - "Refusing to apply the overlay because the pinned framework layout " - "does not match:", - file=sys.stderr, - ) - for path in missing_existing: - print(f" missing {path}", file=sys.stderr) - for path in invalid_new: - print(f" invalid destination for new file {path}", file=sys.stderr) - return 1 - - # Manifest membership is the review boundary. Only '+' entries may add a - # file inside an existing framework directory; unmarked entries retain the - # pinned-layout typo/removal guard. - for source, destination in zip(sources, destinations): - shutil.copy2(source, destination) - - mismatched = [ - relative - for source, destination, relative in zip( - sources, destinations, relative_paths - ) - if not filecmp.cmp( - source, - destination, - shallow=False, - ) - ] - if mismatched: - print("Framework overlay verification failed:", file=sys.stderr) - for path in mismatched: - print(f" mismatch {path}", file=sys.stderr) - return 1 - - print(f"Applied and verified {len(sources)} PSXRecomp overlay files.") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/audit_codegen.py b/tools/audit_codegen.py index 73fc9ee..038105b 100644 --- a/tools/audit_codegen.py +++ b/tools/audit_codegen.py @@ -220,6 +220,23 @@ 0x800433A0: 0x02A02821, } +# Code that runs from RAM gets the same calls from one function of the dispatch file. +INTERPRETED_HOOKS = { + "disruptor_vertical_camera_instruction_hook": VERTICAL_CAMERA_SITES, + "disruptor_far_rendering_instruction_hook": FAR_RENDERING_SITES, + "disruptor_billboard_aspect_instruction_hook": BILLBOARD_ASPECT_SITES, + "disruptor_backdrop_tiles_instruction_hook": BACKDROP_TILE_SITES, + "disruptor_sprite_depth_instruction_hook": SPRITE_DEPTH_SITES, + "disruptor_intro_skip_instruction_hook": INTRO_SKIP_SITES, +} +INTERPRETED_CASE = re.compile( + r" case 0x([0-9A-F]{8})u:\n((?: if \(instruction == 0x[0-9A-F]{8}u\) \w+\(cpu, address, instruction, 1\);\n)+)" + r" break;\n" +) +INTERPRETED_CALL = re.compile( + r" if \(instruction == 0x([0-9A-F]{8})u\) (\w+)\(cpu, address, instruction, 1\);\n" +) + # Tests 4-6 widened Disruptor's portal spans into negative X. Its static-world # renderer was authored around unsigned 0..320 spans, and yaw-dependent portal # selection became unstable. Test 7 instead keeps every portal/outcode bound @@ -255,6 +272,31 @@ def billboard_seam_precedes_texture_stores(shard_text: str) -> bool: return seam < shard_text.index(late[0]) < shard_text.index(late[1]) +def interpreted_hooks(dispatch_text: str) -> dict[str, dict[int, int]] | None: + """The calls the dispatch file makes after an instruction that ran from RAM, or None when it has no such list.""" + opening = "void psx_game_instruction_ran(CPUState* cpu, uint32_t address, uint32_t instruction) {\n switch (address) {\n" + closing = " default:\n break;\n }\n}\n" + if dispatch_text.count(opening) != 1: + return None + body = dispatch_text[dispatch_text.index(opening) + len(opening) :] + if closing not in body: + return None + body = body[: body.index(closing)] + cases = INTERPRETED_CASE.findall(body) + if "".join(f" case 0x{address}u:\n{calls} break;\n" for address, calls in cases) != body: + return None + hooks: dict[str, dict[int, int]] = {} + for address, calls in cases: + called = [callback for _, callback in INTERPRETED_CALL.findall(calls)] + if called != [callback for callback in INTERPRETED_HOOKS if callback in called]: + return None + for word, callback in INTERPRETED_CALL.findall(calls): + if int(address, 16) in hooks.setdefault(callback, {}): + return None + hooks[callback][int(address, 16)] = int(word, 16) + return hooks + + def format_addresses(addresses: set[int]) -> str: values = sorted(addresses) shown = ", ".join(f"0x{value:08X}" for value in values[:12]) @@ -703,6 +745,12 @@ def main() -> int: + str(generated_precision_copy_stores) ) + if interpreted_hooks(dispatch_text) != INTERPRETED_HOOKS: + failures.append( + "the dispatch file must give code that runs from RAM the calls native code has: " + + str(interpreted_hooks(dispatch_text)) + ) + for site in VANILLA_PORTAL_AND_POINT_SITES: if site not in shard_text: failures.append( diff --git a/tools/framework.py b/tools/framework.py new file mode 100644 index 0000000..000f8b1 --- /dev/null +++ b/tools/framework.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +"""Carry this project's changes to the pinned PSXRecomp checkout, in both directions. + +The changes live under framework/. PIN names the framework revision. FILES.txt lists every file the project +changes there, one path a line, with "+" before a file the project adds. patches/.patch is the diff of a +changed file against the pinned revision, and files/ is an added file whole. + + apply write each changed file as its pinned content with its patch applied, copy the added files, verify + refresh rewrite patches/ and files/ from the checkout, after an edit made there + check verify that the checkout holds exactly what framework/ describes + +Files are read and written as bytes and a patch is applied here, not by git, so the result does not depend on +a machine's line-ending settings. A patch is still a unified diff that `git apply` and `patch` read. +""" + +import argparse +import difflib +import hashlib +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +INDEX_LINE = re.compile(rb"^index ([0-9a-f]{40})\.\.([0-9a-f]{40}) 100644\n", re.MULTILINE) +HUNK_LINE = re.compile(rb"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") + + +class FrameworkError(Exception): + """A state of the project's folder or of the checkout that the command cannot work with.""" + + +def blob_hash(content: bytes) -> str: + """Returns the hash git gives a file of this content.""" + return hashlib.sha1(b"blob %d\0" % len(content) + content, usedforsecurity=False).hexdigest() + + +def lines_of(content: bytes) -> list[bytes]: + """Splits at line feeds, each line keeping its ending, a carriage return before it included. + + Raises: + FrameworkError: The content does not end with a line feed. + """ + if content and not content.endswith(b"\n"): + raise FrameworkError("a file without a final line feed cannot be patched") + return [line + b"\n" for line in content.split(b"\n")[:-1]] + + +def make_patch(path: PurePosixPath, before: bytes, after: bytes) -> bytes: + """Returns the patch that turns `before` into `after`, empty when they are the same.""" + if before == after: + return b"" + name = str(path).encode() + hunks = difflib.diff_bytes(difflib.unified_diff, lines_of(before), lines_of(after), b"a/" + name, b"b/" + name) + head = b"diff --git a/%s b/%s\nindex %s..%s 100644\n" % ( + name, + name, + blob_hash(before).encode(), + blob_hash(after).encode(), + ) + return head + b"".join(hunks) + + +def images(patch: bytes) -> tuple[str, str]: + """Returns the hashes of the file before and after a patch, as its index line gives them. + + Raises: + FrameworkError: The patch does not carry exactly one index line with full hashes. + """ + found = INDEX_LINE.findall(patch) + if len(found) != 1: + raise FrameworkError("a patch must change one file and name its full hashes") + return found[0][0].decode(), found[0][1].decode() + + +def apply_patch(before: bytes, patch: bytes) -> bytes: + """Returns `before` with the patch applied, every line of every hunk matched exactly where the hunk says. + + Raises: + FrameworkError: The patch is not for this content, or the result is not the one it names. + """ + wanted_before, wanted_after = images(patch) + if blob_hash(before) != wanted_before: + raise FrameworkError("the patch was made against other content") + source, rows = lines_of(before), lines_of(patch) + result: list[bytes] = [] + taken = index = 0 + while index < len(rows): + hunk = HUNK_LINE.match(rows[index]) + index += 1 + if hunk is None: + continue + old_left, new_left = int(hunk[2] or 1), int(hunk[4] or 1) + first = int(hunk[1]) - (1 if old_left else 0) + result += source[taken:first] + taken = first + while old_left or new_left: + kind, text = rows[index][:1], rows[index][1:] + index += 1 + if kind in b" -": + if taken >= len(source) or source[taken] != text: + raise FrameworkError(f"a hunk does not match at line {taken + 1}") + taken += 1 + old_left -= 1 + if kind in b" +": + result.append(text) + new_left -= 1 + if kind not in b" -+" or old_left < 0 or new_left < 0: + raise FrameworkError("a hunk is malformed") + after = b"".join(result + source[taken:]) + if blob_hash(after) != wanted_after: + raise FrameworkError("the patch did not give the content it names") + return after + + +@dataclass(frozen=True) +class Change: + """One file of the framework that the project changes. + + Attributes: + path: The file's path inside the framework. + added: Whether the project adds the file, as opposed to changing one the framework has. + """ + + path: PurePosixPath + added: bool + + +class Project: + """The project's own record of its changes: the pin, the list, the patches and the added files.""" + + def __init__(self, root: Path) -> None: + self.folder = root / "framework" + self.manifest = self.folder / "FILES.txt" + + def pin(self) -> str: + """Returns the framework revision the patches are made against.""" + return (self.folder / "PIN").read_text(encoding="utf-8").strip() + + def changes(self) -> list[Change]: + """Reads FILES.txt. + + Raises: + FrameworkError: The list is empty, repeats a path or names one outside the framework. + """ + changes = [] + for number, raw in enumerate(self.manifest.read_text(encoding="utf-8").splitlines(), start=1): + line = raw.strip() + if not line or line.startswith("#"): + continue + path = PurePosixPath(line.removeprefix("+").strip()) + if not path.parts or path.is_absolute() or ".." in path.parts or "\\" in line or ":" in line: + raise FrameworkError(f"unsafe path in {self.manifest.name} at line {number}: {line}") + changes.append(Change(path, line.startswith("+"))) + if not changes: + raise FrameworkError(f"{self.manifest} lists no file") + if len({change.path for change in changes}) != len(changes): + raise FrameworkError(f"{self.manifest} lists a file twice") + return changes + + def record(self, change: Change) -> Path: + """Returns where the change is kept: the patch of a changed file, or an added file itself.""" + if change.added: + return self.folder / "files" / change.path + return self.folder / "patches" / f"{change.path}.patch" + + def strays(self, changes: list[Change]) -> list[str]: + """Returns the patches and added files on disk that FILES.txt does not list.""" + listed = {self.record(change) for change in changes} + kept = {path for name in ("patches", "files") for path in (self.folder / name).rglob("*") if path.is_file()} + return sorted(path.relative_to(self.folder).as_posix() for path in kept - listed) + + +class Checkout: + """A git checkout of the framework that has the pinned revision.""" + + def __init__(self, folder: Path, pin: str) -> None: + if not (folder / "runtime" / "runtime.cmake").is_file(): + raise FrameworkError(f"not a PSXRecomp checkout: {folder}") + self.folder = folder + self.pin = pin + # No braces in an argument: an MSYS git started by a native Python expands them. + if self._git("cat-file", "-e", f"{pin}^0").returncode != 0: + raise FrameworkError(f"the checkout does not have the pinned revision {pin}") + + def _git(self, *arguments: str) -> subprocess.CompletedProcess[bytes]: + return subprocess.run(["git", "-C", str(self.folder), *arguments], capture_output=True, check=False) + + def path(self, change: Change) -> Path: + """Returns the file's place in the checkout.""" + return self.folder / change.path + + def pinned(self, change: Change) -> bytes | None: + """Returns the file as the pinned revision has it, or None when the framework has no such file.""" + done = self._git("cat-file", "blob", f"{self.pin}:{change.path}") + return done.stdout if done.returncode == 0 else None + + def present(self, change: Change) -> bytes | None: + """Returns the file as it is in the checkout now, or None when it is not there.""" + path = self.path(change) + return path.read_bytes() if path.is_file() else None + + def head(self) -> str: + """Returns the revision the checkout is at.""" + return self._git("rev-parse", "HEAD").stdout.decode().strip() + + def changed(self) -> set[str]: + """Returns the files of the framework that git sees as changed in the checkout.""" + done = self._git("-c", "core.quotepath=off", "status", "--porcelain", "--untracked-files=no", "--no-renames") + return {line[3:] for line in done.stdout.decode().splitlines() if line} + + +class Framework: + """The three commands, over a project and a checkout.""" + + def __init__(self, project: Project, checkout: Checkout) -> None: + self.project = project + self.checkout = checkout + self.changes = project.changes() + self.pinned = {change: checkout.pinned(change) for change in self.changes} + for change, pinned in self.pinned.items(): + if (pinned is None) != change.added: + raise FrameworkError( + f"{change.path} is marked '+' but the framework has it" + if change.added + else f"{change.path} is not in the framework: mark it '+' if the project adds it" + ) + + def _described(self, change: Change) -> bytes: + """Returns the file as framework/ says the checkout should hold it.""" + record = self.project.record(change) + if not record.is_file(): + raise FrameworkError(f"framework/ has no record of {change.path}: run `python tools/framework.py refresh`") + pinned = self.pinned[change] + return record.read_bytes() if pinned is None else apply_patch(pinned, record.read_bytes()) + + def _differing(self, pinned_too: bool) -> list[str]: + """Lists the files of the checkout that are not what framework/ describes. + + Args: + pinned_too: Whether a file still as the pinned revision has it, an added file being absent, also passes. + """ + differing = [] + for change in self.changes: + present = self.checkout.present(change) + same = present == self._described(change) or (pinned_too and present == self.pinned[change]) + if not same: + differing.append(str(change.path)) + return differing + + def _refuse_strays(self) -> None: + strays = self.project.strays(self.changes) + if strays: + raise FrameworkError(f"not listed in {self.project.manifest.name}: {', '.join(strays)}") + + def check(self) -> str: + """Verifies that the checkout is the pinned revision with exactly the changes framework/ describes. + + Raises: + FrameworkError: The checkout is at another revision, a file differs, a framework file is changed + without being listed, or framework/ keeps a file that FILES.txt does not list. + """ + self._refuse_strays() + if self.checkout.head() != self.checkout.pin: + raise FrameworkError(f"the checkout is at {self.checkout.head()}, not at the pinned {self.checkout.pin}") + unlisted = sorted(self.checkout.changed() - {str(change.path) for change in self.changes}) + if unlisted: + raise FrameworkError( + f"changed in the checkout and not in {self.project.manifest.name}: {', '.join(unlisted)}" + ) + differing = self._differing(pinned_too=False) + if differing: + raise FrameworkError( + f"the checkout differs from framework/ in {', '.join(differing)}: " + "run `python tools/framework.py apply`, or `refresh` to keep an edit made there" + ) + return f"The checkout holds the {len(self.changes)} framework changes." + + def apply(self, discard: bool) -> str: + """Writes each changed file as its pinned content with its patch applied, and copies the added files. + + A file that already holds that content is left alone, so a build does not see it as changed. + + Args: + discard: Whether to overwrite files of the checkout that hold an edit framework/ does not have. + + Raises: + FrameworkError: The checkout holds such an edit and `discard` is off, or a patch does not apply. + """ + self._refuse_strays() + edited = self._differing(pinned_too=True) + if edited and not discard: + raise FrameworkError( + f"the checkout holds edits that framework/ does not have, in {', '.join(edited)}: " + "run `python tools/framework.py refresh` to keep them, or `apply --discard` to drop them" + ) + described = {change: self._described(change) for change in self.changes} + for change, content in described.items(): + destination = self.checkout.path(change) + if not destination.parent.is_dir(): + raise FrameworkError(f"the framework has no folder for {change.path}") + if self.checkout.present(change) != content: + destination.write_bytes(content) + self.check() + return f"Applied and verified {len(self.changes)} framework changes." + + def refresh(self) -> str: + """Rewrites patches/ and files/ from the checkout. + + Raises: + FrameworkError: A listed file is missing from the checkout or is the same as the pinned one. + """ + for change in self.changes: + present, pinned = self.checkout.present(change), self.pinned[change] + if present is None: + raise FrameworkError(f"the checkout has no {change.path}") + content = present if pinned is None else make_patch(change.path, pinned, present) + if not content and pinned is not None: + raise FrameworkError(f"{change.path} is the pinned file: take it out of {self.project.manifest.name}") + record = self.project.record(change) + record.parent.mkdir(parents=True, exist_ok=True) + record.write_bytes(content) + self._refuse_strays() + return f"Saved {len(self.changes)} framework changes." + + +def main() -> int: + """Runs one command and returns the process's exit status.""" + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("command", choices=["apply", "refresh", "check"]) + parser.add_argument("--framework", type=Path, default=PROJECT_ROOT / "psxrecomp", help="the PSXRecomp checkout") + parser.add_argument("--project", type=Path, default=PROJECT_ROOT, help="the folder that holds framework/") + parser.add_argument("--discard", action="store_true", help="with apply: drop edits the checkout holds") + arguments = parser.parse_args() + try: + project = Project(arguments.project.resolve()) + framework = Framework(project, Checkout(arguments.framework.resolve(), project.pin())) + match arguments.command: + case "apply": + print(framework.apply(arguments.discard)) + case "refresh": + print(framework.refresh()) + case _: + print(framework.check()) + except (FrameworkError, OSError) as e: + print(f"framework.py: {e}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) 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", diff --git a/tools/patch_openbios_seeds.py b/tools/patch_openbios_seeds.py deleted file mode 100644 index b5ff2cb..0000000 --- a/tools/patch_openbios_seeds.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env python3 -"""Apply the project-local OpenBIOS framework fixes. - -The pinned PSXRecomp revision's ELF seed list marks osDbgPrintf's entry but -not the verified first data byte after it. Whole-function discovery can then -follow an unconditional branch into the adjacent data and omit osDbgPrintf. - -The same revision also emits a const-qualified object as a file-scope C -initializer. That is not a C constant expression and MSVC rejects the generated -OpenBIOS dispatch table. Emit the count as an enum constant instead. - -Both patches are deliberately small, checked, and idempotent so a fresh -framework checkout produces the same BIOS translation used by this project. -""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path - - -ADDRESS = "0xBFC095F8" -PATCH_SEED = { - "address": ADDRESS, - "label": "data_after_osDbgPrintf", - "rationale": ( - "verified exclusive end of osDbgPrintf; prevents unconditional-branch " - "fallthrough analysis from entering adjacent data" - ), -} -EMITTER_OLD = ( - ' out += fmt::format("static const uint32_t ' - '{}psx_bios_kernel_body_count = {}u;\\n\\n",\n' - ' g_sym_prefix, kb_count);' -) -EMITTER_NEW = ( - ' out += fmt::format("enum {{ ' - '{}psx_bios_kernel_body_count = {}u }};\\n\\n",\n' - ' g_sym_prefix, kb_count);' -) - - -def parse_args() -> argparse.Namespace: - project_root = Path(__file__).resolve().parents[1] - parser = argparse.ArgumentParser() - parser.add_argument( - "--framework", - type=Path, - default=project_root / "psxrecomp", - help="path to the pinned PSXRecomp checkout", - ) - return parser.parse_args() - - -def prepare_emitter_patch(framework: Path): - emitter_path = framework / "recompiler" / "src" / "full_function_emitter.cpp" - if not emitter_path.is_file(): - raise SystemExit(f"OpenBIOS emitter source not found: {emitter_path}") - - source = emitter_path.read_text(encoding="utf-8") - old_count = source.count(EMITTER_OLD) - new_count = source.count(EMITTER_NEW) - if old_count == 0 and new_count == 1: - return emitter_path, None - if old_count != 1 or new_count != 0: - raise SystemExit( - "Unexpected OpenBIOS kernel-body count emitter; refusing to patch" - ) - - return emitter_path, source.replace(EMITTER_OLD, EMITTER_NEW) - - -def main() -> int: - args = parse_args() - seed_path = ( - args.framework - / "recompiler" - / "seeds" - / "openbios_elf_seeds.json" - ) - if not seed_path.is_file(): - raise SystemExit(f"OpenBIOS seed file not found: {seed_path}") - - with seed_path.open("r", encoding="utf-8") as stream: - document = json.load(stream) - - seeds = document.get("seeds") - if document.get("schema") != "psxrecomp phase2 seeds" or not isinstance(seeds, list): - raise SystemExit(f"Unexpected OpenBIOS seed schema: {seed_path}") - if document.get("seed_count") != len(seeds): - raise SystemExit( - "OpenBIOS seed_count does not match the seed array; refusing to patch" - ) - - target = int(ADDRESS, 16) - matches = [ - seed for seed in seeds - if int(seed.get("address", "0"), 16) == target - ] - if matches: - if len(matches) != 1 or matches[0] != PATCH_SEED: - raise SystemExit(f"Conflicting OpenBIOS seed already exists at {ADDRESS}") - seed_update = False - else: - if len(seeds) != 646: - raise SystemExit( - f"Expected the pinned 646-seed baseline, found {len(seeds)}; " - "refusing to guess" - ) - dbg_printf = [ - seed for seed in seeds - if int(seed.get("address", "0"), 16) == 0xBFC091EC - ] - if len(dbg_printf) != 1 or dbg_printf[0].get("label") != "osDbgPrintf": - raise SystemExit("Pinned osDbgPrintf seed was not found at 0xBFC091EC") - - insert_at = next( - (index for index, seed in enumerate(seeds) - if int(seed["address"], 16) > int(ADDRESS, 16)), - len(seeds), - ) - seeds.insert(insert_at, PATCH_SEED) - document["seed_count"] = len(seeds) - seed_update = True - - # Validate both pinned inputs before writing either one. A layout mismatch - # must never leave a fresh checkout with only half of this patch applied. - emitter_path, emitter_update = prepare_emitter_patch(args.framework) - - if seed_update: - with seed_path.open("w", encoding="utf-8", newline="\n") as stream: - json.dump(document, stream, indent=2) - stream.write("\n") - print(f"Applied OpenBIOS boundary seed: {ADDRESS}") - else: - print(f"OpenBIOS boundary seed already present: {ADDRESS}") - - if emitter_update is not None: - with emitter_path.open("w", encoding="utf-8", newline="\n") as stream: - stream.write(emitter_update) - print("Applied OpenBIOS constant-expression emitter fix") - else: - print("OpenBIOS constant-expression emitter fix already present") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main())