Checker — reading a `.spcf` in the browser
This file documents the clientside profile viewer at /check: how a file gets in, every section of the report it produces, how OLED frames and animations are dra
This file documents the client-side profile viewer at /check: how a file gets in, every section of the report it produces, how OLED frames and animations are drawn, the two safety warnings, and the sub-profile merge. Everything here was read out of public/check.html, public/js/check.js, public/css/check.css and the shared lib/spcfParser.js / lib/nesting.js / lib/spcfWriter.js they use.
What it is
/check is a .spcf reader, viewer and linter. Two files make up the page:
public/check.html
public/js/check.js
public/check.html loads, in order:
<script src="js/export-helpers.js"></script>
<script src="js/spcfParser.js"></script>
<script src="js/spcfWriter.js"></script>
<script src="js/check.js"></script>
<script src="js/notifications.js" defer></script>
<script src="js/chrome.js" defer></script>
The parser and writer are served from lib/ by routes/seo.js (GET /js/spcfParser.js, GET /js/spcfWriter.js), both as UMD bundles, so the browser and the Node side run the identical parsing and serialising code. The parser's nesting core, lib/nesting.js, rides along inside the /js/spcfParser.js response rather than as its own <script> — the page has not been changed to add one, and routes/seo.js sends the core first so self.nesting exists before the parser runs. check.js is one IIFE; the only three globals it touches are SPHubExportHelpers (badge text and the isDrawableOled predicate), spcfParser and spcfWriter.
Everything is client-side: there is no server round trip, and the file never leaves the device. The only outbound request anywhere in the tool is the Example button, which fetches a published profile:
GET api/profiles/8/download
check.js hard-codes that path in EXAMPLE_PROFILE_FETCH_URL with the comment that it loads the real "CS2" profile (id 8) through the same download route the site uses. It is a plain fetch() with no body and no options; the response is read as text and handed to the same handleText() a dropped file goes through. Nothing is uploaded, and check.js contains no POST call at all — test/legal-pages.test.js asserts both facts (EXAMPLE_PROFILE_FETCH_URL must still be there, and method: 'POST' must not appear) as the reason the privacy page is allowed to make the claim it makes.
/check is served as noindex,nofollow (both in the static markup and re-injected by the route), with WebApplication JSON-LD using applicationCategory: 'UtilityApplication', the title .spcf Profile Viewer — Swiftpoint Hub, and og:image pointing at /og-image/tool-check.png.
Getting a file in
Three ways, all landing in the same pipeline:
| Entry point | Id | Behaviour |
|---|---|---|
Open… in the header strip |
#ck-open |
Clicks the hidden #file input |
| The drop zone | #drop |
tabindex="0", role="button"; click, Enter, Space, or drag-and-drop with a .dragover state |
Example / Load the CS2 example profile |
#ck-demo (strip), #demo (empty state) |
Fetches api/profiles/8/download |
The file input is #file with accept=".spcf,.spc,text/plain,application/octet-stream". handleFile() reads it with FileReader.readAsText and calls handleText(text, file.name).
handleText() runs parser.parseSpcf(text) inside a try/catch; a thrown message is shown in #error and #results stays hidden. The error string is the parser's own, so a non-.spcf file produces messages such as File has no recognizable format (no header found)., File appears to be binary — a .spcf file must be text. or Profile contains no groups..
#ck-file in the strip reads No file loaded until something loads, then the file name. #drop-hint ("or click to choose one") is hidden automatically after a successful load, via a MutationObserver watching the hidden attribute on #results.
The same file also goes through parseRawDoc() — a second, deliberately looser parse that keeps the raw node tree (not the display-ready one) in destRaw. That is what makes the merge tool possible, and it is deliberately re-read fresh so repeated merges never mutate the original.
The report
renderGroup() is called once per top-level group and recurses. Each group becomes one article.group-card whose header carries:
- a
span.badge-layerreadingLayer; - an
h2.group-titleholding the path, not just the name: a nested group renders asParent › Child, with›as the separator; - a
span.group-sub.monowithN mapping(s)—itemCountfrom the file when present, otherwise buttons plus tilt mappings; - one
span.app-chipper entry in the group'sApplications.
Child groups are appended after their parent inside a nested .groups wrapper, so the nesting of the file is visible in the nesting of the page.
The body is assembled from six optional sections, in this order, each returning an empty string when the group has nothing to show.
1. Sends inputs on activation
Rendered by renderOnLoad() from group.settings.onLoadOutputs — the outputs of the group's systemEvent mappings, which is how the Z3 marks the actions that run the instant a profile becomes active.
Sends inputs on activation
These fire automatically when this layer becomes active — no button press required.
The non-OLED outputs become the same key-badge chips used everywhere else, and any drawable OLED item is rendered through oledBlockHtml(oledOuts, 'On activation'). The parser only puts a drawn image into this list for oled and oledFlash items; plain OLED mode and message entries stay a device setting, and device-configuration kinds (dpiConfigure, reportInterval, liftOff, gameDevice, led, oled, autoRelease, tiltAutoZeroing) are filtered out of the on-load list entirely by SETTING_OUTPUT_KINDS.
2. Device settings
renderSpecGrid() builds a .spec-grid of .spec-card label/value pairs under an h2 reading Device settings:
| Label | Source |
|---|---|
Base DPI |
The dpiConfigure levels, x alone when x === y, otherwise x×y |
Report rate |
N Hz from the rI_NNNN_Hz report interval |
Lift-off |
N mm |
Device mode |
The game device selection |
LED |
The LED mode string |
OLED |
The OLED mode string |
Auto-release |
On / Off (enabled / disabled) |
Tilt auto-zero |
On / Off |
Mappings |
nesting.count — the group's mapping count. buttonMappings and tiltMappings now contain the nesting children, so adding them together would count a nested mapping twice |
Launch with |
The group's Applications, comma-joined |
Cards with no data are omitted; if nothing at all is known the whole section disappears.
3. Button mappings
renderGroupMappings() groups the group's button mappings by button id and emits one article.map-card per button, under an h2 reading Button mappings. The card header has the friendly button name (h3.map-name) and a span.map-count with the number of rows.
buttonMappings now contains the nesting children, so a card can hold both a root and the bindings that only exist while it is held. A trigger with no Button key of its own — a gated scroll wheel, a tilt region — is filed under the trigger that gates it, resolved through m.parentIndex against the group's raw mapping list, never against a compacted display list.
Each row is a div.map-row, marked .deep when the trigger is a forceRegionToggle:
- the trigger — a
.trigspan with.trig-labelfromdescribeInput(m), plus chips:- a
start–end%depth chip whenever the input carries aregion(and is not a tilt region); - a
while <trigger> is heldchip whenever the mapping has a parent, resolved throughm.parentIndexback to its host's owntriggerstring. This used to fire only for gated scrolls and saidwhile <button> held; it now fires for every nested binding and names the host's trigger, because that is what actually gates it.
- a
- the outputs —
span.key-badgechips. When the activation list contains agoto, the whole row's badges get the extraloopclass, so a looping mapping looks different from an ordinary one. A mapping with no outputs shows a single—. - the label —
span.map-label, the profile's ownActivationOutputName. - OLED previews —
oledBlockHtml(oleds, 'OLED · <button>'). - on release — a
div.deactcontaining a.deact-capreadingon releasefollowed by the deactivation list's badges with thedimclass. Only rendered when the deactivation list is non-empty, which is what makes astopLoopingvisible where it matters.
A scroll mapping with no outputs and no OLED is skipped entirely, and so is a card that ends up with no rows.
4. Deep click · force stages
renderForceStages() emits, under an h2 reading Deep click · force stages and the subtitle Press depth from 0% (rest) to 100% (bottomed out). Press further to cross a force threshold., one div.force-card per button that has at least one deep stage.
Each card has:
- a
.force-headwith the button name andN deep stage(s); - a
.force-trackwith two bands:div.force-band.lightwhose width is the first threshold, anddiv.force-band.deepfilling the rest, labelleddeep clickwhen the deep band is at least 18% wide; - a
.force-scale.monorow of0%,25%,50%,75%,100%.
Then one .rung per stage, ordered from the lightest to the deepest:
| Rung | Tag | Range shown |
|---|---|---|
| Light press | Light press |
0–N%, emitted only when the first deep stage starts above 0% |
| Deep · 1, Deep · 2, … | Deep · n |
start–end%, or start%+ for the last stage |
Every rung carries a .rung-bar with a .rung-fill positioned and sized by its percentage span, the stage's output badges, the mapping labels, its OLED frames, and — for the mappings nested under it — a .deact line per follower reading while held ⇢ <Trigger> followed by that follower's own badges and OLED preview. The followers are looked up by m.parentIndex, a raw ActionMappings index keyed and read in the same space; only followers on a different control are shown, so a light-to-deep link on the same button stays one card instead of three cross-references.
5. Held-button nesting
renderNesting() emits one article.seq-card per nesting tree under an h2 reading Held-button nesting, with the subtitle:
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.
nestingRows(group) walks group.nesting.roots / children depth-first, one row per mapping with its depth. It carries a seen set as belt-and-braces: the tree the parser hands over already visits every index exactly once, but a renderer that can loop forever on a malformed summary is not worth the risk. Anything the roots do not reach — a detached cycle, an unreachable node — is emitted at depth 0 rather than dropped, because a mapping visible in the raw JSON must not vanish from the report.
Rows are indented by 16 + depth * 24 px and carry a └ elbow at depth > 0. Depth is drawn as indentation, never as a step number: a 3 of 7 badge would claim an order the format does not have. Each row shows node.trigger, the gate pill while held ⇡ on every child, the output badges (with the loop class when the activation list holds a goto), the label, any OLED frames, and the on release badges.
The card header names the root's trigger and counts the run: N nested mapping(s) · D level(s) deep.
Two trees are deliberately not drawn here:
- a root with no children — it is an ordinary binding and already has a card in section 3;
- a run made entirely of press stages — one button's light press plus its own deep zones is a depth axis, not a gate, and section 4 already draws it on a force scale.
isPressStack()is that test.
A broken link is a finding, and this page is a checker, so it is reported even when no tree is left to draw: nesting.issues renders a ⚠ Broken nesting link(s). alert naming each message and stating that the mapping is shown at the top level because there is no usable parent. nesting.issues is [] on all 48 stored profiles.
What this replaced: renderSequences(), which drew a numbered step list called Macro sequences, and the parser's two classification exceptions. A chain that stays on one button was excluded as a deep-click stack and a chain whose later steps were all scrolls was excluded as a modifier combo — the first is now section 4 by construction and the second is an ordinary child. There is no case left where a nested binding is not shown.
6. Roll · Tilt · Pivot
renderTilt() emits one div.tilt-card per tilt mapping under an h2 reading Roll · Tilt · Pivot, and skips any mapping with no outputs at all.
Each card has a .tilt-title (the trigger text), a .tilt-gauge, and a .tilt-out with badges and OLED frames. The gauge is:
- a
.tilt-axisholding a small inline SVG chosen from the mapping's axis:pitch(vertical arrows up and down),roll(horizontal arrows left and right),yaw(a rotation arrow); - a
.tilt-trackon a 0–360° scale with a.tilt-fillpositioned and sized from the mapping's degrees, and a fixed.tilt-levelmarker at the midpoint.
For an imuAngleRegionToggle the fill spans entryStart to entryEnd. For an imuAngleRange it is drawn as the symmetric band around 180°: the gauge takes the larger of |start - 180| and |end - 180| across both ranges and spans 180 - d to 180 + d, which is how the driver expresses a range that fires on either side of upright.
OLED rendering
isDrawableOled(item) from export-helpers.js is the single test used throughout: an item is drawable when it carries a decoded image and its kind is oled or oledFlash.
drawOledCanvas() unpacks the base64 and writes a 64x32 ImageData with the same ON colour as the editor, rgb(124, 255, 227), and an off colour of rgb(6, 10, 14) — one shade off from the editor's rgb(5, 8, 10), since the two were written separately.
Each canvas is painted once and marked with dataset.painted, so re-rendering the page does not redraw every frame. For an oledFlash item, oledSuffix() appends the frame's real duration and blocking mode to the caption, for example OLED · 1/3 · 0.25s wait.
oledBlockHtml() handles the animation. With one frame it is just a still. With two or more it builds a .oled-block containing:
- a playable
.oled-anim-canvasplus a play/pause button whose icon flips between▶and❚❚, a.oled-anim-countreadingi/N, and a.oled-anim-timereadingelapsed / totalin seconds; - a caption
label · animation · N frames; - and, underneath, a still of every frame in
.oled-frames, each captionedlabel · i/N, so the animation is never the only way to see a frame.
Playback timing comes from the file, not from an assumption. Each frame's Duration10ms is passed through, falling back to 100 when it is absent or not greater than zero, multiplied by 10 for milliseconds, and capped by MAX_DUR = 5000 so one absurd duration in a file cannot freeze the tab. The total shown in the caption sums the same defaulted values.
One inconsistency is worth recording: the elapsed-time accumulator adds durs10[cur] but falls back to 5, not 100, when a duration is missing or zero. A file of oledSet items with no Duration10ms therefore shows a total computed at 100 per frame while the counter advances at 5 per frame, and the elapsed number reaches the total before the last frame. It is a display inconsistency only; the frame timing itself is driven by the same defaulted value.
Warnings
There are exactly two, rendered by renderWarnings() into #warnings, both of which walk the whole group tree recursively including the on-load outputs.
⚡ Sends inputs on activation. — an alert alert-warn with role="note". It fires when any group's settings.onLoadOutputs contains an item of kind key. The full text:
Sends inputs on activation. This profile fires keyboard keys automatically the
moment it loads in the Swiftpoint software — before any button press. Only
activate profiles from authors you trust.
What it protects the reader from is the one thing a .spcf file can do that a screenshot of its bindings cannot show: the profile is armed the instant it is selected. Everything else in the report is inert until a button is pressed; this one is not, and the key could be anything.
⚠ Potential endless loop. — an alert alert-danger with role="alert". It fires when any activation list — of any mapping in the group, or the on-load list — contains a goto with loopCount === 0 and no stopOnRelease. The full text:
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.
stopOnRelease is not read out of the file: the parser adds it when the mapping's deactivation list contains a stopLooping item. That is the difference between a benign "loop while held" — which the OLED Editor's own injection produces, and which is safe because it stops when you let go — and a loop with nothing to end it. See The .spcf format for what gotoOutputAction and stopLooping actually do in the driver.
Summary chips
renderSummary() builds #summary from the parse rollup, in this fixed order, each omitting itself when it has nothing to say:
| Chip | Source |
|---|---|
| The file name | The Content-Disposition filename for the example, file.name otherwise |
N group(s) |
Top-level groups only |
N button binding(s) |
rollup.totalButtons, accumulated over every group including children |
DPI min–max |
rollup.minDpi/maxDpi, shown only when a DPI value was found; only absolute dpiSelect values and dpiConfigure levels count |
v<Version> |
The document's Version field |
| The format word | The first comma-separated word of the header line — json for json,2,4 |
The tool strip after a report
The header's .tbar on /check has no .tbar-start tool row of its own. Before a report there are only Open…, Example and the file name. render() un-hides #ck-tools once there is something to report on, revealing four controls:
| Control | Id | Behaviour |
|---|---|---|
Sub-profile import |
#ck-merge |
Scrolls #merge into view and focuses #merge-drop |
Index |
#ck-chapters |
aria-pressed; toggles #chapters.hidden |
Raw JSON |
#ck-raw |
aria-pressed; toggles #raw-box.open |
Open in Builder |
a ghost .tbar-btn |
A plain link to ./editor |
#ck-chapters ships aria-pressed="true" in the markup, but renderChapters() runs after every render and sets it to "false" together with hiding the list — so the button and the list start out in sync, and the button is what reveals the list. #ck-raw is bidirectional: clicking it opens the <details>, and the <details>' own toggle event writes aria-pressed back, so the two never disagree.
Index and chapters
renderChapters() builds #chapters from a fixed list of five anchors:
| Label | href |
|---|---|
| Overview | #ch-overview |
| Bindings | #ch-bindings |
| Deep click | #ch-force |
| Nesting | #ch-nesting |
| Tilt / Pivot | #ch-tilt |
It always finishes by hiding the list and setting aria-pressed="false", so the Index is off by default on every load. The Index button is the only thing that reveals it.
Those five anchor targets do not exist. No element rendered by renderGroup(), renderSpecGrid(), renderGroupMappings(), renderForceStages(), renderNesting() or renderTilt() carries id="ch-overview" or any of the other four, and a search of public/ finds the strings only inside renderChapters() itself. The links are therefore currently inert — they jump nowhere rather than to the wrong section. That is what the code does today; it is recorded here rather than papered over.
Sub-profile import (merge)
This is the Checker's most substantive feature and the reason the tool exists alongside a plain viewer.
Why it exists. The official X1 Control Panel cannot import a .spcf as a nested sub-profile of an existing profile. The Checker can, and it says so in the intro copy and in the panel copy: X1 can't nest an imported profile under an existing one — here you can. The loaded file is the destination; a second file is the source whose top-level groups become sub-profiles of a chosen group inside the destination.
The destination's raw node tree is kept in destRaw, its header line in destHeader, and its name in destName. #merge (and the #ck-merge jump button) stay hidden until a destination exists.
Loading the source
#merge-drop / #merge-file load the source with the same accept list and the same click / Enter / Space / drag-and-drop handling as the main drop zone. readMergeSource() parses it with parseRawDoc() — header split, NUL check, JSON.parse, require RootProfile.Groups — and reports:
<filename> — N top-level profile(s) ready to import.
into #merge-status. A source with no groups throws The imported profile contains no groups. into #merge-error.
Choosing what and where
| Control | Id | Contents |
|---|---|---|
| What to import | #merge-source-groups |
All N top-level profile(s), then one option per top-level group labelled <name> — N mapping(s) |
| Where to put it | #merge-dest |
Every group of the destination, indented by nesting depth, labelled <name> — N mapping(s), N sub-profile(s); plus Profile root (as a top-level profile) |
| Position | name="merge-pos" |
append as last (default) or insert as first |
The destination select's values are JSON index paths, not names: root for the profile root, "0" for the first top-level group, "2/0" for the first child of the third top-level group. mergeDestArray() walks that path and returns the Groups array it names, creating the array if the node has none. Indentation is one · per depth level starting at 1 for top-level groups.
The live preview
#merge-preview restates the choice as a sentence on every change to any of the three controls:
Will add “X” under Y.
Will add 3 profiles under Y.
Will add “X” under Profile root (top level).
That file contains no importable groups.
#merge-build ("Download merged .spcf") is disabled until a source with at least one importable group is chosen; updateMergeState() is what enables it, and it is the same function that clears it again. Choosing a new destination file resets the source selection, because a merge built against the old destination would mean nothing.
What the build does
buildMerged() is the whole of it:
JSON.parse(JSON.stringify(destRaw))— a fresh deep copy, so the in-memory destination is never mutated and two merges in a row are independent.mergeDestArray()resolves the chosen path to the targetGroupsarray.- The chosen source groups are deep-copied and passed through
normalizeGroup(), which deep-sanitises each one so the merged document still loads in the driver: it setsItemTypetogroup, supplies a name if one is missing, ensures aGroupsarray, and ensures anActionMappingsobject of the form{ $type: writer.ACTION_MAPPING_LIST_TYPE, $values: [] }. It recurses into children. unshiftfor "insert as first" orpushfor "append as last".writer.fixGroupCounts(root)recomputes every group'sItemCountto match itsActionMappings.$valueslength, and the root's togroups.length + its own mapping count. The driver trusts that field.writer.serializeDoc(dest, destHeader)re-emitsheader + '\n' + JSON.stringify(doc, null, 4)— the destination's header, not the source's.
Filenames come from filenameForMerge(): the destination name is stripped of .spcf, sanitised to [a-z0-9_-], and suffixed _merged.spcf when the target was the profile root, or _with-subprofiles.spcf for a nested merge. On success #merge-status becomes Merged and downloaded as <name>.
This rewrites a file. The output is not the destination byte-for-byte with something appended; it is a re-serialised document. It is also not re-parsed after the merge, though it is written by the same kind of parser that would read it back.
Raw JSON
<details class="raw-box" id="raw-box"> with the summary Raw JSON of this file and a read-only <pre id="raw-json"> inside. render() fills it by taking everything after the first newline of the file and JSON.stringify(JSON.parse(...), null, 2) — two-space indent, unlike the four-space indent the writer uses. If that fails it falls back to the raw text rather than showing nothing. There is no editing, no copy button and no input: it is a view of exactly what the file contained.
What the Checker deliberately does not do
- It does not upload. No
POST, no upload endpoint, no telemetry carrying the file. - It does not rate, score, moderate or comment. It reports what a file does and nothing about whether that is good. The two warnings are a safety notice, not a judgement.
- Its two warnings are the entire security review. There is no third check — no nesting-depth limit report, no cross-profile reference resolution, no verification that the mappings do what their names suggest. What the file says is what it is shown as doing, and the reader is expected to notice the two cases where that is not what they want.
- It does not import or modify the destination. The merge produces a new download; the loaded file is only ever read.
- It is
noindex,nofollow, carriesWebApplicationJSON-LD withapplicationCategory: 'UtilityApplication', and its social card is/og-image/tool-check.png.
How this tool is tested
| Test file | What it protects here |
|---|---|
test/dom-contract.test.js |
Every getElementById a page script performs must exist in that page's markup. It lists { html: 'check.html', scripts: ['js/check.js'] } and { html: 'oled.html', scripts: ['oled/js/oled-editor.js'] }, and counts an id as accounted for if the page's own script builds it. |
test/spcf-format.test.js |
The parser and writer round-trip: oledSet and oledFlash descriptions (image, duration10ms, options) survive a parse/serialise cycle, as do the export-helpers badge functions the Checker uses. |
test/parser-nesting.test.js |
The tree this page renders: the profile-69 graph verbatim, every index reachable, a tilt trigger with no Button key, and a gated scroll as a plain child. |
test/profile-format.test.js |
The same guarantee against real profile files in the database rather than synthetic ones. |
test/ui-language.test.js |
No client-facing string in public/js/ or public/oled/js/ is German, by a blunt word scan inside quoted strings with comment lines skipped. |
test/legal-pages.test.js |
The privacy page's local-first claim matches the code: check.js must still contain EXAMPLE_PROFILE_FETCH_URL and must not contain method: 'POST', and the privacy page must state that it does not upload. |
test/social-card.test.js |
GET /oled and GET /check inject their OG images (tool-oled, tool-check). |
test/ui-shape.test.js |
The OLED Editor's strip is still the two-row variant and its six export buttons kept their handlers when they moved into the menu. |
See Testing for the suite as a whole, and Security and privacy for what the Checker is the evidence for.