From e1bd3c6f947d28f7e0827f9a8c239e8cf60c24e7 Mon Sep 17 00:00:00 2001 From: iChris4 Date: Thu, 24 Sep 2026 04:41:44 +0200 Subject: [PATCH] feat: Enhance Mii handling and rendering in Retro WFC - Introduced PlayerMii class to encapsulate Mii data and image. - Added parseMiiData function to decode Mii data from JSON. - Updated miiImage function to playerMii for better clarity and functionality. - Enhanced RksysProfiles to include Mii ID and updated parsing logic. - Modified SidebarProfileCard to accommodate new Mii handling. - Redesigned layout for activity_launcher.xml to improve Mii display. - Adjusted item_mii.xml and page_profiles.xml for better Mii image sizing. - Updated strings.xml for clarity on Mii parts and bodies. - Added tests for Mii database and rendering to ensure functionality. - Created MiiBodies class to handle upper body rendering for Miis. - Implemented MiiBodiesTest to validate body model parsing and rendering. --- .../wiicompiled/quest/launcher/MiiBodies.kt | 89 +++++++++++ .../wiicompiled/quest/launcher/MiiDatabase.kt | 15 ++ .../wiicompiled/quest/launcher/MiiEditor.kt | 4 +- .../wiicompiled/quest/launcher/MiiImages.kt | 65 ++++++-- .../quest/launcher/MiiRenderResource.kt | 146 +++++++++++++---- .../wiicompiled/quest/launcher/MiiRenderer.kt | 151 ++++++++++++++++-- .../wiicompiled/quest/launcher/MiisPage.kt | 17 +- .../quest/launcher/ProfileStore.kt | 136 ++++++++++++---- .../quest/launcher/ProfilesPage.kt | 22 ++- .../wiicompiled/quest/launcher/RetroWfc.kt | 21 ++- .../quest/launcher/RksysProfiles.kt | 9 +- .../quest/launcher/SidebarProfileCard.kt | 19 ++- .../src/main/res/layout/activity_launcher.xml | 60 +++---- android/app/src/main/res/layout/item_mii.xml | 9 +- .../app/src/main/res/layout/page_profiles.xml | 4 +- android/app/src/main/res/values/strings.xml | 6 +- .../quest/launcher/MiiBodiesTest.kt | 106 ++++++++++++ .../quest/launcher/MiiDatabaseTest.kt | 15 ++ .../quest/launcher/MiiRendererTest.kt | 61 +++++++ .../quest/launcher/RetroWfcTest.kt | 13 ++ .../quest/launcher/RksysProfilesTest.kt | 12 +- docs/quest-port.md | 48 +++--- 22 files changed, 853 insertions(+), 175 deletions(-) create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/MiiBodies.kt create mode 100644 android/app/src/test/java/org/wiicompiled/quest/launcher/MiiBodiesTest.kt diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiBodies.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiBodies.kt new file mode 100644 index 0000000..22af2bd --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiBodies.kt @@ -0,0 +1,89 @@ +package org.wiicompiled.quest.launcher + +import java.io.File +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * The upper body the PC launcher draws below a Mii's head: the 3DS Mii bodies it carries + * (mii_static_body_3ds_male_LE.rmdl and its female twin), each a shirt and a pair of trousers in + * rio's little-endian model format. They are Nintendo's, so like the Mii parts they are + * downloaded ([MiiRenderResource]) rather than shipped. + */ +class MiiBodies(val male: List, val female: List) { + + /** + * One mesh, its own scale, rotation and translation already applied to its positions and + * normals as the PC applies them to each vertex. Odd meshes are the trousers. + */ + class Mesh(val positions: FloatArray, val normals: FloatArray, val texcoords: FloatArray, val indices: IntArray, val pants: Boolean) + + companion object { + /** The 3DS row of the PC's body_models.csv: the models' scale, and the head's height above their origin. */ + const val MODEL_SCALE = 7f + const val HEAD_Y = 10.7766f + + private const val HEADER_SIZE = 0x20 + private const val MESH_SIZE = 0x38 + private const val VERTEX_SIZE = 0x20 + + @Throws(IOException::class) + fun load(male: File, female: File) = MiiBodies(parse(male.readBytes()), parse(female.readBytes())) + + /** The meshes of a rio model, read as NativeMiiRenderer's TryReadRioModelBytes reads them. */ + @Throws(IOException::class) + fun parse(bytes: ByteArray): List { + if (bytes.size < HEADER_SIZE || String(bytes, 0, 8, Charsets.US_ASCII) != "riomodel") { + throw IOException("Not a rio model.") + } + val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + val declared = buffer.getInt(0x0C).toLong() and 0xFFFFFFFFL + if (declared in 1..Int.MAX_VALUE && bytes.size.toLong() != declared) { + throw IOException("The model is ${bytes.size} bytes, not $declared.") + } + val meshCount = buffer.getInt(0x14).toLong() and 0xFFFFFFFFL + if (meshCount == 0L || meshCount > 1024) throw IOException("The model has $meshCount meshes.") + val list = 0x10L + buffer.getInt(0x10) + + val meshes = ArrayList() + for (index in 0 until meshCount.toInt()) { + val at = list + index.toLong() * MESH_SIZE + if (at < 0 || at + MESH_SIZE > bytes.size) throw IOException("Mesh $index is outside the model.") + val mesh = at.toInt() + val vertexCount = buffer.getInt(mesh + 0x04).toLong() and 0xFFFFFFFFL + val indexCount = buffer.getInt(mesh + 0x0C).toLong() and 0xFFFFFFFFL + if (vertexCount == 0L || indexCount < 3) continue + if (vertexCount > 200_000 || indexCount > 2_000_000) throw IOException("Mesh $index is too large.") + val vertices = at + buffer.getInt(mesh) + val indices = at + 0x08 + buffer.getInt(mesh + 0x08) + if (vertices < 0 || indices < 0 || vertices + vertexCount * VERTEX_SIZE > bytes.size || indices + indexCount * 4 > bytes.size) { + throw IOException("Mesh $index's data is outside the model.") + } + fun vector(offset: Int) = floatArrayOf(buffer.getFloat(offset), buffer.getFloat(offset + 4), buffer.getFloat(offset + 8)) + // CreateBodyMeshSrt: scale, then rotation (radians), then translation. + val srt = Mat4.scale(vector(mesh + 0x10)) * Mat4.rotation(vector(mesh + 0x1C)) * Mat4.translation(vector(mesh + 0x28)) + + val count = vertexCount.toInt() + val positions = FloatArray(count * 3) + val normals = FloatArray(count * 3) + val texcoords = FloatArray(count * 2) + for (i in 0 until count) { + val vertex = vertices.toInt() + i * VERTEX_SIZE + srt.transformPoint(vector(vertex), 0, positions, i * 3) + texcoords[i * 2] = buffer.getFloat(vertex + 0x0C) + texcoords[i * 2 + 1] = buffer.getFloat(vertex + 0x10) + srt.transformNormal(vector(vertex + 0x14), 0, normals, i * 3) + } + val order = IntArray(indexCount.toInt()) { i -> + val value = buffer.getInt(indices.toInt() + i * 4).toLong() and 0xFFFFFFFFL + if (value >= vertexCount) throw IOException("Mesh $index names vertex $value of $vertexCount.") + value.toInt() + } + meshes += Mesh(positions, normals, texcoords, order, pants = (index and 1) == 1) + } + if (meshes.isEmpty()) throw IOException("The model has no meshes.") + return meshes + } + } +} 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 index 6347a76..eb30b93 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiDatabase.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiDatabase.kt @@ -83,6 +83,21 @@ object MiiDatabase { @Throws(IOException::class) fun miis(file: File): List = slots(file).mapNotNull { block -> block?.let { runCatching { MiiData.parse(it) }.getOrNull() } } + /** + * The database's Miis by ID, as the PC's GetByAvatarId finds them: the first slot holding an ID + * is that ID's Mii, and one the PC could not read either is left out. + */ + @Throws(IOException::class) + fun byId(file: File): Map { + val found = LinkedHashMap() + for (block in slots(file)) { + if (block == null) continue + val id = readId(block, 0) + if (id !in found) found[id] = runCatching { MiiData.parse(block) }.getOrNull() + } + return found.mapNotNull { (id, mii) -> mii?.let { id to it } }.toMap() + } + /** Adds a Mii in the first free slot. */ @Throws(IOException::class) fun add(file: File, mii: Mii) = edit(file) { db -> 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 index 11fce1a..089fcef 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiEditor.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiEditor.kt @@ -602,13 +602,13 @@ class MiiEditor(private val activity: Activity, private val root: View, private spacer() } - /** Pictures a choice as the Mii's head wearing it, enlarged past the cell's edges as the list's tiles are. */ + /** Pictures a choice as the Mii's head wearing it, without the body, enlarged past the cell's edges so the head fills it. */ 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()) + MiiImages.head(activity, view, variant, (choicePixels() * HEAD_ZOOM).toInt() and 1.inv(), withBody = false) } /** Pictures a flat part's choices from its texture; [none] is the choice that is no part at all. */ 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 index 3ed408f..6369b8c 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiImages.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiImages.kt @@ -8,13 +8,15 @@ import android.util.Log import android.util.LruCache import android.widget.ImageView import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicInteger 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. + * Mii pictures for My Miis, its editor and the profiles, 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. Miis have their bodies once those are downloaded too. */ object MiiImages { private const val TAG = "WiiCompiledLauncher" @@ -25,12 +27,20 @@ object MiiImages { 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() + /** Changes with [clear]: a picture drawn before is shown but not kept. */ + private val cacheGeneration = AtomicInteger() 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) { + /** + * Shows [mii] in [view], [size] pixels square, with its upper body unless not [withBody]; a + * recycled view only takes the picture it asked for last. + */ + fun head(context: Context, view: ImageView, mii: Mii, size: Int, withBody: Boolean = true) { val copy = mii.copy() - show(context, view, "head:$size:${copy.lookKey()}") { resource -> MiiRenderer.render(resource, copy, size) } + val app = context.applicationContext + show(context, view, "${if (withBody) "mii" else "head"}:$size:${copy.lookKey()}") { resource -> + MiiRenderer.render(resource, copy, size, bodies = if (withBody) MiiRenderResource.bodies(app) else null) + } } /** Shows one choice of a face part in [view] (MiiRenderer.partIcon); nothing for "none". */ @@ -47,29 +57,56 @@ object MiiImages { show(context, view, "part:$part:$index:$size:$colour") { resource -> MiiRenderer.partIcon(resource, copy, part, index, size) } } + /** + * [mii] turned as [pose], [size] pixels square, for [done] on the main thread; null when it + * cannot be drawn, such as before the Mii parts are downloaded. + */ + fun picture(context: Context, mii: Mii, size: Int, pose: MiiRenderer.Pose, done: (Bitmap?) -> Unit) { + val copy = mii.copy() + val key = "mii:$size:${pose.key}:${copy.lookKey()}" + cache.get(key)?.let { + done(it) + return + } + val app = context.applicationContext + val generation = cacheGeneration.get() + renderers.execute { + val bitmap = draw(app, key, generation) { resource -> + MiiRenderer.render(resource, copy, size, pose, MiiRenderResource.bodies(app)) + } + main.post { done(bitmap) } + } + } + /** * 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()}" + val key = "mii:$size:${mii.lookKey()}" cache.get(key)?.let { previewGeneration.incrementAndGet() done(it) return } val generation = previewGeneration.incrementAndGet() + val installs = cacheGeneration.get() val copy = mii.copy() previewer.execute { if (previewGeneration.get() != generation) return@execute - val bitmap = draw(app, key) { resource -> MiiRenderer.render(resource, copy, size) } + val bitmap = draw(app, key, installs) { resource -> + MiiRenderer.render(resource, copy, size, bodies = MiiRenderResource.bodies(app)) + } 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() + fun clear() { + cacheGeneration.incrementAndGet() + cache.evictAll() + } private fun show(context: Context, view: ImageView, key: String, render: (FflResource) -> IntArray?) { view.setTag(R.id.mii_image_key, key) @@ -80,22 +117,26 @@ object MiiImages { } view.setImageDrawable(null) val app = context.applicationContext + val generation = cacheGeneration.get() 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 + val bitmap = draw(app, key, generation, 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? { + private fun draw(context: Context, key: String, generation: Int, 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) } + Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888).also { + // Drawn from what was installed before a clear: shown, but not kept. + if (cacheGeneration.get() == generation) cache.put(key, it) + } } catch (e: Exception) { Log.w(TAG, "Cannot draw a Mii picture", e) 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 index ad35bcd..261bf7a 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiRenderResource.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiRenderResource.kt @@ -16,36 +16,63 @@ 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. + * What Mii pictures are drawn from: FFL's Mii parts (FFLResHigh.dat), the PC launcher's + * MiiRenderingResourceInstaller, and the 3DS Mii bodies the PC carries ([MiiBodies]). Both are + * Nintendo's, so the app never carries them; the player downloads them once, the parts from the + * Internet Archive's copy of Miitomo's files as on the PC, the bodies from Wheel Wizard's own + * repository, and each is checked against the SHA-256 of the copy both launchers use. */ 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 ARCHIVE_BYTES = 4_393_464L private const val FILE_BYTES = 4_579_008L + + /** The PC's embedded body models, at the Wheel Wizard commit that added them. */ + private const val BODY_URL = "https://raw.githubusercontent.com/TeamWheelWizard/WheelWizard/" + + "cc4c2e9df2ef5f7717e9b1fbe18a94acc4076b5f/WheelWizard/Features/MiiRendering/Resources/" + private val MALE_BODY = BodyFile("mii_static_body_3ds_male_LE.rmdl", 14_320L, "f17b764f4c42729572548f1cf760ab6e2eb6420cde5898b904be7eaea68cf837") + private val FEMALE_BODY = BodyFile("mii_static_body_3ds_female_LE.rmdl", 14_736L, "371639d2b73280bc2e23e6aac44b49403a026a0f019ad287ac2c7c996451742f") + private const val ATTEMPTS = 3 private const val TIMEOUT_MS = 30_000 private const val TAG = "WiiCompiledLauncher" + private class BodyFile(val name: String, val bytes: Long, val sha256: String) + 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 + /** The bodies once read, or null while they have not been; [NO_BODIES] when they could not be. */ + @Volatile + private var loadedBodies: MiiBodies? = null + private val NO_BODIES = MiiBodies(emptyList(), emptyList()) + /** Whether a download is running; main thread only. */ var installing = false private set fun file(context: Context): File = File(context.filesDir, "MiiRendering/FFLResHigh.dat") + private fun bodyFile(context: Context, body: BodyFile) = File(context.filesDir, "MiiRendering/${body.name}") + + /** Whether Miis can be drawn: the parts are here. */ fun installed(context: Context): Boolean = file(context).length() == FILE_BYTES + /** Whether Miis are drawn with their bodies, as on the PC: the parts and the bodies are here. */ + fun complete(context: Context): Boolean = + installed(context) && listOf(MALE_BODY, FEMALE_BODY).all { bodyFile(context, it).length() == it.bytes } + + /** How much [install] downloads: what is not here yet. */ + fun downloadBytes(context: Context): Long = + (if (installed(context)) 0L else ARCHIVE_BYTES) + + listOf(MALE_BODY, FEMALE_BODY).filter { bodyFile(context, it).length() != it.bytes }.sumOf { it.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 } @@ -61,9 +88,26 @@ object MiiRenderResource { } } + /** The bodies, read once; null when they are not installed or cannot be read. Not on the main thread. */ + fun bodies(context: Context): MiiBodies? { + loadedBodies?.let { return it.takeIf { it !== NO_BODIES } } + synchronized(this) { + loadedBodies?.let { return it.takeIf { it !== NO_BODIES } } + if (!complete(context)) return null + val bodies = try { + MiiBodies.load(bodyFile(context, MALE_BODY), bodyFile(context, FEMALE_BODY)) + } catch (e: IOException) { + Log.w(TAG, "Cannot read the Mii bodies", e) + NO_BODIES + } + loadedBodies = bodies + return bodies.takeIf { it !== NO_BODIES } + } + } + /** - * 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. + * Downloads and installs what is missing. [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 @@ -77,6 +121,8 @@ object MiiRenderResource { Log.w(TAG, "Cannot install the Mii parts", e) e.message ?: e.toString() } + // Read the bodies again, now that they may be here. + loadedBodies = null main.post { installing = false done(error) @@ -85,42 +131,79 @@ object MiiRenderResource { } private fun download(context: Context, progress: (Long) -> Unit) { - val target = file(context) - target.parentFile?.mkdirs() + var before = 0L + if (!installed(context)) { + retrying("Mii parts") { downloadParts(context) { bytes -> progress(before + bytes) } } + before += ARCHIVE_BYTES + } + for (body in listOf(MALE_BODY, FEMALE_BODY)) { + if (bodyFile(context, body).length() == body.bytes) continue + retrying("Mii body ${body.name}") { downloadBody(context, body) { bytes -> progress(before + bytes) } } + before += body.bytes + } + } + + private fun retrying(what: String, attempt: () -> Unit) { var failure: IOException? = null - for (attempt in 1..ATTEMPTS) { + for (number 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}") + attempt() return } catch (e: IOException) { - Log.w(TAG, "Mii parts download attempt $attempt/$ATTEMPTS failed: ${e.message}") + Log.w(TAG, "$what download attempt $number/$ATTEMPTS failed: ${e.message}") failure = e } } - throw failure ?: IOException("The Mii parts could not be downloaded.") + throw failure ?: IOException("The $what could not be downloaded.") } - private fun fetch(progress: (Long) -> Unit): ByteArray { - val connection = (URL(ARCHIVE_URL).openConnection() as HttpURLConnection).apply { + private fun downloadParts(context: Context, progress: (Long) -> Unit) { + val target = file(context) + target.parentFile?.mkdirs() + val archive = fetch(ARCHIVE_URL, "The Internet Archive", ARCHIVE_BYTES, progress) + if (archive.size < 4 || archive[0] != 'P'.code.toByte() || archive[1] != 'K'.code.toByte()) { + throw IOException("The download is not a ZIP archive.") + } + 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}") + } + + private fun downloadBody(context: Context, body: BodyFile, progress: (Long) -> Unit) { + val target = bodyFile(context, body) + target.parentFile?.mkdirs() + val bytes = fetch(BODY_URL + body.name, "GitHub", body.bytes, progress) + val digest = sha256(bytes) + if (digest != body.sha256) throw IOException("The downloaded Mii body is not the expected file (SHA-256 $digest).") + MiiBodies.parse(bytes) + val partial = File(target.parentFile, target.name + ".partial") + try { + partial.writeBytes(bytes) + if (!partial.renameTo(target)) throw IOException("Cannot store ${target.path}") + } finally { + partial.delete() + } + Log.i(TAG, "Installed the Mii body ${target.path}") + } + + private fun fetch(url: String, server: String, size: Long, progress: (Long) -> Unit): ByteArray { + val connection = (URL(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.") + if (code != HttpURLConnection.HTTP_OK) throw IOException("$server answered $code.") val expected = connection.contentLengthLong - val bytes = ByteArrayOutputStream(if (expected > 0) expected.toInt() else DOWNLOAD_BYTES.toInt()) + val bytes = ByteArrayOutputStream(if (expected > 0) expected.toInt() else size.toInt()) connection.inputStream.use { input -> val buffer = ByteArray(64 * 1024) var total = 0L @@ -138,16 +221,15 @@ object MiiRenderResource { 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 + return bytes.toByteArray() } finally { connection.disconnect() } } + private fun sha256(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + /** 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 -> 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 index df262b6..b81738e 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiRenderer.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiRenderer.kt @@ -14,39 +14,136 @@ 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. + * Draws a Mii 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. + * Pictures are the PC's face framing: the head, and below it the upper body in the Mii's + * favourite colour, from the 3DS bodies ([MiiBodies]) once they are downloaded; without them, + * the head alone (the PC's face_only). 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. */ + /** + * How the Mii is turned and seen, in degrees, as the PC's MiiImageSpecifications turn it: + * the Mii about its X, Y and Z axes, and the camera orbiting its head. + */ + class Pose( + val characterX: Float, + val characterY: Float, + val characterZ: Float, + val cameraX: Float, + val cameraY: Float, + val cameraZ: Float, + ) { + /** Tells pictures of the same Mii in different poses apart. */ + val key: String get() = "$characterX,$characterY,$characterZ,$cameraX,$cameraY,$cameraZ" + + companion object { + /** Straight ahead, as My Miis and the editor show a Mii. */ + val FRONT = Pose(0f, 0f, 0f, 0f, 0f, 0f) + + /** CurrentUserSideProfile and FriendsSideProfile: the profile page's and sidebar's three-quarter view. */ + val SIDE = Pose(350f, 15f, 355f, 12f, 0f, 0f) + } + } + + /** + * The Mii at [size] x [size], as non-premultiplied ARGB colours, transparent around it: the + * head, with the upper body below it when [bodies] are given. + */ @Throws(IOException::class) - fun render(resource: FflResource, mii: Mii, size: Int): IntArray = render(resource, FflCharInfo.of(mii), size) + fun render(resource: FflResource, mii: Mii, size: Int, pose: Pose = Pose.FRONT, bodies: MiiBodies? = null): IntArray = + render(resource, FflCharInfo.of(mii), size, pose = pose, bodies = bodies) @Throws(IOException::class) - internal fun render(resource: FflResource, info: FflCharInfo, size: Int, expression: Int = 0): IntArray { + internal fun render( + resource: FflResource, + info: FflCharInfo, + size: Int, + expression: Int = 0, + pose: Pose = Pose.FRONT, + bodies: MiiBodies? = null, + ): 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 body = bodies?.let { body(it, info) } 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. + // The PC's face view: 15 degrees of field of view on the head, the camera orbiting it + // (CalculateCameraOrbitPosition) and the head turned about its own origin. With a body + // the head sits on its shoulders, and the camera rises with it; the body turns about its feet. 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 camera = radians(pose.cameraX, pose.cameraY, pose.cameraZ) + val position = floatArrayOf( + z * -sin(camera[1]) * cos(camera[0]), + z * sin(camera[0]), + z * cos(camera[1]) * cos(camera[0]), + ) + position[1] += y + val lookAt = floatArrayOf(0f, y, 0f) + if (body != null) { + for (c in 0 until 3) { + position[c] += body.headTranslation[c] + lookAt[c] += body.headTranslation[c] + } + } + val up = floatArrayOf(sin(camera[2]), cos(camera[2]), 0f) + val view = Mat4.lookAt(position, lookAt, up) 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) } + val rotation = Mat4.rotation(radians(pose.characterX, pose.characterY, pose.characterZ)) + val meshes = ArrayList() + // The body first, so the head is drawn over the collar. + if (body != null) { + val bodyModel = rotation * body.scale + body.draws.mapNotNullTo(meshes) { prepare(it, size, size, bodyModel, view, projection) } + } + val headModel = if (body != null) rotation * Mat4.translation(body.headTranslation) else rotation + draws.mapNotNullTo(meshes) { prepare(it, size, size, headModel, view, projection) } drawAll(target, meshes, light = true, Blend.Over) return target.argb() } + /** [info]'s body (TryCreateBodyRenderData), or null when [bodies] has none for its gender. */ + private fun body(bodies: MiiBodies, info: FflCharInfo): Body? { + val meshes = if (Math.floorMod(info.gender, 2) == 1) bodies.female else bodies.male + if (meshes.isEmpty()) return null + // CalculateBodyScale: build widens the body, height stretches it. + val build = info.build.toFloat().coerceIn(0f, 127f) + val height = info.height.toFloat().coerceIn(0f, 127f) + val scaleX = (build * (height * 0.003671875f + 0.4f)) / 128.0f + height * 0.001796875f + 0.4f + val scaleY = height * 0.006015625f + 0.5f + val headTranslation = floatArrayOf(0f, MiiBodies.HEAD_Y * scaleY * MiiBodies.MODEL_SCALE, 0f) + val shirt = Colors.favorite(info.favoriteColor) + val draws = meshes.map { mesh -> + val count = mesh.positions.size / 3 + val type = if (mesh.pants) TYPE_PANTS else TYPE_BODY + Draw( + positions = mesh.positions, + texcoords = mesh.texcoords, + normals = mesh.normals, + tangents = FloatArray(count * 3), + // The PC's vertex parameters for the body: full specular and rim. + parameters = FloatArray(count * 4) { if (it % 4 == 2) 0f else 1f }, + indices = mesh.indices, + cull = CULL_BACK, + modulate = Modulate(0, type, r = if (mesh.pants) PANTS else shirt), + material = MATERIALS[type], + ) + } + return Body(draws, Mat4.scale(floatArrayOf(scaleX, scaleY, scaleX)), headTranslation) + } + + /** ConvertDegreesToRadians: each angle brought into -180..180 by an IEEE remainder first. */ + private fun radians(x: Float, y: Float, z: Float): FloatArray = + floatArrayOf(x, y, z).also { angles -> + for (i in angles.indices) angles[i] = Math.IEEEremainder(angles[i].toDouble(), 360.0).toFloat() * (Math.PI.toFloat() / 180f) + } + /** * 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. @@ -899,6 +996,9 @@ object MiiRenderer { /** 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) + /** A Mii's body (BodyRenderData): its meshes, their scale, and where the head sits on them. */ + private class Body(val draws: List, val scale: Mat4, val headTranslation: 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) @@ -977,6 +1077,8 @@ object MiiRenderer { private const val TYPE_MASK = 6 private const val TYPE_NOSELINE = 7 private const val TYPE_GLASS = 8 + private const val TYPE_BODY = 9 + private const val TYPE_PANTS = 10 // MiiLightingProfiles.Default on the PC. private const val PROFILE_AMBIENT = 0.71f @@ -990,6 +1092,8 @@ object MiiRenderer { 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) + /** The PC's trousers: the grey of an ordinary Mii's. */ + private val PANTS = floatArrayOf(0.2509804f, 0.2745099f, 0.30588239f, 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) @@ -1019,6 +1123,9 @@ object MiiRenderer { 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 body and the trousers. + material(floatArrayOf(0.95622f, 0.95622f, 0.95622f), 0.496733f, 0.2409f, 3.0f, false, 0.4f), + material(floatArrayOf(0.95622f, 0.95622f, 0.95622f), 1.084967f, 0.2409f, 3.0f, false, 0.4f), ) /** The PC multiplies its light colours into each material per pixel; the products are the same. */ @@ -1068,6 +1175,21 @@ internal class Mat4(val m: FloatArray) { companion object { val IDENTITY = Mat4(floatArrayOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f)) + /** Matrix4x4.CreateScale. */ + fun scale(scale: FloatArray) = Mat4(floatArrayOf(scale[0], 0f, 0f, 0f, 0f, scale[1], 0f, 0f, 0f, 0f, scale[2], 0f, 0f, 0f, 0f, 1f)) + + /** Matrix4x4.CreateTranslation. */ + fun translation(offset: FloatArray) = Mat4(floatArrayOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, offset[0], offset[1], offset[2], 1f)) + + /** CreateRotationMatrix on the PC: CreateRotationX, then Y, then Z, each by [radians]'s angle. */ + fun rotation(radians: FloatArray): Mat4 { + val (x, y, z) = radians.map { cos(it) to sin(it) } + val rotateX = Mat4(floatArrayOf(1f, 0f, 0f, 0f, 0f, x.first, x.second, 0f, 0f, -x.second, x.first, 0f, 0f, 0f, 0f, 1f)) + val rotateY = Mat4(floatArrayOf(y.first, 0f, -y.second, 0f, 0f, 1f, 0f, 0f, y.second, 0f, y.first, 0f, 0f, 0f, 0f, 1f)) + val rotateZ = Mat4(floatArrayOf(z.first, z.second, 0f, 0f, -z.second, z.first, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f)) + return rotateX * rotateY * rotateZ + } + /** Matrix4x4.CreateLookAt (right-handed). */ fun lookAt(position: FloatArray, target: FloatArray, up: FloatArray): Mat4 { val axisZ = FloatArray(3) @@ -1192,6 +1314,7 @@ internal object Colors { internal class FflCharInfo(studio: IntArray) { val beardColor = studio[0] or COMMON_COLOR val beardType = studio[1] + val build = studio[2] val eyeScaleY = studio[3] val eyeColor = studio[4] or COMMON_COLOR val eyeRotate = studio[5] @@ -1211,6 +1334,7 @@ internal class FflCharInfo(studio: IntArray) { val faceType = studio[19] val faceLine = studio[20] val favoriteColor = studio[21] + val gender = studio[22] val glassColor = studio[23] or COMMON_COLOR val glassScale = studio[24] val glassType = studio[25] @@ -1218,6 +1342,7 @@ internal class FflCharInfo(studio: IntArray) { val hairColor = studio[27] or COMMON_COLOR val hairDir = studio[28] val hairType = studio[29] + val height = studio[30] val moleScale = studio[31] val moleType = studio[32] val molePositionX = studio[33] 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 index da8a135..76aacb6 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/MiisPage.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiisPage.kt @@ -443,28 +443,33 @@ class MiisPage( // --- The Mii parts --- + /** The download offer: the parts and bodies, or only the bodies once the parts are here. */ private fun showParts() { - if (MiiRenderResource.installed(activity)) { + if (MiiRenderResource.complete(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)) + val message = if (MiiRenderResource.installed(activity)) R.string.miis_bodies_message else R.string.miis_parts_message + partsText.text = activity.getString(message, Formatter.formatShortFileSize(activity, MiiRenderResource.downloadBytes(activity))) } } private fun downloadParts() { partsDownload.isEnabled = false - partsText.text = activity.getString(R.string.miis_parts_connecting) + val total = MiiRenderResource.downloadBytes(activity) + partsText.text = activity.getString( + if (MiiRenderResource.installed(activity)) R.string.miis_bodies_connecting else 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), + Formatter.formatShortFileSize(activity, total), ) }, ) { error -> @@ -497,8 +502,8 @@ class MiisPage( 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 + /** The list's pictures, at the 109 dp they are shown at: larger than the tile, which crops them. */ + const val TILE_PICTURE = 136 /** 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/ProfileStore.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfileStore.kt index 2104612..1818270 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfileStore.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfileStore.kt @@ -14,19 +14,25 @@ import org.wiicompiled.quest.GameStorage import org.wiicompiled.quest.R /** - * What the profiles page and the sidebar's profile card show: the licences of Retro Rewind's save, - * which one is primary, and what Retro WFC knows of them (their Mii pictures, who is in a room - * now, their VR history), plus WheelWizard's badges. Files are read and the network asked off the - * main thread, and every answer arrives on it. Mii pictures are also kept on disk, so the sidebar - * shows them at once, even offline. + * What the profiles page and the sidebar's profile card show: the licences of Retro Rewind's save + * and their Miis, which one is primary, and what Retro WFC knows of them (the Miis they last played + * with, who is in a room now, their VR history), plus WheelWizard's badges. Files are read and the + * network asked off the main thread, and every answer arrives on it. What Retro WFC saw of a Mii is + * also kept on disk, so the sidebar shows it at once, even offline. */ object ProfileStore { - /** The four licence slots of the save, an empty one null. */ - data class Snapshot(val licenses: List) { + /** + * The four licence slots of the save, an empty one null, and the licences' Miis the headset's + * Mii database holds, by ID. + */ + data class Snapshot(val licenses: List, val miis: Map = emptyMap()) { val any: Boolean get() = licenses.any { it != null } } + /** What Retro WFC last saw of a profile's Mii: the Mii, to draw it, and its own 64-pixel picture of it. */ + private class Remote(val mii: Mii?, val picture: Bitmap?, val data: ByteArray?, val image: ByteArray?) + private const val TAG = "WiiCompiledLauncher" // LauncherActivity's preferences file, where the launcher keeps its other choices. private const val PREFERENCES = "launcher" @@ -41,9 +47,9 @@ object ProfileStore { private val main by lazy { Handler(Looper.getMainLooper()) } // Main thread only. - private val images = HashMap() - private val imagesAsked = HashSet() - private val waiting = HashMap Unit>>() + private val remotes = HashMap() + private val remotesAsked = HashSet() + private val waiting = HashMap Unit>>() private var online: Set = emptySet() private var onlineAt = 0L private var badges: Map>? = null @@ -64,7 +70,15 @@ object ProfileStore { } catch (e: IOException) { emptyMap() } - return RksysProfiles.parse(save, ratings)?.let(::Snapshot) + val licenses = RksysProfiles.parse(save, ratings) ?: return null + // GameLicenseService.ParseMiiData: a licence's Mii is looked up in the Mii database by ID. + val ids = licenses.mapNotNull { it?.miiId }.toSet() + val miis = try { + MiiDatabase.file(GameStorage.nandDirectory(context)).takeIf { it.isFile }?.let(MiiDatabase::byId).orEmpty() + } catch (e: IOException) { + emptyMap() + } + return Snapshot(licenses, miis.filterKeys { it in ids }) } /** GameLicenseService's FOCUSED_USER: the licence the sidebar shows and the page opens on. */ @@ -84,60 +98,118 @@ object ProfileStore { snapshot.licenses.getOrNull(primarySlot(context)) ?: snapshot.licenses.firstOrNull { it != null } /** - * The Mii picture Retro WFC keeps for [friendCode]. [done] gets the one already known at once - * (from memory or disk), then Retro WFC's own the first time this session it differs, or null - * when there is none at all. A licence never taken online has no friend code, and no picture. + * [license]'s Mii, [size] pixels square and turned as the PC's profile page and sidebar show it. + * Like the PC, the Mii is the one of the headset's Mii database (the game's own, which My Miis + * edits) with the licence's ID. A licence whose Mii is not there shows the Mii Retro WFC last + * saw it play with, while that is still the licence's Mii. Both are drawn from the Mii parts, + * with their upper bodies, once My Miis downloaded them; until then Retro WFC's 64-pixel + * picture stands in. [done] can be called twice: with what is known at once, then with what + * Retro WFC answers. */ - fun miiImage(context: Context, friendCode: String, done: (Bitmap?) -> Unit) { + fun miiPicture( + context: Context, + snapshot: Snapshot, + license: RksysProfiles.License, + size: Int, + done: (Bitmap?) -> Unit, + ) { + val drawable = MiiRenderResource.installed(context) + val pose = MiiRenderer.Pose.SIDE + val local = snapshot.miis[license.miiId] + if (local != null && drawable) { + MiiImages.picture(context, local, size, pose, done) + return + } + val friendCode = license.friendCode + remote(context, friendCode) { remote -> + // What Retro WFC saw is of another Mii once the licence took a new one. + val stale = remote?.mii != null && remote.mii.miiId != license.miiId + when { + remote == null || stale -> done(null) + remote.mii != null && drawable -> MiiImages.picture(context, remote.mii, size, pose) { bitmap -> + // A newer answer may have arrived while this one was drawn. + if (remotes[friendCode] === remote) done(bitmap ?: remote.picture) + } + else -> done(remote.picture) + } + } + } + + /** + * What Retro WFC last saw of [friendCode]'s Mii. [done] gets what is already known at once (from + * memory or disk), then Retro WFC's answer the first time this session it differs, or null when + * there is nothing at all. A licence never taken online has no friend code, and nothing here. + */ + private fun remote(context: Context, friendCode: String, done: (Remote?) -> Unit) { if (friendCode.isEmpty()) { done(null) return } - val known = images[friendCode] + val known = remotes[friendCode] if (known != null) done(known) // A fetch already on its way answers everyone who asked meanwhile. waiting[friendCode]?.let { it += done return } - if (!imagesAsked.add(friendCode)) { + if (!remotesAsked.add(friendCode)) { if (known == null) done(null) return } waiting[friendCode] = mutableListOf(done) - val file = File(File(context.applicationContext.cacheDir, IMAGE_DIRECTORY), "$friendCode.png") + val directory = File(context.applicationContext.cacheDir, IMAGE_DIRECTORY) + val imageFile = File(directory, "$friendCode.png") + val dataFile = File(directory, "$friendCode.mii") worker.execute { - val cached = if (file.isFile) runCatching { file.readBytes() }.getOrNull() else null - if (known == null) cached?.let(::decode)?.let { bitmap -> main.post { remember(friendCode, bitmap, finished = false) } } + val cached = remoteOf(read(dataFile), read(imageFile)) + if (known == null && cached != null) main.post { remember(friendCode, cached, finished = false) } val fresh = try { - RetroWfc.miiImage(friendCode) + RetroWfc.playerMii(friendCode) } catch (e: IOException) { - Log.i(TAG, "No Mii picture for $friendCode from Retro WFC: ${e.message}") + Log.i(TAG, "No Mii for $friendCode from Retro WFC: ${e.message}") null } - val bitmap = fresh?.takeIf { !it.contentEquals(cached ?: byteArrayOf()) }?.let(::decode) - if (fresh != null && bitmap != null) { + val changed = fresh != null && ( + !(fresh.data ?: NONE).contentEquals(cached?.data ?: NONE) || !(fresh.image ?: NONE).contentEquals(cached?.image ?: NONE) + ) + val remote = if (fresh != null && changed) remoteOf(fresh.data, fresh.image) else null + if (fresh != null && changed) { runCatching { - file.parentFile?.mkdirs() - file.writeBytes(fresh) + directory.mkdirs() + store(dataFile, fresh.data) + store(imageFile, fresh.image) } } - main.post { remember(friendCode, bitmap, finished = true) } + main.post { remember(friendCode, remote, finished = true) } } } - /** Hands a picture to everyone waiting for [friendCode]; the last answer also tells those with none. */ - private fun remember(friendCode: String, bitmap: Bitmap?, finished: Boolean) { - if (bitmap != null) images[friendCode] = bitmap + private fun remoteOf(data: ByteArray?, image: ByteArray?): Remote? { + val mii = data?.let { runCatching { MiiData.parse(it) }.getOrNull() } + val picture = image?.let(::decode) + return if (mii == null && picture == null) null else Remote(mii, picture, data, image) + } + + private fun read(file: File): ByteArray? = if (file.isFile) runCatching { file.readBytes() }.getOrNull() else null + + private fun store(file: File, bytes: ByteArray?) { + if (bytes == null) file.delete() else file.writeBytes(bytes) + } + + /** Hands what Retro WFC saw to everyone waiting for [friendCode]; the last answer also tells those with none. */ + private fun remember(friendCode: String, remote: Remote?, finished: Boolean) { + if (remote != null) remotes[friendCode] = remote val callbacks = (if (finished) waiting.remove(friendCode) else waiting[friendCode]?.toList()).orEmpty() when { - bitmap != null -> callbacks.forEach { it(bitmap) } - finished && images[friendCode] == null -> callbacks.forEach { it(null) } + remote != null -> callbacks.forEach { it(remote) } + finished && remotes[friendCode] == null -> callbacks.forEach { it(null) } } } private fun decode(bytes: ByteArray): Bitmap? = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + private val NONE = ByteArray(0) + /** The friend codes in a room now, asked again when the last answer is old; the last known ones when that fails. */ fun online(done: (Set) -> Unit) { if (onlineAt != 0L && SystemClock.elapsedRealtime() - onlineAt < ONLINE_REFRESH_MS) { diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfilesPage.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfilesPage.kt index 781c14c..191182c 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfilesPage.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfilesPage.kt @@ -34,9 +34,10 @@ import org.wiicompiled.quest.R * licence is Online, with a glow, while its friend code is in a Retro WFC room, and one licence is * primary: the one the sidebar's card shows. * - * It only reads. The headset has no Mii database, so a licence's Mii is the picture Retro WFC keeps - * of it (a licence never taken online has none), and nothing renames a licence or changes its Mii. - * The PC's region picker is left out too, since the Quest only runs PAL. + * A licence's Mii is drawn as the PC draws it, from the headset's Mii database, or else from the Mii + * Retro WFC last saw the licence play with (ProfileStore.miiPicture). It only reads: nothing renames + * a licence or changes its Mii. The PC's region picker is left out too, since the Quest only runs + * PAL. */ class ProfilesPage( private val activity: Activity, @@ -218,15 +219,18 @@ class ProfilesPage( loadHistory(license) } + /** The licence's Mii, drawn at the size it is shown at (UserProfilePage's CurrentUserSideProfile). */ private fun showMii(license: RksysProfiles.License) { - val friendCode = license.friendCode - if (mii.tag != friendCode) { - mii.tag = friendCode + val loaded = snapshot ?: return + val key = "${license.slot}:${license.miiId}:${license.friendCode}" + if (mii.tag != key) { + mii.tag = key mii.setImageDrawable(null) placeholder.visibility = View.VISIBLE } - ProfileStore.miiImage(activity, friendCode) { bitmap -> - if (mii.tag != friendCode) return@miiImage + val size = (PICTURE_DP * activity.resources.displayMetrics.density).toInt() and 1.inv() + ProfileStore.miiPicture(activity, loaded, license, size) { bitmap -> + if (mii.tag != key) return@miiPicture mii.setImageBitmap(bitmap) placeholder.visibility = if (bitmap == null) View.VISIBLE else View.GONE } @@ -407,6 +411,8 @@ class ProfilesPage( private fun dp(value: Int): Int = PatchesWidgets.dp(activity, value) private companion object { + /** The Mii picture's frame in page_profiles.xml. */ + const val PICTURE_DP = 320 const val CAROUSEL_PAGES = 2 const val DEFAULT_DAYS = 30 /** What VrHistoryGraph asks for "Lifetime". */ diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/RetroWfc.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/RetroWfc.kt index 4cf7a65..46d6f68 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/RetroWfc.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/RetroWfc.kt @@ -68,6 +68,22 @@ object RetroWfc { // Parsing + /** What Retro WFC last saw of a profile's Mii: its 74 bytes, and its own picture of it. */ + class PlayerMii(val data: ByteArray?, val image: ByteArray?) + + /** + * The 74 bytes of the Mii a profile last played with (the Wii's RFLCharData, as the game sent + * it), or null without one. + */ + fun parseMiiData(json: String): ByteArray? = parsing { + val encoded = JSONObject(json).text("miiData") ?: return@parsing null + try { + Base64.getMimeDecoder().decode(encoded).takeIf { it.size >= MiiData.SIZE }?.copyOf(MiiData.SIZE) + } catch (e: IllegalArgumentException) { + null + } + } + /** The PNG of the player's Mii a profile carries (64 by 64 pixels), or null without one. */ fun parseMiiImage(json: String): ByteArray? = parsing { val encoded = JSONObject(json).text("miiImageBase64") ?: return@parsing null @@ -139,8 +155,9 @@ object RetroWfc { // Network; everything below blocks, so callers run it off the main thread. - /** The Mii picture of [friendCode]'s profile, or null when Retro WFC has never seen it. */ - fun miiImage(friendCode: String): ByteArray? = get(profileUrl(friendCode), missingIsNull = true)?.let(::parseMiiImage) + /** The Mii of [friendCode]'s profile, or null when Retro WFC has never seen it. */ + fun playerMii(friendCode: String): PlayerMii? = + get(profileUrl(friendCode), missingIsNull = true)?.let { json -> PlayerMii(parseMiiData(json), parseMiiImage(json)) } fun history(friendCode: String, days: Int): History { val json = get(historyUrl(friendCode, days), missingIsNull = true) diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/RksysProfiles.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/RksysProfiles.kt index 7bb0bd2..215bcec 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/RksysProfiles.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/RksysProfiles.kt @@ -8,8 +8,8 @@ import java.security.MessageDigest /** * The licences of a Mario Kart Wii save, read as WheelWizard's GameLicenseService reads rksys.dat * (after kazuki-4ys' FaceThief and https://wiki.tockdom.com/wiki/Rksys.dat): after the RKSD0006 - * magic come four RKPD blocks, one per licence, each holding its Mii's name, the profile ID its - * friend code derives from, its VR and BR, and race counts. Retro Rewind keeps the ratings it plays + * magic come four RKPD blocks, one per licence, each holding its Mii's name and ID, the profile ID + * its friend code derives from, its VR and BR, and race counts. Retro Rewind keeps the ratings it plays * with in Pulsar's RRRating.pul, which outranks the save's own when it knows the profile. * * Nothing here writes a save: the profiles page only shows what the game recorded. @@ -26,6 +26,8 @@ object RksysProfiles { private const val NAME_OFFSET = 0x14 private const val NAME_CHARS = 10 + /** The Mii's ID in the Mii database, which the PC calls its avatar ID. */ + private const val MII_ID_OFFSET = 0x28 private const val PROFILE_ID_OFFSET = 0x5C private const val VR_OFFSET = 0xB0 private const val BR_OFFSET = 0xB2 @@ -51,6 +53,8 @@ object RksysProfiles { val br: Int, val races: Long, val wins: Long, + /** The licence's Mii in the Mii database; a guest Mii's (0x80000001 and on) is in none. */ + val miiId: Long = 0, ) /** A rating from RRRating.pul, already as the game shows it (60.50 is 6050). */ @@ -76,6 +80,7 @@ object RksysProfiles { br = rating?.br ?: u16(save, base + BR_OFFSET), races = u32(save, base + RACES_OFFSET), wins = u32(save, base + WINS_OFFSET), + miiId = u32(save, base + MII_ID_OFFSET), ) } } diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/SidebarProfileCard.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/SidebarProfileCard.kt index 979c7e6..830b763 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/SidebarProfileCard.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/SidebarProfileCard.kt @@ -29,20 +29,27 @@ class SidebarProfileCard(private val activity: Activity, private val card: View, if (activity.isDestroyed) return val license = snapshot?.let { ProfileStore.sidebarLicense(activity, it) } card.visibility = if (license == null) View.GONE else View.VISIBLE - if (license == null) return + if (snapshot == null || license == null) return name.text = ProfileStore.displayName(activity, license) code.text = license.friendCode code.visibility = if (license.friendCode.isEmpty()) View.GONE else View.VISIBLE - val friendCode = license.friendCode - if (mii.tag != friendCode) { - mii.tag = friendCode + val key = "${license.slot}:${license.miiId}:${license.friendCode}" + if (mii.tag != key) { + mii.tag = key mii.setImageDrawable(null) placeholder.visibility = View.VISIBLE } - ProfileStore.miiImage(activity, friendCode) { bitmap -> - if (mii.tag != friendCode) return@miiImage + // FriendsSideProfile on the PC, drawn at twice its pixels for smooth edges. + val size = (2 * PICTURE_DP * activity.resources.displayMetrics.density).toInt() and 1.inv() + ProfileStore.miiPicture(activity, snapshot, license, size) { bitmap -> + if (mii.tag != key) return@miiPicture mii.setImageBitmap(bitmap) placeholder.visibility = if (bitmap == null) View.VISIBLE else View.GONE } } + + private companion object { + /** The Mii picture's size in activity_launcher.xml. */ + const val PICTURE_DP = 80 + } } diff --git a/android/app/src/main/res/layout/activity_launcher.xml b/android/app/src/main/res/layout/activity_launcher.xml index 6ea047b..02bc462 100644 --- a/android/app/src/main/res/layout/activity_launcher.xml +++ b/android/app/src/main/res/layout/activity_launcher.xml @@ -9,6 +9,8 @@ - - + - - - - - - + - + + + - + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index fa7cb96..baa4041 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -181,9 +181,11 @@ 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. + Mii pictures are drawn from FFL\'s Mii parts and the 3DS Mii bodies (%1$s), the files Wheel Wizard uses for the same reason: the parts from a copy of Miitomo\'s kept by the Internet Archive, the bodies from Wheel Wizard\'s own repository. They are downloaded once and stay on this headset. + Wheel Wizard draws each Mii with its upper body, from the 3DS Mii bodies in its own repository (%1$s). Download them once to see Miis the same way here. Download Connecting to the Internet Archive… + Connecting to GitHub… Downloading the Mii parts: %1$s of %2$s The Mii parts could not be downloaded: %1$s New Mii @@ -342,7 +344,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. + 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, and the 3DS Mii bodies, which you download from Wheel Wizard\'s repository. 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. diff --git a/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiBodiesTest.kt b/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiBodiesTest.kt new file mode 100644 index 0000000..554b073 --- /dev/null +++ b/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiBodiesTest.kt @@ -0,0 +1,106 @@ +package org.wiicompiled.quest.launcher + +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +/** One mesh of a made-up rio model: its vertices (x, y, z, all facing +Z), triangles and own placement. */ +internal class RioMesh( + val vertices: List, + val indices: IntArray, + val scale: FloatArray = floatArrayOf(1f, 1f, 1f), + val rotate: FloatArray = FloatArray(3), + val translate: FloatArray = FloatArray(3), +) + +/** The rectangle [left]..[right] by [bottom]..[top] at depth [z], facing +Z. */ +internal fun quad(left: Float, right: Float, bottom: Float, top: Float, z: Float = 0f) = RioMesh( + listOf(floatArrayOf(left, top, z), floatArrayOf(right, top, z), floatArrayOf(left, bottom, z), floatArrayOf(right, bottom, z)), + // Counter-clockwise on screen, so back-face culling keeps them. + intArrayOf(0, 2, 1, 1, 2, 3), +) + +/** + * A little-endian "riomodel" holding [meshes], laid out as the PC's reader expects: the header, + * the 0x38-byte mesh records, then each mesh's 0x20-byte vertices and 32-bit indices. The real + * bodies are Nintendo's and never in this repository. + */ +internal fun rioModel(vararg meshes: RioMesh): ByteArray { + val records = 0x20 + var size = records + meshes.size * 0x38 + for (mesh in meshes) size += mesh.vertices.size * 0x20 + mesh.indices.size * 4 + val buffer = ByteBuffer.allocate(size).order(ByteOrder.LITTLE_ENDIAN) + buffer.put("riomodel".toByteArray(Charsets.US_ASCII)) + buffer.putInt(0x08, 1) + buffer.putInt(0x0C, size) + // The mesh list, from the field's own offset. + buffer.putInt(0x10, records - 0x10) + buffer.putInt(0x14, meshes.size) + var data = records + meshes.size * 0x38 + for ((index, mesh) in meshes.withIndex()) { + val record = records + index * 0x38 + buffer.putInt(record, data - record) + buffer.putInt(record + 0x04, mesh.vertices.size) + for ((i, vertex) in mesh.vertices.withIndex()) { + val at = data + i * 0x20 + for (c in 0 until 3) buffer.putFloat(at + c * 4, vertex[c]) + buffer.putFloat(at + 0x1C, 1f) + } + data += mesh.vertices.size * 0x20 + buffer.putInt(record + 0x08, data - (record + 0x08)) + buffer.putInt(record + 0x0C, mesh.indices.size) + for ((i, value) in mesh.indices.withIndex()) buffer.putInt(data + i * 4, value) + data += mesh.indices.size * 4 + for (c in 0 until 3) { + buffer.putFloat(record + 0x10 + c * 4, mesh.scale[c]) + buffer.putFloat(record + 0x1C + c * 4, mesh.rotate[c]) + buffer.putFloat(record + 0x28 + c * 4, mesh.translate[c]) + } + } + return buffer.array() +} + +/** The body models are read as the PC's TryReadRioModelBytes reads them. */ +class MiiBodiesTest { + + @Test + fun readsMeshesPlacedAndTheOddOnesAsTrousers() { + val shirt = quad(-1f, 1f, 0f, 2f).let { + RioMesh(it.vertices, it.indices, scale = floatArrayOf(2f, 3f, 1f), translate = floatArrayOf(0f, 10f, 0f)) + } + val turned = quad(-1f, 1f, 0f, 2f).let { RioMesh(it.vertices, it.indices, rotate = floatArrayOf(0f, (Math.PI / 2).toFloat(), 0f)) } + val empty = RioMesh(emptyList(), IntArray(0)) + val meshes = MiiBodies.parse(rioModel(shirt, quad(-1f, 1f, -2f, 0f), empty, turned)) + + // The empty mesh is left out, but still counts: the last one is odd, so trousers. + assertEquals(listOf(false, true, true), meshes.map { it.pants }) + // Scaled, then moved: the top left corner (-1, 2) is at (-2, 16). + assertArrayEquals(floatArrayOf(-2f, 16f, 0f), meshes[0].positions.copyOfRange(0, 3), 1e-6f) + assertArrayEquals(intArrayOf(0, 2, 1, 1, 2, 3), meshes[0].indices) + assertArrayEquals(FloatArray(8), meshes[0].texcoords, 0f) + // A normal turns with its mesh but never moves: +Z turned a quarter about Y is +X. + assertArrayEquals(floatArrayOf(0f, 0f, 1f), meshes[0].normals.copyOfRange(0, 3), 1e-6f) + assertArrayEquals(floatArrayOf(1f, 0f, 0f), meshes[2].normals.copyOfRange(0, 3), 1e-6f) + } + + @Test + fun refusesWhatIsNotABodyModel() { + val good = rioModel(quad(-1f, 1f, 0f, 2f)) + MiiBodies.parse(good) + assertThrows(IOException::class.java) { MiiBodies.parse(good.copyOf().also { it[0] = 'X'.code.toByte() }) } + // Not the size its header says. + assertThrows(IOException::class.java) { MiiBodies.parse(good + 0.toByte()) } + // A triangle naming a fifth vertex of four. + assertThrows(IOException::class.java) { MiiBodies.parse(rioModel(RioMesh(quad(-1f, 1f, 0f, 2f).vertices, intArrayOf(0, 1, 4)))) } + // Nothing drawable at all. + assertThrows(IOException::class.java) { MiiBodies.parse(rioModel(RioMesh(emptyList(), IntArray(0)))) } + // More meshes than the file holds. + assertThrows(IOException::class.java) { + MiiBodies.parse(good.copyOf().also { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).putInt(0x14, 40) }) + } + } +} 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 index ac36a77..f1abb0f 100644 --- a/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiDatabaseTest.kt +++ b/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiDatabaseTest.kt @@ -105,6 +105,21 @@ class MiiDatabaseTest { assertEquals(1, file.readBytes()[0x100].toInt()) } + @Test + fun findsMiisByIdAsThePcDoes() { + val file = newDatabase() + MiiDatabase.add(file, mii("First", 0x80000001L)) + MiiDatabase.add(file, mii("Second", 0x80000002L)) + val db = file.readBytes() + // A later slot with the same ID loses to the first, as GetByAvatarId finds it. + MiiData.serialize(mii("Later", 0x80000001L)).copyInto(db, 4 + 2 * MiiData.SIZE) + file.writeBytes(db) + val byId = MiiDatabase.byId(file) + assertEquals(setOf(0x80000001L, 0x80000002L), byId.keys) + assertEquals("First", byId[0x80000001L]!!.name) + assertEquals("Second", byId[0x80000002L]!!.name) + } + @Test fun listsOnlyTheSlotsItCanRead() { val file = newDatabase() 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 index 464400b..c958016 100644 --- a/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiRendererTest.kt +++ b/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiRendererTest.kt @@ -3,6 +3,7 @@ package org.wiicompiled.quest.launcher import java.io.ByteArrayOutputStream import java.nio.ByteBuffer import java.util.zip.Deflater +import kotlin.math.abs import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -126,6 +127,66 @@ class MiiRendererTest { assertTrue("covered $covered", covered in 100..200) } + @Test + fun posesTurnTheHead() { + val mii = MiiFactory.male("Square").apply { faceShape = 5 } + val front = MiiRenderer.render(resource(), mii, 64) + val side = MiiRenderer.render(resource(), mii, 64, MiiRenderer.Pose.SIDE) + fun covered(pixels: IntArray) = pixels.count { it ushr 24 == 0xFF } + // Turned away by 15 degrees and seen from above, the square covers other pixels, and less. + assertTrue(!side.contentEquals(front)) + assertTrue("side ${covered(side)} front ${covered(front)}", covered(side) < covered(front)) + // The front pose is the one My Miis draws without a pose. + assertTrue(MiiRenderer.render(resource(), mii, 64, MiiRenderer.Pose.FRONT).contentEquals(front)) + } + + /** Made-up bodies: a boy's shirt on the left and trousers on the right, both below the head; no girl's body. */ + private fun bodies() = MiiBodies( + male = MiiBodies.parse(rioModel(quad(-15f, 0f, 40f, 76f), quad(0f, 15f, 40f, 76f))), + female = emptyList(), + ) + + private fun opaqueIn(pixels: IntArray, rows: IntRange) = rows.sumOf { y -> (0 until 64).count { x -> pixels[y * 64 + x] ushr 24 == 0xFF } } + + @Test + fun drawsTheUpperBodyBelowTheHeadInItsColours() { + val mii = MiiFactory.male("Square").apply { + faceShape = 5 + favoriteColor = 5 + } + val alone = MiiRenderer.render(resource(), mii, 64) + val dressed = MiiRenderer.render(resource(), mii, 64, bodies = bodies()) + // The camera rises with the head onto the shoulders, so the head is where it was... + assertEquals(opaqueIn(alone, 0 until 48), opaqueIn(dressed, 0 until 48)) + assertEquals(0xFF, dressed[32 * 64 + 32] ushr 24) + // ...and below it, where there was nothing, is the body. + assertEquals(0, alone[60 * 64 + 28] ushr 24) + fun channels(pixel: Int) = Triple((pixel shr 16) and 0xFF, (pixel shr 8) and 0xFF, pixel and 0xFF) + // The shirt in the favourite colour, blue... + val (shirtRed, _, shirtBlue) = channels(dressed[60 * 64 + 28]) + assertEquals(0xFF, dressed[60 * 64 + 28] ushr 24) + assertTrue("shirt $shirtRed vs $shirtBlue", shirtBlue > shirtRed + 60) + // ...and the trousers grey. + val (pantsRed, _, pantsBlue) = channels(dressed[60 * 64 + 36]) + assertEquals(0xFF, dressed[60 * 64 + 36] ushr 24) + assertTrue("trousers $pantsRed vs $pantsBlue", abs(pantsBlue - pantsRed) < 30) + } + + @Test + fun theBodyFollowsBuildAndGender() { + val thin = MiiFactory.male("Thin").apply { + faceShape = 5 + weight = 0 + } + val heavy = thin.copy(weight = 127) + val rows = 50 until 64 + assertTrue(opaqueIn(MiiRenderer.render(resource(), heavy, 64, bodies = bodies()), rows) > + opaqueIn(MiiRenderer.render(resource(), thin, 64, bodies = bodies()), rows)) + // These bodies have none for a girl, who is drawn as without bodies at all. + val girl = MiiFactory.female("Girl").apply { faceShape = 5 } + assertTrue(MiiRenderer.render(resource(), girl, 64, bodies = bodies()).contentEquals(MiiRenderer.render(resource(), girl, 64))) + } + @Test fun aMiiWithoutItsFacelineCannotBeDrawn() { val mii = MiiFactory.male("Missing").apply { faceShape = 1 } diff --git a/android/app/src/test/java/org/wiicompiled/quest/launcher/RetroWfcTest.kt b/android/app/src/test/java/org/wiicompiled/quest/launcher/RetroWfcTest.kt index 459a012..2f16259 100644 --- a/android/app/src/test/java/org/wiicompiled/quest/launcher/RetroWfcTest.kt +++ b/android/app/src/test/java/org/wiicompiled/quest/launcher/RetroWfcTest.kt @@ -70,6 +70,19 @@ class RetroWfcTest { assertNull(RetroWfc.parseMiiImage("""{"pid": "1", "miiImageBase64": "***"}""")) } + @Test + fun aProfileCarriesItsMii() { + // The Mii Retro WFC keeps is the game's 74-byte one: here a Mii made in My Miis. + val mii = MiiFactory.female("Quest").apply { miiId = 0x89BF58D1L; systemId = 0xC8C6363CL } + val data = MiiData.serialize(mii) + val json = """{"pid": "601298307", "miiData": "${Base64.getEncoder().encodeToString(data)}"}""" + assertArrayEquals(data, RetroWfc.parseMiiData(json)) + assertEquals(mii, MiiData.parse(RetroWfc.parseMiiData(json)!!)) + assertNull(RetroWfc.parseMiiData("""{"pid": "1", "miiData": null}""")) + assertNull(RetroWfc.parseMiiData("""{"pid": "1", "miiData": "AAEC"}""")) + assertNull(RetroWfc.parseMiiData("""{"pid": "1"}""")) + } + @Test fun requestsAreTheOnesWheelWizardMakes() { assertEquals("https://rwfc.net/api/leaderboard/player/0349-6103-6675", RetroWfc.profileUrl("0349-6103-6675")) diff --git a/android/app/src/test/java/org/wiicompiled/quest/launcher/RksysProfilesTest.kt b/android/app/src/test/java/org/wiicompiled/quest/launcher/RksysProfilesTest.kt index 80a0d3b..0052311 100644 --- a/android/app/src/test/java/org/wiicompiled/quest/launcher/RksysProfilesTest.kt +++ b/android/app/src/test/java/org/wiicompiled/quest/launcher/RksysProfilesTest.kt @@ -41,21 +41,27 @@ class RksysProfilesTest { val bytes = save( 0 to { buffer: ByteBuffer, base: Int -> buffer.name(base, "Mario") + // The Mii: the headset's own, made in My Miis. + buffer.putInt(base + 0x28, 0x89BF58D1.toInt()) buffer.putInt(base + 0x5C, 0x23D71583) buffer.putShort(base + 0xB0, 5793.toShort()) buffer.putShort(base + 0xB2, 5052.toShort()) buffer.putInt(base + 0xB4, 1062) buffer.putInt(base + 0xDC, 416) }, - 2 to { buffer: ByteBuffer, base: Int -> buffer.name(base, "no name") }, + 2 to { buffer: ByteBuffer, base: Int -> + buffer.name(base, "no name") + // A guest Mii. + buffer.putInt(base + 0x28, 0x80000001.toInt()) + }, ) val licenses = RksysProfiles.parse(bytes)!! assertEquals(4, licenses.size) assertNull(licenses[1]) assertNull(licenses[3]) - assertEquals(RksysProfiles.License(0, "Mario", 0x23D71583L, "0349-6103-6675", 5793, 5052, 1062, 416), licenses[0]) + assertEquals(RksysProfiles.License(0, "Mario", 0x23D71583L, "0349-6103-6675", 5793, 5052, 1062, 416, 0x89BF58D1L), licenses[0]) // A licence never taken online has no profile ID, so no friend code. - assertEquals(RksysProfiles.License(2, "no name", 0, "", 0, 0, 0, 0), licenses[2]) + assertEquals(RksysProfiles.License(2, "no name", 0, "", 0, 0, 0, 0, 0x80000001L), licenses[2]) } @Test diff --git a/docs/quest-port.md b/docs/quest-port.md index 6042cb6..dee264c 100644 --- a/docs/quest-port.md +++ b/docs/quest-port.md @@ -229,11 +229,16 @@ and race counts, the VR and BR replaced by Pulsar's `RRRating.pul` in the NAND w knows the profile, as the PC's `RRratingReader` does. Retro WFC's public API gives the 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. 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 +comes from `/api/leaderboard/player//history?days=N`, and +`/api/leaderboard/player/` gives the Mii it last played with, as the 74-byte +`miiData` and a 64-pixel PNG, kept on disk. The Mii is drawn as the PC draws it, +turned three-quarters (`CurrentUserSideProfile`) by My Miis' renderer (below): the +Mii of the headset's Mii database with the licence's avatar ID (RKPD `+0x28`), as the +PC looks it up, so a Mii made in My Miis shows once a licence takes it; else Retro +WFC's `miiData` while its ID is still the licence's. It is 320 dp on the page, and +80 dp in the sidebar, where, as on the PC, it stands on the card's bottom edge and +rises out of it. Until the Mii parts are downloaded Retro WFC's PNG stands in, and +a licence with neither 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` @@ -257,21 +262,26 @@ 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 +`FflResource`, `MiiBodies`), framed as the PC's `face` pictures: the head, and below +it the upper body in the Mii's favourite colour, sized by its height and build. It +draws from FFL's Mii parts (`FFLResHigh.dat`) and the 3DS body models the PC carries +in its assembly (`mii_static_body_3ds_{male,female}_LE.rmdl`), all Nintendo's and +never in the APK: the page downloads the parts once from the Internet Archive's copy +of Miitomo's `AFLResHigh_2_3.dat` (a 4.4 MB zip), as the PC does, and the bodies +(29 KB) from WheelWizard's repository at the commit that added them, and checks each +against its SHA-256 (`MiiRenderResource`). A headset with the parts but not the +bodies draws heads alone, and the page offers the bodies. Compared with the PC's C# +renderer on 257 Miis covering every part and colour, 283 of 299 head pictures were +identical and the rest differed by one colour level in at most four pixels; with +bodies, 62 of 66 front pictures were identical and the rest one pixel apart, and the +three-quarter ones differed in at most 32 edge pixels of 160,000. 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 without its body, 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. +about 40 ms. `MiiDataTest`, `MiiDatabaseTest`, `MiiIdsTest`, `MiiBodiesTest` and +`MiiRendererTest` (on made-up parts and body files) 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,