Skip to content

Creating new addons

shadowcookie edited this page Jan 15, 2025 · 10 revisions

The content editor expects a specific flow for optimal behavior for addons. The gist of it is:

  • an addon registers its entity types (udb.register_entity_type('entity_type'))
  • the content editor waits for at least one frame to ensure all installed addons managed to execute, and then for some of the game data to be properly setup (in the rare case of super early REF startup)
  • the content editor emits the 'get_existing_data' event, at this point every addon that is listening to this event should fetch any existing data from the game catalogs etc
  • after that, all activated bundles are loaded in the user defined order
  • the importer function is called for each imported entity, given both the import data and the existing instance as a parameter (so there's no need to keep separate track of objects)

Every editor will generally have 4 parts to it. Depending on the specific type of data, some of these steps can work differently or be done in the same step.

  1. getting existing game data
  2. updating game data
  3. data serialization (import/export)
  4. editor UI

For a holistic view, it may help to look at some of the existing editors, e.g. https://github.com/kagenocookie/dd2-content-editor/blob/0dfc82668e4d2848bbdff3370e62842bdb247cf5/reframework/autorun/editor_shops.lua.

Additionally, all of the content editor API is accessible globally through the usercontent variable. It can be used for prototyping through the console or easier access to any part of the API.

Defining the entity

Start by defining the entity that is to be edited. The minimum requirement is having a way to get some sort of unique integer ID. Most objects in RE engine tend to have some sort of numerical ID (like an enum), in which case using that is the best solution. Failing that, sometimes there's a System.Guid instead, which could be converted to an integer maybe through the mData4L field. If there is neither then maybe have some combination of fields that are checked for equality when attempting to modify or add new objects.

DD2 shop basic example:

local udb = require('content_editor.database')
local import_handlers = require('content_editor.import_handlers')

udb.register_entity_type('shop', {
    export = function (instance)
        return {
            -- this automatically converts the app.ItemShopParam instance to a json compatible object
            data = import_handlers.export(instance.runtime_instance, 'app.ItemShopParam')
        }
    end,
    import = function (data, entity)
        -- the entity instance received here can be either an empty one when creating a new entity (meaning it only has the ID and type set), or a full existing one
        -- this converts the json object from the export function back into the game class structure
        -- by passing in the existing runtime instance in the 3rd parameter we already update all the data into the instance if it exists
        entity.runtime_instance = import_handlers.import('app.ItemShopParam', data.data, entity.runtime_instance)
        -- if the shop does not exist and the game stores them in a dictionary, then we'd add it to its catalog here, this here is just illustrative and not actually functional
        if not ShopManager._ShopData:ContainsKey(data.id) then
            ShopManager._ShopData[data.id] = entity.runtime_instance
        end
    end,
    -- the content editor supports different kinds of delete types depending on what is possible to do safely, if no delete is supported the method can just be left unspecified, which will be treated as not deletable.
    delete = function (entity)
        return 'not_deletable'
    end,
    -- generates an automatic label for the entity, if you want to show more than just entity type + ID, maybe add the name of the object or similar
    generate_label = function (entity)
        return 'Shop ' .. entity.id .. ': ' .. entity.runtime_instance:get_ShopName()
    end,
    -- we need to define a range in which custom object IDs can be generated. This should be defined such that it minimizes the odds of conflicting with base game objects
    -- if the IDs are sequential, maybe just make the range start at something at least 3x higher than the basegame max id
    -- if the IDs are randomized (hashes), consider making the IDs lower than the lowest non-zero hash number
    insert_id_range = {1000, 999000},
    -- the types for which to pre-generate type cache data, should basically be a list of types that the entity references
    root_types = {'app.ItemShopParam'},
    -- if the IDs are based on an ingame enum, it should be set here, otherwise can be left unset
    -- this will also replace the selections on any reference of the enum with the entity labels and include any custom entities
    replaced_enum = 'app.QuestDefine.ID',
})

Fetching and updating game data

Getting game data:

  • if the data is accessible globally (e.g. from a singleton), you can use the get_existing_data event
  • if the data is loaded by the game on demand as needed, a hook will be needed that registers and/or updates the entity

Globally accessible data

The easiest case is when the data is globally accessible from a catalog. If there's multiple object related to the entity sharing the same ID, they should be fetched and grouped in here as well before being registered. Example:

local udb = require('content_editor.database')

udb.events.on('get_existing_data', function ()
    local ItemManager = sdk.get_managed_singleton('app.ItemManager')
    local dataRoot = ItemManager.ItemShopData ---@type REManagedObject|table
    for _, shop in ipairs(dataRoot._Params:get_elements()) do
        udb.register_pristine_entity({
            id = shop._ShopId,
            type = 'shop',
            runtime_instance = shop,
        })
    end
end)

For updating the data, usually I find that if it's a System.Collections.Generic.Dictionary or System.Collections.Generic.List, I just add new entries during the entity's import method. If it's an array, then the entities_created and entities_updated events can be used instead, since arrays are immutable, so we don't create new arrays for each single imported entity.

data contains an object in the structure {[entity_type] = {entity1, entity2}}, with a separate array for each unique type of entity that was imported in this batch. During a full bundle load, the event is triggered only for entities whose instance changed during the bundle import, meaning either a new entity that is not yet present in the game data, or a new entity instance was created/replaced. It is also triggered for any single load or reimport of an entity.

local udb = require('content_editor.database')
local helpers = require('content_editor.helpers')

udb.events.on('entities_created', function (data)
    if data.shop and #data.shop > 0 then
        local shopInstances = utils.pluck(data.shop, 'runtime_instance')
        -- this function may also want to filter out instances that are already stored in the array
        ItemManager.ItemShopData._Params = helpers.expand_system_array(ItemManager.ItemShopData._Params, shopInstances, 'app.ItemShopParam')
    end
end)

Data accessible on demand

So far I've found two patterns

  • all the data is loaded on game load and just not yet accessible in the main menu (for example, quest data)
  • each individual entity is loaded on demand as needed (enemy spawns, enemy data, objects tied to specific areas)

For quests, the data is globally accessible but only after the game is loaded. This means we can find all the data by going through the quest root scene. Example: https://github.com/kagenocookie/dd2-content-editor/blob/master/reframework/autorun/quest_editor/quest_processors.lua

For others, the entities only get loaded individually and are not globally accessible. In this case, the following update hook could also serve as the get hook.

For updating, both of these cases need to define a hook at which point the data should get injected. Depending on how the method works, the real logic may need to be either in the prehook or the post hook.

Do note that for hooks that get called during gameplay and not just load screens, it may end up hurting performance if it's a lot of data that gets imported each time, since the default importer does a full import and export of all the data. The better solution then may be to do some manual work to only modify the fields that actually matter instead of relying on the content editor provided automatic serialization of entities.

DD2 quest processors example:

local udb = require('content_editor.database')

sdk.hook(
    sdk.find_type_definition('app.ProcessorFolderController'):get_method('collectProcessors'),
    function (args)
        thread.get_hook_storage().this = sdk.to_managed_object(args[2])
    end,
    function (ret)
        local this = thread.get_hook_storage().this
        local questId = this._QuestController._QuestID

        --- @type QuestProcessorData[]
        local editedProcessors = udb.get_entities_where('quest_processor', function (proc)
            --- @cast proc QuestProcessorData
            return proc.raw_data.questId == questId
        end)

        for _, proc in ipairs(editedProcessors) do
            -- implementation here is deferred to a separate function, exact implementation entirely depends on the specific entity
            if not proc.disabled then
                importer.quest.processor(proc, this)
            end
        end
        return ret
    end
)

Example for enemy spawns:

local udb = require('content_editor.database')
local import_handlers = require('content_editor.import_handlers')

sdk.hook(
    sdk.find_type_definition("app.EnvironmentSceneController"):get_method("requestGenerateData()"),
    function (args)
        local EnvSceneController = sdk.to_managed_object(args[2]) --[[@as app.EnvironmentSceneController]]
        local table = EnvSceneController._ManualSetTableListData
        local id = table._Guid.mData4L

        local entity = udb.get_entity('enemy_spawn_table', id)
        if entity then
            -- update the instance reference on the content editor entity
            entity.runtime_instance = table
            -- this will update all the data with whatever's in our modded data
            import_handlers.import('app.GenerateTableListData', entity.data, entity.runtime_instance)
        else
            entity = udb.register_pristine_entity({
                id = id,
                type = 'enemy_spawn_table',
                data = import_handlers.export(table, 'app.GenerateTableListData', { raw = true }),
                runtime_instance = table,
                label = table:get_Path()
            })
        end
    end
)

Defining the editor

Most of the editors are locked behind the core.editor_enabled setting which allows us to skip editor setup and optimize some things which may lead to better performance and less bug reports for users. This setting could also be used to skip registering unknown entities that are only needed when editing and reduce memory consumption on that front as well.

local core = require('content_editor.core')
local udb = require('content_editor.database')

if core.editor_enabled then
    local enums = require('content_editor.enums')

    local editor = require('content_editor.editor')
    local ui = require('content_editor.ui')

    -- this defines an editor window
    editor.define_window('shop', 'Shops', function (state)
        -- the state parameter can be used to store any semi-permanent UI settings and preferences

        -- show a CREATE NEW button with a preset picker when a bundle is selected for editing
        if editor.active_bundle then
            local create, preset = ui.editor.create_button_with_preset(state, 'shop')
            if create then
                local newEntity = udb.insert_new_entity('shop', editor.active_bundle, preset or {})
                ui.editor.set_selected_entity_picker_entity(state, 'shop', newEntity)
            end
        end

        -- easy entity selector
        local selectedShop = ui.editor.entity_picker('shop', state)
        if selectedShop then
            --- @cast selectedShop ItemShopData
            imgui.spacing()
            imgui.indent(8)
            imgui.begin_rect()
            -- this shows the select bundle, entity label and save buttons
            ui.editor.show_entity_metadata(selectedShop)
            -- pop the runtime object directly editable straight into the UI, including detection when changes are made
            ui.handlers.show_editable(selectedShop, 'runtime_instance', selectedShop, nil, 'app.ItemShopParam')
            imgui.end_rect(4)
            imgui.unindent(8)
        end
    end)

    -- show the editor as a tab on the main content editor window
    editor.add_editor_tab('shop')
end

For changing how certain objects are serialized as well as UI display, the content editor supports additional overrides per class and per field to make things easier to deal with. See also https://github.com/kagenocookie/dd2-content-editor/wiki/UI-extensions.

local definitions = require('content_editor.definitions')
local enums = require('content_editor.enums')
local ui = require('content_editor.ui')

definitions.override('', {
    ['app.ItemShopBuyParam'] = {
        fields = {
            _ItemId = { uiHandler = ui.handlers.common.enum('app.ItemIDEnum') }
        },
        toString = function (value) return 'Buy ' .. enums.get_enum('app.ItemIDEnum').get_label(value._ItemId) end
    },
    ['app.ItemShopSellParam'] = {
        fields = {
            _ItemId = { uiHandler = ui.handlers.common.enum('app.ItemIDEnum') }
        },
        toString = function (value) return 'Sell ' .. enums.get_enum('app.ItemIDEnum').get_label(value._ItemId) end
    },
    ['app.ItemShopParam'] = {
        fields = {
            _ShopNameId = { extensions = { { type = 'translate_guid' } } },
        },
    },
})

Clone this wiki locally