mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-06 01:00:14 +02:00
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.
This commit is contained in:
1 parent
e2a898eab7
commit
e1bd3c6f94
22 files changed
+853
-175
No files matched your search
@@ -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<Mesh>, val female: List<Mesh>) {
|
||||
|
||||
/**
|
||||
* 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<Mesh> {
|
||||
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<Mesh>()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,21 @@ object MiiDatabase {
|
||||
@Throws(IOException::class)
|
||||
fun miis(file: File): List<Mii> = 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<Long, Mii> {
|
||||
val found = LinkedHashMap<Long, Mii?>()
|
||||
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 ->
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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<Prepared>()
|
||||
// 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<Draw>, 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]
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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<RksysProfiles.License?>) {
|
||||
/**
|
||||
* 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<RksysProfiles.License?>, val miis: Map<Long, Mii> = 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<String, Bitmap>()
|
||||
private val imagesAsked = HashSet<String>()
|
||||
private val waiting = HashMap<String, MutableList<(Bitmap?) -> Unit>>()
|
||||
private val remotes = HashMap<String, Remote>()
|
||||
private val remotesAsked = HashSet<String>()
|
||||
private val waiting = HashMap<String, MutableList<(Remote?) -> Unit>>()
|
||||
private var online: Set<String> = emptySet()
|
||||
private var onlineAt = 0L
|
||||
private var badges: Map<String, List<RetroWfc.Badge>>? = 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<String>) -> Unit) {
|
||||
if (onlineAt != 0L && SystemClock.elapsedRealtime() - onlineAt < ONLINE_REFRESH_MS) {
|
||||
|
||||
@@ -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". */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
<LinearLayout
|
||||
android:layout_width="210dp"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingTop="12dp"
|
||||
@@ -39,8 +41,10 @@
|
||||
android:textSize="16sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- WheelWizard's sidebar profile block: the primary licence, opening My profiles. -->
|
||||
<LinearLayout
|
||||
<!-- WheelWizard's sidebar profile block: the primary licence, opening My profiles. As on the
|
||||
PC, its Mii stands on the block's bottom edge and rises out of it: an 80-pixel picture
|
||||
beside CurrentUserProfile's 58-pixel column, the text 10 pixels past it. -->
|
||||
<FrameLayout
|
||||
android:id="@+id/sidebar_profile"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="60dp"
|
||||
@@ -48,38 +52,24 @@
|
||||
android:background="@drawable/bg_sidebar_profile"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="6dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/sidebar_profile_placeholder"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_user_circle"
|
||||
android:tint="@color/neutral_600" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/sidebar_profile_mii"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitCenter" />
|
||||
</FrameLayout>
|
||||
<ImageView
|
||||
android:id="@+id/sidebar_profile_placeholder"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_gravity="start|center_vertical"
|
||||
android:layout_marginStart="18dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_user_circle"
|
||||
android:tint="@color/neutral_600" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginStart="73dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
@@ -100,7 +90,17 @@
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/sidebar_profile_mii"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="80dp"
|
||||
android:layout_gravity="bottom|start"
|
||||
android:layout_marginStart="-7dp"
|
||||
android:layout_marginBottom="1dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitCenter" />
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.SectionLabel"
|
||||
|
||||
@@ -36,13 +36,14 @@
|
||||
android:tint="@color/neutral_500"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- Larger than the tile and raised, as WheelWizard crops its list pictures. -->
|
||||
<!-- Wider than the tile and raised, the shoulders on its bottom edge: WheelWizard's
|
||||
ImageOnlyMargin (-4,-9,-4,0) on its 90-pixel tile. -->
|
||||
<ImageView
|
||||
android:id="@+id/mii_picture"
|
||||
android:layout_width="140dp"
|
||||
android:layout_height="140dp"
|
||||
android:layout_width="109dp"
|
||||
android:layout_height="109dp"
|
||||
android:layout_gravity="center_horizontal|top"
|
||||
android:layout_marginTop="-19dp"
|
||||
android:layout_marginTop="-9dp"
|
||||
android:importantForAccessibility="no" />
|
||||
|
||||
<ImageView
|
||||
|
||||
@@ -95,8 +95,8 @@
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/profiles_mii"
|
||||
android:layout_width="176dp"
|
||||
android:layout_height="176dp"
|
||||
android:layout_width="320dp"
|
||||
android:layout_height="320dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitCenter" />
|
||||
|
||||
@@ -181,9 +181,11 @@
|
||||
<string name="miis_import">Import</string>
|
||||
<string name="miis_empty_title">No Miis yet!</string>
|
||||
<string name="miis_empty_message">The Mii database in the game\'s NAND cannot be opened: %1$s</string>
|
||||
<string name="miis_parts_message">Mii pictures are drawn from FFL\'s Mii parts (%1$s), the file Wheel Wizard downloads for the same reason: a copy of Miitomo\'s kept by the Internet Archive. They are downloaded once and stay on this headset.</string>
|
||||
<string name="miis_parts_message">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.</string>
|
||||
<string name="miis_bodies_message">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.</string>
|
||||
<string name="miis_parts_download">Download</string>
|
||||
<string name="miis_parts_connecting">Connecting to the Internet Archive…</string>
|
||||
<string name="miis_bodies_connecting">Connecting to GitHub…</string>
|
||||
<string name="miis_parts_downloading">Downloading the Mii parts: %1$s of %2$s</string>
|
||||
<string name="miis_parts_failed">The Mii parts could not be downloaded: %1$s</string>
|
||||
<string name="miis_new_title">New Mii</string>
|
||||
@@ -342,7 +344,7 @@
|
||||
<string name="about_credit_nod">nod by encounter, which reads your disc image, built into this app as libnod_jni.</string>
|
||||
<string name="about_credit_mod_browser">The Patches page\'s mod browser finds mods on GameBanana, as Wheel Wizard\'s does. Their .7z and .rar archives are unpacked by sevenz-rust2 (Apache 2.0) and rars by bitplane (MIT or Apache 2.0), built into libnod_jni.</string>
|
||||
<string name="about_credit_profiles">My profiles reads your licences from Retro Rewind\'s save, and shows each one\'s Mii picture, VR history and online status from Retro WFC (rwfc.net), as Wheel Wizard does. Badges come from Wheel Wizard\'s data.</string>
|
||||
<string name="about_credit_miis">My Miis edits the game\'s Mii database, and its Mii editor works as Wheel Wizard\'s. Miis are drawn by a port of Wheel Wizard\'s Mii renderer (GPL v3), from FFL\'s Mii parts, which you download from the Internet Archive\'s copy of Miitomo\'s files.</string>
|
||||
<string name="about_credit_miis">My Miis edits the game\'s Mii database, and its Mii editor works as Wheel Wizard\'s. Miis are drawn by a port of Wheel Wizard\'s Mii renderer (GPL v3), from FFL\'s Mii parts, which you download from the Internet Archive\'s copy of Miitomo\'s files, and the 3DS Mii bodies, which you download from Wheel Wizard\'s repository.</string>
|
||||
<string name="about_credit_toolchain">On-headset builds use Termux\'s Android clang and lld, Google\'s Android NDK headers and libraries, and .NET for the translator.</string>
|
||||
<string name="about_credit_dolphin">The Dolphin emulator, the reference for how the Wii behaves.</string>
|
||||
<string name="about_credit_logo">The WiiCompiled logo by inkwreck.</string>
|
||||
|
||||
@@ -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<FloatArray>,
|
||||
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) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user