Skip to content

Luau

Courage Reborn uses Luau, Roblox's typed superset of Lua 5.1. Plain Lua mostly works. Type annotations are optional.

Info

If you're new to Lua/Luau, the Luau language reference is a good starting point.

Type checking#

Put --!nocheck on the first line of a mod entry file to skip Luau's type checker for that module. Useful for quick scripts and examples without type annotations:

--!nocheck
local MyMod = mod("my-mod")
// TypeScript mods are type-checked by the compiler. No file directive needed.
declare function mod(name: string): any
const MyMod = mod("my-mod")

Other file modes: --!nonstrict (default for new files) and --!strict. See the Luau type checking docs.


Entry point#

When Petrichor loads a luau mod it:

  1. Reads the file named by entry in mod.json (default: main.luau).
  2. Compiles it to Luau bytecode and runs it once in the shared Luau VM.
  3. Expects the chunk to return a mod class from mod().
  4. Calls new(), then tick(delta) each frame and shutdown() on teardown.

Register hooks in init (or code called from init). See Script entry point.

-- main.luau
local Log = require("Petrichor.Log")

local MyMod = mod("my-mod")

function MyMod:init()
    self:before("MyClass::myMethod", function(ctx)
        Log.info("my-mod", "myMethod is about to run")
    end)
end

return MyMod
// src/main.ts
import * as Log from "Petrichor.Log"

declare function mod(name: string): any

const MyMod = mod("my-mod")

MyMod.init = function () {
    this.before("MyClass::myMethod", (ctx: HookCtx) => {
        Log.info("my-mod", "myMethod is about to run")
    })
}

export = MyMod

require() resolution#

The built-in require resolves names in order:

  1. Built-in Petrichor modules: "Augment.Hook", "Augment.Mixin", "Augment.Class", "Petrichor.Log", "Petrichor.Mod", "Petrichor.Events", "Petrichor.Task", "Petrichor.Timer", "Petrichor.Net", "Petrichor.Storage", "Petrichor.Json", "Petrichor.Random", "Petrichor.Hash", "Petrichor.Bits", "Petrichor.Math"
  2. Courage host modules: "Courage.Shaders", "Courage.Assets", "Courage.Game", "Courage.World", "Courage.Player", "Courage.Save", "Courage.Hooks", "Courage.Bmg", "Courage.Settings", "Courage.Config", "Courage.Timer", "Courage.UI", "Courage.Layout", "Courage.Audio"
  3. File-based modules: dots become /, .luau is appended, path is relative to the active mod folder.
-- resolves to <mod-dir>/utils/math.luau
local MathUtils = require("utils.math")
// resolves to src/utils/math.ts, compiles to out/utils/math.luau
import * as MathUtils from "./utils/math"

Petrichor.Standard (newclass, mod, log, Levels) is preloaded as globals, not via require().


Async, timers, and networking#

Petrichor ships a task scheduler on the main Luau thread. Use it for waiting on hooks without blocking the game:

local Task  = require("Petrichor.Task")
local Timer = require("Petrichor.Timer")
local Log   = require("Petrichor.Log")

Timer.after(2.0, function()
    Log.info("my-mod", "ready")
end)

Task.async(function()
    Task.sleep(1.0)
    Log.info("my-mod", "one second later")
end)()
import * as Task from "Petrichor.Task"
import * as Timer from "Petrichor.Timer"
import * as Log from "Petrichor.Log"

Timer.after(2.0, () => {
    Log.info("my-mod", "ready")
})

Task.async(async () => {
    await Task.sleep(1.0)
    Log.info("my-mod", "one second later")
})()

See Petrichor.Task, Petrichor.Timer, Petrichor.Net, and Petrichor.Storage.


Hook lifecycle#

Hooks registered at load stay for the whole session. You cannot unregister a Luau hook at runtime today.

The execution order for a hooked function is:

  [ before hooks, sorted by priority (ascending) ]
        ↓
  [ original function ]   # skipped if a before hook sets ctx.cancelled = true
        ↓
  [ after hooks, sorted by priority (ascending) ]

A replace hook entirely replaces the original function slot, and the original does not run.


Struct views#

When ctx.self or a parameter is a pointer to a game struct, the hook runtime wraps it in a view proxy that lets you read and write fields by name:

Hook["daCow_c::action_move"]:before(function(ctx)
    local cow = ctx.self    -- proxy for daCow_c*

    -- read a field
    local mode = cow.mMode  -- returns a number

    -- write a field
    cow.mMode = 3

    -- access an embedded struct field (returns another proxy)
    local pos = cow.mCurrentPos
    print(pos.x, pos.y, pos.z)
end)
import * as Hook from "Augment.Hook"

Hook.daCow_c_action_move.before((ctx) => {
    const cow = ctx.self  // typed daCow_c proxy

    const mode = cow.mMode
    cow.mMode = 3

    const pos = cow.mCurrentPos
    print(pos.x, pos.y, pos.z)
})

Field names and types come from the game symbol map (augment.bin). Accessing a field that doesn't exist raises an error.


Module pattern#

For larger mods, split logic across helper modules and call them from the entry file:

-- patches/cow.luau
local Hook = require("Augment.Hook")
local Log  = require("Petrichor.Log")

return function()
    Hook["daCow_c::Execute"]:before(function(ctx)
        Log.info("cow-patch", "tick")
    end)
end

-- main.luau
local registerCow = require("patches.cow")
local registerLink = require("patches.link")

registerCow()
registerLink()
// src/patches/cow.ts
import * as Hook from "Augment.Hook"
import * as Log from "Petrichor.Log"

export = function () {
    Hook.daCow_c_Execute.before((ctx) => {
        Log.info("cow-patch", "tick")
    })
}

// src/main.ts
import registerCow from "./patches/cow"
import registerLink from "./patches/link"

registerCow()
registerLink()

Type annotations (optional)#

Luau supports gradual typing. You can annotate your callbacks for better editor support:

type CowCtx = { self: any, cancelled: boolean }

Hook["daCow_c::Execute"]:before(function(ctx: CowCtx)
    ctx.self.mMode = 0
end)
import * as Hook from "Augment.Hook"

Hook.daCow_c_Execute.before((ctx: HookCtx) => {
    ctx.self.mMode = 0
})

For full type safety, you can use the TypeScript workflow as well.