diff --git a/.vscode/launch.json b/.vscode/launch.json index 087202b538..9d05bc1ce5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,7 @@ "type": "cppvsdbg", "request": "launch", "program": "${command:cmake.launchTargetPath}", - "args": ["-l", "1", "--dvd", "${workspaceRoot}/orig/GZ2E01/GZ2E01.iso", "--console", "--mods", "${workspaceRoot}/tools/mod_template/mods"], + "args": ["-l", "1", "--dvd", "${workspaceRoot}/orig/GZ2E01/GZ2E01.iso", "--console", "--mods", "${workspaceRoot}/mods"], "MIMode": "gdb", "miDebuggerPath": "gdb", "symbolSearchPath": "${command:cmake.launchTargetPath}", diff --git a/cmake/DuskModSDK.cmake b/cmake/DuskModSDK.cmake index 809c84426c..9ae8e92b93 100644 --- a/cmake/DuskModSDK.cmake +++ b/cmake/DuskModSDK.cmake @@ -7,7 +7,8 @@ function(add_dusk_mod target_name) message(FATAL_ERROR "add_dusk_mod: MOD_JSON is required") endif() - add_library(${target_name} SHARED ${ARG_SOURCES}) + add_library(${target_name} SHARED ${ARG_SOURCES} + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/dusk_imgui_ctx.cpp") set_target_properties(${target_name} PROPERTIES PREFIX "" WINDOWS_EXPORT_ALL_SYMBOLS ON) target_compile_features(${target_name} PRIVATE cxx_std_20) target_link_libraries(${target_name} PRIVATE dusk_game_headers) diff --git a/cmake/dusk_imgui_ctx.cpp b/cmake/dusk_imgui_ctx.cpp new file mode 100644 index 0000000000..48848ffcee --- /dev/null +++ b/cmake/dusk_imgui_ctx.cpp @@ -0,0 +1,5 @@ +#include "imgui.h" + +extern "C" void dusk_mod_set_imgui_ctx(void* ctx) { + ImGui::SetCurrentContext(static_cast(ctx)); +} diff --git a/docs/modding.md b/docs/modding.md new file mode 100644 index 0000000000..6d3ec54075 --- /dev/null +++ b/docs/modding.md @@ -0,0 +1,309 @@ +# Dusk Mod API + +Mods are shared libraries packaged into a `.dusk` zip archive. The loader scans the `mods/` directory at startup, extracts each library, and calls your exports each frame. + +## Table of Contents + +1. [Getting Started](#getting-started) +2. [mod.json](#modjson) +3. [Required Exports](#required-exports) +4. [DuskModAPI Reference](#duskmodapi-reference) +5. [Logging](#logging) +6. [Loading Resources](#loading-resources) +7. [ImGui Integration](#imgui-integration) +8. [Hooking Game Functions](#hooking-game-functions) + - [Pre-hooks](#pre-hooks) + - [Post-hooks](#post-hooks) + - [Replace hooks](#replace-hooks) + - [Reading and writing arguments](#reading-and-writing-arguments) + - [Raw C hook API](#raw-c-hook-api) +9. [Full Example](#full-example) + +--- + +## Getting Started + +Fork the [mod template](../tools/mod_template/), it is a self-contained CMake project that references dusk as a subdirectory. + +``` +my_mod/ +├── CMakeLists.txt +├── mod.json +├── src/mod.cpp +└── res/ (optional bundled resources) +``` + +**CMakeLists.txt:** + +```cmake +cmake_minimum_required(VERSION 3.25) +project(my_mod CXX) + +set(DUSK_DIR "${CMAKE_CURRENT_SOURCE_DIR}/dusk" CACHE PATH "Path to dusk source root") +add_subdirectory("${DUSK_DIR}" dusk EXCLUDE_FROM_ALL) + +add_dusk_mod(my_mod + SOURCES src/mod.cpp + MOD_JSON mod.json + RES_DIR res # optional +) +``` + +After building, `my_mod.dusk` is placed in `mods/` next to the project root (`DUSK_MODS_OUTPUT_DIR` cache variable). Copy it to the game's `mods/` folder and launch. + +The `.dusk` archive is a standard zip containing `mod.json`, the compiled library, and an optional `res/` tree. `add_dusk_mod()` creates it automatically. + +--- + +## mod.json + +```json +{ + "name": "My Mod", + "version": "1.0.0", + "author": "Your Name", + "description": "A short description shown in the mod manager." +} +``` + +All fields are optional but recommended. `name` falls back to the filename, `version` to `"?"`. + +--- + +## Required Exports + +```cpp +#include "dusk/mod_api.h" + +extern "C" { + +void mod_init (DuskModAPI* api); // required — called once at startup +void mod_tick (DuskModAPI* api); // required — called every frame +void mod_cleanup(DuskModAPI* api); // optional — called on shutdown + +} +``` + +--- + +## DuskModAPI Reference + +The `api` pointer is valid for the lifetime of the mod. When using `hook.hpp`, call `dusk::init(api)` once and `dusk::g_api` is set for you. + +| Field | Description | +|-------|-------------| +| `api_version` | ABI version, check against `DUSK_MOD_API_VERSION` if needed | +| `mod_dir` | Absolute path to the extracted mod cache directory | +| `log_info` / `log_warn` / `log_error` | `printf`-style logging, prefixed with the mod name | +| `load_resource` / `free_resource` | Load files from the `res/` tree in the `.dusk` archive | +| `register_tab_content` | Add a panel to the mod manager's per-mod tab | +| `register_menu_item` | Add an item to the quick-access menu | +| `hook_install` / `hook_pre` / `hook_post` / `hook_replace` | Low-level hook registration (see [Raw C hook API](#raw-c-hook-api)) | +| `hook_dispatch_pre` / `hook_dispatch_post` | Called by the trampoline, do not call directly | + +--- + +## Logging + +```cpp +api->log_info("Player health: %d", hp); +api->log_warn("Something looks wrong"); +api->log_error("Fatal: %s", msg); +``` + +Output appears in the dusk console as `[My Mod] ...` + +The format string is `printf`-compatible. + +--- + +## Loading Resources + +```cpp +size_t size = 0; +void* data = api->load_resource("config.txt", &size); +if (data) { + std::string text(static_cast(data), size); + api->free_resource(data); +} +``` + +- Path is relative to `res/`, pass `"config.txt"` not `"res/config.txt"` +- Always call `free_resource`, the buffer is owned by miniz +- For writable storage, write files under `api->mod_dir` + +--- + +## ImGui Integration + +**Tab content:** shown in the mod's panel in the Mods window, called every frame while visible: + +```cpp +static void DrawPanel(void* userdata) { + ImGui::Text("Hello!"); +} +api->register_tab_content(DrawPanel, nullptr); +``` + +Pass a pointer through `userdata` if your callback needs state: + +```cpp +api->register_tab_content(DrawPanel, &g_state); +``` + +**Menu items:** added to the quick-access menu. Use `ImGui::MenuItem`, `ImGui::Separator`, etc.: + +```cpp +static void DrawMenuEntry(void*) { + if (ImGui::MenuItem("Reset rotation")) { ... } +} +api->register_menu_item(DrawMenuEntry, nullptr); +``` + +--- + +## Hooking Game Functions + +Call `dusk::init(api)` first. + +```cpp +#include "dusk/hook.hpp" + +extern "C" void mod_init(DuskModAPI* api) { + dusk::init(api); + dusk::hookAddPre<&ClassName::Method>(callback); +} +``` + +The trampoline is installed once per address. Multiple mods can register pre/post callbacks for the same function independently. + +### Pre-hooks + +Run before the original. Return `0` to let it proceed, non-zero to cancel it. Post-hooks still run either way. + +```cpp +static int32_t on_posMove_pre(void* args) { + daAlink_c* link = dusk::arg(args, 0); // this + if (link->shape_angle.y > 10000) + return 1; // cancel + return 0; +} +dusk::hookAddPre<&daAlink_c::posMove>(on_posMove_pre); +``` + +### Post-hooks + +Run after the original (or replace-hook). + +```cpp +static void on_posMove_post(void* args) { + daAlink_c* link = dusk::arg(args, 0); + dusk::g_api->log_info("New Y angle: %d", (int)link->shape_angle.y); +} +dusk::hookAddPost<&daAlink_c::posMove>(on_posMove_post); +``` + +### Replace hooks + +Completely substitutes the original. Only one replace-hook per function, a second install overwrites with a warning. + +```cpp +static void on_posMove_replace(void* args) { + daAlink_c* link = dusk::arg(args, 0); + link->shape_angle.y += 100; +} +dusk::hookSetReplace<&daAlink_c::posMove>(on_posMove_replace); +``` + +To call the original from inside a replace-hook: + +```cpp +using Entry = dusk::HookEntry<&daAlink_c::posMove>; + +static void on_posMove_replace(void* args) { + daAlink_c* link = dusk::arg(args, 0); + link->shape_angle.y = 0; + Entry::g_orig(link); +} +``` + +### Reading and writing arguments + +`args` is a `void*[N]` array. Index `0` is `this`, subsequent indices are parameters in declaration order. + +```cpp +T value = dusk::arg (args, n); // copy +T& ref = dusk::argRef(args, n); // reference (read/write) +``` + +**Example:** halve incoming damage + +```cpp +// void daEnemy_c::takeDamage(int amount, daActor_c* source) +static int32_t on_takeDamage_pre(void* args) { + dusk::argRef(args, 1) /= 2; + return 0; +} +dusk::hookAddPre<&daEnemy_c::takeDamage>(on_takeDamage_pre); +``` + +For reference parameters (e.g. `const cXyz& pos`), use `argRef` to get a direct reference. + + +--- + +## Full Example + +```cpp +#include "d/actor/d_a_alink.h" +#include "dusk/hook.hpp" +#include "dusk/mod_api.h" +#include "imgui.h" +#include "m_Do/m_Do_controller_pad.h" + +static int g_ticks = 0; + +static int32_t on_posMove_pre(void* args) { + daAlink_c* link = dusk::arg(args, 0); + if (mDoCPd_c::getHoldR(PAD_1)) { + link->shape_angle.y -= 2048; + } + return 0; +} + +static void DrawPanel(void*) { + daAlink_c* link = daAlink_getAlinkActorClass(); + ImGui::Text("Ticks: %d", g_ticks); + if (link) { + ImGui::Text("Y angle: %d", (int)link->shape_angle.y); + if (ImGui::Button("Reset rotation")) { + link->shape_angle.y = 0; + } + } +} + +static void DrawMenuEntry(void*) { + daAlink_c* link = daAlink_getAlinkActorClass(); + if (ImGui::MenuItem("Reset rotation", nullptr, false, link != nullptr)) { + link->shape_angle.y = 0; + } +} + +extern "C" { + +void mod_init(DuskModAPI* api) { + dusk::init(api); + dusk::hookAddPre<&daAlink_c::posMove>(on_posMove_pre); + api->register_tab_content(DrawPanel, nullptr); + api->register_menu_item(DrawMenuEntry, nullptr); +} + +void mod_tick(DuskModAPI* api) { + ++g_ticks; +} + +void mod_cleanup(DuskModAPI* api) { + api->log_info("Unloaded after %d ticks.", g_ticks); +} +} +``` diff --git a/tools/mod_template/res/.gitkeep b/tools/mod_template/res/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/mod_template/res/text.txt b/tools/mod_template/res/text.txt deleted file mode 100644 index 7753e7b422..0000000000 --- a/tools/mod_template/res/text.txt +++ /dev/null @@ -1,2 +0,0 @@ -This text has been loaded from the mods resources! -Press R to rotate Link! diff --git a/tools/mod_template/src/mod.cpp b/tools/mod_template/src/mod.cpp index 34b820cdcc..bca66181b6 100644 --- a/tools/mod_template/src/mod.cpp +++ b/tools/mod_template/src/mod.cpp @@ -1,80 +1,18 @@ -#include "d/actor/d_a_alink.h" #include "dusk/hook.hpp" #include "dusk/mod_api.h" -#include "imgui.h" -#include "m_Do/m_Do_controller_pad.h" - -#include - -static int TickCount = 0; -static std::string TextContents; - -static int32_t on_posMove_pre(void* args) { - if (!mDoCPd_c::getHoldR(PAD_1)) - return 0; - daAlink_c* link = dusk::arg(args, 0); - link->shape_angle.y -= 2048; - dusk::g_api->log_info("ROTATING %d", link->shape_angle.y); - return 0; -} - -static void DrawTabContent(void*) { - daAlink_c* link = daAlink_getAlinkActorClass(); - if (link) { - ImGui::Text("Y angle: %d", (int)link->shape_angle.y); - ImGui::Spacing(); - if (ImGui::Button("Reset rotation")) { - link->shape_angle.y = 0; - } - } - if (!TextContents.empty()) { - ImGui::Separator(); - ImGui::TextUnformatted(TextContents.c_str()); - } -} - -static void DrawMenuItem(void*) { - if (ImGui::MenuItem("Reset rotation")) { - daAlink_c* link = daAlink_getAlinkActorClass(); - if (link) { - link->shape_angle.y = 0; - } - } -} extern "C" { -void dusk_mod_set_imgui_ctx(void* ctx) { - ImGui::SetCurrentContext(static_cast(ctx)); -} - void mod_init(DuskModAPI* api) { - api->log_info("Test Mod initializing..."); - dusk::init(api); - dusk::hookAddPre<&daAlink_c::posMove>(on_posMove_pre); - - size_t size = 0; - void* data = api->load_resource("text.txt", &size); - if (data) { - TextContents.assign(static_cast(data), size); - api->free_resource(data); - api->log_info("Loaded text.txt (%zu bytes)", size); - } else { - api->log_warn("Failed to load text.txt"); - } - - api->register_tab_content(DrawTabContent, nullptr); - api->register_menu_item(DrawMenuItem, nullptr); - api->log_info("Test Mod ready. Mod folder: %s", api->mod_dir); } void mod_tick(DuskModAPI* api) { - ++TickCount; + (void)api; } void mod_cleanup(DuskModAPI* api) { - api->log_info("Test Mod unloading after %d ticks.", TickCount); + (void)api; } } diff --git a/tools/mod_test/CMakeLists.txt b/tools/mod_test/CMakeLists.txt new file mode 100644 index 0000000000..4c1fcf6332 --- /dev/null +++ b/tools/mod_test/CMakeLists.txt @@ -0,0 +1,13 @@ +cmake_minimum_required(VERSION 3.25) +project(mod_test CXX) + +set(DUSK_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../.." CACHE PATH "Path to dusk source root") +add_subdirectory("${DUSK_DIR}" dusk EXCLUDE_FROM_ALL) + +set(DUSK_MODS_OUTPUT_DIR "${CMAKE_SOURCE_DIR}/mods" CACHE PATH "Directory to write .dusk packages into") + +add_dusk_mod(mod_test + SOURCES src/mod.cpp + MOD_JSON mod.json + RES_DIR res +) diff --git a/tools/mod_test/mod.json b/tools/mod_test/mod.json new file mode 100644 index 0000000000..0a168a6c22 --- /dev/null +++ b/tools/mod_test/mod.json @@ -0,0 +1,6 @@ +{ + "name": "API Test Mod", + "version": "1.0.0", + "author": "dusk", + "description": "Exercises every feature of the Dusk mod API." +} diff --git a/tools/mod_test/res/hello.txt b/tools/mod_test/res/hello.txt new file mode 100644 index 0000000000..0fa772fe99 --- /dev/null +++ b/tools/mod_test/res/hello.txt @@ -0,0 +1 @@ +Hello from the mod archive! diff --git a/tools/mod_test/src/mod.cpp b/tools/mod_test/src/mod.cpp new file mode 100644 index 0000000000..44de2f5020 --- /dev/null +++ b/tools/mod_test/src/mod.cpp @@ -0,0 +1,154 @@ +// Tests every feature of the Dusk mod API. Results shown in the mod tab. + +#include "d/actor/d_a_alink.h" +#include "dusk/hook.hpp" +#include "dusk/mod_api.h" +#include "imgui.h" +#include "m_Do/m_Do_controller_pad.h" + +#include +#include + +static int g_ticks = 0; +static bool g_pre_fired = false; +static bool g_post_fired = false; +static bool g_replace_fired = false; +static bool g_arg_write_ok = false; +static int g_pre_cancel_count = 0; +static int g_post_count = 0; +static bool g_resource_ok = false; +static std::string g_resource_text; +static char g_mod_dir_snippet[64] = {}; + +// Pre-hook on posMove. Hold L to test argRef write and cancellation. +static int32_t on_posMove_pre(void* args) { + g_pre_fired = true; + if (mDoCPd_c::getHoldL(PAD_1)) { + dusk::argRef(args, 0)->shape_angle.y = 0; + g_arg_write_ok = true; + ++g_pre_cancel_count; + return 1; // cancel + } + return 0; +} + +// Post-hook on posMove. Fires even when the pre-hook cancelled. +static void on_posMove_post(void* args) { + g_post_fired = true; + ++g_post_count; + (void)args; +} + +// Replace-hook on execute. Calls through to the original so gameplay is unaffected. +using ExecuteEntry = dusk::HookEntry<&daAlink_c::execute>; +static void on_execute_replace(void* args) { + g_replace_fired = true; + ExecuteEntry::g_orig(dusk::arg(args, 0)); +} + +static void DrawPanel(void*) { + auto status = [](const char* label, bool ok) { + ImGui::TextColored(ok ? ImVec4(0, 1, 0, 1) : ImVec4(1, 0.35f, 0.35f, 1), + ok ? "[PASS]" : "[WAIT]"); + ImGui::SameLine(); + ImGui::Text("%s", label); + }; + + ImGui::SeparatorText("Hooks"); + status("pre-hook fired (posMove)", g_pre_fired); + status("post-hook fired (posMove)", g_post_fired); + status("replace-hook fired (execute)", g_replace_fired); + status("argRef write + pre cancel (hold L)", g_arg_write_ok); + ImGui::Text(" pre cancels: %d post calls: %d", g_pre_cancel_count, g_post_count); + + ImGui::SeparatorText("Resources"); + status("load_resource (hello.txt)", g_resource_ok); + if (!g_resource_text.empty()) + ImGui::TextWrapped(" \"%s\"", g_resource_text.c_str()); + + ImGui::SeparatorText("API Fields"); + status("mod_dir non-empty", g_mod_dir_snippet[0] != '\0'); + ImGui::TextWrapped(" %s", g_mod_dir_snippet); + + ImGui::Spacing(); + ImGui::Separator(); + if (ImGui::Button("Reset results")) { + g_pre_fired = false; + g_post_fired = false; + g_replace_fired = false; + g_arg_write_ok = false; + g_pre_cancel_count = 0; + g_post_count = 0; + } + daAlink_c* link = daAlink_getAlinkActorClass(); + if (link) { + ImGui::SameLine(); + ImGui::Text("(Link y angle: %d)", (int)link->shape_angle.y); + } +} + +static void DrawMenuEntry(void*) { + if (ImGui::MenuItem("Test: log all levels")) { + dusk::g_api->log_info("log_info test"); + dusk::g_api->log_warn("log_warn test"); + dusk::g_api->log_error("log_error test"); + } + if (ImGui::MenuItem("Test: reset Link y angle")) { + daAlink_c* link = daAlink_getAlinkActorClass(); + if (link) link->shape_angle.y = 0; + } +} + +extern "C" { + +void mod_init(DuskModAPI* api) { + dusk::init(api); + + api->log_info("mod_test initializing"); + api->log_warn("log_warn smoke test"); + api->log_error("log_error smoke test"); + + std::snprintf(g_mod_dir_snippet, sizeof(g_mod_dir_snippet), "%.60s", api->mod_dir); + + size_t size = 0; + void* data = api->load_resource("hello.txt", &size); + if (data) { + g_resource_text.assign(static_cast(data), size); + while (!g_resource_text.empty() && g_resource_text.back() == '\n') + g_resource_text.pop_back(); + api->free_resource(data); + g_resource_ok = true; + api->log_info("load_resource OK: \"%s\"", g_resource_text.c_str()); + } else { + api->log_error("load_resource FAILED for hello.txt"); + } + + // Missing file should return nullptr gracefully. + void* missing = api->load_resource("does_not_exist.bin", nullptr); + if (!missing) + api->log_info("load_resource missing-file: correctly returned nullptr"); + else { + api->log_error("load_resource missing-file: unexpectedly returned data"); + api->free_resource(missing); + } + + dusk::hookAddPre <&daAlink_c::posMove>(on_posMove_pre); + dusk::hookAddPost<&daAlink_c::posMove>(on_posMove_post); + dusk::hookSetReplace<&daAlink_c::execute>(on_execute_replace); + + api->register_tab_content(DrawPanel, nullptr); + api->register_menu_item(DrawMenuEntry, nullptr); + + api->log_info("mod_test ready"); +} + +void mod_tick(DuskModAPI* api) { + ++g_ticks; + (void)api; +} + +void mod_cleanup(DuskModAPI* api) { + api->log_info("mod_test unloaded after %d ticks", g_ticks); +} + +}