Added Quest profiles page;

- Added vector drawable icons: ic_chart, ic_code, ic_copy, ic_star, ic_translation, ic_user_circle.
- Created layout file for the profiles page, including UI elements for displaying user profiles, history, and statistics.
- Implemented unit tests for Retro WFC functionality, ensuring correct parsing of history, online friend codes, badges, and Mii images.
- Developed tests for Rksys profiles, validating friend code generation, license parsing, and handling of ratings.
This commit is contained in:
iChris4 committed 2026-09-24 02:09:53 +02:00
1 parent 51567c7987
commit be161a0281
31 files changed
+2318 -3

No files matched your search

@@ -50,6 +50,26 @@ object GameStorage {
fun configFile(context: Context): File = File(gameRoot(context), "Config.toml")
/**
* The runtime's NAND (ManagedNandRootPath in runtime/include/nand_path.h): `[paths] nand_root`
* when Config.toml sets one, relative to the config's folder, otherwise NAND beside it.
*/
fun nandDirectory(context: Context): File {
val configured = runCatching { TomlConfig.parse(configFile(context).readText()).string("paths", "nand_root") }.getOrNull()
if (configured.isNullOrBlank()) return File(gameRoot(context), "NAND")
val path = File(configured)
return if (path.isAbsolute) path else File(gameRoot(context), configured)
}
/**
* Retro Rewind's save: the pack's Riivolution XML sends the game's saves to
* riivolution/save/RetroWFC/<game ID><region> beside the pack, and the Quest only runs PAL.
*/
fun retroRewindSave(context: Context): File = File(gameRoot(context), "riivolution/save/RetroWFC/RMCP/rksys.dat")
/** Pulsar's record of the VR and BR Retro Rewind plays with, per profile. */
fun retroRewindRatings(context: Context): File = File(nandDirectory(context), "shared2/Pulsar/RetroRewind6/RRRating.pul")
fun logsDirectory(context: Context): File = File(gameRoot(context), "Logs")
/** Game packages dropped here (adb push, Build-QuestGame.ps1 -Install) are imported when the launcher opens. */
@@ -0,0 +1,89 @@
package org.wiicompiled.quest.launcher
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.view.View
import org.wiicompiled.quest.R
/**
* One of WheelWizard's badges (Views/Components/Badge.axaml), drawn in proportion to its size. A
* role badge is two discs with an icon; a tournament medal is a small disc behind the Award ribbon,
* turned 21 degrees, in gold, silver or bronze. The tip shows when the pointer rests on it.
*/
class BadgeView(context: Context, private val badge: RetroWfc.Badge) : View(context) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
init {
tooltipText = badge.tip
contentDescription = badge.tip
}
override fun onDraw(canvas: Canvas) {
val size = minOf(width, height).toFloat()
if (size <= 0f) return
canvas.save()
canvas.translate((width - size) / 2f, (height - size) / 2f)
when (badge) {
RetroWfc.Badge.WhWzDev -> role(canvas, size, res(R.color.primary_100), res(R.color.primary_400), R.drawable.ic_code, res(R.color.primary_900))
RetroWfc.Badge.RrDev -> role(canvas, size, res(R.color.warning_100), res(R.color.warning_500), R.drawable.ic_code, res(R.color.warning_800))
RetroWfc.Badge.Translator -> role(canvas, size, 0xFFE9C7FF.toInt(), 0xFF8A01CB.toInt(), R.drawable.ic_translation, 0xFF400C55.toInt())
RetroWfc.Badge.TranslatorLead -> {
discs(canvas, size, 0xFFFFC7FB.toInt(), 0xFFCB01AE.toInt())
// The translation icon sits low and right, with a small star above it on the left.
icon(canvas, R.drawable.ic_translation, 0xFF550C48.toInt(), RectF(size * 3 / 14, size * 2 / 14, size * 12 / 14, size * 11 / 14))
icon(canvas, R.drawable.ic_star, 0xFF550C48.toInt(), RectF(size * 1.5f / 14, size * 5.5f / 14, size * 5.5f / 14, size * 8.5f / 14))
}
RetroWfc.Badge.Heart -> {
discs(canvas, size, 0xFFA7FCFF.toInt(), 0xFFF0F0EC.toInt())
icon(canvas, R.drawable.ic_heart, 0xFF72F2FF.toInt(), RectF(size / 4, size * 0.3f, size * 3 / 4, size * 0.8f))
}
RetroWfc.Badge.Firestarter_GoldWinner,
RetroWfc.Badge.SummitShowdown_GoldWinner,
RetroWfc.Badge.Leafstruck_GoldWinner -> medal(canvas, size, res(R.color.warning_100), res(R.color.warning_600))
RetroWfc.Badge.Firestarter_SilverWinner,
RetroWfc.Badge.SummitShowdown_SilverWinner,
RetroWfc.Badge.Leafstruck_SilverWinner -> medal(canvas, size, res(R.color.neutral_50), res(R.color.neutral_400))
RetroWfc.Badge.Firestarter_BronzeWinner,
RetroWfc.Badge.SummitShowdown_BronzeWinner,
RetroWfc.Badge.Leafstruck_BronzeWinner -> medal(canvas, size, 0xFFFFD19D.toInt(), 0xFFEC5616.toInt())
}
canvas.restore()
}
/** A 14-unit grid: the outer disc fills it, the inner one leaves a unit, the icon two. */
private fun role(canvas: Canvas, size: Float, outer: Int, inner: Int, icon: Int, fill: Int) {
discs(canvas, size, outer, inner)
icon(canvas, icon, fill, RectF(size * 2 / 14, size * 2 / 14, size * 12 / 14, size * 12 / 14))
}
private fun discs(canvas: Canvas, size: Float, outer: Int, inner: Int) {
paint.color = outer
canvas.drawCircle(size / 2, size / 2, size / 2, paint)
paint.color = inner
canvas.drawCircle(size / 2, size / 2, size * 6 / 14, paint)
}
/** Rows of 1, 2 and 2 fifths and columns of 1, 2 and 1 quarters: the disc takes the middle cell. */
private fun medal(canvas: Canvas, size: Float, disc: Int, ribbon: Int) {
paint.color = disc
val cell = RectF(size / 4, size / 5, size * 3 / 4, size * 3 / 5)
val radius = minOf(cell.width(), cell.height()) / 2
canvas.drawRoundRect(cell, radius, radius, paint)
canvas.save()
canvas.rotate(21f, size / 2, size / 2)
icon(canvas, R.drawable.ic_award, ribbon, RectF(0f, 0f, size, size))
canvas.restore()
}
private fun icon(canvas: Canvas, drawable: Int, tint: Int, box: RectF) {
val icon = context.getDrawable(drawable)?.mutate() ?: return
icon.setTint(tint)
icon.setBounds(box.left.toInt(), box.top.toInt(), box.right.toInt(), box.bottom.toInt())
icon.draw(canvas)
}
private fun res(color: Int): Int = context.getColor(color)
}
@@ -28,8 +28,9 @@ import org.wiicompiled.quest.R
/**
* The app's entry point on the headset: a 2D panel modelled on the PC launcher (WheelWizard VR),
* with a Home page that sets up and starts the game, a Patches page that finds mods for Retro
* Rewind on GameBanana or imports them, and a Settings page that edits Config.toml.
* with a Home page that sets up and starts the game, a My profiles page that shows the licences
* of Retro Rewind's save, a Patches page that finds mods for Retro Rewind on GameBanana or imports
* them, and a Settings page that edits Config.toml.
*
* The APK carries no game code. Playing needs two things the player owns: the game files (DATA,
* extracted from their disc image here or on a PC) and the game itself (libmain.so, built from
@@ -42,15 +43,17 @@ import org.wiicompiled.quest.R
*/
class LauncherActivity : Activity() {
private enum class Page { Home, Patches, Settings }
private enum class Page { Home, Profiles, Patches, Settings }
/** What Home's main and secondary buttons do. */
private enum class Action { Play, Resume, SelectDisc, ImportGame, BuildGame, DownloadModPack, Reset }
private lateinit var navHome: View
private lateinit var navProfiles: View
private lateinit var navPatches: View
private lateinit var navSettings: View
private lateinit var homePage: View
private lateinit var profilesView: View
private lateinit var patchesView: View
private lateinit var settingsView: View
private lateinit var trails: WheelTrailsView
@@ -68,6 +71,8 @@ class LauncherActivity : Activity() {
private lateinit var gameToggle: LinearLayout
private lateinit var settings: SettingsPage
private lateinit var patches: PatchesPage
private lateinit var profilesPage: ProfilesPage
private lateinit var sidebarProfile: SidebarProfileCard
/** The games this APK carries a kit for, and the one the player picked. */
private val profiles: List<GameProfile> by lazy { GameProfile.available(this) }
@@ -101,9 +106,11 @@ class LauncherActivity : Activity() {
}
navHome = findViewById(R.id.nav_home)
navProfiles = findViewById(R.id.nav_profiles)
navPatches = findViewById(R.id.nav_patches)
navSettings = findViewById(R.id.nav_settings)
homePage = findViewById(R.id.page_home)
profilesView = findViewById(R.id.page_profiles)
patchesView = findViewById(R.id.page_patches)
settingsView = findViewById(R.id.page_settings)
trails = findViewById(R.id.home_trails)
@@ -140,11 +147,14 @@ class LauncherActivity : Activity() {
patches = PatchesPage(this, patchesView) {
openPicker(REQUEST_PATCH_FILES, multiple = true, noPicker = R.string.patches_no_picker)
}
sidebarProfile = SidebarProfileCard(this, findViewById(R.id.sidebar_profile)) { showPage(Page.Profiles) }
profilesPage = ProfilesPage(this, profilesView) { snapshot -> sidebarProfile.show(snapshot) }
savedInstanceState?.getString(KEY_TAB)?.let { name ->
SettingsPage.Tab.entries.firstOrNull { it.name == name }?.let(settings::select)
}
navHome.setOnClickListener { showPage(Page.Home) }
navProfiles.setOnClickListener { showPage(Page.Profiles) }
navPatches.setOnClickListener { showPage(Page.Patches) }
navSettings.setOnClickListener { showPage(Page.Settings) }
playButton.setOnClickListener { perform(mainAction) }
@@ -189,6 +199,9 @@ class LauncherActivity : Activity() {
refresh()
GameSetup.addListener(setupListener)
patches.attach()
// The game may have changed the save while it ran; the profiles page reads it again itself.
if (page != Page.Profiles) sidebarProfile.refresh()
profilesPage.setVisible(page == Page.Profiles)
// The game's process can take a moment to go away after its activity
// closes, which would still read as running.
val runningAtResume = isGameRunning()
@@ -200,6 +213,7 @@ class LauncherActivity : Activity() {
override fun onPause() {
GameSetup.removeListener(setupListener)
patches.detach()
profilesPage.setVisible(false)
super.onPause()
}
@@ -235,10 +249,13 @@ class LauncherActivity : Activity() {
private fun showPage(target: Page) {
page = target
navHome.isSelected = target == Page.Home
navProfiles.isSelected = target == Page.Profiles
navPatches.isSelected = target == Page.Patches
navSettings.isSelected = target == Page.Settings
homePage.visibility = if (target == Page.Home) View.VISIBLE else View.GONE
profilesView.visibility = if (target == Page.Profiles) View.VISIBLE else View.GONE
patchesView.visibility = if (target == Page.Patches) View.VISIBLE else View.GONE
profilesPage.setVisible(target == Page.Profiles)
settingsView.visibility = if (target == Page.Settings) View.VISIBLE else View.GONE
if (target == Page.Home) {
trailsAway = true
@@ -249,6 +266,7 @@ class LauncherActivity : Activity() {
private fun refresh() {
when (page) {
Page.Home -> refreshHome()
Page.Profiles -> profilesPage.refresh()
Page.Patches -> patches.refresh()
Page.Settings -> settings.refresh()
}
@@ -0,0 +1,191 @@
package org.wiicompiled.quest.launcher
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import java.io.File
import java.io.IOException
import java.util.concurrent.Executors
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.
*/
object ProfileStore {
/** The four licence slots of the save, an empty one null. */
data class Snapshot(val licenses: List<RksysProfiles.License?>) {
val any: Boolean get() = licenses.any { it != null }
}
private const val TAG = "WiiCompiledLauncher"
// LauncherActivity's preferences file, where the launcher keeps its other choices.
private const val PREFERENCES = "launcher"
private const val KEY_PRIMARY = "primaryProfile"
private const val IMAGE_DIRECTORY = "profile-miis"
/** SettingValues.NoName: the name the game's guest Miis carry. */
private const val GUEST_NAME = "no name"
/** Who is in a room is asked again after this long; WheelWizard polls its live rooms similarly. */
private const val ONLINE_REFRESH_MS = 30_000L
private val worker = Executors.newFixedThreadPool(2) { runnable -> Thread(runnable, "Profiles").apply { isDaemon = true } }
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 var online: Set<String> = emptySet()
private var onlineAt = 0L
private var badges: Map<String, List<RetroWfc.Badge>>? = null
/** Reads Retro Rewind's save; [done] gets null when there is none, or it is not readable. */
fun load(context: Context, done: (Snapshot?) -> Unit) {
val app = context.applicationContext
worker.execute {
val snapshot = read(app)
main.post { done(snapshot) }
}
}
private fun read(context: Context): Snapshot? {
val save = RksysProfiles.readLicenses(GameStorage.retroRewindSave(context)) ?: return null
val ratings = try {
GameStorage.retroRewindRatings(context).takeIf { it.isFile }?.readBytes()?.let(RksysProfiles::parseRatings).orEmpty()
} catch (e: IOException) {
emptyMap()
}
return RksysProfiles.parse(save, ratings)?.let(::Snapshot)
}
/** GameLicenseService's FOCUSED_USER: the licence the sidebar shows and the page opens on. */
fun primarySlot(context: Context): Int =
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).getInt(KEY_PRIMARY, 0).coerceIn(0, RksysProfiles.SLOTS - 1)
fun setPrimarySlot(context: Context, slot: Int) {
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit().putInt(KEY_PRIMARY, slot).apply()
}
/** A licence's name as the PC shows it: a guest Mii's "no name" reads No name. */
fun displayName(context: Context, license: RksysProfiles.License): String =
if (license.name.isBlank() || license.name == GUEST_NAME) context.getString(R.string.profiles_no_name) else license.name
/** The licence the sidebar shows: the primary one, or the first there is when its slot is empty. */
fun sidebarLicense(context: Context, snapshot: Snapshot): RksysProfiles.License? =
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.
*/
fun miiImage(context: Context, friendCode: String, done: (Bitmap?) -> Unit) {
if (friendCode.isEmpty()) {
done(null)
return
}
val known = images[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 (known == null) done(null)
return
}
waiting[friendCode] = mutableListOf(done)
val file = File(File(context.applicationContext.cacheDir, IMAGE_DIRECTORY), "$friendCode.png")
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 fresh = try {
RetroWfc.miiImage(friendCode)
} catch (e: IOException) {
Log.i(TAG, "No Mii picture for $friendCode from Retro WFC: ${e.message}")
null
}
val bitmap = fresh?.takeIf { !it.contentEquals(cached ?: byteArrayOf()) }?.let(::decode)
if (fresh != null && bitmap != null) {
runCatching {
file.parentFile?.mkdirs()
file.writeBytes(fresh)
}
}
main.post { remember(friendCode, bitmap, 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
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) }
}
}
private fun decode(bytes: ByteArray): Bitmap? = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
/** 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) {
done(online)
return
}
worker.execute {
val fresh = try {
RetroWfc.onlineFriendCodes()
} catch (e: IOException) {
Log.i(TAG, "Retro WFC's rooms are unavailable: ${e.message}")
null
}
main.post {
if (fresh != null) {
online = fresh
onlineAt = SystemClock.elapsedRealtime()
}
done(online)
}
}
}
/** WheelWizard's badges by friend code, fetched once a session. */
fun badges(done: (Map<String, List<RetroWfc.Badge>>) -> Unit) {
badges?.let {
done(it)
return
}
worker.execute {
val fresh = try {
RetroWfc.badges()
} catch (e: IOException) {
Log.i(TAG, "WheelWizard's badges are unavailable: ${e.message}")
null
}
main.post {
if (fresh != null) badges = fresh
done(badges.orEmpty())
}
}
}
/** [friendCode]'s VR over the last [days] days (999 for all of it). */
fun history(friendCode: String, days: Int, done: (Result<RetroWfc.History>) -> Unit) {
worker.execute {
val result = runCatching { RetroWfc.history(friendCode, days) }
main.post { done(result) }
}
}
}
@@ -0,0 +1,419 @@
package org.wiicompiled.quest.launcher
import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.graphics.PointF
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.os.Handler
import android.os.Looper
import android.text.TextUtils
import android.view.Gravity
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.RadioButton
import android.widget.Spinner
import android.widget.Switch
import android.widget.TextView
import android.widget.Toast
import java.text.NumberFormat
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
import org.wiicompiled.quest.R
/**
* The launcher's My profiles page, WheelWizard's UserProfilePage (Views/Pages/UserProfilePage.axaml.cs):
* the four licences of Retro Rewind's save, one shown at a time with its Mii, name, friend code and
* badges, beside a carousel of its VR history (VrHistoryGraph) and its numbers. As on the PC, a
* 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.
*/
class ProfilesPage(
private val activity: Activity,
root: View,
/** The save was read again or the primary licence changed: the sidebar card follows. */
private val changed: (ProfileStore.Snapshot?) -> Unit,
) {
private val content: View = root.findViewById(R.id.profiles_content)
private val empty: View = root.findViewById(R.id.profiles_empty)
private val card: View = root.findViewById(R.id.profiles_card)
private val slots: LinearLayout = root.findViewById(R.id.profiles_slots)
private val face: View = root.findViewById(R.id.profiles_face)
private val glow: View = root.findViewById(R.id.profiles_glow)
private val mii: ImageView = root.findViewById(R.id.profiles_mii)
private val placeholder: View = root.findViewById(R.id.profiles_placeholder)
private val name: TextView = root.findViewById(R.id.profiles_name)
private val codeRow: View = root.findViewById(R.id.profiles_code_row)
private val code: TextView = root.findViewById(R.id.profiles_code)
private val primaryRadio: RadioButton = root.findViewById(R.id.profiles_primary_radio)
private val badgeRow: LinearLayout = root.findViewById(R.id.profiles_badges)
private val historyPage: View = root.findViewById(R.id.profiles_history)
private val statsPage: View = root.findViewById(R.id.profiles_stats)
private val dots: List<View> = listOf(root.findViewById(R.id.profiles_dot0), root.findViewById(R.id.profiles_dot1))
private val historyStart: TextView = root.findViewById(R.id.history_start)
private val historyEnd: TextView = root.findViewById(R.id.history_end)
private val historyChange: TextView = root.findViewById(R.id.history_change)
private val historyRange: TextView = root.findViewById(R.id.history_range)
private val historyLoading: View = root.findViewById(R.id.history_loading)
private val historyMatches: Switch = root.findViewById(R.id.history_matches)
private val historyGraph: View = root.findViewById(R.id.history_graph)
private val historyEmpty: View = root.findViewById(R.id.history_empty)
private val historyEmptyMessage: TextView = root.findViewById(R.id.history_empty_message)
private val historyMax: TextView = root.findViewById(R.id.history_max)
private val historyMin: TextView = root.findViewById(R.id.history_min)
private val historyChart: VrHistoryChart = root.findViewById(R.id.history_chart)
private val historyLabels: List<TextView> = listOf(
root.findViewById(R.id.history_label_start),
root.findViewById(R.id.history_label_mid),
root.findViewById(R.id.history_label_end),
)
private val statsVr: TextView = root.findViewById(R.id.stats_vr)
private val statsBr: TextView = root.findViewById(R.id.stats_br)
private val statsWins: TextView = root.findViewById(R.id.stats_wins)
private val statsRaces: TextView = root.findViewById(R.id.stats_races)
private var snapshot: ProfileStore.Snapshot? = null
private var slot = -1
private val current: RksysProfiles.License? get() = snapshot?.licenses?.getOrNull(slot)
private var online: Set<String> = emptySet()
private var badges: Map<String, List<RetroWfc.Badge>> = emptyMap()
private var carouselPage = 0
private var days = DEFAULT_DAYS
private var history: RetroWfc.History? = null
/** The friend code and period the history on screen, or on its way, is for. */
private var historyFor: Pair<String, Int>? = null
private var historyGeneration = 0
private val main = Handler(Looper.getMainLooper())
private var visible = false
private val onlineTicker = object : Runnable {
override fun run() {
refreshOnline()
main.postDelayed(this, ONLINE_REFRESH_MS)
}
}
private val slotTabs: List<TextView> = (0 until RksysProfiles.SLOTS).map { index ->
TextView(activity).apply {
gravity = Gravity.CENTER
textSize = 15f
isSingleLine = true
ellipsize = TextUtils.TruncateAt.END
setPadding(dp(8), 0, dp(8), 0)
setTextColor(activity.getColorStateList(R.color.profile_slot_text))
setBackgroundResource(R.drawable.bg_profile_slot)
isClickable = true
isFocusable = true
setOnClickListener {
if (index != slot) {
slot = index
render()
}
}
slots.addView(this, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f))
}
}
init {
root.findViewById<View>(R.id.profiles_copy).setOnClickListener { copyFriendCode() }
root.findViewById<View>(R.id.profiles_primary).setOnClickListener { makePrimary() }
root.findViewById<View>(R.id.profiles_previous).setOnClickListener { moveCarousel(-1) }
root.findViewById<View>(R.id.profiles_next).setOnClickListener { moveCarousel(1) }
PatchesWidgets.styleSwitch(activity, historyMatches)
historyMatches.setOnCheckedChangeListener { _, _ -> renderHistory() }
root.findViewById<View>(R.id.history_matches_row).setOnClickListener { historyMatches.toggle() }
root.findViewById<Spinner>(R.id.history_days).apply {
background = activity.getDrawable(R.drawable.bg_dropdown)
setPopupBackgroundDrawable(activity.getDrawable(R.drawable.bg_dropdown_popup))
dropDownVerticalOffset = dp(4)
adapter = ArrayAdapter(activity, R.layout.item_dropdown, DAY_LABELS.map { activity.getString(it) }).apply {
setDropDownViewResource(R.layout.item_dropdown_popup)
}
setSelection(DAY_OPTIONS.indexOf(DEFAULT_DAYS), false)
onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
val chosen = DAY_OPTIONS[position]
if (chosen == days) return
days = chosen
current?.let(::loadHistory)
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
}
}
renderCarousel()
}
/** While the page is on screen, who is online is asked again now and then, as the PC polls its rooms. */
fun setVisible(shown: Boolean) {
if (shown == visible) return
visible = shown
main.removeCallbacks(onlineTicker)
if (shown) main.post(onlineTicker)
}
/** Reads the save again, which the game may have changed since, and shows the licence chosen. */
fun refresh() {
ProfileStore.load(activity) { loaded ->
if (activity.isDestroyed) return@load
snapshot = loaded
// VrHistoryGraph reloads each time it is shown: races since then count.
historyFor = null
val licenses = loaded?.licenses.orEmpty()
if (licenses.getOrNull(slot) == null) {
// UserProfilePage opens on the focused user, here the primary licence.
val primary = ProfileStore.primarySlot(activity)
slot = if (licenses.getOrNull(primary) != null) primary else licenses.indexOfFirst { it != null }
}
render()
changed(loaded)
}
ProfileStore.badges { loaded ->
badges = loaded
if (!activity.isDestroyed) renderBadges()
}
}
private fun render() {
val license = current
val hasProfiles = snapshot?.any == true && license != null
content.visibility = if (hasProfiles) View.VISIBLE else View.GONE
empty.visibility = if (hasProfiles) View.GONE else View.VISIBLE
if (license == null) return
slotTabs.forEachIndexed { index, tab ->
val entry = snapshot?.licenses?.getOrNull(index)
tab.text = entry?.let { ProfileStore.displayName(activity, it) } ?: activity.getString(R.string.profiles_no_license)
tab.isEnabled = entry != null
tab.isSelected = index == slot
tab.setTypeface(null, if (entry == null) Typeface.ITALIC else Typeface.NORMAL)
}
name.text = ProfileStore.displayName(activity, license)
code.text = license.friendCode
codeRow.visibility = if (license.friendCode.isEmpty()) View.GONE else View.VISIBLE
primaryRadio.isChecked = ProfileStore.primarySlot(activity) == license.slot
showMii(license)
renderOnline()
renderBadges()
statsVr.text = license.vr.toString()
statsBr.text = license.br.toString()
statsWins.text = license.wins.toString()
statsRaces.text = license.races.toString()
loadHistory(license)
}
private fun showMii(license: RksysProfiles.License) {
val friendCode = license.friendCode
if (mii.tag != friendCode) {
mii.tag = friendCode
mii.setImageDrawable(null)
placeholder.visibility = View.VISIBLE
}
ProfileStore.miiImage(activity, friendCode) { bitmap ->
if (mii.tag != friendCode) return@miiImage
mii.setImageBitmap(bitmap)
placeholder.visibility = if (bitmap == null) View.VISIBLE else View.GONE
}
}
// Online, as UserProfilePage.UpdateOnlineBorders shows it.
private fun refreshOnline() {
ProfileStore.online { codes ->
online = codes
if (!activity.isDestroyed) renderOnline()
}
}
private fun renderOnline() {
val license = current ?: return
val isOnline = license.friendCode.isNotEmpty() && license.friendCode in online
card.background = box(R.color.neutral_900, if (isOnline) R.color.primary_400 else R.color.neutral_900, 8)
face.background = box(R.color.neutral_950, if (isOnline) R.color.primary_400 else R.color.neutral_600, 4)
glow.visibility = if (isOnline) View.VISIBLE else View.GONE
face.contentDescription = if (isOnline) activity.getString(R.string.profiles_online) else null
}
private fun renderBadges() {
badgeRow.removeAllViews()
val license = current ?: return
for (badge in badges[license.friendCode].orEmpty()) {
badgeRow.addView(BadgeView(activity, badge), LinearLayout.LayoutParams(dp(30), dp(30)).apply { marginStart = dp(3) })
}
}
// Actions
private fun copyFriendCode() {
val friendCode = current?.friendCode?.takeIf { it.isNotEmpty() } ?: return
activity.getSystemService(ClipboardManager::class.java)
?.setPrimaryClip(ClipData.newPlainText(activity.getString(R.string.profiles_code_label), friendCode))
Toast.makeText(activity, R.string.profiles_code_copied, Toast.LENGTH_SHORT).show()
}
/** UserProfilePage.SetUserAsPrimary: the sidebar's card follows the new choice. */
private fun makePrimary() {
val license = current ?: return
if (ProfileStore.primarySlot(activity) == license.slot) return
ProfileStore.setPrimarySlot(activity, license.slot)
primaryRadio.isChecked = true
Toast.makeText(activity, R.string.profiles_primary_set, Toast.LENGTH_SHORT).show()
changed(snapshot)
}
private fun moveCarousel(offset: Int) {
carouselPage = Math.floorMod(carouselPage + offset, CAROUSEL_PAGES)
renderCarousel()
}
private fun renderCarousel() {
historyPage.visibility = if (carouselPage == 0) View.VISIBLE else View.GONE
statsPage.visibility = if (carouselPage == 1) View.VISIBLE else View.GONE
dots.forEachIndexed { index, dot ->
val active = index == carouselPage
dot.layoutParams = dot.layoutParams.apply { width = dp(if (active) 26 else 10) }
dot.background = GradientDrawable().apply {
setColor(activity.getColor(if (active) R.color.primary_300 else R.color.neutral_500))
cornerRadius = dp(5).toFloat()
alpha = if (active) 255 else 191
}
}
}
// VR history, as VrHistoryGraph loads and draws it.
private fun loadHistory(license: RksysProfiles.License) {
val friendCode = license.friendCode
if (historyFor == friendCode to days) return
historyFor = friendCode to days
val generation = ++historyGeneration
history = null
if (friendCode.isEmpty() || friendCode.none { it in '1'..'9' }) {
// SetNoFriendCodeState: a licence never taken online has nothing to show.
historyLoading.visibility = View.GONE
showHistoryState(activity.getString(R.string.history_no_code))
historyRange.text = ""
return
}
historyLoading.visibility = View.VISIBLE
ProfileStore.history(friendCode, days) { result ->
if (generation != historyGeneration || activity.isDestroyed) return@history
historyLoading.visibility = View.GONE
result.onSuccess { loaded ->
history = loaded
renderHistory()
}.onFailure { failure ->
historyRange.text = ""
showHistoryState(failure.message ?: failure.toString())
}
}
}
/** VrHistoryGraph.ApplyHistoryData: the totals, the range, then the plot by time or by match. */
private fun renderHistory() {
val data = history ?: return
historyStart.text = grouped(data.starting)
historyEnd.text = grouped(data.ending)
historyChange.text = if (data.totalChange > 0) "+${grouped(data.totalChange)}" else grouped(data.totalChange)
historyChange.setTextColor(
activity.getColor(
when {
data.totalChange > 0 -> R.color.primary_400
data.totalChange < 0 -> R.color.danger_400
else -> R.color.neutral_100
},
),
)
historyRange.text = if (days == LIFETIME_DAYS) {
activity.getString(R.string.history_range_all)
} else {
val fromPattern = if (year(data.from) != year(data.to)) "MMM d, yyyy" else "MMM d"
activity.getString(R.string.history_range, days, date(data.from, fromPattern), date(data.to, "MMM d"))
}
val entries = data.entries
if (entries.isEmpty()) {
showHistoryState(activity.getString(R.string.history_none_range))
return
}
historyGraph.visibility = View.VISIBLE
historyEmpty.visibility = View.GONE
val start = entries.first().time
val span = maxOf(1_000L, entries.last().time - start)
val lowest = entries.minOf { it.total }
val highest = entries.maxOf { it.total }
val vrRange = maxOf(1, highest - lowest).toFloat()
historyMax.text = grouped(highest)
historyMin.text = grouped(lowest)
val byMatch = historyMatches.isChecked
val labels = if (byMatch) {
listOf(1, entries.size / 2 + 1, entries.size).map { activity.getString(R.string.history_match, it) }
} else {
val pattern = if (days >= 7) "MMM d" else "MMM d HH:mm"
listOf(start, start + span / 2, entries.last().time).map { date(it, pattern) }
}
historyLabels.zip(labels).forEach { (view, text) -> view.text = text }
historyChart.setPoints(
entries.mapIndexed { index, entry ->
val x = if (byMatch) {
if (entries.size > 1) index.toFloat() / (entries.size - 1) else 0f
} else {
(entry.time - start).toFloat() / span
}
PointF(x, (entry.total - lowest) / vrRange)
},
)
}
/** VrHistoryGraph's empty and error states, which share one look. */
private fun showHistoryState(message: String) {
historyGraph.visibility = View.GONE
historyEmpty.visibility = View.VISIBLE
historyEmptyMessage.text = message
for (view in listOf(historyStart, historyEnd, historyChange)) view.text = "0"
historyChange.setTextColor(activity.getColor(R.color.neutral_100))
historyChart.setPoints(emptyList())
}
private fun grouped(value: Int): String = NumberFormat.getIntegerInstance(Locale.getDefault()).format(value)
private fun date(time: Long, pattern: String): String =
DateTimeFormatter.ofPattern(pattern, Locale.getDefault()).withZone(ZoneId.systemDefault()).format(Instant.ofEpochMilli(time))
private fun year(time: Long): Int = Instant.ofEpochMilli(time).atZone(ZoneId.systemDefault()).year
private fun box(fill: Int, stroke: Int, radius: Int) = GradientDrawable().apply {
setColor(activity.getColor(fill))
setStroke(dp(1), activity.getColor(stroke))
cornerRadius = dp(radius).toFloat()
}
private fun dp(value: Int): Int = PatchesWidgets.dp(activity, value)
private companion object {
const val CAROUSEL_PAGES = 2
const val DEFAULT_DAYS = 30
/** What VrHistoryGraph asks for "Lifetime". */
const val LIFETIME_DAYS = 999
val DAY_OPTIONS = listOf(1, 7, 30, 60, LIFETIME_DAYS)
val DAY_LABELS = listOf(R.string.history_days_1, R.string.history_days_7, R.string.history_days_30, R.string.history_days_60, R.string.history_days_all)
/** How often who is online is asked again while the page is shown. */
const val ONLINE_REFRESH_MS = 30_000L
}
}
@@ -0,0 +1,174 @@
package org.wiicompiled.quest.launcher
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.time.Instant
import java.time.format.DateTimeParseException
import java.util.Base64
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import org.wiicompiled.quest.BuildConfig
/**
* Retro WFC, the server Retro Rewind plays online on, asked what WheelWizard's profile page asks it
* (Features/RrRooms/IRwfcApi): a player's profile, which carries a picture of their Mii, their VR
* history, and the rooms open now. Also WheelWizard's own badges (badges.json), by friend code.
*
* Values are read with plain `opt` and type checks, as in [GameBanana], so Android's org.json and
* the JVM one the tests use agree.
*/
object RetroWfc {
private const val API = "https://rwfc.net/api"
private const val ROOM_STATUS_URL = "$API/roomstatus"
private const val BADGES_URL = "https://raw.githubusercontent.com/TeamWheelWizard/WheelWizard-Data/main/badges.json"
private const val TIMEOUT_MS = 20_000
private val userAgent: String get() = "WiiCompiledVR-Quest/${BuildConfig.VERSION_NAME}"
/** One VR change, at [time] in epoch milliseconds, leaving the player at [total]. */
data class HistoryEntry(val time: Long, val change: Int, val total: Int)
/** RwfcPlayerVrHistoryResponse: the entries of a period, oldest first, and its totals. */
data class History(
val from: Long,
val to: Long,
val starting: Int,
val ending: Int,
val totalChange: Int,
val entries: List<HistoryEntry>,
)
/** WheelWizard's BadgeVariant, with the tip its Badge component shows. */
enum class Badge(val tip: String) {
WhWzDev("Wheel Wizard Developer (hiii!)"),
RrDev("Retro Rewind Developer"),
Translator("Translator"),
TranslatorLead("Translator Lead"),
Heart("Heart of the Community"),
Firestarter_GoldWinner("Firestarter Tournament Winner"),
Firestarter_SilverWinner("Firestarter Tournament Runner-Up"),
Firestarter_BronzeWinner("Firestarter Tournament Runner-Up"),
SummitShowdown_GoldWinner("Summit Showdown Tournament Winner"),
SummitShowdown_SilverWinner("Summit Showdown Tournament Runner-Up"),
SummitShowdown_BronzeWinner("Summit Showdown Tournament Runner-Up"),
Leafstruck_GoldWinner("Leafstruck Tournament Winner"),
Leafstruck_SilverWinner("Leafstruck Tournament Runner-Up"),
Leafstruck_BronzeWinner("Leafstruck Tournament Runner-Up"),
}
fun profileUrl(friendCode: String): String = "$API/leaderboard/player/${encode(friendCode)}"
fun historyUrl(friendCode: String, days: Int): String = "${profileUrl(friendCode)}/history?days=$days"
private fun encode(text: String): String = URLEncoder.encode(text, "UTF-8")
// Parsing
/** 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
try {
// The MIME decoder skips line breaks, should a server ever wrap the text, and anything
// else that is not Base64, which leaves nothing.
Base64.getMimeDecoder().decode(encoded).takeIf { it.isNotEmpty() }
} catch (e: IllegalArgumentException) {
null
}
}
fun parseHistory(json: String): History = parsing {
val root = JSONObject(json)
val entries = (root.opt("history") as? JSONArray)?.let { array ->
(0 until array.length()).mapNotNull { index ->
val entry = array.opt(index) as? JSONObject ?: return@mapNotNull null
HistoryEntry(
time = time(entry.text("date")) ?: return@mapNotNull null,
change = entry.number("vrChange"),
total = entry.number("totalVR"),
)
}
}.orEmpty().sortedBy { it.time }
History(
from = time(root.text("fromDate")) ?: entries.firstOrNull()?.time ?: 0L,
to = time(root.text("toDate")) ?: entries.lastOrNull()?.time ?: 0L,
starting = root.number("startingVR"),
ending = root.number("endingVR"),
totalChange = root.number("totalVRChange"),
entries = entries,
)
}
/** The friend codes of everyone in a room now (RRLiveRooms, as GameLicenseService.RefreshOnlineStatus uses it). */
fun parseOnlineFriendCodes(json: String): Set<String> = parsing {
val rooms = JSONObject(json).opt("rooms") as? JSONArray ?: return@parsing emptySet()
(0 until rooms.length()).flatMap { index ->
val players = (rooms.opt(index) as? JSONObject)?.opt("players") as? JSONArray ?: return@flatMap emptyList()
(0 until players.length()).mapNotNull { (players.opt(it) as? JSONObject)?.text("friendCode") }
}.toSet()
}
/** WhWzDataSingletonService.LoadBadgesAsync: friend code to badges, unknown badge names left out. */
fun parseBadges(json: String): Map<String, List<Badge>> = parsing {
val root = JSONObject(json)
root.keys().asSequence().associateWith { key ->
val names = root.opt(key) as? JSONArray ?: return@associateWith emptyList()
(0 until names.length()).mapNotNull { index ->
(names.opt(index) as? String)?.let { name -> Badge.entries.firstOrNull { it.name == name } }
}
}
}
private fun time(text: String?): Long? = try {
text?.let { Instant.parse(it).toEpochMilli() }
} catch (e: DateTimeParseException) {
null
}
private fun <T> parsing(block: () -> T): T = try {
block()
} catch (e: JSONException) {
throw IOException("Retro WFC sent something this launcher cannot read.", e)
}
private fun JSONObject.text(key: String): String? = (opt(key) as? String)?.takeIf { it.isNotBlank() }
private fun JSONObject.number(key: String): Int = (opt(key) as? Number)?.toInt() ?: 0
// 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)
fun history(friendCode: String, days: Int): History {
val json = get(historyUrl(friendCode, days), missingIsNull = true)
?: throw IOException("Retro WFC has no VR history for $friendCode.")
return parseHistory(json)
}
fun onlineFriendCodes(): Set<String> = parseOnlineFriendCodes(get(ROOM_STATUS_URL) ?: "{}")
fun badges(): Map<String, List<Badge>> = parseBadges(get(BADGES_URL) ?: "{}")
private fun get(url: String, missingIsNull: Boolean = false): String? {
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
setRequestProperty("User-Agent", userAgent)
}
try {
val code = try {
connection.responseCode
} catch (e: IOException) {
throw IOException("Retro WFC could not be reached. Check the headset's internet connection.", e)
}
if (code == HttpURLConnection.HTTP_NOT_FOUND && missingIsNull) return null
if (code !in 200..299) throw IOException("${URL(url).host} answered $code.")
return connection.inputStream.use { it.readBytes().toString(Charsets.UTF_8) }
} finally {
connection.disconnect()
}
}
}
@@ -0,0 +1,131 @@
package org.wiicompiled.quest.launcher
import java.io.File
import java.io.IOException
import java.io.RandomAccessFile
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
* 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.
*/
object RksysProfiles {
const val SLOTS = 4
private const val MAGIC = "RKSD0006"
private const val LICENSE_MAGIC = "RKPD"
private const val LICENSE_SIZE = 0x8CC0
/** Where the licences end: the magic, then the four blocks. The rest of the save is not read. */
const val LICENSES_END = 8 + SLOTS * LICENSE_SIZE
private const val NAME_OFFSET = 0x14
private const val NAME_CHARS = 10
private const val PROFILE_ID_OFFSET = 0x5C
private const val VR_OFFSET = 0xB0
private const val BR_OFFSET = 0xB2
private const val RACES_OFFSET = 0xB4
private const val WINS_OFFSET = 0xDC
private const val RATING_MAGIC = 0x52525254L // "RRRT"
private const val RATING_VERSION = 1
private const val RATING_ENTRIES = 100
private const val RATING_ENTRY_SIZE = 16
private const val RATING_HAS_DATA = 1L
/**
* One licence as the profiles page shows it. [friendCode] is empty for a licence never taken
* online, which has no profile ID yet.
*/
data class License(
val slot: Int,
val name: String,
val profileId: Long,
val friendCode: String,
val vr: Int,
val br: Int,
val races: Long,
val wins: Long,
)
/** A rating from RRRating.pul, already as the game shows it (60.50 is 6050). */
data class Rating(val vr: Int, val br: Int)
/**
* The four licence slots of [save], an empty one null, or null when [save] is not a Mario Kart
* Wii save. [ratings] are RRRating.pul's, by profile ID.
*/
fun parse(save: ByteArray, ratings: Map<Long, Rating> = emptyMap()): List<License?>? {
if (save.size < LICENSES_END || String(save, 0, MAGIC.length, Charsets.US_ASCII) != MAGIC) return null
return (0 until SLOTS).map { slot ->
val base = MAGIC.length + slot * LICENSE_SIZE
if (String(save, base, LICENSE_MAGIC.length, Charsets.US_ASCII) != LICENSE_MAGIC) return@map null
val profileId = u32(save, base + PROFILE_ID_OFFSET)
val rating = if (profileId != 0L) ratings[profileId] else null
License(
slot = slot,
name = String(save, base + NAME_OFFSET, NAME_CHARS * 2, Charsets.UTF_16BE).substringBefore('\u0000').trim(),
profileId = profileId,
friendCode = friendCode(profileId),
vr = rating?.vr ?: u16(save, base + VR_OFFSET),
br = rating?.br ?: u16(save, base + BR_OFFSET),
races = u32(save, base + RACES_OFFSET),
wins = u32(save, base + WINS_OFFSET),
)
}
}
/**
* FriendCodeGenerator.GetFriendCode: the profile ID with, above it, the top seven bits of the
* MD5 of the ID (little-endian) followed by "JCMR", written as three groups of four digits.
* Empty for ID 0.
*/
fun friendCode(profileId: Long): String {
if (profileId == 0L) return ""
val id = profileId.toInt()
val input = byteArrayOf(id.toByte(), (id ushr 8).toByte(), (id ushr 16).toByte(), (id ushr 24).toByte(), 0x4A, 0x43, 0x4D, 0x52)
val check = (MessageDigest.getInstance("MD5").digest(input)[0].toInt() and 0xFF) ushr 1
val digits = "%012d".format((check.toLong() shl 32) or profileId)
return "${digits.substring(0, 4)}-${digits.substring(4, 8)}-${digits.substring(8)}"
}
/**
* RRratingReader: RRRating.pul is "RRRT", version 1 and 100 entries of profile ID, VR and BR
* as floats, and flags. The PC multiplies by 100 and rounds half to even, as .NET's Math.Round.
*/
fun parseRatings(pul: ByteArray): Map<Long, Rating> {
if (pul.size < 8 + RATING_ENTRIES * RATING_ENTRY_SIZE) return emptyMap()
if (u32(pul, 0) != RATING_MAGIC || u16(pul, 4) != RATING_VERSION || u16(pul, 6) != RATING_ENTRIES) return emptyMap()
val ratings = HashMap<Long, Rating>()
for (index in 0 until RATING_ENTRIES) {
val entry = 8 + index * RATING_ENTRY_SIZE
val profileId = u32(pul, entry)
if (u32(pul, entry + 12) and RATING_HAS_DATA == 0L || profileId == 0L) continue
ratings[profileId] = Rating(rating(pul, entry + 4), rating(pul, entry + 8))
}
return ratings
}
private fun rating(data: ByteArray, offset: Int): Int = Math.rint((Float.fromBits(u32(data, offset).toInt()) * 100f).toDouble()).toInt()
/** The part of [file] the licences occupy, or null when it is not there or too short to be a save. */
fun readLicenses(file: File): ByteArray? = try {
RandomAccessFile(file, "r").use { input ->
if (input.length() < LICENSES_END) return null
ByteArray(LICENSES_END).also { input.readFully(it) }
}
} catch (e: IOException) {
null
}
private fun u16(data: ByteArray, offset: Int): Int = ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
private fun u32(data: ByteArray, offset: Int): Long =
((data[offset].toLong() and 0xFF) shl 24) or ((data[offset + 1].toLong() and 0xFF) shl 16) or
((data[offset + 2].toLong() and 0xFF) shl 8) or (data[offset + 3].toLong() and 0xFF)
}
@@ -401,6 +401,7 @@ class SettingsPage(
info(R.string.about_credit_title_platform, activity.getString(R.string.about_credit_openxr), stacked = true)
info(R.string.about_credit_title_disc, activity.getString(R.string.about_credit_nod), stacked = true)
info(R.string.about_credit_title_mod_browser, activity.getString(R.string.about_credit_mod_browser), stacked = true)
info(R.string.about_credit_title_profiles, activity.getString(R.string.about_credit_profiles), stacked = true)
if (BuildConfig.ON_DEVICE_BUILD) {
info(R.string.about_credit_title_toolchain, activity.getString(R.string.about_credit_toolchain), stacked = true)
}
@@ -0,0 +1,48 @@
package org.wiicompiled.quest.launcher
import android.app.Activity
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import org.wiicompiled.quest.R
/**
* WheelWizard's sidebar profile block (Views/Patterns/CurrentUserProfile): the primary licence of
* Retro Rewind's save, with its Mii, name and friend code, opening My profiles. Hidden while the
* save has no licence.
*/
class SidebarProfileCard(private val activity: Activity, private val card: View, open: () -> Unit) {
private val mii: ImageView = card.findViewById(R.id.sidebar_profile_mii)
private val placeholder: View = card.findViewById(R.id.sidebar_profile_placeholder)
private val name: TextView = card.findViewById(R.id.sidebar_profile_name)
private val code: TextView = card.findViewById(R.id.sidebar_profile_code)
init {
card.setOnClickListener { open() }
}
/** Reads the save again, which the game may have changed. */
fun refresh() = ProfileStore.load(activity, ::show)
fun show(snapshot: ProfileStore.Snapshot?) {
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
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
mii.setImageDrawable(null)
placeholder.visibility = View.VISIBLE
}
ProfileStore.miiImage(activity, friendCode) { bitmap ->
if (mii.tag != friendCode) return@miiImage
mii.setImageBitmap(bitmap)
placeholder.visibility = if (bitmap == null) View.VISIBLE else View.GONE
}
}
}
@@ -0,0 +1,81 @@
package org.wiicompiled.quest.launcher
import android.content.Context
import android.graphics.Canvas
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PointF
import android.graphics.RectF
import android.graphics.Shader
import android.util.AttributeSet
import android.view.View
import org.wiicompiled.quest.R
/**
* The plot of WheelWizard's VrHistoryGraph: a dark panel with three lines across and three down,
* the area under the VR line in a fading primary gradient, and the line itself. Points are given
* from 0 to 1 on both axes, 0 being the left and the lowest VR.
*/
class VrHistoryChart(context: Context, attrs: AttributeSet? = null) : View(context, attrs) {
private val density = resources.displayMetrics.density
private val panel = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = context.getColor(R.color.neutral_950) }
private val panelEdge = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = density
color = context.getColor(R.color.neutral_900)
}
private val grid = Paint().apply {
strokeWidth = density
color = context.getColor(R.color.neutral_800)
}
private val line = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 2 * density
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
color = context.getColor(R.color.primary_400)
}
private val area = Paint(Paint.ANTI_ALIAS_FLAG).apply { alpha = (0.30f * 255).toInt() }
private var points: List<PointF> = emptyList()
fun setPoints(normalized: List<PointF>) {
points = normalized
invalidate()
}
override fun onDraw(canvas: Canvas) {
val bounds = RectF(0f, 0f, width.toFloat(), height.toFloat())
val corner = 6 * density
canvas.drawRoundRect(bounds, corner, corner, panel)
canvas.drawRoundRect(bounds.apply { inset(density / 2, density / 2) }, corner, corner, panelEdge)
val inner = RectF(8 * density, 8 * density, width - 8 * density, height - 8 * density)
if (inner.width() <= 0 || inner.height() <= 0) return
for (fraction in listOf(0f, 0.5f, 1f)) {
val y = inner.top + inner.height() * fraction
canvas.drawLine(inner.left, y, inner.right, y, grid)
val x = inner.left + inner.width() * fraction
canvas.drawLine(x, inner.top, x, inner.bottom, grid)
}
if (points.isEmpty()) return
fun at(point: PointF) = PointF(inner.left + point.x * inner.width(), inner.bottom - point.y * inner.height())
val stroke = Path()
val fill = Path().apply { moveTo(inner.left, inner.bottom) }
points.map(::at).forEachIndexed { index, point ->
if (index == 0) stroke.moveTo(point.x, point.y) else stroke.lineTo(point.x, point.y)
fill.lineTo(point.x, point.y)
}
fill.lineTo(inner.right, inner.bottom)
fill.close()
area.shader = LinearGradient(
0f, inner.top, 0f, inner.bottom,
context.getColor(R.color.primary_300), context.getColor(R.color.primary_700), Shader.TileMode.CLAMP,
)
canvas.drawPath(fill, area)
canvas.drawPath(stroke, line)
}
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- ProfileSelector's text: white when chosen, primary under the pointer, plain when empty. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/neutral_200" android:state_enabled="false" />
<item android:color="@color/neutral_50" android:state_selected="true" />
<item android:color="@color/primary_300" android:state_hovered="true" />
<item android:color="@color/neutral_200" />
</selector>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The glow behind an online licence's Mii (WheelWizard blurs a Primary200 ellipse at half opacity). -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<gradient
android:endColor="#008EFFE2"
android:gradientRadius="130dp"
android:startColor="#808EFFE2"
android:type="radial" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- One licence in the selector: the chosen one takes the card's colour (ProfileSelector). -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/neutral_900" android:state_selected="true" />
<item android:drawable="@android:color/transparent" />
</selector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The profiles page's licence selector (WheelWizard's RadioButtons border). -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/neutral_950" />
<stroke android:width="1dp" android:color="@color/neutral_600" />
<corners android:radius="4dp" />
</shape>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The sidebar's profile card (WheelWizard's SidebarProfileBlock). -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_800" />
<stroke android:width="1dp" android:color="@color/neutral_600" />
<corners android:radius="8dp" />
</shape>
</item>
<item android:state_hovered="true">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_800" />
<stroke android:width="1dp" android:color="@color/neutral_600" />
<corners android:radius="8dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@android:color/transparent" />
<stroke android:width="1dp" android:color="@color/neutral_600" />
<corners android:radius="8dp" />
</shape>
</item>
</selector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's ArrowRight icon (Font Awesome, 448x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group android:translateX="32">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M438.6 278.6c12.5-12.5 12.5-32.8 0-45.3l-160-160c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L338.8 224 32 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l306.7 0L233.4 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l160-160z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's Award icon (Font Awesome, 384x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group android:translateX="64">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M173.8 5.5c11-7.3 25.4-7.3 36.4 0L228 17.2c6 3.9 13 5.8 20.1 5.4l21.3-1.3c13.2-.8 25.6 6.4 31.5 18.2l9.6 19.1c3.2 6.4 8.4 11.5 14.7 14.7L344.5 83c11.8 5.9 19 18.3 18.2 31.5l-1.3 21.3c-.4 7.1 1.5 14.2 5.4 20.1l11.8 17.8c7.3 11 7.3 25.4 0 36.4L366.8 228c-3.9 6-5.8 13-5.4 20.1l1.3 21.3c.8 13.2-6.4 25.6-18.2 31.5l-19.1 9.6c-6.4 3.2-11.5 8.4-14.7 14.7L301 344.5c-5.9 11.8-18.3 19-31.5 18.2l-21.3-1.3c-7.1-.4-14.2 1.5-20.1 5.4l-17.8 11.8c-11 7.3-25.4 7.3-36.4 0L156 366.8c-6-3.9-13-5.8-20.1-5.4l-21.3 1.3c-13.2 .8-25.6-6.4-31.5-18.2l-9.6-19.1c-3.2-6.4-8.4-11.5-14.7-14.7L39.5 301c-11.8-5.9-19-18.3-18.2-31.5l1.3-21.3c.4-7.1-1.5-14.2-5.4-20.1L5.5 210.2c-7.3-11-7.3-25.4 0-36.4L17.2 156c3.9-6 5.8-13 5.4-20.1l-1.3-21.3c-.8-13.2 6.4-25.6 18.2-31.5l19.1-9.6C65 70.2 70.2 65 73.4 58.6L83 39.5c5.9-11.8 18.3-19 31.5-18.2l21.3 1.3c7.1 .4 14.2-1.5 20.1-5.4L173.8 5.5zM272 192a80 80 0 1 0 -160 0 80 80 0 1 0 160 0zM1.3 441.8L44.4 339.3c.2 .1 .3 .2 .4 .4l9.6 19.1c11.7 23.2 36 37.3 62 35.8l21.3-1.3c.2 0 .5 0 .7 .2l17.8 11.8c5.1 3.3 10.5 5.9 16.1 7.7l-37.6 89.3c-2.3 5.5-7.4 9.2-13.3 9.7s-11.6-2.2-14.8-7.2L74.4 455.5l-56.1 8.3c-5.7 .8-11.4-1.5-15-6s-4.3-10.7-2.1-16zm248 60.4L211.7 413c5.6-1.8 11-4.3 16.1-7.7l17.8-11.8c.2-.1 .4-.2 .7-.2l21.3 1.3c26 1.5 50.3-12.6 62-35.8l9.6-19.1c.1-.2 .2-.3 .4-.4l43.2 102.5c2.2 5.3 1.4 11.4-2.1 16s-9.3 6.9-15 6l-56.1-8.3-32.2 49.2c-3.2 5-8.9 7.7-14.8 7.2s-11-4.3-13.3-9.7z" />
</group>
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's Chart icon (512x512). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M23 23v466h466v-18H41v-82.184l85.854-57.234 70.023 70.022 65.133-260.536L387.28 203.7l67.79-107.97 19.317 11.858 6.102-71.1-60.644 37.616 19.884 12.207-59.01 93.99-130.732-65.366-62.865 251.462-57.98-57.978L41 367.184V23H23z" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's Code icon (Font Awesome, 640x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="640"
android:viewportHeight="640">
<group android:translateY="64">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M392.8 1.2c-17-4.9-34.7 5-39.6 22l-128 448c-4.9 17 5 34.7 22 39.6s34.7-5 39.6-22l128-448c4.9-17-5-34.7-22-39.6zm80.6 120.1c-12.5 12.5-12.5 32.8 0 45.3L562.7 256l-89.4 89.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l112-112c12.5-12.5 12.5-32.8 0-45.3l-112-112c-12.5-12.5-32.8-12.5-45.3 0zm-306.7 0c-12.5-12.5-32.8-12.5-45.3 0l-112 112c-12.5 12.5-12.5 32.8 0 45.3l112 112c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256l89.4-89.4c12.5-12.5 12.5-32.8 0-45.3z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's Copy icon (Font Awesome, 448x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="18dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group android:translateX="32">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M384 336l-192 0c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l140.1 0L400 115.9 400 320c0 8.8-7.2 16-16 16zM192 384l192 0c35.3 0 64-28.7 64-64l0-204.1c0-12.7-5.1-24.9-14.1-33.9L366.1 14.1c-9-9-21.2-14.1-33.9-14.1L192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-32-48 0 0 32c0 8.8-7.2 16-16 16L64 464c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l32 0 0-48-32 0z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's StarIcon (Font Awesome, 576x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="576"
android:viewportHeight="576">
<group android:translateY="32">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M316.9 18C311.6 7 300.4 0 288.1 0s-23.4 7-28.8 18L195 150.3 51.4 171.5c-12 1.8-22 10.2-25.7 21.7s-.7 24.2 7.9 32.7L137.8 329 113.2 474.7c-2 12 3 24.2 12.9 31.3s23 8 33.8 2.3l128.3-68.5 128.3 68.5c10.8 5.7 23.9 4.9 33.8-2.3s14.9-19.3 12.9-31.3L438.5 329 542.7 225.9c8.6-8.5 11.7-21.2 7.9-32.7s-13.7-19.9-25.7-21.7L381.2 150.3 316.9 18z" />
</group>
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's Translation icon (52x52). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="52"
android:viewportHeight="52">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M39,18.67H35.42l-4.2,11.12A29,29,0,0,1,20.6,24.91a28.76,28.76,0,0,0,7.11-14.49h5.21a2,2,0,0,0,0-4H19.67V2a2,2,0,1,0-4,0V6.42H2.41a2,2,0,0,0,0,4H7.63a28.73,28.73,0,0,0,7.1,14.49A29.51,29.51,0,0,1,3.27,30a2,2,0,0,0,.43,4,1.61,1.61,0,0,0,.44-.05,32.56,32.56,0,0,0,13.53-6.25,32,32,0,0,0,12.13,5.9L22.83,52H28l2.7-7.76H43.64L46.37,52h5.22Zm-15.3-8.25a23.76,23.76,0,0,1-6,11.86,23.71,23.71,0,0,1-6-11.86Zm8.68,29.15,4.83-13.83L42,39.57Z" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's UserCircle icon (Font Awesome, 512x512). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M399 384.2C376.9 345.8 335.4 320 288 320l-64 0c-47.4 0-88.9 25.8-111 64.2c35.2 39.2 86.2 63.8 143 63.8s107.8-24.7 143-63.8zM0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256zm256 16a72 72 0 1 0 0-144 72 72 0 1 0 0 144z" />
</vector>
@@ -39,6 +39,69 @@
android:textSize="16sp" />
</LinearLayout>
<!-- WheelWizard's sidebar profile block: the primary licence, opening My profiles. -->
<LinearLayout
android:id="@+id/sidebar_profile"
android:layout_width="match_parent"
android:layout_height="60dp"
android:layout_marginBottom="18dp"
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>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/sidebar_profile_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="sans-serif-medium"
android:singleLine="true"
android:textColor="@color/neutral_200"
android:textSize="14sp" />
<TextView
android:id="@+id/sidebar_profile_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:textColor="@color/neutral_400"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
<TextView
style="@style/Launcher.SectionLabel"
android:layout_width="wrap_content"
@@ -61,6 +124,20 @@
android:text="@string/launcher_nav_home" />
</LinearLayout>
<LinearLayout
android:id="@+id/nav_profiles"
style="@style/Launcher.NavItem">
<ImageView
style="@style/Launcher.NavIcon"
android:importantForAccessibility="no"
android:src="@drawable/ic_user_circle" />
<TextView
style="@style/Launcher.NavLabel"
android:text="@string/launcher_nav_profiles" />
</LinearLayout>
<LinearLayout
android:id="@+id/nav_patches"
style="@style/Launcher.NavItem">
@@ -119,6 +196,11 @@
android:id="@+id/page_home"
layout="@layout/page_home" />
<include
android:id="@+id/page_profiles"
layout="@layout/page_profiles"
android:visibility="gone" />
<include
android:id="@+id/page_patches"
layout="@layout/page_patches"
@@ -0,0 +1,615 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's My profiles page (Views/Pages/UserProfilePage.axaml): the licences of Retro
Rewind's save and what Retro WFC knows of them. The card and the carousel sit side by side on
the headset's wide panel rather than one above the other. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingEnd="24dp">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="64dp">
<TextView
style="@style/Launcher.PageTitle"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="bottom"
android:paddingBottom="10dp"
android:text="@string/profiles_title" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginBottom="14dp"
android:text="@string/profiles_save"
android:textColor="@color/neutral_500"
android:textSize="12sp" />
</FrameLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/neutral_700" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:id="@+id/profiles_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:paddingTop="12dp"
android:paddingBottom="16dp"
android:visibility="gone">
<!-- The card: the four licences, and the one chosen. -->
<LinearLayout
android:id="@+id/profiles_card"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:padding="10dp">
<LinearLayout
android:id="@+id/profiles_slots"
android:layout_width="match_parent"
android:layout_height="36dp"
android:background="@drawable/bg_profile_slots"
android:clipToOutline="true"
android:orientation="horizontal"
android:padding="1dp" />
<FrameLayout
android:id="@+id/profiles_face"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:clipToOutline="true">
<View
android:id="@+id/profiles_glow"
android:layout_width="260dp"
android:layout_height="260dp"
android:layout_gravity="center"
android:background="@drawable/bg_profile_glow"
android:visibility="gone" />
<ImageView
android:id="@+id/profiles_placeholder"
android:layout_width="96dp"
android:layout_height="96dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/ic_user_circle"
android:tint="@color/neutral_700" />
<ImageView
android:id="@+id/profiles_mii"
android:layout_width="176dp"
android:layout_height="176dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:scaleType="fitCenter" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top|start"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/profiles_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="sans-serif-medium"
android:singleLine="true"
android:textColor="@color/neutral_50"
android:textSize="24sp" />
<LinearLayout
android:id="@+id/profiles_code_row"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/profiles_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/neutral_200"
android:textSize="15sp" />
<ImageView
android:id="@+id/profiles_copy"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="4dp"
android:background="@drawable/bg_nav_item"
android:clickable="true"
android:contentDescription="@string/profiles_copy_code"
android:focusable="true"
android:padding="8dp"
android:src="@drawable/ic_copy"
android:tint="@color/neutral_500"
android:tooltipText="@string/profiles_copy_code" />
</LinearLayout>
</LinearLayout>
<!-- Make Primary on the left, the badges on the right, scrolling rather than
running over it when there are many. -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="6dp">
<LinearLayout
android:id="@+id/profiles_primary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_nav_item"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingEnd="10dp"
android:tooltipText="@string/profiles_primary_helper">
<RadioButton
android:id="@+id/profiles_primary_radio"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:buttonTint="@color/primary_400"
android:clickable="false"
android:focusable="false" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:text="@string/profiles_make_primary"
android:textColor="@color/neutral_200"
android:textSize="14sp" />
</LinearLayout>
<HorizontalScrollView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:fillViewport="true"
android:scrollbars="none">
<LinearLayout
android:id="@+id/profiles_badges"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="end|center_vertical"
android:orientation="horizontal"
android:paddingEnd="4dp" />
</HorizontalScrollView>
</LinearLayout>
</FrameLayout>
</LinearLayout>
<!-- The carousel: VR history, then the licence's numbers. -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="16dp"
android:layout_weight="1.1"
android:orientation="vertical">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:id="@+id/profiles_history"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<LinearLayout
style="@style/Launcher.StatCard"
android:layout_marginEnd="4dp">
<TextView
style="@style/Launcher.StatLabel"
android:text="@string/history_start" />
<TextView
android:id="@+id/history_start"
style="@style/Launcher.StatValue" />
</LinearLayout>
<LinearLayout
style="@style/Launcher.StatCard"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp">
<TextView
style="@style/Launcher.StatLabel"
android:text="@string/history_end" />
<TextView
android:id="@+id/history_end"
style="@style/Launcher.StatValue" />
</LinearLayout>
<LinearLayout
style="@style/Launcher.StatCard"
android:layout_marginStart="4dp">
<TextView
style="@style/Launcher.StatLabel"
android:text="@string/history_change" />
<TextView
android:id="@+id/history_change"
style="@style/Launcher.StatValue" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
style="@style/Launcher.SectionLabel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/history_title" />
<ProgressBar
android:id="@+id/history_loading"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginEnd="8dp"
android:indeterminateTint="@color/neutral_500"
android:visibility="gone" />
<LinearLayout
android:id="@+id/history_matches_row"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:orientation="horizontal">
<Switch
android:id="@+id/history_matches"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/history_matches"
android:textColor="@color/neutral_200"
android:textSize="12sp" />
</LinearLayout>
<Spinner
android:id="@+id/history_days"
android:layout_width="185dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp" />
</LinearLayout>
<TextView
android:id="@+id/history_range"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/neutral_400"
android:textSize="11sp" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1">
<LinearLayout
android:id="@+id/history_graph"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_row_single"
android:orientation="horizontal"
android:padding="8dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical">
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1">
<TextView
android:id="@+id/history_max"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:textColor="@color/neutral_400"
android:textSize="11sp" />
<TextView
android:id="@+id/history_min"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:textColor="@color/neutral_400"
android:textSize="11sp" />
</FrameLayout>
<Space
android:layout_width="0dp"
android:layout_height="22dp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:orientation="vertical">
<org.wiicompiled.quest.launcher.VrHistoryChart
android:id="@+id/history_chart"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="16dp"
android:layout_marginTop="6dp"
android:orientation="horizontal">
<TextView
android:id="@+id/history_label_start"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="@color/neutral_400"
android:textSize="11sp" />
<TextView
android:id="@+id/history_label_mid"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:textColor="@color/neutral_400"
android:textSize="11sp" />
<TextView
android:id="@+id/history_label_end"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="end"
android:textColor="@color/neutral_400"
android:textSize="11sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/history_empty"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="16dp"
android:visibility="gone">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_chart"
android:tint="@color/neutral_600" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:fontFamily="sans-serif-medium"
android:text="@string/history_none_title"
android:textColor="@color/neutral_200"
android:textSize="16sp" />
<TextView
android:id="@+id/history_empty_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
android:maxWidth="320dp"
android:textColor="@color/neutral_400"
android:textSize="13sp" />
</LinearLayout>
</FrameLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/profiles_stats"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<LinearLayout
style="@style/Launcher.StatCard"
android:layout_marginEnd="4dp"
android:tooltipText="@string/profiles_vr_full">
<TextView
style="@style/Launcher.StatLabel"
android:text="@string/profiles_vr" />
<TextView
android:id="@+id/stats_vr"
style="@style/Launcher.StatValue" />
</LinearLayout>
<LinearLayout
style="@style/Launcher.StatCard"
android:layout_marginStart="4dp"
android:tooltipText="@string/profiles_br_full">
<TextView
style="@style/Launcher.StatLabel"
android:text="@string/profiles_br" />
<TextView
android:id="@+id/stats_br"
style="@style/Launcher.StatValue" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<LinearLayout
style="@style/Launcher.StatCard"
android:layout_marginEnd="4dp">
<TextView
style="@style/Launcher.StatLabel"
android:text="@string/profiles_total_won" />
<TextView
android:id="@+id/stats_wins"
style="@style/Launcher.StatValue" />
</LinearLayout>
<LinearLayout
style="@style/Launcher.StatCard"
android:layout_marginStart="4dp">
<TextView
style="@style/Launcher.StatLabel"
android:text="@string/profiles_total_played" />
<TextView
android:id="@+id/stats_races"
style="@style/Launcher.StatValue" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</FrameLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="36dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/profiles_previous"
style="@style/Launcher.CarouselArrow"
android:contentDescription="@string/profiles_previous"
android:src="@drawable/ic_arrow_left" />
<View
android:id="@+id/profiles_dot0"
android:layout_width="26dp"
android:layout_height="10dp"
android:layout_marginStart="8dp" />
<View
android:id="@+id/profiles_dot1"
android:layout_width="10dp"
android:layout_height="10dp"
android:layout_marginStart="6dp"
android:layout_marginEnd="8dp" />
<ImageView
android:id="@+id/profiles_next"
style="@style/Launcher.CarouselArrow"
android:contentDescription="@string/profiles_next"
android:src="@drawable/ic_arrow_right" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/profiles_empty"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_horizontal"
android:orientation="vertical"
android:visibility="gone">
<ImageView
android:layout_width="56dp"
android:layout_height="56dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_user_circle"
android:tint="@color/neutral_600" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:fontFamily="sans-serif-medium"
android:text="@string/profiles_empty_title"
android:textColor="@color/neutral_200"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:gravity="center"
android:maxWidth="440dp"
android:text="@string/profiles_empty_message"
android:textColor="@color/neutral_400"
android:textSize="14sp" />
</LinearLayout>
</FrameLayout>
</LinearLayout>
@@ -16,18 +16,21 @@
<color name="neutral_900">#363944</color>
<color name="neutral_950">#24252D</color>
<color name="primary_100">#C7FFF0</color>
<color name="primary_200">#8EFFE2</color>
<color name="primary_300">#34EAC5</color>
<color name="primary_400">#01CBA5</color>
<color name="primary_600">#00A488</color>
<color name="primary_700">#03826E</color>
<color name="primary_800">#08675A</color>
<color name="primary_900">#0C554A</color>
<color name="warning_50">#FFFEE7</color>
<color name="warning_100">#FFFDC1</color>
<color name="warning_500">#FFC800</color>
<color name="warning_600">#D19200</color>
<color name="warning_700">#A66802</color>
<color name="warning_800">#89510A</color>
<color name="danger_300">#FF9DAF</color>
<color name="danger_400">#FF6581</color>
<color name="danger_500">#FE2048</color>
@@ -7,6 +7,7 @@
<!-- Launcher shell -->
<string name="launcher_section_general">General</string>
<string name="launcher_nav_home">Home</string>
<string name="launcher_nav_profiles">My profiles</string>
<string name="launcher_nav_patches">Patches</string>
<string name="launcher_nav_settings">Settings</string>
<string name="launcher_version">v%1$s</string>
@@ -130,6 +131,45 @@
<string name="home_installing_patches">Installing mods…</string>
<string name="home_patches_failed">The mods could not be installed</string>
<!-- My profiles: WheelWizard's UserProfilePage and VrHistoryGraph, wording from en.yml -->
<string name="profiles_title">My profiles</string>
<string name="profiles_save">Retro Rewind save</string>
<string name="profiles_empty_title">No profiles</string>
<string name="profiles_empty_message">You have to play Retro Rewind at least once in order to see your profiles listed here</string>
<string name="profiles_no_license">No license</string>
<string name="profiles_no_name">No name</string>
<string name="profiles_make_primary">Make Primary</string>
<string name="profiles_primary_helper">The primary profile is the one the sidebar shows.</string>
<string name="profiles_primary_set">Set profile as primary</string>
<string name="profiles_copy_code">Copy friend code</string>
<string name="profiles_code_copied">Copied friend code to clipboard</string>
<string name="profiles_code_label">Friend code</string>
<string name="profiles_online">Online</string>
<string name="profiles_vr">VR</string>
<string name="profiles_vr_full">Versus Rating</string>
<string name="profiles_br">BR</string>
<string name="profiles_br_full">Battle Rating</string>
<string name="profiles_total_won">Total games won</string>
<string name="profiles_total_played">Total games played</string>
<string name="profiles_previous">Previous</string>
<string name="profiles_next">Next</string>
<string name="history_start">Start VR</string>
<string name="history_end">End VR</string>
<string name="history_change">Net Change</string>
<string name="history_title">VR History</string>
<string name="history_matches">Matches</string>
<string name="history_days_1">Last 24 hours</string>
<string name="history_days_7">Last 7 days</string>
<string name="history_days_30">Last 30 days</string>
<string name="history_days_60">Last 60 days</string>
<string name="history_days_all">Lifetime</string>
<string name="history_range">Past %1$d days (%2$s to %3$s)</string>
<string name="history_range_all">Lifetime history</string>
<string name="history_match">Match %1$d</string>
<string name="history_none_title">No VR history</string>
<string name="history_none_range">No VR history found for this range yet.</string>
<string name="history_no_code">Friend code is required to load VR history.</string>
<!-- The mod browser: WheelWizard's ModBrowserWindow and ModContent, wording from en.yml -->
<string name="browser_title">Mod Browser</string>
<string name="browser_back">Back to your mods</string>
@@ -188,6 +228,7 @@
<string name="about_credit_openxr">The OpenXR SDK by Khronos, and SDL 3 for windowing, audio and controllers.</string>
<string name="about_credit_nod">nod by encounter, which reads your disc image, built into this app as libnod_jni.</string>
<string name="about_credit_mod_browser">The Patches page\'s mod browser finds mods on GameBanana, as Wheel Wizard\'s does. Their .7z and .rar archives are unpacked by sevenz-rust2 (Apache 2.0) and rars by bitplane (MIT or Apache 2.0), built into libnod_jni.</string>
<string name="about_credit_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_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>
@@ -202,6 +243,7 @@
<string name="about_credit_title_platform">OpenXR and SDL</string>
<string name="about_credit_title_disc">Disc images</string>
<string name="about_credit_title_mod_browser">Mod browser</string>
<string name="about_credit_title_profiles">My profiles</string>
<string name="about_credit_title_toolchain">On-headset builds</string>
<string name="about_credit_title_dolphin">Dolphin</string>
<string name="about_credit_title_logo">Logo</string>
@@ -144,6 +144,43 @@
<item name="android:focusable">true</item>
</style>
<!-- The profiles page's number cards (WheelWizard's profileStatCard). -->
<style name="Launcher.StatCard">
<item name="android:layout_width">0dp</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_weight">1</item>
<item name="android:orientation">vertical</item>
<item name="android:background">@drawable/bg_state_box</item>
<item name="android:padding">10dp</item>
</style>
<style name="Launcher.StatLabel">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:textColor">@color/neutral_300</item>
<item name="android:textSize">13sp</item>
<item name="android:fontFamily">sans-serif-medium</item>
</style>
<style name="Launcher.StatValue">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_marginTop">2dp</item>
<item name="android:textColor">@color/neutral_100</item>
<item name="android:textSize">17sp</item>
</style>
<!-- The profiles carousel's arrows (WheelWizard's small UglyLight buttons). -->
<style name="Launcher.CarouselArrow">
<item name="android:layout_width">30dp</item>
<item name="android:layout_height">30dp</item>
<item name="android:background">@drawable/bg_button_secondary</item>
<item name="android:padding">8dp</item>
<item name="android:tint">@color/neutral_300</item>
<item name="android:clickable">true</item>
<item name="android:focusable">true</item>
</style>
<!-- WheelWizard's StateBox: a count with its icon. -->
<style name="Launcher.StateBox">
<item name="android:layout_width">wrap_content</item>
@@ -0,0 +1,87 @@
package org.wiicompiled.quest.launcher
import java.io.IOException
import java.time.Instant
import java.util.Base64
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.fail
import org.junit.Test
/** The profiles page reads Retro WFC's answers the way WheelWizard does; shapes taken from the live API. */
class RetroWfcTest {
@Test
fun historyEntriesComeOldestFirst() {
val json = """
{"playerId": "601298307", "fromDate": "2026-08-24T23:31:00.6148765Z", "toDate": "2026-09-23T23:31:00.6148765Z",
"history": [
{"date": "2026-09-10T00:38:50.250617Z", "vrChange": 39, "totalVR": 5832},
{"date": "2026-09-10T00:38:49.250617Z", "vrChange": 0, "totalVR": 5793},
{"date": "not a date", "vrChange": 1, "totalVR": 1}],
"totalVRChange": 257, "startingVR": 5793, "endingVR": 6050}
""".trimIndent()
val history = RetroWfc.parseHistory(json)
assertEquals(5793, history.starting)
assertEquals(6050, history.ending)
assertEquals(257, history.totalChange)
assertEquals(Instant.parse("2026-08-24T23:31:00.6148765Z").toEpochMilli(), history.from)
assertEquals(listOf(5793, 5832), history.entries.map { it.total })
assertEquals(listOf(0, 39), history.entries.map { it.change })
assertEquals(1000L, history.entries[1].time - history.entries[0].time)
}
@Test
fun onlineIsBeingInARoomNow() {
val json = """
{"rooms": [
{"id": "XESURM", "players": []},
{"id": "A", "players": [{"pid": "166930", "friendCode": "4123-1702-7346", "mii": {"data": "", "name": "x"}},
{"pid": "204981", "friendCode": "4939-2144-4021"}]},
{"id": "B", "players": [{"pid": "1", "friendCode": null}]}],
"totalPlayers": 3}
""".trimIndent()
assertEquals(setOf("4123-1702-7346", "4939-2144-4021"), RetroWfc.parseOnlineFriendCodes(json))
assertEquals(emptySet<String>(), RetroWfc.parseOnlineFriendCodes("""{"rooms": null}"""))
}
@Test
fun badgesAreListedByFriendCode() {
val json = """
{"all": ["WhWzDev", "RrDev", "Translator", "TranslatorLead", "Heart"],
"4343-3434-3434": ["Firestarter_GoldWinner", "SomethingNew", "SummitShowdown_SilverWinner"]}
""".trimIndent()
val badges = RetroWfc.parseBadges(json)
assertEquals(
listOf(RetroWfc.Badge.Firestarter_GoldWinner, RetroWfc.Badge.SummitShowdown_SilverWinner),
badges["4343-3434-3434"],
)
assertEquals(5, badges["all"]!!.size)
assertNull(badges["0349-6103-6675"])
}
@Test
fun aProfileCarriesItsMiiPicture() {
val png = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 1, 2, 3)
val json = """{"pid": "601298307", "name": "no name", "miiImageBase64": "${Base64.getEncoder().encodeToString(png)}"}"""
assertArrayEquals(png, RetroWfc.parseMiiImage(json))
assertNull(RetroWfc.parseMiiImage("""{"pid": "1", "miiImageBase64": null}"""))
assertNull(RetroWfc.parseMiiImage("""{"pid": "1", "miiImageBase64": "***"}"""))
}
@Test
fun requestsAreTheOnesWheelWizardMakes() {
assertEquals("https://rwfc.net/api/leaderboard/player/0349-6103-6675", RetroWfc.profileUrl("0349-6103-6675"))
assertEquals("https://rwfc.net/api/leaderboard/player/0349-6103-6675/history?days=30", RetroWfc.historyUrl("0349-6103-6675", 30))
}
@Test
fun unreadableAnswersAreIoErrors() {
try {
RetroWfc.parseHistory("Player with friend code '0000-0000-0001' not found")
fail("parsed a plain-text answer")
} catch (expected: IOException) {
}
}
}
@@ -0,0 +1,106 @@
package org.wiicompiled.quest.launcher
import java.io.File
import java.nio.ByteBuffer
import java.nio.file.Files
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** The profiles page reads licences as WheelWizard's GameLicenseService does. */
class RksysProfilesTest {
private val licenseSize = 0x8CC0
/** A save with the magic and, for each given slot, an RKPD block. */
private fun save(vararg licenses: Pair<Int, (ByteBuffer, Int) -> Unit>): ByteArray {
val buffer = ByteBuffer.allocate(RksysProfiles.LICENSES_END + 0x100)
buffer.put(0, "RKSD0006".toByteArray())
for ((slot, fill) in licenses) {
val base = 8 + slot * licenseSize
buffer.put(base, "RKPD".toByteArray())
fill(buffer, base)
}
return buffer.array()
}
private fun ByteBuffer.name(base: Int, name: String) = put(base + 0x14, name.toByteArray(Charsets.UTF_16BE))
@Test
fun friendCodesAreTheProfileIdWithItsChecksum() {
// The licence on the author's headset, and a player Retro WFC listed in a room.
assertEquals("0349-6103-6675", RksysProfiles.friendCode(0x23D71583L))
assertEquals("4123-1702-7346", RksysProfiles.friendCode(166930L))
assertEquals("", RksysProfiles.friendCode(0L))
}
@Test
fun licencesComeWithNameCodeRatingAndRaces() {
val bytes = save(
0 to { buffer: ByteBuffer, base: Int ->
buffer.name(base, "Mario")
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") },
)
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])
// 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])
}
@Test
fun retroRewindsRatingsOutrankTheSave() {
val bytes = save(0 to { buffer: ByteBuffer, base: Int ->
buffer.putInt(base + 0x5C, 0x23D71583)
buffer.putShort(base + 0xB0, 5793.toShort())
buffer.putShort(base + 0xB2, 5000.toShort())
})
val pul = ByteBuffer.allocate(8 + 100 * 16).apply {
putInt(0x52525254)
putShort(1)
putShort(100)
// Slot 3 holds the licence's rating; slot 4 a rating without its "has data" flag.
putInt(8 + 3 * 16, 0x23D71583)
putFloat(8 + 3 * 16 + 4, 60.50f)
putFloat(8 + 3 * 16 + 8, 50.52f)
putInt(8 + 3 * 16 + 12, 1)
putInt(8 + 4 * 16, 42)
putFloat(8 + 4 * 16 + 4, 99f)
}.array()
val ratings = RksysProfiles.parseRatings(pul)
assertEquals(mapOf(0x23D71583L to RksysProfiles.Rating(6050, 5052)), ratings)
val license = RksysProfiles.parse(bytes, ratings)!![0]!!
assertEquals(6050, license.vr)
assertEquals(5052, license.br)
// Anything else is no rating file at all.
assertTrue(RksysProfiles.parseRatings(ByteArray(8 + 100 * 16)).isEmpty())
}
@Test
fun anythingElseIsNoSave() {
assertNull(RksysProfiles.parse(ByteArray(RksysProfiles.LICENSES_END)))
assertNull(RksysProfiles.parse("RKSD0006".toByteArray()))
}
@Test
fun onlyTheLicencesAreRead() {
val folder = Files.createTempDirectory("rksys").toFile()
val bytes = save(1 to { buffer: ByteBuffer, base: Int -> buffer.name(base, "Luigi") })
val file = File(folder, "rksys.dat").apply { writeBytes(bytes) }
val read = RksysProfiles.readLicenses(file)!!
assertArrayEquals(bytes.copyOf(RksysProfiles.LICENSES_END), read)
assertEquals("Luigi", RksysProfiles.parse(read)!![1]!!.name)
assertNull(RksysProfiles.readLicenses(File(folder, "missing.dat")))
assertNull(RksysProfiles.readLicenses(File(folder, "short.dat").apply { writeBytes(ByteArray(64)) }))
}
}
+18
View File
@@ -220,6 +220,24 @@ that still holds files is only cleared if the player says so. The pack's
Riivolution XML maps that folder onto `/patches` and `/sound`. `ModLibraryTest`
covers the rules.
**My profiles**, between Home and Patches, is the PC's UserProfilePage
(`ProfilesPage`, `ProfileStore`, `RksysProfiles`, `RetroWfc`), with the PC's sidebar
profile card above General (`SidebarProfileCard`). Like the PC it reads Retro Rewind's
save only (`riivolution/save/RetroWFC/RMCP/rksys.dat`): the four licences with their
name, friend code (derived from the profile ID as `FriendCodeGenerator` does), VR, BR
and race counts, the VR and BR replaced by Pulsar's `RRRating.pul` in the NAND when it
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/<fc>/history?days=N`, and its Mii picture is the
64-pixel PNG of `/api/leaderboard/player/<fc>`, kept on disk for the sidebar. The
headset has no Mii database (`RFL_DB.dat`), so this picture stands in for the PC's
rendered Mii, and a licence never taken online shows a silhouette. Badges come from
WheelWizard's `badges.json`. It only reads: Rename and change Mii need that database,
and the PC's region picker is gone since the Quest runs PAL only. The card and the
carousel sit side by side on the wide panel. `RksysProfilesTest` and `RetroWfcTest`
cover the parsing.
The launcher follows the runtime's rules exactly. `TomlConfig` edits one line
the way `RuntimeConfigFile::WriteSetting` does, and every edit re-reads the file,
so values the in-headset panel wrote are kept. Each row reads its key with the