For Developers/API Registries·匠

API Registries

Listening to events lets you reshape what Cultivation already does. The registries here let you add content that the mod then treats as its own - a race that appears in the race menu, a technique usable through every technique trigger, a page on the nav bar, an article in the Codex, a title on the picker, a banner a sect can fly, a palette that re-colors every menu.

CultivationAPI has 30 register* methods (33 counting overloads), and 23 of them have an unregister* twin. Every one is below, with what it registers and the section that explains it:

MethodReturnsRegistersSection
registerRace(String id, String displayName, String translationKey, CultivationRealm unlockRealm, Supplier<RaceConfig> stats)PlayerRaceA playable race.Registering a race
registerTechnique(String id, String displayName, String nameKey, String descriptionKey, TechniqueRule defaultRule, TechniqueEffect effect)TechniqueA performable technique.Registering a technique
registerTechniqueGate(String id, String failureKey, TechniqueGate.Condition condition) (+1 overload)TechniqueGateA runtime condition a technique can require.Technique gates
registerQiAbsorptionItemModifier(String itemId, float multiplier)A Spirit Vein absorption multiplier for a held item.Registering a Qi absorption item
registerAdminConfigSection(AdminConfigSection section)A group of tunable rows on the admin menu's Config tab.Registering admin settings
registerPlayerAdminAction(PlayerAdminAction action)A per-player row on the admin menu's Players tab.Registering a player admin action
registerIdentitySection(IdentitySection section)A tab on the Identity page, after Race and Titles.Identity sections
registerMenuPage(CultivationMenuPage page)A button on the nav bar every Cultivation menu carries.Menu pages
registerCodexEntry(CodexEntry entry)An article in the in-game Codex.Codex entries
registerCodexCategory(CodexCategory category)A heading in the Codex index.Codex categories
registerRevealableSystem(RevealableSystem system)A system Progressive Reveal can hide until it becomes relevant.Revealable systems
registerTitle(CultivationTitle title)A cosmetic title.Registering a title
registerSectBanner(SectBanner banner)A banner a sect can fly over its hall.Registering a sect banner
registerPalette(CultivationPalette palette)A menu and HUD palette.Registering a palette
registerPaletteDefault(String key, CultivationPaletteDefault rule)A rule that picks a palette for players who have not chosen one.Palette defaults and locks
registerPaletteLock(CultivationPaletteLock lock)A rule that forces a palette on some players.Palette defaults and locks
registerMeditationAura(CultivationMeditationAura aura)A meditation, breakthrough and advancement particle look.Meditation auras
registerOffRingRoot(DaoRoot root)A claim on an off-ring DaoElement slot.Off-ring roots
registerPersonalDao(PersonalDao dao)A Personal Dao (a Way comprehended through deeds).Personal daos
registerBeastArt(String id, String displayName, String nameKey, String descriptionKey, BeastArtRule defaultRule, BeastArtEffect effect)BeastArtA companion-side art.Beast arts
registerSectBuildingType(SectBuildingType type)A kind of sect building.Sect buildings
registerLifeBoundTrait(LifeBoundTrait trait)A nature a bound treasure can reveal.Life-Bound traits
registerMasteryStage(MasteryStageRule stage)booleanAn extra rung on the technique mastery ladder.Mastery rungs
registerModifierSource(String key, CultivationModifierSource source)A standing per-player modifier over Cultivation's systems.Standing modifiers
registerStoreBenefit(StoreBenefit benefit)A web-store product tied to an in-game benefit.Store benefits
registerTechniquePresetCap(String key, int cap)A higher cap on keybind layouts per player.Raising the caps
registerProfileCap(String key, int cap)A higher cap on profiles per player.Raising the caps
registerUpdateCheck(String modId, String displayName, String currentVersion, String manifestUrl, String downloadUrl) (+1 overload)UpdateCheckYour mod in the update check.Update checks
registerBuildCheck(String modId, String displayName, String version, Path jarPath, String manifestUrl)BuildCheckYour mod in the build check.Build checks
registerCompatCheck(String modId, String displayName, String version, String manifestUrl, Consumer<CompatCheck> onVerdict) (+1 overload)CompatCheckYour addon in the compatibility check.Compat checks

All of them are safe to call from your own plugin's setup() in any load order relative to Cultivation's own setup(). The registries are plain static structures that nothing reads until a player actually interacts - opens the race menu, meditates, performs a technique, opens the Codex - which only happens well after every plugin has finished loading. Withdraw anything you registered from your shutdown() with the matching unregister*, so a reload does not leave an entry pointing at classes that are gone.

Re-registering the same id for a race, a technique or a beast art is a no-op: it returns the existing entry rather than erroring, so it is safe across a reload of your plugin. For most other registries, registering an id that already exists replaces the earlier entry - which is also how a mod restyles or takes over one of Cultivation's own built-ins rather than adding beside it.

Two whole-layer takeovers are installed rather than registered - setProgressionProvider and setTheme - and are covered on API Addons. The full method list, including the readers, is on API Reference.

Registering a race

@Nonnull
public static PlayerRace registerRace(@Nonnull String id, @Nonnull String displayName, @Nullable String translationKey,
                                      @Nonnull CultivationRealm unlockRealm, @Nonnull Supplier<RaceConfig> stats)
ParameterDescription
idA stable, unique id, not shown to players. Namespace it with your mod's name - "MyMod:Vampire" - to avoid colliding with another mod's race of the same short name.
displayNameThe name shown in the UI when no translationKey is given, or as a fallback if the key does not resolve for a player's locale.
translationKeyA server.lang key for the localized name, or null to always show displayName as plain untranslated text.
unlockRealmThe realm a player's cultivation must reach before this race can be chosen from the menu.
statsSupplies this race's stat bonuses each time they are needed.

The stats supplier is called live, so back it with your own plugin's withConfig(name, RaceConfig.codec(...)) for a server-owner-editable JSON file, or just () -> myConstantConfig for a fixed one. unlockRealm only seeds RaceConfig's Unlock-Realm when the supplied config does not already specify one - a caller backing their stats with their own JSON file, which may itself set Unlock-Realm, stays server-owner-editable.

RaceConfig has getters and setters for each of its tunables: getDescription / setDescription, getUnlockRealm / setUnlockRealm, getHealthBonusPercent / setHealthBonusPercent, getDamageBonusPercent / setDamageBonusPercent, getQiGainRatePercentBonus / setQiGainRatePercentBonus, getBreakthroughDurationPercentReduction / setBreakthroughDurationPercentReduction, and getQiAlignmentYinBiasPercent / setQiAlignmentYinBiasPercent. Those map one-to-one onto the Health-Bonus-Percent, Damage-Bonus-Percent, Qi-Gain-Rate-Percent-Bonus, Breakthrough-Duration-Percent-Reduction and Qi-Alignment-Yin-Bias-Percent keys in the built-in race config files on the Config page.

Worked example - a Vampire race unlocked at Nascent Soul, with fixed stats:

import plugin.siren.API.CultivationAPI;
import plugin.siren.ECS.Races.PlayerRace;
import plugin.siren.ECS.Realms.CultivationRealm;
import plugin.siren.Utils.Config.RaceConfig;

public final class MyRaces {

    public static PlayerRace VAMPIRE;

    public static void register(){
        VAMPIRE = CultivationAPI.registerRace(
                "MyMod:Vampire",
                "Vampire",
                "server.mymod.race.vampire",
                CultivationRealm.NASCENT_SOUL,
                MyRaces::vampireStats);
    }

    private static RaceConfig vampireStats(){
        RaceConfig config = new RaceConfig();
        config.setDescription("Blood-drinkers. Frail, but they draw Qi twice as fast.");
        config.setHealthBonusPercent(-10.0F);
        config.setDamageBonusPercent(15.0F);
        config.setQiGainRatePercentBonus(100.0F);
        config.setBreakthroughDurationPercentReduction(0.0F);
        config.setQiAlignmentYinBiasPercent(80.0F);
        return config;
    }
}

The Qi-Alignment-Yin-Bias-Percent value feeds straight into the Yin-Yang balance described on The Dao page, so a race registered this way also biases which moral path its members drift toward.

Registering a technique

@Nonnull
public static Technique registerTechnique(@Nonnull String id, @Nonnull String displayName, @Nullable String nameKey,
                                          @Nullable String descriptionKey, @Nonnull TechniqueRule defaultRule,
                                          @Nonnull TechniqueEffect effect)

This is the same system the built-in One Step, a Thousand Li uses. Once registered, the technique is usable through every technique trigger automatically:

ParameterDescription
idA stable, unique id. It doubles as the config key and the activation item's TechniqueId. Namespace it - "MyMod:flame_step".
displayNameShown when no nameKey is given, or as a fallback if the key does not resolve.
nameKeyA server.lang key for the localized name, or null to show displayName raw.
descriptionKeyA server.lang key for the description, or null.
defaultRuleThe rule the technique runs by. This is the only source of rules for your technique unless a server owner adds a matching override entry to Cultivation's TechniqueConfig.json.
effectWhat performing it does. Only ever invoked after all gates pass and the Qi cost and cooldown have been applied.

TechniqueEffect is a @FunctionalInterface with one method, void execute(TechniqueContext context), so a lambda or method reference is enough. Everything the effect needs is on the context: getAccessor(), getRef(), getPlayerRef(), getTechnique(), getRule(), getParam(String key, float fallback), getCultivation(), getRealmIndex(), getStageIndex(), getPosition(), getLookDirection(), getWorld(), teleport(Vector3d), spawnParticle(String particleId, Vector3d position) and sendMessage(Message). The vectors are org.joml.Vector3d, so read their components with x(), y() and z(); getPosition() returns null when the entity has no transform, so guard it before use in production code.

Build the rule with the helper rather than the constructor:

@Nonnull
public static TechniqueRule newTechniqueRule(@Nonnull String id, boolean enabled, boolean daoSpecific,
                                             @Nullable String requiredElement, @Nullable String elements,
                                             @Nullable String damageType, @Nonnull String unlockRealm,
                                             float qiCost, float cooldownSeconds, @Nonnull Object... params)
ParameterDescription
requiredElementA DaoElement enum name, e.g. "WIND", when daoSpecific is true; otherwise "" or null.
elementsComma-separated DaoElement names the technique "carries" - metadata and flavor - or "".
damageTypeA DamageCause asset id for a damaging technique, or "" for none.
unlockRealmThe CultivationRealm enum name required to use it, e.g. "QI_CONDENSATION".
paramsAlternating key/value pairs your effect reads back with context.getParam(key, fallback). Must be an even number of arguments: String, float, String, float, ....

Worked example - a Flame Step that carries the Fire element and teleports the cultivator forward:

import org.joml.Vector3d;
import plugin.siren.API.CultivationAPI;
import plugin.siren.ECS.Technique.Technique;
import plugin.siren.Utils.Config.TechniqueRule;

public final class MyTechniques {

    public static Technique FLAME_STEP;

    public static void register(){
        TechniqueRule rule = CultivationAPI.newTechniqueRule(
                "MyMod:flame_step",
                true,                 // enabled
                true,                 // daoSpecific
                "FIRE",               // requiredElement
                "FIRE",               // carried elements
                "",                   // damageType - none
                "QI_CONDENSATION",    // unlockRealm
                25.0F,                // qiCost
                8.0F,                 // cooldownSeconds
                "Distance", 12.0F);   // params

        FLAME_STEP = CultivationAPI.registerTechnique(
                "MyMod:flame_step",
                "Flame Step",
                "server.mymod.technique.flame_step",
                "server.mymod.technique.flame_step.desc",
                rule,
                context -> {
                    float distance = context.getParam("Distance", 12.0F);
                    Vector3d from = context.getPosition();
                    Vector3d look = context.getLookDirection();
                    context.teleport(new Vector3d(
                            from.x() + look.x() * distance,
                            from.y(),
                            from.z() + look.z() * distance));
                    context.spawnParticle("MyMod:FlameStepBurst", new Vector3d(from));
                });
    }
}

Because daoSpecific is true and requiredElement is "FIRE", only cultivators walking the Fire dao can perform it - see The Dao. Performing it fires TechniqueEvents.PreTechniquePerformEvent and TechniquePerformEvent exactly as a built-in technique does, so other addons can re-price or veto yours too.

To fire it from your own trigger:

boolean performed = CultivationAPI.performTechnique(accessor, ref, playerRef, MyTechniques.FLAME_STEP);

That runs every gate - system enabled, per-technique enabled, realm unlock, dao match, Qi cost, cooldown - and on success deducts the Qi, stamps the cooldown and runs the effect. The player is messaged either way, with the effect's success message or the failure reason. It returns true only when the technique was actually performed.

A technique can also require runtime conditions that only another mod can answer: see Technique gates.

Technique gates

v0.9.1 A TechniqueGate lets another mod veto a technique on a condition only it can see - "is this player currently transformed into some other mod's creature form". The technique names the gate ids it needs on its rule; the gate's owner answers them at cast time.

// 1. Register the gate.
CultivationAPI.registerTechniqueGate(
        "mymod:song_ready",                    // stable id, namespaced with your mod's name
        "server.mymod.gate.songNotReady",      // chat message when it refuses a cast; receives a `technique` param
        "server.mymod.gate.songStatus",        // optional short label for the technique list and keybinds UI
        (accessor, ref, technique) -> MySongs.isReady(accessor, ref));

// 2. Require it on a technique's rule.
TechniqueRule rule = CultivationAPI.newTechniqueRule(/* ... */)
        .requiresGates("mymod:song_ready");

Registering a Qi absorption item

public static void registerQiAbsorptionItemModifier(@Nonnull String itemId, float multiplier)

This is the exact mechanism behind the built-in Qi Gathering Talisman: while the item is in a meditating player's active hotbar slot, their Spirit Vein absorption is multiplied by multiplier. Registering an id that already has a modifier overwrites it, so you can also re-tune the built-in one.

CultivationAPI.registerQiAbsorptionItemModifier("MyMod:JadePendant", 1.75F);

Server owners see and edit the built-in entries under the Qi-Absorption-Item-Modifiers key on the Config page; the mechanic itself is described on Qi Gathering.

Registering admin settings

v0.6.1 An add-on can put its own tunable values into Cultivation's admin menu, where they sit on the section rail beside Cultivation's own and are edited and saved exactly the same way. Both host pages are gated on cultivation.admin, so a section may expose real balance numbers.

CultivationAPI.registerAdminConfigSection(
    CultivationAPI.newAdminConfigSection("MyMod:power",
            "server.mymod.admin.power", "server.mymod.admin.powerHint",
            AdminConfigSection.SORT_LAST, config::save,
            List.of(
                CultivationAPI.newAdminConfigField("MyMod:BaseXp",
                        Message.translation("server.mymod.admin.baseXp"),
                        () -> config.get().getBaseXp(),
                        value -> config.get().setBaseXp((float) value)),
                CultivationAPI.newAdminBooleanField("MyMod:Enabled",
                        Message.translation("server.mymod.admin.enabled"),
                        () -> config.get().isEnabled(),
                        config.get()::setEnabled))));

Five kinds of row are available, each backed by the vanilla widget of that shape:

FactoryWidget
newAdminConfigFieldA decimal number field. The default, and what every pre-0.6.1 field is.
newAdminIntFieldA whole-number field - for a count, a level cap or a tier, where two decimal places are noise.
newAdminBooleanFieldA real checkbox. Before this, a master switch had to masquerade as a 0/1 number, which is why so many of Cultivation's own were config-file only.
newAdminChoiceFieldA dropdown over AdminConfigChoices - the right shape for anything enum-valued, where a text field would let an admin type something that resolves to nothing.
newAdminTextFieldFree-form text.

withTooltip(field, String) adds a one-line explanation to any of them.

Read through the supplier, never a captured config

Every getter and setter above reaches the config through its holder at the moment it is called. A reload replaces the instance behind the holder, and a captured config object would then be a discarded copy whose edits go nowhere. This is the same rule CultivationConfigs documents.

A tooltip is a plain String and therefore cannot be translated - the underlying property is a string client-side, and a rich message pushed at it disconnects the player. Put anything that must be readable in every language in the field's label, which does take a Message.

v0.6.1 Cultivation's own 62 sections (53 built in, plus 9 registered by its own subsystems) are ordinary registrations in this same registry, so an add-on can reorder, hide or replace any of them — registering under an existing key replaces it. Sections also carry getSortOrder() (Cultivation's own claim SORT_BUILTIN_FIRST upward in steps of 100) and isVisible(), which is how the Endless Leveling section hides itself when that mod is not installed.

Registering a player admin action

v0.9.1 registerPlayerAdminAction is the per-player analog of registering admin settings above: instead of a server-wide value, an add-on that owns a concept entirely its own - a bloodline, a bonded flame, anything Cultivation has no built-in row for - gets a row on /cultivation admin's Players tab, targeting whichever player the admin has selected.

PlayerAdminAction is an interface you implement; there is no factory method for it.

CultivationAPI.registerPlayerAdminAction(new PlayerAdminAction(){
    @Override public String getKey(){ return "MyMod:setFlame"; }
    @Override public Message getLabel(){ return Message.translation("server.mymod.admin.setFlame"); }
    @Override public Message getButtonLabel(){ return Message.translation("server.mymod.admin.setFlameButton"); }
    @Override public String getTooltip(){ return null; }
    @Override public List<AdminConfigChoice> getChoices(){ return MyFlames.asChoices(); }
    @Override public String getDefaultValue(){ return "none"; }

    @Override
    public void apply(Store<EntityStore> targetStore, Ref<EntityStore> targetRef, PlayerRef targetPlayerRef,
                      PlayerRef actingAdmin, boolean targetingSelf, String value){
        // apply the change directly through targetStore - see the note below
    }
});

The row renders as a dropdown built from getChoices() plus an inline apply button - not a plain text field. Build the options with AdminConfigChoice.translated(id, labelKey), AdminConfigChoice.raw(id, displayName) or AdminConfigChoice.of(enumConstant, labelKey). The choices are re-read on every render, so a set that depends on what your mod has registered stays current. Two methods have defaults: getSortOrder() (AdminConfigSection.SORT_LAST) and isVisible() (true, read on every render so an action belonging to a switched-off subsystem can hide itself).

Its key doubles as the action string the button sends back and must contain a : and must not equal one of the reserved built-in keys ("setrealm", "setlevel", ...). A bad key is rejected with a logged warning rather than crashing the panel. getTooltip() is a plain String and therefore cannot be translated.

Write straight through the Store here

apply is handed a real Store, not a CommandBuffer - writing directly through it, including Store.putComponent, is the correct, established pattern for this one interface, unlike the "route structural writes through a CommandBuffer" rule that applies to ticking systems and listeners. apply also runs on the target player's world thread, which may differ from the admin's, so report outcomes only through PlayerRef#sendMessage. The value arrived from a client: re-resolve it against your own known values rather than trusting it.

Registering the same key twice replaces the first; unregisterPlayerAdminAction(key) withdraws it from your shutdown().

Registering a title

v0.7.0 registerTitle puts a cosmetic title of your own on the Titles page, where it is picked, shown above heads, in chat, and on the rankings exactly like a built-in one. Titles are purely cosmetic - nothing in this registry grants a stat, a permission side-effect, or any gameplay change.

CultivationAPI.registerTitle(CultivationTitle.builder("MyMod:dragonslayer")
        .name("server.mymod.title.dragonslayer")          // server.lang key
        .section("server.mymod.title.section")            // picker group caption
        .unlocked((store, ref, player) -> MyDeeds.hasSlainDragon(player))
        .hint("server.mymod.title.dragonslayer.hint")     // shown greyed until earned
        .build());

CultivationTitle.builder(key) takes:

Builder methodDescription
name(String) / name(Supplier<Message>)The display name - a server.lang key, or a supplier for parameterised names (the built-in element titles use one for {element} Dao).
section(String)A caption key grouping titles on the picker. Drawn when it differs from the previous entry, so keep registrations of one section together.
unlocked(UnlockCheck)The earned gate: boolean test(Store, Ref, PlayerRef). A locked title stays on the picker, grayed, with the hint explaining what earns it. Omit it and the title is available to everyone who can see it.
hint(String) / hint(Supplier<Message>)The line shown on the locked tile.
permission(String) / visible(Predicate<PlayerRef>)The visibility gate: both must pass or the title is hidden from that player entirely. This is the operator/feature switch - use unlocked for anything a player can work toward.

Reads: getTitles(), getTitle(String), and getTitle(store, ref) for a player's equipped title. Registering an existing key replaces it; unregisterTitle withdraws one. Note the equipped title is stored on the player's settings, not their profile - switching profiles keeps it - and the mod deliberately does not re-check unlocked at read time, so a title that was earnable when equipped stays shown.

One engine caveat worth knowing: the overhead name is written through the shared PersistentDisplayName component, so a nickname mod that also writes it will overwrite, or be overwritten by, the title.

Registering a sect banner

v0.7.0 registerSectBanner adds a banner sects can fly over their hall, beside the built-in six.

CultivationAPI.registerSectBanner(SectBanner.builder("MyMod:crimson")
        .name("server.mymod.banner.crimson")
        .section("server.mymod.banner.section")
        .swatch(0xD8452E)                       // RGB on the picker tile and map pin
        .particle("MyMod_HallBanner_Crimson")   // REQUIRED - the light itself
        .build());

Reads: getSectBanners(), getSectBanner(String) (null-tolerant). Changing a banner fires SectEvents.PreSectBannerChangeEvent / SectBannerChangeEvent - see Events.

Registering a palette

v0.7.0 registerPalette lets a mod re-color every Cultivation menu and the HUD for players who pick it. A palette is mostly re-colored .ui documents, not hex values: you ship recolored copies of Cultivation's pages under one root and declare which files you cover.

CultivationAPI.registerPalette(CultivationPalette.builder("MyMod:moonlit")
        .name("server.mymod.palette.moonlit")
        .swatch(0x8FA8FF)                                  // picker tile
        .documentRoot("Common/UI/Custom/Pages/MyMod/Moonlit/")
        .documents(Set.of("CultivationPage.ui", "CultivationHud.ui"))  // the files you cover
        .halo(SkillTreeBranch.VITALITY, 0x9BD8A0)          // all nine branches, or none
        // ... the other eight branches ...
        .build());

Palette defaults and locks

v0.10.0 Two optional rules sit around a player's own palette choice (the lock arrived in 0.10.0, the default in 0.10.2). A default supplies a look for players who have not chosen one - a race pack styling its own newcomers. A lock forces a look on some players and overrides their choice outright - an off-ring Dao root claiming its own robes. CultivationAPI.getPalette(accessor, ref) asks the lock first, then the player's own stored choice, and only then the defaults.

// A default: only fills the gap where the player has chosen nothing.
CultivationAPI.registerPaletteDefault("mymod:newcomers",
        (accessor, ref) -> MyMod.isNewcomer(accessor, ref) ? "mymod:moonlit" : null);

// A lock: wins over everything while it applies.
CultivationAPI.registerPaletteLock(new CultivationPaletteLock(){
    @Override
    public String lockedPaletteKey(ComponentAccessor<EntityStore> accessor, Ref<EntityStore> ref){
        return MyMod.holdsSignature(accessor, ref) ? "mymod:robes" : null;
    }

    @Override
    public String reasonKey(){
        return "server.mymod.palette.lockReason";   // shown in place of the theme dropdown's usual hint
    }
});

Meditation auras

v0.10.0 A palette re-colors the menus and HUD a player looks at; a meditation aura is what a player looks like while sitting, breaking through or advancing. The two choices are independent: a player can wear the default crimson-and-gold menus and still meditate under someone else's aura.

CultivationAPI.registerMeditationAura(
        CultivationMeditationAura.builder("myMod:frostBreath")
                .name("server.myMod.meditationAura.frostBreath")
                .section("server.myMod.meditationAura.section.winter")
                .swatch(0x9FD8F0)
                .auraPrefix("MyMod_MeditationAura_")
                .build());
Builder methodDescription
name(String)The picker tile's text, a server.lang key.
section(String)Groups the tile under a captioned section in the picker.
swatch(int)The color drawn on the picker tile (default 0xD9A63E).
auraPrefix(String)The id prefix of your meditation-tick particles. The mod asks for MyMod_MeditationAura_Stirring, _Gathering and _Converging as a session deepens, the same way its own three tiers do.
breakthroughParticle(String) / advancementParticle(String)A single optional override for each ritual's pulse. Whichever you leave unset falls through to Cultivation's own.
permission(String) / visible(Predicate<PlayerRef>)Hide the aura from players who fail either gate. Both must pass; isAvailableTo is re-checked when the aura is applied.

build() refuses an aura that overrides nothing - set at least one of the prefix and the two particles, or it would sit in the picker looking choosable and change nothing.

Off-ring Dao roots

v0.10.0 DaoElement is a plain Java enum, so a mod cannot add a constant to it. Cultivation therefore pre-declares the off-ring slots a mod may claim - currently only the joke DUCK - and takes no further opinion on them. Until a mod claims one, the element sits inert: no player can select it, whatever Dao-Mutant-Roots-Enabled says.

CultivationAPI.registerOffRingRoot(
        DaoRoot.builder(DaoElement.DUCK)
                .name("server.myMod.dao.element.duck")
                .counterBonusPercent(35f)
                .build());

Personal Daos

v0.9.0 A Personal Dao is a Way a cultivator comprehends through deeds rather than choosing - the open registry beside the Elemental Dao and the Heavenly Dao. Seven ship built in (Sword, Slaughter, Time-Space, Beast Taming, Devil, Body Refinement and Elemental); a mod registers more. Comprehension itself lives on the player's DaoComprehensionComponent and is written only through CultivationAPI.addPersonalDaoComprehension; the PersonalDao you register describes the Way, not a player's progress on it. Events: DaoComprehensionEvents.

CultivationAPI.registerPersonalDao(PersonalDao.builder("mymod:moon")
        .name("server.mymod.dao.moon")
        .description("server.mymod.dao.moon.desc")
        .howToComprehend("server.mymod.dao.moon.how")    // required
        .manifestThreshold(150f)
        .maxComprehension(600f)
        .manifestation(new PersonalDao.Manifestation(){
            @Override
            public float outgoingDamageMultiplier(ComponentAccessor<EntityStore> accessor,
                    Ref<EntityStore> attackerRef, Ref<EntityStore> targetRef, float fraction){
                return 1f + 0.2f * fraction;             // fraction is comprehension / max, 0 to 1
            }
        })
        .build());
Builder methodDefaultDescription
name, description, howToComprehendname is the raw keyEach takes a server.lang key, a Message, or a Supplier<Message>. howToComprehend is required - a Way with no stated way to earn it is unplayable, and build() throws.
sortOrder(int)SORT_DEFAULT (1000)SORT_FIRST is 100; lower sorts first.
manifestThreshold(float)100Comprehension at which the Dao manifests.
maxComprehension(float)500The ceiling.
hiddenUntilComprehended(boolean)falseHide the Way from menus until the player has some comprehension.
visible(Predicate<PlayerRef>)everyoneHides it from players the predicate rejects.
manifestation(Manifestation)does nothingWhat the manifested Dao pays off with.

PersonalDao.Manifestation has four methods, every one defaulting to "no opinion": outgoingDamageMultiplier(accessor, attackerRef, targetRef, fraction) (runs inside the damage pipeline - keep it cheap and read-only), priceTechnique(accessor, ref, PreTechniquePerformEvent event, fraction) (re-prices one technique performance), onManifested(accessor, ref, playerRef) and onSetAside(accessor, ref, playerRef).

onManifested may receive a raw Store

A manifestation reached from the technique-perform listener, including the charged-release path, runs under the store's processing lock and is handed the raw Store, not a CommandBuffer. Never call putComponent or create a component that might not exist from onManifested: that throws "Store is currently processing!" and takes the world thread down. Read or mutate components you know exist, or send messages and events.

Read the registry with getPersonalDaos() and getPersonalDao(key); getPersonalDaoComprehension, isPersonalDaoManifested and addPersonalDaoComprehension take a key. Registering a key twice replaces the first. unregisterPersonalDao(key) withdraws it.

Registering a beast art

A beast art is the companion-side twin of a technique, and its registry is open for the same reason: a Java enum cannot gain constants at runtime.

BeastArt frostFang = CultivationAPI.registerBeastArt(
        "mymod:frost_fang",
        "Frost Fang",
        "mymod.beast.art.frost_fang.name",          // or null for raw display text
        "mymod.beast.art.frost_fang.description",
        new BeastArtRule("mymod:frost_fang", true, "FOUNDATION_ESTABLISHMENT", 12f, "Ice",
                new TechniqueParam[]{
                        new TechniqueParam("Radius", 4f),
                        new TechniqueParam("BaseDamage", 8f),
                        new TechniqueParam("DamagePerLevel", 0.7f)
                }),
        context -> {
            // Both ends of the bond: getBeastRef() performs it, getOwnerRef() is served by it.
            context.sendMessage(Text.of("mymod.beast.art.frost_fang.playerMsg.hit"));
        });
Registering an art gives it to nobody

An art belongs to a species. Its id has to appear in that species’ Arts list in BeastConfig.json before any creature can learn it. That is deliberate — it lets you ship arts and leave the server owner to decide which creatures get them.

The rule’s UnlockRealm is the beast’s realm, never its owner’s. Its damageType names a DamageCause asset (vanilla’s Fire, Ice, Poison…, or one of Cultivation’s Cultivation_*); a name that resolves to nothing silently falls back to physical rather than erroring, so a typo costs you the element without a log line.

Reading and driving them: getBeastArts(), getBeastArt(id), getBeastArtRule(art), getKnownBeastArts(accessor, ref), and performBeastArt(accessor, ref, art, playerRef) — which runs every gate the mod’s own callers run, so you never reproduce them.

Registering a sect building type

A sect building kind, with its meditation multiplier, the sect level it waits for, and the switch that decides whether its ground carries the sect’s Dao:

CultivationAPI.registerSectBuildingType(
        new SectBuildingType("mymod:frost_terrace", 1.12f, 2, false));
//                            id                   medMult  level  daoActiveByDefault

The last argument is the one to think about. false makes it neutral ground — a disciple of any element can cultivate there without being turned toward the sect’s Dao, which is how a sect takes in a friend who walks a different element. A type meant as a guest hall should default to false.

Registering a kind only makes it known; a server owner still decides whether their sects may raise it. Your entry never overrides a config entry of the same id — the owner’s always wins.

Registering a Life-Bound trait

A nature a bound treasure can turn out to have. It is rolled once, at binding, and never re-rolled:

CultivationAPI.registerLifeBoundTrait(
        new LifeBoundTrait("mymod:frostbite", LifeBoundTrait.Slot.WEAPON,
                           1f, 0.5f, 25f, 1.2f)      // base, perLevel, max, roll weight
                .unlocks("frozen_domain", 9));       // an art it lends while held, from level 9

Two consequences worth planning around. Your trait enters the weighted roll immediately, so it changes what future bindings can produce — treasures already bound keep whatever they rolled, and there is no re-roll anywhere in the mod. And Slot is a real restriction: a WEAPON trait never lands on a breastplate, which is what stops the roll from disappointing half the time.

Read one back with getLifeBoundTraitAmount(stack, "lifesteal"), which returns 0 when the treasure is of a different nature and so is safe to ask about any trait on any item, and getLifeBoundGrantedTechnique(stack) for the art it currently lends.

Registering a mastery rung

The mastery ladder is normally the server’s five configured rungs. An addon may append one:

boolean added = CultivationAPI.registerMasteryStage(
        new MasteryStageRule("Transcendent", "SOUL_FORMATION", 2500f, 8, 2.6f, 0.7f, 0.65f));

It returns false if the ladder is already full rather than silently ignoring you — the ladder is capped at five, because the UI and the lang keys only cover that many. Check the return value.

Registering a standing modifier

New in 0.7.4. The hook for anything that changes what a cultivator is rather than what they do - a bloodline, a constitution, a physique. One registration answers a set of typed channels, and Cultivation asks them at each of its own chokepoints:

CultivationAPI.registerModifierSource("mymod:bloodlines", new CultivationModifierSource() {
    @Override
    public float qiGainMultiplier(ComponentAccessor<EntityStore> accessor, Ref<EntityStore> ref) {
        return Bloodline.of(accessor, ref) == Bloodline.DRAGON ? 1.15f : 1f;
    }

    @Override
    public float damageTakenMultiplier(ComponentAccessor<EntityStore> accessor, Ref<EntityStore> ref) {
        return Bloodline.of(accessor, ref) == Bloodline.DRAGON ? 0.95f : 1f;
    }
});

Every method has a default meaning "no opinion", so you implement only the channels you care about. Eighteen typed channels exist, plus two that contribute rows to the Overview page rather than changing a number:

ChannelCombinesScales or decides
qiGainMultipliermultiplyEvery point of Qi gained, from any source.
meditationQiMultiplier(accessor, ref, boolean inLava)multiplyQi drawn by meditating specifically, on top of the above.
damageDealtMultipliermultiplyAll damage dealt - weapons, techniques and fists alike.
unarmedDamageMultipliermultiplyDamage dealt bare-handed, on top of the above. Never applies to a weapon.
damageTakenMultipliermultiplyDamage taken. Below 1 is protection, above 1 is fragility.
isImmuneToDamageCause(accessor, ref, String damageCauseId)ORWhether a damage cause is ignored outright. A hard immunity - use sparingly.
chargeSecondsMultipliermultiplyHow long a charged art must be gathered. Below 1 is faster.
ignoresDaoElementLockORWhether the cultivator may perform a dao-locked art of an element they have not comprehended.
daoAffinityMultiplier(accessor, ref, DaoElement element)multiplyAffinity gained toward one dao element.
alignmentShiftMultiplier(accessor, ref, boolean towardYin)multiplyHow far a single alignment shift moves them, per direction.
yinYangBalanceToleranceMultipliermultiplyThe width of the band that counts as a balanced Yin-Yang.
ritualDifficultyMultipliermultiplyHow hard a breakthrough or advancement ritual is: its length and the tribulation strikes. Below 1 is easier.
canMeditateInLavaORWhether they may meditate while standing in lava.
talismanPotencyMultipliermultiplyA talisman's potency, on top of the grade's own magnitude multiplier.
beastBonusMultiplier(accessor, ownerRef, BeastSpecies species)multiplyA summoned beast's bonus multiplier, on top of its species and element resonance. ownerRef is the owner, not the beast.
lootRollMultiplier(accessor, ref, ItemEvents.LootType type)multiplyThe chance a cultivation drop rolls at all - the luck channel. The product is clamped into 0 to 1, and it scales the probability, never the quantity.
qiRequirementReductionPercentsumA percentage knocked off the banked Qi the next rank-up costs. See below.
auraScaleMultipliermultiplyThe size of the realm aura. Cosmetic only.
bonuses(accessor, ref)listOverview rows built with CultivationAPI.newBonus. Return an empty list when you grant nothing.
traits(accessor, ref)listNamed Message lines for the Overview page's Constitution section, such as "Ancient Sacred Body". The section hides itself when no source contributes.

lootRollMultiplier is consulted at four roll sites today - a manual from a demonic cultivator, a manual shed by a slain cultivator, the Spirit Stone kill drop and a treasure cache's material roll. The cultivation-core kill drop is not one of them (it rolls which tier, not whether one falls); use ItemEvents.PreLootDropEvent to re-price that. LootType gains constants over time, so add a default arm rather than switching exhaustively.

qiRequirementReductionPercent is the one channel that is summed rather than multiplied, because it feeds a block that is already additive: the skill tree's Qi Cost Reduction and Cultivation Legacy's heir buff are added together and the total is clamped once against Legacy-Min-Qi-Cost-Floor-Percent. Whatever you return joins that sum and is bound by the same floor, so two sources cannot make a breakthrough free. It is inert under a replacement ProgressionProvider, which owns its own curve - say so in your own UI.

Why this and not events

Events are the right tool for reacting to a moment. This is for a standing fact about a player that re-prices a dozen unrelated systems at once - doing that with events would mean a listener on every one of them, each re-deriving the same fact.

Every other multiplier multiplies across all registered sources and booleans OR, so two addons each granting +10% produce ×1.21 rather than ×1.20 - the same way Cultivation's own internal multipliers already stack. A source that throws is caught, logged once and thereafter ignored, so a broken addon cannot take the damage pipeline down with it.

Your methods are called on the world thread, sometimes from inside the damage pipeline or a per-tick system, so keep them to a component read and some arithmetic and never write through the accessor. When nothing is registered every channel returns its neutral value immediately, so a server without such an addon pays one emptiness check per chokepoint. Registering a key twice replaces the first; withdraw it from your shutdown() with unregisterModifierSource, or a reload leaves a source behind pointing at classes that are gone.

Two helpers build on it: CultivationAPI.newBonus(sourceKey, statKey, amount, percent) builds an Overview row (reuse one of Cultivation's own stat keys where it fits, so your row sums with the mod's; rows disagreeing on percent are never summed), and CultivationAPI.applyStatBonus(accessor, ref, statIndex, key, amount) applies a keyed flat bonus to one of the player's engine stats - calling it again with the same key replaces that key's contribution, and 0 withdraws it.

Cultivation: Sacred Bodies is built entirely on this registry and nothing else, and is worth reading as a shape to copy: it answers every channel from a single component read, and contributes its own named lines to the Overview so a constitution shows up as readable text rather than as an unexplained change in the player's numbers.

Registering a celestial event

New in 0.8.0. Celestial Events are an open registry. The four built-ins - Spirit Tide, Meteor Shower, Blood Moon and Starfall Convergence - are registered exactly this way, and an addon's fifth is registered with nothing more privileged:

CelestialManager.registerEventType(new CelestialEventType(
        "mymod:frost_veil",                        // id — lowercase, stable, the /celestial start argument
        "server.mymod.celestial.name.frostVeil",   // lang key for the display name
        "MyMod_Weather_FrostVeil",                 // the sky's Weather asset id
        14f,                                       // duration in minutes
        1f,                                        // pick weight among enabled events
        true));                                    // whether the scheduler may pick it
A thin descriptor, not a callback interface

Registering buys you scheduling, the sky and the announcement for free — everything that is generic to any event. It does not carry your gameplay effect, because the built-ins' own effects are bespoke enough that they are plain static queries on CelestialManager too. Your systems read CelestialManager.active() and apply whatever they like while your id is the one running. It is the same shape SectBuildingType uses for addon sect buildings.

A weight of zero or less is never picked — effectively disabled without unregistering — and enabled: false only gates automatic play: an admin's exact /celestial start <id> still works either way, the same relationship Tribulation-Storm-Enabled has to the storm itself. Registering an existing id replaces it.

Three more types are registered by the subsystems that own them rather than by CelestialManager itself: heavenly_schism by Path War, beast_tide by Beast Tide and beast_omen by the Calamity Beast. The last two register with a weight of zero, so the scheduler never picks them; their own managers start them. Listen to CelestialEvents to see every one.

Registering a store benefit

New in 0.8.0. Ties a product in the Treasure Pavilion to something that happens in game, on any server running Cultivation:

CultivationAPI.registerStoreBenefit(
        StoreBenefit.builder("myMod:store:crown", "my-mod-crown")
                .name("server.myMod.store.benefit.crown")
                .title("server.myMod.title.store.crown")
                .hint("server.myMod.title.storeHint.crown")
                .build());

The first argument is your benefit key, the second the slug the store publishes its entitlement list under — a product slug (my-mod-crown) or, to key off a whole family subscription rather than one product, sub_<familyId> (sub_cultivation). A product sold both ways (subscription-included or outright, e.g. Sacred Bodies) can instead be keyed once_<slug> (once_cultivation-sacred-bodies) to require the outright purchase specifically, excluding anyone who only holds the family subscription — 404 for a product that has no one-time option. name is the label /cultivation store status prints; title optionally names a title unlocked by owning it.

v0.10.3 .hint(...) optionally names a lang key shown on the title's locked tile before it is earned — the same grayed-tile-with-a-reason treatment every other registered title gets, rather than a bare lock. Leave it unset and the tile falls back to a generic "from the Treasure Pavilion" hint.

The shared sync does the fetching, the 6-hour caching, the revoking on refund and the fail-open behavior for you — you are only declaring what the product is. Cultivation's own product is registered through this exact API rather than a private path, which is the usual guarantee that the public route is the real one. A title registered this way disappears from the picker whenever the sync itself is switched off (Web-Store-Enabled: false) or the specific slug it depends on is listed in Disabled-Benefits — a server that refused the benefit never shows the title it would have unlocked.

Registering a mission metric

New in 0.8.0. The sect mission board's Metric field is a free string rather than a closed enum, so the namespace is open even though the shipped credit sources are fixed:

MissionManager.creditProgress(player, "myMod:eventId", amount);

Then a Sect-Mission-Types entry in SectConfig.json naming the same string becomes a real, rank-gated, cooldown-limited mission that pays contribution. Four metrics ship credited by Cultivation's own hooks — gather_item, slay_beast, meditate_seconds and win_duel. A mission Id may not contain :, ; or |, which are the delimiters the persisted progress rows use.

v0.6.0 Every Cultivation menu carries a shared nav bar. registerMenuPage puts a button on it, so a mod's own UI sits beside Overview, Race, Dao and the rest instead of behind a command of its own. The bar scrolls horizontally, so there is no practical limit on how many pages may be added.

CultivationAPI.registerMenuPage(
        CultivationMenuPage.builder("myMod:alchemy")
                .label("server.myMod.nav.alchemy")
                .permission("myMod.alchemy")
                .onOpen((store, ref, playerRef) ->
                        CultivationAPI.openMenuPage(store, ref, new MyAlchemyUIPage(playerRef)))
                .build());
Builder methodDescription
label(String) / label(Message) / label(Supplier<Message>)The button text. Prefer the translation-key form: it is resolved on every draw, so it localizes and picks up an installed CultivationTheme.
sortOrder(int)Position on the bar, lower first. SORT_FIRST is 100, SORT_DEFAULT (the default) is 1000, SORT_LAST is 9000 - where the built-in Admin button sits.
permission(String)Hides the button from, and refuses to open the page for, players without it.
visible(Predicate<PlayerRef>)A gate a permission cannot express, such as a page that only means anything once the player has joined a sect. Combines with permission; both must pass.
onOpen(Opener)What a click does. Required: build() throws without it, and without an id.

Putting the bar on your own page

  1. Give your .ui document a placeholder: Group #NavBar { LayoutMode: Left; Anchor: (Bottom: 4, Height: 46); }.
  2. Call CultivationAPI.buildMenuNav(commandBuilder, eventBuilder, playerRef, "myMod:alchemy", store, ref) from your page's build. The overload with store and ref draws the bar in the viewer's palette; the shorter one always draws the default crimson and gold, which on a themed page is a strip of the old colors.
  3. Add a plain Codec.STRING field to your event codec under CultivationAPI.MENU_NAV_EVENT_KEY. It carries a literal page id, so it takes no @ prefix: .addField(new KeyedCodec<>(CultivationAPI.MENU_NAV_EVENT_KEY, Codec.STRING), (data, value) -> data.nav = value, data -> data.nav)
  4. Hand that field to CultivationAPI.handleMenuNav(store, ref, playerRef, data.nav) from handleDataEvent. It is safe with the null your field holds when the event was one of your own buttons, and with an id the player may not open. tryHandleMenuNav returns whether a page opened; if you call it from a locking nav binding, answer the click yourself on false with sendUpdate(null, null, false) or the client stays on its loading state.

Identity sections

v0.10.0 The nav bar's race button opens the Identity page, which carries a Race tab and a Titles tab. registerIdentitySection adds a third, fourth and so on, always after both built-ins. Race and Titles are not entries in this registry - they stay hardcoded because each carries partial-update logic specific to its content - but every registered section gets its own tab button, toggled the same way.

CultivationAPI.registerIdentitySection(
        CultivationAPI.newIdentitySection("MyMod:classes",
                Message.translation("server.mymod.identity.tabClasses"),
                IdentitySection.SORT_DEFAULT,
                context -> {
                    context.getCommandBuilder().append(context.getContainerSelector(), myDocument);
                    context.bindAction(CustomUIEventBindingType.Activating, "#SomeButton", "pick");
                },
                (context, action, value) -> {
                    if("pick".equals(action)){
                        // handle the click
                    }
                }));

newIdentitySection builds one from a build callback and an optional action handler; the four-argument overload (no handler) is for a section with no interactive content. Implement the IdentitySection interface yourself if you prefer:

MethodDescription
getId()A stable, unique, namespaced id. Must not equal "race" or "titles". What a tab click sends back, and what handleAction is dispatched by.
getLabel()The tab button's text. Use Message.translation(...) so it localizes.
build(IdentitySectionContext)Builds the section's content once per page open, never on a tab switch - switching only flips .Visible on your container, so rebuilding would drop the event bindings you registered.
handleAction(context, action, value)Handles a click or value push from a widget you bound. The default does nothing. value is null for a plain click.
isVisible(PlayerRef)Whether this player gets the tab at all, checked on every build. Defaults to true.
getSortOrder()Order among registered sections (Race and Titles are not part of it). Defaults to SORT_DEFAULT, 1000.

IdentitySectionContext hands build everything it needs: getCommandBuilder(), getEventBuilder(), getStore(), getRef(), getPlayerRef(), getPalette() (resolve your own documents through CultivationAPI.document), getContainerSelector() and getSectionId(). Use selector(childSelector) to scope a selector to your container - never address a bare, unscoped id, because UI ids are global to the loaded page and another section may reuse the same widget name. bindAction(type, childSelector, action) routes a click to handleAction with a null value; bindValueAction(childSelector, action) routes a ValueChanged with the widget's live value, which is the shape a dropdown or text field uses.

All calls run on the viewing player's world thread: read their components, never write to the Store. A section that throws is caught and logged rather than failing the page for every player.

Codex entries

v0.6.0 The in-game Codex is an open registry. An entry is written fresh for each reader, which is the point of an in-game codex over a wiki page: it can state the numbers this server is actually running and what the reader has actually reached.

CultivationAPI.registerCodexEntry(
        CodexEntry.builder("myMod:soulRings")
                .title("server.myMod.codex.soulRings.title")
                .summary("server.myMod.codex.soulRings.summary")
                .category(CodexCategory.SELF)
                .body(page -> page
                        .heading("server.myMod.codex.soulRings.heading")
                        .paragraph("server.myMod.codex.soulRings.body")
                        .stat("server.myMod.codex.soulRings.maxRings", config.getMaxRings())
                        .noteIf(!MySouls.hasAwakened(page.getAccessor(), page.getRef()),
                                "server.myMod.codex.soulRings.locked")));
Builder methodDescription
title(String) / title(Supplier<Message>)The article's name, in the index and at the top of its page. Resolved per draw.
summary(String) / summary(Supplier<Message>)One line under the title saying what the article covers.
category(String)The id of a CodexCategory: one of the four built-ins or your own. Defaults to CodexCategory.PATH.
sortOrder(int)Order within its category. Defaults to SORT_DEFAULT, 10000 - after the built-ins, which are spaced 100 apart.
visible(Predicate<PlayerRef>)Hides the article from readers it rejects. Prefer saying "locked" in the body: a codex that hides what you have not unlocked cannot tell you how to unlock it.
body(Body)Required. Writes the article against a CodexPage. build() throws without a body or an id.

CodexPage collects blocks in order, and the Codex decides how each one looks. Every method returns the page, so calls chain:

MethodBlock
heading(key)A section heading inside the entry.
paragraph(key)A paragraph of body text. Wraps.
stat(labelKey, value)A label and the value it resolves to on this server. value may be a Message, a String, an int or a float (trimmed to as few decimals as it needs).
note(key) / noteIf(condition, key)A dimmed aside - a caveat, or something true only for this reader.
recipe(itemId)A crafting recipe, looked up live from the item's own asset. An item with no recipe renders nothing.
icon(iconPath, nameKey, descriptionKey)A picture beside its name and what it means. iconPath is an asset path from the asset root, never a document-relative ../ path.
divider()A thin rule.

Each text method takes either a full lang key (including the server. prefix) or a Message. The page also carries the reader - getAccessor(), getRef(), getPlayerRef() - and two static helpers, CodexPage.formatNumber(float) and formatPercent(float), which format a configured number the way the Codex shows them (1.5, not 1.50).

Codex categories

A CodexCategory is a heading in the Codex index that groups articles. Four ship with the mod, spaced 100 apart so a mod can slot a group of its own between them: CodexCategory.PATH (realms, Qi, breakthroughs, tribulation), SELF (race, dao, techniques, companions), WORLD (veins, sects, abodes, formations, other cultivators) and CRAFT (alchemy, refinement, manuals). Filing under one of these is usually better for a reader than adding a fifth - someone looking for spirit beasts wants them under Self, not under the name of the mod that happened to add them.

CultivationAPI.registerCodexCategory(CodexCategory.of("myMod:winter", "server.myMod.codex.category.winter", 450));

CodexCategory.of(id, translationKey, sortOrder) takes a lang key; the second overload takes a Supplier<Message>. The label is resolved per draw, so an installed CultivationTheme can re-word it. Registering an existing id replaces it, which is how a group gets renamed or moved. getCodexCategories() returns the headings in display order and unregisterCodexCategory(id) removes one.

Revealable systems

v0.10.3 Progressive Reveal keeps a system out of the menus and the Codex until it becomes relevant to a player - by default, until their highest-ever realm reaches a gate. registerRevealableSystem lets an addon opt its own content in, the same way it would contribute a Codex entry.

CultivationAPI.registerRevealableSystem(
        RevealableSystem.builder("myMod:soulRings")
                .name("server.myMod.reveal.soulRings.name")
                .hint("server.myMod.reveal.soulRings.hint")
                .revealRealm(CultivationRealm.QI_CONDENSATION)
                .codexEntries("myMod:soulRings")
                .build());
Builder methodDescription
name(String) / hint(String)Both required. Lang keys for the short name (the Compass "Newly Opened" title) and the one-line reason it matters.
revealRealm(CultivationRealm) / revealRealm(IntSupplier) / neverByRealm()The realm gate. The IntSupplier form returns a realm ordinal read live, so an admin edit takes effect without a restart. neverByRealm() means only the predicates below can surface it.
unlockedBy(Predicate<PlayerRef>)Reveals the system and announces it; sticky once true.
engagedBy(Predicate<PlayerRef>)Reveals it silently, regardless of realm; sticky once true, never announced on its own.
enabledWhen(BooleanSupplier)Whether the system is switched on at all. Disabled means never announced and never counted as hidden.
codexEntries(String...)Codex entry ids whose visibility this system contributes to. An entry hides only when every system claiming it is hidden.
opensCodex(String) / opensMenuPage(String)What the Compass or notice "open" action deep-links to.
openedNote(String)A lang key for how it was opened ("Opened by Ascension"); the default is the plain "opened at " line.
sortOrder(int)Order among systems. Defaults to 10000.

Update checks

v0.7.1 registerUpdateCheck puts your mod into Cultivation's update check: it fetches the manifest you name on a background thread, and when the installed version is behind the one published there, tells server administrators as they join. Your mod gets a line in the same message Cultivation's own update would appear in, so a server two versions behind on three mods is told once.

CultivationAPI.registerUpdateCheck("SoulRings", "Soul Rings",
        this.getManifest().getVersion().toString(),
        "https://xianxia.dev/api/get/version/SoulRings.json",
        "https://xianxia.dev/");        // where to get the new version; the five-argument overload only

The manifest is a single static JSON object. release is the version you consider current; ignore lists installed versions that are never notified even though they are behind it - a beta you would rather leave alone, or a build whose upgrade path is not ready:

{ "release": "0.7.1", "ignore": ["0.7.0"] }

UpdateCheck is the read-only handle you get back. Its getters are safe from any thread and answer for the most recent completed fetch:

MethodReturns
getModId(), getDisplayName(), getCurrentVersion(), getManifestUrl()What you registered. The display name is plain text, not a language key.
getDownloadUrl()The destination you named, or null.
getLatestVersion()The release the last successful fetch reported, or null if none has succeeded.
isUpdateAvailable()Whether the installed version is behind and not suppressed - the flag the join message is built from.
isSuppressed()Whether an update exists but the installed version is in the manifest's ignore list.
getLastCheckedMillis()When the last fetch finished, or 0.

Build checks

v0.7.1 registerBuildCheck hashes your jar's compiled classes and compares the result against the digests you have published as official, so a jar that was decompiled and rebuilt can be told from the one you shipped. It rides the same background thread and the same Update-Check-Enabled switch as the update check.

CultivationAPI.registerBuildCheck("SoulRings", "Soul Rings",
        this.getManifest().getVersion().toString(),
        this.getFile(),                                  // your plugin's own jar
        "https://xianxia.dev/api/get/build/SoulRings.json");
{ "builds": { "0.7.1": ["a3f2...9c"], "0.7.0": ["71bd...04", "5e90...11"] } }

An array per version, so a version you rebuilt and still consider official can carry more than one digest.

Detection, not protection

Anybody able to rebuild your mod is equally able to delete this call. What it catches is the cases nobody bothered to strip it from - a repackaged upload, a redistributed paid addon, a damaged download. Treat BuildStatus.UNOFFICIAL as a signal, never a lock, and be aware that gating features on it mostly punishes honest servers. The verdict goes to the console and to getBuildStatus(modId); nothing in the game changes and no player is told.

BuildStatus has three states, because "we could not tell" is a real and common answer:

ValueMeaning
UNKNOWNNothing established: the check has not run, the request failed, or the published list has no entry for this version. Never evidence of anything.
OFFICIALThe jar's code matches a digest published as official for its version.
UNOFFICIALThe list carries digests for this version and the installed jar matches none of them. A fact about bytes, not an accusation about a person.

It fails open: an unreachable endpoint, an unreadable jar, or a version you never published a digest for all leave the verdict UNKNOWN. getBuildStatus(modId) reports one mod (UNKNOWN if nothing is registered under that id), getBuildStatus() reports Cultivation itself, and isOfficialBuild() reduces that to a boolean that is false only on a positive mismatch - it deliberately cannot tell "verified" from "could not tell". getBuildChecks() lists every registration and unregisterBuildCheck(modId) removes yours.

BuildCheck's getters are getModId(), getDisplayName(), getVersion(), getManifestUrl(), getLocalDigest() (null if the jar could not be read, which leaves the status UNKNOWN), getStatus() and getLastCheckedMillis().

Compat checks

v0.7.4 The compatibility check publishes which Cultivation versions an addon's installed version works against, so a pairing that turns out to be broken can be corrected after both jars have already shipped. Player-facing description: Compatibility.

This is deliberately the second layer. The first is your own manifest.json, whose Dependencies entry accepts a full semver range rather than only a floor:

"Dependencies": { "Siren:Cultivation": ">=0.11.0 <0.12.0" }

That is the real gate: the engine refuses to load your addon against a Cultivation outside the band, before a line of your code runs and with no network involved. What a shipped manifest cannot do is change its mind - it can only encode what was known on release day. The published matrix is the part that can be edited afterward, with no jar and no release.

CultivationAPI.registerCompatCheck("MyAddon", "My Addon",
        this.getManifest().getVersion().toString(),
        "https://xianxia.dev/api/get/compat/MyAddon.json",
        check -> {
            if(check.getStatus() == CompatStatus.INCOMPATIBLE){
                // Say so in your log and unregister what you registered. Never throw.
                MyAddon.standDown(check.getRequiredRange(), check.getCultivationVersion());
            }
        });

The matrix is keyed by your version. min is inclusive, below is exclusive, and blocked names individual Cultivation versions inside that band which turned out not to work after all. Every field is optional: a range with only min has no ceiling, and one with neither bound means "any Cultivation, except the blocked ones":

{ "compatible": { "0.1.2": { "min": "0.7.4", "below": "0.8.0", "blocked": [] } } }

CompatStatus has three values:

ValueMeaning
UNKNOWNNothing is known, or nothing could be learned: before the first check lands, and whenever the answer cannot be trusted (a dead host, a timeout, a malformed body, an addon version the matrix says nothing about). Treat it as "carry on" - it is the state every offline server sits in permanently.
COMPATIBLEThe matrix names a range for this addon version and Cultivation is inside it.
INCOMPATIBLEThe matrix names a range and Cultivation is outside it, or names this exact Cultivation version as blocked. The only verdict an addon should act on, reached only by a positive statement about this exact pairing - never by absence, never by a failed request.

CompatCheck is the handle you get back: getModId(), getDisplayName(), getVersion() (the key looked up in the matrix), getManifestUrl(), getCultivationVersion() (the one actually running), getRequiredRange() (text such as ">=0.7.4 <0.8.0", or null when the matrix named none), getStatus() and getLastCheckedMillis().

Raising the caps

v0.7.0 Two limits are addon-raisable rather than config keys, through the same register/unregister shape:

MethodDefaultCeilingRaises
registerTechniquePresetCap(String key, int cap)38How many technique keybind loadouts a player may keep.
registerProfileCap(String key, int cap)36How many profiles a player may keep. The test sandbox never counts against it.

The key is an id for your mod ("jadeSlip"). The live value - getMaxTechniquePresets() / getMaxProfiles() - is the highest registered cap, never the sum: two addons that both raise it to six mean six, not twelve. Nothing is destroyed when a cap falls. A player who filled six slots and then lost the addon that allowed them keeps all six; only adding is refused until they are back under the live cap. Register from setup(), withdraw with the matching unregister*Cap in shutdown().

Public types

Every public type in plugin.siren.API that the registries above use, in one place. Events are in API Events; the config holders are in API Reference.

TypeWhat it is
UpdateCheck, BuildCheck, CompatCheckThe read-only handles returned by registerUpdateCheck, registerBuildCheck and registerCompatCheck.
BuildStatus, CompatStatusTheir verdict enums: UNKNOWN, OFFICIAL, UNOFFICIAL and UNKNOWN, COMPATIBLE, INCOMPATIBLE.
CodexCategory, CodexEntry, CodexPageA Codex heading, an article and the block writer its body receives.
CultivationMenuPageA button on the shared nav bar, built with CultivationMenuPage.builder(id).
IdentitySection, IdentitySectionContextA tab on the Identity page and the build-time context it receives.
CultivationMeditationAuraA meditation, breakthrough and advancement look.
CultivationPaletteDefault, CultivationPaletteLockThe two palette rules.
DaoRootA claim on an off-ring Dao element.
RevealableSystemA system Progressive Reveal can hide.
CultivationBonusA record (sourceKey, statKey, amount, percent): one Overview bonus row, built with CultivationAPI.newBonus and returned from CultivationModifierSource.bonuses.
CultivationModifierSourceThe standing-modifier interface.
PlayerAdminAction, AdminConfigChoiceThe per-player admin row and its dropdown options.
TechniquePresetSnapshotA keybind layout as a detached value; see below.
EventBusThe listener-list plumbing behind every *Events class: newListenerList(), dispatch(...) and fire(...). Not a stable API surface - register through the *Events classes instead.
AuctionListingTypeThe two ways an item can be offered on the Auction House: FIXED ("fixed", a buy-now listing) and TIMED ("timed", a competitive-bidding auction). getId(), getTranslationKey() and fromId(String) (unknown or null falls back to FIXED). Closed on purpose, because AuctionManager branches on it throughout; to observe or veto a bid use MarketEvents.PreAuctionBidEvent.
MarketCategoryA tab in the Auction House's category filter row: an id, a per-draw label and a sort order, plus a Predicate<ItemStack> deciding whether an item belongs on it. MarketCategory.of(id, translationKey, sortOrder, matcher).
SectRelationKindThe inter-sect diplomacy ladder, each rung a superset of the one below: NON_AGGRESSION (neither may declare war), TRADE (plus a discounted Auction House cut between members) and ALLIANCE (plus trusted-guest land access and tribulation assist). SectEvents.SectRelationChangedEvent carries it; token() is the lowercase word a player types at /sect ally propose.

MarketCategory is an open registry, but unlike the rest its registration point is not on the facade: it lives on plugin.siren.Utils.Market.MarketCategories.register(MarketCategory), an internal class that may change between versions. The built-in tab ids are spirit, alchemy, manuals, talismans, treasures and sundries.

TechniquePresetSnapshot

A TechniquePresetSnapshot is one keybind layout, detached from the player who wrote it: immutable, safe to hold, and able to travel onto an item, into a config or across a save. A snapshot is not a grant - it records which arts a layout pointed at but never confers them.

TechniquePresetSnapshot snapshot = CultivationAPI.exportTechniquePreset(accessor, ref, 0);
String wire = snapshot.serialize();      // one string, fits a single Codec.STRING item field

// ... on another player, later ...
TechniquePresetSnapshot back = TechniquePresetSnapshot.parse(wire);       // null if unreadable; never throws
TechniquePresetSnapshot.ImportResult result =
        CultivationAPI.importTechniquePreset(accessor, ref, back, "Imported layout");

The record is (name, authorName, slots) with exactly four Slot(modifier, key, techniqueId) values; TechniquePresetSnapshot.of(name, author, slots) is the forgiving constructor that pads a short list with empty slots. boundSlotCount() and techniqueIds() summarize it, and the wire form is 1|name|author|NONE:KEY_6:,ALT:KEY_7:cloud_step,NONE:KEY_8:,NONE:KEY_9: - the leading 1 is the format version. authorName is a label, never an identity: do not authorise anything off it.

importTechniquePreset keeps an art only if it is a technique this server registers, its rule is enabled, and the cultivator already knows it. The ImportResult record carries status(), index(), finalName(), imported(), droppedUnknown() and droppedDisabled(), plus isAdded() and dropped(). Status is one of ADDED, AT_CAP (they already keep getMaxTechniquePresets() layouts; nothing is lost and the snapshot can be imported again), EMPTY_AFTER_FILTER (every art was filtered out, so the layout would have arrived empty) or DISABLED (the technique system is off).

What is not registrable

Not every extension point is a register* method on the facade. Celestial events are registered on CelestialManager.registerEventType, Auction House category tabs on MarketCategories.register, and sect-mission metrics are credited through MissionManager.creditProgress. Two whole layers - progression and the vocabulary - are installed with setProgressionProvider and setTheme (API Addons).

Skill tree nodes, manuals and spirit beast species are declared in Cultivation's own JSON config instead - see the Config page - and are not registrable from code. (An art can be registered from code, but which species learns it is still the config's call.) You can still reach all three through events: node unlocks through CultivationEvents.PreSkillUnlockEvent, manuals through ItemEvents.PreManualReadEvent, and beast species through the species() getter on every BeastEvents event.

See also