From a8ec84e7d659bf1c090719f5f270c34ade6310b9 Mon Sep 17 00:00:00 2001 From: niklas Date: Sun, 1 Mar 2026 23:29:05 +0100 Subject: [PATCH 1/7] tests: add loop-continue shortcut shape regression Add a regression test with a fixed SPIR-V fixture that exercises a loop CFG containing a pass-through body-select arm feeding a body merge and conditional continue. The test lowers, structurizes, links, and lifts the module, then inspects lifted blocks and fails if the shortcut shape remains, because that shape can violate intended edge-local value flow through the loop backedge/exit region. --- .../control_flow_mem2reg_undef_rust.spvbin | Bin 0 -> 1688 bytes tests/regression_loop_continue_shortcut.rs | 193 ++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 tests/data/control_flow_mem2reg_undef_rust.spvbin create mode 100644 tests/regression_loop_continue_shortcut.rs diff --git a/tests/data/control_flow_mem2reg_undef_rust.spvbin b/tests/data/control_flow_mem2reg_undef_rust.spvbin new file mode 100644 index 0000000000000000000000000000000000000000..a4a8ccfd4153ae44a47ce37bd92c012f69573207 GIT binary patch literal 1688 zcmYk6*-lhZ5JgLF3*taC8lwRlAx2Q+5Je)QNKinGA|$?ci1J{hA<<~!o4@0mzvM?a z$HcX6-_l#8`s}JYr`8$hE_$Z=qBs;0&DiEs8;YJVYaKe~l$;}ATH zxe2uUF>W#M7~^r=+(H$*A9EM0K58xN^39FHKEY_8fhs3s7KZsIo;?Q9?tiAL4b^JH zXzwcLj-cH`#ylN_|M3{w{m=2~+3l-&{vUKY$@rZP&rD*!(e8H(x9+&YTKDQ3$kH-* z8ZA%z%skI>2DgXv?LE$@{bHsy;|bjU>Sb=!J2{KzeY$>;`S~{1elmZO(RHpjcHhS4 zzj8wPzPI;jZ|`mfw`Q+t+}_q^acjBn3ux=!|2Np@8LeyX-MX=Pnd*<#-PkMF*7xb$ zPn@rx@e=Od?C&0ud%23eOYiFnqbAdu@l{-=I`8Wmx`EqEzsp*4aJh^qAwdCDF=h^J%+04mH z)*^ZsPu4xOCd-=f5-v+-on{XBb{{G=tX&(Ozy_R^g literal 0 HcmV?d00001 diff --git a/tests/regression_loop_continue_shortcut.rs b/tests/regression_loop_continue_shortcut.rs new file mode 100644 index 00000000..e64c7c02 --- /dev/null +++ b/tests/regression_loop_continue_shortcut.rs @@ -0,0 +1,193 @@ +use std::collections::HashMap; +use std::rc::Rc; + +use spirt::spv; + +struct Block { + label: spv::Id, + insts: Vec, +} + +fn lifted_blocks_from_fixture() -> Vec { + let cx = Rc::new(spirt::Context::new()); + let mut module = spirt::Module::lower_from_spv_bytes( + cx, + include_bytes!("data/control_flow_mem2reg_undef_rust.spvbin").to_vec(), + ) + .unwrap(); + + spirt::passes::link::minimize_exports(&mut module, |export_key| { + matches!(export_key, spirt::ExportKey::SpvEntryPoint { .. }) + }); + spirt::passes::legalize::structurize_func_cfgs(&mut module); + spirt::passes::link::resolve_imports(&mut module); + + let emitted = module.lift_to_spv_module_emitter().unwrap(); + let spv_bytes = emitted.words.iter().flat_map(|word| word.to_le_bytes()).collect::>(); + let parser = spv::read::ModuleParser::read_from_spv_bytes(spv_bytes).unwrap(); + + let wk = &spv::spec::Spec::get().well_known; + + let mut in_first_function = false; + let mut blocks = Vec::new(); + let mut current: Option = None; + + for inst in parser { + let inst = inst.unwrap(); + + if inst.opcode == wk.OpFunction { + if in_first_function { + continue; + } + in_first_function = true; + continue; + } + if !in_first_function { + continue; + } + if inst.opcode == wk.OpFunctionEnd { + if let Some(block) = current.take() { + blocks.push(block); + } + break; + } + if inst.opcode == wk.OpLabel { + if let Some(block) = current.take() { + blocks.push(block); + } + current = Some(Block { label: inst.result_id.unwrap(), insts: Vec::new() }); + continue; + } + + if let Some(block) = &mut current { + block.insts.push(inst); + } + } + + blocks +} + +fn block_is_trivial_branch_to(wk: &spv::spec::WellKnown, block: &Block, target: spv::Id) -> bool { + matches!(block.insts.as_slice(), [terminator] if terminator.opcode == wk.OpBranch && terminator.ids.as_slice() == [target]) +} + +fn block_terminator(block: &Block) -> Option<&spv::InstWithIds> { + block.insts.last() +} + +fn find_bad_loop_continue_shortcut_shape(blocks: &[Block]) -> bool { + let wk = &spv::spec::Spec::get().well_known; + + let by_label = blocks.iter().map(|b| (b.label, b)).collect::>(); + + let loop_merge_and_continue = |block: &Block| { + block.insts.iter().find(|inst| inst.opcode == wk.OpLoopMerge).and_then(|inst| { + match inst.ids.as_slice() { + [loop_merge, loop_continue] => Some((*loop_merge, *loop_continue)), + _ => None, + } + }) + }; + + let selection_merge = |block: &Block| { + block.insts.iter().find(|inst| inst.opcode == wk.OpSelectionMerge).and_then(|inst| { + match inst.ids.as_slice() { + [merge] => Some(*merge), + _ => None, + } + }) + }; + + for header in blocks { + let Some((loop_merge, loop_continue)) = loop_merge_and_continue(header) else { + continue; + }; + + let Some(header_term) = block_terminator(header) else { + continue; + }; + if !(header_term.opcode == wk.OpBranch && header_term.ids.len() == 1) { + continue; + } + let body = header_term.ids[0]; + + let Some(body_block) = by_label.get(&body) else { + continue; + }; + let Some(body_merge) = selection_merge(body_block) else { + continue; + }; + let Some(body_term) = block_terminator(body_block) else { + continue; + }; + if !(body_term.opcode == wk.OpBranchConditional && body_term.ids.len() == 3) { + continue; + } + + let body_cond = body_term.ids[0]; + let body_t0 = body_term.ids[1]; + let body_t1 = body_term.ids[2]; + + let pass_target = if by_label + .get(&body_t0) + .is_some_and(|b| block_is_trivial_branch_to(wk, b, body_merge)) + { + body_t0 + } else if by_label + .get(&body_t1) + .is_some_and(|b| block_is_trivial_branch_to(wk, b, body_merge)) + { + body_t1 + } else { + continue; + }; + + let Some(body_merge_block) = by_label.get(&body_merge) else { + continue; + }; + let Some(body_merge_term) = block_terminator(body_merge_block) else { + continue; + }; + if !(body_merge_term.opcode == wk.OpBranch + && body_merge_term.ids.as_slice() == [loop_continue]) + { + continue; + } + + let Some(loop_continue_block) = by_label.get(&loop_continue) else { + continue; + }; + let Some(loop_continue_term) = block_terminator(loop_continue_block) else { + continue; + }; + if !(loop_continue_term.opcode == wk.OpBranchConditional + && loop_continue_term.ids.len() == 3 + && loop_continue_term.ids[0] == body_cond) + { + continue; + } + + let continue_targets = [loop_continue_term.ids[1], loop_continue_term.ids[2]]; + if !continue_targets.contains(&header.label) || !continue_targets.contains(&loop_merge) { + continue; + } + + eprintln!( + "found shortcut shape: header=%{:?} body=%{:?} pass=%{:?} body_merge=%{:?} continue=%{:?} merge=%{:?}", + header.label, body, pass_target, body_merge, loop_continue, loop_merge, + ); + return true; + } + + false +} + +#[test] +fn no_loop_continue_shortcut_shape_after_lift() { + let blocks = lifted_blocks_from_fixture(); + + assert!( + !find_bad_loop_continue_shortcut_shape(&blocks), + "found loop-continue shortcut shape that triggers control_flow_mem2reg_undef miscompile" + ); +} From 81f6060537a5a622758ebc42a62b7644debb006c Mon Sep 17 00:00:00 2001 From: niklas Date: Mon, 2 Mar 2026 00:14:15 +0100 Subject: [PATCH 2/7] tests: broaden loop-continue shortcut repro coverage Extend the regression to check two independent shortcut-shape fixtures (base + nested-loop variant) and report all offending fixtures in one failure. Also add a non-loop SPIR-V fixture sanity check so the matcher is exercised on unrelated control flow. --- tests/data/basic.frag.glsl.dbg.spvbin | Bin 0 -> 1032 bytes ...loop-continue-shortcut-nested.repro.spvbin | Bin 0 -> 1872 bytes ...in => loop-continue-shortcut.repro.spvbin} | Bin tests/regression_loop_continue_shortcut.rs | 39 ++++++++++++++---- 4 files changed, 31 insertions(+), 8 deletions(-) create mode 100644 tests/data/basic.frag.glsl.dbg.spvbin create mode 100644 tests/data/loop-continue-shortcut-nested.repro.spvbin rename tests/data/{control_flow_mem2reg_undef_rust.spvbin => loop-continue-shortcut.repro.spvbin} (100%) diff --git a/tests/data/basic.frag.glsl.dbg.spvbin b/tests/data/basic.frag.glsl.dbg.spvbin new file mode 100644 index 0000000000000000000000000000000000000000..ecc9e6e22472fdd82fad790c275ff8a158722a21 GIT binary patch literal 1032 zcmZ`&-*3|}5O&wC?NZo4NbrUZ?WGk>+KPeDD&VQAggS<_Y2vMM5;wKx#Fgy?3PR#< z&IuMr5KA*q)&ObiacaEB@USn<6V)xm)J!CtqhHjfR&|_>eJ)Z{FCBgB_ z0U-`porVf?h?rgU_s}vfbBcRt4-iAKW@Y;o)(PuuKjDt|BW`6J%neV2R9VFk{k5v~ z6AM%AejiR{E;FuxP?^Xb*ac67>A_qZD6D|b(k&pQe4eM5TuG$XChUO-(g5a#QV{Db z<9XsC9c6JyL{y|QpKFK;sS@b8g@Dla9XF|PzR1fEqy?t|rx2bkPjyl#@zLm5SSymS zdqQOYR0aPMwpqX~r74HO3-D9;>$Kow^}81kBvBgJWf^+jQ7Md-ItRRMk7|85yBK#w zlB}XGSe*&rCX&u@vjXwOiM#^S>2SB_x-hXAZ^B{V9o=Fk)7kjb#mvTBaWjjQj<05l zuXN$y1VqjwB|L8(=-?uEw2L<|=mJ4X5g(JYEZi*CN}G}Am0Bi1&)(_6HxICF12C78 zI>TVl9eLkL7O0gL>gj*`ARX=A(b693ixRhaiRXQ-!D1yz7Gzv>w2Z_weUuubj8y!z@FI4*}6~y)k6-vr~hCOMX{Kwdn#sTd~b-K0YX&rFB zk0aq)Xrt>naZ>C#&MCKz#@LT{J)9Bl9va2At%X?n7n`+LcuT}Ve~cQ{`-S^!;JCqV W{_|TzQccR;tR0|F?KiT%M*9Ple&hH6 literal 0 HcmV?d00001 diff --git a/tests/data/loop-continue-shortcut-nested.repro.spvbin b/tests/data/loop-continue-shortcut-nested.repro.spvbin new file mode 100644 index 0000000000000000000000000000000000000000..4c995a7247254978864cde2b248e7c86b5b2f465 GIT binary patch literal 1872 zcmYk6Z%b5B5XMj5P5bL=s}Y5^W)f;pRES~9W|A9Ln36_i7b#yvu0m1JyS`5E`bvF> z{uA{3-My1{V)i^U&zyPYoO3hl8ShKkp_EcR<@{=csVA9n{W_G=v2-M@uWsZk?X~>F z``tI&dwFN4v;X#E-r0Y(vzz);R_yb)o(}Ub75h8eulMqo@6tf(E&Fxz_STnEt*Gq< z*NQl!GNGDM8%C^+cNgljLg@^#d(|8J&voMqmDry0p=BI=1C_pl&p2Hw@Gw1(f}6ND zf`Vr;Hwtzh<0f^-Sx?~R=E~T4%uSbl#9Hv`n;Sy=B&&V;%bXfhXqd0#(PIGY{F5a& zSm92Ay{o8gfSn;?o(_eBco^*bGyHmX`)Z#5C!J2S{-DE_N$d~Ud0*i2u1oB7R^LD^ zEplhU>SNW`L=Q~!!@vbV}`3G&r-}FZ@qbE5PvG)N1fPPoy*#&@yu~XYdoKQ zT#e&?J>zHI#XFnDo!6N>Q_Om^jQs|D=Q&o*yOJ}$h1*{o?>rB#;r7xO*lSLm=)C|| zFTSA`ShKfW3%`xqTMcL5D&rPwXf1-{o3dYgQ+JA*1HC0;`8dm6u%@1z@iMNSx>Y^7 z&})O^Z1(eP=G2R`-2?moIJ8#4np$$k_i?q8*-tHVO+2(7f*W{ft%5bR zrfrw4Q@Q%YJH^Q>(hiUe(xd?Js{#XYqgIzl~`C literal 0 HcmV?d00001 diff --git a/tests/data/control_flow_mem2reg_undef_rust.spvbin b/tests/data/loop-continue-shortcut.repro.spvbin similarity index 100% rename from tests/data/control_flow_mem2reg_undef_rust.spvbin rename to tests/data/loop-continue-shortcut.repro.spvbin diff --git a/tests/regression_loop_continue_shortcut.rs b/tests/regression_loop_continue_shortcut.rs index e64c7c02..7d997aea 100644 --- a/tests/regression_loop_continue_shortcut.rs +++ b/tests/regression_loop_continue_shortcut.rs @@ -8,13 +8,9 @@ struct Block { insts: Vec, } -fn lifted_blocks_from_fixture() -> Vec { +fn lifted_blocks_from_spv_fixture(spv_bytes: &[u8]) -> Vec { let cx = Rc::new(spirt::Context::new()); - let mut module = spirt::Module::lower_from_spv_bytes( - cx, - include_bytes!("data/control_flow_mem2reg_undef_rust.spvbin").to_vec(), - ) - .unwrap(); + let mut module = spirt::Module::lower_from_spv_bytes(cx, spv_bytes.to_vec()).unwrap(); spirt::passes::link::minimize_exports(&mut module, |export_key| { matches!(export_key, spirt::ExportKey::SpvEntryPoint { .. }) @@ -184,10 +180,37 @@ fn find_bad_loop_continue_shortcut_shape(blocks: &[Block]) -> bool { #[test] fn no_loop_continue_shortcut_shape_after_lift() { - let blocks = lifted_blocks_from_fixture(); + let fixtures: [(&str, &[u8]); 2] = [ + ( + "loop-continue-shortcut.repro", + include_bytes!("data/loop-continue-shortcut.repro.spvbin"), + ), + ( + "loop-continue-shortcut-nested.repro", + include_bytes!("data/loop-continue-shortcut-nested.repro.spvbin"), + ), + ]; + + let mut offenders = Vec::new(); + for (name, spv_bytes) in fixtures { + let blocks = lifted_blocks_from_spv_fixture(spv_bytes); + if find_bad_loop_continue_shortcut_shape(&blocks) { + offenders.push(name); + } + } + + assert!( + offenders.is_empty(), + "found loop-continue shortcut shape that can mis-handle loop edge values in fixtures: {offenders:?}" + ); +} + +#[test] +fn detector_does_not_trigger_on_non_loop_fixture() { + let blocks = lifted_blocks_from_spv_fixture(include_bytes!("data/basic.frag.glsl.dbg.spvbin")); assert!( !find_bad_loop_continue_shortcut_shape(&blocks), - "found loop-continue shortcut shape that triggers control_flow_mem2reg_undef miscompile" + "detector matched a non-loop fixture unexpectedly" ); } From 710956cc053ec8eb4c3145f21cfe097e6b30067b Mon Sep 17 00:00:00 2001 From: niklas Date: Mon, 2 Mar 2026 00:17:49 +0100 Subject: [PATCH 3/7] tests: add pretest/nested variant fixture matrix Expand the loop-continue shortcut regression to a wider fixture set: pre-test while (const/len), nested pre-test while (const/len), and two control variants (post-test loop and no-loop). The reproducer assertion now reports all offending fixtures, while control fixtures are asserted non-matching to guard detector specificity. --- ...op-continue-shortcut-control-noloop.spvbin | Bin 0 -> 1560 bytes ...-continue-shortcut-control-posttest.spvbin | Bin 0 -> 1612 bytes ...-continue-shortcut-nested-len.repro.spvbin | Bin 0 -> 1872 bytes ...continue-shortcut-pretest-len.repro.spvbin | Bin 0 -> 1688 bytes tests/regression_loop_continue_shortcut.rs | 35 +++++++++++++++--- 5 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 tests/data/loop-continue-shortcut-control-noloop.spvbin create mode 100644 tests/data/loop-continue-shortcut-control-posttest.spvbin create mode 100644 tests/data/loop-continue-shortcut-nested-len.repro.spvbin create mode 100644 tests/data/loop-continue-shortcut-pretest-len.repro.spvbin diff --git a/tests/data/loop-continue-shortcut-control-noloop.spvbin b/tests/data/loop-continue-shortcut-control-noloop.spvbin new file mode 100644 index 0000000000000000000000000000000000000000..f6cc52053bc708dc109f5ded632e05154e85028e GIT binary patch literal 1560 zcmYk6T}u^F6oyxhX`dcVi=wonLR8Whl~|&Q8J_g!SkUbt>iYZy}{wfujSzI-QIqTMNzHuw-YD%i)#H~_x(Zn_EU_7VUk}VLD$-?Q*UFpyU#sJ}R%}n}$uiFRB$UyrUU0go^hJ7}Mt5;-8iHpr zH-mOR#$D!}Wju$QTW(_aV{W18qt>!6-`o`J^NjWxYjQGjT?g%+Gag5KCmBzmWo3K; zZSRaH(e87RU(aYS&2#>w6OS~0&|%9Y_6P00wsGr@8?1FtmvC8H=DdG-+A;Gy%N5)n z&bRkfM(sB&ojC2n~a+Kwq|?_ci-;+7o+!PY%R}m8=cQr?kc^^b@5~^pylTs z-9c-ztQp_M?Jcv-vX-oS=)5!g<(=I}TT9*pwDmmOL$oH(n(-rCp4>LiTJj#F^KAC> zZ06)8YZ2{xnye+XCd-=f6I_%aWBSi=7SB}Z$9 literal 0 HcmV?d00001 diff --git a/tests/data/loop-continue-shortcut-control-posttest.spvbin b/tests/data/loop-continue-shortcut-control-posttest.spvbin new file mode 100644 index 0000000000000000000000000000000000000000..06f735a7fe1278e9f4f6ad5a6071d8b52a058cf6 GIT binary patch literal 1612 zcmYk6Sx*%~5JpSp3a(rch)V=7Vl;7$J1$^EBG;fmH1Typlm{ah5{)Ll`HPHy$sge! z6VI8MGE<~(e^u38r@Lo{VrX_Gid_-Wjdgytu^0+7j$bDt_QjrfzW$Hl{fRhR>!AWu|2IP%Q)-fP)4eH!ReyXll0t+?%~=L1kYk_ z8ts0Jd(7LtFK zduPletM+-5U(aYS&2#>w(Qu z&bRjoM(sBSn_4f7| zpT$2lXE3(zy?$=?dj@S@&Ucun8C|2fXX}}hWv#szYkBr_XxZM2XTQLx*~gmkMch8> zJo_bd2Y0Q$z*=+eGkafd;=I=@#G1XW8DGWiEu(!u){=D%o%d}2yyxp^YstHTww`CX zh1TR*GoHic$!+tjCGR#m&t^Z*W=>wR?x20QlXVZR$+BiVkIRzTW?4(t0yh2znf$|Kx?wB8870pWVTs#t$tr0QcIToWSR3_O4btEZ!%er(3&i3#>==YnQfM} zJXar`Ec?ka=ev@u$7sLzWUZhzS=Nl7;Id@4S=N&E6rC*l$ucLay~bYc*l+PK|6%6v Ef0_1l7XSbN literal 0 HcmV?d00001 diff --git a/tests/data/loop-continue-shortcut-nested-len.repro.spvbin b/tests/data/loop-continue-shortcut-nested-len.repro.spvbin new file mode 100644 index 0000000000000000000000000000000000000000..f6cea38bc4015e7454e1bcc0ed06141855c73b66 GIT binary patch literal 1872 zcmYk6YfDs76oz-6NxM4QXhflnnS>e?6=Ik&ndF2Orle8XLCO!2qfiv|t-sT^{!%}p zJ3-HL=BzwR*4gh`@80WO`{HCh<9#VRlv1jvoUb;RdXgE(uR|#vOGncB>PEiOUdunc z-+i;amv?qL`)@zyo&8rkyQx2A#X7(BbeO+XtnX~U-pgOUO9QF5tk=z3TVGDKVs0T=I6RIh-VZ_>acVK>2D4ij8t$Jhsxo&)+5?fO~w2Xsqpwd_H8M{jb9;W6|a1+-? zP;f8iM#1)D++^Ny#uK=?xiYpNbJJxVu@=1g=7!Kd$!MMaGN;BA8s_VG)EEHU|76Jx zR=87O&no6N!1fR^SBJtuJPfw~8NTk_x|;j{Nu|?_Kd7)}68i(T-xs*N;}UD_)jLp2 zi`-eTdfFG}xtDXeHJoql5k~DdGv$m&aqEj0IZ@B#JRaxi`g!KZ-N^l7{ura{TyJdO z#^%4VL-oCH&(qph@Qb*dwI*j-hiI ztd4VT;Mei!&z|g2&byV19l?6#tBmrRJ;)oIw+Hd3@_y8bwbj0y8*|*Vd$hiD z+@EzEjpKaX<7e*0Gn>Wj*EQ}bdc9f3z5(xfj#0BuIpbTn^~G_|^WYk8Eq#Ht=G2MW z3t;u)4Yk0UwdGp)ZQR;w*!xx)w-`ff5gc#Idhw?26mt&rmWbtJFL%M3dUD3gxO(bV z_2fdY4UWB8&%K#bFZOm1?Ei6St$;PPt;swd9O9aJAH|YRQGxCOEXLr;Pr;g6a>mbawbZO?$%WQ)aA;XiEpuvB*I26>`_}&QYdVYn118Rmc>n+a literal 0 HcmV?d00001 diff --git a/tests/data/loop-continue-shortcut-pretest-len.repro.spvbin b/tests/data/loop-continue-shortcut-pretest-len.repro.spvbin new file mode 100644 index 0000000000000000000000000000000000000000..e29272cb0632024e2e5a1355055320a27b97391b GIT binary patch literal 1688 zcmYk6*-lhZ5JgLF3*taC8lwRlAx2Q+5Je)QNKinGA|$?ci1J{hA<<~!o4@0mzvM?a z$HcX6-_l#8`s}JYr`8$hE_$Z=qBs;0&DiEs8;YJVYaKe~l$;}ATH zxe2uUF>W#M7~^r=+(H$*A9EM0K58xN^39FHKEY_8fhs3s7KZsIo;?Q9?tiAL4b^JH zXzwcLj-cH`#ylN_|M3{w{m=2~+3l-&{vUKY$@rZP&rD*!(e8H(x9+&YTKDQ3$kH-* z8ZA%z%skI>2DgXv?LE$@{bHsy;|bjU>Sb=!J2{KzeY$>;`S~{1elmZO(RHpjcHhS4 zzj8wPzPI;jZ|`mfw`Q+t+}_q^acjBn3ux;t{2T1^jMg>xZr#|tO!dd=ZtUe+->$w- z=YHaR{fw7z_vRe;klf2v>|J_aR~R*!){L*>GSzus*U$~zUiw|unv;{gucPJVTiifv z_O@nx6SudF`kh)!)*L$D#(w!Wx6sy-H;=ZSXSt2m bool { #[test] fn no_loop_continue_shortcut_shape_after_lift() { - let fixtures: [(&str, &[u8]); 2] = [ + let fixtures: [(&str, &[u8]); 4] = [ ( "loop-continue-shortcut.repro", include_bytes!("data/loop-continue-shortcut.repro.spvbin"), ), + ( + "loop-continue-shortcut-pretest-len.repro", + include_bytes!("data/loop-continue-shortcut-pretest-len.repro.spvbin"), + ), ( "loop-continue-shortcut-nested.repro", include_bytes!("data/loop-continue-shortcut-nested.repro.spvbin"), ), + ( + "loop-continue-shortcut-nested-len.repro", + include_bytes!("data/loop-continue-shortcut-nested-len.repro.spvbin"), + ), ]; let mut offenders = Vec::new(); @@ -207,10 +215,25 @@ fn no_loop_continue_shortcut_shape_after_lift() { #[test] fn detector_does_not_trigger_on_non_loop_fixture() { - let blocks = lifted_blocks_from_spv_fixture(include_bytes!("data/basic.frag.glsl.dbg.spvbin")); + let fixtures: [(&str, &[u8]); 3] = [ + ("basic.frag.glsl.dbg", include_bytes!("data/basic.frag.glsl.dbg.spvbin")), + ( + "loop-continue-shortcut-control-posttest", + include_bytes!("data/loop-continue-shortcut-control-posttest.spvbin"), + ), + ( + "loop-continue-shortcut-control-noloop", + include_bytes!("data/loop-continue-shortcut-control-noloop.spvbin"), + ), + ]; - assert!( - !find_bad_loop_continue_shortcut_shape(&blocks), - "detector matched a non-loop fixture unexpectedly" - ); + let mut offenders = Vec::new(); + for (name, spv_bytes) in fixtures { + let blocks = lifted_blocks_from_spv_fixture(spv_bytes); + if find_bad_loop_continue_shortcut_shape(&blocks) { + offenders.push(name); + } + } + + assert!(offenders.is_empty(), "detector matched control fixtures unexpectedly: {offenders:?}"); } From a870372ca9cc217ef838ef019043297aab106d0d Mon Sep 17 00:00:00 2001 From: niklas Date: Mon, 2 Mar 2026 00:29:05 +0100 Subject: [PATCH 4/7] spv/lift: canonicalize loop shortcut before phi materialization Add a guarded CFG canonicalization pass in FuncLifting that rewrites a strict loop shortcut shape before dead-block pruning and OpPhi case collection. Matched shape: loop header branches to a body select with one empty pass-through arm into body merge, body merge branches to loop_continue, and loop_continue conditionally branches to loop header or loop merge on the same boolean condition (or its negation). Rewrite: retarget pass-through arm directly to loop merge, drop the body selection merge marker, and canonicalize loop_continue to an unconditional backedge while preserving only header-target phi payloads. Then recompute predecessor/use counts before block retention. This preserves CFG/phi invariants through explicit guards on terminator kinds, merge topology, predecessor counts, and payload arity, while eliminating a fragile structured-loop encoding that can mis-handle edge-carried values. --- src/spv/lift.rs | 330 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 330 insertions(+) diff --git a/src/spv/lift.rs b/src/spv/lift.rs index 5bbb6551..5612b2e4 100644 --- a/src/spv/lift.rs +++ b/src/spv/lift.rs @@ -342,6 +342,13 @@ enum Merge { }, } +/// Relationship between loop body condition and loop-continue condition. +#[derive(Copy, Clone, PartialEq, Eq)] +enum CondRelation { + Same, + Not, +} + impl<'a> NeedsIdsCollector<'a> { fn alloc_ids( self, @@ -508,6 +515,326 @@ impl FuncAt<'_, Node> { } impl<'a> FuncLifting<'a> { + /// Recompute incoming edge counts for each CFG point in `blocks`. + /// + /// This has to run after control-flow rewrites and before dead-block + /// pruning, to avoid stale predecessor counts. + fn recompute_use_counts( + blocks: &FxIndexMap>, + use_counts: &mut FxHashMap, + ) { + use_counts.clear(); + use_counts.reserve(blocks.len()); + let all_edges = blocks.first().map(|(&entry_point, _)| entry_point).into_iter().chain( + blocks.values().flat_map(|block| { + block + .terminator + .merge + .iter() + .flat_map(|merge| { + let (a, b) = match merge { + Merge::Selection(a) => (a, None), + Merge::Loop { loop_merge: a, loop_continue: b } => (a, Some(b)), + }; + [a].into_iter().chain(b) + }) + .chain(&block.terminator.targets) + .copied() + }), + ); + for target in all_edges { + *use_counts.entry(target).or_default() += 1; + } + } + + /// Return `true` iff `point` is an empty pass-through block branching only + /// to `target`. + fn is_passthrough_branch_to( + blocks: &FxIndexMap>, + point: CfgPoint, + target: CfgPoint, + ) -> bool { + let Some(block) = blocks.get(&point) else { + return false; + }; + block.phis.is_empty() + && block.insts.is_empty() + && block.terminator.attrs == AttrSet::default() + && matches!(&*block.terminator.kind, cfg::ControlInstKind::Branch) + && block.terminator.inputs.is_empty() + && block.terminator.targets.as_slice() == [target] + && block.terminator.target_phi_values.keys().all(|&phi_target| phi_target == target) + && block.terminator.merge.is_none() + } + + fn is_const_opcode(cx: &Context, v: Value, opcode: spec::Opcode) -> bool { + match v { + Value::Const(c) => match &cx[c].kind { + ConstKind::SpvInst { spv_inst_and_const_inputs } => { + spv_inst_and_const_inputs.0.opcode == opcode + } + _ => false, + }, + _ => false, + } + } + + /// Determine whether `continue_cond` is equal to `body_cond` or its + /// logical negation. + fn continue_cond_relation( + cx: &Context, + func_def_body: &'a crate::FuncDefBody, + continue_cond: Value, + body_cond: Value, + ) -> Option { + if continue_cond == body_cond { + return Some(CondRelation::Same); + } + + let wk = &spec::Spec::get().well_known; + match continue_cond { + Value::DataInstOutput(_) => None, + + Value::ControlNodeOutput { control_node, output_idx } => { + let control_node_def = func_def_body.at(control_node).def(); + let ControlNodeKind::Select { kind: SelectionKind::BoolCond, scrutinee, cases } = + &control_node_def.kind + else { + return None; + }; + if *scrutinee != body_cond || cases.len() != 2 { + return None; + } + + let output_idx = output_idx as usize; + let true_case_outputs = &func_def_body.at(cases[0]).def().outputs; + let false_case_outputs = &func_def_body.at(cases[1]).def().outputs; + if output_idx >= true_case_outputs.len() || output_idx >= false_case_outputs.len() { + return None; + } + + let on_true = true_case_outputs[output_idx]; + let on_false = false_case_outputs[output_idx]; + if Self::is_const_opcode(cx, on_true, wk.OpConstantTrue) + && Self::is_const_opcode(cx, on_false, wk.OpConstantFalse) + { + Some(CondRelation::Same) + } else if Self::is_const_opcode(cx, on_true, wk.OpConstantFalse) + && Self::is_const_opcode(cx, on_false, wk.OpConstantTrue) + { + Some(CondRelation::Not) + } else { + None + } + } + + _ => None, + } + } + + /// Rewrite `loop_continue` into an unconditional backedge while preserving + /// only phi payloads for the loop header edge. + fn rewrite_continue_as_unconditional_backedge( + blocks: &mut FxIndexMap>, + loop_continue: CfgPoint, + ) { + let continue_block = blocks.get_mut(&loop_continue).unwrap(); + continue_block.terminator.kind = Cow::Owned(cfg::ControlInstKind::Branch); + continue_block.terminator.inputs = [].into_iter().collect(); + let header_point = continue_block.terminator.targets[0]; + continue_block.terminator.targets = [header_point].into_iter().collect(); + continue_block.terminator.target_phi_values.retain(|&target, _| target == header_point); + } + + /// Canonicalize strict loop shortcut patterns: + /// * loop header branches to a body select, + /// * one body arm is an empty pass-through to body merge, + /// * body merge branches to `loop_continue`, + /// * `loop_continue` conditionally branches to header/merge. + /// + /// Rewriting the pass-through arm directly to `loop_merge` avoids + /// preserving this fragile shape in lifted CFG. + fn canonicalize_loop_continue_shortcuts( + cx: &Context, + func_def_body: &'a crate::FuncDefBody, + blocks: &mut FxIndexMap>, + ) { + let mut loop_continue_shortcuts = + SmallVec::<[(CfgPoint, CfgPoint, CfgPoint, CfgPoint, bool); 4]>::new(); + for (&header_point, header_block) in &*blocks { + let header_term = &header_block.terminator; + let Some(Merge::Loop { loop_merge, loop_continue }) = header_term.merge else { + continue; + }; + if header_term.attrs != AttrSet::default() + || !matches!(&*header_term.kind, cfg::ControlInstKind::Branch) + || !header_term.inputs.is_empty() + || header_term.targets.len() != 1 + || !header_term.target_phi_values.is_empty() + { + continue; + } + + let body_point = header_term.targets[0]; + let Some(body_block) = blocks.get(&body_point) else { + continue; + }; + let body_term = &body_block.terminator; + let Some(Merge::Selection(body_merge)) = body_term.merge else { + continue; + }; + if body_term.attrs != AttrSet::default() + || !matches!( + &*body_term.kind, + cfg::ControlInstKind::SelectBranch(SelectionKind::BoolCond) + ) + || body_term.inputs.len() != 1 + || body_term.targets.len() != 2 + || !body_term.target_phi_values.is_empty() + { + continue; + } + + let Some(continue_block) = blocks.get(&loop_continue) else { + continue; + }; + if !continue_block.phis.is_empty() || !continue_block.insts.is_empty() { + continue; + } + let continue_term = &continue_block.terminator; + if continue_term.attrs != AttrSet::default() + || !matches!( + &*continue_term.kind, + cfg::ControlInstKind::SelectBranch(SelectionKind::BoolCond) + ) + || continue_term.inputs.len() != 1 + || continue_term.targets.as_slice() != [header_point, loop_merge] + || continue_term.target_phi_values.keys().any(|&target| target != header_point) + || continue_term.merge.is_some() + { + continue; + } + + let t0 = body_term.targets[0]; + let t1 = body_term.targets[1]; + let (_work_target, pass_target) = + if Self::is_passthrough_branch_to(blocks, t0, body_merge) { + (t1, t0) + } else if Self::is_passthrough_branch_to(blocks, t1, body_merge) { + (t0, t1) + } else { + continue; + }; + let Some(cond_relation) = Self::continue_cond_relation( + cx, + func_def_body, + continue_term.inputs[0], + body_term.inputs[0], + ) else { + continue; + }; + let continue_routes_work_to_header = match cond_relation { + CondRelation::Same => pass_target == t1, + CondRelation::Not => pass_target == t0, + }; + if !continue_routes_work_to_header { + continue; + } + + let body_merge_preds: SmallVec<[CfgPoint; 4]> = blocks + .iter() + .filter_map(|(&point, block)| { + block.terminator.targets.contains(&body_merge).then_some(point) + }) + .collect(); + if body_merge_preds.len() != 2 || !body_merge_preds.contains(&pass_target) { + continue; + } + let Some(other_body_merge_pred) = + body_merge_preds.into_iter().find(|&point| point != pass_target) + else { + continue; + }; + + let continue_pred_count = blocks + .values() + .filter(|block| block.terminator.targets.contains(&loop_continue)) + .count(); + if continue_pred_count != 1 { + continue; + } + + let Some(loop_merge_block) = blocks.get(&loop_merge) else { + continue; + }; + if !loop_merge_block.phis.is_empty() { + continue; + } + + let Some(body_merge_block) = blocks.get(&body_merge) else { + continue; + }; + let merge_term = &body_merge_block.terminator; + if merge_term.attrs != AttrSet::default() + || !matches!(&*merge_term.kind, cfg::ControlInstKind::Branch) + || !merge_term.inputs.is_empty() + || merge_term.targets.as_slice() != [loop_continue] + || !merge_term.target_phi_values.is_empty() + || merge_term.merge.is_some() + { + continue; + } + + let body_merge_phi_count = body_merge_block.phis.len(); + let payload_arity_to = |source: CfgPoint, target: CfgPoint| { + blocks + .get(&source) + .and_then(|block| block.terminator.target_phi_values.get(&target)) + .map_or(0, |values| values.len()) + }; + if payload_arity_to(pass_target, body_merge) != body_merge_phi_count + || payload_arity_to(other_body_merge_pred, body_merge) != body_merge_phi_count + { + continue; + } + + let header_phi_count = header_block.phis.len(); + let continue_payload_arity = + continue_term.target_phi_values.get(&header_point).map_or(0, |values| values.len()); + if continue_payload_arity != header_phi_count { + continue; + } + + loop_continue_shortcuts.push(( + body_point, + pass_target, + loop_merge, + loop_continue, + continue_routes_work_to_header, + )); + } + for ( + body_point, + pass_target, + loop_merge, + loop_continue, + rewrite_continue_to_unconditional_backedge, + ) in loop_continue_shortcuts + { + let body_block = blocks.get_mut(&body_point).unwrap(); + body_block.terminator.merge = None; + for target in &mut body_block.terminator.targets { + if *target == pass_target { + *target = loop_merge; + } + } + + if rewrite_continue_to_unconditional_backedge { + Self::rewrite_continue_as_unconditional_backedge(blocks, loop_continue); + } + } + } + fn from_func_decl( cx: &Context, func_decl: &'a FuncDecl, @@ -913,6 +1240,9 @@ impl<'a> FuncLifting<'a> { } } + Self::canonicalize_loop_continue_shortcuts(cx, func_def_body, &mut blocks); + Self::recompute_use_counts(&blocks, &mut use_counts); + // Remove now-unused blocks. blocks.retain(|point, _| use_counts.get(point).is_some_and(|&count| count > 0)); From ac46bd735f5f7cb57ea483251d37aec220c771c6 Mon Sep 17 00:00:00 2001 From: niklas Date: Mon, 2 Mar 2026 02:29:33 +0100 Subject: [PATCH 5/7] tests: add semantic loop-carried undef regression detector --- tests/regression_loop_continue_shortcut.rs | 227 ++++++++++++++++++++- 1 file changed, 221 insertions(+), 6 deletions(-) diff --git a/tests/regression_loop_continue_shortcut.rs b/tests/regression_loop_continue_shortcut.rs index 4d8decb8..293058bd 100644 --- a/tests/regression_loop_continue_shortcut.rs +++ b/tests/regression_loop_continue_shortcut.rs @@ -8,7 +8,12 @@ struct Block { insts: Vec, } -fn lifted_blocks_from_spv_fixture(spv_bytes: &[u8]) -> Vec { +struct LiftedFixture { + blocks: Vec, + undef_ids: std::collections::BTreeSet, +} + +fn lifted_fixture_from_spv_fixture(spv_bytes: &[u8]) -> LiftedFixture { let cx = Rc::new(spirt::Context::new()); let mut module = spirt::Module::lower_from_spv_bytes(cx, spv_bytes.to_vec()).unwrap(); @@ -27,10 +32,17 @@ fn lifted_blocks_from_spv_fixture(spv_bytes: &[u8]) -> Vec { let mut in_first_function = false; let mut blocks = Vec::new(); let mut current: Option = None; + let mut undef_ids = std::collections::BTreeSet::new(); for inst in parser { let inst = inst.unwrap(); + if inst.opcode == wk.OpUndef { + if let Some(result_id) = inst.result_id { + undef_ids.insert(result_id); + } + } + if inst.opcode == wk.OpFunction { if in_first_function { continue; @@ -60,7 +72,7 @@ fn lifted_blocks_from_spv_fixture(spv_bytes: &[u8]) -> Vec { } } - blocks + LiftedFixture { blocks, undef_ids } } fn block_is_trivial_branch_to(wk: &spv::spec::WellKnown, block: &Block, target: spv::Id) -> bool { @@ -178,6 +190,151 @@ fn find_bad_loop_continue_shortcut_shape(blocks: &[Block]) -> bool { false } +fn find_loop_carried_undef_from_shortcut( + blocks: &[Block], + undef_ids: &std::collections::BTreeSet, +) -> bool { + let wk = &spv::spec::Spec::get().well_known; + + let by_label = blocks.iter().map(|b| (b.label, b)).collect::>(); + + let loop_merge_and_continue = |block: &Block| { + block.insts.iter().find(|inst| inst.opcode == wk.OpLoopMerge).and_then(|inst| { + match inst.ids.as_slice() { + [loop_merge, loop_continue] => Some((*loop_merge, *loop_continue)), + _ => None, + } + }) + }; + + let selection_merge = |block: &Block| { + block.insts.iter().find(|inst| inst.opcode == wk.OpSelectionMerge).and_then(|inst| { + match inst.ids.as_slice() { + [merge] => Some(*merge), + _ => None, + } + }) + }; + + for header in blocks { + let Some((loop_merge, loop_continue)) = loop_merge_and_continue(header) else { + continue; + }; + + let Some(header_term) = block_terminator(header) else { + continue; + }; + if !(header_term.opcode == wk.OpBranch && header_term.ids.len() == 1) { + continue; + } + let body = header_term.ids[0]; + + let Some(body_block) = by_label.get(&body) else { + continue; + }; + let Some(body_merge) = selection_merge(body_block) else { + continue; + }; + let Some(body_term) = block_terminator(body_block) else { + continue; + }; + if !(body_term.opcode == wk.OpBranchConditional && body_term.ids.len() == 3) { + continue; + } + + let body_cond = body_term.ids[0]; + let body_t0 = body_term.ids[1]; + let body_t1 = body_term.ids[2]; + + let (pass_target, _work_target) = if by_label + .get(&body_t0) + .is_some_and(|b| block_is_trivial_branch_to(wk, b, body_merge)) + { + (body_t0, body_t1) + } else if by_label + .get(&body_t1) + .is_some_and(|b| block_is_trivial_branch_to(wk, b, body_merge)) + { + (body_t1, body_t0) + } else { + continue; + }; + + let Some(body_merge_block) = by_label.get(&body_merge) else { + continue; + }; + let Some(body_merge_term) = block_terminator(body_merge_block) else { + continue; + }; + if !(body_merge_term.opcode == wk.OpBranch + && body_merge_term.ids.as_slice() == [loop_continue]) + { + continue; + } + + let Some(loop_continue_block) = by_label.get(&loop_continue) else { + continue; + }; + let Some(loop_continue_term) = block_terminator(loop_continue_block) else { + continue; + }; + if !(loop_continue_term.opcode == wk.OpBranchConditional + && loop_continue_term.ids.len() == 3 + && loop_continue_term.ids[0] == body_cond) + { + continue; + } + let continue_targets = [loop_continue_term.ids[1], loop_continue_term.ids[2]]; + if !continue_targets.contains(&header.label) || !continue_targets.contains(&loop_merge) { + continue; + } + + let mut backedge_values = std::collections::BTreeSet::new(); + for inst in &header.insts { + if inst.opcode != wk.OpPhi { + continue; + } + for pair in inst.ids.chunks_exact(2) { + let incoming_value = pair[0]; + let incoming_pred = pair[1]; + if incoming_pred == loop_continue { + backedge_values.insert(incoming_value); + } + } + } + + for inst in &body_merge_block.insts { + if inst.opcode != wk.OpPhi { + continue; + } + let Some(phi_result) = inst.result_id else { + continue; + }; + if !backedge_values.contains(&phi_result) { + continue; + } + + for pair in inst.ids.chunks_exact(2) { + let incoming_value = pair[0]; + let incoming_pred = pair[1]; + if incoming_pred != pass_target { + continue; + } + + if undef_ids.contains(&incoming_value) { + eprintln!( + "found loop-carried undef via shortcut: header=%{:?} body=%{:?} pass=%{:?} body_merge=%{:?} continue=%{:?} phi=%{:?}", + header.label, body, pass_target, body_merge, loop_continue, phi_result + ); + return true; + } + } + } + } + + false +} + #[test] fn no_loop_continue_shortcut_shape_after_lift() { let fixtures: [(&str, &[u8]); 4] = [ @@ -201,8 +358,8 @@ fn no_loop_continue_shortcut_shape_after_lift() { let mut offenders = Vec::new(); for (name, spv_bytes) in fixtures { - let blocks = lifted_blocks_from_spv_fixture(spv_bytes); - if find_bad_loop_continue_shortcut_shape(&blocks) { + let fixture = lifted_fixture_from_spv_fixture(spv_bytes); + if find_bad_loop_continue_shortcut_shape(&fixture.blocks) { offenders.push(name); } } @@ -229,11 +386,69 @@ fn detector_does_not_trigger_on_non_loop_fixture() { let mut offenders = Vec::new(); for (name, spv_bytes) in fixtures { - let blocks = lifted_blocks_from_spv_fixture(spv_bytes); - if find_bad_loop_continue_shortcut_shape(&blocks) { + let fixture = lifted_fixture_from_spv_fixture(spv_bytes); + if find_bad_loop_continue_shortcut_shape(&fixture.blocks) { offenders.push(name); } } assert!(offenders.is_empty(), "detector matched control fixtures unexpectedly: {offenders:?}"); } + +#[test] +fn no_loop_carried_undef_from_shortcut_after_lift() { + let repro_fixtures: [(&str, &[u8]); 4] = [ + ( + "loop-continue-shortcut.repro", + include_bytes!("data/loop-continue-shortcut.repro.spvbin"), + ), + ( + "loop-continue-shortcut-pretest-len.repro", + include_bytes!("data/loop-continue-shortcut-pretest-len.repro.spvbin"), + ), + ( + "loop-continue-shortcut-nested.repro", + include_bytes!("data/loop-continue-shortcut-nested.repro.spvbin"), + ), + ( + "loop-continue-shortcut-nested-len.repro", + include_bytes!("data/loop-continue-shortcut-nested-len.repro.spvbin"), + ), + ]; + + let mut offenders = Vec::new(); + for (name, spv_bytes) in repro_fixtures { + let fixture = lifted_fixture_from_spv_fixture(spv_bytes); + if find_loop_carried_undef_from_shortcut(&fixture.blocks, &fixture.undef_ids) { + offenders.push(name); + } + } + + assert!( + offenders.is_empty(), + "found loop-carried undef values through shortcut fixtures: {offenders:?}" + ); + + let control_fixtures: [(&str, &[u8]); 3] = [ + ("basic.frag.glsl.dbg", include_bytes!("data/basic.frag.glsl.dbg.spvbin")), + ( + "loop-continue-shortcut-control-posttest", + include_bytes!("data/loop-continue-shortcut-control-posttest.spvbin"), + ), + ( + "loop-continue-shortcut-control-noloop", + include_bytes!("data/loop-continue-shortcut-control-noloop.spvbin"), + ), + ]; + let mut false_positives = Vec::new(); + for (name, spv_bytes) in control_fixtures { + let fixture = lifted_fixture_from_spv_fixture(spv_bytes); + if find_loop_carried_undef_from_shortcut(&fixture.blocks, &fixture.undef_ids) { + false_positives.push(name); + } + } + assert!( + false_positives.is_empty(), + "semantic detector matched control fixtures unexpectedly: {false_positives:?}" + ); +} From 1f6227779b832ffdac635a40a547fe5e76f16f4d Mon Sep 17 00:00:00 2001 From: niklas Date: Mon, 2 Mar 2026 09:14:45 +0100 Subject: [PATCH 6/7] spv/lift: adapt NodeOutput matching after main rebase --- src/spv/lift.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/spv/lift.rs b/src/spv/lift.rs index 5612b2e4..c3e41946 100644 --- a/src/spv/lift.rs +++ b/src/spv/lift.rs @@ -595,10 +595,10 @@ impl<'a> FuncLifting<'a> { match continue_cond { Value::DataInstOutput(_) => None, - Value::ControlNodeOutput { control_node, output_idx } => { - let control_node_def = func_def_body.at(control_node).def(); - let ControlNodeKind::Select { kind: SelectionKind::BoolCond, scrutinee, cases } = - &control_node_def.kind + Value::NodeOutput { node, output_idx } => { + let node_def = func_def_body.at(node).def(); + let NodeKind::Select { kind: SelectionKind::BoolCond, scrutinee, cases } = + &node_def.kind else { return None; }; From 0e40966f27468d4896b51e28ea0e9080a8300bbe Mon Sep 17 00:00:00 2001 From: niklas Date: Tue, 3 Mar 2026 09:08:03 +0100 Subject: [PATCH 7/7] spv/lift: remove redundant continue_cond match arm --- src/spv/lift.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/spv/lift.rs b/src/spv/lift.rs index c3e41946..257e9f1b 100644 --- a/src/spv/lift.rs +++ b/src/spv/lift.rs @@ -593,8 +593,6 @@ impl<'a> FuncLifting<'a> { let wk = &spec::Spec::get().well_known; match continue_cond { - Value::DataInstOutput(_) => None, - Value::NodeOutput { node, output_idx } => { let node_def = func_def_body.at(node).def(); let NodeKind::Select { kind: SelectionKind::BoolCond, scrutinee, cases } =