classicapi: C_EncodingUtil for real, and two namespaces 1.12 has no subsystem for
`C_EncodingUtil` is the one namespace in this family that is pure algorithm, which is why it is built properly rather than stubbed: all eight members, in the REAL formats. pfUI's profile sharing is the reader and its own header names the pipeline — SerializeCBOR → CompressString (zlib) → EncodeBase64, prefixed `!pf1!` — with the export path NOT inside a pcall, so clicking Export raised, and the import path inside one, so it failed silently. 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 (already the workspace's inflate for MPQ sectors and its compressor for CMSG_AUTH_SESSION's addon block, so this adds a crate to one more manifest and not to the build), RFC 4648 base64, RFC 8949 CBOR, RFC 8259 JSON. Nothing invented here — the trap a hand-rolled codec falls into is round-tripping with ITSELF while being wrong. The decoders are written for hostile input, because a profile string arrives pasted from a forum: every length is checked against what is actually left, nesting is capped, the inflate output is capped at 16 MiB, and a malformed blob answers nil rather than raising or allocating what a crafted header asked for. Tests run the RFCs' own vectors in both directions — including the half-float and tag forms we never write but a real encoder may — a truncated/overlong/deep-nested set, and pfUI's whole export/import pipeline end to end. `C_EquipmentSet` and `C_LootHistory` are subsystems 1.12 does not have at all, and both answer an honest nothing rather than being absent, because pfUI builds both windows and reaches one member of each OUTSIDE its own gate (the equipment manager's `CanUseEquipmentSets` test is one line in one addon; the loot window's Clear button has no test at all). What each would take is written down rather than guessed at. Equipment sets are persistent state 1.12's wire has no concept of — a feature to decide on, not a gap to close, and pfUI already supplies the UI. The loot log is closer than it looks: both packets already carry what the getters answer and `ui_loot_roll.rs` handles them, so it is a bounded list plus three events — but the getters' tuple order is the one thing here that IS a guess, read out of pfUI's own destructures, and building the log would make that guess load-bearing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
Generated
+1
@@ -645,6 +645,7 @@ dependencies = [
|
||||
name = "benilla-ui"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"mlua",
|
||||
"roxmltree",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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<usize> {
|
||||
let mut n = 0usize;
|
||||
for pair in t.clone().pairs::<Value, Value>() {
|
||||
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::<Value>(i as i64).ok()?, out, depth + 1)?;
|
||||
}
|
||||
out.push(']');
|
||||
} else {
|
||||
out.push('{');
|
||||
let mut first = true;
|
||||
for pair in t.clone().pairs::<Value, Value>() {
|
||||
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<String> {
|
||||
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<Value> {
|
||||
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<u8>) {
|
||||
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<u8>, 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::<Value>(i as i64).ok()?, out, depth + 1)?;
|
||||
}
|
||||
} else {
|
||||
let pairs: Vec<(Value, Value)> = t
|
||||
.clone()
|
||||
.pairs::<Value, Value>()
|
||||
.collect::<mlua::Result<_>>()
|
||||
.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<u8>) {
|
||||
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<u8> {
|
||||
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<u64> {
|
||||
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<Value> {
|
||||
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<Table> {
|
||||
let t = lua.create_table()?;
|
||||
|
||||
t.set(
|
||||
"EncodeBase64",
|
||||
lua.create_function(|lua, data: Option<mlua::String>| {
|
||||
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<mlua::String>| {
|
||||
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<mlua::String>| {
|
||||
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<mlua::String>| {
|
||||
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<mlua::String>| {
|
||||
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<mlua::String>| {
|
||||
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<u8> = 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::<bool>(
|
||||
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::<bool>(
|
||||
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::<bool>(
|
||||
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::<String>(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::<bool>(
|
||||
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::<String>(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::<bool>(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::<bool>(
|
||||
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::<bool>(
|
||||
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::<bool>(r#"return C_EncodingUtil.DecompressString("not zlib") == nil"#)
|
||||
.unwrap());
|
||||
}
|
||||
}
|
||||
@@ -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<Table> {
|
||||
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::<bool>(
|
||||
r#"if not C_EquipmentSet or not C_EquipmentSet.CanUseEquipmentSets() then
|
||||
return true
|
||||
end
|
||||
return false"#
|
||||
)
|
||||
.unwrap());
|
||||
assert!(
|
||||
!s.eval::<bool>("return C_EquipmentSet.CanUseEquipmentSets()")
|
||||
.unwrap(),
|
||||
"false rather than nil: a caller that stores and prints the answer wants a boolean"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("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::<i64>(
|
||||
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::<bool>(r#"return C_EquipmentSet.SaveEquipmentSet(1) == nil"#)
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return C_EquipmentSet.UseEquipmentSet(1) == nil"#)
|
||||
.unwrap());
|
||||
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
|
||||
}
|
||||
}
|
||||
@@ -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<Table> {
|
||||
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::<i64>("return C_LootHistory.GetNumItems()").unwrap(),
|
||||
0
|
||||
);
|
||||
s.run("C_LootHistory.Clear()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>(
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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)?)?;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user