Skip to content

alcvar: settings declared once, read through handles, kept in settings.db - #588

Draft
RyeMutt wants to merge 7 commits into
rye/aldbfrom
claude/alcvar-284073
Draft

RyeMutt wants to merge 7 commits into
rye/aldbfrom
claude/alcvar-284073

Conversation

@RyeMutt

@RyeMutt RyeMutt commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Stacked on #586 (rye/aldb). Adds alcvar, the store the viewer's settings are to move onto, and alcvargen, which compiles their declarations. Nothing links alcvar yet, and indra/cvars has no areas yet: the tests run on a schema of their own. How to declare and change a setting is in doc/CVARS.md. Draft until this push's CI passes.

What it does

  • Declared once, read through handles. Settings are declared in TOML under indra/cvars.

    • alcvargen (tomlplusplus) fails the build at the entry's line and column for bad names, types, defaults, ranges, units and groups, with "did you mean" for misspelled fields, units, types and values.
    • It checks every setting's history against the committed lock (below).
    • --where finds a setting or an old name.
  • Generated code:

    • a header for each group, holding only names and types and rewritten only when they change, so editing a default, a range or help text recompiles one .cpp;
    • descriptor tables carrying each setting's steps and migrates, constinit slots, the registry, cvars.json, a natvis entry and an LLDB summary.

    The generator is built for the machine the build runs on, in a tree of its own, so cross builds work. It runs at configure time and in the build.

  • A handle's read is one load from a constant-initialized slot, kept to one load under -fPIC by hidden visibility on the generated storage. Small vectors and colours sit behind a sequence word. Strings and lists are immutable values behind an atomic shared pointer.

  • Layers: compiled default, Quality, Global, Account, Session. Owners can hold a value, clamp a number, deny writes or hide a setting until they let go.

    • A hold is fitted as a write is, and other owners' clamps bound it. Denied writes cover resets and scope moves too.
    • A set() that changes nothing returns before taking any lock.
    • read() of a group sees a change whole or not at all.
  • Coroutine-safe writes: every lock in the write path, the main-thread queue and persistence is an LLCoros::Mutex. A write may come from any coroutine, and a coroutine waiting for a lock gives its thread's other coroutines their turn.

  • Listeners are told on the main thread after the slot holds the value, from the value they were last given.

    • Writes from other threads are coalesced and told at pump().
    • A batch tells each setting that changed once.
    • A pass still feeding itself after 16 rounds asserts.
    • Validators and migrations are registered by name, so alcvar links nothing above it.
  • One conversion for every stored value, across types, units, choices and renames, by the same table alcvargen checks the history by. find() resolves old names through alias codecs: inverted booleans, UUIDs or lists kept in strings, old units.

  • settings.db: a Durable aldb file.

    • The flusher, an LL::ThreadPool of one that the store closes (not the viewer's quitting), writes changes in one transaction 250 ms after the last change, and at most a second after the first.
    • Rows carry the fingerprint of the step they were written at, history, tombstones and LLDate timestamps. What a flush carries is kept until the writer says it committed.
    • An LLSD setting's value crosses to the flusher as JSON text, never as LLSD.
    • A file that is damaged, of another kind, or open only in memory isn't kept. A read-only file is read, nothing is written to it, and onFailure is told so.
    • The account's layer is swapped in one write at login and kept only once confirmAccount() says the login succeeded.
  • XML import: the settings.xml and per-account XML an LLControlGroup saved are imported once. The first quality level applied afterwards settles which values the feature table wrote. Values of settings whose scope moved are kept for the scope moves, and names this build doesn't know are kept for the viewer that declares them.

The cvars lock

indra/cvars/cvars.lock is the history of what each setting's stored values have meant, committed with the change: JSON Lines, one line per step. There's no schema version and nothing waits for a release.

  • A step is known by its history. Its fingerprint hashes the step it follows, its type, unit and choices, and its migrate. Two branches that each add a different step 2 to one setting get different fingerprints.
  • What makes a step: a type or unit change, a choice removed or remapped (was, folded across steps), a migrate, a rename. Adding a choice makes none. Defaults, ranges, help and labels are outside the lock.
  • The lock only grows. Every build rebuilds each setting's history from its entry, so an edited or deleted migrate, or a reverted step, fails at the entry. CI checks a pull request's lock against its target branch's (alcvargen --check --base), so a revert can't remove a line unseen.
  • Names are never reused.
    • A rename continues the old name's history, so a migrate from before the rename reaches an old row once.
    • A deletion is recorded retired, and declaring a retired name again needs reuse = true.
    • A name that vanished without either fails the build, and names the alias to add.
  • Forks are joined, not cut. When two branches change one setting, the build names both steps. The merged entry ends in a join that answers the other branch's steps, and both branches' lines stay.
  • At open:
    • A row at an earlier step is walked forward and rewritten at the last step, recorded in history.
    • A row is rewritten only when its step moves. A clamp or a fallback applied as it's read is never written back, so an older viewer can't clamp a newer one's value for good.
    • A migrate's rewrite keeps the old row in prior, for a viewer that knows only the old step.
    • A row of a step this build doesn't know is read by its own type and unit and left alone.
  • Hooks fail alone. A migration hook that throws or gives nothing costs that row's value for the session, logged, never the start. A map names what happens to values it doesn't list (else). A setting can take its first value from others (from), for a split or a merge, at the XML import as well as over settings.db.

Developer tooling

  • Diagnostics in the compilers' format: file:line:col: error:, and file(line,col): error CV0001: under Visual Studio.
  • A local build records a new setting's first step itself. CI configures with AL_CVARS_STRICT, which fails on any line the commit doesn't carry.
  • cvars_lock records any other change, folding the steps a setting gained since HEAD into one.
  • alcvargen --help; doc/CVARS.md; a JSON Schema (indra/cvars/cvars.schema.json, wired through .taplo.toml) so editors check the TOML.
  • The slider increment is ui_step, so step means a lock step alone, and cvars.json has one key per meaning.
  • alcvar_preview <settings.db> prints what each row of a real file would become, and writes nothing.

Since the last description

Testing

  • Windows, at this push: ctest -L 'alcvar|alcvargen|aldb' passes 19 of 19, codegen gate included. alcvar_tests has 126 cases and alcvargen_tests 37, both also run in shuffled orders.
  • Fail first: every new generator and runtime case was seen failing on the code before its change.
  • Checked by hand with the generator:
    • two branches' different step 2s get different fingerprints, and their union is refused as a fork;
    • a rename without an alias fails, naming the alias;
    • --check --base fails on a line missing from the lock;
    • misspellings are located and suggested.
  • The cases include:
    • a killed process: a child writes, waits past the longest delay, writes again and is killed; the first write survives;
    • two reader threads that never see a torn value or half a quality level's change;
    • every conversion-table row and its round trips;
    • histories with renames, a retired name reused, a fork and its join, a split, a choice renamed twice, and a hook that throws;
    • the flusher's pool going when the store closes;
    • a default edited in TOML recompiling only its area's .cpp under MSBuild.
  • Codegen gate (AlCVarAsmCheck.cmake): in RelWithDebInfo and Release, each scalar read must be one load from its slot and a return.
  • GCC 16.2: all 30 sources compile with -Werror.
  • CI at the previous push (before the redesign): every job passed, including the macOS, Linux arm64 and ASan test runs.
  • Benchmarks, pinned to one core, measured with the budgets commit: a scalar read 0.23 ns (budget 1 ns), an LLColor4 0.65 ns, read() of 71 values 19.9 ns, an unchanged set() 10.2 ns, a 150-setting Look 43.4 µs, opening a 500-row file 1.35 ms. They haven't been run since the lock and the redesign.
  • Owed:
    • this push's CI, including the new lock check's first run;
    • Taplo in a real editor;
    • actionlint on the workflow change;
    • the natvis and LLDB formatters in a real debugger;
    • the benchmarks again.

Left for later

  • Before the switch:
    • the floater-geometry and notification-ignore tables, in place of runtime-declared settings;
    • the runtime's rules held in Release builds (no store lock held long, batches belonging to a coroutine, a listener that throws failing alone, LLCachedControl refreshed from the setting's generation);
    • the store's lifetime owned by LLAppViewer.
  • The spelling check on names needs a word list.
  • alcvar_preview reads settings.db only; .albackup has no reader yet.
  • aldb's onFailure isn't connected to the viewer yet.
  • Comparing the import against real users' files needs the viewer's schema; that check belongs to the switch-over.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Oct 27, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@RyeMutt
RyeMutt added this pull request to stack #589 October 27, 2026 06:00
@RyeMutt
RyeMutt force-pushed the claude/alcvar-284073 branch from bdb85f2 to aeca4e7 Compare October 27, 2026 07:42
@RyeMutt
RyeMutt force-pushed the claude/alcvar-284073 branch from aeca4e7 to 4943200 Compare October 27, 2026 09:14
@RyeMutt
RyeMutt force-pushed the claude/alcvar-284073 branch from 4943200 to 37f5c53 Compare October 27, 2026 19:56
@RyeMutt
RyeMutt force-pushed the claude/alcvar-284073 branch from 37f5c53 to b236744 Compare October 28, 2026 01:22
@RyeMutt
RyeMutt force-pushed the claude/alcvar-284073 branch from b236744 to f88aa77 Compare October 28, 2026 05:08
@github-actions github-actions Bot added the github_actions Pull requests that update GitHub Actions code label Oct 28, 2026
@RyeMutt
RyeMutt force-pushed the claude/alcvar-284073 branch 2 times, most recently from 4366f4d to e7a7649 Compare October 28, 2026 06:25
RyeMutt and others added 7 commits October 28, 2026 05:27
… them

The viewer's settings are to be declared once, in TOML under indra/cvars,
and read through typed handles rather than by name. This is the first part
of alcvar, the library that keeps them: the compiled form of a schema and
the generator that writes it. Nothing links alcvar yet, and indra/cvars has
no areas yet; the tests run on a schema of their own.

alcvargen reads the declarations with tomlplusplus and fails the build at
the entry for what it can see, as a compiler says it, so that an IDE lists
it and opens the entry: the entry's absolute path, line and column,
file:line:col: error: <setting>: <what>, and file(line,col): error CV0001:
under MSVC, which Visual Studio's error list reads; a note says the same
of its entry. A field, unit, type, enum value or area nearly right says
what it nearly is (did you mean 'default'?), and step, which is the lock's
word alone now, says that a slider's increment is ui_step. It fails for
names that are not lower_snake, a
keyword, a macro some platform defines, a field's or a generated name, an
area not in the list, an origin in a name, a boolean naming what false
means, a unit or a misspelling in a name; unknown types and fields,
defaults of the wrong type or outside their range or choices, a UUID that
says nothing of what it refers to, a string naming a file with no asset
kind, a sensitive setting backed up unasked, a dangerous one in a preset,
a group declared after its settings or not at all, a name or an alias
declared twice, an area split over two files; a tag that is no C++ name --
a keyword, a macro, a digit first -- a number that is not finite, as
TOML's inf and nan are not, in a range, a ui_step, a scale or a map, and an
llsd default that is not JSON the viewer reads: no plus, no leading zero,
a digit on each side of a point. --where finds a setting, or an old name,
in the declarations or in a cvars.json; --help says what each option does,
and doc/CVARS.md, tracked, how to add, change, rename, delete and split a
setting and what every message asks.

What each setting's stored values have meant is a lock committed beside
the schema, indra/cvars/cvars.lock: JSON Lines, a line a step of a
setting's history, sorted by name and step, each the step's type, unit and
choices with the values that became them, the step it follows (parent),
the hash of its migrate, and its fingerprint (decl), a hash of all of
them. A step is known by its history, not its number: two branches' step
2s of one setting have two fingerprints, and a migrate edited after it was
recorded makes another. A default, a range, help, a label and the name are
not in it, so editing any of them, or moving a line, leaves the lock byte
for byte. A setting's first step is written when it is declared; it gains
one when its type or unit changes, when a choice is taken away or given to
another (`was`), when a migrate -- now a list, an entry a step: migrate =
[{ step = 2, map = ... }] -- names the step after its last, and when it is
renamed. A choice added makes none, as no stored value means anything new
by it, and each choice's `was` folds in what the steps before gave it, so a
choice renamed twice still takes its first name's values; a `was` that is
still a choice is refused. A map takes a value of the step before, a
number or a string as that step held it, to one of its own, and says what
becomes of what it does not list (else = "keep", "default" or a value); its
keys are checked against the step before's choices, a string's too, and its
values and its else against its own. A map of numbers hashes as it always
has. There is no schema version, and nothing waits for a
release.

The lock only grows. Every run works each setting's history out again
from its entry -- the lock's steps, its migrate list, the rename's codec --
so a line the entry no longer gives, a migrate edited or deleted, fails at
the entry, and a line that follows one the lock does not hold fails at the
lock; --base checks that every line of a target branch's lock is still
there, which a revert or a dropped commit would otherwise take out unseen.
A rename continues the old name's history: the new name's first step
follows the old name's last, its migrate the alias's codec, and the old
history ends in a renamed_to line. A setting deleted is retired by the
lock's target; a name renamed away or retired is declared again only with
reuse = true, which begins a history its old values never meet; and a name
in the lock that is declared nowhere, an alias of nothing and not retired
fails the build, naming the alias to add where a new setting has its type,
unit and choices. A setting may name settings it takes its first value
from (from = [{ name, fn }]), several naming one source, each in the lock.
Each step must follow from the one before by the conversions or by a
migrate of its own, so a change nothing converts fails at its entry,
naming the step to write -- fn or reset for a type nothing converts, since
no map or scale takes it. A unit named where there was none, or dropped,
keeps the number as it is. A new setting's first step, which changes
nothing stored, a local configure or build records in the lock itself and
goes on (--record-new), saying so; any other change the lock does not
record yet fails the build, saying what it does to stored values -- a step
that fails says that alone, not what it would do -- and naming the lock's
target, cvars_lock, which writes the new lines sorted among the ones there
are, touches none of them, and refuses a change nothing answers. It reads
the lock HEAD holds through git (ALCVarCommittedLock.cmake, --committed)
and works the lines HEAD does not hold out again from the entries, so the
steps a setting gained since are one: a change made twice before a commit
is one step. With no git, or a merge, rebase, cherry-pick or revert under
way, whose other side's lines HEAD does not hold yet, it folds nothing. A
configure only warns, generating the step each change will be, so that the
tree it writes can build that target, which depends on the generator and
the schema alone; it prints the generator's lines as they are, at their
entries, before CMake's own. AL_CVARS_STRICT, which every CI configure now
sets, records nothing and fails the configure on any line the commit does
not carry, a new setting's too, and a pull request's lock is checked
against its target branch's with --base in one Viewer row of the build. The
lock merges by union (.gitattributes): lines in any order are one lock, a
line merged in twice once. Two steps that follow one step are a fork,
refused, naming both, until the merged entry keeps one branch's migrate
list and ends it with a join, which answers each of the other branch's
steps by its fingerprint; both branches' lines stay, as either may have
shipped. A line whose fingerprint is not its own is refused. Each
descriptor carries its setting's steps, which old name each was written
under, its migrates by step, a rename's and a join's among them, the
steps of the setting a reused name was before, and its sources with their
steps.

It writes a header a group: the group's storage as a struct of arrays by
size, an empty constexpr handle a key, ALCVar<T, Group_, key>, and read(),
the group's numbers and small values at one moment. A header holds names
and types alone and is written only when it changes, so a default, a
range, help or a line inserted above an entry recompiles the area's one
.cpp; measured, a default edited in the test schema regenerated and
recompiled cvartest_render.cpp and nothing else under MSBuild. It also
writes the areas' descriptor tables, each array named by its setting's
whole name with the letter after each dot made upper case, so that two
names that flatten alike stay apart, and constinit slot arrays, the
registry, cvars.json -- one key a meaning, ui_step the slider's increment
and step the lock's, where an entry once held step twice -- a natvis entry
and an LLDB summary a handle; the
units' enumerators are a table the build checks against the enum. It is
built for the machine the build runs on, in a tree of its own with
tomlplusplus header-only and none of the build's flags -- a Visual Studio
build for ARM64 on an x64 machine builds an x64 generator, an arm64 build
on an Intel Mac an x86_64 one, an AL_ISA_TIER=v4 tree one any machine runs
-- begun again whenever how it is configured changes. It runs at configure
time, so that a fresh checkout's IDE has the headers, and in the build,
which a configure has rebuilt it for whenever its sources changed.
--json-schema writes the JSON Schema of the declarations from the
generator's own tables, committed as indra/cvars/cvars.schema.json, which
.taplo.toml gives Taplo and Even Better TOML to complete and check the TOML
by: each field's help, the types, units, scopes and flags, the areas.

A slot is a plain integer, constant-initialized with its default and
accessed only atomically, so a handle's read is the compiler's relaxed
load builtin of one address the linker gives: no object, no guard, and one
instruction even unoptimized. bool is a byte, S32/U32/F32 a word,
S64/F64/vec2 a double word, the 16- and 24-byte values two or three words
behind a sequence word, strings and lists an immutable value behind an
atomic shared pointer, LLSD a box for the main thread. vec2 is in a double
word, where the plan left it unplaced, so it reads with one load.

ALCVars, an LLSingleton, takes a schema once: it numbers every setting and
group, puts every slot back to its compiled default and publishes the
strings', lists' and LLSD's defaults. Before it, handles read the compiled
defaults, and strings and lists their descriptor's, and a generation reads
0 rather than making a store to ask.

alcvar_tests: 9 cases on the test schema, every type's default with and
without a store, descriptors and the steps they carry, registration,
read(), and each type's LLSD and JSON forms; the test schema has a lock of
its own, two of its settings with a type and a unit changed in their
history, and others a rename, a retirement and a reuse, a fork joined, a
split, a migration by name, a choice renamed twice, a string's choices
remapped and a string's become numbers by a map, and a split from a
declared source with a migrate. alcvargen_tests: 37
cases, broken declarations failing at their
file and line, headers unchanged by default, help and range edits and a
new key changing its group's alone, finite numbers, names that flatten
alike, llsd defaults, tags, --where, and the lock: a convertible change
said and recorded as the next step, the lines there were left as they
were; one nothing converts failing with the step to write, a map or a
scale for a rect, a migrate of the first step and one two steps on
refused, and passing once reset answers it; a lock behind the schema
failing with its target's command while a configure goes on with the
step; recording that refuses an unanswered change and touches no line; a
deleted migrate, and one edited after it was recorded, failing at the
entry; a line taken out of the lock failing, and one a base branch has; an
entry changed back taking a new step; a default, range, help or label
edited and a line moved leaving the lock byte for byte; a union merge's
lines in any order read as one lock; two branches' steps of one setting
told apart, refused as a fork, naming both, and passed once a join answers
the other, and a line edited by hand refused; a setting deleted failing
with the alias to add, then retired, and its name declared again only
with reuse; a rename continuing the old name's history; a choice added
making no step; a `was` still a choice, a map with no else and a map key
the step before could not hold refused; sources the lock holds; a unit
named or dropped keeping values; and a unit that does not scale answered
by a migrate of its step; messages in the compilers' form at an absolute
path and column, Visual Studio's form, a relative path said whole, a note
at its entry and the lock's error at its first line; no note for a step
that fails, an integer's range as integers, and what a misspelled field,
unit, type, value or flag nearly is; cvars.json read by a reader that
refuses a key held twice; a local build recording a new setting's first
step and nothing else, a broken schema recording nothing, and a strict one
failing; a recording folding the steps since the committed lock into one,
and recording a step a recording with none; a map of strings, its keys
and values checked; --help naming every option; and the committed JSON
Schema the generator's. alcvargen_host, a CMake script, gives the generator's
own build each generator and build machine; alcvargen_committed reads the
lock HEAD holds in a scratch repository, and none with no git, no commit or
a merge under way. The header-stability case
fails with a line number put into the handles' comments; the lock's cases
with the generator taking the lock's path and recording nothing, then with
the step number hashed and no parent, renames, retirements, forks, `else`
or sources, and the byte-for-byte case with a setting's range made part of
a step; the `was` folded across steps with the fold taken out; the
cases added with inf and nan taken, a dot flattened to one underscore, the old JSON reader, keywords,
macros and a digit first taken as tags, and the build's own platform
passed to the generator's. The eight cases of the messages, ui_step, the
local record, the fold, string maps, --help and the JSON Schema fail on the
generator before them, which wrote step twice into an entry of cvars.json
(the slider's 8.0 and the lock's 1) and said missing.toml:0: cannot be
read; the 18 cases that assert messages fail on its form, and
alcvargen_committed with no script. The JSON Schema accepts the test
schema's files and refuses eight broken ones under Python's jsonschema;
actionlint passes the workflows. On Windows, a configure printed a broken
entry's errors in Visual Studio's form and failed, a configure and a build
each recorded a new setting's first step, and a strict configure refused
it. The units' table fails the build, not a test,
when a unit is put in the enum alone: shown with GCC, which compiles the
old array silently. tomlplusplus is a build tool in the attribution table.
Built and passing on Windows; macOS and Linux have not been built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er them

A setting's value is now worked out from layers rather than written over:
its compiled default, the Quality layer the feature table will fill for
the level in use, the user's Global and Account layers, and the Session
layer of the command line and forced values; then held or clamped by
owners. What each layer is and who writes it is fixed by the store, not by
the order things happened to be loaded in.

set(), from any thread, writes the user's own layer -- Global, or Account
for an account setting and for an account_override one the account has a
value of -- and clears the Session value over it, as a saved write
discards the unsaved values today. A value equal to the compiled default
is not kept unless the Quality layer gives the setting one of its own, so
a value the user picked stays theirs when the GPU class or the feature
table changes. A write is refused, and set() returns false, when it is of
the wrong type, not finite, not among the choices, a file name that is not
a bare name, or one Windows opens as a device (NUL, COM1), for a setting
with an asset kind, or denied; a number past the range, or a vector's
component, is clamped into it. reset() drops the user's value,
resetGroup() every one in a group and below it. setQuality() replaces the
Quality layer, and drops the user's values of what it names when a level
is chosen. A setting whose writes are denied keeps its user value through
all of them. A handle's write with no store does nothing and makes none: a
store asked for on a worker is made on the main thread, which may be
waiting for that worker.

Owners hold a value, clamp a number, deny the user's writes and hide a
setting or a group, each until that owner lets go, so the user's value
shows again when the last one does. Holds stack, the last placed winning;
clamps combine, the tightest winning, the higher minimum where they leave
nothing between them. A hold is fitted as a write is, clamped into the
range and refused outside the choices, so a reader indexing by choice
never sees another; owners' clamps bound a hold as they bound the layers,
and what they give stays within the declared range. A clamp's bounds are
finite, and kept within what the setting's type holds, so one past it
gives the type's most rather than a number converted out of range.

Every write is serialized by one lock, under which the layers change and
the effective value is published: each touched group's sequence is held
odd until the last of its slots is written, so read() sees a change whole
or not at all, and the setting's, its tags' and its groups' generations
move. The lock, and the one over the strings writes replace, are the
coroutines' own (LLCoros::Mutex): a write may come from any thread or
coroutine, and one that waits for a lock lets the rest of its thread's
coroutines run rather than stopping the thread. A write that would change
nothing -- the value the slot holds, no Session value or owner above it,
and nothing the layers would keep or drop -- returns before the lock, from
a flag word each setting keeps, read with the slot between two reads of
the setting's write sequence, which a write under the lock holds odd from
the flags to the slot: a hold or a Session value placed meanwhile sends
the write the long way rather than letting a stale flag pass a fresh value
for the user's. A string or list a write replaces is kept until the main
thread's endFrame(), so a view taken in the frame stays valid to its end.

alcvarlayers_test: 17 cases. A million unchanged writes take no lock and
move no generation, 13 ms in all. The cases fail with the early return
taken out and with the Session value left under a user's write; the five
added with the write sequence left out of the early return (a worker's
write lost to a hold landing under it, once in 24,000 pairs), resets of a
denied setting going through, holds unfitted and under no clamp, bounds
converted past the type, and device names taken. Built and passing on
Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A change of a setting's effective value is now told to its listeners on
the main thread, with the new value and the last one listeners were given,
after the slot holds it: the voice listeners read the very settings that
changed, as LLControlCache's connection group 0 arranges today. A write of
the value already held tells nothing, and a change is told from the value
listeners were last given: one written again while it waits in a pass, or
queued before a value the pass read, is not told twice.

A change made on the main thread outside a batch is told before set()
returns. One made on another thread is queued under the write lock, in the
order made, coalesced by setting, and told at the main thread's pump().
Inside an ALCVars::Batch nothing is told until the outermost one ends;
then each setting that changed is told once, and one that changed back not
at all. A listener may write settings, and what that changes is told in
the same pass, round after round; a pass still feeding itself after 16
rounds asserts and leaves the store as it was, from a batch's end as from
a write. A batch unwound by an exception leaves its pass for the next
pump, and one kept past its store ends nothing on the next. Two settings
that mirror each other settle, since the mirrored write changes nothing. A
listener may disconnect itself or another during a pass: it is not called
again and is removed when the pass ends. A listener that owns another's
connection lets it go once it is out of the table, and as the store is
destroyed its listeners go first, while the store is whole. A group's
listener hears of every change in the group and below it. Listeners are
move-only callables in a table by setting, connected through an RAII
ALCVarConnection, not signals2; a write with no one listening records
nothing.

Declarations name validators by string, and the code that owns a rule
registers it at init, so alcvar links nothing above it. A validator may
adjust a value, which is fitted to the setting again, or refuse it. One
that reads the main thread's state is registered for the main thread:
every write of its settings from another thread -- set, reset, a Session
value set or cleared, an unchanged one too -- is posted there and made at
the next pump in the order it was made, validated there, so a later write
never lands before an earlier one. A Session value is validated as set()
validates. checkHooks() asserts that every validator the schema names has
been registered, for the viewer to call once startup is done.

alcvarlisteners_test: 19 cases. alcvarthreads_test: 4 cases on two reader
threads, small values never torn, a group's read() never half of a quality
level's change, and a worker's shares of a string replaced twenty thousand
times across frames. A fourth has a worker and the main thread write one
listened setting, each change told from the value the last ended on. As
before, both thread cases fail when the sequence word's retry or the
group's sequence is taken out, and the batch case when unchanged settings
are told; the order case with an unchanged write and a reset made at once,
and the Session case with no validator run; the owner case's process dies
with a sweep inside a sweep, the told-once case tells the round's own
write again, both batch cases end in std::terminate, the no-store case
segfaults, and the chain case breaks (once in 11,526 changes) with changes
queued after the lock and no value recorded as told. The case of a
connection let go as the store is destroyed does not fail without a
sanitizer: what it reads of the dying store still looks whole. Built and
passing on Windows; the sanitizer runs are Linux's and have not been made.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y name

A setting's declaration may change: its type, its unit, its choices, its
name. Whatever was stored under the old declaration is to follow by
itself, so every stored value -- a settings file's rows, and later preset
values, backups and imported XML -- goes through one conversion,
ALCVarConvert::fromStored, into the setting it is now.

The conversion table is the one alcvargen reports changes by
(alcvarconvertible.h), and a test holds the two together: for every pair
of types, toType converts exactly when the table says it does. Integers
are clamped to a narrower type, integers to floats exact, floats rounded
to the nearest integer and clamped, any nonzero number true; strings
formatted and parsed, one that does not parse nothing; a number in every
component of a vector and alpha 1, colours gaining and losing alpha, a
vec3 and a vec3d; a scalar a one-element list; LLSD through its form where
the shape fits. Units scale between units of one quantity -- seconds and
milliseconds, degrees and radians, percent and ratio, metres and
kilometres, bytes and megabytes -- and not across quantities; a side that
said nothing of a unit is taken to be the other's. An integer setting's
value is scaled as a real and rounded once, so an alias in another unit
reads and writes a real: 1500 ms is 1.5 s under the old name, not 2.

A stored value carries the fingerprint of the step of its setting's
history it was written at, and is brought forward through each step after
it, in order. A step with a migrate -- map and its else, scale and
offset, reset, or a migration registered by name, which is given the value
and the others stored by name -- takes the value as the step before held
it and gives it in its own type and unit; between them
the table converts it and scales its unit, an integer's value a real until
the last step rounds it once. So a migrate reaches every value written
before its step, and none of its own step or a later one. A map's keys are
matched as the step before held the value -- a number in that step's type,
as an F32 holds 0.1, and a string by its text -- and it gives a number or,
for a string, text, its else too; a unit that does not scale is the map's
or the scale's to answer. A value of
a step the history does not hold -- a newer viewer's, another branch's --
is read by its own type and unit, through the table alone; one of the
setting a reused name was before is never this one's. One found under an
old name is at its step of the old name's history and comes through the
steps after it, the rename -- the alias's codec -- and the setting's own,
so a migrate the setting had before its rename reaches it once; where the
old name had no history, through the codec at the setting's first step.
One of no step is the setting's own first step's: what the XML import
keeps of a name the schema did not declare then, taken as every imported
value is. One of a fork's other branch takes the join's answer, at the
join's step. A hook that throws or gives nothing costs only that value:
the setting keeps its default for the session, the row is left as it was,
and the failure is logged once, with the setting, the step and the hook.
With no value of its own, a setting takes what its sources' hooks give
(`from`), each source's value brought to its own history's last step --
through its own declaration, migrates and all, where it is declared, and
by the table alone where it was retired -- at the setting's first. Then
the value is fitted against the declaration as it is: clamped into the
range, a
value no longer among the choices taken to the one whose `was` lists it,
and anything else, or anything not finite, nothing, so the setting takes
its default and the stored value is left as it was; a file name a setting
with an asset kind holds is fitted as a write is, a bare name and never
one Windows opens as a device.

ALCVars::find resolves a name or an old name, from any thread, through a
map made at registration and never changed. Its ALCVarRef reads and
writes in the form of the alias it was found under, through the alias's
codec both ways: a boolean's opposite, a UUID or a list kept in a string,
joined or as JSON, the old unit; its listeners hear in that form too.
getAs gives a value as another type by the table, as a by-name read of
another type will. checkHooks now names unregistered migrations too, a
join's and a source's among them.

alcvarconvert_test: 27 cases, every table row and its exact round trips,
the edges (U32 past S32, an F32 past 2^24, NaN, malformed strings), each
unit pair, a row brought forward through a type and a unit changed and
rounded once, a row of a step the history does not hold, a row of no step,
each migrate form
on the rows before its step and at it, a migrate reaching no row of its
step or a later one, an old name's rows of each of its steps through the
rest once, a reused name's old rows, a fork's other branch through the
join, a map's else, a `was` of an earlier step through a later one, hooks
that throw and give nothing, a split's two settings from one source,
strings mapped to strings and to numbers, a declared source's row through
its own migrates, a
unit change a scale answers, an integer's old unit, a float's map, choices
that were, a device's file name, and the codecs on every path through a
reference. Three cases fail with floats
truncated instead of rounded; those added with every migrate applied to a
row of any step but the last, a row of an unknown step taken as the
first's, a row of no step read as an unknown step's, no walk (every row
read by its own type and unit), a value
rounded at each step, the unit scaled before the migrate, an old name's
integer decoded in its own type, and keys compared as doubles; the
rename, reuse, join, else, hook and split cases with an old name's rows
read at the setting's first step, no superseded steps or joins, a map's
unlisted value kept, a hook's exception let through, and only a
migration by name merging; the device case with a bare name the only
check; the string-map case with keys matched as numbers alone, and the
declared source's with its row brought by the table, its hook given 150, a
percentage, for 1.5. Built and passing on Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A user's values now reach the disk within a second of changing, in a
transaction, so a crash or a kill loses at most the last moment's
changes and never leaves a file half written. Nothing is written at exit
but what is still to be written.

ALCVarFile is one settings.db, a Durable aldb file of kind 'ALST': its
meta, settings, history and prior tables as the plan lays them out. A row holds
the value with the type and unit it was written in, the fingerprint of the
step of its setting's history it was written at, whether it is a
tombstone, and when it was written; numbers as themselves, a UUID as its
text, vectors, lists and LLSD as JSON, so that a support request can be
answered with sqlite3.

The store keeps what is to be written for each file, setting by setting,
with its oldest old value and newest new one. A thread of its own -- an
LL::ThreadPool of one, which the store closes after its last flush, not
the viewer's quitting, and starts with whichever file opens first --
writes it as one transaction 250 ms after the last change, at most a
second after the first, and never while a batch is open; flushNow()
writes it at once and waits for both files together, no longer than a
file's close waits (2 s), and closeAccount() no longer than its close.
The thread waits on the coroutines' own lock and condition, and a row's
time is LLDate's, in milliseconds. A slider dragged through a hundred
values writes one row and one history row. A reset is a tombstone. Holds,
the Session layer and runtime settings write nothing; a sensitive
setting's history says only that it changed.

What a flush carries is kept until the writer says it committed: a
transaction the writer drops for good -- a row the file refuses, damage
found while writing -- goes again with the next flush, under anything
newer, and a failure the writer cannot get past is told to
ALCVarFileOptions::onFailure, for the viewer to say so. An LLSD
setting's values become their JSON text on the main thread as they are
marked to be written, and come back from the file as text parsed there,
so no LLSD object reaches the flusher's thread or the writer's: its
reference counts are not atomic.

Opening a file takes its rows into their layer, each through the one
conversion into its setting as it is declared now. A setting's own row and
its old names' rows are weighed by when they were written: a value the
user reset does not come back through an old name, and a change an older
viewer made since does. A row a rename or a conversion touched is written
back under the setting's name in the first transaction after the open --
not the open's own, which is aldb's for its table migrations -- and the
old row is kept for older viewers; a rewrite is written at the setting's
last step, so the next open takes it as it is. A row is written forward
only when its step moves: what is fitted as it is read -- a value clamped
into the range, a choice no longer offered taking the default -- is fitted
at every read and never written back, as a newer viewer's wider range or
added choice is no step, and an older viewer writing its fitted value
would clamp it for good. Each rewrite goes into the history (kind 1), and
one a migrate made keeps the row it replaced in prior, by name and step,
until the setting is next written. A row of a step this build does not
know -- a newer viewer's, or a build of another branch's -- is left as it
is, nothing converting backwards and the viewer that wrote it bringing a
rewrite forward a second time; where a prior row of it is of a step this
build knows, the newest such is read, and otherwise the row by its own
type and unit. A setting with no row of its own takes what its sources
give, written at its last step. A row of no step, which the XML import kept before its name
was declared, is the setting's first step, and is written again at the
last. Rows of settings this build
does not know, of a type or unit it does not know, and rows that do not
convert, are left as they were. What a migration hook looks up stays
where it is for the whole open. A setting moved from the global scope into
accounts is taken by each account at its next login, once, recorded in
the account's meta; one moved out of accounts takes the first account's
value, if the global file has no row of it.

A file is kept only if it is on disk and its rows were read whole: one
of another kind, one aldb could open only in memory, and one whose read
stopped at damage are left as they are, the open returns false, and the
store runs on what it holds in memory. A file this process may only read
is read and kept, nothing is written to it, and onFailure is told so.

ALCVarPreview says what an open would do to a file's rows, a row a line,
as the open would take each: the step it is at, what it becomes and
through which steps, whether it is written forward, or what is left alone
and why -- a step this build does not know, a name no setting has, a hook
this build does not register, a removal, another scope's setting -- with a
line for a setting its sources give a value. alcvar_preview <settings.db>,
built with the tests against their schema until the viewer's declares
settings, runs it over a copy of the file and its journal in a directory
of its own, never the file, and writes nothing. A backup (.albackup) has
no reader yet, and is refused.

openAccount() at each login attempt: the account's rows replace the
Account layer in one write, every touched group's sequence held odd until
the last slot is written, so a reader never sees a default between two
accounts. What was written to account settings since the last attempt is
laid over them and kept only once confirmAccount() says the login
succeeded; closeAccount() drops it otherwise. An openAccount() that fails
closes the account open before it, so that its writes never reach the last
account's file. An account_override setting moves into an account alone,
and back to all accounts, but not while its writes are denied.

alcvarfile_test: 38 cases, among them a killed process: a child writes,
waits past the longest delay, writes again and is killed by
TerminateProcess; the first write survives. Another counts the live thread
pools and work queues: the flusher's are gone once the store closes. Fail
first: the killed case and the quiet-time case fail with no flusher
started, the reader case with the account swap made through the defaults,
the rename case with a tombstone left out of the newest row, the pool case
with a close that joins the flusher's thread but keeps its pool; a flush
refused by a trigger with what is dirty cleared as it is posted; the full
disk, and the file that may only be read, with onFailure not passed to
aldb; the file that opens only in memory, and the one damaged mid-table,
taken; an account with no global file with the flusher started by the
global one alone; flushNow and closeAccount over a hung disk with each
file waited for in turn, and a flush before the close; the LLSD case,
which holds the writer and counts the LLSD the process keeps, with the
values handed over as LLSD; the failed account open with the account
before it left open; a row of a step this build does not know rewritten,
and migrated as though it were the first step's, a row read by its own
type and unit rather than brought forward, a rewrite written at the first
step's fingerprint and so brought forward again, a row of no step read
as an unknown step's and left as it was, and a hook's lookups
moved by the map behind them growing; a current row clamped and written
back, no history or prior row kept, a prior row not read, a hook's
exception stopping the open, a split's settings never given their
sources, an old name's row of its chain's later step migrated again, and
a reused name's old row taken; an override moved while its
writes are denied. alcvarpreview_test: 2 cases, a file of rows at each kind
of step said line by line and left byte for byte as it was, and a backup,
a missing file and another kind of file refused; both fail on a preview
that says nothing. The case of an account closed while a coroutine's
flushNow waits does not fail without a sanitizer: the freed file it read
still said it was closed. Built and passing on Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A user's settings.xml, and each account's settings_per_account.xml, are
to be imported into the new settings files the first time they are made,
and left where they are for an older viewer. ALCVarFileOptions::importXml
names the file to import when a settings file opens; the switch-over will
pass it.

ALCVarXmlImport reads the file's LLSD map of Type, Value and Persist.
Each value is found by its name or an old name, given the type the file
says -- a U32 past 2^31, which LLControlGroup keeps as a negative LLSD
integer, read back as the U32 -- and taken through the one conversion as
written at its setting's first step, through the name it was found under
and in the unit its alias says, so that every migrate since reaches it,
and written at the setting's last: an inverted
boolean comes out turned, a UUID kept in a string parsed, a list joined
into one split, an old unit scaled, a value past its range clamped. One
that does not convert -- a UUID that does not parse, a number that is not
one -- is logged, a sensitive setting's without its value, and keeps its
default. What the file says was never saved is left out. A setting now of
the other scope is kept as a row too, for the scope moves to take: an
account's setting in settings.xml goes to each account at its next login,
a global one in an account's file to the global layer if it has none.
Names the schema does not know are kept as rows of their own, at no step,
which the viewer that declares them takes as their first. A hook is given
the file's values by name, as it is given settings.db's rows, and a
setting split from others or merged that the file did not save takes what
its sources' hooks make of the file's values of them, in the import's own
transaction.

Every value is written, and the import marked pending in meta: the XML
cannot say which values the feature table wrote and which the user chose,
and the global file opens before the window, so before any quality level
is applied. The first level setQuality() applies after the import -- or
the import itself, where a level is in place already, as at an account's
first login -- settles it: an imported value equal to what the level gives
was the feature table's and goes, the Quality layer giving it again; a
setting of the file's own scope the level names that the XML did not hold
was at its compiled default, which LLControlGroup never saves, so the
default is kept as the user's over the level's. A level the user chooses
drops the user's values it names, and settles nothing more. The stamp the
rows carry says which values are still the import's, so a run that ends
before any level is applied leaves it for the next.

The rows are written in one transaction with the check that the file has
imported nothing before, recorded in meta.imported_xml; the meta key's
primary key refuses a second import besides, so two viewers starting at
once import once. The file is not even read once a settings file records
an import. No LLSD object reaches the file's writer: an LLSD setting's
value goes as its JSON text.

alcvarxmlimport_test: 10 cases: a settings file through every codec, with
other viewers' values kept, and not imported again when an older viewer
saves the XML later; malformed values; an account's file at its first
login; a value of the feature table's going when the level settles the
import, and staying gone; a default the XML could not hold kept over the
level's, after the file opened and as it opens under a level; values of
settings whose scope moved; a sensitive value refused unlogged; a U32
saved negative; values taken at their settings' first step, under an old
name and their own; a hook reading another value of the file, and a
split. They fail with every import guard taken out, and the
cases added with nothing settled at the level, the other scope's values
skipped, the value logged, the negative integer refused, the values
taken as of no step, and hooks given nothing to look up and no sources. The gate of
Section 7 -- a real user's files compared with what LLControlGroup gives
-- needs the viewer's schema and is the switch-over's. Built and passing
on Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… one load

Benchmarks of each operation the plan budgets, each beside a control
that does the least the same work could, and a check of the code the
compiler makes of a handle's read. The test schema gains a perf area: a
group of 71 values, as many as the colour correction pass reads, a Look
of 150 settings, and enough for a file of five hundred rows.

Measured on Windows, RelWithDebInfo, MSVC 19.51, pinned to one core of the
9950X3D (two cores of one CCD for the case with a writer), means of 50
samples:

  a scalar handle read, F32 / bool    0.23 ns  (control: an atomic load 0.27)   budget 1 ns
  a small value, LLColor4             0.65 ns                                    5 ns
    with a writer on another thread   0.94 ns                                   20 ns
  a string view() on the main thread  0.36 ns                                    2 ns
  read() of a group of 71 values      19.9 ns  (control: a plain copy 5.0)     100 ns
  set() of an unchanged value         10.2 ns  (control: a std::mutex and a store 11.0)  30 ns
  set(), the file open, no listener   199 ns                                   500 ns
  set() with one listener             295 ns                                     1 us
  a Look: 150 set()s with listeners   43.4 us                                  100 us
  find() and a read as F32            22.7 ns  (control: a map lookup 6.5)      50 ns
  opening a file of 500 rows          1.35 ms median of 25                       5 ms

The control now takes a mutex of the write lock's kind, LLCoros::Mutex.
Measured beside std::mutex in one sitting -- pinned to the other CCD,
the machine not quiet -- it costs about a nanosecond more (12.4-13.4 ns
against 11.1-12.5), and set(), set() with a listener and the Look are
within noise of each other.

Two of them were over until this commit. A write woke the flusher thread
every time, which on one core is a context switch a write (5.1 us); only
the first change since a flush brings the flush nearer, so only that one
wakes it now. And view()'s check that it is on the main thread is live in
RelWithDebInfo and cost more than the read (2.9 ns); it is checked in the
debug configurations alone.

alcvar_asm_probe compiles a handle's read of each scalar type with the
tree's flags, and AlCVarAsmCheck.cmake fails unless each is one load from
its slot's own symbol and a return, in RelWithDebInfo and Release: no
branch to a read left out of line, no address loaded from the global
offset table first, and on arm64 one load after the page and its offset.
alcvar_asm_check gives the check listings of each kind; before it refused
branches and the table, it passed six of the bad ones. It found that MSVC
read an F32 or F64 handle into an integer register and moved it across,
since its relaxed-load intrinsic is an integer's, and so did GCC for
arm64, whose atomic load is an integer's too (ldr w0, then fmov s0): on
MSVC and GCC a float handle now reads through a volatile float, one load
into a float register, GCC's of a float type that may alias the word, so
that no store of the word as an integer is moved past it; on Clang it
reads through the builtin, which folds into one load into a float
register. A bool reads as its byte, which holds 0 or 1, in the form each
compiler loads in one instruction: Clang masks a byte cast to bool to its
low bit, so it reads the byte as a bool, and GCC tests an atomic bool load
against zero, so it casts the byte.

The generated groups' storage is declared hidden (ALCVAR_GROUP_STORAGE).
GCC and Clang leave a static member they see only declared at default
visibility, whatever -fvisibility says, and position-independent code --
every library here, the probe among them, and with Clang an executable's
code too -- reaches it through the global offset table: a load of the
slot's address, then of the value, and on arm64 a bool read of four
instructions. Hidden, each read is one load relative to the program
counter on ELF and Mach-O. The storage is the executable's own; no other
module reads it.

In Debug and OptDebug, where nothing inlines, the probe calls the
handle's operator(), whose body holds the one load of the slot from the
linker's address and no other read of memory.

The generated natvis is now a source of whatever links the schema's
target, as Boost's are: the /NATVIS link option became a link setting
MSBuild ignores. Both natvis files are in alcvar_tests.pdb, and the test
schema's validates against Visual Studio's natvis.xsd. The LLDB
formatter maps every handle and summarizes both spellings of its type
against a stand-in lldb module; it has not been run in LLDB.

Off MSVC the check has been run on assembly compiled here: the probe's
from GCC 15 and 16 (as the Linux build compiles a library: -O3, -fPIC,
hidden visibility) and from Clang 22 for x86-64 Linux pass it, and so does
a stand-in of the probe with no system header, compiled by Clang for arm64
macOS and Linux, which fails it as the declarations were. In CI, GCC 15
for Linux x64 passed it, and GCC 15 for Linux arm64 failed it on the float
reads before they read as a volatile float; GCC for arm64 has not compiled
them since. Apple Clang and the macOS build have not been seen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@RyeMutt
RyeMutt force-pushed the claude/alcvar-284073 branch from e7a7649 to 636e561 Compare October 28, 2026 09:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c/cpp cmake github_actions Pull requests that update GitHub Actions code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant