Skip to content

Ocarina of Time (0400486024DEC000) loses its forest haze: bounded uniform reads (26062f9e, 918e9f34 / #2127) miscompile in Apple's Metal compiler at -O1 #2425

Closed

Description

@dpadr

Summary

On Ocarina of Time (0400486024DEC000 v131072, Switch 2, NVN2), the haze and light shafts of Kokiri Forest are gone since the chain that bounds uniform reads at computed offsets (ecd9cb97f72c, against bf6035002b98 before it).

The cause is one compute shader, the froxel light injection 9ed48d1011f1b48b. As translated with the bounds, it writes only zeros into its 128x72x96 froxel volumes. The volumetric haze pass that reads them then adds nothing.

  • The translation is correct: the same MSL compiled at -O0 gives the right picture.
  • It is Apple's Metal compiler (32023.922) at -O1 that miscompiles the shader whenever its 195 uniform reads at computed offsets carry an upper bound, whatever the form of the bound.

Environment

  • Apple M2 (MacBook Air, 16 GB), macOS 27.0 (26A428).
  • Apple metal version 32023.922 (metalfe-32023.922.4).
  • dmsh_build's Mac flags: -O1 -mmacosx-version-min=26.6 -fpreserve-invariance -fwrapv -fbracket-depth=1024.

How it was found

  • Scene: Kokiri Forest, at the load point of a save there, camera still.
  • Method: one frame's passes and colour targets were dumped raw (DM_NVN_PASSDUMP, texdump with DM_NVN_SNAPRAW, texraw), on chain ecd9cb97f72c and on bf6035002b98, and compared target by target. The whole scene's upper half is 3.7× darker.
  • Inputs that match byte for byte:
    • the 256x256 sky/ambient pair and the SH producer's buffer;
    • the R16 1360x1000x8 array;
    • the probe, the shadow mask and the 64x36 ambient volumes.
  • First divergent data: the 128x72x96 froxel volumes (six 3D storage images, two ping-pong sets) are all zero. They are written by 9ed48d1011f1b48b and integrated by 5b2bb0924ecc403b, whose translation is unchanged between the two chains.
  • The shader's uniform bindings are the same on both chains: c3 13568 bytes, c4 16960, c5 14848. The size table (bank 25) holds them.

The split

Every variant of 9ed48d1011f1b48b was given to the unchanged ecd9cb97f72c host through a library overlay. Ratios are against bf6035002b98 (scene upper half / haze volume / final image); 1.0 is correct and about 0.26 is the regression.

Variant Result
bf6035002b98's translation 1.01 / 1.02 / 1.03 ✓
ecd9cb97f72c's translation as shipped (local compile, control) 0.27 / 0.35 / 0.38 ✗
… with the 15 new threadgroup fences removed 0.27 / 0.34 / 0.38 ✗
… with the size table reporting 64 KB for every bound slot (host switch) 0.27 / 0.35 / 0.38 ✗
… the 195 bounded reads as plain reads cb[i][c] 1.00 / 1.00 / 1.01 ✓
… every guard compared with a constant 64 KB (always true here) 0.26 / 0.32 / 0.38 ✗
… select(0, cb[i][c], cond) (plain index) 0.25 / 0.34 / 0.38 ✗
… cb[cond ? i : 0][c] (no select) 0.26 / 0.32 / 0.38 ✗
… the guard reduced to i >= 0 (no upper bound) 1.00 / 1.03 / 1.02 ✓
… the guard as uint(i) < size >> 4 (row compare) 0.26 / 0.35 / 0.38 ✗
… a bitwise mask of the load instead of select 0.26 / 0.33 / 0.38 ✗
… the shipped MSL unchanged, compiled at -O0 0.99 / 1.02 / 1.03 ✓

Why the bounded and plain forms must agree here

Statically, every guarded read is a small base plus a clamped index:

  • c3 at 12352–12408 bytes plus a cascade index (0–3) × 64;
  • c5 at 944–1832 bytes plus min(max(i, 0), 4) × 112, and similar.

Against bound sizes of 13568 and 14848 bytes, every guard is true. So select(0, read, cond) must equal the plain read. With a constant 64 KB guard it cannot even depend on the table, and it still fails at -O1.

Possible upstream fixes (for the maintainer)

  1. Drop the upper bound and keep >= 0. This gives up the protection 26062f9 added for Paper Mario: The Thousand-Year Door's reads past a 16-byte binding (Paper Mario: TTYD 1.0.1 — GPU hangs during Peach’s invisibility-potion sequence on Mac and iPad #1860). Not recommended alone.
  2. Bound on the host instead of in the shader: bind short uniform slots padded with zeros up to what the shader can read, as e45694b does for fixed offsets, and emit plain reads. It keeps Maxwell's semantics and avoids the compiler, at a bandwidth cost of up to 64 KB per slot read at computed offsets, per draw or dispatch.
  3. (Narrow) Emit the plain read where the translator can prove a read in bounds: a clamped index times a constant stride plus a constant base, within the slot's fixed extent. That would have covered this shader, and it keeps the bound elsewhere.

Workaround in use

Our OoT build (cut8h) replaces this one shader's library with the plain-read form, compiled at -O1. It is the "plain reads" row above: every read is statically in bounds here, so it means the same. It is a one-shader replacement in the app's library image, reapplied by a script after each library rebuild.

Open

  • Other games: whether other shaders on other games hit the same miscompile is unknown.
  • How widespread in OoT: 329 of its 42,848 translated shaders carry bounded computed-offset uniform reads: 165 compute kernels and 164 others, about 23,000 guarded reads in all, up to 840 in one shader. Only 9ed48d10 is known to be miscompiled. The others are unchecked; comparing their -O0 and -O1 output on captured frames would check them.
  • Apple: a reduced case (the MSL, the -O1 and -O0 libraries, flags, expected and actual output) is ready for Apple Feedback.

Activity

  1. seedds commented on Nov 12, 2026

    @seedds
    Owner

    Thanks for the careful split. I checked it on an M4 Pro (20-core GPU), macOS 27.2 (26B123), with the same Metal compiler (32023.922), and could not reproduce it there:

    • The MSL main writes for 9ed48d1011f1b48b is byte-identical to the shader in the current select-form library (chain 8d9afacc48e2). Its -O1 LLVM IR is what the source says: each guarded read is a load at the chosen row and a select. There is no unreachable or added poison, and all six froxel writes are kept.
    • Run alone with synthetic inputs, the kernel gives the same three volumes from the -O0 and the -O1 library (largest difference 1.5e-4).
    • In the game, on the Kokiri Forest save: the shipped library shows the haze and light shafts. Against an overlay of this one shader compiled at -O0, the upper half's brightness ratio is 0.98–1.01 (two runs of the shipped form), where your M2 gave 0.27.

    So the translation looks right, and the fault looks like Apple's GPU back end on older GPUs, as in #2127 (M1 only).

    One question decides the next step: does the haze still go missing on your M2 after updating to macOS 27.2 (or later), with the shipped library and no overlay? The GPU compiler ships with the OS, and you are on 27.0. Your library-overlay ratios against bf6035002b98 would be enough, as before.

    If it is still broken on 27.2, the plan is your option 3 in general form: the translator proves the index range of a computed-offset read (a clamp times a constant stride plus a constant base, ranges merged through phis), and a proven read is bounded by the host's padding, as fixed-offset reads are since e45694b, and emitted as a plain read. Only unproven reads keep the in-shader bound. For this shader that should give your "plain reads" row. We would then need you to check it on the M2.

  2. reefbluesky commented on Nov 12, 2026

    @reefbluesky

    @seedds @dpadr answering the question about macOS 27.2, with one more data point.

    Still broken on 27.2. MacBook Pro M2 Pro, macOS 27.2 (26B123), Metal 32023.922, Ocarina of Time 0400486024DEC000 v131072: with the shipped library (main 0d84a3f7 + local commits that do not touch drawing, chain 2dadab744059) the haze and light shafts at the Kokiri Forest exit and in Hyrule Field are gone. A build from main ab41d6a1 (+ #2030 and 2f131edc) draws them; the commit where they vanish for me is 918e9f34.

    One-shader overlay. I took only 9ed48d1011f1b48b (library and reflection, the reflection's chain field set to the host's) from a tree where its computed-offset reads are written as cond ? read : 0 (main 0d84a3f7 with e45694b7, 918e9f34 and adaff735 reverted, chain 204d2bdb369d; 599 such reads against 236 selects in the shipped one) and put it in an overlay folder over the shipped library, with DM_PSO_ARCHIVE=off DM_PSO_BUNDLE=off (log: overlay folder loaded, 0 from the archive, no chain rejection, no [RED]). The haze and light shafts come back, at both spots, with the same save and camera.

    So on this M2 Pro the shader written with the branch form cond ? read : 0 works, while the shipped one with the select form fails, which is a form missing from the table in the issue (that one lists select, cb[cond ? i : 0][c], a row compare and a bitwise mask). The branch form was what 26062f9 produced until 918e9f3 changed it.

    A caveat on what this shows: the overlay differs from the shipped shader in two ways, not one. Besides the 195 computed-offset reads (same count in both, branch form against select), it also guards the fixed-offset reads (they are bare in the shipped one), so I have not isolated which of the two matters, and I have not compiled at -O0 here, so "Apple's compiler" is your inference and the M4 Pro result, not something I measured. I then ran that overlay: I took the shipped 9ed48d1011f1b48b and changed only its 195 uniform reads with a computed offset, from the select form back to the branch form (cond ? read : 0), leaving everything else untouched (the fixed-offset reads stay as they are). The mist comes back in the Hyrule field on the M2 Pro (macOS 27.2). As a control, recompiling the unmodified source reproduces the shipped library byte for byte. So the trigger seems to be the select form on those computed-offset reads, not the fixed reads. This is one short session and one screenshot, so please treat it as a strong hint, not a proof of a compiler bug. A minimal fix could be to emit those reads in the branch form again.

    This is the same symptom as #2276 (fog, light shafts and glow gone on an M1 Max, and the same M2 Pro here): the regression window and the 918e9f34 bisect there point at the same change, and the three mist shaders discussed in that thread (task, mesh and fragment) are not the cause: seven variants of them, an exact-form overlay of the first two and a diff of their MSL changed nothing here, while this one-shader overlay brings the mist back. Logs and the overlay are available.

  3. computerguy526 commented on Nov 12, 2026

    @computerguy526
    Contributor

    I think the M2/M4 split is a threadgroup-size limit, not a miscompile. Data from an M4 Pro (macOS 26.6.2), with offline compiles for the M2 using the Metal toolchain's applegpu-nt (32023.922).

    What happens. On GPUs without Dynamic Caching (applegpu_g13* / applegpu_g14*: M1, M2), a compute pipeline's maxTotalThreadsPerThreadgroup drops as the kernel uses more registers:

    32-bit registers, at most threads per threadgroup
    52 1,024
    104 512
    128 384

    The GPU compiler picks the register count without knowing the threadgroup size. nvn_mtl.m dispatches the shader's local size and never compares it with the pipeline's limit. 9ed48d1011f1b48b is 8x8x8 = 512 threads.

    9ed48d1011f1b48b on applegpu_g14g at -O1 with dmsh_build's flags (registers / spill bytes per thread):

    form g14g (M2) g16s (M4 Pro)
    shipped select form (918e9f3) 128 / 48 96 / 112
    plain reads 104 / 0 96 / 48
    branch form cond ? read : 0 100 / 0 96 / 48
    lower bound only same IR as plain reads (the front end proves i >= 0)
    shipped MSL at -O0 48 / 12016 96 / 11888
    shipped + [[max_total_threads_per_threadgroup(512)]] 104 / 112 96 / 112, GPU code byte-identical to without

    At 128 registers the pipeline allows 384 threads. A 512-thread dispatch is then over the limit, no invocation runs, and the six volumes stay zero. This matches every row of the split table. The working forms all come in at 104 registers or fewer, and plain reads land exactly on the 512-thread budget. Every upper-bound form comes in at 128, which is why the form of the bound didn't matter. -O0 works because it spills down to 48 registers. M3/M4 (g15/g16) top out at 96 registers and allow 1,024 threads, which explains why the M4 Pro could not reproduce it.

    The front end's -O1 IR matches seedds' reading: no unreachable, only benign poison, and all six write_texture_3d calls kept.

    Other froxel kernels. Seven more 8x8x8 froxel kernels in the game are at 124–128 registers on g14g with the select form: f2e5a4e0, 6b65ea05, 2503f644, 109fc917, 2445e600, 3044d755, f01717f6. Under the branch form all are at 72–96. Across four of our main-based libraries, 123 of 363 kernels with more than 384 threads exceed their size's budget on g14g:

    game over budget / kernels above 384 threads
    Ocarina of Time 8 / 20
    PRAGMATA 86 / 255
    Donkey Kong Bananza 1 / 6
    Orbitals 28 / 82

    On g16s none of them changes.

    Proposed fix (translator, general). Emit [[max_total_threads_per_threadgroup(N)]] on every compute kernel whose local size is above 384. Below that size no register count can make the pipeline refuse the dispatch. The front end passes it to the GPU compiler as air.max_work_group_size, and the compiler fits the registers to N, spilling the rest. On g16s the GPU code is byte-identical with or without it, for all 363 large kernels and a 120-kernel sample of small ones. On g13/g14 the attribute also changes the code of about half the kernels that already fit, which is why sizes of 384 and below are left alone. Task and mesh threadgroups here are 32 threads or fewer.

    In game, M4 Pro (Kokiri Forest exit save, main-based build): the haze and light shafts are present, and 9ed48d10 dispatches 16x9x12 groups of 512 threads.
    With these eight kernels replaced by copies that return at once (what an over-limit dispatch does), the haze and light shafts vanish on the M4 too, and the scene's upper half drops to 0.64–0.66 of normal. With the attribute added to the same eight kernels through a side library, the picture is unchanged (0.99–1.01) and frame times are unchanged, as expected from identical GPU code.

    The fix still needs a check on an M1 or M2 (dpadr, reefbluesky): the shipped library plus a one-shader overlay of 9ed48d1011f1b48b built with the attribute added before kernel void main0(. A host-side check of each dispatch against ps.maxTotalThreadsPerThreadgroup, with a red line when it is exceeded, would also make this visible in the log.

  4. reefbluesky commented on Nov 12, 2026

    @reefbluesky

    @computerguy526 confirmed on an M2 Pro (macOS 27.2, Metal 32023.922).

    I took the shipped 9ed48d1011f1b48b (still the select form) and only added [[max_total_threads_per_threadgroup(512)]] before kernel void main0(, as a one-shader overlay with the reflection's chain set to the host's. The haze and light shafts come back in Hyrule Field.

    That fits your threadgroup-limit explanation better than what I wrote above. My earlier overlay, which turned the 195 computed-offset reads from select back into the branch form, also brought the mist back, but I now read that as the lower register count doing it, not the select form itself. So please disregard my "the trigger seems to be the select form" line.

    This is one short session on one machine, so please read it as a single confirmation, not a sweep. I can run other kernels or a host-side check if that helps.

  5. computerguy526 commented on Nov 12, 2026

    @computerguy526
    Contributor

    @reefbluesky thanks, that's the confirmation the offline numbers needed: the select form unchanged, only the attribute added, and the haze is back on the M2 Pro.

    The general fix is up as #2487. The translator adds [[max_total_threads_per_threadgroup(N)]] to every compute kernel whose local size is above 384, and leaves smaller kernels alone. On g16s the GPU code is byte-identical for every kernel I measured, so M3/M4 shouldn't change.

    If you have time for one more check, a library built from #2487 (it changes the chain, so it's a full rebuild) would cover the other seven 8x8x8 froxel kernels as well, not just 9ed48d10. Any spot where volumetrics or light shafts were missing on the M2 since 918e9f34 would be worth a look. Hyrule Field and the Kokiri Forest exit are the two we know.

    On the host-side check: a red line when a dispatch's threadgroup is larger than the pipeline's maxTotalThreadsPerThreadgroup would have named this at once. I'll keep it out of #2487 so that stays one topic, and can follow up with it separately if seedds wants it.

  6. emmanuel-ghz commented on Nov 12, 2026

    @emmanuel-ghz

    Disclaimer: I'm not a developer. The checks below were done by Claude Code (an AI coding assistant) on my machine. Please read this as one user's data point, not a reviewed test.

    #2487's attribute fixes the missing fog on an M1 Pro as well.

    • Machine: M1 Pro (16-core GPU, applegpu_g13s), 16 GB, macOS 27.0.1, Metal 32023.922.
    • Build: Ocarina of Time 0400486024DEC000 v131072, chain b-8c0bedfbc881 (main near 224ecb18).

    What was done. I did not rebuild the chain. Instead:

    1. For the 20 compute kernels with a local size above 384 (from shader_facts.tsv), each shipped <key>_b.metal was taken from cas.db.
    2. [[max_total_threads_per_threadgroup(N)]] was added before kernel void main0(, as in max_threads.
    3. Each was recompiled with MAC_METAL + -fbracket-depth=1024. As a check, an unmodified shader recompiled with these flags came out byte-identical to the cached library.
    4. The broken ones went into Contents/Resources/msl/b-8c0bedfbc881/ as a folder overlay, and the app was re-signed ad-hoc.

    maxTotalThreadsPerThreadgroup measured on this GPU (shipped → with the attribute; function constants zeroed):

    kernel needs shipped with attribute
    109fc917f6f88db5 512 384 512
    2445e600f3887bc2 512 384 512
    2503f644249ad387 512 384 512
    3044d7555ebdd427 512 384 512
    6b65ea05fc1e816c 512 384 512
    9ed48d1011f1b48b 512 384 512
    f01717f643536e86 512 384 512
    f2e5a4e0899b3013 512 384 512
    the other 12 (990c4f53, 739ed280, 21d9f83e at 1024, …) 512–1024 already fit fit

    All 8 failing kernels are from AglShader.sharcb.

    Result in play. Only those 8 were overlaid. Haze, mist and light shafts came back in Kokiri Forest and Lost Woods (the maze's darkness), and the player reports them in almost every area visited. No [RED] lines.

    Side effect on a 16 GB Mac. With the effects drawing again, footprint in Kokiri Forest rose from about 11 to about 13 GB (DM_GFX_SCALE=1, handheld), and the machine started swapping. Going back to Hyrule Field did not recover the frame rate. That fits the earlier observation in #2276 that the broken build used about 3.5 GB less. DM_GUEST_FILE=1 brought footprint to about 6.4 GB and kept ~30 fps. It costs heavy file writes, though: macOS flagged about 2 GB of dirtied file-backed memory in 40 s.

    Separately, with the frame cap at 60 the player had GPU stalls and one full system freeze that needed a forced power-off. The log was lost with the reboot, so this is only a report. At a 30 fps cap it did not happen.

  7. reefbluesky commented on Nov 12, 2026

    @reefbluesky

    @computerguy526 checked #2487 with a full rebuild on an M2 Pro (macOS 27.2, Metal 32023.922), no overlay.

    The tree is main 0d84a3f7 plus #2271 plus the commit of #2487, plus a few local commits that do not touch the shader translation (my base differs from the PR's, so dmsh_chain.h was regenerated: chain 99737dc05205). The haze and light shafts are back at both places we know, the Kokiri Forest exit and Hyrule Field. The log has no [RED] or ErrorHang, and the game holds 30 fps (p99 33.3 ms over 300-frame windows) at about 10 GB footprint on a 32 GB Mac.

    One session on one machine, and I only looked at those two places, so I did not check the other seven froxel kernels one by one. On my older base 4 of the 5 tests in the PR's test file pass; the fifth uses dmsh_chain.MAC_METAL, which my base does not have, so it is not about the patch.

  8. added a commit that references this issue on Nov 13, 2026
    529cf68
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions