Skip to content

fix(encoder): A32 i64 mul/shift/rotate/compare + tripwire — no silent-NOP arm survives (#615)#620

Merged
avrabe merged 4 commits into
mainfrom
fix/615-a32-i64-stubs
Jul 7, 2026
Merged

fix(encoder): A32 i64 mul/shift/rotate/compare + tripwire — no silent-NOP arm survives (#615)#620
avrabe merged 4 commits into
mainfrom
fix/615-a32-i64-stubs

Conversation

@avrabe

@avrabe avrabe commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Closes #615.

Root cause

The A32 (--target cortex-r5 / armv7r, IsaVariant::Arm32) arm of ArmEncoder::encode still carried verification-era "encode as NOP for now" arms: every i64 op (mul, shifts, rotates, all ten comparisons, eqz, clz/ctz/popcnt, div/rem, const, load/store, extends, wrap) plus the i32 SetCond/SelectMove/Popcnt pseudo-ops encoded to the literal NOP word 0xE1A00000. Once cortex-r5 made ARM mode user-reachable, the operation silently vanished — functions returned whatever garbage sat in the result registers, with no diagnostic (validate_instructions only gates FPU/MVE).

Class history: #594 (A32 call_indirect → NOP, call never happened) → #610/#613 (Thumb i64 rot/div silently zero) → #615 (every A32 i64 op → NOP).

What this PR does

1. A32 expansion helper (encode_arm_expanded), wired as an early return in encode_arm — mirrors each Thumb-2 twin's register contract exactly:

Op family A32 implementation
SetCond, SelectMove conditional MOV<c> (A32 conditional execution replaces IT blocks)
I64SetCond (10 conds), I64SetCondZ, I64Eq..I64GeU, I64Eqz CMP lo,lo + SBCS hi,hi idiom w/ operand swap; ORRS for eqz
I64Mul MUL/MLA cross products into R12 + UMULL + ADD
I64Shl/ShrU/ShrS mask-63 + SUBS/BPL small/large split
I64Rotl/Rotr #610 fixed-ABI wrapper (value R0:R1, amount R2) + word-table core
I64DivU/DivS/RemU/RemS A32 transcription of the Thumb #613 fixed-ABI arms: marshal, ORRS R2,R3 + UDF #0 zero-divisor trap, 64-round shift-subtract core, signed variants negate/re-sign
I64Clz/Ctz CLZ/RBIT + conditional execution
I64Const/Ldr/Str/Extend*/I32WrapI64 MOVW/MOVT, word pairs w/ imm12 range check (Err on overflow), SXTB/SXTH + ASR #31
I64Add/Sub/And/Or/Xor ADDS+ADC / SUBS+SBC / word pairs
Popcnt, I64Popcnt bit-twiddle expansions transcribed from the Thumb arms

New free helpers emit_a32_i64_fixed_abi_entry/exit + emit_a32_i64_divisor_zero_trap are A32 twins of the #610 Thumb wrappers (same marshal contract, STMDB SP!/LDR [SP],#4 encodings, same swapped-pair Err).

2. No silent NOP survives. The former NOP arms are now:

  • unreachable!("handled by encode_arm_expanded (#615)") for everything the helper covers (the call_indirect on the A32 path (--target cortex-r5) compiles to a NOP — silently returns wrong result instead of calling the target #594 CallIndirect pattern), and
  • typed Err for ops with no A32 encoding: the verification-only pseudo-ops (Select, LocalGet/Set/Tee, GlobalGet/Set, BrTable, Call — modeled by synth-verify's ArmSemantics, never constructed by any codegen path; Err rather than unreachable! keeps the encoder total per the encoder_no_panic Ok-or-Err contract) and the 41 MVE ops (Helium is Thumb-2-only; the selector only emits MVE for M55). Loud-Err'd, not implemented, because no A32 target can legally reach them — and if one ever does, it now fails with a message instead of shipping wrong code.

3. Tripwire test (a32_no_silent_nop_615.rs) — the structural payoff: an exhaustive no-wildcard match over all 221 ArmOp variants (adding a variant fails compilation until classified) plus one non-degenerate representative instance per variant, asserting the A32 encoding is never the bare NOP word — except the documented allowlist (ArmOp::Nop, Mov rd,rd, I32WrapI64 rd==rnlo, Lsl rd,rd,#0, all genuine no-ops). Err/unreachable-covered ops count as PASS (they are loud). The class cannot silently regrow.

Verification

Clang cross-check (#544 pattern): 24 representative encodings (SBCS, UMULL, CLZNE, register-controlled LSL, RBIT, MOVT, ORR lsr#31, STR [SP,#-4]!, LDR [SP],#4, MUL, MLA, SBC, ADC, MOVW, AND #63, RSB #32, SXTB, MVN, ADDS #1, ORRS, CMP, SUBS #1, PUSH, POP) assembled with clang -target armv7r-none-eabiall 24 match bit-for-bit.

Branch-offset audit: hardcoded A32 branch words re-derived against instruction counts (target = PC+8+imm24*4): shift BPL +5 skips the 6-insn small path onto .large, B +1 skips the 2-insn large path; rot BPL/B +7 skip the 8-insn halves; div-loop BHI +2/BLO +4/BLO +2 land on subtract/decrement; trap BNE +0 skips exactly the UDF. All consistent (and execution-verified below).

Execution differential (scripts/repro/a32_i64_615_differential.py): unicorn UC_MODE_ARM (not Thumb) vs the wasmtime oracle, symbols from the ELF symtab (#489), module compiled --target cortex-r5 --all-exports --relocatable --no-optimize. 40 exported functions covering every op above; vectors include 0x8000000000000000, -1, 0, 1, lo/hi straddles, shift/rot amounts {0,1,31,32,33,63,65}:

352 cases, 0 mismatches
ORACLE: PASS — A32 (cortex-r5) matches wasmtime on all vectors

Red→green: the same script against a pre-fix build (origin/main encoder, same harness):

MISMATCH mul64(...): synth=<garbage> wasmtime=<product>   (… 254 total)
MISMATCH const64(): synth=0x0 wasmtime=0x123456789abcdef0
MISMATCH lt_s32(1, 2): synth=0x0 wasmtime=0x1
MISMATCH sel32(11, 22, 1): synth=0x16 wasmtime=0xb
352 cases, 254 mismatches
ORACLE: FAIL — A32 i64 codegen diverges from wasmtime (#615)

Pre-fix ground truth (issue evidence, reconfirmed): mul64 body contained mov r0, r0 where the multiply belongs. Post-fix: mul ip,r4,r3; mla ip,r5,r2,ip; umull r6,r7,r4,r2; add r7,r7,ip.

Frozen anchors: cargo test -p synth-cli --test frozen_codegen_bytes — 9/9 pass, Thumb bytes byte-identical (this change only touches the A32 path).

Suites: cargo test -p synth-backend 214+ green (one unit test updated: test_encode_mve_arm32_nop pinned the old silent-NOP MVE behavior and now asserts the loud Err); full workspace green (--exclude synth-verify, local z3 quirk); fmt + clippy clean.

🤖 Generated with Claude Code

avrabe and others added 4 commits July 7, 2026 20:24
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-NOP arm survives (#615)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@avrabe avrabe merged commit e5a6d1a into main Jul 7, 2026
28 checks passed
@avrabe avrabe deleted the fix/615-a32-i64-stubs branch July 7, 2026 19:42
@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.99625% with 24 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/synth-backend/src/arm_encoder.rs 96.99% 24 Missing ⚠️

📢 Thoughts on this report? Let us know!

avrabe added a commit that referenced this pull request Jul 7, 2026
…622)

Pin sweep 0.30.1 -> 0.30.2 + CHANGELOG.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A32 encoder silently NOPs i64 mul/shl/shr/rotl/rotr/compare/eqz (--target cortex-r5) — returns garbage instead of computing or rejecting

1 participant