mirror of
https://github.com/valentderah/totk-pc-recomp.git
synced 2026-11-11 01:41:02 +00:00
TagInfo took the UID as dump bytes 0..6, so the check byte BCC0 (byte 3) replaced the UID's last byte. ModelInfo had the series and the figure type swapped, because the dump offsets were misread (0x57 is the figure type, 0x5A the series), and the model number in the tag's big-endian order, while the game reads it little-endian. The game tells figures of one character apart by that number, so scans gave the wrong rewards. The years of the common and register info dates go little-endian too. --probe-services checks TagInfo and ModelInfo of a Link (Archer) dump. The same fixes are in pull requests #519 (genres) and #582 (Gelomban6), which agree on the ModelInfo layout; their event and feature changes are not part of this. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
401 lines
13 KiB
C
401 lines
13 KiB
C
// Amiibo dump loader and Horizon OS NFP state emulation.
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
#include "amiibo.h"
|
|
#include "host.h"
|
|
#include "platform/platform.h"
|
|
|
|
static PlatMutex g_amiibo_lock = PLAT_MUTEX_INIT;
|
|
|
|
// 540-byte raw NTAG215 binary dump
|
|
static uint8_t g_dump[AMIIBO_DUMP_SIZE];
|
|
static bool g_has_dump = false;
|
|
static AmiiboInfo g_info;
|
|
|
|
static bool g_device_present = true;
|
|
static bool g_detecting = false;
|
|
static bool g_tag_present = false;
|
|
static bool g_mounted = false;
|
|
static bool g_auto_remove = true;
|
|
static float g_auto_remove_delay_s = 2.5f;
|
|
|
|
static uint64_t g_tap_timestamp_ns = 0;
|
|
static bool g_timer_active = false;
|
|
|
|
static bool amiibo_validate_dump(const uint8_t *data, uint64_t size) {
|
|
if (size != AMIIBO_DUMP_SIZE) {
|
|
return false;
|
|
}
|
|
|
|
// NTAG215 Capability Container (CC) at page 3 (offset 0x0C). An amiibo's is F1 10 FF EE, not the E1 of a plain
|
|
// NDEF tag (3dbrew, "Amiibo"); the CC is not encrypted, so encrypted dumps carry it too.
|
|
static const uint8_t amiibo_cc[4] = {0xF1, 0x10, 0xFF, 0xEE};
|
|
if (memcmp(data + 0x0C, amiibo_cc, sizeof amiibo_cc)) {
|
|
return false;
|
|
}
|
|
|
|
// UID sanity: UID (bytes 0..2 and 4..7) cannot be all zeroes or all 0xFF.
|
|
if ((data[0] == 0 && data[1] == 0 && data[2] == 0 && data[4] == 0 &&
|
|
data[5] == 0 && data[6] == 0 && data[7] == 0) ||
|
|
(data[0] == 0xFF && data[1] == 0xFF && data[2] == 0xFF && data[4] == 0xFF &&
|
|
data[5] == 0xFF && data[6] == 0xFF && data[7] == 0xFF)) {
|
|
return false;
|
|
}
|
|
|
|
// ISO 14443-3 / NTAG215 Bit Check Character (BCC) verification.
|
|
// Page 0 byte 3 (BCC0) = 0x88 ^ UID[0] ^ UID[1] ^ UID[2]
|
|
// Page 2 byte 0 (BCC1) = UID[3] ^ UID[4] ^ UID[5] ^ UID[6]
|
|
uint8_t bcc0 = (uint8_t)(0x88 ^ data[0] ^ data[1] ^ data[2]);
|
|
uint8_t bcc1 = (uint8_t)(data[4] ^ data[5] ^ data[6] ^ data[7]);
|
|
if (data[3] != bcc0 || data[8] != bcc1) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// The 7-byte UID of an NTAG215 dump: bytes 0..2, then 4..7. Byte 3 is the check byte BCC0.
|
|
static void copy_uid(uint8_t out[7], const uint8_t *dump) {
|
|
memcpy(out, dump, 3);
|
|
memcpy(out + 3, dump + 4, 4);
|
|
}
|
|
|
|
static void parse_dump_info(const char *path) {
|
|
memset(&g_info, 0, sizeof(g_info));
|
|
if (path) {
|
|
snprintf(g_info.path, sizeof(g_info.path), "%s", path);
|
|
const char *slash = strrchr(path, '\\');
|
|
const char *fslash = strrchr(path, '/');
|
|
const char *name = slash ? slash + 1 : (fslash ? fslash + 1 : path);
|
|
if (slash && fslash) {
|
|
name = (slash > fslash ? slash : fslash) + 1;
|
|
}
|
|
snprintf(g_info.filename, sizeof(g_info.filename), "%s", name);
|
|
}
|
|
|
|
copy_uid(g_info.uuid, g_dump);
|
|
g_info.uuid_len = 7;
|
|
|
|
// Amiibo ID at offset 0x54 (8 bytes, 3dbrew "Amiibo"): character ID and variant, figure type, model number
|
|
// (big-endian), series, format version 02
|
|
memcpy(g_info.character_id, g_dump + 0x54, 3);
|
|
g_info.nfp_type = g_dump[0x57];
|
|
g_info.numbering_id = ((uint16_t)g_dump[0x58] << 8) | g_dump[0x59];
|
|
g_info.series_id = g_dump[0x5A];
|
|
g_info.version = g_dump[0x5B];
|
|
}
|
|
|
|
void amiibo_init(void) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
g_device_present = true;
|
|
g_detecting = false;
|
|
g_tag_present = false;
|
|
g_mounted = false;
|
|
g_has_dump = false;
|
|
g_timer_active = false;
|
|
memset(g_dump, 0, sizeof(g_dump));
|
|
memset(&g_info, 0, sizeof(g_info));
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
}
|
|
|
|
void amiibo_set_device_present(bool present) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
g_device_present = present;
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
}
|
|
|
|
bool amiibo_is_device_present(void) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
bool res = g_device_present;
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
return res;
|
|
}
|
|
|
|
// While the game waits for a tag it stops and starts detection again several times a second (tens with the menu open),
|
|
// so the log names the first few and then a count every 200th start.
|
|
static uint64_t g_detect_starts;
|
|
static uint64_t g_detect_stop_ns; // the last stop: the menu holds "searching" across the game's restarts
|
|
|
|
void amiibo_start_detection(void) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
g_detecting = true;
|
|
uint64_t n = ++g_detect_starts;
|
|
if (n <= 3) rt_log("amiibo: start detection");
|
|
else if (n % 200 == 0) rt_log("amiibo: detection started %llu times so far", (unsigned long long)n);
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
}
|
|
|
|
void amiibo_stop_detection(void) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
if (g_detecting) g_detect_stop_ns = plat_now_ns();
|
|
g_detecting = false;
|
|
if (g_detect_starts <= 3) rt_log("amiibo: stop detection");
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
}
|
|
|
|
bool amiibo_is_detecting(void) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
bool res = g_detecting;
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
return res;
|
|
}
|
|
|
|
bool amiibo_mount(void) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
if (!g_device_present || !g_tag_present) {
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
return false;
|
|
}
|
|
g_mounted = true;
|
|
rt_log("amiibo: mounted tag '%s'", g_info.filename[0] ? g_info.filename : "unknown");
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
return true;
|
|
}
|
|
|
|
void amiibo_unmount(void) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
g_mounted = false;
|
|
rt_log("amiibo: unmounted tag");
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
}
|
|
|
|
bool amiibo_is_mounted(void) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
bool res = g_mounted;
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
return res;
|
|
}
|
|
|
|
AmiiboDeviceState amiibo_get_device_state(void) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
if (!g_device_present) {
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
return AMIIBO_DEV_STATE_UNAVAILABLE;
|
|
}
|
|
if (g_mounted) {
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
return AMIIBO_DEV_STATE_TAG_MOUNTED;
|
|
}
|
|
if (g_tag_present) {
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
return AMIIBO_DEV_STATE_TAG_FOUND;
|
|
}
|
|
if (g_detecting) {
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
return AMIIBO_DEV_STATE_SEARCHING;
|
|
}
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
return AMIIBO_DEV_STATE_INITIALIZED;
|
|
}
|
|
|
|
AmiiboDeviceState amiibo_get_reader_state(void) {
|
|
AmiiboDeviceState state = amiibo_get_device_state();
|
|
if (state != AMIIBO_DEV_STATE_INITIALIZED) return state;
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
bool recent = g_detect_stop_ns && plat_now_ns() - g_detect_stop_ns < 1000000000ull;
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
return recent ? AMIIBO_DEV_STATE_SEARCHING : state;
|
|
}
|
|
|
|
bool amiibo_load_file(const char *path) {
|
|
if (!path || !*path) return false;
|
|
uint64_t size = 0;
|
|
uint8_t *data = plat_file_read(path, &size);
|
|
if (!data) {
|
|
rt_log("amiibo: failed to read file: %s", path);
|
|
return false;
|
|
}
|
|
if (size != AMIIBO_DUMP_SIZE) {
|
|
rt_log("amiibo: invalid dump size (%llu bytes, expected exactly %d): %s",
|
|
(unsigned long long)size, AMIIBO_DUMP_SIZE, path);
|
|
free(data);
|
|
return false;
|
|
}
|
|
if (!amiibo_validate_dump(data, size)) {
|
|
rt_log("amiibo: dump failed NTAG215 structure sanity checks: %s", path);
|
|
free(data);
|
|
return false;
|
|
}
|
|
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
memcpy(g_dump, data, AMIIBO_DUMP_SIZE);
|
|
g_has_dump = true;
|
|
parse_dump_info(path);
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
|
|
free(data);
|
|
rt_log("amiibo: loaded dump '%s' (char_id: %02x%02x%02x, series: %02x, num: %04x)",
|
|
g_info.filename, g_info.character_id[0], g_info.character_id[1], g_info.character_id[2],
|
|
g_info.series_id, g_info.numbering_id);
|
|
return true;
|
|
}
|
|
|
|
void amiibo_tap_tag(void) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
if (!g_has_dump) {
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
return;
|
|
}
|
|
g_tag_present = true;
|
|
g_tap_timestamp_ns = plat_now_ns();
|
|
if (g_auto_remove) {
|
|
g_timer_active = true;
|
|
}
|
|
rt_log("amiibo: tag tapped on reader ('%s')", g_info.filename);
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
}
|
|
|
|
bool amiibo_scan_and_tap(const char *path) {
|
|
if (!amiibo_load_file(path)) return false;
|
|
amiibo_tap_tag();
|
|
return true;
|
|
}
|
|
|
|
void amiibo_remove_tag(void) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
if (g_tag_present || g_mounted) {
|
|
g_tag_present = false;
|
|
g_mounted = false;
|
|
g_timer_active = false;
|
|
rt_log("amiibo: tag removed from reader");
|
|
}
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
}
|
|
|
|
void amiibo_tick(void) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
if (g_timer_active && g_tag_present) {
|
|
uint64_t now = plat_now_ns();
|
|
uint64_t diff = now > g_tap_timestamp_ns ? now - g_tap_timestamp_ns : 0;
|
|
uint64_t limit_ns = (uint64_t)(g_auto_remove_delay_s * 1000000000.0f);
|
|
if (diff >= limit_ns) {
|
|
g_tag_present = false;
|
|
g_mounted = false;
|
|
g_timer_active = false;
|
|
rt_log("amiibo: auto-removed tag after %.1fs timeout", (double)g_auto_remove_delay_s);
|
|
}
|
|
}
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
}
|
|
|
|
void amiibo_set_auto_remove(bool enable) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
g_auto_remove = enable;
|
|
if (!enable) g_timer_active = false;
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
}
|
|
|
|
bool amiibo_get_auto_remove(void) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
bool res = g_auto_remove;
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
return res;
|
|
}
|
|
|
|
void amiibo_set_auto_remove_delay_s(float seconds) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
if (seconds >= 0.5f && seconds <= 10.0f) g_auto_remove_delay_s = seconds;
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
}
|
|
|
|
float amiibo_get_auto_remove_delay_s(void) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
float res = g_auto_remove_delay_s;
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
return res;
|
|
}
|
|
|
|
bool amiibo_has_tag(void) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
bool res = g_has_dump && g_tag_present;
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
return res;
|
|
}
|
|
|
|
const AmiiboInfo *amiibo_get_current_info(void) {
|
|
return &g_info;
|
|
}
|
|
|
|
const uint8_t *amiibo_get_raw_data(void) {
|
|
return g_dump;
|
|
}
|
|
|
|
void amiibo_get_tag_info(void *out_tag_info) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
uint8_t *out = (uint8_t *)out_tag_info;
|
|
memset(out, 0, 0x58);
|
|
// UUID: offset 0x00, 10 bytes max
|
|
copy_uid(out, g_dump);
|
|
out[0x0A] = 7; // UUID length
|
|
// Protocol at offset 0x20: TypeA = 1
|
|
*(uint32_t *)(out + 0x20) = 1;
|
|
// TagType at offset 0x24: Type2 / NTAG215 = 2
|
|
*(uint32_t *)(out + 0x24) = 2;
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
}
|
|
|
|
void amiibo_get_model_info(void *out_model_info) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
uint8_t *out = (uint8_t *)out_model_info;
|
|
memset(out, 0, 0x40);
|
|
// nn::nfp::ModelInfo from the amiibo ID at 0x54: character ID and variant in the tag's byte order, series, model
|
|
// number little-endian (big-endian on the tag), figure type. The game tells figures of one character apart by the
|
|
// model number.
|
|
memcpy(out + 0x00, g_dump + 0x54, 3); // character ID, variant
|
|
out[0x03] = g_dump[0x5A]; // series
|
|
out[0x04] = g_dump[0x59]; // model number
|
|
out[0x05] = g_dump[0x58];
|
|
out[0x06] = g_dump[0x57]; // figure type
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
}
|
|
|
|
void amiibo_get_common_info(void *out_common_info) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
uint8_t *out = (uint8_t *)out_common_info;
|
|
memset(out, 0, 0x40);
|
|
// Last write date: 0x00..0x03
|
|
out[0x00] = 0xE7; out[0x01] = 0x07; // 2023, u16 little-endian
|
|
out[0x02] = 0x05; out[0x03] = 0x0C; // May 12
|
|
// Write counter: 0x04..0x05
|
|
out[0x04] = 0x00; out[0x05] = 0x01;
|
|
// Version: 0x06
|
|
out[0x06] = 0x02;
|
|
// Application area size: 0x08..0x0B = 0xD8 (216 bytes)
|
|
*(uint32_t *)(out + 0x08) = 0xD8;
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
}
|
|
|
|
void amiibo_get_register_info(void *out_register_info) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
uint8_t *out = (uint8_t *)out_register_info;
|
|
memset(out, 0, 0x100);
|
|
// Mii CharInfo at 0x00..0x57 (dummy valid mii)
|
|
out[0x00] = 0x03; // version 3
|
|
// First write date at 0x58..0x5B
|
|
out[0x58] = 0xE7; out[0x59] = 0x07; // 2023, u16 little-endian
|
|
out[0x5A] = 0x05; out[0x5B] = 0x0C;
|
|
// Amiibo Name at 0x5C: copy up to 40 bytes NUL-terminated
|
|
const char *name = g_info.filename[0] ? g_info.filename : "Amiibo";
|
|
snprintf((char *)(out + 0x5C), 0x29, "%s", name);
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
}
|
|
|
|
int amiibo_get_application_area(void *out_buf, uint32_t max_size) {
|
|
plat_mutex_lock(&g_amiibo_lock);
|
|
if (!g_has_dump || !out_buf) {
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
return 0;
|
|
}
|
|
uint32_t to_copy = max_size < 0xD8 ? max_size : 0xD8;
|
|
// Decrypted/payload application area starts at offset 0xDC (or 0x0A0..0x178 in raw dump)
|
|
if (0xDC + to_copy <= AMIIBO_DUMP_SIZE) {
|
|
memcpy(out_buf, g_dump + 0xDC, to_copy);
|
|
} else {
|
|
memset(out_buf, 0, to_copy);
|
|
}
|
|
plat_mutex_unlock(&g_amiibo_lock);
|
|
return (int)to_copy;
|
|
}
|