diff --git a/Cargo.lock b/Cargo.lock index 11e57f02..863ac0df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -645,6 +645,7 @@ dependencies = [ name = "benilla-ui" version = "0.2.0" dependencies = [ + "flate2", "mlua", "roxmltree", ] diff --git a/crates/benilla-ui/Cargo.toml b/crates/benilla-ui/Cargo.toml index d771455d..d2993b35 100644 --- a/crates/benilla-ui/Cargo.toml +++ b/crates/benilla-ui/Cargo.toml @@ -12,3 +12,9 @@ workspace = true roxmltree = "0.20" # The script host's Lua 5.1; `vendored` builds it from the workspace's patched `lua-src`. mlua = { version = "0.11", features = ["lua51", "vendored"] } +# zlib for ClassicAPI's `C_EncodingUtil.CompressString`/`DecompressString` +# (`script/compat/classic_api/encoding_util.rs`): a profile blob is pasted between players, so it +# has to be a REAL zlib stream in both directions. Already the workspace's inflate — `benilla-mpq` +# reads MPQ sectors with it and `benilla-protocol` builds `CMSG_AUTH_SESSION`'s addon block with +# its compressor — so this adds a crate to one more manifest, not to the build. +flate2 = "1.1" diff --git a/crates/benilla-ui/src/script/compat/classic_api/encoding_util.rs b/crates/benilla-ui/src/script/compat/classic_api/encoding_util.rs new file mode 100644 index 00000000..fa3cad14 --- /dev/null +++ b/crates/benilla-ui/src/script/compat/classic_api/encoding_util.rs @@ -0,0 +1,906 @@ +//! ClassicAPI's `EncodingUtil` namespace (`C_EncodingUtil`): base64, zlib, JSON and CBOR over Lua +//! values. Eight members, no client state at all — the one namespace in this family that is pure +//! algorithm, and the reason it is worth doing properly rather than stubbing. +//! +//! **What reads it.** pfUI's profile sharing (`modules/share.lua`), whose own header names the +//! pipeline exactly: *export* is `SerializeCBOR` → `CompressString (zlib)` → `EncodeBase64`, +//! prefixed with its `!pf1!` magic; *import* is the reverse. Its Decode/Encode button converts +//! the same table to and from a JSON view. The export path is **not** wrapped in a `pcall`, so +//! clicking Export on this client raised; the import path is, so it failed silently. +//! +//! **Real formats, not self-consistent ones.** A profile string is pasted between players, so a +//! blob this client writes has to be readable by the real one and the other way round. So: zlib +//! through `flate2` (the inflate `benilla-mpq` already uses for MPQ sectors), RFC 4648 base64 with +//! padding, RFC 8949 CBOR, and RFC 8259 JSON. Nothing here is a format invented for this file — +//! the trap a hand-rolled codec falls into is round-tripping with *itself* while being wrong, +//! which `credential.rs` carries its own note about. +//! +//! **Lua's one number type decides two conversions.** 5.1 has only doubles, so a value that is +//! exactly an integer is written as one (JSON without a decimal point, CBOR as a major-type 0/1 +//! integer) and anything else as a float64. Reading back, an integer stays exact up to 2^53, which +//! is every id, count and coordinate a profile carries. +//! +//! **A table is an array when its keys are exactly 1..n, and a map otherwise** — the only rule +//! available, since Lua has one table type for both. An **empty** table is a map (`{}`), which is +//! what pfUI's config diffs are made of; an empty *array* round-trips as an empty map and reads +//! back as an empty table, which is the same value in Lua either way. +//! +//! **These blobs arrive from strangers.** A profile string is pasted in from a forum, so the +//! decoders are written for hostile input: every length is checked against what is actually left +//! in the buffer, nesting is capped ([`MAX_DEPTH`]), and a malformed blob answers **nil** rather +//! than raising or allocating what a crafted header asked for. pfUI's own note on the same +//! question is that it never `loadstring`s a profile, so neither does this. + +use std::io::{Read as _, Write as _}; + +use mlua::{Lua, Table, Value}; + +/// How deeply a decoded document may nest. A pasted blob is untrusted input and the decoders are +/// recursive; 64 is far past any real profile (pfUI's config is three or four deep) and shallow +/// enough that a crafted one cannot run the stack out. +const MAX_DEPTH: usize = 64; + +/// RFC 4648 §4's alphabet. +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +fn b64_encode(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b = [ + chunk[0], + chunk.get(1).copied().unwrap_or(0), + chunk.get(2).copied().unwrap_or(0), + ]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + for i in 0..4 { + // The padding count is 3 − len: a two-byte tail writes three symbols and one `=`. + if i <= chunk.len() { + out.push(B64[((n >> (18 - 6 * i)) & 0x3f) as usize] as char); + } else { + out.push('='); + } + } + } + out +} + +fn b64_decode(text: &str) -> Option> { + let mut acc = 0u32; + let mut bits = 0u32; + let mut out = Vec::with_capacity(text.len() / 4 * 3); + for b in text.bytes() { + // Whitespace is skipped: pfUI chunks its own blob into 92-column lines, and a pasted one + // arrives with whatever line breaks the forum put in it. + if b.is_ascii_whitespace() { + continue; + } + if b == b'=' { + break; + } + let v = B64.iter().position(|c| *c == b)? as u32; + acc = (acc << 6) | v; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push(((acc >> bits) & 0xff) as u8); + } + } + Some(out) +} + +/// Whether `t` is a 1..n array, and its length. An empty table is not an array (see the header). +fn array_len(t: &Table) -> Option { + let mut n = 0usize; + for pair in t.clone().pairs::() { + let (k, _) = pair.ok()?; + match k { + Value::Integer(i) if i >= 1 => n = n.max(usize::try_from(i).ok()?), + // A float key that is exactly a positive integer counts: Lua 5.1 hands `t[1]` back as + // a float in some paths, and a table built by `table.insert` must not read as a map. + Value::Number(f) if f >= 1.0 && f.fract() == 0.0 => { + n = n.max(usize::try_from(f as i64).ok()?); + } + _ => return None, + } + } + (n > 0 && t.raw_len() == n).then_some(n) +} + +// ── JSON ──────────────────────────────────────────────────────────────────────────────────────── + +fn json_escape(s: &str, out: &mut String) { + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); +} + +/// A Lua number as JSON: an exact integer without a decimal point, anything else as a float. +/// A non-finite number has no JSON spelling at all, so it writes as `null` — the alternative is +/// emitting `nan`, which no parser accepts and which would make the whole document unreadable. +fn json_number(n: f64, out: &mut String) { + if !n.is_finite() { + out.push_str("null"); + } else if n.fract() == 0.0 && n.abs() < 9.007_199_254_740_992e15 { + out.push_str(&format!("{}", n as i64)); + } else { + out.push_str(&format!("{n}")); + } +} + +fn json_write(v: &Value, out: &mut String, depth: usize) -> Option<()> { + if depth > MAX_DEPTH { + return None; + } + match v { + Value::Nil => out.push_str("null"), + Value::Boolean(b) => out.push_str(if *b { "true" } else { "false" }), + Value::Integer(i) => out.push_str(&format!("{i}")), + Value::Number(n) => json_number(*n, out), + Value::String(s) => { + // A Lua string is bytes; a non-UTF-8 one has no JSON spelling, so it is written + // lossily rather than dropping the document. CBOR is the lossless transport here and + // the JSON view is for a human to read and edit (pfUI's Decode button). + json_escape(&s.to_string_lossy(), out); + } + Value::Table(t) => { + if let Some(n) = array_len(t) { + out.push('['); + for i in 1..=n { + if i > 1 { + out.push(','); + } + json_write(&t.raw_get::(i as i64).ok()?, out, depth + 1)?; + } + out.push(']'); + } else { + out.push('{'); + let mut first = true; + for pair in t.clone().pairs::() { + let (k, val) = pair.ok()?; + if !first { + out.push(','); + } + first = false; + // Every JSON key is a string; a numeric Lua key is written as its decimal. + match &k { + Value::String(s) => json_escape(&s.to_string_lossy(), out), + Value::Integer(i) => json_escape(&i.to_string(), out), + Value::Number(n) => { + let mut key = String::new(); + json_number(*n, &mut key); + json_escape(&key, out); + } + // A boolean or table key has no JSON spelling; the pair is dropped rather + // than the document, which is what a lossy *view* should do. + _ => { + first = true; + out.pop(); + continue; + } + } + out.push(':'); + json_write(&val, out, depth + 1)?; + } + out.push('}'); + } + } + // A function, userdata or thread is not data. `null`, so the document stays well-formed. + _ => out.push_str("null"), + } + Some(()) +} + +struct Json<'a> { + b: &'a [u8], + i: usize, +} + +impl Json<'_> { + fn ws(&mut self) { + while self.b.get(self.i).is_some_and(u8::is_ascii_whitespace) { + self.i += 1; + } + } + fn eat(&mut self, c: u8) -> Option<()> { + (self.b.get(self.i)? == &c).then(|| self.i += 1) + } + fn lit(&mut self, word: &[u8]) -> Option<()> { + self.b.get(self.i..self.i + word.len())?.eq(word).then(|| { + self.i += word.len(); + }) + } + fn string(&mut self) -> Option { + self.eat(b'"')?; + let mut out = String::new(); + loop { + let c = *self.b.get(self.i)?; + self.i += 1; + match c { + b'"' => return Some(out), + b'\\' => { + let e = *self.b.get(self.i)?; + self.i += 1; + match e { + b'"' => out.push('"'), + b'\\' => out.push('\\'), + b'/' => out.push('/'), + b'b' => out.push('\u{8}'), + b'f' => out.push('\u{c}'), + b'n' => out.push('\n'), + b'r' => out.push('\r'), + b't' => out.push('\t'), + b'u' => { + let hex = std::str::from_utf8(self.b.get(self.i..self.i + 4)?).ok()?; + self.i += 4; + let n = u32::from_str_radix(hex, 16).ok()?; + // A lone surrogate half has no character; it becomes U+FFFD rather + // than failing the whole document. + out.push(char::from_u32(n).unwrap_or('\u{fffd}')); + } + _ => return None, + } + } + c => { + // Bytes above ASCII are passed through: the input is UTF-8 by the spec and a + // multi-byte sequence arrives one byte at a time here. + out.push_str(std::str::from_utf8(&[c]).map_or("\u{fffd}", |s| s)); + if c >= 0x80 { + // Re-read the whole sequence: push the continuation bytes verbatim. + out.pop(); + let start = self.i - 1; + let len = match c { + 0xc0..=0xdf => 2, + 0xe0..=0xef => 3, + 0xf0..=0xf7 => 4, + _ => 1, + }; + let seq = self.b.get(start..start + len)?; + out.push_str(std::str::from_utf8(seq).unwrap_or("\u{fffd}")); + self.i = start + len; + } + } + } + } + } + fn value(&mut self, lua: &Lua, depth: usize) -> Option { + if depth > MAX_DEPTH { + return None; + } + self.ws(); + match *self.b.get(self.i)? { + b'n' => { + self.lit(b"null")?; + Some(Value::Nil) + } + b't' => { + self.lit(b"true")?; + Some(Value::Boolean(true)) + } + b'f' => { + self.lit(b"false")?; + Some(Value::Boolean(false)) + } + b'"' => Some(Value::String(lua.create_string(self.string()?).ok()?)), + b'[' => { + self.i += 1; + let t = lua.create_table().ok()?; + self.ws(); + if self.eat(b']').is_some() { + return Some(Value::Table(t)); + } + let mut n = 0i64; + loop { + let v = self.value(lua, depth + 1)?; + n += 1; + t.raw_set(n, v).ok()?; + self.ws(); + if self.eat(b',').is_some() { + continue; + } + self.eat(b']')?; + return Some(Value::Table(t)); + } + } + b'{' => { + self.i += 1; + let t = lua.create_table().ok()?; + self.ws(); + if self.eat(b'}').is_some() { + return Some(Value::Table(t)); + } + loop { + self.ws(); + let k = self.string()?; + self.ws(); + self.eat(b':')?; + let v = self.value(lua, depth + 1)?; + // A JSON null is an absent Lua value: setting it would be a no-op anyway, so + // the key is simply not created. + if !matches!(v, Value::Nil) { + t.raw_set(k, v).ok()?; + } + self.ws(); + if self.eat(b',').is_some() { + continue; + } + self.eat(b'}')?; + return Some(Value::Table(t)); + } + } + _ => { + let start = self.i; + while self + .b + .get(self.i) + .is_some_and(|c| matches!(c, b'0'..=b'9' | b'-' | b'+' | b'.' | b'e' | b'E')) + { + self.i += 1; + } + let text = std::str::from_utf8(self.b.get(start..self.i)?).ok()?; + let n: f64 = text.parse().ok()?; + Some(number_value(n)) + } + } + } +} + +/// A decoded number as the narrowest Lua value that holds it exactly: an integer where one fits, +/// so `GetSpellInfo(tonumber(x))`-style readers see an integer rather than `133.0`. +fn number_value(n: f64) -> Value { + if n.fract() == 0.0 && n.abs() <= 9.007_199_254_740_992e15 { + Value::Integer(n as i64) + } else { + Value::Number(n) + } +} + +// ── CBOR (RFC 8949) ───────────────────────────────────────────────────────────────────────────── + +/// Write one major-type header: the type in the top three bits, then the argument, minimally +/// encoded (§3). +fn cbor_head(major: u8, arg: u64, out: &mut Vec) { + let m = major << 5; + match arg { + 0..=23 => out.push(m | arg as u8), + 24..=0xff => out.extend_from_slice(&[m | 24, arg as u8]), + 0x100..=0xffff => { + out.push(m | 25); + out.extend_from_slice(&(arg as u16).to_be_bytes()); + } + 0x1_0000..=0xffff_ffff => { + out.push(m | 26); + out.extend_from_slice(&(arg as u32).to_be_bytes()); + } + _ => { + out.push(m | 27); + out.extend_from_slice(&arg.to_be_bytes()); + } + } +} + +fn cbor_write(v: &Value, out: &mut Vec, depth: usize) -> Option<()> { + if depth > MAX_DEPTH { + return None; + } + match v { + // `null` (§3.3). A Lua nil inside a table cannot be reached, so this is only ever a + // top-level or array-hole value. + Value::Nil => out.push(0xf6), + Value::Boolean(b) => out.push(if *b { 0xf5 } else { 0xf4 }), + Value::Integer(i) => cbor_int(*i, out), + Value::Number(n) => { + if n.fract() == 0.0 && n.abs() <= 9.007_199_254_740_992e15 { + cbor_int(*n as i64, out); + } else { + // float64 (§3.3): the only width written, so no reader has to agree with us about + // when a narrower one is safe. Every reader accepts all three. + out.push(0xfb); + out.extend_from_slice(&n.to_be_bytes()); + } + } + Value::String(s) => { + let bytes = s.as_bytes(); + // A valid UTF-8 string is major type 3 (text), anything else major type 2 (bytes): + // a Lua string is bytes, and claiming text for a binary one makes a strict decoder + // reject the document. + let major = if std::str::from_utf8(&bytes).is_ok() { + 3 + } else { + 2 + }; + cbor_head(major, bytes.len() as u64, out); + out.extend_from_slice(&bytes); + } + Value::Table(t) => { + if let Some(n) = array_len(t) { + cbor_head(4, n as u64, out); + for i in 1..=n { + cbor_write(&t.raw_get::(i as i64).ok()?, out, depth + 1)?; + } + } else { + let pairs: Vec<(Value, Value)> = t + .clone() + .pairs::() + .collect::>() + .ok()?; + cbor_head(5, pairs.len() as u64, out); + for (k, val) in pairs { + cbor_write(&k, out, depth + 1)?; + cbor_write(&val, out, depth + 1)?; + } + } + } + _ => out.push(0xf6), + } + Some(()) +} + +/// An integer as major type 0 (non-negative) or 1 (negative, encoded as `-1 - n`). +fn cbor_int(i: i64, out: &mut Vec) { + if i >= 0 { + cbor_head(0, i as u64, out); + } else { + cbor_head(1, (-1 - i) as u64, out); + } +} + +struct Cbor<'a> { + b: &'a [u8], + i: usize, +} + +impl Cbor<'_> { + fn byte(&mut self) -> Option { + let b = *self.b.get(self.i)?; + self.i += 1; + Some(b) + } + fn take(&mut self, n: usize) -> Option<&[u8]> { + let s = self.b.get(self.i..self.i.checked_add(n)?)?; + self.i += n; + Some(s) + } + /// The argument of a header's low five bits (§3). `None` for the indefinite-length and + /// reserved forms, which this decoder does not accept — see the `value` arm. + fn arg(&mut self, low: u8) -> Option { + Some(match low { + 0..=23 => u64::from(low), + 24 => u64::from(self.byte()?), + 25 => u64::from(u16::from_be_bytes(self.take(2)?.try_into().ok()?)), + 26 => u64::from(u32::from_be_bytes(self.take(4)?.try_into().ok()?)), + 27 => u64::from_be_bytes(self.take(8)?.try_into().ok()?), + _ => return None, + }) + } + fn value(&mut self, lua: &Lua, depth: usize) -> Option { + if depth > MAX_DEPTH { + return None; + } + let head = self.byte()?; + let (major, low) = (head >> 5, head & 0x1f); + match major { + 0 => Some(number_value(self.arg(low)? as f64)), + 1 => { + let n = self.arg(low)?; + Some(number_value(-1.0 - n as f64)) + } + 2 | 3 => { + let n = self.arg(low)?; + // The length is checked against what is left before anything is allocated: a + // crafted 8-byte length must not reserve 16 exabytes. + let bytes = self.take(usize::try_from(n).ok()?)?; + Some(Value::String(lua.create_string(bytes).ok()?)) + } + 4 => { + let n = usize::try_from(self.arg(low)?).ok()?; + let t = lua.create_table().ok()?; + for i in 1..=n { + // One element at a time, so a huge declared count costs one allocation per + // element actually present and runs out of input instead of memory. + let v = self.value(lua, depth + 1)?; + t.raw_set(i as i64, v).ok()?; + } + Some(Value::Table(t)) + } + 5 => { + let n = usize::try_from(self.arg(low)?).ok()?; + let t = lua.create_table().ok()?; + for _ in 0..n { + let k = self.value(lua, depth + 1)?; + let v = self.value(lua, depth + 1)?; + // A nil key or value is not storable in Lua; the pair is skipped rather than + // failing a document that is otherwise fine. + if !matches!(k, Value::Nil) && !matches!(v, Value::Nil) { + t.raw_set(k, v).ok()?; + } + } + Some(Value::Table(t)) + } + // A tag (§3.4) wraps one data item: the tag number is read and discarded, so a tagged + // value decodes as its content rather than failing. Nothing in the pipeline here + // writes tags, and dropping the semantics is better than dropping the document. + 6 => { + self.arg(low)?; + self.value(lua, depth + 1) + } + 7 => match low { + 20 => Some(Value::Boolean(false)), + 21 => Some(Value::Boolean(true)), + // `null` and `undefined` both read as nil, which is Lua's one absence. + 22 | 23 => Some(Value::Nil), + 25 => { + let h = u16::from_be_bytes(self.take(2)?.try_into().ok()?); + Some(number_value(f16_to_f64(h))) + } + 26 => Some(number_value(f64::from(f32::from_be_bytes( + self.take(4)?.try_into().ok()?, + )))), + 27 => Some(number_value(f64::from_be_bytes( + self.take(8)?.try_into().ok()?, + ))), + // A simple value this decoder has no Lua spelling for. + _ => None, + }, + _ => None, + } + } +} + +/// IEEE half to double (§3.3's own appendix algorithm). Written out because a real encoder is +/// allowed to pick the narrowest float that round-trips, and a decoder that rejects half-floats +/// rejects documents that are perfectly legal. +fn f16_to_f64(h: u16) -> f64 { + let sign = if h & 0x8000 != 0 { -1.0 } else { 1.0 }; + let exp = ((h >> 10) & 0x1f) as i32; + let frac = f64::from(h & 0x3ff); + let v = match exp { + 0 => frac * 2f64.powi(-24), + 31 => { + return if frac == 0.0 { + sign * f64::INFINITY + } else { + f64::NAN + } + } + e => (frac / 1024.0 + 1.0) * 2f64.powi(e - 15), + }; + sign * v +} + +// ── The namespace ─────────────────────────────────────────────────────────────────────────────── + +pub(super) fn install(lua: &Lua) -> mlua::Result { + let t = lua.create_table()?; + + t.set( + "EncodeBase64", + lua.create_function(|lua, data: Option| { + let Some(data) = data else { + return Ok(Value::Nil); + }; + Ok(Value::String( + lua.create_string(b64_encode(&data.as_bytes()))?, + )) + })?, + )?; + t.set( + "DecodeBase64", + lua.create_function(|lua, text: Option| { + let Some(text) = text else { + return Ok(Value::Nil); + }; + let Ok(text) = std::str::from_utf8(&text.as_bytes()).map(str::to_string) else { + return Ok(Value::Nil); + }; + Ok(match b64_decode(&text) { + Some(bytes) => Value::String(lua.create_string(bytes)?), + None => Value::Nil, + }) + })?, + )?; + + // zlib, through the same `flate2` the MPQ sector reader uses. A real stream in both + // directions: a profile string is pasted between players, so ours has to be readable by the + // real client and the real client's by ours. + t.set( + "CompressString", + lua.create_function(|lua, data: Option| { + let Some(data) = data else { + return Ok(Value::Nil); + }; + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + if e.write_all(&data.as_bytes()).is_err() { + return Ok(Value::Nil); + } + Ok(match e.finish() { + Ok(bytes) => Value::String(lua.create_string(bytes)?), + Err(_) => Value::Nil, + }) + })?, + )?; + t.set( + "DecompressString", + lua.create_function(|lua, data: Option| { + let Some(data) = data else { + return Ok(Value::Nil); + }; + let mut out = Vec::new(); + // Capped: the input is pasted by the player, and a zlib bomb must not be able to ask + // this client for an unbounded allocation. 16 MiB is orders of magnitude past any + // profile (pfUI's whole config is tens of kilobytes). + const CAP: u64 = 16 << 20; + let bytes = data.as_bytes(); + let mut d = flate2::read::ZlibDecoder::new(&bytes[..]).take(CAP); + if d.read_to_end(&mut out).is_err() { + return Ok(Value::Nil); + } + Ok(Value::String(lua.create_string(out)?)) + })?, + )?; + + t.set( + "SerializeJSON", + lua.create_function(|lua, v: Value| { + let mut out = String::new(); + Ok(match json_write(&v, &mut out, 0) { + Some(()) => Value::String(lua.create_string(out)?), + None => Value::Nil, + }) + })?, + )?; + t.set( + "DeserializeJSON", + lua.create_function(|lua, text: Option| { + let Some(text) = text else { + return Ok(Value::Nil); + }; + let bytes = text.as_bytes(); + let mut p = Json { b: &bytes, i: 0 }; + Ok(p.value(lua, 0).unwrap_or(Value::Nil)) + })?, + )?; + + t.set( + "SerializeCBOR", + lua.create_function(|lua, v: Value| { + let mut out = Vec::new(); + Ok(match cbor_write(&v, &mut out, 0) { + Some(()) => Value::String(lua.create_string(out)?), + None => Value::Nil, + }) + })?, + )?; + t.set( + "DeserializeCBOR", + lua.create_function(|lua, data: Option| { + let Some(data) = data else { + return Ok(Value::Nil); + }; + let bytes = data.as_bytes(); + let mut p = Cbor { b: &bytes, i: 0 }; + Ok(p.value(lua, 0).unwrap_or(Value::Nil)) + })?, + )?; + + Ok(t) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::script::UiScript; + + /// RFC 4648 §10's own vectors, which is what makes this base64 and not a base64-shaped thing. + #[test] + fn base64_matches_the_rfcs_own_vectors() { + for (plain, encoded) in [ + ("", ""), + ("f", "Zg=="), + ("fo", "Zm8="), + ("foo", "Zm9v"), + ("foob", "Zm9vYg=="), + ("fooba", "Zm9vYmE="), + ("foobar", "Zm9vYmFy"), + ] { + assert_eq!(b64_encode(plain.as_bytes()), encoded, "encode {plain:?}"); + assert_eq!( + b64_decode(encoded).as_deref(), + Some(plain.as_bytes()), + "decode {encoded:?}" + ); + } + // pfUI chunks its own blob into 92-column lines, so the decoder has to skip whitespace. + assert_eq!(b64_decode("Zm9v\nYmFy").as_deref(), Some(&b"foobar"[..])); + assert_eq!( + b64_decode("not base64!"), + None, + "a symbol outside the alphabet" + ); + } + + /// RFC 8949's own appendix-A vectors for the forms this encoder emits. + #[test] + fn cbor_matches_the_rfcs_own_vectors() { + let enc = |v: &Value| { + let mut out = Vec::new(); + cbor_write(v, &mut out, 0).unwrap(); + out + }; + assert_eq!(enc(&Value::Integer(0)), vec![0x00]); + assert_eq!(enc(&Value::Integer(23)), vec![0x17]); + assert_eq!(enc(&Value::Integer(24)), vec![0x18, 0x18]); + assert_eq!(enc(&Value::Integer(1000)), vec![0x19, 0x03, 0xe8]); + assert_eq!(enc(&Value::Integer(-1)), vec![0x20]); + assert_eq!(enc(&Value::Integer(-1000)), vec![0x39, 0x03, 0xe7]); + assert_eq!(enc(&Value::Boolean(false)), vec![0xf4]); + assert_eq!(enc(&Value::Boolean(true)), vec![0xf5]); + assert_eq!(enc(&Value::Nil), vec![0xf6]); + assert_eq!( + enc(&Value::Number(1.1)), + vec![0xfb, 0x3f, 0xf1, 0x99, 0x99, 0x99, 0x99, 0x99, 0x9a], + "float64, the one width this writes" + ); + + // And the decoder reads the vectors the RFC gives for widths we never write, which is the + // half that matters for a blob from the real client. + let lua = mlua::Lua::new(); + let dec = |bytes: &[u8]| { + let mut p = Cbor { b: bytes, i: 0 }; + p.value(&lua, 0) + }; + assert!( + matches!(dec(&[0xf9, 0x3e, 0x00]), Some(Value::Number(n)) if (n - 1.5).abs() < 1e-9) + ); + assert!(matches!( + dec(&[0xfa, 0x47, 0xc3, 0x50, 0x00]), + Some(Value::Integer(100_000)) + )); + assert!(matches!(dec(&[0x18, 0x18]), Some(Value::Integer(24)))); + assert!(matches!( + dec(&[0x39, 0x03, 0xe7]), + Some(Value::Integer(-1000)) + )); + // A tagged value decodes as its content: `1(1363896240)`, the RFC's epoch example. + assert!(matches!( + dec(&[0xc1, 0x1a, 0x51, 0x4b, 0x67, 0xb0]), + Some(Value::Integer(1_363_896_240)) + )); + } + + /// A truncated or crafted blob answers nil: these arrive pasted from a forum. + #[test] + fn a_hostile_blob_answers_nil_rather_than_raising_or_allocating() { + let lua = mlua::Lua::new(); + let dec = |bytes: &[u8]| { + let mut p = Cbor { b: bytes, i: 0 }; + p.value(&lua, 0) + }; + // A declared 8-byte length with nothing behind it: the length is checked against what is + // left before anything is reserved. + assert!(dec(&[0x5b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]).is_none()); + // An array claiming 2^32 elements, with none. + assert!(dec(&[0x9a, 0xff, 0xff, 0xff, 0xff]).is_none()); + assert!(dec(&[]).is_none(), "empty input"); + assert!(dec(&[0x7f]).is_none(), "indefinite-length text is refused"); + // Nesting past the cap, built as 200 nested one-element arrays. + let deep: Vec = std::iter::repeat_n(0x81u8, 200).chain([0x00]).collect(); + assert!(dec(&deep).is_none(), "nesting past the depth cap"); + } + + /// **pfUI's whole pipeline**, transcribed from `modules/share.lua:43-60`: a config table out + /// through CBOR, zlib and base64, and back in through the reverse. This is the test the + /// namespace exists for. + #[test] + fn pfuis_export_and_import_round_trip_through_the_real_formats() { + let s = UiScript::new().unwrap(); + let round = s + .eval::( + r#"local MAGIC = "!pf1!" + local config = { + chat = { bubbles = { alpha = ".75", borders = "1" } }, + unitframes = { player = { width = 160, height = 40 } }, + nested = { { 1, 2, 3 }, { "a", "b" } }, + } + local blob = MAGIC .. C_EncodingUtil.EncodeBase64( + C_EncodingUtil.CompressString( + C_EncodingUtil.SerializeCBOR(config))) + local back = C_EncodingUtil.DeserializeCBOR( + C_EncodingUtil.DecompressString( + C_EncodingUtil.DecodeBase64(string.sub(blob, string.len(MAGIC) + 1)))) + return back.chat.bubbles.alpha == ".75" + and back.chat.bubbles.borders == "1" + and back.unitframes.player.width == 160 + and back.nested[1][2] == 2 + and back.nested[2][1] == "a" + and table.getn(back.nested) == 2"#, + ) + .unwrap(); + assert!(round, "pfUI's own export/import pipeline round-trips"); + + // The compression really compresses, and the blob really is base64. + assert!(s + .eval::( + r#"local long = string.rep("pfUI config value; ", 400) + local z = C_EncodingUtil.CompressString(long) + return string.len(z) < string.len(long) / 4 + and C_EncodingUtil.DecompressString(z) == long"# + ) + .unwrap()); + assert!(s + .eval::( + r#"local b = C_EncodingUtil.EncodeBase64("foobar") + return b == "Zm9vYmFy" and C_EncodingUtil.DecodeBase64(b) == "foobar""# + ) + .unwrap()); + } + + /// The JSON view pfUI's Decode button shows, and reads back. + #[test] + fn json_round_trips_and_spells_numbers_as_lua_sees_them() { + let s = UiScript::new().unwrap(); + assert_eq!( + s.eval::(r#"return C_EncodingUtil.SerializeJSON({ a = 1, b = "two" }) "#) + .unwrap() + .len(), + // Key order is a table's own, so the text is one of two spellings; the length is the + // same either way and the round trip below is what pins the content. + r#"{"a":1,"b":"two"}"#.len() + ); + assert!(s + .eval::( + r#"local t = C_EncodingUtil.DeserializeJSON('{"n":133,"f":1.5,"s":"x","arr":[1,2],"t":true,"nil":null}') + return t.n == 133 and t.f == 1.5 and t.s == "x" + and t.arr[2] == 2 and table.getn(t.arr) == 2 + and t.t == true and t["nil"] == nil"# + ) + .unwrap()); + // An exact integer is written without a decimal point, so a reader doing string work on + // it does not see `133.0`. + assert_eq!( + s.eval::(r#"return C_EncodingUtil.SerializeJSON({ 133, 1.5 })"#) + .unwrap(), + "[133,1.5]", + "an array, because the keys are exactly 1..n" + ); + // Malformed JSON is nil, not a raise: the text comes out of an edit box. + assert!(s + .eval::(r#"return C_EncodingUtil.DeserializeJSON("{not json") == nil"#) + .unwrap()); + } + + /// A binary string survives CBOR as a byte string rather than being claimed as text, and a + /// zlib stream is binary — which is exactly what the pipeline nests. + #[test] + fn a_binary_string_round_trips_as_bytes() { + let s = UiScript::new().unwrap(); + assert!(s + .eval::( + r#"local raw = string.char(0, 255, 128, 10, 13) + local back = C_EncodingUtil.DeserializeCBOR(C_EncodingUtil.SerializeCBOR(raw)) + return back == raw"# + ) + .unwrap()); + assert!( + s.eval::( + r#"local z = C_EncodingUtil.CompressString(string.char(0,1,2,3,0,0,0)) + return C_EncodingUtil.DecompressString(z) == string.char(0,1,2,3,0,0,0)"# + ) + .unwrap(), + "a NUL-bearing string survives the whole zlib round trip" + ); + // Decompressing something that is not a zlib stream is nil, not a raise. + assert!(s + .eval::(r#"return C_EncodingUtil.DecompressString("not zlib") == nil"#) + .unwrap()); + } +} diff --git a/crates/benilla-ui/src/script/compat/classic_api/equipment_set.rs b/crates/benilla-ui/src/script/compat/classic_api/equipment_set.rs new file mode 100644 index 00000000..aa00fe78 --- /dev/null +++ b/crates/benilla-ui/src/script/compat/classic_api/equipment_set.rs @@ -0,0 +1,137 @@ +//! ClassicAPI's `EquipmentSet` namespace (`C_EquipmentSet`): the 4.3 gear manager's thirteen +//! members, backported by upstream onto a client that has no such subsystem at all. +//! +//! **Registered, and answers "there are no sets".** `CanUseEquipmentSets()` is `false`, which is +//! the one member every real caller asks first — pfUI's whole backported GearManagerDialog opens +//! with `if not C_EquipmentSet or not C_EquipmentSet.CanUseEquipmentSets() then return end` +//! (`modules/equipmentmanager.lua:5`), so the module stays dormant exactly as it is today. The +//! other twelve answer emptily rather than being absent, because the gate is one line in one +//! addon and the next caller to skip it would index a nil namespace. +//! +//! **Why not build it.** Equipment sets are *state*, not a reading: a named set per character, an +//! item location per slot, a per-set ignored-slot mask, all persisted across sessions, plus an +//! equip action that moves items out of bags into slots in an order that does not strand a +//! two-handed weapon against an off-hand. None of that exists on this client, 1.12's wire has no +//! concept of it, and every byte of it would be benilla's own invention — which is a feature to +//! decide on, not a compatibility gap to close. The row in `docs/ADDON_COMPAT.md` carries the +//! same note, and pfUI supplies the whole UI, so the day it is built the manager appears with it. +//! +//! The shapes below are upstream's: an id is a number, a set list is an array of ids, and +//! `GetEquipmentSetInfo` answers the four-value tuple `(name, icon, setID, isEquipped)`. + +use mlua::{Lua, MultiValue, Table, Value}; + +pub(super) fn install(lua: &Lua) -> mlua::Result
{ + let t = lua.create_table()?; + + // The gate. `false`, not nil: a caller testing `not C_EquipmentSet.CanUseEquipmentSets()` + // reads either the same way, but one that stores the answer and prints it wants a boolean. + t.set( + "CanUseEquipmentSets", + lua.create_function(|_, ()| Ok(false))?, + )?; + + // The readers. A set id nobody has answers nothing at all (no returns), which is upstream's + // own shape for an unknown id and what distinguishes "no such set" from "a set with no name". + for name in [ + "GetEquipmentSetID", + "GetEquipmentSetInfo", + "EquipmentSetContainsLockedItems", + "GetIgnoredSlots", + ] { + t.set( + name, + lua.create_function(|_, _args: MultiValue| Ok(MultiValue::new()))?, + )?; + } + + // The id list: an **empty table**, not nothing. Every caller walks it (`for _, id in + // ipairs(C_EquipmentSet.GetEquipmentSetIDs())`), and `ipairs(nil)` raises — the same lesson + // `C_ChatBubbles` carries. + t.set( + "GetEquipmentSetIDs", + lua.create_function(|lua, ()| lua.create_table())?, + )?; + + // The writers. Each answers nothing, so a caller that checks the result sees failure rather + // than believing a set was saved: a silent success here would make the manager draw a set + // that does not exist and lose the player's gear choice the next time they clicked it. + for name in [ + "CreateEquipmentSet", + "DeleteEquipmentSet", + "ModifyEquipmentSet", + "SaveEquipmentSet", + "UseEquipmentSet", + "IgnoreSlotForSave", + "ClearIgnoredSlotsForSave", + ] { + t.set( + name, + lua.create_function(|_, _args: MultiValue| Ok(Value::Nil))?, + )?; + } + + Ok(t) +} + +#[cfg(test)] +mod tests { + use crate::script::UiScript; + + /// pfUI's gate (`modules/equipmentmanager.lua:5`), transcribed: the module stays dormant, and + /// it does so by *reading* the answer rather than by the namespace being absent. + #[test] + fn the_gate_is_false_so_the_backported_manager_stays_dormant() { + let s = UiScript::new().unwrap(); + assert!(s + .eval::( + r#"if not C_EquipmentSet or not C_EquipmentSet.CanUseEquipmentSets() then + return true + end + return false"# + ) + .unwrap()); + assert!( + !s.eval::("return C_EquipmentSet.CanUseEquipmentSets()") + .unwrap(), + "false rather than nil: a caller that stores and prints the answer wants a boolean" + ); + assert_eq!( + s.eval::("return type(C_EquipmentSet.CanUseEquipmentSets())") + .unwrap(), + "boolean" + ); + } + + /// The twelve others answer emptily rather than raising — the point of registering at all, + /// since the gate is one line in one addon and the next caller may skip it. + #[test] + fn every_other_member_answers_emptily_rather_than_raising() { + let s = UiScript::new().unwrap(); + // The id list is walkable: `ipairs(nil)` is the raise this prevents. + assert_eq!( + s.eval::( + r#"local n = 0 + for _ in ipairs(C_EquipmentSet.GetEquipmentSetIDs()) do n = n + 1 end + return n"# + ) + .unwrap(), + 0 + ); + // An unknown id answers no values at all, which is "no such set" rather than a blank one. + assert_eq!(s.arity("C_EquipmentSet.GetEquipmentSetInfo(1)").unwrap(), 0); + assert_eq!( + s.arity(r#"C_EquipmentSet.GetEquipmentSetID("Tank")"#) + .unwrap(), + 0 + ); + // And a write reports failure rather than a success that saved nothing. + assert!(s + .eval::(r#"return C_EquipmentSet.SaveEquipmentSet(1) == nil"#) + .unwrap()); + assert!(s + .eval::(r#"return C_EquipmentSet.UseEquipmentSet(1) == nil"#) + .unwrap()); + assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors()); + } +} diff --git a/crates/benilla-ui/src/script/compat/classic_api/loot_history.rs b/crates/benilla-ui/src/script/compat/classic_api/loot_history.rs new file mode 100644 index 00000000..718f45f8 --- /dev/null +++ b/crates/benilla-ui/src/script/compat/classic_api/loot_history.rs @@ -0,0 +1,81 @@ +//! ClassicAPI's `LootHistory` namespace (`C_LootHistory`): the group-loot roll log, a 4.x window +//! upstream backports. +//! +//! **Registered, and answers an empty log.** pfUI builds the whole window +//! (`modules/loothistory.lua`) and drives it from three events — +//! `LOOT_HISTORY_FULL_UPDATE`, `LOOT_HISTORY_ROLL_CHANGED`, `LOOT_HISTORY_ROLL_COMPLETE` +//! (`:349-351`) — none of which this client fires, so its refresh never runs and the only member +//! reached today is `Clear()` from the window's own button (`:87`), unguarded. An empty log makes +//! that a no-op instead of `attempt to index global 'C_LootHistory' (a nil value)`. +//! +//! **What the real thing needs, which is not much and is not guessed at.** Both packets already +//! carry everything the three getters answer: `SMSG_LOOT_ROLL` has the roller, the item, the roll +//! number and the vote, and `SMSG_LOOT_ROLL_WON` has the winner and the winning roll +//! (`benilla-protocol`'s `loot.rs`), and `benilla-app`'s `ui_loot_roll.rs` already handles both. +//! So a log is: a bounded list of rolls, each accumulating one `(name, class, rollType, roll)` row +//! per roller as the announcements arrive, plus the three events fired on the same edges. The two +//! pieces that need care are the names and classes — a roller's guid has to resolve through the +//! name cache, which is ask-once and may answer a frame later — and the bound, since a long raid +//! would otherwise keep every roll of the night. +//! +//! It is not built because it is a window nobody is missing yet, and because the getters' tuple +//! order is the one thing here that *is* a guess: pfUI reads +//! `rollID, itemLink, numPlayers, isDone, winnerIdx` from `GetItem` and +//! `name, class, rollType, roll` from `GetPlayerInfo`, and those readings are the only evidence +//! for them. Building the log would make that guess load-bearing; answering an empty one does not. + +use mlua::{Lua, MultiValue, Table, Value}; + +pub(super) fn install(lua: &Lua) -> mlua::Result
{ + let t = lua.create_table()?; + + // The count every reader branches on first. `0`, not nil: pfUI does `for i = 1, num` with it. + t.set("GetNumItems", lua.create_function(|_, ()| Ok(0i64))?)?; + + // Past the end of an empty log: no returns at all. pfUI's row painter reads the first value + // as `rollID` and tests it (`if rollID and expanded[rollID]`), so nothing is the honest + // answer and an invented zero would index its `expanded` table on a roll that never happened. + for name in ["GetItem", "GetPlayerInfo"] { + t.set( + name, + lua.create_function(|_, _args: MultiValue| Ok(MultiValue::new()))?, + )?; + } + + // The window's Clear button (`loothistory.lua:87`), the one member reached today. A no-op + // that answers nothing: there is nothing to clear, and it must not raise. + t.set("Clear", lua.create_function(|_, ()| Ok(Value::Nil))?)?; + + Ok(t) +} + +#[cfg(test)] +mod tests { + use crate::script::UiScript; + + /// The window's own two reads, transcribed: the Clear button (`:87`) and the refresh's count + /// (`:303`). Neither raises, and the count is a number the `for` loop can take. + #[test] + fn an_empty_log_answers_zero_and_clear_is_a_no_op() { + let s = UiScript::new().unwrap(); + assert_eq!( + s.eval::("return C_LootHistory.GetNumItems()").unwrap(), + 0 + ); + s.run("C_LootHistory.Clear()").unwrap(); + assert_eq!( + s.eval::( + r#"local n = 0 + for i = 1, C_LootHistory.GetNumItems() do n = n + 1 end + return n"# + ) + .unwrap(), + 0, + "the refresh walks nothing" + ); + // And a row read past the end answers nothing, which pfUI's painter tests for. + assert_eq!(s.arity("C_LootHistory.GetItem(1)").unwrap(), 0); + assert_eq!(s.arity("C_LootHistory.GetPlayerInfo(1, 1)").unwrap(), 0); + assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors()); + } +} diff --git a/crates/benilla-ui/src/script/compat/classic_api/mod.rs b/crates/benilla-ui/src/script/compat/classic_api/mod.rs index d14df489..efa73232 100644 --- a/crates/benilla-ui/src/script/compat/classic_api/mod.rs +++ b/crates/benilla-ui/src/script/compat/classic_api/mod.rs @@ -36,7 +36,9 @@ mod container; mod creature_info; mod cvar; mod date_and_time; +mod encoding_util; mod enums; +mod equipment_set; mod event_util; mod expansion; mod faction; @@ -49,6 +51,7 @@ mod input; mod item; mod item_location; mod item_mixin; +mod loot_history; mod loss_of_control; mod lua_ext; mod macro_ext; @@ -113,6 +116,9 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { g.set("C_DateAndTime", date_and_time::install(lua)?)?; g.set("C_ChatBubbles", chat_bubbles::install(lua)?)?; g.set("C_ChatInfo", chat_info::install(lua)?)?; + g.set("C_EncodingUtil", encoding_util::install(lua)?)?; + g.set("C_EquipmentSet", equipment_set::install(lua)?)?; + g.set("C_LootHistory", loot_history::install(lua)?)?; g.set("C_LossOfControl", loss_of_control::install(lua)?)?; g.set("C_Container", container::install(lua)?)?; g.set("C_CreatureInfo", creature_info::install(lua)?)?; diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index 5aba6f72..59c3ca1a 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -313,6 +313,15 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> { // the loss-of-control count — so an absent namespace is a raise, not a missing feature. "C_ChatInfo", "C_LossOfControl", + // ClassicAPI's `C_EncodingUtil` (base64, zlib, JSON, CBOR), `C_EquipmentSet` and + // `C_LootHistory`. The first is pure algorithm and fully built — pfUI's profile sharing + // rides it, and a blob is pasted between players, so it is real zlib and real CBOR rather + // than formats invented here. The other two are subsystems 1.12 does not have at all, and + // each answers an honest nothing (no sets, an empty log) rather than being absent: pfUI + // builds both windows and reaches one member of each outside its own gate. + "C_EncodingUtil", + "C_EquipmentSet", + "C_LootHistory", // ClassicAPI's detection pair (script/compat/classic_api/mod.rs): pfUI gates its whole // load on `CLASSIC_API_VERSION`, so this one is load-bearing, not cosmetic. "CLASSIC_API_VERSION",