From 2c7ed4ab3858cb53df3f462e78f202fcce8ae3d6 Mon Sep 17 00:00:00 2001 From: Wagner Felipe Leszkiewicz <5971235+wagcbjr@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:33:55 -0300 Subject: [PATCH] Backport the base instance from Metal and -fwrapv (issue #12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pokémon Legends: Z-A built from this fork draws Lumiose's building walls and the walkways beside its ladders invisible, while their window frames and ladders float and the walkways' collision still holds. Two upstream fixes made after the fork's last sync (2026-10-27) are missing: - seedds/nxnative 8fa9b2d1: GLSLC's vertex shaders read the draw's base instance from c0 + 0x644, which the host fills from the draw command; an indirect draw's base instance is in GPU memory, so it was 0 and every GPU-culled instance took the first one's transform. dmsh_build.py's vs_base_instance now reads Metal's [[base_instance]]. - seedds/nxnative e4e5ff6: translated shaders compile with -fwrapv, as signed overflow wraps on the Switch's GPUs and Metal's front end folded around it (dmsh_build.py, ios_metal.py, vk_shaders.py, gl_shaders.py). Chain 764aff238713 -> 8a1b56655ae9 (dmsh_chain.py --header). Checked: 775 of Z-A's 28,603 translated shaders read c0 + 0x644 by the text vs_base_instance looks for; all 775, rewritten, compile with Metal Developer Tools for Windows (metal.exe, air64-apple-ios18.0, with -fwrapv), 0 failures. A full build made 28,603 of 28,603 iOS libraries, all 775 with the base instance from Metal and none on the old form. On an iPhone (A18 Pro, iOS 27.2), Z-A 2.0.2's walkways beside Lumiose's ladders, invisible on chain 764aff238713, are drawn on 8a1b56655ae9. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VDGHF5LyEKSqoK23EvYr6W --- dm/gfx/nvn/dmsh_chain.h | 2 +- dm/gfx/tools/dmsh_build.py | 23 ++++++++++++++++++++--- dm/gfx/tools/gl_shaders.py | 2 +- dm/gfx/tools/vk_shaders.py | 2 +- dm/ios/tools/ios_metal.py | 2 +- 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/dm/gfx/nvn/dmsh_chain.h b/dm/gfx/nvn/dmsh_chain.h index 9a8bbaafbb..41b0bb1342 100644 --- a/dm/gfx/nvn/dmsh_chain.h +++ b/dm/gfx/nvn/dmsh_chain.h @@ -1,3 +1,3 @@ // Generated file (dm/gfx/tools/dmsh_chain.py --header): the B shader library translation chain version this source tree expects (default options), one for every game; // the host loads only /b-/. Run dmsh_chain.py --header after changing the chain's tools and commit this file with the change -#define DMSH_CHAIN "764aff238713" +#define DMSH_CHAIN "8a1b56655ae9" diff --git a/dm/gfx/tools/dmsh_build.py b/dm/gfx/tools/dmsh_build.py index e04fb6f782..9a9782cdea 100644 --- a/dm/gfx/tools/dmsh_build.py +++ b/dm/gfx/tools/dmsh_build.py @@ -56,7 +56,9 @@ def precise(msl): return re.sub(r'\bfast::(min|max|clamp)\(', r'precise::\1(', m # -O1: the front end's -O2 (CorrelatedValuePropagation, then SimplifyCFG) folds the translated shaders' repeated tests of one value into switches whose cases jump past their own join. # Apple's GPU backend then exceeds its control-flow ID budget ("Max Unstruct ID 758 ... exceeds supported limit", a note, not an error) and falls back to a slower lowering: # Odyssey's alRenderMaterialUber took 25 minutes and 17.3 MB of GPU code, 31 s and 1.3 MB at -O1. Ordinary shaders come out within a few percent either way (the backend optimizes again) -MAC_METAL = ['xcrun', '-sdk', 'macosx', 'metal', '-c', '-O1', '-fpreserve-invariance'] +# -fwrapv: signed integer overflow wraps, as SPIR-V's integer arithmetic and the Switch's GPUs do; in MSL it is undefined, and the front end folds around it +# (backport of seedds/nxnative e4e5ff6: Ocarina of Time's sign extension a = w * 65536, (a >> 31) << 16 | uint(a) >> 16 took its sign bits from bits 15-30 of w) +MAC_METAL = ['xcrun', '-sdk', 'macosx', 'metal', '-c', '-O1', '-fpreserve-invariance', '-fwrapv'] MAC_RECORD = ' '.join(MAC_METAL) + '; xcrun -sdk macosx metallib' # The outer shader and fetch-variant caches restore compiled libraries directly, # before the MSL compile cache can check their compiler. Keep override builds separate there too. @@ -670,6 +672,21 @@ def vs_init(src): k += len('main0_out out = {};') return src[:k] + ''.join(f'\n out.{n} = float4(0.0, 0.0, 0.0, 1.0);' for n in outs) + src[k:] +VP_BASE_INSTANCE = '(*spvDescriptorSet0.vp_c0)._m0[100].y' # c0 + 0x644, the driver's base-instance word (row 100, second component) + +def vs_base_instance(src): + """The draw's base instance from Metal, not from the driver segment. GLSLC adds c0 + 0x644 to Maxwell's instance ID where a shader needs the absolute instance (gl_InstanceID + + gl_BaseInstance); the host writes that word from the draw command (nvn_mtl.m encode_args), but an indirect draw's base instance is in its argument record, which the GPU may write + earlier in the same submission. Metal passes every draw's base instance, direct or indirect, as [[base_instance]], so a vertex function reads it there. Super Mario Party Jamboree's + GPU-driven instances (276504dc5c987ecf writes the records, 4b05556737e9535f draws them: record = instance + c0 + 0x644, 80 bytes each) all took the first records' transforms + with 0 there, and stray grey triangles crossed the board. The segment's rows are float4 holding raw bits, so the word becomes the float with the base instance's bits; a function without + the parameter gets it (backport of seedds/nxnative 8fa9b2d1)""" + if VP_BASE_INSTANCE not in src: return src + src = src.replace(VP_BASE_INSTANCE, 'as_type(gl_BaseInstance)') + def add(m): + return m.group(0) if '[[base_instance]]' in m.group(0) else m.group(0)[:-1] + ', uint gl_BaseInstance [[base_instance]])' + return re.sub(r'\bvertex\s+\w+\s+main0\s*\((?:[^()]|\([^()]*\))*\)', add, src) # the parameter list, whose attributes hold parentheses ([[buffer(0)]]) + def vs_layer(src): """A vertex shader that writes the layer (render_target_array_index) loses the point size output. The translator writes gl_PointSize = 1.0 in every vertex shader; Metal builds a pipeline whose vertex function writes the layer only with an input primitive class, and one that writes a point size only with the point class (or none), so with both no pipeline can be built. @@ -943,8 +960,8 @@ def finish(key, w, j, c1): except ValueError as e: return 'image atomic MSL failed', str(e), None src = precise(src); open(msl, 'w').write(src) # min / max / clamp with Maxwell's NaN results (see precise) pad = [] - if j['stage'] == 'vs': # outputs start as (0,0,0,1) (see vs_init); no point size beside a layer output (see vs_layer) - src = vs_layer(vs_init(src)); open(msl, 'w').write(src) + if j['stage'] == 'vs': # outputs start as (0,0,0,1) (see vs_init); no point size beside a layer output (see vs_layer); the base instance from Metal (see vs_base_instance) + src = vs_base_instance(vs_layer(vs_init(src))); open(msl, 'w').write(src) if j['stage'] == 'vs' and PAIRS.get(key): # outputs the pixel shader reads but the vertex shader does not write get (0,0,0,1) (see vs_pad) src, pad = vs_pad(key, src) if pad: open(msl, 'w').write(src) diff --git a/dm/gfx/tools/gl_shaders.py b/dm/gfx/tools/gl_shaders.py index 6d4e404107..0d262aec40 100644 --- a/dm/gfx/tools/gl_shaders.py +++ b/dm/gfx/tools/gl_shaders.py @@ -162,7 +162,7 @@ def metallib(msls, out, sdk, extra): os.makedirs(out, exist_ok=True) def comp(m): a = m[:-6] + f'.{sdk}.air' - r = subprocess.run(['xcrun', '-sdk', sdk, 'metal', '-c', '-fpreserve-invariance', *extra, m, '-o', a], capture_output=True, text=True) + r = subprocess.run(['xcrun', '-sdk', sdk, 'metal', '-c', '-fpreserve-invariance', '-fwrapv', *extra, m, '-o', a], capture_output=True, text=True) if r.returncode: red(f'{m}: metal compile failed: {r.stderr.strip()[:400]}') return a with cf.ThreadPoolExecutor(os.cpu_count()) as ex: airs = list(ex.map(comp, msls)) diff --git a/dm/gfx/tools/vk_shaders.py b/dm/gfx/tools/vk_shaders.py index 38e4155b47..7d72d34cc4 100644 --- a/dm/gfx/tools/vk_shaders.py +++ b/dm/gfx/tools/vk_shaders.py @@ -147,7 +147,7 @@ def metallib(msls, out, sdk, extra): airs = [] def comp(m): a = m[:-6] + f'.{sdk}.air' - r = subprocess.run(['xcrun', '-sdk', sdk, 'metal', '-c', '-fpreserve-invariance', *extra, m, '-o', a], capture_output=True, text=True) + r = subprocess.run(['xcrun', '-sdk', sdk, 'metal', '-c', '-fpreserve-invariance', '-fwrapv', *extra, m, '-o', a], capture_output=True, text=True) if r.returncode: red(f'{m}: metal compile failed: {r.stderr.strip()[:400]}') return a with cf.ThreadPoolExecutor(os.cpu_count()) as ex: airs = list(ex.map(comp, msls)) diff --git a/dm/ios/tools/ios_metal.py b/dm/ios/tools/ios_metal.py index f270b18a9a..2abb5b1a6a 100644 --- a/dm/ios/tools/ios_metal.py +++ b/dm/ios/tools/ios_metal.py @@ -5,7 +5,7 @@ # and the default fast mode lets each GPU's backend compiler treat them as always false; on the A20 Pro the opening course's odd frames had 727k NaN pixels in the main image (a few dozen with the same AIR on the M4 Pro), which became the black band / black-edged glow # -fpreserve-invariance: [[invariant]] position outputs only agree across shaders with this flag (as dmsh_build.py's MAC_METAL: a depth pre-pass followed by a less-or-equal G-buffer) # -O1: -O2's switch folding leaves a control-flow graph Apple's GPU backend can only lower slowly into very large code (as dmsh_build.py's MAC_METAL) -METAL = ['xcrun', '-sdk', 'iphoneos', 'metal', '-c', '-O1', '-mios-version-min=18.0', '-fmetal-math-mode=relaxed', '-fpreserve-invariance'] +METAL = ['xcrun', '-sdk', 'iphoneos', 'metal', '-c', '-O1', '-mios-version-min=18.0', '-fmetal-math-mode=relaxed', '-fpreserve-invariance', '-fwrapv'] # -fwrapv: see dmsh_build.py MAC_METAL METALLIB = ['xcrun', '-sdk', 'iphoneos', 'metallib'] RECORD = ' '.join(METAL) + '; ' + ' '.join(METALLIB) # Written into json chain.metal