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:
| Method | Returns | Registers | Section |
|---|---|---|---|
registerRace(String id, String displayName, String translationKey, CultivationRealm unlockRealm, Supplier<RaceConfig> stats) | PlayerRace | A playable race. | Registering a race |
registerTechnique(String id, String displayName, String nameKey, String descriptionKey, TechniqueRule defaultRule, TechniqueEffect effect) | Technique | A performable technique. | Registering a technique |
registerTechniqueGate(String id, String failureKey, TechniqueGate.Condition condition) (+1 overload) | TechniqueGate | A 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) | BeastArt | A 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) | boolean | An 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) | UpdateCheck | Your mod in the update check. | Update checks |
registerBuildCheck(String modId, String displayName, String version, Path jarPath, String manifestUrl) | BuildCheck | Your mod in the build check. | Build checks |
registerCompatCheck(String modId, String displayName, String version, String manifestUrl, Consumer<CompatCheck> onVerdict) (+1 overload) | CompatCheck | Your 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)
| Parameter | Description |
|---|---|
id | A 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. |
displayName | The 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. |
translationKey | A server.lang key for the localized name, or null to always show displayName as plain untranslated text. |
unlockRealm | The realm a player's cultivation must reach before this race can be chosen from the menu. |
stats | Supplies 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:
- the
/cultivation technique <id>command, and its list (see Commands), - any activation item whose
CultivationActivateTechniqueinteraction carries this id as itsTechniqueId, - your own trigger - a keybind, another item, an event - via
CultivationAPI.performTechnique.
| Parameter | Description |
|---|---|
id | A stable, unique id. It doubles as the config key and the activation item's TechniqueId. Namespace it - "MyMod:flame_step". |
displayName | Shown when no nameKey is given, or as a fallback if the key does not resolve. |
nameKey | A server.lang key for the localized name, or null to show displayName raw. |
descriptionKey | A server.lang key for the description, or null. |
defaultRule | The 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. |
effect | What 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)
| Parameter | Description |
|---|---|
requiredElement | A DaoElement enum name, e.g. "WIND", when daoSpecific is true; otherwise "" or null. |
elements | Comma-separated DaoElement names the technique "carries" - metadata and flavor - or "". |
damageType | A DamageCause asset id for a damaging technique, or "" for none. |
unlockRealm | The CultivationRealm enum name required to use it, e.g. "QI_CONDENSATION". |
params | Alternating 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");
TechniqueGate.Conditionis one method,boolean test(ComponentAccessor<EntityStore> accessor, Ref<EntityStore> ref, Technique technique).- Both message keys are full
server.langkeys including theserver.prefix. Leave the status key out (the three-argument overload) and the list shows the genericserver.cultivation.technique.status.gateRefusedlabel. - A gate fails open: a condition that throws counts as a pass and is logged once per gate id. A technique effect that depends on a gate should therefore also re-check its own real condition, so a transient failure degrades to a wasted cast rather than a permanently uncastable art.
- Registering an id again replaces the gate;
unregisterTechniqueGate(id)withdraws it.
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:
| Factory | Widget |
|---|---|
newAdminConfigField | A decimal number field. The default, and what every pre-0.6.1 field is. |
newAdminIntField | A whole-number field - for a count, a level cap or a tier, where two decimal places are noise. |
newAdminBooleanField | A 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. |
newAdminChoiceField | A dropdown over AdminConfigChoices - the right shape for anything enum-valued, where a text field would let an admin type something that resolves to nothing. |
newAdminTextField | Free-form text. |
withTooltip(field, String) adds a one-line explanation to any of them.
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.
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 method | Description |
|---|---|
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());
- A banner is a particle system, not a color. The engine's color-on-spawn field is unreliable, so the banner's colors are baked into the
.particlesystemasset itself;swatchonly tints the picker tile and the hall's map pin.build()refuses a banner with no particle. - The asset must burn out on its own. Give it a finite
TotalParticlesand an explicit systemLifeSpansized to finish insideSect-Hall-Beacon-Interval-Seconds(2.5s by default) - an uncapped system would leak an instance on every client that ever walked past the hall, forever. permission(String)/visible(Predicate)gate who may fly it - none of the built-in six are gated.- Uninstalling is safe. A sect stores the banner id, never the banner. An id nobody registers any more simply resolves to the hall's plain vein-tier light, and the choice survives for if the mod returns.
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());
- Declared documents are matched on the bare file name against
documentRoot, so every covered file must live under that one root. A page you do not declare deliberately falls through to Cultivation's own - an unresolvable.uipath kills the whole page load with no log line, so the registry never invents one. - The nine skill tree branch halos are all-or-none: pass every
SkillTreeBranchor none -build()refuses a partial set. CultivationPalette.DEFAULT_KEY("cultivation:default") is the built-in look.getPalette(store, ref)returns null for it;document(palette, basePath)resolves a path through whichever palette a player is on.- Like the equipped title, a player's palette choice lives on their settings, shared across profiles.
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
}
});
- A default,
CultivationPaletteDefault, is a functional interface:String defaultPaletteKey(accessor, ref). Return the key of the palette to use, ornullfor "no opinion". More than one may be registered; the first to name a registered palette wins, in registration order. - A default must gate itself.
CultivationPalette.isAvailableTois not re-checked on its answer, because this path runs on every draw. Returnnullfor anybody your look is not for. Keep the body to a component read and a comparison. - A player turns a suggested default down by picking
CultivationPalette.NONE_KEY, which resolves to Cultivation's own look and stops every default being asked. PickingCultivationPalette.DEFAULT_KEYreturns to the look and lets a default suggest again. - A lock,
CultivationPaletteLock, has two methods:lockedPaletteKey(accessor, ref)(null when it does not apply) andreasonKey()(a lang key for the hint, or null). The first lock that names a registered palette wins. While one applies,setPaletterefuses and the settings page shows your reason. - Both are evaluated on the viewing player's world thread, so read their components freely and never write to the Store. A key naming a palette nobody has registered counts as "no opinion" for both, so a rule whose palette is missing can never strand a player.
unregisterPaletteDefault(key)takes the key you registered under;unregisterPaletteLock(lock)takes the lock object itself.resolvePaletteDefaultandresolveActivePaletteLockread the current answer, andgetExplicitPaletteKeyreports the id the player actually has stored, before any lock or default.
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 method | Description |
|---|---|
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.
- Nothing is validated against a real asset. An id that names no particle raises nothing at that moment rather than failing, so test a new aura in game.
- Uninstalling is safe. A player's choice is stored as an id, never the object; an id nobody claims resolves back to Cultivation's own built-in particles.
- Read with
getMeditationAuras(),getMeditationAura(key)andgetMeditationAura(accessor, ref)(null means the stock look).setMeditationAura(store, ref, key)re-validates the key against the registry andisAvailableTobefore applying, because a client event can forge an id; pass null or empty to return to the built-in particles.CultivationMeditationAura.DEFAULT_KEYis a picker-only marker for "no aura chosen" and is never stored.
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());
name(String)is aserver.langkey.counterBonusPercent(float)is optional: when set it replaces the server'sDao-Counter-Bonus-Percentwhenever this element counters another in combat; omit it to use the server's own figure.build()refuses an element that is not off-ring (DaoElement.isOffRing()), so you cannot claim a root of the main ring.- Naming, titles, Codex entries and iconography are left entirely to the claiming mod: the element-title loop, the "X beats Y" listing and both Dao-page ring rows skip off-ring elements outright. Pair a claim with
registerPaletteLockif the root should come with its own robes. - Claiming an already-claimed element replaces the claim.
unregisterOffRingRoot(element)releases it; a cultivator already walking it is not migrated, the element simply becomes unselectable again.getOffRingRoot(element)andisOffRingRootClaimed(element)read the registry.
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 method | Default | Description |
|---|---|---|
name, description, howToComprehend | name is the raw key | Each 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) | 100 | Comprehension at which the Dao manifests. |
maxComprehension(float) | 500 | The ceiling. |
hiddenUntilComprehended(boolean) | false | Hide the Way from menus until the player has some comprehension. |
visible(Predicate<PlayerRef>) | everyone | Hides it from players the predicate rejects. |
manifestation(Manifestation) | does nothing | What 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).
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"));
});
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:
| Channel | Combines | Scales or decides |
|---|---|---|
qiGainMultiplier | multiply | Every point of Qi gained, from any source. |
meditationQiMultiplier(accessor, ref, boolean inLava) | multiply | Qi drawn by meditating specifically, on top of the above. |
damageDealtMultiplier | multiply | All damage dealt - weapons, techniques and fists alike. |
unarmedDamageMultiplier | multiply | Damage dealt bare-handed, on top of the above. Never applies to a weapon. |
damageTakenMultiplier | multiply | Damage taken. Below 1 is protection, above 1 is fragility. |
isImmuneToDamageCause(accessor, ref, String damageCauseId) | OR | Whether a damage cause is ignored outright. A hard immunity - use sparingly. |
chargeSecondsMultiplier | multiply | How long a charged art must be gathered. Below 1 is faster. |
ignoresDaoElementLock | OR | Whether the cultivator may perform a dao-locked art of an element they have not comprehended. |
daoAffinityMultiplier(accessor, ref, DaoElement element) | multiply | Affinity gained toward one dao element. |
alignmentShiftMultiplier(accessor, ref, boolean towardYin) | multiply | How far a single alignment shift moves them, per direction. |
yinYangBalanceToleranceMultiplier | multiply | The width of the band that counts as a balanced Yin-Yang. |
ritualDifficultyMultiplier | multiply | How hard a breakthrough or advancement ritual is: its length and the tribulation strikes. Below 1 is easier. |
canMeditateInLava | OR | Whether they may meditate while standing in lava. |
talismanPotencyMultiplier | multiply | A talisman's potency, on top of the grade's own magnitude multiplier. |
beastBonusMultiplier(accessor, ownerRef, BeastSpecies species) | multiply | A 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) | multiply | The 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. |
qiRequirementReductionPercent | sum | A percentage knocked off the banked Qi the next rank-up costs. See below. |
auraScaleMultiplier | multiply | The size of the realm aura. Cosmetic only. |
bonuses(accessor, ref) | list | Overview rows built with CultivationAPI.newBonus. Return an empty list when you grant nothing. |
traits(accessor, ref) | list | Named 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.
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
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.
Menu pages
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 method | Description |
|---|---|
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. |
- Ids. The id is what a click sends back, so namespace it (
"myMod:alchemy"). Cultivation's own ten ids are bare words:overview,settings,race,skilltree,dao,rankings,codex,sense,adminandinfo. Six more ids open real pages but have no button -bonuses(folded intooverview),titles(intorace, which is why that button now reads "Identity"),profilesandkeybinds(reached fromsettings),daomasteryanddaoinheritance(reached from a link row ondao). Registering any of them yourself puts a button back, since the bar is drawn purely from the registry. - Replacing a built-in. Registering an existing id replaces it, which is how a mod takes over one of Cultivation's own entries.
- Hidden means hidden. There is no grayed-out state - the bar already uses "disabled" to mark the page you are on. Visibility is checked again before the page opens, so a hand-crafted packet cannot reach a page whose button was hidden.
- Threading.
getLabel,isVisibleToandopenrun on the world thread that owns the viewing player, while their page is built or swapped. Read components freely; never write to the Store.
Putting the bar on your own page
- Give your
.uidocument a placeholder:Group #NavBar { LayoutMode: Left; Anchor: (Bottom: 4, Height: 46); }. - Call
CultivationAPI.buildMenuNav(commandBuilder, eventBuilder, playerRef, "myMod:alchemy", store, ref)from your page'sbuild. The overload withstoreandrefdraws 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. - Add a plain
Codec.STRINGfield to your event codec underCultivationAPI.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) - Hand that field to
CultivationAPI.handleMenuNav(store, ref, playerRef, data.nav)fromhandleDataEvent. 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.tryHandleMenuNavreturns whether a page opened; if you call it from a locking nav binding, answer the click yourself onfalsewithsendUpdate(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:
| Method | Description |
|---|---|
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 method | Description |
|---|---|
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:
| Method | Block |
|---|---|
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).
- Namespacing and replacement. Namespace the id with your mod's name. Registering an existing id replaces it, which is how a mod rewrites a built-in article.
- Threading. The body runs on the reader's world thread while their page is built: read their components freely, do not write to the Store.
getCodexEntries()returns entries ordered by sort order then registration; grouping them is the caller's job.getCodexEntry(id)andunregisterCodexEntry(id)complete the set.
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 method | Description |
|---|---|
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. |
- The id is persisted. It must match
^[A-Za-z0-9_.:-]{1,64}$and must never be renamed once shipped: it is stored verbatim in the player's revealed and unread sets, and a rename silently un-reveals the system for every account that had earned it.build()throws on a bad id. - Predicates are cheap, read-only and never create state. Use
store.getComponent-style reads; never call agetOrCreateaccessor from one. A predicate that throws is treated as having passed (the system reveals) and is logged once per id. isSystemRevealed(accessor, ref, id)answers whether a player currently sees a system -truefor an unregistered id, since there is nothing to hide it.getHighestRealmReached(accessor, ref)returns the permanent high-water realm ordinal Progressive Reveal reads, which only earned progress raises.
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"] }
- Load order does not matter. Registering before Cultivation starts its sweep joins that sweep; registering after it triggers a fetch for your mod alone. Registering the same id twice replaces the first.
- It fails open. A missing field, a malformed body, a dead host or a timeout all mean "no update", never an error anyone sees.
- The server owner's
Update-Check-Enabledswitch governs every registered mod, yours included: with it off, the registration is kept but never fetched. - Use
getUpdateChecks()for every registration (Cultivation's own included) andgetAvailableUpdates()for only those known to be behind and not suppressed - empty until the first sweep lands, a few seconds after startup.unregisterUpdateCheck(modId)removes yours.
UpdateCheck is the read-only handle you get back. Its getters are safe from any thread and answer for the most recent completed fetch:
| Method | Returns |
|---|---|
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.
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:
| Value | Meaning |
|---|---|
UNKNOWN | Nothing established: the check has not run, the request failed, or the published list has no entry for this version. Never evidence of anything. |
OFFICIAL | The jar's code matches a digest published as official for its version. |
UNOFFICIAL | The 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:
| Value | Meaning |
|---|---|
UNKNOWN | Nothing 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. |
COMPATIBLE | The matrix names a range for this addon version and Cultivation is inside it. |
INCOMPATIBLE | The 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. |
- Act on
INCOMPATIBLEby withdrawing what you registered and saying so in the log - not by throwing. A plugin that throws out ofsetup()takes the entire server down, and by the time a verdict lands you have been running for half a minute anyway. onVerdictis not on a world thread. It runs once, on the check's own daemon thread, as soon as the pairing resolves to anything other thanUNKNOWN. Touch registries and the log, never entity state. Pass the four-argument overload (no callback) and pollgetCompatStatus(modId)instead.- It fails open in every direction, so an offline server is never told its mods disagree.
getCompatChecks()lists every registration, andunregisterCompatCheck(modId)removes yours.
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:
| Method | Default | Ceiling | Raises |
|---|---|---|---|
registerTechniquePresetCap(String key, int cap) | 3 | 8 | How many technique keybind loadouts a player may keep. |
registerProfileCap(String key, int cap) | 3 | 6 | How 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.
| Type | What it is |
|---|---|
UpdateCheck, BuildCheck, CompatCheck | The read-only handles returned by registerUpdateCheck, registerBuildCheck and registerCompatCheck. |
BuildStatus, CompatStatus | Their verdict enums: UNKNOWN, OFFICIAL, UNOFFICIAL and UNKNOWN, COMPATIBLE, INCOMPATIBLE. |
CodexCategory, CodexEntry, CodexPage | A Codex heading, an article and the block writer its body receives. |
CultivationMenuPage | A button on the shared nav bar, built with CultivationMenuPage.builder(id). |
IdentitySection, IdentitySectionContext | A tab on the Identity page and the build-time context it receives. |
CultivationMeditationAura | A meditation, breakthrough and advancement look. |
CultivationPaletteDefault, CultivationPaletteLock | The two palette rules. |
DaoRoot | A claim on an off-ring Dao element. |
RevealableSystem | A system Progressive Reveal can hide. |
CultivationBonus | A record (sourceKey, statKey, amount, percent): one Overview bonus row, built with CultivationAPI.newBonus and returned from CultivationModifierSource.bonuses. |
CultivationModifierSource | The standing-modifier interface. |
PlayerAdminAction, AdminConfigChoice | The per-player admin row and its dropdown options. |
TechniquePresetSnapshot | A keybind layout as a detached value; see below. |
EventBus | The listener-list plumbing behind every *Events class: newListenerList(), dispatch(...) and fire(...). Not a stable API surface - register through the *Events classes instead. |
AuctionListingType | The 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. |
MarketCategory | A 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). |
SectRelationKind | The 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
- API Events - the 370 hooks your registered content fires through.
- API Reference - every facade method, and reading a player's state before you act on it.
- Races, Techniques, Qi Gathering, Titles, Sects, Codex, Compatibility, Config, Commands.