Options and config
How a feature declares its settings with @Option, which control each field type gets, sections, order, dependsOn, keywords and hidden options, how values are stored per config profile, and how to rename or migrate a setting without losing anyone's value.
On this page
Every setting in Vantage is a field on a feature, marked @Option. The menu page, the search, the export string
and the saved file are all built from those fields, so a feature never draws its own settings screen. This page is
for anyone adding or changing a setting: the field types and the control each one gets, the attributes that shape
the page, how a feature reads and reacts to its values, where they are saved, and how to change a setting's name or
default after players already have it. As of 2.0.1; the code is in core/config/.
An option is a field
A field becomes an option when it carries @Option and sits on a registered config owner. Every feature is one:
the registry registers it under its id before onInit() runs. The field must be an instance field, not static
and not final, and it must be initialised, because the initial value is the default.
@Option(name = "Play a sound", section = "11:11", order = 4)
public boolean sound = true;That line, from the 11:11 feature, is the whole declaration: a switch
labelled "Play a sound", on by default, saved as eleven_eleven.sound. The feature reads it as a plain field.
The config scans the owner's class and every superclass, superclass fields first, so options a base class declares
appear before the subclass's own. Every feature inherits one option from Feature itself:
@Option(name = "Enabled", description = "Master switch for this feature.", order = Integer.MIN_VALUE)
public boolean enabled;That is the master switch. Its starting value comes from enabledByDefault in @FeatureInfo, and the feature's
page draws it as the switch on the top card rather than as a row.
A feature is not the only possible owner. Anything can be registered with ConfigManager.get().register(id, owner);
ApiManager registers ApiConfig, the api owner, that way. The id must be unique and must never change, for the reason
below.
Types and their controls
The control is chosen from the field's type. Nothing else is needed, and no other type is accepted.
| Field type | Control | Saved as | Notes |
|---|---|---|---|
boolean | toggle | true / false | |
int, float, double with @Range | slider | a number | clamped and snapped to the range |
int, float, double without @Range | number field | a number | |
an enum | dropdown | the constant's name | the label is toString(), or displayName() when the enum implements Named |
String | text field | a string | |
Color | colour picker | { "argb": "#AARRGGBB", "chroma": … } | alpha included; chroma only when above 0 |
Keybind | key button | { "key": …, "mouse": …, "modifiers": … } | GLFW codes; Keybind.NONE is unbound |
List<String> | editable list | an array of strings | drawn on its own line under the label |
Runnable | button | not saved | needs @Button; see Buttons |
A field of any other type, a List of anything but String, a static or final field, and a Runnable with no
initial action all throw an IllegalArgumentException naming the field when the owner registers. For a feature that
happens during start-up, so the mistake shows the first time you run the client.
This website's feature reference names the same controls in its Type column: Toggle, Slider, Number, Dropdown, Text, Colour, Keybind, List and Button.
The attributes
| Attribute | Default | What it does |
|---|---|---|
name | required | the label on the row |
description | empty | the line under the label; also searched |
section | empty | groups rows under a heading on the feature's page |
order | 0 | position on the page; lower comes first |
dependsOn | empty | another field whose value enables this row |
keywords | none | extra words for search |
hidden | false | saved but never shown |
Sections and order
The config sorts an owner's options by order, keeping declaration order for ties, after putting superclass fields
first. The page then groups them by section in the order each section first appears, and draws each section as
one glass sheet with its rows separated by hairlines. When a feature has more than one section, options with no
section are gathered under "General".
section is free text, so two spellings make two sections. A shared constant, as the features in the mod use,
avoids that:
private static final String S_KEYS = "Keys";
@Option(name = "Open browser", description = "Opens the item browser. Works in the world and inside any menu.",
section = S_KEYS, order = 1)
public Keybind openKey = Keybind.NONE;That is from the Item Browser feature.
dependsOn
dependsOn names another field on the same owner. While that field is false, this row is drawn greyed out. A
leading ! inverts it, so dependsOn = "!rainbow" greys a colour picker out while a rainbow switch is on. The
named field is looked up on the owner's class and its superclasses. A field that is not a boolean counts as true
whenever it is not null.
keywords
Search matches more than the name. Every option's search text is its name, description, section and keywords, the
owner's name and id, and the field's own name, lower-cased. Add keywords for the words a player would type that
appear in none of those:
keywords = {"wizard", "first", "onboarding", "tutorial", "start"}hidden
A hidden option is saved and restored like any other, but never drawn, never searched and left out of this
website's reference. It is how a feature keeps a small piece of state in the config profile, such as whether it has
shown a one-time notice:
@Option(name = "Seen the notice", hidden = true)
public boolean noticeShown = false;Ranges
@Range turns a number into a slider and constrains it.
@Option(name = "Glass opacity", section = "Glass", order = 5, description = "…")
@Range(min = 0, max = 100, step = 1, unit = "%")
public int glassOpacity = 62;| Member | Meaning |
|---|---|
min, max | the bounds |
step | the grid; 0 means continuous for float and double, and 1 for int |
unit | a suffix shown after the value, such as "%", "px" or "s" |
Every value that reaches the field is clamped to the bounds and snapped to the step: what the slider sets, what is read from a saved profile, what an import brings, and the default itself, which is normalised once when the owner registers. A value saved before a range was tightened therefore comes back inside the new range.
Buttons
A Runnable field with @Button is drawn as a button. Pressing it runs the action; there is no value, so nothing is
saved. The field must be initialised with the action.
@Option(name = "Reset", order = 2)
@Button(label = "Reset")
public Runnable resetButton = () -> {
clearDrops();
Notifier.success("Ender Node tracker reset", "Cleared.");
};That is from the Ender Node tracker. An action that throws is logged and nothing else happens.
Enums that read well
An enum option is a dropdown whose labels are toString(). To label the choices in words, implement Named:
public enum When implements Named {
BOTH("Morning and night"),
NIGHT("Night only"),
MORNING("Morning only");
private final String label;
When(String label) { this.label = label; }
@Override public String displayName() { return label; }
}
@Option(name = "When", section = S, order = 1, description = "Whether 11:11 in the morning counts too.")
public When when = When.BOTH;The saved value is the constant's name, BOTH, not the label, so labels can be reworded freely. Renaming or
removing a constant is a different matter: a saved name that no longer matches any constant is ignored with a
warning in the log, and the option falls back to its default. Write a migration when you rename one (below).
Colours and keys
Color is a record of an ARGB int and a chroma speed. Make one with new Color(0xFFEF4444), Color.rgb(r, g, b) or
Color.hex("#3D8BFF"). A chroma above 0 joins the mod's single rainbow cycle, so draw with color.resolve(), which
applies it, rather than reading argb() directly.
Keybind is a key or mouse button plus a modifier mask. Keybind.NONE is unbound and Keybind.key(GLFW.GLFW_KEY_H)
binds a key. Feature keys are options like any other, set on the feature's page rather than in Minecraft's Controls.
Commands and keybinds shows how a feature reads one.
Reading and reacting
A feature reads its options as plain fields, every time it needs them. When the player changes a value, the config writes the field directly, so the next read sees it.
When a feature has to do something as a value changes, such as re-applying a theme, it registers a change listener in
onInit():
@Override
protected void onInit() {
ConfigManager.get().onChange((owner, field, value) -> {
if (id().equals(owner)) apply();
});
apply();
}That is the pattern the Notifications feature uses. The listener is called with the owner id, the field name and
the new value whenever a value changes through the menu, a reset or an import. It is not called field by field when
a whole profile loads: loading and switching config profile post Events.ConfigReloaded instead, and an import does
both, so a feature that caches anything derived from its options should also refresh on that event.
Where values are stored
Values are kept per config profile, under the owner's id and the field's name, in
<minecraft>/config/vantage/profiles/<profile>.json (in the development client, run/config/vantage/profiles/):
{
"version": 5,
"values": {
"eleven_eleven": {
"enabled": true,
"watch": "BOTH",
"when": "BOTH",
"message": "Make a wish",
"sayWhere": true,
"sound": true
}
}
}The rest of the layout:
Path under config/vantage/ | Holds |
|---|---|
vantage.json | which config profile is active, the config version and the mod version that wrote it |
profiles/<name>.json | one config profile, as above |
backups/<name>-<yyyyMMdd-HHmmss>.json | a copy taken each time an existing profile is loaded; the newest ten per profile are kept |
backups/<name>-corrupt-<stamp>.json | a profile that could not be read, kept aside while the game starts from defaults |
Saves are debounced by half a second and written atomically. Every option of every registered owner is written,
defaults included. Keys the running build does not know, from a newer version or a removed feature, are kept and
written back untouched. Players manage profiles from the menu and /vt profile; Profiles is
the player's view of the same files.
Changing a default later
Because every value is written, defaults included, a player who has run a build with your option has its default
saved in their profile as an explicit value. Changing the initialiser later changes it for new profiles only. When
the old default should give way, write a migration that removes the old value where it is still the old default, so
the new initialiser applies. ConfigMigrations.adoptVantageDiscordApp does exactly that for an application id that
used to ship empty.
Renaming without losing settings
The owner id and the field name are the storage key, so renaming a feature's id, renaming a field, or merging two features orphans whatever players had set. Migrations prevent that. Each one rewrites the raw JSON tree of a profile when it loads, before any value reaches a field, so feature code only ever sees the new shape.
The current schema version is ConfigManagerImpl.CONFIG_VERSION, which is 5 as of 2.0.1. Every
step so far:
| Step | Method in ConfigMigrations | What it did |
|---|---|---|
| 1 to 2 | mergeAshfang | folded eight Ashfang features into one, moving each old switch and colour to a field on crimson_ashfang |
| 2 to 3 | adoptVantageDiscordApp | dropped an empty Discord application id so the new default applies |
| 3 to 4 | retireAmethyst | rewrote the retired Amethyst theme to Obsidian |
| 4 to 5 | retireHypixelApiKey | deleted the stored Hypixel API key and flagged profiles that had one |
To add one, raise CONFIG_VERSION by one and register a step from the previous version in
ConfigMigrations.register:
// ConfigManagerImpl
public static final int CONFIG_VERSION = 6;
// ConfigMigrations.register(...)
config.addMigration(Migration.of(5, 6, ConfigMigrations::renamePursePace));
static void renamePursePace(JsonObject root) {
JsonObject values = JsonUtils.getOrCreateObject(root, "values");
JsonObject old = JsonUtils.getObject(values, "purse_pace");
if (old == null) return;
values.remove("purse_pace");
values.add("mining_purse_pace", old);
}Steps run in ascending from order until the tree reaches CONFIG_VERSION. A step that throws is logged and the
chain stops there; a profile with no path to the current version loads as it is, with a warning. Imports go through
the same migrations, so an export made by an older build still lands in the right places. HUD element ids have their
own, separate migration in hud.json; HUD elements covers it.
Export and import
/vt export [feature] copies the active profile, or one owner, to the clipboard as VANTAGE1: followed by the
base64 of the gzipped JSON, with the mod version inside. /vt import accepts that string or raw JSON, migrates it,
and merges it into the active profile. Presets use the same string format with a HUD layout added. Owners that are
not registered keep their imported values for a later build to use.
What must never be an option
An export is a string people paste to each other, and every load of a profile writes a backup copy of it. So anything that
belongs to an account rather than to a preference stays out of @Option: a sign-in token, a consent record, a share
key. Vantage Stats keeps all of those in config/vantage/stats/ for exactly that reason; its feature's own options are
four ordinary preferences and two buttons that open its screens. If a setting would be wrong on somebody else's computer, it is not an option.