All pages

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.

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 typeControlSaved asNotes
booleantoggletrue / false
int, float, double with @Rangeslidera numberclamped and snapped to the range
int, float, double without @Rangenumber fielda number
an enumdropdownthe constant's namethe label is toString(), or displayName() when the enum implements Named
Stringtext fielda string
Colorcolour picker{ "argb": "#AARRGGBB", "chroma": … }alpha included; chroma only when above 0
Keybindkey button{ "key": …, "mouse": …, "modifiers": … }GLFW codes; Keybind.NONE is unbound
List<String>editable listan array of stringsdrawn on its own line under the label
Runnablebuttonnot savedneeds @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

AttributeDefaultWhat it does
namerequiredthe label on the row
descriptionemptythe line under the label; also searched
sectionemptygroups rows under a heading on the feature's page
order0position on the page; lower comes first
dependsOnemptyanother field whose value enables this row
keywordsnoneextra words for search
hiddenfalsesaved 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;
MemberMeaning
min, maxthe bounds
stepthe grid; 0 means continuous for float and double, and 1 for int
unita 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.jsonwhich config profile is active, the config version and the mod version that wrote it
profiles/<name>.jsonone config profile, as above
backups/<name>-<yyyyMMdd-HHmmss>.jsona copy taken each time an existing profile is loaded; the newest ten per profile are kept
backups/<name>-corrupt-<stamp>.jsona 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:

StepMethod in ConfigMigrationsWhat it did
1 to 2mergeAshfangfolded eight Ashfang features into one, moving each old switch and colour to a field on crimson_ashfang
2 to 3adoptVantageDiscordAppdropped an empty Discord application id so the new default applies
3 to 4retireAmethystrewrote the retired Amethyst theme to Obsidian
4 to 5retireHypixelApiKeydeleted 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.