Skip to content

Commit

Permalink
🎮 New mod type: '.gadget'
Browse files Browse the repository at this point in the history
Gadgets are essentially scripts wrapped as modcache entries. The name "gadgets" is inspired by Beyond All Reason RTS (running open source Recoil engine). Usual modcache treatment applies:
* can be zipped or unzipped in directory
* can have preview (aka 'mini') image
* selectable via MainSelectorUI (open from TopMenubarUI, menu Tools, button BrowseGadgets

All methods of loading a script were extended to recognize the .gadget suffix and load the gadget appropriately:
* the 'loadscript' console command
* the cvars 'app_custom_scripts' and 'app_recent_scripts'
* the -runscript command line argument
NOTE that although `ScriptCategory::GADGET` was added, all the above methods still load everything as `ScriptCategory::CUSTOM` and the game adjusts it based on extension.

The ScriptMonitorUI was extended to display .gadget file name for `ScriptCategory::GADGET` script units.

Example .gadget file:
```
; This is a .gadget mod - basically a script wrapped as a modcache entry.
; The .gadget file must be present (even if empty) to get recognized.
; The script must have same base filename, i.e. 'foo.gadget' -> 'foo.as'
; Any extra include scripts or other resources can be bundled.
; -----------------------------------------------------------------------

; Name to display in Selector UI.
gadget_name "Engine Tool"

; Authors (Syntax: <credit>, <forumID>, <name>, [<email>]) - multiple authors can be given.
gadget_author "base script" 351 ohlidalp

; Description to display in Selector UI.
gadget_description "In-game engine diag and adjustment."

; Category for Selector UI (300 = Generic gadgets, 301 = Actor gadgets, 302 = Terrain gadgets).
gadget_category 301
```
  • Loading branch information
ohlidalp committed Mar 1, 2025
1 parent 00fbddd commit 02ca86b
Show file tree
Hide file tree
Showing 9 changed files with 157 additions and 15 deletions.
5 changes: 5 additions & 0 deletions source/main/Application.h
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ enum LoaderType //!< Search mode for `ModCache::Query()` & Operation mode for `G
LT_Tuneup, // No script alias, invoked manually, ext: tuneup
LT_AssetPack, // No script alias, invoked manually, ext: assetpack
LT_DashBoard, // No script alias, invoked manually, ext: dashboard
LT_Gadget, // No script alias, invoked manually, ext: gadget
};

enum CacheCategoryId
Expand All @@ -332,6 +333,10 @@ enum CacheCategoryId
CID_DashboardsTruck = 201,
CID_DashboardsBoat = 202,

CID_GadgetsGeneric = 300,
CID_GadgetsActor = 301,
CID_GadgetsTerrain = 302,

CID_Projects = 8000, //!< For truck files under 'projects/' directory, to allow listing from editors.
CID_Tuneups = 8001, //!< For unsorted tuneup files.

Expand Down
11 changes: 10 additions & 1 deletion source/main/gui/panels/GUI_MainSelector.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
#include "GUI_LoadingWindow.h"
#include "InputEngine.h"
#include "Language.h"
#include "ScriptEngine.h"
#include "Utils.h"

#include <MyGUI.h>
Expand Down Expand Up @@ -629,7 +630,15 @@ void MainSelector::Apply()
ROR_ASSERT(m_selected_entry > -1); // Programmer error
DisplayEntry& sd_entry = m_display_entries[m_selected_entry];

if (m_loader_type == LT_Terrain &&
if (m_loader_type == LT_Gadget)
{
auto request = new LoadScriptRequest();
request->lsr_filename = sd_entry.sde_entry->fname;
request->lsr_category = ScriptCategory::GADGET;
App::GetGameContext()->PushMessage(Message(MSG_APP_LOAD_SCRIPT_REQUESTED, request));
this->Close();
}
else if (m_loader_type == LT_Terrain &&
App::app_state->getEnum<AppState>() == AppState::MAIN_MENU)
{
App::GetGameContext()->PushMessage(Message(MSG_SIM_LOAD_TERRN_REQUESTED, sd_entry.sde_entry->fname));
Expand Down
24 changes: 19 additions & 5 deletions source/main/gui/panels/GUI_ScriptMonitor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,14 @@ void ScriptMonitor::Draw()
ImGui::TextDisabled("%d", id);
ImGui::NextColumn();
ImGui::AlignTextToFramePadding();
ImGui::Text("%s", unit.scriptName.c_str());
if (unit.scriptCategory == ScriptCategory::GADGET && unit.originatingGadget)
{
ImGui::Text("%s", unit.originatingGadget->fname.c_str());
}
else
{
ImGui::Text("%s", unit.scriptName.c_str());
}
ImGui::NextColumn();
switch (unit.scriptCategory)
{
Expand All @@ -71,13 +78,20 @@ void ScriptMonitor::Draw()
break;

case ScriptCategory::CUSTOM:
case ScriptCategory::GADGET:
{
std::string filename = unit.scriptName;
if (unit.scriptCategory == ScriptCategory::GADGET && unit.originatingGadget)
{
filename = unit.originatingGadget->fname;
}

if (ImGui::Button(_LC("ScriptMonitor", "Reload")))
{
App::GetGameContext()->PushMessage(Message(MSG_APP_UNLOAD_SCRIPT_REQUESTED, new ScriptUnitID_t(id)));
LoadScriptRequest* req = new LoadScriptRequest();
req->lsr_category = unit.scriptCategory;
req->lsr_filename = unit.scriptName;
req->lsr_filename = filename;
App::GetGameContext()->ChainMessage(Message(MSG_APP_LOAD_SCRIPT_REQUESTED, req));
}
ImGui::SameLine();
Expand All @@ -87,13 +101,13 @@ void ScriptMonitor::Draw()
}

ImGui::SameLine();
bool autoload_set = std::find(autoload.begin(), autoload.end(), unit.scriptName) != autoload.end();
bool autoload_set = std::find(autoload.begin(), autoload.end(), filename) != autoload.end();
if (ImGui::Checkbox(_LC("ScriptMonitor", "Autoload"), &autoload_set))
{
if (autoload_set)
CvarAddFileToList(App::app_custom_scripts, unit.scriptName);
CvarAddFileToList(App::app_custom_scripts, filename);
else
CvarRemoveFileFromList(App::app_custom_scripts, unit.scriptName);
CvarRemoveFileFromList(App::app_custom_scripts, filename);
}
break;
}
Expand Down
6 changes: 6 additions & 0 deletions source/main/gui/panels/GUI_TopMenubar.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,12 @@ void TopMenubar::Draw(float dt)
}
}

if (ImGui::Button(_LC("TopMenubar", "Browse gadgets ...")))
{
App::GetGameContext()->PushMessage(Message(MSG_GUI_OPEN_SELECTOR_REQUESTED, new LoaderType(LT_Gadget)));
m_open_menu = TopMenu::TOPMENU_NONE;
}

ImGui::Separator();
ImGui::TextColored(GRAY_HINT_TEXT, "%s", _LC("TopMenubar", "Pre-spawn diag. options:"));

Expand Down
12 changes: 10 additions & 2 deletions source/main/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,11 @@ int main(int argc, char *argv[])
for (Ogre::String const& scriptname: script_names)
{
LOG(fmt::format("Loading startup script '{}' (from command line)", scriptname));
App::GetScriptEngine()->loadScript(scriptname, ScriptCategory::CUSTOM);
// We cannot call `loadScript()` directly because modcache isn't up yet - gadgets cannot be resolved
LoadScriptRequest* req = new LoadScriptRequest();
req->lsr_category = ScriptCategory::CUSTOM;
req->lsr_filename = scriptname;
App::GetGameContext()->PushMessage(Message(MSG_APP_LOAD_SCRIPT_REQUESTED, req));
// errors are logged by OGRE & AngelScript
}
}
Expand All @@ -245,7 +249,11 @@ int main(int argc, char *argv[])
for (Ogre::String const& scriptname: script_names)
{
LOG(fmt::format("Loading startup script '{}' (from config file)", scriptname));
App::GetScriptEngine()->loadScript(scriptname, ScriptCategory::CUSTOM);
// We cannot call `loadScript()` directly because modcache isn't up yet - gadgets cannot be resolved
LoadScriptRequest* req = new LoadScriptRequest();
req->lsr_category = ScriptCategory::CUSTOM;
req->lsr_filename = scriptname;
App::GetGameContext()->PushMessage(Message(MSG_APP_LOAD_SCRIPT_REQUESTED, req));
// errors are logged by OGRE & AngelScript
}
}
Expand Down
59 changes: 59 additions & 0 deletions source/main/resources/CacheSystem.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ CacheSystem::CacheSystem()
m_known_extensions.push_back("tuneup");
m_known_extensions.push_back("assetpack");
m_known_extensions.push_back("dashboard");
m_known_extensions.push_back("gadget");

// register the dirs
m_content_dirs.push_back("mods");
Expand Down Expand Up @@ -801,6 +802,12 @@ void CacheSystem::AddFile(String group, Ogre::FileInfo f, String ext)
FillDashboardDetailInfo(entry, ds);
new_entries.push_back(entry);
}
else if (ext == "gadget")
{
CacheEntryPtr entry = new CacheEntry();
FillGadgetDetailInfo(entry, ds);
new_entries.push_back(entry);
}
else
{
CacheEntryPtr entry = new CacheEntry();
Expand Down Expand Up @@ -1330,6 +1337,46 @@ void CacheSystem::FillDashboardDetailInfo(CacheEntryPtr& entry, Ogre::DataStream

}

void CacheSystem::FillGadgetDetailInfo(CacheEntryPtr& entry, Ogre::DataStreamPtr ds)
{
GenericDocumentPtr doc = new GenericDocument();
BitMask_t options
= GenericDocument::OPTION_ALLOW_SLASH_COMMENTS
| GenericDocument::OPTION_ALLOW_NAKED_STRINGS
| GenericDocument::OPTION_NAKEDSTR_USCORES_TO_SPACES;
doc->loadFromDataStream(ds, options);

GenericDocContextPtr ctx = new GenericDocContext(doc);
while (!ctx->endOfFile())
{
if (ctx->isTokKeyword() && ctx->getTokKeyword() == "gadget_name" && ctx->isTokString(1))
{
entry->dname = ctx->getTokString(1);
}
else if (ctx->isTokKeyword() && ctx->getTokKeyword() == "gadget_description" && ctx->isTokString(1))
{
entry->description = ctx->getTokString(1);
}
else if (ctx->isTokKeyword() && ctx->getTokKeyword() == "gadget_category" && ctx->isTokInt(1))
{
entry->categoryid = ctx->getTokInt(1);
}
else if (ctx->isTokKeyword() && ctx->getTokKeyword() == "gadget_author")
{
int n = ctx->countLineArgs();
AuthorInfo author;
if (n > 1) { author.type = ctx->getTokString(1); }
if (n > 2) { author.id = ctx->getTokInt(2); }
if (n > 3) { author.name = ctx->getTokString(3); }
if (n > 4) { author.email = ctx->getTokString(4); }
entry->authors.push_back(author);
}

ctx->seekNextLine();
}

}

void CacheSystem::FillTuneupDetailInfo(CacheEntryPtr &entry, TuneupDefPtr& tuneup_def)
{
if (!tuneup_def->author_name.empty())
Expand Down Expand Up @@ -1528,6 +1575,16 @@ void CacheSystem::LoadResource(CacheEntryPtr& entry)
entry->resource_bundle_path, entry->resource_bundle_type, group, recursive, readonly);
App::GetContentManager()->InitManagedMaterials(group);
}
else if (entry->fext == "gadget")
{
// This is a .gadget bundle - use `inGlobalPool=false` to prevent resource name conflicts.
ResourceGroupManager::getSingleton().createResourceGroup(group, /*inGlobalPool=*/false);
ResourceGroupManager::getSingleton().addResourceLocation(
entry->resource_bundle_path, entry->resource_bundle_type, group, recursive, readonly);
App::GetContentManager()->InitManagedMaterials(group);
// Allow using builtin include scripts
App::GetContentManager()->AddResourcePack(ContentManager::ResourcePack::SCRIPTS, group);
}
else
{
// A vehicle bundle - use `inGlobalPool=false` to prevent resource name conflicts.
Expand Down Expand Up @@ -2187,6 +2244,8 @@ size_t CacheSystem::Query(CacheQuery& query)
add = (query.cqy_filter_type == LT_AssetPack);
else if (entry->fext == "dashboard")
add = (query.cqy_filter_type == LT_DashBoard);
else if (entry->fext == "gadget")
add = (query.cqy_filter_type == LT_Gadget);
else if (entry->fext == "truck")
add = (query.cqy_filter_type == LT_AllBeam || query.cqy_filter_type == LT_Vehicle || query.cqy_filter_type == LT_Truck);
else if (entry->fext == "car")
Expand Down
6 changes: 6 additions & 0 deletions source/main/resources/CacheSystem.h
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ class CacheSystem
void FillTuneupDetailInfo(CacheEntryPtr &entry, TuneupDefPtr& tuneup_def);
void FillAssetPackDetailInfo(CacheEntryPtr &entry, Ogre::DataStreamPtr ds);
void FillDashboardDetailInfo(CacheEntryPtr& entry, Ogre::DataStreamPtr ds);
void FillGadgetDetailInfo(CacheEntryPtr& entry, Ogre::DataStreamPtr ds);
/// @}

void GenerateHashFromFilenames(); //!< For quick detection of added/removed content
Expand Down Expand Up @@ -420,6 +421,11 @@ class CacheSystem
{201, _LC("ModCategory", "Dashboards - Truck")},
{202, _LC("ModCategory", "Dashboards - Boat")},

// gadgets
{CID_GadgetsGeneric, _LC("ModCategory", "Gadgets - Generic")},
{CID_GadgetsActor, _LC("ModCategory", "Gadgets - Actor")},
{CID_GadgetsTerrain, _LC("ModCategory", "Gadgets - Terrain")},

// note: these categories are NOT in the repository:
{5000, _LC("ModCategory", "Official Terrains")},
{5001, _LC("ModCategory", "Night Terrains")},
Expand Down
37 changes: 35 additions & 2 deletions source/main/scripting/ScriptEngine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -826,7 +826,7 @@ String ScriptEngine::composeModuleName(String const& scriptName, ScriptCategory
}

ScriptUnitID_t ScriptEngine::loadScript(
String scriptName, ScriptCategory category/* = ScriptCategory::TERRAIN*/,
String scriptOrGadgetFileName, ScriptCategory category/* = ScriptCategory::TERRAIN*/,
ActorPtr associatedActor /*= nullptr*/, std::string buffer /* =""*/)
{
// This function creates a new script unit, tries to set it up and removes it if setup fails.
Expand All @@ -836,12 +836,41 @@ ScriptUnitID_t ScriptEngine::loadScript(
// be created early, and removed if setup fails.
static ScriptUnitID_t id_counter = 0;

std::string basename, ext, scriptName;
Ogre::StringUtil::splitBaseFilename(scriptOrGadgetFileName, basename, ext);
CacheEntryPtr originatingGadget;
if (ext == "gadget")
{
originatingGadget = App::GetCacheSystem()->FindEntryByFilename(LT_Gadget, /* partial: */false, scriptOrGadgetFileName);
if (!originatingGadget)
{
App::GetConsole()->putMessage(Console::CONSOLE_MSGTYPE_INFO, Console::CONSOLE_SYSTEM_ERROR,
fmt::format("Could not load script '{}' - gadget not found.", scriptOrGadgetFileName));
return SCRIPTUNITID_INVALID;
}
App::GetCacheSystem()->LoadResource(originatingGadget);
scriptName = fmt::format("{}.as", basename);
// Ensure a .gadget file is always loaded as `GADGET`, even if requested as `CUSTOM`
category = ScriptCategory::GADGET;
}
else if (ext == "as")
{
scriptName = scriptOrGadgetFileName;
}
else
{
App::GetConsole()->putMessage(Console::CONSOLE_MSGTYPE_INFO, Console::CONSOLE_SYSTEM_ERROR,
fmt::format("Could not load script '{}' - unknown file extension.", scriptOrGadgetFileName));
return SCRIPTUNITID_INVALID;
}

ScriptUnitID_t unit_id = id_counter++;
auto itor_pair = m_script_units.insert(std::make_pair(unit_id, ScriptUnit()));
m_script_units[unit_id].uniqueId = unit_id;
m_script_units[unit_id].scriptName = scriptName;
m_script_units[unit_id].scriptCategory = category;
m_script_units[unit_id].scriptBuffer = buffer;
m_script_units[unit_id].originatingGadget = originatingGadget;
if (category == ScriptCategory::TERRAIN)
{
m_terrain_script_unit = unit_id;
Expand All @@ -859,6 +888,10 @@ ScriptUnitID_t ScriptEngine::loadScript(
{
CvarAddFileToList(App::app_recent_scripts, scriptName);
}
else if (category == ScriptCategory::GADGET && originatingGadget)
{
CvarAddFileToList(App::app_recent_scripts, originatingGadget->fname);
}

// If setup failed, remove the unit.
if (result != 0)
Expand All @@ -879,7 +912,7 @@ int ScriptEngine::setupScriptUnit(int unit_id)
int result=0;

String moduleName = this->composeModuleName(
m_script_units[unit_id].scriptName, m_script_units[unit_id].scriptCategory, m_script_units[unit_id].uniqueId);
m_script_units[unit_id].scriptName, m_script_units[unit_id].scriptCategory, m_script_units[unit_id].uniqueId);

// The builder is a helper class that will load the script file,
// search for #include directives, and load any included files as
Expand Down
12 changes: 7 additions & 5 deletions source/main/scripting/ScriptEngine.h
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ enum class ScriptCategory
INVALID,
ACTOR, //!< Defined in truck file under 'scripts', contains global variable `BeamClass@ thisActor`.
TERRAIN, //!< Defined in terrn2 file under '[Scripts]', receives terrain eventbox notifications.
CUSTOM //!< Loaded by user via either: A) ingame console 'loadscript'; B) RoR.cfg 'app_custom_scripts'; C) commandline '-runscript'.
GADGET, //!< Associated with a .gadget mod file, launched via UI or any method given below for CUSTOM scripts (use .gadget suffix - game will fix up category to `GADGET`).
CUSTOM //!< Loaded by user via either: A) ingame console 'loadscript'; B) RoR.cfg 'app_custom_scripts'; C) commandline '-runscript'; If used with .gadget file, game will fix up category to `GADGET`.
};

const char* ScriptCategoryToString(ScriptCategory c);
Expand All @@ -80,7 +81,8 @@ struct ScriptUnit
AngelScript::asIScriptFunction* eventCallbackExFunctionPtr = nullptr; //!< script function pointer to the event callback function
AngelScript::asIScriptFunction* defaultEventCallbackFunctionPtr = nullptr; //!< script function pointer for spawner events
ActorPtr associatedActor; //!< For ScriptCategory::ACTOR
Ogre::String scriptName;
CacheEntryPtr originatingGadget; //!< For ScriptCategory::GADGET ~ determines resource group
Ogre::String scriptName; //!< Name of the '.as' file exclusively.
Ogre::String scriptHash;
Ogre::String scriptBuffer;
};
Expand All @@ -89,7 +91,7 @@ typedef std::map<ScriptUnitID_t, ScriptUnit> ScriptUnitMap;

struct LoadScriptRequest
{
std::string lsr_filename; //!< Load from resource (file). If buffer is supplied, use this as display name only.
std::string lsr_filename; //!< Load from resource ('.as' file or '.gadget' file); If buffer is supplied, use this as display name only.
std::string lsr_buffer; //!< Load from memory buffer.
ScriptCategory lsr_category = ScriptCategory::TERRAIN;
ActorInstanceID_t lsr_associated_actor = ACTORINSTANCEID_INVALID; //!< For ScriptCategory::ACTOR
Expand Down Expand Up @@ -181,13 +183,13 @@ class ScriptEngine : public Ogre::LogListener

/**
* Loads a script
* @param scriptname filename to load; if buffer is supplied, this is only a display name.
* @param filename '.as' file or '.gadget' file to load; if buffer is supplied, this is only a display name.
* @param category How to treat the script?
* @param associatedActor Only for category ACTOR
* @param buffer String with full script body; if empty, a file will be loaded as usual.
* @return Unique ID of the script unit (because one script file can be loaded multiple times).
*/
ScriptUnitID_t loadScript(Ogre::String scriptname, ScriptCategory category = ScriptCategory::TERRAIN,
ScriptUnitID_t loadScript(Ogre::String filename, ScriptCategory category = ScriptCategory::TERRAIN,
ActorPtr associatedActor = nullptr, std::string buffer = "");

/**
Expand Down

0 comments on commit 02ca86b

Please sign in to comment.