Lua Scripting

CU3ML embeds Lua 5.4 (bound with sol2) and exposes a single global table, ue3, that wraps the engine functions the loader has resolved.

When scripts run

The VM is created in lua_host::init(), at the end of GEngineLoop::Init processing — after the UE3 layout is resolved, content paths are registered, and every hook is committed. Then, in dependency order, each enabled mod's main.lua is executed, followed by any scripts passed with -lua=<path>.

Standard libraries opened: base, package, string, table, math, os, io, coroutine. There is no debug and no ffi.

Before a mod's script runs, its own folder is appended to package.path as both <mod>/?.lua and <mod>/?/init.lua, so require "helpers" resolves against files shipped with the mod.

print is redirected to the log file, tab-joining its arguments and prefixing them with [lua]. There is no console window.

Enabling and disabling

Control Effect
lua = false in Mods/cu3ml.toml VM never starts
-nolua Overrides the config and disables the VM for this launch
lua_verbose = true / -luaverbose Logs the result of every chunk the host runs
-lua=C:\path\script.lua Runs an extra script after all mod scripts; repeatable

Example

-- Runs once, in dependency order, after CU3ML resolves the UE3 layout.

local b = ue3.build()
ue3.log(("main.lua up on %s (%s)"):format(b.arch, b.loader))

for _, m in ipairs(ue3.mods()) do
    print("mod:", m.name, m.version or "?", "by", m.author or "unknown")
end

print("cmdline:", ue3.cmdline())
if ue3.has_switch("example") then
    ue3.log("simple example of cmdline arg")
end

-- FName index 0 is always "None" — a cheap sanity check that the name
-- table resolved correctly
print("fname(0) =", ue3.fname(0))

local obj = ue3.load("EngineMaterials.DefaultDiffuse")
ue3.log("load DefaultDiffuse -> " .. tostring(obj ~= nil))

local text, handled = ue3.exec("obj classes")
if handled and text then
    ue3.log("obj classes returned " .. tostring(#text) .. " bytes")
end

ue3 reference

Logging

Function Notes
ue3.log(msg) Writes an INFO line, prefixed [lua]
ue3.warn(msg) Writes a WARN line
ue3.error(msg) Writes an ERROR line; does not raise a Lua error
ue3.msgbox(text [, caption]) Blocking MessageBoxW. Caption defaults to "CU3ML"

Environment

Function Returns
ue3.build() { arch = "x64" \| "x86", loader = "CU3ML" }
ue3.cmdline() The raw command line the game was launched with
ue3.has_switch(name) true if -name or /name was passed. Case-insensitive; loader-consumed switches like nolua are not visible here
ue3.mods() Array of { name, version, author } for enabled mods, in load order
ue3.module_base() Base address of the game module as an integer
ue3.layout() { ok, StaticLoadObject, StaticFindObjectFast, GetPackageLinker, Preload, FNameInit, GPackageFileCache } — pointers formatted as hex strings, for logging

Objects and names

Function Notes
ue3.load(path [, outer]) Calls StaticLoadObject. Returns a pointer, or nil if the object wasn't found or StaticLoadObject was never resolved
ue3.name(str) Interns a string via FName::Init, returning { index, number }, or nil if FNameInit is unresolved
ue3.fname(index) Reverse lookup: the string for a name-table index, or nil. Handles both ANSI and Unicode name entries
ue3.set_engine_free(ptr_or_rva) Tells the loader which function to use to free engine-allocated memory. Accepts a pointer or an integer RVA relative to the module base

Console commands

local out, handled = ue3.exec("obj classes")

ue3.exec(cmd) calls the engine's global StaticExec with a capturing output device, returning the captured text (or nil) plus a boolean saying whether the command was handled. It covers obj, get, set, listprops and friends without needing a GEngine pointer.

For input-object commands, pass the object pointer explicitly:

Function Notes
ue3.input.exec(self, cmd) UInput::Exec. Returns text, handled
ue3.input.exec_commands(self, cmd) UInput::ExecInputCommands. Returns captured text

Both return nil, false if self is nil or the engine function couldn't be resolved from its anchor string.

Localization and config sections

ue3.section.localize("Menu", "PlayButton", "MyGamePackage", "START")
ue3.section.set("Engine.Engine", "bSmoothFrameRate", "MyGamePackage", "False")
Function Notes
ue3.section.localize(section, key, package, new_string) Overrides what Localize() returns for that triple, in memory, for the lifetime of the process
ue3.section.set(section, key, package, value [, file]) Removes the existing key then adds value
ue3.section.add(section, key, package, value [, file]) Adds without removing — for multi-value keys
ue3.section.remove(section, key, package [, file]) Removes the key
ue3.config.load_file(gconfig, filename) FConfigCacheIni::LoadFile against a config-cache pointer you supply

The section.* calls install their engine hooks lazily on first use. If the target section isn't loaded yet, the operation is queued and applied once it appears, so scripts don't have to care about config load order. All four return false when the underlying hooks couldn't be installed at all.

Resolution is lazy and anchored

Engine functions behind exec, input.*, config.* and section.* are located on first call by searching for a distinctive string literal they reference, then cached in Mods/cu3ml.addrlist under lua.* keys. A call that can't resolve its target logs an error and returns a nil-ish result rather than crashing.