SWIFTPOINT//HUB
Support

SpCF format — reading and writing the file

A .spcf file is one line of format header followed by a JSON document, and this page documents that document: its shape, every trigger and action kind, the nume

A .spcf file is one line of format header followed by a JSON document, and this page documents that document: its shape, every trigger and action kind, the numeric encodings, and the round-trip guarantees the codebase enforces. It covers the reader (lib/spcfParser.js) and the writer (lib/spcfWriter.js); the UI that uses them is documented in the Profile Builder.

What a .spcf file is

A plain text file with two parts: a header line naming the serializer, and a JSON body that is the profile itself. A minimal real file — one group, one button binding:

json,2,4
{
    "Version": 2,
    "RootProfile": {
        "$type": "ZMouseConfig.Items.UserConfigurationGroupItem, ZMouseConfig",
        "ItemType": "group",
        "ItemCount": 1,
        "Name": null,
        "Groups": [
            {
                "$type": "ZMouseConfig.Items.UserConfigurationGroupItem, ZMouseConfig",
                "ItemType": "group",
                "ItemCount": 1,
                "Name": "Base",
                "ActionMappings": {
                    "$type": "ZMouseConfig.Items.UserConfigurationGroupItem+ActionMappingList, ZMouseConfig",
                    "$values": [
                        {
                            "$type": "ZMouseConfig.Items.UserConfigurationMappingItem, ZMouseConfig",
                            "ItemType": "actionMapping",
                            "Id": "4211",
                            "Predecessor": null,
                            "PredecessorId": 65535,
                            "ActivationOutputActionItems": [
                                {
                                    "$type": "ZMouseConfig.OutputActionItems.MouseButtonOutputActionItem, ZMouseConfig",
                                    "TypeId": "mouseButton",
                                    "ItemType": "outputAction",
                                    "IsValid": true,
                                    "DataType": "trigger",
                                    "ButtonId": 0,
                                    "ButtonState": "tap"
                                }
                            ],
                            "DeactivationOutputActionItems": [],
                            "OutputActionSequenceDelay": 0,
                            "InputActionType": {
                                "$type": "ZMouseConfig.InputActionTypes.ButtonToggleInputActionType, ZMouseConfig",
                                "Id": "buttonToggle",
                                "IsValid": true,
                                "DataType": "trigger",
                                "ActiveState": "activeWhenOn",
                                "Button": { "Id": "left", "KeyName": "MouseButtonLeft", "IsValid": true }
                            }
                        }
                    ]
                },
                "Groups": []
            }
        ]
    }
}

$type values are .NET DataContract class names. They are part of the file — the Swiftpoint software reads them — not decoration.

The container

The container rules live in parseSpcf() and in the Builder's parseDocText(), and both are deliberately identical:

Rule Behaviour
BOM A leading byte-order mark is stripped before anything else.
Header The first line, trimmed. It must match `/^json([,\s]
Body Everything after the first newline. The header line itself is not JSON.
Binary guard A NUL character (U+0000) anywhere in the body means the upload is not a text profile, and the file is refused before JSON.parse is attempted.
No newline A file with no newline at all has no header and is refused.

The original header is preserved. parseSpcf() returns it as formatHeader; the Builder keeps it in state.header and passes it back to serializeDoc(doc, header) on every write. This matters because the header varies in the wild — the hub's own store contains json,2,1, json,2,2, json,2,3 and json,2,4 — and rewriting a file with the wrong header changes what the driver does with it.

The database splits the file in two: format_header holds the header line, raw_json holds the body. GET api/profiles/:id/download re-joins them as header + newline + raw_json.

Parsing

lib/spcfParser.js exports parseSpcf(rawText). It is a UMD file with a tiny browser shim: the only Node API it touches is Buffer.from(value, 'base64'), so a self.Buffer stand-in (backed by atob and a Uint8Array) is installed when there is no module. It also reads its nesting core, lib/nesting.js, off self.nesting — exactly as it reads a require in Node.

routes/seo.js serves the same file to the browser at /js/spcfParser.js (and the writer at /js/spcfWriter.js), so the server, the profile pages, the Checker and the Profile Builder all parse a file with identical code. Because the pages that load the parser are not in a wave that can take a new <script> yet, that one route sends lib/nesting.js and lib/spcfParser.js concatenated in a single response, core first; lib/nesting.js is also served on its own at /js/nesting.js.

Before any recursion, validateRoot() enforces hard caps so a crafted upload cannot exhaust memory or the call stack:

Cap Value Failure message
Object root doc is a plain object Profile content is not a JSON object.
RootProfile doc.RootProfile is a plain object File does not contain a valid "RootProfile".
Groups RootProfile.Groups is a non-empty array Profile contains no groups.
MAX_GROUPS 100 Profile has too many groups.
MAX_MAPPINGS 1000 (cumulative, all groups) Profile has too many mappings.
MAX_NESTING 20 Profile groups are nested too deeply.
Group entry each visited node is a plain object Malformed group entry in profile.
Mapping list ActionMappings.$values is an array when present Malformed ActionMappings in profile.

MAX_NESTING is about group nesting (Groups[]). The bound on input nesting is lib/nesting.js's MAX_NEST_DEPTH, also 20, and it is a report rather than a refusal: an over-deep mapping chain arrives as an entry in nesting.issues, and the Builder refuses to create one.

Container-level failures, verbatim:

Condition Message
No newline in the file File has no recognizable format (no header found).
Header does not match Unknown file format: "jsonx,2,4"
NUL in the body File appears to be binary — a .spcf file must be text.
Body is not JSON Could not parse the JSON content of the .spcf file: <JSON error>

The Builder and the Checker's file-open path do not call parseSpcf; they share a near-identical inline reader (parseDocText() in public/js/editor.js, parseRawDoc() in public/js/check.js) so a bad file fails with a message aimed at the person who opened it. These strings differ from the parser's — the code wins over any assumption that they match:

Condition Builder / Checker message
No newline Not a .spcf file — no header line found.
Header does not match Unsupported format: "jsonx,2,4"
NUL in the body This looks like a binary file.
Root is an array or a primitive Bad profile document. (Builder only)
No usable RootProfile.Groups No RootProfile.Groups found.
Groups is empty Profile contains no groups.
Body is not JSON the raw JSON.parse message, caught and shown

parseSpcf() returns { formatHeader, version, groups, rollup }. Every node below it is a plain display object, not the raw document: triggers are described by describeInputActionType() and given a short trigger line by describeTrigger(), actions decoded by describeOutputAction(), and each mapping keeps the tree links (parentIndex, children, depth, isRoot) so no consumer has to re-derive the nesting. Every index in the display model is the raw ActionMappings.$values index — see One index space.

Document shape

RootProfile                                  (a group whose own ActionMappings are normally empty)
|-- Groups[]                                 top-level profiles / "base groups"
    |-- Group
        |-- Name, ItemCount, Applications[], ShortName, Description, InfoProperties[]
        |-- ActionMappings                   { $type, $values: [] }  (a list object, not an array)
        |   |-- ActionMapping (UserConfigurationMappingItem)
        |       |-- Id                       random decimal string
        |       |-- Predecessor              a byte-identical recursive copy of the parent mapping, null for a root
        |       |-- PredecessorId            0-based index into this group's ActionMappings, 65535 = none
        |       |-- OutputActionSequenceDelay
        |       |-- ActivationOutputName     the label the UI shows
        |       |-- ShouldBlockOnActivation / ShouldBlockOnDeactivation
        |       |-- ActionMappingIndexToBlockOnActivation / ...Deactivation   (65535 sentinel)
        |       |-- InputActionType          the trigger
        |       |   |-- e.g. ButtonToggleInputActionType / ForceRegionToggleInputActionType
        |       |-- ActivationOutputActionItems[]       run when the trigger engages
        |       |-- DeactivationOutputActionItems[]     run when it releases / leaves the zone
        |-- Groups[]                         nested sub-profiles (same shape, recursive)
Term What it is
Group One switchable layer: a name, its own mappings, and its own device settings. A .spcf may hold several; the driver shows them as profile slots.
Sub-profile A Group nested inside another group's Groups[]. Same node type, one level (or more) deeper.
ItemCount The driver's cached count of a group's own mappings. The driver trusts it, so the writer recomputes it on every save; the parser ignores it and uses the real array length.
Applications Optional launcher hints, surfaced by the site and used as the chip label for a sub-profile.
ActionMapping One trigger plus its two action lists. The unit the site calls a binding and the Builder calls a mapping.
InputAction The trigger side: InputActionType, discriminated by Id.
OutputAction One thing to do, discriminated by TypeId, carrying a DataContract $type.
Activation / deactivation list Ordered. Order is meaningful — a loop jumps to an index inside the activation list.
Input nesting The only structure a group's mappings have: PredecessorId. See Input nesting and loops. Do not confuse it with the group nesting below, which is Groups[].

Group nesting and input nesting are two different things and the vocabulary keeps them apart. A Group nested inside another group's Groups[] is a sub-profile — a whole switchable layer. A mapping nested under another mapping via PredecessorId is input nesting: a gated trigger, live only while the trigger above it is held. Both are "nested" and neither is a sequence.

Groups, sub-profiles and layer nesting

This is Groups[] nesting — whole switchable layers inside one another. It is unrelated to input nesting, which is PredecessorId linking two mappings inside one group; the two are separate mechanisms with separate depth limits and nothing connects them.

Groups nest up to the parser's MAX_NESTING (20). The site renders a top-level group as a layer chip and a nested one as a sub-profile chip, and both the chip row and the status bar show the path you are in (Base > Sub > Sub-sub). The base chip is tagged Base and is always clickable, so you can always get back up the tree; children are tagged with their Applications name or Sub.

Sub-profiles inherit the base group's device settings for display only: descending into a child merges the parent's settings underneath the child's ({ ...parent.inherited, ...parent.group.settings } in public/js/profile-beta.js). The file itself stores nothing about inheritance — a sub-profile either has its own profileChanged mapping or it does not.

One operational fact drives the whole layer-nesting story: the X1 driver cannot nest an imported profile under an existing one. That is stated on /check ("X1 can't nest an imported profile under an existing one") and is why the Checker ships the sub-profile import (merge) tool that does it locally.

Input actions (triggers)

Seven trigger kinds exist (INPUT_TYPE_NAMES in lib/spcfWriter.js). The Builder's "add binding" menu offers six; systemEvent is internal and is created by the Settings panel.

Trigger DataContract class ($type) Key fields Range / units UI label
buttonToggle ...InputActionTypes.ButtonToggleInputActionType, ZMouseConfig Button{Id, KeyName, IsValid}, ActiveState ActiveState is activeWhenOn in practice Button press
forceRegionToggle ...ForceRegionToggleInputActionType, ZMouseConfig Button, Direction, Region{EntryStart/EndPercentageTenths, ExitStart/EndPercentageTenths} 0–100 %, stored in tenths (0.1 % precision) Deep-click zone
imuAngleRegionToggle ...ImuAngleRegionToggleInputActionType, ZMouseConfig Axis, Direction, Region{EntryStart/EndDegreeTenths, ExitStart/EndDegreeTenths} 0–360°, level = 180 Tilt gesture / Pivot gesture (yaw)
imuAngleRange ...ImuAngleRangeInputActionType, ZMouseConfig Axis, Mirrored, LeftRange/RightRange{Start/EndDegreeTenths, IsValid} shown signed, −180 to +180 relative to level Tilt range / Pivot range (analog yaw)
scroll ...ScrollInputActionType, ZMouseConfig ScrollWheelDirection, CountDivisior, Repeat forward/back; divisor 1–200 Mousewheel
surfaceCombined ...SurfaceCombinedInputActionType, ZMouseConfig Direction, CountDivisor, Repeat all/forward/back; divisor 1–200 Surface drag
systemEvent ...SystemEventInputActionType, ZMouseConfig Code profileChanged only System event

Note the driver's inconsistent spelling: the wheel's divisor field is CountDivisior (no second r), while the surface trigger's is CountDivisor. Both are written and read exactly as the driver spells them.

imuAngleRange is a band around level, stored as two half-ranges. The driver works in 0–360 with 180 as level; the user thinks in signed degrees. tiltRangeParams() in public/js/editor.js converts one into the other: a band entirely below level keeps a live left half and a zero-width right half, a band crossing level uses both, and Mirrored is set only when the band is exactly symmetric, which is what lets an asymmetric band round-trip back to the same two numbers.

The wheel pull and push are triggers, not presses. Clicking the wheel pull or push card for the first time creates a scroll trigger (back for pull, forward for push), not a buttonToggle; only wheel press becomes a buttonToggle. The comment on defaultTriggerFor() gives the reason: 49 of the 981 bindings stored on the hub are scrolls and not one of them uses the button-shaped variant.

The 16 physical controls

BUTTON_SECTIONS (the Mouse-tab grid, in card order) and BUTTON_ORDER (the writer's canonical list) both contain 16 ids — not 15. They differ only in ordering: BUTTON_ORDER puts the three wheel inputs last.

# Id KeyName Card label Section (grid span) Force sensor
1 mousewheelPull MouseButtonMousewheelPull Wheel Pull Mousewheel (2) no
2 mousewheelPush MouseButtonMousewheelPush Wheel Push Mousewheel (2) no
3 mousewheelPress MouseButtonMousewheelPress Wheel Press Mousewheel (2) no
4 left MouseButtonLeft Left Button Main (1) yes
5 right MouseButtonRight Right Button Main (1) yes
6 middle MouseButtonMiddle Middle Button Main (1) yes
7 thumbTop MouseButtonThumbTop Top Thumb Thumb (1) no
8 thumbBottom MouseButtonThumbBottom Bottom Thumb Thumb (1) no
9 fingertipLeft MouseButtonFingertipLeft Left Fingertip Fingertip (1) yes
10 fingertipRight MouseButtonFingertipRight Right Fingertip Fingertip (1) yes
11 triggerLeftPull MouseButtonTriggerLeftPull Left Trigger Pull Triggers (2) no
12 triggerLeftPush MouseButtonTriggerLeftPush Left Trigger Push Triggers (2) no
13 triggerRightPull MouseButtonTriggerRightPull Right Trigger Pull Triggers (2) no
14 triggerRightPush MouseButtonTriggerRightPush Right Trigger Push Triggers (2) no
15 edgeRear MouseButtonEdgeRear Rear Edge Edges (1) no
16 edgeFront MouseButtonEdgeFront Front Edge Edges (1) no

BUTTON_BY_ID also carries legacy aliases (primaryLeft, primaryRight, wheelPull, wheelPush, wheelPress, maxButtonValue) that appear in files written by older Control Panel versions. maxButtonValue is labelled Right Trigger Pull because it is seen aliasing triggerRightPull in the wild.

The parser's BUTTON_LABELS table is keyed by those legacy ids and by the short ids the writer emits, so both spellings resolve: a Builder-written profile (ids left, right, middle) reads as Left Button on the site, not MouseButtonLeft. Before the fix, primaryLeft resolved and left did not, and friendlyButtonName() fell through to KeyName — so the same profile showed two different names depending on which surface drew it.

Output actions (what a trigger can do)

OUTPUT_TYPE_NAMES has 27 entries. Every one is listed below with the Builder's composer label, the discriminating fields, the values the UI offers, and the text the site (the profile page and the Checker, via public/js/export-helpers.js) renders.

Kind (TypeId) DataContract class suffix Key fields Range / units Site phrasing
keyboardKey KeyboardKeyOutputActionItem KeyCode.HidPageValue, ButtonState 90-entry HID table (below); state tap/downOn/upOff F, or Hold F
keyboardMedia KeyboardMediaOutputActionItem MediaKeyCode.HidPageValue, ButtonState 8 consumer-page keys Media: Volume Up
keyboardKeyString KeyboardKeyStringOutputActionItem KeyString text, max 80 in the UI falls through to keyboardKeyString
mouseButton MouseButtonOutputActionItem ButtonId (0-based), ButtonState 0 Left, 1 Right, 2 Middle, 3 Back, 4 Forward, 5 Extra Right click / Hold Right click / Release Right click
mouseScroll MouseScrollOutputActionItem ScrollDirection, Count down/up; notches −20 to 20 falls through to mouseScroll
mousePointer MousePointerOutputActionItem Axis, PointerOption, XCount, YCount xy/x/y; X and Y −50 to 50 falls through to mousePointer
joystickButton JoystickButtonOutputActionItem ButtonId, UsesData, ButtonState UI 1–16; the stored ButtonId is 0-based and the badge shows it plus one Pad btn 3 / Hold pad btn 3
joystickStick JoystickStickOutputActionItem Axis, Mirrored, LeftRange/RightRange percentages leftRight/forwardBack/rotateLeftRight/upDown; 0–100 % Joystick X (left/right) · L 2–50% · R 50–100% · mirrored
hapticFeedback HapticFeedbackOutputActionItem StrengthPercentage, Time10ms, HapticFeedbackOption UI 0–100 % and 10–500 ms; the stored strength is displayed times 1.2 Haptic 30% · 0.03s
wait WaitOutputActionItem TimeToWaitms 0–10000 ms Wait 10ms
dpiConfigure DpiConfigureOutputActionItem SeparateXAndY, DefaultLevelIndex, Levels[{XDpiRaw, YDpiRaw, isValid}] each axis 0–32000 (UI step 50) DPI: 800 / 1600
dpiSelect DpiSelectOutputActionItem SelectionType, Value select/index/next/previous/increase/decrease; 0–32000 DPI to 800, DPI cycle: next level, DPI +500
reportInterval ReportIntervalOutputActionItem ReportInterval ("rI_<hz>_Hz") 125 / 250 / 500 / 1000 Hz 1000 Hz
liftOffSelect LiftOffSelectOutputActionItem LiftOffMm UI offers 0.7 / 1 / 2 Lift-off 2 mm
gameDeviceSelect GameDeviceSelectOutputActionItem GameDevice none/joystick/gamepad Device mode: none
ledSet LedSetOutputActionItem LedConfigurationParameters{Mode, Brightness, Duration10ms, Colour1, Colour2} off/fixedColour/hueRotation; 0–100; 10–20000 ms LED: off
oledSet OledSetOutputActionItem OledConfigurationParameters{Mode, Message, Image} 9 modes; message 32 max; image 64×32 OLED: profile / OLED: CS2
oledFlash OledSetOutputActionItem (shared with oledSet) OledConfigurationParameters, Duration10ms, Options 10–2000 ms; wait/none OLED frame 1s · wait
autoReleaseInputs AutoReleaseInputsOutputActionItem State enabled/disabled Auto-release: On
tiltAutoZeroing TiltAutoZeroingOutputActionItem State enabled/disabled Tilt auto-zero: On
gotoOutputAction GotoOutputActionOutputActionItem Index, LoopCount index 0–100 within this binding's list; repeat 0 means forever Jump to action #0 (loops while held) or (endless loop)
stopLooping StopLoopingOutputActionItem SkipWaitActions boolean falls through to stopLooping
doNothing DoNothingOutputActionItem IsSelected boolean not rendered at all (the parser returns null)
profileSelect ProfileSelectOutputActionItem Level, SelectionType down/up/next; level currentLevel falls through to profileSelect
profileJump ProfileJumpOutputActionItem ProfileJumpId{ProfileName, ProfileIndex, SubProfileIndex} name 60 max; indices 0–100 falls through to profileJump
imuAnglesZero ImuAnglesZeroOutputActionItem Axis roll/pitch/yaw falls through to imuAnglesZero
alternateOutputActions AlternateOutputActionsOutputActionItem ActionCount 1–20 falls through to alternateOutputActions

Two kinds are reachable outside that table:

  • oledAnimation is a Builder-only composer kind with no $type of its own. Committing it expands into one oledFlash per frame (see the Profile Builder).
  • dpiConfigure is in the writer but not in the Builder's composer list (KIND_ORDER); it is written only by the Settings panel.

"Falls through" means the shared label helper has no case for that kind and prints the raw kind string, so those actions read as the type name on the site and as a short badge inside the Builder.

The HID key table

HID_KEYS (writer) and HID_KEY_LABELS (parser) are the same 90 entries: A–Z (0x04–0x1D), 1–9 then 0 (0x1E–0x27), Enter, Esc, Backspace, Tab, Space, -, =, [, ], backslash, ;, ', backtick, ,, ., /, CapsLock, F1–F12, Home, PageUp, Delete, End, PageDown, Right, Left, Down, Up, then the modifiers LCtrl, LShift, LAlt, LWin, RCtrl, RShift, RAlt, RWin (0xE0–0xE7). Anything outside the table is still writable and renders as HID 0xNN.

The eight media keys are Play/Pause, Next Track, Prev Track, Stop, Eject, Mute, Volume Up, Volume Down.

Deep-click (force) zones

A light press is a buttonToggle — the whole 0–100 % axis. A deep zone is a forceRegionToggle: a band on the same axis, entered when the press force rises into it and left when it falls back out. Region carries four tenths numbers — entry start/end and exit start/end — and setting the exit band lower than the entry band gives the hysteresis that stops a wobbling press from re-triggering.

Only five controls carry a force sensor:

Id Card label
left Left Button
right Right Button
middle Middle Button
fingertipLeft Left Fingertip
fingertipRight Right Fingertip

DEEP_CAPABLE in public/js/editor.js is that list, and the Builder hides the deep-click affordances everywhere else: no + Deep zone button, no forceRegionToggle entry in the trigger dropdown, and the press-stages card is not rendered at all for a control without a sensor. Adding one is refused with This button has no deep-click sensor.

A new zone picks its own range. autoDeepRange() returns 35–100 % for the first zone on a control, and min(lastZoneEnd, 95)–100 % for every later one, so zones stack downwards without overlapping; the exit band is initialised equal to the entry band.

Overlap is diagnosed per control. collectDiagIssues() buckets all forceRegionToggle mappings by button label, sorts them by entry start, and reports Left Button: deep zones overlap at 35% — the number is the previous zone's end, which is where the two collide. The only action offered is Jump to Mapping. This check lives in the Builder; the Checker draws the force axis but does not warn.

A deep zone linked to its own light press is a press-stage stack, not a gate. If a light press and its own deep zones are linked with PredecessorId, that link is a force threshold: the zones are deeper bands of one axis, and nesting.singleButtonStackMembers() names that run so it is drawn in the control's press card with the depth scale it belongs on. The moment a follower moves to another control, it stops being a depth and becomes input nesting, and it is drawn as a tree under the trigger that holds it — see Input nesting and loops.

Input nesting and loops

PredecessorId is the format's only structure inside a group, and it means input nesting / gating, not sequencing:

PredecessorId: N = this mapping is active while the trigger of mapping N is held.

There is no if/else and no conditional logic, no ordering to run, and no timing mechanism. What the format cannot express is "press left, release it, then press right" — that is two independent gestures, and both of them live in the same file. The link is PredecessorId, a 0-based index into the group's own ActionMappings.$values, with 65535 meaning "no parent". The Predecessor field carries no information of its own: it is a byte-identical recursive deep copy of the parent mapping, kept only for format fidelity.

What the corpus says

Measured across all 48 stored profiles / 60 groups / 1026 mappings in data/swiftpoint-hub.db:

Fact Value Why it matters
Mappings with a parent 196, of which 31 parents have more than one follower The structure is a tree with branches, not a list of chains. A traversal that follows only the first follower at each hop silently loses mappings.
Max depth on disk 5 edges (profile 69) The real limit is MAX_NEST_DEPTH = 20 edges; the corpus never gets near it. Depth is counted in edges, so a root-only group is depth 0.
Non-contiguous children real — profile 35 has a child of index 1 at index 55 Children of one parent are not adjacent, and are often nowhere near it. Never infer a link from array position.
Index invariant parent index < child index, 196/196 The array is a valid topological order, but nothing may rely on it: the parser tolerates out-of-order parents, self-references and cycles.
Predecessor field present 196/196, byte-identical to the parent Pure denormalized duplication, regenerable from the array. That is what the writer's applyNesting() does.
OutputActionSequenceDelay 0 in 1026/1026 There is no timing to configure. The field is still written as 0 for wire fidelity and is not exposed in the UI.
ActivationOutputName free-text user label (e.g. Knife: 3) It is a name, not a lookup key, and the format never reads it.
Id on a mapping dead weight — no field references it. 736 distinct values across the corpus: 735 UUID-shaped, and the literal "1" Leave it alone.
ActionMappingIndexToBlockOnActivation / ShouldBlockOnActivation 65535 / false in 100 % Not a second nesting mechanism. Leave alone.
ShouldBlockOnDeactivation true on 12 of 1026 Unrelated to nesting. Leave alone.

Array order is the Control Panel's UI insertion order and carries no semantics. It is not priority, not a run order, and not a fallback chain.

lib/nesting.js — the nesting core

lib/nesting.js (836 lines, UMD, zero dependencies) owns the tree. It is shape-agnostic — built from a count plus a callback that yields an item's parent index — so the same code serves the parser (display objects) and the Builder (raw .spcf nodes). It is served verbatim to the browser (routes/seo.js sends it ahead of spcfParser.js in the same /js/spcfParser.js response, and on its own at /js/nesting.js), so it must stand alone.

Export What it is for
buildTree(count, parentIndexOf) the tree
buildTreeFromValues(values) parentIndexOf = values[i].PredecessorId — what a raw .spcf needs
parentIndexOfValues(values) the accessor above, as a function
childrenOf(tree, i) ascending follower indexes of one node
walk(tree, startIndex, visitFn) visitFn(index, depth, parentIndex); startIndex null walks the whole forest. Returns the visit count
flatten(tree) [{ index, depth, parentIndex }] in depth-first render order
pathToRoot(tree, i) the chain to the root, or null when it is cyclic
isDescendant(tree, ancestor, node) whether one node is under another
validate(tree) [{ code, index, depth?, indices?, message }] — the codes are in CODES
isSingleButtonStack(count, parentIndexOf, buttonIdOf, inputIdOf) true when some subtree is one button's press stack
singleButtonStackMembers(...) which indexes those are, ascending; [] when the group is pure nesting
insertChild(values, parentIndex, newNode, opts) returns { values, remap }
removeNode(values, index, opts) as above; refuses a node with followers unless opts.reparentChildren
moveNode(values, fromIndex, toIndex, opts) toIndex is a slot in the resulting array; opts.newParent is in new space
NO_PARENT / MAX_NEST_DEPTH / CODES / STACK_INPUT_IDS 65535, 20, the shared error vocabulary, ['buttonToggle','forceRegionToggle']
cloneValue(v), toIndex(v), isNestingError(err) iterative deep copy, integer coercion, error predicate

tree carries roots, children (Map, ascending), parent (Map, null for roots), depth, maxDepth, orphans (dangling links, promoted to root), selfRefs, detached (cycle entry points), hasCycle, cycleMembers, parentRaw and count.

The defensive contract is the point. An out-of-range or self-referential PredecessorId makes that node a root; a cycle is detached at its lowest-index member and walked at depth 0. Neither throws, and every index is visited exactly once no matter how broken the input is — so a damaged file loses the shape of a branch, never a binding.

The mutation helpers return a new array while mutating the mapping objects in place (opts.clone leaves the input untouched), and a remap with toNew (keyed by old index, -1 for dropped), indexOf(old), remapParent(p) (old-space parent → new space) and translate(map) (any target space). Depth and cycle guards run before anything is written, so a refusal leaves the array untouched.

One index space

Everything in the display model is keyed by the raw ActionMappings.$values index. There is exactly one index space, and parseGroup() no longer rewrites anything into buttonMappings space:

  • nesting.children, nesting.parentOf, nesting.depthOf are keyed by raw index.
  • nesting.nodes[i].index === nesting.nodes[i].rawIndex === i.
  • A mapping's predecessorId is its normalized raw parent (null for a root, a dangling link or a self-reference — nesting.issues says which and why); rawPredecessorId keeps the file's untouched value.
  • buttonMappings and tiltMappings keep their type filters but no longer exclude nesting children; each holds every mapping of its kind exactly once. Profile 69: 8 + 1 = 9 of 9.

indexInList(list, rawIndex) is the only sanctioned bridge from raw index space into a compacted display list, returning the position or -1. The old index-translation step — which rewrote predecessorId into buttonMappings space and nulled any link whose target was not a button binding — was removed; on profile 69 it nulled 3 of the 4 parent links it was supposed to resolve.

groups[i].nesting

The shape, with the profile-69 graph filled in. Profile 69's own pressStages is {} — none of its runs is a single button's press stack, because no two of its mappings share a Button.Id:

{
  count: 9,
  roots: [0],
  children: { 0:[1], 1:[2,4], 2:[3], 4:[5], 5:[6,7], 7:[8] },  // only parents are keys; ascending
  parentOf: { 0:null, 1:0, 2:1, 3:2, 4:1, 5:4, 6:5, 7:5, 8:7 },
  depthOf:  { 0:0, 1:1, 2:2, 3:3, 4:2, 5:3, 6:4, 7:4, 8:5 },
  maxDepth: 5,
  orphans: [], detached: [], cycleMembers: [], hasCycle: false,
  issues: [],                  // nesting.validate() — [] on all 48 stored profiles
  order: [0,1,2,3,4,5,6,7,8],  // depth-first render order, raw indices
  nodes: [ /* every mapping, raw array order; nodes[i].index === i */ ],
  pressStages: {},                     // keyed by button id — {} for profile 69
}

children, parentOf and depthOf are plain objects, not Maps, and must stay that way: routes/profiles.js JSON.stringifys parsed.groups into the parsed_summary column and every consumer reads it back with JSON.parse, and a Map serialises to {}. A node carries the same facts individually, so the round trip loses nothing.

A node is the mapping object itself — the identical object that appears in buttonMappings / tiltMappings — so it is summarizeMapping's shape plus:

{
  index, rawIndex,               // both === the raw index
  rawPredecessorId,              // the file's untouched PredecessorId (65535 → null)
  predecessorId,                 // normalized RAW parent, or null
  parentIndex, depth, isRoot, children /*raw indices*/,
  button, buttonName, trigger,   // one line per trigger, e.g. "Left Button Press"
  pressStage                     // 'light' | 'deep' | null
}

pressStages is one button's light-press/deep-zone stack, keyed by button id. That structure is a depth axis, not a gate, so it is the one case where a PredecessorId link is not described as "while held" — calling a deep zone "held while Bottom Thumb held" would be nonsense. nesting.singleButtonStackMembers() is the single definition of it, shared by the parser and the Builder.

A tilt trigger has no Button key at all. Any code reaching for InputActionType.Button.Id without a guard crashes on one.

A worked tree — profile 69

Profile 69 (nesting_testing.spcf) is the reference: 9 mappings, 1 root, 2 branch points, max depth 5 edges, output actions on 1 of 9, every ActivationOutputName empty.

parent→children:  0→[1]   1→[2,4]   2→[3]   4→[5]   5→[6,7]   7→[8]
roots: [0]
depth:           0=0 1=1 2=2 3=3 4=2 5=3 6=4 7=4 8=5
triggers: 0=left(buttonToggle) 1=right 2=thumbBottom 3=triggerRightPush 4=triggerRightPush
         5=imuAngleRegionToggle axis=roll Direction=both region 0.0-178.8° (NO Button key!)
         6=thumbBottom 7=edgeRear 8=triggerLeftPush
only output: idx 0 → mouseButton ButtonId 0 downOn (activation) + upOff (deactivation)

Drawn from nesting.roots / nesting.children with each node's trigger and depth, all 9 visible and both branches — 4 spaces per edge, trailing #n is the raw index:

#0 Left Button Press
#1     └ Right Button Press
#2         └ Bottom Thumb Press
#3             └ Right Trigger Push
#4         └ Right Trigger Push
#5             └ [tilt region on roll]
#6                 └ Bottom Thumb Press
#7                 └ Rear Edge Press
#8                     └ Left Trigger Push

#4 is depth 2 — a child of #1 and a sibling of #2, not a sibling of Right Button Press — and it is the branch that nests the tilt.

The button names are describeTrigger() output. The tilt node is written as its trigger type on purpose: describeTrigger() in lib/spcfParser.js named a tilt gesture from the region edge nearest level and never read Direction, so every imuAngleRegionToggle in the store — all 175 of them, each stored Direction: "both" — came out one-sided. That defect was found during the nesting rework and is fixed in lib/spcfParser.js describeTrigger(); what the corrected label reads is a property of that fix, so it is named by its trigger type here rather than guessed.

Two pairs of mappings with identical trigger strings — raw 2 and raw 6 (Bottom Thumb Press), and raw 3 and raw 4 (Right Trigger Push) — sit on different branches. Keying by trigger collapses them; the model is keyed by raw index. Every renderer on the site walks nesting.roots / nesting.children / node.trigger / node.depth and keys by index.

What is not in the format

There is no per-nesting gate/sequence switch, and offering one would be dishonest: no field the device reads would distinguish them. The one place the old code invented a distinction — the parser refusing to "treat" a chain as a sequence when every later step was a scroll — was a correct reading of the wrong model. Hold Top Thumb + scroll is a gate, and so is every other link; the gated scroll just happened to be the case where the old docs contradicted themselves. A scroll is still filed under the trigger that gates it and still labelled while <trigger> is held, because that is the only accurate description available.

Loops

Inside one binding's activation list, gotoOutputAction jumps to an index in that same list:

{
    "$type": "ZMouseConfig.OutputActionItems.GotoOutputActionOutputActionItem, ZMouseConfig",
    "TypeId": "gotoOutputAction",
    "ItemType": "outputAction",
    "IsValid": true,
    "DataType": "trigger",
    "Index": 0,
    "LoopCount": 0
}
  • LoopCount: 0 plus a stopLooping in the deactivation list means loops while held. The parser marks the goto stopOnRelease: true and the site renders Jump to action #0 (loops while held).
  • LoopCount: 0 with no stopLooping is a genuine endless loop. The Checker raises Potential endless loop. A jump-to-action repeats forever with no stop-on-release, so it can fire inputs non-stop until you switch profiles away.
  • LoopCount: n > 0 repeats a fixed number of times: Jump to action #0 (x3).

stopLooping.SkipWaitActions additionally cancels pending wait actions when the loop is killed.

A gotoOutputAction is a loop inside one binding's action list. It is not the same thing as PredecessorId, which links one binding's trigger to another's, and the two never interact: the loop runs while its own trigger is engaged, and a nested binding runs while its parent's trigger is held.

Device settings

Settings are not a separate section of the file. They are an ordered list of output actions on a systemEvent mapping whose Code is profileChanged — the only way the hub writes them and the only way the parser reads them (extractProfileSettings() takes the first systemEvent mapping in the group).

The Builder's Settings panel rewrites that list in this fixed order, then appends anything it did not recognise:

dpiConfigure, reportInterval, liftOffSelect, gameDeviceSelect, ledSet, oledSet, autoReleaseInputs, tiltAutoZeroing

Non-setting items already on the node survive the rewrite (a keyboardKey that fires on load, for example) but end up after the eight, because the panel assigns make.concat(others).

The blank profile the Builder starts from pre-fills these defaults (newSettingsMapping()):

Setting Default
DPI levels one level, 800 x 800
Report rate 1000 Hz
Lift-off 2 mm
Game device none
LED off (brightness 100)
OLED profile
Auto-release inputs enabled
Tilt auto-zeroing enabled

Two notes on the panel: it exposes LED mode and brightness only — Apply settings writes Duration10ms: 500 every time — and the settings DPI rows have no per-row remove button (the composer's level widget does).

Encoding details

Concern Encoding
Percentages and degrees Stored in tenths: lvlTenths(v) = max(0, round(v * 10)), so 35 % is 350 and 178.5° is 1785, and 0.1-unit precision round-trips exactly.
Region field names EntryStart + Percentage/Degree + Tenths; the same four names for both forceRegionToggle and imuAngleRegionToggle.
Contract fields $type, TypeId, ItemType, IsValid and DataType are DataContract fields, not application data. Every factory stamps them.
DataType default trigger for almost everything.
DataType exceptions oneRangeAndRangeInstance for joystickStick and imuAngleRange; twoCount for mousePointer and surfaceCombined.
OLED nodes oledSet and oledFlash share $type ...OutputActionItems.OledSetOutputActionItem — this is what the driver writes — and are told apart by TypeId.
Missing fields A region value the writer was not given is written as undefined, which JSON.stringify then omits, so a partial region simply has fewer keys.

One normalisation the writer performs silently: mouseScroll.DataType is always stamped trigger, while some stored profiles carry oneCount for that kind. Levels are likewise written as { XDpiRaw, YDpiRaw, isValid }, while some stored profiles use { IsValid, XDpi, YDpi }.

OLED bitmaps

The Z3 display is 64×32 monochrome. The Image field is base64 over exactly 256 bytes, row-major, 8 horizontal pixels packed per byte, most-significant bit = leftmost pixel.

  • packOled(pixels) takes a Uint8Array(2048) of 0/1 (x = fast axis) and returns the base64 string.
  • unpackOled(b64) returns a Uint8Array(2048) of 0/1, or null unless the decoded length is exactly 256 bytes. That is the guard that stops a malformed or empty image from ever reaching a canvas.
  • The parser's decodeOledImage() applies the same length check before it attaches an image to oledSet/oledFlash output descriptions.

Because the target is 1-bit, a pasted PNG is thresholded on luma:

buf[p] = (d[p * 4] * 0.299 + d[p * 4 + 1] * 0.587 + d[p * 4 + 2] * 0.114) > 128 ? 1 : 0;

Writing

lib/spcfWriter.js (window.spcfWriter, UMD, served at /js/spcfWriter.js) builds the raw nodes the Control Panel writes. The whole body is wrapped in an IIFE so a classic <script> tag does not leak top-level consts into the global scope and collide with the parser bundle.

Two invariants hold across every factory:

  1. Unknown fields survive. newOutputAction(typeId, fields, base) and newInputAction(id, fields, base) start from Object.assign({}, base), so editing one field of an existing node keeps everything else the driver had in it — including fields this codebase has never seen.
  2. Contract fields are stamped. $type, TypeId, ItemType, IsValid and DataType are always (re)written.

newMapping() generates a fresh node:

Field Value
Id random decimal string, Math.floor(Math.random() * 0xffffff) + 1
Predecessor null — regenerated by applyNesting() once the tree is known
PredecessorId 65535
ActivationOutputActionItems / DeactivationOutputActionItems []
ActivationOutputName null
OutputActionSequenceDelay 0, and not exposed in the UI
ShouldBlockOnActivation / ShouldBlockOnDeactivation false
ActionMappingIndexToBlockOnActivation / ...Deactivation 65535

applyNesting(values)

The writer gained one function for nesting, and it is the answer to "the Predecessor field is a recursive copy — who keeps it correct?".

applyNesting(values) // => values, mutated in place

It resolves every PredecessorId from the array itself, breaks loops, then walks the mappings in dependency order so a parent's own links are already final before its children copy it:

  • PredecessorId is rewritten from the resolved parent link, so an out-of-range, self-referential or looping reference becomes 65535.
  • Predecessor becomes a deep copy of the parent for a follower, and null for a root.
  • Every other field is left exactly as it was. The deep copy is iterative, so a deep chain cannot overflow the stack.

It is idempotent — calling it twice produces byte-identical JSON — and it is verified a byte-level no-op on all 60 groups of all 48 stored profiles. It is deliberately not auto-called from serializeDoc() or fixGroupCounts(); the call sites decide, because a caller that has just rebuilt the array already knows the links and one that has not needs them regenerated.

fixGroupCounts(root) recomputes ItemCount on the root (child groups plus the root's own mappings) and on every group, recursively, from ActionMappings.$values.length, because the driver trusts that field while the parser derives it. It is called before every serialise.

serializeDoc(doc, header) // => (header || 'json,2,4') + '\n' + JSON.stringify(doc, null, 4)

Round-trip guarantees

The format has to stay still, and the guarantees are mechanical rather than aspirational:

  • Opening preserves the file. The header and the document are kept; the profile title survives in profileMeta and, when absent, falls back to the root group name.
  • Downloading self-checks. buildSpcfText() fixes the counts, serialises, and then re-parses the result with parseSpcf. If the parser throws, the download is refused with that message instead of producing a file the driver would reject.
  • Values outside the option lists still render. A select in the action composer injects a "<value> (custom)" option when the stored value is not in the list; the Settings lift-off select prepends "<value> mm (non-standard)".
  • Merging is a first-class path. The Checker's sub-profile import deep-copies the destination, normalizeGroup()s every imported group (ItemType, a name, Groups: [], a well-formed ActionMappings), runs fixGroupCounts(), and re-serialises with the destination's header.
  • Non-official lift-off values round-trip 1:1. The UI offers 0.7 / 1 / 2 mm; a stored 3–8 mm profile parses and re-writes unchanged.
  • applyNesting() is a no-op on the real corpus. It is run over the ActionMappings.$values of every group of every stored profile and the bytes must not change. That is the proof that the recursive Predecessor copies the writer regenerates are exactly the ones the Control Panel wrote.

test/nesting-tree.test.js (15 tests) and test/parser-nesting.test.js (13 tests) enforce the nesting model against the profile-69 reference graph:

Test file What it pins
nesting-tree.test.js the profile-69 graph verbatim; "traversal visits all 9" — the assertion that fails against the old first-follower walk; branching with non-contiguous children; all 25 (from, to) permutations of a 5-chain move; cycle / self-reference / dangling-parent guards; MAX_NEST_DEPTH at 20 and 21; applyNesting idempotence, and the byte-level no-op over all 60 groups
parser-nesting.test.js the same graph through parseSpcf; maxDepth 5; all 9 reachable; all 8 non-root parentOf links non-null — the regression for the index translation that nulled 3 of 4; 9-of-9 mapping coverage; a tilt trigger with no Button key; duplicate triggers staying distinct; a full branch traversal; a gated scroll as a plain child; press stages distinguished from nesting; both button-id spellings; survival of a JSON round trip

test/spcf-format.test.js enforces the field level:

Test What it pins
blank profile round-trips through the parser header, group count, group name, 800 DPI, 1000 Hz, none, off, tilt zero enabled
every writer output kind is parseable and describes its kind all 27 kinds build, parse and decode; joystick half-ranges survive
non-official lift-off values still round-trip 1:1 7 mm in, 7 mm out
input action kinds all parse all six user triggers through buttonMappings / tiltMappings
writer field templates match every kind in the real database every TypeId and Id in the store has a factory, and every stored node can be rebuilt from its own fields
OLED pack/unpack round-trips 256 bytes out, identical 2048 pixels back
editing an existing node preserves unknown fields the base merge
fixGroupCounts keeps ItemCount in sync root versus child counts
joystick stick ranges survive dead-zone trims such as 2–50 % and 50–100 %

test/profile-format.test.js enforces it against the real store: there are profiles to check (more than ten), every stored profile survives a write and re-parse unchanged, nothing writes out something the parser rejects (including a ^json,d+,d+ header check), every save path goes through buildSpcfText(), and the editor's state object has gained no field that is neither layout nor document.

One known fidelity bug. setState() in the writer is called with the field name 'key' for keyboardKey and keyboardMedia, which makes it read f.key instead of f.state / f.buttonState. The result is that a newly written key action stores the key itself in ButtonState:

{ "TypeId": "keyboardKey", "KeyCode": { "HidPageValue": 9 }, "ButtonState": 9 }

Stored profiles use tap / downOn / upOff there (963 keyboardKey nodes in the hub store, 193 of them tap), so a key action written or edited by the Builder round-trips the HID value instead of the state. mouseButton and joystickButton pass 'button' to the same helper and are unaffected. See Known gaps.

Worked example

One layer with a settings mapping, a deep-click zone, and two nested bindings. Produced by the writer with header json,2,4; condensed to two-space indentation, otherwise verbatim:

{
  "Version": 2,
  "RootProfile": {
    "$type": "ZMouseConfig.Items.UserConfigurationGroupItem, ZMouseConfig",
    "ItemType": "group",
    "ItemCount": 1,
    "Name": null,
    "ShortName": null,
    "Description": null,
    "Applications": [],
    "InfoProperties": [],
    "CreationDate": "0001-01-01T00:00:00+00:00",
    "CreationUser": null,
    "ModificationDate": "0001-01-01T00:00:00+00:00",
    "ModificationUser": null,
    "ActionMappings": {
      "$type": "ZMouseConfig.Items.UserConfigurationGroupItem+ActionMappingList, ZMouseConfig",
      "$values": []
    },
    "Groups": [
      {
        "$type": "ZMouseConfig.Items.UserConfigurationGroupItem, ZMouseConfig",
        "ItemType": "group",
        "ItemCount": 4,
        "Name": "CS2 layer",
        "ShortName": null,
        "Description": null,
        "Applications": [],
        "InfoProperties": [],
        "CreationDate": "0001-01-01T00:00:00+00:00",
        "CreationUser": null,
        "ModificationDate": "0001-01-01T00:00:00+00:00",
        "ModificationUser": null,
        "ActionMappings": {
          "$type": "ZMouseConfig.Items.UserConfigurationGroupItem+ActionMappingList, ZMouseConfig",
          "$values": [
            {
              "$type": "ZMouseConfig.Items.UserConfigurationMappingItem, ZMouseConfig",
              "ItemType": "actionMapping",
              "Id": "6131104",
              "Predecessor": null,
              "PredecessorId": 65535,
              "ActivationOutputActionItems": [
                {
                  "$type": "ZMouseConfig.OutputActionItems.DpiConfigureOutputActionItem, ZMouseConfig",
                  "TypeId": "dpiConfigure",
                  "ItemType": "outputAction",
                  "IsValid": true,
                  "DataType": "trigger",
                  "SeparateXAndY": false,
                  "DefaultLevelIndex": 0,
                  "Levels": [
                    { "XDpiRaw": 800, "YDpiRaw": 800, "isValid": true },
                    { "XDpiRaw": 1600, "YDpiRaw": 1600, "isValid": true }
                  ]
                },
                {
                  "$type": "ZMouseConfig.OutputActionItems.ReportIntervalOutputActionItem, ZMouseConfig",
                  "TypeId": "reportInterval",
                  "ItemType": "outputAction",
                  "IsValid": true,
                  "DataType": "trigger",
                  "ReportInterval": "rI_1000_Hz"
                },
                {
                  "$type": "ZMouseConfig.OutputActionItems.LiftOffSelectOutputActionItem, ZMouseConfig",
                  "TypeId": "liftOffSelect",
                  "ItemType": "outputAction",
                  "IsValid": true,
                  "DataType": "trigger",
                  "LiftOffMm": 2
                },
                {
                  "$type": "ZMouseConfig.OutputActionItems.GameDeviceSelectOutputActionItem, ZMouseConfig",
                  "TypeId": "gameDeviceSelect",
                  "ItemType": "outputAction",
                  "IsValid": true,
                  "DataType": "trigger",
                  "GameDevice": "none"
                },
                {
                  "$type": "ZMouseConfig.OutputActionItems.LedSetOutputActionItem, ZMouseConfig",
                  "TypeId": "ledSet",
                  "ItemType": "outputAction",
                  "IsValid": true,
                  "DataType": "trigger",
                  "LedConfigurationParameters": { "Mode": "off", "Brightness": 100, "Duration10ms": 500 }
                },
                {
                  "$type": "ZMouseConfig.OutputActionItems.OledSetOutputActionItem, ZMouseConfig",
                  "TypeId": "oledSet",
                  "ItemType": "outputAction",
                  "IsValid": true,
                  "DataType": "trigger",
                  "OledConfigurationParameters": { "Mode": "profile" }
                },
                {
                  "$type": "ZMouseConfig.OutputActionItems.AutoReleaseInputsOutputActionItem, ZMouseConfig",
                  "TypeId": "autoReleaseInputs",
                  "ItemType": "outputAction",
                  "IsValid": true,
                  "DataType": "trigger",
                  "State": "enabled"
                },
                {
                  "$type": "ZMouseConfig.OutputActionItems.TiltAutoZeroingOutputActionItem, ZMouseConfig",
                  "TypeId": "tiltAutoZeroing",
                  "ItemType": "outputAction",
                  "IsValid": true,
                  "DataType": "trigger",
                  "State": "enabled"
                }
              ],
              "DeactivationOutputActionItems": [],
              "OutputActionSequenceDelay": 0,
              "ShouldBlockOnActivation": false,
              "ShouldBlockOnDeactivation": false,
              "ActionMappingIndexToBlockOnActivation": 65535,
              "ActionMappingIndexToBlockOnDeactivation": 65535,
              "InputActionType": {
                "$type": "ZMouseConfig.InputActionTypes.SystemEventInputActionType, ZMouseConfig",
                "Id": "systemEvent",
                "IsValid": true,
                "DataType": "trigger",
                "Code": "profileChanged"
              }
            },
            {
              "$type": "ZMouseConfig.Items.UserConfigurationMappingItem, ZMouseConfig",
              "ItemType": "actionMapping",
              "Id": "15349111",
              "Predecessor": null,
              "PredecessorId": 65535,
              "ActivationOutputActionItems": [
                {
                  "$type": "ZMouseConfig.OutputActionItems.MouseButtonOutputActionItem, ZMouseConfig",
                  "TypeId": "mouseButton",
                  "ItemType": "outputAction",
                  "IsValid": true,
                  "DataType": "trigger",
                  "ButtonId": 1,
                  "ButtonState": "downOn"
                }
              ],
              "DeactivationOutputActionItems": [
                {
                  "$type": "ZMouseConfig.OutputActionItems.MouseButtonOutputActionItem, ZMouseConfig",
                  "TypeId": "mouseButton",
                  "ItemType": "outputAction",
                  "IsValid": true,
                  "DataType": "trigger",
                  "ButtonId": 1,
                  "ButtonState": "upOff"
                }
              ],
              "ActivationOutputName": "ADS",
              "OutputActionSequenceDelay": 0,
              "ShouldBlockOnActivation": false,
              "ShouldBlockOnDeactivation": false,
              "ActionMappingIndexToBlockOnActivation": 65535,
              "ActionMappingIndexToBlockOnDeactivation": 65535,
              "InputActionType": {
                "$type": "ZMouseConfig.InputActionTypes.ForceRegionToggleInputActionType, ZMouseConfig",
                "Id": "forceRegionToggle",
                "IsValid": true,
                "DataType": "trigger",
                "ActiveState": "activeWhenOn",
                "Direction": "increasing",
                "Button": { "Id": "right", "KeyName": "MouseButtonRight", "IsValid": true },
                "Region": {
                  "IsValid": true,
                  "EntryStartPercentageTenths": 350,
                  "EntryEndPercentageTenths": 1000,
                  "ExitStartPercentageTenths": 350,
                  "ExitEndPercentageTenths": 1000
                }
              }
            },
            {
              "$type": "ZMouseConfig.Items.UserConfigurationMappingItem, ZMouseConfig",
              "ItemType": "actionMapping",
              "Id": "14162564",
              "Predecessor": null,
              "PredecessorId": 65535,
              "ActivationOutputActionItems": [
                {
                  "$type": "ZMouseConfig.OutputActionItems.MouseButtonOutputActionItem, ZMouseConfig",
                  "TypeId": "mouseButton",
                  "ItemType": "outputAction",
                  "IsValid": true,
                  "DataType": "trigger",
                  "ButtonId": 2,
                  "ButtonState": "tap"
                }
              ],
              "DeactivationOutputActionItems": [],
              "ActivationOutputName": "Ping",
              "OutputActionSequenceDelay": 0,
              "ShouldBlockOnActivation": false,
              "ShouldBlockOnDeactivation": false,
              "ActionMappingIndexToBlockOnActivation": 65535,
              "ActionMappingIndexToBlockOnDeactivation": 65535,
              "InputActionType": {
                "$type": "ZMouseConfig.InputActionTypes.ButtonToggleInputActionType, ZMouseConfig",
                "Id": "buttonToggle",
                "IsValid": true,
                "DataType": "trigger",
                "ActiveState": "activeWhenOn",
                "Button": { "Id": "thumbTop", "KeyName": "MouseButtonThumbTop", "IsValid": true }
              }
            },
            {
              "$type": "ZMouseConfig.Items.UserConfigurationMappingItem, ZMouseConfig",
              "ItemType": "actionMapping",
              "Id": "15611087",
              "Predecessor": null,
              "PredecessorId": 2,
              "ActivationOutputActionItems": [
                {
                  "$type": "ZMouseConfig.OutputActionItems.WaitOutputActionItem, ZMouseConfig",
                  "TypeId": "wait",
                  "ItemType": "outputAction",
                  "IsValid": true,
                  "DataType": "trigger",
                  "TimeToWaitms": 10
                },
                {
                  "$type": "ZMouseConfig.OutputActionItems.MouseButtonOutputActionItem, ZMouseConfig",
                  "TypeId": "mouseButton",
                  "ItemType": "outputAction",
                  "IsValid": true,
                  "DataType": "trigger",
                  "ButtonId": 2,
                  "ButtonState": "tap"
                }
              ],
              "DeactivationOutputActionItems": [],
              "ActivationOutputName": "Mark",
              "OutputActionSequenceDelay": 0,
              "ShouldBlockOnActivation": false,
              "ShouldBlockOnDeactivation": false,
              "ActionMappingIndexToBlockOnActivation": 65535,
              "ActionMappingIndexToBlockOnDeactivation": 65535,
              "InputActionType": {
                "$type": "ZMouseConfig.InputActionTypes.ButtonToggleInputActionType, ZMouseConfig",
                "Id": "buttonToggle",
                "IsValid": true,
                "DataType": "trigger",
                "ActiveState": "activeWhenOn",
                "Button": { "Id": "edgeRear", "KeyName": "MouseButtonEdgeRear", "IsValid": true }
              }
            }
          ]
        },
        "Groups": []
      }
    ]
  }
}

Two notes on the sample itself. It is what the writer's factories emit, i.e. before applyNesting() runs: mapping #3's "Predecessor": null is the factory default, and applyNesting() replaces it with a byte-identical recursive copy of mapping #2 (PredecessorId: 2). And OutputActionSequenceDelay is 0 on every mapping, because the UI does not expose it and the corpus never uses it.

What parseSpcf() makes of it — the shape the site renders:

{
    "formatHeader": "json,2,4",
    "version": 2,
    "rollup": { "tags": ["CS2 layer"], "totalButtons": 3, "minDpi": 800, "maxDpi": 1600 }
}
Device settings   DPI: 800 / 1600 · 1000 Hz · Lift-off 2 mm · Device mode: none
                  LED: off · OLED: profile · Auto-release: On · Tilt auto-zero: On

Right Button      Deep click Right Button 35–100%  -> Hold Right click
                                                    -> Release Right click   (label "ADS")

Held-button nesting
A mapping indented under another one is active only while the trigger above it is held.
Nothing here runs in order — there is no timing, no priority and no else-branch.

Top Thumb Press              1 nested mapping · 1 level deep
  └ Rear Edge Press   while held ⇡   [Wait 10ms · Mouse Middle]   Mark

Three things to read off that rendering. totalButtons is 3, not 1: it counts buttonMappings, which now contain the nesting children — before the rework a nested binding was invisible to the rollup, and it is counted here (profile 69 goes 4 → 8, the whole corpus 673 → 708). The nested step is drawn under its parent with while held ⇡ rather than as a numbered step with an arrow, because that is what it is. The Builder's own row text for the deep zone is Deep Right Button [35–100%] increasing with Mouse Right [down] / Mouse Right [up], because it looks ids up in BUTTON_BY_ID.

How the site reads a profile

parseSpcf() produces the parsed_summary that the database stores and the gallery, the profile page and the Checker all read.

Rollups (rollup, computed over every group including nested ones):

Field Derivation
tags every group name, in document order — used as the gallery's search tags, with the canonical game name appended
totalButtons sum of buttonMappings.length across all groups. Because buttonMappings now contains nesting children, a nested binding counts here where it previously did not
minDpi / maxDpi from dpiConfigure levels, plus any absolute dpiSelect whose SelectionType is select or absent, scanned over every mapping of each group via nesting.nodes. next / previous / increase / decrease values are driver defaults or steps, not levels, so they are deliberately ignored — otherwise every profile containing a "next level" action would report a bogus minimum

Per group:

Key Contents
name Name or (untitled)
itemCount the stored ItemCount (the real array length is used where it is missing)
applications the Applications array
settings the decoded profileChanged settings, plus onLoadOutputs when the event carries actions that are not settings
buttonMappings buttonToggle / forceRegionToggle bindings and scroll bindings that have a parent, nesting children included, each with button, buttonName, trigger, rawIndex, parentIndex and children
tiltMappings imuAngleRegionToggle / imuAngleRange bindings
nesting { count, roots, children, parentOf, depthOf, maxDepth, orphans, detached, cycleMembers, hasCycle, issues, order, nodes, pressStages }, all keyed by raw index — see One index space
childGroups nested groups, parsed recursively

settings.onLoadOutputs exists because a systemEvent mapping can carry more than settings: keys, media, haptics, a jump, or an OLED bitmap all fire the instant the profile loads. The parser skips the eight setting kinds (SETTING_OUTPUT_KINDS), but surfaces an OLED entry that carries a drawable image even when it is oledSet, because the site can render it.

Server-side upload validation (routes/profiles.js):

Check Rule
Transport multipart file, multer memory storage, 2 MB limit
Session requireUser — a Discord session
Title required after cleaning, 80 characters maximum
Per-user cap MAX_PROFILES_PER_USER (default 3, overridable by an admin setting); admins exempt; over the cap is 429
Parse parseSpcf() on the buffer; any parser message becomes a 400 with that exact text
Store format_header from the parser, raw_json = everything after the first newline, parsed_summary = JSON.stringify(parsed.groups), version = parsed.version
Rollups stored total_buttons, min_dpi, max_dpi, tags with the game appended

PUT api/profiles/:id runs the same parse and store path and is restricted to the uploader or an admin (403 otherwise). Because the download route re-joins the stored header and body, a profile uploaded through any of these paths comes back as a header plus a serializeDoc file.

parsed_summary is a cache, and lib/profile-groups.js does not trust it

parsed_summary is JSON.stringify(parseSpcf(file).groups) — a cache of the parser's shape, not the file. When nesting landed, that shape moved (groups gained a nesting key, and predecessorId became the raw index it had always been), so every one of the 48 rows stored at the time predates it. A consumer reading g.nesting off such a row gets undefined and draws a profile whose nesting is silently missing: the bindings look unconnected rather than wrong.

lib/profile-groups.js (groupsForRow(row)) is the fix, and both readers use it — routes/profiles.js (which re-exports it as router.groupsForRow) and lib/share-image.js:

  • If every group in the cached copy has a nesting block, use it.
  • Otherwise re-parse the stored file — format_header + \n + raw_json, the same bytes the download route hands back — and use that.
  • A body that cannot be parsed degrades to the row exactly as stored, so a malformed profile becomes an old-looking page rather than a 500.

The file is the truth, and parsing is deterministic, so a re-parse cannot disagree with it the way a duplicated column can. It is also cheap where it matters: only rows without nesting re-parse, and the whole corpus re-parses well under a millisecond per profile — a rounding error next to the SQL that just read the row. Rewriting the 48 existing rows would fix today's shape and leave the identical trap for the next parser change, on a live site, for no gain.

Related: Profile Builder · OLED Editor · Checker · Gallery and profiles · Walkthrough recorder · Markdown blocks · Known gaps