add xbox hdd formatter

This commit is contained in:
izzy2lost
2026-03-13 13:21:36 -04:00
parent 51e16997ff
commit 784f810f5b
6 changed files with 1268 additions and 0 deletions
+1
View File
@@ -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)
File diff suppressed because it is too large Load Diff
@@ -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<MaterialButton>(R.id.btn_settings_save)
val btnRedoSetup = findViewById<MaterialButton>(R.id.btn_redo_setup_wizard)
val btnClearCache = findViewById<MaterialButton>(R.id.btn_clear_system_cache)
val btnInitializeRetailHdd = findViewById<MaterialButton>(R.id.btn_initialize_retail_hdd)
tvVulkanDriverName = findViewById(R.id.tv_vulkan_driver_name)
val btnVulkanBrowse = findViewById<MaterialButton>(R.id.btn_vulkan_browse)
val btnVulkanClear = findViewById<MaterialButton>(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<Int, Int> {
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)
@@ -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<Layout> {
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<Long, Long>? {
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)
}
}
@@ -599,6 +599,39 @@
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="@color/xemu_text_muted" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="8dp"
android:text="@string/settings_section_hdd_tools"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="@color/xemu_green" />
<TextView
android:id="@+id/tv_hdd_tools_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="@color/xemu_text_muted" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="10dp"
android:text="@string/settings_hdd_tools_hint"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="@color/xemu_text_muted" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_initialize_retail_hdd"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="6dp"
android:text="@string/settings_hdd_init_action" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -173,6 +173,33 @@
<string name="settings_saved_with_eeprom">Settings and EEPROM saved</string>
<string name="settings_saved_eeprom_missing">Settings saved. Launch a game once, then edit EEPROM.</string>
<string name="settings_saved_eeprom_failed">Settings saved, but EEPROM update failed</string>
<string name="settings_section_hdd_tools">HDD Tools</string>
<string name="settings_hdd_tools_hint">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.</string>
<string name="settings_hdd_status_missing">No local HDD image is configured. Pick one in the setup wizard first.</string>
<string name="settings_hdd_status_configured">Current HDD image:\n%1$s\nTap Initialize HDD Layout to inspect the image and choose a layout.</string>
<string name="settings_hdd_format_raw">Raw</string>
<string name="settings_hdd_format_qcow2">QCOW2</string>
<string name="settings_hdd_status_ready">Current HDD image: %1$s (%2$s)\n%3$s</string>
<string name="settings_hdd_status_too_small">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.</string>
<string name="settings_hdd_status_error">Failed to inspect the current HDD image.\n%1$s</string>
<string name="settings_hdd_layout_pick_title">Choose HDD Layout</string>
<string name="settings_hdd_layout_retail">Retail Only (X, Y, Z, C, E)</string>
<string name="settings_hdd_layout_retail_f">Retail + F</string>
<string name="settings_hdd_layout_retail_f_g">Retail + F + G</string>
<string name="settings_hdd_layout_summary_retail">Rewrites X, Y, Z, C, and E.</string>
<string name="settings_hdd_layout_summary_retail_f">Rewrites X, Y, Z, C, E, and uses all remaining space as F.</string>
<string name="settings_hdd_layout_summary_retail_f_g">Rewrites X, Y, Z, C, E, keeps F up to the standard 137 GB boundary, and uses the rest as G.</string>
<string name="settings_hdd_layout_unavailable_format">%1$s (%2$s)</string>
<string name="settings_hdd_layout_unavailable_title">Layout Unavailable</string>
<string name="settings_hdd_layout_unavailable_no_extended_space">This HDD image only has enough room for the stock retail partitions. There is no extra space available for F or G.</string>
<string name="settings_hdd_layout_unavailable_needs_standard_g_boundary">Retail + F + G needs an HDD image larger than 128 GiB so G can start at the standard Xbox boundary.</string>
<string name="settings_hdd_layout_unavailable_not_enough_space">This HDD image does not have enough usable space for that layout.</string>
<string name="settings_hdd_init_title">Initialize HDD Layout</string>
<string name="settings_hdd_init_message">Initialize the current HDD image with %1$s?\n\n%2$s\n\nImage: %3$s</string>
<string name="settings_hdd_init_action">Initialize HDD Layout</string>
<string name="settings_hdd_init_working">Initializing HDD...</string>
<string name="settings_hdd_init_success">HDD layout initialized</string>
<string name="settings_hdd_init_failed">Failed to initialize HDD: %1$s</string>
<string name="settings_section_maintenance">Maintenance</string>
<string name="settings_clear_cache_title">Clear Cache</string>
<string name="settings_clear_cache_message">Clear emulator cache files now? This removes shader and app cache data, but keeps your games, saves, and HDD image.</string>