disasm: ISA-conformance fixes found by differential review

Reviewed the PPC disassembler against two independent oracles: xenia-canary's
authoritative encoding table, and capstone 5.0.7 (PPC64 big-endian) run over
all 1,859,397 instructions in the reference title.

Decoding was already sound — our tables reproduce all 455 entries of canary's
`ppc_opcode_table_gen.cc` exactly, and every word we render as `.long` is
genuinely not an instruction (9,332 zero padding + 388 with reserved primary
opcode 0). The defects were all in the text layer:

- `mfocrf`/`mtocrf` were printed as `mfcr`/`mtcrf`. They share XO with the
  wide forms and differ only in bit 11; the one-field form also carries an FXM
  operand naming which CR field is touched. 163 of 165 `mfcr` sites in this
  title are really `mfocrf`, so the disassembly was dropping that operand
  entirely. (Canary folds both into one handler because the wide read is a safe
  superset at runtime — a disassembler cannot.)
- `rlwinm rA,rS,0,0,31` produced no simplified form: every branch in
  `fmt_rlwinm` was gated on `sh > 0`, but rotate-by-zero under a full mask is
  still `slwi rA,rS,0`. It is the single most common `rlwinm` encoding here —
  3,720 sites.
- `vor vD,vA,vA` and `vnor vD,vA,vA` are the vector move and complement
  (`vmr`/`vnot`); 1,544 sites were left in base form. The guarded arms have to
  precede the catch-all VX group or they are unreachable.
- Combined CTR+condition branches emitted `bdnzne`, a mnemonic assembled by
  nothing. PowerISA names these `bdnzt`/`bdnzf`/`bdzt`/`bdzf` with the CR bit
  as an operand.
- Static branch-prediction hints were discarded. The `at` bits in `BO`
  (`001at`, `011at`, `1a00t`, `1a01t`) are the only record of the compiler's
  prediction, and are now rendered as the ISA's `+`/`-` suffix.
- `twi`/`tdi` with TO=31 had no simplified form; the register form already had
  `trap`, the immediate form's counterpart is `twui`/`tdui`. Also adds the
  missing TO=3 (`lne`) row.

Capstone disagreements fall from 5,596 to 135, and every survivor is ours
being right for the target or a free choice of name:
  - `dcbz128` vs `dcbzl`   (55) — same encoding, Xenon name vs POWER name
  - `lvx128`, `vsldoi128`  (70) — primary opcode 4 is the VMX128 space on
    Xenon; capstone applies POWER9, which reuses it for
    `vcmpequd`/`maddhd`/`maddld`
  - `slwi.` vs `rotlwi.`   (10) — both name rotate-0/full-mask; capstone is
    itself inconsistent here, using `slwi` when Rc=0

Adds `xenia-cpu --example decode_table_check`, which replays canary's table
through our decoder (455/455), and 9 regression tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-10 20:32:58 +02:00
parent ac20afb1a9
commit 8401d4d511
2 changed files with 301 additions and 14 deletions

View File

@@ -0,0 +1,37 @@
//! Cross-check our decoder against xenia-canary's authoritative encoding table.
//!
//! Canary's `ppc_opcode_table_gen.cc` lists, for every opcode it knows, a
//! representative instruction word with the operand fields zeroed. Feeding each
//! word to our decoder must yield the matching opcode — anything else is a hole
//! or a mis-decode in our tables.
//!
//! ```text
//! cargo run --release -p xenia-cpu --example decode_table_check -- <table.txt>
//! ```
//! where each line is `0xWORD name`.
use std::io::BufRead;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let path = std::env::args().nth(1).ok_or("usage: decode_table_check <table>")?;
let f = std::io::BufReader::new(std::fs::File::open(path)?);
let (mut ok, mut bad, mut invalid) = (0u32, 0u32, 0u32);
for line in f.lines() {
let line = line?;
let mut it = line.split_whitespace();
let (Some(w), Some(name)) = (it.next(), it.next()) else { continue };
let word = u32::from_str_radix(w.trim_start_matches("0x"), 16)?;
let d = xenia_cpu::decoder::decode(word, 0x8200_0000);
let got = format!("{:?}", d.opcode);
if got == name {
ok += 1;
} else if got == "Invalid" {
invalid += 1;
println!("MISSING {w} {name:<14} -> Invalid");
} else {
bad += 1;
println!("MISMATCH {w} {name:<14} -> {got}");
}
}
println!("\nmatched {ok}, mismatched {bad}, missing {invalid}");
Ok(())
}

View File

@@ -127,6 +127,7 @@ fn trap_cond(to: u32) -> Option<&'static str> {
match to {
1 => Some("lgt"),
2 => Some("llt"),
3 => Some("lne"),
4 => Some("eq"),
5 => Some("lge"),
6 => Some("lle"),
@@ -298,7 +299,7 @@ pub fn format(instr: &DecodedInstr) -> DisasmText {
// ── Special register moves ─────────────────────────────────────────
PpcOpcode::mfspr => fmt_mfspr(instr),
PpcOpcode::mtspr => fmt_mtspr(instr),
PpcOpcode::mfcr => base("mfcr", gpr(instr.rd()), 8),
PpcOpcode::mfcr => fmt_mfcr(instr),
PpcOpcode::mtcrf => fmt_mtcrf(instr),
PpcOpcode::mfmsr => base("mfmsr", gpr(instr.rd()), 8),
PpcOpcode::mtmsr => base("mtmsr", gpr(instr.rs()), 8),
@@ -441,6 +442,16 @@ pub fn format(instr: &DecodedInstr) -> DisasmText {
// ── Standard VMX (5-bit registers) ────────────────────────────────
// 3-operand VD, VA, VB
// `vor vD,vA,vA` is the canonical vector register move, and
// `vnor vD,vA,vA` the canonical vector complement. Both are extremely
// common (1,535 and 9 sites here) and both read as noise in base form.
PpcOpcode::vor if instr.ra() == instr.rb() => {
fmt_vmx_move(instr, "vor", "vmr")
}
PpcOpcode::vnor if instr.ra() == instr.rb() => {
fmt_vmx_move(instr, "vnor", "vnot")
}
PpcOpcode::vaddubm | PpcOpcode::vmaxub | PpcOpcode::vrlb | PpcOpcode::vmuloub |
PpcOpcode::vaddfp | PpcOpcode::vmrghb | PpcOpcode::vpkuhum |
PpcOpcode::vadduhm | PpcOpcode::vmaxuh | PpcOpcode::vrlh | PpcOpcode::vmulouh |
@@ -468,6 +479,7 @@ pub fn format(instr: &DecodedInstr) -> DisasmText {
fmt_vmx_3op(instr, opcode_name(instr.opcode))
}
// VMX unary VD, VB
PpcOpcode::vrefp | PpcOpcode::vrsqrtefp | PpcOpcode::vexptefp |
PpcOpcode::vlogefp | PpcOpcode::vrfin | PpcOpcode::vrfiz |
@@ -799,6 +811,33 @@ fn fmt_b(instr: &DecodedInstr) -> DisasmText {
with_target(base(mnem, ops, 8), target)
}
/// Static branch-prediction hint suffix for a `BO` field.
///
/// PowerISA gives several `BO` encodings an `at` pair — `001at`, `011at`,
/// `1a00t`, `1a01t` — where `at=0b10` means "unlikely" (`-`) and `0b11` means
/// "likely" (`+`); `0b00` is "no hint" and `0b01` is reserved. The forms whose
/// low bit is the reserved `z` (`0000z`, `0001z`, `0100z`, `0101z`) carry no
/// hint at all. Dropping the suffix loses the compiler's static prediction,
/// which is the only place it is recorded.
fn hint_suffix(bo: u32) -> &'static str {
let b = |i: u32| (bo >> (4 - i)) & 1; // b(0) is the MSB of the 5-bit field
let at = match (b(0), b(2), b(3)) {
// 1z1zz — branch always, no hint.
(1, 1, _) => return "",
// 1a00t / 1a01t — the `a` bit is b1.
(1, 0, _) => (b(1) << 1) | b(4),
// 001at / 011at — the `a` bit is b3.
(0, 1, _) => (b(3) << 1) | b(4),
// 0000z / 0001z / 0100z / 0101z — low bit reserved, no hint.
_ => return "",
};
match at {
0b10 => "-",
0b11 => "+",
_ => "",
}
}
fn fmt_bc(instr: &DecodedInstr) -> DisasmText {
let bo = instr.bo();
let bi = instr.bi();
@@ -818,6 +857,7 @@ fn fmt_bc(instr: &DecodedInstr) -> DisasmText {
let decr = bo & 0x04 == 0;
let uncond = bo & 0x10 != 0;
let hint = hint_suffix(bo);
let result = if uncond && !decr {
// Unconditional branch.
let ext_mnem = format!("b{a}{l}");
@@ -836,15 +876,26 @@ fn fmt_bc(instr: &DecodedInstr) -> DisasmText {
if decr {
let z = if bo & 0x02 != 0 { "z" } else { "nz" };
// BO bit 4 (uncond) means CR is ignored — pure CTR-decrement branch.
// Without this guard, bdnz/bdz would emit a spurious `ge` suffix derived
// from the don't-care BI=0 / cond_true=false pair (PPCBUG-640).
let cond_str = if uncond { "" } else { cond_name_opt.unwrap_or("") };
let ext_mnem = format!("bd{z}{cond_str}{a}{l}");
let ext_ops = format!("{cr}0x{target:08X}");
with_ext(&base_mnem, base_ops, 8, &ext_mnem, ext_ops, 8)
if uncond {
// BO bit 4 set means CR is ignored — a pure CTR-decrement
// branch. Without this guard bdnz/bdz would emit a spurious
// `ge` suffix derived from the don't-care BI=0 /
// cond_true=false pair (PPCBUG-640).
let ext_mnem = format!("bd{z}{a}{l}{hint}");
let ext_ops = format!("0x{target:08X}");
with_ext(&base_mnem, base_ops, 8, &ext_mnem, ext_ops, 8)
} else {
// Combined CTR + condition. PowerISA names these `bdnzt` /
// `bdnzf` / `bdzt` / `bdzf` with the CR bit as an operand —
// not a condition-suffixed `bdnzne`, which is an invention no
// assembler accepts.
let t = if cond_true { "t" } else { "f" };
let ext_mnem = format!("bd{z}{t}{a}{l}{hint}");
let ext_ops = format!("{}, 0x{target:08X}", crb(bi));
with_ext(&base_mnem, base_ops, 8, &ext_mnem, ext_ops, 8)
}
} else if let Some(cond_name) = cond_name_opt {
let ext_mnem = format!("b{cond_name}{a}{l}");
let ext_mnem = format!("b{cond_name}{a}{l}{hint}");
let ext_ops = format!("{cr}0x{target:08X}");
with_ext(&base_mnem, base_ops, 8, &ext_mnem, ext_ops, 8)
} else {
@@ -861,6 +912,7 @@ fn fmt_bclr(instr: &DecodedInstr) -> DisasmText {
let l = if lk { "l" } else { "" };
let base_mnem = format!("bclr{l}");
let base_ops = format!("{bo}, {}", crb(bi));
let hint = hint_suffix(bo);
// BO=20 (binary 10100) sets both "ignore CTR" and "ignore CR" bits, making
// the branch unconditional regardless of BI. BI is don't-care by spec, so
@@ -871,7 +923,7 @@ fn fmt_bclr(instr: &DecodedInstr) -> DisasmText {
}
if let Some((cond, cr)) = cond_branch_ext(bo, bi) {
let cr_no_comma = cr.trim_end_matches(", ");
let ext_mnem = format!("b{cond}lr{l}");
let ext_mnem = format!("b{cond}lr{l}{hint}");
if cr_no_comma.is_empty() {
return with_ext(&base_mnem, base_ops, 8, &ext_mnem, String::new(), 0);
} else {
@@ -882,7 +934,7 @@ fn fmt_bclr(instr: &DecodedInstr) -> DisasmText {
let uncond = bo & 0x10 != 0;
if decr && uncond {
let z = if bo & 0x02 != 0 { "z" } else { "nz" };
let ext_mnem = format!("bd{z}lr{l}");
let ext_mnem = format!("bd{z}lr{l}{hint}");
return with_ext(&base_mnem, base_ops, 8, &ext_mnem, String::new(), 0);
}
base(&base_mnem, base_ops, 8)
@@ -895,6 +947,7 @@ fn fmt_bcctr(instr: &DecodedInstr) -> DisasmText {
let l = if lk { "l" } else { "" };
let base_mnem = format!("bcctr{l}");
let base_ops = format!("{bo}, {}", crb(bi));
let hint = hint_suffix(bo);
// BO=20 unconditional pattern: BI is don't-care (see fmt_bclr).
if bo == 20 {
@@ -903,7 +956,7 @@ fn fmt_bcctr(instr: &DecodedInstr) -> DisasmText {
}
if let Some((cond, cr)) = cond_branch_ext(bo, bi) {
let cr_no_comma = cr.trim_end_matches(", ");
let ext_mnem = format!("b{cond}ctr{l}");
let ext_mnem = format!("b{cond}ctr{l}{hint}");
if cr_no_comma.is_empty() {
return with_ext(&base_mnem, base_ops, 8, &ext_mnem, String::new(), 0);
} else {
@@ -921,7 +974,12 @@ fn fmt_trap_imm(instr: &DecodedInstr, mnem: &str, simplified_prefix: &str) -> Di
let base_ops = format!("{to}, {}, {imm}", gpr(ra));
if let Some(cond) = trap_cond(to) {
if cond.is_empty() {
base(mnem, base_ops, 8)
// TO=31 traps unconditionally. The register form has `trap`; the
// immediate form's counterpart is `twui`/`tdui` (binutils), which
// is what every other disassembler prints for these 16 sites.
let ext_mnem = format!("{simplified_prefix}ui");
let ext_ops = format!("{}, {imm}", gpr(ra));
with_ext(mnem, base_ops, 8, &ext_mnem, ext_ops, 8)
} else {
let ext_mnem = format!("{simplified_prefix}{cond}i");
let ext_ops = format!("{}, {imm}", gpr(ra));
@@ -1118,7 +1176,13 @@ fn fmt_rlwinm(instr: &DecodedInstr) -> DisasmText {
let base_ops = format!("{}, {}, {sh}, {mb}, {me}", gpr(ra), gpr(rs));
// Priority-ordered simplified forms.
if sh > 0 && mb == 0 && me == 31 - sh {
//
// `slwi` is deliberately not gated on `sh > 0`: `rlwinm rA,rS,0,0,31` is a
// rotate-by-zero under a full mask, which the ISA's table still names
// `slwi rA,rS,0` (and which LLVM/capstone print that way). It is the single
// most common `rlwinm` encoding in this binary — 3,720 sites — so gating it
// away left the largest simplified-mnemonic gap we had.
if mb == 0 && me == 31 - sh {
let ext = format!("slwi{rc}");
return with_ext(&mnem, base_ops, 8, &ext, format!("{}, {}, {sh}", gpr(ra), gpr(rs)), 8);
}
@@ -1435,9 +1499,32 @@ fn fmt_mtspr(instr: &DecodedInstr) -> DisasmText {
}
}
/// `mfcr` and `mfocrf` share XO=19 and are told apart by bit 11.
///
/// With bit 11 clear the instruction copies the whole CR into `rD`; with it set
/// this is `mfocrf`, which copies only the single CR field named by `FXM` and
/// leaves the rest of `rD` undefined. Printing the latter as a bare `mfcr rD`
/// loses which field was read — and on this title 163 of 165 sites are the
/// one-field form. (The reference emulator folds both into one handler because
/// the wide read is a safe superset at runtime; a disassembler cannot.)
fn fmt_mfcr(instr: &DecodedInstr) -> DisasmText {
let rd = instr.rd();
if instr.raw & (1 << 20) != 0 {
let fxm = (instr.raw >> 12) & 0xFF;
base("mfocrf", format!("{}, 0x{fxm:02X}", gpr(rd)), 8)
} else {
base("mfcr", gpr(rd), 8)
}
}
/// `mtcrf` and `mtocrf` share XO=144, told apart by bit 11 exactly as
/// [`fmt_mfcr`] describes.
fn fmt_mtcrf(instr: &DecodedInstr) -> DisasmText {
let rs = instr.rs();
let fxm = (instr.raw >> 12) & 0xFF;
if instr.raw & (1 << 20) != 0 {
return base("mtocrf", format!("0x{fxm:02X}, {}", gpr(rs)), 8);
}
let bo = format!("0x{fxm:02X}, {}", gpr(rs));
if fxm == 0xFF {
with_ext("mtcrf", bo, 8, "mtcr", gpr(rs), 8)
@@ -1597,6 +1684,20 @@ fn fmt_mtfsb(instr: &DecodedInstr, mnem: &str) -> DisasmText {
}
// VMX (5-bit registers).
/// A VX-form op whose two sources are the same register, so it degenerates to
/// a move/complement: emit the base form plus the two-operand simplified one.
fn fmt_vmx_move(instr: &DecodedInstr, base_mnem: &str, ext_mnem: &str) -> DisasmText {
let vd = instr.rd();
let va = instr.ra();
let vb = instr.rb();
with_ext(
base_mnem,
format!("{}, {}, {}", vr(vd), vr(va), vr(vb)), 8,
ext_mnem,
format!("{}, {}", vr(vd), vr(va)), 8,
)
}
fn fmt_vmx_3op(instr: &DecodedInstr, mnem: &str) -> DisasmText {
let vd = instr.rd();
let va = instr.ra();
@@ -1875,4 +1976,153 @@ mod tests {
let items: Vec<_> = super::iter_disasm(&bytes, 0, 0, 6).collect();
assert_eq!(items.len(), 1);
}
/// `mfocrf` shares XO=19 with `mfcr`, differing only in bit 11. Printing it
/// as `mfcr` drops the FXM field naming which CR field is actually read —
/// 163 of 165 sites in the reference title are this form.
#[test]
fn mfocrf_is_distinguished_from_mfcr() {
// 0x7d502026: mfocrf r10, 0x02 (bit 11 set)
let d = crate::decoder::decode(0x7d50_2026, 0x8200_0000);
let t = format(&d);
assert_eq!(t.mnemonic, "mfocrf");
assert_eq!(t.operands, "r10, 0x02");
// Same encoding with bit 11 clear is a plain whole-CR read.
let d = crate::decoder::decode(0x7d50_2026 & !(1 << 20), 0x8200_0000);
let t = format(&d);
assert_eq!(t.mnemonic, "mfcr");
assert_eq!(t.operands, "r10");
}
/// The mirror case on the write side.
#[test]
fn mtocrf_is_distinguished_from_mtcrf() {
let base_word = 0x7c10_1120u32; // mtcrf-form, XO=144
let d = crate::decoder::decode(base_word | (1 << 20), 0x8200_0000);
assert_eq!(format(&d).mnemonic, "mtocrf");
let d = crate::decoder::decode(base_word & !(1 << 20), 0x8200_0000);
assert_eq!(format(&d).mnemonic, "mtcrf");
}
/// `rlwinm rA,rS,0,0,31` is a rotate-by-zero under a full mask. It is the
/// most common `rlwinm` encoding in the reference title (3,720 sites) and
/// was falling through to the base form because every simplified branch was
/// gated on `sh > 0`.
#[test]
fn rlwinm_shift_zero_still_simplifies() {
// 0x5548003e: rlwinm r8, r10, 0, 0, 31
let d = crate::decoder::decode(0x5548_003e, 0x8200_0000);
let t = format(&d);
assert_eq!(t.mnemonic, "rlwinm");
assert_eq!(t.ext_mnemonic.as_deref(), Some("slwi"));
assert_eq!(t.ext_operands.as_deref(), Some("r8, r10, 0"));
// The record-bit form keeps its dot.
let d = crate::decoder::decode(0x5569_003f, 0x8200_0000);
assert_eq!(format(&d).ext_mnemonic.as_deref(), Some("slwi."));
}
/// A genuine bit-extraction has no short name and must stay in base form.
#[test]
fn rlwinm_bit_extract_has_no_simplified_form() {
// rlwinm rA,rS,0,30,30 — extracts one bit; not a shift or clear.
let word = 0x5548_0000 | (0 << 11) | (30 << 6) | (30 << 1);
let t = format(&crate::decoder::decode(word, 0x8200_0000));
assert_eq!(t.mnemonic, "rlwinm");
assert_eq!(t.ext_mnemonic, None);
}
/// `vor vD,vA,vA` is the vector register move; `vnor vD,vA,vA` the vector
/// complement. Both only apply when the two sources are the same register.
#[test]
fn vor_and_vnor_simplify_only_when_sources_match() {
// 0x11800484: vor v12, v0, v0
let t = format(&crate::decoder::decode(0x1180_0484, 0x8200_0000));
assert_eq!(t.mnemonic, "vor");
assert_eq!(t.ext_mnemonic.as_deref(), Some("vmr"));
assert_eq!(t.ext_operands.as_deref(), Some("v12, v0"));
// 0x10000504: vnor v0, v0, v0
let t = format(&crate::decoder::decode(0x1000_0504, 0x8200_0000));
assert_eq!(t.ext_mnemonic.as_deref(), Some("vnot"));
// Distinct sources: a real bitwise OR, no simplification.
let t = format(&crate::decoder::decode(0x1180_1484, 0x8200_0000));
assert_eq!(t.mnemonic, "vor");
assert_eq!(t.ext_mnemonic, None);
}
/// PowerISA names the combined CTR+condition branches `bdnzt`/`bdnzf`
/// (and `bdzt`/`bdzf`) with the CR bit as an operand. We used to synthesise
/// `bdnzne` by gluing on a condition suffix — readable, but not a mnemonic
/// any assembler accepts.
#[test]
fn bdnz_with_condition_uses_the_isa_t_f_form() {
// 0x4002fff8: BO=00000 (dec CTR, branch if CTR!=0 and CR[BI]==0), BI=eq
let t = format(&crate::decoder::decode(0x4002_fff8, 0x8200_0000));
assert_eq!(t.ext_mnemonic.as_deref(), Some("bdnzf"));
assert!(t.ext_operands.as_deref().unwrap().starts_with("eq,"));
}
/// The `at` hint bits are the only record of the compiler's static branch
/// prediction, so they must survive into the text.
#[test]
fn branch_prediction_hints_are_preserved() {
// 0x4320fff0: bdnz with at=0b11 -> "+"
assert_eq!(
format(&crate::decoder::decode(0x4320_fff0, 0x8200_0000)).ext_mnemonic.as_deref(),
Some("bdnz+")
);
// 0x41c20024: beq with at=0b10 -> "-"
assert_eq!(
format(&crate::decoder::decode(0x41c2_0024, 0x8200_0000)).ext_mnemonic.as_deref(),
Some("beq-")
);
// 0x4de20020: beqlr with at=0b11 -> "+"
assert_eq!(
format(&crate::decoder::decode(0x4de2_0020, 0x8200_0000)).ext_mnemonic.as_deref(),
Some("beqlr+")
);
}
/// A branch with no hint bits set must stay unsuffixed, and `blr` (BO=20,
/// the branch-always form) never takes a hint at all.
#[test]
fn unhinted_branches_gain_no_suffix() {
// 0x4182000c: beq, at=0b00
let t = format(&crate::decoder::decode(0x4182_000c, 0x8200_0000));
assert_eq!(t.ext_mnemonic.as_deref(), Some("beq"));
// 0x4e800020: blr
let t = format(&crate::decoder::decode(0x4e80_0020, 0x8200_0000));
assert_eq!(t.ext_mnemonic.as_deref(), Some("blr"));
}
/// The whole trap family, checked against the reference table: TO=31 is
/// unconditional, which the register form calls `trap` and the immediate
/// form `twui` — the latter was the one gap.
#[test]
fn trap_extended_mnemonics_cover_the_table() {
// 0x0fe00016: twi 31, r0, 22 -> unconditional
let t = format(&crate::decoder::decode(0x0fe0_0016, 0x8200_0000));
assert_eq!(t.mnemonic, "twi");
assert_eq!(t.ext_mnemonic.as_deref(), Some("twui"));
// TO=6 is "logically less than or equal" — the divide-by-zero guard
// MSVC emits, and the most common trap in the reference title.
let word = 0x0c00_0000 | (6 << 21) | (3 << 16) | 0;
assert_eq!(
format(&crate::decoder::decode(word, 0x8200_0000)).ext_mnemonic.as_deref(),
Some("twllei")
);
// TO=5 is "logically greater than or equal".
let word = 0x0c00_0000 | (5 << 21) | (3 << 16) | 0;
assert_eq!(
format(&crate::decoder::decode(word, 0x8200_0000)).ext_mnemonic.as_deref(),
Some("twlgei")
);
// tw 31,0,0 stays the register-form `trap`.
let t = format(&crate::decoder::decode(0x7fe0_0008, 0x8200_0000));
assert_eq!(t.ext_mnemonic.as_deref(), Some("trap"));
}
}