diff --git a/platforms/ios/app/src/main/assets/shaders/ATTRIBUTION.md b/platforms/ios/app/src/main/assets/shaders/ATTRIBUTION.md index 2d7204b3cd..06fbaca96d 100644 --- a/platforms/ios/app/src/main/assets/shaders/ATTRIBUTION.md +++ b/platforms/ios/app/src/main/assets/shaders/ATTRIBUTION.md @@ -8,8 +8,17 @@ licence, and where it came from. **Pinned commit:** `80372284ea8c00ae5e25e5a6e4f9f49415f85896` (2026-08-15) Every file below is copied byte for byte from that commit, licence headers intact. Nothing -was renamed, reformatted or edited. To verify any file, clone the upstream repository at the -pinned commit and compare it against the copy in this bundle at the upstream path given. +was renamed or reformatted. To verify any file, clone the upstream repository at the pinned +commit and compare it against the copy in this bundle at the upstream path given. + +**Two files carry a one line change**, recorded in +`platforms/ios/patches/slang-shaders-prescale-zero-guard.patch` and reversible with +`git apply -R`. `crt/shaders/crt-aperture.slang` and +`pixel-art-scaling/shaders/sharp-bilinear.slang` each clamp a derived integer prescale to a +minimum of one. Upstream divides by that prescale without checking it, which is safe in +RetroArch but not here: PCSX2 renders internally at up to 8x, and once the source is taller +than the screen the prescale floors to zero and the frame turns black. Both files remain +under their original licences and authorship. The upstream collection is mixed licence and has no repository-wide licence file, so each file here was cleared on its own header rather than on a blanket grant. Files whose headers diff --git a/platforms/ios/app/src/main/assets/shaders/presets/crt/shaders/crt-aperture.slang b/platforms/ios/app/src/main/assets/shaders/presets/crt/shaders/crt-aperture.slang index 8ca5192e40..508c8fa2eb 100644 --- a/platforms/ios/app/src/main/assets/shaders/presets/crt/shaders/crt-aperture.slang +++ b/platforms/ios/app/src/main/assets/shaders/presets/crt/shaders/crt-aperture.slang @@ -142,7 +142,7 @@ layout(set = 0, binding = 2) uniform sampler2D Source; void main() { - float scale = floor(params.OutputSize.y * params.SourceSize.w); + float scale = max(floor(params.OutputSize.y * params.SourceSize.w), 1.0); float offset = 1.0 / scale * 0.5; if (bool(mod(scale, 2.0))) offset = 0.0; diff --git a/platforms/ios/app/src/main/assets/shaders/presets/pixel-art-scaling/shaders/sharp-bilinear.slang b/platforms/ios/app/src/main/assets/shaders/presets/pixel-art-scaling/shaders/sharp-bilinear.slang index 90508aa907..90865cc715 100644 --- a/platforms/ios/app/src/main/assets/shaders/presets/pixel-art-scaling/shaders/sharp-bilinear.slang +++ b/platforms/ios/app/src/main/assets/shaders/presets/pixel-art-scaling/shaders/sharp-bilinear.slang @@ -47,7 +47,7 @@ void main() vec2 texel = vTexCoord * params.SourceSize.xy; vec2 texel_floored = floor(texel); vec2 s = fract(texel); - float scale = (params.AUTO_PRESCALE > 0.5) ? floor(params.OutputSize.y / params.SourceSize.y + 0.01) : params.SHARP_BILINEAR_PRE_SCALE; + float scale = max((params.AUTO_PRESCALE > 0.5) ? floor(params.OutputSize.y / params.SourceSize.y + 0.01) : params.SHARP_BILINEAR_PRE_SCALE, 1.0); float region_range = 0.5 - 0.5 / scale; // Figure out where in the texel to sample to get correct pre-scaled bilinear. diff --git a/platforms/ios/patches/slang-shaders-prescale-zero-guard.patch b/platforms/ios/patches/slang-shaders-prescale-zero-guard.patch new file mode 100644 index 0000000000..f09f0e3376 --- /dev/null +++ b/platforms/ios/patches/slang-shaders-prescale-zero-guard.patch @@ -0,0 +1,47 @@ +Guard the integer prescale in crt-aperture and sharp-bilinear against zero. + +Both shaders derive a whole-number prescale from output height over source height, +then divide by it. RetroArch only ever feeds them a small console framebuffer being +scaled up, so the ratio is never below one. PCSX2 renders internally at up to 8x, and +above roughly 1.5x on a phone the source is taller than the screen: the ratio drops +under one, floor() returns zero, and the divide yields NaN. The whole frame goes black. + +Reported on an iPhone SE 2 (1334x750) where 1.5x worked and 2x did not, then reproduced +in the simulator at 3x. Fixed by clamping the prescale to one, which is what the sibling +sharp-bilinear-simple already does with max(floor(...), vec2(1.0)) and what crt-geom does +with clamp(floor(...), 1.0, 2.0). + +This diverges from the pinned upstream commit, so it is recorded here and in +shaders/ATTRIBUTION.md. Reverse it to recover the upstream bytes: + + git apply -R platforms/ios/patches/slang-shaders-prescale-zero-guard.patch + +test_ios_shader_prescale_guard.py fails if either guard is dropped, which is what a +re-sync from upstream would otherwise do silently. + +diff --git a/platforms/ios/app/src/main/assets/shaders/presets/crt/shaders/crt-aperture.slang b/platforms/ios/app/src/main/assets/shaders/presets/crt/shaders/crt-aperture.slang +index 8ca5192e40..508c8fa2eb 100644 +--- a/platforms/ios/app/src/main/assets/shaders/presets/crt/shaders/crt-aperture.slang ++++ b/platforms/ios/app/src/main/assets/shaders/presets/crt/shaders/crt-aperture.slang +@@ -142,7 +142,7 @@ layout(set = 0, binding = 2) uniform sampler2D Source; + + void main() + { +- float scale = floor(params.OutputSize.y * params.SourceSize.w); ++ float scale = max(floor(params.OutputSize.y * params.SourceSize.w), 1.0); + float offset = 1.0 / scale * 0.5; + + if (bool(mod(scale, 2.0))) offset = 0.0; +diff --git a/platforms/ios/app/src/main/assets/shaders/presets/pixel-art-scaling/shaders/sharp-bilinear.slang b/platforms/ios/app/src/main/assets/shaders/presets/pixel-art-scaling/shaders/sharp-bilinear.slang +index 90508aa907..90865cc715 100644 +--- a/platforms/ios/app/src/main/assets/shaders/presets/pixel-art-scaling/shaders/sharp-bilinear.slang ++++ b/platforms/ios/app/src/main/assets/shaders/presets/pixel-art-scaling/shaders/sharp-bilinear.slang +@@ -47,7 +47,7 @@ void main() + vec2 texel = vTexCoord * params.SourceSize.xy; + vec2 texel_floored = floor(texel); + vec2 s = fract(texel); +- float scale = (params.AUTO_PRESCALE > 0.5) ? floor(params.OutputSize.y / params.SourceSize.y + 0.01) : params.SHARP_BILINEAR_PRE_SCALE; ++ float scale = max((params.AUTO_PRESCALE > 0.5) ? floor(params.OutputSize.y / params.SourceSize.y + 0.01) : params.SHARP_BILINEAR_PRE_SCALE, 1.0); + float region_range = 0.5 - 0.5 / scale; + + // Figure out where in the texel to sample to get correct pre-scaled bilinear. diff --git a/platforms/ios/scripts/tests/test_ios_shader_prescale_guard.py b/platforms/ios/scripts/tests/test_ios_shader_prescale_guard.py new file mode 100644 index 0000000000..97ddf11c3c --- /dev/null +++ b/platforms/ios/scripts/tests/test_ios_shader_prescale_guard.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""A bundled shader must not divide by an unguarded integer prescale. + +Several slang shaders derive a whole-number prescale from output height over source +height and then divide by it. That is safe in RetroArch, where the source is always a +small console framebuffer being scaled up. PCSX2 renders internally at up to 8x, so +above roughly 1.5x the source is taller than the screen, the ratio falls under one, +floor() returns zero and the frame turns black. Reported on an iPhone SE 2 at 2x, +reproduced in the simulator at 3x. + +The guard is a max(..., 1.0) around the prescale, which sharp-bilinear-simple and +crt-geom already carry upstream. This test exists because re-syncing a preset from +upstream would silently drop it again. +""" + +import re +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[4] +PRESETS = ROOT / "platforms/ios/app/src/main/assets/shaders/presets" + +# A prescale is a floor() over the output/source height ratio, in either the divide or +# the reciprocal-multiply spelling. SourceSize.w and .zw are 1/height and 1/size. +PRESCALE = re.compile( + r"floor\s*\([^;]*?OutputSize\.[xy]{1,2}[^;]*?" + r"(?:/\s*[a-zA-Z_.]*SourceSize\.[xy]{1,2}|\*\s*[a-zA-Z_.]*SourceSize\.[zw]{1,2})", + re.IGNORECASE, +) + + +def shaders(): + return sorted(PRESETS.rglob("*.slang")) + + +class PrescaleGuard(unittest.TestCase): + def test_presets_exist(self): + self.assertTrue(shaders(), f"no .slang files under {PRESETS}") + + def test_every_prescale_is_clamped(self): + offenders = [] + for path in shaders(): + for number, line in enumerate(path.read_text().splitlines(), 1): + code = line.split("//")[0] + if not PRESCALE.search(code): + continue + # clamp() carries its own lower bound; max() is the direct guard. + if "max(" in code or "clamp(" in code: + continue + offenders.append(f" {path.relative_to(PRESETS)}:{number}: {code.strip()}") + self.assertEqual( + offenders, + [], + "a derived integer prescale is not clamped to at least 1. Above ~1.5x internal " + "resolution it floors to zero and the frame goes black. Wrap it in max(..., 1.0) " + "and record the change in patches/slang-shaders-prescale-zero-guard.patch:\n" + + "\n".join(offenders), + ) + + def test_the_two_known_shaders_still_carry_their_guard(self): + """Named directly, so a rename or a re-sync cannot quietly drop the fix.""" + for relative in ( + "crt/shaders/crt-aperture.slang", + "pixel-art-scaling/shaders/sharp-bilinear.slang", + ): + path = PRESETS / relative + self.assertTrue(path.is_file(), f"missing {relative}") + self.assertIn( + "max(", + path.read_text(), + f"{relative} lost its prescale guard; see " + "patches/slang-shaders-prescale-zero-guard.patch", + ) + + +if __name__ == "__main__": + unittest.main()