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
Groupnested inside another group'sGroups[]is a sub-profile — a whole switchable layer. A mapping nested under another mapping viaPredecessorIdis 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:
oledAnimationis a Builder-only composer kind with no$typeof its own. Committing it expands into oneoledFlashper frame (see the Profile Builder).dpiConfigureis 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.depthOfare keyed by raw index.nesting.nodes[i].index === nesting.nodes[i].rawIndex === i.- A mapping's
predecessorIdis its normalized raw parent (nullfor a root, a dangling link or a self-reference —nesting.issuessays which and why);rawPredecessorIdkeeps the file's untouched value. buttonMappingsandtiltMappingskeep 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: 0plus astopLoopingin the deactivation list means loops while held. The parser marks the gotostopOnRelease: trueand the site rendersJump to action #0 (loops while held).LoopCount: 0with nostopLoopingis a genuine endless loop. The Checker raisesPotential 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 > 0repeats 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 aUint8Array(2048)of 0/1 (x = fast axis) and returns the base64 string.unpackOled(b64)returns aUint8Array(2048)of 0/1, ornullunless 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 tooledSet/oledFlashoutput 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:
- Unknown fields survive.
newOutputAction(typeId, fields, base)andnewInputAction(id, fields, base)start fromObject.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. - Contract fields are stamped.
$type,TypeId,ItemType,IsValidandDataTypeare 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:
PredecessorIdis rewritten from the resolved parent link, so an out-of-range, self-referential or looping reference becomes65535.Predecessorbecomes a deep copy of the parent for a follower, andnullfor 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
profileMetaand, when absent, falls back to the root group name. - Downloading self-checks.
buildSpcfText()fixes the counts, serialises, and then re-parses the result withparseSpcf. 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
selectin 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-formedActionMappings), runsfixGroupCounts(), 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 theActionMappings.$valuesof every group of every stored profile and the bytes must not change. That is the proof that the recursivePredecessorcopies 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
nestingblock, 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