Added Quest Mii management features

- Introduced new layout files for Mii items, Mii editor, and My Miis page.
- Implemented MiiData serialization and deserialization tests to ensure data integrity.
- Created MiiDatabase tests to validate database creation, modification, and error handling.
- Added MiiIds tests to verify ID generation and MAC address derivation.
- Developed MiiRenderer tests to confirm rendering functionality for Mii parts.
This commit is contained in:
iChris4 committed 2026-09-24 03:41:39 +02:00
1 parent be161a0281
commit e2a898eab7
43 files changed
+5331 -8

No files matched your search

@@ -0,0 +1,363 @@
package org.wiicompiled.quest.launcher
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException
import java.util.zip.GZIPInputStream
import java.util.zip.Inflater
import kotlin.math.sqrt
/**
* FFL's Mii part resource (FFLResHigh.dat): the head shapes and part textures Mii pictures are
* made of. A port of the PC launcher's ManagedFflResourceArchive and of NativeMiiRenderer's shape
* and texture decoding. The file is Nintendo's, so the app never ships it: the player downloads
* the same copy as the PC does (see [MiiRenderResource]).
*/
class FflResource private constructor(
private val bytes: ByteArray,
private val bigEndian: Boolean,
private val textureParts: Array<Array<PartInfo>>,
private val shapeParts: Array<Array<PartInfo>>,
/** AFL's resources (Miitomo's, which the download is) store linear textures and scale glasses differently. */
val linearTextures: Boolean,
private val halfFloatLayout: Boolean,
) {
private class PartInfo(val position: Int, val size: Int, val compressedSize: Int, val windowBits: Int, val strategy: Int)
private val shapes = HashMap<Long, Any>()
private val textures = HashMap<Long, Any>()
/** A shape, or null when the part is empty or cannot be used, which drawing skips. */
internal fun shape(partType: Int, index: Int): FflShape? {
val key = (partType.toLong() shl 32) or (index.toLong() and 0xFFFFFFFFL)
synchronized(shapes) { shapes[key]?.let { return it as? FflShape } }
val shape = runCatching { decodeShape(loadPart(shapeParts, partType, index), partType) }.getOrNull()
synchronized(shapes) { shapes[key] = shape ?: MISSING }
return shape
}
/**
* A texture, or null for a negative index or an empty part (which drawing skips). A part that
* cannot be read throws, and so does the picture, as on the PC.
*/
internal fun texture(partType: Int, index: Int): FflTexture? {
if (index < 0) return null
val key = (partType.toLong() shl 32) or index.toLong()
synchronized(textures) { textures[key]?.let { return it as? FflTexture } }
val data = loadPart(textureParts, partType, index)
val texture = if (data.size <= 12) null else decodeTexture(data)
synchronized(textures) { textures[key] = texture ?: MISSING }
return texture
}
private fun loadPart(table: Array<Array<PartInfo>>, partType: Int, index: Int): ByteArray {
if (partType !in table.indices) throw IOException("Part type $partType is out of range.")
val parts = table[partType]
if (index !in parts.indices) throw IOException("Part index $index is out of range for part type $partType.")
val info = parts[index]
if (info.size <= 0) return ByteArray(0)
if (info.position < 0 || info.position >= bytes.size) throw IOException("dataPos out of range: ${info.position}")
if (info.strategy == 5) {
if (info.position + info.size > bytes.size) throw IOException("Uncompressed part range is out of file bounds.")
return bytes.copyOfRange(info.position, info.position + info.size)
}
if (info.compressedSize <= 0) throw IOException("Compressed part has empty compressedSize.")
if (info.position + info.compressedSize > bytes.size) throw IOException("Compressed part range is out of file bounds.")
val compressed = bytes.copyOfRange(info.position, info.position + info.compressedSize)
val decoded = when (info.windowBits) {
in 8..15 -> gunzip(compressed)
16 -> runCatching { inflate(compressed) }.getOrElse { gunzip(compressed) }
else -> inflate(compressed)
}
return if (decoded.size == info.size) decoded else decoded.copyOf(info.size)
}
private fun decodeShape(data: ByteArray, partType: Int): FflShape? {
if (data.size < 0x90) return null
val positionOf = IntArray(6) { u32(data, it * 4).toInt() }
val sizeOf = IntArray(6) { u32(data, 24 + it * 4).toInt() }
for (i in 0 until 6) {
if (sizeOf[i] == 0) continue
// An index "size" counts u16 indices; the others are byte sizes.
val byteSize = if (i == 5) sizeOf[i].toLong() * 2 else sizeOf[i].toLong() and 0xFFFFFFFFL
if (positionOf[i] < 0 || positionOf[i] >= data.size || positionOf[i] + byteSize > data.size) return null
}
val positionSize = sizeOf[0]
if (positionSize <= 0) return null
var positionStride = if (halfFloatLayout) 6 else 16
if (!halfFloatLayout && positionSize % positionStride != 0 && positionSize % 12 == 0) positionStride = 12
val count = positionSize / positionStride
if (count <= 0) return null
val positions = FloatArray(count * 3)
val texcoords = FloatArray(count * 2)
val normals = FloatArray(count * 3) { if (it % 3 == 2) 1f else 0f }
val tangents = FloatArray(count * 3)
val parameters = FloatArray(count * 4) { if (it % 4 == 2) 0f else 1f }
for (i in 0 until count) {
val o = positionOf[0] + i * positionStride
for (axis in 0 until 3) {
positions[i * 3 + axis] = if (halfFloatLayout) half(u16(data, o + axis * 2)) else f32(data, o + axis * 4)
}
}
if (sizeOf[2] > 0) {
val stride = if (halfFloatLayout) 4 else 8
for (i in 0 until minOf(count, sizeOf[2] / stride)) {
val o = positionOf[2] + i * stride
texcoords[i * 2] = if (halfFloatLayout) half(u16(data, o)) else f32(data, o)
texcoords[i * 2 + 1] = if (halfFloatLayout) half(u16(data, o + 2)) else f32(data, o + 4)
}
}
if (sizeOf[1] > 0) {
for (i in 0 until minOf(count, sizeOf[1] / 4)) {
val o = positionOf[1] + i * 4
if (halfFloatLayout) {
snorm8Normal(data, o, normals, i * 3, zeroFallback = false)
} else {
decodeInt2101010(u32(data, o).toInt(), normals, i * 3)
}
}
}
if (sizeOf[3] > 0) {
for (i in 0 until minOf(count, sizeOf[3] / 4)) snorm8Normal(data, positionOf[3] + i * 4, tangents, i * 3, zeroFallback = true)
}
if (sizeOf[4] > 0) {
for (i in 0 until minOf(count, sizeOf[4] / 4)) {
for (c in 0 until 4) parameters[i * 4 + c] = (data[positionOf[4] + i * 4 + c].toInt() and 0xFF) / 255f
}
}
if (sizeOf[5] <= 0) return null
val indices = IntArray(sizeOf[5]) { u16(data, positionOf[5] + it * 2) }
if (indices.any { it >= count }) return null
var translates: Array<FloatArray>? = null
if (partType == SHAPE_FACELINE && data.size >= 0x48 + 0x24) {
translates = Array(3) { t -> FloatArray(3) { axis -> f32(data, 0x48 + t * 12 + axis * 4) } }
}
return FflShape(positions, texcoords, normals, tangents, parameters, indices, translates)
}
private fun decodeTexture(data: ByteArray): FflTexture {
val footer = data.size - 12
val width = u16(data, footer + 4)
val height = u16(data, footer + 6)
val format = data[footer + 9].toInt() and 0xFF
if (width == 0 || height == 0) throw IOException("Texture part has invalid dimensions.")
val stride = when (format) {
FflTexture.R8 -> 1
FflTexture.RG8 -> 2
FflTexture.RGBA8 -> 4
else -> throw IOException("Unsupported texture format $format.")
}
val imageSize = width.toLong() * height * stride
if (imageSize > footer) throw IOException("Texture image payload is truncated.")
// Mipmaps follow the base level; the PC samples only that.
return FflTexture(width, height, format, data.copyOf(imageSize.toInt()))
}
private fun u16(data: ByteArray, offset: Int): Int {
val a = data[offset].toInt() and 0xFF
val b = data[offset + 1].toInt() and 0xFF
return if (bigEndian) (a shl 8) or b else (b shl 8) or a
}
private fun u32(data: ByteArray, offset: Int): Long {
val high = u16(data, if (bigEndian) offset else offset + 2).toLong()
val low = u16(data, if (bigEndian) offset + 2 else offset).toLong()
return (high shl 16) or low
}
private fun f32(data: ByteArray, offset: Int): Float = java.lang.Float.intBitsToFloat(u32(data, offset).toInt())
companion object {
/** The SHA-256 of AFLResHigh_2_3.dat, the file both launchers install as FFLResHigh.dat. */
const val SHA256 = "4a4be71d75162c20b48720ef89cd3d4e6cd4e8e21bcbc2aed63ee08d96de7722"
internal const val SHAPE_BEARD = 0
internal const val SHAPE_HAT = 1
internal const val SHAPE_FACELINE = 3
internal const val SHAPE_GLASS = 4
internal const val SHAPE_MASK = 5
internal const val SHAPE_NOSELINE = 6
internal const val SHAPE_NOSE = 7
internal const val SHAPE_HAIR = 8
internal const val SHAPE_FOREHEAD = 10
internal const val TEXTURE_BEARD = 0
internal const val TEXTURE_CAP = 1
internal const val TEXTURE_EYE = 2
internal const val TEXTURE_EYEBROW = 3
internal const val TEXTURE_FACELINE = 4
internal const val TEXTURE_MAKEUP = 5
internal const val TEXTURE_GLASS = 6
internal const val TEXTURE_MOLE = 7
internal const val TEXTURE_MOUTH = 8
internal const val TEXTURE_MUSTACHE = 9
internal const val TEXTURE_NOSELINE = 10
private const val MAGIC = 0x46465241L
private const val VERSION = 0x00070000L
private const val HEADER_SIZE = 0x4A00
private const val TEXTURE_HEADER = 0x14
private const val EXPANDED_AFL = 0x0239D5E0L
private const val EXPANDED_AFL_23 = 0x02502DE0L
private val MISSING = Any()
private val TEXTURE_COUNTS = intArrayOf(3, 132, 62, 24, 12, 12, 9, 2, 37, 6, 18)
private val TEXTURE_COUNTS_AFL = intArrayOf(3, 132, 80, 28, 12, 12, 9, 2, 52, 6, 18)
private val TEXTURE_COUNTS_AFL_23 = intArrayOf(3, 132, 80, 28, 12, 12, 20, 2, 52, 6, 18)
private val SHAPE_COUNTS = intArrayOf(4, 132, 132, 12, 1, 12, 18, 18, 132, 132, 132, 132)
@Throws(IOException::class)
fun load(file: File): FflResource = parse(file.readBytes())
@Throws(IOException::class)
fun parse(bytes: ByteArray): FflResource {
if (bytes.size < HEADER_SIZE) throw IOException("FFL resource file is too small (${bytes.size} bytes).")
val bigEndian = when (MAGIC) {
readU32(bytes, 0, true) -> true
readU32(bytes, 0, false) -> false
else -> throw IOException("FFL resource has an invalid magic.")
}
val version = readU32(bytes, 4, bigEndian)
if (version != VERSION) throw IOException("FFL resource has unsupported version 0x%08X.".format(version))
val expanded = readU32(bytes, 12, bigEndian)
val halfFloat = readU32(bytes, 16, bigEndian) == 0x841F10A7L
val hint = expanded ushr 29
val expandedSize = expanded and 0x1FFFFFFF
val afl23 = hint == 3L || expandedSize == EXPANDED_AFL_23
val afl = afl23 || hint == 2L || expandedSize == EXPANDED_AFL
val textureCounts = when {
afl23 -> TEXTURE_COUNTS_AFL_23
afl -> TEXTURE_COUNTS_AFL
else -> TEXTURE_COUNTS
}
val textureTable = TEXTURE_HEADER + textureCounts.size * 4
val textureParts = readParts(bytes, bigEndian, textureTable, textureCounts)
val shapeHeader = TEXTURE_HEADER + textureCounts.size * 4 + textureCounts.sum() * 16
val shapeParts = readParts(bytes, bigEndian, shapeHeader + SHAPE_COUNTS.size * 4, SHAPE_COUNTS)
return FflResource(bytes, bigEndian, textureParts, shapeParts, linearTextures = afl, halfFloatLayout = halfFloat)
}
private fun readParts(bytes: ByteArray, bigEndian: Boolean, start: Int, counts: IntArray): Array<Array<PartInfo>> {
var offset = start
return Array(counts.size) { type ->
Array(counts[type]) {
if (offset + 16 > bytes.size) throw IOException("Resource header is truncated while parsing parts info.")
PartInfo(
position = readU32(bytes, offset, bigEndian).toInt(),
size = readU32(bytes, offset + 4, bigEndian).toInt(),
compressedSize = readU32(bytes, offset + 8, bigEndian).toInt(),
windowBits = bytes[offset + 13].toInt() and 0xFF,
strategy = bytes[offset + 15].toInt() and 0xFF,
).also { offset += 16 }
}
}
}
private fun readU32(bytes: ByteArray, offset: Int, bigEndian: Boolean): Long {
var value = 0L
for (i in 0 until 4) {
val byte = bytes[offset + if (bigEndian) i else 3 - i].toLong() and 0xFF
value = (value shl 8) or byte
}
return value
}
private fun inflate(compressed: ByteArray): ByteArray {
val inflater = Inflater()
try {
inflater.setInput(compressed)
val output = ByteArrayOutputStream(compressed.size * 4)
val buffer = ByteArray(64 * 1024)
while (!inflater.finished()) {
val count = inflater.inflate(buffer)
if (count == 0 && (inflater.needsInput() || inflater.needsDictionary())) throw IOException("Truncated zlib part.")
output.write(buffer, 0, count)
}
return output.toByteArray()
} finally {
inflater.end()
}
}
private fun gunzip(compressed: ByteArray): ByteArray = GZIPInputStream(ByteArrayInputStream(compressed)).use { it.readBytes() }
private fun half(bits: Int): Float {
val exponent = (bits shr 10) and 0x1F
val mantissa = bits and 0x3FF
val magnitude = when (exponent) {
0 -> mantissa * (1f / (1 shl 24))
0x1F -> if (mantissa == 0) Float.POSITIVE_INFINITY else Float.NaN
else -> java.lang.Float.intBitsToFloat(((exponent + 112) shl 23) or (mantissa shl 13))
}
return if (bits and 0x8000 != 0) -magnitude else magnitude
}
/** Snorm8 vectors; a zero-length normal becomes +Z and a zero-length tangent stays zero. */
private fun snorm8Normal(data: ByteArray, offset: Int, out: FloatArray, at: Int, zeroFallback: Boolean) {
val x = data[offset] / 127f
val y = data[offset + 1] / 127f
val z = data[offset + 2] / 127f
normalizeInto(x, y, z, out, at, 1e-8f, if (zeroFallback) 0f else 1f)
}
/** Int2101010 normals: NativeMiiRenderer.DecodeInt2101010. */
internal fun decodeInt2101010(packed: Int, out: FloatArray, at: Int) {
val x = signExtend10(packed) / 511f
val y = signExtend10(packed shr 10) / 511f
val z = signExtend10(packed shr 20) / 511f
normalizeInto(x, y, z, out, at, 1e-5f, 1f)
}
private fun signExtend10(value: Int): Int {
val bits = value and 0x3FF
return if (bits and 0x200 != 0) bits - 0x400 else bits
}
/** Normalizes, or writes (0, 0, [fallbackZ]) when shorter than the threshold. */
internal fun normalizeInto(x: Float, y: Float, z: Float, out: FloatArray, at: Int, threshold: Float, fallbackZ: Float) {
val lengthSquared = x * x + y * y + z * z
if (lengthSquared < threshold) {
out[at] = 0f
out[at + 1] = 0f
out[at + 2] = fallbackZ
return
}
val length = sqrt(lengthSquared)
out[at] = x / length
out[at + 1] = y / length
out[at + 2] = z / length
}
}
}
internal class FflTexture(val width: Int, val height: Int, val format: Int, val pixels: ByteArray) {
val stride = when (format) {
R8 -> 1
RG8 -> 2
else -> 4
}
companion object {
const val R8 = 0
const val RG8 = 1
const val RGBA8 = 2
}
}
/** A decoded shape, three floats per position, normal and tangent, two per texcoord, four per parameter. */
internal class FflShape(
val positions: FloatArray,
val texcoords: FloatArray,
val normals: FloatArray,
val tangents: FloatArray,
val parameters: FloatArray,
val indices: IntArray,
/** The faceline's hair, nose and beard anchors. */
val translates: Array<FloatArray>?,
) {
val vertexCount get() = positions.size / 3
}
@@ -30,7 +30,8 @@ import org.wiicompiled.quest.R
* The app's entry point on the headset: a 2D panel modelled on the PC launcher (WheelWizard VR), * The app's entry point on the headset: a 2D panel modelled on the PC launcher (WheelWizard VR),
* with a Home page that sets up and starts the game, a My profiles page that shows the licences * with a Home page that sets up and starts the game, a My profiles page that shows the licences
* of Retro Rewind's save, a Patches page that finds mods for Retro Rewind on GameBanana or imports * of Retro Rewind's save, a Patches page that finds mods for Retro Rewind on GameBanana or imports
* them, and a Settings page that edits Config.toml. * them, a My Miis page that makes and edits the game's Miis, and a Settings page that edits
* Config.toml.
* *
* The APK carries no game code. Playing needs two things the player owns: the game files (DATA, * The APK carries no game code. Playing needs two things the player owns: the game files (DATA,
* extracted from their disc image here or on a PC) and the game itself (libmain.so, built from * extracted from their disc image here or on a PC) and the game itself (libmain.so, built from
@@ -43,7 +44,7 @@ import org.wiicompiled.quest.R
*/ */
class LauncherActivity : Activity() { class LauncherActivity : Activity() {
private enum class Page { Home, Profiles, Patches, Settings } private enum class Page { Home, Profiles, Patches, Miis, Settings }
/** What Home's main and secondary buttons do. */ /** What Home's main and secondary buttons do. */
private enum class Action { Play, Resume, SelectDisc, ImportGame, BuildGame, DownloadModPack, Reset } private enum class Action { Play, Resume, SelectDisc, ImportGame, BuildGame, DownloadModPack, Reset }
@@ -51,10 +52,12 @@ class LauncherActivity : Activity() {
private lateinit var navHome: View private lateinit var navHome: View
private lateinit var navProfiles: View private lateinit var navProfiles: View
private lateinit var navPatches: View private lateinit var navPatches: View
private lateinit var navMiis: View
private lateinit var navSettings: View private lateinit var navSettings: View
private lateinit var homePage: View private lateinit var homePage: View
private lateinit var profilesView: View private lateinit var profilesView: View
private lateinit var patchesView: View private lateinit var patchesView: View
private lateinit var miisView: View
private lateinit var settingsView: View private lateinit var settingsView: View
private lateinit var trails: WheelTrailsView private lateinit var trails: WheelTrailsView
private lateinit var playButton: View private lateinit var playButton: View
@@ -71,6 +74,7 @@ class LauncherActivity : Activity() {
private lateinit var gameToggle: LinearLayout private lateinit var gameToggle: LinearLayout
private lateinit var settings: SettingsPage private lateinit var settings: SettingsPage
private lateinit var patches: PatchesPage private lateinit var patches: PatchesPage
private lateinit var miis: MiisPage
private lateinit var profilesPage: ProfilesPage private lateinit var profilesPage: ProfilesPage
private lateinit var sidebarProfile: SidebarProfileCard private lateinit var sidebarProfile: SidebarProfileCard
@@ -108,10 +112,12 @@ class LauncherActivity : Activity() {
navHome = findViewById(R.id.nav_home) navHome = findViewById(R.id.nav_home)
navProfiles = findViewById(R.id.nav_profiles) navProfiles = findViewById(R.id.nav_profiles)
navPatches = findViewById(R.id.nav_patches) navPatches = findViewById(R.id.nav_patches)
navMiis = findViewById(R.id.nav_miis)
navSettings = findViewById(R.id.nav_settings) navSettings = findViewById(R.id.nav_settings)
homePage = findViewById(R.id.page_home) homePage = findViewById(R.id.page_home)
profilesView = findViewById(R.id.page_profiles) profilesView = findViewById(R.id.page_profiles)
patchesView = findViewById(R.id.page_patches) patchesView = findViewById(R.id.page_patches)
miisView = findViewById(R.id.page_miis)
settingsView = findViewById(R.id.page_settings) settingsView = findViewById(R.id.page_settings)
trails = findViewById(R.id.home_trails) trails = findViewById(R.id.home_trails)
playButton = findViewById(R.id.home_play) playButton = findViewById(R.id.home_play)
@@ -147,6 +153,13 @@ class LauncherActivity : Activity() {
patches = PatchesPage(this, patchesView) { patches = PatchesPage(this, patchesView) {
openPicker(REQUEST_PATCH_FILES, multiple = true, noPicker = R.string.patches_no_picker) openPicker(REQUEST_PATCH_FILES, multiple = true, noPicker = R.string.patches_no_picker)
} }
miis = MiisPage(
this,
miisView,
gameRunning = ::isGameRunning,
pickImport = { openPicker(REQUEST_MII_FILES, multiple = true, noPicker = R.string.miis_no_picker) },
pickExport = ::openMiiExport,
)
sidebarProfile = SidebarProfileCard(this, findViewById(R.id.sidebar_profile)) { showPage(Page.Profiles) } sidebarProfile = SidebarProfileCard(this, findViewById(R.id.sidebar_profile)) { showPage(Page.Profiles) }
profilesPage = ProfilesPage(this, profilesView) { snapshot -> sidebarProfile.show(snapshot) } profilesPage = ProfilesPage(this, profilesView) { snapshot -> sidebarProfile.show(snapshot) }
savedInstanceState?.getString(KEY_TAB)?.let { name -> savedInstanceState?.getString(KEY_TAB)?.let { name ->
@@ -156,6 +169,7 @@ class LauncherActivity : Activity() {
navHome.setOnClickListener { showPage(Page.Home) } navHome.setOnClickListener { showPage(Page.Home) }
navProfiles.setOnClickListener { showPage(Page.Profiles) } navProfiles.setOnClickListener { showPage(Page.Profiles) }
navPatches.setOnClickListener { showPage(Page.Patches) } navPatches.setOnClickListener { showPage(Page.Patches) }
navMiis.setOnClickListener { showPage(Page.Miis) }
navSettings.setOnClickListener { showPage(Page.Settings) } navSettings.setOnClickListener { showPage(Page.Settings) }
playButton.setOnClickListener { perform(mainAction) } playButton.setOnClickListener { perform(mainAction) }
secondary.setOnClickListener { secondaryAction?.let(::perform) } secondary.setOnClickListener { secondaryAction?.let(::perform) }
@@ -179,12 +193,21 @@ class LauncherActivity : Activity() {
showPage(Page.Patches) showPage(Page.Patches)
patches.showBrowser(true, modId.takeIf { it >= 0 }, install = intent.getBooleanExtra(EXTRA_DEBUG_INSTALL_MOD, false)) patches.showBrowser(true, modId.takeIf { it >= 0 }, install = intent.getBooleanExtra(EXTRA_DEBUG_INSTALL_MOD, false))
} }
// ...and open My Miis, optionally with the editor on a Mii and one of its pages.
if (BuildConfig.DEBUG && savedInstanceState == null && intent.hasExtra(EXTRA_DEBUG_MIIS)) {
val spec = intent.getStringExtra(EXTRA_DEBUG_MIIS).orEmpty()
Log.i(TAG, "Opening My Miis over adb: $spec")
showPage(Page.Miis)
miis.debugOpen(spec)
}
} }
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
override fun onBackPressed() { override fun onBackPressed() {
// The mod browser sits in place of the Patches list, so Back returns to the list first. // The mod browser sits in place of the Patches list, so Back returns to the list first, and
// so does the Mii editor on My Miis.
if (page == Page.Patches && patches.back()) return if (page == Page.Patches && patches.back()) return
if (page == Page.Miis && miis.back()) return
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
super.onBackPressed() super.onBackPressed()
} }
@@ -235,6 +258,17 @@ class LauncherActivity : Activity() {
patches.importPicked(uris) patches.importPicked(uris)
return return
} }
if (requestCode == REQUEST_MII_FILES) {
val clip = data.clipData
val uris = if (clip != null) (0 until clip.itemCount).map { clip.getItemAt(it).uri } else listOfNotNull(data.data)
showPage(Page.Miis)
miis.importPicked(uris)
return
}
if (requestCode == REQUEST_MII_EXPORT || requestCode == REQUEST_MII_EXPORT_FOLDER) {
data.data?.let { miis.exportPicked(it, folder = requestCode == REQUEST_MII_EXPORT_FOLDER) }
return
}
val uri = data.data ?: return val uri = data.data ?: return
val task = when (requestCode) { val task = when (requestCode) {
REQUEST_DISC_IMAGE -> GameSetup.Task.ExtractDisc REQUEST_DISC_IMAGE -> GameSetup.Task.ExtractDisc
@@ -251,10 +285,12 @@ class LauncherActivity : Activity() {
navHome.isSelected = target == Page.Home navHome.isSelected = target == Page.Home
navProfiles.isSelected = target == Page.Profiles navProfiles.isSelected = target == Page.Profiles
navPatches.isSelected = target == Page.Patches navPatches.isSelected = target == Page.Patches
navMiis.isSelected = target == Page.Miis
navSettings.isSelected = target == Page.Settings navSettings.isSelected = target == Page.Settings
homePage.visibility = if (target == Page.Home) View.VISIBLE else View.GONE homePage.visibility = if (target == Page.Home) View.VISIBLE else View.GONE
profilesView.visibility = if (target == Page.Profiles) View.VISIBLE else View.GONE profilesView.visibility = if (target == Page.Profiles) View.VISIBLE else View.GONE
patchesView.visibility = if (target == Page.Patches) View.VISIBLE else View.GONE patchesView.visibility = if (target == Page.Patches) View.VISIBLE else View.GONE
miisView.visibility = if (target == Page.Miis) View.VISIBLE else View.GONE
profilesPage.setVisible(target == Page.Profiles) profilesPage.setVisible(target == Page.Profiles)
settingsView.visibility = if (target == Page.Settings) View.VISIBLE else View.GONE settingsView.visibility = if (target == Page.Settings) View.VISIBLE else View.GONE
if (target == Page.Home) { if (target == Page.Home) {
@@ -268,6 +304,7 @@ class LauncherActivity : Activity() {
Page.Home -> refreshHome() Page.Home -> refreshHome()
Page.Profiles -> profilesPage.refresh() Page.Profiles -> profilesPage.refresh()
Page.Patches -> patches.refresh() Page.Patches -> patches.refresh()
Page.Miis -> miis.refresh()
Page.Settings -> settings.refresh() Page.Settings -> settings.refresh()
} }
} }
@@ -632,6 +669,28 @@ class LauncherActivity : Activity() {
} }
} }
/**
* Asks where Export saves the Miis: a new .mii file for one, a folder for several, which the
* PC would ask for one file at a time.
*/
private fun openMiiExport(suggestedName: String, several: Boolean) {
val intent = if (several) {
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
} else {
Intent(Intent.ACTION_CREATE_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("application/octet-stream")
.putExtra(Intent.EXTRA_TITLE, suggestedName)
}
try {
@Suppress("DEPRECATION")
startActivityForResult(intent, if (several) REQUEST_MII_EXPORT_FOLDER else REQUEST_MII_EXPORT)
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "No document picker", e)
Toast.makeText(this, R.string.miis_no_picker, Toast.LENGTH_LONG).show()
}
}
/** /**
* Imports the newest .wcgame in the Import folder, where Build-QuestGame.ps1 -Install and adb * Imports the newest .wcgame in the Import folder, where Build-QuestGame.ps1 -Install and adb
* put them. Each package is tried once, whether it imports or not, until it changes: a file * put them. Each package is tried once, whether it imports or not, until it changes: a file
@@ -759,6 +818,9 @@ class LauncherActivity : Activity() {
const val REQUEST_DISC_IMAGE = 1 const val REQUEST_DISC_IMAGE = 1
const val REQUEST_GAME_PACKAGE = 2 const val REQUEST_GAME_PACKAGE = 2
const val REQUEST_PATCH_FILES = 3 const val REQUEST_PATCH_FILES = 3
const val REQUEST_MII_FILES = 4
const val REQUEST_MII_EXPORT = 5
const val REQUEST_MII_EXPORT_FOLDER = 6
const val PREFERENCES = "launcher" const val PREFERENCES = "launcher"
const val KEY_LAST_DROPPED_IMPORT = "lastDroppedImport" const val KEY_LAST_DROPPED_IMPORT = "lastDroppedImport"
const val EXTRA_DEBUG_BUILD_GAME = "org.wiicompiled.quest.debug.BUILD_GAME" const val EXTRA_DEBUG_BUILD_GAME = "org.wiicompiled.quest.debug.BUILD_GAME"
@@ -766,5 +828,7 @@ class LauncherActivity : Activity() {
const val EXTRA_DEBUG_MOD_BROWSER = "org.wiicompiled.quest.debug.MOD_BROWSER" const val EXTRA_DEBUG_MOD_BROWSER = "org.wiicompiled.quest.debug.MOD_BROWSER"
/** With MOD_BROWSER naming a mod: install it as Download and Install would, without the dialogs. */ /** With MOD_BROWSER naming a mod: install it as Download and Install would, without the dialogs. */
const val EXTRA_DEBUG_INSTALL_MOD = "org.wiicompiled.quest.debug.INSTALL_MOD" const val EXTRA_DEBUG_INSTALL_MOD = "org.wiicompiled.quest.debug.INSTALL_MOD"
/** A string: empty for My Miis, or edit:N[:Section] for the editor on the Nth Mii and that page. */
const val EXTRA_DEBUG_MIIS = "org.wiicompiled.quest.debug.MIIS"
} }
} }
@@ -0,0 +1,384 @@
package org.wiicompiled.quest.launcher
import kotlin.random.Random
/**
* A Wii Mii, as the 74 bytes the Mii database stores (RFL's RFLCharData), field for field like the
* PC launcher's Mii model (WheelWizard's WiiManagement/MiiManagement). The ranges are the Wii's,
* which the editor keeps to and [MiiData.parse] checks.
*/
data class Mii(
var invalid: Boolean = false,
var girl: Boolean = false,
/** Kept as stored, 0 when the Mii has no birthday; the editor does not show it. */
var birthMonth: Int = 1,
var birthDay: Int = 1,
var favoriteColor: Int = 11,
var favorite: Boolean = false,
var name: String = "no name",
var height: Int = 1,
var weight: Int = 1,
/** Also called the avatar ID; licences refer to their Mii by it. */
var miiId: Long = 0,
/** Also called the client ID: derived from the MAC address of the console that made it. */
var systemId: Long = 0,
var faceShape: Int = 5,
var skinColor: Int = 0,
var facialFeature: Int = 0,
var mingleOff: Boolean = false,
var downloaded: Boolean = false,
var hairType: Int = 1,
var hairColor: Int = 0,
var hairFlipped: Boolean = false,
var eyebrowType: Int = 1,
var eyebrowRotation: Int = 0,
var eyebrowColor: Int = 0,
var eyebrowSize: Int = 4,
var eyebrowVertical: Int = 10,
var eyebrowSpacing: Int = 1,
var eyeType: Int = 1,
var eyeRotation: Int = 6,
var eyeVertical: Int = 7,
var eyeColor: Int = 0,
var eyeSize: Int = 3,
var eyeSpacing: Int = 6,
var noseType: Int = 0,
var noseSize: Int = 6,
var noseVertical: Int = 4,
var lipType: Int = 1,
var lipColor: Int = 0,
var lipSize: Int = 4,
var lipVertical: Int = 9,
var glassesType: Int = 0,
var glassesColor: Int = 0,
var glassesSize: Int = 4,
var glassesVertical: Int = 1,
var mustacheType: Int = 0,
var beardType: Int = 0,
var facialHairColor: Int = 0,
var mustacheSize: Int = 1,
var mustacheVertical: Int = 1,
var moleEnabled: Boolean = false,
var moleSize: Int = 0,
var moleVertical: Int = 0,
var moleHorizontal: Int = 0,
var creatorName: String = "no name",
) {
/** Every field that shows in a picture, which is what a render is cached by. */
fun lookKey(): String = listOf(
girl, favoriteColor, height, weight, faceShape, skinColor, facialFeature, hairType, hairColor, hairFlipped,
eyebrowType, eyebrowRotation, eyebrowColor, eyebrowSize, eyebrowVertical, eyebrowSpacing,
eyeType, eyeRotation, eyeVertical, eyeColor, eyeSize, eyeSpacing, noseType, noseSize, noseVertical,
lipType, lipColor, lipSize, lipVertical, glassesType, glassesColor, glassesSize, glassesVertical,
mustacheType, beardType, facialHairColor, mustacheSize, mustacheVertical,
moleEnabled, moleSize, moleVertical, moleHorizontal,
).joinToString(",")
}
/** The Wii's value ranges, from the PC's Mii part classes and editor pages. */
object MiiRanges {
const val NAME_LENGTH = 10
const val SCALE_MAX = 127
const val FAVORITE_COLORS = 12
const val FACE_SHAPES = 8
const val SKIN_COLORS = 6
const val FACIAL_FEATURES = 12
const val HAIR_TYPES = 72
const val HAIR_COLORS = 8
const val EYEBROW_TYPES = 24
val EYEBROW_ROTATION = 0..11
val EYEBROW_SIZE = 0..8
val EYEBROW_VERTICAL = 3..18
val EYEBROW_SPACING = 0..12
const val EYE_TYPES = 48
const val EYE_COLORS = 6
val EYE_ROTATION = 0..7
val EYE_VERTICAL = 0..18
val EYE_SIZE = 0..7
val EYE_SPACING = 0..12
const val NOSE_TYPES = 12
val NOSE_SIZE = 0..8
val NOSE_VERTICAL = 0..18
const val LIP_TYPES = 24
const val LIP_COLORS = 3
val LIP_SIZE = 0..8
val LIP_VERTICAL = 0..18
const val GLASSES_TYPES = 9
const val GLASSES_COLORS = 6
val GLASSES_SIZE = 0..7
val GLASSES_VERTICAL = 0..20
const val MUSTACHE_TYPES = 4
const val BEARD_TYPES = 4
val FACIAL_HAIR_SIZE = 0..8
val FACIAL_HAIR_VERTICAL = 0..16
val MOLE_SIZE = 0..8
val MOLE_VERTICAL = 0..30
val MOLE_HORIZONTAL = 0..16
}
/** The 74-byte Mii block: MiiSerializer on the PC. */
object MiiData {
const val SIZE = 74
/** Reads a block, refusing empty slots and values the Wii never writes. */
fun parse(data: ByteArray): Mii {
require(data.size == SIZE) { "Invalid Mii data length." }
require(!data.all { it == 0.toByte() } && !data.all { it == 0xFF.toByte() }) { "Mii data is empty." }
val mii = Mii()
val header = u16(data, 0)
mii.invalid = header and 0x8000 != 0
mii.girl = header and 0x4000 != 0
mii.birthMonth = (header shr 10) and 0x0F
mii.birthDay = (header shr 5) and 0x1F
mii.favoriteColor = check((header shr 1) and 0x0F, MiiRanges.FAVORITE_COLORS, "MiiFavoriteColor")
mii.favorite = header and 0x01 != 0
mii.name = name(data, 0x02)
require(mii.name.isNotEmpty()) { "Invalid MiiName" }
mii.height = check(data[0x16].toInt() and 0xFF, MiiRanges.SCALE_MAX + 1, "Height")
mii.weight = check(data[0x17].toInt() and 0xFF, MiiRanges.SCALE_MAX + 1, "Weight")
mii.miiId = u32(data, 0x18)
mii.systemId = u32(data, 0x1C)
val face = u16(data, 0x20)
mii.faceShape = (face shr 13) and 0x07
mii.skinColor = check((face shr 10) and 0x07, MiiRanges.SKIN_COLORS, "SkinColor")
mii.facialFeature = check((face shr 6) and 0x0F, MiiRanges.FACIAL_FEATURES, "FacialFeature")
mii.mingleOff = (face shr 2) and 0x01 != 0
mii.downloaded = face and 0x01 != 0
val hair = u16(data, 0x22)
mii.hairType = check((hair shr 9) and 0x7F, MiiRanges.HAIR_TYPES, "HairType")
mii.hairColor = (hair shr 6) and 0x07
mii.hairFlipped = (hair shr 5) and 0x01 != 0
val brow = u32(data, 0x24)
mii.eyebrowType = check(((brow shr 27) and 0x1F).toInt(), MiiRanges.EYEBROW_TYPES, "Eyebrow type")
mii.eyebrowRotation = check(((brow shr 22) and 0x0F).toInt(), MiiRanges.EYEBROW_ROTATION, "Eyebrow rotation")
mii.eyebrowColor = ((brow shr 13) and 0x07).toInt()
mii.eyebrowSize = check(((brow shr 9) and 0x0F).toInt(), MiiRanges.EYEBROW_SIZE, "Eyebrow size")
mii.eyebrowVertical = check(((brow shr 4) and 0x1F).toInt(), MiiRanges.EYEBROW_VERTICAL, "Eyebrow vertical position")
mii.eyebrowSpacing = check((brow and 0x0F).toInt(), MiiRanges.EYEBROW_SPACING, "Eyebrow spacing")
val eye = u32(data, 0x28)
mii.eyeType = check(((eye shr 26) and 0x3F).toInt(), MiiRanges.EYE_TYPES, "Eye type")
mii.eyeRotation = ((eye shr 21) and 0x07).toInt()
mii.eyeVertical = check(((eye shr 16) and 0x1F).toInt(), MiiRanges.EYE_VERTICAL, "Eye vertical position")
mii.eyeColor = check(((eye shr 13) and 0x07).toInt(), MiiRanges.EYE_COLORS, "EyeColor")
mii.eyeSize = ((eye shr 9) and 0x07).toInt()
mii.eyeSpacing = check(((eye shr 5) and 0x0F).toInt(), MiiRanges.EYE_SPACING, "Eye spacing")
val nose = u16(data, 0x2C)
mii.noseType = check((nose shr 12) and 0x0F, MiiRanges.NOSE_TYPES, "NoseType")
mii.noseSize = check((nose shr 8) and 0x0F, MiiRanges.NOSE_SIZE, "Nose size")
mii.noseVertical = check((nose shr 3) and 0x1F, MiiRanges.NOSE_VERTICAL, "Nose vertical position")
val lip = u16(data, 0x2E)
mii.lipType = check((lip shr 11) and 0x1F, MiiRanges.LIP_TYPES, "Lip type")
mii.lipColor = check((lip shr 9) and 0x03, MiiRanges.LIP_COLORS, "LipColor")
mii.lipSize = check((lip shr 5) and 0x0F, MiiRanges.LIP_SIZE, "Lip size")
mii.lipVertical = check(lip and 0x1F, MiiRanges.LIP_VERTICAL, "Lip vertical position")
val glasses = u16(data, 0x30)
mii.glassesType = check((glasses shr 12) and 0x0F, MiiRanges.GLASSES_TYPES, "GlassesType")
mii.glassesColor = check((glasses shr 9) and 0x07, MiiRanges.GLASSES_COLORS, "GlassesColor")
mii.glassesSize = (glasses shr 5) and 0x07
mii.glassesVertical = check(glasses and 0x1F, MiiRanges.GLASSES_VERTICAL, "Glasses vertical position")
val facial = u16(data, 0x32)
mii.mustacheType = (facial shr 14) and 0x03
mii.beardType = (facial shr 12) and 0x03
mii.facialHairColor = (facial shr 9) and 0x07
mii.mustacheSize = check((facial shr 5) and 0x0F, MiiRanges.FACIAL_HAIR_SIZE, "Facial hair size")
mii.mustacheVertical = check(facial and 0x1F, MiiRanges.FACIAL_HAIR_VERTICAL, "Facial hair vertical position")
val mole = u16(data, 0x34)
mii.moleEnabled = (mole shr 15) and 0x01 != 0
mii.moleSize = check((mole shr 11) and 0x0F, MiiRanges.MOLE_SIZE, "Mole size")
mii.moleVertical = check((mole shr 6) and 0x1F, MiiRanges.MOLE_VERTICAL, "Mole vertical position")
mii.moleHorizontal = check((mole shr 1) and 0x1F, MiiRanges.MOLE_HORIZONTAL, "Mole horizontal position")
mii.creatorName = name(data, 0x36)
return mii
}
fun serialize(mii: Mii): ByteArray {
require(mii.miiId != 0L) { "Mii ID cannot be 0." }
require(mii.name.length <= MiiRanges.NAME_LENGTH && mii.creatorName.length <= MiiRanges.NAME_LENGTH) {
"Mii name too long, maximum is 10 characters"
}
val data = ByteArray(SIZE)
var header = 0
if (mii.invalid) header = header or 0x8000
if (mii.girl) header = header or 0x4000
header = header or ((mii.birthMonth and 0x0F) shl 10) or ((mii.birthDay and 0x1F) shl 5)
header = header or ((mii.favoriteColor and 0x0F) shl 1)
if (mii.favorite) header = header or 0x01
put16(data, 0, header)
putName(data, 0x02, mii.name)
data[0x16] = mii.height.toByte()
data[0x17] = mii.weight.toByte()
put32(data, 0x18, mii.miiId)
put32(data, 0x1C, mii.systemId)
put16(
data, 0x20,
((mii.faceShape and 0x07) shl 13) or ((mii.skinColor and 0x07) shl 10) or
((mii.facialFeature and 0x0F) shl 6) or (bit(mii.mingleOff) shl 2) or bit(mii.downloaded),
)
put16(data, 0x22, ((mii.hairType and 0x7F) shl 9) or ((mii.hairColor and 0x07) shl 6) or (bit(mii.hairFlipped) shl 5))
put32(
data, 0x24,
((mii.eyebrowType.toLong() and 0x1F) shl 27) or ((mii.eyebrowRotation.toLong() and 0x0F) shl 22) or
((mii.eyebrowColor.toLong() and 0x07) shl 13) or ((mii.eyebrowSize.toLong() and 0x0F) shl 9) or
((mii.eyebrowVertical.toLong() and 0x1F) shl 4) or (mii.eyebrowSpacing.toLong() and 0x0F),
)
put32(
data, 0x28,
((mii.eyeType.toLong() and 0x3F) shl 26) or ((mii.eyeRotation.toLong() and 0x07) shl 21) or
((mii.eyeVertical.toLong() and 0x1F) shl 16) or ((mii.eyeColor.toLong() and 0x07) shl 13) or
((mii.eyeSize.toLong() and 0x07) shl 9) or ((mii.eyeSpacing.toLong() and 0x0F) shl 5),
)
put16(data, 0x2C, ((mii.noseType and 0x0F) shl 12) or ((mii.noseSize and 0x0F) shl 8) or ((mii.noseVertical and 0x1F) shl 3))
put16(
data, 0x2E,
((mii.lipType and 0x1F) shl 11) or ((mii.lipColor and 0x03) shl 9) or ((mii.lipSize and 0x0F) shl 5) or
(mii.lipVertical and 0x1F),
)
put16(
data, 0x30,
((mii.glassesType and 0x0F) shl 12) or ((mii.glassesColor and 0x07) shl 9) or
((mii.glassesSize and 0x07) shl 5) or (mii.glassesVertical and 0x1F),
)
put16(
data, 0x32,
((mii.mustacheType and 0x03) shl 14) or ((mii.beardType and 0x03) shl 12) or
((mii.facialHairColor and 0x07) shl 9) or ((mii.mustacheSize and 0x0F) shl 5) or (mii.mustacheVertical and 0x1F),
)
put16(
data, 0x34,
(bit(mii.moleEnabled) shl 15) or ((mii.moleSize and 0x0F) shl 11) or ((mii.moleVertical and 0x1F) shl 6) or
((mii.moleHorizontal and 0x1F) shl 1),
)
putName(data, 0x36, mii.creatorName)
return data
}
/** The Mii ID's lower 29 bits count 4-second ticks from 2006, so it also dates the Mii. */
fun creationTimeMillis(miiId: Long): Long? =
if (miiId == 0L) null else EPOCH_2006_MILLIS + (miiId and 0x1FFFFFFF) * 4_000L
const val EPOCH_2006_MILLIS = 1_136_073_600_000L
private fun check(value: Int, count: Int, what: String): Int = check(value, 0 until count, what)
private fun check(value: Int, range: IntRange, what: String): Int {
require(value in range) { "Invalid $what" }
return value
}
private fun bit(value: Boolean) = if (value) 1 else 0
private fun u16(data: ByteArray, offset: Int) = ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
private fun u32(data: ByteArray, offset: Int): Long = (u16(data, offset).toLong() shl 16) or u16(data, offset + 2).toLong()
private fun put16(data: ByteArray, offset: Int, value: Int) {
data[offset] = (value shr 8).toByte()
data[offset + 1] = value.toByte()
}
private fun put32(data: ByteArray, offset: Int, value: Long) {
put16(data, offset, (value shr 16).toInt())
put16(data, offset + 2, value.toInt())
}
private fun name(data: ByteArray, offset: Int): String =
String(data, offset, 20, Charsets.UTF_16BE).trimEnd('\u0000')
private fun putName(data: ByteArray, offset: Int, name: String) {
name.padEnd(MiiRanges.NAME_LENGTH, '\u0000').toByteArray(Charsets.UTF_16BE).copyInto(data, offset)
}
}
/** New Miis, as the PC's MiiFactory makes them. */
object MiiFactory {
private fun base(name: String) = Mii(
name = name,
creatorName = "",
favorite = false,
favoriteColor = 0,
faceShape = 0,
skinColor = 0,
facialFeature = 0,
hairType = 30,
hairColor = 1,
hairFlipped = false,
eyebrowType = 0, eyebrowRotation = 6, eyebrowColor = 1, eyebrowSize = 4, eyebrowVertical = 10, eyebrowSpacing = 2,
glassesType = 0, glassesColor = 0, glassesSize = 4, glassesVertical = 10,
eyeType = 2, eyeRotation = 4, eyeVertical = 12, eyeColor = 0, eyeSize = 4, eyeSpacing = 2,
noseType = 1, noseSize = 4, noseVertical = 9,
lipType = 23, lipColor = 0, lipSize = 4, lipVertical = 13,
mustacheType = 0, beardType = 0, facialHairColor = 0, mustacheSize = 4, mustacheVertical = 10,
moleEnabled = false, moleSize = 4, moleVertical = 20, moleHorizontal = 2,
height = 63,
weight = 63,
miiId = 1,
)
fun female(name: String): Mii = base(name).apply {
girl = true
hairType = 12
eyeType = 4
eyeRotation = 3
}
fun male(name: String): Mii = base(name).apply {
girl = false
hairType = 33
eyebrowType = 6
}
fun random(name: String, random: Random = Random.Default): Mii = base(name).apply {
val hair = random.nextInt(MiiRanges.HAIR_COLORS)
girl = random.nextInt(2) == 0
hairType = random.nextInt(71)
hairColor = hair
hairFlipped = random.nextInt(3) == 0
eyebrowType = random.nextInt(23)
eyebrowColor = hair
eyeType = random.nextInt(47)
eyeColor = random.nextInt(MiiRanges.EYE_COLORS)
favoriteColor = random.nextInt(MiiRanges.FAVORITE_COLORS)
faceShape = random.nextInt(MiiRanges.FACE_SHAPES)
skinColor = random.nextInt(MiiRanges.SKIN_COLORS)
facialFeature = random.nextInt(MiiRanges.FACIAL_FEATURES)
noseType = random.nextInt(MiiRanges.NOSE_TYPES)
lipType = random.nextInt(23)
lipColor = random.nextInt(MiiRanges.LIP_COLORS)
moleEnabled = random.nextInt(4) == 0
if (random.nextInt(4) == 0) {
glassesType = random.nextInt(MiiRanges.GLASSES_TYPES)
glassesColor = random.nextInt(MiiRanges.GLASSES_COLORS)
}
if (random.nextInt(4) == 0) {
mustacheType = random.nextInt(MiiRanges.MUSTACHE_TYPES)
beardType = random.nextInt(MiiRanges.BEARD_TYPES)
facialHairColor = hair
}
}
/**
* The editor's Randomize: a new look that keeps who the Mii is. The PC also resets the birthday
* and the mingle and downloaded flags, which the editor does not show; those are kept here.
*/
fun randomLook(of: Mii, random: Random = Random.Default): Mii = random(of.name, random).also {
it.favorite = of.favorite
it.miiId = of.miiId
it.systemId = of.systemId
it.creatorName = of.creatorName
it.birthMonth = of.birthMonth
it.birthDay = of.birthDay
it.mingleOff = of.mingleOff
it.downloaded = of.downloaded
}
}
@@ -0,0 +1,216 @@
package org.wiicompiled.quest.launcher
import java.io.File
import java.io.IOException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
/**
* The Wii's Mii database, `shared2/menu/FaceLib/RFL_DB.dat` in the runtime's NAND, which the game
* reads its Miis from. A port of the PC launcher's MiiRepositoryService and MiiDbService: 100
* slots of 74 bytes after the "RNOD" magic, an empty slot being all zeros, and a CRC-16 over the
* first 0x1F1DE bytes stored after them.
*
* The Quest's NAND starts without one, so the page creates the PC's empty database the first
* time it is opened. Writes go through a temporary file, so a failure never leaves half a file.
*/
object MiiDatabase {
const val SLOTS = 100
const val FILE_SIZE = 779_968
private const val HEADER = 4
private const val CRC_OFFSET = 0x1F1DE
private const val HIDDEN_ENTRIES = 10_000
private const val HIDDEN_ENTRY_SIZE = 12
/** The first hidden entry's pair of 0x7FFF links. */
private const val HIDDEN_LINKS = 0x1D10
fun file(nand: File): File = File(nand, "shared2/menu/FaceLib/RFL_DB.dat")
/**
* A new database: the PC's (both magics, the hidden database's empty markers and the CRC), and
* like one the Wii formats, the hidden database's 10,000 entries linked to nothing (0x7FFF).
* The PC leaves those links zero; the game's Mii library reads this file, so it gets the Wii's.
*/
fun empty(): ByteArray {
val db = ByteArray(FILE_SIZE)
"RNOD".toByteArray(Charsets.US_ASCII).copyInto(db, 0)
db[0x1CE0 + 0x0C] = 0x80.toByte()
"RNHD".toByteArray(Charsets.US_ASCII).copyInto(db, 0x1D00)
for (i in 0x1D04..0x1D07) db[i] = 0xFF.toByte()
for (entry in 0 until HIDDEN_ENTRIES) {
val at = HIDDEN_LINKS + entry * HIDDEN_ENTRY_SIZE
db[at] = 0x7F
db[at + 1] = 0xFF.toByte()
db[at + 2] = 0x7F
db[at + 3] = 0xFF.toByte()
}
writeCrc(db)
return db
}
/** CRC-16/XMODEM, CrcHelper.ComputeCrc16Ccitt on the PC. */
fun crc16(data: ByteArray, offset: Int, length: Int): Int {
var crc = 0
for (i in offset until offset + length) {
crc = crc xor ((data[i].toInt() and 0xFF) shl 8)
repeat(8) { crc = if (crc and 0x8000 != 0) ((crc shl 1) xor 0x1021) and 0xFFFF else (crc shl 1) and 0xFFFF }
}
return crc
}
/** Creates the empty database unless there is one. */
@Throws(IOException::class)
fun create(file: File) {
if (file.exists()) return
file.parentFile?.let { if (!it.isDirectory && !it.mkdirs()) throw IOException("Cannot create ${it.path}") }
write(file, empty())
}
/** The 100 slots, null where empty. */
@Throws(IOException::class)
fun slots(file: File): List<ByteArray?> = slots(file.readBytes())
fun slots(db: ByteArray): List<ByteArray?> = (0 until SLOTS).map { slot ->
val start = HEADER + slot * MiiData.SIZE
if (start + MiiData.SIZE > db.size) return@map null
db.copyOfRange(start, start + MiiData.SIZE).takeUnless { block -> block.all { it == 0.toByte() } }
}
/**
* Every Mii in the database, in slot order. A slot the PC could not read either is left out;
* it stays in the file untouched.
*/
@Throws(IOException::class)
fun miis(file: File): List<Mii> = slots(file).mapNotNull { block -> block?.let { runCatching { MiiData.parse(it) }.getOrNull() } }
/** Adds a Mii in the first free slot. */
@Throws(IOException::class)
fun add(file: File, mii: Mii) = edit(file) { db ->
val block = MiiData.serialize(mii)
val free = (0 until SLOTS).firstOrNull { slot -> isEmpty(db, slot) } ?: throw IOException("No empty Mii slot available.")
block.copyInto(db, offset(free))
}
/** Replaces the Mii with the same ID. */
@Throws(IOException::class)
fun update(file: File, mii: Mii) {
val block = MiiData.serialize(mii)
edit(file) { db -> block.copyInto(db, offset(slotOf(db, mii.miiId))) }
}
/** Empties the Mii's slot. */
@Throws(IOException::class)
fun remove(file: File, miiId: Long) = edit(file) { db -> ByteArray(MiiData.SIZE).copyInto(db, offset(slotOf(db, miiId))) }
private fun edit(file: File, change: (ByteArray) -> Unit) {
if (!file.isFile) throw IOException("RFL_DB.dat not found.")
val db = file.readBytes()
if (db.size < CRC_OFFSET + 2) throw IOException("RFL_DB.dat is too short (${db.size} bytes).")
val stored = ((db[CRC_OFFSET].toInt() and 0xFF) shl 8) or (db[CRC_OFFSET + 1].toInt() and 0xFF)
val computed = crc16(db, 0, CRC_OFFSET)
if (stored != computed) {
throw IOException("Corrupt Mii database (bad CRC 0x%04X, expected 0x%04X).".format(stored, computed))
}
change(db)
writeCrc(db)
write(file, db)
}
private fun slotOf(db: ByteArray, miiId: Long): Int {
if (miiId == 0L) throw IOException("Invalid Client ID.")
return (0 until SLOTS).firstOrNull { slot -> !isEmpty(db, slot) && readId(db, offset(slot)) == miiId }
?: throw IOException("Mii not found.")
}
private fun offset(slot: Int) = HEADER + slot * MiiData.SIZE
private fun isEmpty(db: ByteArray, slot: Int): Boolean {
val start = offset(slot)
return (start until start + MiiData.SIZE).all { db[it] == 0.toByte() }
}
private fun readId(db: ByteArray, block: Int): Long =
(0 until 4).fold(0L) { id, i -> (id shl 8) or (db[block + 0x18 + i].toLong() and 0xFF) }
private fun writeCrc(db: ByteArray) {
val crc = crc16(db, 0, CRC_OFFSET)
db[CRC_OFFSET] = (crc shr 8).toByte()
db[CRC_OFFSET + 1] = crc.toByte()
}
private fun write(file: File, db: ByteArray) {
val temporary = File(file.parentFile, file.name + ".partial")
try {
temporary.writeBytes(db)
Files.move(temporary.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
} finally {
temporary.delete()
}
}
}
/**
* The IDs that make a Mii this headset's own: MiiDbService.AddToDatabase and GenerateMiiId on the
* PC. The system ID comes from the console's MAC address, which the runtime derives from the NAND's
* serial number (RuntimeConsoleIdentity::FromSerial), so a Mii made here belongs to the same
* console as the game's saves.
*/
object MiiIds {
/** The PC gives imported Miis this address, so they never pass for the console's own. */
val IMPORT_MAC = byteArrayOf(0x02, 0x11, 0x11, 0x11, 0x11, 0x11)
private var lastCounter = -1L
private var sequenceOffset = 0L
fun systemId(mac: ByteArray): Long {
val first = ((mac[0].toInt() and 0xFF) + (mac[1].toInt() and 0xFF) + (mac[2].toInt() and 0xFF)) and 0xFF
return (first.toLong() shl 24) or ((mac[3].toLong() and 0xFF) shl 16) or ((mac[4].toLong() and 0xFF) shl 8) or (mac[5].toLong() and 0xFF)
}
/** A new Mii ID: 0b100 and a 4-second counter from 2006, bumped when two share a tick. */
@Synchronized
fun newMiiId(nowMillis: Long = System.currentTimeMillis()): Long {
val base = ((nowMillis - MiiData.EPOCH_2006_MILLIS) / 4_000L) and 0xFFFFFFFFL
if (base == lastCounter) {
sequenceOffset++
} else {
lastCounter = base
sequenceOffset = 0
}
return (0b100L shl 29) or ((base + sequenceOffset) and 0x1FFFFFFF)
}
/**
* The console's MAC address, as the runtime computes it from `setting.txt` in the NAND, or
* null before the game has created that file on its first start.
*/
fun consoleMac(nand: File): ByteArray? {
val serial = consoleSerial(File(nand, "title/00000001/00000002/data/setting.txt")) ?: return null
var hash = 2166136261L
for (byte in serial.toByteArray(Charsets.US_ASCII)) {
hash = ((hash xor (byte.toLong() and 0xFF)) * 16777619L) and 0xFFFFFFFFL
}
var suffix = hash and 0x00FFFFFF
if (suffix == 0L || suffix == 0x00FFFFFFL) suffix = suffix xor 0x005A17C3
return byteArrayOf(0x00, 0x09, 0xBF.toByte(), (suffix shr 16).toByte(), (suffix shr 8).toByte(), suffix.toByte())
}
/** SERNO from the Wii's setting.txt, a 256-byte buffer under a rotating XOR key. */
fun consoleSerial(file: File): String? {
val bytes = runCatching { file.readBytes() }.getOrNull()?.takeIf { it.size >= 256 } ?: return null
var key = 0x73B5DBFA
val text = StringBuilder()
for (i in 0 until 256) {
val value = ((bytes[i].toInt() xor key) and 0xFF).toChar()
key = (key shl 1) or (key ushr 31)
if (value == '\u0000') break
if (value != '\r') text.append(value)
}
val serial = text.lineSequence()
.mapNotNull { line -> line.split('=', limit = 2).takeIf { it.size == 2 && it[0] == "SERNO" }?.get(1) }
.firstOrNull()
?: return null
val valid = serial.length in 1..9 && serial.all { it in '0'..'9' } && serial.any { it != '0' }
return serial.takeIf { valid }
}
}
@@ -0,0 +1,675 @@
package org.wiicompiled.quest.launcher
import android.app.Activity
import android.app.AlertDialog
import android.content.res.ColorStateList
import android.graphics.drawable.GradientDrawable
import android.text.Editable
import android.text.InputFilter
import android.text.TextWatcher
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.CheckBox
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.RadioButton
import android.widget.RadioGroup
import android.widget.ScrollView
import android.widget.SeekBar
import android.widget.Spinner
import android.widget.TextView
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import org.wiicompiled.quest.R
/**
* WheelWizard's Mii editor (MiiEditorWindow and its EditorStartPage, EditorGeneral, EditorFace
* and the other part pages) in place of My Miis' list: the pages on the left, the Mii's face on
* the right, redrawn after every change. The PC draws each choice from icons of its own; here each
* is drawn from the Mii parts, in the Mii's colours, or as the head wearing it.
*/
class MiiEditor(private val activity: Activity, private val root: View, private val onClose: () -> Unit) {
private enum class Section(val title: Int) {
General(R.string.mii_section_general),
Face(R.string.mii_section_face),
Hair(R.string.mii_section_hair),
Eyebrows(R.string.mii_section_eyebrows),
Eyes(R.string.mii_section_eyes),
Nose(R.string.mii_section_nose),
Lips(R.string.mii_section_lips),
Glasses(R.string.mii_section_glasses),
FacialHair(R.string.mii_section_facial_hair),
Mole(R.string.mii_section_mole),
}
private val start: View = root.findViewById(R.id.mii_editor_start)
private val page: View = root.findViewById(R.id.mii_editor_page)
private val favorite: ImageView = root.findViewById(R.id.mii_editor_favorite)
private val name: TextView = root.findViewById(R.id.mii_editor_name)
private val sections: LinearLayout = root.findViewById(R.id.mii_editor_sections)
private val pageTitle: TextView = root.findViewById(R.id.mii_editor_page_title)
private val pageScroll: ScrollView = root.findViewById(R.id.mii_editor_page_scroll)
private val content: LinearLayout = root.findViewById(R.id.mii_editor_page_content)
private val preview: ImageView = root.findViewById(R.id.mii_editor_preview)
private val placeholder: View = root.findViewById(R.id.mii_editor_placeholder)
private val note: View = root.findViewById(R.id.mii_editor_note)
private var mii = Mii()
private var original = Mii()
private var section: Section? = null
private var onSave: (Mii) -> Unit = {}
val isOpen get() = root.visibility == View.VISIBLE
init {
for (entry in Section.entries) {
sections.addView(
TextView(activity).apply {
text = activity.getString(entry.title)
textSize = 16f
setTextColor(activity.getColor(R.color.neutral_100))
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(14), 0, dp(12), 0)
background = activity.getDrawable(R.drawable.bg_nav_item)
setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, activity.getDrawable(R.drawable.ic_arrow_right), null)
compoundDrawableTintList = ColorStateList.valueOf(activity.getColor(R.color.neutral_400))
isClickable = true
isFocusable = true
setOnClickListener { showSection(entry) }
},
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(44)).apply { bottomMargin = dp(2) },
)
}
favorite.setOnClickListener {
mii.favorite = !mii.favorite
showFavorite()
}
root.findViewById<View>(R.id.mii_editor_randomize).setOnClickListener {
mii = MiiFactory.randomLook(mii)
changed()
}
root.findViewById<View>(R.id.mii_editor_cancel).setOnClickListener { close() }
root.findViewById<View>(R.id.mii_editor_save).setOnClickListener {
val edited = mii.copy()
close()
onSave(edited)
}
root.findViewById<View>(R.id.mii_editor_back).setOnClickListener { showStart() }
}
/** Opens the editor on a copy of [mii]; [onSave] gets the edited Mii. */
fun open(mii: Mii, isNew: Boolean, onSave: (Mii) -> Unit) {
this.mii = mii.copy()
// A new Mii counts as changed, so Back asks before throwing it away.
original = if (isNew) Mii() else mii.copy()
this.onSave = onSave
root.visibility = View.VISIBLE
preview.setImageDrawable(null)
showStart()
refreshPicture()
}
/** For unattended tests: opens the page named [name], such as Hair. */
fun debugShow(name: String) {
Section.entries.firstOrNull { it.name.equals(name, ignoreCase = true) }?.let(::showSection)
}
/** Back: from a page to the start page, from there out of the editor, asking first if the Mii changed. */
fun back(): Boolean {
if (!isOpen) return false
if (section != null) {
showStart()
return true
}
if (mii == original) {
close()
return true
}
AlertDialog.Builder(activity)
.setTitle(R.string.mii_editor_discard_title)
.setMessage(R.string.mii_editor_discard_message)
.setPositiveButton(R.string.mii_editor_discard) { _, _ -> close() }
.setNegativeButton(android.R.string.cancel, null)
.show()
return true
}
/** Draws the face again, such as once the Mii parts are installed. */
fun refreshPicture() {
val installed = MiiRenderResource.installed(activity)
note.visibility = if (installed) View.GONE else View.VISIBLE
if (!installed) {
preview.setImageDrawable(null)
placeholder.visibility = View.VISIBLE
return
}
val size = (PREVIEW_DP * activity.resources.displayMetrics.density).toInt() and 1.inv()
MiiImages.preview(activity, mii, size) { bitmap ->
if (!isOpen) return@preview
preview.setImageBitmap(bitmap)
placeholder.visibility = if (bitmap == null) View.VISIBLE else View.GONE
}
section?.let { renderSection(it, keepScroll = true) }
}
private fun close() {
root.visibility = View.GONE
section = null
content.removeAllViews()
onClose()
}
private fun showStart() {
section = null
page.visibility = View.GONE
start.visibility = View.VISIBLE
content.removeAllViews()
name.text = mii.name
showFavorite()
}
private fun showFavorite() {
favorite.setImageResource(if (mii.favorite) R.drawable.ic_star else R.drawable.ic_star_empty)
favorite.imageTintList = ColorStateList.valueOf(activity.getColor(if (mii.favorite) R.color.warning_500 else R.color.neutral_400))
}
private fun showSection(target: Section) {
section = target
start.visibility = View.GONE
page.visibility = View.VISIBLE
pageTitle.setText(target.title)
renderSection(target, keepScroll = false)
}
/** Something about the Mii changed: the face is drawn again, and the open page shows the new values. */
private fun changed() {
name.text = mii.name
refreshPicture()
}
private fun renderSection(target: Section, keepScroll: Boolean) {
val scrollY = pageScroll.scrollY
content.removeAllViews()
when (target) {
Section.General -> general()
Section.Face -> face()
Section.Hair -> hair()
Section.Eyebrows -> eyebrows()
Section.Eyes -> eyes()
Section.Nose -> nose()
Section.Lips -> lips()
Section.Glasses -> glasses()
Section.FacialHair -> facialHair()
Section.Mole -> mole()
}
pageScroll.post { pageScroll.scrollTo(0, if (keepScroll) scrollY else 0) }
}
// --- The pages ---
/** EditorGeneral. */
private fun general() {
label(R.string.mii_name)
val nameError = errorText()
textField(mii.name, R.string.mii_name_hint) { text ->
// MiiName: 3 to 10 characters; the name only changes while it is valid.
val trimmed = text.trim()
val valid = trimmed.length in 3..MiiRanges.NAME_LENGTH
nameError.visibility = if (valid) View.GONE else View.VISIBLE
if (valid) {
mii.name = text
name.text = text
}
}
nameError.setText(R.string.mii_name_length)
content.addView(nameError)
spacer()
label(R.string.mii_creator_name)
textField(mii.creatorName, R.string.mii_creator_hint) { text -> mii.creatorName = text }
spacer()
label(R.string.mii_creation_date)
content.addView(
TextView(activity).apply {
text = MiiData.creationTimeMillis(mii.miiId)?.takeIf { mii.miiId != 1L }?.let {
SimpleDateFormat("yyyy-MM-dd HH:mm 'UTC'", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(Date(it))
} ?: activity.getString(R.string.mii_creation_unknown)
textSize = 13f
setTextColor(activity.getColor(R.color.neutral_400))
},
)
spacer()
label(R.string.mii_gender)
val group = RadioGroup(activity).apply { orientation = RadioGroup.HORIZONTAL }
for ((index, text) in listOf(R.string.mii_gender_male, R.string.mii_gender_female).withIndex()) {
group.addView(
RadioButton(activity).apply {
id = View.generateViewId()
setText(text)
setTextColor(activity.getColor(R.color.neutral_100))
buttonTintList = ColorStateList.valueOf(activity.getColor(R.color.primary_400))
isChecked = mii.girl == (index == 1)
setOnCheckedChangeListener { _, checked ->
if (checked && mii.girl != (index == 1)) {
mii.girl = index == 1
changed()
}
}
},
LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { marginEnd = dp(16) },
)
}
content.addView(group)
spacer()
slider(R.string.mii_height, mii.height) { mii.height = it }
slider(R.string.mii_width, mii.weight) { mii.weight = it }
spacer()
label(R.string.mii_favorite_color)
colorGrid(FAVORITE_COLORS, 5, mii.favoriteColor) { mii.favoriteColor = it }
}
/** EditorFace. */
private fun face() {
label(R.string.mii_skin_color)
colorGrid(SKIN_COLORS, 6, mii.skinColor) { mii.skinColor = it }
spacer()
label(R.string.mii_head_shape)
choiceGrid(MiiRanges.FACE_SHAPES, mii.faceShape, { view, i -> head(view, mii.copy(faceShape = i)) }) { mii.faceShape = it }
spacer()
label(R.string.mii_facial_feature)
dropdown(activity.resources.getStringArray(R.array.mii_facial_features).toList(), mii.facialFeature) { mii.facialFeature = it }
}
/** EditorHair. */
private fun hair() {
label(R.string.mii_hair_color)
colorGrid(HAIR_COLORS, 5, mii.hairColor) { mii.hairColor = it }
spacer()
checkbox(R.string.mii_mirror_hair, mii.hairFlipped) { mii.hairFlipped = it }
spacer()
label(R.string.mii_hair_type)
choiceGrid(MiiRanges.HAIR_TYPES, mii.hairType, { view, i -> head(view, mii.copy(hairType = i)) }) { mii.hairType = it }
}
/** EditorEyebrows. */
private fun eyebrows() {
label(R.string.mii_hair_color)
colorGrid(HAIR_COLORS, 5, mii.eyebrowColor) { mii.eyebrowColor = it }
spacer()
stepper(R.string.mii_vertical_position, mii.eyebrowVertical, MiiRanges.EYEBROW_VERTICAL, { (it - 10) * -1 }, VERTICAL) { mii.eyebrowVertical = it }
stepper(R.string.mii_rotation, mii.eyebrowRotation, MiiRanges.EYEBROW_ROTATION, { it - 6 }, ROTATION) { mii.eyebrowRotation = it }
stepper(R.string.mii_size, mii.eyebrowSize, MiiRanges.EYEBROW_SIZE, { it }, SIZE) { mii.eyebrowSize = it }
stepper(R.string.mii_space_between, mii.eyebrowSpacing, MiiRanges.EYEBROW_SPACING, { it }, SIZE) { mii.eyebrowSpacing = it }
label(R.string.mii_eyebrow_type)
choiceGrid(MiiRanges.EYEBROW_TYPES, mii.eyebrowType, part(MiiRenderer.Part.Eyebrow, none = NO_EYEBROWS)) { mii.eyebrowType = it }
}
/** EditorEyes. */
private fun eyes() {
label(R.string.mii_eye_color)
colorGrid(EYE_COLORS, 6, mii.eyeColor) { mii.eyeColor = it }
spacer()
stepper(R.string.mii_vertical_position, mii.eyeVertical, MiiRanges.EYE_VERTICAL, { (it - 12) * -1 }, VERTICAL) { mii.eyeVertical = it }
stepper(R.string.mii_rotation, mii.eyeRotation, MiiRanges.EYE_ROTATION, { it - 4 }, ROTATION) { mii.eyeRotation = it }
stepper(R.string.mii_size, mii.eyeSize, MiiRanges.EYE_SIZE, { it }, SIZE) { mii.eyeSize = it }
stepper(R.string.mii_space_between, mii.eyeSpacing, MiiRanges.EYE_SPACING, { it }, SIZE) { mii.eyeSpacing = it }
label(R.string.mii_eye_type)
choiceGrid(MiiRanges.EYE_TYPES, mii.eyeType, part(MiiRenderer.Part.Eye)) { mii.eyeType = it }
}
/** EditorNose. */
private fun nose() {
stepper(R.string.mii_vertical_position, mii.noseVertical, MiiRanges.NOSE_VERTICAL, { (it - 9) * -1 }, VERTICAL) { mii.noseVertical = it }
stepper(R.string.mii_size, mii.noseSize, MiiRanges.NOSE_SIZE, { it }, SIZE) { mii.noseSize = it }
label(R.string.mii_nose_type)
choiceGrid(MiiRanges.NOSE_TYPES, mii.noseType, part(MiiRenderer.Part.Nose)) { mii.noseType = it }
}
/** EditorLips. */
private fun lips() {
label(R.string.mii_lip_color)
colorGrid(LIP_COLORS, 5, mii.lipColor) { mii.lipColor = it }
spacer()
stepper(R.string.mii_vertical_position, mii.lipVertical, MiiRanges.LIP_VERTICAL, { (it - 13) * -1 }, VERTICAL) { mii.lipVertical = it }
stepper(R.string.mii_size, mii.lipSize, MiiRanges.LIP_SIZE, { it }, SIZE) { mii.lipSize = it }
label(R.string.mii_mouth_type)
choiceGrid(MiiRanges.LIP_TYPES, mii.lipType, part(MiiRenderer.Part.Mouth)) { mii.lipType = it }
}
/** EditorGlasses. */
private fun glasses() {
label(R.string.mii_glasses_type)
choiceGrid(MiiRanges.GLASSES_TYPES, mii.glassesType, part(MiiRenderer.Part.Glasses, none = 0)) { mii.glassesType = it }
spacer()
label(R.string.mii_glasses_color)
colorGrid(GLASSES_COLORS, 6, mii.glassesColor) { mii.glassesColor = it }
spacer()
stepper(R.string.mii_vertical_position, mii.glassesVertical, MiiRanges.GLASSES_VERTICAL, { (it - 10) * -1 }, VERTICAL) { mii.glassesVertical = it }
stepper(R.string.mii_size, mii.glassesSize, MiiRanges.GLASSES_SIZE, { it }, SIZE) { mii.glassesSize = it }
}
/** EditorBeardPage. */
private fun facialHair() {
label(R.string.mii_hair_color)
colorGrid(HAIR_COLORS, 5, mii.facialHairColor) { mii.facialHairColor = it }
spacer()
label(R.string.mii_mustache_type)
choiceGrid(MiiRanges.MUSTACHE_TYPES, mii.mustacheType, part(MiiRenderer.Part.Mustache, none = 0)) { mii.mustacheType = it }
spacer()
stepper(R.string.mii_mustache_vertical, mii.mustacheVertical, MiiRanges.FACIAL_HAIR_VERTICAL, { (it - 10) * -1 }, VERTICAL) { mii.mustacheVertical = it }
stepper(R.string.mii_mustache_size, mii.mustacheSize, MiiRanges.FACIAL_HAIR_SIZE, { it }, SIZE) { mii.mustacheSize = it }
label(R.string.mii_beard_type)
choiceGrid(MiiRanges.BEARD_TYPES, mii.beardType, { view, i -> head(view, mii.copy(beardType = i)) }) { mii.beardType = it }
}
/** EditorMole. */
private fun mole() {
checkbox(R.string.mii_mole_enabled, mii.moleEnabled) { mii.moleEnabled = it }
spacer()
stepper(R.string.mii_vertical_position, mii.moleVertical, MiiRanges.MOLE_VERTICAL, { (it - 20) * -1 }, VERTICAL) { mii.moleVertical = it }
stepper(R.string.mii_horizontal_position, mii.moleHorizontal, MiiRanges.MOLE_HORIZONTAL, { it - 8 }, HORIZONTAL) { mii.moleHorizontal = it }
stepper(R.string.mii_size, mii.moleSize, MiiRanges.MOLE_SIZE, { it }, SIZE) { mii.moleSize = it }
}
// --- Fields ---
private fun label(text: Int) {
content.addView(
TextView(activity, null, 0, R.style.Launcher_FieldLabel).apply { setText(text) },
LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { bottomMargin = dp(6) },
)
}
private fun spacer() {
content.addView(View(activity), LinearLayout.LayoutParams(1, dp(14)))
}
private fun errorText() = TextView(activity).apply {
textSize = 12f
setTextColor(activity.getColor(R.color.danger_400))
setPadding(0, dp(4), 0, 0)
visibility = View.GONE
}
private fun textField(value: String, hint: Int, set: (String) -> Unit) {
content.addView(
EditText(activity).apply {
setText(value)
setHint(hint)
filters = arrayOf(InputFilter.LengthFilter(MiiRanges.NAME_LENGTH))
isSingleLine = true
textSize = 15f
setTextColor(activity.getColor(R.color.neutral_100))
setHintTextColor(activity.getColor(R.color.neutral_500))
background = activity.getDrawable(R.drawable.bg_search_field)
setPadding(dp(12), 0, dp(12), 0)
importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO
addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) = set(s?.toString().orEmpty())
})
},
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(40)),
)
}
/** Height and Width: 0 to 127. */
private fun slider(text: Int, value: Int, set: (Int) -> Unit) {
label(text)
content.addView(
SeekBar(activity).apply {
max = MiiRanges.SCALE_MAX
progress = value
progressTintList = ColorStateList.valueOf(activity.getColor(R.color.primary_400))
thumbTintList = ColorStateList.valueOf(activity.getColor(R.color.primary_300))
progressBackgroundTintList = ColorStateList.valueOf(activity.getColor(R.color.neutral_600))
setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(bar: SeekBar, progress: Int, fromUser: Boolean) {
if (!fromUser) return
set(progress)
refreshFaceOnly()
}
override fun onStartTrackingTouch(bar: SeekBar) = Unit
override fun onStopTrackingTouch(bar: SeekBar) = Unit
})
},
LinearLayout.LayoutParams(dp(260), dp(36)),
)
}
private fun checkbox(text: Int, checked: Boolean, set: (Boolean) -> Unit) {
content.addView(
CheckBox(activity).apply {
setText(text)
isChecked = checked
setTextColor(activity.getColor(R.color.neutral_200))
buttonTintList = ColorStateList.valueOf(activity.getColor(R.color.primary_400))
setOnCheckedChangeListener { _, value ->
set(value)
changed()
}
},
)
}
private fun dropdown(entries: List<String>, selected: Int, set: (Int) -> Unit) {
content.addView(
Spinner(activity).apply {
background = activity.getDrawable(R.drawable.bg_dropdown)
setPopupBackgroundDrawable(activity.getDrawable(R.drawable.bg_dropdown_popup))
adapter = ArrayAdapter(activity, R.layout.item_dropdown, entries).apply { setDropDownViewResource(R.layout.item_dropdown_popup) }
setSelection(selected, false)
onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
if (position == selected) return
set(position)
changed()
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
}
},
LinearLayout.LayoutParams(dp(220), dp(40)),
)
}
/**
* WheelWizard's transform controls: the value as the PC shows it, between buttons that move it
* by one within [range]. [icons] are the decrease and increase buttons' pictures.
*/
private fun stepper(text: Int, value: Int, range: IntRange, display: (Int) -> Int, icons: Pair<Int, Int>, set: (Int) -> Unit) {
label(text)
val row = LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
background = GradientDrawable().apply {
setColor(activity.getColor(R.color.neutral_950))
cornerRadius = dp(6).toFloat()
}
setPadding(dp(2), dp(2), dp(2), dp(2))
}
fun button(icon: Int, step: Int) = ImageView(activity).apply {
setImageResource(icon)
imageTintList = ColorStateList.valueOf(activity.getColor(R.color.neutral_100))
background = activity.getDrawable(R.drawable.bg_button_secondary)
setPadding(dp(9), dp(9), dp(9), dp(9))
isEnabled = value + step in range
alpha = if (isEnabled) 1f else 0.4f
setOnClickListener {
set(value + step)
changed()
}
}
row.addView(button(icons.first, -1), LinearLayout.LayoutParams(dp(34), dp(34)))
row.addView(
TextView(activity).apply {
setText(display(value).toString())
gravity = Gravity.CENTER
textSize = 15f
setTextColor(activity.getColor(R.color.neutral_100))
},
LinearLayout.LayoutParams(dp(44), ViewGroup.LayoutParams.WRAP_CONTENT),
)
row.addView(button(icons.second, 1), LinearLayout.LayoutParams(dp(34), dp(34)))
content.addView(row, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT))
spacer()
}
/** The PC's paint-brush buttons: one swatch per colour. */
private fun colorGrid(colors: IntArray, columns: Int, selected: Int, set: (Int) -> Unit) {
val grid = TileGrid(activity).apply {
this.columns = columns
spacing = dp(8)
}
for ((index, color) in colors.withIndex()) {
val cell = FrameLayout(activity).apply {
background = activity.getDrawable(R.drawable.bg_mii_choice)
isSelected = index == selected
isClickable = true
isFocusable = true
setOnClickListener {
if (index == selected) return@setOnClickListener
set(index)
changed()
}
}
cell.addView(
View(activity).apply {
background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(color)
setStroke(dp(1), activity.getColor(R.color.neutral_600))
}
},
FrameLayout.LayoutParams(dp(26), dp(26), Gravity.CENTER),
)
grid.addView(cell, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(40)))
}
content.addView(grid)
}
/** The PC's icon buttons: [count] choices, five to a row, each pictured by [bind]. */
private fun choiceGrid(count: Int, selected: Int, bind: (ImageView, Int) -> Unit, set: (Int) -> Unit) {
val grid = TileGrid(activity).apply {
columns = 5
spacing = dp(8)
}
for (index in 0 until count) {
val cell = FrameLayout(activity).apply {
background = activity.getDrawable(R.drawable.bg_mii_choice)
isSelected = index == selected
isClickable = true
isFocusable = true
contentDescription = (index + 1).toString()
setOnClickListener {
if (index == selected) return@setOnClickListener
set(index)
changed()
}
}
val picture = ImageView(activity)
cell.addView(picture, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
if (MiiRenderResource.installed(activity)) {
bind(picture, index)
} else {
// Without the parts there is nothing to draw: the PC's order, numbered.
cell.addView(
TextView(activity).apply {
text = (index + 1).toString()
gravity = Gravity.CENTER
setTextColor(activity.getColor(R.color.neutral_300))
},
FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT),
)
}
grid.addView(cell, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(CHOICE_DP)))
}
content.addView(grid)
spacer()
}
/** Pictures a choice as the Mii's head wearing it, enlarged past the cell's edges as the list's tiles are. */
private fun head(view: ImageView, variant: Mii) {
view.scaleX = HEAD_ZOOM
view.scaleY = HEAD_ZOOM
view.translationY = -dp(4).toFloat()
(view.parent as? View)?.clipToOutline = true
MiiImages.head(activity, view, variant, (choicePixels() * HEAD_ZOOM).toInt() and 1.inv())
}
/** Pictures a flat part's choices from its texture; [none] is the choice that is no part at all. */
private fun part(part: MiiRenderer.Part, none: Int = -1): (ImageView, Int) -> Unit = { view, index ->
view.setPadding(dp(6), dp(6), dp(6), dp(6))
if (index == none) {
view.setImageResource(R.drawable.ic_x_mark)
view.imageTintList = ColorStateList.valueOf(activity.getColor(R.color.neutral_500))
view.setPadding(dp(18), dp(18), dp(18), dp(18))
} else {
view.background = GradientDrawable().apply {
setColor(SKIN_COLORS[mii.skinColor.coerceIn(0, SKIN_COLORS.size - 1)])
cornerRadius = dp(6).toFloat()
}
(view.layoutParams as? FrameLayout.LayoutParams)?.setMargins(dp(4), dp(4), dp(4), dp(4))
MiiImages.part(activity, view, mii, part, index, choicePixels())
}
}
/** A redraw of the face alone, for sliders, whose page must not be rebuilt under the finger. */
private fun refreshFaceOnly() {
val size = (PREVIEW_DP * activity.resources.displayMetrics.density).toInt() and 1.inv()
MiiImages.preview(activity, mii, size) { bitmap ->
if (!isOpen) return@preview
preview.setImageBitmap(bitmap)
placeholder.visibility = if (bitmap == null) View.VISIBLE else View.GONE
}
}
private fun choicePixels() = (CHOICE_DP * activity.resources.displayMetrics.density).toInt() and 1.inv()
private fun dp(value: Int): Int = PatchesWidgets.dp(activity, value)
private companion object {
const val PREVIEW_DP = 340
const val CHOICE_DP = 60
const val HEAD_ZOOM = 1.45f
/** The Wii's last eyebrow is none. */
const val NO_EYEBROWS = 23
val VERTICAL = R.drawable.ic_arrow_up to R.drawable.ic_arrow_down
val HORIZONTAL = R.drawable.ic_arrow_left to R.drawable.ic_arrow_right
val ROTATION = R.drawable.ic_rotate_left to R.drawable.ic_rotate_right
val SIZE = R.drawable.ic_minus to R.drawable.ic_plus
private fun rgb(r: Int, g: Int, b: Int) = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
// MiiColorMappings on the PC, in the Wii's order of each colour.
val FAVORITE_COLORS = intArrayOf(
rgb(252, 33, 20), rgb(255, 119, 27), rgb(255, 237, 33), rgb(143, 240, 31), rgb(0, 130, 50), rgb(10, 80, 184),
rgb(71, 186, 225), rgb(255, 98, 126), rgb(138, 42, 176), rgb(87, 62, 23), rgb(255, 255, 250), rgb(0, 0, 0),
)
val SKIN_COLORS = intArrayOf(
rgb(255, 211, 157), rgb(255, 185, 99), rgb(222, 123, 61), rgb(255, 171, 128), rgb(200, 83, 39), rgb(117, 46, 23),
)
val HAIR_COLORS = intArrayOf(
rgb(0, 0, 0), rgb(86, 45, 27), rgb(120, 37, 21), rgb(157, 74, 32), rgb(152, 139, 140), rgb(104, 78, 27),
rgb(171, 106, 36), rgb(255, 183, 87),
)
val EYE_COLORS = intArrayOf(rgb(0, 0, 0), rgb(0x47, 0x4B, 0x5D), rgb(150, 72, 45), rgb(165, 152, 55), rgb(85, 93, 195), rgb(72, 143, 100))
val LIP_COLORS = intArrayOf(rgb(255, 93, 13), rgb(255, 18, 13), rgb(255, 83, 77))
val GLASSES_COLORS = intArrayOf(rgb(144, 144, 144), rgb(202, 147, 102), rgb(255, 87, 77), rgb(123, 135, 189), rgb(255, 175, 71), rgb(220, 197, 190))
}
}
@@ -0,0 +1,106 @@
package org.wiicompiled.quest.launcher
import android.content.Context
import android.graphics.Bitmap
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.util.LruCache
import android.widget.ImageView
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicLong
import org.wiicompiled.quest.R
/**
* Mii pictures for My Miis and its editor, drawn by [MiiRenderer] off the main thread and kept
* in a memory cache (MiiImagesSingletonService on the PC). Nothing is drawn until the player has
* downloaded the Mii parts ([MiiRenderResource]); callers then show their placeholder.
*/
object MiiImages {
private const val TAG = "WiiCompiledLauncher"
private val cache = object : LruCache<String, Bitmap>((Runtime.getRuntime().maxMemory() / 8).toInt()) {
override fun sizeOf(key: String, value: Bitmap): Int = value.allocationByteCount
}
private val renderers = Executors.newFixedThreadPool(2) { runnable -> Thread(runnable, "MiiImages").apply { isDaemon = true } }
private val previewer = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "MiiPreview").apply { isDaemon = true } }
private val previewGeneration = AtomicLong()
private val main by lazy { Handler(Looper.getMainLooper()) }
/** Shows [mii]'s head in [view], [size] pixels square; a recycled view only takes the picture it asked for last. */
fun head(context: Context, view: ImageView, mii: Mii, size: Int) {
val copy = mii.copy()
show(context, view, "head:$size:${copy.lookKey()}") { resource -> MiiRenderer.render(resource, copy, size) }
}
/** Shows one choice of a face part in [view] (MiiRenderer.partIcon); nothing for "none". */
fun part(context: Context, view: ImageView, mii: Mii, part: MiiRenderer.Part, index: Int, size: Int) {
val colour = when (part) {
MiiRenderer.Part.Eyebrow -> mii.eyebrowColor
MiiRenderer.Part.Eye -> mii.eyeColor
MiiRenderer.Part.Mouth -> mii.lipColor
MiiRenderer.Part.Glasses -> mii.glassesColor
MiiRenderer.Part.Mustache -> mii.facialHairColor
MiiRenderer.Part.Nose -> 0
}
val copy = mii.copy()
show(context, view, "part:$part:$index:$size:$colour") { resource -> MiiRenderer.partIcon(resource, copy, part, index, size) }
}
/**
* The editor's picture: only the newest request is drawn, so a burst of changes costs one
* render. [done] gets null when there is nothing to show.
*/
fun preview(context: Context, mii: Mii, size: Int, done: (Bitmap?) -> Unit) {
val app = context.applicationContext
val key = "head:$size:${mii.lookKey()}"
cache.get(key)?.let {
previewGeneration.incrementAndGet()
done(it)
return
}
val generation = previewGeneration.incrementAndGet()
val copy = mii.copy()
previewer.execute {
if (previewGeneration.get() != generation) return@execute
val bitmap = draw(app, key) { resource -> MiiRenderer.render(resource, copy, size) }
main.post { if (previewGeneration.get() == generation) done(bitmap) }
}
}
/** Forgets every picture, for when the parts were just installed and placeholders can become Miis. */
fun clear() = cache.evictAll()
private fun show(context: Context, view: ImageView, key: String, render: (FflResource) -> IntArray?) {
view.setTag(R.id.mii_image_key, key)
val cached = cache.get(key)
if (cached != null) {
view.setImageBitmap(cached)
return
}
view.setImageDrawable(null)
val app = context.applicationContext
renderers.execute {
// A view scrolled away before its turn does not need its picture any more. Reading its
// tag here is only a hint; the main thread checks again before showing the picture.
if (view.getTag(R.id.mii_image_key) != key) return@execute
val bitmap = draw(app, key, render) ?: return@execute
main.post { if (view.getTag(R.id.mii_image_key) == key) view.setImageBitmap(bitmap) }
}
}
private fun draw(context: Context, key: String, render: (FflResource) -> IntArray?): Bitmap? {
cache.get(key)?.let { return it }
val resource = MiiRenderResource.load(context) ?: return null
return try {
val pixels = render(resource) ?: return null
val size = kotlin.math.sqrt(pixels.size.toDouble()).toInt()
Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888).also { cache.put(key, it) }
} catch (e: Exception) {
Log.w(TAG, "Cannot draw a Mii picture", e)
null
} catch (e: OutOfMemoryError) {
null
}
}
}
@@ -0,0 +1,171 @@
package org.wiicompiled.quest.launcher
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import java.util.concurrent.Executors
import java.util.zip.ZipInputStream
import org.wiicompiled.quest.BuildConfig
/**
* FFL's Mii parts (FFLResHigh.dat), which every Mii picture is drawn from: the PC launcher's
* MiiRenderingResourceInstaller. The file is Nintendo's, so the app never carries it; like the PC,
* the player downloads it once from the Internet Archive's copy of Miitomo's files, and it is
* checked against the SHA-256 of the copy both launchers install.
*/
object MiiRenderResource {
/** Endpoints.MiiRenderingArchive on the PC. */
private const val ARCHIVE_URL = "https://web.archive.org/web/20180502054513id_/" +
"http://download-cdn.miitomo.com/native/20180125111639/android/v2/asset_model_character_mii_AFLResHigh_2_3_dat.zip"
private const val ENTRY = "asset/model/character/mii/AFLResHigh_2_3.dat"
const val DOWNLOAD_BYTES = 4_393_464L
private const val FILE_BYTES = 4_579_008L
private const val ATTEMPTS = 3
private const val TIMEOUT_MS = 30_000
private const val TAG = "WiiCompiledLauncher"
private val worker = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "MiiResource").apply { isDaemon = true } }
private val main by lazy { Handler(Looper.getMainLooper()) }
@Volatile
private var loaded: FflResource? = null
/** Whether a download is running; main thread only. */
var installing = false
private set
fun file(context: Context): File = File(context.filesDir, "MiiRendering/FFLResHigh.dat")
fun installed(context: Context): Boolean = file(context).length() == FILE_BYTES
/** The parts, read once; null when they are not installed or cannot be read. Not on the main thread. */
fun load(context: Context): FflResource? {
loaded?.let { return it }
synchronized(this) {
loaded?.let { return it }
if (!installed(context)) return null
return try {
FflResource.load(file(context)).also { loaded = it }
} catch (e: IOException) {
Log.w(TAG, "Cannot read the Mii parts", e)
null
}
}
}
/**
* Downloads and installs the parts. [progress] gets the bytes downloaded so far, and [done]
* null or the reason it failed, both on the main thread.
*/
fun install(context: Context, progress: (Long) -> Unit, done: (String?) -> Unit) {
if (installing) return
installing = true
val app = context.applicationContext
worker.execute {
val error = try {
download(app) { bytes -> main.post { progress(bytes) } }
null
} catch (e: IOException) {
Log.w(TAG, "Cannot install the Mii parts", e)
e.message ?: e.toString()
}
main.post {
installing = false
done(error)
}
}
}
private fun download(context: Context, progress: (Long) -> Unit) {
val target = file(context)
target.parentFile?.mkdirs()
var failure: IOException? = null
for (attempt in 1..ATTEMPTS) {
try {
val archive = fetch(progress)
val partial = File(target.parentFile, target.name + ".partial")
try {
val digest = extract(archive, partial)
if (digest != FflResource.SHA256) throw IOException("The downloaded Mii parts are not the expected file (SHA-256 $digest).")
FflResource.load(partial)
if (!partial.renameTo(target)) throw IOException("Cannot store ${target.path}")
} finally {
partial.delete()
}
Log.i(TAG, "Installed the Mii parts to ${target.path}")
return
} catch (e: IOException) {
Log.w(TAG, "Mii parts download attempt $attempt/$ATTEMPTS failed: ${e.message}")
failure = e
}
}
throw failure ?: IOException("The Mii parts could not be downloaded.")
}
private fun fetch(progress: (Long) -> Unit): ByteArray {
val connection = (URL(ARCHIVE_URL).openConnection() as HttpURLConnection).apply {
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
setRequestProperty("User-Agent", "WiiCompiledVR-Quest/${BuildConfig.VERSION_NAME}")
}
try {
val code = connection.responseCode
if (code != HttpURLConnection.HTTP_OK) throw IOException("The Internet Archive answered $code.")
val expected = connection.contentLengthLong
val bytes = ByteArrayOutputStream(if (expected > 0) expected.toInt() else DOWNLOAD_BYTES.toInt())
connection.inputStream.use { input ->
val buffer = ByteArray(64 * 1024)
var total = 0L
var reported = 0L
while (true) {
val count = input.read(buffer)
if (count < 0) break
bytes.write(buffer, 0, count)
total += count
if (total - reported >= 64 * 1024) {
reported = total
progress(total)
}
}
progress(total)
if (expected > 0 && total != expected) throw IOException("The download stopped after $total of $expected bytes.")
}
val archive = bytes.toByteArray()
if (archive.size < 4 || archive[0] != 'P'.code.toByte() || archive[1] != 'K'.code.toByte()) {
throw IOException("The download is not a ZIP archive.")
}
return archive
} finally {
connection.disconnect()
}
}
/** Writes the archive's resource entry to [to] and returns its SHA-256. */
private fun extract(archive: ByteArray, to: File): String {
ZipInputStream(ByteArrayInputStream(archive)).use { zip ->
while (true) {
val entry = zip.nextEntry ?: throw IOException("The archive does not contain $ENTRY.")
if (entry.name != ENTRY) continue
val digest = MessageDigest.getInstance("SHA-256")
to.outputStream().use { output ->
val buffer = ByteArray(64 * 1024)
while (true) {
val count = zip.read(buffer)
if (count < 0) break
digest.update(buffer, 0, count)
output.write(buffer, 0, count)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,506 @@
package org.wiicompiled.quest.launcher
import android.app.Activity
import android.app.AlertDialog
import android.net.Uri
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import android.text.format.Formatter
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import java.io.File
import java.io.IOException
import java.util.concurrent.Executors
import org.wiicompiled.quest.GameStorage
import org.wiicompiled.quest.R
/**
* My Miis: WheelWizard's MiiListPage for the headset. It lists the Miis of the game's own Mii
* database ([MiiDatabase], created empty the first time), favourites first, and makes, edits,
* duplicates, imports, exports and deletes them. The game reads the same file, so a Mii made
* here can be picked for a licence.
*
* The PC selects with a click and adds to the selection with Shift or Ctrl; the headset has
* neither, so a long press adds a Mii to the selection or takes it out.
*/
class MiisPage(
private val activity: Activity,
private val root: View,
private val gameRunning: () -> Boolean,
private val pickImport: () -> Unit,
private val pickExport: (suggestedName: String, several: Boolean) -> Unit,
) {
private val list: View = root.findViewById(R.id.miis_list)
private val count: TextView = root.findViewById(R.id.miis_count)
private val buttons: View = root.findViewById(R.id.miis_buttons)
private val favoriteButton: ImageView = root.findViewById(R.id.miis_favorite)
private val editButton: View = root.findViewById(R.id.miis_edit)
private val duplicateButton: View = root.findViewById(R.id.miis_duplicate)
private val exportButton: View = root.findViewById(R.id.miis_export)
private val deleteButton: View = root.findViewById(R.id.miis_delete)
private val importButton: View = root.findViewById(R.id.miis_import)
private val parts: View = root.findViewById(R.id.miis_parts)
private val partsText: TextView = root.findViewById(R.id.miis_parts_text)
private val partsDownload: TextView = root.findViewById(R.id.miis_parts_download)
private val empty: View = root.findViewById(R.id.miis_empty)
private val emptyMessage: TextView = root.findViewById(R.id.miis_empty_message)
private val scroll: View = root.findViewById(R.id.miis_scroll)
private val grid: TileGrid = root.findViewById(R.id.miis_grid)
private val editor = MiiEditor(activity, root.findViewById(R.id.miis_editor), ::editorClosed)
private var miis: List<Mii> = emptyList()
private val selected = LinkedHashSet<Long>()
/** The console's system ID, which marks the Miis made on this headset; null before the game's first start. */
private var consoleSystemId: Long? = null
/** Miis waiting for the player to choose where Export saves them. */
private var exporting: List<Mii> = emptyList()
/** An editor to open once the list is loaded, for unattended tests (LauncherActivity's EXTRA_DEBUG_MIIS). */
private var debugSpec: String? = null
init {
grid.cellWidth = dp(104)
grid.spacing = dp(12)
favoriteButton.setOnClickListener { toggleFavorite(selectedMiis()) }
editButton.setOnClickListener { selectedMiis().singleOrNull()?.let(::edit) }
duplicateButton.setOnClickListener { duplicate(selectedMiis()) }
exportButton.setOnClickListener { export(selectedMiis()) }
deleteButton.setOnClickListener { delete(selectedMiis()) }
importButton.setOnClickListener {
if (!refuseWhileGameRuns()) pickImport()
}
partsDownload.setOnClickListener { downloadParts() }
}
fun refresh() {
showParts()
if (editor.isOpen) {
editor.refreshPicture()
return
}
load()
}
/** For unattended tests: edit:N[:Section] opens the editor on the Nth Mii listed, and that page. */
fun debugOpen(spec: String) {
debugSpec = spec.takeIf { it.startsWith("edit:") }
}
/** Back closes the editor's page, then the editor, before leaving My Miis. */
fun back(): Boolean = editor.back()
// --- The list ---
private fun load() {
val app = activity.applicationContext
worker.execute {
val nand = GameStorage.nandDirectory(app)
val file = MiiDatabase.file(nand)
val result = runCatching {
MiiDatabase.create(file)
MiiDatabase.miis(file)
}
val systemId = MiiIds.consoleMac(nand)?.let(MiiIds::systemId)
activity.runOnUiThread {
if (activity.isDestroyed) return@runOnUiThread
consoleSystemId = systemId
result.onSuccess { show(it) }.onFailure { error ->
Log.w(TAG, "Cannot open the Mii database", error)
showEmpty(error.message ?: error.toString())
}
}
}
}
private fun show(all: List<Mii>) {
// MiiListPage: favourites first, otherwise in the database's order.
miis = all.sortedByDescending { it.favorite }
selected.retainAll(miis.map { it.miiId }.toSet())
empty.visibility = View.GONE
scroll.visibility = View.VISIBLE
buttons.visibility = View.VISIBLE
count.visibility = View.VISIBLE
count.text = miis.size.toString()
grid.removeAllViews()
val inflater = LayoutInflater.from(activity)
for (mii in miis) {
val tile = inflater.inflate(R.layout.item_mii, grid, false)
bindTile(tile, mii)
tile.setOnClickListener { select(mii, keepOthers = false) }
tile.setOnLongClickListener {
select(mii, keepOthers = true)
true
}
grid.addView(tile)
}
if (miis.size < MiiDatabase.SLOTS) {
val add = inflater.inflate(R.layout.item_mii, grid, false)
add.findViewById<View>(R.id.mii_placeholder).visibility = View.GONE
add.findViewById<View>(R.id.mii_add).visibility = View.VISIBLE
add.contentDescription = activity.getString(R.string.miis_new_title)
add.setOnClickListener {
selected.clear()
updateSelection()
create()
}
grid.addView(add)
}
updateSelection()
debugSpec?.let { spec ->
debugSpec = null
val parts = spec.split(':')
miis.getOrNull(parts.getOrNull(1)?.toIntOrNull() ?: 0)?.let { mii ->
openEditor(mii, isNew = false) { edited -> change({ file -> MiiDatabase.update(file, edited) }) }
parts.getOrNull(2)?.let(editor::debugShow)
}
}
}
private fun bindTile(tile: View, mii: Mii) {
tile.findViewById<TextView>(R.id.mii_name).text = mii.name
tile.contentDescription = mii.name
tile.findViewById<View>(R.id.mii_favorite).visibility = if (mii.favorite) View.VISIBLE else View.GONE
tile.findViewById<View>(R.id.mii_foreign).visibility = if (isForeign(mii)) View.VISIBLE else View.GONE
tile.isActivated = mii.favorite
tile.tag = mii.miiId
tile.findViewById<View>(R.id.mii_placeholder).visibility =
if (MiiRenderResource.installed(activity)) View.GONE else View.VISIBLE
val picture = tile.findViewById<ImageView>(R.id.mii_picture)
MiiImages.head(activity, picture, mii, TILE_PICTURE)
}
/** MiiExtensions.IsGlobal: a special Mii, or one another console made. */
private fun isForeign(mii: Mii): Boolean {
if ((mii.miiId ushr 29) == 0b110L) return true
val own = consoleSystemId ?: return false
return mii.systemId != own
}
private fun showEmpty(reason: String) {
miis = emptyList()
selected.clear()
grid.removeAllViews()
scroll.visibility = View.GONE
buttons.visibility = View.GONE
count.visibility = View.GONE
empty.visibility = View.VISIBLE
emptyMessage.text = activity.getString(R.string.miis_empty_message, reason)
}
/** A click selects only that Mii, or nothing when it was the one selected; a long press adds or removes it. */
private fun select(mii: Mii, keepOthers: Boolean) {
val wasSelected = mii.miiId in selected
if (!keepOthers) selected.clear()
if (wasSelected) selected.remove(mii.miiId) else selected.add(mii.miiId)
updateSelection()
}
private fun selectedMiis(): List<Mii> = miis.filter { it.miiId in selected }
/** MiiListPage.ChangeTopButtons. */
private fun updateSelection() {
for (i in 0 until grid.childCount) {
val tile = grid.getChildAt(i)
tile.isSelected = tile.tag in selected
}
val chosen = selectedMiis()
val any = chosen.isNotEmpty()
favoriteButton.visibility = if (any) View.VISIBLE else View.GONE
editButton.visibility = if (chosen.size == 1) View.VISIBLE else View.GONE
duplicateButton.visibility = if (any) View.VISIBLE else View.GONE
exportButton.visibility = if (any) View.VISIBLE else View.GONE
deleteButton.visibility = if (any) View.VISIBLE else View.GONE
importButton.visibility = if (any) View.GONE else View.VISIBLE
val unfavorite = any && chosen.all { it.favorite }
favoriteButton.setImageResource(if (unfavorite) R.drawable.ic_star_empty else R.drawable.ic_star)
val label = activity.getString(if (unfavorite) R.string.miis_unfavorite else R.string.miis_favorite)
favoriteButton.contentDescription = label
favoriteButton.tooltipText = label
}
// --- Actions ---
private fun toggleFavorite(chosen: List<Mii>) {
if (chosen.isEmpty()) return
val favorite = !chosen.all { it.favorite }
change({ file -> chosen.forEach { MiiDatabase.update(file, it.copy(favorite = favorite)) } })
}
private fun edit(mii: Mii) {
// Refused before editing rather than at Save, which would lose the edits.
if (refuseWhileGameRuns()) return
openEditor(mii, isNew = false) { edited -> change({ file -> MiiDatabase.update(file, edited) }) }
}
/** MiiListPage.CreateNewMii: a random, male or female start, then the editor. */
private fun create() {
if (refuseWhileGameRuns()) return
val mac = consoleMac() ?: return
val name = activity.getString(R.string.miis_default_name)
val choices = arrayOf(
activity.getString(R.string.miis_new_random),
activity.getString(R.string.miis_new_male),
activity.getString(R.string.miis_new_female),
)
AlertDialog.Builder(activity)
.setTitle(R.string.miis_new_title)
.setItems(choices) { _, which ->
val mii = when (which) {
0 -> MiiFactory.random(name)
1 -> MiiFactory.male(name)
else -> MiiFactory.female(name)
}
openEditor(mii, isNew = true) { edited -> change({ file -> MiiDatabase.add(file, stamped(edited, mac)) }) }
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
/** MiiListPage.DuplicateMii: copies with new IDs, as this headset's own Miis. */
private fun duplicate(chosen: List<Mii>) {
if (chosen.isEmpty() || refuseWhileGameRuns()) return
val mac = consoleMac() ?: return
val message = if (chosen.size == 1) {
activity.getString(R.string.miis_duplicated_one, chosen[0].name)
} else {
activity.getString(R.string.miis_duplicated_many, chosen.size)
}
change({ file -> chosen.forEach { MiiDatabase.add(file, stamped(it, mac)) } }) { toast(message) }
}
/**
* The MAC address Miis made on this headset are stamped with (MiiDbService.AddToDatabase), or
* null after telling the player to start the game once: the game creates the console's
* identity on its first start.
*/
private fun consoleMac(): ByteArray? {
MiiIds.consoleMac(GameStorage.nandDirectory(activity))?.let { return it }
AlertDialog.Builder(activity)
.setTitle(R.string.miis_no_console_title)
.setMessage(R.string.miis_no_console_message)
.setPositiveButton(android.R.string.ok, null)
.show()
return null
}
/** A new Mii of the database: valid, from [mac]'s console, with a fresh ID. */
private fun stamped(mii: Mii, mac: ByteArray) = mii.copy(invalid = false, systemId = MiiIds.systemId(mac), miiId = MiiIds.newMiiId())
/** MiiListPage.DeleteMii: never favourites, and only once confirmed. */
private fun delete(chosen: List<Mii>) {
if (chosen.isEmpty() || refuseWhileGameRuns()) return
if (chosen.any { it.favorite }) {
AlertDialog.Builder(activity)
.setTitle(R.string.miis_delete_favorite_title)
.setMessage(R.string.miis_delete_favorite_message)
.setPositiveButton(android.R.string.ok, null)
.show()
return
}
val one = chosen.size == 1
AlertDialog.Builder(activity)
.setTitle(
if (one) activity.getString(R.string.miis_delete_title_one, chosen[0].name)
else activity.getString(R.string.miis_delete_title_many, chosen.size),
)
.setMessage(R.string.miis_delete_message)
.setPositiveButton(R.string.miis_delete) { _, _ ->
val done = if (one) activity.getString(R.string.miis_deleted_one, chosen[0].name)
else activity.getString(R.string.miis_deleted_many, chosen.size)
change({ file -> chosen.forEach { MiiDatabase.remove(file, it.miiId) } }) { toast(done) }
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
/**
* The files the player picked with Import: .mii files, 74 bytes each. As on the PC, each is
* added as a copy with a new ID, marked as made on another console. Unlike the PC, one bad
* file does not stop the others.
*/
fun importPicked(uris: List<Uri>) {
if (uris.isEmpty()) return
val resolver = activity.contentResolver
val names = uris.map(::displayName)
change({ file ->
val problems = ArrayList<String>()
for ((uri, name) in uris.zip(names)) {
try {
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() } ?: throw IOException("Cannot read the file.")
val mii = MiiData.parse(bytes).copy(invalid = false, systemId = MiiIds.systemId(MiiIds.IMPORT_MAC), miiId = MiiIds.newMiiId())
MiiDatabase.add(file, mii)
} catch (e: IllegalArgumentException) {
problems += activity.getString(R.string.miis_import_problem, name, e.message ?: e.toString())
} catch (e: IOException) {
problems += activity.getString(R.string.miis_import_problem, name, e.message ?: e.toString())
}
}
problems
}) { problems ->
val added = uris.size - problems.size
val summary = activity.resources.getQuantityString(R.plurals.miis_imported, added, added)
if (problems.isEmpty()) {
toast(summary)
} else {
AlertDialog.Builder(activity)
.setTitle(summary)
.setMessage(problems.joinToString("\n"))
.setPositiveButton(android.R.string.ok, null)
.show()
}
}
}
/** MiiListPage.ExportMultipleMiiFiles: each Mii as its 74 bytes in a .mii file. */
private fun export(chosen: List<Mii>) {
if (chosen.isEmpty()) return
exporting = chosen
pickExport(fileName(chosen[0]), chosen.size > 1)
}
/** Where the player chose to save one Mii (a new document) or several (a folder). */
fun exportPicked(uri: Uri, folder: Boolean) {
val chosen = exporting
exporting = emptyList()
if (chosen.isEmpty()) return
val resolver = activity.contentResolver
worker.execute {
val result = runCatching {
if (!folder) {
resolver.openOutputStream(uri, "wt")?.use { it.write(MiiData.serialize(chosen[0])) } ?: throw IOException("Cannot write the file.")
activity.getString(R.string.miis_exported_one, chosen[0].name)
} else {
val parent = DocumentsContract.buildDocumentUriUsingTree(uri, DocumentsContract.getTreeDocumentId(uri))
for (mii in chosen) {
val document = DocumentsContract.createDocument(resolver, parent, MII_MIME, fileName(mii))
?: throw IOException("Cannot create ${fileName(mii)}.")
resolver.openOutputStream(document, "wt")?.use { it.write(MiiData.serialize(mii)) }
?: throw IOException("Cannot write ${fileName(mii)}.")
}
activity.getString(R.string.miis_exported_many, chosen.size)
}
}
activity.runOnUiThread {
if (activity.isDestroyed) return@runOnUiThread
toast(result.getOrElse { activity.getString(R.string.miis_export_failed, it.message ?: it.toString()) })
}
}
}
/** CustomCharactersService.NormalizeToAscii and ReplaceInvalidFileNameChars, then .mii. */
private fun fileName(mii: Mii): String {
val ascii = java.text.Normalizer.normalize(mii.name, java.text.Normalizer.Form.NFD)
.replace(Regex("\\p{M}+"), "")
.filter { it.code in 0x20..0x7E }
.split('/', '\\', ':', '*', '?', '"', '<', '>', '|')
.filter { it.isNotEmpty() }
.joinToString("_")
.trim()
return (ascii.ifEmpty { "Mii" }) + ".mii"
}
private fun displayName(uri: Uri): String {
val queried = runCatching {
activity.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
}
}.getOrNull()
return queried?.takeIf { it.isNotBlank() } ?: uri.lastPathSegment?.substringAfterLast('/') ?: uri.toString()
}
/**
* Changes the database off the main thread, unless the game runs (it reads the file), then
* lists the Miis again and hands the change's result to [done]. A failure is shown instead.
*/
private fun <T> change(work: (File) -> T, done: (T) -> Unit = {}) {
if (refuseWhileGameRuns()) return
val app = activity.applicationContext
worker.execute {
val result = runCatching { work(MiiDatabase.file(GameStorage.nandDirectory(app))) }
activity.runOnUiThread {
if (activity.isDestroyed) return@runOnUiThread
result.onSuccess(done).onFailure { error ->
Log.w(TAG, "Mii database change failed", error)
AlertDialog.Builder(activity)
.setTitle(R.string.miis_change_failed)
.setMessage(error.message ?: error.toString())
.setPositiveButton(android.R.string.ok, null)
.show()
}
load()
}
}
}
private fun toast(message: String) = Toast.makeText(activity, message, Toast.LENGTH_LONG).show()
private fun editorClosed() {
list.visibility = View.VISIBLE
}
// --- The Mii parts ---
private fun showParts() {
if (MiiRenderResource.installed(activity)) {
parts.visibility = View.GONE
return
}
parts.visibility = View.VISIBLE
partsDownload.isEnabled = !MiiRenderResource.installing
if (!MiiRenderResource.installing) {
partsText.text = activity.getString(R.string.miis_parts_message, Formatter.formatShortFileSize(activity, MiiRenderResource.DOWNLOAD_BYTES))
}
}
private fun downloadParts() {
partsDownload.isEnabled = false
partsText.text = activity.getString(R.string.miis_parts_connecting)
MiiRenderResource.install(
activity,
progress = { bytes ->
partsText.text = activity.getString(
R.string.miis_parts_downloading,
Formatter.formatShortFileSize(activity, bytes),
Formatter.formatShortFileSize(activity, MiiRenderResource.DOWNLOAD_BYTES),
)
},
) { error ->
if (activity.isDestroyed) return@install
if (error != null) {
partsDownload.isEnabled = true
partsText.text = activity.getString(R.string.miis_parts_failed, error)
return@install
}
MiiImages.clear()
showParts()
if (editor.isOpen) editor.refreshPicture() else load()
}
}
private fun refuseWhileGameRuns(): Boolean {
if (!gameRunning()) return false
Toast.makeText(activity, R.string.miis_close_game, Toast.LENGTH_LONG).show()
return true
}
/** The editor takes the list's place, as the PC opens its editor over the page. */
private fun openEditor(mii: Mii, isNew: Boolean, onSave: (Mii) -> Unit) {
list.visibility = View.GONE
editor.open(mii, isNew, onSave)
}
private fun dp(value: Int): Int = PatchesWidgets.dp(activity, value)
private companion object {
const val TAG = "WiiCompiledLauncher"
const val MII_MIME = "application/octet-stream"
/** The list's pictures, at the 140 dp they are shown at: larger than the tile, which crops them. */
const val TILE_PICTURE = 176
/** One database change at a time, in order. */
val worker = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "MiiDatabase").apply { isDaemon = true } }
}
}
@@ -402,6 +402,7 @@ class SettingsPage(
info(R.string.about_credit_title_disc, activity.getString(R.string.about_credit_nod), stacked = true) info(R.string.about_credit_title_disc, activity.getString(R.string.about_credit_nod), stacked = true)
info(R.string.about_credit_title_mod_browser, activity.getString(R.string.about_credit_mod_browser), stacked = true) info(R.string.about_credit_title_mod_browser, activity.getString(R.string.about_credit_mod_browser), stacked = true)
info(R.string.about_credit_title_profiles, activity.getString(R.string.about_credit_profiles), stacked = true) info(R.string.about_credit_title_profiles, activity.getString(R.string.about_credit_profiles), stacked = true)
info(R.string.about_credit_title_miis, activity.getString(R.string.about_credit_miis), stacked = true)
if (BuildConfig.ON_DEVICE_BUILD) { if (BuildConfig.ON_DEVICE_BUILD) {
info(R.string.about_credit_title_toolchain, activity.getString(R.string.about_credit_toolchain), stacked = true) info(R.string.about_credit_title_toolchain, activity.getString(R.string.about_credit_toolchain), stacked = true)
} }
@@ -0,0 +1,71 @@
package org.wiicompiled.quest.launcher
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import kotlin.math.max
/**
* Children in rows of equal cells (WheelWizard's UniformGrid): a fixed number of [columns], or
* with [columns] at 0 as many cells of [cellWidth] as fit, the rows then centred. A child is as
* tall as its layout height, or its content, and each row as its tallest child.
*/
class TileGrid @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : ViewGroup(context, attrs) {
var columns = 0
var cellWidth = 0
var spacing = 0
private var measuredColumns = 1
private var measuredCell = 0
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val width = MeasureSpec.getSize(widthMeasureSpec) - paddingLeft - paddingRight
measuredColumns = if (columns > 0) columns else max(1, (width + spacing) / (cellWidth + spacing))
measuredCell = if (columns > 0) max(0, (width - spacing * (measuredColumns - 1)) / measuredColumns) else cellWidth
val cellSpec = MeasureSpec.makeMeasureSpec(measuredCell, MeasureSpec.EXACTLY)
var total = 0
var rowHeight = 0
var visible = 0
for (i in 0 until childCount) {
val child = getChildAt(i)
if (child.visibility == View.GONE) continue
val height = child.layoutParams?.height ?: LayoutParams.WRAP_CONTENT
val heightSpec = if (height >= 0) {
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
} else {
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
}
child.measure(cellSpec, heightSpec)
rowHeight = max(rowHeight, child.measuredHeight)
visible++
if (visible % measuredColumns == 0) {
total += rowHeight + spacing
rowHeight = 0
}
}
if (visible % measuredColumns != 0) total += rowHeight else if (visible > 0) total -= spacing
setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), total + paddingTop + paddingBottom)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
val used = measuredColumns * measuredCell + (measuredColumns - 1) * spacing
val left = paddingLeft + if (columns > 0) 0 else max(0, (r - l - paddingLeft - paddingRight - used) / 2)
var top = paddingTop
var column = 0
var rowHeight = 0
for (i in 0 until childCount) {
val child = getChildAt(i)
if (child.visibility == View.GONE) continue
val x = left + column * (measuredCell + spacing)
child.layout(x, top, x + child.measuredWidth, top + child.measuredHeight)
rowHeight = max(rowHeight, child.measuredHeight)
column++
if (column == measuredColumns) {
column = 0
top += rowHeight + spacing
rowHeight = 0
}
}
}
}
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's Danger button, used for Delete. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="false">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_700" />
<corners android:radius="6dp" />
</shape>
</item>
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="@color/danger_300" />
<corners android:radius="6dp" />
</shape>
</item>
<item android:state_hovered="true">
<shape android:shape="rectangle">
<solid android:color="@color/danger_400" />
<corners android:radius="6dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@color/danger_500" />
<corners android:radius="6dp" />
</shape>
</item>
</selector>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A choice in the Mii editor (WheelWizard's MultiIconRadioButton): the chosen one outlined. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_800" />
<stroke android:width="2dp" android:color="@color/primary_400" />
<corners android:radius="8dp" />
</shape>
</item>
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_700" />
<corners android:radius="8dp" />
</shape>
</item>
<item android:state_hovered="true">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_700" />
<corners android:radius="8dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@color/neutral_950" />
<corners android:radius="8dp" />
</shape>
</item>
</selector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Mii editor's page column, Neutral900 as in WheelWizard's MiiEditorWindow. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/neutral_900" />
<corners android:radius="8dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Mii editor's picture frame. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<stroke android:width="1dp" android:color="@color/neutral_700" />
<corners android:radius="6dp" />
</shape>
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's MiiBlock: selected in the primary colour, a favourite in gold. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_800" />
<stroke android:width="2dp" android:color="@color/primary_400" />
<corners android:radius="8dp" />
</shape>
</item>
<item android:state_activated="true">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_900" />
<stroke android:width="2dp" android:color="@color/warning_500" />
<corners android:radius="8dp" />
</shape>
</item>
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_700" />
<stroke android:width="1dp" android:color="@color/neutral_500" />
<corners android:radius="8dp" />
</shape>
</item>
<item android:state_hovered="true">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_700" />
<stroke android:width="1dp" android:color="@color/neutral_500" />
<corners android:radius="8dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@color/neutral_900" />
<stroke android:width="1dp" android:color="@color/neutral_600" />
<corners android:radius="8dp" />
</shape>
</item>
</selector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's ArrowDown (Font Awesome, 384x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group android:translateX="64">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M169.4 470.6c12.5 12.5 32.8 12.5 45.3 0l160-160c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L224 370.8 224 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 306.7L54.6 265.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l160 160z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's ArrowUp (Font Awesome, 384x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group android:translateX="64">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M214.6 41.4c-12.5-12.5-32.8-12.5-45.3 0l-160 160c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 141.2 160 448c0 17.7 14.3 32 32 32s32-14.3 32-32l0-306.7L329.4 246.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-160-160z" />
</group>
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's Duplicate (Font Awesome, 512x512), square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M288 448L64 448l0-224 64 0 0-64-64 0c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l224 0c35.3 0 64-28.7 64-64l0-64-64 0 0 64zm-64-96l224 0c35.3 0 64-28.7 64-64l0-224c0-35.3-28.7-64-64-64L224 0c-35.3 0-64 28.7-64 64l0 224c0 35.3 28.7 64 64 64z" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's FileExport (Font Awesome, 384x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group android:translateX="64">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-288-128 0c-17.7 0-32-14.3-32-32L224 0 64 0zM256 0l0 128 128 0L256 0zM216 232l0 102.1 31-31c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-72 72c-9.4 9.4-24.6 9.4-33.9 0l-72-72c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l31 31L168 232c0-13.3 10.7-24 24-24s24 10.7 24 24z" />
</group>
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's Global icon (Font Awesome, 512x512), square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="18dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M352 256c0 22.2-1.2 43.6-3.3 64l-185.3 0c-2.2-20.4-3.3-41.8-3.3-64s1.2-43.6 3.3-64l185.3 0c2.2 20.4 3.3 41.8 3.3 64zm28.8-64l123.1 0c5.3 20.5 8.1 41.9 8.1 64s-2.8 43.5-8.1 64l-123.1 0c2.1-20.6 3.2-42 3.2-64s-1.1-43.4-3.2-64zm112.6-32l-116.7 0c-10-63.9-29.8-117.4-55.3-151.6c78.3 20.7 142 77.5 171.9 151.6zm-149.1 0l-176.6 0c6.1-36.4 15.5-68.6 27-94.7c10.5-23.6 22.2-40.7 33.5-51.5C239.4 3.2 248.7 0 256 0s16.6 3.2 27.8 13.8c11.3 10.8 23 27.9 33.5 51.5c11.6 26 20.9 58.2 27 94.7zm-209 0L18.6 160C48.6 85.9 112.2 29.1 190.6 8.4C165.1 42.6 145.3 96.1 135.3 160zM8.1 192l123.1 0c-2.1 20.6-3.2 42-3.2 64s1.1 43.4 3.2 64L8.1 320C2.8 299.5 0 278.1 0 256s2.8-43.5 8.1-64zM194.7 446.6c-11.6-26-20.9-58.2-27-94.6l176.6 0c-6.1 36.4-15.5 68.6-27 94.6c-10.5 23.6-22.2 40.7-33.5 51.5C272.6 508.8 263.3 512 256 512s-16.6-3.2-27.8-13.8c-11.3-10.8-23-27.9-33.5-51.5zM135.3 352c10 63.9 29.8 117.4 55.3 151.6C112.2 482.9 48.6 426.1 18.6 352l116.7 0zm358.1 0c-30 74.1-93.6 130.9-171.9 151.6c25.5-34.2 45.2-87.7 55.3-151.6l116.7 0z" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's MinMark (Font Awesome, 448x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group android:translateX="32">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M432 256c0 17.7-14.3 32-32 32L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l352 0c17.7 0 32 14.3 32 32z" />
</group>
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's Pen (Font Awesome, 512x512), square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's PlusMark (Font Awesome, 448x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group android:translateX="32">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 144L48 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0 0 144c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144 144 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-144 0 0-144z" />
</group>
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's RotateLeft (Font Awesome, 512x512), square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M125.7 160l50.3 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L48 224c-17.7 0-32-14.3-32-32L16 64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 51.2L97.6 97.6c87.5-87.5 229.3-87.5 316.8 0s87.5 229.3 0 316.8s-229.3 87.5-316.8 0c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0c62.5 62.5 163.8 62.5 226.3 0s62.5-163.8 0-226.3s-163.8-62.5-226.3 0L125.7 160z" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's RotateRight (Font Awesome, 512x512), square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M386.3 160L336 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 51.2L414.4 97.6c-87.5-87.5-229.3-87.5-316.8 0s-87.5 229.3 0 316.8s229.3 87.5 316.8 0c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0c-62.5 62.5-163.8 62.5-226.3 0s-62.5-163.8 0-226.3s163.8-62.5 226.3 0L386.3 160z" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's Shirt (Font Awesome, 640x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="640"
android:viewportHeight="640">
<group android:translateY="64">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M211.8 0c7.8 0 14.3 5.7 16.7 13.2C240.8 51.9 277.1 80 320 80s79.2-28.1 91.5-66.8C413.9 5.7 420.4 0 428.2 0l12.6 0c22.5 0 44.2 7.9 61.5 22.3L628.5 127.4c6.6 5.5 10.7 13.5 11.4 22.1s-2.1 17.1-7.8 23.6l-56 64c-11.4 13.1-31.2 14.6-44.6 3.5L480 197.7 480 448c0 35.3-28.7 64-64 64l-192 0c-35.3 0-64-28.7-64-64l0-250.3-51.5 42.9c-13.3 11.1-33.1 9.6-44.6-3.5l-56-64c-5.7-6.5-8.5-15-7.8-23.6s4.8-16.6 11.4-22.1L137.7 22.3C155 7.9 176.7 0 199.2 0l12.6 0z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's EmptyStarIcon (Font Awesome, 576x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="576"
android:viewportHeight="576">
<group android:translateY="32">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M287.9 0c9.2 0 17.6 5.2 21.6 13.5l68.6 141.3 153.2 22.6c9 1.3 16.5 7.6 19.3 16.3s.5 18.1-5.9 24.5L433.6 328.4l26.2 155.6c1.5 9-2.2 18.1-9.7 23.5s-17.3 6-25.3 1.7l-137-73.2L151 509.1c-8.1 4.3-17.9 3.7-25.3-1.7s-11.2-14.5-9.7-23.5l26.2-155.6L31.1 218.2c-6.5-6.4-8.7-15.9-5.9-24.5s10.3-14.9 19.3-16.3l153.2-22.6L266.3 13.5C270.4 5.2 278.7 0 287.9 0zm0 79L235.4 187.2c-3.5 7.1-10.2 12.1-18.1 13.3L99 217.9 184.9 303c5.5 5.5 8.1 13.3 6.8 21L171.4 443.7l105.2-56.2c7.1-3.8 15.6-3.8 22.6 0l105.2 56.2L384.2 324.1c-1.3-7.7 1.2-15.5 6.8-21l85.9-85.1L358.6 200.5c-7.8-1.2-14.6-6.1-18.1-13.3L287.9 79z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's Trash (Font Awesome, 448x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group android:translateX="32">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M135.2 17.7C140.6 6.8 151.7 0 163.8 0L284.2 0c12.1 0 23.2 6.8 28.6 17.7L320 32l96 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 7.2-14.3zM32 128l384 0 0 320c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-320zm96 64c-8.8 0-16 7.2-16 16l0 224c0 8.8 7.2 16 16 16s16-7.2 16-16l0-224c0-8.8-7.2-16-16-16zm96 0c-8.8 0-16 7.2-16 16l0 224c0 8.8 7.2 16 16 16s16-7.2 16-16l0-224c0-8.8-7.2-16-16-16zm96 0c-8.8 0-16 7.2-16 16l0 224c0 8.8 7.2 16 16 16s16-7.2 16-16l0-224c0-8.8-7.2-16-16-16z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's XMark (Font Awesome, 384x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="18dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group android:translateX="64">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z" />
</group>
</vector>
@@ -152,6 +152,20 @@
android:text="@string/launcher_nav_patches" /> android:text="@string/launcher_nav_patches" />
</LinearLayout> </LinearLayout>
<LinearLayout
android:id="@+id/nav_miis"
style="@style/Launcher.NavItem">
<ImageView
style="@style/Launcher.NavIcon"
android:importantForAccessibility="no"
android:src="@drawable/ic_shirt" />
<TextView
style="@style/Launcher.NavLabel"
android:text="@string/launcher_nav_miis" />
</LinearLayout>
<LinearLayout <LinearLayout
android:id="@+id/nav_settings" android:id="@+id/nav_settings"
style="@style/Launcher.NavItem"> style="@style/Launcher.NavItem">
@@ -206,6 +220,11 @@
layout="@layout/page_patches" layout="@layout/page_patches"
android:visibility="gone" /> android:visibility="gone" />
<include
android:id="@+id/page_miis"
layout="@layout/page_miis"
android:visibility="gone" />
<include <include
android:id="@+id/page_settings" android:id="@+id/page_settings"
layout="@layout/page_settings" layout="@layout/page_settings"
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A Mii on My Miis (WheelWizard's MiiBlock), or the tile that makes a new one. The tile shows
the item's state: selected is the selection, activated a favourite. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="104dp"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
android:gravity="center_horizontal"
android:orientation="vertical">
<FrameLayout
android:id="@+id/mii_tile"
android:layout_width="100dp"
android:layout_height="100dp"
android:background="@drawable/bg_mii_tile"
android:clipToOutline="true"
android:duplicateParentState="true">
<ImageView
android:id="@+id/mii_placeholder"
android:layout_width="46dp"
android:layout_height="46dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/ic_user"
android:tint="@color/neutral_700" />
<ImageView
android:id="@+id/mii_add"
android:layout_width="34dp"
android:layout_height="34dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/ic_plus"
android:tint="@color/neutral_500"
android:visibility="gone" />
<!-- Larger than the tile and raised, as WheelWizard crops its list pictures. -->
<ImageView
android:id="@+id/mii_picture"
android:layout_width="140dp"
android:layout_height="140dp"
android:layout_gravity="center_horizontal|top"
android:layout_marginTop="-19dp"
android:importantForAccessibility="no" />
<ImageView
android:id="@+id/mii_favorite"
android:layout_width="17dp"
android:layout_height="17dp"
android:layout_gravity="top|start"
android:layout_margin="6dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_star"
android:tint="@color/warning_500"
android:visibility="gone" />
<ImageView
android:id="@+id/mii_foreign"
android:layout_width="17dp"
android:layout_height="17dp"
android:layout_gravity="top|end"
android:layout_margin="6dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_globe"
android:tint="@color/mii_foreign"
android:visibility="gone" />
</FrameLayout>
<TextView
android:id="@+id/mii_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:ellipsize="end"
android:maxWidth="100dp"
android:singleLine="true"
android:textColor="@color/neutral_200"
android:textSize="13sp" />
</LinearLayout>
@@ -0,0 +1,214 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's Mii editor (Views/Popups/MiiManagement/MiiEditorWindow.axaml): its pages on the
left, the Mii's face on the right. The pages' fields are built by MiiEditor. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:paddingStart="24dp"
android:paddingTop="20dp"
android:paddingEnd="24dp"
android:paddingBottom="20dp">
<FrameLayout
android:layout_width="370dp"
android:layout_height="match_parent"
android:background="@drawable/bg_mii_editor_panel">
<!-- EditorStartPage: the Mii's name, its sections and the editor's buttons. -->
<LinearLayout
android:id="@+id/mii_editor_start"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="56dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingEnd="12dp">
<ImageView
android:id="@+id/mii_editor_favorite"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/bg_nav_item"
android:clickable="true"
android:contentDescription="@string/miis_favorite"
android:focusable="true"
android:padding="9dp"
android:src="@drawable/ic_star_empty"
android:tint="@color/neutral_400"
android:tooltipText="@string/miis_favorite" />
<TextView
android:id="@+id/mii_editor_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:ellipsize="end"
android:fontFamily="sans-serif-medium"
android:singleLine="true"
android:textColor="@color/neutral_100"
android:textSize="20sp" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/neutral_700" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:id="@+id/mii_editor_sections"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="8dp" />
</ScrollView>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/neutral_700" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="10dp">
<TextView
android:id="@+id/mii_editor_randomize"
style="@style/Launcher.Button.Secondary"
android:text="@string/mii_editor_randomize" />
<Space
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/mii_editor_cancel"
style="@style/Launcher.Button.Warning"
android:text="@string/mii_editor_cancel" />
<TextView
android:id="@+id/mii_editor_save"
style="@style/Launcher.Button.Primary"
android:layout_marginStart="8dp"
android:text="@string/mii_editor_save" />
</LinearLayout>
</LinearLayout>
<!-- One of the editor's pages, such as Eyes: Back, its title and its fields. -->
<LinearLayout
android:id="@+id/mii_editor_page"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:visibility="gone">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="48dp">
<TextView
android:id="@+id/mii_editor_back"
style="@style/Launcher.LinkButton"
android:layout_height="36dp"
android:layout_gravity="start|center_vertical"
android:layout_marginStart="8dp"
android:drawableStart="@drawable/ic_arrow_left"
android:drawableTint="@color/neutral_100"
android:text="@string/mii_editor_back"
android:textColor="@color/neutral_100"
android:textSize="16sp" />
<TextView
android:id="@+id/mii_editor_page_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:layout_marginEnd="14dp"
android:textColor="@color/neutral_300"
android:textSize="15sp" />
</FrameLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/neutral_700" />
<ScrollView
android:id="@+id/mii_editor_page_scroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:id="@+id/mii_editor_page_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp" />
</ScrollView>
</LinearLayout>
</FrameLayout>
<View
android:layout_width="1dp"
android:layout_height="match_parent"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:background="@color/neutral_700" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<FrameLayout
android:layout_width="340dp"
android:layout_height="340dp"
android:background="@drawable/bg_mii_preview">
<ImageView
android:id="@+id/mii_editor_placeholder"
android:layout_width="120dp"
android:layout_height="120dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/ic_user"
android:tint="@color/neutral_700" />
<ImageView
android:id="@+id/mii_editor_preview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no" />
</FrameLayout>
<TextView
android:id="@+id/mii_editor_note"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center"
android:maxWidth="320dp"
android:text="@string/mii_editor_no_parts"
android:textColor="@color/neutral_400"
android:textSize="13sp"
android:visibility="gone" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,198 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's My Miis page (Views/Pages/MiiListPage.axaml), with its Mii editor
(page_mii_editor.xml) opened in place of the list. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/miis_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingEnd="24dp">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="64dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="bottom"
android:orientation="horizontal"
android:paddingBottom="10dp">
<TextView
style="@style/Launcher.PageTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/launcher_nav_miis" />
<TextView
android:id="@+id/miis_count"
style="@style/Launcher.StateBox"
android:layout_marginStart="10dp"
android:layout_marginBottom="4dp"
android:visibility="gone" />
</LinearLayout>
<LinearLayout
android:id="@+id/miis_buttons"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginBottom="10dp"
android:orientation="horizontal"
android:visibility="gone">
<ImageView
android:id="@+id/miis_favorite"
style="@style/Launcher.IconButton"
android:contentDescription="@string/miis_favorite"
android:src="@drawable/ic_star"
android:tooltipText="@string/miis_favorite" />
<ImageView
android:id="@+id/miis_edit"
style="@style/Launcher.IconButton"
android:contentDescription="@string/miis_edit"
android:src="@drawable/ic_pen"
android:tooltipText="@string/miis_edit" />
<ImageView
android:id="@+id/miis_duplicate"
style="@style/Launcher.IconButton"
android:contentDescription="@string/miis_duplicate"
android:src="@drawable/ic_duplicate"
android:tooltipText="@string/miis_duplicate" />
<ImageView
android:id="@+id/miis_export"
style="@style/Launcher.IconButton"
android:contentDescription="@string/miis_export"
android:src="@drawable/ic_file_export"
android:tooltipText="@string/miis_export" />
<ImageView
android:id="@+id/miis_delete"
style="@style/Launcher.IconButton.Danger"
android:contentDescription="@string/miis_delete"
android:src="@drawable/ic_trash"
android:tooltipText="@string/miis_delete" />
<ImageView
android:id="@+id/miis_import"
style="@style/Launcher.IconButton"
android:contentDescription="@string/miis_import"
android:src="@drawable/ic_file_import"
android:tooltipText="@string/miis_import" />
</LinearLayout>
</FrameLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/neutral_700" />
<!-- The Mii parts every picture needs, until they are downloaded. -->
<LinearLayout
android:id="@+id/miis_parts"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/bg_banner_info"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="12dp"
android:paddingTop="10dp"
android:paddingEnd="12dp"
android:paddingBottom="10dp"
android:visibility="gone">
<ImageView
android:layout_width="22dp"
android:layout_height="22dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_download"
android:tint="@color/primary_400" />
<TextView
android:id="@+id/miis_parts_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:layout_weight="1"
android:textColor="@color/neutral_200"
android:textSize="13sp" />
<TextView
android:id="@+id/miis_parts_download"
style="@style/Launcher.Button.Primary"
android:text="@string/miis_parts_download" />
</LinearLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:id="@+id/miis_empty"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_horizontal"
android:orientation="vertical"
android:visibility="gone">
<ImageView
android:layout_width="56dp"
android:layout_height="56dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_shirt"
android:tint="@color/neutral_600" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:fontFamily="sans-serif-medium"
android:text="@string/miis_empty_title"
android:textColor="@color/neutral_200"
android:textSize="20sp" />
<TextView
android:id="@+id/miis_empty_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:gravity="center"
android:maxWidth="440dp"
android:textColor="@color/neutral_400"
android:textSize="14sp" />
</LinearLayout>
<ScrollView
android:id="@+id/miis_scroll"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingTop="16dp"
android:paddingBottom="16dp">
<org.wiicompiled.quest.launcher.TileGrid
android:id="@+id/miis_grid"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</ScrollView>
</FrameLayout>
</LinearLayout>
<include
android:id="@+id/miis_editor"
layout="@layout/page_mii_editor"
android:visibility="gone" />
</FrameLayout>
@@ -41,4 +41,6 @@
<color name="patches_image">#28503B</color> <color name="patches_image">#28503B</color>
<color name="patches_badge">#1E5E3A</color> <color name="patches_badge">#1E5E3A</color>
<color name="patches_badge_text">#D6FFE2</color> <color name="patches_badge_text">#D6FFE2</color>
<!-- WheelWizard marks Miis made on another console with a DeepSkyBlue globe. -->
<color name="mii_foreign">#00BFFF</color>
</resources> </resources>
+2
View File
@@ -2,4 +2,6 @@
<resources> <resources>
<!-- The picture URL an ImageView last asked RemoteImages for. --> <!-- The picture URL an ImageView last asked RemoteImages for. -->
<item name="remote_image_url" type="id" /> <item name="remote_image_url" type="id" />
<!-- The Mii picture an ImageView last asked MiiImages for. -->
<item name="mii_image_key" type="id" />
</resources> </resources>
+115
View File
@@ -9,6 +9,7 @@
<string name="launcher_nav_home">Home</string> <string name="launcher_nav_home">Home</string>
<string name="launcher_nav_profiles">My profiles</string> <string name="launcher_nav_profiles">My profiles</string>
<string name="launcher_nav_patches">Patches</string> <string name="launcher_nav_patches">Patches</string>
<string name="launcher_nav_miis">My Miis</string>
<string name="launcher_nav_settings">Settings</string> <string name="launcher_nav_settings">Settings</string>
<string name="launcher_version">v%1$s</string> <string name="launcher_version">v%1$s</string>
@@ -170,6 +171,118 @@
<string name="history_none_range">No VR history found for this range yet.</string> <string name="history_none_range">No VR history found for this range yet.</string>
<string name="history_no_code">Friend code is required to load VR history.</string> <string name="history_no_code">Friend code is required to load VR history.</string>
<!-- My Miis and the Mii editor: WheelWizard's MiiListPage and MiiEditorWindow, wording from en.yml -->
<string name="miis_favorite">Favorite</string>
<string name="miis_unfavorite">Unfavorite</string>
<string name="miis_edit">Edit</string>
<string name="miis_duplicate">Duplicate</string>
<string name="miis_export">Export</string>
<string name="miis_delete">Delete</string>
<string name="miis_import">Import</string>
<string name="miis_empty_title">No Miis yet!</string>
<string name="miis_empty_message">The Mii database in the game\'s NAND cannot be opened: %1$s</string>
<string name="miis_parts_message">Mii pictures are drawn from FFL\'s Mii parts (%1$s), the file Wheel Wizard downloads for the same reason: a copy of Miitomo\'s kept by the Internet Archive. They are downloaded once and stay on this headset.</string>
<string name="miis_parts_download">Download</string>
<string name="miis_parts_connecting">Connecting to the Internet Archive…</string>
<string name="miis_parts_downloading">Downloading the Mii parts: %1$s of %2$s</string>
<string name="miis_parts_failed">The Mii parts could not be downloaded: %1$s</string>
<string name="miis_new_title">New Mii</string>
<string name="miis_new_random">Randomize</string>
<string name="miis_new_male">Male</string>
<string name="miis_new_female">Female</string>
<string name="miis_default_name">New Mii</string>
<string name="miis_delete_title_one">Are you sure you want to delete %1$s?</string>
<string name="miis_delete_title_many">Are you sure you want to delete %1$d Miis?</string>
<string name="miis_delete_message">Deleting is permanent and cannot be undone.</string>
<string name="miis_delete_favorite_title">Cannot delete favorite Mii(s)</string>
<string name="miis_delete_favorite_message">One or more of the selected Mii(s) is a favorite. Miis can only be deleted if they are not favorites to prevent accidental deletions.</string>
<string name="miis_deleted_one">Deleted \'%1$s\'</string>
<string name="miis_deleted_many">Deleted %1$d Miis</string>
<string name="miis_duplicated_one">Created duplicate of \'%1$s\'</string>
<string name="miis_duplicated_many">Created %1$d duplicate Miis</string>
<plurals name="miis_imported">
<item quantity="one">Imported %1$d Mii</item>
<item quantity="other">Imported %1$d Miis</item>
</plurals>
<string name="miis_import_problem">%1$s: %2$s</string>
<string name="miis_exported_one">Saved Mii \'%1$s\'</string>
<string name="miis_exported_many">Saved %1$d Miis</string>
<string name="miis_export_failed">The Miis could not be saved: %1$s</string>
<string name="miis_change_failed">The Mii database could not be changed</string>
<string name="miis_close_game">Close the game first: it reads the Mii database.</string>
<string name="miis_no_console_title">Start the game once first</string>
<string name="miis_no_console_message">Miis made on this headset carry its console identity, which the game creates the first time it starts. Start it once, then come back.</string>
<string name="miis_no_picker">No file picker is available on this headset.</string>
<string name="mii_editor_randomize">Randomize</string>
<string name="mii_editor_cancel">Cancel</string>
<string name="mii_editor_save">Save</string>
<string name="mii_editor_back">Back</string>
<string name="mii_editor_no_parts">Download the Mii parts on My Miis to see your Mii here.</string>
<string name="mii_editor_discard_title">Discard your changes?</string>
<string name="mii_editor_discard_message">Leaving the editor without saving loses them.</string>
<string name="mii_editor_discard">Discard</string>
<string name="mii_section_general">General</string>
<string name="mii_section_face">Face</string>
<string name="mii_section_hair">Hair</string>
<string name="mii_section_eyebrows">Eyebrows</string>
<string name="mii_section_eyes">Eyes</string>
<string name="mii_section_nose">Nose</string>
<string name="mii_section_lips">Lips</string>
<string name="mii_section_glasses">Glasses</string>
<string name="mii_section_facial_hair">Facial Hair</string>
<string name="mii_section_mole">Mole</string>
<string name="mii_name">Mii Name</string>
<string name="mii_name_hint">Enter Mii name…</string>
<string name="mii_name_length">Names must be between 3 and 10 characters long.</string>
<string name="mii_creator_name">Creator Name</string>
<string name="mii_creator_hint">Optional</string>
<string name="mii_creation_date">Creation Date</string>
<string name="mii_creation_unknown">Unknown</string>
<string name="mii_gender">Gender</string>
<string name="mii_gender_male">Male</string>
<string name="mii_gender_female">Female</string>
<string name="mii_height">Height</string>
<string name="mii_width">Width</string>
<string name="mii_favorite_color">Favorite Color</string>
<string name="mii_skin_color">Skin Color</string>
<string name="mii_head_shape">Head Shape</string>
<string name="mii_facial_feature">Facial Feature</string>
<string-array name="mii_facial_features">
<item>None</item>
<item>Cheeks</item>
<item>Cheek and eyes</item>
<item>Freckles</item>
<item>Baggy eyes</item>
<item>Chad</item>
<item>Tired</item>
<item>Chin</item>
<item>Eye shadow</item>
<item>Beard</item>
<item>Mouth corners</item>
<item>Wrinkles</item>
</string-array>
<string name="mii_hair_color">Hair Color</string>
<string name="mii_mirror_hair">Mirror Hair</string>
<string name="mii_hair_type">Hair Type</string>
<string name="mii_vertical_position">Vertical Position (Up/Down)</string>
<string name="mii_horizontal_position">Horizontal Position (Left/Right)</string>
<string name="mii_rotation">Rotation (Rotate Left/Right)</string>
<string name="mii_size">Size</string>
<string name="mii_space_between">Spacing in between</string>
<string name="mii_eyebrow_type">Eyebrow Type</string>
<string name="mii_eye_color">Eye Color</string>
<string name="mii_eye_type">Eye Type</string>
<string name="mii_nose_type">Nose Type</string>
<string name="mii_lip_color">Lip Color</string>
<string name="mii_mouth_type">Mouth Type</string>
<string name="mii_glasses_type">Glasses Type</string>
<string name="mii_glasses_color">Glasses Color</string>
<string name="mii_mustache_type">Mustache Type</string>
<string name="mii_mustache_vertical">Mustache Vertical Position (Up/Down)</string>
<string name="mii_mustache_size">Mustache Size</string>
<string name="mii_beard_type">Beard Type</string>
<string name="mii_mole_enabled">Enable Mole?</string>
<!-- The mod browser: WheelWizard's ModBrowserWindow and ModContent, wording from en.yml --> <!-- The mod browser: WheelWizard's ModBrowserWindow and ModContent, wording from en.yml -->
<string name="browser_title">Mod Browser</string> <string name="browser_title">Mod Browser</string>
<string name="browser_back">Back to your mods</string> <string name="browser_back">Back to your mods</string>
@@ -229,6 +342,7 @@
<string name="about_credit_nod">nod by encounter, which reads your disc image, built into this app as libnod_jni.</string> <string name="about_credit_nod">nod by encounter, which reads your disc image, built into this app as libnod_jni.</string>
<string name="about_credit_mod_browser">The Patches page\'s mod browser finds mods on GameBanana, as Wheel Wizard\'s does. Their .7z and .rar archives are unpacked by sevenz-rust2 (Apache 2.0) and rars by bitplane (MIT or Apache 2.0), built into libnod_jni.</string> <string name="about_credit_mod_browser">The Patches page\'s mod browser finds mods on GameBanana, as Wheel Wizard\'s does. Their .7z and .rar archives are unpacked by sevenz-rust2 (Apache 2.0) and rars by bitplane (MIT or Apache 2.0), built into libnod_jni.</string>
<string name="about_credit_profiles">My profiles reads your licences from Retro Rewind\'s save, and shows each one\'s Mii picture, VR history and online status from Retro WFC (rwfc.net), as Wheel Wizard does. Badges come from Wheel Wizard\'s data.</string> <string name="about_credit_profiles">My profiles reads your licences from Retro Rewind\'s save, and shows each one\'s Mii picture, VR history and online status from Retro WFC (rwfc.net), as Wheel Wizard does. Badges come from Wheel Wizard\'s data.</string>
<string name="about_credit_miis">My Miis edits the game\'s Mii database, and its Mii editor works as Wheel Wizard\'s. Miis are drawn by a port of Wheel Wizard\'s Mii renderer (GPL v3), from FFL\'s Mii parts, which you download from the Internet Archive\'s copy of Miitomo\'s files.</string>
<string name="about_credit_toolchain">On-headset builds use Termux\'s Android clang and lld, Google\'s Android NDK headers and libraries, and .NET for the translator.</string> <string name="about_credit_toolchain">On-headset builds use Termux\'s Android clang and lld, Google\'s Android NDK headers and libraries, and .NET for the translator.</string>
<string name="about_credit_dolphin">The Dolphin emulator, the reference for how the Wii behaves.</string> <string name="about_credit_dolphin">The Dolphin emulator, the reference for how the Wii behaves.</string>
<string name="about_credit_logo">The WiiCompiled logo by inkwreck.</string> <string name="about_credit_logo">The WiiCompiled logo by inkwreck.</string>
@@ -244,6 +358,7 @@
<string name="about_credit_title_disc">Disc images</string> <string name="about_credit_title_disc">Disc images</string>
<string name="about_credit_title_mod_browser">Mod browser</string> <string name="about_credit_title_mod_browser">Mod browser</string>
<string name="about_credit_title_profiles">My profiles</string> <string name="about_credit_title_profiles">My profiles</string>
<string name="about_credit_title_miis">My Miis</string>
<string name="about_credit_title_toolchain">On-headset builds</string> <string name="about_credit_title_toolchain">On-headset builds</string>
<string name="about_credit_title_dolphin">Dolphin</string> <string name="about_credit_title_dolphin">Dolphin</string>
<string name="about_credit_title_logo">Logo</string> <string name="about_credit_title_logo">Logo</string>
@@ -208,4 +208,32 @@
<item name="android:clickable">true</item> <item name="android:clickable">true</item>
<item name="android:focusable">true</item> <item name="android:focusable">true</item>
</style> </style>
<!-- WheelWizard's icon-only Default and Danger buttons, the My Miis page's actions. -->
<style name="Launcher.IconButton">
<item name="android:layout_width">40dp</item>
<item name="android:layout_height">40dp</item>
<item name="android:layout_marginStart">6dp</item>
<item name="android:padding">11dp</item>
<item name="android:scaleType">fitCenter</item>
<item name="android:background">@drawable/bg_button_secondary</item>
<item name="android:tint">@color/neutral_100</item>
<item name="android:clickable">true</item>
<item name="android:focusable">true</item>
</style>
<style name="Launcher.IconButton.Danger">
<item name="android:background">@drawable/bg_button_danger</item>
<item name="android:tint">@color/neutral_50</item>
</style>
<!-- The Mii editor's field labels (WheelWizard's FormFieldLabel). -->
<style name="Launcher.FieldLabel">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_marginBottom">6dp</item>
<item name="android:textColor">@color/neutral_300</item>
<item name="android:textSize">14sp</item>
<item name="android:fontFamily">sans-serif-medium</item>
</style>
</resources> </resources>
@@ -0,0 +1,145 @@
package org.wiicompiled.quest.launcher
import kotlin.random.Random
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
/** Miis are read and written as WheelWizard's MiiSerializer does. */
class MiiDataTest {
@Test
fun serializesTheFieldsWhereThePcPutsThem() {
val mii = MiiFactory.male("Ab").apply {
girl = true
birthMonth = 4
birthDay = 4
favoriteColor = 3
favorite = true
miiId = 0x87C0DB3EL
systemId = 0xC271D44CL
creatorName = "Me"
}
val data = MiiData.serialize(mii)
assertEquals(MiiData.SIZE, data.size)
// Girl, April 4th, favourite colour 3, favourite.
assertEquals(0x5087, u16(data, 0x00))
assertArrayEquals(bytes(0x00, 0x41, 0x00, 0x62, 0x00, 0x00), data.copyOfRange(0x02, 0x08))
assertEquals(63, data[0x16].toInt())
assertEquals(63, data[0x17].toInt())
assertEquals(0x87C0DB3EL, u32(data, 0x18))
assertEquals(0xC271D44CL, u32(data, 0x1C))
// Male's hair 33, brown, not flipped.
assertEquals((33 shl 9) or (1 shl 6), u16(data, 0x22))
// Eyebrows 6, rotation 6, brown, size 4, vertical 10, spacing 2.
assertEquals((6L shl 27) or (6L shl 22) or (1L shl 13) or (4L shl 9) or (10L shl 4) or 2L, u32(data, 0x24))
// Eyes 2, rotation 4, vertical 12, black, size 4, spacing 2.
assertEquals((2L shl 26) or (4L shl 21) or (12L shl 16) or (4L shl 9) or (2L shl 5), u32(data, 0x28))
assertArrayEquals(bytes(0x00, 0x4D, 0x00, 0x65, 0x00, 0x00), data.copyOfRange(0x36, 0x3C))
}
@Test
fun roundTripsEveryField() {
val random = Random(7)
repeat(50) {
val mii = MiiFactory.random("Mii$it", random).apply {
miiId = 0x80000000L + it
systemId = 0x12345678L
birthMonth = random.nextInt(13)
birthDay = random.nextInt(32)
favorite = random.nextBoolean()
mingleOff = random.nextBoolean()
eyebrowVertical = 3 + random.nextInt(16)
moleVertical = random.nextInt(31)
moleHorizontal = random.nextInt(17)
creatorName = "Creator"
}
assertEquals(mii, MiiData.parse(MiiData.serialize(mii)))
}
}
@Test
fun keepsABirthdayThatWasNeverSet() {
// The PC turns month 0 into January 1st when it saves; the headset keeps what was there.
val mii = MiiFactory.female("Nobirthday").apply {
miiId = 5
birthMonth = 0
birthDay = 0
}
val parsed = MiiData.parse(MiiData.serialize(mii))
assertEquals(0, parsed.birthMonth)
assertEquals(0, parsed.birthDay)
}
@Test
fun refusesWhatTheWiiNeverWrites() {
val good = MiiData.serialize(MiiFactory.male("Good").apply { miiId = 9 })
assertThrows(IllegalArgumentException::class.java) { MiiData.parse(ByteArray(MiiData.SIZE)) }
assertThrows(IllegalArgumentException::class.java) { MiiData.parse(ByteArray(MiiData.SIZE) { 0xFF.toByte() }) }
assertThrows(IllegalArgumentException::class.java) { MiiData.parse(good.copyOf(73)) }
// No name.
assertThrows(IllegalArgumentException::class.java) { MiiData.parse(good.copyOf().also { it.fill(0, 0x02, 0x16) }) }
// Favourite colour 12.
assertThrows(IllegalArgumentException::class.java) { MiiData.parse(good.copyOf().also { it[1] = (12 shl 1).toByte() }) }
// Hair type 72.
assertThrows(IllegalArgumentException::class.java) { MiiData.parse(good.copyOf().also { it[0x22] = (72 shl 1).toByte() }) }
// A Mii without an ID cannot be written.
assertThrows(IllegalArgumentException::class.java) { MiiData.serialize(MiiFactory.male("NoId").apply { miiId = 0 }) }
}
@Test
fun datesAMiiByItsId() {
// 0b100 and 4-second ticks from 2006-01-01 UTC.
assertEquals(MiiData.EPOCH_2006_MILLIS + 1_000L * 4_000L, MiiData.creationTimeMillis(0x80000000L + 1_000L))
assertEquals(null, MiiData.creationTimeMillis(0))
}
@Test
fun randomizeKeepsWhoTheMiiIs() {
val mii = MiiFactory.female("Keeper").apply {
favorite = true
miiId = 0x80001234L
systemId = 0xAABBCCDDL
creatorName = "Someone"
birthMonth = 7
birthDay = 9
}
val random = MiiFactory.randomLook(mii, Random(3))
assertEquals("Keeper", random.name)
assertEquals(true, random.favorite)
assertEquals(0x80001234L, random.miiId)
assertEquals(0xAABBCCDDL, random.systemId)
assertEquals("Someone", random.creatorName)
assertEquals(7, random.birthMonth)
assertEquals(9, random.birthDay)
}
@Test
fun mapsTheWiiMiiToStudioValuesAsThePcDoes() {
val mii = MiiFactory.male("Studio").apply {
hairColor = 0
eyeColor = 2
lipColor = 1
glassesColor = 0
facialHairColor = 5
facialFeature = 4
}
val studio = FflCharInfo.studio(mii)
assertEquals(8, studio[0x1B])
assertEquals(10, studio[4])
assertEquals(20, studio[0x24])
assertEquals(8, studio[0x17])
assertEquals(5, studio[0])
// Baggy eyes are FFL's wrinkles 5, without make-up.
assertEquals(5, studio[0x14])
assertEquals(0, studio[0x12])
assertEquals(mii.lookKey(), mii.copy(name = "Other", miiId = 77).lookKey())
}
private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() }
private fun u16(data: ByteArray, offset: Int) = ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
private fun u32(data: ByteArray, offset: Int) = (u16(data, offset).toLong() shl 16) or u16(data, offset + 2).toLong()
}
@@ -0,0 +1,118 @@
package org.wiicompiled.quest.launcher
import java.io.File
import java.io.IOException
import java.nio.file.Files
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
/** The Mii database is created and changed as WheelWizard's MiiRepositoryService does. */
class MiiDatabaseTest {
private fun newDatabase(): File {
val file = File(Files.createTempDirectory("miidb").toFile(), "shared2/menu/FaceLib/RFL_DB.dat")
MiiDatabase.create(file)
return file
}
private fun mii(name: String, id: Long) = MiiFactory.male(name).apply {
miiId = id
systemId = 0xC8112233L
}
@Test
fun crcIsXmodem() {
assertEquals(0x31C3, MiiDatabase.crc16("123456789".toByteArray(), 0, 9))
}
@Test
fun createsThePcsEmptyDatabase() {
val db = newDatabase().readBytes()
assertEquals(MiiDatabase.FILE_SIZE, db.size)
assertEquals("RNOD", String(db, 0, 4, Charsets.US_ASCII))
assertEquals("RNHD", String(db, 0x1D00, 4, Charsets.US_ASCII))
assertEquals(0x80, db[0x1CEC].toInt() and 0xFF)
assertTrue((0x1D04..0x1D07).all { db[it] == 0xFF.toByte() })
// Like a database the Wii formatted: 10,000 hidden entries, each linked to nothing.
for (entry in 0 until 10_000) {
val at = 0x1D08 + entry * 12
assertArrayEquals(
byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0, 0x7F, 0xFF.toByte(), 0x7F, 0xFF.toByte()),
db.copyOfRange(at, at + 12),
)
}
assertTrue((0x1D08 + 10_000 * 12 until 0x1F1DE).all { db[it] == 0.toByte() })
assertTrue((0x1F1E0 until db.size).all { db[it] == 0.toByte() })
val stored = ((db[0x1F1DE].toInt() and 0xFF) shl 8) or (db[0x1F1DF].toInt() and 0xFF)
assertEquals(MiiDatabase.crc16(db, 0, 0x1F1DE), stored)
assertTrue(MiiDatabase.slots(db).all { it == null })
}
@Test
fun createLeavesAnExistingDatabaseAlone() {
val file = newDatabase()
MiiDatabase.add(file, mii("Kept", 0x80000001L))
MiiDatabase.create(file)
assertEquals(listOf("Kept"), MiiDatabase.miis(file).map { it.name })
}
@Test
fun addsUpdatesAndRemovesBySlot() {
val file = newDatabase()
MiiDatabase.add(file, mii("First", 0x80000001L))
MiiDatabase.add(file, mii("Second", 0x80000002L))
MiiDatabase.add(file, mii("Third", 0x80000003L))
assertEquals(listOf("First", "Second", "Third"), MiiDatabase.miis(file).map { it.name })
MiiDatabase.remove(file, 0x80000002L)
assertNull(MiiDatabase.slots(file)[1])
// A new Mii takes the first free slot, as on the PC.
MiiDatabase.add(file, mii("Fourth", 0x80000004L))
assertEquals(listOf("First", "Fourth", "Third"), MiiDatabase.miis(file).map { it.name })
MiiDatabase.update(file, mii("Renamed", 0x80000003L).apply { favorite = true })
val third = MiiDatabase.miis(file)[2]
assertEquals("Renamed", third.name)
assertEquals(true, third.favorite)
val db = file.readBytes()
val stored = ((db[0x1F1DE].toInt() and 0xFF) shl 8) or (db[0x1F1DF].toInt() and 0xFF)
assertEquals(MiiDatabase.crc16(db, 0, 0x1F1DE), stored)
assertArrayEquals(MiiData.serialize(mii("First", 0x80000001L)), db.copyOfRange(4, 4 + MiiData.SIZE))
}
@Test
fun refusesChangesItCannotMake() {
val file = newDatabase()
assertThrows(IOException::class.java) { MiiDatabase.update(file, mii("Missing", 0x80000009L)) }
assertThrows(IOException::class.java) { MiiDatabase.remove(file, 0) }
for (slot in 0 until MiiDatabase.SLOTS) MiiDatabase.add(file, mii("M$slot", 0x80000100L + slot))
assertThrows(IOException::class.java) { MiiDatabase.add(file, mii("Extra", 0x80000999L)) }
assertEquals(MiiDatabase.SLOTS, MiiDatabase.miis(file).size)
}
@Test
fun refusesToWriteOverACorruptDatabase() {
val file = newDatabase()
val db = file.readBytes()
db[0x100] = 1
file.writeBytes(db)
assertThrows(IOException::class.java) { MiiDatabase.add(file, mii("Nope", 0x80000001L)) }
assertEquals(1, file.readBytes()[0x100].toInt())
}
@Test
fun listsOnlyTheSlotsItCanRead() {
val file = newDatabase()
MiiDatabase.add(file, mii("Good", 0x80000001L))
val db = file.readBytes()
// A second slot the PC could not read either: favourite colour 15.
MiiData.serialize(mii("Bad", 0x80000002L)).also { it[1] = (15 shl 1).toByte() }.copyInto(db, 4 + MiiData.SIZE)
file.writeBytes(db)
assertEquals(listOf("Good"), MiiDatabase.miis(file).map { it.name })
}
}
@@ -0,0 +1,67 @@
package org.wiicompiled.quest.launcher
import java.io.File
import java.nio.file.Files
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** New Miis get their IDs as on the PC, from the console identity the runtime uses. */
class MiiIdsTest {
/** A NAND with setting.txt as RuntimeNandSettings::EncodeNew writes it. */
private fun nand(serial: String): File {
val root = Files.createTempDirectory("nand").toFile()
val text = "AREA=EUR\r\nMODEL=RVL-001(EUR)\r\nDVD=0\r\nMPCH=0x7FFE\r\nCODE=LEH\r\nSERNO=$serial\r\nVIDEO=PAL\r\nGAME=EU\r\n"
val bytes = ByteArray(256)
var key = 0x73B5DBFA
for ((i, char) in text.withIndex()) {
bytes[i] = (char.code xor (key and 0xFF)).toByte()
key = (key shl 1) or (key ushr 31)
}
File(root, "title/00000001/00000002/data").mkdirs()
File(root, "title/00000001/00000002/data/setting.txt").writeBytes(bytes)
return root
}
@Test
fun readsTheSerialFromSettingTxt() {
val root = nand("123456789")
assertEquals("123456789", MiiIds.consoleSerial(File(root, "title/00000001/00000002/data/setting.txt")))
}
@Test
fun derivesTheMacAsTheRuntimeDoes() {
// FNV-1a of the serial's digits, below Nintendo's 00:09:BF.
assertArrayEquals(bytes(0x00, 0x09, 0xBF, 0x86, 0xB1, 0x1C), MiiIds.consoleMac(nand("123456789")))
assertArrayEquals(bytes(0x00, 0x09, 0xBF, 0x45, 0x62, 0x31), MiiIds.consoleMac(nand("000000042")))
}
@Test
fun hasNoMacBeforeTheGameMadeOne() {
assertNull(MiiIds.consoleMac(Files.createTempDirectory("nand").toFile()))
// An all-zero serial is not an identity.
assertNull(MiiIds.consoleMac(nand("000000000")))
}
@Test
fun systemIdSumsTheMacVendorBytes() {
assertEquals(0xC886B11CL, MiiIds.systemId(bytes(0x00, 0x09, 0xBF, 0x86, 0xB1, 0x1C)))
// The address the PC gives imported Miis.
assertEquals(0x24111111L, MiiIds.systemId(MiiIds.IMPORT_MAC))
}
@Test
fun newIdsCountFourSecondTicksFrom2006() {
val now = MiiData.EPOCH_2006_MILLIS + 4_000L * 1_000_000L + 1_500L
val first = MiiIds.newMiiId(now)
assertEquals(0b100L, first ushr 29)
assertEquals(1_000_000L, first and 0x1FFFFFFF)
// Two Miis in the same tick still get different IDs.
assertEquals(1_000_001L, MiiIds.newMiiId(now) and 0x1FFFFFFF)
assertEquals(2_000_000L, MiiIds.newMiiId(now + 4_000L * 1_000_000L) and 0x1FFFFFFF)
}
private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() }
}
@@ -0,0 +1,152 @@
package org.wiicompiled.quest.launcher
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.util.zip.Deflater
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The renderer on a made-up parts file in FFL's format: the real one is Nintendo's and never in
* this repository. Its only faceline is a square facing the camera, and its only texture a white
* eye, which is enough to follow a Mii from the file to a picture.
*/
class MiiRendererTest {
private val textureCounts = intArrayOf(3, 132, 80, 28, 12, 12, 20, 2, 52, 6, 18)
private val shapeCounts = intArrayOf(4, 132, 132, 12, 1, 12, 18, 18, 132, 132, 132, 132)
/** A big-endian FFRA file with [shapes] and [textures] (type to index to payload), zlib-compressed. */
private fun archive(shapes: Map<Pair<Int, Int>, ByteArray>, textures: Map<Pair<Int, Int>, ByteArray>): ByteArray {
val tableSize = 0x14 + textureCounts.size * 4 + textureCounts.sum() * 16 + shapeCounts.size * 4 + shapeCounts.sum() * 16
val header = ByteBuffer.allocate(maxOf(tableSize, 0x4A00))
header.putInt(0, 0x46465241)
header.putInt(4, 0x00070000)
// AFL 2.3's expanded size, which picks its part counts.
header.putInt(12, 0x02502DE0)
val payload = ByteArrayOutputStream()
fun table(start: Int, counts: IntArray, parts: Map<Pair<Int, Int>, ByteArray>) {
var offset = start
for ((type, count) in counts.withIndex()) {
for (index in 0 until count) {
parts[type to index]?.let { data ->
val compressed = deflate(data)
header.putInt(offset, header.capacity() + payload.size())
header.putInt(offset + 4, data.size)
header.putInt(offset + 8, compressed.size)
header.put(offset + 13, 5)
payload.write(compressed)
}
offset += 16
}
}
}
val textureTable = 0x14 + textureCounts.size * 4
table(textureTable, textureCounts, textures)
table(textureTable + textureCounts.sum() * 16 + shapeCounts.size * 4, shapeCounts, shapes)
return header.array() + payload.toByteArray()
}
private fun deflate(data: ByteArray): ByteArray {
val deflater = Deflater()
deflater.setInput(data)
deflater.finish()
val out = ByteArrayOutputStream()
val buffer = ByteArray(4096)
while (!deflater.finished()) out.write(buffer, 0, deflater.deflate(buffer))
deflater.end()
return out.toByteArray()
}
/** A shape: a square of side [size] around (0, [centreY]), facing +Z, with its three anchors. */
private fun square(size: Float, centreY: Float): ByteArray {
val half = size / 2
val positions = listOf(-half to half, half to half, -half to -half, half to -half)
val buffer = ByteBuffer.allocate(0x100 + 4 * 16 + 6 * 2)
val positionAt = 0x100
val indexAt = positionAt + 4 * 16
buffer.putInt(0, positionAt)
buffer.putInt(20, indexAt)
buffer.putInt(24, 4 * 16)
buffer.putInt(44, 6)
// The faceline's hair, nose and beard anchors.
for (anchor in 0 until 3) buffer.putFloat(0x48 + anchor * 12 + 4, centreY)
for ((i, position) in positions.withIndex()) {
buffer.putFloat(positionAt + i * 16, position.first)
buffer.putFloat(positionAt + i * 16 + 4, centreY + position.second)
buffer.putFloat(positionAt + i * 16 + 8, 0f)
}
// Counter-clockwise on screen, so back-face culling keeps them.
for ((i, index) in listOf(0, 2, 1, 1, 2, 3).withIndex()) buffer.putShort(indexAt + i * 2, index.toShort())
return buffer.array()
}
/** A texture part: [width] x [height] RGBA8 pixels of [value], then FFL's 12-byte footer. */
private fun texture(width: Int, height: Int, value: Int): ByteArray {
val buffer = ByteBuffer.allocate(width * height * 4 + 12)
for (i in 0 until width * height * 4) buffer.put(i, value.toByte())
val footer = width * height * 4
buffer.putShort(footer + 4, width.toShort())
buffer.putShort(footer + 6, height.toShort())
buffer.put(footer + 8, 1)
buffer.put(footer + 9, 2)
return buffer.array()
}
private fun resource() = FflResource.parse(
archive(
shapes = mapOf((FflResource.SHAPE_FACELINE to 5) to square(20f, 34.32143f)),
textures = mapOf((FflResource.TEXTURE_EYE to 2) to texture(16, 8, 255)),
),
)
@Test
fun drawsTheFacelineInTheSkinColour() {
val mii = MiiFactory.male("Square").apply {
faceShape = 5
skinColor = 0
}
val pixels = MiiRenderer.render(resource(), mii, 64)
assertEquals(64 * 64, pixels.size)
// Nothing around the head...
assertEquals(0, pixels[0] ushr 24)
assertEquals(0, pixels[64 * 64 - 1] ushr 24)
// ...and the lit, opaque skin colour in its middle.
val middle = pixels[32 * 64 + 32]
assertEquals(0xFF, middle ushr 24)
val red = (middle shr 16) and 0xFF
val blue = middle and 0xFF
assertTrue("skin is warm: $red vs $blue", red > blue)
// The face view shows about 108 units at the head, so the 20-unit square is about 12 pixels wide.
val covered = pixels.count { it ushr 24 == 0xFF }
assertTrue("covered $covered", covered in 100..200)
}
@Test
fun aMiiWithoutItsFacelineCannotBeDrawn() {
val mii = MiiFactory.male("Missing").apply { faceShape = 1 }
assertThrows(java.io.IOException::class.java) { MiiRenderer.render(resource(), mii, 64) }
}
@Test
fun drawsAPartIconFromItsTexture() {
val mii = MiiFactory.male("Icons")
val icon = MiiRenderer.partIcon(resource(), mii, MiiRenderer.Part.Eye, 2, 32)
assertNotNull(icon)
// The 16x8 eye is fitted across the icon and centred.
assertEquals(0xFF, icon!![16 * 32 + 16] ushr 24)
assertEquals(0, icon[1 * 32 + 16] ushr 24)
// A part that is not in the file is no picture at all.
assertNull(MiiRenderer.partIcon(resource(), mii, MiiRenderer.Part.Eye, 3, 32))
}
@Test
fun refusesFilesThatAreNotFflResources() {
assertThrows(java.io.IOException::class.java) { FflResource.parse(ByteArray(0x4A00)) }
assertThrows(java.io.IOException::class.java) { FflResource.parse(ByteArray(16)) }
}
}
+49 -5
View File
@@ -230,14 +230,49 @@ knows the profile, as the PC's `RRratingReader` does. Retro WFC's public API giv
rest: a licence is Online (card glow) while its friend code is in a room rest: a licence is Online (card glow) while its friend code is in a room
(`/api/roomstatus`, asked again every 30 s while the page is shown), its VR history (`/api/roomstatus`, asked again every 30 s while the page is shown), its VR history
comes from `/api/leaderboard/player/<fc>/history?days=N`, and its Mii picture is the comes from `/api/leaderboard/player/<fc>/history?days=N`, and its Mii picture is the
64-pixel PNG of `/api/leaderboard/player/<fc>`, kept on disk for the sidebar. The 64-pixel PNG of `/api/leaderboard/player/<fc>`, kept on disk for the sidebar. It
headset has no Mii database (`RFL_DB.dat`), so this picture stands in for the PC's stands in for the PC's rendered Mii because a licence's Mii is rarely in the
rendered Mii, and a licence never taken online shows a silhouette. Badges come from headset's Mii database, which only exists once My Miis has created it; a licence
WheelWizard's `badges.json`. It only reads: Rename and change Mii need that database, never taken online shows a silhouette. Badges come from WheelWizard's
and the PC's region picker is gone since the Quest runs PAL only. The card and the `badges.json`. It only reads, without the PC's Rename and change Mii, and the PC's
region picker is gone since the Quest runs PAL only. The card and the
carousel sit side by side on the wide panel. `RksysProfilesTest` and `RetroWfcTest` carousel sit side by side on the wide panel. `RksysProfilesTest` and `RetroWfcTest`
cover the parsing. cover the parsing.
**My Miis**, after Patches, is the PC's MiiListPage with its Mii editor
(`MiisPage`, `MiiEditor`). It lists and changes the Wii's Mii database in the
game's own NAND, `shared2/menu/FaceLib/RFL_DB.dat` (`MiiDatabase`, the PC's
`MiiRepositoryService` and `MiiDbService`), which the game reads, so a Mii made here
can be picked for a new licence. The Quest's NAND starts without one, so the page
creates it empty the first time, as the PC does, with one difference: a database
the Wii formatted links its 10,000 hidden-Mii entries to nothing (`0x7FFF`), and
the PC leaves those links zero, so the headset writes the Wii's (checked byte for
byte against Dolphin's database). A Mii is the 74-byte block of the PC's
`MiiSerializer` (`MiiData`). A Mii made or duplicated here gets a new ID and this
console's system ID, from the MAC address the runtime derives from `setting.txt`'s
serial (`RuntimeConsoleIdentity::FromSerial`), so making one needs the game to have
started once. An imported `.mii` gets the PC's import address and, like any Mii
from another console, the PC's globe. The PC selects more Miis with Shift or Ctrl;
here a long press adds or removes one. Export saves one `.mii` through the save
dialog, several into a chosen folder. Changes are refused while the game runs.
The pictures come from a Kotlin port of the PC's software renderer (`MiiRenderer`,
`FflResource`), without the PC's body, whose 3DS models are not ours to ship, so
the head is drawn alone, as the Wii's own Mii icons show it. It draws from FFL's
Mii parts (`FFLResHigh.dat`), which are Nintendo's and never in the APK: like the
PC, the page downloads them once from the Internet Archive's copy of Miitomo's
`AFLResHigh_2_3.dat` (a 4.4 MB zip) and checks them against their SHA-256
(`MiiRenderResource`). Compared with the PC's C# renderer on 257 Miis covering
every part and colour, 283 of 299 pictures were identical and the rest differed by
one colour level in at most four pixels, except for one deliberate fix: the PC
colours a beard with the hair colour, here with the facial hair colour, as the Wii
does. The editor's choices are drawn from the same parts, the flat parts from their
textures in the Mii's colours and hairstyles, head shapes and beards as the Mii's
head, where the PC shows icons of its own. On a Quest 3 the 424-pixel face in the
editor takes about 150 ms, drawn in four bands of rows in parallel, and a choice
about 40 ms. `MiiDataTest`, `MiiDatabaseTest`, `MiiIdsTest` and `MiiRendererTest`
(on a made-up parts file) cover it.
The launcher follows the runtime's rules exactly. `TomlConfig` edits one line The launcher follows the runtime's rules exactly. `TomlConfig` edits one line
the way `RuntimeConfigFile::WriteSetting` does, and every edit re-reads the file, the way `RuntimeConfigFile::WriteSetting` does, and every edit re-reads the file,
so values the in-headset panel wrote are kept. Each row reads its key with the so values the in-headset panel wrote are kept. Each row reads its key with the
@@ -716,6 +751,15 @@ name); `adb logcat -s WiiCompiledLauncher` shows the searches and the install:
adb shell am start -n org.wiicompiled.quest/.launcher.LauncherActivity --ei org.wiicompiled.quest.debug.MOD_BROWSER 699980 --ez org.wiicompiled.quest.debug.INSTALL_MOD true adb shell am start -n org.wiicompiled.quest/.launcher.LauncherActivity --ei org.wiicompiled.quest.debug.MOD_BROWSER 699980 --ez org.wiicompiled.quest.debug.INSTALL_MOD true
``` ```
`MIIS` opens My Miis, and `edit:N` or `edit:N:<Page>` the Mii editor on its Nth Mii
and one of its pages. The launcher panel takes `adb shell input tap x y` in its
1280x800 pixels, and `adb shell uiautomator dump` gives its views' bounds, so a
test can go on from there with nobody wearing the headset:
```powershell
adb shell am start -n org.wiicompiled.quest/.launcher.LauncherActivity --es org.wiicompiled.quest.debug.MIIS edit:0:Hair
```
Performance, measured 2026-09-16 on a 50cc Luigi Circuit start with the player Performance, measured 2026-09-16 on a 50cc Luigi Circuit start with the player
idle, over 40 s, with an optimized build (`-O3`, translated code `-O2`, idle, over 40 s, with an optimized build (`-O3`, translated code `-O2`,
`-mcpu=cortex-a77`): `-mcpu=cortex-a77`):