Skip to content

Repository files navigation

allmiibo-sync

allmiibo-sync: sync your amiibo collection over Bluetooth

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.

Self-contained by design

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.

Status

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.
  • remove deletes 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".
  • rename moves 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.

Quick start

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 Collection

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.

Vehicles and card sets

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/4 tally; the detail page shows the four machines as image cards, official renders fetched locally by fetch-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/91 pill), 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.

Advanced sync

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 .bin files 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.json is 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 FOLDER needs no device and PACK DEVICE needs 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.

Debug tools

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 interface

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.

How amiibo are identified

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 03 rather than 02. 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 - Isabelle rather than Isabelle), 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.

The curated overlay

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.

Curating a series

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.

Naming a file for an amiibo

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 SuperstarsMSS, Street Fighter 6SF6), 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.

All-in-one bundles

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 FOLDER and PACK 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 FCA magic 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 in test/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 (0xA5 magic, capability container F1 10 FF EE, both UID check bytes);
  • the padding is 32 × 0xFF in 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.

Admin

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.

Updating from upstream

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

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.

Why applying reloads the page

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.

Why a fetch does not take effect

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.

Backups, restore and export

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.

Running it

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.

Security

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.

Design notes

  • 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).

How it works

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.

Tests

npm test

595 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 touch document and cannot be imported under node: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, since amiibos/ is not committed; the two real samples are checked behind an existsSync guard.

  • 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 %2F and 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's tools/.cache is what every other test and npm run update-db depend 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, with fetch and 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.mjs and collectiongrid.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, every data-ico names 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.js is 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 preview dbdiff generates 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 class app.css styles unqualified. It exists because .fRow was 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 into amiibopanel.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.

Layout

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

Hosting

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.

Keep dumps and keys out of git

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.

Legal and licensing

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.

Third-party data and code

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

Licences in this repository

  • The author's own source, everything except web/data/amiibo-db.js, is MIT (LICENSE). That includes content/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.js embeds 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.json declares MIT AND GPL-2.0-only to reflect both.

Attribution to every source is retained in the generator, the generated file's header, and the site footer.

About

Folder sync for Allmiibo / PIXL NFC emulator devices over Bluetooth LE

Topics

Resources

Contributing

Stars

7 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages