v0.9

17. WoW Client Emulation

main · 1ce3a81 · latest release v0.9source on GitHub ↗

Status: planned (M9). This chapter describes the environment wowlua test runs addons in (Tooling §16.1): a Lua VM and a WoW engine written in Rust, faithful enough that a test catches what the game client would raise.

17.1 Goals

  • Fidelity over convenience. A test passes only if the same code would run in the client of that flavor. Where the emulation cannot know the client's behaviour, it raises a clear error instead of guessing.
  • The bugs that matter. Taint, combat lockdown, secret values, flavor differences and WoW's own Lua differences are where addons break in game; the emulation models them, and reports them better than the game does.
  • Verified against the client. Every behaviour is backed by the Lua 5.1 reference, Blizzard's documentation and UI code, or an in-game probe (/stdtest probe), and the verification is automated (§17.8).
  • One binary. The VM and the engine are part of wowlua, in Rust, with no C toolchain (decision T185).

17.2 The VM

A Lua 5.1 virtual machine in Rust, in its own crate (wowlua-vm).

  • Language. Lua 5.1 as the client runs it: values, tables with metatables and every metamethod, closures and upvalues, varargs, coroutines, setfenv/getfenv, weak tables, __gc on userdata, error values and pcall/xpcall with tracebacks, string.dump absent.
  • Input. The VM runs what the compiler produces: wowlua's output and plain Lua files, with the compiler's source maps, so every location the VM reports is a .wowlua (or .lua) line and column.
  • Standard library. Exact Lua 5.1 behaviour: Lua patterns, string.format (including %g and %q output byte for byte), tostring of numbers, table sorting, number parsing.
  • Determinism. Iteration order, random numbers (math.random, fastrandom, seeded per test), time and memory are deterministic, so a test run repeats exactly.
  • Instrumentation. Coverage counters (lines, statements, branches, functions, conditions) and profiling are counted by the VM, not inserted into the code (§17.6, Tooling §16.1.6).
  • Limits. Lua's limits apply as in the client (200 locals, 60 upvalues, C stack depth), so code that compiles but would not load in game fails here too.

Its design (§17.9): a register bytecode of its own, a tracing garbage collector, and stackless coroutines.

17.3 WoW's Lua

The differences from Lua 5.1 elsewhere, as probed on WoW: Forever (decisions S37, S38):

  • Libraries. No os, io, debug or package; date, time and difftime as globals. The client's additions to string, table and math (Types §2.8), bit, and WoW's global aliases and helpers (strsplit, strjoin, strtrim, tinsert, wipe, debugstack, debuglocals, debugprofilestop, geterrorhandler, …).
  • Arithmetic. x / 0 and x % 0 raise "Division by zero"; math.sqrt and math.log raise on a negative argument; string.format("%d", x) raises for an infinite x.
  • Limits. The client's limits, at its thresholds, from crash-search probes (/stdtest crashlimit, separate from /stdtest probe): a C function's frame holds 8000 slots with its arguments (unpack and string.byte fail beyond, "too many results to unpack", "stack overflow (string slice too long)"); strsplit returns at most 4000 pieces ("strsplit(): Stack overflow"). The client runs its Lua with API assertions on and gives C functions 100 free slots: pcall returning its argument count plus 100 or more values (xpcall one fewer, its handler takes a slot; pcallwithenv three fewer, it pushes the function and its environment after them) pushes its status past them, the assertion L->top < L->ci->top fails and the game crashes. Such calls fail with an error naming the call (decision T194). The call depth is Lua 5.1's: a thread's call-info array (a slot per Lua or C call) doubles from 8, so the first "stack overflow" comes at 16384 calls; after one, the array is cut back to 20000 slots and each collection may halve it again (the next overflow comes at 20000, then 19968, 19456 or 18432), and while handling one it may grow to 32768 ("error in error handling"). Nested C calls (pcall, metamethods, loadstring's parser, which counts its syntax levels on from them) stop at 200 with "C stack overflow"; unlike stock Lua 5.1's, the client's coroutines continue their resumer's count, so nesting coroutine.resume stops there too instead of overflowing the C stack. securecall, securecallfunction, securecallmethod and the coroutine functions return up to the 8000 slots without crashing.
  • Per flavor. Where flavors differ (a library function, an arithmetic rule), the VM follows the flavor under test. Behaviour not yet probed on a flavor follows the newest probed client and is listed in the emulation's coverage report (§17.8).

17.3.1 Layers

The client's in-game environment, sorted from the client itself (/stdtest inventory, recorded in crates/wowlua-vm/tests/client/) against Blizzard's UI code and API documentation for the build:

Layer What Where wowlua has it
Lua runtime Lua 5.1's libraries and the client's C extensions: the string, table, math and bit additions, the aliases (strfind, tinsert, …), date/time, debugstack, the secure API, and the FrameScript functions (issecretvalue, securecallmethod, Mixin, …) the VM (Vm::wow)
Engine API C functions: the documented API (C_* and globals) and older undocumented ones (GetActionInfo, …) the engine (§17.6)
Blizzard's Lua FrameXML and SharedXML: print, tostringall, assertf, the degree-based sin/cos, mixins, and Lua wrappers even inside C_ namespaces (C_Traits.GetEntryInfo) Blizzard's own code, loaded by the engine (§17.7)
GlueXML the login and character screens' Lua not in game; not emulated

On WoW Forever 1.60.1 (70374): 5,037 documented C functions, 2,059 undocumented ones (151 of them the runtime's), 3,942 Lua functions, every one defined in the published UI code for the build.

17.4 Taint and secure execution

The client's security model, in the VM:

  • Secure and insecure code. Blizzard's code (the engine's Lua and, when loaded, FrameXML, §17.7) runs secure; addon code runs insecure, tainted with its addon's name.

  • Propagation. A value written by insecure code carries that taint; secure code that reads a tainted value becomes tainted for the rest of its execution. Taint follows table fields, globals, upvalues and locals, and crosses coroutines as in the client.

  • The secure API. issecure, issecurevariable, securecall, securecallfunction, secureexecuterange, hooksecurefunc (hooks run insecure without tainting the hooked call), forceinsecure, scrub, and the taint barriers the client has (error handlers, __gc).

  • Protected actions. Protected functions and frame methods are refused from tainted execution: the call does nothing, the client's event fires with the tainting addon's name and the action, and the test fails.

    • Protected global functions, always: ADDON_ACTION_FORBIDDEN with the addon and the action as Lua's debug information names the call ("MyAddon", "UseAction()" from UseAction(…), as /stdtest taint recorded; UNKNOWN() through pcall, /stdtest engine protected); the call returns its documented results' empty values (false from ClearTarget). The documentation does not mark them; the engine lists the known ones (targeting, casting, using actions and items, movement, macros), the harmless ones checked in game: TargetUnit, ClearTarget, FocusUnit, AssistUnit, SpellStopCasting and SpellStopTargeting, the movement stops and PetFollow are protected; ClearFocus, TargetLastTarget and StopAttack are not.
    • Protected methods (the documentation's IsProtectedFunction: Show, Hide, SetPoint, SetSize, SetParent, …) and SetAttribute of a protected frame, in combat lockdown: ADDON_ACTION_BLOCKED, after the method's own argument checks (/stdtest engine blocked). A frame is protected through a secure template (Secure…Template; IsProtected then gives true, true) or mock.protect.
  • Taint reports. A refusal reports the taint chain: the protected call, the secure code that became tainted, the value it read, and the addon code (file and line) that wrote that value, through any number of hops through secure code (decision T196). This is what taint.log cannot show:

    text
    UseAction() blocked (ADDON_ACTION_BLOCKED)  ActionButton_OnClick  FrameXML/ActionButton.lua:211    read field 'action'  written by ActionBarButton_Update    FrameXML/ActionButton.lua:98    while tainted by MyAddon, after reading  global 'ACTIONBAR_PAGE'    written by MyAddon  src/bars.wowlua:42

    Every tainted value knows the event behind its taint: the store that put it there, and what tainted the code that stored it; every tainted execution, the read that tainted it. Tests can also assert that a value or a handler stays secure, and get the same chain when it does not (decision T197).

17.5 Secret values

Secret values (WoW Forever and the 12.x clients) are a value kind of the VM, as the client has them (probed on WoW Forever 1.60.1, /stdtest probe):

  • What tainted code may do. A secret keeps its type: type, passing, storing as a table value, select, unpack, pcall and error work, and so does whatever needs only the type: truth tests of a secret number, string or table (a secret nil is false), not of those, and == with a value of another type (false) or of a secret nil with nil (true). Secret strings and numbers concatenate into a secret string, and tostring, string.format, strjoin and strconcat of a secret give a secret string.
  • What it may not. Arithmetic, comparisons (== of two values of one type too), #, indexing, calls, .. of other types, and a secret boolean's truth test fail with the client's error, naming the operand as Lua does: "attempt to compare local 's' (a secret number value, while execution tainted by 'MyAddon')", or without a name "attempt to compare a secret number value (execution tainted by 'MyAddon')". A secret table key fails for reads and writes ("attempted to index a table that cannot be indexed with secret keys"). Library functions see the type secret ("table expected, got secret") and refuse to convert one ("attempt to perform string conversion on a secret string value (execution tainted by …)"); the client's own functions with usage messages add "Secret values are only allowed during untainted execution for this argument".
  • The functions. secretwrap, issecretvalue, hasanysecretvalues, scrubsecretvalues, scrub, canaccesssecrets, canaccessvalue, canaccessallvalues, canaccesstable, issecrettable, dropsecretaccess, mapvalues, dumpobject, settablesecurity and securecopy behave as in the client.
  • Secure code may use secrets; what it computes from one is secret again (decision T195, from Blizzard's documentation: addon code cannot observe it).

The engine returns secrets where the client does: from the documentation's SecretReturns, SecretArguments and SecretWhen… flags, under the addon restrictions in effect (combat, encounters, challenge modes, PvP matches, restricted maps and chat lockdown; /stdtest restrictions records the client's by the restrictions active, in tests/client/forever-camelot-restrictions.lua). The rules above do not change with them: every conformance case gives the same result in combat on a restricted map as out of it.

17.6 The engine

The WoW API the VM's code calls, per flavor.

  • Generated. Every function, namespace, event, enum and widget method is generated from the flavor's API documentation (the same source as the wow-api declarations, decision T83): its signature, its existence in the flavor, whether it is protected or restricted, and which of its arguments and results are secret. A function the flavor lacks is absent, as in the client. The documentation comes as data (decision T199): wowlua-types-gen writes each flavor's as JSON, every key kept, to crates/wowlua-api/data/<flavor>.api.json.gz. Functions the client has but does not document (UseAction, about 2,000 on WoW Forever, from /stdtest inventory) exist too, with their behaviour or "not mocked".
  • Natives. The API functions are natives of the VM, as the client's are C functions (decision T198): [C] in stack traces, the client's argument errors, no taint of their own.
  • Behaviour. Functions have hand-written behaviour where tests need it: the widget system (frames, regions, textures, font strings, scripts, events and unit events, points and sizes, show and hide, parenting, templates, mixins, protected frames), the event loop and OnUpdate frames, timers on a virtual clock, combat lockdown, units, auras, spells and items (from test data), saved variables across reloads, slash commands, keybindings, addon messages, settings, tooltips, menus and dialogs. The M8 test mock is the starting point.
  • Layout. A region's rectangle comes from its points as the client lays it out: each edge from the points that set it (a LEFT and a RIGHT point give the width, a TOP and a BOTTOM the height), otherwise its set size placed by the axis's first point; GetSize, GetWidth and GetHeight give the laid-out size of an anchored region (unverified beyond fresh widgets; /stdtest engine records layoutSizes).
  • Not mocked. A generated function without behaviour raises "<name> is not mocked" when called, unless the project provides a mock (Tooling §16.1.4), so gaps are visible instead of silently returning nil.
  • Engine-side Lua. Lua the client defines in its engine (not FrameXML) is provided as secure code.

17.7 Blizzard's UI code

With test.blizzard-ui (Manifest §9.18), the test runner loads the flavor's FrameXML (Lua and XML from wow-ui-source, at the client version the API declarations come from) as secure code before the addon. Tests then run against the real Edit Mode, Settings, action bars, unit frames and the rest, so taint the addon spreads into Blizzard's code, and Blizzard code that fails because of what an addon did, show up as in game. Off by default: mock-only tests start faster (decision T186).

The code comes from wow-ui-source at the flavor's client version and the global strings from the same dump the declarations use, downloaded on first use into a user cache keyed by version (decision T204; about 10 seconds and 45 MB per flavor, once: $WOWLUA_HOME/cache/blizzard-ui/, at the commit the flavor's declarations name; WOWLUA_UI_SOURCE names a local copy instead). The engine's loader (wowlua_engine::ui::load) takes the flavor's Interface folder and its global strings:

  • Startup set (as /stdtest engine recorded: the 201 Blizzard addons WoW Forever had loaded when StdTest loaded): every Blizzard addon allowed in the game (AllowLoad game or both) and for the flavor's game types (its id and family, for AllowLoadGameType and ExcludeLoadGameType), read from the client's suffixed TOC first (_Mainline.toc); a LoadOnDemand one only as a startup addon's dependency; OnlyBetaAndPTR ones on beta and PTR clients (WoW Forever runs from _classic_beta_).
  • Load order (inferred, unverified): LoadFirst ones first, each after its Dep, Dependencies, RequiredDep and OptionalDep addons, otherwise by name. File lines follow their inline directives; [Family], [Game] and [TextLocale] name the flavor's folders (Mainline, Camelot, enUS). ADDON_LOADED fires after each addon.
  • Global strings come first, from the client's GlobalStrings dump.
  • Lua files run as secure code with the addon's name and table.
  • XML builds widgets as the client does: virtual elements are templates (for inherits and CreateFrame), intrinsic ones frame types; an element's templates apply first, then its attributes, mixins, parentKey and parentArray, key values, sizes, anchors, layers, child frames and scripts (function, method or a body; inherit="prepend" / "append"); OnLoad runs once the widget and its children are built. Fonts and font families become font objects.
  • Gaps. Without game data or a recording, Blizzard's code gets empty values from functions that have no behaviour: secure code calling such a function gets its documented results' empty values (nil where nilable, false, 0, "", a table) and the engine lists the function; addon code still gets "not mocked". Secure code may register events and set scripts the engine's lists lack. On camelot (70425) the UI loads, logs in and runs frames without a Lua error, as the client's does (its probe logged none): enums, constants and namespaces the documentation lacks come from the client's recording (/stdtest engine), secure addons run in the client's secure environment, and Blizzard's errors are caught where ScriptErrors reports them (C_Log.LogErrorMessage). The CVars start at the client's defaults (the 248 the UI code reads that have one, recorded by /stdtest engine).
  • The client's cases. With camelot's UI code loaded (and the addon's saved variables loaded as the addon's), all 819 cases /stdtest probe recorded on WoW Forever 1.60.1 give the client's results, the 22 the VM alone cannot pass included (Blizzard's string.rtgsub, getglobal, the string metatable RestrictedExecution.lua sets up).

17.8 Verification

  • Lua 5.1 conformance. Against PUC Lua 5.1 (embedded only in wowlua's own tests):
    • cargo xtask corpus vm compiles every file of the conformance corpus to the same bytecode and line info as luac, and runs its main chunk with the same output and error.
    • cargo xtask corpus lua51 runs the official Lua 5.1 test suite. It excludes only what the client does not have, each with its reason: the io, os, debug and package libraries, string.dump, the implicit arg of vararg functions, string.gfind, and a 32-bit size_t overflow. Shims serve the suite's plumbing only (timing, temporary files in memory, loading its helper files).
    • Differential tests compare snippets' output and errors.
  • Sources of truth. In-game probes on the current client first, then Blizzard's documentation and UI code for the build (the generated API documentation's SecretArguments, ProtectedFunction, HasRestrictions, SecureHooksAllowed and SecretWhen… flags, FrameScriptDocumentation). elune (an open reimplementation of an older client) is only a hint: a rule that rests on it alone is listed as unverified in the coverage report.
  • Taint. StdTest's taint experiments (/stdtest taint) taint one value on a path the client's secure code takes to a protected function and record whether the action is blocked; the VM must agree. On WoW Forever 1.60.1 (tests/client/forever-camelot-taint.lua): secure code reading a tainted global (Lua or C function) or table field is tainted, and the protected call raises ADDON_ACTION_FORBIDDEN naming the addon; hooksecurefunc leaves the hooked variable secure and the click allowed; setfenv taints the function itself (its variable stays secure, the click is forbidden). With camelot's UI code loaded, the engine reproduces all eight: the action button's click reaches UseAction through Blizzard's own SecureTemplates.lua and is forbidden exactly where the client forbade it (wowlua-engine's tests/taint.rs). elune's taint cases (vendored in crates/wowlua-vm/tests/elune/) also pass on the VM, but count only where the experiments confirm them.
  • The client's behaviour. Every WoW-specific rule (§17.3 to §17.5) has a probe in StdTest whose recorded in-game result is a test fixture; a rule changes only with a new probe.
  • In-game regressions. Bugs found in game become emulation tests that must fail the same way: os.date in wowlua:format, 0 / 0 in wowlua:serialize, SuuUI's reparented status-bar containers breaking Edit Mode, and taint met in combat.
  • Coverage of the emulation. A report lists which API functions have behaviour, which are generated only, and which rules were probed on which flavor.

17.9 VM design

  • Execution (decision T190). The VM compiles the compiler's output to a register bytecode of its own, close to Lua 5.1's design. Each instruction carries its source span (from the source maps), and coverage, taint and profiling attach per instruction.
  • Memory (decision T191). A tracing garbage collector over an arena, in safe Rust, stopping the world at the interpreter's allocation points:
    • Cycles are collected and weak tables clear.
    • __gc runs as in the client: newest first and unprotected, so an error in a finalizer reaches the code that caused the collection.
    • collectgarbage reports memory, and step counts work, as Lua 5.1's incremental collector would.
  • Taint (decision T193). Exact, as the client tracks it: every stack slot, table field, upvalue and object carries the taint (the addon) of the code that wrote it, and each thread its execution's taint (§17.4).
  • Coroutines (decision T192). Stackless: each coroutine is its own VM stack, and a yield unwinds to its resume without the native stack. As in the client's Lua 5.1, a yield cannot cross pcall, a metamethod or another native call ("attempt to yield across metamethod/C-call boundary"); each thread has its own global table (setfenv(0, t)).