Files
moonwell-client-core/crates/benilla-lua/tests/front_end.rs
T
Sam Sung 6aef15f7e7 Numerals and sscanf convert as 1.12.1's C runtime does (#2675)
Lua numerals and the sscanf ports used Rust's correctly rounded parse. 1.12.1's C runtime rounds its own way: `__RoundMan` rounds up only when the round bit and some bit two past it are set (`0x40fb43`), so ties truncate and `6e23` reads one ulp low. It flushes results below 2^-1022 to +0 (`0x741ed3`) and keeps 24 digits (`0x41157f`). benilla-re holds these at proof two.

One port of that conversion now lives in benilla-lua (`strtod.rs`, `ld12.rs`). It serves `str2d` and `%f`, which rounds once to float (`0x40feef`) and consumes a digitless exponent marker (`0x7451a5`). `%d` wraps in 32 bits (`0x7456c9`). The time zone is read once per process (`0x414568`), and two client faults are written as Deviations.

A table of 3487 cases generated from benilla-re's transcription, and the exe's fixed cases, pin it.

Fixes #2647 Fixes #2648 Fixes #2649 Fixes #2650 Fixes #2651 Fixes #2652

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 08:13:32 +07:00

317 lines
13 KiB
Rust

//! The front end over every Lua chunk in the addon corpus and the install's interface files: each
//! compiles, but for the corpus chunks the script host's C Lua build rejected, which must fail
//! with the message it gave.
//!
//! Each compiled chunk's shape (constants in order, register count, parameters, locals,
//! upvalues, line runs) must also be the one `front_end.digest` records: a line per chunk, a hash
//! of its source and a hash of its shape. The shapes are the C build's, but where benilla-lua
//! since follows the client's 5.0 against 5.1 (the `for` loops' locals, closed once at the
//! loop's end; a long string's `\r`, a byte; a constructor's list items stored every 32,
//! `0x6fd6d0`, not every 50) or its runtime against the C library's (a numeral as its `strtod`,
//! `0x741e39`, rounds it): such a change rewrites the digest with `BENILLA_BLESS=1`, which
//! prints each chunk whose shape moved, for the review. A chunk whose source is not in it
//! (another corpus version) is not checked against it.
use std::path::{Path, PathBuf};
struct Chunk {
name: String,
bytes: Vec<u8>,
}
/// `benilla_ui::source::chunk`: one leading UTF-8 BOM and a leading `#` line go.
fn prepare(bytes: &[u8]) -> Vec<u8> {
let b = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF][..]).unwrap_or(bytes);
match b.first() {
Some(b'#') => match b.iter().position(|&c| c == b'\n') {
Some(nl) => b[nl..].to_vec(),
None => Vec::new(),
},
_ => b.to_vec(),
}
}
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
walk(&p, out);
} else {
out.push(p);
}
}
}
/// Inline `<Script>` text and every handler body in an XML file, each as its own chunk.
fn xml_chunks(name: &str, bytes: &[u8], out: &mut Vec<Chunk>) {
let text =
String::from_utf8_lossy(bytes.strip_prefix(&[0xEF, 0xBB, 0xBF][..]).unwrap_or(bytes))
.into_owned();
let Ok(doc) = roxmltree::Document::parse(&text) else {
return;
};
for n in doc.descendants().filter(|n| n.is_element()) {
let tag = n.tag_name().name();
let inside_scripts = n.parent().is_some_and(|p| p.tag_name().name() == "Scripts");
if (tag == "Script" && n.attribute("file").is_none()) || inside_scripts {
let body: String = n.children().filter_map(|c| c.text()).collect();
if !body.trim().is_empty() {
out.push(Chunk {
name: format!("{name}:{tag}"),
bytes: body.into_bytes(),
});
}
}
}
}
fn corpus_chunks(out: &mut Vec<Chunk>) -> bool {
let Some(root) = benilla_formats::addon_corpus() else {
return false;
};
let mut files = Vec::new();
walk(&root, &mut files);
files.sort();
for f in files {
let name = f
.strip_prefix(&root)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
let ext = f
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
let Ok(bytes) = std::fs::read(&f) else {
continue;
};
match ext.as_deref() {
Some("lua") => out.push(Chunk {
name,
bytes: prepare(&bytes),
}),
Some("xml") => xml_chunks(&name, &bytes, out),
_ => {}
}
}
true
}
fn chain_chunks(out: &mut Vec<Chunk>) -> bool {
let Some(data) = benilla_formats::wow_data() else {
return false;
};
let chain = benilla_formats::open_chain(&data).expect("open the patch chain");
let mut names: Vec<String> = chain
.list()
.expect("list the chain")
.into_iter()
.map(|e| e.name)
.filter(|n| n.to_ascii_lowercase().starts_with("interface\\"))
.collect();
names.sort();
names.dedup();
for name in names {
let lower = name.to_ascii_lowercase();
let Ok(bytes) = chain.read(&name) else {
continue;
};
if lower.ends_with(".lua") {
out.push(Chunk {
name,
bytes: prepare(&bytes),
});
} else if lower.ends_with(".xml") {
xml_chunks(&name, &bytes, out);
}
}
true
}
/// The chunks the C Lua build rejected, with its messages: 5.1 constructs the 1.12 grammar lacks,
/// and handlers written as prose.
const REJECTED: &[(&str, &str)] = &[
("AceGUI/AceGUIDropDownMenu.lua", "AceGUI/AceGUIDropDownMenu.lua:14: `<name>' expected near `<eof>'"),
("AckisRecipeList/ARLDataminer.lua", "AckisRecipeList/ARLDataminer.lua:2: unexpected symbol near `#'"),
("AckisRecipeList/ARLFrame.lua", "AckisRecipeList/ARLFrame.lua:1043: unexpected symbol near `#'"),
("AckisRecipeList/AckisRecipeList.lua", "AckisRecipeList/AckisRecipeList.lua:1604: unexpected symbol near `...'"),
("AckisRecipeList/libs/AceAddon-3.0/AceAddon-3.0.lua", "AckisRecipeList/libs/AceAddon-3.0/AceAddon-3.0.lua:63: unexpected symbol near `...'"),
("AckisRecipeList/libs/AceConfig-3.0/AceConfigCmd-3.0/AceConfigCmd-3.0.lua", "AckisRecipeList/libs/AceConfig-3.0/AceConfigCmd-3.0/AceConfigCmd-3.0.lua:43: unexpected symbol near `...'"),
("AckisRecipeList/libs/AceConfig-3.0/AceConfigDialog-3.0/AceConfigDialog-3.0.lua", "...ecipeList/libs/AceConfig-3.0/AceConfigDialog-3.0/AceConfigDialog-3.0.lua:76: unexpected symbol near `...'"),
("AckisRecipeList/libs/AceConfig-3.0/AceConfigRegistry-3.0/AceConfigRegistry-3.0.lua", "...eList/libs/AceConfig-3.0/AceConfigRegistry-3.0/AceConfigRegistry-3.0.lua:43: unexpected symbol near `...'"),
("AckisRecipeList/libs/AceConsole-3.0/AceConsole-3.0.lua", "AckisRecipeList/libs/AceConsole-3.0/AceConsole-3.0.lua:31: unexpected symbol near `...'"),
("AckisRecipeList/libs/AceEvent-3.0/AceEvent-3.0.lua", "AckisRecipeList/libs/AceEvent-3.0/AceEvent-3.0.lua:70: unexpected symbol near `...'"),
("AckisRecipeList/libs/AceGUI-3.0/AceGUI-3.0.lua", "AckisRecipeList/libs/AceGUI-3.0/AceGUI-3.0.lua:72: unexpected symbol near `...'"),
("AckisRecipeList/libs/AceGUI-3.0/widgets/AceGUIWidget-DropDown-Items.lua", "AckisRecipeList/libs/AceGUI-3.0/widgets/AceGUIWidget-DropDown-Items.lua:7: unexpected symbol near `...'"),
("AckisRecipeList/libs/AceGUI-3.0/widgets/AceGUIWidget-DropDown.lua", "AckisRecipeList/libs/AceGUI-3.0/widgets/AceGUIWidget-DropDown.lua:8: unexpected symbol near `...'"),
("AckisRecipeList/libs/AceGUI-3.0/widgets/AceGUIWidget-Icon.lua", "AckisRecipeList/libs/AceGUI-3.0/widgets/AceGUIWidget-Icon.lua:30: unexpected symbol near `...'"),
("AckisRecipeList/libs/AceGUI-3.0/widgets/AceGUIWidget-Label.lua", "AckisRecipeList/libs/AceGUI-3.0/widgets/AceGUIWidget-Label.lua:83: unexpected symbol near `...'"),
("AckisRecipeList/libs/AceGUI-3.0/widgets/AceGUIWidget-TabGroup.lua", "AckisRecipeList/libs/AceGUI-3.0/widgets/AceGUIWidget-TabGroup.lua:173: unexpected symbol near `#'"),
("AckisRecipeList/libs/AceGUI-3.0/widgets/AceGUIWidget-TreeGroup.lua", "AckisRecipeList/libs/AceGUI-3.0/widgets/AceGUIWidget-TreeGroup.lua:316: unexpected symbol near `#'"),
("AckisRecipeList/libs/CallbackHandler-1.0/CallbackHandler-1.0.lua", "AckisRecipeList/libs/CallbackHandler-1.0/CallbackHandler-1.0.lua:91: unexpected symbol near `...'"),
("Enchantrix/EnchantrixBarker.xml:OnValueChanged", "Enchantrix/EnchantrixBarker.xml:OnValueChanged:1: `=' expected near `need'"),
("FixCTGroups/FixCTGroups.xml:OnLoad", "FixCTGroups/FixCTGroups.xml:OnLoad:2: `=' expected near `pressing'"),
("FixCTGroups/FixCTGroupsCells.xml:OnLoad", "FixCTGroups/FixCTGroupsCells.xml:OnLoad:2: `=' expected near `pressing'"),
("FuBar_AtlasFu/Libs/Tablet-2.0/Tablet-2.0.lua", "FuBar_AtlasFu/Libs/Tablet-2.0/Tablet-2.0.lua:150: unexpected symbol near `#'"),
("TipBuddy/TipBuddy.xml:OnEnter", "TipBuddy/TipBuddy.xml:OnEnter:3: `=' expected near `TipBuddy'"),
("TipBuddy/TipBuddy.xml:OnUpdate", "TipBuddy/TipBuddy.xml:OnUpdate:3: `=' expected near `should'"),
];
/// One function's shape, the parts of a 5.1 `string.dump` the code generator must match. Its
/// `Debug` text is what `front_end.digest` hashes, so a field's type and order stay as the C
/// build's undump wrote them.
#[derive(Debug, PartialEq)]
struct Shape {
linedefined: u32,
nups: u8,
numparams: u8,
vararg: bool,
maxstack: u8,
/// Each instruction's line, runs of one line taken as one: 5.1 spends two instructions on
/// a test benilla fuses into one.
lines: Vec<u32>,
consts: Vec<String>,
locals: Vec<String>,
upvals: Vec<String>,
protos: Vec<Shape>,
}
fn shape(p: &benilla_lua::proto::Proto, heap: &benilla_lua::heap::Heap) -> Shape {
use benilla_lua::value::V;
Shape {
linedefined: p.line_defined,
nups: p.upvals.len() as u8,
numparams: p.nparams,
vararg: p.vararg,
// The client's `maxstacksize`, without benilla's own registers.
maxstack: p.c_maxstack,
lines: {
let mut l = p.lines.clone();
l.dedup();
l
},
consts: p
.consts
.iter()
.map(|v| match v.unpack() {
V::Nil => "nil".to_string(),
V::Bool(b) => format!("{b}"),
V::Num(n) => format!("{n:?}"),
V::Str(s) => format!("{:?}", String::from_utf8_lossy(heap.str(s))),
v => panic!("constant {v:?}"),
})
.collect(),
locals: p
.locvars
.iter()
.map(|l| String::from_utf8_lossy(&l.name).into_owned())
.collect(),
upvals: p
.upval_names
.iter()
.map(|n| String::from_utf8_lossy(n).into_owned())
.collect(),
protos: p.protos.iter().map(|q| shape(q, heap)).collect(),
}
}
/// FNV-1a, 64 bits: stable across platforms and Rust versions, which `DefaultHasher` is not.
fn fnv64(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |h, &b| {
(h ^ b as u64).wrapping_mul(0x0000_0100_0000_01b3)
})
}
/// `front_end.digest`: each source hash's shape hash, as the C build compiled it.
fn read_digest() -> std::collections::HashMap<u64, u64> {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/front_end.digest");
std::fs::read_to_string(path)
.expect("the digest file")
.lines()
.filter_map(|l| l.split_once(' '))
.map(|(a, b)| {
(
u64::from_str_radix(a, 16).unwrap(),
u64::from_str_radix(b, 16).unwrap(),
)
})
.collect()
}
fn write_digest(d: &std::collections::HashMap<u64, u64>) {
let mut lines: Vec<String> = d
.iter()
.map(|(a, b)| format!("{a:016x} {b:016x}"))
.collect();
lines.sort();
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/front_end.digest");
std::fs::write(path, lines.join("\n") + "\n").expect("write the digest file");
}
#[test]
fn every_chunk_compiles_as_the_c_build_compiled_it() {
let mut chunks = Vec::new();
let corpus = corpus_chunks(&mut chunks);
let chain = chain_chunks(&mut chunks);
if !corpus && !chain {
benilla_formats::skipped("no addon corpus and no WoW install", &[]);
return;
}
let got: Vec<(&str, String)> = chunks
.iter()
.filter_map(|c| {
let chunkname = format!("@{}", c.name);
let e = benilla_lua::check(&c.bytes, chunkname.as_bytes()).err()?;
Some((c.name.as_str(), e.to_string()))
})
.collect();
// The rejected chunks are all the corpus's.
let want: Vec<(&str, String)> = REJECTED
.iter()
.filter(|_| corpus)
.map(|&(n, m)| (n, m.to_string()))
.collect();
println!("{} chunks, {} rejected", chunks.len(), got.len());
assert_eq!(got, want);
let mut recorded = read_digest();
let bless = std::env::var_os("BENILLA_BLESS").is_some_and(|v| v == "1");
let mut heap = benilla_lua::heap::Heap::default();
let (mut checked, mut differ) = (0, Vec::new());
for c in &chunks {
let key = fnv64(&c.bytes);
let Some(&want) = recorded.get(&key) else {
continue;
};
let chunkname = format!("@{}", c.name);
let p = benilla_lua::compile(&c.bytes, chunkname.as_bytes(), &mut heap).unwrap();
checked += 1;
let got = fnv64(format!("{:?}", shape(&p, &heap)).as_bytes());
if got != want {
differ.push(c.name.as_str());
if bless {
println!("blessed: {}", c.name);
recorded.insert(key, got);
}
}
}
if bless {
write_digest(&recorded);
differ.clear();
}
println!("{checked} chunks checked against the digest");
assert!(
checked > 0 || !corpus,
"the corpus shares no chunk with the digest"
);
assert!(
differ.is_empty(),
"{} chunks compile to another shape than the digest's: {:?}",
differ.len(),
&differ[..differ.len().min(20)]
);
}