Change a game menu from a plugin

A beginner guide to xScal's Scaleform movie API, version 2 (with version 3's attachCodeObject): read and change the game's menus and HUD while it runs.

1. What this is

The game draws its menus and HUD with Scaleform. Each menu is a SWF file, and the game keeps every loaded SWF as a movie. The movie API lets your plugin reach into a loaded movie and work with the ActionScript objects inside it, while the game runs. You write C++ in a plugin DLL. You do not touch the SWF files.

This guide uses include/xscal/movie.hpp, a C++20 header in the SDK that wraps the C interface XScalScaleformMovieV2 from plugin_api.h. For building, signing and installing the plugin, follow getting-started.html. This page does not repeat that.

What you can do

  • Read variables and members: movie.get("root1.Menu_mc"), value.get("text").
  • Change members: text, color, visibility, alpha, position, anything with a name.
  • Call ActionScript functions and read what they return.
  • Create objects (TextField, Sprite) and add them to a menu.
  • Give ActionScript a C++ function it can call.
  • Replace a function, and still call the original from your replacement.
  • Run code once per frame.
  • Look at, change or drop the calls that reach a menu through MovieRoot::Invoke. The game sends most HUD data another way (recipe h).

What you cannot do

  • You cannot edit SWF files or ActionScript bytecode. You only change objects in memory after the SWF has loaded. Nothing is saved back; the next time the menu opens, your plugin has to make the change again.
  • Sealed ActionScript 3 classes (a document class such as HUDMenu) refuse new members and refuse replacement of their methods. Tested in the game. See the note below.
  • You cannot delete a member, and you cannot list the members of an object. Value::remove sets the member to undefined; the name stays. Reflection is not available in the game (describeType, getQualifiedClassName and JSON all return null). To find out what an object has, walk the display children and read the member names you expect (recipe j).
  • A movie is reachable only after its root1 exists. Your code first runs in onLoad, not before.
  • Members declared internal or private in ActionScript cannot be read by name. get fails for them (tested).
  • You cannot build an ActionScript null. nullptr is sent as undefined.
  • Callbacks can run on different threads. Do not assume one thread (section 2).
Tested in the game: sealed classes refuse changes. A sealed class is an AS3 class that is not dynamic. In Fallout 76 (Steam 1.7.26.22) the document class of HUDMenu.swf (root1) and HUDImprovedBars refused new members (Value::set returned false) and refused replacement of a class method (set("displayMessage", fn) returned false, wrapMethod returned an empty Ref). Calling their public methods and reading their getters and setters works (displayMessage, configData, xpToLevelUp). Put your functions on a plain Object (movie.create("Object")) or on a MovieClip you create. Dynamic objects, MovieClips and function-valued variables accept replacement. Check the return value every time.

What was tested

These results come from three runs in Fallout 76 (Steam 1.7.26.22) with a test plugin.

  • Worked: swfName matching (the URL looks like Interface/HUDMenu.swf); get and set; invokeMember with 3, 6 and 7 arguments; Movie::invoke with 3 arguments; create of Object, Array, TextField and TextFormat; addChild; functions from registerCall and createFunction, called through invokeMember and through fn.call(this, ...) with the right this, arguments and return value; wrapMethod with forwardTo on a function stored in a dynamic Object member; retain and load across more than 400 frames; post() from a worker thread; load and unload events for every menu.
  • HUD mods that HUDModLoader loads live inside HUDMenu.swf. In one run root1 > child 52 (instance45) > content (the content of a Loader) was ImprovedBars.swf, found by its loaderInfo.url (recipe i).
  • Did not work: new members and method replacement on sealed classes, reading internal and private members, reflection, and invoke filters on HUD data (recipe h).

2. Key ideas in plain words

Movie

One loaded SWF. In C++ it is an xscal::Movie, a small handle (two pointers) that you receive as an argument of a callback. The game owns the movie; you borrow the handle for the length of the callback.

root1 and paths

root1 is the top object of the SWF. From there the movie is a tree of display objects, and each object has members. A path joins names with dots. Movie::get and Movie::invoke take a path. Value::get takes one member name of the object you already hold.

HUDMenu.swf               the movie
 └─ root1
     ├─ Menu_mc            root1.Menu_mc
     │   └─ Title_tf       root1.Menu_mc.Title_tf
     └─ (a Loader)         a mod SWF can sit here, with its own tree

The names in this tree and in the examples below are made up. Use names from your SWF; recipe j shows how to find them.

Values: owned and borrowed

An xscal::Value is one ActionScript value: a number, a string, an object, a function. A value that points to an object holds a reference to it. Until that reference is released, the game cannot free the object, so forgetting a release leaks it. Value releases for you in its destructor. That is why Value cannot be copied or moved: a second copy would release the same reference twice.

KindComes fromDestructor
OwnedMovie::get, invoke, create, function; Value::get, call, at, childAt, push, erase; Call::forwardToReleases the reference.
Borrowed viewCall::arg, Call::self, Args::at, Movie::loadDoes nothing. Someone else owns it.
Plain primitiveMovie::makeBool, makeInt, makeNumber, makeStringNothing to release.

Because a Value cannot be copied, write xscal::Value menu = movie.get("root1.Menu_mc"); and let the call construct it in place. A function can return a Value only as a fresh call result, not as a named local variable. To keep an object across callbacks, give it to Movie::retain and keep the returned Ref (recipe c).

A std::string_view you read from a Value points into that Value. Keep the Value in a variable while you use the view (recipe b).

Threads and callbacks

You never touch a movie from your own code at an arbitrary time. The host calls you, and only inside that call is the Movie handle valid. The callbacks are: onLoad, onUnload, onFrame, onGameInvoke, a function that ActionScript calls (recipe e), and posted work (recipe k). Do not store a Movie or a Value in a member variable or a global.

The callbacks do not all run on one thread, and a movie does not have one fixed thread. In the test the game loaded the HUD on one thread and ran its later frames on another. Lookup handlers and invoke filters run on the thread that made the game call; the others run on whatever thread advances or destroys the movie at that moment. If two callbacks share plugin state, protect it with a mutex or atomics. Host callbacks do not nest: while one of your callbacks runs, a game call it triggers does not start another callback.

swfName matching

MoviePatch("HUDMenu.swf") matches a movie whose root1.loaderInfo.url has the file name HUDMenu.swf. The comparison ignores case and ignores the directory. It compares the whole file name, so "HUDMenu" does not match. nullptr matches every movie. If a movie was already loaded when you install the patch, it gets onLoad on its next frame.

SWFs inside another movie

Mod loaders (for example HUD mods) load their own SWFs into the HUD. Such a SWF is not a movie of its own. It is a branch of the display tree of HUDMenu.swf, and a patch with its file name never fires. Find it by walking the display tree and reading loaderInfo.url of each child (recipe i). Those mods usually load after the HUD, so expect to look again a few seconds later.

3. Setup

Three things: ask the host for the interface, write a MoviePatch, install it in load().

  1. Query the interface

    xscal::queryMovieApi(host) returns a const xscal::MovieApi*. It is null when the host only has version 1 of the movie interface. Check it and return 0 from load() (or skip the feature) when it is null.

  2. Derive from MoviePatch

    Pass the SWF file name to the constructor and override what you need: onLoad, onUnload, onFrame (needs wantFrames), onGameInvoke (needs wantInvokes). Create one object per SWF with static storage duration. The host keeps the pointer until the game exits.

  3. Install it from load()

    install(api) returns false when the host refused a handler (table full, or the game's frame function could not be hooked). There is no uninstall.

A complete plugin source file. The plugin ID must start with the prefix of your certificate (see getting-started.html).

#include "xscal/movie.hpp"

namespace {

const XScalHost* host = nullptr;

class WatchHud final : public xscal::MoviePatch {
public:
    WatchHud() : MoviePatch("HUDMenu.swf") {}

    void onLoad(xscal::Movie& movie) override
    {
        xscal::log(host, "HUDMenu.swf loaded, url %s", movie.url());
    }
};

// Static storage: the host keeps this pointer until the game exits.
WatchHud watchHud;

int32_t __cdecl load(const XScalHost* loadingHost) noexcept
{
    if (!xscal::isHostUsable(loadingHost))
        return 0;
    host = loadingHost;

    const xscal::MovieApi* api = xscal::queryMovieApi(host);
    if (api == nullptr) {
        xscal::log(host, "This xScal has no movie API version 2");
        return 0;
    }
    if (!watchHud.install(api)) {
        xscal::log(host, "Could not install the HUDMenu.swf patch");
        return 0;
    }
    return 1;
}

}

extern "C" XSCAL_PLUGIN_EXPORT int32_t __cdecl XScalPlugin_Query(
    uint32_t hostAbiVersion, uint32_t descriptorSize, XScalPlugin* descriptor)
{
    return xscal::describePlugin(hostAbiVersion, descriptorSize, descriptor,
        {
            .id = "acme_hudwatch",
            .name = "HUD Watch",
            .load = &load,
        });
}

All recipes below use the same host variable and show only the new parts. Where a recipe has a register... function, call it from load() before install:

if (!registerFunctions(api) || !attachFunctions.install(api))
    return 0;
The movie API has no log of its own. Use xscal::log(host, "text %d", number). Output goes to xscal.log in the game folder, prefixed with your plugin name.

4. Recipes

Each recipe is a small, complete class. Paths such as root1.Menu_mc.Title_tf are placeholders. HUDMenu.swf is a real file name; the names inside it in these examples are not. The code compiles against movie.hpp. Which menus accept which changes depends on the game, so test each one in the game and read xscal.log.

a. Run code when a menu opens and closes

onLoad runs once per movie, after root1 exists. onUnload runs just before the movie is destroyed; the movie is still usable there. A menu that opens ten times gives ten loads and ten unloads.

class HudEvents final : public xscal::MoviePatch {
public:
    HudEvents() : MoviePatch("HUDMenu.swf") {}

    // root1 exists. The movie is ready to read and change.
    void onLoad(xscal::Movie& movie) override
    {
        xscal::Value root = movie.get("root1");
        xscal::log(host, "opened %s, root1 is %s", movie.url(),
            root.valid() ? "readable" : "missing");
    }

    // The movie is about to be destroyed but is still usable.
    void onUnload(xscal::Movie& movie) override
    {
        xscal::log(host, "closing %s", movie.url());
    }
};

HudEvents hudEvents;           // call hudEvents.install(api) from load()

Do not know the file name of a menu? Install a patch with nullptr as the name, open the menu, and read the log.

// A patch with swfName nullptr sees every movie. Use it once to learn the
// file names the game loads.
class LogEveryMovie final : public xscal::MoviePatch {
public:
    LogEveryMovie() : MoviePatch(nullptr) {}

    void onLoad(xscal::Movie& movie) override
    {
        xscal::log(host, "movie loaded: %s", movie.url());
    }
    void onUnload(xscal::Movie& movie) override
    {
        xscal::log(host, "movie closing: %s", movie.url());
    }
};

LogEveryMovie logEveryMovie;   // call logEveryMovie.install(api) from load()

b. Read and change values

get returns a Value. Test it with if (!value): it is invalid when the member does not exist or the object cannot be reached. Read primitives with read(out) (returns false on a wrong type) or as<T>(fallback). T is bool, double, int32_t, uint32_t or std::string_view.

The helpers setText, setHtml, setTextColor (0xRRGGBB), setVisible, setAlpha (0 to 1 in ActionScript) and setPosition are short forms of set("text", ...), set("htmlText", ...) and so on. setTextFormat(format) calls the TextField method of that name. Text in a TextField you create draws as boxes unless you set embedFonts and use a game font (recipe f). set takes a bool, a number, a string, nullptr (sent as undefined) or another Value, and returns false when the object refuses the write.

class ReadAndChange final : public xscal::MoviePatch {
public:
    ReadAndChange() : MoviePatch("HUDMenu.swf") {}

    void onLoad(xscal::Movie& movie) override
    {
        // Made-up path: use one that exists in your SWF.
        xscal::Value title = movie.get("root1.Menu_mc.Title_tf");
        if (!title) {
            xscal::log(host, "Title_tf not found");
            return;
        }

        // Read. 'text' owns the string, so keep it alive while you use the view.
        xscal::Value text = title.get("text");
        std::string_view current;
        if (text.read(current))
            xscal::log(host, "title is '%.*s'",
                static_cast<int>(current.size()), current.data());

        // Numbers and booleans come back by value. The argument is the
        // fallback used when the read fails.
        const double alpha = title.get("alpha").as<double>(1.0);
        const bool shown = title.get("visible").as<bool>(true);
        xscal::log(host, "alpha %.2f, visible %d, color %06X",
            alpha, shown ? 1 : 0, title.textColor());

        // Change.
        title.setText("Hello");
        title.setHtml("<b>Hello</b>");
        title.setTextColor(0xFFFFAA);
        title.setAlpha(0.5);
        title.setVisible(true);
        title.setPosition(100, 40);

        // Any other member.
        title.set("width", 300);
        title.set("selectable", false);
    }
};

ReadAndChange readAndChange;
The string view current points into text. Writing title.get("text").as<std::string_view>() in one line would leave the view pointing at a value that is already destroyed.

c. Run code every frame, keep objects between frames

Create the patch with wantFrames and override onFrame. It runs after each frame of the movie, before the movie is drawn. Keep it cheap: it runs every frame, so do real work every N frames or only when something changed.

A Value cannot outlive the callback. To use an object in a later callback, move it into the movie's reference table with retain. You get a Ref, a number you can store. In a later callback, load(ref) gives a borrowed view. drop(ref) frees the table entry. Ref has no destructor, so drop it yourself, in onUnload. The host also drops all references of a movie when the movie is destroyed.

#include <cstdio>

class FrameCounter final : public xscal::MoviePatch {
public:
    FrameCounter() : MoviePatch("HUDMenu.swf", wantFrames) {}

    void onLoad(xscal::Movie& movie) override
    {
        xscal::Value root = movie.get("root1");
        xscal::Value field = movie.create("flash.text.TextField");
        if (!root || !field)
            return;
        field.setPosition(20, 20);
        // A created TextField needs embedFonts, or its text draws as boxes
        // (recipe f). The font and color go in the htmlText below.
        field.set("embedFonts", true);
        field.set("autoSize", "left");
        root.call("addChild", field);

        // Keep the field for later frames. 'field' is now invalid.
        label_ = movie.retain(std::move(field));
        frames_ = 0;
    }

    void onFrame(xscal::Movie& movie) override
    {
        ++frames_;
        if (frames_ % 30 != 0)
            return;

        xscal::Value field = movie.load(label_);   // borrowed view
        if (!field)
            return;
        char text[96];
        std::snprintf(text, sizeof(text),
            "<font face='$MAIN_Font' size='20' color='#FFFFFF'>frames: %u</font>", frames_);
        field.setHtml(text);
    }

    void onUnload(xscal::Movie& movie) override
    {
        movie.drop(label_);
        label_ = {};
    }

private:
    xscal::Ref label_;
    uint32_t frames_ = 0;
};

FrameCounter frameCounter;

retain takes only an owned value and returns an empty Ref (test with if (!ref)) when the table is full (1024 entries). A Ref belongs to the movie that retained it. If the SWF can be loaded twice at the same time, keep one Ref per movie.identity().

d. Call ActionScript methods, return values, arrays

There are two ways to call.

  • movie.invoke(path, args...) goes through the game's own MovieRoot::Invoke with a full path. It does not need the Address Library invoke record.
  • value.call(name, args...) calls a method on an object you hold, with that object as this. It needs the invoke record. Without it the result is an invalid Value (section 5).

Arguments can be bool, numbers, string literals, std::string_view, nullptr (undefined) and valid Values, up to 16. The result is a Value; check valid() to tell a failed call from a call that returned nothing useful.

Arrays (and Vectors, and anything with a numeric length): size(), at(i) and setAt(i, x) read and write members and need no invoke record. push(...) and erase(index, count) call Array.push and Array.splice and do need it.

class CallExamples final : public xscal::MoviePatch {
public:
    CallExamples() : MoviePatch("HUDMenu.swf") {}

    void onLoad(xscal::Movie& movie) override
    {
        // By path, through the game's MovieRoot::Invoke. Needs no Address
        // Library invoke record. The path names a function; the call has no
        // "this" you choose.
        xscal::Value first = movie.invoke("root1.Menu_mc.SetTitle", "Hello", 3, true);
        if (!first)
            xscal::log(host, "SetTitle by path failed");

        // On an object you hold. Needs the invoke record.
        xscal::Value menu = movie.get("root1.Menu_mc");
        xscal::Value second = menu.call("SetTitle", "Hello", 3, true);
        if (!second)
            xscal::log(host, "menu.SetTitle failed");

        // Return value.
        xscal::Value countValue = menu.call("GetCount");
        const int32_t count = countValue.as<int32_t>(-1);
        xscal::log(host, "GetCount returned %d", count);

        // Arrays.
        xscal::Value items = menu.get("items");
        for (uint32_t i = 0; i < items.size(); ++i) {
            xscal::Value item = items.at(i);
            xscal::Value nameValue = item.get("name");
            std::string_view name;
            if (nameValue.read(name))
                xscal::log(host, "item %u: %.*s", i,
                    static_cast<int>(name.size()), name.data());
        }
        items.push("new entry");     // Array.push, needs the invoke record
        items.setAt(0, "first");     // items[0] = "first"
        items.erase(1, 1);           // Array.splice(1, 1), needs the invoke record
    }
};

CallExamples callExamples;
Strings given as std::string_view are copied into a 1024-byte buffer shared by all arguments of one call. A call with more text than that fails and returns an invalid Value. String literals and const char* are not copied and have no such limit.

e. Add your own function that ActionScript can call

xscal::defineFunction(api, lambda) registers a C++ function and returns an ID. Call it from load(). The lambda takes (xscal::Movie&, xscal::Call&) and must not capture anything; use namespace-level variables for state. Inside, call.self() is this, call.arg(i) is argument i (both borrowed views), and call.size() is the argument count. Set the result with call.returns(...): a number, a bool, a string, or an owned Value (moved in with std::move). Without a returns the result is undefined.

To use the function, turn the ID into a function object with movie.function(id) and store it in a member with set. Your function can be called by a SWF you ship, by another mod, or by you: as an event listener (recipe f) or as a replacement for an existing function (recipe g).

uint32_t addFunctionId = xscal::invalidFunctionId;
uint32_t describeFunctionId = xscal::invalidFunctionId;

// Call this from load(), before any movie loads.
bool registerFunctions(const xscal::MovieApi* api)
{
    // The lambda must not capture anything. Use namespace-level variables
    // for state.
    addFunctionId = xscal::defineFunction(api,
        [](xscal::Movie&, xscal::Call& call) {
            const double a = call.arg(0).as<double>();
            const double b = call.arg(1).as<double>();
            call.returns(a + b);
        });

    describeFunctionId = xscal::defineFunction(api,
        [](xscal::Movie&, xscal::Call& call) {
            // self() is the object the function was called on.
            xscal::Value self = call.self();
            xscal::Value name = self.get("name");
            std::string_view text;
            if (name.read(text))
                xscal::log(host, "called on '%.*s'",
                    static_cast<int>(text.size()), text.data());
            call.returns("done");
        });

    return addFunctionId != xscal::invalidFunctionId &&
        describeFunctionId != xscal::invalidFunctionId;
}

class AttachFunctions final : public xscal::MoviePatch {
public:
    AttachFunctions() : MoviePatch("HUDMenu.swf") {}

    void onLoad(xscal::Movie& movie) override
    {
        xscal::Value root = movie.get("root1");
        // ActionScript: root1.xscalAdd(2, 3)  ->  5
        if (!root.set("xscalAdd", movie.function(addFunctionId)))
            xscal::log(host, "root1 refused the new member");
        root.set("xscalDescribe", movie.function(describeFunctionId));
    }
};

AttachFunctions attachFunctions;

ActionScript side: var sum:Number = root1.xscalAdd(2, 3); gives 5. A string passed to returns as std::string_view can be at most 1023 bytes. returns gives false when it could not write the result.

Adding a new member works on dynamic objects and MovieClips. An instance of a sealed class (a Sprite subclass that is not dynamic, such as the document class of HUDMenu.swf) refuses new members: in the test set returned false. Attach the function to an object from movie.create("Object") or to a MovieClip you create, and give ActionScript that object.

f. Add a new element: text field and clickable button

movie.create("flash.text.TextField") makes a new ActionScript object. Set its properties with set, then add it to the display list with addChild on an existing container. A button is a Sprite with a rectangle drawn through its graphics object and a click listener. The listener is a function from recipe e: addEventListener("click", movie.function(id)). The function receives the event as call.arg(0).

Text in a TextField you create. Tested in the game: defaultTextFormat is ignored when the text is drawn, and text without an embedded font draws as boxes. These worked on screen:
  • Set embedFonts to true, set the text, then call setTextFormat(format) with a TextFormat whose font is a game font. A format copied with getTextFormat() from an existing HUD TextField had the font $MAIN_Font_Bold.
  • Set embedFonts to true and set htmlText to <font face='$MAIN_Font' size='20' color='#00FF00'>text</font>. The example below does this.
These did not work: defaultTextFormat with embedFonts, $MAIN_Font with embedFonts false, and no font at all.
uint32_t clickFunctionId = xscal::invalidFunctionId;

bool registerClickFunction(const xscal::MovieApi* api)
{
    clickFunctionId = xscal::defineFunction(api,
        [](xscal::Movie&, xscal::Call& call) {
            // arg(0) is the MouseEvent.
            xscal::Value event = call.arg(0);
            xscal::Value type = event.get("type");
            std::string_view name;
            if (type.read(name))
                xscal::log(host, "button got '%.*s'",
                    static_cast<int>(name.size()), name.data());
        });
    return clickFunctionId != xscal::invalidFunctionId;
}

class AddButton final : public xscal::MoviePatch {
public:
    AddButton() : MoviePatch("HUDMenu.swf") {}

    void onLoad(xscal::Movie& movie) override
    {
        xscal::Value root = movie.get("root1");
        if (!root)
            return;

        // Text label. Without embedFonts the text draws as boxes, and
        // defaultTextFormat is not used for rendering, so the font, size and
        // color go in the htmlText.
        xscal::Value label = movie.create("flash.text.TextField");
        label.set("embedFonts", true);
        label.setHtml("<font face='$MAIN_Font' size='20' color='#000000'>Click me</font>");
        label.set("autoSize", "left");
        label.set("selectable", false);
        label.set("mouseEnabled", false);   // clicks go to the button

        // Button: a Sprite with a yellow rectangle behind the label.
        xscal::Value button = movie.create("flash.display.Sprite");
        xscal::Value graphics = button.get("graphics");
        graphics.call("beginFill", 0xFFFFAA);
        graphics.call("drawRect", 0, 0, 160, 32);
        graphics.call("endFill");
        button.setPosition(40, 300);
        button.set("buttonMode", true);
        button.call("addChild", label);
        button.call("addEventListener", "click", movie.function(clickFunctionId));

        root.call("addChild", button);
    }
};

AddButton addButton;

An element lives as long as its parent. When the movie is destroyed it goes with it, so you need no cleanup. To remove it earlier call parent.call("removeChild", element). Whether a given menu delivers mouse clicks to your button depends on the game's input handling; test it in the game.

To change the text of a field later and keep the game font, set the text and then apply a format with Value::setTextFormat. Copy the format from a TextField of the HUD that already draws correctly:

// Style a TextField that already has its text. 'source' is a TextField of the
// HUD that draws correctly; its format uses a game font ("$MAIN_Font_Bold"
// in the HUD of the test game).
bool styleLikeSource(const xscal::Value& field, const xscal::Value& source)
{
    field.set("embedFonts", true);                  // before the text
    xscal::Value format = source.call("getTextFormat");
    if (!format)
        return false;
    // Apply the format after the text is set. defaultTextFormat would not draw.
    return field.setTextFormat(format);
}

// Usage, in a callback:
//   field.setText("Hello");
//   styleLikeSource(field, existingHudTextField);

g. Replace or wrap an existing function

Two ways to change what a function does:

  • Replace it: object.set("Refresh", movie.function(id)). The original is gone.
  • Wrap it: wrapMethod(movie, object, "Refresh", id) stores the original and returns a Ref to it. In your function, movie.load(ref) gives the original and call.forwardTo(original) runs it with the same this and arguments (up to 15). wrapMethod returns an empty Ref and changes nothing when the member is missing, the function cannot be created, or the object refuses the write. Tested: wrapMethod with forwardTo works on a function stored in a member of a dynamic Object; it does not work on the methods of sealed classes (below).
xscal::Ref originalRefresh;
uint32_t refreshWrapperId = xscal::invalidFunctionId;

bool registerRefreshWrapper(const xscal::MovieApi* api)
{
    refreshWrapperId = xscal::defineFunction(api,
        [](xscal::Movie& movie, xscal::Call& call) {
            xscal::log(host, "Refresh called with %u arguments", call.size());

            // Run the original with the same "this" and arguments.
            xscal::Value original = movie.load(originalRefresh);
            xscal::Value result = call.forwardTo(original);
            call.returns(std::move(result));
        });
    return refreshWrapperId != xscal::invalidFunctionId;
}

class WrapRefresh final : public xscal::MoviePatch {
public:
    WrapRefresh() : MoviePatch("HUDMenu.swf") {}

    void onLoad(xscal::Movie& movie) override
    {
        xscal::Value menu = movie.get("root1.Menu_mc");
        // Wrap an object once. A second wrapMethod on the same object would
        // wrap the wrapper and overwrite originalRefresh.
        originalRefresh = xscal::wrapMethod(movie, menu, "Refresh", refreshWrapperId);
        if (!originalRefresh)
            xscal::log(host, "Refresh is missing or the object refused the change");
    }

    void onUnload(xscal::Movie& movie) override
    {
        movie.drop(originalRefresh);
        originalRefresh = {};
    }
};

WrapRefresh wrapRefresh;
Wrap an object once. A second wrapMethod on the same object stores your first wrapper as the "original". The wrapper then calls itself. Drop the Ref in onUnload and wrap again in onLoad.

Native functions of the game: BGSCodeObj

Menus talk to the game through an object named BGSCodeObj. Its members, for example onButtonPress or onAcceptPress, are native functions that the game puts on the object, so the object accepts replacement like any dynamic object. A new movie has its own BGSCodeObj object, so a replacement made on an earlier movie does not carry over. Apply it again in every onLoad. The example wraps the confirm-button function of the message box menu, whose path is root1.Menu_mc.BGSCodeObj (xScal's own popup code uses it); confirm the file name with the logger from recipe a.

xscal::Ref originalPress;
uint32_t pressWrapperId = xscal::invalidFunctionId;

bool registerPressWrapper(const xscal::MovieApi* api)
{
    pressWrapperId = xscal::defineFunction(api,
        [](xscal::Movie& movie, xscal::Call& call) {
            const int32_t buttonIndex = call.arg(0).as<int32_t>(-1);
            xscal::log(host, "onButtonPress(%d)", buttonIndex);

            xscal::Value original = movie.load(originalPress);
            xscal::Value result = call.forwardTo(original);
            call.returns(std::move(result));
        });
    return pressWrapperId != xscal::invalidFunctionId;
}

class WrapMessageBoxPress final : public xscal::MoviePatch {
public:
    WrapMessageBoxPress() : MoviePatch("MessageBoxMenu.swf") {}

    // Runs every time the menu opens. Each opening is a new movie with a
    // new BGSCodeObj, so the wrapper has to be installed again.
    void onLoad(xscal::Movie& movie) override
    {
        xscal::Value codeObject = movie.get("root1.Menu_mc.BGSCodeObj");
        originalPress = xscal::wrapMethod(
            movie, codeObject, "onButtonPress", pressWrapperId);
        if (!originalPress)
            xscal::log(host, "could not wrap onButtonPress");
    }

    void onUnload(xscal::Movie& movie) override
    {
        movie.drop(originalPress);
        originalPress = {};
    }
};

WrapMessageBoxPress wrapMessageBoxPress;

Sealed class methods

Tested in the game: they refuse replacement. A method declared in a sealed AS3 class cannot be replaced. setMember("displayMessage", fn) on the HUDMenu document class returned false, and wrapMethod returned an empty Ref. The same holds for HUDImprovedBars. Instead:
  • Call the public methods yourself and read the getters and setters; they work (displayMessage, configData, xpToLevelUp).
  • Read the game's data objects and change the display objects the method updates, in onFrame (recipe c). This is the reliable way to react to HUD data.
  • Use onGameInvoke (recipe h) only for menus whose data arrives through MovieRoot::Invoke. It saw no calls into HUDMenu.swf.

h. Intercept what the game sends into a menu

The game updates menus by calling ActionScript functions with arguments (health, a list of items). Create the patch with wantInvokes and override onGameInvoke(movie, path, args). It runs before the call reaches ActionScript, for every call that goes through MovieRoot::Invoke. path is the function path, and args is the game's own argument array.

Tested in the game: HUD data does not arrive here. During a full session the filter saw 0 calls into HUDMenu.swf. The game mostly feeds menus through direct object calls, which are not routed through MovieRoot::Invoke. An invoke filter only sees calls made through MovieRoot::Invoke: some menus, other mods, and xScal's own calls. To react to HUD data, use onFrame and read the data objects (recipe c); that is the reliable way.
  • Read an argument: args.at(i) (a borrowed view), args.type(i) (an XSCAL_MOVIE_TYPE_* code).
  • Change a number or boolean argument: args.set(i, x). It returns false for strings and objects. Strings cannot be replaced.
  • Change an object argument: args.at(i).set("member", value).
  • Drop the call: return false. The game sees a failed Invoke.
class HudInvokes final : public xscal::MoviePatch {
public:
    HudInvokes() : MoviePatch("HUDMenu.swf", wantInvokes) {}

    // Runs for every call routed through MovieRoot::Invoke. In the tested
    // session this was zero calls for HUDMenu.swf, so do not rely on it for HUD data.
    bool onGameInvoke(xscal::Movie&, std::string_view path, xscal::Args& args) override
    {
        // Discovery: log the first calls.
        if (logged_ < 100) {
            ++logged_;
            xscal::log(host, "game calls %.*s with %u arguments",
                static_cast<int>(path.size()), path.data(), args.size());
        }

        // Change a number. Works only if the argument already holds a number
        // or boolean.
        if (path == "root1.Bars_mc.SetPercent" && args.size() >= 1) {
            double percent = 0.0;
            if (args.at(0).read(percent))
                args.set(0, percent * 0.5);
            return true;
        }

        // Change a member of an object argument.
        if (path == "root1.List_mc.SetData" && args.size() >= 1) {
            args.at(0).set("title", "Changed by a plugin");
            return true;
        }

        // Drop the call. The game sees a failed Invoke.
        if (path == "root1.Popup_mc.Show")
            return false;

        return true;
    }

private:
    uint32_t logged_ = 0;
};

HudInvokes hudInvokes;

This function is called very often, so compare the path first and do nothing for paths you do not care about.

i. Find a mod SWF loaded inside another movie

A HUD mod such as ImprovedBars is loaded into HUDMenu.swf. There is no movie called ImprovedBars.swf, so MoviePatch("ImprovedBars.swf") never fires. Instead, patch HUDMenu.swf and walk the display tree: for each child, child.get("loaderInfo").get("url") is the URL of the SWF the child came from. When it contains the mod's file name, you have the mod's top object. A Loader also keeps the URL in contentLoaderInfo and the loaded object in content.

Tested in the game: the URL of a movie looks like Interface/HUDMenu.swf, and HUD mods loaded by HUDModLoader live inside HUDMenu.swf. In one run the path root1 > child 52 (instance45) > content was ImprovedBars.swf, found by loaderInfo.url. The child index is specific to that run; search by URL, not by index. The mod's own document class (HUDImprovedBars) is sealed: reading its getters and calling its public methods works, adding members does not.

The mod loads after the HUD, so the first search may find nothing. The patch below searches in onLoad, then once per second (60 frames) until it finds the mod. Replace "ImprovedBars" with a part of the real file name; to learn it, log the URL of every child while you search.

bool urlContains(const xscal::Value& loaderInfo, std::string_view part)
{
    xscal::Value url = loaderInfo.get("url");
    std::string_view text;
    return url.read(text) && text.find(part) != std::string_view::npos;
}

// Walks the display tree under 'parent' and returns a Ref to the first
// object that comes from a SWF whose URL contains 'part'. Returns an empty
// Ref when there is none. Do not call this every frame.
xscal::Ref findBySwf(const xscal::Movie& movie, const xscal::Value& parent,
    std::string_view part, int depth)
{
    const uint32_t count = parent.numChildren();
    for (uint32_t i = 0; i < count; ++i) {
        xscal::Value child = parent.childAt(i);   // needs the invoke record
        if (!child)
            continue;

        // loaderInfo is the SWF the object came from.
        if (urlContains(child.get("loaderInfo"), part))
            return movie.retain(std::move(child));

        // A Loader keeps the SWF it loaded in contentLoaderInfo.
        if (urlContains(child.get("contentLoaderInfo"), part)) {
            xscal::Value content = child.get("content");
            if (content)
                return movie.retain(std::move(content));
        }

        if (depth > 0) {
            const xscal::Ref found = findBySwf(movie, child, part, depth - 1);
            if (found)
                return found;
        }
    }
    return {};
}

class FindImprovedBars final : public xscal::MoviePatch {
public:
    FindImprovedBars() : MoviePatch("HUDMenu.swf", wantFrames) {}

    void onLoad(xscal::Movie& movie) override
    {
        ticks_ = 0;
        search(movie);
    }

    // Mods are loaded after HUDMenu.swf, so look again once a second.
    void onFrame(xscal::Movie& movie) override
    {
        if (!mod_ && ++ticks_ % 60 == 0)
            search(movie);
    }

    void onUnload(xscal::Movie& movie) override
    {
        movie.drop(mod_);
        mod_ = {};
    }

private:
    void search(xscal::Movie& movie)
    {
        xscal::Value root = movie.get("root1");
        mod_ = findBySwf(movie, root, "ImprovedBars", 6);
        if (!mod_)
            return;
        xscal::log(host, "found ImprovedBars");
        xscal::Value mod = movie.load(mod_);   // borrowed view
        mod.setAlpha(0.6);
    }

    xscal::Ref mod_;
    uint32_t ticks_ = 0;
};

FindImprovedBars findImprovedBars;

childAt needs the invoke record, so this recipe does not work on a game version without it. Keep the search depth small and do not run it every frame: each node costs several calls.

j. Inspect an unknown object

You cannot list members directly, and reflection is not available in the game's Scaleform. Tested: loaderInfo.applicationDomain.getDefinition(...) returns null for "flash.utils.describeType", "flash.utils.getQualifiedClassName" and "JSON", so there is no way to get a member list or to turn an object into text. Members declared internal or private cannot be read by name either.

What works: walk numChildren and childAt, read member names you already know, identify an object by its loaderInfo.url (recipe i), and identify its kind by testing for a member that only that kind has: embedFonts for a TextField, or a known getter of a mod class. A member that is missing gives an invalid or undefined Value. The example logs the tree below root1 two levels deep.

// True when 'object' has a member that only a TextField has. describeType and
// getQualifiedClassName cannot be used, so identify objects by their members.
bool looksLikeTextField(const xscal::Value& object)
{
    xscal::Value embedFonts = object.get("embedFonts");
    return embedFonts.valid() && !embedFonts.isUndefined();
}

// Logs the display tree below 'node': index, instance name, the SWF the child
// came from, and whether it looks like a TextField. Stops at 'maxDepth'.
// Do not call this every frame: each child costs several calls.
void logTree(const xscal::Value& node, int depth, int maxDepth)
{
    const uint32_t count = node.numChildren();
    for (uint32_t i = 0; i < count; ++i) {
        xscal::Value child = node.childAt(i);   // needs the invoke record
        if (!child)
            continue;

        xscal::Value nameValue = child.get("name");
        std::string_view name;
        nameValue.read(name);

        xscal::Value urlValue = child.get("loaderInfo").get("url");
        std::string_view url;
        urlValue.read(url);

        xscal::log(host, "%*s[%u] name=%.*s url=%.*s%s", depth * 2, "", i,
            static_cast<int>(name.size()), name.data(),
            static_cast<int>(url.size()), url.data(),
            looksLikeTextField(child) ? " (TextField)" : "");

        if (depth < maxDepth)
            logTree(child, depth + 1, maxDepth);
    }
}

class DumpMenu final : public xscal::MoviePatch {
public:
    DumpMenu() : MoviePatch("HUDMenu.swf") {}

    void onLoad(xscal::Movie& movie) override
    {
        xscal::Value root = movie.get("root1");
        logTree(root, 0, 2);
    }
};

DumpMenu dumpMenu;

Run it once, read xscal.log, and then write code that uses the names and URLs you saw. childAt needs the invoke record.

k. Work from another thread (network, timers)

Your own thread must not read or write a Value, and it has no valid Movie. Instead, hand work to the movie: movie.api()->post(identity, function, userData). post may be called from any thread (tested from a worker thread). The host runs function inside a frame of the movie with that identity. It runs on whatever thread advances that movie at that moment, which can change over time, and never on the posting thread. There you get a valid movie handle and may touch values. If the function shares data with your own thread, protect it with a mutex, as below.

  • Save movie.identity() in onLoad and clear it in onUnload. The identity differs between two movies created at the same address.
  • post returns 0 when the queue (256 entries) is full. Work for a movie that was destroyed first is dropped without being called.
  • Do not pass heap memory that the posted function must free: it may never run. Keep the data in a mutex-protected global, as below.
  • The posted function receives a raw XScalMovie*. Wrap it with xscal::Movie(api, handle).
#include <atomic>
#include <mutex>
#include <string>

const xscal::MovieApi* movieApi = nullptr;      // set in load()
std::atomic<uintptr_t> hudIdentity{0};          // set by the patch below
std::mutex pendingMutex;
std::string pendingText;

// Runs inside a frame of the HUD movie, on whichever thread advances it at
// that moment. It never runs on the thread that called post().
void __cdecl showPendingText(void*, XScalMovie* handle) noexcept
{
    xscal::Movie movie(movieApi, handle);

    std::string text;
    {
        std::lock_guard<std::mutex> lock(pendingMutex);
        text = pendingText;
    }
    xscal::Value field = movie.get("root1.Status_tf");   // made-up path
    field.setText(text);
}

// Call this from your own thread (network, timer, file watcher).
bool publishFromThread(const std::string& text)
{
    const uintptr_t identity = hudIdentity.load();
    if (identity == 0)
        return false;                   // no HUD movie right now
    {
        std::lock_guard<std::mutex> lock(pendingMutex);
        pendingText = text;
    }
    return movieApi->post(identity, &showPendingText, nullptr) != 0;
}

class RememberHud final : public xscal::MoviePatch {
public:
    RememberHud() : MoviePatch("HUDMenu.swf") {}

    void onLoad(xscal::Movie& movie) override { hudIdentity.store(movie.identity()); }
    void onUnload(xscal::Movie&) override { hudIdentity.store(0); }
};

RememberHud rememberHud;

l. Give a SWF you load into a game menu xScal's call()

A SWF gets xScal's call() (the Scaleform API: Input.*, Game.*, modStorage) on its __SFCodeObj. xScal attaches it by itself only when the game looks up root1 or a configured component path and the object already exists there. HUDModLoader's HUD does that; many menus never do after they open. Tested: the container menu (securetrade.swf) got no call() on either path in 300 frames.

Movie interface version 3 adds attachCodeObject(movie, objectPath), in C++ movie.attachCodeObject(path). It attaches call() to objectPath.__SFCodeObj right away. The path must start with root1.. On hosts with movie interface version 2 it returns false; the rest of the plugin keeps working, because queryMovieApi still asks for version 2.

  1. In the SWF, give the main class a public var __SFCodeObj:Object = new Object(); and publish the instance under root1, for example root1["MyMod"] = this; (the menu's root1 must be dynamic, as a plain MovieClip is).
  2. In the plugin, once the SWF has done that, call movie.attachCodeObject("root1.MyMod") once.
  3. In the SWF, use __SFCodeObj.call("Input.IsKeyPressed", 70) and the rest once __SFCodeObj.call is Function.
// Inside onFrame of the menu's MoviePatch, after loading your SWF.
if (!attached_ && movie.get("root1.MyMod").isObject()) {
    attached_ = true;
    if (!movie.attachCodeObject("root1.MyMod"))
        xscal::log(host, "This xScal has no movie API version 3");
}

Calling it again for an attached object returns true and changes nothing. It also returns false while another thread holds xScal's attach lock, so try again on a later frame before you give up.

5. Rules and limits

The limits come from plugin_api.h (XSCAL_SCALEFORM_MOVIE_*) and movie.hpp. Tables are shared by all plugins in the game process.

WhatLimitWhat happens at the limit
Native functions (defineFunction, registerCall, registerFunction)256 IDs, all plugins togetherdefineFunction returns invalidFunctionId. IDs are permanent.
defineFunction with a plain function pointer64 per pluginReturns invalidFunctionId. A lambda has no such limit.
Movie handlers (one per MoviePatch)32install returns false.
Invoke filters (one per patch with wantInvokes)32install returns false.
Advance (frame) handlers8 in the whole host. All patches of one plugin share one.install returns false for a patch with wantFrames.
Lookup handlers (C interface only)8addLookupHandler returns 0.
Movies watched for frames at once16watchAdvance fails.
Retained references (retain)1024Returns an empty Ref; the Value stays valid and releases itself.
Queued posted work (post)256Returns 0.
Arguments of invoke, call16More than 16 is a compile error in movie.hpp.
Arguments for forwardTo15 (this takes one slot)Returns an invalid Value.
Text of std::string_view arguments in one call1024 bytes in totalThe call fails with an invalid Value.
String given to Call::returns(std::string_view)1023 bytesreturns gives false.
Nested native function calls while a plugin callback runs8 levelsThe header documents 8 as the depth; do not nest deeper.
URL of a movie (movie.url())260 bytes bufferLonger URLs are cut.
swfNameshorter than 128 bytes (host source)install returns false.
One xscal::log message511 bytesCut.

Limits found by testing

  • Sealed classes refuse new members and method replacement (section 1, recipe g).
  • internal and private members cannot be read by name.
  • No reflection: describeType, getQualifiedClassName and JSON are not available (recipe j).
  • Invoke filters see only calls made through MovieRoot::Invoke. In the tested session they saw 0 calls into HUDMenu.swf. Use onFrame and read data objects to react to HUD data (recipe h).
  • Runtime-created TextFields need embedFonts and a game font (recipe f).
  • A movie does not stay on one thread; posted work runs on the thread that advances the movie (recipe k).

The Address Library invoke record

Calling a method on an object (invokeMember) needs the Address Library record XSCAL_ADDRESS_SCALEFORM_INVOKE for the game version. The record exists for Steam and Game Pass 1.7.26.22. On a version without it, these fail and return an invalid Value:

  • Value::call and the helpers built on it: push, erase, childAt, and so addChild and addEventListener called on an object.
  • Call::forwardTo, so a wrapper made with wrapMethod cannot run the original.

These work without it: Movie::get, invoke, create, function; Value::get, set and the setText family; size, at, setAt, numChildren; retain, load, drop; defineFunction; onGameInvoke; post.

Other rules

  • Never let a C++ exception leave a callback. MoviePatch calls your overrides from noexcept code, so an exception ends the game process.
  • Register functions and install patches in load(). There is no uninstall and plugins are never unloaded.
  • Pass a string literal as swfName. The pointer must stay valid until exit.

6. Debugging

Write to the log with xscal::log(host, "format %u", value). Messages appear in xscal.log in the game folder, prefixed with the plugin name. Log inside callbacks. Log every failed get, set, call and wrapMethod once; most problems show up as an invalid Value or a false return, not as a crash.

Common mistakes

  • Using a Value or Movie after the callback. Both are invalid once the callback returns. Keep a Ref from retain and load it in the next callback.
  • Expecting a mod SWF to exist in onLoad. HUD mods load later. Search again in onFrame (recipe i).
  • Wrong swfName. The match is the whole file name, case-insensitive, no wildcards. Log movie.url() for every movie (recipe a) and copy the name from there. A SWF inside another SWF is never a movie of its own.
  • Replacing a method or adding a member on a sealed class. set returns false and wrapMethod returns an empty Ref. See the note in recipe g.
  • Text shows as squares. A TextField made with create has no embedded font. Set embedFonts to true, then either set htmlText with <font face='$MAIN_Font'> or call setTextFormat with a format that uses a game font. defaultTextFormat is not used for drawing (recipe f).
  • onGameInvoke never fires for the HUD. The game sends HUD data by direct object calls, not through MovieRoot::Invoke. Read the data objects in onFrame (recipe h).
  • Looking for describeType or JSON. They are not available in the game. Walk the display tree (recipe j).
  • Reading a string through a temporary. x.get("text").as<std::string_view>() leaves a view into a destroyed Value.
  • Wrapping the same object twice. The wrapper ends up calling itself.
  • Calling call on a game version without the invoke record. Check the result's valid() and log it.
  • Touching a Value from your own thread. Use post (recipe k).
  • Assuming one thread for a movie. Callbacks of the same movie can come from different threads over time. Protect shared plugin state.
  • Letting an exception out of an override. It ends the process. Catch inside the override if you call code that can throw.
  • Forgetting drop. Each unreleased Ref uses one of 1024 table entries until the movie is destroyed.

7. Glossary

ActionScript 3 (AS3)
The language the SWF code is written in.
Address Library
A database that maps IDs to code addresses for each game version. The invoke record is one entry.
Application domain
The set of class and function definitions a SWF can see. getDefinition looks a name up in it. In this game it has no describeType, getQualifiedClassName or JSON.
Borrowed value
A Value that views something another part owns. Its destructor does nothing.
BGSCodeObj
The object through which a menu calls native game code, for example onButtonPress.
Display list
The tree of display objects in a movie. addChild adds a node; numChildren and getChildAt walk it.
Dynamic object
An object that accepts new members and replaced members. Object and MovieClip are dynamic.
Identity
A number that identifies one movie instance (movie.identity()). Used with post.
Invoke record
The Address Library entry XSCAL_ADDRESS_SCALEFORM_INVOKE. Value::call needs it.
Loader / loaderInfo
A Loader loads another SWF. loaderInfo.url is the URL of the SWF an object came from.
Movie
One loaded SWF as the game holds it (MovieRoot).
Advancing thread
The thread that runs a frame of a movie at that moment. It can differ between frames of the same movie. Posted work runs there.
Owned value
A Value that holds a reference and releases it when destroyed.
Ref
A number that stands for a retained value. Valid until drop or until the movie is destroyed.
root1
The top object of a movie. Paths start here.
Scaleform (GFx)
The UI library the game uses to play SWF files.
Sealed class
An AS3 class that is not dynamic. Its declared methods refuse replacement and its instances refuse new members. Its public methods and getters can still be called.
Sprite, MovieClip, TextField
AS3 display classes: a plain container, a container with a timeline, and a text box.
SWF
A Flash file. The game's menus are SWFs.
swfName
The file name used to pick movies, such as HUDMenu.swf.