- benilla loads real 1.12 addons from Interface/AddOns — TOC, XML and Lua through one loader, with per-addon saved variables, Bindings.xml and the lifecycle events addons expect - both AddOns screens are in: the character-select list with per-addon enable state, and the ESC menu's in-game list - the API surface is measured, not guessed — a corpus harness runs hundreds of real 1.12 addons and ranks exactly which globals, templates and verbs they demand - the Lua dialect is the client's own, read off the binary — 5.0 semantics with the 1.12 additions, and UI source files are bytes, not UTF-8 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
208 lines
11 KiB
Rust
208 lines
11 KiB
Rust
//! **UI source bytes** — what a `.lua`, `.xml` or `.toc` file *is*, before any parser sees it.
|
||
//!
|
||
//! One rule, and it is the reference's: **a UI source file is bytes, not text.** wow-5875-re has
|
||
//! the whole path byte-verified (`system/ui/scratch/lua-chunk-load-encoding.md`, a §5 trio run for
|
||
//! this decision): `0x704bc0` slurps the file (`0x648620`) with `pad = 0`, so there is not even a
|
||
//! trailing NUL, the handle is opened **binary** on every leg of `0x647db0`, and `0x704ae0` hands
|
||
//! the pointer and length straight to `luaL_loadbuffer 0x6f5690`. No transcoding, no codepage
|
||
//! conversion; the only `WideCharToMultiByte` on the path converts the *path*. Lua 5.0 strings
|
||
//! *are* byte strings, so a cp1252 German locale file loads there and its literals carry the raw
|
||
//! bytes.
|
||
//!
|
||
//! Two things the compiler's front door does do, and we do them too because they are the
|
||
//! reference's own code rather than Lua's: it **strips a UTF-8 BOM** ([`strip_bom`] — ten
|
||
//! instructions Blizzard patched into `luaL_loadbuffer`) and it **eats a leading `#`-line**
|
||
//! ([`strip_hashbang`] — Lua 5.0 puts that in `luaX_setinput`, where every chunk passes; 5.1 moved
|
||
//! it somewhere mlua never calls).
|
||
//!
|
||
//! benilla read every one of these files with `read_to_string` until decision 1193, which meant a
|
||
//! file that was not valid UTF-8 did not merely lose a glyph — it **did not exist**: the read
|
||
//! returned `None` and the loader reported "not found". Measured over a real 218-addon vanilla
|
||
//! corpus, that is **80 addons of 218** (37 %): 76 non-UTF-8 `.lua` files, 5 non-UTF-8 `.toc`
|
||
//! files (which made those addons invisible to discovery entirely), and 160 files carrying a UTF-8
|
||
//! BOM. `AceAddon-2.0.lua` is one of them, and it is embedded in ~30 addons — one library file
|
||
//! taking out the whole Ace/FuBar half of the ecosystem.
|
||
//!
|
||
//! ## Two verbs, because there are two kinds of consumer
|
||
//!
|
||
//! - [`strip_bom`] + raw bytes → **Lua chunks**. Byte-transparent, exactly as the reference.
|
||
//! `string.len()` on a literal returns what it returns there, and byte-indexing agrees.
|
||
//! - [`decode`] → **our own parsers** (FrameXML XML, `.toc`), which need `&str` because Rust
|
||
//! parsers do. This is the only place a guess is made, and it is made where it is cheapest.
|
||
//!
|
||
//! **Why not transcode the Lua too, so everything downstream is UTF-8?** Because that would
|
||
//! silently change `string.len`, `string.sub` and `string.byte` on every non-ASCII literal — a
|
||
//! divergence from the verified mechanism, bought for a rendering convenience. The right place to
|
||
//! turn bytes into text is the boundary that actually requires text, which is a Rust binding
|
||
//! signature, not the file loader. A stray byte should cost one glyph, not the file.
|
||
|
||
use std::borrow::Cow;
|
||
|
||
/// The UTF-8 byte-order mark. Not an encoding — a three-byte signature some Windows editors write
|
||
/// in front of a UTF-8 file.
|
||
const BOM: &[u8] = &[0xEF, 0xBB, 0xBF];
|
||
|
||
/// `bytes` without a leading UTF-8 BOM.
|
||
///
|
||
/// **This is fidelity, not a modernisation** — and that is a correction to this module's first
|
||
/// draft, which reasoned that stock Lua 5.0's lexer meets `0xEF` and stops, so a BOM'd file must
|
||
/// have failed on the real client too, so stripping it was us being kinder than the original. An
|
||
/// RE dispatch into wow-5875-re read the bytes instead of the manual: **Blizzard patched the strip
|
||
/// into `luaL_loadbuffer 0x6f5690` themselves.** Ten instructions stock Lua does not have
|
||
/// (`0x6f5699`–`0x6f56b4`): a `size >= 3` guard, three byte compares against `EF BB BF`, then
|
||
/// `add edx,3` / `sub eax,3` — all before the `LoadS` fill and `lua_load 0x6f4320`. That is the
|
||
/// only live door into the compiler, so addon files, FrameXML, XML script bodies and `loadstring`
|
||
/// all get it. The `.toc` reader has its own separate skip at `0x6edc71`.
|
||
///
|
||
/// The lexer reasoning was right and the conclusion drawn from it was wrong: `luaX_lex 0x6ff610`
|
||
/// *would* reject `0xEF`, which is precisely why the patch has to exist. A verified mechanism
|
||
/// beats a correct-sounding derivation, every time.
|
||
///
|
||
/// The edge semantics are the reference's, byte for byte: **at most one** mark, **only** at offset
|
||
/// 0, and a file shorter than three bytes is untouched (a BOM-only file compiles as an empty
|
||
/// chunk). A UTF-16 BOM (`FF FE` / `FE FF`) is left alone — the reference does not handle one
|
||
/// either, that file is not a Lua chunk in any encoding it could run, and transcoding it would be
|
||
/// inventing content rather than reading it. None exists in the corpus.
|
||
pub fn strip_bom(bytes: &[u8]) -> &[u8] {
|
||
bytes.strip_prefix(BOM).unwrap_or(bytes)
|
||
}
|
||
|
||
/// `bytes` with a leading `#`-line removed — the **hash-bang skip**, which is a dialect difference
|
||
/// and not a convenience.
|
||
///
|
||
/// Lua 5.0 puts this skip in `luaX_setinput` (`0x6ff5cd`–`0x6ff600`), which every chunk goes
|
||
/// through: a source file whose first character is `#` loses its whole first line, including one
|
||
/// handed to `loadstring`. **Lua 5.1 moved it out** into `luaL_loadfile`, which mlua's
|
||
/// `Lua::load` does not call — so a 5.1-family VM silently keeps the line and then fails to
|
||
/// compile it. Verified in the binary by the same RE dispatch that corrected [`strip_bom`].
|
||
///
|
||
/// Applied after the BOM strip, because that is the order the reference applies them in: the mark
|
||
/// is removed inside `luaL_loadbuffer`, and the lexer sees the shortened buffer.
|
||
pub fn strip_hashbang(bytes: &[u8]) -> &[u8] {
|
||
match bytes.first() {
|
||
Some(b'#') => match bytes.iter().position(|&b| b == b'\n') {
|
||
// Lua 5.0 leaves the newline for the line counter; so do we.
|
||
Some(nl) => &bytes[nl..],
|
||
None => &bytes[bytes.len()..],
|
||
},
|
||
_ => bytes,
|
||
}
|
||
}
|
||
|
||
/// A chunk exactly as the reference's compiler receives it: [`strip_bom`] then [`strip_hashbang`].
|
||
pub fn chunk(bytes: &[u8]) -> &[u8] {
|
||
strip_hashbang(strip_bom(bytes))
|
||
}
|
||
|
||
/// Interpret UI source bytes as text, for the parsers that cannot take bytes.
|
||
///
|
||
/// Valid UTF-8 passes through **borrowed and unchanged** — which is every file we ship and the
|
||
/// overwhelming majority of every corpus, so the guess below is never reached for them.
|
||
///
|
||
/// Otherwise the bytes are decoded as **cp1252** (Windows-1252). That is a guess, and it is the
|
||
/// right one to make: the vanilla-era addons that are not UTF-8 are Western-European locale files
|
||
/// written on a Windows box — German and French `## Notes:` lines and `<Include>`d localisation
|
||
/// documents. cp1252 maps every byte (the five undefined slots become U+FFFD), so the decode
|
||
/// cannot fail and the addon cannot vanish. A GBK or EUC-KR file decodes to mojibake instead of
|
||
/// disappearing, which is a strictly better failure and one that is visible rather than silent.
|
||
///
|
||
/// The BOM is stripped first, so a BOM'd UTF-8 file is borrowed too.
|
||
pub fn decode(bytes: &[u8]) -> Cow<'_, str> {
|
||
let bytes = strip_bom(bytes);
|
||
match std::str::from_utf8(bytes) {
|
||
Ok(s) => Cow::Borrowed(s),
|
||
Err(_) => Cow::Owned(bytes.iter().map(|&b| cp1252(b)).collect()),
|
||
}
|
||
}
|
||
|
||
/// One cp1252 byte as a `char`.
|
||
///
|
||
/// cp1252 is Latin-1 (where a byte *is* its code point) except for `0x80..=0x9F`, which Latin-1
|
||
/// leaves as C1 control codes and Windows fills with the punctuation people actually type — the
|
||
/// curly quotes, the em dash, the euro sign. Getting that window wrong is what turns a French
|
||
/// addon's `l'objet` into a control character, so the window is spelled out rather than
|
||
/// approximated by a Latin-1 cast.
|
||
fn cp1252(b: u8) -> char {
|
||
const C1: [char; 32] = [
|
||
'\u{20AC}', '\u{FFFD}', '\u{201A}', '\u{0192}', '\u{201E}', '\u{2026}', '\u{2020}',
|
||
'\u{2021}', '\u{02C6}', '\u{2030}', '\u{0160}', '\u{2039}', '\u{0152}', '\u{FFFD}',
|
||
'\u{017D}', '\u{FFFD}', '\u{FFFD}', '\u{2018}', '\u{2019}', '\u{201C}', '\u{201D}',
|
||
'\u{2022}', '\u{2013}', '\u{2014}', '\u{02DC}', '\u{2122}', '\u{0161}', '\u{203A}',
|
||
'\u{0153}', '\u{FFFD}', '\u{017E}', '\u{0178}',
|
||
];
|
||
match b {
|
||
0x80..=0x9F => C1[(b - 0x80) as usize],
|
||
_ => b as char,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// A BOM'd file loses exactly three bytes, and a file without one is untouched.
|
||
#[test]
|
||
fn the_utf8_bom_is_removed_and_nothing_else_is() {
|
||
assert_eq!(strip_bom(b"\xEF\xBB\xBFlocal x = 1"), b"local x = 1");
|
||
assert_eq!(strip_bom(b"local x = 1"), b"local x = 1");
|
||
// A lone 0xEF is not a BOM, and a truncated one is not either — neither may be eaten.
|
||
assert_eq!(strip_bom(b"\xEF\xBB"), b"\xEF\xBB");
|
||
assert_eq!(strip_bom(b"\xEF"), b"\xEF");
|
||
assert_eq!(strip_bom(b""), b"");
|
||
// A UTF-16 mark is left alone: that file is not a chunk we can honestly run.
|
||
assert_eq!(strip_bom(b"\xFF\xFEl\0"), b"\xFF\xFEl\0");
|
||
}
|
||
|
||
/// Valid UTF-8 is borrowed, not rebuilt — the common path allocates nothing.
|
||
#[test]
|
||
fn valid_utf8_is_borrowed_unchanged() {
|
||
let text = "## Title: Sch\u{e4}tze";
|
||
assert!(matches!(decode(text.as_bytes()), Cow::Borrowed(s) if s == text));
|
||
// ...including through a BOM.
|
||
let bom = [BOM, text.as_bytes()].concat();
|
||
assert_eq!(decode(&bom), text);
|
||
}
|
||
|
||
/// A cp1252 `.toc` line decodes to the glyphs its author typed, rather than to nothing.
|
||
///
|
||
/// `0xE4` is `a`-umlaut in both Latin-1 and cp1252; `0x92` is the right single quote in cp1252
|
||
/// and a C1 control in Latin-1, which is the difference this table exists for.
|
||
#[test]
|
||
fn cp1252_is_decoded_where_utf8_fails() {
|
||
assert_eq!(decode(b"## Notes: Sch\xE4tze"), "## Notes: Sch\u{e4}tze");
|
||
assert_eq!(decode(b"l\x92objet"), "l\u{2019}objet");
|
||
assert_eq!(decode(b"\x80"), "\u{20AC}");
|
||
// Undefined cp1252 slots survive as the replacement char — never as a decode failure.
|
||
assert_eq!(decode(b"\x81"), "\u{FFFD}");
|
||
}
|
||
|
||
/// The `#`-line skip is Lua 5.0's, and it is a *dialect* fact rather than a nicety.
|
||
///
|
||
/// `luaX_setinput 0x6ff5cd` eats it for every chunk the reference compiles, including one
|
||
/// handed to `loadstring`. mlua's 5.1 does not, because 5.1 moved the skip into
|
||
/// `luaL_loadfile` and `Lua::load` never calls that.
|
||
#[test]
|
||
fn a_leading_hash_line_is_eaten_the_way_lua_5_0_eats_it() {
|
||
// The newline survives — 5.0 leaves it so the line counter stays honest.
|
||
assert_eq!(strip_hashbang(b"#!/usr/bin/lua\nreturn 1"), b"\nreturn 1");
|
||
// A `#` with no newline at all leaves nothing behind.
|
||
assert_eq!(strip_hashbang(b"# only a comment"), b"");
|
||
// Anything else is untouched — `#t` mid-expression is a length operator, not a shebang.
|
||
assert_eq!(strip_hashbang(b"local n = #t"), b"local n = #t");
|
||
assert_eq!(strip_hashbang(b""), b"");
|
||
// ...and the two run in the reference's order: mark first, then the line.
|
||
assert_eq!(chunk(b"\xEF\xBB\xBF#line\nreturn 1"), b"\nreturn 1");
|
||
}
|
||
|
||
/// The whole point, stated as a property: **no byte sequence makes a source file vanish**.
|
||
#[test]
|
||
fn every_byte_sequence_decodes_to_something() {
|
||
let all: Vec<u8> = (0u8..=255).collect();
|
||
let text = decode(&all);
|
||
assert!(!text.is_empty());
|
||
// ASCII survives verbatim, which is what a parser actually keys on.
|
||
assert!(text.starts_with('\0'));
|
||
assert!(text.contains("ABC"));
|
||
}
|
||
}
|