Repository navigation
Dev Develop Model
Status: steps 1a, 1c, 1b and 1g (1d/1f use it; 1b's manual import test in the installed Lightroom Classic is pending, see Hand test). The crate has the key registry, the typed model, the policy filter, the readers for the Lua table form and for XMP (sidecars, develop presets, profiles), the develop-preset writer, the mask builders and the Lua writer. The Lua writer's forms are provisional until experiments E1/E2/E4/E11 are run (see Writing the Lua form). Background and the research behind it: AI Edit via XMP — Findings and Experiments.
server-rs/crates/lrg-develop holds the facts and shapes of Lightroom
Classic's develop settings: which keys exist, what they mean, how they are
stored, and what may be learned, shared or written. It is a leaf crate —
no image, network or async dependencies — so cargo test -p lrg-develop runs
in seconds without an ONNX or LanceDB build.
What it deliberately does not hold: learning and blending (majority votes,
circular means, thresholds) stay in lrg-analysis::style_engine, and the LLM
edit-recipe schema stays in lrg-providers::edit_recipe.
Who uses it today:
-
lrg-analysis::trainingreads a training example'sdevelop_settingsblob withlua::read, learns the registry keys that have aglobal.*recipe alias, and keeps the white balance as a typedWbSetting(CANONICAL_VERSIONmarks the stored form). A black-and-white example (ConvertToGrayscale) teaches nocolor_grading_*/hsl_*keys: the recipe does not carry the B&W switch, so its toning would reach colour photos. -
lrg-analysis::style_enginerounds blends to each key's registry precision, blends eachCircularHuekey with its saturation as a mean colour vector (hue = angle, saturation = length), and decides the white balance per key family. -
lrg-api(routes/training.rs,routes/style_edit.rs) stores and re-reads that canonical form. -
lrg-providersbuilds AI Edit's retoucher tool schema (edit_loop.rs) frombuild::recipe: the globals it offers, the mask targets and local adjustments, each with the registry's UI range. A test still requires every range the older LLM recipe schema (edit_recipe.rs) declares for a registry key to equal the key's UI range. -
lrg-api(edit_loop.rs) turns each of the retoucher's edits into the tableapplyDevelopSettings()takes:build::recipe::settings_from_recipe, thenlua::to_lua_valuein apply mode for the photo (From an edit recipe). It is the first production caller of the Lua writer.
| Module | What |
|---|---|
registry |
One KeySpec per develop key (table.rs), name families (patterns.rs), UI ↔ stored scaling (ui.rs, the only place), int/real conversion (coerce.rs, the only place) |
model |
DevelopSettings, Value/Finite/Opaque, corrections and masks (correction.rs), white balance (whitebalance.rs), the policy filter (policy.rs) |
lua::read |
getDevelopSettings() as JSON.lua encodes it → model + warnings |
lua::write |
model → the table applyDevelopSettings() takes, as JSON for JSON.lua (LuaMode::Apply), or a lossless test rewrite (LuaMode::TestRoundTrip, feature test-roundtrip); the experiment-decided forms are LuaOptions
|
xmp::read |
an XMP sidecar, preset or profile → XmpDocument (kind, header, model, warnings, skipped subtrees) |
xmp::write |
model → a develop preset (WriteMode::Preset), or a lossless test rewrite (WriteMode::TestRoundTrip, feature test-roundtrip) |
xmp::format |
how the writer spells numbers, booleans, curves, ids, versions; XML escaping |
build |
CorrectionBuilder: new corrections from UI values and semantic, gradient and luminance-range components, with deterministic sync ids; build::recipe: settings from an edit recipe (UI units, recipe aliases, semantic mask targets) and a photo's starting values back in that vocabulary |
reader (crate-private) |
what both readers share: typing through the registry, closed sets and ids, curve points, file kind, process-version check, assembly |
parse |
ParseError (input unusable) and ParseWarning (one value, reading went on) |
Every key is one row: name, level (global, preset header, correction, mask
tool, or a field of a nested structure), value kind, range in the stored
unit, UI scaling, XMP number format, defaults for raw and non-raw files,
policy class, frame scope, file kind (raw only / non-raw only / both), where
it occurs (Lua, XMP, both, or not yet observed), minimum process version and
recipe alias. Lookup
is by (level, name), because names such as Version mean different things
on different levels. Families that are never modelled one key at a time are
patterns: Table_<md5> and BrushTable_<md5> blobs, pm_* patch state,
UprightTransform_N/UprightFourSegments_N, and everything under
FilterList.
The table was seeded from server-rs/scripts/develop_registry/spec.py
by gen_table_rs.py (same folder; it imports spec.py and reads one
committed data file, the non-raw readback in
server-rs/testdata/develop/defaults/; never the rendered Markdown, an
inventory or the training dump). table.rs is the source of truth.
Adding or changing a key is editing a row there by hand, then refreshing the
review snapshot:
cd server-rs
LRG_BLESS=1 cargo test -p lrg-develop --test registry_snapshotserver-rs/testdata/develop/registry_snapshot.json is generated from the
table and the patterns and compared on every run, so every registry change is
a readable diff in review. Never edit the snapshot by hand.
Re-running the generator overwrites hand edits to table.rs. Do it only
when spec.py itself changed, and review the diff:
cd server-rs/scripts/develop_registry
python3 gen_table_rs.py --out ../../crates/lrg-develop/src/registry/table.rs --countsA new key needs: its level (the same name can mean different things on
different levels, e.g. Version), the value kind, the range in the stored
unit, the UI scale if it is a slider, raw and non-raw defaults (Unverified
if unknown, NoDefault if Lightroom fills it per photo or only writes it when
a feature is used), the policy class
(UNKNOWN until someone has checked what it does), frame scope, file scope,
where it occurs and the first process version that has it. The invariants
(unique (level, name), ranges contain defaults, + signs only on global
keys, raw-only keys absent for non-raw files, unobserved keys only UNKNOWN or
COMPUTED, ...) are unit tests in registry/tests.rs and catch many
half-filled rows. Temp is not a key: the plugin and the style engine's alias
table used to read and write it by mistake (see Bugs found in the current
code in Dev-AI-Edit-XMP-Findings); it resolves to
unknown, and a test pins that. tests/no_temp_key.rs keeps it from coming back
as a key literal in server-rs/crates/*/src and the plugin's
DevelopEditManager.lua (comments excluded; the develop experiments, which
write Temp on purpose, are allowlisted, and so is the one constant the Lua
writer refuses it with, RETIRED_TEMP_KEY in src/lua/write.rs, via
ALLOWED_LINES, matched on /-joined paths so the Windows release job
agrees). Because it guards a plugin file, CI
also runs it in the unfiltered format-lint-rust job of lint-format.yml, not
only in server-rs-tests.yml, which skips plugin-only PRs.
Defaults: every training example is a raw file, so the non-raw column comes
from a readback instead. Experiment E13 read Lightroom's full develop table
for one non-raw photo without develop edits (Lightroom Classic 15.6, process
version 15.4, a virtual copy of a TIFF; Findings: Results),
committed scrubbed as server-rs/testdata/develop/defaults/non_raw_lrc15.6.json
(key -> value, plus the Lightroom version, process version and file kind).
gen_table_rs.py gives every global row the text leaves Unverified for
non-raw files the value read back, if the readback has the key (145 rows),
and stops when a readback contradicts the non-raw default spec.py states.
It holds back the keys in NON_RAW_READBACK_NOT_CONSTANT: HDRMaxValue read
2.3 (raw 4.0) on that one photo and may be per image, so its non-raw default
stays Unverified. registry/tests.rs (non_raw_defaults_come_from_the_readback)
requires every non-raw Value to match the file and no row the readback
covers to be left Unverified, except the held-back keys. Most equal the raw
default; the ones that differ are Sharpness 0 (raw 40),
ColorNoiseReduction 0 (raw 25), CameraProfile "Embedded" (raw "Adobe
Standard") and IncrementalTemperature/IncrementalTint 0 (absent for raw).
The policy filter reads this column: a non-raw source's removed value equal
to it is dropped without a report (non_raw_defaults_are_removed_without_a_report
in model/policy.rs). One virtual copy is thin evidence for a table: the
report's own check (a freshly imported JPEG and raw for the default tables)
is still open. A key the readback does not have stays Unverified:
absence can mean "written only when used", so no key was marked Absent on
this evidence. NoDefault means there is no fixed default for either file
kind (as-shot white balance, digests, Look, the Upright solver state),
which a readback does not change (19 readback keys kept it). Hand edits to
table.rs are mirrored in spec.py, so the generator reproduces the table
and a later readback (a raw default table, another Lightroom version) is one
regeneration plus a reviewed snapshot diff.
| Class | Learn/blend | Shared preset | Apply to one photo | Test round trip |
|---|---|---|---|---|
| LEARN | yes | yes | yes | yes |
| LEARN† (gated) | yes, behind the gate | if the gate holds for every target | if the gate holds for the photo | yes |
| PHOTO | never averaged | no | yes, with the photo's own context | yes |
| COMPUTED | no | no | no | yes |
| NEVER | no | no | no | yes |
META, global (ProcessVersion, Version, Enable*, ...) |
no | no: set by the writer | no: set by the writer | yes |
| META, nested (correction, mask and structure bookkeeping) | no | yes | yes | yes |
| UNKNOWN | no | no | no | yes |
Gates: RawOnly/NonRawOnly (white balance), FileKindDefault (defaults
differ between raw and non-raw), OnlyValues (only some values transfer),
CameraRestricted (profiles), and the learning-side gates Categorical,
CircularHue, DependsOn, ByMaskType, NeedsAiUpdate.
CircularHue(<key>) names the saturation that weighs the angle
(SplitToningShadowHue → SplitToningShadowSaturation, LocalToningHue →
LocalToningSaturation, same level): the style engine treats each example as
a colour vector of that length and averages the vectors by score (see
POST /v1/edit/style in
Dev-Backend-API). A registry test checks that the named key
exists on the same level and cannot be negative.
recipe_alias is the edit-recipe field a global key is learned and sent as
(global.exposure, global.hsl.red.hue, global.color_grading.shadows.hue,
global.tone_curve.shadow_split). Since step 1f every field the installed
plugin's DevelopEditManager applies has one: 65 keys (the basic and presence
sliders, detail, vignette, grain, the parametric curve with its splits, the 24
HSL values, colour grading shadows/highlights hue and saturation plus balance).
Deliberately without an alias: white balance (typed, WbSetting), colour
grading midtones/global/luminance/blending (installed plugins warn and drop
them), point curves, lens, crop, Look. Tests pin that aliases are unique,
only on learnable global keys, never a prefix of one another, and that the LLM
schema declares the registry's UI range for each (edit_recipe.rs). The
aliases live in gen_table_rs.py's ALIAS table and are regenerated into
table.rs with the rest; adding one means bumping CANONICAL_VERSION in
lrg-analysis::training, so stored examples are re-read from their blob.
CameraRestricted on CameraProfile depends on the value: Adobe Standard
and the symbolic Default Color/Default Monochrome/Default Profile exist
for every camera and go everywhere (PORTABLE_PROFILES); any other name is
camera-specific and never goes into a shared preset. For one photo
(ApplyPhoto) it passes, because the model knows neither the example's camera
nor the photo's make: until the target carries the make, the caller must only
apply settings from the same make.
Lua tables and XMP files both carry the stored unit:
LocalExposure2012 is EV / 4; the other signed Local* sliders,
LocalToningSaturation and CorrectionAmount are UI / 100;
LocalToningHue and globals are UI units. The model always holds the stored
unit; registry::to_ui/from_ui are the only conversion. A builder or blend
uses from_ui_value, which returns the model value of the key's kind
(Value::Int for Contrast2012, as the readers produce), so integer rounding
stays in one place. Keys whose scale is unverified (LocalHue, LocalGrain)
cannot be converted, so nothing can write them from UI values.
DevelopSettings holds global values in a map keyed by registry id (so the
order is the same in every build, whatever serde_json features the
workspace unifies). Invariant, enforced by insert (the map is private): only
global keys, and never MaskGroupBasedCorrections or Look, which are the
typed fields corrections and look. Everything the registry does not know
is kept verbatim in opaque, never dropped (from a Lua table sorted by key
name, so every build agrees).
Typed views exist only where logic hangs:
-
Corrections and masks. A
Correctionhas its adjustments (local, stored unit), its components in evaluation order, and everything else inextra. Components are recognised by structure, never by name (names are localised):MaskToolRecognised from Semantic::Subject/SkyMask/Image,MaskSubType1 / 2Semantic::BackgroundMask/Image, subtype 0, category 22Semantic::PeoplePart(part)subtype 3, a people-part category (preset form) Semantic::Landscape(class)subtype 0, category 50001–50008 Semantic::PersonPartAt { part, point }subtype 0, a people-part category, a ReferencePoint(one person; photo-specific)LinearMask/GradientRadialMask/CircularGradientwithAngle0LuminanceRangeMask/RangeMask,CorrectionRangeMask.Type2Opaque { what }anything else: brush aggregates, Select Object, colour/depth ranges, rotated radial gradients — kept for the round trip only How a component combines with the ones before it:
CombineMaskBlendModeMaskValueMaskInvertedAdd { inverted }0 1 invertedSubtract1 0 false Intersect1 0 true (A ∩ B = A − ¬B) Any other combination stays as it is (
Combine::Unrecognised, with a warning). -
Look. The profile is read whole from its source (name, UUID, amount, camera restriction, and the rest including
Parametersverbatim). It is never rebuilt from a profile name. An emptyCameraModelRestrictionmeans "no restriction" and stays in the rest as it was. What of it leaves the model depends on the target (see Policy filter and frame scope). -
White balance.
WbSetting { mode, family, temperature, tint }. The family (Temperature/Tintfor raw,IncrementalTemperature/IncrementalTintfor non-raw) comes from the keys the settings carry, via the registry's file scope, not from the file extension: a DNG converted from a JPEG uses the non-raw family. The white-balance policy (what transfers) belongs to the style engine (blend_white_balance, seePOST /v1/edit/stylein Dev-Backend-API); what a preset may carry is the policy filter's: the numbers only next to aCustom(or absent)WhiteBalance, never anAs ShotorAutoresult turned into a custom white balance (see Policy filter and frame scope). -
Process version.
process_version(); PV2012 ("6.7") is the lowest version whose settings are learned.
Finite is the float for everything that can reach a writer: it cannot be NaN
or infinite (JSON.lua and serde_json would both write null), and -0.0 is
stored as 0.0.
DevelopSettings::filtered(target) keeps what may go to a Target
(Preset { mixed_file_kinds }, ApplyPhoto { file_kind, process_version, camera }, TestRoundTrip) and returns every removed value that differs from
its default as Skipped { path, reason }, so a caller can say what did not
transfer. A correction goes or stays whole, decided by its most restrictive
component (semantic masks, except a person part at a reference point, and
luminance ranges are LEARN; gradients, Select Object and colour ranges PHOTO;
brushes NEVER). A camera-restricted Look only goes to that camera. In a
preset, white-balance numbers (Temperature, Tint, Incremental*) go only
with a custom white balance: next to any other WhiteBalance mode (As Shot, Auto, Daylight, ...) they are what that mode resolved to for the
source photo, so they are dropped and reported as
SkipReason::WhiteBalanceMode(mode) instead of pinning one photo's white
balance on every photo (WhiteBalance itself is PHOTO; writing As Shot
into a preset is a later policy change).
ApplyPhoto also drops keys newer than the photo's process version and keys
of the other file kind. A single-kind preset (Preset { mixed_file_kinds: false }) is for photos of the settings' own file kind: it drops the other
kind's keys, and when the settings carry both white-balance families with no
known file kind (the readers warn ConflictingFileKind) it drops both
families, since either would make the preset contradict itself. A mask
component whose MaskBlendMode/MaskValue/MaskInverted form no known
combination (Combine::Unrecognised) is UNKNOWN, so its correction reaches
neither a preset nor a photo: what it does to the mask is not known. Both
rules came out of the writer's round-trip tests (a preset written from such
settings read back with a warning).
The verdict is per key at every depth: a kept structure is rebuilt from the
fields that pass at their own registry rows, and each removed field is
reported with its full path (LensBlur.SampledArea,
MaskGroupBasedCorrections[0].CorrectionMasks[1].CorrectionRangeMask.LuminanceDepthSampleInfo).
So a shared LensBlur keeps Active and the look sliders but not its sampled
area or focal range, and a luminance range loses its eyedropper sample. A
registry value the reader kept whole (Value::Opaque: an rdf:Bag,
qualifiers, a Lua table of an unexpected shape) reaches neither a preset nor
a photo and is always reported (SkipReason::Opaque). The Look goes to one
photo whole (its profile record as the source has it) and to a preset by
reference: Name, UUID, Amount (and the META Stubbed when present),
without its COMPUTED fields — Parameters (the profile definition, whose
Table_* blobs a preset never carries), Group, Cluster, Copyright,
SortName, Supports*, isAdobeAdaptive. Those are copies Lightroom takes
from its profile library, none of Adobe's 118 bundled presets with a Look
has Parameters, and they are dropped without a report (nothing the user
could act on). Adobe's presets themselves use either the name alone or a
stub (Stubbed="true" with the profile's Group, Cluster, Copyright
and Supports*); the writer's form, Name/UUID/Amount, is untested in
Lightroom until the E11 experiment. A range mask's SampleType is UNKNOWN
(default 0) and is dropped without a report; the XMP writer emits Adobe's
fixed range-mask form (Version 3, SampleType 0) itself (see Preset
mode).
Global META values (ProcessVersion, Version, CompatibleVersion,
ToneCurveName2012, HasCrop, the Enable* panel switches, ...) never leave
for a preset or a photo, and are not reported: they describe the example, and
the writer sets them from the target's context. Copying an example's
ProcessVersion would silently change the target photo's process version.
Nested META (a correction's name and sync id, a range mask's Version) stays
for now.
split_by_frame_scope() sorts values into shareable, per-frame-adjusted,
per-frame-only and not-applicable parts — the basis for sharing one recipe
across a series later.
lua::from_lua_str(text, hint) / from_lua_value(&json, hint) read the
develop_settings blob the plugin sends (JSON.lua's encoding of
photo:getDevelopSettings()):
- a top-level
[]is the empty table; an empty table ([]or{}) as the value of any key, at any depth, means the key is absent (LensBlur,AILook,FilterList, ...), and an empty table as an element of a list of objects (corrections, masks, structures) is no element, all without a warning; - an integer where a real is expected is fine; a non-integer on an integer key is rounded half-to-even and warned about;
- booleans and 0/1 are accepted on bool and 0/1-flag keys and normalised;
- a known key with the wrong shape (
nullin an array, a mixed array, text for a number) is kept verbatim with aWrongTypewarning and never written; - an unknown key is kept verbatim with an
UnknownKeywarning — never an error; pattern-family keys are kept verbatim without a warning; - the file kind comes from the white-balance keys; a caller hint (
is_raw) that contradicts them is warned about and loses; - a process version below 6.7 is warned about (such settings are not learned);
- keys are read in name order, so opaque entries and warnings come out in the
same order whatever
serde_jsonfeatures the build unifies.
ParseError is only for input that is unusable as a whole: invalid JSON,
more than 4 MiB, or a top level that is not a table. Warnings are a list with
a path such as MaskGroupBasedCorrections[2].CorrectionMasks[0].MaskSubType.
xmp::parse(bytes) reads a sidecar, a develop preset or a profile into an
XmpDocument { kind, header, develop, warnings, skipped_subtrees }. It lands
in the same model the Lua reader produces: the same key ids, value
variants, correction and mask classification, Look and white-balance family.
Both readers only walk their own syntax; typing (through KeySpec::coerce),
the checks on closed sets and ids, curve points, the file kind and the
process-version check live once in the crate-private reader module, and the
correction/mask classification in model::correction.
tests/xmp_lua_equivalence.rs holds the two readers to that. What differs by
design, and what those fixtures therefore avoid or strip:
- global keys of only one format (
Presence::XmpOnly/LuaOnly); -
PerFormatkeys (PointColors,RetouchInfo): aStructListfrom Lua, aStrListfrom XMP; -
ValueKind::AnyandSettingskeys (RGBTables,ProfileGainTableMap,Look.Parameters, ...):Opaque::Jsonfrom Lua,Opaque::Xmpfrom XMP; - struct-, correction- and mask-level Lua-only keys (
CorrectionID,CorrectionReferenceX/Y,MaskID,Look.isAdobeAdaptive); - Lightroom writes
PointColorsinto every sidecar as a sentinel item (19 ×-1) wheregetDevelopSettings()returns[], so an XMP model reports one point colour that the Lua model does not.
Pairing real training rows with their sidecars (September 2026, key names
only) found no key present in both formats with a different value variant,
and identical mask tools and combine sequences. It also found Presence
rows the data contradicts, a follow-up for the registry (not changed yet):
seen only in XMP although marked Both — Version, CompatibleVersion,
CameraProfileDigest, OverrideLookVignette, ColorVariance,
AutoToneDigest, AutoToneDigestNoSat, ToggleStyle*, AllowFilters,
RangeMaskMapInfo, Upright*DependentDigest; seen only in Lua — the PV2010
keys (Exposure, Contrast, Brightness, Shadows, FillLight,
HighlightRecovery), the Auto* flags and ExtendedToneCurvePV2012*.
Two passes. The first turns RDF into the XMP data model (XmpNode), whatever
the syntax: attribute form and element form, structures as a nested
rdf:Description, as attributes on the property element or with
rdf:parseType="Resource", rdf:Seq/rdf:Bag/rdf:Alt with empty items,
xml:lang and qualifiers (rdf:value, as an element or an attribute). Every
rdf:Description under every outermost rdf:RDF is merged in document
order. Top-level properties outside crs: are counted and never built. A
property element that mixes forms RDF does not allow (a container next to
other elements or field attributes, something other than rdf:li in a
container) is read as a plain structure of everything it holds, so the
second pass reports it (WrongType/UnknownKey) and keeps it whole; text
next to elements, which the data model cannot hold, is dropped with one
MalformedRdf warning per top-level property (the first place, and how many
more). Namespace URIs are shared (Arc<str>), not copied per field, so a
long URI cannot multiply into gigabytes. The second pass types the crs:
properties through the registry:
- the namespace is matched by URI (
http://ns.adobe.com/camera-raw-settings/1.0/), never by thecrs:prefix; - top-level names resolve at the global level first, then as preset header
keys (
Baselineis a header key though it looks like a setting); -
True/true/False/falseare all booleans,"+15"is 15,"1.000000"is 1.0; a non-integer on an integer key is rounded half-to-even with a warning, as in the Lua reader; ids are checked for 32 hex digits; - global curves (
"x, y") and local curves ("x,y") both becomeValue::Curve— the same value the Lua reader makes of a flat number list and of"x,y"strings; - an
rdf:Altwith onlyx-defaultisValue::Alt. One with further languages, anrdf:Bagorrdf:Altwhere the registry expects anrdf:Seq, and any value with qualifiers are kept whole under their key asOpaque::Xmp(lossless, no warning: valid XMP the model does not interpret;XmpNode::alt_defaultstill finds the default language). Each such key is counted inskipped_subtrees.kept_whole, so the lost typing is visible; the local sweeps require 0 for Lightroom's own files; -
crss:(snapshots) is skipped entirely;Look.Parameters(a profile definition) andPreset.Parameters(the last applied preset) are kept opaque and never evaluated; properties in other namespaces (dc:,xmp:,exif:, ...) are not part of the model.skipped_subtreescounts each; - an unknown
crs:key, or a foreign property inside a develop structure, is kept verbatim (with its namespace) with anUnknownKeywarning; a known key of the wrong shape withWrongType; a key that appears twice at one level (Adobe's own bundle has profiles withGrouptwice in one description) keeps the first occurrence — typed, or opaque if it did not type — and keeps later ones verbatim withDuplicateKey.
The kind: a sidecar has crs:ProcessVersion and no crs:PresetType; a
preset has PresetType="Normal", a profile "Look" (its settings are
the profile definition); anything else is NotDevelop, which is not an error.
The header's common fields are typed (PresetHeader: type, UUID, the x-default
names, cluster, camera restriction, the four Supports* flags); every other
header key stays in PresetHeader::rest.
ParseError only when the file is unusable as a whole: more than
MAX_XMP_BYTES (4 MiB, the sidecar limit of lrg-imaging), not UTF-8 after
an optional byte-order mark, not well-formed XML (a DTD counts as that, and
so do more than 2^18 XML nodes), no rdf:RDF, elements nested deeper than
MAX_XMP_DEPTH (64) below a description (TooDeep; real files nest about
20), or beyond a structural limit (Limit): more than MAX_XMP_ATTRIBUTES
(1024) attributes on one element, more than MAX_XMP_NAMESPACES (256)
namespace declarations, or a namespace URI longer than
MAX_XMP_NAMESPACE_URI (1024 bytes); real files stay near 200, 20 and 60.
The depth and structural limits are enforced by a linear, non-recursive
pre-scan of the text before roxmltree sees it. roxmltree's tokenizer recurses
once per element with no depth limit, so without it a deeply nested file
overflows the stack inside the XML parser — an abort that takes down the
whole backend, not a catchable panic — and its duplicate-attribute check and
namespace resolution are quadratic, so a file under 4 MiB could take minutes
to hours. The reader's own recursion checks the depth a second time.
Filtering * [conflicted].xmp sync copies is the caller's job, not the
parser's.
The fixtures cover the forms; whether the registry covers what Lightroom
actually writes is checked by the local sweeps in tests/xmp_goldens_local.rs
(see Tests for how to run them). Last run (September 2026,
Lightroom Classic 15.6):
| Source | Files | Result |
|---|---|---|
Lightroom Classic's bundled presets and profiles (Contents/Resources/Settings) |
1,045 (446 presets, 599 profiles) | 0 parse errors, 0 unknown keys, 0 values outside a registry range; the only warnings are 6 DuplicateKey Group
|
A sidecar corpus of 28,680 .xmp files |
10,814 Lightroom sidecars (PV 11.0 and 15.4, 5,523 corrections) | 0 parse errors, 0 warnings of any kind |
| Camera Raw's user folder: its presets and profiles | 23 | 0 parse errors, 0 warnings |
The Group duplicates are Adobe's: some bundled camera profiles carry
crs:Group twice in one description, with different values. The reader keeps
the first and warns; the sweep allows exactly that pair (ADOBE_ALLOWED) and
nothing else. Camera Raw's folder also holds its own state in crs: form
(Defaults/Preferences.xmp, Defaults/Previous.xmp, GPU/…), with keys no
preset or sidecar carries (JPEGHandling, SubsetRetouch, …); those are not
develop documents, so that sweep checks only presets and profiles and counts
the rest by kind. No registry row had to be added for any of these sources,
and no registry key arrived in a form the model keeps whole
(kept_whole is 0 in all three). The corpus is counted as the non-conflicted
files carrying crs:ProcessVersion as of September 2026, so it differs from
the photo count in the research.
October 2026 re-run of the corpus: 28,681 files, 10,815 sidecars parsed
(8,006 at PV 15.4, 2,809 at 11.0; 5,524 corrections). One new sidecar,
written by the Camera Raw 18.7 engine, carried crs:Glow, the switch of the
Glow panel whose GlowRange/GlowWarmth/GlowSpread/GlowStyle rows
already existed (unobserved); the full sweep failed on it (UnknownKey Glow) until it got its own row (global, Any, UNKNOWN, observed in XMP
only so far). With the row the sweep is clean again: 0 parse errors, 0
warnings, nothing kept whole. The other September counts on this page are
from the 10,814-sidecar run.
xmp::write(&settings, &WriteMode) -> Result<Written, WriteError> writes a
develop preset Lightroom Classic imports (Develop → Presets → Import). It is
hand-written and deterministic: the same settings give the same bytes in every
build (registry order for keys, the reader's order for opaque content, never a
serde_json map). Written { xmp, skipped } returns the document and every
value that did not make it, so a caller can say what was left out.
let header = PresetHeader::lrgenius(uuid, "LrGenius · Warm evening");
let mut spec = PresetSpec::new(header); // mixed_file_kinds: true, no process version
spec.process_version = Some(ProcessVersion::V6);
let Written { xmp, skipped } = xmp::write(&settings, &WriteMode::Preset(spec))?;WriteMode::Preset(PresetSpec { header, mixed_file_kinds, process_version })
is the only production mode:
- The settings go through
filtered(Target::Preset { mixed_file_kinds })first; only keys whose policy reaches a shared preset are written, and the filter'sSkippedreports becomeWritten::skipped. A mixed preset (the default) carries no raw-only or non-raw-only key (Temperature,Tint,Incremental*), no crop, geometry mask or lens identity; a single-kind one keeps only its own kind's keys, and white-balance numbers only next to aCustom(or absent)WhiteBalance; theLookgoes by reference, without its profile definition (see Policy filter and frame scope). - The writer sets what describes the file rather than the settings:
- the header:
PresetType="Normal"(always;Lookprofiles cannot be written),UUID(required, upper-cased),Name(required),ShortName,SortName,Group(LrGeniusAIfromPresetHeader::lrgenius) andDescriptionasrdf:Altwith anx-defaultitem (empty ones as<rdf:li xml:lang="x-default"/>),Cluster,CameraModelRestriction,Copyright,ContactInfo(empty by default), the fourSupports*flags of the header (defaultTrue), and the values Adobe's newer (Camera Raw >= 14.4) presets carry:SupportsHighDynamicRange,SupportsNormalDynamicRange,SupportsSceneReferred,SupportsOutputReferredTrue,RequiresRGBTablesFalse(163 of the 446 bundled develop presets have these keys — exactly the ones withSupportsAmount2, every adaptive preset included; the older 283 have none).RequiresRGBTablesFalseis right because a preset never carries an RGB table.Namemust not be blank (WriteError::MissingHeaderField). Further META header keys inPresetHeader::rest(ShowInPresets, ...) are written; anything else there is skipped and reported; -
Version="18.5"(TARGET_ENGINE: the oldest engine targeted, Lightroom Classic 15.5.1's Camera Raw 18.5.1; the installed 15.6 is newer),CompatibleVersionfrom the feature table below,ProcessVersiononly whenPresetSpec::process_versionis set (the settings' own process version describes the example and never reaches a preset),HasSettings="True"; -
WhiteBalance="Custom"whenever white-balance numbers are written (only a custom or absent source mode lets them through), andToneCurveName2012(LinearorCustom) next to a point curve (a typed one; a curve kept whole never reaches a preset).
- the header:
- It completes the fixed form of Adobe's adaptive presets for what the
policy filter removes as per-photo or a builder may leave out (present
values are kept): a correction's
What="Correction",CorrectionAmount1,CorrectionActive; a component'sMaskActive; an AI mask'sMaskVersion1,ReferencePoint="0.500000 0.500000",ErrorReason0; a luminance range'sCorrectionRangeMaskVersion3 andSampleType0. Builders therefore need not set these. What this form puts back without a loss is taken out ofWritten::skippedagain, so a caller that showsskippedto the user (as step 2 will) does not warn about it: an AI mask'sReferencePointandErrorReason(Lightroom recomputes both per photo; Adobe's presets carry the centre and 0 whatever photo they were made on), a luminance range'sSampleType0, andWhiteBalance="Custom"next to its numbers. A people part's (MaskSubType3)ReferencePointaway from the centre stays reported, because the point may pick the person; a non-zeroSampleTypetoo. - A last guard refuses the never-write list (
NEVER_WRITE) whatever the policy says: every*Digest*key,CorrectionID,MaskID,CorrectionReferenceX/Y,FullMaskSize,WholeImageArea,Origin,ModelVersion,MaskBrushTable*,Dabs,RetouchAreas,RemoveAreas,RetouchInfo,RedEyeInfo,FilterList,AILook,DepthMapInfo,orientation,AutoWhiteVersion,RawFileName,AlreadyApplied, the globalEnable*switches, and every Lua-only or never-observed key. Pattern families (Table_*,pm_*, ...) are opaque and never reach a preset. A unit test pins that the policy filter already removes each of them, so the guard only catches a future filter bug. A registry value kept whole (Opaque::Xmp) is likewise skipped and reported by the writer itself, should the filter ever let one through.
Content read from a Lua table (Opaque::Json) has no XMP form: the filter
removes it and reports it (a Lua example's Look.Parameters is COMPUTED and
goes silently, as from XMP); converting it is step 1g's business.
CompatibleVersion (crs:CompatibleVersion, packed major << 24 | minor << 16) is the highest row a preset's content needs, capped at the engine, and
absent when no row applies (as in most of Adobe's own presets without masks).
The rows are the lowest values Adobe's bundled presets use for the same
feature (COMPATIBLE_VERSIONS):
| Feature | Version | Evidence |
|---|---|---|
| any mask group, subject, sky | 14.0 | adaptive Subject/Sky presets |
| people parts | 15.0 | adaptive Portrait presets |
| background | 15.0 | not in the bundle; Select Background shipped with the people masks — unverified: the hand test only shows that the installed (newer) Lightroom imports it, and an import into 15.x cannot check a minimum |
| landscape classes | 15.3 | adaptive Landscape presets |
| point curves inside a correction | 15.3 | adaptive presets with MainCurve/BlueCurve
|
LensBlur |
16.0 | Blur Background presets |
global CurveRefineSaturation
|
17.0 | presets with that curve |
LocalPointColors |
17.4 | adaptive presets with point colour in a mask |
The writer computes the version from the filtered settings, so two rows
never apply to a preset it writes: LocalPointColors (UNKNOWN) and a person
part at a point (PHOTO, the other half of the people-parts row) never pass
the filter. They stay for compatible_version on unfiltered settings.
One rdf:Description in the crs: namespace under x:xmpmeta with
x:xmptk="LrGeniusAI <backend version>" (never an Adobe toolkit string), in
Adobe's layout: header attributes, version stamps, global values,
HasSettings, then the rdf:Alt names, then rdf:Seqs and structures as
elements. Simple values are attributes; a structure of simple values only is
written as attributes on its property element or rdf:li (mask components),
one with a complex field as a nested rdf:Description (corrections, a range
mask's component). rdf:parseType="Resource" and rdf:Bag are never
written. Numbers and the rest follow xmp::format:
| Rule | Example |
|---|---|
NumFmt::Int: no decimal point |
Temperature 3569 → 3569
|
NumFmt::Fixed(n): exactly n decimals |
Exposure2012 0.5 → +0.50, SharpenRadius 1 → +1.0
|
NumFmt::Trim6: up to 6 decimals, trailing zeros trimmed |
LocalExposure2012 0.0625 → 0.0625, CorrectionAmount 1 → 1
|
NumFmt::CompoundFixed6: %.6f each, one space apart |
ReferencePoint → 0.500000 0.500000, LumRange
|
+ only on plus_sign keys at the global level, only above 0 |
Tint 6 → +6, 0 → 0, -6 → -6; never in a structure |
never -0
|
-0.001 at 2 decimals → 0.00
|
| booleans by level |
True/False global and header, true/false in structures |
| curves | global points x, y, local points x,y
|
| ids | 32 upper-case hex digits (UUID, CorrectionSyncID, MaskSyncID) |
| escaping |
& < > " ' as entities; tab, line feed, carriage return as character references in attributes (a parser would turn them into spaces), a carriage return also in element text; UTF-8 unchanged; a character XML 1.0 cannot hold is a WriteError, never a broken file |
A value whose variant does not fit its key (text for Exposure2012) is a
WriteError::WrongValue, not a guess.
WriteMode::TestRoundTrip(Option<PresetHeader>) writes everything the model
and the header hold — opaque, COMPUTED, NEVER and UNKNOWN content, rdf:Bags,
language alternatives, qualifiers and foreign namespaces inside structures —
so XMP → model → XMP → model compares equal. Numbers keep their registry
spelling unless it would change the value; then the shortest exact text is
written (Lightroom never writes such a number). It exists only behind the
crate feature test-roundtrip, which only lrg-develop's own
dev-dependency on itself switches on ([dev-dependencies] lrg-develop = { path = ".", features = ["test-roundtrip"] }): cargo tree -p lrg-server -e features -i lrg-develop shows only default. Any build that compiles
lrg-develop's tests (cargo test --workspace, cargo clippy --workspace --all-targets, so CI and pre-commit) unifies the feature into the
lrg-develop every crate links, so CI cannot catch another crate naming
TestRoundTrip; only the release build would fail. Never name it outside
lrg-develop; WriteMode is #[non_exhaustive], so a match elsewhere
always needs a wildcard arm. Never write a sidecar with
it: the model drops every namespace but crs: (dc:, xmp:, exif:,
tiff:, aux:, photoshop:, xmpMM:) and crss: snapshots, so a rewritten
sidecar would lose the photo's metadata. Writing into an existing sidecar
needs a merge into that document (later step).
Round trip 1 of the plan (XMP → model → XMP → model) runs with this mode in
tests/xmp_roundtrip.rs over every committed fixture and writer golden, and
in the local sweeps over Adobe's and the maintainer's files (see
Tests). Last local run (September 2026): all 1,045 bundled presets
and profiles, the 23 presets and profiles of Camera Raw's user folder and all
10,814 corpus sidecars write without an error, read back into an equal model
(header, kind and opaque content included, no key differing), and write again
byte-identically. The same sources written as presets (production mode, mixed
and single-kind: the 446 bundled develop presets, the 4 Camera Raw presets,
all 10,814 sidecars, and the 1,294 training-dump rows) write without an error
and read back as presets with no warning, no key outside the preset policy,
nothing kept whole, and exactly the policy-filtered settings (2.3 million
values compared over the corpus, none missing, changed or extra).
The byte rules against the source (plan §7.4) are part of the same sweeps:
every crs: value text of the source (attributes, text-only elements,
rdf:li items, outside crss: snapshots) is compared with the lossless
output as a multiset per file, and anything but a known variance fails.
Known variances, all keys Adobe spells unusually and rarely: over the
10,814 corpus sidecars the legacy PV2010 Clarity (1 value, written with a
+ the row does not have) and CropAngle/CropBottom/CropTop in two
sidecars that spell them untrimmed at %.6f (all never in a preset); in
Adobe's bundle Look.Amount (1.000000 where sidecars and the writer write
1, 35 values), legacy Clarity and legacy Exposure (two decimals, the
row is Trim6), PerspectiveX/PerspectiveY in Classic/General/Zeroed.xmp
(0 against Fixed(2)), one LocalTemperature="0.10" in an Adobe Sky preset; in
Camera Raw's folder one LocalShadows2012 with two decimals. The
registry rows keep Lightroom's sidecar spelling. Follow-up for the registry
(round-trip spelling only, both COMPUTED): legacy Clarity has no
plus_sign, and legacy Exposure is Trim6 where Adobe writes two
decimals.
Provisional while fields are open. Experiments E1, E2, E4 and E11 show which forms
applyDevelopSettingsand plugin presets accept on the write side (Findings: Running the experiments). The writer decides none of them: every alternative an experiment measures that shapes the table, or the call that applies it, is oneLuaOptionsfield, the defaults sit in one place,LuaOptions::PROVISIONAL, and how far the evidence carries each of them isLuaOptions::EVIDENCE(the table below). The first run (Lightroom 15.6, 2026-10-03, one non-raw TIFF, run from the Library module; Findings: Results) settled the AI-mask forms for subject, sky and background and the non-raw mode-only choices (wb_mode_only,flatten_auto_now).wb_custom_with_numbersis only supported, becauseCustomwith the incremental numbers (the writer's non-raw form) was never applied, and so ispanel_switches. People parts andint_flag_asare open,look_formis settled on raw (a stub sets no Look, a complete Look does), and the wire goldens are not frozen.
lua::to_lua_value(&settings, &LuaMode, &LuaOptions) -> Result<LuaWritten, WireError> writes the table photo:applyDevelopSettings() takes, as the
JSON the plugin's JSON.lua decodes into it. LuaWritten { table, skipped, call }: the table (always a JSON object; is_empty() when nothing reaches
the photo), every value that did not make it (as xmp::Written::skipped),
and ApplyCall { update_ai_settings, flatten_auto_now }, the call
arguments that go with the table.
let photo = PhotoContext { file_kind: Some(FileKind::Raw), process_version: Some(ProcessVersion::V6), camera: None };
let LuaWritten { table, skipped, call } =
lua::to_lua_value(&settings, &LuaMode::Apply(photo), &LuaOptions::default())?;Two modes, as for XMP:
-
LuaMode::Apply(PhotoContext { file_kind, process_version, camera })is production. The settings go throughfiltered(Target::ApplyPhoto { .. })first: PHOTO values pass (the settings are meant for this photo; crop, gradients, a person's reference point), gates, file-kind keys,min_pvand camera-restricted profiles are checked against the photo, and every removed non-default value is reported. An unknownfile_kindgets no white-balance numbers and no raw-only or non-raw-only key. -
LuaMode::Preset(PhotoContext)is the same table for a plugin preset applied to that one photo (addDevelopPresetForPlugin, the E4 route), plusSupportsAmount/SupportsAmount2whenpreset_amount_flagsasks for them. It is the only mode that honours that field, so a preset-route answer stored inPROVISIONALcannot leak intoapplyDevelopSettingstables. -
LuaMode::TestRoundTrip(featuretest-roundtrip, as for XMP) writes everything the model holds, opaque content read from a Lua table included; content kept as XMP has no Lua form and is aWireError::NotLua. Of the options only the encodings apply (mask_enum_as,int_flag_as), and the reader reads each of them back (an integer string on a mask enum is read as that integer, without a warning), so round trip 2 holds whichever form E2 settles on.
- Keys without
crs:; only what the policy filter lets through for the photo, then a never-write guard (lua_never_written): the XMP writer'sNEVER_WRITElist (CorrectionID,MaskID,CorrectionReferenceX/Y,FullMaskSize,WholeImageArea,Origin,ModelVersion, retouch and filter payloads,orientation, ...), every key withDigestin its name,MaskBrushTable*, the globalEnable*switches (onlypanel_switcheswrites them) and XMP-only keys. The guard's drops are reported. Global META is set by the writer, never copied: noProcessVersion(it describes the example),ToneCurveName2012derived from the curve,WhiteBalance="Custom"next to numbers. - Integer keys as JSON integers (a real on an integer key goes through
KeySpec::coerce, the one rounding place; a value no integer key can take is aWireError::WrongValue), real keys as JSON numbers at full precision. Every number is aFinite, so nevernull. - Booleans as JSON booleans; 0/1 flags (
IntFlag) perint_flag_as. - Numeric-looking strings stay strings:
ProcessVersion,ReferencePoint,LumRange,FocalRange,Look.UUID, local curve points. A compound number string keeps its spelling (Lightroom's tables mix0and0.170630) but must be a list of numbers (WireError::NotCompound); only what the writer builds itself (LumRangeof a luminance range, a person'sReferencePoint) is spelled%.6f. Ids are upper-cased and must be 32 hex digits. - Global curves as a flat number list
[x, y, x, y, ...](integers where they are), at least two points (WireError::ShortCurve); local curves as a list of"x,y"strings. -
MaskGroupBasedCorrectionsas a list in panel order,Local*directly on the correction,CorrectionMasksnever empty. Every correction getsWhat,CorrectionAmount1 andCorrectionActivewhen the model has none, and (local_form = AdaptivePreset) the 23Local*keys of Adobe's adaptive presets at 0 where the model has no value (ADAPTIVE_PRESET_LOCALS, the listDevelopExperiments.luaapplies, pinned against it by a test; its legacy PV2010 keys are written only as this 0). Every component getsMaskActive;mask_formdecides the rest. - Never an empty container: JSON.lua encodes
{}as[], and an emptyMaskGroupBasedCorrectionswould delete every mask of the photo. When no correction reaches the photo the key is absent; a structure left empty by the filter is absent (the reader reads both as "absent" too). - No mixed tables, no sparse arrays: no
null, one JSON type per list. - White balance as a family. After the filter only the photo's family is
left; numbers go with
Customor no mode (and get"Custom"perwb_custom_with_numbers); numbers next to any other mode (As Shot,Auto,Daylight, ...) are what that mode resolved to for the source and are reported asSkipReason::WhiteBalanceMode; a named mode without numbers is written only withwb_mode_only(reported otherwise,As Shot, the default, silently).Customwithout numbers (they belonged to the other family, or the file kind is unknown) and a mode outside the registry's list are always skipped and reported:Customalone would pin whatever the photo has.Tempis never written:RETIRED_TEMP_KEYis the one place the name is spelled, refused at any depth. - The forms the experiments decide are
LuaOptions(below).
Rules 7–9 are checked once more on the finished table (check_wire, public
for the apply layer); a failure is a writer bug and a
WireError::NotWireSafe, never a table handed to Lightroom.
The Look follows look_form, by default Full (E11: only a complete Look
with Parameters is applied; a stub sets none). A Look without Parameters
is still written but reported as Look.Parameters, since Lightroom would
ignore it like a stub; an adaptive Look has none by design and is not
reported. The stub forms (on request) carry Name, UUID,
Amount (and Stubbed): the profile's COMPUTED fields (Parameters,
Group, Supports*, CameraModelRestriction, isAdobeAdaptive, ...) are
copies Lightroom fills in from its profile library when it resolves the
stub, so dropping them is not reported (as in a preset). A camera
restriction is not reported either: the policy filter has already kept a
restricted Look to its own camera, so the stub that reaches the photo loses
nothing. An Adobe Adaptive Look (isAdobeAdaptive) sets
ApplyCall::update_ai_settings (with ai_update) in every form, since its
AILook state is never written. LookForm::Full writes the whole record,
with Parameters only when it was read from a Lua table and passes
check_wire — the one place kept-verbatim content reaches Lightroom, and
only on request. No experiment applies a camera-restricted or an adaptive
Look (E11 uses Adobe Raw profiles and rejects adaptive ones).
Number spelling is not part of the contract: JSON.lua under Lua 5.1 (the
Lightroom runtime and CI's busted) re-encodes with %.14g, a local Lua 5.5
with the shortest exact form. Tests compare decoded values, never encoded
text.
Each field's default and status, as LuaOptions::PROVISIONAL and
LuaOptions::EVIDENCE hold them (a test requires one evidence row per
field). Settled means the first run applied the default and it did what
the writer needs, on Lightroom 15.6 with one non-raw TIFF, run from the
Library module; it says nothing about raw files, other versions or the
Develop module. Supported: the evidence points to the default without
settling it: the default was not applied as written, or was applied where it
could make no difference. Open: no
experiment has answered it. An alternative no experiment applies stays
unmeasured whatever the run says.
LuaOptions field |
Alternatives | Default | Evidence (first run, LrC 15.6, one non-raw TIFF, Library module) | Still open | Status |
|---|---|---|---|---|---|
mask_enum_as |
Number / String (mask enums, KeySpec::is_mask_enum: every closed integer set below the global level — MaskSubType, MaskBlendMode, ErrorReason, CorrectionRangeMask.Type/SampleType, ... — and MaskSubCategoryID) |
Number |
E2a–E2d apply mask enums as numbers; the subject, sky and background masks are kept and computed (a hair mask ended failed, ErrorReason 1). Read back: a number in 655 of 655 masks of the training dump |
String applied by no experiment (not needed) |
settled (E2, non-raw) |
int_flag_as |
Number (0/1) / Bool (LensProfileEnable, AutoLateralCA, CropConstrainToWarp, HDREditMode) |
Number |
no experiment writes a flag key; read side only: numbers in 1,294 of 1,294 training rows and in the E13 readback | a write of a flag key, numbers against booleans (cheap evidence: read back photos DevelopEditManager.lua applied AutoLateralCA to as a boolean) |
open |
panel_switches (the plan's include_panel_switches) |
None / MaskOnly (EnableMaskGroupBasedCorrections next to corrections) / All (plus the switch of every panel the table touches, PANEL_SWITCHES) |
MaskOnly (was None) |
E2 sent the switch, but E13 shows it was already true on the source photo (E2's copies inherit it), so the run does not tell None from MaskOnly; the default is the form E2 applied |
None against MaskOnly (the blocking wire-golden variant, with and without the switch); All and the PANEL_SWITCHES map are unverified (check them before turning All on) |
supported (E2, non-raw) |
mask_form |
PresetForm (an AI mask's MaskVersion 1, ReferencePoint centre, ErrorReason 0; a luminance range's Version 3, SampleType 0) / Minimal
|
PresetForm |
E2a: a digest-less subject mask in this form is kept, pending until updateAISettings(), then computed; sky and background likewise |
people parts: the one applied (E2d hair, MaskSubType 3 / MaskSubCategoryID 5, the writer's form) ended failed, ErrorReason 1; form or photo is not known. Minimal, and any luminance range, applied by no experiment |
settled for subject, sky and background (E2, non-raw) |
local_form |
AdaptivePreset (the 23 Local* keys at 0 unless the model has a value) / Sparse (the model's own) |
AdaptivePreset |
E2 applies the zero-filled form, and the corrections work |
Sparse applied by no experiment |
settled (E2, non-raw) |
wb_custom_with_numbers |
numbers with WhiteBalance="Custom" / alone |
true |
E1b: incremental numbers alone are stored, and leave the mode at "As Shot" in the readback, so the writer keeps writing Custom with them; whether As Shot + numbers renders them is the open Basic-panel check. E1d: "Custom" is taken as a mode |
non-raw: Custom + incremental numbers in one call (the writer's non-raw form, never applied); raw: E1b against E1c on a raw file |
supported on non-raw; open on raw |
wb_mode_only |
a named mode without numbers written / skipped and reported (Custom alone is never written) |
false |
E1f/E1g: "Auto" alone sets the mode, and the readback showed the previous incremental numbers unchanged within 15 s (the Basic-panel check is open). Never honoured for a non-raw photo: the writer and the style engine refuse a mode alone there before reading the switch. Stays together with the style engine's EMIT_NAMED_WB_MODES (off) |
raw: E1e (Daylight) and Auto on a raw file, where a positive answer turns mode-only on for raw targets only; "As Shot" and the other named modes alone are applied by no experiment |
settled on non-raw; open on raw |
flatten_auto_now |
ApplyCall::flatten_auto_now when the table sets WhiteBalance="Auto"
|
false |
E1f against E1g: optFlattenAutoNow = true made no difference to the readback within 15 s |
raw (matters only with wb_mode_only) |
settled on non-raw |
ai_update |
ApplyCall::update_ai_settings after an AI mask (Mask/Image), LensBlur or an Adobe Adaptive Look
|
true |
E2b: without the call the mask is still pending after 30 s in the Library module; E2c: after photo:updateAISettings() it is computed (call 0.6 s) |
an Adobe Adaptive Look (E11 rejects adaptive Looks); people parts (the subType-3 form, on a photo with a visible person) |
settled for AI masks (E2, non-raw) |
look_form |
Stub (Stubbed = true) / BareStub / Full
|
Full |
E11b: a complete Look with Parameters is honoured; E11a/E11c: a stub sets no Look (raw, 15.6) |
camera-restricted and adaptive Looks by no experiment | settled |
preset_amount_flags |
SupportsAmount/SupportsAmount2 = true in a plugin preset's table (LuaMode::Preset only) |
false |
E4e: amount 50 and 200 leave the globals and the mask values at the preset's own, with and without the flags (read back) | the look of the amount copies (manual check); the preset route is not chosen (see below) | settled: no effect in 15.6 |
The default table now equals the one E2 applies for AI masks (preset-form masks, zero-filled locals, numbers, the mask switch; the switch was already on in E2's photo). The goldens are still not frozen: no experiment has applied the writer's own output, see Wire goldens.
What the experiments measure that is not a writer switch, and why:
-
E1a/E1d (
Tempaccepted?): accepted without an error and ignored (15.6, non-raw).Tempis never written; the family keys are the documented ones (rule 9). -
E1c on a non-raw file (
{Temperature = 22}): a cross-family probe. Lightroom 15.6 stores the raw key on the non-raw photo as a dead value (the mode and the incremental keys stay as they were); the writer never writes the other family's keys (rule 9), so the answer changes no table. - E2 timing (update call and mask-ready time): runtime planning for step 2, no table shape.
- E4a/E4b (same-name presets), E4f (apply then delete the file): the preset route's lifecycle in the apply layer (step 2); the table is the same either way.
-
E4c (preset masks merged or replacing): replacing (15.6), and the
preset's
updateAI = trueleft its sky mask pending for 60 s. The writer writes exactlysettings.corrections. Keeping the photo's existing masks is the apply layer's job on the photo's raw tables: the writer drops digests (rule 1), so an existing AI mask passed through the model would lose its computed state. Step 2 applies masks throughapplyDevelopSettings+updateAISettings()instead of a preset. - E4d (re-apply duplicates or updates by sync id): updates in place (15.6). Sync ids come from the builders (derived per role, see Building corrections); the writer keeps them.
When an experiment has answered, in one change:
- change the field in
LuaOptions::PROVISIONAL, its row inLuaOptions::EVIDENCEand the pinning tests (the_provisional_choices_are_pinned,the_first_run_statuses_are_pinned); - E1 (mode only): flip
wb_mode_onlytogether withlrg_analysis::style_engine::EMIT_NAMED_WB_MODESand its const assert ina_named_mode_majority_is_not_sent_while_the_switch_is_off. A positive raw E1e turns mode-only on for raw targets only: both refuse a mode alone for a non-raw photo before reading the switch (E1f/E1g), pinned bycustom_without_numbers_is_never_written_aloneanda_non_raw_photo_gets_no_mode_alone_whatever_the_switch; - re-bless the wire goldens and run
busted(Wire goldens); no test or reader change is needed (every_golden_case_is_clean_under_every_option_flipproves that for every single flip); - set the field's Status in the table above (and the run's scope: version
and file kind), and correct the field's rustdoc and the
PROVISIONALtable insrc/lua/write.rsif the run settled more or less than the table says. The Findings page,wire/README.mdand the plugin spec point here instead of restating the values.
Plan §7.3, in tests/support/lua_round.rs (shared by tests/lua_roundtrip.rs
and the local sweeps):
-
Round trip 2, model → Lua → model (
LuaMode::TestRoundTrip): the same model back for everyPresence::Both/LuaOnlyvalue, opaque Lua content and read warnings included, and writing the read-back model again gives the same table. A model read from XMP goes in without what has no Lua form (without_xmp_only): XMP-only keys (HasSettings,HasCrop, ...; a preset header is not part of the model),PerFormatvalues in their XMP shape, content kept as XMP. Empty containers need no exception: the reader reads them as "absent". -
Round trip 3, Lua → model → XMP preset → model → Lua, mixed and
single-kind preset: the preset's model goes through Lua losslessly and
without a warning, and every value it shares with the source (filtered
for the preset, rounded with
Value::quantizedto the preset writer'sNumFmt, then through Lua) is equal. Opaque content is excepted by definition (format-specific; a preset never carries it).
| Source | Round trip 2 | Round trip 3 |
|---|---|---|
committed: Lua fixtures, wire goldens, XMP fixtures (lua_roundtrip.rs) |
42 Lua tables under 4 encodings (168), every XMP fixture (22) | 64 sources, 128 presets, 7,374 shared values |
| training dump, 1,294 rows (local) | 311,746 values | 2,588 presets, 301,834 shared values |
| Lightroom bundle, 446 presets (local) | 27,151 values | 892 presets, 48,281 shared values |
| Camera Raw presets, 4 (local) | 310 values | 8 presets, 530 shared values |
| sidecar corpus, all 10,815 (local) | 1,645,988 values | 21,630 presets, 2,267,558 shared values |
All clean (October 2026): no value changed, lost or warned about, every second write identical.
server-rs/testdata/develop/wire/*.json are tables the writer produces in
apply mode with the default options (LuaOptions::PROVISIONAL; re-blessed
2026-10-03 for panel_switches = MaskOnly, which added
EnableMaskGroupBasedCorrections = true to the ten goldens with
corrections), one per shape: basic global
sliders, raw Custom white balance, non-raw incremental white balance, global
and parametric tone curves, HSL and colour grading, a subject and a sky
correction, linear and radial gradients, a sky minus subject intersected
with a luminance range, a people part, local point curves on a correction,
the Look as a stub and whole, a source whose only correction is a brush
(MaskGroupBasedCorrections absent, never []), and five committed
readbacks applied to their own photo: a sky mask, a colour range
(PointModels, ColorAmount), an area colour range next to AI masks
(AreaModels, ColorRangeMaskAreaSampleInfo), Select Object polygons
(Gesture/Points) and the LensBlur struct (18 files).
tests/lua_wire_goldens.rs writes and compares them, pins each case's
skipped paths and ApplyCall, and checks each file both ways with the
sweeps' check (support/lua_apply.rs): it reads back without a warning,
every value of the filtered settings is in it or reported, and it holds
nothing beyond those and the writer's fixed form. Sources are committed
scrubbed fixtures or models built in the test, with synthetic sync ids; the
files pass both hygiene checks. Their README (wire/README.md) says who
reads them and how to re-bless.
The same test file also
- writes every case with each
LuaOptionsfield flipped to each of its other values in turn, in apply and preset mode, and requires the same two-way check to pass (504 tables): flipping a field and re-blessing needs no test or reader change on the Rust side; - generates
wire/key_classes.txtfrom the registry (blessed with the goldens): each key name with the classes of its values (number,integer,flag,maskenum,compound, ...) ornever, which the plugin spec checks every value against, so the spec's key lists cannot drift from the registry or fromNEVER_WRITE; - pins
ADAPTIVE_PRESET_LOCALSagainstLOCAL_KEYSinDevelopExperiments.lua.
Not frozen. The first run settled the AI-mask forms for subject, sky
and background on one non-raw photo, and the default table is now the form
E2 applied; people parts, panel_switches (None against MaskOnly),
int_flag_as and the Basic-panel checks are open. Freezing also needs one thing the
experiments as built do not do: apply the writer's own output. E2 and E4
apply hand-built tables (the AI-mask preset form, all 23 locals,
EnableMaskGroupBasedCorrections), never a golden. Blocking item before the
goldens are frozen: an E2 variant that decodes masks_subject_sky.json,
mask_luminance_intersect.json and global_basic.json with JSON.lua and
passes them unchanged to applyDevelopSettings (with and without the mask
switch, on a photo where the switch is off). Variants for numeric-string mask enums, MaskForm::Minimal,
LocalForm::Sparse and booleans on the flag keys would measure the
alternatives no experiment applies yet.
To re-bless after an intended writer change, or after an experiment has
flipped a field of LuaOptions::PROVISIONAL:
cd server-rs
LRG_BLESS=1 cargo test -p lrg-develop --test lua_wire_goldens # writes the files and key_classes.txt
cargo test -p lrg-develop --test lua_wire_goldens --test lua_roundtrip
cd .. && busted # the plugin specThe blessing run skips the read-back test (it would race the files being written), so the second command checks them. A golden no case writes fails the test: delete a file together with its case.
The plugin side. plugin/spec/native_wire_format_spec.lua decodes every
golden with the plugin's own JSON.lua, under Lua 5.1 in CI as in
Lightroom (lua-tests.yml, which also runs when only
server-rs/testdata/develop/wire/** changes), and checks what reaches
applyDevelopSettings: a top-level table with string keys; real sequences
(keys 1..n) and no empty, mixed, sparse or null value (decoded with a
null placeholder so a null cannot hide as a missing key); every value of
the Lua type of its key's registry class (key_classes.txt): numbers as
numbers, numeric-looking strings as strings, compound strings as lists of
numbers (ReferencePoint 2, LumRange and FocalRange 4; an empty
string is no list), CorrectionRangeMask.Type only inside a range mask,
sync ids 32 hex digits, booleans as booleans and never "true"/"false";
no key the registry does not know; global curves flat number lists of even
length ≥ 4, local curves lists of "x,y" strings; a non-empty
CorrectionMasks on every correction; no never-written key at any depth
(the registry's never, *Digest*, MaskBrushTable*), no Temp, no
top-level ProcessVersion; one white-balance family, numbers only with
Custom or no mode; and the same decoded values after the plugin's own
encode and decode. The encodings the experiments bear on are accepted
either way (mask enums as integers or integer strings, 0/1 flags as 0/1 or
booleans): all 504 flipped tables of the Rust test pass the spec under Lua
5.1 and 5.5. The shapes the checks are about (corrections, global and local
curves, compound strings and lists, booleans, flags, mask enums, numbers)
are counted while the goldens are collected, and a check fails when one is
missing, so a re-bless cannot make them pass vacuously; that check does not
depend on the other tests having run. A block of crafted broken tables (a
number as a string, an empty compound string, a wrong count, a mask enum
that is no integer, an unknown key, ...) proves each check catches what it
is for. Two pin tests hold JSON.lua's own behaviour the rules rest on:
encode({}) is "[]", and a null inside an array decodes to a hole.
Values are compared decoded, numbers within %.14g, never as encoded text.
lrg_develop::build makes new local corrections: a CorrectionBuilder takes
adjustments in UI units and mask components in evaluation order and returns a
Correction the preset writer (or, later, the Lua writer) takes as it is.
let ns = SyncNamespace::lrgenius();
let sky = CorrectionBuilder::new("Sky", &ns) // CorrectionName "LrGenius · Sky"
.amount_ui(100.0)? // CorrectionAmount 1 (the default)
.local_ui("LocalHighlights2012", -40.0)? // stored -0.4
.local_ui("LocalDehaze", 15.0)? // stored 0.15
.add(Semantic::Sky)
.subtract(Semantic::Subject)
.intersect(LuminanceRange::highs(0.5, 0.2)?)
.build()?; // Result<Correction, BuildError>-
Adjustments.
local_ui(key, ui)converts through the registry's UI scale (LocalExposure2012EV / 4, the other signed sliders / 100,LocalToningHueandLocalCurveRefineSaturationas they are);local_curve(key, &[(x, y)])setsMainCurve/RedCurve/GreenCurve/BlueCurve(2+ points, x strictly increasing). Out of range isBuildError::Ui(OutOfRange), never clamped. Only learnable adjustments with a verified UI scale are writable:LocalHueandLocalGrain(unverified scales),LocalPointColors/LocalColorVariance(UNKNOWN), the legacyLocalExposurefamily (COMPUTED) and every bookkeeping key areBuildError::NotWritable. -
Components.
add,add_inverted,subtract,intersectencode as the combination table in The model.buildrefuses a correction without components (Empty), one whose first component is not added (FirstNotAdd), one without any adjustment (NoAdjustments) and an empty role (EmptyRole).MaskValueis 0 exactly whenMaskBlendModeis 1. A luminance range'sCorrectionRangeMask.Invertis set to the component'sMaskInverted(not the caller's), as Lightroom writes every range mask — an intersected range therefore hasInvert="true"; a range whoseInvertdisagreed could be read as its opposite. -
Names. The correction is
"LrGenius · <role>"; each component gets a neutral EnglishMaskNameclose to Adobe's (Subject,Sky,Background,Iris and Pupil,Vegetation,Linear Gradient,Radial Gradient,Luminance Range, ...; Adobe's own Subject and Sky presets saySubject 1/Sky 1). Names are display only; nothing reads them back. -
Sync ids.
CorrectionSyncIDand everyMaskSyncIDare the first 16 bytes of SHA-256 over a domain tag and the length-prefixed namespace, role and slot (the correction, or component i), as 32 upper-case hex digits (SyncNamespace::id). The same role gets the same ids on every run, so a later step can replace a correction rather than stack a second one (whether Lightroom replaces or duplicates is experiment E4/E5). Two corrections of one preset therefore need different roles: assemble a preset's corrections withbuild::corrections([builder, ...]), which builds each and refuses a repeated role (BuildError::DuplicateRole). The preset writer does not check it, because Lightroom's own sidecars repeatMaskSyncIDs across the corrections of one file (133 of 3,465 sidecars with corrections, 12 even within one correction). Known-answer tests pin three ids, so a change to the domain tag, the length prefixes or the slot texts — which would stop a later release from replacing what an earlier one wrote — fails. -
What is left to the writer. The builder sets the typed parts only
(name, id, amount, active, adjustments, tool, combination). The preset
writer adds
What, an AI mask'sMaskVersion/ReferencePoint/ErrorReasonand a range mask'sVersion3/SampleType0 (see Preset mode). A radial gradient'sVersion2 is set by the builder, because the reader keeps that field in the component'sextra.
| Component | Built from | Encoding | Policy |
|---|---|---|---|
Semantic::Subject / Sky
|
— |
Mask/Image, subtype 1 / 2 |
LEARN |
Semantic::Background |
— | subtype 0, category 22 | LEARN |
Semantic::people_part(PeoplePart) |
FaceSkin 2, IrisAndPupil 3, BodySkin 4, Hair 5, Lips 6, FacialHair 7, EyeSclera 8, Eyebrows 9, Clothes 11, Teeth 12 |
subtype 3 (preset form; "all people" unconfirmed) | LEARN |
Semantic::landscape(LandscapeClass) |
Architecture 50001 … Snow 50008 |
subtype 0 | LEARN |
Semantic::person_part_at(part, SensorPoint) |
a part and the person's point | subtype 0 + a real ReferencePoint
|
PHOTO, unverified until E2/E8 |
LinearGradient::new(zero, full) |
two different SensorPoints (by the key names no effect at zero, full at full; the direction is unverified until E6) |
Mask/Gradient |
PHOTO |
RadialGradient::new(top, left, bottom, right, feather) |
top < bottom, left < right, feather 0..=100 |
Mask/CircularGradient, Angle 0, Midpoint 50, Roundness 0, Version 2, Flipped = !MaskInverted
|
PHOTO |
LuminanceRange::new([fl, l, h, fh]), lows(upto, feather), highs(from, feather)
|
0 ≤ fl ≤ l ≤ h ≤ fh ≤ 1 |
Mask/RangeMask, CorrectionRangeMask.Type 2, Invert = MaskInverted; no eyedropper sample (LuminanceDepthSampleInfo, which every range in Lightroom's sidecars has) — unverified in a preset until the hand test |
LEARN |
A category outside these tables is BuildError::InvalidCategory (whole-person
20036 included); MaskTool::Opaque (brush, Select Object, colour or depth
range) and a luminance range carrying further CorrectionRangeMask fields
(the eyedropper sample) are NotBuildable. A PHOTO component makes its whole
correction PHOTO, so the preset writer leaves it out and reports it in
Written::skipped.
Coordinates. A SensorPoint { x, y } (and a radial gradient's Top,
Left, Bottom, Right) is normalised per axis in the uncropped image in
sensor orientation: x 0 is the left edge and 1 the right, y 0 the top
and 1 the bottom, as the raw file's sensor lies, before crop and before the
photo's rotation. Values may leave 0..1 (a gradient may start outside the
frame). Builders take SensorPoints and normalised numbers only, never pixel
tuples; converting export or display pixels (crop, rotation, orientation) is
FrameGeometry, which comes with its first consumer in step 2. Until then the
gradient builders are only usable with points that already are in this frame
(tests, presets).
Radial gradients: Angle 0 only. The reader types a radial gradient only
when its Angle is 0; a rotated one stays MaskTool::Opaque (with every
field, so it still round-trips) until experiment E6 settles the rotation
convention, and the builder has no angle parameter. It also fixes Midpoint
50 and Roundness 0 and sets Flipped = !MaskInverted itself: that is the
relation every radial gradient in the maintainer's sidecar corpus holds
(414 typed ones; the local sweep counts it on every run, next to
Invert = MaskInverted for all 207 typed luminance ranges, their Version
3 and their SampleType: 195 × 0, 12 × 1).
Parity with Adobe's adaptive presets (lightroom_adaptive_presets_rebuild_with_the_builders
in tests/xmp_goldens_local.rs, local only). Every correction of the bundle's
adaptive presets is rebuilt from the parsed model with the builders (the
amount and adjustments through to_ui → amount_ui/local_ui/local_curve,
the same components and combinations, a luminance range without its
Version/SampleType, which the writer adds) and written as a preset. Last
run (September 2026, Lightroom Classic 15.6's bundle): 38 presets, 109
corrections, all rebuilt; read back, they equal Adobe's corrections written by
the same writer in every field (names and sync ids apart, which are ours by
design). Against Adobe's files as they are, the only differing fields are ones
the writer also holds back from Adobe's own corrections: the digests
LocalInputDigest/LocalInputDigestVersion (36), the legacy PV2010
LocalBrightness/LocalClarity/LocalContrast/LocalExposure (109, all at
their default), LocalCorrectedDepth (65) and LocalGrain (65, unverified
scale, all 0), LocalHue (109, unverified scale; one non-zero), and
LocalPointColors/LocalColorVariance (9, UNKNOWN). The builder refuses
LocalGrain, LocalHue and LocalPointColors/LocalColorVariance as
NotWritable, which the test counts; LocalCorrectedDepth, the digests and
the legacy keys are COMPUTED and never offered to it. The bundle holds only
added AI masks (110 components, all Add), so this parity says nothing about
subtract, intersect, luminance ranges, gradients or Select Background; the
corpus sweep and the third hand-test preset cover those forms. CompatibleVersion is
compared for the report: equal in 28 presets, ours lower in 10 — 6 carry
point colour (Adobe 17.4, ours 15.3, since we drop it) and 4 are Subject, Sky
or people presets Adobe stamped 15.3 whose only difference from Adobe's own
14.0/15.0 presets of the same content is LocalCorrectedDepth/LocalGrain
at 0, which we do not write.
build::recipe::settings_from_recipe(&recipe, file_kind) is how AI Edit's
retoucher reaches Lightroom: the model edits in the recipe's vocabulary and this
turns it into DevelopSettings for the Lua writer.
-
Globals at their recipe alias paths (
global.exposure,global.hsl.red.hue, ...), plusEXTRA_GLOBALS, which have no alias because no training example teaches them: the colour-grading midtones, the three luminances and blending, and the black-and-white mix. Values go throughfrom_ui_value; one outside the key's range is clamped and reported. -
White balance (
global.temperature/tint) goes into the photo's own family withWhiteBalance = "Custom". A number on the other family's scale (Kelvin for a rendered file, an offset for a raw one) is dropped with a note, never clamped; an unknown file kind gets no white balance. -
treatment("black_and_white"/"color") setsConvertToGrayscale. -
Masks (
[{target, adjustments}]): one correction per target fromMASK_TARGETS(subject, sky, background, the ten people parts in their preset form, seven landscape classes — not the landscape sky, whichskyalready is), built byCorrectionBuilderwith the role as name (LrGenius · Sky). Their sync ids follow from the role, so the same target in a later round replaces the earlier correction. Adjustments areLOCAL_ADJUSTMENTS(exposure in EV, the rest ±100). A second mask on one target, an unknown target or a mask without a non-zero adjustment is left out; the first two with a note.
starting_values(&settings) is the way back for telling the model where a
photo starts: the basic sliders always, every other recipe global that is not
0, the white balance on the photo's own scale, and whether it is black and
white.
The one check no test can do: does Lightroom Classic (15.5.1 or later)
import a written preset and show its masks? It runs on the installed
Lightroom Classic — 15.6 on the maintainer's machine, newer than the 15.5.1
TARGET_ENGINE aims at — and the version actually used goes into the
record below and the PR. An import into a newer Lightroom cannot confirm a
CompatibleVersion minimum. tests/builder_presets.rs builds three
presets with the real builders and writer in preset mode (ProcessVersion
15.4):
| File | CompatibleVersion |
Global | Corrections (Masking panel) |
|---|---|---|---|
LrGenius Handtest Subject+Sky.xmp |
14.0 | Contrast +15 | "LrGenius · Subject" (Subject: Exposure +0.30); "LrGenius · Sky" (Sky: Highlights −40, Dehaze +15) |
LrGenius Handtest Landscape+People.xmp |
15.3 | — | "LrGenius · Vegetation" (Vegetation: Saturation +20); "LrGenius · Eyes" (Iris and Pupil of all people: Saturation +30) |
LrGenius Handtest Combinations+Background.xmp |
15.0 | — | "LrGenius · Bright sky" (Sky, minus Subject, intersected with a luminance range 0.3/0.5/1/1: Highlights −30); "LrGenius · Background" (Background: Exposure −0.50, Saturation −30) |
The third preset carries what no preset of Adobe's does: a subtract, an
intersect (with the range's Invert="true" mirroring MaskInverted), a
luminance range without the eyedropper sample every range in Lightroom's
sidecars has, and Select Background.
LRG_HANDTEST_DIR=/some/dir cargo test -p lrg-develop --test builder_presetswrites all three there. In Lightroom Classic: Develop → Presets panel → + →
Import Presets…, pick the files; they appear in the group "LrGeniusAI".
Apply the first and third to a photo with a person, sky and some bright
background, the second to one with vegetation and to one with two or more
people (does the people part select everyone, or one person?). Expected:
the global Contrast moves to +15; the Masks panel lists the named masks,
each with the components the table names (Lightroom computes the masks on
apply, which can take a few seconds; the people and landscape masks may ask
for an update); the mask sliders show the values above; the "Bright sky"
mask covers only the bright sky around the subject. What to note if it
fails: an import error, masks listed but empty, a mask missing, a
component shown inverted (a range selecting the shadows), or a people part
on one person only. Until the run is recorded here, Select Background's
15.0 row, the luminance range and the subtract/intersect encodings in a
preset, and "all people" are unverified. The same presets with synthetic
ids are the byte goldens in testdata/develop/written/ (see Fixtures and
hygiene).
Record (Lightroom version, date, per preset: imported / masks shown / values / notes): pending.
Test data lives in server-rs/testdata/develop/ (layout and scrubbing rules
in its README):
-
lua/*.jsonlisted inlua/manifest.jsonare generated byserver-rs/scripts/develop_registry/extract_fixtures.pyfrom the maintainer's edits and replaced on every run. Do not edit them. - Shapes the source data does not have (booleans as 0/1, non-integers on
integer keys,
nullin arrays, mixed arrays, non-raw white balance, both white-balance families, PV2010) are hand-written aslua/hand_*.json; the generator never touches them. Add a new one there, and give it its own test intests/lua_read.rsnaming the warnings it must produce. - Any other
*.jsoninlua/is a hygiene failure. -
xmp/*.xmpare self-authored, one file per XMP form or develop shape (no Adobe file, no copy or excerpt of one, no real sidecar); the list is in the folder's README and inFIXTURESintests/xmp_fixtures.rs. -
wire/*.jsonare the Lua writer's wire goldens (see Wire goldens): not frozen whileLuaOptionsfields are open, regenerated withLRG_BLESS=1 cargo test -p lrg-develop --test lua_wire_goldens, read by Rust and the plugin's busted spec.wire/key_classes.txt, generated by the same run from the registry, holds key names and value classes only. -
defaults/non_raw_lrc15.6.jsonis E13's readback of one non-raw photo without develop edits (a virtual copy of a TIFF; key -> value, plus Lightroom version, process version and file kind), the source of the registry's non-raw default column (see Maintaining the registry). -
written/*.xmpare the writer's byte goldens: the three hand-test presets exactly as the preset writer emits them, with synthetic ids in place of the derived sync ids (a SHA-256 id cannot be told from a catalog's by the hygiene check) and the development toolkit string. Regenerate after an intended format change withLRG_BLESS=1 cargo test -p lrg-develop --test builder_presets.
The hygiene rules keep private data out of the repository: no photo ids,
paths, file or image names, dates, real 32-hex ids or GUIDs, profile
definitions or lookup tables (Look.Parameters is at most a stub of
ConvertToGrayscale/ProcessVersion), and Look names/UUIDs only from the
verified Adobe profiles or synthetic. The Python check
(extract_fixtures.py --check-only, in pre-commit and CI) and the Rust test
tests/fixture_hygiene.rs implement the same rules and must stay in step:
both match patterns against whole strings with ASCII digits, and both run the
same crafted files through the file-level rules
(extract_fixtures.py --self-test, and
dispatch_rules_flag_exactly_the_crafted_files in the Rust test).
registry_snapshot.json holds key names and their metadata, no settings, so
it gets the text rules without the table-key substrings, plus the id check.
An XMP file must be well-formed with an rdf:RDF, its rdf:Alt items need a
valid xml:lang, and its data model (xmp_as_tree, the same conversion on
both sides) gets the rules of a hand-written JSON file, so a Look.Parameters
beyond the stub, a real id or a date in an XMP fixture fails the same way.
The table-key substrings (Table_, LookTable, RGBTable) are forbidden as
text, with one exception on both sides: the preset-header key
RequiresRGBTables as a whole word, which Adobe's newer presets (the 163 of
446 bundled ones that carry SupportsAmount2, every adaptive preset
included) and every preset the writer produces carry (the writer goldens and
xmp/preset_header.xmp hold it). It is removed before the text rules run, so RGBTable_<md5> or
RequiresRGBTablesX still fail; crafted self-test files pin both cases.
Everything under server-rs/testdata/develop/ is checked out with LF line
endings on every platform (.gitattributes), and the snapshot test compares
content, not line endings, so a Windows checkout passes too.
-
Unit tests next to the code: registry invariants, scaling, coercion,
Finite, the settings invariant, combine/semantic tables, the policy filter, the frame split, every reader rule. -
tests/lua_read.rs: every fixture inserver-rs/testdata/develop/lua/parses without errors or unknown keys; generated fixtures without any warning; every shape a fixture'smanifest.jsonentry claims is recognised in the model; each hand-writtenhand_*.jsonshape produces exactly its expected warnings. -
tests/fixture_hygiene.rs: mirrorsextract_fixtures.py --check-only(rules inserver-rs/testdata/develop/README.md): no photo ids, paths, file names, dates, real ids, profile definitions or lookup tables in the test data. -
tests/xmp_fixtures.rs: every fixture intestdata/develop/xmp/parses; onlyunknown_keys.xmphasUnknownKeywarnings and only it andcoercion_warnings.xmpany warning; onlybag.xmp(1) andalt_second_language.xmp(2) keep registry keys whole (kept_whole); no fixture uses a Lua-only key; each form has its own assertions (attribute and element form, and plain andparseType="Resource"structures, read into equal models). The pre-scan, the limits, mixed-form elements, duplicates andrdf:valueattributes are unit tests inxmp/read.rs(deep nesting is parsed on a 2 MiB-stack thread). -
tests/xmp_lua_equivalence.rs: for the curve, mask, look and number fixtures, the same settings hand-written in the Lua table form read into an equal model. Removed before comparing: global keys of only one format (Presence::XmpOnly/LuaOnly) andLook.Parameters, which each reader keeps opaque in its own format. The other by-design differences listed under Reading XMP (PerFormatandAnykeys, nested Lua-only keys, thePointColorssentinel) are kept out of the fixtures instead. -
xmp::formatandxmp::writeunit tests: every spelling rule above, the document shape against Adobe's layout, the preset header defaults and stamps, policy filtering and the never-write guard (with everyNEVER_WRITEkey checked against its policy), theCompatibleVersiontable, errors instead of broken files, byte determinism (twice the same bytes, insertion order irrelevant), theLookby reference without its profile definition or tables, values kept whole never reaching a preset, white-balance numbers only with a custom mode, the recomputed AI-mask fields not reported, a blank name refused, and — with thetest-roundtripfeature — the lossless mode's own rules (numbers the format would round, foreign namespaces and qualifiers, Lua content refused). -
tests/xmp_roundtrip.rs(plan §7.3): round trip 1 over every fixture intestdata/develop/xmp/and every golden intestdata/develop/written/(model, header, kind and opaque content equal; writing the read-back model gives the same bytes); the key enumeration — every LEARN and PHOTO key with an XMP form, at every level (global, correction, mask component,Look,LensBlur, range-mask, area-model and gesture fields), with its minimum, maximum, midpoint, default, a negative value for a signed range, a value with more digits than any format keeps, and every member of a closed set, survives the lossless mode exactly (789 values), and every LEARN value a preset can carry by itself (global values, a correction's adjustments and amount,LensBlurfields, theLookamount) survives preset mode, mixed and single-kind, after rounding to itsNumFmt(Value::quantized; 1,073 checks; a LEARN† key may be held back by its gate — the raw-only and non-raw-only white balance, the file-kind-defaultSharpnessandColorNoiseReductionin a mixed preset,LensProfileSetupoutside its values — and what is held back is pinned per key, gate and mode; a plain LEARN key never is). Mask fields are typed and go through presets in the builder tests. And a preset written from every Lua and XMP fixture, mixed and single-kind, is byte-deterministic and reads back as a preset with no warning, nothing kept whole, no key outside LEARN, LEARN† and META (apart from the writer's own fixed values:WhiteBalance="Custom", an AI mask'sReferencePoint/ErrorReason, a range mask'sSampleType), and exactly the policy-filtered settings, each value rounded as written (the writer's stamps and fixed form apart). Both checks live intests/support/preset.rsand are shared with the local sweeps; a test pins that they catch a lost, changed or extra value, a PHOTO key and content kept whole. -
buildunit tests: every semantic variant with its encoding and name, unknown categories, the combination table andMaskValue 0 ⇔ MaskBlendMode 1, first-component-added, empty correction,Flipped = !MaskInverted, a range'sInvert = MaskInvertedunder every combination, radial and linear checks, luminance-range ordering, UI scaling and the not-writable keys, local curves, sync-id determinism, uniqueness per namespace, role and slot, and known answers,correctionsrefusing a repeated role; through the writer: built semantic corrections read back unchanged from a preset (an intersected range withInvert="true"), PHOTO corrections are skipped and reported, output is byte-deterministic, and (featuretest-roundtrip) every built tool round-trips exactly. -
lua::writeunit tests: one per writer rule — keys and policy, the guard against everyNEVER_WRITEkey, computed mask bookkeeping never written, JSON types of integers/reals/booleans/flags, coercion and wrong variants, numeric-looking strings, compound strings and ids, curves (flat, local strings, one point refused), corrections in panel order with their fixed form, gradients, no empty containers (no corrections, a brush-only source, an emptied structure),check_wireagainstnull, empty and mixed lists andTemp, the white-balance family (raw, non-raw, unknown kind, other modes, mode only,Customnever alone, flatten,Customstamp), everyLuaOptionsalternative (the adaptive-preset locals and the sparse form, the panel switchesMaskOnlyandAll, the amount flags only in preset mode), theLookforms, a camera restriction and an adaptive Look's AI update,PANEL_SWITCHESnaming registry keys, read-back and determinism, and the provisional choices pinned. -
tests/lua_wire_goldens.rs: the wire goldens above, every case under every single option flip,key_classes.txt, and the adaptive-preset locals against the experiments' list. -
plugin/spec/native_wire_format_spec.lua(busted, from the repo root): the same goldens decoded by the plugin's JSON.lua (see Wire goldens). -
tests/lua_roundtrip.rs(plan §7.3, see Round trips 2 and 3): round trip 2 (model → Lua → model, lossless) over every Lua fixture and wire golden, warnings included, under all four encodings (mask enums as numbers or strings × flags as numbers or booleans; 42 tables each), and over every XMP fixture without what has no Lua form; round trip 3 (Lua → model → XMP preset → model → Lua, mixed and single-kind) over the Lua fixtures, the wire goldens and the XMP fixtures, agreeing on every shared value afterValue::quantized(7,374 values; a failure names the source); the key enumeration (tests/support/enumerate.rs, shared withxmp_roundtrip.rs) through the lossless mode (792 values) and through apply mode for a raw and a non-raw photo, where every global, correction,LensBlurandLook.Amountvalue reaches the photo exactly or is reported or is its default (1,299 written), with what is held back pinned per key and file kind (the other white-balance family, a mode without numbers,LensProfileSetupoutside its values, the guard'sLensProfileDigestandorientation); and the sweeps' apply-mode check over every committed Lua and XMP fixture (profiles excluded), with the provisional options and every single flip, in apply and preset mode (45 sources, 1,350 tables, 130,792 values compared, nothing lost, changed or added) — the check that covers the mask-tool, range-mask and gesture values the enumeration does not place. -
tests/builder_presets.rs: the three hand-test presets (see Hand test) build, write without skips and read back unchanged, and (with synthetic ids) match their byte goldens intestdata/develop/written/;LRG_HANDTEST_DIRwrites them out for Lightroom. -
tests/registry_snapshot.rs: the snapshot above. -
tests/support/: helpers the local sweeps share (mask tool and combination counts, the mask forms the builders rely on —Flipped, a range'sInvert,VersionandSampleType—, the registry-range check, warning paths without indices), all reducing a model to key names and counts;support/flat.rsflattens a model intopath → valuewith the typed parts expanded back into the XMP keys they stand for, so two models are compared key by key;support/preset.rsholds the two checks every preset-writing test applies (and the quantization round trip 3 uses);support/enumerate.rsthe key enumeration both writers' round trips share;support/lua_apply.rsthe two-way apply-mode check of the Lua writer (every filtered value written or reported, nothing added beyond the writer's fixed form) and the option flips, shared by the local sweeps,lua_roundtrip.rsandlua_wire_goldens.rs;support/lua_round.rsround trips 2 and 3, counted, forlua_roundtrip.rsand the local sweeps (all included by path from the tests that use them). -
tests/training_dump_local.rs— local only: parses every row of the maintainer's private training dump and requires zero parse errors, zero warnings of any kind and every number within its key's registry range, printing counts per warning kind, mask tool, combination and out-of-range key (never values or photo ids; failing rows are named by position). Every row is also written as a preset, mixed and single-kind — the production path, since learned settings come from Lua — and must pass the same two preset checks as the fixtures; and every row goes through the Lua writer: round trips 2 and 3 (tests/support/lua_round.rs; 311,746 and 301,834 values) and apply mode for its own photo (tests/support/lua_apply.rs: a table that passescheck_wire, reads back without a warning, holds every filtered value exactly or reports it, and holds nothing beyond those and the writer's fixed form; 1,294 rows, 213,826 values compared, none changed, lost or added, October 2026):LRG_DEVELOP_TRAINING_DUMP=/path/to/training_rows.json \ cargo test -p lrg-develop --test training_dump_local -- --nocaptureWithout the variable it skips through the shared golden gate (
crates/lrg-ml/tests/common) as familytraining-dump, which is local-only:LRG_REQUIRE_GOLDENS=allleaves it out, and onlyLRG_REQUIRE_GOLDENS=training-dumpmakes a missing dump a failure. Setting the variable makes a missing file a failure: a typo or an unmounted volume must not pass as a skipped run. -
tests/xmp_goldens_local.rs— local only: the XMP reader and writer over files that can never be in the repository or CI, one test and one gate family per source:Variable Family Parses Must hold LRG_LRC_PRESETS_DIRlrc-presetsevery *.xmpbelow it (Lightroom'sContents/Resources/Settings)0 parse errors, 0 UnknownKey, no warning butDuplicateKey Group, 0 keys kept whole; presets and profiles onlyLRG_ACR_PRESETS_DIRacr-presetsthe presets and profiles among the *.xmpbelow it (Camera Raw's user folder: its presets and profiles)as the bundle; keys kept whole only printed (a user preset may have a localized name) LRG_XMP_CORPUS_DIRxmp-corpusthe sidecars below it with crs:ProcessVersion;LRG_XMP_CORPUS_SAMPLE=Ntakes a fixed-seed sample of N0 parse errors, no warning at all, 0 keys kept whole, every file a SidecarEvery sweep also fails on an unrecognised mask combination, on a mask component against a relation the builders rely on (
Flippedother than!MaskInverted, a range'sInvertother thanMaskInverted, a rangeVersionother than 3), and on a number outside its key's registry range, and writes every parsed document back: losslessly (round trip 1: no write error, no key differing after reading back, the same model, header and kind, idempotent bytes, and everycrs:value spelled as the source spelled it, apart from a per-sweep allow-list of known Adobe variances with their reasons; mismatches are counted per key with indices removed) and as a preset, mixed and single-kind (no write error, reads back as a preset with no warning, no key outside the preset policy, nothing kept whole, and exactly the policy-filtered settings; what the policy holds back is printed per mode, key and reason, pattern families folded toTable_*/UprightTransform_*). Every document but a profile also goes through the Lua writer in apply mode for its own photo, with the same check as the training dump (October 2026, both ways — every filtered value written or reported, nothing added beyond the writer's fixed form: the 446 bundled presets (24,283 values), the 4 Camera Raw presets (267) and all 10,815 corpus sidecars (1,376,155) clean), and, without what has no Lua form, through round trips 2 and 3 (counts in Round trips 2 and 3). A fourth test, onLRG_LRC_PRESETS_DIR, is the builder parity with Adobe's adaptive presets (see Building corrections). The corpus walk skips* [conflicted].xmpsync copies, darktable's<name>.<ext>.xmpsidecars (nocrs:at all) and metadata-only sidecars, and prints how many of each. Output is counts per document kind, warning kind, process version, skipped subtree, mask tool and combination, plus key names; never a value, and corpus or Camera Raw files are named by ordinal only.LRG_LRC_PRESETS_DIR="/Applications/Adobe Lightroom Classic/Adobe Lightroom Classic.app/Contents/Resources/Settings" \ LRG_ACR_PRESETS_DIR="$HOME/Library/Application Support/Adobe/CameraRaw" \ LRG_XMP_CORPUS_DIR=/path/to/photos \ cargo test -p lrg-develop --test xmp_goldens_local -- --nocapture
The full corpus above (October 2026: 28,681 files, 10,815 parsed) takes about 150 s in a debug build with all write-backs and checks;
LRG_XMP_CORPUS_SAMPLE=3000about 25 s. Gating works as for the training dump: an unset variable skips, the families are local-only (allleaves them out, only naming one makes its absence a failure), and a set variable that is not a directory, or a directory without a single file to check, fails.A new unknown key found here goes into the registry as a hand-written row in
registry/table.rs, followed byLRG_BLESS=1 cargo test -p lrg-develop --test registry_snapshot(see Maintaining the registry), never into an allow-list.