Reverse-engineered from the official Web Bluetooth clients:
https://bt.allmiibo.com/: Vue app, bundle/js/app.5be9472d.jshttps://pixl.amiibo.xyz/: bundle/index.js?815c0d29c8e2d63e8fcc
Both clients speak the identical wire protocol. The PIXL bundle additionally
implements vfs_read_file (opcode 20), which the allmiibo page never calls.
That is the missing half that makes device→local sync possible.
The device firmware is open source, which makes it the authority for anything the clients left ambiguous:
solosky/pixl.jsfw/application/src/mod/df/df_proto_vfs.c: command handlersfw/application/src/mod/vfs/vfs.h: limits, mode flags, error codes
Everything below has been cross-checked against that firmware, and confirmed against hardware running Pixl.js 2.11.2 and 2.16.0. The wire protocol is unchanged across those five releases (October 2024 to January 2026), including the addition of v3 amiibo emulation in 2.16.0.
The stock web UI only allows manual, one-file-at-a-time transfers and manual folder creation. This project replaces it with a folder-level sync tool.
Goals:
- Keep a local directory tree (subfolders + files) in sync with the device's internal filesystem.
- Explicit direction control:
push: local is master; device mirrors local.pull: device is master; local mirrors device.two-way: reconcile both sides against a stored sync state.
- Recursive: create/remove folders on the device as needed, not by hand.
- Idempotent: re-running a sync with no changes transfers nothing.
- Dry-run mode that prints the full plan before touching either side.
- Deletion propagation as an explicit opt-in (
--delete), never by default.
Non-goals: firmware updates (DFU), amiibo key handling, tag emulation logic.
Nordic UART Service (NUS):
| Role | UUID | Properties |
|---|---|---|
| Service | 6e400001-b5a3-f393-e0a9-e50e24dcca9e |
none |
| RX (host → device) | 6e400002-b5a3-f393-e0a9-e50e24dcca9e |
Write |
| TX (device → host) | 6e400003-b5a3-f393-e0a9-e50e24dcca9e |
Notify |
The client scans with filters: [{ services: [NUS] }], connects, subscribes to
notifications on TX, and writes requests to RX.
Sizing constants from the client:
MTU = 247
HEADER_LEN = 4
MAX_PAYLOAD = MTU - HEADER_LEN = 243
WRITE_CHUNK = MAX_PAYLOAD - 1 = 242 // minus 1 byte for file_id
All integers are little-endian.
| Offset | Type | Field |
|---|---|---|
| 0 | u8 | cmd |
| 1 | u8 | status |
| 2 | u16 | chunk |
A request is a single GATT write: header with status = 0, chunk = 0,
followed by the command payload. Requests are never split, since every
command's payload fits within MAX_PAYLOAD.
Responses arrive as one or more notifications.
- If
chunk & 0x8000is set, more notifications follow. - The final notification has bit 15 clear.
Reassembly (mirroring the client's accumulator):
- First notification of a multi-part response: append the entire buffer, including its 4-byte header.
- Subsequent notifications: append payload only (skip the 4-byte header).
- On the final notification: append payload, then parse the accumulated buffer, whose leading 4 bytes are the header from step 1.
A single-notification response is parsed directly.
status is binary. The firmware only ever emits these two values:
typedef enum { DF_STATUS_OK = 0, DF_STATUS_ERR = 1 } df_status_t;There is no detailed error reporting on the wire. Internally the VFS layer has
a richer set (VFS_ERR_NOOBJ = -90, VFS_ERR_NOSPC = -91,
VFS_ERR_OBJEX = -4, VFS_ERR_UNSUPT = -99, …) but df_proto_vfs.c collapses
them all to DF_STATUS_ERR; the source even carries a // TODO mapping error
where that happens. A failed command therefore says that it failed, never
why.
The client keeps a FIFO queue with strictly one command in flight. The next request is only written after the previous response fully arrives. Do not pipeline: the device has no request IDs to correlate replies.
A response deadline must measure silence, not elapsed time. A large
read_dir legitimately streams for a minute on a slow link (§7.2), so a
deadline armed once when the request is written kills healthy transfers. Arm it
per notification instead, and re-arm on every frame including continuations.
Field report: a device whose E:/amiibo held ~760 entries failed every sync
against a 15 s whole-response deadline, while the same folder listed fine once
the deadline counted idle time. Keep a separate, much larger absolute ceiling
for a device that trickles forever without ever finishing.
After a timeout, wait for the link to fall quiet before writing again. With no request IDs, frames still arriving from the response you abandoned are indistinguishable from the reply to your next command, and get spliced onto the front of it. The reassembly buffer must also be reset when the next request is written, not only when a response completes.
u16 byte_length
u8[] utf8_bytes
(The client encodes via encodeURIComponent, i.e. plain UTF-8.)
Used by vfs_read_dir (read) and vfs_update_meta (write).
u8 total_length // 0 => no metadata
then, repeated until consumed:
u8 tag
tag 1 (notes): u8 length, u8[length] utf8_bytes
tag 2 (flags): u8 flags // bit 0 = hidden
Constraint: notes must be ≤ 90 bytes (client throws above that).
string name
u32 size
u8 type // 0 = regular file, non-zero = directory
meta metadata
Every path is drive-prefixed and the firmware validates the prefix strictly:
static bool validate_path(char *path) {
if (path[0] != 'I' && path[0] != 'E') return false;
if (path[1] != ':' || path[2] != '/') return false;
return true;
}So a path looks like E:/folder/file.bin. The two drive labels are fixed:
| Label | Root | Drive |
|---|---|---|
I |
I:/ |
internal flash |
E |
E:/ |
external flash |
The root must be built from the drive's label, not its name. The name
field is a human-readable string such as "External Flash". The official UI
gets away with name.substr(0, 3) only because it renders its own drive rows;
applying that to the value returned by vfs_get_drive_list yields "Ext",
which fails validate_path. See §7.1.
The firmware strips the first two bytes (VFS_DRIVE_LABEL_LEN) and passes the
remainder, /folder/file.bin, to the filesystem driver.
Size limits, from vfs.h (these include the NUL terminator, hence the
clients' 47/63):
| Constant | Value | Effective limit |
|---|---|---|
VFS_MAX_NAME_LEN |
48 | filename ≤ 47 bytes |
VFS_MAX_PATH_LEN |
64 | path ≤ 63 bytes |
VFS_MAX_META_LEN |
128 | none |
VFS_MAX_FOLDER_SIZE |
32 | entries per folder |
This is the single most important reason to validate paths before sending.
An over-long path does not produce an error. buff_get_string in
df_buffer.h clamps and carries on:
static inline void buff_get_string(buffer_t *buffer, char *string, size_t max_length) {
uint16_t length = buff_get_u16(buffer);
...
max_length = max_length - 1; // exclude '\0'
uint16_t min_length = max_length > length ? length : max_length;
buff_get_byte_array(buffer, string, min_length);
string[min_length] = '\0';
buffer->pos += length - min_length; // discard the excess
}Handlers read into char path[VFS_MAX_FULL_PATH_LEN] (66 bytes), so anything
longer than 65 bytes is truncated and the command then executes against the
truncated path, reporting DF_STATUS_OK.
Consequences, worst first:
vfs_removedeletes the wrong entry. Truncation can land the path on a different file, or on a directory, and removal is recursive (§9.4). An unvalidated remove can therefore destroy an entire subtree while reporting success.vfs_open_filein write mode creates a file at the truncated path, typically losing the.binextension, and silently.vfs_renamemoves to the wrong destination.
There is no memory-safety issue: the clamp is correct and NUL-termination is guaranteed. The hazard is purely that the device does something other than what was asked, without saying so.
Enforce every path client-side, on every path-taking command, including the destructive ones.
The firmware's own ceiling is 65 bytes for a full path: a 66-byte buffer less
the NUL. After get_file_path strips the 2-byte drive label, the driver sees
≤ 63 characters, which matches SPIFFS_OBJ_NAME_LEN (64). SPIFFS is flat, so
the whole path is the object name.
Both official clients nonetheless enforce 63 bytes, two below what the firmware would accept. This project keeps 63, for two reasons:
- Files created outside the official clients' limit could not be managed by the stock web UI, which refuses to send such paths at all.
- Two bytes of headroom is not worth being the only tool that can address a given file.
The filename cap of 47 bytes is enforced only by the clients. The firmware
does not check it separately on the request path, though vfs_obj_t.name is a
48-byte field that directory listings strncpy into, so a longer name would be
truncated in listings regardless.
| Opcode | Name | Request payload | Response payload |
|---|---|---|---|
| 1 | get_version |
none | string ver, optional string ble_addr |
| 2 | enter_dfu |
none | none |
| 16 | vfs_get_drive_list |
none | u8 count, then entries (see 5.1) |
| 17 | vfs_drive_format |
u8 label_char |
none |
| 18 | vfs_open_file |
string path, u8 mode |
u8 file_id |
| 19 | vfs_close_file |
u8 file_id |
none |
| 20 | vfs_read_file |
u8 file_id |
u8[] contents (all remaining bytes) |
| 21 | vfs_write_file |
u8 file_id, u8[] data |
none |
| 22 | vfs_read_dir |
string path |
repeated dir entries until exhausted |
| 23 | vfs_create_folder |
string path |
none |
| 24 | vfs_remove |
string path |
none |
| 25 | vfs_rename |
string from, string to |
none |
| 26 | vfs_update_meta |
string path, meta |
none |
Opcodes 3–15 are unused by both clients.
u8 status // 0 = available, 1 = unavailable
u8 label // 'I' or 'E'
string name // human-readable, e.g. "External Flash"
u32 total_size
u32 free_size // remaining, NOT used (see below)
count is vfs_drive_enabled(INT) + vfs_drive_enabled(EXT), so it can be 2.
The official client reads only the first entry; parse all of them.
Observed on hardware (Pixl.js 2.11.2): count = 1, status = 0,
label = 'E', name = "External Flash", total_size = 1,920,401,
free_size = 966,601, meaning 953,800 bytes used.
The second u32 is free space, not used space. The official Pixl.js client renders the drive row as
free/total, truncated to two decimals. An empty device reportedtotal_size = 1,920,401andfree_size = 1,918,644, which that client showed as "1.82 MB/1.83 MB" (1,918,644 ÷ 1024² = 1.8298, floored). That leaves 1,757 bytes actually used, about what littlefs spends on its superblock. Reading the field as "used" inverts the drive and makes a nearly empty device look full.
Firmware quirk. In the internal-drive branch,
df_proto_vfs.ccallsvfs_get_driver(VFS_DRIVE_EXT)where it plainly meansVFS_DRIVE_INT, so a device with internal flash enabled reports the external drive's stats under label'I'. Do not trusttotal_size/free_sizefor theIdrive.
The mode is a u32, read by the firmware with buff_get_u32. Both official
clients write a single byte and get away with it only because the frame buffer
is zeroed beneath them. Send all four bytes.
Flags are enum vfs_mode_t in vfs.h:
| Flag | Value |
|---|---|
VFS_MODE_APPEND |
1 |
VFS_MODE_TRUNC |
2 |
VFS_MODE_CREATE |
4 |
VFS_MODE_READONLY |
8 |
VFS_MODE_WRITEONLY |
16 |
The combinations the clients use:
| Mode | Value | Meaning |
|---|---|---|
"r" |
8 |
READONLY |
"w" |
22 |
WRITEONLY | CREATE | TRUNC; creates if absent, truncates if present |
Note that "w" truncates an existing file, so a failed write leaves the
destination empty rather than untouched.
Only one file is open at a time: opening a new file while another is open silently closes the previous handle.
vfs_read_dir(path) -> [{ name, size, type, meta }, ...]
Recurse into entries with type != 0.
r = vfs_open_file(path, "r") // r.status must be 0
data = vfs_read_file(r.file_id) // single command; response is chunked
vfs_close_file(r.file_id)
The whole file returns in one logical response, which the chunking layer reassembles. Always close, including on error.
r = vfs_open_file(path, "w") // r.status must be 0
offset = 0
while offset < size:
n = min(242, size - offset)
vfs_write_file(r.file_id, data[offset : offset+n])
offset += n
vfs_close_file(r.file_id)
242 bytes per write (WRITE_CHUNK). Sequential, one in flight. On any write
error, still issue vfs_close_file.
vfs_create_folder(path)
Not recursive: create parents first, one level at a time. The handler is a
thin wrapper over the driver's create_dir, returning DF_STATUS_ERR on any
failure. Whether an already-existing folder counts as a failure is left to the
filesystem driver and is still unconfirmed on hardware.
vfs_remove first calls stat_file, then dispatches to remove_dir or
remove_file based on the entry type, so one command handles both. A missing
path returns DF_STATUS_ERR. Whether remove_dir succeeds on a non-empty
folder depends on the driver (LittleFS refuses; SPIFFS has no real
directories) and is still unconfirmed.
Build the root as `${drive.label}:/`. Reusing the official UI's
name.substr(0, 3) against the drive-list response produces "Ext" from
"External Flash", which fails validate_path.
The failure mode is quiet: open_dir fails, the handler returns
DF_STATUS_ERR, and a client that treats an error as "empty directory" reports
a perfectly healthy device as having no files. Confirmed on hardware: a walk
from "Ext" returned zero entries against a drive with 966 KB in use.
Full read-only walk: 862 files, 44 folders, 45 read_dir calls, 0 errors,
465 KB of content.
E:/amiibolink/ 00.bin … 25.bin AmiiboLink slot emulation
E:/amiibo/<cat>/[<sub>/] <name>.bin browsable library, up to 3 levels
E:/amiibo/fav/ (empty)
E:/amiibo/data/ (empty)
E:/chameleon/slots/ 00.bin, 01.bin, config.bin
E:/key_retail.bin 160 B, amiibo signing keys
E:/settings.bin 17 B, device settings, hidden
846 files are exactly 540 bytes (NTAG215) and 10 are 572; the rest are device state, not dumps.
Findings that constrain the sync engine:
VFS_MAX_FOLDER_SIZE is not a per-folder entry cap. Two folders hold 100
entries each and listed without error. The firmware imposes no limit.
But a large folder is a large response, and that is a client problem. An
entry costs roughly 38 bytes on the wire, so 100 entries is ~4 KB and 760 is
~29 KB, streamed 243 bytes at a time at 0.5–2 KB/s. Reported from the field: a
device holding ~760 dumps in one flat E:/amiibo timed out on every scan
against a client that gave the whole response 15 s, while its drive reported
846,372 bytes used and the walk had accounted for only 4,657. Nothing was wrong
with the device or the folder. A client must measure its timeout per
notification (§3.4), and a library should still be spread across subfolders:
listing a 760-entry folder costs a minute you pay on every single scan.
The path budget is the binding constraint. The 63-byte cap covers the whole
path including the E:/ prefix. Observed maxima: longest full path exactly
63 bytes (E:/amiibo/others/Monster Hunter/Palamute _Canyne Malzeno X_.bin),
with four files in the 60–63 range and none over. The longest filename is
only 39 bytes against a 47-byte cap, so paths run out of room long before
names do, and nesting is what costs you. A sync tool must validate each
destination path before transferring and report what will not fit, rather than
failing partway through a copy.
Not everything on the drive is a dump. settings.bin is device
configuration (and flagged hidden), key_retail.bin holds the amiibo signing
keys, and chameleon/ is separate emulator state.
A firmware upgrade from 2.11.2 to 2.16.0 changed exactly one thing on the
drive: settings.bin grew from 17 to 24 bytes. Every one of the 862 dumps was
untouched. Had settings.bin been syncable, a pull taken before the upgrade
would have cached the old layout and a later push could have written it back
over the new one. A whole-drive pull would
sweep these up, and a whole-drive push with --delete could destroy them.
Sync should be scoped to a subtree such as E:/amiibo and treat device-managed
files as excluded by default.
Metadata is real but rare. Two entries out of 862 carry it, and between
them they exercise both TLV tags: E:/chameleon/slots/00.bin has
notes = "Slot 01wee", and E:/settings.bin has the hide flag set. Both
decoded correctly, so the TLV parser is confirmed against hardware.
The firmware does NOT sanitise filenames. Twelve names contain _ where
the source clearly had something else: Mr. Game _ Watch.bin,
Banjo _ Kazooie.bin, Rosalina _ Luma.bin, Zelda _ Loftwing.bin (&);
Link (Majora_s Mask).bin ('); [MOD _ MAX LEVEL] Wolf Link.bin (/).
That looks like device-side rewriting, but it is not. The same drive also holds, stored literally:
| Path | Character |
|---|---|
E:/amiibo/Animal/Figures/13 - Timmy & Tommy.bin |
& |
E:/amiibo/Animal/Series 4/390 - O'Hare.bin |
' |
E:/amiibo/Animal/Figures/15 - Kapp'n.bin |
' |
E:/amiibo/others/Yoshi's (folder) |
' |
& and ' survive verbatim on this device, while other files on the same
device have those characters replaced. A filesystem cannot be selectively
lossy, so the substitution happened in the dump packs before upload, not in
the firmware.
Consequence: sync needs no name-mapping layer. Compare names byte-for-byte.
(The one character that genuinely cannot appear in a name is /, since it is
the path separator.)
Non-ASCII survives intact: Link (Link’s Awakening).bin (U+2019, 29 bytes /
27 characters), Tatsuhisa “Luke” Kamijō.bin, Gakuto Sōgetsu.bin. Names are
plain UTF-8 with no transliteration, but multi-byte characters cost more against
the byte caps than their character count suggests.
A 160-byte file took 176 ms end to end (open + read + close, three round-trips). At roughly 60 ms per command, hashing all 862 files to detect changes would cost on the order of two and a half minutes. Size-first comparison, with content hashing reserved for ambiguous cases, matters more than it would on a faster link.
These figures are one device's. The same 160-byte key_retail.bin took
649 ms on an older unit (Pixl.js 2.13.0, coin-cell hardware), a 3.7×
spread on an identical operation. Everything timing-related in this document
was measured on the fast one, so treat it as a floor: the planner's
OPEN_CLOSE_MS / DOWNLOAD_MS / COMMAND_MS are estimates for a progress
bar, and no timeout should ever be derived from them.
- No modification times.
vfs_read_dirreturns name, size, type and metadata only. Change detection cannot use mtime on the device side. - Consequence: the tool keeps a local state file recording, per synced path, the size and content hash last seen on each side. Size is the fast pre-filter; a content hash requires reading the file back from the device.
- Amiibo dumps are small (540 / 572 bytes), so full-tree hashing is cheap in bytes but costs one open/read/close round-trip per file.
- Two-way conflicts (both sides changed since last sync) cannot be resolved by
timestamp. Default: report the conflict and skip, with
--prefer local|deviceto force. vfs_renameexists, so detected moves can avoid a re-upload.- The
hiddenmetadata flag andnotesare device-side state with no local filesystem equivalent. They are preserved on update but not synced, unless a sidecar file is introduced later.
What is recoverable, and what is not. read_dir is the only enumeration
primitive, and open_file takes a full path, so a filename is the only handle
on a file. A listing that returns nothing leaves that folder's contents
unreachable by any client. There is no fallback: no index-based access, no
wildcards, no cursor. Erasing such a folder destroys data no one can read, and
a tool that offers to do it should say exactly that.
A listing that stalls part-way is a different case. Frames arrive in order and each carries its own payload, so everything received before the device went quiet is exactly what it sent. Those are real entries, and they are addressable. The client must therefore keep the reassembly buffer on a timeout instead of dropping it, because that accumulation is the only handle recovery has.
The drain. Move the recovered entries out of the folder and list it again. The response is now shorter, so it reaches further; repeat until the listing completes or a pass yields nothing new. Notes from implementing it:
- Move, do not copy-and-delete.
renameis one command against open + read- close + remove's four, and a failed
renameleaves the file exactly where it was, so there is never a moment when a file exists nowhere. It also needs no host-side storage, so recovery does not depend on the user choosing a folder.
- close + remove's four, and a failed
- Generate the destination names. The source names carry nothing a client
needs (identity is in the file's own bytes, not its name), while costing
sanitisation, collision handling and path-budget arithmetic in the middle of
a recovery. Short generated names sidestep all of it:
E:/r_/1/0001.binis 17 bytes against the 63-byte cap, so no file can be skipped for not fitting. - Cap what you create. Parking several hundred files in one staging folder rebuilds the oversized listing you are dismantling. Batch them.
- Truncate defensively. A length prefix severed mid-field can decode to a plausible but wrong name, so stop parsing at the first read past the end and discard the last surviving entry too. It costs nothing: the folder is re-listed anyway.
- A stall is the ceiling, not a bug. If the device dies at the same entry every time, everything before it is out and the rest is unreachable. Stop and say so rather than inviting another attempt.
- Nothing is created twice.
create_folderis not idempotent (§9.3), so only create what a listing showed absent, and resume an interrupted run at the next free name rather than reusing one.
Resolved against firmware and hardware:
-
Meaning of non-zero: binary only,statusvaluesOK = 0,ERR = 1(§3.3). -
Open-mode flag semantics:enum vfs_mode_t, and the field is a u32 (§5.2). -
Whether more than one drive is reported: up to 2, labelsIandE(§5.1). -
Root path format:E://I:/, built fromlabel(§4.4). -
Directory type value:VFS_TYPE_REG = 0,VFS_TYPE_DIR = 1. -
Whether: it does not; 100-entry folders list fine (§7.2).VFS_MAX_FOLDER_SIZE(32) caps entries per folder -
Whether: yes, verified byte-for-byte on a 160-byte file.read_filereturns exactly the sizeread_dirreported -
Does the firmware sanitise filenames on write?: no. Confirmed twice: by inference from the library (§7.2) and directly by writing&,',",*,?and:and reading every one back byte-for-byte (§9). -
: returnsvfs_create_folderon an existing pathDF_STATUS_ERR(§9). -
: succeeds, recursively (§9).vfs_removeon a non-empty directory -
Can: yes (§9).vfs_renamemove between folders? -
Practical throughput: ~2 KB/s (§9).
Still open:
- Whether the device tolerates write-without-response, which is the obvious lever for improving on 2 KB/s.
- Whether a larger ATT MTU is negotiable.
- Idle behaviour: the device can power itself off while connected but
quiet, and nothing in the protocol documents an idle timer or a way to
reset it. The web client mitigates by sending
get_versionafter ten seconds of silence; whether that traffic actually defers the power-off needs confirming on hardware. - The largest
read_dirresponse the firmware will emit, and how long it may take before the first notification of a big listing, since the device has to scan the directory before it can answer. Unmeasured beyond ~760 entries; the client's absolute ceiling is 120 s, picked with roughly 2× headroom over the only large sample there is. - Whether a
read_dirthat stalls part-way does so deterministically at the same entry. It matters for recovery: if it does, moving the entries that did arrive out of the folder and re-listing gets progressively further.
Measured on Pixl.js 2.11.2 by writing to a scratch folder and reading back, then re-run unchanged on 2.16.0: every behaviour below is identical on both, and throughput moved only from 2.00 to 1.96 KB/s.
All five probes round-tripped byte-for-byte:
| Written | Stored exactly |
|---|---|
Mr. Game & Watch.bin |
yes |
Majora's Mask.bin |
yes |
Quote "X".bin |
yes |
Star*Q?.bin |
yes |
Colon:Test.bin |
yes |
Characters that are illegal on FAT (*, ?, :, ") are accepted, because
the underlying filesystem is not FAT. Only / is unavailable, being the
separator.
540 bytes written and read back identical.
Creating an existing folder returns DF_STATUS_ERR. Since status is binary
(§3.3), "already exists" is indistinguishable from a real failure.
Implication: list the parent with read_dir and create only when absent.
Do not create-and-ignore-the-error, or genuine failures pass silently.
vfs_remove on a non-empty folder succeeds and takes the contents with it.
There is no "directory not empty" guard.
Implication: this is the most dangerous call in the protocol. A single
mistargeted remove can erase an entire library. A sync tool must never remove
a directory as a shortcut for removing its contents. Delete files
individually, and treat directory removal as a separate, explicitly confirmed
step.
Corollary: never remove a folder whose listing you did not complete. A walk
records a directory before reading it, so one that failed to list sits in the
index with no children, shaped exactly like an empty folder. That covers a
timeout, a path too long for its children to be addressable, and a cancelled
scan. Because
remove is recursive, treating the two alike erases a subtree nobody has ever
seen. A childless directory in an index means "never looked", not "empty",
and the difference has to be recorded at walk time; it cannot be recovered
later.
rename("E:/a/x.bin", "E:/a/sub/moved.bin") succeeded and the entry appeared
in the subfolder.
Implication: a file that moved between folders can be relocated with one command instead of a re-upload. At 2 KB/s that is the difference between ~0.3 s and ~0.5 s for a 540-byte dump, and far more for anything larger, so a move detector keyed on content hash is worth having.
16,384 bytes took 8,010 ms, so 2.00 KB/s, about 118 ms per 242-byte chunk (8,160 ms / 1.96 KB/s on 2.16.0).
Write speed degrades as the drive fills. Across one 1049-upload run onto a freshly cleared drive, the first hundred uploads averaged 1.04 s and the last hundred 1.68 s; a push onto a nearly full drive averaged 2.5 s per dump. Flash allocation, not BLE, is the moving part. Deletes on a mostly empty drive run ~66 ms against ~240 ms when full, for the same reason. Estimates calibrated at one fill level will misestimate at another, so err on the full-drive figures.
A failed upload leaves the file at whatever length was committed. Observed after a push died of a full drive: files of 0 bytes (open succeeded, no chunk written), 242 bytes (one chunk), and 484 bytes (two chunks). The planner treats a size mismatch as knowably different, so these are re-uploaded on the next run rather than stranded. That is dominated by per-command latency, not bandwidth: each chunk is a separate acknowledged write, and the device commits to external SPI flash between them.
A 540-byte dump is open + 3 writes + close, and the fixed cost of open and close dominates, so the real per-file time is far above what the chunk rate alone suggests. Measured on hardware, not estimated:
| Operation | Measured |
|---|---|
| One 540-byte upload, empty drive | ~1.0 s |
| One 540-byte upload, full drive | ~2.5 s |
| One 540-byte download | ~0.4 s |
| One delete | 66 ms empty · 240 ms full |
| Full replace of a 1049-dump library (delete + re-upload, from empty) | ~26 min |
| A push onto a nearly full drive (the same scale) | ~48 min |
Implications: transfer the minimum. Never re-upload unchanged files; prefer
rename over re-upload for moves; report progress continuously; and make a
long push resumable, since a disconnect deep into a 25-to-50-minute run should
not restart from zero.
Not part of the BLE protocol, but essential for any tool that reasons about which amiibo a file holds.
// fw/application/src/amiibo_helper.c
uint32_t head = to_little_endian_int32(&ntag->data[84]);
uint32_t tail = to_little_endian_int32(&ntag->data[88]);
const db_amiibo_t *amd = get_amiibo_by_id(head, tail);The firmware also defines AMII_ID_OFFSET 476 and writes the ID to both 84
and 476 when generating a tag, but in retail dumps offset 476 falls inside
encrypted data and reads as noise. Measured across 1035 real dumps: offset 84
yields a valid ID in every case, offset 476 in none.
16 hex characters, e.g. 0181000100440502:
| Bytes | Field | Notes |
|---|---|---|
| 0–1 | game / character | |
| 2 | variant | |
| 3 | figure type | 00 figure, 01 card, 02 yarn, 03 band |
| 4–5 | model number | |
| 6 | amiibo series | see below |
| 7 | constant | 0x02 in all 1035 dumps measured |
Byte 7 being invariably 0x02 makes a useful validity check when locating the
ID in a dump of unknown format.
Series byte values, derived by correlating against a verified collection:
00 Super Smash Bros. |
01 Super Mario |
02 Chibi-Robo |
03 Yoshi's Woolly World |
04 Splatoon |
05 Animal Crossing |
06 8-bit Mario |
07 Skylanders |
09 Legend of Zelda |
0a Shovel Knight |
0c Kirby |
0d Pokémon |
0e Mario Sports Superstars |
0f Monster Hunter Stories |
10 BoxBoy! |
11 Pikmin |
12 Fire Emblem |
13 Metroid |
15 Mega Man |
16 Diablo |
17 Power Pro Baseball |
18 Monster Hunter Rise |
19 Yu-Gi-Oh! |
1a Donkey Kong |
1b Xenoblade |
1d Street Fighter |
21 Pragmata |
Two dumps of the same character differ in UID and save data, so they hash differently. Measured on one collection: 1035 files, 1035 distinct SHA-256 hashes, but only 943 distinct amiibo IDs. Byte comparison reported 92 re-dumps of characters already held as brand-new figures.
Match on the amiibo ID instead.
The ID identifies a model, not always a distinct figure:
- Skylanders light and dark variants share an ID and differ only in data.
Hammer Slam BowserandDark Hammer Slam Bowserare both0005ff00023a0702. - Animal Crossing Happy Home Designer item cards share a single ID
(
026a000100000502) across 91 distinct files.
So "same ID, different bytes" is a real and meaningful state. Report it rather than collapsing it.
From ntag_def.h, all seen in the wild:
| Bytes | Format |
|---|---|
| 540 | NTAG215, the standard full dump |
| 532 | TagMo |
| 572 | Thenaya |
532 is a truncation, so offset 84 still holds. 572 carries 32 extra bytes; the
tool tries offset 84 first and then 84 + 32, accepting whichever gives a
trailing ID byte of 0x02.
Releases from Kirby Air Riders (November 2025) onward use a different tag:
NXP NTAG I²C Plus 2K, dumping to 2048 bytes. The firmware already
anticipates the size as NTAG_I2C_2K_DATA_SIZE.
Two things break naive parsers:
The trailing ID byte is not always 0x02. These carry 0x03; it is an
amiibo format version, not a constant. Of the 932 database entries, 930 are
v2 and 2 are v3. A validity check of id[7] === 0x02 silently rejects the
entire series, so use the dump length instead.
The ID is still at byte 84. Confirmed against real dumps and against xSke's
page-level analysis, where pages 0x15–0x16 (= bytes 84–91) hold
1f030100 04c91e03. The 64 bytes of new data sit at 0x80–0xA0, after the
ID, so the offset is unaffected.
An Air Riders amiibo is two pieces: the character figure carries the tag, the vehicle acts as its antenna. The amiibo ID identifies the character only, so all four vehicles for one character share an ID.
The vehicle is in the tag's SRAM buffer at pages 0xF0–0xFF. Measured across
16 dumps (4 characters × 4 vehicles), files for one character differ only
within that range, by 21 to 22 bytes, and the signature is identical across
characters:
| Bytes 979–984 | Byte 988 | Vehicle |
|---|---|---|
PB4W17 |
0x02 |
Warp Star |
PB4W17 |
0x04 |
Winged Star |
PB5T42 |
0x04 |
Shadow Star |
PC6V28 |
0x04 |
Tank Star |
Bytes 975–978 vary per physical tag, so they are not part of the signature.
Consequences: four dumps of one character are not duplicates. They are distinct vehicle pairings sharing an ID. Any tool matching purely on amiibo ID must report same-ID-different-bytes rather than collapsing it.
Background and credit: AmiiboAPI issue #243, particularly xSke's write-up of the memory layout and SRAM protocol.