Skip to content

Cookbook ​

Short, complete recipes. Each one is a script you can drop into a mod's scripts/ folder.

Run something once a match starts ​

Scripts run at load, in the lobby, before a player exists. Wait for the local player:

lua
local done = false
tempest.on("PostRender", function()
  local pc = tempest.local_player()
  if done or not pc then return end
  done = true
  print("in a match as " .. tostring(pc))
end)

Rename players as they join ​

lua
tempest.on("TgGame.TgGame.PostLogin", function(game, params)
  local pri = params.NewPlayer.PlayerReplicationInfo
  if pri then pri.PlayerName = "Kyiro" end
end)

Cancel or rewrite a call ​

lua
-- Block a console command, rewrite another.
tempest.before("Engine.PlayerController.ConsoleCommand", function(pc, params)
  if params.Command == "disconnect" then return false end
  if params.Command == "fps" then params.Command = "stat fps" end
end)

Call functions ​

lua
local pc = tempest.local_player()
pc:ClientMessage("hello")                       -- a script event
local name = pc:GetHumanReadableName()           -- return values come back
local controller = pc:GetALocalPlayerController() -- natives too

Find out what a function takes before calling it:

lua
for _, p in ipairs(pc.Spawn:params()) do
  print(p.name, p.kind, p.out and "out" or "", p.ret and "return" or "")
end

Teleport the pawn ​

lua
tempest.command("up", function(pc)
  local pawn = pc.Pawn
  if not pawn then return end
  local l = pawn.Location
  pawn.Location = { x = l.x, y = l.y, z = l.z + 500 }
end, "hop up")

Walk an array of structs ​

lua
local binds = tempest.local_player().PlayerInput.Bindings
for i, b in binds:items() do
  if b.Command:find("Jump") then print(i, b.Name, "->", b.Command) end
end

Read a native map ​

lua
local hud = tempest.local_player().myHUD
if hud and hud.m_pMovie then
  for name, scene in hud.m_pMovie:map("m_Scenes"):pairs() do
    print(name, scene)
  end
end

Spawn an actor in front of the player ​

lua
tempest.command("spawn", function(pc, rest, argv)
  local l = pc.Pawn.Location
  local actor = tempest.spawn(argv[1] or "Engine.KActorSpawnable", { x = l.x + 200, y = l.y, z = l.z })
  pc:ClientMessage("spawned " .. tostring(actor))
end, "spawn <Pkg.Class>")

Key bindings ​

lua
tempest.on_key("F5", function()
  local pc = tempest.local_player()
  if pc then pc:ClientMessage("F5") end
end)

tempest.on_key("Ctrl+F5", function()
  -- ...
  return true  -- the game never sees Ctrl+F5
end)

A settings panel ​

lua
local panel = { open = false, speed = 1.0, godmode = false }

tempest.on_key("Insert", function()
  panel.open = not panel.open
  ui.capture(panel.open)
  return true
end)

ui.on_draw(function()
  if not panel.open then return end
  local still_open = ui.window("Settings", { open = true }, function()
    panel.speed = ui.slider_float("Speed", panel.speed, 0.5, 3, "%.1fx")
    panel.godmode = ui.checkbox("God mode", panel.godmode)
    local pawn = tempest.local_player() and tempest.local_player().Pawn
    if pawn and ui.button("Apply") then
      pawn.GroundSpeed = 600 * panel.speed
    end
  end)
  if still_open == false then panel.open = false ui.capture(false) end
end)

Draw over the game ​

No window, no mouse: draw lists paint straight onto the screen.

lua
ui.on_draw(function()
  local pc = tempest.local_player()
  if not pc or not pc.Pawn then return end
  local w, h = ui.screen()
  ui.fg.text(8, h - 24, ui.rgb(0xffffff, 0.8), "health %d", pc.Pawn.Health)
  ui.fg.circle(w / 2, h / 2, 5, ui.rgb(0x00ff00), 0, 2)
end)

Share code between mods ​

lua
-- mod "util-mod", scripts/main.lua
local fmt = require("lib.format")        -- scripts/lib/format.lua, same sandbox
tempest.export("banner", fmt.banner)
lua
-- any other mod
local banner = tempest.import("banner")
if banner then print(banner("hi")) end

Declare the dependency so util-mod always loads first:

toml
# manifest.toml of the consuming mod
[scripting]
after = ["util-mod"]

Find what fires ​

lua
tempest.trace("Tick", 3)          -- which objects tick, with their DeltaTime
tempest.trace("TakeDamage", 5)    -- damage events and their params

Hook a C++ function by address ​

memory.hook detours anything with an address, not only UnrealScript functions. A native's C++ implementation is the easiest to find: every UFunction object stores it in its Func field.

lua
local fn = tempest.find_object("Function Engine.PlayerController.ConsoleCommand")
-- UFunction.Func: 0xF0 on the Win64 client, 0xA0 on Win32
local entry = memory.read_ptr(fn:ptr() + (tempest.status().arch == "x86" and 0xA0 or 0xF0))

-- Func(this, FFrame&, Result) is a C++ method: on Win32 that means thiscall
-- (the option is ignored on Win64).
local id = memory.hook(entry, function(original, this, frame, result)
  print("ConsoleCommand on " .. tostring(tempest.object(this)))
  return original()                                      -- pass through unchanged
end, { args = 3, conv = "thiscall" })

tempest.command("stop_tracing", function() memory.unhook(id) end)

For a function found by signature, scan for it and hook the first hit:

lua
local target = memory.scan("48 89 5C 24 ? 57 48 83 EC 30 48 8B D9 ...")[1]
memory.hook(target, function(original, a, b)
  local r = original(a, b)
  if r == 0 then print("call failed") end
  return r
end, { args = 2 })

Arguments are integers (registers and stack slots), so read strings with memory.read_wstr(ptr) and wrap object pointers with tempest.object(ptr). Signatures differ between the two clients, so a mod that hooks by signature scans for the right one per tempest.status().arch. See the memory reference for the rules on original, return values, calling conventions and threads.

Enable the game console ​

The shipping game does not create its console. A mod can, the way TgMod does:

lua
local viewport = tempest.engine().GameViewport
if not viewport.ViewportConsole then
  local console = tempest.new("Engine.Console", viewport)
  viewport.ViewportConsole = console
  viewport:InsertInteraction(console, 0)   -- first, so the UI does not eat the key
  console.ConsoleKey = "F2"
  console.TypeKey = "Tab"
end