Folder sync and collection tracking for Pixl.js / AmiiboLink-compatible NFC emulator devices over Bluetooth LE.
Try it at allmiibo.mathieu.dev. No install, no account, nothing leaves the browser. Chrome or Edge to sync a device, any browser to browse the database and scan a folder.
The stock web tools (bt.allmiibo.com, pixl.amiibo.xyz) support only manual,
one-file-at-a-time transfers and hand-made folders. This project keeps a local
directory tree, subfolders and all, in sync with the device, with explicit
control over which side is authoritative, and tracks a collection against the
full amiibo database while it is at it.
Unofficial fan project. Not affiliated with, endorsed by, or connected to Nintendo, the Pixl.js / AmiiboLink projects, or any device vendor. It ships no Nintendo data, no amiibo dumps, and no cryptographic keys. It only moves and catalogues the files already present on a connected device. See Legal and licensing.
Currently 1.0.2. What changed, and when: CHANGELOG.md.
No CDNs, no build step, no telemetry. Everything the tool needs lives in this repository and runs on browser and Node built-ins alone. The BLE protocol is documented in PROTOCOL.md rather than being pulled from someone else's script at runtime.
The one dependency is linkedom, and it is a devDependency: it gives
npm test a real DOM so the pages can be executed rather than only read. The
site ships nothing, the admin service ships nothing, and only running the tests
needs an install.
The site people visit is static and has no server. It is a directory of files: no API call, no database, no runtime fetch of anything. That is unchanged, and it is the property the rest of this document assumes.
There is one exception, and it is not part of the site. server/ is a
private admin service for curating the amiibo database (see
Admin), which regenerates the site's data file when something is
edited. The site never talks to it, does not know it exists, and works exactly
the same whether it is running or not. It is optional, it is not deployed with
the site, and it too has no dependencies.
Everything below is verified on hardware: Pixl.js 2.11.2 and 2.16.0 (nRF52832, external flash); the protocol is unchanged across those releases, and 2.16.0 (January 2026) added on-device emulation for v3 amiibo.
- Protocol: reverse-engineered, cross-checked against the open-source firmware, verified on hardware. ✅
- Sync engine: five named operations, shared by two surfaces. A full 1914-operation replace of a 1049-dump library completed with zero failures. ✅
- The Collection: the app's home: browse all 947 amiibo, scan a local folder and a folder on the device, sync them, or transfer a hand-picked selection. ✅
- Interface: an 8-bit skin with three themes, an original pirate mascot in twelve colourways, and an Advanced toggle that keeps the expert layer out of the way until asked for. ✅
- Tests: 782 across thirty-three files; the protocol suite runs against a simulated device, the admin suite against a real HTTP server on an ephemeral port, the UI suites against a real DOM, the rest are pure. Nothing reaches the network. ✅
Four hardware findings shape the sync design:
- ~2 KB/s, and slower as the drive fills: 1.0 s per dump onto an empty drive, 2.5 s onto a full one. Transfer the minimum; long runs are resumable and every operation is logged.
removedeletes folders recursively, with no "not empty" guard. Files are deleted individually, and a folder the walk could not list is never a deletion candidate. A childless folder in a device listing means "never looked", not "empty".renamemoves between folders, so a relocated file need not be re-uploaded.- Devices vary by ~4× on identical operations. The same 160-byte read took 176 ms on one unit and 649 ms on an older one, so response deadlines measure silence rather than elapsed time: a big folder listing legitimately streams for a minute before it finishes.
All commands are collected in COMMANDS.md.
Requires Node (any recent version) and, for syncing, Chrome or Edge. Web
Bluetooth needs a secure context, so the page must be served over
http://localhost; opening it as a file:// URL will not work. Firefox and
Safari can browse the database and scan a folder read-only to track a
collection; Bluetooth and writable folder access are Chrome/Edge only.
npm run serve # or: node serve.mjs [port]Then open http://localhost:8080/. An 8-bit title screen leads into the Collection, which is the whole app. The in-app HOW TO page covers every feature from the user's side; this README covers the same ground from the repository's side.
The chosen folder's handle is remembered (IndexedDB), so the Collection reconnects it on the next visit; if the browser has let the permission lapse, the folder chip offers tap to reconnect instead of silently forgetting. Theme, mascot colour, view, sort, filters, operation choices, the device folder and the fan-made-cards toggle all persist across reloads and sessions.
Close any other tab connected to the device first: it accepts one BLE connection at a time.
The page renders the full database immediately: 947 amiibo across 31 series (946 database entries plus the curated Happy Home Designer card set), greyed out until a source says otherwise. Two sources, either or both:
- Your folder: the OS directory picker, then a scan. Owned amiibo light up; identity comes from the bytes, so filenames are irrelevant.
- Your device: connect over Bluetooth, pick the folder to read in a
browser-of-the-device dialog (default
E:/amiibo), and every file is read back and identified. That costs ~0.2 s per file, so a few minutes for a full library; it shows live progress and can be stopped part-way.
Both sources live in one SOURCES panel as identical rows, each a chip
with its controls (rescan, change folder, forget) and only its own facts
beneath: the folder shows its dump-file total, the device its amiibo count
and a bar for how much of the union is synced. One line at the panel's foot
gives the verdict: ALL SYNCED when the two sides are identical,
otherwise the synced percentage with what's left (15 to send · 5 to fetch). Below the panel sits the collection's completion, hero-sized:
owned out of total with a chunky bar, where owned counts both sources, so
an amiibo that lives only on the device still counts; when that happens the
stat carries an ONLY ON DEVICE sub-count. The first time a state reaches identical, an
old-game victory overlay plays once; the counts under each source double as
filter shortcuts.
The toolbar has search (press /), filter pills with live counts, three
sorts (release, name, completion), a CARDS / LIST toggle, and a ⋯
menu (COPY MISSING LIST, SCAN REPORT, EXPORT SCAN LOG for bug
reports (filenames, sizes and IDs as JSON, never file contents), and
expand/collapse all). Every
series row carries one tally pill: owned/total, a check when complete, a
bluetooth mark once a device is scanned, ▲/▼ deltas for what still needs to
move, and a sparkle count for dumps newer than the database. Each
amiibo opens a detail page with full-resolution artwork, your actual
filenames, a copy-able ID, prev/next navigation (arrow keys work), the rest
of its series, and its character variants. Scans are cached per tab, so
returning from a detail page is instant.
With both sources connected:
- SYNC fills the gaps in both directions: it downloads what only the device has, uploads what only the folder has, deletes nothing, and leaves the two sides identical. You review counts, sizes, a time estimate and the device's capacity before anything is written.
- SELECT turns the grid into a picker: choose amiibo, then SEND TO DEVICE or DOWNLOAD moves exactly those. Only amiibo missing on the receiving side are transferred.
Two products break the one-ID-one-amiibo rule, and each gets a tailored presentation instead of 90-odd duplicate rows:
- Kirby Air Riders vehicles. Every rider can be paired with four
machines (Warp Star, Winged Star, Shadow Star, Tank Star), and each pairing
is its own dump under the same ID. The collection cell shows a
n/4tally; the detail page shows the four machines as image cards, official renders fetched locally byfetch-images, greyed until you hold that pairing. - The fan-made Happy Home Designer cards. A community-made pack of 91
item-unlock cards for the 3DS game, not official Nintendo cards. All 91
carry one fabricated amiibo ID, so the collection shows them as a single
curated entry (with original card-stack pixel art and a
n/91pill), and the detail page lists every card with its item count and a taste of what it unlocks. Individual cards are recognised by their NFC UID against a small vendored index (web/data/hhd-cards.js).
The set is shown by default; collectors who track only official amiibo can switch it off in Settings → COLLECTION, which removes it from the list and every total (947 becomes 946), so an official-only collection can read complete. The switch is display-only: sync still treats the card files like any other files.
You only need this page if the Collection's SYNC is too polite. The Advanced toggle (header → Settings) adds it to the navigation; it walks three steps (connect, choose, review) and offers every operation:
| Operation | Direction | Deletes | Matches on | Visible |
|---|---|---|---|---|
| BACKUP | device → local | no | nothing, takes it all | always |
| SYNC | both | opt-in | path, against the last sync | always |
| MATCH | both | no | amiibo identity | Advanced |
| REPLACE | local → device | yes | path | Advanced |
| WIPE | device only | yes | nothing, takes it all | Advanced |
| ORGANISE DEVICE | device → itself | empty folders | amiibo identity | Advanced |
| ORGANISE FOLDER | folder → itself | empty folders | amiibo identity | Advanced |
| CHECK | read-only report | no | file content | Advanced |
| PACK FOLDER | local → one file | no | amiibo identity | Advanced |
| PACK DEVICE | device → one file | no | amiibo identity | Advanced |
ORGANISE renames and refiles one side into the layout the app would have chosen itself, which is also the cure for the failure mode that makes a device stop listing: several hundred files in one directory. It moves files, never copies or deletes them, and sweeps only the folders its own moves emptied. The device variant reads every file first, because a dump's identity is in its bytes and never in its filename, so budget roughly 0.4 s per file. Both are one-sided, so neither needs the other half connected.
Three things it will not do. A file it cannot identify stays exactly where it
is and is listed rather than guessed at. A second dump of an amiibo that
already claimed its name is reported as blocked, not silently suffixed. And
amiibolink/ and chameleon/ are never touched: those hold genuine 540-byte
dumps, so identification recognises them, and refiling them would break slot
emulation.
WIPE clears the device folder and writes nothing back. REPLACE also clears
it, but only as the first half of writing your folder over the top, so it needs
a folder and always writes one. WIPE is for wanting the device empty, or for
clearing a staging folder a repair left behind. It keeps key_retail.bin and
settings.bin (deleting the signing keys breaks the device rather than
emptying it), leaves any folder that would not list standing, and says so in
both cases before you confirm.
You can also delete chosen amiibo from the device: turn on SELECT on the Collection, pick them, then DELETE FROM DEVICE. Your folder is untouched. The confirm counts files, not amiibo, because one id is not one file: the 91 HHD item cards share a single fabricated id and Air Riders vehicles share one four ways, so selecting "one amiibo" can be ninety-one deletions. The dialog names the ones that expand.
If a repair has parked files in E:/r_, connecting on either page offers to
organise them, back them up, or delete them. E:/r_ is a sibling of the
device folder, so no ordinary scan would ever mention it. Choosing to delete
opens a second, explicit confirm, because the picker resolves on one click and
that must never be enough to erase a few hundred files.
Options live inside the operation they belong to and are remembered per operation:
-
BACKUP: Re-download files I already have and Include system files. A second run skips what it already holds: a file is skipped when it was downloaded before, the local copy still hashes to what was written, and the device file is still the same size. Every dump is 540 bytes, so a device-side edit that kept the size is the one case this cannot notice; the first option re-fetches regardless, and the plan says how many were skipped and why.
-
SYNC: Also delete (removals carry over) and Verify doubtful files (slow). It uses the record of the last sync to tell an edit from a deletion, and matches on path, so it works best when both sides are laid out the same way.
-
MATCH ignores folders and filenames and asks only whether each amiibo is on the other side. It is the right choice when the two layouts differ. Identity is the amiibo ID plus the vehicle for Air Riders, falling back to file content, so the 91 Happy Home Designer item cards (one shared ID) and the four vehicle pairings of an Air Riders character transfer individually. It reads every device file first, which takes a few minutes, and never deletes.
-
REPLACE really does replace. Everything under the device folder is deleted, then the whole local folder is written back: nothing skipped, nothing trusted. A mirror that skips files it believes correct is a different operation, and that belief cannot be checked: a device file of the right size and the wrong contents is indistinguishable without reading it back at 2 KB/s. If you want only the differences moved, use SYNC. It confirms first, with counts, in a hold-to-think dialog.
-
CHECK answers "is this amiibo on the device anywhere?" once files have been renamed or refiled: it reads every device file and matches on content, reporting what each side is missing, variants (the same amiibo where the device holds bytes you do not have. This is what catches Skylanders dark figures), files relocated under different names, and duplicates within each side. Validated against two copies of one collection filed completely differently: zero shared paths, yet every amiibo matched, and the only genuine differences surfaced as variants.
-
Loose
.binfiles can stand in for a folder: pick dumps directly, or a single all-in-one file. They are a read-only source, since a folder is also where downloads land and where.allmiibo-sync.jsonis written, so they can be browsed and pushed but not pulled into. Picking files replaces a connected folder. -
PACK FOLDER and PACK DEVICE write a library out as a single all-in-one
.bin(see below). They read one side and hand back a file, so neither has a plan to apply.PACK FOLDERneeds no device andPACK DEVICEneeds no folder, and the button is enabled accordingly.
Every operation is a dry run until you press APPLY. The review shows summary tiles, warnings in plain language, a capacity meter, and per-action file lists; deletions ask again. The RUN LOG drawer (Advanced) keeps the whole run. SAVE JSON exports the plan, the capacity figures, and every operation with its duration, outcome and, where the device refused, the command and status it returned. Useful when something fails 300 operations into a 48-minute push.
Settings → DEBUG TOOLS opens the device-internals page (the old
probe.html / write-test.html URLs redirect there):
- The probe is strictly read-only: firmware version, drive capacity, a full directory walk, optionally one file read back to confirm sizes are honest. It produces a JSON report, useful against the open questions at the end of PROTOCOL.md.
- The write test writes only inside
E:/_synctest, aborts if that folder is not empty, and cleans up after itself even when a check fails. - The repair tool is for a folder the device will not finish listing,
usually several hundred files in one directory on older hardware. Unlike the
other two it changes the device. It reads as far as the device manages, moves
those files into
E:/r_in batches of fifty under generated names, then lists again; each pass reaches further because the folder is shorter. Nothing is deleted and nothing leaves the device, so no local folder is needed. Erasing the folder afterwards is a separate, confirmed step, and the dialog is explicit that whatever the device could never read was never moved and goes with it. Takes a typed path, since the folder picker cannot navigate into a folder that will not open.
The skin is deliberate 8-bit: square corners, hard shadows, a vendored pixel
font for chrome and system stacks for data, steps()-eased micro-animations
that respect prefers-reduced-motion, and inline-SVG pixel icons. Three
switchable themes (console-shell grey, CRT indigo, near-black) and an
original pirate mascot in twelve colourways live under Settings; the
ADVANCED toggle there is the app's single progressive-disclosure axis:
default mode stays lean, Advanced reveals the expert operations and details.
Settings also holds the COLLECTION toggle for the fan-made HHD card set,
on by default and switched off by official-only collectors.
A guided tour runs once on a first visit to the Collection and to Advanced sync: a spotlight over one control at a time, ending on the HOW TO page. It is remembered as soon as it is closed, however it is closed (finished, skipped, Escape, or a click outside), and after that only replays on request from NEED HELP?, in the corner of the sources card on the Collection and at the end of the connect row on Advanced sync. Steps whose target is hidden are skipped rather than shown pointing at nothing, which matters because half the controls live behind Advanced and the review panels do not exist until a scan has run.
If you are curious how the look was chosen,
web/design-lab.html is the actual moodboard used to
pick the theme, the font pairing, and the pirate (from a line-up that at
various points included a snail, a viking that was supposed to be a pirate,
and a rice farmer that was also supposed to be a pirate). It ships in the
repo, works offline, and is best enjoyed by clicking every pirate.
The collection needs each dump to map to a catalogued amiibo, and a content hash cannot do that: the same amiibo dumped twice differs in UID and save data, so it hashes differently. Measured on one real library, 1035 dumps produced 1035 distinct SHA-256 hashes but only 943 distinct amiibo IDs: hashing would have treated re-scans of amiibos already owned as brand-new, unrecognised blobs. So identity is the amiibo ID inside each dump, with file content as the tiebreaker where one ID covers several products.
Three caveats, all real:
- The ID identifies a model, not always a distinct file. The clearest case is the fan-made Animal Crossing: Happy Home Designer item-card pack: 91 community-crafted cards (not official Nintendo products) that all carry one fabricated figure ID, so the individual cards are told apart by the 7-byte NFC UID inside each dump. Skylanders light/dark variants, and the four vehicle pairings of a Kirby Air Riders character, likewise share one ID. The tool falls back to vehicle bytes or file content to keep all of these distinct rather than collapsing them.
- Newer amiibo (Kirby Air Riders onward) are v3: 2048-byte NTAG I2C 2K
dumps whose ID ends in
03rather than02. They parse, and their vehicle is decoded from the tag's SRAM buffer (see PROTOCOL.md §10.6). - A dump can be recognised as an amiibo yet be newer than the database, so it has no name. Those are marked as new, named by their character where the character is already known from an earlier figure.
The database (web/data/amiibo-db.js, 946 entries across 31 series and
5 types) is generated by tools/build-amiibo-db.mjs from two public sources
and vendored; nothing is fetched at runtime:
- solosky/pixl.js
db_amiibo.c: the table the device itself uses. Its names are more specific than the alternative ([AC] 001 - Isabellerather thanIsabelle), which matters for a collection list. - 8bitDream/AmiiboAPI
amiibo.json(MIT): an actively maintained fork of N3evin/AmiiboAPI, providing amiibo-series and figure-type labels plus release dates. Chosen over the upstream because the fork adds the newest releases (Kirby Air Riders v3, Mario Wonder, Splatoon Raiders, Pragmata…) while remaining a verified strict superset (no upstream entries dropped or corrupted).
npm run update-db re-fetches both sources, regenerates the file, prints
every added, renamed or removed entry, so a bad upstream edit can't slip
into a commit unreviewed, and fetches artwork for anything new.
Upstream is not always right, and it is not always current. Corrections live in
content/amiibo-overrides.json, which the
generator merges on top of the two sources. It can correct any upstream field,
add amiibo upstream does not have yet, pin a filename or a device path, and
define categories and notes.
It exists because the database is generated: npm run update-db rewrites
web/data/amiibo-db.js from scratch, so an edit made directly to that file
survives exactly until the next refresh. The overlay is an input, so it does not.
The merge happens after the two sources combine and before anything is derived from the names. That ordering is the whole safety argument: a corrected or authored entry then goes through the same disambiguation, the same 47-byte name check and the same collision gate as an upstream one, with no special-casing anywhere downstream. A curated name cannot bypass the check that stops two amiibos landing on one device path.
Precedence, highest first:
| Output | 1 | 2 | 3 |
|---|---|---|---|
| the ID exists | overlay kind:"new" |
db_amiibo.c |
amiibo.json |
AMIIBO_NAMES |
overlay name |
firmware table | API |
AMIIBO_SERIES / _TYPES |
overlay label |
API | Series ${b} |
AMIIBO_RELEASE |
overlay release (null deletes) |
earliest regional | absent |
AMIIBO_SERIES_SHORT |
overlay short |
the committed token | minted |
AMIIBO_FILE_NAMES / _SHORT_NAMES |
overlay pin | derived | omitted |
| device path | a filename you chose on disk | overlay path |
the ladder above |
Six more generated tables carry it: AMIIBO_CATEGORIES, AMIIBO_PATHS,
AMIIBO_NOTES, AMIIBO_AUTHORED, AMIIBO_UPSTREAM (what an override
replaced, which is how update-db notices upstream moving underneath a
correction, since the generated file alone cannot show a value an override was
masking) and AMIIBO_EXCLUDED (an entry upstream has that this database
deliberately does not carry, kept by name rather than merely omitted so the next
update can offer it again). All are emitted whether or not anything is curated,
so importers need no fallback.
The overlay's excluded list is removed before anything is derived, so an
excluded amiibo takes part in no naming, no disambiguation and no collision
check: it simply is not there. Note that an excluded ID you own a dump of reads
as "not in the database" on the collection page, which is the cost of holding an
addition out.
One overlay key reaches no generated table at all: artwork, which records an
artwork change you declined, keyed by the blob hash you declined. It is not
database content, since the pictures are gitignored and the database says
nothing about them. It lives in the overlay because that is where decisions
live, and it is written in the same save as everything else, so one apply is
one restore point.
Upstream can only warn; the overlay author can fail. A routine refresh must
not break because a third party edited their repository, so an override for an ID
upstream dropped warns, and one upstream has caught up with is reported as
redundant. Authoring an ID that upstream now has is fatal: two sources claim to
name it and the tool cannot choose. So is an unknown key, because a mistyped
"catagories" that silently does nothing is the worst failure a curated file can
have.
A path cannot be pinned on an ID that stands for more than one physical dump. Kirby Air Riders characters have four vehicle pairings per ID and the 91 Happy Home Designer cards share a fabricated one, so a single pinned path would collapse them and keep the last.
Authored entries appear behind the same Settings switch as the fan-made card set. Neither is an official product, so with it off the headline completion figure stays comparable.
NEW AMIIBO in the admin creates one, for something upstream does not list yet. It asks for the 16-hex ID and a name, and decodes the ID as you type. The series and the type are what those bytes mean, and typing them blind is how an amiibo ends up filed under the wrong series. It refuses an ID upstream already has, one already in the overlay, and one whose series or type byte has no name, because the generator hard-fails on that and the error would otherwise arrive at save time talking about the build rather than about the ID.
A newly authored amiibo appears in the grid immediately, before it is saved:
buildCollection takes an extra map for entries a view knows about that the
published table does not. Without it the one screen whose job is creating them
would not show them until after publishing.
Deleting an authored entry says DELETE rather than REVERT, and asks. Reverting an override falls back to upstream; an authored entry has nothing to fall back to and simply ceases to exist.
A series is a byte in the amiibo ID, not a record: it exists because amiibo carry it. The cog on a series header opens its editor, where three things are curated.
The name. Replaces the upstream label everywhere, including in the search index, since a renamed series has to be findable by what it is now called.
The folder on the device. This is the expensive one. The token names a real
directory (E:/amiibo/SSB/), so changing it renames that folder on every
device already synced and the next sync moves every file inside. The field says
so as you type, in before-and-after device paths, and publishing a token change
asks separately from the ordinary save, with the paths and the file count on
the dialog. It is validated as one folder name, held to the same rules a
filename is.
The image. There is no series logo anywhere and no public dataset of one, so
the site picks a representative amiibo by name-matching a hardcoded table. The
editor lets that be chosen instead, from the amiibo in that series and only
those. A face from elsewhere would read as an artwork bug rather than a bad
pin. It is emitted as AMIIBO_SERIES_FACE and seriesRepresentative() prefers
it over the guess. Uploading bespoke artwork is a separate matter and waits on
the image pipeline.
NEW SERIES names a byte upstream has not named. That is the whole of "creating" one. Its purpose is to unblock authoring an amiibo into a series the database does not know yet, which the authoring form refuses until the byte has a name, and says so.
Sync normally moves files whose names you chose, and folder names are never
used for identity. But an amiibo can arrive without a home: as a member of an
all-in-one bundle, which has no filename at all, or as a loose .bin picked on
its own, which has a name but no folder. Then a path has to be worked out.
The two are not treated alike. A bundle member gets a name built from the
database. A picked file keeps the name you gave it and gains only a series
folder, so K+WarpStar.bin becomes Kirby Air Riders/K+WarpStar.bin; a name
too long for the device is the only case that falls back to a built one.
Air Riders needs one more thing. All four vehicles of a character share a single
amiibo ID, so a database-built name puts four real dumps on one path and keeps
the last. Where the app names one itself it adds the vehicle, shortened:
KAR/Kirby (Warp).bin. The full name does not fit, pushing the longest Air
Riders path to 72 bytes against a 63-byte limit. Bundles never hit this, since a
572-byte record cannot carry a vehicle at all. Where it
goes is decided at generation time rather than at sync time, in three extra
tables the generator emits:
| Table | Holds | Rows |
|---|---|---|
AMIIBO_SERIES_SHORT |
series byte → short folder token | 31 |
AMIIBO_FILE_NAMES |
ID → filename unique within its series | 21 |
AMIIBO_SHORT_NAMES |
ID → abbreviated filename | 46 |
The last two carry only the rows that differ from AMIIBO_NAMES, so 21 and 46
rather than 946 apiece.
Deciding it at build time is what makes it checkable. The generator exits
non-zero and writes nothing if any (series, filename) pair is not unique.
A silent fallback here would surface much later as two amiibos overwriting each
other on a device. test/db.test.mjs asserts the same invariants against the
committed file, since the header forbids hand edits but cannot prevent them.
Raw database names collide 13 times, and every clash has something real to name it by, so none of them need a bare counter:
| What differs | Rule | Example | Count |
|---|---|---|---|
| figure type | append the type | Luke (Card) |
3 |
| character variant | append the variant | Palico v2 |
2 |
| model number only | append the model | Terry 04e8 |
8 |
The eight model-only pairs are two printings of one Street Fighter 6 card that
differ in nothing else. The 91 Happy Home Designer cards share a single ID, so
they are filed by UID instead (AC/HHD 04ab17fc2e4080.bin).
AMIIBO_SERIES_SHORT holds initials (Mario Sports Superstars → MSS,
Street Fighter 6 → SF6), falling back to the full label where initials
would be useless or already taken. These are stable: a token already
committed is never re-derived, because changing one renames a folder on every
synced device and the next sync then moves everything inside it. The generator
reads what is already there and mints tokens only for new series, and
npm run update-db reports any change to one as loudly as it reports a removal.
Paths are then assembled by amiiboRelPath (in planner.js, next to
checkDestination), which shortens in steps until the path fits the device's
63-byte limit: full series label, then its initials, then the abbreviated name,
then the ID as a last resort. It measures against the real device root, because
how much shortening is needed depends on the root's depth. Measured over all 946
entries:
| Device root | Full label | Initials | Abbreviated | ID | Collisions |
|---|---|---|---|---|---|
E:/amiibo |
937 | 9 | 0 | 0 | 0 |
E:/amiibo/library |
878 | 68 | 0 | 0 | 0 |
E:/a/very/deep/nested/root |
577 | 357 | 6 | 6 | 0 |
The last two rungs are never reached in normal use and stay as a guard. The ID fallback replaced an earlier truncating version, which produced a collision at a deep root: two amiibos trimmed to the same prefix would have overwritten each other, and an ugly filename is much better than a silent loss.
Some tools distribute a whole amiibo library as a single file. Two such
containers are read; both are specified in full in
FORMATS.md.
- Flat 572, the one written as well as read, produced by
PACK FOLDERandPACK DEVICE. Reverse engineered, described below. - FCA, read only, to the published specification
by fishybow (MIT). A real archive with a header and length-prefixed typed
entries, and the better of the two where there is a choice: its type-2 entries
carry whole 2048-byte v3 dumps, so Kirby Air Riders vehicles survive, which
the flat format cannot manage. Detection needs the
FCAmagic bytes, entries that tile the file exactly, and at least one amiibo among them; a Skylanders archive is recognised as none of this app's business rather than torn apart. Verified against four real Flashiibo exports: 525, 417, 942 and 16 entries, the last being 4 characters × 4 vehicles as whole 2048-byte dumps. Those numbers are pinned intest/fca.test.mjs.
The flat container is as simple as it gets: a run of fixed-size records, no header, no index, no name table, no checksum, no version field.
record[0x000 .. 0x21B] 540 bytes NTAG215 image (pages 0..134)
record[0x21C .. 0x23B] 32 bytes 0xFF padding
572 bytes total, repeated to end of file
572 is already a size the firmware recognises (DUMP_SIZES calls it Thenaya),
so every record is an ordinary dump and parseAmiiboId reads its ID at byte 84
unchanged. Reverse engineered from two real bundles, 949 records in total:
- every record passes the NTAG structural checks (
0xA5magic, capability containerF1 10 FF EE, both UID check bytes); - the padding is 32 ×
0xFFin all 949, with no other variant; - a 943-record bundle was sorted ascending by amiibo ID from record 1 on, with one late arrival prepended out of order, so ordering is a convention rather than something to rely on;
- 942 of those 943 were 532-byte dumps zero-extended to 540 (password and PACK
zeroed, dynamic lock
0F BD). Harmless: 2044 of 2084 dumps in one real library have the same zeroed tail, and the firmware never reads it.
A bundle in your sync folder is detected during the normal scan, unpacked in memory (943 × 540 bytes is about 509 kB, so there is nothing to stream) and its amiibos planned individually. The container itself is excluded from every plan. Before this existed the planner treated it as an ordinary foreign file and would have pushed half a megabyte to a device that cannot read it, about four and a half minutes at 2 kB/s.
Detection is deliberately strict, because mistaking a real dump for a bundle would replace one amiibo with a phantom library: the length must divide evenly, there must be at least two records, every record must pass the structural checks, and at least 90% must name an amiibo in the database. Record sizes are not coprime (77,220 bytes divides by both 540 and 572), so when more than one reading fits, the one recognising more amiibos wins.
Only what is missing transfers. A record is dropped when you already hold
that amiibo locally, when the device already has it, when an earlier bundle in
the same folder offered it, or when it duplicates an earlier record, either
byte-identically or as a second tag of the same character. That check lives in
bundlesource.js rather than in the planner on purpose: planIdentitySync keys
identity on content hash as well as ID, deliberately, so that the 91 item cards
and the four vehicle pairings stay distinct. Bundle dumps carry freshly
generated UIDs (not one of 943 records matched any of 2084 local dumps
byte-for-byte), so left to the planner every member would read as a new item and
duplicate something already on the device.
Matching against the device by amiibo rather than by path requires MATCH, which reads every device file. A plain SYNC compares paths only, and the scan says which of the two it did rather than implying the check was thorough.
What a bundle cannot carry. A record holds 540 bytes. Kirby Air Riders amiibo are 2048-byte NTAG I2C 2K dumps whose vehicle lives at byte 979, well past the end of a record, so all four vehicle pairings for a character collapse into one vehicle-less entry. The bundles' Air Riders records are re-generated NTAG215 tags with UIDs unrelated to the real figures. A 2048-byte local dump is strictly better than any bundle copy of the same amiibo, which is why one is never dropped in favour of a bundle's version.
Artwork (npm run fetch-images) downloads official artwork from
AmiiboAPI into three tiers: 96 px thumbnails, 256 px for Retina-sharp lists,
and full-size for the detail page, plus the four Air Riders vehicle renders
from Nintendo's asset CDN. It is Nintendo's artwork, so no tier is
committed to the repository: everything is cached locally and deployed to
your own host. A fresh clone shows letter placeholders until you run
fetch-images; the page uses the sharpest tier present and never fetches
anything at runtime.
An optional private service for curating the database from a browser, without
git and without a deploy. It is not part of the site: it lives outside
web/, is not deployed by the site's deploy path, and nothing links to it.
you ──▶ the admin (Node) the site (Apache, static)
│ ▲
│ writes content/…json │
└─ regenerates ───────────────┘ web/data/amiibo-db.js
Saving writes the overlay, then regenerates the site's database, so an edit is
live immediately. Both writes are atomic, a temporary file renamed into place,
so a crash leaves the previous version intact rather than a truncated one, and a
visitor loading the site mid-save gets the old file or the new one, never half of
either. Every save keeps a timestamped copy of the previous overlay first, in
content/backups/ (gitignored: the overlay itself is committed, and git is the
history).
Regeneration calls the same generate() the command line calls, and is tried as
a dry run before anything is written. A save that would put two amiibos on one
device path is refused with the reason, and neither file is touched.
UPDATE in the admin bar fetches the two sources and shows what they would
change, before anything is published. npm run update-db -- --dry-run prints
the same report on the command line and writes nothing.
The model is Track Changes: every modification is individually ACCEPT or DECLINE, walked through in steps that run coarse to fine.
① SERIES ② TYPES ③ AMIIBO ④ ON YOUR DEVICE ⑤ CONFIRM
Steps with nothing in them are not shown, so the numbering counts what there is to look at. Within a step, sections are added · removed · changed, each with ACCEPT ALL / DECLINE ALL.
The unit is the entity, not the field. An amiibo arriving with a name, a series and a release date is one arrival and one row, with those values nested inside it. This matters more than it sounds: the previous version counted fields as peers, so the summary and the list of rows described different populations and none of the numbers agreed. The headline is now derived from the same array the steps render, which makes disagreeing impossible.
Update: 2 amiibo to add, 0 to change, 0 to remove · 1 series to add
Nothing you have curated is affected · No files move on your device
Named counts with the zeros left in, after Terraform's Plan: N to add, N to change, N to destroy. The second line is the only part that changes colour,
because it is the only part that says whether the update needs care.
What DECLINE does depends on what is being declined, and is always "not this time" rather than "never":
| ACCEPT | DECLINE | |
|---|---|---|
| a name, date, filename or folder token changed | upstream wins, writing nothing | pins the published value |
| an amiibo upstream dropped | drops your overlay entry for it | keeps it, as an authored entry |
| an amiibo upstream added | it is added | held out by excluded, and offered again next update |
Accepting is free: re-applying the overlay is what the build already does, so it
writes nothing at all. Only declining grows the overlay, and only by what is
needed to hold the line. A declined change also records upstreamWas, meaning
what upstream said at the moment the line was drawn, so a later update can tell
a pin still doing its job from one upstream has come round to. A record with no
pin beside it is a validation error, which is what makes dropping a pin
self-checking.
Some changes have no second answer, and say so instead of pretending. A brand-new series has no previous name to fall back to, and the generator refuses a series byte without one. So it is shown as a consequence of accepting the amiibo that need it, with no buttons. Declining every amiibo in a new series declines the series with them.
Nothing blocks APPLY. Anything left untouched is accepted, and the confirm
step says how many that is (3 left untouched, and will be accepted). The
previous version refused to proceed until every row had been clicked, including
rows whose only possible answer was yes. That is the failure NN/g describes as
"do not use confirmation dialogs for routine actions". Renames on the device
keep their own confirmation, with the actual paths
(E:/amiibo/SSB/Mario.bin), because that is the one consequence that reaches
hardware.
Artwork is part of the update and is reviewed the same way, as its own step:
UPDATE → [ DATA & PICTURES · DATA ONLY · PICTURES ONLY ]
① SERIES ② TYPES ③ AMIIBO ④ ARTWORK ⑤ ON YOUR DEVICE ⑥ CONFIRM
It had not been part of it at all. The image pass lived only in
npm run update-db, so an update applied through the admin added two amiibo and
silently left the site with 948 entries and 946 images, with nothing reporting
it because nothing looked.
UPDATE asks what to cover first: data, pictures, or both. The two move on different schedules upstream, so each has to be reachable alone. That is a scope question, not a second feature, and whichever is chosen lands on the same screen with the same ACCEPT and DECLINE. Pictures alone fetch no sources at all, since the comparison is against the published database and one image index.
A picture is reviewed by looking at it, so an artwork row is a pair of pictures rather than a pair of hashes: what the site serves now, and what upstream would replace it with. An arrival shows the one picture there is, because an empty box beside it would be a broken image rather than a comparison.
Every picture is a question, arrivals included. An earlier version stated arrivals as a count and fetched them regardless, reasoning that there is no picture to keep and so nothing to decide. That was wrong: "I do not want that one" is a real answer, and deciding on the reviewer's behalf is the opposite of a review.
Noticing a change costs one request and no image bytes. GitHub's git trees
API returns the whole images/ directory with a blob hash per file, and that
hash is sha1("blob <len>\0" + bytes), which is computable from a local file.
So the comparison is a hash of what is on disk against a list that arrived in
one response:
GET /repos/8bitDream/AmiiboAPI/git/trees/dev:images
→ 948 entries · 946 identical · 2 missing · 0 changed
Candidates are then downloaded lazily, one per row, when you actually look at one. A review listing 300 changed pictures costs a single request until you start scrolling, and anything you looked at is already staged when you accept it. Nothing live is touched until then.
A refusal names the version refused, recorded in the overlay as
artwork: { "<id>": { "declined": "<blob hash>" } }. This is the one place the
artwork model deliberately differs from the data model. Declining an arrival
means "not this time" and comes back next update. Declining a change means "I
prefer the picture I have", and being asked about the same picture every month
is nagging, not review. Recording the specific hash is what stops that
from becoming "never": once upstream changes the picture again, the record no
longer matches and the new version is a new question. A declined arrival records
nothing at all, because there is no version of it here to hold on to.
A check that could not run says so. A rate-limited index and an unchanged image set look identical from the outside and mean opposite things, so a failure is reported as its own line rather than as an absence. Read the lenient way, a 403 makes every local picture look like a removal, and the screen would confidently propose deleting all 948. The data review is unaffected either way: GitHub throttling the image index is no reason to be unable to review a rename.
Applying fetches what you accepted, replaces what you accepted a new version of, and deletes what upstream dropped and you agreed to drop. It is bounded by the size of the update rather than the size of the database, so it stays inside one request. It cannot fail an update: the database is written and promoted before any picture is requested, and what happened comes back in the receipt.
A pictures-only update goes to its own endpoint and never touches the
database, since pictures are not generated from the sources and there is
nothing there for it to change. That case is why the scope question exists:
upstream
ships artwork on its own schedule, so an amiibo added in July can get its
picture in August with no data change to carry it. Grace Ashcroft and Leon S.
Kennedy were in exactly that state. A full sweep of an empty install is still a
job for npm run fetch-images, which is incremental and skips everything
already on disk.
A 404 is not an error. Upstream publishes pictures on its own schedule, so
an amiibo newer than the image set simply has none yet and gets one on a later
run. The receipt distinguishes that (1 have none upstream yet) from a real
failure.
The resized tiers need an image tool, and which one depends on the machine:
sips on macOS, ImageMagick on a Linux server. The first available of sips,
magick, convert is used, and when there is none the full-size images are
still fetched and the receipt says so plainly rather than leaving you to notice
missing thumbnails:
Artwork: 2 fetched. Tiers NOT generated: no image tool on this machine.
Run `npm run fetch-images` locally and redeploy to build them.
The generated database is an ES module, evaluated once per document and
cached by the browser. Applying rewrites that file on the server, but the page
still holds the version it loaded at boot, so re-fetching the API and redrawing
showed the old counts and the old rows, which is exactly what it did.
Nothing short of a new document picks up a new database. The receipt is parked
in sessionStorage and read back on the next boot, so the reload does not throw
away what just happened.
A refresh writes into tools/.cache/pending/ and never over the live pair. That
is the one structural decision here and it earns its keep three times. A bad
fetch cannot destroy a working cache. A generate() that fails afterwards needs
no rollback. And the one that actually matters: an ordinary save regenerates
from the live cache, so a refresh that overwrote it would make the next
unrelated save publish unreviewed upstream data.
Each source is refused unless it parses to at least 500 entries. That check protects the cache far more than atomicity does: GitHub answers 200 with an HTML error page more often than it 404s, and such a page would be written perfectly atomically.
The apply order is the safety story, and one part of it looks wrong until it is said out loud: the cache is promoted before the database is written. If the rebuild then fails, the cache is new and the database is old, and a retry produces the same reviewed result. The other order would leave the database ahead of the cache, so the next ordinary save would silently revert published data. Reverting is the worse failure.
Four gates, each covered by a test that fails when it is removed:
- Undecided. A change you did not look at cannot publish as if accepted.
- Fingerprint. The preview is stamped with what it was computed from. If a second tab saved, or someone re-fetched, apply is refused rather than applied to a world that moved.
- Post-decision build. The decisions are applied and the result built before anything is written. Two amiibo landing on one device path is refused with the reason. The only shape that reaches this gate is a rotation: upstream swaps two names in one series, which is self-consistent upstream, and keeping only half of it collides.
- Promote on apply, never on refresh.
The same comparison runs on the command line. npm run update-db prints the
identical groups from the same module (web/js/dbdiff.js),
because two reports of what upstream changed would drift, and the drift would be
invisible until one of them called a device-wide rename harmless.
The BACKUPS drawer lists those timestamped copies, newest first, and offers each one for download or restore. A restore is a save: it runs the same dry run, is refused the same way if the result would not build, and takes its own backup of what it replaces before writing, so restoring the wrong one is itself undoable. Both go through one function in the server for exactly that reason; two code paths to the same file is how one of them quietly loses a gate.
Restoring over unsaved edits asks about the edits first, separately, rather than burying "you will lose your work" inside "restore this backup?".
EXPORT downloads the live overlay. It fetches it rather than navigating to the URL: navigation bypasses the error handling, so an expired session used to save the 401 response body under the name of a backup. That is a corrupt file you would not discover until you needed it.
Only a file whose name is a backup stamp can be read from the backup directory.
The pattern is defined once, in server/store.mjs, and imported by the route;
two copies of a filename whitelist drifting apart is how the interesting bugs
get in.
Secrets live in the environment and never in the repository.
Hash a password once:
node -e "import('./server/auth.mjs').then(m=>console.log(m.hashPassword('your password')))"Then set these in the environment, wherever your host keeps them. They are
written out here as names only, deliberately: the leak guard in
test/admin.test.mjs fails on anything in the repository that looks like one of
these being assigned a value, and documentation is not worth an exception.
| Variable | Value |
|---|---|
ADMIN_PASSWORD_HASH |
the output of the command above |
SESSION_SECRET |
32 bytes of hex, e.g. from openssl rand -hex 32 |
PUBLIC_SITE_DIR |
where the site's files are served from |
DATA_DIR |
where the overlay and its backups live |
CACHE_DIR |
the fetched upstream sources |
PORT, HOST |
optional; default 8081 on 127.0.0.1 |
Then node server/index.mjs.
With no password and no session secret it refuses every request and will not start from the command line. It fails closed rather than running open.
The repository is public, so the code describes the whole scheme; the security is
in the secrets, which are only ever in the environment. The password is
scrypt-hashed and compared in constant time. Sessions are signed, stateless and
expiring, with an HttpOnly, Secure, SameSite=Strict cookie. Every mutating
request needs a CSRF token belonging to that session. Failed logins are rate
limited per client. Request bodies are capped as they arrive rather than trusting
the declared length. The one place a request string reaches the filesystem is
normalised and checked to be inside its root. Errors never carry a stack.
The hostname it answers on is treated as a secret too, which mostly means the
code never needs it: the UI uses relative URLs and the server reads its host from
the environment. The admin page carries no og:*, no manifest and no canonical
link, any of which would publish the address in a preview card. A test enforces
all of this over every committed and uncommitted file, without itself naming
the host.
Worth being plain about: a secret hostname is obscurity, not security. It appears in DNS and in Certificate Transparency logs the moment TLS is issued for it, which makes subdomains publicly enumerable. The password and the session handling are what actually protect this.
- Speed. A full replace of a ~1000-dump library (clear the device, then re-upload everything) took about 26 minutes on real hardware; a push onto an already-full drive is slower still (~2.5 s per dump). Every plan shows a time estimate before you commit, calibrated against real runs rather than the per-chunk figure, which proved five times optimistic.
- Capacity. Deleting comes first, so only the final state has to fit rather than both copies at once. Capacity is checked against what a file actually occupies, not its contents: a 540-byte dump costs about 1.3 kB once filesystem overhead is counted, so a 1049-dump library needs ~1.4 MB rather than the 590 kB its bytes suggest.
- Why "verify doubtful files" exists. Every dump is exactly 540 bytes, so size cannot detect a content change. A SHA-256 per path is recorded at the end of each sync; on the first run there is no record, so files present on both sides at the same size are genuinely undecidable: they are listed as unverified rather than guessed at. The option reads each one back to compare hashes: correct but slow, at roughly 0.2 s per file.
- No modification times. The device reports none, so change detection is file size plus the recorded hash (see PROTOCOL.md).
The device exposes a small virtual-filesystem RPC over the Nordic UART
Service: vfs_read_dir, vfs_open_file, vfs_read_file, vfs_write_file,
vfs_create_folder, vfs_remove, vfs_rename. Files move in 242-byte
chunks with one command in flight at a time. The full wire format is in
PROTOCOL.md.
The protocol has no heartbeat, and an idle device can power itself off, so
after ten seconds of silence the client sends a get_version as a
keep-alive: the cheapest command there is, and it never interleaves with
real work.
npm test595 tests, no hardware needed:
-
protocol.test.mjs: against a simulated device: framing, multi-notification reassembly, command serialisation, chunked writes, error-status propagation, disconnects. -
planner.test.mjs: the reconciliation rules and safety properties: folders are never removed as a shortcut for their contents, deletions require opt-in, equal size is never mistaken for equal content, over-long paths are blocked rather than attempted. -
amiibo.test.mjs: ID parsing and the collection model, including the curated HHD entry, its UID-keyed card manifest, and the hide toggle. -
ui-modules.test.mjs: static checks over the page modules, which touchdocumentand cannot be imported undernode:test: every function called must be defined or imported. Exists because a refactor once deleted a render function and left its call site, and nothing caught it until the page threw. -
pages.test.mjs: the "stupid mistake" guards for a site with no build step: every page carries the full head kit, wiring, and assets it claims. -
sprite.test.mjs: the mascot's pixel maps stay rectangular and its colourways stay sound. -
prefs.test.mjs: preference storage, defaults, and the one-shot legacy migration. -
bundle.test.mjs: the all-in-one format. Detection accepts a real bundle and rejects a single dump, a wrong length, and right-length noise; pack and split round-trip byte for byte; path assignment stays unique and inside the device's limits across all 946 entries at several device roots; unpacking drops what you already hold, collapses duplicates, keeps the 91 UID-keyed cards apart, and never steals a path a real file occupies. Everything is asserted against synthetic bundles built to the spec, sinceamiibos/is not committed; the two real samples are checked behind anexistsSyncguard. -
dbsource.test.mjs: the upstream parsers and the name derivation, against tiny committed fixtures. This logic built the database for months with no direct test, because it lived inside the generator where nothing could reach it. Covers escaped quotes and non-ASCII names, malformed rows being skipped without throwing, the earliest release winning, and each disambiguation rule in turn. -
overlay.test.mjs: the curated overlay. Mostly about what it refuses: unknown keys, uppercase IDs, a filename that is really a path, a pin that would not fit the device, and a pinned path on an ID that stands for many physical dumps. -
auth.test.mjs: passwords, sessions, cookies, CSRF and rate limiting. Includes the cases that are easy to get wrong: a malformed hash refusing everyone rather than everyone, and garbage tokens failing rather than throwing. -
server.test.mjs: the admin over real HTTP on an ephemeral port. Refuses without a session, without a CSRF token, and with another session's token; refuses a save that would not build, leaving both files untouched; caps body size; blocks four shapes of path traversal. Also the backup routes: a restore meets the same gate as a save and is itself undoable, and only a file whose name is a backup stamp can be read from the backup directory. That last one is asserted by planting a file that is not and requiring it to stay unreadable, since Node's URL parser never decodes%2Fand so a traversal string cannot reach that code to begin with. -
admin.test.mjs: the leak guards. No committed or uncommitted file may name a subdomain of the public site, assign a secret, or contain a password hash; nothing may link to the admin. The file does not name the host it protects. -
db.test.mjs: invariants of the generated database, enforced on the committed file as well as at generation: every(series, filename)pair unique, short tokens present and unique, the delta tables carrying only real deltas, and no filename over the device's 47-byte limit. -
dbdiff.test.mjs: what an upstream refresh would change, and what each answer writes. Built by mutating a real generated database rather than a fixture, because the thing under test parses that exact format. Covers the two silent bugs it inherited from the CLI: release dates read as none at all, and a delta row appearing for an existing ID read as a new filename rather than a device-side rename. Also the full life of a declined addition: an exclusion is written, both databases then omit the entry so a names-only comparison would see nothing, and the next update offers it once more until it is accepted. -
upstream.test.mjs: the fetcher, against a local origin rather than the network, so the real fetch path including abort is exercised. Nearly every test asserts the same thing from a different angle: the live cache is not touched. Also the plausibility gate, which is what stops a 200 carrying an HTML error page from being written perfectly atomically over a working cache. -
server-upstream.test.mjs: the refresh over real HTTP, with its own COPY of the cache. Applying promotes the pending sources over the live ones, and the repository'stools/.cacheis what every other test andnpm run update-dbdepend on. Each of the four apply gates is covered by a test that fails when the gate is removed. -
artwork.test.mjs: fetching pictures and building the tiers, withfetchand the child-process runner both injected, so no test needs an image tool installed and the Linux path (mogrify) is exercised on a Mac. The case with most riding on it is the one with no tool at all: the artwork still arrives and the report says the tiers were skipped, because the alternative is missing thumbnails and nothing anywhere explaining them. -
artwork-compare.test.mjs: noticing a picture changed without downloading it. The manifest is served by a real local origin rather than a stub. Two of git's own published blob hashes are asserted directly, which is what pins the hash to git's definition rather than to this implementation of it.
No test reaches the network. Everything is served from an ephemeral local
origin, which the suite is checked for: preloading a wrapper around fetch into
every test process and running the whole suite reports zero requests off the
machine. That was verified by first making a deliberate one and watching it get
caught, since a guard that cannot fire proves nothing.
The last seven run the interface itself, against a real DOM from linkedom.
They exist because every UI bug in this project's history lived in a gap
ui-modules.test.mjs cannot see: a selector matching nothing, a container
styled through a child it does not have, a page that renders blank because a
module failed to load. npm test said everything passed each time.
collectionview.test.mjsandcollectiongrid.test.mjs: the collection, split into what it decides and what it draws. The grid is built for all 31 series and inspected: no amiibo is dropped between the data and the DOM, filtering hides prebuilt cells rather than rebuilding them, a match forces its series open, and changing the sort moves the existing nodes instead of remaking them.admin-ui.test.mjs: the admin page read without executing it. Every element the script reaches for exists, everydata-iconames a real icon, the sign-in form is visible before any script runs, and the grid is styled in one place rather than once per page.admin-boot.test.mjs: the admin actually run.adminui.jsis imported against the real page with a stubbed API, then driven: sign in, search, filter, edit, revert, publish. This is the file that would have caught the white screen. Its review section is driven step by step against a previewdbdiffgenerates from a mutated real database, so the screen and the fixture cannot drift: the headline count is asserted to equal the number of rows the steps render, which is the arithmetic the previous screen got wrong.admin-style.test.mjs: the CSS as text, since there is no layout engine. Its centrepiece is a class-clash detector: every class the admin borrows is checked against every classapp.cssstyles unqualified. It exists because.fRowwas such a class, owned by the site footer, and the admin's form rows inherited a flex row that put each label, input and error side by side.amiibodetail-page.test.mjs: the detail page rendered for three fixtures (a plain figure, the Kirby Air Riders vehicle set and the 91-card HHD entry) and compared against a committed snapshot. It was written against the page before its renderer moved intoamiibopanel.js, so it is the acceptance test for that extraction: the public page draws byte-for-byte what it drew.amiibopanel.test.mjs: the renderer underneath it. Chiefly that every image URL comes from the injected hook. Four inline./data/images/...literals were the reason the panel could not be shared, since the site is served from./and the admin from/. Also that "nothing scanned" stays distinct from "scanned, and not owned", which a boolean would collapse.
Each of these files ends with a test whose only job is to prove the file can fail: it replays the real regressions against the same assertions and requires them to throw. A test that cannot fail is decoration.
Two limits worth knowing: there is no layout engine, so anything about pixels,
sizes or overflow is still invisible and needs a human; and linkedom does not
reflect every IDL property onto an attribute (open and loading are
properties only), so assertions use the property unless the markup carries the
attribute.
PROTOCOL.md reverse-engineered wire protocol
FORMATS.md the two all-in-one container formats, in full
COMMANDS.md every command in one place
serve.mjs zero-dependency static server (Node built-ins only)
package.json scripts only, no dependencies to install
LICENSE / LICENSE.GPL-2.0 MIT for the source, GPL-2.0 for the generated DB
web/index.html 8-bit title screen (home)
web/collection.html the app: collection + everyday sync
web/amiibo.html per-amiibo detail page
web/sync.html Advanced sync (every operation and option)
web/help.html HOW TO page: every feature explained in-app
web/legal.html legal & licensing, the README's Legal section in-app
web/changelog.html what's new: every release, generated from one data file
web/debug.html device internals: probe + write test + repair
web/probe.html redirect stub -> debug.html (old links keep working)
web/write-test.html redirect stub -> debug.html
web/design-lab.html the design moodboard the NES skin was picked from,
kept in the repo for fun, never deployed
web/css/app.css shared styles: three NES themes, pixel components
web/css/collection.css the collection grid and its toolbar, shared with
the admin so both draw the same list
web/css/amiibodetail.css the detail panel, shared with the admin
web/fonts/press-start-2p/ vendored Press Start 2P (SIL OFL) + its licence
web/favicon.svg the pirate mascot, generated from js/sprite.js
web/icons/ PNG icons + OG share image, all rendered from the mascot
web/manifest.webmanifest pinned-to-home-screen metadata (Android/iOS)
web/data/amiibo-db.js 946 amiibo IDs -> name/series/type/date (generated)
web/data/hhd-cards.js index of the fan-made HHD card pack (91 UIDs, no tag data)
web/data/changelog.js release history; the only place entries are written.
The page renders it, CHANGELOG.md is generated from it
web/data/images/ artwork tiers + vehicle renders, all gitignored,
fetched + deployed
web/js/amiibo.js amiibo ID parsing, series/type/faces, collection model
web/js/bytes.js little-endian codecs, string and metadata TLV
web/js/ble.js Web Bluetooth transport (Nordic UART Service)
web/js/protocol.js framing, reassembly, command queue, VFS commands
web/js/planner.js reconciliation logic + path assignment (pure, no I/O)
web/js/dbdiff.js what an upstream refresh would change, and what each
answer writes; shared by the CLI and the admin
web/js/dbsource.js upstream parsers + name derivation, shared by the
generator, the admin server and the browser
web/js/devicepath.js device byte limits and safe names, with no DB import
web/js/overlay.js the curated overlay: schema, validation, merge
web/js/bundle.js flat 572 all-in-one format: detect, split, pack
web/js/fca.js FCA all-in-one archive: detect, split (read only)
web/js/bundlesource.js unpack either into the local index, gap-fill dedupe
web/js/localfs.js local folder access, hashing, sync state
web/js/syncflow.js the sync engine both surfaces share (scan/plan/apply)
web/js/devicepicker.js folder browser for the device side
web/js/sync.js device walk and plan executor
web/js/syncui.js Advanced sync page logic
web/js/collectionui.js collection page logic
web/js/collectionview.js filtering, counting and ordering a collection (pure)
web/js/collectiongrid.js the series/cards grid, shared with the admin
web/js/amiibopanel.js one amiibo drawn, shared with the admin
web/js/amiibodetail.js detail page: the URL, the scan cache, prev/next
web/js/artwork.js where the artwork lives: tiers, URLs, error fallback
web/js/changelogui.js renders the release history onto changelog.html
web/js/chrome.js the header bar and footer as builders, seeded by the
page that mounts them
web/js/header.js the site's header contents: nav, Settings sections
web/js/prefs.js every stored preference behind one tiny surface
web/js/ui.js shared UI kit: toasts, status, progress, dialogs,
debounce, counters, formatters
web/js/dialog.js themed confirm on native <dialog>
web/js/footer.js the site's footer contents
web/js/version.js build id shown in the footer ('dev' in the repo,
stamped with the commit at deploy time)
web/js/icons.js 8-bit UI icons (Pixelarticons, inlined)
web/js/sprite.js the pirate mascot as pixel-map -> SVG
web/js/tutorial.js the guided tour: spotlight overlay + per-page steps
web/js/probe.js read-only probe logic
web/js/writetest.js write-test logic
web/js/rescue.js drain a folder that will not list (engine, no DOM)
web/js/repair.js repair-tool UI on the debug page
content/amiibo-overrides.json curated corrections, merged by the generator
content/backups/ the admin's timestamped saves (gitignored)
server/index.mjs the admin service: routing, sessions, static UI
server/auth.mjs scrypt password, signed cookie, rate limit, CSRF
server/store.mjs atomic overlay writes and backups
server/regen.mjs rebuild the site database after an edit
server/upstream.mjs fetch the sources into pending/, promote, discard
server/artwork.mjs compare artwork by blob hash, stage, promote, discard
admin/ the admin UI (not part of the public site)
tools/build-amiibo-db.mjs regenerate the database; also importable as generate()
tools/update-db.mjs fetch upstream sources + regenerate + report the diff
tools/fetch-amiibo-images.mjs download artwork, build the three tiers
(a module: the admin server calls fetchArtwork() too)
tools/build-changelog.mjs regenerate CHANGELOG.md from web/data/changelog.js
test/ thirty-three files, see Tests above
The site is static files: the bundled Node server exists only to give Web
Bluetooth the secure context it requires during local development. To host it
elsewhere, serve the contents of web/ from any HTTPS server; HTTPS is what
Web Bluetooth requires. A deployment is a plain mirror of web/: the
artwork tiers are gitignored but do get deployed, and design-lab.html stays
home.
The admin is not part of that mirror. It lives outside web/, so a
deploy neither carries it nor exposes it. If you run one, it needs its own site
and its own hostname, and the deploy must stop overwriting web/data/. The
admin owns that directory once it is regenerating the database, and a mirror
would put the repository's copy back over your edits.
If you sync into a folder inside a clone of this repo, note that a device also
holds key_retail.bin (the amiibo signing keys) alongside your dumps. The
included .gitignore excludes *.bin and common sync-target folder names for
that reason; check git status before committing if you change it. It also
excludes the per-folder sync state (.allmiibo-sync.json), the debug page's
report exports, and all amiibo artwork: it is Nintendo's and is never
committed.
Everything in this section is also published in-app at
/legal.html, linked from every page's footer.
This is a non-commercial, community fan project provided as-is, without warranty of any kind. Use it at your own risk; the author accepts no liability for data loss, damage to a device, or any other consequence of its use.
No affiliation. Not affiliated with, authorised by, or endorsed by Nintendo, the Pixl.js or AmiiboLink projects, or any hardware vendor. "amiibo" and all game, character, and product names are trademarks of their respective owners, used here only descriptively to identify the files a user is managing. No trademark claim is made or implied.
What this project does not contain or distribute. No amiibo dumps
(.bin files), no NTAG contents, and no cryptographic keys (e.g.
key_retail.bin). They are excluded by .gitignore and must never be
committed. The tool neither generates, decrypts, nor modifies amiibo data; it
only moves and catalogues the files already present on a connected device.
Interoperability. The Bluetooth protocol in PROTOCOL.md was determined from the device firmware, which is published as open source by its authors, and from the vendors' own publicly served web clients, for the sole purpose of interoperating with a compatible device over Bluetooth.
Nintendo artwork. amiibo images shown by the collection view, and the
Air Riders vehicle renders on detail pages, are © Nintendo. They are fetched
at the user's request for personal, local use and are not redistributed
by this repository: the entire web/data/images/ tree is gitignored, at
every resolution.
The pirate mascot and logo are original pixel art made for this project. They depict no Nintendo character or mark.
| Source | Used for | Licence |
|---|---|---|
| 8bitDream/AmiiboAPI (fork of N3evin/AmiiboAPI) | amiibo series/type labels, release dates, artwork URLs | MIT |
| solosky/pixl.js | wire protocol reference; amiibo name table | GPL-2.0 |
Press Start 2P (vendored in web/fonts/) |
pixel display font | SIL OFL 1.1 |
Pixelarticons by Gerrit Halfmann (inlined in web/js/icons.js) |
8-bit UI icons | MIT |
| fishybow/fca (SPEC.md) | published specification for the FCA all-in-one container, implemented as a reader in web/js/fca.js |
MIT |
| amiibo artwork + vehicle renders (fetched locally, never committed) | collection and detail images | © Nintendo |
| fan-made HHD card pack (community, authors unknown) | factual index only: card number, NFC UID, item count, teaser (web/data/hhd-cards.js); no tag data |
facts, compiled for this project |
- The author's own source, everything except
web/data/amiibo-db.js, is MIT (LICENSE). That includescontent/amiibo-overrides.json: it is independently authored corrections, not a modification of the GPL-2.0 name table, and it embeds none of it. Only the generated combination is GPL-2.0. - The generated
web/data/amiibo-db.jsembeds the amiibo name table from pixl.js and is therefore GPL-2.0 (LICENSE.GPL-2.0); its series/type labels and dates come from AmiiboAPI (MIT). package.jsondeclaresMIT AND GPL-2.0-onlyto reflect both.
Attribution to every source is retained in the generator, the generated file's header, and the site footer.