From a7b8810d37e74356856f37b83b4d561e603ef8fa Mon Sep 17 00:00:00 2001 From: epicmeme546 <178368684+epicmeme546@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:49:06 -0500 Subject: [PATCH] Add the game to Steam from Customize Launcher, with Navi as its icon and the title screen's moonlit field and logo as its pictures, made from the player's own game. The new row, Add to Steam (Windows), puts a non-Steam shortcut named "The Legend of Zelda Ocarina of Time" into the shortcuts.vdf of every Steam account that signed in on the PC (launcher/steam.py): the binary key-value file is read and written back as it was, with ours added or updated in place, and the file from before the first change kept beside it. Steam reads that file only at its start and writes it back when it closes, so a running Steam is asked to close (-shutdown) and started again after. The pictures come from the player's game when the row is selected (launcher/gameart.py), as the launcher's fonts and sounds do: Navi from the HUD's icon and its glow, the logo with the Master Sword and the Hylian Shield from the title screen's texture archive (decoded with runtime/tex_astc.c, built into a library with the toolchain's clang), and a frame 12 seconds into the first title movie, the field under the full moon, decoded from its AV1 with the toolchain's dav1d.exe, as the background, the cover and the wide tile. They go into config/grid named by the shortcut's id, CRC-32 of Exe and AppName with the top bit set, which matches the id Steam gave an existing shortcut on the test PC. Steam starts the launcher's pythonw.exe on data/steam_play.pyw, which runs the launcher of app/current.txt with the new --play: the game starts from its folder with its console hidden, as Play starts it, and the process waits for it and for the copies its own Restart starts, so Steam counts the game as running while it is. ocarina.exe is a console program, so starting it directly would open a console window beside the game, and NX2navi.exe ends six seconds after it started the launcher. Started that way on an RX 7900 XT PC, the game ran, the Python process stayed until the game was closed and ended with code 0. Tested against copies of that PC's Steam files; the real Steam library is not changed until the player selects the row. Co-Authored-By: Claude Opus 5.5 --- docs/launcher.md | 14 +- docs/releases/v0.7.2.md | 7 + launcher/gameart.py | 358 ++++++++++++++++++++++++++++++++++++++++ launcher/steam.py | 329 ++++++++++++++++++++++++++++++++++++ launcher/ui/app.py | 100 ++++++++++- tests/launcher_test.py | 55 ++++++ 6 files changed, 860 insertions(+), 3 deletions(-) create mode 100644 launcher/gameart.py create mode 100644 launcher/steam.py diff --git a/docs/launcher.md b/docs/launcher.md index 5cc12655..05470cc5 100644 --- a/docs/launcher.md +++ b/docs/launcher.md @@ -55,6 +55,18 @@ Customize Launcher (on the home screen) makes the look the player's own: it is silent until the player's game has been read. Moving the cursor makes no sound. They are played from memory (PlaySound on Windows; `pw-play`, `paplay` or `aplay` elsewhere). +- Add to Steam (Windows): the game in the player's Steam library as *The Legend of Zelda Ocarina of Time*, started + from there without the launcher's window (`launcher/steam.py`). Its pictures are made from the player's game when + the row is selected (`launcher/gameart.py`): Navi as the game's HUD draws her for the icon, the title screen's logo, + and a frame of the first title movie (the moonlit field) as the background, the cover and the wide tile, the movie + decoded with the toolchain's `dav1d.exe` and the logo with the runtime's own ASTC decoder built with its clang. They + go into each signed-in account's `config/grid`, the shortcut into its `shortcuts.vdf` (other shortcuts kept as they + are, ours updated when it is there; the file before the first change kept as `shortcuts.vdf.nx2navi-backup`). + Steam reads that file only when it starts and writes it back when it closes, so a running Steam is asked to close + first and started again after. The shortcut runs the launcher's `pythonw.exe` on `data/steam_play.pyw`, which + starts the launcher of `app/current.txt` with `--play`: the game starts as Play starts it, and the process waits + for it, so Steam counts the game as running while it is. + The choices are `theme`, `background`, `background_dim`, `animations` and `sounds` in `data/launcher.json`. Navi (`art.NAVI`, 22 x 21 pixels, drawn in whole pixels at every size) is also the icon of `NX2navi.exe`, @@ -66,7 +78,7 @@ Screens (`launcher/ui/app.py`): first run (dump, install folder, Desktop Shortcu Update Channel, Begin Installation); the steps of an install, update or rebuild with their progress, the build log's last lines and Cancel, and after a failure Try Again and Copy Report; home (Play, Updates, Game Folder, Saves, Rebuild the Game, Customize Launcher, Settings); customize (Color Scheme, Background, with a picture set Background -Dimming and Remove Background, Animations, Sounds, Back); saves (Export Save, Add Log and Settings, Export To, Import a Save, Import a Save Folder, +Dimming and Remove Background, Animations, Sounds, Add to Steam on Windows, Back); saves (Export Save, Add Log and Settings, Export To, Import a Save, Import a Save Folder, Open Saves Folder, Back); settings (update check, Install Updates Automatically, Hide Launcher While Playing, Keep Build Cache, Update Channel, the folders, Open Logs Folder, Uninstall, Back); uninstall (keep or remove the built game). A screen with more rows than the window holds draws them closer together (`fit_rows`). diff --git a/docs/releases/v0.7.2.md b/docs/releases/v0.7.2.md index 6aecd747..23311b26 100644 --- a/docs/releases/v0.7.2.md +++ b/docs/releases/v0.7.2.md @@ -16,6 +16,13 @@ Draft notes for the release after 0.7.1. The version number is not decided yet. (`gate_wait_ms`): 54.5 fps instead of 53 in the same place and the longest frame 33 ms instead of 46; with `vk_recorder = 1`, which AMD cards do not have on by default, 58.6 fps. +## New + +- **Add to Steam** in Customize Launcher (Windows): the game joins your Steam library as *The Legend of Zelda Ocarina + of Time*, with Navi as its icon, the title screen's moonlit field as its background and cover, and the game's logo, + all made from your own game files when you select the row. Playing it from Steam starts the game straight away, + without the launcher. Steam closes for a moment while the shortcut is written and starts again with it. + ## Fixed - Black and white blocks on AMD RX 5000 and RX 6000 cards: on Navi and on Link next to her, on bright floors near her, diff --git a/launcher/gameart.py b/launcher/gameart.py new file mode 100644 index 00000000..bb3c1623 --- /dev/null +++ b/launcher/gameart.py @@ -0,0 +1,358 @@ +"""The pictures of the game's Steam shortcut (launcher/steam.py), made from the player's own game: nothing ships. + + icon Navi as the game's HUD draws her (HUDNaviIcon_Body_00 over the glow HUDNaviIcon_Flare_00), 128 x 128 + logo the title screen's logo with the Master Sword and the Hylian Shield (Logo_USEU_00), cut to its outline + hero the title screen's moonlit field, a frame of its first movie (Event/Movie/Dm_Title_0005.webm), 1920 x 620 + portrait that frame with the logo over it, 600 x 900 (the library's cover) + wide the same at 920 x 430 (the library's wide tile) + +The textures come from the interface archive UI/Layout2Archive/Title...bntx.zs as runtime/menu_assets.c reads them: +a BNTX's BRTI records give format, size, block height and data; the level is block linear in the Switch 2 GOB layout +(runtime/tex_deswizzle.c tex_bl_copy); BC4 masks become white with the mask as alpha; ASTC 4x4 goes through the +runtime's own decoder, runtime/tex_astc.c, built here into a library with the toolchain's clang (the launcher builds +the game with it, so it is on every PC that has a game to add). The movie is WebM with AV1 video: the frames from the +key frame before TITLE_AT up to it go into an IVF file for the toolchain's dav1d.exe, whose last picture (8-bit 4:2:0, +limited range BT.709) becomes the background. Every picture is written as PNG with art.png_bytes; nothing here needs +an imaging package. +""" +import ctypes +import hashlib +import os +import struct +import subprocess +import tempfile +from pathlib import Path + +import numpy as np + +from . import gamefiles + +TITLE_ARCHIVE = 'UI/Layout2Archive/Title.Product.100.Nin_Ounce_NVN2.bntx.zs' +COMMON_ARCHIVE = 'UI/Layout2Archive/Common.Product.100.Nin_Ounce_NVN2.bntx.zs' +LOGO, NAVI, NAVI_GLOW = 'Logo_USEU_00', 'HUDNaviIcon_Body_00', 'HUDNaviIcon_Flare_00' +TITLE_MOVIE = 'Event/Movie/Dm_Title_0005.webm' +TITLE_AT = 12.0 # seconds into it: the field under the full moon, as the title screen shows it first +KIND_BC4, KIND_ASTC4 = 0x1d, 0x2d +NO_WINDOW = 0x08000000 if os.name == 'nt' else 0 + + +class ArtError(Exception): + pass + + +# ---------------------------------------------------------------- BNTX + +def _u16(b, at): return struct.unpack_from(' b + for i in range(6): + pal[..., 2 + i] = np.where(big, ((6 - i) * a + (i + 1) * b) // 7, 0) + for i in range(4): + pal[..., 2 + i] = np.where(big, pal[..., 2 + i], ((4 - i) * a + (i + 1) * b) // 5) + pal[..., 6] = np.where(big, pal[..., 6], 0) + pal[..., 7] = np.where(big, pal[..., 7], 255) + bits = np.zeros((hb, wb), dtype=np.uint64) + for i in range(6): + bits |= blocks[..., 2 + i].astype(np.uint64) << np.uint64(8 * i) + alpha = np.zeros((hb * 4, wb * 4), dtype=np.uint8) + for i in range(16): + sel = ((bits >> np.uint64(3 * i)) & np.uint64(7)).astype(np.intp) + alpha[i // 4::4, i % 4::4] = np.take_along_axis(pal, sel[..., None], 2)[..., 0] + rgba = np.full((h, w, 4), 255, dtype=np.uint8) + rgba[..., 3] = alpha[:h, :w] + return rgba + + +def astc_library(layout): + """runtime/tex_astc.c of this release as a library in data/cache, built once per source with the toolchain.""" + src = layout.app / 'runtime' / 'tex_astc.c' + if not src.is_file(): + raise ArtError(f'{src} is missing') + tag = hashlib.sha256(src.read_bytes() + (layout.app / 'runtime' / 'tex.h').read_bytes()).hexdigest()[:12] + dll = layout.cache / f'astc-{tag}.dll' + if not dll.is_file(): + clang = layout.msys / 'bin' / 'clang.exe' + if not clang.is_file(): + raise ArtError('the build tools are not installed (Rebuild the Game installs them)') + env = dict(os.environ, PATH=str(layout.msys / 'bin') + os.pathsep + os.environ.get('PATH', '')) + tmp = dll.with_name(dll.name + '.tmp') + r = subprocess.run([str(clang), '-shared', '-O2', '-static', '-w', '-I', str(layout.app / 'runtime'), + '-I', str(layout.msys / 'include' / 'vma'), str(src), '-o', str(tmp)], + capture_output=True, text=True, env=env, creationflags=NO_WINDOW) + if r.returncode or not tmp.is_file(): + raise ArtError(f'the texture decoder did not build: {(r.stderr or r.stdout).strip()[-300:]}') + os.replace(tmp, dll) + lib = ctypes.CDLL(str(dll)) + lib.astc_decode_ldr.restype = None + lib.astc_decode_ldr.argtypes = [ctypes.c_char_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32, + ctypes.c_uint32, ctypes.c_bool, ctypes.c_void_p] + return lib + + +def texture(bntx, name, lib): + """The named texture as an HxWx4 uint8 array (BC4 or ASTC 4x4), or None when the file has no such texture.""" + for t in bntx_textures(bntx): + if t['name'] != name: + continue + kind = t['fmt'] >> 8 + w, h = t['w'], t['h'] + wb, hb = (w + 3) // 4, (h + 3) // 4 + if kind == KIND_BC4: + return bc4(deswizzle(t, bntx, wb * 8, hb), wb, hb, w, h) + if kind == KIND_ASTC4: + lin = deswizzle(t, bntx, wb * 16, hb) + rgba = np.zeros((h, w, 4), dtype=np.uint8) + lib.astc_decode_ldr(lin.tobytes(), w, h, 4, 4, (t['fmt'] & 0xff) == 6, rgba.ctypes.data) + return rgba + raise ArtError(f'{name} has a texture format this launcher does not read ({t["fmt"]:#x})') + return None + + +# ---------------------------------------------------------------- the title movie + +_MASTERS = {0x18538067, 0x1F43B675, 0xA0, 0x1654AE6B, 0xAE, 0xE0} # Segment, Cluster, BlockGroup, Tracks, TrackEntry, Video + + +def _vint(b, at, keep_marker): + first = b[at] + n = 1 + while n <= 8 and not first & (0x80 >> (n - 1)): + n += 1 + if n > 8: + raise ArtError('the movie is not WebM') + v = first if keep_marker else first & (0xff >> n) + for i in range(1, n): + v = v << 8 | b[at + i] + return v, n + + +def webm_frames(b): + """(time in ms, key frame, data) of every block of a single-track WebM, and the video's width and height.""" + out, info = [], {} + + def walk(at, end, cluster): + while at < end: + eid, n1 = _vint(b, at, True) + size, n2 = _vint(b, at + n1, False) + body = at + n1 + n2 + if size == (1 << (7 * n2)) - 1: # unknown size: to the end of the parent + size = end - body + if eid in _MASTERS: + walk(body, min(body + size, end), cluster) + elif eid == 0xE7: + cluster[0] = int.from_bytes(b[body:body + size], 'big') + elif eid == 0xB0: + info['w'] = int.from_bytes(b[body:body + size], 'big') + elif eid == 0xBA: + info['h'] = int.from_bytes(b[body:body + size], 'big') + elif eid in (0xA3, 0xA1): # SimpleBlock (its flags say key frame), Block + _track, n = _vint(b, body, False) + rel = struct.unpack_from('>h', b, body + n)[0] + key = eid == 0xA3 and bool(b[body + n + 2] & 0x80) + out.append((cluster[0] + rel, key, b[body + n + 3:body + size])) + at = body + size + + walk(0, len(b), [0]) + if not out or 'w' not in info or 'h' not in info: + raise ArtError('the title movie has no video frames') + return out, info['w'], info['h'] + + +def movie_frame(layout, data, at_s, work): + """The picture of the movie `data` at at_s seconds as an HxWx3 uint8 array, decoded by the toolchain's dav1d.""" + dav1d = layout.msys / 'bin' / 'dav1d.exe' + if not dav1d.is_file(): + raise ArtError('the build tools are not installed (Rebuild the Game installs them)') + frames, w, h = webm_frames(data) + upto = [f for f in frames if f[0] <= at_s * 1000] or frames[:1] + k0 = max((i for i, f in enumerate(upto) if f[1]), default=0) + sel = upto[k0:] + ivf, y4m = Path(work) / 'title.ivf', Path(work) / 'title.y4m' + with open(ivf, 'wb') as f: + f.write(b'DKIF' + struct.pack('= 2 or fx >= 2: + fy, fx = max(fy, 1), max(fx, 1) + H, W = a.shape[0] // fy * fy, a.shape[1] // fx * fx + a = a[:H, :W].reshape(H // fy, fy, W // fx, fx, -1).mean(axis=(1, 3)) + ys = (np.arange(h) + 0.5) * a.shape[0] / h - 0.5 + xs = (np.arange(w) + 0.5) * a.shape[1] / w - 0.5 + y0 = np.clip(np.floor(ys).astype(int), 0, a.shape[0] - 1) + x0 = np.clip(np.floor(xs).astype(int), 0, a.shape[1] - 1) + y1, x1 = np.minimum(y0 + 1, a.shape[0] - 1), np.minimum(x0 + 1, a.shape[1] - 1) + wy = np.clip(ys - y0, 0, 1)[:, None, None] + wx = np.clip(xs - x0, 0, 1)[None, :, None] + top = a[y0][:, x0] * (1 - wx) + a[y0][:, x1] * wx + bottom = a[y1][:, x0] * (1 - wx) + a[y1][:, x1] * wx + return top * (1 - wy) + bottom * wy + + +def premultiplied_resize(rgba, w, h): + """Resize with the colours weighted by alpha, so transparent texels add no dark fringe; uint8 straight alpha out.""" + a = rgba.astype(np.float32) + a[..., :3] *= a[..., 3:4] / 255 + r = resize(a, w, h) + r[..., :3] /= np.maximum(r[..., 3:4], 1e-3) / 255 + return np.clip(r + 0.5, 0, 255).astype(np.uint8) + + +def trim(rgba, pad=8): + """rgba cut to the box of its visible texels, pad texels around.""" + ys, xs = np.nonzero(rgba[..., 3] > 4) + if not len(ys): + return rgba + y0, y1 = max(ys.min() - pad, 0), min(ys.max() + 1 + pad, rgba.shape[0]) + x0, x1 = max(xs.min() - pad, 0), min(xs.max() + 1 + pad, rgba.shape[1]) + return rgba[y0:y1, x0:x1] + + +def place(dst, src, x, y): + """src (uint8 RGBA, straight alpha) over dst (uint8 RGB) at x, y.""" + h, w = src.shape[:2] + s = src.astype(np.float32) + d = dst[y:y + h, x:x + w].astype(np.float32) + al = s[..., 3:4] / 255 + dst[y:y + h, x:x + w] = np.clip(d * (1 - al) + s[..., :3] * al + 0.5, 0, 255).astype(np.uint8) + + +def crop(frame, w, h, cx, cy): + """The largest w:h part of frame centred near (cx, cy) as fractions, scaled to w x h (uint8 RGB).""" + H, W = frame.shape[:2] + if W * h > H * w: + ch, cw = H, H * w // h + else: + cw, ch = W, W * h // w + x0 = int(np.clip(cx * W - cw / 2, 0, W - cw)) + y0 = int(np.clip(cy * H - ch / 2, 0, H - ch)) + return np.clip(resize(frame[y0:y0 + ch, x0:x0 + cw], w, h) + 0.5, 0, 255).astype(np.uint8) + + +def with_logo(bg, logo, width_share, cy): + """bg (RGB) with the logo scaled to width_share of its width, centred at height cy (a fraction).""" + H, W = bg.shape[:2] + lw = int(W * width_share) + lh = int(lw * logo.shape[0] / logo.shape[1]) + if lh > H * 0.8: + lh = int(H * 0.8) + lw = int(lh * logo.shape[1] / logo.shape[0]) + small = premultiplied_resize(logo, lw, lh) + out = bg.copy() + place(out, small, (W - lw) // 2, int(np.clip(cy * H - lh / 2, 0, H - lh))) + return out + + +def navi_icon(body, glow, size=128): + """Navi's HUD icon on her glow, size x size RGBA: the glow tinted the pale blue of her light.""" + out = np.zeros((size, size, 4), dtype=np.float32) + g = resize(glow, size, size) + g[..., :3] = np.array([200, 228, 255], dtype=np.float32) + g[..., 3] *= 0.85 + b = resize(body, int(size * 0.8), int(size * 0.8)) + for layer, at in ((g, 0), (b, (size - b.shape[0]) // 2)): + h, w = layer.shape[:2] + d = out[at:at + h, at:at + w] + sa, da = layer[..., 3:4] / 255, d[..., 3:4] / 255 + oa = sa + da * (1 - sa) + d[..., :3] = (layer[..., :3] * sa + d[..., :3] * da * (1 - sa)) / np.maximum(oa, 1e-6) + d[..., 3:4] = oa * 255 + return np.clip(out + 0.5, 0, 255).astype(np.uint8) + + +def rgba_of(rgb): + return np.concatenate([rgb, np.full(rgb.shape[:2] + (1,), 255, dtype=np.uint8)], -1) + + +def make(layout, game_dir='', dump=''): + """{name: HxWx4 uint8} for icon, logo, hero, portrait and wide, from the player's game. ArtError when the game's + files or the tools to read them are not there.""" + romfs = gamefiles.open_romfs(layout, game_dir, dump) + if romfs is None: + raise ArtError('the game\'s files were not found (the game folder\'s romfs, or the dump)') + zs = gamefiles.ZsReader(romfs) + title, common = zs.read(TITLE_ARCHIVE), zs.read(COMMON_ARCHIVE) + movie = romfs.read(TITLE_MOVIE) + if not title or not common or not movie: + raise ArtError('the game\'s title screen files were not found') + lib = astc_library(layout) + logo = texture(title, LOGO, lib) + body, glow = texture(common, NAVI, lib), texture(common, NAVI_GLOW, lib) + if logo is None or body is None or glow is None: + raise ArtError('the game\'s logo or Navi\'s icon was not found') + logo = trim(logo) + with tempfile.TemporaryDirectory(dir=str(layout.cache)) as work: + frame = movie_frame(layout, movie, TITLE_AT, work) + # the moon sits right of the middle and high: every cut keeps it, and the hero keeps the horizon too + hero = crop(frame, 1920, 620, 0.5, 0.42) + portrait = with_logo(crop(frame, 600, 900, 0.68, 0.5), logo, 0.88, 0.70) + wide = with_logo(crop(frame, 920, 430, 0.5, 0.45), logo, 0.52, 0.55) + return {'icon': navi_icon(body, glow), 'logo': logo, 'hero': rgba_of(hero), 'portrait': rgba_of(portrait), + 'wide': rgba_of(wide)} diff --git a/launcher/steam.py b/launcher/steam.py new file mode 100644 index 00000000..02397d35 --- /dev/null +++ b/launcher/steam.py @@ -0,0 +1,329 @@ +"""Add to Steam (Customize Launcher): the game as a non-Steam shortcut in the player's Steam library, with Navi as its +icon and the game's title screen and logo as its pictures (launcher/gameart.py). Windows only. + +Steam keeps the shortcuts of each account in /userdata//config/shortcuts.vdf, a binary key-value +file: a map is 0x00 name 0x00 ... 0x08, a string 0x01 name 0x00 value 0x00, a 32-bit integer 0x02 name 0x00 value +(0x07 is a 64-bit one). The file is the map "shortcuts" holding one map per shortcut, named "0", "1", ... Each holds +appid, AppName, Exe, StartDir, icon, LaunchOptions and Steam's own fields; the other shortcuts are read and written +back as they are, and a shortcut of ours already there (by its name or its id) is updated in place. Its pictures go +into config/grid named by the shortcut's id: p.png the cover, .png the wide tile, _hero.png the +background, _logo.png the logo, and the icon file the shortcut names. The id is CRC-32 of Exe and AppName as +Steam writes them, with the top bit set. + +Steam reads the file when it starts and writes it back when it closes, so a running Steam would neither see the new +shortcut nor keep it: Steam is asked to close (steam.exe -shutdown), the files are written, and Steam starts again. + +The shortcut starts the launcher's pythonw.exe on data/steam_play.pyw, which starts this launcher with --play: the game +runs from its own folder with its console hidden, and the Python process waits for it, so Steam shows the game as +running for as long as it is (the game's own Restart included). Starting ocarina.exe directly would open a console +window beside the game, and NX2navi.exe ends a few seconds after it started the launcher. +""" +import os +import shutil +import struct +import subprocess +import time +import zlib +from pathlib import Path + +NAME = 'The Legend of Zelda Ocarina of Time' +PLAY_SCRIPT = 'steam_play.pyw' +NO_WINDOW = 0x08000000 if os.name == 'nt' else 0 +STEAM64_BASE = 76561197960265728 + +PLAY_TEXT = '''"""Written by the NX2navi launcher (Customize Launcher > Add to Steam): Steam starts the game through this file. +It runs the launcher of app/current.txt with --play, which starts the game and waits for it.""" +import os +import runpy +import sys + +root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +with open(os.path.join(root, 'app', 'current.txt'), encoding='utf-8') as f: + version = f.read().strip() +os.environ['NX2NAVI_LAUNCHER_ROOT'] = root +sys.argv = [os.path.join(root, 'app', version, 'launcher', 'start.pyw'), '--play'] + sys.argv[1:] +runpy.run_path(sys.argv[0], run_name='__main__') +''' + + +class SteamError(Exception): + pass + + +# ---------------------------------------------------------------- binary VDF + +T_MAP, T_STR, T_INT, T_FLOAT, T_PTR, T_WSTR, T_COLOR, T_U64, T_END = 0, 1, 2, 3, 4, 5, 6, 7, 8 + + +def vdf_load(b): + """A binary VDF as a list of [type, key, value]; a map's value is such a list. Raises SteamError when malformed.""" + def cstr(at): + end = b.index(b'\0', at) + return b[at:end].decode('utf-8', 'surrogateescape'), end + 1 + + def walk(at, top): + items = [] + while True: + if at >= len(b): + if top: + return items, at + raise SteamError('shortcuts.vdf ends inside a map') + t = b[at] + at += 1 + if t == T_END: + return items, at + key, at = cstr(at) + if t == T_MAP: + v, at = walk(at, False) + elif t == T_STR: + v, at = cstr(at) + elif t in (T_INT, T_FLOAT, T_PTR, T_COLOR): + v, at = b[at:at + 4], at + 4 + elif t == T_U64: + v, at = b[at:at + 8], at + 8 + else: + raise SteamError(f'shortcuts.vdf holds a value of type {t}') + items.append([t, key, v]) + + try: + items, at = walk(0, True) + except ValueError: + raise SteamError('shortcuts.vdf is cut short') from None + return items + + +def vdf_dump(items, top=True): + out = bytearray() + for t, key, v in items: + out += bytes([t]) + key.encode('utf-8', 'surrogateescape') + b'\0' + if t == T_MAP: + out += vdf_dump(v, False) + elif t == T_STR: + out += v.encode('utf-8', 'surrogateescape') + b'\0' + else: + out += v + if not top or items: + out += bytes([T_END]) + return bytes(out) + + +def _get(items, key): + for it in items: + if it[1].lower() == key.lower(): + return it + return None + + +def _set(items, t, key, v): + it = _get(items, key) + if it: + it[0], it[2] = t, v + else: + items.append([t, key, v]) + + +def appid(exe, name): + """The id Steam gives a shortcut: CRC-32 of its Exe and AppName fields with the top bit set (unsigned).""" + return (zlib.crc32((exe + name).encode('utf-8')) & 0xFFFFFFFF) | 0x80000000 + + +def add_shortcut(vdf_bytes, exe, start_dir, options, icon, name=NAME): + """shortcuts.vdf (b'' for none) with our shortcut added or updated; returns (new bytes, id).""" + items = vdf_load(vdf_bytes) if vdf_bytes else [] + root = _get(items, 'shortcuts') + if root is None or root[0] != T_MAP: + items = [[T_MAP, 'shortcuts', []]] + root = items[0] + sid = appid(exe, name) + entry = None + for it in root[2]: + if it[0] != T_MAP: + continue + n, a = _get(it[2], 'AppName'), _get(it[2], 'appid') + if (n and n[0] == T_STR and n[2] == name) or (a and a[0] == T_INT and struct.unpack('