diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f37332d..069cad7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,6 +26,9 @@ jobs: python -m py_compile \ tools/patch_ndsrecomp_mph_runtime.py \ tools/patch_ndsrecomp_mph_adaptive_capability.py \ + tools/patch_ndsrecomp_mph_aspect_ratio_mod.py \ + tools/patch_ndsrecomp_nearest_presentation.py \ + tools/patch_mph_launcher_aspect_mod.py \ tools/patch_ndsrecomp_rom_free_release.py \ tools/patch_recomp_ui_mph_multirom.py \ tools/ci/check_rom_free_release_sources.py \ diff --git a/.github/workflows/mph-multirom-static.yml b/.github/workflows/mph-multirom-static.yml index a90348e..42b4d93 100644 --- a/.github/workflows/mph-multirom-static.yml +++ b/.github/workflows/mph-multirom-static.yml @@ -34,7 +34,11 @@ jobs: tools/patch_ndsrecomp_mph_runtime.py \ tools/patch_ndsrecomp_mph_runtime_core.py \ tools/patch_ndsrecomp_mph_widescreen.py \ + tools/patch_ndsrecomp_mph_adaptive_capability.py \ tools/patch_ndsrecomp_mph_widescreen_reset.py \ + tools/patch_ndsrecomp_mph_aspect_ratio_mod.py \ + tools/patch_ndsrecomp_nearest_presentation.py \ + tools/patch_mph_launcher_aspect_mod.py \ tools/promote_mph_static_coverage.py \ tools/promote_mph_runtime_coverage.py \ tools/probe_mph_wfc.py \ @@ -199,8 +203,11 @@ jobs: for file in /tmp/mph-launcher-us10/launcher_main_profile.cpp /tmp/mph-launcher-eu11/launcher_main_profile.cpp; do grep -q 'game.known_sha1_hex = nullptr;' "$file" grep -q 'game.num_known_sha1 = 0;' "$file" - grep -A1 'int mod_feature_count' "$file" | grep -q 'return 3;' + grep -A1 'int mod_feature_count' "$file" | grep -q 'return 4;' grep -q 'copy_text(output->id, "hd-rendering")' "$file" + grep -q 'copy_text(output->id, "game-aspect-ratio-patch")' "$file" + grep -q 'bool aspect_ratio_patch = false;' "$file" + grep -q -- '--mph-aspect-ratio-patch' "$file" grep -q -- '--firmware-state-path' "$file" grep -q 'bool adaptive_widescreen = true;' "$file" grep -q 'std::filesystem::is_regular_file(default_rom, initial_rom_error)' "$file" @@ -234,8 +241,19 @@ jobs: diff -u /tmp/first.sha256 /tmp/second.sha256 grep -q 'nds_title_patches_select_mph_runtime_profile' /tmp/ndsrecomp/runner/src/main.cpp grep -q 'nds_title_patches_set_mph_adaptive' /tmp/ndsrecomp/runner/src/main.cpp + grep -q -- '--mph-aspect-ratio-patch' /tmp/ndsrecomp/runner/src/main.cpp grep -q 'slirp_virtual_network_instance' /tmp/ndsrecomp/runner/src/main.cpp grep -q '0x02111B5Cu, 0x0211D208u, 0x02111380u' /tmp/ndsrecomp/runner/src/mph_widescreen_profiles.generated.h + grep -q 'SDL_SetHintWithPriority(SDL_HINT_RENDER_SCALE_QUALITY, "0"' /tmp/ndsrecomp/runner/src/frontend.cpp + test "$(grep -c 'SDL_SetTextureScaleMode' /tmp/ndsrecomp/runner/src/frontend.cpp)" -ge 2 + if grep -A3 'SDL_HINT_RENDER_SCALE_QUALITY' /tmp/ndsrecomp/runner/src/frontend.cpp | grep -q '"1"'; then + echo 'linear SDL framebuffer scaling unexpectedly remains enabled' >&2 + exit 1 + fi + grep -q 'GL_TEXTURE_MIN_FILTER, GL_NEAREST' /tmp/ndsrecomp/runner/src/melonds_compute/ComputeHost.cpp + grep -q 'GL_TEXTURE_MAG_FILTER, GL_NEAREST' /tmp/ndsrecomp/runner/src/melonds_compute/ComputeHost.cpp + grep -q 'GL_TEXTURE_MIN_FILTER, GL_NEAREST' /tmp/ndsrecomp/runner/vendor/melonds/GPU3D_Compute.cpp + grep -q 'GL_TEXTURE_MAG_FILTER, GL_NEAREST' /tmp/ndsrecomp/runner/vendor/melonds/GPU3D_Compute.cpp ! grep -q 'kMphUs10MorphState' /tmp/ndsrecomp/runner/src/frontend.cpp ! grep -q 'kMphUs10AimX' /tmp/ndsrecomp/runner/src/title_patches.cpp diff --git a/docs/WIDESCREEN_AB_TEST.md b/docs/WIDESCREEN_AB_TEST.md new file mode 100644 index 0000000..5ad3276 --- /dev/null +++ b/docs/WIDESCREEN_AB_TEST.md @@ -0,0 +1,29 @@ +# Widescreen modes + +The launcher exposes two alternative widescreen implementations: + +- **Adaptive Widescreen** — the original ndsrecomp host-side 448px renderer / compositor / HUD anchoring path. +- **Game Aspect Ratio Patch** — the MPH guest-side 21:9 projection/culling patch derived from melonPrimeDS and mphCodex. + +They are **mutually exclusive**. Turning either widescreen feature ON immediately turns the other one OFF. Both may be OFF for native 4:3 output, but both may not be ON at the same time. + +The game-side patch defaults **OFF**, so the original ndsrecomp Adaptive Widescreen implementation remains the default widescreen path. + +## Valid combinations + +| Adaptive Widescreen | Game Aspect Ratio Patch | Result | +|---|---|---| +| OFF | OFF | Native 4:3 / 256x192 | +| ON | OFF | Original ndsrecomp host widescreen only | +| OFF | ON | Guest projection/culling patch; native 256x192 top image is stretched to 448x192 only at final presentation | +| ON | ON | **Invalid state** — the launcher automatically switches one side OFF | + +When only the guest patch is enabled, the DS-native render surface remains 256x192. The final 256-to-448 stretch is required because the game-side patch produces projection geometry for a 21:9 target; displaying the resulting native surface unchanged as 4:3 would make it appear horizontally compressed. + +Legacy `mods.ini` files from the comparison build may contain both values as `true`. The loader resolves that state while parsing and the next save writes a valid mutually-exclusive pair. The final process-launch argument construction also refuses to pass both widescreen mechanisms to the runner even if an invalid state is somehow introduced later. + +The runner logs the selected guest policy. Guest-side code/data writes remain fail-closed and require an authoritative supported MPH executable checksum; header-only fallback never authorizes the aspect-ratio patch. + +## Validation + +The separate host-only / guest-only presentation paths and the nearest-only Supersampling presentation fix were visually tested with MPH before mutual exclusion was enabled. The current launcher policy then passed the full ROM-free Windows/Linux build and static regression suite at head `50271f43a4d0be9fe4c02a87c278bec80f4a4a47`. \ No newline at end of file diff --git a/launcher/recomp-ui/CMakeLists.txt b/launcher/recomp-ui/CMakeLists.txt index 3c3005e..03d6c91 100644 --- a/launcher/recomp-ui/CMakeLists.txt +++ b/launcher/recomp-ui/CMakeLists.txt @@ -72,10 +72,29 @@ set(MPH_PROFILE_LAUNCHER_SOURCE "${CMAKE_CURRENT_BINARY_DIR}/launcher_main_profile.cpp") file(WRITE "${MPH_PROFILE_LAUNCHER_SOURCE}" "${MPH_LAUNCHER_SOURCE}") -# Configure-time regression guard for the fresh-release UX. The generated -# launcher must never pass a missing conventional ROM path into recomp-ui as if -# the user had selected it. This deliberately tests the generated TU rather -# than the untransformed upstream-tracking source. +# MPH display policy layer. Keep the upstream-tracking launcher source simple, +# but expose the melonPrimeDS/mphCodex game-side projection/culling patch as a +# separate Mods item in the generated launcher. It defaults OFF; the existing +# Adaptive Widescreen feature remains the original host-side ndsrecomp path. +# The two features are radio-button-like: enabling either disables the other. +execute_process( + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/../../tools/patch_mph_launcher_aspect_mod.py" + --source "${MPH_PROFILE_LAUNCHER_SOURCE}" + RESULT_VARIABLE _mph_aspect_mod_result + OUTPUT_VARIABLE _mph_aspect_mod_stdout + ERROR_VARIABLE _mph_aspect_mod_stderr) +if(NOT _mph_aspect_mod_result EQUAL 0) + message(FATAL_ERROR + "Failed to add MPH launcher aspect-ratio mod:\n" + "${_mph_aspect_mod_stdout}${_mph_aspect_mod_stderr}") +endif() +message(STATUS "${_mph_aspect_mod_stdout}") +file(READ "${MPH_PROFILE_LAUNCHER_SOURCE}" MPH_LAUNCHER_SOURCE) + +# Configure-time regression guards for the generated launcher. The release must +# start without a fake ROM selection, and the two widescreen mechanisms must +# remain separately addressable while never being enabled together. string(FIND "${MPH_LAUNCHER_SOURCE}" "std::filesystem::is_regular_file(default_rom, initial_rom_error)" _mph_initial_rom_exists_guard) @@ -88,6 +107,48 @@ string(FIND "${MPH_LAUNCHER_SOURCE}" if(_mph_initial_rom_argument_guard EQUAL -1) message(FATAL_ERROR "generated launcher still passes the unconditional default ROM path") endif() +string(FIND "${MPH_LAUNCHER_SOURCE}" + "mph-game-aspect-ratio-patch" + _mph_aspect_mod_feature_guard) +if(_mph_aspect_mod_feature_guard EQUAL -1) + message(FATAL_ERROR "generated launcher lost the Game Aspect Ratio Patch Mods feature") +endif() +string(FIND "${MPH_LAUNCHER_SOURCE}" + "--mph-aspect-ratio-patch" + _mph_aspect_mod_arg_guard) +if(_mph_aspect_mod_arg_guard EQUAL -1) + message(FATAL_ERROR "generated launcher does not pass the guest aspect-ratio policy to nds_runner") +endif() +string(FIND "${MPH_LAUNCHER_SOURCE}" + "bool aspect_ratio_patch = false;" + _mph_aspect_mod_default_guard) +if(_mph_aspect_mod_default_guard EQUAL -1) + message(FATAL_ERROR "Game Aspect Ratio Patch must remain opt-in by default") +endif() +string(FIND "${MPH_LAUNCHER_SOURCE}" + "MPH_WIDESCREEN_MUTUAL_EXCLUSION" + _mph_widescreen_exclusion_guard) +if(_mph_widescreen_exclusion_guard EQUAL -1) + message(FATAL_ERROR "generated launcher lost widescreen mutual-exclusion logic") +endif() +string(FIND "${MPH_LAUNCHER_SOURCE}" + "if (state->adaptive_widescreen) state->aspect_ratio_patch = false;" + _mph_adaptive_disables_guest_guard) +if(_mph_adaptive_disables_guest_guard EQUAL -1) + message(FATAL_ERROR "Adaptive Widescreen no longer disables Game Aspect Ratio Patch") +endif() +string(FIND "${MPH_LAUNCHER_SOURCE}" + "if (state->aspect_ratio_patch) state->adaptive_widescreen = false;" + _mph_guest_disables_adaptive_guard) +if(_mph_guest_disables_adaptive_guard EQUAL -1) + message(FATAL_ERROR "Game Aspect Ratio Patch no longer disables Adaptive Widescreen") +endif() +string(FIND "${MPH_LAUNCHER_SOURCE}" + "((adaptive && !mods.aspect_ratio_patch) ? L\"top\" : L\"none\")" + _mph_runner_exclusion_guard) +if(_mph_runner_exclusion_guard EQUAL -1) + message(FATAL_ERROR "runner launch path can pass both widescreen implementations") +endif() add_executable(mph-recomp-ui "${MPH_PROFILE_LAUNCHER_SOURCE}" "${NDSRECOMP_ROOT}/recompiler/support/sha1.cpp") @@ -128,7 +189,9 @@ endif() enable_testing() add_executable(mph-mod-provider-test tests/launcher_mod_provider_test.cpp "${NDSRECOMP_ROOT}/recompiler/support/sha1.cpp") -# The test #includes launcher_main.cpp whole, so it needs ndsrecomp's sha1.h. +# The baseline provider test still covers the upstream-tracking source. The +# generated launcher itself is compiled above and guarded for the additional +# MPH aspect-ratio feature at configure time. target_include_directories(mph-mod-provider-test PRIVATE "${NDSRECOMP_ROOT}/recompiler/support" "${CMAKE_CURRENT_SOURCE_DIR}" @@ -143,4 +206,4 @@ include("${RECOMP_UI_ROOT}/recomp_ui.cmake") recomp_target_launcher_ui(mph-recomp-ui CONSOLE nds BOXART "${CMAKE_CURRENT_SOURCE_DIR}/assets/boxart.tga") -add_test(NAME mph_mod_provider_test COMMAND mph-mod-provider-test) +add_test(NAME mph_mod_provider_test COMMAND mph-mod-provider-test) \ No newline at end of file diff --git a/tools/patch_mph_launcher_aspect_mod.py b/tools/patch_mph_launcher_aspect_mod.py new file mode 100644 index 0000000..654d371 --- /dev/null +++ b/tools/patch_mph_launcher_aspect_mod.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Add the MPH game-side aspect-ratio feature to the launcher TU. + +The repository launcher source intentionally tracks upstream closely. The MPH +profile CMake already generates a launcher_main_profile.cpp and layers project- +specific multi-ROM transforms onto it. This script adds one more project layer: + +* Adaptive Widescreen remains the original ndsrecomp host-side 448px renderer + and HUD anchoring feature. +* Game Aspect Ratio Patch controls the melonPrimeDS/mphCodex guest-side 21:9 + projection/culling writes via --mph-aspect-ratio-patch. +* The two widescreen mechanisms are mutually exclusive. Enabling either one + immediately disables the other, so the launcher can never intentionally + start the runner with both transforms active at the same time. + +The guest patch defaults OFF so existing users receive only the original host +Adaptive Widescreen path unless they explicitly select the game-side patch. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +MARKER = "MPH_GAME_ASPECT_RATIO_MOD" + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + if old not in text: + raise SystemExit( + f"Refusing launcher aspect-mod patch: expected preimage for {label} " + "was not found" + ) + return text.replace(old, new, 1) + + +def patch(source: Path) -> None: + text = source.read_text(encoding="utf-8") + if MARKER in text: + print("MPH launcher game aspect-ratio mod already applied") + return + + text = replace_once( + text, + "struct ModState {\n bool adaptive_widescreen = true;\n", + "struct ModState {\n" + " bool adaptive_widescreen = true;\n" + " // MPH_GAME_ASPECT_RATIO_MOD: game-side 21:9 patch.\n" + " // Mutually exclusive with Adaptive Widescreen; OFF by default so\n" + " // the original ndsrecomp host path remains the baseline.\n" + " bool aspect_ratio_patch = false;\n", + "ModState aspect flag", + ) + + # Legacy builds temporarily allowed both widescreen paths to be persisted. + # Resolve such files deterministically while parsing: whichever key appears + # later and is true wins. Newly saved files can never contain both true. + text = replace_once( + text, + " } else if (key == \"adaptive_widescreen\") {\n" + " state.adaptive_widescreen = value != \"false\";\n" + " } else if (key == \"hd_rendering\") {\n", + " } else if (key == \"adaptive_widescreen\") {\n" + " state.adaptive_widescreen = value != \"false\";\n" + " if (state.adaptive_widescreen)\n" + " state.aspect_ratio_patch = false;\n" + " } else if (key == \"aspect_ratio_patch\") {\n" + " state.aspect_ratio_patch = value == \"true\";\n" + " if (state.aspect_ratio_patch)\n" + " state.adaptive_widescreen = false;\n" + " } else if (key == \"hd_rendering\") {\n", + "settings load", + ) + + text = replace_once( + text, + " file << \"settings_version=3\\n\"\n" + " << \"adaptive_widescreen=\"\n" + " << (state.adaptive_widescreen ? \"true\" : \"false\") << '\\n'\n" + " << \"hd_rendering=\"\n", + " file << \"settings_version=4\\n\"\n" + " << \"adaptive_widescreen=\"\n" + " << (state.adaptive_widescreen ? \"true\" : \"false\") << '\\n'\n" + " << \"aspect_ratio_patch=\"\n" + " << (state.aspect_ratio_patch ? \"true\" : \"false\") << '\\n'\n" + " << \"hd_rendering=\"\n", + "settings save", + ) + + text = replace_once( + text, + "// The online identity is NOT a mod: it lives on the dashboard's ONLINE\n" + "// card (GameInfo.has_player_name + the NDS profile's \"identity\" panel),\n" + "// directly under the controller card. Only the two real gameplay mods\n" + "// remain here.\n" + "int mod_feature_count(void*) {\n" + " return 3;\n" + "}\n", + "// The online identity is NOT a mod: it lives on the dashboard's ONLINE\n" + "// card. The two widescreen implementations are exposed separately,\n" + "// but their enable state is mutually exclusive.\n" + "int mod_feature_count(void*) {\n" + " return 4;\n" + "}\n", + "feature count", + ) + + text = replace_once( + text, + " if (!context || !output || index < 0 || index > 2) return 0;\n", + " if (!context || !output || index < 0 || index > 3) return 0;\n", + "feature index range", + ) + + # Tell users about the radio-button-like behavior directly in the host + # feature description too, not just on the new game-side feature. + text = replace_once( + text, + " \"Expands the upper gameplay screen to 21:9 and anchors its HUD \"\n" + " \"while keeping the lower touchscreen native and clickable.\");\n", + " \"Expands the upper gameplay screen to 21:9 and anchors its HUD \"\n" + " \"while keeping the lower touchscreen native and clickable. \"\n" + " \"Enabling it automatically disables Game Aspect Ratio Patch.\");\n", + "adaptive feature description", + ) + + aspect_feature = ''' } else if (index == 3) { + copy_text(output->id, "game-aspect-ratio-patch"); + copy_text(output->package_id, "mph-game-aspect-ratio-patch"); + copy_text(output->package_version, "0.1.0"); + copy_text(output->package_name, "MPH Game Aspect Ratio Patch"); + copy_text(output->name, "Game Aspect Ratio Patch"); + copy_text(output->author, "melonPrimeDS / mphCodex integration"); + copy_text( + output->description, + "Applies the MPH game-side 21:9 projection and culling patch. " + "It is mutually exclusive with Recomp's host Adaptive Widescreen; " + "enabling this automatically disables Adaptive Widescreen."); + copy_text(output->source_name, "ag-advania/melonPrimeDS"); + copy_text(output->source_url, + "https://github.com/ag-advania/melonPrimeDS"); + copy_text(output->group, "Display enhancements"); + copy_text(output->status, + state->aspect_ratio_patch ? "Enabled" : "Disabled"); + output->enabled = state->aspect_ratio_patch ? 1 : 0; +''' + text = replace_once( + text, + " output->option_count = 2;\n } else {\n" + " copy_text(output->id, \"prime-controls\");\n", + " output->option_count = 2;\n" + aspect_feature + + " } else {\n" + " copy_text(output->id, \"prime-controls\");\n", + "aspect feature metadata", + ) + + text = replace_once( + text, + " if (std::strcmp(package_id, \"mph-adaptive-widescreen\") == 0 &&\n" + " std::strcmp(feature_id, \"adaptive-widescreen\") == 0) {\n" + " state->adaptive_widescreen = enabled != 0;\n" + " return 1;\n" + " }\n" + " if (std::strcmp(package_id, \"mph-prime-controls\") == 0 &&\n", + " if (std::strcmp(package_id, \"mph-adaptive-widescreen\") == 0 &&\n" + " std::strcmp(feature_id, \"adaptive-widescreen\") == 0) {\n" + " state->adaptive_widescreen = enabled != 0;\n" + " // MPH_WIDESCREEN_MUTUAL_EXCLUSION: enabling one widescreen\n" + " // implementation switches the other one off immediately.\n" + " if (state->adaptive_widescreen) state->aspect_ratio_patch = false;\n" + " return 1;\n" + " }\n" + " if (std::strcmp(package_id, \"mph-game-aspect-ratio-patch\") == 0 &&\n" + " std::strcmp(feature_id, \"game-aspect-ratio-patch\") == 0) {\n" + " state->aspect_ratio_patch = enabled != 0;\n" + " if (state->aspect_ratio_patch) state->adaptive_widescreen = false;\n" + " return 1;\n" + " }\n" + " if (std::strcmp(package_id, \"mph-prime-controls\") == 0 &&\n", + "aspect feature enable", + ) + + # Keep a final defense at process launch. Even if a hand-edited settings + # file or a future frontend bug somehow presents both states as true, the + # runner receives only the guest path in that impossible state, never both. + text = replace_once( + text, + " (adaptive || mods.prime_controls || display_layout == 1\n" + " ? L\"separate\"\n" + " : L\"stacked\") +\n" + " L\" --adaptive-widescreen \" +\n" + " (adaptive ? L\"top\" : L\"none\") +\n", + " (adaptive || mods.aspect_ratio_patch || mods.prime_controls ||\n" + " display_layout == 1\n" + " ? L\"separate\"\n" + " : L\"stacked\") +\n" + " L\" --adaptive-widescreen \" +\n" + " ((adaptive && !mods.aspect_ratio_patch) ? L\"top\" : L\"none\") +\n" + " L\" --mph-aspect-ratio-patch \" +\n" + " (mods.aspect_ratio_patch ? L\"on\" : L\"off\") +\n", + "runner launch arguments", + ) + + source.write_text(text, encoding="utf-8") + print( + "Patched launcher Mods: Adaptive Widescreen (host) and Game Aspect " + "Ratio Patch (guest) are mutually exclusive; guest default=off" + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--source", type=Path, required=True) + args = parser.parse_args() + patch(args.source.resolve()) + + +if __name__ == "__main__": + main() diff --git a/tools/patch_ndsrecomp_mph_aspect_ratio_mod.py b/tools/patch_ndsrecomp_mph_aspect_ratio_mod.py new file mode 100644 index 0000000..5be17c0 --- /dev/null +++ b/tools/patch_ndsrecomp_mph_aspect_ratio_mod.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +"""Expose the MPH guest-side 21:9 patch independently from host adaptive output. + +The normal ndsrecomp Adaptive Widescreen path and the melonPrimeDS/mphCodex +MPH projection/culling patch solve different parts of widescreen rendering. +This layer deliberately decouples them: + +* --adaptive-widescreen controls ndsrecomp host-side wide rendering/HUD logic. +* --mph-aspect-ratio-patch controls the MPH guest projection/culling patch. +* when only the guest patch is enabled, the DS-native 256x192 top image is + stretched to 448x192 at final presentation so the guest's 21:9 projection is + displayed at the aspect ratio it targets without enabling host wide render. +* when both are enabled, no extra stretch is added; the host renderer already + produces a 448-wide source. This intentionally permits A/B testing of the + suspected double-application path. + +The guest patch remains fail-closed: only an authoritative executable checksum +that permits MPH host writes can activate it. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +CLI_VAR_MARKER = "MPH_ASPECT_RATIO_MOD_CLI_VAR" +CLI_PARSE_MARKER = "MPH_ASPECT_RATIO_MOD_CLI_PARSE" +CLI_USAGE_MARKER = "MPH_ASPECT_RATIO_MOD_CLI_USAGE" +CLI_VALIDATE_MARKER = "MPH_ASPECT_RATIO_MOD_CLI_VALIDATE" +MAIN_GATE_MARKER = "MPH_ASPECT_RATIO_MOD_GATE" +GETTER_MARKER = "MPH_ASPECT_RATIO_MOD_STATE_GETTER" +FRONTEND_MARKER = "MPH_ASPECT_RATIO_MOD_PRESENTATION" + + +def patch_once(path: Path, old: str, new: str, marker: str) -> None: + text = path.read_text(encoding="utf-8") + if marker in text: + return + if old not in text: + raise SystemExit( + f"Refusing MPH aspect-ratio mod patch for {path}: expected pinned " + f"preimage for {marker!r} was not found" + ) + path.write_text(text.replace(old, new, 1), encoding="utf-8") + + +def patch(framework_root: Path) -> None: + src = framework_root / "runner" / "src" + main_cpp = src / "main.cpp" + title_h = src / "title_patches.h" + title_cpp = src / "title_patches.cpp" + frontend_cpp = src / "frontend.cpp" + for path in (main_cpp, title_h, title_cpp, frontend_cpp): + if not path.is_file(): + raise SystemExit(f"runner source missing: {path}") + + # Expose the latched guest-patch state to frontend.cpp. g_mph_adaptive is + # the existing widescreen patcher's state variable; this layer only + # changes what policy feeds it, not its guarded code/data writes. + patch_once( + title_h, + "void nds_title_patches_set_mph_adaptive(bool enabled);\n", + "void nds_title_patches_set_mph_adaptive(bool enabled);\n" + "// MPH_ASPECT_RATIO_MOD_STATE_GETTER: guest projection/culling state.\n" + "bool nds_title_patches_mph_aspect_ratio_enabled();\n", + GETTER_MARKER, + ) + patch_once( + title_cpp, + "void nds_title_patches_set_mph_adaptive(bool enabled) {\n" + " g_mph_adaptive = enabled && g_mph_runtime_profile &&\n" + " g_mph_host_writes_compatible;\n" + " if (!g_mph_adaptive) g_mph_aspect_ratio_applied = false;\n" + "}\n\n" + "bool nds_title_patches_mph_host_writes_compatible() {\n", + "void nds_title_patches_set_mph_adaptive(bool enabled) {\n" + " g_mph_adaptive = enabled && g_mph_runtime_profile &&\n" + " g_mph_host_writes_compatible;\n" + " if (!g_mph_adaptive) g_mph_aspect_ratio_applied = false;\n" + "}\n\n" + "// MPH_ASPECT_RATIO_MOD_STATE_GETTER: guest projection/culling state.\n" + "bool nds_title_patches_mph_aspect_ratio_enabled() {\n" + " return g_mph_adaptive;\n" + "}\n\n" + "bool nds_title_patches_mph_host_writes_compatible() {\n", + GETTER_MARKER, + ) + + # Independent CLI policy. Default OFF is intentional: existing users keep + # the original ndsrecomp host Adaptive Widescreen behavior and no longer + # receive a second game-side aspect transform unless they opt into the mod. + patch_once( + main_cpp, + " std::string cli_adaptive_screens;\n" + " std::string cli_supersampling;\n", + " std::string cli_adaptive_screens;\n" + " // MPH_ASPECT_RATIO_MOD_CLI_VAR\n" + " std::string cli_mph_aspect_ratio_patch;\n" + " std::string cli_supersampling;\n", + CLI_VAR_MARKER, + ) + patch_once( + main_cpp, + " } else if (a == \"--adaptive-widescreen\" && i + 1 < argc) {\n" + " cli_adaptive_screens = argv[++i];\n" + " } else if (a == \"--supersampling\" && i + 1 < argc) {\n", + " } else if (a == \"--adaptive-widescreen\" && i + 1 < argc) {\n" + " cli_adaptive_screens = argv[++i];\n" + " // MPH_ASPECT_RATIO_MOD_CLI_PARSE\n" + " } else if (a == \"--mph-aspect-ratio-patch\" && i + 1 < argc) {\n" + " cli_mph_aspect_ratio_patch = argv[++i];\n" + " } else if (a == \"--supersampling\" && i + 1 < argc) {\n", + CLI_PARSE_MARKER, + ) + patch_once( + main_cpp, + " \"[--adaptive-widescreen none|top|bottom|both] \"\n" + " \"[--supersampling 1|2|3|4] \"\n", + " \"[--adaptive-widescreen none|top|bottom|both] \"\n" + " // MPH_ASPECT_RATIO_MOD_CLI_USAGE\n" + " \"[--mph-aspect-ratio-patch on|off] \"\n" + " \"[--supersampling 1|2|3|4] \"\n", + CLI_USAGE_MARKER, + ) + patch_once( + main_cpp, + " if (!cli_supersampling.empty() &&\n", + " // MPH_ASPECT_RATIO_MOD_CLI_VALIDATE\n" + " bool mph_aspect_ratio_patch = false;\n" + " if (!cli_mph_aspect_ratio_patch.empty()) {\n" + " if (cli_mph_aspect_ratio_patch == \"on\")\n" + " mph_aspect_ratio_patch = true;\n" + " else if (cli_mph_aspect_ratio_patch != \"off\") {\n" + " std::fprintf(stderr,\n" + " \"invalid --mph-aspect-ratio-patch \"\n" + " \"(expected on or off)\\n\");\n" + " return 2;\n" + " }\n" + " }\n" + " if (!cli_supersampling.empty() &&\n", + CLI_VALIDATE_MARKER, + ) + + patch_once( + main_cpp, + " nds_title_patches_set_mph_adaptive(\n" + " nds_title_patches_mph_host_writes_compatible() &&\n" + " (frontend_options.adaptive_screens & NDS_ADAPTIVE_TOP) != 0u);\n" + " nds_title_patches_set_mph_mouse_aim(mph_mouse_aim_policy);\n", + " // MPH_ASPECT_RATIO_MOD_GATE: guest aspect patch is independent of\n" + " // ndsrecomp host Adaptive Widescreen. Unknown/header-only variants\n" + " // still fail closed because host writes are not authorized.\n" + " const bool mph_aspect_ratio_patch_policy =\n" + " mph_aspect_ratio_patch &&\n" + " nds_title_patches_mph_host_writes_compatible();\n" + " nds_title_patches_set_mph_adaptive(mph_aspect_ratio_patch_policy);\n" + " if (mph_aspect_ratio_patch && !mph_aspect_ratio_patch_policy)\n" + " std::fprintf(stderr,\n" + " \"[mph] game aspect-ratio patch disabled: \"\n" + " \"unknown executable checksum\\n\");\n" + " if (mph_aspect_ratio_patch_policy)\n" + " std::fprintf(stderr,\n" + " \"[mph] game aspect-ratio patch requested; \"\n" + " \"host adaptive=%s\\n\",\n" + " (frontend_options.adaptive_screens & NDS_ADAPTIVE_TOP)\n" + " ? \"on\" : \"off\");\n" + " nds_title_patches_set_mph_mouse_aim(mph_mouse_aim_policy);\n", + MAIN_GATE_MARKER, + ) + + # Separate the emulated source width from its final presentation width. + # Host Adaptive ON already makes the source 448px. Guest-only mode keeps + # source render/composition at 256px and stretches only the finished image. + patch_once( + frontend_cpp, + " int screen_widths[2]{kScreenWidth, kScreenWidth};\n" + " int canvas_width = kScreenWidth;\n" + " int sample_scale = 1;\n", + " // MPH_ASPECT_RATIO_MOD_PRESENTATION\n" + " int source_widths[2]{kScreenWidth, kScreenWidth};\n" + " int screen_widths[2]{kScreenWidth, kScreenWidth};\n" + " int canvas_width = kScreenWidth;\n" + " int sample_scale = 1;\n", + FRONTEND_MARKER, + ) + patch_once( + frontend_cpp, + " }\n" + " presentation.canvas_width = std::max(\n" + " presentation.screen_widths[0],\n" + " presentation.screen_widths[1]);\n" + " const int first_height = presentation.separate\n", + " }\n" + " presentation.source_widths[0] = presentation.screen_widths[0];\n" + " presentation.source_widths[1] = presentation.screen_widths[1];\n" + " if (nds_title_patches_mph_aspect_ratio_enabled() &&\n" + " presentation.screen_widths[0] == kScreenWidth) {\n" + " // Guest-only mode: preserve native DS rendering but present\n" + " // the completed image at the 21:9 width targeted by the patch.\n" + " presentation.screen_widths[0] = 448;\n" + " std::fprintf(stderr,\n" + " \"[mph] guest-only aspect mode: native 256x192 \"\n" + " \"top -> 448x192 presentation stretch\\n\");\n" + " }\n" + " presentation.canvas_width = std::max(\n" + " presentation.screen_widths[0],\n" + " presentation.screen_widths[1]);\n" + " const int first_height = presentation.separate\n", + "MPH_ASPECT_RATIO_MOD_PRESENTATION_DESTINATION", + ) + patch_once( + frontend_cpp, + " presentation.screen_widths[screen], kScreenHeight);\n" + " if (!presentation.textures[screen]) {\n", + " presentation.source_widths[screen], kScreenHeight);\n" + " if (!presentation.textures[screen]) {\n", + "MPH_ASPECT_RATIO_MOD_TEXTURE_SOURCE_WIDTH", + ) + patch_once( + frontend_cpp, + " presentation.screen_widths[screen] *\n" + " presentation.sample_scale,\n" + " kScreenHeight * presentation.sample_scale);\n", + " presentation.source_widths[screen] *\n" + " presentation.sample_scale,\n" + " kScreenHeight * presentation.sample_scale);\n", + "MPH_ASPECT_RATIO_MOD_SAMPLE_SOURCE_WIDTH", + ) + patch_once( + frontend_cpp, + " const uint16_t output_width = static_cast(std::max(\n" + " presentation.screen_widths[0],\n" + " presentation.screen_widths[1]));\n", + " // Guest-only stretch must not silently reactivate a 448px GPU3D\n" + " // render surface; only host Adaptive Widescreen may do that.\n" + " const uint16_t output_width = static_cast(std::max(\n" + " presentation.source_widths[0],\n" + " presentation.source_widths[1]));\n", + "MPH_ASPECT_RATIO_MOD_GPU_SOURCE_WIDTH", + ) + + print( + "Patched independent MPH aspect-ratio mod: host Adaptive Widescreen and " + "guest projection/culling can be toggled separately" + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--framework-root", type=Path, required=True) + parser.add_argument("--profiles", type=Path, required=False) + args = parser.parse_args() + patch(args.framework_root.resolve()) + + +if __name__ == "__main__": + main() diff --git a/tools/patch_ndsrecomp_mph_runtime.py b/tools/patch_ndsrecomp_mph_runtime.py index d858d33..563a5b5 100755 --- a/tools/patch_ndsrecomp_mph_runtime.py +++ b/tools/patch_ndsrecomp_mph_runtime.py @@ -5,8 +5,11 @@ without weakening its whole-ROM/content identity rules. The later stages add the melonPrimeDS/mphCodex profile-aware 21:9 projection/culling patch, grant adaptive TOP capability only after authoritative MPH executable detection, make -that patch re-eligible after an in-process guest reset, and finally add -end-user startup diagnostics plus the ROM-free multi-ROM content-gate policy. +that patch re-eligible after an in-process guest reset, add end-user startup +diagnostics plus the ROM-free multi-ROM content-gate policy, expose the +game-side aspect-ratio patch independently from ndsrecomp's host Adaptive +Widescreen renderer, and finally force native framebuffer presentation to use +nearest-neighbor sampling so supersampling/AA cannot blur DS pixels. """ from __future__ import annotations @@ -16,16 +19,38 @@ from pathlib import Path +def _framework_root(args: list[str]) -> Path | None: + for index, arg in enumerate(args[:-1]): + if arg == "--framework-root": + return Path(args[index + 1]).resolve() + return None + + def main() -> None: here = Path(__file__).resolve().parent args = sys.argv[1:] + framework_root = _framework_root(args) for script in ( here / "patch_ndsrecomp_mph_runtime_core.py", here / "patch_ndsrecomp_mph_widescreen.py", here / "patch_ndsrecomp_mph_adaptive_capability.py", here / "patch_ndsrecomp_mph_widescreen_reset.py", here / "patch_ndsrecomp_mph_diagnostics.py", + here / "patch_ndsrecomp_mph_aspect_ratio_mod.py", + here / "patch_ndsrecomp_nearest_presentation.py", ): + # The aspect layer consists of several coordinated edits across main, + # title_patches and frontend. Its primary marker in main is enough to + # establish that the complete layer was applied by a prior successful + # stack invocation. Skip the whole layer on rerun rather than trying to + # match already-transformed frontend width expressions piecemeal. + if (script.name == "patch_ndsrecomp_mph_aspect_ratio_mod.py" and + framework_root is not None): + main_cpp = framework_root / "runner" / "src" / "main.cpp" + if main_cpp.is_file() and "MPH_ASPECT_RATIO_MOD_CLI_VAR" in \ + main_cpp.read_text(encoding="utf-8"): + print("MPH independent aspect-ratio mod already applied") + continue subprocess.run([sys.executable, str(script), *args], check=True) diff --git a/tools/patch_ndsrecomp_nearest_presentation.py b/tools/patch_ndsrecomp_nearest_presentation.py new file mode 100644 index 0000000..f388c35 --- /dev/null +++ b/tools/patch_ndsrecomp_nearest_presentation.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Force DS framebuffer presentation to use nearest-neighbor sampling. + +The pinned ndsrecomp frontend historically changed SDL_HINT_RENDER_SCALE_QUALITY +to linear whenever presentation supersampling or AA was selected. That makes a +native 256x192 DS framebuffer blurry while scaling. It became especially +visible after the HD direct presenter landed: the top screen can bypass SDL and +stay crisp via OpenGL texelFetch/NEAREST while the bottom screen still passes +through SDL's linear RenderCopy path. + +Presentation scaling is pixel-art/framebuffer scaling, not texture enhancement. +Keep it nearest regardless of supersampling/AA settings. Texture upscaling is a +separate, explicit HD Rendering option and is not changed here. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +HINT_MARKER = "NDS_MPH_NEAREST_PRESENT_HINT" +TEXTURE_MARKER = "NDS_MPH_NEAREST_PRESENT_TEXTURE" +TARGET_MARKER = "NDS_MPH_NEAREST_PRESENT_TARGET" + + +def patch_once(path: Path, old: str, new: str, marker: str) -> None: + text = path.read_text(encoding="utf-8") + if marker in text: + return + if old not in text: + raise SystemExit( + f"Refusing nearest-presentation patch for {path}: expected pinned " + f"preimage for {marker!r} was not found" + ) + path.write_text(text.replace(old, new, 1), encoding="utf-8") + + +def patch(framework_root: Path) -> None: + frontend = framework_root / "runner" / "src" / "frontend.cpp" + if not frontend.is_file(): + raise SystemExit(f"runner source missing: {frontend}") + + # Never let supersampling/AA silently select SDL bilinear filtering. Use + # OVERRIDE so an inherited/environment hint cannot re-enable smoothing. + patch_once( + frontend, + ''' SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY,\n (options.supersampling > 1 || options.antialiasing > 0)\n ? "1" : "0");\n''', + ''' // NDS_MPH_NEAREST_PRESENT_HINT: DS framebuffer presentation is\n // pixel-exact. Supersampling/AA must not silently turn SDL RenderCopy\n // into bilinear filtering (which especially blurs the native bottom\n // screen while an HD/OpenGL top screen remains crisp).\n if (SDL_SetHintWithPriority(SDL_HINT_RENDER_SCALE_QUALITY, "0",\n SDL_HINT_OVERRIDE) == SDL_FALSE) {\n std::fprintf(stderr,\n "[sdl] warning: could not force nearest render-scale hint\\n");\n }\n''', + HINT_MARKER, + ) + + # The global hint is only a default at texture creation time. Pin the + # actual source texture explicitly too, so later hint changes or backend + # defaults cannot alter presentation quality. + patch_once( + frontend, + ''' if (!presentation.textures[screen]) {\n std::fprintf(stderr, "[sdl] texture failed: %s\\n",\n SDL_GetError());\n destroy_presentation(presentation);\n return false;\n }\n if (presentation.sample_scale > 1) {\n''', + ''' if (!presentation.textures[screen]) {\n std::fprintf(stderr, "[sdl] texture failed: %s\\n",\n SDL_GetError());\n destroy_presentation(presentation);\n return false;\n }\n // NDS_MPH_NEAREST_PRESENT_TEXTURE: never smooth DS framebuffer pixels.\n if (SDL_SetTextureScaleMode(presentation.textures[screen],\n SDL_ScaleModeNearest) != 0) {\n std::fprintf(stderr,\n "[sdl] nearest texture scale mode failed: %s\\n",\n SDL_GetError());\n destroy_presentation(presentation);\n return false;\n }\n if (presentation.sample_scale > 1) {\n''', + TEXTURE_MARKER, + ) + + # A supersample target is subsequently used as the source of another + # RenderCopy. It needs an explicit nearest source mode as well, otherwise + # the second copy can still blur even if the native upload texture is crisp. + patch_once( + frontend, + ''' if (!presentation.sample_targets[screen]) {\n std::fprintf(stderr,\n "[sdl] supersample target failed: %s\\n",\n SDL_GetError());\n destroy_presentation(presentation);\n return false;\n }\n }\n''', + ''' if (!presentation.sample_targets[screen]) {\n std::fprintf(stderr,\n "[sdl] supersample target failed: %s\\n",\n SDL_GetError());\n destroy_presentation(presentation);\n return false;\n }\n // NDS_MPH_NEAREST_PRESENT_TARGET: the enlarged target is also a\n // later RenderCopy source, so pin that copy to nearest explicitly.\n if (SDL_SetTextureScaleMode(presentation.sample_targets[screen],\n SDL_ScaleModeNearest) != 0) {\n std::fprintf(stderr,\n "[sdl] nearest supersample scale mode failed: %s\\n",\n SDL_GetError());\n destroy_presentation(presentation);\n return false;\n }\n }\n''', + TARGET_MARKER, + ) + + print( + "Patched SDL framebuffer presentation: nearest-only scaling for native " + "textures and supersample targets" + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--framework-root", type=Path, required=True) + parser.add_argument("--profiles", type=Path, required=False) + args = parser.parse_args() + patch(args.framework_root.resolve()) + + +if __name__ == "__main__": + main()