From c8b373d6e2860c3d647bce65cb0ed48f5940b63d Mon Sep 17 00:00:00 2001 From: izzy2lost Date: Mon, 16 Mar 2026 11:35:09 -0400 Subject: [PATCH] online, HDD, and dashboard stuff --- android/app/src/main/cpp/CMakeLists.txt | 110 ++++- android/app/src/main/cpp/config-host.h | 1 + android/app/src/main/cpp/xemu_android.cpp | 9 +- android/app/src/main/cpp/xemu_fatx_import.c | 170 ++++++- android/app/src/main/cpp/xemu_fatx_import.h | 14 + android/app/src/main/cpp/xemu_hdd_tools_jni.c | 258 +++++++++++ .../app/src/main/cpp/xemu_settings_android.cc | 198 +++++++- .../izzy2lost/x1box/GameLibraryActivity.kt | 22 + .../com/izzy2lost/x1box/SettingsActivity.kt | 435 +++++++++++++++++- .../com/izzy2lost/x1box/XboxEepromEditor.kt | 30 ++ .../com/izzy2lost/x1box/XboxInsigniaHelper.kt | 102 ++++ .../main/res/layout/activity_game_library.xml | 8 + .../src/main/res/layout/activity_settings.xml | 144 +++++- android/app/src/main/res/values/strings.xml | 38 ++ 14 files changed, 1499 insertions(+), 40 deletions(-) create mode 100644 android/app/src/main/java/com/izzy2lost/x1box/XboxInsigniaHelper.kt diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt index 09bcba80c8..b9658a55df 100644 --- a/android/app/src/main/cpp/CMakeLists.txt +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -78,6 +78,83 @@ FetchContent_Declare( ) FetchContent_MakeAvailable(tomlplusplus) +# --- libslirp --- +set(LIBSLIRP_GIT_REV "26be815b86e8d49add8c9a8b320239b9594ff03d") +FetchContent_Declare( + libslirp + GIT_REPOSITORY "https://gitlab.freedesktop.org/slirp/libslirp.git" + GIT_TAG "${LIBSLIRP_GIT_REV}" + GIT_SHALLOW FALSE +) +FetchContent_GetProperties(libslirp) +if(NOT libslirp_POPULATED) + if(POLICY CMP0169) + cmake_policy(PUSH) + cmake_policy(SET CMP0169 OLD) + endif() + FetchContent_Populate(libslirp) + if(POLICY CMP0169) + cmake_policy(POP) + endif() +endif() + +set(LIBSLIRP_GENERATED_DIR "${CMAKE_BINARY_DIR}/libslirp-generated") +file(MAKE_DIRECTORY "${LIBSLIRP_GENERATED_DIR}") +file(STRINGS "${libslirp_SOURCE_DIR}/meson.build" LIBSLIRP_VERSION_LINE + REGEX "^[ ]*version : '[^']+'") +if(NOT LIBSLIRP_VERSION_LINE) + message(FATAL_ERROR "Failed to determine libslirp version from meson.build") +endif() +string(REGEX REPLACE ".*version : '([^']+)'.*" "\\1" + LIBSLIRP_VERSION "${LIBSLIRP_VERSION_LINE}") +string(REPLACE "." ";" LIBSLIRP_VERSION_COMPONENTS "${LIBSLIRP_VERSION}") +list(LENGTH LIBSLIRP_VERSION_COMPONENTS LIBSLIRP_VERSION_COMPONENT_COUNT) +if(NOT LIBSLIRP_VERSION_COMPONENT_COUNT EQUAL 3) + message(FATAL_ERROR "Unexpected libslirp version format: ${LIBSLIRP_VERSION}") +endif() +list(GET LIBSLIRP_VERSION_COMPONENTS 0 SLIRP_MAJOR_VERSION) +list(GET LIBSLIRP_VERSION_COMPONENTS 1 SLIRP_MINOR_VERSION) +list(GET LIBSLIRP_VERSION_COMPONENTS 2 SLIRP_MICRO_VERSION) +set(SLIRP_VERSION_STRING "\"${LIBSLIRP_VERSION}\"") +configure_file( + "${libslirp_SOURCE_DIR}/src/libslirp-version.h.in" + "${LIBSLIRP_GENERATED_DIR}/libslirp-version.h" + @ONLY +) +set(LIBSLIRP_SOURCES + "${libslirp_SOURCE_DIR}/src/arp_table.c" + "${libslirp_SOURCE_DIR}/src/bootp.c" + "${libslirp_SOURCE_DIR}/src/cksum.c" + "${libslirp_SOURCE_DIR}/src/dhcpv6.c" + "${libslirp_SOURCE_DIR}/src/dnssearch.c" + "${libslirp_SOURCE_DIR}/src/if.c" + "${libslirp_SOURCE_DIR}/src/ip6_icmp.c" + "${libslirp_SOURCE_DIR}/src/ip6_input.c" + "${libslirp_SOURCE_DIR}/src/ip6_output.c" + "${libslirp_SOURCE_DIR}/src/ip_icmp.c" + "${libslirp_SOURCE_DIR}/src/ip_input.c" + "${libslirp_SOURCE_DIR}/src/ip_output.c" + "${libslirp_SOURCE_DIR}/src/mbuf.c" + "${libslirp_SOURCE_DIR}/src/misc.c" + "${libslirp_SOURCE_DIR}/src/ncsi.c" + "${libslirp_SOURCE_DIR}/src/ndp_table.c" + "${libslirp_SOURCE_DIR}/src/sbuf.c" + "${libslirp_SOURCE_DIR}/src/slirp.c" + "${libslirp_SOURCE_DIR}/src/socket.c" + "${libslirp_SOURCE_DIR}/src/state.c" + "${libslirp_SOURCE_DIR}/src/stream.c" + "${libslirp_SOURCE_DIR}/src/tcp_input.c" + "${libslirp_SOURCE_DIR}/src/tcp_output.c" + "${libslirp_SOURCE_DIR}/src/tcp_subr.c" + "${libslirp_SOURCE_DIR}/src/tcp_timer.c" + "${libslirp_SOURCE_DIR}/src/tftp.c" + "${libslirp_SOURCE_DIR}/src/udp.c" + "${libslirp_SOURCE_DIR}/src/udp6.c" + "${libslirp_SOURCE_DIR}/src/util.c" + "${libslirp_SOURCE_DIR}/src/version.c" + "${libslirp_SOURCE_DIR}/src/vmstate.c" +) + if(XEMU_ENABLE_VULKAN) # --- Vulkan deps (volk, glslang, SPIRV-Reflect, VMA) --- set(VOLK_GIT_REV "0b17a763ba5643e32da1b2152f8140461b3b7345") @@ -517,7 +594,6 @@ list(FILTER XEMU_CORE_SOURCES EXCLUDE REGEX "ui[\\\\/]thirdparty[\\\\/]noc_file_ list(FILTER XEMU_CORE_SOURCES EXCLUDE REGEX "ui[\\\\/]x_keymap\\.c$") list(FILTER XEMU_CORE_SOURCES EXCLUDE REGEX "ui[\\\\/]vdagent\\.c$") list(FILTER XEMU_CORE_SOURCES EXCLUDE REGEX "ui[\\\\/]xemu-os-utils-windows\\.c$") -list(FILTER XEMU_CORE_SOURCES EXCLUDE REGEX "ui[\\\\/]xemu-net\\.c$") list(FILTER XEMU_CORE_SOURCES EXCLUDE REGEX "util[\\\\/]aio-win32\\.c$") list(FILTER XEMU_CORE_SOURCES EXCLUDE REGEX "util[\\\\/]atomic64\\.c$") list(FILTER XEMU_CORE_SOURCES EXCLUDE REGEX "util[\\\\/]coroutine-ucontext\\.c$") @@ -549,7 +625,6 @@ list(FILTER XEMU_CORE_SOURCES EXCLUDE REGEX "net[\\\\/]af-xdp\\.c$") list(FILTER XEMU_CORE_SOURCES EXCLUDE REGEX "net[\\\\/]netmap\\.c$") list(FILTER XEMU_CORE_SOURCES EXCLUDE REGEX "net[\\\\/]pcap\\.c$") list(FILTER XEMU_CORE_SOURCES EXCLUDE REGEX "net[\\\\/]passt\\.c$") -list(FILTER XEMU_CORE_SOURCES EXCLUDE REGEX "net[\\\\/]slirp\\.c$") list(FILTER XEMU_CORE_SOURCES EXCLUDE REGEX "net[\\\\/]tap-bsd\\.c$") list(FILTER XEMU_CORE_SOURCES EXCLUDE REGEX "net[\\\\/]tap-solaris\\.c$") list(FILTER XEMU_CORE_SOURCES EXCLUDE REGEX "net[\\\\/]tap-win32\\.c$") @@ -654,7 +729,6 @@ list(APPEND XEMU_CORE_SOURCES "${CMAKE_CURRENT_LIST_DIR}/watchdog_stub.c" "${CMAKE_CURRENT_LIST_DIR}/async_teardown_stub.c" "${CMAKE_CURRENT_LIST_DIR}/block_export_stub.c" - "${CMAKE_CURRENT_LIST_DIR}/xemu_net_stub.c" "${CMAKE_CURRENT_LIST_DIR}/xemu_notifications_stub.c" "${CMAKE_CURRENT_LIST_DIR}/xemu_snapshots_stub.c" "${CMAKE_CURRENT_LIST_DIR}/xemu_hud_stub.c" @@ -872,6 +946,7 @@ target_compile_definitions(xemu_core PRIVATE ANDROID __ANDROID__ XBOX + CONFIG_SLIRP CONFIG_SDL CONFIG_OPENGL $<$:CONFIG_VULKAN> @@ -906,6 +981,8 @@ target_include_directories(xemu_core PRIVATE "${CMAKE_CURRENT_LIST_DIR}" "${CMAKE_BINARY_DIR}" "${QAPI_GEN_DIR}" + "${libslirp_SOURCE_DIR}/src" + "${LIBSLIRP_GENERATED_DIR}" "${GLIB_INSTALL_DIR}/include/glib-2.0" "${GLIB_INSTALL_DIR}/lib/glib-2.0/include" "${tomlplusplus_SOURCE_DIR}/include" @@ -946,6 +1023,32 @@ set_target_properties(gthread-2.0 PROPERTIES IMPORTED_LOCATION "${GLIB_INSTALL_DIR}/lib/libgthread-2.0.a" ) +add_library(slirp_static STATIC + ${LIBSLIRP_SOURCES} +) +set_target_properties(slirp_static PROPERTIES + POSITION_INDEPENDENT_CODE ON +) +target_compile_definitions(slirp_static PRIVATE + BUILDING_LIBSLIRP + G_LOG_DOMAIN=\"Slirp\" + GLIB_STATIC_COMPILATION + GIO_STATIC_COMPILATION + GMODULE_STATIC_COMPILATION + GI_STATIC_COMPILATION + GOBJECT_STATIC_COMPILATION +) +target_include_directories(slirp_static PRIVATE + "${libslirp_SOURCE_DIR}/src" + "${LIBSLIRP_GENERATED_DIR}" + "${GLIB_INSTALL_DIR}/include/glib-2.0" + "${GLIB_INSTALL_DIR}/lib/glib-2.0/include" +) +target_link_libraries(slirp_static PRIVATE + glib-2.0 +) +add_dependencies(slirp_static glib_ep) + add_library(xemu SHARED xemu_android.cpp android_crash_handler.cpp @@ -1006,6 +1109,7 @@ endif() target_link_libraries(xemu PRIVATE xemu_core + slirp_static iconv_stub glib-2.0 gobject-2.0 diff --git a/android/app/src/main/cpp/config-host.h b/android/app/src/main/cpp/config-host.h index 4906602811..f3a37f6f55 100644 --- a/android/app/src/main/cpp/config-host.h +++ b/android/app/src/main/cpp/config-host.h @@ -8,6 +8,7 @@ #define CONFIG_SDL 1 #define CONFIG_SDL2 1 #define CONFIG_AUDIO_SDL 1 +#define CONFIG_SLIRP 1 #define CONFIG_AUDIO_DRIVERS "sdl", #define CONFIG_DEVICES "config-devices.h" diff --git a/android/app/src/main/cpp/xemu_android.cpp b/android/app/src/main/cpp/xemu_android.cpp index d95a1b2b4c..28f88c9241 100644 --- a/android/app/src/main/cpp/xemu_android.cpp +++ b/android/app/src/main/cpp/xemu_android.cpp @@ -725,6 +725,7 @@ struct EmulatorSettings { bool hard_fpu = true; bool vsync = true; bool skip_boot_anim = false; + bool network_enabled = false; }; struct SetupFiles { @@ -773,11 +774,13 @@ static bool WriteConfigToml(const std::string& config_path, toml::table* audio = EnsureTable(tbl, "audio"); toml::table* audio_vp = EnsureTable(*audio, "vp"); toml::table* android = EnsureTable(tbl, "android"); + toml::table* net = EnsureTable(tbl, "net"); + toml::table* net_nat = EnsureTable(*net, "nat"); toml::table* sys = EnsureTable(tbl, "sys"); toml::table* perf = EnsureTable(tbl, "perf"); toml::table* files = EnsureTable(*sys, "files"); if (!general || !display || !display_quality || !display_window || !audio || - !audio_vp || !android || !sys || !perf || !files) { + !audio_vp || !android || !net || !net_nat || !sys || !perf || !files) { LogErrorFmt("Failed to build config tables at %s", config_path.c_str()); return false; } @@ -826,6 +829,8 @@ static bool WriteConfigToml(const std::string& config_path, if (!android->contains("audio_driver")) { android->insert_or_assign("audio_driver", "openslES"); } + net->insert_or_assign("enable", settings.network_enabled); + net->insert_or_assign("backend", "nat"); sys->insert_or_assign( "mem_limit", settings.system_memory_mib == 128 ? "128" : "64"); @@ -965,6 +970,8 @@ static SetupFiles SyncSetupFiles() { emuSettings.hard_fpu = GetPrefBool(env, activity, "setting_hard_fpu", true); emuSettings.skip_boot_anim = GetPrefBool(env, activity, "setting_skip_boot_anim", false); + emuSettings.network_enabled = + GetPrefBool(env, activity, "setting_network_enable", false); { std::string tcgThread = GetPrefString(env, activity, "setting_tcg_thread"); if (tcgThread == "single") { diff --git a/android/app/src/main/cpp/xemu_fatx_import.c b/android/app/src/main/cpp/xemu_fatx_import.c index b268d22f81..185efed586 100644 --- a/android/app/src/main/cpp/xemu_fatx_import.c +++ b/android/app/src/main/cpp/xemu_fatx_import.c @@ -157,6 +157,38 @@ static void xemu_fatx_propagate_error(XemuFatxFs *fs, Error **errp, const char * } } +static bool xemu_fatx_ascii_equal_n(const char *left, const char *right, size_t len) +{ + size_t i; + + for (i = 0; i < len; ++i) { + if (!left[i] || !right[i]) { + return false; + } + if (g_ascii_tolower(left[i]) != g_ascii_tolower(right[i])) { + return false; + } + } + + return true; +} + +static bool xemu_fatx_ascii_equal(const char *left, const char *right) +{ + size_t left_len = strlen(left); + size_t right_len = strlen(right); + + return left_len == right_len && xemu_fatx_ascii_equal_n(left, right, left_len); +} + +static bool xemu_fatx_ascii_starts_with(const char *value, const char *prefix) +{ + size_t prefix_len = strlen(prefix); + + return strlen(value) >= prefix_len && + xemu_fatx_ascii_equal_n(value, prefix, prefix_len); +} + static bool xemu_fatx_read_bytes(BlockBackend *blk, uint64_t offset, void *buffer, @@ -785,7 +817,7 @@ static int xemu_fatx_open_dir(XemuFatxFs *fs, const char *path, XemuFatxDir *dir if (status == XEMU_FATX_STATUS_SUCCESS) { if ((attr.attributes & XEMU_FATX_ATTR_DIRECTORY) != 0 && strlen(dirent.filename) == len && - memcmp(dirent.filename, start, len) == 0) { + xemu_fatx_ascii_equal_n(dirent.filename, start, len)) { dir->cluster = attr.first_cluster; dir->entry = 0; break; @@ -817,7 +849,7 @@ static int xemu_fatx_get_attr_dir(XemuFatxFs *fs, while (1) { status = xemu_fatx_read_dir(fs, dir, dirent, attr); if (status == XEMU_FATX_STATUS_SUCCESS) { - if (strcmp(basename, dirent->filename) == 0) { + if (xemu_fatx_ascii_equal(basename, dirent->filename)) { return XEMU_FATX_STATUS_SUCCESS; } } else if (status == XEMU_FATX_STATUS_FILE_DELETED) { @@ -1283,7 +1315,7 @@ static int xemu_fatx_unlink(XemuFatxFs *fs, const char *path) while (1) { status = xemu_fatx_read_dir(fs, &dir, &entry, &attr); if (status == XEMU_FATX_STATUS_SUCCESS) { - if (strcmp(entry.filename, basename) == 0) { + if (xemu_fatx_ascii_equal(entry.filename, basename)) { break; } } else if (status == XEMU_FATX_STATUS_FILE_DELETED) { @@ -1692,6 +1724,138 @@ static bool xemu_dashboard_import_partition(BlockBackend *blk, return true; } +static bool xemu_fatx_probe_path(XemuFatxFs *fs, + const char *path, + bool *exists_out, + bool *is_dir_out) +{ + XemuFatxAttr attr; + int status = xemu_fatx_get_attr(fs, path, &attr); + + if (status == XEMU_FATX_STATUS_SUCCESS) { + if (exists_out) { + *exists_out = true; + } + if (is_dir_out) { + *is_dir_out = (attr.attributes & XEMU_FATX_ATTR_DIRECTORY) != 0; + } + return true; + } + + if (status == XEMU_FATX_STATUS_FILE_NOT_FOUND) { + if (exists_out) { + *exists_out = false; + } + if (is_dir_out) { + *is_dir_out = false; + } + return true; + } + + return false; +} + +static bool xemu_fatx_scan_root_dashboard_markers(XemuFatxFs *fs, + XemuFatxDashboardStatus *status, + Error **errp) +{ + XemuFatxDir dir; + XemuFatxDirent dirent; + XemuFatxAttr attr; + int scan_status; + + if (!status) { + return true; + } + + scan_status = xemu_fatx_open_dir(fs, "/", &dir); + if (scan_status != XEMU_FATX_STATUS_SUCCESS) { + xemu_fatx_propagate_error(fs, errp, NULL); + return false; + } + + while (1) { + scan_status = xemu_fatx_read_dir(fs, &dir, &dirent, &attr); + if (scan_status == XEMU_FATX_STATUS_SUCCESS) { + if ((attr.attributes & XEMU_FATX_ATTR_DIRECTORY) != 0) { + if (xemu_fatx_ascii_equal(dirent.filename, "xodash")) { + status->xodash_dir_present = true; + } else if (xemu_fatx_ascii_equal(dirent.filename, "audio")) { + status->audio_dir_present = true; + } else if (xemu_fatx_ascii_equal(dirent.filename, "fonts")) { + status->fonts_dir_present = true; + } else if (xemu_fatx_ascii_starts_with(dirent.filename, "xboxdashdata.")) { + status->xboxdashdata_dir_present = true; + } + } else { + if (xemu_fatx_ascii_equal(dirent.filename, "xboxdash.xbe")) { + status->xboxdash_xbe_present = true; + } else if (xemu_fatx_ascii_equal(dirent.filename, "msdash.xbe")) { + status->msdash_xbe_present = true; + } else if (xemu_fatx_ascii_equal(dirent.filename, "xbox.xtf")) { + status->xbox_xtf_present = true; + } + } + } else if (scan_status == XEMU_FATX_STATUS_FILE_DELETED) { + /* Skip. */ + } else if (scan_status == XEMU_FATX_STATUS_END_OF_DIR) { + return true; + } else { + xemu_fatx_propagate_error(fs, errp, NULL); + return false; + } + + scan_status = xemu_fatx_next_dir_entry(fs, &dir); + if (scan_status != XEMU_FATX_STATUS_SUCCESS) { + xemu_fatx_propagate_error(fs, errp, NULL); + return false; + } + } +} + +bool xemu_fatx_inspect_retail_dashboard(BlockBackend *blk, + XemuFatxDashboardStatus *status, + Error **errp) +{ + XemuFatxFs fs; + bool ok = true; + + if (status) { + memset(status, 0, sizeof(*status)); + } + + if (!xemu_fatx_open_fs(&fs, blk, + XEMU_XBOX_HDD_PARTITION_C_OFFSET, + XEMU_XBOX_HDD_PARTITION_C_SIZE, + errp)) { + return false; + } + + if (status) { + bool is_dir = false; + + ok = xemu_fatx_probe_path(&fs, "/xboxdash.xbe", + &status->xboxdash_xbe_present, NULL) && + xemu_fatx_probe_path(&fs, "/msdash.xbe", + &status->msdash_xbe_present, NULL) && + xemu_fatx_probe_path(&fs, "/xbox.xtf", + &status->xbox_xtf_present, NULL) && + xemu_fatx_probe_path(&fs, "/xodash", + &status->xodash_dir_present, &is_dir); + status->xodash_dir_present = status->xodash_dir_present && is_dir; + ok = ok && xemu_fatx_scan_root_dashboard_markers(&fs, status, errp); + } + + if (!ok) { + xemu_fatx_propagate_error(&fs, errp, NULL); + xemu_fatx_close_fs(&fs); + return false; + } + + xemu_fatx_close_fs(&fs); + return true; +} + bool xemu_fatx_import_dashboard(BlockBackend *blk, const char *source_root, const char *backup_root, diff --git a/android/app/src/main/cpp/xemu_fatx_import.h b/android/app/src/main/cpp/xemu_fatx_import.h index 4f830b4171..f02e05d623 100644 --- a/android/app/src/main/cpp/xemu_fatx_import.h +++ b/android/app/src/main/cpp/xemu_fatx_import.h @@ -6,9 +6,23 @@ #include +typedef struct XemuFatxDashboardStatus { + bool xboxdash_xbe_present; + bool msdash_xbe_present; + bool xbox_xtf_present; + bool xodash_dir_present; + bool audio_dir_present; + bool fonts_dir_present; + bool xboxdashdata_dir_present; +} XemuFatxDashboardStatus; + bool xemu_fatx_import_dashboard(BlockBackend *blk, const char *source_root, const char *backup_root, Error **errp); +bool xemu_fatx_inspect_retail_dashboard(BlockBackend *blk, + XemuFatxDashboardStatus *status, + Error **errp); + #endif diff --git a/android/app/src/main/cpp/xemu_hdd_tools_jni.c b/android/app/src/main/cpp/xemu_hdd_tools_jni.c index 569d785fca..cfe939112b 100644 --- a/android/app/src/main/cpp/xemu_hdd_tools_jni.c +++ b/android/app/src/main/cpp/xemu_hdd_tools_jni.c @@ -27,6 +27,16 @@ #define ANDROID_XBOX_HDD_REFURB_OFFSET 0x600ULL #define ANDROID_XBOX_HDD_REFURB_SIGNATURE 0x42524652U +#define ANDROID_XBOX_HDD_CONFIG_STATIC_PRIMARY_DNS_OFFSET 0x102CULL +#define ANDROID_XBOX_HDD_CONFIG_STATIC_SECONDARY_DNS_OFFSET 0x1030ULL +#define ANDROID_XBOX_HDD_CONFIG_XBLIVE_PRIMARY_DNS_OFFSET 0x1060ULL +#define ANDROID_XBOX_HDD_CONFIG_XBLIVE_SECONDARY_DNS_OFFSET 0x1064ULL +#define ANDROID_XBOX_HDD_CONFIG_DHCP_PRIMARY_DNS_OFFSET 0x1178ULL +#define ANDROID_XBOX_HDD_CONFIG_DHCP_SECONDARY_DNS_OFFSET 0x117CULL +#define ANDROID_XBOX_HDD_CONFIG_PPPOE_PRIMARY_DNS_OFFSET 0x118CULL +#define ANDROID_XBOX_HDD_CONFIG_PPPOE_SECONDARY_DNS_OFFSET 0x1190ULL +#define ANDROID_XBOX_HDD_CONFIG_MINIMUM_BYTES 0x1194ULL + #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 @@ -82,6 +92,13 @@ 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 bool android_xbox_hdd_write_bytes(BlockBackend *blk, + uint64_t offset, + const void *buffer, + uint64_t size, + const char *label, + Error **errp); + static void android_xbox_hdd_throw_exception(JNIEnv *env, const char *class_name, const char *message) @@ -231,6 +248,38 @@ static BlockBackend *android_xbox_hdd_open_backend(const char *path, return blk_new_open(path, NULL, options, flags, errp); } +static bool android_xbox_hdd_copy_ipv4(JNIEnv *env, + jbyteArray array, + uint8_t out[4], + const char *label) +{ + jbyte buffer[4]; + jsize length; + + if (!array) { + android_xbox_hdd_throw_exception(env, "java/lang/IllegalArgumentException", + label); + return false; + } + + length = (*env)->GetArrayLength(env, array); + if (length != 4) { + android_xbox_hdd_throw_exception(env, "java/lang/IllegalArgumentException", + label); + return false; + } + + (*env)->GetByteArrayRegion(env, array, 0, 4, buffer); + if ((*env)->ExceptionCheck(env)) { + return false; + } + + for (size_t i = 0; i < 4; ++i) { + out[i] = (uint8_t)buffer[i]; + } + return true; +} + static bool android_xbox_hdd_inspect_locked(const char *path, AndroidXboxHddFormat *format, int64_t *total_bytes, @@ -266,6 +315,119 @@ cleanup: return ok; } +static bool android_xbox_hdd_inspect_dashboard_locked(const char *path, + jint *flags_out, + Error **errp) +{ + BlockBackend *blk = NULL; + XemuFatxDashboardStatus status; + bool ok = false; + jint flags = 0; + + blk = android_xbox_hdd_open_backend(path, false, errp); + if (!blk) { + return false; + } + + if (!xemu_fatx_inspect_retail_dashboard(blk, &status, errp)) { + goto cleanup; + } + + if (status.xboxdash_xbe_present) { + flags |= 1 << 0; + } + if (status.xodash_dir_present) { + flags |= 1 << 1; + } + if (status.xbox_xtf_present) { + flags |= 1 << 2; + } + if (status.msdash_xbe_present) { + flags |= 1 << 3; + } + if (status.audio_dir_present) { + flags |= 1 << 4; + } + if (status.fonts_dir_present) { + flags |= 1 << 5; + } + if (status.xboxdashdata_dir_present) { + flags |= 1 << 6; + } + + if (flags_out) { + *flags_out = flags; + } + ok = true; + +cleanup: + if (blk) { + blk_drain(blk); + blk_unref(blk); + } + return ok; +} + +static bool android_xbox_hdd_write_insignia_dns_locked(const char *path, + const uint8_t primary_dns[4], + const uint8_t secondary_dns[4], + Error **errp) +{ + static const uint64_t primary_offsets[] = { + ANDROID_XBOX_HDD_CONFIG_STATIC_PRIMARY_DNS_OFFSET, + ANDROID_XBOX_HDD_CONFIG_XBLIVE_PRIMARY_DNS_OFFSET, + ANDROID_XBOX_HDD_CONFIG_DHCP_PRIMARY_DNS_OFFSET, + ANDROID_XBOX_HDD_CONFIG_PPPOE_PRIMARY_DNS_OFFSET, + }; + static const uint64_t secondary_offsets[] = { + ANDROID_XBOX_HDD_CONFIG_STATIC_SECONDARY_DNS_OFFSET, + ANDROID_XBOX_HDD_CONFIG_XBLIVE_SECONDARY_DNS_OFFSET, + ANDROID_XBOX_HDD_CONFIG_DHCP_SECONDARY_DNS_OFFSET, + ANDROID_XBOX_HDD_CONFIG_PPPOE_SECONDARY_DNS_OFFSET, + }; + BlockBackend *blk = NULL; + int64_t total_bytes; + 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 ((uint64_t)total_bytes < ANDROID_XBOX_HDD_CONFIG_MINIMUM_BYTES) { + error_setg(errp, "The current HDD image is too small to contain the Xbox config sector"); + goto cleanup; + } + + for (size_t i = 0; i < G_N_ELEMENTS(primary_offsets); ++i) { + if (!android_xbox_hdd_write_bytes(blk, primary_offsets[i], primary_dns, 4, + "Insignia primary DNS", errp) || + !android_xbox_hdd_write_bytes(blk, secondary_offsets[i], secondary_dns, 4, + "Insignia secondary DNS", errp)) { + goto cleanup; + } + } + + if (blk_flush(blk) < 0) { + error_setg(errp, "Failed to flush Insignia DNS writes"); + goto cleanup; + } + + 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 / @@ -820,6 +982,102 @@ Java_com_izzy2lost_x1box_XboxHddFormatter_00024NativeBridge_nativeInitializeHdd( (*env)->ReleaseStringUTFChars(env, jpath, path); } +JNIEXPORT jint JNICALL +Java_com_izzy2lost_x1box_XboxInsigniaHelper_00024NativeBridge_nativeInspectDashboardFlags( + JNIEnv *env, jobject obj, jstring jhdd_path) +{ + const char *hdd_path; + Error *err = NULL; + jint flags = 0; + + (void)obj; + + if (!jhdd_path) { + android_xbox_hdd_throw_exception(env, "java/io/IOException", + "No local HDD image is configured."); + return 0; + } + + hdd_path = (*env)->GetStringUTFChars(env, jhdd_path, NULL); + if (!hdd_path) { + return 0; + } + + if (!android_xbox_hdd_ensure_block_layer(&err)) { + (*env)->ReleaseStringUTFChars(env, jhdd_path, hdd_path); + android_xbox_hdd_throw_error(env, "java/io/IOException", + "Failed to initialize HDD tools: ", err); + return 0; + } + + { + BQL_LOCK_GUARD(); + if (!android_xbox_hdd_inspect_dashboard_locked(hdd_path, &flags, &err)) { + (*env)->ReleaseStringUTFChars(env, jhdd_path, hdd_path); + android_xbox_hdd_throw_error(env, "java/io/IOException", NULL, err); + return 0; + } + } + + (*env)->ReleaseStringUTFChars(env, jhdd_path, hdd_path); + return flags; +} + +JNIEXPORT void JNICALL +Java_com_izzy2lost_x1box_XboxInsigniaHelper_00024NativeBridge_nativeApplyConfigSectorDns( + JNIEnv *env, + jobject obj, + jstring jhdd_path, + jbyteArray jprimary_dns, + jbyteArray jsecondary_dns) +{ + const char *hdd_path; + uint8_t primary_dns[4]; + uint8_t secondary_dns[4]; + Error *err = NULL; + + (void)obj; + + if (!jhdd_path) { + android_xbox_hdd_throw_exception(env, "java/io/IOException", + "No local HDD image is configured."); + return; + } + + if (!android_xbox_hdd_copy_ipv4(env, jprimary_dns, primary_dns, + "Insignia primary DNS must be an IPv4 address.") || + !android_xbox_hdd_copy_ipv4(env, jsecondary_dns, secondary_dns, + "Insignia secondary DNS must be an IPv4 address.")) { + return; + } + + hdd_path = (*env)->GetStringUTFChars(env, jhdd_path, NULL); + if (!hdd_path) { + return; + } + + if (!android_xbox_hdd_ensure_block_layer(&err)) { + (*env)->ReleaseStringUTFChars(env, jhdd_path, hdd_path); + android_xbox_hdd_throw_error(env, "java/io/IOException", + "Failed to initialize HDD tools: ", err); + return; + } + + { + BQL_LOCK_GUARD(); + if (!android_xbox_hdd_write_insignia_dns_locked(hdd_path, + primary_dns, + secondary_dns, + &err)) { + (*env)->ReleaseStringUTFChars(env, jhdd_path, hdd_path); + android_xbox_hdd_throw_error(env, "java/io/IOException", NULL, err); + return; + } + } + + (*env)->ReleaseStringUTFChars(env, jhdd_path, hdd_path); +} + JNIEXPORT void JNICALL Java_com_izzy2lost_x1box_XboxDashboardImporter_00024NativeBridge_nativeImportDashboard( JNIEnv *env, jobject obj, jstring jhdd_path, jstring jsource_root, jstring jbackup_root) diff --git a/android/app/src/main/cpp/xemu_settings_android.cc b/android/app/src/main/cpp/xemu_settings_android.cc index 74fc8bf82d..7618b3849e 100644 --- a/android/app/src/main/cpp/xemu_settings_android.cc +++ b/android/app/src/main/cpp/xemu_settings_android.cc @@ -12,10 +12,14 @@ #include #include #include +#include +#include #include "xemu-settings.h" struct config g_config; +using NetNatForwardPort = + std::remove_pointer_t; static std::string settings_path_storage; static const char *settings_path; @@ -146,10 +150,79 @@ static bool parse_filtering(const std::string &value, CONFIG_DISPLAY_FILTERING * static std::string to_lower_ascii(std::string value) { - for (char &c : value) { - c = static_cast(std::tolower(static_cast(c))); + for (char &c : value) { + c = static_cast(std::tolower(static_cast(c))); + } + return value; +} + +static bool parse_network_backend(toml::node_view node, + CONFIG_NET_BACKEND *out) +{ + if (!out) { + return false; } - return value; + + if (auto backend = node.value()) { + std::string normalized = to_lower_ascii(*backend); + if (normalized == "nat" || normalized == "user") { + *out = CONFIG_NET_BACKEND_NAT; + return true; + } + if (normalized == "udp" || normalized == "udp_tunnel" || + normalized == "udp tunnel") { + *out = CONFIG_NET_BACKEND_UDP; + return true; + } + if (normalized == "pcap" || normalized == "bridge" || + normalized == "bridged adapter") { + *out = CONFIG_NET_BACKEND_PCAP; + return true; + } + return false; + } + + if (auto backend = node.value()) { + if (*backend < 0 || *backend >= CONFIG_NET_BACKEND__COUNT) { + return false; + } + *out = static_cast(*backend); + return true; + } + + return false; +} + +static bool parse_nat_forward_protocol( + toml::node_view node, + CONFIG_NET_NAT_FORWARD_PORTS_PROTOCOL *out) +{ + if (!out) { + return false; + } + + if (auto protocol = node.value()) { + std::string normalized = to_lower_ascii(*protocol); + if (normalized == "tcp") { + *out = CONFIG_NET_NAT_FORWARD_PORTS_PROTOCOL_TCP; + return true; + } + if (normalized == "udp") { + *out = CONFIG_NET_NAT_FORWARD_PORTS_PROTOCOL_UDP; + return true; + } + return false; + } + + if (auto protocol = node.value()) { + if (*protocol < 0 || *protocol >= CONFIG_NET_NAT_FORWARD_PORTS_PROTOCOL__COUNT) { + return false; + } + *out = static_cast(*protocol); + return true; + } + + return false; } const char *xemu_settings_get_error_message(void) @@ -240,6 +313,10 @@ bool xemu_settings_load(void) auto audio_vp = audio["vp"]; auto perf = tbl["perf"]; auto android_cfg = tbl["android"]; + auto net = tbl["net"]; + auto net_udp = net["udp"]; + auto net_pcap = net["pcap"]; + auto net_nat = net["nat"]; auto sys = tbl["sys"]; auto sys_files = sys["files"]; @@ -318,6 +395,69 @@ bool xemu_settings_load(void) g_config.audio.volume_limit = volume; } + if (auto net_enable = net["enable"].value()) { + g_config.net.enable = *net_enable; + } + { + CONFIG_NET_BACKEND parsed_backend; + if (parse_network_backend(net["backend"], &parsed_backend)) { + g_config.net.backend = parsed_backend; + } + } + if (auto bind_addr = net_udp["bind_addr"].value()) { + xemu_settings_set_string(&g_config.net.udp.bind_addr, bind_addr->c_str()); + } + if (auto remote_addr = net_udp["remote_addr"].value()) { + xemu_settings_set_string(&g_config.net.udp.remote_addr, remote_addr->c_str()); + } + if (auto netif = net_pcap["netif"].value()) { + xemu_settings_set_string(&g_config.net.pcap.netif, netif->c_str()); + } + if (auto forward_ports = net_nat["forward_ports"].as_array()) { + std::vector parsed_ports; + parsed_ports.reserve(forward_ports->size()); + + for (const auto &entry : *forward_ports) { + const toml::table *table = entry.as_table(); + if (!table) { + continue; + } + + auto host = (*table)["host"].value(); + auto guest = (*table)["guest"].value(); + if (!host || !guest || *host < 1 || *host > 65535 || + *guest < 1 || *guest > 65535) { + continue; + } + + CONFIG_NET_NAT_FORWARD_PORTS_PROTOCOL protocol = + CONFIG_NET_NAT_FORWARD_PORTS_PROTOCOL_TCP; + parse_nat_forward_protocol((*table)["protocol"], &protocol); + + NetNatForwardPort port = {}; + port.host = static_cast(*host); + port.guest = static_cast(*guest); + port.protocol = protocol; + parsed_ports.push_back(port); + } + + free(g_config.net.nat.forward_ports); + g_config.net.nat.forward_ports = NULL; + g_config.net.nat.forward_ports_count = 0; + + if (!parsed_ports.empty()) { + auto *ports = static_cast(calloc( + parsed_ports.size(), sizeof(NetNatForwardPort))); + if (ports) { + memcpy(ports, parsed_ports.data(), + parsed_ports.size() * sizeof(NetNatForwardPort)); + g_config.net.nat.forward_ports = ports; + g_config.net.nat.forward_ports_count = + static_cast(parsed_ports.size()); + } + } + } + // Android-specific settings if (auto egl_offscreen = android_cfg["egl_offscreen"].value()) { if (!*egl_offscreen) { @@ -456,14 +596,58 @@ void xemu_settings_save(void) void add_net_nat_forward_ports(int host, int guest, CONFIG_NET_NAT_FORWARD_PORTS_PROTOCOL protocol) { - (void)host; - (void)guest; - (void)protocol; + if (host < 1 || host > 65535 || guest < 1 || guest > 65535 || + protocol < 0 || protocol >= CONFIG_NET_NAT_FORWARD_PORTS_PROTOCOL__COUNT) { + return; + } + + unsigned int old_count = g_config.net.nat.forward_ports_count; + unsigned int new_count = old_count + 1; + auto *ports = static_cast(realloc( + g_config.net.nat.forward_ports, sizeof(NetNatForwardPort) * new_count)); + if (!ports) { + return; + } + + ports[old_count].host = host; + ports[old_count].guest = guest; + ports[old_count].protocol = protocol; + g_config.net.nat.forward_ports = ports; + g_config.net.nat.forward_ports_count = new_count; } void remove_net_nat_forward_ports(unsigned int index) { - (void)index; + if (index >= g_config.net.nat.forward_ports_count) { + return; + } + + unsigned int old_count = g_config.net.nat.forward_ports_count; + unsigned int new_count = old_count - 1; + auto *ports = + static_cast(g_config.net.nat.forward_ports); + + if (index + 1 < old_count) { + memmove(&ports[index], &ports[index + 1], + (old_count - index - 1) * sizeof(NetNatForwardPort)); + } + + if (new_count == 0) { + free(ports); + g_config.net.nat.forward_ports = NULL; + g_config.net.nat.forward_ports_count = 0; + return; + } + + auto *resized = static_cast(realloc( + ports, sizeof(NetNatForwardPort) * new_count)); + if (!resized) { + g_config.net.nat.forward_ports_count = new_count; + return; + } + + g_config.net.nat.forward_ports = resized; + g_config.net.nat.forward_ports_count = new_count; } bool xemu_settings_load_gamepad_mapping(const char *guid, diff --git a/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt index 3c7f9a0264..3a1a9256bc 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt @@ -257,6 +257,22 @@ class GameLibraryActivity : AppCompatActivity() { return } + resolveConfiguredLocalHddFile()?.let { hddFile -> + runCatching { XboxInsigniaHelper.inspectDashboard(hddFile) } + .getOrNull() + ?.let { dashboardStatus -> + if (!dashboardStatus.looksRetailDashboardInstalled) { + val messageRes = if (dashboardStatus.hasAnyRetailDashboardFiles) { + R.string.library_boot_dashboard_incomplete_retail + } else { + R.string.library_boot_dashboard_missing_retail + } + Toast.makeText(this, getString(messageRes), Toast.LENGTH_LONG).show() + return + } + } + } + // MainActivity runs in :xemu, so the disc selection must be flushed before // the other process reads SharedPreferences during startup. prefs.edit() @@ -285,6 +301,12 @@ class GameLibraryActivity : AppCompatActivity() { return uri != null && hasPersistedReadPermission(uri) } + private fun resolveConfiguredLocalHddFile(): File? { + val path = prefs.getString("hddPath", null) ?: return null + val file = File(path) + return file.takeIf { it.isFile } + } + private fun slotName(slot: Int) = "android_slot_$slot" private fun snapshotPreviewDir(): File = File(filesDir, "x1box/snapshots") 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 bf33099a9d..64cd9285fd 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt @@ -6,6 +6,7 @@ import android.net.Uri import android.os.Bundle import android.provider.OpenableColumns import android.text.format.Formatter +import android.view.View import android.widget.ArrayAdapter import android.widget.AutoCompleteTextView import android.widget.LinearLayout @@ -29,6 +30,13 @@ import java.util.Locale import java.util.zip.ZipInputStream class SettingsActivity : AppCompatActivity() { + companion object { + private const val PREF_ADVANCED_EXPERIMENTAL_EXPANDED = "settings_advanced_experimental_expanded" + private const val PREF_INSIGNIA_SETUP_URI = "setting_insignia_setup_assistant_uri" + private const val PREF_INSIGNIA_SETUP_NAME = "setting_insignia_setup_assistant_name" + private const val INSIGNIA_SIGN_UP_URL = "https://insignia.live/" + private const val INSIGNIA_GUIDE_URL = "https://insignia.live/guide/connect" + } private val prefs by lazy { getSharedPreferences("x1box_prefs", Context.MODE_PRIVATE) } @@ -74,6 +82,15 @@ class SettingsActivity : AppCompatActivity() { val retailBootReady: Boolean, ) + private data class InsigniaStatusSnapshot( + val hasLocalHdd: Boolean, + val hasEeprom: Boolean, + val dashboardStatus: XboxInsigniaHelper.DashboardStatus?, + val dashboardError: String?, + val setupAssistantName: String?, + val setupAssistantReady: Boolean, + ) + private val eepromLanguageOptions = listOf( EepromLanguageOption(XboxEepromEditor.Language.ENGLISH, R.string.settings_eeprom_language_english), EepromLanguageOption(XboxEepromEditor.Language.JAPANESE, R.string.settings_eeprom_language_japanese), @@ -110,11 +127,20 @@ class SettingsActivity : AppCompatActivity() { private var clearVulkan = false private var isInitializingHdd = false private var isImportingDashboard = false + private var isPreparingInsignia = false + private lateinit var switchNetworkEnable: MaterialSwitch private lateinit var tvVulkanDriverName: TextView + private lateinit var tvInsigniaStatus: TextView private lateinit var tvEepromStatus: TextView private lateinit var tvHddToolsStatus: TextView + private lateinit var btnToggleAdvancedExperimental: MaterialButton + private lateinit var btnInsigniaGuide: MaterialButton + private lateinit var btnInsigniaSignUp: MaterialButton + private lateinit var btnPrepareInsignia: MaterialButton + private lateinit var btnRegisterInsignia: MaterialButton private lateinit var btnImportDashboard: MaterialButton + private lateinit var layoutAdvancedExperimentalContent: LinearLayout private lateinit var inputEepromLanguage: TextInputLayout private lateinit var inputEepromVideoStandard: TextInputLayout private lateinit var inputEepromAspectRatio: TextInputLayout @@ -162,6 +188,23 @@ class SettingsActivity : AppCompatActivity() { prepareDashboardImportFromFolder(uri) } + private val pickInsigniaSetupAssistant = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> + uri ?: return@registerForActivityResult + persistUriPermission(uri) + + val name = getFileName(uri) + ?: uri.lastPathSegment + ?: getString(R.string.settings_insignia_setup_source_unknown) + prefs.edit() + .putString(PREF_INSIGNIA_SETUP_URI, uri.toString()) + .putString(PREF_INSIGNIA_SETUP_NAME, name) + .apply() + + refreshInsigniaStatus() + launchInsigniaSetupAssistant(uri) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_settings) @@ -191,7 +234,15 @@ class SettingsActivity : AppCompatActivity() { 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) + switchNetworkEnable = findViewById(R.id.switch_network_enable) + tvInsigniaStatus = findViewById(R.id.tv_insignia_status) + btnToggleAdvancedExperimental = findViewById(R.id.btn_toggle_advanced_experimental) + btnInsigniaGuide = findViewById(R.id.btn_insignia_guide) + btnInsigniaSignUp = findViewById(R.id.btn_insignia_sign_up) + btnPrepareInsignia = findViewById(R.id.btn_prepare_insignia) + btnRegisterInsignia = findViewById(R.id.btn_register_insignia) btnImportDashboard = findViewById(R.id.btn_import_dashboard) + layoutAdvancedExperimentalContent = findViewById(R.id.layout_advanced_experimental_content) tvVulkanDriverName = findViewById(R.id.tv_vulkan_driver_name) val btnVulkanBrowse = findViewById(R.id.btn_vulkan_browse) val btnVulkanClear = findViewById(R.id.btn_vulkan_clear) @@ -268,6 +319,8 @@ class SettingsActivity : AppCompatActivity() { switchVsync.isChecked = prefs.getBoolean("setting_vsync", true) switchSkipBootAnim.isChecked = prefs.getBoolean("setting_skip_boot_anim", false) + switchNetworkEnable.isChecked = + prefs.getBoolean("setting_network_enable", false) val audioDriver = prefs.getString("setting_audio_driver", "openslES") ?: "openslES" when (audioDriver) { @@ -294,7 +347,26 @@ class SettingsActivity : AppCompatActivity() { } setupEepromEditor() + refreshInsigniaStatus() refreshHddToolsPreview(btnInitializeRetailHdd) + setAdvancedExperimentalExpanded( + prefs.getBoolean(PREF_ADVANCED_EXPERIMENTAL_EXPANDED, false) + ) + btnToggleAdvancedExperimental.setOnClickListener { + setAdvancedExperimentalExpanded(layoutAdvancedExperimentalContent.visibility != View.VISIBLE) + } + btnInsigniaGuide.setOnClickListener { + openExternalLink(INSIGNIA_GUIDE_URL) + } + btnInsigniaSignUp.setOnClickListener { + openExternalLink(INSIGNIA_SIGN_UP_URL) + } + btnPrepareInsignia.setOnClickListener { + prepareInsigniaNetworking() + } + btnRegisterInsignia.setOnClickListener { + showInsigniaSetupAssistantPrompt() + } btnImportDashboard.setOnClickListener { showDashboardImportSourcePicker() } @@ -349,6 +421,7 @@ class SettingsActivity : AppCompatActivity() { .putBoolean("setting_hard_fpu", switchFpu.isChecked) .putBoolean("setting_vsync", switchVsync.isChecked) .putBoolean("setting_skip_boot_anim", switchSkipBootAnim.isChecked) + .putBoolean("setting_network_enable", switchNetworkEnable.isChecked) .putString("setting_audio_driver", selectedAudioDriver) .putString("setting_filtering", selectedFiltering) .putString("setting_renderer", selectedRenderer) @@ -382,6 +455,224 @@ class SettingsActivity : AppCompatActivity() { } } + private fun setAdvancedExperimentalExpanded(expanded: Boolean) { + layoutAdvancedExperimentalContent.visibility = if (expanded) View.VISIBLE else View.GONE + btnToggleAdvancedExperimental.text = getString( + if (expanded) { + R.string.settings_advanced_experimental_hide + } else { + R.string.settings_advanced_experimental_show + } + ) + prefs.edit().putBoolean(PREF_ADVANCED_EXPERIMENTAL_EXPANDED, expanded).apply() + } + + private fun openExternalLink(url: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + } + try { + startActivity(intent) + } catch (_: Exception) { + Toast.makeText(this, getString(R.string.library_about_open_failed), Toast.LENGTH_SHORT).show() + } + } + + private fun refreshInsigniaStatus() { + val hddFile = resolveHddFile() + val eepromFile = resolveEepromFile() + val setupUri = resolveInsigniaSetupAssistantUri() + val setupName = resolveInsigniaSetupAssistantName() + val setupReady = setupUri != null && hasPersistedReadPermission(setupUri) + + tvInsigniaStatus.text = getString(R.string.settings_insignia_status_checking) + + Thread { + var dashboardStatus: XboxInsigniaHelper.DashboardStatus? = null + var dashboardError: String? = null + + if (hddFile != null) { + try { + dashboardStatus = XboxInsigniaHelper.inspectDashboard(hddFile) + } catch (error: Exception) { + dashboardError = error.message ?: error.javaClass.simpleName + } + } + + val snapshot = InsigniaStatusSnapshot( + hasLocalHdd = hddFile != null, + hasEeprom = eepromFile.isFile, + dashboardStatus = dashboardStatus, + dashboardError = dashboardError, + setupAssistantName = setupName, + setupAssistantReady = setupReady, + ) + + runOnUiThread { + if (isFinishing || isDestroyed) { + return@runOnUiThread + } + tvInsigniaStatus.text = buildInsigniaStatusText(snapshot) + } + }.start() + } + + private fun buildInsigniaStatusText(snapshot: InsigniaStatusSnapshot): String { + val dashboardLine = when { + !snapshot.hasLocalHdd -> + getString(R.string.settings_insignia_status_dashboard_unavailable) + snapshot.dashboardError != null -> + getString(R.string.settings_insignia_status_dashboard_error, snapshot.dashboardError) + snapshot.dashboardStatus?.looksRetailDashboardInstalled == true -> + getString(R.string.settings_insignia_status_dashboard_ready) + snapshot.dashboardStatus?.hasAnyRetailDashboardFiles == true -> + getString(R.string.settings_insignia_status_dashboard_partial) + else -> + getString(R.string.settings_insignia_status_dashboard_missing) + } + + val eepromLine = if (snapshot.hasEeprom) { + getString(R.string.settings_insignia_status_eeprom_ready) + } else { + getString(R.string.settings_insignia_status_eeprom_missing) + } + + val setupLine = when { + snapshot.setupAssistantReady -> + getString( + R.string.settings_insignia_status_setup_selected, + snapshot.setupAssistantName ?: getString(R.string.settings_insignia_setup_source_unknown), + ) + snapshot.setupAssistantName != null -> + getString(R.string.settings_insignia_status_setup_inaccessible, snapshot.setupAssistantName) + else -> + getString(R.string.settings_insignia_status_setup_missing) + } + + return listOf( + getString( + R.string.settings_insignia_status_dns, + XboxInsigniaHelper.PRIMARY_DNS, + XboxInsigniaHelper.SECONDARY_DNS, + ), + dashboardLine, + eepromLine, + setupLine, + ).joinToString("\n") + } + + private fun prepareInsigniaNetworking() { + if (isPreparingInsignia || isInitializingHdd || isImportingDashboard) { + return + } + + val hddFile = resolveHddFile() + if (hddFile == null) { + Toast.makeText(this, R.string.settings_insignia_prepare_no_hdd, Toast.LENGTH_LONG).show() + refreshInsigniaStatus() + return + } + + val eepromFile = resolveEepromFile() + if (!eepromFile.isFile) { + Toast.makeText(this, R.string.settings_insignia_prepare_no_eeprom, Toast.LENGTH_LONG).show() + refreshInsigniaStatus() + return + } + + switchNetworkEnable.isChecked = true + prefs.edit().putBoolean("setting_network_enable", true).apply() + + isPreparingInsignia = true + Toast.makeText(this, R.string.settings_insignia_prepare_working, Toast.LENGTH_SHORT).show() + + Thread { + val result = runCatching { + XboxInsigniaHelper.applyConfigSectorDns(hddFile) + XboxEepromEditor.applyXboxLiveDns(eepromFile, XboxInsigniaHelper.primaryDnsBytes()) + runCatching { XboxInsigniaHelper.inspectDashboard(hddFile) }.getOrNull() + } + + runOnUiThread { + isPreparingInsignia = false + if (isFinishing || isDestroyed) { + return@runOnUiThread + } + + result.onSuccess { dashboardStatus -> + refreshInsigniaStatus() + val messageRes = if (dashboardStatus?.looksRetailDashboardInstalled == false) { + R.string.settings_insignia_prepare_success_missing_dashboard + } else { + R.string.settings_insignia_prepare_success + } + Toast.makeText(this, messageRes, Toast.LENGTH_LONG).show() + }.onFailure { error -> + Toast.makeText( + this, + getString( + R.string.settings_insignia_prepare_failed, + error.message ?: error.javaClass.simpleName, + ), + Toast.LENGTH_LONG, + ).show() + refreshInsigniaStatus() + } + } + }.start() + } + + private fun showInsigniaSetupAssistantPrompt() { + val setupUri = resolveInsigniaSetupAssistantUri() + if (setupUri == null || !hasPersistedReadPermission(setupUri)) { + if (setupUri != null) { + prefs.edit() + .remove(PREF_INSIGNIA_SETUP_URI) + .remove(PREF_INSIGNIA_SETUP_NAME) + .apply() + } + refreshInsigniaStatus() + Toast.makeText(this, R.string.settings_insignia_register_pick_prompt, Toast.LENGTH_SHORT).show() + pickInsigniaSetupAssistant.launch(arrayOf("*/*")) + return + } + + val setupName = resolveInsigniaSetupAssistantName() + ?: getString(R.string.settings_insignia_setup_source_unknown) + MaterialAlertDialogBuilder(this) + .setTitle(R.string.settings_insignia_register_title) + .setMessage(getString(R.string.settings_insignia_register_message, setupName)) + .setPositiveButton(R.string.settings_insignia_register_boot_action) { _, _ -> + launchInsigniaSetupAssistant(setupUri) + } + .setNeutralButton(R.string.settings_insignia_register_choose_new) { _, _ -> + pickInsigniaSetupAssistant.launch(arrayOf("*/*")) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun launchInsigniaSetupAssistant(uri: Uri) { + persistUriPermission(uri) + switchNetworkEnable.isChecked = true + prefs.edit() + .putBoolean("setting_network_enable", true) + .putString("dvdUri", uri.toString()) + .remove("dvdPath") + .putBoolean("skip_game_picker", false) + .commit() + + startActivity(Intent(this, MainActivity::class.java)) + } + + private fun resolveInsigniaSetupAssistantUri(): Uri? { + return prefs.getString(PREF_INSIGNIA_SETUP_URI, null)?.let(Uri::parse) + } + + private fun resolveInsigniaSetupAssistantName(): String? { + return prefs.getString(PREF_INSIGNIA_SETUP_NAME, null) + } + private fun setupEepromEditor() { val languageLabels = eepromLanguageOptions.map { getString(it.labelRes) } val videoLabels = eepromVideoOptions.map { getString(it.labelRes) } @@ -725,6 +1016,7 @@ class SettingsActivity : AppCompatActivity() { runOnUiThread { isInitializingHdd = false refreshHddToolsState(button) + refreshInsigniaStatus() result.onSuccess { Toast.makeText(this, R.string.settings_hdd_init_success, Toast.LENGTH_SHORT).show() }.onFailure { error -> @@ -969,6 +1261,7 @@ class SettingsActivity : AppCompatActivity() { plan.workingDir.deleteRecursively() isImportingDashboard = false btnImportDashboard.isEnabled = true + refreshInsigniaStatus() result.onSuccess { val messageRes = when { plan.bootAliasCreated -> R.string.settings_dashboard_import_success_with_alias @@ -1089,14 +1382,18 @@ class SettingsActivity : AppCompatActivity() { var current = root while (true) { - val children = current.listFiles() - ?.filterNot { shouldSkipDashboardSourceEntry(it.name) } - .orEmpty() + val children = dashboardSourceEntries(current) if (children.size != 1 || !children.first().isDirectory) { - return current + break } current = children.first() } + + if (looksLikeDashboardSourceRoot(current)) { + return current + } + + return findNestedDashboardSourceRoot(current) ?: current } private fun buildDashboardImportLayout(sourceRoot: File, workingDir: File): File { @@ -1112,9 +1409,13 @@ class SettingsActivity : AppCompatActivity() { } val sourceC = entries.firstOrNull { it.isDirectory && it.name.equals("C", ignoreCase = true) } + ?.let(::normalizeDashboardPartitionRoot) val sourceE = entries.firstOrNull { it.isDirectory && it.name.equals("E", ignoreCase = true) } - val rootEntriesForC = entries.filterNot { entry -> + ?.let(::normalizeDashboardPartitionRoot) + val rootEntriesForC = if (sourceC == null) entries.filterNot { entry -> entry.isDirectory && (entry.name.equals("C", ignoreCase = true) || entry.name.equals("E", ignoreCase = true)) + } else { + emptyList() } sourceC?.let { copyLocalDirectoryContents(it, File(layoutRoot, "C")) } @@ -1130,6 +1431,27 @@ class SettingsActivity : AppCompatActivity() { return layoutRoot } + private fun normalizeDashboardPartitionRoot(partitionDir: File): File { + if (!partitionDir.isDirectory) { + return partitionDir + } + + var current = partitionDir + while (true) { + if (looksLikeDashboardSourceRoot(current)) { + return current + } + + val children = dashboardSourceEntries(current).filter { it.isDirectory } + if (children.size != 1) { + break + } + current = children.first() + } + + return findNestedDashboardSourceRoot(current) ?: current + } + private fun copyLocalDirectoryContents(sourceDir: File, targetDir: File) { val children = sourceDir.listFiles().orEmpty() if (!targetDir.exists() && !targetDir.mkdirs()) { @@ -1265,9 +1587,102 @@ class SettingsActivity : AppCompatActivity() { } private fun dashboardSourceHasFiles(root: File): Boolean { - return root.walkTopDown().any { file -> - file.isFile && !shouldSkipDashboardSourceEntry(file.name) + return looksLikeDashboardSourceRoot(root) || findNestedDashboardSourceRoot(root) != null + } + + private fun dashboardSourceEntries(root: File): List { + return root.listFiles() + ?.filterNot { shouldSkipDashboardSourceEntry(it.name) } + .orEmpty() + } + + private fun looksLikeDashboardSourceRoot(root: File): Boolean { + val entries = dashboardSourceEntries(root) + if (entries.isEmpty()) { + return false } + + val hasPartitionDir = entries.any { entry -> + entry.isDirectory && + (entry.name.equals("C", ignoreCase = true) || entry.name.equals("E", ignoreCase = true)) && + dashboardSourceEntries(entry).isNotEmpty() + } + if (hasPartitionDir) { + return true + } + + return scoreDashboardSourceRoot(root, root) > 0 + } + + private fun findNestedDashboardSourceRoot(root: File): File? { + var bestDir: File? = null + var bestScore = Int.MIN_VALUE + + root.walkTopDown() + .maxDepth(8) + .forEach { candidate -> + if (!candidate.isDirectory || candidate == root) { + return@forEach + } + + val score = scoreDashboardSourceRoot(root, candidate) + if (score > bestScore) { + bestScore = score + bestDir = candidate + } + } + + return bestDir?.takeIf { bestScore > 0 } + } + + private fun scoreDashboardSourceRoot(searchRoot: File, candidate: File): Int { + val entries = dashboardSourceEntries(candidate) + if (entries.isEmpty()) { + return Int.MIN_VALUE + } + + val partitionDirs = entries.filter { entry -> + entry.isDirectory && + (entry.name.equals("C", ignoreCase = true) || entry.name.equals("E", ignoreCase = true)) && + dashboardSourceEntries(entry).isNotEmpty() + } + val directFiles = entries.filter { it.isFile } + val directDirs = entries.filter { it.isDirectory } + + var score = 0 + if (partitionDirs.isNotEmpty()) { + score += 10_000 + } + if (directFiles.any { it.name.equals("xboxdash.xbe", ignoreCase = true) }) { + score += 9_000 + } + if (directFiles.any { it.name.equals("msdash.xbe", ignoreCase = true) }) { + score += 7_000 + } + if (directFiles.any { it.name.equals("xbox.xtf", ignoreCase = true) }) { + score += 3_000 + } + if (directDirs.any { it.name.equals("xodash", ignoreCase = true) }) { + score += 3_000 + } + if (directDirs.any { it.name.equals("audio", ignoreCase = true) }) { + score += 1_500 + } + if (directDirs.any { it.name.equals("fonts", ignoreCase = true) }) { + score += 1_500 + } + if (directFiles.any { it.extension.equals("xbe", ignoreCase = true) }) { + score += 1_000 + } + + if (score <= 0) { + return score + } + + val depth = candidate.relativeTo(searchRoot) + .invariantSeparatorsPath + .count { it == '/' } + 1 + return score - (depth * 120) } private fun describeDashboardSource(root: File): String { @@ -1300,6 +1715,12 @@ class SettingsActivity : AppCompatActivity() { } } + private fun hasPersistedReadPermission(uri: Uri): Boolean { + return contentResolver.persistedUriPermissions.any { permission -> + permission.isReadPermission && permission.uri == uri + } + } + private fun hddFormatLabelRes(format: XboxHddFormatter.ImageFormat): Int { return when (format) { XboxHddFormatter.ImageFormat.RAW -> R.string.settings_hdd_format_raw diff --git a/android/app/src/main/java/com/izzy2lost/x1box/XboxEepromEditor.kt b/android/app/src/main/java/com/izzy2lost/x1box/XboxEepromEditor.kt index a3263ae63a..3db52cd825 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/XboxEepromEditor.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/XboxEepromEditor.kt @@ -14,6 +14,8 @@ internal object XboxEepromEditor { private const val USER_CHECKSUM_START = 0x64 private const val USER_CHECKSUM_LENGTH = 0x5C + private const val XBOX_LIVE_DNS_OFFSET = 0xAC + private const val VIDEO_STANDARD_OFFSET = 0x58 private const val LANGUAGE_OFFSET = 0x90 private const val VIDEO_SETTINGS_OFFSET = 0x94 @@ -189,6 +191,34 @@ internal object XboxEepromEditor { return true } + @Throws(IOException::class, IllegalArgumentException::class) + fun applyXboxLiveDns( + file: File, + dnsServer: ByteArray, + ): Boolean { + require(dnsServer.size == 4) { "Xbox Live DNS must be a 4-byte IPv4 address." } + + val data = file.readBytes() + ensureValidSize(file, data) + + var changed = false + for (index in dnsServer.indices) { + if (data[XBOX_LIVE_DNS_OFFSET + index] != dnsServer[index]) { + data[XBOX_LIVE_DNS_OFFSET + index] = dnsServer[index] + changed = true + } + } + if (!changed) { + return false + } + + val userChecksum = xboxChecksum(data, USER_CHECKSUM_START, USER_CHECKSUM_LENGTH) + writeLeUInt32(data, USER_CHECKSUM_OFFSET, userChecksum) + + file.writeBytes(data) + return true + } + private fun ensureValidSize(file: File, data: ByteArray) { if (data.size != EEPROM_SIZE) { throw IllegalArgumentException( diff --git a/android/app/src/main/java/com/izzy2lost/x1box/XboxInsigniaHelper.kt b/android/app/src/main/java/com/izzy2lost/x1box/XboxInsigniaHelper.kt new file mode 100644 index 0000000000..5563af69b8 --- /dev/null +++ b/android/app/src/main/java/com/izzy2lost/x1box/XboxInsigniaHelper.kt @@ -0,0 +1,102 @@ +package com.izzy2lost.x1box + +import java.io.File +import java.io.IOException + +internal object XboxInsigniaHelper { + const val PRIMARY_DNS = "46.101.64.175" + const val SECONDARY_DNS = "8.8.8.8" + + private const val FLAG_XBOXDASH_XBE = 1 shl 0 + private const val FLAG_XODASH_DIR = 1 shl 1 + private const val FLAG_XBOX_XTF = 1 shl 2 + private const val FLAG_MSDASH_XBE = 1 shl 3 + private const val FLAG_AUDIO_DIR = 1 shl 4 + private const val FLAG_FONTS_DIR = 1 shl 5 + private const val FLAG_XBOXDASHDATA_DIR = 1 shl 6 + + data class DashboardStatus( + val flags: Int, + ) { + val hasBootXbe: Boolean + get() = (flags and FLAG_XBOXDASH_XBE) != 0 + + val hasXodashAssets: Boolean + get() = (flags and FLAG_XODASH_DIR) != 0 + + val hasXboxFont: Boolean + get() = (flags and FLAG_XBOX_XTF) != 0 + + val hasMsdashXbe: Boolean + get() = (flags and FLAG_MSDASH_XBE) != 0 + + val hasAudioDir: Boolean + get() = (flags and FLAG_AUDIO_DIR) != 0 + + val hasFontsDir: Boolean + get() = (flags and FLAG_FONTS_DIR) != 0 + + val hasXboxdashdataDir: Boolean + get() = (flags and FLAG_XBOXDASHDATA_DIR) != 0 + + val looksRetailDashboardInstalled: Boolean + get() = hasBootXbe && ( + hasXodashAssets || + hasXboxFont || + hasMsdashXbe || + hasAudioDir || + hasFontsDir || + hasXboxdashdataDir + ) + + val hasAnyRetailDashboardFiles: Boolean + get() = flags != 0 + } + + private val primaryDnsBytes = parseIpv4(PRIMARY_DNS) + private val secondaryDnsBytes = parseIpv4(SECONDARY_DNS) + + @Throws(IOException::class, IllegalArgumentException::class) + fun inspectDashboard(hddFile: File): DashboardStatus { + require(hddFile.isFile) { "No local HDD image is configured." } + return DashboardStatus(NativeBridge.nativeInspectDashboardFlags(hddFile.absolutePath)) + } + + @Throws(IOException::class, IllegalArgumentException::class) + fun applyConfigSectorDns(hddFile: File) { + require(hddFile.isFile) { "No local HDD image is configured." } + NativeBridge.nativeApplyConfigSectorDns( + hddFile.absolutePath, + primaryDnsBytes, + secondaryDnsBytes, + ) + } + + fun primaryDnsBytes(): ByteArray = primaryDnsBytes.copyOf() + + private fun parseIpv4(value: String): ByteArray { + val octets = value.split('.') + require(octets.size == 4) { "Invalid IPv4 address: $value" } + return ByteArray(4) { index -> + val octet = octets[index].toIntOrNull() + ?: throw IllegalArgumentException("Invalid IPv4 address: $value") + require(octet in 0..255) { "Invalid IPv4 address: $value" } + octet.toByte() + } + } + + private object NativeBridge { + init { + System.loadLibrary("SDL2") + System.loadLibrary("xemu") + } + + external fun nativeInspectDashboardFlags(hddPath: String): Int + + external fun nativeApplyConfigSectorDns( + hddPath: String, + primaryDns: ByteArray, + secondaryDns: ByteArray, + ) + } +} diff --git a/android/app/src/main/res/layout/activity_game_library.xml b/android/app/src/main/res/layout/activity_game_library.xml index d1d9353569..9fca332712 100644 --- a/android/app/src/main/res/layout/activity_game_library.xml +++ b/android/app/src/main/res/layout/activity_game_library.xml @@ -83,6 +83,14 @@ app:iconTint="@color/xemu_black" app:iconPadding="10dp" /> + + + + + + + + + + + + + + + + + + + + - + android:text="@string/settings_advanced_experimental_show" /> + + + + + + + + + style="@style/Widget.Xemu.Button.Outlined.Pill" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="6dp" + android:text="@string/settings_hdd_init_action" /> - + + + Game Library Boot Dashboard + Requires a hard drive image with the Microsoft dashboard installed. Barebones HDD layouts will not work. Snapshots Convert ISO to XISO List @@ -66,6 +67,8 @@ Could not resolve destination folder for selected game. Unable to restart. The selected game is no longer accessible. Unable to boot the dashboard. One or more core files are no longer accessible. + No retail dashboard was found on the current HDD image. Import the Microsoft dashboard in Settings first. + Some retail dashboard files are present, but the dashboard looks incomplete. Re-import the Microsoft dashboard files in Settings. Delete Game Delete Game Delete @@ -144,6 +147,37 @@ OpenSL ES AAudio Disabled + Online + Enable online networking (Insignia) + Turns on NAT networking for dashboards and Insignia. Use the helper buttons below to write the DNS preset and boot the Setup Assistant. + Insignia Guide + Sign Up With Insignia + Prepare Insignia Networking + Register With Setup Assistant + Checking Insignia setup... + DNS preset: %1$s / %2$s + Retail dashboard: detected on C: + Retail dashboard: some files were found, but the install looks incomplete + Retail dashboard: not detected on C: + Retail dashboard: unavailable because no local HDD image is configured + Retail dashboard: failed to inspect the HDD image (%1$s) + EEPROM: ready for DNS updates + EEPROM: missing. Launch emulation once to create eeprom.bin + Setup Assistant: %1$s + Setup Assistant: %1$s (pick it again to restore access) + Setup Assistant: not selected yet + Writing Insignia DNS settings... + Insignia networking is ready. Restart emulation, then sign in through the dashboard or boot the Setup Assistant. + Insignia DNS was written, but the retail dashboard is still missing from C:. + Failed to prepare Insignia networking: %1$s + No local HDD image is configured. Pick one in the setup wizard first. + EEPROM not found yet. Launch emulation once to create eeprom.bin, then try again. + Setup Assistant + Boot %1$s now? This launches the Insignia Setup Assistant disc image. + Boot Setup Assistant + Choose Different Image + Pick your Insignia Setup Assistant disc image. + Unknown source EEPROM EEPROM Language English @@ -183,6 +217,10 @@ Settings and EEPROM saved Settings saved. Launch a game once, then edit EEPROM. Settings saved, but EEPROM update failed + Advanced / Experimental + Less common HDD management tools live here so the main settings flow stays focused on everyday setup. + Show Advanced / Experimental Tools + Hide Advanced / Experimental Tools 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.