From 9cd1a9a967b147b7d7e330ebfb8341b37775c8e0 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 19:14:21 +0900 Subject: [PATCH 01/26] Add guest-wide host-adaptive-off comparison patch --- ...patch_ndsrecomp_mph_guest_wide_host_off.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tools/patch_ndsrecomp_mph_guest_wide_host_off.py diff --git a/tools/patch_ndsrecomp_mph_guest_wide_host_off.py b/tools/patch_ndsrecomp_mph_guest_wide_host_off.py new file mode 100644 index 0000000..dfc31f6 --- /dev/null +++ b/tools/patch_ndsrecomp_mph_guest_wide_host_off.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Build-only experiment: keep MPH guest 21:9 patches but disable host adaptive output. + +This patch is intentionally for comparison builds only. It latches the MPH +projection/culling patch from the user's Adaptive Widescreen request, then +removes TOP from frontend_options.adaptive_screens and disables host HUD +anchoring before renderer presentation state is configured. + +Result: + guest projection/culling patch: ON for executable-compatible MPH + host 448px adaptive framebuffer: OFF + host adaptive HUD anchoring: OFF +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +MARKER = "MPH_TEST_GUEST_WIDE_HOST_ADAPTIVE_OFF" +OLD = ''' 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''' +NEW = ''' // MPH_TEST_GUEST_WIDE_HOST_ADAPTIVE_OFF: comparison build only.\n // Preserve the user's Adaptive Widescreen request long enough to enable\n // the validated guest projection/culling patch, then force host adaptive\n // presentation back to native 256x192 and disable HUD band anchoring.\n const bool mph_guest_widescreen_policy =\n nds_title_patches_mph_host_writes_compatible() &&\n (frontend_options.adaptive_screens & NDS_ADAPTIVE_TOP) != 0u;\n nds_title_patches_set_mph_adaptive(mph_guest_widescreen_policy);\n if (mph_guest_widescreen_policy) {\n frontend_options.adaptive_screens &= ~NDS_ADAPTIVE_TOP;\n frontend_options.adaptive_hud_anchor = false;\n std::fprintf(stderr,\n "[mph-test] guest 21:9 projection/culling ON; "\n "host 448px adaptive framebuffer/HUD anchoring OFF\\n");\n }\n nds_title_patches_set_mph_mouse_aim(mph_mouse_aim_policy);\n''' + +def patch(framework_root: Path) -> None: + main_cpp = framework_root / "runner" / "src" / "main.cpp" + if not main_cpp.is_file(): + raise SystemExit(f"runner source missing: {main_cpp}") + text = main_cpp.read_text(encoding="utf-8") + if MARKER in text: + print("MPH guest-wide/host-off comparison patch already applied") + return + if OLD not in text: + raise SystemExit( + "Refusing comparison patch: expected MPH adaptive call site was not found; " + "apply patch_ndsrecomp_mph_widescreen.py first" + ) + main_cpp.write_text(text.replace(OLD, NEW, 1), encoding="utf-8") + print("Patched comparison build: guest widescreen ON, host adaptive output OFF") + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--framework-root", type=Path, required=True) + args = parser.parse_args() + patch(args.framework_root.resolve()) + +if __name__ == "__main__": + main() From fb32ca3c8b1a0464d447deba82d8ffc2b31184d2 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 19:14:56 +0900 Subject: [PATCH 02/26] Allow comparison patch in shared runtime patch stack --- tools/patch_ndsrecomp_mph_guest_wide_host_off.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/patch_ndsrecomp_mph_guest_wide_host_off.py b/tools/patch_ndsrecomp_mph_guest_wide_host_off.py index dfc31f6..a7ace37 100644 --- a/tools/patch_ndsrecomp_mph_guest_wide_host_off.py +++ b/tools/patch_ndsrecomp_mph_guest_wide_host_off.py @@ -40,6 +40,9 @@ def patch(framework_root: Path) -> None: def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--framework-root", type=Path, required=True) + # Accepted so this experiment can be appended to the normal runtime patch + # stack, which forwards the shared --profiles argument to every layer. + parser.add_argument("--profiles", type=Path, required=False) args = parser.parse_args() patch(args.framework_root.resolve()) From 485f64fad1f8d67f0296c6917045b33f77b9dbbc Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 19:15:05 +0900 Subject: [PATCH 03/26] Build comparison runner with host adaptive output disabled --- tools/patch_ndsrecomp_mph_runtime.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/patch_ndsrecomp_mph_runtime.py b/tools/patch_ndsrecomp_mph_runtime.py index d858d33..0d09197 100755 --- a/tools/patch_ndsrecomp_mph_runtime.py +++ b/tools/patch_ndsrecomp_mph_runtime.py @@ -7,6 +7,10 @@ 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. + +This comparison branch appends one final experiment layer that keeps the guest +projection/culling patch enabled while forcing the host adaptive framebuffer and +HUD anchoring off. It is not intended for develop. """ from __future__ import annotations @@ -25,6 +29,7 @@ def main() -> None: here / "patch_ndsrecomp_mph_adaptive_capability.py", here / "patch_ndsrecomp_mph_widescreen_reset.py", here / "patch_ndsrecomp_mph_diagnostics.py", + here / "patch_ndsrecomp_mph_guest_wide_host_off.py", ): subprocess.run([sys.executable, str(script), *args], check=True) From e8fcc4f89a466ae15b27809b28c2983cc7e592dd Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 19:15:16 +0900 Subject: [PATCH 04/26] Document host-adaptive-off comparison build --- docs/HOST_ADAPTIVE_OFF_TEST.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 docs/HOST_ADAPTIVE_OFF_TEST.md diff --git a/docs/HOST_ADAPTIVE_OFF_TEST.md b/docs/HOST_ADAPTIVE_OFF_TEST.md new file mode 100644 index 0000000..411fd52 --- /dev/null +++ b/docs/HOST_ADAPTIVE_OFF_TEST.md @@ -0,0 +1,18 @@ +# Host Adaptive Off comparison build + +This branch is a temporary diagnostic build only. + +When the launcher requests Adaptive Widescreen for an executable-compatible MPH ROM: + +- guest-side MPH 21:9 projection/culling patch: **enabled** +- ndsrecomp host 448px adaptive framebuffer: **disabled** +- ndsrecomp adaptive HUD band anchoring/splitting: **disabled** +- host presentation falls back to the native 256x192 compositor path + +The runner prints this marker when the comparison path is active: + +```text +[mph-test] guest 21:9 projection/culling ON; host 448px adaptive framebuffer/HUD anchoring OFF +``` + +This experiment is intended to determine whether the current visual corruption is caused by combining the game-side melonPrimeDS/mphCodex aspect-ratio patch with ndsrecomp's host-side Adaptive Widescreen renderer. From b08e3f9e29465737ed97b00e42ce4d29f147083a Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 19:15:28 +0900 Subject: [PATCH 05/26] Mark comparison branch as test-only --- TEST_BUILD_MARKER.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 TEST_BUILD_MARKER.txt diff --git a/TEST_BUILD_MARKER.txt b/TEST_BUILD_MARKER.txt new file mode 100644 index 0000000..d8a9944 --- /dev/null +++ b/TEST_BUILD_MARKER.txt @@ -0,0 +1 @@ +TEST ONLY: guest MPH 21:9 projection/culling ON; host 448px adaptive framebuffer/HUD anchoring OFF. From aee1746bd0be9c47351085f5410e006ccb800a06 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 19:19:46 +0900 Subject: [PATCH 06/26] Stretch guest-wide native frame only at presentation --- ...patch_ndsrecomp_mph_guest_wide_host_off.py | 175 +++++++++++++++--- 1 file changed, 151 insertions(+), 24 deletions(-) diff --git a/tools/patch_ndsrecomp_mph_guest_wide_host_off.py b/tools/patch_ndsrecomp_mph_guest_wide_host_off.py index a7ace37..f302cf3 100644 --- a/tools/patch_ndsrecomp_mph_guest_wide_host_off.py +++ b/tools/patch_ndsrecomp_mph_guest_wide_host_off.py @@ -1,15 +1,22 @@ #!/usr/bin/env python3 -"""Build-only experiment: keep MPH guest 21:9 patches but disable host adaptive output. +"""Build-only experiment: guest MPH 21:9 patch + native-frame stretch. -This patch is intentionally for comparison builds only. It latches the MPH -projection/culling patch from the user's Adaptive Widescreen request, then -removes TOP from frontend_options.adaptive_screens and disables host HUD -anchoring before renderer presentation state is configured. +This is a diagnostic comparison build, not a develop candidate. -Result: - guest projection/culling patch: ON for executable-compatible MPH - host 448px adaptive framebuffer: OFF - host adaptive HUD anchoring: OFF +When Adaptive Widescreen is requested for an executable-compatible MPH ROM: + +* keep the validated guest-side projection/culling patch enabled; +* disable ndsrecomp's host adaptive 448px renderer/compositor path; +* disable adaptive HUD anchoring/splitting; +* keep the emulated top-screen render surface at native 256x192; and +* stretch that completed native top-screen image to 448x192 only at SDL + presentation time. + +That separation is important: a guest 21:9 projection patch rendered into the +DS-native 256x192 surface is expected to look horizontally compressed if it is +then displayed as 4:3. The final 256->448 stretch restores the intended 21:9 +shape without re-enabling the host-side 448px renderer we are trying to remove +from this A/B test. """ from __future__ import annotations @@ -17,25 +24,144 @@ import argparse from pathlib import Path -MARKER = "MPH_TEST_GUEST_WIDE_HOST_ADAPTIVE_OFF" -OLD = ''' 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''' -NEW = ''' // MPH_TEST_GUEST_WIDE_HOST_ADAPTIVE_OFF: comparison build only.\n // Preserve the user's Adaptive Widescreen request long enough to enable\n // the validated guest projection/culling patch, then force host adaptive\n // presentation back to native 256x192 and disable HUD band anchoring.\n const bool mph_guest_widescreen_policy =\n nds_title_patches_mph_host_writes_compatible() &&\n (frontend_options.adaptive_screens & NDS_ADAPTIVE_TOP) != 0u;\n nds_title_patches_set_mph_adaptive(mph_guest_widescreen_policy);\n if (mph_guest_widescreen_policy) {\n frontend_options.adaptive_screens &= ~NDS_ADAPTIVE_TOP;\n frontend_options.adaptive_hud_anchor = false;\n std::fprintf(stderr,\n "[mph-test] guest 21:9 projection/culling ON; "\n "host 448px adaptive framebuffer/HUD anchoring OFF\\n");\n }\n nds_title_patches_set_mph_mouse_aim(mph_mouse_aim_policy);\n''' +MAIN_MARKER = "MPH_TEST_GUEST_WIDE_HOST_ADAPTIVE_OFF" +GETTER_MARKER = "MPH_TEST_GUEST_WIDE_STATE_GETTER" +FRONTEND_MARKER = "MPH_TEST_NATIVE_WIDE_STRETCH_PRESENTATION" -def patch(framework_root: Path) -> None: - main_cpp = framework_root / "runner" / "src" / "main.cpp" - if not main_cpp.is_file(): - raise SystemExit(f"runner source missing: {main_cpp}") - text = main_cpp.read_text(encoding="utf-8") - if MARKER in text: - print("MPH guest-wide/host-off comparison patch already applied") +MAIN_OLD = ''' 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''' +MAIN_NEW = ''' // MPH_TEST_GUEST_WIDE_HOST_ADAPTIVE_OFF: comparison build only.\n // Latch the user's Adaptive Widescreen request into the guest-side MPH\n // projection/culling patch, then remove TOP from the host adaptive path.\n // frontend.cpp still presents the completed native 256x192 image at\n // 448x192, so the guest 21:9 projection is not left horizontally squashed.\n const bool mph_guest_widescreen_policy =\n nds_title_patches_mph_host_writes_compatible() &&\n (frontend_options.adaptive_screens & NDS_ADAPTIVE_TOP) != 0u;\n nds_title_patches_set_mph_adaptive(mph_guest_widescreen_policy);\n if (mph_guest_widescreen_policy) {\n frontend_options.adaptive_screens &= ~NDS_ADAPTIVE_TOP;\n frontend_options.adaptive_hud_anchor = false;\n std::fprintf(stderr,\n "[mph-test] guest 21:9 projection/culling ON; "\n "host adaptive renderer/HUD OFF; "\n "native 256x192 -> 448x192 present stretch ON\\n");\n }\n nds_title_patches_set_mph_mouse_aim(mph_mouse_aim_policy);\n''' + + +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: + if old not in text: raise SystemExit( - "Refusing comparison patch: expected MPH adaptive call site was not found; " - "apply patch_ndsrecomp_mph_widescreen.py first" + f"Refusing comparison patch for {path}: expected pinned preimage " + f"for {marker!r} was not found" ) - main_cpp.write_text(text.replace(OLD, NEW, 1), encoding="utf-8") - print("Patched comparison build: guest widescreen ON, host adaptive output OFF") + 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 already-latched guest widescreen state to frontend.cpp. The + # normal host adaptive flag is deliberately cleared in main.cpp below, so + # frontend.cpp cannot infer this comparison mode from adaptive_screens. + 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_TEST_GUEST_WIDE_STATE_GETTER: comparison build only.\n" + "bool nds_title_patches_mph_adaptive_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_TEST_GUEST_WIDE_STATE_GETTER: comparison build only.\n" + "bool nds_title_patches_mph_adaptive_enabled() {\n" + " return g_mph_adaptive;\n" + "}\n\n" + "bool nds_title_patches_mph_host_writes_compatible() {\n", + GETTER_MARKER, + ) + + patch_once(main_cpp, MAIN_OLD, MAIN_NEW, MAIN_MARKER) + + # Split source/render width from presentation width. Normal builds use the + # same value for both. In this experiment only the top destination becomes + # 448px; its texture, GPU3D output, GPU2D composition and upload pitch all + # remain native 256px. + patch_once( + frontend_cpp, + " int screen_widths[2]{kScreenWidth, kScreenWidth};\n" + " int canvas_width = kScreenWidth;\n" + " int sample_scale = 1;\n", + " // MPH_TEST_NATIVE_WIDE_STRETCH_PRESENTATION: comparison build only.\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_adaptive_enabled()) {\n" + " // MPH_TEST_NATIVE_WIDE_STRETCH_DESTINATION: render/composite at\n" + " // native DS width, stretch only the completed top image.\n" + " presentation.source_widths[0] = kScreenWidth;\n" + " presentation.screen_widths[0] = 448;\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_TEST_NATIVE_WIDE_STRETCH_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", + "presentation.source_widths[screen], kScreenHeight", + ) + 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", + "presentation.source_widths[screen] *", + ) + patch_once( + frontend_cpp, + " const uint16_t output_width = static_cast(std::max(\n" + " presentation.screen_widths[0],\n" + " presentation.screen_widths[1]));\n", + " // MPH_TEST_NATIVE_WIDE_STRETCH_GPU_WIDTH: do not let the 448px\n" + " // presentation destination turn the host GPU3D renderer wide again.\n" + " const uint16_t output_width = static_cast(std::max(\n" + " presentation.source_widths[0],\n" + " presentation.source_widths[1]));\n", + "MPH_TEST_NATIVE_WIDE_STRETCH_GPU_WIDTH", + ) + + print( + "Patched comparison build: guest 21:9 ON; host adaptive render/HUD OFF; " + "native 256x192 top stretched to 448x192 at presentation" + ) + def main() -> None: parser = argparse.ArgumentParser() @@ -46,5 +172,6 @@ def main() -> None: args = parser.parse_args() patch(args.framework_root.resolve()) + if __name__ == "__main__": main() From 107a1ff979c4c609e3e887608082db365d9908d4 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 19:19:59 +0900 Subject: [PATCH 07/26] Clarify native-frame stretch comparison architecture --- docs/HOST_ADAPTIVE_OFF_TEST.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/HOST_ADAPTIVE_OFF_TEST.md b/docs/HOST_ADAPTIVE_OFF_TEST.md index 411fd52..a322e72 100644 --- a/docs/HOST_ADAPTIVE_OFF_TEST.md +++ b/docs/HOST_ADAPTIVE_OFF_TEST.md @@ -5,14 +5,27 @@ This branch is a temporary diagnostic build only. When the launcher requests Adaptive Widescreen for an executable-compatible MPH ROM: - guest-side MPH 21:9 projection/culling patch: **enabled** -- ndsrecomp host 448px adaptive framebuffer: **disabled** +- ndsrecomp host 448px adaptive GPU3D/render-width path: **disabled** +- ndsrecomp adaptive 448px GPU2D framebuffer/compositor path: **disabled** - ndsrecomp adaptive HUD band anchoring/splitting: **disabled** -- host presentation falls back to the native 256x192 compositor path +- emulated top-screen source remains **native 256x192** +- only the completed top-screen image is stretched by SDL from **256x192 to 448x192** for final display + +The last point is required for a meaningful test. The MPH game-side patch changes the projection/culling for a 21:9 target, but the DS still produces a native 256x192 image. Displaying that image unchanged as 4:3 would make the game look horizontally compressed. This branch therefore restores the intended display shape with a simple final stretch while deliberately avoiding ndsrecomp's host-side widescreen renderer. The runner prints this marker when the comparison path is active: ```text -[mph-test] guest 21:9 projection/culling ON; host 448px adaptive framebuffer/HUD anchoring OFF +[mph-test] guest 21:9 projection/culling ON; host adaptive renderer/HUD OFF; native 256x192 -> 448x192 present stretch ON ``` -This experiment is intended to determine whether the current visual corruption is caused by combining the game-side melonPrimeDS/mphCodex aspect-ratio patch with ndsrecomp's host-side Adaptive Widescreen renderer. +## What this isolates + +Normal current Adaptive Widescreen combines two mechanisms: + +1. MPH guest code/data patches derived from melonPrimeDS/mphCodex, which change projection/culling. +2. ndsrecomp host adaptive rendering, which widens the actual host render/composition path to 448 pixels and can separately anchor/split HUD content. + +This comparison keeps (1), removes (2), and replaces (2) only with a dumb final 256-to-448 stretch. If the visual corruption disappears, that strongly indicates the current problem comes from combining the guest aspect-ratio patch with the host adaptive renderer rather than from the guest patch alone. + +This is not automatically expected to be the final implementation. The original ndsrecomp host-wide path can provide higher-quality wide rendering because it actually renders extra horizontal pixels rather than stretching a native DS image. The purpose of this branch is A/B diagnosis of the suspected double application. From dd79e2612e438a535a2899287f59e19b30aff6f1 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 19:20:06 +0900 Subject: [PATCH 08/26] Mark native-frame stretch comparison build --- TEST_BUILD_MARKER.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TEST_BUILD_MARKER.txt b/TEST_BUILD_MARKER.txt index d8a9944..7c8b734 100644 --- a/TEST_BUILD_MARKER.txt +++ b/TEST_BUILD_MARKER.txt @@ -1 +1 @@ -TEST ONLY: guest MPH 21:9 projection/culling ON; host 448px adaptive framebuffer/HUD anchoring OFF. +TEST ONLY: guest MPH 21:9 projection/culling ON; host adaptive 448px render/HUD OFF; native 256x192 top image stretched to 448x192 only at final presentation. From 0bae77e75bf925fd344f2f6f271c4d1086ad0e31 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 19:43:18 +0900 Subject: [PATCH 09/26] Expose MPH guest aspect patch as independent runtime option --- tools/patch_ndsrecomp_mph_aspect_ratio_mod.py | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 tools/patch_ndsrecomp_mph_aspect_ratio_mod.py 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() From c0c75c2a46404e11220ece9c54e5969988095259 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 19:43:48 +0900 Subject: [PATCH 10/26] Add launcher mod toggle for game aspect patch --- tools/patch_mph_launcher_aspect_mod.py | 190 +++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 tools/patch_mph_launcher_aspect_mod.py diff --git a/tools/patch_mph_launcher_aspect_mod.py b/tools/patch_mph_launcher_aspect_mod.py new file mode 100644 index 0000000..3122005 --- /dev/null +++ b/tools/patch_mph_launcher_aspect_mod.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Add an independent 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 guest patch defaults OFF so existing users receive only the original host +Adaptive Widescreen path unless they explicitly opt into the comparison mod. +""" + +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: independent game-side 21:9 patch.\n" + " // OFF by default so the original ndsrecomp host Adaptive\n" + " // Widescreen path remains the baseline and is not double-applied.\n" + " bool aspect_ratio_patch = false;\n", + "ModState aspect flag", + ) + + 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" + " } else if (key == \"aspect_ratio_patch\") {\n" + " state.aspect_ratio_patch = value == \"true\";\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. Display features deliberately expose host Adaptive Widescreen\n" + "// and the game-side aspect-ratio patch independently for A/B testing.\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", + ) + + 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. " + "This is independent from Recomp's host Adaptive Widescreen, " + "so either path can be tested alone or both can be enabled."); + 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" + " 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" + " return 1;\n" + " }\n" + " if (std::strcmp(package_id, \"mph-prime-controls\") == 0 &&\n", + "aspect feature enable", + ) + + 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 ? 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 independent; 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() From da25635d2e626fa27996b7bfe7505ce1a10b454e Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 19:44:01 +0900 Subject: [PATCH 11/26] Decouple host and guest widescreen runtime policies --- tools/patch_ndsrecomp_mph_runtime.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tools/patch_ndsrecomp_mph_runtime.py b/tools/patch_ndsrecomp_mph_runtime.py index 0d09197..4bca5a7 100755 --- a/tools/patch_ndsrecomp_mph_runtime.py +++ b/tools/patch_ndsrecomp_mph_runtime.py @@ -5,12 +5,10 @@ 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. - -This comparison branch appends one final experiment layer that keeps the guest -projection/culling patch enabled while forcing the host adaptive framebuffer and -HUD anchoring off. It is not intended for develop. +that patch re-eligible after an in-process guest reset, add end-user startup +diagnostics plus the ROM-free multi-ROM content-gate policy, and finally expose +the game-side aspect-ratio patch as an option independent from ndsrecomp's host +Adaptive Widescreen renderer. """ from __future__ import annotations @@ -29,7 +27,7 @@ def main() -> None: here / "patch_ndsrecomp_mph_adaptive_capability.py", here / "patch_ndsrecomp_mph_widescreen_reset.py", here / "patch_ndsrecomp_mph_diagnostics.py", - here / "patch_ndsrecomp_mph_guest_wide_host_off.py", + here / "patch_ndsrecomp_mph_aspect_ratio_mod.py", ): subprocess.run([sys.executable, str(script), *args], check=True) From b791877e019fd242f172a67ea8bf79d28ee97495 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 19:44:33 +0900 Subject: [PATCH 12/26] Expose guest aspect patch in generated launcher Mods --- launcher/recomp-ui/CMakeLists.txt | 48 +++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/launcher/recomp-ui/CMakeLists.txt b/launcher/recomp-ui/CMakeLists.txt index 3c3005e..83238ca 100644 --- a/launcher/recomp-ui/CMakeLists.txt +++ b/launcher/recomp-ui/CMakeLists.txt @@ -72,10 +72,28 @@ 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. +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 independently addressable from the Mods page / runner command line. string(FIND "${MPH_LAUNCHER_SOURCE}" "std::filesystem::is_regular_file(default_rom, initial_rom_error)" _mph_initial_rom_exists_guard) @@ -88,6 +106,24 @@ 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() add_executable(mph-recomp-ui "${MPH_PROFILE_LAUNCHER_SOURCE}" "${NDSRECOMP_ROOT}/recompiler/support/sha1.cpp") @@ -128,7 +164,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}" From 2bf2d6df8ea37732809c69e811125d7f8f9f5b65 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 19:44:48 +0900 Subject: [PATCH 13/26] Remove hardcoded host-off widescreen experiment --- ...patch_ndsrecomp_mph_guest_wide_host_off.py | 177 ------------------ 1 file changed, 177 deletions(-) delete mode 100644 tools/patch_ndsrecomp_mph_guest_wide_host_off.py diff --git a/tools/patch_ndsrecomp_mph_guest_wide_host_off.py b/tools/patch_ndsrecomp_mph_guest_wide_host_off.py deleted file mode 100644 index f302cf3..0000000 --- a/tools/patch_ndsrecomp_mph_guest_wide_host_off.py +++ /dev/null @@ -1,177 +0,0 @@ -#!/usr/bin/env python3 -"""Build-only experiment: guest MPH 21:9 patch + native-frame stretch. - -This is a diagnostic comparison build, not a develop candidate. - -When Adaptive Widescreen is requested for an executable-compatible MPH ROM: - -* keep the validated guest-side projection/culling patch enabled; -* disable ndsrecomp's host adaptive 448px renderer/compositor path; -* disable adaptive HUD anchoring/splitting; -* keep the emulated top-screen render surface at native 256x192; and -* stretch that completed native top-screen image to 448x192 only at SDL - presentation time. - -That separation is important: a guest 21:9 projection patch rendered into the -DS-native 256x192 surface is expected to look horizontally compressed if it is -then displayed as 4:3. The final 256->448 stretch restores the intended 21:9 -shape without re-enabling the host-side 448px renderer we are trying to remove -from this A/B test. -""" - -from __future__ import annotations - -import argparse -from pathlib import Path - -MAIN_MARKER = "MPH_TEST_GUEST_WIDE_HOST_ADAPTIVE_OFF" -GETTER_MARKER = "MPH_TEST_GUEST_WIDE_STATE_GETTER" -FRONTEND_MARKER = "MPH_TEST_NATIVE_WIDE_STRETCH_PRESENTATION" - -MAIN_OLD = ''' 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''' -MAIN_NEW = ''' // MPH_TEST_GUEST_WIDE_HOST_ADAPTIVE_OFF: comparison build only.\n // Latch the user's Adaptive Widescreen request into the guest-side MPH\n // projection/culling patch, then remove TOP from the host adaptive path.\n // frontend.cpp still presents the completed native 256x192 image at\n // 448x192, so the guest 21:9 projection is not left horizontally squashed.\n const bool mph_guest_widescreen_policy =\n nds_title_patches_mph_host_writes_compatible() &&\n (frontend_options.adaptive_screens & NDS_ADAPTIVE_TOP) != 0u;\n nds_title_patches_set_mph_adaptive(mph_guest_widescreen_policy);\n if (mph_guest_widescreen_policy) {\n frontend_options.adaptive_screens &= ~NDS_ADAPTIVE_TOP;\n frontend_options.adaptive_hud_anchor = false;\n std::fprintf(stderr,\n "[mph-test] guest 21:9 projection/culling ON; "\n "host adaptive renderer/HUD OFF; "\n "native 256x192 -> 448x192 present stretch ON\\n");\n }\n nds_title_patches_set_mph_mouse_aim(mph_mouse_aim_policy);\n''' - - -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 comparison patch for {path}: expected pinned preimage " - f"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 already-latched guest widescreen state to frontend.cpp. The - # normal host adaptive flag is deliberately cleared in main.cpp below, so - # frontend.cpp cannot infer this comparison mode from adaptive_screens. - 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_TEST_GUEST_WIDE_STATE_GETTER: comparison build only.\n" - "bool nds_title_patches_mph_adaptive_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_TEST_GUEST_WIDE_STATE_GETTER: comparison build only.\n" - "bool nds_title_patches_mph_adaptive_enabled() {\n" - " return g_mph_adaptive;\n" - "}\n\n" - "bool nds_title_patches_mph_host_writes_compatible() {\n", - GETTER_MARKER, - ) - - patch_once(main_cpp, MAIN_OLD, MAIN_NEW, MAIN_MARKER) - - # Split source/render width from presentation width. Normal builds use the - # same value for both. In this experiment only the top destination becomes - # 448px; its texture, GPU3D output, GPU2D composition and upload pitch all - # remain native 256px. - patch_once( - frontend_cpp, - " int screen_widths[2]{kScreenWidth, kScreenWidth};\n" - " int canvas_width = kScreenWidth;\n" - " int sample_scale = 1;\n", - " // MPH_TEST_NATIVE_WIDE_STRETCH_PRESENTATION: comparison build only.\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_adaptive_enabled()) {\n" - " // MPH_TEST_NATIVE_WIDE_STRETCH_DESTINATION: render/composite at\n" - " // native DS width, stretch only the completed top image.\n" - " presentation.source_widths[0] = kScreenWidth;\n" - " presentation.screen_widths[0] = 448;\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_TEST_NATIVE_WIDE_STRETCH_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", - "presentation.source_widths[screen], kScreenHeight", - ) - 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", - "presentation.source_widths[screen] *", - ) - patch_once( - frontend_cpp, - " const uint16_t output_width = static_cast(std::max(\n" - " presentation.screen_widths[0],\n" - " presentation.screen_widths[1]));\n", - " // MPH_TEST_NATIVE_WIDE_STRETCH_GPU_WIDTH: do not let the 448px\n" - " // presentation destination turn the host GPU3D renderer wide again.\n" - " const uint16_t output_width = static_cast(std::max(\n" - " presentation.source_widths[0],\n" - " presentation.source_widths[1]));\n", - "MPH_TEST_NATIVE_WIDE_STRETCH_GPU_WIDTH", - ) - - print( - "Patched comparison build: guest 21:9 ON; host adaptive render/HUD OFF; " - "native 256x192 top stretched to 448x192 at presentation" - ) - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--framework-root", type=Path, required=True) - # Accepted so this experiment can be appended to the normal runtime patch - # stack, which forwards the shared --profiles argument to every layer. - parser.add_argument("--profiles", type=Path, required=False) - args = parser.parse_args() - patch(args.framework_root.resolve()) - - -if __name__ == "__main__": - main() From 8a9cd4b4b66006c1ce647da42d77741bd414f525 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 19:45:01 +0900 Subject: [PATCH 14/26] Remove obsolete hardcoded comparison marker --- TEST_BUILD_MARKER.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 TEST_BUILD_MARKER.txt diff --git a/TEST_BUILD_MARKER.txt b/TEST_BUILD_MARKER.txt deleted file mode 100644 index 7c8b734..0000000 --- a/TEST_BUILD_MARKER.txt +++ /dev/null @@ -1 +0,0 @@ -TEST ONLY: guest MPH 21:9 projection/culling ON; host adaptive 448px render/HUD OFF; native 256x192 top image stretched to 448x192 only at final presentation. From e755c1a4a6dc62df4e19cadc861af1662b566be8 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 19:45:22 +0900 Subject: [PATCH 15/26] Document independent host and guest widescreen toggles --- docs/WIDESCREEN_AB_TEST.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 docs/WIDESCREEN_AB_TEST.md diff --git a/docs/WIDESCREEN_AB_TEST.md b/docs/WIDESCREEN_AB_TEST.md new file mode 100644 index 0000000..153627b --- /dev/null +++ b/docs/WIDESCREEN_AB_TEST.md @@ -0,0 +1,21 @@ +# Widescreen A/B test modes + +The launcher exposes two independent display features: + +- **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. + +The game-side patch defaults **OFF** so the original ndsrecomp Adaptive Widescreen implementation remains the baseline and is not double-applied automatically. + +## Four test 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 | Both mechanisms enabled; intentionally reproduces the suspected double-application path | + +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. + +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. From 23202b5a2102d41ad69d180675ac9b43fcc87cca Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 19:45:30 +0900 Subject: [PATCH 16/26] Replace hardcoded host-off test notes with A/B matrix --- docs/HOST_ADAPTIVE_OFF_TEST.md | 31 ------------------------------- 1 file changed, 31 deletions(-) delete mode 100644 docs/HOST_ADAPTIVE_OFF_TEST.md diff --git a/docs/HOST_ADAPTIVE_OFF_TEST.md b/docs/HOST_ADAPTIVE_OFF_TEST.md deleted file mode 100644 index a322e72..0000000 --- a/docs/HOST_ADAPTIVE_OFF_TEST.md +++ /dev/null @@ -1,31 +0,0 @@ -# Host Adaptive Off comparison build - -This branch is a temporary diagnostic build only. - -When the launcher requests Adaptive Widescreen for an executable-compatible MPH ROM: - -- guest-side MPH 21:9 projection/culling patch: **enabled** -- ndsrecomp host 448px adaptive GPU3D/render-width path: **disabled** -- ndsrecomp adaptive 448px GPU2D framebuffer/compositor path: **disabled** -- ndsrecomp adaptive HUD band anchoring/splitting: **disabled** -- emulated top-screen source remains **native 256x192** -- only the completed top-screen image is stretched by SDL from **256x192 to 448x192** for final display - -The last point is required for a meaningful test. The MPH game-side patch changes the projection/culling for a 21:9 target, but the DS still produces a native 256x192 image. Displaying that image unchanged as 4:3 would make the game look horizontally compressed. This branch therefore restores the intended display shape with a simple final stretch while deliberately avoiding ndsrecomp's host-side widescreen renderer. - -The runner prints this marker when the comparison path is active: - -```text -[mph-test] guest 21:9 projection/culling ON; host adaptive renderer/HUD OFF; native 256x192 -> 448x192 present stretch ON -``` - -## What this isolates - -Normal current Adaptive Widescreen combines two mechanisms: - -1. MPH guest code/data patches derived from melonPrimeDS/mphCodex, which change projection/culling. -2. ndsrecomp host adaptive rendering, which widens the actual host render/composition path to 448 pixels and can separately anchor/split HUD content. - -This comparison keeps (1), removes (2), and replaces (2) only with a dumb final 256-to-448 stretch. If the visual corruption disappears, that strongly indicates the current problem comes from combining the guest aspect-ratio patch with the host adaptive renderer rather than from the guest patch alone. - -This is not automatically expected to be the final implementation. The original ndsrecomp host-wide path can provide higher-quality wide rendering because it actually renders extra horizontal pixels rather than stretching a native DS image. The purpose of this branch is A/B diagnosis of the suspected double application. From a4b405119a38976388129d378045ba8497f226e2 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 19:45:46 +0900 Subject: [PATCH 17/26] Validate widescreen A/B patch helpers in CI --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f37332d..0a2b9de 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,6 +26,8 @@ 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_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 \ From 35a7ce65f25f8b1f5eb386791adecf05670d7fc5 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 19:50:28 +0900 Subject: [PATCH 18/26] Force nearest-neighbor framebuffer presentation --- tools/patch_ndsrecomp_nearest_presentation.py | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tools/patch_ndsrecomp_nearest_presentation.py 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() From 7b941978e43014e3bcaa6adaa83d53f6ecd26567 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 19:50:44 +0900 Subject: [PATCH 19/26] Keep framebuffer presentation nearest-only --- tools/patch_ndsrecomp_mph_runtime.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tools/patch_ndsrecomp_mph_runtime.py b/tools/patch_ndsrecomp_mph_runtime.py index 4bca5a7..e2ee119 100755 --- a/tools/patch_ndsrecomp_mph_runtime.py +++ b/tools/patch_ndsrecomp_mph_runtime.py @@ -6,9 +6,10 @@ 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, add end-user startup -diagnostics plus the ROM-free multi-ROM content-gate policy, and finally expose -the game-side aspect-ratio patch as an option independent from ndsrecomp's host -Adaptive Widescreen renderer. +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 @@ -28,6 +29,7 @@ def main() -> None: 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", ): subprocess.run([sys.executable, str(script), *args], check=True) From 8f67865657ce7950b9e563ebaa6f71aa10535004 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 19:51:00 +0900 Subject: [PATCH 20/26] Validate nearest-presentation patch helper --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0a2b9de..069cad7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,6 +27,7 @@ jobs: 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 \ From db7e9db8cfe08d168571c18e72b92297e139699f Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 19:52:42 +0900 Subject: [PATCH 21/26] Cover independent aspect mod and nearest presentation in static CI --- .github/workflows/mph-multirom-static.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) 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 From 1786debd10ba60847cc4e67e29e15235ebd4ca45 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 19:54:07 +0900 Subject: [PATCH 22/26] Keep aspect-ratio layer idempotent in runtime stack --- tools/patch_ndsrecomp_mph_runtime.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tools/patch_ndsrecomp_mph_runtime.py b/tools/patch_ndsrecomp_mph_runtime.py index e2ee119..563a5b5 100755 --- a/tools/patch_ndsrecomp_mph_runtime.py +++ b/tools/patch_ndsrecomp_mph_runtime.py @@ -19,9 +19,17 @@ 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", @@ -31,6 +39,18 @@ def main() -> None: 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) From 5941c1bf42ddaa95a61daf56ae02822f8e756297 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 20:07:18 +0900 Subject: [PATCH 23/26] Make widescreen launcher mods mutually exclusive --- tools/patch_mph_launcher_aspect_mod.py | 51 ++++++++++++++++++++------ 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/tools/patch_mph_launcher_aspect_mod.py b/tools/patch_mph_launcher_aspect_mod.py index 3122005..654d371 100644 --- a/tools/patch_mph_launcher_aspect_mod.py +++ b/tools/patch_mph_launcher_aspect_mod.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Add an independent MPH game-side aspect-ratio feature to the launcher TU. +"""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- @@ -9,9 +9,12 @@ 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 opt into the comparison mod. +Adaptive Widescreen path unless they explicitly select the game-side patch. """ from __future__ import annotations @@ -42,13 +45,16 @@ def patch(source: Path) -> None: "struct ModState {\n bool adaptive_widescreen = true;\n", "struct ModState {\n" " bool adaptive_widescreen = true;\n" - " // MPH_GAME_ASPECT_RATIO_MOD: independent game-side 21:9 patch.\n" - " // OFF by default so the original ndsrecomp host Adaptive\n" - " // Widescreen path remains the baseline and is not double-applied.\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" @@ -56,8 +62,12 @@ def patch(source: Path) -> None: " } 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", ) @@ -87,8 +97,8 @@ def patch(source: Path) -> None: " return 3;\n" "}\n", "// The online identity is NOT a mod: it lives on the dashboard's ONLINE\n" - "// card. Display features deliberately expose host Adaptive Widescreen\n" - "// and the game-side aspect-ratio patch independently for A/B testing.\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", @@ -102,6 +112,18 @@ def patch(source: Path) -> None: "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"); @@ -112,8 +134,8 @@ def patch(source: Path) -> None: copy_text( output->description, "Applies the MPH game-side 21:9 projection and culling patch. " - "This is independent from Recomp's host Adaptive Widescreen, " - "so either path can be tested alone or both can be enabled."); + "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"); @@ -143,17 +165,24 @@ def patch(source: Path) -> None: " 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" @@ -166,7 +195,7 @@ def patch(source: Path) -> None: " ? L\"separate\"\n" " : L\"stacked\") +\n" " L\" --adaptive-widescreen \" +\n" - " (adaptive ? L\"top\" : L\"none\") +\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", @@ -175,7 +204,7 @@ def patch(source: Path) -> None: source.write_text(text, encoding="utf-8") print( "Patched launcher Mods: Adaptive Widescreen (host) and Game Aspect " - "Ratio Patch (guest) are independent; guest default=off" + "Ratio Patch (guest) are mutually exclusive; guest default=off" ) From c93d47bb0425a4a9c552d91f93bab073bd5eade8 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 20:08:00 +0900 Subject: [PATCH 24/26] Guard widescreen mod mutual exclusion --- launcher/recomp-ui/CMakeLists.txt | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/launcher/recomp-ui/CMakeLists.txt b/launcher/recomp-ui/CMakeLists.txt index 83238ca..03d6c91 100644 --- a/launcher/recomp-ui/CMakeLists.txt +++ b/launcher/recomp-ui/CMakeLists.txt @@ -76,6 +76,7 @@ file(WRITE "${MPH_PROFILE_LAUNCHER_SOURCE}" "${MPH_LAUNCHER_SOURCE}") # 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" @@ -93,7 +94,7 @@ 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 independently addressable from the Mods page / runner command line. +# 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) @@ -124,6 +125,30 @@ string(FIND "${MPH_LAUNCHER_SOURCE}" 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") @@ -181,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 From 50271f43a4d0be9fe4c02a87c278bec80f4a4a47 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 20:08:13 +0900 Subject: [PATCH 25/26] Document mutually exclusive widescreen modes --- docs/WIDESCREEN_AB_TEST.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/WIDESCREEN_AB_TEST.md b/docs/WIDESCREEN_AB_TEST.md index 153627b..b0c87ae 100644 --- a/docs/WIDESCREEN_AB_TEST.md +++ b/docs/WIDESCREEN_AB_TEST.md @@ -1,21 +1,25 @@ -# Widescreen A/B test modes +# Widescreen modes -The launcher exposes two independent display features: +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. -The game-side patch defaults **OFF** so the original ndsrecomp Adaptive Widescreen implementation remains the baseline and is not double-applied automatically. +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. -## Four test combinations +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 | Both mechanisms enabled; intentionally reproduces the suspected double-application path | +| 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. -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. +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. \ No newline at end of file From c10c3a6e091df0af3fffe753a9709a1815176b93 Mon Sep 17 00:00:00 2001 From: Zection6V Date: Mon, 17 Aug 2026 20:10:41 +0900 Subject: [PATCH 26/26] Note verified launcher behavior --- docs/WIDESCREEN_AB_TEST.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/WIDESCREEN_AB_TEST.md b/docs/WIDESCREEN_AB_TEST.md index b0c87ae..5ad3276 100644 --- a/docs/WIDESCREEN_AB_TEST.md +++ b/docs/WIDESCREEN_AB_TEST.md @@ -22,4 +22,8 @@ When only the guest patch is enabled, the DS-native render surface remains 256x1 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. \ No newline at end of file +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