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.
| Kind | Comes from | Destructor |
| Owned | Movie::get, invoke, create, function; Value::get, call, at, childAt, push, erase; Call::forwardTo | Releases the reference. |
| Borrowed view | Call::arg, Call::self, Args::at, Movie::load | Does nothing. Someone else owns it. |
| Plain primitive | Movie::makeBool, makeInt, makeNumber, makeString | Nothing 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.
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.
- 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).
- In the plugin, once the SWF has done that, call
movie.attachCodeObject("root1.MyMod") once.
- 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.