From 65b00242d9def4e7bcb676532c995db9888de2dd Mon Sep 17 00:00:00 2001 From: The412Banner Date: Wed, 23 Sep 2026 00:40:22 -0400 Subject: [PATCH] Added games: the user's own Windows games in the client's library A Games folder chosen in the Steam cog is scanned one level deep, one subfolder per game; the program to launch is the .exe named after the folder, else the largest, never an installer, redistributable or crash reporter, and the settings page lets the user pick another for any game. Each session the app writes the list, and bannerlator-steam-shortcuts puts the games into the client's shortcuts.vdf as non-Steam games (the client's own entries kept) with the appid the client itself derives, mapped to the ARM64 Proton through the compat registrar. They appear in the rail under Steam and launch by steam://rungameid/ like any title. --- .../com/steamdeck/launcher/MainActivity.kt | 45 ++++- .../steamdeck/launcher/frontend/AddedGames.kt | 103 ++++++++++ .../steamdeck/launcher/frontend/Library.kt | 3 +- .../launcher/session/SessionFiles.kt | 1 + .../launcher/session/SessionPrefs.kt | 14 ++ .../launcher/session/SessionService.kt | 8 + .../launcher/ui/ModeSettingsDialog.kt | 26 +++ .../overlay/usr/local/bin/bannerlator-session | 6 + .../usr/local/bin/bannerlator-steam-shortcuts | 177 ++++++++++++++++++ 9 files changed, 380 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/steamdeck/launcher/frontend/AddedGames.kt create mode 100755 tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shortcuts diff --git a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt index a8986e50e..0eace7cca 100644 --- a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt @@ -2,6 +2,7 @@ package com.steamdeck.launcher import android.Manifest import android.content.Intent +import java.io.File import android.content.pm.PackageManager import android.net.Uri import android.provider.OpenableColumns @@ -113,6 +114,26 @@ class MainActivity : ComponentActivity() { private val pickAndroidDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { importDriver(it, linux = false) } } + private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> + if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> + SessionPrefs.setAddedGamesDir(this, path) + addedGamesDir = path + refreshAddedGames() + refresh() + } + } + private var pendingAddedGame: String? = null + private val pickAddedGameExe = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> + val folder = pendingAddedGame ?: return@registerForActivityResult + pendingAddedGame = null + if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> + SessionPrefs.setAddedGameExe(this, folder, path) + refreshAddedGames() + refresh() + } + } + private var addedGamesDir by mutableStateOf("") + private var addedGames by mutableStateOf>(emptyList()) private val pickRomsDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> SessionPrefs.setRomsDir(this, path) @@ -183,7 +204,7 @@ class MainActivity : ComponentActivity() { }, onSteamGame = { g -> startActivity(Intent(this, SessionActivity::class.java) - .putExtra(SessionService.EXTRA_STEAM_URL, "steam://rungameid/${g.appId}")) + .putExtra(SessionService.EXTRA_STEAM_URL, "steam://rungameid/${g.gameId}")) }, onDesktop = { startActivity(Intent(this, SessionActivity::class.java) @@ -371,6 +392,8 @@ class MainActivity : ComponentActivity() { storageOptions = storageOptions, fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null, steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null, + addedGamesDir = if (mode == SessionService.MODE_STEAM) addedGamesDir else null, + addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(), ), ModeSettingsActions( onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap }, @@ -401,6 +424,13 @@ class MainActivity : ComponentActivity() { }, onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id }, onSteamChannel = { id -> SessionPrefs.setSteamChannel(this, id); steamChannel = id }, + onPickAddedGamesDir = { pickAddedGamesDir.launch(InAppFilePicker.buildDirIntent(this, "Choose the folder of your own games", addedGamesDir.ifEmpty { null })) }, + onClearAddedGamesDir = { SessionPrefs.setAddedGamesDir(this, ""); addedGamesDir = ""; addedGames = emptyList(); refresh() }, + onAddedGameExe = { folder, path -> SessionPrefs.setAddedGameExe(this, folder, path); refreshAddedGames(); refresh() }, + onPickAddedGameExe = { folder -> + pendingAddedGame = folder + pickAddedGameExe.launch(InAppFilePicker.buildIntent(this, listOf("exe"), "Choose the game's .exe", File(addedGamesDir, folder).path)) + }, onDismiss = { settingsMode = null }, ), ) @@ -434,11 +464,20 @@ class MainActivity : ComponentActivity() { ) } + /** The added games as the settings page lists them; a scan of the folder, on this thread (one level, small). */ + private fun refreshAddedGames() { + addedGames = com.steamdeck.launcher.frontend.AddedGames.scan(this).map { g -> + com.steamdeck.launcher.ui.AddedGameRow(g.folderName(), g.exe.path, g.exe.name, g.candidates.map { c -> c.path to c.name }.distinctBy { it.first }) + } + } + private fun openModeSettings(mode: String) { refreshDrivers() resolutionCap = SessionPrefs.resolutionCap(this, mode) fexPreset = SessionPrefs.fexPreset(this) steamChannel = SessionPrefs.steamChannel(this) + addedGamesDir = SessionPrefs.addedGamesDir(this) + refreshAddedGames() shapeMode = SessionPrefs.shapeMode(this) hdrOn = SessionPrefs.hdr(this, mode) hdrReason = com.steamdeck.launcher.wayland.HdrSupport.probe(this).reason @@ -574,7 +613,9 @@ class MainActivity : ComponentActivity() { } else null // The libraries, off the main thread: manifests and a folder scan. Thread({ - val games = if (ready) Library.steamGames(this) else emptyList() + val games = if (ready) Library.steamGames(this) + com.steamdeck.launcher.frontend.AddedGames.scan(this).map { g -> + Library.SteamGame(g.appId.toInt(), g.name, null, "added", g.gameId) + } else emptyList() val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null } ui.post { steamGames = games; emulatorList = emus } }, "library").start() diff --git a/app/src/main/java/com/steamdeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/steamdeck/launcher/frontend/AddedGames.kt new file mode 100644 index 000000000..ee31d50a6 --- /dev/null +++ b/app/src/main/java/com/steamdeck/launcher/frontend/AddedGames.kt @@ -0,0 +1,103 @@ +package com.steamdeck.launcher.frontend + +import android.content.Context +import android.os.Environment +import android.util.Log +import com.steamdeck.launcher.session.GameStorage +import com.steamdeck.launcher.session.SessionPrefs +import java.io.File +import java.util.zip.CRC32 + +/** + * The user's own Windows games, added to the Steam client's library. + * + * A Games folder is scanned one level deep: each subfolder is one game, and the program to launch + * is the .exe in it that looks most like the game - the one named after the folder, else the + * largest, never an installer, redistributable or crash reporter - unless the user picked one + * for that game. Each game becomes a non-Steam shortcut in the client, under the ARM64 Proton, + * with the appid the client itself would derive, so launching from the rail and from the + * client's own library are the same thing. The folder has to be somewhere the session can see: + * internal storage (/root/Storage), the ROMs folder, or the SD-card game library. + */ +object AddedGames { + private const val TAG = "AddedGames" + + class Game( + val folder: File, val name: String, val exe: File, val guestExe: String, val guestDir: String, + /** The client's 32-bit appid for this shortcut, as an unsigned value. */ + val appId: Long, + /** What steam://rungameid/ takes for a shortcut. */ + val gameId: Long, + val candidates: List, + ) { + fun folderName(): String = folder.name + } + + private val SKIP = Regex( + "(?i)^(unins.*|setup.*|.*redist.*|vcredist.*|dxsetup.*|dxwebsetup.*|.*crash.*|.*report.*|dotnet.*|directx.*|.*prereq.*" + + "|.*installer.*|.*uninstall.*|.*updater?.*|.*config(ur.*)?|.*settings.*|.*editor.*|.*server.*|.*benchmark.*|.*helper.*|.*eac.*|.*easyanticheat.*|.*battleye.*)\\.exe$", + ) + + /** Where a host path appears inside the session, or null when the session cannot see it. */ + fun guestPath(context: Context, host: File): String? { + val path = host.absolutePath + val storage = Environment.getExternalStorageDirectory().absolutePath + if (path.startsWith("$storage/")) return "/root/Storage/" + path.removePrefix("$storage/") + SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let { roms -> + if (path.startsWith("$roms/")) return "/root/ROMs/" + path.removePrefix("$roms/") + } + GameStorage.effective(context)?.let { lib -> + if (path.startsWith("${lib.path}/")) return "/mnt/bannerlator-sd/" + path.removePrefix("${lib.path}/") + } + return null + } + + /** The .exe files a game folder offers, best first. */ + fun candidates(folder: File): List { + val exes = ArrayList() + val roots = listOf(folder) + (folder.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) + for (dir in roots) { + dir.listFiles { f -> f.isFile && f.name.endsWith(".exe", ignoreCase = true) && !SKIP.matches(f.name) } + ?.let { exes.addAll(it) } + } + val key = folder.name.lowercase().replace(Regex("[^a-z0-9]"), "") + return exes.sortedWith( + compareByDescending { it.parentFile == folder } + .thenByDescending { it.nameWithoutExtension.lowercase().replace(Regex("[^a-z0-9]"), "").let { n -> n == key || key.startsWith(n) || n.startsWith(key) } } + .thenByDescending { it.length() }, + ) + } + + fun scan(context: Context): List { + val dir = SessionPrefs.addedGamesDir(context).takeIf { it.isNotEmpty() }?.let { File(it) } ?: return emptyList() + if (!dir.isDirectory) return emptyList() + val out = ArrayList() + for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) { + val candidates = candidates(folder) + val chosen = SessionPrefs.addedGameExe(context, folder.name).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile } + val exe = chosen ?: candidates.firstOrNull() ?: continue + val guestExe = guestPath(context, exe) ?: run { Log.w(TAG, "${folder.name}: the session cannot see ${exe.path}"); continue } + val guestDir = guestPath(context, exe.parentFile ?: folder) ?: continue + val name = folder.name + val crc = CRC32().apply { update(("\"$guestExe\"" + name).toByteArray()) }.value + val appId = crc or 0x80000000L + out.add(Game(folder, name, exe, guestExe, guestDir, appId, (appId shl 32) or 0x02000000L, candidates)) + } + return out + } + + /** The list the session hands the runtime's shortcuts writer; one file per session start. */ + fun writeListing(context: Context, games: List): File { + val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() } + val json = StringBuilder("[") + games.forEachIndexed { i, g -> + if (i > 0) json.append(',') + json.append("{\"name\":").append(quote(g.name)).append(",\"exe\":").append(quote(g.guestExe)) + .append(",\"dir\":").append(quote(g.guestDir)).append(",\"appid\":").append(g.appId).append('}') + } + file.writeText(json.append(']').toString()) + return file + } + + private fun quote(s: String) = "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" +} diff --git a/app/src/main/java/com/steamdeck/launcher/frontend/Library.kt b/app/src/main/java/com/steamdeck/launcher/frontend/Library.kt index b77246828..4b491242d 100644 --- a/app/src/main/java/com/steamdeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/steamdeck/launcher/frontend/Library.kt @@ -13,7 +13,8 @@ import java.io.File * folder as a hint). Read on a worker thread; nothing here is cached beyond one screen refresh. */ object Library { - class SteamGame(val appId: Int, val name: String, val art: File?, val library: String) + /** [gameId] is what steam://rungameid/ takes: the appid for a Steam title, the shortcut id for an added game. */ + class SteamGame(val appId: Int, val name: String, val art: File?, val library: String, val gameId: Long = appId.toLong()) class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null) class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List) { /** The emulator's own icon, bundled (the runtime keeps them as theme SVGs the app cannot draw). */ diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionFiles.kt index f619c9d6d..66e05168f 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionFiles.kt @@ -37,6 +37,7 @@ object SessionFiles { "usr/local/bin/bannerlator-steam-launch" to "usr/local/bin/bannerlator-steam-launch", "usr/local/bin/bannerlator-desktop-games" to "usr/local/bin/bannerlator-desktop-games", "usr/local/bin/bannerlator-steam-shim" to "usr/local/bin/bannerlator-steam-shim", + "usr/local/bin/bannerlator-steam-shortcuts" to "usr/local/bin/bannerlator-steam-shortcuts", // The SteamOS helpers the client calls in Deck mode: the two Armada found it needs, plus // the three under /usr/bin, all no-ops that answer "nothing to do" (see each file). "usr/bin/steamos-update" to "usr/bin/steamos-update", diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt index 4632af37f..29eb761ef 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt @@ -233,6 +233,20 @@ object SessionPrefs { prefs(context).edit().putString("steamChannel", id).apply() } + /** The folder of the user's own Windows games (one subfolder each), "" = none. */ + fun addedGamesDir(context: Context): String = prefs(context).getString("addedGamesDir", "") ?: "" + + fun setAddedGamesDir(context: Context, path: String) { + prefs(context).edit().putString("addedGamesDir", path).apply() + } + + /** The .exe the user chose for one game folder (by folder name), "" = the scanner's pick. */ + fun addedGameExe(context: Context, folderName: String): String = prefs(context).getString("addedExe:$folderName", "") ?: "" + + fun setAddedGameExe(context: Context, folderName: String, path: String) { + prefs(context).edit().putString("addedExe:$folderName", path).apply() + } + /** The app's colour theme (ui/Themes ids); Paper on black unless chosen otherwise. */ fun theme(context: Context): String = prefs(context).getString("theme", "paper") ?: "paper" diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt index 5dc246c11..9e054bb98 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt @@ -316,6 +316,14 @@ class SessionService : Service() { .takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() } guest.add("BL_WLR_RENDERER=" + (override?.takeIf { it.isNotEmpty() } ?: SessionPrefs.desktopRenderer(this))) } + // The user's own games, for the runtime's shortcuts writer to put in the client's library + // before the client starts (see frontend/AddedGames and bannerlator-steam-shortcuts). + if (SessionState.mode == MODE_STEAM) { + val added = com.steamdeck.launcher.frontend.AddedGames.scan(this) + val listing = com.steamdeck.launcher.frontend.AddedGames.writeListing(this, added) + guest.add("BL_ADDED_GAMES=" + listing.path) + if (added.isNotEmpty()) Log.i(TAG, "added games: " + added.joinToString { "${it.name} (${it.exe.name})" }) + } // Where the guest leaves a request for another session (the desktop's Steam launchers). guest.add("BL_LAUNCH_DIR=" + sessionRoot.path) // The second library's name, for bannerlator-steam-library; the bind itself is made below. diff --git a/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt index b70aa92c4..eabe1c7e1 100644 --- a/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt @@ -37,8 +37,14 @@ class ModeSettings( val fexPreset: String? = null, /** Steam only: the client branch forced on the command line. */ val steamChannel: String? = null, + /** Steam only: the user's own games folder and what was found in it. */ + val addedGamesDir: String? = null, + val addedGames: List = emptyList(), ) +/** One added game as the settings page shows it: its folder, the chosen .exe, the other .exe files it could be. */ +class AddedGameRow(val folderName: String, val exePath: String, val exeName: String, val candidates: List>) + class ModeSettingsActions( val onResolution: (Int) -> Unit, val onShape: (String) -> Unit, @@ -58,6 +64,10 @@ class ModeSettingsActions( val onPickGameStorageFolder: () -> Unit = {}, val onFexPreset: (String) -> Unit = {}, val onSteamChannel: (String) -> Unit = {}, + val onPickAddedGamesDir: () -> Unit = {}, + val onClearAddedGamesDir: () -> Unit = {}, + val onAddedGameExe: (folderName: String, path: String) -> Unit = { _, _ -> }, + val onPickAddedGameExe: (folderName: String) -> Unit = {}, val onDismiss: () -> Unit, ) @@ -135,6 +145,22 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { onPick = a.onSteamChannel, ) } + if (steam && s.addedGamesDir != null) SettingsGroup("Added games") { + ActionRow( + "Games folder", + if (s.addedGamesDir.isEmpty()) "Your own Windows games, one subfolder each, anywhere on internal storage, in the ROMs folder or on the SD-card library. Each goes into the client's library as a non-Steam game under the ARM64 Proton, at the next session start." + else s.addedGamesDir + " · " + (if (s.addedGames.isEmpty()) "no game folders with a .exe found" else "${s.addedGames.size} game${if (s.addedGames.size == 1) "" else "s"}"), + if (s.addedGamesDir.isEmpty()) "Choose…" else "Change…", + onClick = a.onPickAddedGamesDir, + ) + if (s.addedGamesDir.isNotEmpty()) ActionRow("Forget the folder", "The games leave the client's library at the next session start; nothing on disk is touched.", "Forget", onClick = a.onClearAddedGamesDir) + for (g in s.addedGames) ChoiceRow( + host, "added:" + g.folderName, g.folderName, "Launches ${g.exeName}", + g.candidates + ("__pick__" to "Choose another file…"), g.exePath, + note = "The .exe files found in the game's folder; the one named after the folder, else the largest, is picked unless you choose.", + onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderName) else a.onAddedGameExe(g.folderName, path) }, + ) + } if (steam && s.fexPreset != null) SettingsGroup("Games") { ChoiceRow( host, "fex", "FEX preset", "How FEX translates the x86 games the client launches. Applies to the next game launch. Also in the session's drawer.", diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index 4a98797ae..61702de28 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -216,6 +216,12 @@ case $mode in bannerlator-steam-library "$steam_root" || true step "setting up the ARM64 Proton compatibility tool" bannerlator-steam-compat "$steam_root" || true + # The user's own games, as non-Steam shortcuts under that tool; the list is written by the app + # each session (empty = the app's earlier entries are removed, the client's own are kept). + if [ -n "${BL_ADDED_GAMES:-}" ]; then + step "adding the app's own games to the client's library" + bannerlator-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true + fi # Steam runs a game's shared redistributables inside its prefix on first launch; marking # them as already run keeps those x86 installers from starting under emulation at all. bannerlator-seed-redists "$steam_root" || true diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shortcuts b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shortcuts new file mode 100755 index 000000000..98debbcf7 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shortcuts @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Puts the app's added games (Windows games in the user's own folders) into the Steam client's +library as non-Steam shortcuts, each mapped to the ARM64 Proton, before the client starts. + + bannerlator-steam-shortcuts + +The JSON is a list the app writes each session: {"name", "exe", "dir", "appid"} with guest paths. +The client keeps non-Steam games in userdata//config/shortcuts.vdf, a binary VDF it +reads at start-up and rewrites at exit, so this runs while it is down. Entries the app wrote +carry the tag "steamdeck-app" and are replaced wholesale; anything the user added inside the +client is kept as it is. The appid is the one the client itself derives (crc32 of exe+name with +the top bit set), so the launch URL the app uses matches what the client shows. +""" +import importlib.util +import json +import os +import re +import struct +import sys + +TAG = "steamdeck-app" + + +def account_dir(steam_root): + """The most recent account's userdata folder, else the only one that is not 0.""" + users = os.path.join(steam_root, "config", "loginusers.vdf") + recent = None + try: + with open(users, encoding="utf-8", errors="replace") as f: + text = f.read() + for block in re.finditer(r'"(\d{5,})"\s*\{([^}]*)\}', text): + if re.search(r'"MostRecent"\s*"1"', block.group(2)): + recent = int(block.group(1)) + except OSError: + pass + userdata = os.path.join(steam_root, "userdata") + if recent is not None: + # loginusers holds the 64-bit id; userdata folders use the 32-bit account id. + acct = recent - 76561197960265728 + return os.path.join(userdata, str(acct)) + try: + dirs = [d for d in os.listdir(userdata) if d.isdigit() and d != "0"] + except OSError: + dirs = [] + return os.path.join(userdata, dirs[0]) if len(dirs) == 1 else None + + +# ---- binary VDF: 0x00 map, 0x01 string, 0x02 int32, 0x08 end ---- + +def parse(data): + pos = [0] + + def cstr(): + end = data.index(b"\x00", pos[0]) + s = data[pos[0]:end].decode("utf-8", "replace") + pos[0] = end + 1 + return s + + def node(): + out = {} + while True: + t = data[pos[0]] + pos[0] += 1 + if t == 0x08: + return out + key = cstr() + if t == 0x00: + out[key] = node() + elif t == 0x01: + out[key] = cstr() + elif t == 0x02: + out[key] = struct.unpack_from("