Skip to content

aowlspt/settings

Source: aowl/src/aowlspt/settings.nim — 978 lines.

aowlspt/settings — a mod declares its config schema, the F12 settings UI renders it, and an edit made in-game is written back through the same config.json the mod already reads.

The problem this solves. Every mod already has a config.json and reads it with setting("key").asFloat(default). Nothing anywhere says what those keys are — their type, their range, whether the value is even wired to anything. So there is no way for a settings screen to draw the right control for a key, and no way for it to warn that a key is read-and-ignored. This module is where a mod says all of that once, in code, next to the handler that reads it.

import aowlspt/settings

proc onLoad(): Status = declareSettings(@[ floatSetting("opticFovMulti", "Optic FOV multiplier", 1.0, lo = 0.5, hi = 2.0, step = 0.01, category = "FOV", description = "FOV scale while aiming a magnified sight"), boolSetting("changeMouseSensitivity", "Scale mouse sensitivity", true, category = "Sensitivity"), keybindSetting("zoomToggleKey", "Toggle-zoom key", "M", category = "Toggle zoom", implemented = false, description = "Not wired: KeyCode enum mapping is missing")]) ...

The declaration is pure data. It is serialised to the JSON the overlay's settings panel fetches (schemaJson), and it never itself touches the runtime — a mod that declares a schema and does nothing else is still a no-op mod. implemented = false is the honest half: aowlspt ports upstream mods a capability at a time, and a key whose value is carried but not yet acted on is drawn greyed with that reason rather than pretended to work.

Types

SettingType

nim
  SettingType* = enum
    stBool
    stInt
    stFloat
    stEnum
    stString
    stKeybind
    stSelect
    stColor

The control the UI draws. stKeybind is a string underneath — the value on disk is a key name like "KeypadMultiply" — but it is drawn as a key-capture box rather than a free text field, which is the whole reason it is a type of its own and not stString.

aowl/src/aowlspt/settings.nim:45

Setting

nim
  Setting* = object
    key*: string          ## the config.json key, verbatim
    label*: string        ## the human name drawn on the row
    kind*: SettingType
    defaultJson*: string  ## the default as a JSON literal (`1.0`, `true`, `"M"`)
    lo*: float            ## min, for stInt/stFloat
    hi*: float            ## max
    step*: float          ## slider granularity
    hasRange*: bool       ## lo/hi/step are meaningful
    options*: seq[string] ## the choices, for stEnum
    category*: string     ## the sub-page/section this row groups under
    subcategory*: string  ## an optional second level INSIDE `category`
    optionLabels*: seq[string] ## display names parallel to `options`, or empty
    optionsUrl*: string   ## for stSelect: a route serving choices on demand
    colorFormat*: string  ## for stColor ONLY: the ON-DISK TEXT SHAPE the owning
                          ## mod's own parser reads back. `""`/`"rgb"` is the
                          ## default `"r,g,b[,a]"` in 0..1; `"hex"` is bare
                          ## `RRGGBB[AA]`; `"hex#"` is `#RRGGBB[AA]`.
                          ##
                          ## This exists because the picker is SHARED and the
                          ## parsers are not. `mods/maps` reads its contact
                          ## colours with `parseRgb`, which requires EXACTLY six
                          ## hex digits and returns the DEFAULT on anything else
                          ## -- so a picker that wrote `"0.9,0.27,0.24"` into
                          ## `colorBot` would leave the dot on screen at its old
                          ## colour while config.json held the new one. That is
                          ## the "control that moves and changes nothing"
                          ## failure, and it is unrepresentable now: the row
                          ## declares the shape its own reader parses, and the
                          ## widget writes that shape.
                          ##
                          ## A renderer that has never heard of this field falls
                          ## back to `"rgb"`, which is what every existing colour
                          ## row already uses, so nothing can regress by adding
                          ## it.
    description*: string  ## one sentence of help
    keybind*: bool        ## THE KEYBIND FACET. `true` means "this row belongs to
                          ## the key-binding surface", which is a WIDER claim than
                          ## `kind == stKeybind` and deliberately separate from it.
                          ##
                          ## `stKeybind` is a VALUE SHAPE (a key NAME string, drawn
                          ## as a capture box). Two kinds of row are keybinds to a
                          ## player and cannot be that shape:
                          ##
                          ## * a key stored as a VIRTUAL-KEY CODE -- `mods/debug`'s
                          ##   `overlayToggleKey` is `114` (VK_F3) and its reader
                          ##   parses an int. Retyping it `stKeybind` would change
                          ##   the on-disk shape under a parser that would then
                          ##   read the default instead. It declares the facet.
                          ## * the GATE for a key -- `mods/maps` and `mods/admin`
                          ##   both have a bool `hotkeys` that must be ON before
                          ##   any key is polled at all. A "keybinds only" view
                          ##   that hid the gate would show a key that provably
                          ##   does nothing and no way to find out why. That is
                          ##   the "control that changes nothing" failure, so the
                          ##   gates declare the facet too.
                          ##
                          ## The alternative -- a filter that pattern-matches names
                          ## containing "key" -- gets BOTH directions wrong on this
                          ## repo's real data: it would hide `hotkeys`/`overlay
                          ## ToggleKey`-style gates it did not recognise, and it
                          ## would show `mods/loadammoanim`'s `hijackKey`, which is
                          ## a BUNDLE NAME and not a key at all. Declared, not
                          ## guessed.
                          ##
                          ## `keybindSetting` sets this itself; nothing else has
                          ## to. Read it through `isKeybind`, never directly.
    implemented*: bool    ## false → drawn greyed with the reason in `description`
    appliesOn*: string    ## when an edit takes effect: `"live"` (default) or
                          ## `"restart"`. A row that CANNOT take effect until the
                          ## game is relaunched must say `"restart"` here, so the
                          ## panel can label it. A control that changes nothing
                          ## and does not say why is the failure this whole
                          ## mechanism exists to stop; "restart" is an honest
                          ## answer, silence is not. NOTE: this is unrelated to
                          ## `mods/manager`'s `appliesOn` wire field, which
                          ## carries a mod's SIDE (`client`/`server`/`both`).

aowl/src/aowlspt/settings.nim:82

SettingsApplyHook

nim
  SettingsApplyHook* = nil proc (key: string)

Run after a SettingsApplyQuery edit has persisted, so the mod can re-read its config and push the change into whatever it drives. Nilable -- nimony proc types are non-nil by default, hence nil proc.

aowl/src/aowlspt/settings.nim:722

Constants

cColorFmtRgb

nim
  cColorFmtRgb* = "rgb"

"r,g,b" / "r,g,b,a", components 0..1. THE DEFAULT.

aowl/src/aowlspt/settings.nim:252

cColorFmtHex

nim
  cColorFmtHex* = "hex"

"rrggbb" / "rrggbbaa" -- NO leading #.

aowl/src/aowlspt/settings.nim:254

cColorFmtHexHash

nim
  cColorFmtHexHash* = "hex#"

"#rrggbb" / "#rrggbbaa" -- WITH the leading #.

aowl/src/aowlspt/settings.nim:256

SettingsIndexQuery

nim
  SettingsIndexQuery* = "aowlspt.settings.indexQuery"

Broadcast by the /aowlspt/settings/index aggregator (see mods/settingshub). Every mod that has ever called declareSettings replies synchronously with SettingsIndexAnnounce — see emit/on in aowlspt.nim: deliverEvent calls every subscriber before returning, so by the time the query's emit call returns, the aggregator has already heard from everybody. This is the same synchronous broadcast-then-collect shape the host's own mod-control replies use (deliverEvent's comment: "the mod-control replies come from here").

Chosen over a process-wide registry proc (design doc §6, option 1) because that would need a new export on the mod ABI, and abi/**/host/** are off limits to this change; chosen over N loopback HTTP calls (option 2) because there is no in-process route dispatch exposed to a mod, only the real network listener, and a real HTTP round-trip per mod per index fetch — through TLS, on the request thread that is itself serving the index route — is worse than one broadcast. on/emit already exist and already cross mods without either knowing the other exists, which is exactly this problem.

aowl/src/aowlspt/settings.nim:464

SettingsIndexAnnounce

nim
  SettingsIndexAnnounce* = "aowlspt.settings.indexAnnounce"

Payload: {"guid":"...","name":"...","count":N,"done":M}. Emitted by every mod that has declared settings, once per SettingsIndexQuery it hears. done is how many of the count rows are implemented.

aowl/src/aowlspt/settings.nim:483

SettingsPageQuery

nim
  SettingsPageQuery* = "aowlspt.settings.pageQuery"

Payload: a bare guid. The mod whose modGuid() matches replies with SettingsPageAnnounce; every other subscriber ignores it.

This is the event twin of GET /aowlspt/settings/<guid>, and it exists because the client host refuses route_register: a client-only mod (mods/graphics is sides = {sideClient}) registers its settings route into nothing, so its page was unreachable BY CONSTRUCTION -- and the /aowlspt/settings/index aggregator in mods/settingshub is server-side, so its SettingsIndexQuery broadcast never reached a client-only mod either. The event bus IS implemented on the client (aowlspt_nim_event_emit in the client host), so it is the only in-process channel a client mod's schema can travel on.

aowl/src/aowlspt/settings.nim:487

SettingsPageAnnounce

nim
  SettingsPageAnnounce* = "aowlspt.settings.pageAnnounce"

Payload: {"guid":"...","rows":[ ...schema... ]} -- the same rows the GET route returns, so one reader parses both transports.

aowl/src/aowlspt/settings.nim:500

SettingsApplyQuery

nim
  SettingsApplyQuery* = "aowlspt.settings.applyQuery"

Payload: {"guid":"...","key":"...","value":<literal>}. The owning mod persists it through the SAME applySettingFromBody its route uses and then runs its apply hook (onSettingsApplied), so the edit HOT-APPLIES in the process that owns the runtime. That is the point: a page that renders but whose edits never reach the live instance is worse than an absent page, so the schema and the write travel the same way.

aowl/src/aowlspt/settings.nim:503

SettingsApplyAnnounce

nim
  SettingsApplyAnnounce* = "aowlspt.settings.applyAnnounce"

Payload: {"guid":"...","ok":true|false,"err":"...","rows":[...]}. rows is the schema RE-READ after the write, never an echo of what was asked for, so a caller verifies by value and not by "the call returned" (fact #135: a chunked POST is answered 200 with the schema unchanged).

aowl/src/aowlspt/settings.nim:510

ApplyEffectApplied

nim
  ApplyEffectApplied*  = "applied"

aowl/src/aowlspt/settings.nim:757

ApplyEffectRestart

nim
  ApplyEffectRestart*  = "restart"

aowl/src/aowlspt/settings.nim:758

ApplyEffectIgnored

nim
  ApplyEffectIgnored*  = "ignored"

aowl/src/aowlspt/settings.nim:759

ApplyEffectNoHook

nim
  ApplyEffectNoHook*   = "nohook"

aowl/src/aowlspt/settings.nim:760

ApplyEffectSilent

nim
  ApplyEffectSilent*   = "unreported"

aowl/src/aowlspt/settings.nim:761

Routines

isKeybind

nim
proc isKeybind*(s: Setting): bool

THE single predicate for "is this row part of the key-binding surface". Every filter, in every renderer, must ask this and nothing else -- the whole point of the facet is that there is one answer and it is declared.

aowl/src/aowlspt/settings.nim:160

kindName

nim
proc kindName*(k: SettingType): string

The wire name of a type, matching what the UI switches on.

aowl/src/aowlspt/settings.nim:166

boolSetting

nim
proc boolSetting*(key, label: string; default: bool; category = ""; subcategory = ""; description = ""; implemented = true; appliesOn = "live"; keybind = false): Setting

keybind = true for a row that GATES a key (see Setting.keybind) -- a bool is not a key, but hiding the gate from a keybinds-only view leaves a key on screen that cannot fire.

aowl/src/aowlspt/settings.nim:186

intSetting

nim
proc intSetting*(key, label: string; default: int; lo = 0; hi = 0; step = 1; category = ""; subcategory = ""; description = ""; implemented = true; appliesOn = "live"; keybind = false): Setting

keybind = true for a key stored as a VIRTUAL-KEY CODE. The int stays an int on disk -- the facet is metadata, not a retype, so the mod's own parser is untouched.

aowl/src/aowlspt/settings.nim:199

floatSetting

nim
proc floatSetting*(key, label: string; default: float; lo = 0.0; hi = 0.0; step = 0.0; category = ""; subcategory = ""; description = ""; implemented = true; appliesOn = "live"): Setting

aowl/src/aowlspt/settings.nim:214

enumSetting

nim
proc enumSetting*(key, label: string; default: string; options: seq[string]; category = ""; subcategory = ""; description = ""; implemented = true; appliesOn = "live"): Setting

aowl/src/aowlspt/settings.nim:224

stringSetting

nim
proc stringSetting*(key, label: string; default: string; category = ""; subcategory = ""; description = ""; implemented = true; appliesOn = "live"): Setting

aowl/src/aowlspt/settings.nim:233

keybindSetting

nim
proc keybindSetting*(key, label: string; default: string; category = ""; subcategory = ""; description = ""; implemented = true; appliesOn = "live"): Setting

aowl/src/aowlspt/settings.nim:242

normalizeColorFormat

nim
proc normalizeColorFormat*(fmt: string): string

THE ONE PLACE a colour format string is validated. Every producer and every consumer goes through this or through the three constants above, so the two UIs cannot drift apart on a bare literal -- which is exactly what happened: the native settings renderer parsed #rrggbb only, while the default format every mod gets is r,g,b, so every default-format colour row was demoted to an unbound text stub in game while the web picker worked.

aowl/src/aowlspt/settings.nim:259

colorSetting

nim
proc colorSetting*(key, label: string; default: string; category = ""; subcategory = ""; description = ""; implemented = true; appliesOn = "live"; format = cColorFmtRgb): Setting

A colour row. default is "r,g,b" or "r,g,b,a", components in 0..1 -- the exact text the host's duParseRgb already reads out of aowlspt-debugui.json, so switching an existing stringSetting colour key to this builder changes the DRAWN CONTROL and nothing on disk.

Note what is deliberately NOT done here: the default is not normalised, re-formatted or round-tripped through a float. A colour that goes in as "1,1,0.62" is stored as "1,1,0.62". Re-formatting is how a colour picks up drift -- 0.62 becoming 0.6200000000000001 on every save -- and drift in a value the user typed is indistinguishable, from the outside, from the setting not persisting.

format names the TEXT SHAPE the owning mod's own reader parses -- see Setting.colorFormat. Leave it alone unless the mod already parses hex; "hex"/"hex#" exist so an EXISTING hex key can be upgraded from a bare text box to this picker without touching a byte of what is on disk.

aowl/src/aowlspt/settings.nim:268

selectSetting

nim
proc selectSetting*(key, label: string; default: string; options: seq[string]; optionLabels: seq[string] = @[]; optionsUrl = ""; category = ""; subcategory = ""; description = ""; implemented = true; appliesOn = "live"): Setting

A choice out of a set too large to draw as a <select> -- an item id out of the whole handbook, a map, a trader. The VALUE is a plain string, the same as enumSetting, so nothing about persistence or configSet changes; only the control a renderer picks does.

Two ways to supply the choices, and a row may use both:

  • options (with optional optionLabels, a parallel array of display names) ships the choices inline in the schema. Fine for hundreds.
  • optionsUrl names a route the UI queries as the user types -- GET <optionsUrl>?q=<term>&limit=<n> returning {"options":[{"value":"...","label":"..."}...]}. This is the one that scales to thousands of item ids, because the schema stays small and the mod that owns the ids does the searching.

A renderer that has never heard of select and falls through to its enum branch still draws a working control from options; that is why the value shape was kept identical rather than introducing an object.

aowl/src/aowlspt/settings.nim:297

settingPath

nim
proc settingPath*(s: Setting): seq[string]

The GROUP PATH this row lives at, to ARBITRARY DEPTH.

category and subcategory stayed exactly as they were -- every mod that has already declared settings keeps working, unedited, and keeps getting the same one- or two-level grouping it had. Depth beyond two is expressed by putting separators IN category (or subcategory):

category = "Player/Health/Regeneration"

which yields the path ["Player", "Health", "Regeneration"] and renders as Singleplayer > Player > Health > Regeneration. That was chosen over adding a path: seq[string] argument to all seven builders because it changes NO existing call site and no existing wire field: category and subcategory are still emitted verbatim next to path, so a renderer that has never heard of path (the served web UI, an older overlay) degrades to the two-level grouping it already drew rather than to nothing.

Empty segments are dropped, so "A//B", "/A/B" and "A/B/" all mean ["A", "B"] -- a stray separator must not produce an unnamed group, which is precisely the "a group renders with no title" defect this exists to make unrepresentable.

aowl/src/aowlspt/settings.nim:331

toJson

nim
proc toJson*(s: Setting): JsonObject

One row, as the UI receives it. value is the current value read out of config.json (falling back to defaultJson when the file has no such key), so the panel can be drawn without a second round trip.

aowl/src/aowlspt/settings.nim:365

schemaJson

nim
proc schemaJson*(settings: seq[Setting]): Json

A whole schema as a JSON array, current values folded in.

aowl/src/aowlspt/settings.nim:441

declaredSettings

nim
proc declaredSettings*(): seq[Setting]

What this mod declared, for a route handler that serves it.

aowl/src/aowlspt/settings.nim:540

declaredSchemaJson

nim
proc declaredSchemaJson*(): Json

This mod's declared schema, current values folded in — the body a /aowlspt/settings/<guid> route returns.

aowl/src/aowlspt/settings.nim:544

declaredSchemaReply

nim
proc declaredSchemaReply*(applyStatus: Status): Json

The reply a route sends after a POST (edit or reset) attempt. Ok replies with the bare array GET always returned — the wire shape both the F12 overlay and the fallback page already parse. Anything else wraps the SAME rows in {"err":...,"rows":[...]} instead: a caller that only ever checked Array.isArray on the reply could not tell a persisted write from a discarded one, which is the exact 200-with-unchanged-echo shape this whole change exists to stop being silent. rows still carries the current schema so a caller that has not been updated to look at err degrades to "did not refresh" rather than "threw".

aowl/src/aowlspt/settings.nim:574

applySetting

nim
proc applySetting*(key, valueJson: string): Status

Persist one edit into this mod's config.json. valueJson is a JSON literal — "1.0", "true", "\"M\"". The host merges it into the file; a mod that wants the new value live re-reads it (most call their own loadConfig again) rather than this function applying it, because only the mod knows which of its runtime writes a given key feeds.

OBSERVABILITY, and why it is here rather than in each transport. This is the ONE proc every settings write in this process passes through -- applySettingFromBody (the route and the bus both), resetSetting, resetAllSettings and backfillDeclaredDefaults. A write logged per transport can only ever name the transports we already knew about; logged here, a transport nobody has thought of still announces itself. That is what the maps OFF-toggle hunt needed and did not have: three transports were each patched and the bug survived, with nothing in the log saying which process had done the write.

aowl/src/aowlspt/settings.nim:598

applySettingFromBody

nim
proc applySettingFromBody*(body: string): Status

The edit a settings-UI POST carries, applied. The body is {"key":"<configKey>","value":<literal>} — the F12 overlay's write-back shape — where value is a JSON literal (1.5, true, "KeypadMultiply"). The key is one of this mod's own config keys and the value is persisted verbatim, quotes and all, into config.json.

ErrBadArg when the body is not that shape, so a route handler can serve the schema back regardless and the panel simply shows the unchanged value. A mod that wants the new value live re-reads its config after this returns Ok — the same rule as applySetting, and for the same reason.

aowl/src/aowlspt/settings.nim:618

resetSetting

nim
proc resetSetting*(key: string): Status

Reset one declared key to the value its builder call declared as default. ErrNotFound for a key this mod never declared -- resetting an unknown key is not "no-op successfully", it is "there is nothing to reset", and the caller should see that rather than a silent 200.

aowl/src/aowlspt/settings.nim:667

resetAllSettings

nim
proc resetAllSettings*(): Status

Reset every declared key of this mod to its default. Stops at the first failure and reports that key's status -- a partial reset with no indication of where it stopped would be worse than refusing outright.

aowl/src/aowlspt/settings.nim:677

resetFromBody

nim
proc resetFromBody*(body: string): Status

The body a reset POST may carry: {"key":"<configKey>"} resets that one key; an empty body (no key field, or an empty body entirely) resets every key this mod declared. Mirrors applySettingFromBody's shape.

aowl/src/aowlspt/settings.nim:689

settingApplied

nim
proc settingApplied*(detail = "")

Call from inside an apply hook: the change is IN FORCE NOW, and detail should name the value that is now live (not the value that was stored -- those differ whenever a preset, a clamp or a master switch is involved).

aowl/src/aowlspt/settings.nim:766

settingAppliesOnRestart

nim
proc settingAppliesOnRestart*(detail = "")

Call from inside an apply hook: stored, but it cannot take effect until the game is relaunched. This is a legitimate answer; silence is not.

aowl/src/aowlspt/settings.nim:773

settingIgnored

nim
proc settingIgnored*(reason: string)

Call from inside an apply hook: stored, and deliberately NOT acted on. reason is mandatory, because "ignored" without a reason is the same dead end as saying nothing.

aowl/src/aowlspt/settings.nim:779

onSettingsApplied

nim
proc onSettingsApplied*(cb: SettingsApplyHook)

Register the hot-apply hook. A mod without one still gets its page and still persists edits -- it just will not SHOW them until it next re-reads its config, and a control that moves and changes nothing is the exact failure this whole mechanism exists to avoid. Register one.

aowl/src/aowlspt/settings.nim:786

backfillDeclaredDefaults

nim
proc backfillDeclaredDefaults*(settings: seq[Setting]): int

Write the declared default into config.json for every declared key the file does not already hold. Returns how many keys were added.

THIS IS HOW A NEW KEY REACHES AN EXISTING INSTALL, and without it there was no such path at all. The deploy seed rule copies a mod's config.json only when the file is ABSENT -- deliberately, because overwriting one is overwriting the player's settings -- so a key added to the repo's config.json after the first deploy never arrives. That is exactly what happened to progressionPlayerLevel, progressionSkillLevel and progressionMasteryLevel: present in the repo, declared, wired to emu/progression, and absent from the live mods/tarkov/config.json, so configGet answered ErrNotFound, the rows rendered at their schema defaults and resolved to nothing.

Backfill only, never overwrite: a key the file already holds is left alone, whatever its value, so a player's edits survive every upgrade. The only observable change to an install that is already complete is zero writes.

A file that exists and does not PARSE is refused outright rather than backfilled: configSet would merge into a document we cannot read, and the honest answer to "which keys are missing" from an unparseable file is that we do not know. It warns and writes nothing.

aowl/src/aowlspt/settings.nim:853

declareSettings

nim
proc declareSettings*(settings: seq[Setting]; inIndex = true)

Register this mod's schema. Replaces any previous declaration — call it once with the whole list, not once per row.

aowl/src/aowlspt/settings.nim:910

serveSettingsRoutes

nim
proc serveSettingsRoutes*(): Status

Register /aowlspt/settings/<guid> and /aowlspt/settings/<guid>/reset with the standard handlers.

This existed already, four times, copied by hand: mods/waypoints, mods/tarkov and the rest each wrote the same two three-line handlers and the same two serve calls. Copied boilerplate is where the drift lives -- a mod that spelled the route /aowlspt/settings/<guid>/ with a trailing slash, or forgot the reset half, gets a settings page whose controls store nothing, and nothing anywhere says so.

Call it AFTER declareSettings, from onLoad, on the server side. It is a no-op returning Ok when this mod declared no settings, so it is safe to call unconditionally.

Not folded into declareSettings itself, deliberately: declareSettings is called on the client side too, where there is no route table, and a mod that already registers these routes by hand would double-register and get a conflict it did not ask for.

aowl/src/aowlspt/settings.nim:947

aoughwl — self-hosted platform for things n stuff. Contact / Support on Discord for access to the private backends.