Skip to content

Creating Mods

xAstroBoy edited this page Mar 20, 2026 · 1 revision

Creating Mods

This guide covers everything you need to know to create mods for Quest UE4 Modloader.

Mod Structure

Every mod is a folder inside mods/ containing at least a main.lua file:

mods/
└── MyMod/
    ├── main.lua        # Entry point (required)
    └── *.pak           # Optional PAK files (auto-mounted)

The modloader discovers and loads all mods/*/main.lua files on game start.

Mod Lifecycle

  1. Discovery — Modloader scans mods/ for folders with main.lua
  2. Loading — Each main.lua is executed in its own Lua environment
  3. Globals injectedMOD_NAME, MOD_DIR, SharedAPI, and the full API
  4. Hooks registered — Your hooks are installed and active
  5. Runtime — Hooks fire as the game runs, timers execute, bridge commands available

Per-Mod Globals

Every mod gets these globals automatically:

Global Type Description
MOD_NAME string Folder name (e.g., "MyMod")
MOD_DIR string Full path to mod folder
SharedAPI table Cross-mod communication (shared across all mods)

Core Patterns

Pattern 1: Simple Hook

-- Hook a game function and modify behavior
RegisterPostHook("/Script/Game.SomeClass:SomeFunction", function(self, func, parms)
    local obj = self:get()
    if obj and obj:IsValid() then
        obj:Set("Health", 100)
    end
end)

Pattern 2: Toggle with Config

local TAG = "MyMod"
local state = { enabled = true }

-- Load saved config
local saved = ModConfig.Load(TAG)
if saved and saved.enabled ~= nil then
    state.enabled = saved.enabled
end

-- Register in debug menu
if SharedAPI and SharedAPI.DebugMenu then
    SharedAPI.DebugMenu.RegisterToggle(TAG, "My Feature", state.enabled,
        function(new_state)
            state.enabled = new_state
            ModConfig.Save(TAG, state)
        end
    )
end

-- Conditional hook
RegisterPostHook("/Script/Game.SomeClass:SomeFunc", function(self, func, parms)
    if not state.enabled then return end
    -- Do something only when enabled
end)

Pattern 3: Block a Function

-- PreHook can return "BLOCK" to prevent the original from running
RegisterPreHook("/Script/Game.DamageSystem:ApplyDamage", function(self, func, parms)
    return "BLOCK"  -- Damage never applies
end)

Pattern 4: Spawn Watcher

-- React when new objects are spawned
NotifyOnNewObject("/Script/Game.EnemyCharacter", function(obj)
    if not obj or not obj:IsValid() then return end
    pcall(function()
        obj:Set("MaxHealth", 1)
        Log("Modified enemy: " .. obj:GetName())
    end)
end)

Pattern 5: Delayed / Periodic Execution

-- One-shot after 5 seconds
ExecuteWithDelay(5000, function()
    Log("5 seconds have passed!")
end)

-- Repeat every 2 seconds
LoopAsync(2000, function()
    local player = FindFirstOf("PlayerController")
    if player and player:IsValid() then
        -- Periodic check/modification
    end
end)

-- Next frame (game thread)
ExecuteInGameThread(function()
    -- Safe to modify game objects here
end)

Pattern 6: Working with Structs

-- Read a struct property
local actor = FindFirstOf("SomeActor")
local pos = actor:Get("Position")  -- Returns LuaUStruct

-- Access fields
Log("X=" .. pos.X .. " Y=" .. pos.Y .. " Z=" .. pos.Z)

-- Modify fields (writes to live memory)
pos.X = 100
pos.Y = 200
pos.Z = 300

-- Or set from a table
actor:Set("Position", {X=100, Y=200, Z=300})

-- Or pass struct to Call()
actor:Call("SetActorLocation", {X=100, Y=200, Z=300})

-- Clone a struct (creates an independent copy)
local saved_pos = pos:Clone()

Safety: pcall Everything

Always wrap UObject operations in pcall(). Objects can become invalid at any time (garbage collected, level unloaded, etc.).

-- ❌ WRONG: crash if obj is invalid
local health = obj:Get("Health")

-- ✅ RIGHT: safe
local ok, health = pcall(function() return obj:Get("Health") end)
if ok then
    Log("Health: " .. tostring(health))
end

Use separate pcall blocks for independent operations:

-- ❌ WRONG: if first fails, second never runs
pcall(function()
    obj:Set("Health", 100)
    obj:Set("Armor", 50)
end)

-- ✅ RIGHT: independent operations in separate blocks
pcall(function() obj:Set("Health", 100) end)
pcall(function() obj:Set("Armor", 50) end)

Debug Menu Integration

The DebugMenuAPI mod provides an in-game menu for toggles, actions, and sub-pages.

Simple Registration

local api = SharedAPI and SharedAPI.DebugMenu

if api then
    -- Toggle (on/off switch)
    api.RegisterToggle("MyMod", "Feature Name", false, function(state)
        Log("Feature: " .. tostring(state))
    end)

    -- Action (one-shot button)
    api.RegisterAction("MyMod", "Do Something", function()
        Log("Button pressed!")
    end)

    -- Selector (cycle through options)
    api.RegisterSelector("MyMod", "Difficulty", {"Easy","Normal","Hard"}, function(value, index)
        Log("Selected: " .. value)
    end)

    -- Sub-menu (opens a new page)
    api.RegisterSubMenu("MyMod", "Advanced", function()
        api.NavigateTo({
            name = "Advanced Settings",
            populate = function()
                api.AddItem("Option 1", function() Log("1!") end)
                api.AddItem("Option 2", function() Log("2!") end)
            end
        })
    end)
end

See Debug Menu API for full documentation.

Cross-Mod Communication

Use SharedAPI to expose functionality to other mods:

-- In your mod:
SharedAPI.MyMod = {
    GetHealth = function() return current_health end,
    SetGodMode = function(on) god_mode = on end,
}

-- In another mod:
if SharedAPI.MyMod then
    SharedAPI.MyMod.SetGodMode(true)
end

Or use shared variables:

SetSharedVariable("MyMod_Enabled", true)
local val = GetSharedVariable("MyMod_Enabled")

Bridge Commands

Register custom commands accessible from the ADB bridge:

RegisterBridgeCommand("mymod_status", function()
    return {
        enabled = state.enabled,
        health = current_health,
        version = "1.0"
    }
end)

Test from your PC:

python tools/deploy.py console
> mymod_status

File I/O

-- Read a file
local content = ReadTextFile(MOD_DIR .. "config.txt")

-- Write a file
WriteTextFile(MOD_DIR .. "output.txt", "Hello!")

-- Check existence
if FileExists(MOD_DIR .. "data.json") then
    -- ...
end

For structured config, prefer ModConfig:

-- Save
ModConfig.Save("MyMod", { enabled = true, level = 5 })

-- Load
local cfg = ModConfig.Load("MyMod")
if cfg then
    Log("Level: " .. tostring(cfg.level))
end

Tips & Best Practices

  1. Always use reflection (Get/Set/Call) — never raw memory offsets for UObject properties
  2. Wrap everything in pcall — objects can be GC'd at any time
  3. Separate pcall blocks — don't combine independent operations
  4. TArray is 1-indexedarr[1] is the first element
  5. Test via bridge first — validate API calls with exec_lua before coding
  6. Log generously — use Log() with your mod's tag for debugging
  7. Save state with ModConfig — persist settings across game restarts
  8. Register in debug menu — let users toggle your mod in-game
  9. Check IsValid() — before using any UObject reference
  10. Use ExecuteWithDelay — game objects aren't ready at load time