From e2a898eab77f23340a41a48771f2ec64412de808 Mon Sep 17 00:00:00 2001 From: iChris4 Date: Thu, 24 Sep 2026 03:41:39 +0200 Subject: [PATCH] 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. --- .../wiicompiled/quest/launcher/FflResource.kt | 363 +++++ .../quest/launcher/LauncherActivity.kt | 70 +- .../org/wiicompiled/quest/launcher/Mii.kt | 384 +++++ .../wiicompiled/quest/launcher/MiiDatabase.kt | 216 +++ .../wiicompiled/quest/launcher/MiiEditor.kt | 675 +++++++++ .../wiicompiled/quest/launcher/MiiImages.kt | 106 ++ .../quest/launcher/MiiRenderResource.kt | 171 +++ .../wiicompiled/quest/launcher/MiiRenderer.kt | 1301 +++++++++++++++++ .../wiicompiled/quest/launcher/MiisPage.kt | 506 +++++++ .../quest/launcher/SettingsPage.kt | 1 + .../wiicompiled/quest/launcher/TileGrid.kt | 71 + .../main/res/drawable/bg_button_danger.xml | 28 + .../src/main/res/drawable/bg_mii_choice.xml | 29 + .../main/res/drawable/bg_mii_editor_panel.xml | 6 + .../src/main/res/drawable/bg_mii_preview.xml | 6 + .../app/src/main/res/drawable/bg_mii_tile.xml | 39 + .../src/main/res/drawable/ic_arrow_down.xml | 13 + .../app/src/main/res/drawable/ic_arrow_up.xml | 13 + .../src/main/res/drawable/ic_duplicate.xml | 11 + .../src/main/res/drawable/ic_file_export.xml | 13 + .../app/src/main/res/drawable/ic_globe.xml | 11 + .../app/src/main/res/drawable/ic_minus.xml | 13 + android/app/src/main/res/drawable/ic_pen.xml | 11 + android/app/src/main/res/drawable/ic_plus.xml | 13 + .../src/main/res/drawable/ic_rotate_left.xml | 11 + .../src/main/res/drawable/ic_rotate_right.xml | 11 + .../app/src/main/res/drawable/ic_shirt.xml | 13 + .../src/main/res/drawable/ic_star_empty.xml | 13 + .../app/src/main/res/drawable/ic_trash.xml | 13 + .../app/src/main/res/drawable/ic_x_mark.xml | 13 + .../src/main/res/layout/activity_launcher.xml | 19 + android/app/src/main/res/layout/item_mii.xml | 81 + .../src/main/res/layout/page_mii_editor.xml | 214 +++ android/app/src/main/res/layout/page_miis.xml | 198 +++ android/app/src/main/res/values/colors.xml | 2 + android/app/src/main/res/values/ids.xml | 2 + android/app/src/main/res/values/strings.xml | 115 ++ android/app/src/main/res/values/themes.xml | 28 + .../wiicompiled/quest/launcher/MiiDataTest.kt | 145 ++ .../quest/launcher/MiiDatabaseTest.kt | 118 ++ .../wiicompiled/quest/launcher/MiiIdsTest.kt | 67 + .../quest/launcher/MiiRendererTest.kt | 152 ++ docs/quest-port.md | 54 +- 43 files changed, 5331 insertions(+), 8 deletions(-) create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/FflResource.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/Mii.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/MiiDatabase.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/MiiEditor.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/MiiImages.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/MiiRenderResource.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/MiiRenderer.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/MiisPage.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/TileGrid.kt create mode 100644 android/app/src/main/res/drawable/bg_button_danger.xml create mode 100644 android/app/src/main/res/drawable/bg_mii_choice.xml create mode 100644 android/app/src/main/res/drawable/bg_mii_editor_panel.xml create mode 100644 android/app/src/main/res/drawable/bg_mii_preview.xml create mode 100644 android/app/src/main/res/drawable/bg_mii_tile.xml create mode 100644 android/app/src/main/res/drawable/ic_arrow_down.xml create mode 100644 android/app/src/main/res/drawable/ic_arrow_up.xml create mode 100644 android/app/src/main/res/drawable/ic_duplicate.xml create mode 100644 android/app/src/main/res/drawable/ic_file_export.xml create mode 100644 android/app/src/main/res/drawable/ic_globe.xml create mode 100644 android/app/src/main/res/drawable/ic_minus.xml create mode 100644 android/app/src/main/res/drawable/ic_pen.xml create mode 100644 android/app/src/main/res/drawable/ic_plus.xml create mode 100644 android/app/src/main/res/drawable/ic_rotate_left.xml create mode 100644 android/app/src/main/res/drawable/ic_rotate_right.xml create mode 100644 android/app/src/main/res/drawable/ic_shirt.xml create mode 100644 android/app/src/main/res/drawable/ic_star_empty.xml create mode 100644 android/app/src/main/res/drawable/ic_trash.xml create mode 100644 android/app/src/main/res/drawable/ic_x_mark.xml create mode 100644 android/app/src/main/res/layout/item_mii.xml create mode 100644 android/app/src/main/res/layout/page_mii_editor.xml create mode 100644 android/app/src/main/res/layout/page_miis.xml create mode 100644 android/app/src/test/java/org/wiicompiled/quest/launcher/MiiDataTest.kt create mode 100644 android/app/src/test/java/org/wiicompiled/quest/launcher/MiiDatabaseTest.kt create mode 100644 android/app/src/test/java/org/wiicompiled/quest/launcher/MiiIdsTest.kt create mode 100644 android/app/src/test/java/org/wiicompiled/quest/launcher/MiiRendererTest.kt diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/FflResource.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/FflResource.kt new file mode 100644 index 0000000..ba27da2 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/FflResource.kt @@ -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>, + private val shapeParts: Array>, + /** 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() + private val textures = HashMap() + + /** 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>, 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? = 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> { + 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?, +) { + val vertexCount get() = positions.size / 3 +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt index bb9ad3e..7a246a8 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt @@ -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), * 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 - * 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, * 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() { - 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. */ 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 navProfiles: View private lateinit var navPatches: View + private lateinit var navMiis: View private lateinit var navSettings: View private lateinit var homePage: View private lateinit var profilesView: View private lateinit var patchesView: View + private lateinit var miisView: View private lateinit var settingsView: View private lateinit var trails: WheelTrailsView private lateinit var playButton: View @@ -71,6 +74,7 @@ class LauncherActivity : Activity() { private lateinit var gameToggle: LinearLayout private lateinit var settings: SettingsPage private lateinit var patches: PatchesPage + private lateinit var miis: MiisPage private lateinit var profilesPage: ProfilesPage private lateinit var sidebarProfile: SidebarProfileCard @@ -108,10 +112,12 @@ class LauncherActivity : Activity() { navHome = findViewById(R.id.nav_home) navProfiles = findViewById(R.id.nav_profiles) navPatches = findViewById(R.id.nav_patches) + navMiis = findViewById(R.id.nav_miis) navSettings = findViewById(R.id.nav_settings) homePage = findViewById(R.id.page_home) profilesView = findViewById(R.id.page_profiles) patchesView = findViewById(R.id.page_patches) + miisView = findViewById(R.id.page_miis) settingsView = findViewById(R.id.page_settings) trails = findViewById(R.id.home_trails) playButton = findViewById(R.id.home_play) @@ -147,6 +153,13 @@ class LauncherActivity : Activity() { patches = PatchesPage(this, patchesView) { 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) } profilesPage = ProfilesPage(this, profilesView) { snapshot -> sidebarProfile.show(snapshot) } savedInstanceState?.getString(KEY_TAB)?.let { name -> @@ -156,6 +169,7 @@ class LauncherActivity : Activity() { navHome.setOnClickListener { showPage(Page.Home) } navProfiles.setOnClickListener { showPage(Page.Profiles) } navPatches.setOnClickListener { showPage(Page.Patches) } + navMiis.setOnClickListener { showPage(Page.Miis) } navSettings.setOnClickListener { showPage(Page.Settings) } playButton.setOnClickListener { perform(mainAction) } secondary.setOnClickListener { secondaryAction?.let(::perform) } @@ -179,12 +193,21 @@ class LauncherActivity : Activity() { showPage(Page.Patches) 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") 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.Miis && miis.back()) return @Suppress("DEPRECATION") super.onBackPressed() } @@ -235,6 +258,17 @@ class LauncherActivity : Activity() { patches.importPicked(uris) 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 task = when (requestCode) { REQUEST_DISC_IMAGE -> GameSetup.Task.ExtractDisc @@ -251,10 +285,12 @@ class LauncherActivity : Activity() { navHome.isSelected = target == Page.Home navProfiles.isSelected = target == Page.Profiles navPatches.isSelected = target == Page.Patches + navMiis.isSelected = target == Page.Miis navSettings.isSelected = target == Page.Settings homePage.visibility = if (target == Page.Home) 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 + miisView.visibility = if (target == Page.Miis) View.VISIBLE else View.GONE profilesPage.setVisible(target == Page.Profiles) settingsView.visibility = if (target == Page.Settings) View.VISIBLE else View.GONE if (target == Page.Home) { @@ -268,6 +304,7 @@ class LauncherActivity : Activity() { Page.Home -> refreshHome() Page.Profiles -> profilesPage.refresh() Page.Patches -> patches.refresh() + Page.Miis -> miis.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 * 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_GAME_PACKAGE = 2 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 KEY_LAST_DROPPED_IMPORT = "lastDroppedImport" 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" /** 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" + /** 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" } } diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/Mii.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/Mii.kt new file mode 100644 index 0000000..3d2116a --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/Mii.kt @@ -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 + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiDatabase.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiDatabase.kt new file mode 100644 index 0000000..6347a76 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiDatabase.kt @@ -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 = slots(file.readBytes()) + + fun slots(db: ByteArray): List = (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 = 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 } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiEditor.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiEditor.kt new file mode 100644 index 0000000..11fce1a --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiEditor.kt @@ -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(R.id.mii_editor_randomize).setOnClickListener { + mii = MiiFactory.randomLook(mii) + changed() + } + root.findViewById(R.id.mii_editor_cancel).setOnClickListener { close() } + root.findViewById(R.id.mii_editor_save).setOnClickListener { + val edited = mii.copy() + close() + onSave(edited) + } + root.findViewById(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, 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, 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)) + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiImages.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiImages.kt new file mode 100644 index 0000000..3ed408f --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiImages.kt @@ -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((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 + } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiRenderResource.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiRenderResource.kt new file mode 100644 index 0000000..ad35bcd --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiRenderResource.kt @@ -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) } + } + } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiRenderer.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiRenderer.kt new file mode 100644 index 0000000..df262b6 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiRenderer.kt @@ -0,0 +1,1301 @@ +package org.wiicompiled.quest.launcher + +import java.io.IOException +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.cos +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.round +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.math.tan + +/** + * Draws a Mii's head from [FflResource] on the CPU: the PC launcher's NativeMiiRenderer ported + * to Kotlin, with the same parts, face and mask textures, colours, camera, lighting and + * rasteriser, so a Mii looks the same in both launchers. + * + * Only the PC's body is left out. Its 3DS body models are not ours to ship, so every picture here + * is the PC's face_only framing: the head, as the Wii's own Mii icons show it. One deliberate fix: + * the PC colours a beard with the hair colour; here it has its facial hair colour, as on the Wii. + */ +object MiiRenderer { + /** The head at [size] x [size], as non-premultiplied ARGB colours, transparent around it. */ + @Throws(IOException::class) + fun render(resource: FflResource, mii: Mii, size: Int): IntArray = render(resource, FflCharInfo.of(mii), size) + + @Throws(IOException::class) + internal fun render(resource: FflResource, info: FflCharInfo, size: Int, expression: Int = 0): IntArray { + require(size in 16..4096 && size % 2 == 0) { "Unsupported picture size $size" } + val resolution = if (size <= 384) 256 else 512 + val draws = buildDraws(resource, info, resolution, expression) + if (draws.isEmpty()) throw IOException("The renderer produced no drawable meshes for this Mii.") + + val target = Target(size, size, withDepth = true) + target.fill(255, 255, 255, 0) + // The PC's face view: 15 degrees of field of view on the head, from straight ahead. + val y = 4.805f / 0.14f + val z = 57.553f / 0.14f + val view = Mat4.lookAt(floatArrayOf(-0f, y, z), floatArrayOf(0f, y, 0f), floatArrayOf(0f, 1f, 0f)) + val projection = Mat4.perspective(15f * (Math.PI.toFloat() / 180f), 1f, 10f, 1200f) + val model = Mat4.IDENTITY + val meshes = draws.mapNotNull { prepare(it, size, size, model, view, projection) } + drawAll(target, meshes, light = true, Blend.Over) + return target.argb() + } + + /** + * Draws [meshes] in order. A large picture is split into bands of rows drawn in parallel, + * each band drawing every mesh in order, so the picture is the same as one pass. + */ + private fun drawAll(target: Target, meshes: List, light: Boolean, blend: Blend) { + val height = target.height + val bands = if (height >= PARALLEL_SIZE) BANDS else 1 + if (bands == 1) { + for (mesh in meshes) rasterize(target, mesh, light, blend, 0, height) + return + } + val rows = (height + bands - 1) / bands + val tasks = (0 until bands).map { band -> + java.util.concurrent.Callable { + for (mesh in meshes) rasterize(target, mesh, light, blend, band * rows, min(height, (band + 1) * rows)) + } + } + for (future in bandWorkers.invokeAll(tasks)) { + try { + future.get() + } catch (e: java.util.concurrent.ExecutionException) { + throw e.cause ?: e + } + } + } + + private const val PARALLEL_SIZE = 256 + private val BANDS = Runtime.getRuntime().availableProcessors().coerceIn(1, 4) + private val bandWorkers by lazy { + java.util.concurrent.Executors.newFixedThreadPool(BANDS) { runnable -> Thread(runnable, "MiiRaster").apply { isDaemon = true } } + } + + /** The face parts the editor pictures from their textures rather than as a head. */ + enum class Part { Eyebrow, Eye, Nose, Mouth, Glasses, Mustache } + + /** + * One choice of a face part, drawn alone from its texture in the Mii's colours as the face + * would show it, [size] x [size] and transparent around it: the pictures on the editor's + * choice buttons, which the PC draws from icons of its own. Null for a choice that is no part + * at all, such as no glasses. + */ + @Throws(IOException::class) + fun partIcon(resource: FflResource, mii: Mii, part: Part, index: Int, size: Int): IntArray? { + val info = FflCharInfo.of( + when (part) { + Part.Eyebrow -> mii.copy(eyebrowType = index) + Part.Eye -> mii.copy(eyeType = index) + Part.Nose -> mii.copy(noseType = index) + Part.Mouth -> mii.copy(lipType = index) + Part.Glasses -> mii.copy(glassesType = index) + Part.Mustache -> mii.copy(mustacheType = index) + }, + ) + // The texture, how it is coloured, whether its other half is its mirror image, and the + // part of it worth showing (noses sit small in the middle of theirs). + val texture: FflTexture + val modulate: Modulate + var mirrored = false + var window = 0f + when (part) { + Part.Eyebrow -> { + texture = resource.texture(FflResource.TEXTURE_EYEBROW, info.eyebrowType) ?: return null + modulate = Modulate(3, TYPE_MASK, r = Colors.hair(info.eyebrowColor), texture = texture) + } + Part.Eye -> { + texture = resource.texture(FflResource.TEXTURE_EYE, info.eyeType) ?: return null + modulate = eyeModulate(info, info.eyeType, texture) + } + Part.Nose -> { + texture = resource.texture(FflResource.TEXTURE_NOSELINE, info.noseType) ?: return null + modulate = Modulate(3, TYPE_NOSELINE, r = BLACK, texture = texture) + window = 0.25f + } + Part.Mouth -> { + texture = resource.texture(FflResource.TEXTURE_MOUTH, info.mouthType) ?: return null + modulate = if (info.mouthType > 36) { + Modulate(1, TYPE_MASK, texture = texture) + } else { + Modulate(2, TYPE_MASK, r = Colors.mouthR(info.mouthColor), g = Colors.mouthG(info.mouthColor), b = WHITE, texture = texture) + } + } + Part.Glasses -> { + texture = resource.texture(FflResource.TEXTURE_GLASS, info.glassType) ?: return null + modulate = Modulate(4, TYPE_GLASS, r = Colors.glass(info.glassColor), texture = texture) + mirrored = true + } + Part.Mustache -> { + texture = resource.texture(FflResource.TEXTURE_MUSTACHE, info.mustacheType) ?: return null + modulate = Modulate(3, TYPE_MASK, r = Colors.hair(info.beardColor), texture = texture) + mirrored = true + } + } + // "None" is an 8x8 blank. + if (texture.width <= 8 && texture.height <= 8) return null + + val span = 1f - 2f * window + val sourceWidth = texture.width * span * (if (mirrored) 2 else 1) + val sourceHeight = texture.height * span + val scale = size * 0.9f / max(sourceWidth, sourceHeight) + val width = sourceWidth * scale + val height = sourceHeight * scale + val left = (size - width) / 2f + val top = (size - height) / 2f + val out = IntArray(size * size) + val sample = FloatArray(4) + val at = FloatArray(STRIDE) + val scratch = Scratch() + for (y in 0 until size) { + for (x in 0 until size) { + // 2x2 samples per pixel, averaged with their alpha. + var r = 0f + var g = 0f + var b = 0f + var a = 0f + for (sy in 0 until 2) { + for (sx in 0 until 2) { + val fx = (x + 0.25f + sx * 0.5f - left) / width + val fy = (y + 0.25f + sy * 0.5f - top) / height + if (fx < 0f || fx >= 1f || fy < 0f || fy >= 1f) continue + at[4] = window + fx * span * (if (mirrored) 2 else 1) + at[5] = window + fy * span + if (modulate(modulate, at[4], at[5], sample, scratch)) clampColor(sample) + r += sample[0] * sample[3] + g += sample[1] * sample[3] + b += sample[2] * sample[3] + a += sample[3] + } + } + if (a <= 0f) continue + val alpha = a / 4f + out[y * size + x] = (toByte(alpha) shl 24) or (toByte(r / a) shl 16) or (toByte(g / a) shl 8) or toByte(b / a) + } + } + return out + } + + // --- Building the head (BuildManagedDrawParams) --- + + private fun buildDraws(resource: FflResource, info: FflCharInfo, resolution: Int, expression: Int): List { + val draws = ArrayList(12) + val faceline = resource.shape(FflResource.SHAPE_FACELINE, info.faceType) + ?: throw IOException("Faceline shape ${info.faceType} is missing.") + val zero = FloatArray(3) + val hairPos = faceline.translates?.get(0) ?: zero + val faceCenter = faceline.translates?.get(1) ?: zero + val beardPos = faceline.translates?.get(2) ?: zero + + val facelineTexture = facelineTexture(resource, info, resolution) + val maskTexture = maskTexture(resource, info, resolution, expression) + val skin = Colors.faceline(info.facelineColor) + + draws += draw( + faceline, 1f, 1f, null, false, CULL_BACK, + if (facelineTexture == null) Modulate(0, TYPE_FACELINE, r = skin) else Modulate(1, TYPE_FACELINE, texture = facelineTexture), + ) + + val hairColor = Colors.hair(info.hairColor) + val hairFlip = info.hairDir > 0 + val hairCull = if (hairFlip) CULL_FRONT else CULL_BACK + resource.shape(FflResource.SHAPE_HAIR, info.hairType)?.let { + draws += draw(it, 1f, 1f, hairPos, hairFlip, hairCull, Modulate(0, TYPE_HAIR, r = hairColor)) + } + resource.shape(FflResource.SHAPE_FOREHEAD, info.hairType)?.let { + draws += draw(it, 1f, 1f, hairPos, hairFlip, hairCull, Modulate(0, TYPE_FOREHEAD, r = skin)) + } + resource.texture(FflResource.TEXTURE_CAP, info.hairType)?.let { cap -> + resource.shape(FflResource.SHAPE_HAT, info.hairType)?.let { + draws += draw(it, 1f, 1f, hairPos, hairFlip, hairCull, Modulate(5, TYPE_CAP, r = Colors.favorite(info.favoriteColor), texture = cap)) + } + } + if (info.beardType in 0 until 4) { + resource.shape(FflResource.SHAPE_BEARD, info.beardType)?.let { + draws += draw(it, 1f, 1f, beardPos, false, CULL_BACK, Modulate(0, TYPE_BEARD, r = Colors.hair(info.beardColor))) + } + } + + if (expression !in NO_NOSE_EXPRESSIONS) { + val noseScale = info.noseScale * 0.175f + 0.4f + val nosePos = floatArrayOf(faceCenter[0], faceCenter[1] + (info.nosePositionY - 8) * -1.5f, faceCenter[2]) + resource.shape(FflResource.SHAPE_NOSE, info.noseType)?.let { + draws += draw(it, noseScale, noseScale, nosePos, false, CULL_BACK, Modulate(0, TYPE_NOSE, r = skin)) + } + resource.texture(FflResource.TEXTURE_NOSELINE, info.noseType)?.let { line -> + resource.shape(FflResource.SHAPE_NOSELINE, info.noseType)?.let { + draws += draw(it, noseScale, noseScale, nosePos, false, CULL_BACK, Modulate(3, TYPE_NOSELINE, r = BLACK, texture = line)) + } + } + if (maskTexture != null) { + resource.shape(FflResource.SHAPE_MASK, info.faceType)?.let { + val cull = if (resource.linearTextures) CULL_NONE else CULL_BACK + draws += draw(it, 1f, 1f, null, false, cull, Modulate(1, TYPE_MASK, texture = maskTexture)) + } + } + } + + if (info.glassType > 0) { + resource.texture(FflResource.TEXTURE_GLASS, info.glassType)?.let { glass -> + val scale = info.glassScale * (if (resource.linearTextures) 0.175f else 0.15f) + 0.4f + val position = floatArrayOf( + faceCenter[0], + faceCenter[1] + (info.glassPositionY - 11) * -1.5f + 5.0f, + faceCenter[2] + 2.0f, + ) + resource.shape(FflResource.SHAPE_GLASS, 0)?.let { + draws += draw(it, scale, scale, position, false, CULL_NONE, Modulate(4, TYPE_GLASS, r = Colors.glass(info.glassColor), texture = glass)) + } + } + } + return draws + } + + /** Wrinkles, make-up and textured beards on the skin colour (BuildManagedFacelineTexture). */ + private fun facelineTexture(resource: FflResource, info: FflCharInfo, resolution: Int): FflTexture? { + if (info.faceLine == 0 && info.faceMakeup == 0 && info.beardType < 4) return null + val overlays = ArrayList(3) + if (info.faceMakeup > 0) { + resource.texture(FflResource.TEXTURE_MAKEUP, info.faceMakeup)?.let { + overlays += fullScreen(Modulate(1, TYPE_FACELINE, texture = it)) + } + } + if (info.faceLine > 0) { + resource.texture(FflResource.TEXTURE_FACELINE, info.faceLine)?.let { + overlays += fullScreen(Modulate(3, TYPE_FACELINE, r = BLACK, texture = it)) + } + } + if (info.beardType >= 4) { + resource.texture(FflResource.TEXTURE_BEARD, info.beardType - 3)?.let { + overlays += fullScreen(Modulate(3, TYPE_FACELINE, r = Colors.hair(info.beardColor), texture = it)) + } + } + if (overlays.isEmpty()) return null + return overlayTexture(overlays, max(1, resolution / 2), max(1, resolution), Colors.faceline(info.facelineColor), Blend.Faceline) + } + + /** Eyes, eyebrows, mouth, mustache and mole, placed on FFL's 64-unit face grid (BuildManagedMaskTexture). */ + private fun maskTexture(resource: FflResource, info: FflCharInfo, resolution: Int, expression: Int): FflTexture? { + val element = EXPRESSION_ELEMENTS[expression.coerceIn(0, 18)] + val eyeIndexR = eyeTexture(info, element[0]) + val eyeIndexL = eyeTexture(info, element[1]) + val mouthIndex = mouthTexture(info, element[2]) + val eyebrowIndex = if (element[3] == 0) info.eyebrowType else element[3] + val parts = MaskParts(info) + val overlays = ArrayList(8) + + if (info.mustacheType != 0) { + resource.texture(FflResource.TEXTURE_MUSTACHE, info.mustacheType)?.let { + val modulate = Modulate(3, TYPE_MASK, r = Colors.hair(info.beardColor), texture = it) + overlays += maskQuad(parts.mustacheR, modulate) + overlays += maskQuad(parts.mustacheL, modulate) + } + } + resource.texture(FflResource.TEXTURE_MOUTH, mouthIndex)?.let { + overlays += maskQuad( + parts.mouth, + if (mouthIndex > 36) { + Modulate(1, TYPE_MASK, texture = it) + } else { + Modulate(2, TYPE_MASK, r = Colors.mouthR(info.mouthColor), g = Colors.mouthG(info.mouthColor), b = WHITE, texture = it) + }, + ) + } + if (eyebrowIndex != 23) { + resource.texture(FflResource.TEXTURE_EYEBROW, eyebrowIndex)?.let { + val modulate = Modulate(3, TYPE_MASK, r = Colors.hair(info.eyebrowColor), texture = it) + overlays += maskQuad(parts.eyebrowR, modulate) + overlays += maskQuad(parts.eyebrowL, modulate) + } + } + val eyeR = resource.texture(FflResource.TEXTURE_EYE, eyeIndexR) + val eyeL = resource.texture(FflResource.TEXTURE_EYE, eyeIndexL) + eyeR?.let { overlays += maskQuad(parts.eyeR, eyeModulate(info, eyeIndexR, it)) } + eyeL?.let { overlays += maskQuad(parts.eyeL, eyeModulate(info, eyeIndexL, it)) } + if (info.moleType != 0) { + resource.texture(FflResource.TEXTURE_MOLE, info.moleType)?.let { + overlays += maskQuad(parts.mole, Modulate(3, TYPE_MASK, r = MOLE, texture = it)) + } + } + if (overlays.isEmpty()) return null + val size = max(1, resolution) + return overlayTexture(overlays, size, size, floatArrayOf(0f, 0f, 0f, 0f), Blend.Mask) + } + + private fun eyeModulate(info: FflCharInfo, index: Int, texture: FflTexture) = + if (index in DIRECT_EYES) { + Modulate(1, TYPE_MASK, texture = texture) + } else { + Modulate(2, TYPE_MASK, r = floatArrayOf(0f, 1f, 1f, 1f), g = WHITE, b = Colors.eyeB(info.eyeColor), texture = texture) + } + + private fun eyeTexture(info: FflCharInfo, type: Int) = when (type) { + 1 -> 60 + 3 -> 61 + 4 -> 26 + 5 -> 47 + else -> info.eyeType + } + + private fun mouthTexture(info: FflCharInfo, type: Int) = when (type) { + 1 -> 10 + 2 -> 12 + 3 -> 36 + 5 -> 19 + else -> info.mouthType + } + + private fun overlayTexture(overlays: List, width: Int, height: Int, clear: FloatArray, blend: Blend): FflTexture { + val target = Target(width, height, withDepth = false) + target.fill(toByte(clear[0]), toByte(clear[1]), toByte(clear[2]), toByte(clear[3])) + drawAll(target, overlays.mapNotNull { prepare(it, width, height, Mat4.IDENTITY, Mat4.IDENTITY, Mat4.IDENTITY) }, light = false, blend) + return FflTexture(width, height, FflTexture.RGBA8, target.pixels) + } + + private fun fullScreen(modulate: Modulate): Draw { + val shape = FflShape( + positions = floatArrayOf(-1f, 1f, 0f, 1f, 1f, 0f, -1f, -1f, 0f, 1f, -1f, 0f), + texcoords = floatArrayOf(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f), + normals = FloatArray(12) { if (it % 3 == 2) 1f else 0f }, + tangents = FloatArray(12), + parameters = FloatArray(16) { if (it % 4 == 2) 0f else 1f }, + indices = intArrayOf(0, 1, 2, 2, 1, 3), + translates = null, + ) + return draw(shape, 1f, 1f, null, false, CULL_NONE, modulate) + } + + /** A part's quad on the mask (CreateRawMaskOverlayDrawParam), in the mask's clip space. */ + private fun maskQuad(part: MaskPart, modulate: Modulate): Draw { + val posXAdd = when (part.origin) { + Origin.Center -> -0.5f + Origin.Left -> -1f + Origin.Right -> 0f + } + val tex01 = if (part.origin == Origin.Right) 0f else 1f + val tex23 = if (part.origin == Origin.Right) 1f else 0f + val baseX = floatArrayOf(1f, 1f, 0f, 0f) + val baseY = floatArrayOf(-0.5f, 0.5f, 0.5f, -0.5f) + val uvY = floatArrayOf(0f, 1f, 1f, 0f) + val radians = part.rotation * (Math.PI.toFloat() / 180f) + val cos = cos(radians) + val sin = sin(radians) + val positions = FloatArray(12) + val texcoords = FloatArray(8) + for (i in 0 until 4) { + val lx = baseX[i] + posXAdd + val ly = baseY[i] + val xr = lx * part.scaleX * cos - ly * part.scaleY * sin + val yr = lx * part.scaleX * sin + ly * part.scaleY * cos + val xw = 0.88961464f * xr + part.x + val yw = 0.9276675f * yr + part.y + positions[i * 3] = xw * (2f / 64f) - 1f + positions[i * 3 + 1] = 1f - yw * (2f / 64f) + texcoords[i * 2] = if (i < 2) tex01 else tex23 + texcoords[i * 2 + 1] = uvY[i] + } + val shape = FflShape( + positions, texcoords, + normals = FloatArray(12) { if (it % 3 == 2) 1f else 0f }, + tangents = FloatArray(12), + parameters = FloatArray(16) { if (it % 4 == 2) 0f else 1f }, + indices = intArrayOf(2, 1, 3, 1, 3, 0), + translates = null, + ) + return draw(shape, 1f, 1f, null, false, CULL_NONE, modulate) + } + + /** + * A shape placed on the head (BuildManagedShapeDrawParam). The PC passes normals, tangents and + * vertex parameters through FFL's packed vertex formats, so they are quantised the same way. + */ + private fun draw(shape: FflShape, scaleX: Float, scaleY: Float, translate: FloatArray?, flipX: Boolean, cull: Int, modulate: Modulate): Draw { + val count = shape.vertexCount + if (count == 0 || shape.indices.size < 3) throw IOException("Shape has no drawable geometry.") + val scaleZ = (scaleX + scaleY) * 0.5f + val tx = translate?.get(0) ?: 0f + val ty = translate?.get(1) ?: 0f + val tz = translate?.get(2) ?: 0f + val positions = FloatArray(count * 3) + val normals = FloatArray(count * 3) + val tangents = FloatArray(count * 3) + val parameters = FloatArray(count * 4) + for (i in 0 until count) { + val x = if (flipX) -shape.positions[i * 3] else shape.positions[i * 3] + positions[i * 3] = x * scaleX + tx + positions[i * 3 + 1] = shape.positions[i * 3 + 1] * scaleY + ty + positions[i * 3 + 2] = shape.positions[i * 3 + 2] * scaleZ + tz + + val nx = if (flipX) -shape.normals[i * 3] else shape.normals[i * 3] + val packed = pack10(nx) or (pack10(shape.normals[i * 3 + 1]) shl 10) or (pack10(shape.normals[i * 3 + 2]) shl 20) + FflResource.decodeInt2101010(packed, normals, i * 3) + + val tangentX = if (flipX) -shape.tangents[i * 3] else shape.tangents[i * 3] + FflResource.normalizeInto( + pack8(tangentX) / 127f, pack8(shape.tangents[i * 3 + 1]) / 127f, pack8(shape.tangents[i * 3 + 2]) / 127f, + tangents, i * 3, 1e-8f, 0f, + ) + for (c in 0 until 4) parameters[i * 4 + c] = toByte(shape.parameters[i * 4 + c]) / 255f + } + return Draw(positions, shape.texcoords, normals, tangents, parameters, shape.indices, cull, modulate, MATERIALS[modulate.type]) + } + + private fun pack10(value: Float): Int = round(value.coerceIn(-1f, 1f) * 511f).toInt().coerceIn(-512, 511) and 0x3FF + + private fun pack8(value: Float): Int = round(value.coerceIn(-1f, 1f) * 127f).toInt().coerceIn(-127, 127) + + // --- Rasterising (PrepareMesh, RasterizeTriangle, EvaluateModulateColor, BlendPixel) --- + + private fun prepare(draw: Draw, width: Int, height: Int, model: Mat4, view: Mat4, projection: Mat4): Prepared? { + val count = draw.positions.size / 3 + val modelView = model * view + val out = FloatArray(count * STRIDE) + val world = FloatArray(3) + val eye = FloatArray(3) + val clip = FloatArray(4) + for (i in 0 until count) { + model.transformPoint(draw.positions, i * 3, world, 0) + view.transformPoint(world, 0, eye, 0) + projection.transform4(eye, clip) + if (abs(clip[3]) <= 1e-6f) return null + val invW = 1f / clip[3] + val ndcX = clip[0] * invW + val ndcY = clip[1] * invW + val ndcZ = clip[2] * invW + val o = i * STRIDE + out[o] = (ndcX * 0.5f + 0.5f) * width + out[o + 1] = (1f - (ndcY * 0.5f + 0.5f)) * height + out[o + 2] = ndcZ * 0.5f + 0.5f + out[o + 3] = invW + out[o + 4] = draw.texcoords.getOrElse(i * 2) { 0f } + out[o + 5] = draw.texcoords.getOrElse(i * 2 + 1) { 0f } + out[o + 6] = eye[0] + out[o + 7] = eye[1] + out[o + 8] = eye[2] + modelView.transformNormal(draw.normals, i * 3, out, o + 9) + modelView.transformNormal(draw.tangents, i * 3, out, o + 12) + for (c in 0 until 4) out[o + 15 + c] = draw.parameters[i * 4 + c] + } + return Prepared(draw, out) + } + + /** Draws [mesh]'s triangles into rows [rowStart] to [rowEnd] of [target]. */ + private fun rasterize(target: Target, mesh: Prepared, light: Boolean, blend: Blend, rowStart: Int, rowEnd: Int) { + val indices = mesh.draw.indices + val at = FloatArray(STRIDE) + val color = FloatArray(4) + val scratch = Scratch() + var i = 0 + while (i + 2 < indices.size) { + triangle(target, mesh, light, blend, indices[i], indices[i + 1], indices[i + 2], at, color, scratch, rowStart, rowEnd) + i += 3 + } + } + + private fun triangle( + target: Target, + mesh: Prepared, + light: Boolean, + blend: Blend, + ia: Int, + ib: Int, + ic: Int, + at: FloatArray, + color: FloatArray, + scratch: Scratch, + rowStart: Int, + rowEnd: Int, + ) { + val v = mesh.vertices + val count = v.size / STRIDE + if (ia !in 0 until count || ib !in 0 until count || ic !in 0 until count) return + val a = ia * STRIDE + val b = ib * STRIDE + val c = ic * STRIDE + val ax = v[a] + val ay = v[a + 1] + val bx = v[b] + val by = v[b + 1] + val cx = v[c] + val cy = v[c + 1] + val area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax) + if (abs(area) < 1e-6f) return + val cull = mesh.draw.cull + if (cull == CULL_BACK && area >= 0f) return + if (cull == CULL_FRONT && area <= 0f) return + + val width = target.width + val height = target.height + val minX = floor(min(ax, min(bx, cx))).toInt().coerceIn(0, width - 1) + val maxX = ceil(max(ax, max(bx, cx))).toInt().coerceIn(0, width - 1) + val minY = floor(min(ay, min(by, cy))).toInt().coerceIn(0, height - 1) + val maxY = ceil(max(ay, max(by, cy))).toInt().coerceIn(0, height - 1) + val firstRow = max(minY, rowStart) + val lastRow = min(maxY, rowEnd - 1) + if (firstRow > lastRow) return + + val invArea = 1f / area + val sampleX = minX + 0.5f + val sampleY = minY + 0.5f + val e0x = by - cy + val e0y = cx - bx + val e0c = bx * cy - by * cx + val e1x = cy - ay + val e1y = ax - cx + val e1c = cx * ay - cy * ax + val e2x = ay - by + val e2y = bx - ax + val e2c = ax * by - ay * bx + var e0Row = e0x * sampleX + e0y * sampleY + e0c + var e1Row = e1x * sampleX + e1y * sampleY + e1c + var e2Row = e2x * sampleX + e2y * sampleY + e2c + // Rows before the band are stepped as the PC steps them, so every row's edges round the same. + for (skipped in minY until firstRow) { + e0Row += e0y + e1Row += e1y + e2Row += e2y + } + val aw = v[a + 3] + val bw = v[b + 3] + val cw = v[c + 3] + val depth = target.depth + + for (y in firstRow..lastRow) { + var e0 = e0Row + var e1 = e1Row + var e2 = e2Row + var pixel = y * width + minX + for (x in minX..maxX) { + val negative = e0 < 0f || e1 < 0f || e2 < 0f + val positive = e0 > 0f || e1 > 0f || e2 > 0f + if (!(negative && positive)) { + val w0 = e0 * invArea + val w1 = e1 * invArea + val w2 = e2 * invArea + val denominator = w0 * aw + w1 * bw + w2 * cw + if (abs(denominator) >= 1e-8f) { + val z = (w0 * v[a + 2] * aw + w1 * v[b + 2] * bw + w2 * v[c + 2] * cw) / denominator + if (z in 0f..1f && (depth == null || z <= depth[pixel])) { + at[4] = (w0 * v[a + 4] * aw + w1 * v[b + 4] * bw + w2 * v[c + 4] * cw) / denominator + at[5] = (w0 * v[a + 5] * aw + w1 * v[b + 5] * bw + w2 * v[c + 5] * cw) / denominator + // The rest only matters for a texel that shows. + if (modulate(mesh.draw.modulate, at[4], at[5], color, scratch)) { + if (light) { + for (k in 6 until STRIDE) { + at[k] = (w0 * v[a + k] * aw + w1 * v[b + k] * bw + w2 * v[c + k] * cw) / denominator + } + light(mesh.draw.material, at, color, scratch) + } else { + clampColor(color) + } + } + if (color[3] > 0f) { + target.blend(pixel * 4, color, blend) + if (depth != null) depth[pixel] = z + } + } + } + } + e0 += e0x + e1 += e1x + e2 += e2x + pixel++ + } + e0Row += e0y + e1Row += e1y + e2Row += e2y + } + } + + /** + * The texel's colour before lighting (EvaluateModulateColor's modulate modes), not yet clamped. + * False when it is fully transparent, which draws nothing. + */ + private fun modulate(modulate: Modulate, u: Float, v: Float, out: FloatArray, scratch: Scratch): Boolean { + val texel = scratch.texel + sample(modulate.texture, u, v, texel, scratch.corners) + val r = modulate.r + val g = modulate.g + val b = modulate.b + when (modulate.mode) { + 0 -> { out[0] = r[0]; out[1] = r[1]; out[2] = r[2]; out[3] = 1f } + 1 -> { out[0] = texel[0]; out[1] = texel[1]; out[2] = texel[2]; out[3] = texel[3] } + 2 -> { + out[0] = texel[0] * r[0] + texel[1] * g[0] + texel[2] * b[0] + out[1] = texel[0] * r[1] + texel[1] * g[1] + texel[2] * b[1] + out[2] = texel[0] * r[2] + texel[1] * g[2] + texel[2] * b[2] + out[3] = texel[3] + } + 3 -> { out[0] = r[0]; out[1] = r[1]; out[2] = r[2]; out[3] = texel[0] } + 4 -> { out[0] = texel[1] * r[0]; out[1] = texel[1] * r[1]; out[2] = texel[1] * r[2]; out[3] = texel[0] } + 5 -> { out[0] = texel[0] * r[0]; out[1] = texel[0] * r[1]; out[2] = texel[0] * r[2]; out[3] = 1f } + else -> out.fill(1f) + } + if (modulate.mode != 0 && out[3] <= 0f) { + out.fill(0f) + return false + } + return true + } + + /** + * Lights the modulated colour in [color] (EvaluateModulateColor's lit path). Specular maths is + * skipped for materials without specular, where the PC multiplies it by zero. + */ + private fun light(material: Material, at: FloatArray, color: FloatArray, scratch: Scratch) { + val baseR = color[0] + val baseG = color[1] + val baseB = color[2] + val n = scratch.normal + FflResource.normalizeInto(at[9], at[10], at[11], n, 0, 1e-8f, 1f) + val diffuseDot = max(LIGHT[0] * n[0] + LIGHT[1] * n[1] + LIGHT[2] * n[2], PROFILE_DIFFUSE_FLOOR) + val diffuseFactor = 1f + (diffuseDot - 1f) * PROFILE_DIRECTIONAL + var reflection = 0f + var strength = 0f + if (material.hasSpecular) { + val e = scratch.eye + FflResource.normalizeInto(-at[6], -at[7], -at[8], e, 0, 1e-8f, 1f) + // Reflect(-light, n) = -light - 2 * dot(-light, n) * n. + val dotReflect = -LIGHT[0] * n[0] + -LIGHT[1] * n[1] + -LIGHT[2] * n[2] + val reflectX = -LIGHT[0] - 2f * dotReflect * n[0] + val reflectY = -LIGHT[1] - 2f * dotReflect * n[1] + val reflectZ = -LIGHT[2] - 2f * dotReflect * n[2] + val blinn = power(max(reflectX * e[0] + reflectY * e[1] + reflectZ * e[2], 0f), material.specularPower) + if (material.anisotropic) { + val t = scratch.tangent + val tangentLength = at[12] * at[12] + at[13] * at[13] + at[14] * at[14] + if (tangentLength < 1e-8f) { + t[0] = 1f + t[1] = 0f + t[2] = 0f + } else { + FflResource.normalizeInto(at[12], at[13], at[14], t, 0, 1e-8f, 0f) + } + val dotLt = LIGHT[0] * t[0] + LIGHT[1] * t[1] + LIGHT[2] * t[2] + val dotVt = e[0] * t[0] + e[1] * t[1] + e[2] * t[2] + val dotLn = sqrt(max(0f, 1f - dotLt * dotLt)) + val dotVr = dotLn * sqrt(max(0f, 1f - dotVt * dotVt)) - dotLt * dotVt + val anisotropic = power(max(0f, dotVr), material.specularPower) + reflection = anisotropic + (blinn - anisotropic) * at[15] + strength = at[16] + } else { + reflection = blinn + strength = 1f + } + } + val rimFactor = power(max(0f, at[18] * (1f - abs(n[2]))), PROFILE_RIM_POWER) + val rim = rimFactor * PROFILE_RIM_SCALE + val diffuseScale = diffuseFactor * PROFILE_DIFFUSE_SCALE + for (channel in 0 until 3) { + val ambient = material.ambient[channel] * PROFILE_AMBIENT + val diffuse = material.diffuse[channel] * diffuseScale + val specular = material.specular[channel] * reflection * strength * PROFILE_SPECULAR + val base = when (channel) { + 0 -> baseR + 1 -> baseG + else -> baseB + } + color[channel] = clamp01((ambient + diffuse) * base + specular + material.rim[channel] * rim) + } + color[3] = clamp01(color[3]) + } + + /** The unlit colour, clamped as the PC returns it. */ + private fun clampColor(color: FloatArray) { + for (c in 0 until 4) color[c] = clamp01(color[c]) + } + + /** Float pow as the PC's MathF.Pow gives it; 0 to a positive power is 0 without the call. */ + private fun power(base: Float, exponent: Float): Float = if (base == 0f) 0f else base.pow(exponent) + + /** Bilinear with mirrored repeat, texel centres on the edges (SampleTexture). */ + private fun sample(texture: FflTexture?, u: Float, v: Float, out: FloatArray, corners: FloatArray) { + if (texture == null) { + out.fill(1f) + return + } + if (texture.width <= 1 || texture.height <= 1) { + texel(texture, 0, 0, out, 0) + return + } + val fx = mirror(u) * (texture.width - 1) + val fy = mirror(v) * (texture.height - 1) + val x0 = floor(fx).toInt().coerceIn(0, texture.width - 1) + val y0 = floor(fy).toInt().coerceIn(0, texture.height - 1) + val x1 = min(x0 + 1, texture.width - 1) + val y1 = min(y0 + 1, texture.height - 1) + val tx = fx - x0 + val ty = fy - y0 + texel(texture, x0, y0, corners, 0) + texel(texture, x1, y0, corners, 4) + texel(texture, x0, y1, corners, 8) + texel(texture, x1, y1, corners, 12) + for (c in 0 until 4) { + val top = lerp(corners[c], corners[4 + c], tx) + val bottom = lerp(corners[8 + c], corners[12 + c], tx) + out[c] = lerp(top, bottom, ty) + } + } + + private fun lerp(a: Float, b: Float, t: Float) = a * (1f - t) + b * t + + private fun mirror(value: Float): Float { + var wrapped = value % 2f + if (wrapped < 0f) wrapped += 2f + return if (wrapped <= 1f) wrapped else 2f - wrapped + } + + private fun texel(texture: FflTexture, x: Int, y: Int, out: FloatArray, at: Int) { + val index = (y * texture.width + x) * texture.stride + val p = texture.pixels + if (index < 0 || index + texture.stride > p.size) { + for (c in 0 until 4) out[at + c] = 1f + return + } + when (texture.format) { + FflTexture.R8 -> { + val value = (p[index].toInt() and 0xFF) / 255f + out[at] = value + out[at + 1] = value + out[at + 2] = value + out[at + 3] = 1f + } + FflTexture.RG8 -> { + out[at] = (p[index].toInt() and 0xFF) / 255f + out[at + 1] = (p[index + 1].toInt() and 0xFF) / 255f + out[at + 2] = 0f + out[at + 3] = 1f + } + else -> for (c in 0 until 4) out[at + c] = (p[index + c].toInt() and 0xFF) / 255f + } + } + + private fun clamp01(value: Float) = value.coerceIn(0f, 1f) + + private fun toByte(value: Float): Int = round(clamp01(value) * 255f).toInt().coerceIn(0, 255) + + // --- Types and tables --- + + private enum class Blend { Over, Faceline, Mask } + + /** Per-thread working arrays, so shading a pixel allocates nothing. */ + private class Scratch { + val texel = FloatArray(4) + val corners = FloatArray(16) + val normal = FloatArray(3) + val eye = FloatArray(3) + val tangent = FloatArray(3) + } + + /** An RGBA8 picture being drawn, with a depth buffer for the head. */ + private class Target(val width: Int, val height: Int, withDepth: Boolean) { + val pixels = ByteArray(width * height * 4) + val depth: FloatArray? = if (withDepth) FloatArray(width * height) { 1f } else null + + fun fill(r: Int, g: Int, b: Int, a: Int) { + for (i in 0 until width * height) { + pixels[i * 4] = r.toByte() + pixels[i * 4 + 1] = g.toByte() + pixels[i * 4 + 2] = b.toByte() + pixels[i * 4 + 3] = a.toByte() + } + } + + fun blend(i: Int, src: FloatArray, mode: Blend) { + val srcA = clamp01(src[3]) + if (srcA <= 0f) return + if (mode == Blend.Over && srcA >= 0.999f) { + pixels[i] = toByte(src[0]).toByte() + pixels[i + 1] = toByte(src[1]).toByte() + pixels[i + 2] = toByte(src[2]).toByte() + pixels[i + 3] = 255.toByte() + return + } + val dstR = (pixels[i].toInt() and 0xFF) / 255f + val dstG = (pixels[i + 1].toInt() and 0xFF) / 255f + val dstB = (pixels[i + 2].toInt() and 0xFF) / 255f + val dstA = (pixels[i + 3].toInt() and 0xFF) / 255f + val outR: Float + val outG: Float + val outB: Float + val outA: Float + when (mode) { + Blend.Faceline -> { + outR = src[0] * srcA + dstR * (1f - srcA) + outG = src[1] * srcA + dstG * (1f - srcA) + outB = src[2] * srcA + dstB * (1f - srcA) + outA = srcA + dstA + } + Blend.Mask -> { + outR = src[0] * (1f - dstA) + dstR * dstA + outG = src[1] * (1f - dstA) + dstG * dstA + outB = src[2] * (1f - dstA) + dstB * dstA + outA = srcA * srcA + dstA * dstA + } + Blend.Over -> { + outA = srcA + dstA * (1f - srcA) + if (outA <= 0f) return + outR = (src[0] * srcA + dstR * dstA * (1f - srcA)) / outA + outG = (src[1] * srcA + dstG * dstA * (1f - srcA)) / outA + outB = (src[2] * srcA + dstB * dstA * (1f - srcA)) / outA + } + } + pixels[i] = toByte(outR).toByte() + pixels[i + 1] = toByte(outG).toByte() + pixels[i + 2] = toByte(outB).toByte() + pixels[i + 3] = toByte(outA).toByte() + } + + fun argb(): IntArray = IntArray(width * height) { i -> + ((pixels[i * 4 + 3].toInt() and 0xFF) shl 24) or ((pixels[i * 4].toInt() and 0xFF) shl 16) or + ((pixels[i * 4 + 1].toInt() and 0xFF) shl 8) or (pixels[i * 4 + 2].toInt() and 0xFF) + } + } + + private class Modulate( + val mode: Int, + val type: Int, + r: FloatArray? = null, + g: FloatArray? = null, + b: FloatArray? = null, + val texture: FflTexture? = null, + ) { + // A colour the PC leaves out reads as white. + val r = r?.map(::clamp01)?.toFloatArray() ?: WHITE + val g = g?.map(::clamp01)?.toFloatArray() ?: WHITE + val b = b?.map(::clamp01)?.toFloatArray() ?: WHITE + } + + private class Material( + val ambient: FloatArray, + val diffuse: FloatArray, + val specular: FloatArray, + val specularPower: Float, + val anisotropic: Boolean, + val rim: FloatArray, + ) { + val hasSpecular = specular.any { it != 0f } + } + + private class Draw( + val positions: FloatArray, + val texcoords: FloatArray, + val normals: FloatArray, + val tangents: FloatArray, + val parameters: FloatArray, + val indices: IntArray, + val cull: Int, + val modulate: Modulate, + val material: Material, + ) + + /** Per vertex: screen x, y, depth, 1/w, u, v, view position, normal, tangent and the four parameters. */ + private class Prepared(val draw: Draw, val vertices: FloatArray) + + private enum class Origin { Center, Left, Right } + + private class MaskPart(val x: Float, val y: Float, val scaleX: Float, val scaleY: Float, val rotation: Float, val origin: Origin) + + /** Where each face part sits on the mask (BuildRawMaskParts). */ + private class MaskParts(info: FflCharInfo) { + val eyeR: MaskPart + val eyeL: MaskPart + val eyebrowR: MaskPart + val eyebrowL: MaskPart + val mouth: MaskPart + val mustacheR: MaskPart + val mustacheL: MaskPart + val mole: MaskPart + + init { + val posXAdd = 3.5323312f + val posYAdd = 4.629278f + val spacingMul = 0.88961464f + val posXMul = 1.7792293f + val posYMul = 1.0760943f + val posYAddEye = posYAdd + 13.822246f + val posYAddEyebrow = posYAdd + 11.920528f + val posYAddMouth = posYAdd + 24.629572f + val posYAddMustache = posYAdd + 27.134275f + val posXAddMole = posXAdd + 14.233834f + val posYAddMole = posYAdd + 11.178394f + 2f * posYMul + + val eyeSpacing = info.eyeSpacingX * spacingMul + val eyeBase = 0.4f * info.eyeScale + 1f + val eyeBaseY = 0.12f * info.eyeScaleY + 0.64f + val eyeScaleX = 5.34375f * eyeBase + val eyeScaleY = 4.5f * eyeBase * eyeBaseY + val eyeY = info.eyePositionY * posYMul + posYAddEye + val eyeRotate = ((info.eyeRotate + 32 - EYE_ROTATE[info.eyeType.coerceIn(0, EYE_ROTATE.size - 1)]) % 32) * (360f / 32f) + + val browSpacing = info.eyebrowSpacingX * spacingMul + val browBase = 0.4f * info.eyebrowScale + 1f + val browBaseY = 0.12f * info.eyebrowScaleY + 0.64f + val browScaleX = 5.0625f * browBase + val browScaleY = 4.5f * browBase * browBaseY + val browY = info.eyebrowPositionY * posYMul + posYAddEyebrow + val browRotate = + ((info.eyebrowRotate + 32 - EYEBROW_ROTATE[info.eyebrowType.coerceIn(0, EYEBROW_ROTATE.size - 1)]) % 32) * (360f / 32f) + + val mouthBase = 0.4f * info.mouthScale + 1f + val mouthBaseY = 0.12f * info.mouthScaleY + 0.64f + val mustacheBase = 0.4f * info.mustacheScale + 1f + val mustacheY = info.mustachePositionY * posYMul + posYAddMustache + val moleScale = 0.4f * info.moleScale + 1f + + eyeR = MaskPart(32 - eyeSpacing, eyeY, eyeScaleX, eyeScaleY, eyeRotate, Origin.Left) + eyeL = MaskPart(eyeSpacing + 32, eyeY, eyeScaleX, eyeScaleY, 360f - eyeRotate, Origin.Right) + eyebrowR = MaskPart(32 - browSpacing, browY, browScaleX, browScaleY, browRotate, Origin.Left) + eyebrowL = MaskPart(browSpacing + 32, browY, browScaleX, browScaleY, 360f - browRotate, Origin.Right) + mouth = MaskPart(32f, info.mouthPositionY * posYMul + posYAddMouth, 6.1875f * mouthBase, 4.5f * mouthBase * mouthBaseY, 0f, Origin.Center) + mustacheR = MaskPart(32f, mustacheY, 4.5f * mustacheBase, 9.0f * mustacheBase, 0f, Origin.Left) + mustacheL = MaskPart(32f, mustacheY, 4.5f * mustacheBase, 9.0f * mustacheBase, 0f, Origin.Right) + mole = MaskPart( + info.molePositionX * posXMul + posXAddMole, info.molePositionY * posYMul + posYAddMole, + moleScale, moleScale, 0f, Origin.Center, + ) + } + } + + private const val STRIDE = 19 + private const val CULL_NONE = 0 + private const val CULL_BACK = 1 + private const val CULL_FRONT = 2 + private const val TYPE_FACELINE = 0 + private const val TYPE_BEARD = 1 + private const val TYPE_NOSE = 2 + private const val TYPE_FOREHEAD = 3 + private const val TYPE_HAIR = 4 + private const val TYPE_CAP = 5 + private const val TYPE_MASK = 6 + private const val TYPE_NOSELINE = 7 + private const val TYPE_GLASS = 8 + + // MiiLightingProfiles.Default on the PC. + private const val PROFILE_AMBIENT = 0.71f + private const val PROFILE_DIRECTIONAL = 0.32f + private const val PROFILE_DIFFUSE_SCALE = 1.32f + private const val PROFILE_DIFFUSE_FLOOR = 0.23f + private const val PROFILE_SPECULAR = 0.78f + private const val PROFILE_RIM_SCALE = 0.88f + private const val PROFILE_RIM_POWER = 2.56f + + private val WHITE = floatArrayOf(1f, 1f, 1f, 1f) + private val BLACK = floatArrayOf(0f, 0f, 0f, 1f) + private val MOLE = floatArrayOf(0.071f, 0.059f, 0.059f, 1f) + private val LIGHT = FloatArray(3).also { FflResource.normalizeInto(-0.4531539381f, 0.4226179123f, 0.7848858833f, it, 0, 0f, 1f) } + private val NO_NOSE_EXPRESSIONS = setOf(49, 50, 51, 52, 61, 62) + private val DIRECT_EYES = setOf(60, 62, 65, 69, 70, 71, 72, 73, 74, 75, 78, 79) + + /** Right eye, left eye, mouth and eyebrow per expression; 0 keeps the Mii's own part. */ + private val EXPRESSION_ELEMENTS = arrayOf( + intArrayOf(0, 0, 0, 0), intArrayOf(1, 1, 0, 0), intArrayOf(0, 0, 1, 0), intArrayOf(2, 2, 2, 0), + intArrayOf(3, 3, 0, 0), intArrayOf(4, 4, 0, 0), intArrayOf(0, 0, 3, 0), intArrayOf(1, 1, 3, 0), + intArrayOf(0, 0, 3, 0), intArrayOf(2, 2, 3, 0), intArrayOf(3, 3, 3, 0), intArrayOf(4, 4, 3, 0), + intArrayOf(5, 0, 0, 0), intArrayOf(0, 5, 0, 0), intArrayOf(5, 0, 3, 0), intArrayOf(0, 5, 3, 0), + intArrayOf(5, 0, 5, 0), intArrayOf(0, 5, 5, 0), intArrayOf(5, 5, 2, 0), + ) + + private val EYE_ROTATE = intArrayOf( + 3, 4, 4, 4, 3, 4, 4, 4, 3, 4, 4, 4, 4, 3, 3, 4, 4, 4, 3, 3, 4, 3, 4, 3, 3, 4, 3, 4, 4, 3, 4, 4, 4, 3, 3, 3, 4, 4, 3, 3, + 3, 4, 4, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 3, 4, 4, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, + ) + private val EYEBROW_ROTATE = intArrayOf(6, 6, 5, 7, 6, 7, 6, 7, 4, 7, 6, 8, 5, 5, 6, 6, 7, 7, 6, 6, 5, 6, 7, 5, 6, 6, 6, 6) + + private val MATERIALS = arrayOf( + material(floatArrayOf(0.85f, 0.75f, 0.75f), 0.75f, 0.30f, 1.2f, false, 0.3f), + material(floatArrayOf(1.0f, 1.0f, 1.0f), 0.7f, 0.0f, 40.0f, false, 0.3f), + material(floatArrayOf(0.90f, 0.85f, 0.85f), 0.75f, 0.22f, 1.5f, false, 0.3f), + material(floatArrayOf(0.85f, 0.75f, 0.75f), 0.75f, 0.30f, 1.2f, false, 0.3f), + material(floatArrayOf(1.00f, 1.00f, 1.00f), 0.70f, 0.10f, 10.0f, true, 0.3f), + material(floatArrayOf(0.75f, 0.75f, 0.75f), 0.72f, 0.30f, 1.5f, false, 0.3f), + material(floatArrayOf(1.0f, 1.0f, 1.0f), 0.7f, 0.0f, 40.0f, true, 0.3f), + material(floatArrayOf(1.0f, 1.0f, 1.0f), 0.7f, 0.0f, 40.0f, true, 0.3f), + material(floatArrayOf(1.0f, 1.0f, 1.0f), 0.7f, 0.0f, 40.0f, true, 0.3f), + ) + + /** The PC multiplies its light colours into each material per pixel; the products are the same. */ + private fun material(ambient: FloatArray, diffuse: Float, specular: Float, power: Float, anisotropic: Boolean, rim: Float) = Material( + ambient = FloatArray(3) { 0.73f * ambient[it] }, + diffuse = FloatArray(3) { 0.60f * diffuse }, + specular = FloatArray(3) { 0.70f * specular }, + specularPower = power, + anisotropic = anisotropic, + rim = FloatArray(3) { rim }, + ) +} + +/** A 4x4 matrix in System.Numerics' layout: row vectors, translation in the last row. */ +internal class Mat4(val m: FloatArray) { + operator fun times(other: Mat4): Mat4 { + val o = other.m + val result = FloatArray(16) + for (row in 0 until 4) { + for (column in 0 until 4) { + result[row * 4 + column] = m[row * 4] * o[column] + m[row * 4 + 1] * o[4 + column] + + m[row * 4 + 2] * o[8 + column] + m[row * 4 + 3] * o[12 + column] + } + } + return Mat4(result) + } + + fun transformPoint(v: FloatArray, at: Int, out: FloatArray, to: Int) { + val x = v[at] + val y = v[at + 1] + val z = v[at + 2] + for (c in 0 until 3) out[to + c] = x * m[c] + y * m[4 + c] + z * m[8 + c] + m[12 + c] + } + + fun transformNormal(v: FloatArray, at: Int, out: FloatArray, to: Int) { + val x = v[at] + val y = v[at + 1] + val z = v[at + 2] + for (c in 0 until 3) out[to + c] = x * m[c] + y * m[4 + c] + z * m[8 + c] + } + + /** (x, y, z, 1) times this matrix. */ + fun transform4(v: FloatArray, out: FloatArray) { + for (c in 0 until 4) out[c] = v[0] * m[c] + v[1] * m[4 + c] + v[2] * m[8 + c] + m[12 + c] + } + + companion object { + val IDENTITY = Mat4(floatArrayOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f)) + + /** Matrix4x4.CreateLookAt (right-handed). */ + fun lookAt(position: FloatArray, target: FloatArray, up: FloatArray): Mat4 { + val axisZ = FloatArray(3) + FflResource.normalizeInto(position[0] - target[0], position[1] - target[1], position[2] - target[2], axisZ, 0, 0f, 1f) + val crossX = up[1] * axisZ[2] - up[2] * axisZ[1] + val crossY = up[2] * axisZ[0] - up[0] * axisZ[2] + val crossZ = up[0] * axisZ[1] - up[1] * axisZ[0] + val axisX = FloatArray(3) + FflResource.normalizeInto(crossX, crossY, crossZ, axisX, 0, 0f, 1f) + val axisY = floatArrayOf( + axisZ[1] * axisX[2] - axisZ[2] * axisX[1], + axisZ[2] * axisX[0] - axisZ[0] * axisX[2], + axisZ[0] * axisX[1] - axisZ[1] * axisX[0], + ) + fun dot(axis: FloatArray) = axis[0] * -position[0] + axis[1] * -position[1] + axis[2] * -position[2] + return Mat4( + floatArrayOf( + axisX[0], axisY[0], axisZ[0], 0f, + axisX[1], axisY[1], axisZ[1], 0f, + axisX[2], axisY[2], axisZ[2], 0f, + dot(axisX), dot(axisY), dot(axisZ), 1f, + ), + ) + } + + /** Matrix4x4.CreatePerspectiveFieldOfView (right-handed, depth 0 to 1). */ + fun perspective(fieldOfView: Float, aspect: Float, near: Float, far: Float): Mat4 { + val height = 1f / tan(fieldOfView * 0.5f) + val width = height / aspect + val range = far / (near - far) + return Mat4( + floatArrayOf( + width, 0f, 0f, 0f, + 0f, height, 0f, 0f, + 0f, 0f, range, -1f, + 0f, 0f, range * near, 0f, + ), + ) + } + } +} + +/** FFL's colour tables, as the PC resolves them (sRGB). */ +internal object Colors { + fun faceline(index: Int) = FACELINE[index.coerceIn(0, FACELINE.size - 1)] + fun favorite(index: Int) = FAVORITE[index.coerceIn(0, FAVORITE.size - 1)] + fun hair(encoded: Int) = common(encoded, COMMON) ?: palette(encoded, HAIR) + fun glass(encoded: Int) = common(encoded, COMMON) ?: palette(encoded, GLASS) + fun eyeB(encoded: Int) = common(encoded, COMMON) ?: palette(encoded, EYE) + fun mouthR(encoded: Int) = common(encoded, COMMON) ?: palette(encoded, MOUTH_R) + fun mouthG(encoded: Int) = common(encoded, UPPER_LIP) ?: palette(encoded, MOUTH_G) + + private fun common(encoded: Int, table: Array): FloatArray? { + if (encoded and FflCharInfo.COMMON_COLOR == 0) return null + val index = encoded and 0xFF + return if (index < table.size) table[index] else table[0] + } + + private fun palette(encoded: Int, table: Array): FloatArray { + val index = if (encoded and FflCharInfo.COMMON_COLOR != 0) encoded and 0xFF else encoded + return table[index.coerceIn(0, table.size - 1)] + } + + private fun rgb(r: Float, g: Float, b: Float) = floatArrayOf(r, g, b, 1f) + + private val FACELINE = arrayOf( + rgb(1.000f, 0.827f, 0.678f), rgb(1.000f, 0.714f, 0.420f), rgb(0.870f, 0.475f, 0.259f), + rgb(1.000f, 0.667f, 0.549f), rgb(0.678f, 0.318f, 0.161f), rgb(0.388f, 0.173f, 0.094f), + ) + private val COMMON = arrayOf( + rgb(0.1764706f, 0.1568628f, 0.1568628f), rgb(0.2509804f, 0.1254902f, 0.0627451f), rgb(0.3607844f, 0.0941177f, 0.0392157f), + rgb(0.4862746f, 0.2274510f, 0.0784314f), rgb(0.4705883f, 0.4705883f, 0.5019608f), rgb(0.3058824f, 0.2431373f, 0.0627451f), + rgb(0.5333334f, 0.3450981f, 0.0941177f), rgb(0.8156863f, 0.6274510f, 0.2901961f), rgb(0.0000000f, 0.0000000f, 0.0000000f), + rgb(0.4235295f, 0.4392157f, 0.4392157f), rgb(0.4000000f, 0.2352942f, 0.1725491f), rgb(0.3764706f, 0.3686275f, 0.1882353f), + rgb(0.2745099f, 0.3294118f, 0.6588236f), rgb(0.2196079f, 0.4392157f, 0.3450981f), rgb(0.3764706f, 0.2196079f, 0.0627451f), + rgb(0.6588236f, 0.0627451f, 0.0313726f), rgb(0.1254902f, 0.1882353f, 0.4078432f), rgb(0.6588236f, 0.3764706f, 0.0000000f), + rgb(0.4705883f, 0.4392157f, 0.4078432f), rgb(0.8470589f, 0.3215687f, 0.0313726f), rgb(0.9411765f, 0.0470589f, 0.0313726f), + rgb(0.9607844f, 0.2823530f, 0.2823530f), rgb(0.9411765f, 0.6039216f, 0.4549020f), rgb(0.5490197f, 0.3137255f, 0.2509804f), + ) + private val UPPER_LIP = arrayOf( + rgb(0.0901961f, 0.0784314f, 0.0784314f), rgb(0.1254902f, 0.0627451f, 0.0313726f), rgb(0.1803922f, 0.0470589f, 0.0196079f), + rgb(0.2901961f, 0.1372550f, 0.0470589f), rgb(0.3294118f, 0.3294118f, 0.3529412f), rgb(0.1529412f, 0.1215687f, 0.0313726f), + rgb(0.3215687f, 0.2078432f, 0.0549020f), rgb(0.6941177f, 0.5019608f, 0.1568628f), rgb(0.0000000f, 0.0000000f, 0.0000000f), + rgb(0.2980393f, 0.3058824f, 0.3058824f), rgb(0.2000000f, 0.1176471f, 0.0862746f), rgb(0.2274510f, 0.2196079f, 0.1137255f), + rgb(0.1647059f, 0.1960785f, 0.3960785f), rgb(0.1529412f, 0.3058824f, 0.2431373f), rgb(0.1882353f, 0.1098040f, 0.0313726f), + rgb(0.3960785f, 0.0392157f, 0.0196079f), rgb(0.0627451f, 0.0941177f, 0.2039216f), rgb(0.4627451f, 0.2627451f, 0.0000000f), + rgb(0.3294118f, 0.3058824f, 0.2862746f), rgb(0.5098040f, 0.1882353f, 0.0941177f), rgb(0.4705883f, 0.0470589f, 0.0470589f), + rgb(0.5333334f, 0.1254902f, 0.1568628f), rgb(0.8627451f, 0.4705883f, 0.3137255f), rgb(0.2745099f, 0.1176471f, 0.0392157f), + ) + private val HAIR = arrayOf( + rgb(0.118f, 0.102f, 0.094f), rgb(0.251f, 0.125f, 0.063f), rgb(0.361f, 0.094f, 0.039f), rgb(0.486f, 0.227f, 0.078f), + rgb(0.471f, 0.471f, 0.502f), rgb(0.306f, 0.243f, 0.063f), rgb(0.533f, 0.345f, 0.094f), rgb(0.816f, 0.627f, 0.290f), + ) + private val GLASS = arrayOf( + rgb(0.094f, 0.094f, 0.094f), rgb(0.376f, 0.219f, 0.062f), rgb(0.658f, 0.062f, 0.031f), + rgb(0.125f, 0.188f, 0.407f), rgb(0.658f, 0.376f, 0.000f), rgb(0.470f, 0.439f, 0.407f), + ) + private val EYE = arrayOf( + rgb(0.000f, 0.000f, 0.000f), rgb(0.424f, 0.439f, 0.439f), rgb(0.400f, 0.235f, 0.173f), + rgb(0.376f, 0.369f, 0.188f), rgb(0.275f, 0.329f, 0.659f), rgb(0.220f, 0.439f, 0.345f), + ) + private val MOUTH_R = arrayOf( + rgb(0.847f, 0.322f, 0.031f), rgb(0.941f, 0.047f, 0.031f), rgb(0.961f, 0.282f, 0.282f), + rgb(0.941f, 0.604f, 0.455f), rgb(0.549f, 0.314f, 0.251f), + ) + private val MOUTH_G = arrayOf( + rgb(0.510f, 0.188f, 0.094f), rgb(0.471f, 0.047f, 0.047f), rgb(0.533f, 0.125f, 0.157f), + rgb(0.863f, 0.471f, 0.314f), rgb(0.275f, 0.118f, 0.039f), + ) + private val FAVORITE = arrayOf( + rgb(0.824f, 0.118f, 0.078f), rgb(1.000f, 0.431f, 0.098f), rgb(1.000f, 0.847f, 0.125f), rgb(0.471f, 0.824f, 0.125f), + rgb(0.000f, 0.471f, 0.188f), rgb(0.039f, 0.282f, 0.706f), rgb(0.235f, 0.667f, 0.871f), rgb(0.961f, 0.353f, 0.490f), + rgb(0.451f, 0.157f, 0.678f), rgb(0.282f, 0.220f, 0.094f), rgb(0.878f, 0.878f, 0.878f), rgb(0.094f, 0.094f, 0.078f), + ) +} + +/** + * FFL's description of a Mii (FFLiCharInfo's parts), made the way the PC makes it: the Wii Mii is + * turned into Mii Studio's 46 values (MiiStudioDataSerializer.GenerateStudioDataArray), which are + * then read as FFL parts (NativeMiiRenderer.MapStudioDataToCharInfo). + */ +internal class FflCharInfo(studio: IntArray) { + val beardColor = studio[0] or COMMON_COLOR + val beardType = studio[1] + val eyeScaleY = studio[3] + val eyeColor = studio[4] or COMMON_COLOR + val eyeRotate = studio[5] + val eyeScale = studio[6] + val eyeType = studio[7] + val eyeSpacingX = studio[8] + val eyePositionY = studio[9] + val eyebrowScaleY = studio[10] + val eyebrowColor = studio[11] or COMMON_COLOR + val eyebrowRotate = studio[12] + val eyebrowScale = studio[13] + val eyebrowType = studio[14] + val eyebrowSpacingX = studio[15] + val eyebrowPositionY = studio[16] + val facelineColor = studio[17] + val faceMakeup = studio[18] + val faceType = studio[19] + val faceLine = studio[20] + val favoriteColor = studio[21] + val glassColor = studio[23] or COMMON_COLOR + val glassScale = studio[24] + val glassType = studio[25] + val glassPositionY = studio[26] + val hairColor = studio[27] or COMMON_COLOR + val hairDir = studio[28] + val hairType = studio[29] + val moleScale = studio[31] + val moleType = studio[32] + val molePositionX = studio[33] + val molePositionY = studio[34] + val mouthScaleY = studio[35] + val mouthColor = studio[36] or COMMON_COLOR + val mouthScale = studio[37] + val mouthType = studio[38] + val mouthPositionY = studio[39] + val mustacheScale = studio[40] + val mustacheType = studio[41] + val mustachePositionY = studio[42] + val noseScale = studio[43] + val noseType = studio[44] + val nosePositionY = studio[45] + + companion object { + const val COMMON_COLOR = 0x80000000.toInt() + + private val MAKEUP = intArrayOf(0, 1, 6, 9, 0, 0, 0, 0, 0, 10, 0, 0) + private val WRINKLES = intArrayOf(0, 0, 0, 0, 5, 2, 3, 7, 8, 0, 9, 11) + + fun of(mii: Mii) = FflCharInfo(studio(mii)) + + /** GenerateStudioDataArray: the Wii Mii's look as Mii Studio's values. */ + fun studio(mii: Mii): IntArray { + val s = IntArray(46) + s[0x16] = if (mii.girl) 1 else 0 + s[0x15] = mii.favoriteColor + s[0x1E] = mii.height + s[2] = mii.weight + s[0x13] = mii.faceShape + s[0x11] = mii.skinColor + s[0x14] = WRINKLES.getOrElse(mii.facialFeature) { 0 } + s[0x12] = MAKEUP.getOrElse(mii.facialFeature) { 0 } + s[0x1D] = mii.hairType + s[0x1B] = if (mii.hairColor == 0) 8 else mii.hairColor + s[0x1C] = if (mii.hairFlipped) 1 else 0 + s[0xE] = mii.eyebrowType + s[0xC] = mii.eyebrowRotation + s[0xB] = if (mii.eyebrowColor == 0) 8 else mii.eyebrowColor + s[0xD] = mii.eyebrowSize + s[0xA] = 3 + s[0x10] = mii.eyebrowVertical + s[0xF] = mii.eyebrowSpacing + s[7] = mii.eyeType + s[5] = mii.eyeRotation + s[9] = mii.eyeVertical + s[4] = mii.eyeColor + 8 + s[6] = mii.eyeSize + s[3] = 3 + s[8] = mii.eyeSpacing + s[0x2C] = mii.noseType + s[0x2B] = mii.noseSize + s[0x2D] = mii.noseVertical + s[0x26] = mii.lipType + s[0x24] = if (mii.lipColor < 4) mii.lipColor + 19 else 0 + s[0x25] = mii.lipSize + s[0x23] = 3 + s[0x27] = mii.lipVertical + s[0x29] = mii.mustacheType + s[1] = mii.beardType + s[0] = if (mii.facialHairColor == 0) 8 else mii.facialHairColor + s[0x28] = mii.mustacheSize + s[0x2A] = mii.mustacheVertical + s[0x19] = mii.glassesType + s[0x17] = when { + mii.glassesColor == 0 -> 8 + mii.glassesColor < 6 -> mii.glassesColor + 13 + else -> 0 + } + s[0x18] = mii.glassesSize + s[0x1A] = mii.glassesVertical + s[0x20] = if (mii.moleEnabled) 1 else 0 + s[0x1F] = mii.moleSize + s[0x22] = mii.moleVertical + s[0x21] = mii.moleHorizontal + return s + } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/MiisPage.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiisPage.kt new file mode 100644 index 0000000..da8a135 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiisPage.kt @@ -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 = emptyList() + private val selected = LinkedHashSet() + /** 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 = 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) { + // 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(R.id.mii_placeholder).visibility = View.GONE + add.findViewById(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(R.id.mii_name).text = mii.name + tile.contentDescription = mii.name + tile.findViewById(R.id.mii_favorite).visibility = if (mii.favorite) View.VISIBLE else View.GONE + tile.findViewById(R.id.mii_foreign).visibility = if (isForeign(mii)) View.VISIBLE else View.GONE + tile.isActivated = mii.favorite + tile.tag = mii.miiId + tile.findViewById(R.id.mii_placeholder).visibility = + if (MiiRenderResource.installed(activity)) View.GONE else View.VISIBLE + val picture = tile.findViewById(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 = 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) { + 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) { + 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) { + 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) { + if (uris.isEmpty()) return + val resolver = activity.contentResolver + val names = uris.map(::displayName) + change({ file -> + val problems = ArrayList() + 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) { + 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 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 } } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt index c8b5ca2..c860ab2 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt @@ -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_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_miis, activity.getString(R.string.about_credit_miis), stacked = true) if (BuildConfig.ON_DEVICE_BUILD) { info(R.string.about_credit_title_toolchain, activity.getString(R.string.about_credit_toolchain), stacked = true) } diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/TileGrid.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/TileGrid.kt new file mode 100644 index 0000000..1c1bdde --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/TileGrid.kt @@ -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 + } + } + } +} diff --git a/android/app/src/main/res/drawable/bg_button_danger.xml b/android/app/src/main/res/drawable/bg_button_danger.xml new file mode 100644 index 0000000..bb1f9d6 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_button_danger.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/bg_mii_choice.xml b/android/app/src/main/res/drawable/bg_mii_choice.xml new file mode 100644 index 0000000..c422433 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_mii_choice.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/bg_mii_editor_panel.xml b/android/app/src/main/res/drawable/bg_mii_editor_panel.xml new file mode 100644 index 0000000..84ffe08 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_mii_editor_panel.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/bg_mii_preview.xml b/android/app/src/main/res/drawable/bg_mii_preview.xml new file mode 100644 index 0000000..9c15054 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_mii_preview.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/bg_mii_tile.xml b/android/app/src/main/res/drawable/bg_mii_tile.xml new file mode 100644 index 0000000..bae6b3b --- /dev/null +++ b/android/app/src/main/res/drawable/bg_mii_tile.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_arrow_down.xml b/android/app/src/main/res/drawable/ic_arrow_down.xml new file mode 100644 index 0000000..05fd6f8 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_arrow_down.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_arrow_up.xml b/android/app/src/main/res/drawable/ic_arrow_up.xml new file mode 100644 index 0000000..52406b1 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_arrow_up.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_duplicate.xml b/android/app/src/main/res/drawable/ic_duplicate.xml new file mode 100644 index 0000000..e60490f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_duplicate.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_file_export.xml b/android/app/src/main/res/drawable/ic_file_export.xml new file mode 100644 index 0000000..241f7ab --- /dev/null +++ b/android/app/src/main/res/drawable/ic_file_export.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_globe.xml b/android/app/src/main/res/drawable/ic_globe.xml new file mode 100644 index 0000000..deff22f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_globe.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_minus.xml b/android/app/src/main/res/drawable/ic_minus.xml new file mode 100644 index 0000000..43b3027 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_minus.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pen.xml b/android/app/src/main/res/drawable/ic_pen.xml new file mode 100644 index 0000000..5872fc7 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pen.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_plus.xml b/android/app/src/main/res/drawable/ic_plus.xml new file mode 100644 index 0000000..14baadc --- /dev/null +++ b/android/app/src/main/res/drawable/ic_plus.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_rotate_left.xml b/android/app/src/main/res/drawable/ic_rotate_left.xml new file mode 100644 index 0000000..5aa4d16 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_rotate_left.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_rotate_right.xml b/android/app/src/main/res/drawable/ic_rotate_right.xml new file mode 100644 index 0000000..2c46be5 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_rotate_right.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_shirt.xml b/android/app/src/main/res/drawable/ic_shirt.xml new file mode 100644 index 0000000..88b278f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_shirt.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_star_empty.xml b/android/app/src/main/res/drawable/ic_star_empty.xml new file mode 100644 index 0000000..4e7499c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_star_empty.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_trash.xml b/android/app/src/main/res/drawable/ic_trash.xml new file mode 100644 index 0000000..d3c7019 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_trash.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_x_mark.xml b/android/app/src/main/res/drawable/ic_x_mark.xml new file mode 100644 index 0000000..e0162e7 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_x_mark.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/layout/activity_launcher.xml b/android/app/src/main/res/layout/activity_launcher.xml index 4dbb0ef..6ea047b 100644 --- a/android/app/src/main/res/layout/activity_launcher.xml +++ b/android/app/src/main/res/layout/activity_launcher.xml @@ -152,6 +152,20 @@ android:text="@string/launcher_nav_patches" /> + + + + + + + @@ -206,6 +220,11 @@ layout="@layout/page_patches" android:visibility="gone" /> + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/page_mii_editor.xml b/android/app/src/main/res/layout/page_mii_editor.xml new file mode 100644 index 0000000..5beb573 --- /dev/null +++ b/android/app/src/main/res/layout/page_mii_editor.xml @@ -0,0 +1,214 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/page_miis.xml b/android/app/src/main/res/layout/page_miis.xml new file mode 100644 index 0000000..aa6e21e --- /dev/null +++ b/android/app/src/main/res/layout/page_miis.xml @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 9eb9e12..753f499 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -41,4 +41,6 @@ #28503B #1E5E3A #D6FFE2 + + #00BFFF diff --git a/android/app/src/main/res/values/ids.xml b/android/app/src/main/res/values/ids.xml index 36ddcbd..41bb14c 100644 --- a/android/app/src/main/res/values/ids.xml +++ b/android/app/src/main/res/values/ids.xml @@ -2,4 +2,6 @@ + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index c72f6f1..fa7cb96 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -9,6 +9,7 @@ Home My profiles Patches + My Miis Settings v%1$s @@ -170,6 +171,118 @@ No VR history found for this range yet. Friend code is required to load VR history. + + Favorite + Unfavorite + Edit + Duplicate + Export + Delete + Import + No Miis yet! + The Mii database in the game\'s NAND cannot be opened: %1$s + 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. + Download + Connecting to the Internet Archive… + Downloading the Mii parts: %1$s of %2$s + The Mii parts could not be downloaded: %1$s + New Mii + Randomize + Male + Female + New Mii + Are you sure you want to delete %1$s? + Are you sure you want to delete %1$d Miis? + Deleting is permanent and cannot be undone. + Cannot delete favorite Mii(s) + 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. + Deleted \'%1$s\' + Deleted %1$d Miis + Created duplicate of \'%1$s\' + Created %1$d duplicate Miis + + Imported %1$d Mii + Imported %1$d Miis + + %1$s: %2$s + Saved Mii \'%1$s\' + Saved %1$d Miis + The Miis could not be saved: %1$s + The Mii database could not be changed + Close the game first: it reads the Mii database. + Start the game once first + Miis made on this headset carry its console identity, which the game creates the first time it starts. Start it once, then come back. + No file picker is available on this headset. + Randomize + Cancel + Save + Back + Download the Mii parts on My Miis to see your Mii here. + Discard your changes? + Leaving the editor without saving loses them. + Discard + General + Face + Hair + Eyebrows + Eyes + Nose + Lips + Glasses + Facial Hair + Mole + Mii Name + Enter Mii name… + Names must be between 3 and 10 characters long. + Creator Name + Optional + Creation Date + Unknown + Gender + Male + Female + Height + Width + Favorite Color + Skin Color + Head Shape + Facial Feature + + None + Cheeks + Cheek and eyes + Freckles + Baggy eyes + Chad + Tired + Chin + Eye shadow + Beard + Mouth corners + Wrinkles + + Hair Color + Mirror Hair + Hair Type + Vertical Position (Up/Down) + Horizontal Position (Left/Right) + Rotation (Rotate Left/Right) + Size + Spacing in between + Eyebrow Type + Eye Color + Eye Type + Nose Type + Lip Color + Mouth Type + Glasses Type + Glasses Color + Mustache Type + Mustache Vertical Position (Up/Down) + Mustache Size + Beard Type + Enable Mole? + Mod Browser Back to your mods @@ -229,6 +342,7 @@ nod by encounter, which reads your disc image, built into this app as libnod_jni. 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. 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. + 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. On-headset builds use Termux\'s Android clang and lld, Google\'s Android NDK headers and libraries, and .NET for the translator. The Dolphin emulator, the reference for how the Wii behaves. The WiiCompiled logo by inkwreck. @@ -244,6 +358,7 @@ Disc images Mod browser My profiles + My Miis On-headset builds Dolphin Logo diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index 48a59c8..c0e2df5 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -208,4 +208,32 @@ true true + + + + + + + + diff --git a/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiDataTest.kt b/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiDataTest.kt new file mode 100644 index 0000000..73629a7 --- /dev/null +++ b/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiDataTest.kt @@ -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() +} diff --git a/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiDatabaseTest.kt b/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiDatabaseTest.kt new file mode 100644 index 0000000..ac36a77 --- /dev/null +++ b/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiDatabaseTest.kt @@ -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 }) + } +} diff --git a/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiIdsTest.kt b/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiIdsTest.kt new file mode 100644 index 0000000..db95f71 --- /dev/null +++ b/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiIdsTest.kt @@ -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() } +} diff --git a/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiRendererTest.kt b/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiRendererTest.kt new file mode 100644 index 0000000..464400b --- /dev/null +++ b/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiRendererTest.kt @@ -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, ByteArray>, textures: Map, 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, 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)) } + } +} diff --git a/docs/quest-port.md b/docs/quest-port.md index 3562402..6042cb6 100644 --- a/docs/quest-port.md +++ b/docs/quest-port.md @@ -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 (`/api/roomstatus`, asked again every 30 s while the page is shown), its VR history comes from `/api/leaderboard/player//history?days=N`, and its Mii picture is the -64-pixel PNG of `/api/leaderboard/player/`, kept on disk for the sidebar. The -headset has no Mii database (`RFL_DB.dat`), so this picture stands in for the PC's -rendered Mii, and a licence never taken online shows a silhouette. Badges come from -WheelWizard's `badges.json`. It only reads: Rename and change Mii need that database, -and the PC's region picker is gone since the Quest runs PAL only. The card and the +64-pixel PNG of `/api/leaderboard/player/`, kept on disk for the sidebar. It +stands in for the PC's rendered Mii because a licence's Mii is rarely in the +headset's Mii database, which only exists once My Miis has created it; a licence +never taken online shows a silhouette. Badges come from WheelWizard's +`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` 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 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 @@ -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 ``` +`MIIS` opens My Miis, and `edit:N` or `edit:N:` 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 idle, over 40 s, with an optimized build (`-O3`, translated code `-O2`, `-mcpu=cortex-a77`):