Mods: Support Luau mods (#2377)

* Mods: Support Luau mods

* Enable `integer` type & use it

* Link to headers in modding.md

* Update link to webgpu.h
This commit is contained in:
Luke Street
2026-09-03 09:20:51 -06:00
committed by GitHub
parent 281475c9b1
commit 8551f5e0e4
18 changed files with 2963 additions and 157 deletions
+1
View File
@@ -565,6 +565,7 @@ include(cmake/ModSDK.cmake)
if (DUSK_ENABLE_CODE_MODS AND CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
add_custom_target(dusklight_mods) # Aggregate target for all in-tree mods
add_subdirectory(mods/luau_runtime)
add_subdirectory(mods/template_mod)
add_subdirectory(mods/ao_mod)
add_subdirectory(mods/shadow_mod)
+324 -108
View File
@@ -1,35 +1,137 @@
# Dusklight Mod API
Mods are `.dusk` bundles: zip archives that can contain code (in the form of native libraries), resources, DVD overlay
Mods are `.dusk` bundles: zip archives that can contain code (native libraries or scripts), resources, DVD overlay
files, and texture replacements. Mods may be enabled, disabled and reloaded at runtime.
When code mods are loaded, they get dynamically linked by the operating system to the running game process. The mod
There are three types of mods:
- **Asset-only mods**: Mods that contain no code, and may replace files on the game disc ("overlays") and provide
replacement textures (texture packs).
- **Native mods (C++)**: Fully-featured, with the ability to interop with game code and hook functions. Must be compiled
for every supported platform. (See [mod-template](https://github.com/TwilitRealm/mod-template))
- **Script mods (Luau)**: Simple, widely-compatible, but can only access provided services.
## Table of Contents
1. [mod.json](#modjson)
2. [Asset-only Mods](#asset-only-mods)
3. [Native Mods (C++)](#native-mods)
4. [Script Mods (Luau)](#script-mods)
5. [Services](#services)
6. [Built-in Services](#built-in-services)
7. [Hooking Game Functions](#hooking-game-functions)
8. [Asset Overlays](#asset-overlays)
9. [Runtime Lifecycle](#runtime-lifecycle)
10. [Error Handling](#error-handling)
11. [Advanced](#advanced)
---
## mod.json
Every mod starts with a single file, a `mod.json`:
```json
{
"id": "com.example.my_mod",
"name": "My Mod",
"version": "1.0.0",
"author": "Your Name",
"description": "A short description shown in the mod manager.",
"icon": "res/icon.png",
"banner": "res/banner.png"
}
```
`id` is required: a unique, stable identifier (reverse-DNS style; periods, underscores, and lowercase alphanumerics).
Everything else is optional but recommended.
`icon` and `banner` are bundle paths to PNG images that display in the in-game mod manager and mod website. A square
icon (1:1), and a banner (~3.5:1, minimum 800px width). If omitted, `res/icon.png` and `res/banner.png` are used
automatically when present.
Simply create a zip file with a `mod.json`, and rename it to `.dusk`. That's it!
---
## Asset-only Mods
```
my_mod.dusk
├── mod.json
├── res/ (optional bundled resources)
├── overlay/ (optional game file overrides)
└── textures/ (optional texture replacements)
```
Place files in `overlay/` to replace the disc version of that file. Examples:
- `overlay/Movie/demo_movie98_00.thp`: Replaces `Movie/demo_movie98_00.thp` on disc.
- `overlay/res/Object/Kmdl/archive/bmwr/al.bmd`: Replaces `archive/bmwr/al.bmd` _within_ `res/Object/Kmdl.arc` without
overwriting the entire `.arc`.
Place textures in `textures/` to automatically register them as texture replacements when active. These follow the same
[Dolphin-compatible naming scheme](#textureservice-modssvctextureh) as the user `<data>/texture_replacements/`
directory. Directories are scanned recursively. Examples:
- `textures/tex1_256x128_e6a4c7be9bf48305_14.dds`: Replaces the Hero's Clothes texture.
Simply zip the `mod.json` and adjacent folders, then rename to `.dusk`. Then, copy the `.dusk` into the user mods
folder:
- Windows: `%APPDATA%\TwilitRealm\Dusklight\mods`
- Linux: `~/.local/share/TwilitRealm/Dusklight/mods`
- macOS: `~/Library/Application Support/TwilitRealm/Dusklight/mods`
---
## Native Mods
Mods built with [mod-template](https://github.com/TwilitRealm/mod-template) are native C++ mods. Native mods are very
powerful and can interact with and [hook](#hooking-game-functions) game code directly. All features are available to
native mods.
Example C++ mod:
```cpp
#include "mods/service.hpp"
#include "mods/svc/log.h"
DEFINE_MOD(); // once, in exactly one translation unit
IMPORT_SERVICE(LogService, svc_log); // resolved by the loader before mod_initialize
extern "C" {
MOD_EXPORT ModResult mod_initialize(ModError* error) {
svc_log->info(mod_ctx, "hello from my_mod");
return MOD_OK;
}
MOD_EXPORT ModResult mod_update(ModError* error) { // called every frame
return MOD_OK;
}
MOD_EXPORT ModResult mod_shutdown(ModError* error) {
return MOD_OK;
}
}
```
When native mods are loaded, they get dynamically linked by the operating system to the running game process. The mod
exports lifecycle functions that Dusklight calls into (`mod_initialize`, `mod_update`, `mod_shutdown`), and the mod
communicates with the host via **services**: plain C APIs, individually versioned. Dusklight exports several built-in
services, and mods may export services of their own, permitting framework mods and cross-mod integration.
Beyond services, mods have full access to the original game's code: include game headers, call directly into any public
function, read and write data fields, and hook the vast majority of game functions.
Beyond services, native mods have full access to the original game's code: include game headers, call directly into any
public function, read and write data fields, and hook the vast majority of game functions.
## Table of Contents
### Quick Start (Native Mods)
1. [Getting Started](#getting-started)
2. [mod.json](#modjson)
3. [Anatomy of a Code Mod](#anatomy-of-a-code-mod)
4. [Services](#services)
5. [Built-in Services](#built-in-services)
6. [Hooking Game Functions](#hooking-game-functions)
7. [Asset Overlays](#asset-overlays)
8. [Runtime Lifecycle](#runtime-lifecycle)
9. [Error Handling](#error-handling)
10. [Advanced](#advanced)
---
## Getting Started
Fork the [mod template](https://github.com/TwilitRealm/mod-template), a self-contained CMake project that uses the
Dusklight mod SDK.
Create a repository from
the [mod-template](https://github.com/new?template_name=mod-template&template_owner=TwilitRealm),
a self-contained CMake project that uses the Dusklight mod SDK. It includes a GitHub Actions CI workflow that builds the
mod for every supported platform.
```
my_mod/
@@ -65,10 +167,12 @@ add_mod(my_mod
Available features:
- `fmt`: Provides the header-only `{fmt}` library and the formatted logging helpers in `mods/svc/log.hpp`.
- `fmt`: Provides the header-only `{fmt}` library and the formatted logging helpers in
[`mods/svc/log.hpp`](../sdk/include/mods/svc/log.hpp).
- `game`: Allows calling into and hooking game code. Mods that **only** use services may omit it, providing a wider
range of compatibility with Dusklight versions and a slightly faster build process.
- `webgpu`: Allows importing the WebGPU API (`webgpu/webgpu.h`). Must be enabled when using
- `webgpu`: Allows importing the WebGPU API
([`webgpu/webgpu.h`](https://github.com/webgpu-native/webgpu-headers/blob/main/webgpu.h)). Must be enabled when using
[GfxService](#gfxservice-modssvcgfxh).
Building produces `my_mod.dusk` in `build/mods/`. Copy the `.dusk` into the user mods folder:
@@ -81,63 +185,101 @@ Passing `--mods <dir>` on the command line replaces the user directory with one
---
## mod.json
## Script Mods
For simpler use cases where direct game code access and hooks aren't necessary, Luau mods are also supported.
Luau mods do not need to be compiled, and are always supported on all platforms. However, they can only use services
that are provided as Luau modules.
Mods can utilize Luau to add UI elements to their mod panel, use configuration variables, and dynamically swap out
models/textures at runtime (e.g. to switch model variants or disable certain replacements) while remaining widely
compatible.
Example mod structure:
```
my_luau_mod.dusk
├── mod.json
└── res/
├── main.luau
└── lib/util.luau
```
Set the `runtime` to `dev.twilitrealm.luau@1.0` in `mod.json`:
```json
{
"id": "com.example.my_mod",
"name": "My Mod",
"id": "com.example.my_luau_mod",
"name": "My Script Mod",
"version": "1.0.0",
"author": "Your Name",
"description": "A short description shown in the mod manager.",
"icon": "res/my_icon.png",
"banner": "res/my_banner.png"
"runtime": "dev.twilitrealm.luau@1.0"
}
```
`id` is required: a unique, stable identifier (reverse-DNS style; periods, underscores, and alphanumerics). Everything
else is optional but recommended.
`res/main.luau` runs once when the mod activates. Register optional update and shutdown callbacks through
`dusklight.host`:
`icon` and `banner` are bundle-relative paths to PNG images for the in-game mod manager: the square icon (e.g.
512x512), the banner (~3.5:1). If omitted, `res/icon.png` and `res/banner.png` are used automatically when present.
---
## Anatomy of a Code Mod
```cpp
#include "mods/service.hpp"
#include "mods/svc/log.h"
DEFINE_MOD(); // once, in exactly one translation unit
IMPORT_SERVICE(LogService, svc_log); // resolved by the loader before mod_initialize
extern "C" {
MOD_EXPORT ModResult mod_initialize(ModError* error) {
svc_log->info(mod_ctx, "hello from my_mod");
return MOD_OK;
}
MOD_EXPORT ModResult mod_update(ModError* error) { // called every frame
return MOD_OK;
}
MOD_EXPORT ModResult mod_shutdown(ModError* error) {
return MOD_OK;
}
}
```lua
local host = require("dusklight.host")
host.on_update(function()
-- Runs every frame
end)
host.on_shutdown(function()
-- Runs on mod deactivation
end)
```
All three lifecycle exports are required. `mod_ctx` is your mod's identity token, set by the loader before
`mod_initialize` runs. Pass it as the first argument to every service call.
Require script modules with `./` or `../` paths relative to the requiring file. The runtime appends `.luau` and rejects
paths that escape `res/`.
The runtime provides these modules:
- `dusklight.log`: `write`, `trace`, `debug`, `info`, `warn`, and `error`.
- `dusklight.host`: host `version`, mod metadata and directories, lifecycle callbacks, and `fail`.
- `dusklight.config`: bool, integer, float, and string variables with `get`, `set`, and subscriptions.
- `dusklight.resource`: binary-safe reads from the mod's `res/` tree.
- `dusklight.overlay`: file and copied-buffer overlays with removable handles.
- `dusklight.texture`: encoded-file and raw-data replacements with unregisterable handles.
- `dusklight.ui`: Mods panels, controls, lists, windows, dialogs, styles, menu tabs, toasts, and clipboard access.
For completion and type checking, add `sdk/luau/dusklight.d.luau` to the `luau-lsp.types.definitionFiles` setting.
Configurable overlay example:
```lua
local config = require("dusklight.config")
local overlay = require("dusklight.overlay")
local hood = config.register({ name = "hood", type = "bool", default = true })
local current
local function apply(enabled)
if current then current:remove() end
current = overlay.add_file("/res/Object/Alink.arc",
enabled and "res/alink_hood.arc" or "res/alink_nohood.arc")
end
hood:subscribe(apply)
apply(hood:get())
```
To create a script mod, copy `sdk/luau/template`, edit its manifest and source, then zip the `mod.json` and adjacent
folders and rename to `.dusk`. Copy the `.dusk` into the user mods folder:
- Windows: `%APPDATA%\TwilitRealm\Dusklight\mods`
- Linux: `~/.local/share/TwilitRealm/Dusklight/mods`
- macOS: `~/Library/Application Support/TwilitRealm/Dusklight/mods`
**Restrictions:** The Luau VM has no raw filesystem, network, or game-code access. Each script mod has a 64 MiB memory
limit. Calls are interrupted after 250 ms for updates and UI callbacks or 5 seconds for lifecycle calls. An uncaught
error, timeout, or memory exhaustion fails and disables the mod.
---
## Services
A service is a struct of C function pointers with a version header. You declare what you use at file scope, and the
loader resolves it before your mod initializes:
A service is a struct of C function pointers with a version header. Import services at file scope, and the loader
resolves them before initializing the mod:
```cpp
IMPORT_SERVICE(LogService, svc_log); // required, latest minor version
@@ -145,7 +287,7 @@ IMPORT_SERVICE_VERSION(LogService, svc_log, 0); // required, minimum minor ver
IMPORT_OPTIONAL_SERVICE(SomeService, svc_maybe); // may be null
```
A service must be imported in only **one** file (usually your `mod.cpp`). Other files may simply use `svc_log` or
A service should be imported in only **one** file (usually your `mod.cpp`). Other files may simply use `svc_log` or
`mods::log::` after including the appropriate header.
Each service is individually versioned, and there may be multiple major versions of a service provided at once,
@@ -153,26 +295,19 @@ allowing backwards compatibility with older mods while still changing services f
bump is a breaking change, treated as a different service entirely. For **additive** changes, a service appends new
functions to the end of the struct without breaking existing callers and simply bumps the minor version.
`IMPORT_SERVICE` and `IMPORT_OPTIONAL_SERVICE` require the latest minor version compiled against, making every field in
the service safe to call. A mod can use `IMPORT_SERVICE_VERSION` (or its optional counterpart) with an older minor
version to remain compatible with older Dusklight versions, then use `SERVICE_HAS` to check at runtime for fields added
after that explicitly requested version.
The contract (see `sdk/include/mods/api.h` for the full version):
- **A required import is guaranteed valid.** If the service is missing or too old, the mod fails to load with a clear
error. No need to null check at call sites.
- **Anything at or below the minor version you imported can be called unconditionally.** The default macros import
the service type's current minor version; the versioned macros explicitly override that minimum.
- Optional imports may be null; check once in `mod_initialize`.
- Fields newer than your imported minor version must be gated behind `SERVICE_HAS(service, ServiceType, field)` plus a
null check.
`IMPORT_SERVICE` and `IMPORT_OPTIONAL_SERVICE` require the latest minor version compiled against, guaranteeing that
every function is present. If a mod doesn't use (or may operate without) functions added in later minor versions, and
wants to remain compatible with older Dusklight versions, it may use `IMPORT_SERVICE_VERSION` or
`IMPORT_OPTIONAL_SERVICE_VERSION` to require an older minor version. It can then check `SERVICE_HAS` at runtime to see
if a newer function is present (i.e. running on a new enough Dusklight version).
---
## Built-in Services
### LogService (`mods/svc/log.h`)
### LogService ([`mods/svc/log.h`](../sdk/include/mods/svc/log.h))
**C++**
```cpp
IMPORT_SERVICE(LogService, svc_log);
@@ -183,9 +318,16 @@ svc_log->error(mod_ctx, "very bad");
svc_log->write(mod_ctx, LOG_LEVEL_DEBUG, "verbose details");
```
**Luau**
```lua
local log = require("dusklight.log")
log.info("spawned the thing")
```
Messages appear in the console prefixed with your mod ID. Messages are plain UTF-8 strings and are copied before the
call returns. C++ mods can enable `add_mod(... FEATURES fmt)` and use the formatted logging helpers in
`mods/svc/log.hpp`:
[`mods/svc/log.hpp`](../sdk/include/mods/svc/log.hpp):
```cpp
#include <mods/svc/log.hpp>
@@ -194,11 +336,13 @@ mods::log::info("spawned actor {} at ({}, {})", actorName, x, y);
mods::log::warn("health is down to {:.1f}%", healthPercent);
```
### ResourceService (`mods/svc/resource.h`)
### ResourceService ([`mods/svc/resource.h`](../sdk/include/mods/svc/resource.h))
Loads files from the `res/` tree of your `.dusk` archive. Paths are relative to `res/` (pass `"config.txt"`, not
`"res/config.txt"`); absolute paths and `..` are rejected.
**C++**
```cpp
IMPORT_SERVICE(ResourceService, svc_resource);
@@ -209,10 +353,17 @@ if (svc_resource->load(mod_ctx, "config.txt", &buf) == MOD_OK) {
}
```
**Luau**
```lua
local resource = require("dusklight.resource")
local contents = resource.load("config.txt")
```
Missing files return `MOD_UNAVAILABLE`. Always `free` what you `load`. The bundle is read-only; use
`HostService::data_dir` for persistent storage.
### FileService (`mods/svc/file.h`)
### FileService ([`mods/svc/file.h`](../sdk/include/mods/svc/file.h))
Provides file and folder pickers, file I/O, exports and folder enumeration.
@@ -257,9 +408,10 @@ mods::file::export_file(location, "report.txt", [](mods::file::PickResult result
`export_file` copies an existing file to a user-selected destination and returns the destination location in its
callback. Mod-owned persistent files belong in `HostService::data_dir`.
### HttpService (`mods/svc/http.h`)
### HttpService ([`mods/svc/http.h`](../sdk/include/mods/svc/http.h))
Asynchronous HTTPS requests supporting HTTP/2 and TLS 1.2+. C++ mods should use the helpers in `mods/svc/http.hpp`:
Asynchronous HTTPS requests supporting HTTP/2 and TLS 1.2+. C++ mods should use the helpers in
[`mods/svc/http.hpp`](../sdk/include/mods/svc/http.hpp):
```cpp
#include "mods/svc/http.hpp"
@@ -299,9 +451,11 @@ For large responses, set `downloadPath` to an absolute path in the calling mod's
an empty `body` and the final path in `downloadPath`. Check `Response::ok()` before using the file.
`Pending::progress()` reports download progress when the server provides a total size.
### HostService (`mods/svc/host.h`)
### HostService ([`mods/svc/host.h`](../sdk/include/mods/svc/host.h))
Mod metadata and runtime interaction with the loader:
Mod metadata and runtime interaction with the loader.
**C++**
```cpp
IMPORT_SERVICE(HostService, svc_host);
@@ -319,6 +473,20 @@ if (svc_host->data_dir(mod_ctx, &dataDir) == MOD_OK) {
svc_host->fail(mod_ctx, MOD_ERROR, "something unrecoverable happened");
```
**Luau**
```lua
local host = require("dusklight.host")
local dataDir = host.data_dir()
host.on_update(function()
-- Runs every frame
end)
host.on_shutdown(function()
-- Runs on mod deactivation
end)
host.fail("something unrecoverable happened")
```
`get_service`/`publish_service` provide dynamic service lookup; see [Exporting Services](#exporting-services).
**Lifecycle watches.** If your mod provides a service that hands out per-caller state (registrations, callbacks,
@@ -341,17 +509,19 @@ svc_host->watch_mod_lifecycle(mod_ctx, on_mod_lifecycle, nullptr, &watch);
`MOD_LIFECYCLE_DETACHED` fires on the game thread at a lifecycle safe point, after the subject's `mod_shutdown` ran and
every service dropped its state. For your own mod's teardown, use `mod_shutdown` instead.
### HookService (`mods/svc/hook.h`)
### HookService ([`mods/svc/hook.h`](../sdk/include/mods/svc/hook.h))
Installs hooks on game functions and resolves symbols by name. You'll rarely call it directly; use the typed helpers in
`mods/svc/hook.hpp` described in [Hooking Game Functions](#hooking-game-functions).
[`mods/svc/hook.hpp`](../sdk/include/mods/svc/hook.hpp) described in
[Hooking Game Functions](#hooking-game-functions).
### OverlayService (`mods/svc/overlay.h`)
### OverlayService ([`mods/svc/overlay.h`](../sdk/include/mods/svc/overlay.h))
Registers DVD file overlays at runtime: the dynamic counterpart to the static `overlay/` directory (see
[Asset Overlays](#asset-overlays)). Overlay a disc path with a file from your bundle, a file within an archive,
or with a caller-owned buffer
(copied on registration):
or with a caller-owned buffer (copied on registration).
**C++**
```cpp
IMPORT_SERVICE(OverlayService, svc_overlay);
@@ -363,6 +533,14 @@ svc_overlay->add_buffer(mod_ctx, "/generated.txt", data, size, nullptr);
svc_overlay->remove(mod_ctx, handle);
```
**Luau**
```lua
local overlay = require("dusklight.overlay")
local replacement = overlay.add_file("/Movie/demo_movie98_00.thp", "res/replacement.thp")
replacement:remove()
```
`disc_path` must be absolute (leading `/`) and is matched against the disc case-insensitively. Paths that don't exist
on the disc are added as new files. Changes are applied at the next frame boundary, and data the game already read
stays in memory until the file is re-read: sometimes a scene reload, and in the worst case, a full restart.
@@ -372,11 +550,13 @@ data get refreshed without a full restart.
See [Asset Overlays](#asset-overlays) for priority and conflict handling.
### TextureService (`mods/svc/texture.h`)
### TextureService ([`mods/svc/texture.h`](../sdk/include/mods/svc/texture.h))
Registers texture replacements at runtime: the dynamic counterpart to the static `textures/` directory (see
[Asset Overlays](#asset-overlays)). Two forms: raw texel data with an explicit key, or an encoded `.dds`/`.png` from
your bundle whose filename encodes the key:
your bundle whose filename encodes the key.
**C++**
```cpp
IMPORT_SERVICE(TextureService, svc_texture);
@@ -397,6 +577,14 @@ svc_texture->register_data(mod_ctx, &key, &data, nullptr);
svc_texture->unregister(mod_ctx, handle);
```
**Luau**
```lua
local texture = require("dusklight.texture")
local replacement = texture.register_file("res/tex1_32x32_$_6.png")
replacement:unregister()
```
Filenames use the same Dolphin-style convention as the user's `texture_replacements` directory:
`tex1_{w}x{h}_{texhash}[_{tluthash}]_{fmt}.dds|.png`, where hashes may be `$` (wildcard). `_mipN` sidecar files next to
a registered file are picked up automatically. Files are decoded lazily on first use by the renderer; raw data is copied
@@ -404,11 +592,13 @@ at registration. Registrations follow your mod's lifecycle.
See [Asset Overlays](#asset-overlays) for priority and conflict handling.
### ConfigService (`mods/svc/config.h`)
### ConfigService ([`mods/svc/config.h`](../sdk/include/mods/svc/config.h))
Persistent, mod-scoped configuration variables. Each var is stored in the user's `config.json` under
`mod.<escaped mod id>.<name>` (escaping: `.``_`, `_``__`, so `com.example.my_mod` becomes `com_example_my__mod`),
next to the host's own settings:
next to the host's own settings.
**C++**
```cpp
IMPORT_SERVICE(ConfigService, svc_config);
@@ -432,6 +622,17 @@ void on_speed_changed(ModContext* ctx, ConfigVarHandle var, const ConfigVarValue
svc_config->subscribe(mod_ctx, var, on_speed_changed, nullptr, nullptr);
```
**Luau**
```lua
local config = require("dusklight.config")
local speed = config.register({ name = "speedMultiplier", type = "float", default = 1.0 })
speed:subscribe(function(value, previous)
-- React to the new value.
end)
speed:set(2.0)
```
Types: `CONFIG_VAR_BOOL` (`bool`), `CONFIG_VAR_INT` (`int64_t`), `CONFIG_VAR_FLOAT` (`double`), `CONFIG_VAR_STRING`
(UTF-8; `get_string` copies into a caller buffer, pass a `NULL` buffer with size 0 to query the length). Accessors are
typed and must match the registration.
@@ -440,7 +641,7 @@ Change callbacks fire on the game thread whenever the value changes at runtime (
Writes that store the same value are silent. Values applied from `config.json` or `--cvar` at registration do
**not** fire callbacks; read the value after `register_var` for the starting state.
### SaveService (`mods/svc/save.h`)
### SaveService ([`mods/svc/save.h`](../sdk/include/mods/svc/save.h))
Stores named binary blobs for each save slot. Blob names are scoped to the calling mod, and each mod may store up to
`SAVE_BLOB_BUDGET_BYTES` per slot. The service copies data passed to `set_blob`.
@@ -473,7 +674,7 @@ buffer contract as `get_blob`. Pass a `NULL` buffer to either read function to q
are cleared. Observers are removed automatically when the mod is detached, so the output handle is only needed for
manual unregistration. Save callbacks run on the game thread.
### StageService (`mods/svc/stage.h`)
### StageService ([`mods/svc/stage.h`](../sdk/include/mods/svc/stage.h))
Allows making changes to a stage's "stage info" (contents of .dzs/.dzr files).
(Currently only supports editing actor nodes.)
@@ -522,7 +723,7 @@ Stage names may contain up to 8 characters. For patches and deletions, room `0xf
layer; additions require a specific room. Edits are removed when the mod is detached. If multiple mods edit the same
record, the later-loaded mod wins.
### UiService (`mods/svc/ui.h`)
### UiService ([`mods/svc/ui.h`](../sdk/include/mods/svc/ui.h))
Integrate seamlessly with Dusklight's UI system: add controls and buttons to your mod's detail pane in the Mods window,
create custom windows and modal dialogs, apply custom RCSS stylesheets (anywhere!), and add menu bar tabs.
@@ -532,6 +733,8 @@ content is rebuilt, and `update` runs every frame while that mod is selected. Wh
pane carries your mod's id as a `mod-id` attribute (like custom window roots), so scoped RCSS can target it (e.g.
`[mod-id="com.example.mod"]`).
**C++**
```cpp
IMPORT_SERVICE(UiService, svc_ui);
@@ -555,6 +758,18 @@ panel.update = update;
svc_ui->register_mods_panel(mod_ctx, &panel);
```
**Luau**
```lua
local ui = require("dusklight.ui")
ui.register_mods_panel({
build = function(panel)
panel:add_section("Status")
panel:add_text("running")
end,
})
```
Element setters must match the element kind: `elem_set_text`/`elem_set_rml` on text rows, and `elem_set_progress` on
progress bars. `elem_set_class` sets or clears an RCSS class on any element handle, for styling via scoped or
per-window RCSS. A non-`MOD_OK` result from `build`/`update` fails your mod, as do exceptions thrown from any UI
@@ -704,7 +919,7 @@ existing documents restyle immediately, and future ones pick it up when created.
host styles and may override them. Scope selectors tightly (use `[mod-id="..."]`!), especially for `UI_SCOPE_WINDOW`,
unless changing host UI is intentional.
### WindowService (`mods/svc/window.h`)
### WindowService ([`mods/svc/window.h`](../sdk/include/mods/svc/window.h))
Allows creating new windows that can be rendered to via `GfxService`.
@@ -724,12 +939,13 @@ one present target may be attached to a WindowService window at a time.
New windows are hidden by default so a mod can finish attaching graphics before calling `show_window`.
### GfxService (`mods/svc/gfx.h`)
### GfxService ([`mods/svc/gfx.h`](../sdk/include/mods/svc/gfx.h))
**Requires `add_mod(... FEATURES webgpu)`**
Direct WebGPU access at various stages of the rendering pipeline. Mods use the `wgpu*` C API (via `webgpu/webgpu.h`) for
custom draws and compute dispatches. Mods must manage their own WebGPU state, including pipelines and bind groups.
Direct WebGPU access at various stages of the rendering pipeline. Mods use the `wgpu*` C API (via
[`webgpu/webgpu.h`](https://github.com/webgpu-native/webgpu-headers/blob/main/webgpu.h)) for custom draws and compute
dispatches. Mods must manage their own WebGPU state, including pipelines and bind groups.
```cpp
IMPORT_SERVICE(GfxService, svc_gfx);
@@ -779,7 +995,7 @@ To create a `WGPUSurface` manually, `GfxDeviceInfo` holds the `WGPUInstance` and
`push_present` must be called every frame from a GfxService stage callback. If surface was lost, `push_present` returns
`MOD_ERROR`. Unregister and re-register the target before trying again.
### CameraService (`mods/svc/camera.h`)
### CameraService ([`mods/svc/camera.h`](../sdk/include/mods/svc/camera.h))
Converts a game view provided by a render callback into WebGPU-convention camera data. Matrix fields are column-major
`float[16]` values using the matrix * column-vector convention (transpose of the game's row-major `Mtx`/`Mtx44` layout),
@@ -801,7 +1017,7 @@ first in-game frame. Projection matrices match the renderer's WebGPU clip conven
Camera operators allow overriding the main camera. When an operator callback returns true, its values replace the camera
state for the current frame. Register and unregister using `register_camera_operator` / `unregister_camera_operator`.
### GameModeService (`mods/svc/game_mode.h`)
### GameModeService ([`mods/svc/game_mode.h`](../sdk/include/mods/svc/game_mode.h))
Allows a mod to register a game mode with callbacks for key gameplay and save lifecycle events. Registered game modes
appear in the prelaunch menu. Game modes may use a unique set of saves by configuring `save_name`; leave it empty to use
@@ -927,7 +1143,7 @@ svc_game_mode->register_game_mode(mod_ctx, &gameModeDesc);
**Requires `add_mod(... FEATURES game)`**
Mods may hook the vast majority of game functions, including file-local static, private and virtual functions.
`mods/svc/hook.hpp` provides typed helpers over the hook service:
[`mods/svc/hook.hpp`](../sdk/include/mods/svc/hook.hpp) provides typed helpers over the hook service:
```cpp
#include "mods/svc/hook.hpp"
+40
View File
@@ -0,0 +1,40 @@
cmake_minimum_required(VERSION 3.25)
project(luau_runtime CXX)
if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
set(DUSK_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../.." CACHE PATH "Path to dusk source root")
option(DUSK_MOD_USE_FULL_TREE "Use full build instead of the minimal mod SDK" OFF)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
if (DUSK_MOD_USE_FULL_TREE)
add_subdirectory("${DUSK_DIR}" dusk EXCLUDE_FROM_ALL)
else ()
add_subdirectory("${DUSK_DIR}/sdk" dusk-sdk EXCLUDE_FROM_ALL)
endif ()
endif ()
include(FetchContent)
set(LUAU_BUILD_CLI OFF CACHE BOOL "" FORCE)
set(LUAU_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(LUAU_BUILD_WEB OFF CACHE BOOL "" FORCE)
set(LUAU_WERROR OFF CACHE BOOL "" FORCE)
FetchContent_Declare(luau
URL https://github.com/luau-lang/luau/archive/refs/tags/0.734.tar.gz
URL_HASH SHA256=cb55a891226d8c70284e22eb9281cc2b4496c709a4050f52aaa18a355fe7b1a3
DOWNLOAD_EXTRACT_TIMESTAMP FALSE
EXCLUDE_FROM_ALL
)
FetchContent_MakeAvailable(luau)
add_mod(luau_runtime
SOURCES
src/bindings.cpp
src/config.cpp
src/runtime.cpp
src/ui.cpp
MOD_JSON mod.json
RES_DIR res
BUNDLE
)
target_link_libraries(luau_runtime PRIVATE Luau.VM Luau.Compiler)
+7
View File
@@ -0,0 +1,7 @@
{
"id": "dev.twilitrealm.luau",
"name": "Luau Support",
"version": "1.0.0",
"author": "Twilit Realm",
"description": "Luau script runtime for mods"
}
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2019-2025 Roblox Corporation
Copyright (c) 1994-2019 Lua.org, PUC-Rio.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+340
View File
@@ -0,0 +1,340 @@
#include "runtime.hpp"
#include <cstring>
#include <string>
#include <vector>
namespace luau_runtime {
namespace {
constexpr char kOverlayMetatable[] = "dusklight.overlay_handle";
constexpr char kTextureMetatable[] = "dusklight.texture_handle";
uint64_t get_hash_field(lua_State* state, int table, const char* field, bool required) {
lua_getfield(state, table, field);
if (lua_isnil(state, -1)) {
lua_pop(state, 1);
if (required) {
luaL_error(state, "field '%s' is required", field);
}
return 0;
}
if (!lua_isinteger64(state, -1)) {
luaL_error(state, "field '%s' must be an integer", field);
}
const uint64_t value = static_cast<uint64_t>(lua_tointeger64(state, -1, nullptr));
lua_pop(state, 1);
return value;
}
int log_write(lua_State* state, LogLevel level, int messageIndex) {
Vm& vm = vm_from_upvalue(state);
if (svc_log == nullptr) {
service_unavailable(state, "LogService");
}
size_t length = 0;
const char* message = luaL_checklstring(state, messageIndex, &length);
const std::string copy{message, length};
svc_log->write(vm.subject, level, copy.c_str());
return 0;
}
int log_trace(lua_State* state) {
return log_write(state, LOG_LEVEL_TRACE, 1);
}
int log_debug(lua_State* state) {
return log_write(state, LOG_LEVEL_DEBUG, 1);
}
int log_info(lua_State* state) {
return log_write(state, LOG_LEVEL_INFO, 1);
}
int log_warn(lua_State* state) {
return log_write(state, LOG_LEVEL_WARN, 1);
}
int log_error(lua_State* state) {
return log_write(state, LOG_LEVEL_ERROR, 1);
}
int log_write_level(lua_State* state) {
static constexpr const char* kLevels[] = {"trace", "debug", "info", "warn", "error", nullptr};
const int level = luaL_checkoption(state, 1, nullptr, kLevels);
return log_write(state, static_cast<LogLevel>(level), 2);
}
int host_mod_id(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
if (svc_host == nullptr) {
service_unavailable(state, "HostService");
}
lua_pushstring(state, svc_host->mod_id(vm.subject));
return 1;
}
int host_mod_name(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
if (svc_host == nullptr) {
service_unavailable(state, "HostService");
}
lua_pushstring(state, svc_host->mod_name(vm.subject));
return 1;
}
int host_mod_version(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
if (svc_host == nullptr) {
service_unavailable(state, "HostService");
}
lua_pushstring(state, svc_host->mod_version(vm.subject));
return 1;
}
int host_mod_dir(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
if (svc_host == nullptr) {
service_unavailable(state, "HostService");
}
lua_pushstring(state, svc_host->mod_dir(vm.subject));
return 1;
}
int host_data_dir(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
if (svc_host == nullptr || !SERVICE_HAS(svc_host, HostService, data_dir) ||
svc_host->data_dir == nullptr)
{
service_unavailable(state, "HostService::data_dir");
}
const char* path = nullptr;
check_result(state, svc_host->data_dir(vm.subject, &path), "host.data_dir");
lua_pushstring(state, path != nullptr ? path : "");
return 1;
}
int host_fail(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
if (svc_host == nullptr) {
service_unavailable(state, "HostService");
}
const char* message = luaL_checkstring(state, 1);
svc_host->fail(vm.subject, MOD_ERROR, message);
luaL_error(state, "%s", message);
}
int register_host_callback(lua_State* state, std::vector<int>& callbacks) {
luaL_checktype(state, 1, LUA_TFUNCTION);
lua_pushvalue(state, 1);
callbacks.push_back(lua_ref(state, -1));
lua_pop(state, 1);
return 0;
}
int host_on_update(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
return register_host_callback(state, vm.updateRefs);
}
int host_on_shutdown(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
return register_host_callback(state, vm.shutdownRefs);
}
int resource_load(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
if (svc_resource == nullptr) {
service_unavailable(state, "ResourceService");
}
const char* path = luaL_checkstring(state, 1);
ResourceBuffer buffer = RESOURCE_BUFFER_INIT;
check_result(state, svc_resource->load(vm.subject, path, &buffer), "resource.load");
const auto* data = buffer.data != nullptr ? static_cast<const char*>(buffer.data) : "";
const std::string copy{data, buffer.size};
svc_resource->free(vm.subject, &buffer);
lua_pushlstring(state, copy.data(), copy.size());
return 1;
}
int overlay_remove(lua_State* state) {
auto& handle = check_handle(state, 1, kOverlayMetatable, HandleKind::Overlay);
if (svc_overlay == nullptr) {
service_unavailable(state, "OverlayService");
}
check_result(state, svc_overlay->remove(handle.vm->subject, handle.value), "overlay.remove");
handle.value = 0;
return 0;
}
int overlay_add_file(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
if (svc_overlay == nullptr) {
service_unavailable(state, "OverlayService");
}
const char* discPath = luaL_checkstring(state, 1);
const char* bundlePath = luaL_checkstring(state, 2);
OverlayHandle handle = 0;
check_result(state, svc_overlay->add_file(vm.subject, discPath, bundlePath, &handle),
"overlay.add_file");
push_handle(state, vm, handle, HandleKind::Overlay, kOverlayMetatable);
return 1;
}
int overlay_add_buffer(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
if (svc_overlay == nullptr) {
service_unavailable(state, "OverlayService");
}
const char* discPath = luaL_checkstring(state, 1);
size_t size = 0;
const char* data = luaL_checklstring(state, 2, &size);
OverlayHandle handle = 0;
check_result(state, svc_overlay->add_buffer(vm.subject, discPath, data, size, &handle),
"overlay.add_buffer");
push_handle(state, vm, handle, HandleKind::Overlay, kOverlayMetatable);
return 1;
}
int texture_unregister(lua_State* state) {
auto& handle = check_handle(state, 1, kTextureMetatable, HandleKind::Texture);
if (svc_texture == nullptr) {
service_unavailable(state, "TextureService");
}
check_result(
state, svc_texture->unregister(handle.vm->subject, handle.value), "texture.unregister");
handle.value = 0;
return 0;
}
int texture_register_file(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
if (svc_texture == nullptr) {
service_unavailable(state, "TextureService");
}
const char* path = luaL_checkstring(state, 1);
TextureReplacementHandle handle = 0;
check_result(
state, svc_texture->register_file(vm.subject, path, &handle), "texture.register_file");
push_handle(state, vm, handle, HandleKind::Texture, kTextureMetatable);
return 1;
}
int texture_register_data(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
if (svc_texture == nullptr) {
service_unavailable(state, "TextureService");
}
luaL_checktype(state, 1, LUA_TTABLE);
luaL_checktype(state, 2, LUA_TTABLE);
TextureKey key = TEXTURE_KEY_INIT;
key.kind = TEXTURE_KEY_SOURCE;
key.texture_hash = get_hash_field(state, 1, "texture_hash", true);
key.tlut_hash = get_hash_field(state, 1, "tlut_hash", false);
key.width = static_cast<uint32_t>(get_optional_int(state, 1, "width", 0));
key.height = static_cast<uint32_t>(get_optional_int(state, 1, "height", 0));
key.gx_format = static_cast<uint32_t>(get_optional_int(state, 1, "gx_format", 0));
key.has_tlut = get_optional_bool(state, 1, "has_tlut", false);
lua_getfield(state, 2, "data");
size_t size = 0;
const char* bytes = luaL_checklstring(state, -1, &size);
TextureData data = TEXTURE_DATA_INIT;
data.data = bytes;
data.size = size;
data.width = static_cast<uint32_t>(get_optional_int(state, 2, "width", key.width));
data.height = static_cast<uint32_t>(get_optional_int(state, 2, "height", key.height));
data.mip_count = static_cast<uint32_t>(get_optional_int(state, 2, "mip_count", 1));
data.gx_format = static_cast<uint32_t>(get_optional_int(state, 2, "gx_format", key.gx_format));
TextureReplacementHandle handle = 0;
const ModResult result = svc_texture->register_data(vm.subject, &key, &data, &handle);
lua_pop(state, 1);
check_result(state, result, "texture.register_data");
push_handle(state, vm, handle, HandleKind::Texture, kTextureMetatable);
return 1;
}
} // namespace
int open_log(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
if (svc_log == nullptr) {
service_unavailable(state, "LogService");
}
lua_newtable(state);
set_function(state, vm, "write", log_write_level);
set_function(state, vm, "trace", log_trace);
set_function(state, vm, "debug", log_debug);
set_function(state, vm, "info", log_info);
set_function(state, vm, "warn", log_warn);
set_function(state, vm, "error", log_error);
lua_setreadonly(state, -1, true);
return 1;
}
int open_host(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
if (svc_host == nullptr) {
service_unavailable(state, "HostService");
}
lua_newtable(state);
lua_pushstring(state, svc_host->version != nullptr ? svc_host->version : "");
lua_setfield(state, -2, "version");
set_function(state, vm, "mod_id", host_mod_id);
set_function(state, vm, "mod_name", host_mod_name);
set_function(state, vm, "mod_version", host_mod_version);
set_function(state, vm, "mod_dir", host_mod_dir);
set_function(state, vm, "data_dir", host_data_dir);
set_function(state, vm, "on_update", host_on_update);
set_function(state, vm, "on_shutdown", host_on_shutdown);
set_function(state, vm, "fail", host_fail);
lua_setreadonly(state, -1, true);
return 1;
}
int open_resource(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
if (svc_resource == nullptr) {
service_unavailable(state, "ResourceService");
}
lua_newtable(state);
set_function(state, vm, "load", resource_load);
lua_setreadonly(state, -1, true);
return 1;
}
int open_overlay(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
if (svc_overlay == nullptr) {
service_unavailable(state, "OverlayService");
}
static const luaL_Reg kMethods[] = {{"remove", overlay_remove}, {nullptr, nullptr}};
create_handle_metatable(state, kOverlayMetatable, kMethods, "OverlayHandle");
lua_newtable(state);
set_function(state, vm, "add_file", overlay_add_file);
set_function(state, vm, "add_buffer", overlay_add_buffer);
lua_setreadonly(state, -1, true);
return 1;
}
int open_texture(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
if (svc_texture == nullptr) {
service_unavailable(state, "TextureService");
}
static const luaL_Reg kMethods[] = {{"unregister", texture_unregister}, {nullptr, nullptr}};
create_handle_metatable(state, kTextureMetatable, kMethods, "TextureHandle");
lua_newtable(state);
set_function(state, vm, "register_file", texture_register_file);
set_function(state, vm, "register_data", texture_register_data);
lua_pushinteger64(state, static_cast<int64_t>(TEXTURE_HASH_WILDCARD));
lua_setfield(state, -2, "hash_wildcard");
lua_pushinteger64(state, static_cast<int64_t>(TEXTURE_TLUT_WILDCARD));
lua_setfield(state, -2, "tlut_wildcard");
lua_setreadonly(state, -1, true);
return 1;
}
} // namespace luau_runtime
+243
View File
@@ -0,0 +1,243 @@
#include "runtime.hpp"
#include <string>
#include <vector>
namespace luau_runtime {
namespace {
constexpr char kConfigVarMetatable[] = "dusklight.config_var";
constexpr char kConfigSubscriptionMetatable[] = "dusklight.config_subscription";
void config_changed(ModContext*, ConfigVarHandle, const ConfigVarValue* value,
const ConfigVarValue* previous, void* userData) {
auto& callback = *static_cast<Callback*>(userData);
if (value == nullptr || previous == nullptr) {
return;
}
push_config_value(callback.vm->state, *value);
push_config_value(callback.vm->state, *previous);
std::string error;
if (!call_ref(*callback.vm, callback.refs[0], 2, 0, kCallbackBudget, error)) {
fail_callback(*callback.vm, "config change callback", error);
}
}
int config_get(lua_State* state) {
auto& handle = check_handle(state, 1, kConfigVarMetatable, HandleKind::ConfigVar);
if (svc_config == nullptr) {
service_unavailable(state, "ConfigService");
}
switch (handle.configType) {
case CONFIG_VAR_BOOL: {
bool value = false;
check_result(
state, svc_config->get_bool(handle.vm->subject, handle.value, &value), "config get");
lua_pushboolean(state, value);
break;
}
case CONFIG_VAR_INT: {
int64_t value = 0;
check_result(
state, svc_config->get_int(handle.vm->subject, handle.value, &value), "config get");
lua_pushinteger64(state, value);
break;
}
case CONFIG_VAR_FLOAT: {
double value = 0;
check_result(
state, svc_config->get_float(handle.vm->subject, handle.value, &value), "config get");
lua_pushnumber(state, value);
break;
}
case CONFIG_VAR_STRING: {
size_t size = 0;
check_result(state,
svc_config->get_string(handle.vm->subject, handle.value, nullptr, 0, &size),
"config get");
std::vector<char> value(size + 1);
check_result(state,
svc_config->get_string(
handle.vm->subject, handle.value, value.data(), value.size(), nullptr),
"config get");
lua_pushlstring(state, value.data(), size);
break;
}
default:
luaL_error(state, "unknown config value type");
}
return 1;
}
int config_set(lua_State* state) {
auto& handle = check_handle(state, 1, kConfigVarMetatable, HandleKind::ConfigVar);
if (svc_config == nullptr) {
service_unavailable(state, "ConfigService");
}
ModResult result = MOD_INVALID_ARGUMENT;
switch (handle.configType) {
case CONFIG_VAR_BOOL:
result = svc_config->set_bool(
handle.vm->subject, handle.value, luaL_checkboolean(state, 2) != 0);
break;
case CONFIG_VAR_INT:
result = svc_config->set_int(handle.vm->subject, handle.value, check_int64(state, 2));
break;
case CONFIG_VAR_FLOAT:
result =
svc_config->set_float(handle.vm->subject, handle.value, luaL_checknumber(state, 2));
break;
case CONFIG_VAR_STRING:
result =
svc_config->set_string(handle.vm->subject, handle.value, luaL_checkstring(state, 2));
break;
default:
break;
}
check_result(state, result, "config set");
return 0;
}
int config_unregister(lua_State* state) {
auto& handle = check_handle(state, 1, kConfigVarMetatable, HandleKind::ConfigVar);
if (svc_config == nullptr) {
service_unavailable(state, "ConfigService");
}
check_result(
state, svc_config->unregister_var(handle.vm->subject, handle.value), "config unregister");
handle.value = 0;
return 0;
}
int config_unsubscribe(lua_State* state) {
auto& handle =
check_handle(state, 1, kConfigSubscriptionMetatable, HandleKind::ConfigSubscription);
if (svc_config == nullptr) {
service_unavailable(state, "ConfigService");
}
check_result(
state, svc_config->unsubscribe(handle.vm->subject, handle.value), "config unsubscribe");
handle.value = 0;
return 0;
}
int subscribe(lua_State* state, ScriptHandle& variable, int functionIndex) {
luaL_checktype(state, functionIndex, LUA_TFUNCTION);
Callback& callback = retain_callback(*variable.vm);
callback.refs[0] = lua_ref(state, functionIndex);
ConfigSubscriptionHandle subscription = 0;
check_result(state,
svc_config->subscribe(
variable.vm->subject, variable.value, config_changed, &callback, &subscription),
"config.subscribe");
push_handle(state, *variable.vm, subscription, HandleKind::ConfigSubscription,
kConfigSubscriptionMetatable);
return 1;
}
int config_subscribe(lua_State* state) {
if (svc_config == nullptr) {
service_unavailable(state, "ConfigService");
}
auto& variable = check_handle(state, 1, kConfigVarMetatable, HandleKind::ConfigVar);
return subscribe(state, variable, 2);
}
int config_var_subscribe(lua_State* state) {
if (svc_config == nullptr) {
service_unavailable(state, "ConfigService");
}
auto& variable = check_handle(state, 1, kConfigVarMetatable, HandleKind::ConfigVar);
return subscribe(state, variable, 2);
}
int config_register(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
if (svc_config == nullptr) {
service_unavailable(state, "ConfigService");
}
luaL_checktype(state, 1, LUA_TTABLE);
const std::string name = get_optional_string(state, 1, "name");
const std::string type = get_optional_string(state, 1, "type");
ConfigVarDesc desc = CONFIG_VAR_DESC_INIT;
desc.name = name.c_str();
if (type == "bool") {
desc.type = CONFIG_VAR_BOOL;
desc.default_bool = get_optional_bool(state, 1, "default", false);
} else if (type == "int") {
desc.type = CONFIG_VAR_INT;
desc.default_int = get_optional_int(state, 1, "default", 0);
} else if (type == "float") {
desc.type = CONFIG_VAR_FLOAT;
desc.default_float = get_optional_number(state, 1, "default", 0);
} else if (type == "string") {
desc.type = CONFIG_VAR_STRING;
} else {
luaL_error(state, "config type must be 'bool', 'int', 'float', or 'string'");
}
std::string defaultString;
if (desc.type == CONFIG_VAR_STRING) {
defaultString = get_optional_string(state, 1, "default");
desc.default_string = defaultString.c_str();
}
ConfigVarHandle handle = 0;
check_result(state, svc_config->register_var(vm.subject, &desc, &handle), "config.register");
push_handle(state, vm, handle, HandleKind::ConfigVar, kConfigVarMetatable, desc.type);
return 1;
}
} // namespace
void push_config_value(lua_State* state, const ConfigVarValue& value) {
switch (value.type) {
case CONFIG_VAR_BOOL:
lua_pushboolean(state, value.bool_value);
break;
case CONFIG_VAR_INT:
lua_pushinteger64(state, value.int_value);
break;
case CONFIG_VAR_FLOAT:
lua_pushnumber(state, value.float_value);
break;
case CONFIG_VAR_STRING:
lua_pushlstring(state, value.string_value != nullptr ? value.string_value : "",
value.string_value != nullptr ? value.string_length : 0);
break;
default:
lua_pushnil(state);
break;
}
}
int open_config(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
if (svc_config == nullptr) {
service_unavailable(state, "ConfigService");
}
static const luaL_Reg kVarMethods[] = {
{"get", config_get},
{"set", config_set},
{"subscribe", config_var_subscribe},
{"unregister", config_unregister},
{nullptr, nullptr},
};
static const luaL_Reg kSubscriptionMethods[] = {
{"unsubscribe", config_unsubscribe},
{nullptr, nullptr},
};
create_handle_metatable(state, kConfigVarMetatable, kVarMethods, "ConfigVar");
create_handle_metatable(
state, kConfigSubscriptionMetatable, kSubscriptionMethods, "ConfigSubscription");
lua_newtable(state);
set_function(state, vm, "register", config_register);
set_function(state, vm, "subscribe", config_subscribe);
lua_setreadonly(state, -1, true);
return 1;
}
} // namespace luau_runtime
+611
View File
@@ -0,0 +1,611 @@
#include "runtime.hpp"
#include "Luau/Common.h"
#include "luacode.h"
#include "mods/runtime.h"
#include "mods/service.hpp"
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <limits>
#include <new>
#include <optional>
#include <string_view>
#include <utility>
LUAU_FASTFLAG(LuauIntegerLibrary)
LUAU_FASTFLAG(LuauIntegerType2)
DEFINE_MOD();
IMPORT_OPTIONAL_SERVICE(LogService, svc_log);
IMPORT_OPTIONAL_SERVICE(HostService, svc_host);
IMPORT_OPTIONAL_SERVICE(ConfigService, svc_config);
IMPORT_OPTIONAL_SERVICE(ResourceService, svc_resource);
IMPORT_OPTIONAL_SERVICE(OverlayService, svc_overlay);
IMPORT_OPTIONAL_SERVICE(TextureService, svc_texture);
IMPORT_OPTIONAL_SERVICE(UiService, svc_ui);
namespace luau_runtime {
namespace {
std::unordered_map<ModContext*, std::unique_ptr<Vm>> s_vms;
void* limited_realloc(void* userData, void* pointer, size_t oldSize, size_t newSize) {
auto& budget = *static_cast<MemoryBudget*>(userData);
if (newSize == 0) {
std::free(pointer);
budget.used -= std::min(budget.used, oldSize);
return nullptr;
}
const size_t growth = newSize > oldSize ? newSize - oldSize : 0;
if (growth > budget.limit - std::min(budget.used, budget.limit)) {
return nullptr;
}
void* result = std::realloc(pointer, newSize);
if (result != nullptr) {
budget.used -= std::min(budget.used, oldSize);
budget.used += newSize;
}
return result;
}
int traceback_handler(lua_State* state) {
const char* message = lua_tostring(state, 1);
luaL_traceback(state, state, message != nullptr ? message : "Luau error", 1);
return 1;
}
class DeadlineScope {
public:
DeadlineScope(Vm& vm, std::chrono::steady_clock::duration budget)
: m_vm{vm}, m_previousDeadline{vm.deadline}, m_previousActive{vm.deadlineActive} {
const auto requested = std::chrono::steady_clock::now() + budget;
if (!vm.deadlineActive || requested < vm.deadline) {
vm.deadline = requested;
}
vm.deadlineActive = true;
++vm.callDepth;
}
~DeadlineScope() {
--m_vm.callDepth;
m_vm.deadline = m_previousDeadline;
m_vm.deadlineActive = m_previousActive;
}
private:
Vm& m_vm;
std::chrono::steady_clock::time_point m_previousDeadline;
bool m_previousActive;
};
bool protected_call(lua_State* state, Vm& vm, int argumentCount, int resultCount,
std::chrono::steady_clock::duration budget, std::string& outError) {
const int functionIndex = lua_gettop(state) - argumentCount;
lua_pushcfunction(state, traceback_handler, "dusklight traceback");
lua_insert(state, functionIndex);
DeadlineScope deadline{vm, budget};
const int status = lua_pcall(state, argumentCount, resultCount, functionIndex);
if (status != LUA_OK) {
const char* message = lua_tostring(state, -1);
outError = message != nullptr ? message : "unknown Luau error";
lua_pop(state, 1);
lua_remove(state, functionIndex);
return false;
}
lua_remove(state, functionIndex);
return true;
}
std::optional<std::string> normalize_module_path(
std::string_view currentPath, std::string_view requested) {
if ((!requested.starts_with("./") && !requested.starts_with("../")) ||
requested.find('\\') != std::string_view::npos)
{
return std::nullopt;
}
std::vector<std::string_view> parts;
const auto parentEnd = currentPath.rfind('/');
std::string combined;
if (parentEnd != std::string_view::npos) {
combined.assign(currentPath.substr(0, parentEnd + 1));
}
combined.append(requested);
size_t begin = 0;
while (begin <= combined.size()) {
const size_t end = combined.find('/', begin);
const auto part = std::string_view{combined}.substr(
begin, end == std::string::npos ? combined.size() - begin : end - begin);
if (part.empty() || part == ".") {
} else if (part == "..") {
if (parts.empty()) {
return std::nullopt;
}
parts.pop_back();
} else {
parts.push_back(part);
}
if (end == std::string::npos) {
break;
}
begin = end + 1;
}
if (parts.empty()) {
return std::nullopt;
}
std::string normalized;
for (const auto part : parts) {
if (!normalized.empty()) {
normalized.push_back('/');
}
normalized.append(part);
}
if (!normalized.ends_with(".luau")) {
normalized += ".luau";
}
return normalized;
}
ModuleOpenFn module_factory(std::string_view name) {
if (name == "dusklight.log") {
return open_log;
}
if (name == "dusklight.host") {
return open_host;
}
if (name == "dusklight.resource") {
return open_resource;
}
if (name == "dusklight.overlay") {
return open_overlay;
}
if (name == "dusklight.texture") {
return open_texture;
}
if (name == "dusklight.config") {
return open_config;
}
if (name == "dusklight.ui") {
return open_ui;
}
return nullptr;
}
int module_require(lua_State* state);
void install_module_require(lua_State* state, Vm& vm, std::string_view currentPath) {
lua_pushlightuserdata(state, &vm);
lua_pushlstring(state, currentPath.data(), currentPath.size());
lua_pushcclosure(state, module_require, "require", 2);
lua_setglobal(state, "require");
}
bool load_source_module(
lua_State* caller, Vm& vm, const std::string& path, bool keepResult, std::string& outError) {
if (svc_resource == nullptr) {
outError = "ResourceService is not available in this Dusklight build";
return false;
}
ResourceBuffer buffer = RESOURCE_BUFFER_INIT;
const ModResult loadResult = svc_resource->load(vm.subject, path.c_str(), &buffer);
if (loadResult != MOD_OK) {
outError = loadResult == MOD_UNAVAILABLE ? "module not found: res/" + path :
"failed to load module: res/" + path;
return false;
}
std::string source;
if (buffer.data != nullptr) {
source.assign(static_cast<const char*>(buffer.data), buffer.size);
}
svc_resource->free(vm.subject, &buffer);
size_t bytecodeSize = 0;
lua_CompileOptions options{};
options.optimizationLevel = 1;
options.debugLevel = 1;
char* bytecode = luau_compile(source.data(), source.size(), &options, &bytecodeSize);
if (bytecode == nullptr) {
outError = "Luau compiler ran out of memory";
return false;
}
lua_State* moduleState = lua_newthread(caller);
if (moduleState == nullptr) {
std::free(bytecode);
outError = "Luau VM ran out of memory";
return false;
}
luaL_sandboxthread(moduleState);
install_module_require(moduleState, vm, path);
const std::string chunkName = "@res/" + path;
const int loadStatus = luau_load(moduleState, chunkName.c_str(), bytecode, bytecodeSize, 0);
std::free(bytecode);
if (loadStatus != LUA_OK) {
const char* message = lua_tostring(moduleState, -1);
outError = message != nullptr ? message : "failed to load Luau bytecode";
lua_pop(caller, 1);
return false;
}
if (!protected_call(moduleState, vm, 0, keepResult ? 1 : 0, kLifecycleBudget, outError)) {
lua_pop(caller, 1);
return false;
}
if (!keepResult) {
lua_pop(caller, 1);
return true;
}
if (lua_isnil(moduleState, -1)) {
outError = "module res/" + path + " must return a value";
lua_pop(caller, 1);
return false;
}
lua_xmove(moduleState, caller, 1);
lua_remove(caller, -2);
return true;
}
int module_require(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
const std::string requested = luaL_checkstring(state, 1);
std::string moduleName;
ModuleOpenFn factory = nullptr;
if (requested.starts_with("dusklight.")) {
moduleName = requested;
factory = module_factory(moduleName);
if (factory == nullptr) {
luaL_error(state, "unknown module '%s'", moduleName.c_str());
}
} else {
const char* currentPath = lua_tostring(state, lua_upvalueindex(2));
const auto normalized =
normalize_module_path(currentPath != nullptr ? currentPath : "main.luau", requested);
if (!normalized.has_value()) {
luaL_error(
state, "module paths must be relative and remain inside the mod's res directory");
}
moduleName = *normalized;
}
if (const auto found = vm.moduleRefs.find(moduleName); found != vm.moduleRefs.end()) {
lua_getref(state, found->second);
return 1;
}
if (!vm.loadingModules.insert(moduleName).second) {
luaL_error(state, "cyclic require of '%s'", moduleName.c_str());
}
std::string error;
if (factory != nullptr) {
factory(state);
} else if (!load_source_module(state, vm, moduleName, true, error)) {
vm.loadingModules.erase(moduleName);
luaL_error(state, "%s", error.c_str());
}
vm.loadingModules.erase(moduleName);
const int ref = lua_ref(state, -1);
vm.moduleRefs.emplace(moduleName, ref);
return 1;
}
ModResult runtime_activate(ModContext*, ModContext* subject, ModError* outError) {
if (subject == nullptr) {
return set_error(outError, MOD_INVALID_ARGUMENT, "Delegated mod context is null");
}
if (s_vms.contains(subject)) {
return set_error(outError, MOD_CONFLICT, "Delegated mod is already active");
}
auto vm = std::make_unique<Vm>();
vm->subject = subject;
vm->state = lua_newstate(limited_realloc, &vm->memory);
if (vm->state == nullptr) {
return set_error(outError, MOD_ERROR, "Failed to create Luau VM");
}
lua_callbacks(vm->state)->userdata = vm.get();
lua_callbacks(vm->state)->interrupt = [](lua_State* state, int gc) {
auto* current = static_cast<Vm*>(lua_callbacks(state)->userdata);
if (gc < 0 && current != nullptr && current->deadlineActive &&
std::chrono::steady_clock::now() > current->deadline)
{
luaL_error(state, "script execution exceeded its time budget");
}
};
luaL_openlibs(vm->state);
luaL_sandbox(vm->state);
std::string error;
vm->loadingModules.insert("main.luau");
const bool loadedMain = load_source_module(vm->state, *vm, "main.luau", false, error);
vm->loadingModules.erase("main.luau");
if (!loadedMain) {
return set_error(outError, MOD_ERROR, error);
}
s_vms.emplace(subject, std::move(vm));
return MOD_OK;
}
ModResult runtime_update(ModContext*, ModContext* subject, ModError* outError) {
const auto found = s_vms.find(subject);
if (found == s_vms.end()) {
return set_error(outError, MOD_INVALID_ARGUMENT, "Delegated mod is not active");
}
Vm& vm = *found->second;
const size_t callbackCount = vm.updateRefs.size();
if (callbackCount == 0) {
return MOD_OK;
}
DeadlineScope deadline{vm, kUpdateBudget};
std::string error;
for (size_t i = 0; i < callbackCount; ++i) {
const int ref = vm.updateRefs[i];
if (!call_ref(vm, ref, 0, 0, kUpdateBudget, error)) {
return set_error(outError, MOD_ERROR, error);
}
}
return MOD_OK;
}
ModResult runtime_deactivate(ModContext*, ModContext* subject, ModError*) {
const auto found = s_vms.find(subject);
if (found == s_vms.end()) {
return MOD_OK;
}
Vm& vm = *found->second;
DeadlineScope deadline{vm, kLifecycleBudget};
const size_t callbackCount = vm.shutdownRefs.size();
for (size_t i = callbackCount; i > 0; --i) {
std::string error;
const int ref = vm.shutdownRefs[i - 1];
if (!call_ref(vm, ref, 0, 0, kLifecycleBudget, error) && svc_log != nullptr) {
svc_log->write(subject, LOG_LEVEL_ERROR, error.c_str());
}
}
s_vms.erase(found);
return MOD_OK;
}
constexpr ModRuntimeService s_runtimeService{
.header = SERVICE_HEADER(ModRuntimeService, 1, 0),
.activate = runtime_activate,
.update = runtime_update,
.deactivate = runtime_deactivate,
};
EXPORT_SERVICE_AS(s_runtimeService, "dev.twilitrealm.luau");
} // namespace
Vm::~Vm() {
if (state != nullptr) {
lua_close(state);
}
}
Vm& vm_from_upvalue(lua_State* state) {
auto* vm = static_cast<Vm*>(lua_tolightuserdata(state, lua_upvalueindex(1)));
if (vm == nullptr) {
luaL_error(state, "missing Luau runtime context");
}
return *vm;
}
void push_vm_closure(lua_State* state, Vm& vm, lua_CFunction function, const char* name) {
lua_pushlightuserdata(state, &vm);
lua_pushcclosure(state, function, name, 1);
}
void set_function(lua_State* state, Vm& vm, const char* name, lua_CFunction function) {
push_vm_closure(state, vm, function, name);
lua_setfield(state, -2, name);
}
[[noreturn]] void service_unavailable(lua_State* state, const char* name) {
luaL_error(state, "%s is not available in this Dusklight build", name);
}
void check_result(lua_State* state, ModResult result, const char* operation) {
if (result == MOD_OK) {
return;
}
const char* resultName = "error";
switch (result) {
case MOD_UNAVAILABLE:
resultName = "unavailable";
break;
case MOD_UNSUPPORTED:
resultName = "unsupported";
break;
case MOD_CONFLICT:
resultName = "conflict";
break;
case MOD_INVALID_ARGUMENT:
resultName = "invalid argument";
break;
default:
break;
}
luaL_error(state, "%s failed: %s", operation, resultName);
}
bool get_optional_bool(lua_State* state, int table, const char* field, bool fallback) {
lua_getfield(state, table, field);
const bool value = lua_isnil(state, -1) ? fallback : luaL_checkboolean(state, -1) != 0;
lua_pop(state, 1);
return value;
}
bool to_int64(lua_State* state, int index, int64_t& outValue) {
if (lua_isinteger64(state, index)) {
outValue = lua_tointeger64(state, index, nullptr);
return true;
}
if (!lua_isnumber(state, index)) {
return false;
}
const double value = lua_tonumber(state, index);
constexpr double kMaxSafeInteger = 9007199254740991.0;
if (!std::isfinite(value) || value < -kMaxSafeInteger || value > kMaxSafeInteger ||
std::trunc(value) != value)
{
return false;
}
outValue = static_cast<int64_t>(value);
return true;
}
int64_t check_int64(lua_State* state, int index) {
int64_t value = 0;
if (!to_int64(state, index, value)) {
luaL_argerror(state, index, "integer value expected");
}
return value;
}
int64_t get_optional_int(lua_State* state, int table, const char* field, int64_t fallback) {
lua_getfield(state, table, field);
const int64_t value = lua_isnil(state, -1) ? fallback : check_int64(state, -1);
lua_pop(state, 1);
return value;
}
double get_optional_number(lua_State* state, int table, const char* field, double fallback) {
lua_getfield(state, table, field);
const double value = lua_isnil(state, -1) ? fallback : luaL_checknumber(state, -1);
lua_pop(state, 1);
return value;
}
std::string get_optional_string(
lua_State* state, int table, const char* field, std::string fallback) {
lua_getfield(state, table, field);
if (!lua_isnil(state, -1)) {
size_t length = 0;
const char* value = luaL_checklstring(state, -1, &length);
fallback.assign(value, length);
}
lua_pop(state, 1);
return fallback;
}
int ref_optional_function(lua_State* state, int table, const char* field) {
lua_getfield(state, table, field);
if (lua_isnil(state, -1)) {
lua_pop(state, 1);
return LUA_NOREF;
}
luaL_argexpected(state, lua_isfunction(state, -1), table, "function field");
const int ref = lua_ref(state, -1);
lua_pop(state, 1);
return ref;
}
int ref_required_function(lua_State* state, int table, const char* field) {
const int ref = ref_optional_function(state, table, field);
if (ref == LUA_NOREF) {
luaL_error(state, "field '%s' is required", field);
}
return ref;
}
Callback& retain_callback(Vm& vm) {
auto callback = std::make_unique<Callback>();
callback->vm = &vm;
callback->refs.fill(LUA_NOREF);
vm.callbacks.push_back(std::move(callback));
return *vm.callbacks.back();
}
bool call_ref(Vm& vm, int ref, int argumentCount, int resultCount,
std::chrono::steady_clock::duration budget, std::string& outError) {
lua_State* state = vm.state;
lua_getref(state, ref);
if (!lua_isfunction(state, -1)) {
lua_pop(state, 1);
outError = "callback is no longer a function";
return false;
}
if (argumentCount != 0) {
lua_insert(state, -argumentCount - 1);
}
return protected_call(state, vm, argumentCount, resultCount, budget, outError);
}
void fail_callback(Vm& vm, std::string_view callbackName, std::string_view error) {
const std::string message = std::string{callbackName} + ": " + std::string{error};
if (svc_host != nullptr) {
svc_host->fail(vm.subject, MOD_ERROR, message.c_str());
} else if (svc_log != nullptr) {
svc_log->write(vm.subject, LOG_LEVEL_ERROR, message.c_str());
}
}
ModResult set_error(ModError* outError, ModResult result, std::string_view message) {
if (outError != nullptr && outError->struct_size >= sizeof(ModError)) {
outError->code = result;
const size_t size = std::min(message.size(), sizeof(outError->message) - 1);
std::memcpy(outError->message, message.data(), size);
outError->message[size] = '\0';
}
return result;
}
void create_handle_metatable(
lua_State* state, const char* name, const luaL_Reg* methods, const char* typeName) {
luaL_newmetatable(state, name);
luaL_register(state, nullptr, methods);
lua_pushvalue(state, -1);
lua_setfield(state, -2, "__index");
lua_pushstring(state, typeName);
lua_setfield(state, -2, "__type");
lua_setreadonly(state, -1, true);
lua_pop(state, 1);
}
ScriptHandle& check_handle(lua_State* state, int index, const char* metatable, HandleKind kind) {
auto* handle = static_cast<ScriptHandle*>(luaL_checkudata(state, index, metatable));
if (handle == nullptr || handle->kind != kind || handle->vm == nullptr || handle->value == 0) {
luaL_argerror(state, index, "stale handle");
}
return *handle;
}
void push_handle(lua_State* state, Vm& vm, uint64_t value, HandleKind kind, const char* metatable,
ConfigVarType configType) {
auto* handle = static_cast<ScriptHandle*>(lua_newuserdata(state, sizeof(ScriptHandle)));
*handle = ScriptHandle{.vm = &vm, .value = value, .kind = kind, .configType = configType};
luaL_getmetatable(state, metatable);
lua_setmetatable(state, -2);
}
} // namespace luau_runtime
extern "C" {
MOD_EXPORT ModResult mod_initialize(ModError*) {
FFlag::LuauIntegerType2.value = true;
FFlag::LuauIntegerLibrary.value = true;
return MOD_OK;
}
MOD_EXPORT ModResult mod_update(ModError*) {
return MOD_OK;
}
MOD_EXPORT ModResult mod_shutdown(ModError*) {
luau_runtime::s_vms.clear();
return MOD_OK;
}
}
+124
View File
@@ -0,0 +1,124 @@
#pragma once
#include "lua.h"
#include "lualib.h"
#include "mods/api.h"
#include "mods/svc/config.h"
#include "mods/svc/host.h"
#include "mods/svc/log.h"
#include "mods/svc/overlay.h"
#include "mods/svc/resource.h"
#include "mods/svc/texture.h"
#include "mods/svc/ui.h"
#include <array>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <memory>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace luau_runtime {
constexpr size_t kMemoryLimit = 64u * 1024u * 1024u;
constexpr auto kUpdateBudget = std::chrono::milliseconds{250};
constexpr auto kLifecycleBudget = std::chrono::seconds{5};
constexpr auto kCallbackBudget = std::chrono::milliseconds{250};
struct MemoryBudget {
size_t used = 0;
size_t limit = kMemoryLimit;
};
struct Vm;
struct Callback {
Vm* vm = nullptr;
std::array<int, 8> refs{};
std::string returnedString;
int tag = 0;
};
struct Vm {
MemoryBudget memory;
lua_State* state = nullptr;
ModContext* subject = nullptr;
std::vector<int> updateRefs;
std::vector<int> shutdownRefs;
std::unordered_map<std::string, int> moduleRefs;
std::unordered_set<std::string> loadingModules;
std::vector<std::unique_ptr<Callback>> callbacks;
std::chrono::steady_clock::time_point deadline{};
unsigned callDepth = 0;
bool deadlineActive = false;
~Vm();
};
enum class HandleKind : uint8_t {
ConfigVar,
ConfigSubscription,
Overlay,
Texture,
UiWindow,
UiDialog,
UiElement,
UiStyle,
UiMenuTab,
UiList,
};
struct ScriptHandle {
Vm* vm = nullptr;
uint64_t value = 0;
HandleKind kind = HandleKind::ConfigVar;
ConfigVarType configType = CONFIG_VAR_BOOL;
};
using ModuleOpenFn = int (*)(lua_State* state);
Vm& vm_from_upvalue(lua_State* state);
void push_vm_closure(lua_State* state, Vm& vm, lua_CFunction function, const char* name);
void set_function(lua_State* state, Vm& vm, const char* name, lua_CFunction function);
[[noreturn]] void service_unavailable(lua_State* state, const char* name);
void check_result(lua_State* state, ModResult result, const char* operation);
bool get_optional_bool(lua_State* state, int table, const char* field, bool fallback);
bool to_int64(lua_State* state, int index, int64_t& outValue);
int64_t check_int64(lua_State* state, int index);
int64_t get_optional_int(lua_State* state, int table, const char* field, int64_t fallback);
double get_optional_number(lua_State* state, int table, const char* field, double fallback);
std::string get_optional_string(
lua_State* state, int table, const char* field, std::string fallback = {});
int ref_optional_function(lua_State* state, int table, const char* field);
int ref_required_function(lua_State* state, int table, const char* field);
Callback& retain_callback(Vm& vm);
bool call_ref(Vm& vm, int ref, int argumentCount, int resultCount,
std::chrono::steady_clock::duration budget, std::string& outError);
void fail_callback(Vm& vm, std::string_view callbackName, std::string_view error);
ModResult set_error(ModError* outError, ModResult result, std::string_view message);
void create_handle_metatable(
lua_State* state, const char* name, const luaL_Reg* methods, const char* typeName);
ScriptHandle& check_handle(lua_State* state, int index, const char* metatable, HandleKind kind);
void push_handle(lua_State* state, Vm& vm, uint64_t value, HandleKind kind, const char* metatable,
ConfigVarType configType = CONFIG_VAR_BOOL);
void push_config_value(lua_State* state, const ConfigVarValue& value);
void push_ui_handle(lua_State* state, Vm& vm, uint64_t value, HandleKind kind);
int open_log(lua_State* state);
int open_host(lua_State* state);
int open_resource(lua_State* state);
int open_overlay(lua_State* state);
int open_texture(lua_State* state);
int open_config(lua_State* state);
int open_ui(lua_State* state);
} // namespace luau_runtime
+856
View File
@@ -0,0 +1,856 @@
#include "runtime.hpp"
#include <algorithm>
#include <string>
#include <utility>
#include <vector>
namespace luau_runtime {
namespace {
constexpr char kUiWindowMetatable[] = "dusklight.ui_window";
constexpr char kUiDialogMetatable[] = "dusklight.ui_dialog";
constexpr char kUiElementMetatable[] = "dusklight.ui_element";
constexpr char kUiStyleMetatable[] = "dusklight.ui_style";
constexpr char kUiMenuTabMetatable[] = "dusklight.ui_menu_tab";
constexpr char kUiListMetatable[] = "dusklight.ui_list";
const char* ui_metatable(HandleKind kind) {
switch (kind) {
case HandleKind::UiWindow:
return kUiWindowMetatable;
case HandleKind::UiDialog:
return kUiDialogMetatable;
case HandleKind::UiStyle:
return kUiStyleMetatable;
case HandleKind::UiMenuTab:
return kUiMenuTabMetatable;
case HandleKind::UiList:
return kUiListMetatable;
default:
return kUiElementMetatable;
}
}
ScriptHandle& check_ui_handle(lua_State* state, int index, HandleKind kind) {
return check_handle(state, index, ui_metatable(kind), kind);
}
ScriptHandle& check_config_var(lua_State* state, int index) {
luaL_checktype(state, index, LUA_TUSERDATA);
auto* handle = static_cast<ScriptHandle*>(lua_touserdata(state, index));
if (handle == nullptr || handle->kind != HandleKind::ConfigVar || handle->value == 0 ||
handle->vm == nullptr)
{
luaL_argerror(state, index, "expected a live ConfigVar");
}
return *handle;
}
bool call_callback(Callback& callback, int ref, int argumentCount, int resultCount,
const char* name, std::string* outError = nullptr) {
std::string error;
if (call_ref(*callback.vm, ref, argumentCount, resultCount, kCallbackBudget, error)) {
return true;
}
if (outError != nullptr) {
*outError = std::move(error);
} else {
fail_callback(*callback.vm, name, error);
}
return false;
}
ModResult call_build(
Callback& callback, int ref, int argumentCount, const char* name, ModError* outError) {
std::string error;
if (call_callback(callback, ref, argumentCount, 0, name, &error)) {
return MOD_OK;
}
return set_error(outError, MOD_ERROR, error);
}
bool call_predicate(Callback& callback, int ref, int argumentCount, const char* name) {
if (!call_callback(callback, ref, argumentCount, 1, name)) {
return false;
}
lua_State* state = callback.vm->state;
const bool result = lua_toboolean(state, -1) != 0;
lua_pop(state, 1);
return result;
}
void control_get(ModContext*, void* userData, UiControlValue* outValue) {
auto& callback = *static_cast<Callback*>(userData);
if (outValue == nullptr || !call_callback(callback, callback.refs[0], 0, 1, "control get")) {
return;
}
lua_State* state = callback.vm->state;
switch (static_cast<UiControlKind>(callback.tag)) {
case UI_CONTROL_TOGGLE:
outValue->bool_value = lua_toboolean(state, -1) != 0;
break;
case UI_CONTROL_NUMBER:
case UI_CONTROL_SELECT: {
int64_t value = 0;
if (!to_int64(state, -1, value)) {
fail_callback(*callback.vm, "control get", "callback must return an integer");
} else {
outValue->int_value = value;
}
break;
}
case UI_CONTROL_STRING:
case UI_CONTROL_COLOR:
case UI_CONTROL_FILE_PICKER: {
size_t length = 0;
const char* value = lua_tolstring(state, -1, &length);
if (value == nullptr) {
fail_callback(*callback.vm, "control get", "callback must return a string");
} else {
callback.returnedString.assign(value, length);
outValue->string_value = callback.returnedString.c_str();
}
break;
}
default:
break;
}
lua_pop(state, 1);
}
void control_set(ModContext*, void* userData, const UiControlValue* value) {
auto& callback = *static_cast<Callback*>(userData);
if (value == nullptr) {
return;
}
lua_State* state = callback.vm->state;
switch (static_cast<UiControlKind>(callback.tag)) {
case UI_CONTROL_TOGGLE:
lua_pushboolean(state, value->bool_value);
break;
case UI_CONTROL_NUMBER:
case UI_CONTROL_SELECT:
lua_pushinteger64(state, value->int_value);
break;
case UI_CONTROL_STRING:
case UI_CONTROL_COLOR:
case UI_CONTROL_FILE_PICKER:
lua_pushstring(state, value->string_value != nullptr ? value->string_value : "");
break;
default:
return;
}
call_callback(callback, callback.refs[1], 1, 0, "control set");
}
bool control_disabled(ModContext*, void* userData) {
auto& callback = *static_cast<Callback*>(userData);
return call_predicate(callback, callback.refs[3], 0, "control is_disabled");
}
bool control_modified(ModContext*, void* userData) {
auto& callback = *static_cast<Callback*>(userData);
return call_predicate(callback, callback.refs[4], 0, "control is_modified");
}
bool control_selected(ModContext*, void* userData) {
auto& callback = *static_cast<Callback*>(userData);
return call_predicate(callback, callback.refs[5], 0, "control is_selected");
}
void control_pressed(ModContext*, void* userData) {
auto& callback = *static_cast<Callback*>(userData);
call_callback(callback, callback.refs[2], 0, 0, "control on_pressed");
}
ModResult panel_build(ModContext*, UiElementHandle pane, void* userData, ModError* outError) {
auto& callback = *static_cast<Callback*>(userData);
push_ui_handle(callback.vm->state, *callback.vm, pane, HandleKind::UiElement);
return call_build(callback, callback.refs[0], 1, "panel build", outError);
}
ModResult panel_update(ModContext*, void* userData, ModError* outError) {
auto& callback = *static_cast<Callback*>(userData);
return call_build(callback, callback.refs[1], 0, "panel update", outError);
}
ModResult group_build(ModContext*, UiElementHandle pane, void* userData, ModError* outError) {
auto& callback = *static_cast<Callback*>(userData);
push_ui_handle(callback.vm->state, *callback.vm, pane, HandleKind::UiElement);
return call_build(callback, callback.refs[0], 1, "group build", outError);
}
ModResult tab_build(ModContext*, UiWindowHandle window, UiElementHandle left, UiElementHandle right,
void* userData, ModError* outError) {
auto& callback = *static_cast<Callback*>(userData);
push_ui_handle(callback.vm->state, *callback.vm, window, HandleKind::UiWindow);
push_ui_handle(callback.vm->state, *callback.vm, left, HandleKind::UiElement);
push_ui_handle(callback.vm->state, *callback.vm, right, HandleKind::UiElement);
return call_build(callback, callback.refs[0], 3, "window tab build", outError);
}
void window_closed(ModContext*, UiWindowHandle window, void* userData) {
auto& callback = *static_cast<Callback*>(userData);
push_ui_handle(callback.vm->state, *callback.vm, window, HandleKind::UiWindow);
call_callback(callback, callback.refs[0], 1, 0, "window on_closed");
}
void dialog_action(ModContext*, UiDialogHandle dialog, void* userData) {
auto& callback = *static_cast<Callback*>(userData);
push_ui_handle(callback.vm->state, *callback.vm, dialog, HandleKind::UiDialog);
call_callback(callback, callback.refs[0], 1, 0, "dialog action");
}
bool dialog_action_disabled(ModContext*, void* userData) {
auto& callback = *static_cast<Callback*>(userData);
return call_predicate(callback, callback.refs[1], 0, "dialog action is_disabled");
}
void dialog_dismissed(ModContext*, UiDialogHandle dialog, void* userData) {
auto& callback = *static_cast<Callback*>(userData);
push_ui_handle(callback.vm->state, *callback.vm, dialog, HandleKind::UiDialog);
call_callback(callback, callback.refs[0], 1, 0, "dialog on_dismiss");
}
ModResult dialog_build(ModContext*, UiElementHandle pane, void* userData, ModError* outError) {
auto& callback = *static_cast<Callback*>(userData);
push_ui_handle(callback.vm->state, *callback.vm, pane, HandleKind::UiElement);
return call_build(callback, callback.refs[1], 1, "dialog build", outError);
}
void menu_selected(ModContext*, void* userData) {
auto& callback = *static_cast<Callback*>(userData);
call_callback(callback, callback.refs[0], 0, 0, "menu tab on_selected");
}
void list_pressed(ModContext*, UiListHandle list, uint64_t key, void* userData) {
auto& callback = *static_cast<Callback*>(userData);
push_ui_handle(callback.vm->state, *callback.vm, list, HandleKind::UiList);
lua_pushinteger64(callback.vm->state, static_cast<int64_t>(key));
call_callback(callback, callback.refs[0], 2, 0, "list on_pressed");
}
bool list_selected(ModContext*, UiListHandle list, uint64_t key, void* userData) {
auto& callback = *static_cast<Callback*>(userData);
push_ui_handle(callback.vm->state, *callback.vm, list, HandleKind::UiList);
lua_pushinteger64(callback.vm->state, static_cast<int64_t>(key));
return call_predicate(callback, callback.refs[1], 2, "list is_selected");
}
bool list_disabled(ModContext*, UiListHandle list, uint64_t key, void* userData) {
auto& callback = *static_cast<Callback*>(userData);
push_ui_handle(callback.vm->state, *callback.vm, list, HandleKind::UiList);
lua_pushinteger64(callback.vm->state, static_cast<int64_t>(key));
return call_predicate(callback, callback.refs[2], 2, "list is_disabled");
}
std::vector<std::string> string_array(lua_State* state, int table, const char* field) {
std::vector<std::string> result;
lua_getfield(state, table, field);
if (lua_isnil(state, -1)) {
lua_pop(state, 1);
return result;
}
luaL_checktype(state, -1, LUA_TTABLE);
const int count = lua_objlen(state, -1);
result.reserve(count);
for (int i = 1; i <= count; ++i) {
lua_rawgeti(state, -1, i);
result.emplace_back(luaL_checkstring(state, -1));
lua_pop(state, 1);
}
lua_pop(state, 1);
return result;
}
std::vector<UiListItem> list_items(lua_State* state, int table, std::vector<std::string>& labels) {
luaL_checktype(state, table, LUA_TTABLE);
const int count = lua_objlen(state, table);
labels.reserve(count);
std::vector<UiListItem> items;
items.reserve(count);
for (int i = 1; i <= count; ++i) {
lua_rawgeti(state, table, i);
luaL_checktype(state, -1, LUA_TTABLE);
labels.push_back(get_optional_string(state, -1, "label"));
UiListItem item = UI_LIST_ITEM_INIT;
item.key = static_cast<uint64_t>(get_optional_int(state, -1, "key", 0));
items.push_back(item);
lua_pop(state, 1);
}
for (size_t i = 0; i < items.size(); ++i) {
items[i].label = labels[i].c_str();
}
return items;
}
UiControlKind control_kind(lua_State* state, const std::string& kind) {
if (kind == "button")
return UI_CONTROL_BUTTON;
if (kind == "toggle")
return UI_CONTROL_TOGGLE;
if (kind == "number")
return UI_CONTROL_NUMBER;
if (kind == "string")
return UI_CONTROL_STRING;
if (kind == "select")
return UI_CONTROL_SELECT;
if (kind == "color")
return UI_CONTROL_COLOR;
if (kind == "group")
return UI_CONTROL_GROUP;
if (kind == "file_picker")
return UI_CONTROL_FILE_PICKER;
luaL_error(state, "unknown UI control kind '%s'", kind.c_str());
}
UiStyleScope style_scope(lua_State* state, const std::string& scope) {
if (scope == "prelaunch")
return UI_SCOPE_PRELAUNCH;
if (scope == "window")
return UI_SCOPE_WINDOW;
if (scope == "menu_bar")
return UI_SCOPE_MENU_BAR;
if (scope == "overlay")
return UI_SCOPE_OVERLAY;
if (scope == "touch_controls")
return UI_SCOPE_TOUCH_CONTROLS;
if (scope == "graphics_tuner")
return UI_SCOPE_GRAPHICS_TUNER;
luaL_error(state, "unknown UI style scope '%s'", scope.c_str());
}
int pane_add_section(lua_State* state) {
auto& pane = check_ui_handle(state, 1, HandleKind::UiElement);
check_result(state,
svc_ui->pane_add_section(pane.vm->subject, pane.value, luaL_checkstring(state, 2)),
"ui pane_add_section");
return 0;
}
int pane_add_text(lua_State* state) {
auto& pane = check_ui_handle(state, 1, HandleKind::UiElement);
UiElementHandle element = 0;
check_result(state,
svc_ui->pane_add_text(pane.vm->subject, pane.value, luaL_checkstring(state, 2), &element),
"ui pane_add_text");
push_ui_handle(state, *pane.vm, element, HandleKind::UiElement);
return 1;
}
int pane_add_rml(lua_State* state) {
auto& pane = check_ui_handle(state, 1, HandleKind::UiElement);
UiElementHandle element = 0;
check_result(state,
svc_ui->pane_add_rml(pane.vm->subject, pane.value, luaL_checkstring(state, 2), &element),
"ui pane_add_rml");
push_ui_handle(state, *pane.vm, element, HandleKind::UiElement);
return 1;
}
int pane_add_progress(lua_State* state) {
auto& pane = check_ui_handle(state, 1, HandleKind::UiElement);
UiElementHandle element = 0;
check_result(state,
svc_ui->pane_add_progress(
pane.vm->subject, pane.value, static_cast<float>(luaL_checknumber(state, 2)), &element),
"ui pane_add_progress");
push_ui_handle(state, *pane.vm, element, HandleKind::UiElement);
return 1;
}
int pane_add_control(lua_State* state) {
auto& pane = check_ui_handle(state, 1, HandleKind::UiElement);
luaL_checktype(state, 2, LUA_TTABLE);
UiControlDesc desc = UI_CONTROL_DESC_INIT;
const std::string kind = get_optional_string(state, 2, "kind");
const std::string label = get_optional_string(state, 2, "label");
const std::string help = get_optional_string(state, 2, "help_rml");
const std::string prefix = get_optional_string(state, 2, "prefix");
const std::string suffix = get_optional_string(state, 2, "suffix");
desc.kind = control_kind(state, kind);
desc.label = label.c_str();
desc.help_rml = help.empty() ? nullptr : help.c_str();
desc.min = get_optional_int(state, 2, "min", 0);
desc.max = get_optional_int(state, 2, "max", 0);
desc.step = get_optional_int(state, 2, "step", 1);
desc.prefix = prefix.empty() ? nullptr : prefix.c_str();
desc.suffix = suffix.empty() ? nullptr : suffix.c_str();
desc.max_length = static_cast<int32_t>(get_optional_int(state, 2, "max_length", 0));
desc.color_alpha = get_optional_bool(state, 2, "color_alpha", false);
desc.directory_mode = get_optional_bool(state, 2, "directory_mode", false);
desc.string_set_mode = get_optional_string(state, 2, "string_set_mode") == "change" ?
UI_STRING_SET_ON_CHANGE :
UI_STRING_SET_ON_COMMIT;
std::vector<std::string> options = string_array(state, 2, "options");
std::vector<const char*> optionPointers;
optionPointers.reserve(options.size());
for (const auto& option : options)
optionPointers.push_back(option.c_str());
desc.options = optionPointers.data();
desc.option_count = optionPointers.size();
std::vector<std::string> presets = string_array(state, 2, "color_presets");
std::vector<const char*> presetPointers;
presetPointers.reserve(presets.size());
for (const auto& preset : presets)
presetPointers.push_back(preset.c_str());
desc.color_presets = presetPointers.data();
desc.color_preset_count = presetPointers.size();
std::vector<std::string> filterNames;
std::vector<std::string> filterPatterns;
std::vector<FileFilter> filters;
lua_getfield(state, 2, "file_filters");
if (!lua_isnil(state, -1)) {
luaL_checktype(state, -1, LUA_TTABLE);
const int count = lua_objlen(state, -1);
filterNames.reserve(count);
filterPatterns.reserve(count);
filters.resize(count);
for (int i = 1; i <= count; ++i) {
lua_rawgeti(state, -1, i);
filterNames.push_back(get_optional_string(state, -1, "name"));
filterPatterns.push_back(get_optional_string(state, -1, "pattern"));
lua_pop(state, 1);
}
for (int i = 0; i < count; ++i) {
filters[i] = {filterNames[i].c_str(), filterPatterns[i].c_str()};
}
}
lua_pop(state, 1);
desc.file_filters = filters.data();
desc.file_filter_count = filters.size();
Callback& callback = retain_callback(*pane.vm);
callback.tag = desc.kind;
callback.refs[2] = ref_optional_function(state, 2, "on_pressed");
callback.refs[3] = ref_optional_function(state, 2, "is_disabled");
callback.refs[4] = ref_optional_function(state, 2, "is_modified");
callback.refs[5] = ref_optional_function(state, 2, "is_selected");
desc.user_data = &callback;
desc.on_pressed = callback.refs[2] != LUA_NOREF ? control_pressed : nullptr;
desc.is_disabled = callback.refs[3] != LUA_NOREF ? control_disabled : nullptr;
desc.is_modified = callback.refs[4] != LUA_NOREF ? control_modified : nullptr;
desc.is_selected = callback.refs[5] != LUA_NOREF ? control_selected : nullptr;
lua_getfield(state, 2, "config_var");
if (!lua_isnil(state, -1)) {
auto& variable = check_config_var(state, -1);
desc.binding = UI_BINDING_CONFIG_VAR;
desc.config_var = variable.value;
} else if (desc.kind != UI_CONTROL_BUTTON && desc.kind != UI_CONTROL_GROUP) {
desc.binding = UI_BINDING_CALLBACKS;
callback.refs[0] = ref_required_function(state, 2, "get");
callback.refs[1] = ref_required_function(state, 2, "set");
desc.get = control_get;
desc.set = control_set;
}
lua_pop(state, 1);
UiElementHandle element = 0;
check_result(state, svc_ui->pane_add_control(pane.vm->subject, pane.value, &desc, &element),
"ui pane_add_control");
push_ui_handle(state, *pane.vm, element, HandleKind::UiElement);
return 1;
}
int pane_add_group(lua_State* state) {
auto& pane = check_ui_handle(state, 1, HandleKind::UiElement);
auto& target = check_ui_handle(state, 2, HandleKind::UiElement);
luaL_checktype(state, 3, LUA_TTABLE);
const std::string label = get_optional_string(state, 3, "label");
Callback& callback = retain_callback(*pane.vm);
callback.refs[0] = ref_required_function(state, 3, "build");
UiGroupDesc desc = UI_GROUP_DESC_INIT;
desc.label = label.c_str();
desc.build = group_build;
desc.user_data = &callback;
UiElementHandle element = 0;
check_result(state,
svc_ui->pane_add_group(pane.vm->subject, pane.value, target.value, &desc, &element),
"ui pane_add_group");
push_ui_handle(state, *pane.vm, element, HandleKind::UiElement);
return 1;
}
int pane_add_list(lua_State* state) {
auto& pane = check_ui_handle(state, 1, HandleKind::UiElement);
luaL_checktype(state, 2, LUA_TTABLE);
Callback& callback = retain_callback(*pane.vm);
callback.refs[0] = ref_required_function(state, 2, "on_pressed");
callback.refs[1] = ref_optional_function(state, 2, "is_selected");
callback.refs[2] = ref_optional_function(state, 2, "is_disabled");
std::vector<std::string> labels;
std::vector<UiListItem> items;
lua_getfield(state, 2, "items");
if (!lua_isnil(state, -1))
items = list_items(state, -1, labels);
lua_pop(state, 1);
UiListDesc desc = UI_LIST_DESC_INIT;
desc.items = items.data();
desc.item_count = items.size();
desc.on_pressed = list_pressed;
desc.is_selected = callback.refs[1] != LUA_NOREF ? list_selected : nullptr;
desc.is_disabled = callback.refs[2] != LUA_NOREF ? list_disabled : nullptr;
desc.user_data = &callback;
UiListHandle list = 0;
check_result(state, svc_ui->pane_add_list(pane.vm->subject, pane.value, &desc, &list),
"ui pane_add_list");
push_ui_handle(state, *pane.vm, list, HandleKind::UiList);
return 1;
}
int element_set_text(lua_State* state) {
auto& element = check_ui_handle(state, 1, HandleKind::UiElement);
check_result(state,
svc_ui->elem_set_text(element.vm->subject, element.value, luaL_checkstring(state, 2)),
"ui elem_set_text");
return 0;
}
int element_set_rml(lua_State* state) {
auto& element = check_ui_handle(state, 1, HandleKind::UiElement);
check_result(state,
svc_ui->elem_set_rml(element.vm->subject, element.value, luaL_checkstring(state, 2)),
"ui elem_set_rml");
return 0;
}
int element_set_progress(lua_State* state) {
auto& element = check_ui_handle(state, 1, HandleKind::UiElement);
check_result(state,
svc_ui->elem_set_progress(
element.vm->subject, element.value, static_cast<float>(luaL_checknumber(state, 2))),
"ui elem_set_progress");
return 0;
}
int element_set_class(lua_State* state) {
auto& element = check_ui_handle(state, 1, HandleKind::UiElement);
check_result(state,
svc_ui->elem_set_class(element.vm->subject, element.value, luaL_checkstring(state, 2),
luaL_checkboolean(state, 3) != 0),
"ui elem_set_class");
return 0;
}
int list_set_items(lua_State* state) {
auto& list = check_ui_handle(state, 1, HandleKind::UiList);
std::vector<std::string> labels;
auto items = list_items(state, 2, labels);
check_result(state,
svc_ui->list_set_items(list.vm->subject, list.value, items.data(), items.size()),
"ui list_set_items");
return 0;
}
int window_close(lua_State* state) {
auto& window = check_ui_handle(state, 1, HandleKind::UiWindow);
check_result(state, svc_ui->window_close(window.vm->subject, window.value), "ui window_close");
window.value = 0;
return 0;
}
int dialog_close(lua_State* state) {
auto& dialog = check_ui_handle(state, 1, HandleKind::UiDialog);
check_result(state, svc_ui->dialog_close(dialog.vm->subject, dialog.value), "ui dialog_close");
dialog.value = 0;
return 0;
}
int dialog_set_body(lua_State* state) {
auto& dialog = check_ui_handle(state, 1, HandleKind::UiDialog);
check_result(state,
svc_ui->dialog_set_body(dialog.vm->subject, dialog.value, luaL_checkstring(state, 2)),
"ui dialog_set_body");
return 0;
}
int dialog_set_icon(lua_State* state) {
auto& dialog = check_ui_handle(state, 1, HandleKind::UiDialog);
check_result(state,
svc_ui->dialog_set_icon(dialog.vm->subject, dialog.value, luaL_checkstring(state, 2)),
"ui dialog_set_icon");
return 0;
}
int style_unregister(lua_State* state) {
auto& style = check_ui_handle(state, 1, HandleKind::UiStyle);
check_result(
state, svc_ui->unregister_styles(style.vm->subject, style.value), "ui unregister_styles");
style.value = 0;
return 0;
}
int menu_tab_unregister(lua_State* state) {
auto& tab = check_ui_handle(state, 1, HandleKind::UiMenuTab);
check_result(
state, svc_ui->unregister_menu_tab(tab.vm->subject, tab.value), "ui unregister_menu_tab");
tab.value = 0;
return 0;
}
int register_mods_panel(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
luaL_checktype(state, 1, LUA_TTABLE);
Callback& callback = retain_callback(vm);
callback.refs[0] = ref_required_function(state, 1, "build");
callback.refs[1] = ref_optional_function(state, 1, "update");
UiModsPanelDesc desc = UI_MODS_PANEL_DESC_INIT;
desc.build = panel_build;
desc.update = callback.refs[1] != LUA_NOREF ? panel_update : nullptr;
desc.user_data = &callback;
check_result(state, svc_ui->register_mods_panel(vm.subject, &desc), "ui register_mods_panel");
return 0;
}
int window_push(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
luaL_checktype(state, 1, LUA_TTABLE);
std::vector<UiTabDesc> tabs;
std::vector<std::string> titles;
lua_getfield(state, 1, "tabs");
luaL_checktype(state, -1, LUA_TTABLE);
const int tabCount = lua_objlen(state, -1);
tabs.reserve(tabCount);
titles.reserve(tabCount);
for (int i = 1; i <= tabCount; ++i) {
lua_rawgeti(state, -1, i);
titles.push_back(get_optional_string(state, -1, "title"));
Callback& callback = retain_callback(vm);
callback.refs[0] = ref_required_function(state, -1, "build");
callback.refs[1] = ref_optional_function(state, -1, "update");
UiTabDesc tab = UI_TAB_DESC_INIT;
tab.build = tab_build;
tab.update = callback.refs[1] != LUA_NOREF ? panel_update : nullptr;
tab.user_data = &callback;
tabs.push_back(tab);
lua_pop(state, 1);
}
lua_pop(state, 1);
for (size_t i = 0; i < tabs.size(); ++i)
tabs[i].title = titles[i].c_str();
const std::string rcss = get_optional_string(state, 1, "rcss");
Callback& closed = retain_callback(vm);
closed.refs[0] = ref_optional_function(state, 1, "on_closed");
UiWindowDesc desc = UI_WINDOW_DESC_INIT;
desc.tabs = tabs.data();
desc.tab_count = tabs.size();
desc.rcss = rcss.empty() ? nullptr : rcss.c_str();
desc.on_closed = closed.refs[0] != LUA_NOREF ? window_closed : nullptr;
desc.user_data = &closed;
UiWindowHandle window = 0;
check_result(state, svc_ui->window_push(vm.subject, &desc, &window), "ui window_push");
push_ui_handle(state, vm, window, HandleKind::UiWindow);
return 1;
}
UiDialogVariant dialog_variant(lua_State* state, const std::string& variant) {
if (variant.empty() || variant == "normal")
return UI_DIALOG_NORMAL;
if (variant == "warning")
return UI_DIALOG_WARNING;
if (variant == "danger")
return UI_DIALOG_DANGER;
luaL_error(state, "unknown dialog variant '%s'", variant.c_str());
}
int dialog_push(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
luaL_checktype(state, 1, LUA_TTABLE);
const std::string title = get_optional_string(state, 1, "title");
const std::string body = get_optional_string(state, 1, "body_rml");
const std::string icon = get_optional_string(state, 1, "icon");
const std::string variant = get_optional_string(state, 1, "variant");
std::vector<UiDialogAction> actions;
std::vector<std::string> labels;
lua_getfield(state, 1, "actions");
luaL_checktype(state, -1, LUA_TTABLE);
const int actionCount = lua_objlen(state, -1);
actions.reserve(actionCount);
labels.reserve(actionCount);
for (int i = 1; i <= actionCount; ++i) {
lua_rawgeti(state, -1, i);
labels.push_back(get_optional_string(state, -1, "label"));
Callback& callback = retain_callback(vm);
callback.refs[0] = ref_optional_function(state, -1, "on_pressed");
callback.refs[1] = ref_optional_function(state, -1, "is_disabled");
UiDialogAction action = UI_DIALOG_ACTION_INIT;
action.on_pressed = callback.refs[0] != LUA_NOREF ? dialog_action : nullptr;
action.is_disabled = callback.refs[1] != LUA_NOREF ? dialog_action_disabled : nullptr;
action.user_data = &callback;
action.keep_open = get_optional_bool(state, -1, "keep_open", false);
actions.push_back(action);
lua_pop(state, 1);
}
lua_pop(state, 1);
for (size_t i = 0; i < actions.size(); ++i)
actions[i].label = labels[i].c_str();
Callback& callback = retain_callback(vm);
callback.refs[0] = ref_optional_function(state, 1, "on_dismiss");
callback.refs[1] = ref_optional_function(state, 1, "build");
UiDialogDesc desc = UI_DIALOG_DESC_INIT;
desc.title = title.c_str();
desc.body_rml = body.c_str();
desc.icon = icon.empty() ? nullptr : icon.c_str();
desc.variant = dialog_variant(state, variant);
desc.actions = actions.data();
desc.action_count = actions.size();
desc.on_dismiss = callback.refs[0] != LUA_NOREF ? dialog_dismissed : nullptr;
desc.build = callback.refs[1] != LUA_NOREF ? dialog_build : nullptr;
desc.user_data = &callback;
UiDialogHandle dialog = 0;
check_result(state, svc_ui->dialog_push(vm.subject, &desc, &dialog), "ui dialog_push");
push_ui_handle(state, vm, dialog, HandleKind::UiDialog);
return 1;
}
int is_any_document_visible(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
bool visible = false;
check_result(
state, svc_ui->is_any_document_visible(vm.subject, &visible), "ui is_any_document_visible");
lua_pushboolean(state, visible);
return 1;
}
int register_styles(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
const auto scope = style_scope(state, luaL_checkstring(state, 1));
UiStyleHandle style = 0;
check_result(state,
svc_ui->register_styles(vm.subject, scope, luaL_checkstring(state, 2), &style),
"ui register_styles");
push_ui_handle(state, vm, style, HandleKind::UiStyle);
return 1;
}
int register_styles_file(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
const auto scope = style_scope(state, luaL_checkstring(state, 1));
UiStyleHandle style = 0;
check_result(state,
svc_ui->register_styles_file(vm.subject, scope, luaL_checkstring(state, 2), &style),
"ui register_styles_file");
push_ui_handle(state, vm, style, HandleKind::UiStyle);
return 1;
}
int register_menu_tab(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
luaL_checktype(state, 1, LUA_TTABLE);
const std::string label = get_optional_string(state, 1, "label");
Callback& callback = retain_callback(vm);
callback.refs[0] = ref_required_function(state, 1, "on_selected");
UiMenuTabDesc desc = UI_MENU_TAB_DESC_INIT;
desc.label = label.c_str();
desc.on_selected = menu_selected;
desc.user_data = &callback;
UiMenuTabHandle tab = 0;
check_result(state, svc_ui->register_menu_tab(vm.subject, &desc, &tab), "ui register_menu_tab");
push_ui_handle(state, vm, tab, HandleKind::UiMenuTab);
return 1;
}
int push_toast(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
luaL_checktype(state, 1, LUA_TTABLE);
const std::string type = get_optional_string(state, 1, "type");
const std::string title = get_optional_string(state, 1, "title_rml");
const std::string body = get_optional_string(state, 1, "body_rml");
UiToastDesc desc = UI_TOAST_DESC_INIT;
desc.type = type.empty() ? nullptr : type.c_str();
desc.title_rml = title.empty() ? nullptr : title.c_str();
desc.body_rml = body.empty() ? nullptr : body.c_str();
desc.duration_ms = static_cast<uint32_t>(get_optional_int(state, 1, "duration_ms", 0));
check_result(state, svc_ui->push_toast(vm.subject, &desc), "ui push_toast");
return 0;
}
int get_clipboard_text(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
size_t size = 0;
check_result(
state, svc_ui->get_clipboard_text(vm.subject, nullptr, 0, &size), "ui get_clipboard_text");
std::vector<char> text(size + 1);
check_result(state, svc_ui->get_clipboard_text(vm.subject, text.data(), text.size(), nullptr),
"ui get_clipboard_text");
lua_pushlstring(state, text.data(), size);
return 1;
}
int set_clipboard_text(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
check_result(state, svc_ui->set_clipboard_text(vm.subject, luaL_checkstring(state, 1)),
"ui set_clipboard_text");
return 0;
}
} // namespace
void push_ui_handle(lua_State* state, Vm& vm, uint64_t value, HandleKind kind) {
push_handle(state, vm, value, kind, ui_metatable(kind));
}
int open_ui(lua_State* state) {
Vm& vm = vm_from_upvalue(state);
if (svc_ui == nullptr) {
service_unavailable(state, "UiService");
}
static const luaL_Reg kElementMethods[] = {
{"add_section", pane_add_section},
{"add_text", pane_add_text},
{"add_rml", pane_add_rml},
{"add_progress", pane_add_progress},
{"add_control", pane_add_control},
{"add_group", pane_add_group},
{"add_list", pane_add_list},
{"set_text", element_set_text},
{"set_rml", element_set_rml},
{"set_progress", element_set_progress},
{"set_class", element_set_class},
{nullptr, nullptr},
};
static const luaL_Reg kWindowMethods[] = {{"close", window_close}, {nullptr, nullptr}};
static const luaL_Reg kDialogMethods[] = {
{"close", dialog_close},
{"set_body", dialog_set_body},
{"set_icon", dialog_set_icon},
{nullptr, nullptr},
};
static const luaL_Reg kStyleMethods[] = {{"unregister", style_unregister}, {nullptr, nullptr}};
static const luaL_Reg kMenuMethods[] = {
{"unregister", menu_tab_unregister}, {nullptr, nullptr}};
static const luaL_Reg kListMethods[] = {{"set_items", list_set_items}, {nullptr, nullptr}};
create_handle_metatable(state, kUiElementMetatable, kElementMethods, "UiElement");
create_handle_metatable(state, kUiWindowMetatable, kWindowMethods, "UiWindow");
create_handle_metatable(state, kUiDialogMetatable, kDialogMethods, "UiDialog");
create_handle_metatable(state, kUiStyleMetatable, kStyleMethods, "UiStyle");
create_handle_metatable(state, kUiMenuTabMetatable, kMenuMethods, "UiMenuTab");
create_handle_metatable(state, kUiListMetatable, kListMethods, "UiList");
lua_newtable(state);
set_function(state, vm, "register_mods_panel", register_mods_panel);
set_function(state, vm, "window_push", window_push);
set_function(state, vm, "dialog_push", dialog_push);
set_function(state, vm, "is_any_document_visible", is_any_document_visible);
set_function(state, vm, "register_styles", register_styles);
set_function(state, vm, "register_styles_file", register_styles_file);
set_function(state, vm, "register_menu_tab", register_menu_tab);
set_function(state, vm, "push_toast", push_toast);
set_function(state, vm, "get_clipboard_text", get_clipboard_text);
set_function(state, vm, "set_clipboard_text", set_clipboard_text);
lua_setreadonly(state, -1, true);
return 1;
}
} // namespace luau_runtime
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <mods/api.h>
/*
* Lifecycle service for mods delegated to a runtime named by mod.json. The host passes the
* runtime's context as ctx and the delegated mod's context as subject.
*/
typedef struct ModRuntimeService {
ServiceHeader header;
ModResult (*activate)(ModContext* ctx, ModContext* subject, ModError* out_error);
ModResult (*update)(ModContext* ctx, ModContext* subject, ModError* out_error);
ModResult (*deactivate)(ModContext* ctx, ModContext* subject, ModError* out_error);
} ModRuntimeService;
+3 -3
View File
@@ -19,9 +19,9 @@
* All calls must be made on the game thread from mod callbacks (initialize, update, hooks, or UI
* callbacks). Handles are opaque, generation-checked ids; a stale or unknown handle fails with
* MOD_INVALID_ARGUMENT. Element handles die with the content that owns them: a panel or tab rebuild
* destroys the previous build's elements, so re-acquire handles inside the build callback rather
* than caching them. Strings are UTF-8 and, in both directions, only valid for the duration of the
* call.
* destroys the previous build's elements, so re-acquire handles in each build callback and use them
* only until the next rebuild. Strings are UTF-8 and, in both directions, only valid for the
* duration of the call.
*/
/* 0 is never a valid handle. */
+160
View File
@@ -0,0 +1,160 @@
--!strict
export type IntegerInput = integer | number
export type ConfigValue = boolean | integer | number | string
export type ConfigVar<T> = {
get: (self: ConfigVar<T>) -> T,
set: (self: ConfigVar<T>, value: T) -> (),
subscribe: (self: ConfigVar<T>, callback: (value: T, previous: T) -> ()) -> ConfigSubscription,
unregister: (self: ConfigVar<T>) -> (),
}
export type ConfigSubscription = {
unsubscribe: (self: ConfigSubscription) -> (),
}
export type ConfigDesc = {
name: string,
type: "bool" | "int" | "float" | "string",
default: ConfigValue?,
}
export type ConfigModule = {
register: (desc: ConfigDesc) -> ConfigVar<any>,
subscribe: <T>(variable: ConfigVar<T>, callback: (value: T, previous: T) -> ()) -> ConfigSubscription,
}
export type OverlayHandle = { remove: (self: OverlayHandle) -> () }
export type OverlayModule = {
add_file: (discPath: string, bundlePath: string) -> OverlayHandle,
add_buffer: (discPath: string, data: string) -> OverlayHandle,
}
export type TextureHandle = { unregister: (self: TextureHandle) -> () }
export type TextureKey = {
texture_hash: integer,
tlut_hash: integer?,
width: IntegerInput,
height: IntegerInput,
gx_format: IntegerInput,
has_tlut: boolean?,
}
export type TextureData = {
data: string,
width: IntegerInput?,
height: IntegerInput?,
mip_count: IntegerInput?,
gx_format: IntegerInput?,
}
export type TextureModule = {
hash_wildcard: integer,
tlut_wildcard: integer,
register_file: (bundlePath: string) -> TextureHandle,
register_data: (key: TextureKey, data: TextureData) -> TextureHandle,
}
export type UiElement = {
add_section: (self: UiElement, title: string) -> (),
add_text: (self: UiElement, text: string) -> UiElement,
add_rml: (self: UiElement, rml: string) -> UiElement,
add_progress: (self: UiElement, value: number) -> UiElement,
add_control: (self: UiElement, desc: UiControlDesc) -> UiElement,
add_group: (self: UiElement, target: UiElement, desc: UiGroupDesc) -> UiElement,
add_list: (self: UiElement, desc: UiListDesc) -> UiList,
set_text: (self: UiElement, text: string) -> (),
set_rml: (self: UiElement, rml: string) -> (),
set_progress: (self: UiElement, value: number) -> (),
set_class: (self: UiElement, name: string, active: boolean) -> (),
}
export type UiControlDesc = {
kind: "button" | "toggle" | "number" | "string" | "select" | "color" | "group" | "file_picker",
label: string,
help_rml: string?,
config_var: ConfigVar<any>?,
get: (() -> ConfigValue)?,
set: ((value: ConfigValue) -> ())?,
on_pressed: (() -> ())?,
is_disabled: (() -> boolean)?,
is_modified: (() -> boolean)?,
is_selected: (() -> boolean)?,
min: IntegerInput?,
max: IntegerInput?,
step: IntegerInput?,
prefix: string?,
suffix: string?,
options: {string}?,
max_length: IntegerInput?,
color_presets: {string}?,
color_alpha: boolean?,
string_set_mode: ("commit" | "change")?,
file_filters: {{name: string, pattern: string}}?,
directory_mode: boolean?,
}
export type UiGroupDesc = { label: string, build: (pane: UiElement) -> () }
export type UiListItem = { key: IntegerInput, label: string }
export type UiListDesc = {
items: {UiListItem}?,
on_pressed: (list: UiList, key: integer) -> (),
is_selected: ((list: UiList, key: integer) -> boolean)?,
is_disabled: ((list: UiList, key: integer) -> boolean)?,
}
export type UiList = { set_items: (self: UiList, items: {UiListItem}) -> () }
export type UiWindow = { close: (self: UiWindow) -> () }
export type UiDialog = {
close: (self: UiDialog) -> (),
set_body: (self: UiDialog, rml: string) -> (),
set_icon: (self: UiDialog, icon: string) -> (),
}
export type UiStyle = { unregister: (self: UiStyle) -> () }
export type UiMenuTab = { unregister: (self: UiMenuTab) -> () }
export type UiTabDesc = {
title: string,
build: (window: UiWindow, left: UiElement, right: UiElement) -> (),
update: (() -> ())?,
}
export type UiModule = {
register_mods_panel: (desc: {build: (panel: UiElement) -> (), update: (() -> ())?}) -> (),
window_push: (desc: {tabs: {UiTabDesc}, rcss: string?, on_closed: ((window: UiWindow) -> ())?}) -> UiWindow,
dialog_push: (desc: {
title: string,
body_rml: string,
variant: ("normal" | "warning" | "danger")?,
icon: string?,
actions: {{label: string, on_pressed: ((dialog: UiDialog) -> ())?, keep_open: boolean?, is_disabled: (() -> boolean)?}},
on_dismiss: ((dialog: UiDialog) -> ())?,
build: ((pane: UiElement) -> ())?,
}) -> UiDialog,
is_any_document_visible: () -> boolean,
register_styles: (scope: string, rcss: string) -> UiStyle,
register_styles_file: (scope: string, path: string) -> UiStyle,
register_menu_tab: (desc: {label: string, on_selected: () -> ()}) -> UiMenuTab,
push_toast: (desc: {type: string?, title_rml: string?, body_rml: string?, duration_ms: IntegerInput?}) -> (),
get_clipboard_text: () -> string,
set_clipboard_text: (text: string) -> (),
}
export type LogModule = {
write: (level: "trace" | "debug" | "info" | "warn" | "error", message: string) -> (),
trace: (message: string) -> (),
debug: (message: string) -> (),
info: (message: string) -> (),
warn: (message: string) -> (),
error: (message: string) -> (),
}
export type HostModule = {
version: string,
mod_id: () -> string,
mod_name: () -> string,
mod_version: () -> string,
mod_dir: () -> string,
data_dir: () -> string,
on_update: (callback: () -> ()) -> (),
on_shutdown: (callback: () -> ()) -> (),
fail: (message: string) -> never,
}
export type ResourceModule = { load: (relativePath: string) -> string }
declare function require(path: "dusklight.log"): LogModule
declare function require(path: "dusklight.host"): HostModule
declare function require(path: "dusklight.config"): ConfigModule
declare function require(path: "dusklight.resource"): ResourceModule
declare function require(path: "dusklight.overlay"): OverlayModule
declare function require(path: "dusklight.texture"): TextureModule
declare function require(path: "dusklight.ui"): UiModule
declare function require(path: string): any
+8
View File
@@ -0,0 +1,8 @@
{
"id": "com.example.my_script_mod",
"name": "My Script Mod",
"version": "1.0.0",
"author": "Your Name",
"description": "A Luau script mod",
"runtime": "dev.twilitrealm.luau@1.0"
}
+8
View File
@@ -0,0 +1,8 @@
local log = require("dusklight.log")
local host = require("dusklight.host")
log.info("My Script Mod initialized")
host.on_shutdown(function()
log.info("My Script Mod shutting down")
end)
+18 -1
View File
@@ -2,6 +2,7 @@
#include <filesystem>
#include <memory>
#include <optional>
#include <ranges>
#include <string>
#include <string_view>
@@ -10,6 +11,7 @@
#include "dusk/config.hpp"
#include "dusk/config_var.hpp"
#include "mods/api.h"
#include "mods/runtime.h"
namespace dusk::mods {
struct LoadedMod;
@@ -35,6 +37,7 @@ struct ModManifestInfo {
struct Import {
std::string id;
uint16_t major = 0;
uint16_t minMinor = 0;
bool required = false;
bool operator==(const Import&) const = default;
};
@@ -48,6 +51,19 @@ struct ModManifestInfo {
bool operator==(const ModManifestInfo&) const = default;
};
struct DelegatedModRuntime {
std::string id;
uint16_t major = 0;
uint16_t minMinor = 0;
const ModRuntimeService* service = nullptr;
ModContext* providerContext = nullptr;
bool operator==(const DelegatedModRuntime& other) const {
return id == other.id && major == other.major && minMinor == other.minMinor;
}
};
struct ModMetadata {
std::string id;
std::string name;
@@ -180,7 +196,7 @@ struct LoadedMod {
bool loadFailed = false;
std::string failureReason;
// mod_initialize succeeded; a mod_shutdown is owed on deactivation.
// Initialization succeeded; shutdown is owed on deactivation.
bool initialized = false;
// Static service exports are currently present in the registry.
bool servicesRegistered = false;
@@ -202,6 +218,7 @@ struct LoadedMod {
NativeModStatus nativeStatus = NativeModStatus::None;
std::unique_ptr<NativeMod> native;
std::optional<DelegatedModRuntime> runtime;
std::unique_ptr<ModContext> context;
// Shared with overlay file registrations so in-flight DVD reads survive disable/reload.
+176 -44
View File
@@ -5,6 +5,7 @@
#include <borealis/io.hpp>
#include <algorithm>
#include <charconv>
#include <chrono>
#include <cstring>
#include <filesystem>
@@ -269,7 +270,57 @@ static std::string resolve_image_path(ModBundle& bundle, const std::string& modI
return {};
}
static ModMetadata load_metadata(const std::filesystem::path& modPath, ModBundle& bundle) {
struct LoadedManifest {
ModMetadata metadata;
std::optional<DelegatedModRuntime> runtime;
};
static uint16_t parse_runtime_version_component(std::string_view text, std::string_view fieldName) {
uint32_t value = 0;
const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value);
if (text.empty() || error != std::errc{} || end != text.data() + text.size() ||
value > UINT16_MAX)
{
throw InvalidModDataException(fmt::format("Invalid {} in runtime version pin", fieldName));
}
return static_cast<uint16_t>(value);
}
static std::optional<DelegatedModRuntime> parse_runtime(const nlohmann::json& manifest) {
const auto field = manifest.find("runtime");
if (field == manifest.end()) {
return std::nullopt;
}
if (!field->is_string()) {
throw InvalidModDataException("runtime must be a string");
}
const std::string pin = field->get<std::string>();
const auto at = pin.rfind('@');
if (at == std::string::npos || at == 0 || at + 1 == pin.size() || pin.find('@') != at ||
at >= MOD_META_SERVICE_ID_SIZE)
{
throw InvalidModDataException(
"runtime must be a service id followed by @major or @major.minor");
}
const std::string_view version{pin.data() + at + 1, pin.size() - at - 1};
const auto dot = version.find('.');
if (dot != std::string_view::npos && version.find('.', dot + 1) != std::string_view::npos) {
throw InvalidModDataException("runtime version pin has too many components");
}
DelegatedModRuntime result;
result.id = pin.substr(0, at);
result.major = parse_runtime_version_component(
dot == std::string_view::npos ? version : version.substr(0, dot), "major version");
if (dot != std::string_view::npos) {
result.minMinor = parse_runtime_version_component(version.substr(dot + 1), "minor version");
}
return result;
}
static LoadedManifest load_manifest(const std::filesystem::path& modPath, ModBundle& bundle) {
const auto metaJson = bundle.readFile("mod.json");
auto j = nlohmann::json::parse(metaJson);
@@ -297,14 +348,18 @@ static ModMetadata load_metadata(const std::filesystem::path& modPath, ModBundle
std::string bannerPath =
resolve_image_path(bundle, metaId, "banner", metaBanner, "res/banner.png"s);
return ModMetadata{
std::move(metaId),
std::move(metaName),
std::move(metaVersion),
std::move(metaAuthor),
std::move(metaDescription),
std::move(iconPath),
std::move(bannerPath),
return LoadedManifest{
.metadata =
{
std::move(metaId),
std::move(metaName),
std::move(metaVersion),
std::move(metaAuthor),
std::move(metaDescription),
std::move(iconPath),
std::move(bannerPath),
},
.runtime = parse_runtime(j),
};
}
@@ -517,8 +572,8 @@ std::filesystem::path ModLoader::external_native_lib_path(const LoadedMod& mod)
if (libDir.empty()) {
return {};
}
fs::path path = libDir / fs::path(mod.metadata.id +
borealis::io::fs_path_to_string(fs::path(k_nativeLibName).extension()));
fs::path path = libDir / fs::path(mod.metadata.id + borealis::io::fs_path_to_string(
fs::path(k_nativeLibName).extension()));
std::error_code ec;
if (!fs::is_regular_file(path, ec)) {
return {};
@@ -684,6 +739,13 @@ bool ModLoader::load_native_if_present(LoadedMod& mod) {
}
const auto& native = std::get<NativeRuntimeLocation>(result);
if (mod.runtime.has_value() &&
(native.anyLibs || (mod.inPlace && !external_native_lib_path(mod).empty())))
{
mod.nativeStatus = NativeModStatus::InvalidBundle;
fail_mod(mod, MOD_CONFLICT, "A mod cannot declare both runtime and native code");
return false;
}
if (!native.anyLibs && !(mod.inPlace && !external_native_lib_path(mod).empty())) {
mod.nativeStatus = NativeModStatus::None;
return true;
@@ -728,7 +790,7 @@ static ModManifestInfo build_manifest_info(const ModMetaParsed& parsed) {
continue;
}
info.imports.push_back({record->service_id.chars, record->major_version,
(record->rec.flags & SERVICE_IMPORT_OPTIONAL) == 0});
record->min_minor_version, (record->rec.flags & SERVICE_IMPORT_OPTIONAL) == 0});
}
info.exports.reserve(parsed.exports.size());
for (const auto* record : parsed.exports) {
@@ -801,21 +863,22 @@ void ModLoader::try_load_mod(
return;
}
ModMetadata metadata;
LoadedManifest manifest;
try {
metadata = load_metadata(modPath, *bundle);
manifest = load_manifest(modPath, *bundle);
} catch (const std::exception& e) {
Log.error("bad mod.json in {}: {}", data::abbreviated_path_string(modPath), e.what());
return;
}
if (const auto* existing = find_mod(metadata.id)) {
if (const auto* existing = find_mod(manifest.metadata.id)) {
if (existing->searchDirIndex < searchDirIndex) {
log::write(metadata.id, LOG_LEVEL_INFO, "{} shadowed by higher-priority duplicate {}",
log::write(manifest.metadata.id, LOG_LEVEL_INFO,
"{} shadowed by higher-priority duplicate {}",
data::abbreviated_path_string(modPath),
data::abbreviated_path_string(existing->modPath));
} else {
log::write(metadata.id, LOG_LEVEL_ERROR, "duplicate mod id, not loading {}",
log::write(manifest.metadata.id, LOG_LEVEL_ERROR, "duplicate mod id, not loading {}",
data::abbreviated_path_string(modPath));
}
return;
@@ -827,12 +890,29 @@ void ModLoader::try_load_mod(
mod.modPath = fs::absolute(modPath);
mod.searchDirIndex = searchDirIndex;
mod.inPlace = m_searchDirs[searchDirIndex].inPlaceNative && fromDir;
mod.metadata = std::move(metadata);
mod.metadata = std::move(manifest.metadata);
mod.runtime = std::move(manifest.runtime);
mod.bundle = std::move(bundle);
mod.context = std::make_unique<ModContext>();
mod.context->mod = &mod;
mod.cvarIsEnabled =
std::make_unique<ConfigVar<bool>>(mod_enabled_cvar_name(mod.metadata.id), true);
if (mod.runtime.has_value()) {
const auto& runtime = *mod.runtime;
mod.manifestInfo.imports.push_back({runtime.id, runtime.major, runtime.minMinor, true});
std::error_code ec;
mod.dir = fs::absolute(m_cacheDir / mod.metadata.id / "data", ec);
if (!ec) {
fs::create_directories(mod.dir, ec);
}
if (ec) {
fail_mod(mod, MOD_ERROR,
fmt::format("Failed to create script scratch directory: {}", ec.message()));
} else {
mod.dirUtf8 = borealis::io::fs_path_to_string(mod.dir);
}
}
if (load_native_if_present(mod) && mod.native) {
mod.manifestInfo = build_manifest_info(mod.native->parsed);
}
@@ -846,12 +926,12 @@ bool ModLoader::activate_mod(LoadedMod& mod) {
mod.active = true;
// Asset-only mods have no lifecycle beyond their overlay files.
if (!mod.native) {
if (!mod.native && !mod.runtime.has_value()) {
mod.enabledApplied = true;
return true;
}
if (!mod.servicesRegistered) {
if (mod.native && !mod.servicesRegistered) {
if (!register_static_service_exports(mod)) {
log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to register service exports");
deactivate_mod(mod);
@@ -860,30 +940,60 @@ bool ModLoader::activate_mod(LoadedMod& mod) {
mod.servicesRegistered = true;
}
if (!resolve_service_imports(mod)) {
if (mod.native && !resolve_service_imports(mod)) {
log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to resolve service imports");
deactivate_mod(mod);
return false;
}
svc::hook_resolve_mod_records(mod);
if (mod.native) {
svc::hook_resolve_mod_records(mod);
*mod.native->contextSymbol = mod.context.get();
} else {
auto& runtime = *mod.runtime;
const auto* record = svc::find_service(runtime.id.c_str(), runtime.major, runtime.minMinor);
if (record == nullptr) {
fail_mod(mod, MOD_UNAVAILABLE,
describe_missing_import(runtime.id.c_str(), runtime.major, runtime.minMinor));
deactivate_mod(mod);
return false;
}
*mod.native->contextSymbol = mod.context.get();
const auto* service = static_cast<const ModRuntimeService*>(record->service);
constexpr size_t kRequiredSize = offsetof(ModRuntimeService, deactivate) +
sizeof(((ModRuntimeService*)nullptr)->deactivate);
if (record->provider == nullptr || service == nullptr ||
service->header.struct_size < kRequiredSize || service->activate == nullptr ||
service->update == nullptr || service->deactivate == nullptr)
{
fail_mod(mod, MOD_UNAVAILABLE,
fmt::format("Runtime service {}@{} has an invalid lifecycle contract", runtime.id,
runtime.major));
deactivate_mod(mod);
return false;
}
runtime.service = service;
runtime.providerContext = record->provider->context.get();
}
log::write(mod.metadata.id, LOG_LEVEL_TRACE, "calling mod_initialize");
const char* initializeName = mod.native ? "mod_initialize" : "runtime activate";
log::write(mod.metadata.id, LOG_LEVEL_TRACE, "calling {}", initializeName);
try {
ModError error = MOD_ERROR_INIT;
const auto result = mod.native->fn_initialize(&error);
const auto result = mod.native ?
mod.native->fn_initialize(&error) :
mod.runtime->service->activate(
mod.runtime->providerContext, mod.context.get(), &error);
if (result == MOD_OK && !mod.loadFailed) {
mod.initialized = true;
log::write(mod.metadata.id, LOG_LEVEL_TRACE, "mod_initialize succeeded");
log::write(mod.metadata.id, LOG_LEVEL_TRACE, "{} succeeded", initializeName);
} else if (result != MOD_OK && !mod.loadFailed) {
fail_mod(mod, result, lifecycle_error_message("mod_initialize", result, error));
fail_mod(mod, result, lifecycle_error_message(initializeName, result, error));
}
} catch (const std::exception& e) {
fail_mod(mod, MOD_ERROR, fmt::format("Exception in mod_initialize: {}", e.what()));
fail_mod(mod, MOD_ERROR, fmt::format("Exception in {}: {}", initializeName, e.what()));
} catch (...) {
fail_mod(mod, MOD_ERROR, "Unknown exception in mod_initialize");
fail_mod(mod, MOD_ERROR, fmt::format("Unknown exception in {}", initializeName));
}
warn_unpublished_deferred_exports(mod);
@@ -900,21 +1010,31 @@ bool ModLoader::activate_mod(LoadedMod& mod) {
void ModLoader::deactivate_mod(LoadedMod& mod) {
svc::modules_mod_deactivating(mod);
if (mod.initialized && mod.native && mod.native->fn_shutdown) {
log::write(mod.metadata.id, LOG_LEVEL_TRACE, "calling mod_shutdown");
if (mod.initialized && ((mod.native && mod.native->fn_shutdown) ||
(mod.runtime.has_value() && mod.runtime->service != nullptr)))
{
const char* shutdownName = mod.native ? "mod_shutdown" : "runtime deactivate";
log::write(mod.metadata.id, LOG_LEVEL_TRACE, "calling {}", shutdownName);
try {
ModError error = MOD_ERROR_INIT;
const auto result = mod.native->fn_shutdown(&error);
const auto result = mod.native ?
mod.native->fn_shutdown(&error) :
mod.runtime->service->deactivate(
mod.runtime->providerContext, mod.context.get(), &error);
if (result == MOD_OK) {
log::write(mod.metadata.id, LOG_LEVEL_TRACE, "mod_shutdown succeeded");
log::write(mod.metadata.id, LOG_LEVEL_TRACE, "{} succeeded", shutdownName);
} else {
log::write(mod.metadata.id, LOG_LEVEL_ERROR, "mod_shutdown failed: {}",
lifecycle_error_message("mod_shutdown", result, error));
log::write(mod.metadata.id, LOG_LEVEL_ERROR, "{} failed: {}", shutdownName,
lifecycle_error_message(shutdownName, result, error));
}
} catch (...) {
}
}
mod.initialized = false;
if (mod.runtime.has_value()) {
mod.runtime->service = nullptr;
mod.runtime->providerContext = nullptr;
}
if (mod.servicesRegistered) {
svc::remove_services_for_provider(mod);
@@ -1168,29 +1288,35 @@ bool ModLoader::reload_bundle(LoadedMod& mod) {
data::abbreviated_path_string(mod.modPath));
std::shared_ptr<ModBundle> newBundle;
ModMetadata newMetadata;
LoadedManifest newManifest;
try {
std::error_code ec;
newBundle = load_bundle(mod.modPath, fs::is_directory(mod.modPath, ec));
newMetadata = load_metadata(mod.modPath, *newBundle);
newManifest = load_manifest(mod.modPath, *newBundle);
} catch (const std::exception& e) {
fail_mod(mod, MOD_ERROR, fmt::format("Reload failed: {}", e.what()));
return false;
}
if (newMetadata.id != mod.metadata.id) {
if (newManifest.metadata.id != mod.metadata.id) {
fail_mod(mod, MOD_CONFLICT,
fmt::format("Mod ID changed on reload ('{}'); restart required", newMetadata.id));
fmt::format(
"Mod ID changed on reload ('{}'); restart required", newManifest.metadata.id));
return false;
}
mod.metadata = std::move(newMetadata);
mod.metadata = std::move(newManifest.metadata);
mod.runtime = std::move(newManifest.runtime);
// In-flight readers of the old bundle keep it alive through their shared_ptr.
mod.bundle = std::move(newBundle);
mod.loadFailed = false;
mod.failureReason.clear();
ModManifestInfo newInfo;
if (mod.runtime.has_value()) {
const auto& runtime = *mod.runtime;
newInfo.imports.push_back({runtime.id, runtime.major, runtime.minMinor, true});
}
if (!load_native_if_present(mod)) {
return false;
}
@@ -1364,17 +1490,23 @@ void ModLoader::tick() {
apply_pending_requests();
for (auto& mod : mods()) {
if (!mod.active || !mod.native) {
if (!mod.active || (!mod.native && !mod.runtime.has_value())) {
continue;
}
try {
ModError error = MOD_ERROR_INIT;
const auto result = mod.native->fn_update(&error);
const bool delegated = !mod.native;
const auto result = delegated ?
mod.runtime->service->update(
mod.runtime->providerContext, mod.context.get(), &error) :
mod.native->fn_update(&error);
if (result != MOD_OK) {
fail_mod(mod, result, lifecycle_error_message("mod_update", result, error));
fail_mod(mod, result,
lifecycle_error_message(
delegated ? "runtime update" : "mod_update", result, error));
}
} catch (const std::exception& e) {
fail_mod(mod, MOD_ERROR, fmt::format("Exception in mod_update: {}", e.what()));
fail_mod(mod, MOD_ERROR, fmt::format("Exception in mod update: {}", e.what()));
} catch (...) {
fail_mod(mod, MOD_ERROR, "Unknown exception in mod_update");
}
+7 -1
View File
@@ -915,7 +915,7 @@ ModResult ui_window_close(LoadedMod& mod, uint64_t handle) {
if (slot == nullptr || slot->document == nullptr) {
return MOD_INVALID_ARGUMENT;
}
slot->document->hide(true);
slot->document->pop();
return MOD_OK;
}
@@ -1164,6 +1164,7 @@ void ui_remove_mod(LoadedMod& mod) {
if (s_modMenuTabs.erase(&mod) != 0) {
s_menuTabsDirty = true;
}
bool restoreCoveredDocument = false;
auto entries = s_slots.take_all(mod);
for (auto& entry : entries) {
auto& slot = entry.value;
@@ -1171,6 +1172,7 @@ void ui_remove_mod(LoadedMod& mod) {
case UiSlotKind::Window: {
auto* window = static_cast<ui::ModWindow*>(slot.document);
if (window != nullptr) {
restoreCoveredDocument |= ui::top_document() == window;
window->force_hide(true);
}
break;
@@ -1178,6 +1180,7 @@ void ui_remove_mod(LoadedMod& mod) {
case UiSlotKind::Dialog: {
auto* dialog = static_cast<ModDialog*>(slot.document);
if (dialog != nullptr) {
restoreCoveredDocument |= ui::top_document() == dialog;
dialog->force_hide(true);
}
break;
@@ -1189,6 +1192,9 @@ void ui_remove_mod(LoadedMod& mod) {
break;
}
}
if (restoreCoveredDocument) {
ui::uncover_top_document();
}
}
ModResult ui_get_clipboard_text(