diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt index e2943ab2dd..030ed4e445 100644 --- a/android/app/src/main/cpp/CMakeLists.txt +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -950,6 +950,7 @@ add_library(xemu SHARED xemu_android.cpp android_crash_handler.cpp vmstate_if_android.c + xemu_hdd_tools_jni.c ) target_compile_definitions(xemu PRIVATE ANDROID __ANDROID__ XBOX CONFIG_SDL CONFIG_OPENGL) diff --git a/android/app/src/main/cpp/xemu_hdd_tools_jni.c b/android/app/src/main/cpp/xemu_hdd_tools_jni.c new file mode 100644 index 0000000000..c4a28469dd --- /dev/null +++ b/android/app/src/main/cpp/xemu_hdd_tools_jni.c @@ -0,0 +1,819 @@ +#include "qemu/osdep.h" +#include "qapi/error.h" +#include "qemu/error-report.h" +#include "qemu/cutils.h" +#include "qemu/main-loop.h" +#include "qemu/module.h" +#include "qemu/sockets.h" +#include "qobject/qdict.h" +#include "crypto/init.h" +#include "exec/cpu-common.h" +#include "system/cpus.h" +#include "block/block-common.h" +#include "block/block-global-state.h" +#include "system/block-backend-global-state.h" +#include "system/block-backend-io.h" + +#include +#include + +#define ANDROID_XBOX_HDD_MINIMUM_RETAIL_DISK_BYTES 0x1DD156000LL + +#define ANDROID_XBOX_HDD_QCOW2_MAGIC 0x514649FBU +#define ANDROID_XBOX_HDD_SECTOR_SIZE_BYTES 512ULL + +#define ANDROID_XBOX_HDD_REFURB_OFFSET 0x600ULL +#define ANDROID_XBOX_HDD_REFURB_SIGNATURE 0x42524652U + +#define ANDROID_XBOX_HDD_FATX_SIGNATURE 0x58544146U +#define ANDROID_XBOX_HDD_FATX_SUPERBLOCK_SIZE 4096ULL +#define ANDROID_XBOX_HDD_FATX_FAT_OFFSET ANDROID_XBOX_HDD_FATX_SUPERBLOCK_SIZE +#define ANDROID_XBOX_HDD_FATX_RESERVED_ENTRY_COUNT 1ULL +#define ANDROID_XBOX_HDD_FATX_RETAIL_CLUSTER_SIZE (16ULL * 1024ULL) +#define ANDROID_XBOX_HDD_FATX_NON_RETAIL_SECTORS_PER_CLUSTER 128U +#define ANDROID_XBOX_HDD_FATX_END_OF_DIR_MARKER 0xFFU + +#define ANDROID_XBOX_HDD_FATX_CLUSTER_MEDIA_16 0xFFF8U +#define ANDROID_XBOX_HDD_FATX_CLUSTER_END_16 0xFFFFU +#define ANDROID_XBOX_HDD_FATX_CLUSTER_MEDIA_32 0xFFFFFFF8U +#define ANDROID_XBOX_HDD_FATX_CLUSTER_END_32 0xFFFFFFFFU + +#define ANDROID_XBOX_HDD_EXTENDED_PARTITION_OFFSET 0x1DD156000ULL +#define ANDROID_XBOX_HDD_G_PARTITION_OFFSET \ + (0x0FFFFFFFULL * ANDROID_XBOX_HDD_SECTOR_SIZE_BYTES) + +typedef enum AndroidXboxHddLayout { + ANDROID_XBOX_HDD_LAYOUT_RETAIL = 0, + ANDROID_XBOX_HDD_LAYOUT_RETAIL_PLUS_F = 1, + ANDROID_XBOX_HDD_LAYOUT_RETAIL_PLUS_F_G = 2, +} AndroidXboxHddLayout; + +typedef enum AndroidXboxHddFormat { + ANDROID_XBOX_HDD_FORMAT_RAW = 0, + ANDROID_XBOX_HDD_FORMAT_QCOW2 = 1, +} AndroidXboxHddFormat; + +typedef struct AndroidXboxHddPartition { + char letter; + uint64_t offset; + uint64_t size; +} AndroidXboxHddPartition; + +typedef struct AndroidXboxHddPartitionFormat { + uint32_t volume_id; + uint32_t sectors_per_cluster; + uint64_t bytes_per_cluster; + bool fat_is_16_bit; + uint64_t fat_length_bytes; + uint64_t cluster_offset; +} AndroidXboxHddPartitionFormat; + +static const AndroidXboxHddPartition g_android_xbox_hdd_retail_partitions[] = { + { 'X', 0x00080000ULL, 0x02EE00000ULL }, + { 'Y', 0x2EE80000ULL, 0x02EE00000ULL }, + { 'Z', 0x5DC80000ULL, 0x02EE00000ULL }, + { 'C', 0x8CA80000ULL, 0x01F400000ULL }, + { 'E', 0xABE80000ULL, 0x1312D6000ULL }, +}; + +static pthread_mutex_t g_android_xbox_hdd_init_lock = PTHREAD_MUTEX_INITIALIZER; +static bool g_android_xbox_hdd_init_complete; +static char *g_android_xbox_hdd_init_failure; + +static void android_xbox_hdd_throw_exception(JNIEnv *env, + const char *class_name, + const char *message) +{ + jclass exception_class; + + if ((*env)->ExceptionCheck(env)) { + return; + } + + exception_class = (*env)->FindClass(env, class_name); + if (!exception_class) { + (*env)->ExceptionClear(env); + exception_class = (*env)->FindClass(env, "java/lang/RuntimeException"); + if (!exception_class) { + return; + } + } + + (*env)->ThrowNew(env, exception_class, message ? message : "Unknown error"); +} + +static void android_xbox_hdd_throw_error(JNIEnv *env, + const char *class_name, + const char *prefix, + Error *err) +{ + char *message; + + if (err) { + if (prefix) { + message = g_strdup_printf("%s%s", prefix, error_get_pretty(err)); + } else { + message = g_strdup(error_get_pretty(err)); + } + error_free(err); + } else { + message = g_strdup(prefix ? prefix : "Unknown error"); + } + + android_xbox_hdd_throw_exception(env, class_name, message); + g_free(message); +} + +static bool android_xbox_hdd_ensure_block_layer(Error **errp) +{ + Error *local_err = NULL; + + pthread_mutex_lock(&g_android_xbox_hdd_init_lock); + + if (g_android_xbox_hdd_init_complete) { + pthread_mutex_unlock(&g_android_xbox_hdd_init_lock); + return true; + } + + if (g_android_xbox_hdd_init_failure) { + error_setg(errp, "%s", g_android_xbox_hdd_init_failure); + pthread_mutex_unlock(&g_android_xbox_hdd_init_lock); + return false; + } + + if (socket_init() < 0) { + error_setg(&local_err, "socket initialization failed"); + goto fail; + } + + error_init("xemu-android-hdd"); + qemu_init_cpu_list(); + qemu_init_cpu_loop(); + module_call_init(MODULE_INIT_TRACE); + qemu_init_exec_dir("xemu-android-hdd"); + + if (qemu_init_main_loop(&local_err) < 0) { + goto fail; + } + if (local_err) { + goto fail; + } + + if (qcrypto_init(&local_err) < 0) { + goto fail; + } + if (local_err) { + goto fail; + } + + { + BQL_LOCK_GUARD(); + module_call_init(MODULE_INIT_QOM); + bdrv_init(); + } + + g_android_xbox_hdd_init_complete = true; + pthread_mutex_unlock(&g_android_xbox_hdd_init_lock); + return true; + +fail: + g_android_xbox_hdd_init_failure = g_strdup( + local_err ? error_get_pretty(local_err) : "Unknown block layer initialization failure"); + error_propagate(errp, local_err); + pthread_mutex_unlock(&g_android_xbox_hdd_init_lock); + return false; +} + +static bool android_xbox_hdd_sniff_format(const char *path, + AndroidXboxHddFormat *format, + Error **errp) +{ + FILE *file; + uint8_t header[4]; + size_t bytes_read; + uint32_t magic = 0; + + file = fopen(path, "rb"); + if (!file) { + error_setg(errp, "Could not read HDD image header: %s", g_strerror(errno)); + return false; + } + + bytes_read = fread(header, 1, sizeof(header), file); + fclose(file); + + if (bytes_read == sizeof(header)) { + magic = ((uint32_t)header[0] << 24) | + ((uint32_t)header[1] << 16) | + ((uint32_t)header[2] << 8) | + (uint32_t)header[3]; + } + + *format = (magic == ANDROID_XBOX_HDD_QCOW2_MAGIC) + ? ANDROID_XBOX_HDD_FORMAT_QCOW2 + : ANDROID_XBOX_HDD_FORMAT_RAW; + return true; +} + +static BlockBackend *android_xbox_hdd_open_backend(const char *path, + bool writable, + Error **errp) +{ + QDict *options = qdict_new(); + int flags = BDRV_O_NO_BACKING; + + if (writable) { + flags |= BDRV_O_RDWR; + } + + return blk_new_open(path, NULL, options, flags, errp); +} + +static bool android_xbox_hdd_inspect_locked(const char *path, + AndroidXboxHddFormat *format, + int64_t *total_bytes, + Error **errp) +{ + BlockBackend *blk = NULL; + int64_t length; + bool ok = false; + + if (!android_xbox_hdd_sniff_format(path, format, errp)) { + return false; + } + + blk = android_xbox_hdd_open_backend(path, false, errp); + if (!blk) { + return false; + } + + length = blk_getlength(blk); + if (length < 0) { + error_setg(errp, "Could not determine the HDD size: %s", g_strerror(-length)); + goto cleanup; + } + + *total_bytes = length; + ok = true; + +cleanup: + if (blk) { + blk_drain(blk); + blk_unref(blk); + } + return ok; +} + +static uint32_t android_xbox_hdd_retail_sectors_per_cluster(void) +{ + return (uint32_t)(ANDROID_XBOX_HDD_FATX_RETAIL_CLUSTER_SIZE / + ANDROID_XBOX_HDD_SECTOR_SIZE_BYTES); +} + +static uint64_t android_xbox_hdd_aligned_extended_bytes(uint64_t total_bytes) +{ + if (total_bytes <= ANDROID_XBOX_HDD_EXTENDED_PARTITION_OFFSET) { + return 0; + } + + return ((total_bytes - ANDROID_XBOX_HDD_EXTENDED_PARTITION_OFFSET) / + ANDROID_XBOX_HDD_SECTOR_SIZE_BYTES) * + ANDROID_XBOX_HDD_SECTOR_SIZE_BYTES; +} + +static bool android_xbox_hdd_calculate_standard_fg_layout(uint64_t total_bytes, + uint64_t *f_size, + uint64_t *g_size) +{ + uint64_t aligned_total_bytes = + (total_bytes / ANDROID_XBOX_HDD_SECTOR_SIZE_BYTES) * + ANDROID_XBOX_HDD_SECTOR_SIZE_BYTES; + + if (aligned_total_bytes <= ANDROID_XBOX_HDD_G_PARTITION_OFFSET || + ANDROID_XBOX_HDD_G_PARTITION_OFFSET <= ANDROID_XBOX_HDD_EXTENDED_PARTITION_OFFSET) { + *f_size = 0; + *g_size = 0; + return false; + } + + /* + * Match the classic Xbox "67" layout that most BIOSes and dashboards + * expect: F consumes the space up to the 28-bit LBA boundary and G + * starts exactly at that boundary. + */ + *f_size = ANDROID_XBOX_HDD_G_PARTITION_OFFSET - + ANDROID_XBOX_HDD_EXTENDED_PARTITION_OFFSET; + *g_size = aligned_total_bytes - ANDROID_XBOX_HDD_G_PARTITION_OFFSET; + return *g_size > 0; +} + +static bool android_xbox_hdd_build_partition_format( + const AndroidXboxHddPartition *partition, + int partition_index, + uint32_t sectors_per_cluster, + AndroidXboxHddPartitionFormat *format, + Error **errp) +{ + uint64_t fat_entries; + uint64_t fat_length_bytes; + uint64_t bytes_per_cluster; + + bytes_per_cluster = (uint64_t)sectors_per_cluster * ANDROID_XBOX_HDD_SECTOR_SIZE_BYTES; + if (bytes_per_cluster == 0) { + error_setg(errp, "Invalid cluster size for partition %c", partition->letter); + return false; + } + if (partition->size == 0) { + error_setg(errp, "Partition %c has no usable space", partition->letter); + return false; + } + + fat_entries = partition->size / bytes_per_cluster; + fat_entries += ANDROID_XBOX_HDD_FATX_RESERVED_ENTRY_COUNT; + + format->fat_is_16_bit = fat_entries < 0xFFF0ULL; + fat_length_bytes = fat_entries * (format->fat_is_16_bit ? 2ULL : 4ULL); + if (fat_length_bytes % ANDROID_XBOX_HDD_FATX_SUPERBLOCK_SIZE != 0) { + fat_length_bytes += ANDROID_XBOX_HDD_FATX_SUPERBLOCK_SIZE - + (fat_length_bytes % ANDROID_XBOX_HDD_FATX_SUPERBLOCK_SIZE); + } + if (ANDROID_XBOX_HDD_FATX_FAT_OFFSET + fat_length_bytes + bytes_per_cluster > + partition->size) { + error_setg(errp, "Partition %c is too small for the selected layout", + partition->letter); + return false; + } + + format->volume_id = g_random_int() ^ + (uint32_t)partition_index ^ + (uint32_t)(unsigned char)partition->letter; + format->sectors_per_cluster = sectors_per_cluster; + format->bytes_per_cluster = bytes_per_cluster; + format->fat_length_bytes = fat_length_bytes; + format->cluster_offset = partition->offset + + ANDROID_XBOX_HDD_FATX_FAT_OFFSET + + fat_length_bytes; + return true; +} + +static bool android_xbox_hdd_can_format_partition(uint64_t size, + uint32_t sectors_per_cluster) +{ + AndroidXboxHddPartition partition = { + .letter = '?', + .offset = 0, + .size = size, + }; + AndroidXboxHddPartitionFormat format; + Error *err = NULL; + bool ok; + + ok = android_xbox_hdd_build_partition_format(&partition, 0, + sectors_per_cluster, + &format, &err); + error_free(err); + return ok; +} + +static bool android_xbox_hdd_layout_supported(uint64_t total_bytes, + AndroidXboxHddLayout layout) +{ + uint64_t extended_bytes; + uint64_t f_size; + uint64_t g_size; + + if (total_bytes < ANDROID_XBOX_HDD_MINIMUM_RETAIL_DISK_BYTES) { + return false; + } + + extended_bytes = android_xbox_hdd_aligned_extended_bytes(total_bytes); + + switch (layout) { + case ANDROID_XBOX_HDD_LAYOUT_RETAIL: + return true; + case ANDROID_XBOX_HDD_LAYOUT_RETAIL_PLUS_F: + return android_xbox_hdd_can_format_partition( + extended_bytes, ANDROID_XBOX_HDD_FATX_NON_RETAIL_SECTORS_PER_CLUSTER); + case ANDROID_XBOX_HDD_LAYOUT_RETAIL_PLUS_F_G: + if (!android_xbox_hdd_calculate_standard_fg_layout(total_bytes, &f_size, &g_size)) { + return false; + } + return android_xbox_hdd_can_format_partition( + f_size, ANDROID_XBOX_HDD_FATX_NON_RETAIL_SECTORS_PER_CLUSTER) && + android_xbox_hdd_can_format_partition( + g_size, ANDROID_XBOX_HDD_FATX_NON_RETAIL_SECTORS_PER_CLUSTER); + default: + return false; + } +} + +static void android_xbox_hdd_write_le16(uint8_t *buffer, size_t offset, uint16_t value) +{ + buffer[offset] = (uint8_t)(value & 0xFFU); + buffer[offset + 1] = (uint8_t)((value >> 8) & 0xFFU); +} + +static void android_xbox_hdd_write_le32(uint8_t *buffer, size_t offset, uint32_t value) +{ + buffer[offset] = (uint8_t)(value & 0xFFU); + buffer[offset + 1] = (uint8_t)((value >> 8) & 0xFFU); + buffer[offset + 2] = (uint8_t)((value >> 16) & 0xFFU); + buffer[offset + 3] = (uint8_t)((value >> 24) & 0xFFU); +} + +static bool android_xbox_hdd_write_bytes(BlockBackend *blk, + uint64_t offset, + const void *buffer, + uint64_t size, + const char *label, + Error **errp) +{ + int ret = blk_pwrite(blk, (int64_t)offset, (int64_t)size, buffer, 0); + + if (ret < 0) { + error_setg(errp, "Failed to write %s at 0x%" PRIx64 ": %s", + label, offset, g_strerror(-ret)); + return false; + } + + return true; +} + +static bool android_xbox_hdd_zero_range(BlockBackend *blk, + uint64_t offset, + uint64_t length, + Error **errp) +{ + static const size_t chunk_size = 64 * 1024; + uint8_t *zeroes; + uint64_t remaining = length; + uint64_t current_offset = offset; + bool ok = true; + + if (length == 0) { + return true; + } + + zeroes = g_malloc0(chunk_size); + while (remaining > 0) { + uint64_t bytes_to_write = MIN((uint64_t)chunk_size, remaining); + if (!android_xbox_hdd_write_bytes(blk, current_offset, zeroes, + bytes_to_write, "zeroed FAT region", errp)) { + ok = false; + break; + } + current_offset += bytes_to_write; + remaining -= bytes_to_write; + } + g_free(zeroes); + return ok; +} + +static bool android_xbox_hdd_write_refurb_info(BlockBackend *blk, Error **errp) +{ + uint8_t buffer[16] = { 0 }; + + android_xbox_hdd_write_le32(buffer, 0, ANDROID_XBOX_HDD_REFURB_SIGNATURE); + return android_xbox_hdd_write_bytes(blk, ANDROID_XBOX_HDD_REFURB_OFFSET, + buffer, sizeof(buffer), + "refurb info", errp); +} + +static bool android_xbox_hdd_write_superblock( + BlockBackend *blk, + const AndroidXboxHddPartition *partition, + const AndroidXboxHddPartitionFormat *format, + Error **errp) +{ + uint8_t *superblock = g_malloc(ANDROID_XBOX_HDD_FATX_SUPERBLOCK_SIZE); + bool ok; + + memset(superblock, 0xFF, ANDROID_XBOX_HDD_FATX_SUPERBLOCK_SIZE); + android_xbox_hdd_write_le32(superblock, 0, ANDROID_XBOX_HDD_FATX_SIGNATURE); + android_xbox_hdd_write_le32(superblock, 4, format->volume_id); + android_xbox_hdd_write_le32(superblock, 8, format->sectors_per_cluster); + android_xbox_hdd_write_le32(superblock, 12, 1); + android_xbox_hdd_write_le16(superblock, 16, 0); + + ok = android_xbox_hdd_write_bytes(blk, partition->offset, superblock, + ANDROID_XBOX_HDD_FATX_SUPERBLOCK_SIZE, + "FATX superblock", errp); + g_free(superblock); + return ok; +} + +static bool android_xbox_hdd_write_initial_fat_entries( + BlockBackend *blk, + const AndroidXboxHddPartition *partition, + const AndroidXboxHddPartitionFormat *format, + Error **errp) +{ + uint8_t entries[8] = { 0 }; + uint64_t bytes = format->fat_is_16_bit ? 4 : 8; + + if (format->fat_is_16_bit) { + android_xbox_hdd_write_le16(entries, 0, ANDROID_XBOX_HDD_FATX_CLUSTER_MEDIA_16); + android_xbox_hdd_write_le16(entries, 2, ANDROID_XBOX_HDD_FATX_CLUSTER_END_16); + } else { + android_xbox_hdd_write_le32(entries, 0, ANDROID_XBOX_HDD_FATX_CLUSTER_MEDIA_32); + android_xbox_hdd_write_le32(entries, 4, ANDROID_XBOX_HDD_FATX_CLUSTER_END_32); + } + + return android_xbox_hdd_write_bytes( + blk, + partition->offset + ANDROID_XBOX_HDD_FATX_FAT_OFFSET, + entries, + bytes, + "initial FAT entries", + errp); +} + +static bool android_xbox_hdd_fill_root_directory( + BlockBackend *blk, + const AndroidXboxHddPartitionFormat *format, + Error **errp) +{ + uint8_t *root_data = g_malloc(format->bytes_per_cluster); + bool ok; + + memset(root_data, ANDROID_XBOX_HDD_FATX_END_OF_DIR_MARKER, + format->bytes_per_cluster); + ok = android_xbox_hdd_write_bytes(blk, format->cluster_offset, root_data, + format->bytes_per_cluster, + "root directory cluster", errp); + g_free(root_data); + return ok; +} + +static bool android_xbox_hdd_format_partition( + BlockBackend *blk, + const AndroidXboxHddPartition *partition, + int partition_index, + uint32_t sectors_per_cluster, + Error **errp) +{ + AndroidXboxHddPartitionFormat format; + bool ok = false; + + if (!android_xbox_hdd_build_partition_format(partition, partition_index, + sectors_per_cluster, &format, errp)) { + return false; + } + + if (!android_xbox_hdd_write_superblock(blk, partition, &format, errp)) { + goto cleanup; + } + + if (!android_xbox_hdd_zero_range(blk, + partition->offset + ANDROID_XBOX_HDD_FATX_FAT_OFFSET, + format.fat_length_bytes, + errp)) { + goto cleanup; + } + + if (!android_xbox_hdd_write_initial_fat_entries(blk, partition, &format, errp)) { + goto cleanup; + } + + if (!android_xbox_hdd_fill_root_directory(blk, &format, errp)) { + goto cleanup; + } + + ok = true; + +cleanup: + if (!ok && errp && *errp) { + error_prepend(errp, "Partition %c: ", partition->letter); + } + return ok; +} + +static bool android_xbox_hdd_initialize_locked(const char *path, + AndroidXboxHddLayout layout, + Error **errp) +{ + BlockBackend *blk = NULL; + int64_t total_bytes; + uint64_t extended_bytes; + uint64_t f_size; + uint64_t g_size; + int ret; + bool ok = false; + + blk = android_xbox_hdd_open_backend(path, true, errp); + if (!blk) { + return false; + } + + total_bytes = blk_getlength(blk); + if (total_bytes < 0) { + error_setg(errp, "Could not determine the HDD size: %s", g_strerror(-total_bytes)); + goto cleanup; + } + + if (total_bytes < ANDROID_XBOX_HDD_MINIMUM_RETAIL_DISK_BYTES) { + error_setg(errp, "The current HDD image is smaller than a stock Xbox disk"); + goto cleanup; + } + + if (!android_xbox_hdd_layout_supported((uint64_t)total_bytes, layout)) { + error_setg(errp, + "The current HDD image does not have enough usable space for the selected layout"); + goto cleanup; + } + + if (!android_xbox_hdd_write_refurb_info(blk, errp)) { + goto cleanup; + } + + for (size_t i = 0; i < G_N_ELEMENTS(g_android_xbox_hdd_retail_partitions); ++i) { + if (!android_xbox_hdd_format_partition( + blk, + &g_android_xbox_hdd_retail_partitions[i], + (int)i, + android_xbox_hdd_retail_sectors_per_cluster(), + errp)) { + goto cleanup; + } + } + + extended_bytes = android_xbox_hdd_aligned_extended_bytes((uint64_t)total_bytes); + switch (layout) { + case ANDROID_XBOX_HDD_LAYOUT_RETAIL: + break; + case ANDROID_XBOX_HDD_LAYOUT_RETAIL_PLUS_F: { + AndroidXboxHddPartition partition_f = { + .letter = 'F', + .offset = ANDROID_XBOX_HDD_EXTENDED_PARTITION_OFFSET, + .size = extended_bytes, + }; + if (!android_xbox_hdd_format_partition( + blk, + &partition_f, + (int)G_N_ELEMENTS(g_android_xbox_hdd_retail_partitions), + ANDROID_XBOX_HDD_FATX_NON_RETAIL_SECTORS_PER_CLUSTER, + errp)) { + goto cleanup; + } + break; + } + case ANDROID_XBOX_HDD_LAYOUT_RETAIL_PLUS_F_G: { + AndroidXboxHddPartition partition_f; + AndroidXboxHddPartition partition_g; + + if (!android_xbox_hdd_calculate_standard_fg_layout( + (uint64_t)total_bytes, &f_size, &g_size)) { + error_setg(errp, + "The current HDD image does not have enough usable space for a standard F + G layout"); + goto cleanup; + } + partition_f.letter = 'F'; + partition_f.offset = ANDROID_XBOX_HDD_EXTENDED_PARTITION_OFFSET; + partition_f.size = f_size; + partition_g.letter = 'G'; + partition_g.offset = ANDROID_XBOX_HDD_G_PARTITION_OFFSET; + partition_g.size = g_size; + + if (!android_xbox_hdd_format_partition( + blk, + &partition_f, + (int)G_N_ELEMENTS(g_android_xbox_hdd_retail_partitions), + ANDROID_XBOX_HDD_FATX_NON_RETAIL_SECTORS_PER_CLUSTER, + errp)) { + goto cleanup; + } + if (!android_xbox_hdd_format_partition( + blk, + &partition_g, + (int)G_N_ELEMENTS(g_android_xbox_hdd_retail_partitions) + 1, + ANDROID_XBOX_HDD_FATX_NON_RETAIL_SECTORS_PER_CLUSTER, + errp)) { + goto cleanup; + } + break; + } + default: + error_setg(errp, "Unknown HDD layout"); + goto cleanup; + } + + ret = blk_flush(blk); + if (ret < 0) { + error_setg(errp, "Failed to flush HDD image writes: %s", g_strerror(-ret)); + goto cleanup; + } + + ok = true; + +cleanup: + if (blk) { + blk_drain(blk); + blk_unref(blk); + } + return ok; +} + +JNIEXPORT jlongArray JNICALL +Java_com_izzy2lost_x1box_XboxHddFormatter_00024NativeBridge_nativeInspectHdd( + JNIEnv *env, jobject obj, jstring jpath) +{ + const char *path; + Error *err = NULL; + AndroidXboxHddFormat format; + int64_t total_bytes = 0; + jlongArray result = NULL; + jlong values[2]; + + (void)obj; + + if (!jpath) { + android_xbox_hdd_throw_exception(env, "java/io/IOException", + "No local HDD image is configured."); + return NULL; + } + + path = (*env)->GetStringUTFChars(env, jpath, NULL); + if (!path) { + return NULL; + } + + if (!android_xbox_hdd_ensure_block_layer(&err)) { + (*env)->ReleaseStringUTFChars(env, jpath, path); + android_xbox_hdd_throw_error(env, "java/io/IOException", + "Failed to initialize HDD tools: ", err); + return NULL; + } + + { + BQL_LOCK_GUARD(); + if (!android_xbox_hdd_inspect_locked(path, &format, &total_bytes, &err)) { + (*env)->ReleaseStringUTFChars(env, jpath, path); + android_xbox_hdd_throw_error(env, "java/io/IOException", NULL, err); + return NULL; + } + } + + (*env)->ReleaseStringUTFChars(env, jpath, path); + + values[0] = (jlong)format; + values[1] = (jlong)total_bytes; + result = (*env)->NewLongArray(env, 2); + if (!result) { + return NULL; + } + (*env)->SetLongArrayRegion(env, result, 0, 2, values); + return result; +} + +JNIEXPORT void JNICALL +Java_com_izzy2lost_x1box_XboxHddFormatter_00024NativeBridge_nativeInitializeHdd( + JNIEnv *env, jobject obj, jstring jpath, jint layout_ordinal) +{ + const char *path; + Error *err = NULL; + AndroidXboxHddLayout layout; + + (void)obj; + + if (!jpath) { + android_xbox_hdd_throw_exception(env, "java/io/IOException", + "No local HDD image is configured."); + return; + } + + switch (layout_ordinal) { + case ANDROID_XBOX_HDD_LAYOUT_RETAIL: + case ANDROID_XBOX_HDD_LAYOUT_RETAIL_PLUS_F: + case ANDROID_XBOX_HDD_LAYOUT_RETAIL_PLUS_F_G: + layout = (AndroidXboxHddLayout)layout_ordinal; + break; + default: + android_xbox_hdd_throw_exception(env, + "java/lang/IllegalArgumentException", + "Unknown HDD layout"); + return; + } + + path = (*env)->GetStringUTFChars(env, jpath, NULL); + if (!path) { + return; + } + + if (!android_xbox_hdd_ensure_block_layer(&err)) { + (*env)->ReleaseStringUTFChars(env, jpath, path); + android_xbox_hdd_throw_error(env, "java/io/IOException", + "Failed to initialize HDD tools: ", err); + return; + } + + { + BQL_LOCK_GUARD(); + if (!android_xbox_hdd_initialize_locked(path, layout, &err)) { + (*env)->ReleaseStringUTFChars(env, jpath, path); + android_xbox_hdd_throw_error(env, "java/io/IOException", NULL, err); + return; + } + } + + (*env)->ReleaseStringUTFChars(env, jpath, path); +} diff --git a/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt index 8ae2cd3c6c..de898fbd6d 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.provider.OpenableColumns +import android.text.format.Formatter import android.widget.ArrayAdapter import android.widget.AutoCompleteTextView import android.widget.TextView @@ -81,9 +82,11 @@ class SettingsActivity : AppCompatActivity() { private var pendingVulkanUri: String? = null private var pendingVulkanName: String? = null private var clearVulkan = false + private var isInitializingHdd = false private lateinit var tvVulkanDriverName: TextView private lateinit var tvEepromStatus: TextView + private lateinit var tvHddToolsStatus: TextView private lateinit var inputEepromLanguage: TextInputLayout private lateinit var inputEepromVideoStandard: TextInputLayout private lateinit var inputEepromAspectRatio: TextInputLayout @@ -141,10 +144,12 @@ class SettingsActivity : AppCompatActivity() { val btnSave = findViewById(R.id.btn_settings_save) val btnRedoSetup = findViewById(R.id.btn_redo_setup_wizard) val btnClearCache = findViewById(R.id.btn_clear_system_cache) + val btnInitializeRetailHdd = findViewById(R.id.btn_initialize_retail_hdd) tvVulkanDriverName = findViewById(R.id.tv_vulkan_driver_name) val btnVulkanBrowse = findViewById(R.id.btn_vulkan_browse) val btnVulkanClear = findViewById(R.id.btn_vulkan_clear) tvEepromStatus = findViewById(R.id.tv_eeprom_status) + tvHddToolsStatus = findViewById(R.id.tv_hdd_tools_status) inputEepromLanguage = findViewById(R.id.input_eeprom_language) inputEepromVideoStandard = findViewById(R.id.input_eeprom_video_standard) inputEepromAspectRatio = findViewById(R.id.input_eeprom_aspect_ratio) @@ -242,6 +247,7 @@ class SettingsActivity : AppCompatActivity() { } setupEepromEditor() + refreshHddToolsPreview(btnInitializeRetailHdd) fun persistSettings(): Pair { val selectedDisplayMode = when (toggleDisplayMode.checkedButtonId) { @@ -315,6 +321,10 @@ class SettingsActivity : AppCompatActivity() { showClearCacheConfirmation() } + btnInitializeRetailHdd.setOnClickListener { + showInitializeHddLayoutPicker(btnInitializeRetailHdd) + } + btnSave.setOnClickListener { val toastResult = persistSettings() Toast.makeText(this, toastResult.first, toastResult.second).show() @@ -538,6 +548,216 @@ class SettingsActivity : AppCompatActivity() { .show() } + private fun showInitializeHddLayoutPicker(button: MaterialButton) { + val hddFile = resolveHddFile() + if (hddFile == null) { + refreshHddToolsPreview(button) + return + } + + val inspection = runCatching { XboxHddFormatter.inspect(hddFile) }.getOrElse { error -> + tvHddToolsStatus.text = getString( + R.string.settings_hdd_status_error, + error.message ?: hddFile.absolutePath, + ) + button.isEnabled = false + return + } + + if (!inspection.supportsRetailFormat) { + refreshHddToolsState(button) + return + } + + val supportedLayouts = XboxHddFormatter.supportedLayouts(inspection).toSet() + if (supportedLayouts.isEmpty()) { + refreshHddToolsState(button) + return + } + + val allLayouts = XboxHddFormatter.Layout.entries + val labels = allLayouts + .map { layout -> + val label = getString(hddLayoutLabelRes(layout)) + val availability = XboxHddFormatter.availabilityFor(inspection, layout) + if (availability == XboxHddFormatter.LayoutAvailability.AVAILABLE) { + label + } else { + getString( + R.string.settings_hdd_layout_unavailable_format, + label, + getString(hddLayoutUnavailableReasonRes(availability)), + ) + } + } + .toTypedArray() + MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Xemu_RoundedDialog) + .setTitle(R.string.settings_hdd_layout_pick_title) + .setItems(labels) { _, which -> + val layout = allLayouts[which] + val availability = XboxHddFormatter.availabilityFor(inspection, layout) + if (availability == XboxHddFormatter.LayoutAvailability.AVAILABLE) { + showInitializeHddConfirmation(hddFile, layout, button) + } else { + MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Xemu_RoundedDialog) + .setTitle(R.string.settings_hdd_layout_unavailable_title) + .setMessage(getString(hddLayoutUnavailableReasonRes(availability))) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun showInitializeHddConfirmation( + hddFile: File, + layout: XboxHddFormatter.Layout, + button: MaterialButton, + ) { + MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Xemu_RoundedDialog) + .setTitle(R.string.settings_hdd_init_title) + .setMessage( + getString( + R.string.settings_hdd_init_message, + getString(hddLayoutLabelRes(layout)), + getString(hddLayoutSummaryRes(layout)), + hddFile.absolutePath, + ) + ) + .setPositiveButton(R.string.settings_hdd_init_action) { _, _ -> + initializeHddLayout(hddFile, layout, button) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun initializeHddLayout( + hddFile: File, + layout: XboxHddFormatter.Layout, + button: MaterialButton, + ) { + if (isInitializingHdd) { + return + } + + isInitializingHdd = true + button.isEnabled = false + Toast.makeText(this, R.string.settings_hdd_init_working, Toast.LENGTH_SHORT).show() + + Thread { + val result = runCatching { + XboxHddFormatter.initialize(hddFile, layout) + } + + runOnUiThread { + isInitializingHdd = false + refreshHddToolsState(button) + result.onSuccess { + Toast.makeText(this, R.string.settings_hdd_init_success, Toast.LENGTH_SHORT).show() + }.onFailure { error -> + Toast.makeText( + this, + getString( + R.string.settings_hdd_init_failed, + error.message ?: hddFile.absolutePath, + ), + Toast.LENGTH_LONG, + ).show() + } + } + }.start() + } + + private fun refreshHddToolsState(button: MaterialButton) { + val hddFile = resolveHddFile() + if (hddFile == null) { + tvHddToolsStatus.text = getString(R.string.settings_hdd_status_missing) + button.isEnabled = false + return + } + + val inspection = runCatching { XboxHddFormatter.inspect(hddFile) }.getOrElse { error -> + tvHddToolsStatus.text = getString( + R.string.settings_hdd_status_error, + error.message ?: hddFile.absolutePath, + ) + button.isEnabled = false + return + } + + val sizeLabel = Formatter.formatFileSize(this, inspection.totalBytes) + val formatLabel = getString(hddFormatLabelRes(inspection.format)) + tvHddToolsStatus.text = when { + inspection.totalBytes < XboxHddFormatter.MINIMUM_RETAIL_DISK_BYTES -> getString( + R.string.settings_hdd_status_too_small, + formatLabel, + sizeLabel, + hddFile.absolutePath, + ) + else -> getString( + R.string.settings_hdd_status_ready, + formatLabel, + sizeLabel, + hddFile.absolutePath, + ) + } + button.isEnabled = !isInitializingHdd && XboxHddFormatter.supportedLayouts(inspection).isNotEmpty() + } + + private fun refreshHddToolsPreview(button: MaterialButton) { + val hddFile = resolveHddFile() + if (hddFile == null || !hddFile.isFile) { + tvHddToolsStatus.text = getString(R.string.settings_hdd_status_missing) + button.isEnabled = false + return + } + + tvHddToolsStatus.text = getString( + R.string.settings_hdd_status_configured, + hddFile.absolutePath, + ) + button.isEnabled = !isInitializingHdd + } + + private fun hddFormatLabelRes(format: XboxHddFormatter.ImageFormat): Int { + return when (format) { + XboxHddFormatter.ImageFormat.RAW -> R.string.settings_hdd_format_raw + XboxHddFormatter.ImageFormat.QCOW2 -> R.string.settings_hdd_format_qcow2 + } + } + + private fun hddLayoutLabelRes(layout: XboxHddFormatter.Layout): Int { + return when (layout) { + XboxHddFormatter.Layout.RETAIL -> R.string.settings_hdd_layout_retail + XboxHddFormatter.Layout.RETAIL_PLUS_F -> R.string.settings_hdd_layout_retail_f + XboxHddFormatter.Layout.RETAIL_PLUS_F_G -> R.string.settings_hdd_layout_retail_f_g + } + } + + private fun hddLayoutSummaryRes(layout: XboxHddFormatter.Layout): Int { + return when (layout) { + XboxHddFormatter.Layout.RETAIL -> R.string.settings_hdd_layout_summary_retail + XboxHddFormatter.Layout.RETAIL_PLUS_F -> R.string.settings_hdd_layout_summary_retail_f + XboxHddFormatter.Layout.RETAIL_PLUS_F_G -> R.string.settings_hdd_layout_summary_retail_f_g + } + } + + private fun hddLayoutUnavailableReasonRes( + availability: XboxHddFormatter.LayoutAvailability, + ): Int { + return when (availability) { + XboxHddFormatter.LayoutAvailability.AVAILABLE -> + R.string.settings_hdd_layout_unavailable_not_enough_space + XboxHddFormatter.LayoutAvailability.NO_EXTENDED_SPACE -> + R.string.settings_hdd_layout_unavailable_no_extended_space + XboxHddFormatter.LayoutAvailability.NEEDS_STANDARD_G_BOUNDARY -> + R.string.settings_hdd_layout_unavailable_needs_standard_g_boundary + XboxHddFormatter.LayoutAvailability.NOT_ENOUGH_SPACE -> + R.string.settings_hdd_layout_unavailable_not_enough_space + } + } + private fun clearSystemCache(): CacheClearResult { var result = CacheClearResult(0, false) @@ -632,6 +852,12 @@ class SettingsActivity : AppCompatActivity() { return File(File(base, "x1box"), "eeprom.bin") } + private fun resolveHddFile(): File? { + val path = prefs.getString("hddPath", null) ?: return null + val file = File(path) + return file.takeIf { it.isFile } + } + private fun getFileName(uri: Uri): String? { return contentResolver.query(uri, null, null, null, null)?.use { cursor -> val col = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) diff --git a/android/app/src/main/java/com/izzy2lost/x1box/XboxHddFormatter.kt b/android/app/src/main/java/com/izzy2lost/x1box/XboxHddFormatter.kt new file mode 100644 index 0000000000..2fa0986125 --- /dev/null +++ b/android/app/src/main/java/com/izzy2lost/x1box/XboxHddFormatter.kt @@ -0,0 +1,162 @@ +package com.izzy2lost.x1box + +import java.io.File +import java.io.IOException + +internal object XboxHddFormatter { + const val MINIMUM_RETAIL_DISK_BYTES = 0x1DD156000L + + private const val SECTOR_SIZE_BYTES = 512L + private const val FATX_SUPERBLOCK_SIZE = 4096 + private const val FATX_FAT_OFFSET = FATX_SUPERBLOCK_SIZE.toLong() + private const val FATX_RESERVED_ENTRY_COUNT = 1L + private const val FATX_NON_RETAIL_SECTORS_PER_CLUSTER = 128 + + private const val EXTENDED_PARTITION_OFFSET = 0x1DD156000L + private const val G_PARTITION_OFFSET = 0x0FFFFFFFL * SECTOR_SIZE_BYTES + + enum class Layout { + RETAIL, + RETAIL_PLUS_F, + RETAIL_PLUS_F_G, + } + + enum class LayoutAvailability { + AVAILABLE, + NO_EXTENDED_SPACE, + NEEDS_STANDARD_G_BOUNDARY, + NOT_ENOUGH_SPACE, + } + + enum class ImageFormat { + RAW, + QCOW2, + } + + data class Inspection( + val format: ImageFormat, + val totalBytes: Long, + ) { + val supportsRetailFormat: Boolean + get() = totalBytes >= MINIMUM_RETAIL_DISK_BYTES + + val extendedBytes: Long + get() = alignedExtendedBytes(totalBytes) + } + + @Throws(IOException::class) + fun inspect(file: File): Inspection { + require(file.isFile) { "No local HDD image is configured." } + + val details = NativeBridge.nativeInspectHdd(file.absolutePath) + require(details.size >= 2) { "Failed to inspect the current HDD image." } + + val format = ImageFormat.values().getOrNull(details[0].toInt()) + ?: throw IOException("Unsupported HDD image format.") + return Inspection( + format = format, + totalBytes = details[1], + ) + } + + fun supportedLayouts(inspection: Inspection): List { + if (!inspection.supportsRetailFormat) { + return emptyList() + } + + return Layout.entries.filter { availabilityFor(inspection, it) == LayoutAvailability.AVAILABLE } + } + + @Throws(IOException::class, IllegalArgumentException::class) + fun initialize(file: File, layout: Layout) { + require(file.isFile) { "No local HDD image is configured." } + NativeBridge.nativeInitializeHdd(file.absolutePath, layout.ordinal) + } + + @Throws(IOException::class, IllegalArgumentException::class) + fun initializeRetail(file: File) { + initialize(file, Layout.RETAIL) + } + + fun availabilityFor(inspection: Inspection, layout: Layout): LayoutAvailability { + if (!inspection.supportsRetailFormat) { + return LayoutAvailability.NOT_ENOUGH_SPACE + } + + return when (layout) { + Layout.RETAIL -> LayoutAvailability.AVAILABLE + Layout.RETAIL_PLUS_F -> { + if (inspection.extendedBytes <= 0L) { + LayoutAvailability.NO_EXTENDED_SPACE + } else if (canFormatExtendedPartition(inspection.extendedBytes)) { + LayoutAvailability.AVAILABLE + } else { + LayoutAvailability.NOT_ENOUGH_SPACE + } + } + Layout.RETAIL_PLUS_F_G -> { + val split = standardFgPartitionSizes(inspection.totalBytes) + ?: return LayoutAvailability.NEEDS_STANDARD_G_BOUNDARY + if (canFormatExtendedPartition(split.first) && + canFormatExtendedPartition(split.second) + ) { + LayoutAvailability.AVAILABLE + } else { + LayoutAvailability.NOT_ENOUGH_SPACE + } + } + } + } + + private fun canBuildPartition(size: Long, sectorsPerCluster: Int): Boolean { + val bytesPerCluster = sectorsPerCluster * SECTOR_SIZE_BYTES + if (bytesPerCluster <= 0L || size <= 0L) { + return false + } + + var fatEntries = size / bytesPerCluster + fatEntries += FATX_RESERVED_ENTRY_COUNT + + var fatLengthBytes = fatEntries * if (fatEntries < 0xFFF0) 2L else 4L + if (fatLengthBytes % FATX_SUPERBLOCK_SIZE != 0L) { + fatLengthBytes += FATX_SUPERBLOCK_SIZE - (fatLengthBytes % FATX_SUPERBLOCK_SIZE) + } + return FATX_FAT_OFFSET + fatLengthBytes + bytesPerCluster <= size + } + + private fun canFormatExtendedPartition(size: Long): Boolean { + return canBuildPartition(size, FATX_NON_RETAIL_SECTORS_PER_CLUSTER) + } + + private fun standardFgPartitionSizes(totalBytes: Long): Pair? { + val alignedTotalBytes = (totalBytes / SECTOR_SIZE_BYTES) * SECTOR_SIZE_BYTES + if (alignedTotalBytes <= G_PARTITION_OFFSET || G_PARTITION_OFFSET <= EXTENDED_PARTITION_OFFSET) { + return null + } + + val fSize = G_PARTITION_OFFSET - EXTENDED_PARTITION_OFFSET + val gSize = alignedTotalBytes - G_PARTITION_OFFSET + if (gSize <= 0L) { + return null + } + + return Pair(fSize, gSize) + } + + private fun alignedExtendedBytes(totalBytes: Long): Long { + if (totalBytes <= EXTENDED_PARTITION_OFFSET) { + return 0L + } + return ((totalBytes - EXTENDED_PARTITION_OFFSET) / SECTOR_SIZE_BYTES) * SECTOR_SIZE_BYTES + } + + private object NativeBridge { + init { + System.loadLibrary("SDL2") + System.loadLibrary("xemu") + } + + external fun nativeInspectHdd(path: String): LongArray + external fun nativeInitializeHdd(path: String, layoutOrdinal: Int) + } +} diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml index 932d2a0767..c1c34bb53b 100644 --- a/android/app/src/main/res/layout/activity_settings.xml +++ b/android/app/src/main/res/layout/activity_settings.xml @@ -599,6 +599,39 @@ android:textAppearance="@style/TextAppearance.Material3.BodySmall" android:textColor="@color/xemu_text_muted" /> + + + + + + + + Settings and EEPROM saved Settings saved. Launch a game once, then edit EEPROM. Settings saved, but EEPROM update failed + HDD Tools + Initializes the current local HDD image with a stock retail layout or optional extended F/G partitions. Raw and QCOW2 images are supported. This does not install a dashboard. + No local HDD image is configured. Pick one in the setup wizard first. + Current HDD image:\n%1$s\nTap Initialize HDD Layout to inspect the image and choose a layout. + Raw + QCOW2 + Current HDD image: %1$s (%2$s)\n%3$s + Current HDD image: %1$s (%2$s)\n%3$s\nThis image is smaller than a stock Xbox disk and cannot be initialized as retail. + Failed to inspect the current HDD image.\n%1$s + Choose HDD Layout + Retail Only (X, Y, Z, C, E) + Retail + F + Retail + F + G + Rewrites X, Y, Z, C, and E. + Rewrites X, Y, Z, C, E, and uses all remaining space as F. + Rewrites X, Y, Z, C, E, keeps F up to the standard 137 GB boundary, and uses the rest as G. + %1$s (%2$s) + Layout Unavailable + This HDD image only has enough room for the stock retail partitions. There is no extra space available for F or G. + Retail + F + G needs an HDD image larger than 128 GiB so G can start at the standard Xbox boundary. + This HDD image does not have enough usable space for that layout. + Initialize HDD Layout + Initialize the current HDD image with %1$s?\n\n%2$s\n\nImage: %3$s + Initialize HDD Layout + Initializing HDD... + HDD layout initialized + Failed to initialize HDD: %1$s Maintenance Clear Cache Clear emulator cache files now? This removes shader and app cache data, but keeps your games, saves, and HDD image.